diff --git a/.github/actions/setup-gitnexus-web/action.yml b/.github/actions/setup-gitnexus-web/action.yml index 86331e36d..8f895423a 100644 --- a/.github/actions/setup-gitnexus-web/action.yml +++ b/.github/actions/setup-gitnexus-web/action.yml @@ -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 diff --git a/.github/actions/setup-gitnexus/action.yml b/.github/actions/setup-gitnexus/action.yml index e946f1040..b9b4acb7e 100644 --- a/.github/actions/setup-gitnexus/action.yml +++ b/.github/actions/setup-gitnexus/action.yml @@ -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 diff --git a/.github/dependabot.yml b/.github/dependabot.yml index f3530e4d3..c99b666eb 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -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) diff --git a/.github/scripts/check-tree-sitter-upgrade-readiness.py b/.github/scripts/check-tree-sitter-upgrade-readiness.py index f54afd7f0..5b0fad09e 100644 --- a/.github/scripts/check-tree-sitter-upgrade-readiness.py +++ b/.github/scripts/check-tree-sitter-upgrade-readiness.py @@ -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) diff --git a/.github/workflows/ci-quality.yml b/.github/workflows/ci-quality.yml index 36a936c82..cc334fcaa 100644 --- a/.github/workflows/ci-quality.yml +++ b/.github/workflows/ci-quality.yml @@ -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 diff --git a/.github/workflows/ci-report.yml b/.github/workflows/ci-report.yml index 03908bb2b..03c933ad0 100644 --- a/.github/workflows/ci-report.yml +++ b/.github/workflows/ci-report.yml @@ -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' @@ -279,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 ── diff --git a/.github/workflows/pr-autofix-apply.yml b/.github/workflows/pr-autofix-apply.yml new file mode 100644 index 000000000..b2ec8495f --- /dev/null +++ b/.github/workflows/pr-autofix-apply.yml @@ -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..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 diff --git a/.github/workflows/pr-autofix-publish.yml b/.github/workflows/pr-autofix-publish.yml new file mode 100644 index 000000000..08ad1d60f --- /dev/null +++ b/.github/workflows/pr-autofix-publish.yml @@ -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="" + 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 ` 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})" diff --git a/.github/workflows/pr-autofix.yml b/.github/workflows/pr-autofix.yml new file mode 100644 index 000000000..04eb2468d --- /dev/null +++ b/.github/workflows/pr-autofix.yml @@ -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 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d372c163a..d5af63205 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -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 diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index 9ab00c0bd..ff75bca26 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -149,7 +149,7 @@ jobs: - 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 @@ -294,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 @@ -354,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 diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml index f476ee7bc..f7fba75e9 100644 --- a/.github/workflows/trivy.yml +++ b/.github/workflows/trivy.yml @@ -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: @@ -61,7 +67,7 @@ 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' diff --git a/.github/workflows/workflow-lint.yml b/.github/workflows/workflow-lint.yml index 97f388d28..7b38b8ddd 100644 --- a/.github/workflows/workflow-lint.yml +++ b/.github/workflows/workflow-lint.yml @@ -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 diff --git a/.github/zizmor.yml b/.github/zizmor.yml index c534679c1..b2f89e3ba 100644 --- a/.github/zizmor.yml +++ b/.github/zizmor.yml @@ -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, diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0ed2aeba5..d4f6b12b2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,17 +30,17 @@ Format: `[(scope)][!]: ` 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 `` 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 `. | + +To detect outcome from an agent: `gh pr checks --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/` and `v` tags, then redispatch with `force: - true` to re-run the full RC pipeline (cuts a new RC number). + - Delete `rc/` and `v` 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: diff --git a/Dockerfile.cli b/Dockerfile.cli index c45292e06..925f295f0 100644 --- a/Dockerfile.cli +++ b/Dockerfile.cli @@ -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 diff --git a/Dockerfile.web b/Dockerfile.web index 7d20e09ce..b102a700e 100644 --- a/Dockerfile.web +++ b/Dockerfile.web @@ -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 diff --git a/README.md b/README.md index f5f3c5a88..6beadb63d 100644 --- a/README.md +++ b/README.md @@ -214,6 +214,7 @@ gitnexus clean --all --force # Delete all indexes gitnexus wiki [path] # Generate repository wiki from knowledge graph gitnexus wiki --model # Wiki with custom LLM model (default: gpt-4o-mini) gitnexus wiki --base-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 # Create a repository group @@ -228,6 +229,12 @@ gitnexus group status # 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): diff --git a/gitnexus-shared/package.json b/gitnexus-shared/package.json index 7c1e6847a..0a5d7a2db 100644 --- a/gitnexus-shared/package.json +++ b/gitnexus-shared/package.json @@ -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": { diff --git a/gitnexus-shared/src/index.ts b/gitnexus-shared/src/index.ts index ea66c3855..3c82658f1 100644 --- a/gitnexus-shared/src/index.ts +++ b/gitnexus-shared/src/index.ts @@ -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 { diff --git a/gitnexus-shared/src/integrations/circuit-breaker.ts b/gitnexus-shared/src/integrations/circuit-breaker.ts new file mode 100644 index 000000000..29782a8fb --- /dev/null +++ b/gitnexus-shared/src/integrations/circuit-breaker.ts @@ -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(); + +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(); +} diff --git a/gitnexus-shared/src/integrations/resilient-fetch.ts b/gitnexus-shared/src/integrations/resilient-fetch.ts new file mode 100644 index 000000000..c91b9db3a --- /dev/null +++ b/gitnexus-shared/src/integrations/resilient-fetch.ts @@ -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 `` 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> & { + 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 => + 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 { + 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`. + /* c8 ignore next 2 */ + throw new Error('resilientFetch: retry loop terminated unexpectedly'); +} diff --git a/gitnexus-shared/src/integrations/retry.ts b/gitnexus-shared/src/integrations/retry.ts new file mode 100644 index 000000000..774ca2542 --- /dev/null +++ b/gitnexus-shared/src/integrations/retry.ts @@ -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; + /** 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 => + 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( + fn: (attempt: number) => Promise, + opts: RetryOptions, +): Promise { + 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; +} diff --git a/gitnexus-shared/src/integrations/understand-quickly.ts b/gitnexus-shared/src/integrations/understand-quickly.ts new file mode 100644 index 000000000..f30e7461b --- /dev/null +++ b/gitnexus-shared/src/integrations/understand-quickly.ts @@ -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: { + /** `/` 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; +} diff --git a/gitnexus-shared/src/test-helpers.ts b/gitnexus-shared/src/test-helpers.ts new file mode 100644 index 000000000..a92441878 --- /dev/null +++ b/gitnexus-shared/src/test-helpers.ts @@ -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'; diff --git a/gitnexus-web/package-lock.json b/gitnexus-web/package-lock.json index 7fcc0d299..3a560ca5a 100644 --- a/gitnexus-web/package-lock.json +++ b/gitnexus-web/package-lock.json @@ -8,7 +8,7 @@ "name": "gitnexus", "version": "0.0.0", "dependencies": { - "@langchain/anthropic": "^1.3.28", + "@langchain/anthropic": "^1.3.29", "@langchain/core": "^1.1.44", "@langchain/google-genai": "^2.1.28", "@langchain/langgraph": "^1.2.9", @@ -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" @@ -1396,25 +1396,25 @@ } }, "node_modules/@langchain/anthropic": { - "version": "1.3.28", - "resolved": "https://registry.npmjs.org/@langchain/anthropic/-/anthropic-1.3.28.tgz", - "integrity": "sha512-gOF8oXJL8xDdYes2KXNI9vFm/9TldBBBHOjuCdt27kganVaQKzLvTw5kV6R4mjbnFagV5CWteNH7APLZYCpdwg==", + "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.42" + "@langchain/core": "^1.1.45" } }, "node_modules/@langchain/core": { - "version": "1.1.44", - "resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.1.44.tgz", - "integrity": "sha512-RePW1IjGCHr9ua2vcby3aE8mOOz3EnwDZxMEGbNDT91kf14eqkJqxDXvaZFviGdcN9DTrxM5RPQNAHmwSm4tbg==", + "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", diff --git a/gitnexus-web/package.json b/gitnexus-web/package.json index 81d7fc5fa..51d7b0520 100644 --- a/gitnexus-web/package.json +++ b/gitnexus-web/package.json @@ -19,7 +19,7 @@ }, "dependencies": { "gitnexus-shared": "file:../gitnexus-shared", - "@langchain/anthropic": "^1.3.28", + "@langchain/anthropic": "^1.3.29", "@langchain/core": "^1.1.44", "@langchain/google-genai": "^2.1.28", "@langchain/langgraph": "^1.2.9", diff --git a/gitnexus-web/src/core/llm/agent.ts b/gitnexus-web/src/core/llm/agent.ts index c8cfa8d7c..49862a9e6 100644 --- a/gitnexus-web/src/core/llm/agent.ts +++ b/gitnexus-web/src/core/llm/agent.ts @@ -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]; } diff --git a/gitnexus-web/src/core/llm/settings-service.ts b/gitnexus-web/src/core/llm/settings-service.ts index 5e49cb7af..86330d2a5 100644 --- a/gitnexus-web/src/core/llm/settings-service.ts +++ b/gitnexus-web/src/core/llm/settings-service.ts @@ -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> => { 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) => ({ diff --git a/gitnexus-web/src/core/llm/tools.ts b/gitnexus-web/src/core/llm/tools.ts index cd0b5a800..5a595049e 100644 --- a/gitnexus-web/src/core/llm/tools.ts +++ b/gitnexus-web/src/core/llm/tools.ts @@ -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(' | ')} |`; diff --git a/gitnexus-web/src/services/backend-client.ts b/gitnexus-web/src/services/backend-client.ts index ec8c1a964..e887e3901 100644 --- a/gitnexus-web/src/services/backend-client.ts +++ b/gitnexus-web/src/services/backend-client.ts @@ -7,6 +7,7 @@ */ import type { GraphNode, GraphRelationship } from 'gitnexus-shared'; +import { CircuitOpenError, ResilientFetchExhaustedError, resilientFetch } from 'gitnexus-shared'; // ── Types ────────────────────────────────────────────────────────────────── @@ -204,8 +205,32 @@ export function streamSSE(url: string, handlers: SSEHandlers): A let _backendUrl = 'http://localhost:4747'; +/** + * Validate that a backend URL is a safe http:// or https:// origin before + * storing it as the fetch target base (CodeQL js/client-side-request-forgery). + * + * Throws if the URL uses a non-HTTP scheme (e.g. javascript:, data:, file://). + * All other well-formed http/https URLs are accepted — the client intentionally + * supports connecting to remote GitNexus servers, not just localhost. + */ +export function validateBackendUrl(url: string): void { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + // Do not echo raw input — it may contain credentials. + throw new Error('Invalid backend URL: must be a well-formed http:// or https:// URL'); + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + // Use parsed.protocol only (scheme), not the full URL, to avoid leaking credentials. + throw new Error(`Backend URL must use http:// or https:// (got ${parsed.protocol})`); + } +} + export const setBackendUrl = (url: string): void => { - _backendUrl = url.replace(/\/$/, ''); + const trimmed = url.replace(/\/$/, ''); + validateBackendUrl(trimmed); + _backendUrl = trimmed; }; export const getBackendUrl = (): string => _backendUrl; @@ -237,29 +262,91 @@ export function normalizeServerUrl(input: string): string { const DEFAULT_TIMEOUT_MS = 30_000; const PROBE_TIMEOUT_MS = 2_000; +/** Idempotent HTTP methods. Other verbs (POST, PATCH, PUT, DELETE) get + * a single-attempt retry budget by default to avoid duplicate side + * effects on retry — a POST that 5xx'd may have already executed + * server-side. Callers that have idempotency keys or otherwise know + * their mutation is safe to retry can opt in via `forceRetry`. */ +const IDEMPOTENT_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); + const fetchWithTimeout = async ( url: string, init: RequestInit = {}, timeoutMs: number = DEFAULT_TIMEOUT_MS, + /** + * Force a retry budget on non-idempotent methods. Default false. + * Pass true only when the endpoint is known-idempotent (e.g. DELETE + * of a known-deleted resource — second call is a 404 / no-op) AND + * the duplicate-side-effect window is acceptable. + */ + forceRetry = false, ): Promise => { - 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}`, @@ -268,8 +355,6 @@ const fetchWithTimeout = async ( ); } throw error; - } finally { - clearTimeout(timer); } }; diff --git a/gitnexus-web/test/unit/backend-client-retry.test.ts b/gitnexus-web/test/unit/backend-client-retry.test.ts new file mode 100644 index 000000000..ea0fcf3a7 --- /dev/null +++ b/gitnexus-web/test/unit/backend-client-retry.test.ts @@ -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); + }); +}); diff --git a/gitnexus-web/test/unit/server-connection.test.ts b/gitnexus-web/test/unit/server-connection.test.ts index f5ee43c53..e39b829a1 100644 --- a/gitnexus-web/test/unit/server-connection.test.ts +++ b/gitnexus-web/test/unit/server-connection.test.ts @@ -1,5 +1,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { fetchGraph, normalizeServerUrl, setBackendUrl } from '../../src/services/backend-client'; +import { + fetchGraph, + getBackendUrl, + normalizeServerUrl, + setBackendUrl, + validateBackendUrl, +} from '../../src/services/backend-client'; describe('normalizeServerUrl', () => { it('adds http:// to localhost', () => { @@ -165,3 +171,61 @@ describe('fetchGraph', () => { }); }); }); + +describe('validateBackendUrl', () => { + it('allows http:// URLs', () => { + expect(() => validateBackendUrl('http://localhost:4747')).not.toThrow(); + expect(() => validateBackendUrl('http://127.0.0.1:4747')).not.toThrow(); + }); + + it('allows https:// URLs', () => { + expect(() => validateBackendUrl('https://gitnexus.example.com')).not.toThrow(); + expect(() => validateBackendUrl('https://my-server.internal:4747')).not.toThrow(); + }); + + it('rejects non-http schemes', () => { + expect(() => validateBackendUrl('javascript:alert(1)')).toThrow('must use http:// or https://'); + expect(() => validateBackendUrl('file:///etc/passwd')).toThrow('must use http:// or https://'); + expect(() => validateBackendUrl('data:text/plain,evil')).toThrow( + 'must use http:// or https://', + ); + }); + + it('rejects malformed URLs', () => { + expect(() => validateBackendUrl('not-a-url')).toThrow('Invalid backend URL'); + }); + + it('does not include the raw URL in error messages (credential hygiene)', () => { + const urlWithCreds = 'javascript:alert("sk-secret")'; + let msg = ''; + try { + validateBackendUrl(urlWithCreds); + } catch (e) { + msg = (e as Error).message; + } + expect(msg).not.toContain('sk-secret'); + expect(msg).not.toContain(urlWithCreds); + }); +}); + +describe('setBackendUrl', () => { + it('accepts valid http URLs', () => { + expect(() => setBackendUrl('http://localhost:4747')).not.toThrow(); + }); + + it('accepts valid https URLs', () => { + expect(() => setBackendUrl('https://my-server.example.com')).not.toThrow(); + }); + + it('rejects non-http/https schemes', () => { + expect(() => setBackendUrl('javascript:alert(1)')).toThrow('must use http:// or https://'); + expect(() => setBackendUrl('file:///etc/passwd')).toThrow('must use http:// or https://'); + }); + + it('does not mutate _backendUrl when validation fails', () => { + setBackendUrl('http://localhost:4747'); + expect(() => setBackendUrl('javascript:alert(1)')).toThrow(); + // State must be preserved — validation must happen before the assignment + expect(getBackendUrl()).toBe('http://localhost:4747'); + }); +}); diff --git a/gitnexus/Dockerfile.test b/gitnexus/Dockerfile.test index 0282129ff..37374a5f5 100644 --- a/gitnexus/Dockerfile.test +++ b/gitnexus/Dockerfile.test @@ -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 \ diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index b07ccd7cb..04233b1a1 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -2065,9 +2065,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.6.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.1.tgz", - "integrity": "sha512-coJCN8O1q4AGyyqCAUSP06P+SrMTu18BkEj3NVAK07q6QUneD2wzj3CLv9+yP+BMeZQlMvneXqqvDe3w+xcq7g==", + "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" @@ -3110,9 +3110,9 @@ "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", @@ -3476,9 +3476,9 @@ "license": "MIT" }, "node_modules/hono": { - "version": "4.12.16", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.16.tgz", - "integrity": "sha512-jN0ZewiNAWSe5khM3EyCmBb250+b40wWbwNILNfEvq84VREWwOIkuUsFONk/3i3nqkz7Oe1PcpM2mwQEK2L9Kg==", + "version": "4.12.18", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz", + "integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -4282,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": [ @@ -4301,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": { diff --git a/gitnexus/package.json b/gitnexus/package.json index 84f702762..810052502 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -116,6 +116,6 @@ } }, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" } } diff --git a/gitnexus/src/cli/analyze.ts b/gitnexus/src/cli/analyze.ts index 175db47e6..47745c575 100644 --- a/gitnexus/src/cli/analyze.ts +++ b/gitnexus/src/cli/analyze.ts @@ -27,6 +27,7 @@ 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 @@ -576,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) diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index b89b40db0..e4a455d40 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -160,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 ', '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. diff --git a/gitnexus/src/cli/publish.ts b/gitnexus/src/cli/publish.ts new file mode 100644 index 000000000..8aedc9c35 --- /dev/null +++ b/gitnexus/src/cli/publish.ts @@ -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 ` 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 => { + // ── 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 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; +}; diff --git a/gitnexus/src/cli/setup.ts b/gitnexus/src/cli/setup.ts index d1b7b520f..af3c4737a 100644 --- a/gitnexus/src/cli/setup.ts +++ b/gitnexus/src/cli/setup.ts @@ -365,7 +365,12 @@ async function installClaudeCodeHooks(result: SetupResult): Promise { } 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 () => { diff --git a/gitnexus/src/cli/wiki.ts b/gitnexus/src/cli/wiki.ts index e44566f8f..38a0f82a6 100644 --- a/gitnexus/src/cli/wiki.ts +++ b/gitnexus/src/cli/wiki.ts @@ -602,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( @@ -610,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; diff --git a/gitnexus/src/config/ignore-service.ts b/gitnexus/src/config/ignore-service.ts index ce1fda913..2c3eebe3b 100644 --- a/gitnexus/src/config/ignore-service.ts +++ b/gitnexus/src/config/ignore-service.ts @@ -25,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', diff --git a/gitnexus/src/core/augmentation/engine.ts b/gitnexus/src/core/augmentation/engine.ts index 896813c22..f97415cc9 100644 --- a/gitnexus/src/core/augmentation/engine.ts +++ b/gitnexus/src/core/augmentation/engine.ts @@ -104,7 +104,7 @@ export async function augment(pattern: string, cwd?: string): Promise { } // 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 ''; diff --git a/gitnexus/src/core/embeddings/embedder.ts b/gitnexus/src/core/embeddings/embedder.ts index b37fb45f3..72ddcbd70 100644 --- a/gitnexus/src/core/embeddings/embedder.ts +++ b/gitnexus/src/core/embeddings/embedder.ts @@ -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,7 @@ 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'; /** @@ -171,13 +176,18 @@ export const initEmbedder = async ( } 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); } @@ -202,17 +212,29 @@ export const initEmbedder = async ( 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) { @@ -228,6 +250,20 @@ export const initEmbedder = async ( 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'; logger.info(`⚠️ ${gpuType} not available, falling back to CPU...`); diff --git a/gitnexus/src/core/embeddings/hf-env.ts b/gitnexus/src/core/embeddings/hf-env.ts index 6a977a76d..5ae6d89ae 100644 --- a/gitnexus/src/core/embeddings/hf-env.ts +++ b/gitnexus/src/core/embeddings/hf-env.ts @@ -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(fn: () => Promise, timeoutMs: number): Promise { + return new Promise((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( + fn: () => Promise, + options: HfRetryOptions = {}, +): Promise { + // 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; + } +} diff --git a/gitnexus/src/core/embeddings/http-client.ts b/gitnexus/src/core/embeddings/http-client.ts index 85ad79111..e3fb06045 100644 --- a/gitnexus/src/core/embeddings/http-client.ts +++ b/gitnexus/src/core/embeddings/http-client.ts @@ -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 => { 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[] }; diff --git a/gitnexus/src/core/git-staleness.ts b/gitnexus/src/core/git-staleness.ts index 96f70ddd6..c90cef85e 100644 --- a/gitnexus/src/core/git-staleness.ts +++ b/gitnexus/src/core/git-staleness.ts @@ -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 { + 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 diff --git a/gitnexus/src/core/group/bridge-db.ts b/gitnexus/src/core/group/bridge-db.ts index dbf2350bb..7f44253bf 100644 --- a/gitnexus/src/core/group/bridge-db.ts +++ b/gitnexus/src/core/group/bridge-db.ts @@ -44,26 +44,6 @@ async function removeLbugFile(basePath: string): Promise { } } -/** - * Remove all stale `bridge.lbug.tmp.*` files (and their sidecars) from a - * group directory. With randomBytes-based temp names, a crashed writeBridge - * leaves behind a uniquely-named tmp file that no future run will target by - * name — so we glob for the prefix and clean up everything matching. - */ -async function cleanStaleBridgeTmpFiles(groupDir: string): Promise { - try { - const entries = await fsp.readdir(groupDir); - const staleBases = entries.filter( - (e) => e.startsWith('bridge.lbug.tmp.') && !LBUG_SIDECAR_SUFFIXES.some((s) => e.endsWith(s)), - ); - for (const name of staleBases) { - await removeLbugFile(path.join(groupDir, name)); - } - } catch { - /* best-effort: directory may not exist yet */ - } -} - export function contractNodeId( repo: string, contractId: string, @@ -299,8 +279,24 @@ export async function retryRename(src: string, dst: string, attempts = 3): Promi export async function writeBridgeMeta(groupDir: string, meta: BridgeMeta): Promise { const target = path.join(groupDir, 'meta.json'); + // 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')}`; - await fsp.writeFile(tmp, JSON.stringify(meta, null, 2), 'utf-8'); + 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 @@ -369,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.${randomBytes(8).toString('hex')}`); + // 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 = { @@ -389,43 +397,42 @@ export async function writeBridge( } }; - // Clean up stale tmp files left behind by previously crashed writeBridge - // runs. With randomBytes-based names each run picks a unique path, so - // the old fixed-name `removeLbugFile(tmpPath)` was a no-op — stale - // artifacts accumulated. The glob-based helper finds *all* leftover - // `bridge.lbug.tmp.*` entries and removes them (including sidecars). - await cleanStaleBridgeTmpFiles(groupDir); + // 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, @@ -438,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 { @@ -533,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 */ + }); + } } /* ------------------------------------------------------------------ */ @@ -705,8 +722,15 @@ export async function openBridgeDbReadOnly(groupDir: string): Promise setTimeout(r, delay)); } } + // Strip CRLF from user-controlled strings before logging to close + // CodeQL js/log-injection. Pino's NDJSON serialization already + // JSON-escapes all values, but we sanitize here as a defence-in-depth + // measure so CodeQL can see the taint flow is broken. + const safeGroupDir = String(groupDir).replace(/[\r\n]/g, ' '); + const safeErrMsg = + lastErr instanceof Error ? String(lastErr.message).replace(/[\r\n]/g, ' ') : undefined; bridgeLogger.debug( - { groupDir, err: lastErr, attempts: LBUG_OPEN_RETRY_ATTEMPTS }, + { groupDir: safeGroupDir, errMsg: safeErrMsg, attempts: LBUG_OPEN_RETRY_ATTEMPTS }, 'openBridgeDbReadOnly gave up', ); return null; diff --git a/gitnexus/src/core/group/config-parser.ts b/gitnexus/src/core/group/config-parser.ts index 73a9021b9..29c868171 100644 --- a/gitnexus/src/core/group/config-parser.ts +++ b/gitnexus/src/core/group/config-parser.ts @@ -4,9 +4,26 @@ 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', 'thrift', '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.` 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, @@ -14,6 +31,7 @@ const DEFAULT_DETECT = { topics: true, shared_libs: true, embedding_fallback: true, + includes: false, workspace_deps: false, }; diff --git a/gitnexus/src/core/group/cross-impact.ts b/gitnexus/src/core/group/cross-impact.ts index f8625cdc5..eab942a62 100644 --- a/gitnexus/src/core/group/cross-impact.ts +++ b/gitnexus/src/core/group/cross-impact.ts @@ -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): | { ok: true; @@ -143,13 +162,19 @@ export function validateGroupImpactParams(params: Record): 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[1], timeoutMs: number, ): Promise<{ value: unknown; timedOut: boolean }> { + const safeTimeoutMs = clampTimeout(timeoutMs); let timer: ReturnType | 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 | 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; } diff --git a/gitnexus/src/core/group/extractors/include-extractor.ts b/gitnexus/src/core/group/extractors/include-extractor.ts new file mode 100644 index 000000000..7bbfd61ed --- /dev/null +++ b/gitnexus/src/core/group/extractors/include-extractor.ts @@ -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::`. + * + * **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([...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 { + return true; + } + + async extract( + dbExecutor: CypherExecutor | null, + repoPath: string, + _repo: RepoHandle, + ): Promise { + // 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:` 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:` UIDs in cross-links correspond to graph File nodes. + */ + private async discoverIndexableFiles(repoPath: string): Promise { + 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 { + // 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 { + 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 { + const parser = new Parser(); + const out: ExtractedContract[] = []; + // Compile the include query once per grammar to avoid re-compilation per file + const queryCache = new Map(); + + 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(); + 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; + } +} diff --git a/gitnexus/src/core/group/extractors/manifest-extractor.ts b/gitnexus/src/core/group/extractors/manifest-extractor.ts index 2af3db595..f4d0f77cf 100644 --- a/gitnexus/src/core/group/extractors/manifest-extractor.ts +++ b/gitnexus/src/core/group/extractors/manifest-extractor.ts @@ -274,6 +274,14 @@ 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') { // Workspace extractors produce qualified contracts like "mathlex::Expression". // Graph nodes store the unqualified symbol name ("Expression"), so strip @@ -358,6 +366,8 @@ export class ManifestExtractor { 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)}`); diff --git a/gitnexus/src/core/group/extractors/rust-workspace-extractor.ts b/gitnexus/src/core/group/extractors/rust-workspace-extractor.ts index 63fe7ea82..d58c3e08f 100644 --- a/gitnexus/src/core/group/extractors/rust-workspace-extractor.ts +++ b/gitnexus/src/core/group/extractors/rust-workspace-extractor.ts @@ -31,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 @@ -47,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 } diff --git a/gitnexus/src/core/group/extractors/thrift-extractor.ts b/gitnexus/src/core/group/extractors/thrift-extractor.ts index cfd8fef02..709968790 100644 --- a/gitnexus/src/core/group/extractors/thrift-extractor.ts +++ b/gitnexus/src/core/group/extractors/thrift-extractor.ts @@ -217,6 +217,10 @@ export async function buildThriftContext(repoPath: string): Promise(); @@ -290,6 +294,10 @@ export class ThriftExtractor implements ContractExtractor { 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/**'], }); diff --git a/gitnexus/src/core/group/matching.ts b/gitnexus/src/core/group/matching.ts index 3431f8ddf..0b27655c6 100644 --- a/gitnexus/src/core/group/matching.ts +++ b/gitnexus/src/core/group/matching.ts @@ -107,6 +107,8 @@ export function normalizeContractId(id: string): string { 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; } diff --git a/gitnexus/src/core/group/service.ts b/gitnexus/src/core/group/service.ts index de324e70b..d0473048f 100644 --- a/gitnexus/src/core/group/service.ts +++ b/gitnexus/src/core/group/service.ts @@ -65,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; context( diff --git a/gitnexus/src/core/group/storage.ts b/gitnexus/src/core/group/storage.ts index 99bf27fbd..bc08fd7f9 100644 --- a/gitnexus/src/core/group/storage.ts +++ b/gitnexus/src/core/group/storage.ts @@ -4,6 +4,16 @@ 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'; @@ -35,10 +45,28 @@ export async function writeContractRegistry( registry: ContractRegistry, ): Promise { const targetPath = path.join(groupDir, CONTRACTS_FILE); - const tmpPath = `${targetPath}.tmp.${randomBytes(8).toString('hex')}`; + 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 { @@ -107,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; } diff --git a/gitnexus/src/core/group/sync.ts b/gitnexus/src/core/group/sync.ts index 7ed065131..cd64fdf8c 100644 --- a/gitnexus/src/core/group/sync.ts +++ b/gitnexus/src/core/group/sync.ts @@ -8,12 +8,14 @@ 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 { 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'; @@ -100,6 +102,7 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis const grpcEx = new GrpcExtractor(); const thriftEx = new ThriftExtractor(); const topicEx = new TopicExtractor(); + const includeEx = new IncludeExtractor(); dbExecutors = new Map(); const openPoolIds: string[] = []; @@ -168,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'); @@ -270,6 +284,28 @@ 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 { diff --git a/gitnexus/src/core/group/types.ts b/gitnexus/src/core/group/types.ts index 7d0a14251..8e43ff78f 100644 --- a/gitnexus/src/core/group/types.ts +++ b/gitnexus/src/core/group/types.ts @@ -1,4 +1,4 @@ -export type ContractType = 'http' | 'grpc' | 'thrift' | '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'; @@ -28,6 +28,7 @@ export interface DetectConfig { topics: boolean; shared_libs: boolean; embedding_fallback: boolean; + includes: boolean; workspace_deps: boolean; } diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index 6c59578b0..bf0206057 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -769,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; @@ -3280,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; diff --git a/gitnexus/src/core/ingestion/cobol/cobol-preprocessor.ts b/gitnexus/src/core/ingestion/cobol/cobol-preprocessor.ts index 23cedb99b..34be6bc03 100644 --- a/gitnexus/src/core/ingestion/cobol/cobol-preprocessor.ts +++ b/gitnexus/src/core/ingestion/cobol/cobol-preprocessor.ts @@ -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; diff --git a/gitnexus/src/core/ingestion/cpp-ue-preprocessor.ts b/gitnexus/src/core/ingestion/cpp-ue-preprocessor.ts new file mode 100644 index 000000000..baa5d17ff --- /dev/null +++ b/gitnexus/src/core/ingestion/cpp-ue-preprocessor.ts @@ -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(''); +} diff --git a/gitnexus/src/core/ingestion/field-extractors/configs/csharp.ts b/gitnexus/src/core/ingestion/field-extractors/configs/csharp.ts index 4d83a6cd7..75032a9d7 100644 --- a/gitnexus/src/core/ingestion/field-extractors/configs/csharp.ts +++ b/gitnexus/src/core/ingestion/field-extractors/configs/csharp.ts @@ -9,6 +9,11 @@ import type { SyntaxNode } from '../../utils/ast-helpers.js'; const CSHARP_VIS = new Set(['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; }, diff --git a/gitnexus/src/core/ingestion/heritage-processor.ts b/gitnexus/src/core/ingestion/heritage-processor.ts index 2c973ad8e..f8628e651 100644 --- a/gitnexus/src/core/ingestion/heritage-processor.ts +++ b/gitnexus/src/core/ingestion/heritage-processor.ts @@ -219,9 +219,13 @@ export const processHeritage = async ( let tree = astCache.get(file.path); if (!tree) { // Use larger bufferSize for files > 32KB + // Per-language source preprocessor (length-preserving, e.g. UE macro + // stripping for C++). MUST mirror parsing-processor on cache miss so + // re-parses see the same input as the cached AST. + const parseContent = provider.preprocessSource?.(file.content, file.path) ?? file.content; try { - tree = parser.parse(file.content, undefined, { - bufferSize: getTreeSitterBufferSize(file.content), + tree = parser.parse(parseContent, undefined, { + bufferSize: getTreeSitterBufferSize(parseContent), }); } catch (parseError) { // Skip files that can't be parsed @@ -413,9 +417,10 @@ export async function extractExtractedHeritageFromFiles( let tree = astCache.get(file.path); if (!tree) { + const parseContent = provider.preprocessSource?.(file.content, file.path) ?? file.content; try { - tree = parser.parse(file.content, undefined, { - bufferSize: getTreeSitterBufferSize(file.content), + tree = parser.parse(parseContent, undefined, { + bufferSize: getTreeSitterBufferSize(parseContent), }); } catch { continue; diff --git a/gitnexus/src/core/ingestion/import-processor.ts b/gitnexus/src/core/ingestion/import-processor.ts index 03cbed5b7..6f0b40b6f 100644 --- a/gitnexus/src/core/ingestion/import-processor.ts +++ b/gitnexus/src/core/ingestion/import-processor.ts @@ -305,9 +305,10 @@ export const processImports = async ( let wasReparsed = false; 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; diff --git a/gitnexus/src/core/ingestion/language-provider.ts b/gitnexus/src/core/ingestion/language-provider.ts index 12ce0839b..e139cf5f3 100644 --- a/gitnexus/src/core/ingestion/language-provider.ts +++ b/gitnexus/src/core/ingestion/language-provider.ts @@ -116,6 +116,39 @@ interface LanguageProviderConfig { * Required for tree-sitter languages; empty string for standalone processors. */ readonly treeSitterQueries: string; + /** + * Optional source-text transform that runs **before** tree-sitter parses the file. + * + * Used to elide language constructs that confuse the grammar without affecting + * source-position fidelity — e.g., Unreal Engine reflection macros (`UCLASS`, + * `UFUNCTION`, `MODULENAME_API`) in C++ headers that prevent the parser from + * recognising class/function names correctly. + * + * **Length / position preservation:** the returned string MUST have the same + * JavaScript `.length` as the input AND preserve every newline (`\n`/`\r`) + * position byte-for-byte. Implementations replace elided characters with + * ASCII spaces while leaving newlines untouched. With this contract: + * + * - tree-sitter's reported `startPosition.row`/`startPosition.column` + * match the original file exactly (line/column come from newline counts) + * - `startIndex`/`endIndex` byte offsets match the original file exactly + * **when the elided range is pure ASCII** (UTF-16 `.length` equals UTF-8 + * byte length only for ASCII). + * + * Implementations targeting languages where elided ranges may contain + * non-ASCII content must therefore preserve byte length, not just `.length`, + * if downstream code uses `startIndex` to slice the original UTF-8 bytes. + * The current C++ UE-macro preprocessor relies on the practical fact that + * UE reflection macros and module-export tokens are ASCII-only. + * + * Must be a pure function — same input always yields the same output. Called + * once per file, on every code path that re-parses (parsing-processor, import + * processor, heritage processor, call processor, parse worker). + * + * Default: undefined (no preprocessing — `file.content` is parsed verbatim). + */ + readonly preprocessSource?: (sourceText: string, filePath: string) => string; + // ── Core (required) ─────────────────────────────────────────────── /** Type extraction: declarations, initializers, for-loop bindings */ readonly typeConfig: LanguageTypeConfig; diff --git a/gitnexus/src/core/ingestion/languages/c-cpp.ts b/gitnexus/src/core/ingestion/languages/c-cpp.ts index a5b3e5729..693e8cec2 100644 --- a/gitnexus/src/core/ingestion/languages/c-cpp.ts +++ b/gitnexus/src/core/ingestion/languages/c-cpp.ts @@ -45,6 +45,7 @@ import { cVariableConfig, cppVariableConfig } from '../variable-extractors/confi import { createCallExtractor } from '../call-extractors/generic.js'; import { cCallConfig, cppCallConfig } from '../call-extractors/configs/c-cpp.js'; import { createHeritageExtractor } from '../heritage-extractors/generic.js'; +import { stripUeMacros } from '../cpp-ue-preprocessor.js'; const C_BUILT_INS: ReadonlySet = new Set([ 'printf', @@ -410,6 +411,7 @@ export const cppProvider = defineLanguage({ }, ] satisfies AstFrameworkPatternConfig[], treeSitterQueries: CPP_QUERIES, + preprocessSource: stripUeMacros, typeConfig: cCppConfig, exportChecker: cCppExportChecker, importResolver: createImportResolver(cppImportConfig), diff --git a/gitnexus/src/core/ingestion/languages/csharp/captures.ts b/gitnexus/src/core/ingestion/languages/csharp/captures.ts index 2f913a14e..e29552e78 100644 --- a/gitnexus/src/core/ingestion/languages/csharp/captures.ts +++ b/gitnexus/src/core/ingestion/languages/csharp/captures.ts @@ -42,6 +42,39 @@ const FUNCTION_NODE_TYPES = [ 'local_function_statement', ] as const; +const BUILTIN_TYPE_NAMES = new Set([ + 'bool', + 'byte', + 'char', + 'decimal', + 'double', + 'float', + 'int', + 'long', + 'object', + 'sbyte', + 'short', + 'string', + 'uint', + 'ulong', + 'ushort', + 'void', +]); + +function shouldEmitReadMember(memberNode: SyntaxNode): boolean { + const parent = memberNode.parent; + if (parent === null) return true; + + switch (parent.type) { + case 'invocation_expression': + return parent.childForFieldName('function')?.id !== memberNode.id; + case 'assignment_expression': + return parent.childForFieldName('left')?.id !== memberNode.id; + default: + return true; + } +} + export function emitCsharpScopeCaptures( sourceText: string, _filePath: string, @@ -94,6 +127,14 @@ export function emitCsharpScopeCaptures( continue; } + if (grouped['@reference.read.member'] !== undefined) { + const anchor = grouped['@reference.read.member']; + const memberNode = findNodeAtRange(tree.rootNode, anchor.range, 'member_access_expression'); + if (memberNode === null || !shouldEmitReadMember(memberNode)) { + continue; + } + } + // Synthesize `this` / `base` receiver type-bindings on every // instance method-like. Tree-sitter can't cleanly express "the // implicit receiver of a non-static member of a class/struct/ @@ -209,9 +250,63 @@ export function emitCsharpScopeCaptures( } } + out.push(...synthesizeGenericTypeArgumentReferences(tree.rootNode)); + return out; } +function synthesizeGenericTypeArgumentReferences(root: SyntaxNode): CaptureMatch[] { + const out: CaptureMatch[] = []; + // Treat all generic type arguments as static type references, including + // declaration signatures and call-site generic instantiations. + visit(root, (node) => { + if (node.type !== 'generic_name') return; + const args = findNamedChild(node, 'type_argument_list'); + if (args === null) return; + + for (const arg of args.namedChildren) { + if (arg === null) continue; + const nameNode = terminalTypeNameNode(arg); + if (nameNode === null) continue; + if (BUILTIN_TYPE_NAMES.has(nameNode.text)) continue; + out.push({ + '@reference.type': nodeToCapture('@reference.type', nameNode), + '@reference.name': nodeToCapture('@reference.name', nameNode), + }); + } + }); + return out; +} + +function terminalTypeNameNode(node: SyntaxNode): SyntaxNode | null { + switch (node.type) { + case 'identifier': + return node; + case 'nullable_type': + return node.firstNamedChild === null ? null : terminalTypeNameNode(node.firstNamedChild); + case 'qualified_name': + return node.lastNamedChild; + case 'generic_name': + return node.childForFieldName('name') ?? node.firstNamedChild; + default: + return null; + } +} + +function findNamedChild(node: SyntaxNode, type: string): SyntaxNode | null { + for (const child of node.namedChildren) { + if (child !== null && child.type === type) return child; + } + return null; +} + +function visit(node: SyntaxNode, cb: (node: SyntaxNode) => void): void { + cb(node); + for (const child of node.namedChildren) { + if (child !== null) visit(child, cb); + } +} + /** C# 12 primary constructor: `class X(a, b) { }` / `record X(a, b)`. * The parameters are a bare `parameter_list` named child of the type * declaration (no `constructor_declaration` node). Emit a synthetic diff --git a/gitnexus/src/core/ingestion/languages/csharp/query.ts b/gitnexus/src/core/ingestion/languages/csharp/query.ts index 11da2d8cd..f299aaafa 100644 --- a/gitnexus/src/core/ingestion/languages/csharp/query.ts +++ b/gitnexus/src/core/ingestion/languages/csharp/query.ts @@ -499,6 +499,12 @@ const CSHARP_SCOPE_QUERY = ` left: (member_access_expression expression: "base" @reference.receiver name: (identifier) @reference.name)) @reference.write.member + +;; References — field/property reads: \`obj.Name\` +;; Emit-side filtering drops call targets and assignment left-hand sides. +(member_access_expression + expression: (_) @reference.receiver + name: (identifier) @reference.name) @reference.read.member `; let _parser: Parser | null = null; diff --git a/gitnexus/src/core/ingestion/parsing-processor.ts b/gitnexus/src/core/ingestion/parsing-processor.ts index 8803ec023..98036fbe8 100644 --- a/gitnexus/src/core/ingestion/parsing-processor.ts +++ b/gitnexus/src/core/ingestion/parsing-processor.ts @@ -371,6 +371,11 @@ const processParsingSequential = async ( isVueSetup = extracted.isSetup; } + // Per-language source-text transform (e.g., UE macro stripping for C++). + // Length-preserving — see LanguageProvider.preprocessSource contract. + parseContent = + getProvider(language).preprocessSource?.(parseContent, file.path) ?? parseContent; + try { await loadLanguage(language, file.path); } catch { diff --git a/gitnexus/src/core/ingestion/vue-sfc-extractor.ts b/gitnexus/src/core/ingestion/vue-sfc-extractor.ts index 382417c56..f36a85ab4 100644 --- a/gitnexus/src/core/ingestion/vue-sfc-extractor.ts +++ b/gitnexus/src/core/ingestion/vue-sfc-extractor.ts @@ -23,7 +23,24 @@ interface ScriptBlock { lang: string; } -const SCRIPT_RE = /]*)?>([^]*?)<\/script>/g; +// Closing-tag pattern accepts: +// - whitespace before `>` — ``, `` +// - attribute-like junk after `script` — ``, +// `` +// - any case — ``, `` +// +// HTML5 parses `` as a valid close tag (attributes on +// close tags are ignored by the parser but still terminate the script +// block). A strict `<\/script\s*>` would miss those forms and let a +// crafted Vue file hide content from this extractor — exactly the +// CodeQL `js/bad-tag-filter` failure mode (the published test cases +// it checks include `` and ``). +// +// `[^>]*` after ``, +// matching the HTML parser's actual close-tag behaviour. The `i` flag +// covers the case axis. PR #1330 CI surfaced both the case and +// attribute axes; this expression closes both at once. +const SCRIPT_RE = /]*)?>([^]*?)<\/script[^>]*>/gi; const TEMPLATE_COMPONENT_RE = /<([A-Z][A-Za-z0-9]+)/g; // Greedy: matches from the first . // This is intentional — nested