Compare commits
68
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cea07a6783 | ||
|
|
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 | ||
|
|
c08564abc1 | ||
|
|
16067f882f | ||
|
|
95aa10630e | ||
|
|
fa36254ed5 | ||
|
|
7be1a5a72d | ||
|
|
e92328d474 | ||
|
|
681af2b956 | ||
|
|
c3d58c68b9 | ||
|
|
ec54a51822 | ||
|
|
7cce07b419 | ||
|
|
6ec1f04604 |
@@ -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)
|
||||
|
||||
@@ -11,7 +11,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 +24,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 ──
|
||||
|
||||
@@ -45,7 +45,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
|
||||
@@ -60,8 +60,12 @@ jobs:
|
||||
- 'gitnexus/vendor/**'
|
||||
- 'gitnexus/src/core/parsing/**/parser.c'
|
||||
- 'gitnexus/src/core/parsing/**/parser.js'
|
||||
# Test fixtures are intentionally synthetic inputs (broken/unused
|
||||
# code, malformed samples) used to exercise the analyzer. CodeQL
|
||||
# findings here are noise, not real bugs.
|
||||
- '**/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 }}'
|
||||
|
||||
@@ -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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # 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,7 +35,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
|
||||
|
||||
@@ -123,7 +123,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 +141,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 +294,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 +356,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,13 +1,19 @@
|
||||
name: Trivy Image Scan
|
||||
|
||||
# Builds Dockerfile.cli and Dockerfile.web, then scans the resulting images
|
||||
# for OS-package and language-package CVEs at 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:
|
||||
@@ -44,7 +50,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 +67,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:
|
||||
@@ -17,6 +20,27 @@ concurrency:
|
||||
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 +73,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,
|
||||
|
||||
@@ -2,6 +2,7 @@ dist/
|
||||
coverage/
|
||||
gitnexus/vendor/
|
||||
gitnexus/test/fixtures/
|
||||
gitnexus-web/test/fixtures/
|
||||
gitnexus-web/playwright-report/
|
||||
gitnexus-web/test-results/
|
||||
*.d.ts
|
||||
|
||||
+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:
|
||||
|
||||
|
||||
+20
-8
@@ -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/*
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends curl git && rm -rf /var/lib/apt/lists/* \
|
||||
&& rm -rf /usr/local/lib/node_modules/npm \
|
||||
&& rm -rf /usr/local/lib/node_modules/corepack \
|
||||
&& rm -f /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -47,7 +59,7 @@ COPY --from=builder --chown=node:node /app/gitnexus/vendor ./gitnexus/vendor
|
||||
|
||||
USER node
|
||||
|
||||
# The web UI defaults to http://localhost:4747 — keep that contract.
|
||||
# The web UI defaults to http://localhost:4747 - keep that contract.
|
||||
ENV GITNEXUS_HOME=/data/gitnexus \
|
||||
NODE_ENV=production \
|
||||
PORT=4747
|
||||
|
||||
+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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
+62
-23
@@ -1,11 +1,11 @@
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import { createServer } from 'node:http';
|
||||
import { extname, join, normalize, sep } from 'node:path';
|
||||
import { extname, isAbsolute, normalize, relative, resolve } from 'node:path';
|
||||
|
||||
const host = '0.0.0.0';
|
||||
const port = Number(process.env.PORT || '4173');
|
||||
const root = join(process.cwd(), 'dist');
|
||||
const root = resolve(process.cwd(), 'dist');
|
||||
|
||||
const contentTypes = {
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
@@ -20,39 +20,78 @@ const contentTypes = {
|
||||
'.woff2': 'font/woff2',
|
||||
};
|
||||
|
||||
function resolvePath(urlPath) {
|
||||
// Static asset server for the gitnexus-web Docker image.
|
||||
//
|
||||
// Path-injection containment: the request handler is intentionally a single
|
||||
// inline pipeline with no helper functions on the path-data flow. Each
|
||||
// filesystem sink (stat, createReadStream) is immediately preceded by the
|
||||
// canonical `path.relative` containment check that CodeQL's
|
||||
// `js/path-injection` query recognizes as a sanitizer barrier:
|
||||
//
|
||||
// const rel = relative(root, candidate);
|
||||
// if (rel.startsWith('..') || isAbsolute(rel)) reject;
|
||||
// // candidate is now proven inside `root`
|
||||
//
|
||||
// Earlier iterations of this file used a helper (`resolveWithinRoot`) and a
|
||||
// `startsWith(root + sep)` check. Both were semantically correct but neither
|
||||
// was recognized by CodeQL: `startsWith(root + sep)` is not in the analyzer's
|
||||
// barrier-pattern set, and helper-based sanitization is not followed across
|
||||
// the request handler's reassignment paths in vanilla JS. The inline-at-sink
|
||||
// shape below is the documented analyzer-friendly idiom.
|
||||
const server = createServer(async (req, res) => {
|
||||
const urlPath = req.url?.split('?')[0] || '/';
|
||||
|
||||
let decoded;
|
||||
try {
|
||||
decoded = decodeURIComponent(urlPath);
|
||||
} catch {
|
||||
return null;
|
||||
res.writeHead(400);
|
||||
res.end('Bad request');
|
||||
return;
|
||||
}
|
||||
if (decoded.includes('\0')) return null;
|
||||
if (decoded.includes('\0')) {
|
||||
res.writeHead(400);
|
||||
res.end('Bad request');
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanPath = normalize(decoded.replace(/^\/+/, ''));
|
||||
const candidate = join(root, cleanPath);
|
||||
if (candidate !== root && !candidate.startsWith(root + sep)) return null;
|
||||
return candidate;
|
||||
}
|
||||
const initialPath = resolve(root, cleanPath);
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
const requestPath = req.url?.split('?')[0] || '/';
|
||||
let filePath = resolvePath(requestPath);
|
||||
|
||||
if (!filePath) {
|
||||
// Sanitizer barrier #1 — guards the first stat() sink.
|
||||
const initialRel = relative(root, initialPath);
|
||||
if (initialRel.startsWith('..') || isAbsolute(initialRel)) {
|
||||
res.writeHead(400);
|
||||
res.end('Bad request');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const fileStat = await stat(filePath).catch(() => null);
|
||||
if (fileStat?.isDirectory()) {
|
||||
filePath = join(filePath, 'index.html');
|
||||
} else if (!fileStat?.isFile()) {
|
||||
filePath = join(root, 'index.html');
|
||||
const initialStat = await stat(initialPath).catch(() => null);
|
||||
|
||||
// Pick the path we actually serve. Note: any branch reassigns to a
|
||||
// freshly-resolved path; the next sanitizer barrier re-validates.
|
||||
let finalPath;
|
||||
if (initialStat?.isDirectory()) {
|
||||
finalPath = resolve(initialPath, 'index.html');
|
||||
} else if (!initialStat?.isFile()) {
|
||||
finalPath = resolve(root, 'index.html');
|
||||
} else {
|
||||
finalPath = initialPath;
|
||||
}
|
||||
|
||||
const finalStat = await stat(filePath).catch(() => null);
|
||||
// Sanitizer barrier #2 — guards both the second stat() and the
|
||||
// createReadStream() sinks. No reassignment of finalPath happens
|
||||
// between this guard and either sink, so the analyzer can prove
|
||||
// containment for both.
|
||||
const finalRel = relative(root, finalPath);
|
||||
if (finalRel.startsWith('..') || isAbsolute(finalRel)) {
|
||||
res.writeHead(400);
|
||||
res.end('Bad request');
|
||||
return;
|
||||
}
|
||||
|
||||
const finalStat = await stat(finalPath).catch(() => null);
|
||||
if (!finalStat?.isFile()) {
|
||||
res.writeHead(404);
|
||||
res.end('Not found');
|
||||
@@ -60,14 +99,14 @@ const server = createServer(async (req, res) => {
|
||||
}
|
||||
|
||||
res.writeHead(200, {
|
||||
'Cache-Control': filePath.includes('/assets/')
|
||||
'Cache-Control': finalPath.includes('/assets/')
|
||||
? 'public, max-age=31536000, immutable'
|
||||
: 'no-cache',
|
||||
'Content-Type': contentTypes[extname(filePath)] || 'application/octet-stream',
|
||||
'Content-Type': contentTypes[extname(finalPath)] || 'application/octet-stream',
|
||||
'Cross-Origin-Opener-Policy': 'same-origin',
|
||||
'Cross-Origin-Embedder-Policy': 'require-corp',
|
||||
});
|
||||
const stream = createReadStream(filePath);
|
||||
const stream = createReadStream(finalPath);
|
||||
stream.on('error', () => res.destroy());
|
||||
stream.pipe(res);
|
||||
} catch (error) {
|
||||
|
||||
@@ -100,6 +100,23 @@ it('rejects percent-encoded null bytes with 400', async () => {
|
||||
assert.equal(res.status, 400);
|
||||
});
|
||||
|
||||
it('rejects percent-encoded path traversal with 400', async () => {
|
||||
// %2e%2e%2f decodes to '../'. Without the path.relative inline barrier,
|
||||
// a naive string check on the raw URL would let this through and only
|
||||
// the lexical-decoded path.resolve would catch it. Confirm the barrier
|
||||
// does its job after decodeURIComponent.
|
||||
const res = await rawGet(serverPort, '/%2e%2e%2f%2e%2e%2fetc%2fpasswd');
|
||||
assert.equal(res.status, 400);
|
||||
});
|
||||
|
||||
it('rejects malformed percent-encoding with 400', async () => {
|
||||
// %GG is not a valid percent-encoded sequence — decodeURIComponent throws.
|
||||
// The handler's try/catch around decode must convert this to a 400 rather
|
||||
// than an unhandled rejection.
|
||||
const res = await rawGet(serverPort, '/foo%GGbar');
|
||||
assert.equal(res.status, 400);
|
||||
});
|
||||
|
||||
it('returns 404 when dist/index.html is missing', async () => {
|
||||
await unlink(join(tmpDir, 'dist', 'index.html'));
|
||||
const res = await rawGet(serverPort, '/nonexistent-page');
|
||||
|
||||
@@ -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.
|
||||
+99
-2
@@ -4,6 +4,38 @@ import unusedImports from 'eslint-plugin-unused-imports';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import prettierConfig from 'eslint-config-prettier';
|
||||
|
||||
// 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
|
||||
{
|
||||
@@ -14,6 +46,7 @@ export default [
|
||||
'gitnexus/vendor/**',
|
||||
'gitnexus-web/src/vendor/**',
|
||||
'gitnexus/test/fixtures/**',
|
||||
'gitnexus-web/test/fixtures/**',
|
||||
'gitnexus-web/playwright-report/**',
|
||||
'gitnexus-web/test-results/**',
|
||||
'**/*.d.ts',
|
||||
@@ -58,11 +91,47 @@ 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],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -78,6 +147,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,
|
||||
];
|
||||
|
||||
+3
-3
@@ -6,19 +6,19 @@ readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"mini-swe-agent>=2.0.0",
|
||||
"litellm>=1.50.0,!=1.82.7,!=1.82.8",
|
||||
"litellm!=1.82.7,!=1.82.8,>=1.83.7",
|
||||
"datasets>=3.0.0",
|
||||
"typer>=0.12.0",
|
||||
"rich>=13.0.0",
|
||||
"pyyaml>=6.0",
|
||||
"pandas>=2.0.0",
|
||||
"tabulate>=0.9.0",
|
||||
"python-dotenv>=1.0.0",
|
||||
"python-dotenv>=1.2.2",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0.0",
|
||||
"pytest>=9.0.3",
|
||||
"ruff>=0.5.0",
|
||||
"hypothesis>=6.88.0",
|
||||
"coverage>=7.6.0",
|
||||
|
||||
Generated
+114
-114
@@ -21,7 +21,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "aiohttp"
|
||||
version = "3.13.3"
|
||||
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/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" }
|
||||
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/f1/4c/a164164834f03924d9a29dc3acd9e7ee58f95857e0b467f6d04298594ebb/aiohttp-3.13.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5b6073099fb654e0a068ae678b10feff95c5cae95bbfcbfa7af669d361a8aa6b", size = 746051, upload-time = "2026-01-03T17:29:43.287Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/71/d5c31390d18d4f58115037c432b7e0348c60f6f53b727cad33172144a112/aiohttp-3.13.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cb93e166e6c28716c8c6aeb5f99dfb6d5ccf482d29fe9bf9a794110e6d0ab64", size = 499234, upload-time = "2026-01-03T17:29:44.822Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/c9/741f8ac91e14b1d2e7100690425a5b2b919a87a5075406582991fb7de920/aiohttp-3.13.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:28e027cf2f6b641693a09f631759b4d9ce9165099d2b5d92af9bd4e197690eea", size = 494979, upload-time = "2026-01-03T17:29:46.405Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/b5/31d4d2e802dfd59f74ed47eba48869c1c21552c586d5e81a9d0d5c2ad640/aiohttp-3.13.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b61b7169ababd7802f9568ed96142616a9118dd2be0d1866e920e77ec8fa92a", size = 1748297, upload-time = "2026-01-03T17:29:48.083Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/3e/eefad0ad42959f226bb79664826883f2687d602a9ae2941a18e0484a74d3/aiohttp-3.13.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:80dd4c21b0f6237676449c6baaa1039abae86b91636b6c91a7f8e61c87f89540", size = 1707172, upload-time = "2026-01-03T17:29:49.648Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/3a/54a64299fac2891c346cdcf2aa6803f994a2e4beeaf2e5a09dcc54acc842/aiohttp-3.13.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65d2ccb7eabee90ce0503c17716fc77226be026dcc3e65cce859a30db715025b", size = 1805405, upload-time = "2026-01-03T17:29:51.244Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/70/ddc1b7169cf64075e864f64595a14b147a895a868394a48f6a8031979038/aiohttp-3.13.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b179331a481cb5529fca8b432d8d3c7001cb217513c94cd72d668d1248688a3", size = 1899449, upload-time = "2026-01-03T17:29:53.938Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/7e/6815aab7d3a56610891c76ef79095677b8b5be6646aaf00f69b221765021/aiohttp-3.13.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d4c940f02f49483b18b079d1c27ab948721852b281f8b015c058100e9421dd1", size = 1748444, upload-time = "2026-01-03T17:29:55.484Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/f2/073b145c4100da5511f457dc0f7558e99b2987cf72600d42b559db856fbc/aiohttp-3.13.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9444f105664c4ce47a2a7171a2418bce5b7bae45fb610f4e2c36045d85911d3", size = 1606038, upload-time = "2026-01-03T17:29:57.179Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/c1/778d011920cae03ae01424ec202c513dc69243cf2db303965615b81deeea/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:694976222c711d1d00ba131904beb60534f93966562f64440d0c9d41b8cdb440", size = 1724156, upload-time = "2026-01-03T17:29:58.914Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/cb/3419eabf4ec1e9ec6f242c32b689248365a1cf621891f6f0386632525494/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f33ed1a2bf1997a36661874b017f5c4b760f41266341af36febaf271d179f6d7", size = 1722340, upload-time = "2026-01-03T17:30:01.962Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/e5/76cf77bdbc435bf233c1f114edad39ed4177ccbfab7c329482b179cff4f4/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e636b3c5f61da31a92bf0d91da83e58fdfa96f178ba682f11d24f31944cdd28c", size = 1783041, upload-time = "2026-01-03T17:30:03.609Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/d4/dd1ca234c794fd29c057ce8c0566b8ef7fd6a51069de5f06fa84b9a1971c/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5d2d94f1f5fcbe40838ac51a6ab5704a6f9ea42e72ceda48de5e6b898521da51", size = 1596024, upload-time = "2026-01-03T17:30:05.132Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/58/4345b5f26661a6180afa686c473620c30a66afdf120ed3dd545bbc809e85/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2be0e9ccf23e8a94f6f0650ce06042cefc6ac703d0d7ab6c7a917289f2539ad4", size = 1804590, upload-time = "2026-01-03T17:30:07.135Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/06/05950619af6c2df7e0a431d889ba2813c9f0129cec76f663e547a5ad56f2/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9af5e68ee47d6534d36791bbe9b646d2a7c7deb6fc24d7943628edfbb3581f29", size = 1740355, upload-time = "2026-01-03T17:30:09.083Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/80/958f16de79ba0422d7c1e284b2abd0c84bc03394fbe631d0a39ffa10e1eb/aiohttp-3.13.3-cp311-cp311-win32.whl", hash = "sha256:a2212ad43c0833a873d0fb3c63fa1bacedd4cf6af2fee62bf4b739ceec3ab239", size = 433701, upload-time = "2026-01-03T17:30:10.869Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/f2/27cdf04c9851712d6c1b99df6821a6623c3c9e55956d4b1e318c337b5a48/aiohttp-3.13.3-cp311-cp311-win_amd64.whl", hash = "sha256:642f752c3eb117b105acbd87e2c143de710987e09860d674e068c4c2c441034f", size = 457678, upload-time = "2026-01-03T17:30:12.719Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/be/4fc11f202955a69e0db803a12a062b8379c970c7c84f4882b6da17337cc1/aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c", size = 739732, upload-time = "2026-01-03T17:30:14.23Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/2c/621d5b851f94fa0bb7430d6089b3aa970a9d9b75196bc93bb624b0db237a/aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168", size = 494293, upload-time = "2026-01-03T17:30:15.96Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/43/4be01406b78e1be8320bb8316dc9c42dbab553d281c40364e0f862d5661c/aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d", size = 493533, upload-time = "2026-01-03T17:30:17.431Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/a8/5a35dc56a06a2c90d4742cbf35294396907027f80eea696637945a106f25/aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29", size = 1737839, upload-time = "2026-01-03T17:30:19.422Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/62/4b9eeb331da56530bf2e198a297e5303e1c1ebdceeb00fe9b568a65c5a0c/aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3", size = 1703932, upload-time = "2026-01-03T17:30:21.756Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/f6/af16887b5d419e6a367095994c0b1332d154f647e7dc2bd50e61876e8e3d/aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d", size = 1771906, upload-time = "2026-01-03T17:30:23.932Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/83/397c634b1bcc24292fa1e0c7822800f9f6569e32934bdeef09dae7992dfb/aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463", size = 1871020, upload-time = "2026-01-03T17:30:26Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/f6/a62cbbf13f0ac80a70f71b1672feba90fdb21fd7abd8dbf25c0105fb6fa3/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc", size = 1755181, upload-time = "2026-01-03T17:30:27.554Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/87/20a35ad487efdd3fba93d5843efdfaa62d2f1479eaafa7453398a44faf13/aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf", size = 1561794, upload-time = "2026-01-03T17:30:29.254Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/95/8fd69a66682012f6716e1bc09ef8a1a2a91922c5725cb904689f112309c4/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033", size = 1697900, upload-time = "2026-01-03T17:30:31.033Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/66/7b94b3b5ba70e955ff597672dad1691333080e37f50280178967aff68657/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f", size = 1728239, upload-time = "2026-01-03T17:30:32.703Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/71/6f72f77f9f7d74719692ab65a2a0252584bf8d5f301e2ecb4c0da734530a/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679", size = 1740527, upload-time = "2026-01-03T17:30:34.695Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/b4/75ec16cbbd5c01bdaf4a05b19e103e78d7ce1ef7c80867eb0ace42ff4488/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423", size = 1554489, upload-time = "2026-01-03T17:30:36.864Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/8f/bc518c0eea29f8406dcf7ed1f96c9b48e3bc3995a96159b3fc11f9e08321/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce", size = 1767852, upload-time = "2026-01-03T17:30:39.433Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/f2/a07a75173124f31f11ea6f863dc44e6f09afe2bca45dd4e64979490deab1/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a", size = 1722379, upload-time = "2026-01-03T17:30:41.081Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/4a/1a3fee7c21350cac78e5c5cef711bac1b94feca07399f3d406972e2d8fcd/aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046", size = 428253, upload-time = "2026-01-03T17:30:42.644Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/b7/76175c7cb4eb73d91ad63c34e29fc4f77c9386bba4a65b53ba8e05ee3c39/aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57", size = 455407, upload-time = "2026-01-03T17:30:44.195Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/8a/12ca489246ca1faaf5432844adbfce7ff2cc4997733e0af120869345643a/aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c", size = 734190, upload-time = "2026-01-03T17:30:45.832Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/08/de43984c74ed1fca5c014808963cc83cb00d7bb06af228f132d33862ca76/aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9", size = 491783, upload-time = "2026-01-03T17:30:47.466Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/f8/8dd2cf6112a5a76f81f81a5130c57ca829d101ad583ce57f889179accdda/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3", size = 490704, upload-time = "2026-01-03T17:30:49.373Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/40/a46b03ca03936f832bc7eaa47cfbb1ad012ba1be4790122ee4f4f8cba074/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf", size = 1720652, upload-time = "2026-01-03T17:30:50.974Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/7e/917fe18e3607af92657e4285498f500dca797ff8c918bd7d90b05abf6c2a/aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6", size = 1692014, upload-time = "2026-01-03T17:30:52.729Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/b6/cefa4cbc00d315d68973b671cf105b21a609c12b82d52e5d0c9ae61d2a09/aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d", size = 1759777, upload-time = "2026-01-03T17:30:54.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/e3/e06ee07b45e59e6d81498b591fc589629be1553abb2a82ce33efe2a7b068/aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261", size = 1861276, upload-time = "2026-01-03T17:30:56.512Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/24/75d274228acf35ceeb2850b8ce04de9dd7355ff7a0b49d607ee60c29c518/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0", size = 1743131, upload-time = "2026-01-03T17:30:58.256Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/98/3d21dde21889b17ca2eea54fdcff21b27b93f45b7bb94ca029c31ab59dc3/aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730", size = 1556863, upload-time = "2026-01-03T17:31:00.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/84/da0c3ab1192eaf64782b03971ab4055b475d0db07b17eff925e8c93b3aa5/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91", size = 1682793, upload-time = "2026-01-03T17:31:03.024Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/0f/5802ada182f575afa02cbd0ec5180d7e13a402afb7c2c03a9aa5e5d49060/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3", size = 1716676, upload-time = "2026-01-03T17:31:04.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/8c/714d53bd8b5a4560667f7bbbb06b20c2382f9c7847d198370ec6526af39c/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4", size = 1733217, upload-time = "2026-01-03T17:31:06.868Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/79/e2176f46d2e963facea939f5be2d26368ce543622be6f00a12844d3c991f/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998", size = 1552303, upload-time = "2026-01-03T17:31:08.958Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/6a/28ed4dea1759916090587d1fe57087b03e6c784a642b85ef48217b0277ae/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0", size = 1763673, upload-time = "2026-01-03T17:31:10.676Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/35/4a3daeb8b9fab49240d21c04d50732313295e4bd813a465d840236dd0ce1/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591", size = 1721120, upload-time = "2026-01-03T17:31:12.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/9f/d643bb3c5fb99547323e635e251c609fbbc660d983144cfebec529e09264/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf", size = 427383, upload-time = "2026-01-03T17:31:14.382Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899, upload-time = "2026-01-03T17:31:15.958Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/36/5b6514a9f5d66f4e2597e40dea2e3db271e023eb7a5d22defe96ba560996/aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808", size = 737238, upload-time = "2026-01-03T17:31:17.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/49/459327f0d5bcd8c6c9ca69e60fdeebc3622861e696490d8674a6d0cb90a6/aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415", size = 492292, upload-time = "2026-01-03T17:31:19.919Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/0b/b97660c5fd05d3495b4eb27f2d0ef18dc1dc4eff7511a9bf371397ff0264/aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f", size = 493021, upload-time = "2026-01-03T17:31:21.636Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/d4/438efabdf74e30aeceb890c3290bbaa449780583b1270b00661126b8aae4/aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6", size = 1717263, upload-time = "2026-01-03T17:31:23.296Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/f2/7bddc7fd612367d1459c5bcf598a9e8f7092d6580d98de0e057eb42697ad/aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687", size = 1669107, upload-time = "2026-01-03T17:31:25.334Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/5a/1aeaecca40e22560f97610a329e0e5efef5e0b5afdf9f857f0d93839ab2e/aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26", size = 1760196, upload-time = "2026-01-03T17:31:27.394Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/f8/0ff6992bea7bd560fc510ea1c815f87eedd745fe035589c71ce05612a19a/aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a", size = 1843591, upload-time = "2026-01-03T17:31:29.238Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/d1/e30e537a15f53485b61f5be525f2157da719819e8377298502aebac45536/aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1", size = 1720277, upload-time = "2026-01-03T17:31:31.053Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/45/23f4c451d8192f553d38d838831ebbc156907ea6e05557f39563101b7717/aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25", size = 1548575, upload-time = "2026-01-03T17:31:32.87Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/ed/0a42b127a43712eda7807e7892c083eadfaf8429ca8fb619662a530a3aab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603", size = 1679455, upload-time = "2026-01-03T17:31:34.76Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/b5/c05f0c2b4b4fe2c9d55e73b6d3ed4fd6c9dc2684b1d81cbdf77e7fad9adb/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a", size = 1687417, upload-time = "2026-01-03T17:31:36.699Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/6b/915bc5dad66aef602b9e459b5a973529304d4e89ca86999d9d75d80cbd0b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926", size = 1729968, upload-time = "2026-01-03T17:31:38.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/3b/e84581290a9520024a08640b63d07673057aec5ca548177a82026187ba73/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba", size = 1545690, upload-time = "2026-01-03T17:31:40.57Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/04/0c3655a566c43fd647c81b895dfe361b9f9ad6d58c19309d45cff52d6c3b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c", size = 1746390, upload-time = "2026-01-03T17:31:42.857Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/53/71165b26978f719c3419381514c9690bd5980e764a09440a10bb816ea4ab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43", size = 1702188, upload-time = "2026-01-03T17:31:44.984Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/a7/cbe6c9e8e136314fa1980da388a59d2f35f35395948a08b6747baebb6aa6/aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1", size = 433126, upload-time = "2026-01-03T17:31:47.463Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/56/982704adea7d3b16614fc5936014e9af85c0e34b58f9046655817f04306e/aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984", size = 459128, upload-time = "2026-01-03T17:31:49.2Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/2a/3c79b638a9c3d4658d345339d22070241ea341ed4e07b5ac60fb0f418003/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c", size = 769512, upload-time = "2026-01-03T17:31:51.134Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/b9/3e5014d46c0ab0db8707e0ac2711ed28c4da0218c358a4e7c17bae0d8722/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592", size = 506444, upload-time = "2026-01-03T17:31:52.85Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/03/c1d4ef9a054e151cd7839cdc497f2638f00b93cbe8043983986630d7a80c/aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f", size = 510798, upload-time = "2026-01-03T17:31:54.91Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/76/8c1e5abbfe8e127c893fe7ead569148a4d5a799f7cf958d8c09f3eedf097/aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29", size = 1868835, upload-time = "2026-01-03T17:31:56.733Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/ac/984c5a6f74c363b01ff97adc96a3976d9c98940b8969a1881575b279ac5d/aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc", size = 1720486, upload-time = "2026-01-03T17:31:58.65Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/9a/b7039c5f099c4eb632138728828b33428585031a1e658d693d41d07d89d1/aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2", size = 1847951, upload-time = "2026-01-03T17:32:00.989Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/02/3bec2b9a1ba3c19ff89a43a19324202b8eb187ca1e928d8bdac9bbdddebd/aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587", size = 1941001, upload-time = "2026-01-03T17:32:03.122Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/df/d879401cedeef27ac4717f6426c8c36c3091c6e9f08a9178cc87549c537f/aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8", size = 1797246, upload-time = "2026-01-03T17:32:05.255Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/15/be122de1f67e6953add23335c8ece6d314ab67c8bebb3f181063010795a7/aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632", size = 1627131, upload-time = "2026-01-03T17:32:07.607Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/12/70eedcac9134cfa3219ab7af31ea56bc877395b1ac30d65b1bc4b27d0438/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64", size = 1795196, upload-time = "2026-01-03T17:32:09.59Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/11/b30e1b1cd1f3054af86ebe60df96989c6a414dd87e27ad16950eee420bea/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0", size = 1782841, upload-time = "2026-01-03T17:32:11.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/0d/d98a9367b38912384a17e287850f5695c528cff0f14f791ce8ee2e4f7796/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56", size = 1795193, upload-time = "2026-01-03T17:32:13.705Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/a5/a2dfd1f5ff5581632c7f6a30e1744deda03808974f94f6534241ef60c751/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72", size = 1621979, upload-time = "2026-01-03T17:32:15.965Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/f0/12973c382ae7c1cccbc4417e129c5bf54c374dfb85af70893646e1f0e749/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df", size = 1822193, upload-time = "2026-01-03T17:32:18.219Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/5f/24155e30ba7f8c96918af1350eb0663e2430aad9e001c0489d89cd708ab1/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa", size = 1769801, upload-time = "2026-01-03T17:32:20.25Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/f8/7314031ff5c10e6ece114da79b338ec17eeff3a079e53151f7e9f43c4723/aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767", size = 466523, upload-time = "2026-01-03T17:32:22.215Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/63/278a98c715ae467624eafe375542d8ba9b4383a016df8fdefe0ae28382a7/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344", size = 499694, upload-time = "2026-01-03T17:32:24.546Z" },
|
||||
{ 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]]
|
||||
@@ -274,14 +274,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.3.1"
|
||||
version = "8.1.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload-time = "2024-12-21T18:38:41.666Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -644,11 +644,11 @@ requires-dist = [
|
||||
{ name = "coverage", marker = "extra == 'dev'", specifier = ">=7.6.0" },
|
||||
{ name = "datasets", specifier = ">=3.0.0" },
|
||||
{ name = "hypothesis", marker = "extra == 'dev'", specifier = ">=6.88.0" },
|
||||
{ name = "litellm", specifier = ">=1.50.0" },
|
||||
{ name = "litellm", specifier = "!=1.82.7,!=1.82.8,>=1.83.7" },
|
||||
{ name = "mini-swe-agent", specifier = ">=2.0.0" },
|
||||
{ name = "pandas", specifier = ">=2.0.0" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
|
||||
{ name = "python-dotenv", specifier = ">=1.0.0" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.3" },
|
||||
{ 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" },
|
||||
@@ -769,14 +769,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "importlib-metadata"
|
||||
version = "9.0.0"
|
||||
version = "8.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "zipp" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cd/12/33e59336dca5be0c398a7482335911a33aa0e20776128f038019f1a95f1b/importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7", size = 55304, upload-time = "2024-09-11T14:56:08.937Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7", size = 27789, upload-time = "2026-03-20T06:42:55.665Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/d9/a1e041c5e7caa9a05c925f4bdbdfb7f006d1f74996af53467bc394c97be7/importlib_metadata-8.5.0-py3-none-any.whl", hash = "sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b", size = 26514, upload-time = "2024-09-11T14:56:07.019Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -887,7 +887,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema"
|
||||
version = "4.26.0"
|
||||
version = "4.23.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "attrs" },
|
||||
@@ -895,9 +895,9 @@ dependencies = [
|
||||
{ name = "referencing" },
|
||||
{ name = "rpds-py" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/38/2e/03362ee4034a4c917f697890ccd4aec0800ccf9ded7f511971c75451deec/jsonschema-4.23.0.tar.gz", hash = "sha256:d71497fef26351a33265337fa77ffeb82423f3ea21283cd9467bb03999266bc4", size = 325778, upload-time = "2024-07-08T18:40:05.546Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/4a/4f9dbeb84e8850557c02365a0eee0649abe5eb1d84af92a25731c6c0f922/jsonschema-4.23.0-py3-none-any.whl", hash = "sha256:fbadb6f8b144a8f8cf9f0b89ba94501d143e50411a1278633f56a7acf7fd5566", size = 88462, upload-time = "2024-07-08T18:40:00.165Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -926,7 +926,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "litellm"
|
||||
version = "1.82.6"
|
||||
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/29/75/1c537aa458426a9127a92bc2273787b2f987f4e5044e21f01f2eed5244fd/litellm-1.82.6.tar.gz", hash = "sha256:2aa1c2da21fe940c33613aa447119674a3ad4d2ad5eb064e4d5ce5ee42420136", size = 17414147, upload-time = "2026-03-22T06:36:00.452Z" }
|
||||
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/02/6c/5327667e6dbe9e98cbfbd4261c8e91386a52e38f41419575854248bbab6a/litellm-1.82.6-py3-none-any.whl", hash = "sha256:164a3ef3e19f309e3cabc199bef3d2045212712fefdfa25fc7f75884a5b5b205", size = 15591595, upload-time = "2026-03-22T06:35:56.795Z" },
|
||||
{ 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.29.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/b4/15/203d537e58986b5673e7f232453a2a2f110f22757b15921cbdeea392e520/openai-2.29.0.tar.gz", hash = "sha256:32d09eb2f661b38d3edd7d7e1a2943d1633f572596febe64c0cd370c86d52bec", size = 671128, upload-time = "2026-03-17T17:53:49.599Z" }
|
||||
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/d0/b1/35b6f9c8cf9318e3dbb7146cc82dab4cf61182a8d5406fc9b50864362895/openai-2.29.0-py3-none-any.whl", hash = "sha256:b7c5de513c3286d17c5e29b92c4c98ceaf0d775244ac8159aeb1bddf840eb42a", size = 1141533, upload-time = "2026-03-17T17:53:47.348Z" },
|
||||
{ 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]]
|
||||
@@ -1690,7 +1690,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.2"
|
||||
version = "9.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
@@ -1699,9 +1699,9 @@ dependencies = [
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1900,7 +1900,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.32.5"
|
||||
version = "2.33.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
@@ -1908,9 +1908,9 @@ dependencies = [
|
||||
{ name = "idna" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232, upload-time = "2026-03-25T15:10:41.586Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017, upload-time = "2026-03-25T15:10:40.382Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2224,7 +2224,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "typer"
|
||||
version = "0.24.1"
|
||||
version = "0.23.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-doc" },
|
||||
@@ -2232,9 +2232,9 @@ dependencies = [
|
||||
{ name = "rich" },
|
||||
{ name = "shellingham" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f5/24/cb09efec5cc954f7f9b930bf8279447d24618bb6758d4f6adf2574c41780/typer-0.24.1.tar.gz", hash = "sha256:e39b4732d65fbdcde189ae76cf7cd48aeae72919dea1fdfc16593be016256b45", size = 118613, upload-time = "2026-02-21T16:54:40.609Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fd/07/b822e1b307d40e263e8253d2384cf98c51aa2368cc7ba9a07e523a1d964b/typer-0.23.1.tar.gz", hash = "sha256:2070374e4d31c83e7b61362fd859aa683576432fd5b026b060ad6b4cd3b86134", size = 120047, upload-time = "2026-02-13T10:04:30.984Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e", size = 56085, upload-time = "2026-02-21T16:54:41.616Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/91/9b286ab899c008c2cb05e8be99814807e7fbbd33f0c0c960470826e5ac82/typer-0.23.1-py3-none-any.whl", hash = "sha256:3291ad0d3c701cbf522012faccfbb29352ff16ad262db2139e6b01f15781f14e", size = 56813, upload-time = "2026-02-13T10:04:32.008Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
+97
-97
@@ -8,17 +8,17 @@
|
||||
"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.3.3",
|
||||
"dompurify": "^3.4.2",
|
||||
"gitnexus-shared": "file:../gitnexus-shared",
|
||||
"graphology": "^0.26.0",
|
||||
"graphology-indices": "^0.17.0",
|
||||
@@ -33,7 +33,7 @@
|
||||
"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",
|
||||
@@ -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": {
|
||||
@@ -685,9 +685,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": [
|
||||
{
|
||||
@@ -1337,29 +1337,6 @@
|
||||
"mlly": "^1.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/balanced-match": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
|
||||
"integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/brace-expansion": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz",
|
||||
"integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@isaacs/balanced-match": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/fs-minipass": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
|
||||
@@ -1419,25 +1396,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",
|
||||
@@ -2439,9 +2416,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@ts-morph/common/node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
@@ -3424,12 +3401,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"
|
||||
}
|
||||
@@ -4502,9 +4479,9 @@
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
|
||||
"integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
|
||||
"version": "3.4.2",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.2.tgz",
|
||||
"integrity": "sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
@@ -4586,13 +4563,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"
|
||||
@@ -4845,9 +4822,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -5501,28 +5478,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",
|
||||
@@ -6055,9 +6032,9 @@
|
||||
}
|
||||
},
|
||||
"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"
|
||||
@@ -7077,9 +7054,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/micromatch/node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -7121,21 +7098,44 @@
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "10.1.1",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz",
|
||||
"integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==",
|
||||
"version": "10.2.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"@isaacs/brace-expansion": "^5.0.0"
|
||||
"brace-expansion": "^5.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch/node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch/node_modules/brace-expansion": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
@@ -7452,13 +7452,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 +7727,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": {
|
||||
@@ -8242,9 +8242,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tar": {
|
||||
"version": "7.5.3",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.3.tgz",
|
||||
"integrity": "sha512-ENg5JUHUm2rDD7IvKNFGzyElLXNjachNLp6RaGf4+JOgxXHkqA+gq81ZAMCUmtMtqBsoU62lcp6S27g1LCYGGQ==",
|
||||
"version": "7.5.13",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz",
|
||||
"integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
|
||||
@@ -19,17 +19,17 @@
|
||||
},
|
||||
"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.3.3",
|
||||
"dompurify": "^3.4.2",
|
||||
"graphology": "^0.26.0",
|
||||
"graphology-indices": "^0.17.0",
|
||||
"graphology-layout-force": "^0.2.4",
|
||||
@@ -43,7 +43,7 @@
|
||||
"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",
|
||||
@@ -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';
|
||||
@@ -225,29 +238,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 +331,6 @@ const fetchWithTimeout = async (
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -279,10 +352,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,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 \
|
||||
|
||||
Generated
+312
-55
@@ -1,23 +1,24 @@
|
||||
{
|
||||
"name": "gitnexus",
|
||||
"version": "1.6.3",
|
||||
"version": "1.6.4-rc.108",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "gitnexus",
|
||||
"version": "1.6.3",
|
||||
"version": "1.6.4-rc.108",
|
||||
"hasInstallScript": true,
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"dependencies": {
|
||||
"@huggingface/transformers": "^4.1.0",
|
||||
"@ladybugdb/core": "^0.16.0",
|
||||
"@ladybugdb/core": "^0.16.1",
|
||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||
"@scarf/scarf": "^1.4.0",
|
||||
"cli-progress": "^3.12.0",
|
||||
"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",
|
||||
@@ -614,9 +617,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@hono/node-server": {
|
||||
"version": "1.19.11",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz",
|
||||
"integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==",
|
||||
"version": "1.19.14",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
|
||||
"integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.14.1"
|
||||
@@ -1159,9 +1162,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@ladybugdb/core": {
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@ladybugdb/core/-/core-0.16.0.tgz",
|
||||
"integrity": "sha512-t/t4MPZmBMocFBzG5G3E3iHPwuIiXYEuLeW0CTOloGofkKQ7gHt3JlLzyDn2a+AHNQjr1YqlsodKKYQFhsFZXw==",
|
||||
"version": "0.16.1",
|
||||
"resolved": "https://registry.npmjs.org/@ladybugdb/core/-/core-0.16.1.tgz",
|
||||
"integrity": "sha512-qwuEcR8CVMKb6tNDaHtq7Ux8hT/XbPC0db+vwutX6JxNAejyx7YomHKPSy9XAKURhYK8mezZe3UN8rf+xpHOjQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1169,17 +1172,17 @@
|
||||
"node-addon-api": "^6.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@ladybugdb/core-darwin-arm64": "0.16.0",
|
||||
"@ladybugdb/core-darwin-x64": "0.16.0",
|
||||
"@ladybugdb/core-linux-arm64": "0.16.0",
|
||||
"@ladybugdb/core-linux-x64": "0.16.0",
|
||||
"@ladybugdb/core-win32-x64": "0.16.0"
|
||||
"@ladybugdb/core-darwin-arm64": "0.16.1",
|
||||
"@ladybugdb/core-darwin-x64": "0.16.1",
|
||||
"@ladybugdb/core-linux-arm64": "0.16.1",
|
||||
"@ladybugdb/core-linux-x64": "0.16.1",
|
||||
"@ladybugdb/core-win32-x64": "0.16.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@ladybugdb/core-darwin-arm64": {
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@ladybugdb/core-darwin-arm64/-/core-darwin-arm64-0.16.0.tgz",
|
||||
"integrity": "sha512-2IpiUbd6Lb50KRUkURk+PIgDRKume63uI4KYZNpjxNDwdHRXdadZTBZn74+DgK7IhpTyiPbtKddiXHKtSV2CWg==",
|
||||
"version": "0.16.1",
|
||||
"resolved": "https://registry.npmjs.org/@ladybugdb/core-darwin-arm64/-/core-darwin-arm64-0.16.1.tgz",
|
||||
"integrity": "sha512-Nl+Cf70rD+HaC9IBHv+oeUwqX9plghXD7PN9tyMzMohRVPvcGEbqWPB6YcdJa8rR7qRqCCbmaNMDen5wg4rY2w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1189,10 +1192,23 @@
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@ladybugdb/core-darwin-x64": {
|
||||
"version": "0.16.1",
|
||||
"resolved": "https://registry.npmjs.org/@ladybugdb/core-darwin-x64/-/core-darwin-x64-0.16.1.tgz",
|
||||
"integrity": "sha512-4eAjfimAAQRSmDfUUkGrl9OhefxcW1ziA9tl0eljBlGoUseE7dL02+RSqjGohYMcQ+lzuHAq1QWb0XRlMA8YTQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@ladybugdb/core-linux-arm64": {
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-arm64/-/core-linux-arm64-0.16.0.tgz",
|
||||
"integrity": "sha512-l+lV7BXfnA0w1voApKblBaGE+bKQqSlOG+30HkSYOAW7POYv+OoydgY/BGwabBUTvcnhVyrNApvBsPF8G3Nm3g==",
|
||||
"version": "0.16.1",
|
||||
"resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-arm64/-/core-linux-arm64-0.16.1.tgz",
|
||||
"integrity": "sha512-zkctksev+hsPFrNxHHdq4lYK5OWdLhWfRdQzjzkgDyaHayHU6yCL2fgD6uPGQ8TRQ6/2DxMErb4p3FzGW85Ubw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1203,9 +1219,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@ladybugdb/core-linux-x64": {
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-x64/-/core-linux-x64-0.16.0.tgz",
|
||||
"integrity": "sha512-XOL2H0y51e57dIFIHO8LHtN8Ner2qEyti6zAkxKr+w8LkvczHeVX910doz2de8+xvxDYJyzrcj2xWqDTxcK/Jg==",
|
||||
"version": "0.16.1",
|
||||
"resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-x64/-/core-linux-x64-0.16.1.tgz",
|
||||
"integrity": "sha512-5rAb9T5vif8WKhHwhobosu2/aiOwJkWb/ViybvUc5GFKunKl8VI6RmZQVeufT9zUzRktUwrxBrxblCxsnamXJw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1216,9 +1232,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@ladybugdb/core-win32-x64": {
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@ladybugdb/core-win32-x64/-/core-win32-x64-0.16.0.tgz",
|
||||
"integrity": "sha512-MyKiELqPgzx9gVHmwxzptnToAcDtCN7dTP5Y4IPMYhc2QpNbZKCihmvdXjbOmdIOfIHW4fBp4vrzT8fVbdAMZw==",
|
||||
"version": "0.16.1",
|
||||
"resolved": "https://registry.npmjs.org/@ladybugdb/core-win32-x64/-/core-win32-x64-0.16.1.tgz",
|
||||
"integrity": "sha512-ShOUTrIuZKQ63J95tcRJxKf1cvg8yi2FSYx9kMTSercc1FdQZPV+zxUN0myMq3MTWOl7xDxsVMmdp/t80O29UQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1228,9 +1244,6 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@ladybugdb/core/node_modules/@ladybugdb/core-darwin-x64": {
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@ladybugdb/core/node_modules/node-addon-api": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz",
|
||||
@@ -1568,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",
|
||||
@@ -2046,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"
|
||||
@@ -2369,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",
|
||||
@@ -2568,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",
|
||||
@@ -2672,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",
|
||||
@@ -2795,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",
|
||||
@@ -3007,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"
|
||||
@@ -3039,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",
|
||||
@@ -3405,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.9",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.9.tgz",
|
||||
"integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==",
|
||||
"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"
|
||||
@@ -3475,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"
|
||||
@@ -3564,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",
|
||||
@@ -3881,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"
|
||||
@@ -4172,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",
|
||||
@@ -4194,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": [
|
||||
@@ -4213,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": {
|
||||
@@ -4321,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",
|
||||
@@ -4365,6 +4526,22 @@
|
||||
"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",
|
||||
@@ -4402,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",
|
||||
@@ -4417,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",
|
||||
@@ -4472,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",
|
||||
@@ -4580,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",
|
||||
@@ -4817,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",
|
||||
@@ -4827,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",
|
||||
@@ -4914,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.4-rc.108",
|
||||
"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",
|
||||
@@ -53,13 +54,14 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@huggingface/transformers": "^4.1.0",
|
||||
"@ladybugdb/core": "^0.16.0",
|
||||
"@ladybugdb/core": "^0.16.1",
|
||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||
"@scarf/scarf": "^1.4.0",
|
||||
"cli-progress": "^3.12.0",
|
||||
"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');
|
||||
|
||||
@@ -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);
|
||||
@@ -293,7 +294,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}:`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+134
-33
@@ -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
|
||||
@@ -158,7 +169,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 +178,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 +205,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 +236,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;
|
||||
}
|
||||
@@ -247,6 +277,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 +332,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 +343,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 +368,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
|
||||
@@ -338,7 +406,8 @@ 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,
|
||||
@@ -367,7 +436,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');
|
||||
@@ -440,7 +511,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 +538,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 +549,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 +577,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 +616,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;
|
||||
|
||||
@@ -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,7 +23,11 @@ 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` ' +
|
||||
@@ -156,6 +160,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'] };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -347,7 +365,12 @@ async function installClaudeCodeHooks(result: SetupResult): Promise<void> {
|
||||
}
|
||||
|
||||
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 +627,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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,7 +104,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);
|
||||
const { results: bm25Results } = await searchFTSFromLbug(pattern, 10, repoId);
|
||||
|
||||
if (bm25Results.length === 0) return '';
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -90,46 +99,51 @@ const httpEmbedBatch = async (
|
||||
model: string,
|
||||
apiKey: string,
|
||||
batchIndex = 0,
|
||||
attempt = 0,
|
||||
): Promise<EmbeddingItem[]> => {
|
||||
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({ input: batch, model }),
|
||||
},
|
||||
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[] };
|
||||
|
||||
@@ -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,15 @@ 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)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
// Pino's NDJSON serialization is structurally injection-resistant
|
||||
// (CodeQL js/log-injection): groupDir and err.message are JSON-escaped
|
||||
// by the serializer, so no manual CRLF / U+2028 / ANSI sanitization is
|
||||
// needed. Demoted to debug — only fires when the bridge truly gave up
|
||||
// after retries, and operators only need it at debug verbosity.
|
||||
bridgeLogger.debug(
|
||||
{ groupDir, err: lastErr, attempts: LBUG_OPEN_RETRY_ATTEMPTS },
|
||||
'openBridgeDbReadOnly gave up',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,16 +4,35 @@ 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,
|
||||
workspace_deps: true,
|
||||
includes: false,
|
||||
workspace_deps: false,
|
||||
};
|
||||
|
||||
const DEFAULT_MATCHING = {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
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;
|
||||
groupPath: string;
|
||||
repoPath: string;
|
||||
deps: string[];
|
||||
}
|
||||
|
||||
interface ImportedModule {
|
||||
appName: string;
|
||||
moduleName: string;
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
async function parseMixExs(
|
||||
repoPath: string,
|
||||
): Promise<{ appName: string; modulePrefix: string; deps: string[] } | null> {
|
||||
const mixPath = path.join(repoPath, 'mix.exs');
|
||||
let content: string;
|
||||
try {
|
||||
content = await fs.readFile(mixPath, 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
// app: :my_app
|
||||
const appMatch = content.match(/app:\s*:(\w+)/);
|
||||
if (!appMatch) return null;
|
||||
const appName = appMatch[1];
|
||||
|
||||
// Derive module prefix: my_app -> MyApp
|
||||
const modulePrefix = appName
|
||||
.split('_')
|
||||
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
|
||||
.join('');
|
||||
|
||||
const deps: string[] = [];
|
||||
|
||||
// {:dep_name, "~> 1.0"} or {:dep_name, in_umbrella: true}
|
||||
// {:dep_name, git: "..."} or {:dep_name, path: "..."}
|
||||
const depMatches = content.matchAll(
|
||||
/\{:(\w+)\s*,\s*(?:"[^"]*"|~[^}]*|[^}]*(?:in_umbrella|path|git)\s*:[^}]*)\}/g,
|
||||
);
|
||||
for (const m of depMatches) {
|
||||
deps.push(m[1]);
|
||||
}
|
||||
|
||||
return { appName, modulePrefix, deps: [...new Set(deps)] };
|
||||
}
|
||||
|
||||
async function scanElixirImports(
|
||||
repoPath: string,
|
||||
knownApps: Map<string, string>,
|
||||
): Promise<ImportedModule[]> {
|
||||
const results: ImportedModule[] = [];
|
||||
const sourceFiles = await findElixirFiles(repoPath);
|
||||
|
||||
for (const relFile of sourceFiles) {
|
||||
const absPath = path.join(repoPath, relFile);
|
||||
let content: string;
|
||||
try {
|
||||
content = await fs.readFile(absPath, 'utf-8');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
// alias MyApp.SomeModule
|
||||
// alias MyApp.SomeModule, as: Short
|
||||
// alias MyApp.{ModA, ModB}
|
||||
const aliasRegex = /^\s*alias\s+([A-Z]\w+(?:\.[A-Z]\w+)*(?:\.\{[^}]+\})?)/gm;
|
||||
let match;
|
||||
while ((match = aliasRegex.exec(content)) !== null) {
|
||||
const aliasExpr = match[1];
|
||||
const modules = expandAlias(aliasExpr);
|
||||
for (const mod of modules) {
|
||||
const appName = matchModuleToApp(mod, knownApps);
|
||||
if (appName) {
|
||||
results.push({ appName, moduleName: mod, filePath: relFile });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Direct module reference: MyApp.Module.func() or MyApp.Module
|
||||
// Strip comment lines and string literals to avoid false positives
|
||||
const codeOnly = content
|
||||
.split('\n')
|
||||
.filter((line) => !line.trimStart().startsWith('#'))
|
||||
.join('\n');
|
||||
for (const [prefix, appName] of knownApps) {
|
||||
const refRegex = new RegExp(
|
||||
`\\b(${escapeRegex(prefix)}\\.[A-Z][A-Za-z0-9]*(?:\\.[A-Z][A-Za-z0-9]*)*)`,
|
||||
'g',
|
||||
);
|
||||
while ((match = refRegex.exec(codeOnly)) !== null) {
|
||||
const mod = match[1];
|
||||
if (!results.some((r) => r.moduleName === mod && r.filePath === relFile)) {
|
||||
results.push({ appName, moduleName: mod, filePath: relFile });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function expandAlias(expr: string): string[] {
|
||||
const braceMatch = expr.match(/^([A-Z][\w.]*)\.\{([^}]+)\}$/);
|
||||
if (braceMatch) {
|
||||
const prefix = braceMatch[1];
|
||||
return braceMatch[2]
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.map((s) => `${prefix}.${s}`);
|
||||
}
|
||||
return [expr];
|
||||
}
|
||||
|
||||
function matchModuleToApp(moduleName: string, knownApps: Map<string, string>): string | null {
|
||||
for (const [prefix, appName] of knownApps) {
|
||||
if (moduleName === prefix || moduleName.startsWith(prefix + '.')) {
|
||||
return appName;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function escapeRegex(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function extractTopModule(moduleName: string, prefix: string): string {
|
||||
const rest = moduleName.slice(prefix.length);
|
||||
if (!rest || rest === '.') return moduleName;
|
||||
const afterDot = rest.startsWith('.') ? rest.slice(1) : rest;
|
||||
const parts = afterDot.split('.');
|
||||
return `${prefix}.${parts[0]}`;
|
||||
}
|
||||
|
||||
async function findElixirFiles(repoPath: string): Promise<string[]> {
|
||||
const results: string[] = [];
|
||||
const ig = await loadIgnoreRules(repoPath);
|
||||
|
||||
async function walk(dir: string, rel: string): Promise<void> {
|
||||
let entries;
|
||||
try {
|
||||
entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const childRel = rel ? `${rel}/${entry.name}` : entry.name;
|
||||
if (entry.isDirectory()) {
|
||||
if (shouldIgnorePath(childRel)) continue;
|
||||
if (ig && ig.ignores(childRel + '/')) continue;
|
||||
await walk(path.join(dir, entry.name), childRel);
|
||||
} else if (entry.name.endsWith('.ex') || entry.name.endsWith('.exs')) {
|
||||
if (entry.name === 'mix.exs' || entry.name === 'mix.lock') continue;
|
||||
if (shouldIgnorePath(childRel)) continue;
|
||||
if (ig && ig.ignores(childRel)) continue;
|
||||
results.push(childRel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await walk(repoPath, '');
|
||||
return results;
|
||||
}
|
||||
|
||||
export interface ElixirWorkspaceResult {
|
||||
links: GroupManifestLink[];
|
||||
discoveredApps: Map<string, ElixirAppMeta>;
|
||||
}
|
||||
|
||||
export async function extractElixirWorkspaceLinks(
|
||||
repos: Record<string, string>,
|
||||
repoPaths: Map<string, string>,
|
||||
_dbExecutors?: Map<string, CypherExecutor>,
|
||||
): Promise<ElixirWorkspaceResult> {
|
||||
const appsByName = new Map<string, ElixirAppMeta>();
|
||||
const appsByGroupPath = new Map<string, ElixirAppMeta>();
|
||||
|
||||
for (const [groupPath] of Object.entries(repos)) {
|
||||
const repoPath = repoPaths.get(groupPath);
|
||||
if (!repoPath) continue;
|
||||
|
||||
const manifest = await parseMixExs(repoPath);
|
||||
if (!manifest) continue;
|
||||
|
||||
const meta: ElixirAppMeta = {
|
||||
appName: manifest.appName,
|
||||
modulePrefix: manifest.modulePrefix,
|
||||
groupPath,
|
||||
repoPath,
|
||||
deps: manifest.deps,
|
||||
};
|
||||
const existing = appsByName.get(manifest.appName);
|
||||
if (existing) {
|
||||
logger.warn(
|
||||
`[elixir-workspace-extractor] duplicate app "${manifest.appName}" in "${groupPath}" and "${existing.groupPath}" — skipping "${groupPath}"`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
appsByName.set(manifest.appName, meta);
|
||||
appsByGroupPath.set(groupPath, meta);
|
||||
}
|
||||
|
||||
const links: GroupManifestLink[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const [, app] of appsByGroupPath) {
|
||||
const groupDeps = app.deps.filter((d) => appsByName.has(d));
|
||||
if (groupDeps.length === 0) continue;
|
||||
|
||||
const knownApps = new Map<string, string>();
|
||||
for (const dep of groupDeps) {
|
||||
const depMeta = appsByName.get(dep);
|
||||
if (depMeta) knownApps.set(depMeta.modulePrefix, dep);
|
||||
}
|
||||
|
||||
const imports = await scanElixirImports(app.repoPath, knownApps);
|
||||
|
||||
for (const imp of imports) {
|
||||
const providerApp = appsByName.get(imp.appName);
|
||||
if (!providerApp) continue;
|
||||
|
||||
const topModule = extractTopModule(imp.moduleName, providerApp.modulePrefix);
|
||||
const key = `${app.groupPath}→${providerApp.groupPath}::${topModule}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
|
||||
// V1: Elixir contracts use the full module name (e.g. "Core.Schema") without
|
||||
// an "appName::" prefix. resolveSymbol will query the graph with this full
|
||||
// string — resolution depends on Elixir indexer storing fully-qualified names.
|
||||
const link: GroupManifestLink = {
|
||||
from: providerApp.groupPath,
|
||||
to: app.groupPath,
|
||||
type: 'custom',
|
||||
contract: topModule,
|
||||
role: 'provider' as ContractRole,
|
||||
};
|
||||
links.push(link);
|
||||
}
|
||||
}
|
||||
|
||||
return { links, discoveredApps: appsByGroupPath };
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
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 GoModuleMeta {
|
||||
modulePath: string;
|
||||
groupPath: string;
|
||||
repoPath: string;
|
||||
requires: string[];
|
||||
}
|
||||
|
||||
interface ImportedSymbol {
|
||||
modulePath: string;
|
||||
symbolName: string;
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
async function parseGoMod(
|
||||
repoPath: string,
|
||||
): Promise<{ modulePath: string; requires: string[] } | null> {
|
||||
const goModPath = path.join(repoPath, 'go.mod');
|
||||
let content: string;
|
||||
try {
|
||||
content = await fs.readFile(goModPath, 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const moduleMatch = content.match(/^module\s+(\S+)/m);
|
||||
if (!moduleMatch) return null;
|
||||
const modulePath = moduleMatch[1];
|
||||
|
||||
const requires: string[] = [];
|
||||
|
||||
// Single-line: require github.com/org/repo v1.2.3
|
||||
const singleReqs = content.matchAll(/^require\s+(\S+)\s+/gm);
|
||||
for (const m of singleReqs) requires.push(m[1]);
|
||||
|
||||
// Block: require ( ... )
|
||||
const blockReqs = content.matchAll(/^require\s*\(\s*\n([\s\S]*?)\)/gm);
|
||||
for (const block of blockReqs) {
|
||||
const lines = block[1].split('\n');
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('//')) continue;
|
||||
const parts = trimmed.split(/\s+/);
|
||||
if (parts[0]) requires.push(parts[0]);
|
||||
}
|
||||
}
|
||||
|
||||
// replace directives (local path deps)
|
||||
const replaceLines = content.matchAll(/^replace\s+(\S+)\s+=>\s+\.\//gm);
|
||||
for (const m of replaceLines) {
|
||||
if (!requires.includes(m[1])) requires.push(m[1]);
|
||||
}
|
||||
|
||||
const replaceBlocks = content.matchAll(/^replace\s*\(\s*\n([\s\S]*?)\)/gm);
|
||||
for (const block of replaceBlocks) {
|
||||
const lines = block[1].split('\n');
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('//')) continue;
|
||||
const match = trimmed.match(/^(\S+)\s+=>\s+\.\//);
|
||||
if (match && !requires.includes(match[1])) requires.push(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return { modulePath, requires: [...new Set(requires)] };
|
||||
}
|
||||
|
||||
async function scanGoImports(
|
||||
repoPath: string,
|
||||
knownModules: Map<string, string>,
|
||||
): Promise<ImportedSymbol[]> {
|
||||
const results: ImportedSymbol[] = [];
|
||||
const sourceFiles = await findGoFiles(repoPath);
|
||||
|
||||
for (const relFile of sourceFiles) {
|
||||
const absPath = path.join(repoPath, relFile);
|
||||
let content: string;
|
||||
try {
|
||||
content = await fs.readFile(absPath, 'utf-8');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const importPaths = extractImportPaths(content);
|
||||
for (const importPath of importPaths) {
|
||||
const matchedModule = findMatchingModule(importPath, knownModules);
|
||||
if (!matchedModule) continue;
|
||||
|
||||
const symbols = extractUsedTypes(content, importPath);
|
||||
for (const sym of symbols) {
|
||||
results.push({ modulePath: matchedModule, symbolName: sym, filePath: relFile });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function extractImportPaths(content: string): string[] {
|
||||
const paths: string[] = [];
|
||||
|
||||
// Single: import "path"
|
||||
const singleImports = content.matchAll(/^import\s+"([^"]+)"/gm);
|
||||
for (const m of singleImports) paths.push(m[1]);
|
||||
|
||||
// Single aliased: import alias "path"
|
||||
const aliasedImports = content.matchAll(/^import\s+\w+\s+"([^"]+)"/gm);
|
||||
for (const m of aliasedImports) paths.push(m[1]);
|
||||
|
||||
// Block: import ( ... )
|
||||
const blockImports = content.matchAll(/^import\s*\(\s*\n([\s\S]*?)\)/gm);
|
||||
for (const block of blockImports) {
|
||||
const lines = block[1].split('\n');
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('//')) continue;
|
||||
const pathMatch = trimmed.match(/"([^"]+)"/);
|
||||
if (pathMatch) paths.push(pathMatch[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return [...new Set(paths)];
|
||||
}
|
||||
|
||||
function findMatchingModule(importPath: string, knownModules: Map<string, string>): string | null {
|
||||
for (const [modPath] of knownModules) {
|
||||
if (importPath === modPath || importPath.startsWith(modPath + '/')) {
|
||||
return modPath;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractUsedTypes(content: string, importPath: string): string[] {
|
||||
const pkgName = importPath.split('/').pop() || '';
|
||||
if (!pkgName) return [];
|
||||
|
||||
// Match pkg.TypeName where TypeName is PascalCase (exported)
|
||||
const typeRegex = new RegExp(`\\b${escapeRegex(pkgName)}\\.([A-Z][A-Za-z0-9]*)`, 'g');
|
||||
const types = new Set<string>();
|
||||
let match;
|
||||
while ((match = typeRegex.exec(content)) !== null) {
|
||||
types.add(match[1]);
|
||||
}
|
||||
return [...types];
|
||||
}
|
||||
|
||||
function escapeRegex(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
async function findGoFiles(repoPath: string): Promise<string[]> {
|
||||
const results: string[] = [];
|
||||
const ig = await loadIgnoreRules(repoPath);
|
||||
|
||||
async function walk(dir: string, rel: string): Promise<void> {
|
||||
let entries;
|
||||
try {
|
||||
entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const childRel = rel ? `${rel}/${entry.name}` : entry.name;
|
||||
if (entry.isDirectory()) {
|
||||
if (shouldIgnorePath(childRel)) continue;
|
||||
if (ig && ig.ignores(childRel + '/')) continue;
|
||||
await walk(path.join(dir, entry.name), childRel);
|
||||
} else if (entry.name.endsWith('.go') && !entry.name.endsWith('_test.go')) {
|
||||
if (shouldIgnorePath(childRel)) continue;
|
||||
if (ig && ig.ignores(childRel)) continue;
|
||||
results.push(childRel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await walk(repoPath, '');
|
||||
return results;
|
||||
}
|
||||
|
||||
export interface GoWorkspaceResult {
|
||||
links: GroupManifestLink[];
|
||||
discoveredModules: Map<string, GoModuleMeta>;
|
||||
}
|
||||
|
||||
export async function extractGoWorkspaceLinks(
|
||||
repos: Record<string, string>,
|
||||
repoPaths: Map<string, string>,
|
||||
_dbExecutors?: Map<string, CypherExecutor>,
|
||||
): Promise<GoWorkspaceResult> {
|
||||
const modulesByPath = new Map<string, GoModuleMeta>();
|
||||
const modulesByGroupPath = new Map<string, GoModuleMeta>();
|
||||
|
||||
for (const [groupPath] of Object.entries(repos)) {
|
||||
const repoPath = repoPaths.get(groupPath);
|
||||
if (!repoPath) continue;
|
||||
|
||||
const manifest = await parseGoMod(repoPath);
|
||||
if (!manifest) continue;
|
||||
|
||||
const meta: GoModuleMeta = {
|
||||
modulePath: manifest.modulePath,
|
||||
groupPath,
|
||||
repoPath,
|
||||
requires: manifest.requires,
|
||||
};
|
||||
const existing = modulesByPath.get(manifest.modulePath);
|
||||
if (existing) {
|
||||
logger.warn(
|
||||
`[go-workspace-extractor] duplicate module "${manifest.modulePath}" in "${groupPath}" and "${existing.groupPath}" — skipping "${groupPath}"`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
modulesByPath.set(manifest.modulePath, meta);
|
||||
modulesByGroupPath.set(groupPath, meta);
|
||||
}
|
||||
|
||||
const links: GroupManifestLink[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const [, mod] of modulesByGroupPath) {
|
||||
const groupModDeps = mod.requires.filter((r) => modulesByPath.has(r));
|
||||
if (groupModDeps.length === 0) continue;
|
||||
|
||||
const knownModules = new Map<string, string>();
|
||||
for (const dep of groupModDeps) {
|
||||
knownModules.set(dep, dep);
|
||||
}
|
||||
|
||||
const imports = await scanGoImports(mod.repoPath, knownModules);
|
||||
|
||||
for (const imp of imports) {
|
||||
const providerMod = modulesByPath.get(imp.modulePath);
|
||||
if (!providerMod) continue;
|
||||
|
||||
const qualifiedContract = `${imp.modulePath}::${imp.symbolName}`;
|
||||
const key = `${mod.groupPath}→${providerMod.groupPath}::${qualifiedContract}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
|
||||
const link: GroupManifestLink = {
|
||||
from: providerMod.groupPath,
|
||||
to: mod.groupPath,
|
||||
type: 'custom',
|
||||
contract: qualifiedContract,
|
||||
role: 'provider' as ContractRole,
|
||||
};
|
||||
links.push(link);
|
||||
}
|
||||
}
|
||||
|
||||
return { links, discoveredModules: modulesByGroupPath };
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { createIgnoreFilter } from '../../../config/ignore-service.js';
|
||||
import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js';
|
||||
import type { ExtractedContract, RepoHandle } from '../types.js';
|
||||
import { readSafe } from './fs-utils.js';
|
||||
import { logger } from '../../logger.js';
|
||||
import {
|
||||
GRPC_SCAN_GLOB,
|
||||
getPluginForFile,
|
||||
@@ -344,7 +345,7 @@ export function resolveProtoConflict(
|
||||
// services under a fabricated package-qualified contract id.
|
||||
if (winners.length !== 1) {
|
||||
const paths = candidates.map((c) => c.protoPath).join(', ');
|
||||
console.warn(
|
||||
logger.warn(
|
||||
`[grpc-extractor] Ambiguous proto resolution for service "${serviceName}" from ${sourceFilePath}: ${winners.length} candidates tied at score ${maxScore} among [${paths}] — skipping canonical contract`,
|
||||
);
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,610 @@
|
||||
import * as path from 'node:path';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import { glob } from 'glob';
|
||||
import Parser from 'tree-sitter';
|
||||
import C from 'tree-sitter-c';
|
||||
import Cpp from 'tree-sitter-cpp';
|
||||
import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js';
|
||||
import type { ExtractedContract, RepoHandle } from '../types.js';
|
||||
import { readSafe } from './fs-utils.js';
|
||||
import { buildSuffixIndex, type SuffixIndex } from '../../ingestion/import-resolvers/utils.js';
|
||||
import { createIgnoreFilter } from '../../../config/ignore-service.js';
|
||||
import { getMaxFileSizeBytes } from '../../ingestion/utils/max-file-size.js';
|
||||
import { logger } from '../../logger.js';
|
||||
|
||||
/**
|
||||
* Cross-repo C/C++ `#include` dependency extractor.
|
||||
*
|
||||
* **Provider side:** registers every `.h/.hpp/.hxx/.hh` file in the repo
|
||||
* as a provider contract with `include::<relative-path>`.
|
||||
*
|
||||
* **Consumer side:** parses all C/C++ source/header files for `#include "…"`
|
||||
* directives, attempts suffix-based resolution against the repo's own file
|
||||
* list (reusing the same algorithm as the single-repo ingestion pipeline),
|
||||
* and emits unresolved include paths as consumer contracts.
|
||||
*
|
||||
* Matching: a consumer's `include::map/base/dice_map_view.h` in repo A
|
||||
* matches a provider's `include::map/base/dice_map_view.h` in repo B via
|
||||
* exact contract-id equality in `runExactMatch`.
|
||||
*/
|
||||
|
||||
// ---------- constants ----------
|
||||
|
||||
const HEADER_EXTENSIONS = new Set(['.h', '.hpp', '.hxx', '.hh']);
|
||||
|
||||
// Source = headers (provider-eligible) ∪ implementation files (.c/.cpp/.cc/.cxx).
|
||||
// Spread keeps the subset relationship explicit so a future contributor adding
|
||||
// a new header extension to HEADER_EXTENSIONS does not have to remember to
|
||||
// also add it here.
|
||||
const SOURCE_EXTENSIONS = new Set<string>([...HEADER_EXTENSIONS, '.c', '.cpp', '.cc', '.cxx']);
|
||||
|
||||
const INCLUDE_QUERY_SRC = '(preproc_include path: (_) @import.source) @import';
|
||||
|
||||
/**
|
||||
* Well-known C/C++ standard library headers that can appear in `#include "…"`
|
||||
* form (some projects use quotes for system headers).
|
||||
*/
|
||||
const SYSTEM_HEADERS = new Set([
|
||||
// C standard
|
||||
'assert.h',
|
||||
'complex.h',
|
||||
'ctype.h',
|
||||
'errno.h',
|
||||
'fenv.h',
|
||||
'float.h',
|
||||
'inttypes.h',
|
||||
'iso646.h',
|
||||
'limits.h',
|
||||
'locale.h',
|
||||
'math.h',
|
||||
'setjmp.h',
|
||||
'signal.h',
|
||||
'stdalign.h',
|
||||
'stdarg.h',
|
||||
'stdatomic.h',
|
||||
'stdbool.h',
|
||||
'stddef.h',
|
||||
'stdint.h',
|
||||
'stdio.h',
|
||||
'stdlib.h',
|
||||
'stdnoreturn.h',
|
||||
'string.h',
|
||||
'tgmath.h',
|
||||
'threads.h',
|
||||
'time.h',
|
||||
'uchar.h',
|
||||
'wchar.h',
|
||||
'wctype.h',
|
||||
// C++ standard (extensionless)
|
||||
'algorithm',
|
||||
'any',
|
||||
'array',
|
||||
'atomic',
|
||||
'barrier',
|
||||
'bit',
|
||||
'bitset',
|
||||
'cassert',
|
||||
'cctype',
|
||||
'cerrno',
|
||||
'cfenv',
|
||||
'cfloat',
|
||||
'charconv',
|
||||
'chrono',
|
||||
'cinttypes',
|
||||
'climits',
|
||||
'clocale',
|
||||
'cmath',
|
||||
'codecvt',
|
||||
'compare',
|
||||
'complex',
|
||||
'concepts',
|
||||
'condition_variable',
|
||||
'coroutine',
|
||||
'csetjmp',
|
||||
'csignal',
|
||||
'cstdarg',
|
||||
'cstddef',
|
||||
'cstdint',
|
||||
'cstdio',
|
||||
'cstdlib',
|
||||
'cstring',
|
||||
'ctime',
|
||||
'cuchar',
|
||||
'cwchar',
|
||||
'cwctype',
|
||||
'deque',
|
||||
'exception',
|
||||
'execution',
|
||||
'expected',
|
||||
'filesystem',
|
||||
'format',
|
||||
'forward_list',
|
||||
'fstream',
|
||||
'functional',
|
||||
'future',
|
||||
'generator',
|
||||
'initializer_list',
|
||||
'iomanip',
|
||||
'ios',
|
||||
'iosfwd',
|
||||
'iostream',
|
||||
'istream',
|
||||
'iterator',
|
||||
'latch',
|
||||
'limits',
|
||||
'list',
|
||||
'locale',
|
||||
'map',
|
||||
'mdspan',
|
||||
'memory',
|
||||
'memory_resource',
|
||||
'mutex',
|
||||
'new',
|
||||
'numbers',
|
||||
'numeric',
|
||||
'optional',
|
||||
'ostream',
|
||||
'print',
|
||||
'queue',
|
||||
'random',
|
||||
'ranges',
|
||||
'ratio',
|
||||
'regex',
|
||||
'scoped_allocator',
|
||||
'semaphore',
|
||||
'set',
|
||||
'shared_mutex',
|
||||
'source_location',
|
||||
'span',
|
||||
'spanstream',
|
||||
'sstream',
|
||||
'stack',
|
||||
'stacktrace',
|
||||
'stdexcept',
|
||||
'stdfloat',
|
||||
'stop_token',
|
||||
'streambuf',
|
||||
'string',
|
||||
'string_view',
|
||||
'strstream',
|
||||
'syncstream',
|
||||
'system_error',
|
||||
'thread',
|
||||
'tuple',
|
||||
'type_traits',
|
||||
'typeindex',
|
||||
'typeinfo',
|
||||
'unordered_map',
|
||||
'unordered_set',
|
||||
'utility',
|
||||
'valarray',
|
||||
'variant',
|
||||
'vector',
|
||||
'version',
|
||||
]);
|
||||
|
||||
/** Path prefixes that indicate system/kernel headers. */
|
||||
const SYSTEM_PATH_PREFIXES = [
|
||||
'sys/',
|
||||
'net/',
|
||||
'netinet/',
|
||||
'arpa/',
|
||||
'linux/',
|
||||
'asm/',
|
||||
'bits/',
|
||||
'gnu/',
|
||||
'mach/',
|
||||
'machine/',
|
||||
'xlocale/',
|
||||
];
|
||||
|
||||
/** Regex fallback for files that exceed tree-sitter's 32 KB parse limit. */
|
||||
const INCLUDE_REGEX = /^[ \t]*#\s*include\s*"([^"]+)"/gm;
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
/**
|
||||
* Normalize an include path to a canonical lowercase forward-slash form.
|
||||
*
|
||||
* IMPORTANT — case-folding caveat (PR #1156 review finding #3):
|
||||
* Header paths are lowercased so consumer `#include "Foo/Bar.h"` and
|
||||
* provider file `Foo/Bar.h` normalize to the same contract-id. This is
|
||||
* the right trade-off on case-insensitive filesystems (macOS, Windows)
|
||||
* but on case-sensitive Linux filesystems two distinct headers `Foo.h`
|
||||
* and `foo.h` in the same repo will collide onto the same provider
|
||||
* contract-id; only one survives `dedupe()`. The gain (reliable
|
||||
* cross-platform matching) outweighs the cost (extremely rare header
|
||||
* casing collisions inside a single repo).
|
||||
*/
|
||||
function normalizeIncludePath(raw: string): string {
|
||||
return raw.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+/g, '/').toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip C/C++ block comments from a source blob. Used only by the
|
||||
* regex-fallback path to avoid emitting consumer contracts for
|
||||
* commented-out #include directives. Line comments (`// …`) cannot hide
|
||||
* #include directives because the regex anchors on start-of-line.
|
||||
* See PR #1156 review finding #5.
|
||||
*/
|
||||
function stripBlockComments(src: string): string {
|
||||
return src.replace(/\/\*[\s\S]*?\*\//g, '');
|
||||
}
|
||||
|
||||
function isAngleBracketInclude(rawNodeText: string): boolean {
|
||||
const trimmed = rawNodeText.trim();
|
||||
return trimmed.startsWith('<') && trimmed.endsWith('>');
|
||||
}
|
||||
|
||||
function isSystemHeader(cleanedPath: string): boolean {
|
||||
// Check well-known standard headers
|
||||
if (SYSTEM_HEADERS.has(cleanedPath)) return true;
|
||||
// Check system path prefixes
|
||||
const lower = cleanedPath.toLowerCase();
|
||||
return SYSTEM_PATH_PREFIXES.some((prefix) => lower.startsWith(prefix));
|
||||
}
|
||||
|
||||
function isHeaderFile(filePath: string): boolean {
|
||||
return HEADER_EXTENSIONS.has(path.extname(filePath).toLowerCase());
|
||||
}
|
||||
|
||||
function getLanguageForFile(filePath: string): unknown | null {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
switch (ext) {
|
||||
case '.c':
|
||||
case '.h':
|
||||
return C;
|
||||
case '.cpp':
|
||||
case '.cc':
|
||||
case '.cxx':
|
||||
case '.hpp':
|
||||
case '.hxx':
|
||||
case '.hh':
|
||||
return Cpp;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether an include path resolves to a file inside the local repo.
|
||||
*
|
||||
* Uses *exact full-path* matching on the suffix index — we never accept a
|
||||
* truncated suffix match. For `#include "foo/bar.h"` this checks:
|
||||
* (a) a file whose path ends with the full `foo/bar.h`
|
||||
* (b) if the include omitted the extension, a file whose path ends with
|
||||
* the include + one of the C/C++ header extensions
|
||||
*
|
||||
* Returns `true` when a local file matches — caller should suppress the
|
||||
* cross-repo consumer contract.
|
||||
*
|
||||
* See PR #1156 review finding #4 (suffixResolve ambiguity).
|
||||
*/
|
||||
function isLocalInclude(cleaned: string, suffixIndex: SuffixIndex): boolean {
|
||||
const candidates = [cleaned];
|
||||
if (!/\.[a-zA-Z0-9]+$/.test(cleaned)) {
|
||||
for (const ext of ['.h', '.hpp', '.hxx', '.hh']) candidates.push(cleaned + ext);
|
||||
}
|
||||
for (const c of candidates) {
|
||||
if (suffixIndex.get(c) || suffixIndex.getInsensitive(c)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------- main class ----------
|
||||
|
||||
export class IncludeExtractor implements ContractExtractor {
|
||||
type = 'include' as const;
|
||||
|
||||
/**
|
||||
* Always returns `true`. NOT called by `sync.ts`, which gates extraction via
|
||||
* `config.detect.includes` instead (see `sync.ts:174`). Kept solely to satisfy
|
||||
* the `ContractExtractor` interface so the type stays uniform across extractors.
|
||||
*/
|
||||
async canExtract(_repo: RepoHandle): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
async extract(
|
||||
dbExecutor: CypherExecutor | null,
|
||||
repoPath: string,
|
||||
_repo: RepoHandle,
|
||||
): Promise<ExtractedContract[]> {
|
||||
// 1. Build the local file list using the same discovery as ingestion
|
||||
// (createIgnoreFilter + getMaxFileSizeBytes). This guarantees the
|
||||
// universe of provider/consumer paths matches the universe of File
|
||||
// nodes in the LadybugDB graph — so no cross-link points at a UID
|
||||
// that group impact cannot fan out to.
|
||||
// (PR #1156 Codex follow-up: discovery aligned with ingestion.)
|
||||
const allFiles = await this.discoverIndexableFiles(repoPath);
|
||||
const normalizedFiles = allFiles.map((f) => f.replace(/\\/g, '/'));
|
||||
const suffixIndex = buildSuffixIndex(normalizedFiles, allFiles);
|
||||
|
||||
// 2. Provider: register all header files
|
||||
const providers = await this.extractProviders(dbExecutor, repoPath, allFiles);
|
||||
|
||||
// 3. Consumer: filter the shared discovery list for source extensions
|
||||
// and parse #include directives in those files.
|
||||
const sourceFiles = allFiles.filter((f) =>
|
||||
SOURCE_EXTENSIONS.has(path.extname(f).toLowerCase()),
|
||||
);
|
||||
const consumers = await this.extractConsumers(repoPath, sourceFiles, suffixIndex);
|
||||
|
||||
return this.dedupe([...providers, ...consumers]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover repo-relative file paths using exactly the same rules the
|
||||
* ingestion pipeline uses (`walkRepositoryPaths` in
|
||||
* `gitnexus/src/core/ingestion/filesystem-walker.ts`):
|
||||
* - `createIgnoreFilter` honors `.gitignore`, `.gitnexusignore`, the
|
||||
* hardcoded ignore list, and `.gitnexusignore` last-match-wins
|
||||
* negation.
|
||||
* - `getMaxFileSizeBytes()` drops files larger than the cap so we
|
||||
* never emit `File:<rel>` UIDs for files ingestion would skip.
|
||||
*
|
||||
* Uses sequential stat — there is no `READ_CONCURRENCY` batching here
|
||||
* because group sync runs at startup-time, not the ingestion hot path,
|
||||
* and parallelism gains are not worth the import-graph weight.
|
||||
*
|
||||
* MAINTENANCE: if `walkRepositoryPaths` changes its glob options, ignore
|
||||
* filter shape, or size-cap logic, mirror those changes here. The two
|
||||
* implementations exist because the consumers need different return
|
||||
* shapes (string[] vs ScannedFile[]) and different concurrency, but
|
||||
* they MUST agree on which files are reachable — that is what makes
|
||||
* `File:<rel>` UIDs in cross-links correspond to graph File nodes.
|
||||
*/
|
||||
private async discoverIndexableFiles(repoPath: string): Promise<string[]> {
|
||||
const ignoreFilter = await createIgnoreFilter(repoPath);
|
||||
const maxFileSizeBytes = getMaxFileSizeBytes();
|
||||
|
||||
const candidates = await glob('**/*', {
|
||||
cwd: repoPath,
|
||||
nodir: true,
|
||||
dot: false,
|
||||
ignore: ignoreFilter,
|
||||
});
|
||||
|
||||
const survivors: string[] = [];
|
||||
for (const rel of candidates) {
|
||||
try {
|
||||
const stat = await fs.stat(path.join(repoPath, rel));
|
||||
if (stat.size > maxFileSizeBytes) continue;
|
||||
survivors.push(rel);
|
||||
} catch (err) {
|
||||
// ENOENT is the documented benign race (glob enumerated a file
|
||||
// that was deleted before we stat'd it — same race
|
||||
// walkRepositoryPaths absorbs via Promise.allSettled). Anything
|
||||
// else (EACCES, EMFILE, EIO) deserves a warning so an operator
|
||||
// can spot a permission/resource problem instead of silently
|
||||
// shipping fewer contracts than expected.
|
||||
const code = (err as NodeJS.ErrnoException | undefined)?.code;
|
||||
if (code !== 'ENOENT') {
|
||||
logger.warn(
|
||||
{ err: (err as Error).message, file: rel, repoPath },
|
||||
'⚠️ IncludeExtractor: stat failed during discovery; skipping file',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return survivors;
|
||||
}
|
||||
|
||||
// ---------- provider extraction ----------
|
||||
|
||||
private async extractProviders(
|
||||
dbExecutor: CypherExecutor | null,
|
||||
repoPath: string,
|
||||
allFiles: string[],
|
||||
): Promise<ExtractedContract[]> {
|
||||
// Strategy A: graph-assisted
|
||||
if (dbExecutor) {
|
||||
const graphProviders = await this.extractProvidersGraph(dbExecutor, repoPath);
|
||||
if (graphProviders.length > 0) return graphProviders;
|
||||
}
|
||||
// Strategy B: filesystem fallback
|
||||
return this.extractProvidersFallback(repoPath, allFiles);
|
||||
}
|
||||
|
||||
private async extractProvidersGraph(
|
||||
db: CypherExecutor,
|
||||
repoPath: string,
|
||||
): Promise<ExtractedContract[]> {
|
||||
try {
|
||||
const rows = await db(
|
||||
`MATCH (f:File)
|
||||
WHERE f.filePath =~ '.*\\\\.(h|hpp|hxx|hh)$'
|
||||
RETURN f.filePath AS filePath, f.id AS fileId`,
|
||||
);
|
||||
// gitnexus analyze stores absolute paths in the File.filePath column.
|
||||
// Provider contract IDs MUST be repo-relative — otherwise the consumer
|
||||
// emits `include::map/base/view.h` and the provider emits
|
||||
// `include::/abs/path/to/repo/map/base/view.h`, which never match
|
||||
// through runExactMatch and the cross-link silently disappears.
|
||||
// (PR #1156 follow-up review: graph provider absolute-path bug.)
|
||||
const normalizedRepoPath = path.resolve(repoPath);
|
||||
const out: ExtractedContract[] = [];
|
||||
for (const r of rows) {
|
||||
if (typeof r.filePath !== 'string' || !r.filePath) continue;
|
||||
const absolute = r.filePath as string;
|
||||
const rel = path.relative(normalizedRepoPath, absolute);
|
||||
// Skip rows that resolve outside the repo (e.g., system headers
|
||||
// somehow indexed, or stale absolute paths from a different machine).
|
||||
// path.relative returns a `..`-prefixed path or an absolute path
|
||||
// when the target is outside the base — both are wrong for our IDs.
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) continue;
|
||||
const normalizedRel = rel.replace(/\\/g, '/');
|
||||
out.push({
|
||||
contractId: `include::${normalizeIncludePath(normalizedRel)}`,
|
||||
type: 'include' as const,
|
||||
role: 'provider' as const,
|
||||
symbolUid: String(r.fileId ?? ''),
|
||||
symbolRef: { filePath: normalizedRel, name: path.basename(normalizedRel) },
|
||||
symbolName: path.basename(normalizedRel),
|
||||
confidence: 1.0,
|
||||
meta: { source: 'graph' },
|
||||
});
|
||||
}
|
||||
return out;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private extractProvidersFallback(_repoPath: string, allFiles: string[]): ExtractedContract[] {
|
||||
return allFiles
|
||||
.filter((f) => isHeaderFile(f))
|
||||
.map((f) => {
|
||||
const filePath = f.replace(/\\/g, '/');
|
||||
return {
|
||||
contractId: `include::${normalizeIncludePath(filePath)}`,
|
||||
type: 'include' as const,
|
||||
role: 'provider' as const,
|
||||
symbolUid: `File:${filePath}`,
|
||||
symbolRef: { filePath, name: path.basename(filePath) },
|
||||
symbolName: path.basename(filePath),
|
||||
confidence: 0.95,
|
||||
meta: { source: 'filesystem' },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- consumer extraction ----------
|
||||
|
||||
private async extractConsumers(
|
||||
repoPath: string,
|
||||
sourceFiles: string[],
|
||||
suffixIndex: SuffixIndex,
|
||||
): Promise<ExtractedContract[]> {
|
||||
const parser = new Parser();
|
||||
const out: ExtractedContract[] = [];
|
||||
// Compile the include query once per grammar to avoid re-compilation per file
|
||||
const queryCache = new Map<unknown, Parser.Query>();
|
||||
|
||||
for (const rel of sourceFiles) {
|
||||
const lang = getLanguageForFile(rel);
|
||||
if (!lang) continue;
|
||||
|
||||
const content = readSafe(repoPath, rel);
|
||||
if (!content) continue;
|
||||
|
||||
let query = queryCache.get(lang);
|
||||
if (!query) {
|
||||
try {
|
||||
query = new Parser.Query(lang, INCLUDE_QUERY_SRC);
|
||||
queryCache.set(lang, query);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Collect raw include paths: tree-sitter first, regex fallback for large files.
|
||||
// `extractionSource` is stamped on each emitted consumer contract so
|
||||
// regex-fallback contracts stay auditable post-hoc (PR #1156 review finding #6).
|
||||
let rawIncludes: string[];
|
||||
let extractionSource: 'tree_sitter' | 'regex_fallback';
|
||||
try {
|
||||
parser.setLanguage(lang);
|
||||
const tree = parser.parse(content);
|
||||
let matches: Parser.QueryMatch[];
|
||||
try {
|
||||
matches = query.matches(tree.rootNode);
|
||||
} catch {
|
||||
matches = [];
|
||||
}
|
||||
rawIncludes = [];
|
||||
extractionSource = 'tree_sitter';
|
||||
for (const match of matches) {
|
||||
const sourceNode = match.captures.find((c) => c.name === 'import.source');
|
||||
if (!sourceNode) continue;
|
||||
const rawText = sourceNode.node.text;
|
||||
if (isAngleBracketInclude(rawText)) continue;
|
||||
const cleaned = rawText.replace(/['"<>]/g, '');
|
||||
if (cleaned && cleaned.length <= 2048) rawIncludes.push(cleaned);
|
||||
}
|
||||
} catch {
|
||||
// tree-sitter failed (e.g. file > 32 KB) — fall back to regex.
|
||||
// Strip block comments first so we don't emit a consumer contract
|
||||
// for a commented-out #include (PR #1156 review finding #5).
|
||||
rawIncludes = [];
|
||||
extractionSource = 'regex_fallback';
|
||||
const scanTarget = stripBlockComments(content);
|
||||
INCLUDE_REGEX.lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = INCLUDE_REGEX.exec(scanTarget)) !== null) {
|
||||
if (m[1] && m[1].length <= 2048) rawIncludes.push(m[1]);
|
||||
}
|
||||
}
|
||||
|
||||
for (const cleaned of rawIncludes) {
|
||||
// Filter: skip known system headers and system path prefixes
|
||||
if (isSystemHeader(cleaned)) continue;
|
||||
|
||||
// Skip relative-up includes: `#include "../include/foo.h"` is
|
||||
// almost always an intra-repo reference. The suffix index is built
|
||||
// from repo-relative paths, so isLocalInclude can never match
|
||||
// `../foo.h`, and emitting it as a consumer contract just pollutes
|
||||
// the registry with an entry no provider can ever satisfy.
|
||||
// (PR #1156 follow-up review: `../` relative includes produce
|
||||
// spurious consumer contracts.)
|
||||
if (cleaned.startsWith('../') || cleaned.startsWith('..\\')) continue;
|
||||
|
||||
// Skip macro-style includes: `#include PLATFORM_HEADER` parses as an
|
||||
// identifier under tree-sitter's `(_) @import.source` wildcard. The
|
||||
// identifier text passes the strip/clean step unchanged, so without
|
||||
// this guard we would emit `include::platform_header` as a consumer
|
||||
// contract — and no provider in any repo will ever expose a contract
|
||||
// for a macro identifier (no file is named `PLATFORM_HEADER`). The
|
||||
// contract would sit permanently orphaned in the registry. Real
|
||||
// header references always contain a path separator (`/`, `\`) or an
|
||||
// extension dot (`foo.h`), so an absent both is a reliable signal we
|
||||
// are looking at a macro identifier. (PR #1156 follow-up review:
|
||||
// macro includes emit orphaned consumer contracts.)
|
||||
if (!/[./\\]/.test(cleaned)) continue;
|
||||
|
||||
// Local resolution (PR #1156 review finding #4): only accept an
|
||||
// exact-suffix match on the *full* include path. The generic
|
||||
// suffixResolve() iterates all truncated suffixes, which would
|
||||
// silently suppress a cross-repo `#include "map/base/view.h"`
|
||||
// when the local repo has any `internal/view.h` — a realistic
|
||||
// false-negative in large C++ codebases. Here we only resolve
|
||||
// locally if a file path ends with the complete include string
|
||||
// (optionally re-appending one of the C/C++ header extensions
|
||||
// when the include already omits it).
|
||||
if (isLocalInclude(cleaned, suffixIndex)) continue;
|
||||
|
||||
// Unresolved: emit as consumer contract
|
||||
const normalizedRel = rel.replace(/\\/g, '/');
|
||||
out.push({
|
||||
contractId: `include::${normalizeIncludePath(cleaned)}`,
|
||||
type: 'include' as const,
|
||||
role: 'consumer' as const,
|
||||
symbolUid: `File:${normalizedRel}`,
|
||||
symbolRef: { filePath: normalizedRel, name: cleaned },
|
||||
symbolName: cleaned,
|
||||
confidence: 0.85,
|
||||
meta: {
|
||||
source: extractionSource,
|
||||
includePath: cleaned,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------- deduplication ----------
|
||||
|
||||
private dedupe(items: ExtractedContract[]): ExtractedContract[] {
|
||||
const seen = new Set<string>();
|
||||
const out: ExtractedContract[] = [];
|
||||
for (const c of items) {
|
||||
const k = `${c.contractId}|${c.role}|${c.symbolRef.filePath}`;
|
||||
if (seen.has(k)) continue;
|
||||
seen.add(k);
|
||||
out.push(c);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
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 JavaProjectMeta {
|
||||
groupId: string;
|
||||
artifactId: string;
|
||||
basePackage: string;
|
||||
groupPath: string;
|
||||
repoPath: string;
|
||||
deps: string[];
|
||||
}
|
||||
|
||||
interface ImportedSymbol {
|
||||
artifactKey: string;
|
||||
symbolName: string;
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
async function parseJavaManifest(
|
||||
repoPath: string,
|
||||
): Promise<{ groupId: string; artifactId: string; deps: string[] } | null> {
|
||||
const pomPath = path.join(repoPath, 'pom.xml');
|
||||
try {
|
||||
const content = await fs.readFile(pomPath, 'utf-8');
|
||||
return parsePom(content);
|
||||
} catch {
|
||||
// fall through to Gradle
|
||||
}
|
||||
|
||||
for (const name of ['build.gradle.kts', 'build.gradle']) {
|
||||
const gradlePath = path.join(repoPath, name);
|
||||
try {
|
||||
const content = await fs.readFile(gradlePath, 'utf-8');
|
||||
return parseGradle(content, repoPath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parsePom(content: string): { groupId: string; artifactId: string; deps: string[] } | null {
|
||||
const projectGroupMatch = content.match(/<project[^>]*>[\s\S]*?<groupId>([^<]+)<\/groupId>/);
|
||||
const projectArtifactMatch = content.match(
|
||||
/<project[^>]*>[\s\S]*?<artifactId>([^<]+)<\/artifactId>/,
|
||||
);
|
||||
if (!projectGroupMatch || !projectArtifactMatch) return null;
|
||||
|
||||
const groupId = projectGroupMatch[1].trim();
|
||||
const artifactId = projectArtifactMatch[1].trim();
|
||||
|
||||
const deps: string[] = [];
|
||||
const depBlocks = content.matchAll(/<dependency>\s*([\s\S]*?)<\/dependency>/g);
|
||||
for (const block of depBlocks) {
|
||||
const gMatch = block[1].match(/<groupId>([^<]+)<\/groupId>/);
|
||||
const aMatch = block[1].match(/<artifactId>([^<]+)<\/artifactId>/);
|
||||
if (gMatch && aMatch) {
|
||||
deps.push(`${gMatch[1].trim()}:${aMatch[1].trim()}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { groupId, artifactId, deps: [...new Set(deps)] };
|
||||
}
|
||||
|
||||
function parseGradle(
|
||||
content: string,
|
||||
repoPath: string,
|
||||
): { groupId: string; artifactId: string; deps: string[] } | null {
|
||||
const groupMatch = content.match(/group\s*=\s*['"]([^'"]+)['"]/);
|
||||
const dirName = path.basename(repoPath);
|
||||
const groupId = groupMatch ? groupMatch[1] : '';
|
||||
if (!groupId) return null;
|
||||
|
||||
const artifactId = dirName;
|
||||
|
||||
const deps: string[] = [];
|
||||
// implementation("group:artifact:version") or api("group:artifact:version")
|
||||
const depMatches = content.matchAll(
|
||||
/(?:implementation|api|compileOnly|runtimeOnly)\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
|
||||
);
|
||||
for (const m of depMatches) {
|
||||
const parts = m[1].split(':');
|
||||
if (parts.length >= 2) {
|
||||
deps.push(`${parts[0]}:${parts[1]}`);
|
||||
}
|
||||
}
|
||||
|
||||
// implementation(project(":subproject"))
|
||||
const projDeps = content.matchAll(
|
||||
/(?:implementation|api)\s*\(\s*project\s*\(\s*['"]([^'"]+)['"]\s*\)\s*\)/g,
|
||||
);
|
||||
for (const m of projDeps) {
|
||||
const subName = m[1].replace(/^:/, '');
|
||||
deps.push(`${groupId}:${subName}`);
|
||||
}
|
||||
|
||||
return { groupId, artifactId, deps: [...new Set(deps)] };
|
||||
}
|
||||
|
||||
function deriveBasePackage(groupId: string, artifactId: string): string {
|
||||
const sanitized = artifactId.replace(/-/g, '.');
|
||||
if (groupId.endsWith(`.${sanitized}`) || groupId === sanitized) {
|
||||
return groupId;
|
||||
}
|
||||
return `${groupId}.${sanitized}`;
|
||||
}
|
||||
|
||||
async function scanJavaImports(
|
||||
repoPath: string,
|
||||
knownPackages: Map<string, string>,
|
||||
): Promise<ImportedSymbol[]> {
|
||||
const results: ImportedSymbol[] = [];
|
||||
const sourceFiles = await findJavaFiles(repoPath);
|
||||
|
||||
for (const relFile of sourceFiles) {
|
||||
const absPath = path.join(repoPath, relFile);
|
||||
let content: string;
|
||||
try {
|
||||
content = await fs.readFile(absPath, 'utf-8');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const importRegex = /^import\s+(?:static\s+)?([a-zA-Z][\w.]*\.[A-Z]\w*)/gm;
|
||||
let match;
|
||||
while ((match = importRegex.exec(content)) !== null) {
|
||||
const fullImport = match[1];
|
||||
for (const [basePkg, artifactKey] of knownPackages) {
|
||||
if (fullImport.startsWith(basePkg + '.') || fullImport === basePkg) {
|
||||
const parts = fullImport.split('.');
|
||||
const className = parts[parts.length - 1];
|
||||
if (isPascalCase(className)) {
|
||||
results.push({
|
||||
artifactKey,
|
||||
symbolName: className,
|
||||
filePath: relFile,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function isPascalCase(name: string): boolean {
|
||||
return /^[A-Z][A-Za-z0-9]*$/.test(name);
|
||||
}
|
||||
|
||||
async function findJavaFiles(repoPath: string): Promise<string[]> {
|
||||
const results: string[] = [];
|
||||
const ig = await loadIgnoreRules(repoPath);
|
||||
|
||||
async function walk(dir: string, rel: string): Promise<void> {
|
||||
let entries;
|
||||
try {
|
||||
entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const childRel = rel ? `${rel}/${entry.name}` : entry.name;
|
||||
if (entry.isDirectory()) {
|
||||
if (shouldIgnorePath(childRel)) continue;
|
||||
if (ig && ig.ignores(childRel + '/')) continue;
|
||||
await walk(path.join(dir, entry.name), childRel);
|
||||
} else if (entry.name.endsWith('.java') || entry.name.endsWith('.kt')) {
|
||||
if (shouldIgnorePath(childRel)) continue;
|
||||
if (ig && ig.ignores(childRel)) continue;
|
||||
results.push(childRel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await walk(repoPath, '');
|
||||
return results;
|
||||
}
|
||||
|
||||
export interface JavaWorkspaceResult {
|
||||
links: GroupManifestLink[];
|
||||
discoveredProjects: Map<string, JavaProjectMeta>;
|
||||
}
|
||||
|
||||
export async function extractJavaWorkspaceLinks(
|
||||
repos: Record<string, string>,
|
||||
repoPaths: Map<string, string>,
|
||||
_dbExecutors?: Map<string, CypherExecutor>,
|
||||
): Promise<JavaWorkspaceResult> {
|
||||
const projectsByKey = new Map<string, JavaProjectMeta>();
|
||||
const projectsByGroupPath = new Map<string, JavaProjectMeta>();
|
||||
|
||||
for (const [groupPath] of Object.entries(repos)) {
|
||||
const repoPath = repoPaths.get(groupPath);
|
||||
if (!repoPath) continue;
|
||||
|
||||
const manifest = await parseJavaManifest(repoPath);
|
||||
if (!manifest) continue;
|
||||
|
||||
const key = `${manifest.groupId}:${manifest.artifactId}`;
|
||||
const meta: JavaProjectMeta = {
|
||||
groupId: manifest.groupId,
|
||||
artifactId: manifest.artifactId,
|
||||
basePackage: deriveBasePackage(manifest.groupId, manifest.artifactId),
|
||||
groupPath,
|
||||
repoPath,
|
||||
deps: manifest.deps,
|
||||
};
|
||||
const existing = projectsByKey.get(key);
|
||||
if (existing) {
|
||||
logger.warn(
|
||||
`[java-workspace-extractor] duplicate artifact "${key}" in "${groupPath}" and "${existing.groupPath}" — skipping "${groupPath}"`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
projectsByKey.set(key, meta);
|
||||
projectsByGroupPath.set(groupPath, meta);
|
||||
}
|
||||
|
||||
const links: GroupManifestLink[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const [, proj] of projectsByGroupPath) {
|
||||
const groupDeps = proj.deps.filter((d) => projectsByKey.has(d));
|
||||
if (groupDeps.length === 0) continue;
|
||||
|
||||
const knownPackages = new Map<string, string>();
|
||||
for (const dep of groupDeps) {
|
||||
const depMeta = projectsByKey.get(dep);
|
||||
if (depMeta) knownPackages.set(depMeta.basePackage, dep);
|
||||
}
|
||||
|
||||
const imports = await scanJavaImports(proj.repoPath, knownPackages);
|
||||
|
||||
for (const imp of imports) {
|
||||
const providerProj = projectsByKey.get(imp.artifactKey);
|
||||
if (!providerProj) continue;
|
||||
|
||||
const qualifiedContract = `${providerProj.artifactId}::${imp.symbolName}`;
|
||||
const dedupKey = `${proj.groupPath}→${providerProj.groupPath}::${qualifiedContract}`;
|
||||
if (seen.has(dedupKey)) continue;
|
||||
seen.add(dedupKey);
|
||||
|
||||
const link: GroupManifestLink = {
|
||||
from: providerProj.groupPath,
|
||||
to: proj.groupPath,
|
||||
type: 'custom',
|
||||
contract: qualifiedContract,
|
||||
role: 'provider' as ContractRole,
|
||||
};
|
||||
links.push(link);
|
||||
}
|
||||
}
|
||||
|
||||
return { links, discoveredProjects: projectsByGroupPath };
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ContractType, CrossLink, GroupManifestLink, StoredContract } from '../types.js';
|
||||
import type { CypherExecutor } from '../contract-extractor.js';
|
||||
|
||||
import { logger } from '../../logger.js';
|
||||
export interface ManifestExtractResult {
|
||||
contracts: StoredContract[];
|
||||
crossLinks: CrossLink[];
|
||||
@@ -177,7 +178,7 @@ export class ManifestExtractor {
|
||||
|
||||
// NOTE: All lookups use EXACT equality on the relevant name field and
|
||||
// deterministic ORDER BY before LIMIT 1. Previous versions used CONTAINS
|
||||
// for fuzzy matching (plus an unconditional ".proto" fallback for gRPC)
|
||||
// for fuzzy matching (plus an unconditional IDL file fallback for gRPC)
|
||||
// which produced silent false positives: e.g. manifest "/orders" would
|
||||
// match "/suborders", and a gRPC manifest entry in a repo with any
|
||||
// .proto file would attach to a random proto symbol.
|
||||
@@ -225,16 +226,21 @@ export class ManifestExtractor {
|
||||
LIMIT 1`,
|
||||
{ contract: link.contract },
|
||||
);
|
||||
} else if (link.type === 'grpc') {
|
||||
} else if (link.type === 'grpc' || link.type === 'thrift') {
|
||||
// Contract is "Service/Method" or just "Service" (or package.Service
|
||||
// variants). Prefer matching by method name when present, otherwise
|
||||
// by service name. NO .proto path fallback — that's guaranteed to
|
||||
// return a wrong symbol in any repo with more than one proto file.
|
||||
// by service name. Thrift generated Java classes often use
|
||||
// package.Service in manifests while graph Class/Interface names are
|
||||
// stored as bare Service, so strip the package prefix for thrift
|
||||
// service-name lookups. NO IDL path fallback — that's guaranteed to
|
||||
// return a wrong symbol in any repo with more than one IDL file.
|
||||
// Label filters scope lookups: methods → Function|Method, services
|
||||
// → Class|Interface (no label match = no silent wrong hits on
|
||||
// File/Variable nodes that happen to share the name).
|
||||
const parts = link.contract.split('/');
|
||||
const serviceName = parts[0]?.trim() ?? '';
|
||||
const rawServiceName = parts[0]?.trim() ?? '';
|
||||
const serviceName =
|
||||
link.type === 'thrift' ? (rawServiceName.split('.').pop() ?? '') : rawServiceName;
|
||||
const methodName = parts[1]?.trim() ?? '';
|
||||
if (methodName) {
|
||||
rows = await executor(
|
||||
@@ -268,18 +274,28 @@ export class ManifestExtractor {
|
||||
LIMIT 1`,
|
||||
{ contract: link.contract },
|
||||
);
|
||||
} else if (link.type === 'include') {
|
||||
rows = await executor(
|
||||
`MATCH (f:File) WHERE f.filePath = $contract
|
||||
RETURN f.id AS uid, f.name AS name, f.filePath AS filePath
|
||||
ORDER BY f.filePath ASC
|
||||
LIMIT 1`,
|
||||
{ contract: link.contract },
|
||||
);
|
||||
} else if (link.type === 'custom') {
|
||||
// V1: exact name-only match on code-definition nodes.
|
||||
// Positive allowlist mirrors other contract types. If multiple code
|
||||
// symbols share the same name, ORDER BY filePath ASC LIMIT 1 picks
|
||||
// the alphabetically-first occurrence deterministically.
|
||||
// Workspace extractors produce qualified contracts like "mathlex::Expression".
|
||||
// Graph nodes store the unqualified symbol name ("Expression"), so strip
|
||||
// the "provider::" prefix before querying.
|
||||
const symbolName = link.contract.includes('::')
|
||||
? link.contract.split('::').pop()!
|
||||
: link.contract;
|
||||
rows = await executor(
|
||||
`MATCH (n:Function|Method|Class|Interface|Struct|Enum|Trait|Constructor|TypeAlias|Impl|Macro|Union|Typedef|Property|Record|Delegate|Annotation|Template|Const|Static|CodeElement)
|
||||
WHERE n.name = $contract
|
||||
WHERE n.name = $symbolName
|
||||
RETURN n.id AS uid, n.name AS name, n.filePath AS filePath
|
||||
ORDER BY n.filePath ASC
|
||||
LIMIT 1`,
|
||||
{ contract: link.contract },
|
||||
{ symbolName },
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
@@ -296,7 +312,7 @@ export class ManifestExtractor {
|
||||
// fail the whole manifest extraction. Unresolved contracts still
|
||||
// get a synthetic symbolUid below, so cross-impact can proceed.
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.warn(
|
||||
logger.warn(
|
||||
`[manifest-extractor] resolveSymbol failed for ${link.type}:${link.contract} ` +
|
||||
`in ${repoPathKey}: ${message}`,
|
||||
);
|
||||
@@ -342,12 +358,16 @@ export class ManifestExtractor {
|
||||
}
|
||||
case 'grpc':
|
||||
return `grpc::${contract}`;
|
||||
case 'thrift':
|
||||
return `thrift::${contract}`;
|
||||
case 'topic':
|
||||
return `topic::${contract}`;
|
||||
case 'lib':
|
||||
return `lib::${contract}`;
|
||||
case 'custom':
|
||||
return `custom::${contract}`;
|
||||
case 'include':
|
||||
return `include::${contract}`;
|
||||
default: {
|
||||
const _exhaustive: never = type;
|
||||
throw new Error(`Unhandled ContractType: ${String(_exhaustive)}`);
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
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 PackageMeta {
|
||||
name: string;
|
||||
groupPath: string;
|
||||
repoPath: string;
|
||||
workspaceDeps: string[];
|
||||
}
|
||||
|
||||
interface ImportedSymbol {
|
||||
packageName: string;
|
||||
symbolName: string;
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
async function parsePackageManifest(
|
||||
repoPath: string,
|
||||
): Promise<{ name: string; workspaceDeps: string[] } | null> {
|
||||
const pkgPath = path.join(repoPath, 'package.json');
|
||||
let content: string;
|
||||
try {
|
||||
content = await fs.readFile(pkgPath, 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
let pkg: Record<string, unknown>;
|
||||
try {
|
||||
pkg = JSON.parse(content);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const name = typeof pkg.name === 'string' ? pkg.name : '';
|
||||
if (!name) return null;
|
||||
|
||||
const deps: string[] = [];
|
||||
for (const field of ['dependencies', 'devDependencies', 'peerDependencies']) {
|
||||
const section = pkg[field];
|
||||
if (section && typeof section === 'object') {
|
||||
deps.push(...Object.keys(section as Record<string, unknown>));
|
||||
}
|
||||
}
|
||||
|
||||
return { name, workspaceDeps: [...new Set(deps)] };
|
||||
}
|
||||
|
||||
async function scanImports(
|
||||
repoPath: string,
|
||||
knownPackages: Set<string>,
|
||||
): Promise<ImportedSymbol[]> {
|
||||
const results: ImportedSymbol[] = [];
|
||||
const sourceFiles = await findSourceFiles(repoPath);
|
||||
|
||||
for (const relFile of sourceFiles) {
|
||||
const absPath = path.join(repoPath, relFile);
|
||||
let content: string;
|
||||
try {
|
||||
content = await fs.readFile(absPath, 'utf-8');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
// ES import: import { Foo, Bar } from '<pkg>'
|
||||
// Also: import { Foo as Baz } from '<pkg>'
|
||||
const esImportRegex = /^import\s+\{([^}]+)\}\s+from\s+['"]([^'"]+)['"]/gm;
|
||||
let match;
|
||||
while ((match = esImportRegex.exec(content)) !== null) {
|
||||
const importClause = match[1];
|
||||
const modulePath = match[2];
|
||||
const pkgName = resolvePackageName(modulePath);
|
||||
if (!pkgName || !knownPackages.has(pkgName)) continue;
|
||||
|
||||
const symbols = parseImportClause(importClause);
|
||||
for (const sym of symbols) {
|
||||
if (isExportedName(sym)) {
|
||||
results.push({ packageName: pkgName, symbolName: sym, filePath: relFile });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ES import default: import Foo from '<pkg>'
|
||||
const defaultImportRegex = /^import\s+([A-Z][A-Za-z0-9]*)\s+from\s+['"]([^'"]+)['"]/gm;
|
||||
while ((match = defaultImportRegex.exec(content)) !== null) {
|
||||
const symbolName = match[1];
|
||||
const modulePath = match[2];
|
||||
const pkgName = resolvePackageName(modulePath);
|
||||
if (!pkgName || !knownPackages.has(pkgName)) continue;
|
||||
|
||||
if (isExportedName(symbolName)) {
|
||||
results.push({ packageName: pkgName, symbolName, filePath: relFile });
|
||||
}
|
||||
}
|
||||
|
||||
// CommonJS: const { Foo, Bar } = require('<pkg>')
|
||||
const cjsRegex = /(?:const|let|var)\s+\{([^}]+)\}\s*=\s*require\s*\(\s*['"]([^'"]+)['"]\s*\)/gm;
|
||||
while ((match = cjsRegex.exec(content)) !== null) {
|
||||
const importClause = match[1];
|
||||
const modulePath = match[2];
|
||||
const pkgName = resolvePackageName(modulePath);
|
||||
if (!pkgName || !knownPackages.has(pkgName)) continue;
|
||||
|
||||
const symbols = parseImportClause(importClause);
|
||||
for (const sym of symbols) {
|
||||
if (isExportedName(sym)) {
|
||||
results.push({ packageName: pkgName, symbolName: sym, filePath: relFile });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function resolvePackageName(modulePath: string): string | null {
|
||||
if (modulePath.startsWith('.') || modulePath.startsWith('/')) return null;
|
||||
// Scoped: @scope/pkg or @scope/pkg/sub
|
||||
if (modulePath.startsWith('@')) {
|
||||
const parts = modulePath.split('/');
|
||||
if (parts.length >= 2) return `${parts[0]}/${parts[1]}`;
|
||||
return null;
|
||||
}
|
||||
// Unscoped: pkg or pkg/sub
|
||||
return modulePath.split('/')[0];
|
||||
}
|
||||
|
||||
function parseImportClause(clause: string): string[] {
|
||||
return clause
|
||||
.split(',')
|
||||
.map((s) => {
|
||||
const trimmed = s.trim();
|
||||
// Handle `Foo as Bar` — use the original export name
|
||||
const asMatch = trimmed.match(/^(\S+)\s+as\s+/);
|
||||
return asMatch ? asMatch[1] : trimmed;
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function isExportedName(name: string): boolean {
|
||||
return /^[A-Z][A-Za-z0-9]*$/.test(name);
|
||||
}
|
||||
|
||||
async function findSourceFiles(repoPath: string): Promise<string[]> {
|
||||
const results: string[] = [];
|
||||
const EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.mts', '.cts']);
|
||||
const ig = await loadIgnoreRules(repoPath);
|
||||
|
||||
async function walk(dir: string, rel: string): Promise<void> {
|
||||
let entries;
|
||||
try {
|
||||
entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const childRel = rel ? `${rel}/${entry.name}` : entry.name;
|
||||
if (entry.isDirectory()) {
|
||||
if (shouldIgnorePath(childRel)) continue;
|
||||
if (ig && ig.ignores(childRel + '/')) continue;
|
||||
await walk(path.join(dir, entry.name), childRel);
|
||||
} else {
|
||||
const ext = path.extname(entry.name);
|
||||
if (EXTENSIONS.has(ext)) {
|
||||
if (shouldIgnorePath(childRel)) continue;
|
||||
if (ig && ig.ignores(childRel)) continue;
|
||||
results.push(childRel);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await walk(repoPath, '');
|
||||
return results;
|
||||
}
|
||||
|
||||
export interface NodeWorkspaceResult {
|
||||
links: GroupManifestLink[];
|
||||
discoveredPackages: Map<string, PackageMeta>;
|
||||
}
|
||||
|
||||
export async function extractNodeWorkspaceLinks(
|
||||
repos: Record<string, string>,
|
||||
repoPaths: Map<string, string>,
|
||||
_dbExecutors?: Map<string, CypherExecutor>,
|
||||
): Promise<NodeWorkspaceResult> {
|
||||
const packagesByName = new Map<string, PackageMeta>();
|
||||
const packagesByGroupPath = new Map<string, PackageMeta>();
|
||||
|
||||
for (const [groupPath] of Object.entries(repos)) {
|
||||
const repoPath = repoPaths.get(groupPath);
|
||||
if (!repoPath) continue;
|
||||
|
||||
const manifest = await parsePackageManifest(repoPath);
|
||||
if (!manifest) continue;
|
||||
|
||||
const meta: PackageMeta = {
|
||||
name: manifest.name,
|
||||
groupPath,
|
||||
repoPath,
|
||||
workspaceDeps: manifest.workspaceDeps,
|
||||
};
|
||||
const existing = packagesByName.get(manifest.name);
|
||||
if (existing) {
|
||||
logger.warn(
|
||||
`[node-workspace-extractor] duplicate package name "${manifest.name}" in "${groupPath}" and "${existing.groupPath}" — skipping "${groupPath}"`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
packagesByName.set(manifest.name, meta);
|
||||
packagesByGroupPath.set(groupPath, meta);
|
||||
}
|
||||
|
||||
const links: GroupManifestLink[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const [, pkg] of packagesByGroupPath) {
|
||||
const groupPkgDeps = pkg.workspaceDeps.filter((d) => packagesByName.has(d));
|
||||
if (groupPkgDeps.length === 0) continue;
|
||||
|
||||
const knownPackages = new Set(groupPkgDeps);
|
||||
const imports = await scanImports(pkg.repoPath, knownPackages);
|
||||
|
||||
for (const imp of imports) {
|
||||
const providerPkg = packagesByName.get(imp.packageName);
|
||||
if (!providerPkg) continue;
|
||||
|
||||
const qualifiedContract = `${imp.packageName}::${imp.symbolName}`;
|
||||
const key = `${pkg.groupPath}→${providerPkg.groupPath}::${qualifiedContract}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
|
||||
const link: GroupManifestLink = {
|
||||
from: providerPkg.groupPath,
|
||||
to: pkg.groupPath,
|
||||
type: 'custom',
|
||||
contract: qualifiedContract,
|
||||
role: 'provider' as ContractRole,
|
||||
};
|
||||
links.push(link);
|
||||
}
|
||||
}
|
||||
|
||||
return { links, discoveredPackages: packagesByGroupPath };
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
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 PythonPackageMeta {
|
||||
name: string;
|
||||
importName: string;
|
||||
groupPath: string;
|
||||
repoPath: string;
|
||||
workspaceDeps: string[];
|
||||
}
|
||||
|
||||
interface ImportedSymbol {
|
||||
packageName: string;
|
||||
symbolName: string;
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
async function parsePythonManifest(
|
||||
repoPath: string,
|
||||
): Promise<{ name: string; importName: string; deps: string[] } | null> {
|
||||
const pyprojectPath = path.join(repoPath, 'pyproject.toml');
|
||||
let content: string | null = null;
|
||||
try {
|
||||
content = await fs.readFile(pyprojectPath, 'utf-8');
|
||||
} catch {
|
||||
// fall through to setup.py
|
||||
}
|
||||
|
||||
if (content) return parsePyproject(content);
|
||||
|
||||
const setupPyPath = path.join(repoPath, 'setup.py');
|
||||
try {
|
||||
content = await fs.readFile(setupPyPath, 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return parseSetupPy(content);
|
||||
}
|
||||
|
||||
function parsePyproject(
|
||||
content: string,
|
||||
): { name: string; importName: string; deps: string[] } | null {
|
||||
const nameMatch = content.match(/^\[project\]\s*\n(?:[^\n\[]*\n)*?name\s*=\s*"([^"]+)"/m);
|
||||
if (!nameMatch) return null;
|
||||
const name = nameMatch[1];
|
||||
const importName = name.replace(/-/g, '_');
|
||||
|
||||
const deps: string[] = [];
|
||||
const depsMatch = content.match(/^\[project\]\s*\n[\s\S]*?dependencies\s*=\s*\[([\s\S]*?)\]/m);
|
||||
if (depsMatch) {
|
||||
const depLines = depsMatch[1].matchAll(/"([^"]+)"/g);
|
||||
for (const m of depLines) {
|
||||
deps.push(extractPepName(m[1]));
|
||||
}
|
||||
}
|
||||
|
||||
const optMatch = content.match(/\[project\.optional-dependencies\]\s*\n([\s\S]*?)(?=\n\[|$)/);
|
||||
if (optMatch) {
|
||||
const optDeps = optMatch[1].matchAll(/"([^"]+)"/g);
|
||||
for (const m of optDeps) {
|
||||
deps.push(extractPepName(m[1]));
|
||||
}
|
||||
}
|
||||
|
||||
return { name, importName, deps: [...new Set(deps)] };
|
||||
}
|
||||
|
||||
function parseSetupPy(
|
||||
content: string,
|
||||
): { name: string; importName: string; deps: string[] } | null {
|
||||
const nameMatch = content.match(/name\s*=\s*['"]([^'"]+)['"]/);
|
||||
if (!nameMatch) return null;
|
||||
const name = nameMatch[1];
|
||||
const importName = name.replace(/-/g, '_');
|
||||
|
||||
const deps: string[] = [];
|
||||
const installMatch = content.match(/install_requires\s*=\s*\[([\s\S]*?)\]/);
|
||||
if (installMatch) {
|
||||
const depLines = installMatch[1].matchAll(/['"]([^'"]+)['"]/g);
|
||||
for (const m of depLines) {
|
||||
deps.push(extractPepName(m[1]));
|
||||
}
|
||||
}
|
||||
|
||||
return { name, importName, deps: [...new Set(deps)] };
|
||||
}
|
||||
|
||||
function extractPepName(spec: string): string {
|
||||
return spec.split(/[><=!~;\[]/)[0].trim();
|
||||
}
|
||||
|
||||
async function scanPythonImports(
|
||||
repoPath: string,
|
||||
knownPackages: Map<string, string>,
|
||||
): Promise<ImportedSymbol[]> {
|
||||
const results: ImportedSymbol[] = [];
|
||||
const sourceFiles = await findPythonFiles(repoPath);
|
||||
|
||||
for (const relFile of sourceFiles) {
|
||||
const absPath = path.join(repoPath, relFile);
|
||||
let content: string;
|
||||
try {
|
||||
content = await fs.readFile(absPath, 'utf-8');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
// from <pkg> import Foo, Bar
|
||||
// from <pkg>.module import Foo
|
||||
const fromImportRegex = /^from\s+(\w[\w.]*)\s+import\s+(.+)/gm;
|
||||
let match;
|
||||
while ((match = fromImportRegex.exec(content)) !== null) {
|
||||
const modulePath = match[1];
|
||||
const importClause = match[2];
|
||||
const rootModule = modulePath.split('.')[0];
|
||||
const originalName = knownPackages.get(rootModule);
|
||||
if (!originalName) continue;
|
||||
|
||||
if (importClause.trim() === '(') continue;
|
||||
|
||||
const symbols = importClause
|
||||
.replace(/\(|\)/g, '')
|
||||
.split(',')
|
||||
.map((s) => {
|
||||
const trimmed = s.trim();
|
||||
const asMatch = trimmed.match(/^(\S+)\s+as\s+/);
|
||||
return asMatch ? asMatch[1] : trimmed;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
for (const sym of symbols) {
|
||||
if (isPascalCase(sym)) {
|
||||
results.push({ packageName: originalName, symbolName: sym, filePath: relFile });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function isPascalCase(name: string): boolean {
|
||||
return /^[A-Z][A-Za-z0-9]*$/.test(name);
|
||||
}
|
||||
|
||||
async function findPythonFiles(repoPath: string): Promise<string[]> {
|
||||
const results: string[] = [];
|
||||
const ig = await loadIgnoreRules(repoPath);
|
||||
|
||||
async function walk(dir: string, rel: string): Promise<void> {
|
||||
let entries;
|
||||
try {
|
||||
entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const childRel = rel ? `${rel}/${entry.name}` : entry.name;
|
||||
if (entry.isDirectory()) {
|
||||
if (shouldIgnorePath(childRel)) continue;
|
||||
if (ig && ig.ignores(childRel + '/')) continue;
|
||||
await walk(path.join(dir, entry.name), childRel);
|
||||
} else if (entry.name.endsWith('.py')) {
|
||||
if (shouldIgnorePath(childRel)) continue;
|
||||
if (ig && ig.ignores(childRel)) continue;
|
||||
results.push(childRel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await walk(repoPath, '');
|
||||
return results;
|
||||
}
|
||||
|
||||
export interface PythonWorkspaceResult {
|
||||
links: GroupManifestLink[];
|
||||
discoveredPackages: Map<string, PythonPackageMeta>;
|
||||
}
|
||||
|
||||
export async function extractPythonWorkspaceLinks(
|
||||
repos: Record<string, string>,
|
||||
repoPaths: Map<string, string>,
|
||||
_dbExecutors?: Map<string, CypherExecutor>,
|
||||
): Promise<PythonWorkspaceResult> {
|
||||
const packagesByImportName = new Map<string, PythonPackageMeta>();
|
||||
const packagesByGroupPath = new Map<string, PythonPackageMeta>();
|
||||
|
||||
for (const [groupPath] of Object.entries(repos)) {
|
||||
const repoPath = repoPaths.get(groupPath);
|
||||
if (!repoPath) continue;
|
||||
|
||||
const manifest = await parsePythonManifest(repoPath);
|
||||
if (!manifest) continue;
|
||||
|
||||
const meta: PythonPackageMeta = {
|
||||
name: manifest.name,
|
||||
importName: manifest.importName,
|
||||
groupPath,
|
||||
repoPath,
|
||||
workspaceDeps: manifest.deps,
|
||||
};
|
||||
const existing = packagesByImportName.get(manifest.importName);
|
||||
if (existing) {
|
||||
logger.warn(
|
||||
`[python-workspace-extractor] duplicate package "${manifest.name}" in "${groupPath}" and "${existing.groupPath}" — skipping "${groupPath}"`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
packagesByImportName.set(manifest.importName, meta);
|
||||
packagesByGroupPath.set(groupPath, meta);
|
||||
}
|
||||
|
||||
const links: GroupManifestLink[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const [, pkg] of packagesByGroupPath) {
|
||||
const normalizedDeps = pkg.workspaceDeps.map((d) => d.replace(/-/g, '_'));
|
||||
const groupPkgDeps = normalizedDeps.filter((d) => packagesByImportName.has(d));
|
||||
if (groupPkgDeps.length === 0) continue;
|
||||
|
||||
const knownPackages = new Map<string, string>();
|
||||
for (const dep of groupPkgDeps) {
|
||||
const meta = packagesByImportName.get(dep);
|
||||
if (meta) knownPackages.set(dep, meta.name);
|
||||
}
|
||||
|
||||
const imports = await scanPythonImports(pkg.repoPath, knownPackages);
|
||||
|
||||
for (const imp of imports) {
|
||||
const providerImportName = imp.packageName.replace(/-/g, '_');
|
||||
const providerPkg = packagesByImportName.get(providerImportName);
|
||||
if (!providerPkg) continue;
|
||||
|
||||
const qualifiedContract = `${providerPkg.name}::${imp.symbolName}`;
|
||||
const key = `${pkg.groupPath}→${providerPkg.groupPath}::${qualifiedContract}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
|
||||
const link: GroupManifestLink = {
|
||||
from: providerPkg.groupPath,
|
||||
to: pkg.groupPath,
|
||||
type: 'custom',
|
||||
contract: qualifiedContract,
|
||||
role: 'provider' as ContractRole,
|
||||
};
|
||||
links.push(link);
|
||||
}
|
||||
}
|
||||
|
||||
return { links, discoveredPackages: packagesByGroupPath };
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type { GroupManifestLink, ContractRole } from '../types.js';
|
||||
import { shouldIgnorePath } from '../../../config/ignore-service.js';
|
||||
import { loadIgnoreRules } from '../../../config/ignore-service.js';
|
||||
|
||||
import { logger } from '../../logger.js';
|
||||
/**
|
||||
* Discover cross-crate contracts in a Rust workspace by reading each
|
||||
* member's `Cargo.toml` dependencies and scanning source files for
|
||||
@@ -30,6 +31,32 @@ interface ImportedSymbol {
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Linear-time `[package].name = "..."` lookup. The previous regex
|
||||
* `^\[package\]\s*\n(?:[^\[]*?\n)*?name\s*=\s*"([^"]+)"` had a nested
|
||||
* lazy quantifier on `\n` that CodeQL js/redos flagged as exponential
|
||||
* on inputs like `[package]\n` + many bare `\n`. We walk lines
|
||||
* explicitly: scan from the first `[package]` header until we hit the
|
||||
* next `[...]` section header, looking for the `name = "..."` line.
|
||||
* O(n) with the line count.
|
||||
*
|
||||
* Exported so the U8 ReDoS regression test can drive the production
|
||||
* line-walk directly with adversarial fixtures (multi-line strings,
|
||||
* trailing sections, etc.) instead of duplicating it inline.
|
||||
*/
|
||||
export function parseCargoPackageName(content: string): string | null {
|
||||
const lines = content.split('\n');
|
||||
const packageStart = lines.findIndex((l) => l.trim() === '[package]');
|
||||
if (packageStart < 0) return null;
|
||||
for (let i = packageStart + 1; i < lines.length; i++) {
|
||||
const line = lines[i].trimStart();
|
||||
if (line.startsWith('[')) break; // hit the next section header
|
||||
const m = /^name\s*=\s*"([^"]+)"/.exec(line);
|
||||
if (m) return m[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a Cargo.toml to extract the crate name and workspace dependency
|
||||
* names. Uses simple line-based parsing — no TOML library needed for
|
||||
@@ -46,12 +73,9 @@ async function parseCrateManifest(
|
||||
return null;
|
||||
}
|
||||
|
||||
let name = '';
|
||||
const name = parseCargoPackageName(content) ?? '';
|
||||
const workspaceDeps: string[] = [];
|
||||
|
||||
const nameMatch = content.match(/^\[package\]\s*\n(?:[^\[]*?\n)*?name\s*=\s*"([^"]+)"/m);
|
||||
if (nameMatch) name = nameMatch[1];
|
||||
|
||||
// Match dependencies that use workspace = true, which indicates they
|
||||
// are workspace-internal deps:
|
||||
// dep_name = { workspace = true }
|
||||
@@ -224,7 +248,7 @@ export async function extractRustWorkspaceLinks(
|
||||
};
|
||||
const existing = cratesByName.get(manifest.name);
|
||||
if (existing) {
|
||||
console.warn(
|
||||
logger.warn(
|
||||
`[rust-workspace-extractor] duplicate crate name "${manifest.name}" in "${groupPath}" and "${existing.groupPath}" — skipping "${groupPath}"`,
|
||||
);
|
||||
continue;
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
import { glob } from 'glob';
|
||||
import Parser from 'tree-sitter';
|
||||
import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js';
|
||||
import type { ExtractedContract, RepoHandle } from '../types.js';
|
||||
import { readSafe } from './fs-utils.js';
|
||||
import {
|
||||
getPluginForFile,
|
||||
THRIFT_SCAN_GLOB,
|
||||
type ThriftDetection,
|
||||
} from './thrift-patterns/index.js';
|
||||
|
||||
export interface ThriftServiceInfo {
|
||||
namespace: string;
|
||||
serviceName: string;
|
||||
methods: string[];
|
||||
thriftPath: string;
|
||||
}
|
||||
|
||||
export interface ThriftContext {
|
||||
namespacesByThrift: Map<string, string>;
|
||||
servicesByName: Map<string, ThriftServiceInfo[]>;
|
||||
}
|
||||
|
||||
function normalizeThriftPath(rel: string): string {
|
||||
return rel.replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
export function thriftMethodContractId(
|
||||
namespace: string,
|
||||
serviceName: string,
|
||||
methodName: string,
|
||||
): string {
|
||||
const prefix = namespace ? `${namespace}.${serviceName}` : serviceName;
|
||||
return `thrift::${prefix}/${methodName}`;
|
||||
}
|
||||
|
||||
export function thriftServiceContractId(namespace: string, serviceName: string): string {
|
||||
const prefix = namespace ? `${namespace}.${serviceName}` : serviceName;
|
||||
return `thrift::${prefix}/*`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace Thrift comments and string literals with spaces while preserving
|
||||
* newlines and character offsets. Service block scanning can then count braces
|
||||
* without being confused by examples or comments inside the IDL.
|
||||
*/
|
||||
function stripThriftCommentsAndStrings(content: string): string {
|
||||
const out = new Array<string>(content.length);
|
||||
let i = 0;
|
||||
|
||||
while (i < content.length) {
|
||||
const ch = content[i];
|
||||
const next = content[i + 1];
|
||||
|
||||
if (ch === '/' && next === '/') {
|
||||
out[i] = ' ';
|
||||
out[i + 1] = ' ';
|
||||
i += 2;
|
||||
while (i < content.length && content[i] !== '\n') {
|
||||
out[i] = content[i] === '\r' ? '\r' : ' ';
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '#') {
|
||||
out[i] = ' ';
|
||||
i++;
|
||||
while (i < content.length && content[i] !== '\n') {
|
||||
out[i] = content[i] === '\r' ? '\r' : ' ';
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '/' && next === '*') {
|
||||
out[i] = ' ';
|
||||
out[i + 1] = ' ';
|
||||
i += 2;
|
||||
while (i < content.length) {
|
||||
if (content[i] === '*' && content[i + 1] === '/') {
|
||||
out[i] = ' ';
|
||||
out[i + 1] = ' ';
|
||||
i += 2;
|
||||
break;
|
||||
}
|
||||
out[i] = content[i] === '\n' || content[i] === '\r' ? content[i] : ' ';
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '"' || ch === "'") {
|
||||
const quote = ch;
|
||||
out[i] = ' ';
|
||||
i++;
|
||||
while (i < content.length) {
|
||||
const c = content[i];
|
||||
if (c === '\\' && i + 1 < content.length) {
|
||||
out[i] = ' ';
|
||||
out[i + 1] = ' ';
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (c === quote) {
|
||||
out[i] = ' ';
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
out[i] = c === '\n' || c === '\r' ? c : ' ';
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
out[i] = ch;
|
||||
i++;
|
||||
}
|
||||
|
||||
return out.join('');
|
||||
}
|
||||
|
||||
function extractNamespace(sanitizedContent: string): string {
|
||||
const namespaces: Array<{ language: string; namespace: string }> = [];
|
||||
const namespaceRe = /^\s*namespace\s+([A-Za-z_*][\w.*-]*)\s+([A-Za-z_][\w.]*)\s*$/gm;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = namespaceRe.exec(sanitizedContent)) !== null) {
|
||||
namespaces.push({ language: match[1], namespace: match[2] });
|
||||
}
|
||||
|
||||
return (
|
||||
namespaces.find((entry) => entry.language === 'java')?.namespace ??
|
||||
namespaces[0]?.namespace ??
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
function extractServiceBlocks(sanitizedContent: string): Array<{ name: string; body: string }> {
|
||||
const results: Array<{ name: string; body: string }> = [];
|
||||
const headerRe = /service\s+([A-Za-z_]\w*)\s*(?:extends\s+[A-Za-z_][\w.]*)?\s*\{/g;
|
||||
let headerMatch: RegExpExecArray | null;
|
||||
|
||||
while ((headerMatch = headerRe.exec(sanitizedContent)) !== null) {
|
||||
const serviceName = headerMatch[1];
|
||||
const bodyStart = headerMatch.index + headerMatch[0].length;
|
||||
let depth = 1;
|
||||
let pos = bodyStart;
|
||||
|
||||
while (pos < sanitizedContent.length && depth > 0) {
|
||||
const ch = sanitizedContent[pos];
|
||||
if (ch === '{') depth++;
|
||||
else if (ch === '}') depth--;
|
||||
pos++;
|
||||
}
|
||||
|
||||
if (depth !== 0) continue;
|
||||
|
||||
results.push({
|
||||
name: serviceName,
|
||||
body: sanitizedContent.slice(bodyStart, pos - 1),
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function extractMethods(sanitizedServiceBody: string): string[] {
|
||||
const methods: string[] = [];
|
||||
const methodRe =
|
||||
/(?:^|[;,\n\r])\s*(?:oneway\s+)?[A-Za-z_][\w.]*(?:\s*<[^(){};]*>)?\s+([A-Za-z_]\w*)\s*\(/g;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = methodRe.exec(sanitizedServiceBody)) !== null) {
|
||||
methods.push(match[1]);
|
||||
}
|
||||
|
||||
return methods;
|
||||
}
|
||||
|
||||
function thriftSourceScanSymbolUid(
|
||||
contractId: string,
|
||||
role: 'provider' | 'consumer',
|
||||
filePath: string,
|
||||
symbolName: string,
|
||||
): string {
|
||||
const contractKey = contractId.startsWith('thrift::')
|
||||
? contractId.slice('thrift::'.length)
|
||||
: contractId;
|
||||
return ['source-scan::thrift', role, contractKey, normalizeThriftPath(filePath), symbolName].join(
|
||||
'::',
|
||||
);
|
||||
}
|
||||
|
||||
function makeContract(
|
||||
cid: string,
|
||||
role: 'provider' | 'consumer',
|
||||
filePath: string,
|
||||
symbolName: string,
|
||||
confidence: number,
|
||||
meta: Record<string, unknown>,
|
||||
): ExtractedContract {
|
||||
return {
|
||||
contractId: cid,
|
||||
type: 'thrift',
|
||||
role,
|
||||
symbolUid: thriftSourceScanSymbolUid(cid, role, filePath, symbolName),
|
||||
symbolRef: { filePath: normalizeThriftPath(filePath), name: symbolName },
|
||||
symbolName,
|
||||
confidence,
|
||||
meta: { ...meta, extractionStrategy: 'source_scan' },
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildThriftContext(repoPath: string): Promise<ThriftContext> {
|
||||
const thriftFiles = await glob('**/*.thrift', {
|
||||
cwd: repoPath,
|
||||
absolute: false,
|
||||
nodir: true,
|
||||
// TODO(#1156-followup): replace this hand-rolled list with createIgnoreFilter
|
||||
// (the canonical ingestion ignore filter, like include-extractor.ts now uses).
|
||||
// New entries to DEFAULT_IGNORE_LIST in src/config/ignore-service.ts (e.g.
|
||||
// third_party, 3rdparty added in commit a9936a9b) silently do not apply here.
|
||||
ignore: ['**/node_modules/**', '**/.git/**', '**/vendor/**', '**/dist/**', '**/build/**'],
|
||||
});
|
||||
const namespacesByThrift = new Map<string, string>();
|
||||
const servicesByName = new Map<string, ThriftServiceInfo[]>();
|
||||
|
||||
for (const rel of thriftFiles) {
|
||||
const thriftPath = normalizeThriftPath(rel);
|
||||
const content = readSafe(repoPath, rel);
|
||||
if (!content) continue;
|
||||
|
||||
const sanitized = stripThriftCommentsAndStrings(content);
|
||||
const namespace = extractNamespace(sanitized);
|
||||
namespacesByThrift.set(thriftPath, namespace);
|
||||
|
||||
for (const block of extractServiceBlocks(sanitized)) {
|
||||
const methods = extractMethods(block.body);
|
||||
const info: ThriftServiceInfo = {
|
||||
namespace,
|
||||
serviceName: block.name,
|
||||
methods,
|
||||
thriftPath,
|
||||
};
|
||||
const existing = servicesByName.get(block.name) ?? [];
|
||||
existing.push(info);
|
||||
servicesByName.set(block.name, existing);
|
||||
}
|
||||
}
|
||||
|
||||
return { namespacesByThrift, servicesByName };
|
||||
}
|
||||
|
||||
export class ThriftExtractor implements ContractExtractor {
|
||||
type = 'thrift' as const;
|
||||
|
||||
async canExtract(_repo: RepoHandle): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
async extract(
|
||||
_dbExecutor: CypherExecutor | null,
|
||||
repoPath: string,
|
||||
_repo: RepoHandle,
|
||||
): Promise<ExtractedContract[]> {
|
||||
const out: ExtractedContract[] = [];
|
||||
const context = await buildThriftContext(repoPath);
|
||||
|
||||
for (const infos of context.servicesByName.values()) {
|
||||
for (const info of infos) {
|
||||
for (const methodName of info.methods) {
|
||||
const symbolName = `${info.serviceName}.${methodName}`;
|
||||
out.push(
|
||||
makeContract(
|
||||
thriftMethodContractId(info.namespace, info.serviceName, methodName),
|
||||
'provider',
|
||||
info.thriftPath,
|
||||
symbolName,
|
||||
0.85,
|
||||
{
|
||||
namespace: info.namespace,
|
||||
service: info.serviceName,
|
||||
method: methodName,
|
||||
source: 'thrift_idl',
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sourceFiles = await glob(THRIFT_SCAN_GLOB, {
|
||||
cwd: repoPath,
|
||||
absolute: false,
|
||||
nodir: true,
|
||||
// TODO(#1156-followup): replace this hand-rolled list with createIgnoreFilter
|
||||
// (the canonical ingestion ignore filter, like include-extractor.ts now uses).
|
||||
// New entries to DEFAULT_IGNORE_LIST in src/config/ignore-service.ts (e.g.
|
||||
// third_party, 3rdparty added in commit a9936a9b) silently do not apply here.
|
||||
ignore: ['**/node_modules/**', '**/.git/**', '**/vendor/**', '**/dist/**', '**/build/**'],
|
||||
});
|
||||
|
||||
const parser = new Parser();
|
||||
for (const rel of sourceFiles) {
|
||||
const plugin = getPluginForFile(rel);
|
||||
if (!plugin) continue;
|
||||
const content = readSafe(repoPath, rel);
|
||||
if (!content) continue;
|
||||
|
||||
let detections: ThriftDetection[] = [];
|
||||
try {
|
||||
parser.setLanguage(plugin.language);
|
||||
const tree = parser.parse(content);
|
||||
detections = plugin.scan(tree);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const detection of detections) {
|
||||
const contract = this.detectionToContract(detection, rel, context);
|
||||
if (contract) out.push(contract);
|
||||
}
|
||||
}
|
||||
|
||||
return this.dedupe(out);
|
||||
}
|
||||
|
||||
private detectionToContract(
|
||||
detection: ThriftDetection,
|
||||
filePath: string,
|
||||
context: ThriftContext,
|
||||
): ExtractedContract | null {
|
||||
const candidates = context.servicesByName.get(detection.serviceName) ?? [];
|
||||
if (candidates.length > 1) return null;
|
||||
|
||||
const info = candidates[0];
|
||||
if (info) {
|
||||
if (!info.methods.includes(detection.methodName)) return null;
|
||||
return makeContract(
|
||||
thriftMethodContractId(info.namespace, info.serviceName, detection.methodName),
|
||||
detection.role,
|
||||
filePath,
|
||||
detection.symbolName,
|
||||
detection.confidenceWithIdl,
|
||||
{
|
||||
namespace: info.namespace,
|
||||
service: info.serviceName,
|
||||
method: detection.methodName,
|
||||
source: detection.source,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
detection.role !== 'consumer' ||
|
||||
!detection.methodName ||
|
||||
!detection.usesGeneratedServiceMember
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return makeContract(
|
||||
thriftMethodContractId('', detection.serviceName, detection.methodName),
|
||||
detection.role,
|
||||
filePath,
|
||||
detection.symbolName,
|
||||
detection.confidenceWithoutIdl,
|
||||
{
|
||||
service: detection.serviceName,
|
||||
method: detection.methodName,
|
||||
source: 'java_thrift_consumer_weak',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private dedupe(items: ExtractedContract[]): ExtractedContract[] {
|
||||
const byKey = new Map<string, ExtractedContract>();
|
||||
for (const c of items) {
|
||||
const key = `${c.contractId}|${c.role}|${c.symbolRef.filePath}|${c.symbolName}`;
|
||||
const existing = byKey.get(key);
|
||||
if (!existing || c.confidence > existing.confidence) {
|
||||
byKey.set(key, c);
|
||||
}
|
||||
}
|
||||
return Array.from(byKey.values());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import * as path from 'node:path';
|
||||
import type { ThriftLanguagePlugin } from './types.js';
|
||||
import { JAVA_THRIFT_PLUGIN } from './java.js';
|
||||
|
||||
export type { ThriftDetection, ThriftLanguagePlugin, ThriftRole } from './types.js';
|
||||
|
||||
const REGISTRY: Record<string, ThriftLanguagePlugin> = {
|
||||
'.java': JAVA_THRIFT_PLUGIN,
|
||||
};
|
||||
|
||||
export const THRIFT_SCAN_GLOB = '**/*.java';
|
||||
|
||||
export function getPluginForFile(rel: string): ThriftLanguagePlugin | undefined {
|
||||
const ext = path.extname(rel).toLowerCase();
|
||||
return REGISTRY[ext];
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import Parser from 'tree-sitter';
|
||||
import Java from 'tree-sitter-java';
|
||||
import {
|
||||
compilePatterns,
|
||||
runCompiledPatterns,
|
||||
type LanguagePatterns,
|
||||
} from '../tree-sitter-scanner.js';
|
||||
import type { ThriftDetection, ThriftLanguagePlugin } from './types.js';
|
||||
|
||||
const GENERATED_MEMBER_TYPES = new Set(['Iface', 'Client']);
|
||||
const SERVICE_TYPE_RE = /^[A-Z][A-Za-z0-9]*(?:Service|Management)$/;
|
||||
|
||||
interface VariableBinding {
|
||||
name: string;
|
||||
serviceName: string;
|
||||
usesGeneratedServiceMember: boolean;
|
||||
scopeStart: number;
|
||||
scopeEnd: number;
|
||||
declarationEnd: number;
|
||||
scopeSize: number;
|
||||
}
|
||||
|
||||
interface ServiceTypeMatch {
|
||||
serviceName: string;
|
||||
usesGeneratedServiceMember: boolean;
|
||||
}
|
||||
|
||||
const VARIABLE_PATTERNS = compilePatterns({
|
||||
name: 'java-thrift-variables',
|
||||
language: Java,
|
||||
patterns: [
|
||||
{
|
||||
meta: {},
|
||||
query: `
|
||||
(field_declaration
|
||||
type: (_) @type
|
||||
declarator: (variable_declarator
|
||||
name: (identifier) @var))
|
||||
`,
|
||||
},
|
||||
{
|
||||
meta: {},
|
||||
query: `
|
||||
(local_variable_declaration
|
||||
type: (_) @type
|
||||
declarator: (variable_declarator
|
||||
name: (identifier) @var))
|
||||
`,
|
||||
},
|
||||
{
|
||||
meta: {},
|
||||
query: `
|
||||
(formal_parameter
|
||||
type: (_) @type
|
||||
name: (identifier) @var)
|
||||
`,
|
||||
},
|
||||
],
|
||||
} satisfies LanguagePatterns<Record<string, never>>);
|
||||
|
||||
const CALL_PATTERNS = compilePatterns({
|
||||
name: 'java-thrift-method-calls',
|
||||
language: Java,
|
||||
patterns: [
|
||||
{
|
||||
meta: {},
|
||||
query: `
|
||||
(method_invocation
|
||||
object: (identifier) @receiver
|
||||
name: (identifier) @method)
|
||||
`,
|
||||
},
|
||||
{
|
||||
meta: {},
|
||||
query: `
|
||||
(method_invocation
|
||||
object: (field_access
|
||||
object: (this)
|
||||
field: (identifier) @receiver)
|
||||
name: (identifier) @method)
|
||||
`,
|
||||
},
|
||||
],
|
||||
} satisfies LanguagePatterns<Record<string, never>>);
|
||||
|
||||
const PROVIDER_PATTERNS = compilePatterns({
|
||||
name: 'java-thrift-providers',
|
||||
language: Java,
|
||||
patterns: [
|
||||
{
|
||||
meta: {},
|
||||
query: `
|
||||
(class_declaration
|
||||
name: (identifier) @class_name
|
||||
(super_interfaces
|
||||
(type_list
|
||||
(_) @type))
|
||||
body: (class_body) @body) @class
|
||||
`,
|
||||
},
|
||||
],
|
||||
} satisfies LanguagePatterns<Record<string, never>>);
|
||||
|
||||
function serviceFromType(typeText: string): ServiceTypeMatch | null {
|
||||
const segments = typeText.split('.').filter((segment) => segment.length > 0);
|
||||
const last = segments.at(-1);
|
||||
const service = segments.at(-2);
|
||||
if (last && service && GENERATED_MEMBER_TYPES.has(last)) {
|
||||
return { serviceName: service, usesGeneratedServiceMember: true };
|
||||
}
|
||||
return last && SERVICE_TYPE_RE.test(last)
|
||||
? { serviceName: last, usesGeneratedServiceMember: false }
|
||||
: null;
|
||||
}
|
||||
|
||||
function methodNamesInClassBody(body: Parser.SyntaxNode): string[] {
|
||||
const names: string[] = [];
|
||||
for (let i = 0; i < body.namedChildCount; i++) {
|
||||
const child = body.namedChild(i);
|
||||
if (!child || child.type !== 'method_declaration') continue;
|
||||
const name = child.childForFieldName('name');
|
||||
if (name?.text) names.push(name.text);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
function nearestAncestor(node: Parser.SyntaxNode, types: Set<string>): Parser.SyntaxNode | null {
|
||||
let current: Parser.SyntaxNode | null = node;
|
||||
while (current) {
|
||||
if (types.has(current.type)) return current;
|
||||
current = current.parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function bindingScope(varNode: Parser.SyntaxNode): {
|
||||
scope: Parser.SyntaxNode;
|
||||
declarationEnd: number;
|
||||
} | null {
|
||||
const declaration = nearestAncestor(
|
||||
varNode,
|
||||
new Set(['field_declaration', 'local_variable_declaration', 'formal_parameter']),
|
||||
);
|
||||
if (!declaration) return null;
|
||||
|
||||
if (declaration.type === 'field_declaration') {
|
||||
const classBody = nearestAncestor(declaration, new Set(['class_body']));
|
||||
if (!classBody) return null;
|
||||
return { scope: classBody, declarationEnd: 0 };
|
||||
}
|
||||
|
||||
if (declaration.type === 'formal_parameter') {
|
||||
const callable = nearestAncestor(
|
||||
declaration,
|
||||
new Set(['method_declaration', 'constructor_declaration']),
|
||||
);
|
||||
if (!callable) return null;
|
||||
return { scope: callable, declarationEnd: 0 };
|
||||
}
|
||||
|
||||
const block = nearestAncestor(declaration, new Set(['block']));
|
||||
if (!block) return null;
|
||||
return { scope: block, declarationEnd: declaration.endIndex };
|
||||
}
|
||||
|
||||
function resolveServiceForReceiver(
|
||||
bindings: VariableBinding[],
|
||||
receiver: string,
|
||||
callNode: Parser.SyntaxNode,
|
||||
): VariableBinding | null {
|
||||
const callStart = callNode.startIndex;
|
||||
const candidates = bindings.filter(
|
||||
(binding) =>
|
||||
binding.name === receiver &&
|
||||
binding.scopeStart <= callStart &&
|
||||
callStart <= binding.scopeEnd &&
|
||||
binding.declarationEnd <= callStart,
|
||||
);
|
||||
candidates.sort((a, b) => {
|
||||
if (a.scopeSize !== b.scopeSize) return a.scopeSize - b.scopeSize;
|
||||
return b.declarationEnd - a.declarationEnd;
|
||||
});
|
||||
return candidates[0] ?? null;
|
||||
}
|
||||
|
||||
export const JAVA_THRIFT_PLUGIN: ThriftLanguagePlugin = {
|
||||
name: 'java-thrift',
|
||||
language: Java,
|
||||
scan(tree) {
|
||||
const out: ThriftDetection[] = [];
|
||||
const bindings: VariableBinding[] = [];
|
||||
|
||||
for (const match of runCompiledPatterns(VARIABLE_PATTERNS, tree)) {
|
||||
const typeNode = match.captures.type;
|
||||
const varNode = match.captures.var;
|
||||
if (!typeNode || !varNode) continue;
|
||||
const service = serviceFromType(typeNode.text);
|
||||
if (!service) continue;
|
||||
const scope = bindingScope(varNode);
|
||||
if (!scope) continue;
|
||||
bindings.push({
|
||||
name: varNode.text,
|
||||
serviceName: service.serviceName,
|
||||
usesGeneratedServiceMember: service.usesGeneratedServiceMember,
|
||||
scopeStart: scope.scope.startIndex,
|
||||
scopeEnd: scope.scope.endIndex,
|
||||
declarationEnd: scope.declarationEnd,
|
||||
scopeSize: scope.scope.endIndex - scope.scope.startIndex,
|
||||
});
|
||||
}
|
||||
|
||||
for (const match of runCompiledPatterns(CALL_PATTERNS, tree)) {
|
||||
const receiver = match.captures.receiver?.text;
|
||||
const methodName = match.captures.method?.text;
|
||||
const callNode = match.captures.receiver?.parent;
|
||||
if (!receiver || !methodName) continue;
|
||||
if (!callNode) continue;
|
||||
const binding = resolveServiceForReceiver(bindings, receiver, callNode);
|
||||
if (!binding) continue;
|
||||
out.push({
|
||||
role: 'consumer',
|
||||
serviceName: binding.serviceName,
|
||||
methodName,
|
||||
symbolName: `${receiver}.${methodName}`,
|
||||
source: 'java_thrift_consumer',
|
||||
confidenceWithIdl: 0.75,
|
||||
confidenceWithoutIdl: 0.45,
|
||||
usesGeneratedServiceMember: binding.usesGeneratedServiceMember,
|
||||
});
|
||||
}
|
||||
|
||||
const emittedProviders = new Set<string>();
|
||||
for (const match of runCompiledPatterns(PROVIDER_PATTERNS, tree)) {
|
||||
const typeNode = match.captures.type;
|
||||
const bodyNode = match.captures.body;
|
||||
if (!typeNode || !bodyNode) continue;
|
||||
const service = serviceFromType(typeNode.text);
|
||||
if (!service) continue;
|
||||
|
||||
for (const methodName of methodNamesInClassBody(bodyNode)) {
|
||||
const key = `${service.serviceName}.${methodName}`;
|
||||
if (emittedProviders.has(key)) continue;
|
||||
emittedProviders.add(key);
|
||||
out.push({
|
||||
role: 'provider',
|
||||
serviceName: service.serviceName,
|
||||
methodName,
|
||||
symbolName: `${service.serviceName}.${methodName}`,
|
||||
source: 'java_thrift_provider',
|
||||
confidenceWithIdl: 0.8,
|
||||
confidenceWithoutIdl: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import type Parser from 'tree-sitter';
|
||||
|
||||
export type ThriftRole = 'provider' | 'consumer';
|
||||
|
||||
export interface ThriftDetection {
|
||||
role: ThriftRole;
|
||||
serviceName: string;
|
||||
methodName: string;
|
||||
symbolName: string;
|
||||
source: string;
|
||||
confidenceWithIdl: number;
|
||||
confidenceWithoutIdl: number;
|
||||
usesGeneratedServiceMember?: boolean;
|
||||
}
|
||||
|
||||
export interface ThriftLanguagePlugin {
|
||||
name: string;
|
||||
language: unknown;
|
||||
scan(tree: Parser.Tree): ThriftDetection[];
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { CypherExecutor } from '../contract-extractor.js';
|
||||
import type { GroupManifestLink } from '../types.js';
|
||||
import { extractRustWorkspaceLinks } from './rust-workspace-extractor.js';
|
||||
import { extractNodeWorkspaceLinks } from './node-workspace-extractor.js';
|
||||
import { extractPythonWorkspaceLinks } from './python-workspace-extractor.js';
|
||||
import { extractGoWorkspaceLinks } from './go-workspace-extractor.js';
|
||||
import { extractJavaWorkspaceLinks } from './java-workspace-extractor.js';
|
||||
import { extractElixirWorkspaceLinks } from './elixir-workspace-extractor.js';
|
||||
|
||||
export interface WorkspaceDiscoveryResult {
|
||||
links: GroupManifestLink[];
|
||||
stats: WorkspaceExtractorStats[];
|
||||
}
|
||||
|
||||
interface WorkspaceExtractorStats {
|
||||
ecosystem: string;
|
||||
linkCount: number;
|
||||
projectCount: number;
|
||||
}
|
||||
|
||||
export async function discoverWorkspaceLinks(
|
||||
repos: Record<string, string>,
|
||||
repoPaths: Map<string, string>,
|
||||
dbExecutors?: Map<string, CypherExecutor>,
|
||||
): Promise<WorkspaceDiscoveryResult> {
|
||||
const links: GroupManifestLink[] = [];
|
||||
const stats: WorkspaceExtractorStats[] = [];
|
||||
|
||||
const rustResult = await extractRustWorkspaceLinks(repos, repoPaths, dbExecutors);
|
||||
if (rustResult.links.length > 0) {
|
||||
links.push(...rustResult.links);
|
||||
stats.push({
|
||||
ecosystem: 'Rust',
|
||||
linkCount: rustResult.links.length,
|
||||
projectCount: rustResult.discoveredCrates.size,
|
||||
});
|
||||
}
|
||||
|
||||
const nodeResult = await extractNodeWorkspaceLinks(repos, repoPaths, dbExecutors);
|
||||
if (nodeResult.links.length > 0) {
|
||||
links.push(...nodeResult.links);
|
||||
stats.push({
|
||||
ecosystem: 'Node',
|
||||
linkCount: nodeResult.links.length,
|
||||
projectCount: nodeResult.discoveredPackages.size,
|
||||
});
|
||||
}
|
||||
|
||||
const pyResult = await extractPythonWorkspaceLinks(repos, repoPaths, dbExecutors);
|
||||
if (pyResult.links.length > 0) {
|
||||
links.push(...pyResult.links);
|
||||
stats.push({
|
||||
ecosystem: 'Python',
|
||||
linkCount: pyResult.links.length,
|
||||
projectCount: pyResult.discoveredPackages.size,
|
||||
});
|
||||
}
|
||||
|
||||
const goResult = await extractGoWorkspaceLinks(repos, repoPaths, dbExecutors);
|
||||
if (goResult.links.length > 0) {
|
||||
links.push(...goResult.links);
|
||||
stats.push({
|
||||
ecosystem: 'Go',
|
||||
linkCount: goResult.links.length,
|
||||
projectCount: goResult.discoveredModules.size,
|
||||
});
|
||||
}
|
||||
|
||||
const javaResult = await extractJavaWorkspaceLinks(repos, repoPaths, dbExecutors);
|
||||
if (javaResult.links.length > 0) {
|
||||
links.push(...javaResult.links);
|
||||
stats.push({
|
||||
ecosystem: 'Java',
|
||||
linkCount: javaResult.links.length,
|
||||
projectCount: javaResult.discoveredProjects.size,
|
||||
});
|
||||
}
|
||||
|
||||
const elixirResult = await extractElixirWorkspaceLinks(repos, repoPaths, dbExecutors);
|
||||
if (elixirResult.links.length > 0) {
|
||||
links.push(...elixirResult.links);
|
||||
stats.push({
|
||||
ecosystem: 'Elixir',
|
||||
linkCount: elixirResult.links.length,
|
||||
projectCount: elixirResult.discoveredApps.size,
|
||||
});
|
||||
}
|
||||
|
||||
return { links, stats };
|
||||
}
|
||||
@@ -10,8 +10,8 @@ export interface WildcardMatchResult {
|
||||
remaining: StoredContract[];
|
||||
}
|
||||
|
||||
function isGrpcWildcard(cid: string): boolean {
|
||||
return cid.startsWith('grpc::') && cid.endsWith('/*');
|
||||
function isServiceWildcard(cid: string): boolean {
|
||||
return (cid.startsWith('grpc::') || cid.startsWith('thrift::')) && cid.endsWith('/*');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -69,8 +69,9 @@ export function normalizeContractId(id: string): string {
|
||||
}
|
||||
return id;
|
||||
}
|
||||
case 'grpc': {
|
||||
// Canonical form: `grpc::<lowercased-package-or-service>[/<method>]`.
|
||||
case 'grpc':
|
||||
case 'thrift': {
|
||||
// Canonical form: `<type>::<lowercased-package-or-service>[/<method>]`.
|
||||
//
|
||||
// The package/service segment is lowercased because gRPC package
|
||||
// names are effectively case-insensitive across language bindings
|
||||
@@ -84,27 +85,30 @@ export function normalizeContractId(id: string): string {
|
||||
// as DISTINCT canonical forms: `grpc::userservice` does not match
|
||||
// `grpc::userservice/Login`. That's by design — callers that want
|
||||
// service-level manifest matching against method-level providers
|
||||
// should use the gRPC wildcard form `grpc::UserService/*` which is
|
||||
// should use the service wildcard form `grpc::UserService/*` or
|
||||
// `thrift::UserService/*` which is
|
||||
// handled by runWildcardMatch below.
|
||||
const slashIdx = rest.indexOf('/');
|
||||
if (slashIdx > 0) {
|
||||
const pkg = rest.substring(0, slashIdx).toLowerCase();
|
||||
const method = rest.substring(slashIdx);
|
||||
return `grpc::${pkg}${method}`;
|
||||
return `${type}::${pkg}${method}`;
|
||||
}
|
||||
if (slashIdx === 0) {
|
||||
// Malformed "/method" with leading slash — keep as-is so two
|
||||
// equally malformed ids can still match each other.
|
||||
return `grpc::${rest}`;
|
||||
return `${type}::${rest}`;
|
||||
}
|
||||
// No slash: package/service only. Lowercase to match the package
|
||||
// segment produced by the pkg/method branch above.
|
||||
return `grpc::${rest.toLowerCase()}`;
|
||||
return `${type}::${rest.toLowerCase()}`;
|
||||
}
|
||||
case 'topic':
|
||||
return `topic::${rest.trim().toLowerCase()}`;
|
||||
case 'lib':
|
||||
return `lib::${rest.toLowerCase()}`;
|
||||
case 'include':
|
||||
return `include::${rest.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+/g, '/').toLowerCase()}`;
|
||||
default:
|
||||
return id;
|
||||
}
|
||||
@@ -125,6 +129,32 @@ function findMatchingKeys(contractId: string, index: Map<string, StoredContract[
|
||||
return matches;
|
||||
}
|
||||
|
||||
if (normalized.startsWith('thrift::')) {
|
||||
const rest = normalized.substring('thrift::'.length);
|
||||
const slashIdx = rest.indexOf('/');
|
||||
if (slashIdx > 0) {
|
||||
const service = rest.substring(0, slashIdx);
|
||||
const method = rest.substring(slashIdx + 1);
|
||||
if (!service.includes('.') && method && method !== '*') {
|
||||
const matches: string[] = [];
|
||||
for (const key of index.keys()) {
|
||||
if (!key.startsWith('thrift::') || key.endsWith('/*')) continue;
|
||||
const providerRest = key.substring('thrift::'.length);
|
||||
const providerSlashIdx = providerRest.indexOf('/');
|
||||
if (providerSlashIdx < 0) continue;
|
||||
const providerService = providerRest.substring(0, providerSlashIdx);
|
||||
const providerMethod = providerRest.substring(providerSlashIdx + 1);
|
||||
if (providerMethod !== method) continue;
|
||||
if (providerService === service || providerService.endsWith('.' + service)) {
|
||||
matches.push(key);
|
||||
}
|
||||
}
|
||||
matches.sort();
|
||||
return matches.length === 1 ? matches : [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -152,8 +182,9 @@ export function runExactMatch(
|
||||
const isNoisy = buildNoisyContractFilter(matchingConfig);
|
||||
const index = providerIndex ?? buildProviderIndex(contracts, matchingConfig);
|
||||
|
||||
// Skip service wildcard consumers — they go to wildcard pass only
|
||||
const consumers = contracts.filter(
|
||||
(c) => c.role === 'consumer' && !isGrpcWildcard(c.contractId) && !isNoisy(c.contractId),
|
||||
(c) => c.role === 'consumer' && !isServiceWildcard(c.contractId) && !isNoisy(c.contractId),
|
||||
);
|
||||
|
||||
const matched: CrossLink[] = [];
|
||||
@@ -198,15 +229,15 @@ export function runExactMatch(
|
||||
|
||||
// normalUnmatched: contracts that weren't matched in exact pass
|
||||
const normalUnmatched = contracts.filter((c) => {
|
||||
if (isGrpcWildcard(c.contractId)) return false; // excluded from exact, handled separately
|
||||
if (isServiceWildcard(c.contractId)) return false; // excluded from exact, handled separately
|
||||
if (isNoisy(c.contractId)) return false; // excluded from matching — don't surface as unmatched
|
||||
const id = `${c.repo}::${c.contractId}`;
|
||||
return c.role === 'provider' ? !matchedProviderIds.has(id) : !matchedConsumerIds.has(id);
|
||||
});
|
||||
|
||||
// Re-add gRPC wildcard contracts — they were never in exact matching
|
||||
const grpcWildcards = contracts.filter((c) => isGrpcWildcard(c.contractId));
|
||||
const unmatched = [...normalUnmatched, ...grpcWildcards];
|
||||
// Re-add service wildcard contracts — they were never in exact matching
|
||||
const serviceWildcards = contracts.filter((c) => isServiceWildcard(c.contractId));
|
||||
const unmatched = [...normalUnmatched, ...serviceWildcards];
|
||||
|
||||
return { matched, unmatched };
|
||||
}
|
||||
@@ -216,21 +247,28 @@ export function runWildcardMatch(
|
||||
providerIndex: Map<string, StoredContract[]>,
|
||||
): WildcardMatchResult {
|
||||
const wildcardConsumers = unmatched.filter(
|
||||
(c) => c.role === 'consumer' && isGrpcWildcard(c.contractId),
|
||||
(c) => c.role === 'consumer' && isServiceWildcard(c.contractId),
|
||||
);
|
||||
const matched: CrossLink[] = [];
|
||||
const matchedConsumerIds = new Set<string>();
|
||||
|
||||
for (const consumer of wildcardConsumers) {
|
||||
const normalized = normalizeContractId(consumer.contractId);
|
||||
const typeEnd = normalized.indexOf('::');
|
||||
const consumerType = normalized.slice(0, typeEnd);
|
||||
// "grpc::com.example.userservice/*" → "com.example.userservice"
|
||||
// "grpc::userservice/*" → "userservice"
|
||||
const fqService = normalized.slice(normalized.indexOf('::') + 2, -2); // strip "grpc::" and "/*"
|
||||
// "thrift::userservice/*" → "userservice"
|
||||
const fqService = normalized.slice(typeEnd + 2, -2); // strip "<type>::" and "/*"
|
||||
const candidateProviders: StoredContract[] = [];
|
||||
const matchedProviderServices = new Set<string>();
|
||||
|
||||
for (const [key, providers] of providerIndex) {
|
||||
// Only match against non-wildcard gRPC providers (method-level IDs)
|
||||
if (!key.startsWith('grpc::') || key.endsWith('/*')) continue;
|
||||
const afterPrefix = key.slice(6); // strip "grpc::"
|
||||
// Only match against non-wildcard same-type providers (method-level IDs).
|
||||
const keyTypeEnd = key.indexOf('::');
|
||||
if (keyTypeEnd < 0 || key.endsWith('/*')) continue;
|
||||
const providerType = key.slice(0, keyTypeEnd);
|
||||
if (providerType !== consumerType) continue;
|
||||
const afterPrefix = key.slice(keyTypeEnd + 2); // strip "<type>::"
|
||||
const slashIdx = afterPrefix.indexOf('/');
|
||||
if (slashIdx < 0) continue;
|
||||
const providerFqService = afterPrefix.slice(0, slashIdx);
|
||||
@@ -242,39 +280,46 @@ export function runWildcardMatch(
|
||||
|
||||
if (!isMatch) continue;
|
||||
|
||||
for (const provider of providers) {
|
||||
// Skip same-repo same-service (same logic as runExactMatch)
|
||||
if (provider.repo === consumer.repo) {
|
||||
if (!provider.service || !consumer.service || provider.service === consumer.service) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
matchedProviderServices.add(providerFqService);
|
||||
candidateProviders.push(...providers);
|
||||
}
|
||||
|
||||
matched.push({
|
||||
from: {
|
||||
repo: consumer.repo,
|
||||
service: consumer.service,
|
||||
symbolUid: consumer.symbolUid,
|
||||
symbolRef: consumer.symbolRef,
|
||||
},
|
||||
to: {
|
||||
repo: provider.repo,
|
||||
service: provider.service,
|
||||
symbolUid: provider.symbolUid,
|
||||
symbolRef: provider.symbolRef,
|
||||
},
|
||||
type: consumer.type,
|
||||
contractId: consumer.contractId, // consumer's wildcard ID
|
||||
matchType: 'wildcard',
|
||||
confidence: Math.min(provider.confidence, consumer.confidence),
|
||||
});
|
||||
matchedConsumerIds.add(`${consumer.repo}::${consumer.contractId}`);
|
||||
if (consumerType === 'thrift' && !fqService.includes('.') && matchedProviderServices.size > 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const provider of candidateProviders) {
|
||||
// Skip same-repo same-service (same logic as runExactMatch)
|
||||
if (provider.repo === consumer.repo) {
|
||||
if (!provider.service || !consumer.service || provider.service === consumer.service) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
matched.push({
|
||||
from: {
|
||||
repo: consumer.repo,
|
||||
service: consumer.service,
|
||||
symbolUid: consumer.symbolUid,
|
||||
symbolRef: consumer.symbolRef,
|
||||
},
|
||||
to: {
|
||||
repo: provider.repo,
|
||||
service: provider.service,
|
||||
symbolUid: provider.symbolUid,
|
||||
symbolRef: provider.symbolRef,
|
||||
},
|
||||
type: consumer.type,
|
||||
contractId: consumer.contractId, // consumer's wildcard ID
|
||||
matchType: 'wildcard',
|
||||
confidence: Math.min(provider.confidence, consumer.confidence),
|
||||
});
|
||||
matchedConsumerIds.add(`${consumer.repo}::${consumer.contractId}`);
|
||||
}
|
||||
}
|
||||
|
||||
const remaining = unmatched.filter((c) => {
|
||||
if (c.role !== 'consumer' || !isGrpcWildcard(c.contractId)) return true;
|
||||
if (c.role !== 'consumer' || !isServiceWildcard(c.contractId)) return true;
|
||||
return !matchedConsumerIds.has(`${c.repo}::${c.contractId}`);
|
||||
});
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from './group-path-utils.js';
|
||||
import { getDefaultGitnexusDir, getGroupDir, listGroups, readContractRegistry } from './storage.js';
|
||||
import { syncGroup } from './sync.js';
|
||||
import { logger } from '../logger.js';
|
||||
import type {
|
||||
ContractRegistry,
|
||||
CrossLink,
|
||||
@@ -64,6 +65,15 @@ export interface GroupToolPort {
|
||||
relationTypes: string[];
|
||||
minConfidence: number;
|
||||
includeTests: boolean;
|
||||
// Optional cancellation signal. Callers (notably the cross-impact
|
||||
// Phase-2 fanout) wrap this call in a Promise.race against a
|
||||
// setTimeout-driven AbortController so a single hung neighbor
|
||||
// cannot exceed the request's clamped timeout budget. Implementors
|
||||
// may honor the signal cooperatively or simply let the caller's
|
||||
// race resolve the await — the latter is sufficient for the
|
||||
// resource-exhaustion mitigation. When the signal is absent or
|
||||
// already aborted at call time, behavior is unchanged.
|
||||
signal?: AbortSignal;
|
||||
},
|
||||
): Promise<unknown | null>;
|
||||
context(
|
||||
@@ -170,11 +180,11 @@ async function loadContractRegistryResilient(
|
||||
contracts.push(row);
|
||||
} else {
|
||||
skippedCorrupt++;
|
||||
console.warn('[group] skipping corrupt contract row in contracts.json');
|
||||
logger.warn('[group] skipping corrupt contract row in contracts.json');
|
||||
}
|
||||
} catch {
|
||||
skippedCorrupt++;
|
||||
console.warn('[group] skipping corrupt contract row in contracts.json');
|
||||
logger.warn('[group] skipping corrupt contract row in contracts.json');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -187,11 +197,11 @@ async function loadContractRegistryResilient(
|
||||
crossLinks.push(row);
|
||||
} else {
|
||||
skippedCorrupt++;
|
||||
console.warn('[group] skipping corrupt crossLinks row in contracts.json');
|
||||
logger.warn('[group] skipping corrupt crossLinks row in contracts.json');
|
||||
}
|
||||
} catch {
|
||||
skippedCorrupt++;
|
||||
console.warn('[group] skipping corrupt crossLinks row in contracts.json');
|
||||
logger.warn('[group] skipping corrupt crossLinks row in contracts.json');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,18 @@ import * as fs from 'node:fs';
|
||||
import * as fsp from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import type { ContractRegistry } from './types.js';
|
||||
import { retryRename } from './bridge-db.js';
|
||||
|
||||
/**
|
||||
* Build an unpredictable suffix for atomic-write tmp files. Replaces the
|
||||
* previous `Date.now()` pattern which CodeQL flagged as
|
||||
* js/insecure-temporary-file: a guessable suffix in a writable directory
|
||||
* lets a co-located attacker pre-create or symlink the tmp path before the
|
||||
* write lands.
|
||||
*/
|
||||
const tmpSuffix = (): string => randomBytes(8).toString('hex');
|
||||
|
||||
const CONTRACTS_FILE = 'contracts.json';
|
||||
|
||||
@@ -34,10 +45,28 @@ export async function writeContractRegistry(
|
||||
registry: ContractRegistry,
|
||||
): Promise<void> {
|
||||
const targetPath = path.join(groupDir, CONTRACTS_FILE);
|
||||
const tmpPath = `${targetPath}.tmp.${Date.now()}`;
|
||||
const tmpPath = `${targetPath}.tmp.${tmpSuffix()}`;
|
||||
|
||||
await fsp.writeFile(tmpPath, JSON.stringify(registry, null, 2), 'utf-8');
|
||||
await fsp.rename(tmpPath, targetPath);
|
||||
// O_EXCL via `'wx'` flag + explicit `0o600` mode — closes both halves
|
||||
// of the CodeQL js/insecure-temporary-file finding: `'wx'` rejects a
|
||||
// pre-planted symlink at the path, and `0o600` (user-only) prevents
|
||||
// the file from being created group/world readable while it briefly
|
||||
// contains contract data en route to the rename. The query's
|
||||
// `isSecureMode` predicate inspects ONLY the mode argument, not the
|
||||
// flags, so the explicit mode is what credits the fix.
|
||||
const handle = await fsp.open(tmpPath, 'wx', 0o600);
|
||||
try {
|
||||
await handle.writeFile(JSON.stringify(registry, null, 2), 'utf-8');
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
// retryRename absorbs the documented Windows EPERM/EBUSY/EACCES race that
|
||||
// fires when AV scanners or another concurrent rename briefly hold the
|
||||
// destination handle between rename calls. Same helper bridge-db.ts uses
|
||||
// (lines 304, 583, 587, 595, 605, 677) for the bridge.lbug atomic swap —
|
||||
// single source of truth for the Windows-rename pattern across the group
|
||||
// package.
|
||||
await retryRename(tmpPath, targetPath);
|
||||
}
|
||||
|
||||
export async function readContractRegistry(groupDir: string): Promise<ContractRegistry | null> {
|
||||
@@ -106,6 +135,38 @@ matching:
|
||||
# exclude_links_paths: [/ping, /health, /healthcheck]
|
||||
# exclude_links_param_only_paths: false
|
||||
`;
|
||||
await fsp.writeFile(path.join(groupDir, 'group.yaml'), template, 'utf-8');
|
||||
// Always write group.yaml with O_EXCL via `fsp.open(..., 'wx')` —
|
||||
// refuses to follow a pre-planted symlink at the target path, closing
|
||||
// the TOCTOU window between the existence check (line ~98) and the
|
||||
// write that CodeQL js/insecure-temporary-file flags. Under
|
||||
// `force=true` we unlink the existing file first (best-effort, no-op
|
||||
// when absent) so the subsequent O_EXCL open succeeds AND the same
|
||||
// symlink-rejection guarantee holds — this is strictly safer than
|
||||
// the previous `flag: force ? 'w' : 'wx'` shape, which silently
|
||||
// followed symlinks under force. CodeQL's rule does not recognize
|
||||
// the `writeFile(path, content, { flag: 'wx' })` shape as O_EXCL;
|
||||
// the explicit open() handle below is what credits the mitigation.
|
||||
const yamlPath = path.join(groupDir, 'group.yaml');
|
||||
if (force) {
|
||||
try {
|
||||
await fsp.unlink(yamlPath);
|
||||
} catch (err) {
|
||||
// ENOENT (file absent) is expected on first run; rethrow anything
|
||||
// else so we don't silently mask permission/EBUSY failures.
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
||||
}
|
||||
}
|
||||
// `'wx'` rejects a pre-planted symlink at the path; `0o600` is
|
||||
// user-only (no group/world bits) — gitnexus storage is per-user
|
||||
// (`~/.gitnexus/...`), so any "other user wants to read this" case is
|
||||
// a misconfiguration, not a feature. Keeping the file user-only also
|
||||
// satisfies CodeQL's `isSecureMode` predicate (low 6 bits == 0) and
|
||||
// closes the js/insecure-temporary-file alert at this site.
|
||||
const handle = await fsp.open(yamlPath, 'wx', 0o600);
|
||||
try {
|
||||
await handle.writeFile(template, 'utf-8');
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
return groupDir;
|
||||
}
|
||||
|
||||
@@ -6,15 +6,19 @@ import { readRegistry, type RegistryEntry } from '../../storage/repo-manager.js'
|
||||
import type { GroupConfig, RepoHandle, RepoSnapshot, StoredContract, CrossLink } from './types.js';
|
||||
import { HttpRouteExtractor } from './extractors/http-route-extractor.js';
|
||||
import { GrpcExtractor } from './extractors/grpc-extractor.js';
|
||||
import { ThriftExtractor } from './extractors/thrift-extractor.js';
|
||||
import { TopicExtractor } from './extractors/topic-extractor.js';
|
||||
import { IncludeExtractor } from './extractors/include-extractor.js';
|
||||
import { ManifestExtractor } from './extractors/manifest-extractor.js';
|
||||
import { extractRustWorkspaceLinks } from './extractors/rust-workspace-extractor.js';
|
||||
import { runExactMatch } from './matching.js';
|
||||
import { discoverWorkspaceLinks } from './extractors/workspace-extractor.js';
|
||||
import { buildProviderIndex, runExactMatch, runWildcardMatch } from './matching.js';
|
||||
import { detectServiceBoundaries, assignService } from './service-boundary-detector.js';
|
||||
import type { CypherExecutor } from './contract-extractor.js';
|
||||
import { writeContractRegistry } from './storage.js';
|
||||
import { writeBridge } from './bridge-db.js';
|
||||
import type { ContractRegistry } from './types.js';
|
||||
|
||||
import { logger } from '../logger.js';
|
||||
export interface SyncOptions {
|
||||
extractorOverride?:
|
||||
| ((repo: RepoHandle) => Promise<StoredContract[]>)
|
||||
@@ -96,7 +100,9 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
|
||||
const resolve = opts?.resolveRepoHandle ?? defaultResolveHandle(entries);
|
||||
const httpEx = new HttpRouteExtractor();
|
||||
const grpcEx = new GrpcExtractor();
|
||||
const thriftEx = new ThriftExtractor();
|
||||
const topicEx = new TopicExtractor();
|
||||
const includeEx = new IncludeExtractor();
|
||||
dbExecutors = new Map<string, CypherExecutor>();
|
||||
const openPoolIds: string[] = [];
|
||||
|
||||
@@ -143,6 +149,17 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
|
||||
}
|
||||
}
|
||||
|
||||
if (config.detect.thrift) {
|
||||
const extracted = await thriftEx.extract(executor, handle.repoPath, handle);
|
||||
for (const c of extracted) {
|
||||
autoContracts.push({
|
||||
...c,
|
||||
repo: groupPath,
|
||||
service: assignService(c.symbolRef.filePath, boundaries),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (config.detect.topics) {
|
||||
const extracted = await topicEx.extract(executor, handle.repoPath, handle);
|
||||
for (const c of extracted) {
|
||||
@@ -154,6 +171,17 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
|
||||
}
|
||||
}
|
||||
|
||||
if (config.detect.includes) {
|
||||
const extracted = await includeEx.extract(executor, handle.repoPath, handle);
|
||||
for (const c of extracted) {
|
||||
autoContracts.push({
|
||||
...c,
|
||||
repo: groupPath,
|
||||
service: assignService(c.symbolRef.filePath, boundaries),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const metaPath = path.join(handle.storagePath, 'meta.json');
|
||||
try {
|
||||
const raw = await fs.readFile(metaPath, 'utf-8');
|
||||
@@ -193,13 +221,15 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
|
||||
if (e) repoPaths.set(groupPath, e.path);
|
||||
}
|
||||
|
||||
const wsResult = await extractRustWorkspaceLinks(config.repos, repoPaths, dbExecutors);
|
||||
const wsResult = await discoverWorkspaceLinks(config.repos, repoPaths, dbExecutors);
|
||||
if (wsResult.links.length > 0) {
|
||||
allLinks = [...allLinks, ...wsResult.links];
|
||||
if (opts?.verbose) {
|
||||
console.log(
|
||||
` workspace-deps: discovered ${wsResult.links.length} cross-crate links from ${wsResult.discoveredCrates.size} Rust crates`,
|
||||
);
|
||||
for (const s of wsResult.stats) {
|
||||
logger.info(
|
||||
` workspace-deps: discovered ${s.linkCount} cross-${s.ecosystem.toLowerCase()} links from ${s.projectCount} ${s.ecosystem} projects`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -215,7 +245,7 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
|
||||
for (const link of allLinks) {
|
||||
const dangling = [link.from, link.to].filter((r) => !knownRepos.has(r));
|
||||
if (dangling.length > 0) {
|
||||
console.warn(
|
||||
logger.warn(
|
||||
`[group/sync] manifest link ${link.type}:${link.contract} references repos not in config.repos: ${dangling.join(', ')} — cross-links will use synthetic UIDs`,
|
||||
);
|
||||
}
|
||||
@@ -226,19 +256,21 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
|
||||
autoContracts.push(...manifestResult.contracts);
|
||||
manifestCrossLinks = manifestResult.crossLinks;
|
||||
if (opts?.verbose) {
|
||||
console.log(
|
||||
logger.info(
|
||||
` manifest: ${manifestCrossLinks.length} cross-links from ${allLinks.length} links (${config.links.length} declared + ${allLinks.length - config.links.length} discovered)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const { matched, unmatched } = runExactMatch(autoContracts, undefined, config.matching);
|
||||
const providerIndex = buildProviderIndex(autoContracts, config.matching);
|
||||
const { matched, unmatched } = runExactMatch(autoContracts, providerIndex, config.matching);
|
||||
const wildcard = runWildcardMatch(unmatched, providerIndex);
|
||||
|
||||
// Dedupe cross-links. Manifest contracts participate in runExactMatch, so a
|
||||
// manifest-declared link can also emit a matchType:'exact' CrossLink with the
|
||||
// same endpoints. Prefer the manifest version — it reflects operator intent
|
||||
// and carries matchType:'manifest' which downstream consumers may rely on.
|
||||
const crossLinks = dedupeCrossLinks([...manifestCrossLinks, ...matched]);
|
||||
const crossLinks = dedupeCrossLinks([...manifestCrossLinks, ...matched, ...wildcard.matched]);
|
||||
const allContracts: StoredContract[] = autoContracts;
|
||||
|
||||
const registry: ContractRegistry = {
|
||||
@@ -252,12 +284,34 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
|
||||
|
||||
if (opts?.groupDir && !opts.skipWrite) {
|
||||
await writeContractRegistry(opts.groupDir, registry);
|
||||
// writeBridge failure (disk full, schema error, permission denied) must
|
||||
// not mask the registry — contracts.json was just written successfully
|
||||
// and is the canonical source of truth. A stale or absent bridge
|
||||
// degrades impact queries to empty results, which is recoverable on
|
||||
// the next sync. Surface the failure as a warning so operators can
|
||||
// act, but do not propagate it.
|
||||
// (PR #1156 follow-up review: writeBridge error in sync.ts propagates
|
||||
// uncaught.)
|
||||
try {
|
||||
await writeBridge(opts.groupDir, {
|
||||
contracts: allContracts,
|
||||
crossLinks,
|
||||
repoSnapshots,
|
||||
missingRepos,
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
logger.warn(
|
||||
{ err: msg, groupDir: opts.groupDir },
|
||||
'⚠️ writeBridge failed; contracts.json is intact but bridge.lbug is stale. Re-run `gitnexus group sync` to retry.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
contracts: allContracts,
|
||||
crossLinks,
|
||||
unmatched,
|
||||
unmatched: wildcard.remaining,
|
||||
missingRepos,
|
||||
repoSnapshots,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type ContractType = 'http' | 'grpc' | 'topic' | 'lib' | 'custom';
|
||||
export type ContractType = 'http' | 'grpc' | 'thrift' | 'topic' | 'lib' | 'custom' | 'include';
|
||||
export type MatchType = 'exact' | 'manifest' | 'wildcard' | 'bm25' | 'embedding';
|
||||
export type ContractRole = 'provider' | 'consumer';
|
||||
|
||||
@@ -24,9 +24,11 @@ export interface GroupManifestLink {
|
||||
export interface DetectConfig {
|
||||
http: boolean;
|
||||
grpc: boolean;
|
||||
thrift: boolean;
|
||||
topics: boolean;
|
||||
shared_libs: boolean;
|
||||
embedding_fallback: boolean;
|
||||
includes: boolean;
|
||||
workspace_deps: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { LRUCache } from 'lru-cache';
|
||||
import Parser from 'tree-sitter';
|
||||
|
||||
import { logger } from '../logger.js';
|
||||
/**
|
||||
* Minimal structural shape consumers need when reading Trees back
|
||||
* through a phase-dependency boundary. Declared here so phases that
|
||||
@@ -49,7 +50,7 @@ export const createASTCache = (maxSize: number = 50): ASTCache => {
|
||||
// will hand freed memory to scope-resolution.
|
||||
(tree as unknown as { delete?: () => void }).delete?.();
|
||||
} catch (e) {
|
||||
console.warn('Failed to delete tree from WASM memory', e);
|
||||
logger.warn({ e }, 'Failed to delete tree from WASM memory');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -75,6 +75,7 @@ import { extractReturnTypeName, stripNullable } from './type-extractors/shared.j
|
||||
import type { LiteralTypeInferrer } from './type-extractors/types.js';
|
||||
import type { SyntaxNode } from './utils/ast-helpers.js';
|
||||
|
||||
import { logger } from '../logger.js';
|
||||
/** Per-file resolved type bindings for exported symbols.
|
||||
* Populated during call processing, consumed by Phase 14 re-resolution pass. */
|
||||
export type ExportedTypeMap = Map<string, Map<string, string>>;
|
||||
@@ -768,9 +769,10 @@ export const processCalls = async (
|
||||
|
||||
let tree = astCache.get(file.path);
|
||||
if (!tree) {
|
||||
const parseContent = provider.preprocessSource?.(file.content, file.path) ?? file.content;
|
||||
try {
|
||||
tree = parser.parse(file.content, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(file.content),
|
||||
tree = parser.parse(parseContent, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(parseContent),
|
||||
});
|
||||
} catch (parseError) {
|
||||
continue;
|
||||
@@ -784,7 +786,7 @@ export const processCalls = async (
|
||||
const query = new Parser.Query(lang, queryStr);
|
||||
matches = query.matches(tree.rootNode);
|
||||
} catch (queryError) {
|
||||
console.warn(`Query error for ${file.path}:`, queryError);
|
||||
logger.warn({ queryError }, `Query error for ${file.path}:`);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1391,7 +1393,7 @@ export const processCalls = async (
|
||||
|
||||
if (skippedByLang && skippedByLang.size > 0) {
|
||||
for (const [lang, count] of skippedByLang.entries()) {
|
||||
console.warn(
|
||||
logger.warn(
|
||||
`[ingestion] Skipped ${count} ${lang} file(s) in call processing — ${lang} parser not available.`,
|
||||
);
|
||||
}
|
||||
@@ -3279,9 +3281,10 @@ export const extractFetchCallsFromFiles = async (
|
||||
|
||||
let tree = astCache.get(file.path);
|
||||
if (!tree) {
|
||||
const parseContent = provider.preprocessSource?.(file.content, file.path) ?? file.content;
|
||||
try {
|
||||
tree = parser.parse(file.content, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(file.content),
|
||||
tree = parser.parse(parseContent, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(parseContent),
|
||||
});
|
||||
} catch {
|
||||
continue;
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import { CommunityNode } from './community-processor.js';
|
||||
|
||||
import { logger } from '../logger.js';
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
// ============================================================================
|
||||
@@ -128,7 +129,7 @@ export const enrichClusters = async (
|
||||
enrichments.set(community.id, enrichment);
|
||||
} catch (error) {
|
||||
// On error, fallback to heuristic
|
||||
console.warn(`Failed to enrich cluster ${community.id}:`, error);
|
||||
logger.warn({ error }, `Failed to enrich cluster ${community.id}:`);
|
||||
enrichments.set(community.id, {
|
||||
name: community.heuristicLabel,
|
||||
keywords: [],
|
||||
@@ -210,7 +211,7 @@ Output JSON array:
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Batch enrichment failed, falling back to heuristics:', error);
|
||||
logger.warn({ error }, 'Batch enrichment failed, falling back to heuristics:');
|
||||
// Fallback for this batch
|
||||
for (const community of batch) {
|
||||
enrichments.set(community.id, {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { logger } from '../../logger.js';
|
||||
/**
|
||||
* COBOL COPY statement expansion engine.
|
||||
*
|
||||
@@ -454,7 +455,7 @@ export function expandCopies(
|
||||
if (visited.has(resolvedPath)) {
|
||||
if (!warnedCircular.has(resolvedPath)) {
|
||||
warnedCircular.add(resolvedPath);
|
||||
console.warn(
|
||||
logger.warn(
|
||||
`[cobol-copy-expander] Circular COPY detected: ${cs.target} (${resolvedPath}) ` +
|
||||
`includes itself. Skipping expansion.`,
|
||||
);
|
||||
@@ -464,7 +465,7 @@ export function expandCopies(
|
||||
|
||||
// Max depth exceeded — keep unexpanded
|
||||
if (depth >= maxDepth) {
|
||||
console.warn(
|
||||
logger.warn(
|
||||
`[cobol-copy-expander] Max expansion depth (${maxDepth}) reached for ` +
|
||||
`COPY ${cs.target} in ${srcPath}. Skipping expansion.`,
|
||||
);
|
||||
@@ -475,7 +476,7 @@ export function expandCopies(
|
||||
if (++totalExpansions > MAX_TOTAL_EXPANSIONS) {
|
||||
if (!warnedCircular.has('__max_total__')) {
|
||||
warnedCircular.add('__max_total__');
|
||||
console.warn(
|
||||
logger.warn(
|
||||
`[cobol-copy-expander] Max total expansions (${MAX_TOTAL_EXPANSIONS}) reached ` +
|
||||
`in ${srcPath}. Skipping further expansions.`,
|
||||
);
|
||||
|
||||
@@ -369,9 +369,20 @@ const RE_USE_AFTER =
|
||||
/\bUSE\s+(?:AFTER\s+)?(?:STANDARD\s+)?(?:EXCEPTION|ERROR)\s+ON\s+([A-Z][A-Z0-9-]+|INPUT|OUTPUT|I-O|EXTEND)\b/i;
|
||||
|
||||
// SET statement (condition, index)
|
||||
const RE_SET_TO_TRUE = /\bSET\s+((?:[A-Z][A-Z0-9-]+(?:\s+OF\s+[A-Z][A-Z0-9-]+)?\s+)+)TO\s+TRUE\b/i;
|
||||
const RE_SET_INDEX =
|
||||
/\bSET\s+((?:[A-Z][A-Z0-9-]+\s+)+)(TO|UP\s+BY|DOWN\s+BY)\s+(\d+|[A-Z][A-Z0-9-]+)/i;
|
||||
//
|
||||
// Catastrophic-backtracking note (CodeQL js/redos): the previous shape
|
||||
// `((?:[A-Z][A-Z0-9-]+(?:\s+OF\s+[A-Z][A-Z0-9-]+)?\s+)+)TO\s+TRUE`
|
||||
// nested `\s+` quantifiers across alternations and was exponential on
|
||||
// inputs like "SET a OF a OF a ... TO TRUE". Replaced with a lazy
|
||||
// dot-match bounded by the explicit `\s+TO\s+TRUE` suffix — `.+?` is
|
||||
// O(n) with the trailing anchor, and the captured group is parsed
|
||||
// downstream the same way as before.
|
||||
// Exported so the U8 ReDoS regression test can pin the exact production
|
||||
// pattern. Direct import is the only way to ensure the test's
|
||||
// pathological-input timing assertion exercises the production regex
|
||||
// instead of an inline copy that drifts.
|
||||
export const RE_SET_TO_TRUE = /\bSET\s+(.+?)\s+TO\s+TRUE\b/i;
|
||||
export const RE_SET_INDEX = /\bSET\s+(.+?)\s+(TO|UP\s+BY|DOWN\s+BY)\s+(\d+|[A-Z][A-Z0-9-]+)/i;
|
||||
|
||||
// INITIALIZE statement — data reset (captures targets before REPLACING/WITH clause)
|
||||
const RE_INITIALIZE = /\bINITIALIZE\s+([\s\S]*?)(?=\bREPLACING\b|\bWITH\b|\.\s*$|$)/i;
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* Unreal Engine reflection-macro preprocessor for C++ source.
|
||||
*
|
||||
* Tree-sitter does not expand C preprocessor macros, so Unreal's reflection
|
||||
* markers (`UCLASS(...)`, `UFUNCTION(...)`, `MODULENAME_API`, ...) are parsed
|
||||
* verbatim. The result is mis-parsed declarations: in `class BRAWLUI_API
|
||||
* UMyClass : public UObject`, tree-sitter-cpp captures `BRAWLUI_API` as the
|
||||
* class name and the rest of the declaration becomes structurally wrong.
|
||||
*
|
||||
* This module elides those macros from the source text BEFORE tree-sitter
|
||||
* parses it. Replacement is **length-preserving** (each elided byte becomes
|
||||
* a space, newlines preserved) so byte offsets and line/column positions
|
||||
* tree-sitter reports remain identical to the original file. Symbol
|
||||
* locations in the graph stay accurate.
|
||||
*
|
||||
* A cheap detection guard short-circuits files that don't look like UE
|
||||
* sources, so non-UE C++ codebases pay no cost.
|
||||
*
|
||||
* Pure function — no tree-sitter dependency, safe for worker threads.
|
||||
*/
|
||||
/**
|
||||
* Strong UE markers — reflection macros that only Unreal Engine projects use.
|
||||
* Presence of one of these is sufficient evidence that the file is a UE source
|
||||
* and that `MODULENAME_API` tokens in it are intended as export macros.
|
||||
*
|
||||
* Importantly, `_API` tokens are NOT in this guard — `REST_API`, `HTTP_API`,
|
||||
* `MY_LIB_API` and similar identifiers appear in plenty of non-UE C++ codebases
|
||||
* as constants/enums/parameter names. We must not erase them just because the
|
||||
* file mentions an `_API` token.
|
||||
*/
|
||||
const HAS_UE_HINT =
|
||||
/\b(?:UCLASS|UFUNCTION|UPROPERTY|USTRUCT|UENUM|UINTERFACE|GENERATED_BODY|GENERATED_[A-Z_]+_BODY|UE_DEPRECATED|DECLARE_(?:DYNAMIC_)?(?:MULTICAST_)?DELEGATE)/;
|
||||
|
||||
const SIMPLE_MACROS_NO_ARGS: readonly string[] = [
|
||||
'GENERATED_BODY',
|
||||
'GENERATED_UCLASS_BODY',
|
||||
'GENERATED_USTRUCT_BODY',
|
||||
'GENERATED_UINTERFACE_BODY',
|
||||
'GENERATED_IINTERFACE_BODY',
|
||||
'DECLARE_CLASS',
|
||||
'GENERATED_BODY_LEGACY',
|
||||
];
|
||||
|
||||
const PARENTHESIZED_MACROS: readonly string[] = [
|
||||
'UCLASS',
|
||||
'UFUNCTION',
|
||||
'UPROPERTY',
|
||||
'USTRUCT',
|
||||
'UENUM',
|
||||
'UINTERFACE',
|
||||
'UMETA',
|
||||
'UE_DEPRECATED',
|
||||
];
|
||||
|
||||
const DELEGATE_MACRO_RE =
|
||||
/\bDECLARE_(?:DYNAMIC_)?(?:MULTICAST_)?DELEGATE(?:_(?:RetVal_OneParam|RetVal_TwoParams|RetVal_ThreeParams|RetVal_FourParams|RetVal_FiveParams|RetVal_SixParams|RetVal_SevenParams|RetVal_EightParams|RetVal_NineParams|RetVal|OneParam|TwoParams|ThreeParams|FourParams|FiveParams|SixParams|SevenParams|EightParams|NineParams|TenParams))?(?=\s*\()/g;
|
||||
|
||||
/**
|
||||
* Module export tokens like `BRAWLUI_API`, `ENGINE_API`, `COREUOBJECT_API`.
|
||||
* Pattern: ALL_CAPS identifier ending in `_API`. The leading word boundary
|
||||
* (`\b`) prevents matching mid-identifier.
|
||||
*/
|
||||
const API_MACRO_RE = /\b[A-Z][A-Z0-9_]*_API\b/g;
|
||||
|
||||
/** Replace `[start, end)` of `chars` with spaces, preserving newlines. */
|
||||
function eraseRange(chars: string[], start: number, end: number): void {
|
||||
for (let i = start; i < end; i++) {
|
||||
if (chars[i] !== '\n' && chars[i] !== '\r') {
|
||||
chars[i] = ' ';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the matching close paren for an opening paren at index `openIdx`.
|
||||
* Returns the index of `)` (inclusive end), or -1 if unbalanced.
|
||||
*
|
||||
* Handles nested parens and string/char literals so commas/parens inside
|
||||
* strings don't throw off the match. Does not attempt to handle raw string
|
||||
* literals (`R"(...)"`); UE reflection-macro arguments do not use them in
|
||||
* practice.
|
||||
*/
|
||||
function findMatchingParen(source: string, openIdx: number): number {
|
||||
if (source.charCodeAt(openIdx) !== 0x28) return -1;
|
||||
let depth = 1;
|
||||
let i = openIdx + 1;
|
||||
const len = source.length;
|
||||
while (i < len && depth > 0) {
|
||||
const ch = source.charCodeAt(i);
|
||||
// String literal
|
||||
if (ch === 0x22) {
|
||||
i++;
|
||||
while (i < len) {
|
||||
const c = source.charCodeAt(i);
|
||||
if (c === 0x5c) {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (c === 0x22) {
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Char literal
|
||||
if (ch === 0x27) {
|
||||
i++;
|
||||
while (i < len) {
|
||||
const c = source.charCodeAt(i);
|
||||
if (c === 0x5c) {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (c === 0x27) {
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Line comment
|
||||
if (ch === 0x2f && source.charCodeAt(i + 1) === 0x2f) {
|
||||
while (i < len && source.charCodeAt(i) !== 0x0a) i++;
|
||||
continue;
|
||||
}
|
||||
// Block comment
|
||||
if (ch === 0x2f && source.charCodeAt(i + 1) === 0x2a) {
|
||||
i += 2;
|
||||
while (i < len) {
|
||||
if (source.charCodeAt(i) === 0x2a && source.charCodeAt(i + 1) === 0x2f) {
|
||||
i += 2;
|
||||
break;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch === 0x28) depth++;
|
||||
else if (ch === 0x29) {
|
||||
depth--;
|
||||
if (depth === 0) return i;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** Match a whole-word identifier at `idx`. Returns the byte after the identifier, or -1 on miss. */
|
||||
function matchIdentifierAt(source: string, idx: number, name: string): number {
|
||||
if (idx > 0) {
|
||||
const prev = source.charCodeAt(idx - 1);
|
||||
if (
|
||||
(prev >= 0x30 && prev <= 0x39) ||
|
||||
(prev >= 0x41 && prev <= 0x5a) ||
|
||||
(prev >= 0x61 && prev <= 0x7a) ||
|
||||
prev === 0x5f
|
||||
) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
for (let k = 0; k < name.length; k++) {
|
||||
if (source.charCodeAt(idx + k) !== name.charCodeAt(k)) return -1;
|
||||
}
|
||||
const after = idx + name.length;
|
||||
if (after < source.length) {
|
||||
const next = source.charCodeAt(after);
|
||||
if (
|
||||
(next >= 0x30 && next <= 0x39) ||
|
||||
(next >= 0x41 && next <= 0x5a) ||
|
||||
(next >= 0x61 && next <= 0x7a) ||
|
||||
next === 0x5f
|
||||
) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
return after;
|
||||
}
|
||||
|
||||
/** Skip ASCII whitespace forward from `idx`. Returns the next non-whitespace byte index. */
|
||||
function skipWhitespace(source: string, idx: number): number {
|
||||
const len = source.length;
|
||||
while (idx < len) {
|
||||
const ch = source.charCodeAt(idx);
|
||||
if (ch === 0x20 || ch === 0x09 || ch === 0x0a || ch === 0x0d) {
|
||||
idx++;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return idx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip Unreal Engine reflection macros from C++ source, length-preserving.
|
||||
*
|
||||
* Returns the original string unchanged if no strong UE marker is detected,
|
||||
* so non-UE C++ files (including ones that contain `*_API`-suffixed
|
||||
* identifiers like `REST_API` or `HTTP_API`) incur only a single regex test.
|
||||
*
|
||||
* The `_filePath` parameter is part of the `LanguageProvider.preprocessSource`
|
||||
* contract but is unused — UE detection is purely content-based. Accepted and
|
||||
* ignored here so the function matches the hook signature exactly.
|
||||
*/
|
||||
export function stripUeMacros(source: string, _filePath?: string): string {
|
||||
if (!HAS_UE_HINT.test(source)) return source;
|
||||
|
||||
const chars: string[] = source.split('');
|
||||
|
||||
for (const macro of PARENTHESIZED_MACROS) {
|
||||
let searchFrom = 0;
|
||||
while (true) {
|
||||
const hit = source.indexOf(macro, searchFrom);
|
||||
if (hit < 0) break;
|
||||
searchFrom = hit + 1;
|
||||
const after = matchIdentifierAt(source, hit, macro);
|
||||
if (after < 0) continue;
|
||||
const parenIdx = skipWhitespace(source, after);
|
||||
if (source.charCodeAt(parenIdx) !== 0x28) continue;
|
||||
const close = findMatchingParen(source, parenIdx);
|
||||
if (close < 0) continue;
|
||||
eraseRange(chars, hit, close + 1);
|
||||
}
|
||||
}
|
||||
|
||||
for (const macro of SIMPLE_MACROS_NO_ARGS) {
|
||||
let searchFrom = 0;
|
||||
while (true) {
|
||||
const hit = source.indexOf(macro, searchFrom);
|
||||
if (hit < 0) break;
|
||||
searchFrom = hit + 1;
|
||||
const after = matchIdentifierAt(source, hit, macro);
|
||||
if (after < 0) continue;
|
||||
const parenIdx = skipWhitespace(source, after);
|
||||
if (source.charCodeAt(parenIdx) === 0x28) {
|
||||
const close = findMatchingParen(source, parenIdx);
|
||||
if (close < 0) continue;
|
||||
eraseRange(chars, hit, close + 1);
|
||||
} else {
|
||||
eraseRange(chars, hit, after);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const re of [DELEGATE_MACRO_RE, API_MACRO_RE]) {
|
||||
re.lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = re.exec(source)) !== null) {
|
||||
const start = match.index;
|
||||
let end = start + match[0].length;
|
||||
if (re === DELEGATE_MACRO_RE) {
|
||||
const parenIdx = skipWhitespace(source, end);
|
||||
if (source.charCodeAt(parenIdx) === 0x28) {
|
||||
const close = findMatchingParen(source, parenIdx);
|
||||
if (close >= 0) end = close + 1;
|
||||
}
|
||||
}
|
||||
eraseRange(chars, start, end);
|
||||
}
|
||||
}
|
||||
|
||||
return chars.join('');
|
||||
}
|
||||
@@ -9,6 +9,11 @@ import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
|
||||
const CSHARP_VIS = new Set<FieldVisibility>(['public', 'private', 'protected', 'internal']);
|
||||
|
||||
const extractCsharpDeclaredType = (typeNode: SyntaxNode): string | undefined => {
|
||||
if (typeNode.type === 'generic_name') return typeNode.text.trim();
|
||||
return extractSimpleTypeName(typeNode) ?? typeNode.text?.trim();
|
||||
};
|
||||
|
||||
/**
|
||||
* C# field extraction config.
|
||||
*
|
||||
@@ -53,17 +58,17 @@ export const csharpConfig: FieldExtractionConfig = {
|
||||
const child = node.namedChild(i);
|
||||
if (child?.type === 'variable_declaration') {
|
||||
const typeNode = child.childForFieldName('type');
|
||||
if (typeNode) return extractSimpleTypeName(typeNode) ?? typeNode.text?.trim();
|
||||
if (typeNode) return extractCsharpDeclaredType(typeNode);
|
||||
// fallback: first child that is a type
|
||||
const first = child.firstNamedChild;
|
||||
if (first && first.type !== 'variable_declarator') {
|
||||
return extractSimpleTypeName(first) ?? first.text?.trim();
|
||||
return extractCsharpDeclaredType(first);
|
||||
}
|
||||
}
|
||||
}
|
||||
// property_declaration: type is first named child
|
||||
const typeNode = node.childForFieldName('type');
|
||||
if (typeNode) return extractSimpleTypeName(typeNode) ?? typeNode.text?.trim();
|
||||
if (typeNode) return extractCsharpDeclaredType(typeNode);
|
||||
return undefined;
|
||||
},
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import path from 'path';
|
||||
import { glob } from 'glob';
|
||||
import { createIgnoreFilter } from '../../config/ignore-service.js';
|
||||
|
||||
import { logger } from '../logger.js';
|
||||
export interface FileEntry {
|
||||
path: string;
|
||||
content: string;
|
||||
@@ -74,10 +75,10 @@ export const walkRepositoryPaths = async (
|
||||
if (skippedLarge > 0) {
|
||||
const isDefault = maxFileSizeBytes === DEFAULT_MAX_FILE_SIZE_BYTES;
|
||||
const suffix = isDefault ? ', likely generated/vendored' : '';
|
||||
console.warn(` Skipped ${skippedLarge} large files (>${maxFileSizeBytes / 1024}KB${suffix})`);
|
||||
logger.warn(` Skipped ${skippedLarge} large files (>${maxFileSizeBytes / 1024}KB${suffix})`);
|
||||
if (isVerboseIngestionEnabled()) {
|
||||
for (const p of skippedLargePaths) {
|
||||
console.warn(` - ${p}`);
|
||||
logger.warn(` - ${p}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user