Compare commits
96
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20114bb099 | ||
|
|
507f84b69a | ||
|
|
38ff7365e8 | ||
|
|
a9d72e2dbf | ||
|
|
4cc4e9c84b | ||
|
|
48cd55a120 | ||
|
|
e8c8ddec8a | ||
|
|
ec4624af87 | ||
|
|
aed6cfc7ea | ||
|
|
6a23616873 | ||
|
|
7637bd1c83 | ||
|
|
8083c39f6d | ||
|
|
a2f1b07700 | ||
|
|
0daae93701 | ||
|
|
4fa40e9881 | ||
|
|
d4f34905bc | ||
|
|
f33efa8714 | ||
|
|
2bf6d078aa | ||
|
|
ba1ac3989d | ||
|
|
c5c42a9649 | ||
|
|
a445d38e0b | ||
|
|
0e2c0c77ec | ||
|
|
fcab1e2e82 | ||
|
|
fdf1effb2a | ||
|
|
622f98ade5 | ||
|
|
55b7a79beb | ||
|
|
a3af0dcce2 | ||
|
|
6a8947217c | ||
|
|
e412d292fe | ||
|
|
d69eadfb7f | ||
|
|
2620b704e0 | ||
|
|
5d670a530d | ||
|
|
4848dce9ea | ||
|
|
936a3bed6f | ||
|
|
874d2aa95b | ||
|
|
262ad8b859 | ||
|
|
a2517e050d | ||
|
|
a5c582f547 | ||
|
|
6f1cfffdd7 | ||
|
|
666041d608 | ||
|
|
e02c56f653 | ||
|
|
6906be3695 | ||
|
|
152a0506c9 | ||
|
|
248cb1e634 | ||
|
|
f26a35b17f | ||
|
|
b5627f27d8 | ||
|
|
3daf8c9984 | ||
|
|
d91428ad9d | ||
|
|
32b5c0e3fc | ||
|
|
30e0c7c726 | ||
|
|
98addbd6c4 | ||
|
|
f29147864e | ||
|
|
b89ec5b5df | ||
|
|
5bfe0c5222 | ||
|
|
5497079ab2 | ||
|
|
1d46200c47 | ||
|
|
8ca9cb1a4d | ||
|
|
927a17264d | ||
|
|
c8a1ecf69d | ||
|
|
9d015164a5 | ||
|
|
296a571263 | ||
|
|
0824b96d15 | ||
|
|
d3a7ce95a5 | ||
|
|
4cd3ee3832 | ||
|
|
f40973a1ca | ||
|
|
4e362ba70a | ||
|
|
48f15a3bca | ||
|
|
8e76729750 | ||
|
|
fd4d4a3fee | ||
|
|
c8683d58fc | ||
|
|
de63418f7e | ||
|
|
7639308f65 | ||
|
|
68e4a5aece | ||
|
|
84564e09b8 | ||
|
|
bc98239fb4 | ||
|
|
608be7655d | ||
|
|
b486d04d75 | ||
|
|
28df98c997 | ||
|
|
96578aa4a8 | ||
|
|
a418c47e29 | ||
|
|
05ca80ea30 | ||
|
|
e55256ba53 | ||
|
|
9d91530f94 | ||
|
|
46e4c979c4 | ||
|
|
8fc32e6af9 | ||
|
|
e60e62f193 | ||
|
|
816ae5e66e | ||
|
|
4048f53e35 | ||
|
|
027340292f | ||
|
|
cbe5dac8b7 | ||
|
|
bf11269260 | ||
|
|
f10135649e | ||
|
|
3732fa1e21 | ||
|
|
0add072f25 | ||
|
|
ed4dad2129 | ||
|
|
0844973f52 |
@@ -1,5 +1,5 @@
|
||||
name: Setup GitNexus Web
|
||||
description: Setup Node.js 20.19+ (vite 7 floor), build gitnexus-shared, install web dependencies
|
||||
description: Setup Node.js 22, build gitnexus-shared, install web dependencies
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
@@ -7,9 +7,7 @@ runs:
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
# Vite 7 requires Node ^20.19.0 || >=22.12.0 (require(esm) support).
|
||||
# Pin explicitly so we don't depend on the floating "20" alias resolving
|
||||
# to a high enough patch version on every runner image.
|
||||
node-version: '20.19.0'
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: gitnexus-web/package-lock.json
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: Setup GitNexus
|
||||
description: Setup Node.js 20, install dependencies, and optionally build
|
||||
description: Setup Node.js 22, install dependencies, and optionally build
|
||||
|
||||
inputs:
|
||||
build:
|
||||
@@ -12,7 +12,7 @@ runs:
|
||||
steps:
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: gitnexus/package-lock.json
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ updates:
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
cooldown:
|
||||
default-days: 7
|
||||
open-pull-requests-limit: 5
|
||||
commit-message:
|
||||
prefix: chore
|
||||
@@ -15,6 +17,36 @@ updates:
|
||||
- dependencies
|
||||
- ci
|
||||
|
||||
# Keep pinned Docker base-image digests current for the root Dockerfiles.
|
||||
- package-ecosystem: docker
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
cooldown:
|
||||
default-days: 7
|
||||
open-pull-requests-limit: 5
|
||||
commit-message:
|
||||
prefix: chore(deps)
|
||||
include: scope
|
||||
labels:
|
||||
- dependencies
|
||||
- ci
|
||||
|
||||
# Keep the nested test-image Docker base digest current as well.
|
||||
- package-ecosystem: docker
|
||||
directory: /gitnexus
|
||||
schedule:
|
||||
interval: weekly
|
||||
cooldown:
|
||||
default-days: 7
|
||||
open-pull-requests-limit: 5
|
||||
commit-message:
|
||||
prefix: chore(deps)
|
||||
include: scope
|
||||
labels:
|
||||
- dependencies
|
||||
- ci
|
||||
|
||||
# Gitnexus npm deps — tree-sitter grammars checked daily so we catch
|
||||
# new releases that unblock the tree-sitter 0.25 upgrade ASAP. Grammars
|
||||
# are grouped so lockstep bumps produce a single PR. The tree-sitter
|
||||
@@ -25,6 +57,11 @@ updates:
|
||||
directory: /gitnexus
|
||||
schedule:
|
||||
interval: daily
|
||||
cooldown:
|
||||
default-days: 7
|
||||
semver-major-days: 30
|
||||
semver-minor-days: 7
|
||||
semver-patch-days: 3
|
||||
open-pull-requests-limit: 10
|
||||
commit-message:
|
||||
prefix: chore(deps)
|
||||
@@ -54,6 +91,11 @@ updates:
|
||||
directory: /gitnexus-web
|
||||
schedule:
|
||||
interval: weekly
|
||||
cooldown:
|
||||
default-days: 7
|
||||
semver-major-days: 30
|
||||
semver-minor-days: 7
|
||||
semver-patch-days: 3
|
||||
open-pull-requests-limit: 5
|
||||
commit-message:
|
||||
prefix: chore(deps)
|
||||
@@ -67,6 +109,11 @@ updates:
|
||||
directory: /gitnexus-shared
|
||||
schedule:
|
||||
interval: weekly
|
||||
cooldown:
|
||||
default-days: 7
|
||||
semver-major-days: 30
|
||||
semver-minor-days: 7
|
||||
semver-patch-days: 3
|
||||
open-pull-requests-limit: 5
|
||||
commit-message:
|
||||
prefix: chore(deps)
|
||||
|
||||
@@ -32,6 +32,7 @@ import pathlib
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
REPO_ROOT = pathlib.Path(__file__).resolve().parents[2]
|
||||
@@ -190,7 +191,17 @@ def fetch_text(url: str, timeout: int = 8) -> str | None:
|
||||
set (raises the rate limit from 60 to 5 000 requests/hour).
|
||||
"""
|
||||
headers: dict[str, str] = {}
|
||||
if _GITHUB_TOKEN and ("github.com" in url or "githubusercontent.com" in url):
|
||||
# Parse the URL and check the hostname rather than substring-matching
|
||||
# on the full URL string (CodeQL py/incomplete-url-substring-sanitization).
|
||||
# `https://evil.com/?u=github.com` would have passed the substring check.
|
||||
try:
|
||||
parsed_host = urllib.parse.urlparse(url).hostname or ""
|
||||
except ValueError:
|
||||
parsed_host = ""
|
||||
is_github_host = parsed_host == "github.com" or parsed_host.endswith(
|
||||
(".github.com", ".githubusercontent.com")
|
||||
) or parsed_host == "githubusercontent.com"
|
||||
if _GITHUB_TOKEN and is_github_host:
|
||||
headers["Authorization"] = f"Bearer {_GITHUB_TOKEN}"
|
||||
try:
|
||||
req = urllib.request.Request(url, headers=headers)
|
||||
|
||||
@@ -3,6 +3,9 @@ name: E2E Tests
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check-changes:
|
||||
name: Check web module changes
|
||||
|
||||
@@ -3,6 +3,9 @@ name: Quality Checks
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
format:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -11,7 +14,7 @@ jobs:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: package-lock.json
|
||||
- run: npm ci
|
||||
@@ -24,7 +27,7 @@ jobs:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: package-lock.json
|
||||
- run: npm ci
|
||||
|
||||
@@ -95,31 +95,33 @@ jobs:
|
||||
|
||||
# Validate PR number is a positive integer (artifact comes from
|
||||
# untrusted fork code, so treat contents defensively).
|
||||
PR_NUM=$(cat "$DIR/pr_number" | tr -d '[:space:]')
|
||||
PR_NUM=$(tr -d '[:space:]' < "$DIR/pr_number")
|
||||
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=$(cat "$1" | tr -d '[:space:]')
|
||||
val=$(tr -d '[:space:]' < "$1")
|
||||
case "$val" in
|
||||
success|failure|cancelled|skipped) echo "$val" ;;
|
||||
*) echo "unknown" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
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"
|
||||
{
|
||||
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"
|
||||
|
||||
- name: Checkout (for vitest config)
|
||||
if: steps.meta.outputs.skip != 'true'
|
||||
@@ -138,14 +140,15 @@ jobs:
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Find the latest successful CI run on main
|
||||
// Find recent successful CI runs on main (check several in case
|
||||
// the most recent artifact has expired).
|
||||
const runs = await github.rest.actions.listWorkflowRuns({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: 'ci.yml',
|
||||
branch: 'main',
|
||||
status: 'success',
|
||||
per_page: 1,
|
||||
per_page: 5,
|
||||
});
|
||||
|
||||
if (runs.data.workflow_runs.length === 0) {
|
||||
@@ -154,32 +157,47 @@ jobs:
|
||||
return;
|
||||
}
|
||||
|
||||
const mainRunId = runs.data.workflow_runs[0].id;
|
||||
const artifacts = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: mainRunId,
|
||||
});
|
||||
// Try each run until we find a downloadable test-reports artifact
|
||||
for (const run of runs.data.workflow_runs) {
|
||||
const artifacts = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: run.id,
|
||||
});
|
||||
|
||||
const testReports = artifacts.data.artifacts.find(a => a.name === 'test-reports');
|
||||
if (!testReports) {
|
||||
core.setOutput('found', 'false');
|
||||
core.info('No test-reports artifact on main branch');
|
||||
return;
|
||||
const testReports = artifacts.data.artifacts.find(a => a.name === 'test-reports');
|
||||
if (!testReports) {
|
||||
core.info(`Run ${run.id}: no test-reports artifact, trying next`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const zip = await github.rest.actions.downloadArtifact({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
artifact_id: testReports.id,
|
||||
archive_format: 'zip',
|
||||
});
|
||||
|
||||
const dest = path.join(process.env.RUNNER_TEMP, 'base-coverage');
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
fs.writeFileSync(path.join(dest, 'base.zip'), Buffer.from(zip.data));
|
||||
core.setOutput('found', 'true');
|
||||
core.setOutput('dir', dest);
|
||||
return;
|
||||
} catch (err) {
|
||||
// 410 Gone means the artifact expired; try the next run
|
||||
if (err.status === 410 || err.response?.status === 410) {
|
||||
core.info(`Run ${run.id}: artifact expired, trying next`);
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const zip = await github.rest.actions.downloadArtifact({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
artifact_id: testReports.id,
|
||||
archive_format: 'zip',
|
||||
});
|
||||
|
||||
const dest = path.join(process.env.RUNNER_TEMP, 'base-coverage');
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
fs.writeFileSync(path.join(dest, 'base.zip'), Buffer.from(zip.data));
|
||||
core.setOutput('found', 'true');
|
||||
core.setOutput('dir', dest);
|
||||
// All attempts exhausted — no usable base coverage
|
||||
core.setOutput('found', 'false');
|
||||
core.info('No downloadable test-reports artifact found on main (all expired or missing)');
|
||||
|
||||
- name: Extract base coverage
|
||||
if: steps.meta.outputs.skip != 'true' && steps.base-coverage.outputs.found == 'true'
|
||||
@@ -234,7 +252,7 @@ jobs:
|
||||
printf -v "${prefix}_BRANCH_COV" '%s' ""
|
||||
printf -v "${prefix}_FUNCS_COV" '%s' ""
|
||||
printf -v "${prefix}_LINES_COV" '%s' ""
|
||||
return 1
|
||||
return 0
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -263,14 +281,17 @@ jobs:
|
||||
fi
|
||||
}
|
||||
|
||||
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")"
|
||||
# `_` 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")"
|
||||
|
||||
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,6 +28,9 @@ name: Scope Resolution Parity
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
discover:
|
||||
name: Discover migrated languages
|
||||
|
||||
@@ -3,6 +3,9 @@ name: Tests
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
name: ubuntu / coverage
|
||||
|
||||
@@ -6,6 +6,9 @@ on:
|
||||
paths-ignore: ['**.md', 'docs/**', 'LICENSE']
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention".
|
||||
# Hardcoded `CI-` prefix (not `${{ github.workflow }}`) because this workflow is
|
||||
# invoked as a reusable workflow from publish.yml and release-candidate.yml. In
|
||||
|
||||
@@ -19,6 +19,9 @@ on:
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention".
|
||||
# Serialize per-PR/issue to avoid racing comments.
|
||||
concurrency:
|
||||
@@ -155,6 +158,8 @@ jobs:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
allowed_non_write_users: '*'
|
||||
show_full_output: true
|
||||
# Review posts use Bash (`gh`, etc.); default mode asks for approval — impossible in CI.
|
||||
claude_args: '--dangerously-skip-permissions'
|
||||
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
|
||||
plugins: 'code-review@claude-code-plugins'
|
||||
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ steps.pr.outputs.number }}'
|
||||
prompt: '/code-review:code-review https://github.com/${{ github.repository }}/pull/${{ steps.pr.outputs.number }} --comment'
|
||||
|
||||
@@ -17,6 +17,9 @@ on:
|
||||
# already-merged code without waiting for the next PR.
|
||||
- cron: '0 6 * * 1'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
@@ -45,7 +48,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@0daab03d71ff584ef619d027a3fd9146679c5d84 # v3.35.3
|
||||
uses: github/codeql-action/init@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: security-and-quality
|
||||
@@ -66,6 +69,6 @@ jobs:
|
||||
- '**/test/fixtures/**'
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@0daab03d71ff584ef619d027a3fd9146679c5d84 # v3.35.3
|
||||
uses: github/codeql-action/analyze@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
|
||||
with:
|
||||
category: '/language:${{ matrix.language }}'
|
||||
|
||||
@@ -10,6 +10,9 @@ on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
@@ -26,6 +26,9 @@ on:
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention".
|
||||
# Tag refs are unique per release, so distinct tags run in parallel.
|
||||
# Re-pushes of the same tag serialize. cancel-in-progress: false — never cancel a publish mid-flight.
|
||||
|
||||
@@ -12,6 +12,9 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
@@ -0,0 +1,595 @@
|
||||
name: PR Autofix (apply)
|
||||
|
||||
# CHATOPS HALF of the autofix pipeline.
|
||||
#
|
||||
# Triggered when a contributor comments `/autofix` on a PR. Validates
|
||||
# permission, locates the most recent successful `pr-autofix.yml`
|
||||
# artifact for the PR's current head SHA, applies the patch to the PR
|
||||
# head, and pushes a commit back to the PR branch.
|
||||
#
|
||||
# This workflow runs from the default branch's copy of the file
|
||||
# regardless of where the comment originates -- that's the trust
|
||||
# anchor. Comment body and author login are untrusted; both flow
|
||||
# through env vars and pattern-matched, never interpolated into shell.
|
||||
#
|
||||
# Fork PR support: `git push` with the GITHUB_TOKEN succeeds against
|
||||
# fork branches only when the contributor enabled "Allow edits by
|
||||
# maintainers" on the PR (the default). When they disabled it, we
|
||||
# fail loud with a 👎 reaction and an explanation comment.
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
concurrency:
|
||||
# Per-PR scope. issue_comment events expose `github.event.issue.number`
|
||||
# for both PR and Issue comments; the `pull_request != null` guard on
|
||||
# the job ensures we only run on PRs, so this number is the PR number.
|
||||
# cancel-in-progress: false — a second `/autofix` should wait for the
|
||||
# first to finish (idempotency check on the second invocation handles
|
||||
# the no-op case).
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.issue.number }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
apply:
|
||||
name: apply-autofix
|
||||
# Pre-filter at the workflow level so non-PR comments and unrelated
|
||||
# comments don't even spawn a runner. The job-level body re-check
|
||||
# below (Step 1) is the strict gate.
|
||||
if: >-
|
||||
github.event.issue.pull_request != null
|
||||
&& startsWith(github.event.comment.body, '/autofix')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
# React on the triggering comment + post reply comments.
|
||||
pull-requests: write
|
||||
# Push the apply commit to the PR head branch.
|
||||
contents: write
|
||||
# Required by actions/download-artifact to fetch artifacts produced
|
||||
# by a different workflow run.
|
||||
actions: read
|
||||
steps:
|
||||
- name: Validate comment body precisely
|
||||
id: body
|
||||
env:
|
||||
BODY: ${{ github.event.comment.body }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Whole-line, case-sensitive match: `^/autofix\s*$`. The
|
||||
# workflow-level startsWith guard is coarse — `please don't
|
||||
# /autofix this code` would pass that filter but fail this one.
|
||||
# We exit silently (no reaction) on body mismatch so quoted
|
||||
# text in unrelated discussions doesn't get a visible response.
|
||||
if [[ ! "${BODY}" =~ ^/autofix[[:space:]]*$ ]]; then
|
||||
echo "Body did not match strict /autofix regex — exiting silently."
|
||||
echo "match=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "match=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Validate commenter permission
|
||||
id: perm
|
||||
if: steps.body.outputs.match == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
COMMENTER: ${{ github.event.comment.user.login }}
|
||||
PR_AUTHOR: ${{ github.event.issue.user.login }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Retry wrapper for transient 5xx / 429 / network blips.
|
||||
# Mirrors the helper in pr-autofix-publish.yml. Used on
|
||||
# idempotent GETs only; reactions/comment-POSTs are NOT
|
||||
# wrapped (retrying a POST would dupe the resource).
|
||||
gh_retry() {
|
||||
local n=0 max=3
|
||||
while true; do
|
||||
if gh "$@"; then return 0; fi
|
||||
n=$((n+1))
|
||||
if [ "$n" -ge "$max" ]; then return 1; fi
|
||||
sleep $((n * 2))
|
||||
done
|
||||
}
|
||||
|
||||
# Allowlist the commenter login before it flows into a URL.
|
||||
# GitHub usernames: alphanumeric + dashes, max 39 chars.
|
||||
if ! [[ "${COMMENTER}" =~ ^[A-Za-z0-9-]{1,39}$ ]]; then
|
||||
echo "::error::Invalid commenter login format: $(printf '%q' "${COMMENTER}")"
|
||||
echo "allowed=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Self-comparison: PR author can always /autofix their own PR.
|
||||
if [ "${COMMENTER}" = "${PR_AUTHOR}" ]; then
|
||||
echo "Commenter is PR author — granting access."
|
||||
echo "allowed=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Repo permission lookup. admin/write/maintain are sufficient.
|
||||
# Distinguish API failure (5xx, 429, network) from genuine
|
||||
# permission denial (404 = not a collaborator). Conflating them
|
||||
# would silently refuse a legitimate maintainer with a public
|
||||
# 👎 every time GitHub blips. gh_retry handles transient blips;
|
||||
# the stderr-grep distinguishes 404 from persistent failure.
|
||||
perm_stderr=$(mktemp)
|
||||
if permission=$(gh_retry api "repos/${GH_REPO}/collaborators/${COMMENTER}/permission" \
|
||||
--jq '.permission' 2>"$perm_stderr"); then
|
||||
echo "Commenter permission: ${permission}"
|
||||
case "${permission}" in
|
||||
admin|write|maintain)
|
||||
echo "allowed=true" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
*)
|
||||
echo "allowed=false" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
esac
|
||||
else
|
||||
err=$(cat "$perm_stderr")
|
||||
echo "Permission lookup stderr: ${err}" >&2
|
||||
# 404 (not a collaborator) is a genuine deny.
|
||||
# Anything else is a transient API/network failure.
|
||||
if grep -qE "HTTP 404|Not Found" "$perm_stderr"; then
|
||||
echo "allowed=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "::error::Permission lookup failed transiently — refusing to act."
|
||||
echo "allowed=api-failed" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: React 😕 on transient permission-API failure
|
||||
if: steps.body.outputs.match == 'true' && steps.perm.outputs.allowed == 'api-failed'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
COMMENT_ID: ${{ github.event.comment.id }}
|
||||
PR: ${{ github.event.issue.number }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
|
||||
-f content="confused" >/dev/null
|
||||
gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
|
||||
-f body="⚠️ Couldn't verify your repo permission (transient GitHub API failure). Please comment \`/autofix\` again. ([apply run](https://github.com/${GH_REPO}/actions/runs/${RUN_ID}))" \
|
||||
>/dev/null
|
||||
exit 1
|
||||
|
||||
- name: React 👎 on permission denial
|
||||
if: steps.body.outputs.match == 'true' && steps.perm.outputs.allowed == 'false'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
COMMENT_ID: ${{ github.event.comment.id }}
|
||||
PR: ${{ github.event.issue.number }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
|
||||
-f content="-1" >/dev/null
|
||||
gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
|
||||
-f body="🚫 \`/autofix\` is restricted to users with write access or the PR author. Comment ignored." \
|
||||
>/dev/null
|
||||
# Hard exit so the rest of the job is skipped.
|
||||
exit 1
|
||||
|
||||
- name: React 👀 to acknowledge
|
||||
if: steps.perm.outputs.allowed == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
COMMENT_ID: ${{ github.event.comment.id }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
|
||||
-f content="eyes" >/dev/null
|
||||
|
||||
- name: Resolve PR head and locate autofix run
|
||||
id: locate
|
||||
if: steps.perm.outputs.allowed == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
PR: ${{ github.event.issue.number }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Same retry wrapper used in the permission step, repeated
|
||||
# because each YAML `run:` block is a fresh bash session.
|
||||
gh_retry() {
|
||||
local n=0 max=3
|
||||
while true; do
|
||||
if gh "$@"; then return 0; fi
|
||||
n=$((n+1))
|
||||
if [ "$n" -ge "$max" ]; then return 1; fi
|
||||
sleep $((n * 2))
|
||||
done
|
||||
}
|
||||
|
||||
# Fetch PR metadata. All fields here are server-controlled API
|
||||
# output, but we still allowlist before exporting so anything
|
||||
# weird short-circuits before $GITHUB_OUTPUT. Wrapped in
|
||||
# gh_retry so transient blips don't surface as "no autofix run
|
||||
# found" with a wrong remediation.
|
||||
if ! pr_json=$(gh_retry api "repos/${GH_REPO}/pulls/${PR}"); then
|
||||
echo "::error::PR metadata fetch failed after retries."
|
||||
echo "found_status=api-failed" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
head_sha=$(jq -r '.head.sha' <<< "${pr_json}")
|
||||
head_ref=$(jq -r '.head.ref' <<< "${pr_json}")
|
||||
head_repo=$(jq -r '.head.repo.full_name' <<< "${pr_json}")
|
||||
|
||||
[[ "${head_sha}" =~ ^[0-9a-f]{40}$ ]] || { echo "::error::Bad head_sha"; exit 1; }
|
||||
[[ "${head_ref}" =~ ^[A-Za-z0-9._/-]+$ ]] || { echo "::error::Bad head_ref"; exit 1; }
|
||||
[[ "${head_repo}" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]] || { echo "::error::Bad head_repo"; exit 1; }
|
||||
|
||||
# Find the latest successful pr-autofix.yml run for this head SHA.
|
||||
if ! runs_json=$(gh_retry api "repos/${GH_REPO}/actions/workflows/pr-autofix.yml/runs?head_sha=${head_sha}&per_page=10"); then
|
||||
echo "::error::Workflow run lookup failed after retries."
|
||||
echo "found_status=api-failed" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
run_id=$(jq -r '[.workflow_runs[] | select(.conclusion == "success")] | .[0].id // empty' <<< "${runs_json}")
|
||||
|
||||
if [ -n "${run_id}" ] && [[ "${run_id}" =~ ^[0-9]+$ ]]; then
|
||||
echo "found_status=success" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "found=true"
|
||||
echo "head_sha=${head_sha}"
|
||||
echo "head_ref=${head_ref}"
|
||||
echo "head_repo=${head_repo}"
|
||||
echo "run_id=${run_id}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# No successful run. Distinguish "still running" (producer in
|
||||
# flight after a recent push) from "never ran / all failed".
|
||||
# in_progress / queued / pending / waiting cover the GitHub
|
||||
# workflow-run lifecycle states that precede success/failure.
|
||||
in_progress=$(jq -r '[.workflow_runs[] | select(.status == "in_progress" or .status == "queued" or .status == "pending" or .status == "waiting")] | length' <<< "${runs_json}")
|
||||
if [ "${in_progress:-0}" -gt 0 ]; then
|
||||
echo "::warning::pr-autofix run is still in progress for head ${head_sha}."
|
||||
echo "found_status=in-progress" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "::warning::No successful pr-autofix run found for head ${head_sha}."
|
||||
echo "found_status=not-found" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
# Existing `found` boolean is preserved so downstream gates
|
||||
# (`steps.locate.outputs.found == 'true'`) still work.
|
||||
echo "found=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Reply when locate did not yield a usable run
|
||||
if: steps.perm.outputs.allowed == 'true' && steps.locate.outputs.found != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
COMMENT_ID: ${{ github.event.comment.id }}
|
||||
PR: ${{ github.event.issue.number }}
|
||||
FOUND_STATUS: ${{ steps.locate.outputs.found_status }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
run_url="https://github.com/${GH_REPO}/actions/runs/${RUN_ID}"
|
||||
case "${FOUND_STATUS}" in
|
||||
in-progress)
|
||||
gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
|
||||
-f content="confused" >/dev/null
|
||||
gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
|
||||
-f body="⏳ A pr-autofix run is still in progress for this PR's current head SHA. Wait for it to finish, then comment \`/autofix\` again. ([apply run](${run_url}))" \
|
||||
>/dev/null
|
||||
;;
|
||||
api-failed)
|
||||
gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
|
||||
-f content="confused" >/dev/null
|
||||
gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
|
||||
-f body="⚠️ Couldn't reach the GitHub API to look up the autofix run (transient failure after retries). Please comment \`/autofix\` again. ([apply run](${run_url}))" \
|
||||
>/dev/null
|
||||
;;
|
||||
*)
|
||||
gh api -X POST "repos/${GH_REPO}/issues/comments/${COMMENT_ID}/reactions" \
|
||||
-f content="-1" >/dev/null
|
||||
gh api -X POST "repos/${GH_REPO}/issues/${PR}/comments" \
|
||||
-f body="🤔 No successful autofix run found for this PR's current head SHA. Push a new commit to trigger one, then comment \`/autofix\` again." \
|
||||
>/dev/null
|
||||
;;
|
||||
esac
|
||||
exit 1
|
||||
|
||||
# Pinned to v8.0.1. Same SHA as pr-autofix-publish.yml.
|
||||
# `continue-on-error: true` lets the workflow proceed when the
|
||||
# artifact is expired or pruned (1-day retention). The apply
|
||||
# step distinguishes "patch file missing entirely" (artifact-
|
||||
# expired) from "patch file zero bytes" (genuinely empty patch).
|
||||
- name: Download autofix artifact
|
||||
if: steps.locate.outputs.found == 'true'
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: autofix
|
||||
run-id: ${{ steps.locate.outputs.run_id }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
path: autofix-in
|
||||
|
||||
# Pinned to v5.0.4. Verify SHA via:
|
||||
# gh api repos/actions/checkout/git/refs/tags/v5.0.4
|
||||
#
|
||||
# `persist-credentials: false` disables the default behavior where
|
||||
# actions/checkout writes the GITHUB_TOKEN into `.git/config` as an
|
||||
# extraheader. That default is convenient (subsequent git commands
|
||||
# auth automatically) but it means the token is sitting on disk in
|
||||
# the checkout directory — an `actions/upload-artifact` step on
|
||||
# this directory would leak the token. We don't upload, but
|
||||
# zizmor's `credential-persistence` lint flags it defensively.
|
||||
# Push auth is provided inline at push time via the URL.
|
||||
- name: Checkout PR head
|
||||
if: steps.locate.outputs.found == 'true'
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5.0.4
|
||||
with:
|
||||
repository: ${{ steps.locate.outputs.head_repo }}
|
||||
ref: ${{ steps.locate.outputs.head_sha }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
persist-credentials: false
|
||||
# Fetch full history so the push doesn't hit shallow-clone errors.
|
||||
fetch-depth: 0
|
||||
path: pr-checkout
|
||||
|
||||
- name: Apply patch and push
|
||||
id: apply
|
||||
if: steps.locate.outputs.found == 'true'
|
||||
env:
|
||||
HEAD_REF: ${{ steps.locate.outputs.head_ref }}
|
||||
HEAD_REPO: ${{ steps.locate.outputs.head_repo }}
|
||||
# The SHA we resolved earlier in `locate` — this is what the
|
||||
# remote ref MUST still equal at push time. If the contributor
|
||||
# force-pushed between resolve and now, the lease fails and
|
||||
# we surface that distinctly from a fork-without-maintainer
|
||||
# -edit push failure.
|
||||
HEAD_SHA: ${{ steps.locate.outputs.head_sha }}
|
||||
# Auth for the push only — never persisted to disk. Provided
|
||||
# via env to avoid interpolating into the shell command line.
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
working-directory: pr-checkout
|
||||
run: |
|
||||
set -euo pipefail
|
||||
patch="../autofix-in/autofix.patch"
|
||||
|
||||
# Distinguish artifact-expired (file missing entirely, because
|
||||
# actions/download-artifact ran with continue-on-error and the
|
||||
# 1-day retention had elapsed) from genuinely empty patch
|
||||
# (file present, zero bytes, formatter found nothing).
|
||||
if [ ! -e "$patch" ]; then
|
||||
echo "::warning::Patch file does not exist — autofix artifact likely expired."
|
||||
echo "result=artifact-expired" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ ! -s "$patch" ]; then
|
||||
echo "::warning::Empty patch — nothing to apply."
|
||||
echo "result=empty-patch" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Sensitive-paths guard: refuse to apply patches that touch
|
||||
# `.github/` — workflow files, action definitions, CODEOWNERS,
|
||||
# dependabot config, etc. A malicious PR could ship a custom
|
||||
# prettier/ESLint config that reformats workflow YAML; the
|
||||
# producer would then capture those edits in autofix.patch,
|
||||
# and a maintainer running `/autofix` would push them under
|
||||
# `contents: write`. The default GITHUB_TOKEN lacks `workflows`
|
||||
# scope so the platform would reject workflow-file pushes
|
||||
# anyway, but that surfaces as a generic `push-failed` and
|
||||
# misleads users into enabling maintainer-edit. Reject early
|
||||
# with a specific reason. CODEOWNERS and dependabot.yml live
|
||||
# under .github/ but outside .github/workflows/ — the broader
|
||||
# match is intentional (they all govern trust boundaries).
|
||||
if grep -qE '^(diff --git|---|\+\+\+) [ab]?/?\.github/' "$patch"; then
|
||||
echo "::warning::Patch touches .github/ — refusing to apply (sensitive paths)."
|
||||
echo "result=sensitive-paths" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Re-entrancy guard: if HEAD itself is an autofix bot commit,
|
||||
# refuse to apply again. Without this, lint/formatter config
|
||||
# drift between runs could pump arbitrary apply commits into
|
||||
# the same PR if an automated agent watches the sticky and
|
||||
# re-fires `/autofix` on each new "fixes-available" surface.
|
||||
# The contributor can still get out by force-pushing a
|
||||
# human-authored commit to revert the autofix and re-trigger.
|
||||
head_author=$(git log -1 --format='%ae' HEAD)
|
||||
head_subject=$(git log -1 --format='%s' HEAD)
|
||||
if [ "${head_author}" = "41898282+github-actions[bot]@users.noreply.github.com" ] \
|
||||
&& [[ "${head_subject}" =~ ^chore\(autofix\) ]]; then
|
||||
echo "::warning::HEAD is an autofix bot commit — refusing to re-apply (loop guard)."
|
||||
echo "result=loop-prevented" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Idempotency probe: does the forward apply work?
|
||||
if git apply --check "$patch" 2>/dev/null; then
|
||||
echo "Patch applies cleanly — proceeding."
|
||||
elif git apply --check --reverse "$patch" 2>/dev/null; then
|
||||
# Reverse-check passes => the patch is already applied to
|
||||
# the current tree. Treat as success no-op.
|
||||
echo "Patch is already applied (reverse-check passed) — no-op."
|
||||
echo "result=already-applied" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
else
|
||||
echo "::error::Patch does not apply (stale or conflicting)."
|
||||
echo "result=stale" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Wrap the apply/commit phase so any non-zero exit sets a
|
||||
# meaningful `result=` instead of leaving it unset (which would
|
||||
# send the user to the `*` "unexpected state" arm with a
|
||||
# non-actionable confused-emoji reply).
|
||||
if ! {
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com" &&
|
||||
git config user.name "github-actions[bot]" &&
|
||||
git apply "$patch" &&
|
||||
git add -A &&
|
||||
git commit -m "chore(autofix): apply prettier + eslint fixes via /autofix command"
|
||||
}; then
|
||||
echo "::error::git apply / config / commit failed after idempotency probe passed."
|
||||
echo "result=apply-failed" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Push to the PR head branch with a lease against the resolved
|
||||
# SHA. The lease ensures the remote ref still points at HEAD_SHA
|
||||
# when the push lands — if the contributor force-pushed in the
|
||||
# window between resolve and now, the lease fails and we return
|
||||
# `lease-failed` (NOT `push-failed`, which would mislead users
|
||||
# into enabling maintainer-edit). For fork PRs, the push still
|
||||
# requires "Allow edits by maintainers" to be enabled.
|
||||
#
|
||||
# Auth is supplied inline via `-c http.<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
|
||||
@@ -0,0 +1,316 @@
|
||||
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})"
|
||||
@@ -0,0 +1,146 @@
|
||||
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,6 +35,9 @@ on:
|
||||
pull_request_target:
|
||||
types: [opened, edited, reopened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention".
|
||||
# Include `github.event_name` so `pull_request` (validate-title) and
|
||||
# `pull_request_target` (autolabel) runs for the same PR do NOT share a slot
|
||||
|
||||
@@ -6,6 +6,7 @@ on:
|
||||
- 'v*'
|
||||
|
||||
# No workflow-level permissions — scoped per job below.
|
||||
permissions: {}
|
||||
|
||||
# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention".
|
||||
# Tag refs are unique per release, so distinct tags run in parallel. Re-pushes of the
|
||||
@@ -35,7 +36,7 @@ jobs:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
registry-url: https://registry.npmjs.org
|
||||
# Hermetic install for the published artifact — no cache carry-over
|
||||
# from non-tag contexts. setup-node v5+ caches by default when a
|
||||
|
||||
@@ -58,6 +58,7 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read # read PR labels on the merge commit
|
||||
outputs:
|
||||
should_run: ${{ steps.decide.outputs.should_run }}
|
||||
head_sha: ${{ steps.decide.outputs.head_sha }}
|
||||
@@ -74,6 +75,8 @@ jobs:
|
||||
FORCE: ${{ inputs.force }}
|
||||
BUMP_INPUT: ${{ inputs.bump }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
HEAD_SHA=$(git rev-parse HEAD)
|
||||
@@ -96,6 +99,49 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Skip when the merge commit corresponds to a release ─────────
|
||||
# Two complementary checks (belt-and-suspenders):
|
||||
# 1. The HEAD commit subject matches `chore: release vX.Y.Z`
|
||||
# (the canonical release-PR title in this repo). Anchored
|
||||
# at both ends to require the bare title or the squash-merge
|
||||
# `(#NNNN)` suffix exactly — rejects noisy variants like
|
||||
# `chore: release v1.0.0 (something unrelated)`.
|
||||
# 2. The squash-merged PR carries the `release` label.
|
||||
# Either match suppresses the rc build — stable releases publish
|
||||
# via publish.yml on the v-tag, so the rc cycle should pause for
|
||||
# them rather than racing the npm publish.
|
||||
HEAD_SUBJECT="$(git log -1 --pretty=%s HEAD)"
|
||||
# Sanitise GitHub-Actions annotation prefixes before logging the
|
||||
# raw subject — defence-in-depth so a hypothetical commit subject
|
||||
# containing `::error::` or `::set-output::` cannot forge log
|
||||
# annotations even though %s strips newlines.
|
||||
HEAD_SUBJECT_SAFE="${HEAD_SUBJECT//::/__}"
|
||||
RELEASE_SUBJECT_RE='^chore:[[:space:]]*release[[:space:]]+v[0-9]+\.[0-9]+\.[0-9]+([[:space:]]+\(#[0-9]+\))?$'
|
||||
if [[ "$HEAD_SUBJECT" =~ $RELEASE_SUBJECT_RE ]]; then
|
||||
echo "HEAD commit subject matches a release commit — skipping rc."
|
||||
echo " subject (sanitised): $HEAD_SUBJECT_SAFE"
|
||||
echo "should_run=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Squash-merge commits include `(#NNNN)` at the end of the subject.
|
||||
if [[ "$HEAD_SUBJECT" =~ \(#([0-9]+)\)[[:space:]]*$ ]]; then
|
||||
PR_NUM="${BASH_REMATCH[1]}"
|
||||
echo "Detected squash-merge of PR #$PR_NUM — checking labels."
|
||||
if LABELS_JSON="$(gh pr view "$PR_NUM" --repo "$REPO" --json labels 2>/dev/null)"; then
|
||||
if printf '%s' "$LABELS_JSON" | jq -e '.labels[] | select(.name == "release")' >/dev/null; then
|
||||
echo "PR #$PR_NUM has the 'release' label — skipping rc."
|
||||
echo "should_run=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "PR #$PR_NUM has no 'release' label — proceeding."
|
||||
else
|
||||
# Lookup failure is not fatal — fall through to the dedup check
|
||||
# so a transient GH API hiccup doesn't silently suppress rc builds.
|
||||
echo "::warning::Could not read labels for PR #${PR_NUM} — falling through."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Dedup: is there already an rc/<HEAD_SHA> marker pointing at HEAD?
|
||||
MARKER="rc/${HEAD_SHA}"
|
||||
if git rev-parse "refs/tags/$MARKER" >/dev/null 2>&1; then
|
||||
@@ -123,7 +169,16 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: write # push rc tag + marker
|
||||
# The default GITHUB_TOKEN cannot be granted `workflows: write`, so
|
||||
# tag pushes that reach a commit which modified `.github/workflows/**`
|
||||
# are rejected with: "refusing to allow a GitHub App to create or
|
||||
# update workflow ... without `workflows` permission". We pass a
|
||||
# fine-grained PAT (RELEASE_PUSH_TOKEN, scoped to this repo with
|
||||
# Contents: write + Workflows: write) to `actions/checkout` so that
|
||||
# the subsequent `git push --atomic` of the v-tag and rc marker
|
||||
# carries the PAT's identity. Job-level GITHUB_TOKEN keeps its
|
||||
# scoped permissions for everything else (npm provenance, etc.).
|
||||
contents: write # push rc tag + marker (via PAT)
|
||||
id-token: write # npm provenance
|
||||
outputs:
|
||||
vtag: ${{ steps.reltag.outputs.vtag }}
|
||||
@@ -132,10 +187,15 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
# Use the PAT so `origin` is preauthed for `git push`. Without
|
||||
# this the default GITHUB_TOKEN is wired into the remote, and a
|
||||
# workflows-touching tag push is rejected — see the permissions
|
||||
# block above.
|
||||
token: ${{ secrets.RELEASE_PUSH_TOKEN }}
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
registry-url: https://registry.npmjs.org
|
||||
# Hermetic install — release-candidate produces shipped artifacts.
|
||||
# setup-node v5+ caches by default when a packageManager field is
|
||||
@@ -280,9 +340,11 @@ jobs:
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "base=$BASE" >> "$GITHUB_OUTPUT"
|
||||
echo "rc_n=$NEXT_N" >> "$GITHUB_OUTPUT"
|
||||
echo "rc_version=$RC_VERSION" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "base=$BASE"
|
||||
echo "rc_n=$NEXT_N"
|
||||
echo "rc_version=$RC_VERSION"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Apply rc version in-CI
|
||||
shell: bash
|
||||
@@ -340,9 +402,11 @@ 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" >> "$GITHUB_OUTPUT"
|
||||
echo "marker=$MARKER" >> "$GITHUB_OUTPUT"
|
||||
echo "release_sha=$RELEASE_SHA" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "vtag=$VTAG"
|
||||
echo "marker=$MARKER"
|
||||
echo "release_sha=$RELEASE_SHA"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Publish to npm (rc dist-tag)
|
||||
run: npm publish --provenance --access public --tag rc
|
||||
|
||||
@@ -53,6 +53,6 @@ jobs:
|
||||
retention-days: 5
|
||||
|
||||
- name: Upload to Security tab
|
||||
uses: github/codeql-action/upload-sarif@0daab03d71ff584ef619d027a3fd9146679c5d84 # v3.35.3
|
||||
uses: github/codeql-action/upload-sarif@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
||||
@@ -1,19 +1,28 @@
|
||||
name: Trivy Image Scan
|
||||
|
||||
# Builds Dockerfile.cli and Dockerfile.web, then scans the resulting images
|
||||
# for OS-package and language-package CVEs at HIGH/CRITICAL severity.
|
||||
# for OS-package and language-package CVEs at MEDIUM+ severity.
|
||||
# Findings upload to the Security tab; record-only (does not block merges).
|
||||
#
|
||||
# NOT triggered on PRs — image builds are slow and base-image CVE churn
|
||||
# shouldn't gate feature delivery.
|
||||
# Trigger on Dockerfile changes in PRs so base-image/npm-layer remediation can
|
||||
# be verified before merge without running image scans on every PR.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'Dockerfile.cli'
|
||||
- 'Dockerfile.web'
|
||||
- 'gitnexus/Dockerfile.test'
|
||||
- '.github/workflows/trivy.yml'
|
||||
push:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: '0 8 * * 1'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
@@ -44,7 +53,7 @@ jobs:
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Build image (load locally for scan)
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
file: ${{ matrix.image.dockerfile }}
|
||||
@@ -61,13 +70,13 @@ jobs:
|
||||
image-ref: scan-target:${{ matrix.image.name }}
|
||||
format: sarif
|
||||
output: trivy-${{ matrix.image.name }}.sarif
|
||||
severity: HIGH,CRITICAL
|
||||
severity: MEDIUM,HIGH,CRITICAL
|
||||
# Hides CVEs with no available fix in the base image.
|
||||
ignore-unfixed: true
|
||||
exit-code: '0'
|
||||
|
||||
- name: Upload to Security tab
|
||||
uses: github/codeql-action/upload-sarif@0daab03d71ff584ef619d027a3fd9146679c5d84 # v3.35.3
|
||||
uses: github/codeql-action/upload-sarif@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
|
||||
with:
|
||||
sarif_file: trivy-${{ matrix.image.name }}.sarif
|
||||
category: trivy-${{ matrix.image.name }}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
name: Workflow Lint (zizmor)
|
||||
name: Workflow Lint
|
||||
|
||||
# Lints .github/workflows/** for known GitHub Actions security misconfigurations:
|
||||
# unpinned Actions, dangerous ${{ ... }} interpolation in run: blocks,
|
||||
# missing per-job permissions:, etc.
|
||||
# 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.
|
||||
#
|
||||
# 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:
|
||||
@@ -12,11 +15,35 @@ 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
|
||||
@@ -49,7 +76,7 @@ jobs:
|
||||
continue-on-error: true
|
||||
|
||||
- name: Upload SARIF
|
||||
uses: github/codeql-action/upload-sarif@0daab03d71ff584ef619d027a3fd9146679c5d84 # v3.35.3
|
||||
uses: github/codeql-action/upload-sarif@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
|
||||
with:
|
||||
sarif_file: zizmor.sarif
|
||||
category: zizmor
|
||||
|
||||
@@ -14,6 +14,15 @@ 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,
|
||||
|
||||
@@ -149,11 +149,16 @@ Indexed as **GitNexus** (4325 symbols, 10556 relationships, 300 execution flows)
|
||||
## Keeping the Index Fresh
|
||||
|
||||
```bash
|
||||
npx gitnexus analyze # basic refresh; preserves any existing embeddings
|
||||
npx gitnexus analyze # incremental by default; preserves embeddings
|
||||
npx gitnexus analyze --force # full rebuild from scratch (opt out of incremental)
|
||||
npx gitnexus analyze --embeddings # also generate embeddings for new/changed nodes
|
||||
npx gitnexus analyze --drop-embeddings # explicit opt-in to wipe existing embeddings
|
||||
```
|
||||
|
||||
`analyze` runs **incrementally by default**. The pipeline still parses every file every run (cross-file resolution requires it), but tree-sitter parsing is **served from a content-addressed cache** at `.gitnexus/parse-cache.json` for chunks whose file contents haven't changed since the last run. Only changed-file rows (and their importers) are rewritten in LadybugDB; unchanged-file rows are preserved. Output is byte-equivalent to a full rebuild. Pass `--force` to wipe and re-index from scratch (e.g., to recover from a corrupt index, or after upgrading GitNexus).
|
||||
|
||||
The parse cache key is **content-addressed and version-tagged**: it survives `--force` runs, and is automatically invalidated by a `gitnexus` package upgrade (so a new tree-sitter grammar doesn't silently replay stale parse output). Safe to delete `.gitnexus/parse-cache.json` at any time — it'll be rebuilt on the next analyze.
|
||||
|
||||
Check `.gitnexus/meta.json` `stats.embeddings` (0 = none). A plain `analyze` no longer drops existing vectors — pass `--drop-embeddings` to wipe.
|
||||
|
||||
> Claude Code: PostToolUse hook detects a stale index after `git commit` and `git merge` and prompts the agent to run `analyze`. The hook does not invoke `analyze` itself.
|
||||
|
||||
+58
-24
@@ -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,6 +103,41 @@ 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.
|
||||
@@ -164,8 +199,7 @@ Two publish workflows ship `gitnexus` to npm:
|
||||
the Docker build.
|
||||
- Manually run `docker build` + `docker push` locally and sign with Cosign
|
||||
against the same digest.
|
||||
- Delete `rc/<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:
|
||||
|
||||
|
||||
+31
-9
@@ -1,24 +1,32 @@
|
||||
ARG BUILDPLATFORM
|
||||
ARG TARGETPLATFORM
|
||||
# Pinned npm version used to replace the bundled npm in the upstream Node
|
||||
# image. Bumping requires a coordinated update in Dockerfile.web and
|
||||
# gitnexus/Dockerfile.test so all images bootstrap the same npm.
|
||||
ARG NPM_VERSION=11.14.1
|
||||
|
||||
# ── Builder ────────────────────────────────────────────────────────────
|
||||
# -- Builder -----------------------------------------------------------
|
||||
# Native modules (tree-sitter-*, onnxruntime-node, node-gyp builds for
|
||||
# tree-sitter-proto / tree-sitter-swift) require python3 + a C/C++ toolchain.
|
||||
FROM node:22-trixie-slim AS builder
|
||||
# node:22-bookworm-slim
|
||||
FROM node:22-bookworm-slim@sha256:9f6d5975c7dca860947d3915877f85607946403fc55349f39b4bc3688448bb6e AS builder
|
||||
ARG NPM_VERSION
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN npx --yes npm@${NPM_VERSION} install -g npm@${NPM_VERSION}
|
||||
|
||||
# Toolchain for node-gyp / native builds.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ git && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Build gitnexus-shared first — gitnexus depends on it as a workspace.
|
||||
# Build gitnexus-shared first - gitnexus depends on it as a workspace.
|
||||
COPY gitnexus-shared/package.json gitnexus-shared/package-lock.json ./gitnexus-shared/
|
||||
RUN npm ci --prefix gitnexus-shared
|
||||
COPY gitnexus-shared ./gitnexus-shared
|
||||
RUN rm -f gitnexus-shared/tsconfig.tsbuildinfo
|
||||
RUN npm run build --prefix gitnexus-shared
|
||||
|
||||
# Copy the full gitnexus package before installing — `npm ci` triggers
|
||||
# Copy the full gitnexus package before installing - `npm ci` triggers
|
||||
# `postinstall` (patches tree-sitter-swift, builds the vendored
|
||||
# tree-sitter-proto) and `prepare` (compiles TypeScript via scripts/build.js),
|
||||
# both of which need the source tree.
|
||||
@@ -28,11 +36,15 @@ RUN npm ci --prefix gitnexus
|
||||
# Drop dev dependencies for a smaller runtime layer.
|
||||
RUN npm prune --omit=dev --prefix gitnexus
|
||||
|
||||
# ── Runtime ────────────────────────────────────────────────────────────
|
||||
FROM node:22-trixie-slim AS runtime
|
||||
# -- Runtime -----------------------------------------------------------
|
||||
# node:22-bookworm-slim
|
||||
FROM node:22-bookworm-slim@sha256:9f6d5975c7dca860947d3915877f85607946403fc55349f39b4bc3688448bb6e AS runtime
|
||||
|
||||
# curl for the healthcheck; git so `gitnexus` can clone repos at runtime.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends curl git && rm -rf /var/lib/apt/lists/*
|
||||
# curl for the healthcheck; git for cloning; ca-certificates for TLS verification.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends curl git ca-certificates && rm -rf /var/lib/apt/lists/* \
|
||||
&& rm -rf /usr/local/lib/node_modules/npm \
|
||||
&& rm -rf /usr/local/lib/node_modules/corepack \
|
||||
&& rm -f /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -43,11 +55,21 @@ RUN mkdir -p /data/gitnexus && chown -R node:node /data
|
||||
COPY --from=builder --chown=node:node /app/gitnexus/dist ./gitnexus/dist
|
||||
COPY --from=builder --chown=node:node /app/gitnexus/node_modules ./gitnexus/node_modules
|
||||
COPY --from=builder --chown=node:node /app/gitnexus/package.json ./gitnexus/package.json
|
||||
COPY --from=builder --chown=node:node /app/gitnexus/scripts/install-duckdb-extension.mjs ./gitnexus/scripts/install-duckdb-extension.mjs
|
||||
COPY --from=builder --chown=node:node /app/gitnexus/vendor ./gitnexus/vendor
|
||||
|
||||
# Expose the `gitnexus` binary on PATH so the documented Docker workflow
|
||||
# (`docker compose exec gitnexus-server gitnexus index /workspace/<repo>`)
|
||||
# works without users having to invoke `node /app/gitnexus/dist/cli/index.js`.
|
||||
# `npm prune --omit=dev` in the builder stage strips `node_modules/.bin/`
|
||||
# entries, so the `gitnexus` bin declared in package.json (`dist/cli/index.js`,
|
||||
# which already carries `#!/usr/bin/env node` and 755 perms) is otherwise
|
||||
# unreachable from $PATH.
|
||||
RUN ln -s /app/gitnexus/dist/cli/index.js /usr/local/bin/gitnexus
|
||||
|
||||
USER node
|
||||
|
||||
# The web UI defaults to http://localhost:4747 — keep that contract.
|
||||
# The web UI defaults to http://localhost:4747 - keep that contract.
|
||||
ENV GITNEXUS_HOME=/data/gitnexus \
|
||||
NODE_ENV=production \
|
||||
PORT=4747
|
||||
|
||||
+14
-3
@@ -1,10 +1,17 @@
|
||||
ARG BUILDPLATFORM
|
||||
ARG TARGETPLATFORM
|
||||
# Pinned npm version — keep in sync with Dockerfile.cli and
|
||||
# gitnexus/Dockerfile.test.
|
||||
ARG NPM_VERSION=11.14.1
|
||||
|
||||
FROM --platform=$BUILDPLATFORM node:22-alpine AS builder
|
||||
# node:22-bookworm-slim
|
||||
FROM --platform=$BUILDPLATFORM node:22-bookworm-slim@sha256:9f6d5975c7dca860947d3915877f85607946403fc55349f39b4bc3688448bb6e AS builder
|
||||
ARG NPM_VERSION
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN npx --yes npm@${NPM_VERSION} install -g npm@${NPM_VERSION}
|
||||
|
||||
COPY gitnexus-shared/package.json gitnexus-shared/package-lock.json ./gitnexus-shared/
|
||||
RUN npm ci --prefix gitnexus-shared
|
||||
|
||||
@@ -19,9 +26,13 @@ RUN npm ci --prefix gitnexus-web
|
||||
COPY gitnexus-web ./gitnexus-web
|
||||
RUN npm run build --prefix gitnexus-web
|
||||
|
||||
FROM node:22-alpine AS runtime
|
||||
# node:22-bookworm-slim
|
||||
FROM node:22-bookworm-slim@sha256:9f6d5975c7dca860947d3915877f85607946403fc55349f39b4bc3688448bb6e AS runtime
|
||||
|
||||
RUN apk add --no-cache curl
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* \
|
||||
&& rm -rf /usr/local/lib/node_modules/npm \
|
||||
&& rm -rf /usr/local/lib/node_modules/corepack \
|
||||
&& rm -f /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
+7
-1
@@ -30,9 +30,15 @@ Format: **Trigger → Instruction → Reason**. Append new Signs when the same m
|
||||
### Stale graph after edits
|
||||
|
||||
- **Trigger:** MCP warns index is behind `HEAD`, or search doesn't match latest commit.
|
||||
- **Do:** `npx gitnexus analyze` (plus `--embeddings` if used).
|
||||
- **Do:** `npx gitnexus analyze` (plus `--embeddings` if used). Runs incrementally by default — the pipeline parses every file every run (cross-file resolution requires it), but tree-sitter dispatch is skipped for unchanged file chunks via the content-addressed cache, and only changed-file rows (plus their importers, transitively) are rewritten in LadybugDB.
|
||||
- **Why:** Tools query LadybugDB from last analyze; git changes are invisible until re-indexed.
|
||||
|
||||
### Index seems corrupt or "incremental" is misbehaving
|
||||
|
||||
- **Trigger:** `analyze` produces unexpected results, or `meta.json.incrementalInProgress` is set, or the index is in a half-state after a crash.
|
||||
- **Do:** `npx gitnexus analyze --force` to rebuild from scratch. The dirty-flag check forces this automatically when a previous incremental run didn't complete cleanly, but `--force` is the manual escape hatch. Safe to delete `.gitnexus/parse-cache.json` at any time — content-addressed, will be regenerated.
|
||||
- **Why:** Incremental writeback is selective DB row replacement; if the on-disk state is inconsistent for any reason, a full rebuild is the cheapest path back to a known-good index.
|
||||
|
||||
### Embeddings vanished after analyze
|
||||
|
||||
- **Trigger:** Semantic search quality drops; `stats.embeddings` in `meta.json` is 0 after refresh.
|
||||
|
||||
@@ -109,6 +109,8 @@ That's it. This indexes the codebase, installs agent skills, registers Claude Co
|
||||
|
||||
To configure MCP for your editor, run `npx gitnexus setup` once — or set it up manually below.
|
||||
|
||||
> **Faster install (no C++ toolchain needed):** set `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` before `npm install -g gitnexus` to skip the native `tree-sitter-dart` and `tree-sitter-proto` builds. Dart/Proto files won't be parsed, but install completes in seconds without `python3`/`make`/`g++`. Strict `=1` only — any other value falls through to the rebuild.
|
||||
|
||||
### MCP Setup
|
||||
|
||||
`gitnexus setup` auto-detects your editors and writes the correct global MCP config. You only need to run it once.
|
||||
@@ -118,7 +120,7 @@ To configure MCP for your editor, run `npx gitnexus setup` once — or set it up
|
||||
| Editor | MCP | Skills | Hooks (auto-augment) | Support |
|
||||
| --------------------- | --- | ------ | -------------------- | -------------- |
|
||||
| **Claude Code** | Yes | Yes | Yes (PreToolUse + PostToolUse) | **Full** |
|
||||
| **Cursor** | Yes | Yes | — | MCP + Skills |
|
||||
| **Cursor** | Yes | Yes | Yes (postToolUse, [manual install](gitnexus-cursor-integration/README.md#hook-install)) | **Full** |
|
||||
| **Codex** | Yes | Yes | — | MCP + Skills |
|
||||
| **Windsurf** | Yes | — | — | MCP |
|
||||
| **OpenCode** | Yes | Yes | — | MCP + Skills |
|
||||
@@ -138,6 +140,8 @@ Built by the community — not officially maintained, but worth checking out.
|
||||
|
||||
If you prefer manual configuration:
|
||||
|
||||
> **Recommended for fastest startup:** install gitnexus globally (`npm i -g gitnexus`) and run `gitnexus setup` — this writes an absolute-path MCP config that bypasses `npx` entirely. The pinned-`npx` snippets below are a quickstart fallback; on a cold cache the `npx` install can exceed Claude Code's `MCP_TIMEOUT` default (~30s).
|
||||
|
||||
**Claude Code** (full support — MCP + skills + hooks):
|
||||
|
||||
```bash
|
||||
@@ -210,6 +214,7 @@ 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
|
||||
@@ -224,6 +229,12 @@ 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
-1
@@ -19,7 +19,7 @@ services:
|
||||
- ${WORKSPACE_DIR:-./workspace}:/workspace:ro
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ['CMD', 'curl', '-fsSI', 'http://localhost:4747/api/heartbeat']
|
||||
test: ['CMD', 'curl', '-f', 'http://localhost:4747/api/health']
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
# Using GitNexus across Apache Thrift microservices
|
||||
|
||||
## When to use this guide
|
||||
|
||||
Use this guide when several repositories communicate through Apache Thrift and you want GitNexus to trace impact across provider and consumer boundaries. The walkthrough assumes each service is indexed on its own, then joined through a GitNexus group.
|
||||
|
||||
This is not a framework integration guide. GitNexus reads portable Thrift IDL and common Java generated-code shapes. Framework-specific wiring, service discovery, deployment metadata, and private annotations belong outside the open-source core.
|
||||
|
||||
## Mental model
|
||||
|
||||
- `.thrift` files define the canonical service contract. A method in an IDL service becomes a stable contract id in the form `thrift::<namespace>.<Service>/<Method>`.
|
||||
- Service wildcard ids in the form `thrift::<namespace>.<Service>/*` are supported as manifest and matching fallback forms when a service-level link is needed.
|
||||
- Java generated-code usage points GitNexus toward implementation and call sites. Providers commonly implement generated `Service.Iface`; consumers commonly hold or construct generated service interfaces or clients.
|
||||
- Group sync matches provider and consumer contracts with the same id, then cross-repo impact can hop through those links.
|
||||
- Framework-specific wiring should be modeled by extractor plugins, manifest links, or downstream integrations rather than hard-coded into core Thrift support.
|
||||
|
||||
## Fictional IDL
|
||||
|
||||
```thrift
|
||||
namespace java billing.v1
|
||||
|
||||
struct PlaceOrderRequest {
|
||||
1: string orderId
|
||||
2: double amount
|
||||
}
|
||||
|
||||
struct PlaceOrderResponse {
|
||||
1: bool accepted
|
||||
}
|
||||
|
||||
struct GetOrderRequest {
|
||||
1: string orderId
|
||||
}
|
||||
|
||||
struct GetOrderResponse {
|
||||
1: string orderId
|
||||
2: string status
|
||||
}
|
||||
|
||||
service OrderService {
|
||||
PlaceOrderResponse PlaceOrder(1: PlaceOrderRequest request)
|
||||
GetOrderResponse GetOrder(1: GetOrderRequest request)
|
||||
}
|
||||
```
|
||||
|
||||
The service methods above produce canonical ids:
|
||||
|
||||
- `thrift::billing.v1.OrderService/PlaceOrder`
|
||||
- `thrift::billing.v1.OrderService/GetOrder`
|
||||
- `thrift::billing.v1.OrderService/*` as a service-level manifest or matching fallback form
|
||||
|
||||
## Java provider example
|
||||
|
||||
Generated Java code usually exposes an `Iface` interface for the service. A provider implementation can be detected when it implements that generated interface.
|
||||
|
||||
```java
|
||||
package example.billing;
|
||||
|
||||
import billing.v1.GetOrderRequest;
|
||||
import billing.v1.GetOrderResponse;
|
||||
import billing.v1.OrderService;
|
||||
import billing.v1.PlaceOrderRequest;
|
||||
import billing.v1.PlaceOrderResponse;
|
||||
|
||||
public final class OrderServiceHandler implements OrderService.Iface {
|
||||
@Override
|
||||
public PlaceOrderResponse PlaceOrder(PlaceOrderRequest request) {
|
||||
return new PlaceOrderResponse(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public GetOrderResponse GetOrder(GetOrderRequest request) {
|
||||
return new GetOrderResponse(request.getOrderId(), "CREATED");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
With the IDL available, GitNexus can connect the implementation to `thrift::billing.v1.OrderService/PlaceOrder` and `thrift::billing.v1.OrderService/GetOrder`.
|
||||
|
||||
## Java consumer examples
|
||||
|
||||
Consumers are strongest when Java usage can be tied back to the IDL namespace and service.
|
||||
|
||||
```java
|
||||
package example.checkout;
|
||||
|
||||
import billing.v1.OrderService;
|
||||
import billing.v1.PlaceOrderRequest;
|
||||
|
||||
public final class CheckoutWorkflow {
|
||||
private final OrderService.Iface orders;
|
||||
|
||||
public CheckoutWorkflow(OrderService.Iface orders) {
|
||||
this.orders = orders;
|
||||
}
|
||||
|
||||
public void submit(String orderId) throws Exception {
|
||||
orders.PlaceOrder(new PlaceOrderRequest(orderId, 42.0));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Some generated-code styles use the generated service type directly while keeping enough IDL context through imports and method calls.
|
||||
|
||||
```java
|
||||
package example.reporting;
|
||||
|
||||
import billing.v1.GetOrderRequest;
|
||||
import billing.v1.OrderService;
|
||||
|
||||
public final class OrderLookup {
|
||||
private final OrderService.Client client;
|
||||
|
||||
public OrderLookup(OrderService.Client client) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
public String status(String orderId) throws Exception {
|
||||
return client.GetOrder(new GetOrderRequest(orderId)).getStatus();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When IDL context is missing, GitNexus may still emit a weaker consumer signal for generated `Iface` or `Client` shapes, but confidence is lower.
|
||||
|
||||
## Group configuration
|
||||
|
||||
New group configs enable Thrift contract detection by default. Keep `detect.thrift: true`
|
||||
when a group should scan for Thrift contracts, or set it to `false` to skip Thrift
|
||||
extraction for that group.
|
||||
|
||||
```yaml
|
||||
version: 1
|
||||
name: billing-platform
|
||||
description: Fictional services connected by Apache Thrift
|
||||
|
||||
repos:
|
||||
checkout: checkout-service
|
||||
billing: billing-service
|
||||
|
||||
links: []
|
||||
|
||||
detect:
|
||||
http: true
|
||||
grpc: false
|
||||
thrift: true
|
||||
topics: false
|
||||
shared_libs: true
|
||||
```
|
||||
|
||||
To disable Thrift extraction explicitly:
|
||||
|
||||
```yaml
|
||||
detect:
|
||||
thrift: false
|
||||
```
|
||||
|
||||
After indexing each member repository, run group sync to extract contracts and write cross-repo links:
|
||||
|
||||
```bash
|
||||
npx gitnexus group sync billing-platform
|
||||
```
|
||||
|
||||
## Manifest escape hatch
|
||||
|
||||
Use manifest links when automatic extraction cannot see a provider or consumer, or when generated code is wrapped behind an abstraction. Write the contract without the `thrift::` prefix; GitNexus canonicalizes it to the full Thrift contract id.
|
||||
|
||||
```yaml
|
||||
links:
|
||||
- from: checkout
|
||||
to: billing
|
||||
type: thrift
|
||||
contract: billing.v1.OrderService/PlaceOrder
|
||||
role: consumer
|
||||
```
|
||||
|
||||
GitNexus canonicalizes that manifest entry to `thrift::billing.v1.OrderService/PlaceOrder` and uses it to connect the two repositories.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- Java detection currently targets v1 generated-code patterns.
|
||||
- Maven and POM dependency coordinates are not used for inference.
|
||||
- Framework-specific annotations and service discovery metadata are ignored by open-source Thrift extraction.
|
||||
- Ambiguous same-name services are skipped instead of guessed.
|
||||
- Java consumers without IDL context are lower confidence and limited to generated `Iface` and `Client` shapes.
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Custom ESLint rule: require `parseSourceSafe(parser, content, ...)` instead
|
||||
* of direct `<parser>.parse(<content>, ...)` calls.
|
||||
*
|
||||
* Background: tree-sitter's Node.js native binding crashes with SIGSEGV on
|
||||
* Windows when handed a JS string longer than 32 767 chars. The crash happens
|
||||
* inside the binding's V8 string-to-buffer conversion and cannot be intercepted
|
||||
* by JavaScript `try/catch`. `parseSourceSafe` (in
|
||||
* `gitnexus/src/core/tree-sitter/safe-parse.ts`) routes large inputs through
|
||||
* the chunked-callback overload of `parser.parse(input, ...)` which bypasses
|
||||
* the broken conversion path. PR #1433 fixed every direct call site at the
|
||||
* time; this rule prevents new direct calls from creeping in.
|
||||
*
|
||||
* The rule is auto-fixable for the call-site rewrite. It does NOT auto-add the
|
||||
* import (computing the correct relative path per file is brittle); after the
|
||||
* call rewrite runs, the consumer file's `tsc` will complain about an
|
||||
* undefined identifier and the developer adds the import. This is the same
|
||||
* tradeoff `unused-imports/no-unused-imports` makes in the opposite direction.
|
||||
*
|
||||
* False-positive suppression:
|
||||
* - Skips calls whose receiver is a known non-tree-sitter library (`JSON`,
|
||||
* `URL`, `marked`, `Number`).
|
||||
* - Skips calls whose first argument is a string-literal (grammar-load smoke
|
||||
* tests like `_testParser.parse('service X { rpc Y (R) returns (R); }')`).
|
||||
* - Skips test files (`.test.ts`/`.test.tsx`/`.spec.ts`).
|
||||
* - Skips the `safe-parse.ts` helper itself.
|
||||
*/
|
||||
|
||||
const SKIPPED_RECEIVERS = new Set(['JSON', 'URL', 'marked', 'Number', 'Math']);
|
||||
|
||||
export default {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description:
|
||||
'Require parseSourceSafe instead of direct tree-sitter `<parser>.parse(content, ...)` calls (Windows SIGSEGV protection)',
|
||||
recommended: true,
|
||||
},
|
||||
fixable: 'code',
|
||||
schema: [],
|
||||
messages: {
|
||||
useSafeParse:
|
||||
'Direct `{{receiver}}.parse(...)` can SIGSEGV on Windows for inputs > 32 767 chars (uncatchable from JS). Use `parseSourceSafe({{receiver}}, ...)` from `core/tree-sitter/safe-parse.js`. Auto-fix rewrites the call; add the missing import yourself.',
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
const filename = context.filename ?? context.getFilename();
|
||||
// Don't lint the helper itself or test files.
|
||||
if (filename.includes('safe-parse')) return {};
|
||||
if (/[.](?:test|spec)\.tsx?$/.test(filename)) return {};
|
||||
|
||||
const sourceCode = context.sourceCode ?? context.getSourceCode();
|
||||
|
||||
return {
|
||||
CallExpression(node) {
|
||||
const callee = node.callee;
|
||||
if (callee.type !== 'MemberExpression') return;
|
||||
if (callee.computed) return;
|
||||
if (callee.property.type !== 'Identifier') return;
|
||||
if (callee.property.name !== 'parse') return;
|
||||
|
||||
// Skip known non-tree-sitter receivers.
|
||||
if (callee.object.type === 'Identifier' && SKIPPED_RECEIVERS.has(callee.object.name)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Smoke tests pass a string literal directly; those are trivially safe.
|
||||
const firstArg = node.arguments[0];
|
||||
if (!firstArg) return;
|
||||
if (firstArg.type === 'Literal' && typeof firstArg.value === 'string') return;
|
||||
if (firstArg.type === 'TemplateLiteral' && firstArg.expressions.length === 0) return;
|
||||
|
||||
const receiverText = sourceCode.getText(callee.object);
|
||||
// Receiver-text-shape skip: anything matching well-known JS APIs that
|
||||
// happen to have a `.parse(<expr>)` shape but aren't tree-sitter.
|
||||
if (
|
||||
/^(JSON|URL|marked|Number|Math|Date|globalThis\.JSON)\b/.test(receiverText) ||
|
||||
/\bjson\.parse\b/i.test(receiverText)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'useSafeParse',
|
||||
data: { receiver: receiverText },
|
||||
fix(fixer) {
|
||||
const argsText = node.arguments.map((arg) => sourceCode.getText(arg)).join(', ');
|
||||
return fixer.replaceText(node, `parseSourceSafe(${receiverText}, ${argsText})`);
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
+124
-2
@@ -3,6 +3,47 @@ import tsParser from '@typescript-eslint/parser';
|
||||
import unusedImports from 'eslint-plugin-unused-imports';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import prettierConfig from 'eslint-config-prettier';
|
||||
import requireSafeParse from './eslint-rules/require-safe-parse.mjs';
|
||||
|
||||
// Local plugin hosting custom rules that enforce GitNexus-specific invariants
|
||||
// (currently: the Windows-SIGSEGV-safe parser entrypoint).
|
||||
const gitnexusLocalPlugin = {
|
||||
rules: {
|
||||
'require-safe-parse': requireSafeParse,
|
||||
},
|
||||
};
|
||||
|
||||
// Selectors that protect MCP-reachable code from corrupting the JSON-RPC
|
||||
// stdio frame stream. The MCP-reachable block below uses these directly;
|
||||
// the lbug-adapter file-specific block must spread them in too because
|
||||
// ESLint flat config REPLACES (not merges) `no-restricted-syntax` when
|
||||
// multiple matching configs target the same file. Extracting to a const
|
||||
// makes the dependency mechanical instead of documentation-enforced.
|
||||
const mcpStdoutWriteSelectors = [
|
||||
{
|
||||
selector:
|
||||
"MemberExpression[object.type='MemberExpression'][object.object.name='process'][object.property.name='stdout'][property.name='write']",
|
||||
message:
|
||||
'Direct process.stdout.write is forbidden in MCP-reachable code. Route diagnostics through console.error or process.stderr.write — the MCP stdio transport owns stdout for JSON-RPC frames.',
|
||||
},
|
||||
{
|
||||
selector:
|
||||
"CallExpression[callee.type='MemberExpression'][callee.object.type='MemberExpression'][callee.object.object.name='process'][callee.object.property.name='stdout'][callee.property.name='write']",
|
||||
message:
|
||||
'Direct process.stdout.write is forbidden in MCP-reachable code. Route diagnostics through console.error or process.stderr.write — the MCP stdio transport owns stdout for JSON-RPC frames.',
|
||||
},
|
||||
{
|
||||
// Catches the canonical destructuring shape:
|
||||
// const { write } = process.stdout;
|
||||
// (and any other ObjectPattern destructure rooted at process.stdout)
|
||||
// which would otherwise capture a reference to the original write
|
||||
// and bypass the sentinel.
|
||||
selector:
|
||||
"VariableDeclarator[init.type='MemberExpression'][init.object.name='process'][init.property.name='stdout'] > ObjectPattern",
|
||||
message:
|
||||
'Destructuring process.stdout is forbidden in MCP-reachable code — bypasses the sentinel. Use process.stderr.write for diagnostics.',
|
||||
},
|
||||
];
|
||||
|
||||
export default [
|
||||
// Global ignores
|
||||
@@ -59,11 +100,64 @@ export default [
|
||||
},
|
||||
},
|
||||
|
||||
// CLI package — allow console.log (it's a CLI tool)
|
||||
// CLI/server packages — `console.log` IS the contract (CLI tool data output
|
||||
// on stdout, e.g. `gitnexus query | jq`; server pretty-printed banners).
|
||||
// Diagnostic logging (`warn`/`error`/`debug`/`info`) goes through pino like
|
||||
// the rest of the codebase.
|
||||
{
|
||||
files: ['gitnexus/src/cli/**/*.ts', 'gitnexus/src/server/**/*.ts'],
|
||||
rules: {
|
||||
'no-console': 'off',
|
||||
'no-console': ['error', { allow: ['log'] }],
|
||||
},
|
||||
},
|
||||
|
||||
// Forcing function for the pino migration. Severity is `error` — the
|
||||
// codebase-wide migration is complete; new `console.*` in core source
|
||||
// must fail lint. CLI/server are exempt above (legitimate stdout output).
|
||||
// Tests, bin scripts, and the logger module itself remain exempt.
|
||||
{
|
||||
files: ['gitnexus/src/**/*.ts'],
|
||||
ignores: ['gitnexus/src/cli/**', 'gitnexus/src/server/**', 'gitnexus/src/core/logger.ts'],
|
||||
rules: {
|
||||
'no-console': 'error',
|
||||
},
|
||||
},
|
||||
|
||||
// MCP-reachable code: forbid stdout-corrupting writes. The MCP stdio
|
||||
// transport writes JSON-RPC frames to stdout; per the spec, the server
|
||||
// MUST NOT write anything to stdout that is not a valid MCP message.
|
||||
// Diagnostics must go to stderr (console.error). Direct process.stdout.write
|
||||
// bypasses the gate and is also forbidden in these dirs.
|
||||
// cli/mcp.ts is included here even though it lives under cli/ — it is the
|
||||
// MCP entrypoint and inherits stricter discipline than the rest of cli/.
|
||||
{
|
||||
files: [
|
||||
'gitnexus/src/mcp/**/*.ts',
|
||||
'gitnexus/src/core/lbug/**/*.ts',
|
||||
'gitnexus/src/core/embeddings/**/*.ts',
|
||||
'gitnexus/src/core/tree-sitter/**/*.ts',
|
||||
'gitnexus/src/cli/mcp.ts',
|
||||
],
|
||||
rules: {
|
||||
'no-console': ['error', { allow: ['error'] }],
|
||||
'no-restricted-syntax': ['error', ...mcpStdoutWriteSelectors],
|
||||
},
|
||||
},
|
||||
|
||||
// Windows SIGSEGV protection: every tree-sitter parse in `core/` must route
|
||||
// through parseSourceSafe. Direct `<parser>.parse(content, ...)` crashes on
|
||||
// Windows for inputs > 32 767 chars (V8 string-conversion bug, uncatchable
|
||||
// from JS). The rule auto-fixes the call site; the developer adds the
|
||||
// missing import after the fix runs. Out of scope: tests (skipped by the
|
||||
// rule), the helper itself (`safe-parse.ts`), and the `grpc-patterns/proto.ts`
|
||||
// grammar-load smoke test (filtered by string-literal-arg skip in the rule).
|
||||
{
|
||||
files: ['gitnexus/src/core/**/*.ts'],
|
||||
plugins: {
|
||||
gitnexus: gitnexusLocalPlugin,
|
||||
},
|
||||
rules: {
|
||||
'gitnexus/require-safe-parse': 'error',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -79,6 +173,34 @@ export default [
|
||||
},
|
||||
},
|
||||
|
||||
// Prevent direct conn.close() / db.close() in the LadybugDB adapter (#1376).
|
||||
// All close operations must go through safeClose() so the WAL is always
|
||||
// flushed before the connection is released. The sole authorised call site
|
||||
// inside safeClose itself uses an eslint-disable-next-line override.
|
||||
//
|
||||
// ESLint flat config REPLACES (not merges) `no-restricted-syntax` when
|
||||
// multiple matching configs target the same file. lbug-adapter.ts is also
|
||||
// covered by the MCP-reachable block above, so we spread the shared
|
||||
// mcpStdoutWriteSelectors here alongside the safeClose selectors. Without
|
||||
// this, lbug-adapter would silently lose its MCP stdout-write protection.
|
||||
{
|
||||
files: ['gitnexus/src/core/lbug/lbug-adapter.ts'],
|
||||
rules: {
|
||||
'no-restricted-syntax': [
|
||||
'error',
|
||||
...mcpStdoutWriteSelectors,
|
||||
{
|
||||
selector: "CallExpression[callee.object.name='conn'][callee.property.name='close']",
|
||||
message: 'Use safeClose() instead of calling conn.close() directly (#1376).',
|
||||
},
|
||||
{
|
||||
selector: "CallExpression[callee.object.name='db'][callee.property.name='close']",
|
||||
message: 'Use safeClose() instead of calling db.close() directly (#1376).',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
// Disable formatting rules (prettier handles those)
|
||||
prettierConfig,
|
||||
];
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ dependencies = [
|
||||
"pyyaml>=6.0",
|
||||
"pandas>=2.0.0",
|
||||
"tabulate>=0.9.0",
|
||||
"python-dotenv>=1.0.0",
|
||||
"python-dotenv>=1.2.2",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
Generated
+100
-100
@@ -21,7 +21,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "aiohttp"
|
||||
version = "3.13.5"
|
||||
version = "3.13.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohappyeyeballs" },
|
||||
@@ -32,93 +32,93 @@ dependencies = [
|
||||
{ name = "propcache" },
|
||||
{ name = "yarl" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/45/4a/064321452809dae953c1ed6e017504e72551a26b6f5708a5a80e4bf556ff/aiohttp-3.13.4.tar.gz", hash = "sha256:d97a6d09c66087890c2ab5d49069e1e570583f7ac0314ecf98294c1b6aaebd38", size = 7859748, upload-time = "2026-03-28T17:19:40.6Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/f5/a20c4ac64aeaef1679e25c9983573618ff765d7aa829fa2b84ae7573169e/aiohttp-3.13.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ab7229b6f9b5c1ba4910d6c41a9eb11f543eadb3f384df1b4c293f4e73d44d6", size = 757513, upload-time = "2026-03-31T21:57:02.146Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/0a/39fa6c6b179b53fcb3e4b3d2b6d6cad0180854eda17060c7218540102bef/aiohttp-3.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8f14c50708bb156b3a3ca7230b3d820199d56a48e3af76fa21c2d6087190fe3d", size = 506748, upload-time = "2026-03-31T21:57:04.275Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/ec/e38ce072e724fd7add6243613f8d1810da084f54175353d25ccf9f9c7e5a/aiohttp-3.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e7d2f8616f0ff60bd332022279011776c3ac0faa0f1b463f7bb12326fbc97a1c", size = 501673, upload-time = "2026-03-31T21:57:06.208Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/ba/3bc7525d7e2beaa11b309a70d48b0d3cfc3c2089ec6a7d0820d59c657053/aiohttp-3.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2567b72e1ffc3ab25510db43f355b29eeada56c0a622e58dcdb19530eb0a3cb", size = 1763757, upload-time = "2026-03-31T21:57:07.882Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/ab/e87744cf18f1bd78263aba24924d4953b41086bd3a31d22452378e9028a0/aiohttp-3.13.5-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fb0540c854ac9c0c5ad495908fdfd3e332d553ec731698c0e29b1877ba0d2ec6", size = 1720152, upload-time = "2026-03-31T21:57:09.946Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/f3/ed17a6f2d742af17b50bae2d152315ed1b164b07a5fd5cc1754d99e4dfa5/aiohttp-3.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9883051c6972f58bfc4ebb2116345ee2aa151178e99c3f2b2bbe2af712abd13", size = 1818010, upload-time = "2026-03-31T21:57:12.157Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/06/ecbc63dc937192e2a5cb46df4d3edb21deb8225535818802f210a6ea5816/aiohttp-3.13.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2294172ce08a82fb7c7273485895de1fa1186cc8294cfeb6aef4af42ad261174", size = 1907251, upload-time = "2026-03-31T21:57:14.023Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/a5/0521aa32c1ddf3aa1e71dcc466be0b7db2771907a13f18cddaa45967d97b/aiohttp-3.13.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3a807cabd5115fb55af198b98178997a5e0e57dead43eb74a93d9c07d6d4a7dc", size = 1759969, upload-time = "2026-03-31T21:57:16.146Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/78/a38f8c9105199dd3b9706745865a8a59d0041b6be0ca0cc4b2ccf1bab374/aiohttp-3.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa6d0d932e0f39c02b80744273cd5c388a2d9bc07760a03164f229c8e02662f6", size = 1616871, upload-time = "2026-03-31T21:57:17.856Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/41/27392a61ead8ab38072105c71aa44ff891e71653fe53d576a7067da2b4e8/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:60869c7ac4aaabe7110f26499f3e6e5696eae98144735b12a9c3d9eae2b51a49", size = 1739844, upload-time = "2026-03-31T21:57:19.679Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/55/5564e7ae26d94f3214250009a0b1c65a0c6af4bf88924ccb6fdab901de28/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:26d2f8546f1dfa75efa50c3488215a903c0168d253b75fba4210f57ab77a0fb8", size = 1731969, upload-time = "2026-03-31T21:57:22.006Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/c5/705a3929149865fc941bcbdd1047b238e4a72bcb215a9b16b9d7a2e8d992/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1162a1492032c82f14271e831c8f4b49f2b6078f4f5fc74de2c912fa225d51d", size = 1795193, upload-time = "2026-03-31T21:57:24.256Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/19/edabed62f718d02cff7231ca0db4ef1c72504235bc467f7b67adb1679f48/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8b14eb3262fad0dc2f89c1a43b13727e709504972186ff6a99a3ecaa77102b6c", size = 1606477, upload-time = "2026-03-31T21:57:26.364Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/fc/76f80ef008675637d88d0b21584596dc27410a990b0918cb1e5776545b5b/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ca9ac61ac6db4eb6c2a0cd1d0f7e1357647b638ccc92f7e9d8d133e71ed3c6ac", size = 1813198, upload-time = "2026-03-31T21:57:28.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/67/5b3ac26b80adb20ea541c487f73730dc8fa107d632c998f25bbbab98fcda/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7996023b2ed59489ae4762256c8516df9820f751cf2c5da8ed2fb20ee50abab3", size = 1752321, upload-time = "2026-03-31T21:57:30.549Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/06/e4a2e49255ea23fa4feeb5ab092d90240d927c15e47b5b5c48dff5a9ce29/aiohttp-3.13.5-cp311-cp311-win32.whl", hash = "sha256:77dfa48c9f8013271011e51c00f8ada19851f013cde2c48fca1ba5e0caf5bb06", size = 439069, upload-time = "2026-03-31T21:57:32.388Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/43/8c7163a596dab4f8be12c190cf467a1e07e4734cf90eebb39f7f5d53fc6a/aiohttp-3.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:d3a4834f221061624b8887090637db9ad4f61752001eae37d56c52fddade2dc8", size = 462859, upload-time = "2026-03-31T21:57:34.455Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/6f/353954c29e7dcce7cf00280a02c75f30e133c00793c7a2ed3776d7b2f426/aiohttp-3.13.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:023ecba036ddd840b0b19bf195bfae970083fd7024ce1ac22e9bba90464620e9", size = 748876, upload-time = "2026-03-31T21:57:36.319Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/1b/428a7c64687b3b2e9cd293186695affc0e1e54a445d0361743b231f11066/aiohttp-3.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15c933ad7920b7d9a20de151efcd05a6e38302cbf0e10c9b2acb9a42210a2416", size = 499557, upload-time = "2026-03-31T21:57:38.236Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/47/7be41556bfbb6917069d6a6634bb7dd5e163ba445b783a90d40f5ac7e3a7/aiohttp-3.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab2899f9fa2f9f741896ebb6fa07c4c883bfa5c7f2ddd8cf2aafa86fa981b2d2", size = 500258, upload-time = "2026-03-31T21:57:39.923Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/84/c9ecc5828cb0b3695856c07c0a6817a99d51e2473400f705275a2b3d9239/aiohttp-3.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60eaa2d440cd4707696b52e40ed3e2b0f73f65be07fd0ef23b6b539c9c0b0b4", size = 1749199, upload-time = "2026-03-31T21:57:41.938Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/d3/3c6d610e66b495657622edb6ae7c7fd31b2e9086b4ec50b47897ad6042a9/aiohttp-3.13.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:55b3bdd3292283295774ab585160c4004f4f2f203946997f49aac032c84649e9", size = 1721013, upload-time = "2026-03-31T21:57:43.904Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/a0/24409c12217456df0bae7babe3b014e460b0b38a8e60753d6cb339f6556d/aiohttp-3.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2b2355dc094e5f7d45a7bb262fe7207aa0460b37a0d87027dcf21b5d890e7d5", size = 1781501, upload-time = "2026-03-31T21:57:46.285Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/9d/b65ec649adc5bccc008b0957a9a9c691070aeac4e41cea18559fef49958b/aiohttp-3.13.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b38765950832f7d728297689ad78f5f2cf79ff82487131c4d26fe6ceecdc5f8e", size = 1878981, upload-time = "2026-03-31T21:57:48.734Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/d8/8d44036d7eb7b6a8ec4c5494ea0c8c8b94fbc0ed3991c1a7adf230df03bf/aiohttp-3.13.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b18f31b80d5a33661e08c89e202edabf1986e9b49c42b4504371daeaa11b47c1", size = 1767934, upload-time = "2026-03-31T21:57:51.171Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/04/d3f8211f273356f158e3464e9e45484d3fb8c4ce5eb2f6fe9405c3273983/aiohttp-3.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33add2463dde55c4f2d9635c6ab33ce154e5ecf322bd26d09af95c5f81cfa286", size = 1566671, upload-time = "2026-03-31T21:57:53.326Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/db/073e4ebe00b78e2dfcacff734291651729a62953b48933d765dc513bf798/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:327cc432fdf1356fb4fbc6fe833ad4e9f6aacb71a8acaa5f1855e4b25910e4a9", size = 1705219, upload-time = "2026-03-31T21:57:55.385Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/45/7dfba71a2f9fd97b15c95c06819de7eb38113d2cdb6319669195a7d64270/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7c35b0bf0b48a70b4cb4fc5d7bed9b932532728e124874355de1a0af8ec4bc88", size = 1743049, upload-time = "2026-03-31T21:57:57.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/71/901db0061e0f717d226386a7f471bb59b19566f2cae5f0d93874b017271f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:df23d57718f24badef8656c49743e11a89fd6f5358fa8a7b96e728fda2abf7d3", size = 1749557, upload-time = "2026-03-31T21:57:59.626Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/d5/41eebd16066e59cd43728fe74bce953d7402f2b4ddfdfef2c0e9f17ca274/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:02e048037a6501a5ec1f6fc9736135aec6eb8a004ce48838cb951c515f32c80b", size = 1558931, upload-time = "2026-03-31T21:58:01.972Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/e6/4a799798bf05740e66c3a1161079bda7a3dd8e22ca392481d7a7f9af82a6/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31cebae8b26f8a615d2b546fee45d5ffb76852ae6450e2a03f42c9102260d6fe", size = 1774125, upload-time = "2026-03-31T21:58:04.007Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/63/7749337c90f92bc2cb18f9560d67aa6258c7060d1397d21529b8004fcf6f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:888e78eb5ca55a615d285c3c09a7a91b42e9dd6fc699b166ebd5dee87c9ccf14", size = 1732427, upload-time = "2026-03-31T21:58:06.337Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/de/cf2f44ff98d307e72fb97d5f5bbae3bfcb442f0ea9790c0bf5c5c2331404/aiohttp-3.13.5-cp312-cp312-win32.whl", hash = "sha256:8bd3ec6376e68a41f9f95f5ed170e2fcf22d4eb27a1f8cb361d0508f6e0557f3", size = 433534, upload-time = "2026-03-31T21:58:08.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/ca/eadf6f9c8fa5e31d40993e3db153fb5ed0b11008ad5d9de98a95045bed84/aiohttp-3.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:110e448e02c729bcebb18c60b9214a87ba33bac4a9fa5e9a5f139938b56c6cb1", size = 460446, upload-time = "2026-03-31T21:58:10.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/e9/d76bf503005709e390122d34e15256b88f7008e246c4bdbe915cd4f1adce/aiohttp-3.13.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5029cc80718bbd545123cd8fe5d15025eccaaaace5d0eeec6bd556ad6163d61", size = 742930, upload-time = "2026-03-31T21:58:13.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/00/4b7b70223deaebd9bb85984d01a764b0d7bd6526fcdc73cca83bcbe7243e/aiohttp-3.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4bb6bf5811620003614076bdc807ef3b5e38244f9d25ca5fe888eaccea2a9832", size = 496927, upload-time = "2026-03-31T21:58:15.073Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/f5/0fb20fb49f8efdcdce6cd8127604ad2c503e754a8f139f5e02b01626523f/aiohttp-3.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a84792f8631bf5a94e52d9cc881c0b824ab42717165a5579c760b830d9392ac9", size = 497141, upload-time = "2026-03-31T21:58:17.009Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/86/b7c870053e36a94e8951b803cb5b909bfbc9b90ca941527f5fcafbf6b0fa/aiohttp-3.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57653eac22c6a4c13eb22ecf4d673d64a12f266e72785ab1c8b8e5940d0e8090", size = 1732476, upload-time = "2026-03-31T21:58:18.925Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/e5/4e161f84f98d80c03a238671b4136e6530453d65262867d989bbe78244d0/aiohttp-3.13.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5e5f7debc7a57af53fdf5c5009f9391d9f4c12867049d509bf7bb164a6e295b", size = 1706507, upload-time = "2026-03-31T21:58:21.094Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/56/ea11a9f01518bd5a2a2fcee869d248c4b8a0cfa0bb13401574fa31adf4d4/aiohttp-3.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c719f65bebcdf6716f10e9eff80d27567f7892d8988c06de12bbbd39307c6e3a", size = 1773465, upload-time = "2026-03-31T21:58:23.159Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/40/333ca27fb74b0383f17c90570c748f7582501507307350a79d9f9f3c6eb1/aiohttp-3.13.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d97f93fdae594d886c5a866636397e2bcab146fd7a132fd6bb9ce182224452f8", size = 1873523, upload-time = "2026-03-31T21:58:25.59Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/d2/e2f77eef1acb7111405433c707dc735e63f67a56e176e72e9e7a2cd3f493/aiohttp-3.13.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df334e39d4c2f899a914f1dba283c1aadc311790733f705182998c6f7cae665", size = 1754113, upload-time = "2026-03-31T21:58:27.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/56/3f653d7f53c89669301ec9e42c95233e2a0c0a6dd051269e6e678db4fdb0/aiohttp-3.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe6970addfea9e5e081401bcbadf865d2b6da045472f58af08427e108d618540", size = 1562351, upload-time = "2026-03-31T21:58:29.918Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/a6/9b3e91eb8ae791cce4ee736da02211c85c6f835f1bdfac0594a8a3b7018c/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7becdf835feff2f4f335d7477f121af787e3504b48b449ff737afb35869ba7bb", size = 1693205, upload-time = "2026-03-31T21:58:32.214Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/fc/bfb437a99a2fcebd6b6eaec609571954de2ed424f01c352f4b5504371dd3/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:676e5651705ad5d8a70aeb8eb6936c436d8ebbd56e63436cb7dd9bb36d2a9a46", size = 1730618, upload-time = "2026-03-31T21:58:34.728Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/b6/c8534862126191a034f68153194c389addc285a0f1347d85096d349bbc15/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:9b16c653d38eb1a611cc898c41e76859ca27f119d25b53c12875fd0474ae31a8", size = 1745185, upload-time = "2026-03-31T21:58:36.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/93/4ca8ee2ef5236e2707e0fd5fecb10ce214aee1ff4ab307af9c558bda3b37/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:999802d5fa0389f58decd24b537c54aa63c01c3219ce17d1214cbda3c2b22d2d", size = 1557311, upload-time = "2026-03-31T21:58:39.38Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/ae/76177b15f18c5f5d094f19901d284025db28eccc5ae374d1d254181d33f4/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ec707059ee75732b1ba130ed5f9580fe10ff75180c812bc267ded039db5128c6", size = 1773147, upload-time = "2026-03-31T21:58:41.476Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/a4/62f05a0a98d88af59d93b7fcac564e5f18f513cb7471696ac286db970d6a/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2d6d44a5b48132053c2f6cd5c8cb14bc67e99a63594e336b0f2af81e94d5530c", size = 1730356, upload-time = "2026-03-31T21:58:44.049Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/85/fc8601f59dfa8c9523808281f2da571f8b4699685f9809a228adcc90838d/aiohttp-3.13.5-cp313-cp313-win32.whl", hash = "sha256:329f292ed14d38a6c4c435e465f48bebb47479fd676a0411936cc371643225cc", size = 432637, upload-time = "2026-03-31T21:58:46.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/1b/ac685a8882896acf0f6b31d689e3792199cfe7aba37969fa91da63a7fa27/aiohttp-3.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:69f571de7500e0557801c0b51f4780482c0ec5fe2ac851af5a92cfce1af1cb83", size = 458896, upload-time = "2026-03-31T21:58:48.119Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/ce/46572759afc859e867a5bc8ec3487315869013f59281ce61764f76d879de/aiohttp-3.13.5-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:eb4639f32fd4a9904ab8fb45bf3383ba71137f3d9d4ba25b3b3f3109977c5b8c", size = 745721, upload-time = "2026-03-31T21:58:50.229Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/fe/8a2efd7626dbe6049b2ef8ace18ffda8a4dfcbe1bcff3ac30c0c7575c20b/aiohttp-3.13.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:7e5dc4311bd5ac493886c63cbf76ab579dbe4641268e7c74e48e774c74b6f2be", size = 497663, upload-time = "2026-03-31T21:58:52.232Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/91/cc8cc78a111826c54743d88651e1687008133c37e5ee615fee9b57990fac/aiohttp-3.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:756c3c304d394977519824449600adaf2be0ccee76d206ee339c5e76b70ded25", size = 499094, upload-time = "2026-03-31T21:58:54.566Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/33/a8362cb15cf16a3af7e86ed11962d5cd7d59b449202dc576cdc731310bde/aiohttp-3.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecc26751323224cf8186efcf7fbcbc30f4e1d8c7970659daf25ad995e4032a56", size = 1726701, upload-time = "2026-03-31T21:58:56.864Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/0c/c091ac5c3a17114bd76cbf85d674650969ddf93387876cf67f754204bd77/aiohttp-3.13.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10a75acfcf794edf9d8db50e5a7ec5fc818b2a8d3f591ce93bc7b1210df016d2", size = 1683360, upload-time = "2026-03-31T21:58:59.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/73/bcee1c2b79bc275e964d1446c55c54441a461938e70267c86afaae6fba27/aiohttp-3.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f7a18f258d124cd678c5fe072fe4432a4d5232b0657fca7c1847f599233c83a", size = 1773023, upload-time = "2026-03-31T21:59:01.776Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/ef/720e639df03004fee2d869f771799d8c23046dec47d5b81e396c7cda583a/aiohttp-3.13.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df6104c009713d3a89621096f3e3e88cc323fd269dbd7c20afe18535094320be", size = 1853795, upload-time = "2026-03-31T21:59:04.568Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/c9/989f4034fb46841208de7aeeac2c6d8300745ab4f28c42f629ba77c2d916/aiohttp-3.13.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:241a94f7de7c0c3b616627aaad530fe2cb620084a8b144d3be7b6ecfe95bae3b", size = 1730405, upload-time = "2026-03-31T21:59:07.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/75/ee1fd286ca7dc599d824b5651dad7b3be7ff8d9a7e7b3fe9820d9180f7db/aiohttp-3.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c974fb66180e58709b6fc402846f13791240d180b74de81d23913abe48e96d94", size = 1558082, upload-time = "2026-03-31T21:59:09.484Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/20/1e9e6650dfc436340116b7aa89ff8cb2bbdf0abc11dfaceaad8f74273a10/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6e27ea05d184afac78aabbac667450c75e54e35f62238d44463131bd3f96753d", size = 1692346, upload-time = "2026-03-31T21:59:12.068Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/40/8ebc6658d48ea630ac7903912fe0dd4e262f0e16825aa4c833c56c9f1f56/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a79a6d399cef33a11b6f004c67bb07741d91f2be01b8d712d52c75711b1e07c7", size = 1698891, upload-time = "2026-03-31T21:59:14.552Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/78/ea0ae5ec8ba7a5c10bdd6e318f1ba5e76fcde17db8275188772afc7917a4/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c632ce9c0b534fbe25b52c974515ed674937c5b99f549a92127c85f771a78772", size = 1742113, upload-time = "2026-03-31T21:59:17.068Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/66/9d308ed71e3f2491be1acb8769d96c6f0c47d92099f3bc9119cada27b357/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fceedde51fbd67ee2bcc8c0b33d0126cc8b51ef3bbde2f86662bd6d5a6f10ec5", size = 1553088, upload-time = "2026-03-31T21:59:19.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/a6/6cc25ed8dfc6e00c90f5c6d126a98e2cf28957ad06fa1036bd34b6f24a2c/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f92995dfec9420bb69ae629abf422e516923ba79ba4403bc750d94fb4a6c68c1", size = 1757976, upload-time = "2026-03-31T21:59:22.311Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/2b/cce5b0ffe0de99c83e5e36d8f828e4161e415660a9f3e58339d07cce3006/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20ae0ff08b1f2c8788d6fb85afcb798654ae6ba0b747575f8562de738078457b", size = 1712444, upload-time = "2026-03-31T21:59:24.635Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/cf/9e1795b4160c58d29421eafd1a69c6ce351e2f7c8d3c6b7e4ca44aea1a5b/aiohttp-3.13.5-cp314-cp314-win32.whl", hash = "sha256:b20df693de16f42b2472a9c485e1c948ee55524786a0a34345511afdd22246f3", size = 438128, upload-time = "2026-03-31T21:59:27.291Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/4d/eaedff67fc805aeba4ba746aec891b4b24cebb1a7d078084b6300f79d063/aiohttp-3.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:f85c6f327bf0b8c29da7d93b1cabb6363fb5e4e160a32fa241ed2dce21b73162", size = 464029, upload-time = "2026-03-31T21:59:29.429Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/11/c27d9332ee20d68dd164dc12a6ecdef2e2e35ecc97ed6cf0d2442844624b/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:1efb06900858bb618ff5cee184ae2de5828896c448403d51fb633f09e109be0a", size = 778758, upload-time = "2026-03-31T21:59:31.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/fb/377aead2e0a3ba5f09b7624f702a964bdf4f08b5b6728a9799830c80041e/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:fee86b7c4bd29bdaf0d53d14739b08a106fdda809ca5fe032a15f52fae5fe254", size = 512883, upload-time = "2026-03-31T21:59:34.098Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/a6/aa109a33671f7a5d3bd78b46da9d852797c5e665bfda7d6b373f56bff2ec/aiohttp-3.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:20058e23909b9e65f9da62b396b77dfa95965cbe840f8def6e572538b1d32e36", size = 516668, upload-time = "2026-03-31T21:59:36.497Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/b3/ca078f9f2fa9563c36fb8ef89053ea2bb146d6f792c5104574d49d8acb63/aiohttp-3.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cf20a8d6868cb15a73cab329ffc07291ba8c22b1b88176026106ae39aa6df0f", size = 1883461, upload-time = "2026-03-31T21:59:38.723Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/e3/a7ad633ca1ca497b852233a3cce6906a56c3225fb6d9217b5e5e60b7419d/aiohttp-3.13.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:330f5da04c987f1d5bdb8ae189137c77139f36bd1cb23779ca1a354a4b027800", size = 1747661, upload-time = "2026-03-31T21:59:41.187Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/b9/cd6fe579bed34a906d3d783fe60f2fa297ef55b27bb4538438ee49d4dc41/aiohttp-3.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f1cbf0c7926d315c3c26c2da41fd2b5d2fe01ac0e157b78caefc51a782196cf", size = 1863800, upload-time = "2026-03-31T21:59:43.84Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/3f/2c1e2f5144cefa889c8afd5cf431994c32f3b29da9961698ff4e3811b79a/aiohttp-3.13.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:53fc049ed6390d05423ba33103ded7281fe897cf97878f369a527070bd95795b", size = 1958382, upload-time = "2026-03-31T21:59:46.187Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/1d/f31ec3f1013723b3babe3609e7f119c2c2fb6ef33da90061a705ef3e1bc8/aiohttp-3.13.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:898703aa2667e3c5ca4c54ca36cd73f58b7a38ef87a5606414799ebce4d3fd3a", size = 1803724, upload-time = "2026-03-31T21:59:48.656Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/b4/57712dfc6f1542f067daa81eb61da282fab3e6f1966fca25db06c4fc62d5/aiohttp-3.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0494a01ca9584eea1e5fbd6d748e61ecff218c51b576ee1999c23db7066417d8", size = 1640027, upload-time = "2026-03-31T21:59:51.284Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/3c/734c878fb43ec083d8e31bf029daae1beafeae582d1b35da234739e82ee7/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6cf81fe010b8c17b09495cbd15c1d35afbc8fb405c0c9cf4738e5ae3af1d65be", size = 1806644, upload-time = "2026-03-31T21:59:53.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/a5/f671e5cbec1c21d044ff3078223f949748f3a7f86b14e34a365d74a5d21f/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:c564dd5f09ddc9d8f2c2d0a301cd30a79a2cc1b46dd1a73bef8f0038863d016b", size = 1791630, upload-time = "2026-03-31T21:59:56.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/63/fb8d0ad63a0b8a99be97deac8c04dacf0785721c158bdf23d679a87aa99e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2994be9f6e51046c4f864598fd9abeb4fba6e88f0b2152422c9666dcd4aea9c6", size = 1809403, upload-time = "2026-03-31T21:59:59.103Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/0c/bfed7f30662fcf12206481c2aac57dedee43fe1c49275e85b3a1e1742294/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:157826e2fa245d2ef46c83ea8a5faf77ca19355d278d425c29fda0beb3318037", size = 1634924, upload-time = "2026-03-31T22:00:02.116Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/d6/fd518d668a09fd5a3319ae5e984d4d80b9a4b3df4e21c52f02251ef5a32e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a8aca50daa9493e9e13c0f566201a9006f080e7c50e5e90d0b06f53146a54500", size = 1836119, upload-time = "2026-03-31T22:00:04.756Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/b7/15fb7a9d52e112a25b621c67b69c167805cb1f2ab8f1708a5c490d1b52fe/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3b13560160d07e047a93f23aaa30718606493036253d5430887514715b67c9d9", size = 1772072, upload-time = "2026-03-31T22:00:07.494Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/df/57ba7f0c4a553fc2bd8b6321df236870ec6fd64a2a473a8a13d4f733214e/aiohttp-3.13.5-cp314-cp314t-win32.whl", hash = "sha256:9a0f4474b6ea6818b41f82172d799e4b3d29e22c2c520ce4357856fced9af2f8", size = 471819, upload-time = "2026-03-31T22:00:10.277Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/29/2f8418269e46454a26171bfdd6a055d74febf32234e474930f2f60a17145/aiohttp-3.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:18a2f6c1182c51baa1d28d68fea51513cb2a76612f038853c0ad3c145423d3d9", size = 505441, upload-time = "2026-03-31T22:00:12.791Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/7e/cb94129302d78c46662b47f9897d642fd0b33bdfef4b73b20c6ced35aa4c/aiohttp-3.13.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8ea0c64d1bcbf201b285c2246c51a0c035ba3bbd306640007bc5844a3b4658c1", size = 760027, upload-time = "2026-03-28T17:15:33.022Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/cd/2db3c9397c3bd24216b203dd739945b04f8b87bb036c640da7ddb63c75ef/aiohttp-3.13.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6f742e1fa45c0ed522b00ede565e18f97e4cf8d1883a712ac42d0339dfb0cce7", size = 508325, upload-time = "2026-03-28T17:15:34.714Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/a3/d28b2722ec13107f2e37a86b8a169897308bab6a3b9e071ecead9d67bd9b/aiohttp-3.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6dcfb50ee25b3b7a1222a9123be1f9f89e56e67636b561441f0b304e25aaef8f", size = 502402, upload-time = "2026-03-28T17:15:36.409Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/d6/acd47b5f17c4430e555590990a4746efbcb2079909bb865516892bf85f37/aiohttp-3.13.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3262386c4ff370849863ea93b9ea60fd59c6cf56bf8f93beac625cf4d677c04d", size = 1771224, upload-time = "2026-03-28T17:15:38.223Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/af/af6e20113ba6a48fd1cd9e5832c4851e7613ef50c7619acdaee6ec5f1aff/aiohttp-3.13.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:473bb5aa4218dd254e9ae4834f20e31f5a0083064ac0136a01a62ddbae2eaa42", size = 1731530, upload-time = "2026-03-28T17:15:39.988Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/16/78a2f5d9c124ad05d5ce59a9af94214b6466c3491a25fb70760e98e9f762/aiohttp-3.13.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e56423766399b4c77b965f6aaab6c9546617b8994a956821cc507d00b91d978c", size = 1827925, upload-time = "2026-03-28T17:15:41.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/1f/79acf0974ced805e0e70027389fccbb7d728e6f30fcac725fb1071e63075/aiohttp-3.13.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8af249343fafd5ad90366a16d230fc265cf1149f26075dc9fe93cfd7c7173942", size = 1923579, upload-time = "2026-03-28T17:15:44.071Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/53/29f9e2054ea6900413f3b4c3eb9d8331f60678ec855f13ba8714c47fd48d/aiohttp-3.13.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bc0a5cf4f10ef5a2c94fdde488734b582a3a7a000b131263e27c9295bd682d9", size = 1767655, upload-time = "2026-03-28T17:15:45.911Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/57/462fe1d3da08109ba4aa8590e7aed57c059af2a7e80ec21f4bac5cfe1094/aiohttp-3.13.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5c7ff1028e3c9fc5123a865ce17df1cb6424d180c503b8517afbe89aa566e6be", size = 1630439, upload-time = "2026-03-28T17:15:48.11Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/4b/4813344aacdb8127263e3eec343d24e973421143826364fa9fc847f6283f/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ba5cf98b5dcb9bddd857da6713a503fa6d341043258ca823f0f5ab7ab4a94ee8", size = 1745557, upload-time = "2026-03-28T17:15:50.13Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/01/1ef1adae1454341ec50a789f03cfafe4c4ac9c003f6a64515ecd32fe4210/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:d85965d3ba21ee4999e83e992fecb86c4614d6920e40705501c0a1f80a583c12", size = 1741796, upload-time = "2026-03-28T17:15:52.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/04/8cdd99af988d2aa6922714d957d21383c559835cbd43fbf5a47ddf2e0f05/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:49f0b18a9b05d79f6f37ddd567695943fcefb834ef480f17a4211987302b2dc7", size = 1805312, upload-time = "2026-03-28T17:15:54.407Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/7f/b48d5577338d4b25bbdbae35c75dbfd0493cb8886dc586fbfb2e90862239/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7f78cb080c86fbf765920e5f1ef35af3f24ec4314d6675d0a21eaf41f6f2679c", size = 1621751, upload-time = "2026-03-28T17:15:56.564Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/89/4eecad8c1858e6d0893c05929e22343e0ebe3aec29a8a399c65c3cc38311/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:67a3ec705534a614b68bbf1c70efa777a21c3da3895d1c44510a41f5a7ae0453", size = 1826073, upload-time = "2026-03-28T17:15:58.489Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/5c/9dc8293ed31b46c39c9c513ac7ca152b3c3d38e0ea111a530ad12001b827/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d6630ec917e85c5356b2295744c8a97d40f007f96a1c76bf1928dc2e27465393", size = 1760083, upload-time = "2026-03-28T17:16:00.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/19/8bbf6a4994205d96831f97b7d21a0feed120136e6267b5b22d229c6dc4dc/aiohttp-3.13.4-cp311-cp311-win32.whl", hash = "sha256:54049021bc626f53a5394c29e8c444f726ee5a14b6e89e0ad118315b1f90f5e3", size = 439690, upload-time = "2026-03-28T17:16:02.902Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/f5/ac409ecd1007528d15c3e8c3a57d34f334c70d76cfb7128a28cffdebd4c1/aiohttp-3.13.4-cp311-cp311-win_amd64.whl", hash = "sha256:c033f2bc964156030772d31cbf7e5defea181238ce1f87b9455b786de7d30145", size = 463824, upload-time = "2026-03-28T17:16:05.058Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/bd/ede278648914cabbabfdf95e436679b5d4156e417896a9b9f4587169e376/aiohttp-3.13.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ee62d4471ce86b108b19c3364db4b91180d13fe3510144872d6bad5401957360", size = 752158, upload-time = "2026-03-28T17:16:06.901Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/de/581c053253c07b480b03785196ca5335e3c606a37dc73e95f6527f1591fe/aiohttp-3.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c0fd8f41b54b58636402eb493afd512c23580456f022c1ba2db0f810c959ed0d", size = 501037, upload-time = "2026-03-28T17:16:08.82Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/f9/a5ede193c08f13cc42c0a5b50d1e246ecee9115e4cf6e900d8dbd8fd6acb/aiohttp-3.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4baa48ce49efd82d6b1a0be12d6a36b35e5594d1dd42f8bfba96ea9f8678b88c", size = 501556, upload-time = "2026-03-28T17:16:10.63Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/10/88ff67cd48a6ec36335b63a640abe86135791544863e0cfe1f065d6cef7a/aiohttp-3.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d738ebab9f71ee652d9dbd0211057690022201b11197f9a7324fd4dba128aa97", size = 1757314, upload-time = "2026-03-28T17:16:12.498Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/15/fdb90a5cf5a1f52845c276e76298c75fbbcc0ac2b4a86551906d54529965/aiohttp-3.13.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0ce692c3468fa831af7dceed52edf51ac348cebfc8d3feb935927b63bd3e8576", size = 1731819, upload-time = "2026-03-28T17:16:14.558Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/df/28146785a007f7820416be05d4f28cc207493efd1e8c6c1068e9bdc29198/aiohttp-3.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8e08abcfe752a454d2cb89ff0c08f2d1ecd057ae3e8cc6d84638de853530ebab", size = 1793279, upload-time = "2026-03-28T17:16:16.594Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/47/689c743abf62ea7a77774d5722f220e2c912a77d65d368b884d9779ef41b/aiohttp-3.13.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5977f701b3fff36367a11087f30ea73c212e686d41cd363c50c022d48b011d8d", size = 1891082, upload-time = "2026-03-28T17:16:18.71Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/b6/f7f4f318c7e58c23b761c9b13b9a3c9b394e0f9d5d76fbc6622fa98509f6/aiohttp-3.13.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54203e10405c06f8b6020bd1e076ae0fe6c194adcee12a5a78af3ffa3c57025e", size = 1773938, upload-time = "2026-03-28T17:16:21.125Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/06/f207cb3121852c989586a6fc16ff854c4fcc8651b86c5d3bd1fc83057650/aiohttp-3.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:358a6af0145bc4dda037f13167bef3cce54b132087acc4c295c739d05d16b1c3", size = 1579548, upload-time = "2026-03-28T17:16:23.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/58/e1289661a32161e24c1fe479711d783067210d266842523752869cc1d9c2/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:898ea1850656d7d61832ef06aa9846ab3ddb1621b74f46de78fbc5e1a586ba83", size = 1714669, upload-time = "2026-03-28T17:16:25.713Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/0a/3e86d039438a74a86e6a948a9119b22540bae037d6ba317a042ae3c22711/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7bc30cceb710cf6a44e9617e43eebb6e3e43ad855a34da7b4b6a73537d8a6763", size = 1754175, upload-time = "2026-03-28T17:16:28.18Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/30/e717fc5df83133ba467a560b6d8ef20197037b4bb5d7075b90037de1018e/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4a31c0c587a8a038f19a4c7e60654a6c899c9de9174593a13e7cc6e15ff271f9", size = 1762049, upload-time = "2026-03-28T17:16:30.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/28/8f7a2d4492e336e40005151bdd94baf344880a4707573378579f833a64c1/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2062f675f3fe6e06d6113eb74a157fb9df58953ffed0cdb4182554b116545758", size = 1570861, upload-time = "2026-03-28T17:16:32.953Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/45/12e1a3d0645968b1c38de4b23fdf270b8637735ea057d4f84482ff918ad9/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d1ba8afb847ff80626d5e408c1fdc99f942acc877d0702fe137015903a220a9", size = 1790003, upload-time = "2026-03-28T17:16:35.468Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/0f/60374e18d590de16dcb39d6ff62f39c096c1b958e6f37727b5870026ea30/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b08149419994cdd4d5eecf7fd4bc5986b5a9380285bcd01ab4c0d6bfca47b79d", size = 1737289, upload-time = "2026-03-28T17:16:38.187Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/bf/535e58d886cfbc40a8b0013c974afad24ef7632d645bca0b678b70033a60/aiohttp-3.13.4-cp312-cp312-win32.whl", hash = "sha256:fc432f6a2c4f720180959bc19aa37259651c1a4ed8af8afc84dd41c60f15f791", size = 434185, upload-time = "2026-03-28T17:16:40.735Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/1a/d92e3325134ebfff6f4069f270d3aac770d63320bd1fcd0eca023e74d9a8/aiohttp-3.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:6148c9ae97a3e8bff9a1fc9c757fa164116f86c100468339730e717590a3fb77", size = 461285, upload-time = "2026-03-28T17:16:42.713Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/ac/892f4162df9b115b4758d615f32ec63d00f3084c705ff5526630887b9b42/aiohttp-3.13.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:63dd5e5b1e43b8fb1e91b79b7ceba1feba588b317d1edff385084fcc7a0a4538", size = 745744, upload-time = "2026-03-28T17:16:44.67Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/a9/c5b87e4443a2f0ea88cb3000c93a8fdad1ee63bffc9ded8d8c8e0d66efc6/aiohttp-3.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:746ac3cc00b5baea424dacddea3ec2c2702f9590de27d837aa67004db1eebc6e", size = 498178, upload-time = "2026-03-28T17:16:46.766Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/42/07e1b543a61250783650df13da8ddcdc0d0a5538b2bd15cef6e042aefc61/aiohttp-3.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bda8f16ea99d6a6705e5946732e48487a448be874e54a4f73d514660ff7c05d3", size = 498331, upload-time = "2026-03-28T17:16:48.9Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/d6/492f46bf0328534124772d0cf58570acae5b286ea25006900650f69dae0e/aiohttp-3.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b061e7b5f840391e3f64d0ddf672973e45c4cfff7a0feea425ea24e51530fc2", size = 1744414, upload-time = "2026-03-28T17:16:50.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/4d/e02627b2683f68051246215d2d62b2d2f249ff7a285e7a858dc47d6b6a14/aiohttp-3.13.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b252e8d5cd66184b570d0d010de742736e8a4fab22c58299772b0c5a466d4b21", size = 1719226, upload-time = "2026-03-28T17:16:53.173Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/6c/5d0a3394dd2b9f9aeba6e1b6065d0439e4b75d41f1fb09a3ec010b43552b/aiohttp-3.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:20af8aad61d1803ff11152a26146d8d81c266aa8c5aa9b4504432abb965c36a0", size = 1782110, upload-time = "2026-03-28T17:16:55.362Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/2d/c20791e3437700a7441a7edfb59731150322424f5aadf635602d1d326101/aiohttp-3.13.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:13a5cc924b59859ad2adb1478e31f410a7ed46e92a2a619d6d1dd1a63c1a855e", size = 1884809, upload-time = "2026-03-28T17:16:57.734Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/94/d99dbfbd1924a87ef643833932eb2a3d9e5eee87656efea7d78058539eff/aiohttp-3.13.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:534913dfb0a644d537aebb4123e7d466d94e3be5549205e6a31f72368980a81a", size = 1764938, upload-time = "2026-03-28T17:17:00.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/61/3ce326a1538781deb89f6cf5e094e2029cd308ed1e21b2ba2278b08426f6/aiohttp-3.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:320e40192a2dcc1cf4b5576936e9652981ab596bf81eb309535db7e2f5b5672f", size = 1570697, upload-time = "2026-03-28T17:17:02.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/77/4ab5a546857bb3028fbaf34d6eea180267bdab022ee8b1168b1fcde4bfdd/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9e587fcfce2bcf06526a43cb705bdee21ac089096f2e271d75de9c339db3100c", size = 1702258, upload-time = "2026-03-28T17:17:05.28Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/63/d8f29021e39bc5af8e5d5e9da1b07976fb9846487a784e11e4f4eeda4666/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9eb9c2eea7278206b5c6c1441fdd9dc420c278ead3f3b2cc87f9b693698cc500", size = 1740287, upload-time = "2026-03-28T17:17:07.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/3a/cbc6b3b124859a11bc8055d3682c26999b393531ef926754a3445b99dfef/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:29be00c51972b04bf9d5c8f2d7f7314f48f96070ca40a873a53056e652e805f7", size = 1753011, upload-time = "2026-03-28T17:17:10.053Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/30/836278675205d58c1368b21520eab9572457cf19afd23759216c04483048/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90c06228a6c3a7c9f776fe4fc0b7ff647fffd3bed93779a6913c804ae00c1073", size = 1566359, upload-time = "2026-03-28T17:17:12.433Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/b4/8032cc9b82d17e4277704ba30509eaccb39329dc18d6a35f05e424439e32/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a533ec132f05fd9a1d959e7f34184cd7d5e8511584848dab85faefbaac573069", size = 1785537, upload-time = "2026-03-28T17:17:14.721Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/7d/5873e98230bde59f493bf1f7c3e327486a4b5653fa401144704df5d00211/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1c946f10f413836f82ea4cfb90200d2a59578c549f00857e03111cf45ad01ca5", size = 1740752, upload-time = "2026-03-28T17:17:17.387Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/f2/13e46e0df051494d7d3c68b7f72d071f48c384c12716fc294f75d5b1a064/aiohttp-3.13.4-cp313-cp313-win32.whl", hash = "sha256:48708e2706106da6967eff5908c78ca3943f005ed6bcb75da2a7e4da94ef8c70", size = 433187, upload-time = "2026-03-28T17:17:19.523Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/c0/649856ee655a843c8f8664592cfccb73ac80ede6a8c8db33a25d810c12db/aiohttp-3.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:74a2eb058da44fa3a877a49e2095b591d4913308bb424c418b77beb160c55ce3", size = 459778, upload-time = "2026-03-28T17:17:21.964Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/29/6657cc37ae04cacc2dbf53fb730a06b6091cc4cbe745028e047c53e6d840/aiohttp-3.13.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:e0a2c961fc92abeff61d6444f2ce6ad35bb982db9fc8ff8a47455beacf454a57", size = 749363, upload-time = "2026-03-28T17:17:24.044Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/7f/30ccdf67ca3d24b610067dc63d64dcb91e5d88e27667811640644aa4a85d/aiohttp-3.13.4-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:153274535985a0ff2bff1fb6c104ed547cec898a09213d21b0f791a44b14d933", size = 499317, upload-time = "2026-03-28T17:17:26.199Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/13/e372dd4e68ad04ee25dafb050c7f98b0d91ea643f7352757e87231102555/aiohttp-3.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:351f3171e2458da3d731ce83f9e6b9619e325c45cbd534c7759750cabf453ad7", size = 500477, upload-time = "2026-03-28T17:17:28.279Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/fe/ee6298e8e586096fb6f5eddd31393d8544f33ae0792c71ecbb4c2bef98ac/aiohttp-3.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f989ac8bc5595ff761a5ccd32bdb0768a117f36dd1504b1c2c074ed5d3f4df9c", size = 1737227, upload-time = "2026-03-28T17:17:30.587Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/b9/a7a0463a09e1a3fe35100f74324f23644bfc3383ac5fd5effe0722a5f0b7/aiohttp-3.13.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d36fc1709110ec1e87a229b201dd3ddc32aa01e98e7868083a794609b081c349", size = 1694036, upload-time = "2026-03-28T17:17:33.29Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/7c/8972ae3fb7be00a91aee6b644b2a6a909aedb2c425269a3bfd90115e6f8f/aiohttp-3.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42adaeea83cbdf069ab94f5103ce0787c21fb1a0153270da76b59d5578302329", size = 1786814, upload-time = "2026-03-28T17:17:36.035Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/01/c81e97e85c774decbaf0d577de7d848934e8166a3a14ad9f8aa5be329d28/aiohttp-3.13.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:92deb95469928cc41fd4b42a95d8012fa6df93f6b1c0a83af0ffbc4a5e218cde", size = 1866676, upload-time = "2026-03-28T17:17:38.441Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/5f/5b46fe8694a639ddea2cd035bf5729e4677ea882cb251396637e2ef1590d/aiohttp-3.13.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c0c7c07c4257ef3a1df355f840bc62d133bcdef5c1c5ba75add3c08553e2eed", size = 1740842, upload-time = "2026-03-28T17:17:40.783Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/a2/0d4b03d011cca6b6b0acba8433193c1e484efa8d705ea58295590fe24203/aiohttp-3.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f062c45de8a1098cb137a1898819796a2491aec4e637a06b03f149315dff4d8f", size = 1566508, upload-time = "2026-03-28T17:17:43.235Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/17/e689fd500da52488ec5f889effd6404dece6a59de301e380f3c64f167beb/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:76093107c531517001114f0ebdb4f46858ce818590363e3e99a4a2280334454a", size = 1700569, upload-time = "2026-03-28T17:17:46.165Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/0d/66402894dbcf470ef7db99449e436105ea862c24f7ea4c95c683e635af35/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:6f6ec32162d293b82f8b63a16edc80769662fbd5ae6fbd4936d3206a2c2cc63b", size = 1707407, upload-time = "2026-03-28T17:17:48.825Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/eb/af0ab1a3650092cbd8e14ef29e4ab0209e1460e1c299996c3f8288b3f1ff/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5903e2db3d202a00ad9f0ec35a122c005e85d90c9836ab4cda628f01edf425e2", size = 1752214, upload-time = "2026-03-28T17:17:51.206Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/bf/72326f8a98e4c666f292f03c385545963cc65e358835d2a7375037a97b57/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2d5bea57be7aca98dbbac8da046d99b5557c5cf4e28538c4c786313078aca09e", size = 1562162, upload-time = "2026-03-28T17:17:53.634Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/9f/13b72435f99151dd9a5469c96b3b5f86aa29b7e785ca7f35cf5e538f74c0/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:bcf0c9902085976edc0232b75006ef38f89686901249ce14226b6877f88464fb", size = 1768904, upload-time = "2026-03-28T17:17:55.991Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/bc/28d4970e7d5452ac7776cdb5431a1164a0d9cf8bd2fffd67b4fb463aa56d/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3295f98bfeed2e867cab588f2a146a9db37a85e3ae9062abf46ba062bd29165", size = 1723378, upload-time = "2026-03-28T17:17:58.348Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/74/b32458ca1a7f34d65bdee7aef2036adbe0438123d3d53e2b083c453c24dd/aiohttp-3.13.4-cp314-cp314-win32.whl", hash = "sha256:a598a5c5767e1369d8f5b08695cab1d8160040f796c4416af76fd773d229b3c9", size = 438711, upload-time = "2026-03-28T17:18:00.728Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/b2/54b487316c2df3e03a8f3435e9636f8a81a42a69d942164830d193beb56a/aiohttp-3.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:c555db4bc7a264bead5a7d63d92d41a1122fcd39cc62a4db815f45ad46f9c2c8", size = 464977, upload-time = "2026-03-28T17:18:03.367Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/fb/e41b63c6ce71b07a59243bb8f3b457ee0c3402a619acb9d2c0d21ef0e647/aiohttp-3.13.4-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45abbbf09a129825d13c18c7d3182fecd46d9da3cfc383756145394013604ac1", size = 781549, upload-time = "2026-03-28T17:18:05.779Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/53/532b8d28df1e17e44c4d9a9368b78dcb6bf0b51037522136eced13afa9e8/aiohttp-3.13.4-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:74c80b2bc2c2adb7b3d1941b2b60701ee2af8296fc8aad8b8bc48bc25767266c", size = 514383, upload-time = "2026-03-28T17:18:08.096Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/1f/62e5d400603e8468cd635812d99cb81cfdc08127a3dc474c647615f31339/aiohttp-3.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c97989ae40a9746650fa196894f317dafc12227c808c774929dda0ff873a5954", size = 518304, upload-time = "2026-03-28T17:18:10.642Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/57/2326b37b10896447e3c6e0cbef4fe2486d30913639a5cfd1332b5d870f82/aiohttp-3.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dae86be9811493f9990ef44fff1685f5c1a3192e9061a71a109d527944eed551", size = 1893433, upload-time = "2026-03-28T17:18:13.121Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/b4/a24d82112c304afdb650167ef2fe190957d81cbddac7460bedd245f765aa/aiohttp-3.13.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1db491abe852ca2fa6cc48a3341985b0174b3741838e1341b82ac82c8bd9e871", size = 1755901, upload-time = "2026-03-28T17:18:16.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/2d/0883ef9d878d7846287f036c162a951968f22aabeef3ac97b0bea6f76d5d/aiohttp-3.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0e5d701c0aad02a7dce72eef6b93226cf3734330f1a31d69ebbf69f33b86666e", size = 1876093, upload-time = "2026-03-28T17:18:18.703Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/52/9204bb59c014869b71971addad6778f005daa72a96eed652c496789d7468/aiohttp-3.13.4-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8ac32a189081ae0a10ba18993f10f338ec94341f0d5df8fff348043962f3c6f8", size = 1970815, upload-time = "2026-03-28T17:18:21.858Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/b5/e4eb20275a866dde0f570f411b36c6b48f7b53edfe4f4071aa1b0728098a/aiohttp-3.13.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98e968cdaba43e45c73c3f306fca418c8009a957733bac85937c9f9cf3f4de27", size = 1816223, upload-time = "2026-03-28T17:18:24.729Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/23/e98075c5bb146aa61a1239ee1ac7714c85e814838d6cebbe37d3fe19214a/aiohttp-3.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca114790c9144c335d538852612d3e43ea0f075288f4849cf4b05d6cd2238ce7", size = 1649145, upload-time = "2026-03-28T17:18:27.269Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/c1/7bad8be33bb06c2bb224b6468874346026092762cbec388c3bdb65a368ee/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ea2e071661ba9cfe11eabbc81ac5376eaeb3061f6e72ec4cc86d7cdd1ffbdbbb", size = 1816562, upload-time = "2026-03-28T17:18:29.847Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/10/c00323348695e9a5e316825969c88463dcc24c7e9d443244b8a2c9cf2eae/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:34e89912b6c20e0fd80e07fa401fd218a410aa1ce9f1c2f1dad6db1bd0ce0927", size = 1800333, upload-time = "2026-03-28T17:18:32.269Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/43/9b2147a1df3559f49bd723e22905b46a46c068a53adb54abdca32c4de180/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0e217cf9f6a42908c52b46e42c568bd57adc39c9286ced31aaace614b6087965", size = 1820617, upload-time = "2026-03-28T17:18:35.238Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/7f/b3481a81e7a586d02e99387b18c6dafff41285f6efd3daa2124c01f87eae/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:0c296f1221e21ba979f5ac1964c3b78cfde15c5c5f855ffd2caab337e9cd9182", size = 1643417, upload-time = "2026-03-28T17:18:37.949Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/72/07181226bc99ce1124e0f89280f5221a82d3ae6a6d9d1973ce429d48e52b/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d99a9d168ebaffb74f36d011750e490085ac418f4db926cce3989c8fe6cb6b1b", size = 1849286, upload-time = "2026-03-28T17:18:40.534Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/e6/1b3566e103eca6da5be4ae6713e112a053725c584e96574caf117568ffef/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cb19177205d93b881f3f89e6081593676043a6828f59c78c17a0fd6c1fbed2ba", size = 1782635, upload-time = "2026-03-28T17:18:43.073Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/58/1b11c71904b8d079eb0c39fe664180dd1e14bebe5608e235d8bfbadc8929/aiohttp-3.13.4-cp314-cp314t-win32.whl", hash = "sha256:c606aa5656dab6552e52ca368e43869c916338346bfaf6304e15c58fb113ea30", size = 472537, upload-time = "2026-03-28T17:18:46.286Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/8f/87c56a1a1977d7dddea5b31e12189665a140fdb48a71e9038ff90bb564ec/aiohttp-3.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:014dcc10ec8ab8db681f0d68e939d1e9286a5aa2b993cbbdb0db130853e02144", size = 506381, upload-time = "2026-03-28T17:18:48.74Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -648,7 +648,7 @@ requires-dist = [
|
||||
{ name = "mini-swe-agent", specifier = ">=2.0.0" },
|
||||
{ name = "pandas", specifier = ">=2.0.0" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.3" },
|
||||
{ name = "python-dotenv", specifier = ">=1.0.0" },
|
||||
{ name = "python-dotenv", specifier = ">=1.2.2" },
|
||||
{ name = "pyyaml", specifier = ">=6.0" },
|
||||
{ name = "rich", specifier = ">=13.0.0" },
|
||||
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.5.0" },
|
||||
@@ -926,7 +926,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "litellm"
|
||||
version = "1.83.7"
|
||||
version = "1.83.14"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
@@ -942,9 +942,9 @@ dependencies = [
|
||||
{ name = "tiktoken" },
|
||||
{ name = "tokenizers" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/77/2b/b58bf6bbcbc3d0e55d0a84fdf9128e5b1436517f46fce89b1cd8948ebb81/litellm-1.83.7.tar.gz", hash = "sha256:e2f2cb99df2e2b2eab63f1354faa45c88dd7c8d40c18eb648afb1b349c689633", size = 17791694, upload-time = "2026-04-13T17:35:01.606Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8d/7c/c095649380adc96c8630273c1768c2ad1e74aa2ee1dd8dd05d218a60569f/litellm-1.83.14.tar.gz", hash = "sha256:24aef9b47cdc424c833e32f3727f411741c690832cd1fe4405e0077144fe09c9", size = 14836599, upload-time = "2026-04-26T03:16:10.176Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/75/80/caeb4cdcad96451ba83ad3ba2a9da08b1e1a915fa845c489f56ea044488b/litellm-1.83.7-py3-none-any.whl", hash = "sha256:5784a1d9a9a4a8acd6ca1e347003a5e2e1b3c749b4d41e7da4904577adade111", size = 16069807, upload-time = "2026-04-13T17:34:58.36Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/5c/1b5691575420135e90578543b2bf219497caa33cfd0af64cb38f30288450/litellm-1.83.14-py3-none-any.whl", hash = "sha256:92b11ba2a32cf80707ddf388d18526696c7999a21b418c5e3b6eda1243d2cfdb", size = 16457054, upload-time = "2026-04-26T03:16:05.72Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1302,7 +1302,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "openai"
|
||||
version = "2.30.0"
|
||||
version = "2.24.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
@@ -1314,9 +1314,9 @@ dependencies = [
|
||||
{ name = "tqdm" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/88/15/52580c8fbc16d0675d516e8749806eda679b16de1e4434ea06fb6feaa610/openai-2.30.0.tar.gz", hash = "sha256:92f7661c990bda4b22a941806c83eabe4896c3094465030dd882a71abe80c885", size = 676084, upload-time = "2026-03-25T22:08:59.96Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/55/13/17e87641b89b74552ed408a92b231283786523edddc95f3545809fab673c/openai-2.24.0.tar.gz", hash = "sha256:1e5769f540dbd01cb33bc4716a23e67b9d695161a734aff9c5f925e2bf99a673", size = 658717, upload-time = "2026-02-24T20:02:07.958Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/9e/5bfa2270f902d5b92ab7d41ce0475b8630572e71e349b2a4996d14bdda93/openai-2.30.0-py3-none-any.whl", hash = "sha256:9a5ae616888eb2748ec5e0c5b955a51592e0b201a11f4262db920f2a78c5231d", size = 1146656, upload-time = "2026-03-25T22:08:58.2Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/30/844dc675ee6902579b8eef01ed23917cc9319a1c9c0c14ec6e39340c96d0/openai-2.24.0-py3-none-any.whl", hash = "sha256:fed30480d7d6c884303287bde864980a4b137b60553ffbcf9ab4a233b7a73d94", size = 1120122, upload-time = "2026-02-24T20:02:05.669Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1718,11 +1718,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "python-dotenv"
|
||||
version = "1.0.1"
|
||||
version = "1.2.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115, upload-time = "2024-01-23T06:33:00.505Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863, upload-time = "2024-01-23T06:32:58.246Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2278,11 +2278,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.6.3"
|
||||
version = "2.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
const { acquireHookSlot } = require('./hook-lock.js');
|
||||
|
||||
/**
|
||||
* Read JSON input from stdin synchronously.
|
||||
@@ -217,7 +218,8 @@ function sendHookResponse(hookEventName, message) {
|
||||
function handlePreToolUse(input) {
|
||||
const cwd = input.cwd || process.cwd();
|
||||
if (!path.isAbsolute(cwd)) return;
|
||||
if (!findGitNexusDir(cwd)) return;
|
||||
const gitNexusDir = findGitNexusDir(cwd);
|
||||
if (!gitNexusDir) return;
|
||||
|
||||
const toolName = input.tool_name || '';
|
||||
const toolInput = input.tool_input || {};
|
||||
@@ -227,6 +229,9 @@ function handlePreToolUse(input) {
|
||||
const pattern = extractPattern(toolName, toolInput);
|
||||
if (!pattern || pattern.length < 3) return;
|
||||
|
||||
const release = acquireHookSlot(gitNexusDir);
|
||||
if (!release) return;
|
||||
|
||||
let result = '';
|
||||
try {
|
||||
const child = runGitNexusCli(['augment', '--', pattern], cwd, 7000);
|
||||
@@ -235,6 +240,8 @@ function handlePreToolUse(input) {
|
||||
}
|
||||
} catch {
|
||||
/* graceful failure */
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
|
||||
if (result && result.trim()) {
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const HOOK_LOCK_SUBDIR = '.hook-locks';
|
||||
const HOOK_LOCK_MAX_INFLIGHT = 3;
|
||||
const HOOK_LOCK_STALE_MS = 30000;
|
||||
|
||||
function acquireHookSlot(gitNexusDir) {
|
||||
const lockDir = path.join(gitNexusDir, HOOK_LOCK_SUBDIR);
|
||||
try {
|
||||
fs.mkdirSync(lockDir, { recursive: true });
|
||||
} catch {
|
||||
// Cannot create lock dir (read-only fs, cross-user perm denial, out of
|
||||
// inodes, etc.) — fail closed by returning null. Caller skips augment.
|
||||
// Fail-open here would let N concurrent hooks all proceed unguarded and
|
||||
// reintroduce the #1486 fan-out the guard exists to prevent.
|
||||
return null;
|
||||
}
|
||||
|
||||
const myPidStr = String(process.pid);
|
||||
|
||||
for (let slot = 0; slot < HOOK_LOCK_MAX_INFLIGHT; slot++) {
|
||||
const slotPath = path.join(lockDir, `slot-${slot}.lock`);
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
try {
|
||||
fs.writeFileSync(slotPath, myPidStr, { flag: 'wx' });
|
||||
let released = false;
|
||||
const release = () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
try {
|
||||
// Only unlink if we still own the slot. If we appeared stale and
|
||||
// another hook took over, the file now belongs to it — leave alone.
|
||||
const content = fs.readFileSync(slotPath, 'utf-8').trim();
|
||||
if (content === myPidStr) fs.unlinkSync(slotPath);
|
||||
} catch {
|
||||
/* already removed or unreadable */
|
||||
}
|
||||
};
|
||||
process.on('exit', release);
|
||||
return release;
|
||||
} catch {
|
||||
// Slot exists. Decide whether to take it over.
|
||||
// Open once and inspect mtime + content via the same fd so there's
|
||||
// no TOCTOU between the metadata check and the content read
|
||||
// (codeql js/file-system-race).
|
||||
let fd;
|
||||
try {
|
||||
fd = fs.openSync(slotPath, 'r');
|
||||
} catch {
|
||||
continue; // Vanished between EEXIST and open — retry this slot.
|
||||
}
|
||||
let isLive = false;
|
||||
let mtimeMs = Date.now();
|
||||
try {
|
||||
mtimeMs = fs.fstatSync(fd).mtimeMs;
|
||||
const buf = Buffer.alloc(32);
|
||||
const n = fs.readSync(fd, buf, 0, 32, 0);
|
||||
const ownerStr = buf.slice(0, n).toString('utf-8').trim();
|
||||
if (ownerStr === '') {
|
||||
// Owner created the file but hasn't written its PID yet. The
|
||||
// wx open+write window is microseconds; give it the benefit
|
||||
// of the doubt and treat as live.
|
||||
isLive = true;
|
||||
} else {
|
||||
const owner = Number.parseInt(ownerStr, 10);
|
||||
if (Number.isFinite(owner) && owner > 0) {
|
||||
try {
|
||||
process.kill(owner, 0);
|
||||
isLive = true;
|
||||
} catch (e) {
|
||||
// ESRCH = process gone → treat as dead. EPERM = process exists
|
||||
// but owned by another user (cross-user lock dir) → still alive,
|
||||
// keep the slot. Anything else: be conservative, assume alive.
|
||||
if (e && e.code === 'ESRCH') {
|
||||
isLive = false;
|
||||
} else {
|
||||
isLive = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* unreadable — treat as dead */
|
||||
} finally {
|
||||
try {
|
||||
fs.closeSync(fd);
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
}
|
||||
// For slots younger than HOOK_LOCK_STALE_MS, PID-liveness wins —
|
||||
// a slow-but-alive hook is never wrongly evicted. For older slots,
|
||||
// age is the final arbiter as a defense against PID reuse on long-
|
||||
// abandoned slots. 30s >> the 7s augment timeout, so a healthy run
|
||||
// never crosses this threshold.
|
||||
if (isLive && Date.now() - mtimeMs > HOOK_LOCK_STALE_MS) {
|
||||
isLive = false;
|
||||
}
|
||||
if (isLive) break; // Try the next slot.
|
||||
try {
|
||||
fs.unlinkSync(slotPath);
|
||||
} catch {
|
||||
/* another hook beat us to it — retry will hit EEXIST */
|
||||
}
|
||||
// Loop and retry this slot.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
HOOK_LOCK_SUBDIR,
|
||||
HOOK_LOCK_MAX_INFLIGHT,
|
||||
HOOK_LOCK_STALE_MS,
|
||||
acquireHookSlot,
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
# GitNexus — Cursor integration
|
||||
|
||||
Static config that adds GitNexus knowledge-graph augmentation and skill files to Cursor.
|
||||
|
||||
> **Hooks require Cursor 2.4+.** Earlier versions don't expose `postToolUse` and the hook will silently no-op.
|
||||
|
||||
## What you get
|
||||
|
||||
| Layer | What it does | How it's installed |
|
||||
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
|
||||
| **MCP** | `gitnexus` MCP server with 16 tools (`query`, `context`, `impact`, `detect_changes`, `rename`, …) | `npx gitnexus setup` writes `~/.cursor/mcp.json` automatically. |
|
||||
| **Skills** | `/gitnexus-exploring`, `/gitnexus-debugging`, `/gitnexus-impact-analysis`, `/gitnexus-refactoring`, `/gitnexus-pr-review` markdown skills | `npx gitnexus setup` copies them to `~/.cursor/skills/gitnexus/`. |
|
||||
| **Hooks** _(this README)_ | `postToolUse` hook that enriches `Shell` / `Read` / `Grep` tool calls with graph context — same augmentation Claude Code gets | **Manual** — copy the files described below into your project's `.cursor/`. |
|
||||
|
||||
## Hook install
|
||||
|
||||
Cursor 2.4+ reads `.cursor/hooks.json` from the project root and runs hook commands with the project root as the working directory ([docs](https://cursor.com/docs/agent/hooks)).
|
||||
|
||||
From this repo's `gitnexus-cursor-integration/hooks/`, copy the files below into your **project root**:
|
||||
|
||||
```text
|
||||
<your-project>/
|
||||
├── .cursor/
|
||||
│ └── hooks.json ← from gitnexus-cursor-integration/hooks/hooks.json
|
||||
└── hooks/
|
||||
├── gitnexus-hook.cjs ← from gitnexus-cursor-integration/hooks/gitnexus-hook.cjs
|
||||
└── hook-lock.cjs ← from gitnexus-cursor-integration/hooks/hook-lock.cjs
|
||||
```
|
||||
|
||||
Equivalent shell commands (run from your project root, with `$GITNEXUS_REPO` pointing at a clone of this repo):
|
||||
|
||||
```bash
|
||||
mkdir -p .cursor hooks
|
||||
cp "$GITNEXUS_REPO/gitnexus-cursor-integration/hooks/hooks.json" .cursor/hooks.json
|
||||
cp "$GITNEXUS_REPO/gitnexus-cursor-integration/hooks/gitnexus-hook.cjs" hooks/gitnexus-hook.cjs
|
||||
cp "$GITNEXUS_REPO/gitnexus-cursor-integration/hooks/hook-lock.cjs" hooks/hook-lock.cjs
|
||||
```
|
||||
|
||||
If you already have a `.cursor/hooks.json`, merge the `hooks.postToolUse` array rather than overwriting.
|
||||
|
||||
### Verify
|
||||
|
||||
1. Index the project: `npx gitnexus analyze`
|
||||
2. Reload the Cursor window so it picks up the new hook config.
|
||||
3. Ask the agent something that triggers `Read` / `Grep` / `Shell rg`. You should see a `[GitNexus]` block appended to the tool result.
|
||||
4. Diagnose silent no-ops by setting `GITNEXUS_DEBUG=1` in your shell environment — the hook will write Cursor's raw event payload to stderr so you can verify field names.
|
||||
|
||||
### What's installed manually vs. automated
|
||||
|
||||
| Step | Automated by `gitnexus setup`? |
|
||||
| -------------------------------------------------------------------- | ------------------------------ |
|
||||
| `~/.cursor/mcp.json` | ✅ |
|
||||
| `~/.cursor/skills/gitnexus/*` | ✅ |
|
||||
| `<project>/.cursor/hooks.json` + `<project>/hooks/gitnexus-hook.cjs` + `<project>/hooks/hook-lock.cjs` | ❌ — copy manually (see above) |
|
||||
|
||||
Hook install is per-project (Cursor scopes hooks to a project root); skills and MCP config are global.
|
||||
|
||||
## Hook contract
|
||||
|
||||
The hook receives a JSON event on stdin matching Cursor 2.4's `postToolUse` shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"tool_name": "Grep" | "Read" | "Shell",
|
||||
"tool_input": { /* tool-specific */ },
|
||||
"tool_output": { /* optional */ },
|
||||
"cwd": "/absolute/path/to/project"
|
||||
}
|
||||
```
|
||||
|
||||
It writes augmentation context to stdout as:
|
||||
|
||||
```json
|
||||
{ "additional_context": "[GitNexus] …" }
|
||||
```
|
||||
|
||||
Empty stdout means "no augmentation, continue normally" — the hook never blocks the tool.
|
||||
|
||||
### Pattern extraction per tool
|
||||
|
||||
| Tool | Pattern source | Notes |
|
||||
| ------- | ---------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
|
||||
| `Grep` | `tool_input.query` (also `pattern`, `regex`, `q`, `search`, `searchQuery`) | Last-resort fallback: longest string value in `tool_input` (≥ 3 chars). |
|
||||
| `Read` | basename of `tool_input.target_file` (also `file_path`, `filePath`, `path`, `file`), stripped to identifier characters | `auth/handler.ts` → `handler`. |
|
||||
| `Shell` | First positional argument after `rg` / `grep` in `tool_input.command` | Best-effort tokenizer; quoted multi-word patterns (`rg "User Service"`) extract the first word only. |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Nothing happens** — Confirm Cursor is on 2.4+ and the project root has `.cursor/hooks.json` plus both hook files at `hooks/gitnexus-hook.cjs` and `hooks/hook-lock.cjs`. Then `npx gitnexus list` to confirm the project is indexed.
|
||||
- **`gitnexus` not found** — The hook prefers a locally-resolvable `gitnexus/dist/cli/index.js` and falls back to `npx -y gitnexus`. Install globally with `npm i -g gitnexus` to skip the npx cold-start latency.
|
||||
- **Wrong pattern extracted** — Set `GITNEXUS_DEBUG=1` and run a tool call. The raw stdin payload is logged to stderr; use it to confirm Cursor's actual `tool_input` field names against the table above. If they differ, file an issue with the captured payload.
|
||||
@@ -1,50 +0,0 @@
|
||||
#!/bin/bash
|
||||
# GitNexus beforeShellExecution hook for Cursor
|
||||
# Receives JSON on stdin with { command, cwd, timeout }
|
||||
# Returns JSON on stdout with { permission, agent_message }
|
||||
#
|
||||
# Extracts search pattern from grep/rg commands, runs gitnexus augment,
|
||||
# and injects the enriched context via agent_message.
|
||||
|
||||
INPUT=$(cat)
|
||||
|
||||
COMMAND=$(echo "$INPUT" | jq -r '.command // empty' 2>/dev/null)
|
||||
|
||||
if [ -z "$COMMAND" ]; then
|
||||
echo '{"permission":"allow"}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Skip non-search commands
|
||||
case "$COMMAND" in
|
||||
cd\ *|npm\ *|yarn\ *|pnpm\ *|git\ commit*|git\ push*|git\ pull*|mkdir\ *|rm\ *|cp\ *|mv\ *|echo\ *|cat\ *)
|
||||
echo '{"permission":"allow"}'
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
# Extract search pattern from rg/grep commands
|
||||
PATTERN=""
|
||||
if echo "$COMMAND" | grep -qE '\brg\b'; then
|
||||
PATTERN=$(echo "$COMMAND" | sed -n "s/.*\brg\s\+\(--[^ ]*\s\+\)*['\"]\\?\([^'\";\| >]*\\).*/\2/p")
|
||||
elif echo "$COMMAND" | grep -qE '\bgrep\b'; then
|
||||
PATTERN=$(echo "$COMMAND" | sed -n "s/.*\bgrep\s\+\(-[^ ]*\s\+\)*['\"]\\?\([^'\";\| >]*\\).*/\2/p")
|
||||
fi
|
||||
|
||||
if [ -z "$PATTERN" ] || [ ${#PATTERN} -lt 3 ]; then
|
||||
echo '{"permission":"allow"}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Run gitnexus augment
|
||||
RESULT=$(npx -y gitnexus augment "$PATTERN" 2>/dev/null)
|
||||
|
||||
if [ -n "$RESULT" ]; then
|
||||
# Escape for JSON
|
||||
ESCAPED=$(echo "$RESULT" | jq -Rs .)
|
||||
echo "{\"permission\":\"allow\",\"agent_message\":$ESCAPED}"
|
||||
else
|
||||
echo '{"permission":"allow"}'
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,266 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* GitNexus Cursor postToolUse Hook
|
||||
*
|
||||
* Receives a JSON event on stdin describing a finished tool call, derives a
|
||||
* search pattern (Grep query, Read file basename, or rg/grep arg from a Shell
|
||||
* command), runs `gitnexus augment <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');
|
||||
const { acquireHookSlot } = require('./hook-lock.cjs');
|
||||
|
||||
function readInput() {
|
||||
try {
|
||||
const data = fs.readFileSync(0, 'utf-8');
|
||||
return JSON.parse(data);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function isGlobalRegistryDir(candidate) {
|
||||
if (fs.existsSync(path.join(candidate, 'meta.json'))) return false;
|
||||
return (
|
||||
fs.existsSync(path.join(candidate, 'registry.json')) ||
|
||||
fs.existsSync(path.join(candidate, 'repos'))
|
||||
);
|
||||
}
|
||||
|
||||
function walkForGitNexusDir(startDir) {
|
||||
let dir = startDir;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const candidate = path.join(dir, '.gitnexus');
|
||||
if (fs.existsSync(candidate)) {
|
||||
if (!isGlobalRegistryDir(candidate)) return candidate;
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findCanonicalRepoRoot(cwd) {
|
||||
try {
|
||||
const result = spawnSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 2000,
|
||||
cwd,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
if (result.error || result.status !== 0) return null;
|
||||
const commonDir = (result.stdout || '').trim();
|
||||
if (!commonDir || !path.isAbsolute(commonDir)) return null;
|
||||
return path.dirname(commonDir);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function findGitNexusDir(startDir) {
|
||||
const cwd = startDir || process.cwd();
|
||||
const fromCwd = walkForGitNexusDir(cwd);
|
||||
if (fromCwd) return fromCwd;
|
||||
const canonicalRoot = findCanonicalRepoRoot(cwd);
|
||||
if (canonicalRoot && canonicalRoot !== cwd) {
|
||||
return walkForGitNexusDir(canonicalRoot);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseRgGrepPattern(cmd) {
|
||||
const tokens = cmd.split(/\s+/);
|
||||
let foundCmd = false;
|
||||
let skipNext = false;
|
||||
const flagsWithValues = new Set([
|
||||
'-e',
|
||||
'-f',
|
||||
'-m',
|
||||
'-A',
|
||||
'-B',
|
||||
'-C',
|
||||
'-g',
|
||||
'--glob',
|
||||
'-t',
|
||||
'--type',
|
||||
'--include',
|
||||
'--exclude',
|
||||
]);
|
||||
|
||||
for (const token of tokens) {
|
||||
if (skipNext) {
|
||||
skipNext = false;
|
||||
continue;
|
||||
}
|
||||
if (!foundCmd) {
|
||||
if (/\brg$|\bgrep$/.test(token)) foundCmd = true;
|
||||
continue;
|
||||
}
|
||||
if (token.startsWith('-')) {
|
||||
if (flagsWithValues.has(token)) skipNext = true;
|
||||
continue;
|
||||
}
|
||||
const cleaned = token.replace(/['"]/g, '');
|
||||
return cleaned.length >= 3 ? cleaned : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a search pattern from the tool input. Cursor 2.4 docs at
|
||||
* https://cursor.com/docs/agent/hooks list the tool *matchers* but do not
|
||||
* formally specify the per-tool tool_input field names, so we probe a
|
||||
* generous set of MCP-style aliases. As a last-resort fallback for Grep
|
||||
* (the highest-frequency search path) we also accept the longest plausible
|
||||
* string value in tool_input. Set GITNEXUS_DEBUG=1 to log the raw payload
|
||||
* to stderr if Cursor changes the contract and aliases stop matching.
|
||||
*/
|
||||
function pickLongestStringValue(obj) {
|
||||
let best = null;
|
||||
if (!obj || typeof obj !== 'object') return null;
|
||||
for (const v of Object.values(obj)) {
|
||||
if (typeof v === 'string' && v.length >= 3 && (!best || v.length > best.length)) {
|
||||
best = v;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function extractPattern(toolName, toolInput) {
|
||||
const t = (toolName || '').toLowerCase();
|
||||
|
||||
if (t === 'grep') {
|
||||
const aliases = [
|
||||
toolInput.query,
|
||||
toolInput.pattern,
|
||||
toolInput.regex,
|
||||
toolInput.q,
|
||||
toolInput.search,
|
||||
toolInput.searchQuery,
|
||||
];
|
||||
for (const a of aliases) {
|
||||
if (typeof a === 'string' && a.length >= 3) return a;
|
||||
}
|
||||
// Last resort: scan tool_input for any reasonable-looking string value.
|
||||
return pickLongestStringValue(toolInput);
|
||||
}
|
||||
|
||||
if (t === 'read') {
|
||||
const filePath =
|
||||
toolInput.target_file ||
|
||||
toolInput.file_path ||
|
||||
toolInput.filePath ||
|
||||
toolInput.path ||
|
||||
toolInput.file ||
|
||||
'';
|
||||
if (!filePath) return null;
|
||||
const base = path.basename(String(filePath), path.extname(String(filePath)));
|
||||
const cleaned = base.replace(/[^a-zA-Z0-9_]/g, '');
|
||||
return cleaned.length >= 3 ? cleaned : null;
|
||||
}
|
||||
|
||||
if (t === 'shell') {
|
||||
const cmd = toolInput.command || '';
|
||||
if (!/\brg\b|\bgrep\b/.test(cmd)) return null;
|
||||
// NOTE: parseRgGrepPattern uses split(/\s+/) and cannot handle shell
|
||||
// quoting. `rg "User Service" src/` returns "User" (the first token
|
||||
// after the rg/grep arg, with surrounding quotes stripped) — the
|
||||
// multi-word pattern is intentionally not reconstructed since BM25 is
|
||||
// already token-tolerant. Quoted single tokens (`rg "validateUser"`)
|
||||
// work fine.
|
||||
return parseRgGrepPattern(cmd);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveCliPath() {
|
||||
try {
|
||||
return require.resolve('gitnexus/dist/cli/index.js');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function runGitNexusCli(cliPath, args, cwd, timeout) {
|
||||
const isWin = process.platform === 'win32';
|
||||
if (cliPath) {
|
||||
return spawnSync(process.execPath, [cliPath, ...args], {
|
||||
encoding: 'utf-8',
|
||||
timeout,
|
||||
cwd,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
}
|
||||
return spawnSync(isWin ? 'npx.cmd' : 'npx', ['-y', 'gitnexus', ...args], {
|
||||
encoding: 'utf-8',
|
||||
timeout: timeout + 5000,
|
||||
cwd,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
}
|
||||
|
||||
function main() {
|
||||
try {
|
||||
const input = readInput();
|
||||
if (process.env.GITNEXUS_DEBUG) {
|
||||
// Echo the payload so users can capture Cursor's actual contract when
|
||||
// diagnosing why augmentation isn't firing. Stderr only — stdout is
|
||||
// reserved for the JSON response Cursor consumes.
|
||||
try {
|
||||
process.stderr.write(
|
||||
`GitNexus Cursor hook stdin: ${JSON.stringify(input).slice(0, 500)}\n`,
|
||||
);
|
||||
} catch {
|
||||
/* never let debug logging break the hook */
|
||||
}
|
||||
}
|
||||
const cwd = input.cwd || process.cwd();
|
||||
if (!path.isAbsolute(cwd)) return;
|
||||
const gitNexusDir = findGitNexusDir(cwd);
|
||||
if (!gitNexusDir) return;
|
||||
|
||||
const toolName = input.tool_name || '';
|
||||
const toolInput = input.tool_input || {};
|
||||
|
||||
const pattern = extractPattern(toolName, toolInput);
|
||||
if (!pattern || pattern.length < 3) return;
|
||||
|
||||
const release = acquireHookSlot(gitNexusDir);
|
||||
if (!release) return;
|
||||
|
||||
const cliPath = resolveCliPath();
|
||||
let result = '';
|
||||
try {
|
||||
const child = runGitNexusCli(cliPath, ['augment', '--', pattern], cwd, 7000);
|
||||
if (!child.error && child.status === 0) {
|
||||
result = child.stderr || '';
|
||||
}
|
||||
} catch {
|
||||
/* graceful failure */
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
|
||||
if (result && result.trim()) {
|
||||
console.log(JSON.stringify({ additional_context: result.trim() }));
|
||||
}
|
||||
} catch (err) {
|
||||
if (process.env.GITNEXUS_DEBUG) {
|
||||
console.error('GitNexus Cursor hook error:', (err.message || '').slice(0, 200));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,119 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const HOOK_LOCK_SUBDIR = '.hook-locks';
|
||||
const HOOK_LOCK_MAX_INFLIGHT = 3;
|
||||
const HOOK_LOCK_STALE_MS = 30000;
|
||||
|
||||
function acquireHookSlot(gitNexusDir) {
|
||||
const lockDir = path.join(gitNexusDir, HOOK_LOCK_SUBDIR);
|
||||
try {
|
||||
fs.mkdirSync(lockDir, { recursive: true });
|
||||
} catch {
|
||||
// Cannot create lock dir (read-only fs, cross-user perm denial, out of
|
||||
// inodes, etc.) — fail closed by returning null. Caller skips augment.
|
||||
// Fail-open here would let N concurrent hooks all proceed unguarded and
|
||||
// reintroduce the #1486 fan-out the guard exists to prevent.
|
||||
return null;
|
||||
}
|
||||
|
||||
const myPidStr = String(process.pid);
|
||||
|
||||
for (let slot = 0; slot < HOOK_LOCK_MAX_INFLIGHT; slot++) {
|
||||
const slotPath = path.join(lockDir, `slot-${slot}.lock`);
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
try {
|
||||
fs.writeFileSync(slotPath, myPidStr, { flag: 'wx' });
|
||||
let released = false;
|
||||
const release = () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
try {
|
||||
// Only unlink if we still own the slot. If we appeared stale and
|
||||
// another hook took over, the file now belongs to it — leave alone.
|
||||
const content = fs.readFileSync(slotPath, 'utf-8').trim();
|
||||
if (content === myPidStr) fs.unlinkSync(slotPath);
|
||||
} catch {
|
||||
/* already removed or unreadable */
|
||||
}
|
||||
};
|
||||
process.on('exit', release);
|
||||
return release;
|
||||
} catch {
|
||||
// Slot exists. Decide whether to take it over.
|
||||
// Open once and inspect mtime + content via the same fd so there's
|
||||
// no TOCTOU between the metadata check and the content read
|
||||
// (codeql js/file-system-race).
|
||||
let fd;
|
||||
try {
|
||||
fd = fs.openSync(slotPath, 'r');
|
||||
} catch {
|
||||
continue; // Vanished between EEXIST and open — retry this slot.
|
||||
}
|
||||
let isLive = false;
|
||||
let mtimeMs = Date.now();
|
||||
try {
|
||||
mtimeMs = fs.fstatSync(fd).mtimeMs;
|
||||
const buf = Buffer.alloc(32);
|
||||
const n = fs.readSync(fd, buf, 0, 32, 0);
|
||||
const ownerStr = buf.slice(0, n).toString('utf-8').trim();
|
||||
if (ownerStr === '') {
|
||||
// Owner created the file but hasn't written its PID yet. The
|
||||
// wx open+write window is microseconds; give it the benefit
|
||||
// of the doubt and treat as live.
|
||||
isLive = true;
|
||||
} else {
|
||||
const owner = Number.parseInt(ownerStr, 10);
|
||||
if (Number.isFinite(owner) && owner > 0) {
|
||||
try {
|
||||
process.kill(owner, 0);
|
||||
isLive = true;
|
||||
} catch (e) {
|
||||
// ESRCH = process gone → treat as dead. EPERM = process exists
|
||||
// but owned by another user (cross-user lock dir) → still alive,
|
||||
// keep the slot. Anything else: be conservative, assume alive.
|
||||
if (e && e.code === 'ESRCH') {
|
||||
isLive = false;
|
||||
} else {
|
||||
isLive = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* unreadable — treat as dead */
|
||||
} finally {
|
||||
try {
|
||||
fs.closeSync(fd);
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
}
|
||||
// For slots younger than HOOK_LOCK_STALE_MS, PID-liveness wins —
|
||||
// a slow-but-alive hook is never wrongly evicted. For older slots,
|
||||
// age is the final arbiter as a defense against PID reuse on long-
|
||||
// abandoned slots. 30s >> the 7s augment timeout, so a healthy run
|
||||
// never crosses this threshold.
|
||||
if (isLive && Date.now() - mtimeMs > HOOK_LOCK_STALE_MS) {
|
||||
isLive = false;
|
||||
}
|
||||
if (isLive) break; // Try the next slot.
|
||||
try {
|
||||
fs.unlinkSync(slotPath);
|
||||
} catch {
|
||||
/* another hook beat us to it — retry will hit EEXIST */
|
||||
}
|
||||
// Loop and retry this slot.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
HOOK_LOCK_SUBDIR,
|
||||
HOOK_LOCK_MAX_INFLIGHT,
|
||||
HOOK_LOCK_STALE_MS,
|
||||
acquireHookSlot,
|
||||
};
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"version": 1,
|
||||
"hooks": {
|
||||
"beforeShellExecution": [
|
||||
"postToolUse": [
|
||||
{
|
||||
"command": "./hooks/augment-shell.sh",
|
||||
"timeout": 5,
|
||||
"matcher": "\\brg\\b|\\bgrep\\b"
|
||||
"matcher": "Shell|Read|Grep",
|
||||
"command": "node ./hooks/gitnexus-hook.cjs",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./test-helpers": {
|
||||
"types": "./dist/test-helpers.d.ts",
|
||||
"default": "./dist/test-helpers.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -143,6 +143,34 @@ 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 {
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* Per-process circuit breaker.
|
||||
*
|
||||
* Closed -> Open transition fires after `failureThreshold` consecutive
|
||||
* failures. While Open, `check` throws `CircuitOpenError` until
|
||||
* `cooldownMs` has elapsed since the breaker tripped. The first call
|
||||
* after the cooldown enters Half-Open and consumes the *probe permit*:
|
||||
* a recorded success returns to Closed; a recorded failure flips back
|
||||
* to Open with a fresh timestamp.
|
||||
*
|
||||
* Half-open admits exactly one in-flight probe at a time. Concurrent
|
||||
* callers attempting `check()` while a probe is outstanding receive
|
||||
* `CircuitOpenError` with `retryAfterMs = halfOpenRetryAfterMs` (default
|
||||
* 1000ms; configurable). This prevents the recovery-time thundering
|
||||
* herd that defeats the breaker's "fail fast" promise.
|
||||
*
|
||||
* Outcome reporting splits permit-release from state-resolution:
|
||||
* - `recordSuccess` — releases the probe permit, resets the failure
|
||||
* counter, transitions to Closed. Reserved for true 2xx/3xx outcomes.
|
||||
* - `recordFailure` — releases the probe permit, increments the
|
||||
* consecutive-failure counter, transitions to Open with a fresh
|
||||
* `openedAt` (when called from Half-Open or when the threshold
|
||||
* trips from Closed).
|
||||
* - `recordNeutral` — releases the probe permit, BUT leaves state and
|
||||
* counter untouched. Used for outcomes that are neither evidence of
|
||||
* backend health nor evidence of backend failure (caller-driven
|
||||
* cancellation, local timeout, terminal 4xx client errors). Critical
|
||||
* design point: if `recordNeutral` did not release the permit, a
|
||||
* single `TimeoutError` from per-attempt `AbortSignal.timeout` would
|
||||
* route through `recordNeutral` and permanently park the breaker in
|
||||
* half-open until process restart. Releasing the permit while leaving
|
||||
* state half-open keeps the "neutral doesn't claim health" semantic
|
||||
* without creating that wedge.
|
||||
*
|
||||
* Pairing invariant: every successful `check()` MUST be paired with
|
||||
* exactly one `record*()` on every code path including throws. Direct
|
||||
* consumers should wrap the protected operation in `try/finally`:
|
||||
*
|
||||
* breaker.check();
|
||||
* try {
|
||||
* const result = await operation();
|
||||
* breaker.recordSuccess();
|
||||
* return result;
|
||||
* } catch (err) {
|
||||
* // classify err and call recordFailure / recordNeutral / etc.
|
||||
* throw err;
|
||||
* }
|
||||
*
|
||||
* `resilientFetch`'s catch-all on `fetchImpl` already satisfies this
|
||||
* for that consumer.
|
||||
*
|
||||
* Atomicity model: the half-open gate relies on JavaScript event-loop
|
||||
* single-threadedness within a synchronous `check()` body. There is no
|
||||
* `await` inside `check()`; concurrent callers serialize on microtask
|
||||
* order, and exactly one observes `probeInFlight === false`. Do not
|
||||
* introduce `await` inside `check()` without revisiting the gate. If
|
||||
* this code is ever ported to a runtime with shared-memory threads
|
||||
* (Node `worker_threads` with `SharedArrayBuffer`, Web Workers with
|
||||
* shared registries), the boolean must become an atomic CAS — Resilience4j
|
||||
* and Hystrix use atomic permits *because* they run in JVM thread pools.
|
||||
*
|
||||
* Runtime-agnostic: depends only on a `now()` clock and standard JS —
|
||||
* no Node-only imports. Tests inject `now` to advance the clock
|
||||
* deterministically without `vi.useFakeTimers()`.
|
||||
*/
|
||||
|
||||
export class CircuitOpenError extends Error {
|
||||
override readonly name = 'CircuitOpenError';
|
||||
/** Approximate wait time before the breaker may transition to Half-Open
|
||||
* (or before the in-flight probe is expected to resolve). */
|
||||
readonly retryAfterMs: number;
|
||||
|
||||
constructor(retryAfterMs: number, key?: string) {
|
||||
super(
|
||||
key
|
||||
? `Circuit '${key}' is open; retry in ${Math.ceil(retryAfterMs / 1000)}s`
|
||||
: `Circuit is open; retry in ${Math.ceil(retryAfterMs / 1000)}s`,
|
||||
);
|
||||
this.retryAfterMs = retryAfterMs;
|
||||
}
|
||||
}
|
||||
|
||||
export interface CircuitBreakerOptions {
|
||||
/** Consecutive failures required to trip Closed -> Open. */
|
||||
failureThreshold?: number;
|
||||
/** Milliseconds Open before the next call may probe (Half-Open). */
|
||||
cooldownMs?: number;
|
||||
/**
|
||||
* Milliseconds to suggest in `CircuitOpenError.retryAfterMs` when the
|
||||
* breaker is Half-Open with the probe permit consumed. Default 1000ms.
|
||||
* Consumers with long-running protected ops (LLM streaming, large
|
||||
* uploads) should raise this — the cooldown clock is no longer the
|
||||
* right answer because cooldown has elapsed. Returning 0 invites
|
||||
* retry storms; returning the full cooldown misleads about wait.
|
||||
*/
|
||||
halfOpenRetryAfterMs?: number;
|
||||
/** Optional key for error messages and registry lookups. */
|
||||
key?: string;
|
||||
/** Clock override — defaults to `Date.now`. Tests inject deterministic time. */
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
type State = 'closed' | 'open' | 'half-open';
|
||||
|
||||
export class CircuitBreaker {
|
||||
private readonly failureThreshold: number;
|
||||
private readonly cooldownMs: number;
|
||||
private readonly halfOpenRetryAfterMs: number;
|
||||
private readonly key: string | undefined;
|
||||
private readonly now: () => number;
|
||||
|
||||
private state: State = 'closed';
|
||||
private consecutiveFailures = 0;
|
||||
private openedAt: number | null = null;
|
||||
/**
|
||||
* True between a successful `check()` and the next `record*()` call
|
||||
* during Half-Open. Gates concurrent callers from stampeding a still-
|
||||
* recovering dependency. Boolean rather than counter — single-permit
|
||||
* is the conservative end of the Hystrix/Resilience4j spectrum.
|
||||
*/
|
||||
private probeInFlight = false;
|
||||
|
||||
constructor(opts: CircuitBreakerOptions = {}) {
|
||||
this.failureThreshold = opts.failureThreshold ?? 3;
|
||||
this.cooldownMs = opts.cooldownMs ?? 30_000;
|
||||
this.halfOpenRetryAfterMs = opts.halfOpenRetryAfterMs ?? 1_000;
|
||||
this.key = opts.key;
|
||||
this.now = opts.now ?? (() => Date.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw `CircuitOpenError` if the breaker won't admit this call.
|
||||
* Otherwise consume the half-open probe permit (if applicable) and
|
||||
* return so the caller can attempt the protected work.
|
||||
*
|
||||
* Three rejection paths:
|
||||
* 1. Open and still in cooldown → throws with `retryAfterMs` =
|
||||
* remaining cooldown.
|
||||
* 2. Open with cooldown elapsed AND a probe is already in flight
|
||||
* (race: another caller transitioned to half-open and grabbed
|
||||
* the permit on a microtask before us) → throws with
|
||||
* `halfOpenRetryAfterMs`.
|
||||
* 3. Half-Open with probe in flight → throws with `halfOpenRetryAfterMs`.
|
||||
*
|
||||
* **Pairing invariant**: every successful return from `check()` MUST
|
||||
* be paired with exactly one `recordSuccess` / `recordFailure` /
|
||||
* `recordNeutral` on every code path including thrown exceptions.
|
||||
* Failing to pair leaves the probe permit consumed forever and
|
||||
* wedges the breaker. See file-header JSDoc for the canonical
|
||||
* try/finally pattern.
|
||||
*/
|
||||
check(): void {
|
||||
if (this.state === 'open' && this.openedAt !== null) {
|
||||
const elapsed = this.now() - this.openedAt;
|
||||
if (elapsed < this.cooldownMs) {
|
||||
throw new CircuitOpenError(this.cooldownMs - elapsed, this.key);
|
||||
}
|
||||
// Cooldown elapsed — transition to Half-Open. The very next
|
||||
// `probeInFlight` check below decides whether THIS caller gets
|
||||
// the permit or hits the gate.
|
||||
this.state = 'half-open';
|
||||
}
|
||||
|
||||
if (this.state === 'half-open') {
|
||||
if (this.probeInFlight) {
|
||||
throw new CircuitOpenError(this.halfOpenRetryAfterMs, this.key);
|
||||
}
|
||||
this.probeInFlight = true;
|
||||
}
|
||||
// Closed state falls through silently.
|
||||
}
|
||||
|
||||
recordSuccess(): void {
|
||||
this.probeInFlight = false;
|
||||
this.consecutiveFailures = 0;
|
||||
this.state = 'closed';
|
||||
this.openedAt = null;
|
||||
}
|
||||
|
||||
recordFailure(): void {
|
||||
this.probeInFlight = false;
|
||||
this.consecutiveFailures += 1;
|
||||
if (this.state === 'half-open' || this.consecutiveFailures >= this.failureThreshold) {
|
||||
this.state = 'open';
|
||||
this.openedAt = this.now();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Releases the probe permit BUT leaves state and counter untouched.
|
||||
* Use when an attempt produced a response or error that should not
|
||||
* influence breaker health in either direction — caller-driven aborts,
|
||||
* local AbortSignal timeouts, terminal 4xx client errors.
|
||||
*
|
||||
* Why permit-release-without-state-resolution: if `recordNeutral` did
|
||||
* not clear `probeInFlight`, a single `TimeoutError` from per-attempt
|
||||
* `AbortSignal.timeout` (which routes through neutral classification)
|
||||
* would permanently park the breaker in half-open. Since timeouts are
|
||||
* an *expected* outcome under flaky-dependency conditions, the cited
|
||||
* "per-attempt timeout bounds the stuck state" mitigation would itself
|
||||
* be the trigger for a permanent wedge. Releasing the permit closes
|
||||
* that loop while keeping the "neutral doesn't claim dependency
|
||||
* health" semantic.
|
||||
*
|
||||
* Calling `recordSuccess` for these would erase legitimate prior
|
||||
* failure signal; calling `recordFailure` would trip the breaker for
|
||||
* outcomes the backend isn't responsible for.
|
||||
*/
|
||||
recordNeutral(): void {
|
||||
this.probeInFlight = false;
|
||||
// State and consecutiveFailures are preserved by design.
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure read — no state mutation, no permit accounting. Returns the
|
||||
* *would-be* state at the current instant: 'half-open' if the breaker
|
||||
* is open with cooldown elapsed (regardless of whether a probe is in
|
||||
* flight), 'open' if open and still in cooldown, 'closed' otherwise.
|
||||
*
|
||||
* Inspection-only; safe to call from tests without consuming a probe
|
||||
* permit. The implicit Open -> Half-Open transition that mutates
|
||||
* `state` lives in `check()` only.
|
||||
*/
|
||||
getState(): State {
|
||||
if (this.state === 'open' && this.openedAt !== null) {
|
||||
const elapsed = this.now() - this.openedAt;
|
||||
if (elapsed >= this.cooldownMs) return 'half-open';
|
||||
}
|
||||
return this.state;
|
||||
}
|
||||
getConsecutiveFailures(): number {
|
||||
return this.consecutiveFailures;
|
||||
}
|
||||
/** Inspection-only test accessor for the half-open probe permit. */
|
||||
isProbeInFlight(): boolean {
|
||||
return this.probeInFlight;
|
||||
}
|
||||
/** Timestamp (ms since epoch) when the breaker last transitioned to Open,
|
||||
* or `null` if it's currently Closed. Useful for computing remaining
|
||||
* cooldown without consuming a probe permit via `check()`. */
|
||||
getOpenedAt(): number | null {
|
||||
return this.openedAt;
|
||||
}
|
||||
/** Configured cooldown duration in milliseconds. */
|
||||
getCooldownMs(): number {
|
||||
return this.cooldownMs;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Per-process registry ────────────────────────────────────────────
|
||||
//
|
||||
// Single shared map keyed on caller-chosen strings. Used by
|
||||
// `resilient-fetch.ts` so multiple call sites targeting the same logical
|
||||
// endpoint share breaker state. Per-process only — not persisted.
|
||||
|
||||
const registry = new Map<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();
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* `resilientFetch` — fetch wrapped in retry + circuit breaker, with
|
||||
* GitHub-flavoured retry classification baked in (Retry-After parsing,
|
||||
* 401/403/404/422 treated as terminal client errors).
|
||||
*
|
||||
* Designed for the `gitnexus publish` GitHub `repository_dispatch`
|
||||
* call, but the classification rules apply to any GitHub REST endpoint.
|
||||
* Runtime-agnostic — no Node-only imports.
|
||||
*/
|
||||
|
||||
import {
|
||||
CircuitBreaker,
|
||||
CircuitOpenError,
|
||||
getBreaker,
|
||||
type CircuitBreakerOptions,
|
||||
} from './circuit-breaker.js';
|
||||
import { computeBackoffMs, type RetryOptions } from './retry.js';
|
||||
|
||||
export { CircuitOpenError };
|
||||
|
||||
export interface ResilientFetchOptions {
|
||||
/** Optional fetch implementation override. Defaults to `globalThis.fetch`. */
|
||||
fetchImpl?: typeof fetch;
|
||||
/**
|
||||
* Logical key for the breaker. Defaults to `<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');
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Bounded retry helper with full-jitter exponential backoff.
|
||||
*
|
||||
* Runtime-agnostic: depends only on `setTimeout`, `Math.random`, and the
|
||||
* Promise machinery — no Node-only imports. Safe to consume from CLI,
|
||||
* server, or browser callers.
|
||||
*
|
||||
* Pattern reference: gitnexus/src/core/embeddings/http-client.ts. This
|
||||
* helper is the upgraded form: classification is caller-supplied (so
|
||||
* 4xx-vs-5xx-vs-timeout decisions live with the protocol that knows
|
||||
* them), backoff is exponential with full jitter, and an optional
|
||||
* `afterMs` lets callers honor `Retry-After` headers.
|
||||
*/
|
||||
|
||||
export interface RetryOptions {
|
||||
/** Initial delay before the first retry attempt, in milliseconds. */
|
||||
baseDelayMs: number;
|
||||
/** Upper bound on any single delay, in milliseconds. */
|
||||
capDelayMs: number;
|
||||
/** Total attempts including the first call. Must be >= 1. */
|
||||
maxAttempts: number;
|
||||
/**
|
||||
* Decide whether to retry after a thrown error.
|
||||
* Return `{retry:false}` to terminate immediately and rethrow.
|
||||
* Return `{retry:true}` to retry with exponential-backoff jitter.
|
||||
* Return `{retry:true, afterMs}` to wait at least `afterMs` (still
|
||||
* subject to `capDelayMs`) — used by callers parsing `Retry-After`.
|
||||
*/
|
||||
isRetryable: (err: unknown, attempt: number) => RetryDecision;
|
||||
/** Sleep override — defaults to `setTimeout`. Tests inject fake timers. */
|
||||
sleep?: (ms: number) => Promise<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;
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -40,11 +40,27 @@ export interface MethodDispatchIndex {
|
||||
readonly mroByOwnerDefId: ReadonlyMap<DefId, readonly DefId[]>;
|
||||
/** Interfaces / traits → classes that implement them. */
|
||||
readonly implsByInterfaceDefId: ReadonlyMap<DefId, readonly DefId[]>;
|
||||
/**
|
||||
* Optional parallel MRO view that EXCLUDES mixin-like augmentation
|
||||
* (e.g., PHP traits). Populated only when the input supplies
|
||||
* `computeExtendsOnlyMro`. Used by the super-branch dispatch in
|
||||
* `receiver-bound-calls` so that `parent::method()` walks the
|
||||
* inheritance chain only, not the trait-augmented one. Undefined for
|
||||
* languages without mixin-like semantics — callers should fall back
|
||||
* to `mroFor` when this is missing.
|
||||
*/
|
||||
readonly extendsOnlyMroByOwnerDefId?: ReadonlyMap<DefId, readonly DefId[]>;
|
||||
|
||||
/** `mroByOwnerDefId.get`, with an empty frozen array on miss. */
|
||||
mroFor(ownerDefId: DefId): readonly DefId[];
|
||||
/** `implsByInterfaceDefId.get`, with an empty frozen array on miss. */
|
||||
implementorsOf(interfaceDefId: DefId): readonly DefId[];
|
||||
/**
|
||||
* `extendsOnlyMroByOwnerDefId.get`, with an empty frozen array on miss.
|
||||
* Undefined when `extendsOnlyMroByOwnerDefId` was not populated; callers
|
||||
* should treat this as equivalent to `mroFor` for non-mixin languages.
|
||||
*/
|
||||
readonly extendsOnlyMroFor?: (ownerDefId: DefId) => readonly DefId[];
|
||||
}
|
||||
|
||||
export interface MethodDispatchInput {
|
||||
@@ -81,12 +97,25 @@ export interface MethodDispatchInput {
|
||||
* write-wins policy and fires at most once per unique owner.
|
||||
*/
|
||||
readonly implementsOf: (ownerDefId: DefId) => readonly DefId[];
|
||||
/**
|
||||
* Optional: return the EXTENDS-only ancestor chain for `ownerDefId`,
|
||||
* excluding the owner itself AND any mixin-like augmentation (e.g.,
|
||||
* PHP traits). Languages without mixin semantics leave this undefined
|
||||
* and the index's `extendsOnlyMroByOwnerDefId` stays unpopulated.
|
||||
*
|
||||
* Same contract as `computeMro`: pure, deterministic, `[]` on no parents.
|
||||
* Called at most once per unique owner (first-write-wins).
|
||||
*/
|
||||
readonly computeExtendsOnlyMro?: (ownerDefId: DefId) => readonly DefId[];
|
||||
}
|
||||
|
||||
// ─── Builder ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function buildMethodDispatchIndex(input: MethodDispatchInput): MethodDispatchIndex {
|
||||
const mroByOwnerDefId = new Map<DefId, readonly DefId[]>();
|
||||
const extendsOnlyByOwnerDefId = input.computeExtendsOnlyMro
|
||||
? new Map<DefId, readonly DefId[]>()
|
||||
: undefined;
|
||||
const implsBuilding = new Map<DefId, DefId[]>();
|
||||
const implsSeen = new Map<DefId, Set<DefId>>();
|
||||
|
||||
@@ -97,6 +126,14 @@ export function buildMethodDispatchIndex(input: MethodDispatchInput): MethodDisp
|
||||
const chain = input.computeMro(ownerId);
|
||||
mroByOwnerDefId.set(ownerId, Object.freeze(chain.slice()));
|
||||
}
|
||||
if (
|
||||
input.computeExtendsOnlyMro !== undefined &&
|
||||
extendsOnlyByOwnerDefId !== undefined &&
|
||||
!extendsOnlyByOwnerDefId.has(ownerId)
|
||||
) {
|
||||
const extOnly = input.computeExtendsOnlyMro(ownerId);
|
||||
extendsOnlyByOwnerDefId.set(ownerId, Object.freeze(extOnly.slice()));
|
||||
}
|
||||
|
||||
for (const ifaceId of input.implementsOf(ownerId)) {
|
||||
let seen = implsSeen.get(ifaceId);
|
||||
@@ -121,7 +158,7 @@ export function buildMethodDispatchIndex(input: MethodDispatchInput): MethodDisp
|
||||
implsByInterfaceDefId.set(ifaceId, Object.freeze(owners.slice()));
|
||||
}
|
||||
|
||||
return wrapIndex(mroByOwnerDefId, implsByInterfaceDefId);
|
||||
return wrapIndex(mroByOwnerDefId, implsByInterfaceDefId, extendsOnlyByOwnerDefId);
|
||||
}
|
||||
|
||||
// ─── Internal ───────────────────────────────────────────────────────────────
|
||||
@@ -131,8 +168,9 @@ const EMPTY: readonly DefId[] = Object.freeze([]);
|
||||
function wrapIndex(
|
||||
mroByOwnerDefId: Map<DefId, readonly DefId[]>,
|
||||
implsByInterfaceDefId: Map<DefId, readonly DefId[]>,
|
||||
extendsOnlyMroByOwnerDefId: Map<DefId, readonly DefId[]> | undefined,
|
||||
): MethodDispatchIndex {
|
||||
return {
|
||||
const base: MethodDispatchIndex = {
|
||||
mroByOwnerDefId,
|
||||
implsByInterfaceDefId,
|
||||
mroFor(ownerDefId: DefId): readonly DefId[] {
|
||||
@@ -142,4 +180,14 @@ function wrapIndex(
|
||||
return implsByInterfaceDefId.get(interfaceDefId) ?? EMPTY;
|
||||
},
|
||||
};
|
||||
if (extendsOnlyMroByOwnerDefId !== undefined) {
|
||||
return {
|
||||
...base,
|
||||
extendsOnlyMroByOwnerDefId,
|
||||
extendsOnlyMroFor(ownerDefId: DefId): readonly DefId[] {
|
||||
return extendsOnlyMroByOwnerDefId.get(ownerDefId) ?? EMPTY;
|
||||
},
|
||||
};
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
@@ -423,13 +423,30 @@ function applyArityFilter(
|
||||
}
|
||||
|
||||
let anyCompatible = false;
|
||||
let anyUnknown = false;
|
||||
for (const state of perCandidate.values()) {
|
||||
const verdict = arityFn(callsite, state.def);
|
||||
state.signals.arityVerdict = verdict;
|
||||
if (verdict === 'compatible') anyCompatible = true;
|
||||
else if (verdict === 'unknown') anyUnknown = true;
|
||||
}
|
||||
|
||||
if (!anyCompatible) return;
|
||||
// When ALL candidates are 'incompatible' (none compatible, none unknown),
|
||||
// the call is genuinely arity-broken — drop every candidate so the
|
||||
// registry returns no resolution. This matches the PHP variadic case
|
||||
// f(int $req, ...$rest) called with zero args: every candidate definitively
|
||||
// rejects, and emitting an edge to a definitively-rejected callable is
|
||||
// a false positive. When some candidates are 'unknown' (missing metadata),
|
||||
// keep the set so downstream evidence can break the tie — that's the
|
||||
// original safety-fallback behavior.
|
||||
if (!anyCompatible) {
|
||||
if (!anyUnknown) {
|
||||
for (const defId of perCandidate.keys()) {
|
||||
perCandidate.delete(defId);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter: when at least one compatible candidate exists, drop incompatibles.
|
||||
for (const [defId, state] of perCandidate) {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Test-only helpers.
|
||||
*
|
||||
* Symbols here are reachable from `gitnexus-shared/test-helpers` so test
|
||||
* suites can reset shared registries or exercise internal classifiers,
|
||||
* but they are deliberately NOT re-exported from the main `gitnexus-shared`
|
||||
* barrel. Production consumers should never import this module — calling
|
||||
* `__resetBreakerRegistry__()` from a tool implementation would silently
|
||||
* nuke every circuit breaker process-wide.
|
||||
*/
|
||||
|
||||
export { __resetBreakerRegistry__ } from './integrations/circuit-breaker.js';
|
||||
export { classifyOutcome } from './integrations/resilient-fetch.js';
|
||||
Generated
+94
-236
@@ -8,15 +8,15 @@
|
||||
"name": "gitnexus",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@langchain/anthropic": "^1.3.27",
|
||||
"@langchain/core": "^1.1.41",
|
||||
"@langchain/anthropic": "^1.3.29",
|
||||
"@langchain/core": "^1.1.44",
|
||||
"@langchain/google-genai": "^2.1.28",
|
||||
"@langchain/langgraph": "^1.2.9",
|
||||
"@langchain/ollama": "^1.2.6",
|
||||
"@langchain/openai": "^1.4.5",
|
||||
"@sigma/edge-curve": "^3.1.0",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"axios": "^1.13.2",
|
||||
"axios": "^1.16.0",
|
||||
"d3": "^7.9.0",
|
||||
"dompurify": "^3.4.2",
|
||||
"gitnexus-shared": "file:../gitnexus-shared",
|
||||
@@ -26,14 +26,14 @@
|
||||
"graphology-layout-forceatlas2": "^0.10.1",
|
||||
"graphology-layout-noverlap": "^0.4.2",
|
||||
"graphology-utils": "^2.3.0",
|
||||
"langchain": "^1.3.4",
|
||||
"langchain": "^1.3.5",
|
||||
"lru-cache": "^11.2.4",
|
||||
"lucide-react": "^1.11.0",
|
||||
"mermaid": "^11.14.0",
|
||||
"lucide-react": "^1.14.0",
|
||||
"mermaid": "^11.15.0",
|
||||
"mnemonist": "^0.39.0",
|
||||
"pandemonium": "^2.4.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-syntax-highlighter": "^16.1.0",
|
||||
"react-zoom-pan-pinch": "^4.0.3",
|
||||
@@ -49,7 +49,7 @@
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/dompurify": "^3.2.0",
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
@@ -57,7 +57,7 @@
|
||||
"@vercel/node": "^5.5.16",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"@vitest/coverage-v8": "^4.1.5",
|
||||
"jsdom": "^29.0.2",
|
||||
"jsdom": "^29.1.1",
|
||||
"tree-sitter-wasms": "^0.1.13",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^8.0.10",
|
||||
@@ -95,9 +95,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/sdk": {
|
||||
"version": "0.90.0",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.90.0.tgz",
|
||||
"integrity": "sha512-MzZtPabJF1b0FTDl6Z6H5ljphPwACLGP13lu8MTiB8jXaW/YXlpOp+Po2cVou3MPM5+f5toyLnul9whKCy7fBg==",
|
||||
"version": "0.91.1",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.91.1.tgz",
|
||||
"integrity": "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"json-schema-to-ts": "^3.1.1"
|
||||
@@ -132,9 +132,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/dom-selector": {
|
||||
"version": "7.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.10.tgz",
|
||||
"integrity": "sha512-KyOb19eytNSELkmdqzZZUXWCU25byIlOld5qVFg0RYdS0T3tt7jeDByxk9hIAC73frclD8GKrHttr0SUjKCCdQ==",
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz",
|
||||
"integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -528,41 +528,10 @@
|
||||
"integrity": "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@chevrotain/cst-dts-gen": {
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-12.0.0.tgz",
|
||||
"integrity": "sha512-fSL4KXjTl7cDgf0B5Rip9Q05BOrYvkJV/RrBTE/bKDN096E4hN/ySpcBK5B24T76dlQ2i32Zc3PAE27jFnFrKg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@chevrotain/gast": "12.0.0",
|
||||
"@chevrotain/types": "12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@chevrotain/gast": {
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-12.0.0.tgz",
|
||||
"integrity": "sha512-1ne/m3XsIT8aEdrvT33so0GUC+wkctpUPK6zU9IlOyJLUbR0rg4G7ZiApiJbggpgPir9ERy3FRjT6T7lpgetnQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@chevrotain/types": "12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@chevrotain/regexp-to-ast": {
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-12.0.0.tgz",
|
||||
"integrity": "sha512-p+EW9MaJwgaHguhoqwOtx/FwuGr+DnNn857sXWOi/mClXIkPGl3rn7hGNWvo31HA3vyeQxjqe+H36yZJwYU8cA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@chevrotain/types": {
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-12.0.0.tgz",
|
||||
"integrity": "sha512-S+04vjFQKeuYw0/eW3U52LkAHQsB1ASxsPGsLPUyQgrZ2iNNibQrsidruDzjEX2JYfespXMG0eZmXlhA6z7nWA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@chevrotain/utils": {
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-12.0.0.tgz",
|
||||
"integrity": "sha512-lB59uJoaGIfOOL9knQqQRfhl9g7x8/wqFkp13zTdkRu1huG9kg6IJs1O8hqj9rs6h7orGxHJUKb+mX3rPbWGhA==",
|
||||
"version": "11.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.2.tgz",
|
||||
"integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@cspotcode/source-map-support": {
|
||||
@@ -685,9 +654,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-syntax-patches-for-csstree": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.1.tgz",
|
||||
"integrity": "sha512-BvqN0AMWNAnLk9G8jnUT77D+mUbY/H2b3uDTvg2isJkHaOufUE2R3AOwxWo7VBQKT1lOdwdvorddo2B/lk64+w==",
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz",
|
||||
"integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -1396,25 +1365,25 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@langchain/anthropic": {
|
||||
"version": "1.3.27",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/anthropic/-/anthropic-1.3.27.tgz",
|
||||
"integrity": "sha512-A0pWKIMIhgF01z3ILA8uAbZ6ZR2H8UQP2Ww8Ofq5DtHp36uJkQgrNCdS+q9pUVJJ87eq5dEdFkpjrjmH4fVkfQ==",
|
||||
"version": "1.3.29",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/anthropic/-/anthropic-1.3.29.tgz",
|
||||
"integrity": "sha512-ep1qBIcV07bajsg3fDqMd39rYwoRLOEK/6lk+MCxlm1YB5SRoKKJAZANrblQ/4RYhZJnxf95c6BSQu8VoNbVAQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.90.0",
|
||||
"@anthropic-ai/sdk": "^0.91.1",
|
||||
"zod": "^3.25.76 || ^4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@langchain/core": "^1.1.41"
|
||||
"@langchain/core": "^1.1.45"
|
||||
}
|
||||
},
|
||||
"node_modules/@langchain/core": {
|
||||
"version": "1.1.42",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.1.42.tgz",
|
||||
"integrity": "sha512-d0tN96BrwPMryYyWR9VfyAntSivn7EQrZCe5Kpxum93tcjTXbKKmKvItFec8AluQt88iTcmAJrahUZUNfzGwTA==",
|
||||
"version": "1.1.45",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.1.45.tgz",
|
||||
"integrity": "sha512-Y/wvuglLTMKJahkl4QD9dBIdF/z/CxZJWdTfHJF/q2jtlJtoFf6Mb5JpGxZfsi3mBY6NSG941FSLTcqhCKrhBA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@cfworker/json-schema": "^4.0.2",
|
||||
@@ -1679,12 +1648,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@mermaid-js/parser": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.1.0.tgz",
|
||||
"integrity": "sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw==",
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.1.1.tgz",
|
||||
"integrity": "sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"langium": "^4.0.0"
|
||||
"@chevrotain/types": "~11.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
@@ -2800,13 +2769,14 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/dompurify": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
|
||||
"integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.2.0.tgz",
|
||||
"integrity": "sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==",
|
||||
"deprecated": "This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed.",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/trusted-types": "*"
|
||||
"dompurify": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
@@ -2909,8 +2879,8 @@
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@types/unist": {
|
||||
"version": "3.0.3",
|
||||
@@ -3401,12 +3371,12 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz",
|
||||
"integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==",
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz",
|
||||
"integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.11",
|
||||
"follow-redirects": "^1.16.0",
|
||||
"form-data": "^4.0.5",
|
||||
"proxy-from-env": "^2.1.0"
|
||||
}
|
||||
@@ -3642,34 +3612,6 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/chevrotain": {
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-12.0.0.tgz",
|
||||
"integrity": "sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@chevrotain/cst-dts-gen": "12.0.0",
|
||||
"@chevrotain/gast": "12.0.0",
|
||||
"@chevrotain/regexp-to-ast": "12.0.0",
|
||||
"@chevrotain/types": "12.0.0",
|
||||
"@chevrotain/utils": "12.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/chevrotain-allstar": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/chevrotain-allstar/-/chevrotain-allstar-0.4.1.tgz",
|
||||
"integrity": "sha512-PvVJm3oGqrveUVW2Vt/eZGeiAIsJszYweUcYwcskg9e+IubNYKKD+rHHem7A6XVO22eDAL+inxNIGAzZ/VIWlA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lodash-es": "^4.17.21"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"chevrotain": "^12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/chownr": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
|
||||
@@ -4563,13 +4505,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
||||
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
|
||||
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
@@ -4627,6 +4569,16 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-toolkit": {
|
||||
"version": "1.46.1",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.1.tgz",
|
||||
"integrity": "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"docs",
|
||||
"benchmarks"
|
||||
]
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.27.0",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.0.tgz",
|
||||
@@ -5478,28 +5430,28 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsdom": {
|
||||
"version": "29.0.2",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.2.tgz",
|
||||
"integrity": "sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w==",
|
||||
"version": "29.1.1",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
|
||||
"integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/css-color": "^5.1.5",
|
||||
"@asamuzakjp/dom-selector": "^7.0.6",
|
||||
"@asamuzakjp/css-color": "^5.1.11",
|
||||
"@asamuzakjp/dom-selector": "^7.1.1",
|
||||
"@bramus/specificity": "^2.4.2",
|
||||
"@csstools/css-syntax-patches-for-csstree": "^1.1.1",
|
||||
"@csstools/css-syntax-patches-for-csstree": "^1.1.3",
|
||||
"@exodus/bytes": "^1.15.0",
|
||||
"css-tree": "^3.2.1",
|
||||
"data-urls": "^7.0.0",
|
||||
"decimal.js": "^10.6.0",
|
||||
"html-encoding-sniffer": "^6.0.0",
|
||||
"is-potential-custom-element-name": "^1.0.1",
|
||||
"lru-cache": "^11.2.7",
|
||||
"parse5": "^8.0.0",
|
||||
"lru-cache": "^11.3.5",
|
||||
"parse5": "^8.0.1",
|
||||
"saxes": "^6.0.0",
|
||||
"symbol-tree": "^3.2.4",
|
||||
"tough-cookie": "^6.0.1",
|
||||
"undici": "^7.24.5",
|
||||
"undici": "^7.25.0",
|
||||
"w3c-xmlserializer": "^5.0.0",
|
||||
"webidl-conversions": "^8.0.1",
|
||||
"whatwg-mimetype": "^5.0.0",
|
||||
@@ -5643,53 +5595,21 @@
|
||||
"integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="
|
||||
},
|
||||
"node_modules/langchain": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/langchain/-/langchain-1.3.4.tgz",
|
||||
"integrity": "sha512-umrD+ZC6vr0Q0U1lC5PoIMMQqgnP+7QhaIdAXCeZiSX2GPVBiVR7Ed2ZR+3MPvz1yWrRtIm17wPaovkND+wOXg==",
|
||||
"version": "1.3.5",
|
||||
"resolved": "https://registry.npmjs.org/langchain/-/langchain-1.3.5.tgz",
|
||||
"integrity": "sha512-QSB8TEo6G1tWupgNt1Osm8ylLLoOMq1lLw5NeijnIwRPI5BqdBjUn4/U8usbjEJJcQnbXSK2qTKgTx7zvblnBw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@langchain/langgraph": "^1.2.9",
|
||||
"@langchain/langgraph-checkpoint": "^1.0.1",
|
||||
"langsmith": ">=0.5.0 <1.0.0",
|
||||
"uuid": "^11.1.0",
|
||||
"zod": "^3.25.76 || ^4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@langchain/core": "^1.1.41"
|
||||
}
|
||||
},
|
||||
"node_modules/langchain/node_modules/uuid": {
|
||||
"version": "11.1.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
|
||||
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/esm/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/langium": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/langium/-/langium-4.2.2.tgz",
|
||||
"integrity": "sha512-JUshTRAfHI4/MF9dH2WupvjSXyn8JBuUEWazB8ZVJUtXutT0doDlAv1XKbZ1Pb5sMexa8FF4CFBc0iiul7gbUQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@chevrotain/regexp-to-ast": "~12.0.0",
|
||||
"chevrotain": "~12.0.0",
|
||||
"chevrotain-allstar": "~0.4.1",
|
||||
"vscode-languageserver": "~9.0.1",
|
||||
"vscode-languageserver-textdocument": "~1.0.11",
|
||||
"vscode-uri": "~3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.10.0",
|
||||
"npm": ">=10.2.3"
|
||||
"@langchain/core": "^1.1.42"
|
||||
}
|
||||
},
|
||||
"node_modules/langsmith": {
|
||||
@@ -6032,18 +5952,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "11.2.7",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz",
|
||||
"integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==",
|
||||
"version": "11.3.6",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz",
|
||||
"integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "1.11.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.11.0.tgz",
|
||||
"integrity": "sha512-UOhjdztXCgdBReRcIhsvz2siIBogfv/lhJEIViCpLt924dO+GDms9T7DNoucI23s6kEPpe988m5N0D2ajnzb2g==",
|
||||
"version": "1.14.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.14.0.tgz",
|
||||
"integrity": "sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
@@ -6435,14 +6355,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/mermaid": {
|
||||
"version": "11.14.0",
|
||||
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.14.0.tgz",
|
||||
"integrity": "sha512-GSGloRsBs+JINmmhl0JDwjpuezCsHB4WGI4NASHxL3fHo3o/BRXTxhDLKnln8/Q0lRFRyDdEjmk1/d5Sn1Xz8g==",
|
||||
"version": "11.15.0",
|
||||
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.15.0.tgz",
|
||||
"integrity": "sha512-pTMbcf3rWdtLiYGpmoTjHEpeY8seiy6sR+9nD7LOs8KfUbHE4lOUAprTRqRAcWSQ6MQpdX+YEsxShtGsINtPtw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@braintree/sanitize-url": "^7.1.1",
|
||||
"@iconify/utils": "^3.0.2",
|
||||
"@mermaid-js/parser": "^1.1.0",
|
||||
"@mermaid-js/parser": "^1.1.1",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@upsetjs/venn.js": "^2.0.0",
|
||||
"cytoscape": "^3.33.1",
|
||||
@@ -6453,27 +6373,14 @@
|
||||
"dagre-d3-es": "7.0.14",
|
||||
"dayjs": "^1.11.19",
|
||||
"dompurify": "^3.3.1",
|
||||
"es-toolkit": "^1.45.1",
|
||||
"katex": "^0.16.25",
|
||||
"khroma": "^2.1.0",
|
||||
"lodash-es": "^4.17.23",
|
||||
"marked": "^16.3.0",
|
||||
"roughjs": "^4.6.6",
|
||||
"stylis": "^4.3.6",
|
||||
"ts-dedent": "^2.2.0",
|
||||
"uuid": "^11.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mermaid/node_modules/uuid": {
|
||||
"version": "11.1.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
|
||||
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/esm/bin/uuid"
|
||||
"uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/micromark": {
|
||||
@@ -7452,13 +7359,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/parse5": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz",
|
||||
"integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==",
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
|
||||
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"entities": "^6.0.0"
|
||||
"entities": "^8.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
@@ -7727,24 +7634,24 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.5",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
|
||||
"integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
|
||||
"version": "19.2.6",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
|
||||
"integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "19.2.5",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz",
|
||||
"integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==",
|
||||
"version": "19.2.6",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz",
|
||||
"integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.2.5"
|
||||
"react": "^19.2.6"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
@@ -8888,55 +8795,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vscode-jsonrpc": {
|
||||
"version": "8.2.0",
|
||||
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz",
|
||||
"integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vscode-languageserver": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz",
|
||||
"integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"vscode-languageserver-protocol": "3.17.5"
|
||||
},
|
||||
"bin": {
|
||||
"installServerIntoExtension": "bin/installServerIntoExtension"
|
||||
}
|
||||
},
|
||||
"node_modules/vscode-languageserver-protocol": {
|
||||
"version": "3.17.5",
|
||||
"resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz",
|
||||
"integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"vscode-jsonrpc": "8.2.0",
|
||||
"vscode-languageserver-types": "3.17.5"
|
||||
}
|
||||
},
|
||||
"node_modules/vscode-languageserver-textdocument": {
|
||||
"version": "1.0.12",
|
||||
"resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz",
|
||||
"integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vscode-languageserver-types": {
|
||||
"version": "3.17.5",
|
||||
"resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz",
|
||||
"integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vscode-uri": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",
|
||||
"integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/w3c-xmlserializer": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
||||
|
||||
@@ -19,15 +19,15 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"gitnexus-shared": "file:../gitnexus-shared",
|
||||
"@langchain/anthropic": "^1.3.27",
|
||||
"@langchain/core": "^1.1.41",
|
||||
"@langchain/anthropic": "^1.3.29",
|
||||
"@langchain/core": "^1.1.44",
|
||||
"@langchain/google-genai": "^2.1.28",
|
||||
"@langchain/langgraph": "^1.2.9",
|
||||
"@langchain/ollama": "^1.2.6",
|
||||
"@langchain/openai": "^1.4.5",
|
||||
"@sigma/edge-curve": "^3.1.0",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"axios": "^1.13.2",
|
||||
"axios": "^1.16.0",
|
||||
"d3": "^7.9.0",
|
||||
"dompurify": "^3.4.2",
|
||||
"graphology": "^0.26.0",
|
||||
@@ -36,14 +36,14 @@
|
||||
"graphology-layout-forceatlas2": "^0.10.1",
|
||||
"graphology-layout-noverlap": "^0.4.2",
|
||||
"graphology-utils": "^2.3.0",
|
||||
"langchain": "^1.3.4",
|
||||
"langchain": "^1.3.5",
|
||||
"lru-cache": "^11.2.4",
|
||||
"lucide-react": "^1.11.0",
|
||||
"mermaid": "^11.14.0",
|
||||
"lucide-react": "^1.14.0",
|
||||
"mermaid": "^11.15.0",
|
||||
"mnemonist": "^0.39.0",
|
||||
"pandemonium": "^2.4.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-syntax-highlighter": "^16.1.0",
|
||||
"react-zoom-pan-pinch": "^4.0.3",
|
||||
@@ -59,7 +59,7 @@
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/dompurify": "^3.2.0",
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
@@ -67,7 +67,7 @@
|
||||
"@vercel/node": "^5.5.16",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"@vitest/coverage-v8": "^4.1.5",
|
||||
"jsdom": "^29.0.2",
|
||||
"jsdom": "^29.1.1",
|
||||
"tree-sitter-wasms": "^0.1.13",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^8.0.10",
|
||||
|
||||
@@ -277,8 +277,10 @@ const extractInstanceName = (endpoint: string): string => {
|
||||
try {
|
||||
const url = new URL(endpoint);
|
||||
const hostname = url.hostname;
|
||||
// Extract the first part before .openai.azure.com
|
||||
const match = hostname.match(/^([^.]+)\.openai\.azure\.com/);
|
||||
// Extract the first part before .openai.azure.com. The trailing `$`
|
||||
// anchor is required (CodeQL js/regex/missing-regexp-anchor): without
|
||||
// it `evil.openai.azure.com.attacker.tld` would match.
|
||||
const match = hostname.match(/^([^.]+)\.openai\.azure\.com$/);
|
||||
if (match) {
|
||||
return match[1];
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
ProviderConfig,
|
||||
} from './types';
|
||||
import { DEFAULT_OPENROUTER_BASE_URL, DEFAULT_OLLAMA_BASE_URL } from '../../config/ui-constants';
|
||||
import { resilientFetch } from 'gitnexus-shared';
|
||||
|
||||
const STORAGE_KEY = 'gitnexus-llm-settings';
|
||||
|
||||
@@ -407,7 +408,10 @@ export const getAvailableModels = (provider: LLMProvider): string[] => {
|
||||
*/
|
||||
export const fetchOpenRouterModels = async (): Promise<Array<{ id: string; name: string }>> => {
|
||||
try {
|
||||
const response = await fetch(`${DEFAULT_OPENROUTER_BASE_URL}/models`);
|
||||
const response = await resilientFetch(`${DEFAULT_OPENROUTER_BASE_URL}/models`, undefined, {
|
||||
breakerKey: 'openrouter-models',
|
||||
retry: { maxAttempts: 2, baseDelayMs: 500, capDelayMs: 2_000 },
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to fetch models');
|
||||
const data = await response.json();
|
||||
return data.data.map((model: any) => ({
|
||||
|
||||
@@ -278,8 +278,11 @@ export const createGraphRAGTools = (backend: GraphRAGBackend) => {
|
||||
const val = row[col];
|
||||
if (val === null || val === undefined) return '';
|
||||
if (typeof val === 'object') return JSON.stringify(val);
|
||||
// Truncate long values and escape pipe characters
|
||||
const str = String(val).replace(/\|/g, '\\|');
|
||||
// Truncate long values and escape pipe characters. Escape
|
||||
// backslashes FIRST so the subsequent pipe escape isn't
|
||||
// unescaped by a trailing backslash (CodeQL
|
||||
// js/incomplete-sanitization).
|
||||
const str = String(val).replace(/\\/g, '\\\\').replace(/\|/g, '\\|');
|
||||
return str.length > 60 ? str.slice(0, 57) + '...' : str;
|
||||
});
|
||||
return `| ${values.join(' | ')} |`;
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
|
||||
import type { GraphNode, GraphRelationship } from 'gitnexus-shared';
|
||||
import { CircuitOpenError, ResilientFetchExhaustedError, resilientFetch } from 'gitnexus-shared';
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -72,7 +73,19 @@ export class BackendError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly status: number,
|
||||
public readonly code: 'network' | 'server' | 'client' | 'not_found' | 'timeout',
|
||||
public readonly code:
|
||||
| 'network'
|
||||
| 'server'
|
||||
| 'client'
|
||||
| 'not_found'
|
||||
| 'timeout'
|
||||
| 'rate_limited',
|
||||
/**
|
||||
* Milliseconds until the caller should retry. Populated for rate-limited
|
||||
* responses (HTTP 429) from the server's `Retry-After` header. `undefined`
|
||||
* for every other code, including `client` errors that aren't 429.
|
||||
*/
|
||||
public readonly retryAfterMs?: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'BackendError';
|
||||
@@ -192,8 +205,32 @@ 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 => {
|
||||
_backendUrl = url.replace(/\/$/, '');
|
||||
const trimmed = url.replace(/\/$/, '');
|
||||
validateBackendUrl(trimmed);
|
||||
_backendUrl = trimmed;
|
||||
};
|
||||
|
||||
export const getBackendUrl = (): string => _backendUrl;
|
||||
@@ -225,29 +262,91 @@ export function normalizeServerUrl(input: string): string {
|
||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
const PROBE_TIMEOUT_MS = 2_000;
|
||||
|
||||
/** Idempotent HTTP methods. Other verbs (POST, PATCH, PUT, DELETE) get
|
||||
* a single-attempt retry budget by default to avoid duplicate side
|
||||
* effects on retry — a POST that 5xx'd may have already executed
|
||||
* server-side. Callers that have idempotency keys or otherwise know
|
||||
* their mutation is safe to retry can opt in via `forceRetry`. */
|
||||
const IDEMPOTENT_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||
|
||||
const fetchWithTimeout = async (
|
||||
url: string,
|
||||
init: RequestInit = {},
|
||||
timeoutMs: number = DEFAULT_TIMEOUT_MS,
|
||||
/**
|
||||
* Force a retry budget on non-idempotent methods. Default false.
|
||||
* Pass true only when the endpoint is known-idempotent (e.g. DELETE
|
||||
* of a known-deleted resource — second call is a 404 / no-op) AND
|
||||
* the duplicate-side-effect window is acceptable.
|
||||
*/
|
||||
forceRetry = false,
|
||||
): Promise<Response> => {
|
||||
const controller = new AbortController();
|
||||
// Merge external signal if provided
|
||||
// Merge the external caller signal (if any) with an
|
||||
// `AbortSignal.timeout()` so a timer-fired abort produces a
|
||||
// `DOMException` with `name === 'TimeoutError'` — which
|
||||
// `resilientFetch` correctly classifies as terminal-network (no
|
||||
// retry, no breaker hit). A manual `AbortController.abort()` would
|
||||
// produce `name === 'AbortError'` and route through the
|
||||
// retryable-network branch, which mis-penalizes the breaker for
|
||||
// user-side network slowness.
|
||||
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
||||
const externalSignal = init.signal;
|
||||
if (externalSignal) {
|
||||
externalSignal.addEventListener('abort', () => controller.abort());
|
||||
const signal = externalSignal ? AbortSignal.any([timeoutSignal, externalSignal]) : timeoutSignal;
|
||||
|
||||
const method = (init.method ?? 'GET').toUpperCase();
|
||||
const isIdempotent = IDEMPOTENT_METHODS.has(method);
|
||||
const maxAttempts = isIdempotent || forceRetry ? 2 : 1;
|
||||
|
||||
// Key the breaker by the current backend origin so switching backend
|
||||
// URLs (e.g. recovering from a flapping local server by pointing at
|
||||
// a different host) gives the new origin a fresh breaker state. A
|
||||
// single shared `'web-backend'` key would otherwise leave a user
|
||||
// locked out for the full cooldown after one bad host trips the
|
||||
// circuit. The malformed-URL fallback is defensive — `setBackendUrl`
|
||||
// normalizes input, so this branch shouldn't fire in practice.
|
||||
let breakerKey: string;
|
||||
try {
|
||||
breakerKey = `web-backend:${new URL(_backendUrl).origin}`;
|
||||
} catch {
|
||||
breakerKey = 'web-backend:invalid';
|
||||
}
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, { ...init, signal: controller.signal });
|
||||
// Bounded retries + 5xx/429 handling are delegated to resilientFetch.
|
||||
// Method-aware budget: idempotent verbs retry once on transient
|
||||
// backend failures; mutations (POST/PATCH/PUT/DELETE) default to
|
||||
// single-attempt to avoid duplicate side effects.
|
||||
const response = await resilientFetch(
|
||||
url,
|
||||
{ ...init, signal },
|
||||
{
|
||||
breakerKey,
|
||||
retry: { maxAttempts, baseDelayMs: 250, capDelayMs: 1500 },
|
||||
},
|
||||
);
|
||||
return response;
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
if (externalSignal?.aborted) {
|
||||
throw new BackendError('Request aborted', 0, 'network');
|
||||
}
|
||||
if (error instanceof CircuitOpenError) {
|
||||
throw new BackendError(
|
||||
`GitNexus backend at ${_backendUrl} is unhealthy; retry in ${Math.ceil(error.retryAfterMs / 1000)}s`,
|
||||
0,
|
||||
'network',
|
||||
);
|
||||
}
|
||||
if (error instanceof ResilientFetchExhaustedError) {
|
||||
// Fall through to caller — surface the raw response so assertOk
|
||||
// can craft the BackendError with the right code.
|
||||
return error.response;
|
||||
}
|
||||
if (error instanceof DOMException && error.name === 'TimeoutError') {
|
||||
throw new BackendError(`Request to ${url} timed out after ${timeoutMs}ms`, 0, 'timeout');
|
||||
}
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
// External caller-driven cancellation — `timeoutSignal` would
|
||||
// have surfaced as TimeoutError above, so this branch covers
|
||||
// only the externally-aborted case.
|
||||
throw new BackendError('Request aborted', 0, 'network');
|
||||
}
|
||||
if (error instanceof TypeError) {
|
||||
throw new BackendError(
|
||||
`Network error reaching GitNexus backend at ${_backendUrl}: ${error.message}`,
|
||||
@@ -256,8 +355,6 @@ const fetchWithTimeout = async (
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -279,10 +376,32 @@ const assertOk = async (response: Response): Promise<void> => {
|
||||
const code =
|
||||
response.status === 404
|
||||
? 'not_found'
|
||||
: response.status >= 400 && response.status < 500
|
||||
? 'client'
|
||||
: 'server';
|
||||
throw new BackendError(message, response.status, code);
|
||||
: response.status === 429
|
||||
? 'rate_limited'
|
||||
: response.status >= 400 && response.status < 500
|
||||
? 'client'
|
||||
: 'server';
|
||||
|
||||
// Retry-After is the standard HTTP signal for when the client may try again.
|
||||
// express-rate-limit emits it on 429 with seconds (integer) or HTTP-date.
|
||||
// We accept both shapes; an unparseable header yields undefined retryAfterMs.
|
||||
let retryAfterMs: number | undefined;
|
||||
if (response.status === 429) {
|
||||
const header = response.headers.get('retry-after');
|
||||
if (header) {
|
||||
const seconds = Number(header);
|
||||
if (Number.isFinite(seconds) && seconds >= 0) {
|
||||
retryAfterMs = seconds * 1000;
|
||||
} else {
|
||||
const dateMs = Date.parse(header);
|
||||
if (Number.isFinite(dateMs)) {
|
||||
retryAfterMs = Math.max(0, dateMs - Date.now());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new BackendError(message, response.status, code, retryAfterMs);
|
||||
};
|
||||
|
||||
const repoParam = (repo?: string): string => (repo ? `repo=${encodeURIComponent(repo)}` : '');
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Method-aware retry budget + timeout-as-TimeoutError verification for
|
||||
* backend-client's `fetchWithTimeout`.
|
||||
*
|
||||
* Closes review findings on PR #1448:
|
||||
* - Non-idempotent POST/DELETE must NOT be retried by default —
|
||||
* a 5xx on `startAnalyze` could otherwise start a duplicate job.
|
||||
* - Timer-fired timeout must surface as `DOMException(name='TimeoutError')`,
|
||||
* not `AbortError`, so resilientFetch routes it through the
|
||||
* terminal-network branch (no retry, no breaker hit).
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getBreaker } from 'gitnexus-shared';
|
||||
import { __resetBreakerRegistry__ } from 'gitnexus-shared/test-helpers';
|
||||
import { fetchRepos, setBackendUrl, startAnalyze } from '../../src/services/backend-client';
|
||||
|
||||
const BASE = 'http://localhost:4747';
|
||||
|
||||
describe('backend-client retry budget (method-aware)', () => {
|
||||
beforeEach(() => {
|
||||
__resetBreakerRegistry__();
|
||||
setBackendUrl(BASE);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('GET retries once on transient 503 (idempotent verb)', async () => {
|
||||
let n = 0;
|
||||
const fetchMock = vi.fn(async () => {
|
||||
n += 1;
|
||||
if (n === 1) return new Response('boom', { status: 503 });
|
||||
return new Response('[]', {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const repos = await fetchRepos();
|
||||
expect(repos).toEqual([]);
|
||||
// 1 retry budget on idempotent GET → 2 total fetch calls.
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('POST does NOT retry on 503 by default (non-idempotent verb)', async () => {
|
||||
const fetchMock = vi.fn(async () => new Response('boom', { status: 503 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(startAnalyze({ path: '/tmp/repo' })).rejects.toBeTruthy();
|
||||
// Single attempt — never duplicates a job-start POST.
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('switching backend URL after a circuit opens reaches a fresh breaker (U3)', async () => {
|
||||
// Pre-open the breaker for host-A by directly recording 3 failures.
|
||||
setBackendUrl('http://host-a.test:4747');
|
||||
const aKey = 'web-backend:http://host-a.test:4747';
|
||||
const breakerA = getBreaker(aKey);
|
||||
breakerA.recordFailure();
|
||||
breakerA.recordFailure();
|
||||
breakerA.recordFailure();
|
||||
expect(breakerA.getState()).toBe('open');
|
||||
|
||||
// Switch to host-B and make a request — must succeed against the
|
||||
// new origin without tripping the host-A circuit. Under the old
|
||||
// single-key behaviour the call would throw CircuitOpenError.
|
||||
setBackendUrl('http://host-b.test:4747');
|
||||
const fetchMock = vi.fn(
|
||||
async () =>
|
||||
new Response('[]', {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const repos = await fetchRepos();
|
||||
expect(repos).toEqual([]);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Host-A's breaker is still open in cooldown.
|
||||
expect(breakerA.getState()).toBe('open');
|
||||
// Host-B has its own (fresh) breaker.
|
||||
const bKey = 'web-backend:http://host-b.test:4747';
|
||||
expect(getBreaker(bKey).getState()).toBe('closed');
|
||||
expect(getBreaker(bKey).getConsecutiveFailures()).toBe(0);
|
||||
});
|
||||
|
||||
it('breaker not incremented when timeout fires (TimeoutError, not AbortError)', async () => {
|
||||
// Reject directly with a TimeoutError DOMException, mimicking what
|
||||
// `fetch` produces when its `AbortSignal.timeout()`-wired signal
|
||||
// fires. The real-fetch path goes signal.reason → reject(reason);
|
||||
// we shortcut that here so the test doesn't have to wait the
|
||||
// 30-second default timeout.
|
||||
const fetchMock = vi.fn(async () => {
|
||||
throw new DOMException('aborted by timeout', 'TimeoutError');
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(fetchRepos()).rejects.toMatchObject({ code: 'timeout' });
|
||||
|
||||
// The breaker must not have been penalized for a local timeout.
|
||||
expect(getBreaker(`web-backend:${BASE}`).getConsecutiveFailures()).toBe(0);
|
||||
// Timeout is terminal — no retry attempted.
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,11 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fetchGraph, normalizeServerUrl, setBackendUrl } from '../../src/services/backend-client';
|
||||
import {
|
||||
fetchGraph,
|
||||
getBackendUrl,
|
||||
normalizeServerUrl,
|
||||
setBackendUrl,
|
||||
validateBackendUrl,
|
||||
} from '../../src/services/backend-client';
|
||||
|
||||
describe('normalizeServerUrl', () => {
|
||||
it('adds http:// to localhost', () => {
|
||||
@@ -165,3 +171,61 @@ describe('fetchGraph', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateBackendUrl', () => {
|
||||
it('allows http:// URLs', () => {
|
||||
expect(() => validateBackendUrl('http://localhost:4747')).not.toThrow();
|
||||
expect(() => validateBackendUrl('http://127.0.0.1:4747')).not.toThrow();
|
||||
});
|
||||
|
||||
it('allows https:// URLs', () => {
|
||||
expect(() => validateBackendUrl('https://gitnexus.example.com')).not.toThrow();
|
||||
expect(() => validateBackendUrl('https://my-server.internal:4747')).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects non-http schemes', () => {
|
||||
expect(() => validateBackendUrl('javascript:alert(1)')).toThrow('must use http:// or https://');
|
||||
expect(() => validateBackendUrl('file:///etc/passwd')).toThrow('must use http:// or https://');
|
||||
expect(() => validateBackendUrl('data:text/plain,evil')).toThrow(
|
||||
'must use http:// or https://',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects malformed URLs', () => {
|
||||
expect(() => validateBackendUrl('not-a-url')).toThrow('Invalid backend URL');
|
||||
});
|
||||
|
||||
it('does not include the raw URL in error messages (credential hygiene)', () => {
|
||||
const urlWithCreds = 'javascript:alert("sk-secret")';
|
||||
let msg = '';
|
||||
try {
|
||||
validateBackendUrl(urlWithCreds);
|
||||
} catch (e) {
|
||||
msg = (e as Error).message;
|
||||
}
|
||||
expect(msg).not.toContain('sk-secret');
|
||||
expect(msg).not.toContain(urlWithCreds);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setBackendUrl', () => {
|
||||
it('accepts valid http URLs', () => {
|
||||
expect(() => setBackendUrl('http://localhost:4747')).not.toThrow();
|
||||
});
|
||||
|
||||
it('accepts valid https URLs', () => {
|
||||
expect(() => setBackendUrl('https://my-server.example.com')).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects non-http/https schemes', () => {
|
||||
expect(() => setBackendUrl('javascript:alert(1)')).toThrow('must use http:// or https://');
|
||||
expect(() => setBackendUrl('file:///etc/passwd')).toThrow('must use http:// or https://');
|
||||
});
|
||||
|
||||
it('does not mutate _backendUrl when validation fails', () => {
|
||||
setBackendUrl('http://localhost:4747');
|
||||
expect(() => setBackendUrl('javascript:alert(1)')).toThrow();
|
||||
// State must be preserved — validation must happen before the assignment
|
||||
expect(getBackendUrl()).toBe('http://localhost:4747');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,73 @@ All notable changes to GitNexus will be documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.6.4] - 2026-05-10
|
||||
|
||||
### Added
|
||||
|
||||
- **`gitnexus publish`** — opt-in command to push your indexed graph to the understand-quickly registry for shareable browsing (#1425)
|
||||
- **`IncludeExtractor` for C++** — cross-repo include tracking joins the group contract pipeline (#1156)
|
||||
- **Unreal Engine C++ support** — strips reflection macros (`UCLASS`, `UFUNCTION`, `UPROPERTY`, etc.) before tree-sitter parses, so UE projects index cleanly (#1439)
|
||||
- **Thrift contracts extractor** — group-mode contract detection for Apache Thrift IDL (#1234)
|
||||
- **Workspace extractors for Node, Python, Go, Java, Elixir** — group-mode auto-discovery of cross-package boundaries (#1260)
|
||||
- **Rust workspace cross-crate contracts** — auto-discovery of `[workspace]` member crates and their cross-crate links (#1256)
|
||||
- **Go scope-resolution hooks** — Go joins Python / C# / TypeScript on the registry-primary RFC #909 path (#1302)
|
||||
- **TypeScript registry-primary scope resolution (Ring 3)** — TypeScript fully migrated to scope-based resolution (#1050)
|
||||
- **Configurable group cross-link path exclusions** — reduces false-positive contract links in vendored / monorepo trees (#1093)
|
||||
- **MCP tool safety annotations** — every MCP tool advertises read-only / mutating semantics so hosts can prompt appropriately (#1127)
|
||||
- **`--embeddings <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,6 +1,15 @@
|
||||
FROM node:20-bookworm
|
||||
# Pinned npm version — keep in sync with the root Dockerfile.cli and
|
||||
# Dockerfile.web.
|
||||
ARG NPM_VERSION=11.14.1
|
||||
|
||||
# node:22-bookworm-slim
|
||||
FROM node:22-bookworm-slim@sha256:9f6d5975c7dca860947d3915877f85607946403fc55349f39b4bc3688448bb6e
|
||||
ARG NPM_VERSION
|
||||
WORKDIR /app
|
||||
RUN apt-get -o Acquire::Check-Valid-Until=false -o Acquire::Check-Date=false update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
||||
RUN npx --yes npm@${NPM_VERSION} install -g npm@${NPM_VERSION} \
|
||||
&& apt-get -o Acquire::Check-Valid-Until=false -o Acquire::Check-Date=false update \
|
||||
&& apt-get install -y python3 make g++ \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY . .
|
||||
RUN npm ci --ignore-scripts \
|
||||
&& npm rebuild tree-sitter-swift 2>&1 \
|
||||
|
||||
+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 | — | MCP + Skills |
|
||||
| **Cursor** | Yes | Yes | Yes (postToolUse, [manual install](../gitnexus-cursor-integration/README.md#hook-install)) | **Full** |
|
||||
| **Codex** | Yes | Yes | — | MCP + Skills |
|
||||
| **Windsurf** | Yes | — | — | MCP |
|
||||
| **OpenCode** | Yes | Yes | — | MCP + Skills |
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
const { acquireHookSlot } = require('./hook-lock.cjs');
|
||||
|
||||
/**
|
||||
* Read JSON input from stdin synchronously.
|
||||
@@ -207,7 +208,8 @@ function runGitNexusCli(cliPath, args, cwd, timeout) {
|
||||
function handlePreToolUse(input) {
|
||||
const cwd = input.cwd || process.cwd();
|
||||
if (!path.isAbsolute(cwd)) return;
|
||||
if (!findGitNexusDir(cwd)) return;
|
||||
const gitNexusDir = findGitNexusDir(cwd);
|
||||
if (!gitNexusDir) return;
|
||||
|
||||
const toolName = input.tool_name || '';
|
||||
const toolInput = input.tool_input || {};
|
||||
@@ -217,6 +219,9 @@ function handlePreToolUse(input) {
|
||||
const pattern = extractPattern(toolName, toolInput);
|
||||
if (!pattern || pattern.length < 3) return;
|
||||
|
||||
const release = acquireHookSlot(gitNexusDir);
|
||||
if (!release) return;
|
||||
|
||||
const cliPath = resolveCliPath();
|
||||
let result = '';
|
||||
try {
|
||||
@@ -226,6 +231,8 @@ function handlePreToolUse(input) {
|
||||
}
|
||||
} catch {
|
||||
/* graceful failure */
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
|
||||
if (result && result.trim()) {
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const HOOK_LOCK_SUBDIR = '.hook-locks';
|
||||
const HOOK_LOCK_MAX_INFLIGHT = 3;
|
||||
const HOOK_LOCK_STALE_MS = 30000;
|
||||
|
||||
function acquireHookSlot(gitNexusDir) {
|
||||
const lockDir = path.join(gitNexusDir, HOOK_LOCK_SUBDIR);
|
||||
try {
|
||||
fs.mkdirSync(lockDir, { recursive: true });
|
||||
} catch {
|
||||
// Cannot create lock dir (read-only fs, cross-user perm denial, out of
|
||||
// inodes, etc.) — fail closed by returning null. Caller skips augment.
|
||||
// Fail-open here would let N concurrent hooks all proceed unguarded and
|
||||
// reintroduce the #1486 fan-out the guard exists to prevent.
|
||||
return null;
|
||||
}
|
||||
|
||||
const myPidStr = String(process.pid);
|
||||
|
||||
for (let slot = 0; slot < HOOK_LOCK_MAX_INFLIGHT; slot++) {
|
||||
const slotPath = path.join(lockDir, `slot-${slot}.lock`);
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
try {
|
||||
fs.writeFileSync(slotPath, myPidStr, { flag: 'wx' });
|
||||
let released = false;
|
||||
const release = () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
try {
|
||||
// Only unlink if we still own the slot. If we appeared stale and
|
||||
// another hook took over, the file now belongs to it — leave alone.
|
||||
const content = fs.readFileSync(slotPath, 'utf-8').trim();
|
||||
if (content === myPidStr) fs.unlinkSync(slotPath);
|
||||
} catch {
|
||||
/* already removed or unreadable */
|
||||
}
|
||||
};
|
||||
process.on('exit', release);
|
||||
return release;
|
||||
} catch {
|
||||
// Slot exists. Decide whether to take it over.
|
||||
// Open once and inspect mtime + content via the same fd so there's
|
||||
// no TOCTOU between the metadata check and the content read
|
||||
// (codeql js/file-system-race).
|
||||
let fd;
|
||||
try {
|
||||
fd = fs.openSync(slotPath, 'r');
|
||||
} catch {
|
||||
continue; // Vanished between EEXIST and open — retry this slot.
|
||||
}
|
||||
let isLive = false;
|
||||
let mtimeMs = Date.now();
|
||||
try {
|
||||
mtimeMs = fs.fstatSync(fd).mtimeMs;
|
||||
const buf = Buffer.alloc(32);
|
||||
const n = fs.readSync(fd, buf, 0, 32, 0);
|
||||
const ownerStr = buf.slice(0, n).toString('utf-8').trim();
|
||||
if (ownerStr === '') {
|
||||
// Owner created the file but hasn't written its PID yet. The
|
||||
// wx open+write window is microseconds; give it the benefit
|
||||
// of the doubt and treat as live.
|
||||
isLive = true;
|
||||
} else {
|
||||
const owner = Number.parseInt(ownerStr, 10);
|
||||
if (Number.isFinite(owner) && owner > 0) {
|
||||
try {
|
||||
process.kill(owner, 0);
|
||||
isLive = true;
|
||||
} catch (e) {
|
||||
// ESRCH = process gone → treat as dead. EPERM = process exists
|
||||
// but owned by another user (cross-user lock dir) → still alive,
|
||||
// keep the slot. Anything else: be conservative, assume alive.
|
||||
if (e && e.code === 'ESRCH') {
|
||||
isLive = false;
|
||||
} else {
|
||||
isLive = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* unreadable — treat as dead */
|
||||
} finally {
|
||||
try {
|
||||
fs.closeSync(fd);
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
}
|
||||
// For slots younger than HOOK_LOCK_STALE_MS, PID-liveness wins —
|
||||
// a slow-but-alive hook is never wrongly evicted. For older slots,
|
||||
// age is the final arbiter as a defense against PID reuse on long-
|
||||
// abandoned slots. 30s >> the 7s augment timeout, so a healthy run
|
||||
// never crosses this threshold.
|
||||
if (isLive && Date.now() - mtimeMs > HOOK_LOCK_STALE_MS) {
|
||||
isLive = false;
|
||||
}
|
||||
if (isLive) break; // Try the next slot.
|
||||
try {
|
||||
fs.unlinkSync(slotPath);
|
||||
} catch {
|
||||
/* another hook beat us to it — retry will hit EEXIST */
|
||||
}
|
||||
// Loop and retry this slot.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
HOOK_LOCK_SUBDIR,
|
||||
HOOK_LOCK_MAX_INFLIGHT,
|
||||
HOOK_LOCK_STALE_MS,
|
||||
acquireHookSlot,
|
||||
};
|
||||
Generated
+291
-44
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "gitnexus",
|
||||
"version": "1.6.3",
|
||||
"version": "1.6.5-rc.26",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "gitnexus",
|
||||
"version": "1.6.3",
|
||||
"version": "1.6.5-rc.26",
|
||||
"hasInstallScript": true,
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"dependencies": {
|
||||
@@ -18,6 +18,7 @@
|
||||
"commander": "^14.0.3",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.19.2",
|
||||
"express-rate-limit": "^8.4.1",
|
||||
"glob": "^13.0.6",
|
||||
"graphology": "^0.26.0",
|
||||
"graphology-indices": "^0.17.0",
|
||||
@@ -29,6 +30,8 @@
|
||||
"mnemonist": "^0.40.3",
|
||||
"onnxruntime-node": "^1.24.0",
|
||||
"pandemonium": "^2.4.0",
|
||||
"pino": "^10.3.1",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"tree-sitter": "^0.21.1",
|
||||
"tree-sitter-c": "0.21.4",
|
||||
"tree-sitter-c-sharp": "0.23.1",
|
||||
@@ -60,7 +63,7 @@
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
"node": ">=22.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"node-addon-api": "^8.0.0",
|
||||
@@ -1578,6 +1581,12 @@
|
||||
"url": "https://github.com/sponsors/Boshen"
|
||||
}
|
||||
},
|
||||
"node_modules/@pinojs/redact": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
|
||||
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@protobufjs/aspromise": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
|
||||
@@ -1591,9 +1600,9 @@
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/codegen": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz",
|
||||
"integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==",
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz",
|
||||
"integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/eventemitter": {
|
||||
@@ -1619,9 +1628,9 @@
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/inquire": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz",
|
||||
"integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==",
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.1.tgz",
|
||||
"integrity": "sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/path": {
|
||||
@@ -1637,9 +1646,9 @@
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/utf8": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz",
|
||||
"integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==",
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz",
|
||||
"integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
@@ -2056,9 +2065,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
|
||||
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
|
||||
"version": "25.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.2.tgz",
|
||||
"integrity": "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.19.0"
|
||||
@@ -2379,6 +2388,15 @@
|
||||
"js-tokens": "^10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/atomic-sleep": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
||||
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
@@ -2578,6 +2596,12 @@
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/colorette": {
|
||||
"version": "2.0.20",
|
||||
"resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
|
||||
"integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "14.0.3",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
|
||||
@@ -2682,6 +2706,15 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/dateformat": {
|
||||
"version": "4.6.3",
|
||||
"resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz",
|
||||
"integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -2805,6 +2838,15 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/end-of-stream": {
|
||||
"version": "1.4.5",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"once": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
@@ -3017,12 +3059,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/express-rate-limit": {
|
||||
"version": "8.3.1",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz",
|
||||
"integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==",
|
||||
"version": "8.5.1",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.1.tgz",
|
||||
"integrity": "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ip-address": "10.1.0"
|
||||
"ip-address": "^10.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
@@ -3049,16 +3091,28 @@
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-copy": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.3.tgz",
|
||||
"integrity": "sha512-58apWr0GUiDFM8+3afrO6eYwJBn9ZAhDOzG3L+/9llab/haCARS2UIfffmOurYLwbgDRs8n0rfr6qAAPEAuAQw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-safe-stringify": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
|
||||
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
|
||||
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -3415,10 +3469,16 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/help-me": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz",
|
||||
"integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.12.16",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.16.tgz",
|
||||
"integrity": "sha512-jN0ZewiNAWSe5khM3EyCmBb250+b40wWbwNILNfEvq84VREWwOIkuUsFONk/3i3nqkz7Oe1PcpM2mwQEK2L9Kg==",
|
||||
"version": "4.12.18",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz",
|
||||
"integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
@@ -3485,9 +3545,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
||||
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
@@ -3574,6 +3634,15 @@
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/joycon": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz",
|
||||
"integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
|
||||
@@ -3891,9 +3960,9 @@
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "11.3.5",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz",
|
||||
"integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==",
|
||||
"version": "11.3.6",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz",
|
||||
"integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
@@ -4182,6 +4251,15 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/on-exit-leak-free": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
|
||||
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/on-finished": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||
@@ -4204,15 +4282,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/onnxruntime-common": {
|
||||
"version": "1.25.1",
|
||||
"resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.25.1.tgz",
|
||||
"integrity": "sha512-kKvYQFdos4LWJqhZ+nmKu3NT8NXzw8I5x9fNUKe1rNKcPfNKnYXUtW7JBpcKFsvLtrJashRgVYSbFap4cHxvNg==",
|
||||
"version": "1.26.0",
|
||||
"resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.26.0.tgz",
|
||||
"integrity": "sha512-qVyMR4lcWgbkc4getFV+GQijsTnbg/siteoqcDwa3sI/LxbrMSNw4ePyvCq/ymdQaRomCA7YuWmhzsswxvymdw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/onnxruntime-node": {
|
||||
"version": "1.25.1",
|
||||
"resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.25.1.tgz",
|
||||
"integrity": "sha512-N0M58CGTiTsLkPpx9bxmRFi24GT6r67Qei/GrBEIiDyntcYdXU5vQZp112ypydG9vEKRFgbgUYQJnEi+jll8dg==",
|
||||
"version": "1.26.0",
|
||||
"resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.26.0.tgz",
|
||||
"integrity": "sha512-OHl6PiOEOqxaLHL0N9eFrbzS7IGmu3BtJNH3RTEnRAheCIkfc3gjcjl4sGcjp9C22ZC9YTquDOxSdT/stBQ6BQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"os": [
|
||||
@@ -4223,7 +4301,7 @@
|
||||
"dependencies": {
|
||||
"adm-zip": "^0.5.16",
|
||||
"global-agent": "^4.1.3",
|
||||
"onnxruntime-common": "1.25.1"
|
||||
"onnxruntime-common": "1.26.0"
|
||||
}
|
||||
},
|
||||
"node_modules/onnxruntime-web": {
|
||||
@@ -4331,6 +4409,79 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/pino": {
|
||||
"version": "10.3.1",
|
||||
"resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz",
|
||||
"integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@pinojs/redact": "^0.4.0",
|
||||
"atomic-sleep": "^1.0.0",
|
||||
"on-exit-leak-free": "^2.1.0",
|
||||
"pino-abstract-transport": "^3.0.0",
|
||||
"pino-std-serializers": "^7.0.0",
|
||||
"process-warning": "^5.0.0",
|
||||
"quick-format-unescaped": "^4.0.3",
|
||||
"real-require": "^0.2.0",
|
||||
"safe-stable-stringify": "^2.3.1",
|
||||
"sonic-boom": "^4.0.1",
|
||||
"thread-stream": "^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"pino": "bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/pino-abstract-transport": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz",
|
||||
"integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"split2": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pino-pretty": {
|
||||
"version": "13.1.3",
|
||||
"resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-13.1.3.tgz",
|
||||
"integrity": "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"colorette": "^2.0.7",
|
||||
"dateformat": "^4.6.3",
|
||||
"fast-copy": "^4.0.0",
|
||||
"fast-safe-stringify": "^2.1.1",
|
||||
"help-me": "^5.0.0",
|
||||
"joycon": "^3.1.1",
|
||||
"minimist": "^1.2.6",
|
||||
"on-exit-leak-free": "^2.1.0",
|
||||
"pino-abstract-transport": "^3.0.0",
|
||||
"pump": "^3.0.0",
|
||||
"secure-json-parse": "^4.0.0",
|
||||
"sonic-boom": "^4.0.1",
|
||||
"strip-json-comments": "^5.0.2"
|
||||
},
|
||||
"bin": {
|
||||
"pino-pretty": "bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/pino-pretty/node_modules/strip-json-comments": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz",
|
||||
"integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/pino-std-serializers": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
|
||||
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pkce-challenge": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
|
||||
@@ -4375,23 +4526,39 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/process-warning": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
|
||||
"integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/protobufjs": {
|
||||
"version": "7.5.5",
|
||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.5.tgz",
|
||||
"integrity": "sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg==",
|
||||
"version": "7.5.8",
|
||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.8.tgz",
|
||||
"integrity": "sha512-dvpCIeLPbXZS/Ete7yLaO7RenOdken2NHKykBXbsaGxZT0UTltcarBciw+A78SRQs9iMAAVpsYA+l8b1hTePIA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@protobufjs/aspromise": "^1.1.2",
|
||||
"@protobufjs/base64": "^1.1.2",
|
||||
"@protobufjs/codegen": "^2.0.4",
|
||||
"@protobufjs/codegen": "^2.0.5",
|
||||
"@protobufjs/eventemitter": "^1.1.0",
|
||||
"@protobufjs/fetch": "^1.1.0",
|
||||
"@protobufjs/float": "^1.0.2",
|
||||
"@protobufjs/inquire": "^1.1.0",
|
||||
"@protobufjs/inquire": "^1.1.1",
|
||||
"@protobufjs/path": "^1.1.2",
|
||||
"@protobufjs/pool": "^1.1.0",
|
||||
"@protobufjs/utf8": "^1.1.0",
|
||||
"@protobufjs/utf8": "^1.1.1",
|
||||
"@types/node": ">=13.7.0",
|
||||
"long": "^5.0.0"
|
||||
},
|
||||
@@ -4412,6 +4579,16 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/pump": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
|
||||
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"end-of-stream": "^1.1.0",
|
||||
"once": "^1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.14.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
|
||||
@@ -4427,6 +4604,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/quick-format-unescaped": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
|
||||
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/range-parser": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||
@@ -4482,6 +4665,15 @@
|
||||
"rc": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/real-require": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
|
||||
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
@@ -4590,12 +4782,37 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safe-stable-stringify": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
|
||||
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/secure-json-parse": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz",
|
||||
"integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
@@ -4827,6 +5044,15 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/sonic-boom": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
|
||||
"integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"atomic-sleep": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -4837,6 +5063,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/split2": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/stackback": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||
@@ -4924,6 +5159,18 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/thread-stream": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz",
|
||||
"integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"real-require": "^0.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gitnexus",
|
||||
"version": "1.6.3",
|
||||
"version": "1.6.5-rc.26",
|
||||
"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",
|
||||
@@ -44,6 +44,7 @@
|
||||
"dev": "tsx watch src/cli/index.ts",
|
||||
"test": "vitest run",
|
||||
"test:unit": "vitest run test/unit",
|
||||
"pretest:integration": "node scripts/build.js",
|
||||
"test:integration": "vitest run test/integration",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
@@ -60,6 +61,7 @@
|
||||
"commander": "^14.0.3",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.19.2",
|
||||
"express-rate-limit": "^8.4.1",
|
||||
"glob": "^13.0.6",
|
||||
"graphology": "^0.26.0",
|
||||
"graphology-indices": "^0.17.0",
|
||||
@@ -71,6 +73,8 @@
|
||||
"mnemonist": "^0.40.3",
|
||||
"onnxruntime-node": "^1.24.0",
|
||||
"pandemonium": "^2.4.0",
|
||||
"pino": "^10.3.1",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"tree-sitter": "^0.21.1",
|
||||
"tree-sitter-c": "0.21.4",
|
||||
"tree-sitter-c-sharp": "0.23.1",
|
||||
@@ -112,6 +116,6 @@
|
||||
}
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,17 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
// Opt-out: skip the native rebuild entirely. Dart parsing becomes
|
||||
// unavailable but `npm install gitnexus` finishes much faster on machines
|
||||
// without a C++ toolchain. Strict `=== '1'` only — '=true', '=yes', '=0'
|
||||
// (read as a string), and any other value all fall through to the rebuild.
|
||||
if (process.env.GITNEXUS_SKIP_OPTIONAL_GRAMMARS === '1') {
|
||||
console.warn(
|
||||
'[tree-sitter-dart] Skipping build (GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1). Dart parsing will be unavailable until reinstalled without the env var.',
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const dartDir = path.join(__dirname, '..', 'node_modules', 'tree-sitter-dart');
|
||||
const bindingGyp = path.join(dartDir, 'binding.gyp');
|
||||
const bindingNode = path.join(dartDir, 'build', 'Release', 'tree_sitter_dart_binding.node');
|
||||
|
||||
@@ -34,6 +34,17 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
// Opt-out: skip the native rebuild entirely. Proto parsing becomes
|
||||
// unavailable but `npm install gitnexus` finishes much faster on machines
|
||||
// without a C++ toolchain. Strict `=== '1'` only — '=true', '=yes', '=0'
|
||||
// (read as a string), and any other value all fall through to the rebuild.
|
||||
if (process.env.GITNEXUS_SKIP_OPTIONAL_GRAMMARS === '1') {
|
||||
console.warn(
|
||||
'[tree-sitter-proto] Skipping build (GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1). Proto parsing will be unavailable until reinstalled without the env var.',
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const protoDir = path.join(__dirname, '..', 'node_modules', 'tree-sitter-proto');
|
||||
const bindingGyp = path.join(protoDir, 'binding.gyp');
|
||||
const bindingNode = path.join(protoDir, 'build', 'Release', 'tree_sitter_proto_binding.node');
|
||||
|
||||
@@ -21,11 +21,15 @@ const SHARED_DEST = path.join(DIST, '_shared');
|
||||
|
||||
// ── 1. Build gitnexus-shared ───────────────────────────────────────
|
||||
console.log('[build] compiling gitnexus-shared…');
|
||||
execSync('npx tsc', { cwd: SHARED_ROOT, stdio: 'inherit', timeout: 120_000 });
|
||||
const tscCmd =
|
||||
process.platform === 'win32'
|
||||
? path.join('node_modules', '.bin', 'tsc.cmd')
|
||||
: path.join('node_modules', '.bin', 'tsc');
|
||||
execSync(tscCmd, { cwd: SHARED_ROOT, stdio: 'inherit', timeout: 120_000 });
|
||||
|
||||
// ── 2. Build gitnexus ──────────────────────────────────────────────
|
||||
console.log('[build] compiling gitnexus…');
|
||||
execSync('npx tsc', { cwd: ROOT, stdio: 'inherit', timeout: 120_000 });
|
||||
execSync(tscCmd, { cwd: ROOT, stdio: 'inherit', timeout: 120_000 });
|
||||
|
||||
// ── 3. Copy shared dist ────────────────────────────────────────────
|
||||
console.log('[build] copying shared module into dist/_shared…');
|
||||
|
||||
@@ -10,6 +10,7 @@ import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { type GeneratedSkillInfo } from './skill-gen.js';
|
||||
import { logger } from '../core/logger.js';
|
||||
|
||||
// ESM equivalent of __dirname
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
@@ -27,6 +28,7 @@ interface RepoStats {
|
||||
export interface AIContextOptions {
|
||||
skipAgentsMd?: boolean;
|
||||
noStats?: boolean;
|
||||
skipSkills?: boolean;
|
||||
}
|
||||
|
||||
const GITNEXUS_START_MARKER = '<!-- gitnexus:start -->';
|
||||
@@ -93,6 +95,7 @@ function generateGitNexusContent(
|
||||
generatedSkills?: GeneratedSkillInfo[],
|
||||
groupNames?: string[],
|
||||
noStats?: boolean,
|
||||
skipSkills?: boolean,
|
||||
): string {
|
||||
const generatedRows =
|
||||
generatedSkills && generatedSkills.length > 0
|
||||
@@ -104,14 +107,26 @@ function generateGitNexusContent(
|
||||
.join('\n')
|
||||
: '';
|
||||
|
||||
const skillsTable = `| Task | Read this skill file |
|
||||
|------|---------------------|
|
||||
| Understand architecture / "How does X work?" | \`.claude/skills/gitnexus/gitnexus-exploring/SKILL.md\` |
|
||||
// Standard skill rows reference files installed by installSkills(). When
|
||||
// --skip-skills suppresses that install, these rows must be omitted — else
|
||||
// AGENTS.md/CLAUDE.md would direct agents to read files that don't exist.
|
||||
// Community skills (generatedRows) live in .claude/skills/generated/ and
|
||||
// are independent of --skip-skills, so they remain when present.
|
||||
const standardSkillsRows = skipSkills
|
||||
? ''
|
||||
: `| Understand architecture / "How does X work?" | \`.claude/skills/gitnexus/gitnexus-exploring/SKILL.md\` |
|
||||
| Blast radius / "What breaks if I change X?" | \`.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md\` |
|
||||
| Trace bugs / "Why is X failing?" | \`.claude/skills/gitnexus/gitnexus-debugging/SKILL.md\` |
|
||||
| Rename / extract / split / refactor | \`.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md\` |
|
||||
| Tools, resources, schema reference | \`.claude/skills/gitnexus/gitnexus-guide/SKILL.md\` |
|
||||
| Index, status, clean, wiki CLI commands | \`.claude/skills/gitnexus/gitnexus-cli/SKILL.md\` |${generatedRows ? '\n' + generatedRows : ''}`;
|
||||
| Index, status, clean, wiki CLI commands | \`.claude/skills/gitnexus/gitnexus-cli/SKILL.md\` |`;
|
||||
|
||||
const tableBody = [standardSkillsRows, generatedRows].filter(Boolean).join('\n');
|
||||
const skillsTable = tableBody
|
||||
? `| Task | Read this skill file |
|
||||
|------|---------------------|
|
||||
${tableBody}`
|
||||
: '';
|
||||
|
||||
return `${GITNEXUS_START_MARKER}
|
||||
# GitNexus — Code Intelligence
|
||||
@@ -152,11 +167,15 @@ This repository is listed under GitNexus **group(s): ${groupNames.join(', ')}**
|
||||
|
||||
`
|
||||
: ''
|
||||
}## CLI
|
||||
}${
|
||||
skillsTable
|
||||
? `## CLI
|
||||
|
||||
${skillsTable}
|
||||
|
||||
${GITNEXUS_END_MARKER}`;
|
||||
`
|
||||
: ''
|
||||
}${GITNEXUS_END_MARKER}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -293,7 +312,7 @@ Use GitNexus tools to accomplish this task.
|
||||
installedSkills.push(skill.name);
|
||||
} catch (err) {
|
||||
// Skip on error, don't fail the whole process
|
||||
console.warn(`Warning: Could not install skill ${skill.name}:`, err);
|
||||
logger.warn({ err }, `Warning: Could not install skill ${skill.name}:`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,6 +337,7 @@ export async function generateAIContextFiles(
|
||||
generatedSkills,
|
||||
groupNames,
|
||||
options?.noStats,
|
||||
options?.skipSkills,
|
||||
);
|
||||
const createdFiles: string[] = [];
|
||||
|
||||
@@ -336,10 +356,14 @@ export async function generateAIContextFiles(
|
||||
createdFiles.push('CLAUDE.md (skipped via --skip-agents-md)');
|
||||
}
|
||||
|
||||
// Install skills to .claude/skills/gitnexus/
|
||||
const installedSkills = await installSkills(repoPath);
|
||||
if (installedSkills.length > 0) {
|
||||
createdFiles.push(`.claude/skills/gitnexus/ (${installedSkills.length} skills)`);
|
||||
// Install skills to .claude/skills/gitnexus/ (unless --skip-skills)
|
||||
if (!options?.skipSkills) {
|
||||
const installedSkills = await installSkills(repoPath);
|
||||
if (installedSkills.length > 0) {
|
||||
createdFiles.push(`.claude/skills/gitnexus/ (${installedSkills.length} skills)`);
|
||||
}
|
||||
} else {
|
||||
createdFiles.push('.claude/skills/gitnexus/ (skipped via --skip-skills)');
|
||||
}
|
||||
|
||||
return { files: createdFiles };
|
||||
|
||||
+178
-37
@@ -23,7 +23,11 @@ import {
|
||||
import { getGitRoot, hasGitDir } from '../storage/git.js';
|
||||
import { runFullAnalysis } from '../core/run-analyze.js';
|
||||
import { getMaxFileSizeBannerMessage } from '../core/ingestion/utils/max-file-size.js';
|
||||
import { warnMissingOptionalGrammars } from './optional-grammars.js';
|
||||
import { glob } from 'glob';
|
||||
import fs from 'fs/promises';
|
||||
import { cliError } from './cli-message.js';
|
||||
import { isHfDownloadFailure } from '../core/embeddings/hf-env.js';
|
||||
|
||||
// Capture stderr.write at module load BEFORE anything (LadybugDB native
|
||||
// init, progress bar, console redirection) can monkey-patch it. The
|
||||
@@ -95,7 +99,14 @@ function ensureHeap(): boolean {
|
||||
|
||||
export interface AnalyzeOptions {
|
||||
force?: boolean;
|
||||
embeddings?: boolean;
|
||||
/**
|
||||
* Embedding generation toggle. Commander parses `--embeddings [limit]` as:
|
||||
* - `undefined` when the flag is omitted
|
||||
* - `true` when passed without an argument (use default 50K node cap)
|
||||
* - a string when passed with an argument (`--embeddings 0` disables the
|
||||
* cap, `--embeddings <n>` uses `<n>` as the cap)
|
||||
*/
|
||||
embeddings?: boolean | string;
|
||||
/**
|
||||
* Explicitly drop existing embeddings on rebuild instead of preserving
|
||||
* them. Without this flag, a routine `analyze` keeps any embeddings
|
||||
@@ -108,6 +119,10 @@ export interface AnalyzeOptions {
|
||||
skipAgentsMd?: boolean;
|
||||
/** Omit volatile symbol/relationship counts from AGENTS.md and CLAUDE.md. */
|
||||
noStats?: boolean;
|
||||
/** Skip installing standard GitNexus skill files to .claude/skills/gitnexus/. */
|
||||
skipSkills?: boolean;
|
||||
/** Pure index mode: skip all file injection (AGENTS.md, CLAUDE.md, skills). */
|
||||
indexOnly?: boolean;
|
||||
/** Index the folder even when no .git directory is present. */
|
||||
skipGit?: boolean;
|
||||
/**
|
||||
@@ -139,6 +154,24 @@ export interface AnalyzeOptions {
|
||||
embeddingDevice?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the post-index skill step should run.
|
||||
*
|
||||
* The gated block does two things in sequence: (1) generates the community
|
||||
* skill files from `--skills`, and (2) re-runs `generateAIContextFiles` so
|
||||
* AGENTS.md/CLAUDE.md can reference the freshly written skills. Both are
|
||||
* suppressed together — `--index-only` drops the entire step, not just the
|
||||
* community-skill write. Name retained for the test contract; see call site
|
||||
* in `analyzeCommand` for the AGENTS.md/CLAUDE.md re-generation it also gates.
|
||||
*
|
||||
* Kept as a pure helper so the `--index-only --skills` contract is unit-tested
|
||||
* without booting the full analyze pipeline (#742 review).
|
||||
*/
|
||||
export const shouldGenerateCommunitySkillFiles = (
|
||||
options: Pick<AnalyzeOptions, 'skills' | 'indexOnly'> | undefined,
|
||||
pipelineResult: unknown,
|
||||
): boolean => Boolean(options?.skills && pipelineResult && !options?.indexOnly);
|
||||
|
||||
export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOptions) => {
|
||||
if (ensureHeap()) return;
|
||||
|
||||
@@ -158,7 +191,7 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
|
||||
if (options?.workerTimeout) {
|
||||
const workerTimeoutSeconds = Number(options.workerTimeout);
|
||||
if (!Number.isFinite(workerTimeoutSeconds) || workerTimeoutSeconds < 1) {
|
||||
console.error(' --worker-timeout must be at least 1 second.\n');
|
||||
cliError(' --worker-timeout must be at least 1 second.\n');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
@@ -167,6 +200,25 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
|
||||
);
|
||||
}
|
||||
|
||||
// Parse `--embeddings [limit]`: `true` → default cap, string → numeric cap
|
||||
// (0 disables the cap entirely). Validated up here so failures match the
|
||||
// sibling-validation pattern (exit before bar.start() — otherwise
|
||||
// process.exit() leaves the progress bar's hidden cursor uncleared).
|
||||
let embeddingsNodeLimit: number | undefined;
|
||||
if (typeof options?.embeddings === 'string') {
|
||||
const parsed = Number(options.embeddings);
|
||||
if (!Number.isInteger(parsed) || parsed < 0) {
|
||||
cliError(
|
||||
` --embeddings expects a non-negative integer (got "${options.embeddings}"). ` +
|
||||
`Pass 0 to disable the safety cap, or omit the value to keep the default.\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
embeddingsNodeLimit = parsed;
|
||||
}
|
||||
const embeddingsEnabled = !!options?.embeddings;
|
||||
|
||||
const setPositiveEnv = (
|
||||
optionName: string,
|
||||
envName: string,
|
||||
@@ -175,7 +227,7 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
|
||||
if (value === undefined) return true;
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
console.error(` ${optionName} must be a positive integer.\n`);
|
||||
cliError(` ${optionName} must be a positive integer.\n`);
|
||||
process.exitCode = 1;
|
||||
return false;
|
||||
}
|
||||
@@ -206,7 +258,7 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
|
||||
if (options?.embeddingDevice) {
|
||||
const allowed = new Set(['auto', 'cpu', 'dml', 'cuda', 'wasm']);
|
||||
if (!allowed.has(options.embeddingDevice)) {
|
||||
console.error(' --embedding-device must be one of: auto, cpu, dml, cuda, wasm.\n');
|
||||
cliError(' --embedding-device must be one of: auto, cpu, dml, cuda, wasm.\n');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
@@ -215,6 +267,18 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
|
||||
|
||||
console.log('\n GitNexus Analyzer\n');
|
||||
|
||||
// `--index-only` is the stronger contract — it suppresses every form of file
|
||||
// injection, including community skill writes that `--skills` would normally
|
||||
// produce. Surface the override explicitly so users don't wonder why a
|
||||
// pipeline re-index ran but no skill files appeared. The pipeline still
|
||||
// re-runs (see `force: options?.force || options?.skills` below); the warning
|
||||
// is purely about the dropped post-index write step.
|
||||
if (options?.indexOnly && options?.skills) {
|
||||
console.log(
|
||||
' Note: --index-only overrides --skills; community skill files will not be written.\n',
|
||||
);
|
||||
}
|
||||
|
||||
let repoPath: string;
|
||||
if (inputPath) {
|
||||
repoPath = path.resolve(inputPath);
|
||||
@@ -247,6 +311,30 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
|
||||
);
|
||||
}
|
||||
|
||||
// If the target repo contains files an optional grammar would parse but
|
||||
// that grammar's native binding is absent, warn before analysis so users
|
||||
// learn why those files end up unparsed instead of silently getting a
|
||||
// degraded index.
|
||||
try {
|
||||
const matches = await glob(['**/*.dart', '**/*.proto'], {
|
||||
cwd: repoPath,
|
||||
ignore: ['**/node_modules/**', '**/.git/**', '**/dist/**', '**/build/**'],
|
||||
dot: false,
|
||||
nodir: true,
|
||||
absolute: false,
|
||||
});
|
||||
if (matches.length > 0) {
|
||||
const present = new Set<string>();
|
||||
for (const m of matches) {
|
||||
const ext = path.extname(m).toLowerCase();
|
||||
if (ext) present.add(ext);
|
||||
}
|
||||
warnMissingOptionalGrammars({ context: 'analyze', relevantExtensions: present });
|
||||
}
|
||||
} catch {
|
||||
// Best-effort warning \u2014 never block analyze on the precheck.
|
||||
}
|
||||
|
||||
// KuzuDB migration cleanup is handled by runFullAnalysis internally.
|
||||
// Note: --skills is handled after runFullAnalysis using the returned pipelineResult.
|
||||
|
||||
@@ -278,7 +366,9 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
|
||||
|
||||
bar.start(100, 0, { phase: 'Initializing...' });
|
||||
|
||||
// Graceful SIGINT handling
|
||||
// Graceful SIGINT handling. Pino's default destination is `sync: false`
|
||||
// (buffered) — flush before exit so in-flight records reach stderr.
|
||||
// See `gitnexus/src/core/logger.ts:flushLoggerSync`.
|
||||
let aborted = false;
|
||||
const sigintHandler = () => {
|
||||
if (aborted) process.exit(1);
|
||||
@@ -287,13 +377,23 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
|
||||
console.log('\n Interrupted — cleaning up...');
|
||||
closeLbug()
|
||||
.catch(() => {})
|
||||
.finally(() => process.exit(130));
|
||||
.finally(async () => {
|
||||
const { flushLoggerSync } = await import('../core/logger.js');
|
||||
flushLoggerSync();
|
||||
process.exit(130);
|
||||
});
|
||||
};
|
||||
process.on('SIGINT', sigintHandler);
|
||||
|
||||
// Route console output through bar.log() to prevent progress bar corruption
|
||||
// Route console output through bar.log() to prevent progress bar corruption.
|
||||
// This is a deliberate UI pattern (not a logging concern): analyze runs a
|
||||
// long-lived progress bar on stdout; any concurrent console.* write would
|
||||
// overwrite the bar mid-render. We capture originals, swap to barLog for
|
||||
// the lifetime of the run, and restore on completion/error/SIGINT.
|
||||
const origLog = console.log.bind(console);
|
||||
// eslint-disable-next-line no-console -- intentional console-routing for progress bar UX
|
||||
const origWarn = console.warn.bind(console);
|
||||
// eslint-disable-next-line no-console -- intentional console-routing for progress bar UX
|
||||
const origError = console.error.bind(console);
|
||||
let barCurrentValue = 0;
|
||||
const barLog = (...args: any[]) => {
|
||||
@@ -302,7 +402,9 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
|
||||
bar.update(barCurrentValue);
|
||||
};
|
||||
console.log = barLog;
|
||||
// eslint-disable-next-line no-console -- intentional console-routing for progress bar UX
|
||||
console.warn = barLog;
|
||||
// eslint-disable-next-line no-console -- intentional console-routing for progress bar UX
|
||||
console.error = barLog;
|
||||
|
||||
// Track elapsed time per phase
|
||||
@@ -331,6 +433,9 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
|
||||
|
||||
// ── Run shared analysis orchestrator ───────────────────────────────
|
||||
try {
|
||||
const skipAll = options?.indexOnly;
|
||||
const skipAgentsMd = skipAll || options?.skipAgentsMd;
|
||||
const skipSkills = skipAll || options?.skipSkills;
|
||||
const result = await runFullAnalysis(
|
||||
repoPath,
|
||||
{
|
||||
@@ -338,10 +443,12 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
|
||||
// needs a fresh pipelineResult. Has no bearing on the registry
|
||||
// collision guard (see allowDuplicateName below).
|
||||
force: options?.force || options?.skills,
|
||||
embeddings: options?.embeddings,
|
||||
embeddings: embeddingsEnabled,
|
||||
embeddingsNodeLimit,
|
||||
dropEmbeddings: options?.dropEmbeddings,
|
||||
skipGit: options?.skipGit,
|
||||
skipAgentsMd: options?.skipAgentsMd,
|
||||
skipAgentsMd,
|
||||
skipSkills,
|
||||
noStats: options?.noStats,
|
||||
registryName: options?.name,
|
||||
// Registry-collision bypass — its own CLI flag, intentionally NOT
|
||||
@@ -367,7 +474,9 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
|
||||
clearInterval(elapsedTimer);
|
||||
process.removeListener('SIGINT', sigintHandler);
|
||||
console.log = origLog;
|
||||
// eslint-disable-next-line no-console -- restoring after intentional progress-bar routing
|
||||
console.warn = origWarn;
|
||||
// eslint-disable-next-line no-console -- restoring after intentional progress-bar routing
|
||||
console.error = origError;
|
||||
bar.stop();
|
||||
console.log(' Already up to date\n');
|
||||
@@ -385,8 +494,10 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
|
||||
// a healthy index.
|
||||
await assertAnalysisFinalized(repoPath);
|
||||
|
||||
// Skill generation (CLI-only, uses pipeline result from analysis)
|
||||
if (options?.skills && result.pipelineResult) {
|
||||
// Skill generation (CLI-only, uses pipeline result from analysis).
|
||||
// Gated so `--index-only --skills` skips community skill writes too
|
||||
// (`shouldGenerateCommunitySkillFiles` — see unit test).
|
||||
if (shouldGenerateCommunitySkillFiles(options, result.pipelineResult)) {
|
||||
updateBar(99, 'Generating skill files...');
|
||||
try {
|
||||
const { generateSkillFiles } = await import('./skill-gen.js');
|
||||
@@ -426,7 +537,7 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
|
||||
processes: s.processes,
|
||||
},
|
||||
skillResult.skills,
|
||||
{ skipAgentsMd: options?.skipAgentsMd, noStats: options?.noStats },
|
||||
{ skipAgentsMd, skipSkills, noStats: options?.noStats },
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
@@ -440,7 +551,9 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
|
||||
process.removeListener('SIGINT', sigintHandler);
|
||||
|
||||
console.log = origLog;
|
||||
// eslint-disable-next-line no-console -- restoring after intentional progress-bar routing
|
||||
console.warn = origWarn;
|
||||
// eslint-disable-next-line no-console -- restoring after intentional progress-bar routing
|
||||
console.error = origError;
|
||||
|
||||
bar.update(100, { phase: 'Done' });
|
||||
@@ -465,7 +578,9 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
|
||||
clearInterval(elapsedTimer);
|
||||
process.removeListener('SIGINT', sigintHandler);
|
||||
console.log = origLog;
|
||||
// eslint-disable-next-line no-console -- restoring after intentional progress-bar routing
|
||||
console.warn = origWarn;
|
||||
// eslint-disable-next-line no-console -- restoring after intentional progress-bar routing
|
||||
console.error = origError;
|
||||
bar.stop();
|
||||
|
||||
@@ -474,14 +589,14 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
|
||||
// Registry name-collision from --name (#829) — surface as an
|
||||
// actionable error rather than a generic stack-trace.
|
||||
if (err instanceof RegistryNameCollisionError) {
|
||||
console.error(`\n Registry name collision:\n`);
|
||||
console.error(` "${err.registryName}" is already used by "${err.existingPath}".\n`);
|
||||
console.error(` Options:`);
|
||||
console.error(` • Pick a different alias: gitnexus analyze --name <alias>`);
|
||||
console.error(
|
||||
` • Allow the duplicate: gitnexus analyze --allow-duplicate-name (leaves "-r ${err.registryName}" ambiguous)`,
|
||||
cliError(
|
||||
`\n Registry name collision:\n` +
|
||||
` "${err.registryName}" is already used by "${err.existingPath}".\n\n` +
|
||||
` Options:\n` +
|
||||
` • Pick a different alias: gitnexus analyze --name <alias>\n` +
|
||||
` • Allow the duplicate: gitnexus analyze --allow-duplicate-name (leaves "-r ${err.registryName}" ambiguous)\n`,
|
||||
{ registryName: err.registryName, existingPath: err.existingPath },
|
||||
);
|
||||
console.error('');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
@@ -502,6 +617,26 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
|
||||
return;
|
||||
}
|
||||
|
||||
// HF download failure — show clean guidance without the raw stack trace.
|
||||
// Checked before writeFatalToStderr so the user sees one focused message
|
||||
// rather than a stack-trace dump followed by a second remediation block.
|
||||
if (isHfDownloadFailure(msg) || msg.includes('Failed to download embedding model')) {
|
||||
cliError(
|
||||
` The embedding model could not be downloaded.\n` +
|
||||
` huggingface.co may be unreachable from your network\n` +
|
||||
` (e.g. behind a corporate proxy or a regional firewall).\n` +
|
||||
` Suggestions:\n` +
|
||||
` 1. Set HF_ENDPOINT to a mirror and retry:\n` +
|
||||
` HF_ENDPOINT=https://hf-mirror.com npx gitnexus analyze --embeddings\n` +
|
||||
` (Windows: set HF_ENDPOINT=https://hf-mirror.com && npx gitnexus analyze --embeddings)\n` +
|
||||
` 2. Check your proxy / VPN settings.\n` +
|
||||
` 3. Once downloaded the model is cached — future runs work offline.\n`,
|
||||
{ recoveryHint: 'hf-endpoint-unreachable' },
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Bypass the redirected console.error and write the full stack to
|
||||
// the real stderr captured at module load. The redirected
|
||||
// console.error wraps every line with `\\x1b[2K\\r` (ANSI clear-line)
|
||||
@@ -521,34 +656,40 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
|
||||
msg.includes('heap out of memory') ||
|
||||
msg.includes('JavaScript heap')
|
||||
) {
|
||||
console.error(' This error typically occurs on very large repositories.');
|
||||
console.error(' Suggestions:');
|
||||
console.error(' 1. Add large vendored/generated directories to .gitnexusignore');
|
||||
console.error(' 2. Increase Node.js heap: NODE_OPTIONS="--max-old-space-size=16384"');
|
||||
console.error(' 3. Increase stack size: NODE_OPTIONS="--stack-size=4096"');
|
||||
console.error('');
|
||||
cliError(
|
||||
` This error typically occurs on very large repositories.\n` +
|
||||
` Suggestions:\n` +
|
||||
` 1. Add large vendored/generated directories to .gitnexusignore\n` +
|
||||
` 2. Increase Node.js heap: NODE_OPTIONS="--max-old-space-size=16384"\n` +
|
||||
` 3. Increase stack size: NODE_OPTIONS="--stack-size=4096"\n`,
|
||||
{ recoveryHint: 'large-repo' },
|
||||
);
|
||||
} else if (msg.includes('ERESOLVE') || msg.includes('Could not resolve dependency')) {
|
||||
// Note: the original arborist "Cannot destructure property 'package' of
|
||||
// 'node.target'" crash happens inside npm *before* gitnexus code runs,
|
||||
// so it can't be caught here. This branch handles dependency-resolution
|
||||
// errors that surface at runtime (e.g. dynamic require failures).
|
||||
console.error(' This looks like an npm dependency resolution issue.');
|
||||
console.error(' Suggestions:');
|
||||
console.error(' 1. Clear the npm cache: npm cache clean --force');
|
||||
console.error(' 2. Update npm: npm install -g npm@latest');
|
||||
console.error(' 3. Reinstall gitnexus: npm install -g gitnexus@latest');
|
||||
console.error(' 4. Or try npx directly: npx gitnexus@latest analyze');
|
||||
console.error('');
|
||||
cliError(
|
||||
` This looks like an npm dependency resolution issue.\n` +
|
||||
` Suggestions:\n` +
|
||||
` 1. Clear the npm cache: npm cache clean --force\n` +
|
||||
` 2. Update npm: npm install -g npm@latest\n` +
|
||||
` 3. Reinstall gitnexus: npm install -g gitnexus@latest\n` +
|
||||
` 4. Or try npx directly: npx gitnexus@latest analyze\n`,
|
||||
{ recoveryHint: 'npm-resolution' },
|
||||
);
|
||||
} else if (
|
||||
msg.includes('MODULE_NOT_FOUND') ||
|
||||
msg.includes('Cannot find module') ||
|
||||
msg.includes('ERR_MODULE_NOT_FOUND')
|
||||
) {
|
||||
console.error(' A required module could not be loaded. The installation may be corrupt.');
|
||||
console.error(' Suggestions:');
|
||||
console.error(' 1. Reinstall: npm install -g gitnexus@latest');
|
||||
console.error(' 2. Clear cache: npm cache clean --force && npx gitnexus@latest analyze');
|
||||
console.error('');
|
||||
cliError(
|
||||
` A required module could not be loaded. The installation may be corrupt.\n` +
|
||||
` Suggestions:\n` +
|
||||
` 1. Reinstall: npm install -g gitnexus@latest\n` +
|
||||
` 2. Clear cache: npm cache clean --force && npx gitnexus@latest analyze\n`,
|
||||
{ recoveryHint: 'module-not-found' },
|
||||
);
|
||||
}
|
||||
|
||||
process.exitCode = 1;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Augment CLI Command
|
||||
*
|
||||
* Fast-path command for platform hooks.
|
||||
* Shells out from Claude Code PreToolUse / Cursor beforeShellExecution hooks.
|
||||
* Shells out from Claude Code PreToolUse / Cursor postToolUse hooks.
|
||||
*
|
||||
* Usage: gitnexus augment <pattern>
|
||||
* Returns enriched text to stdout.
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import fs from 'fs/promises';
|
||||
import { logger } from '../core/logger.js';
|
||||
import {
|
||||
findRepo,
|
||||
unregisterRepo,
|
||||
@@ -45,7 +46,7 @@ export const cleanCommand = async (options?: { force?: boolean; all?: boolean })
|
||||
assertSafeStoragePath(entry);
|
||||
} catch (err) {
|
||||
if (err instanceof UnsafeStoragePathError) {
|
||||
console.error(`Refusing to clean ${entry.name}: ${err.message}`);
|
||||
logger.error(`Refusing to clean ${entry.name}: ${err.message}`);
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
@@ -56,7 +57,7 @@ export const cleanCommand = async (options?: { force?: boolean; all?: boolean })
|
||||
await unregisterRepo(entry.path);
|
||||
console.log(`Deleted: ${entry.name} (${entry.storagePath})`);
|
||||
} catch (err) {
|
||||
console.error(`Failed to delete ${entry.name}:`, err);
|
||||
logger.error({ err }, `Failed to delete ${entry.name}:`);
|
||||
}
|
||||
}
|
||||
return;
|
||||
@@ -85,6 +86,6 @@ export const cleanCommand = async (options?: { force?: boolean; all?: boolean })
|
||||
await unregisterRepo(repo.repoPath);
|
||||
console.log(`Deleted: ${repo.storagePath}`);
|
||||
} catch (err) {
|
||||
console.error('Failed to delete:', err);
|
||||
logger.error({ err }, 'Failed to delete:');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* CLI message helpers — for user-facing banners, error guidance, and
|
||||
* recovery hints emitted by `gitnexus` subcommands.
|
||||
*
|
||||
* These functions write **plain text** directly to `process.stderr` AND
|
||||
* tee a structured pino record through the singleton `logger`. Plain text
|
||||
* preserves the human-readable contract for users running `gitnexus`
|
||||
* interactively, redirecting to a file, or piping to `cat`/`grep`. The
|
||||
* structured tee keeps log aggregators happy.
|
||||
*
|
||||
* **Use these for:**
|
||||
* - User-facing banners ("Server listening on http://...:N")
|
||||
* - Validation errors ("--worker-timeout must be at least 1 second")
|
||||
* - Recovery hints ("Suggestions: 1. Clear the npm cache, 2. ...")
|
||||
* - One-line user notices ("No indexed repositories found.")
|
||||
*
|
||||
* **Do NOT use these for:**
|
||||
* - Internal diagnostics (worker progress, retry counts, telemetry)
|
||||
* — use `logger.info`/`warn`/`error` directly. Internal logs only
|
||||
* need structured fields, not double-output to stderr.
|
||||
* - High-volume hot paths — every `cliMessage` call writes twice (raw
|
||||
* + structured). Acceptable for user-facing messages, wasteful for
|
||||
* ingestion pipeline events.
|
||||
*
|
||||
* Design note: stderr is the right channel even for non-error messages
|
||||
* because GitNexus CLI tools (`query`, `cypher`, `impact`) emit JSON
|
||||
* data on stdout for piping (`gitnexus query | jq`). User banners on
|
||||
* stdout would corrupt that pipeline.
|
||||
*/
|
||||
import { logger } from '../core/logger.js';
|
||||
|
||||
function writeStderr(msg: string): void {
|
||||
// Direct write — bypassing `console.*` so it cannot be intercepted by
|
||||
// progress-bar redirection (see `cli/analyze.ts:barLog`) or other
|
||||
// routing. The structured tee below still goes through the logger so
|
||||
// log aggregation works either way.
|
||||
process.stderr.write(msg.endsWith('\n') ? msg : msg + '\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* User-facing informational message. Use for banners, listening URLs,
|
||||
* and any message the user expects to read in plain text.
|
||||
*/
|
||||
export function cliInfo(msg: string, fields?: Record<string, unknown>): void {
|
||||
writeStderr(msg);
|
||||
logger.info(fields ?? {}, msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* User-facing warning. Operator-actionable but non-fatal — `cliWarn`
|
||||
* indicates the command can still proceed in some form.
|
||||
*/
|
||||
export function cliWarn(msg: string, fields?: Record<string, unknown>): void {
|
||||
writeStderr(msg);
|
||||
logger.warn(fields ?? {}, msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* User-facing error. Indicates the command cannot proceed; usually
|
||||
* paired with a non-zero exit code at the call site.
|
||||
*/
|
||||
export function cliError(msg: string, fields?: Record<string, unknown>): void {
|
||||
writeStderr(msg);
|
||||
logger.error(fields ?? {}, msg);
|
||||
}
|
||||
@@ -27,6 +27,8 @@
|
||||
import http from 'http';
|
||||
import { writeSync } from 'node:fs';
|
||||
import { LocalBackend } from '../mcp/local/local-backend.js';
|
||||
import { logger } from '../core/logger.js';
|
||||
import { cliInfo, cliWarn } from './cli-message.js';
|
||||
|
||||
export interface EvalServerOptions {
|
||||
port?: string;
|
||||
@@ -332,13 +334,19 @@ export async function evalServerCommand(options?: EvalServerOptions): Promise<vo
|
||||
const ok = await backend.init();
|
||||
|
||||
if (!ok) {
|
||||
console.error('GitNexus eval-server: No indexed repositories found. Run: gitnexus analyze');
|
||||
// Operator-actionable but the server cannot start; warn-level so log
|
||||
// aggregators don't trip error alerts on a configuration miss. Use
|
||||
// cliWarn so the diagnostic reaches stderr synchronously before
|
||||
// process.exit() — direct logger.warn would be lost to the buffered
|
||||
// pino destination on hard exit (skips beforeExit flush).
|
||||
cliWarn('GitNexus eval-server: No indexed repositories found. Run: gitnexus analyze');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const repos = await backend.listRepos();
|
||||
console.error(
|
||||
`GitNexus eval-server: ${repos.length} repo(s) loaded: ${repos.map((r) => r.name).join(', ')}`,
|
||||
logger.info(
|
||||
{ repoCount: repos.length, repos: repos.map((r) => r.name) },
|
||||
'GitNexus eval-server: repos loaded',
|
||||
);
|
||||
|
||||
let idleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -347,7 +355,7 @@ export async function evalServerCommand(options?: EvalServerOptions): Promise<vo
|
||||
if (idleTimeoutSec <= 0) return;
|
||||
if (idleTimer) clearTimeout(idleTimer);
|
||||
idleTimer = setTimeout(async () => {
|
||||
console.error('GitNexus eval-server: Idle timeout reached, shutting down');
|
||||
logger.info({ idleTimeoutSec }, 'GitNexus eval-server: idle timeout reached, shutting down');
|
||||
await backend.disconnect();
|
||||
process.exit(0);
|
||||
}, idleTimeoutSec * 1000);
|
||||
@@ -419,16 +427,34 @@ export async function evalServerCommand(options?: EvalServerOptions): Promise<vo
|
||||
});
|
||||
|
||||
server.listen(port, '127.0.0.1', () => {
|
||||
console.error(`GitNexus eval-server: listening on http://127.0.0.1:${port}`);
|
||||
console.error(` POST /tool/query — search execution flows`);
|
||||
console.error(` POST /tool/context — 360-degree symbol view`);
|
||||
console.error(` POST /tool/impact — blast radius analysis`);
|
||||
console.error(` POST /tool/cypher — raw Cypher query`);
|
||||
console.error(` GET /health — health check`);
|
||||
console.error(` POST /shutdown — graceful shutdown`);
|
||||
// Plain-text banner for the human watching stderr; structured record
|
||||
// for log aggregation (split into two so the user sees a real banner
|
||||
// not `{"level":30,"msg":"...","port":4747,"endpoints":[...]}`).
|
||||
const bannerLines = [
|
||||
`GitNexus eval-server: listening on http://127.0.0.1:${port}`,
|
||||
` POST /tool/query — search execution flows`,
|
||||
` POST /tool/context — 360-degree symbol view`,
|
||||
` POST /tool/impact — blast radius analysis`,
|
||||
` POST /tool/cypher — raw Cypher query`,
|
||||
` GET /health — health check`,
|
||||
` POST /shutdown — graceful shutdown`,
|
||||
];
|
||||
if (idleTimeoutSec > 0) {
|
||||
console.error(` Auto-shutdown after ${idleTimeoutSec}s idle`);
|
||||
bannerLines.push(` Auto-shutdown after ${idleTimeoutSec}s idle`);
|
||||
}
|
||||
cliInfo(bannerLines.join('\n'), {
|
||||
port,
|
||||
host: '127.0.0.1',
|
||||
idleTimeoutSec: idleTimeoutSec > 0 ? idleTimeoutSec : undefined,
|
||||
endpoints: [
|
||||
'POST /tool/query',
|
||||
'POST /tool/context',
|
||||
'POST /tool/impact',
|
||||
'POST /tool/cypher',
|
||||
'GET /health',
|
||||
'POST /shutdown',
|
||||
],
|
||||
});
|
||||
try {
|
||||
// Use fd 1 directly — LadybugDB captures process.stdout (#324)
|
||||
writeSync(1, `GITNEXUS_EVAL_SERVER_READY:${port}\n`);
|
||||
@@ -440,7 +466,7 @@ export async function evalServerCommand(options?: EvalServerOptions): Promise<vo
|
||||
resetIdleTimer();
|
||||
|
||||
const shutdown = async () => {
|
||||
console.error('GitNexus eval-server: shutting down...');
|
||||
logger.info('GitNexus eval-server: shutting down...');
|
||||
await backend.disconnect();
|
||||
server.close();
|
||||
process.exit(0);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// gitnexus/src/cli/group.ts
|
||||
import { createRequire } from 'node:module';
|
||||
import type { Command } from 'commander';
|
||||
import { logger } from '../core/logger.js';
|
||||
|
||||
const _require = createRequire(import.meta.url);
|
||||
const yaml = _require('js-yaml') as typeof import('js-yaml');
|
||||
@@ -51,7 +52,7 @@ export function registerGroupCommands(program: Command): void {
|
||||
const groupDir = getGroupDir(getDefaultGitnexusDir(), groupName);
|
||||
const config = await loadGroupConfig(groupDir);
|
||||
if (!(repoPath in config.repos)) {
|
||||
console.error(`Repo path "${repoPath}" not found in group "${groupName}"`);
|
||||
logger.error(`Repo path "${repoPath}" not found in group "${groupName}"`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
@@ -239,7 +240,7 @@ export function registerGroupCommands(program: Command): void {
|
||||
|
||||
const raw = await backend.getGroupService().groupImpact(payload);
|
||||
if (raw && typeof raw === 'object' && 'error' in raw) {
|
||||
console.error(String((raw as { error: string }).error));
|
||||
logger.error(String((raw as { error: string }).error));
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
@@ -333,7 +334,7 @@ export function registerGroupCommands(program: Command): void {
|
||||
});
|
||||
|
||||
if (raw && typeof raw === 'object' && 'error' in raw) {
|
||||
console.error(String((raw as { error: string }).error));
|
||||
logger.error(String((raw as { error: string }).error));
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -23,15 +23,30 @@ program
|
||||
.command('analyze [path]')
|
||||
.description('Index a repository (full analysis)')
|
||||
.option('-f, --force', 'Force full re-index even if up to date')
|
||||
.option('--embeddings', 'Enable embedding generation for semantic search (off by default)')
|
||||
.option(
|
||||
'--embeddings [limit]',
|
||||
'Enable embedding generation for semantic search (off by default). ' +
|
||||
'Optional [limit] overrides the 50,000-node safety cap; pass 0 to disable the cap entirely.',
|
||||
)
|
||||
.option(
|
||||
'--drop-embeddings',
|
||||
'Drop existing embeddings on rebuild. By default, an `analyze` without `--embeddings` ' +
|
||||
'preserves any embeddings already present in the index.',
|
||||
)
|
||||
.option('--skills', 'Generate repo-specific skill files from detected communities')
|
||||
.option(
|
||||
'--skills',
|
||||
'Generate repo-specific skill files from detected communities ' +
|
||||
'(no-op when --index-only is also set).',
|
||||
)
|
||||
.option('--skip-agents-md', 'Skip updating the gitnexus section in AGENTS.md and CLAUDE.md')
|
||||
.option('--no-stats', 'Omit volatile file/symbol counts from AGENTS.md and CLAUDE.md')
|
||||
.option(
|
||||
'--skip-skills',
|
||||
'Skip installing standard GitNexus skill files under .claude/skills/gitnexus/. ' +
|
||||
'Does not suppress community skills from --skills (those use .claude/skills/generated/). ' +
|
||||
'Use --index-only to skip all AI-context file injection.',
|
||||
)
|
||||
.option('--index-only', 'Pure index mode: skip all file injection (AGENTS.md, CLAUDE.md, skills)')
|
||||
.option(
|
||||
'--skip-git',
|
||||
'Treat the provided path/cwd as the index root and skip parent git-root discovery',
|
||||
@@ -156,6 +171,18 @@ 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.
|
||||
|
||||
|
||||
+57
-16
@@ -4,23 +4,61 @@
|
||||
* Starts the MCP server in standalone mode.
|
||||
* Loads all indexed repos from the global registry.
|
||||
* No longer depends on cwd — works from any directory.
|
||||
*
|
||||
* IMPORTANT: this module's static-import closure is intentionally tiny
|
||||
* (one chain: `mcp/stdio-context.js` → `mcp/stdio-capture.js`, which is a
|
||||
* leaf with zero non-`node:` imports). All heavy backend modules
|
||||
* (`startMCPServer`, `LocalBackend`, `warnMissingOptionalGrammars`) load
|
||||
* via `await import(...)` AFTER `installGlobalStdoutSentinel()` runs.
|
||||
*
|
||||
* This closes the ESM-evaluation-order window where native init banners
|
||||
* from `@ladybugdb/core` (or any future heavy import) could reach raw
|
||||
* stdout before the sentinel exists. Codex's adversarial review on
|
||||
* PR #1383 found that even with the sentinel-install call as the first
|
||||
* statement of `mcpCommand`, ESM evaluates static imports of THIS module
|
||||
* before the function body runs — so any native side effects during
|
||||
* those imports happen before the sentinel can intercept them.
|
||||
*
|
||||
* If you find yourself adding a static `import` to this file, ask
|
||||
* whether the imported module (or anything it transitively imports)
|
||||
* touches `process.stdout` or loads a native binding at module init. If
|
||||
* either is true, switch it to a dynamic `await import(...)` inside
|
||||
* `mcpCommand` after the sentinel install. The regression test at
|
||||
* `gitnexus/test/integration/mcp/import-closure.test.ts` enforces this.
|
||||
*/
|
||||
|
||||
import { startMCPServer } from '../mcp/server.js';
|
||||
import { LocalBackend } from '../mcp/local/local-backend.js';
|
||||
import { installGlobalStdoutSentinel } from '../mcp/stdio-context.js';
|
||||
|
||||
export const mcpCommand = async () => {
|
||||
// Prevent unhandled errors from crashing the MCP server process.
|
||||
// LadybugDB lock conflicts and transient errors should degrade gracefully.
|
||||
process.on('uncaughtException', (err) => {
|
||||
console.error(`GitNexus MCP: uncaught exception — ${err.message}`);
|
||||
// Process is in an undefined state after uncaughtException — exit after flushing
|
||||
setTimeout(() => process.exit(1), 100);
|
||||
});
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
const msg = reason instanceof Error ? reason.message : String(reason);
|
||||
console.error(`GitNexus MCP: unhandled rejection — ${msg}`);
|
||||
});
|
||||
// Install the global stdout sentinel as the very first thing — before
|
||||
// ANY other module loads. The static-import closure above is leaf-only
|
||||
// (stdio-context → stdio-capture, zero non-`node:` deps), so this is
|
||||
// also the first chance any code in this process has to write to stdout.
|
||||
installGlobalStdoutSentinel();
|
||||
|
||||
// uncaughtException/unhandledRejection handlers are owned by
|
||||
// startMCPServer (gitnexus/src/mcp/server.ts) so the server's shutdown
|
||||
// path runs cleanly with full stack traces. Registering duplicates here
|
||||
// would only produce noisy double-logging on the same exception.
|
||||
|
||||
// Dynamically import heavy backend modules AND the pino logger AFTER
|
||||
// the sentinel installs. The logger is dynamic-imported (rather than
|
||||
// static) to preserve the leaf-only static-import closure documented at
|
||||
// the top of this file — `core/logger.js` itself doesn't write to
|
||||
// stdout at module init, but transitive deps (pino, pino-pretty, the
|
||||
// worker-thread transport) could in theory, and the import-closure
|
||||
// regression test enforces the leaf invariant.
|
||||
const [{ startMCPServer }, { LocalBackend }, { logger }] = await Promise.all([
|
||||
import('../mcp/server.js'),
|
||||
import('../mcp/local/local-backend.js'),
|
||||
import('../core/logger.js'),
|
||||
]);
|
||||
|
||||
// Missing-optional-grammar warnings are intentionally NOT emitted here.
|
||||
// `gitnexus analyze` already warns at index time, filtered by the repo's
|
||||
// actual extensions, and a repo can only be served by MCP after analyze
|
||||
// has run. Repeating an unconditional warning at every MCP startup is
|
||||
// pure noise for users whose indexed repos don't use Dart/Proto.
|
||||
|
||||
// Initialize multi-repo backend from registry.
|
||||
// The server starts even with 0 repos — tools call refreshRepos() lazily,
|
||||
@@ -30,12 +68,15 @@ export const mcpCommand = async () => {
|
||||
|
||||
const repos = await backend.listRepos();
|
||||
if (repos.length === 0) {
|
||||
console.error(
|
||||
// Operator-actionable but the server still starts and serves; warn-level,
|
||||
// not error. Tools will discover newly-analyzed repos via lazy refresh.
|
||||
logger.warn(
|
||||
'GitNexus: No indexed repos yet. Run `gitnexus analyze` in a git repo — the server will pick it up automatically.',
|
||||
);
|
||||
} else {
|
||||
console.error(
|
||||
`GitNexus: MCP server starting with ${repos.length} repo(s): ${repos.map((r) => r.name).join(', ')}`,
|
||||
logger.info(
|
||||
{ repoCount: repos.length, repos: repos.map((r) => r.name) },
|
||||
'GitNexus: MCP server starting',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Optional grammar availability check.
|
||||
*
|
||||
* tree-sitter-dart and tree-sitter-proto are optionalDependencies that
|
||||
* require a `node-gyp rebuild` at install time. The build can be skipped
|
||||
* via GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1 (postinstall scripts), or it can
|
||||
* silently soft-fail when the C++ toolchain is missing.
|
||||
*
|
||||
* Either path produces the same observable: the .node binding is absent
|
||||
* at runtime. This helper detects that condition and surfaces a single
|
||||
* stderr line per missing grammar so users learn why .dart/.proto support
|
||||
* is unavailable instead of silently getting a degraded index.
|
||||
*/
|
||||
|
||||
import { createRequire } from 'module';
|
||||
import { cliWarn } from './cli-message.js';
|
||||
|
||||
const _require = createRequire(import.meta.url);
|
||||
|
||||
interface OptionalGrammar {
|
||||
/** Display name in warnings */
|
||||
name: string;
|
||||
/** Module name to require.resolve */
|
||||
pkg: string;
|
||||
/** File extensions this grammar parses */
|
||||
extensions: string[];
|
||||
}
|
||||
|
||||
const OPTIONAL_GRAMMARS: OptionalGrammar[] = [
|
||||
{ name: 'tree-sitter-dart', pkg: 'tree-sitter-dart', extensions: ['.dart'] },
|
||||
{ name: 'tree-sitter-proto', pkg: 'tree-sitter-proto', extensions: ['.proto'] },
|
||||
];
|
||||
|
||||
export interface MissingGrammar {
|
||||
name: string;
|
||||
extensions: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the list of optional grammars whose native binding cannot be
|
||||
* loaded. Actually `require()`s the package — `require.resolve` would
|
||||
* locate the entry path even when the `.node` binding is absent (the
|
||||
* `file:` package directory is installed regardless of postinstall
|
||||
* outcome), giving false negatives for the exact users we want to warn:
|
||||
* those who installed with `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` or whose
|
||||
* native rebuild soft-failed for missing toolchain.
|
||||
*
|
||||
* Node's module cache memoizes `require()` for us — calling this multiple
|
||||
* times is cheap. The catch distinguishes "missing" (MODULE_NOT_FOUND or
|
||||
* the typical node-gyp-build "could not find any binding" pattern) from
|
||||
* "broken" (SyntaxError, EACCES, native crash). Broken bindings surface a
|
||||
* separate stderr line so users get an actionable message instead of a
|
||||
* misleading "reinstall" hint.
|
||||
*/
|
||||
export function detectMissingOptionalGrammars(): MissingGrammar[] {
|
||||
const missing: MissingGrammar[] = [];
|
||||
for (const g of OPTIONAL_GRAMMARS) {
|
||||
try {
|
||||
_require(g.pkg);
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException | undefined)?.code;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
const looksMissing =
|
||||
code === 'MODULE_NOT_FOUND' ||
|
||||
code === 'ERR_MODULE_NOT_FOUND' ||
|
||||
/could not find|no native build|prebuilds/i.test(msg);
|
||||
if (!looksMissing) {
|
||||
// Present but broken — surface so the user doesn't get a misleading
|
||||
// "reinstall" recovery message that wouldn't actually help. cliWarn
|
||||
// writes plain text to stderr AND tees a structured logger.warn
|
||||
// record; the merged repo-wide ESLint pino-migration rule forbids
|
||||
// direct `console.error` in CLI code (only `console.log` is allowed
|
||||
// there for tool-data stdout output).
|
||||
cliWarn(
|
||||
`GitNexus: optional grammar "${g.name}" is installed but failed to load (${msg.slice(0, 200)}). ${g.extensions.join('/')} files will not be parsed.`,
|
||||
{ grammar: g.name, extensions: g.extensions, error: msg },
|
||||
);
|
||||
}
|
||||
missing.push({ name: g.name, extensions: g.extensions });
|
||||
}
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a one-line stderr warning for each missing grammar. Safe to call
|
||||
* unconditionally — silent if all grammars are present.
|
||||
*
|
||||
* `relevantExtensions`, if provided, filters the warning to grammars whose
|
||||
* extensions appear in the set (e.g. an analyze run can pass the set of
|
||||
* extensions actually present in the target repo so users without any
|
||||
* .dart/.proto files don't see noise).
|
||||
*/
|
||||
export function warnMissingOptionalGrammars(opts?: {
|
||||
context?: string;
|
||||
relevantExtensions?: ReadonlySet<string>;
|
||||
}): void {
|
||||
const missing = detectMissingOptionalGrammars();
|
||||
if (missing.length === 0) return;
|
||||
const ctx = opts?.context ? ` [${opts.context}]` : '';
|
||||
// Hoist the optional set into a local so the closure below can narrow
|
||||
// its type; references to `opts?.relevantExtensions` inside `.some()`
|
||||
// lose the outer null-check narrowing and require a non-null assertion.
|
||||
const relevantExtensions = opts?.relevantExtensions;
|
||||
for (const g of missing) {
|
||||
if (relevantExtensions && !g.extensions.some((e) => relevantExtensions.has(e))) {
|
||||
continue;
|
||||
}
|
||||
cliWarn(
|
||||
`GitNexus${ctx}: optional grammar "${g.name}" is unavailable — ${g.extensions.join('/')} files will not be parsed. Reinstall without GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1 (and ensure python3, make, g++) to enable.`,
|
||||
{ grammar: g.name, extensions: g.extensions, context: opts?.context },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* `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;
|
||||
};
|
||||
@@ -27,6 +27,8 @@
|
||||
*/
|
||||
|
||||
import fs from 'fs/promises';
|
||||
import { logger } from '../core/logger.js';
|
||||
import { cliError } from './cli-message.js';
|
||||
import {
|
||||
readRegistry,
|
||||
resolveRegistryEntry,
|
||||
@@ -51,14 +53,14 @@ export const removeCommand = async (target: string, options?: { force?: boolean
|
||||
// Idempotent: missing target is a no-op warning, not an error.
|
||||
// The `availableNames` hint comes from the error itself so users
|
||||
// can see what they might have meant.
|
||||
console.warn(`Nothing to remove: ${err.message}`);
|
||||
logger.warn(`Nothing to remove: ${err.message}`);
|
||||
return;
|
||||
}
|
||||
if (err instanceof RegistryAmbiguousTargetError) {
|
||||
// Duplicate aliases are allowed via --allow-duplicate-name (#829);
|
||||
// refuse to guess which one the user meant — surface the full list
|
||||
// and exit non-zero so scripts don't silently pick the wrong repo.
|
||||
console.error(`Error: ${err.message}`);
|
||||
cliError(`Error: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
@@ -86,7 +88,7 @@ export const removeCommand = async (target: string, options?: { force?: boolean
|
||||
assertSafeStoragePath(entry);
|
||||
} catch (err) {
|
||||
if (err instanceof UnsafeStoragePathError) {
|
||||
console.error(`Error: ${err.message}`);
|
||||
cliError(`Error: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
@@ -104,7 +106,8 @@ export const removeCommand = async (target: string, options?: { force?: boolean
|
||||
console.log(` Path: ${entry.path}`);
|
||||
console.log(` Storage: ${entry.storagePath}`);
|
||||
} catch (err) {
|
||||
console.error(`Failed to remove ${entry.name}:`, err);
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
cliError(`Failed to remove ${entry.name}: ${msg}`, { err });
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
+34
-12
@@ -1,14 +1,26 @@
|
||||
import { createServer } from '../server/api.js';
|
||||
import { logger, flushLoggerSync } from '../core/logger.js';
|
||||
import { cliError } from './cli-message.js';
|
||||
|
||||
// Catch anything that would cause a silent exit
|
||||
// Catch anything that would cause a silent exit. Pino v10's default
|
||||
// destination is `sync: false` (SonicBoom buffered) — call
|
||||
// `flushLoggerSync()` between the log and `process.exit(1)` so the crash
|
||||
// record is not lost to the unflushed buffer. Worker-thread transports
|
||||
// (pino-pretty under TTY) handle their own flush on process exit in v10,
|
||||
// so no separate `pino.final` integration is needed (the API was removed
|
||||
// in v10 because the transport architecture made it unnecessary).
|
||||
//
|
||||
// We pass the Error itself in `{ err }` so pino's built-in err serializer
|
||||
// captures `type`, `message`, and `stack` as structured fields.
|
||||
process.on('uncaughtException', (err) => {
|
||||
console.error('\n[gitnexus serve] Uncaught exception:', err.message);
|
||||
if (process.env.DEBUG) console.error(err.stack);
|
||||
logger.error({ err }, '[gitnexus serve] Uncaught exception');
|
||||
flushLoggerSync();
|
||||
process.exit(1);
|
||||
});
|
||||
process.on('unhandledRejection', (reason: any) => {
|
||||
console.error('\n[gitnexus serve] Unhandled rejection:', reason?.message || reason);
|
||||
if (process.env.DEBUG) console.error(reason?.stack);
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
const err = reason instanceof Error ? reason : new Error(String(reason));
|
||||
logger.error({ err }, '[gitnexus serve] Unhandled rejection');
|
||||
flushLoggerSync();
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -22,16 +34,26 @@ export const serveCommand = async (options?: { port?: string; host?: string }) =
|
||||
try {
|
||||
await createServer(port, host);
|
||||
} catch (err: any) {
|
||||
console.error(`\nFailed to start GitNexus server:\n`);
|
||||
console.error(` ${err.message || err}\n`);
|
||||
if (err.code === 'EADDRINUSE') {
|
||||
console.error(` Port ${port} is already in use. Either:`);
|
||||
console.error(` 1. Stop the other process using port ${port}`);
|
||||
console.error(` 2. Use a different port: gitnexus serve --port 4748\n`);
|
||||
cliError(
|
||||
`\nFailed to start GitNexus server:\n` +
|
||||
` ${err.message || err}\n\n` +
|
||||
` Port ${port} is already in use. Either:\n` +
|
||||
` 1. Stop the other process using port ${port}\n` +
|
||||
` 2. Use a different port: gitnexus serve --port 4748\n`,
|
||||
{ code: err.code, port, host },
|
||||
);
|
||||
} else {
|
||||
cliError(`\nFailed to start GitNexus server:\n ${err.message || err}\n`, {
|
||||
code: err.code,
|
||||
port,
|
||||
host,
|
||||
});
|
||||
}
|
||||
if (err.stack && process.env.DEBUG) {
|
||||
console.error(err.stack);
|
||||
logger.debug({ stack: err.stack }, 'serve start error stack');
|
||||
}
|
||||
flushLoggerSync();
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { execFile, execFileSync } from 'child_process';
|
||||
import { createRequire } from 'module';
|
||||
import { promisify } from 'util';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { glob } from 'glob';
|
||||
@@ -20,6 +21,21 @@ const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
// Pin the npx fallback to the installed version. Reason: setup.ts writes
|
||||
// a config that persists in the user's editor and is invoked on every MCP
|
||||
// connect. Pinning to the installed version means subsequent invocations
|
||||
// skip the npm-registry metadata roundtrip (and stay reproducible until
|
||||
// the user upgrades). Static configs and READMEs intentionally use
|
||||
// `gitnexus@latest` since they're quickstart docs, not persisted state.
|
||||
const _require = createRequire(import.meta.url);
|
||||
const _pkg = _require('../../package.json') as { version?: unknown };
|
||||
if (typeof _pkg.version !== 'string' || !_pkg.version) {
|
||||
throw new Error(
|
||||
'gitnexus/package.json#version is missing or not a string — cannot generate MCP fallback config.',
|
||||
);
|
||||
}
|
||||
const NPX_REF = `gitnexus@${_pkg.version}`;
|
||||
|
||||
interface SetupResult {
|
||||
configured: string[];
|
||||
skipped: string[];
|
||||
@@ -62,8 +78,10 @@ function resolveGitnexusBin(): string | null {
|
||||
* The MCP server entry for all editors.
|
||||
*
|
||||
* Prefers the globally-installed `gitnexus` binary (starts in ~1 s) over
|
||||
* `npx -y gitnexus@latest` (cold-cache install of native deps can take
|
||||
* >60 s, exceeding Claude Code's 30 s MCP connection timeout).
|
||||
* `npx -y gitnexus@<version>` (cold-cache install of native deps can take
|
||||
* >60 s, exceeding Claude Code's 30 s MCP connection timeout). The fallback
|
||||
* version is read from gitnexus/package.json#version at module load so the
|
||||
* persisted user config matches the installed package.
|
||||
*
|
||||
* Falls back to npx when the binary isn't on PATH — e.g. first-time
|
||||
* users who ran `npx gitnexus analyze` but haven't done `npm i -g`.
|
||||
@@ -79,12 +97,12 @@ function getMcpEntry() {
|
||||
if (process.platform === 'win32') {
|
||||
return {
|
||||
command: 'cmd',
|
||||
args: ['/c', 'npx', '-y', 'gitnexus@latest', 'mcp'],
|
||||
args: ['/c', 'npx', '-y', NPX_REF, 'mcp'],
|
||||
};
|
||||
}
|
||||
return {
|
||||
command: 'npx',
|
||||
args: ['-y', 'gitnexus@latest', 'mcp'],
|
||||
args: ['-y', NPX_REF, 'mcp'],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -100,9 +118,9 @@ function getOpenCodeMcpEntry() {
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
return { type: 'local', command: ['cmd', '/c', 'npx', '-y', 'gitnexus@latest', 'mcp'] };
|
||||
return { type: 'local', command: ['cmd', '/c', 'npx', '-y', NPX_REF, 'mcp'] };
|
||||
}
|
||||
return { type: 'local', command: ['npx', '-y', 'gitnexus@latest', 'mcp'] };
|
||||
return { type: 'local', command: ['npx', '-y', NPX_REF, 'mcp'] };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -346,8 +364,22 @@ async function installClaudeCodeHooks(result: SetupResult): Promise<void> {
|
||||
// Script not found in source — skip
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.copyFile(
|
||||
path.join(pluginHooksPath, 'hook-lock.cjs'),
|
||||
path.join(destHooksDir, 'hook-lock.cjs'),
|
||||
);
|
||||
} catch {
|
||||
// Helper not found in source — skip
|
||||
}
|
||||
|
||||
const hookPath = path.join(destHooksDir, 'gitnexus-hook.cjs').replace(/\\/g, '/');
|
||||
const hookCmd = `node "${hookPath.replace(/"/g, '\\"')}"`;
|
||||
// Escape backslashes FIRST, then quotes (CodeQL js/incomplete-sanitization).
|
||||
// The previous shape `replace(/"/g, '\\"')` alone would let `path\with"quote`
|
||||
// become `path\with\"quote`, where the trailing `\` before `"` could
|
||||
// unescape the quote inside the surrounding double-quoted shell context.
|
||||
const escapedHookPath = hookPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
const hookCmd = `node "${escapedHookPath}"`;
|
||||
|
||||
// Check which hook events need entries (idempotent: skip if already registered)
|
||||
const parsed = await (async () => {
|
||||
@@ -604,7 +636,7 @@ async function installOpenCodeSkills(result: SetupResult): Promise<void> {
|
||||
const installed = await installSkillsTo(skillsDir);
|
||||
if (installed.length > 0) {
|
||||
result.configured.push(
|
||||
`OpenCode skills (${installed.length} skills → ~/.config/opencode/skill/)`,
|
||||
`OpenCode skills (${installed.length} skills → ~/.config/opencode/skills/)`,
|
||||
);
|
||||
}
|
||||
} catch (err: any) {
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
|
||||
import { writeSync } from 'node:fs';
|
||||
import { LocalBackend } from '../mcp/local/local-backend.js';
|
||||
import { cliError } from './cli-message.js';
|
||||
|
||||
let _backend: LocalBackend | null = null;
|
||||
|
||||
@@ -25,7 +26,7 @@ async function getBackend(): Promise<LocalBackend> {
|
||||
_backend = new LocalBackend();
|
||||
const ok = await _backend.init();
|
||||
if (!ok) {
|
||||
console.error('GitNexus: No indexed repositories found. Run: gitnexus analyze');
|
||||
cliError('GitNexus: No indexed repositories found. Run: gitnexus analyze');
|
||||
process.exit(1);
|
||||
}
|
||||
return _backend;
|
||||
@@ -67,7 +68,7 @@ export async function queryCommand(
|
||||
},
|
||||
): Promise<void> {
|
||||
if (!queryText?.trim()) {
|
||||
console.error('Usage: gitnexus query <search_query>');
|
||||
cliError('Usage: gitnexus query <search_query>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -93,7 +94,7 @@ export async function contextCommand(
|
||||
},
|
||||
): Promise<void> {
|
||||
if (!name?.trim() && !options?.uid) {
|
||||
console.error('Usage: gitnexus context <symbol_name> [--uid <uid>] [--file <path>]');
|
||||
cliError('Usage: gitnexus context <symbol_name> [--uid <uid>] [--file <path>]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -118,7 +119,7 @@ export async function impactCommand(
|
||||
},
|
||||
): Promise<void> {
|
||||
if (!target?.trim()) {
|
||||
console.error('Usage: gitnexus impact <symbol_name> [--direction upstream|downstream]');
|
||||
cliError('Usage: gitnexus impact <symbol_name> [--direction upstream|downstream]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -153,7 +154,7 @@ export async function cypherCommand(
|
||||
},
|
||||
): Promise<void> {
|
||||
if (!query?.trim()) {
|
||||
console.error('Usage: gitnexus cypher <cypher_query>');
|
||||
cliError('Usage: gitnexus cypher <cypher_query>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { WikiGenerator, type WikiOptions } from '../core/wiki/generator.js';
|
||||
import { resolveLLMConfig, type LLMProvider } from '../core/wiki/llm-client.js';
|
||||
import { detectCursorCLI } from '../core/wiki/cursor-client.js';
|
||||
import { logger } from '../core/logger.js';
|
||||
|
||||
export interface WikiCommandOptions {
|
||||
force?: boolean;
|
||||
@@ -583,7 +584,7 @@ export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptio
|
||||
} else {
|
||||
console.log(`\n Error: ${err.message}\n`);
|
||||
if (process.env.GITNEXUS_VERBOSE) {
|
||||
console.error(err);
|
||||
logger.error({ err }, 'wiki command failed');
|
||||
}
|
||||
}
|
||||
process.exitCode = 1;
|
||||
@@ -601,6 +602,38 @@ function hasGhCLI(): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strict Gist URL predicate. Rejects:
|
||||
* - any URL that does not parse (URL constructor throws)
|
||||
* - schemes other than https (drops `http:`, `file:`, `gist:`-style spoofs)
|
||||
* - hostnames that are not exactly `gist.github.com` (drops substring spoofs
|
||||
* like `https://evil.com/?u=gist.github.com` and userinfo-prefixed shapes
|
||||
* like `https://[email protected]/...` — note that URL.hostname
|
||||
* strips userinfo, so the equality check rejects the userinfo-prefixed
|
||||
* spoof if the actual host differs from gist.github.com)
|
||||
* - any URL containing userinfo (`username[:password]@`), which the URL
|
||||
* parser exposes via `.username` / `.password`. Defense-in-depth: even
|
||||
* when hostname matches, a credential-bearing URL is suspect and not
|
||||
* produced by `gh gist create`.
|
||||
*
|
||||
* Closes the substring-bypass class CodeQL `js/incomplete-url-substring-
|
||||
* sanitization` flags.
|
||||
*/
|
||||
function isGistUrl(line: string): boolean {
|
||||
const trimmed = line.trim();
|
||||
try {
|
||||
const u = new URL(trimmed);
|
||||
return (
|
||||
u.protocol === 'https:' &&
|
||||
u.hostname === 'gist.github.com' &&
|
||||
u.username === '' &&
|
||||
u.password === ''
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function publishGist(htmlPath: string): { url: string; rawUrl: string } | null {
|
||||
try {
|
||||
const output = execFileSync(
|
||||
@@ -609,13 +642,14 @@ function publishGist(htmlPath: string): { url: string; rawUrl: string } | null {
|
||||
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] },
|
||||
).trim();
|
||||
|
||||
// gh gist create prints the gist URL as the last line
|
||||
const lines = output.split('\n');
|
||||
const gistUrl = lines.find((l) => l.includes('gist.github.com')) || lines[lines.length - 1];
|
||||
// `gh gist create` prints the gist URL as a line in the output. Find the
|
||||
// first parseable Gist URL — if no line is a valid Gist URL, fail closed
|
||||
// (do NOT fall back to lines[last]: a non-Gist last line would propagate
|
||||
// through the regex below and produce a malformed `rawUrl`).
|
||||
const gistUrl = output.split('\n').find(isGistUrl);
|
||||
if (!gistUrl) return null;
|
||||
|
||||
if (!gistUrl || !gistUrl.includes('gist.github.com')) return null;
|
||||
|
||||
// Build a raw viewer URL via gist.githack.com
|
||||
// Build a raw viewer URL via gist.githack.com.
|
||||
// gist URL format: https://gist.github.com/{user}/{id}
|
||||
const match = gistUrl.match(/gist\.github\.com\/([^/]+)\/([a-f0-9]+)/);
|
||||
let rawUrl = gistUrl;
|
||||
|
||||
@@ -2,6 +2,7 @@ import ignore, { type Ignore } from 'ignore';
|
||||
import fs from 'fs/promises';
|
||||
import nodePath from 'path';
|
||||
import type { Path } from 'path-scurry';
|
||||
import { logger } from '../core/logger.js';
|
||||
|
||||
const DEFAULT_IGNORE_LIST = new Set([
|
||||
// Version Control
|
||||
@@ -24,6 +25,8 @@ const DEFAULT_IGNORE_LIST = new Set([
|
||||
'bower_components',
|
||||
'jspm_packages',
|
||||
'vendor', // PHP/Go
|
||||
'third_party', // C/C++ (Google-style vendored dependencies)
|
||||
'3rdparty', // C/C++ (alternate spelling, also Qt convention)
|
||||
// 'packages' removed - commonly used for monorepo source code (lerna, pnpm, yarn workspaces)
|
||||
'venv',
|
||||
'.venv',
|
||||
@@ -365,7 +368,7 @@ export const loadIgnoreRules = async (
|
||||
} catch (err: unknown) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code !== 'ENOENT') {
|
||||
console.warn(` Warning: could not read ${filename}: ${(err as Error).message}`);
|
||||
logger.warn(` Warning: could not read ${filename}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* Augmentation Engine
|
||||
*
|
||||
* Lightweight, fast-path enrichment of search patterns with knowledge graph context.
|
||||
* Designed to be called from platform hooks (Claude Code PreToolUse, Cursor beforeShellExecution)
|
||||
* when an agent runs grep/glob/search.
|
||||
* Designed to be called from platform hooks (Claude Code PreToolUse, Cursor postToolUse)
|
||||
* when an agent runs grep/glob/read/search.
|
||||
*
|
||||
* Performance target: <500ms cold start, <200ms warm.
|
||||
*
|
||||
@@ -86,6 +86,9 @@ async function findRepoForCwd(cwd: string): Promise<{
|
||||
export async function augment(pattern: string, cwd?: string): Promise<string> {
|
||||
if (!pattern || pattern.length < 3) return '';
|
||||
|
||||
const patternFirstWord = pattern.trim().replace(/'/g, "''").split(/\s+/)[0];
|
||||
if (!patternFirstWord || patternFirstWord.length < 2) return '';
|
||||
|
||||
const workDir = cwd || process.cwd();
|
||||
|
||||
try {
|
||||
@@ -104,9 +107,7 @@ export async function augment(pattern: string, cwd?: string): Promise<string> {
|
||||
}
|
||||
|
||||
// Step 1: BM25 search (fast, no embeddings)
|
||||
const bm25Results = await searchFTSFromLbug(pattern, 10, repoId);
|
||||
|
||||
if (bm25Results.length === 0) return '';
|
||||
const { results: bm25Results, ftsAvailable } = await searchFTSFromLbug(pattern, 10, repoId);
|
||||
|
||||
// Step 2: Map BM25 file results to symbols
|
||||
const symbolMatches: Array<{
|
||||
@@ -124,7 +125,7 @@ export async function augment(pattern: string, cwd?: string): Promise<string> {
|
||||
repoId,
|
||||
`
|
||||
MATCH (n) WHERE n.filePath = '${escaped}'
|
||||
AND n.name CONTAINS '${pattern.replace(/'/g, "''").split(/\s+/)[0]}'
|
||||
AND n.name CONTAINS '${patternFirstWord}'
|
||||
RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath
|
||||
LIMIT 3
|
||||
`,
|
||||
@@ -143,6 +144,29 @@ export async function augment(pattern: string, cwd?: string): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
// When FTS indexes are unavailable (read-only DB, first run before indexes are built),
|
||||
// fall back to a direct name CONTAINS query so enrichment still works.
|
||||
if (symbolMatches.length === 0 && !ftsAvailable) {
|
||||
const fallbackRows = await executeQuery(
|
||||
repoId,
|
||||
`
|
||||
MATCH (n)
|
||||
WHERE n.name CONTAINS '${patternFirstWord}'
|
||||
RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath
|
||||
LIMIT 5
|
||||
`,
|
||||
).catch(() => []);
|
||||
for (const sym of fallbackRows) {
|
||||
symbolMatches.push({
|
||||
nodeId: sym.id || sym[0],
|
||||
name: sym.name || sym[1],
|
||||
type: sym.type || sym[2],
|
||||
filePath: sym.filePath || sym[3],
|
||||
score: 1.0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (symbolMatches.length === 0) return '';
|
||||
|
||||
// Step 3: Batch-fetch callers/callees/processes/cohesion for top matches
|
||||
|
||||
@@ -30,6 +30,38 @@ export interface EmbeddingMode {
|
||||
shouldLoadCache: boolean;
|
||||
}
|
||||
|
||||
/** Default safety cap on graph node count for embedding generation. */
|
||||
export const DEFAULT_EMBEDDING_NODE_LIMIT = 50_000;
|
||||
|
||||
export interface EmbeddingCapDecision {
|
||||
/** True when the node-count cap blocks generation for this graph. */
|
||||
skipForCap: boolean;
|
||||
/** True when the user explicitly disabled the cap (`--embeddings 0`). */
|
||||
capDisabled: boolean;
|
||||
/** Effective node limit applied (`0` means disabled). */
|
||||
nodeLimit: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether the node-count safety cap blocks embedding generation.
|
||||
*
|
||||
* - `embeddingsNodeLimit === undefined` → use {@link DEFAULT_EMBEDDING_NODE_LIMIT}
|
||||
* - `embeddingsNodeLimit === 0` → cap disabled, generation always proceeds
|
||||
* - any positive integer → custom cap (skip if `nodeCount > limit`)
|
||||
*
|
||||
* Lives in `embedding-mode.ts` (not `run-analyze.ts`) so the branching
|
||||
* contract is unit-testable without spinning up LadybugDB or the pipeline.
|
||||
*/
|
||||
export function deriveEmbeddingCap(
|
||||
nodeCount: number,
|
||||
embeddingsNodeLimit: number | undefined,
|
||||
): EmbeddingCapDecision {
|
||||
const nodeLimit = embeddingsNodeLimit ?? DEFAULT_EMBEDDING_NODE_LIMIT;
|
||||
const capDisabled = nodeLimit === 0;
|
||||
const skipForCap = !capDisabled && nodeCount > nodeLimit;
|
||||
return { skipForCap, capDisabled, nodeLimit };
|
||||
}
|
||||
|
||||
export function deriveEmbeddingMode(
|
||||
options: EmbeddingModeInput,
|
||||
existingEmbeddingCount: number,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
isLanguageAvailable,
|
||||
resolveLanguageKey,
|
||||
} from '../tree-sitter/parser-loader.js';
|
||||
import { parseSourceSafe } from '../tree-sitter/safe-parse.js';
|
||||
|
||||
const parserCache = new Map<string, any>();
|
||||
|
||||
@@ -29,7 +30,7 @@ export const ensureAndParse = async (content: string, filePath: string): Promise
|
||||
parserCache.set(parserKey, parserInstance);
|
||||
}
|
||||
|
||||
return parserInstance.parse(content);
|
||||
return parseSourceSafe(parserInstance, content);
|
||||
};
|
||||
|
||||
const FUNCTION_LIKE_TYPES = new Set([
|
||||
|
||||
@@ -14,7 +14,12 @@ if (!process.env.ORT_LOG_LEVEL) {
|
||||
process.env.ORT_LOG_LEVEL = '3';
|
||||
}
|
||||
|
||||
import { pipeline, env, type FeatureExtractionPipeline } from '@huggingface/transformers';
|
||||
import {
|
||||
pipeline,
|
||||
env,
|
||||
type FeatureExtractionPipeline,
|
||||
type ProgressInfo,
|
||||
} from '@huggingface/transformers';
|
||||
import { existsSync } from 'fs';
|
||||
import { execFileSync } from 'child_process';
|
||||
import { join, dirname } from 'path';
|
||||
@@ -22,7 +27,8 @@ import { createRequire } from 'module';
|
||||
import { DEFAULT_EMBEDDING_CONFIG, type EmbeddingConfig, type ModelProgress } from './types.js';
|
||||
import { isHttpMode, getHttpDimensions, httpEmbed } from './http-client.js';
|
||||
import { resolveEmbeddingConfig } from './config.js';
|
||||
import { applyHfEnvOverrides } from './hf-env.js';
|
||||
import { applyHfEnvOverrides, isHfDownloadFailure, withHfDownloadRetry } from './hf-env.js';
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
/**
|
||||
* Check whether the onnxruntime-node package that @huggingface/transformers
|
||||
@@ -166,17 +172,22 @@ export const initEmbedder = async (
|
||||
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
if (isDev) {
|
||||
console.log(`🧠 Loading embedding model: ${finalConfig.modelId}`);
|
||||
logger.info(`🧠 Loading embedding model: ${finalConfig.modelId}`);
|
||||
}
|
||||
|
||||
const progressCallback = onProgress
|
||||
? (data: any) => {
|
||||
? (data: ProgressInfo) => {
|
||||
const progress: ModelProgress = {
|
||||
status: data.status || 'progress',
|
||||
file: data.file,
|
||||
progress: data.progress,
|
||||
loaded: data.loaded,
|
||||
total: data.total,
|
||||
// Map the `progress_total` aggregate event (not in ModelProgress.status)
|
||||
// back to 'progress' so callers don't need to handle it separately.
|
||||
status:
|
||||
data.status === 'progress_total'
|
||||
? 'progress'
|
||||
: ((data.status as ModelProgress['status']) ?? 'progress'),
|
||||
file: 'file' in data ? data.file : undefined,
|
||||
progress: 'progress' in data ? data.progress : undefined,
|
||||
loaded: 'loaded' in data ? data.loaded : undefined,
|
||||
total: 'total' in data ? data.total : undefined,
|
||||
};
|
||||
onProgress(progress);
|
||||
}
|
||||
@@ -192,26 +203,38 @@ export const initEmbedder = async (
|
||||
for (const device of devicesToTry) {
|
||||
try {
|
||||
if (isDev && device === 'dml') {
|
||||
console.log('🔧 Trying DirectML (DirectX12) GPU backend...');
|
||||
logger.info('🔧 Trying DirectML (DirectX12) GPU backend...');
|
||||
} else if (isDev && device === 'cuda') {
|
||||
console.log('🔧 Trying CUDA GPU backend...');
|
||||
logger.info('🔧 Trying CUDA GPU backend...');
|
||||
} else if (isDev && device === 'cpu') {
|
||||
console.log('🔧 Using CPU backend...');
|
||||
logger.info('🔧 Using CPU backend...');
|
||||
} else if (isDev && device === 'wasm') {
|
||||
console.log('🔧 Using WASM backend (slower)...');
|
||||
logger.info('🔧 Using WASM backend (slower)...');
|
||||
}
|
||||
|
||||
embedderInstance = await (pipeline as any)('feature-extraction', finalConfig.modelId, {
|
||||
device: device,
|
||||
dtype: 'fp32',
|
||||
progress_callback: progressCallback,
|
||||
session_options: {
|
||||
logSeverityLevel: 3,
|
||||
intraOpNumThreads: finalConfig.threads,
|
||||
interOpNumThreads: 1,
|
||||
executionMode: 'sequential',
|
||||
embedderInstance = await withHfDownloadRetry(
|
||||
() =>
|
||||
pipeline('feature-extraction', finalConfig.modelId, {
|
||||
device: device,
|
||||
dtype: 'fp32',
|
||||
progress_callback: progressCallback,
|
||||
session_options: {
|
||||
logSeverityLevel: 3,
|
||||
intraOpNumThreads: finalConfig.threads,
|
||||
interOpNumThreads: 1,
|
||||
executionMode: 'sequential',
|
||||
},
|
||||
}),
|
||||
{
|
||||
onRetry: isDev
|
||||
? (attempt, max, err) =>
|
||||
logger.warn(
|
||||
{ attempt, max, err: err.message },
|
||||
`⚠️ Model download network error (attempt ${attempt}/${max}), retrying…`,
|
||||
)
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
);
|
||||
currentDevice = device;
|
||||
|
||||
if (isDev) {
|
||||
@@ -221,15 +244,29 @@ export const initEmbedder = async (
|
||||
: device === 'cuda'
|
||||
? 'GPU (CUDA)'
|
||||
: device.toUpperCase();
|
||||
console.log(`✅ Using ${label} backend`);
|
||||
console.log('✅ Embedding model loaded successfully');
|
||||
logger.info(`✅ Using ${label} backend`);
|
||||
logger.info('✅ Embedding model loaded successfully');
|
||||
}
|
||||
|
||||
return embedderInstance!;
|
||||
} catch (deviceError) {
|
||||
// Network errors and circuit-open errors are not device-specific —
|
||||
// they will fail the same way on every device. Rethrow immediately
|
||||
// with actionable HF_ENDPOINT guidance rather than silently falling
|
||||
// back to the next device.
|
||||
const errMsg = deviceError instanceof Error ? deviceError.message : String(deviceError);
|
||||
if (isHfDownloadFailure(errMsg)) {
|
||||
const endpointHint = process.env.HF_ENDPOINT
|
||||
? `The configured endpoint (${process.env.HF_ENDPOINT}) may be unreachable.`
|
||||
: `huggingface.co may be unreachable from your network.\n` +
|
||||
` Set HF_ENDPOINT to a mirror and retry:\n` +
|
||||
` HF_ENDPOINT=https://hf-mirror.com npx gitnexus analyze --embeddings\n` +
|
||||
` (Windows: set HF_ENDPOINT=https://hf-mirror.com && npx gitnexus analyze --embeddings)`;
|
||||
throw new Error(`Failed to download embedding model: ${errMsg}\n ${endpointHint}`);
|
||||
}
|
||||
if (isDev && (device === 'cuda' || device === 'dml')) {
|
||||
const gpuType = device === 'dml' ? 'DirectML' : 'CUDA';
|
||||
console.log(`⚠️ ${gpuType} not available, falling back to CPU...`);
|
||||
logger.info(`⚠️ ${gpuType} not available, falling back to CPU...`);
|
||||
}
|
||||
// Continue to next device in list
|
||||
if (device === devicesToTry[devicesToTry.length - 1]) {
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
} from '../lbug/schema.js';
|
||||
import { loadVectorExtension } from '../lbug/lbug-adapter.js';
|
||||
import { getExactScanLimit } from '../platform/capabilities.js';
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
|
||||
@@ -157,7 +158,7 @@ const queryEmbeddableNodes = async (
|
||||
}
|
||||
} catch (error) {
|
||||
if (isDev) {
|
||||
console.warn(`Query for ${label} nodes failed:`, error);
|
||||
logger.warn({ error }, `Query for ${label} nodes failed:`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -212,7 +213,7 @@ const createVectorIndex = async (
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isDev) {
|
||||
console.warn('Vector index creation warning:', error);
|
||||
logger.warn({ error }, 'Vector index creation warning:');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -256,7 +257,9 @@ export const runEmbeddingPipeline = async (
|
||||
|
||||
try {
|
||||
const vectorAvailable = await ensureVectorExtensionAvailable();
|
||||
if (!vectorAvailable && isDev) console.warn(vectorUnavailableMessage);
|
||||
if (!vectorAvailable && isDev) {
|
||||
logger.warn(vectorUnavailableMessage);
|
||||
}
|
||||
|
||||
// Phase 1: Load embedding model
|
||||
onProgress({
|
||||
@@ -283,7 +286,7 @@ export const runEmbeddingPipeline = async (
|
||||
});
|
||||
|
||||
if (isDev) {
|
||||
console.log('🔍 Querying embeddable nodes...');
|
||||
logger.info('🔍 Querying embeddable nodes...');
|
||||
}
|
||||
|
||||
// Phase 2: Query embeddable nodes
|
||||
@@ -325,7 +328,7 @@ export const runEmbeddingPipeline = async (
|
||||
// (Kuzu forbids SET on vector-indexed properties; DELETE-then-INSERT is the sanctioned pattern)
|
||||
if (staleNodeIds.length > 0) {
|
||||
if (isDev) {
|
||||
console.log(`🔄 Deleting ${staleNodeIds.length} stale embedding rows for re-embed`);
|
||||
logger.info(`🔄 Deleting ${staleNodeIds.length} stale embedding rows for re-embed`);
|
||||
}
|
||||
try {
|
||||
await executeWithReusedStatement(
|
||||
@@ -346,7 +349,7 @@ export const runEmbeddingPipeline = async (
|
||||
}
|
||||
|
||||
if (isDev) {
|
||||
console.log(
|
||||
logger.info(
|
||||
`📦 Incremental embeddings: ${beforeCount} total, ${existingEmbeddings.size} cached, ${staleNodeIds.length} stale, ${nodes.length} to embed`,
|
||||
);
|
||||
}
|
||||
@@ -355,7 +358,7 @@ export const runEmbeddingPipeline = async (
|
||||
const totalNodes = nodes.length;
|
||||
|
||||
if (isDev) {
|
||||
console.log(`📊 Found ${totalNodes} embeddable nodes`);
|
||||
logger.info(`📊 Found ${totalNodes} embeddable nodes`);
|
||||
}
|
||||
|
||||
if (totalNodes === 0) {
|
||||
@@ -442,9 +445,9 @@ export const runEmbeddingPipeline = async (
|
||||
);
|
||||
} catch (chunkErr) {
|
||||
if (isDev) {
|
||||
console.warn(
|
||||
logger.warn(
|
||||
{ chunkErr },
|
||||
`⚠️ AST chunking failed for ${node.label} "${node.name}" (${node.filePath}), falling back to character-based chunking:`,
|
||||
chunkErr,
|
||||
);
|
||||
}
|
||||
chunks = characterChunk(node.content, startLine, endLine, chunkSize, overlap);
|
||||
@@ -482,9 +485,9 @@ export const runEmbeddingPipeline = async (
|
||||
try {
|
||||
embeddings = await embedBatch(subTexts);
|
||||
} catch (embedErr) {
|
||||
console.error(
|
||||
logger.error(
|
||||
{ embedErr },
|
||||
`❌ embedBatch failed for ${subTexts.length} texts (first: "${subTexts[0]?.substring(0, 80)}..."):`,
|
||||
embedErr,
|
||||
);
|
||||
throw embedErr;
|
||||
}
|
||||
@@ -520,7 +523,7 @@ export const runEmbeddingPipeline = async (
|
||||
});
|
||||
|
||||
if (isDev) {
|
||||
console.log('📇 Creating vector index...');
|
||||
logger.info('📇 Creating vector index...');
|
||||
}
|
||||
|
||||
const vectorIndexReady = await createVectorIndex(executeQuery);
|
||||
@@ -533,7 +536,7 @@ export const runEmbeddingPipeline = async (
|
||||
});
|
||||
|
||||
if (isDev) {
|
||||
console.log(
|
||||
logger.info(
|
||||
`✅ Embedding pipeline complete! (${totalChunks} chunks from ${totalNodes} nodes)`,
|
||||
);
|
||||
}
|
||||
@@ -547,7 +550,7 @@ export const runEmbeddingPipeline = async (
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
if (isDev) {
|
||||
console.error('❌ Embedding pipeline error:', error);
|
||||
logger.error({ error }, '❌ Embedding pipeline error:');
|
||||
}
|
||||
|
||||
onProgress({
|
||||
|
||||
@@ -1,6 +1,27 @@
|
||||
import os from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { CircuitBreaker, withRetry } from 'gitnexus-shared';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Download resilience defaults
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Per-attempt timeout for the full model download (5 minutes). */
|
||||
export const HF_DOWNLOAD_TIMEOUT_MS = 5 * 60 * 1_000;
|
||||
/** Maximum total download attempts (1 initial + N-1 retries). */
|
||||
export const HF_MAX_ATTEMPTS = 3;
|
||||
/** Initial delay between retry attempts; doubles on each subsequent retry. */
|
||||
export const HF_BASE_DELAY_MS = 2_000;
|
||||
/** Number of consecutive failures required to open the circuit. */
|
||||
export const CB_FAILURE_THRESHOLD = 3;
|
||||
/** How long the circuit stays open before transitioning to half-open. */
|
||||
export const CB_RESET_TIMEOUT_MS = 60_000;
|
||||
/** Upper bound clamped on the env-override per-attempt timeout (30 minutes). */
|
||||
export const HF_MAX_TIMEOUT_MS = 30 * 60 * 1_000;
|
||||
/** Upper bound clamped on the env-override attempt count. */
|
||||
export const HF_MAX_ATTEMPTS_CAP = 10;
|
||||
|
||||
/**
|
||||
* @internal Exported only for unit tests and the two embedder entry points
|
||||
* (`core/embeddings/embedder.ts` + `mcp/core/embedder.ts`). Not part of the
|
||||
@@ -60,3 +81,234 @@ export function applyHfEnvOverrides(env: HfEnvSubset): void {
|
||||
env.remoteHost = endpoint.endsWith('/') ? endpoint : endpoint + '/';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Exported for unit tests and the two embedder entry points.
|
||||
*
|
||||
* Returns true when an error message indicates a network-level fetch failure
|
||||
* during HuggingFace model download (e.g. `TypeError: fetch failed`,
|
||||
* `ECONNREFUSED`, `ENOTFOUND`, `ETIMEDOUT`, `ECONNRESET`).
|
||||
*
|
||||
* These errors are not device-specific and cannot be fixed by falling back to
|
||||
* a different ONNX device — the caller should rethrow immediately with
|
||||
* guidance about `HF_ENDPOINT`.
|
||||
*/
|
||||
export function isNetworkFetchError(message: string): boolean {
|
||||
return (
|
||||
message.includes('fetch failed') ||
|
||||
message.includes('ECONNREFUSED') ||
|
||||
message.includes('ENOTFOUND') ||
|
||||
message.includes('ETIMEDOUT') ||
|
||||
message.includes('ECONNRESET')
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Circuit breaker
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** @internal Used by `withHfDownloadRetry` to mark a circuit-open rejection. */
|
||||
export const CIRCUIT_OPEN_TAG = 'hf-circuit-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).
|
||||
*/
|
||||
export const hfDownloadCircuit = new CircuitBreaker({
|
||||
failureThreshold: CB_FAILURE_THRESHOLD,
|
||||
cooldownMs: CB_RESET_TIMEOUT_MS,
|
||||
key: 'hf-download',
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Retry + timeout wrapper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** @internal Returns true for errors that should abort without retry (circuit-open). */
|
||||
export function isHfCircuitOpenError(message: string): boolean {
|
||||
return message.includes(CIRCUIT_OPEN_TAG);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true for any HuggingFace download failure that warrants showing the
|
||||
* `HF_ENDPOINT` remediation hint: either a raw network error or a
|
||||
* circuit-open rejection (which itself was caused by repeated network errors).
|
||||
*/
|
||||
export function isHfDownloadFailure(message: string): boolean {
|
||||
return isNetworkFetchError(message) || isHfCircuitOpenError(message);
|
||||
}
|
||||
|
||||
/** @internal Wraps `fn` in a hard time-limit. The timeout error contains
|
||||
* `ETIMEDOUT` so that `isNetworkFetchError` classifies it correctly.
|
||||
*/
|
||||
export function withDownloadTimeout<T>(fn: () => Promise<T>, timeoutMs: number): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(
|
||||
() =>
|
||||
reject(
|
||||
new Error(
|
||||
`ETIMEDOUT: model download timed out after ${Math.round(timeoutMs / 1000)}s — ` +
|
||||
`check your network speed or set HF_ENDPOINT to a faster mirror`,
|
||||
),
|
||||
),
|
||||
timeoutMs,
|
||||
);
|
||||
fn().then(
|
||||
(v) => {
|
||||
clearTimeout(timer);
|
||||
resolve(v);
|
||||
},
|
||||
(e) => {
|
||||
clearTimeout(timer);
|
||||
reject(e);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export interface HfRetryOptions {
|
||||
/** Maximum total attempts including the initial one (default: `HF_MAX_ATTEMPTS`). */
|
||||
maxAttempts?: number;
|
||||
/** Delay before the first retry; doubles on each subsequent attempt (default: `HF_BASE_DELAY_MS`). */
|
||||
baseDelayMs?: number;
|
||||
/** Per-attempt wall-clock timeout in ms (default: `HF_DOWNLOAD_TIMEOUT_MS`). */
|
||||
timeoutMs?: number;
|
||||
/**
|
||||
* Circuit-breaker instance to use. Defaults to the module-level
|
||||
* `hfDownloadCircuit` singleton. Pass a fresh instance in tests.
|
||||
*/
|
||||
circuit?: CircuitBreaker;
|
||||
/**
|
||||
* Optional callback invoked before each retry (not the initial attempt).
|
||||
* @param attempt - 1-based retry number
|
||||
* @param max - total allowed attempts
|
||||
* @param error - the error that triggered the retry
|
||||
*/
|
||||
onRetry?: (attempt: number, max: number, error: Error) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry wrapper for HuggingFace model downloads with per-attempt timeout and
|
||||
* circuit-breaker protection.
|
||||
*
|
||||
* Behaviour:
|
||||
* - If the circuit is **open**, fails immediately with a `CIRCUIT_OPEN_TAG`
|
||||
* message (so `isHfDownloadFailure` still returns true and the caller can
|
||||
* show `HF_ENDPOINT` guidance).
|
||||
* - Each attempt is wrapped in `withDownloadTimeout`.
|
||||
* - On a network-level error (`isNetworkFetchError`) the attempt is retried
|
||||
* with exponential back-off; non-network errors (e.g. ONNX device failure)
|
||||
* are rethrown immediately without retry.
|
||||
* - Every network failure is recorded on the circuit breaker; a success resets
|
||||
* it.
|
||||
* - After all attempts are exhausted, the last network error is rethrown
|
||||
* so the existing `isNetworkFetchError` / `isHfDownloadFailure` guards in
|
||||
* the calling code still fire.
|
||||
*/
|
||||
export async function withHfDownloadRetry<T>(
|
||||
fn: () => Promise<T>,
|
||||
options: HfRetryOptions = {},
|
||||
): Promise<T> {
|
||||
// Resolve effective values — explicit options take precedence over env vars,
|
||||
// which take precedence over built-in defaults. This lets users lower the
|
||||
// per-attempt timeout without rebuilding (e.g.
|
||||
// HF_DOWNLOAD_TIMEOUT_MS=60000 npx gitnexus analyze --embeddings
|
||||
// reduces the worst-case wait from 15 minutes to ~3 minutes).
|
||||
//
|
||||
// Upper bounds are clamped to prevent accidental runaway configuration:
|
||||
// - timeoutMs is capped at HF_MAX_TIMEOUT_MS (30 min)
|
||||
// - maxAttempts is floored (fractional values → integer) and capped at
|
||||
// HF_MAX_ATTEMPTS_CAP (10). Values ≤ 0, NaN, or Infinity fall back to
|
||||
// the built-in defaults.
|
||||
const envTimeout = Number(process.env.HF_DOWNLOAD_TIMEOUT_MS);
|
||||
const envMaxAttempts = Number(process.env.HF_MAX_ATTEMPTS);
|
||||
const resolvedTimeout =
|
||||
Number.isFinite(envTimeout) && envTimeout > 0
|
||||
? Math.min(envTimeout, HF_MAX_TIMEOUT_MS)
|
||||
: HF_DOWNLOAD_TIMEOUT_MS;
|
||||
const resolvedMaxAttempts =
|
||||
Number.isFinite(envMaxAttempts) && envMaxAttempts > 0
|
||||
? Math.min(Math.floor(envMaxAttempts), HF_MAX_ATTEMPTS_CAP)
|
||||
: HF_MAX_ATTEMPTS;
|
||||
const {
|
||||
maxAttempts = resolvedMaxAttempts,
|
||||
baseDelayMs = HF_BASE_DELAY_MS,
|
||||
timeoutMs = resolvedTimeout,
|
||||
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;
|
||||
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;
|
||||
|
||||
try {
|
||||
return await withRetry(
|
||||
async () => {
|
||||
const result = await withDownloadTimeout(fn, timeoutMs);
|
||||
circuit.recordSuccess();
|
||||
return result;
|
||||
},
|
||||
{
|
||||
maxAttempts,
|
||||
baseDelayMs,
|
||||
// Disable the cap to match the bespoke pure-exponential
|
||||
// progression. With the default `HF_MAX_ATTEMPTS_CAP = 10` and
|
||||
// `baseDelayMs = 2000`, the largest possible delay is
|
||||
// `2000 * 2^9 = ~17 minutes` — bounded enough not to need a cap.
|
||||
capDelayMs: Number.MAX_SAFE_INTEGER,
|
||||
isRetryable: (err, attempt) => {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
if (!isNetworkFetchError(error.message)) {
|
||||
// Non-network error (e.g. CUDA unavailable) — propagate
|
||||
// without retry. Use recordNeutral so the breaker's existing
|
||||
// failure-count progress isn't reset by a non-network failure
|
||||
// that says nothing about the CDN's health.
|
||||
circuit.recordNeutral();
|
||||
return { retry: false };
|
||||
}
|
||||
circuit.recordFailure();
|
||||
if (circuit.getState() === 'open') {
|
||||
// Circuit just tripped — fail fast, no more retries.
|
||||
circuitTripped = true;
|
||||
return { retry: false };
|
||||
}
|
||||
// Mirror the bespoke onRetry contract: fire only when there's
|
||||
// actually a next attempt.
|
||||
if (attempt + 1 < maxAttempts) {
|
||||
onRetry?.(attempt + 1, maxAttempts, error);
|
||||
}
|
||||
return { retry: true };
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
if (circuitTripped) {
|
||||
throw new Error(
|
||||
`${CIRCUIT_OPEN_TAG}: HuggingFace download circuit opened after ${CB_FAILURE_THRESHOLD} consecutive failures`,
|
||||
);
|
||||
}
|
||||
// All retries exhausted — rethrow the last network error so
|
||||
// isNetworkFetchError patterns in the calling code still match and
|
||||
// surface HF_ENDPOINT guidance.
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,13 +3,22 @@
|
||||
*
|
||||
* Shared fetch+retry logic for OpenAI-compatible /v1/embeddings endpoints.
|
||||
* Imported by both the core embedder (batch) and MCP embedder (query).
|
||||
*
|
||||
* Network resilience is delegated to `resilientFetch` from
|
||||
* `gitnexus-shared` — bounded retries with exponential-backoff jitter,
|
||||
* `Retry-After` honored on 429, and an in-process circuit breaker that
|
||||
* fails fast on a flapping endpoint. Per-attempt timeout is enforced
|
||||
* via `AbortSignal.timeout` on the underlying fetch.
|
||||
*/
|
||||
|
||||
import { CircuitOpenError, ResilientFetchExhaustedError, resilientFetch } from 'gitnexus-shared';
|
||||
|
||||
const HTTP_TIMEOUT_MS = 30_000;
|
||||
const HTTP_MAX_RETRIES = 2;
|
||||
const HTTP_RETRY_BACKOFF_MS = 1_000;
|
||||
const HTTP_BATCH_SIZE = 64;
|
||||
const DEFAULT_DIMS = 384;
|
||||
const HTTP_BREAKER_KEY = 'embeddings-http';
|
||||
|
||||
interface HttpConfig {
|
||||
baseUrl: string;
|
||||
@@ -31,8 +40,11 @@ const readConfig = (): HttpConfig | null => {
|
||||
const rawDims = process.env.GITNEXUS_EMBEDDING_DIMS;
|
||||
let dimensions: number | undefined;
|
||||
if (rawDims !== undefined) {
|
||||
if (!/^\d+$/.test(rawDims)) {
|
||||
throw new Error(`GITNEXUS_EMBEDDING_DIMS must be a positive integer, got "${rawDims}"`);
|
||||
}
|
||||
const parsed = parseInt(rawDims, 10);
|
||||
if (Number.isNaN(parsed) || parsed <= 0) {
|
||||
if (parsed <= 0) {
|
||||
throw new Error(`GITNEXUS_EMBEDDING_DIMS must be a positive integer, got "${rawDims}"`);
|
||||
}
|
||||
dimensions = parsed;
|
||||
@@ -82,7 +94,13 @@ interface EmbeddingItem {
|
||||
* @param model - Model name for the request body
|
||||
* @param apiKey - Bearer token (only used in Authorization header)
|
||||
* @param batchIndex - Logical batch number (for error context)
|
||||
* @param attempt - Current retry attempt (internal)
|
||||
* @param dimensions - Optional output-vector size. When provided, sent as
|
||||
* the `dimensions` field in the request body. Endpoints that implement
|
||||
* Matryoshka truncation (OpenAI text-embedding-3-*, Cohere embed-v3,
|
||||
* Voyage) return a truncated vector at that size; endpoints that do not
|
||||
* recognise the field may ignore it or return 400. Leave
|
||||
* `GITNEXUS_EMBEDDING_DIMS` unset for strict backends that reject
|
||||
* unknown fields.
|
||||
*/
|
||||
const httpEmbedBatch = async (
|
||||
url: string,
|
||||
@@ -90,46 +108,60 @@ const httpEmbedBatch = async (
|
||||
model: string,
|
||||
apiKey: string,
|
||||
batchIndex = 0,
|
||||
attempt = 0,
|
||||
dimensions?: number,
|
||||
): Promise<EmbeddingItem[]> => {
|
||||
const requestBody: { input: string[]; model: string; dimensions?: number } = {
|
||||
input: batch,
|
||||
model,
|
||||
};
|
||||
if (dimensions !== undefined) {
|
||||
requestBody.dimensions = dimensions;
|
||||
}
|
||||
|
||||
let resp: Response;
|
||||
try {
|
||||
resp = await fetch(url, {
|
||||
method: 'POST',
|
||||
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
resp = await resilientFetch(
|
||||
url,
|
||||
{
|
||||
method: 'POST',
|
||||
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
},
|
||||
body: JSON.stringify({ input: batch, model }),
|
||||
});
|
||||
{
|
||||
breakerKey: HTTP_BREAKER_KEY,
|
||||
retry: { maxAttempts: HTTP_MAX_RETRIES + 1, baseDelayMs: HTTP_RETRY_BACKOFF_MS },
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
// Timeouts should not be retried — the server is unresponsive.
|
||||
// AbortSignal.timeout() throws DOMException with name 'TimeoutError'.
|
||||
const isTimeout = err instanceof DOMException && err.name === 'TimeoutError';
|
||||
if (isTimeout) {
|
||||
if (err instanceof CircuitOpenError) {
|
||||
throw new Error(
|
||||
`Embedding endpoint circuit open (${safeUrl(url)}, batch ${batchIndex}): retry in ${Math.ceil(err.retryAfterMs / 1000)}s`,
|
||||
);
|
||||
}
|
||||
if (err instanceof DOMException && err.name === 'TimeoutError') {
|
||||
throw new Error(
|
||||
`Embedding request timed out after ${HTTP_TIMEOUT_MS}ms (${safeUrl(url)}, batch ${batchIndex})`,
|
||||
);
|
||||
}
|
||||
// DNS, connection errors — retry with backoff
|
||||
if (attempt < HTTP_MAX_RETRIES) {
|
||||
const delay = HTTP_RETRY_BACKOFF_MS * (attempt + 1);
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
return httpEmbedBatch(url, batch, model, apiKey, batchIndex, attempt + 1);
|
||||
if (err instanceof ResilientFetchExhaustedError) {
|
||||
throw new Error(
|
||||
`Embedding endpoint returned ${err.response.status} (${safeUrl(url)}, batch ${batchIndex})`,
|
||||
);
|
||||
}
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(`Embedding request failed (${safeUrl(url)}, batch ${batchIndex}): ${reason}`);
|
||||
}
|
||||
|
||||
if (!resp.ok) {
|
||||
const status = resp.status;
|
||||
if ((status === 429 || status >= 500) && attempt < HTTP_MAX_RETRIES) {
|
||||
const delay = HTTP_RETRY_BACKOFF_MS * (attempt + 1);
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
return httpEmbedBatch(url, batch, model, apiKey, batchIndex, attempt + 1);
|
||||
}
|
||||
throw new Error(`Embedding endpoint returned ${status} (${safeUrl(url)}, batch ${batchIndex})`);
|
||||
// resilientFetch already retried 5xx/429; any non-OK response here is
|
||||
// a terminal client error (4xx other than 429).
|
||||
throw new Error(
|
||||
`Embedding endpoint returned ${resp.status} (${safeUrl(url)}, batch ${batchIndex})`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await resp.json()) as { data: EmbeddingItem[] };
|
||||
@@ -155,7 +187,14 @@ export const httpEmbed = async (texts: string[]): Promise<Float32Array[]> => {
|
||||
for (let i = 0; i < texts.length; i += HTTP_BATCH_SIZE) {
|
||||
const batch = texts.slice(i, i + HTTP_BATCH_SIZE);
|
||||
const batchIndex = Math.floor(i / HTTP_BATCH_SIZE);
|
||||
const items = await httpEmbedBatch(url, batch, config.model, config.apiKey, batchIndex);
|
||||
const items = await httpEmbedBatch(
|
||||
url,
|
||||
batch,
|
||||
config.model,
|
||||
config.apiKey,
|
||||
batchIndex,
|
||||
config.dimensions,
|
||||
);
|
||||
|
||||
if (items.length !== batch.length) {
|
||||
throw new Error(
|
||||
@@ -198,7 +237,14 @@ export const httpEmbedQuery = async (text: string): Promise<number[]> => {
|
||||
if (!config) throw new Error('HTTP embedding not configured');
|
||||
|
||||
const url = `${config.baseUrl}/embeddings`;
|
||||
const items = await httpEmbedBatch(url, [text], config.model, config.apiKey);
|
||||
const items = await httpEmbedBatch(
|
||||
url,
|
||||
[text],
|
||||
config.model,
|
||||
config.apiKey,
|
||||
0,
|
||||
config.dimensions,
|
||||
);
|
||||
if (!items.length) {
|
||||
throw new Error(`Embedding endpoint returned empty response (${safeUrl(url)})`);
|
||||
}
|
||||
|
||||
@@ -3,11 +3,14 @@
|
||||
* Lives in core/ so application code does not depend on the MCP package layer.
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { execFile, execFileSync } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import path from 'path';
|
||||
import { readRegistry, type RegistryEntry, type CwdMatch } from '../storage/repo-manager.js';
|
||||
import { findGitRootByDotGit, getCurrentCommit, getRemoteUrl } from '../storage/git.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export interface StalenessInfo {
|
||||
isStale: boolean;
|
||||
commitsBehind: number;
|
||||
@@ -41,6 +44,39 @@ export function checkStaleness(repoPath: string, lastCommit: string): StalenessI
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Async variant of {@link checkStaleness} — spawns git as a child process
|
||||
* instead of blocking the event loop. Used by `listRepos()` to check many
|
||||
* repos in parallel (issue #1363: 200 repos × sync spawn ≈ 50 s).
|
||||
*/
|
||||
export async function checkStalenessAsync(
|
||||
repoPath: string,
|
||||
lastCommit: string,
|
||||
): Promise<StalenessInfo> {
|
||||
try {
|
||||
// Note: promisified execFile captures stdout/stderr by default (no stdio option needed,
|
||||
// unlike the sync variant which requires explicit stdio: ['pipe','pipe','pipe']).
|
||||
const { stdout } = await execFileAsync('git', ['rev-list', '--count', `${lastCommit}..HEAD`], {
|
||||
cwd: repoPath,
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
|
||||
const commitsBehind = parseInt(stdout.trim(), 10) || 0;
|
||||
|
||||
if (commitsBehind > 0) {
|
||||
return {
|
||||
isStale: true,
|
||||
commitsBehind,
|
||||
hint: `⚠️ Index is ${commitsBehind} commit${commitsBehind > 1 ? 's' : ''} behind HEAD. Run analyze tool to update.`,
|
||||
};
|
||||
}
|
||||
|
||||
return { isStale: false, commitsBehind: 0 };
|
||||
} catch {
|
||||
return { isStale: false, commitsBehind: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare a sibling-clone HEAD against an indexed `lastCommit`. Returns
|
||||
* `undefined` when the indexed commit is not reachable from the sibling
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import lbug from '@ladybugdb/core';
|
||||
import type { LbugValue } from '@ladybugdb/core';
|
||||
import type { BridgeHandle, BridgeMeta, StoredContract, CrossLink, RepoSnapshot } from './types.js';
|
||||
@@ -11,6 +11,9 @@ import {
|
||||
type LbugConnectionHandle,
|
||||
} from '../lbug/lbug-config.js';
|
||||
import { dedupeContracts, dedupeCrossLinks } from './normalization.js';
|
||||
import { createLogger } from '../logger.js';
|
||||
|
||||
const bridgeLogger = createLogger('bridge-db', { debugEnvVar: 'GITNEXUS_DEBUG_BRIDGE' });
|
||||
|
||||
/**
|
||||
* Sidecar files that LadybugDB creates next to a `bridge.lbug` file.
|
||||
@@ -24,7 +27,7 @@ import { dedupeContracts, dedupeCrossLinks } from './normalization.js';
|
||||
* - `.shadow` — non-blocking concurrent checkpoint sidecar (added in
|
||||
* LadybugDB 0.15.4); same pairing constraint as `.wal`.
|
||||
*
|
||||
* `bridge-db` writes to a `bridge.lbug.tmp` file and then atomically renames
|
||||
* `bridge-db` writes to a `bridge.lbug.tmp.<random>` file and then atomically renames
|
||||
* it into place. The rename only moves the main file; sidecars must be
|
||||
* cleaned up explicitly or the next writer trips the database-id check.
|
||||
*/
|
||||
@@ -276,8 +279,24 @@ export async function retryRename(src: string, dst: string, attempts = 3): Promi
|
||||
|
||||
export async function writeBridgeMeta(groupDir: string, meta: BridgeMeta): Promise<void> {
|
||||
const target = path.join(groupDir, 'meta.json');
|
||||
const tmp = `${target}.tmp.${Date.now()}`;
|
||||
await fsp.writeFile(tmp, JSON.stringify(meta, null, 2), 'utf-8');
|
||||
// Unpredictable suffix + O_EXCL via `'wx'` flag closes the symlink/
|
||||
// pre-create attack window. The third argument `0o600` is the
|
||||
// user-only mode mask — CodeQL's `js/insecure-temporary-file` query
|
||||
// sources its verdict from the `mode` argument, NOT from `flags`:
|
||||
// its `isSecureMode(mode)` predicate requires the low 6 bits to be
|
||||
// zero (no group/world bits). Without an explicit mode the file is
|
||||
// created with the process umask (typically 0o644 = group/world
|
||||
// readable), which the query treats as the actual vulnerability.
|
||||
// Both `'wx'` (runtime O_EXCL) AND `0o600` (CodeQL-credited mode)
|
||||
// are needed: one closes the symlink race, the other closes the
|
||||
// permissions exposure.
|
||||
const tmp = `${target}.tmp.${randomBytes(8).toString('hex')}`;
|
||||
const handle = await fsp.open(tmp, 'wx', 0o600);
|
||||
try {
|
||||
await handle.writeFile(JSON.stringify(meta, null, 2), 'utf-8');
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
// Use retryRename for consistency with writeBridge's atomic swap — on
|
||||
// Windows a concurrent reader can cause EBUSY/EPERM even on a tiny
|
||||
// meta.json, and we don't want meta write to be less robust than the
|
||||
@@ -346,7 +365,19 @@ export async function writeBridge(
|
||||
const crossLinks = dedupeCrossLinks(input.crossLinks);
|
||||
|
||||
const finalPath = path.join(groupDir, 'bridge.lbug');
|
||||
const tmpPath = path.join(groupDir, 'bridge.lbug.tmp');
|
||||
// Stage the temp database inside a unique mkdtemp directory rather than
|
||||
// a fixed `bridge.lbug.tmp` name. The previous shape was flagged by
|
||||
// CodeQL js/insecure-temporary-file as a predictable path: a co-located
|
||||
// attacker (or a parallel writeBridge call into the same group) could
|
||||
// pre-create or symlink that path before this writer opens it. mkdtemp
|
||||
// returns a directory whose suffix is filled with cryptographically
|
||||
// random bytes, so the staging path is unguessable AND collision-free
|
||||
// across parallel callers. We anchor the staging directory inside
|
||||
// `groupDir` so the subsequent rename of `bridge.lbug` (and its
|
||||
// `.wal` / `.shadow` sidecars) into place stays on the same filesystem
|
||||
// and remains atomic — moving across `os.tmpdir()` could trip EXDEV.
|
||||
const stagingDir = await fsp.mkdtemp(path.join(groupDir, 'bridge-tmp-'));
|
||||
const tmpPath = path.join(stagingDir, 'bridge.lbug');
|
||||
const bakPath = path.join(groupDir, 'bridge.lbug.bak');
|
||||
|
||||
const report: WriteBridgeReport = {
|
||||
@@ -366,42 +397,42 @@ export async function writeBridge(
|
||||
}
|
||||
};
|
||||
|
||||
// Clean up any leftover tmp main file AND its `.wal` / `.shadow` sidecars.
|
||||
// LadybugDB 0.16.0 rejects opening a database whose sidecars belong to a
|
||||
// different database instance (database-id check), so any stale sidecar
|
||||
// from a crashed previous run will fail the next writeBridge.
|
||||
await removeLbugFile(tmpPath);
|
||||
// The mkdtemp staging directory above is freshly created with a unique
|
||||
// random suffix, so there are no leftover `bridge.lbug.tmp` / `.wal` /
|
||||
// `.shadow` sidecars from a previous crashed run to clean up here — the
|
||||
// directory is empty by construction.
|
||||
|
||||
// 1. Create temp DB, insert all data.
|
||||
//
|
||||
// Everything after `openBridgeDb` must run inside a try/finally so that
|
||||
// if ANY step before the explicit `closeBridgeDb` throws — schema
|
||||
// creation, a contract insert loop that rethrows, a snapshot write, the
|
||||
// cross-link loop, or anything else — the handle is still released. A
|
||||
// leaked handle holds the native LadybugDB file lock on tmpPath, which
|
||||
// (a) leaks a FD and (b) prevents the next writeBridge call from
|
||||
// reusing the same tmp slot.
|
||||
const handle = await openBridgeDb(tmpPath);
|
||||
let handleClosed = false;
|
||||
try {
|
||||
await ensureBridgeSchema(handle);
|
||||
// 1. Create temp DB, insert all data.
|
||||
//
|
||||
// Everything after `openBridgeDb` must run inside a try/finally so that
|
||||
// if ANY step before the explicit `closeBridgeDb` throws — schema
|
||||
// creation, a contract insert loop that rethrows, a snapshot write, the
|
||||
// cross-link loop, or anything else — the handle is still released. A
|
||||
// leaked handle holds the native LadybugDB file lock on tmpPath, which
|
||||
// (a) leaks a FD and (b) prevents the next writeBridge call from
|
||||
// reusing the same tmp slot.
|
||||
const handle = await openBridgeDb(tmpPath);
|
||||
let handleClosed = false;
|
||||
try {
|
||||
await ensureBridgeSchema(handle);
|
||||
|
||||
// Build the lookup index incrementally as contracts are inserted, so
|
||||
// failed inserts are never in the index (and therefore never resolved
|
||||
// by the cross-link loop below). This replaces a previous N+1 query
|
||||
// pattern where each link made up to 6 DB round-trips to find its
|
||||
// endpoints — see ContractLookupIndex.
|
||||
const lookupIndex = createContractLookupIndex();
|
||||
// Build the lookup index incrementally as contracts are inserted, so
|
||||
// failed inserts are never in the index (and therefore never resolved
|
||||
// by the cross-link loop below). This replaces a previous N+1 query
|
||||
// pattern where each link made up to 6 DB round-trips to find its
|
||||
// endpoints — see ContractLookupIndex.
|
||||
const lookupIndex = createContractLookupIndex();
|
||||
|
||||
// Insert contracts — tolerate individual failures (e.g., a corrupt meta
|
||||
// that can't be serialized). The whole sync must not fail because one
|
||||
// contract is broken.
|
||||
for (const c of contracts) {
|
||||
const id = contractNodeId(c.repo, c.contractId, c.role, c.symbolRef.filePath);
|
||||
try {
|
||||
await queryBridge(
|
||||
handle,
|
||||
`CREATE (n:Contract {
|
||||
// Insert contracts — tolerate individual failures (e.g., a corrupt meta
|
||||
// that can't be serialized). The whole sync must not fail because one
|
||||
// contract is broken.
|
||||
for (const c of contracts) {
|
||||
const id = contractNodeId(c.repo, c.contractId, c.role, c.symbolRef.filePath);
|
||||
try {
|
||||
await queryBridge(
|
||||
handle,
|
||||
`CREATE (n:Contract {
|
||||
id: $id,
|
||||
contractId: $contractId,
|
||||
type: $type,
|
||||
@@ -414,91 +445,91 @@ export async function writeBridge(
|
||||
confidence: $confidence,
|
||||
meta: $meta
|
||||
})`,
|
||||
{
|
||||
id,
|
||||
contractId: c.contractId,
|
||||
type: c.type,
|
||||
role: c.role,
|
||||
repo: c.repo,
|
||||
service: c.service ?? '',
|
||||
symbolUid: c.symbolUid,
|
||||
filePath: c.symbolRef.filePath,
|
||||
symbolName: c.symbolName,
|
||||
confidence: c.confidence,
|
||||
meta: JSON.stringify(c.meta),
|
||||
},
|
||||
);
|
||||
report.contractsInserted++;
|
||||
// Only index on successful insert — the cross-link loop must never
|
||||
// resolve to a row that isn't actually in the DB.
|
||||
indexContract(lookupIndex, c, id);
|
||||
} catch (err) {
|
||||
report.contractsFailed++;
|
||||
recordError('contract', id, err);
|
||||
{
|
||||
id,
|
||||
contractId: c.contractId,
|
||||
type: c.type,
|
||||
role: c.role,
|
||||
repo: c.repo,
|
||||
service: c.service ?? '',
|
||||
symbolUid: c.symbolUid,
|
||||
filePath: c.symbolRef.filePath,
|
||||
symbolName: c.symbolName,
|
||||
confidence: c.confidence,
|
||||
meta: JSON.stringify(c.meta),
|
||||
},
|
||||
);
|
||||
report.contractsInserted++;
|
||||
// Only index on successful insert — the cross-link loop must never
|
||||
// resolve to a row that isn't actually in the DB.
|
||||
indexContract(lookupIndex, c, id);
|
||||
} catch (err) {
|
||||
report.contractsFailed++;
|
||||
recordError('contract', id, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Insert repo snapshots
|
||||
for (const [repoId, snap] of Object.entries(input.repoSnapshots)) {
|
||||
try {
|
||||
await queryBridge(
|
||||
handle,
|
||||
`CREATE (s:RepoSnapshot {
|
||||
// Insert repo snapshots
|
||||
for (const [repoId, snap] of Object.entries(input.repoSnapshots)) {
|
||||
try {
|
||||
await queryBridge(
|
||||
handle,
|
||||
`CREATE (s:RepoSnapshot {
|
||||
id: $id,
|
||||
indexedAt: $indexedAt,
|
||||
lastCommit: $lastCommit
|
||||
})`,
|
||||
{
|
||||
id: repoId,
|
||||
indexedAt: snap.indexedAt,
|
||||
lastCommit: snap.lastCommit,
|
||||
},
|
||||
);
|
||||
report.snapshotsInserted++;
|
||||
} catch (err) {
|
||||
report.snapshotsFailed++;
|
||||
recordError('snapshot', repoId, err);
|
||||
}
|
||||
}
|
||||
|
||||
// Insert cross-links (tolerating missing nodes).
|
||||
//
|
||||
// `findContractNode` consults the in-memory lookup index built above,
|
||||
// not the DB — that's an O(1) pure-function lookup per endpoint instead
|
||||
// of the previous 2-3 DB queries. For M cross-links, the previous code
|
||||
// issued up to 6M round-trips; this version issues zero.
|
||||
//
|
||||
// `link.contractId` may differ between the consumer and provider sides
|
||||
// (e.g. wildcard consumer `grpc::Service/*` → method-level provider
|
||||
// `grpc::Service/Method`) — that's why we resolve each endpoint
|
||||
// independently via its own `(repo, role, symbolUid, filePath, symbolName)`
|
||||
// tuple rather than matching on contractId.
|
||||
for (const link of crossLinks) {
|
||||
const linkId = `${link.from.repo}::${link.contractId}->${link.to.repo}::${link.contractId}`;
|
||||
try {
|
||||
const fromId = findContractNode(
|
||||
lookupIndex,
|
||||
link.from.repo,
|
||||
'consumer',
|
||||
link.from.symbolUid,
|
||||
link.from.symbolRef.filePath,
|
||||
link.from.symbolRef.name,
|
||||
);
|
||||
const toId = findContractNode(
|
||||
lookupIndex,
|
||||
link.to.repo,
|
||||
'provider',
|
||||
link.to.symbolUid,
|
||||
link.to.symbolRef.filePath,
|
||||
link.to.symbolRef.name,
|
||||
);
|
||||
if (!fromId || !toId) {
|
||||
report.linksDroppedMissingNode++;
|
||||
continue;
|
||||
{
|
||||
id: repoId,
|
||||
indexedAt: snap.indexedAt,
|
||||
lastCommit: snap.lastCommit,
|
||||
},
|
||||
);
|
||||
report.snapshotsInserted++;
|
||||
} catch (err) {
|
||||
report.snapshotsFailed++;
|
||||
recordError('snapshot', repoId, err);
|
||||
}
|
||||
await queryBridge(
|
||||
handle,
|
||||
`
|
||||
}
|
||||
|
||||
// Insert cross-links (tolerating missing nodes).
|
||||
//
|
||||
// `findContractNode` consults the in-memory lookup index built above,
|
||||
// not the DB — that's an O(1) pure-function lookup per endpoint instead
|
||||
// of the previous 2-3 DB queries. For M cross-links, the previous code
|
||||
// issued up to 6M round-trips; this version issues zero.
|
||||
//
|
||||
// `link.contractId` may differ between the consumer and provider sides
|
||||
// (e.g. wildcard consumer `grpc::Service/*` → method-level provider
|
||||
// `grpc::Service/Method`) — that's why we resolve each endpoint
|
||||
// independently via its own `(repo, role, symbolUid, filePath, symbolName)`
|
||||
// tuple rather than matching on contractId.
|
||||
for (const link of crossLinks) {
|
||||
const linkId = `${link.from.repo}::${link.contractId}->${link.to.repo}::${link.contractId}`;
|
||||
try {
|
||||
const fromId = findContractNode(
|
||||
lookupIndex,
|
||||
link.from.repo,
|
||||
'consumer',
|
||||
link.from.symbolUid,
|
||||
link.from.symbolRef.filePath,
|
||||
link.from.symbolRef.name,
|
||||
);
|
||||
const toId = findContractNode(
|
||||
lookupIndex,
|
||||
link.to.repo,
|
||||
'provider',
|
||||
link.to.symbolUid,
|
||||
link.to.symbolRef.filePath,
|
||||
link.to.symbolRef.name,
|
||||
);
|
||||
if (!fromId || !toId) {
|
||||
report.linksDroppedMissingNode++;
|
||||
continue;
|
||||
}
|
||||
await queryBridge(
|
||||
handle,
|
||||
`
|
||||
MATCH (a:Contract), (b:Contract)
|
||||
WHERE a.id = $fromId AND b.id = $toId
|
||||
CREATE (a)-[:ContractLink {
|
||||
@@ -509,83 +540,93 @@ export async function writeBridge(
|
||||
toRepo: $toRepo
|
||||
}]->(b)
|
||||
`,
|
||||
{
|
||||
fromId,
|
||||
toId,
|
||||
matchType: link.matchType,
|
||||
confidence: link.confidence,
|
||||
contractId: link.contractId,
|
||||
fromRepo: link.from.repo,
|
||||
toRepo: link.to.repo,
|
||||
},
|
||||
);
|
||||
report.linksInserted++;
|
||||
} catch (err) {
|
||||
report.linksFailed++;
|
||||
recordError('link', linkId, err);
|
||||
{
|
||||
fromId,
|
||||
toId,
|
||||
matchType: link.matchType,
|
||||
confidence: link.confidence,
|
||||
contractId: link.contractId,
|
||||
fromRepo: link.from.repo,
|
||||
toRepo: link.to.repo,
|
||||
},
|
||||
);
|
||||
report.linksInserted++;
|
||||
} catch (err) {
|
||||
report.linksFailed++;
|
||||
recordError('link', linkId, err);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Close temp DB (happy path). The finally block also calls
|
||||
// closeBridgeDb if we threw above; `handleClosed` prevents a
|
||||
// double-close on the native handle.
|
||||
await closeBridgeDb(handle);
|
||||
handleClosed = true;
|
||||
} finally {
|
||||
if (!handleClosed) {
|
||||
await closeBridgeDb(handle).catch(() => {
|
||||
/* ignore: cleanup path, best effort */
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Close temp DB (happy path). The finally block also calls
|
||||
// closeBridgeDb if we threw above; `handleClosed` prevents a
|
||||
// double-close on the native handle.
|
||||
await closeBridgeDb(handle);
|
||||
handleClosed = true;
|
||||
} finally {
|
||||
if (!handleClosed) {
|
||||
await closeBridgeDb(handle).catch(() => {
|
||||
/* ignore: cleanup path, best effort */
|
||||
});
|
||||
// 3. Atomic swap: old→.bak, tmp→final, rm .bak
|
||||
//
|
||||
// The current database file (with its `.wal` / `.shadow` sidecars) is
|
||||
// moved aside, then the freshly built tmp database takes its place.
|
||||
// We move the sidecars together with the main file so the open below
|
||||
// and any external readers see a consistent set; orphan sidecars from
|
||||
// the tmp namespace are then removed because LadybugDB looks for them
|
||||
// under the renamed-to base name and would reject mismatching IDs.
|
||||
try {
|
||||
await fsp.access(finalPath);
|
||||
await retryRename(finalPath, bakPath);
|
||||
for (const suffix of LBUG_SIDECAR_SUFFIXES) {
|
||||
try {
|
||||
await fsp.access(`${finalPath}${suffix}`);
|
||||
await retryRename(`${finalPath}${suffix}`, `${bakPath}${suffix}`);
|
||||
} catch {
|
||||
/* sidecar absent — nothing to move */
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* no existing db */
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Atomic swap: old→.bak, tmp→final, rm .bak
|
||||
//
|
||||
// The current database file (with its `.wal` / `.shadow` sidecars) is
|
||||
// moved aside, then the freshly built tmp database takes its place.
|
||||
// We move the sidecars together with the main file so the open below
|
||||
// and any external readers see a consistent set; orphan sidecars from
|
||||
// the tmp namespace are then removed because LadybugDB looks for them
|
||||
// under the renamed-to base name and would reject mismatching IDs.
|
||||
try {
|
||||
await fsp.access(finalPath);
|
||||
await retryRename(finalPath, bakPath);
|
||||
await retryRename(tmpPath, finalPath);
|
||||
for (const suffix of LBUG_SIDECAR_SUFFIXES) {
|
||||
// Rename — not delete — so the WAL (which may carry uncommitted-at-
|
||||
// close-time pages on a graceful close, depending on
|
||||
// `autoCheckpoint` / `checkpointThreshold`) and the `.shadow`
|
||||
// checkpoint snapshot stay paired with the database file under its
|
||||
// final name. LadybugDB 0.16.0's database-id check rejects an open
|
||||
// when the sidecars belong to a different base name.
|
||||
try {
|
||||
await fsp.access(`${finalPath}${suffix}`);
|
||||
await retryRename(`${finalPath}${suffix}`, `${bakPath}${suffix}`);
|
||||
await fsp.access(`${tmpPath}${suffix}`);
|
||||
await retryRename(`${tmpPath}${suffix}`, `${finalPath}${suffix}`);
|
||||
} catch {
|
||||
/* sidecar absent — nothing to move */
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* no existing db */
|
||||
}
|
||||
await retryRename(tmpPath, finalPath);
|
||||
for (const suffix of LBUG_SIDECAR_SUFFIXES) {
|
||||
// Rename — not delete — so the WAL (which may carry uncommitted-at-
|
||||
// close-time pages on a graceful close, depending on
|
||||
// `autoCheckpoint` / `checkpointThreshold`) and the `.shadow`
|
||||
// checkpoint snapshot stay paired with the database file under its
|
||||
// final name. LadybugDB 0.16.0's database-id check rejects an open
|
||||
// when the sidecars belong to a different base name.
|
||||
try {
|
||||
await fsp.access(`${tmpPath}${suffix}`);
|
||||
await retryRename(`${tmpPath}${suffix}`, `${finalPath}${suffix}`);
|
||||
} catch {
|
||||
/* sidecar absent — nothing to move */
|
||||
}
|
||||
}
|
||||
await removeLbugFile(bakPath);
|
||||
await removeLbugFile(bakPath);
|
||||
|
||||
// 4. Write meta.json
|
||||
await writeBridgeMeta(groupDir, {
|
||||
version: BRIDGE_SCHEMA_VERSION,
|
||||
generatedAt: new Date().toISOString(),
|
||||
missingRepos: input.missingRepos,
|
||||
});
|
||||
// 4. Write meta.json
|
||||
await writeBridgeMeta(groupDir, {
|
||||
version: BRIDGE_SCHEMA_VERSION,
|
||||
generatedAt: new Date().toISOString(),
|
||||
missingRepos: input.missingRepos,
|
||||
});
|
||||
|
||||
return report;
|
||||
return report;
|
||||
} finally {
|
||||
// Always remove the mkdtemp staging directory. On the happy path the
|
||||
// main file and sidecars have been renamed out of it, so it's empty;
|
||||
// on any error path it may still contain a partial database — either
|
||||
// way `recursive: true, force: true` removes it without surfacing
|
||||
// "directory not empty" or ENOENT.
|
||||
await fsp.rm(stagingDir, { recursive: true, force: true }).catch(() => {
|
||||
/* best-effort cleanup */
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -681,14 +722,17 @@ export async function openBridgeDbReadOnly(groupDir: string): Promise<BridgeHand
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
}
|
||||
}
|
||||
if (process.env.GITNEXUS_DEBUG_BRIDGE) {
|
||||
console.warn(
|
||||
`[bridge-db] openBridgeDbReadOnly(${groupDir}) gave up after ` +
|
||||
`${LBUG_OPEN_RETRY_ATTEMPTS} attempts: ${
|
||||
lastErr instanceof Error ? lastErr.message : String(lastErr)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
// Strip CRLF from user-controlled strings before logging to close
|
||||
// CodeQL js/log-injection. Pino's NDJSON serialization already
|
||||
// JSON-escapes all values, but we sanitize here as a defence-in-depth
|
||||
// measure so CodeQL can see the taint flow is broken.
|
||||
const safeGroupDir = String(groupDir).replace(/[\r\n]/g, ' ');
|
||||
const safeErrMsg =
|
||||
lastErr instanceof Error ? String(lastErr.message).replace(/[\r\n]/g, ' ') : undefined;
|
||||
bridgeLogger.debug(
|
||||
{ groupDir: safeGroupDir, errMsg: safeErrMsg, attempts: LBUG_OPEN_RETRY_ATTEMPTS },
|
||||
'openBridgeDbReadOnly gave up',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,15 +4,34 @@ import type { GroupConfig, GroupManifestLink, ContractType, ContractRole } from
|
||||
const _require = createRequire(import.meta.url);
|
||||
const yaml = _require('js-yaml') as typeof import('js-yaml');
|
||||
|
||||
const VALID_CONTRACT_TYPES: ContractType[] = ['http', 'grpc', 'topic', 'lib', 'custom'];
|
||||
const VALID_CONTRACT_TYPES: ContractType[] = [
|
||||
'http',
|
||||
'grpc',
|
||||
'thrift',
|
||||
'topic',
|
||||
'lib',
|
||||
'custom',
|
||||
'include',
|
||||
];
|
||||
const VALID_ROLES: ContractRole[] = ['provider', 'consumer'];
|
||||
|
||||
// Defaults matter for backward compatibility: any group.yaml that omits a
|
||||
// `detect.<type>` key inherits its value from this constant. Adding a new
|
||||
// extractor that defaults to `true` silently changes the behavior of every
|
||||
// existing group on the next sync. New extractors must default to `false`
|
||||
// (opt-in) so operators consciously enable them via group.yaml.
|
||||
//
|
||||
// `includes`: opt-in. The C/C++ IncludeExtractor (PR #1156) ships disabled by
|
||||
// default; enable with `detect.includes: true` for groups containing C/C++
|
||||
// repos that need cross-repo header tracking.
|
||||
const DEFAULT_DETECT = {
|
||||
http: true,
|
||||
grpc: true,
|
||||
thrift: true,
|
||||
topics: true,
|
||||
shared_libs: true,
|
||||
embedding_fallback: true,
|
||||
includes: false,
|
||||
workspace_deps: false,
|
||||
};
|
||||
|
||||
|
||||
@@ -91,6 +91,25 @@ function clampCrossDepth(raw: unknown): { depth: number; warning?: string } {
|
||||
return { depth: d };
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamp the impact timeout to a sane bounded range. Callers can feed this
|
||||
* via tool params, so an unclamped value lets a single request hold a
|
||||
* timer slot for an arbitrarily long duration (CodeQL js/resource-
|
||||
* exhaustion). 100ms lower bound preserves test-suite scenarios that
|
||||
* exercise tight timeouts; 5min upper bound is well above any legitimate
|
||||
* single-impact compute. Applied at the validate boundary so the
|
||||
* downstream `deadline` (Date.now() + timeoutMs) and the local-leg
|
||||
* `setTimeout` see the same clamped value — earlier shapes had a 1hr
|
||||
* outer cap and a 5min inner clamp that disagreed.
|
||||
*/
|
||||
export const IMPACT_TIMEOUT_MIN_MS = 100;
|
||||
export const IMPACT_TIMEOUT_MAX_MS = 5 * 60 * 1_000;
|
||||
|
||||
export function clampTimeout(timeoutMs: number): number {
|
||||
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return IMPACT_TIMEOUT_MIN_MS;
|
||||
return Math.min(IMPACT_TIMEOUT_MAX_MS, Math.max(IMPACT_TIMEOUT_MIN_MS, Math.trunc(timeoutMs)));
|
||||
}
|
||||
|
||||
export function validateGroupImpactParams(params: Record<string, unknown>):
|
||||
| {
|
||||
ok: true;
|
||||
@@ -143,13 +162,19 @@ export function validateGroupImpactParams(params: Record<string, unknown>):
|
||||
const service = normalizeServicePrefix(params.service);
|
||||
const subgroup = typeof params.subgroup === 'string' ? params.subgroup : undefined;
|
||||
|
||||
let timeoutMs =
|
||||
// Clamp at the validate boundary so the downstream `deadline` (line
|
||||
// ~366) and `safeLocalImpact`'s `setTimeout` both see a single
|
||||
// bounded value. Without this, the outer deadline budgeted Phase-2
|
||||
// cross-repo fanout up to 1hr while only the inner setTimeout was
|
||||
// capped to 5min — the two halves of CodeQL #184's mitigation
|
||||
// disagreed.
|
||||
const rawTimeoutMs =
|
||||
typeof params.timeoutMs === 'number' && params.timeoutMs > 0
|
||||
? params.timeoutMs
|
||||
: typeof params.timeout === 'number' && params.timeout > 0
|
||||
? params.timeout
|
||||
: DEFAULT_LOCAL_IMPACT_TIMEOUT_MS;
|
||||
if (timeoutMs > 3_600_000) timeoutMs = 3_600_000;
|
||||
const timeoutMs = clampTimeout(rawTimeoutMs);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
@@ -191,12 +216,13 @@ async function safeLocalImpact(
|
||||
impactParams: Parameters<GroupToolPort['impact']>[1],
|
||||
timeoutMs: number,
|
||||
): Promise<{ value: unknown; timedOut: boolean }> {
|
||||
const safeTimeoutMs = clampTimeout(timeoutMs);
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const impactP = port.impact(repo, impactParams).catch((err) => ({
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
}));
|
||||
const timeoutP = new Promise<'timeout'>((resolve) => {
|
||||
timer = setTimeout(() => resolve('timeout'), timeoutMs);
|
||||
timer = setTimeout(() => resolve('timeout'), safeTimeoutMs);
|
||||
});
|
||||
const won = await Promise.race([
|
||||
impactP.then((v) => ({ tag: 'impact' as const, v })),
|
||||
@@ -212,6 +238,65 @@ async function safeLocalImpact(
|
||||
return { value: won.v, timedOut: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Race a single Phase-2 `impactByUid` call against a remaining-budget
|
||||
* timer. The Codex adversarial review on PR #1331 surfaced that the
|
||||
* fanout loop only checked `Date.now() > deadline` *between* neighbor
|
||||
* calls — once `await port.impactByUid(...)` was reached, a hung
|
||||
* neighbor could pin the request indefinitely, and slow neighbors
|
||||
* could compound past the 5-min `IMPACT_TIMEOUT_MAX_MS` cap.
|
||||
*
|
||||
* This helper wraps each call: a `setTimeout(remainingMs)` aborts an
|
||||
* `AbortController` whose signal is forwarded to `impactByUid`, and a
|
||||
* `Promise.race` resolves to `{ timedOut: true }` when the timer
|
||||
* fires before the call completes. Implementors that ignore the
|
||||
* signal (current local backend) still see their await resolved by
|
||||
* the race; full cooperative cancellation inside the BFS is a future
|
||||
* follow-up. On rejection, the value is `null` (matching the
|
||||
* fanout's existing `if (fan == null)` truncation contract).
|
||||
*
|
||||
* Exported for direct unit testing — the helper IS the load-bearing
|
||||
* mitigation surface, so the U3 regression test pins it directly
|
||||
* rather than driving the full `runGroupImpact` path.
|
||||
*/
|
||||
export async function safeNeighborImpact(
|
||||
port: GroupToolPort,
|
||||
repoId: string,
|
||||
uid: string,
|
||||
direction: string,
|
||||
opts: {
|
||||
maxDepth: number;
|
||||
relationTypes: string[];
|
||||
minConfidence: number;
|
||||
includeTests: boolean;
|
||||
},
|
||||
remainingMs: number,
|
||||
): Promise<{ value: unknown; timedOut: boolean }> {
|
||||
const controller = new AbortController();
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const callP = port
|
||||
.impactByUid(repoId, uid, direction, { ...opts, signal: controller.signal })
|
||||
.catch(() => null);
|
||||
const timeoutP = new Promise<'timeout'>((resolve) => {
|
||||
timer = setTimeout(
|
||||
() => {
|
||||
controller.abort();
|
||||
resolve('timeout');
|
||||
},
|
||||
Math.max(0, remainingMs),
|
||||
);
|
||||
});
|
||||
const won = await Promise.race([
|
||||
callP.then((v) => ({ tag: 'impact' as const, v })),
|
||||
timeoutP.then(() => ({ tag: 'timeout' as const })),
|
||||
]);
|
||||
if (timer !== undefined) clearTimeout(timer);
|
||||
if (won.tag === 'timeout') {
|
||||
return { value: null, timedOut: true };
|
||||
}
|
||||
return { value: won.v, timedOut: false };
|
||||
}
|
||||
|
||||
export function collectImpactSymbolUids(
|
||||
local: unknown,
|
||||
servicePrefix: string | undefined,
|
||||
@@ -476,7 +561,8 @@ export async function runGroupImpact(
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
|
||||
if (Date.now() > deadline) {
|
||||
const remainingMs = deadline - Date.now();
|
||||
if (remainingMs <= 0) {
|
||||
truncatedRepos.push(n.neighborRepo);
|
||||
continue;
|
||||
}
|
||||
@@ -492,13 +578,25 @@ export async function runGroupImpact(
|
||||
continue;
|
||||
}
|
||||
|
||||
const fan = await deps.port.impactByUid(neighborHandle.id, n.neighborUid, direction, {
|
||||
maxDepth,
|
||||
relationTypes: relationTypes ?? [],
|
||||
minConfidence,
|
||||
includeTests,
|
||||
});
|
||||
if (fan == null) {
|
||||
// Phase-2 hardening: race each impactByUid against a per-call
|
||||
// timeout derived from the remaining budget. Without this wrap a
|
||||
// single hung neighbor would pin the request past the clamped
|
||||
// timeout, which Codex's adversarial review on PR #1331 flagged
|
||||
// as the still-open half of CodeQL #184 / js/resource-exhaustion.
|
||||
const { value: fan, timedOut: neighborTimedOut } = await safeNeighborImpact(
|
||||
deps.port,
|
||||
neighborHandle.id,
|
||||
n.neighborUid,
|
||||
direction,
|
||||
{
|
||||
maxDepth,
|
||||
relationTypes: relationTypes ?? [],
|
||||
minConfidence,
|
||||
includeTests,
|
||||
},
|
||||
remainingMs,
|
||||
);
|
||||
if (neighborTimedOut || fan == null) {
|
||||
truncatedRepos.push(n.neighborRepo);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { CypherExecutor } from '../contract-extractor.js';
|
||||
import type { GroupManifestLink, ContractRole } from '../types.js';
|
||||
import { shouldIgnorePath, loadIgnoreRules } from '../../../config/ignore-service.js';
|
||||
|
||||
import { logger } from '../../logger.js';
|
||||
interface ElixirAppMeta {
|
||||
appName: string;
|
||||
modulePrefix: string;
|
||||
@@ -202,7 +203,7 @@ export async function extractElixirWorkspaceLinks(
|
||||
};
|
||||
const existing = appsByName.get(manifest.appName);
|
||||
if (existing) {
|
||||
console.warn(
|
||||
logger.warn(
|
||||
`[elixir-workspace-extractor] duplicate app "${manifest.appName}" in "${groupPath}" and "${existing.groupPath}" — skipping "${groupPath}"`,
|
||||
);
|
||||
continue;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user