diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..292cb435d --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,16 @@ +version: 2 +updates: + # Keep third-party Actions SHA pins current. See CONTRIBUTING.md — when + # reviewing these bumps, verify the SHA corresponds to the claimed tag by + # running `gh api repos///git/refs/tags/` before merge. + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + commit-message: + prefix: chore + include: scope + labels: + - dependencies + - ci diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml new file mode 100644 index 000000000..378a60e2c --- /dev/null +++ b/.github/release-drafter.yml @@ -0,0 +1,53 @@ +# release-drafter config — used only for PR autolabeling by +# `.github/workflows/pr-labeler.yml` (the workflow passes `disable-releaser: true`, +# so the draft-release side of release-drafter never runs). +# +# The labels applied here are the same ones `.github/release.yml` maps to +# categorized release-notes sections. +# +# `sync-labels: true` removes managed autolabels that no longer match the PR — +# critical for the breaking-change case: if a PR title drops the `!` or the body +# drops `BREAKING CHANGE:`, the `breaking` label is pulled off automatically. + +# Required by release-drafter; not used because releaser is disabled. +name-template: 'unused' +tag-template: 'unused' +template: | + $CHANGES + +sync-labels: true + +autolabeler: + - label: enhancement + title: + - '/^feat(\([^)]+\))?!?:/i' + - label: bug + title: + - '/^fix(\([^)]+\))?!?:/i' + - label: performance + title: + - '/^perf(\([^)]+\))?!?:/i' + - label: refactor + title: + - '/^refactor(\([^)]+\))?!?:/i' + - label: documentation + title: + - '/^docs(\([^)]+\))?!?:/i' + - label: test + title: + - '/^test(\([^)]+\))?!?:/i' + - label: ci + title: + - '/^ci(\([^)]+\))?!?:/i' + - label: dependencies + title: + - '/^(build|deps)(\([^)]+\))?!?:/i' + - label: chore + title: + - '/^(chore|revert)(\([^)]+\))?!?:/i' + # Breaking-change marker: either `!` in the type prefix or `BREAKING CHANGE:` in body. + - label: breaking + title: + - '/^[a-z]+(\([^)]+\))?!:/i' + body: + - '/BREAKING[ -]CHANGE:/i' diff --git a/.github/scripts/check-workflow-concurrency.py b/.github/scripts/check-workflow-concurrency.py new file mode 100644 index 000000000..300cc7f4b --- /dev/null +++ b/.github/scripts/check-workflow-concurrency.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +"""Enforce the GitHub Actions concurrency convention. + +See CONTRIBUTING.md -> "GitHub Actions — Concurrency Convention" for the rules. + +Invoked from .github/workflows/ci-quality.yml. Runs locally too: + python3 .github/scripts/check-workflow-concurrency.py .github/workflows + +Rules: + 1. Every entry-point (non-reusable) workflow declares a top-level + `concurrency:` block. + 2. Reusable workflows (on: workflow_call ONLY) do NOT declare one. + 3. The `concurrency.group` expression MUST reference either + `${{ github.workflow }}` or a literal `CI-` prefix (the documented + ci.yml reusable-workflow-safe exception). This is checked by substring + containment rather than prefix match because ci.yml's group is a + conditional expression that resolves to a `CI-…` literal at runtime. + +We deliberately do not use a YAML library — keeps the script dependency-free +on any vanilla runner. `on:` block parsing is line-based and handles both the +flat (`on: workflow_call`) and mapping (`on:\n workflow_call:`) forms. +""" + +from __future__ import annotations + +import pathlib +import re +import sys + + +REQUIRED_TOKENS = ("${{ github.workflow }}", "CI-") + + +def is_reusable(lines: list[str]) -> bool: + """Return True iff the workflow's `on:` block names only `workflow_call`.""" + in_on = False + on_indent: int | None = None + keys: list[str] = [] + + for raw in lines: + # Skip blank lines and comments + stripped = raw.strip() + if not stripped or stripped.startswith("#"): + continue + + indent = len(raw) - len(raw.lstrip(" ")) + + if not in_on: + if raw.startswith("on:"): + remainder = raw[len("on:"):].strip() + if not remainder: + # `on:` followed by indented mapping on next lines + in_on = True + on_indent = indent + continue + if remainder.startswith("[") and remainder.endswith("]"): + # Flow-style list: on: [workflow_call] + items = [ + item.strip() for item in remainder.strip("[]").split(",") + ] + return items == ["workflow_call"] + # Scalar form: on: workflow_call (or a single other event) + return remainder == "workflow_call" + continue + + # Inside the `on:` block; stop when indentation returns to <= on_indent + if on_indent is not None and indent <= on_indent: + break + + # Only consider keys at on_indent + indentation step (anything deeper + # is nested config like `types:`) + if ":" not in stripped: + continue + # Heuristic: first-level event keys are those with indent == on_indent + 2 + # (the canonical step for a 2-space YAML doc). We collect all first-level + # keys by tracking the smallest indent seen inside the block. + keys.append((indent, stripped.split(":", 1)[0].strip())) + + if not keys: + return False + + # Take only the outermost-indented keys as the event list + min_indent = min(i for i, _ in keys) + events = [name for i, name in keys if i == min_indent] + return events == ["workflow_call"] + + +CONCURRENCY_RE = re.compile(r"^concurrency:\s*$") +GROUP_RE = re.compile(r"^\s+group:\s*(.+?)\s*$") + + +def extract_group_key(lines: list[str]) -> str | None: + """Return the `group:` value of the top-level `concurrency:` block, or None.""" + for idx, raw in enumerate(lines): + if CONCURRENCY_RE.match(raw): + # Scan forward until we leave the concurrency block (next top-level key + # is at column 0 and ends with `:`). + for follow in lines[idx + 1:]: + if follow and not follow.startswith(" ") and follow.rstrip().endswith(":"): + break + m = GROUP_RE.match(follow) + if m: + return m.group(1).strip().strip("'").strip('"') + break + return None + + +def has_top_level_concurrency(lines: list[str]) -> bool: + return any(CONCURRENCY_RE.match(raw) for raw in lines) + + +def check(workflows_dir: pathlib.Path) -> int: + fail = 0 + files = sorted( + list(workflows_dir.glob("*.yml")) + list(workflows_dir.glob("*.yaml")) + ) + for path in files: + lines = path.read_text(encoding="utf-8").splitlines() + reusable = is_reusable(lines) + has_conc = has_top_level_concurrency(lines) + + if reusable: + if has_conc: + print( + f"::error file={path}::Reusable workflow (on: workflow_call) " + "must NOT declare its own concurrency block — it inherits " + "from the caller. See CONTRIBUTING.md -> GitHub Actions — " + "Concurrency Convention." + ) + fail = 1 + continue + + if not has_conc: + print( + f"::error file={path}::Missing top-level concurrency block. " + "See CONTRIBUTING.md -> GitHub Actions — Concurrency Convention." + ) + fail = 1 + continue + + group = extract_group_key(lines) + if group is None: + print( + f"::error file={path}::concurrency block is missing a " + "`group:` key." + ) + fail = 1 + continue + + if not any(token in group for token in REQUIRED_TOKENS): + print( + f"::error file={path}::concurrency.group `{group}` must " + f"reference one of {REQUIRED_TOKENS}. See CONTRIBUTING.md -> " + "GitHub Actions — Concurrency Convention." + ) + fail = 1 + + return fail + + +def main(argv: list[str]) -> int: + if len(argv) != 2: + print(f"usage: {argv[0]} ", file=sys.stderr) + return 2 + workflows_dir = pathlib.Path(argv[1]) + if not workflows_dir.is_dir(): + print(f"not a directory: {workflows_dir}", file=sys.stderr) + return 2 + return check(workflows_dir) + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/.github/workflows/ci-e2e.yml b/.github/workflows/ci-e2e.yml index 675d9b382..a017af628 100644 --- a/.github/workflows/ci-e2e.yml +++ b/.github/workflows/ci-e2e.yml @@ -11,8 +11,8 @@ jobs: outputs: web_changed: ${{ steps.filter.outputs.web }} steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v3 id: filter with: filters: | @@ -26,7 +26,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 20 steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: ./.github/actions/setup-gitnexus-web @@ -74,7 +74,7 @@ jobs: - name: Upload test results if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: e2e-results path: | diff --git a/.github/workflows/ci-global-upgrade.yml b/.github/workflows/ci-global-upgrade.yml new file mode 100644 index 000000000..e181f46ad --- /dev/null +++ b/.github/workflows/ci-global-upgrade.yml @@ -0,0 +1,113 @@ +name: Global Install Upgrade Smoke + +# Catches regressions where `npm install -g gitnexus@` fails to upgrade +# cleanly over a prior global install. Prior precedent: issue #836 and PR #843's +# incomplete fix slipped past CI because no global-upgrade test existed. +# +# Reusable workflow — only callable from ci.yml. Concurrency is governed by the +# caller (ci.yml), so no `concurrency:` block here. + +on: + workflow_call: + +jobs: + global-upgrade: + name: ${{ matrix.os }} / upgrade over ${{ matrix.prior }} + strategy: + fail-fast: false + matrix: + # macOS is the reporter's platform (issue #836) and the highest-risk + # surface for npm global-install rmdir behavior. Linux and Windows + # provide cross-platform regression coverage. + os: [macos-latest, ubuntu-latest, windows-latest] + # Prior version that must be upgraded OVER. Should be a published rc + # that preceded the fix. Bump when a known-bad version changes. + prior: ['1.6.2-rc.8'] + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - uses: ./.github/actions/setup-gitnexus + with: + build: 'false' + + - name: Install prior published version globally + run: npm install -g gitnexus@${{ matrix.prior }} + + - name: Verify prior version installed + run: gitnexus --version + + - name: Pack current branch + working-directory: gitnexus + run: npm pack + shell: bash + + - name: Compute packed tarball path + id: tarball + working-directory: gitnexus + run: | + TARBALL=$(ls gitnexus-*.tgz | head -1) + echo "path=$(pwd)/$TARBALL" >> "$GITHUB_OUTPUT" + shell: bash + + - name: Upgrade over prior version (the actual regression test) + run: npm install -g "${{ steps.tarball.outputs.path }}" + shell: bash + + - name: Verify upgraded version runs + run: gitnexus --version + + - name: Verify vendor/ has no nested node_modules after install + shell: bash + run: | + # The original #836 bug was about vendor/tree-sitter-proto/node_modules/ + # blocking rmdir on upgrade. That is what the fix eliminates. A + # vendor/tree-sitter-proto/build/ directory can still appear because + # node-gyp-build compiles through the npm-created symlink; the + # contents are plain object files and .node binaries that rmdir + # handles fine, evidenced by this test getting past the upgrade step. + GLOBAL_PREFIX=$(npm root -g) + if [ -d "$GLOBAL_PREFIX/gitnexus/vendor/tree-sitter-proto" ]; then + echo "=== Contents of global vendor/tree-sitter-proto/ ===" + ls -la "$GLOBAL_PREFIX/gitnexus/vendor/tree-sitter-proto/" + if [ -d "$GLOBAL_PREFIX/gitnexus/vendor/tree-sitter-proto/node_modules" ]; then + echo "::error::vendor/tree-sitter-proto/node_modules/ was created — this is the #836 hazard" + exit 1 + fi + fi + + ignore-scripts: + name: ${{ matrix.os }} / --ignore-scripts degraded mode + strategy: + fail-fast: false + matrix: + os: [macos-latest, ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 10 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - uses: ./.github/actions/setup-gitnexus + with: + build: 'false' + + - name: Pack current branch + working-directory: gitnexus + run: npm pack + shell: bash + + - name: Compute packed tarball path + id: tarball + working-directory: gitnexus + run: | + TARBALL=$(ls gitnexus-*.tgz | head -1) + echo "path=$(pwd)/$TARBALL" >> "$GITHUB_OUTPUT" + shell: bash + + - name: Install globally with --ignore-scripts + run: npm install -g --ignore-scripts "${{ steps.tarball.outputs.path }}" + shell: bash + + - name: Verify CLI boots without postinstall (proto parsing may be unavailable) + run: gitnexus --version diff --git a/.github/workflows/ci-quality.yml b/.github/workflows/ci-quality.yml index 9a5b9fedd..5a0da5fd1 100644 --- a/.github/workflows/ci-quality.yml +++ b/.github/workflows/ci-quality.yml @@ -8,8 +8,8 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version: 20 cache: npm @@ -21,8 +21,8 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version: 20 cache: npm @@ -34,7 +34,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: ./.github/actions/setup-gitnexus - run: npx tsc --noEmit working-directory: gitnexus @@ -43,7 +43,30 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: ./.github/actions/setup-gitnexus-web - run: npx tsc -b --noEmit working-directory: gitnexus-web + + # Enforces the convention documented in CONTRIBUTING.md → "GitHub Actions — + # Concurrency Convention": + # 1. Every entry-point (non-reusable) workflow declares a top-level + # `concurrency:` block. + # 2. Reusable workflows (`on: workflow_call` only) do NOT declare one — + # they inherit concurrency from the caller. + # 3. The concurrency group key starts with `${{ github.workflow }}` or + # the literal `CI-` prefix (the documented ci.yml exception for + # reusable-workflow-safe grouping). + # Reusability is detected by parsing each workflow's `on:` block, not an + # allowlist, so new reusable workflows never produce false positives. + workflow-convention: + name: Workflow concurrency convention + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Validate workflow concurrency convention + shell: bash + run: | + set -euo pipefail + python3 .github/scripts/check-workflow-concurrency.py .github/workflows diff --git a/.github/workflows/ci-report.yml b/.github/workflows/ci-report.yml index 2a6e5cea8..a1fa33219 100644 --- a/.github/workflows/ci-report.yml +++ b/.github/workflows/ci-report.yml @@ -14,6 +14,16 @@ permissions: contents: read # needed for sparse checkout of vitest.config.ts pull-requests: write # needed to post sticky PR comment +# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention". +# Serialize sticky-comment writes per PR so two rapid CI completions don't race. +# Internal PRs surface in `pull_requests[0].number`. Fork PRs leave that array empty, +# so we fall back to `/`, which is stable across +# reruns and subsequent pushes for the same fork PR (unlike `workflow_run.id` which +# is unique per run and therefore does not serialize anything). +concurrency: + 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 + jobs: pr-report: name: PR Report @@ -113,7 +123,7 @@ jobs: - name: Checkout (for vitest config) if: steps.meta.outputs.skip != 'true' - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: sparse-checkout: gitnexus/vitest.config.ts sparse-checkout-cone-mode: false diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 6a20032c6..27eb75383 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 25 steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: ./.github/actions/setup-gitnexus with: build: 'true' @@ -43,7 +43,7 @@ jobs: - name: Upload test reports if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: test-reports path: | @@ -63,7 +63,7 @@ jobs: runs-on: ${{ matrix.os }} timeout-minutes: 25 steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: ./.github/actions/setup-gitnexus with: build: 'true' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba7184ace..e2eeeaab2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,9 +9,20 @@ on: paths-ignore: ['**.md', 'docs/**', 'LICENSE'] workflow_call: +# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention". +# Hardcoded `CI-` prefix (not `${{ github.workflow }}`) because this workflow is +# invoked as a reusable workflow from publish.yml and release-candidate.yml. In +# called-workflow context `github.workflow` evaluation is ambiguous across GitHub +# Actions versions, and a prefix that could resolve to the caller's name would +# share a concurrency group with the caller → deadlock. A literal prefix is +# immune. Direct `push`/`pull_request` invocations use `CI-`; invocations +# from a reusable-workflow caller fall into a per-run-unique group that never +# serializes with the caller. +# cancel-in-progress is event-aware: cancel superseded PR runs, queue every other +# event (push to main, workflow_call from publish.yml, etc.). concurrency: - group: ci-${{ github.ref }} - cancel-in-progress: true + group: ${{ (github.event_name == 'pull_request' || github.event_name == 'push') && format('CI-{0}', github.ref) || format('CI-nested-{0}', github.run_id) }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} # ── Reusable workflow orchestration ───────────────────────────────── # Each concern lives in its own workflow file for maintainability: @@ -37,6 +48,11 @@ jobs: permissions: contents: read + global-upgrade: + uses: ./.github/workflows/ci-global-upgrade.yml + permissions: + contents: read + # ── Save PR metadata for the reporting workflow ───────────────── # The ci-report.yml workflow (triggered by workflow_run) needs the # PR number and job results to post a comment. We save them as an @@ -45,7 +61,7 @@ jobs: save-pr-meta: name: Save PR Metadata if: always() && github.event_name == 'pull_request' - needs: [quality, tests, e2e] + needs: [quality, tests, e2e, global-upgrade] runs-on: ubuntu-latest timeout-minutes: 5 steps: @@ -56,6 +72,7 @@ jobs: QUALITY: ${{ needs.quality.result }} TESTS: ${{ needs.tests.result }} E2E: ${{ needs.e2e.result }} + GLOBAL_UPGRADE: ${{ needs.global-upgrade.result }} run: | mkdir -p pr-meta echo "$PR_NUMBER" > pr-meta/pr_number @@ -74,7 +91,7 @@ jobs: cp pr-meta/e2e_result pr-meta/e2e-result - name: Upload PR metadata - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: pr-meta path: pr-meta/ @@ -84,7 +101,7 @@ jobs: # Single required check for branch protection. ci-status: name: CI Gate - needs: [quality, tests, e2e] + needs: [quality, tests, e2e, global-upgrade] if: always() runs-on: ubuntu-latest timeout-minutes: 5 @@ -95,10 +112,12 @@ jobs: QUALITY: ${{ needs.quality.result }} TESTS: ${{ needs.tests.result }} E2E: ${{ needs.e2e.result }} + GLOBAL_UPGRADE: ${{ needs.global-upgrade.result }} run: | - echo "Quality: $QUALITY" - echo "Tests: $TESTS" - echo "E2E: $E2E" + echo "Quality: $QUALITY" + echo "Tests: $TESTS" + echo "E2E: $E2E" + echo "Global upgrade: $GLOBAL_UPGRADE" if [[ "$QUALITY" != "success" ]] || [[ "$TESTS" != "success" ]]; then echo "::error::Quality or test jobs failed" @@ -108,3 +127,7 @@ jobs: echo "::error::E2E job failed" exit 1 fi + if [[ "$GLOBAL_UPGRADE" != "success" && "$GLOBAL_UPGRADE" != "skipped" ]]; then + echo "::error::Global upgrade smoke failed" + exit 1 + fi diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 82a65f844..d9d9decf6 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -16,9 +16,10 @@ on: issue_comment: types: [created] +# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention". # Serialize per-PR to avoid racing review comments. concurrency: - group: claude-review-${{ github.event.issue.number || github.event.pull_request.number }} + group: ${{ github.workflow }}-${{ github.event.issue.number || github.event.pull_request.number }} cancel-in-progress: false jobs: @@ -76,7 +77,7 @@ jobs: core.setOutput('branch', pr.head.ref); - name: Checkout PR head - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: repository: ${{ steps.pr.outputs.repo }} ref: ${{ steps.pr.outputs.sha }} diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 407b2fcf8..c4a2e9450 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -10,9 +10,10 @@ on: pull_request_review: types: [submitted] +# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention". # Serialize per-PR/issue to avoid racing comments. concurrency: - group: claude-code-${{ github.event.issue.number || github.event.pull_request.number || github.event.issue.id }} + group: ${{ github.workflow }}-${{ github.event.issue.number || github.event.pull_request.number || github.event.issue.id }} cancel-in-progress: false jobs: @@ -90,7 +91,7 @@ jobs: core.setOutput('branch', pr.head.ref); - name: Checkout repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: repository: ${{ steps.pr.outputs.is_pr == 'true' && steps.pr.outputs.repo || github.repository }} ref: ${{ steps.pr.outputs.is_pr == 'true' && steps.pr.outputs.sha || '' }} diff --git a/.github/workflows/pr-description-check.yml b/.github/workflows/pr-description-check.yml index d722dfff3..de562fd35 100644 --- a/.github/workflows/pr-description-check.yml +++ b/.github/workflows/pr-description-check.yml @@ -8,8 +8,9 @@ on: permissions: pull-requests: write +# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention". concurrency: - group: pr-desc-${{ github.event.pull_request.number }} + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} cancel-in-progress: true jobs: diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml new file mode 100644 index 000000000..aab2fcaef --- /dev/null +++ b/.github/workflows/pr-labeler.yml @@ -0,0 +1,113 @@ +name: PR Conventional Labeler + +# Two workflows in one file with different triggers, matched to the minimum +# privilege each needs: +# +# validate-title (on: pull_request) +# Fork-safe. Runs with the PR-head's read-only GITHUB_TOKEN. Uses +# `amannn/action-semantic-pull-request` to fail the check when the PR +# title doesn't follow the conventional-commit format. Because the +# action only reads the event payload, no fork-controlled code runs. +# +# autolabel (on: pull_request_target) +# Needs `pull-requests: write` to apply labels, so must be +# pull_request_target. Uses `release-drafter/release-drafter` with +# `disable-releaser: true` to only run the autolabeler against the +# `.github/release-drafter.yml` config from the BASE ref (release- +# drafter reads the config from the repository's default branch, NOT +# the PR head — verify with `gh api repos/release-drafter/release-drafter/contents/...` +# or a fork-test PR before merging if the repo is high-value). +# `sync-labels: true` in the config removes managed autolabels that no +# longer match (e.g. when `!` or `BREAKING CHANGE:` is dropped). +# +# Title format: [(scope)][!]: +# Allowed types: feat, fix, perf, refactor, docs, test, ci, build, chore, revert, deps +# Trailing `!` on the type marks a breaking change. +# See CONTRIBUTING.md → "Pull request titles". + +on: + pull_request: + # Title-only changes fire `edited`. `opened` and `reopened` cover creation. + # `synchronize` (push to the PR branch) is intentionally excluded — titles + # don't change on push, so it only wastes CI minutes and broadens the + # privileged-token exposure window on the autolabel job. + types: [opened, edited, reopened] + pull_request_target: + types: [opened, edited, reopened] + +# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention". +# Include `github.event_name` so `pull_request` (validate-title) and +# `pull_request_target` (autolabel) runs for the same PR do NOT share a slot +# and therefore cannot cancel each other — a cancelled required-check would +# permanently block merge until the next title edit. +# Within each trigger the latest title edit still supersedes the prior run. +concurrency: + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + validate-title: + # Fork-safe job — only runs on `pull_request` (not `pull_request_target`). + # Token is read-only; writes a commit status that branch protection can + # require before merge. + name: Validate PR title + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + pull-requests: read + steps: + # Pinned to v5.5.3. Verify SHA via: + # gh api repos/amannn/action-semantic-pull-request/git/refs/tags/v5.5.3 + - uses: amannn/action-semantic-pull-request@0723387faaf9b38adef4775cd42cfd5155ed6017 # v5.5.3 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + types: | + feat + fix + perf + refactor + docs + test + ci + build + chore + revert + deps + requireScope: false + # Subject must be non-empty. We DO allow capitalized proper nouns + # (MCP, GitHub, API, etc.) — the old `^(?![A-Z]).+$` pattern + # rejected legitimate titles like `fix: MCP tool schema`. + subjectPattern: ^\S.{2,}$ + subjectPatternError: | + The subject "{subject}" in PR title "{title}" is invalid. + Subjects must be at least 3 characters and must not start with whitespace. + wip: false + + autolabel: + # Privileged job — runs only on `pull_request_target` so it can write labels. + # Never checks out fork code, never executes fork-controlled input; only + # reads the PR metadata (title, body, labels) and calls the GitHub API. + name: Apply conventional label + if: github.event_name == 'pull_request_target' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + # `contents: read` is required — release-drafter's context.config() reads + # `.github/release-drafter.yml` from the repo's default branch via the + # repo-contents API. Without it the job silently 403s and no labels are + # applied. Job-level permissions nullify all unlisted scopes, so an + # explicit grant is necessary here. + contents: read + pull-requests: write + steps: + # Pinned to v6.0.0. Verify SHA via: + # gh api repos/release-drafter/release-drafter/git/refs/tags/v6.0.0 + # Note: dependabot will likely propose a bump to v6.x on first run. + - uses: release-drafter/release-drafter@3f0f87098bd6b5c5b9a36d49c41d998ea58f9348 # v6.0.0 + with: + config-name: release-drafter.yml + disable-releaser: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8a0ee6ebc..03898a267 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -7,13 +7,22 @@ on: # No workflow-level permissions — scoped per job below. +# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention". +# Tag refs are unique per release, so distinct tags run in parallel. Re-pushes of the +# same tag serialize. cancel-in-progress: false — never cancel a publish mid-flight. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + jobs: ci: uses: ./.github/workflows/ci.yml permissions: contents: read actions: read - pull-requests: write + # No pull-requests:write — `ci.yml`'s save-pr-meta job is gated on + # `github.event_name == 'pull_request'`, so it never runs during a + # tag-triggered publish. Least-privilege for release-critical paths. publish: needs: ci @@ -23,8 +32,8 @@ jobs: contents: write id-token: write steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version: 20 registry-url: https://registry.npmjs.org diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index ed9bb1780..cc22f4749 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -38,11 +38,11 @@ on: # No workflow-level permissions — scoped per job below. permissions: {} +# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention". +# Serialize all runs on the same ref (push + workflow_dispatch) to prevent two publishes +# racing on the rc counter. cancel-in-progress: false — the earlier merge publishes first. concurrency: - # Serialize all runs on the same ref (push + workflow_dispatch) to prevent - # two publishes racing on the rc counter. Do not cancel an in-progress run - # when a newer one is queued — we want the earlier merge to publish first. - group: release-candidate-${{ github.ref }} + group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: false jobs: @@ -62,7 +62,7 @@ jobs: should_run: ${{ steps.decide.outputs.should_run }} head_sha: ${{ steps.decide.outputs.head_sha }} steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 fetch-tags: true @@ -126,12 +126,12 @@ jobs: contents: write # push rc tag + marker id-token: write # npm provenance steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 fetch-tags: true - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version: 20 registry-url: https://registry.npmjs.org diff --git a/.github/workflows/triage-sweep.yml b/.github/workflows/triage-sweep.yml index ba5514dbf..43d67828d 100644 --- a/.github/workflows/triage-sweep.yml +++ b/.github/workflows/triage-sweep.yml @@ -47,8 +47,10 @@ permissions: issues: write pull-requests: write +# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention". +# Single global slot — newest manual dispatch supersedes any in-flight run. concurrency: - group: triage-sweep + group: ${{ github.workflow }} cancel-in-progress: true jobs: @@ -74,7 +76,7 @@ jobs: run: pip install -r .github/scripts/triage/requirements.txt - name: Cache FastEmbed model weights - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: path: ${{ github.workspace }}/.fastembed_cache key: fastembed-bge-small-en-v1.5 diff --git a/.gitignore b/.gitignore index a19a6a556..c775f0ba5 100644 --- a/.gitignore +++ b/.gitignore @@ -82,6 +82,11 @@ GitNexus.sln # Git worktrees .worktrees/ +# Vendored tree-sitter grammar build artifacts (created at install time, +# never committed). See docs/plans/2026-04-15-002-fix-tree-sitter-proto-vendor-deps-plan.md +gitnexus/vendor/**/build/ +gitnexus/vendor/**/node_modules/ + /github/scripts/triage/__pycache__/ .claude-flow/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7247750f5..d2d48f017 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,18 +21,41 @@ This project uses the [PolyForm Noncommercial License 1.0.0](https://polyformpro ## Branch and pull requests - Use short-lived branches off the default branch of the repo you are targeting. -- Prefer **conventional commits** (short prefix + description), for example: - - ```text - feat: add graph export option - fix: correct MCP tool schema for query - test: cover cluster merge edge case - docs: clarify analyze flags - ``` - -- **PR title:** `[area] Short description` (e.g. `[cli] Fix index refresh race`). +- **PR titles MUST follow the conventional-commit format** — `pr-labeler.yml` enforces this on every PR and auto-applies the matching label so release notes group the change correctly. - **PR description:** what changed, why, how to verify (commands), and any risk or rollback notes. +### Pull request titles + +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) | + +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. + +Examples: + +```text +feat(web): add smart chat scroll +fix(extractors): resolve silent contract mis-resolution +perf: avoid O(n²) traversal in heritage walker +chore(deps): bump vitest to 3.0.0 +ci: standardize workflow concurrency +``` + +Commits within a PR may use any style — only the **merged PR title** shows up in release notes, so that's the one the convention applies to. + ## Before you open a PR - [ ] Tests pass for the packages you touched (`gitnexus` and/or `gitnexus-web`). @@ -45,6 +68,41 @@ This project uses the [PolyForm Noncommercial License 1.0.0](https://polyformpro Maintainers may request changes for correctness, tests, performance, or consistency with existing patterns. Keeping diffs focused makes review faster. +## GitHub Actions — Concurrency Convention + +Every workflow under `.github/workflows/` MUST declare a top-level `concurrency:` block using this convention: + +- **Group key** starts with `${{ github.workflow }}` so no two workflows can collide on the same group name. The discriminator that follows is chosen per event shape: + - Branch/tag scope: `${{ github.workflow }}-${{ github.ref }}` + - Per-PR scope (for `issue_comment`, `pull_request_review*`, `pull_request` meta events): `${{ github.workflow }}-${{ github.event.pull_request.number || github.event.issue.number }}` + - `workflow_run` scope (e.g. `ci-report.yml`): `${{ 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) }}` — the fork fallback must be stable across reruns (never `workflow_run.id`, which is per-run-unique and defeats serialization). + - Global single-slot (manual dispatch utilities): `${{ github.workflow }}` + - **Reusable workflows invoked via `workflow_call`:** do NOT use `${{ github.workflow }}` in the group key — in called-workflow context its evaluation is ambiguous and can resolve to the caller's name, which would deadlock against the caller's own group. Use a hardcoded literal prefix and a `github.event_name`-aware expression that falls through to `github.run_id` for reusable invocations (see `ci.yml` for the canonical form). + - **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 | + +- 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: + + ```yaml + concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + ``` + +- When adding a new workflow, copy the concurrency block from an existing workflow of the same event shape. + ## 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. diff --git a/gitnexus/.npmignore b/gitnexus/.npmignore index 6a4cfb118..cf403314a 100644 --- a/gitnexus/.npmignore +++ b/gitnexus/.npmignore @@ -9,6 +9,10 @@ tsconfig.json .gitignore node_modules/ +# Vendor build artifacts (created during install, not shipped) +vendor/**/node_modules +vendor/**/build + # Package lock (consumers use their own) package-lock.json diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index bf8b5bb35..def88c93c 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -62,6 +62,8 @@ "node": ">=20.0.0" }, "optionalDependencies": { + "node-addon-api": "^8.0.0", + "node-gyp-build": "^4.8.0", "tree-sitter-dart": "git+https://github.com/UserNobody14/tree-sitter-dart.git#80e23c07b64494f7e21090bb3450223ef0b192f4", "tree-sitter-kotlin": "^0.3.8", "tree-sitter-proto": "file:./vendor/tree-sitter-proto", @@ -1226,6 +1228,12 @@ "win32" ] }, + "node_modules/@ladybugdb/core/node_modules/node-addon-api": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", + "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", + "license": "MIT" + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.28.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.28.0.tgz", @@ -4118,10 +4126,13 @@ } }, "node_modules/node-addon-api": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", - "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", - "license": "MIT" + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", + "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } }, "node_modules/node-api-headers": { "version": "1.8.0", @@ -5069,24 +5080,6 @@ } } }, - "node_modules/tree-sitter-c-sharp/node_modules/node-addon-api": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", - "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==", - "license": "MIT", - "engines": { - "node": "^18 || ^20 || >= 21" - } - }, - "node_modules/tree-sitter-c/node_modules/node-addon-api": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", - "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==", - "license": "MIT", - "engines": { - "node": "^18 || ^20 || >= 21" - } - }, "node_modules/tree-sitter-cli": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/tree-sitter-cli/-/tree-sitter-cli-0.23.2.tgz", @@ -5121,18 +5114,9 @@ } } }, - "node_modules/tree-sitter-cpp/node_modules/node-addon-api": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", - "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==", - "license": "MIT", - "engines": { - "node": "^18 || ^20 || >= 21" - } - }, "node_modules/tree-sitter-dart": { "version": "1.0.0", - "resolved": "git+https://github.com/UserNobody14/tree-sitter-dart.git#80e23c07b64494f7e21090bb3450223ef0b192f4", + "resolved": "git+ssh://git@github.com/UserNobody14/tree-sitter-dart.git#80e23c07b64494f7e21090bb3450223ef0b192f4", "integrity": "sha512-Bs/1wAOIJ2akPEXlE/XVpuES19Oo3NqoSJRJ/0N2r38qAd9nTXdqmaGHQ44/JXnA6QHcbgD2YzCCc4wUc98cyQ==", "hasInstallScript": true, "license": "ISC", @@ -5176,15 +5160,6 @@ } } }, - "node_modules/tree-sitter-go/node_modules/node-addon-api": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", - "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==", - "license": "MIT", - "engines": { - "node": "^18 || ^20 || >= 21" - } - }, "node_modules/tree-sitter-java": { "version": "0.23.5", "resolved": "https://registry.npmjs.org/tree-sitter-java/-/tree-sitter-java-0.23.5.tgz", @@ -5204,15 +5179,6 @@ } } }, - "node_modules/tree-sitter-java/node_modules/node-addon-api": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", - "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==", - "license": "MIT", - "engines": { - "node": "^18 || ^20 || >= 21" - } - }, "node_modules/tree-sitter-javascript": { "version": "0.23.1", "resolved": "https://registry.npmjs.org/tree-sitter-javascript/-/tree-sitter-javascript-0.23.1.tgz", @@ -5232,15 +5198,6 @@ } } }, - "node_modules/tree-sitter-javascript/node_modules/node-addon-api": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", - "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==", - "license": "MIT", - "engines": { - "node": "^18 || ^20 || >= 21" - } - }, "node_modules/tree-sitter-kotlin": { "version": "0.3.8", "resolved": "https://registry.npmjs.org/tree-sitter-kotlin/-/tree-sitter-kotlin-0.3.8.tgz", @@ -5287,15 +5244,6 @@ } } }, - "node_modules/tree-sitter-php/node_modules/node-addon-api": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", - "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==", - "license": "MIT", - "engines": { - "node": "^18 || ^20 || >= 21" - } - }, "node_modules/tree-sitter-proto": { "resolved": "vendor/tree-sitter-proto", "link": true @@ -5319,15 +5267,6 @@ } } }, - "node_modules/tree-sitter-python/node_modules/node-addon-api": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", - "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==", - "license": "MIT", - "engines": { - "node": "^18 || ^20 || >= 21" - } - }, "node_modules/tree-sitter-ruby": { "version": "0.23.1", "resolved": "https://registry.npmjs.org/tree-sitter-ruby/-/tree-sitter-ruby-0.23.1.tgz", @@ -5347,15 +5286,6 @@ } } }, - "node_modules/tree-sitter-ruby/node_modules/node-addon-api": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", - "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==", - "license": "MIT", - "engines": { - "node": "^18 || ^20 || >= 21" - } - }, "node_modules/tree-sitter-rust": { "version": "0.23.1", "resolved": "https://registry.npmjs.org/tree-sitter-rust/-/tree-sitter-rust-0.23.1.tgz", @@ -5375,15 +5305,6 @@ } } }, - "node_modules/tree-sitter-rust/node_modules/node-addon-api": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", - "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==", - "license": "MIT", - "engines": { - "node": "^18 || ^20 || >= 21" - } - }, "node_modules/tree-sitter-swift": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tree-sitter-swift/-/tree-sitter-swift-0.6.0.tgz", @@ -5413,16 +5334,6 @@ "license": "ISC", "optional": true }, - "node_modules/tree-sitter-swift/node_modules/node-addon-api": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", - "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==", - "license": "MIT", - "optional": true, - "engines": { - "node": "^18 || ^20 || >= 21" - } - }, "node_modules/tree-sitter-swift/node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -5459,24 +5370,6 @@ } } }, - "node_modules/tree-sitter-typescript/node_modules/node-addon-api": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", - "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==", - "license": "MIT", - "engines": { - "node": "^18 || ^20 || >= 21" - } - }, - "node_modules/tree-sitter/node_modules/node-addon-api": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", - "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==", - "license": "MIT", - "engines": { - "node": "^18 || ^20 || >= 21" - } - }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -5884,26 +5777,11 @@ }, "vendor/tree-sitter-proto": { "version": "0.4.1", - "hasInstallScript": true, "license": "MIT", "optional": true, - "dependencies": { - "node-addon-api": "^8.0.0", - "node-gyp-build": "^4.8.0" - }, "peerDependencies": { "tree-sitter": ">=0.21.0" } - }, - "vendor/tree-sitter-proto/node_modules/node-addon-api": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", - "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==", - "license": "MIT", - "optional": true, - "engines": { - "node": "^18 || ^20 || >= 21" - } } } } diff --git a/gitnexus/package.json b/gitnexus/package.json index 6448e716a..ad075041d 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -46,7 +46,7 @@ "test:integration": "vitest run test/integration", "test:watch": "vitest", "test:coverage": "vitest run --coverage", - "postinstall": "node scripts/patch-tree-sitter-swift.cjs", + "postinstall": "node scripts/patch-tree-sitter-swift.cjs && node scripts/build-tree-sitter-proto.cjs", "prepare": "node scripts/build.js", "prepack": "node scripts/build.js" }, @@ -84,6 +84,8 @@ "uuid": "^13.0.0" }, "optionalDependencies": { + "node-addon-api": "^8.0.0", + "node-gyp-build": "^4.8.0", "tree-sitter-dart": "git+https://github.com/UserNobody14/tree-sitter-dart.git#80e23c07b64494f7e21090bb3450223ef0b192f4", "tree-sitter-kotlin": "^0.3.8", "tree-sitter-proto": "file:./vendor/tree-sitter-proto", diff --git a/gitnexus/scripts/build-tree-sitter-proto.cjs b/gitnexus/scripts/build-tree-sitter-proto.cjs new file mode 100644 index 000000000..d2828d5ba --- /dev/null +++ b/gitnexus/scripts/build-tree-sitter-proto.cjs @@ -0,0 +1,82 @@ +#!/usr/bin/env node +/** + * Build tree-sitter-proto native binding. + * + * Why this script exists: + * tree-sitter-proto is vendored under gitnexus/vendor/tree-sitter-proto/ + * and declared as a `file:` optionalDependency. Previously, the vendored + * package had its own `dependencies` and `install` script, which caused + * npm to create `vendor/tree-sitter-proto/node_modules/` and + * `vendor/tree-sitter-proto/build/` during install. Those directories + * blocked `rmdir` on global-install upgrade, producing: + * + * ENOTEMPTY: directory not empty, rmdir + * '.../gitnexus/vendor/tree-sitter-proto/node_modules/node-addon-api' + * + * (See https://github.com/abhigyanpatwari/GitNexus/issues/836.) + * + * We stripped `dependencies` and the `install` script from the vendored + * package.json, hoisted `node-addon-api` and `node-gyp-build` into + * gitnexus's own optionalDependencies, and moved native compilation here. + * + * What this does: + * Runs `npx node-gyp rebuild` inside `node_modules/tree-sitter-proto/` + * (which npm creates as a copy of vendor/tree-sitter-proto/ when + * resolving the file: dep). Build output lands in + * `node_modules/tree-sitter-proto/build/Release/tree_sitter_proto_binding.node` + * — under npm-managed territory, safe on upgrade. + * + * Mirrors scripts/patch-tree-sitter-swift.cjs. Best-effort: if any + * precondition fails (optional dep absent, no toolchain, --ignore-scripts), + * warn and exit 0 so gitnexus install still succeeds. + */ +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); + +const protoDir = path.join(__dirname, '..', 'node_modules', 'tree-sitter-proto'); +const bindingGyp = path.join(protoDir, 'binding.gyp'); +const bindingNode = path.join(protoDir, 'build', 'Release', 'tree_sitter_proto_binding.node'); + +try { + if (!fs.existsSync(bindingGyp)) { + // tree-sitter-proto is an optionalDependency; absent when install + // skipped optional deps or the file: dep was not resolved. + process.exit(0); + } + + // Skip if the native binding already exists (idempotent re-run). + if (fs.existsSync(bindingNode)) { + process.exit(0); + } + + // Pre-flight: the hoisted build deps must be resolvable. + try { + require.resolve('node-addon-api'); + require.resolve('node-gyp-build'); + } catch (resolveErr) { + console.warn( + '[tree-sitter-proto] Skipping build: hoisted build deps not resolvable (%s).', + resolveErr.message, + ); + console.warn( + '[tree-sitter-proto] Proto parsing will be unavailable. Install without --no-optional and with scripts enabled to build.', + ); + process.exit(0); + } + + console.log('[tree-sitter-proto] Building native binding...'); + execSync('npx node-gyp rebuild', { + cwd: protoDir, + stdio: 'pipe', + timeout: 180000, + }); + console.log('[tree-sitter-proto] Native binding built successfully'); +} catch (err) { + console.warn('[tree-sitter-proto] Could not build native binding:', err.message); + console.warn( + '[tree-sitter-proto] Proto (.proto) parsing will be unavailable. Non-proto gitnexus functionality is unaffected.', + ); + // Exit 0: optionalDependency failures must not fail the gitnexus install. + process.exit(0); +} diff --git a/gitnexus/src/core/embeddings/embedder.ts b/gitnexus/src/core/embeddings/embedder.ts index e2d26c9e7..d27718ce5 100644 --- a/gitnexus/src/core/embeddings/embedder.ts +++ b/gitnexus/src/core/embeddings/embedder.ts @@ -157,6 +157,11 @@ export const initEmbedder = async ( try { // Configure transformers.js environment env.allowLocalModels = false; + // Default cache to user-writable location. transformers.js defaults to + // ./node_modules/.cache inside its own install dir, which is unwritable + // when gitnexus is installed globally (e.g. /usr/lib/node_modules/). + // Respect HF_HOME if set, otherwise fall back to ~/.cache/huggingface. + env.cacheDir = process.env.HF_HOME ?? `${process.env.HOME}/.cache/huggingface`; const isDev = process.env.NODE_ENV === 'development'; if (isDev) { diff --git a/gitnexus/src/mcp/core/embedder.ts b/gitnexus/src/mcp/core/embedder.ts index e53a46542..592c2bba9 100644 --- a/gitnexus/src/mcp/core/embedder.ts +++ b/gitnexus/src/mcp/core/embedder.ts @@ -42,6 +42,11 @@ export const initEmbedder = async (): Promise => { initPromise = (async () => { try { env.allowLocalModels = false; + // Default cache to user-writable location. transformers.js defaults to + // ./node_modules/.cache inside its own install dir, which is unwritable + // when gitnexus is installed globally (e.g. /usr/lib/node_modules/). + // Respect HF_HOME if set, otherwise fall back to ~/.cache/huggingface. + env.cacheDir = process.env.HF_HOME ?? `${process.env.HOME}/.cache/huggingface`; console.error('GitNexus: Loading embedding model (first search may take a moment)...'); diff --git a/gitnexus/vendor/tree-sitter-proto/package.json b/gitnexus/vendor/tree-sitter-proto/package.json index 387f3d9bb..aea236ea3 100644 --- a/gitnexus/vendor/tree-sitter-proto/package.json +++ b/gitnexus/vendor/tree-sitter-proto/package.json @@ -5,14 +5,8 @@ "repository": "https://github.com/coder3101/tree-sitter-proto", "license": "MIT", "main": "bindings/node", - "scripts": { - "install": "node-gyp-build" - }, + "_vendoredBy": "gitnexus — build deps (node-addon-api, node-gyp-build) are hoisted into gitnexus/package.json optionalDependencies, and native compilation is performed by gitnexus/scripts/build-tree-sitter-proto.cjs at gitnexus postinstall. Do NOT re-add a dependencies block or an install script here — doing so reintroduces https://github.com/abhigyanpatwari/GitNexus/issues/836 (ENOTEMPTY on global upgrade).", "peerDependencies": { "tree-sitter": ">=0.21.0" - }, - "dependencies": { - "node-addon-api": "^8.0.0", - "node-gyp-build": "^4.8.0" } }