diff --git a/.github/actions/setup-gitnexus-web/action.yml b/.github/actions/setup-gitnexus-web/action.yml
index 86331e36d..8f895423a 100644
--- a/.github/actions/setup-gitnexus-web/action.yml
+++ b/.github/actions/setup-gitnexus-web/action.yml
@@ -1,5 +1,5 @@
name: Setup GitNexus Web
-description: Setup Node.js 20.19+ (vite 7 floor), build gitnexus-shared, install web dependencies
+description: Setup Node.js 22, build gitnexus-shared, install web dependencies
runs:
using: composite
@@ -7,9 +7,7 @@ runs:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
# Vite 7 requires Node ^20.19.0 || >=22.12.0 (require(esm) support).
- # Pin explicitly so we don't depend on the floating "20" alias resolving
- # to a high enough patch version on every runner image.
- node-version: '20.19.0'
+ node-version: 22
cache: npm
cache-dependency-path: gitnexus-web/package-lock.json
diff --git a/.github/actions/setup-gitnexus/action.yml b/.github/actions/setup-gitnexus/action.yml
index e946f1040..b9b4acb7e 100644
--- a/.github/actions/setup-gitnexus/action.yml
+++ b/.github/actions/setup-gitnexus/action.yml
@@ -1,5 +1,5 @@
name: Setup GitNexus
-description: Setup Node.js 20, install dependencies, and optionally build
+description: Setup Node.js 22, install dependencies, and optionally build
inputs:
build:
@@ -12,7 +12,7 @@ runs:
steps:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
- node-version: 20
+ node-version: 22
cache: npm
cache-dependency-path: gitnexus/package-lock.json
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index f3530e4d3..c99b666eb 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -7,6 +7,8 @@ updates:
directory: /
schedule:
interval: weekly
+ cooldown:
+ default-days: 7
open-pull-requests-limit: 5
commit-message:
prefix: chore
@@ -15,6 +17,36 @@ updates:
- dependencies
- ci
+ # Keep pinned Docker base-image digests current for the root Dockerfiles.
+ - package-ecosystem: docker
+ directory: /
+ schedule:
+ interval: weekly
+ cooldown:
+ default-days: 7
+ open-pull-requests-limit: 5
+ commit-message:
+ prefix: chore(deps)
+ include: scope
+ labels:
+ - dependencies
+ - ci
+
+ # Keep the nested test-image Docker base digest current as well.
+ - package-ecosystem: docker
+ directory: /gitnexus
+ schedule:
+ interval: weekly
+ cooldown:
+ default-days: 7
+ open-pull-requests-limit: 5
+ commit-message:
+ prefix: chore(deps)
+ include: scope
+ labels:
+ - dependencies
+ - ci
+
# Gitnexus npm deps — tree-sitter grammars checked daily so we catch
# new releases that unblock the tree-sitter 0.25 upgrade ASAP. Grammars
# are grouped so lockstep bumps produce a single PR. The tree-sitter
@@ -25,6 +57,11 @@ updates:
directory: /gitnexus
schedule:
interval: daily
+ cooldown:
+ default-days: 7
+ semver-major-days: 30
+ semver-minor-days: 7
+ semver-patch-days: 3
open-pull-requests-limit: 10
commit-message:
prefix: chore(deps)
@@ -54,6 +91,11 @@ updates:
directory: /gitnexus-web
schedule:
interval: weekly
+ cooldown:
+ default-days: 7
+ semver-major-days: 30
+ semver-minor-days: 7
+ semver-patch-days: 3
open-pull-requests-limit: 5
commit-message:
prefix: chore(deps)
@@ -67,6 +109,11 @@ updates:
directory: /gitnexus-shared
schedule:
interval: weekly
+ cooldown:
+ default-days: 7
+ semver-major-days: 30
+ semver-minor-days: 7
+ semver-patch-days: 3
open-pull-requests-limit: 5
commit-message:
prefix: chore(deps)
diff --git a/.github/workflows/ci-e2e.yml b/.github/workflows/ci-e2e.yml
index af0e2758d..96b0a4b26 100644
--- a/.github/workflows/ci-e2e.yml
+++ b/.github/workflows/ci-e2e.yml
@@ -3,6 +3,9 @@ name: E2E Tests
on:
workflow_call:
+permissions:
+ contents: read
+
jobs:
check-changes:
name: Check web module changes
diff --git a/.github/workflows/ci-quality.yml b/.github/workflows/ci-quality.yml
index 36a936c82..a81876d9d 100644
--- a/.github/workflows/ci-quality.yml
+++ b/.github/workflows/ci-quality.yml
@@ -3,6 +3,9 @@ name: Quality Checks
on:
workflow_call:
+permissions:
+ contents: read
+
jobs:
format:
runs-on: ubuntu-latest
@@ -11,7 +14,7 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
- node-version: 20
+ node-version: 22
cache: npm
cache-dependency-path: package-lock.json
- run: npm ci
@@ -24,7 +27,7 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
- node-version: 20
+ node-version: 22
cache: npm
cache-dependency-path: package-lock.json
- run: npm ci
diff --git a/.github/workflows/ci-scope-parity.yml b/.github/workflows/ci-scope-parity.yml
index cffdeb341..8e2926ba8 100644
--- a/.github/workflows/ci-scope-parity.yml
+++ b/.github/workflows/ci-scope-parity.yml
@@ -28,6 +28,9 @@ name: Scope Resolution Parity
on:
workflow_call:
+permissions:
+ contents: read
+
jobs:
discover:
name: Discover migrated languages
diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml
index 7010ee6f3..b044d9318 100644
--- a/.github/workflows/ci-tests.yml
+++ b/.github/workflows/ci-tests.yml
@@ -3,6 +3,9 @@ name: Tests
on:
workflow_call:
+permissions:
+ contents: read
+
jobs:
tests:
name: ubuntu / coverage
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 3059a34dd..dd07337b7 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -6,6 +6,9 @@ on:
paths-ignore: ['**.md', 'docs/**', 'LICENSE']
workflow_call:
+permissions:
+ contents: read
+
# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention".
# Hardcoded `CI-` prefix (not `${{ github.workflow }}`) because this workflow is
# invoked as a reusable workflow from publish.yml and release-candidate.yml. In
diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml
index cfba3ecbc..021a56930 100644
--- a/.github/workflows/claude.yml
+++ b/.github/workflows/claude.yml
@@ -19,6 +19,9 @@ on:
pull_request_review:
types: [submitted]
+permissions:
+ contents: read
+
# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention".
# Serialize per-PR/issue to avoid racing comments.
concurrency:
@@ -155,6 +158,8 @@ jobs:
github_token: ${{ secrets.GITHUB_TOKEN }}
allowed_non_write_users: '*'
show_full_output: true
+ # Review posts use Bash (`gh`, etc.); default mode asks for approval — impossible in CI.
+ claude_args: '--dangerously-skip-permissions'
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
plugins: 'code-review@claude-code-plugins'
- prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ steps.pr.outputs.number }}'
+ prompt: '/code-review:code-review https://github.com/${{ github.repository }}/pull/${{ steps.pr.outputs.number }} --comment'
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index d1dda8fb0..41f964cbe 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -17,6 +17,9 @@ on:
# already-merged code without waiting for the next PR.
- cron: '0 6 * * 1'
+permissions:
+ contents: read
+
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml
index ad06267c2..1e2ba1f19 100644
--- a/.github/workflows/dependency-review.yml
+++ b/.github/workflows/dependency-review.yml
@@ -10,6 +10,9 @@ on:
pull_request:
branches: [main]
+permissions:
+ contents: read
+
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
index 9e5750210..0cd526768 100644
--- a/.github/workflows/docker.yml
+++ b/.github/workflows/docker.yml
@@ -26,6 +26,9 @@ on:
required: true
type: string
+permissions:
+ contents: read
+
# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention".
# Tag refs are unique per release, so distinct tags run in parallel.
# Re-pushes of the same tag serialize. cancel-in-progress: false — never cancel a publish mid-flight.
@@ -132,7 +135,7 @@ jobs:
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Install Cosign
- uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
+ uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
- name: Log in to GitHub Container Registry
if: ${{ github.event_name != 'pull_request' && !inputs.dry_run }}
diff --git a/.github/workflows/gitleaks.yml b/.github/workflows/gitleaks.yml
index cfe4d0856..7cfc3bc52 100644
--- a/.github/workflows/gitleaks.yml
+++ b/.github/workflows/gitleaks.yml
@@ -12,6 +12,9 @@ on:
push:
branches: [main]
+permissions:
+ contents: read
+
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
diff --git a/.github/workflows/pr-autofix-apply.yml b/.github/workflows/pr-autofix-apply.yml
new file mode 100644
index 000000000..73bf8ceb0
--- /dev/null
+++ b/.github/workflows/pr-autofix-apply.yml
@@ -0,0 +1,595 @@
+name: PR Autofix (apply)
+
+# CHATOPS HALF of the autofix pipeline.
+#
+# Triggered when a contributor comments `/autofix` on a PR. Validates
+# permission, locates the most recent successful `pr-autofix.yml`
+# artifact for the PR's current head SHA, applies the patch to the PR
+# head, and pushes a commit back to the PR branch.
+#
+# This workflow runs from the default branch's copy of the file
+# regardless of where the comment originates -- that's the trust
+# anchor. Comment body and author login are untrusted; both flow
+# through env vars and pattern-matched, never interpolated into shell.
+#
+# Fork PR support: `git push` with the GITHUB_TOKEN succeeds against
+# fork branches only when the contributor enabled "Allow edits by
+# maintainers" on the PR (the default). When they disabled it, we
+# fail loud with a 👎 reaction and an explanation comment.
+
+on:
+ issue_comment:
+ types: [created]
+
+concurrency:
+ # Per-PR scope. issue_comment events expose `github.event.issue.number`
+ # for both PR and Issue comments; the `pull_request != null` guard on
+ # the job ensures we only run on PRs, so this number is the PR number.
+ # cancel-in-progress: false — a second `/autofix` should wait for the
+ # first to finish (idempotency check on the second invocation handles
+ # the no-op case).
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.issue.number }}
+ cancel-in-progress: false
+
+permissions: {}
+
+jobs:
+ apply:
+ name: apply-autofix
+ # Pre-filter at the workflow level so non-PR comments and unrelated
+ # comments don't even spawn a runner. The job-level body re-check
+ # below (Step 1) is the strict gate.
+ if: >-
+ github.event.issue.pull_request != null
+ && startsWith(github.event.comment.body, '/autofix')
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ permissions:
+ # React on the triggering comment + post reply comments.
+ pull-requests: write
+ # Push the apply commit to the PR head branch.
+ contents: write
+ # Required by actions/download-artifact to fetch artifacts produced
+ # by a different workflow run.
+ actions: read
+ steps:
+ - name: Validate comment body precisely
+ id: body
+ env:
+ BODY: ${{ github.event.comment.body }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ # Whole-line, case-sensitive match: `^/autofix\s*$`. The
+ # workflow-level startsWith guard is coarse — `please don't
+ # /autofix this code` would pass that filter but fail this one.
+ # We exit silently (no reaction) on body mismatch so quoted
+ # text in unrelated discussions doesn't get a visible response.
+ if [[ ! "${BODY}" =~ ^/autofix[[:space:]]*$ ]]; then
+ echo "Body did not match strict /autofix regex — exiting silently."
+ echo "match=false" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+ echo "match=true" >> "$GITHUB_OUTPUT"
+
+ - name: Validate commenter permission
+ id: perm
+ if: steps.body.outputs.match == 'true'
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GH_REPO: ${{ github.repository }}
+ COMMENTER: ${{ github.event.comment.user.login }}
+ PR_AUTHOR: ${{ github.event.issue.user.login }}
+ shell: bash
+ run: |
+ set -euo pipefail
+
+ # Retry wrapper for transient 5xx / 429 / network blips.
+ # Mirrors the helper in pr-autofix-publish.yml. Used on
+ # idempotent GETs only; reactions/comment-POSTs are NOT
+ # wrapped (retrying a POST would dupe the resource).
+ gh_retry() {
+ local n=0 max=3
+ while true; do
+ if gh "$@"; then return 0; fi
+ n=$((n+1))
+ if [ "$n" -ge "$max" ]; then return 1; fi
+ sleep $((n * 2))
+ done
+ }
+
+ # Allowlist the commenter login before it flows into a URL.
+ # GitHub usernames: alphanumeric + dashes, max 39 chars.
+ if ! [[ "${COMMENTER}" =~ ^[A-Za-z0-9-]{1,39}$ ]]; then
+ echo "::error::Invalid commenter login format: $(printf '%q' "${COMMENTER}")"
+ echo "allowed=false" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+
+ # Self-comparison: PR author can always /autofix their own PR.
+ if [ "${COMMENTER}" = "${PR_AUTHOR}" ]; then
+ echo "Commenter is PR author — granting access."
+ echo "allowed=true" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+
+ # Repo permission lookup. admin/write/maintain are sufficient.
+ # Distinguish API failure (5xx, 429, network) from genuine
+ # permission denial (404 = not a collaborator). Conflating them
+ # would silently refuse a legitimate maintainer with a public
+ # 👎 every time GitHub blips. gh_retry handles transient blips;
+ # the stderr-grep distinguishes 404 from persistent failure.
+ perm_stderr=$(mktemp)
+ if permission=$(gh_retry api "repos/${GH_REPO}/collaborators/${COMMENTER}/permission" \
+ --jq '.permission' 2>"$perm_stderr"); then
+ echo "Commenter permission: ${permission}"
+ case "${permission}" in
+ admin|write|maintain)
+ echo "allowed=true" >> "$GITHUB_OUTPUT"
+ ;;
+ *)
+ echo "allowed=false" >> "$GITHUB_OUTPUT"
+ ;;
+ esac
+ else
+ err=$(cat "$perm_stderr")
+ echo "Permission lookup stderr: ${err}" >&2
+ # 404 (not a collaborator) is a genuine deny.
+ # Anything else is a transient API/network failure.
+ if grep -qE "HTTP 404|Not Found" "$perm_stderr"; then
+ echo "allowed=false" >> "$GITHUB_OUTPUT"
+ else
+ echo "::error::Permission lookup failed transiently — refusing to act."
+ echo "allowed=api-failed" >> "$GITHUB_OUTPUT"
+ fi
+ fi
+
+ - name: React 😕 on transient permission-API failure
+ if: steps.body.outputs.match == 'true' && steps.perm.outputs.allowed == 'api-failed'
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GH_REPO: ${{ github.repository }}
+ COMMENT_ID: ${{ github.event.comment.id }}
+ PR: ${{ github.event.issue.number }}
+ RUN_ID: ${{ github.run_id }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
+ -f content="confused" >/dev/null
+ gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
+ -f body="⚠️ Couldn't verify your repo permission (transient GitHub API failure). Please comment \`/autofix\` again. ([apply run](https://github.com/${GH_REPO}/actions/runs/${RUN_ID}))" \
+ >/dev/null
+ exit 1
+
+ - name: React 👎 on permission denial
+ if: steps.body.outputs.match == 'true' && steps.perm.outputs.allowed == 'false'
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GH_REPO: ${{ github.repository }}
+ COMMENT_ID: ${{ github.event.comment.id }}
+ PR: ${{ github.event.issue.number }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
+ -f content="-1" >/dev/null
+ gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
+ -f body="🚫 \`/autofix\` is restricted to users with write access or the PR author. Comment ignored." \
+ >/dev/null
+ # Hard exit so the rest of the job is skipped.
+ exit 1
+
+ - name: React 👀 to acknowledge
+ if: steps.perm.outputs.allowed == 'true'
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GH_REPO: ${{ github.repository }}
+ COMMENT_ID: ${{ github.event.comment.id }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
+ -f content="eyes" >/dev/null
+
+ - name: Resolve PR head and locate autofix run
+ id: locate
+ if: steps.perm.outputs.allowed == 'true'
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GH_REPO: ${{ github.repository }}
+ PR: ${{ github.event.issue.number }}
+ shell: bash
+ run: |
+ set -euo pipefail
+
+ # Same retry wrapper used in the permission step, repeated
+ # because each YAML `run:` block is a fresh bash session.
+ gh_retry() {
+ local n=0 max=3
+ while true; do
+ if gh "$@"; then return 0; fi
+ n=$((n+1))
+ if [ "$n" -ge "$max" ]; then return 1; fi
+ sleep $((n * 2))
+ done
+ }
+
+ # Fetch PR metadata. All fields here are server-controlled API
+ # output, but we still allowlist before exporting so anything
+ # weird short-circuits before $GITHUB_OUTPUT. Wrapped in
+ # gh_retry so transient blips don't surface as "no autofix run
+ # found" with a wrong remediation.
+ if ! pr_json=$(gh_retry api "repos/${GH_REPO}/pulls/${PR}"); then
+ echo "::error::PR metadata fetch failed after retries."
+ echo "found_status=api-failed" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+ head_sha=$(jq -r '.head.sha' <<< "${pr_json}")
+ head_ref=$(jq -r '.head.ref' <<< "${pr_json}")
+ head_repo=$(jq -r '.head.repo.full_name' <<< "${pr_json}")
+
+ [[ "${head_sha}" =~ ^[0-9a-f]{40}$ ]] || { echo "::error::Bad head_sha"; exit 1; }
+ [[ "${head_ref}" =~ ^[A-Za-z0-9._/-]+$ ]] || { echo "::error::Bad head_ref"; exit 1; }
+ [[ "${head_repo}" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]] || { echo "::error::Bad head_repo"; exit 1; }
+
+ # Find the latest successful pr-autofix.yml run for this head SHA.
+ if ! runs_json=$(gh_retry api "repos/${GH_REPO}/actions/workflows/pr-autofix.yml/runs?head_sha=${head_sha}&per_page=10"); then
+ echo "::error::Workflow run lookup failed after retries."
+ echo "found_status=api-failed" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+
+ run_id=$(jq -r '[.workflow_runs[] | select(.conclusion == "success")] | .[0].id // empty' <<< "${runs_json}")
+
+ if [ -n "${run_id}" ] && [[ "${run_id}" =~ ^[0-9]+$ ]]; then
+ echo "found_status=success" >> "$GITHUB_OUTPUT"
+ {
+ echo "found=true"
+ echo "head_sha=${head_sha}"
+ echo "head_ref=${head_ref}"
+ echo "head_repo=${head_repo}"
+ echo "run_id=${run_id}"
+ } >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+
+ # No successful run. Distinguish "still running" (producer in
+ # flight after a recent push) from "never ran / all failed".
+ # in_progress / queued / pending / waiting cover the GitHub
+ # workflow-run lifecycle states that precede success/failure.
+ in_progress=$(jq -r '[.workflow_runs[] | select(.status == "in_progress" or .status == "queued" or .status == "pending" or .status == "waiting")] | length' <<< "${runs_json}")
+ if [ "${in_progress:-0}" -gt 0 ]; then
+ echo "::warning::pr-autofix run is still in progress for head ${head_sha}."
+ echo "found_status=in-progress" >> "$GITHUB_OUTPUT"
+ else
+ echo "::warning::No successful pr-autofix run found for head ${head_sha}."
+ echo "found_status=not-found" >> "$GITHUB_OUTPUT"
+ fi
+ # Existing `found` boolean is preserved so downstream gates
+ # (`steps.locate.outputs.found == 'true'`) still work.
+ echo "found=false" >> "$GITHUB_OUTPUT"
+
+ - name: Reply when locate did not yield a usable run
+ if: steps.perm.outputs.allowed == 'true' && steps.locate.outputs.found != 'true'
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GH_REPO: ${{ github.repository }}
+ COMMENT_ID: ${{ github.event.comment.id }}
+ PR: ${{ github.event.issue.number }}
+ FOUND_STATUS: ${{ steps.locate.outputs.found_status }}
+ RUN_ID: ${{ github.run_id }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ run_url="https://github.com/${GH_REPO}/actions/runs/${RUN_ID}"
+ case "${FOUND_STATUS}" in
+ in-progress)
+ gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
+ -f content="confused" >/dev/null
+ gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
+ -f body="⏳ A pr-autofix run is still in progress for this PR's current head SHA. Wait for it to finish, then comment \`/autofix\` again. ([apply run](${run_url}))" \
+ >/dev/null
+ ;;
+ api-failed)
+ gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
+ -f content="confused" >/dev/null
+ gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
+ -f body="⚠️ Couldn't reach the GitHub API to look up the autofix run (transient failure after retries). Please comment \`/autofix\` again. ([apply run](${run_url}))" \
+ >/dev/null
+ ;;
+ *)
+ gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
+ -f content="-1" >/dev/null
+ gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
+ -f body="🤔 No successful autofix run found for this PR's current head SHA. Push a new commit to trigger one, then comment \`/autofix\` again." \
+ >/dev/null
+ ;;
+ esac
+ exit 1
+
+ # Pinned to v8.0.1. Same SHA as pr-autofix-publish.yml.
+ # `continue-on-error: true` lets the workflow proceed when the
+ # artifact is expired or pruned (1-day retention). The apply
+ # step distinguishes "patch file missing entirely" (artifact-
+ # expired) from "patch file zero bytes" (genuinely empty patch).
+ - name: Download autofix artifact
+ if: steps.locate.outputs.found == 'true'
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ continue-on-error: true
+ with:
+ name: autofix
+ run-id: ${{ steps.locate.outputs.run_id }}
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ path: autofix-in
+
+ # Pinned to v5.0.4. Verify SHA via:
+ # gh api repos/actions/checkout/git/refs/tags/v5.0.4
+ #
+ # `persist-credentials: false` disables the default behavior where
+ # actions/checkout writes the GITHUB_TOKEN into `.git/config` as an
+ # extraheader. That default is convenient (subsequent git commands
+ # auth automatically) but it means the token is sitting on disk in
+ # the checkout directory — an `actions/upload-artifact` step on
+ # this directory would leak the token. We don't upload, but
+ # zizmor's `credential-persistence` lint flags it defensively.
+ # Push auth is provided inline at push time via the URL.
+ - name: Checkout PR head
+ if: steps.locate.outputs.found == 'true'
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5.0.4
+ with:
+ repository: ${{ steps.locate.outputs.head_repo }}
+ ref: ${{ steps.locate.outputs.head_sha }}
+ token: ${{ secrets.GITHUB_TOKEN }}
+ persist-credentials: false
+ # Fetch full history so the push doesn't hit shallow-clone errors.
+ fetch-depth: 0
+ path: pr-checkout
+
+ - name: Apply patch and push
+ id: apply
+ if: steps.locate.outputs.found == 'true'
+ env:
+ HEAD_REF: ${{ steps.locate.outputs.head_ref }}
+ HEAD_REPO: ${{ steps.locate.outputs.head_repo }}
+ # The SHA we resolved earlier in `locate` — this is what the
+ # remote ref MUST still equal at push time. If the contributor
+ # force-pushed between resolve and now, the lease fails and
+ # we surface that distinctly from a fork-without-maintainer
+ # -edit push failure.
+ HEAD_SHA: ${{ steps.locate.outputs.head_sha }}
+ # Auth for the push only — never persisted to disk. Provided
+ # via env to avoid interpolating into the shell command line.
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ shell: bash
+ working-directory: pr-checkout
+ run: |
+ set -euo pipefail
+ patch="../autofix-in/autofix.patch"
+
+ # Distinguish artifact-expired (file missing entirely, because
+ # actions/download-artifact ran with continue-on-error and the
+ # 1-day retention had elapsed) from genuinely empty patch
+ # (file present, zero bytes, formatter found nothing).
+ if [ ! -e "$patch" ]; then
+ echo "::warning::Patch file does not exist — autofix artifact likely expired."
+ echo "result=artifact-expired" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+
+ if [ ! -s "$patch" ]; then
+ echo "::warning::Empty patch — nothing to apply."
+ echo "result=empty-patch" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+
+ # Sensitive-paths guard: refuse to apply patches that touch
+ # `.github/` — workflow files, action definitions, CODEOWNERS,
+ # dependabot config, etc. A malicious PR could ship a custom
+ # prettier/ESLint config that reformats workflow YAML; the
+ # producer would then capture those edits in autofix.patch,
+ # and a maintainer running `/autofix` would push them under
+ # `contents: write`. The default GITHUB_TOKEN lacks `workflows`
+ # scope so the platform would reject workflow-file pushes
+ # anyway, but that surfaces as a generic `push-failed` and
+ # misleads users into enabling maintainer-edit. Reject early
+ # with a specific reason. CODEOWNERS and dependabot.yml live
+ # under .github/ but outside .github/workflows/ — the broader
+ # match is intentional (they all govern trust boundaries).
+ if grep -qE '^(diff --git|---|\+\+\+) [ab]?/?\.github/' "$patch"; then
+ echo "::warning::Patch touches .github/ — refusing to apply (sensitive paths)."
+ echo "result=sensitive-paths" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+
+ # Re-entrancy guard: if HEAD itself is an autofix bot commit,
+ # refuse to apply again. Without this, lint/formatter config
+ # drift between runs could pump arbitrary apply commits into
+ # the same PR if an automated agent watches the sticky and
+ # re-fires `/autofix` on each new "fixes-available" surface.
+ # The contributor can still get out by force-pushing a
+ # human-authored commit to revert the autofix and re-trigger.
+ head_author=$(git log -1 --format='%ae' HEAD)
+ head_subject=$(git log -1 --format='%s' HEAD)
+ if [ "${head_author}" = "41898282+github-actions[bot]@users.noreply.github.com" ] \
+ && [[ "${head_subject}" =~ ^chore\(autofix\) ]]; then
+ echo "::warning::HEAD is an autofix bot commit — refusing to re-apply (loop guard)."
+ echo "result=loop-prevented" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+
+ # Idempotency probe: does the forward apply work?
+ if git apply --check "$patch" 2>/dev/null; then
+ echo "Patch applies cleanly — proceeding."
+ elif git apply --check --reverse "$patch" 2>/dev/null; then
+ # Reverse-check passes => the patch is already applied to
+ # the current tree. Treat as success no-op.
+ echo "Patch is already applied (reverse-check passed) — no-op."
+ echo "result=already-applied" >> "$GITHUB_OUTPUT"
+ exit 0
+ else
+ echo "::error::Patch does not apply (stale or conflicting)."
+ echo "result=stale" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+
+ # Wrap the apply/commit phase so any non-zero exit sets a
+ # meaningful `result=` instead of leaving it unset (which would
+ # send the user to the `*` "unexpected state" arm with a
+ # non-actionable confused-emoji reply).
+ if ! {
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com" &&
+ git config user.name "github-actions[bot]" &&
+ git apply "$patch" &&
+ git add -A &&
+ git commit -m "chore(autofix): apply prettier + eslint fixes via /autofix command"
+ }; then
+ echo "::error::git apply / config / commit failed after idempotency probe passed."
+ echo "result=apply-failed" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+
+ # Push to the PR head branch with a lease against the resolved
+ # SHA. The lease ensures the remote ref still points at HEAD_SHA
+ # when the push lands — if the contributor force-pushed in the
+ # window between resolve and now, the lease fails and we return
+ # `lease-failed` (NOT `push-failed`, which would mislead users
+ # into enabling maintainer-edit). For fork PRs, the push still
+ # requires "Allow edits by maintainers" to be enabled.
+ #
+ # Auth is supplied inline via `-c http..extraheader` (NOT
+ # via a `https://x-access-token:TOKEN@…` URL — those leak into
+ # process listings and `git remote -v` output). The header is
+ # set per-invocation; it never lands in `.git/config` on disk.
+ # The token is base64-encoded for the Basic auth header per
+ # GitHub's documented pattern for this scope.
+ push_url="https://github.com/${HEAD_REPO}.git"
+ auth_header="Authorization: Basic $(printf 'x-access-token:%s' "${GITHUB_TOKEN}" | base64 -w0)"
+ # GitHub's secret-masker only masks the raw token, not its
+ # base64-encoded form. Mask the encoded value so any subsequent
+ # log line (set -x, GIT_TRACE, error spew) gets ***-redacted.
+ echo "::add-mask::${auth_header}"
+ push_stderr=$(mktemp)
+ if git -c http.extraheader="${auth_header}" \
+ push --force-with-lease="refs/heads/${HEAD_REF}:${HEAD_SHA}" \
+ "${push_url}" "HEAD:${HEAD_REF}" 2>"$push_stderr"; then
+ echo "result=applied" >> "$GITHUB_OUTPUT"
+ else
+ cat "$push_stderr" >&2
+ # `--force-with-lease` reports "stale info" when the remote
+ # ref has moved past the expected SHA. Other lease-failure
+ # phrases git emits include "remote rejected" (server-side
+ # reject), "non-fast-forward", and the literal flag name. Match
+ # any of those to distinguish from auth/network/maintainer-
+ # edit failures.
+ if grep -qE "stale info|force-with-lease|rejected.*non-fast-forward|remote rejected|! \[rejected\]" "$push_stderr"; then
+ echo "::error::git push lease failed — branch moved during apply."
+ echo "result=lease-failed" >> "$GITHUB_OUTPUT"
+ else
+ echo "::error::git push failed — likely fork without maintainer-edit enabled."
+ echo "result=push-failed" >> "$GITHUB_OUTPUT"
+ fi
+ exit 0
+ fi
+
+ - name: React and reply on outcome
+ if: always() && steps.locate.outputs.found == 'true' && steps.apply.outcome != 'skipped'
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GH_REPO: ${{ github.repository }}
+ COMMENT_ID: ${{ github.event.comment.id }}
+ PR: ${{ github.event.issue.number }}
+ RESULT: ${{ steps.apply.outputs.result }}
+ RUN_ID: ${{ github.run_id }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ run_url="https://github.com/${GH_REPO}/actions/runs/${RUN_ID}"
+
+ case "${RESULT}" in
+ applied)
+ gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
+ -f content="+1" >/dev/null
+ gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
+ -f body="✅ Applied autofix and pushed a commit. ([apply run](${run_url}))" \
+ >/dev/null
+ ;;
+ already-applied)
+ gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
+ -f content="+1" >/dev/null
+ gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
+ -f body="✅ Autofix is already applied — no changes needed." \
+ >/dev/null
+ ;;
+ empty-patch)
+ gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
+ -f content="+1" >/dev/null
+ gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
+ -f body="✅ No autofix to apply — formatter found nothing." \
+ >/dev/null
+ ;;
+ artifact-expired)
+ gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
+ -f content="confused" >/dev/null
+ gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
+ -f body="⏳ The autofix artifact for this PR's head SHA has expired (1-day retention). Push a new commit to regenerate it, then comment \`/autofix\` again. ([apply run](${run_url}))" \
+ >/dev/null
+ exit 1
+ ;;
+ loop-prevented)
+ gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
+ -f content="confused" >/dev/null
+ gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
+ -f body="🔁 Refusing to re-apply autofix on top of an existing autofix commit. If formatter rules drifted and you genuinely need another pass, push a human-authored commit (or revert the existing autofix commit) before commenting \`/autofix\` again. ([apply run](${run_url}))" \
+ >/dev/null
+ exit 1
+ ;;
+ sensitive-paths)
+ gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
+ -f content="-1" >/dev/null
+ gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
+ -f body="🛑 Refusing to apply: the autofix patch touches files under \`.github/\` (workflow / CODEOWNERS / dependabot config). Apply formatter changes to those files manually in a regular commit so they get human review. ([apply run](${run_url}))" \
+ >/dev/null
+ exit 1
+ ;;
+ stale)
+ gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
+ -f content="-1" >/dev/null
+ gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
+ -f body="⚠️ The autofix patch is stale or conflicts with the current head — push a new commit to regenerate, then comment \`/autofix\` again. ([apply run](${run_url}))" \
+ >/dev/null
+ exit 1
+ ;;
+ apply-failed)
+ gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
+ -f content="-1" >/dev/null
+ gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
+ -f body="⚠️ Autofix applied cleanly in the dry run, but \`git apply\` / \`git commit\` failed when actually landing the patch. This usually means a race with concurrent edits or a corrupt patch. See logs: ${run_url}" \
+ >/dev/null
+ exit 1
+ ;;
+ push-failed)
+ gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
+ -f content="-1" >/dev/null
+ gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
+ -f body="⚠️ Couldn't push the autofix commit. If this is a fork PR, please tick **Allow edits by maintainers** in the PR sidebar, then comment \`/autofix\` again. ([apply run](${run_url}))" \
+ >/dev/null
+ exit 1
+ ;;
+ lease-failed)
+ gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
+ -f content="-1" >/dev/null
+ gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
+ -f body="⚠️ The PR head moved while autofix was applying — a new commit landed in the window between resolve and push. Comment \`/autofix\` again to retry against the latest head. ([apply run](${run_url}))" \
+ >/dev/null
+ exit 1
+ ;;
+ *)
+ gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
+ -f content="confused" >/dev/null
+ gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
+ -f body="❓ Autofix run finished in an unexpected state (\`${RESULT:-unknown}\`). See logs: ${run_url}" \
+ >/dev/null
+ exit 1
+ ;;
+ esac
diff --git a/.github/workflows/pr-autofix-publish.yml b/.github/workflows/pr-autofix-publish.yml
index a22f2cc7a..08ad1d60f 100644
--- a/.github/workflows/pr-autofix-publish.yml
+++ b/.github/workflows/pr-autofix-publish.yml
@@ -3,20 +3,19 @@ name: PR Autofix (publish)
# TRUSTED HALF of the autofix pipeline.
#
# Triggered by `pr-autofix.yml` completing on a PR (including fork PRs).
-# Downloads the diff artifact produced by the untrusted job and posts
-# inline review-comment suggestions to the PR using `reviewdog`. This
-# job NEVER checks out fork code — it only consumes the diff (data) and
-# calls the GitHub API. That isolation is what makes it safe to run
-# under `pull-requests: write` on fork-triggered events.
+# Downloads the diff artifact produced by the untrusted job, verifies
+# its claimed PR identity against the workflow_run authority, then
+# posts (or edits) a single sticky summary comment plus a
+# `gitnexus/autofix` Check Run. This job NEVER checks out fork code —
+# it only consumes the diff (data) and calls the GitHub API. That
+# isolation is what makes it safe to run under `pull-requests: write`
+# on fork-triggered events.
#
-# Also posts (or edits) a single sticky summary comment so contributors
-# and AI agents have one stable, machine-readable signal that says
-# whether autofix had anything to suggest. Look for the heading
-# "## :sparkles: PR Autofix" in the PR's top-level comments.
-#
-# Reviewdog reporter: `github-pr-review` reads $REVIEWDOG_GITHUB_API_TOKEN
-# and posts via the GraphQL/REST PR-review API. It does not need a
-# checkout because the diff itself encodes file paths + line numbers.
+# The sticky comment is the contributor signal: heading
+# "## :sparkles: PR Autofix" in the PR's top-level comments, with a
+# fenced `gitnexus-autofix` JSON block carrying machine-readable state
+# for AI agents. Contributors apply the patch by commenting `/autofix`
+# on the PR — handled by the separate `pr-autofix-apply.yml` workflow.
on:
workflow_run:
@@ -49,9 +48,9 @@ jobs:
# by a different workflow run.
actions: read
# Required to create the `gitnexus/autofix` Check Run that reports
- # the outcome (clean / suggestions-posted / skipped-too-large) to
- # the PR's Checks tab. Branch protection or agents can grep the
- # conclusion + output title without parsing the sticky comment.
+ # the outcome (clean / fixes-available) to the PR's Checks tab.
+ # Branch protection or agents can grep the conclusion + output
+ # title without parsing the sticky comment.
checks: write
steps:
# Pinned to v8.0.1. Verify SHA via:
@@ -114,71 +113,78 @@ jobs:
echo "changed_lines=${CHANGED}"
} >> "$GITHUB_OUTPUT"
- # Pinned to v1.5.0. Verify SHA via:
- # gh api repos/reviewdog/action-setup/git/refs/tags/v1.5.0
- # (annotated tag — resolve via .../git/tags/ --jq .object)
- - name: Install reviewdog
- if: steps.meta.outputs.changed_lines != '0'
- uses: reviewdog/action-setup@d8a7baabd7f3e8544ee4dbde3ee41d0011c3a93f # v1.5.0
- with:
- # Pin the binary, not just the action SHA — a bad reviewdog
- # release otherwise breaks every PR with no rollback. Bump
- # this knob deliberately when validating a new release.
- reviewdog_version: v0.21.0
-
- - name: Post inline suggestions
- id: suggest
+ # Cross-verify the artifact's claimed identity against the
+ # GitHub-controlled workflow_run event. The previous step's
+ # allowlist only proves the fields are well-formed — not that
+ # they refer to the PR/SHA that actually triggered this run.
+ # A fork-controlled `npm run lint:fix` could plausibly mutate
+ # metadata.json to reference another PR or SHA, redirecting our
+ # write-scoped sticky/check-run onto an attacker-chosen target.
+ #
+ # Authority sources are all server-controlled GitHub event fields:
+ # - workflow_run.head_sha
+ # - workflow_run.head_repository.full_name
+ # - workflow_run.pull_requests[].number (within-repo PRs only;
+ # empty array on fork PRs — fall back to commits/{sha}/pulls)
+ #
+ # Mismatch => fail loud BEFORE any sticky/check-run side effect.
+ - name: Verify metadata against workflow_run authority
+ id: verify
if: steps.meta.outputs.changed_lines != '0'
env:
- REVIEWDOG_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- CI_REPO_OWNER: ${{ github.repository_owner }}
- CI_REPO_NAME: ${{ github.event.repository.name }}
- CI_PULL_REQUEST: ${{ steps.meta.outputs.pr_number }}
- CI_COMMIT: ${{ steps.meta.outputs.head_sha }}
- # Pull `changed_lines` through env so bash gets a real
- # variable (and shellcheck SC2170 doesn't fire on `-gt` against
- # a `${{ }}`-interpolated literal).
- CHANGED_LINES: ${{ steps.meta.outputs.changed_lines }}
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GH_REPO: ${{ github.repository }}
+ META_PR_NUMBER: ${{ steps.meta.outputs.pr_number }}
+ META_HEAD_SHA: ${{ steps.meta.outputs.head_sha }}
+ META_HEAD_REPO: ${{ steps.meta.outputs.head_repo }}
+ WF_HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
+ WF_HEAD_REPO: ${{ github.event.workflow_run.head_repository.full_name }}
+ WF_PR_NUMBERS: ${{ toJSON(github.event.workflow_run.pull_requests.*.number) }}
shell: bash
run: |
set -euo pipefail
- patch=autofix-in/autofix.patch
- if [ ! -s "$patch" ]; then
- echo "Empty patch — nothing to suggest."
- echo "posted=false" >> "$GITHUB_OUTPUT"
- exit 0
+
+ # 1) head_sha must match exactly. workflow_run.head_sha is the
+ # commit GitHub actually ran the producer against — definitive.
+ if [ "${META_HEAD_SHA}" != "${WF_HEAD_SHA}" ]; then
+ echo "::error::Artifact head_sha (${META_HEAD_SHA}) does not match workflow_run.head_sha (${WF_HEAD_SHA}) — refusing to publish."
+ exit 1
fi
- # GitHub's review-comment API returns 406 on diffs above ~3k
- # changed lines. Bail out gracefully and let the summary
- # comment carry the signal instead.
- if [ "$CHANGED_LINES" -gt 3000 ]; then
- echo "Diff too large ($CHANGED_LINES lines) — skipping inline suggestions."
- echo "posted=skipped-too-large" >> "$GITHUB_OUTPUT"
- exit 0
+ # 2) head_repo must match exactly. Same authority anchor.
+ if [ "${META_HEAD_REPO}" != "${WF_HEAD_REPO}" ]; then
+ echo "::error::Artifact head_repo (${META_HEAD_REPO}) does not match workflow_run.head_repository (${WF_HEAD_REPO}) — refusing to publish."
+ exit 1
fi
- # `-f.diff.strip=1` matches `git diff` output (a/foo b/foo).
- # `-filter-mode=added` only suggests on lines the PR added,
- # which avoids re-suggesting on already-resolved threads when
- # the contributor re-adds the autoformat label.
- reviewdog \
- -f=diff -f.diff.strip=1 \
- -name="prettier+eslint" \
- -reporter=github-pr-review \
- -filter-mode=added \
- -level=warning \
- -fail-on-error=false < "$patch"
+ # 3) pr_number must reference an open PR with this head SHA.
+ # Within-repo PRs: workflow_run.pull_requests[] is populated.
+ # Fork PRs: that array is empty by GitHub design — fall back
+ # to the REST commit-to-PRs lookup. Fail closed if the lookup
+ # finds no matching open PR (avoids attacker-forged PR ids).
+ allowed_numbers=$(jq -c '.' <<< "${WF_PR_NUMBERS}")
+ if [ "${allowed_numbers}" = "[]" ]; then
+ echo "workflow_run.pull_requests is empty (fork PR) — falling back to commits/{sha}/pulls."
+ allowed_numbers=$(gh api "repos/${GH_REPO}/commits/${WF_HEAD_SHA}/pulls" \
+ --jq '[.[] | select(.state == "open") | .number]' 2>/dev/null || echo "[]")
+ if [ "${allowed_numbers}" = "[]" ]; then
+ echo "::error::No open PR found for head ${WF_HEAD_SHA} via commits/{sha}/pulls — refusing to publish."
+ exit 1
+ fi
+ fi
- echo "posted=true" >> "$GITHUB_OUTPUT"
+ if ! jq -e --argjson n "${META_PR_NUMBER}" 'index($n) != null' <<< "${allowed_numbers}" >/dev/null; then
+ echo "::error::Artifact pr_number (${META_PR_NUMBER}) is not in the authoritative PR list (${allowed_numbers}) — refusing to publish."
+ exit 1
+ fi
+
+ echo "Verified: metadata identity matches workflow_run authority (PR=${META_PR_NUMBER}, head_sha=${META_HEAD_SHA}, head_repo=${META_HEAD_REPO})."
- name: Upsert sticky summary comment
# Only post when ci-quality found something fixable (= the
# autofix patch is non-empty). When prettier/eslint are clean
# the patch is zero bytes and the sticky comment is pure noise,
- # so we skip it. When the diff was too large for inline
- # suggestions, the sticky is the only signal the contributor
- # gets, so we still post in that case.
+ # so we skip it.
if: >-
always()
&& steps.meta.outputs.pr_number != ''
@@ -189,8 +195,6 @@ jobs:
PR: ${{ steps.meta.outputs.pr_number }}
CHANGED: ${{ steps.meta.outputs.changed_lines }}
HEAD_SHA: ${{ steps.meta.outputs.head_sha }}
- SCHEMA: ${{ steps.meta.outputs.schema }}
- POSTED: ${{ steps.suggest.outputs.posted }}
RUN_ID: ${{ github.run_id }}
shell: bash
run: |
@@ -200,25 +204,29 @@ jobs:
marker=""
heading="## :sparkles: PR Autofix"
- if [ "${POSTED}" = "skipped-too-large" ]; then
- ui_state="skipped-too-large"
- prose="Diff is **${CHANGED}** lines — too large for inline suggestions (GitHub caps the review-comment API at ~3000). Run locally: \`npm run lint:fix && npm run format\`."
- else
- ui_state="suggestions-posted"
- prose="Posted formatting / unused-import suggestions inline. Click **Apply suggestion** on each, or run locally: \`npm run lint:fix && npm run format\`."
- fi
+ # Single state. The /autofix slash command works for any diff
+ # size — there's no 3K cap and no no-overlap dead-end because
+ # the apply workflow uses `git apply` + push, not the GitHub
+ # review-comment API.
+ ui_state="fixes-available"
+ prose="Found fixable formatting / unused-import issues across **${CHANGED}** changed lines. **Comment \`/autofix\` on this PR to apply them**, or run \`npm run lint:fix && npm run format\` locally."
# Machine-readable JSON block — agents parse this instead of
# regexing English. Fenced code-block info string is
# `gitnexus-autofix` so agents can locate it without ambiguity.
+ # Schema bumped from v1 -> v2: adds `apply_command`. The v1
+ # field set is preserved as a superset, but the `state` enum
+ # is redefined (v1: suggestions-posted | skipped-too-large |
+ # diff-no-overlap; v2: fixes-available). v1 readers checking
+ # `schema == 'gitnexus.pr-autofix/v1'` see an unfamiliar version
+ # and fall back to prose, which is the intended migration path.
json=$(jq -n -c \
- --arg schema "${SCHEMA}" \
--arg state "${ui_state}" \
--argjson pr_number "${PR}" \
--argjson changed_lines "${CHANGED}" \
--arg head_sha "${HEAD_SHA}" \
--arg run_id "${RUN_ID}" \
- '{schema:$schema, state:$state, pr_number:$pr_number, changed_lines:$changed_lines, head_sha:$head_sha, run_id:$run_id}')
+ '{schema:"gitnexus.pr-autofix/v2", state:$state, pr_number:$pr_number, changed_lines:$changed_lines, head_sha:$head_sha, run_id:$run_id, apply_command:"/autofix"}')
# Multi-line quoted string instead of a column-0 heredoc — YAML's
# `run: |` block ends as soon as a content line dedents below the
@@ -272,10 +280,9 @@ jobs:
- name: Emit gitnexus/autofix Check Run
# Stable check name `gitnexus/autofix` so PR-watching agents can
# `gh pr checks ` and read the conclusion + title without
- # parsing the sticky comment. Three outcomes:
- # clean → conclusion: success
- # suggestions-posted → conclusion: neutral (review suggestions)
- # skipped-too-large → conclusion: neutral (diff > 3000 lines)
+ # parsing the sticky comment. Two outcomes:
+ # clean → conclusion: success
+ # fixes-available → conclusion: neutral
# `neutral` does not block branch-protection required-checks but
# is visually distinct from a green pass.
if: always() && steps.meta.outputs.head_sha != ''
@@ -284,7 +291,6 @@ jobs:
GH_REPO: ${{ github.repository }}
HEAD_SHA: ${{ steps.meta.outputs.head_sha }}
CHANGED: ${{ steps.meta.outputs.changed_lines }}
- POSTED: ${{ steps.suggest.outputs.posted }}
shell: bash
run: |
set -euo pipefail
@@ -293,14 +299,10 @@ jobs:
conclusion="success"
title="Formatting clean"
summary="Prettier and ESLint --fix produced no changes."
- elif [ "${POSTED}" = "skipped-too-large" ]; then
- conclusion="neutral"
- title="Diff too large for inline suggestions (${CHANGED} lines)"
- summary="GitHub caps the review-comment API at ~3000 lines. Run \`npm run lint:fix && npm run format\` locally."
else
conclusion="neutral"
- title="Suggestions posted"
- summary="Inline review-comment suggestions posted. Click **Apply suggestion** on each, or run \`npm run lint:fix && npm run format\` locally."
+ title="Autofix available — comment /autofix to apply"
+ summary="Comment \`/autofix\` on this PR to apply formatter + unused-import fixes (works at any diff size). Or run \`npm run lint:fix && npm run format\` locally."
fi
gh api -X POST "repos/${GH_REPO}/check-runs" \
diff --git a/.github/workflows/pr-autofix.yml b/.github/workflows/pr-autofix.yml
index f15e8c0e6..04eb2468d 100644
--- a/.github/workflows/pr-autofix.yml
+++ b/.github/workflows/pr-autofix.yml
@@ -6,7 +6,9 @@ name: PR Autofix
# (including fork heads) and uploads the resulting diff as an artifact.
# This job has NO privileged token and CANNOT post to the PR. The trusted
# `pr-autofix-publish.yml` workflow downloads the artifact via
-# `workflow_run` and posts the inline review-comment suggestions.
+# `workflow_run` and posts a sticky summary comment + Check Run.
+# Contributors apply the patch by commenting `/autofix` on the PR —
+# handled by the separate `pr-autofix-apply.yml` ChatOps workflow.
#
# Why the split:
# ESLint loads plugins from fork-controlled `node_modules`, so running
@@ -14,8 +16,8 @@ name: PR Autofix
# ship a poisoned eslint plugin and execute arbitrary code under that
# token. By keeping fork code execution in this job (token: read-only)
# and posting from a separate trusted job that never touches fork
-# code, we get the inline-suggestion UX for fork PRs without the
-# supply-chain hole. (See autofix.ci for the same pattern.)
+# code, we get the autofix UX for fork PRs without the supply-chain
+# hole. (See autofix.ci for the same pattern.)
#
# Removes unused imports via `eslint-plugin-unused-imports`, already in
# devDependencies and wired into the `lint` config.
@@ -59,7 +61,7 @@ jobs:
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
- node-version: 20
+ node-version: 22
cache: npm
cache-dependency-path: package-lock.json
@@ -105,11 +107,9 @@ jobs:
git diff --no-color > autofix-out/autofix.patch
# NOTE: `changed_lines` is the line-count of the patch file,
- # which includes hunk headers and context lines — NOT the
- # added/removed source-line count. The 3000-line cap in
- # pr-autofix-publish.yml is therefore conservative (fires
- # before reviewdog hits GitHub's ~3k review-comment API
- # ceiling). That bias is intentional.
+ # (hunk headers + context lines + added/removed). Surfaced in
+ # the sticky comment so contributors and AI agents have a
+ # quick size hint before invoking `/autofix`.
changed_lines=$(wc -l < autofix-out/autofix.patch | tr -d ' ')
echo "changed_lines=${changed_lines}" >> "$GITHUB_OUTPUT"
diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml
index 51664bba8..4a219bfab 100644
--- a/.github/workflows/pr-labeler.yml
+++ b/.github/workflows/pr-labeler.yml
@@ -35,6 +35,9 @@ on:
pull_request_target:
types: [opened, edited, reopened]
+permissions:
+ contents: read
+
# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention".
# Include `github.event_name` so `pull_request` (validate-title) and
# `pull_request_target` (autolabel) runs for the same PR do NOT share a slot
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index d372c163a..0694b5e4c 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -6,6 +6,7 @@ on:
- 'v*'
# No workflow-level permissions — scoped per job below.
+permissions: {}
# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention".
# Tag refs are unique per release, so distinct tags run in parallel. Re-pushes of the
@@ -35,7 +36,7 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
- node-version: 20
+ node-version: 22
registry-url: https://registry.npmjs.org
# Hermetic install for the published artifact — no cache carry-over
# from non-tag contexts. setup-node v5+ caches by default when a
diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml
index 36f37b5c6..2129e765a 100644
--- a/.github/workflows/release-candidate.yml
+++ b/.github/workflows/release-candidate.yml
@@ -58,6 +58,7 @@ jobs:
timeout-minutes: 5
permissions:
contents: read
+ pull-requests: read # read PR labels on the merge commit
outputs:
should_run: ${{ steps.decide.outputs.should_run }}
head_sha: ${{ steps.decide.outputs.head_sha }}
@@ -74,6 +75,8 @@ jobs:
FORCE: ${{ inputs.force }}
BUMP_INPUT: ${{ inputs.bump }}
EVENT_NAME: ${{ github.event_name }}
+ GH_TOKEN: ${{ github.token }}
+ REPO: ${{ github.repository }}
run: |
set -euo pipefail
HEAD_SHA=$(git rev-parse HEAD)
@@ -96,6 +99,49 @@ jobs:
exit 0
fi
+ # ── Skip when the merge commit corresponds to a release ─────────
+ # Two complementary checks (belt-and-suspenders):
+ # 1. The HEAD commit subject matches `chore: release vX.Y.Z`
+ # (the canonical release-PR title in this repo). Anchored
+ # at both ends to require the bare title or the squash-merge
+ # `(#NNNN)` suffix exactly — rejects noisy variants like
+ # `chore: release v1.0.0 (something unrelated)`.
+ # 2. The squash-merged PR carries the `release` label.
+ # Either match suppresses the rc build — stable releases publish
+ # via publish.yml on the v-tag, so the rc cycle should pause for
+ # them rather than racing the npm publish.
+ HEAD_SUBJECT="$(git log -1 --pretty=%s HEAD)"
+ # Sanitise GitHub-Actions annotation prefixes before logging the
+ # raw subject — defence-in-depth so a hypothetical commit subject
+ # containing `::error::` or `::set-output::` cannot forge log
+ # annotations even though %s strips newlines.
+ HEAD_SUBJECT_SAFE="${HEAD_SUBJECT//::/__}"
+ RELEASE_SUBJECT_RE='^chore:[[:space:]]*release[[:space:]]+v[0-9]+\.[0-9]+\.[0-9]+([[:space:]]+\(#[0-9]+\))?$'
+ if [[ "$HEAD_SUBJECT" =~ $RELEASE_SUBJECT_RE ]]; then
+ echo "HEAD commit subject matches a release commit — skipping rc."
+ echo " subject (sanitised): $HEAD_SUBJECT_SAFE"
+ echo "should_run=false" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+
+ # Squash-merge commits include `(#NNNN)` at the end of the subject.
+ if [[ "$HEAD_SUBJECT" =~ \(#([0-9]+)\)[[:space:]]*$ ]]; then
+ PR_NUM="${BASH_REMATCH[1]}"
+ echo "Detected squash-merge of PR #$PR_NUM — checking labels."
+ if LABELS_JSON="$(gh pr view "$PR_NUM" --repo "$REPO" --json labels 2>/dev/null)"; then
+ if printf '%s' "$LABELS_JSON" | jq -e '.labels[] | select(.name == "release")' >/dev/null; then
+ echo "PR #$PR_NUM has the 'release' label — skipping rc."
+ echo "should_run=false" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+ echo "PR #$PR_NUM has no 'release' label — proceeding."
+ else
+ # Lookup failure is not fatal — fall through to the dedup check
+ # so a transient GH API hiccup doesn't silently suppress rc builds.
+ echo "::warning::Could not read labels for PR #${PR_NUM} — falling through."
+ fi
+ fi
+
# Dedup: is there already an rc/ marker pointing at HEAD?
MARKER="rc/${HEAD_SHA}"
if git rev-parse "refs/tags/$MARKER" >/dev/null 2>&1; then
@@ -149,7 +195,7 @@ jobs:
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
- node-version: 20
+ node-version: 22
registry-url: https://registry.npmjs.org
# Hermetic install — release-candidate produces shipped artifacts.
# setup-node v5+ caches by default when a packageManager field is
diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml
index f476ee7bc..1251fa4dd 100644
--- a/.github/workflows/trivy.yml
+++ b/.github/workflows/trivy.yml
@@ -1,19 +1,28 @@
name: Trivy Image Scan
# Builds Dockerfile.cli and Dockerfile.web, then scans the resulting images
-# for OS-package and language-package CVEs at HIGH/CRITICAL severity.
+# for OS-package and language-package CVEs at MEDIUM+ severity.
# Findings upload to the Security tab; record-only (does not block merges).
#
-# NOT triggered on PRs — image builds are slow and base-image CVE churn
-# shouldn't gate feature delivery.
+# Trigger on Dockerfile changes in PRs so base-image/npm-layer remediation can
+# be verified before merge without running image scans on every PR.
on:
+ pull_request:
+ paths:
+ - 'Dockerfile.cli'
+ - 'Dockerfile.web'
+ - 'gitnexus/Dockerfile.test'
+ - '.github/workflows/trivy.yml'
push:
branches: [main]
schedule:
- cron: '0 8 * * 1'
workflow_dispatch:
+permissions:
+ contents: read
+
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
@@ -61,7 +70,7 @@ jobs:
image-ref: scan-target:${{ matrix.image.name }}
format: sarif
output: trivy-${{ matrix.image.name }}.sarif
- severity: HIGH,CRITICAL
+ severity: MEDIUM,HIGH,CRITICAL
# Hides CVEs with no available fix in the base image.
ignore-unfixed: true
exit-code: '0'
diff --git a/.github/workflows/workflow-lint.yml b/.github/workflows/workflow-lint.yml
index 7b38b8ddd..803c4d0bd 100644
--- a/.github/workflows/workflow-lint.yml
+++ b/.github/workflows/workflow-lint.yml
@@ -15,6 +15,9 @@ on:
paths:
- '.github/**'
+permissions:
+ contents: read
+
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
diff --git a/AGENTS.md b/AGENTS.md
index 60317d73d..b9b9138b1 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -149,11 +149,16 @@ Indexed as **GitNexus** (4325 symbols, 10556 relationships, 300 execution flows)
## Keeping the Index Fresh
```bash
-npx gitnexus analyze # basic refresh; preserves any existing embeddings
+npx gitnexus analyze # incremental by default; preserves embeddings
+npx gitnexus analyze --force # full rebuild from scratch (opt out of incremental)
npx gitnexus analyze --embeddings # also generate embeddings for new/changed nodes
npx gitnexus analyze --drop-embeddings # explicit opt-in to wipe existing embeddings
```
+`analyze` runs **incrementally by default**. The pipeline still parses every file every run (cross-file resolution requires it), but tree-sitter parsing is **served from a content-addressed cache** at `.gitnexus/parse-cache.json` for chunks whose file contents haven't changed since the last run. Only changed-file rows (and their importers) are rewritten in LadybugDB; unchanged-file rows are preserved. Output is byte-equivalent to a full rebuild. Pass `--force` to wipe and re-index from scratch (e.g., to recover from a corrupt index, or after upgrading GitNexus).
+
+The parse cache key is **content-addressed and version-tagged**: it survives `--force` runs, and is automatically invalidated by a `gitnexus` package upgrade (so a new tree-sitter grammar doesn't silently replay stale parse output). Safe to delete `.gitnexus/parse-cache.json` at any time — it'll be rebuilt on the next analyze.
+
Check `.gitnexus/meta.json` `stats.embeddings` (0 = none). A plain `analyze` no longer drops existing vectors — pass `--drop-embeddings` to wipe.
> Claude Code: PostToolUse hook detects a stale index after `git commit` and `git merge` and prompts the agent to run `analyze`. The hook does not invoke `analyze` itself.
@@ -169,6 +174,20 @@ Check `.gitnexus/meta.json` `stats.embeddings` (0 = none). A plain `analyze` no
| Tools/resources/schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
| CLI commands (index, status, clean, wiki) | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
+## Hook env knobs
+
+The Claude Code hook (`gitnexus/hooks/claude/gitnexus-hook.cjs` and the mirrored plugin copy under `gitnexus-claude-plugin/hooks/`) honours these env vars. Defaults work for normal installations; set them only to override resolution. All path overrides ignore values that do not exist on disk and fall through to the standard resolution chain.
+
+| Env var | Type | Default | Purpose |
+|---------|------|---------|---------|
+| `GITNEXUS_HOOK_CLI_PATH` | path | resolved via package layout / `require.resolve` | Override path to the `gitnexus` CLI entry the hook spawns for `augment`. |
+| `GITNEXUS_HOOK_LSOF_PATH` | path | `lsof` on `PATH` (with `/usr/bin/lsof`, `/usr/sbin/lsof`, `/sbin/lsof` fallbacks) | Override POSIX `lsof` location for the DB-lock probe. |
+| `GITNEXUS_HOOK_PS_PATH` | path | `ps` on `PATH` (with `/bin/ps`, `/usr/bin/ps` fallbacks) | Override POSIX `ps` location. |
+| `GITNEXUS_HOOK_POWERSHELL_PATH` | path | `%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe` (then `SysWOW64`, then `powershell.exe` on `PATH`) | Override Windows PowerShell location used by the Restart-Manager probe. |
+| `GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS` | integer ms | `1200` | Max wall-clock for the Linux `/proc` fd scan before bailing out to the `lsof` fallback. |
+| `GITNEXUS_HOOK_RM_TARGET` | path | derived | Restart-Manager target file (the LadybugDB path under `.gitnexus/`). Set internally by the hook; rarely overridden manually. |
+| `GITNEXUS_DEBUG` | boolean (`1`/`true`) | unset | Verbose stderr from the hook: prints discarded augment-stderr prefixes and one-shot `.ps1` load-failure warnings. |
+
## Repo reference
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 99930cf0a..d4f6b12b2 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -30,17 +30,17 @@ Format: `[(scope)][!]: `
Allowed types and the release-notes section each one lands in (defined in `.github/release.yml`):
-| Type | Label applied | Release-notes section |
-|------|---------------|-----------------------|
-| `feat` | `enhancement` | 🚀 Features |
-| `fix` | `bug` | 🐛 Bug Fixes |
-| `perf` | `performance` | 🏎️ Performance |
-| `refactor` | `refactor` | 🔄 Refactoring |
-| `test` | `test` | 🧪 Tests |
-| `ci` | `ci` | 👷 CI/CD |
-| `build` / `deps` | `dependencies` | 📦 Dependencies |
-| `docs` | `documentation` | (grouped under Other Changes unless a Docs section is added) |
-| `chore` / `revert` | `chore` | (excluded from release notes) |
+| Type | Label applied | Release-notes section |
+| ------------------ | --------------- | ------------------------------------------------------------ |
+| `feat` | `enhancement` | 🚀 Features |
+| `fix` | `bug` | 🐛 Bug Fixes |
+| `perf` | `performance` | 🏎️ Performance |
+| `refactor` | `refactor` | 🔄 Refactoring |
+| `test` | `test` | 🧪 Tests |
+| `ci` | `ci` | 👷 CI/CD |
+| `build` / `deps` | `dependencies` | 📦 Dependencies |
+| `docs` | `documentation` | (grouped under Other Changes unless a Docs section is added) |
+| `chore` / `revert` | `chore` | (excluded from release notes) |
Append `!` to the type (e.g. `feat(api)!: drop /v1 endpoint`) or include `BREAKING CHANGE:` in the PR body to flag a breaking change — the labeler then adds the `breaking` label and the 💥 Breaking Changes section is rendered first.
@@ -81,17 +81,17 @@ Every workflow under `.github/workflows/` MUST declare a top-level `concurrency:
- **Merge queue (`merge_group`)**: when this event is added, use `${{ github.workflow }}-${{ github.event.merge_group.head_ref }}` with `cancel-in-progress: false` (every queue entry is a distinct ref; never cancel).
- **`cancel-in-progress` policy:**
- | Event | `cancel-in-progress` | Why |
- |-------|----------------------|-----|
- | `pull_request` CI run | `true` | New push supersedes old run |
- | `push` to `main` | `false` | Every main commit gets validated |
- | Tag push (`v*` publish) | `false` | Never cancel mid-publish |
- | `push` to `main` for release-candidate | `false` | Never cancel mid-RC publish |
- | `workflow_dispatch` (release/publish) | `false` | Manual runs are intentional |
- | `workflow_run` (sticky-comment reports) | `false` | Serialize, don't race |
- | Per-PR bot workflows (`@claude`, review) | `false` | Serialize comments per PR |
- | PR-meta re-checks (pr-description-check) | `true` | Cheap, latest wins |
- | Single-slot utilities (triage sweep) | `true` | Latest dispatch supersedes |
+ | Event | `cancel-in-progress` | Why |
+ | ---------------------------------------- | -------------------- | -------------------------------- |
+ | `pull_request` CI run | `true` | New push supersedes old run |
+ | `push` to `main` | `false` | Every main commit gets validated |
+ | Tag push (`v*` publish) | `false` | Never cancel mid-publish |
+ | `push` to `main` for release-candidate | `false` | Never cancel mid-RC publish |
+ | `workflow_dispatch` (release/publish) | `false` | Manual runs are intentional |
+ | `workflow_run` (sticky-comment reports) | `false` | Serialize, don't race |
+ | Per-PR bot workflows (`@claude`, review) | `false` | Serialize comments per PR |
+ | PR-meta re-checks (pr-description-check) | `true` | Cheap, latest wins |
+ | Single-slot utilities (triage sweep) | `true` | Latest dispatch supersedes |
- For workflows that serve multiple events at once (e.g. `ci.yml` handles `pull_request`, `push`, and `workflow_call`), make `cancel-in-progress` event-aware:
@@ -109,18 +109,35 @@ Two workflows produce machine-readable signals on every PR. Coding agents and hu
### `gitnexus/autofix`
-`pr-autofix.yml` (untrusted) + `pr-autofix-publish.yml` (trusted) run `prettier --write` and `eslint --fix` against the PR head and surface the diff as inline review-comment suggestions. Three signals are emitted:
+`pr-autofix.yml` (untrusted) + `pr-autofix-publish.yml` (trusted) run `prettier --write` and `eslint --fix` against the PR head and surface a single ChatOps button on the PR. Three signals are emitted:
-| Surface | Where | Notes |
-|---|---|---|
-| Sticky PR comment | Top-level comment with the HTML marker `` and heading `## :sparkles: PR Autofix`. Only posted when there is something to fix; clean PRs stay silent. | Edit-in-place via marker; one comment per PR. |
-| Fenced JSON block | Inside the sticky, fenced as `gitnexus-autofix`. Schema `gitnexus.pr-autofix/v1` with fields `state` (`suggestions-posted` \| `skipped-too-large`), `pr_number`, `head_sha`, `changed_lines`, `run_id`. | Parseable signal — preferred over regexing prose. |
-| Check Run | Stable name `gitnexus/autofix` on the PR head SHA. Conclusion: `success` (clean) or `neutral` (suggestions-posted / skipped-too-large). The output title disambiguates the two `neutral` cases. | Surfaced under PR Checks; readable via `gh pr checks `. |
+| Surface | Where | Notes |
+| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
+| Sticky PR comment | Top-level comment with the HTML marker `` and heading `## :sparkles: PR Autofix`. Only posted when there is something to fix; clean PRs stay silent. | Edit-in-place via marker; one comment per PR. |
+| Fenced JSON block | Inside the sticky, fenced as `gitnexus-autofix`. Schema `gitnexus.pr-autofix/v2` with fields `state` (`fixes-available`), `pr_number`, `head_sha`, `changed_lines`, `run_id`, and `apply_command` (literal `/autofix`). | Parseable signal — preferred over regexing prose. v1 fields preserved as a superset. |
+| Check Run | Stable name `gitnexus/autofix` on the PR head SHA. Conclusion: `success` (clean) or `neutral` (`fixes-available`). The neutral title is `Autofix available — comment /autofix to apply`. | Surfaced under PR Checks; readable via `gh pr checks `. |
To detect outcome from an agent: `gh pr checks --json name,conclusion,output | jq '.[] | select(.name == "gitnexus/autofix")'`.
Forks are supported. The untrusted half runs fork code with `permissions: {}` and ships the diff as an artifact; the trusted publish job consumes only the diff (data, not code) and posts the comment + check run.
+#### Applying autofix
+
+Comment `/autofix` on the PR (whole-line, no arguments). The `pr-autofix-apply.yml` workflow:
+
+1. Validates the comment body matches `^/autofix\s*$` exactly. Quoted or inline mentions are silently ignored.
+2. Validates the commenter has `admin`, `write`, or `maintain` permission on the repo, OR is the PR author. Other commenters get a 👎 reaction and a refusal reply.
+3. Locates the most recent successful `pr-autofix.yml` run for the PR's current head SHA, downloads its `autofix` artifact, applies the patch, and pushes a `chore(autofix): ...` commit back to the PR head branch.
+4. Reacts ✅ on success, 👎 on stale-patch / push-failure, and posts a short reply with the apply-run URL in either case.
+
+The apply workflow runs from the default branch's copy of the file regardless of where the comment originates — that's the trust anchor. There is no diff-size cap (the apply workflow uses `git apply` + push, not the GitHub review-comment API).
+
+For fork PRs, the push succeeds only when the contributor has **Allow edits by maintainers** enabled on the PR (the default). When they have disabled it, the workflow fails loud with a 👎 reaction and an explanation comment.
+
+Re-invoking `/autofix` after a successful apply is a safe no-op — the workflow detects the already-applied state via `git apply --check --reverse` and reacts ✅ without pushing.
+
+**Sensitive paths.** The apply workflow refuses any patch that touches `.github/` (workflow files, CODEOWNERS, dependabot config). A malicious PR could ship a custom prettier or ESLint config that reformats workflow YAML; if accepted, those edits would be pushed under `contents: write` without human review. Apply formatter changes to files under `.github/` manually in a normal commit so they get the same review every other workflow change gets.
+
## AI-assisted contributions
If you use coding agents, follow project context files (e.g. `AGENTS.md`, `CLAUDE.md`) and avoid drive-by refactors unrelated to the issue. Prefer incremental, test-backed changes.
@@ -182,8 +199,7 @@ Two publish workflows ship `gitnexus` to npm:
the Docker build.
- Manually run `docker build` + `docker push` locally and sign with Cosign
against the same digest.
- - Delete `rc/` and `v` tags, then redispatch with `force:
- true` to re-run the full RC pipeline (cuts a new RC number).
+ - Delete `rc/` and `v` tags, then redispatch with `force: true` to re-run the full RC pipeline (cuts a new RC number).
The rc workflow never moves `latest`. To verify after a change, inspect dist-tags:
diff --git a/Dockerfile.cli b/Dockerfile.cli
index c45292e06..3275c8f7e 100644
--- a/Dockerfile.cli
+++ b/Dockerfile.cli
@@ -1,24 +1,32 @@
ARG BUILDPLATFORM
ARG TARGETPLATFORM
+# Pinned npm version used to replace the bundled npm in the upstream Node
+# image. Bumping requires a coordinated update in Dockerfile.web and
+# gitnexus/Dockerfile.test so all images bootstrap the same npm.
+ARG NPM_VERSION=11.14.1
-# ── Builder ────────────────────────────────────────────────────────────
+# -- Builder -----------------------------------------------------------
# Native modules (tree-sitter-*, onnxruntime-node, node-gyp builds for
# tree-sitter-proto / tree-sitter-swift) require python3 + a C/C++ toolchain.
-FROM node:22-trixie-slim AS builder
+# node:22-bookworm-slim
+FROM node:22-bookworm-slim@sha256:9f6d5975c7dca860947d3915877f85607946403fc55349f39b4bc3688448bb6e AS builder
+ARG NPM_VERSION
WORKDIR /app
+RUN npx --yes npm@${NPM_VERSION} install -g npm@${NPM_VERSION}
+
# Toolchain for node-gyp / native builds.
RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ git && rm -rf /var/lib/apt/lists/*
-# Build gitnexus-shared first — gitnexus depends on it as a workspace.
+# Build gitnexus-shared first - gitnexus depends on it as a workspace.
COPY gitnexus-shared/package.json gitnexus-shared/package-lock.json ./gitnexus-shared/
RUN npm ci --prefix gitnexus-shared
COPY gitnexus-shared ./gitnexus-shared
RUN rm -f gitnexus-shared/tsconfig.tsbuildinfo
RUN npm run build --prefix gitnexus-shared
-# Copy the full gitnexus package before installing — `npm ci` triggers
+# Copy the full gitnexus package before installing - `npm ci` triggers
# `postinstall` (patches tree-sitter-swift, builds the vendored
# tree-sitter-proto) and `prepare` (compiles TypeScript via scripts/build.js),
# both of which need the source tree.
@@ -28,11 +36,15 @@ RUN npm ci --prefix gitnexus
# Drop dev dependencies for a smaller runtime layer.
RUN npm prune --omit=dev --prefix gitnexus
-# ── Runtime ────────────────────────────────────────────────────────────
-FROM node:22-trixie-slim AS runtime
+# -- Runtime -----------------------------------------------------------
+# node:22-bookworm-slim
+FROM node:22-bookworm-slim@sha256:9f6d5975c7dca860947d3915877f85607946403fc55349f39b4bc3688448bb6e AS runtime
-# curl for the healthcheck; git so `gitnexus` can clone repos at runtime.
-RUN apt-get update && apt-get install -y --no-install-recommends curl git && rm -rf /var/lib/apt/lists/*
+# curl for the healthcheck; git for cloning; ca-certificates for TLS verification.
+RUN apt-get update && apt-get install -y --no-install-recommends curl git ca-certificates && rm -rf /var/lib/apt/lists/* \
+ && rm -rf /usr/local/lib/node_modules/npm \
+ && rm -rf /usr/local/lib/node_modules/corepack \
+ && rm -f /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack
WORKDIR /app
@@ -43,11 +55,21 @@ RUN mkdir -p /data/gitnexus && chown -R node:node /data
COPY --from=builder --chown=node:node /app/gitnexus/dist ./gitnexus/dist
COPY --from=builder --chown=node:node /app/gitnexus/node_modules ./gitnexus/node_modules
COPY --from=builder --chown=node:node /app/gitnexus/package.json ./gitnexus/package.json
+COPY --from=builder --chown=node:node /app/gitnexus/scripts/install-duckdb-extension.mjs ./gitnexus/scripts/install-duckdb-extension.mjs
COPY --from=builder --chown=node:node /app/gitnexus/vendor ./gitnexus/vendor
+# Expose the `gitnexus` binary on PATH so the documented Docker workflow
+# (`docker compose exec gitnexus-server gitnexus index /workspace/`)
+# works without users having to invoke `node /app/gitnexus/dist/cli/index.js`.
+# `npm prune --omit=dev` in the builder stage strips `node_modules/.bin/`
+# entries, so the `gitnexus` bin declared in package.json (`dist/cli/index.js`,
+# which already carries `#!/usr/bin/env node` and 755 perms) is otherwise
+# unreachable from $PATH.
+RUN ln -s /app/gitnexus/dist/cli/index.js /usr/local/bin/gitnexus
+
USER node
-# The web UI defaults to http://localhost:4747 — keep that contract.
+# The web UI defaults to http://localhost:4747 - keep that contract.
ENV GITNEXUS_HOME=/data/gitnexus \
NODE_ENV=production \
PORT=4747
diff --git a/Dockerfile.web b/Dockerfile.web
index 7d20e09ce..b102a700e 100644
--- a/Dockerfile.web
+++ b/Dockerfile.web
@@ -1,10 +1,17 @@
ARG BUILDPLATFORM
ARG TARGETPLATFORM
+# Pinned npm version — keep in sync with Dockerfile.cli and
+# gitnexus/Dockerfile.test.
+ARG NPM_VERSION=11.14.1
-FROM --platform=$BUILDPLATFORM node:22-alpine AS builder
+# node:22-bookworm-slim
+FROM --platform=$BUILDPLATFORM node:22-bookworm-slim@sha256:9f6d5975c7dca860947d3915877f85607946403fc55349f39b4bc3688448bb6e AS builder
+ARG NPM_VERSION
WORKDIR /app
+RUN npx --yes npm@${NPM_VERSION} install -g npm@${NPM_VERSION}
+
COPY gitnexus-shared/package.json gitnexus-shared/package-lock.json ./gitnexus-shared/
RUN npm ci --prefix gitnexus-shared
@@ -19,9 +26,13 @@ RUN npm ci --prefix gitnexus-web
COPY gitnexus-web ./gitnexus-web
RUN npm run build --prefix gitnexus-web
-FROM node:22-alpine AS runtime
+# node:22-bookworm-slim
+FROM node:22-bookworm-slim@sha256:9f6d5975c7dca860947d3915877f85607946403fc55349f39b4bc3688448bb6e AS runtime
-RUN apk add --no-cache curl
+RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* \
+ && rm -rf /usr/local/lib/node_modules/npm \
+ && rm -rf /usr/local/lib/node_modules/corepack \
+ && rm -f /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack
WORKDIR /app
diff --git a/GUARDRAILS.md b/GUARDRAILS.md
index 1cc032759..c09f0319a 100644
--- a/GUARDRAILS.md
+++ b/GUARDRAILS.md
@@ -30,9 +30,15 @@ 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).
+- **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.
- **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 `.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.
diff --git a/README.md b/README.md
index 6beadb63d..e08c0eb7d 100644
--- a/README.md
+++ b/README.md
@@ -120,7 +120,7 @@ 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 + PostToolUse) | **Full** |
-| **Cursor** | Yes | Yes | — | MCP + Skills |
+| **Cursor** | Yes | Yes | Yes (postToolUse, [manual install](gitnexus-cursor-integration/README.md#hook-install)) | **Full** |
| **Codex** | Yes | Yes | — | MCP + Skills |
| **Windsurf** | Yes | — | — | MCP |
| **OpenCode** | Yes | Yes | — | MCP + Skills |
@@ -722,6 +722,12 @@ gitnexus wiki --base-url https://api.anthropic.com/v1
# Force full regeneration
gitnexus wiki --force
+
+
+# Increase the timeout or retries for large codebase or slow LLM providers
+gitnexus wiki --timeout # Per-attempt LLM request timeout in seconds (default: 60)
+gitnexus wiki --retries # Max LLM retry attempts per request (default: 3)
+
```
The wiki generator reads the indexed graph structure, groups files into modules via LLM, generates per-module documentation pages, and creates an overview page — all with cross-references to the knowledge graph.
diff --git a/eslint-rules/require-safe-parse.mjs b/eslint-rules/require-safe-parse.mjs
new file mode 100644
index 000000000..4ab9dbd8a
--- /dev/null
+++ b/eslint-rules/require-safe-parse.mjs
@@ -0,0 +1,95 @@
+/**
+ * Custom ESLint rule: require `parseSourceSafe(parser, content, ...)` instead
+ * of direct `.parse(, ...)` calls.
+ *
+ * Background: tree-sitter's Node.js native binding crashes with SIGSEGV on
+ * Windows when handed a JS string longer than 32 767 chars. The crash happens
+ * inside the binding's V8 string-to-buffer conversion and cannot be intercepted
+ * by JavaScript `try/catch`. `parseSourceSafe` (in
+ * `gitnexus/src/core/tree-sitter/safe-parse.ts`) routes large inputs through
+ * the chunked-callback overload of `parser.parse(input, ...)` which bypasses
+ * the broken conversion path. PR #1433 fixed every direct call site at the
+ * time; this rule prevents new direct calls from creeping in.
+ *
+ * The rule is auto-fixable for the call-site rewrite. It does NOT auto-add the
+ * import (computing the correct relative path per file is brittle); after the
+ * call rewrite runs, the consumer file's `tsc` will complain about an
+ * undefined identifier and the developer adds the import. This is the same
+ * tradeoff `unused-imports/no-unused-imports` makes in the opposite direction.
+ *
+ * False-positive suppression:
+ * - Skips calls whose receiver is a known non-tree-sitter library (`JSON`,
+ * `URL`, `marked`, `Number`).
+ * - Skips calls whose first argument is a string-literal (grammar-load smoke
+ * tests like `_testParser.parse('service X { rpc Y (R) returns (R); }')`).
+ * - Skips test files (`.test.ts`/`.test.tsx`/`.spec.ts`).
+ * - Skips the `safe-parse.ts` helper itself.
+ */
+
+const SKIPPED_RECEIVERS = new Set(['JSON', 'URL', 'marked', 'Number', 'Math']);
+
+export default {
+ meta: {
+ type: 'problem',
+ docs: {
+ description:
+ 'Require parseSourceSafe instead of direct tree-sitter `.parse(content, ...)` calls (Windows SIGSEGV protection)',
+ recommended: true,
+ },
+ fixable: 'code',
+ schema: [],
+ messages: {
+ useSafeParse:
+ 'Direct `{{receiver}}.parse(...)` can SIGSEGV on Windows for inputs > 32 767 chars (uncatchable from JS). Use `parseSourceSafe({{receiver}}, ...)` from `core/tree-sitter/safe-parse.js`. Auto-fix rewrites the call; add the missing import yourself.',
+ },
+ },
+ create(context) {
+ const filename = context.filename ?? context.getFilename();
+ // Don't lint the helper itself or test files.
+ if (filename.includes('safe-parse')) return {};
+ if (/[.](?:test|spec)\.tsx?$/.test(filename)) return {};
+
+ const sourceCode = context.sourceCode ?? context.getSourceCode();
+
+ return {
+ CallExpression(node) {
+ const callee = node.callee;
+ if (callee.type !== 'MemberExpression') return;
+ if (callee.computed) return;
+ if (callee.property.type !== 'Identifier') return;
+ if (callee.property.name !== 'parse') return;
+
+ // Skip known non-tree-sitter receivers.
+ if (callee.object.type === 'Identifier' && SKIPPED_RECEIVERS.has(callee.object.name)) {
+ return;
+ }
+
+ // Smoke tests pass a string literal directly; those are trivially safe.
+ const firstArg = node.arguments[0];
+ if (!firstArg) return;
+ if (firstArg.type === 'Literal' && typeof firstArg.value === 'string') return;
+ if (firstArg.type === 'TemplateLiteral' && firstArg.expressions.length === 0) return;
+
+ const receiverText = sourceCode.getText(callee.object);
+ // Receiver-text-shape skip: anything matching well-known JS APIs that
+ // happen to have a `.parse()` shape but aren't tree-sitter.
+ if (
+ /^(JSON|URL|marked|Number|Math|Date|globalThis\.JSON)\b/.test(receiverText) ||
+ /\bjson\.parse\b/i.test(receiverText)
+ ) {
+ return;
+ }
+
+ context.report({
+ node,
+ messageId: 'useSafeParse',
+ data: { receiver: receiverText },
+ fix(fixer) {
+ const argsText = node.arguments.map((arg) => sourceCode.getText(arg)).join(', ');
+ return fixer.replaceText(node, `parseSourceSafe(${receiverText}, ${argsText})`);
+ },
+ });
+ },
+ };
+ },
+};
diff --git a/eslint.config.mjs b/eslint.config.mjs
index f377cba6c..e0779b71c 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -3,6 +3,15 @@ import tsParser from '@typescript-eslint/parser';
import unusedImports from 'eslint-plugin-unused-imports';
import reactHooks from 'eslint-plugin-react-hooks';
import prettierConfig from 'eslint-config-prettier';
+import requireSafeParse from './eslint-rules/require-safe-parse.mjs';
+
+// Local plugin hosting custom rules that enforce GitNexus-specific invariants
+// (currently: the Windows-SIGSEGV-safe parser entrypoint).
+const gitnexusLocalPlugin = {
+ rules: {
+ 'require-safe-parse': requireSafeParse,
+ },
+};
// Selectors that protect MCP-reachable code from corrupting the JSON-RPC
// stdio frame stream. The MCP-reachable block below uses these directly;
@@ -135,6 +144,23 @@ export default [
},
},
+ // Windows SIGSEGV protection: every tree-sitter parse in `core/` must route
+ // through parseSourceSafe. Direct `.parse(content, ...)` crashes on
+ // Windows for inputs > 32 767 chars (V8 string-conversion bug, uncatchable
+ // from JS). The rule auto-fixes the call site; the developer adds the
+ // missing import after the fix runs. Out of scope: tests (skipped by the
+ // rule), the helper itself (`safe-parse.ts`), and the `grpc-patterns/proto.ts`
+ // grammar-load smoke test (filtered by string-literal-arg skip in the rule).
+ {
+ files: ['gitnexus/src/core/**/*.ts'],
+ plugins: {
+ gitnexus: gitnexusLocalPlugin,
+ },
+ rules: {
+ 'gitnexus/require-safe-parse': 'error',
+ },
+ },
+
// React-specific rules for gitnexus-web
{
files: ['gitnexus-web/src/**/*.{ts,tsx}'],
diff --git a/eval/uv.lock b/eval/uv.lock
index fcd666e75..04b89f336 100644
--- a/eval/uv.lock
+++ b/eval/uv.lock
@@ -2278,11 +2278,11 @@ wheels = [
[[package]]
name = "urllib3"
-version = "2.6.3"
+version = "2.7.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
]
[[package]]
diff --git a/gitnexus-claude-plugin/hooks/gitnexus-hook.js b/gitnexus-claude-plugin/hooks/gitnexus-hook.js
index 7d8fbfda4..7ff03e430 100644
--- a/gitnexus-claude-plugin/hooks/gitnexus-hook.js
+++ b/gitnexus-claude-plugin/hooks/gitnexus-hook.js
@@ -14,6 +14,8 @@
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
+const { acquireHookSlot } = require('./hook-lock.js');
+const { hasGitNexusDbLockedByGitNexusServer } = require('./hook-db-lock-probe.cjs');
/**
* Read JSON input from stdin synchronously.
@@ -102,6 +104,28 @@ function findGitNexusDir(startDir) {
return null;
}
+function hasGitNexusServerOwner(gitNexusDir) {
+ return hasGitNexusDbLockedByGitNexusServer(path.join(gitNexusDir, 'lbug'), process.pid);
+}
+
+function extractAugmentContext(stderr) {
+ const output = (stderr || '').trim();
+ const marker = output.indexOf('[GitNexus]');
+ const debug = process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true';
+ if (debug && output.length > 0) {
+ // Emit the FULL discarded prefix (everything before the marker, or all of
+ // it when no marker is present) so suppressed diagnostics — LadybugDB lock
+ // warnings, parser errors, etc. — remain recoverable on the hook's own
+ // stderr. The untruncated payload lets operators see exactly what was
+ // filtered out instead of a 180-char JSON-quoted preview.
+ const discarded = marker === -1 ? output : output.slice(0, marker).trim();
+ if (discarded.length > 0) {
+ process.stderr.write(`[GitNexus hook] augment stderr discarded prefix:\n${discarded}\n`);
+ }
+ }
+ return marker === -1 ? '' : output.slice(marker).trim();
+}
+
/**
* Extract search pattern from tool input.
*/
@@ -169,6 +193,15 @@ function extractPattern(toolName, toolInput) {
*/
function runGitNexusCli(args, cwd, timeout) {
const isWin = process.platform === 'win32';
+ const hookCli = process.env.GITNEXUS_HOOK_CLI_PATH;
+ if (hookCli !== undefined && String(hookCli).trim() && fs.existsSync(String(hookCli))) {
+ return spawnSync(process.execPath, [String(hookCli), ...args], {
+ encoding: 'utf-8',
+ timeout,
+ cwd,
+ stdio: ['pipe', 'pipe', 'pipe'],
+ });
+ }
// Detect whether 'gitnexus' is on PATH (cheap check, no execution)
let useDirectBinary = false;
@@ -217,7 +250,8 @@ function sendHookResponse(hookEventName, message) {
function handlePreToolUse(input) {
const cwd = input.cwd || process.cwd();
if (!path.isAbsolute(cwd)) return;
- if (!findGitNexusDir(cwd)) return;
+ const gitNexusDir = findGitNexusDir(cwd);
+ if (!gitNexusDir) return;
const toolName = input.tool_name || '';
const toolInput = input.tool_input || {};
@@ -226,19 +260,28 @@ function handlePreToolUse(input) {
const pattern = extractPattern(toolName, toolInput);
if (!pattern || pattern.length < 3) return;
+ if (hasGitNexusServerOwner(gitNexusDir)) {
+ process.stderr.write('[GitNexus] augment skipped: MCP server owns DB\n');
+ return;
+ }
+
+ const release = acquireHookSlot(gitNexusDir);
+ if (!release) return;
let result = '';
try {
const child = runGitNexusCli(['augment', '--', pattern], cwd, 7000);
if (!child.error && child.status === 0) {
- result = child.stderr || '';
+ result = extractAugmentContext(child.stderr || '');
}
} catch {
/* graceful failure */
+ } finally {
+ release();
}
- if (result && result.trim()) {
- sendHookResponse('PreToolUse', result.trim());
+ if (result) {
+ sendHookResponse('PreToolUse', result);
}
}
diff --git a/gitnexus-claude-plugin/hooks/hook-db-lock-probe.cjs b/gitnexus-claude-plugin/hooks/hook-db-lock-probe.cjs
new file mode 100644
index 000000000..783cd0804
--- /dev/null
+++ b/gitnexus-claude-plugin/hooks/hook-db-lock-probe.cjs
@@ -0,0 +1,238 @@
+/**
+ * Cross-platform best-effort probe: does another process hold dbPath open
+ * with a command line that looks like a GitNexus MCP/serve server?
+ *
+ * Backends (no user-installed Sysinternals):
+ * - Linux: scan procfs under /proc (per-PID fd entries) via stat(2) (dev+inode); works without lsof;
+ * optional lsof fallback when proc scan finds nothing.
+ * - macOS / *BSD / etc.: trusted lsof + ps (absolute paths first).
+ * - Windows: Restart Manager (rstrtmgr) via bundled PowerShell script +
+ * Win32_Process for command lines; trusted powershell.exe under %SystemRoot%.
+ *
+ * Fail-open on most errors; fail-closed only on lsof ETIMEDOUT (Unix) or
+ * PowerShell ETIMEDOUT (Windows), matching the hook contract.
+ */
+
+const fs = require('fs');
+const path = require('path');
+const { spawnSync } = require('child_process');
+
+function isGitNexusServerCommand(command) {
+ const hasServerMode = /(?:^|\s)(mcp|serve)(?:\s|$)/.test(command);
+ const hasGitNexus =
+ /(?:^|[/\\\s])gitnexus(?:\.cmd)?(?:\s|$)/.test(command) ||
+ /node_modules[/\\]gitnexus[/\\]/.test(command);
+ return hasServerMode && hasGitNexus;
+}
+
+function resolveHookBinary(tool) {
+ const envKey = tool === 'lsof' ? 'GITNEXUS_HOOK_LSOF_PATH' : 'GITNEXUS_HOOK_PS_PATH';
+ const fromEnv = process.env[envKey];
+ if (fromEnv && String(fromEnv).trim() && fs.existsSync(String(fromEnv))) {
+ return String(fromEnv);
+ }
+ const candidates =
+ tool === 'lsof'
+ ? ['/usr/bin/lsof', '/usr/sbin/lsof', '/sbin/lsof', tool]
+ : ['/bin/ps', '/usr/bin/ps', tool];
+ for (const candidate of candidates) {
+ if (candidate === tool) return tool;
+ try {
+ if (fs.existsSync(candidate)) return candidate;
+ } catch {
+ /* ignore */
+ }
+ }
+ return tool;
+}
+
+function resolveWindowsPowerShellPath() {
+ const fromEnv = process.env.GITNEXUS_HOOK_POWERSHELL_PATH;
+ if (fromEnv && String(fromEnv).trim() && fs.existsSync(String(fromEnv).trim())) {
+ return String(fromEnv).trim();
+ }
+ const root = process.env.SystemRoot || 'C:\\Windows';
+ const ps = path.join(root, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
+ if (fs.existsSync(ps)) return ps;
+ const psWow = path.join(root, 'SysWOW64', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
+ if (fs.existsSync(psWow)) return psWow;
+ return 'powershell.exe';
+}
+
+// Sentinel:
+// undefined = not loaded yet (try the read)
+// string = encoded PowerShell command (successful load)
+// null = load attempted and failed (do not retry; warning already emitted)
+let windowsRmListPsEncodedCommandCache;
+let windowsRmListPsLoadFailureWarned = false;
+function getWindowsRmListEncodedCommand() {
+ if (windowsRmListPsEncodedCommandCache !== undefined) {
+ return windowsRmListPsEncodedCommandCache;
+ }
+ try {
+ const ps1Path = path.join(__dirname, 'win-rm-list-json.ps1');
+ const src = fs
+ .readFileSync(ps1Path, 'utf8')
+ .replace(/^\uFEFF/, '')
+ .replace(/\r\n/g, '\n');
+ windowsRmListPsEncodedCommandCache = Buffer.from(src, 'utf16le').toString('base64');
+ } catch (err) {
+ windowsRmListPsEncodedCommandCache = null;
+ if (
+ !windowsRmListPsLoadFailureWarned &&
+ (process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true')
+ ) {
+ windowsRmListPsLoadFailureWarned = true;
+ const msg = err && err.message ? String(err.message).slice(0, 200) : 'unknown';
+ process.stderr.write(`[GitNexus hook] win-rm-list-json.ps1 load failed: ${msg}\n`);
+ }
+ }
+ return windowsRmListPsEncodedCommandCache;
+}
+
+function hasGitNexusServerOwnerWindows(dbPathAbs, myPid) {
+ const encoded = getWindowsRmListEncodedCommand();
+ if (!encoded) return false;
+ const psExe = resolveWindowsPowerShellPath();
+ const r = spawnSync(
+ psExe,
+ [
+ '-NoProfile',
+ '-NonInteractive',
+ '-ExecutionPolicy',
+ 'Bypass',
+ '-STA',
+ '-EncodedCommand',
+ encoded,
+ ],
+ {
+ encoding: 'utf-8',
+ timeout: 6000,
+ stdio: ['ignore', 'pipe', 'ignore'],
+ env: { ...process.env, GITNEXUS_HOOK_RM_TARGET: dbPathAbs },
+ },
+ );
+ // ETIMEDOUT means the PowerShell probe didn't return in time; treat as 'unresponsive process holds DB' → fail-closed (skip augment).
+ if (r.error) return r.error.code === 'ETIMEDOUT';
+ if (r.status !== 0) return false;
+ let rows;
+ try {
+ rows = JSON.parse(String(r.stdout || '').trim() || '[]');
+ } catch {
+ return false;
+ }
+ if (!Array.isArray(rows)) return false;
+ for (const row of rows) {
+ const procId = Number(row.pid);
+ const cmd = String(row.cmd || '');
+ if (!Number.isFinite(procId) || procId === myPid) continue;
+ if (isGitNexusServerCommand(cmd)) return true;
+ }
+ return false;
+}
+
+function readLinuxCmdline(pidStr) {
+ try {
+ return fs.readFileSync(`/proc/${pidStr}/cmdline`, 'utf8').replace(/\0+/g, ' ').trim();
+ } catch {
+ return '';
+ }
+}
+
+function linuxProcScanFindGitNexusServer(dbPathAbs, myPid) {
+ const raw = process.env.GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS;
+ const budget = Number(raw && String(raw).trim()) ? Number.parseInt(String(raw), 10) : 1200;
+ const start = Date.now();
+ let targetStat;
+ try {
+ targetStat = fs.statSync(dbPathAbs);
+ } catch {
+ return false;
+ }
+ let procEntries;
+ try {
+ procEntries = fs.readdirSync('/proc', { withFileTypes: true });
+ } catch {
+ return false;
+ }
+ for (const ent of procEntries) {
+ if (Date.now() - start > budget) return false;
+ if (!ent.isDirectory() || !/^\d+$/.test(ent.name)) continue;
+ const pid = Number.parseInt(ent.name, 10);
+ if (!Number.isFinite(pid) || pid === myPid) continue;
+ const fdDir = path.join('/proc', ent.name, 'fd');
+ let fds;
+ try {
+ fds = fs.readdirSync(fdDir);
+ } catch {
+ continue;
+ }
+ let holds = false;
+ for (const fd of fds) {
+ if (Date.now() - start > budget) return false;
+ try {
+ const st = fs.statSync(path.join(fdDir, fd));
+ if (st.dev === targetStat.dev && st.ino === targetStat.ino) {
+ holds = true;
+ break;
+ }
+ } catch {
+ /* ignore */
+ }
+ }
+ if (!holds) continue;
+ if (isGitNexusServerCommand(readLinuxCmdline(ent.name))) return true;
+ }
+ return false;
+}
+
+function unixLsofPsFindGitNexusServer(dbPathAbs, myPid) {
+ const lsofPath = resolveHookBinary('lsof');
+ const lsof = spawnSync(lsofPath, ['-nP', '-t', '--', dbPathAbs], {
+ encoding: 'utf-8',
+ timeout: 1000,
+ stdio: ['ignore', 'pipe', 'ignore'],
+ });
+ if (lsof.error) return lsof.error.code === 'ETIMEDOUT';
+
+ const pids = (lsof.stdout || '').split(/\s+/).filter(Boolean);
+ const psPath = resolveHookBinary('ps');
+ for (const pid of pids) {
+ if (Number(pid) === myPid) continue;
+ const ps = spawnSync(psPath, ['-p', pid, '-o', 'command='], {
+ encoding: 'utf-8',
+ timeout: 500,
+ stdio: ['ignore', 'pipe', 'ignore'],
+ });
+ if (ps.error) {
+ if (ps.error.code === 'ETIMEDOUT') return true;
+ continue;
+ }
+ if (isGitNexusServerCommand(ps.stdout || '')) return true;
+ }
+ return false;
+}
+
+/**
+ * @param {string} dbPath Absolute or relative path to the DB file (e.g. .../lbug).
+ * @param {number} myPid Current process PID (hook runner), excluded from matches.
+ */
+function hasGitNexusDbLockedByGitNexusServer(dbPath, myPid) {
+ if (!fs.existsSync(dbPath)) return false;
+ const dbPathAbs = path.resolve(dbPath);
+
+ if (process.platform === 'win32') {
+ return hasGitNexusServerOwnerWindows(dbPathAbs, myPid);
+ }
+
+ if (process.platform === 'linux') {
+ if (linuxProcScanFindGitNexusServer(dbPathAbs, myPid)) return true;
+ return unixLsofPsFindGitNexusServer(dbPathAbs, myPid);
+ }
+
+ return unixLsofPsFindGitNexusServer(dbPathAbs, myPid);
+}
+
+module.exports = {
+ hasGitNexusDbLockedByGitNexusServer,
+};
diff --git a/gitnexus-claude-plugin/hooks/hook-lock.js b/gitnexus-claude-plugin/hooks/hook-lock.js
new file mode 100644
index 000000000..759856384
--- /dev/null
+++ b/gitnexus-claude-plugin/hooks/hook-lock.js
@@ -0,0 +1,119 @@
+const fs = require('fs');
+const path = require('path');
+
+const HOOK_LOCK_SUBDIR = '.hook-locks';
+const HOOK_LOCK_MAX_INFLIGHT = 3;
+const HOOK_LOCK_STALE_MS = 30000;
+
+function acquireHookSlot(gitNexusDir) {
+ const lockDir = path.join(gitNexusDir, HOOK_LOCK_SUBDIR);
+ try {
+ fs.mkdirSync(lockDir, { recursive: true });
+ } catch {
+ // Cannot create lock dir (read-only fs, cross-user perm denial, out of
+ // inodes, etc.) — fail closed by returning null. Caller skips augment.
+ // Fail-open here would let N concurrent hooks all proceed unguarded and
+ // reintroduce the #1486 fan-out the guard exists to prevent.
+ return null;
+ }
+
+ const myPidStr = String(process.pid);
+
+ for (let slot = 0; slot < HOOK_LOCK_MAX_INFLIGHT; slot++) {
+ const slotPath = path.join(lockDir, `slot-${slot}.lock`);
+ for (let attempt = 0; attempt < 2; attempt++) {
+ try {
+ fs.writeFileSync(slotPath, myPidStr, { flag: 'wx' });
+ let released = false;
+ const release = () => {
+ if (released) return;
+ released = true;
+ try {
+ // Only unlink if we still own the slot. If we appeared stale and
+ // another hook took over, the file now belongs to it — leave alone.
+ const content = fs.readFileSync(slotPath, 'utf-8').trim();
+ if (content === myPidStr) fs.unlinkSync(slotPath);
+ } catch {
+ /* already removed or unreadable */
+ }
+ };
+ process.on('exit', release);
+ return release;
+ } catch {
+ // Slot exists. Decide whether to take it over.
+ // Open once and inspect mtime + content via the same fd so there's
+ // no TOCTOU between the metadata check and the content read
+ // (codeql js/file-system-race).
+ let fd;
+ try {
+ fd = fs.openSync(slotPath, 'r');
+ } catch {
+ continue; // Vanished between EEXIST and open — retry this slot.
+ }
+ let isLive = false;
+ let mtimeMs = Date.now();
+ try {
+ mtimeMs = fs.fstatSync(fd).mtimeMs;
+ const buf = Buffer.alloc(32);
+ const n = fs.readSync(fd, buf, 0, 32, 0);
+ const ownerStr = buf.slice(0, n).toString('utf-8').trim();
+ if (ownerStr === '') {
+ // Owner created the file but hasn't written its PID yet. The
+ // wx open+write window is microseconds; give it the benefit
+ // of the doubt and treat as live.
+ isLive = true;
+ } else {
+ const owner = Number.parseInt(ownerStr, 10);
+ if (Number.isFinite(owner) && owner > 0) {
+ try {
+ process.kill(owner, 0);
+ isLive = true;
+ } catch (e) {
+ // ESRCH = process gone → treat as dead. EPERM = process exists
+ // but owned by another user (cross-user lock dir) → still alive,
+ // keep the slot. Anything else: be conservative, assume alive.
+ if (e && e.code === 'ESRCH') {
+ isLive = false;
+ } else {
+ isLive = true;
+ }
+ }
+ }
+ }
+ } catch {
+ /* unreadable — treat as dead */
+ } finally {
+ try {
+ fs.closeSync(fd);
+ } catch {
+ /* already closed */
+ }
+ }
+ // For slots younger than HOOK_LOCK_STALE_MS, PID-liveness wins —
+ // a slow-but-alive hook is never wrongly evicted. For older slots,
+ // age is the final arbiter as a defense against PID reuse on long-
+ // abandoned slots. 30s >> the 7s augment timeout, so a healthy run
+ // never crosses this threshold.
+ if (isLive && Date.now() - mtimeMs > HOOK_LOCK_STALE_MS) {
+ isLive = false;
+ }
+ if (isLive) break; // Try the next slot.
+ try {
+ fs.unlinkSync(slotPath);
+ } catch {
+ /* another hook beat us to it — retry will hit EEXIST */
+ }
+ // Loop and retry this slot.
+ }
+ }
+ }
+
+ return null;
+}
+
+module.exports = {
+ HOOK_LOCK_SUBDIR,
+ HOOK_LOCK_MAX_INFLIGHT,
+ HOOK_LOCK_STALE_MS,
+ acquireHookSlot,
+};
diff --git a/gitnexus-claude-plugin/hooks/win-rm-list-json.ps1 b/gitnexus-claude-plugin/hooks/win-rm-list-json.ps1
new file mode 100644
index 000000000..5c1564e30
--- /dev/null
+++ b/gitnexus-claude-plugin/hooks/win-rm-list-json.ps1
@@ -0,0 +1,76 @@
+$ErrorActionPreference = 'Stop'
+$target = $env:GITNEXUS_HOOK_RM_TARGET
+if ([string]::IsNullOrWhiteSpace($target)) { Write-Output '[]'; exit 0 }
+$target = (Resolve-Path -LiteralPath $target).ProviderPath
+
+if (-not ([Management.Automation.PSTypeName]'GitNexusHookRm.Native').Type) {
+Add-Type @'
+using System;
+using System.Runtime.InteropServices;
+namespace GitNexusHookRm {
+ public static class Native {
+ public const int ErrorMoreData = 234;
+ [StructLayout(LayoutKind.Sequential, Pack = 4)]
+ public struct RM_UNIQUE_PROCESS {
+ public int dwProcessId;
+ public long ProcessStartTime;
+ }
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
+ public struct RM_PROCESS_INFO {
+ public RM_UNIQUE_PROCESS Process;
+ [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)]
+ public string strAppName;
+ [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)]
+ public string strServiceShortName;
+ public uint ApplicationType;
+ public uint AppStatus;
+ public uint TSSessionId;
+ public uint bRestartable;
+ }
+ [DllImport("rstrtmgr.dll", CharSet = CharSet.Unicode)]
+ public static extern int RmStartSession(out uint pSessionHandle, uint dwSessionFlags, string strSessionKey);
+ [DllImport("rstrtmgr.dll", CharSet = CharSet.Unicode)]
+ public static extern int RmRegisterResources(uint pSessionHandle, uint nFiles, string[] rgsFileNames, uint nApplications, IntPtr rgApplications, uint nServices, string[] rgsServiceNames);
+ [DllImport("rstrtmgr.dll")]
+ public static extern int RmGetList(uint dwSessionHandle, out uint pnProcInfoNeeded, ref uint pnProcInfo, [In, Out] RM_PROCESS_INFO[] rgAffectedApps, ref uint lpdwRebootReasons);
+ [DllImport("rstrtmgr.dll")]
+ public static extern int RmEndSession(uint pSessionHandle);
+ }
+}
+'@
+}
+
+$h = [uint32]0
+$key = [guid]::NewGuid().ToString('N')
+$rmErr = [GitNexusHookRm.Native]::RmStartSession([ref]$h, 0, $key)
+if ($rmErr -ne 0) { Write-Output '[]'; exit 0 }
+$files = @($target)
+$err = [GitNexusHookRm.Native]::RmRegisterResources($h, 1, $files, 0, [IntPtr]::Zero, 0, $null)
+if ($err -ne 0) {
+ [void][GitNexusHookRm.Native]::RmEndSession($h)
+ Write-Output '[]'
+ exit 0
+}
+$need = [uint32]0
+$n = [uint32]0
+$reboot = [uint32]0
+$err = [GitNexusHookRm.Native]::RmGetList($h, [ref]$need, [ref]$n, $null, [ref]$reboot)
+if ($err -ne [GitNexusHookRm.Native]::ErrorMoreData) {
+ [void][GitNexusHookRm.Native]::RmEndSession($h)
+ Write-Output '[]'
+ exit 0
+}
+$n = $need
+$buf = New-Object GitNexusHookRm.Native+RM_PROCESS_INFO[] ([int]$n)
+$err = [GitNexusHookRm.Native]::RmGetList($h, [ref]$need, [ref]$n, $buf, [ref]$reboot)
+[void][GitNexusHookRm.Native]::RmEndSession($h)
+if ($err -ne 0) { Write-Output '[]'; exit 0 }
+
+$out = @()
+for ($i = 0; $i -lt [int]$n; $i++) {
+ $procId = $buf[$i].Process.dwProcessId
+ $p = Get-CimInstance -ClassName Win32_Process -Filter "ProcessId=$procId" -ErrorAction SilentlyContinue
+ $cmd = if ($p) { $p.CommandLine } else { '' }
+ $out += [PSCustomObject]@{ pid = [int]$procId; cmd = $cmd }
+}
+ConvertTo-Json -InputObject @($out) -Compress
diff --git a/gitnexus-claude-plugin/skills/gitnexus-cli/SKILL.md b/gitnexus-claude-plugin/skills/gitnexus-cli/SKILL.md
index 1c38face4..11945b8cc 100644
--- a/gitnexus-claude-plugin/skills/gitnexus-cli/SKILL.md
+++ b/gitnexus-claude-plugin/skills/gitnexus-cli/SKILL.md
@@ -62,6 +62,8 @@ Generates repository documentation from the knowledge graph using an LLM. Requir
| `--api-key ` | LLM API key |
| `--concurrency ` | Parallel LLM calls (default: 3) |
| `--gist` | Publish wiki as a public GitHub Gist |
+| `--timeout ` | Per-attempt LLM request timeout in seconds (default: 60) |
+| `--retries ` | Max LLM retry attempts per request (default: 3) |
### list — Show all indexed repos
diff --git a/gitnexus-cursor-integration/README.md b/gitnexus-cursor-integration/README.md
new file mode 100644
index 000000000..6545bacec
--- /dev/null
+++ b/gitnexus-cursor-integration/README.md
@@ -0,0 +1,91 @@
+# GitNexus — Cursor integration
+
+Static config that adds GitNexus knowledge-graph augmentation and skill files to Cursor.
+
+> **Hooks require Cursor 2.4+.** Earlier versions don't expose `postToolUse` and the hook will silently no-op.
+
+## What you get
+
+| 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/`. |
+| **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
+
+Cursor 2.4+ reads `.cursor/hooks.json` from the project root and runs hook commands with the project root as the working directory ([docs](https://cursor.com/docs/agent/hooks)).
+
+From this repo's `gitnexus-cursor-integration/hooks/`, copy the files below into your **project root**:
+
+```text
+/
+├── .cursor/
+│ └── hooks.json ← from gitnexus-cursor-integration/hooks/hooks.json
+└── hooks/
+ ├── gitnexus-hook.cjs ← from gitnexus-cursor-integration/hooks/gitnexus-hook.cjs
+ └── hook-lock.cjs ← from gitnexus-cursor-integration/hooks/hook-lock.cjs
+```
+
+Equivalent shell commands (run from your project root, with `$GITNEXUS_REPO` pointing at a clone of this repo):
+
+```bash
+mkdir -p .cursor hooks
+cp "$GITNEXUS_REPO/gitnexus-cursor-integration/hooks/hooks.json" .cursor/hooks.json
+cp "$GITNEXUS_REPO/gitnexus-cursor-integration/hooks/gitnexus-hook.cjs" hooks/gitnexus-hook.cjs
+cp "$GITNEXUS_REPO/gitnexus-cursor-integration/hooks/hook-lock.cjs" hooks/hook-lock.cjs
+```
+
+If you already have a `.cursor/hooks.json`, merge the `hooks.postToolUse` array rather than overwriting.
+
+### Verify
+
+1. Index the project: `npx gitnexus analyze`
+2. Reload the Cursor window so it picks up the new hook config.
+3. Ask the agent something that triggers `Read` / `Grep` / `Shell rg`. You should see a `[GitNexus]` block appended to the tool result.
+4. Diagnose silent no-ops by setting `GITNEXUS_DEBUG=1` in your shell environment — the hook will write Cursor's raw event payload to stderr so you can verify field names.
+
+### What's installed manually vs. automated
+
+| Step | Automated by `gitnexus setup`? |
+| -------------------------------------------------------------------- | ------------------------------ |
+| `~/.cursor/mcp.json` | ✅ |
+| `~/.cursor/skills/gitnexus/*` | ✅ |
+| `/.cursor/hooks.json` + `/hooks/gitnexus-hook.cjs` + `/hooks/hook-lock.cjs` | ❌ — copy manually (see above) |
+
+Hook install is per-project (Cursor scopes hooks to a project root); skills and MCP config are global.
+
+## Hook contract
+
+The hook receives a JSON event on stdin matching Cursor 2.4's `postToolUse` shape:
+
+```json
+{
+ "tool_name": "Grep" | "Read" | "Shell",
+ "tool_input": { /* tool-specific */ },
+ "tool_output": { /* optional */ },
+ "cwd": "/absolute/path/to/project"
+}
+```
+
+It writes augmentation context to stdout as:
+
+```json
+{ "additional_context": "[GitNexus] …" }
+```
+
+Empty stdout means "no augmentation, continue normally" — the hook never blocks the tool.
+
+### Pattern extraction per tool
+
+| Tool | Pattern source | Notes |
+| ------- | ---------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
+| `Grep` | `tool_input.query` (also `pattern`, `regex`, `q`, `search`, `searchQuery`) | Last-resort fallback: longest string value in `tool_input` (≥ 3 chars). |
+| `Read` | basename of `tool_input.target_file` (also `file_path`, `filePath`, `path`, `file`), stripped to identifier characters | `auth/handler.ts` → `handler`. |
+| `Shell` | First positional argument after `rg` / `grep` in `tool_input.command` | Best-effort tokenizer; quoted multi-word patterns (`rg "User Service"`) extract the first word only. |
+
+## Troubleshooting
+
+- **Nothing happens** — Confirm Cursor is on 2.4+ and the project root has `.cursor/hooks.json` plus both hook files at `hooks/gitnexus-hook.cjs` and `hooks/hook-lock.cjs`. Then `npx gitnexus list` to confirm the project is indexed.
+- **`gitnexus` not found** — The hook prefers a locally-resolvable `gitnexus/dist/cli/index.js` and falls back to `npx -y gitnexus`. Install globally with `npm i -g gitnexus` to skip the npx cold-start latency.
+- **Wrong pattern extracted** — Set `GITNEXUS_DEBUG=1` and run a tool call. The raw stdin payload is logged to stderr; use it to confirm Cursor's actual `tool_input` field names against the table above. If they differ, file an issue with the captured payload.
diff --git a/gitnexus-cursor-integration/hooks/augment-shell.sh b/gitnexus-cursor-integration/hooks/augment-shell.sh
deleted file mode 100644
index 48ea185b0..000000000
--- a/gitnexus-cursor-integration/hooks/augment-shell.sh
+++ /dev/null
@@ -1,50 +0,0 @@
-#!/bin/bash
-# GitNexus beforeShellExecution hook for Cursor
-# Receives JSON on stdin with { command, cwd, timeout }
-# Returns JSON on stdout with { permission, agent_message }
-#
-# Extracts search pattern from grep/rg commands, runs gitnexus augment,
-# and injects the enriched context via agent_message.
-
-INPUT=$(cat)
-
-COMMAND=$(echo "$INPUT" | jq -r '.command // empty' 2>/dev/null)
-
-if [ -z "$COMMAND" ]; then
- echo '{"permission":"allow"}'
- exit 0
-fi
-
-# Skip non-search commands
-case "$COMMAND" in
- cd\ *|npm\ *|yarn\ *|pnpm\ *|git\ commit*|git\ push*|git\ pull*|mkdir\ *|rm\ *|cp\ *|mv\ *|echo\ *|cat\ *)
- echo '{"permission":"allow"}'
- exit 0
- ;;
-esac
-
-# Extract search pattern from rg/grep commands
-PATTERN=""
-if echo "$COMMAND" | grep -qE '\brg\b'; then
- PATTERN=$(echo "$COMMAND" | sed -n "s/.*\brg\s\+\(--[^ ]*\s\+\)*['\"]\\?\([^'\";\| >]*\\).*/\2/p")
-elif echo "$COMMAND" | grep -qE '\bgrep\b'; then
- PATTERN=$(echo "$COMMAND" | sed -n "s/.*\bgrep\s\+\(-[^ ]*\s\+\)*['\"]\\?\([^'\";\| >]*\\).*/\2/p")
-fi
-
-if [ -z "$PATTERN" ] || [ ${#PATTERN} -lt 3 ]; then
- echo '{"permission":"allow"}'
- exit 0
-fi
-
-# Run gitnexus augment
-RESULT=$(npx -y gitnexus augment "$PATTERN" 2>/dev/null)
-
-if [ -n "$RESULT" ]; then
- # Escape for JSON
- ESCAPED=$(echo "$RESULT" | jq -Rs .)
- echo "{\"permission\":\"allow\",\"agent_message\":$ESCAPED}"
-else
- echo '{"permission":"allow"}'
-fi
-
-exit 0
diff --git a/gitnexus-cursor-integration/hooks/gitnexus-hook.cjs b/gitnexus-cursor-integration/hooks/gitnexus-hook.cjs
new file mode 100644
index 000000000..74c5587b3
--- /dev/null
+++ b/gitnexus-cursor-integration/hooks/gitnexus-hook.cjs
@@ -0,0 +1,266 @@
+#!/usr/bin/env node
+/**
+ * GitNexus Cursor postToolUse Hook
+ *
+ * Receives a JSON event on stdin describing a finished tool call, derives a
+ * search pattern (Grep query, Read file basename, or rg/grep arg from a Shell
+ * command), runs `gitnexus augment `, and emits the enriched context
+ * back as `{ additional_context: "..." }` so the agent sees it alongside the
+ * tool result.
+ *
+ * Replaces the legacy beforeShellExecution / augment-shell.sh pipeline:
+ * - Cross-platform (no bash, no jq — runs on Windows out of the box)
+ * - Covers Read and Grep, not just Shell rg/grep
+ *
+ * Cursor 2.4+ generic hooks: https://cursor.com/docs/agent/hooks
+ */
+
+const fs = require('fs');
+const path = require('path');
+const { spawnSync } = require('child_process');
+const { acquireHookSlot } = require('./hook-lock.cjs');
+
+function readInput() {
+ try {
+ const data = fs.readFileSync(0, 'utf-8');
+ return JSON.parse(data);
+ } catch {
+ return {};
+ }
+}
+
+function isGlobalRegistryDir(candidate) {
+ if (fs.existsSync(path.join(candidate, 'meta.json'))) return false;
+ return (
+ fs.existsSync(path.join(candidate, 'registry.json')) ||
+ fs.existsSync(path.join(candidate, 'repos'))
+ );
+}
+
+function walkForGitNexusDir(startDir) {
+ let dir = startDir;
+ for (let i = 0; i < 5; i++) {
+ const candidate = path.join(dir, '.gitnexus');
+ if (fs.existsSync(candidate)) {
+ if (!isGlobalRegistryDir(candidate)) return candidate;
+ }
+ const parent = path.dirname(dir);
+ if (parent === dir) break;
+ dir = parent;
+ }
+ return null;
+}
+
+function findCanonicalRepoRoot(cwd) {
+ try {
+ const result = spawnSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], {
+ encoding: 'utf-8',
+ timeout: 2000,
+ cwd,
+ stdio: ['pipe', 'pipe', 'pipe'],
+ });
+ if (result.error || result.status !== 0) return null;
+ const commonDir = (result.stdout || '').trim();
+ if (!commonDir || !path.isAbsolute(commonDir)) return null;
+ return path.dirname(commonDir);
+ } catch {
+ return null;
+ }
+}
+
+function findGitNexusDir(startDir) {
+ const cwd = startDir || process.cwd();
+ const fromCwd = walkForGitNexusDir(cwd);
+ if (fromCwd) return fromCwd;
+ const canonicalRoot = findCanonicalRepoRoot(cwd);
+ if (canonicalRoot && canonicalRoot !== cwd) {
+ return walkForGitNexusDir(canonicalRoot);
+ }
+ return null;
+}
+
+function parseRgGrepPattern(cmd) {
+ const tokens = cmd.split(/\s+/);
+ let foundCmd = false;
+ let skipNext = false;
+ const flagsWithValues = new Set([
+ '-e',
+ '-f',
+ '-m',
+ '-A',
+ '-B',
+ '-C',
+ '-g',
+ '--glob',
+ '-t',
+ '--type',
+ '--include',
+ '--exclude',
+ ]);
+
+ for (const token of tokens) {
+ if (skipNext) {
+ skipNext = false;
+ continue;
+ }
+ if (!foundCmd) {
+ if (/\brg$|\bgrep$/.test(token)) foundCmd = true;
+ continue;
+ }
+ if (token.startsWith('-')) {
+ if (flagsWithValues.has(token)) skipNext = true;
+ continue;
+ }
+ const cleaned = token.replace(/['"]/g, '');
+ return cleaned.length >= 3 ? cleaned : null;
+ }
+ return null;
+}
+
+/**
+ * Extract a search pattern from the tool input. Cursor 2.4 docs at
+ * https://cursor.com/docs/agent/hooks list the tool *matchers* but do not
+ * formally specify the per-tool tool_input field names, so we probe a
+ * generous set of MCP-style aliases. As a last-resort fallback for Grep
+ * (the highest-frequency search path) we also accept the longest plausible
+ * string value in tool_input. Set GITNEXUS_DEBUG=1 to log the raw payload
+ * to stderr if Cursor changes the contract and aliases stop matching.
+ */
+function pickLongestStringValue(obj) {
+ let best = null;
+ if (!obj || typeof obj !== 'object') return null;
+ for (const v of Object.values(obj)) {
+ if (typeof v === 'string' && v.length >= 3 && (!best || v.length > best.length)) {
+ best = v;
+ }
+ }
+ return best;
+}
+
+function extractPattern(toolName, toolInput) {
+ const t = (toolName || '').toLowerCase();
+
+ if (t === 'grep') {
+ const aliases = [
+ toolInput.query,
+ toolInput.pattern,
+ toolInput.regex,
+ toolInput.q,
+ toolInput.search,
+ toolInput.searchQuery,
+ ];
+ for (const a of aliases) {
+ if (typeof a === 'string' && a.length >= 3) return a;
+ }
+ // Last resort: scan tool_input for any reasonable-looking string value.
+ return pickLongestStringValue(toolInput);
+ }
+
+ if (t === 'read') {
+ const filePath =
+ toolInput.target_file ||
+ toolInput.file_path ||
+ toolInput.filePath ||
+ toolInput.path ||
+ toolInput.file ||
+ '';
+ if (!filePath) return null;
+ const base = path.basename(String(filePath), path.extname(String(filePath)));
+ const cleaned = base.replace(/[^a-zA-Z0-9_]/g, '');
+ return cleaned.length >= 3 ? cleaned : null;
+ }
+
+ if (t === 'shell') {
+ const cmd = toolInput.command || '';
+ if (!/\brg\b|\bgrep\b/.test(cmd)) return null;
+ // NOTE: parseRgGrepPattern uses split(/\s+/) and cannot handle shell
+ // quoting. `rg "User Service" src/` returns "User" (the first token
+ // after the rg/grep arg, with surrounding quotes stripped) — the
+ // multi-word pattern is intentionally not reconstructed since BM25 is
+ // already token-tolerant. Quoted single tokens (`rg "validateUser"`)
+ // work fine.
+ return parseRgGrepPattern(cmd);
+ }
+
+ return null;
+}
+
+function resolveCliPath() {
+ try {
+ return require.resolve('gitnexus/dist/cli/index.js');
+ } catch {
+ return '';
+ }
+}
+
+function runGitNexusCli(cliPath, args, cwd, timeout) {
+ const isWin = process.platform === 'win32';
+ if (cliPath) {
+ return spawnSync(process.execPath, [cliPath, ...args], {
+ encoding: 'utf-8',
+ timeout,
+ cwd,
+ stdio: ['pipe', 'pipe', 'pipe'],
+ });
+ }
+ return spawnSync(isWin ? 'npx.cmd' : 'npx', ['-y', 'gitnexus', ...args], {
+ encoding: 'utf-8',
+ timeout: timeout + 5000,
+ cwd,
+ stdio: ['pipe', 'pipe', 'pipe'],
+ });
+}
+
+function main() {
+ try {
+ const input = readInput();
+ if (process.env.GITNEXUS_DEBUG) {
+ // Echo the payload so users can capture Cursor's actual contract when
+ // diagnosing why augmentation isn't firing. Stderr only — stdout is
+ // reserved for the JSON response Cursor consumes.
+ try {
+ process.stderr.write(
+ `GitNexus Cursor hook stdin: ${JSON.stringify(input).slice(0, 500)}\n`,
+ );
+ } catch {
+ /* never let debug logging break the hook */
+ }
+ }
+ const cwd = input.cwd || process.cwd();
+ if (!path.isAbsolute(cwd)) return;
+ const gitNexusDir = findGitNexusDir(cwd);
+ if (!gitNexusDir) return;
+
+ const toolName = input.tool_name || '';
+ const toolInput = input.tool_input || {};
+
+ const pattern = extractPattern(toolName, toolInput);
+ if (!pattern || pattern.length < 3) return;
+
+ const release = acquireHookSlot(gitNexusDir);
+ if (!release) return;
+
+ const cliPath = resolveCliPath();
+ let result = '';
+ try {
+ const child = runGitNexusCli(cliPath, ['augment', '--', pattern], cwd, 7000);
+ if (!child.error && child.status === 0) {
+ result = child.stderr || '';
+ }
+ } catch {
+ /* graceful failure */
+ } finally {
+ release();
+ }
+
+ if (result && result.trim()) {
+ console.log(JSON.stringify({ additional_context: result.trim() }));
+ }
+ } catch (err) {
+ if (process.env.GITNEXUS_DEBUG) {
+ console.error('GitNexus Cursor hook error:', (err.message || '').slice(0, 200));
+ }
+ }
+}
+
+main();
diff --git a/gitnexus-cursor-integration/hooks/hook-lock.cjs b/gitnexus-cursor-integration/hooks/hook-lock.cjs
new file mode 100644
index 000000000..759856384
--- /dev/null
+++ b/gitnexus-cursor-integration/hooks/hook-lock.cjs
@@ -0,0 +1,119 @@
+const fs = require('fs');
+const path = require('path');
+
+const HOOK_LOCK_SUBDIR = '.hook-locks';
+const HOOK_LOCK_MAX_INFLIGHT = 3;
+const HOOK_LOCK_STALE_MS = 30000;
+
+function acquireHookSlot(gitNexusDir) {
+ const lockDir = path.join(gitNexusDir, HOOK_LOCK_SUBDIR);
+ try {
+ fs.mkdirSync(lockDir, { recursive: true });
+ } catch {
+ // Cannot create lock dir (read-only fs, cross-user perm denial, out of
+ // inodes, etc.) — fail closed by returning null. Caller skips augment.
+ // Fail-open here would let N concurrent hooks all proceed unguarded and
+ // reintroduce the #1486 fan-out the guard exists to prevent.
+ return null;
+ }
+
+ const myPidStr = String(process.pid);
+
+ for (let slot = 0; slot < HOOK_LOCK_MAX_INFLIGHT; slot++) {
+ const slotPath = path.join(lockDir, `slot-${slot}.lock`);
+ for (let attempt = 0; attempt < 2; attempt++) {
+ try {
+ fs.writeFileSync(slotPath, myPidStr, { flag: 'wx' });
+ let released = false;
+ const release = () => {
+ if (released) return;
+ released = true;
+ try {
+ // Only unlink if we still own the slot. If we appeared stale and
+ // another hook took over, the file now belongs to it — leave alone.
+ const content = fs.readFileSync(slotPath, 'utf-8').trim();
+ if (content === myPidStr) fs.unlinkSync(slotPath);
+ } catch {
+ /* already removed or unreadable */
+ }
+ };
+ process.on('exit', release);
+ return release;
+ } catch {
+ // Slot exists. Decide whether to take it over.
+ // Open once and inspect mtime + content via the same fd so there's
+ // no TOCTOU between the metadata check and the content read
+ // (codeql js/file-system-race).
+ let fd;
+ try {
+ fd = fs.openSync(slotPath, 'r');
+ } catch {
+ continue; // Vanished between EEXIST and open — retry this slot.
+ }
+ let isLive = false;
+ let mtimeMs = Date.now();
+ try {
+ mtimeMs = fs.fstatSync(fd).mtimeMs;
+ const buf = Buffer.alloc(32);
+ const n = fs.readSync(fd, buf, 0, 32, 0);
+ const ownerStr = buf.slice(0, n).toString('utf-8').trim();
+ if (ownerStr === '') {
+ // Owner created the file but hasn't written its PID yet. The
+ // wx open+write window is microseconds; give it the benefit
+ // of the doubt and treat as live.
+ isLive = true;
+ } else {
+ const owner = Number.parseInt(ownerStr, 10);
+ if (Number.isFinite(owner) && owner > 0) {
+ try {
+ process.kill(owner, 0);
+ isLive = true;
+ } catch (e) {
+ // ESRCH = process gone → treat as dead. EPERM = process exists
+ // but owned by another user (cross-user lock dir) → still alive,
+ // keep the slot. Anything else: be conservative, assume alive.
+ if (e && e.code === 'ESRCH') {
+ isLive = false;
+ } else {
+ isLive = true;
+ }
+ }
+ }
+ }
+ } catch {
+ /* unreadable — treat as dead */
+ } finally {
+ try {
+ fs.closeSync(fd);
+ } catch {
+ /* already closed */
+ }
+ }
+ // For slots younger than HOOK_LOCK_STALE_MS, PID-liveness wins —
+ // a slow-but-alive hook is never wrongly evicted. For older slots,
+ // age is the final arbiter as a defense against PID reuse on long-
+ // abandoned slots. 30s >> the 7s augment timeout, so a healthy run
+ // never crosses this threshold.
+ if (isLive && Date.now() - mtimeMs > HOOK_LOCK_STALE_MS) {
+ isLive = false;
+ }
+ if (isLive) break; // Try the next slot.
+ try {
+ fs.unlinkSync(slotPath);
+ } catch {
+ /* another hook beat us to it — retry will hit EEXIST */
+ }
+ // Loop and retry this slot.
+ }
+ }
+ }
+
+ return null;
+}
+
+module.exports = {
+ HOOK_LOCK_SUBDIR,
+ HOOK_LOCK_MAX_INFLIGHT,
+ HOOK_LOCK_STALE_MS,
+ acquireHookSlot,
+};
diff --git a/gitnexus-cursor-integration/hooks/hooks.json b/gitnexus-cursor-integration/hooks/hooks.json
index ede0dfbf3..9ae542c14 100644
--- a/gitnexus-cursor-integration/hooks/hooks.json
+++ b/gitnexus-cursor-integration/hooks/hooks.json
@@ -1,11 +1,11 @@
{
"version": 1,
"hooks": {
- "beforeShellExecution": [
+ "postToolUse": [
{
- "command": "./hooks/augment-shell.sh",
- "timeout": 5,
- "matcher": "\\brg\\b|\\bgrep\\b"
+ "matcher": "Shell|Read|Grep",
+ "command": "node ./hooks/gitnexus-hook.cjs",
+ "timeout": 10
}
]
}
diff --git a/gitnexus-shared/package.json b/gitnexus-shared/package.json
index 7c1e6847a..0a5d7a2db 100644
--- a/gitnexus-shared/package.json
+++ b/gitnexus-shared/package.json
@@ -10,6 +10,10 @@
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
+ },
+ "./test-helpers": {
+ "types": "./dist/test-helpers.d.ts",
+ "default": "./dist/test-helpers.js"
}
},
"scripts": {
diff --git a/gitnexus-shared/src/index.ts b/gitnexus-shared/src/index.ts
index faf136fe7..3c82658f1 100644
--- a/gitnexus-shared/src/index.ts
+++ b/gitnexus-shared/src/index.ts
@@ -143,6 +143,22 @@ export type { ScopeTree } from './scope-resolution/scope-tree.js';
export { buildPositionIndex } from './scope-resolution/position-index.js';
export type { PositionIndex } from './scope-resolution/position-index.js';
+// Resilient fetch primitives — bounded retries + per-process circuit breaker.
+// Test-only helpers (`__resetBreakerRegistry__`, `classifyOutcome`) are
+// reachable via the separate `gitnexus-shared/test-helpers` subpath; do
+// NOT add them here. Production consumers must not call them.
+export { withRetry, computeBackoffMs } from './integrations/retry.js';
+export type { RetryOptions, RetryDecision } from './integrations/retry.js';
+export { CircuitBreaker, CircuitOpenError, getBreaker } from './integrations/circuit-breaker.js';
+export type { CircuitBreakerOptions } from './integrations/circuit-breaker.js';
+export {
+ resilientFetch,
+ ResilientFetchExhaustedError,
+ RETRY_AFTER_CAP_MS,
+ parseRetryAfter,
+} from './integrations/resilient-fetch.js';
+export type { ResilientFetchOptions } from './integrations/resilient-fetch.js';
+
// Understand-Quickly registry integration (opt-in)
export {
UNDERSTAND_QUICKLY_DISPATCH_URL,
diff --git a/gitnexus-shared/src/integrations/circuit-breaker.ts b/gitnexus-shared/src/integrations/circuit-breaker.ts
new file mode 100644
index 000000000..29782a8fb
--- /dev/null
+++ b/gitnexus-shared/src/integrations/circuit-breaker.ts
@@ -0,0 +1,273 @@
+/**
+ * Per-process circuit breaker.
+ *
+ * Closed -> Open transition fires after `failureThreshold` consecutive
+ * failures. While Open, `check` throws `CircuitOpenError` until
+ * `cooldownMs` has elapsed since the breaker tripped. The first call
+ * after the cooldown enters Half-Open and consumes the *probe permit*:
+ * a recorded success returns to Closed; a recorded failure flips back
+ * to Open with a fresh timestamp.
+ *
+ * Half-open admits exactly one in-flight probe at a time. Concurrent
+ * callers attempting `check()` while a probe is outstanding receive
+ * `CircuitOpenError` with `retryAfterMs = halfOpenRetryAfterMs` (default
+ * 1000ms; configurable). This prevents the recovery-time thundering
+ * herd that defeats the breaker's "fail fast" promise.
+ *
+ * Outcome reporting splits permit-release from state-resolution:
+ * - `recordSuccess` — releases the probe permit, resets the failure
+ * counter, transitions to Closed. Reserved for true 2xx/3xx outcomes.
+ * - `recordFailure` — releases the probe permit, increments the
+ * consecutive-failure counter, transitions to Open with a fresh
+ * `openedAt` (when called from Half-Open or when the threshold
+ * trips from Closed).
+ * - `recordNeutral` — releases the probe permit, BUT leaves state and
+ * counter untouched. Used for outcomes that are neither evidence of
+ * backend health nor evidence of backend failure (caller-driven
+ * cancellation, local timeout, terminal 4xx client errors). Critical
+ * design point: if `recordNeutral` did not release the permit, a
+ * single `TimeoutError` from per-attempt `AbortSignal.timeout` would
+ * route through `recordNeutral` and permanently park the breaker in
+ * half-open until process restart. Releasing the permit while leaving
+ * state half-open keeps the "neutral doesn't claim health" semantic
+ * without creating that wedge.
+ *
+ * Pairing invariant: every successful `check()` MUST be paired with
+ * exactly one `record*()` on every code path including throws. Direct
+ * consumers should wrap the protected operation in `try/finally`:
+ *
+ * breaker.check();
+ * try {
+ * const result = await operation();
+ * breaker.recordSuccess();
+ * return result;
+ * } catch (err) {
+ * // classify err and call recordFailure / recordNeutral / etc.
+ * throw err;
+ * }
+ *
+ * `resilientFetch`'s catch-all on `fetchImpl` already satisfies this
+ * for that consumer.
+ *
+ * Atomicity model: the half-open gate relies on JavaScript event-loop
+ * single-threadedness within a synchronous `check()` body. There is no
+ * `await` inside `check()`; concurrent callers serialize on microtask
+ * order, and exactly one observes `probeInFlight === false`. Do not
+ * introduce `await` inside `check()` without revisiting the gate. If
+ * this code is ever ported to a runtime with shared-memory threads
+ * (Node `worker_threads` with `SharedArrayBuffer`, Web Workers with
+ * shared registries), the boolean must become an atomic CAS — Resilience4j
+ * and Hystrix use atomic permits *because* they run in JVM thread pools.
+ *
+ * Runtime-agnostic: depends only on a `now()` clock and standard JS —
+ * no Node-only imports. Tests inject `now` to advance the clock
+ * deterministically without `vi.useFakeTimers()`.
+ */
+
+export class CircuitOpenError extends Error {
+ override readonly name = 'CircuitOpenError';
+ /** Approximate wait time before the breaker may transition to Half-Open
+ * (or before the in-flight probe is expected to resolve). */
+ readonly retryAfterMs: number;
+
+ constructor(retryAfterMs: number, key?: string) {
+ super(
+ key
+ ? `Circuit '${key}' is open; retry in ${Math.ceil(retryAfterMs / 1000)}s`
+ : `Circuit is open; retry in ${Math.ceil(retryAfterMs / 1000)}s`,
+ );
+ this.retryAfterMs = retryAfterMs;
+ }
+}
+
+export interface CircuitBreakerOptions {
+ /** Consecutive failures required to trip Closed -> Open. */
+ failureThreshold?: number;
+ /** Milliseconds Open before the next call may probe (Half-Open). */
+ cooldownMs?: number;
+ /**
+ * Milliseconds to suggest in `CircuitOpenError.retryAfterMs` when the
+ * breaker is Half-Open with the probe permit consumed. Default 1000ms.
+ * Consumers with long-running protected ops (LLM streaming, large
+ * uploads) should raise this — the cooldown clock is no longer the
+ * right answer because cooldown has elapsed. Returning 0 invites
+ * retry storms; returning the full cooldown misleads about wait.
+ */
+ halfOpenRetryAfterMs?: number;
+ /** Optional key for error messages and registry lookups. */
+ key?: string;
+ /** Clock override — defaults to `Date.now`. Tests inject deterministic time. */
+ now?: () => number;
+}
+
+type State = 'closed' | 'open' | 'half-open';
+
+export class CircuitBreaker {
+ private readonly failureThreshold: number;
+ private readonly cooldownMs: number;
+ private readonly halfOpenRetryAfterMs: number;
+ private readonly key: string | undefined;
+ private readonly now: () => number;
+
+ private state: State = 'closed';
+ private consecutiveFailures = 0;
+ private openedAt: number | null = null;
+ /**
+ * True between a successful `check()` and the next `record*()` call
+ * during Half-Open. Gates concurrent callers from stampeding a still-
+ * recovering dependency. Boolean rather than counter — single-permit
+ * is the conservative end of the Hystrix/Resilience4j spectrum.
+ */
+ private probeInFlight = false;
+
+ constructor(opts: CircuitBreakerOptions = {}) {
+ this.failureThreshold = opts.failureThreshold ?? 3;
+ this.cooldownMs = opts.cooldownMs ?? 30_000;
+ this.halfOpenRetryAfterMs = opts.halfOpenRetryAfterMs ?? 1_000;
+ this.key = opts.key;
+ this.now = opts.now ?? (() => Date.now());
+ }
+
+ /**
+ * Throw `CircuitOpenError` if the breaker won't admit this call.
+ * Otherwise consume the half-open probe permit (if applicable) and
+ * return so the caller can attempt the protected work.
+ *
+ * Three rejection paths:
+ * 1. Open and still in cooldown → throws with `retryAfterMs` =
+ * remaining cooldown.
+ * 2. Open with cooldown elapsed AND a probe is already in flight
+ * (race: another caller transitioned to half-open and grabbed
+ * the permit on a microtask before us) → throws with
+ * `halfOpenRetryAfterMs`.
+ * 3. Half-Open with probe in flight → throws with `halfOpenRetryAfterMs`.
+ *
+ * **Pairing invariant**: every successful return from `check()` MUST
+ * be paired with exactly one `recordSuccess` / `recordFailure` /
+ * `recordNeutral` on every code path including thrown exceptions.
+ * Failing to pair leaves the probe permit consumed forever and
+ * wedges the breaker. See file-header JSDoc for the canonical
+ * try/finally pattern.
+ */
+ check(): void {
+ if (this.state === 'open' && this.openedAt !== null) {
+ const elapsed = this.now() - this.openedAt;
+ if (elapsed < this.cooldownMs) {
+ throw new CircuitOpenError(this.cooldownMs - elapsed, this.key);
+ }
+ // Cooldown elapsed — transition to Half-Open. The very next
+ // `probeInFlight` check below decides whether THIS caller gets
+ // the permit or hits the gate.
+ this.state = 'half-open';
+ }
+
+ if (this.state === 'half-open') {
+ if (this.probeInFlight) {
+ throw new CircuitOpenError(this.halfOpenRetryAfterMs, this.key);
+ }
+ this.probeInFlight = true;
+ }
+ // Closed state falls through silently.
+ }
+
+ recordSuccess(): void {
+ this.probeInFlight = false;
+ this.consecutiveFailures = 0;
+ this.state = 'closed';
+ this.openedAt = null;
+ }
+
+ recordFailure(): void {
+ this.probeInFlight = false;
+ this.consecutiveFailures += 1;
+ if (this.state === 'half-open' || this.consecutiveFailures >= this.failureThreshold) {
+ this.state = 'open';
+ this.openedAt = this.now();
+ }
+ }
+
+ /**
+ * Releases the probe permit BUT leaves state and counter untouched.
+ * Use when an attempt produced a response or error that should not
+ * influence breaker health in either direction — caller-driven aborts,
+ * local AbortSignal timeouts, terminal 4xx client errors.
+ *
+ * Why permit-release-without-state-resolution: if `recordNeutral` did
+ * not clear `probeInFlight`, a single `TimeoutError` from per-attempt
+ * `AbortSignal.timeout` (which routes through neutral classification)
+ * would permanently park the breaker in half-open. Since timeouts are
+ * an *expected* outcome under flaky-dependency conditions, the cited
+ * "per-attempt timeout bounds the stuck state" mitigation would itself
+ * be the trigger for a permanent wedge. Releasing the permit closes
+ * that loop while keeping the "neutral doesn't claim dependency
+ * health" semantic.
+ *
+ * Calling `recordSuccess` for these would erase legitimate prior
+ * failure signal; calling `recordFailure` would trip the breaker for
+ * outcomes the backend isn't responsible for.
+ */
+ recordNeutral(): void {
+ this.probeInFlight = false;
+ // State and consecutiveFailures are preserved by design.
+ }
+
+ /**
+ * Pure read — no state mutation, no permit accounting. Returns the
+ * *would-be* state at the current instant: 'half-open' if the breaker
+ * is open with cooldown elapsed (regardless of whether a probe is in
+ * flight), 'open' if open and still in cooldown, 'closed' otherwise.
+ *
+ * Inspection-only; safe to call from tests without consuming a probe
+ * permit. The implicit Open -> Half-Open transition that mutates
+ * `state` lives in `check()` only.
+ */
+ getState(): State {
+ if (this.state === 'open' && this.openedAt !== null) {
+ const elapsed = this.now() - this.openedAt;
+ if (elapsed >= this.cooldownMs) return 'half-open';
+ }
+ return this.state;
+ }
+ getConsecutiveFailures(): number {
+ return this.consecutiveFailures;
+ }
+ /** Inspection-only test accessor for the half-open probe permit. */
+ isProbeInFlight(): boolean {
+ return this.probeInFlight;
+ }
+ /** Timestamp (ms since epoch) when the breaker last transitioned to Open,
+ * or `null` if it's currently Closed. Useful for computing remaining
+ * cooldown without consuming a probe permit via `check()`. */
+ getOpenedAt(): number | null {
+ return this.openedAt;
+ }
+ /** Configured cooldown duration in milliseconds. */
+ getCooldownMs(): number {
+ return this.cooldownMs;
+ }
+}
+
+// ─── Per-process registry ────────────────────────────────────────────
+//
+// Single shared map keyed on caller-chosen strings. Used by
+// `resilient-fetch.ts` so multiple call sites targeting the same logical
+// endpoint share breaker state. Per-process only — not persisted.
+
+const registry = new Map();
+
+export function getBreaker(key: string, opts?: CircuitBreakerOptions): CircuitBreaker {
+ let breaker = registry.get(key);
+ if (!breaker) {
+ breaker = new CircuitBreaker({ ...opts, key });
+ registry.set(key, breaker);
+ }
+ return breaker;
+}
+
+/**
+ * Test-only: clear all registered breakers. Tests must call this in
+ * `beforeEach` to prevent breaker state from leaking across test cases.
+ */
+export function __resetBreakerRegistry__(): void {
+ registry.clear();
+}
diff --git a/gitnexus-shared/src/integrations/resilient-fetch.ts b/gitnexus-shared/src/integrations/resilient-fetch.ts
new file mode 100644
index 000000000..c91b9db3a
--- /dev/null
+++ b/gitnexus-shared/src/integrations/resilient-fetch.ts
@@ -0,0 +1,279 @@
+/**
+ * `resilientFetch` — fetch wrapped in retry + circuit breaker, with
+ * GitHub-flavoured retry classification baked in (Retry-After parsing,
+ * 401/403/404/422 treated as terminal client errors).
+ *
+ * Designed for the `gitnexus publish` GitHub `repository_dispatch`
+ * call, but the classification rules apply to any GitHub REST endpoint.
+ * Runtime-agnostic — no Node-only imports.
+ */
+
+import {
+ CircuitBreaker,
+ CircuitOpenError,
+ getBreaker,
+ type CircuitBreakerOptions,
+} from './circuit-breaker.js';
+import { computeBackoffMs, type RetryOptions } from './retry.js';
+
+export { CircuitOpenError };
+
+export interface ResilientFetchOptions {
+ /** Optional fetch implementation override. Defaults to `globalThis.fetch`. */
+ fetchImpl?: typeof fetch;
+ /**
+ * Logical key for the breaker. Defaults to `` of the
+ * request URL — call sites targeting the same endpoint share breaker
+ * state regardless of query-string differences.
+ */
+ breakerKey?: string;
+ /** Per-call breaker override. Used for tests and one-off configuration. */
+ breaker?: CircuitBreaker;
+ /** Tuning knobs for the breaker registered under `breakerKey`. */
+ breakerOptions?: CircuitBreakerOptions;
+ /** Tuning knobs for the retry helper. */
+ retry?: Partial> & {
+ sleep?: RetryOptions['sleep'];
+ random?: RetryOptions['random'];
+ };
+ /** Clock override propagated into Retry-After HTTP-date math and breaker. */
+ now?: () => number;
+}
+
+/** Cap on any single Retry-After wait — protects CLI from a buggy registry. */
+export const RETRY_AFTER_CAP_MS = 30_000;
+
+const DEFAULT_RETRY = {
+ maxAttempts: 3,
+ baseDelayMs: 500,
+ capDelayMs: 5_000,
+};
+
+/**
+ * Parse a `Retry-After` header value into milliseconds.
+ * Accepts either a delta-seconds integer (`"30"`) or an HTTP-date.
+ * Returns null on parse failure or negative deltas.
+ */
+export function parseRetryAfter(value: string | null, now: () => number = Date.now): number | null {
+ if (!value) return null;
+ const trimmed = value.trim();
+ if (trimmed === '') return null;
+
+ if (/^[0-9]+$/.test(trimmed)) {
+ const seconds = parseInt(trimmed, 10);
+ if (Number.isNaN(seconds) || seconds < 0) return null;
+ return seconds * 1000;
+ }
+
+ const target = Date.parse(trimmed);
+ if (Number.isNaN(target)) return null;
+ const delta = target - now();
+ return delta >= 0 ? delta : 0;
+}
+
+/** Internal: outcome classification used by the resilientFetch loop. */
+type Outcome =
+ | { kind: 'success'; resp: Response }
+ | { kind: 'terminal-client'; resp: Response } // 4xx other than 429: no retry, breaker neutral
+ | { kind: 'retryable-status'; resp: Response; afterMs: number | undefined } // 5xx, 429
+ | { kind: 'terminal-network'; err: unknown } // TimeoutError or AbortError: no retry, breaker neutral
+ | { kind: 'retryable-network'; err: unknown }; // DNS, ECONNRESET, etc.
+
+/** Exported for unit tests. */
+export function classifyOutcome(
+ result: { kind: 'error'; err: unknown } | { kind: 'response'; resp: Response },
+ now: () => number,
+): Outcome {
+ if (result.kind === 'error') {
+ // Both timer-fired aborts (`AbortSignal.timeout()` → `TimeoutError`)
+ // and caller-driven aborts (`AbortController.abort()` → `AbortError`)
+ // are terminal: retrying against an already-aborted signal would
+ // fail again immediately, and neither outcome reflects backend
+ // health. They route through the breaker's neutral path.
+ if (
+ result.err instanceof DOMException &&
+ (result.err.name === 'TimeoutError' || result.err.name === 'AbortError')
+ ) {
+ return { kind: 'terminal-network', err: result.err };
+ }
+ return { kind: 'retryable-network', err: result.err };
+ }
+ const resp = result.resp;
+ if (resp.status >= 200 && resp.status < 400) return { kind: 'success', resp };
+ if (resp.status === 429) {
+ // `resp.headers` is always present on a real `Response`, but tests
+ // sometimes stub `fetch` with a plain `{ ok, status }` object. Be
+ // defensive — a missing `Retry-After` falls through to exponential
+ // backoff, which is the correct behaviour anyway.
+ const retryAfterHeader =
+ typeof resp.headers?.get === 'function' ? resp.headers.get('Retry-After') : null;
+ const parsed = parseRetryAfter(retryAfterHeader, now);
+ return {
+ kind: 'retryable-status',
+ resp,
+ afterMs: parsed !== null ? Math.min(parsed, RETRY_AFTER_CAP_MS) : undefined,
+ };
+ }
+ if (resp.status >= 500) return { kind: 'retryable-status', resp, afterMs: undefined };
+ return { kind: 'terminal-client', resp };
+}
+
+const defaultSleep = (ms: number): Promise =>
+ new Promise((resolve) => setTimeout(resolve, ms));
+
+function defaultBreakerKey(input: string | URL): string {
+ try {
+ const url = typeof input === 'string' ? new URL(input) : input;
+ return `${url.host}${url.pathname}`;
+ } catch {
+ return String(input);
+ }
+}
+
+/** Final error thrown when retries are exhausted on a 5xx / 429. */
+export class ResilientFetchExhaustedError extends Error {
+ override readonly name = 'ResilientFetchExhaustedError';
+ constructor(public readonly response: Response) {
+ super(`Request failed after retries (HTTP ${response.status})`);
+ }
+}
+
+/**
+ * Wrap `fetch` with bounded retries and a per-process circuit breaker.
+ *
+ * Semantics:
+ * - 5xx and 429 responses are retried; 429 honors `Retry-After` (capped).
+ * - Network throws are retried unless they are `TimeoutError` DOMExceptions.
+ * - Timeouts and 4xx (other than 429) are returned/thrown without retry
+ * AND without incrementing the breaker — they reflect caller config
+ * or local network state, not registry health.
+ * - Each `fetch` call carries the caller-supplied `signal` (e.g. an
+ * `AbortSignal.timeout()`) — that timeout bounds each individual
+ * attempt, not the whole retry sequence.
+ * - When the breaker is open, throws `CircuitOpenError` synchronously
+ * without invoking `fetch`.
+ * - When retries are exhausted on a 5xx / 429, throws
+ * `ResilientFetchExhaustedError` carrying the last response.
+ *
+ * Cumulative wall-clock budget:
+ * maxAttempts × (per-attempt-timeout + capDelayMs)
+ * With defaults (3, 500ms base, 5000ms cap) and a typical 15s per-attempt
+ * timeout from the caller's signal, worst case is ~3 × (15s + 5s) = 60s.
+ * Callers that want a tighter total bound should reduce `maxAttempts` or
+ * wrap `resilientFetch` in their own outer `AbortSignal.timeout()`.
+ */
+export async function resilientFetch(
+ input: string | URL,
+ init: RequestInit | undefined,
+ opts: ResilientFetchOptions = {},
+): Promise {
+ const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
+ const now = opts.now ?? (() => Date.now());
+ const breaker =
+ opts.breaker ?? getBreaker(opts.breakerKey ?? defaultBreakerKey(input), opts.breakerOptions);
+
+ const retryConfig = {
+ maxAttempts: opts.retry?.maxAttempts ?? DEFAULT_RETRY.maxAttempts,
+ baseDelayMs: opts.retry?.baseDelayMs ?? DEFAULT_RETRY.baseDelayMs,
+ capDelayMs: opts.retry?.capDelayMs ?? DEFAULT_RETRY.capDelayMs,
+ };
+ const sleep = opts.retry?.sleep ?? defaultSleep;
+ const random = opts.retry?.random ?? Math.random;
+
+ // Fail fast on an open breaker, before invoking fetch.
+ breaker.check();
+
+ for (let attempt = 0; attempt < retryConfig.maxAttempts; attempt++) {
+ let result: { kind: 'error'; err: unknown } | { kind: 'response'; resp: Response };
+ try {
+ // CodeQL js/server-side-request-forgery — flagged because `input`
+ // is caller-supplied. Suppressed: every concrete caller passes
+ // either a hardcoded URL constant (UNDERSTAND_QUICKLY_DISPATCH_URL,
+ // OpenRouter base URL) or a value derived from configuration
+ // (env vars, saved settings, the local backend URL). User-input
+ // request fields (e.g. PR title, repo name) never flow into
+ // `input`. Validating URL shape here would push false-positive
+ // rejection onto every caller — wrong layer for the check.
+ // lgtm[js/server-side-request-forgery]
+ // codeql[js/server-side-request-forgery]
+ const resp = await fetchImpl(input, init);
+ result = { kind: 'response', resp };
+ } catch (err) {
+ result = { kind: 'error', err };
+ }
+
+ const outcome = classifyOutcome(result, now);
+
+ switch (outcome.kind) {
+ case 'success':
+ breaker.recordSuccess();
+ return outcome.resp;
+
+ case 'terminal-client':
+ // 4xx: do not count as breaker failure (the server is healthy
+ // and rejecting our request — auth, scope, or routing). But
+ // also do NOT call recordSuccess: a 401 sandwiched between
+ // 5xx responses would otherwise erase the running outage
+ // signal. The breaker's neutral path leaves state untouched.
+ breaker.recordNeutral();
+ return outcome.resp;
+
+ case 'terminal-network':
+ // Either `AbortSignal.timeout()` fired locally OR an external
+ // caller cancelled the request via AbortController. The server
+ // never had a chance to answer; this reflects the user's
+ // network or an explicit cancel, not registry health. Don't
+ // punish the breaker AND don't reset its outage signal.
+ breaker.recordNeutral();
+ throw outcome.err;
+
+ case 'retryable-status':
+ if (attempt + 1 >= retryConfig.maxAttempts) {
+ breaker.recordFailure();
+ throw new ResilientFetchExhaustedError(outcome.resp);
+ }
+ await sleep(
+ computeBackoffMs(
+ attempt,
+ retryConfig.baseDelayMs,
+ retryConfig.capDelayMs,
+ outcome.afterMs,
+ random,
+ ),
+ );
+ break;
+
+ case 'retryable-network':
+ if (attempt + 1 >= retryConfig.maxAttempts) {
+ breaker.recordFailure();
+ throw outcome.err;
+ }
+ await sleep(
+ computeBackoffMs(
+ attempt,
+ retryConfig.baseDelayMs,
+ retryConfig.capDelayMs,
+ undefined,
+ random,
+ ),
+ );
+ break;
+
+ default: {
+ // Exhaustiveness guard. If a sixth `Outcome` kind is added in
+ // future, TypeScript will refuse to assign it to `never` and
+ // this line forces the maintainer to add an explicit arm
+ // rather than silently fall through to retry/no-retry behaviour.
+ const _exhaustive: never = outcome;
+ throw new Error(`resilientFetch: unhandled outcome ${JSON.stringify(_exhaustive)}`);
+ }
+ }
+ }
+
+ // Unreachable: every iteration of the loop either returns (success
+ // / terminal-client) or throws (terminal-network / retry exhaustion).
+ // The throw is here purely so TypeScript's control-flow analysis sees
+ // the function never falls off the end without producing `Promise`.
+ /* c8 ignore next 2 */
+ throw new Error('resilientFetch: retry loop terminated unexpectedly');
+}
diff --git a/gitnexus-shared/src/integrations/retry.ts b/gitnexus-shared/src/integrations/retry.ts
new file mode 100644
index 000000000..774ca2542
--- /dev/null
+++ b/gitnexus-shared/src/integrations/retry.ts
@@ -0,0 +1,105 @@
+/**
+ * Bounded retry helper with full-jitter exponential backoff.
+ *
+ * Runtime-agnostic: depends only on `setTimeout`, `Math.random`, and the
+ * Promise machinery — no Node-only imports. Safe to consume from CLI,
+ * server, or browser callers.
+ *
+ * Pattern reference: gitnexus/src/core/embeddings/http-client.ts. This
+ * helper is the upgraded form: classification is caller-supplied (so
+ * 4xx-vs-5xx-vs-timeout decisions live with the protocol that knows
+ * them), backoff is exponential with full jitter, and an optional
+ * `afterMs` lets callers honor `Retry-After` headers.
+ */
+
+export interface RetryOptions {
+ /** Initial delay before the first retry attempt, in milliseconds. */
+ baseDelayMs: number;
+ /** Upper bound on any single delay, in milliseconds. */
+ capDelayMs: number;
+ /** Total attempts including the first call. Must be >= 1. */
+ maxAttempts: number;
+ /**
+ * Decide whether to retry after a thrown error.
+ * Return `{retry:false}` to terminate immediately and rethrow.
+ * Return `{retry:true}` to retry with exponential-backoff jitter.
+ * Return `{retry:true, afterMs}` to wait at least `afterMs` (still
+ * subject to `capDelayMs`) — used by callers parsing `Retry-After`.
+ */
+ isRetryable: (err: unknown, attempt: number) => RetryDecision;
+ /** Sleep override — defaults to `setTimeout`. Tests inject fake timers. */
+ sleep?: (ms: number) => Promise;
+ /** Random override — defaults to `Math.random`. Tests inject seeded values. */
+ random?: () => number;
+}
+
+export type RetryDecision = { retry: false } | { retry: true; afterMs?: number };
+
+const defaultSleep = (ms: number): Promise =>
+ new Promise((resolve) => setTimeout(resolve, ms));
+
+/**
+ * Compute the delay before the next retry attempt.
+ *
+ * - When the caller specifies `afterMs` (e.g., from `Retry-After`), use
+ * `min(afterMs, capDelayMs)` so a misbehaving server can't pin the
+ * client for an arbitrarily long wait.
+ * - Otherwise compute full-jitter exponential backoff:
+ * `random() * min(cap, base * 2^attempt)`. Full jitter (rather than
+ * "equal jitter") avoids retry-storm thundering herd, per AWS
+ * guidance on backoff strategies.
+ */
+export function computeBackoffMs(
+ attempt: number,
+ baseDelayMs: number,
+ capDelayMs: number,
+ afterMs: number | undefined,
+ random: () => number,
+): number {
+ if (afterMs !== undefined) {
+ return Math.min(Math.max(0, afterMs), capDelayMs);
+ }
+ const exponential = baseDelayMs * Math.pow(2, attempt);
+ const upper = Math.min(capDelayMs, exponential);
+ return Math.floor(random() * upper);
+}
+
+/**
+ * Execute `fn` with bounded retries.
+ *
+ * The classification of "retryable" is the caller's responsibility — see
+ * `resilient-fetch.ts` for the GitHub-dispatch-specific rules. This
+ * helper is the mechanical retry loop only.
+ */
+export async function withRetry(
+ fn: (attempt: number) => Promise,
+ opts: RetryOptions,
+): Promise {
+ if (opts.maxAttempts < 1) {
+ throw new Error(`withRetry: maxAttempts must be >= 1, got ${opts.maxAttempts}`);
+ }
+ const sleep = opts.sleep ?? defaultSleep;
+ const random = opts.random ?? Math.random;
+
+ let lastError: unknown;
+ for (let attempt = 0; attempt < opts.maxAttempts; attempt++) {
+ try {
+ return await fn(attempt);
+ } catch (err) {
+ lastError = err;
+ const decision = opts.isRetryable(err, attempt);
+ if (!decision.retry) throw err;
+ // Don't sleep after the final attempt.
+ if (attempt + 1 >= opts.maxAttempts) break;
+ const delayMs = computeBackoffMs(
+ attempt,
+ opts.baseDelayMs,
+ opts.capDelayMs,
+ decision.afterMs,
+ random,
+ );
+ if (delayMs > 0) await sleep(delayMs);
+ }
+ }
+ throw lastError;
+}
diff --git a/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts b/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts
index a94012a0b..f5d3dd0bf 100644
--- a/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts
+++ b/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts
@@ -833,7 +833,16 @@ function expandWildcard(
if (target === undefined) return [edge];
const names = hooks.expandsWildcardTo(edge.targetModuleScope, workspace);
- if (names.length === 0) return [];
+ if (names.length === 0) {
+ // Resolved wildcard with zero propagating names is still a real file-
+ // level dependency (e.g. a C++ header that only declares classes —
+ // `#include` is a valid IMPORTS edge, but unqualified-binding names
+ // are correctly empty since class methods require `Class::method`).
+ // Preserve the original wildcard edge so the file→file IMPORTS edge
+ // survives; downstream binding materialization sees no propagated
+ // names because the edge has no `targetExportedName`/`localName`.
+ return [edge];
+ }
const expanded: ImportEdge[] = [];
for (const name of names) {
diff --git a/gitnexus-shared/src/scope-resolution/method-dispatch-index.ts b/gitnexus-shared/src/scope-resolution/method-dispatch-index.ts
index d09e8fa89..dcaa45055 100644
--- a/gitnexus-shared/src/scope-resolution/method-dispatch-index.ts
+++ b/gitnexus-shared/src/scope-resolution/method-dispatch-index.ts
@@ -40,11 +40,27 @@ export interface MethodDispatchIndex {
readonly mroByOwnerDefId: ReadonlyMap;
/** Interfaces / traits → classes that implement them. */
readonly implsByInterfaceDefId: ReadonlyMap;
+ /**
+ * Optional parallel MRO view that EXCLUDES mixin-like augmentation
+ * (e.g., PHP traits). Populated only when the input supplies
+ * `computeExtendsOnlyMro`. Used by the super-branch dispatch in
+ * `receiver-bound-calls` so that `parent::method()` walks the
+ * inheritance chain only, not the trait-augmented one. Undefined for
+ * languages without mixin-like semantics — callers should fall back
+ * to `mroFor` when this is missing.
+ */
+ readonly extendsOnlyMroByOwnerDefId?: ReadonlyMap;
/** `mroByOwnerDefId.get`, with an empty frozen array on miss. */
mroFor(ownerDefId: DefId): readonly DefId[];
/** `implsByInterfaceDefId.get`, with an empty frozen array on miss. */
implementorsOf(interfaceDefId: DefId): readonly DefId[];
+ /**
+ * `extendsOnlyMroByOwnerDefId.get`, with an empty frozen array on miss.
+ * Undefined when `extendsOnlyMroByOwnerDefId` was not populated; callers
+ * should treat this as equivalent to `mroFor` for non-mixin languages.
+ */
+ readonly extendsOnlyMroFor?: (ownerDefId: DefId) => readonly DefId[];
}
export interface MethodDispatchInput {
@@ -81,12 +97,25 @@ export interface MethodDispatchInput {
* write-wins policy and fires at most once per unique owner.
*/
readonly implementsOf: (ownerDefId: DefId) => readonly DefId[];
+ /**
+ * Optional: return the EXTENDS-only ancestor chain for `ownerDefId`,
+ * excluding the owner itself AND any mixin-like augmentation (e.g.,
+ * PHP traits). Languages without mixin semantics leave this undefined
+ * and the index's `extendsOnlyMroByOwnerDefId` stays unpopulated.
+ *
+ * Same contract as `computeMro`: pure, deterministic, `[]` on no parents.
+ * Called at most once per unique owner (first-write-wins).
+ */
+ readonly computeExtendsOnlyMro?: (ownerDefId: DefId) => readonly DefId[];
}
// ─── Builder ────────────────────────────────────────────────────────────────
export function buildMethodDispatchIndex(input: MethodDispatchInput): MethodDispatchIndex {
const mroByOwnerDefId = new Map();
+ const extendsOnlyByOwnerDefId = input.computeExtendsOnlyMro
+ ? new Map()
+ : undefined;
const implsBuilding = new Map();
const implsSeen = new Map>();
@@ -97,6 +126,14 @@ export function buildMethodDispatchIndex(input: MethodDispatchInput): MethodDisp
const chain = input.computeMro(ownerId);
mroByOwnerDefId.set(ownerId, Object.freeze(chain.slice()));
}
+ if (
+ input.computeExtendsOnlyMro !== undefined &&
+ extendsOnlyByOwnerDefId !== undefined &&
+ !extendsOnlyByOwnerDefId.has(ownerId)
+ ) {
+ const extOnly = input.computeExtendsOnlyMro(ownerId);
+ extendsOnlyByOwnerDefId.set(ownerId, Object.freeze(extOnly.slice()));
+ }
for (const ifaceId of input.implementsOf(ownerId)) {
let seen = implsSeen.get(ifaceId);
@@ -121,7 +158,7 @@ export function buildMethodDispatchIndex(input: MethodDispatchInput): MethodDisp
implsByInterfaceDefId.set(ifaceId, Object.freeze(owners.slice()));
}
- return wrapIndex(mroByOwnerDefId, implsByInterfaceDefId);
+ return wrapIndex(mroByOwnerDefId, implsByInterfaceDefId, extendsOnlyByOwnerDefId);
}
// ─── Internal ───────────────────────────────────────────────────────────────
@@ -131,8 +168,9 @@ const EMPTY: readonly DefId[] = Object.freeze([]);
function wrapIndex(
mroByOwnerDefId: Map,
implsByInterfaceDefId: Map,
+ extendsOnlyMroByOwnerDefId: Map | undefined,
): MethodDispatchIndex {
- return {
+ const base: MethodDispatchIndex = {
mroByOwnerDefId,
implsByInterfaceDefId,
mroFor(ownerDefId: DefId): readonly DefId[] {
@@ -142,4 +180,14 @@ function wrapIndex(
return implsByInterfaceDefId.get(interfaceDefId) ?? EMPTY;
},
};
+ if (extendsOnlyMroByOwnerDefId !== undefined) {
+ return {
+ ...base,
+ extendsOnlyMroByOwnerDefId,
+ extendsOnlyMroFor(ownerDefId: DefId): readonly DefId[] {
+ return extendsOnlyMroByOwnerDefId.get(ownerDefId) ?? EMPTY;
+ },
+ };
+ }
+ return base;
}
diff --git a/gitnexus-shared/src/scope-resolution/registries/lookup-core.ts b/gitnexus-shared/src/scope-resolution/registries/lookup-core.ts
index a18ad4930..fff3e7adb 100644
--- a/gitnexus-shared/src/scope-resolution/registries/lookup-core.ts
+++ b/gitnexus-shared/src/scope-resolution/registries/lookup-core.ts
@@ -423,13 +423,30 @@ function applyArityFilter(
}
let anyCompatible = false;
+ let anyUnknown = false;
for (const state of perCandidate.values()) {
const verdict = arityFn(callsite, state.def);
state.signals.arityVerdict = verdict;
if (verdict === 'compatible') anyCompatible = true;
+ else if (verdict === 'unknown') anyUnknown = true;
}
- if (!anyCompatible) return;
+ // When ALL candidates are 'incompatible' (none compatible, none unknown),
+ // the call is genuinely arity-broken — drop every candidate so the
+ // registry returns no resolution. This matches the PHP variadic case
+ // f(int $req, ...$rest) called with zero args: every candidate definitively
+ // rejects, and emitting an edge to a definitively-rejected callable is
+ // a false positive. When some candidates are 'unknown' (missing metadata),
+ // keep the set so downstream evidence can break the tie — that's the
+ // original safety-fallback behavior.
+ if (!anyCompatible) {
+ if (!anyUnknown) {
+ for (const defId of perCandidate.keys()) {
+ perCandidate.delete(defId);
+ }
+ }
+ return;
+ }
// Filter: when at least one compatible candidate exists, drop incompatibles.
for (const [defId, state] of perCandidate) {
diff --git a/gitnexus-shared/src/scope-resolution/symbol-definition.ts b/gitnexus-shared/src/scope-resolution/symbol-definition.ts
index d07dbf38b..7f9840f5c 100644
--- a/gitnexus-shared/src/scope-resolution/symbol-definition.ts
+++ b/gitnexus-shared/src/scope-resolution/symbol-definition.ts
@@ -30,6 +30,8 @@ export interface SymbolDefinition {
returnType?: string;
/** Declared type for non-callable symbols — fields/properties (e.g. 'Address', 'List') */
declaredType?: string;
+ /** Generic/template specialization arguments for class-like symbols (e.g. ['User'], ['T*']). */
+ templateArguments?: string[];
/** Links Method/Constructor/Property to owning Class/Struct/Trait nodeId */
ownerId?: string;
}
diff --git a/gitnexus-shared/src/test-helpers.ts b/gitnexus-shared/src/test-helpers.ts
new file mode 100644
index 000000000..a92441878
--- /dev/null
+++ b/gitnexus-shared/src/test-helpers.ts
@@ -0,0 +1,13 @@
+/**
+ * Test-only helpers.
+ *
+ * Symbols here are reachable from `gitnexus-shared/test-helpers` so test
+ * suites can reset shared registries or exercise internal classifiers,
+ * but they are deliberately NOT re-exported from the main `gitnexus-shared`
+ * barrel. Production consumers should never import this module — calling
+ * `__resetBreakerRegistry__()` from a tool implementation would silently
+ * nuke every circuit breaker process-wide.
+ */
+
+export { __resetBreakerRegistry__ } from './integrations/circuit-breaker.js';
+export { classifyOutcome } from './integrations/resilient-fetch.js';
diff --git a/gitnexus-web/package-lock.json b/gitnexus-web/package-lock.json
index 7fcc0d299..cf69e1083 100644
--- a/gitnexus-web/package-lock.json
+++ b/gitnexus-web/package-lock.json
@@ -8,9 +8,9 @@
"name": "gitnexus",
"version": "0.0.0",
"dependencies": {
- "@langchain/anthropic": "^1.3.28",
+ "@langchain/anthropic": "^1.3.29",
"@langchain/core": "^1.1.44",
- "@langchain/google-genai": "^2.1.28",
+ "@langchain/google-genai": "^2.1.30",
"@langchain/langgraph": "^1.2.9",
"@langchain/ollama": "^1.2.6",
"@langchain/openai": "^1.4.5",
@@ -26,10 +26,10 @@
"graphology-layout-forceatlas2": "^0.10.1",
"graphology-layout-noverlap": "^0.4.2",
"graphology-utils": "^2.3.0",
- "langchain": "^1.3.4",
+ "langchain": "^1.3.5",
"lru-cache": "^11.2.4",
- "lucide-react": "^1.11.0",
- "mermaid": "^11.14.0",
+ "lucide-react": "^1.14.0",
+ "mermaid": "^11.15.0",
"mnemonist": "^0.39.0",
"pandemonium": "^2.4.0",
"react": "^19.2.5",
@@ -49,7 +49,7 @@
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
- "@types/dompurify": "^3.0.5",
+ "@types/dompurify": "^3.2.0",
"@types/node": "^25.6.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
@@ -60,7 +60,7 @@
"jsdom": "^29.1.1",
"tree-sitter-wasms": "^0.1.13",
"typescript": "^5.4.5",
- "vite": "^8.0.10",
+ "vite": "^8.0.11",
"vitest": "^4.1.5",
"wait-on": "^9.0.5"
},
@@ -95,9 +95,9 @@
}
},
"node_modules/@anthropic-ai/sdk": {
- "version": "0.90.0",
- "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.90.0.tgz",
- "integrity": "sha512-MzZtPabJF1b0FTDl6Z6H5ljphPwACLGP13lu8MTiB8jXaW/YXlpOp+Po2cVou3MPM5+f5toyLnul9whKCy7fBg==",
+ "version": "0.91.1",
+ "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.91.1.tgz",
+ "integrity": "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==",
"license": "MIT",
"dependencies": {
"json-schema-to-ts": "^3.1.1"
@@ -528,41 +528,10 @@
"integrity": "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==",
"license": "MIT"
},
- "node_modules/@chevrotain/cst-dts-gen": {
- "version": "12.0.0",
- "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-12.0.0.tgz",
- "integrity": "sha512-fSL4KXjTl7cDgf0B5Rip9Q05BOrYvkJV/RrBTE/bKDN096E4hN/ySpcBK5B24T76dlQ2i32Zc3PAE27jFnFrKg==",
- "license": "Apache-2.0",
- "dependencies": {
- "@chevrotain/gast": "12.0.0",
- "@chevrotain/types": "12.0.0"
- }
- },
- "node_modules/@chevrotain/gast": {
- "version": "12.0.0",
- "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-12.0.0.tgz",
- "integrity": "sha512-1ne/m3XsIT8aEdrvT33so0GUC+wkctpUPK6zU9IlOyJLUbR0rg4G7ZiApiJbggpgPir9ERy3FRjT6T7lpgetnQ==",
- "license": "Apache-2.0",
- "dependencies": {
- "@chevrotain/types": "12.0.0"
- }
- },
- "node_modules/@chevrotain/regexp-to-ast": {
- "version": "12.0.0",
- "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-12.0.0.tgz",
- "integrity": "sha512-p+EW9MaJwgaHguhoqwOtx/FwuGr+DnNn857sXWOi/mClXIkPGl3rn7hGNWvo31HA3vyeQxjqe+H36yZJwYU8cA==",
- "license": "Apache-2.0"
- },
"node_modules/@chevrotain/types": {
- "version": "12.0.0",
- "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-12.0.0.tgz",
- "integrity": "sha512-S+04vjFQKeuYw0/eW3U52LkAHQsB1ASxsPGsLPUyQgrZ2iNNibQrsidruDzjEX2JYfespXMG0eZmXlhA6z7nWA==",
- "license": "Apache-2.0"
- },
- "node_modules/@chevrotain/utils": {
- "version": "12.0.0",
- "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-12.0.0.tgz",
- "integrity": "sha512-lB59uJoaGIfOOL9knQqQRfhl9g7x8/wqFkp13zTdkRu1huG9kg6IJs1O8hqj9rs6h7orGxHJUKb+mX3rPbWGhA==",
+ "version": "11.1.2",
+ "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.2.tgz",
+ "integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==",
"license": "Apache-2.0"
},
"node_modules/@cspotcode/source-map-support": {
@@ -1396,25 +1365,25 @@
}
},
"node_modules/@langchain/anthropic": {
- "version": "1.3.28",
- "resolved": "https://registry.npmjs.org/@langchain/anthropic/-/anthropic-1.3.28.tgz",
- "integrity": "sha512-gOF8oXJL8xDdYes2KXNI9vFm/9TldBBBHOjuCdt27kganVaQKzLvTw5kV6R4mjbnFagV5CWteNH7APLZYCpdwg==",
+ "version": "1.3.29",
+ "resolved": "https://registry.npmjs.org/@langchain/anthropic/-/anthropic-1.3.29.tgz",
+ "integrity": "sha512-ep1qBIcV07bajsg3fDqMd39rYwoRLOEK/6lk+MCxlm1YB5SRoKKJAZANrblQ/4RYhZJnxf95c6BSQu8VoNbVAQ==",
"license": "MIT",
"dependencies": {
- "@anthropic-ai/sdk": "^0.90.0",
+ "@anthropic-ai/sdk": "^0.91.1",
"zod": "^3.25.76 || ^4"
},
"engines": {
"node": ">=20"
},
"peerDependencies": {
- "@langchain/core": "^1.1.42"
+ "@langchain/core": "^1.1.45"
}
},
"node_modules/@langchain/core": {
- "version": "1.1.44",
- "resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.1.44.tgz",
- "integrity": "sha512-RePW1IjGCHr9ua2vcby3aE8mOOz3EnwDZxMEGbNDT91kf14eqkJqxDXvaZFviGdcN9DTrxM5RPQNAHmwSm4tbg==",
+ "version": "1.1.45",
+ "resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.1.45.tgz",
+ "integrity": "sha512-Y/wvuglLTMKJahkl4QD9dBIdF/z/CxZJWdTfHJF/q2jtlJtoFf6Mb5JpGxZfsi3mBY6NSG941FSLTcqhCKrhBA==",
"license": "MIT",
"dependencies": {
"@cfworker/json-schema": "^4.0.2",
@@ -1433,32 +1402,18 @@
}
},
"node_modules/@langchain/google-genai": {
- "version": "2.1.28",
- "resolved": "https://registry.npmjs.org/@langchain/google-genai/-/google-genai-2.1.28.tgz",
- "integrity": "sha512-iTzNYWST8hTRqOXZdme18tq5GnCUwtrrJECE51ZCjg6Vg0mPsV44amdC+/bc+UK0+uphKWESGWs277aAyM2MlA==",
+ "version": "2.1.30",
+ "resolved": "https://registry.npmjs.org/@langchain/google-genai/-/google-genai-2.1.30.tgz",
+ "integrity": "sha512-0wKgy1NvV89fw5MwYiOOhh18SnUEH20z6MZrPV6Tj2hMAA3jAHVSLlIcCQ2mDRJo2r1aHLV8MDXhzkvD1tEHoQ==",
"license": "MIT",
"dependencies": {
- "@google/generative-ai": "^0.24.0",
- "uuid": "^11.1.0"
+ "@google/generative-ai": "^0.24.0"
},
"engines": {
"node": ">=20"
},
"peerDependencies": {
- "@langchain/core": "^1.1.41"
- }
- },
- "node_modules/@langchain/google-genai/node_modules/uuid": {
- "version": "11.1.0",
- "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
- "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
- "funding": [
- "https://github.com/sponsors/broofa",
- "https://github.com/sponsors/ctavan"
- ],
- "license": "MIT",
- "bin": {
- "uuid": "dist/esm/bin/uuid"
+ "@langchain/core": "^1.1.43"
}
},
"node_modules/@langchain/langgraph": {
@@ -1679,12 +1634,12 @@
}
},
"node_modules/@mermaid-js/parser": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.1.0.tgz",
- "integrity": "sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw==",
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.1.1.tgz",
+ "integrity": "sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw==",
"license": "MIT",
"dependencies": {
- "langium": "^4.0.0"
+ "@chevrotain/types": "~11.1.1"
}
},
"node_modules/@napi-rs/wasm-runtime": {
@@ -1744,9 +1699,9 @@
}
},
"node_modules/@oxc-project/types": {
- "version": "0.127.0",
- "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz",
- "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==",
+ "version": "0.128.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.128.0.tgz",
+ "integrity": "sha512-huv1Y/LzBJkBVHt3OlC7u0zHBW9qXf1FdD7sGmc1rXc2P1mTwHssYv7jyGx5KAACSCH+9B3Bhn6Z9luHRvf7pQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/Boshen"
@@ -1769,9 +1724,9 @@
}
},
"node_modules/@rolldown/binding-android-arm64": {
- "version": "1.0.0-rc.17",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz",
- "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==",
+ "version": "1.0.0-rc.18",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.18.tgz",
+ "integrity": "sha512-lIDyUAfD7U3+BWKzdxMbJcsYHuqXqmGz40aeRqvuAm3y5TkJSYTBW2RDrn65DJFPQqVjUAUqq5uz8urzQ8aBdQ==",
"cpu": [
"arm64"
],
@@ -1785,9 +1740,9 @@
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
- "version": "1.0.0-rc.17",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz",
- "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==",
+ "version": "1.0.0-rc.18",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.18.tgz",
+ "integrity": "sha512-apJq2ktnGp27nSInMR5Vcj8kY6xJzDAvfdIFlpDcAK/w4cDO58qVoi1YQsES/SKiFNge/6e4CUzgjfHduYqWpQ==",
"cpu": [
"arm64"
],
@@ -1801,9 +1756,9 @@
}
},
"node_modules/@rolldown/binding-darwin-x64": {
- "version": "1.0.0-rc.17",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz",
- "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==",
+ "version": "1.0.0-rc.18",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.18.tgz",
+ "integrity": "sha512-5Ofot8xbs+pxRHJqm9/9N/4sTQOvdrwEsmPE9pdLEEoAbdZtG6F2LMDfO1sp6ZAtXJuJV/21ew2srq3W8NXB5g==",
"cpu": [
"x64"
],
@@ -1817,9 +1772,9 @@
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
- "version": "1.0.0-rc.17",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz",
- "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==",
+ "version": "1.0.0-rc.18",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.18.tgz",
+ "integrity": "sha512-7h8eeOTT1eyqJyx64BFCnWZpNm486hGWt2sqeLLgDxA0xI1oGZ9H7gK1S85uNGmBhkdPwa/6reTxfFFKvIsebw==",
"cpu": [
"x64"
],
@@ -1833,9 +1788,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
- "version": "1.0.0-rc.17",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz",
- "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==",
+ "version": "1.0.0-rc.18",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.18.tgz",
+ "integrity": "sha512-eRcm/HVt9U/JFu5RKAEKwGQYtDCKWLiaH6wOnsSEp6NMBb/3Os8LgHZlNyzMpFVNmiiMFlfb2zEnebfzJrHFmg==",
"cpu": [
"arm"
],
@@ -1849,9 +1804,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
- "version": "1.0.0-rc.17",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz",
- "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==",
+ "version": "1.0.0-rc.18",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.18.tgz",
+ "integrity": "sha512-SOrT/cT4ukTmgnrEz/Hg3m7LBnuCLW9psDeMKrimRWY4I8DmnO7Lco8W2vtqPmMkbVu8iJ+g4GFLVLLOVjJ9DQ==",
"cpu": [
"arm64"
],
@@ -1865,9 +1820,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
- "version": "1.0.0-rc.17",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz",
- "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==",
+ "version": "1.0.0-rc.18",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.18.tgz",
+ "integrity": "sha512-QWjdxN1HJCpBTAcZ5N5F7wju3gVPzRzSpmGzx7na0c/1qpN9CFil+xt+l9lV/1M6/gqHSNXCiqPfwhVJPeLnug==",
"cpu": [
"arm64"
],
@@ -1881,9 +1836,9 @@
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
- "version": "1.0.0-rc.17",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz",
- "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==",
+ "version": "1.0.0-rc.18",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.18.tgz",
+ "integrity": "sha512-ugCOyj7a4d9h3q9B+wXmf6g3a68UsjGh6dob5DHevHGMwDUbhsYNbSPxJsENcIttJZ9jv7qGM2UesLw5jqIhdg==",
"cpu": [
"ppc64"
],
@@ -1897,9 +1852,9 @@
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
- "version": "1.0.0-rc.17",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz",
- "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==",
+ "version": "1.0.0-rc.18",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.18.tgz",
+ "integrity": "sha512-kKWRhbsotpXkGbcd5dllUWg5gEXcDAa8u5YnP9AV5DYNbvJHGzzuwv7dpmhc8NqKMJldl0a+x76IHbspEpEmdA==",
"cpu": [
"s390x"
],
@@ -1913,9 +1868,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
- "version": "1.0.0-rc.17",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz",
- "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==",
+ "version": "1.0.0-rc.18",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.18.tgz",
+ "integrity": "sha512-uCo8ElcCIAMyYAZyuIZ81oFkhTSIllNvUCHCAlbhlN4ji3uC28h7IIdlXyIvGO7HsuqnV9p3rD/bpH7XhIyhRw==",
"cpu": [
"x64"
],
@@ -1929,9 +1884,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
- "version": "1.0.0-rc.17",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz",
- "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==",
+ "version": "1.0.0-rc.18",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.18.tgz",
+ "integrity": "sha512-XNOQZtuE6yUIvx4rwGemwh8kpL1xvU41FXy/s9K7T/3JVcqGzo3NfKM2HrbrGgfPYGFW42f07Wk++aOC6B9NWA==",
"cpu": [
"x64"
],
@@ -1945,9 +1900,9 @@
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
- "version": "1.0.0-rc.17",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz",
- "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==",
+ "version": "1.0.0-rc.18",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.18.tgz",
+ "integrity": "sha512-tSn/kzrfa7tNOXr7sEacDBN4YsIqTyLqh45IO0nHDwtpKIDNDJr+VFojt+4klSpChxB29JLyduSsE0MKEwa65A==",
"cpu": [
"arm64"
],
@@ -1961,9 +1916,9 @@
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
- "version": "1.0.0-rc.17",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz",
- "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==",
+ "version": "1.0.0-rc.18",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.18.tgz",
+ "integrity": "sha512-+J9YGmc+czgqlhYmwun3S3O0FIZhsH8ep2456xwjAdIOmuJxM7xz4P4PtrxU+Bz17a/5bqPA8o3HAAoX0teUdg==",
"cpu": [
"wasm32"
],
@@ -1979,9 +1934,9 @@
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
- "version": "1.0.0-rc.17",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz",
- "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==",
+ "version": "1.0.0-rc.18",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.18.tgz",
+ "integrity": "sha512-zsu47DgU0FQzSwi6sU9dZoEdUv7pc1AptSEz/Z8HBg54sV0Pbs3N0+CrIbTsgiu6EyoaNN9CHboqbLaz9lhOyQ==",
"cpu": [
"arm64"
],
@@ -1995,9 +1950,9 @@
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
- "version": "1.0.0-rc.17",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz",
- "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==",
+ "version": "1.0.0-rc.18",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.18.tgz",
+ "integrity": "sha512-7H+3yqGgmnlDTRRhw/xpYY9J1kf4GC681nVc4GqKhExZTDrVVrV2tsOR9kso0fvgBdcTCcQShx4SLLoHgaLwhg==",
"cpu": [
"x64"
],
@@ -2800,13 +2755,14 @@
"license": "MIT"
},
"node_modules/@types/dompurify": {
- "version": "3.0.5",
- "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
- "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.2.0.tgz",
+ "integrity": "sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==",
+ "deprecated": "This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed.",
"dev": true,
"license": "MIT",
"dependencies": {
- "@types/trusted-types": "*"
+ "dompurify": "*"
}
},
"node_modules/@types/estree": {
@@ -2909,8 +2865,8 @@
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
- "devOptional": true,
- "license": "MIT"
+ "license": "MIT",
+ "optional": true
},
"node_modules/@types/unist": {
"version": "3.0.3",
@@ -3642,34 +3598,6 @@
"url": "https://github.com/sponsors/wooorm"
}
},
- "node_modules/chevrotain": {
- "version": "12.0.0",
- "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-12.0.0.tgz",
- "integrity": "sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ==",
- "license": "Apache-2.0",
- "dependencies": {
- "@chevrotain/cst-dts-gen": "12.0.0",
- "@chevrotain/gast": "12.0.0",
- "@chevrotain/regexp-to-ast": "12.0.0",
- "@chevrotain/types": "12.0.0",
- "@chevrotain/utils": "12.0.0"
- },
- "engines": {
- "node": ">=22.0.0"
- }
- },
- "node_modules/chevrotain-allstar": {
- "version": "0.4.1",
- "resolved": "https://registry.npmjs.org/chevrotain-allstar/-/chevrotain-allstar-0.4.1.tgz",
- "integrity": "sha512-PvVJm3oGqrveUVW2Vt/eZGeiAIsJszYweUcYwcskg9e+IubNYKKD+rHHem7A6XVO22eDAL+inxNIGAzZ/VIWlA==",
- "license": "MIT",
- "dependencies": {
- "lodash-es": "^4.17.21"
- },
- "peerDependencies": {
- "chevrotain": "^12.0.0"
- }
- },
"node_modules/chownr": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
@@ -4627,6 +4555,16 @@
"node": ">= 0.4"
}
},
+ "node_modules/es-toolkit": {
+ "version": "1.46.1",
+ "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.1.tgz",
+ "integrity": "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==",
+ "license": "MIT",
+ "workspaces": [
+ "docs",
+ "benchmarks"
+ ]
+ },
"node_modules/esbuild": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.0.tgz",
@@ -5643,53 +5581,21 @@
"integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="
},
"node_modules/langchain": {
- "version": "1.3.4",
- "resolved": "https://registry.npmjs.org/langchain/-/langchain-1.3.4.tgz",
- "integrity": "sha512-umrD+ZC6vr0Q0U1lC5PoIMMQqgnP+7QhaIdAXCeZiSX2GPVBiVR7Ed2ZR+3MPvz1yWrRtIm17wPaovkND+wOXg==",
+ "version": "1.3.5",
+ "resolved": "https://registry.npmjs.org/langchain/-/langchain-1.3.5.tgz",
+ "integrity": "sha512-QSB8TEo6G1tWupgNt1Osm8ylLLoOMq1lLw5NeijnIwRPI5BqdBjUn4/U8usbjEJJcQnbXSK2qTKgTx7zvblnBw==",
"license": "MIT",
"dependencies": {
"@langchain/langgraph": "^1.2.9",
"@langchain/langgraph-checkpoint": "^1.0.1",
"langsmith": ">=0.5.0 <1.0.0",
- "uuid": "^11.1.0",
"zod": "^3.25.76 || ^4"
},
"engines": {
"node": ">=20"
},
"peerDependencies": {
- "@langchain/core": "^1.1.41"
- }
- },
- "node_modules/langchain/node_modules/uuid": {
- "version": "11.1.0",
- "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
- "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
- "funding": [
- "https://github.com/sponsors/broofa",
- "https://github.com/sponsors/ctavan"
- ],
- "license": "MIT",
- "bin": {
- "uuid": "dist/esm/bin/uuid"
- }
- },
- "node_modules/langium": {
- "version": "4.2.2",
- "resolved": "https://registry.npmjs.org/langium/-/langium-4.2.2.tgz",
- "integrity": "sha512-JUshTRAfHI4/MF9dH2WupvjSXyn8JBuUEWazB8ZVJUtXutT0doDlAv1XKbZ1Pb5sMexa8FF4CFBc0iiul7gbUQ==",
- "license": "MIT",
- "dependencies": {
- "@chevrotain/regexp-to-ast": "~12.0.0",
- "chevrotain": "~12.0.0",
- "chevrotain-allstar": "~0.4.1",
- "vscode-languageserver": "~9.0.1",
- "vscode-languageserver-textdocument": "~1.0.11",
- "vscode-uri": "~3.1.0"
- },
- "engines": {
- "node": ">=20.10.0",
- "npm": ">=10.2.3"
+ "@langchain/core": "^1.1.42"
}
},
"node_modules/langsmith": {
@@ -6041,9 +5947,9 @@
}
},
"node_modules/lucide-react": {
- "version": "1.11.0",
- "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.11.0.tgz",
- "integrity": "sha512-UOhjdztXCgdBReRcIhsvz2siIBogfv/lhJEIViCpLt924dO+GDms9T7DNoucI23s6kEPpe988m5N0D2ajnzb2g==",
+ "version": "1.14.0",
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.14.0.tgz",
+ "integrity": "sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
@@ -6435,14 +6341,14 @@
}
},
"node_modules/mermaid": {
- "version": "11.14.0",
- "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.14.0.tgz",
- "integrity": "sha512-GSGloRsBs+JINmmhl0JDwjpuezCsHB4WGI4NASHxL3fHo3o/BRXTxhDLKnln8/Q0lRFRyDdEjmk1/d5Sn1Xz8g==",
+ "version": "11.15.0",
+ "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.15.0.tgz",
+ "integrity": "sha512-pTMbcf3rWdtLiYGpmoTjHEpeY8seiy6sR+9nD7LOs8KfUbHE4lOUAprTRqRAcWSQ6MQpdX+YEsxShtGsINtPtw==",
"license": "MIT",
"dependencies": {
"@braintree/sanitize-url": "^7.1.1",
"@iconify/utils": "^3.0.2",
- "@mermaid-js/parser": "^1.1.0",
+ "@mermaid-js/parser": "^1.1.1",
"@types/d3": "^7.4.3",
"@upsetjs/venn.js": "^2.0.0",
"cytoscape": "^3.33.1",
@@ -6453,27 +6359,14 @@
"dagre-d3-es": "7.0.14",
"dayjs": "^1.11.19",
"dompurify": "^3.3.1",
+ "es-toolkit": "^1.45.1",
"katex": "^0.16.25",
"khroma": "^2.1.0",
- "lodash-es": "^4.17.23",
"marked": "^16.3.0",
"roughjs": "^4.6.6",
"stylis": "^4.3.6",
"ts-dedent": "^2.2.0",
- "uuid": "^11.1.0"
- }
- },
- "node_modules/mermaid/node_modules/uuid": {
- "version": "11.1.0",
- "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
- "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
- "funding": [
- "https://github.com/sponsors/broofa",
- "https://github.com/sponsors/ctavan"
- ],
- "license": "MIT",
- "bin": {
- "uuid": "dist/esm/bin/uuid"
+ "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0"
}
},
"node_modules/micromark": {
@@ -7229,9 +7122,9 @@
}
},
"node_modules/nanoid": {
- "version": "3.3.11",
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
- "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "version": "3.3.12",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
+ "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
"funding": [
{
"type": "github",
@@ -7608,9 +7501,9 @@
}
},
"node_modules/postcss": {
- "version": "8.5.10",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
- "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
+ "version": "8.5.14",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
+ "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
"funding": [
{
"type": "opencollective",
@@ -7960,13 +7853,13 @@
"license": "Unlicense"
},
"node_modules/rolldown": {
- "version": "1.0.0-rc.17",
- "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz",
- "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==",
+ "version": "1.0.0-rc.18",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.18.tgz",
+ "integrity": "sha512-phmyKBpuBdRYDf4hgyynGAYn/rDDe+iZXKVJ7WX5b1zQzpLkP5oJRPGsfJuHdzPMlyyEO/4sPW6yfSx2gf7lVg==",
"license": "MIT",
"dependencies": {
- "@oxc-project/types": "=0.127.0",
- "@rolldown/pluginutils": "1.0.0-rc.17"
+ "@oxc-project/types": "=0.128.0",
+ "@rolldown/pluginutils": "1.0.0-rc.18"
},
"bin": {
"rolldown": "bin/cli.mjs"
@@ -7975,27 +7868,27 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
- "@rolldown/binding-android-arm64": "1.0.0-rc.17",
- "@rolldown/binding-darwin-arm64": "1.0.0-rc.17",
- "@rolldown/binding-darwin-x64": "1.0.0-rc.17",
- "@rolldown/binding-freebsd-x64": "1.0.0-rc.17",
- "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17",
- "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17",
- "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17",
- "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17",
- "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17",
- "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17",
- "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17",
- "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17",
- "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17",
- "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17",
- "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17"
+ "@rolldown/binding-android-arm64": "1.0.0-rc.18",
+ "@rolldown/binding-darwin-arm64": "1.0.0-rc.18",
+ "@rolldown/binding-darwin-x64": "1.0.0-rc.18",
+ "@rolldown/binding-freebsd-x64": "1.0.0-rc.18",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.18",
+ "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.18",
+ "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.18",
+ "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.18",
+ "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.18",
+ "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.18",
+ "@rolldown/binding-linux-x64-musl": "1.0.0-rc.18",
+ "@rolldown/binding-openharmony-arm64": "1.0.0-rc.18",
+ "@rolldown/binding-wasm32-wasi": "1.0.0-rc.18",
+ "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.18",
+ "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.18"
}
},
"node_modules/rolldown/node_modules/@rolldown/pluginutils": {
- "version": "1.0.0-rc.17",
- "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz",
- "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==",
+ "version": "1.0.0-rc.18",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.18.tgz",
+ "integrity": "sha512-CUY5Mnhe64xQBGZEEXQ5WyZwsc1JU3vAZLIxtrsBt3LO6UOb+C8GunVKqe9sT8NeWb4lqSaoJtp2xo6GxT1MNw==",
"license": "MIT"
},
"node_modules/roughjs": {
@@ -8715,15 +8608,15 @@
}
},
"node_modules/vite": {
- "version": "8.0.10",
- "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz",
- "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==",
+ "version": "8.0.11",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.11.tgz",
+ "integrity": "sha512-Jz1mxtUBR5xTT65VOdJZUUeoyLtqljmFkiUXhPTLZka3RDc9vpi/xXkyrnsdRcm2lIi3l3GPMnAidTsEGIj3Ow==",
"license": "MIT",
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
- "postcss": "^8.5.10",
- "rolldown": "1.0.0-rc.17",
+ "postcss": "^8.5.14",
+ "rolldown": "1.0.0-rc.18",
"tinyglobby": "^0.2.16"
},
"bin": {
@@ -8740,7 +8633,7 @@
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
- "@vitejs/devtools": "^0.1.0",
+ "@vitejs/devtools": "^0.1.18",
"esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
@@ -8888,55 +8781,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/vscode-jsonrpc": {
- "version": "8.2.0",
- "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz",
- "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==",
- "license": "MIT",
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/vscode-languageserver": {
- "version": "9.0.1",
- "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz",
- "integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==",
- "license": "MIT",
- "dependencies": {
- "vscode-languageserver-protocol": "3.17.5"
- },
- "bin": {
- "installServerIntoExtension": "bin/installServerIntoExtension"
- }
- },
- "node_modules/vscode-languageserver-protocol": {
- "version": "3.17.5",
- "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz",
- "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==",
- "license": "MIT",
- "dependencies": {
- "vscode-jsonrpc": "8.2.0",
- "vscode-languageserver-types": "3.17.5"
- }
- },
- "node_modules/vscode-languageserver-textdocument": {
- "version": "1.0.12",
- "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz",
- "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==",
- "license": "MIT"
- },
- "node_modules/vscode-languageserver-types": {
- "version": "3.17.5",
- "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz",
- "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==",
- "license": "MIT"
- },
- "node_modules/vscode-uri": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",
- "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==",
- "license": "MIT"
- },
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
diff --git a/gitnexus-web/package.json b/gitnexus-web/package.json
index 81d7fc5fa..211bb65c7 100644
--- a/gitnexus-web/package.json
+++ b/gitnexus-web/package.json
@@ -19,9 +19,9 @@
},
"dependencies": {
"gitnexus-shared": "file:../gitnexus-shared",
- "@langchain/anthropic": "^1.3.28",
+ "@langchain/anthropic": "^1.3.29",
"@langchain/core": "^1.1.44",
- "@langchain/google-genai": "^2.1.28",
+ "@langchain/google-genai": "^2.1.30",
"@langchain/langgraph": "^1.2.9",
"@langchain/ollama": "^1.2.6",
"@langchain/openai": "^1.4.5",
@@ -36,10 +36,10 @@
"graphology-layout-forceatlas2": "^0.10.1",
"graphology-layout-noverlap": "^0.4.2",
"graphology-utils": "^2.3.0",
- "langchain": "^1.3.4",
+ "langchain": "^1.3.5",
"lru-cache": "^11.2.4",
- "lucide-react": "^1.11.0",
- "mermaid": "^11.14.0",
+ "lucide-react": "^1.14.0",
+ "mermaid": "^11.15.0",
"mnemonist": "^0.39.0",
"pandemonium": "^2.4.0",
"react": "^19.2.5",
@@ -59,7 +59,7 @@
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
- "@types/dompurify": "^3.0.5",
+ "@types/dompurify": "^3.2.0",
"@types/node": "^25.6.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
@@ -70,7 +70,7 @@
"jsdom": "^29.1.1",
"tree-sitter-wasms": "^0.1.13",
"typescript": "^5.4.5",
- "vite": "^8.0.10",
+ "vite": "^8.0.11",
"vitest": "^4.1.5",
"wait-on": "^9.0.5"
}
diff --git a/gitnexus-web/src/core/llm/settings-service.ts b/gitnexus-web/src/core/llm/settings-service.ts
index 5e49cb7af..86330d2a5 100644
--- a/gitnexus-web/src/core/llm/settings-service.ts
+++ b/gitnexus-web/src/core/llm/settings-service.ts
@@ -20,6 +20,7 @@ import {
ProviderConfig,
} from './types';
import { DEFAULT_OPENROUTER_BASE_URL, DEFAULT_OLLAMA_BASE_URL } from '../../config/ui-constants';
+import { resilientFetch } from 'gitnexus-shared';
const STORAGE_KEY = 'gitnexus-llm-settings';
@@ -407,7 +408,10 @@ export const getAvailableModels = (provider: LLMProvider): string[] => {
*/
export const fetchOpenRouterModels = async (): Promise> => {
try {
- const response = await fetch(`${DEFAULT_OPENROUTER_BASE_URL}/models`);
+ const response = await resilientFetch(`${DEFAULT_OPENROUTER_BASE_URL}/models`, undefined, {
+ breakerKey: 'openrouter-models',
+ retry: { maxAttempts: 2, baseDelayMs: 500, capDelayMs: 2_000 },
+ });
if (!response.ok) throw new Error('Failed to fetch models');
const data = await response.json();
return data.data.map((model: any) => ({
diff --git a/gitnexus-web/src/services/backend-client.ts b/gitnexus-web/src/services/backend-client.ts
index ec8c1a964..e887e3901 100644
--- a/gitnexus-web/src/services/backend-client.ts
+++ b/gitnexus-web/src/services/backend-client.ts
@@ -7,6 +7,7 @@
*/
import type { GraphNode, GraphRelationship } from 'gitnexus-shared';
+import { CircuitOpenError, ResilientFetchExhaustedError, resilientFetch } from 'gitnexus-shared';
// ── Types ──────────────────────────────────────────────────────────────────
@@ -204,8 +205,32 @@ export function streamSSE(url: string, handlers: SSEHandlers): A
let _backendUrl = 'http://localhost:4747';
+/**
+ * Validate that a backend URL is a safe http:// or https:// origin before
+ * storing it as the fetch target base (CodeQL js/client-side-request-forgery).
+ *
+ * Throws if the URL uses a non-HTTP scheme (e.g. javascript:, data:, file://).
+ * All other well-formed http/https URLs are accepted — the client intentionally
+ * supports connecting to remote GitNexus servers, not just localhost.
+ */
+export function validateBackendUrl(url: string): void {
+ let parsed: URL;
+ try {
+ parsed = new URL(url);
+ } catch {
+ // Do not echo raw input — it may contain credentials.
+ throw new Error('Invalid backend URL: must be a well-formed http:// or https:// URL');
+ }
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
+ // Use parsed.protocol only (scheme), not the full URL, to avoid leaking credentials.
+ throw new Error(`Backend URL must use http:// or https:// (got ${parsed.protocol})`);
+ }
+}
+
export const setBackendUrl = (url: string): void => {
- _backendUrl = url.replace(/\/$/, '');
+ const trimmed = url.replace(/\/$/, '');
+ validateBackendUrl(trimmed);
+ _backendUrl = trimmed;
};
export const getBackendUrl = (): string => _backendUrl;
@@ -237,29 +262,91 @@ export function normalizeServerUrl(input: string): string {
const DEFAULT_TIMEOUT_MS = 30_000;
const PROBE_TIMEOUT_MS = 2_000;
+/** Idempotent HTTP methods. Other verbs (POST, PATCH, PUT, DELETE) get
+ * a single-attempt retry budget by default to avoid duplicate side
+ * effects on retry — a POST that 5xx'd may have already executed
+ * server-side. Callers that have idempotency keys or otherwise know
+ * their mutation is safe to retry can opt in via `forceRetry`. */
+const IDEMPOTENT_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
+
const fetchWithTimeout = async (
url: string,
init: RequestInit = {},
timeoutMs: number = DEFAULT_TIMEOUT_MS,
+ /**
+ * Force a retry budget on non-idempotent methods. Default false.
+ * Pass true only when the endpoint is known-idempotent (e.g. DELETE
+ * of a known-deleted resource — second call is a 404 / no-op) AND
+ * the duplicate-side-effect window is acceptable.
+ */
+ forceRetry = false,
): Promise => {
- const controller = new AbortController();
- // Merge external signal if provided
+ // Merge the external caller signal (if any) with an
+ // `AbortSignal.timeout()` so a timer-fired abort produces a
+ // `DOMException` with `name === 'TimeoutError'` — which
+ // `resilientFetch` correctly classifies as terminal-network (no
+ // retry, no breaker hit). A manual `AbortController.abort()` would
+ // produce `name === 'AbortError'` and route through the
+ // retryable-network branch, which mis-penalizes the breaker for
+ // user-side network slowness.
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
const externalSignal = init.signal;
- if (externalSignal) {
- externalSignal.addEventListener('abort', () => controller.abort());
+ const signal = externalSignal ? AbortSignal.any([timeoutSignal, externalSignal]) : timeoutSignal;
+
+ const method = (init.method ?? 'GET').toUpperCase();
+ const isIdempotent = IDEMPOTENT_METHODS.has(method);
+ const maxAttempts = isIdempotent || forceRetry ? 2 : 1;
+
+ // Key the breaker by the current backend origin so switching backend
+ // URLs (e.g. recovering from a flapping local server by pointing at
+ // a different host) gives the new origin a fresh breaker state. A
+ // single shared `'web-backend'` key would otherwise leave a user
+ // locked out for the full cooldown after one bad host trips the
+ // circuit. The malformed-URL fallback is defensive — `setBackendUrl`
+ // normalizes input, so this branch shouldn't fire in practice.
+ let breakerKey: string;
+ try {
+ breakerKey = `web-backend:${new URL(_backendUrl).origin}`;
+ } catch {
+ breakerKey = 'web-backend:invalid';
}
- const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
- const response = await fetch(url, { ...init, signal: controller.signal });
+ // Bounded retries + 5xx/429 handling are delegated to resilientFetch.
+ // Method-aware budget: idempotent verbs retry once on transient
+ // backend failures; mutations (POST/PATCH/PUT/DELETE) default to
+ // single-attempt to avoid duplicate side effects.
+ const response = await resilientFetch(
+ url,
+ { ...init, signal },
+ {
+ breakerKey,
+ retry: { maxAttempts, baseDelayMs: 250, capDelayMs: 1500 },
+ },
+ );
return response;
} catch (error: unknown) {
- if (error instanceof DOMException && error.name === 'AbortError') {
- if (externalSignal?.aborted) {
- throw new BackendError('Request aborted', 0, 'network');
- }
+ if (error instanceof CircuitOpenError) {
+ throw new BackendError(
+ `GitNexus backend at ${_backendUrl} is unhealthy; retry in ${Math.ceil(error.retryAfterMs / 1000)}s`,
+ 0,
+ 'network',
+ );
+ }
+ if (error instanceof ResilientFetchExhaustedError) {
+ // Fall through to caller — surface the raw response so assertOk
+ // can craft the BackendError with the right code.
+ return error.response;
+ }
+ if (error instanceof DOMException && error.name === 'TimeoutError') {
throw new BackendError(`Request to ${url} timed out after ${timeoutMs}ms`, 0, 'timeout');
}
+ if (error instanceof DOMException && error.name === 'AbortError') {
+ // External caller-driven cancellation — `timeoutSignal` would
+ // have surfaced as TimeoutError above, so this branch covers
+ // only the externally-aborted case.
+ throw new BackendError('Request aborted', 0, 'network');
+ }
if (error instanceof TypeError) {
throw new BackendError(
`Network error reaching GitNexus backend at ${_backendUrl}: ${error.message}`,
@@ -268,8 +355,6 @@ const fetchWithTimeout = async (
);
}
throw error;
- } finally {
- clearTimeout(timer);
}
};
diff --git a/gitnexus-web/test/unit/backend-client-retry.test.ts b/gitnexus-web/test/unit/backend-client-retry.test.ts
new file mode 100644
index 000000000..ea0fcf3a7
--- /dev/null
+++ b/gitnexus-web/test/unit/backend-client-retry.test.ts
@@ -0,0 +1,110 @@
+/**
+ * Method-aware retry budget + timeout-as-TimeoutError verification for
+ * backend-client's `fetchWithTimeout`.
+ *
+ * Closes review findings on PR #1448:
+ * - Non-idempotent POST/DELETE must NOT be retried by default —
+ * a 5xx on `startAnalyze` could otherwise start a duplicate job.
+ * - Timer-fired timeout must surface as `DOMException(name='TimeoutError')`,
+ * not `AbortError`, so resilientFetch routes it through the
+ * terminal-network branch (no retry, no breaker hit).
+ */
+
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { getBreaker } from 'gitnexus-shared';
+import { __resetBreakerRegistry__ } from 'gitnexus-shared/test-helpers';
+import { fetchRepos, setBackendUrl, startAnalyze } from '../../src/services/backend-client';
+
+const BASE = 'http://localhost:4747';
+
+describe('backend-client retry budget (method-aware)', () => {
+ beforeEach(() => {
+ __resetBreakerRegistry__();
+ setBackendUrl(BASE);
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it('GET retries once on transient 503 (idempotent verb)', async () => {
+ let n = 0;
+ const fetchMock = vi.fn(async () => {
+ n += 1;
+ if (n === 1) return new Response('boom', { status: 503 });
+ return new Response('[]', {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ });
+ });
+ vi.stubGlobal('fetch', fetchMock);
+
+ const repos = await fetchRepos();
+ expect(repos).toEqual([]);
+ // 1 retry budget on idempotent GET → 2 total fetch calls.
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ });
+
+ it('POST does NOT retry on 503 by default (non-idempotent verb)', async () => {
+ const fetchMock = vi.fn(async () => new Response('boom', { status: 503 }));
+ vi.stubGlobal('fetch', fetchMock);
+
+ await expect(startAnalyze({ path: '/tmp/repo' })).rejects.toBeTruthy();
+ // Single attempt — never duplicates a job-start POST.
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('switching backend URL after a circuit opens reaches a fresh breaker (U3)', async () => {
+ // Pre-open the breaker for host-A by directly recording 3 failures.
+ setBackendUrl('http://host-a.test:4747');
+ const aKey = 'web-backend:http://host-a.test:4747';
+ const breakerA = getBreaker(aKey);
+ breakerA.recordFailure();
+ breakerA.recordFailure();
+ breakerA.recordFailure();
+ expect(breakerA.getState()).toBe('open');
+
+ // Switch to host-B and make a request — must succeed against the
+ // new origin without tripping the host-A circuit. Under the old
+ // single-key behaviour the call would throw CircuitOpenError.
+ setBackendUrl('http://host-b.test:4747');
+ const fetchMock = vi.fn(
+ async () =>
+ new Response('[]', {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ }),
+ );
+ vi.stubGlobal('fetch', fetchMock);
+
+ const repos = await fetchRepos();
+ expect(repos).toEqual([]);
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+
+ // Host-A's breaker is still open in cooldown.
+ expect(breakerA.getState()).toBe('open');
+ // Host-B has its own (fresh) breaker.
+ const bKey = 'web-backend:http://host-b.test:4747';
+ expect(getBreaker(bKey).getState()).toBe('closed');
+ expect(getBreaker(bKey).getConsecutiveFailures()).toBe(0);
+ });
+
+ it('breaker not incremented when timeout fires (TimeoutError, not AbortError)', async () => {
+ // Reject directly with a TimeoutError DOMException, mimicking what
+ // `fetch` produces when its `AbortSignal.timeout()`-wired signal
+ // fires. The real-fetch path goes signal.reason → reject(reason);
+ // we shortcut that here so the test doesn't have to wait the
+ // 30-second default timeout.
+ const fetchMock = vi.fn(async () => {
+ throw new DOMException('aborted by timeout', 'TimeoutError');
+ });
+ vi.stubGlobal('fetch', fetchMock);
+
+ await expect(fetchRepos()).rejects.toMatchObject({ code: 'timeout' });
+
+ // The breaker must not have been penalized for a local timeout.
+ expect(getBreaker(`web-backend:${BASE}`).getConsecutiveFailures()).toBe(0);
+ // Timeout is terminal — no retry attempted.
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/gitnexus-web/test/unit/server-connection.test.ts b/gitnexus-web/test/unit/server-connection.test.ts
index f5ee43c53..e39b829a1 100644
--- a/gitnexus-web/test/unit/server-connection.test.ts
+++ b/gitnexus-web/test/unit/server-connection.test.ts
@@ -1,5 +1,11 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
-import { fetchGraph, normalizeServerUrl, setBackendUrl } from '../../src/services/backend-client';
+import {
+ fetchGraph,
+ getBackendUrl,
+ normalizeServerUrl,
+ setBackendUrl,
+ validateBackendUrl,
+} from '../../src/services/backend-client';
describe('normalizeServerUrl', () => {
it('adds http:// to localhost', () => {
@@ -165,3 +171,61 @@ describe('fetchGraph', () => {
});
});
});
+
+describe('validateBackendUrl', () => {
+ it('allows http:// URLs', () => {
+ expect(() => validateBackendUrl('http://localhost:4747')).not.toThrow();
+ expect(() => validateBackendUrl('http://127.0.0.1:4747')).not.toThrow();
+ });
+
+ it('allows https:// URLs', () => {
+ expect(() => validateBackendUrl('https://gitnexus.example.com')).not.toThrow();
+ expect(() => validateBackendUrl('https://my-server.internal:4747')).not.toThrow();
+ });
+
+ it('rejects non-http schemes', () => {
+ expect(() => validateBackendUrl('javascript:alert(1)')).toThrow('must use http:// or https://');
+ expect(() => validateBackendUrl('file:///etc/passwd')).toThrow('must use http:// or https://');
+ expect(() => validateBackendUrl('data:text/plain,evil')).toThrow(
+ 'must use http:// or https://',
+ );
+ });
+
+ it('rejects malformed URLs', () => {
+ expect(() => validateBackendUrl('not-a-url')).toThrow('Invalid backend URL');
+ });
+
+ it('does not include the raw URL in error messages (credential hygiene)', () => {
+ const urlWithCreds = 'javascript:alert("sk-secret")';
+ let msg = '';
+ try {
+ validateBackendUrl(urlWithCreds);
+ } catch (e) {
+ msg = (e as Error).message;
+ }
+ expect(msg).not.toContain('sk-secret');
+ expect(msg).not.toContain(urlWithCreds);
+ });
+});
+
+describe('setBackendUrl', () => {
+ it('accepts valid http URLs', () => {
+ expect(() => setBackendUrl('http://localhost:4747')).not.toThrow();
+ });
+
+ it('accepts valid https URLs', () => {
+ expect(() => setBackendUrl('https://my-server.example.com')).not.toThrow();
+ });
+
+ it('rejects non-http/https schemes', () => {
+ expect(() => setBackendUrl('javascript:alert(1)')).toThrow('must use http:// or https://');
+ expect(() => setBackendUrl('file:///etc/passwd')).toThrow('must use http:// or https://');
+ });
+
+ it('does not mutate _backendUrl when validation fails', () => {
+ setBackendUrl('http://localhost:4747');
+ expect(() => setBackendUrl('javascript:alert(1)')).toThrow();
+ // State must be preserved — validation must happen before the assignment
+ expect(getBackendUrl()).toBe('http://localhost:4747');
+ });
+});
diff --git a/gitnexus/CHANGELOG.md b/gitnexus/CHANGELOG.md
index ca51f0493..583ed97b3 100644
--- a/gitnexus/CHANGELOG.md
+++ b/gitnexus/CHANGELOG.md
@@ -4,6 +4,73 @@ All notable changes to GitNexus will be documented in this file.
## [Unreleased]
+## [1.6.4] - 2026-05-10
+
+### Added
+
+- **`gitnexus publish`** — opt-in command to push your indexed graph to the understand-quickly registry for shareable browsing (#1425)
+- **`IncludeExtractor` for C++** — cross-repo include tracking joins the group contract pipeline (#1156)
+- **Unreal Engine C++ support** — strips reflection macros (`UCLASS`, `UFUNCTION`, `UPROPERTY`, etc.) before tree-sitter parses, so UE projects index cleanly (#1439)
+- **Thrift contracts extractor** — group-mode contract detection for Apache Thrift IDL (#1234)
+- **Workspace extractors for Node, Python, Go, Java, Elixir** — group-mode auto-discovery of cross-package boundaries (#1260)
+- **Rust workspace cross-crate contracts** — auto-discovery of `[workspace]` member crates and their cross-crate links (#1256)
+- **Go scope-resolution hooks** — Go joins Python / C# / TypeScript on the registry-primary RFC #909 path (#1302)
+- **TypeScript registry-primary scope resolution (Ring 3)** — TypeScript fully migrated to scope-based resolution (#1050)
+- **Configurable group cross-link path exclusions** — reduces false-positive contract links in vendored / monorepo trees (#1093)
+- **MCP tool safety annotations** — every MCP tool advertises read-only / mutating semantics so hosts can prompt appropriately (#1127)
+- **`--embeddings ` opt-in cap** — bound the embeddings pass on huge graphs (closes #382, #1375)
+- **Pino structured logger** — replaces ad-hoc console output across the core with structured JSON logs (with pretty-print for TTY) (#1336)
+- **Shared resilient-fetch helper** — single retries + circuit breaker module reused by HF / Docker / publish flows (#1448)
+- **`/autofix` ChatOps button** — fork-safe PR autofix pipeline replaces the inline reviewdog flow (#1446, #1458)
+- **Automated security & vulnerability scans** in CI (#1297, #1455)
+
+### Fixed
+
+- **FTS read-only DB cluster** — hook resolves canonical repo root and guards read-only FTS ensure; missing-FTS warning is now surfaced. Closes #1255, #1287, #1170, #1449, #1440, #1216, #1438 (#1226, #1418, #1107, #1123)
+- **WAL corruption recovery** — quarantine corrupted `.wal` files instead of failing analyze; CHECKPOINT before close prevents recurrence; `safeClose` consolidates flush. Closes #1402, #1236, #1273, #1361 (#1417, #1314, #1377)
+- **Embedding download failures** — actionable HF_ENDPOINT guidance, retries, timeout, and circuit breaker; bridge `HF_ENDPOINT` to transformers.js; iterative DFS; HF cache via `os.homedir()`. Closes #1378, #1437, #1205 (#1419, #1252, #1078)
+- **Windows reliability** — pin tree-sitter-c/cpp to fix segfault, prefer `.cmd`/`.bat` from `where` output, robust LadybugDB lock acquisition for CI integration tests, surface silent finalize-skips so analyze cannot exit 0 without persisting. Closes #1242, #1427, #1447, #1468, #1400; partial #1218 (#1243, #1299, #1430, #1237, #1226, #1235)
+- **DuckDB / LadybugDB native** — bumped to 0.16.0 then 0.16.1; prevent extension install hangs; CHECKPOINT before close; WAL quarantine on corruption. Closes #1162, #1160, #273 (#1235, #1326, #1129, #1314, #1417)
+- **C# scope-resolution "Cannot add property" crashes** — generic typed properties included in context and impact, fixing crashes on Unity ECS partial structs and on properties whose name matches the class name. Closes #1426, #1465 (#1399)
+- **C# frozen-bucket regression** + scope-resolution I8 hardening — closes #1066 (#1082, #1085)
+- **Scope resolution** — same-range Module-as-parent for top-level scopes (closes #1086) (#1087); avoid variadic reference-site aggregation (#1112); skip empty scope extraction (#1100); classify Python class methods as Method (#1102)
+- **Python** — index repos with empty `__init__.py` and >32 KB files (#1163); walk ancestors for multi-segment dotted imports (#1241); deterministic multi-segment suffix fallback (#1253)
+- **TypeScript** — capture missed CALLS edges from HOF callbacks and JSX (#1175); name HOC-wrapped const declarations (`forwardRef` / `memo` / `useCallback` / `useMemo` / `observer`) (#1261); pair-with-arrow `@declaration.function` anchored on inner arrow
+- **Go** — loose equality for `Array.find()` null checks (#1384)
+- **Swift** — switched to the official prebuilt parser runtime (#1130)
+- **Server hardening cluster (U2–U8)** — JS path-injection on `/api/file` + docker-server (U2, #1322); git-clone path/CLI-injection / ReDoS hardening (U3, #1325); per-route rate limiting on FS-touching endpoints (U4, #1327); URL/regex/tag-filter sanitization (U7, #1330); ReDoS in cobol-preprocessor + rust-workspace + cross-impact resource exhaustion (U8, #1331); critical type-confusion + validation helper (#1317); rate-limit `/api/analyze` and `/api/embed` (closes #1328, #1339); IPv6 ipKeyGenerator (closes #1360, #1374); IPv4-compatible IPv6 / NAT64 SSRF bypasses in `validateGitUrl` (closes #1148, 95814847); predictable tempfile names → `crypto.randomBytes` (#1387); log-injection / http-to-file-access / client-side request forgery (#1456); pin Docker Node base images + Trivy verification + Dependabot policy (#1455)
+- **Group / contracts** — `runExactMatch` honours `.gitnexusignore` via shared `IgnoreService` (closes #1185, #1247); custom manifest links resolved against graph symbols (#1254); `IgnoreService` EACCES test under uid=0 (#1108)
+- **MCP** — close MCP server timeout via stdout discipline + cold-start friction (#1383); avoid `git` from non-repo cwd in sibling-cwd match (closes #1138, #1293); start MCP bridge correctly when using `npx` (#1114); project `tool_map` flows from handlers (#1113); parallelize staleness checks in `list_repos` (#1416)
+- **Storage / CLI** — derive registry name from canonical repo root, not worktree slug (closes #1259, #1296); `--skip-git` treats cwd as index root (#1245); keep GitNexus ignores inside `.gitnexus/` (#1248); surface silent finalize-skips so `analyze` cannot exit 0 without persisting (closes #1169, #1237); ignore global registry during staleness checks (#1141); use `os.homedir()` instead of `process.env.HOME` for HF cache dir (#1078); correct OpenCode skills install path in status message (#1386)
+- **Docker / server** — dedicated health endpoint for container healthcheck (closes #1147, #1355); HEAD probe so SSE heartbeat doesn't time out healthcheck (#1182); flush WAL after `/api/embed` so search sees new embeddings (closes #1149, #1359); platform-aware semantic fallback (#1150); skip vector index query on unsupported platforms (closes #1178, #1181); serve web UI at root path instead of 404 (#1048)
+- **Worker pool** — wait for replacement worker online before dispatch (#1324); prevent premature pool resolution in worker split-and-retry path (#1321); recover worker parse stalls (#1121); widened CI flake-tolerant timeouts (#1323, #1347, #1354)
+- **Embeddings storage** — CHECKPOINT before closing DB to prevent WAL corruption (#1314)
+- **Performance** — replace O(n³) C3 merge loop with O(n²) head-pointer algorithm (#1316)
+- **Install** — vendor tree-sitter-dart source (#1125)
+- **Git utils** — suppress stderr leak in `getCurrentCommit` and `getGitRoot` (closes #1172, #1341)
+- **Search** — load FTS during core DB init (#1123); create FTS indexes during `analyze` (#1107); surface warning when FTS indexes are missing (#1418)
+- **Hooks** — clarify `PostToolUse` hook is notification-only, not auto-reindex (#1070)
+- **Docs** — README Web UI section corrected (closes #1110, #1159, #2ff3e64f); Goliath capitalisation typo (#1126)
+- **CI** — fork-safe PR autofix pipeline (#1446); consolidated Claude review workflow (#1258); fine-grained PAT for RC tag push (#1407); handle expired artifacts in base coverage fetch (#1410, #1412); allow expected legacy parity failures (#1099); avoid duplicate main push checks; isolate native LadybugDB / CLI e2e flakes; seed e2e with a small fixture repo (#1249); configure e2e GitNexus home at runtime; widen rate-limit test window for Windows CI (#1347)
+
+### Changed
+
+- **`gitnexus publish` artefact contract** — universal opt-in publish format introduced (#1425, #1458)
+- **Refactor: per-language patterns consolidated into `LanguageProvider`** (#1279)
+- **Refactor: `safeClose` helper** consolidates WAL flush across LadybugDB call sites (#1377)
+- **Quality: exclude `test/fixtures` from CodeQL, ESLint, and Prettier** (#1313)
+- **Regression coverage** for `.gitnexusignore` behaviour with `--skip-git` (#1450)
+
+### Chore / Dependencies
+
+- `@ladybugdb/core` 0.16.0 → 0.16.1 (#1235, #1326)
+- `@anthropic-ai/sdk` (#1442), `@langchain/anthropic` (#1389), `@langchain/core` (#1394), `@langchain/openai` (#1215)
+- `hono` 4.12.9 → 4.12.18 + `@hono/node-server` (#1310, #1311, #1443)
+- `axios` (#1345), `fast-uri` 3.1.0 → 3.1.2 (#1441), `lru-cache` 11.3.5 → 11.3.6 (#1344), `mnemonist` 0.40.3 → 0.40.4 (#1239), `express-rate-limit` (#1343, #1397), `onnxruntime-node` (#1213, #1435), `uuid` 13 → 14 in /gitnexus-web (#1211, after revert #1222 / re-land #1250 + #1208)
+- `react`/`@types/react` (#1210), `react-dom` 19.2.5 → 19.2.6 (#1396), `react-zoom-pan-pinch` (#1214), `jsdom` 29.0.2 → 29.1.1 (#1395)
+- npm_and_yarn group bump (#1312), uv group bump (#1315), `python-dotenv` (#1320), `@types/node` (#1212, #1421, #1436)
+- GitHub Actions: `docker/build-push-action` 6.19.2 → 7.1.0 (#1391), `github/codeql-action` 3.35.3 → 4.35.3 (#1390)
+
## [1.6.3] - 2026-04-24
### Added
diff --git a/gitnexus/Dockerfile.test b/gitnexus/Dockerfile.test
index 0282129ff..37374a5f5 100644
--- a/gitnexus/Dockerfile.test
+++ b/gitnexus/Dockerfile.test
@@ -1,6 +1,15 @@
-FROM node:20-bookworm
+# Pinned npm version — keep in sync with the root Dockerfile.cli and
+# Dockerfile.web.
+ARG NPM_VERSION=11.14.1
+
+# node:22-bookworm-slim
+FROM node:22-bookworm-slim@sha256:9f6d5975c7dca860947d3915877f85607946403fc55349f39b4bc3688448bb6e
+ARG NPM_VERSION
WORKDIR /app
-RUN apt-get -o Acquire::Check-Valid-Until=false -o Acquire::Check-Date=false update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
+RUN npx --yes npm@${NPM_VERSION} install -g npm@${NPM_VERSION} \
+ && apt-get -o Acquire::Check-Valid-Until=false -o Acquire::Check-Date=false update \
+ && apt-get install -y python3 make g++ \
+ && rm -rf /var/lib/apt/lists/*
COPY . .
RUN npm ci --ignore-scripts \
&& npm rebuild tree-sitter-swift 2>&1 \
diff --git a/gitnexus/README.md b/gitnexus/README.md
index 8a5144598..55cdc12f7 100644
--- a/gitnexus/README.md
+++ b/gitnexus/README.md
@@ -33,7 +33,7 @@ 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** |
-| **Cursor** | Yes | Yes | — | MCP + Skills |
+| **Cursor** | Yes | Yes | Yes (postToolUse, [manual install](../gitnexus-cursor-integration/README.md#hook-install)) | **Full** |
| **Codex** | Yes | Yes | — | MCP + Skills |
| **Windsurf** | Yes | — | — | MCP |
| **OpenCode** | Yes | Yes | — | MCP + Skills |
diff --git a/gitnexus/hooks/claude/gitnexus-hook.cjs b/gitnexus/hooks/claude/gitnexus-hook.cjs
index 7bfa150cd..e39fcf8e1 100755
--- a/gitnexus/hooks/claude/gitnexus-hook.cjs
+++ b/gitnexus/hooks/claude/gitnexus-hook.cjs
@@ -14,6 +14,8 @@
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
+const { acquireHookSlot } = require('./hook-lock.cjs');
+const { hasGitNexusDbLockedByGitNexusServer } = require('./hook-db-lock-probe.cjs');
/**
* Read JSON input from stdin synchronously.
@@ -102,6 +104,28 @@ function findGitNexusDir(startDir) {
return null;
}
+function hasGitNexusServerOwner(gitNexusDir) {
+ return hasGitNexusDbLockedByGitNexusServer(path.join(gitNexusDir, 'lbug'), process.pid);
+}
+
+function extractAugmentContext(stderr) {
+ const output = (stderr || '').trim();
+ const marker = output.indexOf('[GitNexus]');
+ const debug = process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true';
+ if (debug && output.length > 0) {
+ // Emit the FULL discarded prefix (everything before the marker, or all of
+ // it when no marker is present) so suppressed diagnostics — KuzuDB lock
+ // warnings, parser errors, etc. — remain recoverable on the hook's own
+ // stderr. The untruncated payload lets operators see exactly what was
+ // filtered out instead of a 180-char JSON-quoted preview.
+ const discarded = marker === -1 ? output : output.slice(0, marker).trim();
+ if (discarded.length > 0) {
+ process.stderr.write(`[GitNexus hook] augment stderr discarded prefix:\n${discarded}\n`);
+ }
+ }
+ return marker === -1 ? '' : output.slice(marker).trim();
+}
+
/**
* Extract search pattern from tool input.
*/
@@ -167,6 +191,10 @@ function extractPattern(toolName, toolInput) {
* 3. Fall back to npx (returns empty string)
*/
function resolveCliPath() {
+ const fromEnv = process.env.GITNEXUS_HOOK_CLI_PATH;
+ if (fromEnv !== undefined && String(fromEnv).trim() && fs.existsSync(String(fromEnv))) {
+ return String(fromEnv);
+ }
let cliPath = path.resolve(__dirname, '..', '..', 'dist', 'cli', 'index.js');
if (!fs.existsSync(cliPath)) {
try {
@@ -207,7 +235,8 @@ function runGitNexusCli(cliPath, args, cwd, timeout) {
function handlePreToolUse(input) {
const cwd = input.cwd || process.cwd();
if (!path.isAbsolute(cwd)) return;
- if (!findGitNexusDir(cwd)) return;
+ const gitNexusDir = findGitNexusDir(cwd);
+ if (!gitNexusDir) return;
const toolName = input.tool_name || '';
const toolInput = input.tool_input || {};
@@ -216,20 +245,29 @@ function handlePreToolUse(input) {
const pattern = extractPattern(toolName, toolInput);
if (!pattern || pattern.length < 3) return;
+ if (hasGitNexusServerOwner(gitNexusDir)) {
+ process.stderr.write('[GitNexus] augment skipped: MCP server owns DB\n');
+ return;
+ }
+
+ const release = acquireHookSlot(gitNexusDir);
+ if (!release) return;
const cliPath = resolveCliPath();
let result = '';
try {
const child = runGitNexusCli(cliPath, ['augment', '--', pattern], cwd, 7000);
if (!child.error && child.status === 0) {
- result = child.stderr || '';
+ result = extractAugmentContext(child.stderr || '');
}
} catch {
/* graceful failure */
+ } finally {
+ release();
}
- if (result && result.trim()) {
- sendHookResponse('PreToolUse', result.trim());
+ if (result) {
+ sendHookResponse('PreToolUse', result);
}
}
diff --git a/gitnexus/hooks/claude/hook-db-lock-probe.cjs b/gitnexus/hooks/claude/hook-db-lock-probe.cjs
new file mode 100644
index 000000000..783cd0804
--- /dev/null
+++ b/gitnexus/hooks/claude/hook-db-lock-probe.cjs
@@ -0,0 +1,238 @@
+/**
+ * Cross-platform best-effort probe: does another process hold dbPath open
+ * with a command line that looks like a GitNexus MCP/serve server?
+ *
+ * Backends (no user-installed Sysinternals):
+ * - Linux: scan procfs under /proc (per-PID fd entries) via stat(2) (dev+inode); works without lsof;
+ * optional lsof fallback when proc scan finds nothing.
+ * - macOS / *BSD / etc.: trusted lsof + ps (absolute paths first).
+ * - Windows: Restart Manager (rstrtmgr) via bundled PowerShell script +
+ * Win32_Process for command lines; trusted powershell.exe under %SystemRoot%.
+ *
+ * Fail-open on most errors; fail-closed only on lsof ETIMEDOUT (Unix) or
+ * PowerShell ETIMEDOUT (Windows), matching the hook contract.
+ */
+
+const fs = require('fs');
+const path = require('path');
+const { spawnSync } = require('child_process');
+
+function isGitNexusServerCommand(command) {
+ const hasServerMode = /(?:^|\s)(mcp|serve)(?:\s|$)/.test(command);
+ const hasGitNexus =
+ /(?:^|[/\\\s])gitnexus(?:\.cmd)?(?:\s|$)/.test(command) ||
+ /node_modules[/\\]gitnexus[/\\]/.test(command);
+ return hasServerMode && hasGitNexus;
+}
+
+function resolveHookBinary(tool) {
+ const envKey = tool === 'lsof' ? 'GITNEXUS_HOOK_LSOF_PATH' : 'GITNEXUS_HOOK_PS_PATH';
+ const fromEnv = process.env[envKey];
+ if (fromEnv && String(fromEnv).trim() && fs.existsSync(String(fromEnv))) {
+ return String(fromEnv);
+ }
+ const candidates =
+ tool === 'lsof'
+ ? ['/usr/bin/lsof', '/usr/sbin/lsof', '/sbin/lsof', tool]
+ : ['/bin/ps', '/usr/bin/ps', tool];
+ for (const candidate of candidates) {
+ if (candidate === tool) return tool;
+ try {
+ if (fs.existsSync(candidate)) return candidate;
+ } catch {
+ /* ignore */
+ }
+ }
+ return tool;
+}
+
+function resolveWindowsPowerShellPath() {
+ const fromEnv = process.env.GITNEXUS_HOOK_POWERSHELL_PATH;
+ if (fromEnv && String(fromEnv).trim() && fs.existsSync(String(fromEnv).trim())) {
+ return String(fromEnv).trim();
+ }
+ const root = process.env.SystemRoot || 'C:\\Windows';
+ const ps = path.join(root, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
+ if (fs.existsSync(ps)) return ps;
+ const psWow = path.join(root, 'SysWOW64', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
+ if (fs.existsSync(psWow)) return psWow;
+ return 'powershell.exe';
+}
+
+// Sentinel:
+// undefined = not loaded yet (try the read)
+// string = encoded PowerShell command (successful load)
+// null = load attempted and failed (do not retry; warning already emitted)
+let windowsRmListPsEncodedCommandCache;
+let windowsRmListPsLoadFailureWarned = false;
+function getWindowsRmListEncodedCommand() {
+ if (windowsRmListPsEncodedCommandCache !== undefined) {
+ return windowsRmListPsEncodedCommandCache;
+ }
+ try {
+ const ps1Path = path.join(__dirname, 'win-rm-list-json.ps1');
+ const src = fs
+ .readFileSync(ps1Path, 'utf8')
+ .replace(/^\uFEFF/, '')
+ .replace(/\r\n/g, '\n');
+ windowsRmListPsEncodedCommandCache = Buffer.from(src, 'utf16le').toString('base64');
+ } catch (err) {
+ windowsRmListPsEncodedCommandCache = null;
+ if (
+ !windowsRmListPsLoadFailureWarned &&
+ (process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true')
+ ) {
+ windowsRmListPsLoadFailureWarned = true;
+ const msg = err && err.message ? String(err.message).slice(0, 200) : 'unknown';
+ process.stderr.write(`[GitNexus hook] win-rm-list-json.ps1 load failed: ${msg}\n`);
+ }
+ }
+ return windowsRmListPsEncodedCommandCache;
+}
+
+function hasGitNexusServerOwnerWindows(dbPathAbs, myPid) {
+ const encoded = getWindowsRmListEncodedCommand();
+ if (!encoded) return false;
+ const psExe = resolveWindowsPowerShellPath();
+ const r = spawnSync(
+ psExe,
+ [
+ '-NoProfile',
+ '-NonInteractive',
+ '-ExecutionPolicy',
+ 'Bypass',
+ '-STA',
+ '-EncodedCommand',
+ encoded,
+ ],
+ {
+ encoding: 'utf-8',
+ timeout: 6000,
+ stdio: ['ignore', 'pipe', 'ignore'],
+ env: { ...process.env, GITNEXUS_HOOK_RM_TARGET: dbPathAbs },
+ },
+ );
+ // ETIMEDOUT means the PowerShell probe didn't return in time; treat as 'unresponsive process holds DB' → fail-closed (skip augment).
+ if (r.error) return r.error.code === 'ETIMEDOUT';
+ if (r.status !== 0) return false;
+ let rows;
+ try {
+ rows = JSON.parse(String(r.stdout || '').trim() || '[]');
+ } catch {
+ return false;
+ }
+ if (!Array.isArray(rows)) return false;
+ for (const row of rows) {
+ const procId = Number(row.pid);
+ const cmd = String(row.cmd || '');
+ if (!Number.isFinite(procId) || procId === myPid) continue;
+ if (isGitNexusServerCommand(cmd)) return true;
+ }
+ return false;
+}
+
+function readLinuxCmdline(pidStr) {
+ try {
+ return fs.readFileSync(`/proc/${pidStr}/cmdline`, 'utf8').replace(/\0+/g, ' ').trim();
+ } catch {
+ return '';
+ }
+}
+
+function linuxProcScanFindGitNexusServer(dbPathAbs, myPid) {
+ const raw = process.env.GITNEXUS_HOOK_LINUX_PROC_BUDGET_MS;
+ const budget = Number(raw && String(raw).trim()) ? Number.parseInt(String(raw), 10) : 1200;
+ const start = Date.now();
+ let targetStat;
+ try {
+ targetStat = fs.statSync(dbPathAbs);
+ } catch {
+ return false;
+ }
+ let procEntries;
+ try {
+ procEntries = fs.readdirSync('/proc', { withFileTypes: true });
+ } catch {
+ return false;
+ }
+ for (const ent of procEntries) {
+ if (Date.now() - start > budget) return false;
+ if (!ent.isDirectory() || !/^\d+$/.test(ent.name)) continue;
+ const pid = Number.parseInt(ent.name, 10);
+ if (!Number.isFinite(pid) || pid === myPid) continue;
+ const fdDir = path.join('/proc', ent.name, 'fd');
+ let fds;
+ try {
+ fds = fs.readdirSync(fdDir);
+ } catch {
+ continue;
+ }
+ let holds = false;
+ for (const fd of fds) {
+ if (Date.now() - start > budget) return false;
+ try {
+ const st = fs.statSync(path.join(fdDir, fd));
+ if (st.dev === targetStat.dev && st.ino === targetStat.ino) {
+ holds = true;
+ break;
+ }
+ } catch {
+ /* ignore */
+ }
+ }
+ if (!holds) continue;
+ if (isGitNexusServerCommand(readLinuxCmdline(ent.name))) return true;
+ }
+ return false;
+}
+
+function unixLsofPsFindGitNexusServer(dbPathAbs, myPid) {
+ const lsofPath = resolveHookBinary('lsof');
+ const lsof = spawnSync(lsofPath, ['-nP', '-t', '--', dbPathAbs], {
+ encoding: 'utf-8',
+ timeout: 1000,
+ stdio: ['ignore', 'pipe', 'ignore'],
+ });
+ if (lsof.error) return lsof.error.code === 'ETIMEDOUT';
+
+ const pids = (lsof.stdout || '').split(/\s+/).filter(Boolean);
+ const psPath = resolveHookBinary('ps');
+ for (const pid of pids) {
+ if (Number(pid) === myPid) continue;
+ const ps = spawnSync(psPath, ['-p', pid, '-o', 'command='], {
+ encoding: 'utf-8',
+ timeout: 500,
+ stdio: ['ignore', 'pipe', 'ignore'],
+ });
+ if (ps.error) {
+ if (ps.error.code === 'ETIMEDOUT') return true;
+ continue;
+ }
+ if (isGitNexusServerCommand(ps.stdout || '')) return true;
+ }
+ return false;
+}
+
+/**
+ * @param {string} dbPath Absolute or relative path to the DB file (e.g. .../lbug).
+ * @param {number} myPid Current process PID (hook runner), excluded from matches.
+ */
+function hasGitNexusDbLockedByGitNexusServer(dbPath, myPid) {
+ if (!fs.existsSync(dbPath)) return false;
+ const dbPathAbs = path.resolve(dbPath);
+
+ if (process.platform === 'win32') {
+ return hasGitNexusServerOwnerWindows(dbPathAbs, myPid);
+ }
+
+ if (process.platform === 'linux') {
+ if (linuxProcScanFindGitNexusServer(dbPathAbs, myPid)) return true;
+ return unixLsofPsFindGitNexusServer(dbPathAbs, myPid);
+ }
+
+ return unixLsofPsFindGitNexusServer(dbPathAbs, myPid);
+}
+
+module.exports = {
+ hasGitNexusDbLockedByGitNexusServer,
+};
diff --git a/gitnexus/hooks/claude/hook-lock.cjs b/gitnexus/hooks/claude/hook-lock.cjs
new file mode 100644
index 000000000..759856384
--- /dev/null
+++ b/gitnexus/hooks/claude/hook-lock.cjs
@@ -0,0 +1,119 @@
+const fs = require('fs');
+const path = require('path');
+
+const HOOK_LOCK_SUBDIR = '.hook-locks';
+const HOOK_LOCK_MAX_INFLIGHT = 3;
+const HOOK_LOCK_STALE_MS = 30000;
+
+function acquireHookSlot(gitNexusDir) {
+ const lockDir = path.join(gitNexusDir, HOOK_LOCK_SUBDIR);
+ try {
+ fs.mkdirSync(lockDir, { recursive: true });
+ } catch {
+ // Cannot create lock dir (read-only fs, cross-user perm denial, out of
+ // inodes, etc.) — fail closed by returning null. Caller skips augment.
+ // Fail-open here would let N concurrent hooks all proceed unguarded and
+ // reintroduce the #1486 fan-out the guard exists to prevent.
+ return null;
+ }
+
+ const myPidStr = String(process.pid);
+
+ for (let slot = 0; slot < HOOK_LOCK_MAX_INFLIGHT; slot++) {
+ const slotPath = path.join(lockDir, `slot-${slot}.lock`);
+ for (let attempt = 0; attempt < 2; attempt++) {
+ try {
+ fs.writeFileSync(slotPath, myPidStr, { flag: 'wx' });
+ let released = false;
+ const release = () => {
+ if (released) return;
+ released = true;
+ try {
+ // Only unlink if we still own the slot. If we appeared stale and
+ // another hook took over, the file now belongs to it — leave alone.
+ const content = fs.readFileSync(slotPath, 'utf-8').trim();
+ if (content === myPidStr) fs.unlinkSync(slotPath);
+ } catch {
+ /* already removed or unreadable */
+ }
+ };
+ process.on('exit', release);
+ return release;
+ } catch {
+ // Slot exists. Decide whether to take it over.
+ // Open once and inspect mtime + content via the same fd so there's
+ // no TOCTOU between the metadata check and the content read
+ // (codeql js/file-system-race).
+ let fd;
+ try {
+ fd = fs.openSync(slotPath, 'r');
+ } catch {
+ continue; // Vanished between EEXIST and open — retry this slot.
+ }
+ let isLive = false;
+ let mtimeMs = Date.now();
+ try {
+ mtimeMs = fs.fstatSync(fd).mtimeMs;
+ const buf = Buffer.alloc(32);
+ const n = fs.readSync(fd, buf, 0, 32, 0);
+ const ownerStr = buf.slice(0, n).toString('utf-8').trim();
+ if (ownerStr === '') {
+ // Owner created the file but hasn't written its PID yet. The
+ // wx open+write window is microseconds; give it the benefit
+ // of the doubt and treat as live.
+ isLive = true;
+ } else {
+ const owner = Number.parseInt(ownerStr, 10);
+ if (Number.isFinite(owner) && owner > 0) {
+ try {
+ process.kill(owner, 0);
+ isLive = true;
+ } catch (e) {
+ // ESRCH = process gone → treat as dead. EPERM = process exists
+ // but owned by another user (cross-user lock dir) → still alive,
+ // keep the slot. Anything else: be conservative, assume alive.
+ if (e && e.code === 'ESRCH') {
+ isLive = false;
+ } else {
+ isLive = true;
+ }
+ }
+ }
+ }
+ } catch {
+ /* unreadable — treat as dead */
+ } finally {
+ try {
+ fs.closeSync(fd);
+ } catch {
+ /* already closed */
+ }
+ }
+ // For slots younger than HOOK_LOCK_STALE_MS, PID-liveness wins —
+ // a slow-but-alive hook is never wrongly evicted. For older slots,
+ // age is the final arbiter as a defense against PID reuse on long-
+ // abandoned slots. 30s >> the 7s augment timeout, so a healthy run
+ // never crosses this threshold.
+ if (isLive && Date.now() - mtimeMs > HOOK_LOCK_STALE_MS) {
+ isLive = false;
+ }
+ if (isLive) break; // Try the next slot.
+ try {
+ fs.unlinkSync(slotPath);
+ } catch {
+ /* another hook beat us to it — retry will hit EEXIST */
+ }
+ // Loop and retry this slot.
+ }
+ }
+ }
+
+ return null;
+}
+
+module.exports = {
+ HOOK_LOCK_SUBDIR,
+ HOOK_LOCK_MAX_INFLIGHT,
+ HOOK_LOCK_STALE_MS,
+ acquireHookSlot,
+};
diff --git a/gitnexus/hooks/claude/win-rm-list-json.ps1 b/gitnexus/hooks/claude/win-rm-list-json.ps1
new file mode 100644
index 000000000..5c1564e30
--- /dev/null
+++ b/gitnexus/hooks/claude/win-rm-list-json.ps1
@@ -0,0 +1,76 @@
+$ErrorActionPreference = 'Stop'
+$target = $env:GITNEXUS_HOOK_RM_TARGET
+if ([string]::IsNullOrWhiteSpace($target)) { Write-Output '[]'; exit 0 }
+$target = (Resolve-Path -LiteralPath $target).ProviderPath
+
+if (-not ([Management.Automation.PSTypeName]'GitNexusHookRm.Native').Type) {
+Add-Type @'
+using System;
+using System.Runtime.InteropServices;
+namespace GitNexusHookRm {
+ public static class Native {
+ public const int ErrorMoreData = 234;
+ [StructLayout(LayoutKind.Sequential, Pack = 4)]
+ public struct RM_UNIQUE_PROCESS {
+ public int dwProcessId;
+ public long ProcessStartTime;
+ }
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
+ public struct RM_PROCESS_INFO {
+ public RM_UNIQUE_PROCESS Process;
+ [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)]
+ public string strAppName;
+ [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)]
+ public string strServiceShortName;
+ public uint ApplicationType;
+ public uint AppStatus;
+ public uint TSSessionId;
+ public uint bRestartable;
+ }
+ [DllImport("rstrtmgr.dll", CharSet = CharSet.Unicode)]
+ public static extern int RmStartSession(out uint pSessionHandle, uint dwSessionFlags, string strSessionKey);
+ [DllImport("rstrtmgr.dll", CharSet = CharSet.Unicode)]
+ public static extern int RmRegisterResources(uint pSessionHandle, uint nFiles, string[] rgsFileNames, uint nApplications, IntPtr rgApplications, uint nServices, string[] rgsServiceNames);
+ [DllImport("rstrtmgr.dll")]
+ public static extern int RmGetList(uint dwSessionHandle, out uint pnProcInfoNeeded, ref uint pnProcInfo, [In, Out] RM_PROCESS_INFO[] rgAffectedApps, ref uint lpdwRebootReasons);
+ [DllImport("rstrtmgr.dll")]
+ public static extern int RmEndSession(uint pSessionHandle);
+ }
+}
+'@
+}
+
+$h = [uint32]0
+$key = [guid]::NewGuid().ToString('N')
+$rmErr = [GitNexusHookRm.Native]::RmStartSession([ref]$h, 0, $key)
+if ($rmErr -ne 0) { Write-Output '[]'; exit 0 }
+$files = @($target)
+$err = [GitNexusHookRm.Native]::RmRegisterResources($h, 1, $files, 0, [IntPtr]::Zero, 0, $null)
+if ($err -ne 0) {
+ [void][GitNexusHookRm.Native]::RmEndSession($h)
+ Write-Output '[]'
+ exit 0
+}
+$need = [uint32]0
+$n = [uint32]0
+$reboot = [uint32]0
+$err = [GitNexusHookRm.Native]::RmGetList($h, [ref]$need, [ref]$n, $null, [ref]$reboot)
+if ($err -ne [GitNexusHookRm.Native]::ErrorMoreData) {
+ [void][GitNexusHookRm.Native]::RmEndSession($h)
+ Write-Output '[]'
+ exit 0
+}
+$n = $need
+$buf = New-Object GitNexusHookRm.Native+RM_PROCESS_INFO[] ([int]$n)
+$err = [GitNexusHookRm.Native]::RmGetList($h, [ref]$need, [ref]$n, $buf, [ref]$reboot)
+[void][GitNexusHookRm.Native]::RmEndSession($h)
+if ($err -ne 0) { Write-Output '[]'; exit 0 }
+
+$out = @()
+for ($i = 0; $i -lt [int]$n; $i++) {
+ $procId = $buf[$i].Process.dwProcessId
+ $p = Get-CimInstance -ClassName Win32_Process -Filter "ProcessId=$procId" -ErrorAction SilentlyContinue
+ $cmd = if ($p) { $p.CommandLine } else { '' }
+ $out += [PSCustomObject]@{ pid = [int]$procId; cmd = $cmd }
+}
+ConvertTo-Json -InputObject @($out) -Compress
diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json
index 04233b1a1..b367a3251 100644
--- a/gitnexus/package-lock.json
+++ b/gitnexus/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "gitnexus",
- "version": "1.6.3",
+ "version": "1.6.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gitnexus",
- "version": "1.6.3",
+ "version": "1.6.4",
"hasInstallScript": true,
"license": "PolyForm-Noncommercial-1.0.0",
"dependencies": {
@@ -63,7 +63,7 @@
"vitest": "^4.0.18"
},
"engines": {
- "node": ">=20.0.0"
+ "node": ">=22.0.0"
},
"optionalDependencies": {
"node-addon-api": "^8.0.0",
@@ -142,9 +142,9 @@
}
},
"node_modules/@emnapi/core": {
- "version": "1.9.2",
- "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
- "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==",
+ "version": "1.10.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
+ "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -1572,9 +1572,9 @@
}
},
"node_modules/@oxc-project/types": {
- "version": "0.126.0",
- "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.126.0.tgz",
- "integrity": "sha512-oGfVtjAgwQVVpfBrbtk4e1XDyWHRFta6BS3GWVzrF8xYBT2VGQAk39yJS/wFSMrZqoiCU4oghT3Ch0HaHGIHcQ==",
+ "version": "0.130.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz",
+ "integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==",
"dev": true,
"license": "MIT",
"funding": {
@@ -1600,9 +1600,9 @@
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/codegen": {
- "version": "2.0.4",
- "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz",
- "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==",
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz",
+ "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/eventemitter": {
@@ -1628,9 +1628,9 @@
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/inquire": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz",
- "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==",
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.1.tgz",
+ "integrity": "sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/path": {
@@ -1646,15 +1646,15 @@
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/utf8": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz",
- "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==",
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz",
+ "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==",
"license": "BSD-3-Clause"
},
"node_modules/@rolldown/binding-android-arm64": {
- "version": "1.0.0-rc.16",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.16.tgz",
- "integrity": "sha512-rhY3k7Bsae9qQfOtph2Pm2jZEA+s8Gmjoz4hhmx70K9iMQ/ddeae+xhRQcM5IuVx5ry1+bGfkvMn7D6MJggVSA==",
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz",
+ "integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==",
"cpu": [
"arm64"
],
@@ -1669,9 +1669,9 @@
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
- "version": "1.0.0-rc.16",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.16.tgz",
- "integrity": "sha512-rNz0yK078yrNn3DrdgN+PKiMOW8HfQ92jQiXxwX8yW899ayV00MLVdaCNeVBhG/TbH3ouYVObo8/yrkiectkcQ==",
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz",
+ "integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==",
"cpu": [
"arm64"
],
@@ -1686,9 +1686,9 @@
}
},
"node_modules/@rolldown/binding-darwin-x64": {
- "version": "1.0.0-rc.16",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.16.tgz",
- "integrity": "sha512-r/OmdR00HmD4i79Z//xO06uEPOq5hRXdhw7nzkxQxwSavs3PSHa1ijntdpOiZ2mzOQ3fVVu8C1M19FoNM+dMUQ==",
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz",
+ "integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==",
"cpu": [
"x64"
],
@@ -1703,9 +1703,9 @@
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
- "version": "1.0.0-rc.16",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.16.tgz",
- "integrity": "sha512-KcRE5w8h0OnjUatG8pldyD14/CQ5Phs1oxfR+3pKDjboHRo9+MkqQaiIZlZRpsxC15paeXme/I127tUa9TXJ6g==",
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz",
+ "integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==",
"cpu": [
"x64"
],
@@ -1720,9 +1720,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
- "version": "1.0.0-rc.16",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.16.tgz",
- "integrity": "sha512-bT0guA1bpxEJ/ZhTRniQf7rNF8ybvXOuWbNIeLABaV5NGjx4EtOWBTSRGWFU9ZWVkPOZ+HNFP8RMcBokBiZ0Kg==",
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz",
+ "integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==",
"cpu": [
"arm"
],
@@ -1737,9 +1737,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
- "version": "1.0.0-rc.16",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.16.tgz",
- "integrity": "sha512-+tHktCHWV8BDQSjemUqm/Jl/TPk3QObCTIjmdDy/nlupcujZghmKK2962LYrqFpWu+ai01AN/REOH3NEpqvYQg==",
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz",
+ "integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==",
"cpu": [
"arm64"
],
@@ -1754,9 +1754,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
- "version": "1.0.0-rc.16",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.16.tgz",
- "integrity": "sha512-3fPzdREH806oRLxpTWW1Gt4tQHs0TitZFOECB2xzCFLPKnSOy90gwA7P29cksYilFO6XVRY1kzga0cL2nRjKPg==",
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz",
+ "integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==",
"cpu": [
"arm64"
],
@@ -1771,9 +1771,9 @@
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
- "version": "1.0.0-rc.16",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.16.tgz",
- "integrity": "sha512-EKwI1tSrLs7YVw+JPJT/G2dJQ1jl9qlTTTEG0V2Ok/RdOenRfBw2PQdLPyjhIu58ocdBfP7vIRN/pvMsPxs/AQ==",
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz",
+ "integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==",
"cpu": [
"ppc64"
],
@@ -1788,9 +1788,9 @@
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
- "version": "1.0.0-rc.16",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.16.tgz",
- "integrity": "sha512-Uknladnb3Sxqu6SEcqBldQyJUpk8NleooZEc0MbRBJ4inEhRYWZX0NJu12vNf2mqAq7gsofAxHrGghiUYjhaLQ==",
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz",
+ "integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==",
"cpu": [
"s390x"
],
@@ -1805,9 +1805,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
- "version": "1.0.0-rc.16",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.16.tgz",
- "integrity": "sha512-FIb8+uG49sZBtLTn+zt1AJ20TqVcqWeSIyoVt0or7uAWesgKaHbiBh6OpA/k9v0LTt+PTrb1Lao133kP4uVxkg==",
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz",
+ "integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==",
"cpu": [
"x64"
],
@@ -1822,9 +1822,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
- "version": "1.0.0-rc.16",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.16.tgz",
- "integrity": "sha512-RuERhF9/EgWxZEXYWCOaViUWHIboceK4/ivdtQ3R0T44NjLkIIlGIAVAuCddFxsZ7vnRHtNQUrt2vR2n2slB2w==",
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz",
+ "integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==",
"cpu": [
"x64"
],
@@ -1839,9 +1839,9 @@
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
- "version": "1.0.0-rc.16",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.16.tgz",
- "integrity": "sha512-mXcXnvd9GpazCxeUCCnZ2+YF7nut+ZOEbE4GtaiPtyY6AkhZWbK70y1KK3j+RDhjVq5+U8FySkKRb/+w0EeUwA==",
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz",
+ "integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==",
"cpu": [
"arm64"
],
@@ -1856,9 +1856,9 @@
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
- "version": "1.0.0-rc.16",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.16.tgz",
- "integrity": "sha512-3Q2KQxnC8IJOLqXmUMoYwyIPZU9hzRbnHaoV3Euz+VVnjZKcY8ktnNP8T9R4/GGQtb27C/UYKABxesKWb8lsvQ==",
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz",
+ "integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==",
"cpu": [
"wasm32"
],
@@ -1866,8 +1866,8 @@
"license": "MIT",
"optional": true,
"dependencies": {
- "@emnapi/core": "1.9.2",
- "@emnapi/runtime": "1.9.2",
+ "@emnapi/core": "1.10.0",
+ "@emnapi/runtime": "1.10.0",
"@napi-rs/wasm-runtime": "^1.1.4"
},
"engines": {
@@ -1875,9 +1875,9 @@
}
},
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
- "version": "1.9.2",
- "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz",
- "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==",
+ "version": "1.10.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
+ "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -1886,9 +1886,9 @@
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
- "version": "1.0.0-rc.16",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.16.tgz",
- "integrity": "sha512-tj7XRemQcOcFwv7qhpUxMTBbI5mWMlE4c1Omhg5+h8GuLXzyj8HviYgR+bB2DMDgRqUE+jiDleqSCRjx4aYk/Q==",
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz",
+ "integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==",
"cpu": [
"arm64"
],
@@ -1903,9 +1903,9 @@
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
- "version": "1.0.0-rc.16",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.16.tgz",
- "integrity": "sha512-PH5DRZT+F4f2PTXRXR8uJxnBq2po/xFtddyabTJVJs/ZYVHqXPEgNIr35IHTEa6bpa0Q8Awg+ymkTaGnKITw4g==",
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz",
+ "integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==",
"cpu": [
"x64"
],
@@ -1920,9 +1920,9 @@
}
},
"node_modules/@rolldown/pluginutils": {
- "version": "1.0.0-rc.16",
- "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.16.tgz",
- "integrity": "sha512-45+YtqxLYKDWQouLKCrpIZhke+nXxhsw+qAHVzHDVwttyBlHNBVs2K25rDXrZzhpTp9w1FlAlvweV1H++fdZoA==",
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
"dev": true,
"license": "MIT"
},
@@ -1941,9 +1941,9 @@
"license": "MIT"
},
"node_modules/@tybys/wasm-util": {
- "version": "0.10.1",
- "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
- "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
+ "version": "0.10.2",
+ "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
+ "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -2132,14 +2132,14 @@
}
},
"node_modules/@vitest/coverage-v8": {
- "version": "4.1.5",
- "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.5.tgz",
- "integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==",
+ "version": "4.1.6",
+ "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.6.tgz",
+ "integrity": "sha512-36l628fQ/9a/8ihy97eOtEnvWQEdqULQOJtcaxtoNq0G1w3Mxd4szSahOaMM9/NGyZ+hyKcMtIW/WIxq0XQViQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@bcoe/v8-coverage": "^1.0.2",
- "@vitest/utils": "4.1.5",
+ "@vitest/utils": "4.1.6",
"ast-v8-to-istanbul": "^1.0.0",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
@@ -2153,8 +2153,8 @@
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
- "@vitest/browser": "4.1.5",
- "vitest": "4.1.5"
+ "@vitest/browser": "4.1.6",
+ "vitest": "4.1.6"
},
"peerDependenciesMeta": {
"@vitest/browser": {
@@ -2163,16 +2163,16 @@
}
},
"node_modules/@vitest/expect": {
- "version": "4.1.5",
- "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz",
- "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==",
+ "version": "4.1.6",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.6.tgz",
+ "integrity": "sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
- "@vitest/spy": "4.1.5",
- "@vitest/utils": "4.1.5",
+ "@vitest/spy": "4.1.6",
+ "@vitest/utils": "4.1.6",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
@@ -2181,13 +2181,13 @@
}
},
"node_modules/@vitest/mocker": {
- "version": "4.1.5",
- "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz",
- "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==",
+ "version": "4.1.6",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.6.tgz",
+ "integrity": "sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/spy": "4.1.5",
+ "@vitest/spy": "4.1.6",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
@@ -2208,9 +2208,9 @@
}
},
"node_modules/@vitest/pretty-format": {
- "version": "4.1.5",
- "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz",
- "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==",
+ "version": "4.1.6",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.6.tgz",
+ "integrity": "sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2221,13 +2221,13 @@
}
},
"node_modules/@vitest/runner": {
- "version": "4.1.5",
- "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz",
- "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==",
+ "version": "4.1.6",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.6.tgz",
+ "integrity": "sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/utils": "4.1.5",
+ "@vitest/utils": "4.1.6",
"pathe": "^2.0.3"
},
"funding": {
@@ -2235,14 +2235,14 @@
}
},
"node_modules/@vitest/snapshot": {
- "version": "4.1.5",
- "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz",
- "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==",
+ "version": "4.1.6",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.6.tgz",
+ "integrity": "sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/pretty-format": "4.1.5",
- "@vitest/utils": "4.1.5",
+ "@vitest/pretty-format": "4.1.6",
+ "@vitest/utils": "4.1.6",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
@@ -2251,9 +2251,9 @@
}
},
"node_modules/@vitest/spy": {
- "version": "4.1.5",
- "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz",
- "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==",
+ "version": "4.1.6",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.6.tgz",
+ "integrity": "sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==",
"dev": true,
"license": "MIT",
"funding": {
@@ -2261,13 +2261,13 @@
}
},
"node_modules/@vitest/utils": {
- "version": "4.1.5",
- "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz",
- "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==",
+ "version": "4.1.6",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.6.tgz",
+ "integrity": "sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/pretty-format": "4.1.5",
+ "@vitest/pretty-format": "4.1.6",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
@@ -4151,9 +4151,9 @@
"license": "MIT"
},
"node_modules/nanoid": {
- "version": "3.3.11",
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
- "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "version": "3.3.12",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
+ "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
"dev": true,
"funding": [
{
@@ -4498,9 +4498,9 @@
"license": "MIT"
},
"node_modules/postcss": {
- "version": "8.5.10",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
- "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
+ "version": "8.5.14",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
+ "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
"dev": true,
"funding": [
{
@@ -4543,22 +4543,22 @@
"license": "MIT"
},
"node_modules/protobufjs": {
- "version": "7.5.5",
- "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.5.tgz",
- "integrity": "sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg==",
+ "version": "7.5.8",
+ "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.8.tgz",
+ "integrity": "sha512-dvpCIeLPbXZS/Ete7yLaO7RenOdken2NHKykBXbsaGxZT0UTltcarBciw+A78SRQs9iMAAVpsYA+l8b1hTePIA==",
"hasInstallScript": true,
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.2",
"@protobufjs/base64": "^1.1.2",
- "@protobufjs/codegen": "^2.0.4",
+ "@protobufjs/codegen": "^2.0.5",
"@protobufjs/eventemitter": "^1.1.0",
"@protobufjs/fetch": "^1.1.0",
"@protobufjs/float": "^1.0.2",
- "@protobufjs/inquire": "^1.1.0",
+ "@protobufjs/inquire": "^1.1.1",
"@protobufjs/path": "^1.1.2",
"@protobufjs/pool": "^1.1.0",
- "@protobufjs/utf8": "^1.1.0",
+ "@protobufjs/utf8": "^1.1.1",
"@types/node": ">=13.7.0",
"long": "^5.0.0"
},
@@ -4703,14 +4703,14 @@
}
},
"node_modules/rolldown": {
- "version": "1.0.0-rc.16",
- "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.16.tgz",
- "integrity": "sha512-rzi5WqKzEZw3SooTt7cgm4eqIoujPIyGcJNGFL7iPEuajQw7vxMHUkXylu4/vhCkJGXsgRmxqMKXUpT6FEgl0g==",
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz",
+ "integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@oxc-project/types": "=0.126.0",
- "@rolldown/pluginutils": "1.0.0-rc.16"
+ "@oxc-project/types": "=0.130.0",
+ "@rolldown/pluginutils": "^1.0.0"
},
"bin": {
"rolldown": "bin/cli.mjs"
@@ -4719,21 +4719,21 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
- "@rolldown/binding-android-arm64": "1.0.0-rc.16",
- "@rolldown/binding-darwin-arm64": "1.0.0-rc.16",
- "@rolldown/binding-darwin-x64": "1.0.0-rc.16",
- "@rolldown/binding-freebsd-x64": "1.0.0-rc.16",
- "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.16",
- "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.16",
- "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.16",
- "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.16",
- "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.16",
- "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.16",
- "@rolldown/binding-linux-x64-musl": "1.0.0-rc.16",
- "@rolldown/binding-openharmony-arm64": "1.0.0-rc.16",
- "@rolldown/binding-wasm32-wasi": "1.0.0-rc.16",
- "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.16",
- "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.16"
+ "@rolldown/binding-android-arm64": "1.0.1",
+ "@rolldown/binding-darwin-arm64": "1.0.1",
+ "@rolldown/binding-darwin-x64": "1.0.1",
+ "@rolldown/binding-freebsd-x64": "1.0.1",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.0.1",
+ "@rolldown/binding-linux-arm64-gnu": "1.0.1",
+ "@rolldown/binding-linux-arm64-musl": "1.0.1",
+ "@rolldown/binding-linux-ppc64-gnu": "1.0.1",
+ "@rolldown/binding-linux-s390x-gnu": "1.0.1",
+ "@rolldown/binding-linux-x64-gnu": "1.0.1",
+ "@rolldown/binding-linux-x64-musl": "1.0.1",
+ "@rolldown/binding-openharmony-arm64": "1.0.1",
+ "@rolldown/binding-wasm32-wasi": "1.0.1",
+ "@rolldown/binding-win32-arm64-msvc": "1.0.1",
+ "@rolldown/binding-win32-x64-msvc": "1.0.1"
}
},
"node_modules/router": {
@@ -5612,16 +5612,16 @@
}
},
"node_modules/vite": {
- "version": "8.0.9",
- "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.9.tgz",
- "integrity": "sha512-t7g7GVRpMXjNpa67HaVWI/8BWtdVIQPCL2WoozXXA7LBGEFK4AkkKkHx2hAQf5x1GZSlcmEDPkVLSGahxnEEZw==",
+ "version": "8.0.13",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz",
+ "integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
- "postcss": "^8.5.10",
- "rolldown": "1.0.0-rc.16",
+ "postcss": "^8.5.14",
+ "rolldown": "1.0.1",
"tinyglobby": "^0.2.16"
},
"bin": {
@@ -5638,7 +5638,7 @@
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
- "@vitejs/devtools": "^0.1.0",
+ "@vitejs/devtools": "^0.1.18",
"esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
@@ -5690,19 +5690,19 @@
}
},
"node_modules/vitest": {
- "version": "4.1.5",
- "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz",
- "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==",
+ "version": "4.1.6",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.6.tgz",
+ "integrity": "sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/expect": "4.1.5",
- "@vitest/mocker": "4.1.5",
- "@vitest/pretty-format": "4.1.5",
- "@vitest/runner": "4.1.5",
- "@vitest/snapshot": "4.1.5",
- "@vitest/spy": "4.1.5",
- "@vitest/utils": "4.1.5",
+ "@vitest/expect": "4.1.6",
+ "@vitest/mocker": "4.1.6",
+ "@vitest/pretty-format": "4.1.6",
+ "@vitest/runner": "4.1.6",
+ "@vitest/snapshot": "4.1.6",
+ "@vitest/spy": "4.1.6",
+ "@vitest/utils": "4.1.6",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
@@ -5730,12 +5730,12 @@
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
- "@vitest/browser-playwright": "4.1.5",
- "@vitest/browser-preview": "4.1.5",
- "@vitest/browser-webdriverio": "4.1.5",
- "@vitest/coverage-istanbul": "4.1.5",
- "@vitest/coverage-v8": "4.1.5",
- "@vitest/ui": "4.1.5",
+ "@vitest/browser-playwright": "4.1.6",
+ "@vitest/browser-preview": "4.1.6",
+ "@vitest/browser-webdriverio": "4.1.6",
+ "@vitest/coverage-istanbul": "4.1.6",
+ "@vitest/coverage-v8": "4.1.6",
+ "@vitest/ui": "4.1.6",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
diff --git a/gitnexus/package.json b/gitnexus/package.json
index 84f702762..633c69f24 100644
--- a/gitnexus/package.json
+++ b/gitnexus/package.json
@@ -1,6 +1,6 @@
{
"name": "gitnexus",
- "version": "1.6.3",
+ "version": "1.6.4",
"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",
@@ -116,6 +116,6 @@
}
},
"engines": {
- "node": ">=20.0.0"
+ "node": ">=22.0.0"
}
}
diff --git a/gitnexus/scripts/build.js b/gitnexus/scripts/build.js
index 84f43fc6b..ec7f67cf4 100644
--- a/gitnexus/scripts/build.js
+++ b/gitnexus/scripts/build.js
@@ -21,11 +21,15 @@ const SHARED_DEST = path.join(DIST, '_shared');
// ── 1. Build gitnexus-shared ───────────────────────────────────────
console.log('[build] compiling gitnexus-shared…');
-execSync('npx tsc', { cwd: SHARED_ROOT, stdio: 'inherit', timeout: 120_000 });
+const tscCmd =
+ process.platform === 'win32'
+ ? path.join('node_modules', '.bin', 'tsc.cmd')
+ : path.join('node_modules', '.bin', 'tsc');
+execSync(tscCmd, { cwd: SHARED_ROOT, stdio: 'inherit', timeout: 120_000 });
// ── 2. Build gitnexus ──────────────────────────────────────────────
console.log('[build] compiling gitnexus…');
-execSync('npx tsc', { cwd: ROOT, stdio: 'inherit', timeout: 120_000 });
+execSync(tscCmd, { cwd: ROOT, stdio: 'inherit', timeout: 120_000 });
// ── 3. Copy shared dist ────────────────────────────────────────────
console.log('[build] copying shared module into dist/_shared…');
diff --git a/gitnexus/src/cli/ai-context.ts b/gitnexus/src/cli/ai-context.ts
index 41ba6c73e..42dcb7aa7 100644
--- a/gitnexus/src/cli/ai-context.ts
+++ b/gitnexus/src/cli/ai-context.ts
@@ -28,6 +28,7 @@ interface RepoStats {
export interface AIContextOptions {
skipAgentsMd?: boolean;
noStats?: boolean;
+ skipSkills?: boolean;
}
const GITNEXUS_START_MARKER = '';
@@ -94,6 +95,7 @@ function generateGitNexusContent(
generatedSkills?: GeneratedSkillInfo[],
groupNames?: string[],
noStats?: boolean,
+ skipSkills?: boolean,
): string {
const generatedRows =
generatedSkills && generatedSkills.length > 0
@@ -105,14 +107,26 @@ function generateGitNexusContent(
.join('\n')
: '';
- const skillsTable = `| Task | Read this skill file |
-|------|---------------------|
-| Understand architecture / "How does X work?" | \`.claude/skills/gitnexus/gitnexus-exploring/SKILL.md\` |
+ // 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
+ // 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\` |${generatedRows ? '\n' + generatedRows : ''}`;
+| Index, status, clean, wiki CLI commands | \`.claude/skills/gitnexus/gitnexus-cli/SKILL.md\` |`;
+
+ const tableBody = [standardSkillsRows, generatedRows].filter(Boolean).join('\n');
+ const skillsTable = tableBody
+ ? `| Task | Read this skill file |
+|------|---------------------|
+${tableBody}`
+ : '';
return `${GITNEXUS_START_MARKER}
# GitNexus — Code Intelligence
@@ -153,11 +167,15 @@ This repository is listed under GitNexus **group(s): ${groupNames.join(', ')}**
`
: ''
-}## CLI
+}${
+ skillsTable
+ ? `## CLI
${skillsTable}
-${GITNEXUS_END_MARKER}`;
+`
+ : ''
+ }${GITNEXUS_END_MARKER}`;
}
/**
@@ -181,7 +199,9 @@ async function fileExists(filePath: string): Promise {
async function upsertGitNexusSection(
filePath: string,
content: string,
-): Promise<'created' | 'updated' | 'appended'> {
+ projectName: string,
+ stats: RepoStats,
+): Promise<'created' | 'updated' | 'appended' | 'preserved'> {
const exists = await fileExists(filePath);
if (!exists) {
@@ -205,7 +225,50 @@ async function upsertGitNexusSection(
);
if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
- // Replace existing section
+ const existingSection = existingContent.substring(
+ startIdx,
+ endIdx + GITNEXUS_END_MARKER.length,
+ );
+
+ // If the existing section contains , preserve the user's
+ // custom layout and only update the stats line (node/edge/flow counts).
+ // This lets teams trim the verbose default template to a lean format without
+ // having it overwritten on every `gitnexus analyze`.
+ //
+ // Note: the keep-marker check operates on `existingSection` (the substring
+ // between valid section markers identified by findSectionMarkerIndex), so
+ // a keep marker in user prose OUTSIDE the GitNexus block has no effect.
+ if (existingSection.includes('')) {
+ // Build the new stats line from the caller-provided values directly.
+ // We do NOT re-extract from `content` because:
+ // (a) first-bold extraction is fragile if the template evolves
+ // (b) the parenthesized-text fallback can match unrelated tuples
+ // like `({target: "symbolName", direction: "upstream"})`
+ // when noStats is set
+ // Passing projectName + stats explicitly makes the contract obvious.
+ // noStats controls template generation, not keep-section stat updates — the user opted into a stats line by keeping it.
+ const newStatsInner = `${stats.nodes || 0} symbols, ${stats.edges || 0} relationships, ${stats.processes || 0} execution flows`;
+ const statsLine = `Indexed as **${projectName}** (${newStatsInner})`;
+
+ // Match either canonical phrasing at line start (`^` with `m` flag) so we
+ // cannot replace prose embedded mid-paragraph. Deliberately no `$`: text
+ // after the closing `)` on the same line (e.g. ". MCP tools.") stays intact.
+ const statsPattern = /^(?:Indexed as|indexed by GitNexus as) \*\*[^*]+\*\* \([^)]+\)/m;
+
+ if (statsPattern.test(existingSection)) {
+ const updatedSection = existingSection.replace(statsPattern, statsLine);
+ const before = existingContent.substring(0, startIdx);
+ const after = existingContent.substring(endIdx + GITNEXUS_END_MARKER.length);
+ await fs.writeFile(filePath, (before + updatedSection + after).trim() + '\n', 'utf-8');
+ return 'updated';
+ }
+ // Keep marker present but no stats line matched. Section is preserved
+ // unchanged on disk; return a distinct status so callers/CLI output
+ // don't mis-report this as 'updated' (which would imply a write).
+ return 'preserved';
+ }
+
+ // No keep marker — replace existing section with full verbose content
const before = existingContent.substring(0, startIdx);
const after = existingContent.substring(endIdx + GITNEXUS_END_MARKER.length);
const newContent = before + content + after;
@@ -319,28 +382,33 @@ export async function generateAIContextFiles(
generatedSkills,
groupNames,
options?.noStats,
+ options?.skipSkills,
);
const createdFiles: string[] = [];
if (!options?.skipAgentsMd) {
// Create AGENTS.md (standard for Cursor, Windsurf, OpenCode, Cline, etc.)
const agentsPath = path.join(repoPath, 'AGENTS.md');
- const agentsResult = await upsertGitNexusSection(agentsPath, content);
+ const agentsResult = await upsertGitNexusSection(agentsPath, content, projectName, stats);
createdFiles.push(`AGENTS.md (${agentsResult})`);
// Create CLAUDE.md (for Claude Code)
const claudePath = path.join(repoPath, 'CLAUDE.md');
- const claudeResult = await upsertGitNexusSection(claudePath, content);
+ const claudeResult = await upsertGitNexusSection(claudePath, content, projectName, stats);
createdFiles.push(`CLAUDE.md (${claudeResult})`);
} else {
createdFiles.push('AGENTS.md (skipped via --skip-agents-md)');
createdFiles.push('CLAUDE.md (skipped via --skip-agents-md)');
}
- // Install skills to .claude/skills/gitnexus/
- const installedSkills = await installSkills(repoPath);
- if (installedSkills.length > 0) {
- createdFiles.push(`.claude/skills/gitnexus/ (${installedSkills.length} skills)`);
+ // Install skills to .claude/skills/gitnexus/ (unless --skip-skills)
+ if (!options?.skipSkills) {
+ const installedSkills = await installSkills(repoPath);
+ if (installedSkills.length > 0) {
+ createdFiles.push(`.claude/skills/gitnexus/ (${installedSkills.length} skills)`);
+ }
+ } else {
+ createdFiles.push('.claude/skills/gitnexus/ (skipped via --skip-skills)');
}
return { files: createdFiles };
diff --git a/gitnexus/src/cli/analyze.ts b/gitnexus/src/cli/analyze.ts
index 47745c575..a20503bc4 100644
--- a/gitnexus/src/cli/analyze.ts
+++ b/gitnexus/src/cli/analyze.ts
@@ -117,8 +117,22 @@ export interface AnalyzeOptions {
verbose?: boolean;
/** Skip AGENTS.md and CLAUDE.md gitnexus block updates. */
skipAgentsMd?: boolean;
- /** Omit volatile symbol/relationship counts from AGENTS.md and CLAUDE.md. */
- noStats?: boolean;
+ /**
+ * Stats inclusion in AGENTS.md and CLAUDE.md.
+ *
+ * Commander.js represents `--no-stats` as `stats: boolean` (default
+ * `true`; `false` when the user passes `--no-stats`), NOT as
+ * `noStats: boolean`. Reading the negated form would always be
+ * `undefined` and the flag would silently no-op (#1477). Consumers
+ * that want "did the user request --no-stats?" should compare with
+ * `=== false` to distinguish the explicit-off case from the
+ * default-on case.
+ */
+ stats?: boolean;
+ /** Skip installing standard GitNexus skill files to .claude/skills/gitnexus/. */
+ skipSkills?: boolean;
+ /** Pure index mode: skip all file injection (AGENTS.md, CLAUDE.md, skills). */
+ indexOnly?: boolean;
/** Index the folder even when no .git directory is present. */
skipGit?: boolean;
/**
@@ -150,6 +164,24 @@ export interface AnalyzeOptions {
embeddingDevice?: string;
}
+/**
+ * Whether the post-index skill step should run.
+ *
+ * The gated block does two things in sequence: (1) generates the community
+ * skill files from `--skills`, and (2) re-runs `generateAIContextFiles` so
+ * AGENTS.md/CLAUDE.md can reference the freshly written skills. Both are
+ * suppressed together — `--index-only` drops the entire step, not just the
+ * community-skill write. Name retained for the test contract; see call site
+ * in `analyzeCommand` for the AGENTS.md/CLAUDE.md re-generation it also gates.
+ *
+ * Kept as a pure helper so the `--index-only --skills` contract is unit-tested
+ * without booting the full analyze pipeline (#742 review).
+ */
+export const shouldGenerateCommunitySkillFiles = (
+ options: Pick | undefined,
+ pipelineResult: unknown,
+): boolean => Boolean(options?.skills && pipelineResult && !options?.indexOnly);
+
export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOptions) => {
if (ensureHeap()) return;
@@ -245,6 +277,18 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
console.log('\n GitNexus Analyzer\n');
+ // `--index-only` is the stronger contract — it suppresses every form of file
+ // injection, including community skill writes that `--skills` would normally
+ // produce. Surface the override explicitly so users don't wonder why a
+ // pipeline re-index ran but no skill files appeared. The pipeline still
+ // re-runs (see `force: options?.force || options?.skills` below); the warning
+ // is purely about the dropped post-index write step.
+ if (options?.indexOnly && options?.skills) {
+ console.log(
+ ' Note: --index-only overrides --skills; community skill files will not be written.\n',
+ );
+ }
+
let repoPath: string;
if (inputPath) {
repoPath = path.resolve(inputPath);
@@ -399,6 +443,9 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
// ── Run shared analysis orchestrator ───────────────────────────────
try {
+ const skipAll = options?.indexOnly;
+ const skipAgentsMd = skipAll || options?.skipAgentsMd;
+ const skipSkills = skipAll || options?.skipSkills;
const result = await runFullAnalysis(
repoPath,
{
@@ -410,8 +457,14 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
embeddingsNodeLimit,
dropEmbeddings: options?.dropEmbeddings,
skipGit: options?.skipGit,
- skipAgentsMd: options?.skipAgentsMd,
- noStats: options?.noStats,
+ skipAgentsMd,
+ skipSkills,
+ // commander.js `.option('--no-stats', …)` registers the flag as
+ // `options.stats` (boolean, default true; `false` when the user
+ // passed --no-stats). Reading `options?.noStats` here returns
+ // undefined every time, so the flag was a no-op on the markdown
+ // rewrite path before this fix. See #1477.
+ noStats: options?.stats === false,
registryName: options?.name,
// Registry-collision bypass — its own CLI flag, intentionally NOT
// overloading --force. A user who hits the collision guard should
@@ -456,8 +509,10 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
// a healthy index.
await assertAnalysisFinalized(repoPath);
- // Skill generation (CLI-only, uses pipeline result from analysis)
- if (options?.skills && result.pipelineResult) {
+ // Skill generation (CLI-only, uses pipeline result from analysis).
+ // Gated so `--index-only --skills` skips community skill writes too
+ // (`shouldGenerateCommunitySkillFiles` — see unit test).
+ if (shouldGenerateCommunitySkillFiles(options, result.pipelineResult)) {
updateBar(99, 'Generating skill files...');
try {
const { generateSkillFiles } = await import('./skill-gen.js');
@@ -497,7 +552,13 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
processes: s.processes,
},
skillResult.skills,
- { skipAgentsMd: options?.skipAgentsMd, noStats: options?.noStats },
+ {
+ skipAgentsMd,
+ skipSkills,
+ // Mirror runFullAnalysis `noStats` bridge (#1477) — same expression;
+ // exercised on the `--skills` path by analyze-no-stats-bridge.test.ts.
+ noStats: options?.stats === false,
+ },
);
}
} catch {
diff --git a/gitnexus/src/cli/augment.ts b/gitnexus/src/cli/augment.ts
index 553e3f591..a97f6e23c 100644
--- a/gitnexus/src/cli/augment.ts
+++ b/gitnexus/src/cli/augment.ts
@@ -2,7 +2,7 @@
* Augment CLI Command
*
* Fast-path command for platform hooks.
- * Shells out from Claude Code PreToolUse / Cursor beforeShellExecution hooks.
+ * Shells out from Claude Code PreToolUse / Cursor postToolUse hooks.
*
* Usage: gitnexus augment
* Returns enriched text to stdout.
diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts
index e4a455d40..4b009e4aa 100644
--- a/gitnexus/src/cli/index.ts
+++ b/gitnexus/src/cli/index.ts
@@ -33,9 +33,20 @@ program
'Drop existing embeddings on rebuild. By default, an `analyze` without `--embeddings` ' +
'preserves any embeddings already present in the index.',
)
- .option('--skills', 'Generate repo-specific skill files from detected communities')
+ .option(
+ '--skills',
+ 'Generate repo-specific skill files from detected communities ' +
+ '(no-op when --index-only is also set).',
+ )
.option('--skip-agents-md', 'Skip updating the gitnexus section in AGENTS.md and CLAUDE.md')
.option('--no-stats', 'Omit volatile file/symbol counts from AGENTS.md and CLAUDE.md')
+ .option(
+ '--skip-skills',
+ 'Skip installing standard GitNexus skill files under .claude/skills/gitnexus/. ' +
+ 'Does not suppress community skills from --skills (those use .claude/skills/generated/). ' +
+ 'Use --index-only to skip all AI-context file injection.',
+ )
+ .option('--index-only', 'Pure index mode: skip all file injection (AGENTS.md, CLAUDE.md, skills)')
.option(
'--skip-git',
'Treat the provided path/cwd as the index root and skip parent git-root discovery',
@@ -150,6 +161,8 @@ program
)
.option('--no-reasoning-model', 'Disable reasoning model mode (overrides saved config)')
.option('--concurrency ', 'Parallel LLM calls (default: 3)', '3')
+ .option('--timeout ', 'Per-attempt LLM request timeout in seconds (default: 60)')
+ .option('--retries ', 'Max LLM retry attempts per request (default: 3)')
.option('--gist', 'Publish wiki as a public GitHub Gist after generation')
.option('-v, --verbose', 'Enable verbose output (show LLM commands and responses)')
.option('--review', 'Stop after grouping to review module structure before generating pages')
diff --git a/gitnexus/src/cli/setup.ts b/gitnexus/src/cli/setup.ts
index af3c4737a..3e7cbad8f 100644
--- a/gitnexus/src/cli/setup.ts
+++ b/gitnexus/src/cli/setup.ts
@@ -364,6 +364,33 @@ async function installClaudeCodeHooks(result: SetupResult): Promise {
// Script not found in source — skip
}
+ try {
+ await fs.copyFile(
+ path.join(pluginHooksPath, 'hook-lock.cjs'),
+ path.join(destHooksDir, 'hook-lock.cjs'),
+ );
+ } catch {
+ // Helper not found in source — skip
+ }
+
+ try {
+ await fs.copyFile(
+ path.join(pluginHooksPath, 'hook-db-lock-probe.cjs'),
+ path.join(destHooksDir, 'hook-db-lock-probe.cjs'),
+ );
+ } catch {
+ // Helper not found in source — skip
+ }
+
+ try {
+ await fs.copyFile(
+ path.join(pluginHooksPath, 'win-rm-list-json.ps1'),
+ path.join(destHooksDir, 'win-rm-list-json.ps1'),
+ );
+ } catch {
+ // Helper not found in source — skip
+ }
+
const hookPath = path.join(destHooksDir, 'gitnexus-hook.cjs').replace(/\\/g, '/');
// Escape backslashes FIRST, then quotes (CodeQL js/incomplete-sanitization).
// The previous shape `replace(/"/g, '\\"')` alone would let `path\with"quote`
diff --git a/gitnexus/src/cli/wiki.ts b/gitnexus/src/cli/wiki.ts
index 38a0f82a6..8d9da9572 100644
--- a/gitnexus/src/cli/wiki.ts
+++ b/gitnexus/src/cli/wiki.ts
@@ -33,6 +33,8 @@ export interface WikiCommandOptions {
provider?: LLMProvider;
verbose?: boolean;
review?: boolean;
+ timeout?: string;
+ retries?: string;
}
/**
@@ -347,6 +349,16 @@ export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptio
}
}
+ // ── Apply per-run overrides not saved to config ────────────────────
+ if (options?.timeout) {
+ const secs = parseInt(options.timeout, 10);
+ if (!isNaN(secs) && secs > 0) llmConfig.requestTimeoutMs = secs * 1000;
+ }
+ if (options?.retries) {
+ const n = parseInt(options.retries, 10);
+ if (!isNaN(n) && n > 0) llmConfig.maxAttempts = n;
+ }
+
// ── Setup progress bar with elapsed timer ──────────────────────────
const bar = new cliProgress.SingleBar(
{
diff --git a/gitnexus/src/core/augmentation/engine.ts b/gitnexus/src/core/augmentation/engine.ts
index f97415cc9..81e41077e 100644
--- a/gitnexus/src/core/augmentation/engine.ts
+++ b/gitnexus/src/core/augmentation/engine.ts
@@ -2,8 +2,8 @@
* Augmentation Engine
*
* Lightweight, fast-path enrichment of search patterns with knowledge graph context.
- * Designed to be called from platform hooks (Claude Code PreToolUse, Cursor beforeShellExecution)
- * when an agent runs grep/glob/search.
+ * Designed to be called from platform hooks (Claude Code PreToolUse, Cursor postToolUse)
+ * when an agent runs grep/glob/read/search.
*
* Performance target: <500ms cold start, <200ms warm.
*
@@ -86,6 +86,9 @@ async function findRepoForCwd(cwd: string): Promise<{
export async function augment(pattern: string, cwd?: string): Promise {
if (!pattern || pattern.length < 3) return '';
+ const patternFirstWord = pattern.trim().replace(/'/g, "''").split(/\s+/)[0];
+ if (!patternFirstWord || patternFirstWord.length < 2) return '';
+
const workDir = cwd || process.cwd();
try {
@@ -104,9 +107,7 @@ export async function augment(pattern: string, cwd?: string): Promise {
}
// Step 1: BM25 search (fast, no embeddings)
- const { results: bm25Results } = await searchFTSFromLbug(pattern, 10, repoId);
-
- if (bm25Results.length === 0) return '';
+ const { results: bm25Results, ftsAvailable } = await searchFTSFromLbug(pattern, 10, repoId);
// Step 2: Map BM25 file results to symbols
const symbolMatches: Array<{
@@ -124,7 +125,7 @@ export async function augment(pattern: string, cwd?: string): Promise {
repoId,
`
MATCH (n) WHERE n.filePath = '${escaped}'
- AND n.name CONTAINS '${pattern.replace(/'/g, "''").split(/\s+/)[0]}'
+ AND n.name CONTAINS '${patternFirstWord}'
RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath
LIMIT 3
`,
@@ -143,6 +144,29 @@ export async function augment(pattern: string, cwd?: string): Promise {
}
}
+ // When FTS indexes are unavailable (read-only DB, first run before indexes are built),
+ // fall back to a direct name CONTAINS query so enrichment still works.
+ if (symbolMatches.length === 0 && !ftsAvailable) {
+ const fallbackRows = await executeQuery(
+ repoId,
+ `
+ MATCH (n)
+ WHERE n.name CONTAINS '${patternFirstWord}'
+ RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath
+ LIMIT 5
+ `,
+ ).catch(() => []);
+ for (const sym of fallbackRows) {
+ symbolMatches.push({
+ nodeId: sym.id || sym[0],
+ name: sym.name || sym[1],
+ type: sym.type || sym[2],
+ filePath: sym.filePath || sym[3],
+ score: 1.0,
+ });
+ }
+ }
+
if (symbolMatches.length === 0) return '';
// Step 3: Batch-fetch callers/callees/processes/cohesion for top matches
diff --git a/gitnexus/src/core/embeddings/ast-utils.ts b/gitnexus/src/core/embeddings/ast-utils.ts
index 8456b249e..e4976d71e 100644
--- a/gitnexus/src/core/embeddings/ast-utils.ts
+++ b/gitnexus/src/core/embeddings/ast-utils.ts
@@ -10,6 +10,7 @@ import {
isLanguageAvailable,
resolveLanguageKey,
} from '../tree-sitter/parser-loader.js';
+import { parseSourceSafe } from '../tree-sitter/safe-parse.js';
const parserCache = new Map();
@@ -29,7 +30,7 @@ export const ensureAndParse = async (content: string, filePath: string): Promise
parserCache.set(parserKey, parserInstance);
}
- return parserInstance.parse(content);
+ return parseSourceSafe(parserInstance, content);
};
const FUNCTION_LIKE_TYPES = new Set([
diff --git a/gitnexus/src/core/embeddings/hf-env.ts b/gitnexus/src/core/embeddings/hf-env.ts
index 95548fff2..5ae6d89ae 100644
--- a/gitnexus/src/core/embeddings/hf-env.ts
+++ b/gitnexus/src/core/embeddings/hf-env.ts
@@ -1,6 +1,8 @@
import os from 'node:os';
import { join } from 'node:path';
+import { CircuitBreaker, withRetry } from 'gitnexus-shared';
+
// ---------------------------------------------------------------------------
// Download resilience defaults
// ---------------------------------------------------------------------------
@@ -108,70 +110,19 @@ export function isNetworkFetchError(message: string): boolean {
/** @internal Used by `withHfDownloadRetry` to mark a circuit-open rejection. */
export const CIRCUIT_OPEN_TAG = 'hf-circuit-open';
-/** Circuit-breaker states. */
-type CircuitState = 'closed' | 'open' | 'half-open';
-
/**
- * Circuit breaker for HuggingFace model downloads.
- *
- * After `failureThreshold` consecutive network failures the circuit opens and
- * all subsequent calls to `withHfDownloadRetry` fail immediately without
- * issuing any network requests. After `resetTimeoutMs` the circuit enters the
- * half-open state and the next call is attempted — if it succeeds the circuit
- * closes again; if it fails the circuit re-opens.
- *
- * Exported for unit-testing; production code should use the module-level
- * `hfDownloadCircuit` singleton.
+ * Module-level singleton shared by both embedder entry points
+ * (`core/embeddings/embedder.ts` + `mcp/core/embedder.ts`). Per-process
+ * only — not persisted across restarts. Backed by the shared
+ * `CircuitBreaker` from `gitnexus-shared` (same state machine, same
+ * semantics, plus the single-permit half-open gate that prevents
+ * recovery-time stampedes).
*/
-export class HfDownloadCircuitBreaker {
- private _state: CircuitState = 'closed';
- private _failures = 0;
- /** Timestamp of the last recorded failure (ms since epoch). */
- lastFailureAt = 0;
-
- constructor(
- readonly failureThreshold: number = CB_FAILURE_THRESHOLD,
- readonly resetTimeoutMs: number = CB_RESET_TIMEOUT_MS,
- ) {}
-
- /** Effective state, factoring in the reset-timeout transition. */
- get state(): CircuitState {
- if (this._state === 'open' && Date.now() - this.lastFailureAt > this.resetTimeoutMs) {
- this._state = 'half-open';
- }
- return this._state;
- }
-
- /** Returns true when the circuit is open and calls should be rejected. */
- isOpen(): boolean {
- return this.state === 'open';
- }
-
- /** Record a successful call — resets the failure counter and closes the circuit. */
- recordSuccess(): void {
- this._failures = 0;
- this._state = 'closed';
- }
-
- /** Record a failed call — increments the counter and opens the circuit when the threshold is reached. */
- recordFailure(): void {
- this._failures++;
- this.lastFailureAt = Date.now();
- if (this._failures >= this.failureThreshold) {
- this._state = 'open';
- }
- }
-
- /** @internal Reset to initial state (used in tests). */
- reset(): void {
- this._failures = 0;
- this._state = 'closed';
- this.lastFailureAt = 0;
- }
-}
-
-/** Module-level singleton shared by both embedder entry points. */
-export const hfDownloadCircuit = new HfDownloadCircuitBreaker();
+export const hfDownloadCircuit = new CircuitBreaker({
+ failureThreshold: CB_FAILURE_THRESHOLD,
+ cooldownMs: CB_RESET_TIMEOUT_MS,
+ key: 'hf-download',
+});
// ---------------------------------------------------------------------------
// Retry + timeout wrapper
@@ -219,11 +170,6 @@ export function withDownloadTimeout(fn: () => Promise, timeoutMs: number):
});
}
-/** @internal Async sleep (exposed for testing). */
-export function sleep(ms: number): Promise {
- return new Promise((resolve) => setTimeout(resolve, ms));
-}
-
export interface HfRetryOptions {
/** Maximum total attempts including the initial one (default: `HF_MAX_ATTEMPTS`). */
maxAttempts?: number;
@@ -235,7 +181,7 @@ export interface HfRetryOptions {
* Circuit-breaker instance to use. Defaults to the module-level
* `hfDownloadCircuit` singleton. Pass a fresh instance in tests.
*/
- circuit?: HfDownloadCircuitBreaker;
+ circuit?: CircuitBreaker;
/**
* Optional callback invoked before each retry (not the initial attempt).
* @param attempt - 1-based retry number
@@ -295,49 +241,74 @@ export async function withHfDownloadRetry(
circuit = hfDownloadCircuit,
onRetry,
} = options;
- if (circuit.isOpen()) {
- const secsUntilReset = Math.ceil(
- (circuit.resetTimeoutMs - (Date.now() - circuit.lastFailureAt)) / 1000,
- );
+ if (circuit.getState() === 'open') {
+ // Compute remaining cooldown without consuming a probe permit.
+ const openedAt = circuit.getOpenedAt();
+ const secsUntilReset =
+ openedAt !== null ? Math.ceil((circuit.getCooldownMs() - (Date.now() - openedAt)) / 1000) : 0;
throw new Error(
`${CIRCUIT_OPEN_TAG}: HuggingFace download circuit is open after repeated network failures` +
(secsUntilReset > 0 ? ` — will reset in ~${secsUntilReset}s` : ''),
);
}
- let lastError: Error = new Error('unknown error');
+ // Retry budget delegated to `withRetry` from gitnexus-shared. The
+ // HF-specific bits — per-attempt timeout, network-vs-non-network
+ // classification, circuit-breaker recording, onRetry callback — wire
+ // through the `isRetryable` callback. `circuitTripped` is the
+ // sentinel that lets us replace the final thrown error with a
+ // CIRCUIT_OPEN_TAG message when the breaker tripped mid-loop.
+ let circuitTripped = false;
- for (let attempt = 0; attempt < maxAttempts; attempt++) {
- try {
- const result = await withDownloadTimeout(fn, timeoutMs);
- circuit.recordSuccess();
- return result;
- } catch (err) {
- lastError = err instanceof Error ? err : new Error(String(err));
-
- if (!isNetworkFetchError(lastError.message)) {
- // Non-network error (e.g. CUDA unavailable) — propagate without retry
- throw lastError;
- }
-
- circuit.recordFailure();
-
- if (circuit.isOpen()) {
- // Circuit just tripped — fail fast, no more retries
- throw new Error(
- `${CIRCUIT_OPEN_TAG}: HuggingFace download circuit opened after ${circuit.failureThreshold} consecutive failures`,
- );
- }
-
- if (attempt < maxAttempts - 1) {
- const delay = baseDelayMs * Math.pow(2, attempt);
- onRetry?.(attempt + 1, maxAttempts, lastError);
- await sleep(delay);
- }
+ try {
+ return await withRetry(
+ async () => {
+ const result = await withDownloadTimeout(fn, timeoutMs);
+ circuit.recordSuccess();
+ return result;
+ },
+ {
+ maxAttempts,
+ baseDelayMs,
+ // Disable the cap to match the bespoke pure-exponential
+ // progression. With the default `HF_MAX_ATTEMPTS_CAP = 10` and
+ // `baseDelayMs = 2000`, the largest possible delay is
+ // `2000 * 2^9 = ~17 minutes` — bounded enough not to need a cap.
+ capDelayMs: Number.MAX_SAFE_INTEGER,
+ isRetryable: (err, attempt) => {
+ const error = err instanceof Error ? err : new Error(String(err));
+ if (!isNetworkFetchError(error.message)) {
+ // Non-network error (e.g. CUDA unavailable) — propagate
+ // without retry. Use recordNeutral so the breaker's existing
+ // failure-count progress isn't reset by a non-network failure
+ // that says nothing about the CDN's health.
+ circuit.recordNeutral();
+ return { retry: false };
+ }
+ circuit.recordFailure();
+ if (circuit.getState() === 'open') {
+ // Circuit just tripped — fail fast, no more retries.
+ circuitTripped = true;
+ return { retry: false };
+ }
+ // Mirror the bespoke onRetry contract: fire only when there's
+ // actually a next attempt.
+ if (attempt + 1 < maxAttempts) {
+ onRetry?.(attempt + 1, maxAttempts, error);
+ }
+ return { retry: true };
+ },
+ },
+ );
+ } catch (err) {
+ if (circuitTripped) {
+ throw new Error(
+ `${CIRCUIT_OPEN_TAG}: HuggingFace download circuit opened after ${CB_FAILURE_THRESHOLD} consecutive failures`,
+ );
}
+ // All retries exhausted — rethrow the last network error so
+ // isNetworkFetchError patterns in the calling code still match and
+ // surface HF_ENDPOINT guidance.
+ throw err;
}
-
- // All retries exhausted — throw the last network error so isNetworkFetchError
- // patterns in the calling code still match and surface HF_ENDPOINT guidance.
- throw lastError;
}
diff --git a/gitnexus/src/core/embeddings/http-client.ts b/gitnexus/src/core/embeddings/http-client.ts
index 85ad79111..e8e9073ff 100644
--- a/gitnexus/src/core/embeddings/http-client.ts
+++ b/gitnexus/src/core/embeddings/http-client.ts
@@ -3,13 +3,22 @@
*
* Shared fetch+retry logic for OpenAI-compatible /v1/embeddings endpoints.
* Imported by both the core embedder (batch) and MCP embedder (query).
+ *
+ * Network resilience is delegated to `resilientFetch` from
+ * `gitnexus-shared` — bounded retries with exponential-backoff jitter,
+ * `Retry-After` honored on 429, and an in-process circuit breaker that
+ * fails fast on a flapping endpoint. Per-attempt timeout is enforced
+ * via `AbortSignal.timeout` on the underlying fetch.
*/
+import { CircuitOpenError, ResilientFetchExhaustedError, resilientFetch } from 'gitnexus-shared';
+
const HTTP_TIMEOUT_MS = 30_000;
const HTTP_MAX_RETRIES = 2;
const HTTP_RETRY_BACKOFF_MS = 1_000;
const HTTP_BATCH_SIZE = 64;
const DEFAULT_DIMS = 384;
+const HTTP_BREAKER_KEY = 'embeddings-http';
interface HttpConfig {
baseUrl: string;
@@ -31,8 +40,11 @@ const readConfig = (): HttpConfig | null => {
const rawDims = process.env.GITNEXUS_EMBEDDING_DIMS;
let dimensions: number | undefined;
if (rawDims !== undefined) {
+ if (!/^\d+$/.test(rawDims)) {
+ throw new Error(`GITNEXUS_EMBEDDING_DIMS must be a positive integer, got "${rawDims}"`);
+ }
const parsed = parseInt(rawDims, 10);
- if (Number.isNaN(parsed) || parsed <= 0) {
+ if (parsed <= 0) {
throw new Error(`GITNEXUS_EMBEDDING_DIMS must be a positive integer, got "${rawDims}"`);
}
dimensions = parsed;
@@ -82,7 +94,13 @@ interface EmbeddingItem {
* @param model - Model name for the request body
* @param apiKey - Bearer token (only used in Authorization header)
* @param batchIndex - Logical batch number (for error context)
- * @param attempt - Current retry attempt (internal)
+ * @param dimensions - Optional output-vector size. When provided, sent as
+ * the `dimensions` field in the request body. Endpoints that implement
+ * Matryoshka truncation (OpenAI text-embedding-3-*, Cohere embed-v3,
+ * Voyage) return a truncated vector at that size; endpoints that do not
+ * recognise the field may ignore it or return 400. Leave
+ * `GITNEXUS_EMBEDDING_DIMS` unset for strict backends that reject
+ * unknown fields.
*/
const httpEmbedBatch = async (
url: string,
@@ -90,46 +108,60 @@ const httpEmbedBatch = async (
model: string,
apiKey: string,
batchIndex = 0,
- attempt = 0,
+ dimensions?: number,
): Promise => {
+ const requestBody: { input: string[]; model: string; dimensions?: number } = {
+ input: batch,
+ model,
+ };
+ if (dimensions !== undefined) {
+ requestBody.dimensions = dimensions;
+ }
+
let resp: Response;
try {
- resp = await fetch(url, {
- method: 'POST',
- signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
- headers: {
- 'Content-Type': 'application/json',
- Authorization: `Bearer ${apiKey}`,
+ resp = await resilientFetch(
+ url,
+ {
+ method: 'POST',
+ signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${apiKey}`,
+ },
+ body: JSON.stringify(requestBody),
},
- body: JSON.stringify({ input: batch, model }),
- });
+ {
+ breakerKey: HTTP_BREAKER_KEY,
+ retry: { maxAttempts: HTTP_MAX_RETRIES + 1, baseDelayMs: HTTP_RETRY_BACKOFF_MS },
+ },
+ );
} catch (err) {
- // Timeouts should not be retried — the server is unresponsive.
- // AbortSignal.timeout() throws DOMException with name 'TimeoutError'.
- const isTimeout = err instanceof DOMException && err.name === 'TimeoutError';
- if (isTimeout) {
+ if (err instanceof CircuitOpenError) {
+ throw new Error(
+ `Embedding endpoint circuit open (${safeUrl(url)}, batch ${batchIndex}): retry in ${Math.ceil(err.retryAfterMs / 1000)}s`,
+ );
+ }
+ if (err instanceof DOMException && err.name === 'TimeoutError') {
throw new Error(
`Embedding request timed out after ${HTTP_TIMEOUT_MS}ms (${safeUrl(url)}, batch ${batchIndex})`,
);
}
- // DNS, connection errors — retry with backoff
- if (attempt < HTTP_MAX_RETRIES) {
- const delay = HTTP_RETRY_BACKOFF_MS * (attempt + 1);
- await new Promise((r) => setTimeout(r, delay));
- return httpEmbedBatch(url, batch, model, apiKey, batchIndex, attempt + 1);
+ if (err instanceof ResilientFetchExhaustedError) {
+ throw new Error(
+ `Embedding endpoint returned ${err.response.status} (${safeUrl(url)}, batch ${batchIndex})`,
+ );
}
const reason = err instanceof Error ? err.message : String(err);
throw new Error(`Embedding request failed (${safeUrl(url)}, batch ${batchIndex}): ${reason}`);
}
if (!resp.ok) {
- const status = resp.status;
- if ((status === 429 || status >= 500) && attempt < HTTP_MAX_RETRIES) {
- const delay = HTTP_RETRY_BACKOFF_MS * (attempt + 1);
- await new Promise((r) => setTimeout(r, delay));
- return httpEmbedBatch(url, batch, model, apiKey, batchIndex, attempt + 1);
- }
- throw new Error(`Embedding endpoint returned ${status} (${safeUrl(url)}, batch ${batchIndex})`);
+ // resilientFetch already retried 5xx/429; any non-OK response here is
+ // a terminal client error (4xx other than 429).
+ throw new Error(
+ `Embedding endpoint returned ${resp.status} (${safeUrl(url)}, batch ${batchIndex})`,
+ );
}
const data = (await resp.json()) as { data: EmbeddingItem[] };
@@ -155,7 +187,14 @@ export const httpEmbed = async (texts: string[]): Promise => {
for (let i = 0; i < texts.length; i += HTTP_BATCH_SIZE) {
const batch = texts.slice(i, i + HTTP_BATCH_SIZE);
const batchIndex = Math.floor(i / HTTP_BATCH_SIZE);
- const items = await httpEmbedBatch(url, batch, config.model, config.apiKey, batchIndex);
+ const items = await httpEmbedBatch(
+ url,
+ batch,
+ config.model,
+ config.apiKey,
+ batchIndex,
+ config.dimensions,
+ );
if (items.length !== batch.length) {
throw new Error(
@@ -198,7 +237,14 @@ export const httpEmbedQuery = async (text: string): Promise => {
if (!config) throw new Error('HTTP embedding not configured');
const url = `${config.baseUrl}/embeddings`;
- const items = await httpEmbedBatch(url, [text], config.model, config.apiKey);
+ const items = await httpEmbedBatch(
+ url,
+ [text],
+ config.model,
+ config.apiKey,
+ 0,
+ config.dimensions,
+ );
if (!items.length) {
throw new Error(`Embedding endpoint returned empty response (${safeUrl(url)})`);
}
diff --git a/gitnexus/src/core/group/bridge-db.ts b/gitnexus/src/core/group/bridge-db.ts
index ef6244b22..7f44253bf 100644
--- a/gitnexus/src/core/group/bridge-db.ts
+++ b/gitnexus/src/core/group/bridge-db.ts
@@ -722,13 +722,15 @@ export async function openBridgeDbReadOnly(groupDir: string): Promise setTimeout(r, delay));
}
}
- // Pino's NDJSON serialization is structurally injection-resistant
- // (CodeQL js/log-injection): groupDir and err.message are JSON-escaped
- // by the serializer, so no manual CRLF / U+2028 / ANSI sanitization is
- // needed. Demoted to debug — only fires when the bridge truly gave up
- // after retries, and operators only need it at debug verbosity.
+ // Strip CRLF from user-controlled strings before logging to close
+ // CodeQL js/log-injection. Pino's NDJSON serialization already
+ // JSON-escapes all values, but we sanitize here as a defence-in-depth
+ // measure so CodeQL can see the taint flow is broken.
+ const safeGroupDir = String(groupDir).replace(/[\r\n]/g, ' ');
+ const safeErrMsg =
+ lastErr instanceof Error ? String(lastErr.message).replace(/[\r\n]/g, ' ') : undefined;
bridgeLogger.debug(
- { groupDir, err: lastErr, attempts: LBUG_OPEN_RETRY_ATTEMPTS },
+ { groupDir: safeGroupDir, errMsg: safeErrMsg, attempts: LBUG_OPEN_RETRY_ATTEMPTS },
'openBridgeDbReadOnly gave up',
);
return null;
diff --git a/gitnexus/src/core/group/extractors/grpc-extractor.ts b/gitnexus/src/core/group/extractors/grpc-extractor.ts
index c08ba7c36..56107d8ee 100644
--- a/gitnexus/src/core/group/extractors/grpc-extractor.ts
+++ b/gitnexus/src/core/group/extractors/grpc-extractor.ts
@@ -5,6 +5,7 @@ import { createIgnoreFilter } from '../../../config/ignore-service.js';
import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js';
import type { ExtractedContract, RepoHandle } from '../types.js';
import { readSafe } from './fs-utils.js';
+import { parseSourceSafe } from '../../tree-sitter/safe-parse.js';
import { logger } from '../../logger.js';
import {
GRPC_SCAN_GLOB,
@@ -428,7 +429,7 @@ export class GrpcExtractor implements ContractExtractor {
let detections: GrpcDetection[] = [];
try {
parser.setLanguage(plugin.language);
- const tree = parser.parse(content);
+ const tree = parseSourceSafe(parser, content);
detections = plugin.scan(tree);
} catch {
continue;
diff --git a/gitnexus/src/core/group/extractors/http-patterns/python.ts b/gitnexus/src/core/group/extractors/http-patterns/python.ts
index 27ddf6633..0d3385247 100644
--- a/gitnexus/src/core/group/extractors/http-patterns/python.ts
+++ b/gitnexus/src/core/group/extractors/http-patterns/python.ts
@@ -1,3 +1,4 @@
+import type Parser from 'tree-sitter';
import Python from 'tree-sitter-python';
import {
compilePatterns,
@@ -12,6 +13,7 @@ import type { HttpDetection, HttpLanguagePlugin } from './types.js';
* - FastAPI `@app.get("/path")` provider decorators
* - `requests.get/post/...("url")` consumer calls
* - Generic `requests.request("METHOD", "url")` consumer calls
+ * - `httpx.AsyncClient` instances calling `.get/.post/...("url")`
*/
const FASTAPI_VERBS: Record = {
@@ -77,11 +79,161 @@ const REQUESTS_GENERIC_PATTERNS = compilePatterns({
],
} satisfies LanguagePatterns>);
+// ─── Consumer: httpx.AsyncClient assignments ────────────────────────
+// NOTE: This targeted detector only tracks explicit `httpx.AsyncClient(...)`
+// construction. Direct imports (`from httpx import AsyncClient`) and module
+// aliases (`import httpx as hx`) and annotated assignments (`client: httpx.AsyncClient = ...`)
+// are intentionally left for a follow-up. Module-scope clients are only matched
+// at module scope; calls inside functions require a function/class-local tracked
+// client to avoid false positives from same-name local variables.
+const HTTPX_ASYNC_CLIENT_ASSIGN_PATTERNS = compilePatterns({
+ name: 'python-httpx-async-client-assign',
+ language: Python,
+ patterns: [
+ {
+ meta: {},
+ query: `
+ (assignment
+ left: (_) @client
+ right: (call
+ function: (attribute
+ object: (identifier) @module (#eq? @module "httpx")
+ attribute: (identifier) @client_class (#eq? @client_class "AsyncClient"))))
+ `,
+ },
+ ],
+} satisfies LanguagePatterns>);
+
+// ─── Consumer: async with httpx.AsyncClient() as client ──────────────
+const HTTPX_ASYNC_CLIENT_WITH_ALIAS_PATTERNS = compilePatterns({
+ name: 'python-httpx-async-client-with-alias',
+ language: Python,
+ patterns: [
+ {
+ meta: {},
+ query: `
+ (as_pattern
+ (call
+ function: (attribute
+ object: (identifier) @module (#eq? @module "httpx")
+ attribute: (identifier) @client_class (#eq? @client_class "AsyncClient")))
+ (as_pattern_target (identifier) @client))
+ `,
+ },
+ ],
+} satisfies LanguagePatterns>);
+
+function getScopeKey(node: Parser.SyntaxNode | null, preferClass = false): string {
+ if (preferClass) {
+ let current: Parser.SyntaxNode | null = node;
+ while (current) {
+ if (current.type === 'class_definition') {
+ return `class:${current.startIndex}:${current.endIndex}`;
+ }
+ current = current.parent;
+ }
+ }
+
+ let current: Parser.SyntaxNode | null = node;
+ while (current) {
+ if (current.type === 'function_definition') {
+ return `function:${current.startIndex}:${current.endIndex}`;
+ }
+ current = current.parent;
+ }
+
+ return 'module';
+}
+
+function trackedClientScopeKey(clientNode: Parser.SyntaxNode): string {
+ return getScopeKey(clientNode.parent, clientNode.text.includes('.'));
+}
+
+function callScopeKeys(clientNode: Parser.SyntaxNode): string[] {
+ const keys = new Set();
+ const preferClass = clientNode.text.includes('.');
+ const nearestScope = getScopeKey(clientNode.parent, preferClass);
+
+ keys.add(nearestScope);
+
+ return [...keys];
+}
+
+function collectHttpxAsyncClients(tree: Parser.Tree): Map> {
+ const clients = new Map>();
+
+ const addClient = (clientNode: Parser.SyntaxNode | undefined) => {
+ if (!clientNode) return;
+ const scopeKey = trackedClientScopeKey(clientNode);
+ const clientText = clientNode.text;
+ const scopes = clients.get(clientText) ?? new Set();
+ scopes.add(scopeKey);
+ clients.set(clientText, scopes);
+ };
+
+ for (const match of runCompiledPatterns(HTTPX_ASYNC_CLIENT_ASSIGN_PATTERNS, tree)) {
+ addClient(match.captures.client);
+ }
+
+ for (const match of runCompiledPatterns(HTTPX_ASYNC_CLIENT_WITH_ALIAS_PATTERNS, tree)) {
+ addClient(match.captures.client);
+ }
+
+ return clients;
+}
+
+function hasTrackedHttpxAsyncClient(
+ clients: Map>,
+ clientNode: Parser.SyntaxNode,
+): boolean {
+ const scopes = clients.get(clientNode.text);
+ if (!scopes) return false;
+
+ return callScopeKeys(clientNode).some((scopeKey) => scopes.has(scopeKey));
+}
+
+// ─── Consumer: httpx AsyncClient .get/.post/...("url") ──────────────
+const HTTPX_ASYNC_CLIENT_VERB_PATTERNS = compilePatterns({
+ name: 'python-httpx-async-client-verb',
+ language: Python,
+ patterns: [
+ {
+ meta: {},
+ query: `
+ (call
+ function: (attribute
+ object: (_) @client
+ attribute: (identifier) @method (#match? @method "^(get|post|put|delete|patch)$"))
+ arguments: (argument_list . (string) @path))
+ `,
+ },
+ ],
+} satisfies LanguagePatterns>);
+
+// ─── Consumer: httpx AsyncClient .request("METHOD", "url") ─────────
+const HTTPX_ASYNC_CLIENT_GENERIC_PATTERNS = compilePatterns({
+ name: 'python-httpx-async-client-generic',
+ language: Python,
+ patterns: [
+ {
+ meta: {},
+ query: `
+ (call
+ function: (attribute
+ object: (_) @client
+ attribute: (identifier) @method (#eq? @method "request"))
+ arguments: (argument_list . (string) @http_method (string) @path))
+ `,
+ },
+ ],
+} satisfies LanguagePatterns>);
+
export const PYTHON_HTTP_PLUGIN: HttpLanguagePlugin = {
name: 'python-http',
language: Python,
scan(tree) {
const out: HttpDetection[] = [];
+ const httpxAsyncClients = collectHttpxAsyncClients(tree);
// Providers: FastAPI
for (const match of runCompiledPatterns(FASTAPI_PATTERNS, tree)) {
@@ -137,6 +289,45 @@ export const PYTHON_HTTP_PLUGIN: HttpLanguagePlugin = {
});
}
+ // Consumers: httpx.AsyncClient.("url")
+ for (const match of runCompiledPatterns(HTTPX_ASYNC_CLIENT_VERB_PATTERNS, tree)) {
+ const clientNode = match.captures.client;
+ const methodNode = match.captures.method;
+ const pathNode = match.captures.path;
+ if (!clientNode || !methodNode || !pathNode) continue;
+ if (!hasTrackedHttpxAsyncClient(httpxAsyncClients, clientNode)) continue;
+ const path = unquoteLiteral(pathNode.text);
+ if (path === null) continue;
+ out.push({
+ role: 'consumer',
+ framework: 'python-httpx',
+ method: methodNode.text.toUpperCase(),
+ path,
+ name: null,
+ confidence: 0.7,
+ });
+ }
+
+ // Consumers: httpx.AsyncClient.request("METHOD", "url")
+ for (const match of runCompiledPatterns(HTTPX_ASYNC_CLIENT_GENERIC_PATTERNS, tree)) {
+ const clientNode = match.captures.client;
+ const methodNode = match.captures.http_method;
+ const pathNode = match.captures.path;
+ if (!clientNode || !methodNode || !pathNode) continue;
+ if (!hasTrackedHttpxAsyncClient(httpxAsyncClients, clientNode)) continue;
+ const methodRaw = unquoteLiteral(methodNode.text);
+ const path = unquoteLiteral(pathNode.text);
+ if (methodRaw === null || path === null) continue;
+ out.push({
+ role: 'consumer',
+ framework: 'python-httpx',
+ method: methodRaw.toUpperCase(),
+ path,
+ name: null,
+ confidence: 0.7,
+ });
+ }
+
return out;
},
};
diff --git a/gitnexus/src/core/group/extractors/http-route-extractor.ts b/gitnexus/src/core/group/extractors/http-route-extractor.ts
index d989876a8..898a22c38 100644
--- a/gitnexus/src/core/group/extractors/http-route-extractor.ts
+++ b/gitnexus/src/core/group/extractors/http-route-extractor.ts
@@ -5,6 +5,7 @@ import { createIgnoreFilter } from '../../../config/ignore-service.js';
import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js';
import type { ExtractedContract, RepoHandle } from '../types.js';
import { readSafe } from './fs-utils.js';
+import { parseSourceSafe } from '../../tree-sitter/safe-parse.js';
import { getPluginForFile, HTTP_SCAN_GLOB, type HttpDetection } from './http-patterns/index.js';
/**
@@ -172,7 +173,7 @@ export class HttpRouteExtractor implements ContractExtractor {
}
try {
parser.setLanguage(plugin.language);
- const tree = parser.parse(content);
+ const tree = parseSourceSafe(parser, content);
const detections = plugin.scan(tree);
cachedDetections.set(rel, detections);
return detections;
diff --git a/gitnexus/src/core/group/extractors/include-extractor.ts b/gitnexus/src/core/group/extractors/include-extractor.ts
index 7bbfd61ed..98cbd371f 100644
--- a/gitnexus/src/core/group/extractors/include-extractor.ts
+++ b/gitnexus/src/core/group/extractors/include-extractor.ts
@@ -10,6 +10,7 @@ import { readSafe } from './fs-utils.js';
import { buildSuffixIndex, type SuffixIndex } from '../../ingestion/import-resolvers/utils.js';
import { createIgnoreFilter } from '../../../config/ignore-service.js';
import { getMaxFileSizeBytes } from '../../ingestion/utils/max-file-size.js';
+import { parseSourceSafe } from '../../tree-sitter/safe-parse.js';
import { logger } from '../../logger.js';
/**
@@ -505,7 +506,7 @@ export class IncludeExtractor implements ContractExtractor {
let extractionSource: 'tree_sitter' | 'regex_fallback';
try {
parser.setLanguage(lang);
- const tree = parser.parse(content);
+ const tree = parseSourceSafe(parser, content);
let matches: Parser.QueryMatch[];
try {
matches = query.matches(tree.rootNode);
diff --git a/gitnexus/src/core/group/extractors/thrift-extractor.ts b/gitnexus/src/core/group/extractors/thrift-extractor.ts
index 709968790..7d23e19cc 100644
--- a/gitnexus/src/core/group/extractors/thrift-extractor.ts
+++ b/gitnexus/src/core/group/extractors/thrift-extractor.ts
@@ -3,6 +3,7 @@ import Parser from 'tree-sitter';
import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js';
import type { ExtractedContract, RepoHandle } from '../types.js';
import { readSafe } from './fs-utils.js';
+import { parseSourceSafe } from '../../tree-sitter/safe-parse.js';
import {
getPluginForFile,
THRIFT_SCAN_GLOB,
@@ -311,7 +312,7 @@ export class ThriftExtractor implements ContractExtractor {
let detections: ThriftDetection[] = [];
try {
parser.setLanguage(plugin.language);
- const tree = parser.parse(content);
+ const tree = parseSourceSafe(parser, content);
detections = plugin.scan(tree);
} catch {
continue;
diff --git a/gitnexus/src/core/group/extractors/tree-sitter-scanner.ts b/gitnexus/src/core/group/extractors/tree-sitter-scanner.ts
index cd50456aa..0463e8f0c 100644
--- a/gitnexus/src/core/group/extractors/tree-sitter-scanner.ts
+++ b/gitnexus/src/core/group/extractors/tree-sitter-scanner.ts
@@ -1,4 +1,5 @@
import Parser from 'tree-sitter';
+import { parseSourceSafe } from '../../tree-sitter/safe-parse.js';
/**
* Shared, language-agnostic tree-sitter scanning utilities used by group
@@ -155,7 +156,7 @@ export function scanFile(
let tree: Parser.Tree;
try {
parser.setLanguage(plugin.language);
- tree = parser.parse(content);
+ tree = parseSourceSafe(parser, content);
} catch {
return [];
}
diff --git a/gitnexus/src/core/incremental/shadow-candidates.ts b/gitnexus/src/core/incremental/shadow-candidates.ts
new file mode 100644
index 000000000..415a6d9df
--- /dev/null
+++ b/gitnexus/src/core/incremental/shadow-candidates.ts
@@ -0,0 +1,76 @@
+/**
+ * Shadow-candidate path derivation for incremental indexing.
+ *
+ * Background — Bugbot review on PR #1479:
+ * queryImporters() on a NEWLY ADDED file returns 0 importers in the
+ * pre-pipeline DB, because the new file's IMPORTS rows haven't been
+ * written yet. But pre-existing files may have IMPORTS edges that
+ * *resolved to a sibling path*, and the newcomer can now steal that
+ * resolution under standard JS/TS module-resolution rules. Without
+ * pulling those pre-existing files into the writable set, their
+ * stale CALLS edges remain pointing at the OLD resolution target.
+ *
+ * Given an added file path, this helper enumerates the pre-existing
+ * file paths whose import-resolution claim the newcomer can steal.
+ * Caller filters the candidates against the prior-run `fileHashes`
+ * map so we only query importers of paths that actually existed.
+ *
+ * Shadow patterns covered (resolution-priority-aware):
+ *
+ * (a) Same basename, different extension —
+ * added `foo/bar.ts` shadows `foo/bar.{tsx,js,jsx,mjs,cjs,d.ts}`.
+ * (b) Bare-file beats directory-style index —
+ * added `foo/bar.ts` shadows `foo/bar/index.{ts,tsx,...}`.
+ * (c) Directory-index beats bare-file —
+ * added `foo/index.ts` shadows `foo.{ts,tsx,...}` (rare but real,
+ * e.g. converting a single-file module into a directory module).
+ *
+ * Resolution-order priority is conservatively wide: we enumerate ALL
+ * common extensions because we don't know which the importer actually
+ * specified, and over-seeding is harmless (extra BFS work, but the
+ * subgraph extract still gates write-back by file membership).
+ *
+ * Cross-platform path separators: candidates are emitted with both `/`
+ * and `\` for shadow pattern (b), since the caller's prior fileHashes
+ * map may use either depending on the OS that wrote it.
+ */
+
+const SHADOW_EXTS = ['.d.ts', '.tsx', '.ts', '.jsx', '.js', '.mjs', '.cjs'];
+
+/**
+ * Enumerate pre-existing paths whose import-resolution `added` can steal.
+ *
+ * @param added — repo-relative path of a newly-added file
+ * @returns deduplicated list of candidate paths (NOT filtered against
+ * any known-files set — caller does that)
+ */
+export const shadowCandidatesFor = (added: string): string[] => {
+ const ext = SHADOW_EXTS.find((e) => added.endsWith(e));
+ if (!ext) return [];
+
+ const noExt = added.slice(0, -ext.length);
+ const out = new Set();
+
+ // (a) Same basename, different extension.
+ for (const alt of SHADOW_EXTS) {
+ if (alt !== ext) out.add(noExt + alt);
+ }
+
+ // (b) Bare file beats sibling directory-style index.
+ for (const idx of SHADOW_EXTS) {
+ out.add(`${noExt}/index${idx}`);
+ out.add(`${noExt}\\index${idx}`);
+ }
+
+ // (c) New `foo/index.ext` shadows old `foo.ext`.
+ const idxSuffixSlash = '/index';
+ const idxSuffixBack = '\\index';
+ let dir: string | null = null;
+ if (noExt.endsWith(idxSuffixSlash)) dir = noExt.slice(0, -idxSuffixSlash.length);
+ else if (noExt.endsWith(idxSuffixBack)) dir = noExt.slice(0, -idxSuffixBack.length);
+ if (dir !== null) {
+ for (const alt of SHADOW_EXTS) out.add(dir + alt);
+ }
+
+ return [...out];
+};
diff --git a/gitnexus/src/core/incremental/subgraph-extract.ts b/gitnexus/src/core/incremental/subgraph-extract.ts
new file mode 100644
index 000000000..71fe656be
--- /dev/null
+++ b/gitnexus/src/core/incremental/subgraph-extract.ts
@@ -0,0 +1,123 @@
+/**
+ * Subgraph extraction for incremental DB writeback.
+ *
+ * Given the FULL ctx.graph produced by the pipeline (all files parsed,
+ * all phases run) and the set of file paths whose DB rows must be
+ * replaced, produce a smaller KnowledgeGraph that contains:
+ *
+ * - Every node whose `properties.filePath` is in `toWriteSet`.
+ * - Every graph-wide node (Community, Process) — these are regenerated
+ * each run by the communities/processes phases and must be fully
+ * rewritten.
+ * - Every relationship where AT LEAST ONE endpoint is in the writable
+ * set above. Relationships entirely between unchanged-file nodes
+ * are skipped — their rows are still in the DB and re-inserting
+ * them would PK-conflict at COPY time.
+ *
+ * The resulting subgraph is what gets passed to `loadGraphToLbug` after
+ * the orchestrator has deleted the corresponding DB rows. Hydrated
+ * unchanged-file rows are never touched in the DB.
+ *
+ * # Cross-file edge consistency (Finding 1)
+ *
+ * `extractChangedSubgraph` intentionally does NOT expand the set it is
+ * given — expansion is the orchestrator's job, so the SAME expanded set
+ * can be fed to both `deleteNodesForFile` and this function (asymmetry
+ * between the delete set and the write set silently corrupts the DB).
+ * `computeEffectiveWriteSet` below performs the boundary-crossing 1-hop
+ * walk; the orchestrator composes it with its importer-BFS expansion and
+ * passes the result here.
+ *
+ * Why the 1-hop walk is needed: consider a barrel re-export change —
+ * file C (a barrel) shifts `export { foo } from './b'` to
+ * `export { foo } from './d'`. After scope resolution, file A's CALLS
+ * edge to `foo` resolves to D instead of B, even though A's content is
+ * byte-for-byte identical:
+ *
+ * - Old A→B edge survives in DB (neither A nor B is changed → not deleted)
+ * - New A→D edge is missing (neither A nor D in writable set → skipped)
+ *
+ * Pulling the unchanged-side file of every writable-boundary-crossing
+ * edge into the write set fixes both halves: the orchestrator's
+ * `DETACH DELETE` cleans up the stale unchanged-side rows, and the new
+ * cross-file edges land because at least one endpoint is now writable.
+ *
+ * Limitation (documented): if a file X *stopped* importing from a
+ * changed file C, X has no edge to C in the new graph, so this 1-hop
+ * walk doesn't catch it. The orchestrator's importer-BFS (which reads
+ * IMPORTS from the pre-pipeline DB) covers that case instead.
+ */
+
+import type { GraphNode, GraphRelationship } from 'gitnexus-shared';
+import { createKnowledgeGraph } from '../graph/graph.js';
+import type { KnowledgeGraph } from '../graph/types.js';
+
+const isGraphWide = (label: string): boolean => label === 'Community' || label === 'Process';
+
+/**
+ * Build a Map for every File-bound node in the graph.
+ * Graph-wide nodes (Community/Process) have no filePath and are filtered.
+ */
+const indexNodeFilePaths = (fullGraph: KnowledgeGraph): Map => {
+ const idx = new Map();
+ fullGraph.forEachNode((n: GraphNode) => {
+ const fp = n.properties?.filePath as string | undefined;
+ if (fp) idx.set(n.id, fp);
+ });
+ return idx;
+};
+
+export const extractChangedSubgraph = (
+ fullGraph: KnowledgeGraph,
+ toWriteSet: ReadonlySet,
+): KnowledgeGraph => {
+ const sub = createKnowledgeGraph();
+ const writableNodeIds = new Set();
+
+ fullGraph.forEachNode((n: GraphNode) => {
+ const filePath = n.properties?.filePath as string | undefined;
+ const include = (filePath && toWriteSet.has(filePath)) || isGraphWide(n.label);
+ if (include) {
+ sub.addNode(n);
+ writableNodeIds.add(n.id);
+ }
+ });
+
+ fullGraph.forEachRelationship((r: GraphRelationship) => {
+ if (writableNodeIds.has(r.sourceId) || writableNodeIds.has(r.targetId)) {
+ sub.addRelationship(r);
+ }
+ });
+
+ return sub;
+};
+
+/**
+ * Public — derive the EFFECTIVE write-set: `toWriteSet` expanded by one
+ * hop along every edge in the new graph that crosses the writable
+ * boundary (one endpoint in a writable file, the other in an unchanged
+ * file). The unchanged-side file is pulled in so its stale rows are
+ * deleted + rewritten in lockstep with the changed side.
+ *
+ * Single pass over the edge list. Does NOT mutate `toWriteSet`. The
+ * orchestrator MUST feed the returned set to both `deleteNodesForFile`
+ * and `extractChangedSubgraph` — feeding the unexpanded set to either
+ * one leaves stale rows or PK-conflicts at COPY time.
+ */
+export const computeEffectiveWriteSet = (
+ fullGraph: KnowledgeGraph,
+ toWriteSet: ReadonlySet,
+): Set => {
+ const nodeFilePaths = indexNodeFilePaths(fullGraph);
+ const expanded = new Set(toWriteSet);
+ fullGraph.forEachRelationship((r: GraphRelationship) => {
+ const sourcePath = nodeFilePaths.get(r.sourceId);
+ const targetPath = nodeFilePaths.get(r.targetId);
+ if (!sourcePath || !targetPath) return; // skip edges to graph-wide nodes
+ const sourceWritable = toWriteSet.has(sourcePath);
+ const targetWritable = toWriteSet.has(targetPath);
+ if (sourceWritable && !targetWritable) expanded.add(targetPath);
+ else if (targetWritable && !sourceWritable) expanded.add(sourcePath);
+ });
+ return expanded;
+};
diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts
index bf0206057..35a59dab4 100644
--- a/gitnexus/src/core/ingestion/call-processor.ts
+++ b/gitnexus/src/core/ingestion/call-processor.ts
@@ -40,13 +40,16 @@ import { getLanguageFromFilename, SupportedLanguages } from 'gitnexus-shared';
import { isRegistryPrimary } from './registry-primary-flag.js';
import { isVerboseIngestionEnabled } from './utils/verbose.js';
import { yieldToEventLoop } from './utils/event-loop.js';
+import { parseSourceSafe } from '../tree-sitter/safe-parse.js';
import {
+ CLASS_CONTAINER_TYPES,
FUNCTION_NODE_TYPES,
- findEnclosingClassId,
findEnclosingClassInfo,
genericFuncName,
inferFunctionLabel,
} from './utils/ast-helpers.js';
+import type { FieldInfo, FieldExtractorContext } from './field-types.js';
+import type { LanguageProvider } from './language-provider.js';
import { typeTagForId, constTagForId, buildCollisionGroups } from './utils/method-props.js';
import type { MethodInfo } from './method-types.js';
import {
@@ -76,6 +79,62 @@ import type { LiteralTypeInferrer } from './type-extractors/types.js';
import type { SyntaxNode } from './utils/ast-helpers.js';
import { logger } from '../logger.js';
+
+// ── Property-prepass helpers (parity with parse-worker.ts) ──
+// These mirror the sequential-path equivalents in parse-worker.ts so the main-
+// thread `processCalls` pre-pass produces byte-identical Property nodes/symbols
+// to the worker pool. Drift between the two paths breaks the
+// `incremental ≡ --force` invariant the moment a repo crosses the worker
+// threshold between runs.
+
+/** Walk up to the nearest enclosing class/struct/interface AST node. */
+const findEnclosingClassNode = (node: SyntaxNode): SyntaxNode | null => {
+ let current = node.parent;
+ while (current) {
+ if (CLASS_CONTAINER_TYPES.has(current.type)) return current;
+ current = current.parent;
+ }
+ return null;
+};
+
+/** No-op SymbolTable stub for FieldExtractorContext — matches parse-worker. */
+const NOOP_SYMBOL_TABLE: SymbolTableReader = {
+ lookupExact: () => undefined,
+ lookupExactFull: () => undefined,
+ lookupExactAll: () => [],
+ lookupCallableByName: () => [],
+ getFiles: () => [][Symbol.iterator](),
+ getStats: () => ({ fileCount: 0 }),
+};
+
+/**
+ * Extract (and cache) field info for a class node. Cache is passed in so it
+ * stays scoped to a single `processCalls` invocation rather than leaking
+ * across analyze runs (worker uses module-level caching because each worker
+ * process is short-lived; the main thread is not).
+ *
+ * Cache key is `${filePath}:${classNode.startIndex}` — startIndex alone is a
+ * per-file byte offset, so almost every Ruby/Python file's leading class lands
+ * at byte 0 and would collide across files in the shared map.
+ */
+const getFieldInfo = (
+ classNode: SyntaxNode,
+ provider: LanguageProvider,
+ context: FieldExtractorContext,
+ cache: Map>,
+): Map | undefined => {
+ if (!provider.fieldExtractor) return undefined;
+ const cacheKey = `${context.filePath}:${classNode.startIndex}`;
+ const cached = cache.get(cacheKey);
+ if (cached) return cached;
+ const result = provider.fieldExtractor.extract(classNode, context);
+ if (!result?.fields?.length) return undefined;
+ const map = new Map();
+ for (const field of result.fields) map.set(field.name, field);
+ cache.set(cacheKey, map);
+ return map;
+};
+
/** Per-file resolved type bindings for exported symbols.
* Populated during call processing, consumed by Phase 14 re-resolution pass. */
export type ExportedTypeMap = Map>;
@@ -715,6 +774,7 @@ export const processCalls = async (
propertyName: string;
filePath: string;
srcId: string;
+ line?: number;
}[] = [];
// Phase P cross-file: accumulate heritage across files for cross-file isSubclassOf.
// Used as a secondary check when per-file parentMap lacks the relationship — helps
@@ -771,7 +831,7 @@ export const processCalls = async (
if (!tree) {
const parseContent = provider.preprocessSource?.(file.content, file.path) ?? file.content;
try {
- tree = parser.parse(parseContent, undefined, {
+ tree = parseSourceSafe(parser, parseContent, undefined, {
bufferSize: getTreeSitterBufferSize(parseContent),
});
} catch (parseError) {
@@ -859,6 +919,120 @@ export const processCalls = async (
prepared.push({ file, language, provider, tree, matches, parentMap, typeEnv });
}
+ // ── Property-registration pre-pass ──
+ // Register all routed properties (e.g. Ruby attr_accessor) BEFORE the
+ // resolution loop so cross-file field-type lookups (e.g.
+ // `user.address.save → Address#save`) succeed regardless of file
+ // processing order. This MUST stay in lockstep with the equivalent
+ // worker-path block in parse-worker.ts (kind === 'properties') — any
+ // divergence between the two paths breaks the `incremental ≡ --force`
+ // invariant once a repo crosses the worker threshold between runs.
+ const fieldInfoCache = new Map>();
+ for (const { file, language, provider, matches, typeEnv } of prepared) {
+ const callRouter = provider.callRouter;
+ if (!callRouter) continue;
+ matches.forEach((match) => {
+ const captureMap: Record = {};
+ match.captures.forEach((c) => (captureMap[c.name] = c.node));
+ if (!captureMap['call']) return;
+ const callNameNode = captureMap['call.name'];
+ if (!callNameNode) return;
+ const routed = callRouter(callNameNode.text, captureMap['call']);
+ if (!routed || routed.kind !== 'properties') return;
+
+ const propEnclosingInfo = findEnclosingClassInfo(
+ captureMap['call'],
+ file.path,
+ provider.resolveEnclosingOwner,
+ );
+ const propEnclosingClassId = propEnclosingInfo?.classId ?? null;
+
+ // Enrich routed properties with FieldExtractor metadata so types
+ // discovered from constructor assignments (e.g. `@address = Address.new`)
+ // are propagated even when the routing payload itself lacks declaredType.
+ let routedFieldMap: Map | undefined;
+ if (provider.fieldExtractor && typeEnv) {
+ const classNode = findEnclosingClassNode(captureMap['call']);
+ if (classNode) {
+ routedFieldMap = getFieldInfo(
+ classNode,
+ provider,
+ {
+ typeEnv,
+ symbolTable: NOOP_SYMBOL_TABLE,
+ filePath: file.path,
+ language,
+ },
+ fieldInfoCache,
+ );
+ }
+ }
+
+ const fileId = generateId('File', file.path);
+ for (const item of routed.items) {
+ const routedFieldInfo = routedFieldMap?.get(item.propName);
+ const propQualifiedName = propEnclosingInfo
+ ? `${propEnclosingInfo.className}.${item.propName}`
+ : item.propName;
+ const nodeId = generateId('Property', `${file.path}:${propQualifiedName}`);
+ graph.addNode({
+ id: nodeId,
+ label: 'Property',
+ properties: {
+ name: item.propName,
+ filePath: file.path,
+ startLine: item.startLine,
+ endLine: item.endLine,
+ language,
+ isExported: true,
+ description: item.accessorType,
+ ...(item.declaredType
+ ? { declaredType: item.declaredType }
+ : routedFieldInfo?.type
+ ? { declaredType: routedFieldInfo.type }
+ : {}),
+ ...(routedFieldInfo?.visibility !== undefined
+ ? { visibility: routedFieldInfo.visibility }
+ : {}),
+ ...(routedFieldInfo?.isStatic !== undefined
+ ? { isStatic: routedFieldInfo.isStatic }
+ : {}),
+ ...(routedFieldInfo?.isReadonly !== undefined
+ ? { isReadonly: routedFieldInfo.isReadonly }
+ : {}),
+ },
+ });
+ ctx.model.symbols.add(file.path, item.propName, nodeId, 'Property', {
+ ...(propEnclosingClassId ? { ownerId: propEnclosingClassId } : {}),
+ ...(item.declaredType
+ ? { declaredType: item.declaredType }
+ : routedFieldInfo?.type
+ ? { declaredType: routedFieldInfo.type }
+ : {}),
+ });
+ const relId = generateId('DEFINES', `${fileId}->${nodeId}`);
+ graph.addRelationship({
+ id: relId,
+ sourceId: fileId,
+ targetId: nodeId,
+ type: 'DEFINES',
+ confidence: 1.0,
+ reason: '',
+ });
+ if (propEnclosingClassId) {
+ graph.addRelationship({
+ id: generateId('HAS_PROPERTY', `${propEnclosingClassId}->${nodeId}`),
+ sourceId: propEnclosingClassId,
+ targetId: nodeId,
+ type: 'HAS_PROPERTY',
+ confidence: 1.0,
+ reason: '',
+ });
+ }
+ }
+ });
+ }
+
// ── Resolution loop: verify constructor bindings and resolve calls ──
// The accumulator (if present) is now fully populated from the preparation
// loop above, so verifyConstructorBindings sees all provider bindings
@@ -932,7 +1106,13 @@ export const processCalls = async (
// Defer resolution: Ruby attr_accessor properties are registered during
// this same loop, so cross-file lookups fail if the declaring file hasn't
// been processed yet. Collect now, resolve after all files are done.
- pendingWrites.push({ receiverTypeName, propertyName, filePath: file.path, srcId });
+ pendingWrites.push({
+ receiverTypeName,
+ propertyName,
+ filePath: file.path,
+ srcId,
+ line: captureMap['assignment'].startPosition.row + 1,
+ });
}
// Assignment-only capture (no @call sibling): skip the rest of this
// forEach iteration — this acts as a `continue` in the match loop.
@@ -1052,47 +1232,8 @@ export const processCalls = async (
return;
case 'properties': {
- const fileId = generateId('File', file.path);
- const propEnclosingClassId = findEnclosingClassId(captureMap['call'], file.path);
- for (const item of routed.items) {
- const nodeId = generateId('Property', `${file.path}:${item.propName}`);
- graph.addNode({
- id: nodeId,
- label: 'Property',
- properties: {
- name: item.propName,
- filePath: file.path,
- startLine: item.startLine,
- endLine: item.endLine,
- language,
- isExported: true,
- description: item.accessorType,
- },
- });
- ctx.model.symbols.add(file.path, item.propName, nodeId, 'Property', {
- ...(propEnclosingClassId ? { ownerId: propEnclosingClassId } : {}),
- ...(item.declaredType ? { declaredType: item.declaredType } : {}),
- });
- const relId = generateId('DEFINES', `${fileId}->${nodeId}`);
- graph.addRelationship({
- id: relId,
- sourceId: fileId,
- targetId: nodeId,
- type: 'DEFINES',
- confidence: 1.0,
- reason: '',
- });
- if (propEnclosingClassId) {
- graph.addRelationship({
- id: generateId('HAS_PROPERTY', `${propEnclosingClassId}->${nodeId}`),
- sourceId: propEnclosingClassId,
- targetId: nodeId,
- type: 'HAS_PROPERTY',
- confidence: 1.0,
- reason: '',
- });
- }
- }
+ // Properties already registered in the pre-pass above.
+ // Skip to avoid duplicate nodes/edges.
return;
}
@@ -1381,7 +1522,10 @@ export const processCalls = async (
);
if (fieldOwner) {
graph.addRelationship({
- id: generateId('ACCESSES', `${pw.srcId}:${fieldOwner.nodeId}:write`),
+ id: generateId(
+ 'ACCESSES',
+ `${pw.srcId}:${fieldOwner.nodeId}:write${pw.line !== undefined ? `:${pw.line}` : ''}`,
+ ),
sourceId: pw.srcId,
targetId: fieldOwner.nodeId,
type: 'ACCESSES',
@@ -2978,7 +3122,10 @@ export const processAssignmentsFromExtracted = (
const fieldOwner = resolveFieldOwnership(receiverTypeName, asn.propertyName, asn.filePath, ctx);
if (!fieldOwner) continue;
graph.addRelationship({
- id: generateId('ACCESSES', `${asn.sourceId}:${fieldOwner.nodeId}:write`),
+ id: generateId(
+ 'ACCESSES',
+ `${asn.sourceId}:${fieldOwner.nodeId}:write${asn.line !== undefined ? `:${asn.line}` : ''}`,
+ ),
sourceId: asn.sourceId,
targetId: fieldOwner.nodeId,
type: 'ACCESSES',
@@ -3283,7 +3430,7 @@ export const extractFetchCallsFromFiles = async (
if (!tree) {
const parseContent = provider.preprocessSource?.(file.content, file.path) ?? file.content;
try {
- tree = parser.parse(parseContent, undefined, {
+ tree = parseSourceSafe(parser, parseContent, undefined, {
bufferSize: getTreeSitterBufferSize(parseContent),
});
} catch {
diff --git a/gitnexus/src/core/ingestion/class-extractors/configs/c-cpp.ts b/gitnexus/src/core/ingestion/class-extractors/configs/c-cpp.ts
index fcc1a22bf..fb5df99c3 100644
--- a/gitnexus/src/core/ingestion/class-extractors/configs/c-cpp.ts
+++ b/gitnexus/src/core/ingestion/class-extractors/configs/c-cpp.ts
@@ -2,6 +2,40 @@
import { SupportedLanguages } from 'gitnexus-shared';
import type { ClassExtractionConfig } from '../../class-types.js';
+import {
+ extractTemplateArguments,
+ stripTemplateArguments,
+} from '../../utils/template-arguments.js';
+
+function shouldSkipCppTemplateDuplicateCapture(
+ captureMap: Record,
+ definitionName: string | undefined,
+ capturedName: string | undefined,
+): boolean {
+ if (captureMap['template-arguments'] !== undefined) return false;
+ if (!definitionName) return false;
+ const argsFromDefinitionName = extractTemplateArguments(definitionName);
+ if (argsFromDefinitionName === undefined) return false;
+ const argsFromCaptureName = capturedName ? extractTemplateArguments(capturedName) : undefined;
+ // Generic class capture emits only `List`, while the specialization-aware
+ // capture emits `List` + `@declaration.template-arguments`. Skip the former
+ // when the declaration name itself is templated to avoid duplicate class defs.
+ return argsFromCaptureName === undefined;
+}
+
+function extractCppTemplateArgumentsWithFallback(
+ captureMap: Record,
+ definitionName: string | undefined,
+ capturedName: string | undefined,
+): string[] | undefined {
+ return (
+ (captureMap['template-arguments']
+ ? extractTemplateArguments(captureMap['template-arguments'].text)
+ : undefined) ??
+ (definitionName ? extractTemplateArguments(definitionName) : undefined) ??
+ (capturedName ? extractTemplateArguments(capturedName) : undefined)
+ );
+}
export const cClassConfig: ClassExtractionConfig = {
language: SupportedLanguages.C,
@@ -12,4 +46,27 @@ export const cppClassConfig: ClassExtractionConfig = {
language: SupportedLanguages.CPlusPlus,
typeDeclarationNodes: ['class_specifier', 'struct_specifier', 'enum_specifier'],
ancestorScopeNodeTypes: ['namespace_definition', 'class_specifier', 'struct_specifier'],
+ extractName: (node) => {
+ const nameNode = node.childForFieldName?.('name');
+ if (!nameNode) return undefined;
+ if (nameNode.type !== 'template_type') return undefined;
+ return stripTemplateArguments(nameNode.text);
+ },
+ extractTemplateArguments: (node) => {
+ const nameNode = node.childForFieldName?.('name');
+ if (!nameNode || nameNode.type !== 'template_type') return undefined;
+ return extractTemplateArguments(nameNode.text);
+ },
+ shouldSkipClassCapture: ({ captureMap, definitionNode, nameNode }) =>
+ shouldSkipCppTemplateDuplicateCapture(
+ captureMap,
+ definitionNode?.childForFieldName?.('name')?.text,
+ nameNode?.text,
+ ),
+ extractTemplateArgumentsFromCapture: ({ captureMap, definitionNode, nameNode }) =>
+ extractCppTemplateArgumentsWithFallback(
+ captureMap,
+ definitionNode?.childForFieldName?.('name')?.text,
+ nameNode?.text,
+ ),
};
diff --git a/gitnexus/src/core/ingestion/class-extractors/generic.ts b/gitnexus/src/core/ingestion/class-extractors/generic.ts
index 303eb80c0..5f20d1dc2 100644
--- a/gitnexus/src/core/ingestion/class-extractors/generic.ts
+++ b/gitnexus/src/core/ingestion/class-extractors/generic.ts
@@ -154,10 +154,12 @@ export function createClassExtractor(config: ClassExtractionConfig): ClassExtrac
if (!name || !type) return null;
+ const templateArguments = config.extractTemplateArguments?.(node);
return {
name,
type,
qualifiedName: buildQualifiedName(node, name) || name,
+ ...(templateArguments !== undefined ? { templateArguments } : {}),
};
};
@@ -173,5 +175,13 @@ export function createClassExtractor(config: ClassExtractionConfig): ClassExtrac
extractQualifiedName(node: SyntaxNode, simpleName: string): string | null {
return extract(node, { name: simpleName })?.qualifiedName ?? null;
},
+
+ shouldSkipClassCapture(context): boolean {
+ return config.shouldSkipClassCapture?.(context) ?? false;
+ },
+
+ extractTemplateArgumentsFromCapture(context): string[] | undefined {
+ return config.extractTemplateArgumentsFromCapture?.(context);
+ },
};
}
diff --git a/gitnexus/src/core/ingestion/class-types.ts b/gitnexus/src/core/ingestion/class-types.ts
index 858d4c2eb..9407d41fa 100644
--- a/gitnexus/src/core/ingestion/class-types.ts
+++ b/gitnexus/src/core/ingestion/class-types.ts
@@ -10,6 +10,13 @@ export interface ExtractedClassSymbol {
name: string;
type: ClassLikeNodeLabel;
qualifiedName: string;
+ templateArguments?: string[];
+}
+
+export interface ClassCaptureContext {
+ captureMap: Record;
+ definitionNode: SyntaxNode | null;
+ nameNode: SyntaxNode | undefined;
}
/**
@@ -30,6 +37,10 @@ export interface ClassExtractor {
},
): ExtractedClassSymbol | null;
extractQualifiedName(node: SyntaxNode, simpleName: string): string | null;
+ shouldSkipClassCapture?(
+ context: ClassCaptureContext & { nodeLabel: ClassLikeNodeLabel },
+ ): boolean;
+ extractTemplateArgumentsFromCapture?(context: ClassCaptureContext): string[] | undefined;
}
export interface ClassExtractionConfig {
@@ -41,4 +52,9 @@ export interface ClassExtractionConfig {
extractName?: (node: SyntaxNode) => string | undefined;
extractType?: (node: SyntaxNode) => ClassLikeNodeLabel | undefined;
extractScopeSegments?: (node: SyntaxNode) => string[] | null | undefined;
+ extractTemplateArguments?: (node: SyntaxNode) => string[] | undefined;
+ shouldSkipClassCapture?(
+ context: ClassCaptureContext & { nodeLabel: ClassLikeNodeLabel },
+ ): boolean;
+ extractTemplateArgumentsFromCapture?(context: ClassCaptureContext): string[] | undefined;
}
diff --git a/gitnexus/src/core/ingestion/community-processor.ts b/gitnexus/src/core/ingestion/community-processor.ts
index 9913e4a3f..ac8f068fa 100644
--- a/gitnexus/src/core/ingestion/community-processor.ts
+++ b/gitnexus/src/core/ingestion/community-processor.ts
@@ -41,6 +41,24 @@ interface LeidenDetailedResult {
modularity: number;
}
+/**
+ * Deterministic PRNG (mulberry32) seed for the vendored Leiden algorithm.
+ * Vendored Leiden defaults `rng: Math.random`, which makes community
+ * assignment non-deterministic across runs. Passing a seeded RNG gives us
+ * reproducible community/modularity output, which is required for the
+ * incremental-indexing equivalence test (incremental ≡ full rebuild).
+ */
+const LEIDEN_SEED = 0xc0de;
+function createSeededRng(seed: number): () => number {
+ let s = seed >>> 0;
+ return () => {
+ s = (s + 0x6d2b79f5) >>> 0;
+ let t = Math.imul(s ^ (s >>> 15), 1 | s);
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+}
+
// ============================================================================
// TYPES
// ============================================================================
@@ -150,6 +168,7 @@ export const processCommunities = async (
leiden.detailed(graph, {
resolution: isLarge ? 2.0 : 1.0,
maxIterations: isLarge ? 3 : 0,
+ rng: createSeededRng(LEIDEN_SEED),
}),
),
new Promise((_, reject) =>
diff --git a/gitnexus/src/core/ingestion/heritage-processor.ts b/gitnexus/src/core/ingestion/heritage-processor.ts
index f8628e651..c223bd286 100644
--- a/gitnexus/src/core/ingestion/heritage-processor.ts
+++ b/gitnexus/src/core/ingestion/heritage-processor.ts
@@ -22,6 +22,7 @@ import { generateId } from '../../lib/utils.js';
import { getLanguageFromFilename, type NodeLabel, type SupportedLanguages } from 'gitnexus-shared';
import { isVerboseIngestionEnabled } from './utils/verbose.js';
import { yieldToEventLoop } from './utils/event-loop.js';
+import { parseSourceSafe } from '../tree-sitter/safe-parse.js';
import { getProvider } from './languages/index.js';
import { getTreeSitterBufferSize } from './constants.js';
import type {
@@ -224,7 +225,7 @@ export const processHeritage = async (
// re-parses see the same input as the cached AST.
const parseContent = provider.preprocessSource?.(file.content, file.path) ?? file.content;
try {
- tree = parser.parse(parseContent, undefined, {
+ tree = parseSourceSafe(parser, parseContent, undefined, {
bufferSize: getTreeSitterBufferSize(parseContent),
});
} catch (parseError) {
@@ -419,7 +420,7 @@ export async function extractExtractedHeritageFromFiles(
if (!tree) {
const parseContent = provider.preprocessSource?.(file.content, file.path) ?? file.content;
try {
- tree = parser.parse(parseContent, undefined, {
+ tree = parseSourceSafe(parser, parseContent, undefined, {
bufferSize: getTreeSitterBufferSize(parseContent),
});
} catch {
diff --git a/gitnexus/src/core/ingestion/import-processor.ts b/gitnexus/src/core/ingestion/import-processor.ts
index 6f0b40b6f..f7742faa1 100644
--- a/gitnexus/src/core/ingestion/import-processor.ts
+++ b/gitnexus/src/core/ingestion/import-processor.ts
@@ -8,6 +8,7 @@ import { generateId } from '../../lib/utils.js';
import { getLanguageFromFilename } from 'gitnexus-shared';
import { isVerboseIngestionEnabled } from './utils/verbose.js';
import { yieldToEventLoop } from './utils/event-loop.js';
+import { parseSourceSafe } from '../tree-sitter/safe-parse.js';
import type { ExtractedImport } from './workers/parse-worker.js';
import { getTreeSitterBufferSize } from './constants.js';
import { loadImportConfigs } from './language-config.js';
@@ -307,7 +308,7 @@ export const processImports = async (
if (!tree) {
const parseContent = provider.preprocessSource?.(file.content, file.path) ?? file.content;
try {
- tree = parser.parse(parseContent, undefined, {
+ tree = parseSourceSafe(parser, parseContent, undefined, {
bufferSize: getTreeSitterBufferSize(parseContent),
});
} catch (parseError) {
diff --git a/gitnexus/src/core/ingestion/import-resolvers/standard.ts b/gitnexus/src/core/ingestion/import-resolvers/standard.ts
index f8aae9625..4ee6e1440 100644
--- a/gitnexus/src/core/ingestion/import-resolvers/standard.ts
+++ b/gitnexus/src/core/ingestion/import-resolvers/standard.ts
@@ -72,6 +72,13 @@ export const resolveImportPath = (
const resolved = tryResolveWithExtensions(rewritten, allFiles);
if (resolved) return cache(resolved);
+ // ESM fallback: strip .js/.jsx/.mjs/.cjs and retry with TS equivalents
+ const strippedAlias = stripJsExtension(rewritten);
+ if (strippedAlias !== null) {
+ const esmResolved = tryResolveWithExtensions(strippedAlias, allFiles);
+ if (esmResolved) return cache(esmResolved);
+ }
+
// Try suffix matching as fallback
const parts = rewritten.split('/').filter(Boolean);
const suffixResult = suffixResolve(parts, normalizedFileList, allFileList, index);
@@ -128,7 +135,18 @@ export const resolveImportPath = (
if (importPath.startsWith('.')) {
const resolved = tryResolveWithExtensions(basePath, allFiles);
- return cache(resolved);
+ if (resolved) return cache(resolved);
+
+ // TypeScript ESM: imports use .js/.jsx/.mjs/.cjs but source files are
+ // .ts/.tsx/.mts/.cts. Strip the JS-family extension and re-resolve.
+ if (language === SupportedLanguages.TypeScript || language === SupportedLanguages.JavaScript) {
+ const stripped = stripJsExtension(basePath);
+ if (stripped !== null) {
+ return cache(tryResolveWithExtensions(stripped, allFiles));
+ }
+ }
+
+ return cache(null);
}
// ---- Generic package/absolute import resolution (suffix matching) ----
@@ -182,3 +200,19 @@ export function resolveStandard(
export function createStandardStrategy(language: SupportedLanguages): ImportResolverStrategy {
return (raw, fp, ctx) => resolveStandard(raw, fp, ctx, language);
}
+
+// ============================================================================
+// ESM extension helpers
+// ============================================================================
+
+/** JS-family extensions that TypeScript ESM maps to TS equivalents. */
+const JS_EXTENSION_PATTERN = /\.(js|jsx|mjs|cjs)$/;
+
+/**
+ * Strip a JS-family extension from a path, returning the stem.
+ * Returns `null` if the path does not end with a JS-family extension.
+ */
+export function stripJsExtension(path: string): string | null {
+ const match = JS_EXTENSION_PATTERN.exec(path);
+ return match ? path.slice(0, -match[0].length) : null;
+}
diff --git a/gitnexus/src/core/ingestion/import-resolvers/utils.ts b/gitnexus/src/core/ingestion/import-resolvers/utils.ts
index 8d915eb7f..c4d36556c 100644
--- a/gitnexus/src/core/ingestion/import-resolvers/utils.ts
+++ b/gitnexus/src/core/ingestion/import-resolvers/utils.ts
@@ -9,8 +9,12 @@ export const EXTENSIONS = [
// TypeScript/JavaScript
'.tsx',
'.ts',
+ '.mts',
+ '.cts',
'.jsx',
'.js',
+ '.mjs',
+ '.cjs',
'.vue',
'/index.tsx',
'/index.ts',
diff --git a/gitnexus/src/core/ingestion/languages/c-cpp.ts b/gitnexus/src/core/ingestion/languages/c-cpp.ts
index 693e8cec2..c010e0e15 100644
--- a/gitnexus/src/core/ingestion/languages/c-cpp.ts
+++ b/gitnexus/src/core/ingestion/languages/c-cpp.ts
@@ -46,6 +46,24 @@ import { createCallExtractor } from '../call-extractors/generic.js';
import { cCallConfig, cppCallConfig } from '../call-extractors/configs/c-cpp.js';
import { createHeritageExtractor } from '../heritage-extractors/generic.js';
import { stripUeMacros } from '../cpp-ue-preprocessor.js';
+import {
+ emitCScopeCaptures,
+ interpretCImport,
+ interpretCTypeBinding,
+ cArityCompatibility,
+ cBindingScopeFor,
+ cImportOwningScope,
+ cReceiverBinding,
+} from './c/index.js';
+import {
+ emitCppScopeCaptures,
+ interpretCppImport,
+ interpretCppTypeBinding,
+ cppArityCompatibility,
+ cppBindingScopeFor,
+ cppImportOwningScope,
+ cppReceiverBinding,
+} from './cpp/index.js';
const C_BUILT_INS: ReadonlySet = new Set([
'printf',
@@ -294,12 +312,19 @@ const cCppExtractFunctionName = (
return { funcName, label };
};
-/** Check if a C/C++ function_definition is inside a class or struct body.
+/** Check if a C/C++ function_definition is inside a class or struct body
+ * (and NOT a friend declaration).
* Used by cppLabelOverride to skip duplicate function captures
- * that are already covered by definition.method queries. */
+ * that are already covered by definition.method queries.
+ * Friend functions are free functions defined inside class bodies —
+ * they must NOT be skipped (ISO C++ hidden-friend idiom). */
function isCppInsideClassOrStruct(functionNode: SyntaxNode): boolean {
let ancestor: SyntaxNode | null = functionNode?.parent ?? null;
while (ancestor) {
+ // Friend declarations: the function_definition is wrapped in
+ // `friend_declaration` → `field_declaration_list` → class_specifier.
+ // These are free functions, not methods — don't skip them.
+ if (ancestor.type === 'friend_declaration') return false;
if (ancestor.type === 'class_specifier' || ancestor.type === 'struct_specifier') return true;
ancestor = ancestor.parent;
}
@@ -367,6 +392,16 @@ export const cProvider = defineLanguage({
heritageExtractor: createHeritageExtractor(SupportedLanguages.C),
labelOverride: cppLabelOverride,
builtInNames: C_BUILT_INS,
+
+ // ── RFC #909 Ring 3: scope-based resolution hooks (RFC §5) ──────────
+ emitScopeCaptures: emitCScopeCaptures,
+ interpretImport: interpretCImport,
+ interpretTypeBinding: interpretCTypeBinding,
+ bindingScopeFor: cBindingScopeFor,
+ importOwningScope: cImportOwningScope,
+ receiverBinding: cReceiverBinding,
+ arityCompatibility: cArityCompatibility,
+ // mergeBindings + resolveImportTarget live on ScopeResolver (see c/scope-resolver.ts).
});
export const cppProvider = defineLanguage({
@@ -428,4 +463,14 @@ export const cppProvider = defineLanguage({
heritageExtractor: createHeritageExtractor(SupportedLanguages.CPlusPlus),
labelOverride: cppLabelOverride,
builtInNames: C_BUILT_INS,
+
+ // ── RFC #909 Ring 3: scope-based resolution hooks (RFC §5) ──────────
+ emitScopeCaptures: emitCppScopeCaptures,
+ interpretImport: interpretCppImport,
+ interpretTypeBinding: interpretCppTypeBinding,
+ bindingScopeFor: cppBindingScopeFor,
+ importOwningScope: cppImportOwningScope,
+ receiverBinding: cppReceiverBinding,
+ arityCompatibility: cppArityCompatibility,
+ // mergeBindings + resolveImportTarget live on ScopeResolver (see cpp/scope-resolver.ts).
});
diff --git a/gitnexus/src/core/ingestion/languages/c/arity-metadata.ts b/gitnexus/src/core/ingestion/languages/c/arity-metadata.ts
new file mode 100644
index 000000000..013d78ea8
--- /dev/null
+++ b/gitnexus/src/core/ingestion/languages/c/arity-metadata.ts
@@ -0,0 +1,102 @@
+import type { SyntaxNode } from '../../utils/ast-helpers.js';
+
+export interface CArityInfo {
+ parameterCount?: number;
+ requiredParameterCount?: number;
+ parameterTypes?: string[];
+}
+
+/**
+ * Compute declaration arity from a C function definition or declaration node.
+ */
+export function computeCDeclarationArity(node: SyntaxNode): CArityInfo {
+ // Find the function_declarator child (may be wrapped in pointer_declarator)
+ const funcDecl = findFuncDeclarator(node);
+ if (funcDecl === null) return {};
+
+ const paramList = funcDecl.childForFieldName('parameters');
+ if (paramList === null) return {};
+
+ const params: SyntaxNode[] = [];
+ for (let i = 0; i < paramList.childCount; i++) {
+ const child = paramList.child(i);
+ if (child === null) continue;
+ if (child.type === 'parameter_declaration' || child.type === 'variadic_parameter') {
+ params.push(child);
+ }
+ }
+
+ // K&R old-style declaration: `int foo()` has an empty parameter_list with
+ // no parameter_declaration or variadic_parameter children. Per C89/C99,
+ // this means the function accepts an unspecified number/types of arguments —
+ // NOT zero arguments. Return unknown arity to avoid false 'incompatible'.
+ // `int foo(void)` is the explicit zero-parameter form and is handled below.
+ if (params.length === 0) return {};
+
+ // (void) means zero parameters
+ if (params.length === 1 && params[0].type === 'parameter_declaration') {
+ const typeNode = params[0].childForFieldName('type');
+ const hasDeclarator = params[0].childForFieldName('declarator') !== null;
+ if (typeNode !== null && typeNode.text === 'void' && !hasDeclarator) {
+ return { parameterCount: 0, requiredParameterCount: 0, parameterTypes: [] };
+ }
+ }
+
+ const isVariadic = params.some((p) => p.type === 'variadic_parameter');
+ const nonVariadicCount = params.filter((p) => p.type !== 'variadic_parameter').length;
+
+ const types: string[] = [];
+ for (const p of params) {
+ if (p.type === 'variadic_parameter') {
+ types.push('...');
+ } else {
+ const typeNode = p.childForFieldName('type');
+ types.push(typeNode?.text ?? 'unknown');
+ }
+ }
+
+ return {
+ parameterCount: isVariadic ? undefined : nonVariadicCount,
+ requiredParameterCount: nonVariadicCount,
+ parameterTypes: types,
+ };
+}
+
+/**
+ * Compute call-site arity from a call_expression node.
+ */
+export function computeCCallArity(node: SyntaxNode): number {
+ const argList = node.childForFieldName('arguments');
+ if (argList === null) return 0;
+
+ let count = 0;
+ for (let i = 0; i < argList.childCount; i++) {
+ const child = argList.child(i);
+ if (child === null) continue;
+ // Skip punctuation (commas, parens)
+ if (child.type !== ',' && child.type !== '(' && child.type !== ')') {
+ count++;
+ }
+ }
+ return count;
+}
+
+function findFuncDeclarator(node: SyntaxNode): SyntaxNode | null {
+ // Direct child
+ let decl = node.childForFieldName('declarator');
+ if (decl === null) {
+ for (let i = 0; i < node.childCount; i++) {
+ const c = node.child(i);
+ if (c?.type === 'function_declarator') return c;
+ }
+ return null;
+ }
+ // Unwrap pointer_declarator
+ while (decl.type === 'pointer_declarator') {
+ const next = decl.childForFieldName('declarator');
+ if (next === null) break;
+ decl = next;
+ }
+ if (decl.type === 'function_declarator') return decl;
+ return null;
+}
diff --git a/gitnexus/src/core/ingestion/languages/c/arity.ts b/gitnexus/src/core/ingestion/languages/c/arity.ts
new file mode 100644
index 000000000..7bbfa9e57
--- /dev/null
+++ b/gitnexus/src/core/ingestion/languages/c/arity.ts
@@ -0,0 +1,20 @@
+import type { Callsite, SymbolDefinition } from 'gitnexus-shared';
+
+/**
+ * C arity compatibility: no overloading. Variadic functions detected
+ * via '...' in parameterTypes. Otherwise exact match or unknown.
+ */
+export function cArityCompatibility(
+ def: SymbolDefinition,
+ callsite: Callsite,
+): 'compatible' | 'unknown' | 'incompatible' {
+ const max = def.parameterCount;
+ const min = def.requiredParameterCount;
+ if (max === undefined && min === undefined) return 'unknown';
+ if (!Number.isFinite(callsite.arity) || callsite.arity < 0) return 'unknown';
+
+ const variadic = def.parameterTypes?.some((t) => t === '...') ?? false;
+ if (min !== undefined && callsite.arity < min) return 'incompatible';
+ if (max !== undefined && callsite.arity > max && !variadic) return 'incompatible';
+ return 'compatible';
+}
diff --git a/gitnexus/src/core/ingestion/languages/c/captures.ts b/gitnexus/src/core/ingestion/languages/c/captures.ts
new file mode 100644
index 000000000..836eb3eac
--- /dev/null
+++ b/gitnexus/src/core/ingestion/languages/c/captures.ts
@@ -0,0 +1,142 @@
+import type { Capture, CaptureMatch } from 'gitnexus-shared';
+import {
+ findNodeAtRange,
+ nodeToCapture,
+ syntheticCapture,
+ type SyntaxNode,
+} from '../../utils/ast-helpers.js';
+import { getCParser, getCScopeQuery } from './query.js';
+import { getTreeSitterBufferSize } from '../../constants.js';
+import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
+import { splitCInclude } from './import-decomposer.js';
+import { computeCDeclarationArity, computeCCallArity } from './arity-metadata.js';
+import { markStaticName } from './static-linkage.js';
+
+export function emitCScopeCaptures(
+ sourceText: string,
+ filePath: string,
+ cachedTree?: unknown,
+): readonly CaptureMatch[] {
+ let tree = cachedTree as ReturnType['parse']> | undefined;
+ if (tree === undefined) {
+ tree = parseSourceSafe(getCParser(), sourceText, undefined, {
+ bufferSize: getTreeSitterBufferSize(sourceText),
+ });
+ }
+
+ const rawMatches = getCScopeQuery().matches(tree.rootNode);
+ const out: CaptureMatch[] = [];
+
+ // Track ranges where typedef-struct/union was captured as @declaration.struct/union
+ // so we can suppress the duplicate @declaration.typedef match at the same range.
+ const structTypedefRanges = new Set();
+
+ for (const m of rawMatches) {
+ const grouped: Record = {};
+ for (const c of m.captures) {
+ const tag = '@' + c.name;
+ if (tag.startsWith('@_')) continue;
+ grouped[tag] = nodeToCapture(tag, c.node);
+ }
+ if (Object.keys(grouped).length === 0) continue;
+
+ // Handle #include statements
+ if (grouped['@import.statement'] !== undefined) {
+ const anchor = grouped['@import.statement']!;
+ const includeNode = findNodeAtRange(tree.rootNode, anchor.range, 'preproc_include');
+ if (includeNode !== null) {
+ const split = splitCInclude(includeNode);
+ if (split !== null) {
+ out.push(split);
+ continue;
+ }
+ }
+ }
+
+ // Track typedef-struct ranges to suppress duplicate typedef declarations
+ const structAnchor = grouped['@declaration.struct'] ?? grouped['@declaration.union'];
+ if (structAnchor !== undefined) {
+ const r = structAnchor.range;
+ structTypedefRanges.add(`${r.startLine}:${r.startCol}:${r.endLine}:${r.endCol}`);
+ }
+
+ // Suppress @declaration.typedef if the same range was already captured as struct/union
+ const typedefAnchor = grouped['@declaration.typedef'];
+ if (typedefAnchor !== undefined) {
+ const r = typedefAnchor.range;
+ const key = `${r.startLine}:${r.startCol}:${r.endLine}:${r.endCol}`;
+ if (structTypedefRanges.has(key)) continue;
+ }
+
+ // Enrich function declarations with arity metadata and detect static linkage
+ const declAnchor = grouped['@declaration.function'];
+ if (declAnchor !== undefined) {
+ const fnNode =
+ findNodeAtRange(tree.rootNode, declAnchor.range, 'function_definition') ??
+ findNodeAtRange(tree.rootNode, declAnchor.range, 'declaration');
+ if (fnNode !== null) {
+ const arity = computeCDeclarationArity(fnNode);
+ if (arity.parameterCount !== undefined) {
+ grouped['@declaration.parameter-count'] = syntheticCapture(
+ '@declaration.parameter-count',
+ fnNode,
+ String(arity.parameterCount),
+ );
+ }
+ if (arity.requiredParameterCount !== undefined) {
+ grouped['@declaration.required-parameter-count'] = syntheticCapture(
+ '@declaration.required-parameter-count',
+ fnNode,
+ String(arity.requiredParameterCount),
+ );
+ }
+ if (arity.parameterTypes !== undefined) {
+ grouped['@declaration.parameter-types'] = syntheticCapture(
+ '@declaration.parameter-types',
+ fnNode,
+ JSON.stringify(arity.parameterTypes),
+ );
+ }
+
+ // Detect static storage class (file-local linkage)
+ if (hasStaticStorageClass(fnNode)) {
+ const nameText = grouped['@declaration.name']?.text;
+ if (nameText !== undefined) {
+ markStaticName(filePath, nameText);
+ }
+ }
+ }
+ }
+
+ // Enrich call references with arity
+ const callAnchor = grouped['@reference.call.free'] ?? grouped['@reference.call.member'];
+ if (callAnchor !== undefined && grouped['@reference.arity'] === undefined) {
+ const callNode = findNodeAtRange(tree.rootNode, callAnchor.range, 'call_expression');
+ if (callNode !== null) {
+ grouped['@reference.arity'] = syntheticCapture(
+ '@reference.arity',
+ callNode,
+ String(computeCCallArity(callNode)),
+ );
+ }
+ }
+
+ out.push(grouped);
+ }
+
+ return out;
+}
+
+/**
+ * Check if a C function_definition or declaration has `static` storage class.
+ * Walks direct children for a `storage_class_specifier` node with text `static`.
+ */
+function hasStaticStorageClass(node: SyntaxNode): boolean {
+ for (let i = 0; i < node.childCount; i++) {
+ const child = node.child(i);
+ if (child !== null && child.type === 'storage_class_specifier' && child.text === 'static') {
+ return true;
+ }
+ }
+ return false;
+}
diff --git a/gitnexus/src/core/ingestion/languages/c/header-scan.ts b/gitnexus/src/core/ingestion/languages/c/header-scan.ts
new file mode 100644
index 000000000..034e27f4d
--- /dev/null
+++ b/gitnexus/src/core/ingestion/languages/c/header-scan.ts
@@ -0,0 +1,58 @@
+import { readdirSync, type Dirent } from 'fs';
+import { join, relative } from 'path';
+
+/** C header extensions to scan for in the workspace. */
+const HEADER_EXTENSIONS = new Set(['.h']);
+
+/**
+ * Walk `repoPath` recursively and return relative paths of all `.h` files.
+ * Used by `loadResolutionConfig` so the C resolver can resolve `#include`
+ * targets that live in `.h` files (classified as C++ by language detection
+ * but importable from `.c` files).
+ */
+export function scanHeaderFiles(repoPath: string): ReadonlySet {
+ const headers = new Set();
+ walk(repoPath, repoPath, headers);
+ return headers;
+}
+
+function walk(dir: string, root: string, out: Set): void {
+ let entries: Dirent[];
+ try {
+ entries = readdirSync(dir, { withFileTypes: true, encoding: 'utf8' });
+ } catch {
+ return; // permission denied, etc.
+ }
+ for (const entry of entries) {
+ const name = entry.name;
+ const full = join(dir, name);
+ if (entry.isDirectory()) {
+ // Skip common non-source directories and build output dirs.
+ // Build dirs (dist, build, out, target, _build, .next, cmake-build-*)
+ // may contain generated headers that shadow source headers.
+ if (
+ name === 'node_modules' ||
+ name === '.git' ||
+ name === 'vendor' ||
+ name === 'dist' ||
+ name === 'build' ||
+ name === 'out' ||
+ name === 'target' ||
+ name === '_build' ||
+ name === '.next' ||
+ name.startsWith('cmake-build')
+ ) {
+ continue;
+ }
+ walk(full, root, out);
+ } else if (entry.isFile()) {
+ const ext = name.slice(name.lastIndexOf('.'));
+ if (HEADER_EXTENSIONS.has(ext)) {
+ // Normalize to forward slashes for cross-platform consistency.
+ // path.relative() returns backslash-separated paths on Windows,
+ // but the scope-resolution pipeline uses forward slashes uniformly.
+ out.add(relative(root, full).replace(/\\/g, '/'));
+ }
+ }
+ }
+}
diff --git a/gitnexus/src/core/ingestion/languages/c/import-decomposer.ts b/gitnexus/src/core/ingestion/languages/c/import-decomposer.ts
new file mode 100644
index 000000000..5cef27430
--- /dev/null
+++ b/gitnexus/src/core/ingestion/languages/c/import-decomposer.ts
@@ -0,0 +1,55 @@
+import type { Capture, CaptureMatch } from 'gitnexus-shared';
+import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
+
+/**
+ * Decompose a `preproc_include` node into a CaptureMatch with structured
+ * import captures. C #include maps to a wildcard import (all symbols
+ * from the header are visible).
+ */
+export function splitCInclude(node: SyntaxNode): CaptureMatch | null {
+ // node.type === 'preproc_include'
+ // path field: (string_literal (string_content)) | (system_lib_string)
+ const pathNode = node.childForFieldName?.('path') ?? null;
+ if (pathNode === null) {
+ // Fallback: scan children
+ for (let i = 0; i < node.childCount; i++) {
+ const child = node.child(i);
+ if (child === null) continue;
+ if (child.type === 'string_literal' || child.type === 'system_lib_string') {
+ return buildIncludeCapture(node, child);
+ }
+ }
+ return null;
+ }
+ return buildIncludeCapture(node, pathNode);
+}
+
+function buildIncludeCapture(node: SyntaxNode, pathNode: SyntaxNode): CaptureMatch {
+ let raw: string;
+ if (pathNode.type === 'string_literal') {
+ // string_literal has children: `"`, string_content, `"`
+ // Use namedChildren to find the string_content node
+ const content = pathNode.namedChildren.find((c) => c.type === 'string_content');
+ raw = content?.text ?? pathNode.text.replace(/^"|"$/g, '');
+ } else {
+ // system_lib_string: → strip angle brackets
+ raw = pathNode.text;
+ if (raw.startsWith('<') && raw.endsWith('>')) {
+ raw = raw.slice(1, -1);
+ }
+ }
+
+ const isSystem = pathNode.type === 'system_lib_string';
+
+ const result: Record = {
+ '@import.statement': nodeToCapture('@import.statement', node),
+ '@import.kind': syntheticCapture('@import.kind', node, 'wildcard'),
+ '@import.source': syntheticCapture('@import.source', node, raw),
+ };
+
+ if (isSystem) {
+ result['@import.system'] = syntheticCapture('@import.system', node, 'true');
+ }
+
+ return result;
+}
diff --git a/gitnexus/src/core/ingestion/languages/c/import-target.ts b/gitnexus/src/core/ingestion/languages/c/import-target.ts
new file mode 100644
index 000000000..0cb9c2fb4
--- /dev/null
+++ b/gitnexus/src/core/ingestion/languages/c/import-target.ts
@@ -0,0 +1,64 @@
+import { dirname, join } from 'path';
+
+/**
+ * Resolve a C #include path to a file in the workspace.
+ *
+ * Strategy:
+ * 1. Check for a same-directory sibling relative to the including file
+ * (matches C compiler `#include "…"` relative-lookup semantics).
+ * 2. Check for an exact match (path as-is in the workspace).
+ * 3. Fall back to suffix matching against all workspace file paths.
+ * Tie-breaking: prefer the match with the fewest path components
+ * (closest to root). On equal depth, break ties lexicographically
+ * by normalized path to ensure deterministic resolution regardless
+ * of filesystem iteration order.
+ */
+export function resolveCImportTarget(
+ targetRaw: string,
+ fromFile: string,
+ allFilePaths: ReadonlySet,
+): string | null {
+ if (!targetRaw) return null;
+
+ const normalizedTarget = targetRaw.replace(/\\/g, '/');
+
+ // Same-directory sibling first: mirrors the C compiler's #include "…"
+ // relative-lookup semantics where the directory of the including
+ // file is searched before the include-path list.
+ if (fromFile) {
+ const siblingRaw = join(dirname(fromFile), targetRaw);
+ const sibling = siblingRaw.replace(/\\/g, '/');
+ if (allFilePaths.has(sibling)) return sibling;
+ // When targetRaw contains backslashes, the normalized form may
+ // resolve to a different sibling path — try it as well.
+ if (targetRaw !== normalizedTarget) {
+ const siblingAlt = join(dirname(fromFile), normalizedTarget);
+ const siblingAltNorm = siblingAlt.replace(/\\/g, '/');
+ if (allFilePaths.has(siblingAltNorm)) return siblingAltNorm;
+ }
+ }
+
+ // Exact match (path as-is in the workspace)
+ if (allFilePaths.has(normalizedTarget)) return normalizedTarget;
+
+ // Suffix match: find files ending with /targetRaw or equal to targetRaw
+ const suffix = '/' + normalizedTarget;
+ let bestMatch: string | null = null;
+ let bestDepth = Infinity;
+ let bestNormalized = '';
+
+ for (const filePath of allFilePaths) {
+ const normalized = filePath.replace(/\\/g, '/');
+ if (normalized === normalizedTarget || normalized.endsWith(suffix)) {
+ // Prefer shortest path (closest match)
+ const depth = normalized.split('/').length;
+ if (depth < bestDepth || (depth === bestDepth && normalized < bestNormalized)) {
+ bestDepth = depth;
+ bestMatch = filePath;
+ bestNormalized = normalized;
+ }
+ }
+ }
+
+ return bestMatch;
+}
diff --git a/gitnexus/src/core/ingestion/languages/c/index.ts b/gitnexus/src/core/ingestion/languages/c/index.ts
new file mode 100644
index 000000000..c6900ecba
--- /dev/null
+++ b/gitnexus/src/core/ingestion/languages/c/index.ts
@@ -0,0 +1,16 @@
+/**
+ * C scope-resolution hooks (RFC #909 Ring 3).
+ */
+export { emitCScopeCaptures } from './captures.js';
+export { interpretCImport, interpretCTypeBinding, normalizeCTypeName } from './interpret.js';
+export { splitCInclude } from './import-decomposer.js';
+export { cArityCompatibility } from './arity.js';
+export { cMergeBindings } from './merge-bindings.js';
+export { cBindingScopeFor, cImportOwningScope, cReceiverBinding } from './simple-hooks.js';
+export { resolveCImportTarget } from './import-target.js';
+export {
+ markStaticName,
+ isStaticName,
+ clearStaticNames,
+ expandCWildcardNames,
+} from './static-linkage.js';
diff --git a/gitnexus/src/core/ingestion/languages/c/interpret.ts b/gitnexus/src/core/ingestion/languages/c/interpret.ts
new file mode 100644
index 000000000..e5a15f2fe
--- /dev/null
+++ b/gitnexus/src/core/ingestion/languages/c/interpret.ts
@@ -0,0 +1,51 @@
+import type { CaptureMatch, ParsedImport, ParsedTypeBinding, TypeRef } from 'gitnexus-shared';
+
+/**
+ * Interpret a C #include capture into a ParsedImport.
+ * C includes are always wildcard imports (all symbols from the header).
+ */
+export function interpretCImport(captures: CaptureMatch): ParsedImport | null {
+ const source = captures['@import.source']?.text;
+ if (source === undefined) return null;
+
+ // System headers (e.g. ) are not resolved to local files
+ if (captures['@import.system'] !== undefined) return null;
+
+ return { kind: 'wildcard', targetRaw: source };
+}
+
+/**
+ * Interpret a C type-binding capture into a ParsedTypeBinding.
+ */
+export function interpretCTypeBinding(captures: CaptureMatch): ParsedTypeBinding | null {
+ const name = captures['@type-binding.name']?.text;
+ const type = captures['@type-binding.type']?.text;
+ if (name === undefined || type === undefined) return null;
+
+ let source: TypeRef['source'] = 'annotation';
+
+ if (captures['@type-binding.parameter'] !== undefined) {
+ source = 'parameter-annotation';
+ } else if (captures['@type-binding.assignment'] !== undefined) {
+ source = 'assignment-inferred';
+ }
+
+ return { boundName: name, rawTypeName: normalizeCTypeName(type), source };
+}
+
+/**
+ * Normalize a C type name: strip pointer/array syntax, qualifiers.
+ */
+export function normalizeCTypeName(text: string): string {
+ let t = text.trim();
+ // Strip const, volatile, restrict qualifiers
+ t = t.replace(/\b(const|volatile|restrict|static|extern|inline)\b/g, '').trim();
+ // Strip pointer stars
+ while (t.endsWith('*')) t = t.slice(0, -1).trim();
+ while (t.startsWith('*')) t = t.slice(1).trim();
+ // Strip array brackets
+ t = t.replace(/\[.*?\]/g, '').trim();
+ // Strip struct/union/enum prefixes
+ t = t.replace(/^(struct|union|enum)\s+/, '');
+ return t;
+}
diff --git a/gitnexus/src/core/ingestion/languages/c/merge-bindings.ts b/gitnexus/src/core/ingestion/languages/c/merge-bindings.ts
new file mode 100644
index 000000000..b8bd20c6f
--- /dev/null
+++ b/gitnexus/src/core/ingestion/languages/c/merge-bindings.ts
@@ -0,0 +1,32 @@
+import type { BindingRef } from 'gitnexus-shared';
+
+const TIER: Record = {
+ local: 0,
+ namespace: 1,
+ import: 2,
+ reexport: 3,
+ wildcard: 4,
+};
+
+/**
+ * C merge bindings: simple first-wins by tier (local > import > wildcard).
+ * C has no namespaces or reexports, but the tiers are defined for
+ * compatibility with the shared infrastructure.
+ */
+export function cMergeBindings(
+ existing: readonly BindingRef[],
+ incoming: readonly BindingRef[],
+ _scopeId: string,
+): BindingRef[] {
+ const seen = new Set();
+ return [...existing, ...incoming]
+ .sort(
+ (a, b) =>
+ (TIER[a.origin] ?? 99) - (TIER[b.origin] ?? 99) || a.def.nodeId.localeCompare(b.def.nodeId),
+ )
+ .filter((binding) => {
+ if (seen.has(binding.def.nodeId)) return false;
+ seen.add(binding.def.nodeId);
+ return true;
+ });
+}
diff --git a/gitnexus/src/core/ingestion/languages/c/query.ts b/gitnexus/src/core/ingestion/languages/c/query.ts
new file mode 100644
index 000000000..da49a1a6a
--- /dev/null
+++ b/gitnexus/src/core/ingestion/languages/c/query.ts
@@ -0,0 +1,165 @@
+import Parser from 'tree-sitter';
+import C from 'tree-sitter-c';
+
+const C_SCOPE_QUERY = `
+;; Scopes
+(translation_unit) @scope.module
+(struct_specifier) @scope.class
+(union_specifier) @scope.class
+(function_definition) @scope.function
+(compound_statement) @scope.block
+(if_statement) @scope.block
+(for_statement) @scope.block
+(while_statement) @scope.block
+(do_statement) @scope.block
+(switch_statement) @scope.block
+(case_statement) @scope.block
+
+;; Declarations — struct (named)
+(struct_specifier
+ name: (type_identifier) @declaration.name
+ body: (field_declaration_list)) @declaration.struct
+
+;; Declarations — struct (typedef struct { ... } Name)
+(type_definition
+ type: (struct_specifier
+ body: (field_declaration_list))
+ declarator: (type_identifier) @declaration.name) @declaration.struct
+
+;; Declarations — union (named)
+(union_specifier
+ name: (type_identifier) @declaration.name
+ body: (field_declaration_list)) @declaration.union
+
+;; Declarations — union (typedef union { ... } Name)
+(type_definition
+ type: (union_specifier
+ body: (field_declaration_list))
+ declarator: (type_identifier) @declaration.name) @declaration.union
+
+;; Declarations — enum
+(enum_specifier
+ name: (type_identifier) @declaration.name) @declaration.enum
+
+;; Declarations — function definition
+(function_definition
+ declarator: (function_declarator
+ declarator: (identifier) @declaration.name)) @declaration.function
+
+;; Declarations — function definition with pointer return
+(function_definition
+ declarator: (pointer_declarator
+ declarator: (function_declarator
+ declarator: (identifier) @declaration.name))) @declaration.function
+
+;; Declarations — function declaration (prototype)
+;; Note: Both prototypes and definitions are captured as @declaration.function.
+;; This may produce duplicate Function nodes in the knowledge graph when a
+;; function is declared in a header and defined in a .c file. CALLS edges
+;; resolve correctly through scope-based wildcard import chains; the
+;; duplication is a graph-quality concern only (no false edges).
+(declaration
+ declarator: (function_declarator
+ declarator: (identifier) @declaration.name)) @declaration.function
+
+;; Declarations — function declaration with pointer return (prototype)
+(declaration
+ declarator: (pointer_declarator
+ declarator: (function_declarator
+ declarator: (identifier) @declaration.name))) @declaration.function
+
+;; Declarations — typedef
+(type_definition
+ declarator: (type_identifier) @declaration.name) @declaration.typedef
+
+;; Declarations — typedef for function pointers: typedef void (*callback)(int, int)
+(type_definition
+ declarator: (function_declarator
+ declarator: (parenthesized_declarator
+ (pointer_declarator
+ declarator: (type_identifier) @declaration.name)))) @declaration.typedef
+
+;; Declarations — struct fields
+(field_declaration
+ declarator: (field_identifier) @declaration.name) @declaration.field
+
+;; Declarations — struct fields (pointer)
+(field_declaration
+ declarator: (pointer_declarator
+ declarator: (field_identifier) @declaration.name)) @declaration.field
+
+;; Declarations — variables (with initializer)
+(declaration
+ declarator: (init_declarator
+ declarator: (identifier) @declaration.name)) @declaration.variable
+
+;; Declarations — macro definitions
+(preproc_def
+ name: (identifier) @declaration.name) @declaration.macro
+
+(preproc_function_def
+ name: (identifier) @declaration.name) @declaration.macro
+
+;; Declarations — enum constants
+(enumerator
+ name: (identifier) @declaration.name) @declaration.const
+
+;; Imports
+(preproc_include) @import.statement
+
+;; Type bindings — parameter annotations
+(parameter_declaration
+ type: (_) @type-binding.type
+ declarator: (identifier) @type-binding.name) @type-binding.parameter
+
+;; Type bindings — variable with type (init_declarator)
+(declaration
+ type: (_) @type-binding.type
+ declarator: (init_declarator
+ declarator: (identifier) @type-binding.name)) @type-binding.assignment
+
+;; References — free calls
+;; Note: This also captures calls through function pointer variables (e.g. fp(x))
+;; since tree-sitter-c produces structurally identical AST nodes for both direct
+;; function calls and function-pointer-variable calls. A type-based guard to
+;; distinguish variable-calls from function-calls is not implemented — this is a
+;; known architectural trade-off shared with the Go resolver. The uniqueness
+;; constraint in pickUniqueGlobalCallable limits false edge exposure.
+(call_expression
+ function: (identifier) @reference.name) @reference.call.free
+
+;; References — member calls via pointer (ptr->func())
+(call_expression
+ function: (field_expression
+ argument: (_) @reference.receiver
+ field: (field_identifier) @reference.name)) @reference.call.member
+
+;; References — field reads
+(field_expression
+ argument: (_) @reference.receiver
+ field: (field_identifier) @reference.name) @reference.read
+
+;; References — field writes (assignment)
+(assignment_expression
+ left: (field_expression
+ argument: (_) @reference.receiver
+ field: (field_identifier) @reference.name)) @reference.write
+`;
+
+let _parser: Parser | null = null;
+let _query: Parser.Query | null = null;
+
+export function getCParser(): Parser {
+ if (_parser === null) {
+ _parser = new Parser();
+ _parser.setLanguage(C as Parameters[0]);
+ }
+ return _parser;
+}
+
+export function getCScopeQuery(): Parser.Query {
+ if (_query === null) {
+ _query = new Parser.Query(C as Parameters[0], C_SCOPE_QUERY);
+ }
+ return _query;
+}
diff --git a/gitnexus/src/core/ingestion/languages/c/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/c/scope-resolver.ts
new file mode 100644
index 000000000..90d54e072
--- /dev/null
+++ b/gitnexus/src/core/ingestion/languages/c/scope-resolver.ts
@@ -0,0 +1,73 @@
+import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared';
+import { SupportedLanguages } from 'gitnexus-shared';
+import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js';
+import { populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js';
+import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js';
+import { cProvider } from '../c-cpp.js';
+import { cArityCompatibility, cMergeBindings, resolveCImportTarget } from './index.js';
+import { scanHeaderFiles } from './header-scan.js';
+import { expandCWildcardNames, isStaticName, clearStaticNames } from './static-linkage.js';
+
+/**
+ * C `ScopeResolver` registered in `SCOPE_RESOLVERS` and consumed by
+ * the generic `runScopeResolution` orchestrator (RFC #909 Ring 3).
+ *
+ * C is a structurally simple language for scope resolution:
+ * - No classes (structs are value types, no method dispatch)
+ * - No inheritance (no MRO needed beyond the shared first-wins default)
+ * - No overloading (arity check is simple: variadic detection only)
+ * - `#include` is wildcard import (all symbols from header are visible)
+ * - `static` functions are file-local (not exported)
+ */
+export const cScopeResolver: ScopeResolver = {
+ language: SupportedLanguages.C,
+ languageProvider: cProvider,
+ importEdgeReason: 'c-scope: include',
+
+ loadResolutionConfig: (repoPath: string) => {
+ // Clear stale static-linkage data from any previous invocation to
+ // prevent cross-repo contamination in server-mode scenarios.
+ clearStaticNames();
+ return scanHeaderFiles(repoPath);
+ },
+
+ resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig) => {
+ // Augment allFilePaths with .h files discovered via loadResolutionConfig
+ // since the phase only passes .c files to the C resolver but #include
+ // targets .h files classified as C++ in language detection.
+ const headerPaths = resolutionConfig as ReadonlySet | undefined;
+ if (headerPaths !== undefined && headerPaths.size > 0) {
+ const augmented = new Set(allFilePaths);
+ for (const h of headerPaths) augmented.add(h);
+ return resolveCImportTarget(targetRaw, fromFile, augmented);
+ }
+ return resolveCImportTarget(targetRaw, fromFile, allFilePaths);
+ },
+
+ expandsWildcardTo: (targetModuleScope, parsedFiles) =>
+ expandCWildcardNames(targetModuleScope, parsedFiles),
+
+ mergeBindings: (existing, incoming, scopeId) => cMergeBindings(existing, incoming, scopeId),
+
+ arityCompatibility: (callsite, def) => cArityCompatibility(def, callsite),
+
+ buildMro: (graph, parsedFiles, nodeLookup) =>
+ buildMro(graph, parsedFiles, nodeLookup, defaultLinearize),
+
+ populateOwners: (parsed: ParsedFile) => populateClassOwnedMembers(parsed),
+
+ isSuperReceiver: () => false,
+
+ // C is statically typed — disable field fallback heuristic
+ fieldFallbackOnMethodLookup: false,
+ // C has no method return types to propagate
+ propagatesReturnTypesAcrossImports: false,
+ // C #include brings in all symbols — enable global free call fallback
+ allowGlobalFreeCallFallback: true,
+ // C `static` functions have file-local (translation-unit) linkage —
+ // exclude them from global free-call fallback cross-file resolution.
+ isFileLocalDef: (def: SymbolDefinition) => {
+ const simple = def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? '';
+ return isStaticName(def.filePath, simple);
+ },
+};
diff --git a/gitnexus/src/core/ingestion/languages/c/simple-hooks.ts b/gitnexus/src/core/ingestion/languages/c/simple-hooks.ts
new file mode 100644
index 000000000..537ea07c5
--- /dev/null
+++ b/gitnexus/src/core/ingestion/languages/c/simple-hooks.ts
@@ -0,0 +1,38 @@
+import type {
+ CaptureMatch,
+ ParsedImport,
+ Scope,
+ ScopeId,
+ ScopeTree,
+ TypeRef,
+} from 'gitnexus-shared';
+
+/**
+ * C binding scope: always use default auto-hoist (null).
+ * C has no self/receiver bindings that need special scoping.
+ */
+export function cBindingScopeFor(
+ _decl: CaptureMatch,
+ _innermost: Scope,
+ _tree: ScopeTree,
+): ScopeId | null {
+ return null;
+}
+
+/**
+ * C import owning scope: always use default (null).
+ */
+export function cImportOwningScope(
+ _imp: ParsedImport,
+ _innermost: Scope,
+ _tree: ScopeTree,
+): ScopeId | null {
+ return null;
+}
+
+/**
+ * C receiver binding: always null. C has no methods or receivers.
+ */
+export function cReceiverBinding(_functionScope: Scope): TypeRef | null {
+ return null;
+}
diff --git a/gitnexus/src/core/ingestion/languages/c/static-linkage.ts b/gitnexus/src/core/ingestion/languages/c/static-linkage.ts
new file mode 100644
index 000000000..5cfd166b1
--- /dev/null
+++ b/gitnexus/src/core/ingestion/languages/c/static-linkage.ts
@@ -0,0 +1,64 @@
+import type { ParsedFile, ScopeId, SymbolDefinition } from 'gitnexus-shared';
+
+/**
+ * Per-file set of function names declared with `static` storage class.
+ * Populated during `emitCScopeCaptures` and consumed by `expandCWildcardNames`
+ * to exclude file-local symbols from cross-file wildcard import visibility.
+ *
+ * NOTE: module-level state, single-process-single-repo use only.
+ * For server-mode or multi-repo-in-one-process use cases, call
+ * `clearStaticNames()` at the start of each resolution pass to avoid
+ * stale static-linkage data from a previous invocation.
+ *
+ * Key: filePath, Value: Set of static function names.
+ */
+const staticNames = new Map>();
+
+/** Record a symbol name as `static` (file-local linkage) for the given file. */
+export function markStaticName(filePath: string, name: string): void {
+ let names = staticNames.get(filePath);
+ if (names === undefined) {
+ names = new Set();
+ staticNames.set(filePath, names);
+ }
+ names.add(name);
+}
+
+/** Check whether a symbol name has `static` linkage in the given file. */
+export function isStaticName(filePath: string, name: string): boolean {
+ return staticNames.get(filePath)?.has(name) ?? false;
+}
+
+/** Clear tracked static names (for testing). */
+export function clearStaticNames(): void {
+ staticNames.clear();
+}
+
+/**
+ * Return the names visible through a C wildcard import (`#include`).
+ * All module-scope defs from the target file are visible EXCEPT those
+ * declared with `static` storage class (file-local linkage in C).
+ */
+export function expandCWildcardNames(
+ targetModuleScope: ScopeId,
+ parsedFiles: readonly ParsedFile[],
+): readonly string[] {
+ const target = parsedFiles.find((p) => p.moduleScope === targetModuleScope);
+ if (target === undefined) return [];
+
+ const seen = new Set();
+ const names: string[] = [];
+ for (const def of target.localDefs) {
+ const name = simpleName(def);
+ if (name === '') continue;
+ if (isStaticName(target.filePath, name)) continue;
+ if (seen.has(name)) continue;
+ seen.add(name);
+ names.push(name);
+ }
+ return names;
+}
+
+function simpleName(def: SymbolDefinition): string {
+ return def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? '';
+}
diff --git a/gitnexus/src/core/ingestion/languages/cpp/adl.ts b/gitnexus/src/core/ingestion/languages/cpp/adl.ts
new file mode 100644
index 000000000..0bcda9322
--- /dev/null
+++ b/gitnexus/src/core/ingestion/languages/cpp/adl.ts
@@ -0,0 +1,540 @@
+/**
+ * C++ argument-dependent lookup (ADL / Koenig lookup).
+ *
+ * When ordinary unqualified lookup fails for a free-call site, ADL also
+ * considers candidates declared in the **associated namespaces** of the
+ * call's argument types (ISO C++ `[basic.lookup.argdep]`). The canonical
+ * pattern V1 unlocks:
+ *
+ * namespace audit { struct Event; void record(Event); }
+ * namespace app { void run() { audit::Event e; record(e); } }
+ *
+ * Without ADL: `record(e)` is unresolved because `app::run` doesn't
+ * `using` anything. With V1 ADL: `audit::record` is discovered via
+ * `audit::Event`'s associated namespace.
+ *
+ * ## Current boundary
+ *
+ * The current implementation covers class-typed arguments (value, pointer,
+ * and reference) and template specializations with explicit type arguments:
+ * - `audit::Event e`, `audit::Event* p`, `audit::Event** pp`
+ * - `audit::Event& r`, `audit::Event&& rr`
+ * - `std::vector` (template namespace + template-arg namespaces)
+ *
+ * V2 additionally walks class ancestors (via MRO), so base-class enclosing
+ * namespaces also contribute associated namespaces.
+ *
+ * **GitNexus approximation (not strict ISO C++ ADL):** passing a qualified
+ * function reference like `utils::worker` contributes `utils` to the associated
+ * set, enabling resolution of unqualified calls like `with_callback(utils::worker)`
+ * to `utils::with_callback`. Under ISO C++ `[basic.lookup.argdep]`, associated
+ * entities for function-type arguments come from the **parameter types and return
+ * type** of each function in the overload set — NOT the function's enclosing
+ * namespace. For `void worker()`, the standard-compliant associated set is empty.
+ * GitNexus instead contributes the enclosing namespace of any Function/Method
+ * def whose simple name matches, because it enables the dominant real-world ADL
+ * pattern at reasonable precision cost.
+ *
+ * For qualified refs (e.g. `utils::worker`) the namespace is confirmed via a
+ * workspace lookup (only contributed when a Function/Method named `worker` exists
+ * in `utils`). For unqualified refs the workspace is searched for any Function
+ * def with that simple name. Locally-declared function-pointer variables
+ * (e.g. `void (*g)()`) and function parameters are excluded from this path.
+ *
+ * ADL candidates are merged with ordinary unqualified-lookup candidates
+ * in the free-call fallback before overload narrowing.
+ *
+ * ## Parenthesized-name suppression
+ *
+ * `(f)(s)` MUST NOT trigger ADL — the parenthesized name forces ordinary
+ * lookup only. `captures.ts` records sites whose `function` child is a
+ * `parenthesized_expression` into `noAdlSites`; `pickCppAdlCandidates`
+ * short-circuits when the site key is present.
+ *
+ * ## State lifecycle
+ *
+ * Three module-level maps populated per pipeline invocation, cleared via
+ * `clearCppAdlState()` (called from `clearFileLocalNames`):
+ *
+ * - `argInfoBySite` — per-call-site argument shape (capture-time)
+ * - `noAdlSites` — call sites with parenthesized function (capture-time)
+ * - `classToNamespaceQualifiedName` — class def → its enclosing namespace
+ * qualified name (`populateCppAssociatedNamespaces` time)
+ *
+ * The class→namespace map uses qualified names (not scope IDs) because
+ * C++ namespaces are open: `namespace N { ... }` in file A and
+ * `namespace N { ... }` in file B produce two distinct Namespace scopes
+ * but logically share the same namespace. ADL must consider candidates
+ * declared in either file.
+ */
+
+import type { ParsedFile, ScopeId, SymbolDefinition } from 'gitnexus-shared';
+import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
+import { isCppInlineNamespaceScope } from './inline-namespaces.js';
+
+/**
+ * Per-argument shape information collected at capture time. ADL fires for
+ * arguments where `simpleClassName !== ''`, including class pointers and
+ * references whose declarator chain resolves to a named class type.
+ * Free-function reference arguments use `functionRefText`.
+ */
+export interface CppAdlArgInfo {
+ /** Simple class-like type name (last segment of qualified name); empty
+ * for primitives, literals, function pointers, etc. */
+ readonly simpleClassName: string;
+ /** Template's own simple class-like name (e.g. `vector` for
+ * `std::vector`), empty when arg type is not a template spec. */
+ readonly templateSimpleClassName: string;
+ /** Template's own enclosing namespace (dot-qualified, e.g. `std`), empty
+ * when unavailable / unqualified. */
+ readonly templateNamespace: string;
+ /** Class-like names extracted from explicit type template arguments,
+ * recursively bounded. */
+ readonly templateArgClassNames: readonly string[];
+ /** Enclosing namespaces extracted from explicit type template arguments,
+ * recursively bounded. */
+ readonly templateArgNamespaces: readonly string[];
+ /** When set, the arg is a potential free-function reference (not a locally-
+ * declared function-pointer variable or function parameter). Contains the
+ * identifier text as written in source (e.g. `"utils::worker"` or
+ * `"worker"`). GitNexus approximation: the function's enclosing namespace
+ * is contributed to the ADL associated set. For qualified refs a workspace
+ * lookup confirms a Function/Method with that simple name exists in the
+ * namespace before contributing; for unqualified refs every namespace
+ * containing a matching Function/Method def is contributed. */
+ readonly functionRefText?: string;
+}
+
+const argInfoBySite = new Map();
+const noAdlSites = new Set();
+const classToNamespaceQualifiedName = new Map();
+
+function siteKey(filePath: string, line: number, col: number): string {
+ return `${filePath}:${line}:${col}`;
+}
+
+/** Record per-call-site argument info. Called once per call site from
+ * `emitCppScopeCaptures`. */
+export function markCppAdlSiteArgs(
+ filePath: string,
+ line: number,
+ col: number,
+ args: readonly CppAdlArgInfo[],
+): void {
+ argInfoBySite.set(siteKey(filePath, line, col), args);
+}
+
+/** Mark a call site as ADL-suppressed (function child wrapped in
+ * `parenthesized_expression`, e.g. `(f)(s)`). */
+export function markCppAdlSiteNoAdl(filePath: string, line: number, col: number): void {
+ noAdlSites.add(siteKey(filePath, line, col));
+}
+
+/** Clear ADL state. Called from `clearFileLocalNames` so all C++ resolver
+ * per-pipeline state is reset together. */
+export function clearCppAdlState(): void {
+ argInfoBySite.clear();
+ noAdlSites.clear();
+ classToNamespaceQualifiedName.clear();
+}
+
+/**
+ * Walk `parsed.scopes` to record each Class def's enclosing namespace
+ * qualified name. Run from the cpp resolver's `populateOwners` hook so
+ * the index is available before any resolution pass consults it.
+ *
+ * Computes the namespace's qualified name by walking parent scope chain
+ * and looking up Namespace defs in each parent's `ownedDefs`. The
+ * resulting name is dot-joined (matching `populateClassOwnedMembers`'s
+ * dotted convention; conversion to `::` is consumer-internal).
+ */
+export function populateCppAssociatedNamespaces(parsed: ParsedFile): void {
+ const scopesById = new Map();
+ for (const scope of parsed.scopes) scopesById.set(scope.id, scope);
+
+ for (const scope of parsed.scopes) {
+ if (scope.kind !== 'Class') continue;
+ const nsQName = computeEnclosingNamespaceQName(scope, scopesById);
+ if (nsQName === '') continue;
+ for (const def of scope.ownedDefs) {
+ if (def.type !== 'Class' && def.type !== 'Struct' && def.type !== 'Interface') continue;
+ classToNamespaceQualifiedName.set(def.nodeId, nsQName);
+ }
+ }
+
+ // Enum defs live in Namespace scopes directly (not inside Class scopes).
+ // Map each Enum def to its enclosing namespace so ADL on enum-typed
+ // arguments contributes the correct associated namespace.
+ for (const scope of parsed.scopes) {
+ if (scope.kind !== 'Namespace') continue;
+ const nsQName = computeNamespaceQName(scope, scopesById);
+ if (nsQName === '') continue;
+ for (const def of scope.ownedDefs) {
+ if (def.type !== 'Enum') continue;
+ classToNamespaceQualifiedName.set(def.nodeId, nsQName);
+ }
+ }
+}
+
+/**
+ * ADL candidate collector. Returns:
+ * - `readonly SymbolDefinition[]` — ADL candidates to merge with
+ * ordinary unqualified lookup candidates.
+ * - `undefined` — no ADL candidates.
+ *
+ * Fires only when:
+ * - the call site is not in `noAdlSites` (parenthesized form), AND
+ * - at least one argument resolves to a named class type (value,
+ * pointer, or reference; but not function pointer, literal, or primitive).
+ */
+export function pickCppAdlCandidates(
+ site: {
+ readonly name: string;
+ readonly atRange: { startLine: number; startCol: number };
+ },
+ callerParsed: ParsedFile,
+ scopes: ScopeResolutionIndexes,
+ parsedFiles: readonly ParsedFile[],
+): readonly SymbolDefinition[] | undefined {
+ const key = siteKey(callerParsed.filePath, site.atRange.startLine, site.atRange.startCol);
+ if (noAdlSites.has(key)) return undefined;
+ const args = argInfoBySite.get(key);
+ if (args === undefined || args.length === 0) return undefined;
+
+ // Collect associated namespace QNames from every participating class-typed arg
+ // and from function-reference args.
+ const associatedNamespaces = new Set();
+ for (const arg of args) {
+ collectAssociatedNamespacesForAdlArg(arg, scopes, associatedNamespaces);
+ if (arg.functionRefText !== undefined) {
+ collectFunctionRefNamespaces(arg.functionRefText, parsedFiles, associatedNamespaces);
+ }
+ }
+ if (associatedNamespaces.size === 0) return undefined;
+
+ // Walk every namespace scope in every parsed file; collect callable
+ // ownedDefs whose enclosing namespace matches one of the associated
+ // QNames AND whose simple name matches the call's name.
+ // ISO C++: inline namespaces are transparent — candidates in inline
+ // children of an associated namespace are also ADL-reachable.
+ const candidates: SymbolDefinition[] = [];
+ const seenKey = new Set();
+ for (const parsed of parsedFiles) {
+ const scopesById = new Map();
+ for (const sc of parsed.scopes) scopesById.set(sc.id, sc);
+ for (const scope of parsed.scopes) {
+ if (scope.kind !== 'Namespace') continue;
+ const qName = computeNamespaceQName(scope, scopesById);
+ if (!associatedNamespaces.has(qName)) {
+ // Check if this is an inline-namespace child of an associated NS.
+ // ISO C++ inline namespaces are transparent for ADL: if the outer
+ // namespace is in the associated set, candidates in the inline child
+ // are also reachable.
+ if (!isCppInlineNamespaceScope(scope.id)) continue;
+ const parentScope = scope.parent !== null ? scopesById.get(scope.parent) : undefined;
+ if (parentScope === undefined || parentScope.kind !== 'Namespace') continue;
+ const parentQName = computeNamespaceQName(parentScope, scopesById);
+ if (!associatedNamespaces.has(parentQName)) continue;
+ }
+ for (const def of scope.ownedDefs) {
+ if (def.type !== 'Function' && def.type !== 'Method' && def.type !== 'Constructor') {
+ continue;
+ }
+ const simple = def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? '';
+ if (simple !== site.name) continue;
+ // Dedup by nodeId — using normalized parameter-types as the key
+ // would collapse `process(int)`/`process(long)`-style overloads
+ // (both normalize to `['int']`) before
+ // `isOverloadAmbiguousAfterNormalization` can detect them.
+ if (seenKey.has(def.nodeId)) continue;
+ seenKey.add(def.nodeId);
+ candidates.push(def);
+ }
+ }
+ // ISO C++ `[basic.lookup.argdep]` §2: hidden friend functions declared
+ // inside a class body are visible via ADL when the class is an associated
+ // class. Scan Class scopes whose enclosing namespace is in the associated
+ // set for callable ownedDefs matching the call name. This enables the
+ // canonical "hidden friend" idiom:
+ // struct Foo { friend void swap(Foo&, Foo&) {} };
+ for (const scope of parsed.scopes) {
+ if (scope.kind !== 'Class') continue;
+ // Check if ANY class def in this scope has an associated namespace.
+ let isAssociatedClass = false;
+ for (const def of scope.ownedDefs) {
+ if (def.type !== 'Class' && def.type !== 'Struct' && def.type !== 'Interface') continue;
+ const nsQName = classToNamespaceQualifiedName.get(def.nodeId);
+ if (nsQName !== undefined && associatedNamespaces.has(nsQName)) {
+ isAssociatedClass = true;
+ break;
+ }
+ }
+ if (!isAssociatedClass) continue;
+ // Also scan Function scopes that are direct children of this class
+ // scope — friend function definitions create their own Function scope
+ // underneath the Class scope.
+ for (const childScope of parsed.scopes) {
+ if (childScope.parent !== scope.id) continue;
+ if (childScope.kind !== 'Function') continue;
+ for (const def of childScope.ownedDefs) {
+ if (def.type !== 'Function' && def.type !== 'Method' && def.type !== 'Constructor') {
+ continue;
+ }
+ const simple = def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? '';
+ if (simple !== site.name) continue;
+ if (seenKey.has(def.nodeId)) continue;
+ seenKey.add(def.nodeId);
+ candidates.push(def);
+ }
+ }
+ for (const def of scope.ownedDefs) {
+ if (def.type !== 'Function' && def.type !== 'Method' && def.type !== 'Constructor') {
+ continue;
+ }
+ const simple = def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? '';
+ if (simple !== site.name) continue;
+ if (seenKey.has(def.nodeId)) continue;
+ seenKey.add(def.nodeId);
+ candidates.push(def);
+ }
+ }
+ }
+ if (candidates.length === 0) return undefined;
+ return candidates;
+}
+
+function collectAssociatedNamespacesForAdlArg(
+ arg: CppAdlArgInfo,
+ scopes: ScopeResolutionIndexes,
+ associatedNamespaces: Set,
+): void {
+ // For template args this may be the template name itself (e.g. `vector`);
+ // simple-name lookup can match project classes with the same name (known
+ // V1/V2 simplification).
+ addAssociatedNamespaceForClassName(arg.simpleClassName, scopes, associatedNamespaces);
+
+ // Includes template-owner namespaces (e.g. `std` in std::vector). If
+ // that surfaces extra candidates, merged-candidate overload narrowing in
+ // free-call-fallback suppresses arbitrary edge emission.
+ if (arg.templateNamespace.length > 0) associatedNamespaces.add(arg.templateNamespace);
+
+ for (const ns of arg.templateArgNamespaces) {
+ if (ns.length > 0) associatedNamespaces.add(ns);
+ }
+ for (const className of arg.templateArgClassNames) {
+ addAssociatedNamespaceForClassName(className, scopes, associatedNamespaces);
+ }
+}
+
+function addAssociatedNamespaceForClassName(
+ simpleClassName: string,
+ scopes: ScopeResolutionIndexes,
+ associatedNamespaces: Set,
+): void {
+ if (simpleClassName.length === 0) return;
+ const classLookup = findCppClassDefBySimpleName(simpleClassName, scopes);
+ if (classLookup === undefined) return;
+ const { classDef, ambiguous } = classLookup;
+ const nsQName = classToNamespaceQualifiedName.get(classDef.nodeId);
+ if (nsQName !== undefined) associatedNamespaces.add(nsQName);
+ // Preserve V1 collision behavior for the direct class namespace, but avoid
+ // amplifying a same-simple-name collision by walking an arbitrary class's
+ // full MRO chain.
+ if (ambiguous) return;
+ for (const ancestorDefId of scopes.methodDispatch.mroFor(classDef.nodeId)) {
+ const ancestorNsQName = classToNamespaceQualifiedName.get(ancestorDefId);
+ if (ancestorNsQName !== undefined) associatedNamespaces.add(ancestorNsQName);
+ }
+}
+
+/** Walk upward from a Class scope, finding the innermost enclosing
+ * Namespace scope, and return that namespace's qualified name (dot-
+ * joined, outermost-first). Returns '' when the class has no enclosing
+ * namespace (e.g., declared at translation-unit scope). */
+function computeEnclosingNamespaceQName(
+ classScope: { readonly parent: ScopeId | null },
+ scopesById: ReadonlyMap<
+ ScopeId,
+ {
+ readonly parent: ScopeId | null;
+ readonly kind: string;
+ readonly ownedDefs: readonly SymbolDefinition[];
+ }
+ >,
+): string {
+ let parentId: ScopeId | null = classScope.parent;
+ while (parentId !== null) {
+ const parent = scopesById.get(parentId);
+ if (parent === undefined) return '';
+ if (parent.kind === 'Namespace') {
+ return computeNamespaceQName(parent, scopesById);
+ }
+ parentId = parent.parent;
+ }
+ return '';
+}
+
+/** Walk upward from a Namespace scope collecting each enclosing
+ * Namespace's simple name (innermost last). Returns the dot-joined
+ * qualified name (e.g., `outer.inner`). The namespace's own def lives
+ * in its OWN scope's `ownedDefs` (the C++ extractor stamps the
+ * namespace-decl def into the namespace scope itself, not the parent
+ * module scope). */
+function computeNamespaceQName(
+ nsScope: { readonly parent: ScopeId | null; readonly ownedDefs: readonly SymbolDefinition[] },
+ scopesById: ReadonlyMap<
+ ScopeId,
+ {
+ readonly parent: ScopeId | null;
+ readonly kind: string;
+ readonly ownedDefs: readonly SymbolDefinition[];
+ }
+ >,
+): string {
+ const segments: string[] = [];
+ let currentId: ScopeId | null = nsScope.parent;
+ let current:
+ | { readonly parent: ScopeId | null; readonly ownedDefs: readonly SymbolDefinition[] }
+ | undefined = nsScope;
+ // Outer guard against pathological cycles in malformed scope trees.
+ let safety = 64;
+ while (current !== undefined && safety-- > 0) {
+ const nsDef = findNamespaceDefInScope(current);
+ if (nsDef === undefined) {
+ // No name found — bail out. Returning a partial QName would risk
+ // false ADL associations.
+ return '';
+ }
+ const simple = nsDef.qualifiedName?.split('.').pop() ?? nsDef.qualifiedName ?? '';
+ segments.unshift(simple);
+ // Walk up to next enclosing namespace (skipping non-namespace parents).
+ let nextId: ScopeId | null = currentId;
+ let nextNs: typeof current | undefined;
+ while (nextId !== null) {
+ const nx = scopesById.get(nextId);
+ if (nx === undefined) break;
+ if (nx.kind === 'Namespace') {
+ nextNs = nx;
+ currentId = nx.parent;
+ break;
+ }
+ nextId = nx.parent;
+ }
+ current = nextNs;
+ }
+ return segments.join('.');
+}
+
+/** Find the Namespace def attached to this scope (the namespace's own
+ * decl, stamped into its own `ownedDefs` by the C++ extractor). Returns
+ * the first Namespace-type def encountered — for normal C++ the scope
+ * carries exactly one Namespace-typed self def. */
+function findNamespaceDefInScope(scope: {
+ readonly ownedDefs: readonly SymbolDefinition[];
+}): SymbolDefinition | undefined {
+ for (const def of scope.ownedDefs) {
+ if (def.type === 'Namespace') return def;
+ }
+ return undefined;
+}
+
+/** Find a class-like or enum def by simple name across the workspace.
+ * V1 still arbitrary-picks the first match on collisions (multiple defs
+ * share the simple name), but reports the collision so callers can avoid
+ * amplifying that uncertainty (for example by skipping MRO expansion).
+ * C++ ADL strictness would require full type-driven lookup.
+ *
+ * ISO C++ `[basic.lookup.argdep]` §2: enumerations contribute their
+ * enclosing namespace to the associated set, just like class types. */
+function findCppClassDefBySimpleName(
+ simpleName: string,
+ scopes: ScopeResolutionIndexes,
+): { classDef: SymbolDefinition; ambiguous: boolean } | undefined {
+ let firstMatch: SymbolDefinition | undefined;
+ for (const def of scopes.defs.byId.values()) {
+ if (
+ def.type !== 'Class' &&
+ def.type !== 'Struct' &&
+ def.type !== 'Interface' &&
+ def.type !== 'Enum'
+ )
+ continue;
+ const simple = def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? '';
+ if (simple !== simpleName) continue;
+ if (firstMatch === undefined) {
+ firstMatch = def;
+ continue;
+ }
+ return { classDef: firstMatch, ambiguous: true };
+ }
+ if (firstMatch === undefined) return undefined;
+ return { classDef: firstMatch, ambiguous: false };
+}
+
+/**
+ * Contribute associated namespaces for a function-reference argument.
+ *
+ * - **Qualified refs** (`utils::worker`, `outer::inner::fn`): the namespace
+ * is extracted from the qualifier text (converting `::` to `.` for dot-joined
+ * QName matching). A workspace lookup then **verifies** that a Function or
+ * Method def named `worker` (the simple name after the last `::`) actually
+ * exists in the extracted namespace. This prevents false positives from
+ * namespace-qualified variables, enum values, and static data members, which
+ * also produce `qualified_identifier` AST nodes in tree-sitter-cpp (the
+ * AST node type alone does not distinguish functions from non-function names).
+ * - **Unqualified refs** (`worker`): the workspace is searched for any
+ * Function/Method def whose simple name matches. Every distinct enclosing
+ * namespace found is added — overloads across the same namespace produce
+ * a single entry; GitNexus does not select a specific overload at this stage.
+ */
+function collectFunctionRefNamespaces(
+ refText: string,
+ parsedFiles: readonly ParsedFile[],
+ out: Set,
+): void {
+ const colonIdx = refText.lastIndexOf('::');
+ if (colonIdx !== -1) {
+ // Qualified ref: extract namespace prefix and normalise :: → dot notation.
+ const nsText = refText.slice(0, colonIdx).replace(/::/g, '.');
+ if (nsText === '') return;
+ const simpleName = refText.slice(colonIdx + 2);
+ // Verify that a Function/Method named `simpleName` exists in `nsText`.
+ // Without this guard every `a::b` qualified_identifier arg (variable,
+ // enum value, static member, type alias) would blindly contribute `a`
+ // to the associated set and risk a false-positive CALLS edge.
+ for (const parsed of parsedFiles) {
+ const scopesById = new Map();
+ for (const sc of parsed.scopes) scopesById.set(sc.id, sc);
+ for (const scope of parsed.scopes) {
+ if (scope.kind !== 'Namespace') continue;
+ if (computeNamespaceQName(scope, scopesById) !== nsText) continue;
+ for (const def of scope.ownedDefs) {
+ if (def.type !== 'Function' && def.type !== 'Method') continue;
+ const simple = def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? '';
+ if (simple === simpleName) {
+ out.add(nsText);
+ return; // Namespace confirmed; no need to scan further files.
+ }
+ }
+ }
+ }
+ return;
+ }
+
+ // Unqualified: search all namespace scopes for a Function def with this
+ // simple name and contribute its enclosing namespace.
+ for (const parsed of parsedFiles) {
+ const scopesById = new Map();
+ for (const sc of parsed.scopes) scopesById.set(sc.id, sc);
+ for (const scope of parsed.scopes) {
+ if (scope.kind !== 'Namespace') continue;
+ for (const def of scope.ownedDefs) {
+ if (def.type !== 'Function' && def.type !== 'Method') continue;
+ const simple = def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? '';
+ if (simple !== refText) continue;
+ const nsQName = computeNamespaceQName(scope, scopesById);
+ if (nsQName !== '') out.add(nsQName);
+ }
+ }
+ }
+}
diff --git a/gitnexus/src/core/ingestion/languages/cpp/arity-metadata.ts b/gitnexus/src/core/ingestion/languages/cpp/arity-metadata.ts
new file mode 100644
index 000000000..fb47d3122
--- /dev/null
+++ b/gitnexus/src/core/ingestion/languages/cpp/arity-metadata.ts
@@ -0,0 +1,185 @@
+import type { SyntaxNode } from '../../utils/ast-helpers.js';
+
+export interface CppArityInfo {
+ parameterCount?: number;
+ requiredParameterCount?: number;
+ parameterTypes?: string[];
+}
+
+/**
+ * Compute declaration arity from a C++ function definition or declaration node.
+ * Extends the C arity computation with support for:
+ * - optional_parameter_declaration (default parameters)
+ * - variadic_parameter_declaration / parameter packs
+ * - (void) explicit zero-parameter form
+ */
+export function computeCppDeclarationArity(node: SyntaxNode): CppArityInfo {
+ const funcDecl = findFuncDeclarator(node);
+ if (funcDecl === null) return {};
+
+ const paramList = funcDecl.childForFieldName('parameters');
+ if (paramList === null) return {};
+
+ const params: SyntaxNode[] = [];
+ // Track whether a C-style variadic `...` anonymous token appears.
+ // tree-sitter-cpp emits `...` as an anonymous (non-named) child of
+ // parameter_list, not as `variadic_parameter`.
+ let hasEllipsis = false;
+ for (let i = 0; i < paramList.childCount; i++) {
+ const child = paramList.child(i);
+ if (child === null) continue;
+ if (
+ child.type === 'parameter_declaration' ||
+ child.type === 'optional_parameter_declaration' ||
+ child.type === 'variadic_parameter' ||
+ child.type === 'variadic_parameter_declaration'
+ ) {
+ params.push(child);
+ } else if (child.type === '...' || (!child.isNamed && child.text === '...')) {
+ hasEllipsis = true;
+ }
+ }
+
+ // Empty parameter list: C++ `void foo()` means zero params (unlike C)
+ if (params.length === 0 && !hasEllipsis) {
+ return { parameterCount: 0, requiredParameterCount: 0, parameterTypes: [] };
+ }
+
+ // (void) means zero parameters
+ if (params.length === 1 && params[0].type === 'parameter_declaration') {
+ const typeNode = params[0].childForFieldName('type');
+ const hasDeclarator = params[0].childForFieldName('declarator') !== null;
+ if (typeNode !== null && typeNode.text === 'void' && !hasDeclarator) {
+ return { parameterCount: 0, requiredParameterCount: 0, parameterTypes: [] };
+ }
+ }
+
+ // C-style variadic: `void foo(int x, ...)` — the `...` is an anonymous
+ // token in tree-sitter-cpp, detected via `hasEllipsis` above.
+ // C++ parameter packs: `template void foo(Ts... args)` —
+ // detected as `variadic_parameter_declaration`.
+ const isVariadic =
+ hasEllipsis ||
+ params.some(
+ (p) => p.type === 'variadic_parameter' || p.type === 'variadic_parameter_declaration',
+ );
+ const optionalCount = params.filter((p) => p.type === 'optional_parameter_declaration').length;
+ const requiredCount = params.filter(
+ (p) =>
+ p.type === 'parameter_declaration' ||
+ // variadic_parameter_declaration with a name is a parameter pack — counts as one
+ p.type === 'variadic_parameter_declaration',
+ ).length;
+ const totalNonVariadic = requiredCount + optionalCount;
+
+ const types: string[] = [];
+ for (const p of params) {
+ if (p.type === 'variadic_parameter') {
+ types.push('...');
+ } else if (p.type === 'variadic_parameter_declaration') {
+ // Parameter pack: treated as variadic
+ types.push('...');
+ } else {
+ const typeNode = p.childForFieldName('type');
+ types.push(normalizeCppParamType(typeNode?.text ?? 'unknown'));
+ }
+ }
+ // Append '...' for C-style variadic if not already in types
+ if (hasEllipsis && !types.includes('...')) {
+ types.push('...');
+ }
+
+ return {
+ parameterCount: isVariadic ? undefined : totalNonVariadic,
+ requiredParameterCount: requiredCount,
+ parameterTypes: types,
+ };
+}
+
+/**
+ * Compute call-site arity from a call_expression node.
+ */
+export function computeCppCallArity(node: SyntaxNode): number {
+ const argList = node.childForFieldName('arguments');
+ if (argList === null) return 0;
+
+ let count = 0;
+ for (let i = 0; i < argList.childCount; i++) {
+ const child = argList.child(i);
+ if (child === null) continue;
+ if (child.type !== ',' && child.type !== '(' && child.type !== ')') {
+ count++;
+ }
+ }
+ return count;
+}
+
+/**
+ * Normalize a C++ parameter type for overload disambiguation.
+ * Maps common qualified/aliased types to their canonical short forms
+ * so that `narrowOverloadCandidates` can match against literal-inferred
+ * argument types (e.g. `inferCppLiteralType` returns `'string'` for
+ * string literals, not `'std::string'`).
+ */
+function normalizeCppParamType(raw: string): string {
+ let t = raw.trim();
+ // Strip const, volatile, etc.
+ t = t.replace(/\b(const|volatile|restrict|mutable|constexpr)\b/g, '').trim();
+ // Strip reference/pointer markers
+ t = t.replace(/[&*]+\s*$/, '').trim();
+ // Strip template parameters (loop handles nested: Map> → Map)
+ while (t.includes('<')) {
+ const stripped = t.replace(/<[^<>]*>/g, '');
+ if (stripped === t) break; // avoid infinite loop on malformed input
+ t = stripped;
+ }
+ t = t.trim();
+ // Map std:: types to canonical short forms
+ const STD_MAP: Record = {
+ 'std::string': 'string',
+ 'std::wstring': 'string',
+ 'std::string_view': 'string',
+ string: 'string',
+ char: 'char',
+ int: 'int',
+ long: 'int',
+ short: 'int',
+ unsigned: 'int',
+ 'unsigned int': 'int',
+ 'long long': 'int',
+ size_t: 'int',
+ 'std::size_t': 'int',
+ float: 'double',
+ double: 'double',
+ bool: 'bool',
+ nullptr_t: 'null',
+ 'std::nullptr_t': 'null',
+ };
+ return STD_MAP[t] ?? t;
+}
+
+function findFuncDeclarator(node: SyntaxNode): SyntaxNode | null {
+ let decl = node.childForFieldName('declarator');
+ if (decl === null) {
+ for (let i = 0; i < node.childCount; i++) {
+ const c = node.child(i);
+ if (c?.type === 'function_declarator') return c;
+ }
+ return null;
+ }
+ // Unwrap pointer_declarator / reference_declarator
+ while (decl.type === 'pointer_declarator' || decl.type === 'reference_declarator') {
+ const next = decl.childForFieldName('declarator');
+ if (next === null) {
+ // reference_declarator may not use field name
+ for (let i = 0; i < decl.childCount; i++) {
+ const c = decl.child(i);
+ if (c?.type === 'function_declarator') return c;
+ }
+ break;
+ }
+ decl = next;
+ }
+ if (decl.type === 'function_declarator') return decl;
+ return null;
+}
diff --git a/gitnexus/src/core/ingestion/languages/cpp/arity.ts b/gitnexus/src/core/ingestion/languages/cpp/arity.ts
new file mode 100644
index 000000000..e13fa6a3a
--- /dev/null
+++ b/gitnexus/src/core/ingestion/languages/cpp/arity.ts
@@ -0,0 +1,35 @@
+import type { Callsite, SymbolDefinition } from 'gitnexus-shared';
+
+/**
+ * C++ arity compatibility: supports overloading and default parameters.
+ *
+ * Unlike C (no overloading, exact match only), C++ has:
+ * - Overloaded functions (same name, different signatures)
+ * - Default parameters (requiredParameterCount < parameterCount)
+ * - Variadic functions (C-style `...`)
+ * - Parameter packs (V1: treated as variadic)
+ * - Templates (V1: generic-ignored, arity check on non-template params)
+ *
+ * Verdict:
+ * - 'compatible': callsite.arity fits within [required, total] range
+ * - 'incompatible': callsite.arity is outside the valid range
+ * - 'unknown': insufficient metadata to determine
+ */
+export function cppArityCompatibility(
+ def: SymbolDefinition,
+ callsite: Callsite,
+): 'compatible' | 'unknown' | 'incompatible' {
+ const max = def.parameterCount;
+ const min = def.requiredParameterCount;
+ if (max === undefined && min === undefined) return 'unknown';
+ if (!Number.isFinite(callsite.arity) || callsite.arity < 0) return 'unknown';
+
+ const variadic = def.parameterTypes?.some((t) => t === '...') ?? false;
+
+ // Too few arguments: less than the minimum required
+ if (min !== undefined && callsite.arity < min) return 'incompatible';
+ // Too many arguments: more than the maximum and not variadic
+ if (max !== undefined && callsite.arity > max && !variadic) return 'incompatible';
+
+ return 'compatible';
+}
diff --git a/gitnexus/src/core/ingestion/languages/cpp/captures.ts b/gitnexus/src/core/ingestion/languages/cpp/captures.ts
new file mode 100644
index 000000000..5c74950bb
--- /dev/null
+++ b/gitnexus/src/core/ingestion/languages/cpp/captures.ts
@@ -0,0 +1,1204 @@
+import type { Capture, CaptureMatch } from 'gitnexus-shared';
+import {
+ findNodeAtRange,
+ nodeToCapture,
+ syntheticCapture,
+ type SyntaxNode,
+} from '../../utils/ast-helpers.js';
+import { getCppParser, getCppScopeQuery } from './query.js';
+import { getTreeSitterBufferSize } from '../../constants.js';
+import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
+import { splitCppInclude, splitCppUsingDecl } from './import-decomposer.js';
+import { computeCppDeclarationArity, computeCppCallArity } from './arity-metadata.js';
+import { markCppAnonymousNamespaceRange, markFileLocal } from './file-local-linkage.js';
+import { markCppDependentBase } from './two-phase-lookup.js';
+import { markCppAdlSiteArgs, markCppAdlSiteNoAdl, type CppAdlArgInfo } from './adl.js';
+import { markCppInlineNamespaceRange } from './inline-namespaces.js';
+
+export function emitCppScopeCaptures(
+ sourceText: string,
+ filePath: string,
+ cachedTree?: unknown,
+): readonly CaptureMatch[] {
+ let tree = cachedTree as ReturnType['parse']> | undefined;
+ if (tree === undefined) {
+ tree = parseSourceSafe(getCppParser(), sourceText, undefined, {
+ bufferSize: getTreeSitterBufferSize(sourceText),
+ });
+ }
+
+ const rawMatches = getCppScopeQuery().matches(tree.rootNode);
+ const out: CaptureMatch[] = [];
+
+ // Track ranges where typedef-struct was captured as @declaration.struct
+ // so we can suppress the duplicate @declaration.typedef match.
+ const structTypedefRanges = new Set();
+
+ for (const m of rawMatches) {
+ const grouped: Record = {};
+ for (const c of m.captures) {
+ const tag = '@' + c.name;
+ if (tag.startsWith('@_')) continue;
+ grouped[tag] = nodeToCapture(tag, c.node);
+ }
+ if (Object.keys(grouped).length === 0) continue;
+
+ // ── Handle #include statements ──────────────────────────────────
+ if (grouped['@import.statement'] !== undefined) {
+ const anchor = grouped['@import.statement']!;
+ const includeNode = findNodeAtRange(tree.rootNode, anchor.range, 'preproc_include');
+ if (includeNode !== null) {
+ const split = splitCppInclude(includeNode);
+ if (split !== null) {
+ out.push(split);
+ continue;
+ }
+ }
+ }
+
+ // ── Handle using declarations (using namespace / using name) ────
+ if (grouped['@import.using-decl'] !== undefined) {
+ const anchor = grouped['@import.using-decl']!;
+ const usingNode = findNodeAtRange(tree.rootNode, anchor.range, 'using_declaration');
+ if (usingNode !== null) {
+ const split = splitCppUsingDecl(usingNode);
+ if (split !== null) {
+ out.push(split);
+ continue;
+ }
+ }
+ }
+
+ // ── Track typedef-struct ranges ─────────────────────────────────
+ const structAnchor = grouped['@declaration.struct'] ?? grouped['@declaration.class'];
+ if (structAnchor !== undefined) {
+ const r = structAnchor.range;
+ structTypedefRanges.add(`${r.startLine}:${r.startCol}:${r.endLine}:${r.endCol}`);
+ }
+
+ // Suppress @declaration.typedef if the same range was already captured
+ const typedefAnchor = grouped['@declaration.typedef'];
+ if (typedefAnchor !== undefined) {
+ const r = typedefAnchor.range;
+ const key = `${r.startLine}:${r.startCol}:${r.endLine}:${r.endCol}`;
+ if (structTypedefRanges.has(key)) continue;
+ }
+
+ // ── Enrich function/method declarations with arity metadata ─────
+ const declAnchor = grouped['@declaration.function'] ?? grouped['@declaration.method'];
+ if (declAnchor !== undefined) {
+ const fnNode =
+ findNodeAtRange(tree.rootNode, declAnchor.range, 'function_definition') ??
+ findNodeAtRange(tree.rootNode, declAnchor.range, 'declaration') ??
+ findNodeAtRange(tree.rootNode, declAnchor.range, 'field_declaration');
+ if (fnNode !== null) {
+ const arity = computeCppDeclarationArity(fnNode);
+ if (arity.parameterCount !== undefined) {
+ grouped['@declaration.parameter-count'] = syntheticCapture(
+ '@declaration.parameter-count',
+ fnNode,
+ String(arity.parameterCount),
+ );
+ }
+ if (arity.requiredParameterCount !== undefined) {
+ grouped['@declaration.required-parameter-count'] = syntheticCapture(
+ '@declaration.required-parameter-count',
+ fnNode,
+ String(arity.requiredParameterCount),
+ );
+ }
+ if (arity.parameterTypes !== undefined) {
+ grouped['@declaration.parameter-types'] = syntheticCapture(
+ '@declaration.parameter-types',
+ fnNode,
+ JSON.stringify(arity.parameterTypes),
+ );
+ }
+
+ // Detect static storage class (file-local linkage)
+ if (hasStaticStorageClass(fnNode)) {
+ const nameText = grouped['@declaration.name']?.text;
+ if (nameText !== undefined) {
+ markFileLocal(filePath, nameText);
+ }
+ }
+
+ // Detect anonymous namespace (file-local linkage)
+ if (isInsideAnonymousNamespace(fnNode)) {
+ const nameText = grouped['@declaration.name']?.text;
+ if (nameText !== undefined) {
+ markFileLocal(filePath, nameText);
+ }
+ }
+ }
+ }
+
+ // ── Detect static variables (file-local linkage) ────────────────
+ const varDeclAnchor = grouped['@declaration.variable'];
+ if (varDeclAnchor !== undefined) {
+ const varNode = findNodeAtRange(tree.rootNode, varDeclAnchor.range, 'declaration');
+ if (varNode !== null) {
+ if (hasStaticStorageClass(varNode) || isInsideAnonymousNamespace(varNode)) {
+ const nameText = grouped['@declaration.name']?.text;
+ if (nameText !== undefined) {
+ markFileLocal(filePath, nameText);
+ }
+ }
+ }
+ }
+
+ // ── Enrich call references with arity ───────────────────────────
+ const callAnchor =
+ grouped['@reference.call.free'] ??
+ grouped['@reference.call.member'] ??
+ grouped['@reference.call.qualified'];
+ if (callAnchor !== undefined && grouped['@reference.arity'] === undefined) {
+ const callNode = findNodeAtRange(tree.rootNode, callAnchor.range, 'call_expression');
+ if (callNode !== null) {
+ grouped['@reference.arity'] = syntheticCapture(
+ '@reference.arity',
+ callNode,
+ String(computeCppCallArity(callNode)),
+ );
+ }
+ }
+
+ // ── Enrich constructor calls (new Foo()) with arity ─────────────
+ const ctorCallAnchor = grouped['@reference.call.constructor'];
+ if (ctorCallAnchor !== undefined && grouped['@reference.arity'] === undefined) {
+ const newNode = findNodeAtRange(tree.rootNode, ctorCallAnchor.range, 'new_expression');
+ if (newNode !== null) {
+ grouped['@reference.arity'] = syntheticCapture(
+ '@reference.arity',
+ newNode,
+ String(computeCppCallArity(newNode)),
+ );
+ }
+ }
+
+ // ── Synthesize argument types for overload narrowing ────────────
+ const anyCallAnchor = callAnchor ?? ctorCallAnchor;
+ if (anyCallAnchor !== undefined && grouped['@reference.parameter-types'] === undefined) {
+ const cNode =
+ findNodeAtRange(tree.rootNode, anyCallAnchor.range, 'call_expression') ??
+ findNodeAtRange(tree.rootNode, anyCallAnchor.range, 'new_expression');
+ if (cNode !== null) {
+ const argTypes = inferCppCallArgTypes(cNode);
+ if (argTypes !== undefined && argTypes.length > 0) {
+ grouped['@reference.parameter-types'] = syntheticCapture(
+ '@reference.parameter-types',
+ cNode,
+ JSON.stringify(argTypes),
+ );
+ }
+ }
+ }
+
+ // ── Inline namespace detection ──────────────────────────────────
+ // `inline namespace v1 { ... }` — tree-sitter-cpp exposes the
+ // `inline` keyword as a child of `namespace_definition`. Record the
+ // namespace's source range so `populateCppInlineNamespaceScopes`
+ // (during populateOwners) can match it back to the corresponding
+ // Namespace scope.
+ // `@declaration.namespace` fires only for NAMED namespaces (the query
+ // requires a `name: (namespace_identifier)` child). Use the unconditional
+ // `@scope.namespace` capture so the anonymous-namespace branch also runs.
+ const namespaceScopeAnchor = grouped['@declaration.namespace'] ?? grouped['@scope.namespace'];
+ if (namespaceScopeAnchor !== undefined) {
+ const nsNode = findNodeAtRange(
+ tree.rootNode,
+ namespaceScopeAnchor.range,
+ 'namespace_definition',
+ );
+ if (nsNode !== null) {
+ // Range coords stored in the shared Range shape use 1-based
+ // line numbers (see `ast-helpers.ts` rangeForNode where
+ // `startPosition.row + 1` is applied). Match that convention so
+ // the populators can join against `Scope.range`.
+ const nsRange = {
+ startLine: nsNode.startPosition.row + 1,
+ startCol: nsNode.startPosition.column,
+ endLine: nsNode.endPosition.row + 1,
+ endCol: nsNode.endPosition.column,
+ };
+ if (isInlineNamespace(nsNode)) {
+ markCppInlineNamespaceRange(filePath, nsRange);
+ }
+ // Anonymous namespace: `namespace_definition` with no `name` field.
+ // Recorded so `expandCppWildcardNames` can propagate its members
+ // to including TUs even though their names are also `markFileLocal`'d
+ // (which blocks the global free-call fallback's cross-file path).
+ if ((nsNode.childForFieldName?.('name') ?? null) === null) {
+ markCppAnonymousNamespaceRange(filePath, nsRange);
+ }
+ }
+ }
+
+ // ── ADL (Koenig lookup) per-site recording ──────────────────────
+ // Only free-call sites (no explicit receiver) participate in ADL —
+ // qualified `Ns::f(s)` and member `obj.f(s)` calls bypass the
+ // free-call fallback entirely (handled by receiver-bound-calls).
+ if (grouped['@reference.call.free'] !== undefined) {
+ const freeCallNode = findNodeAtRange(
+ tree.rootNode,
+ grouped['@reference.call.free']!.range,
+ 'call_expression',
+ );
+ if (freeCallNode !== null) {
+ const adlAnchorRange = grouped['@reference.call.free']!.range;
+ if (isParenthesizedFunctionCall(freeCallNode)) {
+ markCppAdlSiteNoAdl(filePath, adlAnchorRange.startLine, adlAnchorRange.startCol);
+ }
+ const adlArgs = inferCppCallAdlArgs(freeCallNode);
+ if (adlArgs.length > 0) {
+ markCppAdlSiteArgs(filePath, adlAnchorRange.startLine, adlAnchorRange.startCol, adlArgs);
+ }
+ }
+ }
+
+ // ── Post-process @type-binding.assignment for auto declarations ──
+ // The wildcard `type: (_)` in the @type-binding.assignment query
+ // pattern matches before the more specific @type-binding.alias and
+ // @type-binding.member-access patterns. When the type is `auto`
+ // (placeholder_type_specifier), we re-inspect the AST to synthesize
+ // the correct capture tags so interpret.ts can produce the right
+ // rawTypeName for compound-receiver chain resolution.
+ if (
+ grouped['@type-binding.assignment'] !== undefined &&
+ grouped['@type-binding.type']?.text === 'auto'
+ ) {
+ const anchor = grouped['@type-binding.assignment']!;
+ const declNode = findNodeAtRange(tree.rootNode, anchor.range, 'declaration');
+ if (declNode !== null) {
+ const declarator = declNode.childForFieldName('declarator');
+ if (declarator?.type === 'init_declarator') {
+ const valueNode = declarator.childForFieldName('value');
+ if (valueNode !== null) {
+ if (valueNode.type === 'identifier') {
+ // auto alias = existingVar → promote to @type-binding.alias
+ grouped['@type-binding.alias'] = anchor;
+ grouped['@type-binding.type'] = nodeToCapture('@type-binding.type', valueNode);
+ delete grouped['@type-binding.assignment'];
+ } else if (valueNode.type === 'field_expression') {
+ // auto addr = user.address → promote to @type-binding.member-access
+ const argNode = valueNode.childForFieldName('argument');
+ const fieldNode = valueNode.childForFieldName('field');
+ if (argNode !== null && fieldNode !== null) {
+ grouped['@type-binding.member-access'] = anchor;
+ grouped['@type-binding.member-access-receiver'] = nodeToCapture(
+ '@type-binding.member-access-receiver',
+ argNode,
+ );
+ grouped['@type-binding.type'] = nodeToCapture('@type-binding.type', fieldNode);
+ delete grouped['@type-binding.assignment'];
+ }
+ } else if (valueNode.type === 'call_expression') {
+ const fnNode = valueNode.childForFieldName('function');
+ if (fnNode?.type === 'field_expression') {
+ // auto city = addr.getCity() → promote to @type-binding.alias
+ // with dotted rawName "addr.getCity" for compound-receiver
+ const argNode = fnNode.childForFieldName('argument');
+ const fieldNode = fnNode.childForFieldName('field');
+ if (argNode !== null && fieldNode !== null) {
+ grouped['@type-binding.member-access'] = anchor;
+ grouped['@type-binding.member-access-receiver'] = nodeToCapture(
+ '@type-binding.member-access-receiver',
+ argNode,
+ );
+ grouped['@type-binding.type'] = nodeToCapture('@type-binding.type', fieldNode);
+ delete grouped['@type-binding.assignment'];
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ out.push(grouped);
+ }
+
+ // ── Emit inheritance references for scope-resolution MRO / EXTENDS ──
+ // Walk every class/struct base list and synthesize `@reference.inherits`
+ // captures consumed by the registry-primary graph bridge. The lookup name
+ // is normalized to the bare class name so `Base` / `outer::v1::Base`
+ // resolve through V1's simple-name `findClassBindingInScope('Base')`.
+ emitCppInheritanceCaptures(tree.rootNode, out);
+
+ // ── Detect dependent-base relationships for two-phase template lookup ──
+ // Walk the tree once, finding every `template_declaration` whose
+ // child is a class/struct definition with a `base_class_clause` whose
+ // base names reference an in-scope template parameter. Record the
+ // (className, dependentBaseName) pair so `populateCppDependentBases`
+ // (called from the `populateOwners` hook) can resolve names to nodeIds
+ // and the resolver can suppress unqualified-call binding to those
+ // bases per ISO C++ two-phase lookup.
+ detectCppDependentBases(tree.rootNode, filePath);
+
+ return out;
+}
+
+/**
+ * Walk every C++ class/struct base clause and emit `@reference.inherits`
+ * captures for each base so scope resolution can resolve them into EXTENDS
+ * edges. Lookup names are normalized to bare class names (`Base` → `Base`,
+ * `outer::v1::Base` → `Base`) to match the V1 simple-name
+ * `findClassBindingInScope` contract. This intentionally preserves the
+ * existing scope-chain tradeoff: qualified namespace context is discarded
+ * here instead of introducing a C++-only name-resolution lane in shared
+ * ingestion infrastructure.
+ */
+function emitCppInheritanceCaptures(root: SyntaxNode, out: CaptureMatch[]): void {
+ const stack: SyntaxNode[] = [root];
+ while (stack.length > 0) {
+ const node = stack.pop()!;
+ if (node.type === 'class_specifier' || node.type === 'struct_specifier') {
+ const baseClause = findChildOfType(node, ['base_class_clause']);
+ if (baseClause !== null) {
+ for (const base of iterBaseClasses(baseClause)) {
+ const baseName = extractBaseLookupName(base);
+ if (baseName.length === 0) continue;
+ out.push({
+ '@reference.inherits': nodeToCapture('@reference.inherits', base),
+ '@reference.name': syntheticCapture('@reference.name', base, baseName),
+ });
+ }
+ }
+ }
+ for (let i = 0; i < node.childCount; i++) {
+ const child = node.child(i);
+ if (child !== null) stack.push(child);
+ }
+ }
+}
+
+/**
+ * Walk the AST finding every template_declaration containing a class or
+ * struct definition with a dependent base. Records (className, baseName)
+ * pairs into the module-level state via `markCppDependentBase`.
+ *
+ * A base is "dependent" when its name (typically a template_type like
+ * `Base`) uses a template parameter of the enclosing template_declaration.
+ * Conservative bias: `typename T::U`, `decltype(...)` and template-template
+ * parameter shapes are also treated as dependent.
+ */
+function detectCppDependentBases(root: SyntaxNode, filePath: string): void {
+ const stack: SyntaxNode[] = [root];
+ while (stack.length > 0) {
+ const node = stack.pop()!;
+ if (node.type === 'template_declaration') {
+ // Collect template-parameter names declared by this declaration.
+ // Inner template_declarations shadow outer ones — handled by the
+ // recursive descent below (each template_declaration creates its
+ // own parameter scope).
+ const params = collectTemplateParameterNames(node);
+
+ // Find the class/struct definition inside this template_declaration.
+ const classNode = findChildOfType(node, ['class_specifier', 'struct_specifier']);
+ if (classNode !== null) {
+ const className = getTypeIdentifierName(classNode);
+ if (className !== '') {
+ const baseClause = findChildOfType(classNode, ['base_class_clause']);
+ if (baseClause !== null) {
+ for (const base of iterBaseClasses(baseClause)) {
+ if (isBaseDependent(base, params)) {
+ const baseName = extractBaseLookupName(base);
+ if (baseName !== '') {
+ markCppDependentBase(filePath, className, baseName);
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ for (let i = 0; i < node.childCount; i++) {
+ const child = node.child(i);
+ if (child !== null) stack.push(child);
+ }
+ }
+}
+
+/** Collect simple template parameter names from a template_declaration. */
+function collectTemplateParameterNames(templateDecl: SyntaxNode): Set {
+ const names = new Set();
+ const paramList = findChildOfType(templateDecl, ['template_parameter_list']);
+ if (paramList === null) return names;
+ for (let i = 0; i < paramList.childCount; i++) {
+ const param = paramList.child(i);
+ if (param === null) continue;
+ if (
+ param.type === 'type_parameter_declaration' ||
+ param.type === 'optional_type_parameter_declaration' ||
+ param.type === 'variadic_type_parameter_declaration'
+ ) {
+ const idNode = findFirstDescendantOfType(param, 'type_identifier');
+ if (idNode !== null) names.add(idNode.text);
+ } else if (
+ param.type === 'parameter_declaration' ||
+ param.type === 'optional_parameter_declaration' ||
+ param.type === 'variadic_parameter_declaration'
+ ) {
+ // Non-type template parameter (e.g. `template`).
+ const idNode = findFirstDescendantOfType(param, 'identifier');
+ if (idNode !== null) names.add(idNode.text);
+ } else if (param.type === 'template_template_parameter_declaration') {
+ // template-template parameter (e.g. `template class TT>`)
+ const idNode = findFirstDescendantOfType(param, 'type_identifier');
+ if (idNode !== null) names.add(idNode.text);
+ }
+ }
+ return names;
+}
+
+/** Yield each base-class entry from a `base_class_clause`. */
+function* iterBaseClasses(baseClause: SyntaxNode): IterableIterator {
+ for (let i = 0; i < baseClause.childCount; i++) {
+ const child = baseClause.child(i);
+ if (child === null) continue;
+ // Skip ':', ',', and access_specifier nodes — the base names are
+ // type_identifier, template_type, or qualified_identifier.
+ if (
+ child.type === 'type_identifier' ||
+ child.type === 'template_type' ||
+ child.type === 'qualified_identifier'
+ ) {
+ yield child;
+ }
+ }
+}
+
+/**
+ * A base is dependent when:
+ * - it's a `template_type` and its argument list contains a
+ * `type_identifier` matching one of the enclosing template's params
+ * (e.g., `Base` where `T` is a template parameter), OR
+ * - it contains a `typename`, `decltype`, or `template_template_parameter`
+ * shape (conservatively treated as dependent).
+ *
+ * Non-dependent: `Base`, `ConcreteBase`, `Base` where
+ * `MyConcrete` is not a template parameter.
+ */
+function isBaseDependent(baseNode: SyntaxNode, templateParams: Set): boolean {
+ if (baseNode.type !== 'template_type') {
+ // Bare `type_identifier` or `qualified_identifier` bases — not
+ // dependent (the base name itself doesn't reference a template
+ // parameter at this level).
+ return false;
+ }
+ // Walk all descendants of the template_argument_list looking for any
+ // type_identifier matching a template parameter, or any conservative-
+ // dependent shape.
+ const stack: SyntaxNode[] = [baseNode];
+ while (stack.length > 0) {
+ const node = stack.pop()!;
+ if (node.type === 'type_identifier' && templateParams.has(node.text)) {
+ return true;
+ }
+ if (
+ node.type === 'decltype' ||
+ node.type === 'dependent_type' ||
+ node.type === 'template_template_parameter_declaration'
+ ) {
+ return true;
+ }
+ if (node.type === 'qualified_identifier') {
+ // `typename T::U` or `T::nested` — if any inner identifier matches
+ // a template parameter, dependent.
+ for (let i = 0; i < node.childCount; i++) {
+ const c = node.child(i);
+ if (c !== null) stack.push(c);
+ }
+ continue;
+ }
+ for (let i = 0; i < node.childCount; i++) {
+ const c = node.child(i);
+ if (c !== null) stack.push(c);
+ }
+ }
+ return false;
+}
+
+/**
+ * Recursively extract the bare lookup name of a base class node.
+ * Examples: `Base` → `Base`, `Base` → `Base`,
+ * `outer::v1::Base` → `Base`. Namespace qualifiers are intentionally
+ * dropped to align with V1 scope-chain lookup everywhere else in the
+ * registry-primary pipeline.
+ */
+function extractBaseLookupName(baseNode: SyntaxNode): string {
+ if (baseNode.type === 'type_identifier' || baseNode.type === 'identifier') return baseNode.text;
+ if (baseNode.type === 'template_type') {
+ const nameNode = baseNode.childForFieldName('name');
+ if (nameNode !== null) return extractBaseLookupName(nameNode);
+ const id =
+ findFirstDescendantOfType(baseNode, 'type_identifier') ??
+ findFirstDescendantOfType(baseNode, 'identifier');
+ if (id !== null) return id.text;
+ }
+ if (baseNode.type === 'qualified_identifier') {
+ const nameNode = baseNode.childForFieldName('name');
+ if (nameNode !== null) {
+ const nested = extractBaseLookupName(nameNode);
+ if (nested.length > 0) return nested;
+ }
+ for (let i = baseNode.childCount - 1; i >= 0; i--) {
+ const child = baseNode.child(i);
+ if (child === null) continue;
+ const nested = extractBaseLookupName(child);
+ if (nested.length > 0) return nested;
+ }
+ }
+ return '';
+}
+
+/** Find the first direct child matching one of the given types. */
+function findChildOfType(node: SyntaxNode, types: readonly string[]): SyntaxNode | null {
+ for (let i = 0; i < node.childCount; i++) {
+ const c = node.child(i);
+ if (c !== null && types.includes(c.type)) return c;
+ }
+ return null;
+}
+
+/** Recursive search for the first descendant of a given type. */
+function findFirstDescendantOfType(node: SyntaxNode, type: string): SyntaxNode | null {
+ if (node.type === type) return node;
+ for (let i = 0; i < node.childCount; i++) {
+ const c = node.child(i);
+ if (c === null) continue;
+ const hit = findFirstDescendantOfType(c, type);
+ if (hit !== null) return hit;
+ }
+ return null;
+}
+
+/** Get the name of a class/struct/template_type node via its `name` field. */
+function getTypeIdentifierName(node: SyntaxNode): string {
+ const nameNode = node.childForFieldName('name');
+ if (nameNode !== null) return nameNode.text;
+ const id = findFirstDescendantOfType(node, 'type_identifier');
+ return id !== null ? id.text : '';
+}
+
+/**
+ * Infer argument types from a call_expression or new_expression node.
+ * Used for overload disambiguation by parameter types.
+ *
+ * Only literal types are inferred — identifiers and complex expressions
+ * return empty string (unknown) so narrowOverloadCandidates treats them
+ * as any-match.
+ */
+function inferCppCallArgTypes(node: SyntaxNode): string[] | undefined {
+ const argList = node.childForFieldName('arguments');
+ if (argList === null) return undefined;
+
+ const types: string[] = [];
+ for (let i = 0; i < argList.childCount; i++) {
+ const child = argList.child(i);
+ if (child === null) continue;
+ if (child.type === ',' || child.type === '(' || child.type === ')') continue;
+ const litType = inferCppLiteralType(child);
+ if (litType !== '') {
+ types.push(litType);
+ } else if (child.type === 'identifier') {
+ // Variable reference — look up declared type in enclosing scope
+ types.push(lookupDeclaredTypeForIdentifier(child));
+ } else {
+ types.push('');
+ }
+ }
+ return types.length > 0 ? types : undefined;
+}
+
+/**
+ * Infer the canonical type name of a C++ literal AST node.
+ * Returns empty string for non-literal / unknown nodes.
+ */
+function inferCppLiteralType(node: SyntaxNode): string {
+ switch (node.type) {
+ case 'number_literal': {
+ const text = node.text;
+ // Floating-point literals contain '.', 'e', 'E', or end with 'f'/'F'
+ if (
+ text.includes('.') ||
+ text.includes('e') ||
+ text.includes('E') ||
+ text.endsWith('f') ||
+ text.endsWith('F')
+ ) {
+ return 'double';
+ }
+ return 'int';
+ }
+ case 'string_literal':
+ case 'raw_string_literal':
+ case 'concatenated_string':
+ return 'string';
+ case 'char_literal':
+ return 'char';
+ case 'true':
+ case 'false':
+ return 'bool';
+ case 'null':
+ case 'nullptr':
+ return 'null';
+ default:
+ return '';
+ }
+}
+
+/**
+ * Look up the declared type of a variable by scanning sibling declarations
+ * in the enclosing compound_statement (function body). Handles:
+ * - `std::string result = ...` → 'string'
+ * - `int n = ...` → 'int'
+ * - `const int n = ...` → 'int'
+ * Returns empty string if no declaration found or type is auto/placeholder.
+ */
+function lookupDeclaredTypeForIdentifier(identNode: SyntaxNode): string {
+ const varName = identNode.text;
+ // Walk up to the enclosing compound_statement (function body)
+ let scope: SyntaxNode | null = identNode.parent;
+ while (
+ scope !== null &&
+ scope.type !== 'compound_statement' &&
+ scope.type !== 'translation_unit'
+ ) {
+ scope = scope.parent;
+ }
+ if (scope === null) return '';
+
+ // Scan declarations in the scope for a matching variable name
+ for (let i = 0; i < scope.childCount; i++) {
+ const stmt = scope.child(i);
+ if (stmt === null || stmt.type !== 'declaration') continue;
+
+ const typeNode = stmt.childForFieldName('type');
+ if (typeNode === null) continue;
+ // Skip auto/placeholder types — those need chain-follow, not literal
+ if (typeNode.type === 'placeholder_type_specifier') continue;
+
+ // Check init_declarator children for the variable name
+ const declarator = stmt.childForFieldName('declarator');
+ if (declarator === null) continue;
+ if (declarator.type === 'init_declarator') {
+ const nameChild = declarator.childForFieldName('declarator');
+ if (nameChild !== null && nameChild.text === varName) {
+ return normalizeCppTypeText(typeNode.text);
+ }
+ } else if (declarator.text === varName) {
+ return normalizeCppTypeText(typeNode.text);
+ }
+ }
+ return '';
+}
+
+/** Normalize a type-specifier text for argument type matching.
+ * Strips qualifiers (const, volatile), namespace prefixes (std::),
+ * and pointer/reference markers. */
+function normalizeCppTypeText(text: string): string {
+ let t = text.trim();
+ t = t.replace(/\b(const|volatile|static|extern|mutable)\b/g, '').trim();
+ t = t.replace(/^.*::/, ''); // strip namespace prefix
+ t = t.replace(/[*&]/g, '').trim();
+ return t;
+}
+
+/**
+ * Detect whether a `namespace_definition` AST node is inline.
+ * Tree-sitter-cpp exposes the `inline` keyword as an anonymous child
+ * node — we scan direct children for that keyword.
+ */
+function isInlineNamespace(nsNode: SyntaxNode): boolean {
+ for (let i = 0; i < nsNode.childCount; i++) {
+ const c = nsNode.child(i);
+ if (c === null) continue;
+ if (c.type === 'inline') return true;
+ // Some grammar variants surface keywords by their text rather than
+ // by a dedicated node type; check both for resilience.
+ if (c.text === 'inline' && (c.type === 'storage_class_specifier' || c.type === 'inline')) {
+ return true;
+ }
+ }
+ return false;
+}
+
+/**
+ * Detect `(f)(args)` shape — the call-expression's `function` field is a
+ * `parenthesized_expression`. ISO C++ specifies that this form suppresses
+ * ADL (`[basic.lookup.argdep]/3.1`): the parenthesized name is treated as
+ * an ordinary unqualified-lookup-only callee.
+ */
+function isParenthesizedFunctionCall(callNode: SyntaxNode): boolean {
+ const fn = callNode.childForFieldName('function');
+ return fn !== null && fn.type === 'parenthesized_expression';
+}
+
+/**
+ * Per-argument ADL classification: walk each argument of a free call and
+ * classify its declared type for associated-namespace lookup.
+ *
+ * Value/pointer/reference class-typed args and template specializations
+ * with explicit type arguments contribute; function pointers, primitives,
+ * literals, and other unsupported shapes produce an empty result.
+ *
+ * Class-typed values/pointers/references (`N::S`, `N::S*`, `N::S&`) all
+ * preserve the class name for associated-namespace lookup.
+ * Function pointers remain excluded even when their return type names a
+ * class, because the associated entity is the pointed-to function type,
+ * not the return type.
+ */
+function inferCppCallAdlArgs(callNode: SyntaxNode): CppAdlArgInfo[] {
+ const argList = callNode.childForFieldName('arguments');
+ if (argList === null) return [];
+ const out: CppAdlArgInfo[] = [];
+ for (let i = 0; i < argList.childCount; i++) {
+ const child = argList.child(i);
+ if (child === null) continue;
+ if (child.type === ',' || child.type === '(' || child.type === ')') continue;
+ out.push(classifyAdlArg(child));
+ }
+ return out;
+}
+
+const ADL_TEMPLATE_RECURSION_MAX_DEPTH = 8;
+const EMPTY_ADL_ARG: CppAdlArgInfo = {
+ simpleClassName: '',
+ templateSimpleClassName: '',
+ templateNamespace: '',
+ templateArgClassNames: [],
+ templateArgNamespaces: [],
+};
+
+function classifyAdlArg(argNode: SyntaxNode): CppAdlArgInfo {
+ // Literals and primitive-shaped expressions never have associated namespaces.
+ if (
+ argNode.type === 'number_literal' ||
+ argNode.type === 'string_literal' ||
+ argNode.type === 'raw_string_literal' ||
+ argNode.type === 'char_literal' ||
+ argNode.type === 'true' ||
+ argNode.type === 'false' ||
+ argNode.type === 'null' ||
+ argNode.type === 'nullptr'
+ ) {
+ return EMPTY_ADL_ARG;
+ }
+ // Qualified expression (a::b) — may be a function, variable, enum value,
+ // or static member. Record as a potential function reference; resolution
+ // time verifies via workspace lookup that a Function/Method with this simple
+ // name exists in the extracted namespace before contributing to the set.
+ if (argNode.type === 'qualified_identifier') {
+ return {
+ simpleClassName: '',
+ templateSimpleClassName: '',
+ templateNamespace: '',
+ templateArgClassNames: [],
+ templateArgNamespaces: [],
+ functionRefText: argNode.text,
+ };
+ }
+ // Variable reference — look up its declared type (preserving pointer /
+ // reference / qualified-name shape; the existing arity-narrowing helper
+ // strips this info).
+ if (argNode.type === 'identifier') {
+ const result = lookupAdlIdentifierType(argNode);
+ if (result === null) {
+ // Not found in the local compound_statement scope — could be a
+ // free-function reference (unqualified name, namespace scope).
+ return {
+ simpleClassName: '',
+ templateSimpleClassName: '',
+ templateNamespace: '',
+ templateArgClassNames: [],
+ templateArgNamespaces: [],
+ functionRefText: argNode.text,
+ };
+ }
+ return result;
+ }
+ // Other shapes (calls, member access, operators) — V1 unsupported.
+ return EMPTY_ADL_ARG;
+}
+
+/**
+ * Returns `true` when `varName` appears as a parameter name in the nearest
+ * enclosing `function_definition` or `function_declarator` that contains
+ * `identNode`. Parameters live in `parameter_list` (a sibling of the
+ * `compound_statement`), so the `compound_statement`-local declaration scan
+ * in `lookupAdlIdentifierType` would not find them — causing them to be
+ * mistakenly classified as potential free-function references.
+ *
+ * In tree-sitter-cpp a `function_definition` does NOT expose `parameters`
+ * as a direct named field; parameters live inside the nested
+ * `function_declarator`. For `function_declarator` nodes the `parameters`
+ * field IS direct. Both cases are handled below.
+ */
+function isIdentifierAFunctionParameter(identNode: SyntaxNode, varName: string): boolean {
+ let node: SyntaxNode | null = identNode.parent;
+ let safety = 64;
+ while (node !== null && safety-- > 0) {
+ let params: SyntaxNode | null = null;
+ if (node.type === 'function_declarator') {
+ // parameters is a direct field on function_declarator.
+ params = node.childForFieldName('parameters');
+ } else if (node.type === 'function_definition') {
+ // function_definition carries parameters inside its `declarator` field
+ // (which is a function_declarator). Walk through it.
+ const decl = node.childForFieldName('declarator');
+ if (decl !== null && decl.type === 'function_declarator') {
+ params = decl.childForFieldName('parameters');
+ }
+ }
+ if (params !== null) {
+ for (let i = 0; i < params.namedChildCount; i++) {
+ const param = params.namedChild(i);
+ if (param === null) continue;
+ const declNode = param.childForFieldName('declarator');
+ if (declNode === null) continue;
+ const leafName = extractDeclaratorLeafName(declNode);
+ if (leafName === varName) return true;
+ }
+ // Only check the immediately enclosing function — do not climb further.
+ break;
+ }
+ if (node.type === 'translation_unit') break;
+ node = node.parent;
+ }
+ return false;
+}
+
+function lookupAdlIdentifierType(identNode: SyntaxNode): CppAdlArgInfo | null {
+ const varName = identNode.text;
+ let scope: SyntaxNode | null = identNode.parent;
+ while (
+ scope !== null &&
+ scope.type !== 'compound_statement' &&
+ scope.type !== 'translation_unit'
+ ) {
+ scope = scope.parent;
+ }
+ if (scope === null) return null;
+
+ // Function parameters live in the enclosing function's `parameter_list`,
+ // NOT inside the `compound_statement`, so the declaration scan below would
+ // never find them and would return `null` — incorrectly triggering the
+ // free-function-reference path. Check the parameter_list first.
+ if (isIdentifierAFunctionParameter(identNode, varName)) {
+ return EMPTY_ADL_ARG;
+ }
+
+ let foundAsLocalFunctionPointer = false;
+ for (let i = 0; i < scope.childCount; i++) {
+ const stmt = scope.child(i);
+ if (stmt === null || stmt.type !== 'declaration') continue;
+ const typeNode = stmt.childForFieldName('type');
+ if (typeNode === null) continue;
+ if (typeNode.type === 'placeholder_type_specifier') continue;
+
+ const declarator = stmt.childForFieldName('declarator');
+ if (declarator === null) continue;
+
+ // Unwrap declarator chain to find pointer/reference markers and the
+ // variable name. `init_declarator > pointer_declarator > identifier`
+ // means pointer-typed; repeated pointer wrappers still count as pointer
+ // typed; `init_declarator > reference_declarator > ...` (or
+ // `rvalue_reference_declarator`) means reference-typed; bare
+ // `init_declarator > identifier` is value.
+ // Function-pointer wrappers (`pointer_declarator > function_declarator`)
+ // must not contribute ADL associated namespaces.
+ let isFunctionPointer = false;
+ let inner: SyntaxNode = declarator;
+ let nameText: string | null = null;
+ let safety = 16; // bound walk depth defensively
+ while (safety-- > 0) {
+ if (inner.type === 'pointer_declarator') {
+ if (findFirstDescendantOfType(inner, 'function_declarator') !== null) {
+ isFunctionPointer = true;
+ // Extract the name from within the function-pointer declarator chain
+ // so `foundAsLocalFunctionPointer` can detect a matching declaration.
+ nameText = extractDeclaratorLeafName(inner);
+ break;
+ }
+ const next = inner.childForFieldName('declarator');
+ if (next === null) break;
+ inner = next;
+ continue;
+ }
+ if (inner.type === 'reference_declarator' || inner.type === 'rvalue_reference_declarator') {
+ // reference_declarator has a single child (the inner declarator).
+ let next: SyntaxNode | null = null;
+ for (let j = 0; j < inner.namedChildCount; j++) {
+ const c = inner.namedChild(j);
+ if (c !== null) {
+ next = c;
+ break;
+ }
+ }
+ if (next === null) break;
+ inner = next;
+ continue;
+ }
+ if (inner.type === 'init_declarator') {
+ const next = inner.childForFieldName('declarator');
+ if (next === null) break;
+ inner = next;
+ continue;
+ }
+ if (inner.type === 'function_declarator') {
+ isFunctionPointer = true;
+ // Extract the name from the inner declarator (e.g. `(*g)` in `void (*g)()`).
+ const innerDecl = inner.childForFieldName('declarator');
+ if (innerDecl !== null) nameText = extractDeclaratorLeafName(innerDecl);
+ break;
+ }
+ // Reached the leaf — usually `identifier`. Take its text.
+ nameText = inner.text;
+ break;
+ }
+ if (nameText === varName && isFunctionPointer) {
+ // Explicitly declared as a function-pointer variable — must not be
+ // treated as a free-function reference by the caller.
+ foundAsLocalFunctionPointer = true;
+ continue;
+ }
+ if (isFunctionPointer || nameText !== varName) continue;
+
+ const simpleClassName = extractAdlSimpleTypeName(typeNode);
+ const {
+ templateSimpleClassName,
+ templateNamespace,
+ templateArgClassNames,
+ templateArgNamespaces,
+ } = extractAdlTemplateInfo(typeNode);
+ return {
+ simpleClassName,
+ templateSimpleClassName,
+ templateNamespace,
+ templateArgClassNames,
+ templateArgNamespaces,
+ };
+ }
+ // If the identifier was found in local scope as a function-pointer variable,
+ // return EMPTY_ADL_ARG so the caller does NOT treat it as a free-function
+ // reference. Otherwise return null to indicate "not in local scope".
+ //
+ // Known limitation (Finding 4): variables whose type is a typedef/using alias
+ // for a function-pointer type are NOT detected here. For example:
+ // using Callback = void (*)();
+ // Callback g;
+ // foo(g); // `g`'s declarator is `identifier` with type `Callback`
+ // The declarator has no `pointer_declarator` wrapper, so `isFunctionPointer`
+ // stays false and `extractAdlSimpleTypeName` returns `"Callback"`. ADL then
+ // looks for a class named `Callback`; if none exists, this degrades to
+ // EMPTY_ADL_ARG (class not found → no namespace contributed). If a class
+ // named `Callback` does exist, a spurious namespace contribution could occur.
+ // Risk is low in practice; a future fix should resolve the typedef/alias chain.
+ return foundAsLocalFunctionPointer ? EMPTY_ADL_ARG : null;
+}
+
+/** Extract the simple class-like type name from a `type:` field node.
+ * Returns '' for primitives and any other
+ * unsupported type-only shape. Function pointers are filtered at the
+ * declarator level in `lookupAdlIdentifierType`. */
+function extractAdlSimpleTypeName(typeNode: SyntaxNode): string {
+ if (typeNode.type === 'type_descriptor') {
+ const innerType = typeNode.childForFieldName('type');
+ if (innerType !== null) return extractAdlSimpleTypeName(innerType);
+ for (let i = 0; i < typeNode.childCount; i++) {
+ const child = typeNode.child(i);
+ if (child === null) continue;
+ if (
+ child.type === 'type_identifier' ||
+ child.type === 'qualified_identifier' ||
+ child.type === 'template_type'
+ ) {
+ return extractAdlSimpleTypeName(child);
+ }
+ }
+ return '';
+ }
+ if (typeNode.type === 'primitive_type') return '';
+ if (typeNode.type === 'sized_type_specifier') return '';
+ if (typeNode.type === 'type_identifier') return typeNode.text;
+ if (typeNode.type === 'template_type') {
+ const nameNode = typeNode.childForFieldName('name');
+ if (nameNode !== null) return extractAdlSimpleTypeName(nameNode);
+ const id = findFirstDescendantOfType(typeNode, 'type_identifier');
+ return id !== null ? id.text : '';
+ }
+ if (typeNode.type === 'qualified_identifier') {
+ const nameNode = typeNode.childForFieldName('name');
+ if (nameNode !== null) return extractAdlSimpleTypeName(nameNode);
+ const id = findFirstDescendantOfType(typeNode, 'type_identifier');
+ return id !== null ? id.text : '';
+ }
+ // Function pointers, decltype, etc — unsupported for ADL participation.
+ return '';
+}
+
+function extractAdlTypeNamespace(typeNode: SyntaxNode): string {
+ if (typeNode.type === 'type_descriptor') {
+ const innerType = typeNode.childForFieldName('type');
+ if (innerType !== null) return extractAdlTypeNamespace(innerType);
+ for (let i = 0; i < typeNode.childCount; i++) {
+ const child = typeNode.child(i);
+ if (child === null) continue;
+ if (
+ child.type === 'qualified_identifier' ||
+ child.type === 'template_type' ||
+ child.type === 'type_identifier'
+ ) {
+ return extractAdlTypeNamespace(child);
+ }
+ }
+ return '';
+ }
+ if (typeNode.type === 'template_type') {
+ const nameNode = typeNode.childForFieldName('name');
+ return nameNode !== null ? extractAdlTypeNamespace(nameNode) : '';
+ }
+ if (typeNode.type === 'qualified_identifier') {
+ const scope = typeNode.childForFieldName('scope');
+ if (scope !== null) return normalizeCppNamespaceQName(scope.text);
+ return extractNamespaceFromQualifiedText(typeNode.text);
+ }
+ return '';
+}
+
+function extractAdlTemplateInfo(typeNode: SyntaxNode): {
+ templateSimpleClassName: string;
+ templateNamespace: string;
+ templateArgClassNames: string[];
+ templateArgNamespaces: string[];
+} {
+ const templateTypeNode = findTemplateTypeNode(typeNode);
+ if (templateTypeNode === null) {
+ return {
+ templateSimpleClassName: '',
+ templateNamespace: '',
+ templateArgClassNames: [],
+ templateArgNamespaces: [],
+ };
+ }
+ const templateArgClassNames: string[] = [];
+ const templateArgNamespaces: string[] = [];
+ collectAdlTemplateArgs(templateTypeNode, 0, templateArgClassNames, templateArgNamespaces);
+ return {
+ templateSimpleClassName: extractAdlSimpleTypeName(templateTypeNode),
+ templateNamespace: extractAdlTypeNamespace(typeNode),
+ templateArgClassNames,
+ templateArgNamespaces,
+ };
+}
+
+function collectAdlTemplateArgs(
+ templateTypeNode: SyntaxNode,
+ depth: number,
+ outClassNames: string[],
+ outNamespaces: string[],
+): void {
+ if (depth >= ADL_TEMPLATE_RECURSION_MAX_DEPTH) return;
+ if (templateTypeNode.type !== 'template_type') return;
+
+ const argList =
+ templateTypeNode.childForFieldName('arguments') ??
+ findChildOfType(templateTypeNode, ['template_argument_list']);
+ if (argList === null) return;
+
+ for (let i = 0; i < argList.namedChildCount; i++) {
+ const arg = argList.namedChild(i);
+ if (arg === null || arg.type !== 'type_descriptor') continue;
+ const simpleClassName = extractAdlSimpleTypeName(arg);
+ if (simpleClassName.length > 0) outClassNames.push(simpleClassName);
+ const ns = extractAdlTypeNamespace(arg);
+ if (ns.length > 0) outNamespaces.push(ns);
+
+ const nestedType = arg.childForFieldName('type');
+ const nestedTemplate = nestedType !== null ? findTemplateTypeNode(nestedType) : null;
+ if (nestedTemplate !== null) {
+ collectAdlTemplateArgs(nestedTemplate, depth + 1, outClassNames, outNamespaces);
+ }
+ }
+}
+
+function findTemplateTypeNode(typeNode: SyntaxNode): SyntaxNode | null {
+ if (typeNode.type === 'template_type') return typeNode;
+ if (typeNode.type === 'type_descriptor') {
+ const innerType = typeNode.childForFieldName('type');
+ if (innerType !== null) return findTemplateTypeNode(innerType);
+ return null;
+ }
+ if (typeNode.type === 'qualified_identifier') {
+ const nameNode = typeNode.childForFieldName('name');
+ if (nameNode !== null) return findTemplateTypeNode(nameNode);
+ return null;
+ }
+ return null;
+}
+
+function normalizeCppNamespaceQName(text: string): string {
+ const normalized = text.replace(/^::/, '').replace(/::$/, '').replace(/::/g, '.');
+ return normalized;
+}
+
+function extractNamespaceFromQualifiedText(text: string): string {
+ const cleaned = text.replace(/\s+/g, '');
+ const idx = cleaned.lastIndexOf('::');
+ if (idx <= 0) return '';
+ return normalizeCppNamespaceQName(cleaned.slice(0, idx));
+}
+
+/**
+ * Walk a declarator node chain, unwrapping pointer/reference/function/
+ * parenthesized wrappers, and return the text of the innermost identifier.
+ * Returns `null` when no identifier is found within `safety` steps.
+ * Used by `lookupAdlIdentifierType` to extract the variable name from
+ * function-pointer declarator trees such as `(*g)()` in `void (*g)()`.
+ */
+function extractDeclaratorLeafName(node: SyntaxNode): string | null {
+ let cur: SyntaxNode = node;
+ let safety = 16;
+ while (safety-- > 0) {
+ if (cur.type === 'identifier' || cur.type === 'type_identifier') return cur.text;
+ // Common wrapper nodes — follow the 'declarator' field when present.
+ const next =
+ cur.childForFieldName('declarator') ??
+ // parenthesized_declarator: single named child
+ (cur.type === 'parenthesized_declarator' ? cur.namedChild(0) : null);
+ if (next === null) return null;
+ cur = next;
+ }
+ return null;
+}
+
+/**
+ * Check if a C++ function_definition or declaration has `static` storage class.
+ */
+function hasStaticStorageClass(node: SyntaxNode): boolean {
+ for (let i = 0; i < node.childCount; i++) {
+ const child = node.child(i);
+ if (child !== null && child.type === 'storage_class_specifier' && child.text === 'static') {
+ return true;
+ }
+ }
+ return false;
+}
+
+/**
+ * Check if a node is inside an anonymous namespace (file-local linkage in C++).
+ * Anonymous namespaces have no `name` field in tree-sitter-cpp.
+ */
+function isInsideAnonymousNamespace(node: SyntaxNode): boolean {
+ let ancestor: SyntaxNode | null = node.parent ?? null;
+ while (ancestor !== null) {
+ if (ancestor.type === 'namespace_definition') {
+ // Anonymous namespace: has declaration_list but no name child
+ const nameChild = ancestor.childForFieldName?.('name') ?? null;
+ if (nameChild === null) return true;
+ }
+ ancestor = ancestor.parent;
+ }
+ return false;
+}
diff --git a/gitnexus/src/core/ingestion/languages/cpp/file-local-linkage.ts b/gitnexus/src/core/ingestion/languages/cpp/file-local-linkage.ts
new file mode 100644
index 000000000..dd5fc8c0a
--- /dev/null
+++ b/gitnexus/src/core/ingestion/languages/cpp/file-local-linkage.ts
@@ -0,0 +1,302 @@
+import type { ParsedFile, Scope, ScopeId, SymbolDefinition } from 'gitnexus-shared';
+import { isCppInlineNamespaceScope } from './inline-namespaces.js';
+
+/**
+ * Per-file set of symbol names with file-local linkage.
+ * In C++ there are two sources of file-local linkage:
+ * 1. `static` storage class (same as C)
+ * 2. Anonymous namespace (`namespace { ... }`)
+ *
+ * Populated during `emitCppScopeCaptures` and consumed by
+ * `expandCppWildcardNames` to exclude file-local symbols from
+ * cross-file wildcard import visibility.
+ *
+ * NOTE: module-level state, single-process-single-repo use only.
+ * Call `clearFileLocalNames()` at the start of each resolution pass.
+ *
+ * Key: filePath, Value: Set of file-local symbol names.
+ */
+const fileLocalNames = new Map>();
+
+/**
+ * Per-file set of `SymbolDefinition.nodeId`s that are NOT visible by
+ * unqualified lookup from outside the file — class-owned methods/fields
+ * and namespace-nested symbols. Populated by `populateCppNonGloballyVisible`
+ * during the per-file `populateOwners` hook; consumed by
+ * `isCppDefGloballyVisible` from both `expandCppWildcardNames` (wildcard
+ * propagation) and the global free-call fallback's `isFileLocalDef` hook.
+ *
+ * Tracked per filePath rather than as a single global set so cross-file
+ * lookup correctly compares the candidate's owning file's non-visible
+ * set without leaking across pipeline invocations (the global free-call
+ * fallback checks `def.filePath !== callerFilePath` and then asks "is
+ * this def visible from outside its own file?" — that's exactly what
+ * this set encodes).
+ */
+const nonGloballyVisibleNodeIds = new Map>();
+
+/**
+ * Per-file set of source-range keys identifying `namespace { ... }` blocks.
+ * Resolved to `ScopeId`s in `populateCppAnonymousNamespaceScopes` and
+ * consumed via `isCppAnonymousNamespaceScope`.
+ *
+ * Anonymous namespaces have file-local linkage but, unlike `static`, their
+ * members propagate to any TU that `#include`s the declaring file — each
+ * including TU gets its own internal-linkage copy. So for wildcard import
+ * expansion (`expandCppWildcardNames`) we treat anonymous-namespace owned
+ * defs as if declared at the enclosing scope. Cross-file unqualified
+ * lookup that does NOT go through `#include` is still blocked by the
+ * `isFileLocal` mark recorded on the def's name.
+ */
+const anonymousNamespaceRangesByFile = new Map>();
+const anonymousNamespaceScopeIds = new Set();
+
+interface RangeKeyShape {
+ readonly startLine: number;
+ readonly startCol: number;
+ readonly endLine: number;
+ readonly endCol: number;
+}
+
+function rangeKey(r: RangeKeyShape): string {
+ return `${r.startLine}:${r.startCol}:${r.endLine}:${r.endCol}`;
+}
+
+/** Record a symbol name as file-local (static or anonymous namespace). */
+export function markFileLocal(filePath: string, name: string): void {
+ let names = fileLocalNames.get(filePath);
+ if (names === undefined) {
+ names = new Set();
+ fileLocalNames.set(filePath, names);
+ }
+ names.add(name);
+}
+
+/** Check whether a symbol name has file-local linkage in the given file. */
+export function isFileLocal(filePath: string, name: string): boolean {
+ return fileLocalNames.get(filePath)?.has(name) ?? false;
+}
+
+/** Capture-time: record an anonymous `namespace_definition` source range. */
+export function markCppAnonymousNamespaceRange(filePath: string, range: RangeKeyShape): void {
+ let set = anonymousNamespaceRangesByFile.get(filePath);
+ if (set === undefined) {
+ set = new Set();
+ anonymousNamespaceRangesByFile.set(filePath, set);
+ }
+ set.add(rangeKey(range));
+}
+
+/** Predicate consumed by `populateCppNonGloballyVisible` and
+ * `expandCppWildcardNames` to exempt anonymous-namespace scopes from
+ * the cross-file unqualified-lookup exclusion that applies to ordinary
+ * named namespaces. */
+export function isCppAnonymousNamespaceScope(scopeId: ScopeId): boolean {
+ return anonymousNamespaceScopeIds.has(scopeId);
+}
+
+/** Clear tracked file-local names (call at start of each resolution pass). */
+export function clearFileLocalNames(): void {
+ fileLocalNames.clear();
+ nonGloballyVisibleNodeIds.clear();
+ anonymousNamespaceRangesByFile.clear();
+ anonymousNamespaceScopeIds.clear();
+}
+
+/** Resolve recorded anonymous-namespace source ranges to `ScopeId`s.
+ * Must run inside `populateOwners` BEFORE `populateCppNonGloballyVisible`
+ * consults the resolved set. */
+export function populateCppAnonymousNamespaceScopes(parsed: {
+ readonly filePath: string;
+ readonly scopes: readonly {
+ readonly id: ScopeId;
+ readonly kind: string;
+ readonly range: RangeKeyShape;
+ }[];
+}): void {
+ const ranges = anonymousNamespaceRangesByFile.get(parsed.filePath);
+ if (ranges === undefined || ranges.size === 0) return;
+ for (const scope of parsed.scopes) {
+ if (scope.kind !== 'Namespace') continue;
+ if (ranges.has(rangeKey(scope.range))) {
+ anonymousNamespaceScopeIds.add(scope.id);
+ }
+ }
+}
+
+/**
+ * Populate per-file "not globally visible" nodeIds by walking the parsed
+ * file's scopes. Run as part of the `populateOwners` hook so every C++
+ * scope is reflected before any cross-file resolution pass consults the
+ * set.
+ *
+ * A def is "not globally visible" when its nearest structurally enclosing
+ * scope is a `Namespace` or `Class` — those require qualification
+ * (`ns::name`, `Class::method`) for cross-file unqualified lookup.
+ * Module-scoped defs remain globally visible.
+ */
+export function populateCppNonGloballyVisible(parsed: {
+ readonly filePath: string;
+ readonly scopes: readonly {
+ readonly id: ScopeId;
+ readonly kind: string;
+ readonly ownedDefs: readonly { readonly nodeId: string }[];
+ }[];
+}): void {
+ let set = nonGloballyVisibleNodeIds.get(parsed.filePath);
+ if (set === undefined) {
+ set = new Set();
+ nonGloballyVisibleNodeIds.set(parsed.filePath, set);
+ }
+ for (const scope of parsed.scopes) {
+ if (scope.kind !== 'Namespace' && scope.kind !== 'Class') continue;
+ // Inline namespaces (`inline namespace v1 { ... }`) propagate their
+ // members to the enclosing namespace's unqualified-lookup scope per
+ // ISO C++ `[namespace.def]/p4`. Skip them here so cross-file
+ // unqualified lookup can still see their callable defs.
+ if (scope.kind === 'Namespace' && isCppInlineNamespaceScope(scope.id)) continue;
+ // Anonymous namespaces give internal linkage but their contents are
+ // visible at the enclosing scope within the same TU and propagate to
+ // any TU that `#include`s the declaring file. The `isFileLocal` mark
+ // (recorded on the def's name in this file) still blocks cross-file
+ // unqualified lookup that does not go through #include, so dropping
+ // the structural visibility exclusion here is safe.
+ if (scope.kind === 'Namespace' && anonymousNamespaceScopeIds.has(scope.id)) continue;
+ for (const def of scope.ownedDefs) {
+ set.add(def.nodeId);
+ }
+ }
+}
+
+/**
+ * Check whether a def is visible by unqualified lookup from outside its
+ * own file. Returns `false` for class-owned and namespace-nested defs.
+ *
+ * Used by the global free-call fallback's `isFileLocalDef` hook (which
+ * historically meant "static / anonymous-namespace" but semantically
+ * stands for "logically invisible cross-file"). Including class methods
+ * and namespace members under the same negative answer fixes the leak
+ * where unqualified `save()` resolved to `User::save` through a shared
+ * workspace registry walk.
+ */
+export function isCppDefGloballyVisible(filePath: string, nodeId: string): boolean {
+ return nonGloballyVisibleNodeIds.get(filePath)?.has(nodeId) !== true;
+}
+
+/**
+ * Return the names visible through a C++ wildcard import (`#include` or
+ * `using namespace`).
+ *
+ * ## Contract
+ *
+ * C++ unqualified name lookup only sees names at the importer's enclosing
+ * scope. Class members and namespace-nested symbols are NOT visible by
+ * unqualified lookup from a free function in an including TU — they must
+ * be reached via `Class::method`, `ns::name`, or a working `using`
+ * declaration. The filter below enforces that contract for header
+ * propagation: only defs whose nearest enclosing scope is the header's
+ * `Module` scope are emitted as wildcard-binding names.
+ *
+ * ## Why scope-aware and not predicate-on-qualifiedName
+ *
+ * A naive `def.qualifiedName.indexOf('.') === -1` check is unreliable
+ * because `populateClassOwnedMembers`
+ * (`gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts`)
+ * only dot-qualifies `qualifiedName` for `Class` scopes. Namespace-nested
+ * defs (`namespace ns { void foo(); }`) arrive in `localDefs` with
+ * `qualifiedName === 'foo'` and `ownerId === undefined`, indistinguishable
+ * from a top-level free function. The structural truth lives in
+ * `Scope.ownedDefs`: each scope lists what it structurally owns; the
+ * Module scope owns only top-level symbols. We look the def up by
+ * `nodeId` against the scope tree to identify its owning kind.
+ *
+ * ## `localDefs` consumer survey (recorded for future maintainers)
+ *
+ * Other consumers of `ParsedFile.localDefs` were audited at the time
+ * this filter was introduced (see PR #1520 / plan
+ * `docs/plans/2026-05-12-002-fix-cpp-resolver-followups-plan.md`):
+ *
+ * - `finalize-orchestrator.ts:113,163` — flattens defs into a workspace
+ * registry keyed by `ownerId` + `qualifiedName`; class-owned and
+ * namespace-owned symbols are registered under their owner, not as
+ * unqualified names. Not a leak surface.
+ * - `csharp/namespace-siblings.ts:307`, `go/expand-wildcards.ts:86`,
+ * `php/scope-resolver.ts:141,151`, `c/static-linkage.ts:51` — other
+ * languages' own wildcard / sibling expansions. Each owns its own
+ * visibility contract.
+ * - `receiver-bound-calls.ts:99`, `reconcile-ownership.ts:66,119`,
+ * `mro.ts:61` — keyed by `ownerId` for member lookup, never used
+ * as unqualified bindings.
+ * - `go/interface-impls.ts:40,53`, `go/package-siblings.ts:41` — Go-
+ * specific, sibling-package scoped.
+ *
+ * No other consumer treats `localDefs` as a flat unqualified-binding
+ * set the way this function did before the fix. If a future consumer
+ * does, mirror this filter or harden registration so class/namespace
+ * members never enter `localDefs` unqualified.
+ */
+export function expandCppWildcardNames(
+ targetModuleScope: ScopeId,
+ parsedFiles: readonly ParsedFile[],
+): readonly string[] {
+ const target = parsedFiles.find((p) => p.moduleScope === targetModuleScope);
+ if (target === undefined) return [];
+
+ // Build nodeId → owning Scope map from the structural scope tree.
+ // `Scope.ownedDefs` is the canonical source of structural ownership;
+ // `localDefs` is its flattened union, which is why the original code
+ // leaked: walking only `localDefs` discards the owning-scope context.
+ const ownerScopeByNodeId = new Map();
+ for (const scope of target.scopes) {
+ for (const ownedDef of scope.ownedDefs) {
+ ownerScopeByNodeId.set(ownedDef.nodeId, scope);
+ }
+ }
+
+ const seen = new Set();
+ const names: string[] = [];
+ for (const def of target.localDefs) {
+ // Defense-in-depth: class methods carry a non-undefined ownerId after
+ // `populateClassOwnedMembers` runs. Skip them outright.
+ if (def.ownerId !== undefined) continue;
+
+ // Structural visibility check: exclude defs whose owning scope is a
+ // Namespace or Class — these require qualification (`ns::name`,
+ // `Class::method`) and are NOT reachable by unqualified lookup in an
+ // including TU. When the owning scope is unknown we default to
+ // include (preserves prior behavior for any def whose structural
+ // ownership wasn't recorded in `Scope.ownedDefs`).
+ //
+ // Anonymous namespaces are exempt: their members propagate to the
+ // enclosing scope of any TU that #includes the declaring file (each
+ // including TU gets its own internal-linkage copy per ISO C++).
+ const ownerScope = ownerScopeByNodeId.get(def.nodeId);
+ const ownerIsAnonymousNamespace =
+ ownerScope !== undefined &&
+ ownerScope.kind === 'Namespace' &&
+ anonymousNamespaceScopeIds.has(ownerScope.id);
+ if (
+ ownerScope !== undefined &&
+ !ownerIsAnonymousNamespace &&
+ (ownerScope.kind === 'Namespace' || ownerScope.kind === 'Class')
+ ) {
+ continue;
+ }
+
+ const name = simpleName(def);
+ if (name === '') continue;
+ // Same exemption for the `isFileLocal` mark — anonymous-namespace
+ // names are recorded as file-local to suppress the global free-call
+ // fallback's cross-file leak, but they MUST still propagate through
+ // wildcard import expansion to including TUs.
+ if (!ownerIsAnonymousNamespace && isFileLocal(target.filePath, name)) continue;
+ if (seen.has(name)) continue;
+ seen.add(name);
+ names.push(name);
+ }
+ return names;
+}
+
+function simpleName(def: SymbolDefinition): string {
+ return def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? '';
+}
diff --git a/gitnexus/src/core/ingestion/languages/cpp/header-scan.ts b/gitnexus/src/core/ingestion/languages/cpp/header-scan.ts
new file mode 100644
index 000000000..39ef608b3
--- /dev/null
+++ b/gitnexus/src/core/ingestion/languages/cpp/header-scan.ts
@@ -0,0 +1,53 @@
+import { readdirSync, type Dirent } from 'fs';
+import { join, relative } from 'path';
+
+/** C++ header extensions to scan for in the workspace. */
+const HEADER_EXTENSIONS = new Set(['.h', '.hpp', '.hxx', '.hh']);
+
+/**
+ * Walk `repoPath` recursively and return relative paths of all C++ header files.
+ * Used by `loadResolutionConfig` so the C++ resolver can resolve `#include`
+ * targets that live in header files.
+ *
+ * Scans for: .h, .hpp, .hxx, .hh
+ */
+export function scanCppHeaderFiles(repoPath: string): ReadonlySet {
+ const headers = new Set();
+ walk(repoPath, repoPath, headers);
+ return headers;
+}
+
+function walk(dir: string, root: string, out: Set): void {
+ let entries: Dirent[];
+ try {
+ entries = readdirSync(dir, { withFileTypes: true, encoding: 'utf8' });
+ } catch {
+ return; // permission denied, etc.
+ }
+ for (const entry of entries) {
+ const name = entry.name;
+ const full = join(dir, name);
+ if (entry.isDirectory()) {
+ if (
+ name === 'node_modules' ||
+ name === '.git' ||
+ name === 'vendor' ||
+ name === 'dist' ||
+ name === 'build' ||
+ name === 'out' ||
+ name === 'target' ||
+ name === '_build' ||
+ name === '.next' ||
+ name.startsWith('cmake-build')
+ ) {
+ continue;
+ }
+ walk(full, root, out);
+ } else if (entry.isFile()) {
+ const ext = name.slice(name.lastIndexOf('.'));
+ if (HEADER_EXTENSIONS.has(ext)) {
+ out.add(relative(root, full).replace(/\\/g, '/'));
+ }
+ }
+ }
+}
diff --git a/gitnexus/src/core/ingestion/languages/cpp/import-decomposer.ts b/gitnexus/src/core/ingestion/languages/cpp/import-decomposer.ts
new file mode 100644
index 000000000..eb6b252ce
--- /dev/null
+++ b/gitnexus/src/core/ingestion/languages/cpp/import-decomposer.ts
@@ -0,0 +1,120 @@
+import type { Capture, CaptureMatch } from 'gitnexus-shared';
+import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
+
+/**
+ * Decompose a `preproc_include` node into a CaptureMatch with structured
+ * import captures. C++ #include maps to a wildcard import (all symbols
+ * from the header are visible). Identical to C's splitCInclude.
+ */
+export function splitCppInclude(node: SyntaxNode): CaptureMatch | null {
+ const pathNode = node.childForFieldName?.('path') ?? null;
+ if (pathNode === null) {
+ for (let i = 0; i < node.childCount; i++) {
+ const child = node.child(i);
+ if (child === null) continue;
+ if (child.type === 'string_literal' || child.type === 'system_lib_string') {
+ return buildIncludeCapture(node, child);
+ }
+ }
+ return null;
+ }
+ return buildIncludeCapture(node, pathNode);
+}
+
+function buildIncludeCapture(node: SyntaxNode, pathNode: SyntaxNode): CaptureMatch {
+ let raw: string;
+ if (pathNode.type === 'string_literal') {
+ const content = pathNode.namedChildren.find((c) => c.type === 'string_content');
+ raw = content?.text ?? pathNode.text.replace(/^"|"$/g, '');
+ } else {
+ raw = pathNode.text;
+ if (raw.startsWith('<') && raw.endsWith('>')) {
+ raw = raw.slice(1, -1);
+ }
+ }
+
+ const isSystem = pathNode.type === 'system_lib_string';
+
+ const result: Record = {
+ '@import.statement': nodeToCapture('@import.statement', node),
+ '@import.kind': syntheticCapture('@import.kind', node, 'wildcard'),
+ '@import.source': syntheticCapture('@import.source', node, raw),
+ };
+
+ if (isSystem) {
+ result['@import.system'] = syntheticCapture('@import.system', node, 'true');
+ }
+
+ return result;
+}
+
+/**
+ * Decompose a `using_declaration` node into a CaptureMatch.
+ *
+ * tree-sitter-cpp produces:
+ * using namespace std; → using_declaration { "using", "namespace", identifier("std"), ";" }
+ * using std::vector; → using_declaration { "using", qualified_identifier("std::vector"), ";" }
+ *
+ * The first form is a wildcard import (all names from namespace).
+ * The second form is a named import (single symbol).
+ */
+export function splitCppUsingDecl(node: SyntaxNode): CaptureMatch | null {
+ if (node.type !== 'using_declaration') return null;
+
+ // Check for "namespace" keyword among anonymous children
+ let hasNamespaceKeyword = false;
+ for (let i = 0; i < node.childCount; i++) {
+ const child = node.child(i);
+ if (child !== null && !child.isNamed && child.text === 'namespace') {
+ hasNamespaceKeyword = true;
+ break;
+ }
+ }
+
+ if (hasNamespaceKeyword) {
+ // using namespace ;
+ // The namespace name can be an identifier or qualified_identifier
+ let namespaceName: string | null = null;
+ for (let i = 0; i < node.namedChildCount; i++) {
+ const child = node.namedChild(i);
+ if (child === null) continue;
+ if (child.type === 'identifier' || child.type === 'qualified_identifier') {
+ namespaceName = child.text;
+ break;
+ }
+ }
+ if (namespaceName === null) return null;
+
+ return {
+ '@import.statement': nodeToCapture('@import.statement', node),
+ '@import.kind': syntheticCapture('@import.kind', node, 'wildcard'),
+ '@import.source': syntheticCapture('@import.source', node, namespaceName),
+ '@import.using-namespace': syntheticCapture('@import.using-namespace', node, 'true'),
+ };
+ }
+
+ // using ; (e.g. using std::vector)
+ let qualId: SyntaxNode | null = null;
+ for (let i = 0; i < node.namedChildCount; i++) {
+ const child = node.namedChild(i);
+ if (child !== null && child.type === 'qualified_identifier') {
+ qualId = child;
+ break;
+ }
+ }
+ if (qualId === null) return null;
+
+ // Extract the imported name (last identifier) and source (namespace part)
+ const nameNode = qualId.childForFieldName?.('name') ?? null;
+ const scopeNode = qualId.childForFieldName?.('scope') ?? null;
+
+ const importedName = nameNode?.text ?? qualId.text.split('::').pop() ?? '';
+ const source = scopeNode?.text ?? qualId.text.replace(new RegExp('::' + importedName + '$'), '');
+
+ return {
+ '@import.statement': nodeToCapture('@import.statement', node),
+ '@import.kind': syntheticCapture('@import.kind', node, 'named'),
+ '@import.source': syntheticCapture('@import.source', node, source),
+ '@import.name': syntheticCapture('@import.name', node, importedName),
+ };
+}
diff --git a/gitnexus/src/core/ingestion/languages/cpp/import-target.ts b/gitnexus/src/core/ingestion/languages/cpp/import-target.ts
new file mode 100644
index 000000000..26e317c6e
--- /dev/null
+++ b/gitnexus/src/core/ingestion/languages/cpp/import-target.ts
@@ -0,0 +1,18 @@
+import { resolveCImportTarget } from '../c/import-target.js';
+
+/**
+ * Resolve a C++ #include path to a file in the workspace.
+ * C++ #include path resolution is identical to C:
+ * 1. Same-directory sibling (relative lookup)
+ * 2. Exact match
+ * 3. Suffix match with depth + lexicographic tiebreak
+ *
+ * Re-exports the C implementation since the #include semantics are shared.
+ */
+export function resolveCppImportTarget(
+ targetRaw: string,
+ fromFile: string,
+ allFilePaths: ReadonlySet,
+): string | null {
+ return resolveCImportTarget(targetRaw, fromFile, allFilePaths);
+}
diff --git a/gitnexus/src/core/ingestion/languages/cpp/index.ts b/gitnexus/src/core/ingestion/languages/cpp/index.ts
new file mode 100644
index 000000000..c4d208d76
--- /dev/null
+++ b/gitnexus/src/core/ingestion/languages/cpp/index.ts
@@ -0,0 +1,16 @@
+/**
+ * C++ scope-resolution hooks (RFC #909 Ring 3).
+ */
+export { emitCppScopeCaptures } from './captures.js';
+export { interpretCppImport, interpretCppTypeBinding, normalizeCppTypeName } from './interpret.js';
+export { splitCppInclude, splitCppUsingDecl } from './import-decomposer.js';
+export { cppArityCompatibility } from './arity.js';
+export { cppMergeBindings } from './merge-bindings.js';
+export { cppBindingScopeFor, cppImportOwningScope, cppReceiverBinding } from './simple-hooks.js';
+export { resolveCppImportTarget } from './import-target.js';
+export {
+ markFileLocal,
+ isFileLocal,
+ clearFileLocalNames,
+ expandCppWildcardNames,
+} from './file-local-linkage.js';
diff --git a/gitnexus/src/core/ingestion/languages/cpp/inline-namespaces.ts b/gitnexus/src/core/ingestion/languages/cpp/inline-namespaces.ts
new file mode 100644
index 000000000..604c50c0b
--- /dev/null
+++ b/gitnexus/src/core/ingestion/languages/cpp/inline-namespaces.ts
@@ -0,0 +1,203 @@
+/**
+ * C++ inline namespace support (U5 of plan 2026-05-13-001).
+ *
+ * `inline namespace v1 { void foo(); }` has two ISO C++ semantics that
+ * GitNexus must model:
+ *
+ * 1. **Transitive unqualified visibility.** Names declared in an inline
+ * namespace are reachable by unqualified lookup from the enclosing
+ * namespace's scope, as if they were declared directly there.
+ * `populateCppNonGloballyVisible` (file-local-linkage.ts) treats
+ * inline-namespace members as globally visible for cross-file
+ * unqualified lookup.
+ *
+ * 2. **Transitive qualified visibility.** `outer::foo()` resolves to
+ * `outer::v1::foo()` when `v1` is inline. The qualified-namespace
+ * receiver resolver (`resolveCppQualifiedNamespaceMember`) walks
+ * inline-namespace children transitively when collecting candidates.
+ *
+ * State lifecycle: capture-time `markCppInlineNamespaceRange` records each
+ * inline namespace's source range; `populateCppInlineNamespaceScopes`
+ * resolves ranges to `ScopeId`s during `populateOwners`. Cleared via
+ * `clearCppInlineNamespaces`, called from `clearFileLocalNames`.
+ *
+ * STL idiom this enables: `std::__1::vector` (libc++) and `std::__cxx11`
+ * (libstdc++) are inline namespaces of `std`. With this support,
+ * `std::vector` qualified calls resolve to the inline-namespace
+ * declaration transparently.
+ */
+
+import type { ParsedFile, ScopeId, SymbolDefinition } from 'gitnexus-shared';
+import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
+import {
+ isOverloadAmbiguousAfterNormalization,
+ narrowOverloadCandidates,
+} from '../../scope-resolution/passes/overload-narrowing.js';
+
+interface RangeKey {
+ readonly startLine: number;
+ readonly startCol: number;
+ readonly endLine: number;
+ readonly endCol: number;
+}
+
+const inlineNamespaceRangesByFile = new Map>();
+const inlineNamespaceScopeIds = new Set();
+
+function rangeKey(r: RangeKey): string {
+ return `${r.startLine}:${r.startCol}:${r.endLine}:${r.endCol}`;
+}
+
+/** Capture-time: record a namespace_definition's range as inline.
+ * Called from `emitCppScopeCaptures` when the tree-sitter AST shows an
+ * `inline` keyword child on `namespace_definition`. */
+export function markCppInlineNamespaceRange(filePath: string, range: RangeKey): void {
+ let set = inlineNamespaceRangesByFile.get(filePath);
+ if (set === undefined) {
+ set = new Set();
+ inlineNamespaceRangesByFile.set(filePath, set);
+ }
+ set.add(rangeKey(range));
+}
+
+/** Clear all inline-namespace state. Called from `clearFileLocalNames`. */
+export function clearCppInlineNamespaces(): void {
+ inlineNamespaceRangesByFile.clear();
+ inlineNamespaceScopeIds.clear();
+}
+
+/** Resolve captured ranges to actual ScopeIds by matching scope ranges
+ * against the inline-namespace ranges recorded for this file. Run from
+ * the cpp resolver's `populateOwners` hook so the per-pipeline Set is
+ * populated before any resolution pass consults it. */
+export function populateCppInlineNamespaceScopes(parsed: ParsedFile): void {
+ const ranges = inlineNamespaceRangesByFile.get(parsed.filePath);
+ if (ranges === undefined || ranges.size === 0) return;
+ for (const scope of parsed.scopes) {
+ if (scope.kind !== 'Namespace') continue;
+ if (ranges.has(rangeKey(scope.range))) {
+ inlineNamespaceScopeIds.add(scope.id);
+ }
+ }
+}
+
+/** Predicate consumed by `populateCppNonGloballyVisible` to exempt
+ * inline-namespace members from cross-file unqualified-lookup
+ * exclusion (they remain reachable as if declared at the enclosing
+ * namespace's level). */
+export function isCppInlineNamespaceScope(scopeId: ScopeId): boolean {
+ return inlineNamespaceScopeIds.has(scopeId);
+}
+
+/**
+ * Walk every parsed file looking for a Namespace scope whose qualified
+ * name matches `receiverName`, collect its callable ownedDefs matching
+ * `memberName`, transitively descending into any inline-namespace
+ * children (since they're members of the enclosing namespace under ISO
+ * C++).
+ *
+ * Returns the most specific (innermost) match — for `outer::foo()`
+ * where `inline namespace v1` declares `foo`, returns `v1::foo`. When
+ * multiple inline-namespace children declare the same name, ISO C++
+ * leaves the call ambiguous; returns `'ambiguous'` so the caller
+ * suppresses edge emission rather than picking arbitrarily (#1564).
+ */
+export function resolveCppQualifiedNamespaceMember(
+ receiverName: string,
+ memberName: string,
+ parsedFiles: readonly ParsedFile[],
+ _scopes: ScopeResolutionIndexes,
+): SymbolDefinition | 'ambiguous' | undefined {
+ const allHits: SymbolDefinition[] = [];
+ const seenNodeId = new Set();
+ for (const parsed of parsedFiles) {
+ const scopesById = new Map();
+ for (const sc of parsed.scopes) scopesById.set(sc.id, sc);
+ for (const scope of parsed.scopes) {
+ if (scope.kind !== 'Namespace') continue;
+ const nsDef = findNamespaceDefInScope(scope);
+ if (nsDef === undefined) continue;
+ const nsName = nsDef.qualifiedName?.split('.').pop() ?? nsDef.qualifiedName ?? '';
+ if (nsName !== receiverName) continue;
+ // Found a matching namespace scope in this file. Collect ALL
+ // members transitively through any inline-namespace children.
+ const hits = findMemberInNamespaceTransitive(scope, scopesById, memberName);
+ for (const hit of hits) {
+ if (seenNodeId.has(hit.nodeId)) continue;
+ seenNodeId.add(hit.nodeId);
+ allHits.push(hit);
+ }
+ }
+ }
+ if (allHits.length === 0) return undefined;
+ if (allHits.length === 1) return allHits[0];
+
+ // Multi-candidate: the `resolveQualifiedReceiverMember` hook has no
+ // access to call-site arity or argument types, so
+ // `narrowOverloadCandidates` cannot actually narrow here — the call
+ // with `(allHits, undefined, undefined)` is effectively a pass-through.
+ // We retain it so that `isOverloadAmbiguousAfterNormalization` can
+ // still detect int/long-style normalization collisions on this path,
+ // but for any multi-hit case where candidates have genuinely distinct
+ // signatures (e.g. `foo(int)` vs `foo(double)` in different inline
+ // children), we conservatively suppress rather than pick arbitrarily.
+ // A future enhancement could thread call-site argument info through
+ // the `resolveQualifiedReceiverMember` contract to enable real
+ // narrowing here.
+ const narrowed = narrowOverloadCandidates(allHits, undefined, undefined);
+ if (narrowed.length === 1) return narrowed[0];
+ if (narrowed.length === 0) return undefined;
+ if (isOverloadAmbiguousAfterNormalization(narrowed, undefined)) return 'ambiguous';
+ // Multiple surviving candidates (distinct signatures) — conservative
+ // suppress because we lack call-site info to disambiguate.
+ return 'ambiguous';
+}
+
+/** Recursively search a namespace scope and any inline-namespace
+ * descendants for callable defs with the given simple name. Non-inline
+ * nested namespaces are NOT traversed — they require explicit
+ * qualification (`outer::nested::foo`). Returns ALL matches so the
+ * caller can detect same-name ambiguity across inline children (#1564). */
+function findMemberInNamespaceTransitive(
+ scope: {
+ readonly id: ScopeId;
+ readonly ownedDefs: readonly SymbolDefinition[];
+ readonly parent: ScopeId | null;
+ },
+ scopesById: ReadonlyMap<
+ ScopeId,
+ {
+ readonly id: ScopeId;
+ readonly kind: string;
+ readonly parent: ScopeId | null;
+ readonly ownedDefs: readonly SymbolDefinition[];
+ }
+ >,
+ memberName: string,
+): SymbolDefinition[] {
+ const results: SymbolDefinition[] = [];
+ // Check this scope's own ownedDefs first.
+ for (const def of scope.ownedDefs) {
+ if (def.type !== 'Function' && def.type !== 'Method' && def.type !== 'Constructor') continue;
+ const simple = def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? '';
+ if (simple === memberName) results.push(def);
+ }
+ // Descend into inline-namespace children.
+ for (const childScope of scopesById.values()) {
+ if (childScope.parent !== scope.id) continue;
+ if (childScope.kind !== 'Namespace') continue;
+ if (!inlineNamespaceScopeIds.has(childScope.id)) continue;
+ const childHits = findMemberInNamespaceTransitive(childScope, scopesById, memberName);
+ for (const hit of childHits) results.push(hit);
+ }
+ return results;
+}
+
+function findNamespaceDefInScope(scope: {
+ readonly ownedDefs: readonly SymbolDefinition[];
+}): SymbolDefinition | undefined {
+ for (const def of scope.ownedDefs) {
+ if (def.type === 'Namespace') return def;
+ }
+ return undefined;
+}
diff --git a/gitnexus/src/core/ingestion/languages/cpp/interpret.ts b/gitnexus/src/core/ingestion/languages/cpp/interpret.ts
new file mode 100644
index 000000000..b330a3fc0
--- /dev/null
+++ b/gitnexus/src/core/ingestion/languages/cpp/interpret.ts
@@ -0,0 +1,110 @@
+import type { CaptureMatch, ParsedImport, ParsedTypeBinding, TypeRef } from 'gitnexus-shared';
+
+/**
+ * Interpret a C++ import capture into a ParsedImport.
+ *
+ * C++ has three import forms:
+ * 1. #include "file.h" → wildcard import (all symbols from header)
+ * 2. using namespace X; → wildcard import (all symbols from namespace X)
+ * 3. using X::name; → named import (single symbol from namespace X)
+ *
+ * System headers (#include <...>) are not resolved to local files.
+ */
+export function interpretCppImport(captures: CaptureMatch): ParsedImport | null {
+ const source = captures['@import.source']?.text;
+ if (source === undefined) return null;
+
+ // System headers are not resolved to local files
+ if (captures['@import.system'] !== undefined) return null;
+
+ const kind = captures['@import.kind']?.text;
+
+ if (kind === 'named') {
+ // using X::name — named import
+ const importedName = captures['@import.name']?.text;
+ if (importedName === undefined) return null;
+ return { kind: 'named', targetRaw: source, localName: importedName, importedName };
+ }
+
+ // #include or using namespace — wildcard import
+ return { kind: 'wildcard', targetRaw: source };
+}
+
+/**
+ * Interpret a C++ type-binding capture into a ParsedTypeBinding.
+ *
+ * Source classification (strongest → weakest):
+ * - `'parameter-annotation'` — function parameter type
+ * - `'annotation'` — explicit type declaration (`User user;`)
+ * - `'assignment-inferred'` — typed init (`User user = ...`)
+ * - `'constructor'` — constructor call (`auto u = User(...)` / `User{}`)
+ * - `'return'` — function return type
+ * - `'field'` — class field type
+ * - `'alias'` — `auto x = existingVar`
+ */
+export function interpretCppTypeBinding(captures: CaptureMatch): ParsedTypeBinding | null {
+ const name = captures['@type-binding.name']?.text;
+ const type = captures['@type-binding.type']?.text;
+ if (name === undefined || type === undefined) return null;
+
+ let source: TypeRef['source'] = 'annotation';
+
+ if (captures['@type-binding.parameter'] !== undefined) {
+ source = 'parameter-annotation';
+ } else if (captures['@type-binding.constructor'] !== undefined) {
+ source = 'constructor-inferred';
+ } else if (captures['@type-binding.return'] !== undefined) {
+ source = 'return-annotation';
+ } else if (captures['@type-binding.field'] !== undefined) {
+ // Field types are structurally equivalent to annotations — the type
+ // is explicitly written, not inferred.
+ source = 'annotation';
+ } else if (captures['@type-binding.member-access'] !== undefined) {
+ // auto addr = user.address — the type is inferred from the member access.
+ // Synthesize a dotted rawName ("receiver.field") so compound-receiver
+ // can resolve the chain: look up receiver's class, then field's type.
+ const receiver = captures['@type-binding.member-access-receiver']?.text;
+ if (receiver !== undefined) {
+ return { boundName: name, rawTypeName: `${receiver}.${type}`, source: 'assignment-inferred' };
+ }
+ source = 'assignment-inferred';
+ } else if (captures['@type-binding.alias'] !== undefined) {
+ // auto alias = existingVar — the type is inferred from the RHS variable.
+ source = 'assignment-inferred';
+ } else if (captures['@type-binding.assignment'] !== undefined) {
+ source = 'assignment-inferred';
+ } else if (captures['@type-binding.annotation'] !== undefined) {
+ source = 'annotation';
+ }
+
+ return { boundName: name, rawTypeName: normalizeCppTypeName(type), source };
+}
+
+/**
+ * Normalize a C++ type name: strip pointer/array/reference syntax,
+ * qualifiers, while preserving template arguments for specialization-aware
+ * receiver binding (`List` vs `List`).
+ *
+ * Keeping template arguments here allows receiver-bound fallback to match
+ * specialization-specific class defs first; non-template behavior is preserved
+ * by base-name fallback in resolveClassBindingForName.
+ */
+export function normalizeCppTypeName(text: string): string {
+ let t = text.trim();
+ // Strip const, volatile, restrict, static, extern, inline, mutable, constexpr
+ t = t
+ .replace(/\b(const|volatile|restrict|static|extern|inline|mutable|constexpr|consteval)\b/g, '')
+ .trim();
+ // Strip pointer stars
+ while (t.endsWith('*')) t = t.slice(0, -1).trim();
+ while (t.startsWith('*')) t = t.slice(1).trim();
+ // Strip reference markers
+ while (t.endsWith('&')) t = t.slice(0, -1).trim();
+ // Strip array brackets
+ t = t.replace(/\[.*?\]/g, '').trim();
+ // Strip struct/union/enum/class prefixes
+ t = t.replace(/^(struct|union|enum|class)\s+/, '');
+ // Strip leading :: (global namespace qualifier)
+ t = t.replace(/^::/, '');
+ return t;
+}
diff --git a/gitnexus/src/core/ingestion/languages/cpp/merge-bindings.ts b/gitnexus/src/core/ingestion/languages/cpp/merge-bindings.ts
new file mode 100644
index 000000000..6409cef46
--- /dev/null
+++ b/gitnexus/src/core/ingestion/languages/cpp/merge-bindings.ts
@@ -0,0 +1,38 @@
+import type { BindingRef } from 'gitnexus-shared';
+
+const TIER: Record = {
+ local: 0,
+ namespace: 1,
+ import: 2,
+ reexport: 3,
+ wildcard: 4,
+};
+
+/**
+ * C++ merge bindings: first-wins by tier.
+ *
+ * C++ tier precedence:
+ * local(0) > namespace(1) > import(2) > reexport(3) > wildcard(4)
+ *
+ * Unlike C (no namespaces), C++ uses the `namespace` tier for symbols
+ * brought in via `using namespace X;` that are then locally referenced.
+ * The tier ordering ensures local definitions shadow namespace imports,
+ * which in turn shadow wildcard #include imports.
+ */
+export function cppMergeBindings(
+ existing: readonly BindingRef[],
+ incoming: readonly BindingRef[],
+ _scopeId: string,
+): BindingRef[] {
+ const seen = new Set();
+ return [...existing, ...incoming]
+ .sort(
+ (a, b) =>
+ (TIER[a.origin] ?? 99) - (TIER[b.origin] ?? 99) || a.def.nodeId.localeCompare(b.def.nodeId),
+ )
+ .filter((binding) => {
+ if (seen.has(binding.def.nodeId)) return false;
+ seen.add(binding.def.nodeId);
+ return true;
+ });
+}
diff --git a/gitnexus/src/core/ingestion/languages/cpp/query.ts b/gitnexus/src/core/ingestion/languages/cpp/query.ts
new file mode 100644
index 000000000..70d544e3d
--- /dev/null
+++ b/gitnexus/src/core/ingestion/languages/cpp/query.ts
@@ -0,0 +1,512 @@
+import Parser from 'tree-sitter';
+import CPP from 'tree-sitter-cpp';
+
+const CPP_SCOPE_QUERY = `
+;; ─── Scopes ──────────────────────────────────────────────────────────
+(translation_unit) @scope.module
+(namespace_definition) @scope.namespace
+(class_specifier) @scope.class
+(struct_specifier) @scope.class
+(function_definition) @scope.function
+(lambda_expression) @scope.function
+(compound_statement) @scope.block
+(if_statement) @scope.block
+(for_statement) @scope.block
+(for_range_loop) @scope.block
+(while_statement) @scope.block
+(do_statement) @scope.block
+(switch_statement) @scope.block
+(case_statement) @scope.block
+(try_statement) @scope.block
+(catch_clause) @scope.block
+
+;; ─── Declarations — namespace ────────────────────────────────────────
+(namespace_definition
+ name: (namespace_identifier) @declaration.name) @declaration.namespace
+
+;; Anonymous namespace (no name child) — captured as scope only, names
+;; inside are marked file-local by captures.ts.
+
+;; ─── Declarations — class / struct (named) ───────────────────────────
+(class_specifier
+ name: (type_identifier) @declaration.name
+ body: (field_declaration_list)) @declaration.class
+
+(class_specifier
+ name: (template_type
+ (type_identifier) @declaration.name
+ (template_argument_list) @declaration.template-arguments)
+ body: (field_declaration_list)) @declaration.class
+
+(struct_specifier
+ name: (type_identifier) @declaration.name
+ body: (field_declaration_list)) @declaration.struct
+
+(struct_specifier
+ name: (template_type
+ (type_identifier) @declaration.name
+ (template_argument_list) @declaration.template-arguments)
+ body: (field_declaration_list)) @declaration.struct
+
+;; ─── Declarations — class / struct inside template_declaration ───────
+(template_declaration
+ (class_specifier
+ name: (type_identifier) @declaration.name
+ body: (field_declaration_list)) @declaration.class)
+
+(template_declaration
+ (class_specifier
+ name: (template_type
+ (type_identifier) @declaration.name
+ (template_argument_list) @declaration.template-arguments)
+ body: (field_declaration_list)) @declaration.class)
+
+(template_declaration
+ (struct_specifier
+ name: (type_identifier) @declaration.name
+ body: (field_declaration_list)) @declaration.struct)
+
+(template_declaration
+ (struct_specifier
+ name: (template_type
+ (type_identifier) @declaration.name
+ (template_argument_list) @declaration.template-arguments)
+ body: (field_declaration_list)) @declaration.struct)
+
+;; ─── Declarations — enum ─────────────────────────────────────────────
+(enum_specifier
+ name: (type_identifier) @declaration.name) @declaration.enum
+
+;; ─── Declarations — enum constants ───────────────────────────────────
+(enumerator
+ name: (identifier) @declaration.name) @declaration.const
+
+;; ─── Declarations — function definition (plain identifier) ──────────
+(function_definition
+ declarator: (function_declarator
+ declarator: (identifier) @declaration.name)) @declaration.function
+
+;; ─── Declarations — function definition with pointer return ─────────
+(function_definition
+ declarator: (pointer_declarator
+ declarator: (function_declarator
+ declarator: (identifier) @declaration.name))) @declaration.function
+
+;; ─── Declarations — out-of-class method (qualified_identifier) ──────
+(function_definition
+ declarator: (function_declarator
+ declarator: (qualified_identifier
+ name: (identifier) @declaration.name))) @declaration.method
+
+;; ─── Declarations — out-of-class method with pointer return ─────────
+(function_definition
+ declarator: (pointer_declarator
+ declarator: (function_declarator
+ declarator: (qualified_identifier
+ name: (identifier) @declaration.name)))) @declaration.method
+
+;; ─── Declarations — out-of-class method (destructor_name) ───────────
+(function_definition
+ declarator: (function_declarator
+ declarator: (qualified_identifier
+ name: (destructor_name) @declaration.name))) @declaration.method
+
+;; ─── Declarations — template function definition ────────────────────
+(template_declaration
+ (function_definition
+ declarator: (function_declarator
+ declarator: (identifier) @declaration.name)) @declaration.function)
+
+;; ─── Declarations — template method (qualified) ─────────────────────
+(template_declaration
+ (function_definition
+ declarator: (function_declarator
+ declarator: (qualified_identifier
+ name: (identifier) @declaration.name))) @declaration.method)
+
+;; ─── Declarations — inline method in class body (field_identifier) ──
+;; tree-sitter-cpp uses field_identifier for names inside class bodies
+(function_definition
+ declarator: (function_declarator
+ declarator: (field_identifier) @declaration.name)) @declaration.method
+
+;; ─── Declarations — inline method with pointer return (field_identifier) ──
+;; Covers: User* lookup(int id) { ... } inside a class body
+;; AST: function_definition > pointer_declarator > function_declarator > field_identifier
+(function_definition
+ declarator: (pointer_declarator
+ declarator: (function_declarator
+ declarator: (field_identifier) @declaration.name))) @declaration.method
+
+;; ─── Declarations — inline method with reference return (field_identifier) ──
+;; Covers: User& getRef() { ... } inside a class body
+(function_definition
+ declarator: (reference_declarator
+ (function_declarator
+ declarator: (field_identifier) @declaration.name))) @declaration.method
+
+;; ─── Declarations — function prototype (forward declaration) ────────
+(declaration
+ declarator: (function_declarator
+ declarator: (identifier) @declaration.name)) @declaration.function
+
+;; ─── Declarations — function prototype with pointer return ──────────
+(declaration
+ declarator: (pointer_declarator
+ declarator: (function_declarator
+ declarator: (identifier) @declaration.name))) @declaration.function
+
+;; ─── Declarations — typedef ─────────────────────────────────────────
+(type_definition
+ declarator: (type_identifier) @declaration.name) @declaration.typedef
+
+;; ─── Declarations — type alias (using Name = Type) ──────────────────
+(alias_declaration
+ name: (type_identifier) @declaration.name) @declaration.typedef
+
+;; ─── Declarations — method prototype in class body (forward decl) ────
+;; Covers: class User { void save(); std::string getName(); };
+;; AST: field_declaration > function_declarator > field_identifier
+(field_declaration
+ declarator: (function_declarator
+ declarator: (field_identifier) @declaration.name)) @declaration.method
+
+;; Method prototype with pointer return: User* lookup(int id);
+(field_declaration
+ declarator: (pointer_declarator
+ declarator: (function_declarator
+ declarator: (field_identifier) @declaration.name))) @declaration.method
+
+;; Method prototype with reference return: User& getRef();
+(field_declaration
+ declarator: (reference_declarator
+ (function_declarator
+ declarator: (field_identifier) @declaration.name))) @declaration.method
+
+;; ─── Declarations — fields ──────────────────────────────────────────
+(field_declaration
+ declarator: (field_identifier) @declaration.name) @declaration.field
+
+;; Declarations — fields (pointer)
+(field_declaration
+ declarator: (pointer_declarator
+ declarator: (field_identifier) @declaration.name)) @declaration.field
+
+;; Declarations — fields (reference)
+(field_declaration
+ declarator: (reference_declarator
+ (field_identifier) @declaration.name)) @declaration.field
+
+;; ─── Declarations — variables (with initializer) ────────────────────
+(declaration
+ declarator: (init_declarator
+ declarator: (identifier) @declaration.name)) @declaration.variable
+
+;; ─── Declarations — macro definitions ───────────────────────────────
+(preproc_def
+ name: (identifier) @declaration.name) @declaration.macro
+
+(preproc_function_def
+ name: (identifier) @declaration.name) @declaration.macro
+
+;; ─── Imports — #include ─────────────────────────────────────────────
+(preproc_include) @import.statement
+
+;; ─── Imports — using declaration ─────────────────────────────────────
+;; Both "using namespace std;" and "using std::vector;" are
+;; using_declaration nodes in tree-sitter-cpp. The captures.ts
+;; differentiates between them by checking for a "namespace" anonymous
+;; child token.
+(using_declaration) @import.using-decl
+
+;; ─── Type bindings — parameter annotations ──────────────────────────
+(parameter_declaration
+ type: (_) @type-binding.type
+ declarator: (identifier) @type-binding.name) @type-binding.parameter
+
+;; Type bindings — reference parameter (const std::string& name)
+(parameter_declaration
+ type: (_) @type-binding.type
+ declarator: (reference_declarator
+ (identifier) @type-binding.name)) @type-binding.parameter
+
+;; Type bindings — pointer parameter (User* ptr)
+(parameter_declaration
+ type: (_) @type-binding.type
+ declarator: (pointer_declarator
+ declarator: (identifier) @type-binding.name)) @type-binding.parameter
+
+;; ─── Type bindings — variable with type (init_declarator) ───────────
+;; Covers: User user("alice"), User user = ..., int x = 0
+(declaration
+ type: (_) @type-binding.type
+ declarator: (init_declarator
+ declarator: (identifier) @type-binding.name)) @type-binding.assignment
+
+;; ─── Type bindings — plain declaration (no initializer) ─────────────
+;; Covers: User user;
+(declaration
+ type: (type_identifier) @type-binding.type
+ declarator: (identifier) @type-binding.name) @type-binding.annotation
+
+;; Covers: List users;
+(declaration
+ type: (template_type) @type-binding.type
+ declarator: (identifier) @type-binding.name) @type-binding.annotation
+
+;; ─── Type bindings — pointer variable declaration ───────────────────
+;; Covers: User* ptr = new User()
+(declaration
+ type: (type_identifier) @type-binding.type
+ declarator: (init_declarator
+ declarator: (pointer_declarator
+ declarator: (identifier) @type-binding.name))) @type-binding.annotation
+
+;; ─── Type bindings — auto + constructor call ────────────────────────
+;; Covers: auto user = User("alice")
+;; AST: declaration > placeholder_type_specifier/auto > init_declarator > identifier + call_expression > identifier
+(declaration
+ type: (placeholder_type_specifier)
+ declarator: (init_declarator
+ declarator: (identifier) @type-binding.name
+ value: (call_expression
+ function: (identifier) @type-binding.type))) @type-binding.constructor
+
+;; ─── Type bindings — auto + brace-init (compound_literal_expression) ─
+;; Covers: auto user = User{}, auto user = User{args}
+;; AST: declaration > placeholder_type_specifier/auto > init_declarator > identifier + compound_literal_expression > type_identifier
+(declaration
+ type: (placeholder_type_specifier)
+ declarator: (init_declarator
+ declarator: (identifier) @type-binding.name
+ value: (compound_literal_expression
+ type: (type_identifier) @type-binding.type))) @type-binding.constructor
+
+;; ─── Type bindings — auto + scoped brace-init (qualified) ───────────
+;; Covers: auto client = ns::HttpClient{}
+;; AST: compound_literal_expression > qualified_identifier > type_identifier
+(declaration
+ type: (placeholder_type_specifier)
+ declarator: (init_declarator
+ declarator: (identifier) @type-binding.name
+ value: (compound_literal_expression
+ type: (qualified_identifier
+ name: (type_identifier) @type-binding.type)))) @type-binding.constructor
+
+;; ─── Type bindings — auto + new expression ──────────────────────────
+;; Covers: auto user = new User(name)
+;; AST: declaration > placeholder_type_specifier/auto > init_declarator > identifier + new_expression > type_identifier
+(declaration
+ type: (placeholder_type_specifier)
+ declarator: (init_declarator
+ declarator: (identifier) @type-binding.name
+ value: (new_expression
+ type: (type_identifier) @type-binding.type))) @type-binding.constructor
+
+;; ─── Type bindings — auto + qualified template factory (std::make_shared()) ─
+;; AST: declaration(1 > placeholder_type_specifier(2)2 > init_declarator(3 >
+;; identifier(4)4 > call_expression(5 > qualified_identifier(6 >
+;; template_function(7 > template_argument_list(8 > type_descriptor(9 >
+;; type_identifier(10)10 )9 )8 )7 )6 )5 )3 )1
+(declaration
+ type: (placeholder_type_specifier)
+ declarator: (init_declarator
+ declarator: (identifier) @type-binding.name
+ value: (call_expression
+ function: (qualified_identifier
+ name: (template_function
+ arguments: (template_argument_list
+ (type_descriptor
+ type: (type_identifier) @type-binding.type))))))) @type-binding.constructor
+
+;; ─── Type bindings — auto + bare template factory (make_shared()) ───────
+;; Same but without qualified_identifier wrapper — one fewer nesting level
+(declaration
+ type: (placeholder_type_specifier)
+ declarator: (init_declarator
+ declarator: (identifier) @type-binding.name
+ value: (call_expression
+ function: (template_function
+ arguments: (template_argument_list
+ (type_descriptor
+ type: (type_identifier) @type-binding.type)))))) @type-binding.constructor
+
+;; ─── Type bindings — auto alias assignment ──────────────────────────
+;; Covers: auto alias = existingVar (RHS is a plain identifier)
+;; AST: declaration > placeholder_type_specifier/auto > init_declarator > identifier + identifier
+(declaration
+ type: (placeholder_type_specifier)
+ declarator: (init_declarator
+ declarator: (identifier) @type-binding.name
+ value: (identifier) @type-binding.type)) @type-binding.alias
+
+;; ─── Type bindings — auto + member access (field_expression) ────────
+;; Covers: auto addr = user.address (RHS is obj.field)
+;; AST: declaration > placeholder_type_specifier > init_declarator > identifier + field_expression
+;; We capture the field name as @type-binding.type so the compound-receiver
+;; chain resolver can look it up on the receiver class scope.
+;; The full obj.field text is synthesized by interpret.ts into a dotted
+;; rawName for chain-follow resolution.
+(declaration
+ type: (placeholder_type_specifier)
+ declarator: (init_declarator
+ declarator: (identifier) @type-binding.name
+ value: (field_expression
+ argument: (_) @type-binding.member-access-receiver
+ field: (field_identifier) @type-binding.type))) @type-binding.member-access
+
+;; ─── Type bindings — function return type ───────────────────────────
+;; Covers: User getUser() { ... }
+;; AST: function_definition > type_identifier + function_declarator > identifier
+(function_definition
+ type: (type_identifier) @type-binding.type
+ declarator: (function_declarator
+ declarator: (identifier) @type-binding.name)) @type-binding.return
+
+;; Return type — out-of-class method: User Class::getUser() { ... }
+(function_definition
+ type: (type_identifier) @type-binding.type
+ declarator: (function_declarator
+ declarator: (qualified_identifier
+ name: (identifier) @type-binding.name))) @type-binding.return
+
+;; Return type — pointer return: User* getUser() { ... }
+(function_definition
+ type: (type_identifier) @type-binding.type
+ declarator: (pointer_declarator
+ declarator: (function_declarator
+ declarator: (identifier) @type-binding.name))) @type-binding.return
+
+;; ─── Type bindings — inline method return type ──────────────────────
+;; Covers: class Foo { User getUser() { ... } };
+(function_definition
+ type: (type_identifier) @type-binding.type
+ declarator: (function_declarator
+ declarator: (field_identifier) @type-binding.name)) @type-binding.return
+
+;; Inline method pointer return type: class Foo { User* lookup(int) { ... } };
+(function_definition
+ type: (type_identifier) @type-binding.type
+ declarator: (pointer_declarator
+ declarator: (function_declarator
+ declarator: (field_identifier) @type-binding.name))) @type-binding.return
+
+;; ─── Type bindings — method prototype return type in class body ──────
+;; Covers: class User { User* lookup(int); std::string getName(); };
+;; AST: field_declaration > function_declarator > field_identifier
+(field_declaration
+ type: (type_identifier) @type-binding.type
+ declarator: (function_declarator
+ declarator: (field_identifier) @type-binding.name)) @type-binding.return
+
+;; Method prototype pointer return type: User* lookup(int id);
+(field_declaration
+ type: (type_identifier) @type-binding.type
+ declarator: (pointer_declarator
+ declarator: (function_declarator
+ declarator: (field_identifier) @type-binding.name))) @type-binding.return
+
+;; ─── Type bindings — field type declarations (class members) ────────
+;; Covers: class User { Address address; };
+(field_declaration
+ type: (type_identifier) @type-binding.type
+ declarator: (field_identifier) @type-binding.name) @type-binding.field
+
+;; Field pointer type: Address* address;
+(field_declaration
+ type: (type_identifier) @type-binding.type
+ declarator: (pointer_declarator
+ declarator: (field_identifier) @type-binding.name)) @type-binding.field
+
+;; Field reference type: Address& address;
+(field_declaration
+ type: (type_identifier) @type-binding.type
+ declarator: (reference_declarator
+ (field_identifier) @type-binding.name)) @type-binding.field
+
+;; ─── References — constructor calls (new Foo()) ─────────────────────
+(new_expression
+ type: (type_identifier) @reference.name) @reference.call.constructor
+
+;; Constructor call with qualified type: new ns::Foo()
+(new_expression
+ type: (qualified_identifier
+ name: (type_identifier) @reference.name)) @reference.call.constructor
+
+;; ─── References — free calls ────────────────────────────────────────
+(call_expression
+ function: (identifier) @reference.name) @reference.call.free
+
+;; ─── References — qualified calls (Namespace func or Class method) ───
+;; Capture the LHS of scope-resolution as the explicit receiver so
+;; qualified static member calls route through receiver-bound-calls
+;; Case 2 (class-name receiver) path. Without the receiver capture,
+;; qualified calls have no explicit receiver and class methods cannot
+;; resolve through receiver-bound paths.
+(call_expression
+ function: (qualified_identifier
+ scope: (_) @reference.receiver
+ name: (identifier) @reference.name)) @reference.call.qualified
+
+;; Nested qualified receiver: outer::v1::Base::f()
+;; tree-sitter-cpp nests this as qualified_identifier(name:
+;; qualified_identifier(scope: qualified_identifier(...), name: identifier)).
+;; Capturing the innermost receiver still gives isSuperReceiverInContext
+;; enough text to strip qualifiers/template args down to Base.
+(call_expression
+ function: (qualified_identifier
+ name: (qualified_identifier
+ scope: (_) @reference.receiver
+ name: (identifier) @reference.name))) @reference.call.qualified
+
+;; Double-nested qualified receiver: outer::v1::Base::f()
+(call_expression
+ function: (qualified_identifier
+ name: (qualified_identifier
+ name: (qualified_identifier
+ scope: (_) @reference.receiver
+ name: (identifier) @reference.name)))) @reference.call.qualified
+
+;; ─── References — member calls (obj.method() / ptr->method()) ───────
+(call_expression
+ function: (field_expression
+ argument: (_) @reference.receiver
+ field: (field_identifier) @reference.name)) @reference.call.member
+
+;; ─── References — template calls (func()) ────────────────────────
+(call_expression
+ function: (template_function
+ name: (identifier) @reference.name)) @reference.call.free
+
+;; Note: Ns::func() is parsed as qualified_identifier by tree-sitter-cpp,
+;; already captured by the qualified calls pattern above.
+
+;; ─── References — field reads ───────────────────────────────────────
+(field_expression
+ argument: (_) @reference.receiver
+ field: (field_identifier) @reference.name) @reference.read
+
+;; ─── References — field writes (assignment) ─────────────────────────
+(assignment_expression
+ left: (field_expression
+ argument: (_) @reference.receiver
+ field: (field_identifier) @reference.name)) @reference.write
+`;
+
+let _parser: Parser | null = null;
+let _query: Parser.Query | null = null;
+
+export function getCppParser(): Parser {
+ if (_parser === null) {
+ _parser = new Parser();
+ _parser.setLanguage(CPP as Parameters[0]);
+ }
+ return _parser;
+}
+
+export function getCppScopeQuery(): Parser.Query {
+ if (_query === null) {
+ _query = new Parser.Query(CPP as Parameters[0], CPP_SCOPE_QUERY);
+ }
+ return _query;
+}
diff --git a/gitnexus/src/core/ingestion/languages/cpp/range-bindings.ts b/gitnexus/src/core/ingestion/languages/cpp/range-bindings.ts
new file mode 100644
index 000000000..204d1ab21
--- /dev/null
+++ b/gitnexus/src/core/ingestion/languages/cpp/range-bindings.ts
@@ -0,0 +1,255 @@
+import type { ParsedFile, Scope, TypeRef } from 'gitnexus-shared';
+import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
+import { getCppParser } from './query.js';
+import { getTreeSitterBufferSize } from '../../constants.js';
+import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
+
+/**
+ * Populate range-for loop variable type bindings for C++.
+ *
+ * Handles three patterns:
+ * 1. `for (auto& user : users)` — simple range-for
+ * 2. `for (auto& [key, user] : userMap)` — structured binding
+ * 3. `for (auto& user : *usersPtr)` — dereference range-for
+ *
+ * Strategy: look up the range source variable's type in scope
+ * typeBindings, extract the last template argument as the element
+ * type, and inject a typeBinding for the loop variable.
+ */
+export function populateCppRangeBindings(
+ parsedFiles: readonly ParsedFile[],
+ _indexes: ScopeResolutionIndexes,
+ ctx: {
+ readonly fileContents: ReadonlyMap;
+ readonly treeCache?: { get(filePath: string): unknown };
+ },
+): void {
+ const parser = getCppParser();
+
+ for (const parsed of parsedFiles) {
+ const sourceText = ctx.fileContents.get(parsed.filePath);
+ if (sourceText === undefined) continue;
+
+ const cachedTree = ctx.treeCache?.get(parsed.filePath);
+ const tree =
+ (cachedTree as ReturnType | undefined) ??
+ parseSourceSafe(parser, sourceText, undefined, {
+ bufferSize: getTreeSitterBufferSize(sourceText),
+ });
+
+ const moduleScope = parsed.scopes.find((s) => s.kind === 'Module');
+ if (moduleScope === undefined) continue;
+
+ const scopeMap = new Map(parsed.scopes.map((s) => [s.id, s]));
+
+ // Build a map from parameter name → AST parameter_declaration node
+ // so we can extract the un-normalized template type from the AST.
+ const paramTypeMap = buildParamTemplateMap(tree.rootNode);
+
+ for (const rangeNode of tree.rootNode.descendantsOfType('for_range_loop')) {
+ // Get the declarator (loop variable)
+ const declarator = rangeNode.childForFieldName('declarator');
+ if (declarator === null) continue;
+
+ // Get the range source expression (right side of ':')
+ const right = rangeNode.childForFieldName('right');
+ if (right === null) continue;
+
+ // Determine the loop variable name(s) and whether this is a structured binding
+ const varNames = extractLoopVarNames(declarator);
+ if (varNames.length === 0) continue;
+
+ // Determine the range source variable name (handle dereference)
+ const sourceVarName = extractSourceVarName(right);
+ if (sourceVarName === null) continue;
+
+ // Look up the source variable's full template type from the AST
+ // (scope typeBindings have been normalized and lost template params)
+ const fullType = paramTypeMap.get(sourceVarName);
+ if (fullType === undefined) continue;
+
+ // Extract element type from the container type
+ const elementType = extractCppElementType(fullType);
+ if (elementType === null) continue;
+
+ // Find the enclosing function scope
+ const functionScope = findEnclosingFunctionScope(rangeNode, scopeMap);
+ const targetScope = functionScope ?? moduleScope;
+ const mutable = targetScope.typeBindings as Map;
+
+ // For structured binding [key, user], bind the last identifier to the element type
+ // For simple range-for, bind the single variable
+ const bindVar = varNames[varNames.length - 1];
+ mutable.set(bindVar, {
+ rawName: elementType,
+ declaredAtScope: targetScope.id,
+ source: 'annotation',
+ });
+ }
+ }
+}
+
+/** Minimal tree-sitter node shape needed by range-binding helpers. */
+interface TsNode {
+ readonly type: string;
+ readonly text: string;
+ readonly childCount: number;
+ child(index: number): TsNode | null;
+ descendantsOfType(type: string): readonly TsNode[];
+ childForFieldName(name: string): TsNode | null;
+}
+
+/**
+ * Build a map from parameter name → full (un-normalized) type text
+ * by walking the AST for all `parameter_declaration` nodes.
+ *
+ * This bypasses `normalizeCppTypeName` which strips template params,
+ * giving us the raw `std::vector` text needed for element-type
+ * extraction.
+ */
+function buildParamTemplateMap(rootNode: TsNode): Map {
+ const map = new Map();
+ for (const paramNode of rootNode.descendantsOfType('parameter_declaration')) {
+ const typeNode = paramNode.childForFieldName('type');
+ if (typeNode === null) continue;
+
+ // Extract the parameter name from the declarator subtree.
+ // The declarator may be: identifier, reference_declarator > identifier,
+ // or pointer_declarator > identifier.
+ const declNode = paramNode.childForFieldName('declarator');
+ if (declNode === null) continue;
+
+ const idents = declNode.descendantsOfType('identifier');
+ if (idents.length === 0) continue;
+ const paramName = idents[idents.length - 1].text;
+
+ // Use the full type node text (preserving template params)
+ map.set(paramName, typeNode.text);
+ }
+ return map;
+}
+
+/**
+ * Extract loop variable name(s) from the declarator node.
+ * Handles both simple `identifier` and `structured_binding_declarator`.
+ */
+function extractLoopVarNames(declarator: TsNode): string[] {
+ // The declarator is typically reference_declarator or pointer_declarator wrapping
+ // either an identifier or a structured_binding_declarator.
+ const structBindings = declarator.descendantsOfType('structured_binding_declarator');
+ if (structBindings.length > 0) {
+ // structured_binding_declarator contains identifiers like [key, user]
+ const idents = structBindings[0].descendantsOfType('identifier');
+ return idents.map((id) => id.text).filter((t) => t !== '_');
+ }
+
+ // Simple case: reference_declarator > identifier or just identifier
+ const idents = declarator.descendantsOfType('identifier');
+ if (idents.length > 0) {
+ return [idents[idents.length - 1].text];
+ }
+
+ return [];
+}
+
+/**
+ * Extract the source variable name from the range expression.
+ * Handles plain identifiers and dereference expressions (*ptr).
+ */
+function extractSourceVarName(right: TsNode): string | null {
+ if (right.type === 'identifier') {
+ return right.text;
+ }
+ if (right.type === 'pointer_expression') {
+ // *usersPtr → get the argument (usersPtr)
+ const arg = right.childForFieldName('argument');
+ if (arg !== null) return arg.text;
+ }
+ return null;
+}
+
+/**
+ * Extract the element type from a C++ container type string.
+ *
+ * Examples:
+ * - `vector` → `User`
+ * - `std::vector` → `User`
+ * - `map` → `User` (last template arg)
+ * - `map` → `User`
+ *
+ * For structured bindings with maps, the last template arg is the value type.
+ * For vectors/sets, the first (and only) template arg is the element type.
+ */
+function extractCppElementType(rawType: string): string | null {
+ // Find the outermost template argument list
+ const ltIdx = rawType.indexOf('<');
+ if (ltIdx === -1) return null;
+
+ // Extract the template argument string (handle nested templates)
+ let depth = 0;
+ let lastCommaOrStart = ltIdx + 1;
+ let lastArg = '';
+
+ for (let i = ltIdx; i < rawType.length; i++) {
+ const ch = rawType[i];
+ if (ch === '<') {
+ depth++;
+ } else if (ch === '>') {
+ depth--;
+ if (depth === 0) {
+ lastArg = rawType.slice(lastCommaOrStart, i).trim();
+ break;
+ }
+ } else if (ch === ',' && depth === 1) {
+ lastCommaOrStart = i + 1;
+ }
+ }
+
+ if (lastArg === '') return null;
+
+ // Strip pointer/reference qualifiers and const
+ let elementType = lastArg
+ .replace(/^const\s+/, '')
+ .replace(/\s*[*&]+\s*$/, '')
+ .trim();
+
+ // Strip namespace prefix (std::string → string)
+ const lastColon = elementType.lastIndexOf('::');
+ if (lastColon !== -1) {
+ elementType = elementType.slice(lastColon + 2);
+ }
+
+ return elementType || null;
+}
+
+/**
+ * Find the enclosing Function scope for a tree-sitter node by
+ * walking up the AST and matching source positions.
+ */
+function findEnclosingFunctionScope(
+ node: unknown,
+ scopeMap: ReadonlyMap,
+): Scope | null {
+ const tsNode = node as {
+ readonly parent: unknown;
+ readonly type: string;
+ readonly startPosition: { readonly row: number; readonly column: number };
+ };
+ let current: typeof tsNode | null = tsNode;
+ while (current !== null) {
+ if (current.type === 'function_definition') {
+ for (const scope of scopeMap.values()) {
+ if (
+ scope.kind === 'Function' &&
+ scope.range.startLine === current.startPosition.row &&
+ scope.range.startCol === current.startPosition.column
+ ) {
+ return scope;
+ }
+ }
+ break;
+ }
+ current = (current.parent as typeof tsNode) ?? null;
+ }
+ return null;
+}
diff --git a/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts
new file mode 100644
index 000000000..52c575cf4
--- /dev/null
+++ b/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts
@@ -0,0 +1,268 @@
+import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared';
+import {
+ findClassBindingInScope,
+ findEnclosingClassDef,
+} from '../../scope-resolution/scope/walkers.js';
+import { SupportedLanguages } from 'gitnexus-shared';
+import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js';
+import { populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js';
+import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js';
+import { cppProvider } from '../c-cpp.js';
+import { cppArityCompatibility } from './arity.js';
+import { cppMergeBindings } from './merge-bindings.js';
+import { resolveCppImportTarget } from './import-target.js';
+import { scanCppHeaderFiles } from './header-scan.js';
+import {
+ expandCppWildcardNames,
+ isFileLocal,
+ clearFileLocalNames,
+ populateCppAnonymousNamespaceScopes,
+ populateCppNonGloballyVisible,
+ isCppDefGloballyVisible,
+} from './file-local-linkage.js';
+import {
+ populateCppDependentBases,
+ clearCppDependentBases,
+ isCppDependentBaseMember,
+} from './two-phase-lookup.js';
+import { populateCppAssociatedNamespaces, clearCppAdlState, pickCppAdlCandidates } from './adl.js';
+import {
+ clearCppInlineNamespaces,
+ populateCppInlineNamespaceScopes,
+ resolveCppQualifiedNamespaceMember,
+} from './inline-namespaces.js';
+import { populateCppRangeBindings } from './range-bindings.js';
+
+/**
+ * C++ `ScopeResolver` registered in `SCOPE_RESOLVERS` and consumed by
+ * the generic `runScopeResolution` orchestrator (RFC #909 Ring 3).
+ *
+ * C++ extends C's scope resolution with:
+ * - Namespaces (`namespace foo { ... }`)
+ * - Classes with methods and multiple inheritance
+ * - `using namespace` (wildcard import from namespace)
+ * - `using X::name` (named import from namespace)
+ * - Anonymous namespace (file-local linkage, like C `static`)
+ * - Default parameters (requiredParameterCount < parameterCount)
+ * - Overloading (arity-based disambiguation)
+ * - Templates (V1: generic-ignored, `List` ≡ `List`)
+ * - Leftmost-base MRO for multiple inheritance
+ */
+export const cppScopeResolver: ScopeResolver = {
+ language: SupportedLanguages.CPlusPlus,
+ languageProvider: cppProvider,
+ importEdgeReason: 'cpp-scope: include',
+
+ loadResolutionConfig: (repoPath: string) => {
+ // Clear stale per-pipeline state from any previous invocation.
+ clearFileLocalNames();
+ clearCppDependentBases();
+ clearCppAdlState();
+ clearCppInlineNamespaces();
+ return scanCppHeaderFiles(repoPath);
+ },
+
+ resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig) => {
+ // Augment allFilePaths with header files discovered via loadResolutionConfig.
+ // C++ .h/.hpp/.hxx/.hh files may be classified differently by language
+ // detection but are importable from .cpp files via #include.
+ const headerPaths = resolutionConfig as ReadonlySet | undefined;
+ if (headerPaths !== undefined && headerPaths.size > 0) {
+ const augmented = new Set(allFilePaths);
+ for (const h of headerPaths) augmented.add(h);
+ return resolveCppImportTarget(targetRaw, fromFile, augmented);
+ }
+ return resolveCppImportTarget(targetRaw, fromFile, allFilePaths);
+ },
+
+ expandsWildcardTo: (targetModuleScope, parsedFiles) =>
+ expandCppWildcardNames(targetModuleScope, parsedFiles),
+
+ mergeBindings: (existing, incoming, scopeId) => cppMergeBindings(existing, incoming, scopeId),
+
+ // Adapter: cppArityCompatibility predates ScopeResolver and uses
+ // (def, callsite). ScopeResolver contract is (callsite, def).
+ arityCompatibility: (callsite, def) => cppArityCompatibility(def, callsite),
+
+ buildMro: (graph, parsedFiles, nodeLookup) =>
+ buildMro(graph, parsedFiles, nodeLookup, defaultLinearize),
+
+ populateOwners: (parsed: ParsedFile) => {
+ populateClassOwnedMembers(parsed);
+ // Resolve inline- and anonymous-namespace ranges (recorded at capture
+ // time) to ScopeIds BEFORE `populateCppNonGloballyVisible` runs, so
+ // both exemptions see the populated Sets.
+ populateCppInlineNamespaceScopes(parsed);
+ populateCppAnonymousNamespaceScopes(parsed);
+ // Track namespace-nested and class-nested defs so the global free-call
+ // fallback and wildcard expansion can suppress them as unqualified
+ // cross-file callables.
+ populateCppNonGloballyVisible(parsed);
+ // Build the class-def → enclosing-namespace-qualified-name map used
+ // by ADL (U2 of plan 2026-05-13-001) to identify each argument type's
+ // associated namespace for Koenig lookup.
+ populateCppAssociatedNamespaces(parsed);
+ },
+
+ // Resolve recorded template-class → dependent-base simple names to
+ // class nodeIds for two-phase template lookup (U3 of plan
+ // 2026-05-13-001). Runs AFTER all files have had `populateOwners`
+ // applied so that cross-file base classes (e.g. Base in base.h,
+ // Derived in derived.h) are reachable in the workspace index.
+ populateWorkspaceOwners: (parsedFiles: readonly ParsedFile[]) => {
+ populateCppDependentBases(parsedFiles);
+ },
+
+ // Simple `isSuperReceiver` returns false for C++. Real super
+ // classification is caller-context-dependent and lives in
+ // `isSuperReceiverInContext` below — without scope context the
+ // previous regex `/^[A-Z]\w*::/` misclassified namespace-qualified
+ // calls (e.g., `Singleton::getInstance()`) as super calls and routed
+ // them through the wrong resolution branch.
+ isSuperReceiver: () => false,
+
+ isSuperReceiverInContext: (text, callerScope, scopes) => {
+ // The receiver text comes from the LHS of `::` in `qualified_identifier`
+ // (e.g., for `Base::method()`, text is `Base`). Strip template
+ // arguments (V1: name-only matching, generics ignored) and any leading
+ // namespace qualifier so the lookup matches the bare class def's
+ // simple name. `Base::method()` → `Base`; `outer::v1::Base` →
+ // `Base`. This handles the Phase 5 cross-unit composition where
+ // qualified base-method calls appear inside template bodies.
+ let lhs = text;
+ const sepIdx = lhs.indexOf('::');
+ if (sepIdx > 0) lhs = lhs.slice(0, sepIdx).trim();
+ // Strip trailing template-argument list (greedy: drop everything from
+ // the first `<` onward — V1 ignores generics).
+ const lt = lhs.indexOf('<');
+ if (lt > 0) lhs = lhs.slice(0, lt).trim();
+ // Strip nested namespace prefix from the receiver text itself (the
+ // `outer::v1::Base` shape that appears in derived-list `base_class_clause`).
+ const lastDoubleColon = lhs.lastIndexOf('::');
+ if (lastDoubleColon >= 0) lhs = lhs.slice(lastDoubleColon + 2).trim();
+ if (lhs.length === 0) return false;
+
+ // Resolve the LHS in the caller's scope chain. Only class-like
+ // resolutions can be super receivers; Namespace and unresolved
+ // names are not super calls.
+ const lhsDef = findClassBindingInScope(callerScope, lhs, scopes);
+ if (lhsDef === undefined) return false;
+
+ // The caller must have an enclosing class — super calls only make
+ // sense inside a class body. Free functions can use `ClassName::`
+ // for namespace-qualified calls but those are not super.
+ const enclosing = findEnclosingClassDef(callerScope, scopes);
+ if (enclosing === undefined) return false;
+
+ // `lhsDef` must be in the caller's MRO (i.e., the caller's enclosing
+ // class derives from it). The class itself counts as its own MRO
+ // root — `Self::method()` is a qualified self-call, not a super
+ // call, so exclude the caller's own class.
+ if (lhsDef.nodeId === enclosing.nodeId) return false;
+ const mro = scopes.methodDispatch.mroFor(enclosing.nodeId);
+ return mro.includes(lhsDef.nodeId);
+ },
+
+ // C++ is statically typed — disable field fallback heuristic
+ fieldFallbackOnMethodLookup: false,
+ // C++ needs return type propagation across #include boundaries
+ propagatesReturnTypesAcrossImports: true,
+ // C++ #include brings in all symbols — enable global free call fallback
+ allowGlobalFreeCallFallback: true,
+ // Range-for element type inference: for (auto& user : users) → bind user to User
+ populateRangeBindings: populateCppRangeBindings,
+ // C++ method return-type bindings need to be visible from module scope
+ // for cross-file propagation and compound-receiver chain resolution.
+ // cppBindingScopeFor hoists @type-binding.return to Module scope.
+ hoistTypeBindingsToModule: true,
+ // Enable receiver-bound explicit-`this` fallback only for C++.
+ resolveThisViaEnclosingClass: true,
+ // The `isFileLocalDef` hook on the global free-call fallback names
+ // file-local linkage historically, but semantically gates "logically
+ // invisible cross-file" defs. C++ extends this to also reject class-
+ // owned methods/fields and namespace-nested symbols — an unqualified
+ // call from a free function MUST NOT resolve to `User::save` or
+ // `ns::foo` (Cppreference, "Unqualified name lookup"). Without this
+ // gate, the global fallback walks every callable in the workspace
+ // registry and matches any class method or namespace function by
+ // simple name.
+ isFileLocalDef: (def: SymbolDefinition) => {
+ const simple = def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? '';
+ if (isFileLocal(def.filePath, simple)) return true;
+ // Class-owned (Method/Field) — `populateClassOwnedMembers` already
+ // stamps `ownerId`; cheap fast-path before consulting the scope map.
+ if (def.ownerId !== undefined) return true;
+ // Namespace-nested defs — require qualification cross-file. Scope-
+ // walked at `populateOwners` time into a per-file nodeId set.
+ if (!isCppDefGloballyVisible(def.filePath, def.nodeId)) return true;
+ return false;
+ },
+
+ // C++ two-phase template lookup: inside a class template body,
+ // unqualified calls MUST NOT bind to members of a dependent base
+ // class. The standard requires `this->name()` or `Base::name()`
+ // forms to make the lookup dependent. Without this gate the global
+ // free-call fallback walks the workspace registry and silently binds
+ // unqualified calls to dependent-base members, producing CALLS edges
+ // the compiler would reject. See plan 2026-05-13-001 U3.
+ isCallableVisibleFromCaller: ({ candidate, callerScope, scopes }) => {
+ if (callerScope === undefined || scopes === undefined) return true;
+ // Reject when the candidate is a member of a dependent base of the
+ // caller's enclosing template class. Otherwise allow.
+ return !isCppDependentBaseMember(callerScope, candidate, scopes);
+ },
+
+ // C++ argument-dependent / Koenig lookup (U2 of plan 2026-05-13-001).
+ // Contributes candidates from associated namespaces of class-typed
+ // arguments; caller merges with ordinary unqualified lookup candidates.
+ // Current boundary: class-typed value/pointer/reference args and template
+ // specializations with explicit type arguments contribute associated
+ // namespaces. Function-pointer args and full conversion-ranking remain
+ // excluded.
+ resolveAdlCandidates: (site, callerParsed, scopes, parsedFiles) => {
+ // `using ns::name;` introduces `name` into ordinary unqualified lookup.
+ // For template-class method bodies, lexical scope walks can miss this
+ // named-using visibility; recover by resolving the imported namespace
+ // member directly when the local call name matches a named using import.
+ const usingNamedHits: SymbolDefinition[] = [];
+ const seenUsing = new Set();
+ for (const imp of callerParsed.parsedImports) {
+ if (imp.kind !== 'named') continue;
+ if (imp.localName !== site.name) continue;
+ const member = resolveCppQualifiedNamespaceMember(
+ imp.targetRaw,
+ imp.importedName,
+ parsedFiles,
+ scopes,
+ );
+ if (member === undefined || member === 'ambiguous') continue;
+ if (seenUsing.has(member.nodeId)) continue;
+ seenUsing.add(member.nodeId);
+ usingNamedHits.push(member);
+ }
+ const adlHits = pickCppAdlCandidates(site, callerParsed, scopes, parsedFiles);
+ if (usingNamedHits.length === 0) return adlHits;
+ if (adlHits === undefined || adlHits.length === 0) return usingNamedHits;
+ const merged: SymbolDefinition[] = [];
+ const seen = new Set();
+ for (const hit of usingNamedHits) {
+ seen.add(hit.nodeId);
+ merged.push(hit);
+ }
+ for (const hit of adlHits) {
+ if (seen.has(hit.nodeId)) continue;
+ seen.add(hit.nodeId);
+ merged.push(hit);
+ }
+ return merged;
+ },
+
+ // C++ qualified namespace-member resolution (U5 of plan 2026-05-13-001).
+ // Handles `outer::foo()` where `outer` is a namespace (not a class).
+ // Walks each parsed file's namespace scopes by simple name, then
+ // descends transitively through inline-namespace children when
+ // searching for the called member. Returns undefined for non-namespace
+ // receivers so receiver-bound-calls Case 2 still gets a chance.
+ resolveQualifiedReceiverMember: (receiverName, memberName, _callerScope, scopes, parsedFiles) =>
+ resolveCppQualifiedNamespaceMember(receiverName, memberName, parsedFiles, scopes),
+};
diff --git a/gitnexus/src/core/ingestion/languages/cpp/simple-hooks.ts b/gitnexus/src/core/ingestion/languages/cpp/simple-hooks.ts
new file mode 100644
index 000000000..63500abd5
--- /dev/null
+++ b/gitnexus/src/core/ingestion/languages/cpp/simple-hooks.ts
@@ -0,0 +1,79 @@
+import type {
+ CaptureMatch,
+ ParsedImport,
+ Scope,
+ ScopeId,
+ ScopeTree,
+ TypeRef,
+} from 'gitnexus-shared';
+
+/**
+ * C++ binding scope: default auto-hoist (null) for most declarations.
+ *
+ * For `for` statement init-scope variables (e.g. `for (int i = 0; ...)`),
+ * the variable is scoped to the for-block, not the enclosing function.
+ * The tree-sitter scope query already captures for_statement as @scope.block,
+ * so tree-sitter's scope nesting handles this automatically — we return null
+ * to let the default auto-hoist apply.
+ */
+export function cppBindingScopeFor(
+ decl: CaptureMatch,
+ innermost: Scope,
+ tree: ScopeTree,
+): ScopeId | null {
+ // Hoist return-type bindings to Module scope so:
+ // 1. propagateImportedReturnTypes can mirror them across files
+ // 2. compound-receiver can find method return types via hoistTypeBindingsToModule
+ if (decl['@type-binding.return'] !== undefined) {
+ let cur: Scope | undefined = innermost;
+ while (cur !== undefined && cur.kind !== 'Module') {
+ const parentId: ScopeId | null = cur.parent ?? null;
+ if (parentId === null) break;
+ cur = tree.getScope(parentId);
+ }
+ if (cur !== undefined && cur.kind === 'Module') return cur.id;
+ }
+ return null; // default auto-hoist for other bindings
+}
+
+/**
+ * C++ import owning scope: default (null).
+ * #include and using declarations are file-scoped in C++.
+ */
+export function cppImportOwningScope(
+ _imp: ParsedImport,
+ _innermost: Scope,
+ _tree: ScopeTree,
+): ScopeId | null {
+ return null;
+}
+
+/**
+ * C++ receiver binding: return `this` TypeRef for methods inside a class.
+ *
+ * When a function scope is inside a class scope, the implicit `this` pointer
+ * refers to the enclosing class. This enables `this->method()` and implicit
+ * `this` member access resolution.
+ */
+export function cppReceiverBinding(functionScope: Scope): TypeRef | null {
+ // Walk up the scope tree to find an enclosing class scope
+ if (functionScope.parent === null) return null;
+
+ // The scope tree structure nests function scopes inside class scopes.
+ // The orchestrator provides the function scope; we need to check if
+ // its parent chain contains a class scope.
+ //
+ // However, the ScopeResolver.receiverBinding contract receives only
+ // the function Scope (not the full ScopeTree), and the Scope type
+ // includes `parent` (a ScopeId) but not a reference to the parent
+ // Scope object.
+ //
+ // The orchestrator already handles this by looking up the class owner
+ // via populateOwners. We return null here and let the shared infra
+ // handle receiver resolution through the class-ownership mechanism.
+ //
+ // This is consistent with how C# and Go handle it — the receiver
+ // binding is established through populateOwners + the MRO chain,
+ // not through this hook.
+ return null;
+}
diff --git a/gitnexus/src/core/ingestion/languages/cpp/two-phase-lookup.ts b/gitnexus/src/core/ingestion/languages/cpp/two-phase-lookup.ts
new file mode 100644
index 000000000..8d0050eb1
--- /dev/null
+++ b/gitnexus/src/core/ingestion/languages/cpp/two-phase-lookup.ts
@@ -0,0 +1,205 @@
+/**
+ * C++ two-phase template lookup support.
+ *
+ * Inside a class template body, names from a dependent base class are NOT
+ * found by ordinary unqualified lookup. The standard requires the
+ * `this->name` or `Base::name` forms to make the lookup dependent.
+ * GitNexus's global free-call fallback otherwise binds such names to the
+ * dependent base's members, producing CALLS edges the compiler would
+ * reject.
+ *
+ * This module records — during `emitCppScopeCaptures` — which template
+ * class declarations have which dependent base class names (per file).
+ * `populateCppDependentBases` then resolves those names to class nodeIds
+ * using a workspace-wide registry, building the per-class set the
+ * `isCppDependentBaseMember` predicate consumes.
+ *
+ * Cross-file resolution: `Base` may be declared in a different header
+ * than `Derived`. `populateCppDependentBases` therefore runs as a
+ * workspace-wide pass (`populateWorkspaceOwners` hook) after every file
+ * has had `populateOwners` applied, so all class defs are reachable.
+ *
+ * Namespace disambiguation: when multiple classes share a simple name
+ * (e.g., `Box` in two namespaces), the resolver prefers the candidate
+ * whose qualified-name prefix (namespace path) matches the deriving
+ * class's prefix. If no namespace match is found, a unique simple-name
+ * match is accepted; ambiguous matches (multiple candidates, no
+ * namespace winner) are skipped conservatively.
+ *
+ * NOTE: module-level state, single-process-single-repo use only.
+ * `clearFileLocalNames()` clears this state alongside file-local linkage
+ * (see `file-local-linkage.ts`).
+ */
+
+import type { ParsedFile, ScopeId, SymbolDefinition } from 'gitnexus-shared';
+import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
+import { findEnclosingClassDef } from '../../scope-resolution/scope/walkers.js';
+
+/**
+ * Capture-time record: for each template class declaration in a file,
+ * the simple names of its dependent base classes.
+ *
+ * Key: filePath
+ * Value: Map>
+ */
+const dependentBasesByFile = new Map>>();
+
+/**
+ * Post-`populateOwners` resolution: per-class-nodeId, the set of
+ * dependent-base-class nodeIds. Built by `populateCppDependentBases`
+ * from `dependentBasesByFile` + the workspace registry.
+ */
+const dependentBaseNodeIds = new Map>();
+
+/**
+ * Record a dependent-base relationship discovered during scope-capture
+ * emission. `className` is the simple name of the template class;
+ * `baseName` is the simple name of the dependent base class.
+ *
+ * The capture-time recorder uses simple names because the registry
+ * resolution that maps names → nodeIds runs later (in
+ * `populateCppDependentBases`).
+ */
+export function markCppDependentBase(filePath: string, className: string, baseName: string): void {
+ let perFile = dependentBasesByFile.get(filePath);
+ if (perFile === undefined) {
+ perFile = new Map();
+ dependentBasesByFile.set(filePath, perFile);
+ }
+ let bases = perFile.get(className);
+ if (bases === undefined) {
+ bases = new Set();
+ perFile.set(className, bases);
+ }
+ bases.add(baseName);
+}
+
+/** Clear two-phase-lookup state. Called from `clearFileLocalNames`. */
+export function clearCppDependentBases(): void {
+ dependentBasesByFile.clear();
+ dependentBaseNodeIds.clear();
+}
+
+/**
+ * Resolve recorded dependent-base simple names to class nodeIds using a
+ * workspace-wide index. Run as `populateWorkspaceOwners` after every
+ * file has had `populateOwners` applied, so class defs from ALL files
+ * are reachable.
+ *
+ * Disambiguation strategy (multiple classes sharing a simple name):
+ * 1. Prefer the candidate whose qualified-name namespace prefix matches
+ * the deriving class's namespace prefix (same-namespace bias).
+ * 2. Fall back to accepting a unique simple-name match.
+ * 3. Skip when multiple candidates exist and no namespace match is
+ * found (conservative: avoids false associations).
+ */
+export function populateCppDependentBases(parsedFiles: readonly ParsedFile[]): void {
+ if (dependentBasesByFile.size === 0) return;
+
+ // Build workspace-wide index: simpleName → {nodeId, nsPrefix}[]
+ // nsPrefix is the dot-joined namespace path (qualifiedName without the
+ // last segment). Classes at global scope have nsPrefix = ''.
+ const classesBySimpleName = new Map();
+ for (const parsed of parsedFiles) {
+ for (const def of parsed.localDefs) {
+ if (def.type !== 'Class' && def.type !== 'Struct' && def.type !== 'Interface') continue;
+ const qn = def.qualifiedName ?? '';
+ const lastDot = qn.lastIndexOf('.');
+ const simple = lastDot >= 0 ? qn.slice(lastDot + 1) : qn;
+ if (simple === '') continue;
+ const nsPrefix = lastDot >= 0 ? qn.slice(0, lastDot) : '';
+ let entries = classesBySimpleName.get(simple);
+ if (entries === undefined) {
+ entries = [];
+ classesBySimpleName.set(simple, entries);
+ }
+ entries.push({ nodeId: def.nodeId, nsPrefix });
+ }
+ }
+
+ // Build a filePath → ParsedFile lookup for fast per-file access.
+ const parsedByFile = new Map();
+ for (const parsed of parsedFiles) parsedByFile.set(parsed.filePath, parsed);
+
+ for (const [filePath, perFile] of dependentBasesByFile) {
+ const parsed = parsedByFile.get(filePath);
+ if (parsed === undefined) continue;
+
+ // Build a simple-name → {nodeId, nsPrefix} map for THIS file's
+ // class-like defs so we can identify each template class precisely
+ // (avoids cross-file name collisions for the deriving class itself).
+ const localClassByName = new Map();
+ for (const def of parsed.localDefs) {
+ if (def.type !== 'Class' && def.type !== 'Struct' && def.type !== 'Interface') continue;
+ const qn = def.qualifiedName ?? '';
+ const lastDot = qn.lastIndexOf('.');
+ const simple = lastDot >= 0 ? qn.slice(lastDot + 1) : qn;
+ if (simple === '') continue;
+ const nsPrefix = lastDot >= 0 ? qn.slice(0, lastDot) : '';
+ localClassByName.set(simple, { nodeId: def.nodeId, nsPrefix });
+ }
+
+ for (const [className, baseNames] of perFile) {
+ const classEntry = localClassByName.get(className);
+ if (classEntry === undefined) continue;
+
+ let bases = dependentBaseNodeIds.get(classEntry.nodeId);
+ if (bases === undefined) {
+ bases = new Set();
+ dependentBaseNodeIds.set(classEntry.nodeId, bases);
+ }
+
+ for (const baseName of baseNames) {
+ const candidates = classesBySimpleName.get(baseName);
+ if (candidates === undefined || candidates.length === 0) continue;
+
+ if (candidates.length === 1) {
+ // Unique simple-name match — accept regardless of namespace.
+ bases.add(candidates[0].nodeId);
+ continue;
+ }
+
+ // Multiple classes share the same simple name — prefer the one
+ // whose namespace matches the deriving class's namespace.
+ // V1: exact dot-prefix match only. Cross-namespace inheritance
+ // (e.g., `ns::outer::Derived` extending bare `Inner` defined in
+ // `ns::outer::inner`) and inline-namespace cases are deferred to
+ // V2; the conservative skip-on-ambiguity below avoids false
+ // associations in those edge cases.
+ const nsMatch = candidates.find((c) => c.nsPrefix === classEntry.nsPrefix);
+ if (nsMatch !== undefined) {
+ bases.add(nsMatch.nodeId);
+ }
+ // else: ambiguous (multiple candidates, no namespace match) → skip.
+ }
+ }
+ }
+}
+
+/**
+ * Two-phase lookup predicate: is the candidate def a member of a
+ * dependent base of the caller's enclosing template class?
+ *
+ * Used as an additional reject-filter in `pickUniqueGlobalCallable` and
+ * the receiver-bound member chain walk. ONLY apply for unqualified
+ * call forms — `this->name` and `Base::name` are dependent lookup
+ * forms that the standard allows.
+ *
+ * Conservative bias: when the caller's enclosing class can't be
+ * identified, return `false` (let normal resolution proceed). Over-
+ * rejection is acceptable for the template case because the standard
+ * itself requires `this->` or qualified forms for dependent base
+ * access; missing edges here match the compiler's diagnostic shape.
+ */
+export function isCppDependentBaseMember(
+ callerScopeId: ScopeId,
+ candidateDef: SymbolDefinition,
+ scopes: ScopeResolutionIndexes,
+): boolean {
+ if (candidateDef.ownerId === undefined) return false;
+ const enclosing = findEnclosingClassDef(callerScopeId, scopes);
+ if (enclosing === undefined) return false;
+ const bases = dependentBaseNodeIds.get(enclosing.nodeId);
+ if (bases === undefined) return false;
+ return bases.has(candidateDef.ownerId);
+}
diff --git a/gitnexus/src/core/ingestion/languages/csharp/captures.ts b/gitnexus/src/core/ingestion/languages/csharp/captures.ts
index e29552e78..a090b82f8 100644
--- a/gitnexus/src/core/ingestion/languages/csharp/captures.ts
+++ b/gitnexus/src/core/ingestion/languages/csharp/captures.ts
@@ -24,6 +24,7 @@ import { synthesizeCsharpReceiverBinding } from './receiver-binding.js';
import { getCsharpParser, getCsharpScopeQuery } from './query.js';
import { recordCacheHit, recordCacheMiss } from './cache-stats.js';
import { getTreeSitterBufferSize } from '../../constants.js';
+import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
/** Declaration anchors that carry function-like arity metadata. */
const FUNCTION_DECL_TAGS = [
@@ -86,7 +87,7 @@ export function emitCsharpScopeCaptures(
// the LanguageProvider contract layer; cast here at the use site.
let tree = cachedTree as ReturnType['parse']> | undefined;
if (tree === undefined) {
- tree = getCsharpParser().parse(sourceText, undefined, {
+ tree = parseSourceSafe(getCsharpParser(), sourceText, undefined, {
bufferSize: getTreeSitterBufferSize(sourceText),
});
recordCacheMiss();
diff --git a/gitnexus/src/core/ingestion/languages/csharp/namespace-siblings.ts b/gitnexus/src/core/ingestion/languages/csharp/namespace-siblings.ts
index 2cd2cf724..b6de589e2 100644
--- a/gitnexus/src/core/ingestion/languages/csharp/namespace-siblings.ts
+++ b/gitnexus/src/core/ingestion/languages/csharp/namespace-siblings.ts
@@ -36,6 +36,7 @@ import type { BindingRef, ParsedFile, Scope, ScopeId, SymbolDefinition } from 'g
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
import { getCsharpParser } from './query.js';
import { getTreeSitterBufferSize } from '../../constants.js';
+import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
interface CsharpFileStructure {
/** Declared namespace names in file source order. Empty array means
@@ -56,7 +57,7 @@ function extractFileStructure(content: string, cachedTree: unknown): CsharpFileS
type CsharpTree = ReturnType['parse']>;
const tree =
(cachedTree as CsharpTree | undefined) ??
- getCsharpParser().parse(content, undefined, {
+ parseSourceSafe(getCsharpParser(), content, undefined, {
bufferSize: getTreeSitterBufferSize(content),
});
const namespaces: string[] = [];
@@ -359,7 +360,7 @@ export function populateCsharpNamespaceSiblings(
const q = def.qualifiedName ?? '';
const key = q.includes('.') ? q.slice(q.lastIndexOf('.') + 1) : q;
if (key === '') continue;
- const arr = defsByName.get(key) ?? [];
+ const arr = [...(defsByName.get(key) ?? [])];
arr.push(def);
defsByName.set(key, arr);
}
diff --git a/gitnexus/src/core/ingestion/languages/go/captures.ts b/gitnexus/src/core/ingestion/languages/go/captures.ts
index 93fcbc51a..73fa61862 100644
--- a/gitnexus/src/core/ingestion/languages/go/captures.ts
+++ b/gitnexus/src/core/ingestion/languages/go/captures.ts
@@ -12,6 +12,7 @@ import { splitGoImportStatement } from './import-decomposer.js';
import { synthesizeGoReceiverBinding } from './receiver-binding.js';
import { synthesizeGoTypeBindings } from './type-binding.js';
import { getTreeSitterBufferSize } from '../../constants.js';
+import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
export function emitGoScopeCaptures(
sourceText: string,
@@ -20,7 +21,7 @@ export function emitGoScopeCaptures(
): readonly CaptureMatch[] {
let tree = cachedTree as ReturnType['parse']> | undefined;
if (tree === undefined) {
- tree = getGoParser().parse(sourceText, undefined, {
+ tree = parseSourceSafe(getGoParser(), sourceText, undefined, {
bufferSize: getTreeSitterBufferSize(sourceText),
});
recordGoCacheMiss();
diff --git a/gitnexus/src/core/ingestion/languages/go/range-binding.ts b/gitnexus/src/core/ingestion/languages/go/range-binding.ts
index 14e520c2d..1f06f3373 100644
--- a/gitnexus/src/core/ingestion/languages/go/range-binding.ts
+++ b/gitnexus/src/core/ingestion/languages/go/range-binding.ts
@@ -2,6 +2,7 @@ import type { ParsedFile, Scope, TypeRef } from 'gitnexus-shared';
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
import { getGoParser } from './query.js';
import { getTreeSitterBufferSize } from '../../constants.js';
+import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
export function populateGoRangeBindings(
parsedFiles: readonly ParsedFile[],
@@ -20,7 +21,7 @@ export function populateGoRangeBindings(
const cachedTree = ctx.treeCache?.get(parsed.filePath);
const tree =
(cachedTree as ReturnType | undefined) ??
- parser.parse(sourceText, undefined, {
+ parseSourceSafe(parser, sourceText, undefined, {
bufferSize: getTreeSitterBufferSize(sourceText),
});
const moduleScope = parsed.scopes.find((s) => s.kind === 'Module');
diff --git a/gitnexus/src/core/ingestion/languages/java.ts b/gitnexus/src/core/ingestion/languages/java.ts
index 96139ccda..c70eacb10 100644
--- a/gitnexus/src/core/ingestion/languages/java.ts
+++ b/gitnexus/src/core/ingestion/languages/java.ts
@@ -27,6 +27,17 @@ import { javaMethodConfig } from '../method-extractors/configs/jvm.js';
import { createVariableExtractor } from '../variable-extractors/generic.js';
import { javaVariableConfig } from '../variable-extractors/configs/jvm.js';
import { createHeritageExtractor } from '../heritage-extractors/generic.js';
+import {
+ emitJavaScopeCaptures,
+ interpretJavaImport,
+ interpretJavaTypeBinding,
+ javaBindingScopeFor,
+ javaImportOwningScope,
+ javaMergeBindings,
+ javaReceiverBinding,
+ javaArityCompatibility,
+ resolveJavaImportTarget,
+} from './java/index.js';
export const javaProvider = defineLanguage({
id: SupportedLanguages.Java,
@@ -65,4 +76,15 @@ export const javaProvider = defineLanguage({
variableExtractor: createVariableExtractor(javaVariableConfig),
classExtractor: createClassExtractor(javaClassConfig),
heritageExtractor: createHeritageExtractor(SupportedLanguages.Java),
+
+ // ── RFC #909 Ring 3: scope-based resolution hooks ──
+ emitScopeCaptures: emitJavaScopeCaptures,
+ interpretImport: interpretJavaImport,
+ interpretTypeBinding: interpretJavaTypeBinding,
+ bindingScopeFor: javaBindingScopeFor,
+ importOwningScope: javaImportOwningScope,
+ mergeBindings: (_scope, bindings) => javaMergeBindings(bindings),
+ receiverBinding: javaReceiverBinding,
+ arityCompatibility: javaArityCompatibility,
+ resolveImportTarget: resolveJavaImportTarget,
});
diff --git a/gitnexus/src/core/ingestion/languages/java/arity-metadata.ts b/gitnexus/src/core/ingestion/languages/java/arity-metadata.ts
new file mode 100644
index 000000000..47cccbff9
--- /dev/null
+++ b/gitnexus/src/core/ingestion/languages/java/arity-metadata.ts
@@ -0,0 +1,49 @@
+/**
+ * Extract Java arity metadata from a method-like tree-sitter node —
+ * `method_declaration` or `constructor_declaration`.
+ *
+ * Reuses `javaMethodConfig.extractParameters` so scope-extracted defs
+ * carry the same arity semantics as the legacy parse-worker path:
+ * - varargs (`...`) collapses `parameterCount` to `undefined`
+ * - `parameterTypes` collects declared type names; a literal
+ * `'varargs'` marker is appended for variadic methods so
+ * `javaArityCompatibility` can detect them.
+ */
+
+import type { SyntaxNode } from '../../utils/ast-helpers.js';
+import { javaMethodConfig } from '../../method-extractors/configs/jvm.js';
+
+export interface JavaArityMetadata {
+ readonly parameterCount: number | undefined;
+ readonly requiredParameterCount: number | undefined;
+ readonly parameterTypes: readonly string[] | undefined;
+}
+
+export function computeJavaArityMetadata(fnNode: SyntaxNode): JavaArityMetadata {
+ const params = javaMethodConfig.extractParameters?.(fnNode) ?? [];
+
+ let hasVariadic = false;
+ const types: string[] = [];
+ for (const p of params) {
+ if (p.isVariadic) hasVariadic = true;
+ if (p.type !== null) types.push(p.type);
+ }
+ if (hasVariadic) types.push('varargs');
+
+ const total = params.length;
+ // For varargs methods, `parameterCount` (max) is unknown — any number of
+ // trailing arguments is valid. But the fixed-prefix parameters (everything
+ // before the variadic `...` param) are still required, so we preserve that
+ // count in `requiredParameterCount` so `javaArityCompatibility` can reject
+ // calls that undersupply the fixed prefix (e.g. `f(int x, String... args)`
+ // called with 0 args).
+ const fixedCount = params.filter((p) => !p.isVariadic).length;
+ const parameterCount = hasVariadic ? undefined : total;
+ const requiredParameterCount = hasVariadic ? fixedCount : total;
+
+ return {
+ parameterCount,
+ requiredParameterCount,
+ parameterTypes: types.length > 0 ? types : undefined,
+ };
+}
diff --git a/gitnexus/src/core/ingestion/languages/java/arity.ts b/gitnexus/src/core/ingestion/languages/java/arity.ts
new file mode 100644
index 000000000..f98a8209d
--- /dev/null
+++ b/gitnexus/src/core/ingestion/languages/java/arity.ts
@@ -0,0 +1,31 @@
+/**
+ * Java arity check, accommodating varargs (`...`).
+ *
+ * Verdicts:
+ * - `'compatible'` — argCount matches parameterCount, OR varargs present.
+ * - `'incompatible'` — argCount mismatches with no varargs.
+ * - `'unknown'` — metadata absent / incomplete.
+ */
+
+import type { Callsite, SymbolDefinition } from 'gitnexus-shared';
+
+export function javaArityCompatibility(
+ def: SymbolDefinition,
+ callsite: Callsite,
+): 'compatible' | 'unknown' | 'incompatible' {
+ const max = def.parameterCount;
+ const min = def.requiredParameterCount;
+ if (max === undefined && min === undefined) return 'unknown';
+
+ const argCount = callsite.arity;
+ if (!Number.isFinite(argCount) || argCount < 0) return 'unknown';
+
+ const hasVarArgs =
+ def.parameterTypes !== undefined &&
+ def.parameterTypes.some((t) => t === 'varargs' || t.includes('...'));
+
+ if (min !== undefined && argCount < min) return 'incompatible';
+ if (max !== undefined && argCount > max && !hasVarArgs) return 'incompatible';
+
+ return 'compatible';
+}
diff --git a/gitnexus/src/core/ingestion/languages/java/cache-stats.ts b/gitnexus/src/core/ingestion/languages/java/cache-stats.ts
new file mode 100644
index 000000000..a4c58f11f
--- /dev/null
+++ b/gitnexus/src/core/ingestion/languages/java/cache-stats.ts
@@ -0,0 +1,30 @@
+/**
+ * Dev-mode counters for the cross-phase scope-captures parse cache
+ * (Java mirror of `languages/csharp/cache-stats.ts`).
+ *
+ * Gated by `PROF_SCOPE_RESOLUTION=1`. Production builds fold every
+ * increment into dead code via the module-level `PROF` constant, so
+ * the hot path in `captures.ts` stays branch-free.
+ */
+
+const PROF = process.env.PROF_SCOPE_RESOLUTION === '1';
+
+let CACHE_HITS = 0;
+let CACHE_MISSES = 0;
+
+export function recordCacheHit(): void {
+ if (PROF) CACHE_HITS++;
+}
+
+export function recordCacheMiss(): void {
+ if (PROF) CACHE_MISSES++;
+}
+
+export function getJavaCaptureCacheStats(): { hits: number; misses: number } {
+ return { hits: CACHE_HITS, misses: CACHE_MISSES };
+}
+
+export function resetJavaCaptureCacheStats(): void {
+ CACHE_HITS = 0;
+ CACHE_MISSES = 0;
+}
diff --git a/gitnexus/src/core/ingestion/languages/java/captures.ts b/gitnexus/src/core/ingestion/languages/java/captures.ts
new file mode 100644
index 000000000..73ea605fe
--- /dev/null
+++ b/gitnexus/src/core/ingestion/languages/java/captures.ts
@@ -0,0 +1,235 @@
+/**
+ * `emitScopeCaptures` for Java.
+ *
+ * Drives the Java scope query against tree-sitter-java and groups raw
+ * matches into `CaptureMatch[]` for the central extractor. Layers:
+ *
+ * 1. **Decomposed import declarations** — each `import_declaration`
+ * is re-emitted with `@import.kind/source/name` markers.
+ * 2. **Receiver binding synthesis** — `this`/`super` type-bindings
+ * on instance methods.
+ * 3. **Arity metadata** on method/constructor declarations.
+ * 4. **Reference arity** on call sites.
+ *
+ * Pure given the input source text. No I/O, no globals consulted.
+ */
+
+import type { Capture, CaptureMatch } from 'gitnexus-shared';
+import { findNodeAtRange, nodeToCapture, syntheticCapture } from '../../utils/ast-helpers.js';
+import { splitImportDeclaration } from './import-decomposer.js';
+import { computeJavaArityMetadata } from './arity-metadata.js';
+import { synthesizeJavaReceiverBinding } from './receiver-binding.js';
+import { getJavaParser, getJavaScopeQuery } from './query.js';
+import { recordCacheHit, recordCacheMiss } from './cache-stats.js';
+import { getTreeSitterBufferSize } from '../../constants.js';
+import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
+
+/** Declaration anchors that carry function-like arity metadata. */
+const FUNCTION_DECL_TAGS = ['@declaration.method', '@declaration.constructor'] as const;
+
+/** tree-sitter-java node types that the method extractor accepts. */
+const FUNCTION_NODE_TYPES = ['method_declaration', 'constructor_declaration'] as const;
+
+/** Suppress read.member emissions when the field_access is already
+ * covered by a method_invocation (object of a call) or an
+ * assignment_expression (write target). */
+function shouldEmitReadMember(memberNode: SyntaxNode): boolean {
+ const parent = memberNode.parent;
+ if (parent === null) return true;
+
+ switch (parent.type) {
+ case 'method_invocation':
+ // Don't emit read.member when the field_access is the object of a method_invocation
+ // (the method call already handles this relationship)
+ return parent.childForFieldName('object')?.id !== memberNode.id;
+ case 'assignment_expression':
+ return parent.childForFieldName('left')?.id !== memberNode.id;
+ default:
+ return true;
+ }
+}
+
+export function emitJavaScopeCaptures(
+ sourceText: string,
+ _filePath: string,
+ cachedTree?: unknown,
+): readonly CaptureMatch[] {
+ let tree = cachedTree as ReturnType['parse']> | undefined;
+ if (tree === undefined) {
+ tree = parseSourceSafe(getJavaParser(), sourceText, undefined, {
+ bufferSize: getTreeSitterBufferSize(sourceText),
+ });
+ recordCacheMiss();
+ } else {
+ recordCacheHit();
+ }
+
+ const rawMatches = getJavaScopeQuery().matches(tree.rootNode);
+ const out: CaptureMatch[] = [];
+
+ for (const m of rawMatches) {
+ const grouped: Record = {};
+ for (const c of m.captures) {
+ const tag = '@' + c.name;
+ grouped[tag] = nodeToCapture(tag, c.node);
+ }
+ if (Object.keys(grouped).length === 0) continue;
+
+ // Decompose each `import_declaration`.
+ if (grouped['@import.statement'] !== undefined) {
+ const stmtCapture = grouped['@import.statement'];
+ const stmtNode = findNodeAtRange(tree.rootNode, stmtCapture.range, 'import_declaration');
+ if (stmtNode !== null) {
+ const decomposed = splitImportDeclaration(stmtNode);
+ if (decomposed !== null) {
+ out.push(decomposed);
+ continue;
+ }
+ }
+ out.push(grouped);
+ continue;
+ }
+
+ // Skip free-call matches that are actually member calls. The query
+ // matches ALL method_invocations as @reference.call.free (without
+ // negation) because tree-sitter-java's query engine drops !object
+ // patterns when a positive object: pattern exists for the same node
+ // type. Filter here: if the match has @reference.call.free but also
+ // has @reference.receiver, it's a member call — skip the free match
+ // (the separate @reference.call.member match covers it).
+ if (
+ grouped['@reference.call.free'] !== undefined &&
+ grouped['@reference.receiver'] !== undefined
+ ) {
+ continue;
+ }
+
+ // Filter read.member when it's a child of method_invocation or assignment.
+ if (grouped['@reference.read.member'] !== undefined) {
+ const anchor = grouped['@reference.read.member'];
+ const memberNode = findNodeAtRange(tree.rootNode, anchor.range, 'field_access');
+ if (memberNode === null || !shouldEmitReadMember(memberNode)) {
+ continue;
+ }
+ }
+
+ // Synthesize `this` / `super` receiver type-bindings on every
+ // instance method-like.
+ if (grouped['@scope.function'] !== undefined) {
+ out.push(grouped);
+ const anchor = grouped['@scope.function']!;
+ const fnNode = findFunctionNode(tree.rootNode, anchor.range);
+ if (fnNode !== null) {
+ for (const synth of synthesizeJavaReceiverBinding(fnNode)) {
+ out.push(synth);
+ }
+ }
+ continue;
+ }
+
+ // Synthesize arity metadata on function-like declarations.
+ const declTag = FUNCTION_DECL_TAGS.find((t) => grouped[t] !== undefined);
+ if (declTag !== undefined) {
+ const anchor = grouped[declTag]!;
+ const fnNode = findFunctionNode(tree.rootNode, anchor.range);
+ if (fnNode !== null) {
+ const arity = computeJavaArityMetadata(fnNode);
+ if (arity.parameterCount !== undefined) {
+ grouped['@declaration.parameter-count'] = syntheticCapture(
+ '@declaration.parameter-count',
+ fnNode,
+ String(arity.parameterCount),
+ );
+ }
+ if (arity.requiredParameterCount !== undefined) {
+ grouped['@declaration.required-parameter-count'] = syntheticCapture(
+ '@declaration.required-parameter-count',
+ fnNode,
+ String(arity.requiredParameterCount),
+ );
+ }
+ if (arity.parameterTypes !== undefined) {
+ grouped['@declaration.parameter-types'] = syntheticCapture(
+ '@declaration.parameter-types',
+ fnNode,
+ JSON.stringify(arity.parameterTypes),
+ );
+ }
+ }
+ }
+
+ // Synthesize `@reference.arity` on every callsite.
+ const callTag = (
+ ['@reference.call.free', '@reference.call.member', '@reference.call.constructor'] as const
+ ).find((t) => grouped[t] !== undefined);
+ if (callTag !== undefined && grouped['@reference.arity'] === undefined) {
+ const anchor = grouped[callTag]!;
+ const callNode =
+ findNodeAtRange(tree.rootNode, anchor.range, 'method_invocation') ??
+ findNodeAtRange(tree.rootNode, anchor.range, 'object_creation_expression');
+ if (callNode !== null) {
+ const argList = callNode.childForFieldName('arguments');
+ const args =
+ argList === null
+ ? []
+ : argList.namedChildren.filter((c) => c !== null && c.type !== 'comment');
+ grouped['@reference.arity'] = syntheticCapture(
+ '@reference.arity',
+ callNode,
+ String(args.length),
+ );
+
+ const argTypes = args.map((arg) => inferArgType(arg!));
+ grouped['@reference.parameter-types'] = syntheticCapture(
+ '@reference.parameter-types',
+ callNode,
+ JSON.stringify(argTypes),
+ );
+ }
+ }
+
+ out.push(grouped);
+ }
+
+ return out;
+}
+
+type SyntaxNode = ReturnType['parse']>['rootNode'];
+
+/** Infer a Java argument's static type from literal patterns. */
+function inferArgType(argNode: SyntaxNode): string {
+ switch (argNode.type) {
+ case 'decimal_integer_literal':
+ case 'hex_integer_literal':
+ case 'octal_integer_literal':
+ case 'binary_integer_literal':
+ return 'int';
+ case 'decimal_floating_point_literal':
+ case 'hex_floating_point_literal':
+ return 'double';
+ case 'string_literal':
+ return 'String';
+ case 'character_literal':
+ return 'char';
+ case 'true':
+ case 'false':
+ return 'boolean';
+ case 'null_literal':
+ return 'null';
+ case 'object_creation_expression': {
+ const typeNode = argNode.childForFieldName('type');
+ return typeNode?.text ?? '';
+ }
+ default:
+ return '';
+ }
+}
+
+/** Find the first Java function-like node at the given range. */
+function findFunctionNode(rootNode: SyntaxNode, range: Capture['range']): SyntaxNode | null {
+ for (const nodeType of FUNCTION_NODE_TYPES) {
+ const n = findNodeAtRange(rootNode, range, nodeType);
+ if (n !== null) return n as SyntaxNode;
+ }
+ return null;
+}
diff --git a/gitnexus/src/core/ingestion/languages/java/import-decomposer.ts b/gitnexus/src/core/ingestion/languages/java/import-decomposer.ts
new file mode 100644
index 000000000..59c74d144
--- /dev/null
+++ b/gitnexus/src/core/ingestion/languages/java/import-decomposer.ts
@@ -0,0 +1,104 @@
+/**
+ * Decompose a Java `import_declaration` into a `CaptureMatch` carrying
+ * the synthesized markers `@import.kind` / `@import.source` /
+ * `@import.name` that `interpretJavaImport` consumes.
+ *
+ * Unlike C#'s using-directive decomposer, Java has four import forms:
+ *
+ * import com.example.User; → named
+ * import com.example.*; → wildcard
+ * import static com.example.Utils.format; → static
+ * import static com.example.Utils.*; → static-wildcard
+ *
+ * Each produces exactly one import. The decomposer inspects the raw
+ * source text and tree-sitter children to determine the flavor.
+ */
+
+import type { Capture, CaptureMatch } from 'gitnexus-shared';
+import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
+
+type ImportKind = 'named' | 'wildcard' | 'static' | 'static-wildcard';
+
+interface ImportSpec {
+ readonly kind: ImportKind;
+ /** Full dotted path: `com.example.User`. */
+ readonly source: string;
+ /** Local binding name — last path segment for named/static,
+ * `'*'` for wildcard/static-wildcard. */
+ readonly name: string;
+ /** Node to anchor the synthesized captures (range-wise). */
+ readonly atNode: SyntaxNode;
+}
+
+export function splitImportDeclaration(stmtNode: SyntaxNode): CaptureMatch | null {
+ if (stmtNode.type !== 'import_declaration') return null;
+ const spec = parseImportDeclaration(stmtNode);
+ if (spec === null) return null;
+ return buildImportMatch(stmtNode, spec);
+}
+
+function parseImportDeclaration(node: SyntaxNode): ImportSpec | null {
+ // Detect `static` by checking for an anonymous `static` token child.
+ let isStatic = false;
+ for (let i = 0; i < node.childCount; i++) {
+ const child = node.child(i);
+ if (child !== null && child.type === 'static') {
+ isStatic = true;
+ break;
+ }
+ }
+
+ // Detect wildcard by checking for `asterisk` named child.
+ let isWildcard = false;
+ for (let i = 0; i < node.namedChildCount; i++) {
+ const child = node.namedChild(i);
+ if (child !== null && child.type === 'asterisk') {
+ isWildcard = true;
+ break;
+ }
+ }
+
+ // Find the scoped_identifier (or identifier for single-segment imports).
+ let pathNode: SyntaxNode | null = null;
+ for (let i = 0; i < node.namedChildCount; i++) {
+ const child = node.namedChild(i);
+ if (child !== null && (child.type === 'scoped_identifier' || child.type === 'identifier')) {
+ pathNode = child;
+ break;
+ }
+ }
+ if (pathNode === null) return null;
+
+ const fullPath = pathNode.text;
+ if (fullPath === '') return null;
+
+ if (isStatic && isWildcard) {
+ // `import static com.example.Utils.*;`
+ return { kind: 'static-wildcard', source: fullPath, name: '*', atNode: node };
+ }
+ if (isStatic) {
+ // `import static com.example.Utils.format;`
+ const lastDot = fullPath.lastIndexOf('.');
+ const name = lastDot >= 0 ? fullPath.slice(lastDot + 1) : fullPath;
+ return { kind: 'static', source: fullPath, name, atNode: node };
+ }
+ if (isWildcard) {
+ // `import com.example.*;`
+ return { kind: 'wildcard', source: fullPath, name: '*', atNode: node };
+ }
+
+ // `import com.example.User;`
+ const lastDot = fullPath.lastIndexOf('.');
+ const name = lastDot >= 0 ? fullPath.slice(lastDot + 1) : fullPath;
+ return { kind: 'named', source: fullPath, name, atNode: node };
+}
+
+function buildImportMatch(stmtNode: SyntaxNode, spec: ImportSpec): CaptureMatch {
+ const m: Record = {
+ '@import.statement': nodeToCapture('@import.statement', stmtNode),
+ '@import.kind': syntheticCapture('@import.kind', spec.atNode, spec.kind),
+ '@import.source': syntheticCapture('@import.source', spec.atNode, spec.source),
+ '@import.name': syntheticCapture('@import.name', spec.atNode, spec.name),
+ };
+ return m;
+}
diff --git a/gitnexus/src/core/ingestion/languages/java/import-target.ts b/gitnexus/src/core/ingestion/languages/java/import-target.ts
new file mode 100644
index 000000000..b78b6369b
--- /dev/null
+++ b/gitnexus/src/core/ingestion/languages/java/import-target.ts
@@ -0,0 +1,108 @@
+/**
+ * Adapter from `(ParsedImport, WorkspaceIndex)` → concrete file path.
+ *
+ * Converts Java package paths (dots → slashes) and tries:
+ * 1. Exact file match: `com/example/User.java`
+ * 2. Suffix match for nested layouts
+ * 3. Directory match (wildcard imports)
+ * 4. Progressive prefix stripping for non-standard layouts
+ *
+ * Returns `null` for unresolvable / JDK imports.
+ */
+
+import type { ParsedImport, WorkspaceIndex } from 'gitnexus-shared';
+
+export interface JavaResolveContext {
+ readonly fromFile: string;
+ readonly allFilePaths: ReadonlySet;
+}
+
+export function resolveJavaImportTarget(
+ parsedImport: ParsedImport,
+ workspaceIndex: WorkspaceIndex,
+): string | null {
+ const ctx = workspaceIndex as JavaResolveContext | undefined;
+ if (
+ ctx === undefined ||
+ typeof (ctx as { fromFile?: unknown }).fromFile !== 'string' ||
+ !((ctx as { allFilePaths?: unknown }).allFilePaths instanceof Set)
+ ) {
+ return null;
+ }
+ if (parsedImport.kind === 'dynamic-unresolved') return null;
+ if (parsedImport.targetRaw === null || parsedImport.targetRaw === '') return null;
+
+ // Strip trailing `.*` for wildcard imports: `com.example.*` → `com.example`
+ let target = parsedImport.targetRaw;
+ if (target.endsWith('.*')) {
+ target = target.slice(0, -2);
+ }
+
+ // Package path: `com.example.User` → `com/example/User`
+ const pathLike = target.replace(/\./g, '/');
+ const suffix = `/${pathLike}`;
+
+ let exactFile: string | null = null;
+ let suffixFile: string | null = null;
+ let directoryChild: string | null = null;
+ const dirPrefix = `${pathLike}/`;
+ const suffixDirPrefix = `/${dirPrefix}`;
+
+ for (const raw of ctx.allFilePaths) {
+ const f = raw.replace(/\\/g, '/');
+ if (!f.endsWith('.java')) continue;
+ if (f === `${pathLike}.java`) {
+ exactFile = raw;
+ break;
+ }
+ if (suffixFile === null && f.endsWith(`${suffix}.java`)) {
+ suffixFile = raw;
+ }
+ if (directoryChild === null) {
+ const atRoot = f.startsWith(dirPrefix);
+ const atNested = f.includes(suffixDirPrefix);
+ if (atRoot || atNested) {
+ const idx = atRoot ? 0 : f.indexOf(suffixDirPrefix) + 1;
+ const after = f.slice(idx + dirPrefix.length);
+ if (after.length > 0 && !after.includes('/')) {
+ directoryChild = raw;
+ }
+ }
+ }
+ }
+
+ if (exactFile !== null) return exactFile;
+ if (suffixFile !== null) return suffixFile;
+ if (directoryChild !== null) return directoryChild;
+
+ // Progressive prefix stripping — handles `import com.example.User;`
+ // in a repo laid out `User.java` (no `com/example/` prefix).
+ const segments = pathLike.split('/').filter(Boolean);
+ for (let skip = 1; skip < segments.length; skip++) {
+ const tail = segments.slice(skip).join('/');
+ if (tail === '') continue;
+ const tailFile = `${tail}.java`;
+ const tailSuffix = `/${tailFile}`;
+ const tailDir = `${tail}/`;
+ const tailSuffixDir = `/${tailDir}`;
+ let tailDirectChild: string | null = null;
+ for (const raw of ctx.allFilePaths) {
+ const f = raw.replace(/\\/g, '/');
+ if (!f.endsWith('.java')) continue;
+ if (f === tailFile) return raw;
+ if (f.endsWith(tailSuffix)) return raw;
+ if (tailDirectChild === null) {
+ const atRoot = f.startsWith(tailDir);
+ const atNested = f.includes(tailSuffixDir);
+ if (atRoot || atNested) {
+ const idx = atRoot ? 0 : f.indexOf(tailSuffixDir) + 1;
+ const after = f.slice(idx + tailDir.length);
+ if (after.length > 0 && !after.includes('/')) tailDirectChild = raw;
+ }
+ }
+ }
+ if (tailDirectChild !== null) return tailDirectChild;
+ }
+
+ return null;
+}
diff --git a/gitnexus/src/core/ingestion/languages/java/index.ts b/gitnexus/src/core/ingestion/languages/java/index.ts
new file mode 100644
index 000000000..443faac4b
--- /dev/null
+++ b/gitnexus/src/core/ingestion/languages/java/index.ts
@@ -0,0 +1,30 @@
+/**
+ * Java scope-resolution hooks (RFC #909 Ring 3).
+ *
+ * Public API barrel. Consumers should import from this file rather than
+ * the individual modules.
+ *
+ * Module layout:
+ *
+ * - `query.ts` — tree-sitter query + lazy parser/query singletons
+ * - `captures.ts` — `emitJavaScopeCaptures` orchestrator
+ * - `import-decomposer.ts` — each `import` → ParsedImport-shaped captures
+ * - `interpret.ts` — capture-match → `ParsedImport` / `ParsedTypeBinding`
+ * - `simple-hooks.ts` — small hooks made explicit
+ * - `receiver-binding.ts` — synthesize `this`/`super` type-bindings on
+ * instance-method entry
+ * - `merge-bindings.ts` — Java import precedence
+ * - `arity.ts` — Java arity compatibility (varargs)
+ * - `arity-metadata.ts` — synthesize arity metadata from declarations
+ * - `import-target.ts` — `(ParsedImport, WorkspaceIndex) → file path` adapter
+ * - `scope-resolver.ts` — `ScopeResolver` registered in `SCOPE_RESOLVERS`
+ * - `cache-stats.ts` — PROF_SCOPE_RESOLUTION cache hit/miss counters
+ */
+
+export { emitJavaScopeCaptures } from './captures.js';
+export { getJavaCaptureCacheStats, resetJavaCaptureCacheStats } from './cache-stats.js';
+export { interpretJavaImport, interpretJavaTypeBinding } from './interpret.js';
+export { javaMergeBindings } from './merge-bindings.js';
+export { javaArityCompatibility } from './arity.js';
+export { resolveJavaImportTarget, type JavaResolveContext } from './import-target.js';
+export { javaBindingScopeFor, javaImportOwningScope, javaReceiverBinding } from './simple-hooks.js';
diff --git a/gitnexus/src/core/ingestion/languages/java/interpret.ts b/gitnexus/src/core/ingestion/languages/java/interpret.ts
new file mode 100644
index 000000000..9c207d451
--- /dev/null
+++ b/gitnexus/src/core/ingestion/languages/java/interpret.ts
@@ -0,0 +1,141 @@
+/**
+ * Capture-match → semantic-shape interpreters for Java.
+ *
+ * - `interpretJavaImport` → `ParsedImport`
+ * - `interpretJavaTypeBinding` → `ParsedTypeBinding`
+ *
+ * Import matches arrive pre-decomposed by `emitJavaScopeCaptures`
+ * (one import per match, with synthesized `@import.kind/source/name`
+ * markers). Type-binding matches arrive from the raw query captures.
+ */
+
+import type { CaptureMatch, ParsedImport, ParsedTypeBinding, TypeRef } from 'gitnexus-shared';
+
+// ─── interpretImport ──────────────────────────────────────────────────────
+
+export function interpretJavaImport(captures: CaptureMatch): ParsedImport | null {
+ const kindCap = captures['@import.kind'];
+ const sourceCap = captures['@import.source'];
+ const nameCap = captures['@import.name'];
+
+ const kind = kindCap?.text;
+ if (kind === undefined || sourceCap === undefined) return null;
+
+ switch (kind) {
+ case 'named': {
+ // `import com.example.User;`
+ return {
+ kind: 'named',
+ localName: nameCap?.text ?? sourceCap.text.split('.').pop() ?? sourceCap.text,
+ importedName: sourceCap.text,
+ targetRaw: sourceCap.text,
+ };
+ }
+ case 'wildcard': {
+ // `import com.example.*;`
+ return {
+ kind: 'wildcard',
+ targetRaw: sourceCap.text + '.*',
+ };
+ }
+ case 'static': {
+ // `import static com.example.Utils.format;`
+ // The source contains the full path including the member name
+ // (e.g. `com.example.Utils.format`). For file resolution we need
+ // the class path (`com.example.Utils`), so strip the final member
+ // segment. The local binding name is the member itself.
+ const fullSource = sourceCap.text;
+ const lastDot = fullSource.lastIndexOf('.');
+ const classPath = lastDot >= 0 ? fullSource.slice(0, lastDot) : fullSource;
+ return {
+ kind: 'named',
+ localName: nameCap?.text ?? (lastDot >= 0 ? fullSource.slice(lastDot + 1) : fullSource),
+ importedName: fullSource,
+ targetRaw: classPath,
+ };
+ }
+ case 'static-wildcard': {
+ // `import static com.example.Utils.*;`
+ // The source is the class path (e.g. `com.example.Utils`).
+ // Resolution should target the class file, not a wildcard directory
+ // scan — `Utils.java` is the file that contains the static members.
+ return {
+ kind: 'wildcard',
+ targetRaw: sourceCap.text + '.*',
+ };
+ }
+ default:
+ return null;
+ }
+}
+
+// ─── interpretTypeBinding ─────────────────────────────────────────────────
+
+export function interpretJavaTypeBinding(captures: CaptureMatch): ParsedTypeBinding | null {
+ const nameCap = captures['@type-binding.name'];
+ const typeCap = captures['@type-binding.type'];
+ if (nameCap === undefined || typeCap === undefined) return null;
+
+ // Strip qualifier first so that `com.example.BaseModel` becomes
+ // `BaseModel` before stripGeneric — the JVM-erasure fallback pattern
+ // requires an unqualified identifier at the start of the string.
+ const rawType = stripGeneric(stripQualifier(typeCap.text.trim()));
+
+ // Skip `var` — tree-sitter-java parses `var` as type_identifier with
+ // text "var". When used without a constructor initializer, there's no
+ // concrete type to bind.
+ if (rawType === 'var') return null;
+
+ let source: TypeRef['source'] = 'parameter-annotation';
+ if (captures['@type-binding.self'] !== undefined) source = 'self';
+ else if (captures['@type-binding.constructor'] !== undefined) source = 'constructor-inferred';
+ else if (captures['@type-binding.annotation'] !== undefined) source = 'annotation';
+ else if (captures['@type-binding.return'] !== undefined) source = 'return-annotation';
+
+ return { boundName: nameCap.text, rawTypeName: rawType, source };
+}
+
+/**
+ * Unwrap generic type parameters from Java types.
+ *
+ * Three tiers, checked in order:
+ * 1. Known single-arg collection wrappers → extract the element type
+ * (`List` → `User`, `Optional` → `User`).
+ * 2. Known two-arg map/container types → extract the value type
+ * (`Map` → `User`).
+ * 3. **Fallback (JVM type erasure):** any other generic type →
+ * strip the generic parameters and keep the raw class name
+ * (`BaseModel` → `BaseModel`, `CustomList` → `CustomList`).
+ * This ensures receiver bindings (`this`/`super`) on classes with
+ * generic superclasses resolve to the correct class file.
+ */
+function stripGeneric(text: string): string {
+ // Single-type-argument containers — extract the element type.
+ const single = text.match(
+ /^(?:[A-Za-z_][A-Za-z0-9_.]*\.)?(?:List|ArrayList|LinkedList|Set|HashSet|TreeSet|SortedSet|LinkedHashSet|Collection|Iterable|Iterator|Optional|Stream|CompletableFuture|Future|Queue|Deque|ArrayDeque|PriorityQueue|Vector|Stack|Supplier|Consumer|Predicate|Function)<([^,<>]+)>$/,
+ );
+ if (single !== null) return single[1].trim();
+
+ // Two-type-argument map/container types — extract the value type (second arg).
+ const twoArg = text.match(
+ /^(?:[A-Za-z_][A-Za-z0-9_.]*\.)?(?:Map|HashMap|TreeMap|LinkedHashMap|ConcurrentHashMap|ConcurrentMap|SortedMap|NavigableMap|Hashtable|EnumMap|WeakHashMap|IdentityHashMap|BiFunction|BiConsumer|BiPredicate|Pair|Entry)<[^,<>]+,\s*([^,<>]+)>$/,
+ );
+ if (twoArg !== null) return twoArg[1].trim();
+
+ // Fallback: strip generic parameters from any unrecognized generic type.
+ // `BaseModel` → `BaseModel`, `Builder` → `Builder`.
+ // This mirrors JVM type erasure — the raw class name is the resolvable symbol.
+ // The pattern matches up to the first `<` to handle nested generics safely
+ // (e.g. `BaseModel>` → `BaseModel`).
+ const fallback = text.match(/^([A-Za-z_$][A-Za-z0-9_$]*)<.+>$/s);
+ if (fallback !== null) return fallback[1].trim();
+
+ return text;
+}
+
+/** `com.example.User` → `User`. */
+function stripQualifier(text: string): string {
+ const lastDot = text.lastIndexOf('.');
+ if (lastDot === -1) return text;
+ return text.slice(lastDot + 1);
+}
diff --git a/gitnexus/src/core/ingestion/languages/java/merge-bindings.ts b/gitnexus/src/core/ingestion/languages/java/merge-bindings.ts
new file mode 100644
index 000000000..9056705d0
--- /dev/null
+++ b/gitnexus/src/core/ingestion/languages/java/merge-bindings.ts
@@ -0,0 +1,44 @@
+/**
+ * Java shadowing precedence for the `mergeBindings` hook.
+ *
+ * Tier ranking (lower wins):
+ * - 0: `local` — class member, method, local variable, parameter
+ * - 1: `import` / `namespace` / `reexport` — explicit imports
+ * - 2: `wildcard` — wildcard imports (`import x.y.*`)
+ *
+ * Within a surviving tier: de-dup by DefId, last-write-wins.
+ */
+
+import type { BindingRef } from 'gitnexus-shared';
+
+const TIER_LOCAL = 0;
+const TIER_IMPORT = 1;
+const TIER_WILDCARD = 2;
+const TIER_UNKNOWN = 3;
+
+function tierOf(b: BindingRef): number {
+ switch (b.origin) {
+ case 'local':
+ return TIER_LOCAL;
+ case 'reexport':
+ case 'import':
+ case 'namespace':
+ return TIER_IMPORT;
+ case 'wildcard':
+ return TIER_WILDCARD;
+ default:
+ return TIER_UNKNOWN;
+ }
+}
+
+export function javaMergeBindings(bindings: readonly BindingRef[]): readonly BindingRef[] {
+ if (bindings.length === 0) return bindings;
+
+ let bestTier = Number.POSITIVE_INFINITY;
+ for (const b of bindings) bestTier = Math.min(bestTier, tierOf(b));
+ const survivors = bindings.filter((b) => tierOf(b) === bestTier);
+
+ const seen = new Map();
+ for (const b of survivors) seen.set(b.def.nodeId, b);
+ return [...seen.values()];
+}
diff --git a/gitnexus/src/core/ingestion/languages/java/query.ts b/gitnexus/src/core/ingestion/languages/java/query.ts
new file mode 100644
index 000000000..3fabbb7bf
--- /dev/null
+++ b/gitnexus/src/core/ingestion/languages/java/query.ts
@@ -0,0 +1,197 @@
+/**
+ * Tree-sitter query for Java scope captures (RFC §5.1).
+ *
+ * Captures the structural skeleton the generic scope-resolution
+ * pipeline consumes: scopes (module/class/function), declarations
+ * (class-likes, method-likes, fields, variables), imports (import
+ * declarations), type bindings (parameter annotations, variable
+ * annotations, constructor inference), and references (call sites,
+ * member writes/reads).
+ *
+ * Java specifics that shape this query:
+ *
+ * - Java uses `program` as the root node (not `compilation_unit`).
+ * - `import_declaration` nodes carry `scoped_identifier` children
+ * and optional `asterisk` for wildcard imports.
+ * - `static` imports are detected by an anonymous `static` token
+ * child within `import_declaration`.
+ * - `var` (Java 10+ local variable type inference) parses as a
+ * `type_identifier` with text `"var"`, not a dedicated node type.
+ * - Modifiers (`public`, `static`, etc.) are grouped under a
+ * `modifiers` named child with anonymous keyword tokens.
+ * - Superclass inheritance uses a `superclass:` field containing
+ * a `superclass` node wrapping a `type_identifier`.
+ *
+ * Exposes lazy `Parser` and `Query` singletons so callers don't pay
+ * tree-sitter init cost per file.
+ */
+
+import Parser from 'tree-sitter';
+import Java from 'tree-sitter-java';
+
+const JAVA_SCOPE_QUERY = `
+;; Scopes
+(program) @scope.module
+
+(class_declaration) @scope.class
+(interface_declaration) @scope.class
+(enum_declaration) @scope.class
+(record_declaration) @scope.class
+(annotation_type_declaration) @scope.class
+
+(method_declaration) @scope.function
+(constructor_declaration) @scope.function
+
+;; Declarations — types
+(class_declaration
+ name: (identifier) @declaration.name) @declaration.class
+
+(interface_declaration
+ name: (identifier) @declaration.name) @declaration.interface
+
+(enum_declaration
+ name: (identifier) @declaration.name) @declaration.enum
+
+(record_declaration
+ name: (identifier) @declaration.name) @declaration.record
+
+(annotation_type_declaration
+ name: (identifier) @declaration.name) @declaration.class
+
+;; Declarations — methods / constructors
+(method_declaration
+ name: (identifier) @declaration.name) @declaration.method
+
+(constructor_declaration
+ name: (identifier) @declaration.name) @declaration.constructor
+
+;; Declarations — fields
+(field_declaration
+ declarator: (variable_declarator
+ name: (identifier) @declaration.name)) @declaration.variable
+
+;; Declarations — local variables
+(local_variable_declaration
+ declarator: (variable_declarator
+ name: (identifier) @declaration.name)) @declaration.variable
+
+;; Imports — single anchor per import_declaration
+(import_declaration) @import.statement
+
+;; Type bindings — parameter annotations: void f(User u)
+(formal_parameter
+ type: (type_identifier) @type-binding.type
+ name: (identifier) @type-binding.name) @type-binding.parameter
+
+(formal_parameter
+ type: (generic_type) @type-binding.type
+ name: (identifier) @type-binding.name) @type-binding.parameter
+
+(formal_parameter
+ type: (scoped_type_identifier) @type-binding.type
+ name: (identifier) @type-binding.name) @type-binding.parameter
+
+;; Type bindings — local variable annotations: User u = new User();
+(local_variable_declaration
+ type: (type_identifier) @type-binding.type
+ declarator: (variable_declarator
+ name: (identifier) @type-binding.name)) @type-binding.annotation
+
+(local_variable_declaration
+ type: (generic_type) @type-binding.type
+ declarator: (variable_declarator
+ name: (identifier) @type-binding.name)) @type-binding.annotation
+
+;; Type bindings — var u = new User(); (Java 10+ local variable type inference)
+;; tree-sitter-java parses \`var\` as a \`type_identifier\` with text "var".
+;; The type-binding.constructor anchor fires when the rhs is an
+;; object_creation_expression so interpretJavaTypeBinding can infer
+;; the concrete type from the constructor call.
+(local_variable_declaration
+ type: (type_identifier) @_var_type
+ declarator: (variable_declarator
+ name: (identifier) @type-binding.name
+ value: (object_creation_expression
+ type: (type_identifier) @type-binding.type))) @type-binding.constructor
+
+;; Type bindings — field declarations: private User user;
+(field_declaration
+ type: (type_identifier) @type-binding.type
+ declarator: (variable_declarator
+ name: (identifier) @type-binding.name)) @type-binding.annotation
+
+(field_declaration
+ type: (generic_type) @type-binding.type
+ declarator: (variable_declarator
+ name: (identifier) @type-binding.name)) @type-binding.annotation
+
+;; Type bindings — method return type: public User getUser() { }
+(method_declaration
+ type: (type_identifier) @type-binding.type
+ name: (identifier) @type-binding.name) @type-binding.return
+
+(method_declaration
+ type: (generic_type) @type-binding.type
+ name: (identifier) @type-binding.name) @type-binding.return
+
+;; Type bindings — enhanced for: for (User u : list)
+(enhanced_for_statement
+ type: (type_identifier) @type-binding.type
+ name: (identifier) @type-binding.name) @type-binding.annotation
+
+(enhanced_for_statement
+ type: (generic_type) @type-binding.type
+ name: (identifier) @type-binding.name) @type-binding.annotation
+
+;; References — all method calls: foo() and obj.method()
+;; tree-sitter-java's query engine drops negation-based \`!object\`
+;; patterns when a positive \`object:\` pattern exists for the same
+;; node type, so we match all calls here and classify free vs
+;; member in captures.ts based on the presence of @reference.receiver.
+(method_invocation
+ object: (_) @reference.receiver
+ name: (identifier) @reference.name) @reference.call.member
+
+(method_invocation
+ name: (identifier) @reference.name) @reference.call.free
+
+;; References — constructor calls: new User(...)
+(object_creation_expression
+ type: (type_identifier) @reference.name) @reference.call.constructor
+
+(object_creation_expression
+ type: (generic_type
+ (type_identifier) @reference.name)) @reference.call.constructor
+
+(object_creation_expression
+ type: (scoped_type_identifier) @reference.call.constructor.qualified) @reference.call.constructor
+
+;; References — field/property writes: obj.name = "x"
+(assignment_expression
+ left: (field_access
+ object: (_) @reference.receiver
+ field: (identifier) @reference.name)) @reference.write.member
+
+;; References — field/property reads: obj.name
+(field_access
+ object: (_) @reference.receiver
+ field: (identifier) @reference.name) @reference.read.member
+`;
+
+let _parser: Parser | null = null;
+let _query: Parser.Query | null = null;
+
+export function getJavaParser(): Parser {
+ if (_parser === null) {
+ _parser = new Parser();
+ _parser.setLanguage(Java as Parameters[0]);
+ }
+ return _parser;
+}
+
+export function getJavaScopeQuery(): Parser.Query {
+ if (_query === null) {
+ _query = new Parser.Query(Java as Parameters[0], JAVA_SCOPE_QUERY);
+ }
+ return _query;
+}
diff --git a/gitnexus/src/core/ingestion/languages/java/receiver-binding.ts b/gitnexus/src/core/ingestion/languages/java/receiver-binding.ts
new file mode 100644
index 000000000..4d6d60ded
--- /dev/null
+++ b/gitnexus/src/core/ingestion/languages/java/receiver-binding.ts
@@ -0,0 +1,103 @@
+/**
+ * Synthesize `@type-binding.self` captures for Java instance methods —
+ * one for `this` (always on non-static methods inside a type
+ * declaration) and optionally one for `super` (only on class methods
+ * when the enclosing class has a `superclass`).
+ *
+ * Mirrors `languages/csharp/receiver-binding.ts` in structure.
+ */
+
+import type { Capture, CaptureMatch } from 'gitnexus-shared';
+import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
+
+const TYPE_DECL_NODE_TYPES = new Set([
+ 'class_declaration',
+ 'interface_declaration',
+ 'enum_declaration',
+ 'record_declaration',
+]);
+
+const FUNCTION_NODE_TYPES = new Set(['method_declaration', 'constructor_declaration']);
+
+/** Walk up to the enclosing type declaration. */
+function findEnclosingTypeDeclaration(node: SyntaxNode): SyntaxNode | null {
+ let cur: SyntaxNode | null = node.parent;
+ while (cur !== null) {
+ if (TYPE_DECL_NODE_TYPES.has(cur.type)) return cur;
+ cur = cur.parent;
+ }
+ return null;
+}
+
+function typeName(typeNode: SyntaxNode): string | null {
+ return typeNode.childForFieldName('name')?.text ?? null;
+}
+
+/** First superclass text. tree-sitter-java uses a `superclass` field
+ * containing a `superclass` node wrapping a `type_identifier`. */
+function firstSuperclassText(typeNode: SyntaxNode): string | null {
+ const superclass = typeNode.childForFieldName('superclass');
+ if (superclass === null) return null;
+ // The superclass node wraps the type_identifier
+ for (let i = 0; i < superclass.namedChildCount; i++) {
+ const child = superclass.namedChild(i);
+ if (child !== null && (child.type === 'type_identifier' || child.type === 'generic_type')) {
+ return child.text;
+ }
+ }
+ return null;
+}
+
+/** Check if a method has the `static` modifier. In tree-sitter-java,
+ * modifiers are grouped under a `modifiers` named child with anonymous
+ * keyword tokens. */
+function isStaticMethod(fnNode: SyntaxNode): boolean {
+ for (let i = 0; i < fnNode.namedChildCount; i++) {
+ const child = fnNode.namedChild(i);
+ if (child !== null && child.type === 'modifiers') {
+ for (let j = 0; j < child.childCount; j++) {
+ const mod = child.child(j);
+ if (mod !== null && mod.text.trim() === 'static') return true;
+ }
+ }
+ }
+ return false;
+}
+
+export function synthesizeJavaReceiverBinding(fnNode: SyntaxNode): CaptureMatch[] {
+ if (!FUNCTION_NODE_TYPES.has(fnNode.type)) return [];
+ if (isStaticMethod(fnNode)) return [];
+
+ const enclosingType = findEnclosingTypeDeclaration(fnNode);
+ if (enclosingType === null) return [];
+
+ const enclosingName = typeName(enclosingType);
+ if (enclosingName === null) return [];
+
+ // Anchor to the method body so the synthesized captures are inside
+ // the function scope.
+ const anchorNode = fnNode.childForFieldName('body');
+ if (anchorNode === null) return [];
+
+ const out: CaptureMatch[] = [];
+ out.push(buildReceiverMatch(anchorNode, 'this', enclosingName));
+
+ // `super` applies only to class/record methods with an explicit superclass.
+ if (enclosingType.type === 'class_declaration' || enclosingType.type === 'record_declaration') {
+ const superText = firstSuperclassText(enclosingType);
+ if (superText !== null) {
+ out.push(buildReceiverMatch(anchorNode, 'super', superText));
+ }
+ }
+
+ return out;
+}
+
+function buildReceiverMatch(anchorNode: SyntaxNode, name: string, typeText: string): CaptureMatch {
+ const m: Record = {
+ '@type-binding.self': nodeToCapture('@type-binding.self', anchorNode),
+ '@type-binding.name': syntheticCapture('@type-binding.name', anchorNode, name),
+ '@type-binding.type': syntheticCapture('@type-binding.type', anchorNode, typeText),
+ };
+ return m;
+}
diff --git a/gitnexus/src/core/ingestion/languages/java/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/java/scope-resolver.ts
new file mode 100644
index 000000000..dac974cc7
--- /dev/null
+++ b/gitnexus/src/core/ingestion/languages/java/scope-resolver.ts
@@ -0,0 +1,97 @@
+/**
+ * Java `ScopeResolver` registered in `SCOPE_RESOLVERS` and consumed by
+ * the generic `runScopeResolution` orchestrator (RFC #909 Ring 3).
+ *
+ * ## Registry-primary parity status
+ *
+ * Java is **not** in `MIGRATED_LANGUAGES` — the scope-resolution
+ * registry runs in shadow mode only. Parity in forced registry mode
+ * (`REGISTRY_PRIMARY_JAVA=1`) is 143/172 (83%). The 29 gaps fall into:
+ *
+ * - switch pattern binding / sealed-class exhaustiveness
+ * - Map.values() / entrySet() iteration type propagation
+ * - assignment / method chain return-type propagation across files
+ * - virtual dispatch / interface default methods
+ *
+ * These are the same category of advanced-resolution gaps seen in prior
+ * migrations (Python, C#, Go). Parity is below the ≥99% flip threshold
+ * per RFC §6.4.
+ *
+ * **CI visibility:** Because Java is absent from `MIGRATED_LANGUAGES`,
+ * the parity CI workflow (`ci-scope-parity.yml`) does not run Java in
+ * either `REGISTRY_PRIMARY_JAVA=0` or `=1` mode. Regressions in forced
+ * mode are only visible via manual `REGISTRY_PRIMARY_JAVA=1 npx vitest
+ * run java.test.ts`. Before flipping Java to registry-primary, a
+ * non-required CI step should be added to run Java tests in forced mode
+ * and report parity as a dashboard input.
+ *
+ * **Parity baseline (29 failures):** The 29 gaps in forced registry mode
+ * are tracked in this PR (#1482) and this JSDoc. If the gap count
+ * changes (up or down), update this baseline accordingly.
+ *
+ * ### Known flip-blockers (must fix before adding to MIGRATED_LANGUAGES)
+ *
+ * - Varargs arity: fixed-prefix count is now preserved, but no
+ * integration fixture exercises the 0-arg rejection path yet.
+ * - Static import resolution: `import static X.Y.m` now correctly
+ * resolves to `X/Y.java` (the class), not `X/Y/m.java` (the member).
+ * Edge cases with nested classes may remain.
+ * - Generic superclass receiver binding: `BaseModel` now strips
+ * to `BaseModel` via JVM type-erasure fallback in `stripGeneric`.
+ * - Wildcard import (`import com.example.*`) file selection is
+ * nondeterministic when multiple classes share a package directory.
+ * May produce wrong-file edges in forced mode.
+ * - Qualified generic type parameters in field/parameter annotations
+ * (`com.example.BaseModel`) — rare in practice but may miss
+ * resolution when the full qualifier is present with generics.
+ */
+
+import type { ParsedFile } from 'gitnexus-shared';
+import { SupportedLanguages } from 'gitnexus-shared';
+import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js';
+import { populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js';
+import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js';
+import { javaProvider } from '../java.js';
+import {
+ javaArityCompatibility,
+ javaMergeBindings,
+ resolveJavaImportTarget,
+ type JavaResolveContext,
+} from './index.js';
+
+const javaScopeResolver: ScopeResolver = {
+ language: SupportedLanguages.Java,
+ languageProvider: javaProvider,
+ importEdgeReason: 'java-scope: import',
+
+ resolveImportTarget: (targetRaw, fromFile, allFilePaths) => {
+ const ws: JavaResolveContext = { fromFile, allFilePaths };
+ return resolveJavaImportTarget(
+ { kind: 'named', localName: '_', importedName: '_', targetRaw },
+ ws,
+ );
+ },
+
+ mergeBindings: (existing, incoming) => [...javaMergeBindings([...existing, ...incoming])],
+
+ arityCompatibility: (callsite, def) => javaArityCompatibility(def, callsite),
+
+ buildMro: (graph, parsedFiles, nodeLookup) =>
+ buildMro(graph, parsedFiles, nodeLookup, defaultLinearize),
+
+ populateOwners: (parsed: ParsedFile) => populateClassOwnedMembers(parsed),
+
+ isSuperReceiver: (text) => text.trim() === 'super',
+
+ // Java is statically typed — field-fallback heuristic stays off
+ fieldFallbackOnMethodLookup: false,
+ propagatesReturnTypesAcrossImports: true,
+
+ // Java doesn't collapse member calls
+ collapseMemberCallsByCallerTarget: false,
+
+ // Hoist return-type bindings to Module scope for cross-file propagation
+ hoistTypeBindingsToModule: true,
+};
+
+export { javaScopeResolver };
diff --git a/gitnexus/src/core/ingestion/languages/java/simple-hooks.ts b/gitnexus/src/core/ingestion/languages/java/simple-hooks.ts
new file mode 100644
index 000000000..e69f768a6
--- /dev/null
+++ b/gitnexus/src/core/ingestion/languages/java/simple-hooks.ts
@@ -0,0 +1,54 @@
+/**
+ * Small hooks for the Java provider. Each is a few lines; they make
+ * the provider's choice explicit rather than relying on defaults.
+ */
+
+import type {
+ CaptureMatch,
+ ParsedImport,
+ Scope,
+ ScopeId,
+ ScopeTree,
+ TypeRef,
+} from 'gitnexus-shared';
+
+// ─── bindingScopeFor ──────────────────────────────────────────────────────
+
+/** Method return-type bindings hoist to Module scope so cross-file
+ * `propagateImportedReturnTypes` and chain-follow can find them. */
+export function javaBindingScopeFor(
+ decl: CaptureMatch,
+ innermost: Scope,
+ tree: ScopeTree,
+): ScopeId | null {
+ if (decl['@type-binding.return'] !== undefined) {
+ let cur: Scope | undefined = innermost;
+ while (cur !== undefined && cur.kind !== 'Module') {
+ const parentId: ScopeId | null = cur.parent ?? null;
+ if (parentId === null) break;
+ cur = tree.getScope(parentId);
+ }
+ if (cur !== undefined && cur.kind === 'Module') return cur.id;
+ }
+ return null;
+}
+
+// ─── importOwningScope ────────────────────────────────────────────────────
+
+/** Java imports are always at compilation-unit (Module) level (JLS §7.5).
+ * Return `null` unconditionally so the default Module scope is used. */
+export function javaImportOwningScope(
+ _imp: ParsedImport,
+ _innermost: Scope,
+ _tree: ScopeTree,
+): ScopeId | null {
+ return null;
+}
+
+// ─── receiverBinding ──────────────────────────────────────────────────────
+
+/** Look up `this` or `super` in the function scope's type bindings. */
+export function javaReceiverBinding(functionScope: Scope): TypeRef | null {
+ if (functionScope.kind !== 'Function') return null;
+ return functionScope.typeBindings.get('this') ?? functionScope.typeBindings.get('super') ?? null;
+}
diff --git a/gitnexus/src/core/ingestion/languages/php.ts b/gitnexus/src/core/ingestion/languages/php.ts
index eb8f296f8..caca85335 100644
--- a/gitnexus/src/core/ingestion/languages/php.ts
+++ b/gitnexus/src/core/ingestion/languages/php.ts
@@ -5,12 +5,22 @@
* and standard export/import resolution. PHP files can use a variety of
* extensions from legacy versions through modern PHP 8.
*/
+import {
+ emitPhpScopeCaptures,
+ interpretPhpImport,
+ interpretPhpTypeBinding,
+ phpArityCompatibility,
+ phpMergeBindings,
+ resolvePhpImportTarget,
+ phpBindingScopeFor,
+ phpImportOwningScope,
+ phpReceiverBinding,
+} from './php/index.js';
import { SupportedLanguages } from 'gitnexus-shared';
import { createClassExtractor } from '../class-extractors/generic.js';
import { phpClassConfig } from '../class-extractors/configs/php.js';
-import { defineLanguage } from '../language-provider.js';
-import type { AstFrameworkPatternConfig } from '../language-provider.js';
+import { defineLanguage, type AstFrameworkPatternConfig } from '../language-provider.js';
import { typeConfig as phpConfig } from '../type-extractors/php.js';
import { phpExportChecker } from '../export-detection.js';
import { createImportResolver } from '../import-resolvers/resolver-factory.js';
@@ -289,4 +299,18 @@ export const phpProvider = defineLanguage({
descriptionExtractor: phpDescriptionExtractor,
isRouteFile: isPhpRouteFile,
builtInNames: BUILT_INS,
+ // ── RFC #909 Ring 3: scope-based resolution hooks ──────────────────────
+ emitScopeCaptures: emitPhpScopeCaptures,
+ interpretImport: interpretPhpImport,
+ interpretTypeBinding: interpretPhpTypeBinding,
+ // LanguageProvider uses (def, callsite); phpArityCompatibility uses (def, callsite) — same.
+ arityCompatibility: phpArityCompatibility,
+ // LanguageProvider adapter: (parsedImport, workspaceIndex) → string | null
+ resolveImportTarget: resolvePhpImportTarget,
+ // mergeBindings on LanguageProvider: (scope, bindings) — ignore scope id,
+ // delegate to phpMergeBindings which uses binding origin tiers.
+ mergeBindings: (_scope, bindings) => [...phpMergeBindings(bindings)],
+ bindingScopeFor: phpBindingScopeFor,
+ importOwningScope: phpImportOwningScope,
+ receiverBinding: phpReceiverBinding,
});
diff --git a/gitnexus/src/core/ingestion/languages/php/arity-metadata.ts b/gitnexus/src/core/ingestion/languages/php/arity-metadata.ts
new file mode 100644
index 000000000..40662bd61
--- /dev/null
+++ b/gitnexus/src/core/ingestion/languages/php/arity-metadata.ts
@@ -0,0 +1,73 @@
+/**
+ * Extract PHP arity metadata from a method-like tree-sitter node —
+ * `method_declaration` or `function_definition`.
+ *
+ * Reuses `phpMethodConfig.extractParameters` so scope-extracted defs
+ * carry the same arity semantics as the legacy parse-worker path:
+ * - `variadic_parameter` (`...$args`) collapses `parameterCount` to
+ * `undefined`, which `phpArityCompatibility` then treats as
+ * "max unknown" — the candidate stays eligible at `argCount >= required`.
+ * - Defaulted parameters (`= expr`) contribute to `optionalCount`;
+ * `requiredParameterCount = total − optionalCount − (variadic ? 1 : 0)`.
+ * The variadic slot itself accepts zero args so it is subtracted from
+ * the required count — `f(int $a, ...$rest)` requires exactly 1 arg,
+ * not 2, and `f(...$rest)` requires 0.
+ * - `property_promotion_parameter` (constructor-promoted) is counted
+ * the same as `simple_parameter` since both consume an argument slot.
+ * - `parameterTypes` collects declared type names; a literal `'...'`
+ * marker is appended for variadic methods so `phpArityCompatibility`
+ * can detect them without re-reading the AST.
+ */
+
+import type { SyntaxNode } from '../../utils/ast-helpers.js';
+import { phpMethodConfig } from '../../method-extractors/configs/php.js';
+
+interface PhpArityMetadata {
+ readonly parameterCount: number | undefined;
+ readonly requiredParameterCount: number | undefined;
+ readonly parameterTypes: readonly string[] | undefined;
+}
+
+export function computePhpArityMetadata(fnNode: SyntaxNode): PhpArityMetadata {
+ const params = phpMethodConfig.extractParameters?.(fnNode) ?? [];
+
+ let hasVariadic = false;
+ let optionalCount = 0;
+ const types: string[] = [];
+
+ for (const p of params) {
+ if (p.isVariadic) {
+ hasVariadic = true;
+ } else if (p.isOptional) {
+ optionalCount++;
+ }
+ if (p.type !== null) types.push(p.type);
+ }
+ // PHP variadic marker convention: append the literal '...' string to
+ // `parameterTypes`. This is intentionally DIFFERENT from C#, which uses
+ // the literal 'params' (its source-language keyword). The shared
+ // `narrowOverloadCandidates` pass in `scope-resolution/passes/overload-
+ // narrowing.ts` checks for the C# 'params' marker — that branch is
+ // dead code for PHP because PHP variadic methods set `parameterCount
+ // = undefined` (see line below), which skips the `max !== undefined`
+ // gate that hosts the 'params' check. PHP's actual variadic-aware
+ // arity logic lives in `phpArityCompatibility` (arity.ts) and now
+ // also in `phpEmitUnresolvedReceiverEdges` (scope-resolver.ts), both
+ // of which check `'...'`. Finding 9 of PR #1497 adversarial review.
+ if (hasVariadic) types.push('...');
+
+ const total = params.length;
+ // Variadic methods accept any arg count ≥ required — leave `parameterCount`
+ // undefined so the registry treats max as unknown.
+ const parameterCount = hasVariadic ? undefined : total;
+ // The variadic slot itself accepts zero args; subtract it from the required
+ // count so PHP's ArgumentCountError-equivalent calls (too few args before
+ // the variadic) are correctly rejected by arity compatibility.
+ const requiredParameterCount = total - optionalCount - (hasVariadic ? 1 : 0);
+
+ return {
+ parameterCount,
+ requiredParameterCount,
+ parameterTypes: types.length > 0 ? types : undefined,
+ };
+}
diff --git a/gitnexus/src/core/ingestion/languages/php/arity.ts b/gitnexus/src/core/ingestion/languages/php/arity.ts
new file mode 100644
index 000000000..5b99a73c7
--- /dev/null
+++ b/gitnexus/src/core/ingestion/languages/php/arity.ts
@@ -0,0 +1,47 @@
+/**
+ * PHP arity check, accommodating variadic (`...$args`) and default parameters.
+ *
+ * The `def` metadata synthesized by `arity-metadata.ts`:
+ * - `parameterCount` — total formal parameters; `undefined` when
+ * the method has a variadic `...$param`.
+ * - `requiredParameterCount` — min required (excludes defaulted params
+ * and the variadic itself).
+ * - `parameterTypes` — declared type strings; contains the
+ * literal `'...'` when the method is variadic.
+ *
+ * Verdicts:
+ * - `'compatible'` — `required <= argCount <= max`, OR the def has
+ * variadic (any `argCount >= required`).
+ * - `'incompatible'` — argCount below required, or above max with no variadic.
+ * - `'unknown'` — metadata absent / incomplete; named-args can satisfy
+ * any arity so we return unknown when we detect them.
+ *
+ * PHP supports named arguments (PHP 8.0+): `save(force: true)`. Named-arg
+ * call sites cannot be arity-checked statically without parsing arg names,
+ * so we return `'unknown'` when the callsite carries named args (signalled
+ * by a negative `arity` value per the shared Callsite contract).
+ */
+
+import type { Callsite, SymbolDefinition } from 'gitnexus-shared';
+
+export function phpArityCompatibility(
+ def: SymbolDefinition,
+ callsite: Callsite,
+): 'compatible' | 'unknown' | 'incompatible' {
+ const max = def.parameterCount;
+ const min = def.requiredParameterCount;
+ if (max === undefined && min === undefined) return 'unknown';
+
+ const argCount = callsite.arity;
+ // Negative arity signals named-argument call sites — can't narrow statically.
+ if (!Number.isFinite(argCount) || argCount < 0) return 'unknown';
+
+ const hasVarArgs =
+ def.parameterTypes !== undefined &&
+ def.parameterTypes.some((t) => t === '...' || t.startsWith('...'));
+
+ if (min !== undefined && argCount < min) return 'incompatible';
+ if (max !== undefined && argCount > max && !hasVarArgs) return 'incompatible';
+
+ return 'compatible';
+}
diff --git a/gitnexus/src/core/ingestion/languages/php/cache-stats.ts b/gitnexus/src/core/ingestion/languages/php/cache-stats.ts
new file mode 100644
index 000000000..508eec580
--- /dev/null
+++ b/gitnexus/src/core/ingestion/languages/php/cache-stats.ts
@@ -0,0 +1,30 @@
+/**
+ * Dev-mode counters for the cross-phase scope-captures parse cache
+ * (PHP mirror of `languages/csharp/cache-stats.ts`).
+ *
+ * Gated by `PROF_SCOPE_RESOLUTION=1`. Production builds fold every
+ * increment into dead code via the module-level `PROF` constant, so
+ * the hot path in `captures.ts` stays branch-free.
+ */
+
+const PROF = process.env.PROF_SCOPE_RESOLUTION === '1';
+
+let CACHE_HITS = 0;
+let CACHE_MISSES = 0;
+
+export function recordCacheHit(): void {
+ if (PROF) CACHE_HITS++;
+}
+
+export function recordCacheMiss(): void {
+ if (PROF) CACHE_MISSES++;
+}
+
+export function getPhpCaptureCacheStats(): { hits: number; misses: number } {
+ return { hits: CACHE_HITS, misses: CACHE_MISSES };
+}
+
+export function resetPhpCaptureCacheStats(): void {
+ CACHE_HITS = 0;
+ CACHE_MISSES = 0;
+}
diff --git a/gitnexus/src/core/ingestion/languages/php/captures.ts b/gitnexus/src/core/ingestion/languages/php/captures.ts
new file mode 100644
index 000000000..692d87bc1
--- /dev/null
+++ b/gitnexus/src/core/ingestion/languages/php/captures.ts
@@ -0,0 +1,806 @@
+/**
+ * `emitScopeCaptures` for PHP (RFC #909 Ring 3 LANG-php).
+ *
+ * Drives the PHP scope query against tree-sitter-php and groups raw
+ * matches into `CaptureMatch[]` for the central extractor. Layers two
+ * synthesized streams on top:
+ *
+ * 1. **Decomposed use declarations** — each `namespace_use_declaration`
+ * is re-emitted with `@import.kind/source/name/alias` markers so
+ * `interpretPhpImport` can recover the ParsedImport shape without
+ * re-parsing raw text. Grouped uses fan out to one match per clause.
+ *
+ * 2. **Receiver-binding synthesis** — `$this` and `parent` type-bindings
+ * are synthesized on every non-static method entry. PHP's grammar
+ * does not express "implicit receiver of a non-static class method"
+ * via a clean `.scm` pattern, so we walk up the AST in code.
+ *
+ * 3. **Arity metadata synthesis** — `@declaration.parameter-count` /
+ * `@declaration.required-parameter-count` / `@declaration.parameter-types`
+ * are synthesized on function-like declarations so the registry can
+ * narrow overloads.
+ *
+ * 4. **PHPDoc synthesis** — @param and @return annotations in comment
+ * nodes preceding method/function declarations are extracted and emitted
+ * as `@type-binding.parameter` and `@type-binding.return` matches.
+ *
+ * 5. **Foreach loop synthesis** — `foreach ($users as $user)` emits
+ * a `@type-binding.alias` match binding the loop variable to the
+ * element type of the iterable (resolved from PHPDoc or scopeEnv).
+ *
+ * Pure given the input source text. No I/O, no globals consulted.
+ */
+
+import type { Capture, CaptureMatch } from 'gitnexus-shared';
+import { findNodeAtRange, nodeToCapture, syntheticCapture } from '../../utils/ast-helpers.js';
+import { splitNamespaceUseDeclaration } from './import-decomposer.js';
+import { computePhpArityMetadata } from './arity-metadata.js';
+import { synthesizePhpReceiverBinding } from './receiver-binding.js';
+import { getPhpParser, getPhpScopeQuery } from './query.js';
+import { recordCacheHit, recordCacheMiss } from './cache-stats.js';
+import { getTreeSitterBufferSize } from '../../constants.js';
+import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
+
+type SyntaxNode = ReturnType['parse']>['rootNode'];
+
+/** Declaration anchors that carry function-like arity metadata. */
+const FUNCTION_DECL_TAGS = ['@declaration.method', '@declaration.function'] as const;
+
+/** tree-sitter-php node types that the method extractor accepts. */
+const FUNCTION_NODE_TYPES = [
+ 'method_declaration',
+ 'function_definition',
+ 'anonymous_function',
+ 'arrow_function',
+] as const;
+
+export function emitPhpScopeCaptures(
+ sourceText: string,
+ _filePath: string,
+ cachedTree?: unknown,
+): readonly CaptureMatch[] {
+ // Skip the parse when the caller already produced a Tree for this source.
+ // The cachedTree parameter is typed as `unknown` at the LanguageProvider
+ // contract layer; cast here at the use site.
+ let tree = cachedTree as ReturnType