mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-28 01:31:23 +00:00
Merge branch 'main' into feat/javascript-scope-resolution-ring3
This commit is contained in:
commit
23d36c726d
27 changed files with 2892 additions and 590 deletions
16
.github/workflows/ci.yml
vendored
16
.github/workflows/ci.yml
vendored
|
|
@ -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' }}
|
||||
|
|
|
|||
11
.github/workflows/docker.yml
vendored
11
.github/workflows/docker.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
864
.github/workflows/publish.yml
vendored
864
.github/workflows/publish.yml
vendored
|
|
@ -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 }}
|
||||
|
|
|
|||
459
.github/workflows/release-candidate.yml
vendored
459
.github/workflows/release-candidate.yml
vendored
|
|
@ -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 }}
|
||||
9
.github/zizmor.yml
vendored
9
.github/zizmor.yml
vendored
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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}:`);
|
||||
|
|
|
|||
47
gitnexus/src/core/ingestion/languages/cpp/conversion-rank.ts
Normal file
47
gitnexus/src/core/ingestion/languages/cpp/conversion-rank.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
@ -169,6 +170,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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -264,6 +264,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
|
||||
|
|
@ -533,6 +534,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`
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import type { WorkspaceResolutionIndex } from '../workspace-index.js';
|
|||
import type { GraphNodeLookup } from '../graph-bridge/node-lookup.js';
|
||||
import { resolveCallerGraphId, resolveDefGraphId } from '../graph-bridge/ids.js';
|
||||
import {
|
||||
findAllCallableBindingsInScope,
|
||||
findCallableBindingInScope,
|
||||
findCallableBindingsAndAdlBlocker,
|
||||
findClassBindingInScope,
|
||||
|
|
@ -32,6 +33,7 @@ import {
|
|||
import {
|
||||
isOverloadAmbiguousAfterNormalization,
|
||||
narrowOverloadCandidates,
|
||||
type ConversionRankFn,
|
||||
} from './overload-narrowing.js';
|
||||
|
||||
export function emitFreeCallFallback(
|
||||
|
|
@ -63,6 +65,7 @@ export function emitFreeCallFallback(
|
|||
scopes: ScopeResolutionIndexes,
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
) => readonly SymbolDefinition[] | undefined;
|
||||
readonly conversionRankFn?: ConversionRankFn;
|
||||
} = {},
|
||||
): number {
|
||||
let emitted = 0;
|
||||
|
|
@ -90,16 +93,59 @@ 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,
|
||||
options.conversionRankFn,
|
||||
);
|
||||
}
|
||||
// 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) {
|
||||
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,
|
||||
options.conversionRankFn,
|
||||
);
|
||||
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 {
|
||||
// ISO C++ `[basic.lookup.unqual]` §7: ADL is suppressed when
|
||||
// ordinary lookup finds a non-function name (variable, class, enum)
|
||||
// or a block-scope function declaration (not via using-declaration)
|
||||
// at the nearest scope where the name exists.
|
||||
// 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,
|
||||
|
|
@ -120,43 +166,67 @@ export function emitFreeCallFallback(
|
|||
parsedFiles,
|
||||
);
|
||||
|
||||
// Preserve existing ordinary-lookup behavior when ADL contributed
|
||||
// no candidates.
|
||||
// When ADL contributed no candidates, narrow ordinary candidates
|
||||
// with conversion-rank scoring when multiple overloads exist.
|
||||
// Single candidate or empty falls through to first-match.
|
||||
if (adl === undefined || adl.length === 0) {
|
||||
fnDef = ordinary[0];
|
||||
if (ordinary.length <= 1 || options.conversionRankFn === undefined) {
|
||||
fnDef = ordinary[0];
|
||||
} else {
|
||||
const siteKey = `${parsed.filePath}:${site.atRange.startLine}:${site.atRange.startCol}`;
|
||||
const narrowed = narrowOverloadCandidates(
|
||||
ordinary,
|
||||
site.arity,
|
||||
site.argumentTypes,
|
||||
options.conversionRankFn,
|
||||
);
|
||||
if (narrowed.length === 1) {
|
||||
fnDef = narrowed[0];
|
||||
} else if (narrowed.length > 1) {
|
||||
// Multiple survivors — suppress when same-file (true
|
||||
// overloads), mirrors ADL merged-candidate behavior.
|
||||
const sameFile = narrowed.every((d) => d.filePath === narrowed[0]!.filePath);
|
||||
if (sameFile) {
|
||||
handledSites.add(siteKey);
|
||||
continue;
|
||||
}
|
||||
fnDef = ordinary[0]; // cross-file shadowing → first-match
|
||||
} else {
|
||||
fnDef = ordinary[0]; // narrowed empty → first-match
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const siteKey = `${parsed.filePath}:${site.atRange.startLine}:${site.atRange.startCol}`;
|
||||
const merged: SymbolDefinition[] = [];
|
||||
const seen = new Set<string>();
|
||||
const seenMerge = new Set<string>();
|
||||
const push = (defs: readonly SymbolDefinition[]): void => {
|
||||
for (const d of defs) {
|
||||
if (seen.has(d.nodeId)) continue;
|
||||
seen.add(d.nodeId);
|
||||
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);
|
||||
const narrowed = narrowOverloadCandidates(
|
||||
merged,
|
||||
site.arity,
|
||||
site.argumentTypes,
|
||||
options.conversionRankFn,
|
||||
);
|
||||
if (narrowed.length === 1) {
|
||||
fnDef = narrowed[0];
|
||||
} else if (narrowed.length === 0) {
|
||||
// ADL contributed candidates, but none survived arity/type
|
||||
// narrowing. Treat as handled to avoid global-name fallback
|
||||
// binding to the same mismatched symbol by simple-name
|
||||
// uniqueness.
|
||||
handledSites.add(siteKey);
|
||||
continue;
|
||||
} else if (narrowed.length > 1) {
|
||||
// Suppress ambiguous overload calls (emit zero edges) when
|
||||
// merged ordinary+ADL candidate sets cannot be disambiguated.
|
||||
if (isOverloadAmbiguousAfterNormalization(narrowed, site.arity)) {
|
||||
handledSites.add(siteKey);
|
||||
continue;
|
||||
}
|
||||
// Multiple survivors remain but no conversion-ranking step
|
||||
// exists yet; suppress instead of picking arbitrarily.
|
||||
// Multiple survivors remain after conversion-rank scoring;
|
||||
// suppress instead of picking arbitrarily.
|
||||
handledSites.add(siteKey);
|
||||
continue;
|
||||
}
|
||||
|
|
@ -184,6 +254,8 @@ export function emitFreeCallFallback(
|
|||
scopes,
|
||||
})
|
||||
: undefined,
|
||||
site.argumentTypes,
|
||||
options.conversionRankFn,
|
||||
);
|
||||
}
|
||||
if (fnDef === undefined) continue;
|
||||
|
|
@ -222,6 +294,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>();
|
||||
|
|
@ -256,6 +330,14 @@ 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>();
|
||||
|
|
@ -289,6 +371,11 @@ 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;
|
||||
}
|
||||
|
|
@ -362,6 +449,7 @@ export function pickImplicitThisOverload(
|
|||
scopes: ScopeResolutionIndexes,
|
||||
workspaceIndex: WorkspaceResolutionIndex,
|
||||
model: SemanticModel,
|
||||
conversionRankFn?: ConversionRankFn,
|
||||
): SymbolDefinition | undefined {
|
||||
// Find the enclosing Class scope by walking parents.
|
||||
let curId: ScopeId | null = site.inScope;
|
||||
|
|
@ -389,7 +477,12 @@ 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,
|
||||
);
|
||||
if (candidates.length !== 1) return undefined;
|
||||
return candidates[0];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,15 +24,35 @@
|
|||
* 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, 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.
|
||||
* 5. Empty input returns empty output.
|
||||
*/
|
||||
|
||||
import type { 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;
|
||||
|
||||
export function narrowOverloadCandidates(
|
||||
overloads: readonly SymbolDefinition[],
|
||||
argCount: number | undefined,
|
||||
argTypes: readonly string[] | undefined,
|
||||
conversionRankFn?: ConversionRankFn,
|
||||
): readonly SymbolDefinition[] {
|
||||
if (overloads.length === 0) return [];
|
||||
|
||||
|
|
@ -84,11 +104,98 @@ export function narrowOverloadCandidates(
|
|||
return true;
|
||||
});
|
||||
if (typed.length > 0) return typed;
|
||||
|
||||
// ── Conversion-rank scoring (step 4b) ──────────────────────────
|
||||
// The exact-type filter above rejected every candidate. When a
|
||||
// per-language conversion-rank function is available, 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.
|
||||
if (conversionRankFn !== undefined) {
|
||||
const ranked = rankByConversion(candidates, argTypes, conversionRankFn);
|
||||
if (ranked.length > 0) return ranked;
|
||||
}
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect when >1 candidate share identical `parameterTypes` after the
|
||||
* per-language normalizer has collapsed distinct underlying types. This
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ type ReceiverBoundProviderSubset = Pick<
|
|||
| 'hoistTypeBindingsToModule'
|
||||
| 'resolveQualifiedReceiverMember'
|
||||
| 'resolveThisViaEnclosingClass'
|
||||
| 'conversionRankFn'
|
||||
>;
|
||||
|
||||
function normalizeTemplateArgToken(value: string): string {
|
||||
|
|
@ -343,6 +344,7 @@ export function emitReceiverBoundCalls(
|
|||
methodOverloads,
|
||||
site.arity,
|
||||
site.argumentTypes,
|
||||
provider.conversionRankFn,
|
||||
);
|
||||
if (isOverloadAmbiguousAfterNormalization(narrowed, site.arity)) {
|
||||
ambiguous = true;
|
||||
|
|
@ -356,6 +358,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;
|
||||
}
|
||||
|
|
@ -640,7 +648,13 @@ 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.conversionRankFn,
|
||||
);
|
||||
if (picked === OVERLOAD_AMBIGUOUS) {
|
||||
ambiguous = true;
|
||||
break;
|
||||
|
|
@ -708,6 +722,7 @@ function pickOverload(
|
|||
memberName: string,
|
||||
site: ParsedFile['referenceSites'][number],
|
||||
model: SemanticModel,
|
||||
conversionRankFn?: (argType: string, paramType: string) => number,
|
||||
): SymbolDefinition | typeof OVERLOAD_AMBIGUOUS | undefined {
|
||||
const overloads = model.methods.lookupAllByOwner(ownerId, memberName);
|
||||
if (overloads.length === 0) {
|
||||
|
|
@ -718,7 +733,12 @@ 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,
|
||||
);
|
||||
// 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.
|
||||
|
|
@ -726,6 +746,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,7 @@ export function runScopeResolution(
|
|||
isFileLocalDef: provider.isFileLocalDef,
|
||||
isCallableVisibleFromCaller: provider.isCallableVisibleFromCaller,
|
||||
resolveAdlCandidates: provider.resolveAdlCandidates,
|
||||
conversionRankFn: provider.conversionRankFn,
|
||||
},
|
||||
);
|
||||
const { emitted, skipped } = emitReferencesViaLookup(
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -201,6 +201,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;
|
||||
|
|
@ -364,17 +521,64 @@ 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 = await openLbugConnection(lbug, dbPath);
|
||||
db = opened.db;
|
||||
conn = opened.conn;
|
||||
} finally {
|
||||
await releaseInitLock();
|
||||
}
|
||||
|
||||
for (const schemaQuery of SCHEMA_QUERIES) {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
10
gitnexus/test/fixtures/lang-resolution/cpp-overload-conversion-rank/lib.cpp
vendored
Normal file
10
gitnexus/test/fixtures/lang-resolution/cpp-overload-conversion-rank/lib.cpp
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
#include "lib.h"
|
||||
|
||||
void Service::f(int x) {}
|
||||
void Service::f(double x) {}
|
||||
void Service::g(int x) {}
|
||||
void Service::g(long x) {}
|
||||
void Service::h(int a, int b) {}
|
||||
void Service::h(double a, double b) {}
|
||||
void Service::p(int x) {}
|
||||
void Service::p(double x) {}
|
||||
33
gitnexus/test/fixtures/lang-resolution/cpp-overload-conversion-rank/lib.h
vendored
Normal file
33
gitnexus/test/fixtures/lang-resolution/cpp-overload-conversion-rank/lib.h
vendored
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
#pragma once
|
||||
|
||||
class Service {
|
||||
public:
|
||||
// Variant 1 & 3: f(int) vs f(double)
|
||||
void f(int x);
|
||||
void f(double x);
|
||||
|
||||
// Variant 2: g(int) vs g(long) — both normalize to 'int'
|
||||
void g(int x);
|
||||
void g(long x);
|
||||
|
||||
// Variant 4: multi-arg tied total score
|
||||
void h(int a, int b);
|
||||
void h(double a, double b);
|
||||
|
||||
// Variant 5: char-literal promotion (exercises conversion ranker)
|
||||
void p(int x);
|
||||
void p(double x);
|
||||
|
||||
// Inline: call sites live inside the class scope so the scope-chain
|
||||
// walk finds the Class scope, enabling pickImplicitThisOverload to
|
||||
// resolve overloads against the declaration-side Method nodes (which
|
||||
// carry distinct parameterTypes and graph-node IDs).
|
||||
void run() {
|
||||
f(2.5); // Variant 1: double literal -> f(double) wins (exact > standard)
|
||||
f(42); // Variant 3: int literal -> f(int) wins (exact > standard)
|
||||
g(42); // Variant 2: int/long both normalize to 'int' -> ambiguous
|
||||
h(42, 2.5); // Variant 4: incomparable — neither dominates the other -> ambiguous
|
||||
h('a', 2.5);// Variant 6: asymmetric — h(int,int) better at arg0 (promotion), h(double,double) better at arg1 (exact) -> ambiguous
|
||||
p('a'); // Variant 5: char literal -> p(int) wins via promotion (rank 1 < rank 2)
|
||||
}
|
||||
};
|
||||
330
gitnexus/test/integration/lbug-orphan-sidecar-recovery.test.ts
Normal file
330
gitnexus/test/integration/lbug-orphan-sidecar-recovery.test.ts
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
/**
|
||||
* Integration test: orphan sidecar recovery in doInitLbug.
|
||||
*
|
||||
* Exercises the real `initLbug` → `doInitLbug` path against a native
|
||||
* LadybugDB instance. Creates actual orphan `.shadow` and
|
||||
* `.wal.checkpoint` files on disk (without a main DB file) and confirms
|
||||
* that `initLbug` cleans them up and opens a fresh database successfully.
|
||||
*
|
||||
* This complements the unit-level mocked coverage in
|
||||
* `lbug-checkpoint-lifecycle.test.ts` with a real-filesystem,
|
||||
* real-LadybugDB integration proof required by DoD §2.7.
|
||||
*/
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createTempDir } from '../helpers/test-db.js';
|
||||
|
||||
/**
|
||||
* LadybugDB 0.16.0 has a known Windows-only regression: `Database.close()`
|
||||
* does not release the underlying file lock until the process exits, so any
|
||||
* `closeLbug()` followed by `initLbug(samePath)` in the same process raises
|
||||
* Win32 Error 33. Skip reopen-dependent tests on Windows.
|
||||
*/
|
||||
const itLbugReopen = process.platform === 'win32' ? it.skip : it;
|
||||
|
||||
describe('orphan sidecar recovery — native integration', () => {
|
||||
itLbugReopen(
|
||||
'initLbug recovers when both .shadow and .wal.checkpoint orphan sidecars are present without a main DB file',
|
||||
async () => {
|
||||
const tmp = await createTempDir('gitnexus-lbug-orphan-');
|
||||
const dbPath = path.join(tmp.dbPath, 'lbug');
|
||||
const shadowPath = `${dbPath}.shadow`;
|
||||
const walCheckpointPath = `${dbPath}.wal.checkpoint`;
|
||||
|
||||
try {
|
||||
// Simulate crash-recovery state: orphan sidecars without main DB file
|
||||
await fs.writeFile(shadowPath, 'stale-shadow-data');
|
||||
await fs.writeFile(walCheckpointPath, 'stale-wal-checkpoint-data');
|
||||
|
||||
// Confirm precondition: main DB file does NOT exist, sidecars DO
|
||||
await expect(fs.access(dbPath)).rejects.toThrow();
|
||||
await expect(fs.access(shadowPath)).resolves.toBeUndefined();
|
||||
await expect(fs.access(walCheckpointPath)).resolves.toBeUndefined();
|
||||
|
||||
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
|
||||
// initLbug should clean up orphan sidecars and open a fresh DB
|
||||
await adapter.initLbug(dbPath);
|
||||
|
||||
// Verify the database is functional — execute a simple query
|
||||
const rows = await adapter.executeQuery('RETURN 1 AS result');
|
||||
expect(rows).toEqual([{ result: 1 }]);
|
||||
|
||||
// Verify orphan sidecars were removed
|
||||
await expect(fs.access(shadowPath)).rejects.toThrow();
|
||||
await expect(fs.access(walCheckpointPath)).rejects.toThrow();
|
||||
|
||||
await adapter.closeLbug();
|
||||
} finally {
|
||||
await tmp.cleanup();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
itLbugReopen(
|
||||
'initLbug recovers when only .shadow orphan sidecar is present (partial crash state)',
|
||||
async () => {
|
||||
const tmp = await createTempDir('gitnexus-lbug-orphan-');
|
||||
const dbPath = path.join(tmp.dbPath, 'lbug');
|
||||
const shadowPath = `${dbPath}.shadow`;
|
||||
const walCheckpointPath = `${dbPath}.wal.checkpoint`;
|
||||
|
||||
try {
|
||||
// Only .shadow present — partial crash state
|
||||
await fs.writeFile(shadowPath, 'stale-shadow-data');
|
||||
|
||||
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
await adapter.initLbug(dbPath);
|
||||
|
||||
const rows = await adapter.executeQuery('RETURN 42 AS answer');
|
||||
expect(rows).toEqual([{ answer: 42 }]);
|
||||
|
||||
// .shadow cleaned, .wal.checkpoint was never present
|
||||
await expect(fs.access(shadowPath)).rejects.toThrow();
|
||||
await expect(fs.access(walCheckpointPath)).rejects.toThrow();
|
||||
|
||||
await adapter.closeLbug();
|
||||
} finally {
|
||||
await tmp.cleanup();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
itLbugReopen('initLbug succeeds on a clean path with no orphan sidecars (baseline)', async () => {
|
||||
const tmp = await createTempDir('gitnexus-lbug-orphan-');
|
||||
const dbPath = path.join(tmp.dbPath, 'lbug');
|
||||
|
||||
try {
|
||||
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
await adapter.initLbug(dbPath);
|
||||
|
||||
const rows = await adapter.executeQuery('RETURN 1 AS ok');
|
||||
expect(rows).toEqual([{ ok: 1 }]);
|
||||
|
||||
await adapter.closeLbug();
|
||||
} finally {
|
||||
await tmp.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
itLbugReopen(
|
||||
'initLbug does not attempt orphan cleanup when the main DB file exists',
|
||||
async () => {
|
||||
const tmp = await createTempDir('gitnexus-lbug-orphan-');
|
||||
const dbPath = path.join(tmp.dbPath, 'lbug');
|
||||
// Place a marker file with a non-sidecar extension next to the DB path.
|
||||
// Our cleanup only targets `.shadow` and `.wal.checkpoint` and only when
|
||||
// the main DB is missing. We verify the DB opens normally and the marker
|
||||
// remains — proving that init did not perform broad sibling file cleanup.
|
||||
const markerPath = `${dbPath}.test-marker`;
|
||||
|
||||
try {
|
||||
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
|
||||
// Create a real DB file by initializing normally
|
||||
await adapter.initLbug(dbPath);
|
||||
await adapter.closeLbug();
|
||||
|
||||
// Plant marker file next to the existing DB
|
||||
await fs.writeFile(markerPath, 'should-survive');
|
||||
|
||||
// Re-init: main DB exists, so orphan cleanup should NOT fire
|
||||
await adapter.initLbug(dbPath);
|
||||
|
||||
const rows = await adapter.executeQuery('RETURN 1 AS ok');
|
||||
expect(rows).toEqual([{ ok: 1 }]);
|
||||
|
||||
// Marker file survives — no broad cleanup happened
|
||||
const content = await fs.readFile(markerPath, 'utf-8');
|
||||
expect(content).toBe('should-survive');
|
||||
|
||||
await adapter.closeLbug();
|
||||
} finally {
|
||||
// Clean up marker file — best-effort; may already be absent
|
||||
await fs.unlink(markerPath).catch(() => {
|
||||
/* test cleanup only */
|
||||
});
|
||||
await tmp.cleanup();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Init lock — cross-process ownership contract
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('init lock — single-process ownership contract', () => {
|
||||
itLbugReopen('acquireInitLock succeeds when parent directory does not exist yet', async () => {
|
||||
const tmp = await createTempDir('gitnexus-lbug-orphan-');
|
||||
// Use a nested path whose parent directory does NOT exist
|
||||
const dbPath = path.join(tmp.dbPath, 'nonexistent-subdir', 'lbug');
|
||||
const lockPath = `${dbPath}.init.lock`;
|
||||
|
||||
try {
|
||||
// Precondition: parent directory must not exist
|
||||
await expect(fs.access(path.dirname(dbPath))).rejects.toThrow();
|
||||
|
||||
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
const release = await adapter.acquireInitLock(dbPath);
|
||||
|
||||
// Lock file should exist — parent dir was created automatically
|
||||
const content = await fs.readFile(lockPath, 'utf-8');
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.pid).toBe(process.pid);
|
||||
|
||||
await release();
|
||||
|
||||
// Lock file gone after release
|
||||
await expect(fs.access(lockPath)).rejects.toThrow();
|
||||
} finally {
|
||||
await tmp.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
itLbugReopen('acquireInitLock creates and releases lock file atomically', async () => {
|
||||
const tmp = await createTempDir('gitnexus-lbug-orphan-');
|
||||
const dbPath = path.join(tmp.dbPath, 'lbug');
|
||||
const lockPath = `${dbPath}.init.lock`;
|
||||
|
||||
try {
|
||||
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
const release = await adapter.acquireInitLock(dbPath);
|
||||
|
||||
// Lock file should exist while held
|
||||
const content = await fs.readFile(lockPath, 'utf-8');
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.pid).toBe(process.pid);
|
||||
expect(typeof parsed.ts).toBe('number');
|
||||
|
||||
// Release the lock
|
||||
await release();
|
||||
|
||||
// Lock file should be gone after release
|
||||
await expect(fs.access(lockPath)).rejects.toThrow();
|
||||
} finally {
|
||||
await tmp.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
itLbugReopen('acquireInitLock blocks concurrent acquire from same process', async () => {
|
||||
const tmp = await createTempDir('gitnexus-lbug-orphan-');
|
||||
const dbPath = path.join(tmp.dbPath, 'lbug');
|
||||
|
||||
try {
|
||||
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
|
||||
const release1 = await adapter.acquireInitLock(dbPath);
|
||||
|
||||
// Second acquire should fail because the lock is held by this (alive) process.
|
||||
// The lock retry budget is small enough that this completes quickly.
|
||||
await expect(adapter.acquireInitLock(dbPath)).rejects.toThrow(/unable to acquire init lock/);
|
||||
|
||||
await release1();
|
||||
} finally {
|
||||
await tmp.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
itLbugReopen('acquireInitLock reclaims stale lock from dead process', async () => {
|
||||
const tmp = await createTempDir('gitnexus-lbug-orphan-');
|
||||
const dbPath = path.join(tmp.dbPath, 'lbug');
|
||||
const lockPath = `${dbPath}.init.lock`;
|
||||
|
||||
try {
|
||||
// PID far above any realistic range — guaranteed not running on any OS.
|
||||
const DEAD_PROCESS_PID = 2_000_000_000;
|
||||
await fs.writeFile(
|
||||
lockPath,
|
||||
JSON.stringify({ pid: DEAD_PROCESS_PID, ts: Date.now() - 60_000 }),
|
||||
);
|
||||
|
||||
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
|
||||
// Should break the stale lock and acquire successfully
|
||||
const release = await adapter.acquireInitLock(dbPath);
|
||||
|
||||
// Verify we own the lock now
|
||||
const content = await fs.readFile(lockPath, 'utf-8');
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.pid).toBe(process.pid);
|
||||
|
||||
await release();
|
||||
} finally {
|
||||
await tmp.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
itLbugReopen('release is idempotent — calling twice does not throw', async () => {
|
||||
const tmp = await createTempDir('gitnexus-lbug-orphan-');
|
||||
const dbPath = path.join(tmp.dbPath, 'lbug');
|
||||
|
||||
try {
|
||||
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
const release = await adapter.acquireInitLock(dbPath);
|
||||
|
||||
await release();
|
||||
// Second release — lock file already gone, should not throw
|
||||
await release();
|
||||
} finally {
|
||||
await tmp.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
itLbugReopen(
|
||||
'initLbug cleans up lock file after successful init with orphan sidecars',
|
||||
async () => {
|
||||
const tmp = await createTempDir('gitnexus-lbug-orphan-');
|
||||
const dbPath = path.join(tmp.dbPath, 'lbug');
|
||||
const lockPath = `${dbPath}.init.lock`;
|
||||
|
||||
try {
|
||||
// Plant orphan sidecars
|
||||
await fs.writeFile(`${dbPath}.shadow`, 'stale-shadow');
|
||||
await fs.writeFile(`${dbPath}.wal.checkpoint`, 'stale-wal');
|
||||
|
||||
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
await adapter.initLbug(dbPath);
|
||||
|
||||
// Lock file should be released after init completes
|
||||
await expect(fs.access(lockPath)).rejects.toThrow();
|
||||
|
||||
// DB should be functional
|
||||
const rows = await adapter.executeQuery('RETURN 1 AS ok');
|
||||
expect(rows).toEqual([{ ok: 1 }]);
|
||||
|
||||
await adapter.closeLbug();
|
||||
} finally {
|
||||
await tmp.cleanup();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
itLbugReopen('initLbug cleans up lock file even when DB open fails', async () => {
|
||||
const tmp = await createTempDir('gitnexus-lbug-orphan-');
|
||||
// Use an invalid path that will cause LadybugDB to fail
|
||||
const dbPath = path.join(tmp.dbPath, 'nonexistent-subdir', 'deep', 'lbug');
|
||||
const lockPath = `${dbPath}.init.lock`;
|
||||
|
||||
try {
|
||||
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
|
||||
// initLbug should fail (parent dir structure may cause issues), but
|
||||
// we primarily care that the lock file is cleaned up even on failure.
|
||||
// Use a try/catch since the DB open may or may not fail depending
|
||||
// on how mkdir works.
|
||||
try {
|
||||
await adapter.initLbug(dbPath);
|
||||
await adapter.closeLbug();
|
||||
} catch {
|
||||
// Expected — DB open can fail for various reasons
|
||||
}
|
||||
|
||||
// Lock file should always be released, even on failure
|
||||
await expect(fs.access(lockPath)).rejects.toThrow();
|
||||
} finally {
|
||||
await tmp.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -1762,6 +1762,80 @@ describe('C++ ambiguous integer-width overloads', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// C++ overload resolution: standard-conversion-sequence ranking (#1578)
|
||||
// Disambiguates overloads when exact normalized-type matching cannot,
|
||||
// by scoring each candidate's conversion cost. Exact match (rank 0) wins
|
||||
// over standard conversion (rank 2); same-rank ties still suppress.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('C++ overload resolution — conversion-rank disambiguation (#1578)', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(
|
||||
path.join(FIXTURES, 'cpp-overload-conversion-rank'),
|
||||
() => {},
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
it('f(2.5) resolves to f(double) — exact match beats standard conversion', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const fCalls = calls.filter((c) => c.source === 'run' && c.target === 'f');
|
||||
// Conversion-rank scoring picks f(double) as the unique best:
|
||||
// f(double) is exact match (rank 0), f(int) is standard conversion (rank 2).
|
||||
const fDoubleEdges = fCalls.filter((c) => {
|
||||
const tgt = result.graph.getNode(c.rel.targetId);
|
||||
return tgt?.properties.parameterTypes?.[0] === 'double';
|
||||
});
|
||||
expect(fDoubleEdges.length).toBe(1);
|
||||
});
|
||||
|
||||
it('f(42) resolves to f(int) — exact match beats standard conversion', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const fCalls = calls.filter((c) => c.source === 'run' && c.target === 'f');
|
||||
// f(int) is exact match (rank 0), f(double) is standard conversion (rank 2).
|
||||
const fIntEdges = fCalls.filter((c) => {
|
||||
const tgt = result.graph.getNode(c.rel.targetId);
|
||||
return tgt?.properties.parameterTypes?.[0] === 'int';
|
||||
});
|
||||
expect(fIntEdges.length).toBe(1);
|
||||
});
|
||||
|
||||
it('g(42) emits zero CALLS edges — int/long normalize to same type, ambiguous', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const gCalls = calls.filter((c) => c.source === 'run' && c.target === 'g');
|
||||
// g(int) and g(long) both normalize to parameterTypes=['int'],
|
||||
// so isOverloadAmbiguousAfterNormalization triggers suppression.
|
||||
expect(gCalls.length).toBe(0);
|
||||
});
|
||||
|
||||
it("p('a') resolves to p(int) — char promotion (rank 1) beats char→double conversion (rank 2)", () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const pCalls = calls.filter((c) => c.source === 'run' && c.target === 'p');
|
||||
// p('a'): argType='char'. Exact-type filter misses both p(int) and
|
||||
// p(double), forcing the conversion ranker (step 4b). char→int is an
|
||||
// integral promotion (rank 1), char→double is a standard conversion
|
||||
// (rank 2). p(int) wins with the lower total cost.
|
||||
expect(pCalls.length).toBe(1);
|
||||
const tgt = result.graph.getNode(pCalls[0].rel.targetId);
|
||||
expect(tgt?.properties.parameterTypes?.[0]).toBe('int');
|
||||
});
|
||||
|
||||
it('h(42, 2.5) emits zero CALLS edges — incomparable multi-arg overloads, ambiguous', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const hCalls = calls.filter((c) => c.source === 'run' && c.target === 'h');
|
||||
// h(42, 2.5) + h('a', 2.5): both call sites produce incomparable
|
||||
// pairwise rankings. For h(42, 2.5) with argTypes=['int','double']:
|
||||
// h(int,int): [rank('int','int')=0, rank('double','int')=2]
|
||||
// h(double,double): [rank('int','double')=2, rank('double','double')=0]
|
||||
// h(int,int) better at arg0, h(double,double) better at arg1 → neither
|
||||
// dominates → ambiguous. Same pattern for h('a',2.5).
|
||||
// Contract: zero edges for ALL h() call sites combined (dedup).
|
||||
expect(hCalls.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// U3: anonymous-namespace symbols MUST NOT leak across translation units
|
||||
// (full-pipeline integration test; unit-level coverage exists separately)
|
||||
|
|
|
|||
|
|
@ -175,6 +175,19 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly<Record<string, Readonly
|
|||
'Derived<T>::g_unqualified() -> f() does NOT bind to Base<T>::f',
|
||||
'Derived<T>::g_this() -> this->f() resolves to Base<T>::f (1 edge)',
|
||||
'Derived<T>::g() -> this->f() emits zero CALLS edges when only hidden derived overload is arity-incompatible',
|
||||
// Conversion-rank scoring (#1578) disambiguates `f(int)` vs `f(double)`
|
||||
// by ranking exact match over standard conversion. The legacy DAG has no
|
||||
// conversion-rank scoring; it either picks arbitrarily or leaves the call
|
||||
// unresolved. Scope-resolver-only correctness win.
|
||||
'f(2.5) resolves to f(double) — exact match beats standard conversion',
|
||||
'f(42) resolves to f(int) — exact match beats standard conversion',
|
||||
'g(42) emits zero CALLS edges — int/long normalize to same type, ambiguous',
|
||||
// char-literal promotion exercises the conversion ranker (step 4b).
|
||||
// Legacy DAG has no conversion-rank scoring. Scope-resolver-only.
|
||||
"p('a') resolves to p(int) — char promotion (rank 1) beats char→double conversion (rank 2)",
|
||||
// Multi-arg incomparable overloads: pairwise dominance check finds
|
||||
// neither h(int,int) nor h(double,double) dominates. Scope-resolver-only.
|
||||
'h(42, 2.5) emits zero CALLS edges — incomparable multi-arg overloads, ambiguous',
|
||||
// The legacy DAG path has no inline-namespace same-name ambiguity
|
||||
// detection. When two inline children declare the same name, the
|
||||
// legacy path picks an arbitrary match. The scope-resolver returns
|
||||
|
|
|
|||
|
|
@ -49,17 +49,36 @@ vi.mock('../../src/core/tree-sitter/parser-loader.js', async (importOriginal) =>
|
|||
};
|
||||
});
|
||||
|
||||
// Default to non-registry-primary so existing tests (which use .ts files) are
|
||||
// not affected by the isRegistryPrimary guard added in cross-file-impl. Tests
|
||||
// that verify the skip behavior can override this with mockReturnValue(true).
|
||||
vi.mock('../../src/core/ingestion/registry-primary-flag.js', () => ({
|
||||
isRegistryPrimary: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
import { runCrossFileBindingPropagation } from '../../src/core/ingestion/pipeline-phases/cross-file-impl.js';
|
||||
import { processCalls } from '../../src/core/ingestion/call-processor.js';
|
||||
import { isRegistryPrimary } from '../../src/core/ingestion/registry-primary-flag.js';
|
||||
import { createResolutionContext } from '../../src/core/ingestion/model/resolution-context.js';
|
||||
import { createKnowledgeGraph } from '../../src/core/graph/graph.js';
|
||||
import type { ExportedTypeMap } from '../../src/core/ingestion/call-processor.js';
|
||||
|
||||
const processCallsMock = vi.mocked(processCalls);
|
||||
const isRegistryPrimaryMock = vi.mocked(isRegistryPrimary);
|
||||
|
||||
/**
|
||||
* Index of the `compiledQueryCache` parameter in the `processCalls` signature.
|
||||
* graph(0), files(1), astCache(2), ctx(3), onProgress?(4), exportedTypeMap?(5),
|
||||
* importedBindingsMap?(6), importedReturnTypesMap?(7),
|
||||
* importedRawReturnTypesMap?(8), heritageMap?(9), bindingAccumulator?(10),
|
||||
* compiledQueryCache?(11).
|
||||
*/
|
||||
const COMPILED_QUERY_CACHE_ARG_INDEX = 11;
|
||||
|
||||
describe('runCrossFileBindingPropagation', () => {
|
||||
beforeEach(() => {
|
||||
processCallsMock.mockClear();
|
||||
isRegistryPrimaryMock.mockReturnValue(false); // reset to non-primary before each test
|
||||
});
|
||||
|
||||
it('returns 0 immediately when namedImportMap is empty', async () => {
|
||||
|
|
@ -162,6 +181,103 @@ describe('runCrossFileBindingPropagation', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('passes the same compiledQueryCache Map instance to every processCalls call', async () => {
|
||||
// Verifies that the O(N)→O(1) query-cache fix is correctly wired: the
|
||||
// `compiledQueryCache` created in runCrossFileBindingPropagation is shared
|
||||
// across all processCalls invocations so each language's Parser.Query is
|
||||
// compiled exactly once, not once per file.
|
||||
const graph = createKnowledgeGraph();
|
||||
const ctx = createResolutionContext();
|
||||
|
||||
const exportedTypeMap: ExportedTypeMap = new Map([
|
||||
['upstream.ts', new Map([['User', 'User']])],
|
||||
]);
|
||||
ctx.importMap.set('upstream.ts', new Set());
|
||||
|
||||
const allPaths = ['upstream.ts'];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const file = `downstream${i}.ts`;
|
||||
allPaths.push(file);
|
||||
const bindings = new Map();
|
||||
bindings.set('User', { sourcePath: 'upstream.ts', exportedName: 'User' });
|
||||
ctx.namedImportMap.set(file, bindings);
|
||||
ctx.importMap.set(file, new Set(['upstream.ts']));
|
||||
}
|
||||
|
||||
await runCrossFileBindingPropagation(
|
||||
graph,
|
||||
ctx,
|
||||
exportedTypeMap,
|
||||
new Set(allPaths),
|
||||
allPaths.length,
|
||||
'/repo',
|
||||
Date.now(),
|
||||
() => {},
|
||||
);
|
||||
|
||||
expect(processCallsMock).toHaveBeenCalledTimes(3);
|
||||
|
||||
// Argument index 11 is compiledQueryCache — see COMPILED_QUERY_CACHE_ARG_INDEX.
|
||||
const caches = processCallsMock.mock.calls.map((call) => call[COMPILED_QUERY_CACHE_ARG_INDEX]);
|
||||
// Every call must receive a non-null Map (not undefined).
|
||||
for (const cache of caches) {
|
||||
expect(cache).toBeDefined();
|
||||
expect(cache).toBeInstanceOf(Map);
|
||||
}
|
||||
// All calls share the SAME instance — the whole point of the cache.
|
||||
expect(caches[1]).toBe(caches[0]);
|
||||
expect(caches[2]).toBe(caches[0]);
|
||||
});
|
||||
|
||||
it('emits live onProgress events every 25 files with N/M format', async () => {
|
||||
// Verifies that the frozen-progress-display fix is correctly wired:
|
||||
// onProgress must be called multiple times from the processing loop,
|
||||
// not just once at phase start, so large repos show real movement in
|
||||
// the UI instead of a frozen percentage bar.
|
||||
const graph = createKnowledgeGraph();
|
||||
const ctx = createResolutionContext();
|
||||
|
||||
const exportedTypeMap: ExportedTypeMap = new Map([
|
||||
['upstream.ts', new Map([['User', 'User']])],
|
||||
]);
|
||||
ctx.importMap.set('upstream.ts', new Set());
|
||||
|
||||
const allPaths = ['upstream.ts'];
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const file = `downstream${i}.ts`;
|
||||
allPaths.push(file);
|
||||
const bindings = new Map();
|
||||
bindings.set('User', { sourcePath: 'upstream.ts', exportedName: 'User' });
|
||||
ctx.namedImportMap.set(file, bindings);
|
||||
ctx.importMap.set(file, new Set(['upstream.ts']));
|
||||
}
|
||||
|
||||
const progressMessages: string[] = [];
|
||||
const onProgress = vi.fn((p: { phase: string; percent: number; message: string }) => {
|
||||
progressMessages.push(p.message);
|
||||
});
|
||||
|
||||
await runCrossFileBindingPropagation(
|
||||
graph,
|
||||
ctx,
|
||||
exportedTypeMap,
|
||||
new Set(allPaths),
|
||||
allPaths.length,
|
||||
'/repo',
|
||||
Date.now(),
|
||||
onProgress,
|
||||
);
|
||||
|
||||
// 1 initial call at phase start + 2 loop calls (at 25 and 50 files).
|
||||
expect(onProgress).toHaveBeenCalledTimes(3);
|
||||
|
||||
// Loop messages must carry the "N/M files" format so the UI is informative.
|
||||
const loopMessages = progressMessages.filter((m) => m.match(/\(\d+\/\d+ files\)/));
|
||||
expect(loopMessages).toHaveLength(2);
|
||||
expect(loopMessages[0]).toContain('(25/50 files)');
|
||||
expect(loopMessages[1]).toContain('(50/50 files)');
|
||||
});
|
||||
|
||||
it('caps processing at MAX_CROSS_FILE_REPROCESS (2000)', async () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
const ctx = createResolutionContext();
|
||||
|
|
@ -203,4 +319,47 @@ describe('runCrossFileBindingPropagation', () => {
|
|||
expect(result).toBe(2000);
|
||||
expect(processCallsMock).toHaveBeenCalledTimes(2000);
|
||||
});
|
||||
|
||||
it('skips registry-primary language files without calling processCalls', async () => {
|
||||
// Finding 3: on large TypeScript/C++ repos (registry-primary since v1.6.4+)
|
||||
// cross-file-impl was calling processCalls 595× per candidate only for
|
||||
// processCalls to immediately return (isRegistryPrimary guard inside).
|
||||
// Now cross-file-impl filters them out BEFORE readFileContents so we avoid
|
||||
// the I/O cost and map-building overhead entirely.
|
||||
const graph = createKnowledgeGraph();
|
||||
const ctx = createResolutionContext();
|
||||
|
||||
const exportedTypeMap: ExportedTypeMap = new Map([
|
||||
['upstream.ts', new Map([['User', 'User']])],
|
||||
]);
|
||||
ctx.importMap.set('upstream.ts', new Set());
|
||||
|
||||
const allPaths = ['upstream.ts'];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const file = `downstream${i}.ts`;
|
||||
allPaths.push(file);
|
||||
const bindings = new Map();
|
||||
bindings.set('User', { sourcePath: 'upstream.ts', exportedName: 'User' });
|
||||
ctx.namedImportMap.set(file, bindings);
|
||||
ctx.importMap.set(file, new Set(['upstream.ts']));
|
||||
}
|
||||
|
||||
// Simulate all files being registry-primary (e.g. TypeScript on main branch).
|
||||
isRegistryPrimaryMock.mockReturnValue(true);
|
||||
|
||||
const result = await runCrossFileBindingPropagation(
|
||||
graph,
|
||||
ctx,
|
||||
exportedTypeMap,
|
||||
new Set(allPaths),
|
||||
allPaths.length,
|
||||
'/repo',
|
||||
Date.now(),
|
||||
() => {},
|
||||
);
|
||||
|
||||
// No files are candidates; no processCalls invocations.
|
||||
expect(result).toBe(0);
|
||||
expect(processCallsMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -135,12 +135,15 @@ describe('loadParseCache / saveParseCache (round-trip)', () => {
|
|||
it('round-trips an empty cache', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'gnx-pc-'));
|
||||
try {
|
||||
const fs = await import('fs/promises');
|
||||
const cache: ParseCache = {
|
||||
version: PARSE_CACHE_VERSION,
|
||||
entries: new Map(),
|
||||
usedKeys: new Set(),
|
||||
};
|
||||
await saveParseCache(dir, cache);
|
||||
await expect(fs.access(path.join(dir, 'parse-cache', 'index.json'))).resolves.toBeUndefined();
|
||||
await expect(fs.access(path.join(dir, 'parse-cache.json'))).rejects.toThrow();
|
||||
const loaded = await loadParseCache(dir);
|
||||
expect(loaded.version).toBe(PARSE_CACHE_VERSION);
|
||||
expect(loaded.entries.size).toBe(0);
|
||||
|
|
@ -189,6 +192,60 @@ describe('loadParseCache / saveParseCache (round-trip)', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('loads a legacy single-file cache for backwards compatibility', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'gnx-pc-'));
|
||||
try {
|
||||
const fs = await import('fs/promises');
|
||||
await fs.writeFile(
|
||||
path.join(dir, 'parse-cache.json'),
|
||||
JSON.stringify({
|
||||
version: PARSE_CACHE_VERSION,
|
||||
entries: {
|
||||
legacyChunk: [minimalResult({ fileCount: 7 })],
|
||||
},
|
||||
}),
|
||||
'utf-8',
|
||||
);
|
||||
const loaded = await loadParseCache(dir);
|
||||
expect(loaded.entries.size).toBe(1);
|
||||
expect(loaded.entries.get('legacyChunk')?.[0]?.fileCount).toBe(7);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('skips corrupt or missing shards while loading the sharded cache', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'gnx-pc-'));
|
||||
try {
|
||||
const fs = await import('fs/promises');
|
||||
const cacheDir = path.join(dir, 'parse-cache');
|
||||
const goodKey = 'a'.repeat(64);
|
||||
const missingKey = 'b'.repeat(64);
|
||||
const badKey = 'c'.repeat(64);
|
||||
await fs.mkdir(cacheDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(cacheDir, 'index.json'),
|
||||
JSON.stringify({
|
||||
version: PARSE_CACHE_VERSION,
|
||||
keys: [goodKey, missingKey, badKey],
|
||||
}),
|
||||
'utf-8',
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(cacheDir, `${goodKey}.json`),
|
||||
JSON.stringify([minimalResult({ fileCount: 3 })]),
|
||||
'utf-8',
|
||||
);
|
||||
await fs.writeFile(path.join(cacheDir, `${badKey}.json`), '{not-json', 'utf-8');
|
||||
|
||||
const loaded = await loadParseCache(dir);
|
||||
expect(loaded.entries.size).toBe(1);
|
||||
expect(loaded.entries.get(goodKey)?.[0]?.fileCount).toBe(3);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('round-trips Map and Set values through the JSON replacer/reviver', async () => {
|
||||
// ParsedFile.scopes[*].typeBindings is a ReadonlyMap<string, TypeRef>.
|
||||
// Without the replacer/reviver pair, JSON.stringify collapses Maps to
|
||||
|
|
@ -196,6 +253,7 @@ describe('loadParseCache / saveParseCache (round-trip)', () => {
|
|||
// with "is not iterable". This test pins the round-trip behaviour.
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'gnx-pc-'));
|
||||
try {
|
||||
const fs = await import('fs/promises');
|
||||
const innerMap = new Map<string, string>([
|
||||
['k1', 'v1'],
|
||||
['k2', 'v2'],
|
||||
|
|
@ -218,14 +276,18 @@ describe('loadParseCache / saveParseCache (round-trip)', () => {
|
|||
],
|
||||
});
|
||||
|
||||
const chunkKey = 'd'.repeat(64);
|
||||
const cache: ParseCache = {
|
||||
version: PARSE_CACHE_VERSION,
|
||||
entries: new Map<string, ParseWorkerResult[]>([['chunk-h', [fake]]]),
|
||||
usedKeys: new Set(['chunk-h']),
|
||||
entries: new Map<string, ParseWorkerResult[]>([[chunkKey, [fake]]]),
|
||||
usedKeys: new Set([chunkKey]),
|
||||
};
|
||||
await saveParseCache(dir, cache);
|
||||
const persisted = await fs.readdir(path.join(dir, 'parse-cache'));
|
||||
expect(persisted).toContain('index.json');
|
||||
expect(persisted).toContain(`${chunkKey}.json`);
|
||||
const loaded = await loadParseCache(dir);
|
||||
const reloaded = loaded.entries.get('chunk-h')?.[0];
|
||||
const reloaded = loaded.entries.get(chunkKey)?.[0];
|
||||
expect(reloaded).toBeDefined();
|
||||
const scope = (reloaded as ParseWorkerResult).parsedFiles[0]?.scopes[0] as unknown as {
|
||||
typeBindings?: unknown;
|
||||
|
|
@ -240,4 +302,140 @@ describe('loadParseCache / saveParseCache (round-trip)', () => {
|
|||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('ignores traversal-like and non-hex keys in sharded index.json', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'gnx-pc-'));
|
||||
try {
|
||||
const fs = await import('fs/promises');
|
||||
const cacheDir = path.join(dir, 'parse-cache');
|
||||
await fs.mkdir(cacheDir, { recursive: true });
|
||||
const safeKey = 'e'.repeat(64);
|
||||
await fs.writeFile(
|
||||
path.join(cacheDir, 'index.json'),
|
||||
JSON.stringify({
|
||||
version: PARSE_CACHE_VERSION,
|
||||
keys: ['../evil', '/absolute', 'G'.repeat(64), safeKey],
|
||||
}),
|
||||
'utf-8',
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(cacheDir, `${safeKey}.json`),
|
||||
JSON.stringify([minimalResult({ fileCount: 9 })]),
|
||||
'utf-8',
|
||||
);
|
||||
const loaded = await loadParseCache(dir);
|
||||
expect(loaded.entries.size).toBe(1);
|
||||
expect(loaded.entries.get(safeKey)?.[0]?.fileCount).toBe(9);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('writes one shard file per cache entry (three distinct keys)', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'gnx-pc-'));
|
||||
try {
|
||||
const fs = await import('fs/promises');
|
||||
const k1 = '1'.repeat(64);
|
||||
const k2 = '2'.repeat(64);
|
||||
const k3 = '3'.repeat(64);
|
||||
const cache: ParseCache = {
|
||||
version: PARSE_CACHE_VERSION,
|
||||
entries: new Map<string, ParseWorkerResult[]>([
|
||||
[k1, [minimalResult({ fileCount: 1 })]],
|
||||
[k2, [minimalResult({ fileCount: 2 })]],
|
||||
[k3, [minimalResult({ fileCount: 3 })]],
|
||||
]),
|
||||
usedKeys: new Set([k1, k2, k3]),
|
||||
};
|
||||
await saveParseCache(dir, cache);
|
||||
const cacheDir = path.join(dir, 'parse-cache');
|
||||
const names = await fs.readdir(cacheDir);
|
||||
expect(names).toContain('index.json');
|
||||
expect(names.filter((n) => n.endsWith('.json') && n !== 'index.json').length).toBe(3);
|
||||
const loaded = await loadParseCache(dir);
|
||||
expect(loaded.entries.size).toBe(3);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('returns empty when sharded index version mismatches even if legacy parse-cache.json is valid', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'gnx-pc-'));
|
||||
try {
|
||||
const fs = await import('fs/promises');
|
||||
const cacheDir = path.join(dir, 'parse-cache');
|
||||
await fs.mkdir(cacheDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(cacheDir, 'index.json'),
|
||||
JSON.stringify({ version: 'foreign-sharded-1', keys: [] }),
|
||||
'utf-8',
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(dir, 'parse-cache.json'),
|
||||
JSON.stringify({
|
||||
version: PARSE_CACHE_VERSION,
|
||||
entries: { legacyChunk: [minimalResult({ fileCount: 42 })] },
|
||||
}),
|
||||
'utf-8',
|
||||
);
|
||||
const loaded = await loadParseCache(dir);
|
||||
expect(loaded.entries.size).toBe(0);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('second saveParseCache replaces the first sharded cache', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'gnx-pc-'));
|
||||
try {
|
||||
const fs = await import('fs/promises');
|
||||
const k1 = '4'.repeat(64);
|
||||
const k2 = '5'.repeat(64);
|
||||
await saveParseCache(dir, {
|
||||
version: PARSE_CACHE_VERSION,
|
||||
entries: new Map([[k1, [minimalResult()]]]),
|
||||
usedKeys: new Set([k1]),
|
||||
});
|
||||
await saveParseCache(dir, {
|
||||
version: PARSE_CACHE_VERSION,
|
||||
entries: new Map([[k2, [minimalResult({ fileCount: 99 })]]]),
|
||||
usedKeys: new Set([k2]),
|
||||
});
|
||||
const names = await fs.readdir(path.join(dir, 'parse-cache'));
|
||||
expect(names).not.toContain(`${k1}.json`);
|
||||
expect(names).toContain(`${k2}.json`);
|
||||
const loaded = await loadParseCache(dir);
|
||||
expect(loaded.entries.size).toBe(1);
|
||||
expect(loaded.entries.get(k2)?.[0]?.fileCount).toBe(99);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('removes legacy parse-cache.json after a successful sharded save', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'gnx-pc-'));
|
||||
try {
|
||||
const fs = await import('fs/promises');
|
||||
await fs.writeFile(
|
||||
path.join(dir, 'parse-cache.json'),
|
||||
JSON.stringify({
|
||||
version: PARSE_CACHE_VERSION,
|
||||
entries: { oldLegacy: [minimalResult({ fileCount: 5 })] },
|
||||
}),
|
||||
'utf-8',
|
||||
);
|
||||
const k = '6'.repeat(64);
|
||||
await saveParseCache(dir, {
|
||||
version: PARSE_CACHE_VERSION,
|
||||
entries: new Map([[k, [minimalResult({ fileCount: 6 })]]]),
|
||||
usedKeys: new Set([k]),
|
||||
});
|
||||
await expect(fs.access(path.join(dir, 'parse-cache.json'))).rejects.toThrow();
|
||||
const loaded = await loadParseCache(dir);
|
||||
expect(loaded.entries.get(k)?.[0]?.fileCount).toBe(6);
|
||||
expect(loaded.entries.has('oldLegacy')).toBe(false);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,13 +1,461 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const makeErrnoError = <TCode extends string>(code: TCode, message: string) =>
|
||||
Object.assign(new Error(message), { code });
|
||||
|
||||
/** Stub file handle returned by mocked `fs.open` for the init lock. */
|
||||
const makeOpenMock = () =>
|
||||
vi.fn(async () => ({
|
||||
writeFile: vi.fn(async () => {}),
|
||||
close: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
/** Standard `fs/promises` mock for tests that only need doInitLbug to succeed. */
|
||||
const mockFsForInit = (dbPath: string) => {
|
||||
const ENOENT_ERROR = makeErrnoError(
|
||||
'ENOENT',
|
||||
`ENOENT: no such file or directory, lstat '${dbPath}'`,
|
||||
);
|
||||
vi.doMock('fs/promises', () => ({
|
||||
default: {
|
||||
lstat: vi.fn(async () => {
|
||||
throw ENOENT_ERROR;
|
||||
}),
|
||||
access: vi.fn(async () => {
|
||||
throw ENOENT_ERROR;
|
||||
}),
|
||||
unlink: vi.fn(async () => {}),
|
||||
mkdir: vi.fn(async () => {}),
|
||||
open: makeOpenMock(),
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
describe('lbug adapter CHECKPOINT lifecycle', () => {
|
||||
afterEach(() => {
|
||||
vi.doUnmock('fs/promises');
|
||||
vi.doUnmock('../../src/core/lbug/lbug-config.js');
|
||||
vi.doUnmock('../../src/core/lbug/extension-loader.js');
|
||||
vi.doUnmock('../../src/core/logger.js');
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('removes orphan sidecars when main DB file is missing before opening LadybugDB', async () => {
|
||||
vi.resetModules();
|
||||
|
||||
const dbPath = '/tmp/gitnexus-lbug-orphan-sidecar/lbug';
|
||||
const ENOENT_ERROR = makeErrnoError(
|
||||
'ENOENT',
|
||||
`ENOENT: no such file or directory, access '${dbPath}'`,
|
||||
);
|
||||
const queryResult = { getAll: vi.fn(async () => []), close: vi.fn() };
|
||||
const conn = {
|
||||
query: vi.fn(async () => queryResult),
|
||||
close: vi.fn(async () => {}),
|
||||
};
|
||||
const db = { close: vi.fn(async () => {}) };
|
||||
|
||||
const unlinkMock = vi.fn(async () => {});
|
||||
const accessMock = vi.fn(async () => {
|
||||
throw ENOENT_ERROR;
|
||||
});
|
||||
|
||||
vi.doMock('fs/promises', () => ({
|
||||
default: {
|
||||
lstat: vi.fn(async () => {
|
||||
throw ENOENT_ERROR;
|
||||
}),
|
||||
access: accessMock,
|
||||
unlink: unlinkMock,
|
||||
mkdir: vi.fn(async () => {}),
|
||||
open: makeOpenMock(),
|
||||
},
|
||||
}));
|
||||
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
|
||||
openLbugConnection: vi.fn(async () => ({ db, conn })),
|
||||
closeLbugConnection: vi.fn(async () => {}),
|
||||
isDbBusyError: vi.fn((err: unknown) => String(err).toLowerCase().includes('lock')),
|
||||
isOpenRetryExhausted: vi.fn(() => false),
|
||||
waitForWindowsHandleRelease: vi.fn(async () => true),
|
||||
}));
|
||||
vi.doMock('../../src/core/lbug/extension-loader.js', () => ({
|
||||
extensionManager: {
|
||||
ensure: vi.fn(async () => true),
|
||||
getCapabilities: vi.fn(() => []),
|
||||
reset: vi.fn(),
|
||||
},
|
||||
}));
|
||||
const warnMock = vi.fn();
|
||||
vi.doMock('../../src/core/logger.js', () => ({
|
||||
logger: {
|
||||
warn: warnMock,
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
await adapter.initLbug(dbPath);
|
||||
|
||||
expect(accessMock).toHaveBeenCalledWith(dbPath);
|
||||
// Unlink called for: .shadow sidecar, .wal.checkpoint sidecar, init lock release
|
||||
expect(unlinkMock).toHaveBeenCalledWith(`${dbPath}.shadow`);
|
||||
expect(unlinkMock).toHaveBeenCalledWith(`${dbPath}.wal.checkpoint`);
|
||||
expect(unlinkMock).toHaveBeenCalledWith(`${dbPath}.init.lock`);
|
||||
expect(warnMock).toHaveBeenCalledTimes(2);
|
||||
expect(warnMock).toHaveBeenCalledWith(
|
||||
'GitNexus: removed orphan sidecar lbug.shadow (no main DB file present)',
|
||||
);
|
||||
expect(warnMock).toHaveBeenCalledWith(
|
||||
'GitNexus: removed orphan sidecar lbug.wal.checkpoint (no main DB file present)',
|
||||
);
|
||||
|
||||
await adapter.closeLbug();
|
||||
});
|
||||
|
||||
it('skips orphan sidecar cleanup when db access fails with non-ENOENT errors', async () => {
|
||||
vi.resetModules();
|
||||
|
||||
const dbPath = '/tmp/gitnexus-lbug-orphan-sidecar-eacces/lbug';
|
||||
const ENOENT_ERROR = makeErrnoError(
|
||||
'ENOENT',
|
||||
`ENOENT: no such file or directory, access '${dbPath}'`,
|
||||
);
|
||||
const EACCES_ERROR = makeErrnoError('EACCES', `EACCES: permission denied, access '${dbPath}'`);
|
||||
const queryResult = { getAll: vi.fn(async () => []), close: vi.fn() };
|
||||
const conn = {
|
||||
query: vi.fn(async () => queryResult),
|
||||
close: vi.fn(async () => {}),
|
||||
};
|
||||
const db = { close: vi.fn(async () => {}) };
|
||||
const accessMock = vi.fn(async () => {
|
||||
throw EACCES_ERROR;
|
||||
});
|
||||
const unlinkMock = vi.fn(async () => {});
|
||||
|
||||
vi.doMock('fs/promises', () => ({
|
||||
default: {
|
||||
lstat: vi.fn(async () => {
|
||||
throw ENOENT_ERROR;
|
||||
}),
|
||||
access: accessMock,
|
||||
unlink: unlinkMock,
|
||||
mkdir: vi.fn(async () => {}),
|
||||
open: makeOpenMock(),
|
||||
},
|
||||
}));
|
||||
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
|
||||
openLbugConnection: vi.fn(async () => ({ db, conn })),
|
||||
closeLbugConnection: vi.fn(async () => {}),
|
||||
isDbBusyError: vi.fn((err: unknown) => String(err).toLowerCase().includes('lock')),
|
||||
isOpenRetryExhausted: vi.fn(() => false),
|
||||
waitForWindowsHandleRelease: vi.fn(async () => true),
|
||||
}));
|
||||
vi.doMock('../../src/core/lbug/extension-loader.js', () => ({
|
||||
extensionManager: {
|
||||
ensure: vi.fn(async () => true),
|
||||
getCapabilities: vi.fn(() => []),
|
||||
reset: vi.fn(),
|
||||
},
|
||||
}));
|
||||
const warnMock = vi.fn();
|
||||
vi.doMock('../../src/core/logger.js', () => ({
|
||||
logger: {
|
||||
warn: warnMock,
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
await adapter.initLbug(dbPath);
|
||||
|
||||
expect(accessMock).toHaveBeenCalledWith(dbPath);
|
||||
// Only the init lock release calls unlink — sidecar cleanup was skipped
|
||||
expect(unlinkMock).toHaveBeenCalledTimes(1);
|
||||
expect(unlinkMock).toHaveBeenCalledWith(`${dbPath}.init.lock`);
|
||||
expect(warnMock).toHaveBeenCalledTimes(1);
|
||||
expect(warnMock.mock.calls[0]?.[0]).toContain(
|
||||
'GitNexus: unable to verify main DB file before orphan sidecar cleanup (EACCES); skipping cleanup:',
|
||||
);
|
||||
|
||||
await adapter.closeLbug();
|
||||
});
|
||||
|
||||
it('does not remove sidecars when main db file is present', async () => {
|
||||
vi.resetModules();
|
||||
|
||||
const dbPath = '/tmp/gitnexus-lbug-present/lbug';
|
||||
const ENOENT_ERROR = makeErrnoError(
|
||||
'ENOENT',
|
||||
`ENOENT: no such file or directory, access '${dbPath}'`,
|
||||
);
|
||||
const queryResult = { getAll: vi.fn(async () => []), close: vi.fn() };
|
||||
const conn = {
|
||||
query: vi.fn(async () => queryResult),
|
||||
close: vi.fn(async () => {}),
|
||||
};
|
||||
const db = { close: vi.fn(async () => {}) };
|
||||
const accessMock = vi.fn(async () => {});
|
||||
const unlinkMock = vi.fn(async () => {});
|
||||
|
||||
vi.doMock('fs/promises', () => ({
|
||||
default: {
|
||||
lstat: vi.fn(async () => {
|
||||
throw ENOENT_ERROR;
|
||||
}),
|
||||
access: accessMock,
|
||||
unlink: unlinkMock,
|
||||
mkdir: vi.fn(async () => {}),
|
||||
open: makeOpenMock(),
|
||||
},
|
||||
}));
|
||||
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
|
||||
openLbugConnection: vi.fn(async () => ({ db, conn })),
|
||||
closeLbugConnection: vi.fn(async () => {}),
|
||||
isDbBusyError: vi.fn((err: unknown) => String(err).toLowerCase().includes('lock')),
|
||||
isOpenRetryExhausted: vi.fn(() => false),
|
||||
waitForWindowsHandleRelease: vi.fn(async () => true),
|
||||
}));
|
||||
vi.doMock('../../src/core/lbug/extension-loader.js', () => ({
|
||||
extensionManager: {
|
||||
ensure: vi.fn(async () => true),
|
||||
getCapabilities: vi.fn(() => []),
|
||||
reset: vi.fn(),
|
||||
},
|
||||
}));
|
||||
const warnMock = vi.fn();
|
||||
vi.doMock('../../src/core/logger.js', () => ({
|
||||
logger: {
|
||||
warn: warnMock,
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
await adapter.initLbug(dbPath);
|
||||
|
||||
expect(accessMock).toHaveBeenCalledWith(dbPath);
|
||||
// Only the init lock release calls unlink — no sidecar cleanup needed
|
||||
expect(unlinkMock).toHaveBeenCalledTimes(1);
|
||||
expect(unlinkMock).toHaveBeenCalledWith(`${dbPath}.init.lock`);
|
||||
expect(warnMock).not.toHaveBeenCalled();
|
||||
|
||||
await adapter.closeLbug();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
code: 'EPERM',
|
||||
message: 'operation not permitted',
|
||||
dbPath: '/tmp/gitnexus-lbug-lstat-eperm/lbug',
|
||||
},
|
||||
{
|
||||
code: 'EACCES',
|
||||
message: 'permission denied',
|
||||
dbPath: '/tmp/gitnexus-lbug-lstat-eacces/lbug',
|
||||
},
|
||||
])('throws when db path lstat fails with non-ENOENT %s', async ({ code, message, dbPath }) => {
|
||||
vi.resetModules();
|
||||
|
||||
const LSTAT_ERROR = makeErrnoError(code, `${code}: ${message}, lstat '${dbPath}'`);
|
||||
const accessMock = vi.fn(async () => {});
|
||||
const unlinkMock = vi.fn(async () => {});
|
||||
|
||||
vi.doMock('fs/promises', () => ({
|
||||
default: {
|
||||
lstat: vi.fn(async () => {
|
||||
throw LSTAT_ERROR;
|
||||
}),
|
||||
access: accessMock,
|
||||
unlink: unlinkMock,
|
||||
mkdir: vi.fn(async () => {}),
|
||||
open: makeOpenMock(),
|
||||
},
|
||||
}));
|
||||
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
|
||||
openLbugConnection: vi.fn(async () => {
|
||||
throw new Error('should not be called');
|
||||
}),
|
||||
closeLbugConnection: vi.fn(async () => {}),
|
||||
isDbBusyError: vi.fn((err: unknown) => String(err).toLowerCase().includes('lock')),
|
||||
isOpenRetryExhausted: vi.fn(() => false),
|
||||
waitForWindowsHandleRelease: vi.fn(async () => true),
|
||||
}));
|
||||
vi.doMock('../../src/core/lbug/extension-loader.js', () => ({
|
||||
extensionManager: {
|
||||
ensure: vi.fn(async () => true),
|
||||
getCapabilities: vi.fn(() => []),
|
||||
reset: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.doMock('../../src/core/logger.js', () => ({
|
||||
logger: {
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
await expect(adapter.initLbug(dbPath)).rejects.toThrow(new RegExp(message, 'i'));
|
||||
expect(accessMock).not.toHaveBeenCalled();
|
||||
expect(unlinkMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles partial orphan sidecar state and removes only present sidecars', async () => {
|
||||
vi.resetModules();
|
||||
|
||||
const dbPath = '/tmp/gitnexus-lbug-partial-sidecar/lbug';
|
||||
const ENOENT_ERROR = makeErrnoError(
|
||||
'ENOENT',
|
||||
`ENOENT: no such file or directory, access '${dbPath}'`,
|
||||
);
|
||||
const queryResult = { getAll: vi.fn(async () => []), close: vi.fn() };
|
||||
const conn = {
|
||||
query: vi.fn(async () => queryResult),
|
||||
close: vi.fn(async () => {}),
|
||||
};
|
||||
const db = { close: vi.fn(async () => {}) };
|
||||
const accessMock = vi.fn(async () => {
|
||||
throw ENOENT_ERROR;
|
||||
});
|
||||
const unlinkMock = vi.fn(async (target: string) => {
|
||||
if (target.endsWith('.shadow')) throw ENOENT_ERROR;
|
||||
});
|
||||
|
||||
vi.doMock('fs/promises', () => ({
|
||||
default: {
|
||||
lstat: vi.fn(async () => {
|
||||
throw ENOENT_ERROR;
|
||||
}),
|
||||
access: accessMock,
|
||||
unlink: unlinkMock,
|
||||
mkdir: vi.fn(async () => {}),
|
||||
open: makeOpenMock(),
|
||||
},
|
||||
}));
|
||||
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
|
||||
openLbugConnection: vi.fn(async () => ({ db, conn })),
|
||||
closeLbugConnection: vi.fn(async () => {}),
|
||||
isDbBusyError: vi.fn((err: unknown) => String(err).toLowerCase().includes('lock')),
|
||||
isOpenRetryExhausted: vi.fn(() => false),
|
||||
waitForWindowsHandleRelease: vi.fn(async () => true),
|
||||
}));
|
||||
vi.doMock('../../src/core/lbug/extension-loader.js', () => ({
|
||||
extensionManager: {
|
||||
ensure: vi.fn(async () => true),
|
||||
getCapabilities: vi.fn(() => []),
|
||||
reset: vi.fn(),
|
||||
},
|
||||
}));
|
||||
const warnMock = vi.fn();
|
||||
vi.doMock('../../src/core/logger.js', () => ({
|
||||
logger: {
|
||||
warn: warnMock,
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
await adapter.initLbug(dbPath);
|
||||
|
||||
expect(unlinkMock).toHaveBeenCalledWith(`${dbPath}.shadow`);
|
||||
expect(unlinkMock).toHaveBeenCalledWith(`${dbPath}.wal.checkpoint`);
|
||||
expect(warnMock).toHaveBeenCalledTimes(1);
|
||||
expect(warnMock).toHaveBeenCalledWith(
|
||||
'GitNexus: removed orphan sidecar lbug.wal.checkpoint (no main DB file present)',
|
||||
);
|
||||
|
||||
await adapter.closeLbug();
|
||||
});
|
||||
|
||||
it('proceeds to openLbugConnection when orphan sidecar unlink fails', async () => {
|
||||
vi.resetModules();
|
||||
|
||||
const dbPath = '/tmp/gitnexus-lbug-sidecar-unlink-fail/lbug';
|
||||
const ENOENT_ERROR = makeErrnoError(
|
||||
'ENOENT',
|
||||
`ENOENT: no such file or directory, access '${dbPath}'`,
|
||||
);
|
||||
const EPERM_ERROR = makeErrnoError(
|
||||
'EPERM',
|
||||
`EPERM: operation not permitted, unlink '${dbPath}.shadow'`,
|
||||
);
|
||||
const queryResult = { getAll: vi.fn(async () => []), close: vi.fn() };
|
||||
const conn = {
|
||||
query: vi.fn(async () => queryResult),
|
||||
close: vi.fn(async () => {}),
|
||||
};
|
||||
const db = { close: vi.fn(async () => {}) };
|
||||
const accessMock = vi.fn(async () => {
|
||||
throw ENOENT_ERROR;
|
||||
});
|
||||
const unlinkMock = vi.fn(async () => {
|
||||
throw EPERM_ERROR;
|
||||
});
|
||||
|
||||
vi.doMock('fs/promises', () => ({
|
||||
default: {
|
||||
lstat: vi.fn(async () => {
|
||||
throw ENOENT_ERROR;
|
||||
}),
|
||||
access: accessMock,
|
||||
unlink: unlinkMock,
|
||||
mkdir: vi.fn(async () => {}),
|
||||
open: makeOpenMock(),
|
||||
},
|
||||
}));
|
||||
const openLbugConnectionMock = vi.fn(async () => ({ db, conn }));
|
||||
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
|
||||
openLbugConnection: openLbugConnectionMock,
|
||||
closeLbugConnection: vi.fn(async () => {}),
|
||||
isDbBusyError: vi.fn((err: unknown) => String(err).toLowerCase().includes('lock')),
|
||||
isOpenRetryExhausted: vi.fn(() => false),
|
||||
waitForWindowsHandleRelease: vi.fn(async () => true),
|
||||
}));
|
||||
vi.doMock('../../src/core/lbug/extension-loader.js', () => ({
|
||||
extensionManager: {
|
||||
ensure: vi.fn(async () => true),
|
||||
getCapabilities: vi.fn(() => []),
|
||||
reset: vi.fn(),
|
||||
},
|
||||
}));
|
||||
const warnMock = vi.fn();
|
||||
vi.doMock('../../src/core/logger.js', () => ({
|
||||
logger: {
|
||||
warn: warnMock,
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
await adapter.initLbug(dbPath);
|
||||
|
||||
expect(unlinkMock).toHaveBeenCalledTimes(3);
|
||||
expect(warnMock).toHaveBeenCalledTimes(3);
|
||||
expect(warnMock.mock.calls[0]?.[0]).toContain(
|
||||
'GitNexus: failed to remove orphan sidecar lbug.shadow (EPERM) while main DB file is missing; LadybugDB open may still fail:',
|
||||
);
|
||||
expect(warnMock.mock.calls[1]?.[0]).toContain(
|
||||
'GitNexus: failed to remove orphan sidecar lbug.wal.checkpoint (EPERM) while main DB file is missing; LadybugDB open may still fail:',
|
||||
);
|
||||
expect(warnMock.mock.calls[2]?.[0]).toContain('GitNexus: failed to release init lock (EPERM)');
|
||||
expect(openLbugConnectionMock).toHaveBeenCalledWith(expect.anything(), dbPath);
|
||||
|
||||
await adapter.closeLbug();
|
||||
});
|
||||
|
||||
it('drains and closes CHECKPOINT result before closing connection and database handles', async () => {
|
||||
vi.resetModules();
|
||||
|
||||
|
|
@ -43,6 +491,7 @@ describe('lbug adapter CHECKPOINT lifecycle', () => {
|
|||
}),
|
||||
};
|
||||
|
||||
mockFsForInit('/tmp/gitnexus-lbug-checkpoint-lifecycle/lbug');
|
||||
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
|
||||
openLbugConnection: vi.fn(async () => ({ db, conn })),
|
||||
closeLbugConnection: vi.fn(async () => {}),
|
||||
|
|
@ -104,6 +553,7 @@ describe('lbug adapter CHECKPOINT lifecycle', () => {
|
|||
close: vi.fn(async () => {}),
|
||||
};
|
||||
|
||||
mockFsForInit('/tmp/gitnexus-lbug-query-lifecycle/lbug');
|
||||
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
|
||||
openLbugConnection: vi.fn(async () => ({ db, conn })),
|
||||
closeLbugConnection: vi.fn(async () => {}),
|
||||
|
|
@ -158,6 +608,7 @@ describe('lbug adapter CHECKPOINT lifecycle', () => {
|
|||
close: vi.fn(async () => {}),
|
||||
};
|
||||
|
||||
mockFsForInit('/tmp/gitnexus-lbug-sync-close-lifecycle/lbug');
|
||||
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
|
||||
openLbugConnection: vi.fn(async () => ({ db, conn })),
|
||||
closeLbugConnection: vi.fn(async () => {}),
|
||||
|
|
@ -223,6 +674,7 @@ describe('lbug adapter CHECKPOINT lifecycle', () => {
|
|||
close: vi.fn(async () => {}),
|
||||
};
|
||||
|
||||
mockFsForInit('/tmp/gitnexus-lbug-array-error-lifecycle/lbug');
|
||||
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
|
||||
openLbugConnection: vi.fn(async () => ({ db, conn })),
|
||||
closeLbugConnection: vi.fn(async () => {}),
|
||||
|
|
@ -303,6 +755,7 @@ describe('lbug adapter CHECKPOINT lifecycle', () => {
|
|||
close: vi.fn(async () => {}),
|
||||
};
|
||||
|
||||
mockFsForInit('/tmp/gitnexus-lbug-stream-lifecycle/lbug');
|
||||
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
|
||||
openLbugConnection: vi.fn(async () => ({ db, conn })),
|
||||
closeLbugConnection: vi.fn(async () => {}),
|
||||
|
|
@ -383,6 +836,7 @@ describe('lbug adapter CHECKPOINT lifecycle', () => {
|
|||
close: vi.fn(async () => {}),
|
||||
};
|
||||
|
||||
mockFsForInit('/tmp/gitnexus-lbug-stream-error-lifecycle/lbug');
|
||||
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
|
||||
openLbugConnection: vi.fn(async () => ({ db, conn })),
|
||||
closeLbugConnection: vi.fn(async () => {}),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue