Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d27ce26bb | ||
|
|
ee39f5b448 | ||
|
|
a462febad6 | ||
|
|
72f854f7de | ||
|
|
a9936a9b97 | ||
|
|
fadbb32c2f | ||
|
|
28a3d99a92 | ||
|
|
6ffef3ab99 | ||
|
|
f6a9d5f5cc | ||
|
|
c4782e2db9 | ||
|
|
48b01fb781 | ||
|
|
ed4b738435 | ||
|
|
11affdd797 | ||
|
|
a96455afca | ||
|
|
8170c7913c | ||
|
|
e6924f31a7 | ||
|
|
658f56be7a | ||
|
|
298c0674d5 | ||
|
|
8df93ee259 | ||
|
|
de6904cef8 | ||
|
|
3f5d21c530 | ||
|
|
fcc4319ab9 | ||
|
|
c61ceff07b | ||
|
|
2cc66c982e |
@@ -1,5 +1,5 @@
|
||||
name: Setup GitNexus Web
|
||||
description: Setup Node.js 22, build gitnexus-shared, install web dependencies
|
||||
description: Setup Node.js 20.19+ (vite 7 floor), build gitnexus-shared, install web dependencies
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
@@ -7,7 +7,9 @@ runs:
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
# Vite 7 requires Node ^20.19.0 || >=22.12.0 (require(esm) support).
|
||||
node-version: 22
|
||||
# Pin explicitly so we don't depend on the floating "20" alias resolving
|
||||
# to a high enough patch version on every runner image.
|
||||
node-version: '20.19.0'
|
||||
cache: npm
|
||||
cache-dependency-path: gitnexus-web/package-lock.json
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: Setup GitNexus
|
||||
description: Setup Node.js 22, install dependencies, and optionally build
|
||||
description: Setup Node.js 20, install dependencies, and optionally build
|
||||
|
||||
inputs:
|
||||
build:
|
||||
@@ -12,7 +12,7 @@ runs:
|
||||
steps:
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: gitnexus/package-lock.json
|
||||
|
||||
|
||||
@@ -7,8 +7,6 @@ updates:
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
cooldown:
|
||||
default-days: 7
|
||||
open-pull-requests-limit: 5
|
||||
commit-message:
|
||||
prefix: chore
|
||||
@@ -17,36 +15,6 @@ updates:
|
||||
- dependencies
|
||||
- ci
|
||||
|
||||
# Keep pinned Docker base-image digests current for the root Dockerfiles.
|
||||
- package-ecosystem: docker
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
cooldown:
|
||||
default-days: 7
|
||||
open-pull-requests-limit: 5
|
||||
commit-message:
|
||||
prefix: chore(deps)
|
||||
include: scope
|
||||
labels:
|
||||
- dependencies
|
||||
- ci
|
||||
|
||||
# Keep the nested test-image Docker base digest current as well.
|
||||
- package-ecosystem: docker
|
||||
directory: /gitnexus
|
||||
schedule:
|
||||
interval: weekly
|
||||
cooldown:
|
||||
default-days: 7
|
||||
open-pull-requests-limit: 5
|
||||
commit-message:
|
||||
prefix: chore(deps)
|
||||
include: scope
|
||||
labels:
|
||||
- dependencies
|
||||
- ci
|
||||
|
||||
# Gitnexus npm deps — tree-sitter grammars checked daily so we catch
|
||||
# new releases that unblock the tree-sitter 0.25 upgrade ASAP. Grammars
|
||||
# are grouped so lockstep bumps produce a single PR. The tree-sitter
|
||||
@@ -57,11 +25,6 @@ updates:
|
||||
directory: /gitnexus
|
||||
schedule:
|
||||
interval: daily
|
||||
cooldown:
|
||||
default-days: 7
|
||||
semver-major-days: 30
|
||||
semver-minor-days: 7
|
||||
semver-patch-days: 3
|
||||
open-pull-requests-limit: 10
|
||||
commit-message:
|
||||
prefix: chore(deps)
|
||||
@@ -91,11 +54,6 @@ updates:
|
||||
directory: /gitnexus-web
|
||||
schedule:
|
||||
interval: weekly
|
||||
cooldown:
|
||||
default-days: 7
|
||||
semver-major-days: 30
|
||||
semver-minor-days: 7
|
||||
semver-patch-days: 3
|
||||
open-pull-requests-limit: 5
|
||||
commit-message:
|
||||
prefix: chore(deps)
|
||||
@@ -109,11 +67,6 @@ updates:
|
||||
directory: /gitnexus-shared
|
||||
schedule:
|
||||
interval: weekly
|
||||
cooldown:
|
||||
default-days: 7
|
||||
semver-major-days: 30
|
||||
semver-minor-days: 7
|
||||
semver-patch-days: 3
|
||||
open-pull-requests-limit: 5
|
||||
commit-message:
|
||||
prefix: chore(deps)
|
||||
|
||||
@@ -3,9 +3,6 @@ name: E2E Tests
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check-changes:
|
||||
name: Check web module changes
|
||||
|
||||
@@ -3,9 +3,6 @@ name: Quality Checks
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
format:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -14,7 +11,7 @@ jobs:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: package-lock.json
|
||||
- run: npm ci
|
||||
@@ -27,7 +24,7 @@ jobs:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: package-lock.json
|
||||
- run: npm ci
|
||||
|
||||
@@ -95,33 +95,31 @@ jobs:
|
||||
|
||||
# Validate PR number is a positive integer (artifact comes from
|
||||
# untrusted fork code, so treat contents defensively).
|
||||
PR_NUM=$(tr -d '[:space:]' < "$DIR/pr_number")
|
||||
PR_NUM=$(cat "$DIR/pr_number" | tr -d '[:space:]')
|
||||
if ! [[ "$PR_NUM" =~ ^[0-9]+$ ]]; then
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
echo "::error::Invalid PR number in artifact: '$PR_NUM'"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
echo "pr_number=$PR_NUM" >> "$GITHUB_OUTPUT"
|
||||
# Validate job-result strings against known GitHub Actions values.
|
||||
# Artifact contents come from the PR workflow (potentially untrusted
|
||||
# fork code), so we whitelist to prevent newline injection into
|
||||
# GITHUB_OUTPUT.
|
||||
validate_result() {
|
||||
local val
|
||||
val=$(tr -d '[:space:]' < "$1")
|
||||
val=$(cat "$1" | tr -d '[:space:]')
|
||||
case "$val" in
|
||||
success|failure|cancelled|skipped) echo "$val" ;;
|
||||
*) echo "unknown" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
{
|
||||
echo "skip=false"
|
||||
echo "pr_number=$PR_NUM"
|
||||
echo "quality=$(validate_result "$DIR/quality_result")"
|
||||
echo "tests=$(validate_result "$DIR/tests_result")"
|
||||
echo "e2e=$(validate_result "$DIR/e2e_result")"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
echo "quality=$(validate_result "$DIR/quality_result")" >> "$GITHUB_OUTPUT"
|
||||
echo "tests=$(validate_result "$DIR/tests_result")" >> "$GITHUB_OUTPUT"
|
||||
echo "e2e=$(validate_result "$DIR/e2e_result")" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout (for vitest config)
|
||||
if: steps.meta.outputs.skip != 'true'
|
||||
@@ -281,17 +279,14 @@ jobs:
|
||||
fi
|
||||
}
|
||||
|
||||
# `_` placeholder for the suite-count column — positional
|
||||
# readability for sum_results' 6-field output, but the value
|
||||
# isn't surfaced in the report (suites are tracked per-test
|
||||
# framework, not as a top-line metric).
|
||||
read -r CLI_T CLI_P CLI_F CLI_S _ CLI_D <<< "$(sum_results "$RESULTS_FILE")"
|
||||
read -r WEB_T WEB_P WEB_F WEB_S _ WEB_D <<< "$(sum_results "$WEB_RESULTS_FILE")"
|
||||
read CLI_T CLI_P CLI_F CLI_S CLI_SU CLI_D <<< "$(sum_results "$RESULTS_FILE")"
|
||||
read WEB_T WEB_P WEB_F WEB_S WEB_SU WEB_D <<< "$(sum_results "$WEB_RESULTS_FILE")"
|
||||
|
||||
TOTAL=$((CLI_T + WEB_T))
|
||||
PASSED=$((CLI_P + WEB_P))
|
||||
FAILED=$((CLI_F + WEB_F))
|
||||
SKIPPED=$((CLI_S + WEB_S))
|
||||
SUITES=$((CLI_SU + WEB_SU))
|
||||
DURATION=$((CLI_D > WEB_D ? CLI_D : WEB_D))
|
||||
|
||||
# ── Status helpers ──
|
||||
|
||||
@@ -28,9 +28,6 @@ name: Scope Resolution Parity
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
discover:
|
||||
name: Discover migrated languages
|
||||
|
||||
@@ -3,9 +3,6 @@ name: Tests
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
name: ubuntu / coverage
|
||||
|
||||
@@ -6,9 +6,6 @@ on:
|
||||
paths-ignore: ['**.md', 'docs/**', 'LICENSE']
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention".
|
||||
# Hardcoded `CI-` prefix (not `${{ github.workflow }}`) because this workflow is
|
||||
# invoked as a reusable workflow from publish.yml and release-candidate.yml. In
|
||||
|
||||
@@ -19,9 +19,6 @@ on:
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention".
|
||||
# Serialize per-PR/issue to avoid racing comments.
|
||||
concurrency:
|
||||
|
||||
@@ -17,9 +17,6 @@ on:
|
||||
# already-merged code without waiting for the next PR.
|
||||
- cron: '0 6 * * 1'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
@@ -10,9 +10,6 @@ on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
@@ -26,9 +26,6 @@ on:
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention".
|
||||
# Tag refs are unique per release, so distinct tags run in parallel.
|
||||
# Re-pushes of the same tag serialize. cancel-in-progress: false — never cancel a publish mid-flight.
|
||||
|
||||
@@ -12,9 +12,6 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
@@ -1,595 +0,0 @@
|
||||
name: PR Autofix (apply)
|
||||
|
||||
# CHATOPS HALF of the autofix pipeline.
|
||||
#
|
||||
# Triggered when a contributor comments `/autofix` on a PR. Validates
|
||||
# permission, locates the most recent successful `pr-autofix.yml`
|
||||
# artifact for the PR's current head SHA, applies the patch to the PR
|
||||
# head, and pushes a commit back to the PR branch.
|
||||
#
|
||||
# This workflow runs from the default branch's copy of the file
|
||||
# regardless of where the comment originates -- that's the trust
|
||||
# anchor. Comment body and author login are untrusted; both flow
|
||||
# through env vars and pattern-matched, never interpolated into shell.
|
||||
#
|
||||
# Fork PR support: `git push` with the GITHUB_TOKEN succeeds against
|
||||
# fork branches only when the contributor enabled "Allow edits by
|
||||
# maintainers" on the PR (the default). When they disabled it, we
|
||||
# fail loud with a 👎 reaction and an explanation comment.
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
concurrency:
|
||||
# Per-PR scope. issue_comment events expose `github.event.issue.number`
|
||||
# for both PR and Issue comments; the `pull_request != null` guard on
|
||||
# the job ensures we only run on PRs, so this number is the PR number.
|
||||
# cancel-in-progress: false — a second `/autofix` should wait for the
|
||||
# first to finish (idempotency check on the second invocation handles
|
||||
# the no-op case).
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.issue.number }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
apply:
|
||||
name: apply-autofix
|
||||
# Pre-filter at the workflow level so non-PR comments and unrelated
|
||||
# comments don't even spawn a runner. The job-level body re-check
|
||||
# below (Step 1) is the strict gate.
|
||||
if: >-
|
||||
github.event.issue.pull_request != null
|
||||
&& startsWith(github.event.comment.body, '/autofix')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
# React on the triggering comment + post reply comments.
|
||||
pull-requests: write
|
||||
# Push the apply commit to the PR head branch.
|
||||
contents: write
|
||||
# Required by actions/download-artifact to fetch artifacts produced
|
||||
# by a different workflow run.
|
||||
actions: read
|
||||
steps:
|
||||
- name: Validate comment body precisely
|
||||
id: body
|
||||
env:
|
||||
BODY: ${{ github.event.comment.body }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Whole-line, case-sensitive match: `^/autofix\s*$`. The
|
||||
# workflow-level startsWith guard is coarse — `please don't
|
||||
# /autofix this code` would pass that filter but fail this one.
|
||||
# We exit silently (no reaction) on body mismatch so quoted
|
||||
# text in unrelated discussions doesn't get a visible response.
|
||||
if [[ ! "${BODY}" =~ ^/autofix[[:space:]]*$ ]]; then
|
||||
echo "Body did not match strict /autofix regex — exiting silently."
|
||||
echo "match=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "match=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Validate commenter permission
|
||||
id: perm
|
||||
if: steps.body.outputs.match == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
COMMENTER: ${{ github.event.comment.user.login }}
|
||||
PR_AUTHOR: ${{ github.event.issue.user.login }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Retry wrapper for transient 5xx / 429 / network blips.
|
||||
# Mirrors the helper in pr-autofix-publish.yml. Used on
|
||||
# idempotent GETs only; reactions/comment-POSTs are NOT
|
||||
# wrapped (retrying a POST would dupe the resource).
|
||||
gh_retry() {
|
||||
local n=0 max=3
|
||||
while true; do
|
||||
if gh "$@"; then return 0; fi
|
||||
n=$((n+1))
|
||||
if [ "$n" -ge "$max" ]; then return 1; fi
|
||||
sleep $((n * 2))
|
||||
done
|
||||
}
|
||||
|
||||
# Allowlist the commenter login before it flows into a URL.
|
||||
# GitHub usernames: alphanumeric + dashes, max 39 chars.
|
||||
if ! [[ "${COMMENTER}" =~ ^[A-Za-z0-9-]{1,39}$ ]]; then
|
||||
echo "::error::Invalid commenter login format: $(printf '%q' "${COMMENTER}")"
|
||||
echo "allowed=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Self-comparison: PR author can always /autofix their own PR.
|
||||
if [ "${COMMENTER}" = "${PR_AUTHOR}" ]; then
|
||||
echo "Commenter is PR author — granting access."
|
||||
echo "allowed=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Repo permission lookup. admin/write/maintain are sufficient.
|
||||
# Distinguish API failure (5xx, 429, network) from genuine
|
||||
# permission denial (404 = not a collaborator). Conflating them
|
||||
# would silently refuse a legitimate maintainer with a public
|
||||
# 👎 every time GitHub blips. gh_retry handles transient blips;
|
||||
# the stderr-grep distinguishes 404 from persistent failure.
|
||||
perm_stderr=$(mktemp)
|
||||
if permission=$(gh_retry api "repos/${GH_REPO}/collaborators/${COMMENTER}/permission" \
|
||||
--jq '.permission' 2>"$perm_stderr"); then
|
||||
echo "Commenter permission: ${permission}"
|
||||
case "${permission}" in
|
||||
admin|write|maintain)
|
||||
echo "allowed=true" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
*)
|
||||
echo "allowed=false" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
esac
|
||||
else
|
||||
err=$(cat "$perm_stderr")
|
||||
echo "Permission lookup stderr: ${err}" >&2
|
||||
# 404 (not a collaborator) is a genuine deny.
|
||||
# Anything else is a transient API/network failure.
|
||||
if grep -qE "HTTP 404|Not Found" "$perm_stderr"; then
|
||||
echo "allowed=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "::error::Permission lookup failed transiently — refusing to act."
|
||||
echo "allowed=api-failed" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: React 😕 on transient permission-API failure
|
||||
if: steps.body.outputs.match == 'true' && steps.perm.outputs.allowed == 'api-failed'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
COMMENT_ID: ${{ github.event.comment.id }}
|
||||
PR: ${{ github.event.issue.number }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
|
||||
-f content="confused" >/dev/null
|
||||
gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
|
||||
-f body="⚠️ Couldn't verify your repo permission (transient GitHub API failure). Please comment \`/autofix\` again. ([apply run](https://github.com/${GH_REPO}/actions/runs/${RUN_ID}))" \
|
||||
>/dev/null
|
||||
exit 1
|
||||
|
||||
- name: React 👎 on permission denial
|
||||
if: steps.body.outputs.match == 'true' && steps.perm.outputs.allowed == 'false'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
COMMENT_ID: ${{ github.event.comment.id }}
|
||||
PR: ${{ github.event.issue.number }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
|
||||
-f content="-1" >/dev/null
|
||||
gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
|
||||
-f body="🚫 \`/autofix\` is restricted to users with write access or the PR author. Comment ignored." \
|
||||
>/dev/null
|
||||
# Hard exit so the rest of the job is skipped.
|
||||
exit 1
|
||||
|
||||
- name: React 👀 to acknowledge
|
||||
if: steps.perm.outputs.allowed == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
COMMENT_ID: ${{ github.event.comment.id }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
|
||||
-f content="eyes" >/dev/null
|
||||
|
||||
- name: Resolve PR head and locate autofix run
|
||||
id: locate
|
||||
if: steps.perm.outputs.allowed == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
PR: ${{ github.event.issue.number }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Same retry wrapper used in the permission step, repeated
|
||||
# because each YAML `run:` block is a fresh bash session.
|
||||
gh_retry() {
|
||||
local n=0 max=3
|
||||
while true; do
|
||||
if gh "$@"; then return 0; fi
|
||||
n=$((n+1))
|
||||
if [ "$n" -ge "$max" ]; then return 1; fi
|
||||
sleep $((n * 2))
|
||||
done
|
||||
}
|
||||
|
||||
# Fetch PR metadata. All fields here are server-controlled API
|
||||
# output, but we still allowlist before exporting so anything
|
||||
# weird short-circuits before $GITHUB_OUTPUT. Wrapped in
|
||||
# gh_retry so transient blips don't surface as "no autofix run
|
||||
# found" with a wrong remediation.
|
||||
if ! pr_json=$(gh_retry api "repos/${GH_REPO}/pulls/${PR}"); then
|
||||
echo "::error::PR metadata fetch failed after retries."
|
||||
echo "found_status=api-failed" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
head_sha=$(jq -r '.head.sha' <<< "${pr_json}")
|
||||
head_ref=$(jq -r '.head.ref' <<< "${pr_json}")
|
||||
head_repo=$(jq -r '.head.repo.full_name' <<< "${pr_json}")
|
||||
|
||||
[[ "${head_sha}" =~ ^[0-9a-f]{40}$ ]] || { echo "::error::Bad head_sha"; exit 1; }
|
||||
[[ "${head_ref}" =~ ^[A-Za-z0-9._/-]+$ ]] || { echo "::error::Bad head_ref"; exit 1; }
|
||||
[[ "${head_repo}" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]] || { echo "::error::Bad head_repo"; exit 1; }
|
||||
|
||||
# Find the latest successful pr-autofix.yml run for this head SHA.
|
||||
if ! runs_json=$(gh_retry api "repos/${GH_REPO}/actions/workflows/pr-autofix.yml/runs?head_sha=${head_sha}&per_page=10"); then
|
||||
echo "::error::Workflow run lookup failed after retries."
|
||||
echo "found_status=api-failed" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
run_id=$(jq -r '[.workflow_runs[] | select(.conclusion == "success")] | .[0].id // empty' <<< "${runs_json}")
|
||||
|
||||
if [ -n "${run_id}" ] && [[ "${run_id}" =~ ^[0-9]+$ ]]; then
|
||||
echo "found_status=success" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "found=true"
|
||||
echo "head_sha=${head_sha}"
|
||||
echo "head_ref=${head_ref}"
|
||||
echo "head_repo=${head_repo}"
|
||||
echo "run_id=${run_id}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# No successful run. Distinguish "still running" (producer in
|
||||
# flight after a recent push) from "never ran / all failed".
|
||||
# in_progress / queued / pending / waiting cover the GitHub
|
||||
# workflow-run lifecycle states that precede success/failure.
|
||||
in_progress=$(jq -r '[.workflow_runs[] | select(.status == "in_progress" or .status == "queued" or .status == "pending" or .status == "waiting")] | length' <<< "${runs_json}")
|
||||
if [ "${in_progress:-0}" -gt 0 ]; then
|
||||
echo "::warning::pr-autofix run is still in progress for head ${head_sha}."
|
||||
echo "found_status=in-progress" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "::warning::No successful pr-autofix run found for head ${head_sha}."
|
||||
echo "found_status=not-found" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
# Existing `found` boolean is preserved so downstream gates
|
||||
# (`steps.locate.outputs.found == 'true'`) still work.
|
||||
echo "found=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Reply when locate did not yield a usable run
|
||||
if: steps.perm.outputs.allowed == 'true' && steps.locate.outputs.found != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
COMMENT_ID: ${{ github.event.comment.id }}
|
||||
PR: ${{ github.event.issue.number }}
|
||||
FOUND_STATUS: ${{ steps.locate.outputs.found_status }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
run_url="https://github.com/${GH_REPO}/actions/runs/${RUN_ID}"
|
||||
case "${FOUND_STATUS}" in
|
||||
in-progress)
|
||||
gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
|
||||
-f content="confused" >/dev/null
|
||||
gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
|
||||
-f body="⏳ A pr-autofix run is still in progress for this PR's current head SHA. Wait for it to finish, then comment \`/autofix\` again. ([apply run](${run_url}))" \
|
||||
>/dev/null
|
||||
;;
|
||||
api-failed)
|
||||
gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
|
||||
-f content="confused" >/dev/null
|
||||
gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
|
||||
-f body="⚠️ Couldn't reach the GitHub API to look up the autofix run (transient failure after retries). Please comment \`/autofix\` again. ([apply run](${run_url}))" \
|
||||
>/dev/null
|
||||
;;
|
||||
*)
|
||||
gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
|
||||
-f content="-1" >/dev/null
|
||||
gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
|
||||
-f body="🤔 No successful autofix run found for this PR's current head SHA. Push a new commit to trigger one, then comment \`/autofix\` again." \
|
||||
>/dev/null
|
||||
;;
|
||||
esac
|
||||
exit 1
|
||||
|
||||
# Pinned to v8.0.1. Same SHA as pr-autofix-publish.yml.
|
||||
# `continue-on-error: true` lets the workflow proceed when the
|
||||
# artifact is expired or pruned (1-day retention). The apply
|
||||
# step distinguishes "patch file missing entirely" (artifact-
|
||||
# expired) from "patch file zero bytes" (genuinely empty patch).
|
||||
- name: Download autofix artifact
|
||||
if: steps.locate.outputs.found == 'true'
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: autofix
|
||||
run-id: ${{ steps.locate.outputs.run_id }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
path: autofix-in
|
||||
|
||||
# Pinned to v5.0.4. Verify SHA via:
|
||||
# gh api repos/actions/checkout/git/refs/tags/v5.0.4
|
||||
#
|
||||
# `persist-credentials: false` disables the default behavior where
|
||||
# actions/checkout writes the GITHUB_TOKEN into `.git/config` as an
|
||||
# extraheader. That default is convenient (subsequent git commands
|
||||
# auth automatically) but it means the token is sitting on disk in
|
||||
# the checkout directory — an `actions/upload-artifact` step on
|
||||
# this directory would leak the token. We don't upload, but
|
||||
# zizmor's `credential-persistence` lint flags it defensively.
|
||||
# Push auth is provided inline at push time via the URL.
|
||||
- name: Checkout PR head
|
||||
if: steps.locate.outputs.found == 'true'
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5.0.4
|
||||
with:
|
||||
repository: ${{ steps.locate.outputs.head_repo }}
|
||||
ref: ${{ steps.locate.outputs.head_sha }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
persist-credentials: false
|
||||
# Fetch full history so the push doesn't hit shallow-clone errors.
|
||||
fetch-depth: 0
|
||||
path: pr-checkout
|
||||
|
||||
- name: Apply patch and push
|
||||
id: apply
|
||||
if: steps.locate.outputs.found == 'true'
|
||||
env:
|
||||
HEAD_REF: ${{ steps.locate.outputs.head_ref }}
|
||||
HEAD_REPO: ${{ steps.locate.outputs.head_repo }}
|
||||
# The SHA we resolved earlier in `locate` — this is what the
|
||||
# remote ref MUST still equal at push time. If the contributor
|
||||
# force-pushed between resolve and now, the lease fails and
|
||||
# we surface that distinctly from a fork-without-maintainer
|
||||
# -edit push failure.
|
||||
HEAD_SHA: ${{ steps.locate.outputs.head_sha }}
|
||||
# Auth for the push only — never persisted to disk. Provided
|
||||
# via env to avoid interpolating into the shell command line.
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
working-directory: pr-checkout
|
||||
run: |
|
||||
set -euo pipefail
|
||||
patch="../autofix-in/autofix.patch"
|
||||
|
||||
# Distinguish artifact-expired (file missing entirely, because
|
||||
# actions/download-artifact ran with continue-on-error and the
|
||||
# 1-day retention had elapsed) from genuinely empty patch
|
||||
# (file present, zero bytes, formatter found nothing).
|
||||
if [ ! -e "$patch" ]; then
|
||||
echo "::warning::Patch file does not exist — autofix artifact likely expired."
|
||||
echo "result=artifact-expired" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ ! -s "$patch" ]; then
|
||||
echo "::warning::Empty patch — nothing to apply."
|
||||
echo "result=empty-patch" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Sensitive-paths guard: refuse to apply patches that touch
|
||||
# `.github/` — workflow files, action definitions, CODEOWNERS,
|
||||
# dependabot config, etc. A malicious PR could ship a custom
|
||||
# prettier/ESLint config that reformats workflow YAML; the
|
||||
# producer would then capture those edits in autofix.patch,
|
||||
# and a maintainer running `/autofix` would push them under
|
||||
# `contents: write`. The default GITHUB_TOKEN lacks `workflows`
|
||||
# scope so the platform would reject workflow-file pushes
|
||||
# anyway, but that surfaces as a generic `push-failed` and
|
||||
# misleads users into enabling maintainer-edit. Reject early
|
||||
# with a specific reason. CODEOWNERS and dependabot.yml live
|
||||
# under .github/ but outside .github/workflows/ — the broader
|
||||
# match is intentional (they all govern trust boundaries).
|
||||
if grep -qE '^(diff --git|---|\+\+\+) [ab]?/?\.github/' "$patch"; then
|
||||
echo "::warning::Patch touches .github/ — refusing to apply (sensitive paths)."
|
||||
echo "result=sensitive-paths" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Re-entrancy guard: if HEAD itself is an autofix bot commit,
|
||||
# refuse to apply again. Without this, lint/formatter config
|
||||
# drift between runs could pump arbitrary apply commits into
|
||||
# the same PR if an automated agent watches the sticky and
|
||||
# re-fires `/autofix` on each new "fixes-available" surface.
|
||||
# The contributor can still get out by force-pushing a
|
||||
# human-authored commit to revert the autofix and re-trigger.
|
||||
head_author=$(git log -1 --format='%ae' HEAD)
|
||||
head_subject=$(git log -1 --format='%s' HEAD)
|
||||
if [ "${head_author}" = "41898282+github-actions[bot]@users.noreply.github.com" ] \
|
||||
&& [[ "${head_subject}" =~ ^chore\(autofix\) ]]; then
|
||||
echo "::warning::HEAD is an autofix bot commit — refusing to re-apply (loop guard)."
|
||||
echo "result=loop-prevented" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Idempotency probe: does the forward apply work?
|
||||
if git apply --check "$patch" 2>/dev/null; then
|
||||
echo "Patch applies cleanly — proceeding."
|
||||
elif git apply --check --reverse "$patch" 2>/dev/null; then
|
||||
# Reverse-check passes => the patch is already applied to
|
||||
# the current tree. Treat as success no-op.
|
||||
echo "Patch is already applied (reverse-check passed) — no-op."
|
||||
echo "result=already-applied" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
else
|
||||
echo "::error::Patch does not apply (stale or conflicting)."
|
||||
echo "result=stale" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Wrap the apply/commit phase so any non-zero exit sets a
|
||||
# meaningful `result=` instead of leaving it unset (which would
|
||||
# send the user to the `*` "unexpected state" arm with a
|
||||
# non-actionable confused-emoji reply).
|
||||
if ! {
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com" &&
|
||||
git config user.name "github-actions[bot]" &&
|
||||
git apply "$patch" &&
|
||||
git add -A &&
|
||||
git commit -m "chore(autofix): apply prettier + eslint fixes via /autofix command"
|
||||
}; then
|
||||
echo "::error::git apply / config / commit failed after idempotency probe passed."
|
||||
echo "result=apply-failed" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Push to the PR head branch with a lease against the resolved
|
||||
# SHA. The lease ensures the remote ref still points at HEAD_SHA
|
||||
# when the push lands — if the contributor force-pushed in the
|
||||
# window between resolve and now, the lease fails and we return
|
||||
# `lease-failed` (NOT `push-failed`, which would mislead users
|
||||
# into enabling maintainer-edit). For fork PRs, the push still
|
||||
# requires "Allow edits by maintainers" to be enabled.
|
||||
#
|
||||
# Auth is supplied inline via `-c http.<base>.extraheader` (NOT
|
||||
# via a `https://x-access-token:TOKEN@…` URL — those leak into
|
||||
# process listings and `git remote -v` output). The header is
|
||||
# set per-invocation; it never lands in `.git/config` on disk.
|
||||
# The token is base64-encoded for the Basic auth header per
|
||||
# GitHub's documented pattern for this scope.
|
||||
push_url="https://github.com/${HEAD_REPO}.git"
|
||||
auth_header="Authorization: Basic $(printf 'x-access-token:%s' "${GITHUB_TOKEN}" | base64 -w0)"
|
||||
# GitHub's secret-masker only masks the raw token, not its
|
||||
# base64-encoded form. Mask the encoded value so any subsequent
|
||||
# log line (set -x, GIT_TRACE, error spew) gets ***-redacted.
|
||||
echo "::add-mask::${auth_header}"
|
||||
push_stderr=$(mktemp)
|
||||
if git -c http.extraheader="${auth_header}" \
|
||||
push --force-with-lease="refs/heads/${HEAD_REF}:${HEAD_SHA}" \
|
||||
"${push_url}" "HEAD:${HEAD_REF}" 2>"$push_stderr"; then
|
||||
echo "result=applied" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
cat "$push_stderr" >&2
|
||||
# `--force-with-lease` reports "stale info" when the remote
|
||||
# ref has moved past the expected SHA. Other lease-failure
|
||||
# phrases git emits include "remote rejected" (server-side
|
||||
# reject), "non-fast-forward", and the literal flag name. Match
|
||||
# any of those to distinguish from auth/network/maintainer-
|
||||
# edit failures.
|
||||
if grep -qE "stale info|force-with-lease|rejected.*non-fast-forward|remote rejected|! \[rejected\]" "$push_stderr"; then
|
||||
echo "::error::git push lease failed — branch moved during apply."
|
||||
echo "result=lease-failed" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "::error::git push failed — likely fork without maintainer-edit enabled."
|
||||
echo "result=push-failed" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
- name: React and reply on outcome
|
||||
if: always() && steps.locate.outputs.found == 'true' && steps.apply.outcome != 'skipped'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
COMMENT_ID: ${{ github.event.comment.id }}
|
||||
PR: ${{ github.event.issue.number }}
|
||||
RESULT: ${{ steps.apply.outputs.result }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
run_url="https://github.com/${GH_REPO}/actions/runs/${RUN_ID}"
|
||||
|
||||
case "${RESULT}" in
|
||||
applied)
|
||||
gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
|
||||
-f content="+1" >/dev/null
|
||||
gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
|
||||
-f body="✅ Applied autofix and pushed a commit. ([apply run](${run_url}))" \
|
||||
>/dev/null
|
||||
;;
|
||||
already-applied)
|
||||
gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
|
||||
-f content="+1" >/dev/null
|
||||
gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
|
||||
-f body="✅ Autofix is already applied — no changes needed." \
|
||||
>/dev/null
|
||||
;;
|
||||
empty-patch)
|
||||
gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
|
||||
-f content="+1" >/dev/null
|
||||
gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
|
||||
-f body="✅ No autofix to apply — formatter found nothing." \
|
||||
>/dev/null
|
||||
;;
|
||||
artifact-expired)
|
||||
gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
|
||||
-f content="confused" >/dev/null
|
||||
gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
|
||||
-f body="⏳ The autofix artifact for this PR's head SHA has expired (1-day retention). Push a new commit to regenerate it, then comment \`/autofix\` again. ([apply run](${run_url}))" \
|
||||
>/dev/null
|
||||
exit 1
|
||||
;;
|
||||
loop-prevented)
|
||||
gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
|
||||
-f content="confused" >/dev/null
|
||||
gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
|
||||
-f body="🔁 Refusing to re-apply autofix on top of an existing autofix commit. If formatter rules drifted and you genuinely need another pass, push a human-authored commit (or revert the existing autofix commit) before commenting \`/autofix\` again. ([apply run](${run_url}))" \
|
||||
>/dev/null
|
||||
exit 1
|
||||
;;
|
||||
sensitive-paths)
|
||||
gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
|
||||
-f content="-1" >/dev/null
|
||||
gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
|
||||
-f body="🛑 Refusing to apply: the autofix patch touches files under \`.github/\` (workflow / CODEOWNERS / dependabot config). Apply formatter changes to those files manually in a regular commit so they get human review. ([apply run](${run_url}))" \
|
||||
>/dev/null
|
||||
exit 1
|
||||
;;
|
||||
stale)
|
||||
gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
|
||||
-f content="-1" >/dev/null
|
||||
gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
|
||||
-f body="⚠️ The autofix patch is stale or conflicts with the current head — push a new commit to regenerate, then comment \`/autofix\` again. ([apply run](${run_url}))" \
|
||||
>/dev/null
|
||||
exit 1
|
||||
;;
|
||||
apply-failed)
|
||||
gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
|
||||
-f content="-1" >/dev/null
|
||||
gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
|
||||
-f body="⚠️ Autofix applied cleanly in the dry run, but \`git apply\` / \`git commit\` failed when actually landing the patch. This usually means a race with concurrent edits or a corrupt patch. See logs: ${run_url}" \
|
||||
>/dev/null
|
||||
exit 1
|
||||
;;
|
||||
push-failed)
|
||||
gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
|
||||
-f content="-1" >/dev/null
|
||||
gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
|
||||
-f body="⚠️ Couldn't push the autofix commit. If this is a fork PR, please tick **Allow edits by maintainers** in the PR sidebar, then comment \`/autofix\` again. ([apply run](${run_url}))" \
|
||||
>/dev/null
|
||||
exit 1
|
||||
;;
|
||||
lease-failed)
|
||||
gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
|
||||
-f content="-1" >/dev/null
|
||||
gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
|
||||
-f body="⚠️ The PR head moved while autofix was applying — a new commit landed in the window between resolve and push. Comment \`/autofix\` again to retry against the latest head. ([apply run](${run_url}))" \
|
||||
>/dev/null
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
|
||||
-f content="confused" >/dev/null
|
||||
gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
|
||||
-f body="❓ Autofix run finished in an unexpected state (\`${RESULT:-unknown}\`). See logs: ${run_url}" \
|
||||
>/dev/null
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -1,316 +0,0 @@
|
||||
name: PR Autofix (publish)
|
||||
|
||||
# TRUSTED HALF of the autofix pipeline.
|
||||
#
|
||||
# Triggered by `pr-autofix.yml` completing on a PR (including fork PRs).
|
||||
# Downloads the diff artifact produced by the untrusted job, verifies
|
||||
# its claimed PR identity against the workflow_run authority, then
|
||||
# posts (or edits) a single sticky summary comment plus a
|
||||
# `gitnexus/autofix` Check Run. This job NEVER checks out fork code —
|
||||
# it only consumes the diff (data) and calls the GitHub API. That
|
||||
# isolation is what makes it safe to run under `pull-requests: write`
|
||||
# on fork-triggered events.
|
||||
#
|
||||
# The sticky comment is the contributor signal: heading
|
||||
# "## :sparkles: PR Autofix" in the PR's top-level comments, with a
|
||||
# fenced `gitnexus-autofix` JSON block carrying machine-readable state
|
||||
# for AI agents. Contributors apply the patch by commenting `/autofix`
|
||||
# on the PR — handled by the separate `pr-autofix-apply.yml` workflow.
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ['PR Autofix']
|
||||
types: [completed]
|
||||
|
||||
concurrency:
|
||||
# Key on PR identity, NOT workflow_run.id — workflow_run.id is per-run
|
||||
# unique, which would defeat serialization and let two parallel
|
||||
# publishes both POST a sticky summary comment. CONTRIBUTING.md
|
||||
# § GitHub Actions — Concurrency Convention names this anti-pattern
|
||||
# explicitly. For fork PRs, `pull_requests[]` is empty in the
|
||||
# workflow_run payload, so we fall back to head-repo + head-branch.
|
||||
group: ${{ github.workflow }}-${{ github.event.workflow_run.pull_requests[0].number || format('{0}/{1}', github.event.workflow_run.head_repository.full_name, github.event.workflow_run.head_branch) }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: publish-autofix
|
||||
if: >-
|
||||
github.event.workflow_run.event == 'pull_request'
|
||||
&& github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
pull-requests: write
|
||||
# Required by actions/download-artifact to fetch artifacts produced
|
||||
# by a different workflow run.
|
||||
actions: read
|
||||
# Required to create the `gitnexus/autofix` Check Run that reports
|
||||
# the outcome (clean / fixes-available) to the PR's Checks tab.
|
||||
# Branch protection or agents can grep the conclusion + output
|
||||
# title without parsing the sticky comment.
|
||||
checks: write
|
||||
steps:
|
||||
# Pinned to v8.0.1. Verify SHA via:
|
||||
# gh api repos/actions/download-artifact/git/refs/tags/v8.0.1
|
||||
- name: Download autofix artifact
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: autofix
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
path: autofix-in
|
||||
|
||||
- name: Read and validate metadata
|
||||
id: meta
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -f autofix-in/metadata.json
|
||||
jq . autofix-in/metadata.json
|
||||
|
||||
# The artifact comes from the untrusted half running fork code.
|
||||
# Every field is allowlist-validated before it can flow into
|
||||
# $GITHUB_OUTPUT. A newline in head_ref would otherwise let a
|
||||
# malicious branch name inject a second `pr_number=N` line and
|
||||
# redirect this job's reviewdog suggestions / sticky summary
|
||||
# comment onto a victim PR under github-actions[bot] with
|
||||
# pull-requests: write.
|
||||
assert_field() {
|
||||
local key="$1" pattern="$2" value
|
||||
value=$(jq -r ".${key} // empty" autofix-in/metadata.json)
|
||||
if [ -z "$value" ] || ! [[ "$value" =~ $pattern ]]; then
|
||||
echo "::error::metadata.${key} failed allowlist (got: $(printf '%q' "$value"))"
|
||||
exit 1
|
||||
fi
|
||||
printf '%s' "$value"
|
||||
}
|
||||
|
||||
SCHEMA=$(assert_field schema '^gitnexus\.pr-autofix/v[0-9]+$')
|
||||
PR_NUMBER=$(assert_field pr_number '^[0-9]+$')
|
||||
HEAD_SHA=$(assert_field head_sha '^[0-9a-f]{40}$')
|
||||
HEAD_REF=$(assert_field head_ref '^[A-Za-z0-9._/-]+$')
|
||||
HEAD_REPO=$(assert_field head_repo '^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$')
|
||||
BASE_REPO=$(assert_field base_repo '^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$')
|
||||
CHANGED=$(assert_field changed_lines '^[0-9]+$')
|
||||
|
||||
# Defence-in-depth: refuse to act if the artifact claims to
|
||||
# belong to a different repo than the one that triggered us.
|
||||
if [ "$BASE_REPO" != "${GITHUB_REPOSITORY}" ]; then
|
||||
echo "::error::Artifact base_repo does not match \$GITHUB_REPOSITORY — refusing to publish."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
{
|
||||
echo "schema=${SCHEMA}"
|
||||
echo "pr_number=${PR_NUMBER}"
|
||||
echo "head_sha=${HEAD_SHA}"
|
||||
echo "head_ref=${HEAD_REF}"
|
||||
echo "head_repo=${HEAD_REPO}"
|
||||
echo "base_repo=${BASE_REPO}"
|
||||
echo "changed_lines=${CHANGED}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Cross-verify the artifact's claimed identity against the
|
||||
# GitHub-controlled workflow_run event. The previous step's
|
||||
# allowlist only proves the fields are well-formed — not that
|
||||
# they refer to the PR/SHA that actually triggered this run.
|
||||
# A fork-controlled `npm run lint:fix` could plausibly mutate
|
||||
# metadata.json to reference another PR or SHA, redirecting our
|
||||
# write-scoped sticky/check-run onto an attacker-chosen target.
|
||||
#
|
||||
# Authority sources are all server-controlled GitHub event fields:
|
||||
# - workflow_run.head_sha
|
||||
# - workflow_run.head_repository.full_name
|
||||
# - workflow_run.pull_requests[].number (within-repo PRs only;
|
||||
# empty array on fork PRs — fall back to commits/{sha}/pulls)
|
||||
#
|
||||
# Mismatch => fail loud BEFORE any sticky/check-run side effect.
|
||||
- name: Verify metadata against workflow_run authority
|
||||
id: verify
|
||||
if: steps.meta.outputs.changed_lines != '0'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
META_PR_NUMBER: ${{ steps.meta.outputs.pr_number }}
|
||||
META_HEAD_SHA: ${{ steps.meta.outputs.head_sha }}
|
||||
META_HEAD_REPO: ${{ steps.meta.outputs.head_repo }}
|
||||
WF_HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
WF_HEAD_REPO: ${{ github.event.workflow_run.head_repository.full_name }}
|
||||
WF_PR_NUMBERS: ${{ toJSON(github.event.workflow_run.pull_requests.*.number) }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# 1) head_sha must match exactly. workflow_run.head_sha is the
|
||||
# commit GitHub actually ran the producer against — definitive.
|
||||
if [ "${META_HEAD_SHA}" != "${WF_HEAD_SHA}" ]; then
|
||||
echo "::error::Artifact head_sha (${META_HEAD_SHA}) does not match workflow_run.head_sha (${WF_HEAD_SHA}) — refusing to publish."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 2) head_repo must match exactly. Same authority anchor.
|
||||
if [ "${META_HEAD_REPO}" != "${WF_HEAD_REPO}" ]; then
|
||||
echo "::error::Artifact head_repo (${META_HEAD_REPO}) does not match workflow_run.head_repository (${WF_HEAD_REPO}) — refusing to publish."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 3) pr_number must reference an open PR with this head SHA.
|
||||
# Within-repo PRs: workflow_run.pull_requests[] is populated.
|
||||
# Fork PRs: that array is empty by GitHub design — fall back
|
||||
# to the REST commit-to-PRs lookup. Fail closed if the lookup
|
||||
# finds no matching open PR (avoids attacker-forged PR ids).
|
||||
allowed_numbers=$(jq -c '.' <<< "${WF_PR_NUMBERS}")
|
||||
if [ "${allowed_numbers}" = "[]" ]; then
|
||||
echo "workflow_run.pull_requests is empty (fork PR) — falling back to commits/{sha}/pulls."
|
||||
allowed_numbers=$(gh api "repos/${GH_REPO}/commits/${WF_HEAD_SHA}/pulls" \
|
||||
--jq '[.[] | select(.state == "open") | .number]' 2>/dev/null || echo "[]")
|
||||
if [ "${allowed_numbers}" = "[]" ]; then
|
||||
echo "::error::No open PR found for head ${WF_HEAD_SHA} via commits/{sha}/pulls — refusing to publish."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! jq -e --argjson n "${META_PR_NUMBER}" 'index($n) != null' <<< "${allowed_numbers}" >/dev/null; then
|
||||
echo "::error::Artifact pr_number (${META_PR_NUMBER}) is not in the authoritative PR list (${allowed_numbers}) — refusing to publish."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Verified: metadata identity matches workflow_run authority (PR=${META_PR_NUMBER}, head_sha=${META_HEAD_SHA}, head_repo=${META_HEAD_REPO})."
|
||||
|
||||
- name: Upsert sticky summary comment
|
||||
# Only post when ci-quality found something fixable (= the
|
||||
# autofix patch is non-empty). When prettier/eslint are clean
|
||||
# the patch is zero bytes and the sticky comment is pure noise,
|
||||
# so we skip it.
|
||||
if: >-
|
||||
always()
|
||||
&& steps.meta.outputs.pr_number != ''
|
||||
&& steps.meta.outputs.changed_lines != '0'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
PR: ${{ steps.meta.outputs.pr_number }}
|
||||
CHANGED: ${{ steps.meta.outputs.changed_lines }}
|
||||
HEAD_SHA: ${{ steps.meta.outputs.head_sha }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Stable heading + marker — agents grep for these exact strings.
|
||||
marker="<!-- gitnexus:pr-autofix-summary -->"
|
||||
heading="## :sparkles: PR Autofix"
|
||||
|
||||
# Single state. The /autofix slash command works for any diff
|
||||
# size — there's no 3K cap and no no-overlap dead-end because
|
||||
# the apply workflow uses `git apply` + push, not the GitHub
|
||||
# review-comment API.
|
||||
ui_state="fixes-available"
|
||||
prose="Found fixable formatting / unused-import issues across **${CHANGED}** changed lines. **Comment \`/autofix\` on this PR to apply them**, or run \`npm run lint:fix && npm run format\` locally."
|
||||
|
||||
# Machine-readable JSON block — agents parse this instead of
|
||||
# regexing English. Fenced code-block info string is
|
||||
# `gitnexus-autofix` so agents can locate it without ambiguity.
|
||||
# Schema bumped from v1 -> v2: adds `apply_command`. The v1
|
||||
# field set is preserved as a superset, but the `state` enum
|
||||
# is redefined (v1: suggestions-posted | skipped-too-large |
|
||||
# diff-no-overlap; v2: fixes-available). v1 readers checking
|
||||
# `schema == 'gitnexus.pr-autofix/v1'` see an unfamiliar version
|
||||
# and fall back to prose, which is the intended migration path.
|
||||
json=$(jq -n -c \
|
||||
--arg state "${ui_state}" \
|
||||
--argjson pr_number "${PR}" \
|
||||
--argjson changed_lines "${CHANGED}" \
|
||||
--arg head_sha "${HEAD_SHA}" \
|
||||
--arg run_id "${RUN_ID}" \
|
||||
'{schema:"gitnexus.pr-autofix/v2", state:$state, pr_number:$pr_number, changed_lines:$changed_lines, head_sha:$head_sha, run_id:$run_id, apply_command:"/autofix"}')
|
||||
|
||||
# Multi-line quoted string instead of a column-0 heredoc — YAML's
|
||||
# `run: |` block ends as soon as a content line dedents below the
|
||||
# block's first-line indent, which would mis-parse the workflow.
|
||||
body="${marker}
|
||||
${heading}
|
||||
|
||||
${prose}
|
||||
|
||||
\`\`\`gitnexus-autofix
|
||||
${json}
|
||||
\`\`\`"
|
||||
# Strip the leading 10-space indent that the YAML block requires
|
||||
# so the rendered comment body starts at column 0.
|
||||
body="$(printf '%s\n' "$body" | sed 's/^ //')"
|
||||
|
||||
# Small retry wrapper for transient 5xx / rate-limit responses
|
||||
# on the GitHub REST API. Three tries with linear backoff. We
|
||||
# only retry GET (idempotent) and PATCH on a known comment id
|
||||
# (idempotent). POST is NOT wrapped — retrying a comment-create
|
||||
# would create duplicates if the first attempt actually landed.
|
||||
gh_retry() {
|
||||
local n=0 max=3
|
||||
while true; do
|
||||
if gh "$@"; then return 0; fi
|
||||
n=$((n+1))
|
||||
if [ "$n" -ge "$max" ]; then return 1; fi
|
||||
sleep $((n * 2))
|
||||
done
|
||||
}
|
||||
|
||||
# Find existing bot comment by the marker and edit-in-place; else create.
|
||||
# CRITICAL: filter by `.user.login == "github-actions[bot]"`. A regular
|
||||
# user posting a comment containing the marker would otherwise be the
|
||||
# `head -n1` match; PATCH on someone else's comment 403s, `set -e`
|
||||
# aborts, and the bot is permanently DoS'd for that PR.
|
||||
existing=$(gh_retry api "repos/${GH_REPO}/issues/${PR}/comments" \
|
||||
--paginate --jq ".[] | select(.user.login == \"github-actions[bot]\" and (.body | contains(\"${marker}\"))) | .id" \
|
||||
| head -n1 || true)
|
||||
|
||||
if [ -n "${existing}" ]; then
|
||||
gh_retry api -X PATCH "repos/${GH_REPO}/issues/comments/${existing}" \
|
||||
-f body="${body}" >/dev/null
|
||||
echo "Updated comment ${existing}."
|
||||
else
|
||||
gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
|
||||
-f body="${body}" >/dev/null
|
||||
echo "Created summary comment."
|
||||
fi
|
||||
|
||||
- name: Emit gitnexus/autofix Check Run
|
||||
# Stable check name `gitnexus/autofix` so PR-watching agents can
|
||||
# `gh pr checks <pr>` and read the conclusion + title without
|
||||
# parsing the sticky comment. Two outcomes:
|
||||
# clean → conclusion: success
|
||||
# fixes-available → conclusion: neutral
|
||||
# `neutral` does not block branch-protection required-checks but
|
||||
# is visually distinct from a green pass.
|
||||
if: always() && steps.meta.outputs.head_sha != ''
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
HEAD_SHA: ${{ steps.meta.outputs.head_sha }}
|
||||
CHANGED: ${{ steps.meta.outputs.changed_lines }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [ "${CHANGED}" = "0" ]; then
|
||||
conclusion="success"
|
||||
title="Formatting clean"
|
||||
summary="Prettier and ESLint --fix produced no changes."
|
||||
else
|
||||
conclusion="neutral"
|
||||
title="Autofix available — comment /autofix to apply"
|
||||
summary="Comment \`/autofix\` on this PR to apply formatter + unused-import fixes (works at any diff size). Or run \`npm run lint:fix && npm run format\` locally."
|
||||
fi
|
||||
|
||||
gh api -X POST "repos/${GH_REPO}/check-runs" \
|
||||
-f name="gitnexus/autofix" \
|
||||
-f head_sha="${HEAD_SHA}" \
|
||||
-f status="completed" \
|
||||
-f conclusion="${conclusion}" \
|
||||
-f "output[title]=${title}" \
|
||||
-f "output[summary]=${summary}" \
|
||||
>/dev/null
|
||||
echo "Posted check-run gitnexus/autofix=${conclusion} (${title})"
|
||||
@@ -1,146 +0,0 @@
|
||||
name: PR Autofix
|
||||
|
||||
# UNTRUSTED HALF of the autofix pipeline.
|
||||
#
|
||||
# Runs `npm run lint:fix` + `npm run format` against the PR head
|
||||
# (including fork heads) and uploads the resulting diff as an artifact.
|
||||
# This job has NO privileged token and CANNOT post to the PR. The trusted
|
||||
# `pr-autofix-publish.yml` workflow downloads the artifact via
|
||||
# `workflow_run` and posts a sticky summary comment + Check Run.
|
||||
# Contributors apply the patch by commenting `/autofix` on the PR —
|
||||
# handled by the separate `pr-autofix-apply.yml` ChatOps workflow.
|
||||
#
|
||||
# Why the split:
|
||||
# ESLint loads plugins from fork-controlled `node_modules`, so running
|
||||
# it in a job with `pull-requests: write` would let a malicious fork PR
|
||||
# ship a poisoned eslint plugin and execute arbitrary code under that
|
||||
# token. By keeping fork code execution in this job (token: read-only)
|
||||
# and posting from a separate trusted job that never touches fork
|
||||
# code, we get the autofix UX for fork PRs without the supply-chain
|
||||
# hole. (See autofix.ci for the same pattern.)
|
||||
#
|
||||
# Removes unused imports via `eslint-plugin-unused-imports`, already in
|
||||
# devDependencies and wired into the `lint` config.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
# Skip lockfile / generated-file PRs entirely — `action-suggester`
|
||||
# cannot post on diffs > ~3k lines (GitHub returns 406) and these
|
||||
# paths produce massive diffs no human wants suggested back inline.
|
||||
paths-ignore:
|
||||
- '**/package-lock.json'
|
||||
- '**/*.snap'
|
||||
- '**/dist/**'
|
||||
- '**/node_modules/**'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
|
||||
# Don't cancel in-flight runs; the publish workflow may already be
|
||||
# downloading the artifact and a cancelled untrusted run produces no
|
||||
# signal at all (worse DX than waiting).
|
||||
cancel-in-progress: false
|
||||
|
||||
# This workflow runs untrusted fork code. Top-level deny-all and NO
|
||||
# job-level grants — the job can only read its own checkout.
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
autofix:
|
||||
name: autofix
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# PR head commit (not the synthetic merge ref) — we need the
|
||||
# exact tree the contributor pushed so suggestions line up.
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: package-lock.json
|
||||
|
||||
# `--ignore-scripts` blocks pre/postinstall lifecycle hooks. ESLint
|
||||
# plugins still load from node_modules (that is the actual escape
|
||||
# hatch on a typical fork), but this job has no token to abuse —
|
||||
# which is the whole point of the split.
|
||||
- run: npm ci --ignore-scripts
|
||||
|
||||
- name: ESLint --fix (removes unused imports)
|
||||
run: npm run lint:fix
|
||||
# Lint errors that --fix can't auto-resolve must not block the
|
||||
# diff artifact — partial fixes are still useful as suggestions.
|
||||
continue-on-error: true
|
||||
|
||||
- name: Prettier --write
|
||||
run: npm run format
|
||||
continue-on-error: true
|
||||
|
||||
- name: Capture diff and metadata
|
||||
id: capture
|
||||
# Pass GitHub-context values via env: rather than `${{ }}`
|
||||
# interpolated directly into the bash body. `head.ref` and
|
||||
# `head.repo.full_name` are fork-controlled strings; expanding
|
||||
# them into shell source is the canonical template-injection
|
||||
# vector zizmor flags. Even though this job has `permissions: {}`,
|
||||
# routing through env: makes it impossible for a future scope
|
||||
# grant to turn into RCE. Inside bash, reference as `$HEAD_REF`
|
||||
# etc. — the values are then plain strings, not code.
|
||||
env:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
HEAD_REF: ${{ github.event.pull_request.head.ref }}
|
||||
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
BASE_REPO: ${{ github.repository }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p autofix-out
|
||||
|
||||
# Produce a unified diff of the working tree vs. the PR head.
|
||||
# Empty diff => nothing to suggest; the publish job short-circuits.
|
||||
git diff --no-color > autofix-out/autofix.patch
|
||||
|
||||
# NOTE: `changed_lines` is the line-count of the patch file,
|
||||
# (hunk headers + context lines + added/removed). Surfaced in
|
||||
# the sticky comment so contributors and AI agents have a
|
||||
# quick size hint before invoking `/autofix`.
|
||||
changed_lines=$(wc -l < autofix-out/autofix.patch | tr -d ' ')
|
||||
echo "changed_lines=${changed_lines}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Carry PR identity over to the trusted job. workflow_run
|
||||
# context is base-repo-only, so the publish job needs these
|
||||
# to call the GitHub PR API on the right resource.
|
||||
# CONTRACT: keep this schema in sync with pr-autofix-publish.yml's
|
||||
# `assert_field` validators and the agent-facing JSON block in
|
||||
# the sticky comment. Bump `schema` when changing field names.
|
||||
jq -n \
|
||||
--arg schema 'gitnexus.pr-autofix/v1' \
|
||||
--argjson pr_number "${PR_NUMBER}" \
|
||||
--arg head_sha "${HEAD_SHA}" \
|
||||
--arg head_ref "${HEAD_REF}" \
|
||||
--arg head_repo "${HEAD_REPO}" \
|
||||
--arg base_repo "${BASE_REPO}" \
|
||||
--argjson changed_lines "${changed_lines}" \
|
||||
'{schema:$schema, pr_number:$pr_number, head_sha:$head_sha, head_ref:$head_ref, head_repo:$head_repo, base_repo:$base_repo, changed_lines:$changed_lines}' \
|
||||
> autofix-out/metadata.json
|
||||
|
||||
echo "--- metadata ---"
|
||||
cat autofix-out/metadata.json
|
||||
echo "--- diff (head) ---"
|
||||
head -c 2000 autofix-out/autofix.patch || true
|
||||
|
||||
# Pinned to v7.0.1. Verify SHA via:
|
||||
# gh api repos/actions/upload-artifact/git/refs/tags/v7.0.1
|
||||
- name: Upload autofix artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: autofix
|
||||
path: autofix-out/
|
||||
retention-days: 1
|
||||
if-no-files-found: error
|
||||
@@ -35,9 +35,6 @@ on:
|
||||
pull_request_target:
|
||||
types: [opened, edited, reopened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention".
|
||||
# Include `github.event_name` so `pull_request` (validate-title) and
|
||||
# `pull_request_target` (autolabel) runs for the same PR do NOT share a slot
|
||||
|
||||
@@ -6,7 +6,6 @@ on:
|
||||
- 'v*'
|
||||
|
||||
# No workflow-level permissions — scoped per job below.
|
||||
permissions: {}
|
||||
|
||||
# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention".
|
||||
# Tag refs are unique per release, so distinct tags run in parallel. Re-pushes of the
|
||||
@@ -36,7 +35,7 @@ jobs:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 20
|
||||
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
|
||||
|
||||
@@ -58,7 +58,6 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read # read PR labels on the merge commit
|
||||
outputs:
|
||||
should_run: ${{ steps.decide.outputs.should_run }}
|
||||
head_sha: ${{ steps.decide.outputs.head_sha }}
|
||||
@@ -75,8 +74,6 @@ jobs:
|
||||
FORCE: ${{ inputs.force }}
|
||||
BUMP_INPUT: ${{ inputs.bump }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
HEAD_SHA=$(git rev-parse HEAD)
|
||||
@@ -99,49 +96,6 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Skip when the merge commit corresponds to a release ─────────
|
||||
# Two complementary checks (belt-and-suspenders):
|
||||
# 1. The HEAD commit subject matches `chore: release vX.Y.Z`
|
||||
# (the canonical release-PR title in this repo). Anchored
|
||||
# at both ends to require the bare title or the squash-merge
|
||||
# `(#NNNN)` suffix exactly — rejects noisy variants like
|
||||
# `chore: release v1.0.0 (something unrelated)`.
|
||||
# 2. The squash-merged PR carries the `release` label.
|
||||
# Either match suppresses the rc build — stable releases publish
|
||||
# via publish.yml on the v-tag, so the rc cycle should pause for
|
||||
# them rather than racing the npm publish.
|
||||
HEAD_SUBJECT="$(git log -1 --pretty=%s HEAD)"
|
||||
# Sanitise GitHub-Actions annotation prefixes before logging the
|
||||
# raw subject — defence-in-depth so a hypothetical commit subject
|
||||
# containing `::error::` or `::set-output::` cannot forge log
|
||||
# annotations even though %s strips newlines.
|
||||
HEAD_SUBJECT_SAFE="${HEAD_SUBJECT//::/__}"
|
||||
RELEASE_SUBJECT_RE='^chore:[[:space:]]*release[[:space:]]+v[0-9]+\.[0-9]+\.[0-9]+([[:space:]]+\(#[0-9]+\))?$'
|
||||
if [[ "$HEAD_SUBJECT" =~ $RELEASE_SUBJECT_RE ]]; then
|
||||
echo "HEAD commit subject matches a release commit — skipping rc."
|
||||
echo " subject (sanitised): $HEAD_SUBJECT_SAFE"
|
||||
echo "should_run=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Squash-merge commits include `(#NNNN)` at the end of the subject.
|
||||
if [[ "$HEAD_SUBJECT" =~ \(#([0-9]+)\)[[:space:]]*$ ]]; then
|
||||
PR_NUM="${BASH_REMATCH[1]}"
|
||||
echo "Detected squash-merge of PR #$PR_NUM — checking labels."
|
||||
if LABELS_JSON="$(gh pr view "$PR_NUM" --repo "$REPO" --json labels 2>/dev/null)"; then
|
||||
if printf '%s' "$LABELS_JSON" | jq -e '.labels[] | select(.name == "release")' >/dev/null; then
|
||||
echo "PR #$PR_NUM has the 'release' label — skipping rc."
|
||||
echo "should_run=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "PR #$PR_NUM has no 'release' label — proceeding."
|
||||
else
|
||||
# Lookup failure is not fatal — fall through to the dedup check
|
||||
# so a transient GH API hiccup doesn't silently suppress rc builds.
|
||||
echo "::warning::Could not read labels for PR #${PR_NUM} — falling through."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Dedup: is there already an rc/<HEAD_SHA> marker pointing at HEAD?
|
||||
MARKER="rc/${HEAD_SHA}"
|
||||
if git rev-parse "refs/tags/$MARKER" >/dev/null 2>&1; then
|
||||
@@ -195,7 +149,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 20
|
||||
registry-url: https://registry.npmjs.org
|
||||
# Hermetic install — release-candidate produces shipped artifacts.
|
||||
# setup-node v5+ caches by default when a packageManager field is
|
||||
@@ -340,11 +294,9 @@ jobs:
|
||||
fi
|
||||
fi
|
||||
|
||||
{
|
||||
echo "base=$BASE"
|
||||
echo "rc_n=$NEXT_N"
|
||||
echo "rc_version=$RC_VERSION"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
echo "base=$BASE" >> "$GITHUB_OUTPUT"
|
||||
echo "rc_n=$NEXT_N" >> "$GITHUB_OUTPUT"
|
||||
echo "rc_version=$RC_VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Apply rc version in-CI
|
||||
shell: bash
|
||||
@@ -402,11 +354,9 @@ jobs:
|
||||
# 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"
|
||||
echo "vtag=$VTAG" >> "$GITHUB_OUTPUT"
|
||||
echo "marker=$MARKER" >> "$GITHUB_OUTPUT"
|
||||
echo "release_sha=$RELEASE_SHA" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Publish to npm (rc dist-tag)
|
||||
run: npm publish --provenance --access public --tag rc
|
||||
|
||||
@@ -1,28 +1,19 @@
|
||||
name: Trivy Image Scan
|
||||
|
||||
# Builds Dockerfile.cli and Dockerfile.web, then scans the resulting images
|
||||
# for OS-package and language-package CVEs at MEDIUM+ severity.
|
||||
# for OS-package and language-package CVEs at HIGH/CRITICAL severity.
|
||||
# Findings upload to the Security tab; record-only (does not block merges).
|
||||
#
|
||||
# Trigger on Dockerfile changes in PRs so base-image/npm-layer remediation can
|
||||
# be verified before merge without running image scans on every PR.
|
||||
# NOT triggered on PRs — image builds are slow and base-image CVE churn
|
||||
# shouldn't gate feature delivery.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'Dockerfile.cli'
|
||||
- 'Dockerfile.web'
|
||||
- 'gitnexus/Dockerfile.test'
|
||||
- '.github/workflows/trivy.yml'
|
||||
push:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: '0 8 * * 1'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
@@ -70,7 +61,7 @@ jobs:
|
||||
image-ref: scan-target:${{ matrix.image.name }}
|
||||
format: sarif
|
||||
output: trivy-${{ matrix.image.name }}.sarif
|
||||
severity: MEDIUM,HIGH,CRITICAL
|
||||
severity: HIGH,CRITICAL
|
||||
# Hides CVEs with no available fix in the base image.
|
||||
ignore-unfixed: true
|
||||
exit-code: '0'
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
name: Workflow Lint
|
||||
name: Workflow Lint (zizmor)
|
||||
|
||||
# Lints .github/workflows/** for both:
|
||||
# - actionlint: YAML syntax, expression typing, shellcheck inside `run:`
|
||||
# blocks, unknown contexts, deprecated runner labels.
|
||||
# - zizmor: security misconfigurations — unpinned actions, dangerous
|
||||
# `${{ }}` interpolation, missing per-job permissions, etc.
|
||||
# Lints .github/workflows/** for known GitHub Actions security misconfigurations:
|
||||
# unpinned Actions, dangerous ${{ ... }} interpolation in run: blocks,
|
||||
# missing per-job permissions:, etc.
|
||||
#
|
||||
# Scoped to PRs that touch .github/** only — keeps off the typical PR
|
||||
# critical path.
|
||||
# Scoped to PRs that touch .github/** only — keeps off the typical PR critical path.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
@@ -15,35 +12,11 @@ on:
|
||||
paths:
|
||||
- '.github/**'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
actionlint:
|
||||
name: actionlint
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# Pinned to v2.1.2. Verify SHA via:
|
||||
# gh api repos/raven-actions/actionlint/git/refs/tags/v2.1.2
|
||||
# The action wraps the upstream `rhysd/actionlint` binary and emits
|
||||
# GitHub-annotation-formatted findings on PRs.
|
||||
- name: Run actionlint
|
||||
uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8 # v2.1.2
|
||||
with:
|
||||
fail-on-error: true
|
||||
|
||||
zizmor:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
@@ -14,15 +14,6 @@ rules:
|
||||
# no checkout of fork code occurs. Header comment in the file documents.
|
||||
- ci-report.yml
|
||||
|
||||
# workflow_run is the trusted half of the autofix pipeline. The
|
||||
# untrusted half (pr-autofix.yml) runs fork code with permissions:{}
|
||||
# and produces only a diff artifact (data, not executable code). The
|
||||
# publish job consumes the artifact, allowlist-validates every field
|
||||
# of metadata.json before exporting to $GITHUB_OUTPUT, never checks
|
||||
# out fork code, and never executes anything fork-controlled. Header
|
||||
# comment in the file documents the split.
|
||||
- pr-autofix-publish.yml
|
||||
|
||||
# pull_request_target needed by claude-code-action to access secrets
|
||||
# and post review comments on fork PRs. Mitigated by: PR checkouts pin
|
||||
# the fork's HEAD SHA (not the branch ref) to prevent TOCTOU races,
|
||||
|
||||
+24
-58
@@ -30,17 +30,17 @@ Format: `<type>[(scope)][!]: <subject>`
|
||||
|
||||
Allowed types and the release-notes section each one lands in (defined in `.github/release.yml`):
|
||||
|
||||
| Type | Label applied | Release-notes section |
|
||||
| ------------------ | --------------- | ------------------------------------------------------------ |
|
||||
| `feat` | `enhancement` | 🚀 Features |
|
||||
| `fix` | `bug` | 🐛 Bug Fixes |
|
||||
| `perf` | `performance` | 🏎️ Performance |
|
||||
| `refactor` | `refactor` | 🔄 Refactoring |
|
||||
| `test` | `test` | 🧪 Tests |
|
||||
| `ci` | `ci` | 👷 CI/CD |
|
||||
| `build` / `deps` | `dependencies` | 📦 Dependencies |
|
||||
| `docs` | `documentation` | (grouped under Other Changes unless a Docs section is added) |
|
||||
| `chore` / `revert` | `chore` | (excluded from release notes) |
|
||||
| Type | Label applied | Release-notes section |
|
||||
|------|---------------|-----------------------|
|
||||
| `feat` | `enhancement` | 🚀 Features |
|
||||
| `fix` | `bug` | 🐛 Bug Fixes |
|
||||
| `perf` | `performance` | 🏎️ Performance |
|
||||
| `refactor` | `refactor` | 🔄 Refactoring |
|
||||
| `test` | `test` | 🧪 Tests |
|
||||
| `ci` | `ci` | 👷 CI/CD |
|
||||
| `build` / `deps` | `dependencies` | 📦 Dependencies |
|
||||
| `docs` | `documentation` | (grouped under Other Changes unless a Docs section is added) |
|
||||
| `chore` / `revert` | `chore` | (excluded from release notes) |
|
||||
|
||||
Append `!` to the type (e.g. `feat(api)!: drop /v1 endpoint`) or include `BREAKING CHANGE:` in the PR body to flag a breaking change — the labeler then adds the `breaking` label and the 💥 Breaking Changes section is rendered first.
|
||||
|
||||
@@ -81,17 +81,17 @@ Every workflow under `.github/workflows/` MUST declare a top-level `concurrency:
|
||||
- **Merge queue (`merge_group`)**: when this event is added, use `${{ github.workflow }}-${{ github.event.merge_group.head_ref }}` with `cancel-in-progress: false` (every queue entry is a distinct ref; never cancel).
|
||||
- **`cancel-in-progress` policy:**
|
||||
|
||||
| Event | `cancel-in-progress` | Why |
|
||||
| ---------------------------------------- | -------------------- | -------------------------------- |
|
||||
| `pull_request` CI run | `true` | New push supersedes old run |
|
||||
| `push` to `main` | `false` | Every main commit gets validated |
|
||||
| Tag push (`v*` publish) | `false` | Never cancel mid-publish |
|
||||
| `push` to `main` for release-candidate | `false` | Never cancel mid-RC publish |
|
||||
| `workflow_dispatch` (release/publish) | `false` | Manual runs are intentional |
|
||||
| `workflow_run` (sticky-comment reports) | `false` | Serialize, don't race |
|
||||
| Per-PR bot workflows (`@claude`, review) | `false` | Serialize comments per PR |
|
||||
| PR-meta re-checks (pr-description-check) | `true` | Cheap, latest wins |
|
||||
| Single-slot utilities (triage sweep) | `true` | Latest dispatch supersedes |
|
||||
| Event | `cancel-in-progress` | Why |
|
||||
|-------|----------------------|-----|
|
||||
| `pull_request` CI run | `true` | New push supersedes old run |
|
||||
| `push` to `main` | `false` | Every main commit gets validated |
|
||||
| Tag push (`v*` publish) | `false` | Never cancel mid-publish |
|
||||
| `push` to `main` for release-candidate | `false` | Never cancel mid-RC publish |
|
||||
| `workflow_dispatch` (release/publish) | `false` | Manual runs are intentional |
|
||||
| `workflow_run` (sticky-comment reports) | `false` | Serialize, don't race |
|
||||
| Per-PR bot workflows (`@claude`, review) | `false` | Serialize comments per PR |
|
||||
| PR-meta re-checks (pr-description-check) | `true` | Cheap, latest wins |
|
||||
| Single-slot utilities (triage sweep) | `true` | Latest dispatch supersedes |
|
||||
|
||||
- For workflows that serve multiple events at once (e.g. `ci.yml` handles `pull_request`, `push`, and `workflow_call`), make `cancel-in-progress` event-aware:
|
||||
|
||||
@@ -103,41 +103,6 @@ Every workflow under `.github/workflows/` MUST declare a top-level `concurrency:
|
||||
|
||||
- When adding a new workflow, copy the concurrency block from an existing workflow of the same event shape.
|
||||
|
||||
## CI automation contracts
|
||||
|
||||
Two workflows produce machine-readable signals on every PR. Coding agents and humans alike can rely on the names and shapes below — change them with intent.
|
||||
|
||||
### `gitnexus/autofix`
|
||||
|
||||
`pr-autofix.yml` (untrusted) + `pr-autofix-publish.yml` (trusted) run `prettier --write` and `eslint --fix` against the PR head and surface a single ChatOps button on the PR. Three signals are emitted:
|
||||
|
||||
| Surface | Where | Notes |
|
||||
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| Sticky PR comment | Top-level comment with the HTML marker `<!-- gitnexus:pr-autofix-summary -->` and heading `## :sparkles: PR Autofix`. Only posted when there is something to fix; clean PRs stay silent. | Edit-in-place via marker; one comment per PR. |
|
||||
| Fenced JSON block | Inside the sticky, fenced as `gitnexus-autofix`. Schema `gitnexus.pr-autofix/v2` with fields `state` (`fixes-available`), `pr_number`, `head_sha`, `changed_lines`, `run_id`, and `apply_command` (literal `/autofix`). | Parseable signal — preferred over regexing prose. v1 fields preserved as a superset. |
|
||||
| Check Run | Stable name `gitnexus/autofix` on the PR head SHA. Conclusion: `success` (clean) or `neutral` (`fixes-available`). The neutral title is `Autofix available — comment /autofix to apply`. | Surfaced under PR Checks; readable via `gh pr checks <pr>`. |
|
||||
|
||||
To detect outcome from an agent: `gh pr checks <pr> --json name,conclusion,output | jq '.[] | select(.name == "gitnexus/autofix")'`.
|
||||
|
||||
Forks are supported. The untrusted half runs fork code with `permissions: {}` and ships the diff as an artifact; the trusted publish job consumes only the diff (data, not code) and posts the comment + check run.
|
||||
|
||||
#### Applying autofix
|
||||
|
||||
Comment `/autofix` on the PR (whole-line, no arguments). The `pr-autofix-apply.yml` workflow:
|
||||
|
||||
1. Validates the comment body matches `^/autofix\s*$` exactly. Quoted or inline mentions are silently ignored.
|
||||
2. Validates the commenter has `admin`, `write`, or `maintain` permission on the repo, OR is the PR author. Other commenters get a 👎 reaction and a refusal reply.
|
||||
3. Locates the most recent successful `pr-autofix.yml` run for the PR's current head SHA, downloads its `autofix` artifact, applies the patch, and pushes a `chore(autofix): ...` commit back to the PR head branch.
|
||||
4. Reacts ✅ on success, 👎 on stale-patch / push-failure, and posts a short reply with the apply-run URL in either case.
|
||||
|
||||
The apply workflow runs from the default branch's copy of the file regardless of where the comment originates — that's the trust anchor. There is no diff-size cap (the apply workflow uses `git apply` + push, not the GitHub review-comment API).
|
||||
|
||||
For fork PRs, the push succeeds only when the contributor has **Allow edits by maintainers** enabled on the PR (the default). When they have disabled it, the workflow fails loud with a 👎 reaction and an explanation comment.
|
||||
|
||||
Re-invoking `/autofix` after a successful apply is a safe no-op — the workflow detects the already-applied state via `git apply --check --reverse` and reacts ✅ without pushing.
|
||||
|
||||
**Sensitive paths.** The apply workflow refuses any patch that touches `.github/` (workflow files, CODEOWNERS, dependabot config). A malicious PR could ship a custom prettier or ESLint config that reformats workflow YAML; if accepted, those edits would be pushed under `contents: write` without human review. Apply formatter changes to files under `.github/` manually in a normal commit so they get the same review every other workflow change gets.
|
||||
|
||||
## AI-assisted contributions
|
||||
|
||||
If you use coding agents, follow project context files (e.g. `AGENTS.md`, `CLAUDE.md`) and avoid drive-by refactors unrelated to the issue. Prefer incremental, test-backed changes.
|
||||
@@ -199,7 +164,8 @@ Two publish workflows ship `gitnexus` to npm:
|
||||
the Docker build.
|
||||
- Manually run `docker build` + `docker push` locally and sign with Cosign
|
||||
against the same digest.
|
||||
- Delete `rc/<HEAD_SHA>` and `v<RC>` tags, then redispatch with `force: true` to re-run the full RC pipeline (cuts a new RC number).
|
||||
- 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).
|
||||
|
||||
The rc workflow never moves `latest`. To verify after a change, inspect dist-tags:
|
||||
|
||||
|
||||
+8
-20
@@ -1,32 +1,24 @@
|
||||
ARG BUILDPLATFORM
|
||||
ARG TARGETPLATFORM
|
||||
# Pinned npm version used to replace the bundled npm in the upstream Node
|
||||
# image. Bumping requires a coordinated update in Dockerfile.web and
|
||||
# gitnexus/Dockerfile.test so all images bootstrap the same npm.
|
||||
ARG NPM_VERSION=11.14.1
|
||||
|
||||
# -- Builder -----------------------------------------------------------
|
||||
# ── Builder ────────────────────────────────────────────────────────────
|
||||
# Native modules (tree-sitter-*, onnxruntime-node, node-gyp builds for
|
||||
# tree-sitter-proto / tree-sitter-swift) require python3 + a C/C++ toolchain.
|
||||
# node:22-bookworm-slim
|
||||
FROM node:22-bookworm-slim@sha256:9f6d5975c7dca860947d3915877f85607946403fc55349f39b4bc3688448bb6e AS builder
|
||||
ARG NPM_VERSION
|
||||
FROM node:22-trixie-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN npx --yes npm@${NPM_VERSION} install -g npm@${NPM_VERSION}
|
||||
|
||||
# Toolchain for node-gyp / native builds.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ git && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Build gitnexus-shared first - gitnexus depends on it as a workspace.
|
||||
# Build gitnexus-shared first — gitnexus depends on it as a workspace.
|
||||
COPY gitnexus-shared/package.json gitnexus-shared/package-lock.json ./gitnexus-shared/
|
||||
RUN npm ci --prefix gitnexus-shared
|
||||
COPY gitnexus-shared ./gitnexus-shared
|
||||
RUN rm -f gitnexus-shared/tsconfig.tsbuildinfo
|
||||
RUN npm run build --prefix gitnexus-shared
|
||||
|
||||
# Copy the full gitnexus package before installing - `npm ci` triggers
|
||||
# Copy the full gitnexus package before installing — `npm ci` triggers
|
||||
# `postinstall` (patches tree-sitter-swift, builds the vendored
|
||||
# tree-sitter-proto) and `prepare` (compiles TypeScript via scripts/build.js),
|
||||
# both of which need the source tree.
|
||||
@@ -36,15 +28,11 @@ RUN npm ci --prefix gitnexus
|
||||
# Drop dev dependencies for a smaller runtime layer.
|
||||
RUN npm prune --omit=dev --prefix gitnexus
|
||||
|
||||
# -- Runtime -----------------------------------------------------------
|
||||
# node:22-bookworm-slim
|
||||
FROM node:22-bookworm-slim@sha256:9f6d5975c7dca860947d3915877f85607946403fc55349f39b4bc3688448bb6e AS runtime
|
||||
# ── Runtime ────────────────────────────────────────────────────────────
|
||||
FROM node:22-trixie-slim AS runtime
|
||||
|
||||
# curl for the healthcheck; git so `gitnexus` can clone repos at runtime.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends curl git && rm -rf /var/lib/apt/lists/* \
|
||||
&& rm -rf /usr/local/lib/node_modules/npm \
|
||||
&& rm -rf /usr/local/lib/node_modules/corepack \
|
||||
&& rm -f /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends curl git && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -59,7 +47,7 @@ COPY --from=builder --chown=node:node /app/gitnexus/vendor ./gitnexus/vendor
|
||||
|
||||
USER node
|
||||
|
||||
# The web UI defaults to http://localhost:4747 - keep that contract.
|
||||
# The web UI defaults to http://localhost:4747 — keep that contract.
|
||||
ENV GITNEXUS_HOME=/data/gitnexus \
|
||||
NODE_ENV=production \
|
||||
PORT=4747
|
||||
|
||||
+3
-14
@@ -1,17 +1,10 @@
|
||||
ARG BUILDPLATFORM
|
||||
ARG TARGETPLATFORM
|
||||
# Pinned npm version — keep in sync with Dockerfile.cli and
|
||||
# gitnexus/Dockerfile.test.
|
||||
ARG NPM_VERSION=11.14.1
|
||||
|
||||
# node:22-bookworm-slim
|
||||
FROM --platform=$BUILDPLATFORM node:22-bookworm-slim@sha256:9f6d5975c7dca860947d3915877f85607946403fc55349f39b4bc3688448bb6e AS builder
|
||||
ARG NPM_VERSION
|
||||
FROM --platform=$BUILDPLATFORM node:22-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN npx --yes npm@${NPM_VERSION} install -g npm@${NPM_VERSION}
|
||||
|
||||
COPY gitnexus-shared/package.json gitnexus-shared/package-lock.json ./gitnexus-shared/
|
||||
RUN npm ci --prefix gitnexus-shared
|
||||
|
||||
@@ -26,13 +19,9 @@ RUN npm ci --prefix gitnexus-web
|
||||
COPY gitnexus-web ./gitnexus-web
|
||||
RUN npm run build --prefix gitnexus-web
|
||||
|
||||
# node:22-bookworm-slim
|
||||
FROM node:22-bookworm-slim@sha256:9f6d5975c7dca860947d3915877f85607946403fc55349f39b4bc3688448bb6e AS runtime
|
||||
FROM node:22-alpine AS runtime
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* \
|
||||
&& rm -rf /usr/local/lib/node_modules/npm \
|
||||
&& rm -rf /usr/local/lib/node_modules/corepack \
|
||||
&& rm -f /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack
|
||||
RUN apk add --no-cache curl
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -120,7 +120,7 @@ To configure MCP for your editor, run `npx gitnexus setup` once — or set it up
|
||||
| Editor | MCP | Skills | Hooks (auto-augment) | Support |
|
||||
| --------------------- | --- | ------ | -------------------- | -------------- |
|
||||
| **Claude Code** | Yes | Yes | Yes (PreToolUse + PostToolUse) | **Full** |
|
||||
| **Cursor** | Yes | Yes | Yes (postToolUse, [manual install](gitnexus-cursor-integration/README.md#hook-install)) | **Full** |
|
||||
| **Cursor** | Yes | Yes | — | MCP + Skills |
|
||||
| **Codex** | Yes | Yes | — | MCP + Skills |
|
||||
| **Windsurf** | Yes | — | — | MCP |
|
||||
| **OpenCode** | Yes | Yes | — | MCP + Skills |
|
||||
@@ -214,7 +214,6 @@ gitnexus clean --all --force # Delete all indexes
|
||||
gitnexus wiki [path] # Generate repository wiki from knowledge graph
|
||||
gitnexus wiki --model <model> # Wiki with custom LLM model (default: gpt-4o-mini)
|
||||
gitnexus wiki --base-url <url> # Wiki with custom LLM API base URL
|
||||
gitnexus publish # Notify the understand-quickly registry (opt-in, see below)
|
||||
|
||||
# Repository groups (multi-repo / monorepo service tracking)
|
||||
gitnexus group create <name> # Create a repository group
|
||||
@@ -229,12 +228,6 @@ gitnexus group status <name> # Check staleness of repos in a group
|
||||
|
||||
If `analyze` reports a worker parse timeout on a large or unusual repository, it keeps running and falls back safely. To give slow worker jobs more time, use `gitnexus analyze --worker-timeout 60` or set `GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=60000`. For very large files, `GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES` controls the worker job byte budget.
|
||||
|
||||
#### Publishing to understand-quickly (opt-in)
|
||||
|
||||
[`looptech-ai/understand-quickly`](https://github.com/looptech-ai/understand-quickly) is a public registry of code-knowledge graphs that lists `gitnexus@1` as a first-class format. After registering your repo once (`npx @understand-quickly/cli add` or the [wizard](https://looptech-ai.github.io/understand-quickly/add.html)), `gitnexus publish` fires a single `repository_dispatch` event so the registry resyncs your entry on demand instead of waiting for the nightly job.
|
||||
|
||||
It is opt-in and a no-op without `UNDERSTAND_QUICKLY_TOKEN` — a fine-grained GitHub PAT with `Repository dispatches: write` on the registry repo. Nothing else happens; no graph file is uploaded. See the [protocol spec](https://github.com/looptech-ai/understand-quickly/blob/main/docs/integrations/protocol.md) for the full contract.
|
||||
|
||||
### What Your AI Agent Gets
|
||||
|
||||
**16 tools** exposed via MCP (11 per-repo + 5 group):
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
# GitNexus — Cursor integration
|
||||
|
||||
Static config that adds GitNexus knowledge-graph augmentation and skill files to Cursor.
|
||||
|
||||
> **Hooks require Cursor 2.4+.** Earlier versions don't expose `postToolUse` and the hook will silently no-op.
|
||||
|
||||
## What you get
|
||||
|
||||
| Layer | What it does | How it's installed |
|
||||
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
|
||||
| **MCP** | `gitnexus` MCP server with 16 tools (`query`, `context`, `impact`, `detect_changes`, `rename`, …) | `npx gitnexus setup` writes `~/.cursor/mcp.json` automatically. |
|
||||
| **Skills** | `/gitnexus-exploring`, `/gitnexus-debugging`, `/gitnexus-impact-analysis`, `/gitnexus-refactoring`, `/gitnexus-pr-review` markdown skills | `npx gitnexus setup` copies them to `~/.cursor/skills/gitnexus/`. |
|
||||
| **Hooks** _(this README)_ | `postToolUse` hook that enriches `Shell` / `Read` / `Grep` tool calls with graph context — same augmentation Claude Code gets | **Manual** — copy the two files described below into your project's `.cursor/`. |
|
||||
|
||||
## Hook install
|
||||
|
||||
Cursor 2.4+ reads `.cursor/hooks.json` from the project root and runs hook commands with the project root as the working directory ([docs](https://cursor.com/docs/agent/hooks)).
|
||||
|
||||
From this repo's `gitnexus-cursor-integration/hooks/`, copy the two files into your **project root**:
|
||||
|
||||
```text
|
||||
<your-project>/
|
||||
├── .cursor/
|
||||
│ └── hooks.json ← from gitnexus-cursor-integration/hooks/hooks.json
|
||||
└── hooks/
|
||||
└── gitnexus-hook.cjs ← from gitnexus-cursor-integration/hooks/gitnexus-hook.cjs
|
||||
```
|
||||
|
||||
Equivalent shell commands (run from your project root, with `$GITNEXUS_REPO` pointing at a clone of this repo):
|
||||
|
||||
```bash
|
||||
mkdir -p .cursor hooks
|
||||
cp "$GITNEXUS_REPO/gitnexus-cursor-integration/hooks/hooks.json" .cursor/hooks.json
|
||||
cp "$GITNEXUS_REPO/gitnexus-cursor-integration/hooks/gitnexus-hook.cjs" hooks/gitnexus-hook.cjs
|
||||
```
|
||||
|
||||
If you already have a `.cursor/hooks.json`, merge the `hooks.postToolUse` array rather than overwriting.
|
||||
|
||||
### Verify
|
||||
|
||||
1. Index the project: `npx gitnexus analyze`
|
||||
2. Reload the Cursor window so it picks up the new hook config.
|
||||
3. Ask the agent something that triggers `Read` / `Grep` / `Shell rg`. You should see a `[GitNexus]` block appended to the tool result.
|
||||
4. Diagnose silent no-ops by setting `GITNEXUS_DEBUG=1` in your shell environment — the hook will write Cursor's raw event payload to stderr so you can verify field names.
|
||||
|
||||
### What's installed manually vs. automated
|
||||
|
||||
| Step | Automated by `gitnexus setup`? |
|
||||
| -------------------------------------------------------------------- | ------------------------------ |
|
||||
| `~/.cursor/mcp.json` | ✅ |
|
||||
| `~/.cursor/skills/gitnexus/*` | ✅ |
|
||||
| `<project>/.cursor/hooks.json` + `<project>/hooks/gitnexus-hook.cjs` | ❌ — copy manually (see above) |
|
||||
|
||||
Hook install is per-project (Cursor scopes hooks to a project root); skills and MCP config are global.
|
||||
|
||||
## Hook contract
|
||||
|
||||
The hook receives a JSON event on stdin matching Cursor 2.4's `postToolUse` shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"tool_name": "Grep" | "Read" | "Shell",
|
||||
"tool_input": { /* tool-specific */ },
|
||||
"tool_output": { /* optional */ },
|
||||
"cwd": "/absolute/path/to/project"
|
||||
}
|
||||
```
|
||||
|
||||
It writes augmentation context to stdout as:
|
||||
|
||||
```json
|
||||
{ "additional_context": "[GitNexus] …" }
|
||||
```
|
||||
|
||||
Empty stdout means "no augmentation, continue normally" — the hook never blocks the tool.
|
||||
|
||||
### Pattern extraction per tool
|
||||
|
||||
| Tool | Pattern source | Notes |
|
||||
| ------- | ---------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
|
||||
| `Grep` | `tool_input.query` (also `pattern`, `regex`, `q`, `search`, `searchQuery`) | Last-resort fallback: longest string value in `tool_input` (≥ 3 chars). |
|
||||
| `Read` | basename of `tool_input.target_file` (also `file_path`, `filePath`, `path`, `file`), stripped to identifier characters | `auth/handler.ts` → `handler`. |
|
||||
| `Shell` | First positional argument after `rg` / `grep` in `tool_input.command` | Best-effort tokenizer; quoted multi-word patterns (`rg "User Service"`) extract the first word only. |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Nothing happens** — Confirm Cursor is on 2.4+ and the project root has both `.cursor/hooks.json` and the script at `hooks/gitnexus-hook.cjs`. Then `npx gitnexus list` to confirm the project is indexed.
|
||||
- **`gitnexus` not found** — The hook prefers a locally-resolvable `gitnexus/dist/cli/index.js` and falls back to `npx -y gitnexus`. Install globally with `npm i -g gitnexus` to skip the npx cold-start latency.
|
||||
- **Wrong pattern extracted** — Set `GITNEXUS_DEBUG=1` and run a tool call. The raw stdin payload is logged to stderr; use it to confirm Cursor's actual `tool_input` field names against the table above. If they differ, file an issue with the captured payload.
|
||||
@@ -0,0 +1,50 @@
|
||||
#!/bin/bash
|
||||
# GitNexus beforeShellExecution hook for Cursor
|
||||
# Receives JSON on stdin with { command, cwd, timeout }
|
||||
# Returns JSON on stdout with { permission, agent_message }
|
||||
#
|
||||
# Extracts search pattern from grep/rg commands, runs gitnexus augment,
|
||||
# and injects the enriched context via agent_message.
|
||||
|
||||
INPUT=$(cat)
|
||||
|
||||
COMMAND=$(echo "$INPUT" | jq -r '.command // empty' 2>/dev/null)
|
||||
|
||||
if [ -z "$COMMAND" ]; then
|
||||
echo '{"permission":"allow"}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Skip non-search commands
|
||||
case "$COMMAND" in
|
||||
cd\ *|npm\ *|yarn\ *|pnpm\ *|git\ commit*|git\ push*|git\ pull*|mkdir\ *|rm\ *|cp\ *|mv\ *|echo\ *|cat\ *)
|
||||
echo '{"permission":"allow"}'
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
# Extract search pattern from rg/grep commands
|
||||
PATTERN=""
|
||||
if echo "$COMMAND" | grep -qE '\brg\b'; then
|
||||
PATTERN=$(echo "$COMMAND" | sed -n "s/.*\brg\s\+\(--[^ ]*\s\+\)*['\"]\\?\([^'\";\| >]*\\).*/\2/p")
|
||||
elif echo "$COMMAND" | grep -qE '\bgrep\b'; then
|
||||
PATTERN=$(echo "$COMMAND" | sed -n "s/.*\bgrep\s\+\(-[^ ]*\s\+\)*['\"]\\?\([^'\";\| >]*\\).*/\2/p")
|
||||
fi
|
||||
|
||||
if [ -z "$PATTERN" ] || [ ${#PATTERN} -lt 3 ]; then
|
||||
echo '{"permission":"allow"}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Run gitnexus augment
|
||||
RESULT=$(npx -y gitnexus augment "$PATTERN" 2>/dev/null)
|
||||
|
||||
if [ -n "$RESULT" ]; then
|
||||
# Escape for JSON
|
||||
ESCAPED=$(echo "$RESULT" | jq -Rs .)
|
||||
echo "{\"permission\":\"allow\",\"agent_message\":$ESCAPED}"
|
||||
else
|
||||
echo '{"permission":"allow"}'
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -1,259 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* GitNexus Cursor postToolUse Hook
|
||||
*
|
||||
* Receives a JSON event on stdin describing a finished tool call, derives a
|
||||
* search pattern (Grep query, Read file basename, or rg/grep arg from a Shell
|
||||
* command), runs `gitnexus augment <pattern>`, and emits the enriched context
|
||||
* back as `{ additional_context: "..." }` so the agent sees it alongside the
|
||||
* tool result.
|
||||
*
|
||||
* Replaces the legacy beforeShellExecution / augment-shell.sh pipeline:
|
||||
* - Cross-platform (no bash, no jq — runs on Windows out of the box)
|
||||
* - Covers Read and Grep, not just Shell rg/grep
|
||||
*
|
||||
* Cursor 2.4+ generic hooks: https://cursor.com/docs/agent/hooks
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
function readInput() {
|
||||
try {
|
||||
const data = fs.readFileSync(0, 'utf-8');
|
||||
return JSON.parse(data);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function isGlobalRegistryDir(candidate) {
|
||||
if (fs.existsSync(path.join(candidate, 'meta.json'))) return false;
|
||||
return (
|
||||
fs.existsSync(path.join(candidate, 'registry.json')) ||
|
||||
fs.existsSync(path.join(candidate, 'repos'))
|
||||
);
|
||||
}
|
||||
|
||||
function walkForGitNexusDir(startDir) {
|
||||
let dir = startDir;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const candidate = path.join(dir, '.gitnexus');
|
||||
if (fs.existsSync(candidate)) {
|
||||
if (!isGlobalRegistryDir(candidate)) return candidate;
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findCanonicalRepoRoot(cwd) {
|
||||
try {
|
||||
const result = spawnSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 2000,
|
||||
cwd,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
if (result.error || result.status !== 0) return null;
|
||||
const commonDir = (result.stdout || '').trim();
|
||||
if (!commonDir || !path.isAbsolute(commonDir)) return null;
|
||||
return path.dirname(commonDir);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function findGitNexusDir(startDir) {
|
||||
const cwd = startDir || process.cwd();
|
||||
const fromCwd = walkForGitNexusDir(cwd);
|
||||
if (fromCwd) return fromCwd;
|
||||
const canonicalRoot = findCanonicalRepoRoot(cwd);
|
||||
if (canonicalRoot && canonicalRoot !== cwd) {
|
||||
return walkForGitNexusDir(canonicalRoot);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseRgGrepPattern(cmd) {
|
||||
const tokens = cmd.split(/\s+/);
|
||||
let foundCmd = false;
|
||||
let skipNext = false;
|
||||
const flagsWithValues = new Set([
|
||||
'-e',
|
||||
'-f',
|
||||
'-m',
|
||||
'-A',
|
||||
'-B',
|
||||
'-C',
|
||||
'-g',
|
||||
'--glob',
|
||||
'-t',
|
||||
'--type',
|
||||
'--include',
|
||||
'--exclude',
|
||||
]);
|
||||
|
||||
for (const token of tokens) {
|
||||
if (skipNext) {
|
||||
skipNext = false;
|
||||
continue;
|
||||
}
|
||||
if (!foundCmd) {
|
||||
if (/\brg$|\bgrep$/.test(token)) foundCmd = true;
|
||||
continue;
|
||||
}
|
||||
if (token.startsWith('-')) {
|
||||
if (flagsWithValues.has(token)) skipNext = true;
|
||||
continue;
|
||||
}
|
||||
const cleaned = token.replace(/['"]/g, '');
|
||||
return cleaned.length >= 3 ? cleaned : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a search pattern from the tool input. Cursor 2.4 docs at
|
||||
* https://cursor.com/docs/agent/hooks list the tool *matchers* but do not
|
||||
* formally specify the per-tool tool_input field names, so we probe a
|
||||
* generous set of MCP-style aliases. As a last-resort fallback for Grep
|
||||
* (the highest-frequency search path) we also accept the longest plausible
|
||||
* string value in tool_input. Set GITNEXUS_DEBUG=1 to log the raw payload
|
||||
* to stderr if Cursor changes the contract and aliases stop matching.
|
||||
*/
|
||||
function pickLongestStringValue(obj) {
|
||||
let best = null;
|
||||
if (!obj || typeof obj !== 'object') return null;
|
||||
for (const v of Object.values(obj)) {
|
||||
if (typeof v === 'string' && v.length >= 3 && (!best || v.length > best.length)) {
|
||||
best = v;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function extractPattern(toolName, toolInput) {
|
||||
const t = (toolName || '').toLowerCase();
|
||||
|
||||
if (t === 'grep') {
|
||||
const aliases = [
|
||||
toolInput.query,
|
||||
toolInput.pattern,
|
||||
toolInput.regex,
|
||||
toolInput.q,
|
||||
toolInput.search,
|
||||
toolInput.searchQuery,
|
||||
];
|
||||
for (const a of aliases) {
|
||||
if (typeof a === 'string' && a.length >= 3) return a;
|
||||
}
|
||||
// Last resort: scan tool_input for any reasonable-looking string value.
|
||||
return pickLongestStringValue(toolInput);
|
||||
}
|
||||
|
||||
if (t === 'read') {
|
||||
const filePath =
|
||||
toolInput.target_file ||
|
||||
toolInput.file_path ||
|
||||
toolInput.filePath ||
|
||||
toolInput.path ||
|
||||
toolInput.file ||
|
||||
'';
|
||||
if (!filePath) return null;
|
||||
const base = path.basename(String(filePath), path.extname(String(filePath)));
|
||||
const cleaned = base.replace(/[^a-zA-Z0-9_]/g, '');
|
||||
return cleaned.length >= 3 ? cleaned : null;
|
||||
}
|
||||
|
||||
if (t === 'shell') {
|
||||
const cmd = toolInput.command || '';
|
||||
if (!/\brg\b|\bgrep\b/.test(cmd)) return null;
|
||||
// NOTE: parseRgGrepPattern uses split(/\s+/) and cannot handle shell
|
||||
// quoting. `rg "User Service" src/` returns "User" (the first token
|
||||
// after the rg/grep arg, with surrounding quotes stripped) — the
|
||||
// multi-word pattern is intentionally not reconstructed since BM25 is
|
||||
// already token-tolerant. Quoted single tokens (`rg "validateUser"`)
|
||||
// work fine.
|
||||
return parseRgGrepPattern(cmd);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveCliPath() {
|
||||
try {
|
||||
return require.resolve('gitnexus/dist/cli/index.js');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function runGitNexusCli(cliPath, args, cwd, timeout) {
|
||||
const isWin = process.platform === 'win32';
|
||||
if (cliPath) {
|
||||
return spawnSync(process.execPath, [cliPath, ...args], {
|
||||
encoding: 'utf-8',
|
||||
timeout,
|
||||
cwd,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
}
|
||||
return spawnSync(isWin ? 'npx.cmd' : 'npx', ['-y', 'gitnexus', ...args], {
|
||||
encoding: 'utf-8',
|
||||
timeout: timeout + 5000,
|
||||
cwd,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
}
|
||||
|
||||
function main() {
|
||||
try {
|
||||
const input = readInput();
|
||||
if (process.env.GITNEXUS_DEBUG) {
|
||||
// Echo the payload so users can capture Cursor's actual contract when
|
||||
// diagnosing why augmentation isn't firing. Stderr only — stdout is
|
||||
// reserved for the JSON response Cursor consumes.
|
||||
try {
|
||||
process.stderr.write(
|
||||
`GitNexus Cursor hook stdin: ${JSON.stringify(input).slice(0, 500)}\n`,
|
||||
);
|
||||
} catch {
|
||||
/* never let debug logging break the hook */
|
||||
}
|
||||
}
|
||||
const cwd = input.cwd || process.cwd();
|
||||
if (!path.isAbsolute(cwd)) return;
|
||||
if (!findGitNexusDir(cwd)) return;
|
||||
|
||||
const toolName = input.tool_name || '';
|
||||
const toolInput = input.tool_input || {};
|
||||
|
||||
const pattern = extractPattern(toolName, toolInput);
|
||||
if (!pattern || pattern.length < 3) return;
|
||||
|
||||
const cliPath = resolveCliPath();
|
||||
let result = '';
|
||||
try {
|
||||
const child = runGitNexusCli(cliPath, ['augment', '--', pattern], cwd, 7000);
|
||||
if (!child.error && child.status === 0) {
|
||||
result = child.stderr || '';
|
||||
}
|
||||
} catch {
|
||||
/* graceful failure */
|
||||
}
|
||||
|
||||
if (result && result.trim()) {
|
||||
console.log(JSON.stringify({ additional_context: result.trim() }));
|
||||
}
|
||||
} catch (err) {
|
||||
if (process.env.GITNEXUS_DEBUG) {
|
||||
console.error('GitNexus Cursor hook error:', (err.message || '').slice(0, 200));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"version": 1,
|
||||
"hooks": {
|
||||
"postToolUse": [
|
||||
"beforeShellExecution": [
|
||||
{
|
||||
"matcher": "Shell|Read|Grep",
|
||||
"command": "node ./hooks/gitnexus-hook.cjs",
|
||||
"timeout": 10
|
||||
"command": "./hooks/augment-shell.sh",
|
||||
"timeout": 5,
|
||||
"matcher": "\\brg\\b|\\bgrep\\b"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -10,10 +10,6 @@
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./test-helpers": {
|
||||
"types": "./dist/test-helpers.d.ts",
|
||||
"default": "./dist/test-helpers.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -143,34 +143,6 @@ export type { ScopeTree } from './scope-resolution/scope-tree.js';
|
||||
export { buildPositionIndex } from './scope-resolution/position-index.js';
|
||||
export type { PositionIndex } from './scope-resolution/position-index.js';
|
||||
|
||||
// Resilient fetch primitives — bounded retries + per-process circuit breaker.
|
||||
// Test-only helpers (`__resetBreakerRegistry__`, `classifyOutcome`) are
|
||||
// reachable via the separate `gitnexus-shared/test-helpers` subpath; do
|
||||
// NOT add them here. Production consumers must not call them.
|
||||
export { withRetry, computeBackoffMs } from './integrations/retry.js';
|
||||
export type { RetryOptions, RetryDecision } from './integrations/retry.js';
|
||||
export { CircuitBreaker, CircuitOpenError, getBreaker } from './integrations/circuit-breaker.js';
|
||||
export type { CircuitBreakerOptions } from './integrations/circuit-breaker.js';
|
||||
export {
|
||||
resilientFetch,
|
||||
ResilientFetchExhaustedError,
|
||||
RETRY_AFTER_CAP_MS,
|
||||
parseRetryAfter,
|
||||
} from './integrations/resilient-fetch.js';
|
||||
export type { ResilientFetchOptions } from './integrations/resilient-fetch.js';
|
||||
|
||||
// Understand-Quickly registry integration (opt-in)
|
||||
export {
|
||||
UNDERSTAND_QUICKLY_DISPATCH_URL,
|
||||
UNDERSTAND_QUICKLY_EVENT_TYPE,
|
||||
UNDERSTAND_QUICKLY_TOKEN_ENV,
|
||||
buildUqDispatchPayload,
|
||||
isValidOwnerRepo,
|
||||
parseOwnerRepoFromRemote,
|
||||
stripGitSuffix,
|
||||
} from './integrations/understand-quickly.js';
|
||||
export type { UqDispatchPayload } from './integrations/understand-quickly.js';
|
||||
|
||||
// Shadow-mode diff + aggregation (RFC §6.3; Ring 2 SHARED #918)
|
||||
export { diffResolutions } from './scope-resolution/shadow/diff.js';
|
||||
export type {
|
||||
|
||||
@@ -1,273 +0,0 @@
|
||||
/**
|
||||
* Per-process circuit breaker.
|
||||
*
|
||||
* Closed -> Open transition fires after `failureThreshold` consecutive
|
||||
* failures. While Open, `check` throws `CircuitOpenError` until
|
||||
* `cooldownMs` has elapsed since the breaker tripped. The first call
|
||||
* after the cooldown enters Half-Open and consumes the *probe permit*:
|
||||
* a recorded success returns to Closed; a recorded failure flips back
|
||||
* to Open with a fresh timestamp.
|
||||
*
|
||||
* Half-open admits exactly one in-flight probe at a time. Concurrent
|
||||
* callers attempting `check()` while a probe is outstanding receive
|
||||
* `CircuitOpenError` with `retryAfterMs = halfOpenRetryAfterMs` (default
|
||||
* 1000ms; configurable). This prevents the recovery-time thundering
|
||||
* herd that defeats the breaker's "fail fast" promise.
|
||||
*
|
||||
* Outcome reporting splits permit-release from state-resolution:
|
||||
* - `recordSuccess` — releases the probe permit, resets the failure
|
||||
* counter, transitions to Closed. Reserved for true 2xx/3xx outcomes.
|
||||
* - `recordFailure` — releases the probe permit, increments the
|
||||
* consecutive-failure counter, transitions to Open with a fresh
|
||||
* `openedAt` (when called from Half-Open or when the threshold
|
||||
* trips from Closed).
|
||||
* - `recordNeutral` — releases the probe permit, BUT leaves state and
|
||||
* counter untouched. Used for outcomes that are neither evidence of
|
||||
* backend health nor evidence of backend failure (caller-driven
|
||||
* cancellation, local timeout, terminal 4xx client errors). Critical
|
||||
* design point: if `recordNeutral` did not release the permit, a
|
||||
* single `TimeoutError` from per-attempt `AbortSignal.timeout` would
|
||||
* route through `recordNeutral` and permanently park the breaker in
|
||||
* half-open until process restart. Releasing the permit while leaving
|
||||
* state half-open keeps the "neutral doesn't claim health" semantic
|
||||
* without creating that wedge.
|
||||
*
|
||||
* Pairing invariant: every successful `check()` MUST be paired with
|
||||
* exactly one `record*()` on every code path including throws. Direct
|
||||
* consumers should wrap the protected operation in `try/finally`:
|
||||
*
|
||||
* breaker.check();
|
||||
* try {
|
||||
* const result = await operation();
|
||||
* breaker.recordSuccess();
|
||||
* return result;
|
||||
* } catch (err) {
|
||||
* // classify err and call recordFailure / recordNeutral / etc.
|
||||
* throw err;
|
||||
* }
|
||||
*
|
||||
* `resilientFetch`'s catch-all on `fetchImpl` already satisfies this
|
||||
* for that consumer.
|
||||
*
|
||||
* Atomicity model: the half-open gate relies on JavaScript event-loop
|
||||
* single-threadedness within a synchronous `check()` body. There is no
|
||||
* `await` inside `check()`; concurrent callers serialize on microtask
|
||||
* order, and exactly one observes `probeInFlight === false`. Do not
|
||||
* introduce `await` inside `check()` without revisiting the gate. If
|
||||
* this code is ever ported to a runtime with shared-memory threads
|
||||
* (Node `worker_threads` with `SharedArrayBuffer`, Web Workers with
|
||||
* shared registries), the boolean must become an atomic CAS — Resilience4j
|
||||
* and Hystrix use atomic permits *because* they run in JVM thread pools.
|
||||
*
|
||||
* Runtime-agnostic: depends only on a `now()` clock and standard JS —
|
||||
* no Node-only imports. Tests inject `now` to advance the clock
|
||||
* deterministically without `vi.useFakeTimers()`.
|
||||
*/
|
||||
|
||||
export class CircuitOpenError extends Error {
|
||||
override readonly name = 'CircuitOpenError';
|
||||
/** Approximate wait time before the breaker may transition to Half-Open
|
||||
* (or before the in-flight probe is expected to resolve). */
|
||||
readonly retryAfterMs: number;
|
||||
|
||||
constructor(retryAfterMs: number, key?: string) {
|
||||
super(
|
||||
key
|
||||
? `Circuit '${key}' is open; retry in ${Math.ceil(retryAfterMs / 1000)}s`
|
||||
: `Circuit is open; retry in ${Math.ceil(retryAfterMs / 1000)}s`,
|
||||
);
|
||||
this.retryAfterMs = retryAfterMs;
|
||||
}
|
||||
}
|
||||
|
||||
export interface CircuitBreakerOptions {
|
||||
/** Consecutive failures required to trip Closed -> Open. */
|
||||
failureThreshold?: number;
|
||||
/** Milliseconds Open before the next call may probe (Half-Open). */
|
||||
cooldownMs?: number;
|
||||
/**
|
||||
* Milliseconds to suggest in `CircuitOpenError.retryAfterMs` when the
|
||||
* breaker is Half-Open with the probe permit consumed. Default 1000ms.
|
||||
* Consumers with long-running protected ops (LLM streaming, large
|
||||
* uploads) should raise this — the cooldown clock is no longer the
|
||||
* right answer because cooldown has elapsed. Returning 0 invites
|
||||
* retry storms; returning the full cooldown misleads about wait.
|
||||
*/
|
||||
halfOpenRetryAfterMs?: number;
|
||||
/** Optional key for error messages and registry lookups. */
|
||||
key?: string;
|
||||
/** Clock override — defaults to `Date.now`. Tests inject deterministic time. */
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
type State = 'closed' | 'open' | 'half-open';
|
||||
|
||||
export class CircuitBreaker {
|
||||
private readonly failureThreshold: number;
|
||||
private readonly cooldownMs: number;
|
||||
private readonly halfOpenRetryAfterMs: number;
|
||||
private readonly key: string | undefined;
|
||||
private readonly now: () => number;
|
||||
|
||||
private state: State = 'closed';
|
||||
private consecutiveFailures = 0;
|
||||
private openedAt: number | null = null;
|
||||
/**
|
||||
* True between a successful `check()` and the next `record*()` call
|
||||
* during Half-Open. Gates concurrent callers from stampeding a still-
|
||||
* recovering dependency. Boolean rather than counter — single-permit
|
||||
* is the conservative end of the Hystrix/Resilience4j spectrum.
|
||||
*/
|
||||
private probeInFlight = false;
|
||||
|
||||
constructor(opts: CircuitBreakerOptions = {}) {
|
||||
this.failureThreshold = opts.failureThreshold ?? 3;
|
||||
this.cooldownMs = opts.cooldownMs ?? 30_000;
|
||||
this.halfOpenRetryAfterMs = opts.halfOpenRetryAfterMs ?? 1_000;
|
||||
this.key = opts.key;
|
||||
this.now = opts.now ?? (() => Date.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw `CircuitOpenError` if the breaker won't admit this call.
|
||||
* Otherwise consume the half-open probe permit (if applicable) and
|
||||
* return so the caller can attempt the protected work.
|
||||
*
|
||||
* Three rejection paths:
|
||||
* 1. Open and still in cooldown → throws with `retryAfterMs` =
|
||||
* remaining cooldown.
|
||||
* 2. Open with cooldown elapsed AND a probe is already in flight
|
||||
* (race: another caller transitioned to half-open and grabbed
|
||||
* the permit on a microtask before us) → throws with
|
||||
* `halfOpenRetryAfterMs`.
|
||||
* 3. Half-Open with probe in flight → throws with `halfOpenRetryAfterMs`.
|
||||
*
|
||||
* **Pairing invariant**: every successful return from `check()` MUST
|
||||
* be paired with exactly one `recordSuccess` / `recordFailure` /
|
||||
* `recordNeutral` on every code path including thrown exceptions.
|
||||
* Failing to pair leaves the probe permit consumed forever and
|
||||
* wedges the breaker. See file-header JSDoc for the canonical
|
||||
* try/finally pattern.
|
||||
*/
|
||||
check(): void {
|
||||
if (this.state === 'open' && this.openedAt !== null) {
|
||||
const elapsed = this.now() - this.openedAt;
|
||||
if (elapsed < this.cooldownMs) {
|
||||
throw new CircuitOpenError(this.cooldownMs - elapsed, this.key);
|
||||
}
|
||||
// Cooldown elapsed — transition to Half-Open. The very next
|
||||
// `probeInFlight` check below decides whether THIS caller gets
|
||||
// the permit or hits the gate.
|
||||
this.state = 'half-open';
|
||||
}
|
||||
|
||||
if (this.state === 'half-open') {
|
||||
if (this.probeInFlight) {
|
||||
throw new CircuitOpenError(this.halfOpenRetryAfterMs, this.key);
|
||||
}
|
||||
this.probeInFlight = true;
|
||||
}
|
||||
// Closed state falls through silently.
|
||||
}
|
||||
|
||||
recordSuccess(): void {
|
||||
this.probeInFlight = false;
|
||||
this.consecutiveFailures = 0;
|
||||
this.state = 'closed';
|
||||
this.openedAt = null;
|
||||
}
|
||||
|
||||
recordFailure(): void {
|
||||
this.probeInFlight = false;
|
||||
this.consecutiveFailures += 1;
|
||||
if (this.state === 'half-open' || this.consecutiveFailures >= this.failureThreshold) {
|
||||
this.state = 'open';
|
||||
this.openedAt = this.now();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Releases the probe permit BUT leaves state and counter untouched.
|
||||
* Use when an attempt produced a response or error that should not
|
||||
* influence breaker health in either direction — caller-driven aborts,
|
||||
* local AbortSignal timeouts, terminal 4xx client errors.
|
||||
*
|
||||
* Why permit-release-without-state-resolution: if `recordNeutral` did
|
||||
* not clear `probeInFlight`, a single `TimeoutError` from per-attempt
|
||||
* `AbortSignal.timeout` (which routes through neutral classification)
|
||||
* would permanently park the breaker in half-open. Since timeouts are
|
||||
* an *expected* outcome under flaky-dependency conditions, the cited
|
||||
* "per-attempt timeout bounds the stuck state" mitigation would itself
|
||||
* be the trigger for a permanent wedge. Releasing the permit closes
|
||||
* that loop while keeping the "neutral doesn't claim dependency
|
||||
* health" semantic.
|
||||
*
|
||||
* Calling `recordSuccess` for these would erase legitimate prior
|
||||
* failure signal; calling `recordFailure` would trip the breaker for
|
||||
* outcomes the backend isn't responsible for.
|
||||
*/
|
||||
recordNeutral(): void {
|
||||
this.probeInFlight = false;
|
||||
// State and consecutiveFailures are preserved by design.
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure read — no state mutation, no permit accounting. Returns the
|
||||
* *would-be* state at the current instant: 'half-open' if the breaker
|
||||
* is open with cooldown elapsed (regardless of whether a probe is in
|
||||
* flight), 'open' if open and still in cooldown, 'closed' otherwise.
|
||||
*
|
||||
* Inspection-only; safe to call from tests without consuming a probe
|
||||
* permit. The implicit Open -> Half-Open transition that mutates
|
||||
* `state` lives in `check()` only.
|
||||
*/
|
||||
getState(): State {
|
||||
if (this.state === 'open' && this.openedAt !== null) {
|
||||
const elapsed = this.now() - this.openedAt;
|
||||
if (elapsed >= this.cooldownMs) return 'half-open';
|
||||
}
|
||||
return this.state;
|
||||
}
|
||||
getConsecutiveFailures(): number {
|
||||
return this.consecutiveFailures;
|
||||
}
|
||||
/** Inspection-only test accessor for the half-open probe permit. */
|
||||
isProbeInFlight(): boolean {
|
||||
return this.probeInFlight;
|
||||
}
|
||||
/** Timestamp (ms since epoch) when the breaker last transitioned to Open,
|
||||
* or `null` if it's currently Closed. Useful for computing remaining
|
||||
* cooldown without consuming a probe permit via `check()`. */
|
||||
getOpenedAt(): number | null {
|
||||
return this.openedAt;
|
||||
}
|
||||
/** Configured cooldown duration in milliseconds. */
|
||||
getCooldownMs(): number {
|
||||
return this.cooldownMs;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Per-process registry ────────────────────────────────────────────
|
||||
//
|
||||
// Single shared map keyed on caller-chosen strings. Used by
|
||||
// `resilient-fetch.ts` so multiple call sites targeting the same logical
|
||||
// endpoint share breaker state. Per-process only — not persisted.
|
||||
|
||||
const registry = new Map<string, CircuitBreaker>();
|
||||
|
||||
export function getBreaker(key: string, opts?: CircuitBreakerOptions): CircuitBreaker {
|
||||
let breaker = registry.get(key);
|
||||
if (!breaker) {
|
||||
breaker = new CircuitBreaker({ ...opts, key });
|
||||
registry.set(key, breaker);
|
||||
}
|
||||
return breaker;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test-only: clear all registered breakers. Tests must call this in
|
||||
* `beforeEach` to prevent breaker state from leaking across test cases.
|
||||
*/
|
||||
export function __resetBreakerRegistry__(): void {
|
||||
registry.clear();
|
||||
}
|
||||
@@ -1,279 +0,0 @@
|
||||
/**
|
||||
* `resilientFetch` — fetch wrapped in retry + circuit breaker, with
|
||||
* GitHub-flavoured retry classification baked in (Retry-After parsing,
|
||||
* 401/403/404/422 treated as terminal client errors).
|
||||
*
|
||||
* Designed for the `gitnexus publish` GitHub `repository_dispatch`
|
||||
* call, but the classification rules apply to any GitHub REST endpoint.
|
||||
* Runtime-agnostic — no Node-only imports.
|
||||
*/
|
||||
|
||||
import {
|
||||
CircuitBreaker,
|
||||
CircuitOpenError,
|
||||
getBreaker,
|
||||
type CircuitBreakerOptions,
|
||||
} from './circuit-breaker.js';
|
||||
import { computeBackoffMs, type RetryOptions } from './retry.js';
|
||||
|
||||
export { CircuitOpenError };
|
||||
|
||||
export interface ResilientFetchOptions {
|
||||
/** Optional fetch implementation override. Defaults to `globalThis.fetch`. */
|
||||
fetchImpl?: typeof fetch;
|
||||
/**
|
||||
* Logical key for the breaker. Defaults to `<host><pathname>` of the
|
||||
* request URL — call sites targeting the same endpoint share breaker
|
||||
* state regardless of query-string differences.
|
||||
*/
|
||||
breakerKey?: string;
|
||||
/** Per-call breaker override. Used for tests and one-off configuration. */
|
||||
breaker?: CircuitBreaker;
|
||||
/** Tuning knobs for the breaker registered under `breakerKey`. */
|
||||
breakerOptions?: CircuitBreakerOptions;
|
||||
/** Tuning knobs for the retry helper. */
|
||||
retry?: Partial<Pick<RetryOptions, 'maxAttempts' | 'baseDelayMs' | 'capDelayMs'>> & {
|
||||
sleep?: RetryOptions['sleep'];
|
||||
random?: RetryOptions['random'];
|
||||
};
|
||||
/** Clock override propagated into Retry-After HTTP-date math and breaker. */
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
/** Cap on any single Retry-After wait — protects CLI from a buggy registry. */
|
||||
export const RETRY_AFTER_CAP_MS = 30_000;
|
||||
|
||||
const DEFAULT_RETRY = {
|
||||
maxAttempts: 3,
|
||||
baseDelayMs: 500,
|
||||
capDelayMs: 5_000,
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse a `Retry-After` header value into milliseconds.
|
||||
* Accepts either a delta-seconds integer (`"30"`) or an HTTP-date.
|
||||
* Returns null on parse failure or negative deltas.
|
||||
*/
|
||||
export function parseRetryAfter(value: string | null, now: () => number = Date.now): number | null {
|
||||
if (!value) return null;
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === '') return null;
|
||||
|
||||
if (/^[0-9]+$/.test(trimmed)) {
|
||||
const seconds = parseInt(trimmed, 10);
|
||||
if (Number.isNaN(seconds) || seconds < 0) return null;
|
||||
return seconds * 1000;
|
||||
}
|
||||
|
||||
const target = Date.parse(trimmed);
|
||||
if (Number.isNaN(target)) return null;
|
||||
const delta = target - now();
|
||||
return delta >= 0 ? delta : 0;
|
||||
}
|
||||
|
||||
/** Internal: outcome classification used by the resilientFetch loop. */
|
||||
type Outcome =
|
||||
| { kind: 'success'; resp: Response }
|
||||
| { kind: 'terminal-client'; resp: Response } // 4xx other than 429: no retry, breaker neutral
|
||||
| { kind: 'retryable-status'; resp: Response; afterMs: number | undefined } // 5xx, 429
|
||||
| { kind: 'terminal-network'; err: unknown } // TimeoutError or AbortError: no retry, breaker neutral
|
||||
| { kind: 'retryable-network'; err: unknown }; // DNS, ECONNRESET, etc.
|
||||
|
||||
/** Exported for unit tests. */
|
||||
export function classifyOutcome(
|
||||
result: { kind: 'error'; err: unknown } | { kind: 'response'; resp: Response },
|
||||
now: () => number,
|
||||
): Outcome {
|
||||
if (result.kind === 'error') {
|
||||
// Both timer-fired aborts (`AbortSignal.timeout()` → `TimeoutError`)
|
||||
// and caller-driven aborts (`AbortController.abort()` → `AbortError`)
|
||||
// are terminal: retrying against an already-aborted signal would
|
||||
// fail again immediately, and neither outcome reflects backend
|
||||
// health. They route through the breaker's neutral path.
|
||||
if (
|
||||
result.err instanceof DOMException &&
|
||||
(result.err.name === 'TimeoutError' || result.err.name === 'AbortError')
|
||||
) {
|
||||
return { kind: 'terminal-network', err: result.err };
|
||||
}
|
||||
return { kind: 'retryable-network', err: result.err };
|
||||
}
|
||||
const resp = result.resp;
|
||||
if (resp.status >= 200 && resp.status < 400) return { kind: 'success', resp };
|
||||
if (resp.status === 429) {
|
||||
// `resp.headers` is always present on a real `Response`, but tests
|
||||
// sometimes stub `fetch` with a plain `{ ok, status }` object. Be
|
||||
// defensive — a missing `Retry-After` falls through to exponential
|
||||
// backoff, which is the correct behaviour anyway.
|
||||
const retryAfterHeader =
|
||||
typeof resp.headers?.get === 'function' ? resp.headers.get('Retry-After') : null;
|
||||
const parsed = parseRetryAfter(retryAfterHeader, now);
|
||||
return {
|
||||
kind: 'retryable-status',
|
||||
resp,
|
||||
afterMs: parsed !== null ? Math.min(parsed, RETRY_AFTER_CAP_MS) : undefined,
|
||||
};
|
||||
}
|
||||
if (resp.status >= 500) return { kind: 'retryable-status', resp, afterMs: undefined };
|
||||
return { kind: 'terminal-client', resp };
|
||||
}
|
||||
|
||||
const defaultSleep = (ms: number): Promise<void> =>
|
||||
new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
function defaultBreakerKey(input: string | URL): string {
|
||||
try {
|
||||
const url = typeof input === 'string' ? new URL(input) : input;
|
||||
return `${url.host}${url.pathname}`;
|
||||
} catch {
|
||||
return String(input);
|
||||
}
|
||||
}
|
||||
|
||||
/** Final error thrown when retries are exhausted on a 5xx / 429. */
|
||||
export class ResilientFetchExhaustedError extends Error {
|
||||
override readonly name = 'ResilientFetchExhaustedError';
|
||||
constructor(public readonly response: Response) {
|
||||
super(`Request failed after retries (HTTP ${response.status})`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap `fetch` with bounded retries and a per-process circuit breaker.
|
||||
*
|
||||
* Semantics:
|
||||
* - 5xx and 429 responses are retried; 429 honors `Retry-After` (capped).
|
||||
* - Network throws are retried unless they are `TimeoutError` DOMExceptions.
|
||||
* - Timeouts and 4xx (other than 429) are returned/thrown without retry
|
||||
* AND without incrementing the breaker — they reflect caller config
|
||||
* or local network state, not registry health.
|
||||
* - Each `fetch` call carries the caller-supplied `signal` (e.g. an
|
||||
* `AbortSignal.timeout()`) — that timeout bounds each individual
|
||||
* attempt, not the whole retry sequence.
|
||||
* - When the breaker is open, throws `CircuitOpenError` synchronously
|
||||
* without invoking `fetch`.
|
||||
* - When retries are exhausted on a 5xx / 429, throws
|
||||
* `ResilientFetchExhaustedError` carrying the last response.
|
||||
*
|
||||
* Cumulative wall-clock budget:
|
||||
* maxAttempts × (per-attempt-timeout + capDelayMs)
|
||||
* With defaults (3, 500ms base, 5000ms cap) and a typical 15s per-attempt
|
||||
* timeout from the caller's signal, worst case is ~3 × (15s + 5s) = 60s.
|
||||
* Callers that want a tighter total bound should reduce `maxAttempts` or
|
||||
* wrap `resilientFetch` in their own outer `AbortSignal.timeout()`.
|
||||
*/
|
||||
export async function resilientFetch(
|
||||
input: string | URL,
|
||||
init: RequestInit | undefined,
|
||||
opts: ResilientFetchOptions = {},
|
||||
): Promise<Response> {
|
||||
const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
||||
const now = opts.now ?? (() => Date.now());
|
||||
const breaker =
|
||||
opts.breaker ?? getBreaker(opts.breakerKey ?? defaultBreakerKey(input), opts.breakerOptions);
|
||||
|
||||
const retryConfig = {
|
||||
maxAttempts: opts.retry?.maxAttempts ?? DEFAULT_RETRY.maxAttempts,
|
||||
baseDelayMs: opts.retry?.baseDelayMs ?? DEFAULT_RETRY.baseDelayMs,
|
||||
capDelayMs: opts.retry?.capDelayMs ?? DEFAULT_RETRY.capDelayMs,
|
||||
};
|
||||
const sleep = opts.retry?.sleep ?? defaultSleep;
|
||||
const random = opts.retry?.random ?? Math.random;
|
||||
|
||||
// Fail fast on an open breaker, before invoking fetch.
|
||||
breaker.check();
|
||||
|
||||
for (let attempt = 0; attempt < retryConfig.maxAttempts; attempt++) {
|
||||
let result: { kind: 'error'; err: unknown } | { kind: 'response'; resp: Response };
|
||||
try {
|
||||
// CodeQL js/server-side-request-forgery — flagged because `input`
|
||||
// is caller-supplied. Suppressed: every concrete caller passes
|
||||
// either a hardcoded URL constant (UNDERSTAND_QUICKLY_DISPATCH_URL,
|
||||
// OpenRouter base URL) or a value derived from configuration
|
||||
// (env vars, saved settings, the local backend URL). User-input
|
||||
// request fields (e.g. PR title, repo name) never flow into
|
||||
// `input`. Validating URL shape here would push false-positive
|
||||
// rejection onto every caller — wrong layer for the check.
|
||||
// lgtm[js/server-side-request-forgery]
|
||||
// codeql[js/server-side-request-forgery]
|
||||
const resp = await fetchImpl(input, init);
|
||||
result = { kind: 'response', resp };
|
||||
} catch (err) {
|
||||
result = { kind: 'error', err };
|
||||
}
|
||||
|
||||
const outcome = classifyOutcome(result, now);
|
||||
|
||||
switch (outcome.kind) {
|
||||
case 'success':
|
||||
breaker.recordSuccess();
|
||||
return outcome.resp;
|
||||
|
||||
case 'terminal-client':
|
||||
// 4xx: do not count as breaker failure (the server is healthy
|
||||
// and rejecting our request — auth, scope, or routing). But
|
||||
// also do NOT call recordSuccess: a 401 sandwiched between
|
||||
// 5xx responses would otherwise erase the running outage
|
||||
// signal. The breaker's neutral path leaves state untouched.
|
||||
breaker.recordNeutral();
|
||||
return outcome.resp;
|
||||
|
||||
case 'terminal-network':
|
||||
// Either `AbortSignal.timeout()` fired locally OR an external
|
||||
// caller cancelled the request via AbortController. The server
|
||||
// never had a chance to answer; this reflects the user's
|
||||
// network or an explicit cancel, not registry health. Don't
|
||||
// punish the breaker AND don't reset its outage signal.
|
||||
breaker.recordNeutral();
|
||||
throw outcome.err;
|
||||
|
||||
case 'retryable-status':
|
||||
if (attempt + 1 >= retryConfig.maxAttempts) {
|
||||
breaker.recordFailure();
|
||||
throw new ResilientFetchExhaustedError(outcome.resp);
|
||||
}
|
||||
await sleep(
|
||||
computeBackoffMs(
|
||||
attempt,
|
||||
retryConfig.baseDelayMs,
|
||||
retryConfig.capDelayMs,
|
||||
outcome.afterMs,
|
||||
random,
|
||||
),
|
||||
);
|
||||
break;
|
||||
|
||||
case 'retryable-network':
|
||||
if (attempt + 1 >= retryConfig.maxAttempts) {
|
||||
breaker.recordFailure();
|
||||
throw outcome.err;
|
||||
}
|
||||
await sleep(
|
||||
computeBackoffMs(
|
||||
attempt,
|
||||
retryConfig.baseDelayMs,
|
||||
retryConfig.capDelayMs,
|
||||
undefined,
|
||||
random,
|
||||
),
|
||||
);
|
||||
break;
|
||||
|
||||
default: {
|
||||
// Exhaustiveness guard. If a sixth `Outcome` kind is added in
|
||||
// future, TypeScript will refuse to assign it to `never` and
|
||||
// this line forces the maintainer to add an explicit arm
|
||||
// rather than silently fall through to retry/no-retry behaviour.
|
||||
const _exhaustive: never = outcome;
|
||||
throw new Error(`resilientFetch: unhandled outcome ${JSON.stringify(_exhaustive)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unreachable: every iteration of the loop either returns (success
|
||||
// / terminal-client) or throws (terminal-network / retry exhaustion).
|
||||
// The throw is here purely so TypeScript's control-flow analysis sees
|
||||
// the function never falls off the end without producing `Promise<Response>`.
|
||||
/* c8 ignore next 2 */
|
||||
throw new Error('resilientFetch: retry loop terminated unexpectedly');
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
/**
|
||||
* Bounded retry helper with full-jitter exponential backoff.
|
||||
*
|
||||
* Runtime-agnostic: depends only on `setTimeout`, `Math.random`, and the
|
||||
* Promise machinery — no Node-only imports. Safe to consume from CLI,
|
||||
* server, or browser callers.
|
||||
*
|
||||
* Pattern reference: gitnexus/src/core/embeddings/http-client.ts. This
|
||||
* helper is the upgraded form: classification is caller-supplied (so
|
||||
* 4xx-vs-5xx-vs-timeout decisions live with the protocol that knows
|
||||
* them), backoff is exponential with full jitter, and an optional
|
||||
* `afterMs` lets callers honor `Retry-After` headers.
|
||||
*/
|
||||
|
||||
export interface RetryOptions {
|
||||
/** Initial delay before the first retry attempt, in milliseconds. */
|
||||
baseDelayMs: number;
|
||||
/** Upper bound on any single delay, in milliseconds. */
|
||||
capDelayMs: number;
|
||||
/** Total attempts including the first call. Must be >= 1. */
|
||||
maxAttempts: number;
|
||||
/**
|
||||
* Decide whether to retry after a thrown error.
|
||||
* Return `{retry:false}` to terminate immediately and rethrow.
|
||||
* Return `{retry:true}` to retry with exponential-backoff jitter.
|
||||
* Return `{retry:true, afterMs}` to wait at least `afterMs` (still
|
||||
* subject to `capDelayMs`) — used by callers parsing `Retry-After`.
|
||||
*/
|
||||
isRetryable: (err: unknown, attempt: number) => RetryDecision;
|
||||
/** Sleep override — defaults to `setTimeout`. Tests inject fake timers. */
|
||||
sleep?: (ms: number) => Promise<void>;
|
||||
/** Random override — defaults to `Math.random`. Tests inject seeded values. */
|
||||
random?: () => number;
|
||||
}
|
||||
|
||||
export type RetryDecision = { retry: false } | { retry: true; afterMs?: number };
|
||||
|
||||
const defaultSleep = (ms: number): Promise<void> =>
|
||||
new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
/**
|
||||
* Compute the delay before the next retry attempt.
|
||||
*
|
||||
* - When the caller specifies `afterMs` (e.g., from `Retry-After`), use
|
||||
* `min(afterMs, capDelayMs)` so a misbehaving server can't pin the
|
||||
* client for an arbitrarily long wait.
|
||||
* - Otherwise compute full-jitter exponential backoff:
|
||||
* `random() * min(cap, base * 2^attempt)`. Full jitter (rather than
|
||||
* "equal jitter") avoids retry-storm thundering herd, per AWS
|
||||
* guidance on backoff strategies.
|
||||
*/
|
||||
export function computeBackoffMs(
|
||||
attempt: number,
|
||||
baseDelayMs: number,
|
||||
capDelayMs: number,
|
||||
afterMs: number | undefined,
|
||||
random: () => number,
|
||||
): number {
|
||||
if (afterMs !== undefined) {
|
||||
return Math.min(Math.max(0, afterMs), capDelayMs);
|
||||
}
|
||||
const exponential = baseDelayMs * Math.pow(2, attempt);
|
||||
const upper = Math.min(capDelayMs, exponential);
|
||||
return Math.floor(random() * upper);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute `fn` with bounded retries.
|
||||
*
|
||||
* The classification of "retryable" is the caller's responsibility — see
|
||||
* `resilient-fetch.ts` for the GitHub-dispatch-specific rules. This
|
||||
* helper is the mechanical retry loop only.
|
||||
*/
|
||||
export async function withRetry<T>(
|
||||
fn: (attempt: number) => Promise<T>,
|
||||
opts: RetryOptions,
|
||||
): Promise<T> {
|
||||
if (opts.maxAttempts < 1) {
|
||||
throw new Error(`withRetry: maxAttempts must be >= 1, got ${opts.maxAttempts}`);
|
||||
}
|
||||
const sleep = opts.sleep ?? defaultSleep;
|
||||
const random = opts.random ?? Math.random;
|
||||
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt < opts.maxAttempts; attempt++) {
|
||||
try {
|
||||
return await fn(attempt);
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
const decision = opts.isRetryable(err, attempt);
|
||||
if (!decision.retry) throw err;
|
||||
// Don't sleep after the final attempt.
|
||||
if (attempt + 1 >= opts.maxAttempts) break;
|
||||
const delayMs = computeBackoffMs(
|
||||
attempt,
|
||||
opts.baseDelayMs,
|
||||
opts.capDelayMs,
|
||||
decision.afterMs,
|
||||
random,
|
||||
);
|
||||
if (delayMs > 0) await sleep(delayMs);
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
/**
|
||||
* Understand-Quickly registry integration helpers.
|
||||
*
|
||||
* Pure, runtime-agnostic logic for opting in to publishing a GitNexus
|
||||
* index to the [`looptech-ai/understand-quickly`](https://github.com/looptech-ai/understand-quickly)
|
||||
* registry. Lives in `gitnexus-shared` so both the Node CLI and any
|
||||
* future browser-side surface can construct identical dispatch payloads.
|
||||
*
|
||||
* Network I/O lives in the CLI command (`gitnexus/src/cli/publish.ts`)
|
||||
* to keep this module free of Node-only imports — see the comment at
|
||||
* the top of `gitnexus-shared/src/graph/types.ts`.
|
||||
*
|
||||
* The protocol contract (single dispatch event, no graph upload) is
|
||||
* documented at:
|
||||
* https://github.com/looptech-ai/understand-quickly/blob/main/docs/integrations/protocol.md
|
||||
*/
|
||||
|
||||
/**
|
||||
* URL of the registry repo's repository_dispatch endpoint. Hardcoded
|
||||
* because the registry is the canonical home for this integration —
|
||||
* users who want a private registry can fork and patch.
|
||||
*/
|
||||
export const UNDERSTAND_QUICKLY_DISPATCH_URL =
|
||||
'https://api.github.com/repos/looptech-ai/understand-quickly/dispatches';
|
||||
|
||||
/**
|
||||
* Event type the registry's sync workflow listens for.
|
||||
* See `looptech-ai/understand-quickly/.github/workflows/sync.yml`.
|
||||
*/
|
||||
export const UNDERSTAND_QUICKLY_EVENT_TYPE = 'sync-entry';
|
||||
|
||||
/** Environment variable that gates the dispatch. */
|
||||
export const UNDERSTAND_QUICKLY_TOKEN_ENV = 'UNDERSTAND_QUICKLY_TOKEN';
|
||||
|
||||
export interface UqDispatchPayload {
|
||||
event_type: typeof UNDERSTAND_QUICKLY_EVENT_TYPE;
|
||||
client_payload: {
|
||||
/** `<owner>/<repo>` shape — must match the registered entry. */
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the JSON body for the `repository_dispatch` ping. Pure — no
|
||||
* env reads, no network. Validates that `id` looks like `owner/repo`
|
||||
* (one slash, no whitespace, both halves non-empty) so a misconfigured
|
||||
* caller fails loudly before the round-trip.
|
||||
*/
|
||||
export function buildUqDispatchPayload(id: string): UqDispatchPayload {
|
||||
if (!isValidOwnerRepo(id)) {
|
||||
throw new Error(
|
||||
`[understand-quickly] expected id of the form "owner/repo", got "${id}". ` +
|
||||
`The registry uses this string to look up your entry in registry.json — ` +
|
||||
`it must match the GitHub owner/repo of the source code, not a local path.`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
event_type: UNDERSTAND_QUICKLY_EVENT_TYPE,
|
||||
client_payload: { id },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `owner/repo` validation. Conservative on purpose: GitHub's actual
|
||||
* naming rules are looser, but we want to catch local paths
|
||||
* (`/Users/...`), bare slugs (`my-repo`), and accidental whitespace.
|
||||
*
|
||||
* Matches GitHub's published slug rules:
|
||||
* owner: starts with alnum, then alnum/hyphen only, must end with
|
||||
* alnum (no trailing hyphen — GitHub rejects this at account
|
||||
* creation, so a `my-org-/repo` input would otherwise pass us
|
||||
* and 422 from GitHub). No underscore, no dot. Length cap 39.
|
||||
* repo: any of alnum/dot/hyphen/underscore. Length cap 100.
|
||||
*/
|
||||
export function isValidOwnerRepo(id: string): boolean {
|
||||
return /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?\/[A-Za-z0-9._-]{1,100}$/.test(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip a single trailing `.git` (case-insensitive) and any trailing
|
||||
* slashes from a URL-ish string. Bounded linear: each character is
|
||||
* visited at most twice, no backtracking.
|
||||
*
|
||||
* Replaces `s.replace(/\.git\/*$/i, '').replace(/\/+$/, '')` which
|
||||
* CodeQL's polynomial-regex check (codeql/js/polynomial-redos) flags as
|
||||
* a worst-case O(n²) on adversarial input like "////.../x".
|
||||
*/
|
||||
export function stripGitSuffix(input: string): string {
|
||||
let end = input.length;
|
||||
// Trim trailing '/'.
|
||||
while (end > 0 && input.charCodeAt(end - 1) === 0x2f) end--;
|
||||
// Drop one trailing '.git' (case-insensitive).
|
||||
if (end >= 4) {
|
||||
const tail = input.slice(end - 4, end).toLowerCase();
|
||||
if (tail === '.git') end -= 4;
|
||||
}
|
||||
// Trim trailing '/' that may have sat between '.git' and the rest.
|
||||
while (end > 0 && input.charCodeAt(end - 1) === 0x2f) end--;
|
||||
return input.slice(0, end);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `owner/repo` out of a git remote URL. Mirrors the heuristic in
|
||||
* `gitnexus/src/storage/git.ts:parseRepoNameFromUrl` but keeps both
|
||||
* halves so we can build a registry id. Returns `null` on shapes we
|
||||
* don't recognise.
|
||||
*
|
||||
* Examples:
|
||||
* git@github.com:looptech-ai/understand-quickly.git
|
||||
* https://github.com/looptech-ai/understand-quickly
|
||||
* ssh://git@github.com/looptech-ai/understand-quickly.git
|
||||
*/
|
||||
export function parseOwnerRepoFromRemote(url: string | null | undefined): string | null {
|
||||
if (!url) return null;
|
||||
const trimmed = url.trim();
|
||||
if (!trimmed) return null;
|
||||
// Strip a trailing `.git` (case-insensitive) and any trailing slashes
|
||||
// so https://h/o/r and https://h/o/r.git collapse to the same id.
|
||||
// Bounded-linear helper avoids the polynomial-regex CodeQL alert.
|
||||
const stripped = stripGitSuffix(trimmed);
|
||||
|
||||
// SCP-form SSH (`git@host:owner/repo`). Capture host so we can reject
|
||||
// non-GitHub remotes — a GitLab origin like
|
||||
// `https://gitlab.example.com/group/sub/project.git` would otherwise
|
||||
// silently dispatch the wrong id (LOW 9).
|
||||
const ssh = stripped.match(/^[^@]+@([^:]+):([^/]+)\/([^/]+)$/);
|
||||
if (ssh) {
|
||||
const host = ssh[1].toLowerCase();
|
||||
if (host !== 'github.com' && host !== 'www.github.com') return null;
|
||||
return `${ssh[2]}/${ssh[3]}`;
|
||||
}
|
||||
|
||||
// URL forms (https://, ssh://, git://, file://) — last two path segments.
|
||||
const url2 = stripped.match(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/([^/]+)\/(.+)$/);
|
||||
if (url2) {
|
||||
// Strip optional `userinfo@` (e.g. `ssh://git@github.com/...`).
|
||||
const authority = url2[1];
|
||||
const atIdx = authority.lastIndexOf('@');
|
||||
const hostAndPort = atIdx >= 0 ? authority.slice(atIdx + 1) : authority;
|
||||
// Strip `:port` suffix if present.
|
||||
const colonIdx = hostAndPort.indexOf(':');
|
||||
const host = (colonIdx >= 0 ? hostAndPort.slice(0, colonIdx) : hostAndPort).toLowerCase();
|
||||
if (host !== 'github.com' && host !== 'www.github.com') return null;
|
||||
const segments = url2[2].split('/').filter(Boolean);
|
||||
if (segments.length >= 2) {
|
||||
const [owner, repo] = segments.slice(-2);
|
||||
return `${owner}/${repo}`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
/**
|
||||
* Test-only helpers.
|
||||
*
|
||||
* Symbols here are reachable from `gitnexus-shared/test-helpers` so test
|
||||
* suites can reset shared registries or exercise internal classifiers,
|
||||
* but they are deliberately NOT re-exported from the main `gitnexus-shared`
|
||||
* barrel. Production consumers should never import this module — calling
|
||||
* `__resetBreakerRegistry__()` from a tool implementation would silently
|
||||
* nuke every circuit breaker process-wide.
|
||||
*/
|
||||
|
||||
export { __resetBreakerRegistry__ } from './integrations/circuit-breaker.js';
|
||||
export { classifyOutcome } from './integrations/resilient-fetch.js';
|
||||
Generated
+42
-29
@@ -8,7 +8,7 @@
|
||||
"name": "gitnexus",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@langchain/anthropic": "^1.3.29",
|
||||
"@langchain/anthropic": "^1.3.28",
|
||||
"@langchain/core": "^1.1.44",
|
||||
"@langchain/google-genai": "^2.1.28",
|
||||
"@langchain/langgraph": "^1.2.9",
|
||||
@@ -26,9 +26,9 @@
|
||||
"graphology-layout-forceatlas2": "^0.10.1",
|
||||
"graphology-layout-noverlap": "^0.4.2",
|
||||
"graphology-utils": "^2.3.0",
|
||||
"langchain": "^1.3.5",
|
||||
"langchain": "^1.3.4",
|
||||
"lru-cache": "^11.2.4",
|
||||
"lucide-react": "^1.14.0",
|
||||
"lucide-react": "^1.11.0",
|
||||
"mermaid": "^11.14.0",
|
||||
"mnemonist": "^0.39.0",
|
||||
"pandemonium": "^2.4.0",
|
||||
@@ -49,7 +49,7 @@
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/dompurify": "^3.2.0",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
@@ -95,9 +95,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/sdk": {
|
||||
"version": "0.91.1",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.91.1.tgz",
|
||||
"integrity": "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==",
|
||||
"version": "0.90.0",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.90.0.tgz",
|
||||
"integrity": "sha512-MzZtPabJF1b0FTDl6Z6H5ljphPwACLGP13lu8MTiB8jXaW/YXlpOp+Po2cVou3MPM5+f5toyLnul9whKCy7fBg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"json-schema-to-ts": "^3.1.1"
|
||||
@@ -1396,25 +1396,25 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@langchain/anthropic": {
|
||||
"version": "1.3.29",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/anthropic/-/anthropic-1.3.29.tgz",
|
||||
"integrity": "sha512-ep1qBIcV07bajsg3fDqMd39rYwoRLOEK/6lk+MCxlm1YB5SRoKKJAZANrblQ/4RYhZJnxf95c6BSQu8VoNbVAQ==",
|
||||
"version": "1.3.28",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/anthropic/-/anthropic-1.3.28.tgz",
|
||||
"integrity": "sha512-gOF8oXJL8xDdYes2KXNI9vFm/9TldBBBHOjuCdt27kganVaQKzLvTw5kV6R4mjbnFagV5CWteNH7APLZYCpdwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.91.1",
|
||||
"@anthropic-ai/sdk": "^0.90.0",
|
||||
"zod": "^3.25.76 || ^4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@langchain/core": "^1.1.45"
|
||||
"@langchain/core": "^1.1.42"
|
||||
}
|
||||
},
|
||||
"node_modules/@langchain/core": {
|
||||
"version": "1.1.45",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.1.45.tgz",
|
||||
"integrity": "sha512-Y/wvuglLTMKJahkl4QD9dBIdF/z/CxZJWdTfHJF/q2jtlJtoFf6Mb5JpGxZfsi3mBY6NSG941FSLTcqhCKrhBA==",
|
||||
"version": "1.1.44",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.1.44.tgz",
|
||||
"integrity": "sha512-RePW1IjGCHr9ua2vcby3aE8mOOz3EnwDZxMEGbNDT91kf14eqkJqxDXvaZFviGdcN9DTrxM5RPQNAHmwSm4tbg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@cfworker/json-schema": "^4.0.2",
|
||||
@@ -2800,14 +2800,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/dompurify": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.2.0.tgz",
|
||||
"integrity": "sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==",
|
||||
"deprecated": "This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed.",
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
|
||||
"integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dompurify": "*"
|
||||
"@types/trusted-types": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
@@ -2910,8 +2909,8 @@
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/unist": {
|
||||
"version": "3.0.3",
|
||||
@@ -5644,21 +5643,35 @@
|
||||
"integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="
|
||||
},
|
||||
"node_modules/langchain": {
|
||||
"version": "1.3.5",
|
||||
"resolved": "https://registry.npmjs.org/langchain/-/langchain-1.3.5.tgz",
|
||||
"integrity": "sha512-QSB8TEo6G1tWupgNt1Osm8ylLLoOMq1lLw5NeijnIwRPI5BqdBjUn4/U8usbjEJJcQnbXSK2qTKgTx7zvblnBw==",
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/langchain/-/langchain-1.3.4.tgz",
|
||||
"integrity": "sha512-umrD+ZC6vr0Q0U1lC5PoIMMQqgnP+7QhaIdAXCeZiSX2GPVBiVR7Ed2ZR+3MPvz1yWrRtIm17wPaovkND+wOXg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@langchain/langgraph": "^1.2.9",
|
||||
"@langchain/langgraph-checkpoint": "^1.0.1",
|
||||
"langsmith": ">=0.5.0 <1.0.0",
|
||||
"uuid": "^11.1.0",
|
||||
"zod": "^3.25.76 || ^4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@langchain/core": "^1.1.42"
|
||||
"@langchain/core": "^1.1.41"
|
||||
}
|
||||
},
|
||||
"node_modules/langchain/node_modules/uuid": {
|
||||
"version": "11.1.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
|
||||
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/esm/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/langium": {
|
||||
@@ -6028,9 +6041,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "1.14.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.14.0.tgz",
|
||||
"integrity": "sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA==",
|
||||
"version": "1.11.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.11.0.tgz",
|
||||
"integrity": "sha512-UOhjdztXCgdBReRcIhsvz2siIBogfv/lhJEIViCpLt924dO+GDms9T7DNoucI23s6kEPpe988m5N0D2ajnzb2g==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"gitnexus-shared": "file:../gitnexus-shared",
|
||||
"@langchain/anthropic": "^1.3.29",
|
||||
"@langchain/anthropic": "^1.3.28",
|
||||
"@langchain/core": "^1.1.44",
|
||||
"@langchain/google-genai": "^2.1.28",
|
||||
"@langchain/langgraph": "^1.2.9",
|
||||
@@ -36,9 +36,9 @@
|
||||
"graphology-layout-forceatlas2": "^0.10.1",
|
||||
"graphology-layout-noverlap": "^0.4.2",
|
||||
"graphology-utils": "^2.3.0",
|
||||
"langchain": "^1.3.5",
|
||||
"langchain": "^1.3.4",
|
||||
"lru-cache": "^11.2.4",
|
||||
"lucide-react": "^1.14.0",
|
||||
"lucide-react": "^1.11.0",
|
||||
"mermaid": "^11.14.0",
|
||||
"mnemonist": "^0.39.0",
|
||||
"pandemonium": "^2.4.0",
|
||||
@@ -59,7 +59,7 @@
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/dompurify": "^3.2.0",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
ProviderConfig,
|
||||
} from './types';
|
||||
import { DEFAULT_OPENROUTER_BASE_URL, DEFAULT_OLLAMA_BASE_URL } from '../../config/ui-constants';
|
||||
import { resilientFetch } from 'gitnexus-shared';
|
||||
|
||||
const STORAGE_KEY = 'gitnexus-llm-settings';
|
||||
|
||||
@@ -408,10 +407,7 @@ export const getAvailableModels = (provider: LLMProvider): string[] => {
|
||||
*/
|
||||
export const fetchOpenRouterModels = async (): Promise<Array<{ id: string; name: string }>> => {
|
||||
try {
|
||||
const response = await resilientFetch(`${DEFAULT_OPENROUTER_BASE_URL}/models`, undefined, {
|
||||
breakerKey: 'openrouter-models',
|
||||
retry: { maxAttempts: 2, baseDelayMs: 500, capDelayMs: 2_000 },
|
||||
});
|
||||
const response = await fetch(`${DEFAULT_OPENROUTER_BASE_URL}/models`);
|
||||
if (!response.ok) throw new Error('Failed to fetch models');
|
||||
const data = await response.json();
|
||||
return data.data.map((model: any) => ({
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
*/
|
||||
|
||||
import type { GraphNode, GraphRelationship } from 'gitnexus-shared';
|
||||
import { CircuitOpenError, ResilientFetchExhaustedError, resilientFetch } from 'gitnexus-shared';
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -205,32 +204,8 @@ export function streamSSE<T = unknown>(url: string, handlers: SSEHandlers<T>): A
|
||||
|
||||
let _backendUrl = 'http://localhost:4747';
|
||||
|
||||
/**
|
||||
* Validate that a backend URL is a safe http:// or https:// origin before
|
||||
* storing it as the fetch target base (CodeQL js/client-side-request-forgery).
|
||||
*
|
||||
* Throws if the URL uses a non-HTTP scheme (e.g. javascript:, data:, file://).
|
||||
* All other well-formed http/https URLs are accepted — the client intentionally
|
||||
* supports connecting to remote GitNexus servers, not just localhost.
|
||||
*/
|
||||
export function validateBackendUrl(url: string): void {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
// Do not echo raw input — it may contain credentials.
|
||||
throw new Error('Invalid backend URL: must be a well-formed http:// or https:// URL');
|
||||
}
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
// Use parsed.protocol only (scheme), not the full URL, to avoid leaking credentials.
|
||||
throw new Error(`Backend URL must use http:// or https:// (got ${parsed.protocol})`);
|
||||
}
|
||||
}
|
||||
|
||||
export const setBackendUrl = (url: string): void => {
|
||||
const trimmed = url.replace(/\/$/, '');
|
||||
validateBackendUrl(trimmed);
|
||||
_backendUrl = trimmed;
|
||||
_backendUrl = url.replace(/\/$/, '');
|
||||
};
|
||||
|
||||
export const getBackendUrl = (): string => _backendUrl;
|
||||
@@ -262,90 +237,28 @@ export function normalizeServerUrl(input: string): string {
|
||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
const PROBE_TIMEOUT_MS = 2_000;
|
||||
|
||||
/** Idempotent HTTP methods. Other verbs (POST, PATCH, PUT, DELETE) get
|
||||
* a single-attempt retry budget by default to avoid duplicate side
|
||||
* effects on retry — a POST that 5xx'd may have already executed
|
||||
* server-side. Callers that have idempotency keys or otherwise know
|
||||
* their mutation is safe to retry can opt in via `forceRetry`. */
|
||||
const IDEMPOTENT_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||
|
||||
const fetchWithTimeout = async (
|
||||
url: string,
|
||||
init: RequestInit = {},
|
||||
timeoutMs: number = DEFAULT_TIMEOUT_MS,
|
||||
/**
|
||||
* Force a retry budget on non-idempotent methods. Default false.
|
||||
* Pass true only when the endpoint is known-idempotent (e.g. DELETE
|
||||
* of a known-deleted resource — second call is a 404 / no-op) AND
|
||||
* the duplicate-side-effect window is acceptable.
|
||||
*/
|
||||
forceRetry = false,
|
||||
): Promise<Response> => {
|
||||
// Merge the external caller signal (if any) with an
|
||||
// `AbortSignal.timeout()` so a timer-fired abort produces a
|
||||
// `DOMException` with `name === 'TimeoutError'` — which
|
||||
// `resilientFetch` correctly classifies as terminal-network (no
|
||||
// retry, no breaker hit). A manual `AbortController.abort()` would
|
||||
// produce `name === 'AbortError'` and route through the
|
||||
// retryable-network branch, which mis-penalizes the breaker for
|
||||
// user-side network slowness.
|
||||
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
||||
const controller = new AbortController();
|
||||
// Merge external signal if provided
|
||||
const externalSignal = init.signal;
|
||||
const signal = externalSignal ? AbortSignal.any([timeoutSignal, externalSignal]) : timeoutSignal;
|
||||
|
||||
const method = (init.method ?? 'GET').toUpperCase();
|
||||
const isIdempotent = IDEMPOTENT_METHODS.has(method);
|
||||
const maxAttempts = isIdempotent || forceRetry ? 2 : 1;
|
||||
|
||||
// Key the breaker by the current backend origin so switching backend
|
||||
// URLs (e.g. recovering from a flapping local server by pointing at
|
||||
// a different host) gives the new origin a fresh breaker state. A
|
||||
// single shared `'web-backend'` key would otherwise leave a user
|
||||
// locked out for the full cooldown after one bad host trips the
|
||||
// circuit. The malformed-URL fallback is defensive — `setBackendUrl`
|
||||
// normalizes input, so this branch shouldn't fire in practice.
|
||||
let breakerKey: string;
|
||||
try {
|
||||
breakerKey = `web-backend:${new URL(_backendUrl).origin}`;
|
||||
} catch {
|
||||
breakerKey = 'web-backend:invalid';
|
||||
if (externalSignal) {
|
||||
externalSignal.addEventListener('abort', () => controller.abort());
|
||||
}
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
// Bounded retries + 5xx/429 handling are delegated to resilientFetch.
|
||||
// Method-aware budget: idempotent verbs retry once on transient
|
||||
// backend failures; mutations (POST/PATCH/PUT/DELETE) default to
|
||||
// single-attempt to avoid duplicate side effects.
|
||||
const response = await resilientFetch(
|
||||
url,
|
||||
{ ...init, signal },
|
||||
{
|
||||
breakerKey,
|
||||
retry: { maxAttempts, baseDelayMs: 250, capDelayMs: 1500 },
|
||||
},
|
||||
);
|
||||
const response = await fetch(url, { ...init, signal: controller.signal });
|
||||
return response;
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof CircuitOpenError) {
|
||||
throw new BackendError(
|
||||
`GitNexus backend at ${_backendUrl} is unhealthy; retry in ${Math.ceil(error.retryAfterMs / 1000)}s`,
|
||||
0,
|
||||
'network',
|
||||
);
|
||||
}
|
||||
if (error instanceof ResilientFetchExhaustedError) {
|
||||
// Fall through to caller — surface the raw response so assertOk
|
||||
// can craft the BackendError with the right code.
|
||||
return error.response;
|
||||
}
|
||||
if (error instanceof DOMException && error.name === 'TimeoutError') {
|
||||
throw new BackendError(`Request to ${url} timed out after ${timeoutMs}ms`, 0, 'timeout');
|
||||
}
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
// External caller-driven cancellation — `timeoutSignal` would
|
||||
// have surfaced as TimeoutError above, so this branch covers
|
||||
// only the externally-aborted case.
|
||||
throw new BackendError('Request aborted', 0, 'network');
|
||||
if (externalSignal?.aborted) {
|
||||
throw new BackendError('Request aborted', 0, 'network');
|
||||
}
|
||||
throw new BackendError(`Request to ${url} timed out after ${timeoutMs}ms`, 0, 'timeout');
|
||||
}
|
||||
if (error instanceof TypeError) {
|
||||
throw new BackendError(
|
||||
@@ -355,6 +268,8 @@ const fetchWithTimeout = async (
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
/**
|
||||
* Method-aware retry budget + timeout-as-TimeoutError verification for
|
||||
* backend-client's `fetchWithTimeout`.
|
||||
*
|
||||
* Closes review findings on PR #1448:
|
||||
* - Non-idempotent POST/DELETE must NOT be retried by default —
|
||||
* a 5xx on `startAnalyze` could otherwise start a duplicate job.
|
||||
* - Timer-fired timeout must surface as `DOMException(name='TimeoutError')`,
|
||||
* not `AbortError`, so resilientFetch routes it through the
|
||||
* terminal-network branch (no retry, no breaker hit).
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getBreaker } from 'gitnexus-shared';
|
||||
import { __resetBreakerRegistry__ } from 'gitnexus-shared/test-helpers';
|
||||
import { fetchRepos, setBackendUrl, startAnalyze } from '../../src/services/backend-client';
|
||||
|
||||
const BASE = 'http://localhost:4747';
|
||||
|
||||
describe('backend-client retry budget (method-aware)', () => {
|
||||
beforeEach(() => {
|
||||
__resetBreakerRegistry__();
|
||||
setBackendUrl(BASE);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('GET retries once on transient 503 (idempotent verb)', async () => {
|
||||
let n = 0;
|
||||
const fetchMock = vi.fn(async () => {
|
||||
n += 1;
|
||||
if (n === 1) return new Response('boom', { status: 503 });
|
||||
return new Response('[]', {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const repos = await fetchRepos();
|
||||
expect(repos).toEqual([]);
|
||||
// 1 retry budget on idempotent GET → 2 total fetch calls.
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('POST does NOT retry on 503 by default (non-idempotent verb)', async () => {
|
||||
const fetchMock = vi.fn(async () => new Response('boom', { status: 503 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(startAnalyze({ path: '/tmp/repo' })).rejects.toBeTruthy();
|
||||
// Single attempt — never duplicates a job-start POST.
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('switching backend URL after a circuit opens reaches a fresh breaker (U3)', async () => {
|
||||
// Pre-open the breaker for host-A by directly recording 3 failures.
|
||||
setBackendUrl('http://host-a.test:4747');
|
||||
const aKey = 'web-backend:http://host-a.test:4747';
|
||||
const breakerA = getBreaker(aKey);
|
||||
breakerA.recordFailure();
|
||||
breakerA.recordFailure();
|
||||
breakerA.recordFailure();
|
||||
expect(breakerA.getState()).toBe('open');
|
||||
|
||||
// Switch to host-B and make a request — must succeed against the
|
||||
// new origin without tripping the host-A circuit. Under the old
|
||||
// single-key behaviour the call would throw CircuitOpenError.
|
||||
setBackendUrl('http://host-b.test:4747');
|
||||
const fetchMock = vi.fn(
|
||||
async () =>
|
||||
new Response('[]', {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const repos = await fetchRepos();
|
||||
expect(repos).toEqual([]);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Host-A's breaker is still open in cooldown.
|
||||
expect(breakerA.getState()).toBe('open');
|
||||
// Host-B has its own (fresh) breaker.
|
||||
const bKey = 'web-backend:http://host-b.test:4747';
|
||||
expect(getBreaker(bKey).getState()).toBe('closed');
|
||||
expect(getBreaker(bKey).getConsecutiveFailures()).toBe(0);
|
||||
});
|
||||
|
||||
it('breaker not incremented when timeout fires (TimeoutError, not AbortError)', async () => {
|
||||
// Reject directly with a TimeoutError DOMException, mimicking what
|
||||
// `fetch` produces when its `AbortSignal.timeout()`-wired signal
|
||||
// fires. The real-fetch path goes signal.reason → reject(reason);
|
||||
// we shortcut that here so the test doesn't have to wait the
|
||||
// 30-second default timeout.
|
||||
const fetchMock = vi.fn(async () => {
|
||||
throw new DOMException('aborted by timeout', 'TimeoutError');
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(fetchRepos()).rejects.toMatchObject({ code: 'timeout' });
|
||||
|
||||
// The breaker must not have been penalized for a local timeout.
|
||||
expect(getBreaker(`web-backend:${BASE}`).getConsecutiveFailures()).toBe(0);
|
||||
// Timeout is terminal — no retry attempted.
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
fetchGraph,
|
||||
getBackendUrl,
|
||||
normalizeServerUrl,
|
||||
setBackendUrl,
|
||||
validateBackendUrl,
|
||||
} from '../../src/services/backend-client';
|
||||
import { fetchGraph, normalizeServerUrl, setBackendUrl } from '../../src/services/backend-client';
|
||||
|
||||
describe('normalizeServerUrl', () => {
|
||||
it('adds http:// to localhost', () => {
|
||||
@@ -171,61 +165,3 @@ describe('fetchGraph', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateBackendUrl', () => {
|
||||
it('allows http:// URLs', () => {
|
||||
expect(() => validateBackendUrl('http://localhost:4747')).not.toThrow();
|
||||
expect(() => validateBackendUrl('http://127.0.0.1:4747')).not.toThrow();
|
||||
});
|
||||
|
||||
it('allows https:// URLs', () => {
|
||||
expect(() => validateBackendUrl('https://gitnexus.example.com')).not.toThrow();
|
||||
expect(() => validateBackendUrl('https://my-server.internal:4747')).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects non-http schemes', () => {
|
||||
expect(() => validateBackendUrl('javascript:alert(1)')).toThrow('must use http:// or https://');
|
||||
expect(() => validateBackendUrl('file:///etc/passwd')).toThrow('must use http:// or https://');
|
||||
expect(() => validateBackendUrl('data:text/plain,evil')).toThrow(
|
||||
'must use http:// or https://',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects malformed URLs', () => {
|
||||
expect(() => validateBackendUrl('not-a-url')).toThrow('Invalid backend URL');
|
||||
});
|
||||
|
||||
it('does not include the raw URL in error messages (credential hygiene)', () => {
|
||||
const urlWithCreds = 'javascript:alert("sk-secret")';
|
||||
let msg = '';
|
||||
try {
|
||||
validateBackendUrl(urlWithCreds);
|
||||
} catch (e) {
|
||||
msg = (e as Error).message;
|
||||
}
|
||||
expect(msg).not.toContain('sk-secret');
|
||||
expect(msg).not.toContain(urlWithCreds);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setBackendUrl', () => {
|
||||
it('accepts valid http URLs', () => {
|
||||
expect(() => setBackendUrl('http://localhost:4747')).not.toThrow();
|
||||
});
|
||||
|
||||
it('accepts valid https URLs', () => {
|
||||
expect(() => setBackendUrl('https://my-server.example.com')).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects non-http/https schemes', () => {
|
||||
expect(() => setBackendUrl('javascript:alert(1)')).toThrow('must use http:// or https://');
|
||||
expect(() => setBackendUrl('file:///etc/passwd')).toThrow('must use http:// or https://');
|
||||
});
|
||||
|
||||
it('does not mutate _backendUrl when validation fails', () => {
|
||||
setBackendUrl('http://localhost:4747');
|
||||
expect(() => setBackendUrl('javascript:alert(1)')).toThrow();
|
||||
// State must be preserved — validation must happen before the assignment
|
||||
expect(getBackendUrl()).toBe('http://localhost:4747');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,73 +4,6 @@ All notable changes to GitNexus will be documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.6.4] - 2026-05-10
|
||||
|
||||
### Added
|
||||
|
||||
- **`gitnexus publish`** — opt-in command to push your indexed graph to the understand-quickly registry for shareable browsing (#1425)
|
||||
- **`IncludeExtractor` for C++** — cross-repo include tracking joins the group contract pipeline (#1156)
|
||||
- **Unreal Engine C++ support** — strips reflection macros (`UCLASS`, `UFUNCTION`, `UPROPERTY`, etc.) before tree-sitter parses, so UE projects index cleanly (#1439)
|
||||
- **Thrift contracts extractor** — group-mode contract detection for Apache Thrift IDL (#1234)
|
||||
- **Workspace extractors for Node, Python, Go, Java, Elixir** — group-mode auto-discovery of cross-package boundaries (#1260)
|
||||
- **Rust workspace cross-crate contracts** — auto-discovery of `[workspace]` member crates and their cross-crate links (#1256)
|
||||
- **Go scope-resolution hooks** — Go joins Python / C# / TypeScript on the registry-primary RFC #909 path (#1302)
|
||||
- **TypeScript registry-primary scope resolution (Ring 3)** — TypeScript fully migrated to scope-based resolution (#1050)
|
||||
- **Configurable group cross-link path exclusions** — reduces false-positive contract links in vendored / monorepo trees (#1093)
|
||||
- **MCP tool safety annotations** — every MCP tool advertises read-only / mutating semantics so hosts can prompt appropriately (#1127)
|
||||
- **`--embeddings <limit>` opt-in cap** — bound the embeddings pass on huge graphs (closes #382, #1375)
|
||||
- **Pino structured logger** — replaces ad-hoc console output across the core with structured JSON logs (with pretty-print for TTY) (#1336)
|
||||
- **Shared resilient-fetch helper** — single retries + circuit breaker module reused by HF / Docker / publish flows (#1448)
|
||||
- **`/autofix` ChatOps button** — fork-safe PR autofix pipeline replaces the inline reviewdog flow (#1446, #1458)
|
||||
- **Automated security & vulnerability scans** in CI (#1297, #1455)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **FTS read-only DB cluster** — hook resolves canonical repo root and guards read-only FTS ensure; missing-FTS warning is now surfaced. Closes #1255, #1287, #1170, #1449, #1440, #1216, #1438 (#1226, #1418, #1107, #1123)
|
||||
- **WAL corruption recovery** — quarantine corrupted `.wal` files instead of failing analyze; CHECKPOINT before close prevents recurrence; `safeClose` consolidates flush. Closes #1402, #1236, #1273, #1361 (#1417, #1314, #1377)
|
||||
- **Embedding download failures** — actionable HF_ENDPOINT guidance, retries, timeout, and circuit breaker; bridge `HF_ENDPOINT` to transformers.js; iterative DFS; HF cache via `os.homedir()`. Closes #1378, #1437, #1205 (#1419, #1252, #1078)
|
||||
- **Windows reliability** — pin tree-sitter-c/cpp to fix segfault, prefer `.cmd`/`.bat` from `where` output, robust LadybugDB lock acquisition for CI integration tests, surface silent finalize-skips so analyze cannot exit 0 without persisting. Closes #1242, #1427, #1447, #1468, #1400; partial #1218 (#1243, #1299, #1430, #1237, #1226, #1235)
|
||||
- **DuckDB / LadybugDB native** — bumped to 0.16.0 then 0.16.1; prevent extension install hangs; CHECKPOINT before close; WAL quarantine on corruption. Closes #1162, #1160, #273 (#1235, #1326, #1129, #1314, #1417)
|
||||
- **C# scope-resolution "Cannot add property" crashes** — generic typed properties included in context and impact, fixing crashes on Unity ECS partial structs and on properties whose name matches the class name. Closes #1426, #1465 (#1399)
|
||||
- **C# frozen-bucket regression** + scope-resolution I8 hardening — closes #1066 (#1082, #1085)
|
||||
- **Scope resolution** — same-range Module-as-parent for top-level scopes (closes #1086) (#1087); avoid variadic reference-site aggregation (#1112); skip empty scope extraction (#1100); classify Python class methods as Method (#1102)
|
||||
- **Python** — index repos with empty `__init__.py` and >32 KB files (#1163); walk ancestors for multi-segment dotted imports (#1241); deterministic multi-segment suffix fallback (#1253)
|
||||
- **TypeScript** — capture missed CALLS edges from HOF callbacks and JSX (#1175); name HOC-wrapped const declarations (`forwardRef` / `memo` / `useCallback` / `useMemo` / `observer`) (#1261); pair-with-arrow `@declaration.function` anchored on inner arrow
|
||||
- **Go** — loose equality for `Array.find()` null checks (#1384)
|
||||
- **Swift** — switched to the official prebuilt parser runtime (#1130)
|
||||
- **Server hardening cluster (U2–U8)** — JS path-injection on `/api/file` + docker-server (U2, #1322); git-clone path/CLI-injection / ReDoS hardening (U3, #1325); per-route rate limiting on FS-touching endpoints (U4, #1327); URL/regex/tag-filter sanitization (U7, #1330); ReDoS in cobol-preprocessor + rust-workspace + cross-impact resource exhaustion (U8, #1331); critical type-confusion + validation helper (#1317); rate-limit `/api/analyze` and `/api/embed` (closes #1328, #1339); IPv6 ipKeyGenerator (closes #1360, #1374); IPv4-compatible IPv6 / NAT64 SSRF bypasses in `validateGitUrl` (closes #1148, 95814847); predictable tempfile names → `crypto.randomBytes` (#1387); log-injection / http-to-file-access / client-side request forgery (#1456); pin Docker Node base images + Trivy verification + Dependabot policy (#1455)
|
||||
- **Group / contracts** — `runExactMatch` honours `.gitnexusignore` via shared `IgnoreService` (closes #1185, #1247); custom manifest links resolved against graph symbols (#1254); `IgnoreService` EACCES test under uid=0 (#1108)
|
||||
- **MCP** — close MCP server timeout via stdout discipline + cold-start friction (#1383); avoid `git` from non-repo cwd in sibling-cwd match (closes #1138, #1293); start MCP bridge correctly when using `npx` (#1114); project `tool_map` flows from handlers (#1113); parallelize staleness checks in `list_repos` (#1416)
|
||||
- **Storage / CLI** — derive registry name from canonical repo root, not worktree slug (closes #1259, #1296); `--skip-git` treats cwd as index root (#1245); keep GitNexus ignores inside `.gitnexus/` (#1248); surface silent finalize-skips so `analyze` cannot exit 0 without persisting (closes #1169, #1237); ignore global registry during staleness checks (#1141); use `os.homedir()` instead of `process.env.HOME` for HF cache dir (#1078); correct OpenCode skills install path in status message (#1386)
|
||||
- **Docker / server** — dedicated health endpoint for container healthcheck (closes #1147, #1355); HEAD probe so SSE heartbeat doesn't time out healthcheck (#1182); flush WAL after `/api/embed` so search sees new embeddings (closes #1149, #1359); platform-aware semantic fallback (#1150); skip vector index query on unsupported platforms (closes #1178, #1181); serve web UI at root path instead of 404 (#1048)
|
||||
- **Worker pool** — wait for replacement worker online before dispatch (#1324); prevent premature pool resolution in worker split-and-retry path (#1321); recover worker parse stalls (#1121); widened CI flake-tolerant timeouts (#1323, #1347, #1354)
|
||||
- **Embeddings storage** — CHECKPOINT before closing DB to prevent WAL corruption (#1314)
|
||||
- **Performance** — replace O(n³) C3 merge loop with O(n²) head-pointer algorithm (#1316)
|
||||
- **Install** — vendor tree-sitter-dart source (#1125)
|
||||
- **Git utils** — suppress stderr leak in `getCurrentCommit` and `getGitRoot` (closes #1172, #1341)
|
||||
- **Search** — load FTS during core DB init (#1123); create FTS indexes during `analyze` (#1107); surface warning when FTS indexes are missing (#1418)
|
||||
- **Hooks** — clarify `PostToolUse` hook is notification-only, not auto-reindex (#1070)
|
||||
- **Docs** — README Web UI section corrected (closes #1110, #1159, #2ff3e64f); Goliath capitalisation typo (#1126)
|
||||
- **CI** — fork-safe PR autofix pipeline (#1446); consolidated Claude review workflow (#1258); fine-grained PAT for RC tag push (#1407); handle expired artifacts in base coverage fetch (#1410, #1412); allow expected legacy parity failures (#1099); avoid duplicate main push checks; isolate native LadybugDB / CLI e2e flakes; seed e2e with a small fixture repo (#1249); configure e2e GitNexus home at runtime; widen rate-limit test window for Windows CI (#1347)
|
||||
|
||||
### Changed
|
||||
|
||||
- **`gitnexus publish` artefact contract** — universal opt-in publish format introduced (#1425, #1458)
|
||||
- **Refactor: per-language patterns consolidated into `LanguageProvider`** (#1279)
|
||||
- **Refactor: `safeClose` helper** consolidates WAL flush across LadybugDB call sites (#1377)
|
||||
- **Quality: exclude `test/fixtures` from CodeQL, ESLint, and Prettier** (#1313)
|
||||
- **Regression coverage** for `.gitnexusignore` behaviour with `--skip-git` (#1450)
|
||||
|
||||
### Chore / Dependencies
|
||||
|
||||
- `@ladybugdb/core` 0.16.0 → 0.16.1 (#1235, #1326)
|
||||
- `@anthropic-ai/sdk` (#1442), `@langchain/anthropic` (#1389), `@langchain/core` (#1394), `@langchain/openai` (#1215)
|
||||
- `hono` 4.12.9 → 4.12.18 + `@hono/node-server` (#1310, #1311, #1443)
|
||||
- `axios` (#1345), `fast-uri` 3.1.0 → 3.1.2 (#1441), `lru-cache` 11.3.5 → 11.3.6 (#1344), `mnemonist` 0.40.3 → 0.40.4 (#1239), `express-rate-limit` (#1343, #1397), `onnxruntime-node` (#1213, #1435), `uuid` 13 → 14 in /gitnexus-web (#1211, after revert #1222 / re-land #1250 + #1208)
|
||||
- `react`/`@types/react` (#1210), `react-dom` 19.2.5 → 19.2.6 (#1396), `react-zoom-pan-pinch` (#1214), `jsdom` 29.0.2 → 29.1.1 (#1395)
|
||||
- npm_and_yarn group bump (#1312), uv group bump (#1315), `python-dotenv` (#1320), `@types/node` (#1212, #1421, #1436)
|
||||
- GitHub Actions: `docker/build-push-action` 6.19.2 → 7.1.0 (#1391), `github/codeql-action` 3.35.3 → 4.35.3 (#1390)
|
||||
|
||||
## [1.6.3] - 2026-04-24
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
# Pinned npm version — keep in sync with the root Dockerfile.cli and
|
||||
# Dockerfile.web.
|
||||
ARG NPM_VERSION=11.14.1
|
||||
|
||||
# node:22-bookworm-slim
|
||||
FROM node:22-bookworm-slim@sha256:9f6d5975c7dca860947d3915877f85607946403fc55349f39b4bc3688448bb6e
|
||||
ARG NPM_VERSION
|
||||
FROM node:20-bookworm
|
||||
WORKDIR /app
|
||||
RUN npx --yes npm@${NPM_VERSION} install -g npm@${NPM_VERSION} \
|
||||
&& apt-get -o Acquire::Check-Valid-Until=false -o Acquire::Check-Date=false update \
|
||||
&& apt-get install -y python3 make g++ \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN apt-get -o Acquire::Check-Valid-Until=false -o Acquire::Check-Date=false update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
||||
COPY . .
|
||||
RUN npm ci --ignore-scripts \
|
||||
&& npm rebuild tree-sitter-swift 2>&1 \
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ To configure MCP for your editor, run `npx gitnexus setup` once — or set it up
|
||||
| Editor | MCP | Skills | Hooks (auto-augment) | Support |
|
||||
|--------|-----|--------|---------------------|---------|
|
||||
| **Claude Code** | Yes | Yes | Yes (PreToolUse) | **Full** |
|
||||
| **Cursor** | Yes | Yes | Yes (postToolUse, [manual install](../gitnexus-cursor-integration/README.md#hook-install)) | **Full** |
|
||||
| **Cursor** | Yes | Yes | — | MCP + Skills |
|
||||
| **Codex** | Yes | Yes | — | MCP + Skills |
|
||||
| **Windsurf** | Yes | — | — | MCP |
|
||||
| **OpenCode** | Yes | Yes | — | MCP + Skills |
|
||||
|
||||
Generated
+18
-18
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "gitnexus",
|
||||
"version": "1.6.5-rc.3",
|
||||
"version": "1.6.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "gitnexus",
|
||||
"version": "1.6.5-rc.3",
|
||||
"version": "1.6.3",
|
||||
"hasInstallScript": true,
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"dependencies": {
|
||||
@@ -2065,9 +2065,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.2.tgz",
|
||||
"integrity": "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==",
|
||||
"version": "25.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.1.tgz",
|
||||
"integrity": "sha512-coJCN8O1q4AGyyqCAUSP06P+SrMTu18BkEj3NVAK07q6QUneD2wzj3CLv9+yP+BMeZQlMvneXqqvDe3w+xcq7g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.19.0"
|
||||
@@ -3110,9 +3110,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
|
||||
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -3476,9 +3476,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.12.18",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz",
|
||||
"integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==",
|
||||
"version": "4.12.16",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.16.tgz",
|
||||
"integrity": "sha512-jN0ZewiNAWSe5khM3EyCmBb250+b40wWbwNILNfEvq84VREWwOIkuUsFONk/3i3nqkz7Oe1PcpM2mwQEK2L9Kg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
@@ -4282,15 +4282,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/onnxruntime-common": {
|
||||
"version": "1.26.0",
|
||||
"resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.26.0.tgz",
|
||||
"integrity": "sha512-qVyMR4lcWgbkc4getFV+GQijsTnbg/siteoqcDwa3sI/LxbrMSNw4ePyvCq/ymdQaRomCA7YuWmhzsswxvymdw==",
|
||||
"version": "1.25.1",
|
||||
"resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.25.1.tgz",
|
||||
"integrity": "sha512-kKvYQFdos4LWJqhZ+nmKu3NT8NXzw8I5x9fNUKe1rNKcPfNKnYXUtW7JBpcKFsvLtrJashRgVYSbFap4cHxvNg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/onnxruntime-node": {
|
||||
"version": "1.26.0",
|
||||
"resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.26.0.tgz",
|
||||
"integrity": "sha512-OHl6PiOEOqxaLHL0N9eFrbzS7IGmu3BtJNH3RTEnRAheCIkfc3gjcjl4sGcjp9C22ZC9YTquDOxSdT/stBQ6BQ==",
|
||||
"version": "1.25.1",
|
||||
"resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.25.1.tgz",
|
||||
"integrity": "sha512-N0M58CGTiTsLkPpx9bxmRFi24GT6r67Qei/GrBEIiDyntcYdXU5vQZp112ypydG9vEKRFgbgUYQJnEi+jll8dg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"os": [
|
||||
@@ -4301,7 +4301,7 @@
|
||||
"dependencies": {
|
||||
"adm-zip": "^0.5.16",
|
||||
"global-agent": "^4.1.3",
|
||||
"onnxruntime-common": "1.26.0"
|
||||
"onnxruntime-common": "1.25.1"
|
||||
}
|
||||
},
|
||||
"node_modules/onnxruntime-web": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gitnexus",
|
||||
"version": "1.6.5-rc.3",
|
||||
"version": "1.6.3",
|
||||
"description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
|
||||
"author": "Abhigyan Patwari",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
@@ -116,6 +116,6 @@
|
||||
}
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Augment CLI Command
|
||||
*
|
||||
* Fast-path command for platform hooks.
|
||||
* Shells out from Claude Code PreToolUse / Cursor postToolUse hooks.
|
||||
* Shells out from Claude Code PreToolUse / Cursor beforeShellExecution hooks.
|
||||
*
|
||||
* Usage: gitnexus augment <pattern>
|
||||
* Returns enriched text to stdout.
|
||||
|
||||
@@ -160,18 +160,6 @@ program
|
||||
.description('Augment a search pattern with knowledge graph context (used by hooks)')
|
||||
.action(createLazyAction(() => import('./augment.js'), 'augmentCommand'));
|
||||
|
||||
program
|
||||
.command('publish [path]')
|
||||
.description(
|
||||
'Notify the understand-quickly registry that this repo has a fresh GitNexus index. ' +
|
||||
'Opt-in: requires UNDERSTAND_QUICKLY_TOKEN (fine-grained PAT with ' +
|
||||
'`Repository dispatches: write` on looptech-ai/understand-quickly). ' +
|
||||
'No-op without the token. See https://github.com/looptech-ai/understand-quickly.',
|
||||
)
|
||||
.option('--id <owner/repo>', 'Override the registry id (defaults to the origin remote)')
|
||||
.option('--skip-git', 'Treat cwd as the repo root and skip parent git-root discovery')
|
||||
.action(createLazyAction(() => import('./publish.js'), 'publishCommand'));
|
||||
|
||||
// ─── Direct Tool Commands (no MCP overhead) ────────────────────────
|
||||
// These invoke LocalBackend directly for use in eval, scripts, and CI.
|
||||
|
||||
|
||||
@@ -1,232 +0,0 @@
|
||||
/**
|
||||
* `gitnexus publish` — opt-in ping to the understand-quickly registry.
|
||||
*
|
||||
* Fires a single `repository_dispatch` event at
|
||||
* `looptech-ai/understand-quickly` so the registry knows to refresh its
|
||||
* entry for the current repo. Does NOT upload anything: per the
|
||||
* understand-quickly protocol, the registry pulls the graph from a
|
||||
* raw-GitHub URL the user controls.
|
||||
*
|
||||
* https://github.com/looptech-ai/understand-quickly/blob/main/docs/integrations/protocol.md
|
||||
*
|
||||
* Defaults:
|
||||
* - Without `UNDERSTAND_QUICKLY_TOKEN` in the env, this is a no-op
|
||||
* (prints one informational line, exit 0). Same shape as the
|
||||
* `--publish` patterns in sibling tools.
|
||||
* - With the token, fires the dispatch and reports the response code.
|
||||
*
|
||||
* The `id` is derived from the repo's `origin` remote unless the caller
|
||||
* passes `--id <owner/repo>` explicitly. We deliberately do NOT auto-add
|
||||
* the repo to the registry — registration is one-time and uses the
|
||||
* `npx @understand-quickly/cli add` path documented in the protocol.
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import {
|
||||
UNDERSTAND_QUICKLY_DISPATCH_URL,
|
||||
UNDERSTAND_QUICKLY_TOKEN_ENV,
|
||||
buildUqDispatchPayload,
|
||||
isValidOwnerRepo,
|
||||
parseOwnerRepoFromRemote,
|
||||
} from 'gitnexus-shared';
|
||||
import { getGitRoot, getRemoteOriginUrl, getCurrentCommit } from '../storage/git.js';
|
||||
import { hasIndex } from '../storage/repo-manager.js';
|
||||
import { cliInfo, cliError } from './cli-message.js';
|
||||
|
||||
export interface PublishOptions {
|
||||
/** Override the auto-derived `owner/repo` id. */
|
||||
id?: string;
|
||||
/** Treat the cwd as the repo root (skip git-root walk). */
|
||||
skipGit?: boolean;
|
||||
}
|
||||
|
||||
const REGISTER_HINT =
|
||||
'Register your repo once with: npx @understand-quickly/cli add\n' +
|
||||
'Or use the wizard: https://looptech-ai.github.io/understand-quickly/add.html';
|
||||
|
||||
/**
|
||||
* Hard cap on the dispatch fetch to keep CI publish steps from stalling
|
||||
* for the OS TCP timeout (~2 min) when api.github.com is unreachable.
|
||||
* Matches the pattern used in `src/core/embeddings/http-client.ts`.
|
||||
*/
|
||||
const DISPATCH_TIMEOUT_MS = 15_000;
|
||||
|
||||
export const publishCommand = async (
|
||||
inputPath?: string,
|
||||
options: PublishOptions = {},
|
||||
): Promise<void> => {
|
||||
// ── 0. Token gate FIRST — guarantees true no-op without the token. ──
|
||||
// The README, CLI --help, and PR body all promise "exit 0 without
|
||||
// UNDERSTAND_QUICKLY_TOKEN". Doing the index/repo-root checks before
|
||||
// the token gate would make those promises false for users who haven't
|
||||
// run `gitnexus analyze` yet but want to verify the command is wired.
|
||||
const token = process.env[UNDERSTAND_QUICKLY_TOKEN_ENV];
|
||||
if (!token) {
|
||||
cliInfo(
|
||||
`[understand-quickly] ${UNDERSTAND_QUICKLY_TOKEN_ENV} is not set — skipping dispatch.\n` +
|
||||
`Set it to a fine-grained PAT with "Repository dispatches: write" on ` +
|
||||
`looptech-ai/understand-quickly to enable instant resync.\n` +
|
||||
`(Without the token, the registry's nightly sync still picks up your entry.)`,
|
||||
{ skipped: 'no-token' },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── 1. Resolve the repo root (same precedence as `analyze`) ──────────
|
||||
let repoPath: string;
|
||||
if (inputPath) {
|
||||
repoPath = path.resolve(inputPath);
|
||||
} else if (options.skipGit) {
|
||||
repoPath = path.resolve(process.cwd());
|
||||
} else {
|
||||
const gitRoot = getGitRoot(process.cwd());
|
||||
if (!gitRoot) {
|
||||
cliError(
|
||||
'[understand-quickly] not inside a git repository.\n' +
|
||||
'Run from a repo, or pass --skip-git to publish from the current directory.',
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
repoPath = gitRoot;
|
||||
}
|
||||
|
||||
// ── 2. Confirm a GitNexus index exists ───────────────────────────────
|
||||
// Publishing without an index is almost always a mistake — the
|
||||
// registry's nightly sync would fetch a stale or missing graph file
|
||||
// and mark the entry `missing`. Refuse loudly with a fix-it hint.
|
||||
if (!(await hasIndex(repoPath))) {
|
||||
cliError(
|
||||
`[understand-quickly] no GitNexus index found at ${repoPath}/.gitnexus.\n` +
|
||||
'Run `gitnexus analyze` first, then re-run `gitnexus publish`.',
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// ── 3. Derive the registry id ─────────────────────────────────────────
|
||||
const id =
|
||||
options.id ?? parseOwnerRepoFromRemote(getRemoteOriginUrl(repoPath) ?? undefined) ?? null;
|
||||
if (!id || !isValidOwnerRepo(id)) {
|
||||
cliError(
|
||||
`[understand-quickly] could not derive a registry id from this repo.\n` +
|
||||
`Pass --id <owner/repo> explicitly (e.g. --id looptech-ai/${path.basename(repoPath)}).\n` +
|
||||
REGISTER_HINT,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// ── 4. Fire the dispatch ─────────────────────────────────────────────
|
||||
const payload = buildUqDispatchPayload(id);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(UNDERSTAND_QUICKLY_DISPATCH_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/vnd.github+json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'gitnexus-cli',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
signal: AbortSignal.timeout(DISPATCH_TIMEOUT_MS),
|
||||
});
|
||||
} catch (err) {
|
||||
// `AbortSignal.timeout()` throws a `DOMException` with `name ===
|
||||
// 'TimeoutError'` on Node 18.14+ (and on browsers/Bun). It is NOT
|
||||
// a plain `AbortError`. Match the pattern used in
|
||||
// gitnexus/src/core/embeddings/http-client.ts so the user sees the
|
||||
// targeted "timed out" message instead of a generic "operation
|
||||
// was aborted".
|
||||
const isTimeout = err instanceof DOMException && err.name === 'TimeoutError';
|
||||
if (isTimeout) {
|
||||
cliError(
|
||||
`[understand-quickly] dispatch timed out after ${DISPATCH_TIMEOUT_MS}ms. ` +
|
||||
`Check network access to api.github.com and retry.`,
|
||||
{ id },
|
||||
);
|
||||
} else {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
cliError(`[understand-quickly] dispatch network error: ${msg}`, { id });
|
||||
}
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// GitHub returns 204 on success. Distinct branches for 401/403/404/422
|
||||
// so users debug without checking the docs.
|
||||
if (response.status === 204) {
|
||||
await response.body?.cancel().catch(() => {});
|
||||
// `getCurrentCommit` is only meaningful in the success path — moving
|
||||
// it inside this branch removes a wasted child-process spawn on every
|
||||
// error response (LOW 7).
|
||||
const commit = getCurrentCommit(repoPath);
|
||||
cliInfo(
|
||||
`[understand-quickly] dispatched sync-entry for ${id}` +
|
||||
(commit ? ` @ ${commit.slice(0, 7)}` : '') +
|
||||
'.\n' +
|
||||
`Note: a 204 only confirms GitHub accepted the dispatch. Whether the ` +
|
||||
`registry workflow finds an entry for "${id}" is logged at ` +
|
||||
`https://github.com/looptech-ai/understand-quickly/actions/workflows/sync.yml`,
|
||||
{ id, commit, status: response.status },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
cliError(
|
||||
`[understand-quickly] dispatch returned 401 — the ${UNDERSTAND_QUICKLY_TOKEN_ENV} value is invalid or expired.\n` +
|
||||
`Regenerate a fine-grained PAT at https://github.com/settings/personal-access-tokens ` +
|
||||
`with Repository access scoped to looptech-ai/understand-quickly and the ` +
|
||||
`"Repository dispatches: write" permission, then retry.`,
|
||||
{ id, status: response.status },
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.status === 403) {
|
||||
cliError(
|
||||
`[understand-quickly] dispatch returned 403 — the token authenticated but ` +
|
||||
`lacks the "Repository dispatches: write" permission on ` +
|
||||
`looptech-ai/understand-quickly. Edit the PAT scopes and retry.`,
|
||||
{ id, status: response.status },
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.status === 404) {
|
||||
cliError(
|
||||
`[understand-quickly] dispatch returned 404 — the token cannot reach ` +
|
||||
`looptech-ai/understand-quickly. Verify the PAT has Repository access to ` +
|
||||
`that exact repo (not just your own org).`,
|
||||
{ id, status: response.status },
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.status === 422) {
|
||||
// Malformed event_type / client_payload — a code bug in this CLI,
|
||||
// not a user mistake. Surface so we get bug reports.
|
||||
const body422 = await response.text().catch(() => '');
|
||||
cliError(
|
||||
`[understand-quickly] dispatch returned 422 (this is a CLI bug; please report).\n` +
|
||||
`Body: ${body422 || '(empty)'}`,
|
||||
{ id, status: response.status },
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// 5xx and anything else → bubble the body so the user has something to act on.
|
||||
const body = await response.text().catch(() => '');
|
||||
cliError(
|
||||
`[understand-quickly] dispatch failed with HTTP ${response.status}: ${body || '(empty body)'}`,
|
||||
{ id, status: response.status },
|
||||
);
|
||||
process.exitCode = 1;
|
||||
};
|
||||
@@ -2,8 +2,8 @@
|
||||
* Augmentation Engine
|
||||
*
|
||||
* Lightweight, fast-path enrichment of search patterns with knowledge graph context.
|
||||
* Designed to be called from platform hooks (Claude Code PreToolUse, Cursor postToolUse)
|
||||
* when an agent runs grep/glob/read/search.
|
||||
* Designed to be called from platform hooks (Claude Code PreToolUse, Cursor beforeShellExecution)
|
||||
* when an agent runs grep/glob/search.
|
||||
*
|
||||
* Performance target: <500ms cold start, <200ms warm.
|
||||
*
|
||||
@@ -104,7 +104,7 @@ export async function augment(pattern: string, cwd?: string): Promise<string> {
|
||||
}
|
||||
|
||||
// Step 1: BM25 search (fast, no embeddings)
|
||||
const { results: bm25Results } = await searchFTSFromLbug(pattern, 10, repoId);
|
||||
const bm25Results = await searchFTSFromLbug(pattern, 10, repoId);
|
||||
|
||||
if (bm25Results.length === 0) return '';
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import os from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { CircuitBreaker, withRetry } from 'gitnexus-shared';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Download resilience defaults
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -110,19 +108,70 @@ export function isNetworkFetchError(message: string): boolean {
|
||||
/** @internal Used by `withHfDownloadRetry` to mark a circuit-open rejection. */
|
||||
export const CIRCUIT_OPEN_TAG = 'hf-circuit-open';
|
||||
|
||||
/** Circuit-breaker states. */
|
||||
type CircuitState = 'closed' | 'open' | 'half-open';
|
||||
|
||||
/**
|
||||
* Module-level singleton shared by both embedder entry points
|
||||
* (`core/embeddings/embedder.ts` + `mcp/core/embedder.ts`). Per-process
|
||||
* only — not persisted across restarts. Backed by the shared
|
||||
* `CircuitBreaker` from `gitnexus-shared` (same state machine, same
|
||||
* semantics, plus the single-permit half-open gate that prevents
|
||||
* recovery-time stampedes).
|
||||
* Circuit breaker for HuggingFace model downloads.
|
||||
*
|
||||
* After `failureThreshold` consecutive network failures the circuit opens and
|
||||
* all subsequent calls to `withHfDownloadRetry` fail immediately without
|
||||
* issuing any network requests. After `resetTimeoutMs` the circuit enters the
|
||||
* half-open state and the next call is attempted — if it succeeds the circuit
|
||||
* closes again; if it fails the circuit re-opens.
|
||||
*
|
||||
* Exported for unit-testing; production code should use the module-level
|
||||
* `hfDownloadCircuit` singleton.
|
||||
*/
|
||||
export const hfDownloadCircuit = new CircuitBreaker({
|
||||
failureThreshold: CB_FAILURE_THRESHOLD,
|
||||
cooldownMs: CB_RESET_TIMEOUT_MS,
|
||||
key: 'hf-download',
|
||||
});
|
||||
export class HfDownloadCircuitBreaker {
|
||||
private _state: CircuitState = 'closed';
|
||||
private _failures = 0;
|
||||
/** Timestamp of the last recorded failure (ms since epoch). */
|
||||
lastFailureAt = 0;
|
||||
|
||||
constructor(
|
||||
readonly failureThreshold: number = CB_FAILURE_THRESHOLD,
|
||||
readonly resetTimeoutMs: number = CB_RESET_TIMEOUT_MS,
|
||||
) {}
|
||||
|
||||
/** Effective state, factoring in the reset-timeout transition. */
|
||||
get state(): CircuitState {
|
||||
if (this._state === 'open' && Date.now() - this.lastFailureAt > this.resetTimeoutMs) {
|
||||
this._state = 'half-open';
|
||||
}
|
||||
return this._state;
|
||||
}
|
||||
|
||||
/** Returns true when the circuit is open and calls should be rejected. */
|
||||
isOpen(): boolean {
|
||||
return this.state === 'open';
|
||||
}
|
||||
|
||||
/** Record a successful call — resets the failure counter and closes the circuit. */
|
||||
recordSuccess(): void {
|
||||
this._failures = 0;
|
||||
this._state = 'closed';
|
||||
}
|
||||
|
||||
/** Record a failed call — increments the counter and opens the circuit when the threshold is reached. */
|
||||
recordFailure(): void {
|
||||
this._failures++;
|
||||
this.lastFailureAt = Date.now();
|
||||
if (this._failures >= this.failureThreshold) {
|
||||
this._state = 'open';
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal Reset to initial state (used in tests). */
|
||||
reset(): void {
|
||||
this._failures = 0;
|
||||
this._state = 'closed';
|
||||
this.lastFailureAt = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** Module-level singleton shared by both embedder entry points. */
|
||||
export const hfDownloadCircuit = new HfDownloadCircuitBreaker();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Retry + timeout wrapper
|
||||
@@ -170,6 +219,11 @@ export function withDownloadTimeout<T>(fn: () => Promise<T>, timeoutMs: number):
|
||||
});
|
||||
}
|
||||
|
||||
/** @internal Async sleep (exposed for testing). */
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export interface HfRetryOptions {
|
||||
/** Maximum total attempts including the initial one (default: `HF_MAX_ATTEMPTS`). */
|
||||
maxAttempts?: number;
|
||||
@@ -181,7 +235,7 @@ export interface HfRetryOptions {
|
||||
* Circuit-breaker instance to use. Defaults to the module-level
|
||||
* `hfDownloadCircuit` singleton. Pass a fresh instance in tests.
|
||||
*/
|
||||
circuit?: CircuitBreaker;
|
||||
circuit?: HfDownloadCircuitBreaker;
|
||||
/**
|
||||
* Optional callback invoked before each retry (not the initial attempt).
|
||||
* @param attempt - 1-based retry number
|
||||
@@ -241,74 +295,49 @@ export async function withHfDownloadRetry<T>(
|
||||
circuit = hfDownloadCircuit,
|
||||
onRetry,
|
||||
} = options;
|
||||
if (circuit.getState() === 'open') {
|
||||
// Compute remaining cooldown without consuming a probe permit.
|
||||
const openedAt = circuit.getOpenedAt();
|
||||
const secsUntilReset =
|
||||
openedAt !== null ? Math.ceil((circuit.getCooldownMs() - (Date.now() - openedAt)) / 1000) : 0;
|
||||
if (circuit.isOpen()) {
|
||||
const secsUntilReset = Math.ceil(
|
||||
(circuit.resetTimeoutMs - (Date.now() - circuit.lastFailureAt)) / 1000,
|
||||
);
|
||||
throw new Error(
|
||||
`${CIRCUIT_OPEN_TAG}: HuggingFace download circuit is open after repeated network failures` +
|
||||
(secsUntilReset > 0 ? ` — will reset in ~${secsUntilReset}s` : ''),
|
||||
);
|
||||
}
|
||||
|
||||
// Retry budget delegated to `withRetry` from gitnexus-shared. The
|
||||
// HF-specific bits — per-attempt timeout, network-vs-non-network
|
||||
// classification, circuit-breaker recording, onRetry callback — wire
|
||||
// through the `isRetryable` callback. `circuitTripped` is the
|
||||
// sentinel that lets us replace the final thrown error with a
|
||||
// CIRCUIT_OPEN_TAG message when the breaker tripped mid-loop.
|
||||
let circuitTripped = false;
|
||||
let lastError: Error = new Error('unknown error');
|
||||
|
||||
try {
|
||||
return await withRetry(
|
||||
async () => {
|
||||
const result = await withDownloadTimeout(fn, timeoutMs);
|
||||
circuit.recordSuccess();
|
||||
return result;
|
||||
},
|
||||
{
|
||||
maxAttempts,
|
||||
baseDelayMs,
|
||||
// Disable the cap to match the bespoke pure-exponential
|
||||
// progression. With the default `HF_MAX_ATTEMPTS_CAP = 10` and
|
||||
// `baseDelayMs = 2000`, the largest possible delay is
|
||||
// `2000 * 2^9 = ~17 minutes` — bounded enough not to need a cap.
|
||||
capDelayMs: Number.MAX_SAFE_INTEGER,
|
||||
isRetryable: (err, attempt) => {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
if (!isNetworkFetchError(error.message)) {
|
||||
// Non-network error (e.g. CUDA unavailable) — propagate
|
||||
// without retry. Use recordNeutral so the breaker's existing
|
||||
// failure-count progress isn't reset by a non-network failure
|
||||
// that says nothing about the CDN's health.
|
||||
circuit.recordNeutral();
|
||||
return { retry: false };
|
||||
}
|
||||
circuit.recordFailure();
|
||||
if (circuit.getState() === 'open') {
|
||||
// Circuit just tripped — fail fast, no more retries.
|
||||
circuitTripped = true;
|
||||
return { retry: false };
|
||||
}
|
||||
// Mirror the bespoke onRetry contract: fire only when there's
|
||||
// actually a next attempt.
|
||||
if (attempt + 1 < maxAttempts) {
|
||||
onRetry?.(attempt + 1, maxAttempts, error);
|
||||
}
|
||||
return { retry: true };
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
if (circuitTripped) {
|
||||
throw new Error(
|
||||
`${CIRCUIT_OPEN_TAG}: HuggingFace download circuit opened after ${CB_FAILURE_THRESHOLD} consecutive failures`,
|
||||
);
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
try {
|
||||
const result = await withDownloadTimeout(fn, timeoutMs);
|
||||
circuit.recordSuccess();
|
||||
return result;
|
||||
} catch (err) {
|
||||
lastError = err instanceof Error ? err : new Error(String(err));
|
||||
|
||||
if (!isNetworkFetchError(lastError.message)) {
|
||||
// Non-network error (e.g. CUDA unavailable) — propagate without retry
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
circuit.recordFailure();
|
||||
|
||||
if (circuit.isOpen()) {
|
||||
// Circuit just tripped — fail fast, no more retries
|
||||
throw new Error(
|
||||
`${CIRCUIT_OPEN_TAG}: HuggingFace download circuit opened after ${circuit.failureThreshold} consecutive failures`,
|
||||
);
|
||||
}
|
||||
|
||||
if (attempt < maxAttempts - 1) {
|
||||
const delay = baseDelayMs * Math.pow(2, attempt);
|
||||
onRetry?.(attempt + 1, maxAttempts, lastError);
|
||||
await sleep(delay);
|
||||
}
|
||||
}
|
||||
// All retries exhausted — rethrow the last network error so
|
||||
// isNetworkFetchError patterns in the calling code still match and
|
||||
// surface HF_ENDPOINT guidance.
|
||||
throw err;
|
||||
}
|
||||
|
||||
// All retries exhausted — throw the last network error so isNetworkFetchError
|
||||
// patterns in the calling code still match and surface HF_ENDPOINT guidance.
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
@@ -3,22 +3,13 @@
|
||||
*
|
||||
* Shared fetch+retry logic for OpenAI-compatible /v1/embeddings endpoints.
|
||||
* Imported by both the core embedder (batch) and MCP embedder (query).
|
||||
*
|
||||
* Network resilience is delegated to `resilientFetch` from
|
||||
* `gitnexus-shared` — bounded retries with exponential-backoff jitter,
|
||||
* `Retry-After` honored on 429, and an in-process circuit breaker that
|
||||
* fails fast on a flapping endpoint. Per-attempt timeout is enforced
|
||||
* via `AbortSignal.timeout` on the underlying fetch.
|
||||
*/
|
||||
|
||||
import { CircuitOpenError, ResilientFetchExhaustedError, resilientFetch } from 'gitnexus-shared';
|
||||
|
||||
const HTTP_TIMEOUT_MS = 30_000;
|
||||
const HTTP_MAX_RETRIES = 2;
|
||||
const HTTP_RETRY_BACKOFF_MS = 1_000;
|
||||
const HTTP_BATCH_SIZE = 64;
|
||||
const DEFAULT_DIMS = 384;
|
||||
const HTTP_BREAKER_KEY = 'embeddings-http';
|
||||
|
||||
interface HttpConfig {
|
||||
baseUrl: string;
|
||||
@@ -99,51 +90,46 @@ const httpEmbedBatch = async (
|
||||
model: string,
|
||||
apiKey: string,
|
||||
batchIndex = 0,
|
||||
attempt = 0,
|
||||
): Promise<EmbeddingItem[]> => {
|
||||
let resp: Response;
|
||||
try {
|
||||
resp = await resilientFetch(
|
||||
url,
|
||||
{
|
||||
method: 'POST',
|
||||
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({ input: batch, model }),
|
||||
resp = await fetch(url, {
|
||||
method: 'POST',
|
||||
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
{
|
||||
breakerKey: HTTP_BREAKER_KEY,
|
||||
retry: { maxAttempts: HTTP_MAX_RETRIES + 1, baseDelayMs: HTTP_RETRY_BACKOFF_MS },
|
||||
},
|
||||
);
|
||||
body: JSON.stringify({ input: batch, model }),
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof CircuitOpenError) {
|
||||
throw new Error(
|
||||
`Embedding endpoint circuit open (${safeUrl(url)}, batch ${batchIndex}): retry in ${Math.ceil(err.retryAfterMs / 1000)}s`,
|
||||
);
|
||||
}
|
||||
if (err instanceof DOMException && err.name === 'TimeoutError') {
|
||||
// Timeouts should not be retried — the server is unresponsive.
|
||||
// AbortSignal.timeout() throws DOMException with name 'TimeoutError'.
|
||||
const isTimeout = err instanceof DOMException && err.name === 'TimeoutError';
|
||||
if (isTimeout) {
|
||||
throw new Error(
|
||||
`Embedding request timed out after ${HTTP_TIMEOUT_MS}ms (${safeUrl(url)}, batch ${batchIndex})`,
|
||||
);
|
||||
}
|
||||
if (err instanceof ResilientFetchExhaustedError) {
|
||||
throw new Error(
|
||||
`Embedding endpoint returned ${err.response.status} (${safeUrl(url)}, batch ${batchIndex})`,
|
||||
);
|
||||
// DNS, connection errors — retry with backoff
|
||||
if (attempt < HTTP_MAX_RETRIES) {
|
||||
const delay = HTTP_RETRY_BACKOFF_MS * (attempt + 1);
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
return httpEmbedBatch(url, batch, model, apiKey, batchIndex, attempt + 1);
|
||||
}
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(`Embedding request failed (${safeUrl(url)}, batch ${batchIndex}): ${reason}`);
|
||||
}
|
||||
|
||||
if (!resp.ok) {
|
||||
// resilientFetch already retried 5xx/429; any non-OK response here is
|
||||
// a terminal client error (4xx other than 429).
|
||||
throw new Error(
|
||||
`Embedding endpoint returned ${resp.status} (${safeUrl(url)}, batch ${batchIndex})`,
|
||||
);
|
||||
const status = resp.status;
|
||||
if ((status === 429 || status >= 500) && attempt < HTTP_MAX_RETRIES) {
|
||||
const delay = HTTP_RETRY_BACKOFF_MS * (attempt + 1);
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
return httpEmbedBatch(url, batch, model, apiKey, batchIndex, attempt + 1);
|
||||
}
|
||||
throw new Error(`Embedding endpoint returned ${status} (${safeUrl(url)}, batch ${batchIndex})`);
|
||||
}
|
||||
|
||||
const data = (await resp.json()) as { data: EmbeddingItem[] };
|
||||
|
||||
@@ -722,15 +722,13 @@ export async function openBridgeDbReadOnly(groupDir: string): Promise<BridgeHand
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
}
|
||||
}
|
||||
// Strip CRLF from user-controlled strings before logging to close
|
||||
// CodeQL js/log-injection. Pino's NDJSON serialization already
|
||||
// JSON-escapes all values, but we sanitize here as a defence-in-depth
|
||||
// measure so CodeQL can see the taint flow is broken.
|
||||
const safeGroupDir = String(groupDir).replace(/[\r\n]/g, ' ');
|
||||
const safeErrMsg =
|
||||
lastErr instanceof Error ? String(lastErr.message).replace(/[\r\n]/g, ' ') : undefined;
|
||||
// Pino's NDJSON serialization is structurally injection-resistant
|
||||
// (CodeQL js/log-injection): groupDir and err.message are JSON-escaped
|
||||
// by the serializer, so no manual CRLF / U+2028 / ANSI sanitization is
|
||||
// needed. Demoted to debug — only fires when the bridge truly gave up
|
||||
// after retries, and operators only need it at debug verbosity.
|
||||
bridgeLogger.debug(
|
||||
{ groupDir: safeGroupDir, errMsg: safeErrMsg, attempts: LBUG_OPEN_RETRY_ATTEMPTS },
|
||||
{ groupDir, err: lastErr, attempts: LBUG_OPEN_RETRY_ATTEMPTS },
|
||||
'openBridgeDbReadOnly gave up',
|
||||
);
|
||||
return null;
|
||||
|
||||
@@ -769,10 +769,9 @@ export const processCalls = async (
|
||||
|
||||
let tree = astCache.get(file.path);
|
||||
if (!tree) {
|
||||
const parseContent = provider.preprocessSource?.(file.content, file.path) ?? file.content;
|
||||
try {
|
||||
tree = parser.parse(parseContent, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(parseContent),
|
||||
tree = parser.parse(file.content, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(file.content),
|
||||
});
|
||||
} catch (parseError) {
|
||||
continue;
|
||||
@@ -3281,10 +3280,9 @@ export const extractFetchCallsFromFiles = async (
|
||||
|
||||
let tree = astCache.get(file.path);
|
||||
if (!tree) {
|
||||
const parseContent = provider.preprocessSource?.(file.content, file.path) ?? file.content;
|
||||
try {
|
||||
tree = parser.parse(parseContent, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(parseContent),
|
||||
tree = parser.parse(file.content, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(file.content),
|
||||
});
|
||||
} catch {
|
||||
continue;
|
||||
|
||||
@@ -1,265 +0,0 @@
|
||||
/**
|
||||
* Unreal Engine reflection-macro preprocessor for C++ source.
|
||||
*
|
||||
* Tree-sitter does not expand C preprocessor macros, so Unreal's reflection
|
||||
* markers (`UCLASS(...)`, `UFUNCTION(...)`, `MODULENAME_API`, ...) are parsed
|
||||
* verbatim. The result is mis-parsed declarations: in `class BRAWLUI_API
|
||||
* UMyClass : public UObject`, tree-sitter-cpp captures `BRAWLUI_API` as the
|
||||
* class name and the rest of the declaration becomes structurally wrong.
|
||||
*
|
||||
* This module elides those macros from the source text BEFORE tree-sitter
|
||||
* parses it. Replacement is **length-preserving** (each elided byte becomes
|
||||
* a space, newlines preserved) so byte offsets and line/column positions
|
||||
* tree-sitter reports remain identical to the original file. Symbol
|
||||
* locations in the graph stay accurate.
|
||||
*
|
||||
* A cheap detection guard short-circuits files that don't look like UE
|
||||
* sources, so non-UE C++ codebases pay no cost.
|
||||
*
|
||||
* Pure function — no tree-sitter dependency, safe for worker threads.
|
||||
*/
|
||||
/**
|
||||
* Strong UE markers — reflection macros that only Unreal Engine projects use.
|
||||
* Presence of one of these is sufficient evidence that the file is a UE source
|
||||
* and that `MODULENAME_API` tokens in it are intended as export macros.
|
||||
*
|
||||
* Importantly, `_API` tokens are NOT in this guard — `REST_API`, `HTTP_API`,
|
||||
* `MY_LIB_API` and similar identifiers appear in plenty of non-UE C++ codebases
|
||||
* as constants/enums/parameter names. We must not erase them just because the
|
||||
* file mentions an `_API` token.
|
||||
*/
|
||||
const HAS_UE_HINT =
|
||||
/\b(?:UCLASS|UFUNCTION|UPROPERTY|USTRUCT|UENUM|UINTERFACE|GENERATED_BODY|GENERATED_[A-Z_]+_BODY|UE_DEPRECATED|DECLARE_(?:DYNAMIC_)?(?:MULTICAST_)?DELEGATE)/;
|
||||
|
||||
const SIMPLE_MACROS_NO_ARGS: readonly string[] = [
|
||||
'GENERATED_BODY',
|
||||
'GENERATED_UCLASS_BODY',
|
||||
'GENERATED_USTRUCT_BODY',
|
||||
'GENERATED_UINTERFACE_BODY',
|
||||
'GENERATED_IINTERFACE_BODY',
|
||||
'DECLARE_CLASS',
|
||||
'GENERATED_BODY_LEGACY',
|
||||
];
|
||||
|
||||
const PARENTHESIZED_MACROS: readonly string[] = [
|
||||
'UCLASS',
|
||||
'UFUNCTION',
|
||||
'UPROPERTY',
|
||||
'USTRUCT',
|
||||
'UENUM',
|
||||
'UINTERFACE',
|
||||
'UMETA',
|
||||
'UE_DEPRECATED',
|
||||
];
|
||||
|
||||
const DELEGATE_MACRO_RE =
|
||||
/\bDECLARE_(?:DYNAMIC_)?(?:MULTICAST_)?DELEGATE(?:_(?:RetVal_OneParam|RetVal_TwoParams|RetVal_ThreeParams|RetVal_FourParams|RetVal_FiveParams|RetVal_SixParams|RetVal_SevenParams|RetVal_EightParams|RetVal_NineParams|RetVal|OneParam|TwoParams|ThreeParams|FourParams|FiveParams|SixParams|SevenParams|EightParams|NineParams|TenParams))?(?=\s*\()/g;
|
||||
|
||||
/**
|
||||
* Module export tokens like `BRAWLUI_API`, `ENGINE_API`, `COREUOBJECT_API`.
|
||||
* Pattern: ALL_CAPS identifier ending in `_API`. The leading word boundary
|
||||
* (`\b`) prevents matching mid-identifier.
|
||||
*/
|
||||
const API_MACRO_RE = /\b[A-Z][A-Z0-9_]*_API\b/g;
|
||||
|
||||
/** Replace `[start, end)` of `chars` with spaces, preserving newlines. */
|
||||
function eraseRange(chars: string[], start: number, end: number): void {
|
||||
for (let i = start; i < end; i++) {
|
||||
if (chars[i] !== '\n' && chars[i] !== '\r') {
|
||||
chars[i] = ' ';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the matching close paren for an opening paren at index `openIdx`.
|
||||
* Returns the index of `)` (inclusive end), or -1 if unbalanced.
|
||||
*
|
||||
* Handles nested parens and string/char literals so commas/parens inside
|
||||
* strings don't throw off the match. Does not attempt to handle raw string
|
||||
* literals (`R"(...)"`); UE reflection-macro arguments do not use them in
|
||||
* practice.
|
||||
*/
|
||||
function findMatchingParen(source: string, openIdx: number): number {
|
||||
if (source.charCodeAt(openIdx) !== 0x28) return -1;
|
||||
let depth = 1;
|
||||
let i = openIdx + 1;
|
||||
const len = source.length;
|
||||
while (i < len && depth > 0) {
|
||||
const ch = source.charCodeAt(i);
|
||||
// String literal
|
||||
if (ch === 0x22) {
|
||||
i++;
|
||||
while (i < len) {
|
||||
const c = source.charCodeAt(i);
|
||||
if (c === 0x5c) {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (c === 0x22) {
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Char literal
|
||||
if (ch === 0x27) {
|
||||
i++;
|
||||
while (i < len) {
|
||||
const c = source.charCodeAt(i);
|
||||
if (c === 0x5c) {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (c === 0x27) {
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Line comment
|
||||
if (ch === 0x2f && source.charCodeAt(i + 1) === 0x2f) {
|
||||
while (i < len && source.charCodeAt(i) !== 0x0a) i++;
|
||||
continue;
|
||||
}
|
||||
// Block comment
|
||||
if (ch === 0x2f && source.charCodeAt(i + 1) === 0x2a) {
|
||||
i += 2;
|
||||
while (i < len) {
|
||||
if (source.charCodeAt(i) === 0x2a && source.charCodeAt(i + 1) === 0x2f) {
|
||||
i += 2;
|
||||
break;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch === 0x28) depth++;
|
||||
else if (ch === 0x29) {
|
||||
depth--;
|
||||
if (depth === 0) return i;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** Match a whole-word identifier at `idx`. Returns the byte after the identifier, or -1 on miss. */
|
||||
function matchIdentifierAt(source: string, idx: number, name: string): number {
|
||||
if (idx > 0) {
|
||||
const prev = source.charCodeAt(idx - 1);
|
||||
if (
|
||||
(prev >= 0x30 && prev <= 0x39) ||
|
||||
(prev >= 0x41 && prev <= 0x5a) ||
|
||||
(prev >= 0x61 && prev <= 0x7a) ||
|
||||
prev === 0x5f
|
||||
) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
for (let k = 0; k < name.length; k++) {
|
||||
if (source.charCodeAt(idx + k) !== name.charCodeAt(k)) return -1;
|
||||
}
|
||||
const after = idx + name.length;
|
||||
if (after < source.length) {
|
||||
const next = source.charCodeAt(after);
|
||||
if (
|
||||
(next >= 0x30 && next <= 0x39) ||
|
||||
(next >= 0x41 && next <= 0x5a) ||
|
||||
(next >= 0x61 && next <= 0x7a) ||
|
||||
next === 0x5f
|
||||
) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
return after;
|
||||
}
|
||||
|
||||
/** Skip ASCII whitespace forward from `idx`. Returns the next non-whitespace byte index. */
|
||||
function skipWhitespace(source: string, idx: number): number {
|
||||
const len = source.length;
|
||||
while (idx < len) {
|
||||
const ch = source.charCodeAt(idx);
|
||||
if (ch === 0x20 || ch === 0x09 || ch === 0x0a || ch === 0x0d) {
|
||||
idx++;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return idx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip Unreal Engine reflection macros from C++ source, length-preserving.
|
||||
*
|
||||
* Returns the original string unchanged if no strong UE marker is detected,
|
||||
* so non-UE C++ files (including ones that contain `*_API`-suffixed
|
||||
* identifiers like `REST_API` or `HTTP_API`) incur only a single regex test.
|
||||
*
|
||||
* The `_filePath` parameter is part of the `LanguageProvider.preprocessSource`
|
||||
* contract but is unused — UE detection is purely content-based. Accepted and
|
||||
* ignored here so the function matches the hook signature exactly.
|
||||
*/
|
||||
export function stripUeMacros(source: string, _filePath?: string): string {
|
||||
if (!HAS_UE_HINT.test(source)) return source;
|
||||
|
||||
const chars: string[] = source.split('');
|
||||
|
||||
for (const macro of PARENTHESIZED_MACROS) {
|
||||
let searchFrom = 0;
|
||||
while (true) {
|
||||
const hit = source.indexOf(macro, searchFrom);
|
||||
if (hit < 0) break;
|
||||
searchFrom = hit + 1;
|
||||
const after = matchIdentifierAt(source, hit, macro);
|
||||
if (after < 0) continue;
|
||||
const parenIdx = skipWhitespace(source, after);
|
||||
if (source.charCodeAt(parenIdx) !== 0x28) continue;
|
||||
const close = findMatchingParen(source, parenIdx);
|
||||
if (close < 0) continue;
|
||||
eraseRange(chars, hit, close + 1);
|
||||
}
|
||||
}
|
||||
|
||||
for (const macro of SIMPLE_MACROS_NO_ARGS) {
|
||||
let searchFrom = 0;
|
||||
while (true) {
|
||||
const hit = source.indexOf(macro, searchFrom);
|
||||
if (hit < 0) break;
|
||||
searchFrom = hit + 1;
|
||||
const after = matchIdentifierAt(source, hit, macro);
|
||||
if (after < 0) continue;
|
||||
const parenIdx = skipWhitespace(source, after);
|
||||
if (source.charCodeAt(parenIdx) === 0x28) {
|
||||
const close = findMatchingParen(source, parenIdx);
|
||||
if (close < 0) continue;
|
||||
eraseRange(chars, hit, close + 1);
|
||||
} else {
|
||||
eraseRange(chars, hit, after);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const re of [DELEGATE_MACRO_RE, API_MACRO_RE]) {
|
||||
re.lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = re.exec(source)) !== null) {
|
||||
const start = match.index;
|
||||
let end = start + match[0].length;
|
||||
if (re === DELEGATE_MACRO_RE) {
|
||||
const parenIdx = skipWhitespace(source, end);
|
||||
if (source.charCodeAt(parenIdx) === 0x28) {
|
||||
const close = findMatchingParen(source, parenIdx);
|
||||
if (close >= 0) end = close + 1;
|
||||
}
|
||||
}
|
||||
eraseRange(chars, start, end);
|
||||
}
|
||||
}
|
||||
|
||||
return chars.join('');
|
||||
}
|
||||
@@ -9,11 +9,6 @@ import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
|
||||
const CSHARP_VIS = new Set<FieldVisibility>(['public', 'private', 'protected', 'internal']);
|
||||
|
||||
const extractCsharpDeclaredType = (typeNode: SyntaxNode): string | undefined => {
|
||||
if (typeNode.type === 'generic_name') return typeNode.text.trim();
|
||||
return extractSimpleTypeName(typeNode) ?? typeNode.text?.trim();
|
||||
};
|
||||
|
||||
/**
|
||||
* C# field extraction config.
|
||||
*
|
||||
@@ -58,17 +53,17 @@ export const csharpConfig: FieldExtractionConfig = {
|
||||
const child = node.namedChild(i);
|
||||
if (child?.type === 'variable_declaration') {
|
||||
const typeNode = child.childForFieldName('type');
|
||||
if (typeNode) return extractCsharpDeclaredType(typeNode);
|
||||
if (typeNode) return extractSimpleTypeName(typeNode) ?? typeNode.text?.trim();
|
||||
// fallback: first child that is a type
|
||||
const first = child.firstNamedChild;
|
||||
if (first && first.type !== 'variable_declarator') {
|
||||
return extractCsharpDeclaredType(first);
|
||||
return extractSimpleTypeName(first) ?? first.text?.trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
// property_declaration: type is first named child
|
||||
const typeNode = node.childForFieldName('type');
|
||||
if (typeNode) return extractCsharpDeclaredType(typeNode);
|
||||
if (typeNode) return extractSimpleTypeName(typeNode) ?? typeNode.text?.trim();
|
||||
return undefined;
|
||||
},
|
||||
|
||||
|
||||
@@ -219,13 +219,9 @@ export const processHeritage = async (
|
||||
let tree = astCache.get(file.path);
|
||||
if (!tree) {
|
||||
// Use larger bufferSize for files > 32KB
|
||||
// Per-language source preprocessor (length-preserving, e.g. UE macro
|
||||
// stripping for C++). MUST mirror parsing-processor on cache miss so
|
||||
// re-parses see the same input as the cached AST.
|
||||
const parseContent = provider.preprocessSource?.(file.content, file.path) ?? file.content;
|
||||
try {
|
||||
tree = parser.parse(parseContent, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(parseContent),
|
||||
tree = parser.parse(file.content, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(file.content),
|
||||
});
|
||||
} catch (parseError) {
|
||||
// Skip files that can't be parsed
|
||||
@@ -417,10 +413,9 @@ export async function extractExtractedHeritageFromFiles(
|
||||
|
||||
let tree = astCache.get(file.path);
|
||||
if (!tree) {
|
||||
const parseContent = provider.preprocessSource?.(file.content, file.path) ?? file.content;
|
||||
try {
|
||||
tree = parser.parse(parseContent, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(parseContent),
|
||||
tree = parser.parse(file.content, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(file.content),
|
||||
});
|
||||
} catch {
|
||||
continue;
|
||||
|
||||
@@ -305,10 +305,9 @@ export const processImports = async (
|
||||
let wasReparsed = false;
|
||||
|
||||
if (!tree) {
|
||||
const parseContent = provider.preprocessSource?.(file.content, file.path) ?? file.content;
|
||||
try {
|
||||
tree = parser.parse(parseContent, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(parseContent),
|
||||
tree = parser.parse(file.content, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(file.content),
|
||||
});
|
||||
} catch (parseError) {
|
||||
continue;
|
||||
|
||||
@@ -116,39 +116,6 @@ interface LanguageProviderConfig {
|
||||
* Required for tree-sitter languages; empty string for standalone processors. */
|
||||
readonly treeSitterQueries: string;
|
||||
|
||||
/**
|
||||
* Optional source-text transform that runs **before** tree-sitter parses the file.
|
||||
*
|
||||
* Used to elide language constructs that confuse the grammar without affecting
|
||||
* source-position fidelity — e.g., Unreal Engine reflection macros (`UCLASS`,
|
||||
* `UFUNCTION`, `MODULENAME_API`) in C++ headers that prevent the parser from
|
||||
* recognising class/function names correctly.
|
||||
*
|
||||
* **Length / position preservation:** the returned string MUST have the same
|
||||
* JavaScript `.length` as the input AND preserve every newline (`\n`/`\r`)
|
||||
* position byte-for-byte. Implementations replace elided characters with
|
||||
* ASCII spaces while leaving newlines untouched. With this contract:
|
||||
*
|
||||
* - tree-sitter's reported `startPosition.row`/`startPosition.column`
|
||||
* match the original file exactly (line/column come from newline counts)
|
||||
* - `startIndex`/`endIndex` byte offsets match the original file exactly
|
||||
* **when the elided range is pure ASCII** (UTF-16 `.length` equals UTF-8
|
||||
* byte length only for ASCII).
|
||||
*
|
||||
* Implementations targeting languages where elided ranges may contain
|
||||
* non-ASCII content must therefore preserve byte length, not just `.length`,
|
||||
* if downstream code uses `startIndex` to slice the original UTF-8 bytes.
|
||||
* The current C++ UE-macro preprocessor relies on the practical fact that
|
||||
* UE reflection macros and module-export tokens are ASCII-only.
|
||||
*
|
||||
* Must be a pure function — same input always yields the same output. Called
|
||||
* once per file, on every code path that re-parses (parsing-processor, import
|
||||
* processor, heritage processor, call processor, parse worker).
|
||||
*
|
||||
* Default: undefined (no preprocessing — `file.content` is parsed verbatim).
|
||||
*/
|
||||
readonly preprocessSource?: (sourceText: string, filePath: string) => string;
|
||||
|
||||
// ── Core (required) ───────────────────────────────────────────────
|
||||
/** Type extraction: declarations, initializers, for-loop bindings */
|
||||
readonly typeConfig: LanguageTypeConfig;
|
||||
|
||||
@@ -45,7 +45,6 @@ import { cVariableConfig, cppVariableConfig } from '../variable-extractors/confi
|
||||
import { createCallExtractor } from '../call-extractors/generic.js';
|
||||
import { cCallConfig, cppCallConfig } from '../call-extractors/configs/c-cpp.js';
|
||||
import { createHeritageExtractor } from '../heritage-extractors/generic.js';
|
||||
import { stripUeMacros } from '../cpp-ue-preprocessor.js';
|
||||
|
||||
const C_BUILT_INS: ReadonlySet<string> = new Set([
|
||||
'printf',
|
||||
@@ -411,7 +410,6 @@ export const cppProvider = defineLanguage({
|
||||
},
|
||||
] satisfies AstFrameworkPatternConfig[],
|
||||
treeSitterQueries: CPP_QUERIES,
|
||||
preprocessSource: stripUeMacros,
|
||||
typeConfig: cCppConfig,
|
||||
exportChecker: cCppExportChecker,
|
||||
importResolver: createImportResolver(cppImportConfig),
|
||||
|
||||
@@ -42,39 +42,6 @@ const FUNCTION_NODE_TYPES = [
|
||||
'local_function_statement',
|
||||
] as const;
|
||||
|
||||
const BUILTIN_TYPE_NAMES = new Set([
|
||||
'bool',
|
||||
'byte',
|
||||
'char',
|
||||
'decimal',
|
||||
'double',
|
||||
'float',
|
||||
'int',
|
||||
'long',
|
||||
'object',
|
||||
'sbyte',
|
||||
'short',
|
||||
'string',
|
||||
'uint',
|
||||
'ulong',
|
||||
'ushort',
|
||||
'void',
|
||||
]);
|
||||
|
||||
function shouldEmitReadMember(memberNode: SyntaxNode): boolean {
|
||||
const parent = memberNode.parent;
|
||||
if (parent === null) return true;
|
||||
|
||||
switch (parent.type) {
|
||||
case 'invocation_expression':
|
||||
return parent.childForFieldName('function')?.id !== memberNode.id;
|
||||
case 'assignment_expression':
|
||||
return parent.childForFieldName('left')?.id !== memberNode.id;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function emitCsharpScopeCaptures(
|
||||
sourceText: string,
|
||||
_filePath: string,
|
||||
@@ -127,14 +94,6 @@ export function emitCsharpScopeCaptures(
|
||||
continue;
|
||||
}
|
||||
|
||||
if (grouped['@reference.read.member'] !== undefined) {
|
||||
const anchor = grouped['@reference.read.member'];
|
||||
const memberNode = findNodeAtRange(tree.rootNode, anchor.range, 'member_access_expression');
|
||||
if (memberNode === null || !shouldEmitReadMember(memberNode)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Synthesize `this` / `base` receiver type-bindings on every
|
||||
// instance method-like. Tree-sitter can't cleanly express "the
|
||||
// implicit receiver of a non-static member of a class/struct/
|
||||
@@ -250,63 +209,9 @@ export function emitCsharpScopeCaptures(
|
||||
}
|
||||
}
|
||||
|
||||
out.push(...synthesizeGenericTypeArgumentReferences(tree.rootNode));
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function synthesizeGenericTypeArgumentReferences(root: SyntaxNode): CaptureMatch[] {
|
||||
const out: CaptureMatch[] = [];
|
||||
// Treat all generic type arguments as static type references, including
|
||||
// declaration signatures and call-site generic instantiations.
|
||||
visit(root, (node) => {
|
||||
if (node.type !== 'generic_name') return;
|
||||
const args = findNamedChild(node, 'type_argument_list');
|
||||
if (args === null) return;
|
||||
|
||||
for (const arg of args.namedChildren) {
|
||||
if (arg === null) continue;
|
||||
const nameNode = terminalTypeNameNode(arg);
|
||||
if (nameNode === null) continue;
|
||||
if (BUILTIN_TYPE_NAMES.has(nameNode.text)) continue;
|
||||
out.push({
|
||||
'@reference.type': nodeToCapture('@reference.type', nameNode),
|
||||
'@reference.name': nodeToCapture('@reference.name', nameNode),
|
||||
});
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function terminalTypeNameNode(node: SyntaxNode): SyntaxNode | null {
|
||||
switch (node.type) {
|
||||
case 'identifier':
|
||||
return node;
|
||||
case 'nullable_type':
|
||||
return node.firstNamedChild === null ? null : terminalTypeNameNode(node.firstNamedChild);
|
||||
case 'qualified_name':
|
||||
return node.lastNamedChild;
|
||||
case 'generic_name':
|
||||
return node.childForFieldName('name') ?? node.firstNamedChild;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function findNamedChild(node: SyntaxNode, type: string): SyntaxNode | null {
|
||||
for (const child of node.namedChildren) {
|
||||
if (child !== null && child.type === type) return child;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function visit(node: SyntaxNode, cb: (node: SyntaxNode) => void): void {
|
||||
cb(node);
|
||||
for (const child of node.namedChildren) {
|
||||
if (child !== null) visit(child, cb);
|
||||
}
|
||||
}
|
||||
|
||||
/** C# 12 primary constructor: `class X(a, b) { }` / `record X(a, b)`.
|
||||
* The parameters are a bare `parameter_list` named child of the type
|
||||
* declaration (no `constructor_declaration` node). Emit a synthetic
|
||||
|
||||
@@ -499,12 +499,6 @@ const CSHARP_SCOPE_QUERY = `
|
||||
left: (member_access_expression
|
||||
expression: "base" @reference.receiver
|
||||
name: (identifier) @reference.name)) @reference.write.member
|
||||
|
||||
;; References — field/property reads: \`obj.Name\`
|
||||
;; Emit-side filtering drops call targets and assignment left-hand sides.
|
||||
(member_access_expression
|
||||
expression: (_) @reference.receiver
|
||||
name: (identifier) @reference.name) @reference.read.member
|
||||
`;
|
||||
|
||||
let _parser: Parser | null = null;
|
||||
|
||||
@@ -371,11 +371,6 @@ const processParsingSequential = async (
|
||||
isVueSetup = extracted.isSetup;
|
||||
}
|
||||
|
||||
// Per-language source-text transform (e.g., UE macro stripping for C++).
|
||||
// Length-preserving — see LanguageProvider.preprocessSource contract.
|
||||
parseContent =
|
||||
getProvider(language).preprocessSource?.(parseContent, file.path) ?? parseContent;
|
||||
|
||||
try {
|
||||
await loadLanguage(language, file.path);
|
||||
} catch {
|
||||
|
||||
@@ -1407,11 +1407,6 @@ const processFileGroup = (
|
||||
isVueSetup = extracted.isSetup;
|
||||
}
|
||||
|
||||
// Per-language source-text transform (e.g., UE macro stripping for C++).
|
||||
// Length-preserving — see LanguageProvider.preprocessSource contract.
|
||||
parseContent =
|
||||
getProvider(language).preprocessSource?.(parseContent, file.path) ?? parseContent;
|
||||
|
||||
clearCaches(); // Reset memoization before each new file
|
||||
|
||||
let tree;
|
||||
|
||||
@@ -301,15 +301,11 @@ export const streamAllCSVsToDisk = async (
|
||||
'Template',
|
||||
'Module',
|
||||
] as const;
|
||||
const propertyHeader = 'id,name,filePath,startLine,endLine,content,description,declaredType';
|
||||
const multiLangWriters = new Map<string, BufferedCSVWriter>();
|
||||
for (const t of MULTI_LANG_TYPES) {
|
||||
multiLangWriters.set(
|
||||
t,
|
||||
new BufferedCSVWriter(
|
||||
path.join(csvDir, `${t.toLowerCase()}.csv`),
|
||||
t === 'Property' ? propertyHeader : multiLangHeader,
|
||||
),
|
||||
new BufferedCSVWriter(path.join(csvDir, `${t.toLowerCase()}.csv`), multiLangHeader),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -482,9 +478,6 @@ export const streamAllCSVsToDisk = async (
|
||||
escapeCSVNumber(node.properties.endLine, -1),
|
||||
escapeCSVField(content),
|
||||
escapeCSVField(node.properties.description || ''),
|
||||
...(node.label === 'Property'
|
||||
? [escapeCSVField(node.properties.declaredType || '')]
|
||||
: []),
|
||||
].join(','),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -608,9 +608,6 @@ const getCopyQuery = (table: NodeTableName, filePath: string): string => {
|
||||
if (table === 'Method') {
|
||||
return `COPY ${t}(id, name, filePath, startLine, endLine, isExported, content, description, parameterCount, returnType) FROM "${filePath}" ${COPY_CSV_OPTS}`;
|
||||
}
|
||||
if (table === 'Property') {
|
||||
return `COPY ${t}(id, name, filePath, startLine, endLine, content, description, declaredType) FROM "${filePath}" ${COPY_CSV_OPTS}`;
|
||||
}
|
||||
// TypeScript/JS code element tables have isExported; multi-language tables do not
|
||||
if (TABLES_WITH_EXPORTED.has(table)) {
|
||||
return `COPY ${t}(id, name, filePath, startLine, endLine, isExported, content, description) FROM "${filePath}" ${COPY_CSV_OPTS}`;
|
||||
@@ -662,11 +659,6 @@ export const insertNodeToLbug = async (
|
||||
? `, description: ${escapeValue(properties.description)}`
|
||||
: '';
|
||||
query = `CREATE (n:${t} {id: ${escapeValue(properties.id)}, name: ${escapeValue(properties.name)}, filePath: ${escapeValue(properties.filePath)}, startLine: ${properties.startLine || 0}, endLine: ${properties.endLine || 0}, isExported: ${!!properties.isExported}, content: ${escapeValue(properties.content || '')}${descPart}})`;
|
||||
} else if (label === 'Property') {
|
||||
const descPart = properties.description
|
||||
? `, description: ${escapeValue(properties.description)}`
|
||||
: '';
|
||||
query = `CREATE (n:${t} {id: ${escapeValue(properties.id)}, name: ${escapeValue(properties.name)}, filePath: ${escapeValue(properties.filePath)}, startLine: ${properties.startLine || 0}, endLine: ${properties.endLine || 0}, content: ${escapeValue(properties.content || '')}${descPart}, declaredType: ${escapeValue(properties.declaredType || '')}})`;
|
||||
} else {
|
||||
// Multi-language tables (Struct, Impl, Trait, Macro, etc.) — no isExported
|
||||
const descPart = properties.description
|
||||
@@ -745,11 +737,6 @@ export const batchInsertNodesToLbug = async (
|
||||
? `, n.description = ${escapeValue(properties.description)}`
|
||||
: '';
|
||||
query = `MERGE (n:${t} {id: ${escapeValue(properties.id)}}) SET n.name = ${escapeValue(properties.name)}, n.filePath = ${escapeValue(properties.filePath)}, n.startLine = ${properties.startLine || 0}, n.endLine = ${properties.endLine || 0}, n.isExported = ${!!properties.isExported}, n.content = ${escapeValue(properties.content || '')}${descPart}`;
|
||||
} else if (label === 'Property') {
|
||||
const descPart = properties.description
|
||||
? `, n.description = ${escapeValue(properties.description)}`
|
||||
: '';
|
||||
query = `MERGE (n:${t} {id: ${escapeValue(properties.id)}}) SET n.name = ${escapeValue(properties.name)}, n.filePath = ${escapeValue(properties.filePath)}, n.startLine = ${properties.startLine || 0}, n.endLine = ${properties.endLine || 0}, n.content = ${escapeValue(properties.content || '')}${descPart}, n.declaredType = ${escapeValue(properties.declaredType || '')}`;
|
||||
} else {
|
||||
const descPart = properties.description
|
||||
? `, n.description = ${escapeValue(properties.description)}`
|
||||
|
||||
@@ -167,18 +167,7 @@ export const TYPE_ALIAS_SCHEMA = CODE_ELEMENT_BASE('TypeAlias');
|
||||
export const CONST_SCHEMA = CODE_ELEMENT_BASE('Const');
|
||||
export const STATIC_SCHEMA = CODE_ELEMENT_BASE('Static');
|
||||
export const VARIABLE_SCHEMA = CODE_ELEMENT_BASE('Variable');
|
||||
export const PROPERTY_SCHEMA = `
|
||||
CREATE NODE TABLE \`Property\` (
|
||||
id STRING,
|
||||
name STRING,
|
||||
filePath STRING,
|
||||
startLine INT64,
|
||||
endLine INT64,
|
||||
content STRING,
|
||||
description STRING,
|
||||
declaredType STRING,
|
||||
PRIMARY KEY (id)
|
||||
)`;
|
||||
export const PROPERTY_SCHEMA = CODE_ELEMENT_BASE('Property');
|
||||
export const RECORD_SCHEMA = CODE_ELEMENT_BASE('Record');
|
||||
export const DELEGATE_SCHEMA = CODE_ELEMENT_BASE('Delegate');
|
||||
export const ANNOTATION_SCHEMA = CODE_ELEMENT_BASE('Annotation');
|
||||
|
||||
@@ -15,16 +15,9 @@ export interface BM25SearchResult {
|
||||
nodeIds?: string[];
|
||||
}
|
||||
|
||||
export interface FTSSearchResponse {
|
||||
results: BM25SearchResult[];
|
||||
/** True when at least one FTS index query succeeded (index exists). */
|
||||
ftsAvailable: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a single FTS query via a custom executor (for MCP connection pool).
|
||||
* Returns `null` when the query fails (e.g. FTS index does not exist) so the
|
||||
* caller can distinguish "zero matches" from "index missing".
|
||||
* Returns the same shape as core queryFTS (from LadybugDB adapter).
|
||||
*/
|
||||
async function queryFTSViaExecutor(
|
||||
executor: (cypher: string) => Promise<any[]>,
|
||||
@@ -32,7 +25,7 @@ async function queryFTSViaExecutor(
|
||||
indexName: string,
|
||||
query: string,
|
||||
limit: number,
|
||||
): Promise<Array<{ filePath: string; score: number; nodeId: string }> | null> {
|
||||
): Promise<Array<{ filePath: string; score: number; nodeId: string }>> {
|
||||
// Escape single quotes and backslashes to prevent Cypher injection
|
||||
const escapedQuery = query.replace(/\\/g, '\\\\').replace(/'/g, "''");
|
||||
const cypher = `
|
||||
@@ -53,7 +46,7 @@ async function queryFTSViaExecutor(
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,9 +65,8 @@ export const searchFTSFromLbug = async (
|
||||
query: string,
|
||||
limit: number = 20,
|
||||
repoId?: string,
|
||||
): Promise<FTSSearchResponse> => {
|
||||
): Promise<BM25SearchResult[]> => {
|
||||
const resultsByIndex: any[][] = [];
|
||||
let queriesSucceeded = 0;
|
||||
|
||||
if (repoId) {
|
||||
// Use MCP connection pool via dynamic import
|
||||
@@ -85,27 +77,15 @@ export const searchFTSFromLbug = async (
|
||||
const executor = (cypher: string) => executeQuery(repoId, cypher);
|
||||
|
||||
for (const { table, indexName } of FTS_INDEXES) {
|
||||
const result = await queryFTSViaExecutor(executor, table, indexName, query, limit);
|
||||
if (result !== null) {
|
||||
queriesSucceeded++;
|
||||
resultsByIndex.push(result);
|
||||
}
|
||||
resultsByIndex.push(await queryFTSViaExecutor(executor, table, indexName, query, limit));
|
||||
}
|
||||
} else {
|
||||
// Use core lbug adapter (CLI / pipeline context) — also sequential for safety.
|
||||
for (const { table, indexName } of FTS_INDEXES) {
|
||||
try {
|
||||
const result = await queryFTS(table, indexName, query, limit, false);
|
||||
queriesSucceeded++;
|
||||
resultsByIndex.push(result);
|
||||
} catch {
|
||||
// FTS index may not exist — count as failed
|
||||
}
|
||||
resultsByIndex.push(await queryFTS(table, indexName, query, limit, false).catch(() => []));
|
||||
}
|
||||
}
|
||||
|
||||
const ftsAvailable = queriesSucceeded > 0;
|
||||
|
||||
// Collect all node scores per filePath to track which nodes actually matched
|
||||
const fileNodeScores = new Map<string, Array<{ score: number; nodeId: string }>>();
|
||||
|
||||
@@ -136,13 +116,10 @@ export const searchFTSFromLbug = async (
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, limit);
|
||||
|
||||
return {
|
||||
results: sorted.map((r, index) => ({
|
||||
filePath: r.filePath,
|
||||
score: r.score,
|
||||
rank: index + 1,
|
||||
nodeIds: r.nodeIds,
|
||||
})),
|
||||
ftsAvailable,
|
||||
};
|
||||
return sorted.map((r, index) => ({
|
||||
filePath: r.filePath,
|
||||
score: r.score,
|
||||
rank: index + 1,
|
||||
nodeIds: r.nodeIds,
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -113,13 +113,12 @@ export const mergeWithRRF = (
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if hybrid search is available.
|
||||
* FTS indexes may be missing on read-only MCP connections (see #1403);
|
||||
* callers should inspect `ftsAvailable` from searchFTSFromLbug for
|
||||
* per-query availability. This helper is a coarse gate only.
|
||||
* Check if hybrid search is available
|
||||
* LadybugDB FTS is always available once the database is initialized.
|
||||
* Semantic search is optional - hybrid works with just FTS if embeddings aren't ready.
|
||||
*/
|
||||
export const isHybridSearchReady = (): boolean => {
|
||||
return true; // FTS is attempted on every query; ftsAvailable signals actual availability
|
||||
return true; // FTS is always available via LadybugDB when DB is open
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -161,7 +160,7 @@ export const hybridSearch = async (
|
||||
) => Promise<SemanticSearchResult[]>,
|
||||
): Promise<HybridSearchResult[]> => {
|
||||
// Use LadybugDB FTS for always-fresh BM25 results
|
||||
const { results: bm25Results } = await searchFTSFromLbug(query, limit);
|
||||
const bm25Results = await searchFTSFromLbug(query, limit);
|
||||
const semanticResults = await semanticSearch(executeQuery, query, limit);
|
||||
return mergeWithRRF(bm25Results, semanticResults, limit);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { logger } from '../logger.js';
|
||||
import { CircuitOpenError, ResilientFetchExhaustedError, resilientFetch } from 'gitnexus-shared';
|
||||
/**
|
||||
* LLM Client for Wiki Generation
|
||||
*
|
||||
@@ -77,49 +76,6 @@ export function estimateTokens(text: string): number {
|
||||
return Math.ceil(text.length / 4);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a base URL supplied for LLM API calls is a safe HTTP/HTTPS
|
||||
* endpoint (CWE-918 / CodeQL js/http-to-file-access).
|
||||
*
|
||||
* Allowed:
|
||||
* - https:// with any hostname (public LLM APIs, Azure, OpenRouter, …)
|
||||
* - http:// restricted to localhost / 127.0.0.1 (local servers: Ollama, LiteLLM, …)
|
||||
*
|
||||
* Rejected:
|
||||
* - file://, data:, javascript:, and any other non-HTTP scheme
|
||||
* - http:// aimed at non-loopback hosts (avoids SSRF against internal networks)
|
||||
*
|
||||
* Throws with a descriptive message on validation failure so callers surface a
|
||||
* clear error rather than an opaque network error.
|
||||
*/
|
||||
export function validateLLMBaseUrl(baseUrl: string): void {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(baseUrl);
|
||||
} catch {
|
||||
// Do not include the raw input in the message — it may contain credentials.
|
||||
throw new Error('Invalid LLM base URL: must be a well-formed http:// or https:// URL');
|
||||
}
|
||||
|
||||
if (!['https:', 'http:'].includes(parsed.protocol)) {
|
||||
// Use parsed.protocol only (scheme), not the full URL, to avoid leaking credentials.
|
||||
throw new Error(`LLM base URL must use http:// or https:// (got ${parsed.protocol})`);
|
||||
}
|
||||
|
||||
if (parsed.protocol === 'http:') {
|
||||
// Node's URL parser preserves IPv6 brackets in hostname (e.g. "[::1]"),
|
||||
// so strip them before comparing to bare address literals.
|
||||
const host = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
||||
if (host !== 'localhost' && host !== '127.0.0.1' && host !== '::1') {
|
||||
// Use parsed.origin (scheme+host+port, no credentials) instead of the full URL.
|
||||
throw new Error(
|
||||
`Insecure http:// LLM base URLs are only allowed for localhost/127.0.0.1. ` +
|
||||
`Use https:// for remote endpoints (got ${parsed.origin})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given base URL is an Azure OpenAI endpoint.
|
||||
* Uses proper hostname matching to avoid spoofed URLs like
|
||||
@@ -171,9 +127,6 @@ export async function callLLM(
|
||||
systemPrompt?: string,
|
||||
options?: CallLLMOptions,
|
||||
): Promise<LLMResponse> {
|
||||
// Validate base URL before any fetch (CodeQL js/http-to-file-access)
|
||||
validateLLMBaseUrl(config.baseUrl);
|
||||
|
||||
const messages: Array<{ role: string; content: string }> = [];
|
||||
if (systemPrompt) {
|
||||
messages.push({ role: 'system', content: systemPrompt });
|
||||
@@ -217,85 +170,86 @@ export async function callLLM(
|
||||
? { 'api-key': config.apiKey }
|
||||
: { Authorization: `Bearer ${config.apiKey}` };
|
||||
|
||||
// Network resilience (bounded retries with exponential-backoff jitter,
|
||||
// 5xx + 429 + Retry-After handling, in-process circuit breaker on the
|
||||
// LLM endpoint) is delegated to resilientFetch. Provider-specific
|
||||
// error parsing (Azure content filter, empty-content checks) stays
|
||||
// here since it requires response-body inspection.
|
||||
let response: Response;
|
||||
try {
|
||||
response = await resilientFetch(
|
||||
url,
|
||||
{
|
||||
const MAX_RETRIES = 3;
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...authHeaders,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
// Per-attempt timeout. Without this each retry can hang
|
||||
// indefinitely on a frozen TCP connection — the per-call
|
||||
// signal is the only timeout `resilientFetch` honors;
|
||||
// `capDelayMs` only bounds the *backoff* between attempts.
|
||||
// 60s matches typical LLM completion budgets.
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
},
|
||||
{
|
||||
breakerKey: `wiki-llm-${new URL(url).host}`,
|
||||
retry: { maxAttempts: 3, baseDelayMs: 2_000, capDelayMs: 30_000 },
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof CircuitOpenError) {
|
||||
throw new Error(
|
||||
`LLM endpoint circuit open: retry in ${Math.ceil(err.retryAfterMs / 1000)}s. ${err.message}`,
|
||||
);
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => 'unknown error');
|
||||
|
||||
// Azure content filter — surface a clear message instead of a generic API error
|
||||
if (
|
||||
azure &&
|
||||
response.status === 400 &&
|
||||
(errorText.includes('content_filter') ||
|
||||
errorText.includes('ResponsibleAIPolicyViolation'))
|
||||
) {
|
||||
throw new Error(
|
||||
`Azure content filter blocked this request. The prompt triggered content policy. Details: ${errorText.slice(0, 300)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Rate limit — wait with exponential backoff and retry
|
||||
if (response.status === 429 && attempt < MAX_RETRIES - 1) {
|
||||
const retryAfter = parseInt(response.headers.get('retry-after') || '0', 10);
|
||||
const delay = retryAfter > 0 ? retryAfter * 1000 : 2 ** attempt * 3000;
|
||||
await sleep(delay);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Server error — retry with backoff
|
||||
if (response.status >= 500 && attempt < MAX_RETRIES - 1) {
|
||||
await sleep((attempt + 1) * 2000);
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new Error(`LLM API error (${response.status}): ${errorText.slice(0, 500)}`);
|
||||
}
|
||||
|
||||
// Streaming path
|
||||
if (useStream && response.body) {
|
||||
return await readSSEStream(response.body, options!.onChunk!);
|
||||
}
|
||||
|
||||
// Non-streaming path
|
||||
const json = (await response.json()) as any;
|
||||
const choice = json.choices?.[0];
|
||||
if (!choice?.message?.content) {
|
||||
throw new Error('LLM returned empty response');
|
||||
}
|
||||
|
||||
return {
|
||||
content: choice.message.content,
|
||||
promptTokens: json.usage?.prompt_tokens,
|
||||
completionTokens: json.usage?.completion_tokens,
|
||||
};
|
||||
} catch (err: any) {
|
||||
lastError = err;
|
||||
|
||||
// Network error — retry with backoff
|
||||
if (
|
||||
attempt < MAX_RETRIES - 1 &&
|
||||
(err.code === 'ECONNREFUSED' || err.code === 'ETIMEDOUT' || err.message?.includes('fetch'))
|
||||
) {
|
||||
await sleep((attempt + 1) * 3000);
|
||||
continue;
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
if (err instanceof ResilientFetchExhaustedError) {
|
||||
const errorText = await err.response.text().catch(() => 'unknown error');
|
||||
throw new Error(
|
||||
`LLM API error (${err.response.status} after retries): ${errorText.slice(0, 500)}`,
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => 'unknown error');
|
||||
|
||||
// Azure content filter — surface a clear message instead of a generic API error.
|
||||
if (
|
||||
azure &&
|
||||
response.status === 400 &&
|
||||
(errorText.includes('content_filter') || errorText.includes('ResponsibleAIPolicyViolation'))
|
||||
) {
|
||||
throw new Error(
|
||||
`Azure content filter blocked this request. The prompt triggered content policy. Details: ${errorText.slice(0, 300)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Any other non-OK response here is a terminal 4xx — resilientFetch
|
||||
// already retried 5xx/429 to exhaustion and would have thrown above.
|
||||
throw new Error(`LLM API error (${response.status}): ${errorText.slice(0, 500)}`);
|
||||
}
|
||||
|
||||
// Streaming path
|
||||
if (useStream && response.body) {
|
||||
return await readSSEStream(response.body, options!.onChunk!);
|
||||
}
|
||||
|
||||
// Non-streaming path
|
||||
const json = (await response.json()) as any;
|
||||
const choice = json.choices?.[0];
|
||||
if (!choice?.message?.content) {
|
||||
throw new Error('LLM returned empty response');
|
||||
}
|
||||
|
||||
return {
|
||||
content: choice.message.content,
|
||||
promptTokens: json.usage?.prompt_tokens,
|
||||
completionTokens: json.usage?.completion_tokens,
|
||||
};
|
||||
throw lastError || new Error('LLM call failed after retries');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -358,3 +312,7 @@ async function readSSEStream(
|
||||
|
||||
return { content };
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
@@ -979,7 +979,7 @@ export class LocalBackend {
|
||||
timing,
|
||||
...(!ftsUsed && {
|
||||
warning:
|
||||
'FTS indexes missing — keyword search degraded. Run: gitnexus analyze --force to rebuild indexes.',
|
||||
'FTS extension unavailable - keyword search degraded. Run: gitnexus analyze --force to rebuild indexes.',
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -993,9 +993,9 @@ export class LocalBackend {
|
||||
limit: number,
|
||||
): Promise<{ results: any[]; ftsUsed: boolean }> {
|
||||
const { searchFTSFromLbug } = await import('../../core/search/bm25-index.js');
|
||||
let ftsResponse;
|
||||
let bm25Results;
|
||||
try {
|
||||
ftsResponse = await searchFTSFromLbug(query, limit, repo.id);
|
||||
bm25Results = await searchFTSFromLbug(query, limit, repo.id);
|
||||
} catch (err: any) {
|
||||
logger.error(
|
||||
{ err: err.message },
|
||||
@@ -1004,8 +1004,7 @@ export class LocalBackend {
|
||||
return { results: [], ftsUsed: false };
|
||||
}
|
||||
|
||||
const bm25Results = ftsResponse.results;
|
||||
const ftsUsed = ftsResponse.ftsAvailable;
|
||||
const ftsUsed = bm25Results.length === 0 || bm25Results[0]?.ftsUsed !== false;
|
||||
|
||||
const results: any[] = [];
|
||||
|
||||
@@ -1756,13 +1755,12 @@ export class LocalBackend {
|
||||
repo.id,
|
||||
`
|
||||
MATCH (caller)-[r:CodeRelation]->(n {id: $symId})
|
||||
WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'USES', 'HAS_METHOD', 'HAS_PROPERTY', 'METHOD_OVERRIDES', 'OVERRIDES', 'METHOD_IMPLEMENTS', 'ACCESSES']
|
||||
WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'HAS_METHOD', 'HAS_PROPERTY', 'METHOD_OVERRIDES', 'OVERRIDES', 'METHOD_IMPLEMENTS', 'ACCESSES']
|
||||
RETURN r.type AS relType, caller.id AS uid, caller.name AS name, caller.filePath AS filePath, labels(caller)[0] AS kind
|
||||
LIMIT 30
|
||||
`,
|
||||
{ symId },
|
||||
);
|
||||
let typedPropertyRows: any[] = [];
|
||||
|
||||
// Fix #480: Class/Interface nodes have no direct CALLS/IMPORTS edges —
|
||||
// those point to Constructor and File nodes respectively. Fetch those
|
||||
@@ -1796,24 +1794,23 @@ export class LocalBackend {
|
||||
|
||||
if (isClassLike) {
|
||||
try {
|
||||
// Run incoming-ref queries in parallel — they are independent.
|
||||
const [ctorIncoming, fileIncoming, typedPropertyIncoming, typedProperties] =
|
||||
await Promise.all([
|
||||
executeParameterized(
|
||||
repo.id,
|
||||
`
|
||||
// Run both incoming-ref queries in parallel — they are independent.
|
||||
const [ctorIncoming, fileIncoming] = await Promise.all([
|
||||
executeParameterized(
|
||||
repo.id,
|
||||
`
|
||||
MATCH (n)-[hm:CodeRelation]->(ctor:Constructor)
|
||||
WHERE n.id = $symId AND hm.type = 'HAS_METHOD'
|
||||
MATCH (caller)-[r:CodeRelation]->(ctor)
|
||||
WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'USES', 'ACCESSES']
|
||||
WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'ACCESSES']
|
||||
RETURN r.type AS relType, caller.id AS uid, caller.name AS name, caller.filePath AS filePath, labels(caller)[0] AS kind
|
||||
LIMIT 30
|
||||
`,
|
||||
{ symId },
|
||||
),
|
||||
executeParameterized(
|
||||
repo.id,
|
||||
`
|
||||
{ symId },
|
||||
),
|
||||
executeParameterized(
|
||||
repo.id,
|
||||
`
|
||||
MATCH (f:File)-[rel:CodeRelation]->(n)
|
||||
WHERE n.id = $symId AND rel.type = 'DEFINES'
|
||||
MATCH (caller)-[r:CodeRelation]->(f)
|
||||
@@ -1821,45 +1818,9 @@ export class LocalBackend {
|
||||
RETURN r.type AS relType, caller.id AS uid, caller.name AS name, caller.filePath AS filePath, labels(caller)[0] AS kind
|
||||
LIMIT 30
|
||||
`,
|
||||
{ symId },
|
||||
),
|
||||
executeParameterized(
|
||||
repo.id,
|
||||
`
|
||||
MATCH (p:\`Property\`)
|
||||
WHERE p.declaredType = $name
|
||||
OR p.declaredType STARTS WITH $genericPrefix
|
||||
OR p.declaredType CONTAINS $genericArg
|
||||
MATCH (caller)-[r:CodeRelation]->(p)
|
||||
WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'USES', 'ACCESSES']
|
||||
RETURN r.type AS relType, caller.id AS uid, caller.name AS name, caller.filePath AS filePath, labels(caller)[0] AS kind
|
||||
LIMIT 30
|
||||
`,
|
||||
{
|
||||
name: sym.name,
|
||||
genericPrefix: `${sym.name}<`,
|
||||
genericArg: `<${sym.name}>`,
|
||||
},
|
||||
),
|
||||
executeParameterized(
|
||||
repo.id,
|
||||
`
|
||||
MATCH (p:\`Property\`)
|
||||
WHERE p.declaredType = $name
|
||||
OR p.declaredType STARTS WITH $genericPrefix
|
||||
OR p.declaredType CONTAINS $genericArg
|
||||
RETURN p.id AS uid, p.name AS name, p.filePath AS filePath, labels(p)[0] AS kind,
|
||||
p.declaredType AS declaredType
|
||||
LIMIT 30
|
||||
`,
|
||||
{
|
||||
name: sym.name,
|
||||
genericPrefix: `${sym.name}<`,
|
||||
genericArg: `<${sym.name}>`,
|
||||
},
|
||||
),
|
||||
]);
|
||||
typedPropertyRows = typedProperties;
|
||||
{ symId },
|
||||
),
|
||||
]);
|
||||
|
||||
// Deduplicate by (relType, uid) — a caller can have multiple relation
|
||||
// types to the same target (e.g. both IMPORTS and CALLS), and each
|
||||
@@ -1867,7 +1828,7 @@ export class LocalBackend {
|
||||
const seenKeys = new Set(
|
||||
incomingRows.map((r: any) => `${r.relType || r[0]}:${r.uid || r[1]}`),
|
||||
);
|
||||
for (const r of [...ctorIncoming, ...fileIncoming, ...typedPropertyIncoming]) {
|
||||
for (const r of [...ctorIncoming, ...fileIncoming]) {
|
||||
const key = `${r.relType || r[0]}:${r.uid || r[1]}`;
|
||||
if (!seenKeys.has(key)) {
|
||||
seenKeys.add(key);
|
||||
@@ -1884,7 +1845,7 @@ export class LocalBackend {
|
||||
repo.id,
|
||||
`
|
||||
MATCH (n {id: $symId})-[r:CodeRelation]->(target)
|
||||
WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'USES', 'HAS_METHOD', 'HAS_PROPERTY', 'METHOD_OVERRIDES', 'OVERRIDES', 'METHOD_IMPLEMENTS', 'ACCESSES']
|
||||
WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'HAS_METHOD', 'HAS_PROPERTY', 'METHOD_OVERRIDES', 'OVERRIDES', 'METHOD_IMPLEMENTS', 'ACCESSES']
|
||||
RETURN r.type AS relType, target.id AS uid, target.name AS name, target.filePath AS filePath, labels(target)[0] AS kind
|
||||
LIMIT 30
|
||||
`,
|
||||
@@ -1973,17 +1934,6 @@ export class LocalBackend {
|
||||
},
|
||||
incoming: categorize(incomingRows),
|
||||
outgoing: categorize(outgoingRows),
|
||||
...(typedPropertyRows.length > 0
|
||||
? {
|
||||
typed_properties: typedPropertyRows.map((r: any) => ({
|
||||
uid: r.uid || r[0],
|
||||
name: r.name || r[1],
|
||||
filePath: r.filePath || r[2],
|
||||
kind: r.kind || r[3],
|
||||
declaredType: r.declaredType || r[4],
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
processes: processRows.map((r: any) => ({
|
||||
id: r.pid || r[0],
|
||||
name: r.label || r[1],
|
||||
@@ -2549,7 +2499,6 @@ export class LocalBackend {
|
||||
const mappedRelTypes = params.relationTypes?.flatMap((t: string) =>
|
||||
t === 'OVERRIDES' ? ['OVERRIDES', 'METHOD_OVERRIDES'] : [t],
|
||||
);
|
||||
const hasExplicitRelationTypes = mappedRelTypes !== undefined && mappedRelTypes.length > 0;
|
||||
const rawRelTypes =
|
||||
mappedRelTypes && mappedRelTypes.length > 0
|
||||
? mappedRelTypes.filter((t: string) => VALID_RELATION_TYPES.has(t))
|
||||
@@ -2558,7 +2507,6 @@ export class LocalBackend {
|
||||
'IMPORTS',
|
||||
'EXTENDS',
|
||||
'IMPLEMENTS',
|
||||
'USES',
|
||||
'METHOD_OVERRIDES',
|
||||
'OVERRIDES',
|
||||
'METHOD_IMPLEMENTS',
|
||||
@@ -2571,7 +2519,6 @@ export class LocalBackend {
|
||||
'IMPORTS',
|
||||
'EXTENDS',
|
||||
'IMPLEMENTS',
|
||||
'USES',
|
||||
'METHOD_OVERRIDES',
|
||||
'OVERRIDES',
|
||||
'METHOD_IMPLEMENTS',
|
||||
@@ -2631,16 +2578,9 @@ export class LocalBackend {
|
||||
};
|
||||
const symType = outcome.resolvedLabel || outcome.symbol.type || '';
|
||||
|
||||
const effectiveRelationTypes =
|
||||
(symType === 'Class' || symType === 'Interface') &&
|
||||
!hasExplicitRelationTypes &&
|
||||
!relationTypes.includes('ACCESSES')
|
||||
? [...relationTypes, 'ACCESSES']
|
||||
: relationTypes;
|
||||
|
||||
return this._runImpactBFS(repo, sym, symType, direction, {
|
||||
maxDepth,
|
||||
relationTypes: effectiveRelationTypes,
|
||||
relationTypes,
|
||||
includeTests,
|
||||
minConfidence,
|
||||
});
|
||||
@@ -2719,30 +2659,6 @@ export class LocalBackend {
|
||||
frontier.push(rid);
|
||||
}
|
||||
}
|
||||
|
||||
const typedPropertyRows = await executeParameterized(
|
||||
repo.id,
|
||||
`
|
||||
MATCH (p:\`Property\`)
|
||||
WHERE p.declaredType = $name
|
||||
OR p.declaredType STARTS WITH $genericPrefix
|
||||
OR p.declaredType CONTAINS $genericArg
|
||||
RETURN p.id AS id, p.name AS name, labels(p)[0] AS type, p.filePath AS filePath
|
||||
`,
|
||||
{
|
||||
name: sym.name,
|
||||
genericPrefix: `${sym.name}<`,
|
||||
genericArg: `<${sym.name}>`,
|
||||
},
|
||||
);
|
||||
|
||||
for (const r of typedPropertyRows) {
|
||||
const rid = r.id || r[0];
|
||||
if (rid && !visited.has(rid)) {
|
||||
visited.add(rid);
|
||||
frontier.push(rid);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logQueryError('impact:class-node-expansion', e);
|
||||
}
|
||||
|
||||
+10
-28
@@ -744,13 +744,8 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
||||
|
||||
if (isMatch && ['queued', 'cloning', 'analyzing'].includes(job.status)) {
|
||||
if (process.env.DEBUG) {
|
||||
// Sanitize user-controlled values to prevent log injection (CodeQL js/log-injection).
|
||||
logger.debug(
|
||||
{
|
||||
jobId: String(job.id).replace(/[\r\n]/g, ' '),
|
||||
repoName: String(normalizedName).replace(/[\r\n]/g, ' '),
|
||||
},
|
||||
'[debug] resolveRepo waiting for active job',
|
||||
console.log(
|
||||
`[debug] resolveRepo waiting for active job ${job.id} (${normalizedName})...`,
|
||||
);
|
||||
}
|
||||
for (let wait = 0; wait < HOLD_QUEUE_TIMEOUT_SECS; wait++) {
|
||||
@@ -774,11 +769,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
||||
// (e.g. registry file not yet flushed after clone completes).
|
||||
if (!found && normalizedName && !isRetry) {
|
||||
if (process.env.DEBUG) {
|
||||
// Sanitize user-controlled values to prevent log injection (CodeQL js/log-injection).
|
||||
logger.debug(
|
||||
{ repoName: String(normalizedName).replace(/[\r\n]/g, ' ') },
|
||||
'[debug] resolveRepo 404, triggering deep init',
|
||||
);
|
||||
console.log(`[debug] resolveRepo 404 for "${normalizedName}". Triggering deep init...`);
|
||||
}
|
||||
await backend.init();
|
||||
return await resolveRepo(normalizedName, true, req);
|
||||
@@ -1069,12 +1060,11 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
||||
|
||||
const results = await withLbugDb(lbugPath, async () => {
|
||||
let searchResults: any[];
|
||||
let ftsAvailable: boolean | undefined;
|
||||
|
||||
if (mode === 'semantic') {
|
||||
const { isEmbedderReady } = await import('../core/embeddings/embedder.js');
|
||||
if (!isEmbedderReady()) {
|
||||
return { searchResults: [] as any[], ftsAvailable: undefined };
|
||||
return [] as any[];
|
||||
}
|
||||
const { semanticSearch: semSearch } =
|
||||
await import('../core/embeddings/embedding-pipeline.js');
|
||||
@@ -1087,9 +1077,8 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
||||
sources: ['semantic'],
|
||||
}));
|
||||
} else if (mode === 'bm25') {
|
||||
const ftsResponse = await searchFTSFromLbug(query, limit);
|
||||
ftsAvailable = ftsResponse.ftsAvailable;
|
||||
searchResults = ftsResponse.results.map((r: any, i: number) => ({
|
||||
searchResults = await searchFTSFromLbug(query, limit);
|
||||
searchResults = searchResults.map((r: any, i: number) => ({
|
||||
...r,
|
||||
rank: i + 1,
|
||||
sources: ['bm25'],
|
||||
@@ -1102,13 +1091,11 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
||||
await import('../core/embeddings/embedding-pipeline.js');
|
||||
searchResults = await hybridSearch(query, limit, executeQuery, semSearch);
|
||||
} else {
|
||||
const ftsResponse = await searchFTSFromLbug(query, limit);
|
||||
ftsAvailable = ftsResponse.ftsAvailable;
|
||||
searchResults = ftsResponse.results;
|
||||
searchResults = await searchFTSFromLbug(query, limit);
|
||||
}
|
||||
}
|
||||
|
||||
if (!enrich) return { searchResults, ftsAvailable };
|
||||
if (!enrich) return searchResults;
|
||||
|
||||
// Server-side enrichment: add connections, cluster, processes per result
|
||||
// Uses parameterized queries to prevent Cypher injection via nodeId
|
||||
@@ -1190,14 +1177,9 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
||||
}),
|
||||
);
|
||||
|
||||
return { searchResults: enriched, ftsAvailable };
|
||||
return enriched;
|
||||
});
|
||||
const response: any = { results: results.searchResults ?? results };
|
||||
if (results.ftsAvailable === false) {
|
||||
response.warning =
|
||||
'FTS indexes missing — keyword search degraded. Run: gitnexus analyze --force to rebuild indexes.';
|
||||
}
|
||||
res.json(response);
|
||||
res.json({ results });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message || 'Search failed' });
|
||||
}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
namespace App;
|
||||
|
||||
public class USER_INFO
|
||||
{
|
||||
public string? USER_ID { get; set; }
|
||||
}
|
||||
|
||||
public interface IEntityTypeConfiguration<T>
|
||||
{
|
||||
}
|
||||
|
||||
public class UserInfoConfiguration : IEntityTypeConfiguration<USER_INFO>
|
||||
{
|
||||
public Task<List<USER_INFO>> Load(List<USER_INFO> users)
|
||||
{
|
||||
return Task.FromResult(users);
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
/**
|
||||
* Integration test: context() expands Class symbols through typed properties.
|
||||
*
|
||||
* Reproduces EF-style usage where code reads a DbContext property
|
||||
* (`db.USER_INFO`) whose source type is `DbSet<USER_INFO>`. The direct
|
||||
* graph edge is Method -> Property, not Method -> Class, so context() must
|
||||
* use the same typed-property bridge that impact() uses.
|
||||
*/
|
||||
import { beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { LocalBackend } from '../../src/mcp/local/local-backend.js';
|
||||
import { listRegisteredRepos } from '../../src/storage/repo-manager.js';
|
||||
import { withTestLbugDB } from '../helpers/test-indexed-db.js';
|
||||
|
||||
vi.mock('../../src/storage/repo-manager.js', () => ({
|
||||
listRegisteredRepos: vi.fn().mockResolvedValue([]),
|
||||
cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }),
|
||||
findSiblingClones: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
const SEED = [
|
||||
`CREATE (c:Class {id:'Class:Models/USER_INFO.cs:USER_INFO', name:'USER_INFO', filePath:'Models/USER_INFO.cs', startLine:1, endLine:5, content:'public class USER_INFO {}', description:''})`,
|
||||
`CREATE (p:\`Property\` {id:'Property:Data/UserDbContext.cs:UserDbContext.USER_INFO', name:'USER_INFO', filePath:'Data/UserDbContext.cs', startLine:10, endLine:10, content:'public DbSet<USER_INFO> USER_INFO { get; set; }', description:'', declaredType:'DbSet<USER_INFO>'})`,
|
||||
`CREATE (m:Method {id:'Method:Services/UserService.cs:UserService.GetUserInfo#1', name:'GetUserInfo', filePath:'Services/UserService.cs', startLine:20, endLine:30, isExported:false, content:'db.USER_INFO.FirstOrDefault();', description:'', parameterCount:1, returnType:'USER_INFO'})`,
|
||||
`MATCH (m:Method {id:'Method:Services/UserService.cs:UserService.GetUserInfo#1'}), (p:\`Property\` {id:'Property:Data/UserDbContext.cs:UserDbContext.USER_INFO'}) CREATE (m)-[:CodeRelation {type:'ACCESSES', confidence:1.0, reason:'read', step:1}]->(p)`,
|
||||
];
|
||||
|
||||
withTestLbugDB(
|
||||
'context-typed-property',
|
||||
(handle) => {
|
||||
let backend: LocalBackend;
|
||||
|
||||
beforeAll(async () => {
|
||||
backend = (handle as any)._backend;
|
||||
});
|
||||
|
||||
describe('context() typed-property expansion', () => {
|
||||
it('surfaces property callers and explains the typed property bridge', async () => {
|
||||
const result = await backend.callTool('context', {
|
||||
uid: 'Class:Models/USER_INFO.cs:USER_INFO',
|
||||
});
|
||||
|
||||
expect(result.status).toBe('found');
|
||||
expect(result.symbol.kind).toBe('Class');
|
||||
|
||||
const accesses = result.incoming.accesses || [];
|
||||
expect(accesses.map((r: any) => r.name)).toContain('GetUserInfo');
|
||||
|
||||
expect(result.typed_properties).toEqual([
|
||||
expect.objectContaining({
|
||||
uid: 'Property:Data/UserDbContext.cs:UserDbContext.USER_INFO',
|
||||
name: 'USER_INFO',
|
||||
declaredType: 'DbSet<USER_INFO>',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
},
|
||||
{
|
||||
seed: SEED,
|
||||
poolAdapter: true,
|
||||
afterSetup: async (handle) => {
|
||||
vi.mocked(listRegisteredRepos).mockResolvedValue([
|
||||
{
|
||||
name: 'test-repo',
|
||||
path: '/test/repo',
|
||||
storagePath: handle.tmpHandle.dbPath,
|
||||
indexedAt: new Date().toISOString(),
|
||||
lastCommit: 'abc123',
|
||||
stats: { files: 3, nodes: 3, communities: 0, processes: 0 },
|
||||
},
|
||||
]);
|
||||
const backend = new LocalBackend();
|
||||
await backend.init();
|
||||
(handle as any)._backend = backend;
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -1440,24 +1440,6 @@ describe('Write access tracking (C#)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generic type references: IEntityTypeConfiguration<USER_INFO>, List<USER_INFO>
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('C# generic type-reference tracking', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(path.join(FIXTURES, 'csharp-generic-type-refs'), () => {});
|
||||
}, 60000);
|
||||
|
||||
it('emits USES edges for generic type arguments', () => {
|
||||
const uses = getRelationships(result, 'USES').filter((e) => e.target === 'USER_INFO');
|
||||
expect(edgeSet(uses)).toContain('UserInfoConfiguration → USER_INFO');
|
||||
expect(edgeSet(uses)).toContain('Load → USER_INFO');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Call-result variable binding (Phase 9): var user = GetUser(); user.Save()
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -11,9 +11,6 @@ import type { GraphRelationship } from 'gitnexus-shared';
|
||||
const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly<Record<string, ReadonlySet<string>>> = {
|
||||
csharp: new Set([
|
||||
'emits the using-import edge App/Program.cs -> Models/User.cs through the scope-resolution path',
|
||||
// Generic type-argument USES edges are emitted by the registry-primary
|
||||
// resolver only; the legacy DAG path does not synthesize these references.
|
||||
'emits USES edges for generic type arguments',
|
||||
]),
|
||||
go: new Set([
|
||||
// The legacy DAG path does not resolve method calls when the method is
|
||||
|
||||
@@ -19,7 +19,7 @@ withTestLbugDB(
|
||||
(_handle) => {
|
||||
describe('searchFTSFromLbug — core adapter (no repoId)', () => {
|
||||
it('returns ranked results for a matching query', async () => {
|
||||
const { results } = await searchFTSFromLbug('user authentication', 10);
|
||||
const results = await searchFTSFromLbug('user authentication', 10);
|
||||
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
|
||||
@@ -40,7 +40,7 @@ withTestLbugDB(
|
||||
});
|
||||
|
||||
it('results are ordered by descending score', async () => {
|
||||
const { results } = await searchFTSFromLbug('user authentication', 10);
|
||||
const results = await searchFTSFromLbug('user authentication', 10);
|
||||
|
||||
for (let i = 1; i < results.length; i++) {
|
||||
expect(results[i - 1].score).toBeGreaterThanOrEqual(results[i].score);
|
||||
@@ -48,7 +48,7 @@ withTestLbugDB(
|
||||
});
|
||||
|
||||
it('auth-related files rank higher than unrelated files', async () => {
|
||||
const { results } = await searchFTSFromLbug('user authentication', 10);
|
||||
const results = await searchFTSFromLbug('user authentication', 10);
|
||||
const filePaths = results.map((r) => r.filePath);
|
||||
|
||||
expect(filePaths).toContain('src/auth.ts');
|
||||
@@ -61,7 +61,7 @@ withTestLbugDB(
|
||||
});
|
||||
|
||||
it('merges scores from multiple node types for the same filePath', async () => {
|
||||
const { results } = await searchFTSFromLbug('user authentication', 20);
|
||||
const results = await searchFTSFromLbug('user authentication', 20);
|
||||
|
||||
const authResult = results.find((r) => r.filePath === 'src/auth.ts');
|
||||
expect(authResult).toBeDefined();
|
||||
@@ -73,12 +73,12 @@ withTestLbugDB(
|
||||
});
|
||||
|
||||
it('respects limit parameter', async () => {
|
||||
const { results } = await searchFTSFromLbug('user authentication', 2);
|
||||
const results = await searchFTSFromLbug('user authentication', 2);
|
||||
expect(results.length).toBeLessThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('returns empty array for a non-matching query', async () => {
|
||||
const { results } = await searchFTSFromLbug('xyzzyplughtwisty', 10);
|
||||
const results = await searchFTSFromLbug('xyzzyplughtwisty', 10);
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -87,32 +87,32 @@ withTestLbugDB(
|
||||
|
||||
describe('unhappy paths', () => {
|
||||
it('returns empty array for empty query string', async () => {
|
||||
const { results } = await searchFTSFromLbug('', 10);
|
||||
const results = await searchFTSFromLbug('', 10);
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array for whitespace-only query', async () => {
|
||||
const { results } = await searchFTSFromLbug(' ', 10);
|
||||
const results = await searchFTSFromLbug(' ', 10);
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles special characters in query gracefully', async () => {
|
||||
const { results } = await searchFTSFromLbug('user* OR auth+', 10);
|
||||
const results = await searchFTSFromLbug('user* OR auth+', 10);
|
||||
expect(Array.isArray(results)).toBe(true);
|
||||
});
|
||||
|
||||
it('handles limit of 0', async () => {
|
||||
const { results } = await searchFTSFromLbug('user authentication', 0);
|
||||
const results = await searchFTSFromLbug('user authentication', 0);
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles negative limit gracefully', async () => {
|
||||
const { results } = await searchFTSFromLbug('user authentication', -1);
|
||||
const results = await searchFTSFromLbug('user authentication', -1);
|
||||
expect(Array.isArray(results)).toBe(true);
|
||||
});
|
||||
|
||||
it('handles very large limit', async () => {
|
||||
const { results } = await searchFTSFromLbug('user authentication', 100000);
|
||||
const results = await searchFTSFromLbug('user authentication', 100000);
|
||||
expect(results.length).toBeLessThanOrEqual(100000);
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
@@ -19,7 +19,7 @@ withTestLbugDB(
|
||||
(handle) => {
|
||||
describe('searchFTSFromLbug — MCP pool adapter (with repoId)', () => {
|
||||
it('returns ranked results via pool adapter', async () => {
|
||||
const { results } = await searchFTSFromLbug('user authentication', 10, handle.repoId);
|
||||
const results = await searchFTSFromLbug('user authentication', 10, handle.repoId);
|
||||
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
|
||||
@@ -35,7 +35,7 @@ withTestLbugDB(
|
||||
});
|
||||
|
||||
it('results are ordered by descending score via pool adapter', async () => {
|
||||
const { results } = await searchFTSFromLbug('user authentication', 10, handle.repoId);
|
||||
const results = await searchFTSFromLbug('user authentication', 10, handle.repoId);
|
||||
|
||||
for (let i = 1; i < results.length; i++) {
|
||||
expect(results[i - 1].score).toBeGreaterThanOrEqual(results[i].score);
|
||||
@@ -43,12 +43,12 @@ withTestLbugDB(
|
||||
});
|
||||
|
||||
it('returns empty array for non-matching query via pool adapter', async () => {
|
||||
const { results } = await searchFTSFromLbug('xyzzyplughtwisty', 10, handle.repoId);
|
||||
const results = await searchFTSFromLbug('xyzzyplughtwisty', 10, handle.repoId);
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it('respects limit parameter via pool adapter', async () => {
|
||||
const { results } = await searchFTSFromLbug('user authentication', 1, handle.repoId);
|
||||
const results = await searchFTSFromLbug('user authentication', 1, handle.repoId);
|
||||
expect(results.length).toBeLessThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
@@ -57,22 +57,22 @@ withTestLbugDB(
|
||||
|
||||
describe('unhappy paths', () => {
|
||||
it('returns empty array for empty query via pool', async () => {
|
||||
const { results } = await searchFTSFromLbug('', 10, handle.repoId);
|
||||
const results = await searchFTSFromLbug('', 10, handle.repoId);
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array for whitespace-only query via pool', async () => {
|
||||
const { results } = await searchFTSFromLbug(' ', 10, handle.repoId);
|
||||
const results = await searchFTSFromLbug(' ', 10, handle.repoId);
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles special characters in query via pool', async () => {
|
||||
const { results } = await searchFTSFromLbug('user* OR auth+', 10, handle.repoId);
|
||||
const results = await searchFTSFromLbug('user* OR auth+', 10, handle.repoId);
|
||||
expect(Array.isArray(results)).toBe(true);
|
||||
});
|
||||
|
||||
it('handles limit of 0 via pool', async () => {
|
||||
const { results } = await searchFTSFromLbug('user authentication', 0, handle.repoId);
|
||||
const results = await searchFTSFromLbug('user authentication', 0, handle.repoId);
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,24 +42,20 @@ describe('BM25 search', () => {
|
||||
});
|
||||
|
||||
describe('searchFTSFromLbug', () => {
|
||||
it('returns empty results when LadybugDB is not initialized', async () => {
|
||||
// Simulate an uninitialized DB: queryFTS throws instead of returning rows
|
||||
const { queryFTS } = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
vi.mocked(queryFTS).mockRejectedValue(new Error('DB not initialized'));
|
||||
|
||||
const { results, ftsAvailable } = await searchFTSFromLbug('test query');
|
||||
it('returns empty array when LadybugDB is not initialized', async () => {
|
||||
// Without LadybugDB init, search should return empty (not crash)
|
||||
const results = await searchFTSFromLbug('test query');
|
||||
expect(Array.isArray(results)).toBe(true);
|
||||
expect(results).toHaveLength(0);
|
||||
expect(ftsAvailable).toBe(false);
|
||||
});
|
||||
|
||||
it('handles empty query', async () => {
|
||||
const { results } = await searchFTSFromLbug('');
|
||||
const results = await searchFTSFromLbug('');
|
||||
expect(Array.isArray(results)).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts custom limit parameter', async () => {
|
||||
const { results } = await searchFTSFromLbug('test', 5);
|
||||
const results = await searchFTSFromLbug('test', 5);
|
||||
expect(Array.isArray(results)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -109,7 +105,7 @@ describe('BM25 search', () => {
|
||||
.mockResolvedValueOnce([]) // Method
|
||||
.mockResolvedValueOnce([]); // Interface
|
||||
|
||||
const { results } = await searchFTSFromLbug('queryset');
|
||||
const results = await searchFTSFromLbug('queryset');
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].filePath).toBe('src/views.py');
|
||||
@@ -131,7 +127,7 @@ describe('BM25 search', () => {
|
||||
.mockResolvedValueOnce([]) // Method
|
||||
.mockResolvedValueOnce([]); // Interface
|
||||
|
||||
const { results } = await searchFTSFromLbug('model');
|
||||
const results = await searchFTSFromLbug('model');
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].score).toBe(8); // 5+3
|
||||
@@ -151,7 +147,7 @@ describe('BM25 search', () => {
|
||||
.mockResolvedValueOnce([]) // Method
|
||||
.mockResolvedValueOnce([]); // Interface
|
||||
|
||||
const { results } = await searchFTSFromLbug('util');
|
||||
const results = await searchFTSFromLbug('util');
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].nodeIds).toEqual([]);
|
||||
@@ -175,7 +171,7 @@ describe('BM25 search', () => {
|
||||
.mockResolvedValueOnce([]) // Method
|
||||
.mockResolvedValueOnce([]); // Interface
|
||||
|
||||
const { results } = await searchFTSFromLbug('auth');
|
||||
const results = await searchFTSFromLbug('auth');
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
// All 3 hits (scores 9+7+4=20) — each from a different table, all top-3
|
||||
@@ -196,7 +192,7 @@ describe('BM25 search', () => {
|
||||
.mockResolvedValueOnce([]) // Method
|
||||
.mockResolvedValueOnce([]); // Interface
|
||||
|
||||
const { results } = await searchFTSFromLbug('fn');
|
||||
const results = await searchFTSFromLbug('fn');
|
||||
|
||||
expect(results[0].filePath).toBe('src/high.py');
|
||||
expect(results[1].filePath).toBe('src/low.py');
|
||||
@@ -224,7 +220,7 @@ describe('BM25 search', () => {
|
||||
return [];
|
||||
});
|
||||
|
||||
const { results } = await searchFTSFromLbug('login', 5, REPO);
|
||||
const results = await searchFTSFromLbug('login', 5, REPO);
|
||||
|
||||
expect(results).toEqual([
|
||||
{ filePath: 'src/auth.ts', score: 8, rank: 1, nodeIds: ['func:login'] },
|
||||
|
||||
@@ -62,7 +62,7 @@ vi.mock('../../src/core/platform/capabilities.js', async (importOriginal) => {
|
||||
|
||||
// Also mock the search modules to avoid loading onnxruntime
|
||||
vi.mock('../../src/core/search/bm25-index.js', () => ({
|
||||
searchFTSFromLbug: vi.fn().mockResolvedValue({ results: [], ftsAvailable: true }),
|
||||
searchFTSFromLbug: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/mcp/core/embedder.js', () => ({
|
||||
@@ -195,27 +195,6 @@ describe('LocalBackend.callTool', () => {
|
||||
expect(result).toHaveProperty('definitions');
|
||||
});
|
||||
|
||||
it('includes FTS-unavailable warning when ftsAvailable is false (#1403)', async () => {
|
||||
const { searchFTSFromLbug } = await import('../../src/core/search/bm25-index.js');
|
||||
vi.mocked(searchFTSFromLbug).mockResolvedValueOnce({ results: [], ftsAvailable: false });
|
||||
(executeParameterized as any).mockResolvedValue([]);
|
||||
|
||||
const result = await backend.callTool('query', { query: 'ProcessActivity' });
|
||||
|
||||
expect(result).toHaveProperty('warning');
|
||||
expect((result as any).warning).toMatch(/gitnexus analyze --force/);
|
||||
});
|
||||
|
||||
it('does not include warning when ftsAvailable is true with zero results', async () => {
|
||||
const { searchFTSFromLbug } = await import('../../src/core/search/bm25-index.js');
|
||||
vi.mocked(searchFTSFromLbug).mockResolvedValueOnce({ results: [], ftsAvailable: true });
|
||||
(executeParameterized as any).mockResolvedValue([]);
|
||||
|
||||
const result = await backend.callTool('query', { query: 'nonexistent' });
|
||||
|
||||
expect(result).not.toHaveProperty('warning');
|
||||
});
|
||||
|
||||
it('skips vector index query when VECTOR is unsupported by the platform', async () => {
|
||||
const cap = _captureLogger();
|
||||
platformMocks.isVectorExtensionSupportedByPlatform.mockReturnValue(false);
|
||||
|
||||
@@ -10,9 +10,6 @@ vi.mock('../../src/cli/mcp.js', () => ({
|
||||
vi.mock('../../src/cli/setup.js', () => ({
|
||||
setupCommand: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../src/cli/publish.js', () => ({
|
||||
publishCommand: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('CLI commands', () => {
|
||||
describe('version', () => {
|
||||
@@ -87,11 +84,4 @@ describe('CLI commands', () => {
|
||||
expect(typeof setupCommand).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('publishCommand', () => {
|
||||
it('is a function', async () => {
|
||||
const { publishCommand } = await import('../../src/cli/publish.js');
|
||||
expect(typeof publishCommand).toBe('function');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,17 +63,4 @@ describe('CLI help surface', () => {
|
||||
expect(result.stdout).toContain('--model <model>');
|
||||
expect(result.stdout).toContain('--gist');
|
||||
});
|
||||
|
||||
it('publish help names the registry, the token env var, and the opt-out behaviour', () => {
|
||||
const result = runHelp('publish');
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toContain('--id <owner/repo>');
|
||||
expect(result.stdout).toContain('--skip-git');
|
||||
// Discoverability contract: a contributor scanning `--help` must see
|
||||
// (a) which registry this dispatches to, and (b) the env var that
|
||||
// gates the opt-in. Both are part of the no-token contract.
|
||||
expect(result.stdout).toContain('understand-quickly');
|
||||
expect(result.stdout).toContain('UNDERSTAND_QUICKLY_TOKEN');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,272 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import Parser from 'tree-sitter';
|
||||
import CPP from 'tree-sitter-cpp';
|
||||
import { stripUeMacros } from '../../src/core/ingestion/cpp-ue-preprocessor.js';
|
||||
|
||||
describe('stripUeMacros — detection guard', () => {
|
||||
it('returns input unchanged when no UE markers are present', () => {
|
||||
const src = `class Plain {\npublic:\n int Get() const;\n};`;
|
||||
expect(stripUeMacros(src)).toBe(src);
|
||||
});
|
||||
|
||||
it('returns input unchanged for STL-style code', () => {
|
||||
const src = `#include <vector>\nstd::vector<int> v;`;
|
||||
expect(stripUeMacros(src)).toBe(src);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripUeMacros — length preservation', () => {
|
||||
const ueSamples: string[] = [
|
||||
`UCLASS()\nclass BRAWLUI_API UMyClass : public UObject { GENERATED_BODY() public: UFUNCTION() void Run(); };`,
|
||||
`UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Combat") int32 Health;`,
|
||||
`USTRUCT(BlueprintType)\nstruct ENGINE_API FMyData { GENERATED_BODY() float Value; };`,
|
||||
`DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FMyDelegate, int32, A, FString, B);`,
|
||||
`UE_DEPRECATED(5.0, "Use NewThing instead") void OldThing();`,
|
||||
];
|
||||
|
||||
for (const src of ueSamples) {
|
||||
it(`preserves byte length: ${src.slice(0, 40).replace(/\n/g, '\\n')}…`, () => {
|
||||
const out = stripUeMacros(src);
|
||||
expect(out.length).toBe(src.length);
|
||||
});
|
||||
|
||||
it(`preserves newline positions: ${src.slice(0, 40).replace(/\n/g, '\\n')}…`, () => {
|
||||
const out = stripUeMacros(src);
|
||||
const inputNewlines: number[] = [];
|
||||
const outputNewlines: number[] = [];
|
||||
for (let i = 0; i < src.length; i++) {
|
||||
if (src.charCodeAt(i) === 0x0a) inputNewlines.push(i);
|
||||
if (out.charCodeAt(i) === 0x0a) outputNewlines.push(i);
|
||||
}
|
||||
expect(outputNewlines).toEqual(inputNewlines);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('stripUeMacros — macro removal', () => {
|
||||
it('elides UCLASS(...) with arguments', () => {
|
||||
const src = `UCLASS(BlueprintType, Category="Foo")\nclass UFoo {};`;
|
||||
const out = stripUeMacros(src);
|
||||
expect(out).not.toContain('UCLASS');
|
||||
expect(out).not.toContain('BlueprintType');
|
||||
expect(out).toContain('class UFoo {};');
|
||||
});
|
||||
|
||||
it('elides UCLASS() with empty parens', () => {
|
||||
const src = `UCLASS()\nclass UBar {};`;
|
||||
const out = stripUeMacros(src);
|
||||
expect(out).not.toContain('UCLASS');
|
||||
expect(out).toContain('class UBar {};');
|
||||
});
|
||||
|
||||
it('elides MODULE_API export macros (BRAWLUI_API style) when paired with a UE marker', () => {
|
||||
const src = `UCLASS()\nclass BRAWLUI_API UMyClass : public UObject {};`;
|
||||
const out = stripUeMacros(src);
|
||||
expect(out).not.toContain('BRAWLUI_API');
|
||||
expect(out).toContain('class');
|
||||
expect(out).toContain('UMyClass');
|
||||
expect(out).toContain('public UObject');
|
||||
});
|
||||
|
||||
it('elides multiple distinct *_API tokens in same file when UE marker is present', () => {
|
||||
const src = `UCLASS()\nclass CORE_API A {};\nUCLASS()\nclass UMG_API B : public A {};`;
|
||||
const out = stripUeMacros(src);
|
||||
expect(out).not.toContain('CORE_API');
|
||||
expect(out).not.toContain('UMG_API');
|
||||
expect(out).toContain('class');
|
||||
expect(out).toContain('A {};');
|
||||
});
|
||||
|
||||
it('elides GENERATED_BODY() inside class body', () => {
|
||||
const src = `class UThing { GENERATED_BODY() public: void Foo(); };`;
|
||||
const out = stripUeMacros(src);
|
||||
expect(out).not.toContain('GENERATED_BODY');
|
||||
expect(out).toContain('public:');
|
||||
expect(out).toContain('void Foo();');
|
||||
});
|
||||
|
||||
it('elides UFUNCTION(...) before method declarations', () => {
|
||||
const src = `class X { UFUNCTION(BlueprintCallable, Server, Reliable) void DoThing(); };`;
|
||||
const out = stripUeMacros(src);
|
||||
expect(out).not.toContain('UFUNCTION');
|
||||
expect(out).not.toContain('BlueprintCallable');
|
||||
expect(out).toContain('void DoThing();');
|
||||
});
|
||||
|
||||
it('elides UPROPERTY(...) before field declarations', () => {
|
||||
const src = `class X { UPROPERTY(EditAnywhere) int32 Health; };`;
|
||||
const out = stripUeMacros(src);
|
||||
expect(out).not.toContain('UPROPERTY');
|
||||
expect(out).not.toContain('EditAnywhere');
|
||||
expect(out).toContain('int32 Health;');
|
||||
});
|
||||
|
||||
it('elides DECLARE_DYNAMIC_MULTICAST_DELEGATE_*Params(...)', () => {
|
||||
const src = `DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FMyDelegate, int32, Value);\nclass X {};`;
|
||||
const out = stripUeMacros(src);
|
||||
expect(out).not.toContain('DECLARE_DYNAMIC_MULTICAST_DELEGATE');
|
||||
expect(out).not.toContain('FMyDelegate');
|
||||
expect(out).toContain('class X {};');
|
||||
});
|
||||
|
||||
it('elides UE_DEPRECATED(...) before function declarations', () => {
|
||||
const src = `UE_DEPRECATED(5.1, "Reason") void Old();`;
|
||||
const out = stripUeMacros(src);
|
||||
expect(out).not.toContain('UE_DEPRECATED');
|
||||
expect(out).not.toContain('5.1');
|
||||
expect(out).toContain('void Old();');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripUeMacros — non-UE files left alone', () => {
|
||||
it('does NOT strip standalone *_API identifiers when no UE marker is present', () => {
|
||||
const src = `enum class Status { REST_API = 1, HTTP_API = 2, MY_LIB_API = 3 };\nvoid handle(REST_API status);`;
|
||||
expect(stripUeMacros(src)).toBe(src);
|
||||
});
|
||||
|
||||
it('does NOT strip _API tokens in a file that only mentions DECLARE_DELEGATE-like macros from non-UE codebases', () => {
|
||||
const src = `// Custom delegate framework, not UE\n#define DECLARE_HANDLER(x) void x()\nDECLARE_HANDLER(MyHandler);\nint REST_API = 0;`;
|
||||
expect(stripUeMacros(src)).toBe(src);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripUeMacros — non-ASCII content preservation', () => {
|
||||
it('leaves non-ASCII content outside elided ranges intact and at the same .length offset', () => {
|
||||
const src = `// Comment with non-ASCII: café résumé naïve\nUCLASS()\nclass UMyClass : public UObject\n{\n GENERATED_BODY()\n // Trailing: 日本語 αβγ\n};`;
|
||||
const out = stripUeMacros(src);
|
||||
expect(out.length).toBe(src.length);
|
||||
expect(out).toContain('café résumé naïve');
|
||||
expect(out).toContain('日本語 αβγ');
|
||||
expect(out).toContain('class UMyClass : public UObject');
|
||||
expect(out).not.toContain('UCLASS');
|
||||
expect(out).not.toContain('GENERATED_BODY');
|
||||
});
|
||||
|
||||
it('preserves newline positions when the file contains non-ASCII characters', () => {
|
||||
const src = `// café\nUPROPERTY()\nint32 Health;\n// résumé\nUFUNCTION()\nvoid Run();`;
|
||||
const out = stripUeMacros(src);
|
||||
const inputNewlines: number[] = [];
|
||||
const outputNewlines: number[] = [];
|
||||
for (let i = 0; i < src.length; i++) {
|
||||
if (src.charCodeAt(i) === 0x0a) inputNewlines.push(i);
|
||||
if (out.charCodeAt(i) === 0x0a) outputNewlines.push(i);
|
||||
}
|
||||
expect(outputNewlines).toEqual(inputNewlines);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripUeMacros — false-positive guards', () => {
|
||||
it('does NOT strip identifiers that merely contain UCLASS as a substring', () => {
|
||||
const src = `void NotUCLASSAtAll(); int MyUCLASS = 0;`;
|
||||
const out = stripUeMacros(src);
|
||||
expect(out).toBe(src);
|
||||
});
|
||||
|
||||
it('does NOT strip _API substrings inside larger identifiers', () => {
|
||||
const src = `class MY_APIName {};\nint not_my_API_thing = 0;`;
|
||||
const out = stripUeMacros(src);
|
||||
expect(out).toContain('MY_APIName');
|
||||
expect(out).toContain('not_my_API_thing');
|
||||
});
|
||||
|
||||
it('does not eat parens balanced inside string literals', () => {
|
||||
const src = `UFUNCTION(meta=(DisplayName="Foo (Bar)")) void Z();`;
|
||||
const out = stripUeMacros(src);
|
||||
expect(out).not.toContain('UFUNCTION');
|
||||
expect(out).not.toContain('DisplayName');
|
||||
expect(out).toContain('void Z();');
|
||||
});
|
||||
|
||||
it('handles UCLASS with deeply nested parens in arguments', () => {
|
||||
const src = `UCLASS(meta=(Categories=("A.B", "C.D")), Within=Foo) class UDeep {};`;
|
||||
const out = stripUeMacros(src);
|
||||
expect(out).not.toContain('UCLASS');
|
||||
expect(out).not.toContain('Categories');
|
||||
expect(out).toContain('class UDeep {};');
|
||||
});
|
||||
|
||||
it('leaves Qt macros alone (only UE markers stripped)', () => {
|
||||
const src = `class QFoo { Q_OBJECT public: void Bar(); };`;
|
||||
const out = stripUeMacros(src);
|
||||
expect(out).toContain('Q_OBJECT');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripUeMacros — class-name extraction sanity', () => {
|
||||
it('after stripping, "class UMyClass" appears immediately after "class "', () => {
|
||||
const src = `UCLASS(BlueprintType)\nclass BRAWLUI_API UMyClass : public UObject\n{\n GENERATED_BODY()\n};`;
|
||||
const out = stripUeMacros(src);
|
||||
const classIdx = out.indexOf('class ');
|
||||
expect(classIdx).toBeGreaterThanOrEqual(0);
|
||||
const tail = out.slice(classIdx + 'class '.length).trimStart();
|
||||
expect(tail.startsWith('UMyClass')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripUeMacros — tree-sitter extraction (end-to-end)', () => {
|
||||
/**
|
||||
* Walk the parse tree and return the captured class name(s). Works against
|
||||
* the actual tree-sitter-cpp grammar so this is a true integration check
|
||||
* for the core PR claim: the indexer now sees `UMyClass`, not `BRAWLUI_API`.
|
||||
*/
|
||||
function extractClassNames(source: string): string[] {
|
||||
const parser = new Parser();
|
||||
parser.setLanguage(CPP as unknown as Parser.Language);
|
||||
const tree = parser.parse(source);
|
||||
const names: string[] = [];
|
||||
const stack: Parser.SyntaxNode[] = [tree.rootNode];
|
||||
while (stack.length > 0) {
|
||||
const node = stack.pop()!;
|
||||
if (node.type === 'class_specifier' || node.type === 'struct_specifier') {
|
||||
const nameNode = node.childForFieldName('name');
|
||||
if (nameNode) names.push(nameNode.text);
|
||||
}
|
||||
for (let i = node.namedChildCount - 1; i >= 0; i--) {
|
||||
const child = node.namedChild(i);
|
||||
if (child) stack.push(child);
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
it('tree-sitter-cpp captures UMyClass as the class name (not BRAWLUI_API)', () => {
|
||||
const src = `UCLASS(BlueprintType)\nclass BRAWLUI_API UMyClass : public UObject\n{\n GENERATED_BODY()\n public:\n UFUNCTION()\n void Run();\n};`;
|
||||
const out = stripUeMacros(src);
|
||||
const names = extractClassNames(out);
|
||||
expect(names).toContain('UMyClass');
|
||||
expect(names).not.toContain('BRAWLUI_API');
|
||||
});
|
||||
|
||||
it('tree-sitter-cpp captures struct name correctly through USTRUCT + MODULE_API', () => {
|
||||
const src = `USTRUCT(BlueprintType)\nstruct ENGINE_API FMyData : public FBase\n{\n GENERATED_BODY()\n float Value;\n};`;
|
||||
const out = stripUeMacros(src);
|
||||
const names = extractClassNames(out);
|
||||
expect(names).toContain('FMyData');
|
||||
expect(names).not.toContain('ENGINE_API');
|
||||
});
|
||||
|
||||
it('tree-sitter-cpp source positions are preserved across stripping (line numbers match)', () => {
|
||||
const src = `UCLASS()\nclass BRAWLUI_API UMyClass : public UObject\n{\n GENERATED_BODY()\n public:\n void Run();\n};`;
|
||||
const out = stripUeMacros(src);
|
||||
const parser = new Parser();
|
||||
parser.setLanguage(CPP as unknown as Parser.Language);
|
||||
const tree = parser.parse(out);
|
||||
const stack: Parser.SyntaxNode[] = [tree.rootNode];
|
||||
let runLine: number | undefined;
|
||||
while (stack.length > 0) {
|
||||
const node = stack.pop()!;
|
||||
if (node.type === 'function_declarator') {
|
||||
const declarator = node.childForFieldName('declarator');
|
||||
if (declarator?.text === 'Run') {
|
||||
runLine = node.startPosition.row;
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (let i = node.namedChildCount - 1; i >= 0; i--) {
|
||||
const child = node.namedChild(i);
|
||||
if (child) stack.push(child);
|
||||
}
|
||||
}
|
||||
expect(runLine).toBe(5); // 0-indexed: "void Run();" is on line 6 (index 5)
|
||||
});
|
||||
});
|
||||
@@ -1,458 +0,0 @@
|
||||
/**
|
||||
* Regression Tests: Cursor postToolUse Hook
|
||||
*
|
||||
* Tests the hook script at gitnexus-cursor-integration/hooks/gitnexus-hook.cjs
|
||||
* which runs as a Cursor 2.4 postToolUse hook.
|
||||
*
|
||||
* Covers:
|
||||
* - extractPattern: pattern extraction from Grep/Read/Shell tool inputs
|
||||
* - findGitNexusDir: .gitnexus directory discovery (shared with Claude hook)
|
||||
* - cwd validation: rejects relative paths
|
||||
* - shell injection: verifies no `shell: true` in spawnSync calls
|
||||
* - cross-platform: Windows .cmd extension handling
|
||||
* - output shape: top-level `additional_context` (NOT Claude's `hookSpecificOutput.additionalContext`)
|
||||
* - hooks.json wiring matches the script's actual handlers
|
||||
*
|
||||
* Cursor hooks reach the augment CLI only when cwd is inside an indexed
|
||||
* repo, so behavior tests stick to early-exit paths to avoid spawning
|
||||
* `npx gitnexus`.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { spawnSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { runHook } from '../utils/hook-test-helpers.js';
|
||||
|
||||
// ─── Path to the Cursor hook + manifest ─────────────────────────────
|
||||
|
||||
const CURSOR_HOOK = path.resolve(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'..',
|
||||
'gitnexus-cursor-integration',
|
||||
'hooks',
|
||||
'gitnexus-hook.cjs',
|
||||
);
|
||||
const CURSOR_HOOKS_JSON = path.resolve(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'..',
|
||||
'gitnexus-cursor-integration',
|
||||
'hooks',
|
||||
'hooks.json',
|
||||
);
|
||||
|
||||
// ─── Cursor-specific output parser ──────────────────────────────────
|
||||
// Cursor postToolUse output shape: { "additional_context": "..." }
|
||||
|
||||
function parseCursorOutput(stdout: string): { additional_context?: string } | null {
|
||||
if (!stdout.trim()) return null;
|
||||
try {
|
||||
return JSON.parse(stdout.trim());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Test fixtures ──────────────────────────────────────────────────
|
||||
|
||||
let tmpDir: string;
|
||||
|
||||
beforeAll(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-cursor-hook-test-'));
|
||||
spawnSync('git', ['init'], { cwd: tmpDir, stdio: 'pipe' });
|
||||
spawnSync('git', ['config', 'user.email', 'test@test.com'], { cwd: tmpDir, stdio: 'pipe' });
|
||||
spawnSync('git', ['config', 'user.name', 'Test'], { cwd: tmpDir, stdio: 'pipe' });
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ─── Manifest + hook file presence ───────────────────────────────────
|
||||
|
||||
describe('Cursor integration files', () => {
|
||||
it('hook script exists', () => {
|
||||
expect(fs.existsSync(CURSOR_HOOK)).toBe(true);
|
||||
});
|
||||
|
||||
it('hooks.json exists', () => {
|
||||
expect(fs.existsSync(CURSOR_HOOKS_JSON)).toBe(true);
|
||||
});
|
||||
|
||||
it('legacy augment-shell.sh has been removed', () => {
|
||||
const legacy = path.resolve(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'..',
|
||||
'gitnexus-cursor-integration',
|
||||
'hooks',
|
||||
'augment-shell.sh',
|
||||
);
|
||||
expect(fs.existsSync(legacy)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── hooks.json wiring ──────────────────────────────────────────────
|
||||
|
||||
describe('hooks.json wiring', () => {
|
||||
const manifest = JSON.parse(fs.readFileSync(CURSOR_HOOKS_JSON, 'utf-8'));
|
||||
|
||||
it('declares version 1', () => {
|
||||
expect(manifest.version).toBe(1);
|
||||
});
|
||||
|
||||
it('registers a postToolUse hook (not legacy beforeShellExecution)', () => {
|
||||
expect(manifest.hooks.postToolUse).toBeDefined();
|
||||
expect(Array.isArray(manifest.hooks.postToolUse)).toBe(true);
|
||||
expect(manifest.hooks.beforeShellExecution).toBeUndefined();
|
||||
});
|
||||
|
||||
it('matches Shell, Read, and Grep tools', () => {
|
||||
const matcher: string = manifest.hooks.postToolUse[0].matcher;
|
||||
expect(matcher).toMatch(/Shell/);
|
||||
expect(matcher).toMatch(/Read/);
|
||||
expect(matcher).toMatch(/Grep/);
|
||||
});
|
||||
|
||||
it('points command at the new Node hook', () => {
|
||||
const command: string = manifest.hooks.postToolUse[0].command;
|
||||
expect(command).toContain('gitnexus-hook.cjs');
|
||||
expect(command).not.toContain('augment-shell.sh');
|
||||
});
|
||||
|
||||
it('declares timeout in seconds (not milliseconds)', () => {
|
||||
// Cursor's `timeout` field is in seconds per
|
||||
// https://cursor.com/docs/agent/hooks. Regression guard: a value of
|
||||
// 1000+ here would be a >16-minute timeout, almost certainly a ms/s mixup.
|
||||
const timeout: number = manifest.hooks.postToolUse[0].timeout;
|
||||
expect(typeof timeout).toBe('number');
|
||||
expect(timeout).toBeGreaterThan(0);
|
||||
expect(timeout).toBeLessThan(120);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Source code regressions ────────────────────────────────────────
|
||||
|
||||
describe('Cursor hook source regressions', () => {
|
||||
const source = fs.readFileSync(CURSOR_HOOK, 'utf-8');
|
||||
|
||||
it('does not pass shell: true to spawnSync', () => {
|
||||
const lines = source.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (line.trim().startsWith('//') || line.trim().startsWith('*')) continue;
|
||||
if (/shell:\s*(true|isWin)/.test(line)) {
|
||||
throw new Error(`Cursor hook line ${i + 1} has shell injection risk: ${line.trim()}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('uses npx.cmd for Windows', () => {
|
||||
expect(source).toContain('npx.cmd');
|
||||
});
|
||||
|
||||
it('validates cwd is an absolute path', () => {
|
||||
expect(source).toMatch(/path\.isAbsolute\(cwd\)/);
|
||||
});
|
||||
|
||||
it('truncates debug error messages to 200 chars', () => {
|
||||
expect(source).toContain('.slice(0, 200)');
|
||||
});
|
||||
|
||||
it('emits Cursor-shape additional_context (not Claude hookSpecificOutput)', () => {
|
||||
expect(source).toContain('additional_context');
|
||||
expect(source).not.toContain('hookSpecificOutput');
|
||||
expect(source).not.toContain('hookEventName');
|
||||
});
|
||||
|
||||
it('rejects patterns shorter than 3 chars', () => {
|
||||
expect(source).toMatch(/length\s*>=\s*3/);
|
||||
});
|
||||
|
||||
it('passes pattern after end-of-options marker (--)', () => {
|
||||
// Regression for #200 — augment patterns starting with `-` would
|
||||
// otherwise be parsed as CLI flags by the gitnexus CLI.
|
||||
expect(source).toMatch(/'augment',\s*'--',\s*pattern/);
|
||||
});
|
||||
|
||||
it('gates on a non-global .gitnexus directory before invoking the CLI', () => {
|
||||
expect(source).toContain('findGitNexusDir');
|
||||
expect(source).toContain('isGlobalRegistryDir');
|
||||
});
|
||||
|
||||
it('handles linked git worktrees via git rev-parse --git-common-dir', () => {
|
||||
expect(source).toContain('--git-common-dir');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── extractPattern coverage (source-level) ─────────────────────────
|
||||
|
||||
describe('Cursor hook extractPattern coverage', () => {
|
||||
const source = fs.readFileSync(CURSOR_HOOK, 'utf-8');
|
||||
|
||||
it("handles 'grep' tool (Cursor matcher: Grep)", () => {
|
||||
expect(source).toMatch(/t === 'grep'/);
|
||||
});
|
||||
|
||||
it('probes a wide alias set for Grep query field (Cursor contract not formally specified)', () => {
|
||||
// Cursor 2.4 docs at https://cursor.com/docs/agent/hooks list the
|
||||
// matchers but not the per-tool tool_input field names. If Cursor
|
||||
// changes the contract, we want the hook to still extract *something*
|
||||
// — these aliases plus the longest-string fallback give us coverage.
|
||||
for (const alias of ['query', 'pattern', 'regex', 'q', 'search', 'searchQuery']) {
|
||||
expect(source).toContain(`toolInput.${alias}`);
|
||||
}
|
||||
expect(source).toContain('pickLongestStringValue');
|
||||
});
|
||||
|
||||
it("handles 'read' tool (Cursor matcher: Read)", () => {
|
||||
expect(source).toMatch(/t === 'read'/);
|
||||
for (const alias of ['target_file', 'file_path', 'filePath', 'path', 'file']) {
|
||||
expect(source).toContain(`toolInput.${alias}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("handles 'shell' tool (Cursor matcher: Shell)", () => {
|
||||
expect(source).toMatch(/t === 'shell'/);
|
||||
expect(source).toMatch(/\\brg\\b\|\\bgrep\\b/);
|
||||
});
|
||||
|
||||
it('logs raw payload to stderr when GITNEXUS_DEBUG is set (for contract diagnostics)', () => {
|
||||
expect(source).toContain('GITNEXUS_DEBUG');
|
||||
expect(source).toContain('GitNexus Cursor hook stdin:');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Behavior: graceful no-op paths (no augment CLI invocation) ─────
|
||||
|
||||
describe('Cursor hook behavior — early-exit paths', () => {
|
||||
it('exits cleanly on empty stdin', () => {
|
||||
const result = spawnSync(process.execPath, [CURSOR_HOOK], {
|
||||
input: '',
|
||||
encoding: 'utf-8',
|
||||
timeout: 10000,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout.trim()).toBe('');
|
||||
});
|
||||
|
||||
it('exits cleanly on invalid JSON stdin', () => {
|
||||
const result = spawnSync(process.execPath, [CURSOR_HOOK], {
|
||||
input: 'not json at all',
|
||||
encoding: 'utf-8',
|
||||
timeout: 10000,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout.trim()).toBe('');
|
||||
});
|
||||
|
||||
it('produces no output when cwd is relative', () => {
|
||||
const result = runHook(CURSOR_HOOK, {
|
||||
tool_name: 'Grep',
|
||||
tool_input: { query: 'validateUser' },
|
||||
cwd: 'relative/path',
|
||||
});
|
||||
expect(result.stdout.trim()).toBe('');
|
||||
expect(result.status).toBe(0);
|
||||
});
|
||||
|
||||
it('produces no output when cwd has no .gitnexus dir', () => {
|
||||
const result = runHook(CURSOR_HOOK, {
|
||||
tool_name: 'Grep',
|
||||
tool_input: { query: 'validateUser' },
|
||||
cwd: tmpDir,
|
||||
});
|
||||
expect(result.stdout.trim()).toBe('');
|
||||
expect(result.status).toBe(0);
|
||||
});
|
||||
|
||||
it('produces no output for unknown tool names', () => {
|
||||
const result = runHook(CURSOR_HOOK, {
|
||||
tool_name: 'TotallyMadeUpTool',
|
||||
tool_input: { foo: 'bar' },
|
||||
cwd: tmpDir,
|
||||
});
|
||||
expect(result.stdout.trim()).toBe('');
|
||||
expect(result.status).toBe(0);
|
||||
});
|
||||
|
||||
it('produces no output for Shell commands without rg/grep', () => {
|
||||
const result = runHook(CURSOR_HOOK, {
|
||||
tool_name: 'Shell',
|
||||
tool_input: { command: 'ls -la' },
|
||||
cwd: tmpDir,
|
||||
});
|
||||
expect(result.stdout.trim()).toBe('');
|
||||
expect(result.status).toBe(0);
|
||||
});
|
||||
|
||||
it('produces no output for Grep with a 2-char query', () => {
|
||||
const result = runHook(CURSOR_HOOK, {
|
||||
tool_name: 'Grep',
|
||||
tool_input: { query: 'is' },
|
||||
cwd: tmpDir,
|
||||
});
|
||||
expect(result.stdout.trim()).toBe('');
|
||||
expect(result.status).toBe(0);
|
||||
});
|
||||
|
||||
it('produces no output for Read whose basename has no identifier chars', () => {
|
||||
const result = runHook(CURSOR_HOOK, {
|
||||
tool_name: 'Read',
|
||||
tool_input: { target_file: '/tmp/--.md' },
|
||||
cwd: tmpDir,
|
||||
});
|
||||
expect(result.stdout.trim()).toBe('');
|
||||
expect(result.status).toBe(0);
|
||||
});
|
||||
|
||||
it('produces no output for Read with no file path', () => {
|
||||
const result = runHook(CURSOR_HOOK, {
|
||||
tool_name: 'Read',
|
||||
tool_input: {},
|
||||
cwd: tmpDir,
|
||||
});
|
||||
expect(result.stdout.trim()).toBe('');
|
||||
expect(result.status).toBe(0);
|
||||
});
|
||||
|
||||
it('treats tool_name case-insensitively (Grep vs grep)', () => {
|
||||
// Both should reach the same handler — and both should early-exit silently
|
||||
// because tmpDir has no .gitnexus.
|
||||
for (const toolName of ['Grep', 'grep', 'GREP']) {
|
||||
const result = runHook(CURSOR_HOOK, {
|
||||
tool_name: toolName,
|
||||
tool_input: { query: 'validateUser' },
|
||||
cwd: tmpDir,
|
||||
});
|
||||
expect(result.stdout.trim()).toBe('');
|
||||
expect(result.status).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Behavior: GITNEXUS_DEBUG payload logging ────────────────────────
|
||||
|
||||
describe('Cursor hook debug logging', () => {
|
||||
it('echoes the payload to stderr only when GITNEXUS_DEBUG is set', () => {
|
||||
const payload = {
|
||||
tool_name: 'Grep',
|
||||
tool_input: { query: 'validateUser' },
|
||||
cwd: tmpDir,
|
||||
};
|
||||
|
||||
// GITNEXUS_DEBUG unset → stderr quiet.
|
||||
const quiet = spawnSync(process.execPath, [CURSOR_HOOK], {
|
||||
input: JSON.stringify(payload),
|
||||
encoding: 'utf-8',
|
||||
timeout: 10000,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: { ...process.env, GITNEXUS_DEBUG: '' },
|
||||
});
|
||||
expect(quiet.status).toBe(0);
|
||||
expect(quiet.stderr).not.toContain('GitNexus Cursor hook stdin');
|
||||
|
||||
// GITNEXUS_DEBUG=1 → payload echoed to stderr (stdout still empty for
|
||||
// unindexed cwd, so the hook output contract is preserved).
|
||||
const verbose = spawnSync(process.execPath, [CURSOR_HOOK], {
|
||||
input: JSON.stringify(payload),
|
||||
encoding: 'utf-8',
|
||||
timeout: 10000,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: { ...process.env, GITNEXUS_DEBUG: '1' },
|
||||
});
|
||||
expect(verbose.status).toBe(0);
|
||||
expect(verbose.stderr).toContain('GitNexus Cursor hook stdin');
|
||||
expect(verbose.stderr).toContain('"tool_name":"Grep"');
|
||||
expect(verbose.stdout.trim()).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Documented contract behavior (extractPattern via the live hook) ─
|
||||
|
||||
describe('Shell quoted-pattern parser limitations (documented)', () => {
|
||||
// The Shell parser cannot reconstruct shell quoting. These tests pin the
|
||||
// current behavior so a future "fix" doesn't silently change extraction
|
||||
// — and so users diagnosing a noisy/missed pattern can find the behavior
|
||||
// documented in tests.
|
||||
//
|
||||
// We can't observe the extracted pattern directly without an indexed
|
||||
// repo, but we *can* confirm the hook reaches the augment-call path
|
||||
// (vs. early-exiting) by checking exit status + clean stdout for cases
|
||||
// where parseRgGrepPattern would yield a >=3-char token.
|
||||
|
||||
it('quoted multi-word `rg "User Service"` extracts the first word only', () => {
|
||||
const result = runHook(CURSOR_HOOK, {
|
||||
tool_name: 'Shell',
|
||||
tool_input: { command: 'rg "User Service" src/' },
|
||||
cwd: tmpDir, // no .gitnexus → exits early after extract
|
||||
});
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout.trim()).toBe('');
|
||||
});
|
||||
|
||||
it('single-token quoted `rg "validateUser"` works as expected', () => {
|
||||
const result = runHook(CURSOR_HOOK, {
|
||||
tool_name: 'Shell',
|
||||
tool_input: { command: 'rg "validateUser"' },
|
||||
cwd: tmpDir,
|
||||
});
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout.trim()).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Install docs ─────────────────────────────────────────────────────
|
||||
|
||||
describe('Cursor integration install docs', () => {
|
||||
const integrationReadme = path.resolve(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'..',
|
||||
'gitnexus-cursor-integration',
|
||||
'README.md',
|
||||
);
|
||||
|
||||
it('install README exists', () => {
|
||||
expect(fs.existsSync(integrationReadme)).toBe(true);
|
||||
});
|
||||
|
||||
it('install README documents the hook install path', () => {
|
||||
const body = fs.readFileSync(integrationReadme, 'utf-8');
|
||||
expect(body).toContain('.cursor/hooks.json');
|
||||
expect(body).toContain('hooks/gitnexus-hook.cjs');
|
||||
expect(body).toContain('Hook install');
|
||||
});
|
||||
|
||||
it('install README documents GITNEXUS_DEBUG for payload diagnostics', () => {
|
||||
const body = fs.readFileSync(integrationReadme, 'utf-8');
|
||||
expect(body).toContain('GITNEXUS_DEBUG');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Output parser sanity (synthetic JSON) ──────────────────────────
|
||||
|
||||
describe('parseCursorOutput', () => {
|
||||
it('parses a well-formed { additional_context } payload', () => {
|
||||
const parsed = parseCursorOutput('{"additional_context":"hello"}');
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed?.additional_context).toBe('hello');
|
||||
});
|
||||
|
||||
it('returns null on empty stdout', () => {
|
||||
expect(parseCursorOutput('')).toBeNull();
|
||||
expect(parseCursorOutput(' \n')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null on malformed JSON', () => {
|
||||
expect(parseCursorOutput('not json')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,12 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import os from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { CircuitBreaker } from 'gitnexus-shared';
|
||||
import {
|
||||
applyHfEnvOverrides,
|
||||
isNetworkFetchError,
|
||||
isHfDownloadFailure,
|
||||
isHfCircuitOpenError,
|
||||
HfDownloadCircuitBreaker,
|
||||
withDownloadTimeout,
|
||||
withHfDownloadRetry,
|
||||
CIRCUIT_OPEN_TAG,
|
||||
@@ -154,13 +154,85 @@ describe('isHfDownloadFailure', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// CircuitBreaker state-machine tests live in
|
||||
// `gitnexus/test/unit/integrations/circuit-breaker.test.ts` — that suite
|
||||
// already covers the closed/open/half-open transitions, recordSuccess/
|
||||
// recordFailure semantics, half-open probe gating, and configurable
|
||||
// thresholds. No need to duplicate here; this file's remaining tests
|
||||
// focus on HF-specific composition (withHfDownloadRetry, env-var
|
||||
// overrides, error classification).
|
||||
describe('HfDownloadCircuitBreaker', () => {
|
||||
it('starts in closed state', () => {
|
||||
const cb = new HfDownloadCircuitBreaker();
|
||||
expect(cb.isOpen()).toBe(false);
|
||||
expect(cb.state).toBe('closed');
|
||||
});
|
||||
|
||||
it('opens after reaching the failure threshold', () => {
|
||||
const cb = new HfDownloadCircuitBreaker(3);
|
||||
cb.recordFailure();
|
||||
cb.recordFailure();
|
||||
expect(cb.isOpen()).toBe(false);
|
||||
cb.recordFailure(); // threshold reached
|
||||
expect(cb.isOpen()).toBe(true);
|
||||
expect(cb.state).toBe('open');
|
||||
});
|
||||
|
||||
it('closes on recordSuccess after being open', () => {
|
||||
const cb = new HfDownloadCircuitBreaker(1);
|
||||
cb.recordFailure();
|
||||
expect(cb.isOpen()).toBe(true);
|
||||
cb.recordSuccess();
|
||||
expect(cb.isOpen()).toBe(false);
|
||||
expect(cb.state).toBe('closed');
|
||||
});
|
||||
|
||||
it('transitions to half-open after the reset timeout', () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const cb = new HfDownloadCircuitBreaker(1, 100 /* 100ms */);
|
||||
cb.recordFailure();
|
||||
expect(cb.isOpen()).toBe(true);
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(cb.isOpen()).toBe(false);
|
||||
expect(cb.state).toBe('half-open');
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('reset() restores closed state', () => {
|
||||
const cb = new HfDownloadCircuitBreaker(1);
|
||||
cb.recordFailure();
|
||||
expect(cb.isOpen()).toBe(true);
|
||||
cb.reset();
|
||||
expect(cb.isOpen()).toBe(false);
|
||||
expect(cb.state).toBe('closed');
|
||||
});
|
||||
|
||||
it('re-opens when a failure is recorded in half-open state', () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const cb = new HfDownloadCircuitBreaker(1, 100 /* 100ms */);
|
||||
cb.recordFailure(); // opens the circuit
|
||||
vi.advanceTimersByTime(200); // advance past reset timeout
|
||||
expect(cb.state).toBe('half-open'); // getter transitions _state to half-open
|
||||
cb.recordFailure(); // failure in half-open → re-opens
|
||||
expect(cb.isOpen()).toBe(true);
|
||||
expect(cb.state).toBe('open');
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('closes the circuit when success is recorded in half-open state', () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const cb = new HfDownloadCircuitBreaker(1, 100 /* 100ms */);
|
||||
cb.recordFailure(); // opens the circuit
|
||||
vi.advanceTimersByTime(200); // advance past reset timeout
|
||||
expect(cb.state).toBe('half-open');
|
||||
cb.recordSuccess(); // success in half-open → closes
|
||||
expect(cb.isOpen()).toBe(false);
|
||||
expect(cb.state).toBe('closed');
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('withDownloadTimeout', () => {
|
||||
it('resolves when fn completes before the timeout', async () => {
|
||||
@@ -190,7 +262,7 @@ describe('withDownloadTimeout', () => {
|
||||
describe('withHfDownloadRetry', () => {
|
||||
it('returns the result on first success', async () => {
|
||||
const fn = vi.fn().mockResolvedValue('ok');
|
||||
const cb = new CircuitBreaker();
|
||||
const cb = new HfDownloadCircuitBreaker();
|
||||
const result = await withHfDownloadRetry(fn, { circuit: cb, baseDelayMs: 0 });
|
||||
expect(result).toBe('ok');
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
@@ -198,7 +270,7 @@ describe('withHfDownloadRetry', () => {
|
||||
|
||||
it('retries on network errors and succeeds on second attempt', async () => {
|
||||
const fn = vi.fn().mockRejectedValueOnce(new Error('fetch failed')).mockResolvedValue('ok');
|
||||
const cb = new CircuitBreaker();
|
||||
const cb = new HfDownloadCircuitBreaker();
|
||||
const result = await withHfDownloadRetry(fn, {
|
||||
circuit: cb,
|
||||
maxAttempts: 3,
|
||||
@@ -210,7 +282,7 @@ describe('withHfDownloadRetry', () => {
|
||||
|
||||
it('throws the last network error after all attempts are exhausted', async () => {
|
||||
const fn = vi.fn().mockRejectedValue(new Error('ECONNREFUSED 127.0.0.1:443'));
|
||||
const cb = new CircuitBreaker({ failureThreshold: 99 });
|
||||
const cb = new HfDownloadCircuitBreaker(99 /* high threshold */);
|
||||
await expect(
|
||||
withHfDownloadRetry(fn, { circuit: cb, maxAttempts: 3, baseDelayMs: 0 }),
|
||||
).rejects.toThrow('ECONNREFUSED');
|
||||
@@ -219,7 +291,7 @@ describe('withHfDownloadRetry', () => {
|
||||
|
||||
it('does not retry non-network errors', async () => {
|
||||
const fn = vi.fn().mockRejectedValue(new Error('Failed to initialize CUDA backend'));
|
||||
const cb = new CircuitBreaker();
|
||||
const cb = new HfDownloadCircuitBreaker();
|
||||
await expect(
|
||||
withHfDownloadRetry(fn, { circuit: cb, maxAttempts: 3, baseDelayMs: 0 }),
|
||||
).rejects.toThrow('Failed to initialize CUDA backend');
|
||||
@@ -228,7 +300,7 @@ describe('withHfDownloadRetry', () => {
|
||||
|
||||
it('fails immediately when the circuit is already open', async () => {
|
||||
const fn = vi.fn().mockResolvedValue('ok');
|
||||
const cb = new CircuitBreaker({ failureThreshold: 1 });
|
||||
const cb = new HfDownloadCircuitBreaker(1);
|
||||
cb.recordFailure(); // open the circuit
|
||||
await expect(withHfDownloadRetry(fn, { circuit: cb })).rejects.toThrow(CIRCUIT_OPEN_TAG);
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
@@ -236,12 +308,12 @@ describe('withHfDownloadRetry', () => {
|
||||
|
||||
it('opens the circuit after failureThreshold failures and throws a circuit-open error', async () => {
|
||||
const fn = vi.fn().mockRejectedValue(new Error('ENOTFOUND huggingface.co'));
|
||||
const cb = new CircuitBreaker({ failureThreshold: 2, cooldownMs: 60_000 });
|
||||
const cb = new HfDownloadCircuitBreaker(2 /* threshold */, 60_000);
|
||||
// First call: 2 attempts, threshold=2 → circuit opens on 2nd failure
|
||||
await expect(
|
||||
withHfDownloadRetry(fn, { circuit: cb, maxAttempts: 2, baseDelayMs: 0 }),
|
||||
).rejects.toThrow(CIRCUIT_OPEN_TAG);
|
||||
expect(cb.getState()).toBe('open');
|
||||
expect(cb.isOpen()).toBe(true);
|
||||
});
|
||||
|
||||
it('calls onRetry with correct arguments on each retry', async () => {
|
||||
@@ -250,7 +322,7 @@ describe('withHfDownloadRetry', () => {
|
||||
.mockRejectedValueOnce(new Error('fetch failed'))
|
||||
.mockRejectedValueOnce(new Error('fetch failed'))
|
||||
.mockResolvedValue('ok');
|
||||
const cb = new CircuitBreaker({ failureThreshold: 99 });
|
||||
const cb = new HfDownloadCircuitBreaker(99);
|
||||
const onRetry = vi.fn();
|
||||
await withHfDownloadRetry(fn, { circuit: cb, maxAttempts: 3, baseDelayMs: 0, onRetry });
|
||||
expect(onRetry).toHaveBeenCalledTimes(2);
|
||||
@@ -270,11 +342,11 @@ describe('withHfDownloadRetry', () => {
|
||||
|
||||
it('resets the circuit on success', async () => {
|
||||
const fn = vi.fn().mockResolvedValue('value');
|
||||
const cb = new CircuitBreaker({ failureThreshold: 5 });
|
||||
const cb = new HfDownloadCircuitBreaker(5);
|
||||
cb.recordFailure();
|
||||
cb.recordFailure(); // 2 failures, circuit still closed
|
||||
await withHfDownloadRetry(fn, { circuit: cb, baseDelayMs: 0 });
|
||||
expect(cb.getState()).toBe('closed');
|
||||
expect(cb.state).toBe('closed');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -299,7 +371,7 @@ describe('withHfDownloadRetry env overrides', () => {
|
||||
it('HF_MAX_ATTEMPTS=1 gives exactly 1 attempt', async () => {
|
||||
process.env.HF_MAX_ATTEMPTS = '1';
|
||||
const fn = vi.fn().mockRejectedValue(new Error('ECONNREFUSED 127.0.0.1:443'));
|
||||
const cb = new CircuitBreaker({ failureThreshold: 99_999 });
|
||||
const cb = new HfDownloadCircuitBreaker(99_999 /* high threshold */);
|
||||
await expect(withHfDownloadRetry(fn, { circuit: cb, baseDelayMs: 0 })).rejects.toThrow(
|
||||
'ECONNREFUSED',
|
||||
);
|
||||
@@ -309,7 +381,7 @@ describe('withHfDownloadRetry env overrides', () => {
|
||||
it('HF_MAX_ATTEMPTS=2 gives exactly 2 attempts', async () => {
|
||||
process.env.HF_MAX_ATTEMPTS = '2';
|
||||
const fn = vi.fn().mockRejectedValue(new Error('ENOTFOUND huggingface.co'));
|
||||
const cb = new CircuitBreaker({ failureThreshold: 99_999 });
|
||||
const cb = new HfDownloadCircuitBreaker(99_999);
|
||||
await expect(withHfDownloadRetry(fn, { circuit: cb, baseDelayMs: 0 })).rejects.toThrow(
|
||||
'ENOTFOUND',
|
||||
);
|
||||
@@ -319,7 +391,7 @@ describe('withHfDownloadRetry env overrides', () => {
|
||||
it('HF_MAX_ATTEMPTS=abc falls back to the built-in default', async () => {
|
||||
process.env.HF_MAX_ATTEMPTS = 'abc';
|
||||
const fn = vi.fn().mockRejectedValue(new Error('fetch failed'));
|
||||
const cb = new CircuitBreaker({ failureThreshold: 99_999 });
|
||||
const cb = new HfDownloadCircuitBreaker(99_999);
|
||||
await expect(withHfDownloadRetry(fn, { circuit: cb, baseDelayMs: 0 })).rejects.toThrow(
|
||||
'fetch failed',
|
||||
);
|
||||
@@ -329,7 +401,7 @@ describe('withHfDownloadRetry env overrides', () => {
|
||||
it('HF_MAX_ATTEMPTS=0 falls back to the built-in default', async () => {
|
||||
process.env.HF_MAX_ATTEMPTS = '0';
|
||||
const fn = vi.fn().mockRejectedValue(new Error('fetch failed'));
|
||||
const cb = new CircuitBreaker({ failureThreshold: 99_999 });
|
||||
const cb = new HfDownloadCircuitBreaker(99_999);
|
||||
await expect(withHfDownloadRetry(fn, { circuit: cb, baseDelayMs: 0 })).rejects.toThrow(
|
||||
'fetch failed',
|
||||
);
|
||||
@@ -339,7 +411,7 @@ describe('withHfDownloadRetry env overrides', () => {
|
||||
it('HF_MAX_ATTEMPTS=-1 falls back to the built-in default', async () => {
|
||||
process.env.HF_MAX_ATTEMPTS = '-1';
|
||||
const fn = vi.fn().mockRejectedValue(new Error('fetch failed'));
|
||||
const cb = new CircuitBreaker({ failureThreshold: 99_999 });
|
||||
const cb = new HfDownloadCircuitBreaker(99_999);
|
||||
await expect(withHfDownloadRetry(fn, { circuit: cb, baseDelayMs: 0 })).rejects.toThrow(
|
||||
'fetch failed',
|
||||
);
|
||||
@@ -349,7 +421,7 @@ describe('withHfDownloadRetry env overrides', () => {
|
||||
it('HF_MAX_ATTEMPTS is clamped to HF_MAX_ATTEMPTS_CAP', async () => {
|
||||
process.env.HF_MAX_ATTEMPTS = '9999';
|
||||
const fn = vi.fn().mockRejectedValue(new Error('fetch failed'));
|
||||
const cb = new CircuitBreaker({ failureThreshold: 99_999 });
|
||||
const cb = new HfDownloadCircuitBreaker(99_999 /* very high threshold */);
|
||||
await expect(withHfDownloadRetry(fn, { circuit: cb, baseDelayMs: 0 })).rejects.toThrow(
|
||||
'fetch failed',
|
||||
);
|
||||
@@ -359,7 +431,7 @@ describe('withHfDownloadRetry env overrides', () => {
|
||||
it('HF_MAX_ATTEMPTS=2.9 is floored to 2', async () => {
|
||||
process.env.HF_MAX_ATTEMPTS = '2.9';
|
||||
const fn = vi.fn().mockRejectedValue(new Error('fetch failed'));
|
||||
const cb = new CircuitBreaker({ failureThreshold: 99_999 });
|
||||
const cb = new HfDownloadCircuitBreaker(99_999);
|
||||
await expect(withHfDownloadRetry(fn, { circuit: cb, baseDelayMs: 0 })).rejects.toThrow(
|
||||
'fetch failed',
|
||||
);
|
||||
@@ -371,7 +443,7 @@ describe('withHfDownloadRetry env overrides', () => {
|
||||
try {
|
||||
process.env.HF_DOWNLOAD_TIMEOUT_MS = '50';
|
||||
const neverResolves = () => new Promise<never>(() => {});
|
||||
const cb = new CircuitBreaker({ failureThreshold: 99 });
|
||||
const cb = new HfDownloadCircuitBreaker(99);
|
||||
const promise = withHfDownloadRetry(neverResolves, { circuit: cb, maxAttempts: 1 });
|
||||
vi.advanceTimersByTime(100);
|
||||
await expect(promise).rejects.toThrow('ETIMEDOUT');
|
||||
@@ -386,7 +458,7 @@ describe('withHfDownloadRetry env overrides', () => {
|
||||
// we just verify that the env var rejection causes options.timeoutMs to be
|
||||
// the default constant (not -1) by confirming the resolved value is used.
|
||||
const fn = vi.fn().mockResolvedValue('ok');
|
||||
const cb = new CircuitBreaker({ failureThreshold: 99 });
|
||||
const cb = new HfDownloadCircuitBreaker(99);
|
||||
// Provide explicit timeoutMs to avoid the default 5-minute wait
|
||||
const result = await withHfDownloadRetry(fn, { circuit: cb, timeoutMs: 100 });
|
||||
expect(result).toBe('ok');
|
||||
@@ -398,7 +470,7 @@ describe('withHfDownloadRetry env overrides', () => {
|
||||
// Set an env value exceeding the 30-minute cap
|
||||
process.env.HF_DOWNLOAD_TIMEOUT_MS = String(HF_MAX_TIMEOUT_MS + 60_000);
|
||||
const neverResolves = () => new Promise<never>(() => {});
|
||||
const cb = new CircuitBreaker({ failureThreshold: 99 });
|
||||
const cb = new HfDownloadCircuitBreaker(99);
|
||||
const promise = withHfDownloadRetry(neverResolves, { circuit: cb, maxAttempts: 1 });
|
||||
// Advance just past the 30-minute cap
|
||||
vi.advanceTimersByTime(HF_MAX_TIMEOUT_MS + 1);
|
||||
@@ -411,7 +483,7 @@ describe('withHfDownloadRetry env overrides', () => {
|
||||
it('explicit options override env vars', async () => {
|
||||
process.env.HF_MAX_ATTEMPTS = '5';
|
||||
const fn = vi.fn().mockRejectedValue(new Error('fetch failed'));
|
||||
const cb = new CircuitBreaker({ failureThreshold: 99 });
|
||||
const cb = new HfDownloadCircuitBreaker(99);
|
||||
// explicit maxAttempts: 2 must win over HF_MAX_ATTEMPTS=5
|
||||
await expect(
|
||||
withHfDownloadRetry(fn, { circuit: cb, maxAttempts: 2, baseDelayMs: 0 }),
|
||||
|
||||
@@ -1,395 +0,0 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { CircuitBreaker, CircuitOpenError, getBreaker } from 'gitnexus-shared';
|
||||
import { __resetBreakerRegistry__ } from 'gitnexus-shared/test-helpers';
|
||||
|
||||
describe('CircuitBreaker', () => {
|
||||
beforeEach(() => __resetBreakerRegistry__());
|
||||
|
||||
function makeClock(start = 1_700_000_000_000) {
|
||||
let t = start;
|
||||
return {
|
||||
now: () => t,
|
||||
advance: (ms: number) => {
|
||||
t += ms;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it('runs through check/recordSuccess in closed state', () => {
|
||||
const clock = makeClock();
|
||||
const b = new CircuitBreaker({ failureThreshold: 3, cooldownMs: 30_000, now: clock.now });
|
||||
expect(b.getState()).toBe('closed');
|
||||
b.check(); // does not throw
|
||||
b.recordSuccess();
|
||||
expect(b.getState()).toBe('closed');
|
||||
expect(b.getConsecutiveFailures()).toBe(0);
|
||||
});
|
||||
|
||||
it('stays closed below the failure threshold', () => {
|
||||
const clock = makeClock();
|
||||
const b = new CircuitBreaker({ failureThreshold: 3, cooldownMs: 30_000, now: clock.now });
|
||||
b.recordFailure();
|
||||
b.recordFailure();
|
||||
expect(b.getState()).toBe('closed');
|
||||
expect(b.getConsecutiveFailures()).toBe(2);
|
||||
});
|
||||
|
||||
it('opens after failureThreshold consecutive failures and check throws', () => {
|
||||
const clock = makeClock();
|
||||
const b = new CircuitBreaker({ failureThreshold: 3, cooldownMs: 30_000, now: clock.now });
|
||||
b.recordFailure();
|
||||
b.recordFailure();
|
||||
b.recordFailure();
|
||||
expect(b.getState()).toBe('open');
|
||||
expect(() => b.check()).toThrow(CircuitOpenError);
|
||||
});
|
||||
|
||||
it('CircuitOpenError.retryAfterMs decreases as time advances', () => {
|
||||
const clock = makeClock();
|
||||
const b = new CircuitBreaker({ failureThreshold: 1, cooldownMs: 30_000, now: clock.now });
|
||||
b.recordFailure();
|
||||
let caught: CircuitOpenError | null = null;
|
||||
try {
|
||||
b.check();
|
||||
} catch (err) {
|
||||
caught = err as CircuitOpenError;
|
||||
}
|
||||
expect(caught?.retryAfterMs).toBe(30_000);
|
||||
|
||||
clock.advance(10_000);
|
||||
try {
|
||||
b.check();
|
||||
} catch (err) {
|
||||
caught = err as CircuitOpenError;
|
||||
}
|
||||
expect(caught?.retryAfterMs).toBe(20_000);
|
||||
});
|
||||
|
||||
it('transitions Open -> Half-Open after cooldown elapses (via check)', () => {
|
||||
const clock = makeClock();
|
||||
const b = new CircuitBreaker({ failureThreshold: 1, cooldownMs: 30_000, now: clock.now });
|
||||
b.recordFailure();
|
||||
expect(b.getState()).toBe('open');
|
||||
clock.advance(31_000);
|
||||
b.check(); // should not throw
|
||||
// After check, internal state is half-open (next call probes).
|
||||
expect(b.getConsecutiveFailures()).toBe(1); // unchanged until next outcome
|
||||
});
|
||||
|
||||
it('half-open + recordSuccess -> closed and counter reset', () => {
|
||||
const clock = makeClock();
|
||||
const b = new CircuitBreaker({ failureThreshold: 1, cooldownMs: 30_000, now: clock.now });
|
||||
b.recordFailure();
|
||||
clock.advance(31_000);
|
||||
b.check();
|
||||
b.recordSuccess();
|
||||
expect(b.getState()).toBe('closed');
|
||||
expect(b.getConsecutiveFailures()).toBe(0);
|
||||
});
|
||||
|
||||
it('half-open + recordFailure -> open with fresh openedAt', () => {
|
||||
const clock = makeClock();
|
||||
const b = new CircuitBreaker({ failureThreshold: 1, cooldownMs: 30_000, now: clock.now });
|
||||
b.recordFailure();
|
||||
const firstOpen = clock.now();
|
||||
clock.advance(31_000); // cooldown expired
|
||||
b.check(); // half-open
|
||||
b.recordFailure();
|
||||
// Open with fresh timestamp — full cooldown again.
|
||||
let caught: CircuitOpenError | null = null;
|
||||
try {
|
||||
b.check();
|
||||
} catch (err) {
|
||||
caught = err as CircuitOpenError;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(CircuitOpenError);
|
||||
expect(caught?.retryAfterMs).toBe(30_000);
|
||||
// Sanity: not the original openedAt (would be negative remaining).
|
||||
expect(clock.now()).toBeGreaterThan(firstOpen);
|
||||
});
|
||||
|
||||
it('recordSuccess from closed state with prior partial failures resets counter', () => {
|
||||
const b = new CircuitBreaker({ failureThreshold: 5 });
|
||||
b.recordFailure();
|
||||
b.recordFailure();
|
||||
expect(b.getConsecutiveFailures()).toBe(2);
|
||||
b.recordSuccess();
|
||||
expect(b.getConsecutiveFailures()).toBe(0);
|
||||
expect(b.getState()).toBe('closed');
|
||||
});
|
||||
|
||||
describe('recordNeutral (U1)', () => {
|
||||
it('is a no-op from closed state with zero prior failures', () => {
|
||||
const b = new CircuitBreaker({ failureThreshold: 3 });
|
||||
b.recordNeutral();
|
||||
expect(b.getState()).toBe('closed');
|
||||
expect(b.getConsecutiveFailures()).toBe(0);
|
||||
});
|
||||
|
||||
it('preserves partial-failure progress (does not reset counter)', () => {
|
||||
const b = new CircuitBreaker({ failureThreshold: 3 });
|
||||
b.recordFailure();
|
||||
b.recordFailure();
|
||||
b.recordNeutral();
|
||||
expect(b.getConsecutiveFailures()).toBe(2);
|
||||
expect(b.getState()).toBe('closed');
|
||||
// Real third failure still trips the breaker — neutrals didn't
|
||||
// erase the running count toward the threshold.
|
||||
b.recordFailure();
|
||||
expect(b.getState()).toBe('open');
|
||||
});
|
||||
|
||||
it('does not reset openedAt or transition out of open state', () => {
|
||||
const clock = makeClock();
|
||||
const b = new CircuitBreaker({ failureThreshold: 1, cooldownMs: 30_000, now: clock.now });
|
||||
b.recordFailure();
|
||||
expect(b.getState()).toBe('open');
|
||||
b.recordNeutral();
|
||||
// Still open; cooldown clock unchanged.
|
||||
expect(() => b.check()).toThrow(CircuitOpenError);
|
||||
});
|
||||
|
||||
it('leaves half-open state alone (next true outcome decides)', () => {
|
||||
const clock = makeClock();
|
||||
const b = new CircuitBreaker({ failureThreshold: 1, cooldownMs: 30_000, now: clock.now });
|
||||
b.recordFailure();
|
||||
clock.advance(31_000);
|
||||
b.check(); // half-open
|
||||
b.recordNeutral();
|
||||
// Still half-open; a subsequent recordFailure flips to open.
|
||||
b.recordFailure();
|
||||
let caught: CircuitOpenError | null = null;
|
||||
try {
|
||||
b.check();
|
||||
} catch (err) {
|
||||
caught = err as CircuitOpenError;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(CircuitOpenError);
|
||||
});
|
||||
|
||||
it('integration: 2 failures + 5 neutrals + 1 failure → opens on third real failure', () => {
|
||||
const b = new CircuitBreaker({ failureThreshold: 3 });
|
||||
b.recordFailure();
|
||||
b.recordFailure();
|
||||
for (let i = 0; i < 5; i++) b.recordNeutral();
|
||||
expect(b.getConsecutiveFailures()).toBe(2);
|
||||
expect(b.getState()).toBe('closed');
|
||||
b.recordFailure();
|
||||
expect(b.getState()).toBe('open');
|
||||
});
|
||||
});
|
||||
|
||||
describe('half-open probe permit gate (U1)', () => {
|
||||
it('admits exactly one caller after cooldown; subsequent check() throws halfOpenRetryAfterMs', () => {
|
||||
const clock = makeClock();
|
||||
const b = new CircuitBreaker({
|
||||
failureThreshold: 1,
|
||||
cooldownMs: 30_000,
|
||||
halfOpenRetryAfterMs: 1_000,
|
||||
now: clock.now,
|
||||
});
|
||||
b.recordFailure();
|
||||
clock.advance(31_000);
|
||||
|
||||
// Caller A: gets the probe permit.
|
||||
b.check();
|
||||
expect(b.isProbeInFlight()).toBe(true);
|
||||
|
||||
// Caller B: blocked.
|
||||
let caught: CircuitOpenError | null = null;
|
||||
try {
|
||||
b.check();
|
||||
} catch (err) {
|
||||
caught = err as CircuitOpenError;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(CircuitOpenError);
|
||||
expect(caught?.retryAfterMs).toBe(1_000);
|
||||
|
||||
// Caller A's recordSuccess clears the breaker.
|
||||
b.recordSuccess();
|
||||
expect(b.isProbeInFlight()).toBe(false);
|
||||
expect(b.getState()).toBe('closed');
|
||||
|
||||
// Caller C: succeeds in closed state.
|
||||
b.check();
|
||||
expect(b.getState()).toBe('closed');
|
||||
});
|
||||
|
||||
it('recordFailure on probe re-opens with fresh cooldown (NOT halfOpenRetryAfterMs)', () => {
|
||||
const clock = makeClock();
|
||||
const b = new CircuitBreaker({
|
||||
failureThreshold: 1,
|
||||
cooldownMs: 30_000,
|
||||
halfOpenRetryAfterMs: 1_000,
|
||||
now: clock.now,
|
||||
});
|
||||
b.recordFailure();
|
||||
clock.advance(31_000);
|
||||
|
||||
b.check(); // A: probe
|
||||
expect(() => b.check()).toThrow(CircuitOpenError); // B: blocked
|
||||
|
||||
b.recordFailure(); // A reports failure → reopens with fresh openedAt
|
||||
|
||||
// C: should see the fresh cooldown remaining, not the probe-in-flight 1s default.
|
||||
let caught: CircuitOpenError | null = null;
|
||||
try {
|
||||
b.check();
|
||||
} catch (err) {
|
||||
caught = err as CircuitOpenError;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(CircuitOpenError);
|
||||
// Fresh openedAt = current clock; cooldown is 30s; retryAfter ≈ 30s.
|
||||
expect(caught?.retryAfterMs).toBe(30_000);
|
||||
});
|
||||
|
||||
it('recordNeutral releases the probe permit but leaves state half-open', () => {
|
||||
const clock = makeClock();
|
||||
const b = new CircuitBreaker({ failureThreshold: 1, cooldownMs: 30_000, now: clock.now });
|
||||
b.recordFailure();
|
||||
clock.advance(31_000);
|
||||
|
||||
b.check(); // A: probe
|
||||
expect(b.isProbeInFlight()).toBe(true);
|
||||
|
||||
b.recordNeutral(); // A: neutral — permit released, state untouched
|
||||
expect(b.isProbeInFlight()).toBe(false);
|
||||
expect(b.getState()).toBe('half-open');
|
||||
|
||||
// B: succeeds (becomes the new probe), no longer blocked.
|
||||
b.check();
|
||||
expect(b.isProbeInFlight()).toBe(true);
|
||||
|
||||
// B's recordSuccess clears the breaker.
|
||||
b.recordSuccess();
|
||||
expect(b.getState()).toBe('closed');
|
||||
});
|
||||
|
||||
it('three sequential probes via neutrals: A → A.neutral → B → B.neutral → C', () => {
|
||||
const clock = makeClock();
|
||||
const b = new CircuitBreaker({ failureThreshold: 1, cooldownMs: 30_000, now: clock.now });
|
||||
b.recordFailure();
|
||||
const initialFailures = b.getConsecutiveFailures();
|
||||
clock.advance(31_000);
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
b.check();
|
||||
b.recordNeutral();
|
||||
}
|
||||
// Counter unchanged; state still half-open; permit released.
|
||||
expect(b.getConsecutiveFailures()).toBe(initialFailures);
|
||||
expect(b.getState()).toBe('half-open');
|
||||
expect(b.isProbeInFlight()).toBe(false);
|
||||
});
|
||||
|
||||
it('5 same-tick sequential callers: exactly one passes, the other 4 throw', () => {
|
||||
// `check()` is synchronous — these calls execute on a single
|
||||
// microtask in declaration order. The first mutates probeInFlight
|
||||
// = true; the next four observe the mutation and throw. This
|
||||
// tests mutation ordering, not true concurrency (the actual
|
||||
// interleaved-async-microtask scenario lives in U2).
|
||||
const clock = makeClock();
|
||||
const b = new CircuitBreaker({ failureThreshold: 1, cooldownMs: 30_000, now: clock.now });
|
||||
b.recordFailure();
|
||||
clock.advance(31_000);
|
||||
|
||||
const results: Array<'pass' | 'throw'> = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
try {
|
||||
b.check();
|
||||
results.push('pass');
|
||||
} catch {
|
||||
results.push('throw');
|
||||
}
|
||||
}
|
||||
expect(results.filter((r) => r === 'pass').length).toBe(1);
|
||||
expect(results.filter((r) => r === 'throw').length).toBe(4);
|
||||
});
|
||||
|
||||
it('probe permit consumed; clock advances another full cooldown without record*; still throws', () => {
|
||||
const clock = makeClock();
|
||||
const b = new CircuitBreaker({ failureThreshold: 1, cooldownMs: 30_000, now: clock.now });
|
||||
b.recordFailure();
|
||||
clock.advance(31_000);
|
||||
|
||||
b.check(); // probe permit consumed
|
||||
clock.advance(60_000); // another full cooldown elapses, no record*
|
||||
|
||||
// Half-open semantics: wait for an outcome, not a timer. The
|
||||
// permit-consumed state doesn't auto-resolve on time.
|
||||
expect(() => b.check()).toThrow(CircuitOpenError);
|
||||
});
|
||||
|
||||
it('halfOpenRetryAfterMs default is 1000 when not configured', () => {
|
||||
const clock = makeClock();
|
||||
const b = new CircuitBreaker({ failureThreshold: 1, cooldownMs: 30_000, now: clock.now });
|
||||
b.recordFailure();
|
||||
clock.advance(31_000);
|
||||
b.check();
|
||||
|
||||
let caught: CircuitOpenError | null = null;
|
||||
try {
|
||||
b.check();
|
||||
} catch (err) {
|
||||
caught = err as CircuitOpenError;
|
||||
}
|
||||
expect(caught?.retryAfterMs).toBe(1_000);
|
||||
});
|
||||
|
||||
it('halfOpenRetryAfterMs is configurable for long-running protected ops', () => {
|
||||
const clock = makeClock();
|
||||
const b = new CircuitBreaker({
|
||||
failureThreshold: 1,
|
||||
cooldownMs: 30_000,
|
||||
halfOpenRetryAfterMs: 10_000, // LLM-streaming-friendly
|
||||
now: clock.now,
|
||||
});
|
||||
b.recordFailure();
|
||||
clock.advance(31_000);
|
||||
b.check();
|
||||
|
||||
let caught: CircuitOpenError | null = null;
|
||||
try {
|
||||
b.check();
|
||||
} catch (err) {
|
||||
caught = err as CircuitOpenError;
|
||||
}
|
||||
expect(caught?.retryAfterMs).toBe(10_000);
|
||||
});
|
||||
|
||||
it('getState() is a pure read — does not consume the probe permit', () => {
|
||||
const clock = makeClock();
|
||||
const b = new CircuitBreaker({ failureThreshold: 1, cooldownMs: 30_000, now: clock.now });
|
||||
b.recordFailure();
|
||||
clock.advance(31_000);
|
||||
|
||||
// Test calls getState() to inspect — must not consume the permit.
|
||||
expect(b.getState()).toBe('half-open');
|
||||
expect(b.isProbeInFlight()).toBe(false);
|
||||
// First check() still gets the permit.
|
||||
b.check();
|
||||
expect(b.isProbeInFlight()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBreaker registry', () => {
|
||||
it('returns the same instance for the same key', () => {
|
||||
const a = getBreaker('endpoint-a');
|
||||
const b = getBreaker('endpoint-a');
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it('returns different instances for different keys', () => {
|
||||
const a = getBreaker('endpoint-a');
|
||||
const b = getBreaker('endpoint-b');
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it('__resetBreakerRegistry__ clears all instances', () => {
|
||||
const a = getBreaker('endpoint-a');
|
||||
__resetBreakerRegistry__();
|
||||
const a2 = getBreaker('endpoint-a');
|
||||
expect(a2).not.toBe(a);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,558 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import {
|
||||
CircuitBreaker,
|
||||
CircuitOpenError,
|
||||
parseRetryAfter,
|
||||
resilientFetch,
|
||||
ResilientFetchExhaustedError,
|
||||
RETRY_AFTER_CAP_MS,
|
||||
} from 'gitnexus-shared';
|
||||
import { __resetBreakerRegistry__, classifyOutcome } from 'gitnexus-shared/test-helpers';
|
||||
|
||||
describe('parseRetryAfter', () => {
|
||||
it('parses delta-seconds form', () => {
|
||||
expect(parseRetryAfter('30')).toBe(30_000);
|
||||
expect(parseRetryAfter('0')).toBe(0);
|
||||
});
|
||||
it('returns null on negative or non-numeric garbage', () => {
|
||||
expect(parseRetryAfter(null)).toBeNull();
|
||||
expect(parseRetryAfter('')).toBeNull();
|
||||
expect(parseRetryAfter(' ')).toBeNull();
|
||||
expect(parseRetryAfter('not-a-number')).toBeNull();
|
||||
});
|
||||
it('parses HTTP-date form against an injected clock', () => {
|
||||
const now = () => Date.parse('Wed, 21 Oct 2025 07:28:00 GMT');
|
||||
expect(parseRetryAfter('Wed, 21 Oct 2025 07:28:30 GMT', now)).toBe(30_000);
|
||||
});
|
||||
it('returns 0 (not negative) on past HTTP-date', () => {
|
||||
const now = () => Date.parse('Wed, 21 Oct 2025 08:00:00 GMT');
|
||||
expect(parseRetryAfter('Wed, 21 Oct 2025 07:28:00 GMT', now)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyOutcome', () => {
|
||||
const now = () => 1_700_000_000_000;
|
||||
|
||||
it('classifies 2xx as success', () => {
|
||||
const resp = new Response(null, { status: 204 });
|
||||
const out = classifyOutcome({ kind: 'response', resp }, now);
|
||||
expect(out.kind).toBe('success');
|
||||
});
|
||||
it('classifies 5xx as retryable-status without afterMs', () => {
|
||||
const resp = new Response(null, { status: 503 });
|
||||
const out = classifyOutcome({ kind: 'response', resp }, now);
|
||||
expect(out.kind).toBe('retryable-status');
|
||||
if (out.kind === 'retryable-status') expect(out.afterMs).toBeUndefined();
|
||||
});
|
||||
it('classifies 429 with Retry-After (capped) as retryable-status', () => {
|
||||
const resp = new Response(null, { status: 429, headers: { 'Retry-After': '99999' } });
|
||||
const out = classifyOutcome({ kind: 'response', resp }, now);
|
||||
expect(out.kind).toBe('retryable-status');
|
||||
if (out.kind === 'retryable-status') expect(out.afterMs).toBe(RETRY_AFTER_CAP_MS);
|
||||
});
|
||||
it('classifies 429 from a header-less fetch mock without throwing', () => {
|
||||
// Tests sometimes stub `fetch` with a plain `{ ok, status }` object
|
||||
// (e.g. http-embedder.test.ts). Real `Response` always carries
|
||||
// `Headers`, but the helper must not crash when the stub does not.
|
||||
// Falls through to exponential-backoff retry like a 429 with no
|
||||
// Retry-After header.
|
||||
const resp = { ok: false, status: 429 } as unknown as Response;
|
||||
const out = classifyOutcome({ kind: 'response', resp }, now);
|
||||
expect(out.kind).toBe('retryable-status');
|
||||
if (out.kind === 'retryable-status') expect(out.afterMs).toBeUndefined();
|
||||
});
|
||||
it('classifies 401/403/404/422 as terminal-client', () => {
|
||||
for (const status of [401, 403, 404, 422, 400]) {
|
||||
const resp = new Response(null, { status });
|
||||
const out = classifyOutcome({ kind: 'response', resp }, now);
|
||||
expect(out.kind).toBe('terminal-client');
|
||||
}
|
||||
});
|
||||
it('classifies TimeoutError as terminal-network', () => {
|
||||
const err = new DOMException('aborted', 'TimeoutError');
|
||||
const out = classifyOutcome({ kind: 'error', err }, now);
|
||||
expect(out.kind).toBe('terminal-network');
|
||||
});
|
||||
it('classifies generic network throw as retryable-network', () => {
|
||||
const err = new TypeError('fetch failed');
|
||||
const out = classifyOutcome({ kind: 'error', err }, now);
|
||||
expect(out.kind).toBe('retryable-network');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resilientFetch', () => {
|
||||
const URL_STR = 'https://example.test/api/dispatch';
|
||||
|
||||
beforeEach(() => __resetBreakerRegistry__());
|
||||
|
||||
function jsonResp(status: number, headers?: Record<string, string>): Response {
|
||||
return new Response(null, { status, headers });
|
||||
}
|
||||
|
||||
function makeBreaker(opts: Partial<ConstructorParameters<typeof CircuitBreaker>[0]> = {}) {
|
||||
let t = 1_700_000_000_000;
|
||||
const breaker = new CircuitBreaker({
|
||||
failureThreshold: 3,
|
||||
cooldownMs: 30_000,
|
||||
key: 'test',
|
||||
now: () => t,
|
||||
...opts,
|
||||
});
|
||||
return { breaker, advance: (ms: number) => (t += ms) };
|
||||
}
|
||||
|
||||
it('204 returns immediately, no retries, breaker stays closed', async () => {
|
||||
const fetchImpl = vi.fn(async () => jsonResp(204));
|
||||
const sleep = vi.fn(async () => {});
|
||||
const { breaker } = makeBreaker();
|
||||
const resp = await resilientFetch(URL_STR, undefined, {
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
breaker,
|
||||
retry: { sleep },
|
||||
});
|
||||
expect(resp.status).toBe(204);
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
expect(sleep).not.toHaveBeenCalled();
|
||||
expect(breaker.getState()).toBe('closed');
|
||||
expect(breaker.getConsecutiveFailures()).toBe(0);
|
||||
});
|
||||
|
||||
it('one 503 then 204 → retried once, returns 204, breaker stays closed', async () => {
|
||||
let n = 0;
|
||||
const fetchImpl = vi.fn(async () => {
|
||||
n += 1;
|
||||
return n === 1 ? jsonResp(503) : jsonResp(204);
|
||||
});
|
||||
const sleep = vi.fn(async () => {});
|
||||
const { breaker } = makeBreaker();
|
||||
const resp = await resilientFetch(URL_STR, undefined, {
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
breaker,
|
||||
retry: { sleep, random: () => 0.5, baseDelayMs: 100, capDelayMs: 1000 },
|
||||
});
|
||||
expect(resp.status).toBe(204);
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
||||
expect(sleep).toHaveBeenCalledTimes(1);
|
||||
expect(breaker.getConsecutiveFailures()).toBe(0);
|
||||
});
|
||||
|
||||
it('429 with Retry-After honored (capped at RETRY_AFTER_CAP_MS)', async () => {
|
||||
let n = 0;
|
||||
const fetchImpl = vi.fn(async () => {
|
||||
n += 1;
|
||||
return n === 1 ? jsonResp(429, { 'Retry-After': '1' }) : jsonResp(204);
|
||||
});
|
||||
const sleep = vi.fn(async () => {});
|
||||
const { breaker } = makeBreaker();
|
||||
await resilientFetch(URL_STR, undefined, {
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
breaker,
|
||||
retry: { sleep },
|
||||
});
|
||||
expect(sleep).toHaveBeenCalledWith(1000); // 1s
|
||||
});
|
||||
|
||||
it('429 with absurd Retry-After is capped to RETRY_AFTER_CAP_MS', async () => {
|
||||
let n = 0;
|
||||
const fetchImpl = vi.fn(async () => {
|
||||
n += 1;
|
||||
return n === 1 ? jsonResp(429, { 'Retry-After': '99999' }) : jsonResp(204);
|
||||
});
|
||||
const sleep = vi.fn(async () => {});
|
||||
const { breaker } = makeBreaker();
|
||||
await resilientFetch(URL_STR, undefined, {
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
breaker,
|
||||
retry: { sleep, capDelayMs: 999_999 }, // ensure cap comes from RETRY_AFTER_CAP_MS, not retry config
|
||||
});
|
||||
expect(sleep).toHaveBeenCalledWith(RETRY_AFTER_CAP_MS);
|
||||
});
|
||||
|
||||
it('429 without Retry-After falls back to exponential-backoff delay', async () => {
|
||||
let n = 0;
|
||||
const fetchImpl = vi.fn(async () => {
|
||||
n += 1;
|
||||
return n === 1 ? jsonResp(429) : jsonResp(204);
|
||||
});
|
||||
const sleep = vi.fn(async () => {});
|
||||
const { breaker } = makeBreaker();
|
||||
await resilientFetch(URL_STR, undefined, {
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
breaker,
|
||||
retry: { sleep, baseDelayMs: 100, capDelayMs: 1000, random: () => 0.5 },
|
||||
});
|
||||
// attempt 0: full-jitter upper = min(1000, 100*1) = 100; floor(0.5*100) = 50
|
||||
expect(sleep).toHaveBeenCalledWith(50);
|
||||
});
|
||||
|
||||
it('401 returned as Response, no retry, breaker not incremented', async () => {
|
||||
const fetchImpl = vi.fn(async () => jsonResp(401));
|
||||
const sleep = vi.fn(async () => {});
|
||||
const { breaker } = makeBreaker();
|
||||
const resp = await resilientFetch(URL_STR, undefined, {
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
breaker,
|
||||
retry: { sleep },
|
||||
});
|
||||
expect(resp.status).toBe(401);
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
expect(breaker.getConsecutiveFailures()).toBe(0);
|
||||
});
|
||||
|
||||
it('422 returned as Response, no retry', async () => {
|
||||
const fetchImpl = vi.fn(async () => jsonResp(422));
|
||||
const { breaker } = makeBreaker();
|
||||
const resp = await resilientFetch(URL_STR, undefined, {
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
breaker,
|
||||
retry: { sleep: async () => {} },
|
||||
});
|
||||
expect(resp.status).toBe(422);
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('TimeoutError rethrown immediately, no retry, breaker not incremented', async () => {
|
||||
const fetchImpl = vi.fn(async () => {
|
||||
throw new DOMException('aborted', 'TimeoutError');
|
||||
});
|
||||
const sleep = vi.fn(async () => {});
|
||||
const { breaker } = makeBreaker();
|
||||
await expect(
|
||||
resilientFetch(URL_STR, undefined, {
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
breaker,
|
||||
retry: { sleep },
|
||||
}),
|
||||
).rejects.toThrow(DOMException);
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
expect(sleep).not.toHaveBeenCalled();
|
||||
expect(breaker.getConsecutiveFailures()).toBe(0);
|
||||
});
|
||||
|
||||
it('three consecutive 503 throws ResilientFetchExhaustedError; breaker increments by 1', async () => {
|
||||
const fetchImpl = vi.fn(async () => jsonResp(503));
|
||||
const sleep = vi.fn(async () => {});
|
||||
const { breaker } = makeBreaker();
|
||||
await expect(
|
||||
resilientFetch(URL_STR, undefined, {
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
breaker,
|
||||
retry: { sleep, maxAttempts: 3 },
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ResilientFetchExhaustedError);
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(3);
|
||||
expect(breaker.getConsecutiveFailures()).toBe(1);
|
||||
});
|
||||
|
||||
it('after three exhausted 503 batches, breaker opens and fails fast', async () => {
|
||||
const fetchImpl = vi.fn(async () => jsonResp(503));
|
||||
const { breaker } = makeBreaker({ failureThreshold: 3, cooldownMs: 60_000 });
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await expect(
|
||||
resilientFetch(URL_STR, undefined, {
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
breaker,
|
||||
retry: { sleep: async () => {}, maxAttempts: 3 },
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ResilientFetchExhaustedError);
|
||||
}
|
||||
expect(breaker.getState()).toBe('open');
|
||||
// 4th call: breaker open, no fetch invoked.
|
||||
const fetchCallsBefore = fetchImpl.mock.calls.length;
|
||||
await expect(
|
||||
resilientFetch(URL_STR, undefined, {
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
breaker,
|
||||
retry: { sleep: async () => {}, maxAttempts: 3 },
|
||||
}),
|
||||
).rejects.toBeInstanceOf(CircuitOpenError);
|
||||
expect(fetchImpl.mock.calls.length).toBe(fetchCallsBefore);
|
||||
});
|
||||
|
||||
it('retryable-network error retries, breaker counts only on exhaustion', async () => {
|
||||
const fetchImpl = vi.fn(async () => {
|
||||
throw new TypeError('fetch failed');
|
||||
});
|
||||
const { breaker } = makeBreaker();
|
||||
await expect(
|
||||
resilientFetch(URL_STR, undefined, {
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
breaker,
|
||||
retry: { sleep: async () => {}, maxAttempts: 3 },
|
||||
}),
|
||||
).rejects.toBeInstanceOf(TypeError);
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(3);
|
||||
expect(breaker.getConsecutiveFailures()).toBe(1);
|
||||
});
|
||||
|
||||
describe('U2: terminal outcomes route through recordNeutral', () => {
|
||||
it('401 does not erase prior partial-failure progress on the breaker', async () => {
|
||||
const { breaker } = makeBreaker();
|
||||
// Pre-seed the breaker with 2 failures (still closed; threshold 3).
|
||||
breaker.recordFailure();
|
||||
breaker.recordFailure();
|
||||
expect(breaker.getConsecutiveFailures()).toBe(2);
|
||||
|
||||
const fetchImpl = vi.fn(async () => jsonResp(401));
|
||||
await resilientFetch(URL_STR, undefined, {
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
breaker,
|
||||
retry: { sleep: async () => {} },
|
||||
});
|
||||
|
||||
// Counter MUST stay at 2 — under the old behaviour recordSuccess
|
||||
// would have reset to 0 and the next 5xx batch would have started
|
||||
// from scratch instead of tipping over the threshold.
|
||||
expect(breaker.getConsecutiveFailures()).toBe(2);
|
||||
expect(breaker.getState()).toBe('closed');
|
||||
});
|
||||
|
||||
it('TimeoutError does not erase prior partial-failure progress', async () => {
|
||||
const { breaker } = makeBreaker();
|
||||
breaker.recordFailure();
|
||||
breaker.recordFailure();
|
||||
|
||||
const fetchImpl = vi.fn(async () => {
|
||||
throw new DOMException('aborted by timeout', 'TimeoutError');
|
||||
});
|
||||
await expect(
|
||||
resilientFetch(URL_STR, undefined, {
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
breaker,
|
||||
retry: { sleep: async () => {} },
|
||||
}),
|
||||
).rejects.toBeInstanceOf(DOMException);
|
||||
|
||||
expect(breaker.getConsecutiveFailures()).toBe(2);
|
||||
});
|
||||
|
||||
it('external AbortError is terminal: no retry, breaker untouched', async () => {
|
||||
const { breaker } = makeBreaker();
|
||||
breaker.recordFailure();
|
||||
|
||||
const fetchImpl = vi.fn(async () => {
|
||||
throw new DOMException('aborted by caller', 'AbortError');
|
||||
});
|
||||
const sleep = vi.fn(async () => {});
|
||||
|
||||
await expect(
|
||||
resilientFetch(URL_STR, undefined, {
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
breaker,
|
||||
retry: { sleep, maxAttempts: 3 },
|
||||
}),
|
||||
).rejects.toMatchObject({ name: 'AbortError' });
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
expect(sleep).not.toHaveBeenCalled();
|
||||
// Counter unchanged — neither incremented (no failure) nor reset
|
||||
// (no synthetic success).
|
||||
expect(breaker.getConsecutiveFailures()).toBe(1);
|
||||
});
|
||||
|
||||
it('interleaved 5xx + 401 + 5xx + 401 + 5xx opens breaker on third real failure', async () => {
|
||||
const { breaker } = makeBreaker({ failureThreshold: 3 });
|
||||
const sequence = [503, 401, 503, 401, 503];
|
||||
let i = 0;
|
||||
const fetchImpl = vi.fn(async () => jsonResp(sequence[i++]));
|
||||
|
||||
// Each call uses maxAttempts:1 so each surfaces a single response
|
||||
// (5xx → ResilientFetchExhaustedError; 4xx → returned Response).
|
||||
const driveOne = () =>
|
||||
resilientFetch(URL_STR, undefined, {
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
breaker,
|
||||
retry: { sleep: async () => {}, maxAttempts: 1 },
|
||||
});
|
||||
|
||||
await expect(driveOne()).rejects.toBeInstanceOf(ResilientFetchExhaustedError); // 5xx fail #1
|
||||
await driveOne(); // 401 neutral
|
||||
await expect(driveOne()).rejects.toBeInstanceOf(ResilientFetchExhaustedError); // 5xx fail #2
|
||||
await driveOne(); // 401 neutral
|
||||
await expect(driveOne()).rejects.toBeInstanceOf(ResilientFetchExhaustedError); // 5xx fail #3 → opens
|
||||
|
||||
expect(breaker.getState()).toBe('open');
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('half-open single-probe gating (U2)', () => {
|
||||
/** Test helper: a fetch mock whose Response is controlled by the test. */
|
||||
function deferredFetch(): {
|
||||
promise: Promise<Response>;
|
||||
resolve: (resp: Response) => void;
|
||||
reject: (err: unknown) => void;
|
||||
} {
|
||||
let resolve!: (resp: Response) => void;
|
||||
let reject!: (err: unknown) => void;
|
||||
const promise = new Promise<Response>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
/** Builds a clock-injected breaker pre-opened with cooldown elapsed. */
|
||||
function preOpenedBreaker(opts: { cooldownMs: number; halfOpenRetryAfterMs?: number }): {
|
||||
breaker: CircuitBreaker;
|
||||
advance: (ms: number) => void;
|
||||
} {
|
||||
let t = 1_700_000_000_000;
|
||||
const breaker = new CircuitBreaker({
|
||||
failureThreshold: 1,
|
||||
cooldownMs: opts.cooldownMs,
|
||||
halfOpenRetryAfterMs: opts.halfOpenRetryAfterMs ?? 1_000,
|
||||
key: 'test',
|
||||
now: () => t,
|
||||
});
|
||||
breaker.recordFailure();
|
||||
t += opts.cooldownMs + 1; // cooldown elapsed
|
||||
return { breaker, advance: (ms) => (t += ms) };
|
||||
}
|
||||
|
||||
it('happy: 3 concurrent calls — exactly 1 hits fetch, others throw CircuitOpenError', async () => {
|
||||
const { breaker } = preOpenedBreaker({ cooldownMs: 10 });
|
||||
const deferred = deferredFetch();
|
||||
const fetchImpl = vi.fn(() => deferred.promise);
|
||||
|
||||
// Synchronous portion of each `resilientFetch` runs eagerly up to
|
||||
// the first await, so by the time r2/r3 are constructed the probe
|
||||
// permit is already consumed by r1 and they reject synchronously.
|
||||
const r1 = resilientFetch(URL_STR, undefined, {
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
breaker,
|
||||
retry: { sleep: async () => {}, maxAttempts: 1 },
|
||||
});
|
||||
const r2 = resilientFetch(URL_STR, undefined, {
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
breaker,
|
||||
retry: { sleep: async () => {}, maxAttempts: 1 },
|
||||
});
|
||||
const r3 = resilientFetch(URL_STR, undefined, {
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
breaker,
|
||||
retry: { sleep: async () => {}, maxAttempts: 1 },
|
||||
});
|
||||
|
||||
// Resolve the probe with 200; r1 should now settle.
|
||||
deferred.resolve(new Response(null, { status: 200 }));
|
||||
|
||||
const results = await Promise.allSettled([r1, r2, r3]);
|
||||
|
||||
expect(results[0].status).toBe('fulfilled');
|
||||
if (results[0].status === 'fulfilled') {
|
||||
expect(results[0].value.status).toBe(200);
|
||||
}
|
||||
expect(results[1].status).toBe('rejected');
|
||||
if (results[1].status === 'rejected') {
|
||||
expect(results[1].reason).toBeInstanceOf(CircuitOpenError);
|
||||
}
|
||||
expect(results[2].status).toBe('rejected');
|
||||
if (results[2].status === 'rejected') {
|
||||
expect(results[2].reason).toBeInstanceOf(CircuitOpenError);
|
||||
}
|
||||
|
||||
// Only ONE underlying fetch was invoked.
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
// Breaker closed after the probe's success.
|
||||
expect(breaker.getState()).toBe('closed');
|
||||
});
|
||||
|
||||
it('error: probe gets 503 — exhausted error; subsequent caller sees fresh full cooldown', async () => {
|
||||
const { breaker } = preOpenedBreaker({ cooldownMs: 10_000, halfOpenRetryAfterMs: 1_000 });
|
||||
const deferred = deferredFetch();
|
||||
const fetchImpl = vi.fn(() => deferred.promise);
|
||||
|
||||
const r1 = resilientFetch(URL_STR, undefined, {
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
breaker,
|
||||
retry: { sleep: async () => {}, maxAttempts: 1 },
|
||||
});
|
||||
const r2 = resilientFetch(URL_STR, undefined, {
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
breaker,
|
||||
retry: { sleep: async () => {}, maxAttempts: 1 },
|
||||
});
|
||||
const r3 = resilientFetch(URL_STR, undefined, {
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
breaker,
|
||||
retry: { sleep: async () => {}, maxAttempts: 1 },
|
||||
});
|
||||
|
||||
// Probe fails with 503 → exhausted (maxAttempts: 1) → recordFailure → reopen.
|
||||
deferred.resolve(new Response(null, { status: 503 }));
|
||||
|
||||
const results = await Promise.allSettled([r1, r2, r3]);
|
||||
|
||||
expect(results[0].status).toBe('rejected');
|
||||
if (results[0].status === 'rejected') {
|
||||
expect(results[0].reason).toBeInstanceOf(ResilientFetchExhaustedError);
|
||||
}
|
||||
expect(results[1].status).toBe('rejected');
|
||||
if (results[1].status === 'rejected') {
|
||||
expect(results[1].reason).toBeInstanceOf(CircuitOpenError);
|
||||
// Blocked-while-half-open used the halfOpenRetryAfterMs default.
|
||||
expect((results[1].reason as CircuitOpenError).retryAfterMs).toBe(1_000);
|
||||
}
|
||||
|
||||
// Breaker has re-opened with a fresh openedAt.
|
||||
expect(breaker.getState()).toBe('open');
|
||||
|
||||
// r4: should see the fresh full cooldown, NOT the probe-in-flight 1000ms.
|
||||
let r4Caught: CircuitOpenError | null = null;
|
||||
try {
|
||||
await resilientFetch(URL_STR, undefined, {
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
breaker,
|
||||
retry: { sleep: async () => {}, maxAttempts: 1 },
|
||||
});
|
||||
} catch (err) {
|
||||
r4Caught = err as CircuitOpenError;
|
||||
}
|
||||
expect(r4Caught).toBeInstanceOf(CircuitOpenError);
|
||||
expect(r4Caught?.retryAfterMs).toBe(10_000);
|
||||
});
|
||||
|
||||
it('cancellation: probe AbortError releases permit; next caller becomes new probe', async () => {
|
||||
const { breaker } = preOpenedBreaker({ cooldownMs: 10_000 });
|
||||
const deferred1 = deferredFetch();
|
||||
const deferred2 = deferredFetch();
|
||||
let callIdx = 0;
|
||||
const fetchImpl = vi.fn(() => (callIdx++ === 0 ? deferred1.promise : deferred2.promise));
|
||||
|
||||
// r1 admitted as the probe; r2 blocked while r1 still in flight.
|
||||
const r1 = resilientFetch(URL_STR, undefined, {
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
breaker,
|
||||
retry: { sleep: async () => {}, maxAttempts: 1 },
|
||||
});
|
||||
const r2 = resilientFetch(URL_STR, undefined, {
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
breaker,
|
||||
retry: { sleep: async () => {}, maxAttempts: 1 },
|
||||
});
|
||||
await expect(r2).rejects.toBeInstanceOf(CircuitOpenError);
|
||||
|
||||
// Cancel the probe — `AbortError` routes through terminal-network →
|
||||
// `recordNeutral` → permit released, state stays half-open.
|
||||
deferred1.reject(new DOMException('aborted by caller', 'AbortError'));
|
||||
await expect(r1).rejects.toMatchObject({ name: 'AbortError' });
|
||||
|
||||
expect(breaker.isProbeInFlight()).toBe(false);
|
||||
expect(breaker.getState()).toBe('half-open');
|
||||
|
||||
// r3: now succeeds and becomes the new probe.
|
||||
const r3 = resilientFetch(URL_STR, undefined, {
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
breaker,
|
||||
retry: { sleep: async () => {}, maxAttempts: 1 },
|
||||
});
|
||||
deferred2.resolve(new Response(null, { status: 200 }));
|
||||
const r3Resp = await r3;
|
||||
expect(r3Resp.status).toBe(200);
|
||||
expect(breaker.getState()).toBe('closed');
|
||||
// Two fetches total: the cancelled probe + the recovery probe.
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,128 +0,0 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { computeBackoffMs, withRetry, type RetryOptions } from 'gitnexus-shared';
|
||||
|
||||
describe('computeBackoffMs', () => {
|
||||
it('returns afterMs (capped) when caller supplies it', () => {
|
||||
expect(computeBackoffMs(0, 500, 5000, 1500, () => 0.5)).toBe(1500);
|
||||
expect(computeBackoffMs(0, 500, 5000, 99_999, () => 0.5)).toBe(5000);
|
||||
expect(computeBackoffMs(0, 500, 5000, 0, () => 0.5)).toBe(0);
|
||||
expect(computeBackoffMs(0, 500, 5000, -1, () => 0.5)).toBe(0);
|
||||
});
|
||||
|
||||
it('full-jitter delay falls within [0, min(cap, base * 2^attempt)]', () => {
|
||||
// attempt 0: upper = min(5000, 500 * 1) = 500
|
||||
expect(computeBackoffMs(0, 500, 5000, undefined, () => 0)).toBe(0);
|
||||
expect(computeBackoffMs(0, 500, 5000, undefined, () => 0.999)).toBeLessThan(500);
|
||||
// attempt 1: upper = min(5000, 500 * 2) = 1000
|
||||
expect(computeBackoffMs(1, 500, 5000, undefined, () => 0.5)).toBe(500);
|
||||
// attempt 4: 500 * 16 = 8000, capped at 5000
|
||||
expect(computeBackoffMs(4, 500, 5000, undefined, () => 0.5)).toBe(2500);
|
||||
expect(computeBackoffMs(4, 500, 5000, undefined, () => 0.999)).toBeLessThan(5000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('withRetry', () => {
|
||||
function makeOpts(overrides: Partial<RetryOptions> = {}): RetryOptions {
|
||||
return {
|
||||
maxAttempts: 3,
|
||||
baseDelayMs: 10,
|
||||
capDelayMs: 100,
|
||||
isRetryable: () => ({ retry: true }),
|
||||
sleep: vi.fn(async () => {}),
|
||||
random: () => 0.5,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it('returns immediately when fn succeeds first try', async () => {
|
||||
const sleep = vi.fn(async () => {});
|
||||
const fn = vi.fn(async () => 'ok');
|
||||
const result = await withRetry(fn, makeOpts({ sleep }));
|
||||
expect(result).toBe('ok');
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
expect(sleep).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('retries when isRetryable returns retry:true and second call succeeds', async () => {
|
||||
const sleep = vi.fn(async () => {});
|
||||
let calls = 0;
|
||||
const fn = async () => {
|
||||
calls += 1;
|
||||
if (calls === 1) throw new Error('boom');
|
||||
return 'ok';
|
||||
};
|
||||
const result = await withRetry(fn, makeOpts({ sleep }));
|
||||
expect(result).toBe('ok');
|
||||
expect(calls).toBe(2);
|
||||
expect(sleep).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('honors afterMs returned by isRetryable', async () => {
|
||||
const sleep = vi.fn(async () => {});
|
||||
let calls = 0;
|
||||
const fn = async () => {
|
||||
calls += 1;
|
||||
if (calls === 1) throw new Error('throttle');
|
||||
return 'ok';
|
||||
};
|
||||
await withRetry(
|
||||
fn,
|
||||
makeOpts({
|
||||
sleep,
|
||||
isRetryable: () => ({ retry: true, afterMs: 1500 }),
|
||||
capDelayMs: 5000,
|
||||
}),
|
||||
);
|
||||
expect(sleep).toHaveBeenCalledWith(1500);
|
||||
});
|
||||
|
||||
it('caps afterMs at capDelayMs', async () => {
|
||||
const sleep = vi.fn(async () => {});
|
||||
let calls = 0;
|
||||
const fn = async () => {
|
||||
calls += 1;
|
||||
if (calls === 1) throw new Error('throttle');
|
||||
return 'ok';
|
||||
};
|
||||
await withRetry(
|
||||
fn,
|
||||
makeOpts({
|
||||
sleep,
|
||||
isRetryable: () => ({ retry: true, afterMs: 10_000 }),
|
||||
capDelayMs: 3000,
|
||||
}),
|
||||
);
|
||||
expect(sleep).toHaveBeenCalledWith(3000);
|
||||
});
|
||||
|
||||
it('rethrows immediately when isRetryable returns retry:false', async () => {
|
||||
const sleep = vi.fn(async () => {});
|
||||
const fn = vi.fn(async () => {
|
||||
throw new Error('terminal');
|
||||
});
|
||||
await expect(
|
||||
withRetry(fn, makeOpts({ sleep, isRetryable: () => ({ retry: false }) })),
|
||||
).rejects.toThrow('terminal');
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
expect(sleep).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws the last error when maxAttempts exhausted', async () => {
|
||||
const sleep = vi.fn(async () => {});
|
||||
let calls = 0;
|
||||
const fn = async () => {
|
||||
calls += 1;
|
||||
throw new Error(`boom-${calls}`);
|
||||
};
|
||||
await expect(withRetry(fn, makeOpts({ sleep, maxAttempts: 3 }))).rejects.toThrow('boom-3');
|
||||
expect(calls).toBe(3);
|
||||
// 3 attempts → 2 sleeps between them; final attempt does not sleep.
|
||||
expect(sleep).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('rejects maxAttempts < 1', async () => {
|
||||
await expect(withRetry(async () => 'ok', makeOpts({ maxAttempts: 0 }))).rejects.toThrow(
|
||||
/maxAttempts must be >= 1/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -32,7 +32,7 @@ vi.mock('../../../src/storage/repo-manager.js', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/core/search/bm25-index.js', () => ({
|
||||
searchFTSFromLbug: vi.fn().mockResolvedValue({ results: [], ftsAvailable: true }),
|
||||
searchFTSFromLbug: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/mcp/core/embedder.js', () => ({
|
||||
|
||||
@@ -1,316 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, test, vi } from 'vitest';
|
||||
import fs from 'fs/promises';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import {
|
||||
buildUqDispatchPayload,
|
||||
isValidOwnerRepo,
|
||||
parseOwnerRepoFromRemote,
|
||||
stripGitSuffix,
|
||||
UNDERSTAND_QUICKLY_TOKEN_ENV,
|
||||
} from 'gitnexus-shared';
|
||||
|
||||
describe('understand-quickly helpers (gitnexus-shared)', () => {
|
||||
describe('isValidOwnerRepo', () => {
|
||||
it.each([
|
||||
['looptech-ai/understand-quickly', true],
|
||||
['abhigyanpatwari/GitNexus', true],
|
||||
// LOW 8: GitHub user/org slugs are alnum/hyphen only — no underscore.
|
||||
['Some_Org/Some.Repo-2', false],
|
||||
['', false],
|
||||
['just-a-name', false],
|
||||
['/Users/me/code/repo', false],
|
||||
['org/with spaces', false],
|
||||
['org//double', false],
|
||||
// LOW 8 additions:
|
||||
['some_org/repo', false], // underscore in owner — invalid
|
||||
['-org/repo', false], // leading hyphen — invalid
|
||||
['org-/repo', false], // trailing hyphen — GitHub rejects at account creation; we mirror that here
|
||||
['org/repo_with_underscore', true],
|
||||
['org/.dotfile', true], // repos may start with dot
|
||||
])('returns %s for %j', (id, expected) => {
|
||||
expect(isValidOwnerRepo(id as string)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripGitSuffix (BLOCKER 1 — ReDoS-safe)', () => {
|
||||
it.each([
|
||||
['https://github.com/o/r.git', 'https://github.com/o/r'],
|
||||
['https://github.com/o/r.git/', 'https://github.com/o/r'],
|
||||
['https://github.com/o/r/', 'https://github.com/o/r'],
|
||||
['https://github.com/o/r', 'https://github.com/o/r'],
|
||||
['https://github.com/o/r.GIT', 'https://github.com/o/r'],
|
||||
['https://github.com/o/r//', 'https://github.com/o/r'],
|
||||
['', ''],
|
||||
['/', ''],
|
||||
])('strips %j -> %j', (input, expected) => {
|
||||
expect(stripGitSuffix(input)).toBe(expected);
|
||||
});
|
||||
|
||||
test('linear time on adversarial trailing slashes (regression for ReDoS)', () => {
|
||||
const adversarial = 'https://github.com/o/r' + '/'.repeat(10_000);
|
||||
const start = performance.now();
|
||||
const result = stripGitSuffix(adversarial);
|
||||
const elapsed = performance.now() - start;
|
||||
expect(result).toBe('https://github.com/o/r');
|
||||
expect(elapsed).toBeLessThan(50); // generous; should be sub-millisecond
|
||||
});
|
||||
|
||||
test('parseOwnerRepoFromRemote terminates quickly on adversarial input', () => {
|
||||
const adversarial = 'https://github.com/o/r.git' + '/'.repeat(10_000);
|
||||
const start = performance.now();
|
||||
const result = parseOwnerRepoFromRemote(adversarial);
|
||||
const elapsed = performance.now() - start;
|
||||
expect(result).toBe('o/r');
|
||||
expect(elapsed).toBeLessThan(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseOwnerRepoFromRemote', () => {
|
||||
it.each([
|
||||
['git@github.com:looptech-ai/understand-quickly.git', 'looptech-ai/understand-quickly'],
|
||||
['https://github.com/looptech-ai/understand-quickly', 'looptech-ai/understand-quickly'],
|
||||
['https://github.com/looptech-ai/understand-quickly.git', 'looptech-ai/understand-quickly'],
|
||||
['ssh://git@github.com/abhigyanpatwari/GitNexus.git', 'abhigyanpatwari/GitNexus'],
|
||||
])('parses %s -> %s', (url, expected) => {
|
||||
expect(parseOwnerRepoFromRemote(url)).toBe(expected);
|
||||
});
|
||||
|
||||
// LOW 9: non-GitHub remotes must be rejected — a wrong id is worse
|
||||
// than no id, since the user can always pass --id explicitly.
|
||||
it.each([
|
||||
['https://gitlab.example.com/group/sub/project.git'],
|
||||
['git@gitlab.example.com:group/sub/project.git'],
|
||||
['https://bitbucket.org/team/repo.git'],
|
||||
])('returns null for non-GitHub host %j', (input) => {
|
||||
expect(parseOwnerRepoFromRemote(input)).toBeNull();
|
||||
});
|
||||
|
||||
it.each([null, undefined, '', ' ', 'not-a-url', 'https://github.com/'])(
|
||||
'returns null for %j',
|
||||
(input) => {
|
||||
expect(parseOwnerRepoFromRemote(input as string | null | undefined)).toBeNull();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('buildUqDispatchPayload', () => {
|
||||
it('wraps the id in the registry-expected event shape', () => {
|
||||
expect(buildUqDispatchPayload('looptech-ai/understand-quickly')).toEqual({
|
||||
event_type: 'sync-entry',
|
||||
client_payload: { id: 'looptech-ai/understand-quickly' },
|
||||
});
|
||||
});
|
||||
|
||||
it('throws on a malformed id rather than building an invalid payload', () => {
|
||||
expect(() => buildUqDispatchPayload('just-a-name')).toThrow(/owner\/repo/);
|
||||
expect(() => buildUqDispatchPayload('/Users/me/repo')).toThrow(/owner\/repo/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('publishCommand (no-token no-op)', () => {
|
||||
let tempDir: string;
|
||||
let originalToken: string | undefined;
|
||||
let exitCodeBefore: number | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-publish-test-'));
|
||||
// Simulate an existing index so hasIndex() returns true.
|
||||
await fs.mkdir(path.join(tempDir, '.gitnexus'), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(tempDir, '.gitnexus', 'meta.json'),
|
||||
JSON.stringify({ repoPath: tempDir, lastCommit: '', indexedAt: '' }),
|
||||
'utf-8',
|
||||
);
|
||||
originalToken = process.env[UNDERSTAND_QUICKLY_TOKEN_ENV];
|
||||
delete process.env[UNDERSTAND_QUICKLY_TOKEN_ENV];
|
||||
exitCodeBefore = process.exitCode;
|
||||
process.exitCode = 0;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (originalToken !== undefined) {
|
||||
process.env[UNDERSTAND_QUICKLY_TOKEN_ENV] = originalToken;
|
||||
} else {
|
||||
delete process.env[UNDERSTAND_QUICKLY_TOKEN_ENV];
|
||||
}
|
||||
process.exitCode = exitCodeBefore;
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('exits 0 without firing a network call when the token is unset', async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(() => {
|
||||
throw new Error('publishCommand should NOT call fetch when the token is missing');
|
||||
});
|
||||
|
||||
const { publishCommand } = await import('../../src/cli/publish.js');
|
||||
await publishCommand(tempDir, { id: 'looptech-ai/understand-quickly', skipGit: true });
|
||||
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(process.exitCode ?? 0).toBe(0);
|
||||
fetchSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('exits 0 with no token even when no index/repo exists (BLOCKER 2)', async () => {
|
||||
// Per the README, CLI --help, and PR body: without a token, the
|
||||
// command must be a no-op even if the repo lacks `.gitnexus/`.
|
||||
const noIndexDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-publish-noidx-'));
|
||||
try {
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(() => {
|
||||
throw new Error('publishCommand should NOT call fetch when the token is missing');
|
||||
});
|
||||
const { publishCommand } = await import('../../src/cli/publish.js');
|
||||
await publishCommand(noIndexDir, {
|
||||
id: 'looptech-ai/understand-quickly',
|
||||
skipGit: true,
|
||||
});
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(process.exitCode ?? 0).toBe(0);
|
||||
fetchSpy.mockRestore();
|
||||
} finally {
|
||||
await fs.rm(noIndexDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('publishCommand response branches (MEDIUM 5)', () => {
|
||||
let tempDir: string;
|
||||
let originalToken: string | undefined;
|
||||
let exitCodeBefore: number | undefined;
|
||||
let fetchSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-publish-resp-'));
|
||||
await fs.mkdir(path.join(tempDir, '.gitnexus'), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(tempDir, '.gitnexus', 'meta.json'),
|
||||
JSON.stringify({ repoPath: tempDir, lastCommit: '', indexedAt: '' }),
|
||||
'utf-8',
|
||||
);
|
||||
originalToken = process.env[UNDERSTAND_QUICKLY_TOKEN_ENV];
|
||||
process.env[UNDERSTAND_QUICKLY_TOKEN_ENV] = 'pat_test';
|
||||
exitCodeBefore = process.exitCode;
|
||||
process.exitCode = 0;
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (originalToken !== undefined) {
|
||||
process.env[UNDERSTAND_QUICKLY_TOKEN_ENV] = originalToken;
|
||||
} else {
|
||||
delete process.env[UNDERSTAND_QUICKLY_TOKEN_ENV];
|
||||
}
|
||||
process.exitCode = exitCodeBefore;
|
||||
vi.restoreAllMocks();
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function mockResponse(status: number, body = '') {
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
status,
|
||||
ok: status >= 200 && status < 300,
|
||||
text: async () => body,
|
||||
body: { cancel: async () => {} },
|
||||
headers: new Headers(),
|
||||
} as unknown as Response);
|
||||
}
|
||||
|
||||
it('204 → exit 0 with success message', async () => {
|
||||
mockResponse(204);
|
||||
const { publishCommand } = await import('../../src/cli/publish.js');
|
||||
await publishCommand(tempDir, {
|
||||
id: 'looptech-ai/understand-quickly',
|
||||
skipGit: true,
|
||||
});
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
expect(process.exitCode ?? 0).toBe(0);
|
||||
});
|
||||
|
||||
it('401 → exit 1 with PAT-invalid hint', async () => {
|
||||
mockResponse(401, '{"message":"Bad credentials"}');
|
||||
const { publishCommand } = await import('../../src/cli/publish.js');
|
||||
await publishCommand(tempDir, {
|
||||
id: 'looptech-ai/understand-quickly',
|
||||
skipGit: true,
|
||||
});
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it('403 → exit 1 with scope-missing hint', async () => {
|
||||
mockResponse(403, '{"message":"Resource not accessible"}');
|
||||
const { publishCommand } = await import('../../src/cli/publish.js');
|
||||
await publishCommand(tempDir, {
|
||||
id: 'looptech-ai/understand-quickly',
|
||||
skipGit: true,
|
||||
});
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it('404 → exit 1 with repo-access hint', async () => {
|
||||
mockResponse(404, '{"message":"Not Found"}');
|
||||
const { publishCommand } = await import('../../src/cli/publish.js');
|
||||
await publishCommand(tempDir, {
|
||||
id: 'looptech-ai/understand-quickly',
|
||||
skipGit: true,
|
||||
});
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it('5xx → exit 1 with raw body', async () => {
|
||||
mockResponse(503, 'gateway timeout');
|
||||
const { publishCommand } = await import('../../src/cli/publish.js');
|
||||
await publishCommand(tempDir, {
|
||||
id: 'looptech-ai/understand-quickly',
|
||||
skipGit: true,
|
||||
});
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it('network throw → exit 1', async () => {
|
||||
fetchSpy.mockRejectedValueOnce(new Error('ECONNRESET'));
|
||||
const { publishCommand } = await import('../../src/cli/publish.js');
|
||||
await publishCommand(tempDir, {
|
||||
id: 'looptech-ai/understand-quickly',
|
||||
skipGit: true,
|
||||
});
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it('TimeoutError (HIGH 4 — fetch timeout) → exit 1 with timed-out message', async () => {
|
||||
// `AbortSignal.timeout()` throws a real `DOMException` with
|
||||
// `name === 'TimeoutError'`. Faking it as `Error{name:'AbortError'}`
|
||||
// (the previous shape of this test) hid a mismatch in publish.ts —
|
||||
// the catch branch only matched 'AbortError' and the user-facing
|
||||
// "timed out" message never fired in production.
|
||||
const abort = new DOMException('The operation was aborted due to timeout', 'TimeoutError');
|
||||
fetchSpy.mockRejectedValueOnce(abort);
|
||||
const errSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
||||
const { publishCommand } = await import('../../src/cli/publish.js');
|
||||
await publishCommand(tempDir, {
|
||||
id: 'looptech-ai/understand-quickly',
|
||||
skipGit: true,
|
||||
});
|
||||
expect(process.exitCode).toBe(1);
|
||||
const written = errSpy.mock.calls.map((c) => String(c[0])).join('');
|
||||
expect(written).toMatch(/timed out/i);
|
||||
errSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('token never appears in any logged output', async () => {
|
||||
process.env[UNDERSTAND_QUICKLY_TOKEN_ENV] = 'pat_secret_value';
|
||||
mockResponse(401, '');
|
||||
const errSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
||||
const { publishCommand } = await import('../../src/cli/publish.js');
|
||||
await publishCommand(tempDir, {
|
||||
id: 'looptech-ai/understand-quickly',
|
||||
skipGit: true,
|
||||
});
|
||||
const written = errSpy.mock.calls.map((c) => String(c[0])).join('');
|
||||
expect(written).not.toContain('pat_secret_value');
|
||||
errSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
CLASS_SCHEMA,
|
||||
INTERFACE_SCHEMA,
|
||||
METHOD_SCHEMA,
|
||||
PROPERTY_SCHEMA,
|
||||
CODE_ELEMENT_SCHEMA,
|
||||
COMMUNITY_SCHEMA,
|
||||
PROCESS_SCHEMA,
|
||||
@@ -118,11 +117,6 @@ describe('LadybugDB Schema', () => {
|
||||
expect(FUNCTION_SCHEMA).toContain('isExported BOOLEAN');
|
||||
});
|
||||
|
||||
it('Property schema preserves declaredType', () => {
|
||||
expect(SCHEMA_QUERIES).toContain(PROPERTY_SCHEMA);
|
||||
expect(PROPERTY_SCHEMA).toContain('declaredType STRING');
|
||||
});
|
||||
|
||||
it('Community schema has heuristicLabel and cohesion', () => {
|
||||
expect(COMMUNITY_SCHEMA).toContain('heuristicLabel STRING');
|
||||
expect(COMMUNITY_SCHEMA).toContain('cohesion DOUBLE');
|
||||
|
||||
@@ -422,47 +422,4 @@ describe('emitCsharpScopeCaptures — references', () => {
|
||||
expect(m!['@reference.receiver'].text).toBe('obj');
|
||||
expect(m!['@reference.name'].text).toBe('Name');
|
||||
});
|
||||
|
||||
it('captures member reads `obj.Name`', () => {
|
||||
const m = findMatch('class A { void M(User obj) { var name = obj.Name; } }', (t) =>
|
||||
t.includes('@reference.read.member'),
|
||||
);
|
||||
expect(m).toBeDefined();
|
||||
expect(m!['@reference.receiver'].text).toBe('obj');
|
||||
expect(m!['@reference.name'].text).toBe('Name');
|
||||
});
|
||||
|
||||
it('does not capture member calls as member reads', () => {
|
||||
const matches = emitCsharpScopeCaptures(
|
||||
'class A { void M(User obj) { obj.Save(); } }',
|
||||
'test.cs',
|
||||
);
|
||||
expect(matches.some((m) => '@reference.call.member' in m)).toBe(true);
|
||||
expect(matches.some((m) => '@reference.read.member' in m)).toBe(false);
|
||||
});
|
||||
|
||||
it('captures generic type arguments as type references', () => {
|
||||
const matches = emitCsharpScopeCaptures(
|
||||
'class A : IEntityTypeConfiguration<USER_INFO> { public Task<List<USER_INFO>> Load(List<USER_INFO> users) => null!; }',
|
||||
'test.cs',
|
||||
);
|
||||
const names = matches
|
||||
.filter((m) => '@reference.type' in m)
|
||||
.map((m) => m['@reference.name'].text);
|
||||
|
||||
expect(names).toContain('USER_INFO');
|
||||
expect(names).not.toContain('string');
|
||||
});
|
||||
|
||||
it('captures call-site generic type arguments as type references', () => {
|
||||
const matches = emitCsharpScopeCaptures(
|
||||
'class A { void M(IRepo repo) { repo.Get<USER_INFO>(); } }',
|
||||
'test.cs',
|
||||
);
|
||||
const names = matches
|
||||
.filter((m) => '@reference.type' in m)
|
||||
.map((m) => m['@reference.name'].text);
|
||||
|
||||
expect(names).toContain('USER_INFO');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,11 +5,9 @@ import os from 'os';
|
||||
import fs from 'fs';
|
||||
|
||||
describe('--skip-git CLI flag', () => {
|
||||
const cliPath = path.resolve(__dirname, '../../dist/cli/index.js');
|
||||
|
||||
it('Commander maps --skip-git to options.skipGit (not --no-git inversion)', () => {
|
||||
// Verify the CLI defines --skip-git and --skip-agents-md in analyze help.
|
||||
const helpOutput = execSync(`node "${cliPath}" analyze --help`, {
|
||||
const helpOutput = execSync('node dist/cli/index.js analyze --help', {
|
||||
cwd: path.resolve(__dirname, '../..'),
|
||||
encoding: 'utf8',
|
||||
timeout: 10000,
|
||||
@@ -39,59 +37,8 @@ describe('--skip-git CLI flag', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('still respects .gitnexusignore when run with --skip-git', () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-skip-git-ignore-'));
|
||||
const gitnexusHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-skip-git-ignore-home-'));
|
||||
fs.mkdirSync(path.join(tmpDir, 'src'), { recursive: true });
|
||||
fs.mkdirSync(path.join(tmpDir, 'customskip'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmpDir, '.gitnexusignore'), 'customskip/\n');
|
||||
fs.writeFileSync(path.join(tmpDir, 'src', 'keep.ts'), 'export function keep() { return 1; }\n');
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'customskip', 'leaked.ts'),
|
||||
'export function leaked() { return 42; }\n',
|
||||
);
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
HOME: gitnexusHome,
|
||||
GITNEXUS_HOME: gitnexusHome,
|
||||
GITNEXUS_LBUG_EXTENSION_INSTALL: 'never',
|
||||
};
|
||||
|
||||
try {
|
||||
execSync(`node "${cliPath}" analyze "${tmpDir}" --skip-git --skip-agents-md`, {
|
||||
encoding: 'utf8',
|
||||
timeout: 60000,
|
||||
env,
|
||||
});
|
||||
|
||||
const keepContext = execSync(
|
||||
`node "${cliPath}" context keep --repo "${path.basename(tmpDir)}"`,
|
||||
{
|
||||
encoding: 'utf8',
|
||||
timeout: 60000,
|
||||
env,
|
||||
},
|
||||
);
|
||||
expect(keepContext).toContain('"status": "found"');
|
||||
expect(keepContext).toContain('"filePath": "src/keep.ts"');
|
||||
|
||||
const leakedContext = execSync(
|
||||
`node "${cliPath}" context leaked --repo "${path.basename(tmpDir)}"`,
|
||||
{
|
||||
encoding: 'utf8',
|
||||
timeout: 60000,
|
||||
env,
|
||||
},
|
||||
);
|
||||
expect(leakedContext).toContain(`"error": "Symbol 'leaked' not found"`);
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
fs.rmSync(gitnexusHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('--skip-git does not walk up to parent git repo (#1232)', () => {
|
||||
const cliPath = path.resolve(__dirname, '../../dist/cli/index.js');
|
||||
let parentDir: string;
|
||||
let gitnexusHome: string;
|
||||
|
||||
|
||||
@@ -21,112 +21,85 @@ import {
|
||||
} from '../../src/core/group/cross-impact.js';
|
||||
|
||||
/**
|
||||
* Linearity-test methodology
|
||||
* --------------------------
|
||||
* Wall-clock perf assertions in CI are notoriously flaky. To make these
|
||||
* robust without losing regression-detection power, we combine four
|
||||
* techniques:
|
||||
* Time a single regex.exec call. Used by the linearity tests below to
|
||||
* compute a 10k/5k ratio in addition to the absolute <500ms bound.
|
||||
*
|
||||
* 1. **Warmup** — run the function a few times before timing, so the
|
||||
* JIT has tiered up by the time we measure.
|
||||
* 2. **Median of N trials** — single measurements are dominated by
|
||||
* GC pauses, scheduler jitter, and OS interrupts. Median of 5
|
||||
* eliminates almost all of that.
|
||||
* 3. **4× input ratio** (not 2×) — linear → ~4×, O(n²) → ~16×,
|
||||
* catastrophic → ≫16×. A wider input ratio gives a much bigger
|
||||
* gap between "linear" and "regressed", so the bound can be loose
|
||||
* enough to absorb noise without losing signal.
|
||||
* 4. **Generous bound (8×)** with a noise floor — only assert the
|
||||
* ratio when the *large* measurement is well above the noise
|
||||
* floor. The absolute <500ms cap still catches catastrophic
|
||||
* backtracking on cold CI even when the ratio is skipped.
|
||||
* Ratio assertions catch sub-exponential O(n²) regressions that fit
|
||||
* inside the absolute cap on warm CI; the absolute cap catches
|
||||
* catastrophic backtracking on cold CI. Two complementary signals.
|
||||
*/
|
||||
function timeRegex(re: RegExp, input: string): number {
|
||||
// Reset regex.lastIndex for global/sticky regexes — ours are not, but
|
||||
// be defensive in case future shape changes add the `g` flag.
|
||||
re.lastIndex = 0;
|
||||
const start = performance.now();
|
||||
re.exec(input);
|
||||
return performance.now() - start;
|
||||
}
|
||||
|
||||
function timeFn<T>(fn: () => T): number {
|
||||
const start = performance.now();
|
||||
fn();
|
||||
return performance.now() - start;
|
||||
}
|
||||
|
||||
// Linear scaling is ~2.0× when input doubles; 3.0× allows generous
|
||||
// slack for CI-runner GC and tier-up jitter. An O(n²) regression on a
|
||||
// 2× input takes ~4× as long, well outside this bound.
|
||||
const LINEAR_RATIO_BOUND = 3.0;
|
||||
|
||||
/**
|
||||
* Minimum elapsed time (in ms) below which `performance.now()` ratios
|
||||
* are dominated by scheduler jitter and become meaningless. When both
|
||||
* timed runs come in below this floor, we skip the ratio assertion —
|
||||
* the absolute <500ms bound still catches catastrophic backtracking,
|
||||
* and the next CI run will measure higher absolute times that the
|
||||
* ratio assertion can evaluate reliably.
|
||||
*
|
||||
* Headroom: linear is expected at ~4×; the bound is 8× → 2× headroom.
|
||||
* O(n²) on a 4× input would clock 16×, well outside the bound.
|
||||
* Calibrated empirically: a flake on macOS reported ratio 5.29×
|
||||
* between two sub-millisecond measurements (~0.5ms vs ~2.6ms), both
|
||||
* genuinely linear but indistinguishable from noise. 5ms is a
|
||||
* comfortable floor where individual measurements are well-separated
|
||||
* from the ~10-100µs `performance.now()` resolution band.
|
||||
*/
|
||||
const PERF_WARMUP_RUNS = 3;
|
||||
const PERF_TRIAL_COUNT = 5;
|
||||
const SIZE_RATIO = 4;
|
||||
const LINEAR_RATIO_BOUND = SIZE_RATIO * 2; // 8× — 2× headroom over expected linear
|
||||
// Median-of-N tightens the noise floor we can rely on. A single-sample 5ms
|
||||
// measurement is ~50% jitter; median-of-5 brings the same 5ms into the
|
||||
// reliably-resolvable range above `performance.now()`'s ~10-100µs band.
|
||||
const RATIO_MEASUREMENT_FLOOR_MS = 5;
|
||||
|
||||
function median(samples: number[]): number {
|
||||
const sorted = [...samples].sort((a, b) => a - b);
|
||||
const mid = Math.floor(sorted.length / 2);
|
||||
return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
|
||||
}
|
||||
|
||||
/**
|
||||
* Median time of `PERF_TRIAL_COUNT` runs of `fn`, after `PERF_WARMUP_RUNS`
|
||||
* warmup iterations. Trial cost: (warmup + trials) × fn cost.
|
||||
* Assert linear scaling between two timed runs on inputs that differ
|
||||
* by 2×. When measurements are too small to be reliable, the ratio
|
||||
* assertion is skipped (the absolute bound still fires elsewhere).
|
||||
*/
|
||||
function medianTimeFn<T>(fn: () => T): number {
|
||||
for (let i = 0; i < PERF_WARMUP_RUNS; i++) fn();
|
||||
const samples: number[] = [];
|
||||
for (let i = 0; i < PERF_TRIAL_COUNT; i++) {
|
||||
const start = performance.now();
|
||||
fn();
|
||||
samples.push(performance.now() - start);
|
||||
}
|
||||
return median(samples);
|
||||
}
|
||||
|
||||
/** Median time of regex.exec — defensively resets lastIndex each call. */
|
||||
function medianTimeRegex(re: RegExp, input: string): number {
|
||||
return medianTimeFn(() => {
|
||||
re.lastIndex = 0;
|
||||
re.exec(input);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert near-linear scaling between two median-timed runs on inputs
|
||||
* that differ by `SIZE_RATIO`×. The bound is `LINEAR_RATIO_BOUND` =
|
||||
* `SIZE_RATIO * 2`, i.e. 2× headroom over the linear expectation —
|
||||
* comfortably under the ~`SIZE_RATIO²` ratio a quadratic regression
|
||||
* would produce, so true regressions still fail loudly.
|
||||
*
|
||||
* Skip semantics: the ratio assertion is skipped only when *both*
|
||||
* measurements are below the noise floor. If either run is reliably
|
||||
* measurable, we still assert — otherwise an O(n²) regression that
|
||||
* happens to stay under the absolute 500ms cap on a fast runner could
|
||||
* slip through with no detector firing. Median-of-N + the 5ms floor
|
||||
* keeps the assertion stable while preserving regression coverage.
|
||||
*/
|
||||
function assertNearLinearScaling(elapsedSmall: number, elapsedLarge: number, label: string): void {
|
||||
function assertSubLinearRatio(elapsedSmall: number, elapsedLarge: number, label: string): void {
|
||||
if (elapsedSmall < RATIO_MEASUREMENT_FLOOR_MS && elapsedLarge < RATIO_MEASUREMENT_FLOOR_MS) {
|
||||
// Both runs completed below the noise floor — even the median is
|
||||
// dominated by `performance.now()` resolution. The absolute <500ms
|
||||
// cap elsewhere still catches catastrophic backtracking.
|
||||
// Both runs completed faster than the noise floor — the ratio is
|
||||
// not meaningful. The absolute <500ms bound elsewhere in this
|
||||
// describe block still pins linearity; we skip rather than risk a
|
||||
// flake on a genuinely-linear implementation.
|
||||
return;
|
||||
}
|
||||
const ratio = elapsedLarge / Math.max(elapsedSmall, 0.001);
|
||||
if (ratio >= LINEAR_RATIO_BOUND) {
|
||||
throw new Error(
|
||||
`${label}: ratio ${ratio.toFixed(2)}× exceeds bound ${LINEAR_RATIO_BOUND}× ` +
|
||||
`on ${SIZE_RATIO}× input (small=${elapsedSmall.toFixed(2)}ms, ` +
|
||||
`large=${elapsedLarge.toFixed(2)}ms, median of ${PERF_TRIAL_COUNT} trials)`,
|
||||
`(small=${elapsedSmall.toFixed(2)}ms, large=${elapsedLarge.toFixed(2)}ms)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
describe('cobol-preprocessor RE_SET_TO_TRUE — linear time on pathological input', () => {
|
||||
it('matches in <500ms on 50k repetitions of "A OF A " AND scales sub-linearly on a 4× input', () => {
|
||||
// 50k → 200k (4× input ratio). Pre-fix nested-quantifier shape would
|
||||
// be exponential here; the post-fix `.+?` shape is linear (~4× when
|
||||
// input quadruples). Median of 5 trials with warmup eliminates GC
|
||||
// and tier-up jitter.
|
||||
it('matches in <500ms on 50k repetitions of "A OF A " AND 100k/50k ratio is sub-linear when measurable', () => {
|
||||
// 50k/100k repetitions chosen so timings exceed the
|
||||
// RATIO_MEASUREMENT_FLOOR_MS noise floor on typical CI hardware.
|
||||
// Pre-fix nested-quantifier shape would be exponential here; the
|
||||
// post-fix `.+?` shape is linear (~2× when input doubles).
|
||||
const inputSmall = 'SET ' + 'A OF A '.repeat(50_000) + 'TO TRUE';
|
||||
const inputLarge = 'SET ' + 'A OF A '.repeat(50_000 * SIZE_RATIO) + 'TO TRUE';
|
||||
const elapsedSmall = medianTimeRegex(RE_SET_TO_TRUE, inputSmall);
|
||||
const elapsedLarge = medianTimeRegex(RE_SET_TO_TRUE, inputLarge);
|
||||
const inputLarge = 'SET ' + 'A OF A '.repeat(100_000) + 'TO TRUE';
|
||||
const elapsedSmall = timeRegex(RE_SET_TO_TRUE, inputSmall);
|
||||
const elapsedLarge = timeRegex(RE_SET_TO_TRUE, inputLarge);
|
||||
expect(RE_SET_TO_TRUE.exec(inputSmall)).not.toBeNull();
|
||||
expect(elapsedSmall).toBeLessThan(500);
|
||||
expect(elapsedLarge).toBeLessThan(500);
|
||||
assertNearLinearScaling(elapsedSmall, elapsedLarge, 'RE_SET_TO_TRUE');
|
||||
assertSubLinearRatio(elapsedSmall, elapsedLarge, 'RE_SET_TO_TRUE');
|
||||
});
|
||||
|
||||
it('still matches a normal SET ... TO TRUE statement', () => {
|
||||
@@ -137,17 +110,17 @@ describe('cobol-preprocessor RE_SET_TO_TRUE — linear time on pathological inpu
|
||||
});
|
||||
|
||||
describe('cobol-preprocessor RE_SET_INDEX — linear time on pathological input', () => {
|
||||
it('rejects in <500ms on 50k tokens with no valid suffix AND scales sub-linearly on a 4× input', () => {
|
||||
it('rejects in <500ms on 50k tokens with no valid suffix AND 100k/50k ratio is sub-linear when measurable', () => {
|
||||
// Forces backtracking against the (TO|UP\s+BY|DOWN\s+BY) alternation
|
||||
// — the richer pathological surface of the two regexes.
|
||||
const inputSmall = 'SET ' + 'A '.repeat(50_000) + 'X';
|
||||
const inputLarge = 'SET ' + 'A '.repeat(50_000 * SIZE_RATIO) + 'X';
|
||||
const elapsedSmall = medianTimeRegex(RE_SET_INDEX, inputSmall);
|
||||
const elapsedLarge = medianTimeRegex(RE_SET_INDEX, inputLarge);
|
||||
const inputLarge = 'SET ' + 'A '.repeat(100_000) + 'X';
|
||||
const elapsedSmall = timeRegex(RE_SET_INDEX, inputSmall);
|
||||
const elapsedLarge = timeRegex(RE_SET_INDEX, inputLarge);
|
||||
expect(RE_SET_INDEX.exec(inputSmall)).toBeNull();
|
||||
expect(elapsedSmall).toBeLessThan(500);
|
||||
expect(elapsedLarge).toBeLessThan(500);
|
||||
assertNearLinearScaling(elapsedSmall, elapsedLarge, 'RE_SET_INDEX');
|
||||
assertSubLinearRatio(elapsedSmall, elapsedLarge, 'RE_SET_INDEX');
|
||||
});
|
||||
|
||||
it('still matches a normal SET INDEX statement', () => {
|
||||
@@ -160,22 +133,22 @@ describe('cobol-preprocessor RE_SET_INDEX — linear time on pathological input'
|
||||
});
|
||||
|
||||
describe('rust-workspace parseCargoPackageName — linear-time line walk', () => {
|
||||
it('extracts the package name in <500ms on 100k blank lines AND scales sub-linearly on a 4× input', () => {
|
||||
// 100k → 400k blank lines (4× input ratio). Median of 5 trials with
|
||||
// warmup keeps the ratio stable across CI runners. A previous 2×
|
||||
// input + 3× bound + single-trial setup flaked at 3.01× on macOS
|
||||
// (small=7.41ms, large=22.31ms) — both above the noise floor but
|
||||
// close enough that single-shot jitter pushed the ratio over.
|
||||
it('extracts the package name in <500ms on 100k blank lines AND 200k/100k ratio is sub-linear when measurable', () => {
|
||||
// 100k/200k blank lines chosen so timings exceed the
|
||||
// RATIO_MEASUREMENT_FLOOR_MS noise floor. Earlier 10k/20k pairing
|
||||
// produced sub-millisecond measurements where scheduler jitter
|
||||
// dominated and the ratio became meaningless (a real macOS run
|
||||
// saw 5.29× between two genuinely-linear sub-ms measurements).
|
||||
const cargoTomlSmall =
|
||||
'[package]\n' + '\n'.repeat(100_000) + 'name = "myrepo"\nversion = "0.1.0"\n';
|
||||
const cargoTomlLarge =
|
||||
'[package]\n' + '\n'.repeat(100_000 * SIZE_RATIO) + 'name = "myrepo"\nversion = "0.1.0"\n';
|
||||
const elapsedSmall = medianTimeFn(() => parseCargoPackageName(cargoTomlSmall));
|
||||
const elapsedLarge = medianTimeFn(() => parseCargoPackageName(cargoTomlLarge));
|
||||
'[package]\n' + '\n'.repeat(200_000) + 'name = "myrepo"\nversion = "0.1.0"\n';
|
||||
const elapsedSmall = timeFn(() => parseCargoPackageName(cargoTomlSmall));
|
||||
const elapsedLarge = timeFn(() => parseCargoPackageName(cargoTomlLarge));
|
||||
expect(parseCargoPackageName(cargoTomlSmall)).toBe('myrepo');
|
||||
expect(elapsedSmall).toBeLessThan(500);
|
||||
expect(elapsedLarge).toBeLessThan(500);
|
||||
assertNearLinearScaling(elapsedSmall, elapsedLarge, 'parseCargoPackageName');
|
||||
assertSubLinearRatio(elapsedSmall, elapsedLarge, 'parseCargoPackageName');
|
||||
});
|
||||
|
||||
it('returns null when [package] section is absent', () => {
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
isAzureProvider,
|
||||
isReasoningModel,
|
||||
buildRequestUrl,
|
||||
validateLLMBaseUrl,
|
||||
} from '../../src/core/wiki/llm-client.js';
|
||||
|
||||
describe('isAzureProvider', () => {
|
||||
@@ -331,88 +330,3 @@ describe('readSSEStream — content_filter handling', () => {
|
||||
).rejects.toThrow('content filter');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateLLMBaseUrl', () => {
|
||||
it('allows https:// for any public host', () => {
|
||||
expect(() => validateLLMBaseUrl('https://api.openai.com/v1')).not.toThrow();
|
||||
expect(() => validateLLMBaseUrl('https://openrouter.ai/api/v1')).not.toThrow();
|
||||
expect(() => validateLLMBaseUrl('https://myres.openai.azure.com/openai/v1')).not.toThrow();
|
||||
});
|
||||
|
||||
it('allows http:// for localhost', () => {
|
||||
expect(() => validateLLMBaseUrl('http://localhost:11434/v1')).not.toThrow();
|
||||
expect(() => validateLLMBaseUrl('http://127.0.0.1:11434/v1')).not.toThrow();
|
||||
// IPv6 loopback — Node's URL parser preserves brackets in hostname: "[::1]"
|
||||
expect(() => validateLLMBaseUrl('http://[::1]:11434/v1')).not.toThrow();
|
||||
});
|
||||
|
||||
it('allows http:// for LOCALHOST (uppercase) — lowercased before comparison', () => {
|
||||
expect(() => validateLLMBaseUrl('http://LOCALHOST:11434/v1')).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects http:// for non-loopback hosts', () => {
|
||||
expect(() => validateLLMBaseUrl('http://evil.example.com/v1')).toThrow('Insecure http://');
|
||||
expect(() => validateLLMBaseUrl('http://192.168.1.1/v1')).toThrow('Insecure http://');
|
||||
// Private IP ranges
|
||||
expect(() => validateLLMBaseUrl('http://10.0.0.1/v1')).toThrow('Insecure http://');
|
||||
// AWS/GCP IMDS — should be blocked
|
||||
expect(() => validateLLMBaseUrl('http://169.254.169.254/latest/meta-data')).toThrow(
|
||||
'Insecure http://',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects http:// hostname-spoofing attempts', () => {
|
||||
// Full-hostname comparison prevents prefix/suffix attacks
|
||||
expect(() => validateLLMBaseUrl('http://localhost.evil.com/v1')).toThrow('Insecure http://');
|
||||
expect(() => validateLLMBaseUrl('http://127.0.0.1.evil.com/v1')).toThrow('Insecure http://');
|
||||
// Trailing dot — hostname 'localhost.' ≠ 'localhost'
|
||||
expect(() => validateLLMBaseUrl('http://localhost./v1')).toThrow('Insecure http://');
|
||||
});
|
||||
|
||||
it('rejects http:// non-loopback IPv6 addresses', () => {
|
||||
// Link-local IPv6
|
||||
expect(() => validateLLMBaseUrl('http://[fe80::1]/v1')).toThrow('Insecure http://');
|
||||
// IPv4-mapped IPv6 loopback — bracket-stripped to '::ffff:127.0.0.1' ≠ '::1'
|
||||
expect(() => validateLLMBaseUrl('http://[::ffff:127.0.0.1]/v1')).toThrow('Insecure http://');
|
||||
});
|
||||
|
||||
it('rejects non-http schemes', () => {
|
||||
expect(() => validateLLMBaseUrl('file:///etc/passwd')).toThrow('must use http:// or https://');
|
||||
expect(() => validateLLMBaseUrl('javascript:alert(1)')).toThrow('must use http:// or https://');
|
||||
expect(() => validateLLMBaseUrl('data:text/plain,evil')).toThrow(
|
||||
'must use http:// or https://',
|
||||
);
|
||||
expect(() => validateLLMBaseUrl('ftp://example.com')).toThrow('must use http:// or https://');
|
||||
});
|
||||
|
||||
it('rejects malformed URLs', () => {
|
||||
expect(() => validateLLMBaseUrl('not-a-url')).toThrow('Invalid LLM base URL');
|
||||
expect(() => validateLLMBaseUrl('')).toThrow('Invalid LLM base URL');
|
||||
});
|
||||
|
||||
it('does not include the raw URL in error messages (credential hygiene)', () => {
|
||||
// Simulates a URL with an embedded API key
|
||||
const urlWithCreds = 'http://192.168.1.1/v1?apikey=sk-secret';
|
||||
let msg = '';
|
||||
try {
|
||||
validateLLMBaseUrl(urlWithCreds);
|
||||
} catch (e) {
|
||||
msg = (e as Error).message;
|
||||
}
|
||||
expect(msg).not.toContain('sk-secret');
|
||||
expect(msg).not.toContain(urlWithCreds);
|
||||
});
|
||||
|
||||
it('callLLM rejects an invalid base URL before fetching', async () => {
|
||||
const { callLLM } = await import('../../src/core/wiki/llm-client.js');
|
||||
await expect(
|
||||
callLLM('prompt', {
|
||||
apiKey: 'key',
|
||||
baseUrl: 'file:///etc/passwd',
|
||||
model: 'gpt-4o',
|
||||
maxTokens: 100,
|
||||
temperature: 0,
|
||||
}),
|
||||
).rejects.toThrow('must use http:// or https://');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user