Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
740d3b9b83 | ||
|
|
350b1ece63 | ||
|
|
af81512f61 | ||
|
|
950f41bfdf | ||
|
|
f1593016da | ||
|
|
f2cbf27219 | ||
|
|
676f91ebf5 | ||
|
|
8c5648563f | ||
|
|
7d500390b9 | ||
|
|
bdc0439a10 | ||
|
|
105efd0f7c | ||
|
|
493827222d | ||
|
|
ed50a6729f | ||
|
|
dfbe68ad24 | ||
|
|
2376912ca7 | ||
|
|
a4dfebd073 | ||
|
|
42d4fcaf6f | ||
|
|
a26ac55fb0 | ||
|
|
467c14caa2 | ||
|
|
fa06c5610b | ||
|
|
f28185d67e | ||
|
|
f69c382bcb | ||
|
|
83fbd4be26 | ||
|
|
263ca353a6 | ||
|
|
8500f18e5f | ||
|
|
aed370b931 | ||
|
|
99b8c7b03b | ||
|
|
813acd7ec5 | ||
|
|
7fbf302018 | ||
|
|
5ee1122330 | ||
|
|
cdac8a691a |
@@ -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' }}
|
||||
|
||||
@@ -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
|
||||
|
||||
+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.
|
||||
|
||||
@@ -155,9 +155,9 @@ npx gitnexus analyze --embeddings # also generate embeddings for new/changed
|
||||
npx gitnexus analyze --drop-embeddings # explicit opt-in to wipe existing embeddings
|
||||
```
|
||||
|
||||
`analyze` runs **incrementally by default**. The pipeline still parses every file every run (cross-file resolution requires it), but tree-sitter parsing is **served from a content-addressed cache** at `.gitnexus/parse-cache.json` for chunks whose file contents haven't changed since the last run. Only changed-file rows (and their importers) are rewritten in LadybugDB; unchanged-file rows are preserved. Output is byte-equivalent to a full rebuild. Pass `--force` to wipe and re-index from scratch (e.g., to recover from a corrupt index, or after upgrading GitNexus).
|
||||
`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 `.gitnexus/parse-cache.json` at any time — it'll be rebuilt on the next analyze.
|
||||
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.
|
||||
|
||||
|
||||
+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:
|
||||
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ Format: **Trigger → Instruction → Reason**. Append new Signs when the same m
|
||||
### Index seems corrupt or "incremental" is misbehaving
|
||||
|
||||
- **Trigger:** `analyze` produces unexpected results, or `meta.json.incrementalInProgress` is set, or the index is in a half-state after a crash.
|
||||
- **Do:** `npx gitnexus analyze --force` to rebuild from scratch. The dirty-flag check forces this automatically when a previous incremental run didn't complete cleanly, but `--force` is the manual escape hatch. Safe to delete `.gitnexus/parse-cache.json` at any time — content-addressed, will be regenerated.
|
||||
- **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
|
||||
|
||||
@@ -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 \
|
||||
@@ -725,9 +725,11 @@ gitnexus wiki --force
|
||||
|
||||
|
||||
# Increase the timeout or retries for large codebase or slow LLM providers
|
||||
gitnexus wiki --timeout <seconds> # Per-attempt LLM request timeout in seconds (default: 60)
|
||||
gitnexus wiki --timeout <seconds> # LLM request timeout in seconds (default: disabled)
|
||||
gitnexus wiki --retries <n> # Max LLM retry attempts per request (default: 3)
|
||||
|
||||
# Change the language generation for wiki
|
||||
gitnexus wiki --lang <lang> # Output language for generated documentation (e.g. english, chinese, spanish, japanese)
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
@@ -56,15 +56,15 @@ Generates repository documentation from the knowledge graph using an LLM. Requir
|
||||
|
||||
| Flag | Effect |
|
||||
|------|--------|
|
||||
| `--force` | Force full regeneration |
|
||||
| `--force` | Force full regeneration, also required to re-gerenate an existing wiki in a different language |
|
||||
| `--model <model>` | LLM model (default: minimax/minimax-m2.5) |
|
||||
| `--base-url <url>` | LLM API base URL |
|
||||
| `--api-key <key>` | LLM API key |
|
||||
| `--concurrency <n>` | Parallel LLM calls (default: 3) |
|
||||
| `--gist` | Publish wiki as a public GitHub Gist |
|
||||
| `--timeout <seconds>` | Per-attempt LLM request timeout in seconds (default: 60) |
|
||||
| `--timeout <seconds>` | LLM request timeout in seconds (default: disabled) |
|
||||
| `--retries <n>` | Max LLM retry attempts per request (default: 3) |
|
||||
|
||||
| `--lang <lang>` | Output language for generated documentation (e.g. english, chinese, spanish, japanese)|
|
||||
### list — Show all indexed repos
|
||||
|
||||
```bash
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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`) ────
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,12 +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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+146
-146
@@ -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": {
|
||||
@@ -1652,9 +1652,9 @@
|
||||
"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": [
|
||||
{
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
@@ -67,13 +68,69 @@ const installFatalHandlers = (): void => {
|
||||
});
|
||||
};
|
||||
|
||||
const HEAP_MB = 8192;
|
||||
const HEAP_FLAG = `--max-old-space-size=${HEAP_MB}`;
|
||||
const HEAP_MB = 16384;
|
||||
const TEST_RESPAWN_HEAP_MB = Number(process.env.GITNEXUS_TEST_RESPAWN_HEAP_MB);
|
||||
const RESPAWN_HEAP_MB =
|
||||
Number.isFinite(TEST_RESPAWN_HEAP_MB) && TEST_RESPAWN_HEAP_MB > 0
|
||||
? Math.floor(TEST_RESPAWN_HEAP_MB)
|
||||
: HEAP_MB;
|
||||
const HEAP_FLAG = `--max-old-space-size=${RESPAWN_HEAP_MB}`;
|
||||
/** Increase default stack size (KB) to prevent stack overflow on deep class hierarchies. */
|
||||
const STACK_KB = 4096;
|
||||
const STACK_FLAG = `--stack-size=${STACK_KB}`;
|
||||
|
||||
/** Re-exec the process with an 8GB heap and larger stack if we're currently below that. */
|
||||
/**
|
||||
* Heuristic for "child re-exec likely died from V8 OOM".
|
||||
*
|
||||
* Platform-independent detection is best-effort: V8/Node usually emit
|
||||
* stable heap-exhaustion phrases in stderr/message across Linux/macOS/Windows
|
||||
* (for example "JavaScript heap out of memory" or "Reached heap limit"),
|
||||
* while some environments only expose status/signal (e.g. 134/SIGABRT).
|
||||
* We combine both text signatures and process-exit signatures.
|
||||
*/
|
||||
const childProcessLikelyOom = (err: unknown): boolean => {
|
||||
if (!err || typeof err !== 'object') return false;
|
||||
const e = err as {
|
||||
status?: unknown;
|
||||
signal?: unknown;
|
||||
stderr?: unknown;
|
||||
stdout?: unknown;
|
||||
message?: unknown;
|
||||
};
|
||||
|
||||
const hasHeapOomSignature = (v: unknown): boolean => {
|
||||
const text = (
|
||||
Buffer.isBuffer(v) ? v.toString('utf8') : typeof v === 'string' ? v : ''
|
||||
).toLowerCase();
|
||||
if (!text) return false;
|
||||
return (
|
||||
text.includes('javascript heap out of memory') ||
|
||||
text.includes('reached heap limit') ||
|
||||
text.includes('allocation failed - javascript heap out of memory') ||
|
||||
text.includes('fatalprocessoutofmemory')
|
||||
);
|
||||
};
|
||||
|
||||
const fields = [e.message, e.stderr, e.stdout];
|
||||
if (fields.some((v) => hasHeapOomSignature(v))) return true;
|
||||
|
||||
const hasAnyChildOutput = [e.stderr, e.stdout].some(
|
||||
(v) => (Buffer.isBuffer(v) && v.length > 0) || (typeof v === 'string' && v.length > 0),
|
||||
);
|
||||
if (hasAnyChildOutput) return false;
|
||||
|
||||
return e.status === 134 || e.signal === 'SIGABRT';
|
||||
};
|
||||
|
||||
const forceHeapOOMForTestIfEnabled = (): void => {
|
||||
if (process.env.GITNEXUS_TEST_FORCE_HEAP_OOM !== '1') return;
|
||||
// Allocate JS strings (not Buffers) so pressure lands on V8 heap itself.
|
||||
// Buffers can allocate off-heap, which makes OOM triggering less reliable.
|
||||
const chunks: string[] = [];
|
||||
for (;;) chunks.push('x'.repeat(1024 * 1024));
|
||||
};
|
||||
|
||||
/** Re-exec the process with a 16GB heap and larger stack if we're currently below that. */
|
||||
function ensureHeap(): boolean {
|
||||
const nodeOpts = process.env.NODE_OPTIONS || '';
|
||||
if (nodeOpts.includes('--max-old-space-size')) return false;
|
||||
@@ -92,6 +149,16 @@ function ensureHeap(): boolean {
|
||||
env: { ...process.env, NODE_OPTIONS: `${nodeOpts} ${HEAP_FLAG}`.trim() },
|
||||
});
|
||||
} catch (e: any) {
|
||||
if (childProcessLikelyOom(e)) {
|
||||
cliError(
|
||||
` Analysis likely ran out of memory.\n` +
|
||||
` Retry with a larger heap if your machine allows it:\n` +
|
||||
` NODE_OPTIONS="--max-old-space-size=24576" gitnexus analyze [your-args]\n` +
|
||||
` (Windows: set NODE_OPTIONS=--max-old-space-size=24576 && gitnexus analyze [your-args])\n` +
|
||||
` If this persists, it may be a native crash unrelated to heap size.\n`,
|
||||
{ recoveryHint: 'heap-oom-respawn' },
|
||||
);
|
||||
}
|
||||
process.exitCode = e.status ?? 1;
|
||||
}
|
||||
return true;
|
||||
@@ -184,6 +251,7 @@ export const shouldGenerateCommunitySkillFiles = (
|
||||
|
||||
export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOptions) => {
|
||||
if (ensureHeap()) return;
|
||||
forceHeapOOMForTestIfEnabled();
|
||||
|
||||
// Install fatal handlers immediately after re-exec resolution so any
|
||||
// async error that escapes the try/catch below (#1169) surfaces with
|
||||
@@ -638,6 +706,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.
|
||||
|
||||
@@ -81,6 +81,7 @@ program
|
||||
' GITNEXUS_MAX_FILE_SIZE=N Override large-file skip threshold (KB). Default 512, max 32768.\n' +
|
||||
' GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=N Worker idle timeout in milliseconds. Default 30000.\n' +
|
||||
' GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker job byte budget. Default 8388608.\n' +
|
||||
' GITNEXUS_ALLOW_CPP_WORKERS=1 Force worker threads even when C/C++ files are detected.\n' +
|
||||
' GITNEXUS_EMBEDDING_THREADS=N Limit local ONNX CPU threads for --embeddings.\n' +
|
||||
' GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT=N Max embedding chunks for exact-scan fallback. Default 10000.\n' +
|
||||
'\nTip: `.gitnexusignore` supports `.gitignore`-style negation. Add e.g.\n' +
|
||||
@@ -161,11 +162,15 @@ program
|
||||
)
|
||||
.option('--no-reasoning-model', 'Disable reasoning model mode (overrides saved config)')
|
||||
.option('--concurrency <n>', 'Parallel LLM calls (default: 3)', '3')
|
||||
.option('--timeout <seconds>', 'Per-attempt LLM request timeout in seconds (default: 60)')
|
||||
.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')
|
||||
.option(
|
||||
'--lang <lang>',
|
||||
'Output language for generated documentation (e.g. english, chinese, spanish, japanese)',
|
||||
)
|
||||
.action(createLazyAction(() => import('./wiki.js'), 'wikiCommand'));
|
||||
|
||||
program
|
||||
|
||||
@@ -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` +
|
||||
|
||||
@@ -35,6 +35,24 @@ export interface WikiCommandOptions {
|
||||
review?: boolean;
|
||||
timeout?: string;
|
||||
retries?: string;
|
||||
lang?: 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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -127,6 +145,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 (
|
||||
@@ -350,13 +379,11 @@ export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptio
|
||||
}
|
||||
|
||||
// ── Apply per-run overrides not saved to config ────────────────────
|
||||
if (options?.timeout) {
|
||||
const secs = parseInt(options.timeout, 10);
|
||||
if (!isNaN(secs) && secs > 0) llmConfig.requestTimeoutMs = secs * 1000;
|
||||
if (timeoutSeconds !== undefined) {
|
||||
llmConfig.requestTimeoutMs = timeoutSeconds * 1000;
|
||||
}
|
||||
if (options?.retries) {
|
||||
const n = parseInt(options.retries, 10);
|
||||
if (!isNaN(n) && n > 0) llmConfig.maxAttempts = n;
|
||||
if (retries !== undefined) {
|
||||
llmConfig.maxAttempts = retries;
|
||||
}
|
||||
|
||||
// ── Setup progress bar with elapsed timer ──────────────────────────
|
||||
@@ -395,6 +422,7 @@ export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptio
|
||||
force: options?.force,
|
||||
concurrency: options?.concurrency ? parseInt(options.concurrency, 10) : undefined,
|
||||
reviewOnly: options?.review,
|
||||
lang: options?.lang,
|
||||
};
|
||||
|
||||
const generator = new WikiGenerator(
|
||||
@@ -563,6 +591,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`);
|
||||
|
||||
@@ -766,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[] = [];
|
||||
@@ -843,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}:`);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -64,6 +64,7 @@ import {
|
||||
cppImportOwningScope,
|
||||
cppReceiverBinding,
|
||||
} from './cpp/index.js';
|
||||
import { extractCppTemplateConstraints } from './cpp/constraint-extractor.js';
|
||||
|
||||
const C_BUILT_INS: ReadonlySet<string> = new Set([
|
||||
'printf',
|
||||
@@ -312,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;
|
||||
}
|
||||
@@ -456,6 +464,7 @@ 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,
|
||||
@@ -467,3 +476,46 @@ export const cppProvider = defineLanguage({
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -15,19 +15,34 @@
|
||||
*
|
||||
* ## Current boundary
|
||||
*
|
||||
* The current implementation covers ONE associated-entity rule: an argument that's a directly-named
|
||||
* class type (`audit::Event e`) contributes its **direct enclosing
|
||||
* namespace** to the candidate set. V2 extends that one step to
|
||||
* pointer-typed and reference-typed class args (`audit::Event* p`,
|
||||
* `audit::Event& r`, `audit::Event&& rr`): they contribute the pointee /
|
||||
* referred class's enclosing namespace too. Function-pointer arguments,
|
||||
* template specializations, base-class associated namespaces, and the
|
||||
* rest of the full closure are still deliberately excluded.
|
||||
* 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)
|
||||
*
|
||||
* The current implementation also short-circuits to ADL only when ordinary lookup is empty
|
||||
* (`findCallableBindingInScope` returned undefined). ISO C++ would
|
||||
* normally merge ADL candidates with ordinary-lookup candidates and
|
||||
* run overload resolution over the union; V1 defers that merge to V2.
|
||||
* 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
|
||||
*
|
||||
@@ -55,33 +70,45 @@
|
||||
|
||||
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';
|
||||
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, template specs, etc. */
|
||||
* 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>();
|
||||
|
||||
/** Sentinel returned by `pickCppAdlCandidates` when ADL surfaces multiple
|
||||
* candidates that share normalized parameter types — the caller MUST
|
||||
* suppress (zero edges) rather than pick arbitrarily. Mirrors the
|
||||
* OVERLOAD_AMBIGUOUS contract from the receiver-bound path. */
|
||||
export const ADL_AMBIGUOUS = Symbol('ADL_AMBIGUOUS');
|
||||
export type AdlResult = SymbolDefinition | typeof ADL_AMBIGUOUS | undefined;
|
||||
|
||||
function siteKey(filePath: string, line: number, col: number): string {
|
||||
return `${filePath}:${line}:${col}`;
|
||||
}
|
||||
@@ -134,16 +161,26 @@ export function populateCppAssociatedNamespaces(parsed: ParsedFile): void {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* V1 ADL candidate picker. Returns:
|
||||
* - `SymbolDefinition` — exactly one ADL candidate (or unique survivor
|
||||
* after narrowing); caller emits the CALLS edge.
|
||||
* - `ADL_AMBIGUOUS` — multiple candidates with no disambiguator;
|
||||
* caller MUST suppress (zero edges).
|
||||
* - `undefined` — no ADL candidates; caller falls through to ordinary
|
||||
* `pickUniqueGlobalCallable` fallback.
|
||||
* 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
|
||||
@@ -153,33 +190,33 @@ export function populateCppAssociatedNamespaces(parsed: ParsedFile): void {
|
||||
export function pickCppAdlCandidates(
|
||||
site: {
|
||||
readonly name: string;
|
||||
readonly arity?: number;
|
||||
readonly argumentTypes?: readonly string[];
|
||||
readonly atRange: { startLine: number; startCol: number };
|
||||
},
|
||||
callerParsed: ParsedFile,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
): AdlResult {
|
||||
): 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.
|
||||
// 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) {
|
||||
if (arg.simpleClassName === '') continue;
|
||||
const classDef = findCppClassDefBySimpleName(arg.simpleClassName, scopes);
|
||||
if (classDef === undefined) continue;
|
||||
const nsQName = classToNamespaceQualifiedName.get(classDef.nodeId);
|
||||
if (nsQName !== undefined) associatedNamespaces.add(nsQName);
|
||||
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) {
|
||||
@@ -188,7 +225,17 @@ export function pickCppAdlCandidates(
|
||||
for (const scope of parsed.scopes) {
|
||||
if (scope.kind !== 'Namespace') continue;
|
||||
const qName = computeNamespaceQName(scope, scopesById);
|
||||
if (!associatedNamespaces.has(qName)) continue;
|
||||
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;
|
||||
@@ -204,22 +251,100 @@ export function pickCppAdlCandidates(
|
||||
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;
|
||||
if (candidates.length === 1) return candidates[0];
|
||||
return candidates;
|
||||
}
|
||||
|
||||
// Multi-candidate: narrow then check ambiguity. Reuses the OVERLOAD_AMBIGUOUS
|
||||
// sentinel contract from `overload-narrowing.ts` so int/long-collision-style
|
||||
// ambiguity also suppresses on the ADL path.
|
||||
const narrowed = narrowOverloadCandidates(candidates, site.arity, site.argumentTypes);
|
||||
if (narrowed.length === 1) return narrowed[0];
|
||||
if (narrowed.length === 0) return undefined;
|
||||
if (isOverloadAmbiguousAfterNormalization(narrowed, site.arity)) return ADL_AMBIGUOUS;
|
||||
// Multiple surviving candidates that aren't normalization-ambiguous —
|
||||
// ISO C++ would run overload resolution; V1 lacks conversion ranking so
|
||||
// suppress rather than pick arbitrarily. Mirrors `pickImplicitThisOverload`'s
|
||||
// unique-survivor requirement (see `pick-implicit-this-overload.test.ts`).
|
||||
return ADL_AMBIGUOUS;
|
||||
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
|
||||
@@ -313,18 +438,103 @@ function findNamespaceDefInScope(scope: {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Find a class-like def by simple name across the workspace. V1
|
||||
* arbitrary-pick on collisions (multiple classes share the simple name);
|
||||
* C++ ADL strictness would require full type-driven lookup, but V1
|
||||
* trades that for simplicity. */
|
||||
/** 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,
|
||||
): SymbolDefinition | undefined {
|
||||
): { 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') continue;
|
||||
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) return def;
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
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[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,26 +75,35 @@ export function computeCppDeclarationArity(node: SyntaxNode): CppArityInfo {
|
||||
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');
|
||||
types.push(normalizeCppParamType(typeNode?.text ?? 'unknown'));
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -120,8 +131,14 @@ export function computeCppCallArity(node: SyntaxNode): number {
|
||||
* 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.
|
||||
*/
|
||||
function normalizeCppParamType(raw: string): string {
|
||||
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();
|
||||
@@ -158,6 +175,52 @@ function normalizeCppParamType(raw: string): string {
|
||||
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) {
|
||||
|
||||
@@ -8,7 +8,10 @@ import type { Callsite, SymbolDefinition } from 'gitnexus-shared';
|
||||
* - Default parameters (requiredParameterCount < parameterCount)
|
||||
* - Variadic functions (C-style `...`)
|
||||
* - Parameter packs (V1: treated as variadic)
|
||||
* - Templates (V1: generic-ignored, arity check on non-template params)
|
||||
* - 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
|
||||
|
||||
@@ -10,10 +10,11 @@ import { getTreeSitterBufferSize } from '../../constants.js';
|
||||
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
|
||||
import { splitCppInclude, splitCppUsingDecl } from './import-decomposer.js';
|
||||
import { computeCppDeclarationArity, computeCppCallArity } from './arity-metadata.js';
|
||||
import { markFileLocal } from './file-local-linkage.js';
|
||||
import { markCppAnonymousNamespaceRange, markFileLocal } from './file-local-linkage.js';
|
||||
import { markCppDependentBase } from './two-phase-lookup.js';
|
||||
import { markCppAdlSiteArgs, markCppAdlSiteNoAdl, type CppAdlArgInfo } from './adl.js';
|
||||
import { markCppInlineNamespaceRange } from './inline-namespaces.js';
|
||||
import { extractCppTemplateConstraints } from './constraint-extractor.js';
|
||||
|
||||
export function emitCppScopeCaptures(
|
||||
sourceText: string,
|
||||
@@ -114,6 +115,13 @@ export function emitCppScopeCaptures(
|
||||
JSON.stringify(arity.parameterTypes),
|
||||
);
|
||||
}
|
||||
if (arity.parameterTypeClasses !== undefined) {
|
||||
grouped['@declaration.parameter-type-classes'] = syntheticCapture(
|
||||
'@declaration.parameter-type-classes',
|
||||
fnNode,
|
||||
JSON.stringify(arity.parameterTypeClasses),
|
||||
);
|
||||
}
|
||||
|
||||
// Detect static storage class (file-local linkage)
|
||||
if (hasStaticStorageClass(fnNode)) {
|
||||
@@ -130,6 +138,24 @@ export function emitCppScopeCaptures(
|
||||
markFileLocal(filePath, nameText);
|
||||
}
|
||||
}
|
||||
|
||||
// SFINAE / `requires`-clause aware constraints for overload
|
||||
// narrowing (issue #1579). Walk from the enclosing
|
||||
// `template_declaration` — not the inner `function_definition` —
|
||||
// so inline method templates (`template<...> class C { template<...> void f(); }`)
|
||||
// pick up the correct outer constraint scope.
|
||||
const templateDecl = findEnclosingTemplateDeclaration(fnNode);
|
||||
if (templateDecl !== null) {
|
||||
const funcDeclarator = findFunctionDeclarator(fnNode);
|
||||
const constraints = extractCppTemplateConstraints(templateDecl, funcDeclarator);
|
||||
if (constraints !== undefined) {
|
||||
grouped['@declaration.template-constraints'] = syntheticCapture(
|
||||
'@declaration.template-constraints',
|
||||
fnNode,
|
||||
JSON.stringify(constraints),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,20 +226,37 @@ export function emitCppScopeCaptures(
|
||||
// namespace's source range so `populateCppInlineNamespaceScopes`
|
||||
// (during populateOwners) can match it back to the corresponding
|
||||
// Namespace scope.
|
||||
if (grouped['@declaration.namespace'] !== undefined) {
|
||||
const anchor = grouped['@declaration.namespace']!;
|
||||
const nsNode = findNodeAtRange(tree.rootNode, anchor.range, 'namespace_definition');
|
||||
if (nsNode !== null && isInlineNamespace(nsNode)) {
|
||||
// `@declaration.namespace` fires only for NAMED namespaces (the query
|
||||
// requires a `name: (namespace_identifier)` child). Use the unconditional
|
||||
// `@scope.namespace` capture so the anonymous-namespace branch also runs.
|
||||
const namespaceScopeAnchor = grouped['@declaration.namespace'] ?? grouped['@scope.namespace'];
|
||||
if (namespaceScopeAnchor !== undefined) {
|
||||
const nsNode = findNodeAtRange(
|
||||
tree.rootNode,
|
||||
namespaceScopeAnchor.range,
|
||||
'namespace_definition',
|
||||
);
|
||||
if (nsNode !== null) {
|
||||
// Range coords stored in the shared Range shape use 1-based
|
||||
// line numbers (see `ast-helpers.ts` rangeForNode where
|
||||
// `startPosition.row + 1` is applied). Match that convention so
|
||||
// `populateCppInlineNamespaceScopes` can join against `Scope.range`.
|
||||
markCppInlineNamespaceRange(filePath, {
|
||||
// the populators can join against `Scope.range`.
|
||||
const nsRange = {
|
||||
startLine: nsNode.startPosition.row + 1,
|
||||
startCol: nsNode.startPosition.column,
|
||||
endLine: nsNode.endPosition.row + 1,
|
||||
endCol: nsNode.endPosition.column,
|
||||
});
|
||||
};
|
||||
if (isInlineNamespace(nsNode)) {
|
||||
markCppInlineNamespaceRange(filePath, nsRange);
|
||||
}
|
||||
// Anonymous namespace: `namespace_definition` with no `name` field.
|
||||
// Recorded so `expandCppWildcardNames` can propagate its members
|
||||
// to including TUs even though their names are also `markFileLocal`'d
|
||||
// (which blocks the global free-call fallback's cross-file path).
|
||||
if ((nsNode.childForFieldName?.('name') ?? null) === null) {
|
||||
markCppAnonymousNamespaceRange(filePath, nsRange);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -535,6 +578,52 @@ function extractBaseLookupName(baseNode: SyntaxNode): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk parent chain from a function_definition / declaration / field_declaration
|
||||
* to find the enclosing `template_declaration`. Returns null when the function
|
||||
* isn't templated. The walk only ascends through wrapper nodes the C++
|
||||
* grammar inserts between `template_declaration` and the function — direct
|
||||
* parent in the common case, two hops for member templates whose outer
|
||||
* class is also templated (we return the INNERMOST template_declaration,
|
||||
* which carries this function's own template parameters).
|
||||
*/
|
||||
function findEnclosingTemplateDeclaration(fnNode: SyntaxNode): SyntaxNode | null {
|
||||
let cur: SyntaxNode | null = fnNode.parent;
|
||||
// Cap the walk — `template_declaration` is typically the immediate parent
|
||||
// or one wrapper away. Anything deeper is an inline-method-in-template
|
||||
// shape and we still want the innermost templates_declaration whose body
|
||||
// wraps `fnNode`.
|
||||
let hops = 8;
|
||||
while (cur !== null && hops-- > 0) {
|
||||
if (cur.type === 'template_declaration') return cur;
|
||||
// Don't ascend past structural boundaries that should reset template scope.
|
||||
if (cur.type === 'translation_unit') return null;
|
||||
cur = cur.parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate the `function_declarator` AST node within a function definition
|
||||
* or declaration. Unwraps pointer/reference declarator wrappers. Returns
|
||||
* null when no function_declarator is found (e.g. variable declaration
|
||||
* mis-classified upstream).
|
||||
*/
|
||||
function findFunctionDeclarator(fnNode: SyntaxNode): SyntaxNode | null {
|
||||
const direct = fnNode.childForFieldName('declarator');
|
||||
let cur: SyntaxNode | null = direct;
|
||||
let hops = 8;
|
||||
while (cur !== null && hops-- > 0) {
|
||||
if (cur.type === 'function_declarator') return cur;
|
||||
if (cur.type === 'pointer_declarator' || cur.type === 'reference_declarator') {
|
||||
cur = cur.childForFieldName('declarator');
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return findFirstDescendantOfType(fnNode, 'function_declarator');
|
||||
}
|
||||
|
||||
/** Find the first direct child matching one of the given types. */
|
||||
function findChildOfType(node: SyntaxNode, types: readonly string[]): SyntaxNode | null {
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
@@ -638,6 +727,15 @@ function inferCppLiteralType(node: SyntaxNode): string {
|
||||
* - `int n = ...` → 'int'
|
||||
* - `const int n = ...` → 'int'
|
||||
* Returns empty string if no declaration found or type is auto/placeholder.
|
||||
*
|
||||
* Limitation: only `declaration` siblings inside the enclosing
|
||||
* `compound_statement` are inspected. Function parameters live in the
|
||||
* `function_declarator`'s `parameter_list` and are NOT resolved here, so
|
||||
* `void run(int n) { process(n); }`
|
||||
* infers `''` for `n` and the constraint filter falls through to
|
||||
* `'unknown'` → ambiguity suppression → 0 CALLS edges. This is a
|
||||
* "degrade not lie" gap (no wrong edges, just missing ones); extending
|
||||
* the scan to `parameter_list` is tracked under #1579 as a follow-up.
|
||||
*/
|
||||
function lookupDeclaredTypeForIdentifier(identNode: SyntaxNode): string {
|
||||
const varName = identNode.text;
|
||||
@@ -720,12 +818,14 @@ function isParenthesizedFunctionCall(callNode: SyntaxNode): boolean {
|
||||
|
||||
/**
|
||||
* Per-argument ADL classification: walk each argument of a free call and
|
||||
* decide whether it resolves to a directly-named class or class-pointer
|
||||
* type (ADL fires) or to an excluded shape such as a reference, function
|
||||
* pointer, primitive, literal, or template specialization.
|
||||
* classify its declared type for associated-namespace lookup.
|
||||
*
|
||||
* Class-typed values and class pointers (`N::S`, `N::S*`, `N::S**`) all
|
||||
* preserve the pointee class name for associated-namespace lookup.
|
||||
* Value/pointer/reference class-typed args and template specializations
|
||||
* with explicit type arguments contribute; function pointers, primitives,
|
||||
* literals, and other unsupported shapes produce an empty result.
|
||||
*
|
||||
* Class-typed values/pointers/references (`N::S`, `N::S*`, `N::S&`) all
|
||||
* preserve the class name for associated-namespace lookup.
|
||||
* Function pointers remain excluded even when their return type names a
|
||||
* class, because the associated entity is the pointed-to function type,
|
||||
* not the return type.
|
||||
@@ -743,7 +843,14 @@ function inferCppCallAdlArgs(callNode: SyntaxNode): CppAdlArgInfo[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
const EMPTY_ADL_ARG: CppAdlArgInfo = { simpleClassName: '' };
|
||||
const ADL_TEMPLATE_RECURSION_MAX_DEPTH = 8;
|
||||
const EMPTY_ADL_ARG: CppAdlArgInfo = {
|
||||
simpleClassName: '',
|
||||
templateSimpleClassName: '',
|
||||
templateNamespace: '',
|
||||
templateArgClassNames: [],
|
||||
templateArgNamespaces: [],
|
||||
};
|
||||
|
||||
function classifyAdlArg(argNode: SyntaxNode): CppAdlArgInfo {
|
||||
// Literals and primitive-shaped expressions never have associated namespaces.
|
||||
@@ -759,17 +866,91 @@ function classifyAdlArg(argNode: SyntaxNode): CppAdlArgInfo {
|
||||
) {
|
||||
return EMPTY_ADL_ARG;
|
||||
}
|
||||
// Qualified expression (a::b) — may be a function, variable, enum value,
|
||||
// or static member. Record as a potential function reference; resolution
|
||||
// time verifies via workspace lookup that a Function/Method with this simple
|
||||
// name exists in the extracted namespace before contributing to the set.
|
||||
if (argNode.type === 'qualified_identifier') {
|
||||
return {
|
||||
simpleClassName: '',
|
||||
templateSimpleClassName: '',
|
||||
templateNamespace: '',
|
||||
templateArgClassNames: [],
|
||||
templateArgNamespaces: [],
|
||||
functionRefText: argNode.text,
|
||||
};
|
||||
}
|
||||
// Variable reference — look up its declared type (preserving pointer /
|
||||
// reference / qualified-name shape; the existing arity-narrowing helper
|
||||
// strips this info).
|
||||
if (argNode.type === 'identifier') {
|
||||
return lookupAdlIdentifierType(argNode);
|
||||
const result = lookupAdlIdentifierType(argNode);
|
||||
if (result === null) {
|
||||
// Not found in the local compound_statement scope — could be a
|
||||
// free-function reference (unqualified name, namespace scope).
|
||||
return {
|
||||
simpleClassName: '',
|
||||
templateSimpleClassName: '',
|
||||
templateNamespace: '',
|
||||
templateArgClassNames: [],
|
||||
templateArgNamespaces: [],
|
||||
functionRefText: argNode.text,
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
// Other shapes (calls, member access, operators) — V1 unsupported.
|
||||
return EMPTY_ADL_ARG;
|
||||
}
|
||||
|
||||
function lookupAdlIdentifierType(identNode: SyntaxNode): CppAdlArgInfo {
|
||||
/**
|
||||
* Returns `true` when `varName` appears as a parameter name in the nearest
|
||||
* enclosing `function_definition` or `function_declarator` that contains
|
||||
* `identNode`. Parameters live in `parameter_list` (a sibling of the
|
||||
* `compound_statement`), so the `compound_statement`-local declaration scan
|
||||
* in `lookupAdlIdentifierType` would not find them — causing them to be
|
||||
* mistakenly classified as potential free-function references.
|
||||
*
|
||||
* In tree-sitter-cpp a `function_definition` does NOT expose `parameters`
|
||||
* as a direct named field; parameters live inside the nested
|
||||
* `function_declarator`. For `function_declarator` nodes the `parameters`
|
||||
* field IS direct. Both cases are handled below.
|
||||
*/
|
||||
function isIdentifierAFunctionParameter(identNode: SyntaxNode, varName: string): boolean {
|
||||
let node: SyntaxNode | null = identNode.parent;
|
||||
let safety = 64;
|
||||
while (node !== null && safety-- > 0) {
|
||||
let params: SyntaxNode | null = null;
|
||||
if (node.type === 'function_declarator') {
|
||||
// parameters is a direct field on function_declarator.
|
||||
params = node.childForFieldName('parameters');
|
||||
} else if (node.type === 'function_definition') {
|
||||
// function_definition carries parameters inside its `declarator` field
|
||||
// (which is a function_declarator). Walk through it.
|
||||
const decl = node.childForFieldName('declarator');
|
||||
if (decl !== null && decl.type === 'function_declarator') {
|
||||
params = decl.childForFieldName('parameters');
|
||||
}
|
||||
}
|
||||
if (params !== null) {
|
||||
for (let i = 0; i < params.namedChildCount; i++) {
|
||||
const param = params.namedChild(i);
|
||||
if (param === null) continue;
|
||||
const declNode = param.childForFieldName('declarator');
|
||||
if (declNode === null) continue;
|
||||
const leafName = extractDeclaratorLeafName(declNode);
|
||||
if (leafName === varName) return true;
|
||||
}
|
||||
// Only check the immediately enclosing function — do not climb further.
|
||||
break;
|
||||
}
|
||||
if (node.type === 'translation_unit') break;
|
||||
node = node.parent;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function lookupAdlIdentifierType(identNode: SyntaxNode): CppAdlArgInfo | null {
|
||||
const varName = identNode.text;
|
||||
let scope: SyntaxNode | null = identNode.parent;
|
||||
while (
|
||||
@@ -779,8 +960,17 @@ function lookupAdlIdentifierType(identNode: SyntaxNode): CppAdlArgInfo {
|
||||
) {
|
||||
scope = scope.parent;
|
||||
}
|
||||
if (scope === null) return EMPTY_ADL_ARG;
|
||||
if (scope === null) return null;
|
||||
|
||||
// Function parameters live in the enclosing function's `parameter_list`,
|
||||
// NOT inside the `compound_statement`, so the declaration scan below would
|
||||
// never find them and would return `null` — incorrectly triggering the
|
||||
// free-function-reference path. Check the parameter_list first.
|
||||
if (isIdentifierAFunctionParameter(identNode, varName)) {
|
||||
return EMPTY_ADL_ARG;
|
||||
}
|
||||
|
||||
let foundAsLocalFunctionPointer = false;
|
||||
for (let i = 0; i < scope.childCount; i++) {
|
||||
const stmt = scope.child(i);
|
||||
if (stmt === null || stmt.type !== 'declaration') continue;
|
||||
@@ -807,6 +997,9 @@ function lookupAdlIdentifierType(identNode: SyntaxNode): CppAdlArgInfo {
|
||||
if (inner.type === 'pointer_declarator') {
|
||||
if (findFirstDescendantOfType(inner, 'function_declarator') !== null) {
|
||||
isFunctionPointer = true;
|
||||
// Extract the name from within the function-pointer declarator chain
|
||||
// so `foundAsLocalFunctionPointer` can detect a matching declaration.
|
||||
nameText = extractDeclaratorLeafName(inner);
|
||||
break;
|
||||
}
|
||||
const next = inner.childForFieldName('declarator');
|
||||
@@ -836,38 +1029,231 @@ function lookupAdlIdentifierType(identNode: SyntaxNode): CppAdlArgInfo {
|
||||
}
|
||||
if (inner.type === 'function_declarator') {
|
||||
isFunctionPointer = true;
|
||||
// Extract the name from the inner declarator (e.g. `(*g)` in `void (*g)()`).
|
||||
const innerDecl = inner.childForFieldName('declarator');
|
||||
if (innerDecl !== null) nameText = extractDeclaratorLeafName(innerDecl);
|
||||
break;
|
||||
}
|
||||
// Reached the leaf — usually `identifier`. Take its text.
|
||||
nameText = inner.text;
|
||||
break;
|
||||
}
|
||||
if (nameText === varName && isFunctionPointer) {
|
||||
// Explicitly declared as a function-pointer variable — must not be
|
||||
// treated as a free-function reference by the caller.
|
||||
foundAsLocalFunctionPointer = true;
|
||||
continue;
|
||||
}
|
||||
if (isFunctionPointer || nameText !== varName) continue;
|
||||
|
||||
const simpleClassName = extractAdlSimpleTypeName(typeNode);
|
||||
return { simpleClassName };
|
||||
const {
|
||||
templateSimpleClassName,
|
||||
templateNamespace,
|
||||
templateArgClassNames,
|
||||
templateArgNamespaces,
|
||||
} = extractAdlTemplateInfo(typeNode);
|
||||
return {
|
||||
simpleClassName,
|
||||
templateSimpleClassName,
|
||||
templateNamespace,
|
||||
templateArgClassNames,
|
||||
templateArgNamespaces,
|
||||
};
|
||||
}
|
||||
return EMPTY_ADL_ARG;
|
||||
// If the identifier was found in local scope as a function-pointer variable,
|
||||
// return EMPTY_ADL_ARG so the caller does NOT treat it as a free-function
|
||||
// reference. Otherwise return null to indicate "not in local scope".
|
||||
//
|
||||
// Known limitation (Finding 4): variables whose type is a typedef/using alias
|
||||
// for a function-pointer type are NOT detected here. For example:
|
||||
// using Callback = void (*)();
|
||||
// Callback g;
|
||||
// foo(g); // `g`'s declarator is `identifier` with type `Callback`
|
||||
// The declarator has no `pointer_declarator` wrapper, so `isFunctionPointer`
|
||||
// stays false and `extractAdlSimpleTypeName` returns `"Callback"`. ADL then
|
||||
// looks for a class named `Callback`; if none exists, this degrades to
|
||||
// EMPTY_ADL_ARG (class not found → no namespace contributed). If a class
|
||||
// named `Callback` does exist, a spurious namespace contribution could occur.
|
||||
// Risk is low in practice; a future fix should resolve the typedef/alias chain.
|
||||
return foundAsLocalFunctionPointer ? EMPTY_ADL_ARG : null;
|
||||
}
|
||||
|
||||
/** Extract the simple class-like type name from a `type:` field node.
|
||||
* Returns '' for primitives, template specializations, and any other
|
||||
* Returns '' for primitives and any other
|
||||
* unsupported type-only shape. Function pointers are filtered at the
|
||||
* declarator level in `lookupAdlIdentifierType`. */
|
||||
function extractAdlSimpleTypeName(typeNode: SyntaxNode): string {
|
||||
if (typeNode.type === 'type_descriptor') {
|
||||
const innerType = typeNode.childForFieldName('type');
|
||||
if (innerType !== null) return extractAdlSimpleTypeName(innerType);
|
||||
for (let i = 0; i < typeNode.childCount; i++) {
|
||||
const child = typeNode.child(i);
|
||||
if (child === null) continue;
|
||||
if (
|
||||
child.type === 'type_identifier' ||
|
||||
child.type === 'qualified_identifier' ||
|
||||
child.type === 'template_type'
|
||||
) {
|
||||
return extractAdlSimpleTypeName(child);
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
if (typeNode.type === 'primitive_type') return '';
|
||||
if (typeNode.type === 'sized_type_specifier') return '';
|
||||
if (typeNode.type === 'type_identifier') return typeNode.text;
|
||||
if (typeNode.type === 'template_type') {
|
||||
const nameNode = typeNode.childForFieldName('name');
|
||||
if (nameNode !== null) return extractAdlSimpleTypeName(nameNode);
|
||||
const id = findFirstDescendantOfType(typeNode, 'type_identifier');
|
||||
return id !== null ? id.text : '';
|
||||
}
|
||||
if (typeNode.type === 'qualified_identifier') {
|
||||
const nameNode = typeNode.childForFieldName('name');
|
||||
if (nameNode !== null) return extractAdlSimpleTypeName(nameNode);
|
||||
const id = findFirstDescendantOfType(typeNode, 'type_identifier');
|
||||
return id !== null ? id.text : '';
|
||||
}
|
||||
// template_type (e.g. `vector<int>`), function pointers, decltype — V1 excludes.
|
||||
// Function pointers, decltype, etc — unsupported for ADL participation.
|
||||
return '';
|
||||
}
|
||||
|
||||
function extractAdlTypeNamespace(typeNode: SyntaxNode): string {
|
||||
if (typeNode.type === 'type_descriptor') {
|
||||
const innerType = typeNode.childForFieldName('type');
|
||||
if (innerType !== null) return extractAdlTypeNamespace(innerType);
|
||||
for (let i = 0; i < typeNode.childCount; i++) {
|
||||
const child = typeNode.child(i);
|
||||
if (child === null) continue;
|
||||
if (
|
||||
child.type === 'qualified_identifier' ||
|
||||
child.type === 'template_type' ||
|
||||
child.type === 'type_identifier'
|
||||
) {
|
||||
return extractAdlTypeNamespace(child);
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
if (typeNode.type === 'template_type') {
|
||||
const nameNode = typeNode.childForFieldName('name');
|
||||
return nameNode !== null ? extractAdlTypeNamespace(nameNode) : '';
|
||||
}
|
||||
if (typeNode.type === 'qualified_identifier') {
|
||||
const scope = typeNode.childForFieldName('scope');
|
||||
if (scope !== null) return normalizeCppNamespaceQName(scope.text);
|
||||
return extractNamespaceFromQualifiedText(typeNode.text);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function extractAdlTemplateInfo(typeNode: SyntaxNode): {
|
||||
templateSimpleClassName: string;
|
||||
templateNamespace: string;
|
||||
templateArgClassNames: string[];
|
||||
templateArgNamespaces: string[];
|
||||
} {
|
||||
const templateTypeNode = findTemplateTypeNode(typeNode);
|
||||
if (templateTypeNode === null) {
|
||||
return {
|
||||
templateSimpleClassName: '',
|
||||
templateNamespace: '',
|
||||
templateArgClassNames: [],
|
||||
templateArgNamespaces: [],
|
||||
};
|
||||
}
|
||||
const templateArgClassNames: string[] = [];
|
||||
const templateArgNamespaces: string[] = [];
|
||||
collectAdlTemplateArgs(templateTypeNode, 0, templateArgClassNames, templateArgNamespaces);
|
||||
return {
|
||||
templateSimpleClassName: extractAdlSimpleTypeName(templateTypeNode),
|
||||
templateNamespace: extractAdlTypeNamespace(typeNode),
|
||||
templateArgClassNames,
|
||||
templateArgNamespaces,
|
||||
};
|
||||
}
|
||||
|
||||
function collectAdlTemplateArgs(
|
||||
templateTypeNode: SyntaxNode,
|
||||
depth: number,
|
||||
outClassNames: string[],
|
||||
outNamespaces: string[],
|
||||
): void {
|
||||
if (depth >= ADL_TEMPLATE_RECURSION_MAX_DEPTH) return;
|
||||
if (templateTypeNode.type !== 'template_type') return;
|
||||
|
||||
const argList =
|
||||
templateTypeNode.childForFieldName('arguments') ??
|
||||
findChildOfType(templateTypeNode, ['template_argument_list']);
|
||||
if (argList === null) return;
|
||||
|
||||
for (let i = 0; i < argList.namedChildCount; i++) {
|
||||
const arg = argList.namedChild(i);
|
||||
if (arg === null || arg.type !== 'type_descriptor') continue;
|
||||
const simpleClassName = extractAdlSimpleTypeName(arg);
|
||||
if (simpleClassName.length > 0) outClassNames.push(simpleClassName);
|
||||
const ns = extractAdlTypeNamespace(arg);
|
||||
if (ns.length > 0) outNamespaces.push(ns);
|
||||
|
||||
const nestedType = arg.childForFieldName('type');
|
||||
const nestedTemplate = nestedType !== null ? findTemplateTypeNode(nestedType) : null;
|
||||
if (nestedTemplate !== null) {
|
||||
collectAdlTemplateArgs(nestedTemplate, depth + 1, outClassNames, outNamespaces);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function findTemplateTypeNode(typeNode: SyntaxNode): SyntaxNode | null {
|
||||
if (typeNode.type === 'template_type') return typeNode;
|
||||
if (typeNode.type === 'type_descriptor') {
|
||||
const innerType = typeNode.childForFieldName('type');
|
||||
if (innerType !== null) return findTemplateTypeNode(innerType);
|
||||
return null;
|
||||
}
|
||||
if (typeNode.type === 'qualified_identifier') {
|
||||
const nameNode = typeNode.childForFieldName('name');
|
||||
if (nameNode !== null) return findTemplateTypeNode(nameNode);
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeCppNamespaceQName(text: string): string {
|
||||
const normalized = text.replace(/^::/, '').replace(/::$/, '').replace(/::/g, '.');
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function extractNamespaceFromQualifiedText(text: string): string {
|
||||
const cleaned = text.replace(/\s+/g, '');
|
||||
const idx = cleaned.lastIndexOf('::');
|
||||
if (idx <= 0) return '';
|
||||
return normalizeCppNamespaceQName(cleaned.slice(0, idx));
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk a declarator node chain, unwrapping pointer/reference/function/
|
||||
* parenthesized wrappers, and return the text of the innermost identifier.
|
||||
* Returns `null` when no identifier is found within `safety` steps.
|
||||
* Used by `lookupAdlIdentifierType` to extract the variable name from
|
||||
* function-pointer declarator trees such as `(*g)()` in `void (*g)()`.
|
||||
*/
|
||||
function extractDeclaratorLeafName(node: SyntaxNode): string | null {
|
||||
let cur: SyntaxNode = node;
|
||||
let safety = 16;
|
||||
while (safety-- > 0) {
|
||||
if (cur.type === 'identifier' || cur.type === 'type_identifier') return cur.text;
|
||||
// Common wrapper nodes — follow the 'declarator' field when present.
|
||||
const next =
|
||||
cur.childForFieldName('declarator') ??
|
||||
// parenthesized_declarator: single named child
|
||||
(cur.type === 'parenthesized_declarator' ? cur.namedChild(0) : null);
|
||||
if (next === null) return null;
|
||||
cur = next;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a C++ function_definition or declaration has `static` storage class.
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -35,6 +35,33 @@ const fileLocalNames = new Map<string, Set<string>>();
|
||||
*/
|
||||
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);
|
||||
@@ -50,10 +77,51 @@ 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,6 +155,13 @@ export function populateCppNonGloballyVisible(parsed: {
|
||||
// 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);
|
||||
}
|
||||
@@ -191,9 +266,18 @@ export function expandCppWildcardNames(
|
||||
// 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;
|
||||
@@ -201,7 +285,11 @@ export function expandCppWildcardNames(
|
||||
|
||||
const name = simpleName(def);
|
||||
if (name === '') continue;
|
||||
if (isFileLocal(target.filePath, 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);
|
||||
|
||||
@@ -29,6 +29,10 @@
|
||||
|
||||
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;
|
||||
@@ -95,15 +99,17 @@ export function isCppInlineNamespaceScope(scopeId: ScopeId): boolean {
|
||||
* 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; V1 returns the first match in source
|
||||
* order (stable across runs).
|
||||
* 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 | undefined {
|
||||
): 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);
|
||||
@@ -113,19 +119,45 @@ export function resolveCppQualifiedNamespaceMember(
|
||||
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 the
|
||||
// member transitively through any inline-namespace children.
|
||||
const hit = findMemberInNamespaceTransitive(scope, scopesById, memberName);
|
||||
if (hit !== undefined) return hit;
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
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 a callable def with the given simple name. Non-inline
|
||||
* descendants for callable defs with the given simple name. Non-inline
|
||||
* nested namespaces are NOT traversed — they require explicit
|
||||
* qualification (`outer::nested::foo`). */
|
||||
* 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;
|
||||
@@ -142,22 +174,23 @@ function findMemberInNamespaceTransitive(
|
||||
}
|
||||
>,
|
||||
memberName: string,
|
||||
): SymbolDefinition | undefined {
|
||||
): 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) return def;
|
||||
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 hit = findMemberInNamespaceTransitive(childScope, scopesById, memberName);
|
||||
if (hit !== undefined) return hit;
|
||||
const childHits = findMemberInNamespaceTransitive(childScope, scopesById, memberName);
|
||||
for (const hit of childHits) results.push(hit);
|
||||
}
|
||||
return undefined;
|
||||
return results;
|
||||
}
|
||||
|
||||
function findNamespaceDefInScope(scope: {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.
|
||||
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';
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
expandCppWildcardNames,
|
||||
isFileLocal,
|
||||
clearFileLocalNames,
|
||||
populateCppAnonymousNamespaceScopes,
|
||||
populateCppNonGloballyVisible,
|
||||
isCppDefGloballyVisible,
|
||||
} from './file-local-linkage.js';
|
||||
@@ -24,22 +26,14 @@ import {
|
||||
clearCppDependentBases,
|
||||
isCppDependentBaseMember,
|
||||
} from './two-phase-lookup.js';
|
||||
import {
|
||||
populateCppAssociatedNamespaces,
|
||||
clearCppAdlState,
|
||||
pickCppAdlCandidates,
|
||||
ADL_AMBIGUOUS,
|
||||
} from './adl.js';
|
||||
import { populateCppAssociatedNamespaces, clearCppAdlState, pickCppAdlCandidates } from './adl.js';
|
||||
import {
|
||||
clearCppInlineNamespaces,
|
||||
populateCppInlineNamespaceScopes,
|
||||
resolveCppQualifiedNamespaceMember,
|
||||
} from './inline-namespaces.js';
|
||||
import { populateCppRangeBindings } from './range-bindings.js';
|
||||
import {
|
||||
isOverloadAmbiguousAfterNormalization,
|
||||
narrowOverloadCandidates,
|
||||
} from '../../scope-resolution/passes/overload-narrowing.js';
|
||||
import { cppConstraintCompatibility } from './constraint-filter.js';
|
||||
|
||||
/**
|
||||
* C++ `ScopeResolver` registered in `SCOPE_RESOLVERS` and consumed by
|
||||
@@ -92,15 +86,22 @@ export const cppScopeResolver: ScopeResolver = {
|
||||
// (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-namespace ranges (recorded at capture time) to
|
||||
// ScopeIds BEFORE `populateCppNonGloballyVisible` runs, so the
|
||||
// inline-namespace exemption sees the populated Set.
|
||||
// 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.
|
||||
@@ -176,6 +177,10 @@ export const cppScopeResolver: ScopeResolver = {
|
||||
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
|
||||
@@ -220,10 +225,12 @@ export const cppScopeResolver: ScopeResolver = {
|
||||
},
|
||||
|
||||
// C++ argument-dependent / Koenig lookup (U2 of plan 2026-05-13-001).
|
||||
// Fires after `findCallableBindingInScope` returns undefined; surfaces
|
||||
// candidates from the associated namespaces of class-typed arguments.
|
||||
// V1 limitation: only direct enclosing-namespace closure for value
|
||||
// class-typed args; pointer/reference/template-spec args excluded.
|
||||
// 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
|
||||
@@ -240,21 +247,26 @@ export const cppScopeResolver: ScopeResolver = {
|
||||
parsedFiles,
|
||||
scopes,
|
||||
);
|
||||
if (member === undefined) continue;
|
||||
if (member === undefined || member === 'ambiguous') continue;
|
||||
if (seenUsing.has(member.nodeId)) continue;
|
||||
seenUsing.add(member.nodeId);
|
||||
usingNamedHits.push(member);
|
||||
}
|
||||
if (usingNamedHits.length > 0) {
|
||||
const narrowed = narrowOverloadCandidates(usingNamedHits, site.arity, site.argumentTypes);
|
||||
if (isOverloadAmbiguousAfterNormalization(narrowed, site.arity)) return 'ambiguous';
|
||||
if (narrowed.length === 1) return narrowed[0];
|
||||
if (narrowed.length > 1) return 'ambiguous';
|
||||
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);
|
||||
}
|
||||
|
||||
const result = pickCppAdlCandidates(site, callerParsed, scopes, parsedFiles);
|
||||
if (result === ADL_AMBIGUOUS) return 'ambiguous';
|
||||
return result;
|
||||
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).
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@
|
||||
* logic up the dependency chain instead.
|
||||
*/
|
||||
|
||||
import type { NodeLabel, SymbolDefinition } from 'gitnexus-shared';
|
||||
import type { NodeLabel, ParameterTypeClass, SymbolDefinition } from 'gitnexus-shared';
|
||||
|
||||
/**
|
||||
* Class-like NodeLabels — used for qualifiedName fallback inside
|
||||
@@ -126,6 +126,7 @@ export interface AddMetadata {
|
||||
parameterCount?: number;
|
||||
requiredParameterCount?: number;
|
||||
parameterTypes?: string[];
|
||||
parameterTypeClasses?: ParameterTypeClass[];
|
||||
returnType?: string;
|
||||
declaredType?: string;
|
||||
templateArguments?: string[];
|
||||
@@ -276,6 +277,9 @@ export const createSymbolTable = (): InternalSymbolTable => {
|
||||
...(metadata?.parameterTypes !== undefined
|
||||
? { parameterTypes: metadata.parameterTypes }
|
||||
: {}),
|
||||
...(metadata?.parameterTypeClasses !== undefined
|
||||
? { parameterTypeClasses: metadata.parameterTypeClasses }
|
||||
: {}),
|
||||
...(metadata?.returnType !== undefined ? { returnType: metadata.returnType } : {}),
|
||||
...(metadata?.declaredType !== undefined ? { declaredType: metadata.declaredType } : {}),
|
||||
...(metadata?.templateArguments !== undefined
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { GraphNode, GraphRelationship, NodeLabel } from 'gitnexus-shared';
|
||||
import type { GraphNode, GraphRelationship, NodeLabel, ParameterTypeClass } from 'gitnexus-shared';
|
||||
import { KnowledgeGraph } from '../graph/types.js';
|
||||
import Parser from 'tree-sitter';
|
||||
import { loadParser, loadLanguage, isLanguageAvailable } from '../tree-sitter/parser-loader.js';
|
||||
@@ -30,7 +30,11 @@ import {
|
||||
constTagForId,
|
||||
buildCollisionGroups,
|
||||
} from './utils/method-props.js';
|
||||
import { extractTemplateArguments, templateArgumentsIdTag } from './utils/template-arguments.js';
|
||||
import {
|
||||
extractTemplateArguments,
|
||||
templateArgumentsIdTag,
|
||||
templateConstraintsIdTag,
|
||||
} from './utils/template-arguments.js';
|
||||
import type { LanguageProvider } from './language-provider.js';
|
||||
import type { ParsedFile } from 'gitnexus-shared';
|
||||
import { WorkerPool } from './workers/worker-pool.js';
|
||||
@@ -128,6 +132,7 @@ export const mergeChunkResults = (
|
||||
parameterCount: sym.parameterCount,
|
||||
requiredParameterCount: sym.requiredParameterCount,
|
||||
parameterTypes: sym.parameterTypes,
|
||||
parameterTypeClasses: sym.parameterTypeClasses,
|
||||
returnType: sym.returnType,
|
||||
declaredType: sym.declaredType,
|
||||
templateArguments: sym.templateArguments,
|
||||
@@ -650,9 +655,38 @@ const processParsingSequential = async (
|
||||
classTemplateArguments.length > 0
|
||||
? templateArgumentsIdTag(classTemplateArguments)
|
||||
: '';
|
||||
// SFINAE / `requires`-clause aware ID disambiguation (issue #1579).
|
||||
// Function-template overloads with identical parameterTypes but
|
||||
// mutually-exclusive constraints (e.g. `enable_if_t<is_integral_v<T>>`
|
||||
// vs `enable_if_t<is_floating_point_v<T>>`) need distinct graph
|
||||
// nodes so the constraint-filter step in `narrowOverloadCandidates`
|
||||
// has two candidates to narrow between. Without this tag they
|
||||
// collapse to a single Function node and the SFINAE call resolves
|
||||
// to only one edge regardless of which overload's constraint holds.
|
||||
// The provider hook is the right invocation point — parsing-processor
|
||||
// sees raw tree-sitter matches without the `@`-prefixed synthetic
|
||||
// captures `scope-extractor` consumes, so we delegate extraction to
|
||||
// the language adapter (C++ implements this; other languages opt out).
|
||||
let parsedTemplateConstraints: unknown = undefined;
|
||||
let constraintsTag = '';
|
||||
if (
|
||||
(nodeLabel === 'Function' || nodeLabel === 'Method') &&
|
||||
provider.extractTemplateConstraints !== undefined &&
|
||||
definitionNode !== null
|
||||
) {
|
||||
try {
|
||||
parsedTemplateConstraints = provider.extractTemplateConstraints(definitionNode);
|
||||
if (parsedTemplateConstraints !== undefined) {
|
||||
constraintsTag = templateConstraintsIdTag(parsedTemplateConstraints);
|
||||
}
|
||||
} catch {
|
||||
parsedTemplateConstraints = undefined;
|
||||
constraintsTag = '';
|
||||
}
|
||||
}
|
||||
const nodeId = generateId(
|
||||
nodeLabel,
|
||||
`${file.path}:${qualifiedName}${classTemplateTag}${arityTag}`,
|
||||
`${file.path}:${qualifiedName}${classTemplateTag}${arityTag}${constraintsTag}`,
|
||||
);
|
||||
const classNodeForSymbol = definitionNodeForRange || definitionNode || nameNode;
|
||||
const qualifiedTypeName =
|
||||
@@ -689,6 +723,9 @@ const processParsingSequential = async (
|
||||
...(classTemplateArguments !== undefined && classTemplateArguments.length > 0
|
||||
? { templateArguments: classTemplateArguments }
|
||||
: {}),
|
||||
...(parsedTemplateConstraints !== undefined
|
||||
? { templateConstraints: parsedTemplateConstraints }
|
||||
: {}),
|
||||
...(frameworkHint
|
||||
? {
|
||||
astFrameworkMultiplier: frameworkHint.entryPointMultiplier,
|
||||
@@ -744,6 +781,7 @@ const processParsingSequential = async (
|
||||
parameterCount: methodProps.parameterCount as number | undefined,
|
||||
requiredParameterCount: methodProps.requiredParameterCount as number | undefined,
|
||||
parameterTypes: methodProps.parameterTypes as string[] | undefined,
|
||||
parameterTypeClasses: methodProps.parameterTypeClasses as ParameterTypeClass[] | undefined,
|
||||
returnType: methodProps.returnType as string | undefined,
|
||||
declaredType,
|
||||
templateArguments: classTemplateArguments,
|
||||
|
||||
@@ -16,12 +16,18 @@ import {
|
||||
} from '../call-processor.js';
|
||||
import type { createResolutionContext } from '../model/resolution-context.js';
|
||||
import { createASTCache } from '../ast-cache.js';
|
||||
import { type PipelineProgress, getLanguageFromFilename } from 'gitnexus-shared';
|
||||
import {
|
||||
type PipelineProgress,
|
||||
getLanguageFromFilename,
|
||||
type SupportedLanguages,
|
||||
} from 'gitnexus-shared';
|
||||
import { readFileContents } from '../filesystem-walker.js';
|
||||
import { isLanguageAvailable } from '../../tree-sitter/parser-loader.js';
|
||||
import { isRegistryPrimary } from '../registry-primary-flag.js';
|
||||
import { topologicalLevelSort } from '../utils/graph-sort.js';
|
||||
import type { KnowledgeGraph } from '../../graph/types.js';
|
||||
import { isDev } from '../utils/env.js';
|
||||
import type Parser from 'tree-sitter';
|
||||
|
||||
import { logger } from '../../logger.js';
|
||||
/** Max AST trees to keep in LRU cache for cross-file binding propagation. */
|
||||
@@ -114,6 +120,36 @@ export async function runCrossFileBindingPropagation(
|
||||
let crossFileResolved = 0;
|
||||
const crossFileStart = Date.now();
|
||||
const astCache = createASTCache(AST_CACHE_CAP);
|
||||
// Compiled query objects keyed by language name. Shared across all processCalls
|
||||
// invocations in this phase so the same tree-sitter query string is only
|
||||
// compiled once per language instead of once per file (O(1) vs O(N)).
|
||||
const compiledQueryCache = new Map<SupportedLanguages, Parser.Query>();
|
||||
|
||||
// Snapshot total topological candidates for progress math. We walk the
|
||||
// levels once more here (fast — no I/O) so we can report meaningful
|
||||
// percentages rather than a frozen display.
|
||||
let totalCandidates = 0;
|
||||
for (const level of levels) {
|
||||
for (const filePath of level) {
|
||||
if (totalCandidates >= MAX_CROSS_FILE_REPROCESS) break;
|
||||
const imports = ctx.namedImportMap.get(filePath);
|
||||
if (!imports) continue;
|
||||
if (!allPathSet.has(filePath)) continue;
|
||||
const lang = getLanguageFromFilename(filePath);
|
||||
if (!lang || !isLanguageAvailable(lang)) continue;
|
||||
// Registry-primary languages have their call resolution handled by the
|
||||
// scope-resolution pipeline — processCalls skips them immediately. Skip
|
||||
// here too so we avoid the I/O cost (readFileContents) and map-building
|
||||
// overhead for files that would be no-ops anyway.
|
||||
if (isRegistryPrimary(lang)) continue;
|
||||
totalCandidates++;
|
||||
}
|
||||
if (totalCandidates >= MAX_CROSS_FILE_REPROCESS) break;
|
||||
}
|
||||
const cappedTotal = Math.min(totalCandidates, MAX_CROSS_FILE_REPROCESS);
|
||||
|
||||
/** Emit a progress event every PROGRESS_INTERVAL files so the UI stays alive. */
|
||||
const PROGRESS_INTERVAL = 25;
|
||||
|
||||
for (const level of levels) {
|
||||
const levelCandidates: {
|
||||
@@ -151,6 +187,10 @@ export async function runCrossFileBindingPropagation(
|
||||
|
||||
const lang = getLanguageFromFilename(filePath);
|
||||
if (!lang || !isLanguageAvailable(lang)) continue;
|
||||
// Registry-primary languages have their call resolution handled by the
|
||||
// scope-resolution pipeline — processCalls skips them immediately. Skip
|
||||
// here to avoid readFileContents I/O and map-building for no-op files.
|
||||
if (isRegistryPrimary(lang)) continue;
|
||||
|
||||
levelCandidates.push({ filePath, seeded, importedReturns, importedRawReturns });
|
||||
}
|
||||
@@ -188,8 +228,24 @@ export async function runCrossFileBindingPropagation(
|
||||
bindings.size > 0 ? bindings : undefined,
|
||||
importedReturnTypesMap.size > 0 ? importedReturnTypesMap : undefined,
|
||||
importedRawReturnTypesMap.size > 0 ? importedRawReturnTypesMap : undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
compiledQueryCache,
|
||||
);
|
||||
crossFileResolved++;
|
||||
|
||||
// Emit progress every PROGRESS_INTERVAL files so the UI shows real
|
||||
// movement instead of a frozen display (cross-file can take minutes
|
||||
// on large repos with many cross-file imports).
|
||||
if (crossFileResolved % PROGRESS_INTERVAL === 0 || crossFileResolved === cappedTotal) {
|
||||
const pct = cappedTotal > 0 ? Math.round((crossFileResolved / cappedTotal) * 8) : 0;
|
||||
onProgress({
|
||||
phase: 'parsing',
|
||||
percent: 82 + pct,
|
||||
message: `Cross-file type propagation (${crossFileResolved}/${cappedTotal} files)...`,
|
||||
stats: { filesProcessed: crossFileResolved, totalFiles, nodesCreated: graph.nodeCount },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (crossFileResolved >= MAX_CROSS_FILE_REPROCESS) {
|
||||
|
||||
@@ -71,6 +71,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { isDev } from '../utils/env.js';
|
||||
import { synthesizeWildcardImportBindings, needsSynthesis } from './wildcard-synthesis.js';
|
||||
import { extractORMQueriesInline } from './orm-extraction.js';
|
||||
import { hasWorkerUnsafeLanguages } from '../workers/worker-language-guard.js';
|
||||
|
||||
import { logger } from '../../logger.js';
|
||||
// ── Constants ──────────────────────────────────────────────────────────────
|
||||
@@ -315,6 +316,8 @@ export async function runChunkedParseAndResolve(
|
||||
const parseCache = options?.parseCache;
|
||||
let chunkCacheHits = 0;
|
||||
let chunkCacheMisses = 0;
|
||||
// Suppress duplicate warnings when multiple consecutive chunks contain C/C++ files.
|
||||
let hasWarnedAboutCppChunks = false;
|
||||
|
||||
try {
|
||||
for (let chunkIdx = 0; chunkIdx < numChunks; chunkIdx++) {
|
||||
@@ -370,6 +373,25 @@ export async function runChunkedParseAndResolve(
|
||||
// them under the chunk hash for the next run.
|
||||
chunkCacheMisses++;
|
||||
const rawResults: ParseWorkerResult[] = [];
|
||||
// C/C++ grammar native bindings can crash worker threads with an
|
||||
// unrecoverable Napi::Error / std::terminate(). Detect C/C++ files
|
||||
// within this specific chunk and route them through the main-thread
|
||||
// sequential path to avoid the crash. Other chunks (no C/C++) keep
|
||||
// using the worker pool. GITNEXUS_ALLOW_CPP_WORKERS=1 opts out of
|
||||
// this guard when a stable custom build is in use.
|
||||
const chunkHasWorkerUnsafeFiles =
|
||||
workerPool &&
|
||||
process.env.GITNEXUS_ALLOW_CPP_WORKERS !== '1' &&
|
||||
hasWorkerUnsafeLanguages(chunkFiles);
|
||||
if (chunkHasWorkerUnsafeFiles && !hasWarnedAboutCppChunks) {
|
||||
hasWarnedAboutCppChunks = true;
|
||||
logger.warn(
|
||||
`C/C++ files detected — parsing chunks containing C/C++ files in sequential mode to avoid known ` +
|
||||
`worker-thread native binding errors (e.g. \`Napi::Error\`). C/C++ source files ` +
|
||||
`are still fully indexed; chunks with no C/C++ continue using workers. ` +
|
||||
`Set GITNEXUS_ALLOW_CPP_WORKERS=1 if you use a custom tree-sitter build without this issue.`,
|
||||
);
|
||||
}
|
||||
chunkWorkerData = await processParsing(
|
||||
graph,
|
||||
chunkFiles,
|
||||
@@ -391,7 +413,11 @@ export async function runChunkedParseAndResolve(
|
||||
},
|
||||
});
|
||||
},
|
||||
workerPool,
|
||||
// For chunks containing C/C++ files, bypass the worker pool to avoid
|
||||
// unrecoverable Napi::Error crashes from native grammar bindings in
|
||||
// worker threads. processParsing falls through to processParsingSequential
|
||||
// (main-thread safe). All other chunks keep using the worker pool.
|
||||
chunkHasWorkerUnsafeFiles ? undefined : workerPool,
|
||||
// Capture raw results only when we have a cache to write to —
|
||||
// otherwise we'd retain extra arrays for nothing.
|
||||
parseCache && chunkHash ? rawResults : undefined,
|
||||
|
||||
@@ -63,6 +63,7 @@ import type {
|
||||
BindingRef,
|
||||
CaptureMatch,
|
||||
ImportEdge,
|
||||
ParameterTypeClass,
|
||||
ParsedFile,
|
||||
ParsedImport,
|
||||
ReferenceSite,
|
||||
@@ -545,8 +546,12 @@ function buildDefFromDeclarationMatch(
|
||||
const parameterCount = parseIntCapture(match['@declaration.parameter-count']);
|
||||
const requiredParameterCount = parseIntCapture(match['@declaration.required-parameter-count']);
|
||||
const parameterTypes = parseJsonStringArrayCapture(match['@declaration.parameter-types']);
|
||||
const parameterTypeClasses = parseJsonParameterTypeClassesCapture(
|
||||
match['@declaration.parameter-type-classes'],
|
||||
);
|
||||
const declaredType = match['@declaration.field-type']?.text;
|
||||
const returnType = match['@declaration.return-type']?.text;
|
||||
const templateConstraints = parseJsonCapture(match['@declaration.template-constraints']);
|
||||
|
||||
return {
|
||||
nodeId: makeDefId(filePath, anchor.range, type, nameCap.text),
|
||||
@@ -556,18 +561,79 @@ function buildDefFromDeclarationMatch(
|
||||
...(parameterCount !== undefined ? { parameterCount } : {}),
|
||||
...(requiredParameterCount !== undefined ? { requiredParameterCount } : {}),
|
||||
...(parameterTypes !== undefined ? { parameterTypes } : {}),
|
||||
...(parameterTypeClasses !== undefined ? { parameterTypeClasses } : {}),
|
||||
...(declaredType !== undefined ? { declaredType } : {}),
|
||||
...(returnType !== undefined ? { returnType } : {}),
|
||||
...(templateArguments !== undefined ? { templateArguments } : {}),
|
||||
...(templateConstraints !== undefined ? { templateConstraints } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Parse an opaque JSON payload synthesized by per-language captures
|
||||
* (e.g. C++ `@declaration.template-constraints`). Producer owns the
|
||||
* shape; shared code threads it through as `unknown` per the
|
||||
* `SymbolDefinition.templateConstraints` contract. */
|
||||
function parseJsonCapture(cap: { readonly text: string } | undefined): unknown {
|
||||
if (cap === undefined) return undefined;
|
||||
try {
|
||||
return JSON.parse(cap.text);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function parseIntCapture(cap: { readonly text: string } | undefined): number | undefined {
|
||||
if (cap === undefined) return undefined;
|
||||
const n = Number.parseInt(cap.text, 10);
|
||||
return Number.isFinite(n) ? n : undefined;
|
||||
}
|
||||
|
||||
function parseJsonParameterTypeClassesCapture(
|
||||
cap: { readonly text: string } | undefined,
|
||||
): ParameterTypeClass[] | undefined {
|
||||
if (cap === undefined) return undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(cap.text);
|
||||
if (!Array.isArray(parsed)) return undefined;
|
||||
const out: ParameterTypeClass[] = [];
|
||||
for (const item of parsed) {
|
||||
if (item === null || typeof item !== 'object') return undefined;
|
||||
const o = item as Record<string, unknown>;
|
||||
if (typeof o.base !== 'string') return undefined;
|
||||
if (
|
||||
o.cv !== 'none' &&
|
||||
o.cv !== 'const' &&
|
||||
o.cv !== 'volatile' &&
|
||||
o.cv !== 'const volatile' &&
|
||||
o.cv !== 'unknown'
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
o.indirection !== 'value' &&
|
||||
o.indirection !== 'lvalue-ref' &&
|
||||
o.indirection !== 'rvalue-ref' &&
|
||||
o.indirection !== 'pointer' &&
|
||||
o.indirection !== 'unknown'
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof o.pointerDepth !== 'number' || !Number.isFinite(o.pointerDepth)) {
|
||||
return undefined;
|
||||
}
|
||||
out.push({
|
||||
base: o.base,
|
||||
cv: o.cv,
|
||||
indirection: o.indirection,
|
||||
pointerDepth: o.pointerDepth,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function parseJsonStringArrayCapture(
|
||||
cap: { readonly text: string } | undefined,
|
||||
): string[] | undefined {
|
||||
@@ -977,6 +1043,7 @@ const KNOWN_SUB_TAGS: ReadonlySet<string> = new Set<string>([
|
||||
'@declaration.parameter-count',
|
||||
'@declaration.required-parameter-count',
|
||||
'@declaration.parameter-types',
|
||||
'@declaration.template-constraints',
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -254,6 +254,7 @@
|
||||
import type {
|
||||
BindingRef,
|
||||
Callsite,
|
||||
ConstraintContext,
|
||||
ParsedFile,
|
||||
ScopeId,
|
||||
SupportedLanguages,
|
||||
@@ -264,6 +265,7 @@ import type { GraphNodeLookup } from '../graph-bridge/node-lookup.js';
|
||||
import { LanguageProvider } from '../../language-provider.js';
|
||||
import { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
|
||||
import type { SemanticModel } from '../../model/semantic-model.js';
|
||||
import type { ConversionRankFn } from '../passes/overload-narrowing.js';
|
||||
|
||||
/** A LinearizeStrategy receives the full ancestor map so C3-style
|
||||
* algorithms (which need to merge each parent's MRO) can implement
|
||||
@@ -278,6 +280,10 @@ export type LinearizeStrategy = (
|
||||
/** Result of `ScopeResolver.arityCompatibility` — mirrors `RegistryProviders.arityCompatibility`. */
|
||||
export type ArityVerdict = 'compatible' | 'unknown' | 'incompatible';
|
||||
|
||||
/** Re-exported for ScopeResolver consumers — same shape as
|
||||
* `RegistryProviders.constraintCompatibility`'s third parameter. */
|
||||
export type { ConstraintContext } from 'gitnexus-shared';
|
||||
|
||||
export interface ScopeResolver {
|
||||
/** Identity for telemetry + per-language flag check. */
|
||||
readonly language: SupportedLanguages;
|
||||
@@ -373,6 +379,28 @@ export interface ScopeResolver {
|
||||
*/
|
||||
arityCompatibility(callsite: Callsite, def: SymbolDefinition): ArityVerdict;
|
||||
|
||||
/**
|
||||
* Per-language constraint compatibility between a callsite and a
|
||||
* candidate `def` that carries `templateConstraints` metadata.
|
||||
* Mirrors `arityCompatibility` semantics: the three-valued verdict
|
||||
* MUST treat `'unknown'` as keep-candidate (monotonicity — adding
|
||||
* a predicate can only narrow correctly, never produce a wrong
|
||||
* edge). Consulted by `narrowOverloadCandidates` after the arity
|
||||
* and parameter-type filters.
|
||||
*
|
||||
* Optional. Languages without constrained-overload semantics
|
||||
* (SFINAE, `requires` clauses, trait bounds, conditional types)
|
||||
* leave this undefined and the constraint filter is a pass-through.
|
||||
*
|
||||
* C++ is the first consumer; see `languages/cpp/constraint-filter.ts`
|
||||
* for the Tier-A predicate registry and Kleene 3-valued evaluator.
|
||||
*/
|
||||
readonly constraintCompatibility?: (
|
||||
callsite: Callsite,
|
||||
def: SymbolDefinition,
|
||||
ctx: ConstraintContext,
|
||||
) => ArityVerdict;
|
||||
|
||||
// ─── Per-language strategies ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -533,6 +561,20 @@ export interface ScopeResolver {
|
||||
*/
|
||||
readonly allowGlobalFreeCallFallback?: boolean;
|
||||
|
||||
/**
|
||||
* Optional per-slot conversion-rank function for overload resolution.
|
||||
* When provided, `narrowOverloadCandidates` uses ranked scoring as a
|
||||
* fallback when the exact-type filter produces no match. The function
|
||||
* returns a numeric cost (0 = exact, 1 = promotion, 2 = standard
|
||||
* conversion, Infinity = incompatible) for converting an argument
|
||||
* type to a parameter type.
|
||||
*
|
||||
* The conversion-rank table is language-specific (issue #1578 pitfall:
|
||||
* keep it out of shared overload-narrowing). C++ provides
|
||||
* `cppConversionRank`; other languages define their own if needed.
|
||||
*/
|
||||
readonly conversionRankFn?: ConversionRankFn;
|
||||
|
||||
/**
|
||||
* Optional predicate to identify definitions with file-local linkage
|
||||
* (e.g. C `static` functions). When provided, `pickUniqueGlobalCallable`
|
||||
@@ -576,16 +618,15 @@ export interface ScopeResolver {
|
||||
* Optional argument-dependent-lookup (ADL / Koenig lookup) hook for
|
||||
* languages with C++-style associated-namespace candidate addition.
|
||||
*
|
||||
* Runs in the free-call fallback AFTER `findCallableBindingInScope`
|
||||
* returns `undefined` and BEFORE `pickUniqueGlobalCallable`. The hook
|
||||
* inspects the call site's argument types, computes the associated
|
||||
* namespace set, and returns either:
|
||||
* - a unique `SymbolDefinition` — emit the CALLS edge to it.
|
||||
* - `'ambiguous'` — multiple candidates share normalized parameter
|
||||
* types; the caller MUST suppress (zero edges). Mirrors the
|
||||
* OVERLOAD_AMBIGUOUS sentinel from `overload-narrowing.ts`.
|
||||
* - `undefined` — no ADL candidates; caller falls through to the
|
||||
* global free-call fallback (`pickUniqueGlobalCallable`).
|
||||
* Runs in the free-call fallback alongside ordinary unqualified lookup.
|
||||
* The fallback merges ordinary candidates with ADL candidates and applies
|
||||
* overload narrowing over the union.
|
||||
*
|
||||
* The hook inspects the call site's argument types, computes the
|
||||
* associated namespace set, and returns either:
|
||||
* - an array of candidate `SymbolDefinition`s to add to the
|
||||
* ordinary-lookup candidate pool.
|
||||
* - `undefined` when ADL contributes no candidates.
|
||||
*
|
||||
* Languages without C++-style ADL leave this undefined. The
|
||||
* cross-language contract is "additive tier" — defining the hook never
|
||||
@@ -601,7 +642,7 @@ export interface ScopeResolver {
|
||||
callerParsed: ParsedFile,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
) => SymbolDefinition | 'ambiguous' | undefined;
|
||||
) => readonly SymbolDefinition[] | undefined;
|
||||
|
||||
/**
|
||||
* Optional resolver for qualified-receiver member calls where the
|
||||
@@ -616,8 +657,9 @@ export interface ScopeResolver {
|
||||
*
|
||||
* Receiver-bound-calls invokes this hook AFTER Case 1 (namespace
|
||||
* imports) and AFTER Case 2 (class-name receiver) fail to resolve.
|
||||
* Returns the target def, or `undefined` to fall through to the
|
||||
* remaining cases.
|
||||
* Returns the target def, `'ambiguous'` when multiple inline-namespace
|
||||
* children declare the same name (suppresses edge emission), or
|
||||
* `undefined` to fall through to the remaining cases.
|
||||
*/
|
||||
readonly resolveQualifiedReceiverMember?: (
|
||||
receiverName: string,
|
||||
@@ -625,7 +667,7 @@ export interface ScopeResolver {
|
||||
callerScope: ScopeId,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
) => SymbolDefinition | undefined;
|
||||
) => SymbolDefinition | 'ambiguous' | undefined;
|
||||
|
||||
/**
|
||||
* Enable the receiver-bound Case 0.5 fallback for explicit `this`
|
||||
|
||||
@@ -21,6 +21,7 @@ import type { NodeLabel, ScopeId, SymbolDefinition } from 'gitnexus-shared';
|
||||
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
|
||||
import { generateId } from '../../../../lib/utils.js';
|
||||
import { qualifiedKey, simpleKey, type GraphNodeLookup } from '../graph-bridge/node-lookup.js';
|
||||
import { templateConstraintsIdTag } from '../../utils/template-arguments.js';
|
||||
/**
|
||||
* Labels that may legitimately ANCHOR a CALLS/ACCESSES edge as the
|
||||
* source ("caller"). A Variable / Property can be the TARGET of an
|
||||
@@ -76,12 +77,31 @@ export function resolveDefGraphId(
|
||||
type?: NodeLabel;
|
||||
parameterTypes?: readonly string[];
|
||||
templateArguments?: readonly string[];
|
||||
templateConstraints?: unknown;
|
||||
},
|
||||
nodeLookup: GraphNodeLookup,
|
||||
): string | undefined {
|
||||
const qn = def.qualifiedName;
|
||||
if (qn === undefined || qn.length === 0) return undefined;
|
||||
if (def.type !== undefined) {
|
||||
// SFINAE / `requires`-clause disambiguation (issue #1579) — try the
|
||||
// constraint-fingerprinted key FIRST. Two function-template overloads
|
||||
// with identical `parameterTypes` but mutually-exclusive SFINAE
|
||||
// constraints route to their distinct graph nodes via this key.
|
||||
// Must run before the parameter-types key because both overloads
|
||||
// share the latter.
|
||||
if (
|
||||
(def.type === 'Function' || def.type === 'Method') &&
|
||||
def.templateConstraints !== undefined
|
||||
) {
|
||||
const cKey = qualifiedKey(
|
||||
filePath,
|
||||
def.type,
|
||||
`${qn}${templateConstraintsIdTag(def.templateConstraints)}`,
|
||||
);
|
||||
const cHit = nodeLookup.get(cKey);
|
||||
if (cHit !== undefined) return cHit;
|
||||
}
|
||||
// Overload disambiguation: when the def carries parameter types,
|
||||
// try the parameter-typed key first so same-name same-arity
|
||||
// overloads route to their distinct graph nodes.
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
|
||||
import type { NodeLabel } from 'gitnexus-shared';
|
||||
import type { KnowledgeGraph } from '../../../graph/types.js';
|
||||
import { templateConstraintsIdTag } from '../../utils/template-arguments.js';
|
||||
|
||||
export type GraphNodeLookup = ReadonlyMap<string, string>;
|
||||
|
||||
@@ -97,6 +98,21 @@ export function buildGraphNodeLookup(graph: KnowledgeGraph): GraphNodeLookup {
|
||||
// Each overload is unique — set unconditionally.
|
||||
lookup.set(pKey, node.id);
|
||||
}
|
||||
// SFINAE / `requires`-clause disambiguation (issue #1579) — register
|
||||
// a constraint-fingerprinted key so resolveDefGraphId can locate the
|
||||
// correct overload by hashing the def's `templateConstraints`. Mirrors
|
||||
// the parameter-types key but keys on the opaque constraint payload
|
||||
// instead, separating two `process<T>` overloads whose
|
||||
// `parameterTypes=['T']` would otherwise collide.
|
||||
const tConstraints = (props as { templateConstraints?: unknown }).templateConstraints;
|
||||
if (tConstraints !== undefined && (node.label === 'Function' || node.label === 'Method')) {
|
||||
const cKey = qualifiedKey(
|
||||
props.filePath,
|
||||
node.label,
|
||||
`${qualified}${templateConstraintsIdTag(tConstraints)}`,
|
||||
);
|
||||
lookup.set(cKey, node.id);
|
||||
}
|
||||
if (
|
||||
(node.label === 'Class' ||
|
||||
node.label === 'Struct' ||
|
||||
|
||||
@@ -23,9 +23,19 @@ import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexe
|
||||
import type { SemanticModel } from '../../model/semantic-model.js';
|
||||
import type { WorkspaceResolutionIndex } from '../workspace-index.js';
|
||||
import type { GraphNodeLookup } from '../graph-bridge/node-lookup.js';
|
||||
import type { ScopeResolver } from '../contract/scope-resolver.js';
|
||||
import { resolveCallerGraphId, resolveDefGraphId } from '../graph-bridge/ids.js';
|
||||
import { findCallableBindingInScope, findClassBindingInScope } from '../scope/walkers.js';
|
||||
import { narrowOverloadCandidates } from './overload-narrowing.js';
|
||||
import {
|
||||
findAllCallableBindingsInScope,
|
||||
findCallableBindingInScope,
|
||||
findCallableBindingsAndAdlBlocker,
|
||||
findClassBindingInScope,
|
||||
} from '../scope/walkers.js';
|
||||
import {
|
||||
isOverloadAmbiguousAfterNormalization,
|
||||
narrowOverloadCandidates,
|
||||
type ConversionRankFn,
|
||||
} from './overload-narrowing.js';
|
||||
|
||||
export function emitFreeCallFallback(
|
||||
graph: KnowledgeGraph,
|
||||
@@ -55,7 +65,14 @@ export function emitFreeCallFallback(
|
||||
callerParsed: ParsedFile,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
) => SymbolDefinition | 'ambiguous' | undefined;
|
||||
) => readonly SymbolDefinition[] | undefined;
|
||||
readonly conversionRankFn?: ConversionRankFn;
|
||||
/** Optional per-language constraint hook threaded into
|
||||
* `narrowOverloadCandidates`. Drops candidates whose template
|
||||
* constraints (e.g. C++ `enable_if_t`, C++20 `requires`) provably
|
||||
* fail at the call site. Three-valued; `'unknown'` keeps the
|
||||
* candidate (monotonicity). */
|
||||
readonly constraintCompatibility?: ScopeResolver['constraintCompatibility'];
|
||||
} = {},
|
||||
): number {
|
||||
let emitted = 0;
|
||||
@@ -83,44 +100,156 @@ export function emitFreeCallFallback(
|
||||
// the same name in a single class, choose the best match by
|
||||
// arity + argument types.
|
||||
if (fnDef === undefined) {
|
||||
fnDef = pickImplicitThisOverload(site, scopes, workspaceIndex, model);
|
||||
fnDef = pickImplicitThisOverload(site, scopes, workspaceIndex, model, {
|
||||
conversionRankFn: options.conversionRankFn,
|
||||
constraintCompatibility: options.constraintCompatibility,
|
||||
});
|
||||
}
|
||||
// Scope-chain callable lookup. First-match preserves scope-chain
|
||||
// precedence (local shadows import). When a conversion-rank function
|
||||
// is available AND the binding scope contains multiple overloads,
|
||||
// refine with `narrowOverloadCandidates` to pick the best overload
|
||||
// by argument types (#1578). The first-match result is kept as a
|
||||
// fallback when narrowing is indeterminate.
|
||||
if (fnDef === undefined) {
|
||||
fnDef = findCallableBindingInScope(site.inScope, site.name, scopes);
|
||||
}
|
||||
// V1 ADL tier (C++ Koenig lookup, opt-in via provider.resolveAdlCandidates).
|
||||
// Fires only when ordinary lookup is empty — V1 limitation per
|
||||
// plan 2026-05-13-001 U2; ISO C++ would merge ADL with ordinary lookup
|
||||
// and run overload resolution over the union.
|
||||
//
|
||||
// Sentinel 'ambiguous': ADL surfaced multiple candidates with
|
||||
// identical normalized parameter types (mirrors OVERLOAD_AMBIGUOUS).
|
||||
// We mark the site handled so `emit-references` does not retry, and
|
||||
// continue to the next site without emitting an edge.
|
||||
if (fnDef === undefined && options.resolveAdlCandidates !== undefined) {
|
||||
const adlResult = options.resolveAdlCandidates(
|
||||
{
|
||||
name: site.name,
|
||||
arity: site.arity,
|
||||
argumentTypes: site.argumentTypes,
|
||||
atRange: { startLine: site.atRange.startLine, startCol: site.atRange.startCol },
|
||||
},
|
||||
parsed,
|
||||
scopes,
|
||||
parsedFiles,
|
||||
);
|
||||
if (adlResult === 'ambiguous') {
|
||||
handledSites.add(`${parsed.filePath}:${site.atRange.startLine}:${site.atRange.startCol}`);
|
||||
continue;
|
||||
}
|
||||
if (adlResult !== undefined) {
|
||||
fnDef = adlResult;
|
||||
if (options.resolveAdlCandidates === undefined) {
|
||||
// Non-ADL path: first-match preserves scope-chain precedence
|
||||
// (local shadows import). When a conversion-rank function is
|
||||
// available AND the binding scope contains multiple overloads,
|
||||
// refine with narrowOverloadCandidates (#1578).
|
||||
fnDef = findCallableBindingInScope(site.inScope, site.name, scopes);
|
||||
if (fnDef !== undefined && options.conversionRankFn !== undefined) {
|
||||
const allCallables = findAllCallableBindingsInScope(site.inScope, site.name, scopes);
|
||||
if (allCallables.length > 1) {
|
||||
const narrowed = narrowOverloadCandidates(
|
||||
allCallables,
|
||||
site.arity,
|
||||
site.argumentTypes,
|
||||
{
|
||||
conversionRankFn: options.conversionRankFn,
|
||||
constraintCompatibility: options.constraintCompatibility,
|
||||
},
|
||||
);
|
||||
if (narrowed.length === 1) {
|
||||
fnDef = narrowed[0];
|
||||
} else if (narrowed.length > 1) {
|
||||
// Multiple survivors after conversion-rank scoring.
|
||||
// Suppress when all candidates share the same file (true
|
||||
// overloads) — mirrors ADL merged-candidate path behavior.
|
||||
// Cross-file candidates are shadowing; keep first-match.
|
||||
const sameFile = narrowed.every((d) => d.filePath === narrowed[0]!.filePath);
|
||||
if (sameFile) {
|
||||
handledSites.add(
|
||||
`${parsed.filePath}:${site.atRange.startLine}:${site.atRange.startCol}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// narrowed.length === 0: keep the first-match fnDef —
|
||||
// preserves local-shadows-import.
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// ADL path: ISO C++ `[basic.lookup.unqual]` §7 — ADL is suppressed
|
||||
// when ordinary lookup finds a non-function name or a block-scope
|
||||
// function declaration.
|
||||
const {
|
||||
callables: ordinary,
|
||||
nonCallableFound,
|
||||
blockScopeDeclFound,
|
||||
} = findCallableBindingsAndAdlBlocker(site.inScope, site.name, scopes);
|
||||
const adlSuppressed = nonCallableFound || blockScopeDeclFound;
|
||||
const adl = adlSuppressed
|
||||
? undefined
|
||||
: options.resolveAdlCandidates(
|
||||
{
|
||||
name: site.name,
|
||||
arity: site.arity,
|
||||
argumentTypes: site.argumentTypes,
|
||||
atRange: { startLine: site.atRange.startLine, startCol: site.atRange.startCol },
|
||||
},
|
||||
parsed,
|
||||
scopes,
|
||||
parsedFiles,
|
||||
);
|
||||
|
||||
const siteKey = `${parsed.filePath}:${site.atRange.startLine}:${site.atRange.startCol}`;
|
||||
if (adl === undefined || adl.length === 0) {
|
||||
// No ADL contribution. Default behavior: `ordinary[0]` —
|
||||
// scope-chain walk preserves local-shadows-import precedence.
|
||||
//
|
||||
// Narrowing kicks in when either disambiguation signal is
|
||||
// present: any candidate carries `templateConstraints`
|
||||
// (SFINAE / `requires`-clause guarded templates, #1579), OR
|
||||
// a conversion-rank function is provided (#1606 / #1578).
|
||||
// Both hooks are threaded into `narrowOverloadCandidates`
|
||||
// via the unified `OverloadNarrowingHookCtx`.
|
||||
const hasConstraints = ordinary.some((d) => d.templateConstraints !== undefined);
|
||||
const canNarrow = hasConstraints || options.conversionRankFn !== undefined;
|
||||
if (ordinary.length <= 1 || !canNarrow) {
|
||||
fnDef = ordinary[0];
|
||||
} else {
|
||||
const narrowed = narrowOverloadCandidates(ordinary, site.arity, site.argumentTypes, {
|
||||
conversionRankFn: options.conversionRankFn,
|
||||
constraintCompatibility: options.constraintCompatibility,
|
||||
});
|
||||
if (narrowed.length === 1) {
|
||||
fnDef = narrowed[0];
|
||||
} else if (narrowed.length === 0) {
|
||||
handledSites.add(siteKey);
|
||||
continue;
|
||||
} else {
|
||||
// >1 survivors: same-file → suppress (true overloads,
|
||||
// "degrade not lie" — no edge beats a wrong one, and
|
||||
// SFINAE-ambiguous calls land here). Cross-file →
|
||||
// first-match (shadowing semantics).
|
||||
const sameFile = narrowed.every((d) => d.filePath === narrowed[0]!.filePath);
|
||||
if (sameFile) {
|
||||
handledSites.add(siteKey);
|
||||
continue;
|
||||
}
|
||||
fnDef = ordinary[0];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const merged: SymbolDefinition[] = [];
|
||||
const seenMerge = new Set<string>();
|
||||
const push = (defs: readonly SymbolDefinition[]): void => {
|
||||
for (const d of defs) {
|
||||
if (seenMerge.has(d.nodeId)) continue;
|
||||
seenMerge.add(d.nodeId);
|
||||
merged.push(d);
|
||||
}
|
||||
};
|
||||
push(ordinary);
|
||||
push(adl);
|
||||
|
||||
const narrowed = narrowOverloadCandidates(merged, site.arity, site.argumentTypes, {
|
||||
conversionRankFn: options.conversionRankFn,
|
||||
constraintCompatibility: options.constraintCompatibility,
|
||||
});
|
||||
if (narrowed.length === 1) {
|
||||
fnDef = narrowed[0];
|
||||
} else if (narrowed.length === 0) {
|
||||
handledSites.add(siteKey);
|
||||
continue;
|
||||
} else if (narrowed.length > 1) {
|
||||
if (isOverloadAmbiguousAfterNormalization(narrowed, site.arity)) {
|
||||
handledSites.add(siteKey);
|
||||
continue;
|
||||
}
|
||||
// Multiple survivors remain after conversion-rank scoring;
|
||||
// suppress instead of picking arbitrarily.
|
||||
handledSites.add(siteKey);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// V1: pickUniqueGlobalCallable ignores import context — resolves to any
|
||||
// globally-unique callable. False cross-package edges are possible when
|
||||
// the caller does not import the target package. Same-package calls are
|
||||
// caught by findCallableBindingInScope above before reaching here.
|
||||
// usually caught by nearest-scope lookup before reaching here.
|
||||
if (fnDef === undefined && options.allowGlobalFallback === true) {
|
||||
fnDef = pickUniqueGlobalCallable(
|
||||
site.name,
|
||||
@@ -138,6 +267,8 @@ export function emitFreeCallFallback(
|
||||
scopes,
|
||||
})
|
||||
: undefined,
|
||||
site.argumentTypes,
|
||||
options.conversionRankFn,
|
||||
);
|
||||
}
|
||||
if (fnDef === undefined) continue;
|
||||
@@ -176,6 +307,8 @@ function pickUniqueGlobalCallable(
|
||||
isFileLocalDef?: (def: SymbolDefinition) => boolean,
|
||||
callArity?: number,
|
||||
isCallerVisible?: (candidate: SymbolDefinition) => boolean,
|
||||
callArgTypes?: readonly string[],
|
||||
conversionRankFn?: ConversionRankFn,
|
||||
): SymbolDefinition | undefined {
|
||||
const scopeDefs: SymbolDefinition[] = [];
|
||||
const scopeSeen = new Set<string>();
|
||||
@@ -210,6 +343,16 @@ function pickUniqueGlobalCallable(
|
||||
const arityMatch = narrowByArity(scopeDefs, callArity);
|
||||
if (arityMatch !== undefined) return arityMatch;
|
||||
}
|
||||
// When arity narrowing left >1 candidate, try overload narrowing with
|
||||
// argument types + conversion ranking (#1578). This picks the unique
|
||||
// best-rank candidate when exact-type or conversion-rank scoring can
|
||||
// disambiguate (e.g., `f(int)` vs `f(double)` called with `f(2.5)`).
|
||||
if (scopeDefs.length > 1) {
|
||||
const narrowed = narrowOverloadCandidates(scopeDefs, callArity, callArgTypes, {
|
||||
conversionRankFn,
|
||||
});
|
||||
if (narrowed.length === 1) return narrowed[0];
|
||||
}
|
||||
|
||||
const defs: SymbolDefinition[] = [];
|
||||
const seen = new Set<string>();
|
||||
@@ -243,6 +386,13 @@ function pickUniqueGlobalCallable(
|
||||
const arityMatch = narrowByArity(defs, callArity);
|
||||
if (arityMatch !== undefined) return arityMatch;
|
||||
}
|
||||
// Same argument-type + conversion-rank narrowing for the model pool.
|
||||
if (defs.length > 1) {
|
||||
const narrowed = narrowOverloadCandidates(defs, callArity, callArgTypes, {
|
||||
conversionRankFn,
|
||||
});
|
||||
if (narrowed.length === 1) return narrowed[0];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
@@ -316,6 +466,10 @@ export function pickImplicitThisOverload(
|
||||
scopes: ScopeResolutionIndexes,
|
||||
workspaceIndex: WorkspaceResolutionIndex,
|
||||
model: SemanticModel,
|
||||
hookCtx?: {
|
||||
readonly conversionRankFn?: ConversionRankFn;
|
||||
readonly constraintCompatibility?: ScopeResolver['constraintCompatibility'];
|
||||
},
|
||||
): SymbolDefinition | undefined {
|
||||
// Find the enclosing Class scope by walking parents.
|
||||
let curId: ScopeId | null = site.inScope;
|
||||
@@ -343,7 +497,10 @@ export function pickImplicitThisOverload(
|
||||
// ambiguous narrowing (multiple compatible candidates with no
|
||||
// disambiguating signal) leaves the call unresolved rather than
|
||||
// routing to an arbitrary first overload by registration order.
|
||||
const candidates = narrowOverloadCandidates(overloads, site.arity, site.argumentTypes);
|
||||
const candidates = narrowOverloadCandidates(overloads, site.arity, site.argumentTypes, {
|
||||
conversionRankFn: hookCtx?.conversionRankFn,
|
||||
constraintCompatibility: hookCtx?.constraintCompatibility,
|
||||
});
|
||||
if (candidates.length !== 1) return undefined;
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
@@ -24,15 +24,63 @@
|
||||
* equality. An empty string in `argTypes[i]` means "unknown" and
|
||||
* counts as a match. Mismatches disqualify. A non-empty typed
|
||||
* result wins; otherwise return the arity-filtered candidates.
|
||||
* 4b. When the exact-type filter from step 4 returns empty AND a
|
||||
* `conversionRankFn` is provided (via `hookCtx`), rank candidates
|
||||
* via pairwise dominance comparison (ISO C++ [over.ics.rank]):
|
||||
* F1 beats F2 only when F1 is not worse for every arg and better
|
||||
* for at least one. Non-dominated candidates are returned;
|
||||
* multiple survivors are genuinely ambiguous.
|
||||
* 4c. Final per-candidate constraint filter (SFINAE / `requires`).
|
||||
* When `constraintCompatibility` is provided via `hookCtx`, drop
|
||||
* candidates whose template constraints provably fail at the
|
||||
* call site. Three-valued; `'unknown'` keeps the candidate
|
||||
* (monotonicity).
|
||||
* 5. Empty input returns empty output.
|
||||
*/
|
||||
|
||||
import type { SymbolDefinition } from 'gitnexus-shared';
|
||||
import type { ArityVerdict, Callsite, ConstraintContext, SymbolDefinition } from 'gitnexus-shared';
|
||||
|
||||
/**
|
||||
* Per-slot conversion-rank function. Returns a numeric cost for
|
||||
* converting `argType` to `paramType`:
|
||||
* - 0 = exact match (no conversion)
|
||||
* - 1 = promotion (e.g. char→int, bool→int in C++)
|
||||
* - 2 = standard conversion (e.g. int→double)
|
||||
* - Infinity = incompatible types
|
||||
*
|
||||
* Each language provides its own implementation. The function operates
|
||||
* on normalized type strings (output of the language's type normalizer).
|
||||
*/
|
||||
export type ConversionRankFn = (argType: string, paramType: string) => number;
|
||||
|
||||
/**
|
||||
* Optional hook bundle for narrowing extension points. Threaded in
|
||||
* from `pickOverload` / `pickImplicitThisOverload` so per-language
|
||||
* narrowing can layer in conversion-rank scoring (#1606) and
|
||||
* constraint filtering (#1579) without changing the call signature
|
||||
* at every site. Each hook is independently optional — leaving both
|
||||
* undefined preserves the legacy arity + exact-type behavior.
|
||||
*/
|
||||
export interface OverloadNarrowingHookCtx {
|
||||
/** Conversion-rank scoring fallback (step 4b). Engages when the
|
||||
* exact-type filter rejects every candidate. */
|
||||
readonly conversionRankFn?: ConversionRankFn;
|
||||
/** Constraint filter (step 4c). Drops candidates whose template
|
||||
* guards (SFINAE `enable_if_t`, C++20 `requires`, future Rust
|
||||
* trait bounds, etc.) provably fail at the call site. Three-valued
|
||||
* — `'unknown'` keeps the candidate (monotonicity). */
|
||||
readonly constraintCompatibility?: (
|
||||
callsite: Callsite,
|
||||
def: SymbolDefinition,
|
||||
ctx: ConstraintContext,
|
||||
) => ArityVerdict;
|
||||
}
|
||||
|
||||
export function narrowOverloadCandidates(
|
||||
overloads: readonly SymbolDefinition[],
|
||||
argCount: number | undefined,
|
||||
argTypes: readonly string[] | undefined,
|
||||
hookCtx?: OverloadNarrowingHookCtx,
|
||||
): readonly SymbolDefinition[] {
|
||||
if (overloads.length === 0) return [];
|
||||
|
||||
@@ -73,6 +121,7 @@ export function narrowOverloadCandidates(
|
||||
const candidates: readonly SymbolDefinition[] =
|
||||
arityMatches.length > 0 ? arityMatches : anyUnknownBounds ? overloads : [];
|
||||
|
||||
let result: readonly SymbolDefinition[] = candidates;
|
||||
if (argTypes !== undefined && argTypes.length > 0) {
|
||||
const typed = candidates.filter((d) => {
|
||||
const params = d.parameterTypes;
|
||||
@@ -83,10 +132,121 @@ export function narrowOverloadCandidates(
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (typed.length > 0) return typed;
|
||||
if (typed.length > 0) {
|
||||
result = typed;
|
||||
} else if (hookCtx?.conversionRankFn !== undefined) {
|
||||
// ── Conversion-rank scoring (step 4b) ──────────────────────────
|
||||
// The exact-type filter rejected every candidate. Rank via
|
||||
// pairwise dominance: F1 beats F2 only when F1 is not worse for
|
||||
// every arg and better for at least one. Non-dominated candidates
|
||||
// are returned; multiple survivors are genuinely ambiguous. When
|
||||
// ranking also yields empty, fall through to the arity-filtered
|
||||
// `candidates` set — matches pre-#1606 behavior.
|
||||
const ranked = rankByConversion(candidates, argTypes, hookCtx.conversionRankFn);
|
||||
if (ranked.length > 0) result = ranked;
|
||||
}
|
||||
}
|
||||
|
||||
return candidates;
|
||||
// Constraint filter (step 4c; Tier-A — SFINAE / `requires` clauses).
|
||||
// Runs after arity, exact-type, and conversion-rank filters so the
|
||||
// hook only sees candidates already viable on the other axes.
|
||||
// Three-valued: `'compatible'` and `'unknown'` keep the candidate
|
||||
// (monotonicity — adding a predicate must never cause a wrong edge);
|
||||
// only `'incompatible'` drops it. Candidates without
|
||||
// `templateConstraints` are always kept.
|
||||
//
|
||||
// No fallback to the unconstrained set when this filter empties the
|
||||
// candidate list: a fully-`'incompatible'` verdict is authoritative.
|
||||
// The downstream `OVERLOAD_AMBIGUOUS` sentinel still guards the empty
|
||||
// case, so a buggy hook that wrongly returns `'incompatible'` for
|
||||
// every candidate degrades to today's "suppress edge" behavior rather
|
||||
// than emitting a wrong edge.
|
||||
if (hookCtx?.constraintCompatibility !== undefined && argCount !== undefined) {
|
||||
const callsite: Callsite = { arity: argCount };
|
||||
const ctx: ConstraintContext = argTypes !== undefined ? { argumentTypes: argTypes } : {};
|
||||
result = result.filter((def) => {
|
||||
if (def.templateConstraints === undefined) return true;
|
||||
return hookCtx.constraintCompatibility!(callsite, def, ctx) !== 'incompatible';
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pairwise dominance comparison (ISO C++ [over.ics.rank]).
|
||||
*
|
||||
* F1 is a better match than F2 when F1's conversion rank is **not
|
||||
* worse** for every argument AND **strictly better** for at least one.
|
||||
* Candidates dominated by any other viable candidate are removed.
|
||||
* If more than one non-dominated candidate remains, they are genuinely
|
||||
* ambiguous — callers suppress the edge rather than picking arbitrarily.
|
||||
*
|
||||
* Candidates with at least one `Infinity`-ranked slot (incompatible
|
||||
* type) are excluded before pairwise comparison begins.
|
||||
*/
|
||||
function rankByConversion(
|
||||
candidates: readonly SymbolDefinition[],
|
||||
argTypes: readonly string[],
|
||||
rankFn: ConversionRankFn,
|
||||
): readonly SymbolDefinition[] {
|
||||
// Step 1: compute per-slot ranks and exclude non-viable candidates.
|
||||
const viable: Array<{ def: SymbolDefinition; ranks: number[] }> = [];
|
||||
for (const d of candidates) {
|
||||
const params = d.parameterTypes;
|
||||
if (params === undefined) continue;
|
||||
const ranks: number[] = [];
|
||||
let ok = true;
|
||||
for (let i = 0; i < argTypes.length && i < params.length; i++) {
|
||||
if (argTypes[i] === '') {
|
||||
ranks.push(0); // unknown arg → any-match (rank 0)
|
||||
continue;
|
||||
}
|
||||
const r = rankFn(argTypes[i], params[i]);
|
||||
if (!isFinite(r)) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
ranks.push(r);
|
||||
}
|
||||
if (!ok) continue;
|
||||
viable.push({ def: d, ranks });
|
||||
}
|
||||
if (viable.length <= 1) return viable.map((v) => v.def);
|
||||
|
||||
// Step 2: pairwise dominance — remove candidates dominated by any other.
|
||||
const dominated = new Set<number>();
|
||||
for (let i = 0; i < viable.length; i++) {
|
||||
if (dominated.has(i)) continue;
|
||||
for (let j = i + 1; j < viable.length; j++) {
|
||||
if (dominated.has(j)) continue;
|
||||
const cmp = pairwiseCompare(viable[i].ranks, viable[j].ranks);
|
||||
if (cmp < 0)
|
||||
dominated.add(j); // i dominates j
|
||||
else if (cmp > 0) dominated.add(i); // j dominates i
|
||||
}
|
||||
}
|
||||
return viable.filter((_, idx) => !dominated.has(idx)).map((v) => v.def);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two per-slot rank vectors.
|
||||
* Returns -1 if `a` dominates `b` (not worse everywhere, better somewhere),
|
||||
* +1 if `b` dominates `a`,
|
||||
* 0 if neither dominates (incomparable or equal).
|
||||
*/
|
||||
function pairwiseCompare(a: readonly number[], b: readonly number[]): -1 | 0 | 1 {
|
||||
let aBetter = false;
|
||||
let bBetter = false;
|
||||
const len = Math.min(a.length, b.length);
|
||||
for (let i = 0; i < len; i++) {
|
||||
if (a[i] < b[i]) aBetter = true;
|
||||
else if (b[i] < a[i]) bBetter = true;
|
||||
if (aBetter && bBetter) return 0; // incomparable — early exit
|
||||
}
|
||||
if (aBetter && !bBetter) return -1;
|
||||
if (bBetter && !aBetter) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -73,6 +73,8 @@ type ReceiverBoundProviderSubset = Pick<
|
||||
| 'hoistTypeBindingsToModule'
|
||||
| 'resolveQualifiedReceiverMember'
|
||||
| 'resolveThisViaEnclosingClass'
|
||||
| 'conversionRankFn'
|
||||
| 'constraintCompatibility'
|
||||
>;
|
||||
|
||||
function normalizeTemplateArgToken(value: string): string {
|
||||
@@ -343,6 +345,10 @@ export function emitReceiverBoundCalls(
|
||||
methodOverloads,
|
||||
site.arity,
|
||||
site.argumentTypes,
|
||||
{
|
||||
conversionRankFn: provider.conversionRankFn,
|
||||
constraintCompatibility: provider.constraintCompatibility,
|
||||
},
|
||||
);
|
||||
if (isOverloadAmbiguousAfterNormalization(narrowed, site.arity)) {
|
||||
ambiguous = true;
|
||||
@@ -356,6 +362,12 @@ export function emitReceiverBoundCalls(
|
||||
hiddenByName = true;
|
||||
break;
|
||||
}
|
||||
// Multiple tied survivors with distinct param types (e.g.
|
||||
// h(int,double) vs h(double,int) both scoring 2) → ambiguous.
|
||||
if (narrowed.length > 1) {
|
||||
ambiguous = true;
|
||||
break;
|
||||
}
|
||||
memberDef = narrowed[0] ?? methodOverloads[0];
|
||||
break;
|
||||
}
|
||||
@@ -445,6 +457,12 @@ export function emitReceiverBoundCalls(
|
||||
scopes,
|
||||
parsedFiles,
|
||||
);
|
||||
if (memberDef === 'ambiguous') {
|
||||
// Same-name ambiguity across inline-namespace children (#1564):
|
||||
// suppress edge emission, mark site handled.
|
||||
handledSites.add(siteKey);
|
||||
continue;
|
||||
}
|
||||
if (memberDef !== undefined) {
|
||||
const ok = tryEmitEdge(
|
||||
graph,
|
||||
@@ -634,7 +652,7 @@ export function emitReceiverBoundCalls(
|
||||
let memberDef: SymbolDefinition | undefined;
|
||||
let ambiguous = false;
|
||||
for (const ownerId of chain) {
|
||||
const picked = pickOverload(ownerId, memberName, site, model);
|
||||
const picked = pickOverload(ownerId, memberName, site, model, provider);
|
||||
if (picked === OVERLOAD_AMBIGUOUS) {
|
||||
ambiguous = true;
|
||||
break;
|
||||
@@ -702,6 +720,7 @@ function pickOverload(
|
||||
memberName: string,
|
||||
site: ParsedFile['referenceSites'][number],
|
||||
model: SemanticModel,
|
||||
provider: ReceiverBoundProviderSubset,
|
||||
): SymbolDefinition | typeof OVERLOAD_AMBIGUOUS | undefined {
|
||||
const overloads = model.methods.lookupAllByOwner(ownerId, memberName);
|
||||
if (overloads.length === 0) {
|
||||
@@ -712,7 +731,10 @@ function pickOverload(
|
||||
}
|
||||
if (overloads.length === 1) return overloads[0];
|
||||
|
||||
const candidates = narrowOverloadCandidates(overloads, site.arity, site.argumentTypes);
|
||||
const candidates = narrowOverloadCandidates(overloads, site.arity, site.argumentTypes, {
|
||||
conversionRankFn: provider.conversionRankFn,
|
||||
constraintCompatibility: provider.constraintCompatibility,
|
||||
});
|
||||
// When narrowing leaves >1 candidate that share identical normalized
|
||||
// parameter-types (e.g., C++ `f(int)` vs `f(long)` both collapsed to
|
||||
// `['int']` by `normalizeCppParamType`), suppress the edge entirely.
|
||||
@@ -720,6 +742,11 @@ function pickOverload(
|
||||
// would arbitrarily pick a candidate and lie about the call's target.
|
||||
// PR #1520 review follow-up plan U2 / Claude review Finding 5.
|
||||
if (isOverloadAmbiguousAfterNormalization(candidates, site.arity)) return OVERLOAD_AMBIGUOUS;
|
||||
// When conversion-rank scoring leaves >1 tied candidate with distinct
|
||||
// parameter types (e.g. h(int,double) vs h(double,int) both scoring 2),
|
||||
// suppress rather than picking arbitrarily — C++ would call this
|
||||
// ambiguous. Mirrors ADL merged-candidate suppression behavior.
|
||||
if (candidates.length > 1) return OVERLOAD_AMBIGUOUS;
|
||||
return candidates[0] ?? overloads[0];
|
||||
}
|
||||
|
||||
|
||||
@@ -382,6 +382,8 @@ export function runScopeResolution(
|
||||
isFileLocalDef: provider.isFileLocalDef,
|
||||
isCallableVisibleFromCaller: provider.isCallableVisibleFromCaller,
|
||||
resolveAdlCandidates: provider.resolveAdlCandidates,
|
||||
conversionRankFn: provider.conversionRankFn,
|
||||
constraintCompatibility: provider.constraintCompatibility,
|
||||
},
|
||||
);
|
||||
const { emitted, skipped } = emitReferencesViaLookup(
|
||||
|
||||
@@ -226,33 +226,132 @@ export function findCallableBindingInScope(
|
||||
callableName: string,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
): SymbolDefinition | undefined {
|
||||
return findAllCallableBindingsInScope(startScope, callableName, scopes)[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up all callable bindings (Function/Method/Constructor) by name
|
||||
* from the nearest scope in the chain that binds `callableName`.
|
||||
*
|
||||
* Preserves the original scope-walk boundary used by
|
||||
* `findCallableBindingInScope`: once any callable binding is found in a
|
||||
* scope, outer scopes are not consulted.
|
||||
*/
|
||||
export function findAllCallableBindingsInScope(
|
||||
startScope: ScopeId,
|
||||
callableName: string,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
): readonly SymbolDefinition[] {
|
||||
let currentId: ScopeId | null = startScope;
|
||||
const visited = new Set<ScopeId>();
|
||||
while (currentId !== null) {
|
||||
if (visited.has(currentId)) return undefined;
|
||||
if (visited.has(currentId)) return [];
|
||||
visited.add(currentId);
|
||||
const scope = scopes.scopeTree.getScope(currentId);
|
||||
if (scope === undefined) return undefined;
|
||||
if (scope === undefined) return [];
|
||||
|
||||
const out: SymbolDefinition[] = [];
|
||||
const seen = new Set<string>();
|
||||
const pushCallable = (def: SymbolDefinition): void => {
|
||||
if (def.type !== 'Function' && def.type !== 'Method' && def.type !== 'Constructor') return;
|
||||
if (seen.has(def.nodeId)) return;
|
||||
seen.add(def.nodeId);
|
||||
out.push(def);
|
||||
};
|
||||
|
||||
const localBindings = scope.bindings.get(callableName);
|
||||
if (localBindings !== undefined) {
|
||||
for (const b of localBindings) {
|
||||
if (b.def.type === 'Function' || b.def.type === 'Method' || b.def.type === 'Constructor') {
|
||||
return b.def;
|
||||
}
|
||||
pushCallable(b.def);
|
||||
}
|
||||
}
|
||||
|
||||
const importedBindings = lookupBindingsAt(currentId, callableName, scopes);
|
||||
for (const b of importedBindings) {
|
||||
if (b.def.type === 'Function' || b.def.type === 'Method' || b.def.type === 'Constructor') {
|
||||
return b.def;
|
||||
pushCallable(b.def);
|
||||
}
|
||||
|
||||
if (out.length > 0) return out;
|
||||
currentId = scope.parent;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* ISO C++ `[basic.lookup.unqual]` §7: ADL is suppressed when ordinary
|
||||
* unqualified lookup finds:
|
||||
* - a name that is NOT a function or function template, OR
|
||||
* - a block-scope function declaration that is NOT a using-declaration.
|
||||
*
|
||||
* Combined walker that stops at the **nearest scope** where `name` has any
|
||||
* binding (callable or non-callable) and returns:
|
||||
* - `callables`: Function/Method/Constructor defs found at that scope
|
||||
* - `nonCallableFound`: a non-function binding was present (variable, class, etc.)
|
||||
* - `blockScopeDeclFound`: a callable was found at a Function or Block scope
|
||||
* (block-scope function declaration that blocks ADL)
|
||||
*
|
||||
* One pass, one stop — no divergence between callable collection and blocker
|
||||
* detection.
|
||||
*/
|
||||
export function findCallableBindingsAndAdlBlocker(
|
||||
startScope: ScopeId,
|
||||
name: string,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
): {
|
||||
callables: readonly SymbolDefinition[];
|
||||
nonCallableFound: boolean;
|
||||
blockScopeDeclFound: boolean;
|
||||
} {
|
||||
let currentId: ScopeId | null = startScope;
|
||||
const visited = new Set<ScopeId>();
|
||||
while (currentId !== null) {
|
||||
if (visited.has(currentId))
|
||||
return { callables: [], nonCallableFound: false, blockScopeDeclFound: false };
|
||||
visited.add(currentId);
|
||||
const scope = scopes.scopeTree.getScope(currentId);
|
||||
if (scope === undefined)
|
||||
return { callables: [], nonCallableFound: false, blockScopeDeclFound: false };
|
||||
|
||||
const callables: SymbolDefinition[] = [];
|
||||
const seen = new Set<string>();
|
||||
let nonCallableFound = false;
|
||||
let anyBinding = false;
|
||||
|
||||
const process = (def: SymbolDefinition): void => {
|
||||
anyBinding = true;
|
||||
if (def.type === 'Function' || def.type === 'Method' || def.type === 'Constructor') {
|
||||
if (!seen.has(def.nodeId)) {
|
||||
seen.add(def.nodeId);
|
||||
callables.push(def);
|
||||
}
|
||||
} else {
|
||||
nonCallableFound = true;
|
||||
}
|
||||
};
|
||||
|
||||
const localBindings = scope.bindings.get(name);
|
||||
if (localBindings !== undefined) {
|
||||
for (const b of localBindings) {
|
||||
process(b.def);
|
||||
}
|
||||
}
|
||||
|
||||
const importedBindings = lookupBindingsAt(currentId, name, scopes);
|
||||
for (const b of importedBindings) {
|
||||
process(b.def);
|
||||
}
|
||||
|
||||
if (anyBinding) {
|
||||
// ISO C++: a block-scope function declaration (Function or Block scope)
|
||||
// that is NOT a using-declaration blocks ADL. If we found callables at
|
||||
// a function/block scope, ADL must be suppressed.
|
||||
const blockScopeDeclFound =
|
||||
callables.length > 0 && (scope.kind === 'Function' || scope.kind === 'Block');
|
||||
return { callables, nonCallableFound, blockScopeDeclFound };
|
||||
}
|
||||
currentId = scope.parent;
|
||||
}
|
||||
return undefined;
|
||||
return { callables: [], nonCallableFound: false, blockScopeDeclFound: false };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -55,3 +55,34 @@ export function templateArgumentsIdTag(templateArguments?: readonly string[]): s
|
||||
if (templateArguments === undefined || templateArguments.length === 0) return '';
|
||||
return `~${templateArguments.join(',')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable short hash for the opaque `SymbolDefinition.templateConstraints`
|
||||
* payload (issue #1579). Two function-template overloads with identical
|
||||
* `parameterTypes` but mutually-exclusive SFINAE constraints
|
||||
* (`enable_if_t<is_integral_v<T>>` vs `enable_if_t<is_floating_point_v<T>>`)
|
||||
* must produce distinct graph node IDs so the constraint-filter step
|
||||
* has two candidates to narrow between. Without this they collapse to
|
||||
* a single Function node and the SFINAE golden case can only emit one
|
||||
* edge regardless of resolver fixes.
|
||||
*
|
||||
* FNV-1a 32-bit, base36 encoded. Deterministic; non-cryptographic — the
|
||||
* tag's job is collision-avoidance among same-name overloads in one
|
||||
* file, not security.
|
||||
*/
|
||||
export function constraintsHash(jsonText: string): string {
|
||||
let h = 0x811c9dc5;
|
||||
for (let i = 0; i < jsonText.length; i++) {
|
||||
h ^= jsonText.charCodeAt(i);
|
||||
h = Math.imul(h, 0x01000193);
|
||||
}
|
||||
return (h >>> 0).toString(36);
|
||||
}
|
||||
|
||||
/** Build the `~c:<hash>` ID suffix from an opaque constraint payload.
|
||||
* Returns empty string when the payload is absent so callers can
|
||||
* string-concatenate unconditionally. */
|
||||
export function templateConstraintsIdTag(payload: unknown): string {
|
||||
if (payload === undefined || payload === null) return '';
|
||||
return `~c:${constraintsHash(JSON.stringify(payload))}`;
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ import {
|
||||
isVueSetupTopLevel,
|
||||
} from '../vue-sfc-extractor.js';
|
||||
import type { NamedBinding } from '../named-bindings/types.js';
|
||||
import type { NodeLabel } from 'gitnexus-shared';
|
||||
import type { NodeLabel, ParameterTypeClass } from 'gitnexus-shared';
|
||||
import type { FieldInfo, FieldExtractorContext } from '../field-types.js';
|
||||
import type { MethodInfo, MethodExtractorContext } from '../method-types.js';
|
||||
import type { VariableExtractorContext } from '../variable-types.js';
|
||||
@@ -128,6 +128,7 @@ interface ParsedSymbol {
|
||||
parameterCount?: number;
|
||||
requiredParameterCount?: number;
|
||||
parameterTypes?: string[];
|
||||
parameterTypeClasses?: ParameterTypeClass[];
|
||||
returnType?: string;
|
||||
declaredType?: string;
|
||||
templateArguments?: string[];
|
||||
@@ -2306,6 +2307,7 @@ const processFileGroup = (
|
||||
parameterCount: methodProps.parameterCount as number | undefined,
|
||||
requiredParameterCount: methodProps.requiredParameterCount as number | undefined,
|
||||
parameterTypes: methodProps.parameterTypes as string[] | undefined,
|
||||
parameterTypeClasses: methodProps.parameterTypeClasses as ParameterTypeClass[] | undefined,
|
||||
returnType: methodProps.returnType as string | undefined,
|
||||
...(declaredType !== undefined ? { declaredType } : {}),
|
||||
...(classTemplateArguments !== undefined && classTemplateArguments.length > 0
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { getLanguageFromFilename, SupportedLanguages } from 'gitnexus-shared';
|
||||
|
||||
const WORKER_UNSAFE_LANGUAGES = new Set<SupportedLanguages>([
|
||||
SupportedLanguages.C,
|
||||
SupportedLanguages.CPlusPlus,
|
||||
]);
|
||||
|
||||
export const hasWorkerUnsafeLanguages = (files: ReadonlyArray<{ path: string }>): boolean =>
|
||||
files.some((f) => {
|
||||
const language = getLanguageFromFilename(f.path);
|
||||
return language !== null && WORKER_UNSAFE_LANGUAGES.has(language);
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import fs from 'fs/promises';
|
||||
import { createReadStream, createWriteStream } from 'fs';
|
||||
import { createReadStream, createWriteStream, constants as fsConstants } from 'fs';
|
||||
import { createInterface } from 'readline';
|
||||
import { once } from 'events';
|
||||
import { finished } from 'stream/promises';
|
||||
@@ -21,7 +21,9 @@ import {
|
||||
closeLbugConnection,
|
||||
isDbBusyError,
|
||||
isOpenRetryExhausted,
|
||||
isWalCorruptionError,
|
||||
openLbugConnection,
|
||||
WAL_RECOVERY_SUGGESTION,
|
||||
waitForWindowsHandleRelease,
|
||||
type LbugConnectionHandle,
|
||||
} from './lbug-config.js';
|
||||
@@ -152,6 +154,7 @@ export const splitRelCsvByLabelPair = async (
|
||||
let db: lbug.Database | null = null;
|
||||
let conn: lbug.Connection | null = null;
|
||||
let currentDbPath: string | null = null;
|
||||
let currentDbReadOnly = false;
|
||||
let ftsLoaded = false;
|
||||
let vectorExtensionLoaded = false;
|
||||
|
||||
@@ -201,6 +204,163 @@ export const isReadOnlyDbError = (err: unknown): boolean => {
|
||||
return /read-only database/i.test(msg);
|
||||
};
|
||||
|
||||
const isMissingFileError = (err: unknown): boolean => {
|
||||
const errno = err as NodeJS.ErrnoException;
|
||||
return errno?.code === 'ENOENT';
|
||||
};
|
||||
|
||||
const extractErrnoCode = (err: unknown): string | undefined => {
|
||||
const errno = err as NodeJS.ErrnoException;
|
||||
return errno?.code;
|
||||
};
|
||||
|
||||
const MAX_LOGGED_ERROR_MESSAGE_LENGTH = 160;
|
||||
|
||||
const summarizeError = (err: unknown): string =>
|
||||
(err instanceof Error ? err.message : String(err)).slice(0, MAX_LOGGED_ERROR_MESSAGE_LENGTH);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cross-process init lock
|
||||
//
|
||||
// Prevents a TOCTOU race in orphan sidecar cleanup: between checking that
|
||||
// the main DB file is missing and unlinking sidecars, another process could
|
||||
// create a fresh DB. The lock file (`${dbPath}.init.lock`) is created with
|
||||
// O_CREAT | O_EXCL (atomic create-or-fail) and contains the owning PID +
|
||||
// timestamp so stale locks from crashed processes can be reclaimed.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Maximum age (ms) before an init lock is considered stale. */
|
||||
const INIT_LOCK_STALE_MS = 30_000;
|
||||
/** Maximum attempts to acquire the init lock before giving up. */
|
||||
const INIT_LOCK_MAX_ATTEMPTS = 6;
|
||||
/** Delay between lock-acquisition retries (ms). */
|
||||
const INIT_LOCK_RETRY_DELAY_MS = 500;
|
||||
|
||||
const initLockPath = (dbPath: string): string => `${dbPath}.init.lock`;
|
||||
|
||||
/**
|
||||
* Returns true when the process identified by `pid` is still running.
|
||||
* Uses `process.kill(pid, 0)` which sends signal 0 (a no-op probe) —
|
||||
* it throws ESRCH when the process does not exist.
|
||||
*/
|
||||
const isProcessAlive = (pid: number): boolean => {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Try to break a stale lock whose owning process has exited.
|
||||
* Returns `true` if the stale lock was removed (caller should retry acquire).
|
||||
* Returns `false` if the lock is still valid (another live process owns it).
|
||||
*/
|
||||
const tryBreakStaleLock = async (lockPath: string): Promise<boolean> => {
|
||||
try {
|
||||
const content = await fs.readFile(lockPath, 'utf-8');
|
||||
const parsed = JSON.parse(content) as { pid?: number; ts?: number };
|
||||
|
||||
// If the owning process is still alive AND the lock is not stale, don't break.
|
||||
if (typeof parsed.pid === 'number' && isProcessAlive(parsed.pid)) {
|
||||
// Even a live process's lock can be stale if it's been held too long
|
||||
// (e.g. the process is hung). Check the timestamp.
|
||||
if (typeof parsed.ts === 'number' && Date.now() - parsed.ts < INIT_LOCK_STALE_MS) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// PID is gone or lock exceeded INIT_LOCK_STALE_MS — reclaim it.
|
||||
await fs.unlink(lockPath);
|
||||
logger.warn(
|
||||
`GitNexus: removed stale init lock (pid=${parsed.pid ?? '?'}, age=${typeof parsed.ts === 'number' ? `${Date.now() - parsed.ts}ms` : '?'})`,
|
||||
);
|
||||
return true;
|
||||
} catch (err) {
|
||||
// Lock file disappeared between our read and unlink, or is unreadable.
|
||||
// Either way, let the caller retry the acquire.
|
||||
if (isMissingFileError(err)) return true;
|
||||
// Permission error or corrupt content — log and let caller retry.
|
||||
const code = extractErrnoCode(err);
|
||||
logger.warn(
|
||||
`GitNexus: unable to inspect init lock (${code ?? 'UNKNOWN'}): ${summarizeError(err)}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Acquire a cross-process init lock for `dbPath`.
|
||||
* Uses `O_CREAT | O_EXCL` for atomic create-or-fail semantics.
|
||||
*
|
||||
* Returns a release function that removes the lock file. The release
|
||||
* function is idempotent and safe to call even if the lock was already
|
||||
* cleaned up externally.
|
||||
*
|
||||
* Throws if the lock cannot be acquired after `INIT_LOCK_MAX_ATTEMPTS`.
|
||||
*/
|
||||
export const acquireInitLock = async (dbPath: string): Promise<() => Promise<void>> => {
|
||||
const lockPath = initLockPath(dbPath);
|
||||
const payload = JSON.stringify({ pid: process.pid, ts: Date.now() });
|
||||
|
||||
// Ensure the parent directory exists before creating the lock file.
|
||||
// On a fresh repo the `.gitnexus/` directory may not exist yet, and
|
||||
// fs.open with O_CREAT | O_EXCL would fail with ENOENT.
|
||||
await fs.mkdir(path.dirname(lockPath), { recursive: true });
|
||||
|
||||
for (let attempt = 1; attempt <= INIT_LOCK_MAX_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
const handle = await fs.open(
|
||||
lockPath,
|
||||
fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY,
|
||||
);
|
||||
await handle.writeFile(payload);
|
||||
await handle.close();
|
||||
|
||||
// Return the idempotent release function
|
||||
return async () => {
|
||||
try {
|
||||
await fs.unlink(lockPath);
|
||||
} catch (err) {
|
||||
if (!isMissingFileError(err)) {
|
||||
const code = extractErrnoCode(err);
|
||||
logger.warn(
|
||||
`GitNexus: failed to release init lock (${code ?? 'UNKNOWN'}): ${summarizeError(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException)?.code !== 'EEXIST') {
|
||||
throw err; // Unexpected error — propagate immediately
|
||||
}
|
||||
|
||||
// Lock file exists — check if it's stale
|
||||
const broken = await tryBreakStaleLock(lockPath);
|
||||
if (broken && attempt < INIT_LOCK_MAX_ATTEMPTS) {
|
||||
continue; // Stale lock removed — retry immediately
|
||||
}
|
||||
|
||||
if (attempt === INIT_LOCK_MAX_ATTEMPTS) {
|
||||
throw new Error(
|
||||
`GitNexus: unable to acquire init lock after ${INIT_LOCK_MAX_ATTEMPTS} attempts — ` +
|
||||
`another gitnexus process may be initializing the same database (${lockPath})`,
|
||||
);
|
||||
}
|
||||
|
||||
// Live process holds the lock — wait and retry
|
||||
await new Promise((resolve) => setTimeout(resolve, INIT_LOCK_RETRY_DELAY_MS));
|
||||
}
|
||||
}
|
||||
|
||||
// Unreachable — loop always throws or returns
|
||||
throw new Error('GitNexus: init lock acquisition failed unexpectedly');
|
||||
};
|
||||
|
||||
/** Exported for testing — returns the lock file path for a given dbPath. */
|
||||
export const _initLockPathForTest = initLockPath;
|
||||
|
||||
const runWithSessionLock = async <T>(operation: () => Promise<T>): Promise<T> => {
|
||||
const previous = sessionLock;
|
||||
let release: (() => void) | null = null;
|
||||
@@ -289,12 +449,17 @@ export const initLbug = async (dbPath: string) => {
|
||||
* database is busy (e.g. `gitnexus analyze` holds the write lock).
|
||||
* Each retry waits DB_LOCK_RETRY_DELAY_MS * attempt milliseconds.
|
||||
*/
|
||||
export const withLbugDb = async <T>(dbPath: string, operation: () => Promise<T>): Promise<T> => {
|
||||
export const withLbugDb = async <T>(
|
||||
dbPath: string,
|
||||
operation: () => Promise<T>,
|
||||
options: { readOnly?: boolean } = {},
|
||||
): Promise<T> => {
|
||||
let lastError: unknown;
|
||||
const readOnly = options.readOnly === true;
|
||||
for (let attempt = 1; attempt <= DB_LOCK_RETRY_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
return await runWithSessionLock(async () => {
|
||||
await ensureLbugInitialized(dbPath);
|
||||
await ensureLbugInitialized(dbPath, readOnly);
|
||||
return operation();
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -324,15 +489,15 @@ export const withLbugDb = async <T>(dbPath: string, operation: () => Promise<T>)
|
||||
throw lastError;
|
||||
};
|
||||
|
||||
const ensureLbugInitialized = async (dbPath: string) => {
|
||||
if (conn && currentDbPath === dbPath) {
|
||||
const ensureLbugInitialized = async (dbPath: string, readOnly: boolean = false) => {
|
||||
if (conn && currentDbPath === dbPath && currentDbReadOnly === readOnly) {
|
||||
return { db, conn };
|
||||
}
|
||||
await doInitLbug(dbPath);
|
||||
await doInitLbug(dbPath, readOnly);
|
||||
return { db, conn };
|
||||
};
|
||||
|
||||
const doInitLbug = async (dbPath: string) => {
|
||||
const doInitLbug = async (dbPath: string, readOnly: boolean = false) => {
|
||||
// Different database requested — close the old one first
|
||||
if (conn || db) {
|
||||
await safeClose();
|
||||
@@ -364,17 +529,67 @@ const doInitLbug = async (dbPath: string) => {
|
||||
await fs.rm(dbPath, { recursive: true, force: true });
|
||||
}
|
||||
// If it's a file, assume it's an existing LadybugDB database - LadybugDB will open it
|
||||
} catch {
|
||||
} catch (err) {
|
||||
if (!isMissingFileError(err)) {
|
||||
throw err;
|
||||
}
|
||||
// Path doesn't exist, which is what LadybugDB wants for a new database
|
||||
}
|
||||
|
||||
// Ensure parent directory exists
|
||||
const parentDir = path.dirname(dbPath);
|
||||
await fs.mkdir(parentDir, { recursive: true });
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cross-process critical section: acquire init lock, clean orphan sidecars,
|
||||
// and open the database. The lock prevents a TOCTOU race where another
|
||||
// process could create a fresh DB between our access() check and the
|
||||
// unlink() of stale sidecars.
|
||||
// ---------------------------------------------------------------------------
|
||||
const releaseInitLock = await acquireInitLock(dbPath);
|
||||
try {
|
||||
// Crash-recovery cleanup: if the main DB file is missing, stale sidecars
|
||||
// from an interrupted run can block fresh opens indefinitely.
|
||||
try {
|
||||
await fs.access(dbPath);
|
||||
} catch (err) {
|
||||
if (isMissingFileError(err)) {
|
||||
// `.shadow` is documented by LadybugDB checkpointing and `.wal.checkpoint`
|
||||
// was observed in the #1618 crash loop that motivated this recovery path.
|
||||
const orphanSidecars = [`${dbPath}.shadow`, `${dbPath}.wal.checkpoint`];
|
||||
for (const sidecar of orphanSidecars) {
|
||||
try {
|
||||
await fs.unlink(sidecar);
|
||||
logger.warn(
|
||||
`GitNexus: removed orphan sidecar ${path.basename(sidecar)} (no main DB file present)`,
|
||||
);
|
||||
} catch (err) {
|
||||
if (isMissingFileError(err)) {
|
||||
continue;
|
||||
}
|
||||
const code = extractErrnoCode(err);
|
||||
logger.warn(
|
||||
`GitNexus: failed to remove orphan sidecar ${path.basename(sidecar)} (${code ?? 'UNKNOWN'}) while main DB file is missing; LadybugDB open may still fail: ${summarizeError(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const code = extractErrnoCode(err);
|
||||
logger.warn(
|
||||
`GitNexus: unable to verify main DB file before orphan sidecar cleanup (${code ?? 'UNKNOWN'}); skipping cleanup: ${summarizeError(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const opened = await openLbugConnection(lbug, dbPath);
|
||||
db = opened.db;
|
||||
conn = opened.conn;
|
||||
// Ensure parent directory exists
|
||||
const parentDir = path.dirname(dbPath);
|
||||
await fs.mkdir(parentDir, { recursive: true });
|
||||
|
||||
const opened = readOnly
|
||||
? await openLbugConnection(lbug, dbPath, { readOnly: true })
|
||||
: await openLbugConnection(lbug, dbPath);
|
||||
db = opened.db;
|
||||
conn = opened.conn;
|
||||
currentDbReadOnly = readOnly;
|
||||
} finally {
|
||||
await releaseInitLock();
|
||||
}
|
||||
|
||||
for (const schemaQuery of SCHEMA_QUERIES) {
|
||||
try {
|
||||
@@ -390,7 +605,25 @@ const doInitLbug = async (dbPath: string) => {
|
||||
// anyway and any genuine cross-process lock contention surfaces
|
||||
// on the next operation via withLbugDb's retry. Logging it here
|
||||
// would just be noise in CI.
|
||||
if (!msg.includes('already exists') && !isDbBusyError(err)) {
|
||||
//
|
||||
// WAL corruption: the first DDL write after DB open triggers WAL
|
||||
// replay — if the WAL file was left in a corrupt state by an
|
||||
// interrupted previous run, the native engine throws here. Rather
|
||||
// than logging a WARN and continuing in a broken state, close the
|
||||
// DB cleanly and surface an actionable error so the caller (serve,
|
||||
// MCP, analyze) can exit with a clear recovery message.
|
||||
if (isWalCorruptionError(err)) {
|
||||
await safeClose();
|
||||
currentDbPath = null;
|
||||
ftsLoaded = false;
|
||||
vectorExtensionLoaded = false;
|
||||
ensuredFTSIndexes.clear();
|
||||
throw new Error(
|
||||
`LadybugDB WAL corruption detected at ${dbPath}. ${WAL_RECOVERY_SUGGESTION}\n` +
|
||||
` Original error: ${msg.slice(0, 200)}`,
|
||||
);
|
||||
}
|
||||
if (!msg.includes('already exists') && !isDbBusyError(err) && !isReadOnlyDbError(err)) {
|
||||
logger.warn(`⚠️ Schema creation warning: ${msg.slice(0, 120)}`);
|
||||
}
|
||||
}
|
||||
@@ -834,12 +1067,7 @@ export const batchInsertNodesToLbug = async (
|
||||
};
|
||||
|
||||
export const executeQuery = async (cypher: string): Promise<any[]> => {
|
||||
if (!conn) {
|
||||
throw new Error('LadybugDB not initialized. Call initLbug first.');
|
||||
}
|
||||
|
||||
const queryResult = await conn.query(cypher);
|
||||
return await readQueryRows(queryResult);
|
||||
return await executePrepared(cypher, {});
|
||||
};
|
||||
|
||||
export const streamQuery = async (
|
||||
@@ -1502,19 +1730,15 @@ export const queryFTS = async (
|
||||
throw new Error('LadybugDB not initialized. Call initLbug first.');
|
||||
}
|
||||
|
||||
// Escape backslashes and single quotes to prevent Cypher injection
|
||||
const escapedQuery = query.replace(/\\/g, '\\\\').replace(/'/g, "''");
|
||||
|
||||
const cypher = `
|
||||
CALL QUERY_FTS_INDEX('${tableName}', '${indexName}', '${escapedQuery}', conjunctive := ${conjunctive})
|
||||
CALL QUERY_FTS_INDEX('${tableName}', '${indexName}', $query, conjunctive := ${conjunctive})
|
||||
RETURN node, score
|
||||
ORDER BY score DESC
|
||||
LIMIT ${limit}
|
||||
`;
|
||||
|
||||
try {
|
||||
const queryResult = await conn.query(cypher);
|
||||
const rows = await readQueryRows(queryResult);
|
||||
const rows = await executePrepared(cypher, { query });
|
||||
|
||||
return rows.map((row: any) => {
|
||||
const node = row.node || row[0] || {};
|
||||
|
||||
@@ -49,7 +49,7 @@ export const LBUG_MAX_DB_SIZE: number = (() => {
|
||||
const WAL_CORRUPTION_RE = /corrupt(ed)?\s+wal|invalid\s+wal\s+record|wal.*corrupt|checksum.*wal/i;
|
||||
|
||||
export const WAL_RECOVERY_SUGGESTION =
|
||||
'WAL corruption detected. Run `gitnexus analyze` to rebuild the index.';
|
||||
'WAL corruption detected. Run `gitnexus analyze --force` to rebuild the index.';
|
||||
|
||||
export function isWalCorruptionError(err: unknown): boolean {
|
||||
if (!err) return false;
|
||||
|
||||
@@ -17,8 +17,12 @@
|
||||
|
||||
import fs from 'fs/promises';
|
||||
import lbug from '@ladybugdb/core';
|
||||
import { loadFTSExtension } from './lbug-adapter.js';
|
||||
import { createLbugDatabase, isWalCorruptionError } from './lbug-config.js';
|
||||
import { isReadOnlyDbError, loadFTSExtension } from './lbug-adapter.js';
|
||||
import {
|
||||
createLbugDatabase,
|
||||
isWalCorruptionError,
|
||||
WAL_RECOVERY_SUGGESTION,
|
||||
} from './lbug-config.js';
|
||||
|
||||
/** Per-repo pool: one Database, many Connections */
|
||||
interface PoolEntry {
|
||||
@@ -375,8 +379,7 @@ async function doInitLbug(repoId: string, dbPath: string): Promise<void> {
|
||||
break;
|
||||
} catch (retryErr) {
|
||||
throw new Error(
|
||||
`LadybugDB WAL corruption detected for ${repoId}. ` +
|
||||
`Run \`gitnexus analyze\` to rebuild the index. ` +
|
||||
`LadybugDB WAL corruption detected for ${repoId}. ${WAL_RECOVERY_SUGGESTION} ` +
|
||||
`(${retryErr instanceof Error ? retryErr.message : String(retryErr)})`,
|
||||
);
|
||||
}
|
||||
@@ -595,30 +598,7 @@ function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise
|
||||
}
|
||||
|
||||
export const executeQuery = async (repoId: string, cypher: string): Promise<any[]> => {
|
||||
const entry = pool.get(repoId);
|
||||
if (!entry) {
|
||||
throw new Error(`LadybugDB not initialized for repo "${repoId}". Call initLbug first.`);
|
||||
}
|
||||
|
||||
if (isWriteQuery(cypher)) {
|
||||
throw new Error('Write operations are not allowed. The pool adapter is read-only.');
|
||||
}
|
||||
|
||||
entry.lastUsed = Date.now();
|
||||
|
||||
const conn = await checkout(entry);
|
||||
silenceStdout();
|
||||
activeQueryCount++;
|
||||
try {
|
||||
const queryResult = await withTimeout(conn.query(cypher), QUERY_TIMEOUT_MS, 'Query');
|
||||
const result = Array.isArray(queryResult) ? queryResult[0] : queryResult;
|
||||
const rows = await result.getAll();
|
||||
return rows;
|
||||
} finally {
|
||||
activeQueryCount--;
|
||||
restoreStdout();
|
||||
checkin(entry, conn);
|
||||
}
|
||||
return await executeParameterized(repoId, cypher, {});
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -650,6 +630,11 @@ export const executeParameterized = async (
|
||||
const result = Array.isArray(queryResult) ? queryResult[0] : queryResult;
|
||||
const rows = await result.getAll();
|
||||
return rows;
|
||||
} catch (err) {
|
||||
if (isReadOnlyDbError(err)) {
|
||||
throw new Error('Write operations are not allowed. The pool adapter is read-only.');
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
activeQueryCount--;
|
||||
restoreStdout();
|
||||
@@ -682,15 +667,3 @@ export const closeLbug = async (repoId?: string): Promise<void> => {
|
||||
* Check if a specific repo's pool is active
|
||||
*/
|
||||
export const isLbugReady = (repoId: string): boolean => pool.has(repoId);
|
||||
|
||||
/** Regex to detect write operations in user-supplied Cypher queries.
|
||||
* Note: CALL is NOT blocked — it's used for read-only FTS (CALL QUERY_FTS_INDEX)
|
||||
* and vector search (CALL QUERY_VECTOR_INDEX). The database is opened in
|
||||
* read-only mode as defense-in-depth against write procedures. */
|
||||
export const CYPHER_WRITE_RE =
|
||||
/(?<!:)\b(CREATE|DELETE|SET|MERGE|REMOVE|DROP|ALTER|COPY|DETACH|FOREACH|INSTALL|LOAD)\b/i;
|
||||
|
||||
/** Check if a Cypher query contains write operations */
|
||||
export function isWriteQuery(query: string): boolean {
|
||||
return CYPHER_WRITE_RE.test(query);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Return true only for plain-object payloads that can be safely used as
|
||||
* named parameter maps in prepared Cypher execution.
|
||||
*
|
||||
* Validation criteria:
|
||||
* - must be a JavaScript object (`typeof value === 'object'`)
|
||||
* - must not be `null`
|
||||
* - must not be an array
|
||||
* - must have a plain-object prototype
|
||||
* - values must be scalar bindable values (string | number | boolean | null)
|
||||
*
|
||||
* Rationale: prepared-statement params are key/value maps; rejecting null/array
|
||||
* and non-plain objects keeps binding behavior predictable and avoids passing
|
||||
* complex host objects to Ladybug parameter binding.
|
||||
*/
|
||||
const isBindableScalar = (value: unknown): value is string | number | boolean | null =>
|
||||
value === null || ['string', 'number', 'boolean'].includes(typeof value);
|
||||
|
||||
export const isValidQueryParams = (value: unknown): value is Record<string, unknown> =>
|
||||
value !== null &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value) &&
|
||||
(Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null) &&
|
||||
Object.values(value).every(isBindableScalar);
|
||||
@@ -27,22 +27,20 @@ export interface FTSSearchResponse {
|
||||
* caller can distinguish "zero matches" from "index missing".
|
||||
*/
|
||||
async function queryFTSViaExecutor(
|
||||
executor: (cypher: string) => Promise<any[]>,
|
||||
executor: (cypher: string, params: Record<string, any>) => Promise<any[]>,
|
||||
tableName: string,
|
||||
indexName: string,
|
||||
query: string,
|
||||
limit: number,
|
||||
): Promise<Array<{ filePath: string; score: number; nodeId: string }> | null> {
|
||||
// Escape single quotes and backslashes to prevent Cypher injection
|
||||
const escapedQuery = query.replace(/\\/g, '\\\\').replace(/'/g, "''");
|
||||
const cypher = `
|
||||
CALL QUERY_FTS_INDEX('${tableName}', '${indexName}', '${escapedQuery}', conjunctive := false)
|
||||
CALL QUERY_FTS_INDEX('${tableName}', '${indexName}', $query, conjunctive := false)
|
||||
RETURN node, score
|
||||
ORDER BY score DESC
|
||||
LIMIT ${limit}
|
||||
`;
|
||||
try {
|
||||
const rows = await executor(cypher);
|
||||
const rows = await executor(cypher, { query });
|
||||
return rows.map((row: any) => {
|
||||
const node = row.node || row[0] || {};
|
||||
const score = row.score ?? row[1] ?? 0;
|
||||
@@ -81,8 +79,9 @@ export const searchFTSFromLbug = async (
|
||||
// IMPORTANT: FTS queries run sequentially to avoid connection contention.
|
||||
// The MCP pool supports multiple connections, but FTS is best run serially.
|
||||
const poolMod = await import('../lbug/pool-adapter.js');
|
||||
const { executeQuery } = poolMod;
|
||||
const executor = (cypher: string) => executeQuery(repoId, cypher);
|
||||
const { executeParameterized } = poolMod;
|
||||
const executor = (cypher: string, params: Record<string, any>) =>
|
||||
executeParameterized(repoId, cypher, params);
|
||||
|
||||
for (const { table, indexName } of FTS_INDEXES) {
|
||||
const result = await queryFTSViaExecutor(executor, table, indexName, query, limit);
|
||||
|
||||
@@ -66,12 +66,15 @@ export interface WikiOptions {
|
||||
concurrency?: number;
|
||||
/** If true, stop after building module tree for user review */
|
||||
reviewOnly?: boolean;
|
||||
/** Output language for generated documentation (e.g. 'english', 'chinese', 'spanish') */
|
||||
lang?: string;
|
||||
}
|
||||
|
||||
export interface WikiMeta {
|
||||
fromCommit: string;
|
||||
generatedAt: string;
|
||||
model: string;
|
||||
lang: string;
|
||||
moduleFiles: Record<string, string[]>;
|
||||
moduleTree: ModuleTreeNode[];
|
||||
}
|
||||
@@ -177,6 +180,28 @@ export class WikiGenerator {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the effective lang string: strip control characters, trim, cap at 50 chars,
|
||||
* then validate against a character allowlist. Returns '' if the value is absent or invalid.
|
||||
* Used for both prompt construction and meta storage/comparison so they are always in sync.
|
||||
*/
|
||||
private effectiveLang(): string {
|
||||
const lang = (this.options.lang ?? '')
|
||||
.replace(/[\x00-\x1F\x7F]/g, '')
|
||||
.trim()
|
||||
.slice(0, 50);
|
||||
return /^[a-zA-Z -]+$/.test(lang) ? lang : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Append an output-language instruction to a system prompt when --lang is set.
|
||||
*/
|
||||
private buildSystemPrompt(base: string): string {
|
||||
const lang = this.effectiveLang();
|
||||
if (!lang) return base;
|
||||
return `${base}\n\nIMPORTANT: Write ALL documentation content in ${lang}. This includes prose, code comments in examples, and diagram labels. Note: page titles (H1 headings) are generated separately and will remain in English.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route LLM call to the appropriate provider (OpenAI-compatible or Cursor CLI).
|
||||
*/
|
||||
@@ -207,6 +232,15 @@ export class WikiGenerator {
|
||||
|
||||
// Up-to-date check (skip if --force)
|
||||
if (!forceMode && existingMeta && existingMeta.fromCommit === currentCommit) {
|
||||
const currentLang = this.effectiveLang();
|
||||
const metaLang = existingMeta.lang ?? '';
|
||||
if (currentLang !== metaLang) {
|
||||
const prevDisplay = metaLang || 'english (default)';
|
||||
const nextDisplay = currentLang || 'english (default)';
|
||||
throw new Error(
|
||||
`Wiki was generated in ${prevDisplay}; use --force to regenerate in ${nextDisplay}.`,
|
||||
);
|
||||
}
|
||||
// Still regenerate the HTML viewer in case it's missing
|
||||
await this.ensureHTMLViewer();
|
||||
return { pagesGenerated: 0, mode: 'up-to-date', failedModules: [] };
|
||||
@@ -235,6 +269,15 @@ export class WikiGenerator {
|
||||
let result: WikiRunResult;
|
||||
try {
|
||||
if (!forceMode && existingMeta && existingMeta.fromCommit) {
|
||||
const currentLang = this.effectiveLang();
|
||||
const metaLang = existingMeta.lang ?? '';
|
||||
if (currentLang !== metaLang) {
|
||||
const prevDisplay = metaLang || 'english (default)';
|
||||
const nextDisplay = currentLang || 'english (default)';
|
||||
throw new Error(
|
||||
`Wiki was generated in ${prevDisplay}; use --force to regenerate in ${nextDisplay}.`,
|
||||
);
|
||||
}
|
||||
result = await this.incrementalUpdate(existingMeta, currentCommit);
|
||||
} else {
|
||||
result = await this.fullGeneration(currentCommit);
|
||||
@@ -368,6 +411,7 @@ export class WikiGenerator {
|
||||
fromCommit: currentCommit,
|
||||
generatedAt: new Date().toISOString(),
|
||||
model: this.llmConfig.model,
|
||||
lang: this.effectiveLang(),
|
||||
moduleFiles,
|
||||
moduleTree,
|
||||
});
|
||||
@@ -415,6 +459,9 @@ export class WikiGenerator {
|
||||
DIRECTORY_TREE: dirTree,
|
||||
});
|
||||
|
||||
// Grouping is a structured-data phase (JSON output), not documentation.
|
||||
// Do NOT apply buildSystemPrompt here — a language instruction would risk
|
||||
// translating module-name keys, breaking slug stability and JSON parsing.
|
||||
const response = await this.invokeLLM(
|
||||
prompt,
|
||||
GROUPING_SYSTEM_PROMPT,
|
||||
@@ -589,9 +636,13 @@ export class WikiGenerator {
|
||||
PROCESSES: formatProcesses(processes),
|
||||
});
|
||||
|
||||
const response = await this.invokeLLM(prompt, MODULE_SYSTEM_PROMPT, this.streamOpts(node.name));
|
||||
const response = await this.invokeLLM(
|
||||
prompt,
|
||||
this.buildSystemPrompt(MODULE_SYSTEM_PROMPT),
|
||||
this.streamOpts(node.name),
|
||||
);
|
||||
|
||||
// Write page with front matter
|
||||
// H1 uses the English module name (stable slug source); body is LLM-translated.
|
||||
const pageContent = sanitizeMermaidMarkdown(`# ${node.name}\n\n${response.content}`);
|
||||
await fs.writeFile(path.join(this.wikiDir, `${node.slug}.md`), pageContent, 'utf-8');
|
||||
}
|
||||
@@ -630,7 +681,11 @@ export class WikiGenerator {
|
||||
CROSS_PROCESSES: formatProcesses(processes),
|
||||
});
|
||||
|
||||
const response = await this.invokeLLM(prompt, PARENT_SYSTEM_PROMPT, this.streamOpts(node.name));
|
||||
const response = await this.invokeLLM(
|
||||
prompt,
|
||||
this.buildSystemPrompt(PARENT_SYSTEM_PROMPT),
|
||||
this.streamOpts(node.name),
|
||||
);
|
||||
|
||||
const pageContent = sanitizeMermaidMarkdown(`# ${node.name}\n\n${response.content}`);
|
||||
await fs.writeFile(path.join(this.wikiDir, `${node.slug}.md`), pageContent, 'utf-8');
|
||||
@@ -678,7 +733,7 @@ export class WikiGenerator {
|
||||
|
||||
const response = await this.invokeLLM(
|
||||
prompt,
|
||||
OVERVIEW_SYSTEM_PROMPT,
|
||||
this.buildSystemPrompt(OVERVIEW_SYSTEM_PROMPT),
|
||||
this.streamOpts('Generating overview', 88),
|
||||
);
|
||||
|
||||
@@ -713,6 +768,7 @@ export class WikiGenerator {
|
||||
...existingMeta,
|
||||
fromCommit: currentCommit,
|
||||
generatedAt: new Date().toISOString(),
|
||||
lang: this.effectiveLang(),
|
||||
});
|
||||
return { pagesGenerated: 0, mode: 'incremental', failedModules: [] };
|
||||
}
|
||||
@@ -817,6 +873,7 @@ export class WikiGenerator {
|
||||
fromCommit: currentCommit,
|
||||
generatedAt: new Date().toISOString(),
|
||||
model: this.llmConfig.model,
|
||||
lang: this.effectiveLang(),
|
||||
});
|
||||
|
||||
this.onProgress('done', 100, 'Incremental update complete');
|
||||
|
||||
@@ -23,7 +23,7 @@ export interface LLMConfig {
|
||||
apiVersion?: string;
|
||||
/** When true, strips sampling params and uses max_completion_tokens instead of max_tokens */
|
||||
isReasoningModel?: boolean;
|
||||
/** Per-attempt fetch timeout in ms (default: 60_000). */
|
||||
/** Per-attempt fetch timeout in ms. Omit to disable request timeouts. */
|
||||
requestTimeoutMs?: number;
|
||||
/** Max fetch attempts before giving up (default: 3). */
|
||||
maxAttempts?: number;
|
||||
@@ -81,6 +81,19 @@ export function estimateTokens(text: string): number {
|
||||
return Math.ceil(text.length / 4);
|
||||
}
|
||||
|
||||
function formatTimeoutDuration(timeoutMs: number): string {
|
||||
if (timeoutMs >= 1000 && timeoutMs % 1000 === 0) {
|
||||
return `${timeoutMs / 1000}s`;
|
||||
}
|
||||
return `${timeoutMs}ms`;
|
||||
}
|
||||
|
||||
function isTimeoutLikeError(err: unknown): boolean {
|
||||
if (!(err instanceof Error)) return false;
|
||||
if (err.name === 'TimeoutError' || err.name === 'AbortError') return true;
|
||||
return /time(d)?\s*out|timeout/i.test(err.message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a base URL supplied for LLM API calls is a safe HTTP/HTTPS
|
||||
* endpoint (CWE-918 / CodeQL js/http-to-file-access).
|
||||
@@ -237,12 +250,13 @@ export async function callLLM(
|
||||
...authHeaders,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
// Per-attempt timeout. Without this each retry can hang
|
||||
// indefinitely on a frozen TCP connection — the per-call
|
||||
// signal is the only timeout `resilientFetch` honors;
|
||||
// `capDelayMs` only bounds the *backoff* between attempts.
|
||||
// Default 60s; raise via --timeout for slow models or large pages.
|
||||
signal: AbortSignal.timeout(config.requestTimeoutMs ?? 60_000),
|
||||
// Request timeout is opt-in for wiki generation. Large local
|
||||
// model runs can legitimately take well over a minute, so the
|
||||
// default runtime path must not impose a hidden 60s ceiling.
|
||||
signal:
|
||||
config.requestTimeoutMs !== undefined
|
||||
? AbortSignal.timeout(config.requestTimeoutMs)
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
breakerKey: `wiki-llm-${new URL(url).host}`,
|
||||
@@ -261,6 +275,12 @@ export async function callLLM(
|
||||
`LLM API error (${err.response.status} after retries): ${errorText.slice(0, 500)}`,
|
||||
);
|
||||
}
|
||||
if (config.requestTimeoutMs !== undefined && isTimeoutLikeError(err)) {
|
||||
throw new Error(
|
||||
`LLM request timed out after ${formatTimeoutDuration(config.requestTimeoutMs)}. ` +
|
||||
'Increase --timeout or omit it to disable the request timeout.',
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,15 +14,20 @@ import {
|
||||
executeParameterized,
|
||||
closeLbug,
|
||||
isLbugReady,
|
||||
isWriteQuery,
|
||||
} from '../../core/lbug/pool-adapter.js';
|
||||
import { isValidQueryParams } from '../../core/lbug/query-params.js';
|
||||
import { isWalCorruptionError, WAL_RECOVERY_SUGGESTION } from '../../core/lbug/lbug-config.js';
|
||||
export { isWriteQuery };
|
||||
// Embedding imports are lazy (dynamic import) to avoid loading onnxruntime-node
|
||||
// at MCP server startup — crashes on unsupported Node ABI versions (#89)
|
||||
// git utilities available if needed
|
||||
// import { isGitRepo, getCurrentCommit, getGitRoot } from '../../storage/git.js';
|
||||
import { parseDiffHunks, type FileDiff } from '../../storage/git.js';
|
||||
import {
|
||||
parseDiffHunks,
|
||||
getCanonicalRepoRoot,
|
||||
getGitRoot,
|
||||
type FileDiff,
|
||||
} from '../../storage/git.js';
|
||||
import { realpathSync } from 'fs';
|
||||
import {
|
||||
listRegisteredRepos,
|
||||
cleanupOldKuzuFiles,
|
||||
@@ -169,6 +174,9 @@ function logQueryError(context: string, err: unknown): void {
|
||||
logger.error({ context, err: msg }, 'GitNexus query failed');
|
||||
}
|
||||
|
||||
const isReadOnlyDbError = (err: unknown): boolean =>
|
||||
/read-only database/i.test(err instanceof Error ? err.message : String(err));
|
||||
|
||||
/**
|
||||
* Per-query latency telemetry for production aggregation (#553).
|
||||
*
|
||||
@@ -211,6 +219,55 @@ interface RepoHandle {
|
||||
stats?: RegistryEntry['stats'];
|
||||
}
|
||||
|
||||
/** Resolve symlinks for path comparison; falls back to path.resolve on error.
|
||||
* Uses `realpathSync.native` (not the pure-JS `realpathSync`) so that Windows
|
||||
* 8.3 short names (e.g. RUNNER~1 → runneradmin) are expanded to long form,
|
||||
* matching the output of `git rev-parse --show-toplevel`. */
|
||||
function tryRealpath(p: string): string {
|
||||
try {
|
||||
return realpathSync.native(p);
|
||||
} catch {
|
||||
return path.resolve(p);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the git diff cwd for detect_changes, auto-detecting linked worktrees.
|
||||
*
|
||||
* When `launchCwd` is a linked worktree of the same canonical repository as
|
||||
* `repoPath` (i.e. `getGitRoot(launchCwd)` differs from `repoPath` but both
|
||||
* share the same `getCanonicalRepoRoot`), returns the worktree's git root so
|
||||
* that `git diff` sees the correct working directory and index.
|
||||
*
|
||||
* Returns `repoPath` unchanged in all other cases (non-worktree, git
|
||||
* unavailable, unrelated repo).
|
||||
*
|
||||
* Extracted as a module-level export so tests can pass any `launchCwd` instead
|
||||
* of relying on `process.cwd()`, which is fixed to the server launch directory
|
||||
* and cannot be changed mid-process.
|
||||
*/
|
||||
export function resolveWorktreeCwd(repoPath: string, launchCwd: string): string {
|
||||
try {
|
||||
const launchGitRoot = getGitRoot(launchCwd);
|
||||
if (launchGitRoot) {
|
||||
// Normalise via realpathSync before comparing so macOS /var → /private/var
|
||||
// symlinks (and Windows 8.3 short names) don't create false mismatches.
|
||||
const realLaunch = tryRealpath(launchGitRoot);
|
||||
const realRepo = tryRealpath(repoPath);
|
||||
if (realLaunch !== realRepo) {
|
||||
const launchCanonical = getCanonicalRepoRoot(launchCwd);
|
||||
const repoCanonical = getCanonicalRepoRoot(repoPath);
|
||||
if (launchCanonical && repoCanonical && launchCanonical === repoCanonical) {
|
||||
return launchGitRoot;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Best-effort; fall through to repoPath.
|
||||
}
|
||||
return repoPath;
|
||||
}
|
||||
|
||||
export class LocalBackend {
|
||||
private repos: Map<string, RepoHandle> = new Map();
|
||||
private contextCache: Map<string, CodebaseContext> = new Map();
|
||||
@@ -1218,31 +1275,41 @@ export class LocalBackend {
|
||||
}
|
||||
}
|
||||
|
||||
async executeCypher(repoName: string, query: string): Promise<any> {
|
||||
async executeCypher(
|
||||
repoName: string,
|
||||
query: string,
|
||||
params: Record<string, unknown> = {},
|
||||
): Promise<any> {
|
||||
const repo = await this.resolveRepo(repoName);
|
||||
return this.cypher(repo, { query });
|
||||
return this.cypher(repo, { query, params });
|
||||
}
|
||||
|
||||
private async cypher(repo: RepoHandle, params: { query: string }): Promise<any> {
|
||||
private async cypher(
|
||||
repo: RepoHandle,
|
||||
request: { query: string; params?: Record<string, unknown> },
|
||||
): Promise<any> {
|
||||
await this.ensureInitialized(repo.id);
|
||||
|
||||
if (!isLbugReady(repo.id)) {
|
||||
return { error: 'LadybugDB not ready. Index may be corrupted.' };
|
||||
}
|
||||
|
||||
// Block write operations (defense-in-depth — DB is already read-only)
|
||||
if (isWriteQuery(params.query)) {
|
||||
if (request.params !== undefined && !isValidQueryParams(request.params)) {
|
||||
return {
|
||||
error:
|
||||
'Write operations (CREATE, DELETE, SET, MERGE, REMOVE, DROP, ALTER, COPY, DETACH) are not allowed. The knowledge graph is read-only.',
|
||||
error: '"params" must be a plain object with scalar values (string/number/boolean/null).',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await executeQuery(repo.id, params.query);
|
||||
const result = await executeParameterized(repo.id, request.query, request.params ?? {});
|
||||
return result;
|
||||
} catch (err: any) {
|
||||
const msg = err.message || 'Query failed';
|
||||
if (isReadOnlyDbError(err)) {
|
||||
return {
|
||||
error:
|
||||
'Write operations (CREATE, DELETE, SET, MERGE, REMOVE, DROP, ALTER, COPY, DETACH) are not allowed. The knowledge graph is read-only.',
|
||||
};
|
||||
}
|
||||
if (isWalCorruptionError(err)) {
|
||||
return {
|
||||
error: msg,
|
||||
@@ -2133,6 +2200,7 @@ export class LocalBackend {
|
||||
params: {
|
||||
scope?: string;
|
||||
base_ref?: string;
|
||||
worktree?: string;
|
||||
},
|
||||
): Promise<any> {
|
||||
await this.ensureInitialized(repo.id);
|
||||
@@ -2161,11 +2229,51 @@ export class LocalBackend {
|
||||
|
||||
let diffOutput: string;
|
||||
try {
|
||||
// Resolve the cwd for git diff.
|
||||
//
|
||||
// In a linked worktree (e.g. /repo/wt-feature/), the user's staged and
|
||||
// unstaged changes live in that worktree's separate working directory and
|
||||
// index. Running `git diff` from the canonical repo root sees a different
|
||||
// working tree and returns empty output.
|
||||
//
|
||||
// Resolution order (see resolveWorktreeCwd for details):
|
||||
// 1. params.worktree — explicit override, validated against the
|
||||
// registered repo's canonical root.
|
||||
// 2. Auto-detect — if the server's launch cwd (process.cwd()) is a
|
||||
// linked worktree of the same canonical repo, use its git root.
|
||||
// 3. repo.repoPath — fallback (original behaviour, handled inside
|
||||
// resolveWorktreeCwd when no worktree is detected).
|
||||
//
|
||||
// Start with the auto-detected value; override with the validated
|
||||
// explicit param when provided. This avoids a dead initial assignment.
|
||||
let diffCwd = resolveWorktreeCwd(repo.repoPath, process.cwd());
|
||||
if (params.worktree) {
|
||||
if (!path.isAbsolute(params.worktree)) {
|
||||
return {
|
||||
error: `worktree must be an absolute path, got: "${params.worktree}"`,
|
||||
};
|
||||
}
|
||||
const providedResolved = path.resolve(params.worktree);
|
||||
const repoCanonical = getCanonicalRepoRoot(repo.repoPath);
|
||||
if (!repoCanonical) {
|
||||
return {
|
||||
error: `Could not determine canonical root for repo "${repo.repoPath}". Is git available?`,
|
||||
};
|
||||
}
|
||||
const worktreeCanonical = getCanonicalRepoRoot(providedResolved);
|
||||
if (!worktreeCanonical || tryRealpath(worktreeCanonical) !== tryRealpath(repoCanonical)) {
|
||||
return {
|
||||
error: `worktree "${params.worktree}" is not a worktree of repo "${repo.repoPath}". Ensure the path is inside the same git repository.`,
|
||||
};
|
||||
}
|
||||
diffCwd = providedResolved;
|
||||
}
|
||||
|
||||
// maxBuffer raised from Node's 1MB default to 256MB to avoid ENOBUFS on
|
||||
// repos with large unstaged/untracked diffs (e.g. unignored build folders).
|
||||
// See issue: spawnSync git ENOBUFS in detect_changes(scope="unstaged").
|
||||
diffOutput = execFileSync('git', diffArgs, {
|
||||
cwd: repo.repoPath,
|
||||
cwd: diffCwd,
|
||||
encoding: 'utf-8',
|
||||
maxBuffer: 256 * 1024 * 1024,
|
||||
});
|
||||
|
||||
@@ -187,6 +187,11 @@ TIPS:
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: { type: 'string', description: 'Cypher query to execute' },
|
||||
params: {
|
||||
type: 'object',
|
||||
description:
|
||||
'Optional query parameters for placeholders (e.g. $name) to execute via prepared statement binding.',
|
||||
},
|
||||
repo: {
|
||||
type: 'string',
|
||||
description: 'Repository name or path. Omit if only one repo is indexed.',
|
||||
@@ -253,6 +258,8 @@ Maps git diff hunks to indexed symbols, then traces which processes are impacted
|
||||
WHEN TO USE: Before committing — to understand what your changes affect. Pre-commit review, PR preparation.
|
||||
AFTER THIS: Review affected processes. Use context() on high-risk symbols. READ gitnexus://repo/{name}/process/{name} for full traces.
|
||||
|
||||
GIT WORKTREE SUPPORT: GitNexus automatically detects when the MCP server was launched from inside a linked git worktree and runs git diff against that worktree — no extra parameters needed in the common case. Pass "worktree" explicitly only when the server was started from a different directory than the worktree you are editing (e.g., the server runs from the canonical root but your changes are in a linked worktree at a different path).
|
||||
|
||||
Returns: changed symbols, affected processes, and a risk summary.`,
|
||||
annotations: READ_ONLY_TOOL_ANNOTATIONS,
|
||||
inputSchema: {
|
||||
@@ -268,6 +275,11 @@ Returns: changed symbols, affected processes, and a risk summary.`,
|
||||
type: 'string',
|
||||
description: 'Branch/commit for "compare" scope (e.g., "main")',
|
||||
},
|
||||
worktree: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Absolute path to a linked git worktree. Pass this when your changes are in a worktree (the .git entry at that path is a file, not a directory). GitNexus will run git diff from that worktree so staged/unstaged changes are correctly detected.',
|
||||
},
|
||||
repo: {
|
||||
type: 'string',
|
||||
description: 'Repository name or path. Omit if only one repo is indexed.',
|
||||
|
||||
+41
-24
@@ -22,8 +22,9 @@ import {
|
||||
flushWAL,
|
||||
closeLbug,
|
||||
withLbugDb,
|
||||
isReadOnlyDbError,
|
||||
} from '../core/lbug/lbug-adapter.js';
|
||||
import { isWriteQuery } from '../core/lbug/pool-adapter.js';
|
||||
import { isValidQueryParams } from '../core/lbug/query-params.js';
|
||||
import { NODE_TABLES, type GraphNode, type GraphRelationship } from 'gitnexus-shared';
|
||||
import { searchFTSFromLbug } from '../core/search/bm25-index.js';
|
||||
import { hybridSearch } from '../core/search/hybrid-search.js';
|
||||
@@ -621,6 +622,44 @@ export const handleFileRequest = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const handleQueryRequest = async (
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
resolveRepo: (repoName?: string) => Promise<{ storagePath: string } | undefined>,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const cypher = req.body.cypher as string;
|
||||
if (!cypher) {
|
||||
res.status(400).json({ error: 'Missing "cypher" in request body' });
|
||||
return;
|
||||
}
|
||||
const queryParams = req.body.params;
|
||||
if (queryParams !== undefined && !isValidQueryParams(queryParams)) {
|
||||
res.status(400).json({
|
||||
error: '"params" must be a plain object with scalar values (string/number/boolean/null)',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = await resolveRepo(requestedRepo(req));
|
||||
if (!entry) {
|
||||
res.status(404).json({ error: 'Repository not found' });
|
||||
return;
|
||||
}
|
||||
const lbugPath = path.join(entry.storagePath, 'lbug');
|
||||
const result = await withLbugDb(lbugPath, () => executePrepared(cypher, queryParams ?? {}), {
|
||||
readOnly: true,
|
||||
});
|
||||
res.json({ result });
|
||||
} catch (err: any) {
|
||||
if (isReadOnlyDbError(err)) {
|
||||
res.status(403).json({ error: 'Write queries are not allowed via the HTTP API' });
|
||||
return;
|
||||
}
|
||||
res.status(500).json({ error: err.message || 'Query failed' });
|
||||
}
|
||||
};
|
||||
|
||||
export const createServer = async (port: number, host: string = '127.0.0.1') => {
|
||||
const app = express();
|
||||
app.disable('x-powered-by');
|
||||
@@ -1020,29 +1059,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
||||
|
||||
// Execute Cypher query
|
||||
app.post('/api/query', async (req, res) => {
|
||||
try {
|
||||
const cypher = req.body.cypher as string;
|
||||
if (!cypher) {
|
||||
res.status(400).json({ error: 'Missing "cypher" in request body' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (isWriteQuery(cypher)) {
|
||||
res.status(403).json({ error: 'Write queries are not allowed via the HTTP API' });
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = await resolveRepo(requestedRepo(req));
|
||||
if (!entry) {
|
||||
res.status(404).json({ error: 'Repository not found' });
|
||||
return;
|
||||
}
|
||||
const lbugPath = path.join(entry.storagePath, 'lbug');
|
||||
const result = await withLbugDb(lbugPath, () => executeQuery(cypher));
|
||||
res.json({ result });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message || 'Query failed' });
|
||||
}
|
||||
await handleQueryRequest(req, res, resolveRepo);
|
||||
});
|
||||
|
||||
// Search (supports mode: 'hybrid' | 'semantic' | 'bm25', and optional enrichment)
|
||||
|
||||
@@ -70,15 +70,28 @@ const GITNEXUS_PKG_VERSION = (() => {
|
||||
})();
|
||||
export const PARSE_CACHE_VERSION = `${SCHEMA_BUMP}+${GITNEXUS_PKG_VERSION}`;
|
||||
|
||||
const CACHE_FILENAME = 'parse-cache.json';
|
||||
const LEGACY_CACHE_FILENAME = 'parse-cache.json';
|
||||
const CACHE_DIRNAME = 'parse-cache';
|
||||
const CACHE_INDEX_FILENAME = 'index.json';
|
||||
|
||||
/** On-disk shape. */
|
||||
/** Keys on disk always come from `computeChunkHash` — 64-char lowercase hex. */
|
||||
const CHUNK_CACHE_KEY_HEX_RE = /^[a-f0-9]{64}$/;
|
||||
|
||||
const isValidChunkCacheKey = (chunkHash: string): boolean => CHUNK_CACHE_KEY_HEX_RE.test(chunkHash);
|
||||
|
||||
/** On-disk shape for the legacy single-file format. */
|
||||
interface ParseCacheFile {
|
||||
version: string;
|
||||
/** key = chunk hash (hex) → cached chunk result list. */
|
||||
entries: Record<string, ParseWorkerResult[]>;
|
||||
}
|
||||
|
||||
/** On-disk shape for the sharded directory format. */
|
||||
interface ShardedParseCacheIndex {
|
||||
version: string;
|
||||
keys: string[];
|
||||
}
|
||||
|
||||
/** Runtime view: keyed Map for fast lookup; mutated in place during a run. */
|
||||
export interface ParseCache {
|
||||
version: string;
|
||||
@@ -144,12 +157,19 @@ const mapReviver = (_key: string, value: unknown): unknown => {
|
||||
return value;
|
||||
};
|
||||
|
||||
/**
|
||||
* Load the parse cache. Returns an empty cache on any failure (missing
|
||||
* file, corrupt JSON, version mismatch). Never throws on a normal load.
|
||||
*/
|
||||
export const loadParseCache = async (storagePath: string): Promise<ParseCache> => {
|
||||
const cachePath = path.join(storagePath, CACHE_FILENAME);
|
||||
const getLegacyCachePath = (storagePath: string): string =>
|
||||
path.join(storagePath, LEGACY_CACHE_FILENAME);
|
||||
|
||||
const getCacheDirPath = (storagePath: string): string => path.join(storagePath, CACHE_DIRNAME);
|
||||
|
||||
const getCacheIndexPath = (storagePath: string): string =>
|
||||
path.join(getCacheDirPath(storagePath), CACHE_INDEX_FILENAME);
|
||||
|
||||
const getCacheChunkPath = (storagePath: string, chunkHash: string): string =>
|
||||
path.join(getCacheDirPath(storagePath), `${chunkHash}.json`);
|
||||
|
||||
const loadLegacyParseCache = async (storagePath: string): Promise<ParseCache> => {
|
||||
const cachePath = getLegacyCachePath(storagePath);
|
||||
try {
|
||||
const raw = await fs.readFile(cachePath, 'utf-8');
|
||||
const data = JSON.parse(raw, mapReviver) as ParseCacheFile;
|
||||
@@ -172,22 +192,90 @@ export const loadParseCache = async (storagePath: string): Promise<ParseCache> =
|
||||
}
|
||||
};
|
||||
|
||||
const loadShardedParseCache = async (storagePath: string): Promise<ParseCache | null> => {
|
||||
const indexPath = getCacheIndexPath(storagePath);
|
||||
try {
|
||||
const raw = await fs.readFile(indexPath, 'utf-8');
|
||||
const data = JSON.parse(raw) as ShardedParseCacheIndex;
|
||||
if (
|
||||
typeof data !== 'object' ||
|
||||
data === null ||
|
||||
data.version !== PARSE_CACHE_VERSION ||
|
||||
!Array.isArray(data.keys)
|
||||
) {
|
||||
return emptyCache();
|
||||
}
|
||||
|
||||
const entries = new Map<string, ParseWorkerResult[]>();
|
||||
for (const chunkHash of data.keys) {
|
||||
if (typeof chunkHash !== 'string' || !isValidChunkCacheKey(chunkHash)) continue;
|
||||
try {
|
||||
const chunkRaw = await fs.readFile(getCacheChunkPath(storagePath, chunkHash), 'utf-8');
|
||||
const chunkData = JSON.parse(chunkRaw, mapReviver) as ParseWorkerResult[];
|
||||
if (Array.isArray(chunkData)) entries.set(chunkHash, chunkData);
|
||||
} catch {
|
||||
/* skip corrupt or missing shard */
|
||||
}
|
||||
}
|
||||
|
||||
return { version: PARSE_CACHE_VERSION, entries, usedKeys: new Set<string>() };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Persist the cache to disk atomically (write-and-rename) so a crash
|
||||
* mid-write doesn't leave a corrupt file.
|
||||
* Load the parse cache. Returns an empty cache on any failure (missing
|
||||
* file, corrupt JSON, version mismatch). Never throws on a normal load.
|
||||
*/
|
||||
export const loadParseCache = async (storagePath: string): Promise<ParseCache> => {
|
||||
const sharded = await loadShardedParseCache(storagePath);
|
||||
if (sharded) return sharded;
|
||||
return loadLegacyParseCache(storagePath);
|
||||
};
|
||||
|
||||
/**
|
||||
* Persist the cache to disk using a temp directory + rename.
|
||||
*
|
||||
* Writes shards under `${cacheDir}.tmp`, then removes the old `cacheDir` and
|
||||
* renames the temp directory into place. There is a crash window after
|
||||
* `rm(cacheDir)` and before `rename(tmpDir, cacheDir)` where no cache exists;
|
||||
* that is acceptable — `loadParseCache` yields empty and the next run
|
||||
* reparses. This is not a single atomic swap of the whole tree, but avoids
|
||||
* leaving a half-written shard set visible to readers.
|
||||
*/
|
||||
export const saveParseCache = async (storagePath: string, cache: ParseCache): Promise<void> => {
|
||||
await fs.mkdir(storagePath, { recursive: true });
|
||||
const cachePath = path.join(storagePath, CACHE_FILENAME);
|
||||
const tmpPath = `${cachePath}.tmp`;
|
||||
const out: ParseCacheFile = {
|
||||
const cacheDir = getCacheDirPath(storagePath);
|
||||
const tmpDir = `${cacheDir}.tmp`;
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
await fs.mkdir(tmpDir, { recursive: true });
|
||||
|
||||
const keys: string[] = [];
|
||||
for (const [chunkHash, chunkResults] of cache.entries) {
|
||||
if (!isValidChunkCacheKey(chunkHash)) continue;
|
||||
let payload: string;
|
||||
try {
|
||||
payload = JSON.stringify(chunkResults, mapReplacer);
|
||||
} catch {
|
||||
// Extremely dense chunks could theoretically exceed string limits; skip
|
||||
// rather than failing the entire save (orchestrator catches save errors).
|
||||
continue;
|
||||
}
|
||||
keys.push(chunkHash);
|
||||
const chunkPath = path.join(tmpDir, `${chunkHash}.json`);
|
||||
await fs.writeFile(chunkPath, payload, 'utf-8');
|
||||
}
|
||||
|
||||
const index: ShardedParseCacheIndex = {
|
||||
version: cache.version,
|
||||
entries: Object.fromEntries(cache.entries),
|
||||
keys,
|
||||
};
|
||||
// Compact JSON; this file can be tens of MB on a large repo and pretty-
|
||||
// printing roughly doubles size for no value.
|
||||
await fs.writeFile(tmpPath, JSON.stringify(out, mapReplacer), 'utf-8');
|
||||
await fs.rename(tmpPath, cachePath);
|
||||
await fs.writeFile(path.join(tmpDir, CACHE_INDEX_FILENAME), JSON.stringify(index), 'utf-8');
|
||||
|
||||
await fs.rm(cacheDir, { recursive: true, force: true });
|
||||
await fs.rename(tmpDir, cacheDir);
|
||||
await fs.rm(getLegacyCachePath(storagePath), { force: true });
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Vendored
+14
@@ -0,0 +1,14 @@
|
||||
#include "base_lib.h"
|
||||
|
||||
namespace app {
|
||||
struct Token : base_one::Base {};
|
||||
|
||||
void run() {
|
||||
Token t;
|
||||
collide(t);
|
||||
}
|
||||
}
|
||||
|
||||
namespace other {
|
||||
struct Token : base_two::Base {};
|
||||
}
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
#pragma once
|
||||
|
||||
namespace base_one {
|
||||
struct Base {};
|
||||
void collide(Base);
|
||||
}
|
||||
|
||||
namespace base_two {
|
||||
struct Base {};
|
||||
void collide(Base);
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
#include "base_lib.h"
|
||||
|
||||
namespace app {
|
||||
struct HiddenDerived : HiddenBase {};
|
||||
struct MissingDerived : missing_ns::UnknownBase {};
|
||||
|
||||
void run_hidden() {
|
||||
HiddenDerived d;
|
||||
hidden_probe(d);
|
||||
}
|
||||
|
||||
void run_missing() {
|
||||
MissingDerived d;
|
||||
unresolved_probe(d);
|
||||
}
|
||||
}
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
namespace {
|
||||
struct HiddenBase {};
|
||||
void hidden_probe(HiddenBase);
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
#include "base_lib.h"
|
||||
|
||||
namespace app {
|
||||
struct Derived : base_lib::Base {};
|
||||
struct MultiLevel : middle_lib::Mid {};
|
||||
struct DiamondDerived : diamond_lib::LeftBranch, diamond_lib::RightBranch {};
|
||||
|
||||
void run_single() {
|
||||
Derived d;
|
||||
log(d);
|
||||
}
|
||||
|
||||
void run_multi() {
|
||||
MultiLevel m;
|
||||
trace(m);
|
||||
}
|
||||
|
||||
void run_diamond() {
|
||||
DiamondDerived d;
|
||||
ping(d);
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
#pragma once
|
||||
|
||||
namespace base_lib {
|
||||
struct Base {};
|
||||
void log(Base);
|
||||
|
||||
struct Root {};
|
||||
void trace(Root);
|
||||
}
|
||||
|
||||
namespace middle_lib {
|
||||
struct Mid : base_lib::Root {};
|
||||
}
|
||||
|
||||
namespace diamond_lib {
|
||||
struct DiamondBase {};
|
||||
struct LeftBranch : DiamondBase {};
|
||||
struct RightBranch : DiamondBase {};
|
||||
void ping(DiamondBase);
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
#include "audit.h"
|
||||
|
||||
namespace app {
|
||||
void run() {
|
||||
// Block-scope function declaration (not via using-declaration).
|
||||
// Per ISO C++ [basic.lookup.argdep], this suppresses ADL — even
|
||||
// though `e` is audit::Event, audit::record should NOT be discovered.
|
||||
void record(int);
|
||||
|
||||
audit::Event e;
|
||||
record(e);
|
||||
}
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
namespace audit {
|
||||
struct Event {};
|
||||
void record(Event e);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
#include "color.h"
|
||||
|
||||
namespace app {
|
||||
void run() {
|
||||
color::Channel ch = color::Channel::R;
|
||||
serialize(ch);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
namespace color {
|
||||
enum class Channel { R, G, B };
|
||||
void serialize(Channel c);
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
#include "utils.h"
|
||||
|
||||
namespace caller {
|
||||
void run() {
|
||||
with_callback(utils::worker);
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
namespace utils {
|
||||
void worker();
|
||||
void worker(int n);
|
||||
void with_callback(int n);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
#include "utils.h"
|
||||
|
||||
namespace caller {
|
||||
void run() {
|
||||
with_callback(utils::worker);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
namespace utils {
|
||||
void worker();
|
||||
void with_callback(int n);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
#include "lib.h"
|
||||
|
||||
namespace app {
|
||||
void run() {
|
||||
lib::Foo f;
|
||||
process(f);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
namespace lib {
|
||||
struct Foo {
|
||||
friend void process(Foo& f) {}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
#include "audit.h"
|
||||
|
||||
namespace app {
|
||||
void run() {
|
||||
audit::Event e;
|
||||
record(e);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
#pragma once
|
||||
|
||||
namespace audit {
|
||||
struct Event {};
|
||||
|
||||
inline namespace v1 {
|
||||
void record(Event e);
|
||||
}
|
||||
}
|
||||
|
||||
namespace other {
|
||||
void record(int x);
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
#include "data.h"
|
||||
|
||||
// Outer namespace has a non-callable `swap` (variable).
|
||||
namespace app {
|
||||
int swap = 0;
|
||||
|
||||
namespace inner {
|
||||
// Inner namespace re-declares `swap` as a function.
|
||||
void swap(int, int);
|
||||
|
||||
void run() {
|
||||
data::Pair a, b;
|
||||
// Ordinary lookup finds `inner::swap(int,int)` first (callable at
|
||||
// nearest scope). The outer `app::swap` variable should NOT suppress
|
||||
// ADL because ordinary lookup stopped at `inner` scope where a
|
||||
// callable was found. ADL contributes `data::swap(Pair&,Pair&)` which
|
||||
// wins via argTypes narrowing.
|
||||
swap(a, b);
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
namespace data {
|
||||
struct Pair {};
|
||||
void swap(Pair& a, Pair& b);
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
#include "audit.h"
|
||||
|
||||
namespace app {
|
||||
void run() {
|
||||
// `g` is a locally-declared function-pointer variable. audit::g() also
|
||||
// exists in the workspace. The local-fp guard (foundAsLocalFunctionPointer)
|
||||
// must detect `g` as a function-pointer variable declaration and return
|
||||
// EMPTY_ADL_ARG, preventing the workspace scan that would otherwise find
|
||||
// audit::g and contribute `audit` to the ADL associated set.
|
||||
void (*g)();
|
||||
record(g);
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
#pragma once
|
||||
|
||||
namespace audit {
|
||||
// A free function named `g` exists in the workspace. Without the local-fp
|
||||
// guard, a locally-declared `void (*g)()` variable would fall through to
|
||||
// EMPTY_ADL_ARG and not be treated as a free-function ref — but this test
|
||||
// specifically verifies that the local fp variable shadows the workspace
|
||||
// function of the same name and no namespace is contributed.
|
||||
void g();
|
||||
void record(void (*fn)());
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
#include "data.h"
|
||||
|
||||
namespace app {
|
||||
void swap(int a, int b);
|
||||
|
||||
void run() {
|
||||
data::Pair a;
|
||||
data::Pair b;
|
||||
swap(a, b);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
namespace data {
|
||||
struct Pair {};
|
||||
void swap(Pair& a, Pair& b);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
namespace alpha {
|
||||
struct Token {};
|
||||
void probe(Token t);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
#include "alpha.h"
|
||||
|
||||
namespace app {
|
||||
void run() {
|
||||
alpha::Token t;
|
||||
probe(t, 42);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
#include "audit.h"
|
||||
|
||||
namespace app {
|
||||
int record = 0;
|
||||
|
||||
void run() {
|
||||
audit::Event e;
|
||||
record(e);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
namespace audit {
|
||||
struct Event {};
|
||||
void record(Event e);
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
#include "utils.h"
|
||||
|
||||
namespace caller {
|
||||
// `callback` is a plain int parameter, not a function reference.
|
||||
// Without the fix: `callback` is not found in the compound_statement
|
||||
// (parameters live in parameter_list) → lookupAdlIdentifierType returns null
|
||||
// → treated as free-function ref → workspace scan finds utils::callback
|
||||
// → `utils` added to ADL set → run_with resolves to utils::run_with (false positive).
|
||||
// With the fix: isIdentifierAFunctionParameter detects `callback` in the
|
||||
// parameter_list → returns EMPTY_ADL_ARG → no namespace contributed → 0 CALLS edges.
|
||||
void run(int callback) {
|
||||
run_with(callback);
|
||||
}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
#pragma once
|
||||
|
||||
namespace utils {
|
||||
// A function named `callback` exists in the `utils` namespace. Without the
|
||||
// parameter-list guard, passing a function *parameter* also named `callback`
|
||||
// would trigger a workspace scan, find utils::callback, contribute `utils`
|
||||
// to the ADL set, and emit a false-positive CALLS edge to utils::run_with.
|
||||
void callback();
|
||||
void run_with(int n);
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
#include "data.h"
|
||||
|
||||
namespace caller {
|
||||
// data::value is a namespace-qualified VARIABLE, not a function.
|
||||
// ADL must NOT contribute `data` to the associated namespace set — the
|
||||
// argument type is `int`, which has no associated namespaces in ISO C++.
|
||||
// GitNexus guards: collectFunctionRefNamespaces verifies a Function/Method
|
||||
// named `value` exists in `data` before contributing. Since `data::value`
|
||||
// is a variable (not a function), `data` is NOT added, and process() is
|
||||
// not resolved via ADL.
|
||||
void run() {
|
||||
process(data::value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
namespace data {
|
||||
extern int value;
|
||||
void process(int n);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
#include "audit.h"
|
||||
|
||||
namespace app {
|
||||
void run() {
|
||||
std::vector<N::T> v;
|
||||
apply(v);
|
||||
}
|
||||
|
||||
void runNested() {
|
||||
std::map<std::string, std::vector<N::T>> m;
|
||||
applyNested(m);
|
||||
}
|
||||
|
||||
void runArray() {
|
||||
std::array<N::T, 4> a;
|
||||
applyArray(a);
|
||||
}
|
||||
|
||||
void runStdConflict() {
|
||||
std::vector<N::T> v;
|
||||
applyStdConflict(v);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace N {
|
||||
struct T {};
|
||||
|
||||
void apply(std::vector<T> v);
|
||||
void applyNested(std::map<std::string, std::vector<T>> m);
|
||||
void applyArray(std::array<T, 4> a);
|
||||
void applyStdConflict(std::vector<T> v);
|
||||
}
|
||||
|
||||
namespace std {
|
||||
void applyStdConflict(vector<N::T> v);
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
#include "lib.h"
|
||||
|
||||
namespace caller {
|
||||
void run() {
|
||||
// Unqualified `worker` — not in local compound_statement scope → treated
|
||||
// as a potential free-function reference. The workspace scan finds
|
||||
// worker() in BOTH alpha and beta namespaces, so BOTH are added to the
|
||||
// associated set. run_with() exists in both namespaces as well, so the
|
||||
// lookup yields two candidates (alpha::run_with, beta::run_with).
|
||||
// Merged-narrowing ambiguity suppression in free-call-fallback emits
|
||||
// zero CALLS edges rather than picking one arbitrarily.
|
||||
run_with(worker);
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
#pragma once
|
||||
|
||||
namespace alpha {
|
||||
// `worker` exists in both alpha and beta namespaces.
|
||||
void worker();
|
||||
void run_with(void (*fn)());
|
||||
}
|
||||
|
||||
namespace beta {
|
||||
void worker();
|
||||
void run_with(void (*fn)());
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
#include "lib.h"
|
||||
|
||||
void run() {
|
||||
outer::foo(42);
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
#pragma once
|
||||
|
||||
namespace outer {
|
||||
inline namespace v1 {
|
||||
void foo(int x);
|
||||
}
|
||||
inline namespace v2 {
|
||||
void foo(double y);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user