Compare commits
64
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb3e653aee | ||
|
|
dfbe68ad24 | ||
|
|
b83c9ccbb9 | ||
|
|
ce17a63961 | ||
|
|
523f3a06a4 | ||
|
|
e4fbfe7559 | ||
|
|
fae3dbade5 | ||
|
|
d74c5dd25b | ||
|
|
e85de16bd6 | ||
|
|
24ad0048a9 | ||
|
|
66522c48c5 | ||
|
|
f8981ed4ff | ||
|
|
d05602112e | ||
|
|
cc7a450e4d | ||
|
|
2376912ca7 | ||
|
|
a4dfebd073 | ||
|
|
42d4fcaf6f | ||
|
|
a26ac55fb0 | ||
|
|
467c14caa2 | ||
|
|
fa06c5610b | ||
|
|
f28185d67e | ||
|
|
f69c382bcb | ||
|
|
83fbd4be26 | ||
|
|
263ca353a6 | ||
|
|
8500f18e5f | ||
|
|
aed370b931 | ||
|
|
99b8c7b03b | ||
|
|
813acd7ec5 | ||
|
|
7fbf302018 | ||
|
|
5ee1122330 | ||
|
|
cdac8a691a | ||
|
|
b00ba2ab47 | ||
|
|
c2193318b5 | ||
|
|
8b2d8018bc | ||
|
|
89c03b2ebb | ||
|
|
911a2ee1e6 | ||
|
|
586dbf7aa1 | ||
|
|
c901ee4666 | ||
|
|
75cb49477e | ||
|
|
e01f0912bc | ||
|
|
e9349ce66a | ||
|
|
a3eef48ce3 | ||
|
|
6229417bd5 | ||
|
|
0566c98b54 | ||
|
|
80acaf052f | ||
|
|
afa38432a4 | ||
|
|
88d3df77cc | ||
|
|
507f84b69a | ||
|
|
38ff7365e8 | ||
|
|
a9d72e2dbf | ||
|
|
4cc4e9c84b | ||
|
|
48cd55a120 | ||
|
|
e8c8ddec8a | ||
|
|
ec4624af87 | ||
|
|
aed6cfc7ea | ||
|
|
6a23616873 | ||
|
|
7637bd1c83 | ||
|
|
8083c39f6d | ||
|
|
a2f1b07700 | ||
|
|
0daae93701 | ||
|
|
4fa40e9881 | ||
|
|
d4f34905bc | ||
|
|
f33efa8714 | ||
|
|
2bf6d078aa |
@@ -11,14 +11,14 @@ permissions:
|
||||
|
||||
# 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
|
||||
# called-workflow context `github.workflow` evaluation is ambiguous across GitHub
|
||||
# Actions versions, and a prefix that could resolve to the caller's name would
|
||||
# share a concurrency group with the caller → deadlock. A literal prefix is
|
||||
# immune. Direct `pull_request` invocations use `CI-<ref>`; invocations from a
|
||||
# reusable-workflow caller fall into a per-run-unique group that never serializes
|
||||
# with the caller. `push` to main is handled by release-candidate.yml, which
|
||||
# calls this workflow once before publishing.
|
||||
# invoked as a reusable workflow from publish.yml. In called-workflow context
|
||||
# `github.workflow` evaluation is ambiguous across GitHub Actions versions, and a
|
||||
# prefix that could resolve to the caller's name would share a concurrency group
|
||||
# with the caller → deadlock. A literal prefix is immune. Direct `pull_request`
|
||||
# invocations use `CI-<ref>`; invocations from a reusable-workflow caller fall
|
||||
# into a per-run-unique group that never serializes with the caller. `push` to
|
||||
# main is handled by publish.yml (RC mode), which calls this workflow once
|
||||
# before publishing.
|
||||
concurrency:
|
||||
group: ${{ github.event_name == 'pull_request' && format('CI-{0}', github.ref) || format('CI-nested-{0}', github.run_id) }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
@@ -158,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 https://github.com/${{ github.repository }}/pull/${{ steps.pr.outputs.number }} --comment'
|
||||
|
||||
@@ -25,6 +25,15 @@ on:
|
||||
a gitnexus/package.json whose version matches the tag.
|
||||
required: true
|
||||
type: string
|
||||
# Explicit secret contract — callers pass these by name. Replaces the
|
||||
# blanket `secrets: inherit` pattern (zizmor `secrets-inherit` audit).
|
||||
# GHCR auth uses the implicit GITHUB_TOKEN; only Docker Hub credentials
|
||||
# need to be passed through.
|
||||
secrets:
|
||||
DOCKERHUB_USERNAME:
|
||||
required: true
|
||||
DOCKERHUB_TOKEN:
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -73,7 +82,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
# Only the workflow_call path requires a non-empty `inputs.tag` — callers
|
||||
# (e.g. release-candidate.yml) must pass the RC tag explicitly. On direct
|
||||
# (publish.yml in RC mode) must pass the RC tag explicitly. On direct
|
||||
# tag pushes the tag comes from `github.ref`, so `inputs.tag` is always
|
||||
# empty and validating it here would break every real release (#1064).
|
||||
# The downstream "Verify tag matches gitnexus/package.json version" step
|
||||
@@ -135,7 +144,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 }}
|
||||
|
||||
+830
-34
@@ -1,62 +1,421 @@
|
||||
name: Publish to npm
|
||||
name: Publish
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Sole publisher for the `gitnexus` npm package, GitHub Releases, and Docker
|
||||
# images. Replaces the former two-workflow design — see issue #1609 for the
|
||||
# double-publish race this unification closes.
|
||||
#
|
||||
# Two release modes, both routed through this file:
|
||||
# • Release candidate (rc) — triggered by push to `main` or workflow_dispatch.
|
||||
# The RC path computes the next rc version, applies it in-CI, pushes a
|
||||
# detached release commit with v<X.Y.Z>-rc.<N> + rc/<SHA> marker
|
||||
# atomically, then publishes to npm with --tag rc and creates a GitHub
|
||||
# prerelease. RC-only docker.yml invocation follows.
|
||||
# • Stable — triggered by push of a v<X.Y.Z> tag (no -rc.*
|
||||
# suffix). Verifies package.json matches the tag, publishes to npm with
|
||||
# --tag latest, creates a stable GitHub Release. No docker (RC-only).
|
||||
#
|
||||
# ⚠️ SELF-TRIGGER INVARIANT — DO NOT WEAKEN ⚠️
|
||||
# The `tags:` filter below uses a negative glob `'!v*-rc.*'` to prevent the
|
||||
# workflow from re-triggering itself when the RC path pushes its own v-tag.
|
||||
# Without this exclusion, every RC publish double-fires (the bug fixed by
|
||||
# #1609). If a NEW prerelease channel is introduced (e.g. `-beta.N`,
|
||||
# `-alpha.N`, `-next.N`), the negative-glob list MUST be extended in
|
||||
# lock-step or self-trigger returns. The same invariant applies to the
|
||||
# `Classify` step further below — its accepted-tag regex must align with
|
||||
# the trigger filter's exclusion list.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- 'docs/**'
|
||||
- 'LICENSE'
|
||||
tags:
|
||||
# Negative-globbed exclusion of RC tags this workflow itself produces
|
||||
# (see the SELF-TRIGGER INVARIANT in the header comment).
|
||||
- 'v*'
|
||||
|
||||
# No workflow-level permissions — scoped per job below.
|
||||
- '!v*-rc.*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
bump:
|
||||
description: >-
|
||||
Cycle policy. 'auto' (default) continues the active rc cycle on
|
||||
this branch if there is one, otherwise bumps patch from latest.
|
||||
Choose 'patch' / 'minor' / 'major' to explicitly start or reset
|
||||
an rc cycle.
|
||||
required: false
|
||||
default: 'auto'
|
||||
type: choice
|
||||
options:
|
||||
- auto
|
||||
- patch
|
||||
- minor
|
||||
- major
|
||||
force:
|
||||
description: 'Publish even when HEAD already has an rc marker'
|
||||
required: false
|
||||
default: 'false'
|
||||
type: choice
|
||||
options:
|
||||
- 'false'
|
||||
- 'true'
|
||||
# Workflow-level deny-all; each job declares the minimum it needs.
|
||||
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
|
||||
# same tag serialize. cancel-in-progress: false — never cancel a publish mid-flight.
|
||||
# Distinct refs (refs/heads/main, refs/tags/v*) run in parallel. The
|
||||
# release-PR-skip in rc-guard is the load-bearing invariant that prevents
|
||||
# an RC main-push and a stable tag-push colliding on the same release commit.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
# ── Phase 1: classify the triggering event into a release mode ─────────────
|
||||
route:
|
||||
name: Classify release event
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 2
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
mode: ${{ steps.classify.outputs.mode }}
|
||||
head_sha: ${{ steps.classify.outputs.head_sha }}
|
||||
bump_input: ${{ inputs.bump }}
|
||||
force_input: ${{ inputs.force }}
|
||||
steps:
|
||||
- name: Classify
|
||||
id: classify
|
||||
shell: bash
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
GH_REF: ${{ github.ref }}
|
||||
GH_REF_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
HEAD_SHA="${GITHUB_SHA}"
|
||||
echo "head_sha=${HEAD_SHA}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Sanitize before logging (annotation-injection defense in depth).
|
||||
REF_SAFE="${GH_REF//::/__}"
|
||||
REF_NAME_SAFE="${GH_REF_NAME//::/__}"
|
||||
echo "event=${EVENT_NAME} ref=${REF_SAFE} ref_name=${REF_NAME_SAFE}"
|
||||
|
||||
MODE=""
|
||||
case "${EVENT_NAME}" in
|
||||
workflow_dispatch)
|
||||
# Manual dispatch is only valid on main — that's the only ref
|
||||
# where a real publish makes sense.
|
||||
if [ "${GH_REF}" = "refs/heads/main" ]; then
|
||||
MODE="rc"
|
||||
else
|
||||
echo "::error::workflow_dispatch is only permitted on refs/heads/main (got ${REF_SAFE})."
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
push)
|
||||
case "${GH_REF}" in
|
||||
refs/heads/main)
|
||||
MODE="rc"
|
||||
;;
|
||||
refs/tags/v*)
|
||||
# The trigger filter already excluded v*-rc.* tags. Anything
|
||||
# reaching here is either a stable semver or a malformed v*.
|
||||
TAG="${GH_REF#refs/tags/}"
|
||||
if [[ "${TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
MODE="stable"
|
||||
else
|
||||
echo "::error::malformed v* tag rejected: ${REF_NAME_SAFE}"
|
||||
echo "::error::stable tags must match ^v[0-9]+\\.[0-9]+\\.[0-9]+\$"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "::error::unexpected push ref ${REF_SAFE} reached publish workflow."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
*)
|
||||
echo "::error::unsupported event ${EVENT_NAME}."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "mode=${MODE}" >> "$GITHUB_OUTPUT"
|
||||
echo "Classified as mode=${MODE}"
|
||||
|
||||
# ── Phase 2 (RC only): dedup marker + release-PR skip ──────────────────────
|
||||
rc-guard:
|
||||
name: RC guard (marker + release-PR skip)
|
||||
needs: route
|
||||
if: needs.route.outputs.mode == 'rc'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
outputs:
|
||||
should_run: ${{ steps.decide.outputs.should_run }}
|
||||
head_sha: ${{ steps.decide.outputs.head_sha }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
# rc-guard reads only — no git pushes from this job. Skip the
|
||||
# default extraheader credential persistence (artipacked audit).
|
||||
persist-credentials: false
|
||||
|
||||
- name: Decide
|
||||
id: decide
|
||||
shell: bash
|
||||
env:
|
||||
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)
|
||||
echo "head_sha=$HEAD_SHA" >> "$GITHUB_OUTPUT"
|
||||
|
||||
if [ "$FORCE" = "true" ]; then
|
||||
echo "Force flag set — running regardless of marker tag."
|
||||
echo "should_run=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Explicit cycle reset on dispatch bypasses dedup.
|
||||
if [ "$EVENT_NAME" = "workflow_dispatch" ] \
|
||||
&& [ -n "${BUMP_INPUT:-}" ] \
|
||||
&& [ "${BUMP_INPUT:-auto}" != "auto" ]; then
|
||||
echo "Explicit bump=$BUMP_INPUT — bypassing marker dedup."
|
||||
echo "should_run=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Skip when the merge commit corresponds to a release ───────────
|
||||
# This skip is load-bearing: it prevents an RC build firing on the
|
||||
# release-PR commit from racing the imminent stable-tag push on the
|
||||
# same SHA. Two complementary checks:
|
||||
# 1. HEAD subject matches `chore: release vX.Y.Z` (the canonical
|
||||
# release-PR title). Anchored to require the bare title or the
|
||||
# squash-merge `(#NNNN)` suffix exactly. Case-insensitive so
|
||||
# `Chore: Release v1.2.3` (IDE auto-capitalization) still
|
||||
# matches — prior commit-author conventions left the door open.
|
||||
# 2. Squash-merged PR carries the `release` label.
|
||||
# Either match suppresses the rc build — stable releases publish on
|
||||
# the v-tag instead.
|
||||
HEAD_SUBJECT="$(git log -1 --pretty=%s HEAD)"
|
||||
# Sanitize GitHub-Actions annotation prefixes before logging — even
|
||||
# though %s strips newlines, a crafted subject containing `::error::`
|
||||
# could forge log annotations.
|
||||
HEAD_SUBJECT_SAFE="${HEAD_SUBJECT//::/__}"
|
||||
RELEASE_SUBJECT_RE='^chore:[[:space:]]*release[[:space:]]+v[0-9]+\.[0-9]+\.[0-9]+([[:space:]]+\(#[0-9]+\))?$'
|
||||
shopt -s nocasematch
|
||||
if [[ "$HEAD_SUBJECT" =~ $RELEASE_SUBJECT_RE ]]; then
|
||||
shopt -u nocasematch
|
||||
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
|
||||
shopt -u nocasematch
|
||||
|
||||
# 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 dedup check.
|
||||
echo "::warning::Could not read labels for PR #${PR_NUM} — falling through."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Dedup: is there already an rc/<HEAD_SHA> marker pointing at HEAD?
|
||||
MARKER="rc/${HEAD_SHA}"
|
||||
if git rev-parse "refs/tags/$MARKER" >/dev/null 2>&1; then
|
||||
echo "HEAD already has marker $MARKER — skipping."
|
||||
echo "should_run=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "No marker on HEAD — proceeding."
|
||||
echo "should_run=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# ── Phase 3: reusable CI gate ──────────────────────────────────────────────
|
||||
# Runs for both rc (when guard says go) and stable. No `secrets:` passed —
|
||||
# ci.yml and its entire reusable-workflow chain (ci-quality, ci-tests,
|
||||
# ci-e2e, ci-scope-parity, ci-report) reference zero `secrets.*` values;
|
||||
# passing any would be unused surface. GITHUB_TOKEN is implicit.
|
||||
ci:
|
||||
needs: [route, rc-guard]
|
||||
if: ${{ always() && (needs.route.outputs.mode == 'stable' || needs.rc-guard.outputs.should_run == 'true') }}
|
||||
uses: ./.github/workflows/ci.yml
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
# No pull-requests:write — `ci.yml`'s save-pr-meta job is gated on
|
||||
# `github.event_name == 'pull_request'`, so it never runs during a
|
||||
# tag-triggered publish. Least-privilege for release-critical paths.
|
||||
|
||||
# ── Phase 4: publish to npm + push refs (RC path) ──────────────────────────
|
||||
# INVARIANT: `timeout-minutes` MUST stay below the App-token TTL (~60 min
|
||||
# for actions/create-github-app-token installation tokens). The atomic
|
||||
# tag-push step relies on the token minted at job start; if the job ever
|
||||
# runs longer than the TTL, the push fails with an opaque 401. If you
|
||||
# need to raise the timeout, re-mint the token immediately before the
|
||||
# `Create and push rc tags` step instead.
|
||||
publish:
|
||||
needs: ci
|
||||
name: Publish to npm
|
||||
needs: [route, rc-guard, ci]
|
||||
if: ${{ always() && needs.ci.result == 'success' && (needs.route.outputs.mode == 'stable' || needs.rc-guard.outputs.should_run == 'true') }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
# contents: write — RC path needs it for `git push --atomic` (v-tag +
|
||||
# marker). Stable path runs in the same job and inherits the grant; it
|
||||
# never invokes `git push`, so the elevated scope is unused there.
|
||||
# id-token: write — npm provenance attestation.
|
||||
contents: write
|
||||
id-token: write
|
||||
outputs:
|
||||
# Two distinct step IDs feed this output; exactly one fires per run.
|
||||
vtag: ${{ steps.rc-tags.outputs.vtag || steps.stable-vtag.outputs.vtag }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
# ── Mint short-lived GitHub App token (RC only) ──────────────────────
|
||||
# Industry direction (2025-2026): GitHub Apps with
|
||||
# `actions/create-github-app-token` over long-lived PATs for
|
||||
# workflow-touching tag pushes. Same fine-grained permission surface,
|
||||
# ~1h expiry, not tied to a user seat, organizationally auditable.
|
||||
# Replaces a prior fine-grained PAT.
|
||||
#
|
||||
# Required secrets (set in repo Settings → Secrets and variables → Actions):
|
||||
# secrets.RELEASE_APP_ID — the App's numeric ID
|
||||
# secrets.RELEASE_APP_PRIVATE_KEY — the App's PEM private key
|
||||
# (The App ID is technically not sensitive — it's visible on the App's
|
||||
# settings page — but storing it as a secret is harmless and avoids
|
||||
# mixing storage classes for the same App.)
|
||||
# The App must be installed on this repository with:
|
||||
# - Contents: write (push the v-tag and rc marker)
|
||||
# - Workflows: write (because the v-tag's tree may touch
|
||||
# .github/workflows/**, which the default
|
||||
# GITHUB_TOKEN cannot author)
|
||||
# - Metadata: read (required for the `gh api /users/<slug>[bot]`
|
||||
# bot-identity lookup in the tag-push step)
|
||||
- name: Mint GitHub App token (RC)
|
||||
if: needs.route.outputs.mode == 'rc'
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
# `client-id` is the renamed input that supersedes the deprecated
|
||||
# `app-id` in v3.x. The action accepts the App's numeric ID or
|
||||
# its Client ID under this name. We pass the numeric App ID,
|
||||
# which the action resolves correctly.
|
||||
client-id: ${{ secrets.RELEASE_APP_ID }}
|
||||
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
|
||||
|
||||
# ── Separate checkout steps per mode ─────────────────────────────────
|
||||
# Conditional `token:` expressions are footguns: empty string passed to
|
||||
# actions/checkout fails opaquely, and `|| github.token` silently
|
||||
# degrades a missing token to GITHUB_TOKEN, masking auth failures until
|
||||
# the eventual `git push`. Two distinct steps make the auth contract
|
||||
# explicit and fail loudly at checkout when the App token mint failed
|
||||
# on the RC path.
|
||||
- name: Checkout (RC)
|
||||
if: needs.route.outputs.mode == 'rc'
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
# Short-lived GitHub App installation token. Required because the
|
||||
# v-tag push lands at a SHA whose tree may touch
|
||||
# `.github/workflows/**`, which the default GITHUB_TOKEN cannot
|
||||
# author.
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
# Do not persist the token in .git/config (artipacked audit). The
|
||||
# RC tag push uses an inline `http.extraheader` at push time only;
|
||||
# the credential never lands on disk. See the
|
||||
# `Create and push rc tags` step below.
|
||||
persist-credentials: false
|
||||
|
||||
- name: Checkout (stable)
|
||||
if: needs.route.outputs.mode == 'stable'
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
# No `token:` — actions/checkout uses GITHUB_TOKEN by default. Stable
|
||||
# path performs no git pushes; the default scope is sufficient.
|
||||
with:
|
||||
# No git pushes from the stable path either. Skip credential
|
||||
# persistence (artipacked audit).
|
||||
persist-credentials: false
|
||||
|
||||
- name: Working-tree sanity
|
||||
# Defense in depth (mirrors the vtag integrity gate, but on the input side):
|
||||
# if a route-mode regression skipped both checkout `if:` gates, all
|
||||
# downstream steps would run on a bare runner and produce confusing
|
||||
# ENOENT errors. Fail loudly and early here instead.
|
||||
shell: bash
|
||||
run: |
|
||||
if [ ! -f gitnexus/package.json ]; then
|
||||
echo "::error::no working tree at gitnexus/package.json — route classification likely failed silently."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
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
|
||||
# packageManager field is present in package.json, so the explicit
|
||||
# opt-out is required to clear the zizmor cache-poisoning audit.
|
||||
# ~30s slower per release; runs rarely.
|
||||
# Node 24 ships with npm >= 11.5.x, which is the minimum that
|
||||
# supports npm Trusted Publishing OIDC. Node 22 ships with npm
|
||||
# 10.9.x (no OIDC) and `npm install -g npm@latest` to self-upgrade
|
||||
# is fragile — it can crash the in-flight reify with
|
||||
# `MODULE_NOT_FOUND` on `promise-retry` etc. Bumping the Node
|
||||
# version is the clean fix; the package's `engines` field is
|
||||
# `>=22.0.0` so consumer-side compatibility is unaffected (this
|
||||
# Node version is only used during publish, not by package users).
|
||||
node-version: 24
|
||||
# `registry-url:` is intentionally OMITTED. Under npm Trusted
|
||||
# Publishing, OIDC only engages when no credential is configured.
|
||||
# Setting `registry-url:` would make setup-node write
|
||||
# `//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}` into the
|
||||
# runner's .npmrc AND export NODE_AUTH_TOKEN from its `token:`
|
||||
# input (default github.token). `npm publish` would then attempt
|
||||
# GITHUB_TOKEN as the npm token, get rejected with 404, and OIDC
|
||||
# would never be tried. See actions/setup-node#1440 and the GitHub
|
||||
# Community discussion #176761 for the upstream bug and consensus
|
||||
# workaround.
|
||||
#
|
||||
# Hermetic install for published artifacts — opt out of the v5+
|
||||
# default packageManager-based caching (clears the zizmor
|
||||
# cache-poisoning audit). ~30s slower per release; runs rarely.
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Build gitnexus-shared
|
||||
run: npm install && npm run build
|
||||
working-directory: gitnexus-shared
|
||||
|
||||
- run: npm ci
|
||||
- name: Install gitnexus dependencies
|
||||
run: npm ci
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Verify version consistency
|
||||
# ── Stable-only: verify the tag and package.json agree ───────────────
|
||||
- name: Verify version consistency (stable)
|
||||
if: needs.route.outputs.mode == 'stable'
|
||||
shell: bash
|
||||
working-directory: gitnexus
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG_VERSION="${GITHUB_REF#refs/tags/v}"
|
||||
if ! [[ "$TAG_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then
|
||||
echo "::error::Tag does not follow semver: v$TAG_VERSION"
|
||||
# Stable mode REJECTS prerelease suffixes — those are filtered at
|
||||
# trigger by the negative-glob filter, but defend at the bash layer too.
|
||||
if ! [[ "$TAG_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "::error::Stable tag must be ^v[0-9]+.[0-9]+.[0-9]+$ — got v$TAG_VERSION"
|
||||
exit 1
|
||||
fi
|
||||
PKG_VERSION=$(node -p "require('./package.json').version")
|
||||
@@ -65,24 +424,376 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
echo "Version verified: $PKG_VERSION"
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Build
|
||||
# ── RC-only: compute the next rc version against the live registry ──
|
||||
- name: Resolve rc version (rc)
|
||||
id: rc-version
|
||||
if: needs.route.outputs.mode == 'rc'
|
||||
shell: bash
|
||||
working-directory: gitnexus
|
||||
env:
|
||||
BUMP_INPUT: ${{ inputs.bump }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
PKG_NAME: gitnexus
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# 1. Current published `latest` — the floor for any new rc base.
|
||||
# Only E404 ("never published") falls back to package.json; any
|
||||
# other error (network, auth, malformed response) fails fast
|
||||
# (retry-loud policy: never silently substitute on transient errors).
|
||||
NPM_STDERR_LATEST="$(mktemp)"
|
||||
if CURRENT_LATEST="$(npm view "$PKG_NAME" version 2>"$NPM_STDERR_LATEST")"; then
|
||||
:
|
||||
else
|
||||
if grep -qiE 'E404|not found' "$NPM_STDERR_LATEST"; then
|
||||
CURRENT_LATEST="$(node -p "require('./package.json').version")"
|
||||
echo "Package not on registry (E404) — seeding from package.json: $CURRENT_LATEST"
|
||||
else
|
||||
echo "::error::npm registry unreachable for 'view version':" >&2
|
||||
cat "$NPM_STDERR_LATEST" >&2
|
||||
rm -f "$NPM_STDERR_LATEST"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
rm -f "$NPM_STDERR_LATEST"
|
||||
CURRENT_LATEST_CLEAN="${CURRENT_LATEST%%-*}"
|
||||
|
||||
# 2. Full version list — needed for the counter and active-cycle
|
||||
# inference. Same E404-only fallback.
|
||||
NPM_STDERR_VERSIONS="$(mktemp)"
|
||||
if VERSIONS_JSON="$(npm view "$PKG_NAME" versions --json 2>"$NPM_STDERR_VERSIONS")"; then
|
||||
:
|
||||
else
|
||||
if grep -qiE 'E404|not found' "$NPM_STDERR_VERSIONS"; then
|
||||
VERSIONS_JSON='[]'
|
||||
echo "No published versions for $PKG_NAME yet (E404)."
|
||||
else
|
||||
echo "::error::npm registry unreachable for 'view versions':" >&2
|
||||
cat "$NPM_STDERR_VERSIONS" >&2
|
||||
rm -f "$NPM_STDERR_VERSIONS"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
rm -f "$NPM_STDERR_VERSIONS"
|
||||
|
||||
# 3. Base selection.
|
||||
# - workflow_dispatch + bump != auto → explicit cycle reset.
|
||||
# - Otherwise (push, or dispatch with bump=auto) → continue the
|
||||
# highest active rc base > latest if any; else patch from latest.
|
||||
# Curated wrapper around `npx semver` — bare npx errors are noisy
|
||||
# and don't distinguish registry-unreachable from invalid-bump-spec.
|
||||
semver_bump() {
|
||||
local kind="$1" current="$2" stderr_file out
|
||||
stderr_file="$(mktemp)"
|
||||
if out="$(npx --yes -p semver@7 semver -i "$kind" "$current" 2>"$stderr_file")"; then
|
||||
rm -f "$stderr_file"
|
||||
printf '%s' "$out"
|
||||
return 0
|
||||
fi
|
||||
echo "::error::semver bump failed (kind=${kind}, current=${current}):" >&2
|
||||
cat "$stderr_file" >&2
|
||||
rm -f "$stderr_file"
|
||||
return 1
|
||||
}
|
||||
|
||||
if [ "$EVENT_NAME" = "workflow_dispatch" ] \
|
||||
&& [ -n "${BUMP_INPUT:-}" ] \
|
||||
&& [ "${BUMP_INPUT:-auto}" != "auto" ]; then
|
||||
BASE="$(semver_bump "$BUMP_INPUT" "$CURRENT_LATEST_CLEAN")"
|
||||
echo "Explicit bump=$BUMP_INPUT → BASE=$BASE"
|
||||
else
|
||||
cat > /tmp/active_base.mjs <<'NODESCRIPT'
|
||||
const latest = process.env.LATEST;
|
||||
let v;
|
||||
try { v = JSON.parse(process.env.VERSIONS_JSON); } catch { v = []; }
|
||||
if (!Array.isArray(v)) v = [v];
|
||||
const parse = s => s.split(".").map(n => parseInt(n, 10));
|
||||
const gt = (a, b) => {
|
||||
const [A, B] = [parse(a), parse(b)];
|
||||
for (let i = 0; i < 3; i++) if (A[i] !== B[i]) return A[i] > B[i];
|
||||
return false;
|
||||
};
|
||||
const bases = new Set();
|
||||
for (const s of v) {
|
||||
const m = /^(\d+\.\d+\.\d+)-rc\.\d+$/.exec(s);
|
||||
if (m && gt(m[1], latest)) bases.add(m[1]);
|
||||
}
|
||||
if (!bases.size) { process.stdout.write(""); process.exit(0); }
|
||||
const sorted = [...bases].sort((a, b) => gt(a, b) ? 1 : -1);
|
||||
process.stdout.write(sorted[sorted.length - 1]);
|
||||
NODESCRIPT
|
||||
ACTIVE_BASE="$(LATEST="$CURRENT_LATEST_CLEAN" VERSIONS_JSON="$VERSIONS_JSON" node /tmp/active_base.mjs)"
|
||||
if [ -n "$ACTIVE_BASE" ]; then
|
||||
BASE="$ACTIVE_BASE"
|
||||
echo "Continuing active rc cycle → BASE=$BASE"
|
||||
else
|
||||
BASE="$(semver_bump patch "$CURRENT_LATEST_CLEAN")"
|
||||
echo "No active rc cycle → patch bump from latest → BASE=$BASE"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 4. Counter: 1 + max existing N for `${BASE}-rc.*`, else 1.
|
||||
cat > /tmp/next_rc.mjs <<'NODESCRIPT'
|
||||
const base = process.env.BASE;
|
||||
const prefix = base + "-rc.";
|
||||
let v;
|
||||
try { v = JSON.parse(process.env.VERSIONS_JSON); } catch { v = []; }
|
||||
if (!Array.isArray(v)) v = [v];
|
||||
const ns = v
|
||||
.filter(s => typeof s === "string" && s.startsWith(prefix))
|
||||
.map(s => parseInt(s.slice(prefix.length), 10))
|
||||
.filter(n => Number.isInteger(n) && n >= 0);
|
||||
process.stdout.write(String(ns.length ? Math.max(...ns) + 1 : 1));
|
||||
NODESCRIPT
|
||||
NEXT_N="$(BASE="$BASE" VERSIONS_JSON="$VERSIONS_JSON" node /tmp/next_rc.mjs)"
|
||||
RC_VERSION="${BASE}-rc.${NEXT_N}"
|
||||
echo "Computed rc: $RC_VERSION"
|
||||
|
||||
# 5. Defensive: if the exact version already exists on the registry
|
||||
# (race with another run), abort before re-publishing.
|
||||
NPM_STDERR_EXISTS="$(mktemp)"
|
||||
if npm view "$PKG_NAME@$RC_VERSION" version 2>"$NPM_STDERR_EXISTS" >/dev/null; then
|
||||
rm -f "$NPM_STDERR_EXISTS"
|
||||
echo "::error::Version $RC_VERSION already exists on npm — aborting."
|
||||
exit 1
|
||||
else
|
||||
if grep -qiE 'E404|not found' "$NPM_STDERR_EXISTS"; then
|
||||
rm -f "$NPM_STDERR_EXISTS"
|
||||
# Version doesn't exist — safe to proceed.
|
||||
else
|
||||
echo "::error::npm registry unreachable for existence check:" >&2
|
||||
cat "$NPM_STDERR_EXISTS" >&2
|
||||
rm -f "$NPM_STDERR_EXISTS"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
{
|
||||
echo "base=$BASE"
|
||||
echo "rc_n=$NEXT_N"
|
||||
echo "rc_version=$RC_VERSION"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Apply rc version in-CI
|
||||
if: needs.route.outputs.mode == 'rc'
|
||||
shell: bash
|
||||
working-directory: gitnexus
|
||||
run: |
|
||||
set -euo pipefail
|
||||
npm version "${{ steps.rc-version.outputs.rc_version }}" \
|
||||
--no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Build gitnexus
|
||||
run: npm run build
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Dry-run publish
|
||||
run: npm publish --dry-run
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Publish to npm
|
||||
run: npm publish --provenance --access public
|
||||
# Cheap verification that the tarball assembles before the real publish.
|
||||
shell: bash
|
||||
working-directory: gitnexus
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
NPM_TAG: ${{ needs.route.outputs.mode == 'rc' && 'rc' || 'latest' }}
|
||||
run: npm publish --dry-run --tag "$NPM_TAG"
|
||||
|
||||
- name: Extract release notes from CHANGELOG
|
||||
# ── Acquire the "rc lock" BEFORE publishing (idempotency anchor) ─────
|
||||
# We create two refs and push atomically:
|
||||
# v<RC_VERSION> → annotated tag on a detached release commit whose
|
||||
# tree contains the rewritten package.json, so the
|
||||
# tag's source matches the npm tarball.
|
||||
# rc/<HEAD_SHA> → lightweight tag on HEAD; the guard's dedup key.
|
||||
# Push fails → nothing published. Push succeeds, npm fails → marker
|
||||
# blocks retries until manual cleanup (see Rollback Runbook in plan).
|
||||
- name: Create and push rc tags
|
||||
id: rc-tags
|
||||
if: needs.route.outputs.mode == 'rc'
|
||||
shell: bash
|
||||
working-directory: gitnexus
|
||||
env:
|
||||
RC_VERSION: ${{ steps.rc-version.outputs.rc_version }}
|
||||
HEAD_SHA: ${{ needs.rc-guard.outputs.head_sha }}
|
||||
# Short-lived GitHub App token. Auth is supplied inline at push
|
||||
# time via `http.extraheader` (per GitHub's documented
|
||||
# x-access-token Basic pattern). It is NOT persisted in
|
||||
# .git/config (artipacked audit) — checkout above ran with
|
||||
# `persist-credentials: false`.
|
||||
PUSH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
# App's slug from create-github-app-token (e.g. `gitnexus-release-bot`).
|
||||
# Used to attribute the release commit to the App identity rather
|
||||
# than the generic github-actions[bot]. The bot's numeric user-id
|
||||
# is resolved at runtime via the GitHub API (the action does not
|
||||
# expose it directly as of v3.2.0).
|
||||
APP_SLUG: ${{ steps.app-token.outputs.app-slug }}
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VTAG="v${RC_VERSION}"
|
||||
MARKER="rc/${HEAD_SHA}"
|
||||
|
||||
# Resolve the App's bot user-id and construct the noreply email
|
||||
# in the GitHub-canonical `<id>+<slug>[bot]@users.noreply.github.com`
|
||||
# shape. `[bot]` is part of the actual login on GitHub.
|
||||
#
|
||||
# The lookup is wrapped in a bounded retry because the first RC
|
||||
# after App installation may hit propagation delay (404), and
|
||||
# transient api.github.com 5xx during heavy org activity is a real
|
||||
# failure class. Without retry, every transient blip aborts the
|
||||
# entire release after CI has already succeeded.
|
||||
BOT_LOGIN="${APP_SLUG}[bot]"
|
||||
BOT_USER_ID=""
|
||||
api_stderr="$(mktemp)"
|
||||
for attempt in 1 2 3; do
|
||||
if BOT_USER_ID="$(gh api "/users/${BOT_LOGIN}" --jq .id 2>"$api_stderr")" \
|
||||
&& [[ "${BOT_USER_ID}" =~ ^[0-9]+$ ]]; then
|
||||
break
|
||||
fi
|
||||
BOT_USER_ID=""
|
||||
if [ "$attempt" -lt 3 ]; then
|
||||
echo "::warning::bot user-id lookup attempt ${attempt} failed; retrying in $((attempt * 5))s"
|
||||
sleep $((attempt * 5))
|
||||
fi
|
||||
done
|
||||
if ! [[ "${BOT_USER_ID}" =~ ^[0-9]+$ ]]; then
|
||||
echo "::error::Could not resolve bot user-id for ${BOT_LOGIN} after 3 attempts."
|
||||
echo "::error::gh api stderr:"
|
||||
cat "$api_stderr" >&2 || true
|
||||
echo "::error::Common causes: (a) newly-installed App — user record still propagating to /users/ (wait ~5min, redispatch with force=true); (b) App lacks Metadata: read permission; (c) transient api.github.com 5xx (redispatch)."
|
||||
rm -f "$api_stderr"
|
||||
exit 1
|
||||
fi
|
||||
rm -f "$api_stderr"
|
||||
git config user.name "${BOT_LOGIN}"
|
||||
git config user.email "${BOT_USER_ID}+${BOT_LOGIN}@users.noreply.github.com"
|
||||
|
||||
# Detached release commit with the version bump — main stays
|
||||
# pristine, but the v-tag's tree matches the published package
|
||||
# exactly (release-integrity).
|
||||
git add package.json package-lock.json 2>/dev/null || git add package.json
|
||||
git commit -m "release: ${VTAG}" --allow-empty
|
||||
RELEASE_SHA="$(git rev-parse HEAD)"
|
||||
echo "Detached release commit: $RELEASE_SHA"
|
||||
|
||||
git tag -a "$VTAG" "$RELEASE_SHA" -m "$VTAG"
|
||||
git tag "$MARKER" "$HEAD_SHA"
|
||||
|
||||
# Inline auth header. The base64-encoded form is masked as well
|
||||
# as the raw token, because GitHub's secret-masker only masks the
|
||||
# raw value — any subsequent `set -x` / GIT_TRACE line would
|
||||
# otherwise expose the encoded credential.
|
||||
#
|
||||
# `set +x` wraps the compute+mask pair so that if an operator
|
||||
# enables ACTIONS_STEP_DEBUG=true for triage (which turns on
|
||||
# `set -x` globally), the assignment is NOT traced for the one
|
||||
# line between compute and mask-registration. Without this wrap,
|
||||
# debug mode would log `+ auth_header='Authorization: Basic <encoded>'`
|
||||
# exposing a still-valid (~1h) App token.
|
||||
{ set +x; } 2>/dev/null
|
||||
auth_header="Authorization: Basic $(printf 'x-access-token:%s' "${PUSH_TOKEN}" | base64 -w0)"
|
||||
echo "::add-mask::${auth_header}"
|
||||
# Re-enable tracing only when explicitly requested via step-debug.
|
||||
if [ "${ACTIONS_STEP_DEBUG:-false}" = "true" ]; then set -x; fi
|
||||
|
||||
# Atomic push of both refs. If either would clobber an existing
|
||||
# remote ref, the push fails and we stop before npm publish.
|
||||
git -c http.extraheader="${auth_header}" \
|
||||
push --atomic origin "refs/tags/$VTAG" "refs/tags/$MARKER"
|
||||
|
||||
{
|
||||
echo "vtag=$VTAG"
|
||||
echo "marker=$MARKER"
|
||||
echo "release_sha=$RELEASE_SHA"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set vtag (stable)
|
||||
id: stable-vtag
|
||||
if: needs.route.outputs.mode == 'stable'
|
||||
shell: bash
|
||||
# github.ref_name flows in via env to avoid templating into the
|
||||
# shell source (template-injection audit). Even though refs are
|
||||
# constrained by git naming rules, the env-passthrough pattern
|
||||
# makes injection structurally impossible.
|
||||
env:
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
echo "vtag=${REF_NAME}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# ── vtag integrity gate ──────────────────────────────────────────────
|
||||
# Fail closed before any artifact-producing step (npm publish, Release,
|
||||
# Docker) runs against an empty or mode-mismatched vtag. Prevents the
|
||||
# silent "Release named main" / "Docker tagged from ref fallback"
|
||||
# failure modes that the previous draft was vulnerable to.
|
||||
- name: vtag integrity gate
|
||||
id: vtag-gate
|
||||
shell: bash
|
||||
env:
|
||||
MODE: ${{ needs.route.outputs.mode }}
|
||||
VTAG: ${{ steps.rc-tags.outputs.vtag || steps.stable-vtag.outputs.vtag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [ -z "$VTAG" ]; then
|
||||
echo "::error::vtag is empty — refusing to create GitHub Release or trigger Docker."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "$MODE" in
|
||||
rc)
|
||||
if ! [[ "$VTAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then
|
||||
echo "::error::vtag '${VTAG}' does not match rc shape ^v[0-9]+.[0-9]+.[0-9]+-rc.[0-9]+$"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
stable)
|
||||
if ! [[ "$VTAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "::error::vtag '${VTAG}' does not match stable shape ^v[0-9]+.[0-9]+.[0-9]+$"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "::error::unknown mode '${MODE}' at vtag integrity gate."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "vtag verified: ${VTAG} (mode=${MODE})"
|
||||
echo "vtag=${VTAG}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# npm Trusted Publishing (GA'd 2025-07-31). OIDC authentication only
|
||||
# engages when no npm credential is configured anywhere — the absence
|
||||
# is the signal. Two upstream behaviors had to be neutralized for
|
||||
# this to work:
|
||||
#
|
||||
# 1. setup-node's `registry-url:` is omitted (see the setup-node
|
||||
# step above). With it, setup-node writes
|
||||
# `//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}` into
|
||||
# .npmrc and exports NODE_AUTH_TOKEN from `token:` (defaulting
|
||||
# to github.token). npm publish then sends GITHUB_TOKEN as the
|
||||
# bearer credential and the registry returns 404. OIDC is never
|
||||
# tried because npm thinks it already has a credential.
|
||||
# 2. The runner's bundled npm (10.9.x on Node 22) has no OIDC
|
||||
# support; the upgrade step above pins it to >= 11.5.1.
|
||||
#
|
||||
# Provenance is auto-attached by the registry on trusted-publisher
|
||||
# publishes — no --provenance flag needed.
|
||||
#
|
||||
# Prerequisite: register the package as a trusted publisher at
|
||||
# https://www.npmjs.com/package/gitnexus/access (Publishing access →
|
||||
# Trusted Publishers → GitHub Actions):
|
||||
# Owner: abhigyanpatwari
|
||||
# Repository: GitNexus
|
||||
# Workflow: publish.yml
|
||||
# Environment: (none)
|
||||
- name: Publish to npm
|
||||
shell: bash
|
||||
working-directory: gitnexus
|
||||
env:
|
||||
NPM_TAG: ${{ needs.route.outputs.mode == 'rc' && 'rc' || 'latest' }}
|
||||
run: npm publish --access public --tag "$NPM_TAG"
|
||||
|
||||
# ── Stable-only: pull CHANGELOG body if present ──────────────────────
|
||||
- name: Extract release notes from CHANGELOG (stable)
|
||||
id: changelog
|
||||
if: needs.route.outputs.mode == 'stable'
|
||||
shell: bash
|
||||
run: |
|
||||
VERSION="${GITHUB_REF#refs/tags/v}"
|
||||
@@ -98,5 +809,90 @@ jobs:
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v2
|
||||
with:
|
||||
body_path: ${{ steps.changelog.outputs.fallback == 'false' && '/tmp/release-notes.md' || '' }}
|
||||
generate_release_notes: ${{ steps.changelog.outputs.fallback == 'true' }}
|
||||
tag_name: ${{ steps.vtag-gate.outputs.vtag }}
|
||||
name: >-
|
||||
${{ needs.route.outputs.mode == 'rc'
|
||||
&& format('Release Candidate {0}', steps.vtag-gate.outputs.vtag)
|
||||
|| steps.vtag-gate.outputs.vtag }}
|
||||
prerelease: ${{ needs.route.outputs.mode == 'rc' }}
|
||||
make_latest: ${{ needs.route.outputs.mode == 'stable' && 'true' || 'false' }}
|
||||
# Stable: prefer CHANGELOG body, fall back to auto-generated.
|
||||
# RC: always auto-generated + the prerelease body block below.
|
||||
body_path: >-
|
||||
${{ needs.route.outputs.mode == 'stable' && steps.changelog.outputs.fallback == 'false'
|
||||
&& '/tmp/release-notes.md' || '' }}
|
||||
generate_release_notes: >-
|
||||
${{ needs.route.outputs.mode == 'rc'
|
||||
|| steps.changelog.outputs.fallback == 'true' }}
|
||||
body: >-
|
||||
${{ needs.route.outputs.mode == 'rc' && format(
|
||||
'Automated release candidate build from `main`.{0}{0}**npm:** `npm install gitnexus@rc`{0}**Version:** `{1}`{0}**Target base:** `{2}` (rc #{3}){0}**Source commit (main):** {4}{0}**Release commit (versioned tree):** {5}{0}{0}Release candidates are pre-stable builds intended for early testing. Stable releases remain on the `latest` dist-tag.',
|
||||
'\n',
|
||||
steps.rc-version.outputs.rc_version,
|
||||
steps.rc-version.outputs.base,
|
||||
steps.rc-version.outputs.rc_n,
|
||||
needs.rc-guard.outputs.head_sha,
|
||||
steps.rc-tags.outputs.release_sha
|
||||
) || '' }}
|
||||
|
||||
# ── RC partial-failure cleanup ───────────────────────────────────────
|
||||
# If anything after the atomic tag-push step failed (npm publish
|
||||
# blew up, GitHub Release call timed out, etc.), the v-tag and
|
||||
# rc/<SHA> marker are already on origin. External consumers
|
||||
# (Renovate, Dependabot, Releases RSS) can ingest a phantom tag for
|
||||
# a version that was never published to npm. This step deletes them
|
||||
# automatically so the operator's recovery is just "redispatch with
|
||||
# force=true on the next commit", not a manual ref cleanup.
|
||||
#
|
||||
# Scoped strictly to RC + real (non-dry-run) + the rc-tags step
|
||||
# actually produced a vtag (otherwise nothing to clean up). The
|
||||
# App token is still valid (~1h TTL, job timeout 20min).
|
||||
- name: Cleanup pushed tags on partial failure
|
||||
if: ${{ failure() && needs.route.outputs.mode == 'rc' && steps.rc-tags.outputs.vtag != '' }}
|
||||
shell: bash
|
||||
working-directory: gitnexus
|
||||
env:
|
||||
VTAG: ${{ steps.rc-tags.outputs.vtag }}
|
||||
MARKER: ${{ steps.rc-tags.outputs.marker }}
|
||||
PUSH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
echo "::warning::Publish step failed after tag push. Cleaning up remote refs to prevent phantom-version ingestion by downstream consumers."
|
||||
|
||||
{ set +x; } 2>/dev/null
|
||||
auth_header="Authorization: Basic $(printf 'x-access-token:%s' "${PUSH_TOKEN}" | base64 -w0)"
|
||||
echo "::add-mask::${auth_header}"
|
||||
if [ "${ACTIONS_STEP_DEBUG:-false}" = "true" ]; then set -x; fi
|
||||
|
||||
# Delete v-tag and marker. Each delete is best-effort — if one
|
||||
# is already absent (atomic push partially rejected, or earlier
|
||||
# cleanup ran), the other still gets attempted.
|
||||
for ref in "refs/tags/${VTAG}" "refs/tags/${MARKER}"; do
|
||||
if git -c http.extraheader="${auth_header}" push origin --delete "${ref}" 2>&1; then
|
||||
echo "deleted origin ${ref}"
|
||||
else
|
||||
echo "::warning::could not delete origin ${ref} — may already be absent or protected. Manual cleanup may be required."
|
||||
fi
|
||||
done
|
||||
|
||||
echo "::notice::Cleanup complete. To retry the release, redispatch the workflow with force=true on the same SHA, or push a new commit to main."
|
||||
|
||||
# ── Phase 5 (RC only): Docker images ───────────────────────────────────────
|
||||
# R6: Docker remains RC-only. Stable Docker builds are explicitly deferred.
|
||||
# Secrets are passed explicitly (not via `secrets: inherit`) so the
|
||||
# callee's secret surface is auditable from the caller's source.
|
||||
docker:
|
||||
name: Build & Push RC Docker images
|
||||
needs: [route, publish]
|
||||
if: ${{ needs.route.outputs.mode == 'rc' && needs.publish.outputs.vtag != '' }}
|
||||
uses: ./.github/workflows/docker.yml
|
||||
secrets:
|
||||
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
attestations: write
|
||||
with:
|
||||
tag: ${{ needs.publish.outputs.vtag }}
|
||||
|
||||
@@ -1,459 +0,0 @@
|
||||
name: Release Candidate
|
||||
|
||||
on:
|
||||
# Publish a release-candidate build whenever a merge/commit lands on main.
|
||||
# Docs/README-only changes are filtered out so prose updates don't
|
||||
# cut a release.
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- 'docs/**'
|
||||
- 'LICENSE'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
bump:
|
||||
description: >-
|
||||
Cycle policy. 'auto' (default) continues the active rc cycle on
|
||||
this branch if there is one, otherwise bumps patch from latest.
|
||||
Choose 'patch' / 'minor' / 'major' to explicitly start or reset
|
||||
an rc cycle.
|
||||
required: false
|
||||
default: 'auto'
|
||||
type: choice
|
||||
options:
|
||||
- auto
|
||||
- patch
|
||||
- minor
|
||||
- major
|
||||
force:
|
||||
description: 'Publish even when HEAD already has an rc marker'
|
||||
required: false
|
||||
default: 'false'
|
||||
type: choice
|
||||
options:
|
||||
- 'false'
|
||||
- 'true'
|
||||
|
||||
# No workflow-level permissions — scoped per job below.
|
||||
permissions: {}
|
||||
|
||||
# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention".
|
||||
# Serialize all runs on the same ref (push + workflow_dispatch) to prevent two publishes
|
||||
# racing on the rc counter. cancel-in-progress: false — the earlier merge publishes first.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
# ── Skip when HEAD already has an rc marker (retry / duplicate dispatch) ──
|
||||
# The marker is a lightweight tag `rc/<HEAD_SHA>` pushed *before* `npm
|
||||
# publish`, so a failed publish leaves the marker in place and the guard
|
||||
# refuses to re-publish. Recovery path after a partial failure:
|
||||
# git push --delete origin rc/<HEAD_SHA> v<RC_VERSION>
|
||||
# then redispatch with force=true.
|
||||
guard:
|
||||
name: Check if release candidate should run
|
||||
runs-on: ubuntu-latest
|
||||
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 }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- name: Decide
|
||||
id: decide
|
||||
shell: bash
|
||||
env:
|
||||
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)
|
||||
echo "head_sha=$HEAD_SHA" >> "$GITHUB_OUTPUT"
|
||||
|
||||
if [ "$FORCE" = "true" ]; then
|
||||
echo "Force flag set — running regardless of marker tag."
|
||||
echo "should_run=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# An explicit cycle reset on dispatch (bump != auto) also bypasses
|
||||
# the dedup guard — the maintainer is deliberately asking for a
|
||||
# new rc from the same commit.
|
||||
if [ "$EVENT_NAME" = "workflow_dispatch" ] \
|
||||
&& [ -n "${BUMP_INPUT:-}" ] \
|
||||
&& [ "${BUMP_INPUT:-auto}" != "auto" ]; then
|
||||
echo "Explicit bump=$BUMP_INPUT — bypassing marker dedup."
|
||||
echo "should_run=true" >> "$GITHUB_OUTPUT"
|
||||
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/<HEAD_SHA> marker pointing at HEAD?
|
||||
MARKER="rc/${HEAD_SHA}"
|
||||
if git rev-parse "refs/tags/$MARKER" >/dev/null 2>&1; then
|
||||
echo "HEAD already has marker $MARKER — skipping."
|
||||
echo "should_run=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "No marker on HEAD — proceeding."
|
||||
echo "should_run=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# ── Reuse the stable CI workflow ─────────────────────────────────────
|
||||
ci:
|
||||
needs: guard
|
||||
if: needs.guard.outputs.should_run == 'true'
|
||||
uses: ./.github/workflows/ci.yml
|
||||
permissions:
|
||||
contents: read
|
||||
secrets: inherit
|
||||
|
||||
# ── Publish the rc build to npm + create GitHub prerelease ───────────
|
||||
publish:
|
||||
name: Publish release candidate to npm
|
||||
needs: [guard, ci]
|
||||
if: needs.guard.outputs.should_run == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
# The default GITHUB_TOKEN cannot be granted `workflows: write`, so
|
||||
# tag pushes that reach a commit which modified `.github/workflows/**`
|
||||
# are rejected with: "refusing to allow a GitHub App to create or
|
||||
# update workflow ... without `workflows` permission". We pass a
|
||||
# fine-grained PAT (RELEASE_PUSH_TOKEN, scoped to this repo with
|
||||
# Contents: write + Workflows: write) to `actions/checkout` so that
|
||||
# the subsequent `git push --atomic` of the v-tag and rc marker
|
||||
# carries the PAT's identity. Job-level GITHUB_TOKEN keeps its
|
||||
# scoped permissions for everything else (npm provenance, etc.).
|
||||
contents: write # push rc tag + marker (via PAT)
|
||||
id-token: write # npm provenance
|
||||
outputs:
|
||||
vtag: ${{ steps.reltag.outputs.vtag }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
# Use the PAT so `origin` is preauthed for `git push`. Without
|
||||
# this the default GITHUB_TOKEN is wired into the remote, and a
|
||||
# workflows-touching tag push is rejected — see the permissions
|
||||
# block above.
|
||||
token: ${{ secrets.RELEASE_PUSH_TOKEN }}
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
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
|
||||
# present in package.json; explicit opt-out is required to clear
|
||||
# the zizmor cache-poisoning audit. See cache-poisoning audit.
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Build gitnexus-shared
|
||||
run: npm install && npm run build
|
||||
working-directory: gitnexus-shared
|
||||
|
||||
- name: Install gitnexus dependencies
|
||||
run: npm ci
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Resolve rc version
|
||||
id: version
|
||||
shell: bash
|
||||
working-directory: gitnexus
|
||||
env:
|
||||
BUMP_INPUT: ${{ inputs.bump }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
PKG_NAME: gitnexus
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# 1. Current published `latest` — the floor for any new rc base.
|
||||
# Only E404 ("never published") falls back to package.json; any
|
||||
# other error (network, auth, malformed response) fails fast.
|
||||
NPM_STDERR_LATEST="$(mktemp)"
|
||||
if CURRENT_LATEST="$(npm view "$PKG_NAME" version 2>"$NPM_STDERR_LATEST")"; then
|
||||
:
|
||||
else
|
||||
if grep -q 'E404' "$NPM_STDERR_LATEST"; then
|
||||
CURRENT_LATEST="$(node -p "require('./package.json').version")"
|
||||
echo "Package not on registry (E404) — seeding from package.json: $CURRENT_LATEST"
|
||||
else
|
||||
echo "::error::npm registry unreachable for 'view version':" >&2
|
||||
cat "$NPM_STDERR_LATEST" >&2
|
||||
rm -f "$NPM_STDERR_LATEST"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
rm -f "$NPM_STDERR_LATEST"
|
||||
CURRENT_LATEST_CLEAN="${CURRENT_LATEST%%-*}"
|
||||
|
||||
# 2. Full version list — needed for the counter and for active-cycle
|
||||
# inference. Same E404-only fallback.
|
||||
NPM_STDERR_VERSIONS="$(mktemp)"
|
||||
if VERSIONS_JSON="$(npm view "$PKG_NAME" versions --json 2>"$NPM_STDERR_VERSIONS")"; then
|
||||
:
|
||||
else
|
||||
if grep -q 'E404' "$NPM_STDERR_VERSIONS"; then
|
||||
VERSIONS_JSON='[]'
|
||||
echo "No published versions for $PKG_NAME yet (E404)."
|
||||
else
|
||||
echo "::error::npm registry unreachable for 'view versions':" >&2
|
||||
cat "$NPM_STDERR_VERSIONS" >&2
|
||||
rm -f "$NPM_STDERR_VERSIONS"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
rm -f "$NPM_STDERR_VERSIONS"
|
||||
|
||||
# 3. Base selection.
|
||||
# - workflow_dispatch + bump ∈ {patch,minor,major} → explicit cycle
|
||||
# reset from latest.
|
||||
# - Everything else (push, or dispatch with bump=auto) → continue
|
||||
# the highest active rc base > latest if one exists; else
|
||||
# default to patch from latest.
|
||||
if [ "$EVENT_NAME" = "workflow_dispatch" ] \
|
||||
&& [ -n "${BUMP_INPUT:-}" ] \
|
||||
&& [ "${BUMP_INPUT:-auto}" != "auto" ]; then
|
||||
BASE="$(npx --yes -p semver@7 semver -i "$BUMP_INPUT" "$CURRENT_LATEST_CLEAN")"
|
||||
echo "Explicit bump=$BUMP_INPUT → BASE=$BASE"
|
||||
else
|
||||
cat > /tmp/active_base.mjs <<'NODESCRIPT'
|
||||
const latest = process.env.LATEST;
|
||||
let v;
|
||||
try { v = JSON.parse(process.env.VERSIONS_JSON); } catch { v = []; }
|
||||
if (!Array.isArray(v)) v = [v];
|
||||
const parse = s => s.split(".").map(n => parseInt(n, 10));
|
||||
const gt = (a, b) => {
|
||||
const [A, B] = [parse(a), parse(b)];
|
||||
for (let i = 0; i < 3; i++) if (A[i] !== B[i]) return A[i] > B[i];
|
||||
return false;
|
||||
};
|
||||
const bases = new Set();
|
||||
for (const s of v) {
|
||||
const m = /^(\d+\.\d+\.\d+)-rc\.\d+$/.exec(s);
|
||||
if (m && gt(m[1], latest)) bases.add(m[1]);
|
||||
}
|
||||
if (!bases.size) { process.stdout.write(""); process.exit(0); }
|
||||
const sorted = [...bases].sort((a, b) => gt(a, b) ? 1 : -1);
|
||||
process.stdout.write(sorted[sorted.length - 1]);
|
||||
NODESCRIPT
|
||||
ACTIVE_BASE="$(LATEST="$CURRENT_LATEST_CLEAN" VERSIONS_JSON="$VERSIONS_JSON" node /tmp/active_base.mjs)"
|
||||
if [ -n "$ACTIVE_BASE" ]; then
|
||||
BASE="$ACTIVE_BASE"
|
||||
echo "Continuing active rc cycle → BASE=$BASE"
|
||||
else
|
||||
BASE="$(npx --yes -p semver@7 semver -i patch "$CURRENT_LATEST_CLEAN")"
|
||||
echo "No active rc cycle → patch bump from latest → BASE=$BASE"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 4. Counter: 1 + max existing N for `${BASE}-rc.*`, else 1.
|
||||
cat > /tmp/next_rc.mjs <<'NODESCRIPT'
|
||||
const base = process.env.BASE;
|
||||
const prefix = base + "-rc.";
|
||||
let v;
|
||||
try { v = JSON.parse(process.env.VERSIONS_JSON); } catch { v = []; }
|
||||
if (!Array.isArray(v)) v = [v];
|
||||
const ns = v
|
||||
.filter(s => typeof s === "string" && s.startsWith(prefix))
|
||||
.map(s => parseInt(s.slice(prefix.length), 10))
|
||||
.filter(n => Number.isInteger(n) && n >= 0);
|
||||
process.stdout.write(String(ns.length ? Math.max(...ns) + 1 : 1));
|
||||
NODESCRIPT
|
||||
NEXT_N="$(BASE="$BASE" VERSIONS_JSON="$VERSIONS_JSON" node /tmp/next_rc.mjs)"
|
||||
RC_VERSION="${BASE}-rc.${NEXT_N}"
|
||||
echo "Computed rc: $RC_VERSION"
|
||||
|
||||
# 5. Defensive: if the exact version already exists on the registry
|
||||
# (e.g., race with another run), abort before re-publishing.
|
||||
# Same E404-only pattern used above — a transient network
|
||||
# failure must fail loudly, not pretend the version is missing.
|
||||
NPM_STDERR_EXISTS="$(mktemp)"
|
||||
if npm view "$PKG_NAME@$RC_VERSION" version 2>"$NPM_STDERR_EXISTS" >/dev/null; then
|
||||
rm -f "$NPM_STDERR_EXISTS"
|
||||
echo "::error::Version $RC_VERSION already exists on npm — aborting."
|
||||
exit 1
|
||||
else
|
||||
if grep -qiE 'E404|not found' "$NPM_STDERR_EXISTS"; then
|
||||
rm -f "$NPM_STDERR_EXISTS"
|
||||
# Version doesn't exist — safe to proceed.
|
||||
else
|
||||
echo "::error::npm registry unreachable for existence check:" >&2
|
||||
cat "$NPM_STDERR_EXISTS" >&2
|
||||
rm -f "$NPM_STDERR_EXISTS"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
{
|
||||
echo "base=$BASE"
|
||||
echo "rc_n=$NEXT_N"
|
||||
echo "rc_version=$RC_VERSION"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Apply rc version in-CI
|
||||
shell: bash
|
||||
working-directory: gitnexus
|
||||
run: |
|
||||
set -euo pipefail
|
||||
npm version "${{ steps.version.outputs.rc_version }}" \
|
||||
--no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Build gitnexus
|
||||
run: npm run build
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Dry-run publish
|
||||
run: npm publish --dry-run --tag rc
|
||||
working-directory: gitnexus
|
||||
|
||||
# ── Acquire the "rc lock" BEFORE publishing (fixes idempotency) ─────
|
||||
# We create two tags and push them atomically:
|
||||
# v<RC_VERSION> → annotated tag on a detached release commit
|
||||
# whose tree contains the rewritten package.json
|
||||
# (so the tag's source matches the npm tarball)
|
||||
# rc/<HEAD_SHA> → lightweight tag on HEAD; the guard's dedup key
|
||||
# If this push fails, nothing is published — safe.
|
||||
# If this push succeeds but npm publish fails, the marker stays on
|
||||
# the remote and blocks retries until an operator manually cleans up.
|
||||
- name: Create and push rc tags
|
||||
id: reltag
|
||||
shell: bash
|
||||
working-directory: gitnexus
|
||||
env:
|
||||
RC_VERSION: ${{ steps.version.outputs.rc_version }}
|
||||
HEAD_SHA: ${{ needs.guard.outputs.head_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VTAG="v${RC_VERSION}"
|
||||
MARKER="rc/${HEAD_SHA}"
|
||||
git config user.name 'github-actions[bot]'
|
||||
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
|
||||
|
||||
# Detached release commit with the version bump — keeps `main`
|
||||
# pristine but gives the v-tag a tree that matches the published
|
||||
# package contents exactly (fixes release-integrity gap).
|
||||
git add package.json package-lock.json 2>/dev/null || git add package.json
|
||||
git commit -m "release: ${VTAG}" --allow-empty
|
||||
RELEASE_SHA="$(git rev-parse HEAD)"
|
||||
echo "Detached release commit: $RELEASE_SHA"
|
||||
|
||||
# Annotated release tag on the release commit.
|
||||
git tag -a "$VTAG" "$RELEASE_SHA" -m "$VTAG"
|
||||
# Lightweight marker on the user-visible HEAD for the guard.
|
||||
git tag "$MARKER" "$HEAD_SHA"
|
||||
|
||||
# Atomic push of both refs. If either would clobber an existing
|
||||
# remote ref, the push fails and we stop before npm publish.
|
||||
git push --atomic origin "refs/tags/$VTAG" "refs/tags/$MARKER"
|
||||
|
||||
{
|
||||
echo "vtag=$VTAG"
|
||||
echo "marker=$MARKER"
|
||||
echo "release_sha=$RELEASE_SHA"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Publish to npm (rc dist-tag)
|
||||
run: npm publish --provenance --access public --tag rc
|
||||
working-directory: gitnexus
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Create GitHub prerelease
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v2
|
||||
with:
|
||||
tag_name: ${{ steps.reltag.outputs.vtag }}
|
||||
name: Release Candidate ${{ steps.reltag.outputs.vtag }}
|
||||
prerelease: true
|
||||
make_latest: 'false'
|
||||
generate_release_notes: true
|
||||
body: |
|
||||
Automated release candidate build from `main`.
|
||||
|
||||
**npm:** `npm install gitnexus@rc`
|
||||
**Version:** `${{ steps.version.outputs.rc_version }}`
|
||||
**Target base:** `${{ steps.version.outputs.base }}` (rc #${{ steps.version.outputs.rc_n }})
|
||||
**Source commit (main):** ${{ needs.guard.outputs.head_sha }}
|
||||
**Release commit (versioned tree):** ${{ steps.reltag.outputs.release_sha }}
|
||||
|
||||
Release candidates are pre-stable builds intended for early testing.
|
||||
Stable releases remain on the `latest` dist-tag.
|
||||
|
||||
# ── Build & push RC Docker images ────────────────────────────────────
|
||||
# Calls docker.yml as a reusable workflow so that the build, signing, and
|
||||
# attestation logic stays in one place. The publish job exposes `vtag`
|
||||
# (e.g. `v1.2.3-rc.1`) as an output so we can pass it as the tag input.
|
||||
# RC images are signed with Cosign keyless signing; the OIDC identity
|
||||
# will be `docker.yml@refs/heads/main` (the caller's ref) rather than a
|
||||
# tag ref — see README.md § Docker for the correct verify command for RCs.
|
||||
docker:
|
||||
name: Build & Push RC Docker images
|
||||
needs: [guard, publish]
|
||||
if: needs.guard.outputs.should_run == 'true' && needs.publish.outputs.vtag != ''
|
||||
uses: ./.github/workflows/docker.yml
|
||||
# Reusable workflows do not receive caller secrets unless inherited; without
|
||||
# this, DOCKERHUB_* / GITHUB_TOKEN are empty in docker.yml → "Username and
|
||||
# password required" on Docker Hub login (see same pattern on `ci:` above).
|
||||
secrets: inherit
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
attestations: write
|
||||
with:
|
||||
tag: ${{ needs.publish.outputs.vtag }}
|
||||
+5
-4
@@ -37,7 +37,8 @@ rules:
|
||||
- pr-labeler.yml
|
||||
|
||||
# Note: cache-poisoning is NOT exempted. The two prior findings in
|
||||
# publish.yml and release-candidate.yml were fixed structurally by
|
||||
# dropping `cache: npm` from those workflows (matches the pattern used
|
||||
# by PyO3/maturin for the same audit). See the commit that added this
|
||||
# file for the rationale.
|
||||
# publish.yml and the former release-candidate.yml were fixed structurally
|
||||
# by dropping `cache: npm` from those workflows (matches the pattern used
|
||||
# by PyO3/maturin for the same audit). After the publish-workflow
|
||||
# unification (issue #1609), only publish.yml remains; the same
|
||||
# cache-poisoning hardening applies there.
|
||||
|
||||
@@ -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** under `.gitnexus/parse-cache/` (per-chunk JSON shards plus `index.json`) for chunks whose file contents haven't changed since the last run. Older installs may still have a legacy single file `.gitnexus/parse-cache.json`, which is read for backward compatibility but no longer written. 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 the whole `.gitnexus/parse-cache/` directory (and remove any legacy `.gitnexus/parse-cache.json` if present) 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. |
|
||||
|
||||
<!-- gitnexus:end -->
|
||||
|
||||
## Repo reference
|
||||
|
||||
+57
-27
@@ -144,16 +144,18 @@ If you use coding agents, follow project context files (e.g. `AGENTS.md`, `CLAUD
|
||||
|
||||
## Releases
|
||||
|
||||
Two publish workflows ship `gitnexus` to npm:
|
||||
One workflow ships `gitnexus` to npm — `.github/workflows/publish.yml`. It
|
||||
routes between two modes based on the triggering event:
|
||||
|
||||
- **Stable** (`.github/workflows/publish.yml`) — triggered by pushing any `v*`
|
||||
tag. Publishes to the `latest` dist-tag with a changelog-backed GitHub
|
||||
release. Maintainers are expected to tag from `main` as a convention; the
|
||||
workflow itself does not enforce branch reachability.
|
||||
- **Release Candidate** (`.github/workflows/release-candidate.yml`) — runs on
|
||||
every push to `main` (typically a merged PR) plus manual dispatch. Docs-only
|
||||
changes are skipped via `paths-ignore`. Publishes to the `rc` dist-tag with
|
||||
version `X.Y.Z-rc.N` and a GitHub prerelease, where:
|
||||
- **Stable mode** — triggered by pushing any `v<X.Y.Z>` tag (no `-rc.*`
|
||||
suffix; RC tags are excluded at trigger via a negative glob). Publishes to
|
||||
the `latest` dist-tag with a changelog-backed GitHub release. Maintainers
|
||||
are expected to tag from `main` as a convention; the workflow itself does
|
||||
not enforce branch reachability. No Docker build (RC-only).
|
||||
- **Release-candidate mode** — runs on every push to `main` (typically a
|
||||
merged PR) plus manual `workflow_dispatch`. Docs-only changes are skipped
|
||||
via `paths-ignore`. Publishes to the `rc` dist-tag with version
|
||||
`X.Y.Z-rc.N` and a GitHub prerelease, where:
|
||||
- `X.Y.Z` is selected automatically. On push (and on dispatch with
|
||||
`bump: auto`, the default) the workflow **continues the active rc cycle**:
|
||||
if the registry already has `X.Y.Z-rc.*` versions with `X.Y.Z` > current
|
||||
@@ -170,36 +172,64 @@ Two publish workflows ship `gitnexus` to npm:
|
||||
caller's ref — see README.md § Docker for the verify command).
|
||||
|
||||
Idempotency: the workflow pushes an `rc/<HEAD_SHA>` marker tag and a
|
||||
`v<RC>` release tag **atomically, before** calling `npm publish`. The guard
|
||||
refuses to re-run once the marker exists, so a post-publish failure will
|
||||
not mint a duplicate rc for the same commit. The `v<RC>` tag points at a
|
||||
detached release commit whose `package.json` matches the npm tarball
|
||||
exactly (traceable releases). Recovery after a partial failure:
|
||||
`v<RC>` release tag **atomically, before** calling `npm publish`. The
|
||||
RC guard refuses to re-run once the marker exists, so a post-publish
|
||||
failure will not mint a duplicate rc for the same commit. The `v<RC>`
|
||||
tag points at a detached release commit whose `package.json` matches
|
||||
the npm tarball exactly (traceable releases). The RC tag is excluded
|
||||
from this workflow's `push: tags:` filter, so it does **not** re-trigger
|
||||
publishing — preventing the double-publish failure mode tracked in #1609.
|
||||
Recovery after a partial failure: the workflow's `if: failure()` cleanup
|
||||
step in the `publish` job auto-deletes the v-tag and marker on most
|
||||
post-publish failures, so the typical retry is just:
|
||||
|
||||
```bash
|
||||
gh workflow run publish.yml --ref main -f force=true
|
||||
# or push a new commit to main, which will cut a fresh RC
|
||||
```
|
||||
|
||||
If auto-cleanup didn't run (e.g. the cleanup step itself failed, or the
|
||||
failure happened in the route/rc-guard phase before the marker was
|
||||
pushed), manual cleanup is:
|
||||
|
||||
```bash
|
||||
git push --delete origin rc/<HEAD_SHA> v<RC>
|
||||
# then redispatch the workflow with force: true
|
||||
# then redispatch with force: true
|
||||
```
|
||||
|
||||
**Release-PR-skip subject pattern.** The rc-guard job recognizes a
|
||||
squash-merged release commit by matching the commit subject against
|
||||
`^chore: release vX.Y.Z` (optionally followed by ` (#NNNN)` for the
|
||||
squash-merge PR-number suffix). Match is case-insensitive — `Chore: Release v1.2.3`
|
||||
works too. PRs that should suppress the RC build must either use this
|
||||
subject shape, or carry the `release` label so the label-based fallback
|
||||
fires. Other release-style subjects (`chore(release): v1.2.3`,
|
||||
`release: v1.2.3`) will NOT trigger the skip — please name the release
|
||||
PR exactly `chore: release vX.Y.Z` to keep the dedup deterministic.
|
||||
|
||||
**Docker-only partial failure:** if `publish` succeeds (npm tarball + tags
|
||||
are live) but the `docker` job subsequently fails (e.g. GHCR flakiness),
|
||||
the npm RC is already published and the `rc/<HEAD_SHA>` marker is in place.
|
||||
Re-running `release-candidate.yml` with `force: true` will abort at the
|
||||
"Version already exists on npm" guard. To recover without cutting a new RC:
|
||||
Recovery without cutting a new RC:
|
||||
|
||||
```bash
|
||||
# 1. Manually trigger only the docker workflow, passing the existing RC tag:
|
||||
gh workflow run docker.yml --ref main -f tag=v<RC_VERSION>
|
||||
# (requires a workflow_dispatch trigger on docker.yml — see note below)
|
||||
# Re-run only the failed docker job from the original workflow run:
|
||||
gh run rerun <run-id> --failed
|
||||
```
|
||||
|
||||
Because `docker.yml` intentionally has no `workflow_dispatch` (images are
|
||||
tag-driven by design), the practical recovery options are:
|
||||
- Wait for the next commit on `main`, which will cut a new RC that includes
|
||||
the Docker build.
|
||||
- Manually run `docker build` + `docker push` locally and sign with Cosign
|
||||
against the same digest.
|
||||
- Delete `rc/<HEAD_SHA>` and `v<RC>` tags, then redispatch with `force: true` to re-run the full RC pipeline (cuts a new RC number).
|
||||
Find the run ID via `gh run list --workflow=publish.yml --branch main`.
|
||||
`docker.yml` intentionally has no `workflow_dispatch` trigger (images are
|
||||
tag-driven by design), so the gh-run-rerun path is the supported recovery.
|
||||
|
||||
**GitHub Release transient failure** (npm publish succeeded, Release step
|
||||
failed): the npm artifact is live but no GitHub Release page exists.
|
||||
Recover by either re-running the failed job (`gh run rerun <run-id> --failed`),
|
||||
or creating the Release manually:
|
||||
|
||||
```bash
|
||||
gh release create v<RC> --prerelease --generate-notes # RC
|
||||
gh release create v<X.Y.Z> --notes-file gitnexus/CHANGELOG.md # stable
|
||||
```
|
||||
|
||||
The rc workflow never moves `latest`. To verify after a change, inspect dist-tags:
|
||||
|
||||
|
||||
+11
-2
@@ -40,8 +40,8 @@ RUN npm prune --omit=dev --prefix gitnexus
|
||||
# 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
|
||||
@@ -58,6 +58,15 @@ COPY --from=builder --chown=node:node /app/gitnexus/package.json ./gitnexus/pack
|
||||
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/<repo>`)
|
||||
# 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.
|
||||
|
||||
+7
-1
@@ -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 the `.gitnexus/parse-cache/` directory (and any legacy `.gitnexus/parse-cache.json`) at any time — content-addressed, will be regenerated.
|
||||
- **Why:** Incremental writeback is selective DB row replacement; if the on-disk state is inconsistent for any reason, a full rebuild is the cheapest path back to a known-good index.
|
||||
|
||||
### Embeddings vanished after analyze
|
||||
|
||||
- **Trigger:** Semantic search quality drops; `stats.embeddings` in `meta.json` is 0 after refresh.
|
||||
|
||||
@@ -429,7 +429,7 @@ The Docker images are version-locked to the npm package:
|
||||
Both registries receive the same digest from a single build step, so you can
|
||||
pull from either and the signature verifies identically.
|
||||
- Release-candidate images (e.g. `:1.7.0-rc.1`) are published alongside each
|
||||
RC npm release. They are built by `release-candidate.yml` calling `docker.yml`
|
||||
RC npm release. They are built by `publish.yml` calling `docker.yml`
|
||||
as a reusable workflow after the RC tag is created and pushed.
|
||||
- `:latest` is auto-promoted only from non-prerelease tags by the Docker
|
||||
metadata action, so it always points at a real, npm-published version.
|
||||
@@ -462,7 +462,7 @@ registries because both sets of tags were signed at the same digest in one
|
||||
workflow run.
|
||||
|
||||
**Release candidates** — signed from `refs/heads/main` (the caller's ref when
|
||||
`release-candidate.yml` invokes `docker.yml` as a reusable workflow):
|
||||
`publish.yml` invokes `docker.yml` as a reusable workflow):
|
||||
|
||||
```bash
|
||||
cosign verify ghcr.io/abhigyanpatwari/gitnexus:1.7.0-rc.1 \
|
||||
@@ -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 <seconds> # LLM request timeout in seconds (default: disabled)
|
||||
gitnexus wiki --retries <n> # 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.
|
||||
|
||||
Generated
+3
-3
@@ -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]]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
|
||||
@@ -62,6 +62,8 @@ Generates repository documentation from the knowledge graph using an LLM. Requir
|
||||
| `--api-key <key>` | LLM API key |
|
||||
| `--concurrency <n>` | Parallel LLM calls (default: 3) |
|
||||
| `--gist` | Publish wiki as a public GitHub Gist |
|
||||
| `--timeout <seconds>` | LLM request timeout in seconds (default: disabled) |
|
||||
| `--retries <n>` | Max LLM retry attempts per request (default: 3) |
|
||||
|
||||
### list — Show all indexed repos
|
||||
|
||||
|
||||
@@ -10,20 +10,21 @@ Static config that adds GitNexus knowledge-graph augmentation and skill files to
|
||||
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
|
||||
| **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 two files described below into your project's `.cursor/`. |
|
||||
| **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 two files into your **project root**:
|
||||
From this repo's `gitnexus-cursor-integration/hooks/`, copy the files below into your **project root**:
|
||||
|
||||
```text
|
||||
<your-project>/
|
||||
├── .cursor/
|
||||
│ └── hooks.json ← from gitnexus-cursor-integration/hooks/hooks.json
|
||||
└── hooks/
|
||||
└── gitnexus-hook.cjs ← from gitnexus-cursor-integration/hooks/gitnexus-hook.cjs
|
||||
├── 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):
|
||||
@@ -32,6 +33,7 @@ Equivalent shell commands (run from your project root, with `$GITNEXUS_REPO` poi
|
||||
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.
|
||||
@@ -49,7 +51,7 @@ If you already have a `.cursor/hooks.json`, merge the `hooks.postToolUse` array
|
||||
| -------------------------------------------------------------------- | ------------------------------ |
|
||||
| `~/.cursor/mcp.json` | ✅ |
|
||||
| `~/.cursor/skills/gitnexus/*` | ✅ |
|
||||
| `<project>/.cursor/hooks.json` + `<project>/hooks/gitnexus-hook.cjs` | ❌ — copy manually (see above) |
|
||||
| `<project>/.cursor/hooks.json` + `<project>/hooks/gitnexus-hook.cjs` + `<project>/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.
|
||||
|
||||
@@ -84,6 +86,6 @@ Empty stdout means "no augmentation, continue normally" — the hook never block
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Nothing happens** — Confirm Cursor is on 2.4+ and the project root has both `.cursor/hooks.json` and the script at `hooks/gitnexus-hook.cjs`. Then `npx gitnexus list` to confirm the project is indexed.
|
||||
- **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.
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
const { acquireHookSlot } = require('./hook-lock.cjs');
|
||||
|
||||
function readInput() {
|
||||
try {
|
||||
@@ -227,7 +228,8 @@ function main() {
|
||||
}
|
||||
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 || {};
|
||||
@@ -235,6 +237,9 @@ function main() {
|
||||
const pattern = extractPattern(toolName, toolInput);
|
||||
if (!pattern || pattern.length < 3) return;
|
||||
|
||||
const release = acquireHookSlot(gitNexusDir);
|
||||
if (!release) return;
|
||||
|
||||
const cliPath = resolveCliPath();
|
||||
let result = '';
|
||||
try {
|
||||
@@ -244,6 +249,8 @@ function main() {
|
||||
}
|
||||
} catch {
|
||||
/* graceful failure */
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
|
||||
if (result && result.trim()) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -26,7 +26,7 @@ export type { PipelinePhase, PipelineProgress } from './pipeline.js';
|
||||
|
||||
// ─── Scope-based resolution — RFC #909 (Ring 1 #910) ────────────────────────
|
||||
// Data model (RFC §2)
|
||||
export type { SymbolDefinition } from './scope-resolution/symbol-definition.js';
|
||||
export type { ParameterTypeClass, SymbolDefinition } from './scope-resolution/symbol-definition.js';
|
||||
export type {
|
||||
ScopeId,
|
||||
DefId,
|
||||
@@ -129,6 +129,7 @@ export type {
|
||||
RegistryProviders,
|
||||
OwnerScopedContributor,
|
||||
ArityVerdict,
|
||||
ConstraintContext,
|
||||
} from './scope-resolution/registries/context.js';
|
||||
|
||||
// Scope tree spine + position lookup (RFC §2.2 + §3.1; Ring 2 SHARED #912)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -40,11 +40,27 @@ export interface MethodDispatchIndex {
|
||||
readonly mroByOwnerDefId: ReadonlyMap<DefId, readonly DefId[]>;
|
||||
/** Interfaces / traits → classes that implement them. */
|
||||
readonly implsByInterfaceDefId: ReadonlyMap<DefId, readonly DefId[]>;
|
||||
/**
|
||||
* 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<DefId, readonly DefId[]>;
|
||||
|
||||
/** `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<DefId, readonly DefId[]>();
|
||||
const extendsOnlyByOwnerDefId = input.computeExtendsOnlyMro
|
||||
? new Map<DefId, readonly DefId[]>()
|
||||
: undefined;
|
||||
const implsBuilding = new Map<DefId, DefId[]>();
|
||||
const implsSeen = new Map<DefId, Set<DefId>>();
|
||||
|
||||
@@ -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<DefId, readonly DefId[]>,
|
||||
implsByInterfaceDefId: Map<DefId, readonly DefId[]>,
|
||||
extendsOnlyMroByOwnerDefId: Map<DefId, readonly DefId[]> | 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;
|
||||
}
|
||||
|
||||
@@ -30,10 +30,43 @@ export interface RegistryProviders {
|
||||
* when absent, every candidate receives `'unknown'` (neutral signal).
|
||||
*/
|
||||
arityCompatibility?(callsite: Callsite, def: SymbolDefinition): ArityVerdict;
|
||||
|
||||
/**
|
||||
* Language-specific constraint compatibility between a callsite and a
|
||||
* candidate `def`. Mirrors `arityCompatibility` and shares its three-valued
|
||||
* verdict shape; the third value `'unknown'` MUST keep the candidate
|
||||
* (monotonicity: adding a predicate can only narrow correctly, never
|
||||
* produce a wrong edge). Consulted by `narrowOverloadCandidates` after
|
||||
* arity + type filters when a candidate carries `templateConstraints`.
|
||||
*
|
||||
* Optional; when absent the constraint filter is a pass-through. Languages
|
||||
* with no constrained-overload semantics leave this undefined.
|
||||
*/
|
||||
constraintCompatibility?(
|
||||
callsite: Callsite,
|
||||
def: SymbolDefinition,
|
||||
ctx: ConstraintContext,
|
||||
): ArityVerdict;
|
||||
}
|
||||
|
||||
export type ArityVerdict = 'compatible' | 'unknown' | 'incompatible';
|
||||
|
||||
/**
|
||||
* Context threaded into `constraintCompatibility`. Kept minimal in the
|
||||
* Tier-A scope (only `argumentTypes`, riding here until a separate
|
||||
* `Callsite`-widening refactor moves them onto the call site directly).
|
||||
* Future Tier-B graph-aware predicates (`is_base_of_v`, etc.) will widen
|
||||
* this interface with `lookupTypeByName` and similar helpers.
|
||||
*/
|
||||
export interface ConstraintContext {
|
||||
/**
|
||||
* Per-slot argument types at the call site, normalized per the language
|
||||
* adapter. Empty string means unknown. Same convention as
|
||||
* `narrowOverloadCandidates`' `argTypes` parameter.
|
||||
*/
|
||||
readonly argumentTypes?: readonly string[];
|
||||
}
|
||||
|
||||
// ─── Owner-scoped contributor (concrete shape for `RegistryContributor`) ────
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -11,6 +11,17 @@
|
||||
|
||||
import type { NodeLabel } from '../graph/types.js';
|
||||
|
||||
export interface ParameterTypeClass {
|
||||
/** Normalized base type, matching the coarse `parameterTypes` vocabulary when known. */
|
||||
base: string;
|
||||
/** Top-level cv signal preserved from the original C++ parameter spelling. */
|
||||
cv: 'none' | 'const' | 'volatile' | 'const volatile' | 'unknown';
|
||||
/** Coarse value/reference/pointer shape. */
|
||||
indirection: 'value' | 'lvalue-ref' | 'rvalue-ref' | 'pointer' | 'unknown';
|
||||
/** Number of pointer markers when indirection is `pointer`; otherwise 0. */
|
||||
pointerDepth: number;
|
||||
}
|
||||
|
||||
export interface SymbolDefinition {
|
||||
nodeId: string;
|
||||
filePath: string;
|
||||
@@ -26,10 +37,22 @@ export interface SymbolDefinition {
|
||||
/** Per-parameter type names for overload disambiguation (e.g. ['int', 'String']).
|
||||
* Populated when parameter types are resolvable from AST (any typed language). */
|
||||
parameterTypes?: string[];
|
||||
/** Additive per-parameter type shape sidecar for languages that need cv/ref/pointer distinctions.
|
||||
* Does not participate in graph node identity unless a resolver explicitly opts in. */
|
||||
parameterTypeClasses?: ParameterTypeClass[];
|
||||
/** Raw return type text extracted from AST (e.g. 'User', 'Promise<User>') */
|
||||
returnType?: string;
|
||||
/** Declared type for non-callable symbols — fields/properties (e.g. 'Address', 'List<User>') */
|
||||
declaredType?: string;
|
||||
/** Generic/template specialization arguments for class-like symbols (e.g. ['User'], ['T*']). */
|
||||
templateArguments?: string[];
|
||||
/** Per-language constraint payload for template / generic overloads
|
||||
* (e.g. C++ `enable_if_t<P, T>` predicate trees, C++20 `requires` clauses).
|
||||
* Opaque to shared code — the producing language adapter owns the shape
|
||||
* and is the only consumer. Read via the optional
|
||||
* `ScopeResolver.constraintCompatibility` hook during overload narrowing.
|
||||
* Absent for symbols that have no constraints (the common case). */
|
||||
templateConstraints?: unknown;
|
||||
/** Links Method/Constructor/Property to owning Class/Struct/Trait nodeId */
|
||||
ownerId?: string;
|
||||
}
|
||||
|
||||
Generated
+114
-257
@@ -10,7 +10,7 @@
|
||||
"dependencies": {
|
||||
"@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",
|
||||
@@ -29,7 +29,7 @@
|
||||
"langchain": "^1.3.5",
|
||||
"lru-cache": "^11.2.4",
|
||||
"lucide-react": "^1.14.0",
|
||||
"mermaid": "^11.14.0",
|
||||
"mermaid": "^11.15.0",
|
||||
"mnemonist": "^0.39.0",
|
||||
"pandemonium": "^2.4.0",
|
||||
"react": "^19.2.5",
|
||||
@@ -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"
|
||||
},
|
||||
@@ -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": {
|
||||
@@ -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"
|
||||
],
|
||||
@@ -3643,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",
|
||||
@@ -4628,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",
|
||||
@@ -5661,24 +5598,6 @@
|
||||
"@langchain/core": "^1.1.42"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"node_modules/langsmith": {
|
||||
"version": "0.5.23",
|
||||
"resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.5.23.tgz",
|
||||
@@ -6422,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",
|
||||
@@ -6440,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": {
|
||||
@@ -7216,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",
|
||||
@@ -7595,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",
|
||||
@@ -7947,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"
|
||||
@@ -7962,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": {
|
||||
@@ -8702,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": {
|
||||
@@ -8727,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",
|
||||
@@ -8875,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",
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"gitnexus-shared": "file:../gitnexus-shared",
|
||||
"@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",
|
||||
@@ -39,7 +39,7 @@
|
||||
"langchain": "^1.3.5",
|
||||
"lru-cache": "^11.2.4",
|
||||
"lucide-react": "^1.14.0",
|
||||
"mermaid": "^11.14.0",
|
||||
"mermaid": "^11.15.0",
|
||||
"mnemonist": "^0.39.0",
|
||||
"pandemonium": "^2.4.0",
|
||||
"react": "^19.2.5",
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": ["mcp__plugin_claude-mem_mcp-search__get_observations"]
|
||||
}
|
||||
"allow": [
|
||||
"mcp__plugin_claude-mem_mcp-search__get_observations",
|
||||
"Skill(gitnexus-exploring)",
|
||||
"Bash(npx gitnexus *)",
|
||||
"mcp__obsidian-memory__search_nodes",
|
||||
"mcp__obsidian-memory__add_observations",
|
||||
"WebSearch",
|
||||
"WebFetch(domain:cppreference.net)",
|
||||
"Bash(xargs grep -l \"templateArguments\\\\|parameterTypes\")",
|
||||
"Bash(gh issue *)",
|
||||
"Bash(gh pr *)"
|
||||
]
|
||||
},
|
||||
"enableAllProjectMcpServers": true,
|
||||
"enabledMcpjsonServers": ["gitnexus"]
|
||||
}
|
||||
|
||||
@@ -4,6 +4,60 @@ All notable changes to GitNexus will be documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.6.5] - 2026-05-16
|
||||
|
||||
### Added
|
||||
|
||||
- **C++ ADL V2** — Argument-Dependent Lookup overhaul. Class-typed reference args (incl. rvalue refs) contribute associated namespaces (#1595); class-pointer args and template-specialization args (with nested template args) included (#1592, #1596); base-class associated namespaces walked via MRO (#1597); free-function reference args contribute enclosing namespace (#1598); ordinary and ADL free-call candidates merged before overload selection (#1599)
|
||||
- **C++ standard-conversion-sequence ranking** for overload resolution (#1606)
|
||||
- **C++ scope-resolution migration** — C++ now runs on the registry-primary RFC #909 path (#938, #1520); template-body `this->` + `using ns::name` calls resolved in the scope resolver (#1590); template specializations disambiguated in class graph IDs and receiver routing (#1587); EXTENDS edges for template and qualified template bases (#1581)
|
||||
- **PHP scope-resolution migration** — PHP moved to scope-based resolution (#938, #1497, supersedes #1124)
|
||||
- **Java scope-resolution migration** — RFC #909 Ring 3 (#1482)
|
||||
- **C scope-resolution migration** — RFC #909 Ring 3 (#1481)
|
||||
- **Incremental indexing** — `gitnexus analyze` now reuses a parse cache, writes back to DB, and short-circuits scope resolution when nothing changed (#1479)
|
||||
- **`gitnexus:keep` marker** — preserves custom context sections (#605, #1508)
|
||||
- **`gitnexus analyze --skip-skills` and `--index-only`** flags (#742, #1485)
|
||||
- **`gitnexus wiki --timeout` and `--retries` flags** — mitigate timeout aborts on large module pages (#1543)
|
||||
- **HTTP embedding `dimensions` parameter** — now forwarded to the embedding endpoint (#1498)
|
||||
- **Cursor 2.4 `postToolUse` hooks** — upgraded for Read/Grep/Shell coverage (#1467)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Cross-file type propagation** — resolved a stall on large repos (#1626)
|
||||
- **C++ inline-namespace ambiguity** — detect same-name ambiguity across inline namespace children (#1564, #1600); workspace-wide dependent-base name resolution for cross-file templates (#1586)
|
||||
- **Parse cache persistence** — sharded on large repos to avoid corruption (#1580)
|
||||
- **TypeScript ESM `.js` extension** — fallback applied to tsconfig path-alias resolution (#1530) and `.js` → `.ts` source resolution (#1525)
|
||||
- **Markdown CRLF line endings** — section heading parser now handles them (#1469)
|
||||
- **`gitnexus analyze --no-stats`** — actually omits volatile counts (#1477, #1478)
|
||||
- **`ensureGitNexusIgnored`** — tolerate read-only workspaces (#1549, #1550)
|
||||
- **Claude augment hook** — skipped when GitNexus server owns the DB (#1493)
|
||||
- **Docker runtime image** — symlink `gitnexus` binary onto `$PATH` (#1551); install `ca-certificates` for TLS verification (#1545, #1547); include duckdb installer script (#1502)
|
||||
- **Windows reliability** — fix 32767-char tree-sitter crash and VECTOR-extension SIGSEGV (#1433); platform-aware `tsc` build command for win32 (#1531)
|
||||
- **Search / FTS** — guard against undefined `bm25Results` when FTS is unavailable (#1489, #1540); CONTAINS fallback in augment when FTS indexes unavailable (#1476)
|
||||
- **Wiki** — sanitize generated mermaid diagrams (#1539)
|
||||
- **Hooks** — cap concurrent augment subprocesses to prevent runaway fan-out (#1486, #1510)
|
||||
- **LadybugDB** — drain checkpoint result before close (#1506); recover `gitnexus analyze` from orphan sidecars when the main DB file is missing (#1622)
|
||||
- **Group / contracts** — detect `httpx` async consumers (#1408)
|
||||
- **Server hardening** — sanitize repo name to prevent argument injection on `/api/analyze` (#1305)
|
||||
|
||||
### Changed
|
||||
|
||||
- **CI release pipeline unified under `publish.yml`** — single source of truth for npm publish, provenance, and GitHub Release creation (#1610)
|
||||
- **CI: skip RC build on release PRs** — release/* branches no longer cut redundant RCs (#1474)
|
||||
- **CI (Claude review): make `/review` reliably post PR comments** (#1522); allow Bash in code-review job without interactive approval (#1523)
|
||||
- **CI publish (post-merge fixes)** — bump publish job to Node 24 for npm OIDC support (#1628); engage npm Trusted Publishing OIDC properly (#1627)
|
||||
- **Tests** — remove flaky regression test for resource exhaustion (#1521); de-flake regex linearity assertions in U8 (#1475)
|
||||
|
||||
### Chore / Dependencies
|
||||
|
||||
- `vitest` 4.1.5 → 4.1.6 in /gitnexus (#1605)
|
||||
- `@langchain/google-genai` bump in /gitnexus-web (#1554)
|
||||
- `vite` 8.0.10 → 8.0.11 in /gitnexus-web (#1555)
|
||||
- `mermaid` bump (#1514)
|
||||
- `protobufjs` 7.5.5 → 7.5.8 + `@protobufjs/utf8` in /gitnexus (#1535, #1536)
|
||||
- `urllib3` bump in /eval uv group (#1512)
|
||||
- GitHub Actions: `sigstore/cosign-installer` 4.1.1 → 4.1.2 (#1557)
|
||||
|
||||
## [1.6.4] - 2026-05-10
|
||||
|
||||
### Added
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
|
||||
Generated
+161
-161
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "gitnexus",
|
||||
"version": "1.6.4",
|
||||
"version": "1.6.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "gitnexus",
|
||||
"version": "1.6.4",
|
||||
"version": "1.6.5",
|
||||
"hasInstallScript": true,
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"dependencies": {
|
||||
@@ -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"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gitnexus",
|
||||
"version": "1.6.4",
|
||||
"version": "1.6.5",
|
||||
"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",
|
||||
|
||||
@@ -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…');
|
||||
|
||||
@@ -199,7 +199,9 @@ async function fileExists(filePath: string): Promise<boolean> {
|
||||
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) {
|
||||
@@ -223,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 <!-- gitnexus:keep -->, 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('<!-- gitnexus:keep -->')) {
|
||||
// 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;
|
||||
@@ -344,12 +389,12 @@ export async function generateAIContextFiles(
|
||||
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)');
|
||||
|
||||
@@ -13,6 +13,7 @@ import { execFileSync } from 'child_process';
|
||||
import v8 from 'v8';
|
||||
import cliProgress from 'cli-progress';
|
||||
import { closeLbug } from '../core/lbug/lbug-adapter.js';
|
||||
import { isWalCorruptionError, WAL_RECOVERY_SUGGESTION } from '../core/lbug/lbug-config.js';
|
||||
import {
|
||||
getStoragePaths,
|
||||
getGlobalRegistryPath,
|
||||
@@ -117,8 +118,18 @@ 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). */
|
||||
@@ -449,7 +460,12 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
|
||||
skipGit: options?.skipGit,
|
||||
skipAgentsMd,
|
||||
skipSkills,
|
||||
noStats: options?.noStats,
|
||||
// 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
|
||||
@@ -537,7 +553,13 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
|
||||
processes: s.processes,
|
||||
},
|
||||
skillResult.skills,
|
||||
{ skipAgentsMd, skipSkills, 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 {
|
||||
@@ -617,6 +639,20 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
|
||||
return;
|
||||
}
|
||||
|
||||
// WAL corruption — the index file is unreadable. Give a clear recovery
|
||||
// path without a confusing stack trace (the native error message alone
|
||||
// is enough signal).
|
||||
if (isWalCorruptionError(err) || msg.includes('LadybugDB WAL corruption')) {
|
||||
cliError(
|
||||
` The GitNexus index has a corrupted WAL file.\n` +
|
||||
` This usually happens when a previous analysis was interrupted mid-write.\n` +
|
||||
` ${WAL_RECOVERY_SUGGESTION}\n`,
|
||||
{ recoveryHint: 'wal-corruption' },
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// HF download failure — show clean guidance without the raw stack trace.
|
||||
// Checked before writeFatalToStderr so the user sees one focused message
|
||||
// rather than a stack-trace dump followed by a second remediation block.
|
||||
|
||||
@@ -161,6 +161,8 @@ program
|
||||
)
|
||||
.option('--no-reasoning-model', 'Disable reasoning model mode (overrides saved config)')
|
||||
.option('--concurrency <n>', 'Parallel LLM calls (default: 3)', '3')
|
||||
.option('--timeout <seconds>', 'LLM request timeout in seconds (default: disabled)')
|
||||
.option('--retries <n>', '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')
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createServer } from '../server/api.js';
|
||||
import { logger, flushLoggerSync } from '../core/logger.js';
|
||||
import { cliError } from './cli-message.js';
|
||||
import { isWalCorruptionError, WAL_RECOVERY_SUGGESTION } from '../core/lbug/lbug-config.js';
|
||||
|
||||
// Catch anything that would cause a silent exit. Pino v10's default
|
||||
// destination is `sync: false` (SonicBoom buffered) — call
|
||||
@@ -34,7 +35,13 @@ export const serveCommand = async (options?: { port?: string; host?: string }) =
|
||||
try {
|
||||
await createServer(port, host);
|
||||
} catch (err: any) {
|
||||
if (err.code === 'EADDRINUSE') {
|
||||
if (isWalCorruptionError(err)) {
|
||||
cliError(
|
||||
`\nGitNexus server could not start: the index has a corrupted WAL file.\n` +
|
||||
` ${WAL_RECOVERY_SUGGESTION}\n`,
|
||||
{ recoveryHint: 'wal-corruption' },
|
||||
);
|
||||
} else if (err.code === 'EADDRINUSE') {
|
||||
cliError(
|
||||
`\nFailed to start GitNexus server:\n` +
|
||||
` ${err.message || err}\n\n` +
|
||||
|
||||
@@ -364,6 +364,33 @@ async function installClaudeCodeHooks(result: SetupResult): Promise<void> {
|
||||
// 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`
|
||||
|
||||
@@ -33,6 +33,25 @@ export interface WikiCommandOptions {
|
||||
provider?: LLMProvider;
|
||||
verbose?: boolean;
|
||||
review?: boolean;
|
||||
timeout?: string;
|
||||
retries?: string;
|
||||
}
|
||||
|
||||
function parsePositiveIntegerOption(
|
||||
value: string | undefined,
|
||||
flag: string,
|
||||
multiplier = 1,
|
||||
): number | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
const trimmed = value.trim();
|
||||
if (!/^[1-9]\d*$/.test(trimmed)) {
|
||||
throw new Error(`${flag} must be a positive integer`);
|
||||
}
|
||||
const parsed = parseInt(trimmed, 10);
|
||||
if (parsed > Math.floor(Number.MAX_SAFE_INTEGER / multiplier)) {
|
||||
throw new Error(`${flag} is too large`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -125,6 +144,17 @@ export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptio
|
||||
return;
|
||||
}
|
||||
|
||||
let timeoutSeconds: number | undefined;
|
||||
let retries: number | undefined;
|
||||
try {
|
||||
timeoutSeconds = parsePositiveIntegerOption(options?.timeout, '--timeout', 1000);
|
||||
retries = parsePositiveIntegerOption(options?.retries, '--retries');
|
||||
} catch (error) {
|
||||
console.log(` Error: ${(error as Error).message}\n`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Resolve LLM config (with interactive fallback) ─────────────────
|
||||
// Save any CLI overrides immediately
|
||||
if (
|
||||
@@ -347,6 +377,14 @@ export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptio
|
||||
}
|
||||
}
|
||||
|
||||
// ── Apply per-run overrides not saved to config ────────────────────
|
||||
if (timeoutSeconds !== undefined) {
|
||||
llmConfig.requestTimeoutMs = timeoutSeconds * 1000;
|
||||
}
|
||||
if (retries !== undefined) {
|
||||
llmConfig.maxAttempts = retries;
|
||||
}
|
||||
|
||||
// ── Setup progress bar with elapsed timer ──────────────────────────
|
||||
const bar = new cliProgress.SingleBar(
|
||||
{
|
||||
@@ -551,6 +589,8 @@ export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptio
|
||||
|
||||
if (err.message?.includes('No source files')) {
|
||||
console.log(`\n ${err.message}\n`);
|
||||
} else if (err.message?.includes('LLM request timed out after')) {
|
||||
console.log(`\n Timeout: ${err.message}\n`);
|
||||
} else if (err.message?.includes('content filter')) {
|
||||
// Content filter block — actionable message
|
||||
console.log(`\n Content Filter: ${err.message}\n`);
|
||||
|
||||
@@ -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<string>();
|
||||
|
||||
// (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];
|
||||
};
|
||||
@@ -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<nodeId, filePath> for every File-bound node in the graph.
|
||||
* Graph-wide nodes (Community/Process) have no filePath and are filtered.
|
||||
*/
|
||||
const indexNodeFilePaths = (fullGraph: KnowledgeGraph): Map<string, string> => {
|
||||
const idx = new Map<string, string>();
|
||||
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<string>,
|
||||
): KnowledgeGraph => {
|
||||
const sub = createKnowledgeGraph();
|
||||
const writableNodeIds = new Set<string>();
|
||||
|
||||
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<string>,
|
||||
): Set<string> => {
|
||||
const nodeFilePaths = indexNodeFilePaths(fullGraph);
|
||||
const expanded = new Set<string>(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;
|
||||
};
|
||||
@@ -42,12 +42,14 @@ 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 {
|
||||
@@ -77,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<string, Map<string, FieldInfo>>,
|
||||
): Map<string, FieldInfo> | 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<string, FieldInfo>();
|
||||
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<string, Map<string, string>>;
|
||||
@@ -708,6 +766,15 @@ export const processCalls = async (
|
||||
importedRawReturnTypesMap?: ReadonlyMap<string, ReadonlyMap<string, string>>,
|
||||
heritageMap?: HeritageMap,
|
||||
bindingAccumulator?: BindingAccumulator,
|
||||
/**
|
||||
* Optional cache for compiled `Parser.Query` objects keyed by language name.
|
||||
* When provided, compiled queries are reused across calls instead of being
|
||||
* re-compiled from the query string for every file. Callers that invoke
|
||||
* `processCalls` many times with single-file batches (e.g. the cross-file
|
||||
* propagation phase) should pass a long-lived map here to avoid O(N)
|
||||
* query recompilation overhead.
|
||||
*/
|
||||
compiledQueryCache?: Map<SupportedLanguages, Parser.Query>,
|
||||
): Promise<ExtractedHeritage[]> => {
|
||||
const parser = await loadParser();
|
||||
const collectedHeritage: ExtractedHeritage[] = [];
|
||||
@@ -716,6 +783,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
|
||||
@@ -784,7 +852,11 @@ export const processCalls = async (
|
||||
let matches;
|
||||
try {
|
||||
const lang = parser.getLanguage();
|
||||
const query = new Parser.Query(lang, queryStr);
|
||||
let query = compiledQueryCache?.get(language);
|
||||
if (!query) {
|
||||
query = new Parser.Query(lang, queryStr);
|
||||
compiledQueryCache?.set(language, query);
|
||||
}
|
||||
matches = query.matches(tree.rootNode);
|
||||
} catch (queryError) {
|
||||
logger.warn({ queryError }, `Query error for ${file.path}:`);
|
||||
@@ -860,6 +932,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<string, Map<string, FieldInfo>>();
|
||||
for (const { file, language, provider, matches, typeEnv } of prepared) {
|
||||
const callRouter = provider.callRouter;
|
||||
if (!callRouter) continue;
|
||||
matches.forEach((match) => {
|
||||
const captureMap: Record<string, any> = {};
|
||||
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<string, FieldInfo> | 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
|
||||
@@ -933,7 +1119,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.
|
||||
@@ -1053,47 +1245,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;
|
||||
}
|
||||
|
||||
@@ -1382,7 +1535,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',
|
||||
@@ -2979,7 +3135,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',
|
||||
|
||||
@@ -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<string, { text: string } | undefined>,
|
||||
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<string, { text: string } | undefined>,
|
||||
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,
|
||||
),
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,6 +10,13 @@ export interface ExtractedClassSymbol {
|
||||
name: string;
|
||||
type: ClassLikeNodeLabel;
|
||||
qualifiedName: string;
|
||||
templateArguments?: string[];
|
||||
}
|
||||
|
||||
export interface ClassCaptureContext {
|
||||
captureMap: Record<string, SyntaxNode>;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<never>((_, reject) =>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -9,8 +9,12 @@ export const EXTENSIONS = [
|
||||
// TypeScript/JavaScript
|
||||
'.tsx',
|
||||
'.ts',
|
||||
'.mts',
|
||||
'.cts',
|
||||
'.jsx',
|
||||
'.js',
|
||||
'.mjs',
|
||||
'.cjs',
|
||||
'.vue',
|
||||
'/index.tsx',
|
||||
'/index.ts',
|
||||
|
||||
@@ -210,6 +210,37 @@ interface LanguageProviderConfig {
|
||||
ancestorNode: SyntaxNode,
|
||||
) => { funcName: string; label: NodeLabel } | null;
|
||||
|
||||
// ── Template constraint extraction (SFINAE / `requires`) ────────────
|
||||
/**
|
||||
* Extract a per-language template-constraint payload for a templated
|
||||
* function / method definition. Used by `parsing-processor` to
|
||||
* disambiguate same-name same-arity overloads whose distinguishing
|
||||
* signal is their template constraints rather than their parameter
|
||||
* types — the canonical C++ SFINAE case (issue #1579):
|
||||
*
|
||||
* template<class T, std::enable_if_t<is_integral_v<T>, int> = 0>
|
||||
* void process(T); // overload A
|
||||
*
|
||||
* template<class T, std::enable_if_t<is_floating_point_v<T>, int> = 0>
|
||||
* void process(T); // overload B
|
||||
*
|
||||
* Both overloads' `parameterTypes` collapse to `['T']`, so without a
|
||||
* constraint fingerprint in the graph node ID they merge into one
|
||||
* Function node and the resolver only ever sees one candidate to
|
||||
* narrow. The hook's return value is stamped onto the node's ID via
|
||||
* `templateConstraintsIdTag()` AND stored on the node's
|
||||
* `templateConstraints` property so `resolveDefGraphId` can look up
|
||||
* the right overload by re-hashing the def's constraints at resolve
|
||||
* time.
|
||||
*
|
||||
* Returns the opaque payload (any JSON-serializable shape — the
|
||||
* producing adapter owns it; shared code MUST NOT inspect) or
|
||||
* `undefined` when no constraints exist / the node isn't a templated
|
||||
* function. Languages without SFINAE / concept semantics leave this
|
||||
* undefined and the disambiguation is a pass-through.
|
||||
*/
|
||||
readonly extractTemplateConstraints?: (definitionNode: SyntaxNode) => unknown;
|
||||
|
||||
// ── Labels ────────────────────────────────────────────────────────
|
||||
/** Override the default node label for definition.function captures.
|
||||
* Return null to skip (C/C++ duplicate), a different label to reclassify
|
||||
|
||||
@@ -55,6 +55,16 @@ import {
|
||||
cImportOwningScope,
|
||||
cReceiverBinding,
|
||||
} from './c/index.js';
|
||||
import {
|
||||
emitCppScopeCaptures,
|
||||
interpretCppImport,
|
||||
interpretCppTypeBinding,
|
||||
cppArityCompatibility,
|
||||
cppBindingScopeFor,
|
||||
cppImportOwningScope,
|
||||
cppReceiverBinding,
|
||||
} from './cpp/index.js';
|
||||
import { extractCppTemplateConstraints } from './cpp/constraint-extractor.js';
|
||||
|
||||
const C_BUILT_INS: ReadonlySet<string> = new Set([
|
||||
'printf',
|
||||
@@ -303,12 +313,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;
|
||||
}
|
||||
@@ -447,4 +464,58 @@ export const cppProvider = defineLanguage({
|
||||
heritageExtractor: createHeritageExtractor(SupportedLanguages.CPlusPlus),
|
||||
labelOverride: cppLabelOverride,
|
||||
builtInNames: C_BUILT_INS,
|
||||
extractTemplateConstraints: extractCppTemplateConstraintsForProvider,
|
||||
|
||||
// ── 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).
|
||||
});
|
||||
|
||||
/**
|
||||
* LanguageProvider hook: walk from a function definition node up to its
|
||||
* enclosing `template_declaration` and extract the SFINAE / `requires`-
|
||||
* clause constraint payload. Used by `parsing-processor` to fingerprint
|
||||
* the graph node ID so two SFINAE overloads with identical
|
||||
* `parameterTypes` get distinct nodes (issue #1579).
|
||||
*
|
||||
* Returns `undefined` for non-templated functions and for templated
|
||||
* functions whose constraints the extractor can't model — both cases
|
||||
* result in no constraint suffix on the node ID.
|
||||
*/
|
||||
function extractCppTemplateConstraintsForProvider(definitionNode: SyntaxNode): unknown {
|
||||
// Walk up to the enclosing template_declaration. Bound the walk so we
|
||||
// can't accidentally land on a far-ancestor template_declaration that
|
||||
// wraps an unrelated function.
|
||||
let cur: SyntaxNode | null = definitionNode.parent;
|
||||
let hops = 8;
|
||||
let templateDecl: SyntaxNode | null = null;
|
||||
while (cur !== null && hops-- > 0) {
|
||||
if (cur.type === 'template_declaration') {
|
||||
templateDecl = cur;
|
||||
break;
|
||||
}
|
||||
if (cur.type === 'translation_unit') break;
|
||||
cur = cur.parent;
|
||||
}
|
||||
if (templateDecl === null) return undefined;
|
||||
|
||||
// Find the function_declarator inside the function definition so the
|
||||
// extractor can map template params to function-argument indices.
|
||||
let declarator: SyntaxNode | null = definitionNode.childForFieldName('declarator');
|
||||
let walk = 8;
|
||||
while (declarator !== null && walk-- > 0) {
|
||||
if (declarator.type === 'function_declarator') break;
|
||||
if (declarator.type === 'pointer_declarator' || declarator.type === 'reference_declarator') {
|
||||
declarator = declarator.childForFieldName('declarator');
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return extractCppTemplateConstraints(templateDecl, declarator);
|
||||
}
|
||||
|
||||
@@ -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<audit::Event>` (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<N::T>`), 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<string, readonly CppAdlArgInfo[]>();
|
||||
const noAdlSites = new Set<string>();
|
||||
const classToNamespaceQualifiedName = new Map<string, string>();
|
||||
|
||||
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<ScopeId, (typeof parsed.scopes)[number]>();
|
||||
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<string>();
|
||||
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<string>();
|
||||
for (const parsed of parsedFiles) {
|
||||
const scopesById = new Map<ScopeId, (typeof parsed.scopes)[number]>();
|
||||
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<string>,
|
||||
): 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<T>). 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<string>,
|
||||
): 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<string>,
|
||||
): 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<ScopeId, (typeof parsed.scopes)[number]>();
|
||||
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<ScopeId, (typeof parsed.scopes)[number]>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
import type { ParameterTypeClass } from 'gitnexus-shared';
|
||||
|
||||
export interface CppArityInfo {
|
||||
parameterCount?: number;
|
||||
requiredParameterCount?: number;
|
||||
parameterTypes?: string[];
|
||||
parameterTypeClasses?: ParameterTypeClass[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<typename... Ts> 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[] = [];
|
||||
const typeClasses: ParameterTypeClass[] = [];
|
||||
for (const p of params) {
|
||||
if (p.type === 'variadic_parameter') {
|
||||
types.push('...');
|
||||
typeClasses.push(unknownTypeClass('...'));
|
||||
} else if (p.type === 'variadic_parameter_declaration') {
|
||||
// Parameter pack: treated as variadic
|
||||
types.push('...');
|
||||
typeClasses.push(unknownTypeClass('...'));
|
||||
} else {
|
||||
const typeNode = p.childForFieldName('type');
|
||||
const rawType = typeNode?.text ?? 'unknown';
|
||||
types.push(normalizeCppParamType(rawType));
|
||||
typeClasses.push(
|
||||
classifyCppParameterType(rawType, p.childForFieldName('declarator')?.text, p.text),
|
||||
);
|
||||
}
|
||||
}
|
||||
// Append '...' for C-style variadic if not already in types
|
||||
if (hasEllipsis && !types.includes('...')) {
|
||||
types.push('...');
|
||||
typeClasses.push(unknownTypeClass('...'));
|
||||
}
|
||||
|
||||
return {
|
||||
parameterCount: isVariadic ? undefined : totalNonVariadic,
|
||||
requiredParameterCount: requiredCount,
|
||||
parameterTypes: types,
|
||||
parameterTypeClasses: typeClasses,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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'`).
|
||||
*
|
||||
* This intentionally remains coarse and graph-ID-stable: cv-qualifiers,
|
||||
* reference markers, and pointer markers are stripped here. C++ callers
|
||||
* that need those distinctions should read `parameterTypeClasses`, which
|
||||
* is an additive sidecar and does not participate in overload node ID
|
||||
* hashing.
|
||||
*/
|
||||
export 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<List<int>> → 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<string, string> = {
|
||||
'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;
|
||||
}
|
||||
|
||||
export function classifyCppParameterType(
|
||||
rawType: string,
|
||||
declaratorText?: string,
|
||||
fullParameterText?: string,
|
||||
): ParameterTypeClass {
|
||||
const source = fullParameterText ?? `${rawType} ${declaratorText ?? ''}`.trim();
|
||||
if (rawType === 'unknown') return unknownTypeClass('unknown');
|
||||
|
||||
const hasConst = /\bconst\b/.test(source);
|
||||
const hasVolatile = /\bvolatile\b/.test(source);
|
||||
const cv: ParameterTypeClass['cv'] =
|
||||
hasConst && hasVolatile
|
||||
? 'const volatile'
|
||||
: hasConst
|
||||
? 'const'
|
||||
: hasVolatile
|
||||
? 'volatile'
|
||||
: 'none';
|
||||
|
||||
const pointerDepth = (source.match(/\*/g) ?? []).length;
|
||||
const indirection: ParameterTypeClass['indirection'] =
|
||||
pointerDepth > 0
|
||||
? 'pointer'
|
||||
: /&&/.test(source)
|
||||
? 'rvalue-ref'
|
||||
: /&/.test(source)
|
||||
? 'lvalue-ref'
|
||||
: 'value';
|
||||
|
||||
return {
|
||||
base: normalizeCppParamType(rawType),
|
||||
cv,
|
||||
indirection,
|
||||
pointerDepth,
|
||||
};
|
||||
}
|
||||
|
||||
function unknownTypeClass(base: string): ParameterTypeClass {
|
||||
return {
|
||||
base,
|
||||
cv: 'unknown',
|
||||
indirection: 'unknown',
|
||||
pointerDepth: 0,
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
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: arity check on non-template params; SFINAE / `requires`
|
||||
* constraints are filtered separately via `constraintCompatibility`
|
||||
* (see `constraint-filter.ts` and issue #1579). Type-argument generic
|
||||
* substitution (`List<T>` ≡ `List<U>`) remains out of V1 scope.
|
||||
*
|
||||
* 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';
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* Extract C++ template constraint expressions for SFINAE-aware overload
|
||||
* narrowing (issue #1579). Recognizes 3 AST shapes:
|
||||
*
|
||||
* F1 — unqualified non-type template param default:
|
||||
* `template<class T, enable_if_t<P, int> = 0> void f(T);`
|
||||
* F2 — `std::`-qualified variant (canonical ticket form):
|
||||
* `template<class T, std::enable_if_t<P, int> = 0> void f(T);`
|
||||
* F4 — C++20 leading requires-clause:
|
||||
* `template<class T> requires P void f(T);`
|
||||
*
|
||||
* Deferred (return `{kind:'unknown'}`):
|
||||
* F3 — void-default `typename = enable_if_t<P>` (cppref labels this
|
||||
* `/* WRONG *\/` because adjacent overloads collapse to redeclarations)
|
||||
* F5 — trailing requires (`void f(T) requires P;`)
|
||||
* `requires_expression` blocks (`requires { typename T::U; }`)
|
||||
* `decltype(...)`, fold-expressions, user-defined `_v` aliases.
|
||||
*
|
||||
* The output payload is opaque to shared code — only
|
||||
* `constraint-filter.ts` consumes it. See ISO `[temp.constr.normal]` /
|
||||
* `<https://en.cppreference.com/w/cpp/language/constraints>` for the
|
||||
* normalization the Kleene 3-valued evaluator implements.
|
||||
*/
|
||||
|
||||
import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
|
||||
export type ConstraintExpr =
|
||||
| { readonly kind: 'atomic'; readonly name: string; readonly args: readonly string[] }
|
||||
| { readonly kind: 'and'; readonly children: readonly ConstraintExpr[] }
|
||||
| { readonly kind: 'or'; readonly children: readonly ConstraintExpr[] }
|
||||
| { readonly kind: 'not'; readonly child: ConstraintExpr }
|
||||
| { readonly kind: 'unknown' };
|
||||
|
||||
export interface CppConstraintPayload {
|
||||
/** Ordered template parameter names (type-params only — non-type defaults
|
||||
* carrying enable_if predicates are folded into `expr`). */
|
||||
readonly templateParams: readonly string[];
|
||||
/**
|
||||
* Mapping from each template parameter name to the call-site argument
|
||||
* index where its deduced type lives. Computed by scanning the function's
|
||||
* parameter list for the first parameter whose type is the bare template
|
||||
* parameter name (or template-typed by it). Missing entries → 'unknown'
|
||||
* verdict at evaluation time.
|
||||
*/
|
||||
readonly paramArgIndex: { readonly [paramName: string]: number };
|
||||
/** Root constraint expression. When multiple constraints (multiple
|
||||
* enable_if defaults, requires clause, etc.) are present they are
|
||||
* implicitly conjoined under a top-level `and` node. */
|
||||
readonly expr: ConstraintExpr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk a `template_declaration` AST node and extract its constraint
|
||||
* payload. Caller is responsible for passing the OUTER `template_declaration`
|
||||
* — for class-member template functions, that means the enclosing
|
||||
* template_declaration of the class OR of the method, whichever
|
||||
* directly precedes the function definition.
|
||||
*
|
||||
* Returns `undefined` when the template_declaration declares no
|
||||
* constraints worth tracking (no enable_if default, no requires clause).
|
||||
* Returns a payload whose `expr.kind === 'unknown'` when constraints are
|
||||
* present but the extractor cannot model them — monotonicity guarantees
|
||||
* the filter keeps the candidate in that case.
|
||||
*/
|
||||
export function extractCppTemplateConstraints(
|
||||
templateDecl: SyntaxNode,
|
||||
funcDeclarator: SyntaxNode | null,
|
||||
): CppConstraintPayload | undefined {
|
||||
const paramList = childOfType(templateDecl, 'template_parameter_list');
|
||||
if (paramList === null) return undefined;
|
||||
|
||||
const templateParams: string[] = [];
|
||||
const exprs: ConstraintExpr[] = [];
|
||||
|
||||
for (let i = 0; i < paramList.namedChildCount; i++) {
|
||||
const param = paramList.namedChild(i);
|
||||
if (param === null) continue;
|
||||
if (
|
||||
param.type === 'type_parameter_declaration' ||
|
||||
param.type === 'optional_type_parameter_declaration' ||
|
||||
param.type === 'variadic_type_parameter_declaration'
|
||||
) {
|
||||
const id = firstDescendantOfType(param, 'type_identifier');
|
||||
if (id !== null) templateParams.push(id.text);
|
||||
continue;
|
||||
}
|
||||
// Non-type parameter — F1 / F2 default-value carries the enable_if
|
||||
// predicate. Shape: `optional_parameter_declaration` with field
|
||||
// `default_value`, whose value is a `template_type` named
|
||||
// `enable_if_t` (F1) or a qualified version (F2).
|
||||
if (param.type === 'optional_parameter_declaration') {
|
||||
const defaultVal = param.childForFieldName('default_value');
|
||||
const typeNode = param.childForFieldName('type');
|
||||
const candidate = extractEnableIfPredicate(typeNode);
|
||||
if (candidate !== undefined) {
|
||||
exprs.push(candidate);
|
||||
} else if (defaultVal !== null) {
|
||||
// Default-value-as-predicate not yet supported. Bail conservatively.
|
||||
exprs.push({ kind: 'unknown' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// F4 — C++20 leading `requires` clause. Tree-sitter-cpp exposes it as a
|
||||
// `requires_clause` child of `template_declaration` (sibling of the
|
||||
// template_parameter_list).
|
||||
const requiresClause = childOfType(templateDecl, 'requires_clause');
|
||||
if (requiresClause !== null) {
|
||||
const parsed = parseRequiresClause(requiresClause);
|
||||
if (parsed !== undefined) exprs.push(parsed);
|
||||
}
|
||||
|
||||
if (templateParams.length === 0 && exprs.length === 0) return undefined;
|
||||
|
||||
const paramArgIndex = buildParamArgIndex(templateParams, funcDeclarator);
|
||||
const expr: ConstraintExpr =
|
||||
exprs.length === 0
|
||||
? { kind: 'unknown' }
|
||||
: exprs.length === 1
|
||||
? exprs[0]
|
||||
: { kind: 'and', children: exprs };
|
||||
|
||||
return { templateParams, paramArgIndex, expr };
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspect a non-type template parameter's declared type to see whether
|
||||
* it's `enable_if_t<P, T>` (F1) or `std::enable_if_t<P, T>` (F2). When
|
||||
* matched, extract the predicate `P` and return it as a `ConstraintExpr`.
|
||||
*
|
||||
* Returns undefined when the parameter's type is not enable_if (so the
|
||||
* caller can decide whether to bail or ignore).
|
||||
*/
|
||||
function extractEnableIfPredicate(typeNode: SyntaxNode | null): ConstraintExpr | undefined {
|
||||
if (typeNode === null) return undefined;
|
||||
// Unwrap a type_descriptor wrapper (when present).
|
||||
let t: SyntaxNode | null = typeNode;
|
||||
if (t.type === 'type_descriptor') {
|
||||
t = t.childForFieldName('type') ?? firstDescendantOfType(t, 'template_type');
|
||||
}
|
||||
// F2 shape: tree-sitter-cpp models `std::enable_if_t<...>` as
|
||||
// `qualified_identifier` whose `name` field is the `template_type`.
|
||||
// F1 shape (unqualified `enable_if_t<...>`) is `template_type` directly.
|
||||
if (t !== null && t.type === 'qualified_identifier') {
|
||||
const inner = t.childForFieldName('name') ?? firstDescendantOfType(t, 'template_type');
|
||||
if (inner !== null && inner.type === 'template_type') {
|
||||
t = inner;
|
||||
}
|
||||
}
|
||||
if (t === null || t.type !== 'template_type') return undefined;
|
||||
|
||||
const nameNode = t.childForFieldName('name');
|
||||
if (nameNode === null) return undefined;
|
||||
const tail = stripQualifiedPrefix(nameNode.text);
|
||||
if (tail !== 'enable_if_t' && tail !== 'enable_if') return undefined;
|
||||
|
||||
// Predicate is the first template argument of enable_if_t.
|
||||
const argList = t.childForFieldName('arguments') ?? childOfType(t, 'template_argument_list');
|
||||
if (argList === null) return { kind: 'unknown' };
|
||||
for (let i = 0; i < argList.namedChildCount; i++) {
|
||||
const arg = argList.namedChild(i);
|
||||
if (arg === null) continue;
|
||||
if (arg.type !== 'type_descriptor') continue;
|
||||
const inner = arg.childForFieldName('type') ?? arg.namedChild(0);
|
||||
if (inner === null) continue;
|
||||
return parseAtomicOrBoolean(inner);
|
||||
}
|
||||
return { kind: 'unknown' };
|
||||
}
|
||||
|
||||
/** Parse a requires-clause body. The body is a binary or unary expression
|
||||
* over atomic predicates (variable templates like `is_integral_v<T>`). */
|
||||
function parseRequiresClause(requiresClause: SyntaxNode): ConstraintExpr | undefined {
|
||||
// tree-sitter-cpp exposes the expression as a named child or via a
|
||||
// `constraint` field. Probe both.
|
||||
let expr: SyntaxNode | null = requiresClause.childForFieldName('constraint');
|
||||
if (expr === null) {
|
||||
for (let i = 0; i < requiresClause.namedChildCount; i++) {
|
||||
const c = requiresClause.namedChild(i);
|
||||
if (c === null) continue;
|
||||
// Skip the `requires` keyword token.
|
||||
if (c.type === 'requires') continue;
|
||||
expr = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (expr === null) return undefined;
|
||||
return parseAtomicOrBoolean(expr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively parse a constraint sub-expression. Recognizes:
|
||||
* - `template_type` / `template_function` named `<predicate>_v` → atomic
|
||||
* - binary_expression with `&&` / `||` → conjunction / disjunction
|
||||
* - unary_expression with `!` → negation
|
||||
* - parenthesized_expression → unwrap
|
||||
* - anything else → `{kind:'unknown'}` (monotonicity-safe)
|
||||
*
|
||||
* `requires_expression` blocks intentionally fall through to 'unknown'
|
||||
* — they need substitution semantics we don't model in V1.
|
||||
*/
|
||||
function parseAtomicOrBoolean(node: SyntaxNode): ConstraintExpr {
|
||||
// Unwrap parentheses.
|
||||
if (node.type === 'parenthesized_expression') {
|
||||
const inner = node.namedChild(0);
|
||||
return inner === null ? { kind: 'unknown' } : parseAtomicOrBoolean(inner);
|
||||
}
|
||||
// Boolean composition.
|
||||
if (node.type === 'binary_expression') {
|
||||
const left = node.childForFieldName('left');
|
||||
const right = node.childForFieldName('right');
|
||||
const opNode = node.childForFieldName('operator');
|
||||
if (left !== null && right !== null && opNode !== null) {
|
||||
const op = opNode.text;
|
||||
const l = parseAtomicOrBoolean(left);
|
||||
const r = parseAtomicOrBoolean(right);
|
||||
if (op === '&&') return { kind: 'and', children: [l, r] };
|
||||
if (op === '||') return { kind: 'or', children: [l, r] };
|
||||
}
|
||||
return { kind: 'unknown' };
|
||||
}
|
||||
if (node.type === 'unary_expression') {
|
||||
const opNode = node.childForFieldName('operator') ?? node.namedChild(0);
|
||||
const arg = node.childForFieldName('argument') ?? node.namedChild(1) ?? node.namedChild(0);
|
||||
if (opNode !== null && opNode.text === '!' && arg !== null && arg !== opNode) {
|
||||
return { kind: 'not', child: parseAtomicOrBoolean(arg) };
|
||||
}
|
||||
return { kind: 'unknown' };
|
||||
}
|
||||
// Atomic predicate — `template_type` is the typical shape for variable
|
||||
// templates like `is_integral_v<T>`. Some grammar variants surface it as
|
||||
// `template_function` or via a `qualified_identifier` wrapper.
|
||||
if (node.type === 'template_type' || node.type === 'template_function') {
|
||||
return parseAtomicTemplate(node);
|
||||
}
|
||||
if (node.type === 'qualified_identifier') {
|
||||
// `std::is_integral_v<T>` shape (without template_type wrapping).
|
||||
const inner = node.childForFieldName('name');
|
||||
if (inner !== null && (inner.type === 'template_type' || inner.type === 'template_function')) {
|
||||
return parseAtomicTemplate(inner);
|
||||
}
|
||||
return { kind: 'unknown' };
|
||||
}
|
||||
// `requires { typename T::U; }` blocks and decltype: out of V1 scope.
|
||||
return { kind: 'unknown' };
|
||||
}
|
||||
|
||||
function parseAtomicTemplate(t: SyntaxNode): ConstraintExpr {
|
||||
const nameNode = t.childForFieldName('name');
|
||||
if (nameNode === null) return { kind: 'unknown' };
|
||||
const name = stripQualifiedPrefix(nameNode.text);
|
||||
const argList = t.childForFieldName('arguments') ?? childOfType(t, 'template_argument_list');
|
||||
const args: string[] = [];
|
||||
if (argList !== null) {
|
||||
for (let i = 0; i < argList.namedChildCount; i++) {
|
||||
const arg = argList.namedChild(i);
|
||||
if (arg === null) continue;
|
||||
if (arg.type !== 'type_descriptor') continue;
|
||||
const inner = arg.childForFieldName('type') ?? arg.namedChild(0);
|
||||
if (inner === null) continue;
|
||||
// For Tier-A predicates the args are bare template-parameter names
|
||||
// (`T`, `U`). Anything more elaborate is bailed via 'unknown' at the
|
||||
// top level if needed; here we just record the textual identifier.
|
||||
const id =
|
||||
inner.type === 'type_identifier' ? inner : firstDescendantOfType(inner, 'type_identifier');
|
||||
args.push(id !== null ? id.text : inner.text);
|
||||
}
|
||||
}
|
||||
return { kind: 'atomic', name, args };
|
||||
}
|
||||
|
||||
/** Build a `paramName → call-site argument index` map by scanning the
|
||||
* function's parameter list for parameters typed by each template param. */
|
||||
function buildParamArgIndex(
|
||||
templateParams: readonly string[],
|
||||
funcDeclarator: SyntaxNode | null,
|
||||
): { [paramName: string]: number } {
|
||||
const out: { [paramName: string]: number } = {};
|
||||
if (funcDeclarator === null || templateParams.length === 0) return out;
|
||||
const paramList = funcDeclarator.childForFieldName('parameters');
|
||||
if (paramList === null) return out;
|
||||
|
||||
let argIdx = 0;
|
||||
for (let i = 0; i < paramList.childCount; i++) {
|
||||
const p = paramList.child(i);
|
||||
if (p === null) continue;
|
||||
if (
|
||||
p.type !== 'parameter_declaration' &&
|
||||
p.type !== 'optional_parameter_declaration' &&
|
||||
p.type !== 'variadic_parameter_declaration'
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const typeNode = p.childForFieldName('type');
|
||||
if (typeNode !== null) {
|
||||
const tname = bareTypeIdentifier(typeNode);
|
||||
if (tname !== null && templateParams.includes(tname) && !(tname in out)) {
|
||||
out[tname] = argIdx;
|
||||
}
|
||||
}
|
||||
argIdx++;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function bareTypeIdentifier(typeNode: SyntaxNode): string | null {
|
||||
if (typeNode.type === 'type_identifier') return typeNode.text;
|
||||
// Allow `T const`, `T&`, `T*` shapes — the inner type_identifier still wins.
|
||||
const id = firstDescendantOfType(typeNode, 'type_identifier');
|
||||
return id !== null ? id.text : null;
|
||||
}
|
||||
|
||||
function stripQualifiedPrefix(text: string): string {
|
||||
const idx = text.lastIndexOf('::');
|
||||
return idx >= 0 ? text.slice(idx + 2) : text;
|
||||
}
|
||||
|
||||
function childOfType(node: SyntaxNode, type: string): SyntaxNode | null {
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const c = node.child(i);
|
||||
if (c !== null && c.type === type) return c;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function firstDescendantOfType(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 = firstDescendantOfType(c, type);
|
||||
if (hit !== null) return hit;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Kleene 3-valued evaluator + curated 4-predicate registry +
|
||||
* `cppConstraintCompatibility` hook export for SFINAE / `requires`-clause
|
||||
* filtering (issue #1579).
|
||||
*
|
||||
* Semantics:
|
||||
* - `'incompatible'` → predicate provably fails for these argumentTypes
|
||||
* (ISO `[temp.constr.atomic]` "not satisfied")
|
||||
* - `'compatible'` → predicate provably holds
|
||||
* - `'unknown'` → cannot decide (missing arg-type info, predicate
|
||||
* not in registry, AST shape bailed during extraction). The shared
|
||||
* filter keeps the candidate on `'unknown'` — monotonicity guarantee.
|
||||
*
|
||||
* Kleene rules (extension of ISO's 2-valued short-circuit conjunction in
|
||||
* `<https://en.cppreference.com/w/cpp/language/constraints>`):
|
||||
* AND: incompatible if any child incompatible; compatible iff all
|
||||
* children compatible; otherwise unknown.
|
||||
* OR: compatible if any child compatible; incompatible iff all
|
||||
* children incompatible; otherwise unknown.
|
||||
* NOT: flip compatible↔incompatible; pass through unknown.
|
||||
*/
|
||||
|
||||
import type { ArityVerdict, Callsite, ConstraintContext, SymbolDefinition } from 'gitnexus-shared';
|
||||
import { classifyType, type TypeClass } from './type-classifier.js';
|
||||
import type { ConstraintExpr, CppConstraintPayload } from './constraint-extractor.js';
|
||||
|
||||
type AtomicEvaluator = (argClasses: readonly TypeClass[]) => ArityVerdict;
|
||||
|
||||
/**
|
||||
* Curated Tier-A predicate registry — the four canonical
|
||||
* `<type_traits>` variable templates whose truth tables are closed-form
|
||||
* over our coarse `TypeClass` enum.
|
||||
*
|
||||
* Deferred predicates that need a cv/ref/pointer sidecar on
|
||||
* `normalizeCppParamType` (today the normalizer strips those markers
|
||||
* before storage) live in #1579 as one-line follow-up adds.
|
||||
*/
|
||||
// ISO `<type_traits>` treats `bool`, `char`, and the signed/unsigned char
|
||||
// variants as integral types (§21.3.4 Table 48), so `is_integral_v<bool>`
|
||||
// and `is_integral_v<char>` must both yield `true`. We keep the `TypeClass`
|
||||
// enum precise (separate `'bool'` / `'char'` buckets) so that
|
||||
// `is_same_v<bool, int>` still resolves to `'incompatible'`; the integral-
|
||||
// family widening lives here in the predicate evaluators instead.
|
||||
function isIntegralClass(c: TypeClass | undefined): boolean {
|
||||
return c === 'integral' || c === 'bool' || c === 'char';
|
||||
}
|
||||
|
||||
const REGISTRY = new Map<string, AtomicEvaluator>([
|
||||
['is_integral_v', (cls) => verdictFromBool(isIntegralClass(cls[0]), cls)],
|
||||
['is_floating_point_v', (cls) => verdictFromBool(cls[0] === 'floating', cls)],
|
||||
[
|
||||
'is_arithmetic_v',
|
||||
(cls) => verdictFromBool(isIntegralClass(cls[0]) || cls[0] === 'floating', cls),
|
||||
],
|
||||
// NOTE: cv-qualifiers are stripped by `normalizeCppParamType` before the
|
||||
// type token reaches `classifyType`, so `is_same_v<const T, T>` returns
|
||||
// `'compatible'` instead of the ISO-correct `false`. Tracked under the
|
||||
// cv-sidecar refactor in #1579's "Out of scope" list; until that lands
|
||||
// this approximation matches the common `is_same_v<T, ConcreteType>`
|
||||
// dispatch idiom and silently degrades on cv-distinct compares.
|
||||
[
|
||||
'is_same_v',
|
||||
(cls) => {
|
||||
if (cls.length < 2 || cls[0] === 'unknown' || cls[1] === 'unknown') return 'unknown';
|
||||
return cls[0] === cls[1] ? 'compatible' : 'incompatible';
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
function verdictFromBool(predicate: boolean, cls: readonly TypeClass[]): ArityVerdict {
|
||||
if (cls[0] === 'unknown') return 'unknown';
|
||||
return predicate ? 'compatible' : 'incompatible';
|
||||
}
|
||||
|
||||
/** Public surface — registered as `ScopeResolver.constraintCompatibility`. */
|
||||
export function cppConstraintCompatibility(
|
||||
_callsite: Callsite,
|
||||
def: SymbolDefinition,
|
||||
ctx: ConstraintContext,
|
||||
): ArityVerdict {
|
||||
const payload = def.templateConstraints as CppConstraintPayload | undefined;
|
||||
if (payload === undefined) return 'unknown';
|
||||
return evaluate(payload.expr, payload, ctx);
|
||||
}
|
||||
|
||||
function evaluate(
|
||||
expr: ConstraintExpr,
|
||||
payload: CppConstraintPayload,
|
||||
ctx: ConstraintContext,
|
||||
): ArityVerdict {
|
||||
switch (expr.kind) {
|
||||
case 'unknown':
|
||||
return 'unknown';
|
||||
case 'atomic': {
|
||||
const evaluator = REGISTRY.get(expr.name);
|
||||
if (evaluator === undefined) return 'unknown';
|
||||
const classes = expr.args.map((paramName) => {
|
||||
const argIdx = payload.paramArgIndex[paramName];
|
||||
if (argIdx === undefined) return 'unknown' as TypeClass;
|
||||
const token = ctx.argumentTypes?.[argIdx];
|
||||
if (token === undefined || token === '') return 'unknown' as TypeClass;
|
||||
return classifyType(token);
|
||||
});
|
||||
return evaluator(classes);
|
||||
}
|
||||
case 'and': {
|
||||
let result: ArityVerdict = 'compatible';
|
||||
for (const child of expr.children) {
|
||||
const v = evaluate(child, payload, ctx);
|
||||
if (v === 'incompatible') return 'incompatible';
|
||||
if (v === 'unknown') result = 'unknown';
|
||||
}
|
||||
return result;
|
||||
}
|
||||
case 'or': {
|
||||
let result: ArityVerdict = 'incompatible';
|
||||
for (const child of expr.children) {
|
||||
const v = evaluate(child, payload, ctx);
|
||||
if (v === 'compatible') return 'compatible';
|
||||
if (v === 'unknown') result = 'unknown';
|
||||
}
|
||||
return result;
|
||||
}
|
||||
case 'not': {
|
||||
const v = evaluate(expr.child, payload, ctx);
|
||||
if (v === 'compatible') return 'incompatible';
|
||||
if (v === 'incompatible') return 'compatible';
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Exposed for unit tests — lets `cpp-constraint.test.ts` assert
|
||||
* `expect(getRegistrySize()).toBe(4)` without exporting the Map itself. */
|
||||
export function getRegistrySize(): number {
|
||||
return REGISTRY.size;
|
||||
}
|
||||
|
||||
/** Exposed for unit tests covering the Kleene 3-valued truth table
|
||||
* directly, without an AST round-trip. */
|
||||
export function evaluateForTest(
|
||||
expr: ConstraintExpr,
|
||||
payload: CppConstraintPayload,
|
||||
ctx: ConstraintContext,
|
||||
): ArityVerdict {
|
||||
return evaluate(expr, payload, ctx);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* C++ conversion-rank scoring for overload resolution (#1578).
|
||||
*
|
||||
* Operates on **normalized** type strings (output of
|
||||
* `normalizeCppParamType` in `arity-metadata.ts`). After normalization:
|
||||
* - int/long/short/unsigned → 'int'
|
||||
* - float/double → 'double'
|
||||
* - char → 'char', bool → 'bool'
|
||||
*
|
||||
* Because the normalizer collapses promotion pairs (int↔long,
|
||||
* float↔double) to the same string, those promotions are invisible at
|
||||
* this layer — they appear as exact matches (rank 0).
|
||||
*
|
||||
* Post-normalization ranking:
|
||||
* - rank 0 — exact (same normalized type)
|
||||
* - rank 1 — integral promotion (char→int, bool→int)
|
||||
* - rank 2 — standard arithmetic conversion (int↔double, char→double,
|
||||
* bool→double)
|
||||
* - Infinity — mismatch (string↔int, user types, pointers, etc.)
|
||||
*
|
||||
* This function is intentionally C++-specific (issue #1578 pitfall:
|
||||
* keep conversion-rank tables out of shared overload-narrowing). Other
|
||||
* languages may define their own `ConversionRankFn` in the future.
|
||||
*/
|
||||
|
||||
/** Set of normalized arithmetic types that support implicit conversion. */
|
||||
const ARITHMETIC = new Set(['int', 'double', 'char', 'bool']);
|
||||
|
||||
/** Integral promotion targets: char→int and bool→int are rank 1. */
|
||||
const INTEGRAL_PROMOTION = new Map([
|
||||
['char', 'int'],
|
||||
['bool', 'int'],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Return the conversion rank from `argType` to `paramType`.
|
||||
*
|
||||
* @returns 0 for exact match, 1 for integral promotion (char/bool→int),
|
||||
* 2 for standard arithmetic conversion, Infinity for mismatch.
|
||||
*/
|
||||
export function cppConversionRank(argType: string, paramType: string): number {
|
||||
if (argType === paramType) return 0;
|
||||
// Integral promotions: char→int, bool→int (ISO C++ [conv.prom])
|
||||
if (INTEGRAL_PROMOTION.get(argType) === paramType) return 1;
|
||||
if (ARITHMETIC.has(argType) && ARITHMETIC.has(paramType)) return 2;
|
||||
return Infinity;
|
||||
}
|
||||
@@ -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<string, Set<string>>();
|
||||
|
||||
/**
|
||||
* 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<string, Set<string>>();
|
||||
|
||||
/**
|
||||
* 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<string, Set<string>>();
|
||||
const anonymousNamespaceScopeIds = new Set<ScopeId>();
|
||||
|
||||
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<string>();
|
||||
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<string>();
|
||||
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<string, Scope>();
|
||||
for (const scope of target.scopes) {
|
||||
for (const ownedDef of scope.ownedDefs) {
|
||||
ownerScopeByNodeId.set(ownedDef.nodeId, scope);
|
||||
}
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
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 ?? '';
|
||||
}
|
||||
@@ -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<string> {
|
||||
const headers = new Set<string>();
|
||||
walk(repoPath, repoPath, headers);
|
||||
return headers;
|
||||
}
|
||||
|
||||
function walk(dir: string, root: string, out: Set<string>): 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, '/'));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string, Capture> = {
|
||||
'@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 <name>;
|
||||
// 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 <qualified_identifier>; (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),
|
||||
};
|
||||
}
|
||||
@@ -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>,
|
||||
): string | null {
|
||||
return resolveCImportTarget(targetRaw, fromFile, allFilePaths);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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<string, Set<string>>();
|
||||
const inlineNamespaceScopeIds = new Set<ScopeId>();
|
||||
|
||||
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<string>();
|
||||
for (const parsed of parsedFiles) {
|
||||
const scopesById = new Map<ScopeId, (typeof parsed.scopes)[number]>();
|
||||
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;
|
||||
}
|
||||
@@ -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<User>` vs `List<Order>`).
|
||||
*
|
||||
* 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;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { BindingRef } from 'gitnexus-shared';
|
||||
|
||||
const TIER: Record<BindingRef['origin'], number> = {
|
||||
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<string>();
|
||||
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;
|
||||
});
|
||||
}
|
||||
@@ -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<User> 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<Dog>()) ─
|
||||
;; 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<Dog>()) ───────
|
||||
;; 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<T>::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<T>::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<T>()) ────────────────────────
|
||||
(call_expression
|
||||
function: (template_function
|
||||
name: (identifier) @reference.name)) @reference.call.free
|
||||
|
||||
;; Note: Ns::func<T>() 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<Parser['setLanguage']>[0]);
|
||||
}
|
||||
return _parser;
|
||||
}
|
||||
|
||||
export function getCppScopeQuery(): Parser.Query {
|
||||
if (_query === null) {
|
||||
_query = new Parser.Query(CPP as Parameters<Parser['setLanguage']>[0], CPP_SCOPE_QUERY);
|
||||
}
|
||||
return _query;
|
||||
}
|
||||
@@ -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<string, string>;
|
||||
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<typeof parser.parse> | 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<string, TypeRef>;
|
||||
|
||||
// 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<User>` text needed for element-type
|
||||
* extraction.
|
||||
*/
|
||||
function buildParamTemplateMap(rootNode: TsNode): Map<string, string> {
|
||||
const map = new Map<string, string>();
|
||||
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>` → `User`
|
||||
* - `std::vector<User>` → `User`
|
||||
* - `map<std::string, User>` → `User` (last template arg)
|
||||
* - `map<string, User>` → `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<string, Scope>,
|
||||
): 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;
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
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 { cppConversionRank } from './conversion-rank.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';
|
||||
import { cppConstraintCompatibility } from './constraint-filter.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<User>` ≡ `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<string> | 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),
|
||||
|
||||
// SFINAE / `requires`-clause aware overload filter (issue #1579).
|
||||
// Drops candidates whose template constraints (`enable_if_t<P, T>`,
|
||||
// C++20 `requires P`) provably fail at the call site. Three-valued —
|
||||
// `'unknown'` keeps the candidate, preserving "degrade not lie".
|
||||
constraintCompatibility: cppConstraintCompatibility,
|
||||
|
||||
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<T>::method()`, text is `Base<T>`). 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<T>::method()` → `Base`; `outer::v1::Base<T>` →
|
||||
// `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,
|
||||
// C++ standard-conversion-sequence ranking for overload resolution (#1578).
|
||||
// Disambiguates `f(int)` vs `f(double)` called with `f(2.5)` by scoring
|
||||
// each candidate's conversion cost; exact match wins over standard conversion.
|
||||
conversionRankFn: cppConversionRank,
|
||||
// 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<T>::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<string>();
|
||||
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<string>();
|
||||
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),
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<T>::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<T>` may be declared in a different header
|
||||
* than `Derived<T>`. `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<className, Set<dependentBaseSimpleName>>
|
||||
*/
|
||||
const dependentBasesByFile = new Map<string, Map<string, Set<string>>>();
|
||||
|
||||
/**
|
||||
* 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<string, Set<string>>();
|
||||
|
||||
/**
|
||||
* 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<string, { nodeId: string; nsPrefix: string }[]>();
|
||||
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<string, ParsedFile>();
|
||||
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<string, { nodeId: string; nsPrefix: string }>();
|
||||
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<T>::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);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Coarse-grained type classifier for C++ constraint evaluation
|
||||
* (`<https://en.cppreference.com/w/cpp/types/is_integral>`,
|
||||
* `<https://en.cppreference.com/w/cpp/types/is_floating_point>`).
|
||||
*
|
||||
* Maps a normalized type token (as produced by `normalizeCppParamType` /
|
||||
* the call-site inference in `captures.ts`) to one of the categories
|
||||
* the `<type_traits>` predicate registry uses for SFINAE filtering.
|
||||
*
|
||||
* Intentionally coarse: cv / pointer / reference qualifiers are stripped
|
||||
* upstream by `normalizeCppParamType`. Tier-A predicates
|
||||
* (`is_integral_v`, `is_floating_point_v`, `is_arithmetic_v`, `is_same_v`)
|
||||
* are insensitive to those modifiers per ISO `<type_traits>` semantics
|
||||
* ("including any cv-qualified variants").
|
||||
*/
|
||||
|
||||
export type TypeClass =
|
||||
| 'integral'
|
||||
| 'floating'
|
||||
| 'bool'
|
||||
| 'char'
|
||||
| 'string'
|
||||
| 'null'
|
||||
| 'class'
|
||||
| 'unknown';
|
||||
|
||||
/**
|
||||
* Classify a normalized C++ type token. The mapping mirrors the literal-
|
||||
* inference table in `captures.ts:inferCppLiteralType` plus the std::
|
||||
* normalization in `arity-metadata.ts:normalizeCppParamType`.
|
||||
*
|
||||
* Caller note: token must already be normalized (no `const`, no `&` / `*`,
|
||||
* no `std::` prefix). Tokens passed via `ConstraintContext.argumentTypes`
|
||||
* coming from `inferCppCallArgTypes` satisfy this.
|
||||
*/
|
||||
export function classifyType(token: string): TypeClass {
|
||||
if (token.length === 0) return 'unknown';
|
||||
switch (token) {
|
||||
case 'int':
|
||||
return 'integral';
|
||||
case 'double':
|
||||
case 'float':
|
||||
return 'floating';
|
||||
case 'bool':
|
||||
return 'bool';
|
||||
case 'char':
|
||||
return 'char';
|
||||
case 'string':
|
||||
return 'string';
|
||||
case 'null':
|
||||
return 'null';
|
||||
default:
|
||||
// After normalization, anything that isn't a recognized primitive
|
||||
// is assumed to be a class-like type. The Tier-A predicate registry
|
||||
// doesn't introspect class types — `is_integral_v` etc. simply
|
||||
// returns `false` for `'class'`, matching ISO behavior.
|
||||
return 'class';
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<ReturnType<typeof getJavaParser>['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<string, Capture> = {};
|
||||
for (const c of m.captures) {
|
||||
const tag = '@' + c.name;
|
||||
grouped[tag] = nodeToCapture(tag, c.node);
|
||||
}
|
||||
if (Object.keys(grouped).length === 0) continue;
|
||||
|
||||
// Decompose each `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<ReturnType<typeof getJavaParser>['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;
|
||||
}
|
||||
@@ -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<string, Capture> = {
|
||||
'@import.statement': nodeToCapture('@import.statement', stmtNode),
|
||||
'@import.kind': syntheticCapture('@import.kind', spec.atNode, spec.kind),
|
||||
'@import.source': syntheticCapture('@import.source', spec.atNode, spec.source),
|
||||
'@import.name': syntheticCapture('@import.name', spec.atNode, spec.name),
|
||||
};
|
||||
return m;
|
||||
}
|
||||
@@ -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<string>;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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<T>` becomes
|
||||
// `BaseModel<T>` 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>` → `User`, `Optional<User>` → `User`).
|
||||
* 2. Known two-arg map/container types → extract the value type
|
||||
* (`Map<String, User>` → `User`).
|
||||
* 3. **Fallback (JVM type erasure):** any other generic type →
|
||||
* strip the generic parameters and keep the raw class name
|
||||
* (`BaseModel<T>` → `BaseModel`, `CustomList<Foo>` → `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<T>` → `BaseModel`, `Builder<Self>` → `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<List<String>>` → `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);
|
||||
}
|
||||
@@ -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<string, BindingRef>();
|
||||
for (const b of survivors) seen.set(b.def.nodeId, b);
|
||||
return [...seen.values()];
|
||||
}
|
||||
@@ -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<Parser['setLanguage']>[0]);
|
||||
}
|
||||
return _parser;
|
||||
}
|
||||
|
||||
export function getJavaScopeQuery(): Parser.Query {
|
||||
if (_query === null) {
|
||||
_query = new Parser.Query(Java as Parameters<Parser['setLanguage']>[0], JAVA_SCOPE_QUERY);
|
||||
}
|
||||
return _query;
|
||||
}
|
||||
@@ -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<string, Capture> = {
|
||||
'@type-binding.self': nodeToCapture('@type-binding.self', anchorNode),
|
||||
'@type-binding.name': syntheticCapture('@type-binding.name', anchorNode, name),
|
||||
'@type-binding.type': syntheticCapture('@type-binding.type', anchorNode, typeText),
|
||||
};
|
||||
return m;
|
||||
}
|
||||
@@ -0,0 +1,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<T>` 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<T>`) — 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 };
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<ReturnType<typeof getPhpParser>['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<ReturnType<typeof getPhpParser>['parse']> | undefined;
|
||||
if (tree === undefined) {
|
||||
tree = parseSourceSafe(getPhpParser(), sourceText, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(sourceText),
|
||||
});
|
||||
recordCacheMiss();
|
||||
} else {
|
||||
recordCacheHit();
|
||||
}
|
||||
|
||||
const rawMatches = getPhpScopeQuery().matches(tree.rootNode);
|
||||
const out: CaptureMatch[] = [];
|
||||
|
||||
// Pre-scan: collect anchor node IDs of property_declaration nodes already
|
||||
// matched by the typed @declaration.property pattern (query.ts ~lines 95–98).
|
||||
// The untyped @declaration.variable catch-all (query.ts ~lines 101–103) is
|
||||
// intentionally loose — it has no `type:` constraint, so tree-sitter also
|
||||
// matches it against typed property declarations and emits a second capture
|
||||
// for the same property_declaration anchor. Graph-level def-id collision
|
||||
// currently masks the duplicate at the node-emit layer, but the catch-all
|
||||
// capture still flows through scope-binding / name-keyed registries with a
|
||||
// `$`-prefixed name that the typed branch's `$`-strip never normalizes —
|
||||
// a known vector for receiver-binding lookup pollution. The two patterns
|
||||
// produce separate rawMatches entries with separate `grouped` maps, so the
|
||||
// dedup has to be cross-match: build the set here, then skip
|
||||
// @declaration.variable matches whose anchor is in it (loop below).
|
||||
const typedPropertyAnchorIds = new Set<number>();
|
||||
for (const m of rawMatches) {
|
||||
for (const c of m.captures) {
|
||||
if (c.name === 'declaration.property') {
|
||||
typedPropertyAnchorIds.add(c.node.id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const m of rawMatches) {
|
||||
// Group captures by their tag name. Tree-sitter strips the leading
|
||||
// `@`; we put it back so the central extractor's prefix lookups work.
|
||||
const grouped: Record<string, Capture> = {};
|
||||
for (const c of m.captures) {
|
||||
const tag = '@' + c.name;
|
||||
grouped[tag] = nodeToCapture(tag, c.node);
|
||||
}
|
||||
if (Object.keys(grouped).length === 0) continue;
|
||||
|
||||
// Cross-match dedup for the typed-property double-match described above:
|
||||
// skip @declaration.variable matches whose anchor was already captured as
|
||||
// @declaration.property in an earlier match.
|
||||
if (grouped['@declaration.variable'] !== undefined) {
|
||||
const varCap = m.captures.find((c) => c.name === 'declaration.variable');
|
||||
if (varCap !== undefined && typedPropertyAnchorIds.has(varCap.node.id)) continue;
|
||||
}
|
||||
|
||||
// Normalize PHP property declarations: strip leading `$` from
|
||||
// `@declaration.name` for @declaration.property matches. PHP stores
|
||||
// field names WITHOUT the `$` sigil in the graph so that member access
|
||||
// lookups like `$user->address` can find the property named `address`
|
||||
// (not `$address`). `@type-binding.annotation` already strips `$` in
|
||||
// `interpretPhpTypeBinding`; this mirrors that for the declaration side.
|
||||
//
|
||||
// Only applies to `@declaration.property` — typed class properties and
|
||||
// constructor-promoted parameters. Untyped `@declaration.variable` keeps
|
||||
// its `$` prefix (those defs are Variable type and not in the field
|
||||
// registry, so their name doesn't affect member lookup).
|
||||
if (
|
||||
grouped['@declaration.property'] !== undefined &&
|
||||
grouped['@declaration.name'] !== undefined
|
||||
) {
|
||||
const nameCap = grouped['@declaration.name'];
|
||||
if (nameCap.text.startsWith('$')) {
|
||||
grouped['@declaration.name'] = { ...nameCap, text: nameCap.text.slice(1) };
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize PHP receiver expressions so the compound-receiver resolver
|
||||
// can walk chains expressed with `->` (PHP) as if they used `.` (the
|
||||
// resolver's canonical separator). Without this, `$user->address->save()`
|
||||
// has receiver text `$user->address` — the resolver sees no `.` separator,
|
||||
// treats it as a bare identifier, and cannot walk field types.
|
||||
//
|
||||
// Transformation applied to `@reference.receiver` captures:
|
||||
// 1. Replace `->` with `.` ($user->address → $user.address)
|
||||
// 2. Strip leading `$` from each segment ($user.address → user.address)
|
||||
// 3. Strip trailing `?` on null-safe receivers ($user? → user)
|
||||
//
|
||||
// This is a PHP-local normalization — no shared pipeline code is changed.
|
||||
if (grouped['@reference.receiver'] !== undefined) {
|
||||
const recvCap = grouped['@reference.receiver']!;
|
||||
const normalized = normalizePhpReceiver(recvCap.text);
|
||||
if (normalized !== recvCap.text) {
|
||||
grouped['@reference.receiver'] = { ...recvCap, text: normalized };
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize static property write: strip leading `$` from `@reference.name`
|
||||
// so `User::$count` resolves to property `count` (stored without `$` in graph).
|
||||
if (grouped['@reference.write.static'] !== undefined) {
|
||||
const nameCap = grouped['@reference.name'];
|
||||
if (nameCap !== undefined && nameCap.text.startsWith('$')) {
|
||||
grouped['@reference.name'] = {
|
||||
...nameCap,
|
||||
text: nameCap.text.slice(1),
|
||||
};
|
||||
}
|
||||
// Re-tag as @reference.write.member so downstream passes see a uniform write kind.
|
||||
grouped['@reference.write.member'] = grouped['@reference.write.static']!;
|
||||
delete grouped['@reference.write.static'];
|
||||
}
|
||||
|
||||
// Decompose each `namespace_use_declaration` so `interpretPhpImport`
|
||||
// sees the kind/source/name/alias markers it consumes.
|
||||
if (grouped['@import.statement'] !== undefined) {
|
||||
const stmtCapture = grouped['@import.statement'];
|
||||
const stmtNode = findNodeAtRange(
|
||||
tree.rootNode,
|
||||
stmtCapture.range,
|
||||
'namespace_use_declaration',
|
||||
);
|
||||
if (stmtNode !== null) {
|
||||
const decomposed = splitNamespaceUseDeclaration(stmtNode);
|
||||
if (decomposed.length > 0) {
|
||||
for (const d of decomposed) out.push(d);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Defensive fallback: emit the raw match.
|
||||
out.push(grouped);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Synthesize `$this` / `parent` receiver type-bindings on every
|
||||
// non-static method-like. Mirrors C#'s `this` / `base` synthesis.
|
||||
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 synthesizePhpReceiverBinding(fnNode)) {
|
||||
out.push(synth);
|
||||
}
|
||||
// Synthesize PHPDoc @param and @return type bindings for this fn.
|
||||
for (const synth of synthesizePhpDocBindings(fnNode)) {
|
||||
out.push(synth);
|
||||
}
|
||||
// Synthesize foreach loop variable bindings inside this fn body.
|
||||
for (const synth of synthesizeForeachBindings(fnNode)) {
|
||||
out.push(synth);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Synthesize arity metadata on function-like declarations so the
|
||||
// registry can narrow overloads.
|
||||
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 = computePhpArityMetadata(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 call site so the registry's
|
||||
// arity filter can narrow overloads. Count the `argument` children of
|
||||
// the backing `arguments` node. Mirrors C#'s pattern (csharp/captures.ts
|
||||
// lines 149-186). PHP needs this for arity-based dispatch (Cluster H).
|
||||
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, 'function_call_expression') ??
|
||||
findNodeAtRange(tree.rootNode, anchor.range, 'member_call_expression') ??
|
||||
findNodeAtRange(tree.rootNode, anchor.range, 'nullsafe_member_call_expression') ??
|
||||
findNodeAtRange(tree.rootNode, anchor.range, 'scoped_call_expression') ??
|
||||
findNodeAtRange(tree.rootNode, anchor.range, 'object_creation_expression');
|
||||
if (callNode !== null) {
|
||||
const argList = callNode.childForFieldName('arguments');
|
||||
const args: SyntaxNode[] = [];
|
||||
if (argList !== null) {
|
||||
for (let i = 0; i < argList.namedChildCount; i++) {
|
||||
const child = argList.namedChild(i);
|
||||
if (child !== null && child.type === 'argument') args.push(child);
|
||||
}
|
||||
}
|
||||
grouped['@reference.arity'] = syntheticCapture(
|
||||
'@reference.arity',
|
||||
callNode,
|
||||
String(args.length),
|
||||
);
|
||||
// Infer argument types from literal nodes for type-based narrowing.
|
||||
// Non-literal arguments emit empty string ("unknown" = any-match).
|
||||
const argTypes = args.map((arg) => inferPhpArgType(arg));
|
||||
grouped['@reference.parameter-types'] = syntheticCapture(
|
||||
'@reference.parameter-types',
|
||||
callNode,
|
||||
JSON.stringify(argTypes),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
out.push(grouped);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Find the first PHP 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;
|
||||
}
|
||||
|
||||
// ─── PHP receiver normalization ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Normalize a PHP receiver expression so the language-agnostic
|
||||
* compound-receiver resolver (which splits on `.`) can walk field-type chains.
|
||||
*
|
||||
* The compound-receiver resolver:
|
||||
* - splits on `.` to get chain segments
|
||||
* - looks up the first segment in `typeBindings` (keyed with `$` for variables)
|
||||
* - walks subsequent segments as field names (stored without `$` in the graph)
|
||||
*
|
||||
* Transformation:
|
||||
* 1. Replace `->` and `?->` with `.` so the resolver's splitter works
|
||||
* 2. Strip any bare `?` fragment left by null-safe chain ends
|
||||
* 3. Strip `$` from all segments EXCEPT the first (which is a variable
|
||||
* and must keep `$` for typeBindings lookup — e.g. `$user → User`)
|
||||
*
|
||||
* Examples:
|
||||
* `$user` → `$user` (bare variable — unchanged)
|
||||
* `$user->address` → `$user.address`
|
||||
* `$user->address->city` → `$user.address.city`
|
||||
* `$user?` → `$user` (null-safe trailing `?` stripped)
|
||||
* `$this` → `$this` (receiverBinding uses `$this`)
|
||||
* `parent` → `parent` (super-receiver check)
|
||||
*/
|
||||
function normalizePhpReceiver(raw: string): string {
|
||||
// Keep `$this`, `parent`, and `self` as-is.
|
||||
if (raw === '$this' || raw === 'parent' || raw === 'self') return raw;
|
||||
|
||||
// Replace `?->` (null-safe) and plain `->` with `.`.
|
||||
let text = raw.replace(/\?->/g, '.').replace(/->/g, '.');
|
||||
// Strip a trailing `?` (null-safe fragment on the last object node).
|
||||
text = text.replace(/\?$/, '');
|
||||
// Collapse any doubled dots from `?->` where `?` was on its own.
|
||||
text = text.replace(/\.{2,}/g, '.');
|
||||
// Strip trailing dot.
|
||||
text = text.replace(/\.$/, '');
|
||||
|
||||
// Split on `.` and strip `$` from all segments EXCEPT the first.
|
||||
// The first segment is a PHP variable (typeBinding key includes `$`).
|
||||
// Subsequent segments are property/method names (stored without `$`).
|
||||
const segments = text.split('.');
|
||||
for (let i = 1; i < segments.length; i++) {
|
||||
const s = segments[i];
|
||||
if (s !== undefined && s.startsWith('$')) segments[i] = s.slice(1);
|
||||
}
|
||||
return segments.join('.');
|
||||
}
|
||||
|
||||
// ─── PHP argument type inference ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Infer the PHP type of a call argument from its literal shape.
|
||||
* Returns an empty string for non-literals (treated as "unknown" = any-match).
|
||||
* Mirrors C#'s `inferArgType` helper.
|
||||
*/
|
||||
function inferPhpArgType(argNode: SyntaxNode): string {
|
||||
// argument node wraps the actual expression
|
||||
const expr = argNode.firstNamedChild ?? argNode;
|
||||
switch (expr.type) {
|
||||
case 'integer':
|
||||
return 'int';
|
||||
case 'float':
|
||||
return 'float';
|
||||
case 'string':
|
||||
case 'encapsed_string':
|
||||
case 'heredoc':
|
||||
case 'nowdoc':
|
||||
return 'string';
|
||||
case 'boolean':
|
||||
case 'true':
|
||||
case 'false':
|
||||
return 'bool';
|
||||
case 'null':
|
||||
return 'null';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── PHPDoc synthesis ─────────────────────────────────────────────────────────
|
||||
|
||||
/** PHP 8+ attribute_list nodes that appear between PHPDoc and method. */
|
||||
const SKIP_SIBLING_TYPES = new Set(['attribute_list', 'attribute', 'comment']);
|
||||
|
||||
/** Regex for PHPDoc @param: standard `@param Type $name` */
|
||||
const PHPDOC_PARAM_RE = /@param\s+(\S+)\s+\$(\w+)/g;
|
||||
/** Regex for PHPDoc @param: alternate `@param $name Type` */
|
||||
const PHPDOC_PARAM_ALT_RE = /@param\s+\$(\w+)\s+(\S+)/g;
|
||||
/** Regex for PHPDoc @return: `@return Type` */
|
||||
const PHPDOC_RETURN_RE = /@return\s+(\S+)/;
|
||||
|
||||
/**
|
||||
* Normalize a PHP type string to a simple class name for binding purposes.
|
||||
* Returns null for primitives or uninformative types.
|
||||
* Mirrors `normalizePhpType` in `interpret.ts` but operates on raw PHPDoc strings.
|
||||
*/
|
||||
function normalizePhpDocType(raw: string): string | null {
|
||||
let type = raw.trim();
|
||||
// Strip nullable prefix
|
||||
if (type.startsWith('?')) type = type.slice(1).trim();
|
||||
// Strip array suffix: User[] → User
|
||||
if (type.endsWith('[]')) type = type.slice(0, -2).trim();
|
||||
// Strip union with null/false/void
|
||||
if (type.includes('|')) {
|
||||
const parts = type
|
||||
.split('|')
|
||||
.map((p) => p.trim())
|
||||
.filter((p) => p !== 'null' && p !== 'false' && p !== 'void' && p !== 'mixed' && p !== '');
|
||||
if (parts.length !== 1) return null;
|
||||
type = parts[0];
|
||||
}
|
||||
// Strip intersection: take first part
|
||||
if (type.includes('&')) {
|
||||
const first = type.split('&')[0].trim();
|
||||
if (first === '') return null;
|
||||
type = first;
|
||||
}
|
||||
// Strip generic wrapper: Collection<User> → User
|
||||
const genericMatch = type.match(/^\w[\w\\]*\s*<([^,<>]+)>$/);
|
||||
if (genericMatch) {
|
||||
type = genericMatch[1].trim();
|
||||
// Strip array suffix again inside generic
|
||||
if (type.endsWith('[]')) type = type.slice(0, -2).trim();
|
||||
}
|
||||
// Strip namespace qualifier: \App\Models\User → User
|
||||
if (type.includes('\\')) {
|
||||
const segs = type.split('\\').filter(Boolean);
|
||||
type = segs[segs.length - 1] ?? type;
|
||||
}
|
||||
// Reject primitives
|
||||
if (PHP_PRIMITIVES.has(type.toLowerCase())) return null;
|
||||
// Must be a simple identifier
|
||||
if (!/^\w+$/.test(type)) return null;
|
||||
return type;
|
||||
}
|
||||
|
||||
const PHP_PRIMITIVES = new Set([
|
||||
'int',
|
||||
'integer',
|
||||
'float',
|
||||
'double',
|
||||
'string',
|
||||
'bool',
|
||||
'boolean',
|
||||
'array',
|
||||
'object',
|
||||
'callable',
|
||||
'iterable',
|
||||
'null',
|
||||
'void',
|
||||
'never',
|
||||
'mixed',
|
||||
'false',
|
||||
'true',
|
||||
'self',
|
||||
'static',
|
||||
'parent',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Collect comment text from siblings immediately before `fnNode`.
|
||||
* Skips PHP 8+ attribute_list nodes.
|
||||
*/
|
||||
function collectPrecedingComments(fnNode: SyntaxNode): string {
|
||||
const texts: string[] = [];
|
||||
let sibling = fnNode.previousSibling;
|
||||
while (sibling !== null) {
|
||||
if (sibling.type === 'comment') {
|
||||
texts.unshift(sibling.text);
|
||||
} else if (sibling.isNamed && !SKIP_SIBLING_TYPES.has(sibling.type)) {
|
||||
break;
|
||||
}
|
||||
sibling = sibling.previousSibling;
|
||||
}
|
||||
return texts.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Synthesize PHPDoc @param and @return type-binding captures for a
|
||||
* method_declaration or function_definition node.
|
||||
*
|
||||
* PHPDoc @param Type $name → `@type-binding.parameter` match (anchored at fn body/return_type).
|
||||
* PHPDoc @return Type → `@type-binding.return` match (anchored at fn name).
|
||||
*/
|
||||
function synthesizePhpDocBindings(fnNode: SyntaxNode): CaptureMatch[] {
|
||||
if (fnNode.type !== 'method_declaration' && fnNode.type !== 'function_definition') return [];
|
||||
|
||||
const commentBlock = collectPrecedingComments(fnNode);
|
||||
if (commentBlock === '') return [];
|
||||
|
||||
const out: CaptureMatch[] = [];
|
||||
|
||||
// Anchor for parameter type-bindings: the function body (or return_type as fallback).
|
||||
// The binding must be inside the function scope so it's visible to body statements.
|
||||
const bodyNode = fnNode.childForFieldName('body');
|
||||
const anchorNode = bodyNode ?? fnNode;
|
||||
|
||||
// ── @param annotations ────────────────────────────────────────────────────
|
||||
PHPDOC_PARAM_RE.lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
const seenParams = new Set<string>();
|
||||
|
||||
while ((m = PHPDOC_PARAM_RE.exec(commentBlock)) !== null) {
|
||||
const rawType = m[1];
|
||||
const paramName = '$' + m[2];
|
||||
const typeName = normalizePhpDocType(rawType);
|
||||
if (typeName === null) continue;
|
||||
seenParams.add(paramName);
|
||||
out.push({
|
||||
'@type-binding.parameter': nodeToCapture('@type-binding.parameter', anchorNode),
|
||||
'@type-binding.name': syntheticCapture('@type-binding.name', anchorNode, paramName),
|
||||
'@type-binding.type': syntheticCapture('@type-binding.type', anchorNode, typeName),
|
||||
});
|
||||
}
|
||||
|
||||
// Also check alternate PHPDoc order: @param $name Type
|
||||
PHPDOC_PARAM_ALT_RE.lastIndex = 0;
|
||||
while ((m = PHPDOC_PARAM_ALT_RE.exec(commentBlock)) !== null) {
|
||||
const paramName = '$' + m[1];
|
||||
if (seenParams.has(paramName)) continue; // standard format takes priority
|
||||
const rawType = m[2];
|
||||
const typeName = normalizePhpDocType(rawType);
|
||||
if (typeName === null) continue;
|
||||
out.push({
|
||||
'@type-binding.parameter': nodeToCapture('@type-binding.parameter', anchorNode),
|
||||
'@type-binding.name': syntheticCapture('@type-binding.name', anchorNode, paramName),
|
||||
'@type-binding.type': syntheticCapture('@type-binding.type', anchorNode, typeName),
|
||||
});
|
||||
}
|
||||
|
||||
// ── @return annotation ────────────────────────────────────────────────────
|
||||
const returnMatch = PHPDOC_RETURN_RE.exec(commentBlock);
|
||||
if (returnMatch !== null) {
|
||||
const rawType = returnMatch[1];
|
||||
const typeName = normalizePhpDocType(rawType);
|
||||
if (typeName !== null) {
|
||||
// @return bindings must be anchored at the method name and hoisted to Module scope
|
||||
// by phpBindingScopeFor (which checks for @type-binding.return presence).
|
||||
// Use the function_definition/method_declaration node itself as the anchor — it
|
||||
// coincides with the innermost scope's range, so auto-hoist kicks in.
|
||||
const nameNode = fnNode.childForFieldName('name') ?? fnNode;
|
||||
out.push({
|
||||
'@type-binding.return': nodeToCapture('@type-binding.return', fnNode),
|
||||
'@type-binding.name': syntheticCapture('@type-binding.name', nameNode, nameNode.text),
|
||||
'@type-binding.type': syntheticCapture('@type-binding.type', nameNode, typeName),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─── Foreach synthesis ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Walk all `foreach_statement` nodes inside `fnNode` and synthesize
|
||||
* `@type-binding.alias` captures binding the loop variable to the
|
||||
* element type of the iterable.
|
||||
*
|
||||
* Supports:
|
||||
* - `foreach ($users as $user)` — simple iterable variable
|
||||
* - `foreach ($users as $k => $user)` — key→value pair
|
||||
* - `foreach ($this->users as $user)` — member access iterable
|
||||
* - `foreach (getUsers() as $user)` — NOT yet supported (needs return type)
|
||||
*
|
||||
* The element type is resolved by:
|
||||
* 1. Looking up the iterable name in PHPDoc @param bindings already
|
||||
* collected for this function (passed via typeBindingsByName).
|
||||
* 2. Direct resolution when iterable's env type IS the element type
|
||||
* (because PHPDoc normalizes `User[]` → `User` already).
|
||||
*/
|
||||
function synthesizeForeachBindings(fnNode: SyntaxNode): CaptureMatch[] {
|
||||
if (
|
||||
fnNode.type !== 'method_declaration' &&
|
||||
fnNode.type !== 'function_definition' &&
|
||||
fnNode.type !== 'anonymous_function' &&
|
||||
fnNode.type !== 'arrow_function'
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const out: CaptureMatch[] = [];
|
||||
|
||||
// Build a mini type map from the function's PHPDoc @param annotations.
|
||||
// This is re-parsed here (not cached from synthesizePhpDocBindings) for simplicity;
|
||||
// the cost is negligible given the small comment sizes.
|
||||
const commentBlock = collectPrecedingComments(fnNode);
|
||||
const paramTypeMap = buildParamTypeMap(commentBlock);
|
||||
|
||||
// Walk the function body for foreach_statement nodes.
|
||||
const bodyNode = fnNode.childForFieldName('body');
|
||||
if (bodyNode === null) return [];
|
||||
collectForeachBindings(bodyNode, fnNode, paramTypeMap, out);
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Build a map of `$paramName → elementTypeName` from PHPDoc @param in a comment block. */
|
||||
function buildParamTypeMap(commentBlock: string): Map<string, string> {
|
||||
const map = new Map<string, string>();
|
||||
if (commentBlock === '') return map;
|
||||
|
||||
PHPDOC_PARAM_RE.lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = PHPDOC_PARAM_RE.exec(commentBlock)) !== null) {
|
||||
const rawType = m[1];
|
||||
const paramName = '$' + m[2];
|
||||
const typeName = normalizePhpDocType(rawType);
|
||||
if (typeName !== null) map.set(paramName, typeName);
|
||||
}
|
||||
PHPDOC_PARAM_ALT_RE.lastIndex = 0;
|
||||
while ((m = PHPDOC_PARAM_ALT_RE.exec(commentBlock)) !== null) {
|
||||
const paramName = '$' + m[1];
|
||||
if (map.has(paramName)) continue;
|
||||
const rawType = m[2];
|
||||
const typeName = normalizePhpDocType(rawType);
|
||||
if (typeName !== null) map.set(paramName, typeName);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk a subtree and collect foreach_statement bindings.
|
||||
* Recursively descends into all child nodes.
|
||||
*/
|
||||
function collectForeachBindings(
|
||||
node: SyntaxNode,
|
||||
fnNode: SyntaxNode,
|
||||
paramTypeMap: Map<string, string>,
|
||||
out: CaptureMatch[],
|
||||
): void {
|
||||
if (node.type === 'foreach_statement') {
|
||||
const synth = synthesizeSingleForeach(node, fnNode, paramTypeMap);
|
||||
if (synth !== null) out.push(synth);
|
||||
}
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const child = node.child(i);
|
||||
if (child !== null) {
|
||||
collectForeachBindings(child, fnNode, paramTypeMap, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Synthesize a single `@type-binding.alias` match for a `foreach_statement`.
|
||||
*
|
||||
* AST structure for foreach_statement (tree-sitter-php):
|
||||
* foreach ( <iterable> as <value_or_pair> ) <body>
|
||||
* Named children (excluding body): first = iterable, second = value or pair.
|
||||
*/
|
||||
function synthesizeSingleForeach(
|
||||
foreachNode: SyntaxNode,
|
||||
fnNode: SyntaxNode,
|
||||
paramTypeMap: Map<string, string>,
|
||||
): CaptureMatch | null {
|
||||
// Collect non-body named children: [iterable, value_or_pair]
|
||||
const bodyNode = foreachNode.childForFieldName('body');
|
||||
const children: SyntaxNode[] = [];
|
||||
for (let i = 0; i < foreachNode.namedChildCount; i++) {
|
||||
const child = foreachNode.namedChild(i);
|
||||
if (child !== null && child !== bodyNode) children.push(child);
|
||||
}
|
||||
if (children.length < 2) return null;
|
||||
|
||||
const iterableNode = children[0];
|
||||
const valueOrPair = children[1];
|
||||
|
||||
// Determine the loop variable node
|
||||
let loopVarNode: SyntaxNode;
|
||||
if (valueOrPair.type === 'pair') {
|
||||
// $key => $value — use the last named child of the pair
|
||||
const lastChild = valueOrPair.namedChild(valueOrPair.namedChildCount - 1);
|
||||
if (lastChild === null) return null;
|
||||
loopVarNode =
|
||||
lastChild.type === 'by_ref' ? (lastChild.firstNamedChild ?? lastChild) : lastChild;
|
||||
} else {
|
||||
loopVarNode =
|
||||
valueOrPair.type === 'by_ref' ? (valueOrPair.firstNamedChild ?? valueOrPair) : valueOrPair;
|
||||
}
|
||||
|
||||
// Loop variable must be a variable_name
|
||||
if (loopVarNode.type !== 'variable_name') return null;
|
||||
const loopVarName = loopVarNode.text; // e.g. '$user'
|
||||
|
||||
// Resolve the element type from the iterable
|
||||
let elementType: string | null = null;
|
||||
|
||||
if (iterableNode.type === 'variable_name') {
|
||||
// foreach ($users as $user) — look up $users in param map
|
||||
const iterableName = iterableNode.text; // e.g. '$users'
|
||||
elementType = paramTypeMap.get(iterableName) ?? null;
|
||||
} else if (iterableNode.type === 'member_access_expression') {
|
||||
// foreach ($this->users as $user) — property name is the field
|
||||
const propNameNode = iterableNode.childForFieldName('name');
|
||||
if (propNameNode !== null) {
|
||||
// Property stored with $ prefix in paramTypeMap (rare for $this->prop patterns)
|
||||
// Try both with and without $ prefix
|
||||
const propKey = '$' + propNameNode.text;
|
||||
elementType = paramTypeMap.get(propKey) ?? null;
|
||||
if (elementType === null) {
|
||||
// Try to find the property type from the enclosing class
|
||||
elementType = findClassPropertyElementType(iterableNode, fnNode);
|
||||
}
|
||||
}
|
||||
} else if (iterableNode.type === 'function_call_expression') {
|
||||
// foreach (getUsers() as $user) — use the function name as a type alias.
|
||||
// The function's @return annotation produces a @type-binding.return binding
|
||||
// in the Module scope (e.g. getUsers → User). The scope-extractor's
|
||||
// followChainedRef will resolve $user → getUsers → User.
|
||||
const funcNode = iterableNode.childForFieldName('function');
|
||||
if (funcNode !== null && funcNode.type === 'name') {
|
||||
elementType = funcNode.text; // e.g. 'getUsers' — chain will be resolved later
|
||||
}
|
||||
} else if (iterableNode.type === 'member_call_expression') {
|
||||
// foreach ($this->getUsers() as $user) — use the method name as a type alias.
|
||||
const methodNameNode = iterableNode.childForFieldName('name');
|
||||
if (methodNameNode !== null) {
|
||||
elementType = methodNameNode.text; // e.g. 'getUsers'
|
||||
}
|
||||
}
|
||||
|
||||
if (elementType === null) return null;
|
||||
|
||||
// Anchor the binding inside the foreach body so it's scoped to the loop.
|
||||
const anchorNode = bodyNode ?? foreachNode;
|
||||
|
||||
return {
|
||||
'@type-binding.alias': nodeToCapture('@type-binding.alias', anchorNode),
|
||||
'@type-binding.name': syntheticCapture('@type-binding.name', anchorNode, loopVarName),
|
||||
'@type-binding.type': syntheticCapture('@type-binding.type', anchorNode, elementType),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to find the element type for `$this->property` member access by walking
|
||||
* up from the foreach to the enclosing class and scanning the property declaration.
|
||||
*/
|
||||
function findClassPropertyElementType(
|
||||
memberAccessNode: SyntaxNode,
|
||||
fnNode: SyntaxNode,
|
||||
): string | null {
|
||||
const propNameNode = memberAccessNode.childForFieldName('name');
|
||||
if (propNameNode === null) return null;
|
||||
const propName = propNameNode.text;
|
||||
|
||||
// Walk up from fnNode to find the enclosing class declaration
|
||||
let cur: SyntaxNode | null = fnNode.parent;
|
||||
while (cur !== null) {
|
||||
if (cur.type === 'class_declaration' || cur.type === 'trait_declaration') {
|
||||
break;
|
||||
}
|
||||
cur = cur.parent;
|
||||
}
|
||||
if (cur === null) return null;
|
||||
|
||||
// Find the property_declaration with matching variable_name '$propName'
|
||||
const declList = cur.childForFieldName('body');
|
||||
if (declList === null) return null;
|
||||
|
||||
for (let i = 0; i < declList.namedChildCount; i++) {
|
||||
const child = declList.namedChild(i);
|
||||
if (child === null || child.type !== 'property_declaration') continue;
|
||||
for (let j = 0; j < child.namedChildCount; j++) {
|
||||
const elem = child.namedChild(j);
|
||||
if (elem === null || elem.type !== 'property_element') continue;
|
||||
const varNameNode = elem.firstNamedChild;
|
||||
if (varNameNode === null || varNameNode.text !== '$' + propName) continue;
|
||||
// Found the property — get its element type from @var PHPDoc or native type
|
||||
return extractPropertyElementType(child);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Regex for PHPDoc @var: `@var Type` */
|
||||
const PHPDOC_VAR_RE = /@var\s+(\S+)/;
|
||||
|
||||
/**
|
||||
* Extract element type from a property_declaration node:
|
||||
* 1. PHPDoc @var annotation on a preceding comment sibling
|
||||
* 2. PHP 7.4+ native type field (non-array)
|
||||
*/
|
||||
function extractPropertyElementType(propDecl: SyntaxNode): string | null {
|
||||
// Strategy 1: PHPDoc @var on a preceding comment sibling
|
||||
let sibling = propDecl.previousSibling;
|
||||
while (sibling !== null) {
|
||||
if (sibling.type === 'comment') {
|
||||
const m = PHPDOC_VAR_RE.exec(sibling.text);
|
||||
if (m !== null) return normalizePhpDocType(m[1]);
|
||||
} else if (sibling.isNamed && !SKIP_SIBLING_TYPES.has(sibling.type)) {
|
||||
break;
|
||||
}
|
||||
sibling = sibling.previousSibling;
|
||||
}
|
||||
// Strategy 2: native type field — skip generic 'array'
|
||||
const typeNode = propDecl.childForFieldName('type');
|
||||
if (typeNode === null) return null;
|
||||
const typeName = typeNode.text.trim();
|
||||
if (typeName === 'array' || typeName === '') return null;
|
||||
return normalizePhpDocType(typeName);
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* Decompose a PHP `namespace_use_declaration` into one or more
|
||||
* `CaptureMatch` objects carrying the synthesized markers
|
||||
* `@import.kind` / `@import.source` / `@import.name` / `@import.alias`
|
||||
* that `interpretPhpImport` consumes.
|
||||
*
|
||||
* PHP import forms handled:
|
||||
*
|
||||
* use Foo\Bar; → namespace, localName=Bar
|
||||
* use Foo\Bar as Baz; → alias, localName=Baz
|
||||
* use function Foo\bar; → function, localName=bar
|
||||
* use const Foo\BAR; → const, localName=BAR
|
||||
* use Foo\{A, B as C}; → grouped: one match per clause
|
||||
* use function Foo\{f, g as h}; → grouped function variants
|
||||
* use const Foo\{X, Y as Z}; → grouped const variants
|
||||
*
|
||||
* Unlike C#'s decomposer this is 1:N — each grouped use_declaration
|
||||
* fans out to one CaptureMatch per inner clause.
|
||||
*/
|
||||
|
||||
import type { Capture, CaptureMatch } from 'gitnexus-shared';
|
||||
import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
|
||||
export type PhpImportKind = 'namespace' | 'alias' | 'function' | 'const';
|
||||
|
||||
interface PhpImportSpec {
|
||||
readonly kind: PhpImportKind;
|
||||
/** Full backslash-separated path (backslashes intact): `Foo\Bar\Baz`. */
|
||||
readonly source: string;
|
||||
/** Local binding name — last source segment for plain imports, the
|
||||
* alias identifier for aliased imports. */
|
||||
readonly name: string;
|
||||
/** Present iff kind === 'alias'. */
|
||||
readonly alias?: string;
|
||||
/** Anchor node for synthesized captures (range-wise). */
|
||||
readonly atNode: SyntaxNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decompose a `namespace_use_declaration` node into one `CaptureMatch`
|
||||
* per logical import. Returns `[]` when the node is unrecognized or
|
||||
* carries no resolvable clauses.
|
||||
*/
|
||||
export function splitNamespaceUseDeclaration(stmtNode: SyntaxNode): CaptureMatch[] {
|
||||
if (stmtNode.type !== 'namespace_use_declaration') return [];
|
||||
|
||||
// Detect qualifier keyword: `use function` / `use const`
|
||||
// tree-sitter-php uses a `use_type` or `function`/`const` keyword
|
||||
// child to distinguish them. We scan the raw text before the first
|
||||
// backslash-path child.
|
||||
const qualifier = detectQualifier(stmtNode);
|
||||
|
||||
// Grouped use: `use Foo\{A, B as C}` — find namespace_use_group child.
|
||||
const groupNode = findNamedChild(stmtNode, 'namespace_use_group');
|
||||
if (groupNode !== null) {
|
||||
return decomposeGrouped(stmtNode, groupNode, qualifier);
|
||||
}
|
||||
|
||||
// Single use clause (possibly aliased).
|
||||
const spec = parseSingleUseClause(stmtNode, qualifier);
|
||||
if (spec === null) return [];
|
||||
return [buildImportMatch(stmtNode, spec)];
|
||||
}
|
||||
|
||||
// ── Qualifier detection ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Return the qualifier keyword appearing after `use`:
|
||||
* `'function'`, `'const'`, or `null` for plain namespace use.
|
||||
*
|
||||
* tree-sitter-php emits the qualifier as a `name` node with text
|
||||
* "function" or "const" (not a keyword token in recent grammars),
|
||||
* or as a dedicated `use_type` node. We inspect the node's raw text
|
||||
* to be grammar-version-agnostic.
|
||||
*/
|
||||
function detectQualifier(node: SyntaxNode): PhpImportKind {
|
||||
const raw = node.text;
|
||||
// Match `use function` or `use const` at the start (after optional whitespace)
|
||||
if (/^\s*use\s+function\s/i.test(raw)) return 'function';
|
||||
if (/^\s*use\s+const\s/i.test(raw)) return 'const';
|
||||
return 'namespace';
|
||||
}
|
||||
|
||||
// ── Single clause parsing ──────────────────────────────────────────────────
|
||||
|
||||
function parseSingleUseClause(node: SyntaxNode, qualifier: PhpImportKind): PhpImportSpec | null {
|
||||
// A plain `namespace_use_declaration` has one or more
|
||||
// `namespace_use_clause` named children (each clause is one import,
|
||||
// comma-separated for multiple). For the single case there is one.
|
||||
const clause = findNamedChild(node, 'namespace_use_clause');
|
||||
if (clause !== null) return parseUseClause(clause, qualifier);
|
||||
|
||||
// Older grammar versions may put the qualified_name directly under
|
||||
// the declaration node. Check for a qualified_name or name child.
|
||||
const qualName = findNamedChild(node, 'qualified_name') ?? findNamedChild(node, 'name');
|
||||
if (qualName === null) return null;
|
||||
const source = qualName.text.trim();
|
||||
if (source === '') return null;
|
||||
return {
|
||||
kind: qualifier,
|
||||
source,
|
||||
name: lastSegment(source),
|
||||
atNode: node,
|
||||
};
|
||||
}
|
||||
|
||||
function parseUseClause(clause: SyntaxNode, qualifier: PhpImportKind): PhpImportSpec | null {
|
||||
// namespace_use_clause:
|
||||
// qualified_name (or name)
|
||||
// optional: alias_clause → "as" name (some grammar versions)
|
||||
// optional: bare name node (tree-sitter-php ≥ 0.22 emits the
|
||||
// alias as a sibling `name` node
|
||||
// directly, not inside alias_clause)
|
||||
const qualName = findNamedChild(clause, 'qualified_name') ?? findNamedChild(clause, 'name');
|
||||
if (qualName === null) return null;
|
||||
const source = qualName.text.trim();
|
||||
if (source === '') return null;
|
||||
|
||||
// Strategy 1: explicit alias_clause wrapper (older grammar versions).
|
||||
const aliasClause = findNamedChild(clause, 'alias_clause');
|
||||
if (aliasClause !== null) {
|
||||
// alias_clause: "as" name
|
||||
const aliasName = findNamedChild(aliasClause, 'name') ?? aliasClause.firstNamedChild;
|
||||
const alias = aliasName?.text.trim() ?? '';
|
||||
if (alias === '') return null;
|
||||
return {
|
||||
kind: 'alias',
|
||||
source,
|
||||
name: alias,
|
||||
alias,
|
||||
atNode: clause,
|
||||
};
|
||||
}
|
||||
|
||||
// Strategy 2: bare sibling `name` node after the qualified_name.
|
||||
// tree-sitter-php (≥ 0.22) emits `use Foo\Bar as Baz` as:
|
||||
// namespace_use_clause
|
||||
// qualified_name "Foo\Bar"
|
||||
// name "Baz" ← alias, no alias_clause wrapper
|
||||
// Detect by: clause has ≥2 named children AND the last named child is
|
||||
// a `name` node that differs from the qualName node.
|
||||
if (clause.namedChildCount >= 2) {
|
||||
const lastChild = clause.namedChild(clause.namedChildCount - 1);
|
||||
if (lastChild !== null && lastChild !== qualName && lastChild.type === 'name') {
|
||||
const alias = lastChild.text.trim();
|
||||
if (alias !== '') {
|
||||
return {
|
||||
kind: 'alias',
|
||||
source,
|
||||
name: alias,
|
||||
alias,
|
||||
atNode: clause,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: qualifier,
|
||||
source,
|
||||
name: lastSegment(source),
|
||||
atNode: clause,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Grouped use decomposition ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Decompose `use Foo\Bar\{A, B as C, function f, const X}` into one
|
||||
* `CaptureMatch` per inner clause.
|
||||
*
|
||||
* The leading prefix (`Foo\Bar`) is prepended to each inner path.
|
||||
* Inner clauses can override the qualifier with their own `function` /
|
||||
* `const` keyword inside the group.
|
||||
*/
|
||||
function decomposeGrouped(
|
||||
stmtNode: SyntaxNode,
|
||||
groupNode: SyntaxNode,
|
||||
outerQualifier: PhpImportKind,
|
||||
): CaptureMatch[] {
|
||||
// The prefix is the qualified_name that precedes the `{...}` group.
|
||||
const prefixNode = findNamedChild(stmtNode, 'qualified_name') ?? findNamedChild(stmtNode, 'name');
|
||||
const prefix = prefixNode?.text.trim() ?? '';
|
||||
|
||||
const out: CaptureMatch[] = [];
|
||||
|
||||
for (let i = 0; i < groupNode.namedChildCount; i++) {
|
||||
const child = groupNode.namedChild(i);
|
||||
if (child === null) continue;
|
||||
|
||||
// Each child in a group may be:
|
||||
// namespace_use_clause — plain or aliased
|
||||
// namespace_use_type — `function` or `const` qualifier inside group
|
||||
// We detect an inline qualifier by checking the raw text of the clause.
|
||||
if (child.type !== 'namespace_use_clause') continue;
|
||||
|
||||
const innerQualifier = detectInnerQualifier(child) ?? outerQualifier;
|
||||
const spec = parseInnerClause(child, prefix, innerQualifier);
|
||||
if (spec !== null) {
|
||||
out.push(buildImportMatch(stmtNode, spec));
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect an inline qualifier keyword inside a grouped clause.
|
||||
* e.g. `use Foo\{function bar, const BAZ}` — each clause may start with
|
||||
* `function` or `const`.
|
||||
*/
|
||||
function detectInnerQualifier(clause: SyntaxNode): PhpImportKind | null {
|
||||
const raw = clause.text.trim();
|
||||
if (/^function\s/i.test(raw)) return 'function';
|
||||
if (/^const\s/i.test(raw)) return 'const';
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseInnerClause(
|
||||
clause: SyntaxNode,
|
||||
prefix: string,
|
||||
qualifier: PhpImportKind,
|
||||
): PhpImportSpec | null {
|
||||
const qualName = findNamedChild(clause, 'qualified_name') ?? findNamedChild(clause, 'name');
|
||||
if (qualName === null) return null;
|
||||
|
||||
// Strip inline `function` / `const` text prefix if present in the text.
|
||||
let innerPath = qualName.text.trim();
|
||||
innerPath = innerPath.replace(/^(?:function|const)\s+/i, '').trim();
|
||||
if (innerPath === '') return null;
|
||||
|
||||
const source = prefix !== '' ? `${prefix}\\${innerPath}` : innerPath;
|
||||
|
||||
// Strategy 1: explicit alias_clause wrapper (older grammar versions).
|
||||
const aliasClause = findNamedChild(clause, 'alias_clause');
|
||||
if (aliasClause !== null) {
|
||||
const aliasName = findNamedChild(aliasClause, 'name') ?? aliasClause.firstNamedChild;
|
||||
const alias = aliasName?.text.trim() ?? '';
|
||||
if (alias === '') return null;
|
||||
return {
|
||||
kind: 'alias',
|
||||
source,
|
||||
name: alias,
|
||||
alias,
|
||||
atNode: clause,
|
||||
};
|
||||
}
|
||||
|
||||
// Strategy 2: bare sibling `name` node after the qualified_name (tree-sitter-php ≥ 0.22).
|
||||
if (clause.namedChildCount >= 2) {
|
||||
const lastChild = clause.namedChild(clause.namedChildCount - 1);
|
||||
if (lastChild !== null && lastChild !== qualName && lastChild.type === 'name') {
|
||||
const alias = lastChild.text.trim();
|
||||
if (alias !== '') {
|
||||
return {
|
||||
kind: 'alias',
|
||||
source,
|
||||
name: alias,
|
||||
alias,
|
||||
atNode: clause,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: qualifier,
|
||||
source,
|
||||
name: lastSegment(innerPath),
|
||||
atNode: clause,
|
||||
};
|
||||
}
|
||||
|
||||
// ── CaptureMatch builder ───────────────────────────────────────────────────
|
||||
|
||||
function buildImportMatch(stmtNode: SyntaxNode, spec: PhpImportSpec): CaptureMatch {
|
||||
const m: Record<string, Capture> = {
|
||||
'@import.statement': nodeToCapture('@import.statement', stmtNode),
|
||||
'@import.kind': syntheticCapture('@import.kind', spec.atNode, spec.kind),
|
||||
'@import.source': syntheticCapture('@import.source', spec.atNode, spec.source),
|
||||
'@import.name': syntheticCapture('@import.name', spec.atNode, spec.name),
|
||||
};
|
||||
if (spec.alias !== undefined) {
|
||||
m['@import.alias'] = syntheticCapture('@import.alias', spec.atNode, spec.alias);
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Last backslash-separated segment: `Foo\Bar\Baz` → `Baz`. */
|
||||
function lastSegment(path: string): string {
|
||||
const parts = path.split('\\').filter(Boolean);
|
||||
return parts[parts.length - 1] ?? path;
|
||||
}
|
||||
|
||||
/** Find the first named child with a given node type. */
|
||||
function findNamedChild(node: SyntaxNode, type: string): SyntaxNode | null {
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (child !== null && child.type === type) return child;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Adapter from `(ParsedImport, WorkspaceIndex)` → concrete file path.
|
||||
*
|
||||
* Delegates to the existing `resolvePhpImportInternal` (PSR-4 via
|
||||
* composer.json + suffix matching fallback). The `WorkspaceIndex` is
|
||||
* opaque at this layer; consumers wire a `PhpResolveContext` shape
|
||||
* carrying `fromFile` + `allFilePaths`.
|
||||
*
|
||||
* `loadPhpComposerConfig` is the `ScopeResolver.loadResolutionConfig`
|
||||
* implementation — it loads `composer.json` once per workspace pass and
|
||||
* threads the parsed config into every subsequent `resolveImportTarget`
|
||||
* call via the opaque `resolutionConfig` parameter.
|
||||
*
|
||||
* Returning `null` lets the finalize algorithm mark the edge as
|
||||
* `linkStatus: 'unresolved'`.
|
||||
*/
|
||||
|
||||
import type { ParsedImport, WorkspaceIndex } from 'gitnexus-shared';
|
||||
import { resolvePhpImportInternal } from '../../import-resolvers/php.js';
|
||||
import type { ComposerConfig } from '../../language-config.js';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
export interface PhpResolveContext {
|
||||
readonly fromFile: string;
|
||||
readonly allFilePaths: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
// ─── loadResolutionConfig ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Load and parse `composer.json` from the repo root. Returns a
|
||||
* `ComposerConfig` object (PSR-4 namespace → directory mappings) or
|
||||
* `null` when no `composer.json` is present or it cannot be parsed.
|
||||
*
|
||||
* The result is threaded into each `resolvePhpImportInternal` call as
|
||||
* the `composerConfig` argument.
|
||||
*/
|
||||
export function loadPhpComposerConfig(repoPath: string): ComposerConfig | null {
|
||||
try {
|
||||
const composerPath = join(repoPath, 'composer.json');
|
||||
const raw = readFileSync(composerPath, 'utf8');
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (typeof parsed !== 'object' || parsed === null) return null;
|
||||
|
||||
const composer = parsed as Record<string, unknown>;
|
||||
const autoload = composer['autoload'] as Record<string, unknown> | undefined;
|
||||
if (autoload === undefined) return null;
|
||||
|
||||
const psr4Raw = (autoload['psr-4'] ?? {}) as Record<string, string | string[]>;
|
||||
const psr4 = new Map<string, string>();
|
||||
|
||||
for (const [ns, dirs] of Object.entries(psr4Raw)) {
|
||||
// namespace prefix ends with `\` — keep as-is; resolver strips it
|
||||
const normalizedNs = ns.replace(/\\$/, '');
|
||||
const dir = Array.isArray(dirs) ? dirs[0] : dirs;
|
||||
if (typeof dir === 'string') {
|
||||
// Normalize directory path (strip trailing slash)
|
||||
const normalizedDir = dir.replace(/\/+$/, '');
|
||||
psr4.set(normalizedNs, normalizedDir);
|
||||
}
|
||||
}
|
||||
|
||||
return { psr4 };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── resolvePhpImportTarget ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* LanguageProvider-shaped adapter: `(ParsedImport, WorkspaceIndex) → string | null`.
|
||||
*
|
||||
* The `WorkspaceIndex` is `unknown` in the shared contract. The scope-resolution
|
||||
* orchestrator hands us a `PhpResolveContext`-shaped object; narrow structurally
|
||||
* rather than via a cast chain so unexpected shapes return `null` cleanly.
|
||||
*/
|
||||
export function resolvePhpImportTarget(
|
||||
parsedImport: ParsedImport,
|
||||
workspaceIndex: WorkspaceIndex,
|
||||
): string | null {
|
||||
const ctx = workspaceIndex as PhpResolveContext | 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;
|
||||
|
||||
const allFiles = ctx.allFilePaths as Set<string>;
|
||||
const normalizedFileList = [...allFiles].map((f) => f.replace(/\\/g, '/'));
|
||||
const allFileList = [...allFiles];
|
||||
|
||||
return resolvePhpImportInternal(
|
||||
parsedImport.targetRaw,
|
||||
null, // composerConfig not available through LanguageProvider path
|
||||
allFiles,
|
||||
normalizedFileList,
|
||||
allFileList,
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ScopeResolver-shaped adapter: `(targetRaw, fromFile, allFilePaths, resolutionConfig?) → string | null`.
|
||||
*
|
||||
* Used inside `scope-resolver.ts`. Accepts the optional `resolutionConfig`
|
||||
* (a `ComposerConfig | null` loaded once per workspace by
|
||||
* `loadPhpComposerConfig`) and threads it into `resolvePhpImportInternal`.
|
||||
*/
|
||||
export function resolvePhpImportTargetInternal(
|
||||
targetRaw: string,
|
||||
_fromFile: string,
|
||||
allFilePaths: ReadonlySet<string>,
|
||||
resolutionConfig?: unknown,
|
||||
): string | null {
|
||||
if (targetRaw === '') return null;
|
||||
|
||||
const composerConfig =
|
||||
resolutionConfig !== undefined && resolutionConfig !== null
|
||||
? (resolutionConfig as ComposerConfig)
|
||||
: null;
|
||||
|
||||
const allFiles = allFilePaths as Set<string>;
|
||||
const normalizedFileList = [...allFiles].map((f) => f.replace(/\\/g, '/'));
|
||||
const allFileList = [...allFiles];
|
||||
|
||||
return resolvePhpImportInternal(
|
||||
targetRaw,
|
||||
composerConfig,
|
||||
allFiles,
|
||||
normalizedFileList,
|
||||
allFileList,
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* PHP scope-resolution hooks (RFC #909 Ring 3 LANG-php, #938).
|
||||
*
|
||||
* Public API barrel. Consumers should import from this file rather than
|
||||
* the individual modules.
|
||||
*
|
||||
* Module layout (each file is a single concern):
|
||||
*
|
||||
* - `query.ts` — tree-sitter query + lazy parser/query singletons
|
||||
* - `captures.ts` — `emitPhpScopeCaptures` orchestrator
|
||||
* - `import-decomposer.ts` — each `namespace_use_declaration` → ParsedImport captures
|
||||
* - `interpret.ts` — capture-match → `ParsedImport` / `ParsedTypeBinding`
|
||||
* - `simple-hooks.ts` — small/no-op hooks made explicit
|
||||
* - `receiver-binding.ts` — synthesize `$this` / `parent` type-bindings on
|
||||
* instance-method entry
|
||||
* - `merge-bindings.ts` — PHP `use` precedence (local > import > wildcard)
|
||||
* - `arity.ts` — PHP arity compatibility (variadic, defaults)
|
||||
* - `arity-metadata.ts` — synthesize arity metadata from declarations
|
||||
* - `import-target.ts` — `(ParsedImport, WorkspaceIndex) → file path` adapter
|
||||
* wrapping `resolvePhpImportInternal` (PSR-4 + composer.json)
|
||||
* - `scope-resolver.ts` — `ScopeResolver` registered in `SCOPE_RESOLVERS`
|
||||
* - `cache-stats.ts` — PROF_SCOPE_RESOLUTION cache hit/miss counters
|
||||
*
|
||||
* ## Known limitations
|
||||
*
|
||||
* The PHP registry-primary path intentionally does NOT resolve the following.
|
||||
* Each is a conscious trade-off at migration time.
|
||||
*
|
||||
* 1. **Trait `$this` → using-class binding** — for methods defined in a
|
||||
* trait, `$this` is synthesized as a binding to the trait itself.
|
||||
* Resolving `$this` to the actual using-class type requires cross-file
|
||||
* analysis of all `use TraitName;` declarations in class bodies.
|
||||
* Deferred to a follow-up; trait method resolution falls back to the
|
||||
* trait scope.
|
||||
*
|
||||
* 2. **Anonymous classes** — `new class extends Foo { }` have no stable
|
||||
* class name and are skipped by receiver-binding synthesis. The class
|
||||
* body is still scoped; member lookups inside it will fall back to
|
||||
* free-call resolution.
|
||||
*
|
||||
* 3. **Dynamic property/method access** — `$obj->{$name}()` and
|
||||
* `$$varName` are not followed. The dynamic receiver is ignored and
|
||||
* the call falls through to the shared free-call resolver.
|
||||
*
|
||||
* 4. **Magic methods** — `__get`, `__set`, `__call`, `__callStatic` are
|
||||
* not modeled as virtual dispatch; they appear as regular method
|
||||
* declarations in the graph but calls that would route through them
|
||||
* at runtime are not distinguished.
|
||||
*
|
||||
* 5. **Laravel facade magic** — `App::make(...)`, `Cache::get(...)` etc.
|
||||
* resolve statically to the Facade class rather than the underlying
|
||||
* bound implementation. Deferred to a Laravel-specific plugin.
|
||||
*
|
||||
* 6. **Intersection types in parameters** — `T&U $param` takes the first
|
||||
* named part (`T`). This matches the legacy type-extractor's behavior.
|
||||
*
|
||||
* Shadow-harness corpus parity is the authoritative signal for which of
|
||||
* these matter in practice. The CI parity gate blocks any PR that regresses
|
||||
* either the legacy or registry-primary run of
|
||||
* `test/integration/resolvers/php.test.ts`.
|
||||
*/
|
||||
|
||||
export { emitPhpScopeCaptures } from './captures.js';
|
||||
export { getPhpCaptureCacheStats, resetPhpCaptureCacheStats } from './cache-stats.js';
|
||||
export { interpretPhpImport, interpretPhpTypeBinding } from './interpret.js';
|
||||
export { phpMergeBindings } from './merge-bindings.js';
|
||||
export { phpArityCompatibility } from './arity.js';
|
||||
export { resolvePhpImportTarget, type PhpResolveContext } from './import-target.js';
|
||||
export { phpBindingScopeFor, phpImportOwningScope, phpReceiverBinding } from './simple-hooks.js';
|
||||
// NOTE: phpScopeResolver is intentionally NOT re-exported from this barrel.
|
||||
// Importing it here would create a circular dependency:
|
||||
// php.ts → php/index.js → php/scope-resolver.js → ../php.js
|
||||
// Registry and other consumers must import directly from './php/scope-resolver.js'.
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* Capture-match → semantic-shape interpreters for PHP.
|
||||
*
|
||||
* - `interpretPhpImport` → `ParsedImport`
|
||||
* - `interpretPhpTypeBinding` → `ParsedTypeBinding`
|
||||
*
|
||||
* Import matches arrive pre-decomposed by `emitPhpScopeCaptures` (one
|
||||
* CaptureMatch per logical import, with synthesized `@import.kind /
|
||||
* source / name / alias` markers). Type-binding matches arrive from
|
||||
* the raw query captures — each `@type-binding.*` anchor carries
|
||||
* `@type-binding.name` + `@type-binding.type`.
|
||||
*/
|
||||
|
||||
import type { CaptureMatch, ParsedImport, ParsedTypeBinding, TypeRef } from 'gitnexus-shared';
|
||||
|
||||
// ─── interpretImport ──────────────────────────────────────────────────────
|
||||
|
||||
export function interpretPhpImport(captures: CaptureMatch): ParsedImport | null {
|
||||
const kindCap = captures['@import.kind'];
|
||||
const sourceCap = captures['@import.source'];
|
||||
const nameCap = captures['@import.name'];
|
||||
const aliasCap = captures['@import.alias'];
|
||||
|
||||
const kind = kindCap?.text;
|
||||
if (kind === undefined || sourceCap === undefined) return null;
|
||||
|
||||
const source = sourceCap.text.trim();
|
||||
if (source === '') return null;
|
||||
|
||||
switch (kind) {
|
||||
case 'namespace': {
|
||||
// `use Foo\Bar;` — PHP `use` is a NAMED import (binds the class
|
||||
// `Bar`, not the namespace `Foo`). This differs from C# `using`,
|
||||
// which is a true namespace import. Producing 'named' here makes
|
||||
// `new Bar()` resolve to the imported class def.
|
||||
const localName = nameCap?.text.trim() ?? lastSegment(source);
|
||||
return {
|
||||
kind: 'named',
|
||||
localName,
|
||||
importedName: localName,
|
||||
targetRaw: source,
|
||||
};
|
||||
}
|
||||
case 'alias': {
|
||||
// `use Foo\Bar as Baz;`
|
||||
if (aliasCap === undefined) return null;
|
||||
const alias = aliasCap.text.trim();
|
||||
if (alias === '') return null;
|
||||
const importedName = lastSegment(source);
|
||||
return {
|
||||
kind: 'alias',
|
||||
localName: alias,
|
||||
importedName,
|
||||
alias,
|
||||
targetRaw: source,
|
||||
};
|
||||
}
|
||||
case 'function': {
|
||||
// `use function Foo\bar;` — treat as named import; importedName is
|
||||
// the function name (last segment). targetRaw is the full path.
|
||||
const localName = nameCap?.text.trim() ?? lastSegment(source);
|
||||
return {
|
||||
kind: 'named',
|
||||
localName,
|
||||
importedName: localName,
|
||||
targetRaw: source,
|
||||
};
|
||||
}
|
||||
case 'const': {
|
||||
// `use const Foo\BAR;` — same shape as function.
|
||||
const localName = nameCap?.text.trim() ?? lastSegment(source);
|
||||
return {
|
||||
kind: 'named',
|
||||
localName,
|
||||
importedName: localName,
|
||||
targetRaw: source,
|
||||
};
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── interpretTypeBinding ─────────────────────────────────────────────────
|
||||
|
||||
export function interpretPhpTypeBinding(captures: CaptureMatch): ParsedTypeBinding | null {
|
||||
const nameCap = captures['@type-binding.name'];
|
||||
const typeCap = captures['@type-binding.type'];
|
||||
if (nameCap === undefined || typeCap === undefined) return null;
|
||||
|
||||
// Determine source from anchor captures. Order: most-specific first.
|
||||
let source: TypeRef['source'] = 'parameter-annotation';
|
||||
if (captures['@type-binding.self'] !== undefined) source = 'self';
|
||||
else if (captures['@type-binding.constructor'] !== undefined) source = 'constructor-inferred';
|
||||
else if (captures['@type-binding.annotation'] !== undefined) source = 'annotation';
|
||||
else if (captures['@type-binding.alias'] !== undefined) source = 'assignment-inferred';
|
||||
else if (captures['@type-binding.return'] !== undefined) source = 'return-annotation';
|
||||
|
||||
let rawType: string | null;
|
||||
|
||||
if (source === 'assignment-inferred') {
|
||||
// `@type-binding.alias` captures cover several assignment RHS shapes:
|
||||
// - `$alias = $u` → rawType = '$u' (variable alias)
|
||||
// - `$u = getUser()` → rawType = 'getUser' (callable alias)
|
||||
// - `$u = new User()` → rawType = 'User' (constructor — via @type-binding.constructor; handled below)
|
||||
// - `$role = UserRole::Viewer` → rawType = 'UserRole' (enum/class constant)
|
||||
//
|
||||
// For variable aliases (`$u`), `normalizePhpType` returns null because
|
||||
// `$` is not a word character. We must preserve the raw `$`-prefixed name
|
||||
// so `followChainedRef` can walk the chain `$alias → $u → User`.
|
||||
// For callable/class names, `normalizePhpType` strips qualifiers correctly.
|
||||
const rawText = typeCap.text.trim();
|
||||
if (rawText.startsWith('$')) {
|
||||
// Variable alias: keep as-is for chain-following.
|
||||
rawType = rawText;
|
||||
} else {
|
||||
rawType = normalizePhpType(rawText);
|
||||
}
|
||||
} else {
|
||||
// All other sources: strip PHP type decoration to get the simple class name:
|
||||
// ?User → User (nullable prefix)
|
||||
// User|null → User (union with null/false/void)
|
||||
// User&Loggable → User (intersection — take first meaningful)
|
||||
// Collection<User> → User (PHPDoc generic wrapper)
|
||||
// User[] → User (array suffix)
|
||||
// \App\Models\User → User (backslash qualifier)
|
||||
rawType = normalizePhpType(typeCap.text.trim());
|
||||
}
|
||||
|
||||
if (rawType === null) return null;
|
||||
|
||||
// PHP variable names include the `$` sigil (e.g. `$user`). Most
|
||||
// bindings keep it because they are looked up via the variable
|
||||
// (`$user->method()` finds binding `$user`). Property field bindings
|
||||
// are different: `$user->address` looks up `address` (no sigil) on
|
||||
// the User class. Property declarations carry source `'annotation'`,
|
||||
// so we strip the leading `$` for that source only.
|
||||
let boundName = nameCap.text.trim();
|
||||
if (source === 'annotation' && boundName.startsWith('$')) {
|
||||
boundName = boundName.slice(1);
|
||||
}
|
||||
|
||||
return { boundName, rawTypeName: rawType, source };
|
||||
}
|
||||
|
||||
// ─── Type normalization ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Normalize a PHP type string to a simple class identifier, or `null`
|
||||
* when the type is uninformative (primitive, void, mixed, self, etc.).
|
||||
*
|
||||
* Rules applied in order:
|
||||
* 1. Strip nullable prefix `?`
|
||||
* 2. Split on `|` (union) — keep only if exactly one non-null part
|
||||
* 3. Take first part of `&` intersection
|
||||
* 4. Strip array suffix `[]`
|
||||
* 5. Strip generic wrapper `Collection<User>` → `User`
|
||||
* 6. Canonicalize leading backslash off: `\App\Models\User` → `App\Models\User`
|
||||
* 7. Reject PHP primitive / pseudo types
|
||||
*
|
||||
* The qualified form is preserved on `TypeRef.rawName` so downstream PHP
|
||||
* receiver resolution can distinguish `\App\Other\User` from a same-simple-name
|
||||
* `User` reachable via `use`. Without this, fully-qualified type hints collapse
|
||||
* to ambiguous simple names and resolve against the caller's scope chain
|
||||
* instead of the explicit target the source named (Codex PR #1497 review,
|
||||
* finding 1).
|
||||
*/
|
||||
export function normalizePhpType(raw: string): string | null {
|
||||
// 1. Strip nullable prefix
|
||||
let type = raw.startsWith('?') ? raw.slice(1).trim() : raw;
|
||||
|
||||
// 2. Union type — keep only if one non-null/false/void part remains
|
||||
if (type.includes('|')) {
|
||||
const parts = type
|
||||
.split('|')
|
||||
.map((p) => p.trim())
|
||||
.filter((p) => p !== 'null' && p !== 'false' && p !== 'void' && p !== 'mixed' && p !== '');
|
||||
if (parts.length !== 1) return null;
|
||||
type = parts[0];
|
||||
}
|
||||
|
||||
// 3. Intersection type — take the first part
|
||||
if (type.includes('&')) {
|
||||
const first = type.split('&')[0].trim();
|
||||
if (first === '') return null;
|
||||
type = first;
|
||||
}
|
||||
|
||||
// 4. Strip array suffix
|
||||
if (type.endsWith('[]')) type = type.slice(0, -2).trim();
|
||||
|
||||
// 5. Strip single-arg generic wrapper: Collection<User> → User
|
||||
// Qualified inner types (Collection<\App\Models\User>) survive — the
|
||||
// capture group preserves whatever the writer named.
|
||||
const genericMatch = type.match(/^\w[\w\\]*\s*<([^,<>]+)>$/);
|
||||
if (genericMatch) {
|
||||
type = genericMatch[1].trim();
|
||||
}
|
||||
|
||||
// 6. Canonicalize leading backslash off — keep the qualified path intact.
|
||||
// `\App\Models\User` → `App\Models\User`. `App\Models\User` → unchanged.
|
||||
// Unqualified `User` stays as `User`. The qualified form is the lookup
|
||||
// key into the workspace QualifiedNameIndex (PHP defs are indexed by
|
||||
// namespace-joined qualifiedName); the leading-backslash distinction in
|
||||
// source is only an "absolute path" anchor, not part of the canonical key.
|
||||
if (type.startsWith('\\')) type = type.replace(/^\\+/, '');
|
||||
|
||||
// 7. Reject primitives / pseudo-types
|
||||
if (isPrimitiveOrPseudo(type)) return null;
|
||||
|
||||
// Must be a (possibly qualified) PHP identifier — segments of word chars
|
||||
// separated by single backslashes. Empty segments (consecutive backslashes,
|
||||
// trailing backslash) are rejected.
|
||||
if (!/^\w+(?:\\\w+)*$/.test(type)) return null;
|
||||
|
||||
return type;
|
||||
}
|
||||
|
||||
const PHP_PRIMITIVE_TYPES = new Set([
|
||||
'int',
|
||||
'integer',
|
||||
'float',
|
||||
'double',
|
||||
'string',
|
||||
'bool',
|
||||
'boolean',
|
||||
'array',
|
||||
'object',
|
||||
'callable',
|
||||
'iterable',
|
||||
'null',
|
||||
'void',
|
||||
'never',
|
||||
'mixed',
|
||||
'false',
|
||||
'true',
|
||||
'self',
|
||||
'static',
|
||||
'parent',
|
||||
]);
|
||||
|
||||
function isPrimitiveOrPseudo(type: string): boolean {
|
||||
return PHP_PRIMITIVE_TYPES.has(type.toLowerCase());
|
||||
}
|
||||
|
||||
/** Last backslash-separated segment: `Foo\Bar\Baz` → `Baz`. */
|
||||
function lastSegment(path: string): string {
|
||||
const parts = path.split('\\').filter(Boolean);
|
||||
return parts[parts.length - 1] ?? path;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* PHP shadowing precedence for the `mergeBindings` hook.
|
||||
*
|
||||
* Tier ranking (lower wins in shadowing):
|
||||
*
|
||||
* - 0: `local` — a class member, method, local variable, or parameter
|
||||
* declared in this scope.
|
||||
* - 1: `import` / `namespace` / `reexport` — `use Foo\Bar;`,
|
||||
* `use Foo\Bar as Baz;`, `use function`, `use const`.
|
||||
* All use-statement flavors that introduce a name sit at this tier.
|
||||
* - 2: `wildcard` — grouped uses / wildcard imports (deferred; mapped
|
||||
* here for completeness).
|
||||
*
|
||||
* Within a surviving tier we de-dup by `DefId`, last-write-wins so a
|
||||
* `use` re-declared further down the file cleanly replaces the earlier
|
||||
* binding.
|
||||
*/
|
||||
|
||||
import type { BindingRef } from 'gitnexus-shared';
|
||||
|
||||
const TIER_LOCAL = 0;
|
||||
const TIER_IMPORT = 1;
|
||||
const TIER_WILDCARD = 2;
|
||||
const TIER_UNKNOWN = 3;
|
||||
|
||||
function tierOf(b: BindingRef): number {
|
||||
switch (b.origin) {
|
||||
case 'local':
|
||||
return TIER_LOCAL;
|
||||
case 'reexport':
|
||||
case 'import':
|
||||
case 'namespace':
|
||||
return TIER_IMPORT;
|
||||
case 'wildcard':
|
||||
return TIER_WILDCARD;
|
||||
default:
|
||||
return TIER_UNKNOWN;
|
||||
}
|
||||
}
|
||||
|
||||
export function phpMergeBindings(bindings: readonly BindingRef[]): readonly BindingRef[] {
|
||||
if (bindings.length === 0) return bindings;
|
||||
|
||||
let bestTier = Number.POSITIVE_INFINITY;
|
||||
for (const b of bindings) bestTier = Math.min(bestTier, tierOf(b));
|
||||
const survivors = bindings.filter((b) => tierOf(b) === bestTier);
|
||||
|
||||
const seen = new Map<string, BindingRef>();
|
||||
for (const b of survivors) seen.set(b.def.nodeId, b);
|
||||
return [...seen.values()];
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* PHP same-namespace cross-file visibility.
|
||||
*
|
||||
* In PHP, every class declared in `namespace Foo\Bar` is visible to all
|
||||
* other files in the same namespace WITHOUT an explicit `use` statement.
|
||||
* Without this pass, `Service.php` (namespace `App\Services`) can't see
|
||||
* `User` declared in `Models.php` (namespace `App\Models`) unless
|
||||
* `UserService.php` has an explicit `use App\Models\User` statement.
|
||||
*
|
||||
* More importantly, A.php (namespace `App\Models`) can return `Greeting`
|
||||
* (same namespace `App\Models`) without importing it, and the compound-
|
||||
* receiver resolver needs to find `Greeting` as a class binding in the
|
||||
* scope chain.
|
||||
*
|
||||
* Implementation mirrors C#'s `namespace-siblings.ts`:
|
||||
* 1. Extract the declared namespace from each PHP file's source.
|
||||
* 2. Group class-like defs by namespace.
|
||||
* 3. Inject sibling class defs into each file's Module scope's
|
||||
* `bindingAugmentations` with `origin: 'namespace'`.
|
||||
* 4. Also mirror return-type bindings from same-namespace siblings
|
||||
* so cross-file chain-follow finds return types without explicit imports.
|
||||
*
|
||||
* Uses the PHP tree-sitter parser (via the lazy singleton in `query.ts`)
|
||||
* to extract namespace declarations — same AST that `extractParsedFile`
|
||||
* already parsed, reused via `treeCache` to avoid double-parsing.
|
||||
*/
|
||||
|
||||
import type { BindingRef, ParsedFile, Scope, ScopeId, SymbolDefinition } from 'gitnexus-shared';
|
||||
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
|
||||
import { getPhpParser } from './query.js';
|
||||
import { getTreeSitterBufferSize } from '../../constants.js';
|
||||
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
|
||||
|
||||
// ─── PHP file structure extraction ──────────────────────────────────────────
|
||||
|
||||
interface PhpFileStructure {
|
||||
/** The declared namespace (backslash-separated), or '' for global namespace. */
|
||||
readonly namespace: string;
|
||||
}
|
||||
|
||||
type PhpTree = ReturnType<ReturnType<typeof getPhpParser>['parse']>;
|
||||
|
||||
/**
|
||||
* Extract the declared namespace from a PHP file's source.
|
||||
* Uses the cached AST tree when available to avoid re-parsing.
|
||||
*/
|
||||
function extractPhpFileStructure(content: string, cachedTree: unknown): PhpFileStructure {
|
||||
const tree =
|
||||
(cachedTree as PhpTree | undefined) ??
|
||||
parseSourceSafe(getPhpParser(), content, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(content),
|
||||
});
|
||||
|
||||
// Walk top-level nodes looking for namespace_definition.
|
||||
// PHP files have at most one namespace declaration (PSR-4 convention).
|
||||
// `namespace_definition` has a `name:` field of type `namespace_name`.
|
||||
const root = tree.rootNode;
|
||||
for (let i = 0; i < root.namedChildCount; i++) {
|
||||
const child = root.namedChild(i);
|
||||
if (child === null) continue;
|
||||
if (child.type === 'namespace_definition') {
|
||||
const nameNode = child.childForFieldName('name');
|
||||
if (nameNode !== null) {
|
||||
return { namespace: nameNode.text };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { namespace: '' };
|
||||
}
|
||||
|
||||
// ─── Augmentation bucket helper ─────────────────────────────────────────────
|
||||
|
||||
function getAugmentationBucket(
|
||||
augmentations: Map<ScopeId, Map<string, BindingRef[]>>,
|
||||
scopeId: ScopeId,
|
||||
name: string,
|
||||
): BindingRef[] {
|
||||
let scopeBindings = augmentations.get(scopeId);
|
||||
if (scopeBindings === undefined) {
|
||||
scopeBindings = new Map<string, BindingRef[]>();
|
||||
augmentations.set(scopeId, scopeBindings);
|
||||
}
|
||||
let bucket = scopeBindings.get(name);
|
||||
if (bucket === undefined) {
|
||||
bucket = [];
|
||||
scopeBindings.set(name, bucket);
|
||||
}
|
||||
return bucket;
|
||||
}
|
||||
|
||||
function isClassLikeDef(def: SymbolDefinition): boolean {
|
||||
return (
|
||||
def.type === 'Class' ||
|
||||
def.type === 'Interface' ||
|
||||
def.type === 'Struct' ||
|
||||
def.type === 'Enum' ||
|
||||
def.type === 'Trait'
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Public entry point ──────────────────────────────────────────────────────
|
||||
|
||||
export interface PhpSiblingInputs {
|
||||
readonly fileContents: ReadonlyMap<string, string>;
|
||||
readonly treeCache?: { get(filePath: string): unknown };
|
||||
}
|
||||
|
||||
/**
|
||||
* Side-channel cache populated by `populatePhpNamespaceSiblings` so that
|
||||
* later visibility-check hooks (e.g., `isCallableVisibleFromCaller`) can
|
||||
* look up a file's PHP namespace without re-parsing. Cleared at the start
|
||||
* of every populate run so stale entries don't leak across resolutions.
|
||||
*/
|
||||
const namespaceByFilePath = new Map<string, string>();
|
||||
|
||||
/**
|
||||
* Read the cached PHP namespace for a given filePath. Returns `''` (global)
|
||||
* when the file has no namespace_definition or hasn't been processed yet.
|
||||
* Callers should only consult this AFTER either `populatePhpClassQualifiedNames`
|
||||
* or `populatePhpNamespaceSiblings` has run for the current resolution.
|
||||
*/
|
||||
export function getPhpNamespaceForFile(filePath: string): string {
|
||||
return namespaceByFilePath.get(filePath) ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject same-namespace class defs and return-type bindings into each
|
||||
* PHP file's Module scope's `bindingAugmentations`. This makes classes
|
||||
* in the same PHP namespace visible to each other without explicit `use`
|
||||
* statements, mirroring PHP's actual runtime behavior.
|
||||
*
|
||||
* Uses `origin: 'namespace'` so `phpMergeBindings` tiers it below
|
||||
* explicit `use` imports (`origin: 'import'`) and local declarations.
|
||||
*/
|
||||
export function populatePhpNamespaceSiblings(
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
indexes: ScopeResolutionIndexes,
|
||||
inputs: PhpSiblingInputs,
|
||||
): void {
|
||||
// Step 1: extract namespace structure for each file. Also seed the
|
||||
// side-channel cache used by visibility-check hooks downstream.
|
||||
namespaceByFilePath.clear();
|
||||
const structureByFile = new Map<string, PhpFileStructure>();
|
||||
for (const parsed of parsedFiles) {
|
||||
const content = inputs.fileContents.get(parsed.filePath);
|
||||
if (content === undefined) continue;
|
||||
const cachedTree = inputs.treeCache?.get(parsed.filePath);
|
||||
const struct = extractPhpFileStructure(content, cachedTree);
|
||||
structureByFile.set(parsed.filePath, struct);
|
||||
namespaceByFilePath.set(parsed.filePath, struct.namespace);
|
||||
}
|
||||
|
||||
// Step 2: group class-like defs and module scopes by namespace.
|
||||
interface NamespaceBucket {
|
||||
readonly scopes: { filePath: string; scopeId: ScopeId; scope: Scope }[];
|
||||
readonly classDefs: SymbolDefinition[];
|
||||
}
|
||||
const buckets = new Map<string, NamespaceBucket>();
|
||||
const getBucket = (ns: string): NamespaceBucket => {
|
||||
let b = buckets.get(ns);
|
||||
if (b === undefined) {
|
||||
b = { scopes: [], classDefs: [] };
|
||||
buckets.set(ns, b);
|
||||
}
|
||||
return b;
|
||||
};
|
||||
|
||||
for (const parsed of parsedFiles) {
|
||||
const struct = structureByFile.get(parsed.filePath);
|
||||
if (struct === undefined) continue;
|
||||
const ns = struct.namespace;
|
||||
const bucket = getBucket(ns);
|
||||
|
||||
// Register the file's module scope in the bucket.
|
||||
const moduleScope = parsed.scopes.find((s) => s.kind === 'Module');
|
||||
if (moduleScope !== undefined) {
|
||||
bucket.scopes.push({
|
||||
filePath: parsed.filePath,
|
||||
scopeId: moduleScope.id,
|
||||
scope: moduleScope,
|
||||
});
|
||||
}
|
||||
|
||||
// Collect class-like defs declared at the top-level of this file
|
||||
// (defs in Class or Module scopes, excluding nested inner classes).
|
||||
for (const scope of parsed.scopes) {
|
||||
if (scope.kind !== 'Class') continue;
|
||||
// Only top-level class scopes (parent is Module or Namespace scope).
|
||||
if (scope.parent === null) continue;
|
||||
const parentScope = parsed.scopes.find((s) => s.id === scope.parent);
|
||||
if (
|
||||
parentScope === undefined ||
|
||||
(parentScope.kind !== 'Module' && parentScope.kind !== 'Namespace')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
for (const def of scope.ownedDefs) {
|
||||
if (isClassLikeDef(def)) {
|
||||
bucket.classDefs.push(def);
|
||||
break; // one class-like per scope
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const augmentations = indexes.bindingAugmentations as Map<ScopeId, Map<string, BindingRef[]>>;
|
||||
|
||||
// Step 3: For each namespace bucket, inject sibling class bindings
|
||||
// into every file's Module scope (that is NOT the declaring file).
|
||||
for (const [, bucket] of buckets) {
|
||||
// Build name → def map (simple name of qualifiedName).
|
||||
const defsByName = new Map<string, SymbolDefinition[]>();
|
||||
for (const def of bucket.classDefs) {
|
||||
const q = def.qualifiedName ?? '';
|
||||
const simpleName = q.includes('.')
|
||||
? q.slice(q.lastIndexOf('.') + 1)
|
||||
: q.includes('\\')
|
||||
? q.slice(q.lastIndexOf('\\') + 1)
|
||||
: q;
|
||||
if (simpleName === '') continue;
|
||||
const arr = defsByName.get(simpleName) ?? [];
|
||||
arr.push(def);
|
||||
defsByName.set(simpleName, arr);
|
||||
}
|
||||
|
||||
for (const { filePath, scopeId, scope } of bucket.scopes) {
|
||||
for (const [name, defs] of defsByName) {
|
||||
// Skip if already locally declared (origin: 'local' wins).
|
||||
const local = scope.bindings.get(name);
|
||||
if (local !== undefined && local.some((b) => b.origin === 'local')) continue;
|
||||
|
||||
for (const def of defs) {
|
||||
if (def.filePath === filePath) continue; // don't self-inject
|
||||
const arr = getAugmentationBucket(augmentations, scopeId, name);
|
||||
if (arr.some((b) => b.def.nodeId === def.nodeId)) continue;
|
||||
arr.push({ def, origin: 'namespace' });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3b: Inject fully-qualified-name bindings into every PHP file's
|
||||
// Module scope. PHP `\App\Models\User` (leading-backslash FQN) and
|
||||
// `App\Models\User` (already-qualified relative) on a parameter or
|
||||
// typed receiver must resolve to the exact namespace-qualified class
|
||||
// regardless of which simple-name `User` the caller's `use` imports
|
||||
// shadowed. The shared `findClassBindingInScope` scope-chain walk
|
||||
// consumes these augmentations via `lookupBindingsAt`, so adding the
|
||||
// qualified key on every file's module scope routes FQN-receivers to
|
||||
// the right def. Codex PR #1497 review, finding 1.
|
||||
//
|
||||
// Cost: O(PHP files × class-like defs in the workspace) augmentation
|
||||
// entries. Bounded and acceptable in practice — typical PHP projects
|
||||
// have hundreds of files and classes, not tens of thousands.
|
||||
for (const parsed of parsedFiles) {
|
||||
const moduleScope = parsed.scopes.find((s) => s.kind === 'Module');
|
||||
if (moduleScope === undefined) continue;
|
||||
const moduleScopeId = moduleScope.id;
|
||||
|
||||
for (const [ns, bucket] of buckets) {
|
||||
if (ns === '') continue; // global-namespace classes have no qualified form to register
|
||||
for (const def of bucket.classDefs) {
|
||||
const q = def.qualifiedName ?? '';
|
||||
const simpleName = q.includes('\\') ? q.slice(q.lastIndexOf('\\') + 1) : q;
|
||||
if (simpleName === '') continue;
|
||||
const fqn = `${ns}\\${simpleName}`;
|
||||
const arr = getAugmentationBucket(augmentations, moduleScopeId, fqn);
|
||||
if (arr.some((b) => b.def.nodeId === def.nodeId)) continue;
|
||||
arr.push({ def, origin: 'namespace' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Mirror return-type bindings from same-namespace sibling files.
|
||||
// This enables chain-follow like `$c->greet()->save()` where `greet()`
|
||||
// returns `Greeting` (declared in A.php, same namespace) and `Greeting`
|
||||
// isn't imported in the calling file. Without this, the compound-receiver
|
||||
// resolver can't resolve `Greeting` as a class binding in the importer's
|
||||
// scope chain.
|
||||
//
|
||||
// Additionally, mirror from files that are imported via `use` (different
|
||||
// namespace) so return types from dependencies are chain-followable too.
|
||||
for (const parsed of parsedFiles) {
|
||||
const moduleScope = parsed.scopes.find((s) => s.kind === 'Module');
|
||||
if (moduleScope === undefined) continue;
|
||||
const moduleTypeBindings = moduleScope.typeBindings as Map<
|
||||
string,
|
||||
import('gitnexus-shared').TypeRef
|
||||
>;
|
||||
|
||||
const struct = structureByFile.get(parsed.filePath);
|
||||
const ownNs = struct?.namespace ?? '';
|
||||
|
||||
// Collect namespaces accessible from this file:
|
||||
// 1. Own namespace (same-ns siblings)
|
||||
// 2. Namespaces of directly imported files (via parsedImports → targetRaw → PSR-4 namespace)
|
||||
const accessibleFiles = new Set<string>();
|
||||
|
||||
// Same-namespace siblings.
|
||||
const sameBucket = buckets.get(ownNs);
|
||||
if (sameBucket !== undefined) {
|
||||
for (const { filePath } of sameBucket.scopes) {
|
||||
if (filePath !== parsed.filePath) accessibleFiles.add(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
// Files directly imported by this file (finalized import edges).
|
||||
const ownModuleScopeBindings = indexes.bindings.get(moduleScope.id);
|
||||
if (ownModuleScopeBindings !== undefined) {
|
||||
for (const [, refs] of ownModuleScopeBindings) {
|
||||
for (const ref of refs) {
|
||||
if (ref.origin === 'import' || ref.origin === 'namespace') {
|
||||
const importFilePath = ref.def.filePath;
|
||||
if (importFilePath !== parsed.filePath) {
|
||||
accessibleFiles.add(importFilePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mirror return-type bindings from accessible files.
|
||||
for (const srcFilePath of accessibleFiles) {
|
||||
const srcParsed = parsedFiles.find((p) => p.filePath === srcFilePath);
|
||||
if (srcParsed === undefined) continue;
|
||||
const srcModuleScope = srcParsed.scopes.find((s) => s.kind === 'Module');
|
||||
if (srcModuleScope === undefined) continue;
|
||||
for (const [boundName, typeRef] of srcModuleScope.typeBindings) {
|
||||
if (moduleTypeBindings.has(boundName)) continue;
|
||||
moduleTypeBindings.set(boundName, typeRef);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user