diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
new file mode 100644
index 000000000..eb6b08207
--- /dev/null
+++ b/.github/workflows/codeql.yml
@@ -0,0 +1,71 @@
+name: CodeQL
+
+# Static analysis (SAST) for TypeScript/JavaScript and Python sources.
+# Findings upload to the GitHub Security tab as SARIF.
+#
+# Advisory only on first introduction — see docs/plans/2026-05-03-001-feat-automated-security-scans-plan.md.
+# Promote to a required check after baseline triage (operator decision).
+
+on:
+ pull_request:
+ branches: [main]
+ paths-ignore: ['**.md', 'docs/**', 'LICENSE']
+ push:
+ branches: [main]
+ schedule:
+ # Weekly Monday 06:00 UTC — catches advisories newly published against
+ # already-merged code without waiting for the next PR.
+ - cron: '0 6 * * 1'
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+
+jobs:
+ analyze:
+ name: Analyze (${{ matrix.language }})
+ runs-on: ubuntu-latest
+ timeout-minutes: 60
+ permissions:
+ actions: read
+ contents: read
+ # security-events:write is what enables SARIF upload to the Security tab.
+ security-events: write
+
+ strategy:
+ fail-fast: false
+ matrix:
+ language: [javascript-typescript, python]
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ # Don't leave GITHUB_TOKEN in .git/config for downstream steps to read.
+ persist-credentials: false
+
+ - name: Initialize CodeQL
+ uses: github/codeql-action/init@0daab03d71ff584ef619d027a3fd9146679c5d84 # v3.35.3
+ with:
+ languages: ${{ matrix.language }}
+ queries: security-and-quality
+ # Exclude generated/vendored code; tune after first-run signal.
+ # gitnexus/vendor/ holds tree-sitter-proto sources (regenerated, not authored).
+ # CodeQL path filters use .gitignore-style globs and do NOT support
+ # brace expansion — list each generated parser file separately.
+ config: |
+ paths-ignore:
+ - '**/dist/**'
+ - '**/node_modules/**'
+ - 'gitnexus/vendor/**'
+ - 'gitnexus/src/core/parsing/**/parser.c'
+ - 'gitnexus/src/core/parsing/**/parser.js'
+ # Test fixtures are intentionally synthetic inputs (broken/unused
+ # code, malformed samples) used to exercise the analyzer. CodeQL
+ # findings here are noise, not real bugs.
+ - '**/test/fixtures/**'
+
+ - name: Perform CodeQL Analysis
+ uses: github/codeql-action/analyze@0daab03d71ff584ef619d027a3fd9146679c5d84 # v3.35.3
+ with:
+ category: '/language:${{ matrix.language }}'
diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml
new file mode 100644
index 000000000..ad06267c2
--- /dev/null
+++ b/.github/workflows/dependency-review.yml
@@ -0,0 +1,36 @@
+name: Dependency Review
+
+# Blocks PRs that introduce dependencies with high/critical known vulnerabilities.
+# Reads the dependency graph diff between PR head and base.
+#
+# This is a required-check candidate after one week of clean runs
+# (operator decision — see docs/plans/2026-05-03-001-feat-automated-security-scans-plan.md).
+
+on:
+ pull_request:
+ branches: [main]
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ review:
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ permissions:
+ contents: read
+ # pull-requests:write enables the inline summary comment on failure.
+ pull-requests: write
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
+
+ - name: Dependency Review
+ uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.9.0
+ with:
+ fail-on-severity: high
+ comment-summary-in-pr: on-failure
diff --git a/.github/workflows/gitleaks.yml b/.github/workflows/gitleaks.yml
new file mode 100644
index 000000000..cfe4d0856
--- /dev/null
+++ b/.github/workflows/gitleaks.yml
@@ -0,0 +1,45 @@
+name: Gitleaks
+
+# Deterministic in-CI secret scanning. Defense-in-depth on top of GitHub's
+# native secret-scanning push protection (which is a repo Settings toggle —
+# see SECURITY.md for the recommended admin action).
+#
+# PR runs scan the diff (fast); main pushes scan full history.
+
+on:
+ pull_request:
+ branches: [main]
+ push:
+ branches: [main]
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+
+jobs:
+ gitleaks:
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ permissions:
+ contents: read
+ pull-requests: write
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ # Full history needed for the on-push full-history scan; on PRs the
+ # action diffs against the base ref so the cost is bounded by the PR.
+ fetch-depth: 0
+ # Don't bake the token into the cloned .git/config; downstream
+ # steps (and Gitleaks itself) don't need it for repo operations.
+ persist-credentials: false
+
+ # No GITLEAKS_LICENSE secret is required for OSS / public-repo usage.
+ # If this repo becomes private, the action will require a license key.
+ - name: Gitleaks
+ uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2.3.9
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GITLEAKS_ENABLE_UPLOAD_ARTIFACT: true
+ GITLEAKS_ENABLE_SUMMARY: true
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 1fc95a13a..d372c163a 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -37,8 +37,12 @@ jobs:
with:
node-version: 20
registry-url: https://registry.npmjs.org
- cache: npm
- cache-dependency-path: gitnexus/package-lock.json
+ # Hermetic install for the published artifact — no cache carry-over
+ # from non-tag contexts. setup-node v5+ caches by default when a
+ # packageManager field is present in package.json, so the explicit
+ # opt-out is required to clear the zizmor cache-poisoning audit.
+ # ~30s slower per release; runs rarely.
+ package-manager-cache: false
- name: Build gitnexus-shared
run: npm install && npm run build
working-directory: gitnexus-shared
diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml
index 7413a02e1..5b598ee14 100644
--- a/.github/workflows/release-candidate.yml
+++ b/.github/workflows/release-candidate.yml
@@ -137,8 +137,11 @@ jobs:
with:
node-version: 20
registry-url: https://registry.npmjs.org
- cache: npm
- cache-dependency-path: gitnexus/package-lock.json
+ # Hermetic install — release-candidate produces shipped artifacts.
+ # setup-node v5+ caches by default when a packageManager field is
+ # present in package.json; explicit opt-out is required to clear
+ # the zizmor cache-poisoning audit. See cache-poisoning audit.
+ package-manager-cache: false
- name: Build gitnexus-shared
run: npm install && npm run build
diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml
new file mode 100644
index 000000000..b132b7c3f
--- /dev/null
+++ b/.github/workflows/scorecard.yml
@@ -0,0 +1,58 @@
+name: Scorecard
+
+# OpenSSF Scorecard supply-chain posture check. Runs weekly + on main push +
+# branch_protection_rule changes. SARIF uploads to the Security tab; the public
+# badge URL resolves once the first scheduled run lands (see README badge wiring).
+
+on:
+ branch_protection_rule:
+ schedule:
+ - cron: '0 7 * * 1'
+ push:
+ branches: [main]
+ workflow_dispatch:
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: false
+
+permissions: read-all
+
+jobs:
+ analysis:
+ name: Scorecard analysis
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ permissions:
+ # Needed to upload SARIF results to the Security tab.
+ security-events: write
+ # Needed for the publish_results badge flow (OIDC).
+ id-token: write
+ contents: read
+ actions: read
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
+
+ - name: Run Scorecard
+ uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3
+ with:
+ results_file: results.sarif
+ results_format: sarif
+ # publish_results enables the public Scorecard badge.
+ publish_results: true
+
+ - name: Upload artifact
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: SARIF file
+ path: results.sarif
+ retention-days: 5
+
+ - name: Upload to Security tab
+ uses: github/codeql-action/upload-sarif@0daab03d71ff584ef619d027a3fd9146679c5d84 # v3.35.3
+ with:
+ sarif_file: results.sarif
diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml
new file mode 100644
index 000000000..5bc5d17a4
--- /dev/null
+++ b/.github/workflows/trivy.yml
@@ -0,0 +1,73 @@
+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.
+# 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.
+
+on:
+ push:
+ branches: [main]
+ schedule:
+ - cron: '0 8 * * 1'
+ workflow_dispatch:
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: false
+
+jobs:
+ scan:
+ name: Trivy (${{ matrix.image.name }})
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ permissions:
+ contents: read
+ security-events: write
+
+ strategy:
+ fail-fast: false
+ matrix:
+ image:
+ - { dockerfile: Dockerfile.cli, name: gitnexus-cli }
+ - { dockerfile: Dockerfile.web, name: gitnexus-web }
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
+
+ - name: Setup Buildx
+ uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
+
+ - name: Build image (load locally for scan)
+ uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
+ with:
+ context: .
+ file: ${{ matrix.image.dockerfile }}
+ load: true
+ push: false
+ tags: scan-target:${{ matrix.image.name }}
+
+ # aquasecurity/trivy-action versions < 0.35.0 are flagged by
+ # GHSA-69fq-xp46-6x23 (briefly compromised supply chain). Pinned to
+ # v0.36.0 (post-incident clean release) by commit SHA.
+ - name: Run Trivy
+ uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
+ with:
+ image-ref: scan-target:${{ matrix.image.name }}
+ format: sarif
+ output: trivy-${{ matrix.image.name }}.sarif
+ severity: HIGH,CRITICAL
+ # Hides CVEs with no available fix in the base image.
+ ignore-unfixed: true
+ exit-code: '0'
+
+ - name: Upload to Security tab
+ uses: github/codeql-action/upload-sarif@0daab03d71ff584ef619d027a3fd9146679c5d84 # v3.35.3
+ with:
+ sarif_file: trivy-${{ matrix.image.name }}.sarif
+ category: trivy-${{ matrix.image.name }}
diff --git a/.github/workflows/workflow-lint.yml b/.github/workflows/workflow-lint.yml
new file mode 100644
index 000000000..3dd492406
--- /dev/null
+++ b/.github/workflows/workflow-lint.yml
@@ -0,0 +1,58 @@
+name: Workflow Lint (zizmor)
+
+# Lints .github/workflows/** for known GitHub Actions security misconfigurations:
+# unpinned Actions, dangerous ${{ ... }} interpolation in run: blocks,
+# missing per-job permissions:, etc.
+#
+# Scoped to PRs that touch .github/** only — keeps off the typical PR critical path.
+
+on:
+ pull_request:
+ branches: [main]
+ paths:
+ - '.github/**'
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ zizmor:
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ permissions:
+ contents: read
+ security-events: write
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
+
+ - name: Setup Python
+ uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
+ with:
+ python-version: '3.12'
+
+ - name: Install zizmor
+ # Pinned — resolves to whatever's latest on PyPI otherwise.
+ # Bump via Dependabot pip ecosystem (see .github/dependabot.yml).
+ run: pipx install zizmor==1.24.1
+
+ # Initial threshold: medium. High+ findings fail the job; medium findings
+ # appear in the Security tab without blocking. Tune after first run.
+ # Per-rule exemptions for pre-existing intentional patterns live in
+ # .github/zizmor.yml (each carries a documented mitigation).
+ - name: Run zizmor
+ run: zizmor --config .github/zizmor.yml --format sarif --min-severity medium . > zizmor.sarif
+ continue-on-error: true
+
+ - name: Upload SARIF
+ uses: github/codeql-action/upload-sarif@0daab03d71ff584ef619d027a3fd9146679c5d84 # v3.35.3
+ with:
+ sarif_file: zizmor.sarif
+ category: zizmor
+
+ - name: Fail on high+ findings
+ run: zizmor --config .github/zizmor.yml --min-severity high .
diff --git a/.github/zizmor.yml b/.github/zizmor.yml
new file mode 100644
index 000000000..c534679c1
--- /dev/null
+++ b/.github/zizmor.yml
@@ -0,0 +1,34 @@
+# zizmor config — pre-existing intentional patterns flagged on initial introduction.
+# Each ignore below has a documented mitigation. Re-evaluate when the source workflow changes.
+#
+# To run zizmor locally with this config:
+# zizmor --config .github/zizmor.yml .
+
+rules:
+ dangerous-triggers:
+ ignore:
+ # workflow_run is REQUIRED to post sticky comments on fork PRs — the
+ # default-branch privileged token isn't accessible from `pull_request`
+ # on a fork. Mitigated by: read-only `actions:read` + `contents:read`
+ # for artifact download; `pull-requests:write` is the only write scope;
+ # no checkout of fork code occurs. Header comment in the file documents.
+ - ci-report.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,
+ # and claude-code-action sandboxes execution. Header comment documents.
+ - claude.yml
+
+ # pull_request_target on the autolabel job needs `pull-requests:write`
+ # to apply labels. Mitigated by: release-drafter runs with `dry-run:
+ # true`, reads only `.github/release-drafter.yml` from the BASE ref,
+ # and the validate-title job (which runs untrusted `pull_request`
+ # context) holds no write permissions. Header comment documents.
+ - pr-labeler.yml
+
+ # Note: cache-poisoning is NOT exempted. The two prior findings in
+ # publish.yml and release-candidate.yml were fixed structurally by
+ # dropping `cache: npm` from those workflows (matches the pattern used
+ # by PyO3/maturin for the same audit). See the commit that added this
+ # file for the rationale.
diff --git a/.prettierignore b/.prettierignore
index 8c43748f4..cc6247d1f 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -2,6 +2,7 @@ dist/
coverage/
gitnexus/vendor/
gitnexus/test/fixtures/
+gitnexus-web/test/fixtures/
gitnexus-web/playwright-report/
gitnexus-web/test-results/
*.d.ts
diff --git a/README.md b/README.md
index ade7a4879..2dad7f2ef 100644
--- a/README.md
+++ b/README.md
@@ -18,6 +18,9 @@
+
+
+
Enterprise (SaaS & Self-hosted) - akonlabs.com
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 000000000..79ef97f6b
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,67 @@
+# Security Policy
+
+## Supported Versions
+
+GitNexus is developed on `main`. Security fixes are applied to the latest released minor on npm (`gitnexus`) and to the published Docker images (`Dockerfile.cli`, `Dockerfile.web`). Older minors are not back-patched.
+
+## Reporting a Vulnerability
+
+**Please do not open a public GitHub issue for security reports.**
+
+Use **GitHub Private Vulnerability Reporting** for this repository:
+
+→ https://github.com/abhigyanpatwari/GitNexus/security/advisories/new
+
+Please include:
+
+- A description of the issue and its potential impact
+- Steps to reproduce (a minimal repro repo or commit hash if possible)
+- The affected version(s) — `npm view gitnexus version`, image digest, or commit SHA
+- Any suggested mitigation
+
+### What to expect
+
+- **Acknowledgement:** best-effort within 5 business days, subject to maintainer capacity.
+- **Triage:** we will confirm whether the report is in scope, request clarifications if needed, and propose a fix timeline.
+- **Disclosure:** coordinated. We will agree on a disclosure date with you before publishing an advisory.
+
+### Scope
+
+In scope:
+
+- The `gitnexus` CLI and MCP server (`gitnexus/`)
+- The `gitnexus-web` thin client (`gitnexus-web/`)
+- The `gitnexus-shared` types package (`gitnexus-shared/`)
+- The published Docker images (`Dockerfile.cli`, `Dockerfile.web`)
+- GitHub Actions workflows in `.github/workflows/`
+
+Out of scope:
+
+- Vulnerabilities in third-party dependencies that we have no influence over (please report upstream; if a viable mitigation exists at the GitNexus layer, that's in scope).
+- Issues requiring physical access to a developer machine or a compromised local environment.
+- Theoretical attacks without a practical exploit against a default GitNexus deployment.
+
+## Recommended Hardening for Forks and Self-Hosted Deployments
+
+If you fork GitNexus or self-host it, we recommend enabling the following in your repository's **Settings → Code security and analysis**:
+
+- **Private vulnerability reporting** — the channel described above.
+- **Dependabot alerts** — alerts on advisories affecting your dependencies.
+- **Dependabot security updates** — automated PRs for security patches (this repo's `.github/dependabot.yml` already covers version updates).
+- **Secret scanning** and **Push protection** — blocks pushes that introduce known secret patterns. Defense-in-depth on top of the in-CI Gitleaks scan documented below.
+- **Code scanning** — surfaces SARIF results from CodeQL, Trivy, Scorecard, and zizmor in one place.
+
+## Automated Scans Running in CI
+
+This repository runs the following scans automatically. Findings appear under the repository's **Security → Code scanning** tab.
+
+| Scan | Tool | Trigger | Action on finding |
+|------|------|---------|-------------------|
+| Static analysis (JS/TS, Python) | [CodeQL](https://github.com/github/codeql-action) | PR, `main` push, weekly | Advisory (Security tab) |
+| Dependency vulnerabilities (PR diff) | [`dependency-review-action`](https://github.com/actions/dependency-review-action) | PR | **Blocks PR** at `high+` severity |
+| Secret scanning | [Gitleaks](https://github.com/gitleaks/gitleaks-action) | PR, `main` push | **Blocks PR** on default rules |
+| Supply-chain posture | [OpenSSF Scorecard](https://github.com/ossf/scorecard-action) | Weekly, `main` push | Advisory (Security tab + public badge) |
+| Workflow lint | [zizmor](https://github.com/woodruffw/zizmor) | PR (touching `.github/**`) | **Blocks PR** at `high+` severity |
+| Container image scan | [Trivy](https://github.com/aquasecurity/trivy-action) | Weekly, `main` push | Advisory (Security tab) |
+
+Dependency version updates are managed separately by Dependabot — see `.github/dependabot.yml`.
diff --git a/docker-server.mjs b/docker-server.mjs
index adf84a07b..8143eb4b7 100644
--- a/docker-server.mjs
+++ b/docker-server.mjs
@@ -1,11 +1,11 @@
import { createReadStream } from 'node:fs';
import { stat } from 'node:fs/promises';
import { createServer } from 'node:http';
-import { extname, join, normalize, sep } from 'node:path';
+import { extname, isAbsolute, normalize, relative, resolve } from 'node:path';
const host = '0.0.0.0';
const port = Number(process.env.PORT || '4173');
-const root = join(process.cwd(), 'dist');
+const root = resolve(process.cwd(), 'dist');
const contentTypes = {
'.css': 'text/css; charset=utf-8',
@@ -20,39 +20,78 @@ const contentTypes = {
'.woff2': 'font/woff2',
};
-function resolvePath(urlPath) {
+// Static asset server for the gitnexus-web Docker image.
+//
+// Path-injection containment: the request handler is intentionally a single
+// inline pipeline with no helper functions on the path-data flow. Each
+// filesystem sink (stat, createReadStream) is immediately preceded by the
+// canonical `path.relative` containment check that CodeQL's
+// `js/path-injection` query recognizes as a sanitizer barrier:
+//
+// const rel = relative(root, candidate);
+// if (rel.startsWith('..') || isAbsolute(rel)) reject;
+// // candidate is now proven inside `root`
+//
+// Earlier iterations of this file used a helper (`resolveWithinRoot`) and a
+// `startsWith(root + sep)` check. Both were semantically correct but neither
+// was recognized by CodeQL: `startsWith(root + sep)` is not in the analyzer's
+// barrier-pattern set, and helper-based sanitization is not followed across
+// the request handler's reassignment paths in vanilla JS. The inline-at-sink
+// shape below is the documented analyzer-friendly idiom.
+const server = createServer(async (req, res) => {
+ const urlPath = req.url?.split('?')[0] || '/';
+
let decoded;
try {
decoded = decodeURIComponent(urlPath);
} catch {
- return null;
+ res.writeHead(400);
+ res.end('Bad request');
+ return;
}
- if (decoded.includes('\0')) return null;
+ if (decoded.includes('\0')) {
+ res.writeHead(400);
+ res.end('Bad request');
+ return;
+ }
+
const cleanPath = normalize(decoded.replace(/^\/+/, ''));
- const candidate = join(root, cleanPath);
- if (candidate !== root && !candidate.startsWith(root + sep)) return null;
- return candidate;
-}
+ const initialPath = resolve(root, cleanPath);
-const server = createServer(async (req, res) => {
- const requestPath = req.url?.split('?')[0] || '/';
- let filePath = resolvePath(requestPath);
-
- if (!filePath) {
+ // Sanitizer barrier #1 — guards the first stat() sink.
+ const initialRel = relative(root, initialPath);
+ if (initialRel.startsWith('..') || isAbsolute(initialRel)) {
res.writeHead(400);
res.end('Bad request');
return;
}
try {
- const fileStat = await stat(filePath).catch(() => null);
- if (fileStat?.isDirectory()) {
- filePath = join(filePath, 'index.html');
- } else if (!fileStat?.isFile()) {
- filePath = join(root, 'index.html');
+ const initialStat = await stat(initialPath).catch(() => null);
+
+ // Pick the path we actually serve. Note: any branch reassigns to a
+ // freshly-resolved path; the next sanitizer barrier re-validates.
+ let finalPath;
+ if (initialStat?.isDirectory()) {
+ finalPath = resolve(initialPath, 'index.html');
+ } else if (!initialStat?.isFile()) {
+ finalPath = resolve(root, 'index.html');
+ } else {
+ finalPath = initialPath;
}
- const finalStat = await stat(filePath).catch(() => null);
+ // Sanitizer barrier #2 — guards both the second stat() and the
+ // createReadStream() sinks. No reassignment of finalPath happens
+ // between this guard and either sink, so the analyzer can prove
+ // containment for both.
+ const finalRel = relative(root, finalPath);
+ if (finalRel.startsWith('..') || isAbsolute(finalRel)) {
+ res.writeHead(400);
+ res.end('Bad request');
+ return;
+ }
+
+ const finalStat = await stat(finalPath).catch(() => null);
if (!finalStat?.isFile()) {
res.writeHead(404);
res.end('Not found');
@@ -60,14 +99,14 @@ const server = createServer(async (req, res) => {
}
res.writeHead(200, {
- 'Cache-Control': filePath.includes('/assets/')
+ 'Cache-Control': finalPath.includes('/assets/')
? 'public, max-age=31536000, immutable'
: 'no-cache',
- 'Content-Type': contentTypes[extname(filePath)] || 'application/octet-stream',
+ 'Content-Type': contentTypes[extname(finalPath)] || 'application/octet-stream',
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp',
});
- const stream = createReadStream(filePath);
+ const stream = createReadStream(finalPath);
stream.on('error', () => res.destroy());
stream.pipe(res);
} catch (error) {
diff --git a/docker-server.test.mjs b/docker-server.test.mjs
index a1005b0e4..0fa84155b 100644
--- a/docker-server.test.mjs
+++ b/docker-server.test.mjs
@@ -100,6 +100,23 @@ it('rejects percent-encoded null bytes with 400', async () => {
assert.equal(res.status, 400);
});
+it('rejects percent-encoded path traversal with 400', async () => {
+ // %2e%2e%2f decodes to '../'. Without the path.relative inline barrier,
+ // a naive string check on the raw URL would let this through and only
+ // the lexical-decoded path.resolve would catch it. Confirm the barrier
+ // does its job after decodeURIComponent.
+ const res = await rawGet(serverPort, '/%2e%2e%2f%2e%2e%2fetc%2fpasswd');
+ assert.equal(res.status, 400);
+});
+
+it('rejects malformed percent-encoding with 400', async () => {
+ // %GG is not a valid percent-encoded sequence — decodeURIComponent throws.
+ // The handler's try/catch around decode must convert this to a 400 rather
+ // than an unhandled rejection.
+ const res = await rawGet(serverPort, '/foo%GGbar');
+ assert.equal(res.status, 400);
+});
+
it('returns 404 when dist/index.html is missing', async () => {
await unlink(join(tmpDir, 'dist', 'index.html'));
const res = await rawGet(serverPort, '/nonexistent-page');
diff --git a/eslint.config.mjs b/eslint.config.mjs
index cd7a331f2..5b365ba67 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -14,6 +14,7 @@ export default [
'gitnexus/vendor/**',
'gitnexus-web/src/vendor/**',
'gitnexus/test/fixtures/**',
+ 'gitnexus-web/test/fixtures/**',
'gitnexus-web/playwright-report/**',
'gitnexus-web/test-results/**',
'**/*.d.ts',
diff --git a/eval/pyproject.toml b/eval/pyproject.toml
index 0b51cdc97..dba52a6ef 100644
--- a/eval/pyproject.toml
+++ b/eval/pyproject.toml
@@ -6,19 +6,19 @@ readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"mini-swe-agent>=2.0.0",
- "litellm>=1.50.0,!=1.82.7,!=1.82.8",
+ "litellm!=1.82.7,!=1.82.8,>=1.83.7",
"datasets>=3.0.0",
"typer>=0.12.0",
"rich>=13.0.0",
"pyyaml>=6.0",
"pandas>=2.0.0",
"tabulate>=0.9.0",
- "python-dotenv>=1.0.0",
+ "python-dotenv>=1.2.2",
]
[project.optional-dependencies]
dev = [
- "pytest>=8.0.0",
+ "pytest>=9.0.3",
"ruff>=0.5.0",
"hypothesis>=6.88.0",
"coverage>=7.6.0",
diff --git a/eval/uv.lock b/eval/uv.lock
index afd47bcba..fcd666e75 100644
--- a/eval/uv.lock
+++ b/eval/uv.lock
@@ -21,7 +21,7 @@ wheels = [
[[package]]
name = "aiohttp"
-version = "3.13.3"
+version = "3.13.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohappyeyeballs" },
@@ -32,93 +32,93 @@ dependencies = [
{ name = "propcache" },
{ name = "yarl" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/45/4a/064321452809dae953c1ed6e017504e72551a26b6f5708a5a80e4bf556ff/aiohttp-3.13.4.tar.gz", hash = "sha256:d97a6d09c66087890c2ab5d49069e1e570583f7ac0314ecf98294c1b6aaebd38", size = 7859748, upload-time = "2026-03-28T17:19:40.6Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/f1/4c/a164164834f03924d9a29dc3acd9e7ee58f95857e0b467f6d04298594ebb/aiohttp-3.13.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5b6073099fb654e0a068ae678b10feff95c5cae95bbfcbfa7af669d361a8aa6b", size = 746051, upload-time = "2026-01-03T17:29:43.287Z" },
- { url = "https://files.pythonhosted.org/packages/82/71/d5c31390d18d4f58115037c432b7e0348c60f6f53b727cad33172144a112/aiohttp-3.13.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cb93e166e6c28716c8c6aeb5f99dfb6d5ccf482d29fe9bf9a794110e6d0ab64", size = 499234, upload-time = "2026-01-03T17:29:44.822Z" },
- { url = "https://files.pythonhosted.org/packages/0e/c9/741f8ac91e14b1d2e7100690425a5b2b919a87a5075406582991fb7de920/aiohttp-3.13.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:28e027cf2f6b641693a09f631759b4d9ce9165099d2b5d92af9bd4e197690eea", size = 494979, upload-time = "2026-01-03T17:29:46.405Z" },
- { url = "https://files.pythonhosted.org/packages/75/b5/31d4d2e802dfd59f74ed47eba48869c1c21552c586d5e81a9d0d5c2ad640/aiohttp-3.13.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b61b7169ababd7802f9568ed96142616a9118dd2be0d1866e920e77ec8fa92a", size = 1748297, upload-time = "2026-01-03T17:29:48.083Z" },
- { url = "https://files.pythonhosted.org/packages/1a/3e/eefad0ad42959f226bb79664826883f2687d602a9ae2941a18e0484a74d3/aiohttp-3.13.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:80dd4c21b0f6237676449c6baaa1039abae86b91636b6c91a7f8e61c87f89540", size = 1707172, upload-time = "2026-01-03T17:29:49.648Z" },
- { url = "https://files.pythonhosted.org/packages/c5/3a/54a64299fac2891c346cdcf2aa6803f994a2e4beeaf2e5a09dcc54acc842/aiohttp-3.13.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65d2ccb7eabee90ce0503c17716fc77226be026dcc3e65cce859a30db715025b", size = 1805405, upload-time = "2026-01-03T17:29:51.244Z" },
- { url = "https://files.pythonhosted.org/packages/6c/70/ddc1b7169cf64075e864f64595a14b147a895a868394a48f6a8031979038/aiohttp-3.13.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b179331a481cb5529fca8b432d8d3c7001cb217513c94cd72d668d1248688a3", size = 1899449, upload-time = "2026-01-03T17:29:53.938Z" },
- { url = "https://files.pythonhosted.org/packages/a1/7e/6815aab7d3a56610891c76ef79095677b8b5be6646aaf00f69b221765021/aiohttp-3.13.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d4c940f02f49483b18b079d1c27ab948721852b281f8b015c058100e9421dd1", size = 1748444, upload-time = "2026-01-03T17:29:55.484Z" },
- { url = "https://files.pythonhosted.org/packages/6b/f2/073b145c4100da5511f457dc0f7558e99b2987cf72600d42b559db856fbc/aiohttp-3.13.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9444f105664c4ce47a2a7171a2418bce5b7bae45fb610f4e2c36045d85911d3", size = 1606038, upload-time = "2026-01-03T17:29:57.179Z" },
- { url = "https://files.pythonhosted.org/packages/0a/c1/778d011920cae03ae01424ec202c513dc69243cf2db303965615b81deeea/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:694976222c711d1d00ba131904beb60534f93966562f64440d0c9d41b8cdb440", size = 1724156, upload-time = "2026-01-03T17:29:58.914Z" },
- { url = "https://files.pythonhosted.org/packages/0e/cb/3419eabf4ec1e9ec6f242c32b689248365a1cf621891f6f0386632525494/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f33ed1a2bf1997a36661874b017f5c4b760f41266341af36febaf271d179f6d7", size = 1722340, upload-time = "2026-01-03T17:30:01.962Z" },
- { url = "https://files.pythonhosted.org/packages/7a/e5/76cf77bdbc435bf233c1f114edad39ed4177ccbfab7c329482b179cff4f4/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e636b3c5f61da31a92bf0d91da83e58fdfa96f178ba682f11d24f31944cdd28c", size = 1783041, upload-time = "2026-01-03T17:30:03.609Z" },
- { url = "https://files.pythonhosted.org/packages/9d/d4/dd1ca234c794fd29c057ce8c0566b8ef7fd6a51069de5f06fa84b9a1971c/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5d2d94f1f5fcbe40838ac51a6ab5704a6f9ea42e72ceda48de5e6b898521da51", size = 1596024, upload-time = "2026-01-03T17:30:05.132Z" },
- { url = "https://files.pythonhosted.org/packages/55/58/4345b5f26661a6180afa686c473620c30a66afdf120ed3dd545bbc809e85/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2be0e9ccf23e8a94f6f0650ce06042cefc6ac703d0d7ab6c7a917289f2539ad4", size = 1804590, upload-time = "2026-01-03T17:30:07.135Z" },
- { url = "https://files.pythonhosted.org/packages/7b/06/05950619af6c2df7e0a431d889ba2813c9f0129cec76f663e547a5ad56f2/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9af5e68ee47d6534d36791bbe9b646d2a7c7deb6fc24d7943628edfbb3581f29", size = 1740355, upload-time = "2026-01-03T17:30:09.083Z" },
- { url = "https://files.pythonhosted.org/packages/3e/80/958f16de79ba0422d7c1e284b2abd0c84bc03394fbe631d0a39ffa10e1eb/aiohttp-3.13.3-cp311-cp311-win32.whl", hash = "sha256:a2212ad43c0833a873d0fb3c63fa1bacedd4cf6af2fee62bf4b739ceec3ab239", size = 433701, upload-time = "2026-01-03T17:30:10.869Z" },
- { url = "https://files.pythonhosted.org/packages/dc/f2/27cdf04c9851712d6c1b99df6821a6623c3c9e55956d4b1e318c337b5a48/aiohttp-3.13.3-cp311-cp311-win_amd64.whl", hash = "sha256:642f752c3eb117b105acbd87e2c143de710987e09860d674e068c4c2c441034f", size = 457678, upload-time = "2026-01-03T17:30:12.719Z" },
- { url = "https://files.pythonhosted.org/packages/a0/be/4fc11f202955a69e0db803a12a062b8379c970c7c84f4882b6da17337cc1/aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c", size = 739732, upload-time = "2026-01-03T17:30:14.23Z" },
- { url = "https://files.pythonhosted.org/packages/97/2c/621d5b851f94fa0bb7430d6089b3aa970a9d9b75196bc93bb624b0db237a/aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168", size = 494293, upload-time = "2026-01-03T17:30:15.96Z" },
- { url = "https://files.pythonhosted.org/packages/5d/43/4be01406b78e1be8320bb8316dc9c42dbab553d281c40364e0f862d5661c/aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d", size = 493533, upload-time = "2026-01-03T17:30:17.431Z" },
- { url = "https://files.pythonhosted.org/packages/8d/a8/5a35dc56a06a2c90d4742cbf35294396907027f80eea696637945a106f25/aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29", size = 1737839, upload-time = "2026-01-03T17:30:19.422Z" },
- { url = "https://files.pythonhosted.org/packages/bf/62/4b9eeb331da56530bf2e198a297e5303e1c1ebdceeb00fe9b568a65c5a0c/aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3", size = 1703932, upload-time = "2026-01-03T17:30:21.756Z" },
- { url = "https://files.pythonhosted.org/packages/7c/f6/af16887b5d419e6a367095994c0b1332d154f647e7dc2bd50e61876e8e3d/aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d", size = 1771906, upload-time = "2026-01-03T17:30:23.932Z" },
- { url = "https://files.pythonhosted.org/packages/ce/83/397c634b1bcc24292fa1e0c7822800f9f6569e32934bdeef09dae7992dfb/aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463", size = 1871020, upload-time = "2026-01-03T17:30:26Z" },
- { url = "https://files.pythonhosted.org/packages/86/f6/a62cbbf13f0ac80a70f71b1672feba90fdb21fd7abd8dbf25c0105fb6fa3/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc", size = 1755181, upload-time = "2026-01-03T17:30:27.554Z" },
- { url = "https://files.pythonhosted.org/packages/0a/87/20a35ad487efdd3fba93d5843efdfaa62d2f1479eaafa7453398a44faf13/aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf", size = 1561794, upload-time = "2026-01-03T17:30:29.254Z" },
- { url = "https://files.pythonhosted.org/packages/de/95/8fd69a66682012f6716e1bc09ef8a1a2a91922c5725cb904689f112309c4/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033", size = 1697900, upload-time = "2026-01-03T17:30:31.033Z" },
- { url = "https://files.pythonhosted.org/packages/e5/66/7b94b3b5ba70e955ff597672dad1691333080e37f50280178967aff68657/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f", size = 1728239, upload-time = "2026-01-03T17:30:32.703Z" },
- { url = "https://files.pythonhosted.org/packages/47/71/6f72f77f9f7d74719692ab65a2a0252584bf8d5f301e2ecb4c0da734530a/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679", size = 1740527, upload-time = "2026-01-03T17:30:34.695Z" },
- { url = "https://files.pythonhosted.org/packages/fa/b4/75ec16cbbd5c01bdaf4a05b19e103e78d7ce1ef7c80867eb0ace42ff4488/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423", size = 1554489, upload-time = "2026-01-03T17:30:36.864Z" },
- { url = "https://files.pythonhosted.org/packages/52/8f/bc518c0eea29f8406dcf7ed1f96c9b48e3bc3995a96159b3fc11f9e08321/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce", size = 1767852, upload-time = "2026-01-03T17:30:39.433Z" },
- { url = "https://files.pythonhosted.org/packages/9d/f2/a07a75173124f31f11ea6f863dc44e6f09afe2bca45dd4e64979490deab1/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a", size = 1722379, upload-time = "2026-01-03T17:30:41.081Z" },
- { url = "https://files.pythonhosted.org/packages/3c/4a/1a3fee7c21350cac78e5c5cef711bac1b94feca07399f3d406972e2d8fcd/aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046", size = 428253, upload-time = "2026-01-03T17:30:42.644Z" },
- { url = "https://files.pythonhosted.org/packages/d9/b7/76175c7cb4eb73d91ad63c34e29fc4f77c9386bba4a65b53ba8e05ee3c39/aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57", size = 455407, upload-time = "2026-01-03T17:30:44.195Z" },
- { url = "https://files.pythonhosted.org/packages/97/8a/12ca489246ca1faaf5432844adbfce7ff2cc4997733e0af120869345643a/aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c", size = 734190, upload-time = "2026-01-03T17:30:45.832Z" },
- { url = "https://files.pythonhosted.org/packages/32/08/de43984c74ed1fca5c014808963cc83cb00d7bb06af228f132d33862ca76/aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9", size = 491783, upload-time = "2026-01-03T17:30:47.466Z" },
- { url = "https://files.pythonhosted.org/packages/17/f8/8dd2cf6112a5a76f81f81a5130c57ca829d101ad583ce57f889179accdda/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3", size = 490704, upload-time = "2026-01-03T17:30:49.373Z" },
- { url = "https://files.pythonhosted.org/packages/6d/40/a46b03ca03936f832bc7eaa47cfbb1ad012ba1be4790122ee4f4f8cba074/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf", size = 1720652, upload-time = "2026-01-03T17:30:50.974Z" },
- { url = "https://files.pythonhosted.org/packages/f7/7e/917fe18e3607af92657e4285498f500dca797ff8c918bd7d90b05abf6c2a/aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6", size = 1692014, upload-time = "2026-01-03T17:30:52.729Z" },
- { url = "https://files.pythonhosted.org/packages/71/b6/cefa4cbc00d315d68973b671cf105b21a609c12b82d52e5d0c9ae61d2a09/aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d", size = 1759777, upload-time = "2026-01-03T17:30:54.537Z" },
- { url = "https://files.pythonhosted.org/packages/fb/e3/e06ee07b45e59e6d81498b591fc589629be1553abb2a82ce33efe2a7b068/aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261", size = 1861276, upload-time = "2026-01-03T17:30:56.512Z" },
- { url = "https://files.pythonhosted.org/packages/7c/24/75d274228acf35ceeb2850b8ce04de9dd7355ff7a0b49d607ee60c29c518/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0", size = 1743131, upload-time = "2026-01-03T17:30:58.256Z" },
- { url = "https://files.pythonhosted.org/packages/04/98/3d21dde21889b17ca2eea54fdcff21b27b93f45b7bb94ca029c31ab59dc3/aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730", size = 1556863, upload-time = "2026-01-03T17:31:00.445Z" },
- { url = "https://files.pythonhosted.org/packages/9e/84/da0c3ab1192eaf64782b03971ab4055b475d0db07b17eff925e8c93b3aa5/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91", size = 1682793, upload-time = "2026-01-03T17:31:03.024Z" },
- { url = "https://files.pythonhosted.org/packages/ff/0f/5802ada182f575afa02cbd0ec5180d7e13a402afb7c2c03a9aa5e5d49060/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3", size = 1716676, upload-time = "2026-01-03T17:31:04.842Z" },
- { url = "https://files.pythonhosted.org/packages/3f/8c/714d53bd8b5a4560667f7bbbb06b20c2382f9c7847d198370ec6526af39c/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4", size = 1733217, upload-time = "2026-01-03T17:31:06.868Z" },
- { url = "https://files.pythonhosted.org/packages/7d/79/e2176f46d2e963facea939f5be2d26368ce543622be6f00a12844d3c991f/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998", size = 1552303, upload-time = "2026-01-03T17:31:08.958Z" },
- { url = "https://files.pythonhosted.org/packages/ab/6a/28ed4dea1759916090587d1fe57087b03e6c784a642b85ef48217b0277ae/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0", size = 1763673, upload-time = "2026-01-03T17:31:10.676Z" },
- { url = "https://files.pythonhosted.org/packages/e8/35/4a3daeb8b9fab49240d21c04d50732313295e4bd813a465d840236dd0ce1/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591", size = 1721120, upload-time = "2026-01-03T17:31:12.575Z" },
- { url = "https://files.pythonhosted.org/packages/bc/9f/d643bb3c5fb99547323e635e251c609fbbc660d983144cfebec529e09264/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf", size = 427383, upload-time = "2026-01-03T17:31:14.382Z" },
- { url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899, upload-time = "2026-01-03T17:31:15.958Z" },
- { url = "https://files.pythonhosted.org/packages/99/36/5b6514a9f5d66f4e2597e40dea2e3db271e023eb7a5d22defe96ba560996/aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808", size = 737238, upload-time = "2026-01-03T17:31:17.909Z" },
- { url = "https://files.pythonhosted.org/packages/f7/49/459327f0d5bcd8c6c9ca69e60fdeebc3622861e696490d8674a6d0cb90a6/aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415", size = 492292, upload-time = "2026-01-03T17:31:19.919Z" },
- { url = "https://files.pythonhosted.org/packages/e8/0b/b97660c5fd05d3495b4eb27f2d0ef18dc1dc4eff7511a9bf371397ff0264/aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f", size = 493021, upload-time = "2026-01-03T17:31:21.636Z" },
- { url = "https://files.pythonhosted.org/packages/54/d4/438efabdf74e30aeceb890c3290bbaa449780583b1270b00661126b8aae4/aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6", size = 1717263, upload-time = "2026-01-03T17:31:23.296Z" },
- { url = "https://files.pythonhosted.org/packages/71/f2/7bddc7fd612367d1459c5bcf598a9e8f7092d6580d98de0e057eb42697ad/aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687", size = 1669107, upload-time = "2026-01-03T17:31:25.334Z" },
- { url = "https://files.pythonhosted.org/packages/00/5a/1aeaecca40e22560f97610a329e0e5efef5e0b5afdf9f857f0d93839ab2e/aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26", size = 1760196, upload-time = "2026-01-03T17:31:27.394Z" },
- { url = "https://files.pythonhosted.org/packages/f8/f8/0ff6992bea7bd560fc510ea1c815f87eedd745fe035589c71ce05612a19a/aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a", size = 1843591, upload-time = "2026-01-03T17:31:29.238Z" },
- { url = "https://files.pythonhosted.org/packages/e3/d1/e30e537a15f53485b61f5be525f2157da719819e8377298502aebac45536/aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1", size = 1720277, upload-time = "2026-01-03T17:31:31.053Z" },
- { url = "https://files.pythonhosted.org/packages/84/45/23f4c451d8192f553d38d838831ebbc156907ea6e05557f39563101b7717/aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25", size = 1548575, upload-time = "2026-01-03T17:31:32.87Z" },
- { url = "https://files.pythonhosted.org/packages/6a/ed/0a42b127a43712eda7807e7892c083eadfaf8429ca8fb619662a530a3aab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603", size = 1679455, upload-time = "2026-01-03T17:31:34.76Z" },
- { url = "https://files.pythonhosted.org/packages/2e/b5/c05f0c2b4b4fe2c9d55e73b6d3ed4fd6c9dc2684b1d81cbdf77e7fad9adb/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a", size = 1687417, upload-time = "2026-01-03T17:31:36.699Z" },
- { url = "https://files.pythonhosted.org/packages/c9/6b/915bc5dad66aef602b9e459b5a973529304d4e89ca86999d9d75d80cbd0b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926", size = 1729968, upload-time = "2026-01-03T17:31:38.622Z" },
- { url = "https://files.pythonhosted.org/packages/11/3b/e84581290a9520024a08640b63d07673057aec5ca548177a82026187ba73/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba", size = 1545690, upload-time = "2026-01-03T17:31:40.57Z" },
- { url = "https://files.pythonhosted.org/packages/f5/04/0c3655a566c43fd647c81b895dfe361b9f9ad6d58c19309d45cff52d6c3b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c", size = 1746390, upload-time = "2026-01-03T17:31:42.857Z" },
- { url = "https://files.pythonhosted.org/packages/1f/53/71165b26978f719c3419381514c9690bd5980e764a09440a10bb816ea4ab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43", size = 1702188, upload-time = "2026-01-03T17:31:44.984Z" },
- { url = "https://files.pythonhosted.org/packages/29/a7/cbe6c9e8e136314fa1980da388a59d2f35f35395948a08b6747baebb6aa6/aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1", size = 433126, upload-time = "2026-01-03T17:31:47.463Z" },
- { url = "https://files.pythonhosted.org/packages/de/56/982704adea7d3b16614fc5936014e9af85c0e34b58f9046655817f04306e/aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984", size = 459128, upload-time = "2026-01-03T17:31:49.2Z" },
- { url = "https://files.pythonhosted.org/packages/6c/2a/3c79b638a9c3d4658d345339d22070241ea341ed4e07b5ac60fb0f418003/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c", size = 769512, upload-time = "2026-01-03T17:31:51.134Z" },
- { url = "https://files.pythonhosted.org/packages/29/b9/3e5014d46c0ab0db8707e0ac2711ed28c4da0218c358a4e7c17bae0d8722/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592", size = 506444, upload-time = "2026-01-03T17:31:52.85Z" },
- { url = "https://files.pythonhosted.org/packages/90/03/c1d4ef9a054e151cd7839cdc497f2638f00b93cbe8043983986630d7a80c/aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f", size = 510798, upload-time = "2026-01-03T17:31:54.91Z" },
- { url = "https://files.pythonhosted.org/packages/ea/76/8c1e5abbfe8e127c893fe7ead569148a4d5a799f7cf958d8c09f3eedf097/aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29", size = 1868835, upload-time = "2026-01-03T17:31:56.733Z" },
- { url = "https://files.pythonhosted.org/packages/8e/ac/984c5a6f74c363b01ff97adc96a3976d9c98940b8969a1881575b279ac5d/aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc", size = 1720486, upload-time = "2026-01-03T17:31:58.65Z" },
- { url = "https://files.pythonhosted.org/packages/b2/9a/b7039c5f099c4eb632138728828b33428585031a1e658d693d41d07d89d1/aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2", size = 1847951, upload-time = "2026-01-03T17:32:00.989Z" },
- { url = "https://files.pythonhosted.org/packages/3c/02/3bec2b9a1ba3c19ff89a43a19324202b8eb187ca1e928d8bdac9bbdddebd/aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587", size = 1941001, upload-time = "2026-01-03T17:32:03.122Z" },
- { url = "https://files.pythonhosted.org/packages/37/df/d879401cedeef27ac4717f6426c8c36c3091c6e9f08a9178cc87549c537f/aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8", size = 1797246, upload-time = "2026-01-03T17:32:05.255Z" },
- { url = "https://files.pythonhosted.org/packages/8d/15/be122de1f67e6953add23335c8ece6d314ab67c8bebb3f181063010795a7/aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632", size = 1627131, upload-time = "2026-01-03T17:32:07.607Z" },
- { url = "https://files.pythonhosted.org/packages/12/12/70eedcac9134cfa3219ab7af31ea56bc877395b1ac30d65b1bc4b27d0438/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64", size = 1795196, upload-time = "2026-01-03T17:32:09.59Z" },
- { url = "https://files.pythonhosted.org/packages/32/11/b30e1b1cd1f3054af86ebe60df96989c6a414dd87e27ad16950eee420bea/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0", size = 1782841, upload-time = "2026-01-03T17:32:11.445Z" },
- { url = "https://files.pythonhosted.org/packages/88/0d/d98a9367b38912384a17e287850f5695c528cff0f14f791ce8ee2e4f7796/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56", size = 1795193, upload-time = "2026-01-03T17:32:13.705Z" },
- { url = "https://files.pythonhosted.org/packages/43/a5/a2dfd1f5ff5581632c7f6a30e1744deda03808974f94f6534241ef60c751/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72", size = 1621979, upload-time = "2026-01-03T17:32:15.965Z" },
- { url = "https://files.pythonhosted.org/packages/fa/f0/12973c382ae7c1cccbc4417e129c5bf54c374dfb85af70893646e1f0e749/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df", size = 1822193, upload-time = "2026-01-03T17:32:18.219Z" },
- { url = "https://files.pythonhosted.org/packages/3c/5f/24155e30ba7f8c96918af1350eb0663e2430aad9e001c0489d89cd708ab1/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa", size = 1769801, upload-time = "2026-01-03T17:32:20.25Z" },
- { url = "https://files.pythonhosted.org/packages/eb/f8/7314031ff5c10e6ece114da79b338ec17eeff3a079e53151f7e9f43c4723/aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767", size = 466523, upload-time = "2026-01-03T17:32:22.215Z" },
- { url = "https://files.pythonhosted.org/packages/b4/63/278a98c715ae467624eafe375542d8ba9b4383a016df8fdefe0ae28382a7/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344", size = 499694, upload-time = "2026-01-03T17:32:24.546Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/7e/cb94129302d78c46662b47f9897d642fd0b33bdfef4b73b20c6ced35aa4c/aiohttp-3.13.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8ea0c64d1bcbf201b285c2246c51a0c035ba3bbd306640007bc5844a3b4658c1", size = 760027, upload-time = "2026-03-28T17:15:33.022Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/cd/2db3c9397c3bd24216b203dd739945b04f8b87bb036c640da7ddb63c75ef/aiohttp-3.13.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6f742e1fa45c0ed522b00ede565e18f97e4cf8d1883a712ac42d0339dfb0cce7", size = 508325, upload-time = "2026-03-28T17:15:34.714Z" },
+ { url = "https://files.pythonhosted.org/packages/36/a3/d28b2722ec13107f2e37a86b8a169897308bab6a3b9e071ecead9d67bd9b/aiohttp-3.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6dcfb50ee25b3b7a1222a9123be1f9f89e56e67636b561441f0b304e25aaef8f", size = 502402, upload-time = "2026-03-28T17:15:36.409Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/d6/acd47b5f17c4430e555590990a4746efbcb2079909bb865516892bf85f37/aiohttp-3.13.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3262386c4ff370849863ea93b9ea60fd59c6cf56bf8f93beac625cf4d677c04d", size = 1771224, upload-time = "2026-03-28T17:15:38.223Z" },
+ { url = "https://files.pythonhosted.org/packages/98/af/af6e20113ba6a48fd1cd9e5832c4851e7613ef50c7619acdaee6ec5f1aff/aiohttp-3.13.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:473bb5aa4218dd254e9ae4834f20e31f5a0083064ac0136a01a62ddbae2eaa42", size = 1731530, upload-time = "2026-03-28T17:15:39.988Z" },
+ { url = "https://files.pythonhosted.org/packages/81/16/78a2f5d9c124ad05d5ce59a9af94214b6466c3491a25fb70760e98e9f762/aiohttp-3.13.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e56423766399b4c77b965f6aaab6c9546617b8994a956821cc507d00b91d978c", size = 1827925, upload-time = "2026-03-28T17:15:41.944Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/1f/79acf0974ced805e0e70027389fccbb7d728e6f30fcac725fb1071e63075/aiohttp-3.13.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8af249343fafd5ad90366a16d230fc265cf1149f26075dc9fe93cfd7c7173942", size = 1923579, upload-time = "2026-03-28T17:15:44.071Z" },
+ { url = "https://files.pythonhosted.org/packages/af/53/29f9e2054ea6900413f3b4c3eb9d8331f60678ec855f13ba8714c47fd48d/aiohttp-3.13.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bc0a5cf4f10ef5a2c94fdde488734b582a3a7a000b131263e27c9295bd682d9", size = 1767655, upload-time = "2026-03-28T17:15:45.911Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/57/462fe1d3da08109ba4aa8590e7aed57c059af2a7e80ec21f4bac5cfe1094/aiohttp-3.13.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5c7ff1028e3c9fc5123a865ce17df1cb6424d180c503b8517afbe89aa566e6be", size = 1630439, upload-time = "2026-03-28T17:15:48.11Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/4b/4813344aacdb8127263e3eec343d24e973421143826364fa9fc847f6283f/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ba5cf98b5dcb9bddd857da6713a503fa6d341043258ca823f0f5ab7ab4a94ee8", size = 1745557, upload-time = "2026-03-28T17:15:50.13Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/01/1ef1adae1454341ec50a789f03cfafe4c4ac9c003f6a64515ecd32fe4210/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:d85965d3ba21ee4999e83e992fecb86c4614d6920e40705501c0a1f80a583c12", size = 1741796, upload-time = "2026-03-28T17:15:52.351Z" },
+ { url = "https://files.pythonhosted.org/packages/22/04/8cdd99af988d2aa6922714d957d21383c559835cbd43fbf5a47ddf2e0f05/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:49f0b18a9b05d79f6f37ddd567695943fcefb834ef480f17a4211987302b2dc7", size = 1805312, upload-time = "2026-03-28T17:15:54.407Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/7f/b48d5577338d4b25bbdbae35c75dbfd0493cb8886dc586fbfb2e90862239/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7f78cb080c86fbf765920e5f1ef35af3f24ec4314d6675d0a21eaf41f6f2679c", size = 1621751, upload-time = "2026-03-28T17:15:56.564Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/89/4eecad8c1858e6d0893c05929e22343e0ebe3aec29a8a399c65c3cc38311/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:67a3ec705534a614b68bbf1c70efa777a21c3da3895d1c44510a41f5a7ae0453", size = 1826073, upload-time = "2026-03-28T17:15:58.489Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/5c/9dc8293ed31b46c39c9c513ac7ca152b3c3d38e0ea111a530ad12001b827/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d6630ec917e85c5356b2295744c8a97d40f007f96a1c76bf1928dc2e27465393", size = 1760083, upload-time = "2026-03-28T17:16:00.677Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/19/8bbf6a4994205d96831f97b7d21a0feed120136e6267b5b22d229c6dc4dc/aiohttp-3.13.4-cp311-cp311-win32.whl", hash = "sha256:54049021bc626f53a5394c29e8c444f726ee5a14b6e89e0ad118315b1f90f5e3", size = 439690, upload-time = "2026-03-28T17:16:02.902Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/f5/ac409ecd1007528d15c3e8c3a57d34f334c70d76cfb7128a28cffdebd4c1/aiohttp-3.13.4-cp311-cp311-win_amd64.whl", hash = "sha256:c033f2bc964156030772d31cbf7e5defea181238ce1f87b9455b786de7d30145", size = 463824, upload-time = "2026-03-28T17:16:05.058Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/bd/ede278648914cabbabfdf95e436679b5d4156e417896a9b9f4587169e376/aiohttp-3.13.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ee62d4471ce86b108b19c3364db4b91180d13fe3510144872d6bad5401957360", size = 752158, upload-time = "2026-03-28T17:16:06.901Z" },
+ { url = "https://files.pythonhosted.org/packages/90/de/581c053253c07b480b03785196ca5335e3c606a37dc73e95f6527f1591fe/aiohttp-3.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c0fd8f41b54b58636402eb493afd512c23580456f022c1ba2db0f810c959ed0d", size = 501037, upload-time = "2026-03-28T17:16:08.82Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/f9/a5ede193c08f13cc42c0a5b50d1e246ecee9115e4cf6e900d8dbd8fd6acb/aiohttp-3.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4baa48ce49efd82d6b1a0be12d6a36b35e5594d1dd42f8bfba96ea9f8678b88c", size = 501556, upload-time = "2026-03-28T17:16:10.63Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/10/88ff67cd48a6ec36335b63a640abe86135791544863e0cfe1f065d6cef7a/aiohttp-3.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d738ebab9f71ee652d9dbd0211057690022201b11197f9a7324fd4dba128aa97", size = 1757314, upload-time = "2026-03-28T17:16:12.498Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/15/fdb90a5cf5a1f52845c276e76298c75fbbcc0ac2b4a86551906d54529965/aiohttp-3.13.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0ce692c3468fa831af7dceed52edf51ac348cebfc8d3feb935927b63bd3e8576", size = 1731819, upload-time = "2026-03-28T17:16:14.558Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/df/28146785a007f7820416be05d4f28cc207493efd1e8c6c1068e9bdc29198/aiohttp-3.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8e08abcfe752a454d2cb89ff0c08f2d1ecd057ae3e8cc6d84638de853530ebab", size = 1793279, upload-time = "2026-03-28T17:16:16.594Z" },
+ { url = "https://files.pythonhosted.org/packages/10/47/689c743abf62ea7a77774d5722f220e2c912a77d65d368b884d9779ef41b/aiohttp-3.13.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5977f701b3fff36367a11087f30ea73c212e686d41cd363c50c022d48b011d8d", size = 1891082, upload-time = "2026-03-28T17:16:18.71Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/b6/f7f4f318c7e58c23b761c9b13b9a3c9b394e0f9d5d76fbc6622fa98509f6/aiohttp-3.13.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54203e10405c06f8b6020bd1e076ae0fe6c194adcee12a5a78af3ffa3c57025e", size = 1773938, upload-time = "2026-03-28T17:16:21.125Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/06/f207cb3121852c989586a6fc16ff854c4fcc8651b86c5d3bd1fc83057650/aiohttp-3.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:358a6af0145bc4dda037f13167bef3cce54b132087acc4c295c739d05d16b1c3", size = 1579548, upload-time = "2026-03-28T17:16:23.588Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/58/e1289661a32161e24c1fe479711d783067210d266842523752869cc1d9c2/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:898ea1850656d7d61832ef06aa9846ab3ddb1621b74f46de78fbc5e1a586ba83", size = 1714669, upload-time = "2026-03-28T17:16:25.713Z" },
+ { url = "https://files.pythonhosted.org/packages/96/0a/3e86d039438a74a86e6a948a9119b22540bae037d6ba317a042ae3c22711/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7bc30cceb710cf6a44e9617e43eebb6e3e43ad855a34da7b4b6a73537d8a6763", size = 1754175, upload-time = "2026-03-28T17:16:28.18Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/30/e717fc5df83133ba467a560b6d8ef20197037b4bb5d7075b90037de1018e/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4a31c0c587a8a038f19a4c7e60654a6c899c9de9174593a13e7cc6e15ff271f9", size = 1762049, upload-time = "2026-03-28T17:16:30.941Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/28/8f7a2d4492e336e40005151bdd94baf344880a4707573378579f833a64c1/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2062f675f3fe6e06d6113eb74a157fb9df58953ffed0cdb4182554b116545758", size = 1570861, upload-time = "2026-03-28T17:16:32.953Z" },
+ { url = "https://files.pythonhosted.org/packages/78/45/12e1a3d0645968b1c38de4b23fdf270b8637735ea057d4f84482ff918ad9/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d1ba8afb847ff80626d5e408c1fdc99f942acc877d0702fe137015903a220a9", size = 1790003, upload-time = "2026-03-28T17:16:35.468Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/0f/60374e18d590de16dcb39d6ff62f39c096c1b958e6f37727b5870026ea30/aiohttp-3.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b08149419994cdd4d5eecf7fd4bc5986b5a9380285bcd01ab4c0d6bfca47b79d", size = 1737289, upload-time = "2026-03-28T17:16:38.187Z" },
+ { url = "https://files.pythonhosted.org/packages/02/bf/535e58d886cfbc40a8b0013c974afad24ef7632d645bca0b678b70033a60/aiohttp-3.13.4-cp312-cp312-win32.whl", hash = "sha256:fc432f6a2c4f720180959bc19aa37259651c1a4ed8af8afc84dd41c60f15f791", size = 434185, upload-time = "2026-03-28T17:16:40.735Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/1a/d92e3325134ebfff6f4069f270d3aac770d63320bd1fcd0eca023e74d9a8/aiohttp-3.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:6148c9ae97a3e8bff9a1fc9c757fa164116f86c100468339730e717590a3fb77", size = 461285, upload-time = "2026-03-28T17:16:42.713Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/ac/892f4162df9b115b4758d615f32ec63d00f3084c705ff5526630887b9b42/aiohttp-3.13.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:63dd5e5b1e43b8fb1e91b79b7ceba1feba588b317d1edff385084fcc7a0a4538", size = 745744, upload-time = "2026-03-28T17:16:44.67Z" },
+ { url = "https://files.pythonhosted.org/packages/97/a9/c5b87e4443a2f0ea88cb3000c93a8fdad1ee63bffc9ded8d8c8e0d66efc6/aiohttp-3.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:746ac3cc00b5baea424dacddea3ec2c2702f9590de27d837aa67004db1eebc6e", size = 498178, upload-time = "2026-03-28T17:16:46.766Z" },
+ { url = "https://files.pythonhosted.org/packages/94/42/07e1b543a61250783650df13da8ddcdc0d0a5538b2bd15cef6e042aefc61/aiohttp-3.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bda8f16ea99d6a6705e5946732e48487a448be874e54a4f73d514660ff7c05d3", size = 498331, upload-time = "2026-03-28T17:16:48.9Z" },
+ { url = "https://files.pythonhosted.org/packages/20/d6/492f46bf0328534124772d0cf58570acae5b286ea25006900650f69dae0e/aiohttp-3.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b061e7b5f840391e3f64d0ddf672973e45c4cfff7a0feea425ea24e51530fc2", size = 1744414, upload-time = "2026-03-28T17:16:50.968Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/4d/e02627b2683f68051246215d2d62b2d2f249ff7a285e7a858dc47d6b6a14/aiohttp-3.13.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b252e8d5cd66184b570d0d010de742736e8a4fab22c58299772b0c5a466d4b21", size = 1719226, upload-time = "2026-03-28T17:16:53.173Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/6c/5d0a3394dd2b9f9aeba6e1b6065d0439e4b75d41f1fb09a3ec010b43552b/aiohttp-3.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:20af8aad61d1803ff11152a26146d8d81c266aa8c5aa9b4504432abb965c36a0", size = 1782110, upload-time = "2026-03-28T17:16:55.362Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/2d/c20791e3437700a7441a7edfb59731150322424f5aadf635602d1d326101/aiohttp-3.13.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:13a5cc924b59859ad2adb1478e31f410a7ed46e92a2a619d6d1dd1a63c1a855e", size = 1884809, upload-time = "2026-03-28T17:16:57.734Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/94/d99dbfbd1924a87ef643833932eb2a3d9e5eee87656efea7d78058539eff/aiohttp-3.13.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:534913dfb0a644d537aebb4123e7d466d94e3be5549205e6a31f72368980a81a", size = 1764938, upload-time = "2026-03-28T17:17:00.221Z" },
+ { url = "https://files.pythonhosted.org/packages/49/61/3ce326a1538781deb89f6cf5e094e2029cd308ed1e21b2ba2278b08426f6/aiohttp-3.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:320e40192a2dcc1cf4b5576936e9652981ab596bf81eb309535db7e2f5b5672f", size = 1570697, upload-time = "2026-03-28T17:17:02.985Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/77/4ab5a546857bb3028fbaf34d6eea180267bdab022ee8b1168b1fcde4bfdd/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9e587fcfce2bcf06526a43cb705bdee21ac089096f2e271d75de9c339db3100c", size = 1702258, upload-time = "2026-03-28T17:17:05.28Z" },
+ { url = "https://files.pythonhosted.org/packages/79/63/d8f29021e39bc5af8e5d5e9da1b07976fb9846487a784e11e4f4eeda4666/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9eb9c2eea7278206b5c6c1441fdd9dc420c278ead3f3b2cc87f9b693698cc500", size = 1740287, upload-time = "2026-03-28T17:17:07.712Z" },
+ { url = "https://files.pythonhosted.org/packages/55/3a/cbc6b3b124859a11bc8055d3682c26999b393531ef926754a3445b99dfef/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:29be00c51972b04bf9d5c8f2d7f7314f48f96070ca40a873a53056e652e805f7", size = 1753011, upload-time = "2026-03-28T17:17:10.053Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/30/836278675205d58c1368b21520eab9572457cf19afd23759216c04483048/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90c06228a6c3a7c9f776fe4fc0b7ff647fffd3bed93779a6913c804ae00c1073", size = 1566359, upload-time = "2026-03-28T17:17:12.433Z" },
+ { url = "https://files.pythonhosted.org/packages/50/b4/8032cc9b82d17e4277704ba30509eaccb39329dc18d6a35f05e424439e32/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a533ec132f05fd9a1d959e7f34184cd7d5e8511584848dab85faefbaac573069", size = 1785537, upload-time = "2026-03-28T17:17:14.721Z" },
+ { url = "https://files.pythonhosted.org/packages/17/7d/5873e98230bde59f493bf1f7c3e327486a4b5653fa401144704df5d00211/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1c946f10f413836f82ea4cfb90200d2a59578c549f00857e03111cf45ad01ca5", size = 1740752, upload-time = "2026-03-28T17:17:17.387Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/f2/13e46e0df051494d7d3c68b7f72d071f48c384c12716fc294f75d5b1a064/aiohttp-3.13.4-cp313-cp313-win32.whl", hash = "sha256:48708e2706106da6967eff5908c78ca3943f005ed6bcb75da2a7e4da94ef8c70", size = 433187, upload-time = "2026-03-28T17:17:19.523Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/c0/649856ee655a843c8f8664592cfccb73ac80ede6a8c8db33a25d810c12db/aiohttp-3.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:74a2eb058da44fa3a877a49e2095b591d4913308bb424c418b77beb160c55ce3", size = 459778, upload-time = "2026-03-28T17:17:21.964Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/29/6657cc37ae04cacc2dbf53fb730a06b6091cc4cbe745028e047c53e6d840/aiohttp-3.13.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:e0a2c961fc92abeff61d6444f2ce6ad35bb982db9fc8ff8a47455beacf454a57", size = 749363, upload-time = "2026-03-28T17:17:24.044Z" },
+ { url = "https://files.pythonhosted.org/packages/90/7f/30ccdf67ca3d24b610067dc63d64dcb91e5d88e27667811640644aa4a85d/aiohttp-3.13.4-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:153274535985a0ff2bff1fb6c104ed547cec898a09213d21b0f791a44b14d933", size = 499317, upload-time = "2026-03-28T17:17:26.199Z" },
+ { url = "https://files.pythonhosted.org/packages/93/13/e372dd4e68ad04ee25dafb050c7f98b0d91ea643f7352757e87231102555/aiohttp-3.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:351f3171e2458da3d731ce83f9e6b9619e325c45cbd534c7759750cabf453ad7", size = 500477, upload-time = "2026-03-28T17:17:28.279Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/fe/ee6298e8e586096fb6f5eddd31393d8544f33ae0792c71ecbb4c2bef98ac/aiohttp-3.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f989ac8bc5595ff761a5ccd32bdb0768a117f36dd1504b1c2c074ed5d3f4df9c", size = 1737227, upload-time = "2026-03-28T17:17:30.587Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/b9/a7a0463a09e1a3fe35100f74324f23644bfc3383ac5fd5effe0722a5f0b7/aiohttp-3.13.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d36fc1709110ec1e87a229b201dd3ddc32aa01e98e7868083a794609b081c349", size = 1694036, upload-time = "2026-03-28T17:17:33.29Z" },
+ { url = "https://files.pythonhosted.org/packages/57/7c/8972ae3fb7be00a91aee6b644b2a6a909aedb2c425269a3bfd90115e6f8f/aiohttp-3.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42adaeea83cbdf069ab94f5103ce0787c21fb1a0153270da76b59d5578302329", size = 1786814, upload-time = "2026-03-28T17:17:36.035Z" },
+ { url = "https://files.pythonhosted.org/packages/93/01/c81e97e85c774decbaf0d577de7d848934e8166a3a14ad9f8aa5be329d28/aiohttp-3.13.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:92deb95469928cc41fd4b42a95d8012fa6df93f6b1c0a83af0ffbc4a5e218cde", size = 1866676, upload-time = "2026-03-28T17:17:38.441Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/5f/5b46fe8694a639ddea2cd035bf5729e4677ea882cb251396637e2ef1590d/aiohttp-3.13.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c0c7c07c4257ef3a1df355f840bc62d133bcdef5c1c5ba75add3c08553e2eed", size = 1740842, upload-time = "2026-03-28T17:17:40.783Z" },
+ { url = "https://files.pythonhosted.org/packages/20/a2/0d4b03d011cca6b6b0acba8433193c1e484efa8d705ea58295590fe24203/aiohttp-3.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f062c45de8a1098cb137a1898819796a2491aec4e637a06b03f149315dff4d8f", size = 1566508, upload-time = "2026-03-28T17:17:43.235Z" },
+ { url = "https://files.pythonhosted.org/packages/98/17/e689fd500da52488ec5f889effd6404dece6a59de301e380f3c64f167beb/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:76093107c531517001114f0ebdb4f46858ce818590363e3e99a4a2280334454a", size = 1700569, upload-time = "2026-03-28T17:17:46.165Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/0d/66402894dbcf470ef7db99449e436105ea862c24f7ea4c95c683e635af35/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:6f6ec32162d293b82f8b63a16edc80769662fbd5ae6fbd4936d3206a2c2cc63b", size = 1707407, upload-time = "2026-03-28T17:17:48.825Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/eb/af0ab1a3650092cbd8e14ef29e4ab0209e1460e1c299996c3f8288b3f1ff/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5903e2db3d202a00ad9f0ec35a122c005e85d90c9836ab4cda628f01edf425e2", size = 1752214, upload-time = "2026-03-28T17:17:51.206Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/bf/72326f8a98e4c666f292f03c385545963cc65e358835d2a7375037a97b57/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2d5bea57be7aca98dbbac8da046d99b5557c5cf4e28538c4c786313078aca09e", size = 1562162, upload-time = "2026-03-28T17:17:53.634Z" },
+ { url = "https://files.pythonhosted.org/packages/67/9f/13b72435f99151dd9a5469c96b3b5f86aa29b7e785ca7f35cf5e538f74c0/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:bcf0c9902085976edc0232b75006ef38f89686901249ce14226b6877f88464fb", size = 1768904, upload-time = "2026-03-28T17:17:55.991Z" },
+ { url = "https://files.pythonhosted.org/packages/18/bc/28d4970e7d5452ac7776cdb5431a1164a0d9cf8bd2fffd67b4fb463aa56d/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3295f98bfeed2e867cab588f2a146a9db37a85e3ae9062abf46ba062bd29165", size = 1723378, upload-time = "2026-03-28T17:17:58.348Z" },
+ { url = "https://files.pythonhosted.org/packages/53/74/b32458ca1a7f34d65bdee7aef2036adbe0438123d3d53e2b083c453c24dd/aiohttp-3.13.4-cp314-cp314-win32.whl", hash = "sha256:a598a5c5767e1369d8f5b08695cab1d8160040f796c4416af76fd773d229b3c9", size = 438711, upload-time = "2026-03-28T17:18:00.728Z" },
+ { url = "https://files.pythonhosted.org/packages/40/b2/54b487316c2df3e03a8f3435e9636f8a81a42a69d942164830d193beb56a/aiohttp-3.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:c555db4bc7a264bead5a7d63d92d41a1122fcd39cc62a4db815f45ad46f9c2c8", size = 464977, upload-time = "2026-03-28T17:18:03.367Z" },
+ { url = "https://files.pythonhosted.org/packages/47/fb/e41b63c6ce71b07a59243bb8f3b457ee0c3402a619acb9d2c0d21ef0e647/aiohttp-3.13.4-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45abbbf09a129825d13c18c7d3182fecd46d9da3cfc383756145394013604ac1", size = 781549, upload-time = "2026-03-28T17:18:05.779Z" },
+ { url = "https://files.pythonhosted.org/packages/97/53/532b8d28df1e17e44c4d9a9368b78dcb6bf0b51037522136eced13afa9e8/aiohttp-3.13.4-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:74c80b2bc2c2adb7b3d1941b2b60701ee2af8296fc8aad8b8bc48bc25767266c", size = 514383, upload-time = "2026-03-28T17:18:08.096Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/1f/62e5d400603e8468cd635812d99cb81cfdc08127a3dc474c647615f31339/aiohttp-3.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c97989ae40a9746650fa196894f317dafc12227c808c774929dda0ff873a5954", size = 518304, upload-time = "2026-03-28T17:18:10.642Z" },
+ { url = "https://files.pythonhosted.org/packages/90/57/2326b37b10896447e3c6e0cbef4fe2486d30913639a5cfd1332b5d870f82/aiohttp-3.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dae86be9811493f9990ef44fff1685f5c1a3192e9061a71a109d527944eed551", size = 1893433, upload-time = "2026-03-28T17:18:13.121Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/b4/a24d82112c304afdb650167ef2fe190957d81cbddac7460bedd245f765aa/aiohttp-3.13.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1db491abe852ca2fa6cc48a3341985b0174b3741838e1341b82ac82c8bd9e871", size = 1755901, upload-time = "2026-03-28T17:18:16.21Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/2d/0883ef9d878d7846287f036c162a951968f22aabeef3ac97b0bea6f76d5d/aiohttp-3.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0e5d701c0aad02a7dce72eef6b93226cf3734330f1a31d69ebbf69f33b86666e", size = 1876093, upload-time = "2026-03-28T17:18:18.703Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/52/9204bb59c014869b71971addad6778f005daa72a96eed652c496789d7468/aiohttp-3.13.4-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8ac32a189081ae0a10ba18993f10f338ec94341f0d5df8fff348043962f3c6f8", size = 1970815, upload-time = "2026-03-28T17:18:21.858Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/b5/e4eb20275a866dde0f570f411b36c6b48f7b53edfe4f4071aa1b0728098a/aiohttp-3.13.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98e968cdaba43e45c73c3f306fca418c8009a957733bac85937c9f9cf3f4de27", size = 1816223, upload-time = "2026-03-28T17:18:24.729Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/23/e98075c5bb146aa61a1239ee1ac7714c85e814838d6cebbe37d3fe19214a/aiohttp-3.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca114790c9144c335d538852612d3e43ea0f075288f4849cf4b05d6cd2238ce7", size = 1649145, upload-time = "2026-03-28T17:18:27.269Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/c1/7bad8be33bb06c2bb224b6468874346026092762cbec388c3bdb65a368ee/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ea2e071661ba9cfe11eabbc81ac5376eaeb3061f6e72ec4cc86d7cdd1ffbdbbb", size = 1816562, upload-time = "2026-03-28T17:18:29.847Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/10/c00323348695e9a5e316825969c88463dcc24c7e9d443244b8a2c9cf2eae/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:34e89912b6c20e0fd80e07fa401fd218a410aa1ce9f1c2f1dad6db1bd0ce0927", size = 1800333, upload-time = "2026-03-28T17:18:32.269Z" },
+ { url = "https://files.pythonhosted.org/packages/84/43/9b2147a1df3559f49bd723e22905b46a46c068a53adb54abdca32c4de180/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0e217cf9f6a42908c52b46e42c568bd57adc39c9286ced31aaace614b6087965", size = 1820617, upload-time = "2026-03-28T17:18:35.238Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/7f/b3481a81e7a586d02e99387b18c6dafff41285f6efd3daa2124c01f87eae/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:0c296f1221e21ba979f5ac1964c3b78cfde15c5c5f855ffd2caab337e9cd9182", size = 1643417, upload-time = "2026-03-28T17:18:37.949Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/72/07181226bc99ce1124e0f89280f5221a82d3ae6a6d9d1973ce429d48e52b/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d99a9d168ebaffb74f36d011750e490085ac418f4db926cce3989c8fe6cb6b1b", size = 1849286, upload-time = "2026-03-28T17:18:40.534Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/e6/1b3566e103eca6da5be4ae6713e112a053725c584e96574caf117568ffef/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cb19177205d93b881f3f89e6081593676043a6828f59c78c17a0fd6c1fbed2ba", size = 1782635, upload-time = "2026-03-28T17:18:43.073Z" },
+ { url = "https://files.pythonhosted.org/packages/37/58/1b11c71904b8d079eb0c39fe664180dd1e14bebe5608e235d8bfbadc8929/aiohttp-3.13.4-cp314-cp314t-win32.whl", hash = "sha256:c606aa5656dab6552e52ca368e43869c916338346bfaf6304e15c58fb113ea30", size = 472537, upload-time = "2026-03-28T17:18:46.286Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/8f/87c56a1a1977d7dddea5b31e12189665a140fdb48a71e9038ff90bb564ec/aiohttp-3.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:014dcc10ec8ab8db681f0d68e939d1e9286a5aa2b993cbbdb0db130853e02144", size = 506381, upload-time = "2026-03-28T17:18:48.74Z" },
]
[[package]]
@@ -274,14 +274,14 @@ wheels = [
[[package]]
name = "click"
-version = "8.3.1"
+version = "8.1.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload-time = "2024-12-21T18:38:41.666Z" },
]
[[package]]
@@ -644,11 +644,11 @@ requires-dist = [
{ name = "coverage", marker = "extra == 'dev'", specifier = ">=7.6.0" },
{ name = "datasets", specifier = ">=3.0.0" },
{ name = "hypothesis", marker = "extra == 'dev'", specifier = ">=6.88.0" },
- { name = "litellm", specifier = ">=1.50.0" },
+ { name = "litellm", specifier = "!=1.82.7,!=1.82.8,>=1.83.7" },
{ name = "mini-swe-agent", specifier = ">=2.0.0" },
{ name = "pandas", specifier = ">=2.0.0" },
- { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
- { name = "python-dotenv", specifier = ">=1.0.0" },
+ { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.3" },
+ { name = "python-dotenv", specifier = ">=1.2.2" },
{ name = "pyyaml", specifier = ">=6.0" },
{ name = "rich", specifier = ">=13.0.0" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.5.0" },
@@ -769,14 +769,14 @@ wheels = [
[[package]]
name = "importlib-metadata"
-version = "9.0.0"
+version = "8.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "zipp" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/cd/12/33e59336dca5be0c398a7482335911a33aa0e20776128f038019f1a95f1b/importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7", size = 55304, upload-time = "2024-09-11T14:56:08.937Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7", size = 27789, upload-time = "2026-03-20T06:42:55.665Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/d9/a1e041c5e7caa9a05c925f4bdbdfb7f006d1f74996af53467bc394c97be7/importlib_metadata-8.5.0-py3-none-any.whl", hash = "sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b", size = 26514, upload-time = "2024-09-11T14:56:07.019Z" },
]
[[package]]
@@ -887,7 +887,7 @@ wheels = [
[[package]]
name = "jsonschema"
-version = "4.26.0"
+version = "4.23.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
@@ -895,9 +895,9 @@ dependencies = [
{ name = "referencing" },
{ name = "rpds-py" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/38/2e/03362ee4034a4c917f697890ccd4aec0800ccf9ded7f511971c75451deec/jsonschema-4.23.0.tar.gz", hash = "sha256:d71497fef26351a33265337fa77ffeb82423f3ea21283cd9467bb03999266bc4", size = 325778, upload-time = "2024-07-08T18:40:05.546Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" },
+ { url = "https://files.pythonhosted.org/packages/69/4a/4f9dbeb84e8850557c02365a0eee0649abe5eb1d84af92a25731c6c0f922/jsonschema-4.23.0-py3-none-any.whl", hash = "sha256:fbadb6f8b144a8f8cf9f0b89ba94501d143e50411a1278633f56a7acf7fd5566", size = 88462, upload-time = "2024-07-08T18:40:00.165Z" },
]
[[package]]
@@ -926,7 +926,7 @@ wheels = [
[[package]]
name = "litellm"
-version = "1.82.6"
+version = "1.83.14"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp" },
@@ -942,9 +942,9 @@ dependencies = [
{ name = "tiktoken" },
{ name = "tokenizers" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/29/75/1c537aa458426a9127a92bc2273787b2f987f4e5044e21f01f2eed5244fd/litellm-1.82.6.tar.gz", hash = "sha256:2aa1c2da21fe940c33613aa447119674a3ad4d2ad5eb064e4d5ce5ee42420136", size = 17414147, upload-time = "2026-03-22T06:36:00.452Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/8d/7c/c095649380adc96c8630273c1768c2ad1e74aa2ee1dd8dd05d218a60569f/litellm-1.83.14.tar.gz", hash = "sha256:24aef9b47cdc424c833e32f3727f411741c690832cd1fe4405e0077144fe09c9", size = 14836599, upload-time = "2026-04-26T03:16:10.176Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/02/6c/5327667e6dbe9e98cbfbd4261c8e91386a52e38f41419575854248bbab6a/litellm-1.82.6-py3-none-any.whl", hash = "sha256:164a3ef3e19f309e3cabc199bef3d2045212712fefdfa25fc7f75884a5b5b205", size = 15591595, upload-time = "2026-03-22T06:35:56.795Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/5c/1b5691575420135e90578543b2bf219497caa33cfd0af64cb38f30288450/litellm-1.83.14-py3-none-any.whl", hash = "sha256:92b11ba2a32cf80707ddf388d18526696c7999a21b418c5e3b6eda1243d2cfdb", size = 16457054, upload-time = "2026-04-26T03:16:05.72Z" },
]
[[package]]
@@ -1302,7 +1302,7 @@ wheels = [
[[package]]
name = "openai"
-version = "2.29.0"
+version = "2.24.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
@@ -1314,9 +1314,9 @@ dependencies = [
{ name = "tqdm" },
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/b4/15/203d537e58986b5673e7f232453a2a2f110f22757b15921cbdeea392e520/openai-2.29.0.tar.gz", hash = "sha256:32d09eb2f661b38d3edd7d7e1a2943d1633f572596febe64c0cd370c86d52bec", size = 671128, upload-time = "2026-03-17T17:53:49.599Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/55/13/17e87641b89b74552ed408a92b231283786523edddc95f3545809fab673c/openai-2.24.0.tar.gz", hash = "sha256:1e5769f540dbd01cb33bc4716a23e67b9d695161a734aff9c5f925e2bf99a673", size = 658717, upload-time = "2026-02-24T20:02:07.958Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/d0/b1/35b6f9c8cf9318e3dbb7146cc82dab4cf61182a8d5406fc9b50864362895/openai-2.29.0-py3-none-any.whl", hash = "sha256:b7c5de513c3286d17c5e29b92c4c98ceaf0d775244ac8159aeb1bddf840eb42a", size = 1141533, upload-time = "2026-03-17T17:53:47.348Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/30/844dc675ee6902579b8eef01ed23917cc9319a1c9c0c14ec6e39340c96d0/openai-2.24.0-py3-none-any.whl", hash = "sha256:fed30480d7d6c884303287bde864980a4b137b60553ffbcf9ab4a233b7a73d94", size = 1120122, upload-time = "2026-02-24T20:02:05.669Z" },
]
[[package]]
@@ -1690,7 +1690,7 @@ wheels = [
[[package]]
name = "pytest"
-version = "9.0.2"
+version = "9.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
@@ -1699,9 +1699,9 @@ dependencies = [
{ name = "pluggy" },
{ name = "pygments" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
]
[[package]]
@@ -1900,7 +1900,7 @@ wheels = [
[[package]]
name = "requests"
-version = "2.32.5"
+version = "2.33.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
@@ -1908,9 +1908,9 @@ dependencies = [
{ name = "idna" },
{ name = "urllib3" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232, upload-time = "2026-03-25T15:10:41.586Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
+ { url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017, upload-time = "2026-03-25T15:10:40.382Z" },
]
[[package]]
@@ -2224,7 +2224,7 @@ wheels = [
[[package]]
name = "typer"
-version = "0.24.1"
+version = "0.23.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
@@ -2232,9 +2232,9 @@ dependencies = [
{ name = "rich" },
{ name = "shellingham" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/f5/24/cb09efec5cc954f7f9b930bf8279447d24618bb6758d4f6adf2574c41780/typer-0.24.1.tar.gz", hash = "sha256:e39b4732d65fbdcde189ae76cf7cd48aeae72919dea1fdfc16593be016256b45", size = 118613, upload-time = "2026-02-21T16:54:40.609Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/fd/07/b822e1b307d40e263e8253d2384cf98c51aa2368cc7ba9a07e523a1d964b/typer-0.23.1.tar.gz", hash = "sha256:2070374e4d31c83e7b61362fd859aa683576432fd5b026b060ad6b4cd3b86134", size = 120047, upload-time = "2026-02-13T10:04:30.984Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e", size = 56085, upload-time = "2026-02-21T16:54:41.616Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/91/9b286ab899c008c2cb05e8be99814807e7fbbd33f0c0c960470826e5ac82/typer-0.23.1-py3-none-any.whl", hash = "sha256:3291ad0d3c701cbf522012faccfbb29352ff16ad262db2139e6b01f15781f14e", size = 56813, upload-time = "2026-02-13T10:04:32.008Z" },
]
[[package]]
diff --git a/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts b/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts
index d363c3c97..b2262b785 100644
--- a/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts
+++ b/gitnexus-shared/src/scope-resolution/finalize-algorithm.ts
@@ -93,7 +93,7 @@ export interface FinalizeHooks {
targetRaw: string,
fromFile: string,
workspaceIndex: WorkspaceIndex,
- ): string | null;
+ ): string | readonly string[] | null;
/**
* For a wildcard `import * from M`, return the names visible in the
@@ -127,20 +127,22 @@ export interface FinalizedScc {
/**
* Counters reported by `finalize`.
*
- * **Counting granularity** — all edge counters are **per-`ParsedImport`**,
- * not per-materialized-`ImportEdge`. A single `wildcard` ParsedImport that
- * expands to N exports counts as one linked edge in these stats; the
- * materialized output (`FinalizeOutput.imports`) will have N edges for
- * that input. `dynamic-unresolved` ParsedImports count as linked (they
- * pass through with no `linkStatus`), so `linkedEdges` ≠ "has a
+ * **Counting granularity** — `totalEdges` is **per-generated-`ImportEdgeDraft`**,
+ * which may exceed the number of `ParsedImport` records when
+ * `resolveImportTarget` returns a multi-file array (e.g. Go package-scoped
+ * imports fan out to every `.go` file in the target directory). A single
+ * `wildcard` ParsedImport that expands to N exports also counts as one
+ * linked edge here; the materialized output (`FinalizeOutput.imports`) will
+ * have N edges for that input. `dynamic-unresolved` ParsedImports count as
+ * linked (they pass through with no `linkStatus`), so `linkedEdges` ≠ "has a
* BindingRef" — use the `bindings` map for that.
*
- * In other words: `totalEdges === input.parsedImports.length` summed
+ * In other words: `totalEdges >= input.parsedImports.length` summed
* across files, and `linkedEdges + unresolvedEdges === totalEdges`.
*/
export interface FinalizeStats {
readonly totalFiles: number;
- /** Total `ParsedImport` records seen across all files. */
+ /** Total `ImportEdgeDraft` records generated (≥ ParsedImport count). */
readonly totalEdges: number;
/**
* `ParsedImport`s whose finalized edge does NOT carry
@@ -179,9 +181,9 @@ export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOu
for (const file of input.files) {
const drafts: ImportEdgeDraft[] = [];
for (const parsed of file.parsedImports) {
- const draft = makeEdgeDraft(parsed, file, hooks, input.workspaceIndex);
- drafts.push(draft);
- totalEdges++;
+ const draftArray = makeEdgeDrafts(parsed, file, hooks, input.workspaceIndex);
+ drafts.push(...draftArray);
+ totalEdges += draftArray.length;
}
edgeIndex.set(file.filePath, drafts);
}
@@ -320,12 +322,12 @@ interface ImportEdgeDraft {
finalized: ImportEdge | null;
}
-function makeEdgeDraft(
+function makeEdgeDrafts(
parsed: ParsedImport,
file: FinalizeFile,
hooks: FinalizeHooks,
workspace: WorkspaceIndex,
-): ImportEdgeDraft {
+): ImportEdgeDraft[] {
// Dynamic-unresolved passes through — no `BindingRef`, no target file.
if (parsed.kind === 'dynamic-unresolved') {
const base: ImportEdge = {
@@ -334,14 +336,16 @@ function makeEdgeDraft(
targetExportedName: '',
kind: 'dynamic-unresolved',
};
- return {
- source: parsed,
- fromFile: file.filePath,
- fromScope: file.moduleScope,
- targetFile: null,
- base,
- finalized: base, // already fully finalized
- };
+ return [
+ {
+ source: parsed,
+ fromFile: file.filePath,
+ fromScope: file.moduleScope,
+ targetFile: null,
+ base,
+ finalized: base, // already fully finalized
+ },
+ ];
}
const targetFile = hooks.resolveImportTarget(parsed.targetRaw ?? '', file.filePath, workspace);
@@ -355,14 +359,16 @@ function makeEdgeDraft(
kind: edgeKindFor(parsed),
linkStatus: 'unresolved',
};
- return {
- source: parsed,
- fromFile: file.filePath,
- fromScope: file.moduleScope,
- targetFile: null,
- base,
- finalized: base,
- };
+ return [
+ {
+ source: parsed,
+ fromFile: file.filePath,
+ fromScope: file.moduleScope,
+ targetFile: null,
+ base,
+ finalized: base,
+ },
+ ];
}
// Resolvable at the file level; intra-SCC fixpoint may still fail to fill
@@ -370,21 +376,40 @@ function makeEdgeDraft(
// and resolved-dynamic imports are terminal at the file level — no
// `targetDefId` needed since they materialize no `BindingRef`. Pre-
// finalize them here so the fixpoint loop skips them entirely.
- const base: ImportEdge = {
- localName: extractLocalName(parsed),
- targetFile,
- targetExportedName: extractExportedName(parsed),
- kind: edgeKindFor(parsed),
- };
+ const targetFiles = Array.isArray(targetFile) ? targetFile : [targetFile];
const isFileLevelTerminal = parsed.kind === 'side-effect' || parsed.kind === 'dynamic-resolved';
- return {
- source: parsed,
- fromFile: file.filePath,
- fromScope: file.moduleScope,
- targetFile,
- base,
- finalized: isFileLevelTerminal ? base : null,
- };
+ return targetFiles.map((tf) => {
+ const base: ImportEdge = {
+ localName: extractLocalName(parsed),
+ targetFile: tf,
+ targetExportedName: extractExportedName(parsed),
+ kind: edgeKindFor(parsed),
+ };
+ return {
+ source: parsed,
+ fromFile: file.filePath,
+ fromScope: file.moduleScope,
+ targetFile: tf,
+ base,
+ finalized: isFileLevelTerminal ? base : null,
+ };
+ });
+}
+
+function edgeKindFor(parsed: ParsedImport): ImportEdge['kind'] {
+ if (parsed.kind === 'wildcard') return 'wildcard-expanded';
+ return parsed.kind;
+}
+
+function extractLocalName(parsed: ParsedImport): string {
+ switch (parsed.kind) {
+ case 'wildcard':
+ case 'side-effect':
+ case 'dynamic-resolved':
+ return '';
+ default:
+ return parsed.localName;
+ }
}
function edgeKindFor(parsed: ParsedImport): ImportEdge['kind'] {
diff --git a/gitnexus-web/package-lock.json b/gitnexus-web/package-lock.json
index 61506f60c..e5fccc412 100644
--- a/gitnexus-web/package-lock.json
+++ b/gitnexus-web/package-lock.json
@@ -18,7 +18,7 @@
"@tailwindcss/vite": "^4.2.4",
"axios": "^1.13.2",
"d3": "^7.9.0",
- "dompurify": "^3.3.3",
+ "dompurify": "^3.4.2",
"gitnexus-shared": "file:../gitnexus-shared",
"graphology": "^0.26.0",
"graphology-indices": "^0.17.0",
@@ -1337,29 +1337,6 @@
"mlly": "^1.8.0"
}
},
- "node_modules/@isaacs/balanced-match": {
- "version": "4.0.1",
- "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
- "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "20 || >=22"
- }
- },
- "node_modules/@isaacs/brace-expansion": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz",
- "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@isaacs/balanced-match": "^4.0.1"
- },
- "engines": {
- "node": "20 || >=22"
- }
- },
"node_modules/@isaacs/fs-minipass": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
@@ -2439,9 +2416,9 @@
}
},
"node_modules/@ts-morph/common/node_modules/minimatch": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
- "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
+ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -4502,9 +4479,9 @@
"peer": true
},
"node_modules/dompurify": {
- "version": "3.3.3",
- "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
- "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
+ "version": "3.4.2",
+ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.2.tgz",
+ "integrity": "sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
@@ -4845,9 +4822,9 @@
}
},
"node_modules/follow-redirects": {
- "version": "1.15.11",
- "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
- "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
+ "version": "1.16.0",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
+ "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"funding": [
{
"type": "individual",
@@ -7077,9 +7054,9 @@
}
},
"node_modules/micromatch/node_modules/picomatch": {
- "version": "2.3.1",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
- "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
+ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -7121,21 +7098,44 @@
}
},
"node_modules/minimatch": {
- "version": "10.1.1",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz",
- "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==",
+ "version": "10.2.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
+ "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
- "@isaacs/brace-expansion": "^5.0.0"
+ "brace-expansion": "^5.0.5"
},
"engines": {
- "node": "20 || >=22"
+ "node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
+ "node_modules/minimatch/node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/minimatch/node_modules/brace-expansion": {
+ "version": "5.0.5",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
+ "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
@@ -8242,9 +8242,9 @@
}
},
"node_modules/tar": {
- "version": "7.5.3",
- "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.3.tgz",
- "integrity": "sha512-ENg5JUHUm2rDD7IvKNFGzyElLXNjachNLp6RaGf4+JOgxXHkqA+gq81ZAMCUmtMtqBsoU62lcp6S27g1LCYGGQ==",
+ "version": "7.5.13",
+ "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz",
+ "integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
diff --git a/gitnexus-web/package.json b/gitnexus-web/package.json
index 6d08273f5..d03509f4f 100644
--- a/gitnexus-web/package.json
+++ b/gitnexus-web/package.json
@@ -29,7 +29,7 @@
"@tailwindcss/vite": "^4.2.4",
"axios": "^1.13.2",
"d3": "^7.9.0",
- "dompurify": "^3.3.3",
+ "dompurify": "^3.4.2",
"graphology": "^0.26.0",
"graphology-indices": "^0.17.0",
"graphology-layout-force": "^0.2.4",
diff --git a/gitnexus-web/src/hooks/useAutoScroll.ts b/gitnexus-web/src/hooks/useAutoScroll.ts
index 55c2946f7..fb2a9ec97 100644
--- a/gitnexus-web/src/hooks/useAutoScroll.ts
+++ b/gitnexus-web/src/hooks/useAutoScroll.ts
@@ -4,8 +4,8 @@ const DEFAULT_BOTTOM_THRESHOLD = 100;
const USER_SCROLL_EPSILON = 5;
export interface UseAutoScrollResult {
- scrollContainerRef: React.RefObject;
- messagesContainerRef: React.RefObject;
+ scrollContainerRef: React.RefObject;
+ messagesContainerRef: React.RefObject;
isAtBottom: boolean;
scrollToBottom: (behavior?: ScrollBehavior) => void;
}
diff --git a/gitnexus-web/src/hooks/useSigma.ts b/gitnexus-web/src/hooks/useSigma.ts
index 8f350f0d3..461de0f5a 100644
--- a/gitnexus-web/src/hooks/useSigma.ts
+++ b/gitnexus-web/src/hooks/useSigma.ts
@@ -65,7 +65,7 @@ interface UseSigmaOptions {
}
interface UseSigmaReturn {
- containerRef: React.RefObject;
+ containerRef: React.RefObject;
sigmaRef: React.RefObject;
setGraph: (graph: Graph) => void;
zoomIn: () => void;
diff --git a/gitnexus-web/src/services/backend-client.ts b/gitnexus-web/src/services/backend-client.ts
index ebc18c17d..ec8c1a964 100644
--- a/gitnexus-web/src/services/backend-client.ts
+++ b/gitnexus-web/src/services/backend-client.ts
@@ -72,7 +72,19 @@ export class BackendError extends Error {
constructor(
message: string,
public readonly status: number,
- public readonly code: 'network' | 'server' | 'client' | 'not_found' | 'timeout',
+ public readonly code:
+ | 'network'
+ | 'server'
+ | 'client'
+ | 'not_found'
+ | 'timeout'
+ | 'rate_limited',
+ /**
+ * Milliseconds until the caller should retry. Populated for rate-limited
+ * responses (HTTP 429) from the server's `Retry-After` header. `undefined`
+ * for every other code, including `client` errors that aren't 429.
+ */
+ public readonly retryAfterMs?: number,
) {
super(message);
this.name = 'BackendError';
@@ -279,10 +291,32 @@ const assertOk = async (response: Response): Promise => {
const code =
response.status === 404
? 'not_found'
- : response.status >= 400 && response.status < 500
- ? 'client'
- : 'server';
- throw new BackendError(message, response.status, code);
+ : response.status === 429
+ ? 'rate_limited'
+ : response.status >= 400 && response.status < 500
+ ? 'client'
+ : 'server';
+
+ // Retry-After is the standard HTTP signal for when the client may try again.
+ // express-rate-limit emits it on 429 with seconds (integer) or HTTP-date.
+ // We accept both shapes; an unparseable header yields undefined retryAfterMs.
+ let retryAfterMs: number | undefined;
+ if (response.status === 429) {
+ const header = response.headers.get('retry-after');
+ if (header) {
+ const seconds = Number(header);
+ if (Number.isFinite(seconds) && seconds >= 0) {
+ retryAfterMs = seconds * 1000;
+ } else {
+ const dateMs = Date.parse(header);
+ if (Number.isFinite(dateMs)) {
+ retryAfterMs = Math.max(0, dateMs - Date.now());
+ }
+ }
+ }
+ }
+
+ throw new BackendError(message, response.status, code, retryAfterMs);
};
const repoParam = (repo?: string): string => (repo ? `repo=${encodeURIComponent(repo)}` : '');
diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json
index 61ddca305..4a2b6bc6b 100644
--- a/gitnexus/package-lock.json
+++ b/gitnexus/package-lock.json
@@ -11,13 +11,14 @@
"license": "PolyForm-Noncommercial-1.0.0",
"dependencies": {
"@huggingface/transformers": "^4.1.0",
- "@ladybugdb/core": "^0.16.0",
+ "@ladybugdb/core": "^0.16.1",
"@modelcontextprotocol/sdk": "^1.0.0",
"@scarf/scarf": "^1.4.0",
"cli-progress": "^3.12.0",
"commander": "^14.0.3",
"cors": "^2.8.5",
"express": "^4.19.2",
+ "express-rate-limit": "^8.4.1",
"glob": "^13.0.6",
"graphology": "^0.26.0",
"graphology-indices": "^0.17.0",
@@ -614,9 +615,9 @@
}
},
"node_modules/@hono/node-server": {
- "version": "1.19.11",
- "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz",
- "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==",
+ "version": "1.19.14",
+ "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
+ "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
"license": "MIT",
"engines": {
"node": ">=18.14.1"
@@ -1159,9 +1160,9 @@
}
},
"node_modules/@ladybugdb/core": {
- "version": "0.16.0",
- "resolved": "https://registry.npmjs.org/@ladybugdb/core/-/core-0.16.0.tgz",
- "integrity": "sha512-t/t4MPZmBMocFBzG5G3E3iHPwuIiXYEuLeW0CTOloGofkKQ7gHt3JlLzyDn2a+AHNQjr1YqlsodKKYQFhsFZXw==",
+ "version": "0.16.1",
+ "resolved": "https://registry.npmjs.org/@ladybugdb/core/-/core-0.16.1.tgz",
+ "integrity": "sha512-qwuEcR8CVMKb6tNDaHtq7Ux8hT/XbPC0db+vwutX6JxNAejyx7YomHKPSy9XAKURhYK8mezZe3UN8rf+xpHOjQ==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -1169,17 +1170,17 @@
"node-addon-api": "^6.0.0"
},
"optionalDependencies": {
- "@ladybugdb/core-darwin-arm64": "0.16.0",
- "@ladybugdb/core-darwin-x64": "0.16.0",
- "@ladybugdb/core-linux-arm64": "0.16.0",
- "@ladybugdb/core-linux-x64": "0.16.0",
- "@ladybugdb/core-win32-x64": "0.16.0"
+ "@ladybugdb/core-darwin-arm64": "0.16.1",
+ "@ladybugdb/core-darwin-x64": "0.16.1",
+ "@ladybugdb/core-linux-arm64": "0.16.1",
+ "@ladybugdb/core-linux-x64": "0.16.1",
+ "@ladybugdb/core-win32-x64": "0.16.1"
}
},
"node_modules/@ladybugdb/core-darwin-arm64": {
- "version": "0.16.0",
- "resolved": "https://registry.npmjs.org/@ladybugdb/core-darwin-arm64/-/core-darwin-arm64-0.16.0.tgz",
- "integrity": "sha512-2IpiUbd6Lb50KRUkURk+PIgDRKume63uI4KYZNpjxNDwdHRXdadZTBZn74+DgK7IhpTyiPbtKddiXHKtSV2CWg==",
+ "version": "0.16.1",
+ "resolved": "https://registry.npmjs.org/@ladybugdb/core-darwin-arm64/-/core-darwin-arm64-0.16.1.tgz",
+ "integrity": "sha512-Nl+Cf70rD+HaC9IBHv+oeUwqX9plghXD7PN9tyMzMohRVPvcGEbqWPB6YcdJa8rR7qRqCCbmaNMDen5wg4rY2w==",
"cpu": [
"arm64"
],
@@ -1189,10 +1190,23 @@
"darwin"
]
},
+ "node_modules/@ladybugdb/core-darwin-x64": {
+ "version": "0.16.1",
+ "resolved": "https://registry.npmjs.org/@ladybugdb/core-darwin-x64/-/core-darwin-x64-0.16.1.tgz",
+ "integrity": "sha512-4eAjfimAAQRSmDfUUkGrl9OhefxcW1ziA9tl0eljBlGoUseE7dL02+RSqjGohYMcQ+lzuHAq1QWb0XRlMA8YTQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
"node_modules/@ladybugdb/core-linux-arm64": {
- "version": "0.16.0",
- "resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-arm64/-/core-linux-arm64-0.16.0.tgz",
- "integrity": "sha512-l+lV7BXfnA0w1voApKblBaGE+bKQqSlOG+30HkSYOAW7POYv+OoydgY/BGwabBUTvcnhVyrNApvBsPF8G3Nm3g==",
+ "version": "0.16.1",
+ "resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-arm64/-/core-linux-arm64-0.16.1.tgz",
+ "integrity": "sha512-zkctksev+hsPFrNxHHdq4lYK5OWdLhWfRdQzjzkgDyaHayHU6yCL2fgD6uPGQ8TRQ6/2DxMErb4p3FzGW85Ubw==",
"cpu": [
"arm64"
],
@@ -1203,9 +1217,9 @@
]
},
"node_modules/@ladybugdb/core-linux-x64": {
- "version": "0.16.0",
- "resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-x64/-/core-linux-x64-0.16.0.tgz",
- "integrity": "sha512-XOL2H0y51e57dIFIHO8LHtN8Ner2qEyti6zAkxKr+w8LkvczHeVX910doz2de8+xvxDYJyzrcj2xWqDTxcK/Jg==",
+ "version": "0.16.1",
+ "resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-x64/-/core-linux-x64-0.16.1.tgz",
+ "integrity": "sha512-5rAb9T5vif8WKhHwhobosu2/aiOwJkWb/ViybvUc5GFKunKl8VI6RmZQVeufT9zUzRktUwrxBrxblCxsnamXJw==",
"cpu": [
"x64"
],
@@ -1216,9 +1230,9 @@
]
},
"node_modules/@ladybugdb/core-win32-x64": {
- "version": "0.16.0",
- "resolved": "https://registry.npmjs.org/@ladybugdb/core-win32-x64/-/core-win32-x64-0.16.0.tgz",
- "integrity": "sha512-MyKiELqPgzx9gVHmwxzptnToAcDtCN7dTP5Y4IPMYhc2QpNbZKCihmvdXjbOmdIOfIHW4fBp4vrzT8fVbdAMZw==",
+ "version": "0.16.1",
+ "resolved": "https://registry.npmjs.org/@ladybugdb/core-win32-x64/-/core-win32-x64-0.16.1.tgz",
+ "integrity": "sha512-ShOUTrIuZKQ63J95tcRJxKf1cvg8yi2FSYx9kMTSercc1FdQZPV+zxUN0myMq3MTWOl7xDxsVMmdp/t80O29UQ==",
"cpu": [
"x64"
],
@@ -3007,9 +3021,9 @@
}
},
"node_modules/express-rate-limit": {
- "version": "8.3.1",
- "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz",
- "integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==",
+ "version": "8.4.1",
+ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.4.1.tgz",
+ "integrity": "sha512-NGVYwQSAyEQgzxX1iCM978PP9AdO/hW93gMcF6ZwQCm+rFvLsBH6w4xcXWTcliS8La5EPRN3p9wzItqBwJrfNw==",
"license": "MIT",
"dependencies": {
"ip-address": "10.1.0"
@@ -3406,9 +3420,9 @@
}
},
"node_modules/hono": {
- "version": "4.12.9",
- "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.9.tgz",
- "integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==",
+ "version": "4.12.16",
+ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.16.tgz",
+ "integrity": "sha512-jN0ZewiNAWSe5khM3EyCmBb250+b40wWbwNILNfEvq84VREWwOIkuUsFONk/3i3nqkz7Oe1PcpM2mwQEK2L9Kg==",
"license": "MIT",
"engines": {
"node": ">=16.9.0"
@@ -3881,9 +3895,9 @@
"license": "Apache-2.0"
},
"node_modules/lru-cache": {
- "version": "11.3.5",
- "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz",
- "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==",
+ "version": "11.3.6",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz",
+ "integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==",
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
diff --git a/gitnexus/package.json b/gitnexus/package.json
index 73ebd3c6c..bd4cbe12c 100644
--- a/gitnexus/package.json
+++ b/gitnexus/package.json
@@ -53,13 +53,14 @@
},
"dependencies": {
"@huggingface/transformers": "^4.1.0",
- "@ladybugdb/core": "^0.16.0",
+ "@ladybugdb/core": "^0.16.1",
"@modelcontextprotocol/sdk": "^1.0.0",
"@scarf/scarf": "^1.4.0",
"cli-progress": "^3.12.0",
"commander": "^14.0.3",
"cors": "^2.8.5",
"express": "^4.19.2",
+ "express-rate-limit": "^8.4.1",
"glob": "^13.0.6",
"graphology": "^0.26.0",
"graphology-indices": "^0.17.0",
diff --git a/gitnexus/src/cli/setup.ts b/gitnexus/src/cli/setup.ts
index 9a2be4505..b301d7f38 100644
--- a/gitnexus/src/cli/setup.ts
+++ b/gitnexus/src/cli/setup.ts
@@ -32,15 +32,27 @@ interface SetupResult {
*/
function resolveGitnexusBin(): string | null {
try {
- const cmd = process.platform === 'win32' ? 'where' : 'which';
- const resolved = execFileSync(cmd, ['gitnexus'], {
+ const isWin = process.platform === 'win32';
+ const cmd = isWin ? 'where' : 'which';
+ const output = execFileSync(cmd, ['gitnexus'], {
encoding: 'utf-8',
timeout: 5000,
stdio: ['ignore', 'pipe', 'ignore'],
- })
- .split('\n')[0]
- .trim();
- return resolved || null;
+ });
+ const lines = output
+ .split('\n')
+ .map((l) => l.trim())
+ .filter(Boolean);
+
+ if (isWin) {
+ // On Windows, `where` returns multiple entries (e.g. the POSIX shell
+ // script AND the .cmd/.bat wrapper). Prefer the wrapper because
+ // child_process.spawn() cannot execute a shell script directly.
+ const cmdLine = lines.find((l) => /\.(cmd|bat)$/i.test(l));
+ return cmdLine || lines[0] || null;
+ }
+
+ return lines[0] || null;
} catch {
return null;
}
diff --git a/gitnexus/src/core/group/config-parser.ts b/gitnexus/src/core/group/config-parser.ts
index d55969a73..4703e6329 100644
--- a/gitnexus/src/core/group/config-parser.ts
+++ b/gitnexus/src/core/group/config-parser.ts
@@ -13,7 +13,7 @@ const DEFAULT_DETECT = {
topics: true,
shared_libs: true,
embedding_fallback: true,
- workspace_deps: true,
+ workspace_deps: false,
};
const DEFAULT_MATCHING = {
diff --git a/gitnexus/src/core/group/extractors/elixir-workspace-extractor.ts b/gitnexus/src/core/group/extractors/elixir-workspace-extractor.ts
new file mode 100644
index 000000000..33afcaed9
--- /dev/null
+++ b/gitnexus/src/core/group/extractors/elixir-workspace-extractor.ts
@@ -0,0 +1,253 @@
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import type { CypherExecutor } from '../contract-extractor.js';
+import type { GroupManifestLink, ContractRole } from '../types.js';
+import { shouldIgnorePath, loadIgnoreRules } from '../../../config/ignore-service.js';
+
+interface ElixirAppMeta {
+ appName: string;
+ modulePrefix: string;
+ groupPath: string;
+ repoPath: string;
+ deps: string[];
+}
+
+interface ImportedModule {
+ appName: string;
+ moduleName: string;
+ filePath: string;
+}
+
+async function parseMixExs(
+ repoPath: string,
+): Promise<{ appName: string; modulePrefix: string; deps: string[] } | null> {
+ const mixPath = path.join(repoPath, 'mix.exs');
+ let content: string;
+ try {
+ content = await fs.readFile(mixPath, 'utf-8');
+ } catch {
+ return null;
+ }
+
+ // app: :my_app
+ const appMatch = content.match(/app:\s*:(\w+)/);
+ if (!appMatch) return null;
+ const appName = appMatch[1];
+
+ // Derive module prefix: my_app -> MyApp
+ const modulePrefix = appName
+ .split('_')
+ .map((s) => s.charAt(0).toUpperCase() + s.slice(1))
+ .join('');
+
+ const deps: string[] = [];
+
+ // {:dep_name, "~> 1.0"} or {:dep_name, in_umbrella: true}
+ // {:dep_name, git: "..."} or {:dep_name, path: "..."}
+ const depMatches = content.matchAll(
+ /\{:(\w+)\s*,\s*(?:"[^"]*"|~[^}]*|[^}]*(?:in_umbrella|path|git)\s*:[^}]*)\}/g,
+ );
+ for (const m of depMatches) {
+ deps.push(m[1]);
+ }
+
+ return { appName, modulePrefix, deps: [...new Set(deps)] };
+}
+
+async function scanElixirImports(
+ repoPath: string,
+ knownApps: Map,
+): Promise {
+ const results: ImportedModule[] = [];
+ const sourceFiles = await findElixirFiles(repoPath);
+
+ for (const relFile of sourceFiles) {
+ const absPath = path.join(repoPath, relFile);
+ let content: string;
+ try {
+ content = await fs.readFile(absPath, 'utf-8');
+ } catch {
+ continue;
+ }
+
+ // alias MyApp.SomeModule
+ // alias MyApp.SomeModule, as: Short
+ // alias MyApp.{ModA, ModB}
+ const aliasRegex = /^\s*alias\s+([A-Z]\w+(?:\.[A-Z]\w+)*(?:\.\{[^}]+\})?)/gm;
+ let match;
+ while ((match = aliasRegex.exec(content)) !== null) {
+ const aliasExpr = match[1];
+ const modules = expandAlias(aliasExpr);
+ for (const mod of modules) {
+ const appName = matchModuleToApp(mod, knownApps);
+ if (appName) {
+ results.push({ appName, moduleName: mod, filePath: relFile });
+ }
+ }
+ }
+
+ // Direct module reference: MyApp.Module.func() or MyApp.Module
+ // Strip comment lines and string literals to avoid false positives
+ const codeOnly = content
+ .split('\n')
+ .filter((line) => !line.trimStart().startsWith('#'))
+ .join('\n');
+ for (const [prefix, appName] of knownApps) {
+ const refRegex = new RegExp(
+ `\\b(${escapeRegex(prefix)}\\.[A-Z][A-Za-z0-9]*(?:\\.[A-Z][A-Za-z0-9]*)*)`,
+ 'g',
+ );
+ while ((match = refRegex.exec(codeOnly)) !== null) {
+ const mod = match[1];
+ if (!results.some((r) => r.moduleName === mod && r.filePath === relFile)) {
+ results.push({ appName, moduleName: mod, filePath: relFile });
+ }
+ }
+ }
+ }
+
+ return results;
+}
+
+function expandAlias(expr: string): string[] {
+ const braceMatch = expr.match(/^([A-Z][\w.]*)\.\{([^}]+)\}$/);
+ if (braceMatch) {
+ const prefix = braceMatch[1];
+ return braceMatch[2]
+ .split(',')
+ .map((s) => s.trim())
+ .filter(Boolean)
+ .map((s) => `${prefix}.${s}`);
+ }
+ return [expr];
+}
+
+function matchModuleToApp(moduleName: string, knownApps: Map): string | null {
+ for (const [prefix, appName] of knownApps) {
+ if (moduleName === prefix || moduleName.startsWith(prefix + '.')) {
+ return appName;
+ }
+ }
+ return null;
+}
+
+function escapeRegex(s: string): string {
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
+function extractTopModule(moduleName: string, prefix: string): string {
+ const rest = moduleName.slice(prefix.length);
+ if (!rest || rest === '.') return moduleName;
+ const afterDot = rest.startsWith('.') ? rest.slice(1) : rest;
+ const parts = afterDot.split('.');
+ return `${prefix}.${parts[0]}`;
+}
+
+async function findElixirFiles(repoPath: string): Promise {
+ const results: string[] = [];
+ const ig = await loadIgnoreRules(repoPath);
+
+ async function walk(dir: string, rel: string): Promise {
+ let entries;
+ try {
+ entries = await fs.readdir(dir, { withFileTypes: true });
+ } catch {
+ return;
+ }
+ for (const entry of entries) {
+ const childRel = rel ? `${rel}/${entry.name}` : entry.name;
+ if (entry.isDirectory()) {
+ if (shouldIgnorePath(childRel)) continue;
+ if (ig && ig.ignores(childRel + '/')) continue;
+ await walk(path.join(dir, entry.name), childRel);
+ } else if (entry.name.endsWith('.ex') || entry.name.endsWith('.exs')) {
+ if (entry.name === 'mix.exs' || entry.name === 'mix.lock') continue;
+ if (shouldIgnorePath(childRel)) continue;
+ if (ig && ig.ignores(childRel)) continue;
+ results.push(childRel);
+ }
+ }
+ }
+
+ await walk(repoPath, '');
+ return results;
+}
+
+export interface ElixirWorkspaceResult {
+ links: GroupManifestLink[];
+ discoveredApps: Map;
+}
+
+export async function extractElixirWorkspaceLinks(
+ repos: Record,
+ repoPaths: Map,
+ _dbExecutors?: Map,
+): Promise {
+ const appsByName = new Map();
+ const appsByGroupPath = new Map();
+
+ for (const [groupPath] of Object.entries(repos)) {
+ const repoPath = repoPaths.get(groupPath);
+ if (!repoPath) continue;
+
+ const manifest = await parseMixExs(repoPath);
+ if (!manifest) continue;
+
+ const meta: ElixirAppMeta = {
+ appName: manifest.appName,
+ modulePrefix: manifest.modulePrefix,
+ groupPath,
+ repoPath,
+ deps: manifest.deps,
+ };
+ const existing = appsByName.get(manifest.appName);
+ if (existing) {
+ console.warn(
+ `[elixir-workspace-extractor] duplicate app "${manifest.appName}" in "${groupPath}" and "${existing.groupPath}" — skipping "${groupPath}"`,
+ );
+ continue;
+ }
+ appsByName.set(manifest.appName, meta);
+ appsByGroupPath.set(groupPath, meta);
+ }
+
+ const links: GroupManifestLink[] = [];
+ const seen = new Set();
+
+ for (const [, app] of appsByGroupPath) {
+ const groupDeps = app.deps.filter((d) => appsByName.has(d));
+ if (groupDeps.length === 0) continue;
+
+ const knownApps = new Map();
+ for (const dep of groupDeps) {
+ const depMeta = appsByName.get(dep);
+ if (depMeta) knownApps.set(depMeta.modulePrefix, dep);
+ }
+
+ const imports = await scanElixirImports(app.repoPath, knownApps);
+
+ for (const imp of imports) {
+ const providerApp = appsByName.get(imp.appName);
+ if (!providerApp) continue;
+
+ const topModule = extractTopModule(imp.moduleName, providerApp.modulePrefix);
+ const key = `${app.groupPath}→${providerApp.groupPath}::${topModule}`;
+ if (seen.has(key)) continue;
+ seen.add(key);
+
+ // V1: Elixir contracts use the full module name (e.g. "Core.Schema") without
+ // an "appName::" prefix. resolveSymbol will query the graph with this full
+ // string — resolution depends on Elixir indexer storing fully-qualified names.
+ const link: GroupManifestLink = {
+ from: providerApp.groupPath,
+ to: app.groupPath,
+ type: 'custom',
+ contract: topModule,
+ role: 'provider' as ContractRole,
+ };
+ links.push(link);
+ }
+ }
+
+ return { links, discoveredApps: appsByGroupPath };
+}
diff --git a/gitnexus/src/core/group/extractors/go-workspace-extractor.ts b/gitnexus/src/core/group/extractors/go-workspace-extractor.ts
new file mode 100644
index 000000000..fbdf0e450
--- /dev/null
+++ b/gitnexus/src/core/group/extractors/go-workspace-extractor.ts
@@ -0,0 +1,258 @@
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import type { CypherExecutor } from '../contract-extractor.js';
+import type { GroupManifestLink, ContractRole } from '../types.js';
+import { shouldIgnorePath, loadIgnoreRules } from '../../../config/ignore-service.js';
+
+interface GoModuleMeta {
+ modulePath: string;
+ groupPath: string;
+ repoPath: string;
+ requires: string[];
+}
+
+interface ImportedSymbol {
+ modulePath: string;
+ symbolName: string;
+ filePath: string;
+}
+
+async function parseGoMod(
+ repoPath: string,
+): Promise<{ modulePath: string; requires: string[] } | null> {
+ const goModPath = path.join(repoPath, 'go.mod');
+ let content: string;
+ try {
+ content = await fs.readFile(goModPath, 'utf-8');
+ } catch {
+ return null;
+ }
+
+ const moduleMatch = content.match(/^module\s+(\S+)/m);
+ if (!moduleMatch) return null;
+ const modulePath = moduleMatch[1];
+
+ const requires: string[] = [];
+
+ // Single-line: require github.com/org/repo v1.2.3
+ const singleReqs = content.matchAll(/^require\s+(\S+)\s+/gm);
+ for (const m of singleReqs) requires.push(m[1]);
+
+ // Block: require ( ... )
+ const blockReqs = content.matchAll(/^require\s*\(\s*\n([\s\S]*?)\)/gm);
+ for (const block of blockReqs) {
+ const lines = block[1].split('\n');
+ for (const line of lines) {
+ const trimmed = line.trim();
+ if (!trimmed || trimmed.startsWith('//')) continue;
+ const parts = trimmed.split(/\s+/);
+ if (parts[0]) requires.push(parts[0]);
+ }
+ }
+
+ // replace directives (local path deps)
+ const replaceLines = content.matchAll(/^replace\s+(\S+)\s+=>\s+\.\//gm);
+ for (const m of replaceLines) {
+ if (!requires.includes(m[1])) requires.push(m[1]);
+ }
+
+ const replaceBlocks = content.matchAll(/^replace\s*\(\s*\n([\s\S]*?)\)/gm);
+ for (const block of replaceBlocks) {
+ const lines = block[1].split('\n');
+ for (const line of lines) {
+ const trimmed = line.trim();
+ if (!trimmed || trimmed.startsWith('//')) continue;
+ const match = trimmed.match(/^(\S+)\s+=>\s+\.\//);
+ if (match && !requires.includes(match[1])) requires.push(match[1]);
+ }
+ }
+
+ return { modulePath, requires: [...new Set(requires)] };
+}
+
+async function scanGoImports(
+ repoPath: string,
+ knownModules: Map,
+): Promise {
+ const results: ImportedSymbol[] = [];
+ const sourceFiles = await findGoFiles(repoPath);
+
+ for (const relFile of sourceFiles) {
+ const absPath = path.join(repoPath, relFile);
+ let content: string;
+ try {
+ content = await fs.readFile(absPath, 'utf-8');
+ } catch {
+ continue;
+ }
+
+ const importPaths = extractImportPaths(content);
+ for (const importPath of importPaths) {
+ const matchedModule = findMatchingModule(importPath, knownModules);
+ if (!matchedModule) continue;
+
+ const symbols = extractUsedTypes(content, importPath);
+ for (const sym of symbols) {
+ results.push({ modulePath: matchedModule, symbolName: sym, filePath: relFile });
+ }
+ }
+ }
+
+ return results;
+}
+
+function extractImportPaths(content: string): string[] {
+ const paths: string[] = [];
+
+ // Single: import "path"
+ const singleImports = content.matchAll(/^import\s+"([^"]+)"/gm);
+ for (const m of singleImports) paths.push(m[1]);
+
+ // Single aliased: import alias "path"
+ const aliasedImports = content.matchAll(/^import\s+\w+\s+"([^"]+)"/gm);
+ for (const m of aliasedImports) paths.push(m[1]);
+
+ // Block: import ( ... )
+ const blockImports = content.matchAll(/^import\s*\(\s*\n([\s\S]*?)\)/gm);
+ for (const block of blockImports) {
+ const lines = block[1].split('\n');
+ for (const line of lines) {
+ const trimmed = line.trim();
+ if (!trimmed || trimmed.startsWith('//')) continue;
+ const pathMatch = trimmed.match(/"([^"]+)"/);
+ if (pathMatch) paths.push(pathMatch[1]);
+ }
+ }
+
+ return [...new Set(paths)];
+}
+
+function findMatchingModule(importPath: string, knownModules: Map): string | null {
+ for (const [modPath] of knownModules) {
+ if (importPath === modPath || importPath.startsWith(modPath + '/')) {
+ return modPath;
+ }
+ }
+ return null;
+}
+
+function extractUsedTypes(content: string, importPath: string): string[] {
+ const pkgName = importPath.split('/').pop() || '';
+ if (!pkgName) return [];
+
+ // Match pkg.TypeName where TypeName is PascalCase (exported)
+ const typeRegex = new RegExp(`\\b${escapeRegex(pkgName)}\\.([A-Z][A-Za-z0-9]*)`, 'g');
+ const types = new Set();
+ let match;
+ while ((match = typeRegex.exec(content)) !== null) {
+ types.add(match[1]);
+ }
+ return [...types];
+}
+
+function escapeRegex(s: string): string {
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
+async function findGoFiles(repoPath: string): Promise {
+ const results: string[] = [];
+ const ig = await loadIgnoreRules(repoPath);
+
+ async function walk(dir: string, rel: string): Promise {
+ let entries;
+ try {
+ entries = await fs.readdir(dir, { withFileTypes: true });
+ } catch {
+ return;
+ }
+ for (const entry of entries) {
+ const childRel = rel ? `${rel}/${entry.name}` : entry.name;
+ if (entry.isDirectory()) {
+ if (shouldIgnorePath(childRel)) continue;
+ if (ig && ig.ignores(childRel + '/')) continue;
+ await walk(path.join(dir, entry.name), childRel);
+ } else if (entry.name.endsWith('.go') && !entry.name.endsWith('_test.go')) {
+ if (shouldIgnorePath(childRel)) continue;
+ if (ig && ig.ignores(childRel)) continue;
+ results.push(childRel);
+ }
+ }
+ }
+
+ await walk(repoPath, '');
+ return results;
+}
+
+export interface GoWorkspaceResult {
+ links: GroupManifestLink[];
+ discoveredModules: Map;
+}
+
+export async function extractGoWorkspaceLinks(
+ repos: Record,
+ repoPaths: Map,
+ _dbExecutors?: Map,
+): Promise {
+ const modulesByPath = new Map();
+ const modulesByGroupPath = new Map();
+
+ for (const [groupPath] of Object.entries(repos)) {
+ const repoPath = repoPaths.get(groupPath);
+ if (!repoPath) continue;
+
+ const manifest = await parseGoMod(repoPath);
+ if (!manifest) continue;
+
+ const meta: GoModuleMeta = {
+ modulePath: manifest.modulePath,
+ groupPath,
+ repoPath,
+ requires: manifest.requires,
+ };
+ const existing = modulesByPath.get(manifest.modulePath);
+ if (existing) {
+ console.warn(
+ `[go-workspace-extractor] duplicate module "${manifest.modulePath}" in "${groupPath}" and "${existing.groupPath}" — skipping "${groupPath}"`,
+ );
+ continue;
+ }
+ modulesByPath.set(manifest.modulePath, meta);
+ modulesByGroupPath.set(groupPath, meta);
+ }
+
+ const links: GroupManifestLink[] = [];
+ const seen = new Set();
+
+ for (const [, mod] of modulesByGroupPath) {
+ const groupModDeps = mod.requires.filter((r) => modulesByPath.has(r));
+ if (groupModDeps.length === 0) continue;
+
+ const knownModules = new Map();
+ for (const dep of groupModDeps) {
+ knownModules.set(dep, dep);
+ }
+
+ const imports = await scanGoImports(mod.repoPath, knownModules);
+
+ for (const imp of imports) {
+ const providerMod = modulesByPath.get(imp.modulePath);
+ if (!providerMod) continue;
+
+ const qualifiedContract = `${imp.modulePath}::${imp.symbolName}`;
+ const key = `${mod.groupPath}→${providerMod.groupPath}::${qualifiedContract}`;
+ if (seen.has(key)) continue;
+ seen.add(key);
+
+ const link: GroupManifestLink = {
+ from: providerMod.groupPath,
+ to: mod.groupPath,
+ type: 'custom',
+ contract: qualifiedContract,
+ role: 'provider' as ContractRole,
+ };
+ links.push(link);
+ }
+ }
+
+ return { links, discoveredModules: modulesByGroupPath };
+}
diff --git a/gitnexus/src/core/group/extractors/java-workspace-extractor.ts b/gitnexus/src/core/group/extractors/java-workspace-extractor.ts
new file mode 100644
index 000000000..66ee35ee5
--- /dev/null
+++ b/gitnexus/src/core/group/extractors/java-workspace-extractor.ts
@@ -0,0 +1,261 @@
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import type { CypherExecutor } from '../contract-extractor.js';
+import type { GroupManifestLink, ContractRole } from '../types.js';
+import { shouldIgnorePath, loadIgnoreRules } from '../../../config/ignore-service.js';
+
+interface JavaProjectMeta {
+ groupId: string;
+ artifactId: string;
+ basePackage: string;
+ groupPath: string;
+ repoPath: string;
+ deps: string[];
+}
+
+interface ImportedSymbol {
+ artifactKey: string;
+ symbolName: string;
+ filePath: string;
+}
+
+async function parseJavaManifest(
+ repoPath: string,
+): Promise<{ groupId: string; artifactId: string; deps: string[] } | null> {
+ const pomPath = path.join(repoPath, 'pom.xml');
+ try {
+ const content = await fs.readFile(pomPath, 'utf-8');
+ return parsePom(content);
+ } catch {
+ // fall through to Gradle
+ }
+
+ for (const name of ['build.gradle.kts', 'build.gradle']) {
+ const gradlePath = path.join(repoPath, name);
+ try {
+ const content = await fs.readFile(gradlePath, 'utf-8');
+ return parseGradle(content, repoPath);
+ } catch {
+ continue;
+ }
+ }
+
+ return null;
+}
+
+function parsePom(content: string): { groupId: string; artifactId: string; deps: string[] } | null {
+ const projectGroupMatch = content.match(/]*>[\s\S]*?([^<]+)<\/groupId>/);
+ const projectArtifactMatch = content.match(
+ /]*>[\s\S]*?([^<]+)<\/artifactId>/,
+ );
+ if (!projectGroupMatch || !projectArtifactMatch) return null;
+
+ const groupId = projectGroupMatch[1].trim();
+ const artifactId = projectArtifactMatch[1].trim();
+
+ const deps: string[] = [];
+ const depBlocks = content.matchAll(/\s*([\s\S]*?)<\/dependency>/g);
+ for (const block of depBlocks) {
+ const gMatch = block[1].match(/([^<]+)<\/groupId>/);
+ const aMatch = block[1].match(/([^<]+)<\/artifactId>/);
+ if (gMatch && aMatch) {
+ deps.push(`${gMatch[1].trim()}:${aMatch[1].trim()}`);
+ }
+ }
+
+ return { groupId, artifactId, deps: [...new Set(deps)] };
+}
+
+function parseGradle(
+ content: string,
+ repoPath: string,
+): { groupId: string; artifactId: string; deps: string[] } | null {
+ const groupMatch = content.match(/group\s*=\s*['"]([^'"]+)['"]/);
+ const dirName = path.basename(repoPath);
+ const groupId = groupMatch ? groupMatch[1] : '';
+ if (!groupId) return null;
+
+ const artifactId = dirName;
+
+ const deps: string[] = [];
+ // implementation("group:artifact:version") or api("group:artifact:version")
+ const depMatches = content.matchAll(
+ /(?:implementation|api|compileOnly|runtimeOnly)\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
+ );
+ for (const m of depMatches) {
+ const parts = m[1].split(':');
+ if (parts.length >= 2) {
+ deps.push(`${parts[0]}:${parts[1]}`);
+ }
+ }
+
+ // implementation(project(":subproject"))
+ const projDeps = content.matchAll(
+ /(?:implementation|api)\s*\(\s*project\s*\(\s*['"]([^'"]+)['"]\s*\)\s*\)/g,
+ );
+ for (const m of projDeps) {
+ const subName = m[1].replace(/^:/, '');
+ deps.push(`${groupId}:${subName}`);
+ }
+
+ return { groupId, artifactId, deps: [...new Set(deps)] };
+}
+
+function deriveBasePackage(groupId: string, artifactId: string): string {
+ const sanitized = artifactId.replace(/-/g, '.');
+ if (groupId.endsWith(`.${sanitized}`) || groupId === sanitized) {
+ return groupId;
+ }
+ return `${groupId}.${sanitized}`;
+}
+
+async function scanJavaImports(
+ repoPath: string,
+ knownPackages: Map,
+): Promise {
+ const results: ImportedSymbol[] = [];
+ const sourceFiles = await findJavaFiles(repoPath);
+
+ for (const relFile of sourceFiles) {
+ const absPath = path.join(repoPath, relFile);
+ let content: string;
+ try {
+ content = await fs.readFile(absPath, 'utf-8');
+ } catch {
+ continue;
+ }
+
+ const importRegex = /^import\s+(?:static\s+)?([a-zA-Z][\w.]*\.[A-Z]\w*)/gm;
+ let match;
+ while ((match = importRegex.exec(content)) !== null) {
+ const fullImport = match[1];
+ for (const [basePkg, artifactKey] of knownPackages) {
+ if (fullImport.startsWith(basePkg + '.') || fullImport === basePkg) {
+ const parts = fullImport.split('.');
+ const className = parts[parts.length - 1];
+ if (isPascalCase(className)) {
+ results.push({
+ artifactKey,
+ symbolName: className,
+ filePath: relFile,
+ });
+ }
+ break;
+ }
+ }
+ }
+ }
+
+ return results;
+}
+
+function isPascalCase(name: string): boolean {
+ return /^[A-Z][A-Za-z0-9]*$/.test(name);
+}
+
+async function findJavaFiles(repoPath: string): Promise {
+ const results: string[] = [];
+ const ig = await loadIgnoreRules(repoPath);
+
+ async function walk(dir: string, rel: string): Promise {
+ let entries;
+ try {
+ entries = await fs.readdir(dir, { withFileTypes: true });
+ } catch {
+ return;
+ }
+ for (const entry of entries) {
+ const childRel = rel ? `${rel}/${entry.name}` : entry.name;
+ if (entry.isDirectory()) {
+ if (shouldIgnorePath(childRel)) continue;
+ if (ig && ig.ignores(childRel + '/')) continue;
+ await walk(path.join(dir, entry.name), childRel);
+ } else if (entry.name.endsWith('.java') || entry.name.endsWith('.kt')) {
+ if (shouldIgnorePath(childRel)) continue;
+ if (ig && ig.ignores(childRel)) continue;
+ results.push(childRel);
+ }
+ }
+ }
+
+ await walk(repoPath, '');
+ return results;
+}
+
+export interface JavaWorkspaceResult {
+ links: GroupManifestLink[];
+ discoveredProjects: Map;
+}
+
+export async function extractJavaWorkspaceLinks(
+ repos: Record,
+ repoPaths: Map,
+ _dbExecutors?: Map,
+): Promise {
+ const projectsByKey = new Map();
+ const projectsByGroupPath = new Map();
+
+ for (const [groupPath] of Object.entries(repos)) {
+ const repoPath = repoPaths.get(groupPath);
+ if (!repoPath) continue;
+
+ const manifest = await parseJavaManifest(repoPath);
+ if (!manifest) continue;
+
+ const key = `${manifest.groupId}:${manifest.artifactId}`;
+ const meta: JavaProjectMeta = {
+ groupId: manifest.groupId,
+ artifactId: manifest.artifactId,
+ basePackage: deriveBasePackage(manifest.groupId, manifest.artifactId),
+ groupPath,
+ repoPath,
+ deps: manifest.deps,
+ };
+ const existing = projectsByKey.get(key);
+ if (existing) {
+ console.warn(
+ `[java-workspace-extractor] duplicate artifact "${key}" in "${groupPath}" and "${existing.groupPath}" — skipping "${groupPath}"`,
+ );
+ continue;
+ }
+ projectsByKey.set(key, meta);
+ projectsByGroupPath.set(groupPath, meta);
+ }
+
+ const links: GroupManifestLink[] = [];
+ const seen = new Set();
+
+ for (const [, proj] of projectsByGroupPath) {
+ const groupDeps = proj.deps.filter((d) => projectsByKey.has(d));
+ if (groupDeps.length === 0) continue;
+
+ const knownPackages = new Map();
+ for (const dep of groupDeps) {
+ const depMeta = projectsByKey.get(dep);
+ if (depMeta) knownPackages.set(depMeta.basePackage, dep);
+ }
+
+ const imports = await scanJavaImports(proj.repoPath, knownPackages);
+
+ for (const imp of imports) {
+ const providerProj = projectsByKey.get(imp.artifactKey);
+ if (!providerProj) continue;
+
+ const qualifiedContract = `${providerProj.artifactId}::${imp.symbolName}`;
+ const dedupKey = `${proj.groupPath}→${providerProj.groupPath}::${qualifiedContract}`;
+ if (seen.has(dedupKey)) continue;
+ seen.add(dedupKey);
+
+ const link: GroupManifestLink = {
+ from: providerProj.groupPath,
+ to: proj.groupPath,
+ type: 'custom',
+ contract: qualifiedContract,
+ role: 'provider' as ContractRole,
+ };
+ links.push(link);
+ }
+ }
+
+ return { links, discoveredProjects: projectsByGroupPath };
+}
diff --git a/gitnexus/src/core/group/extractors/manifest-extractor.ts b/gitnexus/src/core/group/extractors/manifest-extractor.ts
index b65b7712d..3185f05e1 100644
--- a/gitnexus/src/core/group/extractors/manifest-extractor.ts
+++ b/gitnexus/src/core/group/extractors/manifest-extractor.ts
@@ -269,17 +269,19 @@ export class ManifestExtractor {
{ contract: link.contract },
);
} else if (link.type === 'custom') {
- // V1: exact name-only match on code-definition nodes.
- // Positive allowlist mirrors other contract types. If multiple code
- // symbols share the same name, ORDER BY filePath ASC LIMIT 1 picks
- // the alphabetically-first occurrence deterministically.
+ // Workspace extractors produce qualified contracts like "mathlex::Expression".
+ // Graph nodes store the unqualified symbol name ("Expression"), so strip
+ // the "provider::" prefix before querying.
+ const symbolName = link.contract.includes('::')
+ ? link.contract.split('::').pop()!
+ : link.contract;
rows = await executor(
`MATCH (n:Function|Method|Class|Interface|Struct|Enum|Trait|Constructor|TypeAlias|Impl|Macro|Union|Typedef|Property|Record|Delegate|Annotation|Template|Const|Static|CodeElement)
- WHERE n.name = $contract
+ WHERE n.name = $symbolName
RETURN n.id AS uid, n.name AS name, n.filePath AS filePath
ORDER BY n.filePath ASC
LIMIT 1`,
- { contract: link.contract },
+ { symbolName },
);
} else {
return null;
diff --git a/gitnexus/src/core/group/extractors/node-workspace-extractor.ts b/gitnexus/src/core/group/extractors/node-workspace-extractor.ts
new file mode 100644
index 000000000..05a7c95dd
--- /dev/null
+++ b/gitnexus/src/core/group/extractors/node-workspace-extractor.ts
@@ -0,0 +1,248 @@
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import type { CypherExecutor } from '../contract-extractor.js';
+import type { GroupManifestLink, ContractRole } from '../types.js';
+import { shouldIgnorePath, loadIgnoreRules } from '../../../config/ignore-service.js';
+
+interface PackageMeta {
+ name: string;
+ groupPath: string;
+ repoPath: string;
+ workspaceDeps: string[];
+}
+
+interface ImportedSymbol {
+ packageName: string;
+ symbolName: string;
+ filePath: string;
+}
+
+async function parsePackageManifest(
+ repoPath: string,
+): Promise<{ name: string; workspaceDeps: string[] } | null> {
+ const pkgPath = path.join(repoPath, 'package.json');
+ let content: string;
+ try {
+ content = await fs.readFile(pkgPath, 'utf-8');
+ } catch {
+ return null;
+ }
+
+ let pkg: Record;
+ try {
+ pkg = JSON.parse(content);
+ } catch {
+ return null;
+ }
+
+ const name = typeof pkg.name === 'string' ? pkg.name : '';
+ if (!name) return null;
+
+ const deps: string[] = [];
+ for (const field of ['dependencies', 'devDependencies', 'peerDependencies']) {
+ const section = pkg[field];
+ if (section && typeof section === 'object') {
+ deps.push(...Object.keys(section as Record));
+ }
+ }
+
+ return { name, workspaceDeps: [...new Set(deps)] };
+}
+
+async function scanImports(
+ repoPath: string,
+ knownPackages: Set,
+): Promise {
+ const results: ImportedSymbol[] = [];
+ const sourceFiles = await findSourceFiles(repoPath);
+
+ for (const relFile of sourceFiles) {
+ const absPath = path.join(repoPath, relFile);
+ let content: string;
+ try {
+ content = await fs.readFile(absPath, 'utf-8');
+ } catch {
+ continue;
+ }
+
+ // ES import: import { Foo, Bar } from ''
+ // Also: import { Foo as Baz } from ''
+ const esImportRegex = /^import\s+\{([^}]+)\}\s+from\s+['"]([^'"]+)['"]/gm;
+ let match;
+ while ((match = esImportRegex.exec(content)) !== null) {
+ const importClause = match[1];
+ const modulePath = match[2];
+ const pkgName = resolvePackageName(modulePath);
+ if (!pkgName || !knownPackages.has(pkgName)) continue;
+
+ const symbols = parseImportClause(importClause);
+ for (const sym of symbols) {
+ if (isExportedName(sym)) {
+ results.push({ packageName: pkgName, symbolName: sym, filePath: relFile });
+ }
+ }
+ }
+
+ // ES import default: import Foo from ''
+ const defaultImportRegex = /^import\s+([A-Z][A-Za-z0-9]*)\s+from\s+['"]([^'"]+)['"]/gm;
+ while ((match = defaultImportRegex.exec(content)) !== null) {
+ const symbolName = match[1];
+ const modulePath = match[2];
+ const pkgName = resolvePackageName(modulePath);
+ if (!pkgName || !knownPackages.has(pkgName)) continue;
+
+ if (isExportedName(symbolName)) {
+ results.push({ packageName: pkgName, symbolName, filePath: relFile });
+ }
+ }
+
+ // CommonJS: const { Foo, Bar } = require('')
+ const cjsRegex = /(?:const|let|var)\s+\{([^}]+)\}\s*=\s*require\s*\(\s*['"]([^'"]+)['"]\s*\)/gm;
+ while ((match = cjsRegex.exec(content)) !== null) {
+ const importClause = match[1];
+ const modulePath = match[2];
+ const pkgName = resolvePackageName(modulePath);
+ if (!pkgName || !knownPackages.has(pkgName)) continue;
+
+ const symbols = parseImportClause(importClause);
+ for (const sym of symbols) {
+ if (isExportedName(sym)) {
+ results.push({ packageName: pkgName, symbolName: sym, filePath: relFile });
+ }
+ }
+ }
+ }
+
+ return results;
+}
+
+function resolvePackageName(modulePath: string): string | null {
+ if (modulePath.startsWith('.') || modulePath.startsWith('/')) return null;
+ // Scoped: @scope/pkg or @scope/pkg/sub
+ if (modulePath.startsWith('@')) {
+ const parts = modulePath.split('/');
+ if (parts.length >= 2) return `${parts[0]}/${parts[1]}`;
+ return null;
+ }
+ // Unscoped: pkg or pkg/sub
+ return modulePath.split('/')[0];
+}
+
+function parseImportClause(clause: string): string[] {
+ return clause
+ .split(',')
+ .map((s) => {
+ const trimmed = s.trim();
+ // Handle `Foo as Bar` — use the original export name
+ const asMatch = trimmed.match(/^(\S+)\s+as\s+/);
+ return asMatch ? asMatch[1] : trimmed;
+ })
+ .filter(Boolean);
+}
+
+function isExportedName(name: string): boolean {
+ return /^[A-Z][A-Za-z0-9]*$/.test(name);
+}
+
+async function findSourceFiles(repoPath: string): Promise {
+ const results: string[] = [];
+ const EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.mts', '.cts']);
+ const ig = await loadIgnoreRules(repoPath);
+
+ async function walk(dir: string, rel: string): Promise {
+ let entries;
+ try {
+ entries = await fs.readdir(dir, { withFileTypes: true });
+ } catch {
+ return;
+ }
+ for (const entry of entries) {
+ const childRel = rel ? `${rel}/${entry.name}` : entry.name;
+ if (entry.isDirectory()) {
+ if (shouldIgnorePath(childRel)) continue;
+ if (ig && ig.ignores(childRel + '/')) continue;
+ await walk(path.join(dir, entry.name), childRel);
+ } else {
+ const ext = path.extname(entry.name);
+ if (EXTENSIONS.has(ext)) {
+ if (shouldIgnorePath(childRel)) continue;
+ if (ig && ig.ignores(childRel)) continue;
+ results.push(childRel);
+ }
+ }
+ }
+ }
+
+ await walk(repoPath, '');
+ return results;
+}
+
+export interface NodeWorkspaceResult {
+ links: GroupManifestLink[];
+ discoveredPackages: Map;
+}
+
+export async function extractNodeWorkspaceLinks(
+ repos: Record,
+ repoPaths: Map,
+ _dbExecutors?: Map,
+): Promise {
+ const packagesByName = new Map();
+ const packagesByGroupPath = new Map();
+
+ for (const [groupPath] of Object.entries(repos)) {
+ const repoPath = repoPaths.get(groupPath);
+ if (!repoPath) continue;
+
+ const manifest = await parsePackageManifest(repoPath);
+ if (!manifest) continue;
+
+ const meta: PackageMeta = {
+ name: manifest.name,
+ groupPath,
+ repoPath,
+ workspaceDeps: manifest.workspaceDeps,
+ };
+ const existing = packagesByName.get(manifest.name);
+ if (existing) {
+ console.warn(
+ `[node-workspace-extractor] duplicate package name "${manifest.name}" in "${groupPath}" and "${existing.groupPath}" — skipping "${groupPath}"`,
+ );
+ continue;
+ }
+ packagesByName.set(manifest.name, meta);
+ packagesByGroupPath.set(groupPath, meta);
+ }
+
+ const links: GroupManifestLink[] = [];
+ const seen = new Set();
+
+ for (const [, pkg] of packagesByGroupPath) {
+ const groupPkgDeps = pkg.workspaceDeps.filter((d) => packagesByName.has(d));
+ if (groupPkgDeps.length === 0) continue;
+
+ const knownPackages = new Set(groupPkgDeps);
+ const imports = await scanImports(pkg.repoPath, knownPackages);
+
+ for (const imp of imports) {
+ const providerPkg = packagesByName.get(imp.packageName);
+ if (!providerPkg) continue;
+
+ const qualifiedContract = `${imp.packageName}::${imp.symbolName}`;
+ const key = `${pkg.groupPath}→${providerPkg.groupPath}::${qualifiedContract}`;
+ if (seen.has(key)) continue;
+ seen.add(key);
+
+ const link: GroupManifestLink = {
+ from: providerPkg.groupPath,
+ to: pkg.groupPath,
+ type: 'custom',
+ contract: qualifiedContract,
+ role: 'provider' as ContractRole,
+ };
+ links.push(link);
+ }
+ }
+
+ return { links, discoveredPackages: packagesByGroupPath };
+}
diff --git a/gitnexus/src/core/group/extractors/python-workspace-extractor.ts b/gitnexus/src/core/group/extractors/python-workspace-extractor.ts
new file mode 100644
index 000000000..5930808af
--- /dev/null
+++ b/gitnexus/src/core/group/extractors/python-workspace-extractor.ts
@@ -0,0 +1,254 @@
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import type { CypherExecutor } from '../contract-extractor.js';
+import type { GroupManifestLink, ContractRole } from '../types.js';
+import { shouldIgnorePath, loadIgnoreRules } from '../../../config/ignore-service.js';
+
+interface PythonPackageMeta {
+ name: string;
+ importName: string;
+ groupPath: string;
+ repoPath: string;
+ workspaceDeps: string[];
+}
+
+interface ImportedSymbol {
+ packageName: string;
+ symbolName: string;
+ filePath: string;
+}
+
+async function parsePythonManifest(
+ repoPath: string,
+): Promise<{ name: string; importName: string; deps: string[] } | null> {
+ const pyprojectPath = path.join(repoPath, 'pyproject.toml');
+ let content: string | null = null;
+ try {
+ content = await fs.readFile(pyprojectPath, 'utf-8');
+ } catch {
+ // fall through to setup.py
+ }
+
+ if (content) return parsePyproject(content);
+
+ const setupPyPath = path.join(repoPath, 'setup.py');
+ try {
+ content = await fs.readFile(setupPyPath, 'utf-8');
+ } catch {
+ return null;
+ }
+ return parseSetupPy(content);
+}
+
+function parsePyproject(
+ content: string,
+): { name: string; importName: string; deps: string[] } | null {
+ const nameMatch = content.match(/^\[project\]\s*\n(?:[^\n\[]*\n)*?name\s*=\s*"([^"]+)"/m);
+ if (!nameMatch) return null;
+ const name = nameMatch[1];
+ const importName = name.replace(/-/g, '_');
+
+ const deps: string[] = [];
+ const depsMatch = content.match(/^\[project\]\s*\n[\s\S]*?dependencies\s*=\s*\[([\s\S]*?)\]/m);
+ if (depsMatch) {
+ const depLines = depsMatch[1].matchAll(/"([^"]+)"/g);
+ for (const m of depLines) {
+ deps.push(extractPepName(m[1]));
+ }
+ }
+
+ const optMatch = content.match(/\[project\.optional-dependencies\]\s*\n([\s\S]*?)(?=\n\[|$)/);
+ if (optMatch) {
+ const optDeps = optMatch[1].matchAll(/"([^"]+)"/g);
+ for (const m of optDeps) {
+ deps.push(extractPepName(m[1]));
+ }
+ }
+
+ return { name, importName, deps: [...new Set(deps)] };
+}
+
+function parseSetupPy(
+ content: string,
+): { name: string; importName: string; deps: string[] } | null {
+ const nameMatch = content.match(/name\s*=\s*['"]([^'"]+)['"]/);
+ if (!nameMatch) return null;
+ const name = nameMatch[1];
+ const importName = name.replace(/-/g, '_');
+
+ const deps: string[] = [];
+ const installMatch = content.match(/install_requires\s*=\s*\[([\s\S]*?)\]/);
+ if (installMatch) {
+ const depLines = installMatch[1].matchAll(/['"]([^'"]+)['"]/g);
+ for (const m of depLines) {
+ deps.push(extractPepName(m[1]));
+ }
+ }
+
+ return { name, importName, deps: [...new Set(deps)] };
+}
+
+function extractPepName(spec: string): string {
+ return spec.split(/[><=!~;\[]/)[0].trim();
+}
+
+async function scanPythonImports(
+ repoPath: string,
+ knownPackages: Map,
+): Promise {
+ const results: ImportedSymbol[] = [];
+ const sourceFiles = await findPythonFiles(repoPath);
+
+ for (const relFile of sourceFiles) {
+ const absPath = path.join(repoPath, relFile);
+ let content: string;
+ try {
+ content = await fs.readFile(absPath, 'utf-8');
+ } catch {
+ continue;
+ }
+
+ // from import Foo, Bar
+ // from .module import Foo
+ const fromImportRegex = /^from\s+(\w[\w.]*)\s+import\s+(.+)/gm;
+ let match;
+ while ((match = fromImportRegex.exec(content)) !== null) {
+ const modulePath = match[1];
+ const importClause = match[2];
+ const rootModule = modulePath.split('.')[0];
+ const originalName = knownPackages.get(rootModule);
+ if (!originalName) continue;
+
+ if (importClause.trim() === '(') continue;
+
+ const symbols = importClause
+ .replace(/\(|\)/g, '')
+ .split(',')
+ .map((s) => {
+ const trimmed = s.trim();
+ const asMatch = trimmed.match(/^(\S+)\s+as\s+/);
+ return asMatch ? asMatch[1] : trimmed;
+ })
+ .filter(Boolean);
+
+ for (const sym of symbols) {
+ if (isPascalCase(sym)) {
+ results.push({ packageName: originalName, symbolName: sym, filePath: relFile });
+ }
+ }
+ }
+ }
+
+ return results;
+}
+
+function isPascalCase(name: string): boolean {
+ return /^[A-Z][A-Za-z0-9]*$/.test(name);
+}
+
+async function findPythonFiles(repoPath: string): Promise {
+ const results: string[] = [];
+ const ig = await loadIgnoreRules(repoPath);
+
+ async function walk(dir: string, rel: string): Promise {
+ let entries;
+ try {
+ entries = await fs.readdir(dir, { withFileTypes: true });
+ } catch {
+ return;
+ }
+ for (const entry of entries) {
+ const childRel = rel ? `${rel}/${entry.name}` : entry.name;
+ if (entry.isDirectory()) {
+ if (shouldIgnorePath(childRel)) continue;
+ if (ig && ig.ignores(childRel + '/')) continue;
+ await walk(path.join(dir, entry.name), childRel);
+ } else if (entry.name.endsWith('.py')) {
+ if (shouldIgnorePath(childRel)) continue;
+ if (ig && ig.ignores(childRel)) continue;
+ results.push(childRel);
+ }
+ }
+ }
+
+ await walk(repoPath, '');
+ return results;
+}
+
+export interface PythonWorkspaceResult {
+ links: GroupManifestLink[];
+ discoveredPackages: Map;
+}
+
+export async function extractPythonWorkspaceLinks(
+ repos: Record,
+ repoPaths: Map,
+ _dbExecutors?: Map,
+): Promise {
+ const packagesByImportName = new Map();
+ const packagesByGroupPath = new Map();
+
+ for (const [groupPath] of Object.entries(repos)) {
+ const repoPath = repoPaths.get(groupPath);
+ if (!repoPath) continue;
+
+ const manifest = await parsePythonManifest(repoPath);
+ if (!manifest) continue;
+
+ const meta: PythonPackageMeta = {
+ name: manifest.name,
+ importName: manifest.importName,
+ groupPath,
+ repoPath,
+ workspaceDeps: manifest.deps,
+ };
+ const existing = packagesByImportName.get(manifest.importName);
+ if (existing) {
+ console.warn(
+ `[python-workspace-extractor] duplicate package "${manifest.name}" in "${groupPath}" and "${existing.groupPath}" — skipping "${groupPath}"`,
+ );
+ continue;
+ }
+ packagesByImportName.set(manifest.importName, meta);
+ packagesByGroupPath.set(groupPath, meta);
+ }
+
+ const links: GroupManifestLink[] = [];
+ const seen = new Set();
+
+ for (const [, pkg] of packagesByGroupPath) {
+ const normalizedDeps = pkg.workspaceDeps.map((d) => d.replace(/-/g, '_'));
+ const groupPkgDeps = normalizedDeps.filter((d) => packagesByImportName.has(d));
+ if (groupPkgDeps.length === 0) continue;
+
+ const knownPackages = new Map();
+ for (const dep of groupPkgDeps) {
+ const meta = packagesByImportName.get(dep);
+ if (meta) knownPackages.set(dep, meta.name);
+ }
+
+ const imports = await scanPythonImports(pkg.repoPath, knownPackages);
+
+ for (const imp of imports) {
+ const providerImportName = imp.packageName.replace(/-/g, '_');
+ const providerPkg = packagesByImportName.get(providerImportName);
+ if (!providerPkg) continue;
+
+ const qualifiedContract = `${providerPkg.name}::${imp.symbolName}`;
+ const key = `${pkg.groupPath}→${providerPkg.groupPath}::${qualifiedContract}`;
+ if (seen.has(key)) continue;
+ seen.add(key);
+
+ const link: GroupManifestLink = {
+ from: providerPkg.groupPath,
+ to: pkg.groupPath,
+ type: 'custom',
+ contract: qualifiedContract,
+ role: 'provider' as ContractRole,
+ };
+ links.push(link);
+ }
+ }
+
+ return { links, discoveredPackages: packagesByGroupPath };
+}
diff --git a/gitnexus/src/core/group/extractors/workspace-extractor.ts b/gitnexus/src/core/group/extractors/workspace-extractor.ts
new file mode 100644
index 000000000..652f06e46
--- /dev/null
+++ b/gitnexus/src/core/group/extractors/workspace-extractor.ts
@@ -0,0 +1,90 @@
+import type { CypherExecutor } from '../contract-extractor.js';
+import type { GroupManifestLink } from '../types.js';
+import { extractRustWorkspaceLinks } from './rust-workspace-extractor.js';
+import { extractNodeWorkspaceLinks } from './node-workspace-extractor.js';
+import { extractPythonWorkspaceLinks } from './python-workspace-extractor.js';
+import { extractGoWorkspaceLinks } from './go-workspace-extractor.js';
+import { extractJavaWorkspaceLinks } from './java-workspace-extractor.js';
+import { extractElixirWorkspaceLinks } from './elixir-workspace-extractor.js';
+
+export interface WorkspaceDiscoveryResult {
+ links: GroupManifestLink[];
+ stats: WorkspaceExtractorStats[];
+}
+
+interface WorkspaceExtractorStats {
+ ecosystem: string;
+ linkCount: number;
+ projectCount: number;
+}
+
+export async function discoverWorkspaceLinks(
+ repos: Record,
+ repoPaths: Map,
+ dbExecutors?: Map,
+): Promise {
+ const links: GroupManifestLink[] = [];
+ const stats: WorkspaceExtractorStats[] = [];
+
+ const rustResult = await extractRustWorkspaceLinks(repos, repoPaths, dbExecutors);
+ if (rustResult.links.length > 0) {
+ links.push(...rustResult.links);
+ stats.push({
+ ecosystem: 'Rust',
+ linkCount: rustResult.links.length,
+ projectCount: rustResult.discoveredCrates.size,
+ });
+ }
+
+ const nodeResult = await extractNodeWorkspaceLinks(repos, repoPaths, dbExecutors);
+ if (nodeResult.links.length > 0) {
+ links.push(...nodeResult.links);
+ stats.push({
+ ecosystem: 'Node',
+ linkCount: nodeResult.links.length,
+ projectCount: nodeResult.discoveredPackages.size,
+ });
+ }
+
+ const pyResult = await extractPythonWorkspaceLinks(repos, repoPaths, dbExecutors);
+ if (pyResult.links.length > 0) {
+ links.push(...pyResult.links);
+ stats.push({
+ ecosystem: 'Python',
+ linkCount: pyResult.links.length,
+ projectCount: pyResult.discoveredPackages.size,
+ });
+ }
+
+ const goResult = await extractGoWorkspaceLinks(repos, repoPaths, dbExecutors);
+ if (goResult.links.length > 0) {
+ links.push(...goResult.links);
+ stats.push({
+ ecosystem: 'Go',
+ linkCount: goResult.links.length,
+ projectCount: goResult.discoveredModules.size,
+ });
+ }
+
+ const javaResult = await extractJavaWorkspaceLinks(repos, repoPaths, dbExecutors);
+ if (javaResult.links.length > 0) {
+ links.push(...javaResult.links);
+ stats.push({
+ ecosystem: 'Java',
+ linkCount: javaResult.links.length,
+ projectCount: javaResult.discoveredProjects.size,
+ });
+ }
+
+ const elixirResult = await extractElixirWorkspaceLinks(repos, repoPaths, dbExecutors);
+ if (elixirResult.links.length > 0) {
+ links.push(...elixirResult.links);
+ stats.push({
+ ecosystem: 'Elixir',
+ linkCount: elixirResult.links.length,
+ projectCount: elixirResult.discoveredApps.size,
+ });
+ }
+
+ return { links, stats };
+}
diff --git a/gitnexus/src/core/group/sync.ts b/gitnexus/src/core/group/sync.ts
index a9ecb51f4..09b289033 100644
--- a/gitnexus/src/core/group/sync.ts
+++ b/gitnexus/src/core/group/sync.ts
@@ -8,7 +8,7 @@ import { HttpRouteExtractor } from './extractors/http-route-extractor.js';
import { GrpcExtractor } from './extractors/grpc-extractor.js';
import { TopicExtractor } from './extractors/topic-extractor.js';
import { ManifestExtractor } from './extractors/manifest-extractor.js';
-import { extractRustWorkspaceLinks } from './extractors/rust-workspace-extractor.js';
+import { discoverWorkspaceLinks } from './extractors/workspace-extractor.js';
import { runExactMatch } from './matching.js';
import { detectServiceBoundaries, assignService } from './service-boundary-detector.js';
import type { CypherExecutor } from './contract-extractor.js';
@@ -193,13 +193,15 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
if (e) repoPaths.set(groupPath, e.path);
}
- const wsResult = await extractRustWorkspaceLinks(config.repos, repoPaths, dbExecutors);
+ const wsResult = await discoverWorkspaceLinks(config.repos, repoPaths, dbExecutors);
if (wsResult.links.length > 0) {
allLinks = [...allLinks, ...wsResult.links];
if (opts?.verbose) {
- console.log(
- ` workspace-deps: discovered ${wsResult.links.length} cross-crate links from ${wsResult.discoveredCrates.size} Rust crates`,
- );
+ for (const s of wsResult.stats) {
+ console.log(
+ ` workspace-deps: discovered ${s.linkCount} cross-${s.ecosystem.toLowerCase()} links from ${s.projectCount} ${s.ecosystem} projects`,
+ );
+ }
}
}
}
diff --git a/gitnexus/src/core/ingestion/languages/go.ts b/gitnexus/src/core/ingestion/languages/go.ts
index 64b9e359a..5c9aef039 100644
--- a/gitnexus/src/core/ingestion/languages/go.ts
+++ b/gitnexus/src/core/ingestion/languages/go.ts
@@ -29,6 +29,15 @@ import { createCallExtractor } from '../call-extractors/generic.js';
import { goCallConfig } from '../call-extractors/configs/go.js';
import { createHeritageExtractor } from '../heritage-extractors/generic.js';
import { goHeritageConfig } from '../heritage-extractors/configs/go.js';
+import {
+ emitGoScopeCaptures,
+ goArityCompatibility,
+ goBindingScopeFor,
+ goImportOwningScope,
+ goReceiverBinding,
+ interpretGoImport,
+ interpretGoTypeBinding,
+} from './go/index.js';
export const goProvider = defineLanguage({
id: SupportedLanguages.Go,
@@ -83,4 +92,15 @@ export const goProvider = defineLanguage({
variableExtractor: createVariableExtractor(goVariableConfig),
classExtractor: createClassExtractor(goClassConfig),
heritageExtractor: createHeritageExtractor(goHeritageConfig),
+
+ // ── RFC #909 Ring 3: scope-based resolution hooks ──────────
+ emitScopeCaptures: emitGoScopeCaptures,
+ interpretImport: interpretGoImport,
+ interpretTypeBinding: interpretGoTypeBinding,
+ bindingScopeFor: goBindingScopeFor,
+ importOwningScope: goImportOwningScope,
+ receiverBinding: goReceiverBinding,
+ arityCompatibility: goArityCompatibility,
+ // resolveImportTarget lives on ScopeResolver (4-param signature),
+ // not on LanguageProvider (2-param signature). See go/scope-resolver.ts.
});
diff --git a/gitnexus/src/core/ingestion/languages/go/arity-metadata.ts b/gitnexus/src/core/ingestion/languages/go/arity-metadata.ts
new file mode 100644
index 000000000..958d0e70e
--- /dev/null
+++ b/gitnexus/src/core/ingestion/languages/go/arity-metadata.ts
@@ -0,0 +1,46 @@
+import type { SyntaxNode } from '../../utils/ast-helpers.js';
+
+export interface GoArityMetadata {
+ readonly parameterCount?: number;
+ readonly requiredParameterCount?: number;
+ readonly parameterTypes?: readonly string[];
+}
+
+export function computeGoDeclarationArity(node: SyntaxNode): GoArityMetadata {
+ const params = node.childForFieldName('parameters');
+ if (params === null) return {};
+
+ let count = 0;
+ let required = 0;
+ const types: string[] = [];
+
+ for (let i = 0; i < params.namedChildCount; i++) {
+ const param = params.namedChild(i);
+ if (param === null) continue;
+ if (param.type === 'parameter_declaration') {
+ const typeNode = param.childForFieldName('type');
+ const typeName = typeNode === null ? '' : typeNode.text;
+ const names = param.namedChildren.filter((c) => c.type === 'identifier');
+ const n = Math.max(1, names.length);
+ for (let j = 0; j < n; j++) {
+ count++;
+ required++;
+ types.push(typeName);
+ }
+ }
+ if (param.type === 'variadic_parameter_declaration') {
+ const typeNode = param.childForFieldName('type');
+ const typeName = typeNode === null ? '...' : `...${typeNode.text}`;
+ count++;
+ types.push(typeName);
+ }
+ }
+
+ return { parameterCount: count, requiredParameterCount: required, parameterTypes: types };
+}
+
+export function computeGoCallArity(callNode: SyntaxNode): number {
+ const args = callNode.childForFieldName('arguments');
+ if (args === null) return 0;
+ return args.namedChildCount;
+}
diff --git a/gitnexus/src/core/ingestion/languages/go/arity.ts b/gitnexus/src/core/ingestion/languages/go/arity.ts
new file mode 100644
index 000000000..6fc76661e
--- /dev/null
+++ b/gitnexus/src/core/ingestion/languages/go/arity.ts
@@ -0,0 +1,16 @@
+import type { Callsite, SymbolDefinition } from 'gitnexus-shared';
+
+export function goArityCompatibility(
+ def: SymbolDefinition,
+ callsite: Callsite,
+): 'compatible' | 'unknown' | 'incompatible' {
+ const max = def.parameterCount;
+ const min = def.requiredParameterCount;
+ if (max === undefined && min === undefined) return 'unknown';
+ if (!Number.isFinite(callsite.arity) || callsite.arity < 0) return 'unknown';
+
+ const variadic = def.parameterTypes?.some((t) => t.startsWith('...')) ?? false;
+ if (min !== undefined && callsite.arity < min) return 'incompatible';
+ if (max !== undefined && callsite.arity > max && !variadic) return 'incompatible';
+ return 'compatible';
+}
diff --git a/gitnexus/src/core/ingestion/languages/go/cache-stats.ts b/gitnexus/src/core/ingestion/languages/go/cache-stats.ts
new file mode 100644
index 000000000..cee1ec9aa
--- /dev/null
+++ b/gitnexus/src/core/ingestion/languages/go/cache-stats.ts
@@ -0,0 +1,18 @@
+let hits = 0;
+let misses = 0;
+
+export function recordGoCacheHit(): void {
+ hits++;
+}
+export function recordGoCacheMiss(): void {
+ misses++;
+}
+
+export function getGoCaptureCacheStats(): { readonly hits: number; readonly misses: number } {
+ return { hits, misses };
+}
+
+export function resetGoCaptureCacheStats(): void {
+ hits = 0;
+ misses = 0;
+}
diff --git a/gitnexus/src/core/ingestion/languages/go/captures.ts b/gitnexus/src/core/ingestion/languages/go/captures.ts
new file mode 100644
index 000000000..93fcbc51a
--- /dev/null
+++ b/gitnexus/src/core/ingestion/languages/go/captures.ts
@@ -0,0 +1,168 @@
+import type { Capture, CaptureMatch } from 'gitnexus-shared';
+import {
+ findNodeAtRange,
+ nodeToCapture,
+ syntheticCapture,
+ type SyntaxNode,
+} from '../../utils/ast-helpers.js';
+import { getGoParser, getGoScopeQuery } from './query.js';
+import { recordGoCacheHit, recordGoCacheMiss } from './cache-stats.js';
+import { computeGoCallArity, computeGoDeclarationArity } from './arity-metadata.js';
+import { splitGoImportStatement } from './import-decomposer.js';
+import { synthesizeGoReceiverBinding } from './receiver-binding.js';
+import { synthesizeGoTypeBindings } from './type-binding.js';
+import { getTreeSitterBufferSize } from '../../constants.js';
+
+export function emitGoScopeCaptures(
+ sourceText: string,
+ _filePath: string,
+ cachedTree?: unknown,
+): readonly CaptureMatch[] {
+ let tree = cachedTree as ReturnType['parse']> | undefined;
+ if (tree === undefined) {
+ tree = getGoParser().parse(sourceText, undefined, {
+ bufferSize: getTreeSitterBufferSize(sourceText),
+ });
+ recordGoCacheMiss();
+ } else {
+ recordGoCacheHit();
+ }
+
+ const rawMatches = getGoScopeQuery().matches(tree.rootNode);
+ const out: CaptureMatch[] = [];
+
+ for (const m of rawMatches) {
+ const grouped: Record = {};
+ for (const c of m.captures) {
+ const tag = '@' + c.name;
+ if (tag.startsWith('@_')) continue; // skip anonymous captures
+ grouped[tag] = nodeToCapture(tag, c.node);
+ }
+ if (Object.keys(grouped).length === 0) continue;
+
+ if (grouped['@import.statement'] !== undefined) {
+ const anchor = grouped['@import.statement']!;
+ const importNode =
+ findNodeAtRange(tree.rootNode, anchor.range, 'import_declaration') ??
+ findNodeAtRange(tree.rootNode, anchor.range, 'import_spec');
+ if (importNode !== null) {
+ out.push(...splitGoImportStatement(importNode));
+ continue;
+ }
+ }
+
+ if (grouped['@scope.function'] !== undefined) {
+ const scopeCap = grouped['@scope.function']!;
+ const fnNode =
+ findNodeAtRange(tree.rootNode, scopeCap.range, 'function_declaration') ??
+ findNodeAtRange(tree.rootNode, scopeCap.range, 'method_declaration');
+ if (fnNode !== null) {
+ const receiver = synthesizeGoReceiverBinding(fnNode);
+ if (receiver !== null) out.push(receiver);
+ }
+ }
+
+ if (isRawMultiAssignTypeBinding(tree.rootNode, grouped)) continue;
+
+ const declAnchor = grouped['@declaration.function'] ?? grouped['@declaration.method'];
+ if (declAnchor !== undefined) {
+ const fnNode =
+ findNodeAtRange(tree.rootNode, declAnchor.range, 'function_declaration') ??
+ findNodeAtRange(tree.rootNode, declAnchor.range, 'method_declaration');
+ if (fnNode !== null) {
+ const arity = computeGoDeclarationArity(fnNode);
+ if (arity.parameterCount !== undefined) {
+ grouped['@declaration.parameter-count'] = syntheticCapture(
+ '@declaration.parameter-count',
+ fnNode,
+ String(arity.parameterCount),
+ );
+ }
+ if (arity.requiredParameterCount !== undefined) {
+ grouped['@declaration.required-parameter-count'] = syntheticCapture(
+ '@declaration.required-parameter-count',
+ fnNode,
+ String(arity.requiredParameterCount),
+ );
+ }
+ if (arity.parameterTypes !== undefined) {
+ grouped['@declaration.parameter-types'] = syntheticCapture(
+ '@declaration.parameter-types',
+ fnNode,
+ JSON.stringify(arity.parameterTypes),
+ );
+ }
+ }
+ out.push(grouped);
+ continue;
+ }
+
+ const callAnchor =
+ grouped['@reference.call.free'] ??
+ grouped['@reference.call.member'] ??
+ grouped['@reference.call.constructor'];
+ if (callAnchor !== undefined && grouped['@reference.arity'] === undefined) {
+ const callNode =
+ findNodeAtRange(tree.rootNode, callAnchor.range, 'call_expression') ??
+ findNodeAtRange(tree.rootNode, callAnchor.range, 'composite_literal');
+ if (callNode !== null) {
+ grouped['@reference.arity'] = syntheticCapture(
+ '@reference.arity',
+ callNode,
+ String(computeGoCallArity(callNode)),
+ );
+ }
+ }
+
+ out.push(grouped);
+ }
+
+ // Layer on type-binding synthesis (new/make/qualified composite literal)
+ const synthesized = synthesizeGoTypeBindings(tree.rootNode);
+ out.push(...synthesized);
+
+ // Synthesize typeBindings for struct fields so compound receiver
+ // resolution (`user.Address.Save()`) can walk field types.
+ for (const match of out) {
+ if (match['@declaration.field'] === undefined) continue;
+ const nameCap = match['@declaration.name'];
+ const typeCap = match['@declaration.field-type'];
+ if (nameCap === undefined || typeCap === undefined) continue;
+ // Create a synthetic @type-binding.field match using the field
+ // name and its declared type from the @declaration.field-type capture.
+ // This lands in the Class scope's typeBindings (via pass4 positioning).
+ out.push({
+ '@type-binding.field': typeCap,
+ '@type-binding.name': nameCap,
+ '@type-binding.type': {
+ name: '@type-binding.type',
+ text: typeCap.text,
+ range: { ...typeCap.range },
+ },
+ });
+ }
+
+ return out;
+}
+
+function isRawMultiAssignTypeBinding(
+ rootNode: SyntaxNode,
+ grouped: Record,
+): boolean {
+ const anchor =
+ grouped['@type-binding.constructor'] ??
+ grouped['@type-binding.call-return'] ??
+ grouped['@type-binding.assertion'];
+ if (anchor === undefined) return false;
+
+ const node = findNodeAtRange(rootNode, anchor.range, 'short_var_declaration');
+ if (node === null) return false;
+ const lhs = node.childForFieldName('left');
+ const rhs = node.childForFieldName('right');
+ if (lhs === null) return false;
+ if (rhs === null) return false;
+ return (
+ lhs.namedChildren.filter((c) => c.type === 'identifier').length >= 2 &&
+ rhs.namedChildren.length >= 2
+ );
+}
diff --git a/gitnexus/src/core/ingestion/languages/go/expand-wildcards.ts b/gitnexus/src/core/ingestion/languages/go/expand-wildcards.ts
new file mode 100644
index 000000000..767b43212
--- /dev/null
+++ b/gitnexus/src/core/ingestion/languages/go/expand-wildcards.ts
@@ -0,0 +1,99 @@
+import type { BindingRef, ParsedFile, ScopeId, SymbolDefinition } from 'gitnexus-shared';
+import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
+
+/**
+ * Expand Go dot imports (`import . "pkg"`) into binding augmentations.
+ *
+ * Go dot imports are treated as wildcard imports in the scope model.
+ * The shared `expandsWildcardTo` hook defaults to returning `[]` for Go
+ * because it can't easily access the target module's exported defs
+ * (it only receives a `ScopeId`). Instead we post-process wildcard
+ * import edges and augment bindings with the target file's exported
+ * (uppercase) defs — the same augmentation channel used by
+ * `populateGoPackageSiblings` for same-package cross-file visibility.
+ */
+export function expandGoDotImports(
+ parsedFiles: readonly ParsedFile[],
+ indexes: ScopeResolutionIndexes,
+): void {
+ const augmentations = indexes.bindingAugmentations as Map>;
+
+ for (const parsed of parsedFiles) {
+ const moduleEdges = indexes.imports.get(parsed.moduleScope);
+ if (moduleEdges === undefined) continue;
+
+ const wildcardTargets: string[] = [];
+ for (const edge of moduleEdges) {
+ // Go dot imports start as `kind: 'wildcard'`; finalize materializes
+ // them as `wildcard-expanded` import edges.
+ if (edge.kind !== 'wildcard-expanded' && edge.kind !== 'dynamic-resolved') {
+ continue;
+ }
+ if (edge.targetFile === null) continue;
+ if (!wildcardTargets.includes(edge.targetFile)) wildcardTargets.push(edge.targetFile);
+ }
+ if (wildcardTargets.length === 0) continue;
+
+ for (const targetFile of wildcardTargets) {
+ const targetModule = indexes.moduleScopes.byFilePath.get(targetFile);
+ if (targetModule === undefined) continue;
+
+ // Walk target module's local bindings — these are the exported symbols.
+ const targetBindings = indexes.bindings.get(targetModule);
+ if (targetBindings === undefined) continue;
+
+ for (const [name, refs] of targetBindings) {
+ if (name.length === 0) continue;
+ // V1: ASCII-only export check; Unicode uppercase identifiers (e.g. Ñame)
+ // are not recognized as exported. Conforms to Go community convention.
+ const first = name[0]!;
+ if (first < 'A' || first > 'Z') continue;
+
+ // Check if the importer already has this name.
+ const importerBindings = indexes.bindings.get(parsed.moduleScope);
+ if (importerBindings?.has(name)) continue;
+
+ let augBucket = augmentations.get(parsed.moduleScope);
+ if (augBucket === undefined) {
+ augBucket = new Map();
+ augmentations.set(parsed.moduleScope, augBucket);
+ }
+
+ let entries = augBucket.get(name);
+ if (entries === undefined) {
+ entries = [];
+ augBucket.set(name, entries);
+ }
+
+ for (const ref of refs) {
+ if (ref.origin !== 'local') continue;
+ if (entries.some((e) => e.def.nodeId === ref.def.nodeId)) continue;
+ entries.push({ def: ref.def, origin: 'wildcard' });
+ }
+ }
+ }
+ }
+}
+
+export function expandGoWildcardNames(
+ targetModuleScope: ScopeId,
+ parsedFiles: readonly ParsedFile[],
+): readonly string[] {
+ const target = parsedFiles.find((parsed) => parsed.moduleScope === targetModuleScope);
+ if (target === undefined) return [];
+
+ const names: string[] = [];
+ for (const def of target.localDefs) {
+ const name = simpleName(def);
+ if (name === '') continue;
+ // V1: ASCII-only export check; see expandGoDotImports for full note.
+ const first = name[0]!;
+ if (first < 'A' || first > 'Z') continue;
+ if (!names.includes(name)) names.push(name);
+ }
+ return names;
+}
+
+function simpleName(def: SymbolDefinition): string {
+ return def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? '';
+}
diff --git a/gitnexus/src/core/ingestion/languages/go/import-decomposer.ts b/gitnexus/src/core/ingestion/languages/go/import-decomposer.ts
new file mode 100644
index 000000000..7a20ca791
--- /dev/null
+++ b/gitnexus/src/core/ingestion/languages/go/import-decomposer.ts
@@ -0,0 +1,51 @@
+import type { CaptureMatch } from 'gitnexus-shared';
+import { syntheticCapture } from '../../utils/ast-helpers.js';
+import type { SyntaxNode } from '../../utils/ast-helpers.js';
+
+export function splitGoImportStatement(node: SyntaxNode): CaptureMatch[] {
+ if (node.type === 'import_declaration') {
+ const out: CaptureMatch[] = [];
+ for (let i = 0; i < node.namedChildCount; i++) {
+ const child = node.namedChild(i);
+ if (child?.type === 'import_spec') out.push(...splitGoImportStatement(child));
+ if (child?.type === 'import_spec_list') {
+ for (let j = 0; j < child.namedChildCount; j++) {
+ const spec = child.namedChild(j);
+ if (spec?.type === 'import_spec') out.push(...splitGoImportStatement(spec));
+ }
+ }
+ }
+ return out;
+ }
+
+ if (node.type !== 'import_spec') return [];
+ const pathNode = node.childForFieldName('path');
+ if (pathNode === null) return [];
+
+ const rawPath = pathNode.text.replace(/^"|"$/g, '').replace(/^`|`$/g, '');
+ const nameNode = node.childForFieldName('name');
+ const alias = nameNode?.text;
+ const leaf = rawPath.split('/').filter(Boolean).pop() ?? rawPath;
+ const kind =
+ alias === '.' ? 'dot' : alias === '_' ? 'blank' : alias === undefined ? 'namespace' : 'alias';
+
+ // Blank imports (import _ "pkg") are dropped in V1 — they represent
+ // side-effect registrations (e.g. database drivers), but emitting
+ // side-effect edges is deferred. See test: go-imports.test.ts.
+ if (kind === 'blank') return [];
+
+ const aliased = alias !== undefined && alias !== '.' && alias !== '_';
+ return [
+ {
+ '@import.statement': syntheticCapture('@import.statement', node, node.text),
+ '@import.kind': syntheticCapture('@import.kind', node, kind),
+ '@import.source': syntheticCapture('@import.source', pathNode, rawPath),
+ '@import.name': syntheticCapture(
+ '@import.name',
+ nameNode ?? pathNode,
+ aliased ? alias! : leaf,
+ ),
+ ...(aliased ? { '@import.alias': syntheticCapture('@import.alias', nameNode!, alias!) } : {}),
+ },
+ ];
+}
diff --git a/gitnexus/src/core/ingestion/languages/go/import-target.ts b/gitnexus/src/core/ingestion/languages/go/import-target.ts
new file mode 100644
index 000000000..28e8113fd
--- /dev/null
+++ b/gitnexus/src/core/ingestion/languages/go/import-target.ts
@@ -0,0 +1,83 @@
+import type { GoModuleConfig } from '../../language-config.js';
+
+/**
+ * Resolve a Go import path to ALL .go files in the matching package directory.
+ *
+ * Go packages are directory-scoped: one import statement brings in every
+ * (non-test) .go file in the package directory. Return all matching files so
+ * the shared finalize pass creates one ImportEdge per file — enabling both
+ * IMPORTS edge fanout AND binding materialization for every exported symbol in
+ * the package.
+ *
+ * Strategy (first match wins):
+ * 1. go.mod-based: strip module prefix, match package directory
+ * 2. Non-go.mod / GOPATH: progressively shorter directory suffixes
+ */
+export function resolveGoImportTarget(
+ targetRaw: string,
+ _fromFile: string,
+ allFilePaths: ReadonlySet,
+ resolutionConfig?: unknown,
+): string | readonly string[] | null {
+ if (!targetRaw) return null;
+
+ const goModule = resolutionConfig as GoModuleConfig | undefined;
+
+ // 1) go.mod-based: strip module prefix, match directory
+ if (
+ goModule != null &&
+ (targetRaw === goModule.modulePath || targetRaw.startsWith(`${goModule.modulePath}/`))
+ ) {
+ const relativePkg =
+ targetRaw === goModule.modulePath ? '' : targetRaw.slice(goModule.modulePath.length + 1); // e.g. "internal/models"
+ const files =
+ relativePkg === ''
+ ? findRootPackageFiles(allFilePaths)
+ : findAllFilesInPkgDir(allFilePaths, relativePkg);
+ if (files.length > 0) return files;
+ }
+
+ // 2) Non-go.mod / GOPATH: progressively shorter directory suffixes.
+ // "github.com/xxx/yyy/pkg" → try "github.com/xxx/yyy/pkg/" → "xxx/yyy/pkg/" → "yyy/pkg/"
+ // Stop at ≥2 segments to avoid matching a single-segment suffix (e.g.
+ // "pkg", "util", "internal") to a local directory with the same name.
+ const parts = targetRaw.split('/').filter(Boolean);
+ for (let i = 0; i < parts.length - 1; i++) {
+ const files = findAllFilesInPkgDir(allFilePaths, parts.slice(i).join('/'));
+ if (files.length > 0) return files;
+ }
+
+ return null;
+}
+
+function findRootPackageFiles(allFilePaths: ReadonlySet): string[] {
+ const result: string[] = [];
+ for (const raw of allFilePaths) {
+ const normalized = raw.replace(/\\/g, '/');
+ if (normalized.includes('/')) continue;
+ if (!normalized.endsWith('.go') || normalized.endsWith('_test.go')) continue;
+ result.push(raw);
+ }
+ return result.sort();
+}
+
+function findAllFilesInPkgDir(allFilePaths: ReadonlySet, pkgPath: string): string[] {
+ const pkgDir = '/' + pkgPath + '/';
+ const result: string[] = [];
+ for (const raw of allFilePaths) {
+ const normalized = '/' + raw.replace(/\\/g, '/');
+ if (!normalized.includes(pkgDir)) continue;
+ if (!normalized.endsWith('.go') || normalized.endsWith('_test.go')) continue;
+ // Ensure file is directly in the package directory (not a subdirectory)
+ const afterPkg = normalized.substring(normalized.indexOf(pkgDir) + pkgDir.length);
+ if (!afterPkg.includes('/')) result.push(raw);
+ }
+ return result;
+}
+
+/** Preserved for backward compat. */
+export interface GoResolveContext {
+ readonly fromFile: string;
+ readonly allFilePaths: ReadonlySet;
+ readonly goModule?: GoModuleConfig;
+}
diff --git a/gitnexus/src/core/ingestion/languages/go/index.ts b/gitnexus/src/core/ingestion/languages/go/index.ts
new file mode 100644
index 000000000..a71cba2fc
--- /dev/null
+++ b/gitnexus/src/core/ingestion/languages/go/index.ts
@@ -0,0 +1,17 @@
+/**
+ * Go scope-resolution hooks (RFC #909 Ring 3).
+ */
+export { emitGoScopeCaptures } from './captures.js';
+export { getGoCaptureCacheStats, resetGoCaptureCacheStats } from './cache-stats.js';
+export { interpretGoImport, interpretGoTypeBinding, normalizeGoTypeName } from './interpret.js';
+export { splitGoImportStatement } from './import-decomposer.js';
+export { synthesizeGoReceiverBinding } from './receiver-binding.js';
+export { synthesizeGoTypeBindings } from './type-binding.js';
+export { goArityCompatibility } from './arity.js';
+export { goMergeBindings } from './merge-bindings.js';
+export { goBindingScopeFor, goImportOwningScope, goReceiverBinding } from './simple-hooks.js';
+export { resolveGoImportTarget, type GoResolveContext } from './import-target.js';
+export { populateGoPackageSiblings } from './package-siblings.js';
+export { populateGoRangeBindings } from './range-binding.js';
+export { detectGoInterfaceImplementations } from './interface-impls.js';
+export { mirrorGoNamespaceTypeBindings } from './namespace-mirror.js';
diff --git a/gitnexus/src/core/ingestion/languages/go/interface-impls.ts b/gitnexus/src/core/ingestion/languages/go/interface-impls.ts
new file mode 100644
index 000000000..bf21117a1
--- /dev/null
+++ b/gitnexus/src/core/ingestion/languages/go/interface-impls.ts
@@ -0,0 +1,87 @@
+import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared';
+import type { SemanticModel } from '../../model/semantic-model.js';
+import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
+
+export function detectGoInterfaceImplementations(
+ parsedFiles: readonly ParsedFile[],
+ _indexes: ScopeResolutionIndexes,
+ _model: SemanticModel,
+): Map {
+ // 1. Collect interface defs → method names (from scope.ownedDefs)
+ const interfaceMethods = new Map>();
+ const interfaceDefsById = new Map();
+
+ // 2. Collect struct defs → method names
+ const structMethods = new Map>();
+
+ for (const parsed of parsedFiles) {
+ // Collect interface defs and their owned methods
+ for (const scope of parsed.scopes) {
+ if (scope.kind !== 'Class') continue;
+
+ // Find the type def for this scope
+ const typeDef = scope.ownedDefs.find((d) => d.type === 'Interface' || d.type === 'Struct');
+ if (typeDef === undefined) continue;
+
+ if (typeDef.type === 'Interface') {
+ interfaceDefsById.set(typeDef.nodeId, typeDef);
+ const methodNames = new Set();
+ // Methods are in child scopes (Function kind) or ownedDefs
+ for (const childScope of parsed.scopes) {
+ if (childScope.parent === scope.id && childScope.kind === 'Function') {
+ for (const def of childScope.ownedDefs) {
+ if (def.type === 'Method' || def.type === 'Function') {
+ methodNames.add(def.qualifiedName?.split('.').pop() ?? '');
+ }
+ }
+ }
+ }
+ // Also check if methods have ownerId pointing to this interface
+ for (const def of parsed.localDefs) {
+ if (
+ (def as { ownerId?: string }).ownerId === typeDef.nodeId &&
+ (def.type === 'Method' || def.type === 'Function')
+ ) {
+ methodNames.add(def.qualifiedName?.split('.').pop() ?? '');
+ }
+ }
+ interfaceMethods.set(typeDef.nodeId, methodNames);
+ }
+
+ if (typeDef.type === 'Struct') {
+ const methodNames = new Set();
+ for (const def of parsed.localDefs) {
+ if (
+ (def as { ownerId?: string }).ownerId === typeDef.nodeId &&
+ (def.type === 'Method' || def.type === 'Function')
+ ) {
+ methodNames.add(def.qualifiedName?.split('.').pop() ?? '');
+ }
+ }
+ structMethods.set(typeDef.nodeId, methodNames);
+ }
+ }
+ }
+
+ // 3. For each interface, find structs whose method set is a superset
+ const impls = new Map();
+ for (const [ifaceId, ifaceMethods] of interfaceMethods) {
+ if (ifaceMethods.size === 0) continue;
+ const implementors: string[] = [];
+ for (const [structId, methods] of structMethods) {
+ if (isSuperset(methods, ifaceMethods)) {
+ implementors.push(structId);
+ }
+ }
+ if (implementors.length > 0) impls.set(ifaceId, implementors);
+ }
+
+ return impls;
+}
+
+function isSuperset(superset: Set, subset: Set): boolean {
+ for (const item of subset) {
+ if (!superset.has(item)) return false;
+ }
+ return true;
+}
diff --git a/gitnexus/src/core/ingestion/languages/go/interpret.ts b/gitnexus/src/core/ingestion/languages/go/interpret.ts
new file mode 100644
index 000000000..c0bd71f48
--- /dev/null
+++ b/gitnexus/src/core/ingestion/languages/go/interpret.ts
@@ -0,0 +1,124 @@
+import type { CaptureMatch, ParsedImport, ParsedTypeBinding, TypeRef } from 'gitnexus-shared';
+
+export function interpretGoImport(captures: CaptureMatch): ParsedImport | null {
+ const kind = captures['@import.kind']?.text;
+ const source = captures['@import.source']?.text;
+ const name = captures['@import.name']?.text;
+ const alias = captures['@import.alias']?.text;
+ if (kind === undefined || source === undefined) return null;
+
+ if (kind === 'dot') return { kind: 'wildcard', targetRaw: source };
+ if (kind === 'alias') {
+ if (alias === undefined || name === undefined) return null;
+ return { kind: 'namespace', localName: alias, importedName: name, targetRaw: source };
+ }
+ if (kind === 'namespace') {
+ if (name === undefined) return null;
+ return { kind: 'namespace', localName: name, importedName: name, targetRaw: source };
+ }
+ return null;
+}
+
+export function interpretGoTypeBinding(captures: CaptureMatch): ParsedTypeBinding | null {
+ const name = captures['@type-binding.name']?.text;
+ const type = captures['@type-binding.type']?.text;
+ if (name === undefined || type === undefined) return null;
+
+ let source: TypeRef['source'] = 'annotation';
+ let normalizedType: string;
+ if (captures['@type-binding.self'] !== undefined) {
+ source = 'self';
+ normalizedType = normalizeGoTypeName(type);
+ } else if (captures['@type-binding.constructor'] !== undefined) {
+ source = 'constructor-inferred';
+ normalizedType = normalizeGoTypeName(type);
+ } else if (captures['@type-binding.call-return'] !== undefined) {
+ source = 'constructor-inferred';
+ normalizedType = normalizeGoTypeName(type);
+ } else if (captures['@type-binding.assertion'] !== undefined) {
+ source = 'annotation';
+ normalizedType = normalizeGoTypeName(type);
+ } else if (captures['@type-binding.new'] !== undefined) {
+ source = 'constructor-inferred';
+ normalizedType = normalizeGoTypeName(type);
+ } else if (captures['@type-binding.field'] !== undefined) {
+ source = 'assignment-inferred';
+ normalizedType = normalizeGoTypeName(type);
+ } else if (captures['@type-binding.range'] !== undefined) {
+ source = 'constructor-inferred';
+ normalizedType = normalizeGoTypeName(type);
+ } else if (captures['@type-binding.index'] !== undefined) {
+ source = 'constructor-inferred';
+ normalizedType = normalizeGoTypeName(type);
+ } else if (captures['@type-binding.multi-assign'] !== undefined) {
+ source = 'constructor-inferred';
+ normalizedType = normalizeGoTypeName(type);
+ } else if (captures['@type-binding.make'] !== undefined) {
+ source = 'constructor-inferred';
+ normalizedType = normalizeGoTypeName(type);
+ } else if (captures['@type-binding.return'] !== undefined) {
+ // Preserve dotted names for cross-package return-type chains.
+ source = 'return-annotation';
+ normalizedType = normalizeGoReturnType(type);
+ } else if (captures['@type-binding.alias'] !== undefined) {
+ source = 'assignment-inferred';
+ normalizedType = normalizeGoTypeName(type);
+ } else if (captures['@type-binding.assignment'] !== undefined) {
+ source = 'assignment-inferred';
+ normalizedType = normalizeGoTypeName(type);
+ } else if (captures['@type-binding.parameter'] !== undefined) {
+ source = 'parameter-annotation';
+ normalizedType = normalizeGoTypeName(type);
+ } else {
+ normalizedType = normalizeGoTypeName(type);
+ }
+
+ return { boundName: name, rawTypeName: normalizedType, source };
+}
+
+export function normalizeGoTypeName(text: string): string {
+ let t = text.trim();
+ while (t.startsWith('*')) t = t.slice(1).trim();
+ if (t.startsWith('[]')) t = t.slice(2).trim();
+ const mapMatch = t.match(/^map\[[^\]]+\]\s*(.+)$/);
+ if (mapMatch) t = mapMatch[1].trim();
+ t = t.replace(/^(?:<-)?chan\s+/, '');
+ if (t.startsWith('func(')) {
+ const retMatch = t.match(/^func\([^)]*\)\s*(.*)$/);
+ if (retMatch) t = retMatch[1].trim();
+ }
+ const dot = t.lastIndexOf('.');
+ if (dot !== -1) t = t.slice(dot + 1);
+ const bracket = t.indexOf('[');
+ if (bracket !== -1) t = t.slice(0, bracket);
+ return t;
+}
+
+/**
+ * Like `normalizeGoTypeName` but preserves dotted package-prefix
+ * (`models.User` stays `models.User`). Used for return-type
+ * annotations so cross-package type chains can resolve through
+ * QualifiedNameIndex (which carries `pkg.Type` entries).
+ */
+export function normalizeGoReturnType(text: string): string {
+ let t = text.trim();
+ // Multi-return syntax: (*T, error) → extract first type. Handles
+ // the common Go pattern where functions return (value, error).
+ if (t.startsWith('(') && t.includes(',')) {
+ const closeIdx = t.indexOf(',');
+ t = t.slice(1, closeIdx).trim();
+ }
+ while (t.startsWith('*')) t = t.slice(1).trim();
+ if (t.startsWith('[]')) t = t.slice(2).trim();
+ const mapMatch = t.match(/^map\[[^\]]+\]\s*(.+)$/);
+ if (mapMatch) t = mapMatch[1].trim();
+ t = t.replace(/^(?:<-)?chan\s+/, '');
+ if (t.startsWith('func(')) {
+ const retMatch = t.match(/^func\([^)]*\)\s*(.*)$/);
+ if (retMatch) t = retMatch[1].trim();
+ }
+ // Preserve dotted qualified names for cross-package resolution.
+ const bracket = t.indexOf('[');
+ if (bracket !== -1) t = t.slice(0, bracket);
+ return t;
+}
diff --git a/gitnexus/src/core/ingestion/languages/go/merge-bindings.ts b/gitnexus/src/core/ingestion/languages/go/merge-bindings.ts
new file mode 100644
index 000000000..71d5031b2
--- /dev/null
+++ b/gitnexus/src/core/ingestion/languages/go/merge-bindings.ts
@@ -0,0 +1,27 @@
+import type { BindingRef } from 'gitnexus-shared';
+
+const TIER: Record = {
+ local: 0,
+ namespace: 1,
+ import: 2,
+ reexport: 3,
+ wildcard: 4,
+};
+
+export function goMergeBindings(
+ existing: readonly BindingRef[],
+ incoming: readonly BindingRef[],
+ _scopeId: string,
+): BindingRef[] {
+ const seen = new Set();
+ return [...existing, ...incoming]
+ .sort(
+ (a, b) =>
+ (TIER[a.origin] ?? 99) - (TIER[b.origin] ?? 99) || a.def.nodeId.localeCompare(b.def.nodeId),
+ )
+ .filter((binding) => {
+ if (seen.has(binding.def.nodeId)) return false;
+ seen.add(binding.def.nodeId);
+ return true;
+ });
+}
diff --git a/gitnexus/src/core/ingestion/languages/go/method-owners.ts b/gitnexus/src/core/ingestion/languages/go/method-owners.ts
new file mode 100644
index 000000000..457f9f60a
--- /dev/null
+++ b/gitnexus/src/core/ingestion/languages/go/method-owners.ts
@@ -0,0 +1,108 @@
+import type { ParsedFile } from 'gitnexus-shared';
+import { isClassLike, populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js';
+
+/**
+ * Populate `ownerId` on Go Method defs by matching receiver types
+ * extracted from `@type-binding.self` captures against struct defs in
+ * the module scope.
+ *
+ * Go method declarations are top-level (`func (r *T) M()`), not nested
+ * inside a struct body. The generic `populateClassOwnedMembers` requires
+ * the method's parent scope to be a `Class` scope, which never matches
+ * Go. This pass bridges the gap by reading the self typeBinding that
+ * `synthesizeGoReceiverBinding` creates, locating the matching struct
+ * def, and stamping `ownerId` onto the Method def.
+ */
+export function populateGoOwners(parsed: ParsedFile): void {
+ // 1. Standard nested-class pass — stamps ownerId on Property/Method defs
+ // inside Class scopes. With Class scopes now created for Go
+ // struct/interface declarations, this handles struct field ownership.
+ populateClassOwnedMembers(parsed);
+
+ populateGoOwnersInPackage([parsed]);
+}
+
+export function populateGoWorkspaceOwners(
+ parsedFiles: readonly ParsedFile[],
+ ctx: { readonly fileContents: ReadonlyMap },
+): void {
+ const filesByPackage = new Map();
+ for (const parsed of parsedFiles) {
+ const pkgName = inferPackageName(ctx.fileContents.get(parsed.filePath) ?? '');
+ if (pkgName === null) continue;
+ const key = `${packageDir(parsed.filePath)}\0${pkgName}`;
+ const bucket = filesByPackage.get(key) ?? [];
+ bucket.push(parsed);
+ filesByPackage.set(key, bucket);
+ }
+
+ for (const bucket of filesByPackage.values()) {
+ populateGoOwnersInPackage(bucket);
+ }
+}
+
+function populateGoOwnersInPackage(parsedFiles: readonly ParsedFile[]): void {
+ // Build struct name → def map from ALL scopes' ownedDefs (struct defs
+ // live in Class scopes now, not Module scope).
+ const structByQualifiedName = new Map(); // qname → nodeId
+ for (const parsed of parsedFiles) {
+ for (const scope of parsed.scopes) {
+ for (const def of scope.ownedDefs) {
+ if (isClassLike(def.type) && def.qualifiedName) {
+ structByQualifiedName.set(def.qualifiedName, def.nodeId);
+ }
+ }
+ }
+ }
+
+ // 2. Go-specific method owner: each Method def lives in a Function
+ // scope whose typeBindings carry the self entry (kept there by
+ // goBindingScopeFor). Match the self rawName against struct defs.
+ if (structByQualifiedName.size > 0) {
+ for (const parsed of parsedFiles) {
+ for (const scope of parsed.scopes) {
+ if (scope.kind !== 'Function') continue;
+ const methodDefs = scope.ownedDefs.filter(
+ (d) => d.type === 'Method' && d.ownerId === undefined,
+ );
+ if (methodDefs.length === 0) continue;
+
+ // Find the self typeBinding in this Function scope.
+ let receiverType: string | undefined;
+ for (const [, tb] of scope.typeBindings) {
+ if (tb.source === 'self') {
+ receiverType = tb.rawName;
+ break;
+ }
+ }
+ if (receiverType === undefined) continue;
+
+ let ownerId = structByQualifiedName.get(receiverType);
+ if (ownerId === undefined) {
+ for (const [qname, nodeId] of structByQualifiedName) {
+ if (qname.endsWith('.' + receiverType)) {
+ ownerId = nodeId;
+ break;
+ }
+ }
+ }
+ if (ownerId !== undefined) {
+ for (const def of methodDefs) {
+ (def as { ownerId?: string }).ownerId = ownerId;
+ }
+ }
+ }
+ }
+ }
+}
+
+function inferPackageName(sourceText: string): string | null {
+ const match = sourceText.match(/^\s*package\s+([A-Za-z_][A-Za-z0-9_]*)/m);
+ return match?.[1] ?? null;
+}
+
+function packageDir(filePath: string): string {
+ const normalized = filePath.replace(/\\/g, '/');
+ const idx = normalized.lastIndexOf('/');
+ return idx === -1 ? '' : normalized.slice(0, idx);
+}
diff --git a/gitnexus/src/core/ingestion/languages/go/namespace-mirror.ts b/gitnexus/src/core/ingestion/languages/go/namespace-mirror.ts
new file mode 100644
index 000000000..854e81f1e
--- /dev/null
+++ b/gitnexus/src/core/ingestion/languages/go/namespace-mirror.ts
@@ -0,0 +1,59 @@
+import type { ParsedFile, TypeRef } from 'gitnexus-shared';
+import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
+import type { WorkspaceResolutionIndex } from '../../scope-resolution/workspace-index.js';
+import { followChainPostFinalize } from '../../scope-resolution/passes/imported-return-types.js';
+
+/**
+ * Mirror exported typeBindings from namespace-import target modules
+ * into the importer's module scope.
+ *
+ * Go uses namespace imports (`import "pkg"`) where the target package's
+ * exported symbols are visible as `pkg.Func`. For cross-package return-type
+ * resolution to work, the importer needs the target package's exported
+ * typeBindings (e.g. `NewUser → User`) mirrored into its own module scope.
+ *
+ * Exported-symbol filter: Go uses uppercase first letter for exported names.
+ */
+export function mirrorGoNamespaceTypeBindings(
+ parsedFiles: readonly ParsedFile[],
+ indexes: ScopeResolutionIndexes,
+ workspaceIndex: WorkspaceResolutionIndex,
+): void {
+ const moduleScopeByFile = workspaceIndex.moduleScopeByFile;
+
+ for (const parsed of parsedFiles) {
+ const importerModule = moduleScopeByFile.get(parsed.filePath);
+ if (importerModule === undefined) continue;
+
+ const moduleEdges = indexes.imports.get(importerModule.id);
+ if (moduleEdges === undefined) continue;
+
+ const nsTargets = new Map();
+ for (const edge of moduleEdges) {
+ if (edge.kind !== 'namespace' || edge.targetFile === null) continue;
+ let targets = nsTargets.get(edge.localName);
+ if (targets === undefined) {
+ targets = [];
+ nsTargets.set(edge.localName, targets);
+ }
+ if (!targets.includes(edge.targetFile)) targets.push(edge.targetFile);
+ }
+
+ for (const targetFiles of nsTargets.values()) {
+ for (const targetFile of targetFiles) {
+ const sourceModule = moduleScopeByFile.get(targetFile);
+ if (sourceModule === undefined) continue;
+
+ for (const [name, ref] of sourceModule.typeBindings) {
+ if (name.length === 0) continue;
+ const first = name[0]!;
+ if (first < 'A' || first > 'Z') continue;
+ if (importerModule.typeBindings.has(name)) continue;
+
+ const terminal = followChainPostFinalize(ref, sourceModule.id, indexes);
+ (importerModule.typeBindings as Map).set(name, terminal);
+ }
+ }
+ }
+ }
+}
diff --git a/gitnexus/src/core/ingestion/languages/go/package-siblings.ts b/gitnexus/src/core/ingestion/languages/go/package-siblings.ts
new file mode 100644
index 000000000..bcb0687dd
--- /dev/null
+++ b/gitnexus/src/core/ingestion/languages/go/package-siblings.ts
@@ -0,0 +1,101 @@
+import type { BindingRef, ParsedFile, ScopeId, SymbolDefinition } from 'gitnexus-shared';
+import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
+
+import { expandGoDotImports } from './expand-wildcards.js';
+
+/**
+ * O(n²×d) where n = files per package, d = defs per file.
+ * Acceptable for V1 since Go packages are typically small (< 20 files).
+ * Future optimization: build a name→def inverted index per package to reduce
+ * to O(n×d).
+ */
+export function populateGoPackageSiblings(
+ parsedFiles: readonly ParsedFile[],
+ indexes: ScopeResolutionIndexes,
+ ctx: { readonly fileContents: ReadonlyMap },
+): void {
+ // 0. Filter out test files — Go _test.go files should not contribute
+ // same-package sibling bindings to non-test files.
+ const nonTestFiles = parsedFiles.filter((f) => !f.filePath.endsWith('_test.go'));
+
+ // 1. Expand dot imports first so subsequent same-package sibling
+ // augmentation can also see dot-imported names.
+ expandGoDotImports(nonTestFiles, indexes);
+
+ // 2. Group files by package directory plus package name. Go package
+ // identity is directory-scoped; repeated `package main` directories
+ // must not see each other's unqualified names.
+ const packageByFile = new Map();
+ for (const parsed of nonTestFiles) {
+ const pkgName = inferPackageName(ctx.fileContents.get(parsed.filePath) ?? '');
+ if (pkgName !== null) {
+ packageByFile.set(parsed.filePath, `${packageDir(parsed.filePath)}\0${pkgName}`);
+ }
+ }
+
+ const filesByPackage = new Map();
+ for (const parsed of nonTestFiles) {
+ const pkgName = packageByFile.get(parsed.filePath);
+ if (pkgName === undefined) continue;
+ const list = filesByPackage.get(pkgName) ?? [];
+ list.push({ filePath: parsed.filePath, defs: [...parsed.localDefs] });
+ filesByPackage.set(pkgName, list);
+ }
+
+ // 2. Use bindingAugmentations channel per I8
+ const augmentations = indexes.bindingAugmentations as Map>;
+
+ for (const [, siblings] of filesByPackage) {
+ for (const target of siblings) {
+ const targetModule = indexes.moduleScopes.byFilePath.get(target.filePath);
+ if (targetModule === undefined) continue;
+
+ for (const receiver of siblings) {
+ if (receiver.filePath === target.filePath) continue; // no self-reference
+ const receiverModule = indexes.moduleScopes.byFilePath.get(receiver.filePath);
+ if (receiverModule === undefined) continue;
+
+ for (const def of target.defs) {
+ // Go: same-package sibling files can see ALL names (both
+ // exported/uppercase and unexported/lowercase). Only cross-
+ // package visibility requires uppercase first letter.
+ const name = def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? '';
+ if (name === '') continue;
+
+ const bucket = getAugmentationBucket(augmentations, receiverModule, name);
+ if (bucket.some((b) => b.def.nodeId === def.nodeId)) continue;
+ bucket.push({ def, origin: 'namespace' });
+ }
+ }
+ }
+ }
+}
+
+function inferPackageName(sourceText: string): string | null {
+ const match = sourceText.match(/^\s*package\s+([A-Za-z_][A-Za-z0-9_]*)/m);
+ return match?.[1] ?? null;
+}
+
+function packageDir(filePath: string): string {
+ const normalized = filePath.replace(/\\/g, '/');
+ const idx = normalized.lastIndexOf('/');
+ return idx === -1 ? '' : normalized.slice(0, idx);
+}
+
+function getAugmentationBucket(
+ augmentations: Map>,
+ scopeId: ScopeId,
+ name: string,
+): BindingRef[] {
+ let scopeBindings = augmentations.get(scopeId);
+ if (scopeBindings === undefined) {
+ scopeBindings = new Map();
+ augmentations.set(scopeId, scopeBindings);
+ }
+ let bucketArr = scopeBindings.get(name);
+ if (bucketArr === undefined) {
+ bucketArr = [];
+ scopeBindings.set(name, bucketArr);
+ }
+ return bucketArr;
+}
diff --git a/gitnexus/src/core/ingestion/languages/go/query.ts b/gitnexus/src/core/ingestion/languages/go/query.ts
new file mode 100644
index 000000000..21aae19ad
--- /dev/null
+++ b/gitnexus/src/core/ingestion/languages/go/query.ts
@@ -0,0 +1,211 @@
+import Parser from 'tree-sitter';
+import Go from 'tree-sitter-go';
+
+const GO_SCOPE_QUERY = `
+;; Scopes
+(source_file) @scope.module
+(type_declaration
+ (type_spec
+ type: [(struct_type) (interface_type)])) @scope.class
+(function_declaration) @scope.function
+(method_declaration) @scope.function
+(func_literal) @scope.function
+(block) @scope.block
+(if_statement) @scope.block
+(for_statement) @scope.block
+(select_statement) @scope.block
+(expression_switch_statement) @scope.block
+(type_switch_statement) @scope.block
+(expression_case) @scope.block
+(default_case) @scope.block
+(type_case) @scope.block
+(communication_case) @scope.block
+
+;; Declarations — struct
+(type_declaration
+ (type_spec name: (type_identifier) @declaration.name
+ type: (struct_type))) @declaration.struct
+
+;; Declarations — interface
+(type_declaration
+ (type_spec name: (type_identifier) @declaration.name
+ type: (interface_type))) @declaration.interface
+
+;; Declarations — function
+(function_declaration
+ name: (identifier) @declaration.name) @declaration.function
+
+;; Declarations — method
+(method_declaration
+ name: (field_identifier) @declaration.name) @declaration.method
+
+;; Declarations — struct fields
+(struct_type
+ (field_declaration_list
+ (field_declaration
+ name: (field_identifier) @declaration.name
+ type: (_) @declaration.field-type))) @declaration.field
+
+;; Declarations — variables
+(var_declaration
+ (var_spec
+ name: (identifier) @declaration.name)) @declaration.variable
+
+(const_declaration
+ (const_spec
+ name: (identifier) @declaration.name)) @declaration.const
+
+(short_var_declaration
+ left: (expression_list (identifier) @declaration.name)) @declaration.variable
+
+;; Imports
+(import_spec) @import.statement
+
+;; Type bindings — parameter annotations
+(function_declaration
+ name: (identifier) @_fn_name
+ parameters: (parameter_list
+ (parameter_declaration
+ name: (identifier) @type-binding.name
+ type: [(type_identifier) (qualified_type) (pointer_type) (slice_type) (map_type)] @type-binding.type))) @type-binding.parameter
+
+(method_declaration
+ name: (field_identifier) @_fn_name
+ parameters: (parameter_list
+ (parameter_declaration
+ name: (identifier) @type-binding.name
+ type: [(type_identifier) (qualified_type) (pointer_type) (slice_type) (map_type)] @type-binding.type))) @type-binding.parameter
+
+;; Type bindings — constructor-inferred (:= T{})
+(short_var_declaration
+ left: (expression_list (identifier) @type-binding.name)
+ right: (expression_list
+ (composite_literal
+ type: [(type_identifier) (qualified_type)] @type-binding.type))) @type-binding.constructor
+
+;; Type bindings — pointer constructor (:= &T{})
+(short_var_declaration
+ left: (expression_list (identifier) @type-binding.name)
+ right: (expression_list
+ (unary_expression
+ "&"
+ operand: (composite_literal
+ type: [(type_identifier) (qualified_type)] @type-binding.type)))) @type-binding.constructor
+
+;; Type bindings — type assertion (:= s.(T))
+(short_var_declaration
+ left: (expression_list (identifier) @type-binding.name)
+ right: (expression_list
+ (type_assertion_expression
+ type: (_) @type-binding.type))) @type-binding.assertion
+
+(var_declaration
+ (var_spec
+ name: (identifier) @type-binding.name
+ value: (expression_list
+ (type_assertion_expression
+ type: (_) @type-binding.type)))) @type-binding.assertion
+
+;; Type bindings — explicit var type
+(var_declaration
+ (var_spec
+ name: (identifier) @type-binding.name
+ type: (_) @type-binding.type)) @type-binding.assignment
+
+;; Type bindings — call-return inference (:= Func(args))
+(short_var_declaration
+ left: (expression_list (identifier) @type-binding.name)
+ right: (expression_list (call_expression
+ function: (identifier) @type-binding.type))) @type-binding.call-return
+
+;; Type bindings — call-return inference qualified (:= pkg.Func(args))
+(short_var_declaration
+ left: (expression_list (identifier) @type-binding.name)
+ right: (expression_list (call_expression
+ function: (selector_expression
+ field: (field_identifier) @type-binding.type)))) @type-binding.call-return
+
+;; Type bindings — return type annotation (func Foo() *Type)
+(function_declaration
+ name: (identifier) @type-binding.name
+ result: (_) @type-binding.type) @type-binding.return
+
+;; Type bindings — method return type (func (r *T) Method() *Type)
+(method_declaration
+ name: (field_identifier) @type-binding.name
+ result: (_) @type-binding.type) @type-binding.return
+
+;; Type bindings — variable alias (y := x)
+(short_var_declaration
+ left: (expression_list (identifier) @type-binding.name)
+ right: (expression_list (identifier) @type-binding.type)) @type-binding.alias
+
+;; Type bindings — variable alias var form (var x = y)
+(var_declaration
+ (var_spec
+ name: (identifier) @type-binding.name
+ value: (expression_list (identifier) @type-binding.type))) @type-binding.alias
+
+;; Type bindings — call-return var form (var x = Func())
+(var_declaration
+ (var_spec
+ name: (identifier) @type-binding.name
+ value: (expression_list (call_expression
+ function: (identifier) @type-binding.type)))) @type-binding.call-return
+
+;; References — free calls
+(call_expression
+ function: (identifier) @reference.name) @reference.call.free
+
+;; References — member calls
+(call_expression
+ function: (selector_expression
+ operand: (_) @reference.receiver
+ field: (field_identifier) @reference.name)) @reference.call.member
+
+;; References — constructor calls (T{})
+(composite_literal
+ type: [(type_identifier) (qualified_type)] @reference.name) @reference.call.constructor
+
+;; References — field reads
+(selector_expression
+ operand: (_) @reference.receiver
+ field: (field_identifier) @reference.name) @reference.read
+
+;; References — field writes (assignment)
+(assignment_statement
+ left: (expression_list
+ (selector_expression
+ operand: (_) @reference.receiver
+ field: (field_identifier) @reference.name))) @reference.write
+
+;; References — field writes (inc: obj.Field++)
+(inc_statement
+ (selector_expression
+ operand: (_) @reference.receiver
+ field: (field_identifier) @reference.name)) @reference.write
+
+;; References — field writes (dec: obj.Field--)
+(dec_statement
+ (selector_expression
+ operand: (_) @reference.receiver
+ field: (field_identifier) @reference.name)) @reference.write
+`;
+
+let _parser: Parser | null = null;
+let _query: Parser.Query | null = null;
+
+export function getGoParser(): Parser {
+ if (_parser === null) {
+ _parser = new Parser();
+ _parser.setLanguage(Go as Parameters[0]);
+ }
+ return _parser;
+}
+
+export function getGoScopeQuery(): Parser.Query {
+ if (_query === null) {
+ _query = new Parser.Query(Go as Parameters[0], GO_SCOPE_QUERY);
+ }
+ return _query;
+}
diff --git a/gitnexus/src/core/ingestion/languages/go/range-binding.ts b/gitnexus/src/core/ingestion/languages/go/range-binding.ts
new file mode 100644
index 000000000..d95676480
--- /dev/null
+++ b/gitnexus/src/core/ingestion/languages/go/range-binding.ts
@@ -0,0 +1,133 @@
+import type { ParsedFile, Scope, TypeRef } from 'gitnexus-shared';
+import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
+import { getGoParser } from './query.js';
+import { getTreeSitterBufferSize } from '../../constants.js';
+
+export function populateGoRangeBindings(
+ parsedFiles: readonly ParsedFile[],
+ _indexes: ScopeResolutionIndexes,
+ ctx: {
+ readonly fileContents: ReadonlyMap;
+ readonly treeCache?: { get(filePath: string): unknown };
+ },
+): void {
+ const parser = getGoParser();
+
+ for (const parsed of parsedFiles) {
+ const sourceText = ctx.fileContents.get(parsed.filePath);
+ if (sourceText === undefined) continue;
+
+ const cachedTree = ctx.treeCache?.get(parsed.filePath);
+ const tree =
+ (cachedTree as ReturnType | undefined) ??
+ parser.parse(sourceText, undefined, {
+ bufferSize: getTreeSitterBufferSize(sourceText),
+ });
+ const moduleScope = parsed.scopes.find((s) => s.kind === 'Module');
+ if (moduleScope === undefined) continue;
+
+ const scopeMap = new Map(parsed.scopes.map((s) => [s.id, s]));
+
+ for (const rangeNode of tree.rootNode.descendantsOfType('for_statement')) {
+ const rangeClause = rangeNode.namedChildren.find((c) => c.type === 'range_clause');
+ if (rangeClause === null) continue;
+
+ const left = rangeClause.namedChildren.find((c) => c.type === 'expression_list');
+ if (left === null) continue;
+
+ const rangeExpr = rangeClause.namedChildren.find(
+ (c, idx) => c.type !== 'expression_list' && idx > rangeClause.namedChildren.indexOf(left),
+ );
+ if (rangeExpr === undefined) continue;
+
+ // Identify the value variable (skip the `_` discard if present)
+ const idents = left.namedChildren.filter((c) => c.type === 'identifier');
+ let valueVar: string | null = null;
+ if (idents.length >= 2) {
+ valueVar = idents[1].text; // for _, v := range ...
+ } else if (idents.length === 1) {
+ valueVar = idents[0].text; // for v := range ...
+ }
+
+ if (valueVar === null || valueVar === '_') continue;
+
+ // Resolve range expression type
+ let elementType: string | null = null;
+
+ if (rangeExpr.type === 'identifier') {
+ // Look up the identifier's type in scope typeBindings (V1: module scope only)
+ const binding = moduleScope.typeBindings.get(rangeExpr.text);
+ if (binding !== null && binding !== undefined) {
+ elementType = extractElementType(binding);
+ }
+ } else if (rangeExpr.type === 'call_expression') {
+ const fnNode = rangeExpr.childForFieldName('function');
+ if (fnNode !== null) {
+ const fnName =
+ fnNode.type === 'selector_expression'
+ ? fnNode.childForFieldName('field')?.text
+ : fnNode.text;
+ if (fnName !== undefined) {
+ const binding = moduleScope.typeBindings.get(fnName);
+ if (binding !== null && binding !== undefined) {
+ elementType = extractElementType(binding);
+ }
+ }
+ }
+ }
+
+ if (elementType !== null && valueVar !== null) {
+ // Inject type binding for the range variable onto the enclosing function scope
+ const functionScope = findEnclosingFunctionScope(rangeNode, scopeMap);
+ const targetScope = functionScope ?? moduleScope;
+ const mutable = targetScope.typeBindings as Map;
+ mutable.set(valueVar, {
+ rawName: elementType,
+ declaredAtScope: targetScope.id,
+ source: 'annotation',
+ });
+ }
+ }
+ }
+}
+
+function extractElementType(binding: TypeRef): string | null {
+ const raw = binding.rawName;
+ const mapMatch = raw.match(/^map\[[^\]]+\]\s*(.+)$/);
+ if (mapMatch) return mapMatch[1].trim();
+ if (raw.startsWith('[]')) return raw.slice(2).trim();
+ const arrMatch = raw.match(/^\[\d+\](.+)$/);
+ if (arrMatch) return arrMatch[1].trim();
+ return raw;
+}
+
+function findEnclosingFunctionScope(
+ node: unknown,
+ scopeMap: ReadonlyMap,
+): Scope | null {
+ const tsNode = node as {
+ readonly parent: unknown;
+ readonly type: string;
+ readonly startPosition: { readonly row: number; readonly column: number };
+ };
+ // Walk up the AST to find the enclosing function or method declaration.
+ let current: typeof tsNode | null = tsNode;
+ while (current !== null) {
+ if (current.type === 'function_declaration' || current.type === 'method_declaration') {
+ // Match by source position: the scope whose range starts at the
+ // same line/column as the tree-sitter node.
+ for (const scope of scopeMap.values()) {
+ if (
+ scope.kind === 'Function' &&
+ scope.range.startLine === current.startPosition.row &&
+ scope.range.startCol === current.startPosition.column
+ ) {
+ return scope;
+ }
+ }
+ break;
+ }
+ current = (current.parent as typeof tsNode) ?? null;
+ }
+ return null;
+}
diff --git a/gitnexus/src/core/ingestion/languages/go/receiver-binding.ts b/gitnexus/src/core/ingestion/languages/go/receiver-binding.ts
new file mode 100644
index 000000000..d5089fa00
--- /dev/null
+++ b/gitnexus/src/core/ingestion/languages/go/receiver-binding.ts
@@ -0,0 +1,21 @@
+import type { CaptureMatch } from 'gitnexus-shared';
+import { syntheticCapture } from '../../utils/ast-helpers.js';
+import type { SyntaxNode } from '../../utils/ast-helpers.js';
+
+export function synthesizeGoReceiverBinding(fnNode: SyntaxNode): CaptureMatch | null {
+ if (fnNode.type !== 'method_declaration') return null;
+ const receiver = fnNode.childForFieldName('receiver');
+ if (receiver === null) return null;
+ const param = receiver.namedChildren.find((c) => c.type === 'parameter_declaration');
+ if (param === undefined) return null;
+ const nameNode = param.childForFieldName('name');
+ const typeNode = param.childForFieldName('type');
+ if (nameNode === null || typeNode === null) return null;
+ const typeName = typeNode.text.replace(/^\*/, '');
+
+ return {
+ '@type-binding.self': syntheticCapture('@type-binding.self', fnNode, nameNode.text),
+ '@type-binding.name': syntheticCapture('@type-binding.name', nameNode, nameNode.text),
+ '@type-binding.type': syntheticCapture('@type-binding.type', typeNode, typeName),
+ };
+}
diff --git a/gitnexus/src/core/ingestion/languages/go/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/go/scope-resolver.ts
new file mode 100644
index 000000000..15d345736
--- /dev/null
+++ b/gitnexus/src/core/ingestion/languages/go/scope-resolver.ts
@@ -0,0 +1,55 @@
+import type { ParsedFile } from 'gitnexus-shared';
+import { SupportedLanguages } from 'gitnexus-shared';
+import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js';
+import { populateGoOwners, populateGoWorkspaceOwners } from './method-owners.js';
+import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js';
+import { loadGoModulePath } from '../../language-config.js';
+import { goProvider } from '../go.js';
+import {
+ goArityCompatibility,
+ goMergeBindings,
+ populateGoPackageSiblings,
+ resolveGoImportTarget,
+ mirrorGoNamespaceTypeBindings,
+} from './index.js';
+import { detectGoInterfaceImplementations } from './interface-impls.js';
+import { populateGoRangeBindings } from './range-binding.js';
+import { expandGoWildcardNames } from './expand-wildcards.js';
+
+export const goScopeResolver: ScopeResolver = {
+ language: SupportedLanguages.Go,
+ languageProvider: goProvider,
+ importEdgeReason: 'go-scope: import',
+
+ loadResolutionConfig: (repoPath: string) => loadGoModulePath(repoPath),
+
+ resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig) =>
+ resolveGoImportTarget(targetRaw, fromFile, allFilePaths, resolutionConfig),
+
+ expandsWildcardTo: (targetModuleScope, parsedFiles) =>
+ expandGoWildcardNames(targetModuleScope, parsedFiles),
+
+ mergeBindings: (existing, incoming, scopeId) => goMergeBindings(existing, incoming, scopeId),
+
+ arityCompatibility: (callsite, def) => goArityCompatibility(def, callsite),
+
+ buildMro: (graph, parsedFiles, nodeLookup) =>
+ buildMro(graph, parsedFiles, nodeLookup, defaultLinearize),
+
+ populateOwners: (parsed: ParsedFile) => populateGoOwners(parsed),
+ populateWorkspaceOwners: (parsedFiles, ctx) => populateGoWorkspaceOwners(parsedFiles, ctx),
+
+ isSuperReceiver: () => false,
+
+ fieldFallbackOnMethodLookup: false,
+ hoistTypeBindingsToModule: true,
+ propagatesReturnTypesAcrossImports: true,
+ allowGlobalFreeCallFallback: true,
+
+ populateNamespaceSiblings: populateGoPackageSiblings,
+ mirrorNamespaceTypeBindings: mirrorGoNamespaceTypeBindings,
+ // Staged/V2: registered but not yet wired in run.ts — method-name-only
+ // matching produces false IMPLEMENTS edges; awaits signature-level comparison.
+ detectInterfaceImplementations: detectGoInterfaceImplementations,
+ populateRangeBindings: populateGoRangeBindings,
+};
diff --git a/gitnexus/src/core/ingestion/languages/go/simple-hooks.ts b/gitnexus/src/core/ingestion/languages/go/simple-hooks.ts
new file mode 100644
index 000000000..e2396f395
--- /dev/null
+++ b/gitnexus/src/core/ingestion/languages/go/simple-hooks.ts
@@ -0,0 +1,38 @@
+import type {
+ CaptureMatch,
+ ParsedImport,
+ Scope,
+ ScopeId,
+ ScopeTree,
+ TypeRef,
+} from 'gitnexus-shared';
+
+export function goBindingScopeFor(
+ decl: CaptureMatch,
+ innermost: Scope,
+ _tree: ScopeTree,
+): ScopeId | null {
+ // Keep self typeBindings in the method's Function scope (prevent
+ // auto-hoist to Module) so populateGoOwners can match Method defs
+ // to their receiver types by inspecting each Function scope.
+ if (decl['@type-binding.self'] !== undefined) {
+ return innermost.id;
+ }
+ return null; // default auto-hoist for other bindings
+}
+
+export function goImportOwningScope(
+ _imp: ParsedImport,
+ _innermost: Scope,
+ _tree: ScopeTree,
+): ScopeId | null {
+ return null;
+}
+
+export function goReceiverBinding(functionScope: Scope): TypeRef | null {
+ if (functionScope.kind !== 'Function') return null;
+ for (const binding of functionScope.typeBindings.values()) {
+ if (binding.source === 'self') return binding;
+ }
+ return null;
+}
diff --git a/gitnexus/src/core/ingestion/languages/go/type-binding.ts b/gitnexus/src/core/ingestion/languages/go/type-binding.ts
new file mode 100644
index 000000000..c0808a990
--- /dev/null
+++ b/gitnexus/src/core/ingestion/languages/go/type-binding.ts
@@ -0,0 +1,285 @@
+import type { CaptureMatch } from 'gitnexus-shared';
+import { syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
+
+export function synthesizeGoTypeBindings(rootNode: SyntaxNode): CaptureMatch[] {
+ const out: CaptureMatch[] = [];
+
+ for (const node of rootNode.descendantsOfType('short_var_declaration')) {
+ const right = node.childForFieldName('right');
+ if (right === null) continue;
+ const lhs = node.childForFieldName('left');
+ if (lhs === null) continue;
+
+ // Multi-assignment: pair LHS identifiers positionally with RHS
+ // expressions. The tree-sitter query produces all LHS/RHS
+ // combinations; emit only the positions whose RHS carries an
+ // inferable type.
+ const lhsIds = lhs.namedChildren.filter((c) => c.type === 'identifier');
+ const rhsExprs = right.namedChildren;
+ if (lhsIds.length >= 2 && rhsExprs.length >= 2) {
+ for (let i = 0; i < Math.min(lhsIds.length, rhsExprs.length); i++) {
+ const lhsId = lhsIds[i]!;
+ const rhsExpr = rhsExprs[i]!;
+ const typeNode = extractTypeNode(rhsExpr);
+ if (typeNode === null) continue;
+ const typeName = extractSimpleTypeNameText(typeNode);
+ out.push({
+ '@type-binding.multi-assign': syntheticCapture(
+ '@type-binding.multi-assign',
+ node,
+ lhsId.text,
+ ),
+ '@type-binding.name': syntheticCapture('@type-binding.name', lhsId, lhsId.text),
+ '@type-binding.type': syntheticCapture(
+ '@type-binding.type',
+ typeNode ?? rhsExpr,
+ typeName,
+ ),
+ });
+ }
+ continue; // synthesized matches replace tree-sitter combinations
+ }
+
+ // Walk the expression_list for call_expression function == "new" or "make"
+ for (let i = 0; i < right.namedChildCount; i++) {
+ const expr = right.namedChild(i);
+ if (expr?.type === 'call_expression') {
+ const fn = expr.childForFieldName('function');
+ const args = expr.childForFieldName('arguments');
+ if (fn?.type === 'identifier' && fn.text === 'new' && args !== null) {
+ const typeArg = args.namedChildren.find((c) =>
+ ['type_identifier', 'qualified_type'].includes(c.type),
+ );
+ if (typeArg !== null) {
+ const typeName = extractSimpleTypeNameText(typeArg);
+ const nameNodes = lhs.namedChildren.filter((c) => c.type === 'identifier');
+ if (nameNodes.length > 0) {
+ out.push({
+ '@type-binding.new': syntheticCapture('@type-binding.new', node, 'new'),
+ '@type-binding.name': syntheticCapture(
+ '@type-binding.name',
+ nameNodes[0],
+ nameNodes[0].text,
+ ),
+ '@type-binding.type': syntheticCapture('@type-binding.type', typeArg, typeName),
+ });
+ }
+ }
+ }
+ if (fn?.type === 'identifier' && fn.text === 'make' && args !== null) {
+ const sliceOrMap = args.namedChildren.find((c) =>
+ // V1: channel_type not handled — make(chan T) produces no typeBinding.
+ ['slice_type', 'map_type'].includes(c.type),
+ );
+ if (sliceOrMap !== null) {
+ let typeName = '';
+ if (sliceOrMap.type === 'slice_type') {
+ const elem = sliceOrMap.namedChildren.find((c) =>
+ ['type_identifier', 'qualified_type'].includes(c.type),
+ );
+ if (elem !== null) typeName = extractSimpleTypeNameText(elem);
+ } else if (sliceOrMap.type === 'map_type') {
+ const typeChildren = sliceOrMap.namedChildren.filter((c) =>
+ ['type_identifier', 'qualified_type'].includes(c.type),
+ );
+ const valueType = typeChildren[1] ?? typeChildren[0];
+ if (valueType !== undefined) typeName = extractSimpleTypeNameText(valueType);
+ }
+ if (typeName !== '') {
+ const nameNodes = lhs.namedChildren.filter((c) => c.type === 'identifier');
+ if (nameNodes.length > 0) {
+ out.push({
+ '@type-binding.make': syntheticCapture('@type-binding.make', node, 'make'),
+ '@type-binding.name': syntheticCapture(
+ '@type-binding.name',
+ nameNodes[0],
+ nameNodes[0].text,
+ ),
+ '@type-binding.type': syntheticCapture(
+ '@type-binding.type',
+ sliceOrMap,
+ typeName,
+ ),
+ });
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Synthesize typeBindings for for-range loop variables and index
+ // expressions (sl[0], m["key"]) so member calls on them resolve.
+ synthesizeElementAccessBindings(rootNode, out);
+
+ return out;
+}
+
+function synthesizeElementAccessBindings(rootNode: SyntaxNode, out: CaptureMatch[]): void {
+ // Build a map of variable → element type from make/new/range.
+ const varElementType = new Map();
+
+ for (const node of rootNode.descendantsOfType('short_var_declaration')) {
+ const right = node.childForFieldName('right');
+ const lhs = node.childForFieldName('left');
+ if (right === null || lhs === null) continue;
+ const lhsIds = lhs.namedChildren.filter((c) => c.type === 'identifier');
+ if (lhsIds.length === 0) continue;
+
+ for (const expr of right.namedChildren) {
+ let typeName: string | undefined;
+ if (expr.type === 'call_expression') {
+ const fn = expr.childForFieldName('function');
+ if (fn?.type === 'identifier' && (fn.text === 'make' || fn.text === 'new')) {
+ const args = expr.childForFieldName('arguments');
+ const typeNode = args?.namedChildren.find((c) =>
+ ['type_identifier', 'qualified_type', 'slice_type', 'map_type'].includes(c.type),
+ );
+ if (typeNode) {
+ if (typeNode.type === 'slice_type') {
+ const elem = typeNode.namedChildren.find((c) =>
+ ['type_identifier', 'qualified_type'].includes(c.type),
+ );
+ if (elem) typeName = extractSimpleTypeNameText(elem);
+ } else if (typeNode.type === 'map_type') {
+ const tc = typeNode.namedChildren.filter((c) =>
+ ['type_identifier', 'qualified_type'].includes(c.type),
+ );
+ typeName = tc[1]
+ ? extractSimpleTypeNameText(tc[1])
+ : tc[0]
+ ? extractSimpleTypeNameText(tc[0])
+ : undefined;
+ } else {
+ typeName = extractSimpleTypeNameText(typeNode);
+ }
+ }
+ }
+ }
+ if (typeName !== undefined && lhsIds.length > 0) {
+ varElementType.set(lhsIds[0]!.text, typeName);
+ }
+ }
+ }
+
+ // Handle for-range: for _, user := range GetUsers() / range slice / range map
+ for (const rangeClause of rootNode.descendantsOfType('range_clause')) {
+ const right = rangeClause.childForFieldName('right');
+ if (right === null || right.namedChildren.length === 0) continue;
+ const left = rangeClause.childForFieldName('left');
+ if (left === null) continue;
+
+ // The right side IS the range expression (call_expression, identifier, etc.)
+ const rangeExpr = right;
+
+ let elemType: string | undefined;
+ // Call expression: range GetUsers()
+ if (rangeExpr.type === 'call_expression') {
+ const fn = rangeExpr.childForFieldName('function');
+ if (fn !== null) {
+ elemType = fn.text;
+ }
+ }
+ // Identifier: range userMap / range users
+ if (rangeExpr.type === 'identifier' || rangeExpr.type === 'selector_expression') {
+ const existing = varElementType.get(rangeExpr.text);
+ if (existing !== undefined) elemType = existing;
+ }
+
+ if (elemType === undefined) continue;
+
+ // Capture the loop variable (skip blank_identifier like _)
+ for (const child of left.namedChildren) {
+ if (child.type === 'identifier') {
+ // Create a typeBinding: loopVar → elemType
+ out.push({
+ '@type-binding.range': syntheticCapture('@type-binding.range', rangeClause, child.text),
+ '@type-binding.name': syntheticCapture('@type-binding.name', child, child.text),
+ '@type-binding.type': syntheticCapture('@type-binding.type', rangeExpr, elemType),
+ });
+ }
+ }
+ }
+
+ // Handle index expressions: sl[0], m["key"]
+ for (const node of rootNode.descendantsOfType('call_expression')) {
+ const fn = node.childForFieldName('function');
+ if (fn?.type !== 'selector_expression') continue;
+ const operand = fn.childForFieldName('operand');
+ if (operand === null) continue;
+
+ if (operand.type === 'index_expression') {
+ const base = operand.childForFieldName('operand');
+ if (base?.type === 'identifier' && varElementType.has(base.text)) {
+ const elemType = varElementType.get(base.text)!;
+ out.push({
+ '@type-binding.index': syntheticCapture('@type-binding.index', node, operand.text),
+ '@type-binding.name': syntheticCapture('@type-binding.name', operand, operand.text),
+ '@type-binding.type': syntheticCapture('@type-binding.type', operand, elemType),
+ });
+ }
+ }
+ }
+}
+
+function extractSimpleTypeNameText(node: SyntaxNode): string {
+ if (node.type === 'qualified_type') {
+ const parts = node.text.split('.');
+ return parts[parts.length - 1] ?? node.text;
+ }
+ return node.text;
+}
+
+/** Extract the type/signature node from a RHS expression. */
+function extractTypeNode(expr: SyntaxNode): SyntaxNode | null {
+ if (expr.type === 'composite_literal') {
+ return (
+ expr.childForFieldName('type') ??
+ expr.namedChildren.find((c) => ['type_identifier', 'qualified_type'].includes(c.type)) ??
+ null
+ );
+ }
+ if (expr.type === 'unary_expression') {
+ const operand = expr.childForFieldName('operand');
+ return operand === null ? null : extractTypeNode(operand);
+ }
+ if (expr.type === 'call_expression') {
+ const fn = expr.childForFieldName('function');
+ if (fn?.type === 'identifier' && fn.text === 'new') {
+ const args = expr.childForFieldName('arguments');
+ return (
+ args?.namedChildren.find((c) =>
+ ['type_identifier', 'qualified_type', 'pointer_type'].includes(c.type),
+ ) ?? null
+ );
+ }
+ if (fn?.type === 'identifier' && fn.text === 'make') {
+ const args = expr.childForFieldName('arguments');
+ const container = args?.namedChildren.find((c) =>
+ ['slice_type', 'map_type'].includes(c.type),
+ );
+ if (container?.type === 'slice_type') {
+ return (
+ container.namedChildren.find((c) =>
+ ['type_identifier', 'qualified_type'].includes(c.type),
+ ) ?? null
+ );
+ }
+ if (container?.type === 'map_type') {
+ const typeChildren = container.namedChildren.filter((c) =>
+ ['type_identifier', 'qualified_type'].includes(c.type),
+ );
+ return typeChildren[1] ?? typeChildren[0] ?? null;
+ }
+ }
+ if (fn?.type === 'identifier') return fn;
+ if (fn?.type === 'selector_expression') {
+ return fn.childForFieldName('field') ?? fn;
+ }
+ }
+ if (expr.type === 'type_assertion_expression') {
+ return expr.childForFieldName('type');
+ }
+ return null;
+}
diff --git a/gitnexus/src/core/ingestion/model/resolve.ts b/gitnexus/src/core/ingestion/model/resolve.ts
index 62653a762..55da60c62 100644
--- a/gitnexus/src/core/ingestion/model/resolve.ts
+++ b/gitnexus/src/core/ingestion/model/resolve.ts
@@ -157,19 +157,27 @@ export function c3Linearize(
// Add the direct parents list as the final sequence
const sequences = [...parentLinearizations, [...directParents]];
+ const heads = new Uint32Array(sequences.length); // head pointer per sequence
const result: string[] = [];
+ // Tail-count map: how many sequences contain this id at index > head.
+ // O(1) membership check replaces O(n) indexOf scans.
+ const tailCount = new Map();
+ for (const seq of sequences) {
+ for (let i = 1; i < seq.length; i++) {
+ tailCount.set(seq[i], (tailCount.get(seq[i]) ?? 0) + 1);
+ }
+ }
+
+ let remaining = sequences.reduce((n, s) => n + s.length, 0);
let inconsistent = false;
- while (sequences.some((s) => s.length > 0)) {
- // Find a good head: one that doesn't appear in the tail of any other sequence
+
+ while (remaining > 0) {
let head: string | null = null;
- for (const seq of sequences) {
- if (seq.length === 0) continue;
- const candidate = seq[0];
- const inTail = sequences.some(
- (other) => other.length > 1 && other.indexOf(candidate, 1) !== -1,
- );
- if (!inTail) {
+ for (let si = 0; si < sequences.length; si++) {
+ if (heads[si] >= sequences[si].length) continue;
+ const candidate = sequences[si][heads[si]];
+ if ((tailCount.get(candidate) ?? 0) === 0) {
head = candidate;
break;
}
@@ -182,10 +190,19 @@ export function c3Linearize(
result.push(head);
- // Remove the chosen head from all sequences
- for (const seq of sequences) {
- if (seq.length > 0 && seq[0] === head) {
- seq.shift();
+ // Advance head pointers past the chosen head; update tail counts
+ for (let si = 0; si < sequences.length; si++) {
+ if (heads[si] >= sequences[si].length) continue;
+ if (sequences[si][heads[si]] === head) {
+ heads[si]++;
+ remaining--;
+ // promoted was in this sequence's active tail; now it's the new head — remove from tailCount
+ if (heads[si] < sequences[si].length) {
+ const promoted = sequences[si][heads[si]];
+ const prev = tailCount.get(promoted)!;
+ if (prev <= 1) tailCount.delete(promoted);
+ else tailCount.set(promoted, prev - 1);
+ }
}
}
}
diff --git a/gitnexus/src/core/ingestion/registry-primary-flag.ts b/gitnexus/src/core/ingestion/registry-primary-flag.ts
index 3eaeb4579..713b32c07 100644
--- a/gitnexus/src/core/ingestion/registry-primary-flag.ts
+++ b/gitnexus/src/core/ingestion/registry-primary-flag.ts
@@ -70,6 +70,7 @@ export const MIGRATED_LANGUAGES: ReadonlySet = new Set,
resolutionConfig?: unknown,
- ): string | null;
+ ): string | readonly string[] | null;
+
+ /**
+ * Enumerate names visible through a wildcard import after the target
+ * module scope has been linked. Languages that do not support
+ * wildcard-style imports leave this undefined.
+ */
+ readonly expandsWildcardTo?: (
+ targetModuleScope: ScopeId,
+ parsedFiles: readonly ParsedFile[],
+ ) => readonly string[];
+
+ /**
+ * Optional one-shot loader for cross-file import-resolution config
+ * (e.g. tsconfig path aliases for TypeScript, go.mod paths for Go,
+ * composer.json autoload for PHP). The orchestrator calls this once
+ * per workspace pass with the repo root and threads the result into
+ * every subsequent `resolveImportTarget` call as the
+ * `resolutionConfig` parameter.
+ *
+ * Languages that don't need any per-workspace config leave this
+ * undefined; the orchestrator threads `undefined` to
+ * `resolveImportTarget` in that case. Returning `null` is also
+ * supported and equivalent to "no config available".
+ *
+ * May be sync or async — the orchestrator awaits the result. The
+ * shape is opaque to the orchestrator (`unknown`); the per-language
+ * `resolveImportTarget` casts it to the language's expected shape.
+ */
+ loadResolutionConfig?(repoPath: string): Promise | unknown;
/**
* Optional one-shot loader for cross-file import-resolution config
@@ -384,6 +414,18 @@ export interface ScopeResolver {
*/
populateOwners(parsed: ParsedFile): void;
+ /**
+ * Optional workspace-wide ownership reconciliation for languages whose
+ * member owner can be declared in a different file from the owner type.
+ * Runs after every file has had `populateOwners(parsed)` applied, but
+ * still before `reconcileOwnership`, so stamped ownerIds are copied into
+ * the semantic model registries.
+ */
+ readonly populateWorkspaceOwners?: (
+ parsedFiles: readonly ParsedFile[],
+ ctx: { readonly fileContents: ReadonlyMap },
+ ) => void;
+
/**
* Recognize a `super(...)`-style receiver text. Python returns
* `/^super\s*\(/.test(t)`. Java returns `t === 'super'`. C++ may
@@ -441,6 +483,14 @@ export interface ScopeResolver {
*/
readonly collapseMemberCallsByCallerTarget?: boolean;
+ /**
+ * Allow free-call emission to fall back to a unique workspace-wide
+ * callable match when lexical/import bindings miss. Kept opt-in
+ * because this mirrors legacy resolver behavior for some languages
+ * but is too loose as a default for strict module systems.
+ */
+ readonly allowGlobalFreeCallFallback?: boolean;
+
/**
* Optional post-finalize hook to inject cross-file bindings that
* aren't modeled via explicit imports. Runs after
@@ -485,4 +535,52 @@ export interface ScopeResolver {
* level bindings.
*/
readonly hoistTypeBindingsToModule?: boolean;
+
+ /**
+ * Optional: detect structural (duck-typing) interface implementations.
+ * Languages like Go use structural typing — a struct satisfies an
+ * interface if its method set is a superset, without an explicit
+ * `implements` keyword. Runs after finalize, before resolution passes.
+ * Returns: Map.
+ * Default: undefined (no structural interface detection).
+ */
+ readonly detectInterfaceImplementations?: (
+ parsedFiles: readonly ParsedFile[],
+ indexes: ScopeResolutionIndexes,
+ model: SemanticModel,
+ ) => Map;
+
+ /**
+ * Optional: mirror typeBindings from namespace-import target modules
+ * into the importer's module scope. Languages like Go use namespace
+ * imports (`import "pkg"`) and need the target package's exported
+ * typeBindings visible in the importer's scope chain for cross-package
+ * return-type resolution (e.g. `x := pkg.NewUser(); x.Save()` needs
+ * `NewUser → User` mirrored from the target package). Runs after
+ * `populateNamespaceSiblings` and before `propagateImportedReturnTypes`
+ * so the SCC-ordered pass sees the mirrored bindings.
+ * Default: undefined (no namespace typeBinding mirroring).
+ */
+ readonly mirrorNamespaceTypeBindings?: (
+ parsedFiles: readonly ParsedFile[],
+ indexes: ScopeResolutionIndexes,
+ workspaceIndex: import('../../scope-resolution/workspace-index.js').WorkspaceResolutionIndex,
+ ) => void;
+
+ /**
+ * Optional: bind for-range loop variables to their element/value types.
+ * Languages like Go need to resolve `for _, v := range m` where `m` is
+ * `map[K]V` — the variable `v` should bind to `V`. Runs after finalize,
+ * before resolution passes. Mutates scope typeBindings via the Map cast
+ * convention (see Invariant I8).
+ * Default: undefined (no range variable binding).
+ */
+ readonly populateRangeBindings?: (
+ parsedFiles: readonly ParsedFile[],
+ indexes: ScopeResolutionIndexes,
+ ctx: {
+ readonly fileContents: ReadonlyMap;
+ readonly treeCache?: { get(filePath: string): unknown };
+ },
+ ) => void;
}
diff --git a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts
index 39f5a9a74..ad59f4f10 100644
--- a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts
+++ b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts
@@ -21,7 +21,6 @@ import type { NodeLabel, ScopeId, SymbolDefinition } from 'gitnexus-shared';
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
import { generateId } from '../../../../lib/utils.js';
import { qualifiedKey, simpleKey, type GraphNodeLookup } from '../graph-bridge/node-lookup.js';
-
/**
* Labels that may legitimately ANCHOR a CALLS/ACCESSES edge as the
* source ("caller"). A Variable / Property can be the TARGET of an
diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts
index e6c160467..248ee439f 100644
--- a/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts
+++ b/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts
@@ -36,6 +36,7 @@ export function emitFreeCallFallback(
handledSites: Set,
model: SemanticModel,
workspaceIndex: WorkspaceResolutionIndex,
+ options: { readonly allowGlobalFallback?: boolean } = {},
): number {
let emitted = 0;
const seen = new Set();
@@ -67,6 +68,13 @@ export function emitFreeCallFallback(
if (fnDef === undefined) {
fnDef = findCallableBindingInScope(site.inScope, site.name, scopes);
}
+ // V1: pickUniqueGlobalCallable ignores import context — resolves to any
+ // globally-unique callable. False cross-package edges are possible when
+ // the caller does not import the target package. Same-package calls are
+ // caught by findCallableBindingInScope above before reaching here.
+ if (fnDef === undefined && options.allowGlobalFallback === true) {
+ fnDef = pickUniqueGlobalCallable(site.name, model, scopes);
+ }
if (fnDef === undefined) continue;
const callerGraphId = resolveCallerGraphId(site.inScope, scopes, nodeLookup);
if (callerGraphId === undefined) continue;
@@ -95,6 +103,51 @@ export function emitFreeCallFallback(
return emitted;
}
+function pickUniqueGlobalCallable(
+ name: string,
+ model: SemanticModel,
+ scopes: ScopeResolutionIndexes,
+): SymbolDefinition | undefined {
+ const scopeDefs: SymbolDefinition[] = [];
+ const scopeSeen = new Set();
+ for (const def of scopes.defs.byId.values()) {
+ const simple = def.qualifiedName?.split('.').pop() ?? def.qualifiedName;
+ if (simple !== name) continue;
+ if (def.type !== 'Function' && def.type !== 'Method' && def.type !== 'Constructor') continue;
+ const key = logicalCallableKey(def);
+ if (scopeSeen.has(key)) continue;
+ scopeSeen.add(key);
+ scopeDefs.push(def);
+ }
+ if (scopeDefs.length === 1) return scopeDefs[0];
+
+ const defs: SymbolDefinition[] = [];
+ const seen = new Set();
+ const push = (pool: readonly SymbolDefinition[]): void => {
+ for (const def of pool) {
+ const key = logicalCallableKey(def);
+ if (seen.has(key)) continue;
+ seen.add(key);
+ defs.push(def);
+ }
+ };
+
+ push(model.symbols.lookupCallableByName(name));
+ push(model.methods.lookupMethodByName(name));
+
+ return defs.length === 1 ? defs[0] : undefined;
+}
+
+function logicalCallableKey(def: SymbolDefinition): string {
+ return [
+ def.filePath,
+ def.qualifiedName ?? '',
+ def.type,
+ def.parameterCount ?? '',
+ def.parameterTypes?.join(',') ?? '',
+ ].join('\0');
+}
+
/** For a constructor call `new X(...)`, return the X class's explicit
* Constructor def (by walking the class scope's ownedDefs) or the
* Class def itself when no explicit Constructor exists. Matches
diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/imported-return-types.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/imported-return-types.ts
index 3ad9a28ae..cbdfc62aa 100644
--- a/gitnexus/src/core/ingestion/scope-resolution/passes/imported-return-types.ts
+++ b/gitnexus/src/core/ingestion/scope-resolution/passes/imported-return-types.ts
@@ -59,7 +59,7 @@ const RECHAIN_MAX_DEPTH = 8;
* `followChainedRef` but operates on post-finalize Scope objects so
* it can see imported return-types propagated by
* `propagateImportedReturnTypes`. */
-function followChainPostFinalize(
+export function followChainPostFinalize(
start: TypeRef,
fromScopeId: ScopeId,
scopes: ScopeResolutionIndexes,
diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/mro.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/mro.ts
index a4d7e4cfb..ab6842771 100644
--- a/gitnexus/src/core/ingestion/scope-resolution/passes/mro.ts
+++ b/gitnexus/src/core/ingestion/scope-resolution/passes/mro.ts
@@ -24,6 +24,7 @@ import type { KnowledgeGraph } from '../../../graph/types.js';
import type { GraphNodeLookup } from '../graph-bridge/node-lookup.js';
import type { LinearizeStrategy } from '../contract/scope-resolver.js';
import { resolveDefGraphId } from '../graph-bridge/ids.js';
+import { isClassLike } from '../scope/walkers.js';
/**
* Build an MRO map keyed by scope-resolution Class `DefId`.
@@ -58,7 +59,7 @@ export function buildMro(
const defIdByGraphId = new Map();
for (const parsed of parsedFiles) {
for (const def of parsed.localDefs) {
- if (def.type !== 'Class') continue;
+ if (!isClassLike(def.type)) continue;
const graphId = resolveDefGraphId(parsed.filePath, def, nodeLookup);
if (graphId !== undefined) defIdByGraphId.set(graphId, def.nodeId);
}
diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts
index 2c994c0ae..925151b6c 100644
--- a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts
+++ b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts
@@ -46,6 +46,7 @@ import {
findExportedDef,
findOwnedMember,
findReceiverTypeBinding,
+ isClassLike,
} from '../scope/walkers.js';
import { tryEmitEdge } from '../graph-bridge/edges.js';
import { resolveCompoundReceiverClass } from '../passes/compound-receiver.js';
@@ -249,25 +250,30 @@ export function emitReceiverBoundCalls(
}
// ── Case 1: namespace receiver ───────────────────────────────
- const targetFile = namespaceTargets.get(receiverName);
- if (targetFile !== undefined) {
- const memberDef = findExportedDef(targetFile, memberName, index);
- if (memberDef !== undefined) {
- const ok = tryEmitEdge(
- graph,
- scopes,
- nodeLookup,
- site,
- memberDef,
- memberDef.filePath !== parsed.filePath ? 'import-resolved' : 'global',
- seen,
- 0.85,
- collapse,
- );
- if (ok) emitted++;
- handledSites.add(siteKey);
- continue;
+ const targetFiles = namespaceTargets.get(receiverName);
+ if (targetFiles !== undefined) {
+ let found = false;
+ for (const targetFile of targetFiles) {
+ const memberDef = findExportedDef(targetFile, memberName, index);
+ if (memberDef !== undefined) {
+ const ok = tryEmitEdge(
+ graph,
+ scopes,
+ nodeLookup,
+ site,
+ memberDef,
+ memberDef.filePath !== parsed.filePath ? 'import-resolved' : 'global',
+ seen,
+ 0.85,
+ collapse,
+ );
+ if (ok) emitted++;
+ handledSites.add(siteKey);
+ found = true;
+ break;
+ }
}
+ if (found) continue;
}
// ── Case 2: class-name receiver ──────────────────────────────
@@ -309,28 +315,33 @@ export function emitReceiverBoundCalls(
if (typeRef !== undefined && typeRef.rawName.includes('.')) {
const [nsName, ...classNameParts] = typeRef.rawName.split('.');
const className = classNameParts.join('.');
- const targetFile3 = namespaceTargets.get(nsName);
- if (targetFile3 !== undefined && className.length > 0) {
- const classDef3 = findExportedDef(targetFile3, className, index);
- if (classDef3 !== undefined) {
- const memberDef = findOwnedMember(classDef3.nodeId, memberName, model);
- if (memberDef !== undefined) {
- const ok = tryEmitEdge(
- graph,
- scopes,
- nodeLookup,
- site,
- memberDef,
- memberDef.filePath !== parsed.filePath ? 'import-resolved' : 'global',
- seen,
- );
- if (ok) {
- emitted++;
- handledSites.add(siteKey);
+ const targetFiles3 = namespaceTargets.get(nsName);
+ if (targetFiles3 !== undefined && className.length > 0) {
+ let found3 = false;
+ for (const targetFile3 of targetFiles3) {
+ const classDef3 = findExportedDef(targetFile3, className, index);
+ if (classDef3 !== undefined) {
+ const memberDef = findOwnedMember(classDef3.nodeId, memberName, model);
+ if (memberDef !== undefined) {
+ const ok = tryEmitEdge(
+ graph,
+ scopes,
+ nodeLookup,
+ site,
+ memberDef,
+ memberDef.filePath !== parsed.filePath ? 'import-resolved' : 'global',
+ seen,
+ );
+ if (ok) {
+ emitted++;
+ handledSites.add(siteKey);
+ }
+ found3 = true;
+ break;
}
- continue;
}
}
+ if (found3) continue;
}
}
@@ -394,10 +405,20 @@ export function emitReceiverBoundCalls(
if (typeRef !== undefined && !typeRef.rawName.includes('.')) {
let ownerDef = findClassBindingInScope(site.inScope, typeRef.rawName, scopes);
// `findClassBindingInScope(..., typeRef.rawName)` only works when
- // rawName is itself a class symbol. Map for-of tuple bindings
- // (`__MAP_TUPLE_i__:mapId`), callable aliases (`getUser` → User),
- // and other compound-friendly shapes need the compound resolver
- // keyed by the receiver identifier.
+ // rawName is itself a class symbol reachable through scope bindings.
+ // For languages with namespace-style imports (Go), imported types
+ // don't create bindings. Fall back to QualifiedNameIndex — single-
+ // match wins; ambiguous/missing falls through.
+ if (ownerDef === undefined) {
+ const qnameIds = scopes.qualifiedNames.get(typeRef.rawName);
+ if (qnameIds.length === 1) {
+ const qdef = scopes.defs.get(qnameIds[0]!);
+ if (qdef !== undefined && isClassLike(qdef.type)) ownerDef = qdef;
+ }
+ }
+ // Map for-of tuple bindings (`__MAP_TUPLE_i__:mapId`), callable
+ // aliases (`getUser` → User), and other compound-friendly shapes
+ // need the compound resolver keyed by the receiver identifier.
if (ownerDef === undefined) {
ownerDef = resolveCompoundReceiverClass(
receiverName,
diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.ts
index 7b96f2136..3d9915fd7 100644
--- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.ts
+++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.ts
@@ -14,6 +14,7 @@ import type { ScopeResolver } from '../contract/scope-resolver.js';
import { pythonScopeResolver } from '../../languages/python/scope-resolver.js';
import { csharpScopeResolver } from '../../languages/csharp/scope-resolver.js';
import { typescriptScopeResolver } from '../../languages/typescript/scope-resolver.js';
+import { goScopeResolver } from '../../languages/go/scope-resolver.js';
/** Map of `SupportedLanguages` → `ScopeResolver`. The phase iterates
* this map intersected with `MIGRATED_LANGUAGES` (the per-language
@@ -26,4 +27,5 @@ export const SCOPE_RESOLVERS: ReadonlyMap = n
[SupportedLanguages.Python, pythonScopeResolver],
[SupportedLanguages.CSharp, csharpScopeResolver],
[SupportedLanguages.TypeScript, typescriptScopeResolver],
+ [SupportedLanguages.Go, goScopeResolver],
]);
diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts
index feebf2405..b069f0dd1 100644
--- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts
+++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts
@@ -90,6 +90,14 @@ export function runScopeResolution(
const onWarn = input.onWarn ?? (() => {});
const PROF = process.env.PROF_SCOPE_RESOLUTION === '1';
const tStart = PROF ? process.hrtime.bigint() : 0n;
+ let fileContents: Map | undefined;
+ const getFileContents = (): Map => {
+ if (fileContents === undefined) {
+ fileContents = new Map();
+ for (const f of files) fileContents.set(f.path, f.content);
+ }
+ return fileContents;
+ };
// ── Phase 1: extract each file → ParsedFile ────────────────────────────
const parsedFiles: ParsedFile[] = [];
@@ -111,6 +119,7 @@ export function runScopeResolution(
provider.populateOwners(parsed);
parsedFiles.push(parsed);
}
+ provider.populateWorkspaceOwners?.(parsedFiles, { fileContents: getFileContents() });
// Reconcile scope-resolution's ownership view into the SemanticModel.
// See `reconcile-ownership.ts` for the full rationale (Contract
@@ -149,6 +158,8 @@ export function runScopeResolution(
hooks: {
resolveImportTarget: (targetRaw, fromFile) =>
provider.resolveImportTarget(targetRaw, fromFile, allFilePaths, resolutionConfig),
+ expandsWildcardTo: (targetModuleScope) =>
+ provider.expandsWildcardTo?.(targetModuleScope, parsedFiles) ?? [],
mergeBindings: (existing, incoming, scopeId) =>
provider.mergeBindings(existing, incoming, scopeId),
},
@@ -178,16 +189,21 @@ export function runScopeResolution(
// The hook writes to `bindingAugmentations` only; finalized
// `indexes.bindings` remains immutable post-finalize (I8).
if (provider.populateNamespaceSiblings !== undefined) {
- const fileContents = new Map();
- for (const f of files) fileContents.set(f.path, f.content);
provider.populateNamespaceSiblings(parsedFiles, indexes, {
- fileContents,
+ fileContents: getFileContents(),
treeCache,
});
}
const tFinalize = PROF ? process.hrtime.bigint() : 0n;
+ // Cross-package namespace typeBinding mirroring. Runs before
+ // propagateImportedReturnTypes so the SCC-ordered pass sees the
+ // mirrored bindings.
+ if (provider.mirrorNamespaceTypeBindings !== undefined) {
+ provider.mirrorNamespaceTypeBindings(parsedFiles, indexes, workspaceIndex);
+ }
+
// Cross-file return-type propagation (Contract Invariant I3 timing:
// after finalize, before resolve). Split-timed separately so the
// SCC-ordered pass's cost is observable (PR #1050 made this O(files)
@@ -196,6 +212,13 @@ export function runScopeResolution(
if (provider.propagatesReturnTypesAcrossImports !== false) {
propagateImportedReturnTypes(parsedFiles, indexes, workspaceIndex);
}
+
+ if (provider.populateRangeBindings !== undefined) {
+ provider.populateRangeBindings(parsedFiles, indexes, {
+ fileContents: getFileContents(),
+ treeCache,
+ });
+ }
const tPropagate = PROF ? process.hrtime.bigint() : 0n;
// Opt-in I8 invariant guard. Runs once after all post-finalize hooks
@@ -237,6 +260,7 @@ export function runScopeResolution(
handledSites,
readonlyModel,
workspaceIndex,
+ { allowGlobalFallback: provider.allowGlobalFreeCallFallback === true },
);
const { emitted, skipped } = emitReferencesViaLookup(
graph,
diff --git a/gitnexus/src/core/ingestion/scope-resolution/scope/namespace-targets.ts b/gitnexus/src/core/ingestion/scope-resolution/scope/namespace-targets.ts
index 4af63bd75..6ee0e2a16 100644
--- a/gitnexus/src/core/ingestion/scope-resolution/scope/namespace-targets.ts
+++ b/gitnexus/src/core/ingestion/scope-resolution/scope/namespace-targets.ts
@@ -38,8 +38,8 @@ import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexe
export function collectNamespaceTargets(
parsed: ParsedFile,
scopes: ScopeResolutionIndexes,
-): Map {
- const out = new Map();
+): Map {
+ const out = new Map();
const moduleEdges = scopes.imports.get(parsed.moduleScope);
if (moduleEdges === undefined) return out;
@@ -51,7 +51,12 @@ export function collectNamespaceTargets(
for (const edge of moduleEdges) {
if (edge.targetFile === null) continue;
if (!namespaceLocals.has(edge.localName)) continue;
- out.set(edge.localName, edge.targetFile);
+ let targets = out.get(edge.localName);
+ if (targets === undefined) {
+ targets = [];
+ out.set(edge.localName, targets);
+ }
+ if (!targets.includes(edge.targetFile)) targets.push(edge.targetFile);
}
return out;
}
diff --git a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts
index 27298328c..3e347cd4e 100644
--- a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts
+++ b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts
@@ -187,6 +187,27 @@ export function findClassBindingInScope(
currentId = scope.parent;
}
+ // Fallback for languages (Go) where namespace-style imports don't
+ // create scope bindings: resolve via QualifiedNameIndex. Only fires
+ // when the scope-chain walk found nothing; single-match wins.
+ const qnames = scopes.qualifiedNames.get(receiverName);
+ if (qnames.length === 1) {
+ const def = scopes.defs.get(qnames[0]!);
+ if (def !== undefined && isClassLike(def.type)) return def;
+ }
+ // Second fallback: dotted names like "models.User" — try the simple
+ // name (tail after last dot) for languages where defs are indexed by
+ // simple name (Go). Only when the dotted lookup fails.
+ if (receiverName.includes('.')) {
+ const simple = receiverName.slice(receiverName.lastIndexOf('.') + 1);
+ if (simple.length > 0 && simple !== receiverName) {
+ const simpleIds = scopes.qualifiedNames.get(simple);
+ if (simpleIds.length === 1) {
+ const def = scopes.defs.get(simpleIds[0]!);
+ if (def !== undefined && isClassLike(def.type)) return def;
+ }
+ }
+ }
return undefined;
}
diff --git a/gitnexus/src/core/ingestion/workers/worker-pool.ts b/gitnexus/src/core/ingestion/workers/worker-pool.ts
index f670171db..4be6af6b1 100644
--- a/gitnexus/src/core/ingestion/workers/worker-pool.ts
+++ b/gitnexus/src/core/ingestion/workers/worker-pool.ts
@@ -332,11 +332,22 @@ export const createWorkerPool = (
if (!settled) {
settled = true;
cleanup();
- activeWorkers--;
inFlightProgress[workerIndex] = 0;
const shouldContinue = requeueAfterTimeout(workerIndex, job, lastProgress);
- if (!shouldContinue) return;
- await replaceWorker(workerIndex);
+ if (!shouldContinue) {
+ activeWorkers--;
+ return;
+ }
+ try {
+ await replaceWorker(workerIndex);
+ } catch (err) {
+ void fail(
+ err instanceof Error ? err : new Error(`Worker replacement failed: ${err}`),
+ );
+ return;
+ } finally {
+ activeWorkers--;
+ }
reportProgress();
runWorker(workerIndex);
maybeDone();
diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts
index e14f7a30f..e754a0242 100644
--- a/gitnexus/src/core/run-analyze.ts
+++ b/gitnexus/src/core/run-analyze.ts
@@ -30,7 +30,13 @@ import {
registerRepo,
cleanupOldKuzuFiles,
} from '../storage/repo-manager.js';
-import { getCurrentCommit, getRemoteUrl, hasGitDir, getInferredRepoName } from '../storage/git.js';
+import {
+ getCurrentCommit,
+ getRemoteUrl,
+ hasGitDir,
+ getInferredRepoName,
+ resolveRepoIdentityRoot,
+} from '../storage/git.js';
import type { CachedEmbedding } from './embeddings/types.js';
import { generateAIContextFiles } from '../cli/ai-context.js';
import { EMBEDDING_TABLE_NAME } from './lbug/schema.js';
@@ -168,7 +174,13 @@ export async function runFullAnalysis(
if (currentCommit !== '') {
await ensureGitNexusIgnored(repoPath);
return {
- repoName: options.registryName ?? getInferredRepoName(repoPath) ?? path.basename(repoPath),
+ // `resolveRepoIdentityRoot` collapses worktree roots to the
+ // canonical repo basename (#1259) but leaves arbitrary subdirs
+ // and `--skip-git` paths unchanged (#1232/#1233 intent preserved).
+ repoName:
+ options.registryName ??
+ getInferredRepoName(repoPath) ??
+ path.basename(resolveRepoIdentityRoot(repoPath)),
repoPath,
stats: existingMeta.stats ?? {},
alreadyUpToDate: true,
@@ -345,7 +357,19 @@ export async function runFullAnalysis(
}
const { readServerMapping } = await import('./embeddings/server-mapping.js');
- const projectName = path.basename(repoPath);
+ // Mirror the registry's name-resolution chain so the server-mapping
+ // lookup key stays aligned with the final registry name (#1259):
+ // --name → remote-derived → canonical-root basename
+ // (preserved-alias is intentionally NOT consulted here — server
+ // mappings are addressed by the operationally-meaningful name the
+ // user configures, not by a sticky registry-only alias they may not
+ // know about. The previous canonical-only logic ignored both --name
+ // and remote-derived names, silently breaking server-mapping for
+ // anyone with a `--name` alias or remote-named repo.)
+ const projectName =
+ options.registryName ??
+ getInferredRepoName(repoPath) ??
+ path.basename(resolveRepoIdentityRoot(repoPath));
const serverName = await readServerMapping(projectName);
const embeddingResult = await runEmbeddingPipeline(
executeQuery,
diff --git a/gitnexus/src/server/api.ts b/gitnexus/src/server/api.ts
index 85dfaaec3..a50de5d33 100644
--- a/gitnexus/src/server/api.ts
+++ b/gitnexus/src/server/api.ts
@@ -32,6 +32,7 @@ import { LocalBackend } from '../mcp/local/local-backend.js';
import { fork } from 'child_process';
import { fileURLToPath, pathToFileURL } from 'url';
import { JobManager } from './analyze-job.js';
+import { assertString, escapeRegExp, BadRequestError, createRouteLimiter } from './validation.js';
import { extractRepoName, getCloneDir, cloneOrPull } from './git-clone.js';
const _require = createRequire(import.meta.url);
@@ -219,7 +220,19 @@ export const registerWebUI = (app: express.Express, staticDir: string | null): v
// The regex excludes /api paths AND paths with file extensions (.js, .css, etc.)
// so missing assets get real 404s instead of the SPA HTML.
// Adding routes below this will be unreachable for non-API, non-asset paths.
- app.get(SPA_FALLBACK_REGEX, (_req, res) => {
+ // Rate-limited (CodeQL js/missing-rate-limiting): the SPA fallback
+ // serves a constant index.html, but the FS access from a route handler
+ // is enough to trip the analyzer. The limit is generous (300 rpm/IP =
+ // 5 req/s sustained) so that multi-tab browser navigation, prefetch,
+ // and service-worker revalidation do not produce 429s for legitimate
+ // SPA users. At this rate, real browser navigation is extremely
+ // unlikely to hit the limit in practice, so the cosmetic issue of
+ // JSON-on-429 to a browser is a low-likelihood path. Content
+ // negotiation on the 429 (returning the SPA shell to HTML clients
+ // instead of `{ error: '...' }`) would require swapping
+ // express-rate-limit's `message` for a `handler` function and is
+ // deferred to keep this PR focused on closing the CodeQL alert.
+ app.get(SPA_FALLBACK_REGEX, createRouteLimiter({ limit: 300 }), (_req, res) => {
res.sendFile(path.join(staticDir, 'index.html'));
});
} else {
@@ -509,6 +522,9 @@ const mountSSEProgress = (app: express.Express, routePath: string, jm: JobManage
};
const statusFromError = (err: any): number => {
+ // Validation helpers throw BadRequestError / ForbiddenError with a typed
+ // .status field — honor it before falling back to message-string matching.
+ if (err instanceof BadRequestError) return err.status;
const msg = String(err?.message ?? '');
if (msg.includes('No indexed repositories') || msg.includes('not found')) return 404;
if (msg.includes('Multiple repositories')) return 400;
@@ -526,10 +542,112 @@ const requestedRepo = (req: express.Request): string | undefined => {
return undefined;
};
+/**
+ * Handle a GET /api/file request body. Extracted from createServer's route
+ * registration so it can be unit-tested without spinning up an HTTP server
+ * — calling app.get(...) inside a test triggers CodeQL's
+ * js/missing-rate-limiting query, which is appropriate for production
+ * route handlers but a false positive for tests of the handler logic.
+ *
+ * The function takes the express req and res (typed loosely so test code
+ * can pass minimal mocks) plus the resolved repo path. All path-traversal
+ * containment is done inline at the readFile sink with the canonical
+ * path.relative idiom for CodeQL js/path-injection recognition.
+ */
+export const handleFileRequest = async (
+ req: { query: any },
+ res: {
+ status: (code: number) => { json: (body: any) => void };
+ json: (body: any) => void;
+ },
+ repoPath: string,
+): Promise => {
+ try {
+ // Type-confusion guard — req.query.path is `string | string[] | ParsedQs`.
+ // Without this, an attacker could pass `?path=a&path=b` to bypass the
+ // length-bound traversal check below (CodeQL js/type-confusion-through-
+ // parameter-tampering, same class as the /api/grep critical fix).
+ const rawFilePath = req.query.path;
+ if (rawFilePath === undefined || rawFilePath === '') {
+ res.status(400).json({ error: 'Missing path' });
+ return;
+ }
+ const filePath = assertString(rawFilePath, 'path');
+
+ // Path-injection containment — inline at the sink with the canonical
+ // path.relative idiom that CodeQL's js/path-injection sanitizer
+ // recognizes. assertSafePath in validation.ts performs the equivalent
+ // check, but cross-module helpers are not followed by CodeQL's
+ // interprocedural analysis for path-traversal sanitization in JS, so
+ // the barrier must be visible inline at the readFile sink.
+ const repoRoot = path.resolve(repoPath);
+ const fullPath = path.resolve(repoRoot, filePath);
+ const fullRel = path.relative(repoRoot, fullPath);
+ if (fullRel.startsWith('..') || path.isAbsolute(fullRel)) {
+ res.status(403).json({ error: 'Path traversal denied' });
+ return;
+ }
+
+ const raw = await fs.readFile(fullPath, 'utf-8');
+
+ // Optional line-range support: ?startLine=10&endLine=50
+ // Returns only the requested slice (0-indexed), plus metadata.
+ const startLine = req.query.startLine !== undefined ? Number(req.query.startLine) : undefined;
+ const endLine = req.query.endLine !== undefined ? Number(req.query.endLine) : undefined;
+
+ if (startLine !== undefined && Number.isFinite(startLine)) {
+ const lines = raw.split('\n');
+ const start = Math.max(0, startLine);
+ const end =
+ endLine !== undefined && Number.isFinite(endLine)
+ ? Math.min(lines.length, endLine + 1)
+ : lines.length;
+ res.json({
+ content: lines.slice(start, end).join('\n'),
+ startLine: start,
+ endLine: end - 1,
+ totalLines: lines.length,
+ });
+ } else {
+ res.json({ content: raw, totalLines: raw.split('\n').length });
+ }
+ } catch (err: any) {
+ if (err.code === 'ENOENT') {
+ res.status(404).json({ error: 'File not found' });
+ } else {
+ // statusFromError returns err.status for BadRequestError / ForbiddenError
+ // (assertString → 400 on array-form ?path=a&path=b; ForbiddenError → 403
+ // on traversal). Falls back to 500 for unrecognized failures.
+ res.status(statusFromError(err)).json({ error: err.message || 'Failed to read file' });
+ }
+ }
+};
+
export const createServer = async (port: number, host: string = '127.0.0.1') => {
const app = express();
app.disable('x-powered-by');
+ // Trust X-Forwarded-* headers only when the connection comes from the
+ // local loopback or RFC1918 private/link-local addresses — exactly the
+ // origins the CORS allowlist accepts. Without this, every request behind
+ // any reverse proxy / Docker bridge counts as the same `req.ip` and a
+ // single user can trip the per-IP rate limiter for everyone.
+ //
+ // SCOPE: this setting is process-wide. Every middleware and route in this
+ // Express app sees req.ip resolved from X-Forwarded-For when the upstream
+ // hop is in the trusted set above — not just the rate-limited routes.
+ // Future IP-based middleware (audit logging, IP-bound authz) inherits this
+ // behavior.
+ //
+ // CLOUD-DEPLOY CAVEAT: a public cloud LB (AWS ALB, Cloudflare, Fly.io
+ // edge, CGNAT 100.64/10) is NOT in the trusted set. In those topologies
+ // req.ip will collapse to the LB hop IP for every request and the per-IP
+ // rate limiter degrades to per-server. Add an explicit env-var override
+ // and document the cloud-deploy story before binding to a non-loopback
+ // host in those topologies (tracked as a follow-up; not blocking for the
+ // local-bound default).
+ app.set('trust proxy', 'loopback, linklocal, uniquelocal');
+
// CORS: allow localhost, private/LAN networks, and the deployed site.
// Non-browser requests (curl, server-to-server) have no origin and are allowed.
// Disallowed origins get the response without Access-Control-Allow-Origin,
@@ -755,7 +873,10 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
});
// Delete a repo — removes index, clone dir (if any), and unregisters it
- app.delete('/api/repo', async (req, res) => {
+ // Rate-limited (CodeQL js/missing-rate-limiting): destructive operation
+ // doing fs.rm of clone + storage dirs. Default 60 rpm/IP is generous for
+ // delete; tighten if abuse is observed.
+ app.delete('/api/repo', createRouteLimiter(), async (req, res) => {
try {
const repoName = requestedRepo(req);
if (!repoName) {
@@ -786,15 +907,26 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
const storagePath = getStoragePath(entry.path);
await fs.rm(storagePath, { recursive: true, force: true }).catch(() => {});
- // 2. Delete the cloned repo dir if it lives under ~/.gitnexus/repos/
- const cloneDir = getCloneDir(entry.name);
+ // 2. Delete the cloned repo dir if it lives under ~/.gitnexus/repos/.
+ // getCloneDir now throws on names that are not filesystem-safe (e.g.
+ // local repos registered with names like "my project" or "org/repo").
+ // Such repos legitimately have no clone dir, so treat the rejection as
+ // "nothing to clean up" rather than letting it fail the delete handler.
+ let cloneDir: string | null = null;
try {
- const stat = await fs.stat(cloneDir);
- if (stat.isDirectory()) {
- await fs.rm(cloneDir, { recursive: true, force: true });
- }
+ cloneDir = getCloneDir(entry.name);
} catch {
- /* clone dir may not exist (local repos) */
+ /* repo name not eligible for a clone dir (local repo) */
+ }
+ if (cloneDir) {
+ try {
+ const stat = await fs.stat(cloneDir);
+ if (stat.isDirectory()) {
+ await fs.rm(cloneDir, { recursive: true, force: true });
+ }
+ } catch {
+ /* clone dir may not exist */
+ }
}
// 3. Unregister from the global registry
@@ -1057,84 +1189,58 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
});
// Read file — with path traversal guard
- app.get('/api/file', async (req, res) => {
- try {
- const entry = await resolveRepo(requestedRepo(req));
- if (!entry) {
- res.status(404).json({ error: 'Repository not found' });
- return;
- }
- const filePath = req.query.path as string;
- if (!filePath) {
- res.status(400).json({ error: 'Missing path' });
- return;
- }
-
- // Prevent path traversal — resolve and verify the path stays within the repo root
- const repoRoot = path.resolve(entry.path);
- const fullPath = path.resolve(repoRoot, filePath);
- if (!fullPath.startsWith(repoRoot + path.sep) && fullPath !== repoRoot) {
- res.status(403).json({ error: 'Path traversal denied' });
- return;
- }
-
- const raw = await fs.readFile(fullPath, 'utf-8');
-
- // Optional line-range support: ?startLine=10&endLine=50
- // Returns only the requested slice (0-indexed), plus metadata.
- const startLine = req.query.startLine !== undefined ? Number(req.query.startLine) : undefined;
- const endLine = req.query.endLine !== undefined ? Number(req.query.endLine) : undefined;
-
- if (startLine !== undefined && Number.isFinite(startLine)) {
- const lines = raw.split('\n');
- const start = Math.max(0, startLine);
- const end =
- endLine !== undefined && Number.isFinite(endLine)
- ? Math.min(lines.length, endLine + 1)
- : lines.length;
- res.json({
- content: lines.slice(start, end).join('\n'),
- startLine: start,
- endLine: end - 1,
- totalLines: lines.length,
- });
- } else {
- res.json({ content: raw, totalLines: raw.split('\n').length });
- }
- } catch (err: any) {
- if (err.code === 'ENOENT') {
- res.status(404).json({ error: 'File not found' });
- } else {
- res.status(500).json({ error: err.message || 'Failed to read file' });
- }
+ // Rate-limited (CodeQL js/missing-rate-limiting): per-request fs.readFile.
+ app.get('/api/file', createRouteLimiter(), async (req, res) => {
+ const entry = await resolveRepo(requestedRepo(req));
+ if (!entry) {
+ res.status(404).json({ error: 'Repository not found' });
+ return;
}
+ await handleFileRequest(req, res, entry.path);
});
// Grep — regex search across file contents in the indexed repo
// Uses filesystem-based search for memory efficiency (never loads all files into memory)
- app.get('/api/grep', async (req, res) => {
+ // Rate-limited (CodeQL js/missing-rate-limiting): scans every file in
+ // the indexed repo per request — heaviest I/O endpoint. Same default 60
+ // rpm/IP for now; consider tightening if real-world load shows abuse.
+ app.get('/api/grep', createRouteLimiter(), async (req, res) => {
try {
const entry = await resolveRepo(requestedRepo(req));
if (!entry) {
res.status(404).json({ error: 'Repository not found' });
return;
}
- const pattern = req.query.pattern as string;
- if (!pattern) {
+ // Type-confusion guard (CodeQL js/type-confusion-through-parameter-tampering):
+ // req.query.pattern is `string | string[] | ParsedQs` — without an explicit
+ // type check, the `.length` guard below counts array elements instead of
+ // characters, allowing arbitrarily long patterns through.
+ const rawPattern = req.query.pattern;
+ if (rawPattern === undefined) {
+ res.status(400).json({ error: 'Missing "pattern" query parameter' });
+ return;
+ }
+ const pattern = assertString(rawPattern, 'pattern');
+ if (pattern.length === 0) {
res.status(400).json({ error: 'Missing "pattern" query parameter' });
return;
}
- // ReDoS protection: reject overly long or dangerous patterns
+ // Length cap: applies to both literal and regex modes as a defense-in-depth
+ // bound against pathological input.
if (pattern.length > 200) {
res.status(400).json({ error: 'Pattern too long (max 200 characters)' });
return;
}
- // Validate regex syntax
+ // Treat user input as a literal substring in all cases to prevent
+ // regex-injection/ReDoS via attacker-controlled regex syntax.
+ const effectivePattern = escapeRegExp(pattern);
+
+ // Validate regex syntax (catches both opt-in user regex and any escapeRegExp bug)
let regex: RegExp;
try {
- regex = new RegExp(pattern, 'gim');
+ regex = new RegExp(effectivePattern, 'gim');
} catch {
res.status(400).json({ error: 'Invalid regex pattern' });
return;
@@ -1182,7 +1288,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
res.json({ results });
} catch (err: any) {
- res.status(500).json({ error: err.message || 'Grep failed' });
+ res.status(statusFromError(err)).json({ error: err.message || 'Grep failed' });
}
});
@@ -1253,7 +1359,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
// ── Analyze API ──────────────────────────────────────────────────────
// POST /api/analyze — start a new analysis job
- app.post('/api/analyze', async (req, res) => {
+ app.post('/api/analyze', createRouteLimiter({ limit: 10 }), async (req, res) => {
try {
const { url: repoUrl, path: repoLocalPath, force, embeddings, dropEmbeddings } = req.body;
@@ -1520,7 +1626,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
const embedJobManager = new JobManager();
// POST /api/embed — trigger server-side embedding generation
- app.post('/api/embed', async (req, res) => {
+ app.post('/api/embed', createRouteLimiter({ limit: 20 }), async (req, res) => {
try {
const entry = await resolveRepo(requestedRepo(req));
if (!entry) {
diff --git a/gitnexus/src/server/git-clone.ts b/gitnexus/src/server/git-clone.ts
index 0f7bc2653..b1f369dd7 100644
--- a/gitnexus/src/server/git-clone.ts
+++ b/gitnexus/src/server/git-clone.ts
@@ -11,16 +11,47 @@ import os from 'os';
import fs from 'fs/promises';
import { isIP } from 'net';
-/** Extract the repository name from a git URL (HTTPS or SSH). */
+/** Root directory for all cloned repositories. Targets must resolve inside this. */
+const CLONE_ROOT = path.resolve(path.join(os.homedir(), '.gitnexus', 'repos'));
+
+// A valid git repository name is filesystem-safe: alphanumerics plus `. _ -`.
+// Rejecting anything else (including `..`, `/`, `\`, shell metacharacters)
+// guarantees getCloneDir(repoName) cannot escape CLONE_ROOT regardless of
+// how the caller derived repoName.
+const REPO_NAME_PATTERN = /^[a-zA-Z0-9._-]+$/;
+
+/**
+ * Extract the repository name from a git URL (HTTPS or SSH).
+ *
+ * Throws if the URL does not yield a filesystem-safe last segment. A name
+ * like `..` or `foo/bar` would otherwise let `getCloneDir(name)` escape the
+ * clone root via path traversal.
+ */
export function extractRepoName(url: string): string {
- const cleaned = url.replace(/\/+$/, '');
- const lastSegment = cleaned.split(/[/:]/).pop() || 'unknown';
- return lastSegment.replace(/\.git$/, '');
+ // Strip trailing slashes without a regex to avoid polynomial-ReDoS on
+ // pathological inputs like `https://x.com/y` + '/'.repeat(1e6). CodeQL's
+ // js/polynomial-redos flagged `/\/+$/` here.
+ let end = url.length;
+ while (end > 0 && url.charCodeAt(end - 1) === 47 /* '/' */) end--;
+ const cleaned = url.slice(0, end);
+
+ const lastSegment = cleaned.split(/[/:]/).pop() || '';
+ const stripped = lastSegment.endsWith('.git') ? lastSegment.slice(0, -4) : lastSegment;
+
+ if (!stripped || stripped === '.' || stripped === '..' || !REPO_NAME_PATTERN.test(stripped)) {
+ throw new Error('Could not extract a valid repository name from URL');
+ }
+ return stripped;
}
/** Get the clone target directory for a repo name. */
export function getCloneDir(repoName: string): string {
- return path.join(os.homedir(), '.gitnexus', 'repos', repoName);
+ // Re-validate at the boundary even though extractRepoName already checked —
+ // callers may pass a repoName from another source (test fixtures, scripts).
+ if (!repoName || repoName === '.' || repoName === '..' || !REPO_NAME_PATTERN.test(repoName)) {
+ throw new Error('Invalid repository name');
+ }
+ return path.join(CLONE_ROOT, repoName);
}
// Cloud metadata hostnames that must never be reachable via user-supplied URLs
@@ -139,6 +170,39 @@ function assertNotPrivateIPv6(ip: string): void {
if (lower.includes(':ffff:')) {
throw new Error('Cloning from private/internal addresses is not allowed');
}
+
+ // IPv4-compatible IPv6 (RFC 4291 § 2.5.5.1, deprecated form: ::w.x.y.z).
+ // Node's URL parser collapses http://[::127.0.0.1]/ to "::7f00:1" — the IPv4
+ // is hidden in the last 32 bits without the ::ffff: marker, so the check
+ // above misses it. The form is still routable to the embedded IPv4 on most
+ // network stacks, so any address compressed to ::xxxx[:yyyy] must be blocked.
+ if (/^::[0-9a-f]{1,4}(:[0-9a-f]{1,4})?$/.test(lower)) {
+ throw new Error('Cloning from private/internal addresses is not allowed');
+ }
+
+ // NAT64 well-known prefix (RFC 6052 § 2.1: 64:ff9b::/96, plus the local
+ // 64:ff9b:1::/48 from RFC 8215). Maps any IPv4 address — including private
+ // ranges — into IPv6, so a host with NAT64 can reach the embedded IPv4 via
+ // e.g. 64:ff9b::7f00:1 → 127.0.0.1.
+ // The check intentionally covers the full 64:ff9b::/32 block (broader than
+ // the two cited ranges): IANA reserves it for IPv4-IPv6 translation, so
+ // blocking the whole prefix is defensively sound and prevents a narrower
+ // CIDR check from quietly re-opening the bypass for 64:ff9b:1::/48 or any
+ // future translation assignment.
+ if (lower.startsWith('64:ff9b:')) {
+ throw new Error('Cloning from private/internal addresses is not allowed');
+ }
+
+ // 6to4 (RFC 3056, 2002::/16). Encodes an IPv4 address in bits 17-48, so
+ // 2002:7f00:0001::1 routes to 127.0.0.1 on 6to4-capable stacks. The
+ // protocol was deprecated by RFC 7526 and the public relay anycast
+ // (192.88.99.1) has been retired, so broad-blocking the prefix has near-
+ // zero false-positive cost while closing the IPv4-embedded bypass.
+ // Teredo (2001::/32) embeds IPv4 obfuscated by XOR; precise blocking is
+ // impractical and is out of scope here.
+ if (lower.startsWith('2002:')) {
+ throw new Error('Cloning from private/internal addresses is not allowed');
+ }
}
function assertNotPrivateIPv4(ip: string): void {
@@ -163,32 +227,200 @@ export interface CloneProgress {
message: string;
}
+/**
+ * Build the `git clone` argument list for a given URL and target directory.
+ *
+ * The `--` separator is non-negotiable: it stops git from parsing a URL that
+ * starts with `--` (e.g. `--upload-pack=evil`) as an option flag, which would
+ * otherwise execute an attacker-chosen subprocess (CodeQL
+ * js/second-order-command-line-injection, alerts #166/#167).
+ *
+ * Exported so the separator placement is testable without mocking spawn.
+ */
+export function buildCloneArgs(url: string, targetDir: string): string[] {
+ return ['clone', '--depth', '1', '--', url, targetDir];
+}
+
+/**
+ * Normalize a git URL into a comparable form.
+ *
+ * Two URLs are considered the same repository when their normalized forms
+ * are identical: lowercased hostname, no trailing `.git`, no trailing
+ * slashes on the path, default port stripped. Path comparison stays
+ * case-sensitive because that's how Git hosts treat the path component on
+ * the wire (case-folding GitHub's web UI is a separate convenience).
+ *
+ * Returns the original input if URL parsing fails — the caller can still
+ * compare with the literal string for non-URL forms (e.g. SSH `git@host:`).
+ */
+export function normalizeGitUrlForCompare(url: string): string {
+ // Strip trailing slashes and a trailing `.git` for both URL and SSH forms.
+ let trimmed = url;
+ while (trimmed.length > 0 && trimmed[trimmed.length - 1] === '/') {
+ trimmed = trimmed.slice(0, -1);
+ }
+ if (trimmed.endsWith('.git')) trimmed = trimmed.slice(0, -4);
+
+ try {
+ const parsed = new URL(trimmed);
+ parsed.hostname = parsed.hostname.toLowerCase();
+ // strip default ports
+ if (
+ (parsed.protocol === 'https:' && parsed.port === '443') ||
+ (parsed.protocol === 'http:' && parsed.port === '80')
+ ) {
+ parsed.port = '';
+ }
+ // Strip credentials — never material to repo identity, and including
+ // them would let two equivalent URLs (with/without basic auth) compare
+ // unequal.
+ parsed.username = '';
+ parsed.password = '';
+ // Recompose without trailing slash on the path.
+ let pathname = parsed.pathname;
+ while (pathname.length > 1 && pathname[pathname.length - 1] === '/') {
+ pathname = pathname.slice(0, -1);
+ }
+ parsed.pathname = pathname;
+ return `${parsed.protocol}//${parsed.hostname}${parsed.port ? ':' + parsed.port : ''}${parsed.pathname}`;
+ } catch {
+ // Non-URL forms (e.g. `git@github.com:owner/repo`) — return the trimmed
+ // form lowercased on the hostname-ish prefix. SSH-form normalization
+ // is best-effort; exact-string compare is sufficient for the threat
+ // model (mismatched origins still differ at the literal level).
+ return trimmed.toLowerCase();
+ }
+}
+
+/**
+ * Read `remote.origin.url` from an existing clone using `git config --get`.
+ *
+ * Returns `null` if the config key is absent, the spawn fails, or the
+ * directory isn't a git repository. The caller decides what a missing
+ * remote means for its threat model — for cloneOrPull, a missing remote
+ * on an existing clone is treated as a refuse-to-pull condition.
+ */
+export function getRemoteOriginUrl(cwd: string): Promise {
+ return new Promise((resolve) => {
+ const proc = spawn('git', ['config', '--get', 'remote.origin.url'], {
+ cwd,
+ stdio: ['ignore', 'pipe', 'pipe'],
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
+ });
+ let stdout = '';
+ proc.stdout.on('data', (chunk: Buffer) => {
+ stdout += chunk;
+ });
+ proc.on('close', (code) => {
+ if (code === 0 && stdout.trim()) {
+ resolve(stdout.trim());
+ } else {
+ resolve(null);
+ }
+ });
+ proc.on('error', () => resolve(null));
+ });
+}
+
+/**
+ * Verify that an existing clone's `remote.origin.url` matches the requested
+ * URL (after normalization). Throws on mismatch or missing remote.
+ *
+ * Closes the wrong-repo silent-analysis vector that Codex's adversarial
+ * review on PR #1325 surfaced: clone dirs are keyed by URL basename, so a
+ * request for `https://gitlab.example/attacker/repo.git` would otherwise
+ * collide with an existing `~/.gitnexus/repos/repo` cloned from a different
+ * origin and `git pull --ff-only` would silently succeed against the wrong
+ * remote.
+ *
+ * Exported so the comparison logic is testable in isolation against any
+ * tmpdir-based fixture, without needing to populate CLONE_ROOT.
+ */
+export async function assertRemoteMatchesRequestedUrl(
+ targetDir: string,
+ requestedUrl: string,
+): Promise {
+ const remoteUrl = await getRemoteOriginUrl(targetDir);
+ if (remoteUrl === null) {
+ throw new Error(`Existing clone at ${targetDir} has no remote.origin — refusing to pull`);
+ }
+ if (normalizeGitUrlForCompare(remoteUrl) !== normalizeGitUrlForCompare(requestedUrl)) {
+ throw new Error(
+ `Existing clone at ${targetDir} has remote ${remoteUrl}, not the requested URL ${requestedUrl}`,
+ );
+ }
+}
+
/**
* Clone or pull a git repository.
* If targetDir doesn't exist: git clone --depth 1
- * If targetDir exists with .git: git pull --ff-only
+ * If targetDir exists with .git: git pull --ff-only (after verifying the
+ * existing clone's remote.origin matches the requested URL).
+ *
+ * Security:
+ * - targetDir must resolve inside CLONE_ROOT (~/.gitnexus/repos/). The
+ * path.relative containment barrier below is the inline canonical idiom
+ * CodeQL's js/path-injection sanitizer recognizes.
+ * - validateGitUrl runs unconditionally on the requested URL — both the
+ * clone path and the pull path. An earlier shape only validated on the
+ * clone branch; an existing clone with the same basename let an
+ * attacker's URL skip the SSRF / scheme / private-IP checks (Codex
+ * adversarial review on PR #1325).
+ * - When the target already has `.git`, the existing clone's
+ * remote.origin.url is fetched and compared (normalized) to the
+ * requested URL. Refuses to pull if they differ — this closes the
+ * wrong-repo silent-analysis vector where two URLs sharing a basename
+ * would collide on the same on-disk clone dir.
+ * - The git URL is passed after a `--` separator so a value beginning with
+ * `--` (e.g. `--upload-pack=evil`) cannot be interpreted as a git option
+ * (CodeQL js/second-order-command-line-injection).
*/
export async function cloneOrPull(
url: string,
targetDir: string,
onProgress?: (progress: CloneProgress) => void,
): Promise {
- const exists = await fs.access(path.join(targetDir, '.git')).then(
+ // Containment barrier — inline with the canonical path.relative idiom so
+ // CodeQL recognizes the sanitizer at every following filesystem and
+ // subprocess sink. The same `safeTarget` is used for every downstream
+ // path operation — no reassignment that the analyzer could lose track of.
+ //
+ // Limitation: this is a lexical containment check, not a realpath check.
+ // If an attacker can place a symlink under CLONE_ROOT pointing outside it,
+ // the lexical check passes but the clone lands at the symlink target. That
+ // requires pre-existing local write access to CLONE_ROOT, so the threat
+ // model considers it out of scope; CodeQL js/path-injection accepts the
+ // lexical form. Tracked as a follow-up if defense-in-depth is needed.
+ const safeTarget = path.resolve(targetDir);
+ const rel = path.relative(CLONE_ROOT, safeTarget);
+ if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) {
+ throw new Error(`Clone target must be a subdirectory of ${CLONE_ROOT}`);
+ }
+
+ // Always validate the requested URL — the prior shape only ran this in
+ // the clone branch, leaving the pull branch as an SSRF / blocked-host
+ // bypass when an existing clone shared the basename of an attacker URL.
+ validateGitUrl(url);
+
+ const exists = await fs.access(path.join(safeTarget, '.git')).then(
() => true,
() => false,
);
if (exists) {
+ // Confirm the existing clone is actually the same repository the caller
+ // requested. Without this check, a pull would silently succeed against
+ // whatever remote the dir was originally cloned from.
+ await assertRemoteMatchesRequestedUrl(safeTarget, url);
onProgress?.({ phase: 'pulling', message: 'Pulling latest changes...' });
- await runGit(['pull', '--ff-only'], targetDir);
+ await runGit(['pull', '--ff-only'], safeTarget);
} else {
- validateGitUrl(url);
- await fs.mkdir(path.dirname(targetDir), { recursive: true });
+ await fs.mkdir(path.dirname(safeTarget), { recursive: true });
onProgress?.({ phase: 'cloning', message: `Cloning ${url}...` });
- await runGit(['clone', '--depth', '1', url, targetDir]);
+ await runGit(buildCloneArgs(url, safeTarget));
}
- return targetDir;
+ return safeTarget;
}
function runGit(args: string[], cwd?: string): Promise {
diff --git a/gitnexus/src/server/validation.ts b/gitnexus/src/server/validation.ts
new file mode 100644
index 000000000..54bf73d4f
--- /dev/null
+++ b/gitnexus/src/server/validation.ts
@@ -0,0 +1,158 @@
+/**
+ * Server-side input validation helpers.
+ *
+ * Convention: helpers throw BadRequestError (or its 403 subclass ForbiddenError)
+ * when user input fails validation. Existing route handlers wrap their bodies in
+ * try/catch and translate the error to res.status(err.status).json({error: err.message}).
+ * This pattern was chosen over an asyncHandler middleware to stay compatible with
+ * Express 4's non-propagation of async-thrown errors and to match the existing
+ * try/catch shape used throughout api.ts.
+ *
+ * Scope (this PR — U1 of the security remediation plan):
+ * - assertString: closes js/type-confusion-through-parameter-tampering (api.ts:1118)
+ * - assertSafePath: consolidates the path-traversal guard from api.ts:1067-1077
+ * for reuse across other path-injection findings (U2/U3)
+ * - escapeRegExp: utility for upcoming regex-injection fix at /api/grep (U5)
+ *
+ * Helpers added in later units (U3 git-clone hardening, U4 rate-limiting) live
+ * in this module too but are introduced with the dependency they require.
+ */
+
+import path from 'node:path';
+import rateLimit, { type RateLimitRequestHandler } from 'express-rate-limit';
+import type { Request } from 'express';
+
+/**
+ * Thrown by validation helpers when user input is rejected.
+ * Routes catch via existing try/catch and convert with err.status / err.message.
+ */
+export class BadRequestError extends Error {
+ readonly status: number;
+ constructor(message: string, status = 400) {
+ super(message);
+ this.name = 'BadRequestError';
+ this.status = status;
+ }
+}
+
+export class ForbiddenError extends BadRequestError {
+ constructor(message: string) {
+ super(message, 403);
+ this.name = 'ForbiddenError';
+ }
+}
+
+/**
+ * Type guard for HTTP request parameters that must be a single string.
+ *
+ * Express's req.query and req.body parsers return `string | string[] | ParsedQs`
+ * for any field, but route handlers commonly cast to `string` and operate on
+ * `.length`. When the caller passes the same key twice (?x=a&x=b) the value
+ * arrives as an array, and a `.length` check intended for the string ends up
+ * counting array elements — bypassing length-based guards (CodeQL
+ * js/type-confusion-through-parameter-tampering, alert at api.ts:1118).
+ *
+ * @throws BadRequestError when value is not a string (array, object, undefined, etc.)
+ */
+export function assertString(value: unknown, fieldName: string): string {
+ if (typeof value !== 'string') {
+ if (Array.isArray(value)) {
+ throw new BadRequestError(`Parameter "${fieldName}" must be a single string, got an array`);
+ }
+ throw new BadRequestError(`Parameter "${fieldName}" must be a string`);
+ }
+ return value;
+}
+
+/**
+ * Resolve a user-supplied relative path against an allowed root and verify it
+ * stays inside that root. Mirrors the existing guard at api.ts:1067-1077.
+ *
+ * Returns the absolute resolved path. Rejects empty paths, null bytes, and
+ * paths that resolve outside the root (e.g., `../../../etc/passwd`).
+ *
+ * @throws BadRequestError when the path is empty or contains a null byte
+ * @throws ForbiddenError when the resolved path escapes the root
+ */
+export function assertSafePath(rawPath: string, root: string): string {
+ if (rawPath.length === 0) {
+ throw new BadRequestError('Path must not be empty');
+ }
+ if (rawPath.includes('\0')) {
+ throw new BadRequestError('Path must not contain null bytes');
+ }
+ const resolvedRoot = path.resolve(root);
+ const fullPath = path.resolve(resolvedRoot, rawPath);
+ if (fullPath !== resolvedRoot && !fullPath.startsWith(resolvedRoot + path.sep)) {
+ throw new ForbiddenError('Path traversal denied');
+ }
+ return fullPath;
+}
+
+/**
+ * Escape regex metacharacters in a user-supplied string so it can be safely
+ * embedded as a literal in `new RegExp(...)`. Used by /api/grep's literal mode
+ * and any future endpoint that constructs a regex from caller input.
+ */
+export function escapeRegExp(input: string): string {
+ return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
+/**
+ * Default rate-limit policy for FS-touching API routes (CodeQL
+ * js/missing-rate-limiting). Tuned for the local-bound HTTP server's expected
+ * traffic — interactive web UI use stays well under the limit; abusive loops
+ * trip 429.
+ *
+ * Module-internal — not exported. Tests assert the observable behavior
+ * (61st request returns 429), not the literal value, so callers don't grow
+ * a coupling on this number.
+ */
+const DEFAULT_RATE_LIMIT_RPM = 60;
+
+/**
+ * Project-specific subset of express-rate-limit options that callers may
+ * override. Intentionally narrow — `Partial` would let a
+ * caller pass `{ skip: () => true }` and silently disable limiting on a
+ * route. The two knobs below are sufficient for tests and any future
+ * legitimate per-route tuning.
+ */
+export interface RouteLimiterOverrides {
+ windowMs?: number;
+ /** Canonical name in express-rate-limit v8+. `max` is the deprecated alias. */
+ limit?: number;
+}
+
+/**
+ * Build a per-route rate-limit middleware with project-uniform defaults.
+ *
+ * Each call returns a NEW limiter instance — independent counters per route,
+ * so /api/file traffic doesn't push /api/grep into 429.
+ *
+ * Defaults:
+ * - 60 requests per IP per minute
+ * - draft-7 RateLimit-* response headers (no legacy X-RateLimit-* headers)
+ * - 429 with a JSON body matching the project's `{ error: '...' }` shape
+ * - passOnStoreError: store failures let the request through rather than
+ * producing an HTML 500 from Express's default error handler
+ * - keyGenerator: req.ip with a socket.remoteAddress fallback so abruptly
+ * closed connections do not trigger ERR_ERL_UNDEFINED_IP_ADDRESS
+ * (which would 500 the request via Express's default error handler).
+ * Caller must wire `app.set('trust proxy', ...)` correctly — see
+ * createServer in api.ts.
+ *
+ * Tests pass `{ windowMs: 100, limit: 3 }` to keep limiter tests fast and
+ * deterministic.
+ */
+export function createRouteLimiter(opts?: RouteLimiterOverrides): RateLimitRequestHandler {
+ return rateLimit({
+ windowMs: 60 * 1000,
+ limit: DEFAULT_RATE_LIMIT_RPM,
+ standardHeaders: 'draft-7',
+ legacyHeaders: false,
+ passOnStoreError: true,
+ keyGenerator: (req: Request) => req.ip ?? req.socket?.remoteAddress ?? 'unknown',
+ message: { error: 'Too many requests, please try again later.' },
+ ...opts,
+ });
+}
diff --git a/gitnexus/src/storage/git.ts b/gitnexus/src/storage/git.ts
index 9105a4da3..fe1b8511d 100644
--- a/gitnexus/src/storage/git.ts
+++ b/gitnexus/src/storage/git.ts
@@ -94,6 +94,80 @@ export const getGitRoot = (fromPath: string): string | null => {
}
};
+/**
+ * Get the *canonical* repository root, dereferencing git worktrees.
+ *
+ * Unlike `getGitRoot` (which uses `git rev-parse --show-toplevel` and
+ * returns the WORKTREE's root when called inside a linked worktree),
+ * this uses `git rev-parse --git-common-dir` — the shared `.git`
+ * directory, identical for the main checkout and every linked
+ * worktree — and returns its parent.
+ *
+ * Why it matters (#1259): when `gitnexus analyze` runs inside a
+ * worktree (e.g. `/repo/wt-feature/`), deriving `repoName` from
+ * `path.basename(getGitRoot(cwd))` registers the project under the
+ * worktree's directory slug (`wt-feature`) instead of the canonical
+ * repo's basename (`repo`). Each worktree then re-registers as a
+ * "different" project, AGENTS.md is rewritten with the wrong MCP URI,
+ * and Claude-Code-style worktree workflows silently accumulate
+ * duplicate registry entries.
+ *
+ * Returns `null` when the path is not inside a git repository or
+ * `git` is not available, so callers can chain safely:
+ * `getCanonicalRepoRoot(p) ?? getGitRoot(p) ?? p`.
+ *
+ * `--path-format=absolute` is required because `--git-common-dir`
+ * returns a path *relative to cwd* by default (e.g. `../.git` when
+ * called from a worktree), which would resolve to the wrong absolute
+ * path if the caller later resolved it from a different directory.
+ */
+export const getCanonicalRepoRoot = (fromPath: string): string | null => {
+ try {
+ const commonDir = execSync('git rev-parse --path-format=absolute --git-common-dir', {
+ cwd: fromPath,
+ stdio: ['ignore', 'pipe', 'ignore'],
+ })
+ .toString()
+ .trim();
+ if (!commonDir) return null;
+ // Common dir is `/.git` for both the main checkout and all
+ // linked worktrees. Its parent is the canonical repo root.
+ return path.dirname(path.resolve(commonDir));
+ } catch {
+ return null;
+ }
+};
+
+/**
+ * Resolve `fromPath` to the directory whose basename should drive the
+ * registry name (#1259) — the *identity root*. Three outcomes:
+ *
+ * 1. `fromPath` IS the canonical checkout root → returns it unchanged.
+ * 2. `fromPath` is a linked-worktree root (has its own `.git` entry, but
+ * `git rev-parse --git-common-dir` points at a different `.git`) →
+ * returns the canonical repo root.
+ * 3. `fromPath` is anything else — an arbitrary subdir under a git repo,
+ * a non-git folder, a `--skip-git` subdir of an unrelated parent
+ * checkout — returns `fromPath` unchanged.
+ *
+ * Why not just use `getCanonicalRepoRoot` directly? Because `git rev-parse
+ * --git-common-dir` resolves the same canonical root for ANY path inside
+ * a git repo, including unrelated subdirs. Using it for registry-name
+ * derivation would silently re-key a `--skip-git` subdir analyze under
+ * the parent git's basename, defeating the user's `--skip-git` intent
+ * (regressing the #1232/#1233 fix). The "is this path a tree root"
+ * gate confines the canonical-root collapse to exactly the cases where
+ * #1259 matters: main checkouts and linked worktrees.
+ */
+export const resolveRepoIdentityRoot = (fromPath: string): string => {
+ const resolved = path.resolve(fromPath);
+ const canonical = getCanonicalRepoRoot(resolved);
+ if (!canonical) return resolved; // non-git → use as-is
+ if (canonical === resolved) return canonical; // canonical checkout
+ if (hasGitDir(resolved)) return canonical; // linked worktree (has .git file)
+ return resolved; // arbitrary subdir under a git repo → preserve as-is
+};
+
/**
* Find a git root by checking only `.git` entries on the ancestor chain.
*
diff --git a/gitnexus/src/storage/repo-manager.ts b/gitnexus/src/storage/repo-manager.ts
index 9741f54b4..8c0bda95f 100644
--- a/gitnexus/src/storage/repo-manager.ts
+++ b/gitnexus/src/storage/repo-manager.ts
@@ -10,7 +10,7 @@ import fs from 'fs/promises';
import { realpathSync } from 'fs';
import path from 'path';
import os from 'os';
-import { getInferredRepoName } from './git.js';
+import { getInferredRepoName, resolveRepoIdentityRoot } from './git.js';
/**
* Normalise a repo path for registry comparison across platforms
@@ -389,6 +389,17 @@ export class RegistryNameCollisionError extends Error {
const hasCustomAlias = (entry: RegistryEntry, inferredName: string | null): boolean => {
const resolved = path.resolve(entry.path);
if (entry.name === path.basename(resolved)) return false;
+ // Canonical-root-derived names are not user aliases either (#1259):
+ // a worktree registered under the canonical repo's basename
+ // (e.g. `{name: 'repo', path: '/repo/wt-feature'}`) must re-register
+ // cleanly without firing the duplicate-name collision guard. Without
+ // this check `entry.name = 'repo'` !== `path.basename('/repo/wt-feature') = 'wt-feature'`,
+ // so the prior check returns true → `isPreservedAlias = true` → guard
+ // throws `RegistryNameCollisionError` against the also-registered
+ // canonical checkout entry. The Claude-Code per-task worktree workflow
+ // — analyze canonical, then analyze worktree, then re-analyze worktree
+ // — would break on the third call.
+ if (entry.name === path.basename(resolveRepoIdentityRoot(resolved))) return false;
if (inferredName && entry.name === inferredName) return false;
return true;
};
@@ -470,7 +481,13 @@ export const registerRepo = async (
name = existing.name;
isPreservedAlias = true;
} else {
- name = inferred ?? path.basename(resolved);
+ // Canonical-root fallback: when `resolved` is a worktree root,
+ // derive the registry name from the canonical repo's basename, not
+ // the worktree slug — see #1259. `resolveRepoIdentityRoot` confines
+ // the collapse to canonical checkouts and linked worktree roots only,
+ // so `--skip-git` subdirs of unrelated parent git repos keep using
+ // their own basename (preserves the #1232/#1233 fix's intent).
+ name = inferred ?? path.basename(resolveRepoIdentityRoot(resolved));
}
}
diff --git a/gitnexus/test/fixtures/lang-resolution/go-aliased-package-import/go.mod b/gitnexus/test/fixtures/lang-resolution/go-aliased-package-import/go.mod
new file mode 100644
index 000000000..02d98452c
--- /dev/null
+++ b/gitnexus/test/fixtures/lang-resolution/go-aliased-package-import/go.mod
@@ -0,0 +1,3 @@
+module example.com/aliasimport
+
+go 1.21
diff --git a/gitnexus/test/fixtures/lang-resolution/go-aliased-package-import/internal/util/log.go b/gitnexus/test/fixtures/lang-resolution/go-aliased-package-import/internal/util/log.go
new file mode 100644
index 000000000..c9920227e
--- /dev/null
+++ b/gitnexus/test/fixtures/lang-resolution/go-aliased-package-import/internal/util/log.go
@@ -0,0 +1,3 @@
+package util
+
+func Log() {}
diff --git a/gitnexus/test/fixtures/lang-resolution/go-aliased-package-import/main.go b/gitnexus/test/fixtures/lang-resolution/go-aliased-package-import/main.go
new file mode 100644
index 000000000..d3a46469d
--- /dev/null
+++ b/gitnexus/test/fixtures/lang-resolution/go-aliased-package-import/main.go
@@ -0,0 +1,7 @@
+package main
+
+import util "example.com/aliasimport/internal/util"
+
+func main() {
+ util.Log()
+}
diff --git a/gitnexus/test/fixtures/lang-resolution/go-same-package-factory/go.mod b/gitnexus/test/fixtures/lang-resolution/go-same-package-factory/go.mod
new file mode 100644
index 000000000..70b6fc53d
--- /dev/null
+++ b/gitnexus/test/fixtures/lang-resolution/go-same-package-factory/go.mod
@@ -0,0 +1,3 @@
+module example.com/samefactory
+
+go 1.21
diff --git a/gitnexus/test/fixtures/lang-resolution/go-same-package-factory/main.go b/gitnexus/test/fixtures/lang-resolution/go-same-package-factory/main.go
new file mode 100644
index 000000000..d3878c76e
--- /dev/null
+++ b/gitnexus/test/fixtures/lang-resolution/go-same-package-factory/main.go
@@ -0,0 +1,10 @@
+package main
+
+func NewUser() *User {
+ return &User{}
+}
+
+func processUser() {
+ user := NewUser()
+ user.Save()
+}
diff --git a/gitnexus/test/fixtures/lang-resolution/go-same-package-factory/repo.go b/gitnexus/test/fixtures/lang-resolution/go-same-package-factory/repo.go
new file mode 100644
index 000000000..e760c880d
--- /dev/null
+++ b/gitnexus/test/fixtures/lang-resolution/go-same-package-factory/repo.go
@@ -0,0 +1,7 @@
+package main
+
+type Repo struct{}
+
+func (r *Repo) Save() bool {
+ return true
+}
diff --git a/gitnexus/test/fixtures/lang-resolution/go-same-package-factory/user.go b/gitnexus/test/fixtures/lang-resolution/go-same-package-factory/user.go
new file mode 100644
index 000000000..10a724c78
--- /dev/null
+++ b/gitnexus/test/fixtures/lang-resolution/go-same-package-factory/user.go
@@ -0,0 +1,7 @@
+package main
+
+type User struct{}
+
+func (u *User) Save() bool {
+ return true
+}
diff --git a/gitnexus/test/fixtures/lang-resolution/go-split-method-owner/go.mod b/gitnexus/test/fixtures/lang-resolution/go-split-method-owner/go.mod
new file mode 100644
index 000000000..d7c722bd7
--- /dev/null
+++ b/gitnexus/test/fixtures/lang-resolution/go-split-method-owner/go.mod
@@ -0,0 +1,3 @@
+module example.com/splitowner
+
+go 1.21
diff --git a/gitnexus/test/fixtures/lang-resolution/go-split-method-owner/main.go b/gitnexus/test/fixtures/lang-resolution/go-split-method-owner/main.go
new file mode 100644
index 000000000..c96ec3516
--- /dev/null
+++ b/gitnexus/test/fixtures/lang-resolution/go-split-method-owner/main.go
@@ -0,0 +1,6 @@
+package main
+
+func process() {
+ user := User{}
+ user.Save()
+}
diff --git a/gitnexus/test/fixtures/lang-resolution/go-split-method-owner/repo.go b/gitnexus/test/fixtures/lang-resolution/go-split-method-owner/repo.go
new file mode 100644
index 000000000..e760c880d
--- /dev/null
+++ b/gitnexus/test/fixtures/lang-resolution/go-split-method-owner/repo.go
@@ -0,0 +1,7 @@
+package main
+
+type Repo struct{}
+
+func (r *Repo) Save() bool {
+ return true
+}
diff --git a/gitnexus/test/fixtures/lang-resolution/go-split-method-owner/save.go b/gitnexus/test/fixtures/lang-resolution/go-split-method-owner/save.go
new file mode 100644
index 000000000..c6f550ef8
--- /dev/null
+++ b/gitnexus/test/fixtures/lang-resolution/go-split-method-owner/save.go
@@ -0,0 +1,5 @@
+package main
+
+func (u *User) Save() bool {
+ return true
+}
diff --git a/gitnexus/test/fixtures/lang-resolution/go-split-method-owner/user.go b/gitnexus/test/fixtures/lang-resolution/go-split-method-owner/user.go
new file mode 100644
index 000000000..150e04c86
--- /dev/null
+++ b/gitnexus/test/fixtures/lang-resolution/go-split-method-owner/user.go
@@ -0,0 +1,3 @@
+package main
+
+type User struct{}
diff --git a/gitnexus/test/integration/resolvers/go.test.ts b/gitnexus/test/integration/resolvers/go.test.ts
index f9a57d660..2a9b637ca 100644
--- a/gitnexus/test/integration/resolvers/go.test.ts
+++ b/gitnexus/test/integration/resolvers/go.test.ts
@@ -1,11 +1,12 @@
/**
* Go: package imports + cross-package calls + ambiguous struct disambiguation
*/
-import { describe, it, expect, beforeAll } from 'vitest';
+import { describe, expect, beforeAll } from 'vitest';
import path from 'path';
import {
FIXTURES,
CROSS_FILE_FIXTURES,
+ createResolverParityIt,
getRelationships,
getNodesByLabel,
getNodesByLabelFull,
@@ -14,6 +15,8 @@ import {
type PipelineResult,
} from './helpers.js';
+const it = createResolverParityIt('go');
+
// ---------------------------------------------------------------------------
// Heritage: package imports + cross-package calls (exercises PackageMap)
// ---------------------------------------------------------------------------
@@ -610,6 +613,30 @@ describe('Go return type inference via explicit function return type', () => {
});
});
+describe('Go same-package factory return type inference', () => {
+ let result: PipelineResult;
+
+ beforeAll(async () => {
+ result = await runPipelineFromRepo(path.join(FIXTURES, 'go-same-package-factory'), () => {});
+ }, 60000);
+
+ it('resolves user.Save() through same-package NewUser() return type', () => {
+ const calls = getRelationships(result, 'CALLS');
+ const userSave = calls.find(
+ (c) => c.target === 'Save' && c.source === 'processUser' && c.targetFilePath === 'user.go',
+ );
+ expect(userSave).toBeDefined();
+ });
+
+ it('does not resolve user.Save() to Repo.Save', () => {
+ const calls = getRelationships(result, 'CALLS');
+ const repoSave = calls.find(
+ (c) => c.target === 'Save' && c.source === 'processUser' && c.targetFilePath === 'repo.go',
+ );
+ expect(repoSave).toBeUndefined();
+ });
+});
+
// ---------------------------------------------------------------------------
// Go multi-return factory inference: user, err := NewUser("alice"); user.Save()
// ---------------------------------------------------------------------------
@@ -1261,6 +1288,47 @@ describe('Go cross-file binding propagation', () => {
});
});
+describe('Go aliased package selector resolution', () => {
+ let result: PipelineResult;
+
+ beforeAll(async () => {
+ result = await runPipelineFromRepo(path.join(FIXTURES, 'go-aliased-package-import'), () => {});
+ }, 60000);
+
+ it('resolves util.Log() through an aliased package import', () => {
+ const calls = getRelationships(result, 'CALLS');
+ const logCall = calls.find(
+ (c) =>
+ c.target === 'Log' && c.source === 'main' && c.targetFilePath === 'internal/util/log.go',
+ );
+ expect(logCall).toBeDefined();
+ });
+});
+
+describe('Go method owner resolution across package files', () => {
+ let result: PipelineResult;
+
+ beforeAll(async () => {
+ result = await runPipelineFromRepo(path.join(FIXTURES, 'go-split-method-owner'), () => {});
+ }, 60000);
+
+ it('resolves user.Save() to the method whose receiver type is declared in another package file', () => {
+ const calls = getRelationships(result, 'CALLS');
+ const userSave = calls.find(
+ (c) => c.target === 'Save' && c.source === 'process' && c.targetFilePath === 'save.go',
+ );
+ expect(userSave).toBeDefined();
+ });
+
+ it('does not resolve user.Save() to Repo.Save', () => {
+ const calls = getRelationships(result, 'CALLS');
+ const repoSave = calls.find(
+ (c) => c.target === 'Save' && c.source === 'process' && c.targetFilePath === 'repo.go',
+ );
+ expect(repoSave).toBeUndefined();
+ });
+});
+
// ---------------------------------------------------------------------------
// Go cmd/ helper files should NOT get entry-point multiplier (P0-1 fix)
// Only main.go files should get the 3.0 entry-point boost, not arbitrary
diff --git a/gitnexus/test/integration/resolvers/helpers.ts b/gitnexus/test/integration/resolvers/helpers.ts
index 427fd7e5f..5368c1715 100644
--- a/gitnexus/test/integration/resolvers/helpers.ts
+++ b/gitnexus/test/integration/resolvers/helpers.ts
@@ -12,6 +12,13 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly Models/User.cs through the scope-resolution path',
]),
+ go: new Set([
+ // The legacy DAG path does not resolve method calls when the method is
+ // defined in a different file from the receiver type (go-split-method-owner
+ // fixture). This requires scope-based cross-file package-sibling resolution
+ // which is only available in the registry-primary path.
+ 'resolves user.Save() to the method whose receiver type is declared in another package file',
+ ]),
python: new Set([
// Suffix-fallback lex tiebreak depends on the registry-primary
// resolver's deterministic sort. The legacy resolver returns the
diff --git a/gitnexus/test/integration/worker-pool.test.ts b/gitnexus/test/integration/worker-pool.test.ts
index 5a7821949..eb0baebdf 100644
--- a/gitnexus/test/integration/worker-pool.test.ts
+++ b/gitnexus/test/integration/worker-pool.test.ts
@@ -155,7 +155,7 @@ describe('worker pool integration', () => {
}).toThrow(/Worker script not found/);
});
- // ─── Unhappy paths ──────────────────────────────────────────────────
+ // --- Unhappy paths -----------------------------------------------------
it.skipIf(!hasDistWorker)('dispatch after terminate rejects', async () => {
const workerUrl = pathToFileURL(DIST_WORKER) as URL;
@@ -473,6 +473,63 @@ describe('worker pool integration', () => {
}
});
+ it('completes split-and-retry when the timed-out worker is the only active worker', async () => {
+ // Regression test for: the split-and-retry path resolving early when no other
+ // workers are active (activeWorkers === 0 during await replaceWorker).
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-worker-sole-active-'));
+ const markerPath = path.join(tempDir, 'stalled-once.txt');
+ const workerPath = path.join(tempDir, 'worker.js');
+ fs.writeFileSync(
+ workerPath,
+ `
+ const fs = require('node:fs');
+ const { parentPort } = require('node:worker_threads');
+ const markerPath = ${JSON.stringify(markerPath)};
+ let current = [];
+ parentPort.on('message', (msg) => {
+ if (msg && msg.type === 'sub-batch') {
+ current = msg.files.map((file) => file.path);
+ if (current.length > 1 && !fs.existsSync(markerPath)) {
+ fs.writeFileSync(markerPath, 'stall once');
+ return;
+ }
+ parentPort.postMessage({ type: 'progress', filesProcessed: current.length });
+ parentPort.postMessage({ type: 'sub-batch-done' });
+ return;
+ }
+ if (msg && msg.type === 'flush') {
+ parentPort.postMessage({ type: 'result', data: { fileCount: current.length, paths: current } });
+ }
+ });
+ `,
+ );
+
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
+ // 2 workers but subBatchSize=4 means all 4 items form 1 job; second worker stays idle.
+ pool = createWorkerPool(pathToFileURL(workerPath) as URL, 2, {
+ subBatchSize: 4,
+ subBatchIdleTimeoutMs: 300,
+ maxTimeoutRetries: 0,
+ timeoutBackoffFactor: 3,
+ });
+
+ try {
+ const results = await pool.dispatch([
+ { path: 'a.ts', content: '' },
+ { path: 'b.ts', content: '' },
+ { path: 'c.ts', content: '' },
+ { path: 'd.ts', content: '' },
+ ]);
+
+ const allPaths = results.flatMap((r: any) => r.paths);
+ expect(allPaths.sort()).toEqual(['a.ts', 'b.ts', 'c.ts', 'd.ts']);
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Splitting into'));
+ } finally {
+ warnSpy.mockRestore();
+ fs.rmSync(tempDir, { recursive: true, force: true });
+ }
+ }, 15_000);
+
it('fails fast on a result message that violates the worker protocol', async () => {
const { tempDir, workerPath } = writeTempWorker(
'gitnexus-worker-protocol-',
diff --git a/gitnexus/test/unit/api-file-route.test.ts b/gitnexus/test/unit/api-file-route.test.ts
new file mode 100644
index 000000000..71713ff8b
--- /dev/null
+++ b/gitnexus/test/unit/api-file-route.test.ts
@@ -0,0 +1,130 @@
+/**
+ * Unit tests for the /api/file handler — handleFileRequest.
+ *
+ * Calls the handler directly with mock req/res rather than mounting it on
+ * an Express app and binding a port. This is an intentional design choice:
+ * - Mounting the handler via app.get(...) inside a test triggers CodeQL's
+ * js/missing-rate-limiting query, which is correct for production
+ * route handlers but a false positive on tests of the handler logic.
+ * - Direct invocation also runs faster (no port allocation, no listen)
+ * and exercises the same code path used in production via createServer.
+ *
+ * Covers the gaps the PR #1322 review identified:
+ * - `?path=a&path=b` (array form) returns 400, not 500 — proves the catch
+ * block correctly routes BadRequestError via statusFromError.
+ * - `?path=../../../etc/passwd` returns 403 — traversal rejection.
+ * - `?path=%2e%2e%2fsecret` (encoded traversal) returns 403 — Express
+ * decodes the query string before the handler sees it.
+ * - Valid relative path returns 200 with file content.
+ * - Missing path returns 400.
+ * - Common-prefix sibling escape returns 403 (the path.relative idiom
+ * catches what startsWith(root + sep) would have missed).
+ */
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import path from 'node:path';
+import fs from 'node:fs/promises';
+import os from 'node:os';
+import { handleFileRequest } from '../../src/server/api.js';
+
+let tmpRoot: string;
+
+beforeAll(async () => {
+ tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-api-file-test-'));
+ await fs.writeFile(path.join(tmpRoot, 'hello.txt'), 'hello world\n', 'utf-8');
+ await fs.mkdir(path.join(tmpRoot, 'sub'), { recursive: true });
+ await fs.writeFile(path.join(tmpRoot, 'sub', 'nested.txt'), 'nested\n', 'utf-8');
+});
+
+afterAll(async () => {
+ await fs.rm(tmpRoot, { recursive: true, force: true });
+});
+
+// Minimal express-shaped mock that captures status() / json() calls in a
+// shape compatible with the handler's expected interface. Returns the
+// final status (default 200 for naked res.json) and JSON body.
+const invoke = async (query: Record): Promise<{ status: number; body: any }> => {
+ let capturedStatus = 200;
+ let capturedBody: any = undefined;
+ const res = {
+ status(code: number) {
+ capturedStatus = code;
+ return this;
+ },
+ json(body: any) {
+ capturedBody = body;
+ },
+ };
+ await handleFileRequest({ query }, res, tmpRoot);
+ return { status: capturedStatus, body: capturedBody };
+};
+
+describe('handleFileRequest — security wiring', () => {
+ it('returns 200 with content for a valid relative path', async () => {
+ const { status, body } = await invoke({ path: 'hello.txt' });
+ expect(status).toBe(200);
+ expect(body.content).toBe('hello world\n');
+ });
+
+ it('returns 200 for a nested valid path', async () => {
+ const { status, body } = await invoke({ path: 'sub/nested.txt' });
+ expect(status).toBe(200);
+ expect(body.content).toBe('nested\n');
+ });
+
+ it('returns 400 when path is missing', async () => {
+ const { status, body } = await invoke({});
+ expect(status).toBe(400);
+ expect(body.error).toBe('Missing path');
+ });
+
+ it('returns 400 when path is an empty string', async () => {
+ const { status, body } = await invoke({ path: '' });
+ expect(status).toBe(400);
+ expect(body.error).toBe('Missing path');
+ });
+
+ // Reproducer for the PR #1322 review's HIGH finding #1.
+ // Before the catch-block fix this returned 500.
+ it('returns 400 when path is an array (?path=a&path=b)', async () => {
+ const { status, body } = await invoke({ path: ['a', 'b'] });
+ expect(status).toBe(400);
+ expect(body.error).toContain('path');
+ expect(body.error).toContain('array');
+ });
+
+ it('returns 403 for parent-directory traversal', async () => {
+ const { status, body } = await invoke({ path: '../../../etc/passwd' });
+ expect(status).toBe(403);
+ expect(body.error).toBe('Path traversal denied');
+ });
+
+ it('returns 403 for already-decoded traversal segments', async () => {
+ // Express decodes the query string before the handler sees it, so the
+ // analogue of a percent-encoded `%2e%2e%2f` arrives at the handler as
+ // '../'. Confirm the barrier still rejects.
+ const { status, body } = await invoke({ path: '../etc/passwd' });
+ expect(status).toBe(403);
+ expect(body.error).toBe('Path traversal denied');
+ });
+
+ it('returns 403 for an absolute path that escapes the root', async () => {
+ const { status, body } = await invoke({ path: '/etc/passwd' });
+ expect(status).toBe(403);
+ expect(body.error).toBe('Path traversal denied');
+ });
+
+ it('returns 404 for a path that resolves inside root but does not exist', async () => {
+ const { status, body } = await invoke({ path: 'does-not-exist.txt' });
+ expect(status).toBe(404);
+ expect(body.error).toBe('File not found');
+ });
+
+ it('rejects a common-prefix sibling directory escape (path.relative idiom)', async () => {
+ // The classic pitfall of `startsWith(root + sep)` is that '/tmp/repo' does
+ // not catch '/tmp/repo-evil/x'. The path.relative idiom does.
+ const sibling = path.basename(tmpRoot) + '-evil/secret';
+ const { status, body } = await invoke({ path: `../${sibling}` });
+ expect(status).toBe(403);
+ expect(body.error).toBe('Path traversal denied');
+ });
+});
diff --git a/gitnexus/test/unit/git-clone.test.ts b/gitnexus/test/unit/git-clone.test.ts
index 832f95641..18ac8e9d6 100644
--- a/gitnexus/test/unit/git-clone.test.ts
+++ b/gitnexus/test/unit/git-clone.test.ts
@@ -1,5 +1,18 @@
-import { describe, it, expect } from 'vitest';
-import { extractRepoName, getCloneDir, validateGitUrl } from '../../src/server/git-clone.js';
+import { afterAll, beforeAll, describe, it, expect } from 'vitest';
+import {
+ extractRepoName,
+ getCloneDir,
+ validateGitUrl,
+ cloneOrPull,
+ buildCloneArgs,
+ normalizeGitUrlForCompare,
+ assertRemoteMatchesRequestedUrl,
+ getRemoteOriginUrl,
+} from '../../src/server/git-clone.js';
+import path from 'node:path';
+import os from 'node:os';
+import fs from 'node:fs/promises';
+import { spawn } from 'node:child_process';
describe('git-clone', () => {
describe('extractRepoName', () => {
@@ -22,6 +35,48 @@ describe('git-clone', () => {
it('handles nested paths', () => {
expect(extractRepoName('https://gitlab.com/group/subgroup/repo.git')).toBe('repo');
});
+
+ it('rejects URLs whose last segment is "..": prevents getCloneDir traversal escape', () => {
+ // Without the safe-name pattern, a URL ending in `/..` would yield
+ // `getCloneDir('..')` = `~/.gitnexus/repos/..` = `~/.gitnexus/`, breaking
+ // out of the intended clone root.
+ expect(() => extractRepoName('https://github.com/owner/repo:..')).toThrow(
+ 'valid repository name',
+ );
+ expect(() => extractRepoName('https://example.com/foo:..')).toThrow('valid repository name');
+ });
+
+ it('rejects URLs that yield a single dot', () => {
+ expect(() => extractRepoName('https://example.com/foo:.')).toThrow('valid repository name');
+ });
+
+ it('rejects URLs with shell metacharacters in the last segment', () => {
+ // The split on /[/:]/ does not split on backslashes or other shell chars,
+ // so a name like `repo;rm -rf /` would slip through without the pattern.
+ expect(() => extractRepoName('https://example.com/foo:repo;rm')).toThrow(
+ 'valid repository name',
+ );
+ expect(() => extractRepoName('https://example.com/foo:repo$x')).toThrow(
+ 'valid repository name',
+ );
+ });
+
+ it('rejects empty input', () => {
+ expect(() => extractRepoName('')).toThrow('valid repository name');
+ });
+
+ it('handles many trailing slashes without polynomial-time blowup', () => {
+ // Pathological input the previous /\\/+$/ regex was flagged for
+ // (CodeQL js/polynomial-redos). The string-loop replacement is O(n).
+ const url = 'https://example.com/repo' + '/'.repeat(10000);
+ const start = performance.now();
+ expect(extractRepoName(url)).toBe('repo');
+ const elapsedMs = performance.now() - start;
+ // Threshold of 500ms is intentionally loose to absorb slow CI runners
+ // while still catching a true polynomial regression (which would take
+ // multiple seconds on 10k slashes).
+ expect(elapsedMs).toBeLessThan(500);
+ });
});
describe('getCloneDir', () => {
@@ -31,6 +86,26 @@ describe('git-clone', () => {
expect(dir).toMatch(/repos/);
expect(dir).toContain('my-repo');
});
+
+ it('rejects ".." to prevent path-traversal escape from the clone root', () => {
+ expect(() => getCloneDir('..')).toThrow('Invalid repository name');
+ expect(() => getCloneDir('.')).toThrow('Invalid repository name');
+ expect(() => getCloneDir('')).toThrow('Invalid repository name');
+ });
+
+ it('rejects names containing path separators', () => {
+ expect(() => getCloneDir('foo/bar')).toThrow('Invalid repository name');
+ expect(() => getCloneDir('foo\\bar')).toThrow('Invalid repository name');
+ });
+
+ it('returned path is always a direct child of the clone root', () => {
+ const cloneRoot = path.resolve(path.join(os.homedir(), '.gitnexus', 'repos'));
+ const dir = getCloneDir('my-repo');
+ const rel = path.relative(cloneRoot, path.resolve(dir));
+ // path.relative from the parent to the child must be just the child name —
+ // no .. and no path separators inside.
+ expect(rel).toBe('my-repo');
+ });
});
describe('validateGitUrl', () => {
@@ -96,8 +171,73 @@ describe('git-clone', () => {
);
});
- it('does not block valid public IPs', () => {
+ it('blocks IPv4-compatible IPv6 (RFC 4291 deprecated, ::w.x.y.z)', () => {
+ // Node's URL parser collapses ::127.0.0.1 to ::7f00:1 — no ::ffff: marker,
+ // but still routable to 127.0.0.1 on most stacks.
+ expect(() => validateGitUrl('http://[::127.0.0.1]/repo.git')).toThrow('private/internal');
+ expect(() => validateGitUrl('http://[::7f00:1]/repo.git')).toThrow('private/internal');
+ // 169.254.169.254 (cloud metadata) embedded as IPv4-compatible
+ expect(() => validateGitUrl('http://[::a9fe:a9fe]/repo.git')).toThrow('private/internal');
+ });
+
+ it('blocks IPv4-compatible IPv6 in expanded / zero-padded forms', () => {
+ // The compressed-form check above relies on the WHATWG URL parser
+ // normalising fully-expanded inputs to ::xxxx[:yyyy]. These cases pin
+ // that assumption: if a future Node release stops collapsing them, a
+ // bypass would silently re-open without these tests catching it.
+ expect(() => validateGitUrl('http://[0:0:0:0:0:0:7f00:1]/repo.git')).toThrow(
+ 'private/internal',
+ );
+ expect(() =>
+ validateGitUrl('http://[0000:0000:0000:0000:0000:0000:7f00:0001]/repo.git'),
+ ).toThrow('private/internal');
+ // Mixed notation: trailing IPv4 quad in an otherwise expanded address.
+ expect(() => validateGitUrl('http://[0:0:0:0:0:0:127.0.0.1]/repo.git')).toThrow(
+ 'private/internal',
+ );
+ });
+
+ it('blocks NAT64 well-known prefix (64:ff9b::/96)', () => {
+ // 64:ff9b::7f00:1 → 127.0.0.1 via NAT64 translation
+ expect(() => validateGitUrl('http://[64:ff9b::7f00:1]/repo.git')).toThrow('private/internal');
+ expect(() => validateGitUrl('http://[64:ff9b::a9fe:a9fe]/repo.git')).toThrow(
+ 'private/internal',
+ );
+ // RFC 8215 local NAT64 prefix
+ expect(() => validateGitUrl('http://[64:ff9b:1::1]/repo.git')).toThrow('private/internal');
+ });
+
+ it('blocks NAT64 with embedded RFC1918 addresses', () => {
+ // The startsWith('64:ff9b:') check covers any embedded IPv4. These
+ // explicit RFC1918 cases document SSRF coverage for the full private
+ // IPv4 surface — not just loopback and cloud metadata.
+ expect(() => validateGitUrl('http://[64:ff9b::a00:1]/repo.git')).toThrow('private/internal'); // 10.0.0.1
+ expect(() => validateGitUrl('http://[64:ff9b::ac10:1]/repo.git')).toThrow('private/internal'); // 172.16.0.1
+ expect(() => validateGitUrl('http://[64:ff9b::c0a8:101]/repo.git')).toThrow(
+ 'private/internal',
+ ); // 192.168.1.1
+ });
+
+ it('blocks 6to4 prefix (2002::/16, RFC 3056)', () => {
+ // 6to4 encodes an IPv4 address in bits 17-48, so 2002:WWXX:YYZZ::*
+ // routes to W.X.Y.Z on 6to4-capable stacks. The protocol is deprecated
+ // (RFC 7526), so the entire 2002::/16 block is defensively rejected.
+ expect(() => validateGitUrl('http://[2002:7f00:1::1]/repo.git')).toThrow('private/internal'); // 127.0.0.1
+ expect(() => validateGitUrl('http://[2002:a9fe:a9fe::1]/repo.git')).toThrow(
+ 'private/internal',
+ ); // 169.254.169.254
+ expect(() => validateGitUrl('http://[2002:c0a8:101::1]/repo.git')).toThrow(
+ 'private/internal',
+ ); // 192.168.1.1
+ });
+
+ it('does not block valid public IPs (IPv4 and IPv6)', () => {
expect(() => validateGitUrl('https://140.82.121.4/repo.git')).not.toThrow();
+ // Regression guard against over-blocking legitimate public IPv6.
+ // Cloudflare DNS (2606:4700::/32) and Google DNS (2001:4860::/32) —
+ // chosen because their prefixes don't collide with any block above.
+ expect(() => validateGitUrl('https://[2606:4700:4700::1111]/repo.git')).not.toThrow();
+ expect(() => validateGitUrl('https://[2001:4860:4860::8888]/repo.git')).not.toThrow();
});
it('blocks CGN range (100.64.0.0/10)', () => {
@@ -122,4 +262,268 @@ describe('git-clone', () => {
expect(() => validateGitUrl('http://0.0.0.0/repo.git')).toThrow('private/internal');
});
});
+
+ describe('buildCloneArgs', () => {
+ // Closes the test-coverage gap that PR #1325 review (HIGH finding 1)
+ // identified for CodeQL js/second-order-command-line-injection alerts
+ // #166/#167. The barrier these tests guard is the `--` separator that
+ // prevents an option-like URL from being parsed by git as a flag.
+ it('places `--` before the URL', () => {
+ const args = buildCloneArgs('https://github.com/owner/repo.git', '/safe/target');
+ const dashDashIdx = args.indexOf('--');
+ const urlIdx = args.indexOf('https://github.com/owner/repo.git');
+ expect(dashDashIdx).toBeGreaterThan(-1);
+ expect(urlIdx).toBeGreaterThan(dashDashIdx);
+ });
+
+ it('treats an option-like URL as a positional argument, not a flag', () => {
+ // The exact mitigation for second-order-command-line-injection: a URL
+ // beginning with `--` must appear after the `--` separator so git
+ // refuses to interpret it as `--upload-pack=evil`.
+ const args = buildCloneArgs('--upload-pack=evil', '/safe/target');
+ const dashDashIdx = args.indexOf('--');
+ const urlIdx = args.indexOf('--upload-pack=evil');
+ expect(dashDashIdx).toBeGreaterThan(-1);
+ expect(urlIdx).toBeGreaterThan(dashDashIdx);
+ // And targetDir comes after URL, also positional.
+ expect(args.indexOf('/safe/target')).toBeGreaterThan(urlIdx);
+ });
+
+ it('preserves --depth 1 for shallow clones', () => {
+ const args = buildCloneArgs('https://github.com/owner/repo.git', '/safe/target');
+ const depthIdx = args.indexOf('--depth');
+ expect(depthIdx).toBeGreaterThan(-1);
+ expect(args[depthIdx + 1]).toBe('1');
+ // --depth must be before the `--` separator (it's an option, not a positional).
+ expect(depthIdx).toBeLessThan(args.indexOf('--'));
+ });
+ });
+
+ describe('cloneOrPull — containment barrier', () => {
+ // Closes the test-coverage gap that PR #1325 review (HIGH finding 1)
+ // identified for CodeQL js/path-injection alerts #176/#177/#178. The
+ // barrier these tests guard is the path.relative containment check at
+ // the entry of cloneOrPull, which must reject any targetDir not strictly
+ // inside CLONE_ROOT before any filesystem or subprocess sink.
+ //
+ // These tests do NOT mock spawn — the barrier throws synchronously
+ // before git is invoked, so the rejection is observable directly.
+ const cloneRoot = path.resolve(path.join(os.homedir(), '.gitnexus', 'repos'));
+
+ it('rejects an absolute target outside CLONE_ROOT', async () => {
+ await expect(cloneOrPull('https://github.com/a/b.git', '/etc/passwd')).rejects.toThrow(
+ 'Clone target must be a subdirectory',
+ );
+ });
+
+ it('rejects CLONE_ROOT itself (the rel === "" branch)', async () => {
+ await expect(cloneOrPull('https://github.com/a/b.git', cloneRoot)).rejects.toThrow(
+ 'Clone target must be a subdirectory',
+ );
+ });
+
+ it('rejects a parent-directory traversal attempt', async () => {
+ await expect(
+ cloneOrPull('https://github.com/a/b.git', path.join(cloneRoot, '..', 'escape')),
+ ).rejects.toThrow('Clone target must be a subdirectory');
+ });
+
+ it('rejects a sibling directory with a common prefix (CLONE_ROOT-evil)', async () => {
+ // Classic startsWith(root + sep) pitfall: '/x/repos' does not catch
+ // '/x/repos-evil/...'. The path.relative idiom does, and the test
+ // documents that property at the cloneOrPull boundary.
+ await expect(cloneOrPull('https://github.com/a/b.git', cloneRoot + '-evil')).rejects.toThrow(
+ 'Clone target must be a subdirectory',
+ );
+ });
+
+ // Closes the SSRF-bypass vector that Codex's adversarial review on
+ // PR #1325 surfaced: validateGitUrl was only called in the clone
+ // branch. An attacker URL that shared a basename with an existing
+ // clone would skip the SSRF check entirely on the pull path.
+ //
+ // The barrier-pass-but-validateGitUrl-throw case here works because
+ // cloneOrPull validates the URL after the containment check and before
+ // the existence probe, so the rejection fires regardless of whether
+ // the target dir exists on disk.
+ it('rejects URLs that fail validateGitUrl even when the target shape is valid', async () => {
+ const fakeTarget = path.join(cloneRoot, 'name-that-does-not-exist');
+ await expect(cloneOrPull('http://127.0.0.1/repo.git', fakeTarget)).rejects.toThrow(
+ 'private/internal',
+ );
+ await expect(cloneOrPull('http://localhost/repo.git', fakeTarget)).rejects.toThrow(
+ 'private/internal',
+ );
+ await expect(cloneOrPull('file:///etc/passwd', fakeTarget)).rejects.toThrow(
+ 'Only https:// and http://',
+ );
+ });
+ });
+
+ describe('normalizeGitUrlForCompare', () => {
+ it('strips trailing .git', () => {
+ expect(normalizeGitUrlForCompare('https://github.com/owner/repo.git')).toBe(
+ normalizeGitUrlForCompare('https://github.com/owner/repo'),
+ );
+ });
+
+ it('strips trailing slashes', () => {
+ expect(normalizeGitUrlForCompare('https://github.com/owner/repo/')).toBe(
+ normalizeGitUrlForCompare('https://github.com/owner/repo'),
+ );
+ expect(normalizeGitUrlForCompare('https://github.com/owner/repo///')).toBe(
+ normalizeGitUrlForCompare('https://github.com/owner/repo'),
+ );
+ });
+
+ it('lowercases the hostname but preserves path case', () => {
+ expect(normalizeGitUrlForCompare('https://GitHub.com/owner/Repo.git')).toBe(
+ normalizeGitUrlForCompare('https://github.com/owner/Repo'),
+ );
+ // Different path case → distinct repos (hosts treat path as case-sensitive on the wire)
+ expect(normalizeGitUrlForCompare('https://github.com/owner/repo')).not.toBe(
+ normalizeGitUrlForCompare('https://github.com/owner/REPO'),
+ );
+ });
+
+ it('strips default ports', () => {
+ expect(normalizeGitUrlForCompare('https://github.com:443/owner/repo')).toBe(
+ normalizeGitUrlForCompare('https://github.com/owner/repo'),
+ );
+ expect(normalizeGitUrlForCompare('http://github.com:80/owner/repo')).toBe(
+ normalizeGitUrlForCompare('http://github.com/owner/repo'),
+ );
+ });
+
+ it('preserves non-default ports', () => {
+ expect(normalizeGitUrlForCompare('https://git.corp:8443/owner/repo')).not.toBe(
+ normalizeGitUrlForCompare('https://git.corp/owner/repo'),
+ );
+ });
+
+ it('strips userinfo (basic auth) so equivalent URLs compare equal', () => {
+ expect(normalizeGitUrlForCompare('https://user:pass@github.com/owner/repo.git')).toBe(
+ normalizeGitUrlForCompare('https://github.com/owner/repo'),
+ );
+ });
+
+ it('treats different hosts as distinct', () => {
+ expect(normalizeGitUrlForCompare('https://github.com/owner/repo')).not.toBe(
+ normalizeGitUrlForCompare('https://gitlab.com/owner/repo'),
+ );
+ });
+
+ it('treats different paths on the same host as distinct', () => {
+ expect(normalizeGitUrlForCompare('https://github.com/owner/repo')).not.toBe(
+ normalizeGitUrlForCompare('https://github.com/attacker/repo'),
+ );
+ });
+ });
+
+ describe('assertRemoteMatchesRequestedUrl', () => {
+ // Closes the wrong-repo silent-analysis vector that Codex's adversarial
+ // review on PR #1325 surfaced. Tests use a tmpdir-based fixture
+ // (anywhere on disk — independent of CLONE_ROOT) so the helper can be
+ // exercised without polluting the user's actual clone root.
+ let fixtureDir: string;
+
+ beforeAll(async () => {
+ fixtureDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-remote-match-'));
+ // git init + set remote.origin.url. We can't call git init via runGit
+ // since it's private; spawn directly.
+ await new Promise((resolve, reject) => {
+ const proc = spawn('git', ['init', '--quiet'], { cwd: fixtureDir, stdio: 'ignore' });
+ proc.on('close', (code) =>
+ code === 0 ? resolve() : reject(new Error(`git init exit ${code}`)),
+ );
+ proc.on('error', reject);
+ });
+ await new Promise((resolve, reject) => {
+ const proc = spawn(
+ 'git',
+ ['config', 'remote.origin.url', 'https://github.com/legitorg/myproject.git'],
+ { cwd: fixtureDir, stdio: 'ignore' },
+ );
+ proc.on('close', (code) =>
+ code === 0 ? resolve() : reject(new Error(`git config exit ${code}`)),
+ );
+ proc.on('error', reject);
+ });
+ });
+
+ afterAll(async () => {
+ await fs.rm(fixtureDir, { recursive: true, force: true });
+ });
+
+ it('accepts the requested URL when it matches the configured remote', async () => {
+ await expect(
+ assertRemoteMatchesRequestedUrl(fixtureDir, 'https://github.com/legitorg/myproject.git'),
+ ).resolves.toBeUndefined();
+ });
+
+ it('accepts equivalent forms (with/without .git, trailing slash, default port)', async () => {
+ await expect(
+ assertRemoteMatchesRequestedUrl(fixtureDir, 'https://github.com/legitorg/myproject'),
+ ).resolves.toBeUndefined();
+ await expect(
+ assertRemoteMatchesRequestedUrl(fixtureDir, 'https://github.com/legitorg/myproject/'),
+ ).resolves.toBeUndefined();
+ await expect(
+ assertRemoteMatchesRequestedUrl(
+ fixtureDir,
+ 'https://github.com:443/legitorg/myproject.git',
+ ),
+ ).resolves.toBeUndefined();
+ });
+
+ // The exact wrong-repo vector from Codex's review:
+ // existing clone → github.com/legitorg/myproject
+ // request URL → gitlab.example/attacker/myproject
+ // Both share the basename 'myproject'. Without this check, the pull
+ // would succeed and analysis would return wrong-repo data.
+ it('rejects a different host with the same basename', async () => {
+ await expect(
+ assertRemoteMatchesRequestedUrl(
+ fixtureDir,
+ 'https://gitlab.example/attacker/myproject.git',
+ ),
+ ).rejects.toThrow('not the requested URL');
+ });
+
+ it('rejects a different owner on the same host', async () => {
+ await expect(
+ assertRemoteMatchesRequestedUrl(fixtureDir, 'https://github.com/attacker/myproject.git'),
+ ).rejects.toThrow('not the requested URL');
+ });
+
+ it('rejects when the directory has no remote.origin', async () => {
+ const noRemoteDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-no-remote-'));
+ try {
+ await new Promise((resolve, reject) => {
+ const proc = spawn('git', ['init', '--quiet'], { cwd: noRemoteDir, stdio: 'ignore' });
+ proc.on('close', (code) =>
+ code === 0 ? resolve() : reject(new Error(`git init exit ${code}`)),
+ );
+ proc.on('error', reject);
+ });
+ await expect(
+ assertRemoteMatchesRequestedUrl(noRemoteDir, 'https://github.com/owner/repo.git'),
+ ).rejects.toThrow('no remote.origin');
+ } finally {
+ await fs.rm(noRemoteDir, { recursive: true, force: true });
+ }
+ });
+ });
+
+ describe('getRemoteOriginUrl', () => {
+ it('returns null for a directory that is not a git repository', async () => {
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-not-git-'));
+ try {
+ const result = await getRemoteOriginUrl(tmp);
+ expect(result).toBeNull();
+ } finally {
+ await fs.rm(tmp, { recursive: true, force: true });
+ }
+ });
+ });
});
diff --git a/gitnexus/test/unit/git-utils.test.ts b/gitnexus/test/unit/git-utils.test.ts
index d1fc187c4..e8fcdf6b9 100644
--- a/gitnexus/test/unit/git-utils.test.ts
+++ b/gitnexus/test/unit/git-utils.test.ts
@@ -180,3 +180,90 @@ describe('getRemoteUrl', () => {
}
});
});
+
+// ─── getCanonicalRepoRoot (#1259) ────────────────────────────────────────
+//
+// Critical for the worktree-naming bug: when `gitnexus analyze` runs from a
+// linked worktree, deriving `repoName` from `path.basename(getGitRoot(cwd))`
+// uses the worktree's directory slug instead of the canonical repo's
+// basename. `getCanonicalRepoRoot` exists specifically to dereference
+// worktrees via `git rev-parse --git-common-dir`.
+
+describe('getCanonicalRepoRoot', () => {
+ it('returns null for a plain temp directory (not a git repo)', async () => {
+ const { getCanonicalRepoRoot } = await import('../../src/storage/git.js');
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-canonical-'));
+ try {
+ expect(getCanonicalRepoRoot(tmpDir)).toBeNull();
+ } finally {
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ }
+ });
+
+ it('returns null for a non-existent path', async () => {
+ const { getCanonicalRepoRoot } = await import('../../src/storage/git.js');
+ expect(getCanonicalRepoRoot('/tmp/__gitnexus_canonical_nonexistent__')).toBeNull();
+ });
+
+ it('returns the repo root when called from a regular (non-worktree) checkout', async () => {
+ const { getCanonicalRepoRoot } = await import('../../src/storage/git.js');
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-canonical-main-'));
+ try {
+ execSync('git init -q', { cwd: tmpDir });
+ // Compare via `path.basename` instead of full-path string equality so
+ // the test is robust to platform path-format quirks (Windows 8.3 short
+ // names like `C:\Users\RUNNER~1\…` vs long form `C:\Users\runneradmin\…`,
+ // macOS `/var/folders/… ↔ /private/var/folders/…`). The basename is the
+ // only part that registry name derivation actually uses (#1259).
+ const result = getCanonicalRepoRoot(tmpDir);
+ expect(result).not.toBeNull();
+ expect(path.basename(result!)).toBe(path.basename(tmpDir));
+ } finally {
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ }
+ });
+
+ it('returns the CANONICAL repo root when called from inside a linked worktree (#1259)', async () => {
+ const { getCanonicalRepoRoot, getGitRoot } = await import('../../src/storage/git.js');
+ const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-canonical-wt-'));
+ try {
+ execSync('git init -q', { cwd: repoDir });
+ // `git worktree add` requires at least one commit on a real branch.
+ execSync('git config user.email "test@example.com"', { cwd: repoDir });
+ execSync('git config user.name "Test"', { cwd: repoDir });
+ execSync('git commit --allow-empty -q -m "initial"', { cwd: repoDir });
+ // Create a linked worktree on a new branch outside the main checkout.
+ const worktreeDir = path.join(repoDir, 'wt-feature');
+ execSync(`git worktree add -q -b feature "${worktreeDir}"`, { cwd: repoDir });
+
+ // Both calls go through the same git executable, so their path-format
+ // output is guaranteed consistent — equality between them is the
+ // stable cross-platform assertion. (Comparing against `realpathSync`
+ // breaks on Windows where 8.3 short names and long names diverge.)
+ const fromMain = getCanonicalRepoRoot(repoDir);
+ const fromWorktree = getCanonicalRepoRoot(worktreeDir);
+
+ expect(fromMain).not.toBeNull();
+ // From inside the worktree: canonical points BACK to the main repo's
+ // shared `.git`. This is the regression-guard for #1259 — the
+ // registry name derivation collapses across worktrees.
+ expect(fromWorktree).toBe(fromMain);
+ // Basename matches the canonical repo dir (NOT the worktree slug).
+ expect(path.basename(fromWorktree!)).toBe(path.basename(repoDir));
+ expect(path.basename(fromWorktree!)).not.toBe('wt-feature');
+ // Sanity: getGitRoot returns the worktree-local root (existing
+ // behavior unchanged). Compare basenames for the same path-format
+ // reason as above.
+ expect(path.basename(getGitRoot(worktreeDir)!)).toBe('wt-feature');
+ } finally {
+ // Best-effort cleanup; worktree teardown can leak open handles on
+ // Windows so use force.
+ try {
+ execSync('git worktree remove -f wt-feature', { cwd: repoDir });
+ } catch {
+ // ignore — fall through to recursive rm
+ }
+ fs.rmSync(repoDir, { recursive: true, force: true });
+ }
+ });
+});
diff --git a/gitnexus/test/unit/group/elixir-workspace-extractor.test.ts b/gitnexus/test/unit/group/elixir-workspace-extractor.test.ts
new file mode 100644
index 000000000..f7af3861c
--- /dev/null
+++ b/gitnexus/test/unit/group/elixir-workspace-extractor.test.ts
@@ -0,0 +1,261 @@
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import os from 'node:os';
+import { extractElixirWorkspaceLinks } from '../../../src/core/group/extractors/elixir-workspace-extractor.js';
+
+describe('ElixirWorkspaceExtractor', () => {
+ let tmpDir: string;
+
+ beforeEach(async () => {
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-ex-ws-'));
+ });
+
+ afterEach(async () => {
+ await fs.rm(tmpDir, { recursive: true, force: true });
+ });
+
+ async function writeFile(relPath: string, content: string) {
+ const absPath = path.join(tmpDir, relPath);
+ await fs.mkdir(path.dirname(absPath), { recursive: true });
+ await fs.writeFile(absPath, content, 'utf-8');
+ }
+
+ it('discovers cross-app alias imports', async () => {
+ await writeFile(
+ 'core/mix.exs',
+ 'defmodule Core.MixProject do\n use Mix.Project\n def project do\n [app: :core, version: "0.1.0"]\n end\nend\n',
+ );
+ await writeFile('core/lib/core/schema.ex', 'defmodule Core.Schema do\nend\n');
+
+ await writeFile(
+ 'web/mix.exs',
+ 'defmodule Web.MixProject do\n use Mix.Project\n def project do\n [app: :web, version: "0.1.0"]\n end\n defp deps do\n [{:core, in_umbrella: true}]\n end\nend\n',
+ );
+ await writeFile(
+ 'web/lib/web/controller.ex',
+ 'defmodule Web.Controller do\n alias Core.Schema\nend\n',
+ );
+
+ const repos = { core: 'core', web: 'web' };
+ const repoPaths = new Map([
+ ['core', path.join(tmpDir, 'core')],
+ ['web', path.join(tmpDir, 'web')],
+ ]);
+
+ const result = await extractElixirWorkspaceLinks(repos, repoPaths);
+
+ expect(result.links).toHaveLength(1);
+ expect(result.links[0]).toEqual({
+ from: 'core',
+ to: 'web',
+ type: 'custom',
+ contract: 'Core.Schema',
+ role: 'provider',
+ });
+ });
+
+ it('handles grouped alias (alias MyApp.{ModA, ModB})', async () => {
+ await writeFile(
+ 'shared/mix.exs',
+ 'defmodule Shared.MixProject do\n use Mix.Project\n def project do\n [app: :shared, version: "0.1.0"]\n end\nend\n',
+ );
+ await writeFile('shared/lib/shared/config.ex', 'defmodule Shared.Config do\nend\n');
+ await writeFile('shared/lib/shared/logger.ex', 'defmodule Shared.Logger do\nend\n');
+
+ await writeFile(
+ 'app/mix.exs',
+ 'defmodule App.MixProject do\n use Mix.Project\n def project do\n [app: :app, version: "0.1.0"]\n end\n defp deps do\n [{:shared, "~> 0.1"}]\n end\nend\n',
+ );
+ await writeFile(
+ 'app/lib/app/main.ex',
+ 'defmodule App.Main do\n alias Shared.{Config, Logger}\nend\n',
+ );
+
+ const repos = { shared: 'shared', app: 'app' };
+ const repoPaths = new Map([
+ ['shared', path.join(tmpDir, 'shared')],
+ ['app', path.join(tmpDir, 'app')],
+ ]);
+
+ const result = await extractElixirWorkspaceLinks(repos, repoPaths);
+
+ expect(result.links).toHaveLength(2);
+ const contracts = result.links.map((l) => l.contract).sort();
+ expect(contracts).toEqual(['Shared.Config', 'Shared.Logger']);
+ });
+
+ it('handles direct module references (no alias)', async () => {
+ await writeFile(
+ 'auth/mix.exs',
+ 'defmodule Auth.MixProject do\n use Mix.Project\n def project do\n [app: :auth, version: "0.1.0"]\n end\nend\n',
+ );
+ await writeFile('auth/lib/auth/token.ex', 'defmodule Auth.Token do\nend\n');
+
+ await writeFile(
+ 'api/mix.exs',
+ 'defmodule Api.MixProject do\n use Mix.Project\n def project do\n [app: :api, version: "0.1.0"]\n end\n defp deps do\n [{:auth, path: "../auth"}]\n end\nend\n',
+ );
+ await writeFile(
+ 'api/lib/api/handler.ex',
+ 'defmodule Api.Handler do\n def verify do\n Auth.Token.verify()\n end\nend\n',
+ );
+
+ const repos = { auth: 'auth', api: 'api' };
+ const repoPaths = new Map([
+ ['auth', path.join(tmpDir, 'auth')],
+ ['api', path.join(tmpDir, 'api')],
+ ]);
+
+ const result = await extractElixirWorkspaceLinks(repos, repoPaths);
+
+ expect(result.links).toHaveLength(1);
+ expect(result.links[0].contract).toBe('Auth.Token');
+ });
+
+ it('handles underscore app names (my_app -> MyApp)', async () => {
+ await writeFile(
+ 'data-store/mix.exs',
+ 'defmodule DataStore.MixProject do\n use Mix.Project\n def project do\n [app: :data_store, version: "0.1.0"]\n end\nend\n',
+ );
+ await writeFile('data-store/lib/data_store/repo.ex', 'defmodule DataStore.Repo do\nend\n');
+
+ await writeFile(
+ 'web/mix.exs',
+ 'defmodule Web.MixProject do\n use Mix.Project\n def project do\n [app: :web, version: "0.1.0"]\n end\n defp deps do\n [{:data_store, in_umbrella: true}]\n end\nend\n',
+ );
+ await writeFile('web/lib/web/page.ex', 'defmodule Web.Page do\n alias DataStore.Repo\nend\n');
+
+ const repos = { store: 'data_store', web: 'web' };
+ const repoPaths = new Map([
+ ['store', path.join(tmpDir, 'data-store')],
+ ['web', path.join(tmpDir, 'web')],
+ ]);
+
+ const result = await extractElixirWorkspaceLinks(repos, repoPaths);
+
+ expect(result.links).toHaveLength(1);
+ expect(result.links[0].contract).toBe('DataStore.Repo');
+ });
+
+ it('skips repos without mix.exs', async () => {
+ await writeFile('js-app/package.json', '{"name": "js-app"}');
+
+ const repos = { app: 'js-app' };
+ const repoPaths = new Map([['app', path.join(tmpDir, 'js-app')]]);
+
+ const result = await extractElixirWorkspaceLinks(repos, repoPaths);
+
+ expect(result.links).toHaveLength(0);
+ expect(result.discoveredApps.size).toBe(0);
+ });
+
+ it('deduplicates identical module refs from multiple files', async () => {
+ await writeFile(
+ 'lib/mix.exs',
+ 'defmodule Lib.MixProject do\n use Mix.Project\n def project do\n [app: :lib, version: "0.1.0"]\n end\nend\n',
+ );
+ await writeFile('lib/lib/lib/config.ex', 'defmodule Lib.Config do\nend\n');
+
+ await writeFile(
+ 'app/mix.exs',
+ 'defmodule App.MixProject do\n use Mix.Project\n def project do\n [app: :app, version: "0.1.0"]\n end\n defp deps do\n [{:lib, "~> 0.1"}]\n end\nend\n',
+ );
+ await writeFile('app/lib/app/a.ex', 'defmodule App.A do\n alias Lib.Config\nend\n');
+ await writeFile('app/lib/app/b.ex', 'defmodule App.B do\n alias Lib.Config\nend\n');
+
+ const repos = { lib: 'lib', app: 'app' };
+ const repoPaths = new Map([
+ ['lib', path.join(tmpDir, 'lib')],
+ ['app', path.join(tmpDir, 'app')],
+ ]);
+
+ const result = await extractElixirWorkspaceLinks(repos, repoPaths);
+
+ expect(result.links).toHaveLength(1);
+ });
+
+ it('collapses nested submodules to top-level module contract', async () => {
+ await writeFile(
+ 'core/mix.exs',
+ 'defmodule Core.MixProject do\n use Mix.Project\n def project do\n [app: :core, version: "0.1.0"]\n end\nend\n',
+ );
+ await writeFile('core/lib/core/auth/token.ex', 'defmodule Core.Auth.Token do\nend\n');
+ await writeFile('core/lib/core/auth/session.ex', 'defmodule Core.Auth.Session do\nend\n');
+
+ await writeFile(
+ 'web/mix.exs',
+ 'defmodule Web.MixProject do\n use Mix.Project\n def project do\n [app: :web, version: "0.1.0"]\n end\n defp deps do\n [{:core, in_umbrella: true}]\n end\nend\n',
+ );
+ await writeFile(
+ 'web/lib/web/ctrl.ex',
+ 'defmodule Web.Ctrl do\n alias Core.Auth.Token\n alias Core.Auth.Session\nend\n',
+ );
+
+ const repos = { core: 'core', web: 'web' };
+ const repoPaths = new Map([
+ ['core', path.join(tmpDir, 'core')],
+ ['web', path.join(tmpDir, 'web')],
+ ]);
+
+ const result = await extractElixirWorkspaceLinks(repos, repoPaths);
+
+ expect(result.links).toHaveLength(1);
+ expect(result.links[0].contract).toBe('Core.Auth');
+ });
+
+ it('does not produce false positives from module references in comments', async () => {
+ await writeFile(
+ 'auth/mix.exs',
+ 'defmodule Auth.MixProject do\n use Mix.Project\n def project do\n [app: :auth, version: "0.1.0"]\n end\nend\n',
+ );
+ await writeFile('auth/lib/auth/token.ex', 'defmodule Auth.Token do\nend\n');
+
+ await writeFile(
+ 'api/mix.exs',
+ 'defmodule Api.MixProject do\n use Mix.Project\n def project do\n [app: :api, version: "0.1.0"]\n end\n defp deps do\n [{:auth, path: "../auth"}]\n end\nend\n',
+ );
+ await writeFile(
+ 'api/lib/api/handler.ex',
+ 'defmodule Api.Handler do\n # See Auth.Token for details\n # Auth.Token.verify() is deprecated\n def handle, do: :ok\nend\n',
+ );
+
+ const repos = { auth: 'auth', api: 'api' };
+ const repoPaths = new Map([
+ ['auth', path.join(tmpDir, 'auth')],
+ ['api', path.join(tmpDir, 'api')],
+ ]);
+
+ const result = await extractElixirWorkspaceLinks(repos, repoPaths);
+
+ expect(result.links).toHaveLength(0);
+ });
+
+ it('handles git and path deps alongside umbrella deps', async () => {
+ await writeFile(
+ 'utils/mix.exs',
+ 'defmodule Utils.MixProject do\n use Mix.Project\n def project do\n [app: :utils, version: "0.1.0"]\n end\nend\n',
+ );
+ await writeFile('utils/lib/utils/helper.ex', 'defmodule Utils.Helper do\nend\n');
+
+ await writeFile(
+ 'svc/mix.exs',
+ 'defmodule Svc.MixProject do\n use Mix.Project\n def project do\n [app: :svc, version: "0.1.0"]\n end\n defp deps do\n [{:utils, git: "https://github.com/org/utils.git"}]\n end\nend\n',
+ );
+ await writeFile(
+ 'svc/lib/svc/worker.ex',
+ 'defmodule Svc.Worker do\n alias Utils.Helper\nend\n',
+ );
+
+ const repos = { utils: 'utils', svc: 'svc' };
+ const repoPaths = new Map([
+ ['utils', path.join(tmpDir, 'utils')],
+ ['svc', path.join(tmpDir, 'svc')],
+ ]);
+
+ const result = await extractElixirWorkspaceLinks(repos, repoPaths);
+
+ expect(result.links).toHaveLength(1);
+ expect(result.links[0].contract).toBe('Utils.Helper');
+ });
+});
diff --git a/gitnexus/test/unit/group/go-workspace-extractor.test.ts b/gitnexus/test/unit/group/go-workspace-extractor.test.ts
new file mode 100644
index 000000000..a09e4314e
--- /dev/null
+++ b/gitnexus/test/unit/group/go-workspace-extractor.test.ts
@@ -0,0 +1,244 @@
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import os from 'node:os';
+import { extractGoWorkspaceLinks } from '../../../src/core/group/extractors/go-workspace-extractor.js';
+
+describe('GoWorkspaceExtractor', () => {
+ let tmpDir: string;
+
+ beforeEach(async () => {
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-go-ws-'));
+ });
+
+ afterEach(async () => {
+ await fs.rm(tmpDir, { recursive: true, force: true });
+ });
+
+ async function writeFile(relPath: string, content: string) {
+ const absPath = path.join(tmpDir, relPath);
+ await fs.mkdir(path.dirname(absPath), { recursive: true });
+ await fs.writeFile(absPath, content, 'utf-8');
+ }
+
+ it('discovers cross-module type usage via require', async () => {
+ await writeFile('models/go.mod', 'module github.com/org/models\n\ngo 1.21\n');
+ await writeFile('models/schema.go', 'package models\n\ntype Schema struct {}\n');
+
+ await writeFile(
+ 'api/go.mod',
+ 'module github.com/org/api\n\ngo 1.21\n\nrequire github.com/org/models v0.1.0\n',
+ );
+ await writeFile(
+ 'api/main.go',
+ 'package main\n\nimport "github.com/org/models"\n\nfunc main() {\n\tvar s models.Schema\n\t_ = s\n}\n',
+ );
+
+ const repos = {
+ 'libs/models': 'models',
+ 'services/api': 'api',
+ };
+ const repoPaths = new Map([
+ ['libs/models', path.join(tmpDir, 'models')],
+ ['services/api', path.join(tmpDir, 'api')],
+ ]);
+
+ const result = await extractGoWorkspaceLinks(repos, repoPaths);
+
+ expect(result.links).toHaveLength(1);
+ expect(result.links[0]).toEqual({
+ from: 'libs/models',
+ to: 'services/api',
+ type: 'custom',
+ contract: 'github.com/org/models::Schema',
+ role: 'provider',
+ });
+ });
+
+ it('handles block require syntax', async () => {
+ await writeFile('auth/go.mod', 'module github.com/org/auth\n\ngo 1.21\n');
+ await writeFile('auth/token.go', 'package auth\n\ntype Token struct {}\n');
+
+ await writeFile(
+ 'svc/go.mod',
+ 'module github.com/org/svc\n\ngo 1.21\n\nrequire (\n\tgithub.com/org/auth v1.0.0\n)\n',
+ );
+ await writeFile(
+ 'svc/main.go',
+ 'package main\n\nimport (\n\t"github.com/org/auth"\n)\n\nfunc handle() auth.Token { return auth.Token{} }\n',
+ );
+
+ const repos = { auth: 'auth', svc: 'svc' };
+ const repoPaths = new Map([
+ ['auth', path.join(tmpDir, 'auth')],
+ ['svc', path.join(tmpDir, 'svc')],
+ ]);
+
+ const result = await extractGoWorkspaceLinks(repos, repoPaths);
+
+ expect(result.links).toHaveLength(1);
+ expect(result.links[0].contract).toBe('github.com/org/auth::Token');
+ });
+
+ it('handles subpackage imports (module/pkg)', async () => {
+ await writeFile('core/go.mod', 'module github.com/org/core\n\ngo 1.21\n');
+ await writeFile('core/types/entity.go', 'package types\n\ntype Entity struct {}\n');
+
+ await writeFile(
+ 'app/go.mod',
+ 'module github.com/org/app\n\ngo 1.21\n\nrequire github.com/org/core v0.1.0\n',
+ );
+ await writeFile(
+ 'app/main.go',
+ 'package main\n\nimport "github.com/org/core/types"\n\nvar e types.Entity\n',
+ );
+
+ const repos = { core: 'core', app: 'app' };
+ const repoPaths = new Map([
+ ['core', path.join(tmpDir, 'core')],
+ ['app', path.join(tmpDir, 'app')],
+ ]);
+
+ const result = await extractGoWorkspaceLinks(repos, repoPaths);
+
+ expect(result.links).toHaveLength(1);
+ expect(result.links[0].contract).toBe('github.com/org/core::Entity');
+ });
+
+ it('handles replace directive with local paths', async () => {
+ await writeFile('lib/go.mod', 'module github.com/org/lib\n\ngo 1.21\n');
+ await writeFile('lib/config.go', 'package lib\n\ntype Config struct {}\n');
+
+ await writeFile(
+ 'app/go.mod',
+ 'module github.com/org/app\n\ngo 1.21\n\nrequire github.com/org/lib v0.0.0\n\nreplace github.com/org/lib => ./lib\n',
+ );
+ await writeFile(
+ 'app/main.go',
+ 'package main\n\nimport "github.com/org/lib"\n\nvar c lib.Config\n',
+ );
+
+ const repos = { lib: 'lib', app: 'app' };
+ const repoPaths = new Map([
+ ['lib', path.join(tmpDir, 'lib')],
+ ['app', path.join(tmpDir, 'app')],
+ ]);
+
+ const result = await extractGoWorkspaceLinks(repos, repoPaths);
+
+ expect(result.links).toHaveLength(1);
+ expect(result.links[0].contract).toBe('github.com/org/lib::Config');
+ });
+
+ it('ignores unexported (lowercase) identifiers', async () => {
+ await writeFile('lib/go.mod', 'module github.com/org/lib\n\ngo 1.21\n');
+ await writeFile('lib/util.go', 'package lib\n\nfunc helper() {}\ntype Config struct {}\n');
+
+ await writeFile(
+ 'app/go.mod',
+ 'module github.com/org/app\n\ngo 1.21\n\nrequire github.com/org/lib v0.1.0\n',
+ );
+ await writeFile(
+ 'app/main.go',
+ 'package main\n\nimport "github.com/org/lib"\n\nvar c lib.Config\n',
+ );
+
+ const repos = { lib: 'lib', app: 'app' };
+ const repoPaths = new Map([
+ ['lib', path.join(tmpDir, 'lib')],
+ ['app', path.join(tmpDir, 'app')],
+ ]);
+
+ const result = await extractGoWorkspaceLinks(repos, repoPaths);
+
+ expect(result.links).toHaveLength(1);
+ expect(result.links[0].contract).toBe('github.com/org/lib::Config');
+ });
+
+ it('does not produce links for aliased imports (V1 false-negative limitation)', async () => {
+ await writeFile('shared/go.mod', 'module github.com/org/shared\n\ngo 1.21\n');
+ await writeFile('shared/config.go', 'package shared\n\ntype Config struct {}\n');
+
+ await writeFile(
+ 'app/go.mod',
+ 'module github.com/org/app\n\ngo 1.21\n\nrequire github.com/org/shared v0.1.0\n',
+ );
+ await writeFile(
+ 'app/main.go',
+ 'package main\n\nimport cfg "github.com/org/shared"\n\nvar c cfg.Config\n',
+ );
+
+ const repos = { shared: 'shared', app: 'app' };
+ const repoPaths = new Map([
+ ['shared', path.join(tmpDir, 'shared')],
+ ['app', path.join(tmpDir, 'app')],
+ ]);
+
+ const result = await extractGoWorkspaceLinks(repos, repoPaths);
+
+ expect(result.links).toHaveLength(0);
+ });
+
+ it('skips repos without go.mod', async () => {
+ await writeFile('js-app/package.json', '{"name": "js-app"}');
+
+ const repos = { app: 'js-app' };
+ const repoPaths = new Map([['app', path.join(tmpDir, 'js-app')]]);
+
+ const result = await extractGoWorkspaceLinks(repos, repoPaths);
+
+ expect(result.links).toHaveLength(0);
+ expect(result.discoveredModules.size).toBe(0);
+ });
+
+ it('deduplicates identical type usage from multiple files', async () => {
+ await writeFile('lib/go.mod', 'module github.com/org/lib\n\ngo 1.21\n');
+ await writeFile('lib/model.go', 'package lib\n\ntype Model struct {}\n');
+
+ await writeFile(
+ 'app/go.mod',
+ 'module github.com/org/app\n\ngo 1.21\n\nrequire github.com/org/lib v0.1.0\n',
+ );
+ await writeFile('app/a.go', 'package main\n\nimport "github.com/org/lib"\n\nvar x lib.Model\n');
+ await writeFile('app/b.go', 'package main\n\nimport "github.com/org/lib"\n\nvar y lib.Model\n');
+
+ const repos = { lib: 'lib', app: 'app' };
+ const repoPaths = new Map([
+ ['lib', path.join(tmpDir, 'lib')],
+ ['app', path.join(tmpDir, 'app')],
+ ]);
+
+ const result = await extractGoWorkspaceLinks(repos, repoPaths);
+
+ expect(result.links).toHaveLength(1);
+ });
+
+ it('discovers multiple types from the same module', async () => {
+ await writeFile('lib/go.mod', 'module github.com/org/lib\n\ngo 1.21\n');
+ await writeFile(
+ 'lib/types.go',
+ 'package lib\n\ntype Request struct {}\ntype Response struct {}\n',
+ );
+
+ await writeFile(
+ 'app/go.mod',
+ 'module github.com/org/app\n\ngo 1.21\n\nrequire github.com/org/lib v0.1.0\n',
+ );
+ await writeFile(
+ 'app/main.go',
+ 'package main\n\nimport "github.com/org/lib"\n\nfunc handle(r lib.Request) lib.Response { return lib.Response{} }\n',
+ );
+
+ const repos = { lib: 'lib', app: 'app' };
+ const repoPaths = new Map([
+ ['lib', path.join(tmpDir, 'lib')],
+ ['app', path.join(tmpDir, 'app')],
+ ]);
+
+ const result = await extractGoWorkspaceLinks(repos, repoPaths);
+
+ expect(result.links).toHaveLength(2);
+ const contracts = result.links.map((l) => l.contract).sort();
+ expect(contracts).toEqual(['github.com/org/lib::Request', 'github.com/org/lib::Response']);
+ });
+});
diff --git a/gitnexus/test/unit/group/java-workspace-extractor.test.ts b/gitnexus/test/unit/group/java-workspace-extractor.test.ts
new file mode 100644
index 000000000..09233ab12
--- /dev/null
+++ b/gitnexus/test/unit/group/java-workspace-extractor.test.ts
@@ -0,0 +1,240 @@
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import os from 'node:os';
+import { extractJavaWorkspaceLinks } from '../../../src/core/group/extractors/java-workspace-extractor.js';
+
+describe('JavaWorkspaceExtractor', () => {
+ let tmpDir: string;
+
+ beforeEach(async () => {
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-java-ws-'));
+ });
+
+ afterEach(async () => {
+ await fs.rm(tmpDir, { recursive: true, force: true });
+ });
+
+ async function writeFile(relPath: string, content: string) {
+ const absPath = path.join(tmpDir, relPath);
+ await fs.mkdir(path.dirname(absPath), { recursive: true });
+ await fs.writeFile(absPath, content, 'utf-8');
+ }
+
+ const pomTemplate = (g: string, a: string, deps: string[] = []) => {
+ const depXml = deps
+ .map((d) => {
+ const [gid, aid] = d.split(':');
+ return `${gid}${aid}`;
+ })
+ .join('\n');
+ return `${g}${a}${depXml}`;
+ };
+
+ it('discovers cross-project imports via Maven pom.xml', async () => {
+ await writeFile('models/pom.xml', pomTemplate('com.acme', 'models'));
+ await writeFile(
+ 'models/src/main/java/com/acme/models/User.java',
+ 'package com.acme.models;\npublic class User {}\n',
+ );
+
+ await writeFile('api/pom.xml', pomTemplate('com.acme', 'api', ['com.acme:models']));
+ await writeFile(
+ 'api/src/main/java/com/acme/api/UserService.java',
+ 'package com.acme.api;\nimport com.acme.models.User;\npublic class UserService {}\n',
+ );
+
+ const repos = { models: 'models', api: 'api' };
+ const repoPaths = new Map([
+ ['models', path.join(tmpDir, 'models')],
+ ['api', path.join(tmpDir, 'api')],
+ ]);
+
+ const result = await extractJavaWorkspaceLinks(repos, repoPaths);
+
+ expect(result.links).toHaveLength(1);
+ expect(result.links[0]).toEqual({
+ from: 'models',
+ to: 'api',
+ type: 'custom',
+ contract: 'models::User',
+ role: 'provider',
+ });
+ });
+
+ it('handles Gradle build files', async () => {
+ await writeFile('core/build.gradle.kts', 'group = "com.acme"\nversion = "1.0"\n');
+ await writeFile(
+ 'core/src/main/java/com/acme/core/Config.java',
+ 'package com.acme.core;\npublic class Config {}\n',
+ );
+
+ await writeFile(
+ 'svc/build.gradle.kts',
+ 'group = "com.acme"\nversion = "1.0"\ndependencies {\n implementation("com.acme:core:1.0")\n}\n',
+ );
+ await writeFile(
+ 'svc/src/main/java/com/acme/svc/App.java',
+ 'package com.acme.svc;\nimport com.acme.core.Config;\npublic class App {}\n',
+ );
+
+ const repos = { core: 'core', svc: 'svc' };
+ const repoPaths = new Map([
+ ['core', path.join(tmpDir, 'core')],
+ ['svc', path.join(tmpDir, 'svc')],
+ ]);
+
+ const result = await extractJavaWorkspaceLinks(repos, repoPaths);
+
+ expect(result.links).toHaveLength(1);
+ expect(result.links[0].contract).toBe('core::Config');
+ });
+
+ it('handles Gradle project dependencies', async () => {
+ await writeFile('common/build.gradle', "group = 'com.org'\nversion = '1.0'\n");
+ await writeFile(
+ 'common/src/main/java/com/org/common/Entity.java',
+ 'package com.org.common;\npublic class Entity {}\n',
+ );
+
+ await writeFile(
+ 'app/build.gradle',
+ "group = 'com.org'\nversion = '1.0'\ndependencies {\n implementation(project(':common'))\n}\n",
+ );
+ await writeFile(
+ 'app/src/main/java/com/org/app/Main.java',
+ 'package com.org.app;\nimport com.org.common.Entity;\npublic class Main {}\n',
+ );
+
+ const repos = { common: 'common', app: 'app' };
+ const repoPaths = new Map([
+ ['common', path.join(tmpDir, 'common')],
+ ['app', path.join(tmpDir, 'app')],
+ ]);
+
+ const result = await extractJavaWorkspaceLinks(repos, repoPaths);
+
+ expect(result.links).toHaveLength(1);
+ expect(result.links[0].contract).toBe('common::Entity');
+ });
+
+ it('handles static imports', async () => {
+ await writeFile('lib/pom.xml', pomTemplate('com.acme', 'lib'));
+ await writeFile(
+ 'lib/src/main/java/com/acme/lib/Constants.java',
+ 'package com.acme.lib;\npublic class Constants {}\n',
+ );
+
+ await writeFile('app/pom.xml', pomTemplate('com.acme', 'app', ['com.acme:lib']));
+ await writeFile(
+ 'app/src/main/java/com/acme/app/Main.java',
+ 'package com.acme.app;\nimport static com.acme.lib.Constants;\npublic class Main {}\n',
+ );
+
+ const repos = { lib: 'lib', app: 'app' };
+ const repoPaths = new Map([
+ ['lib', path.join(tmpDir, 'lib')],
+ ['app', path.join(tmpDir, 'app')],
+ ]);
+
+ const result = await extractJavaWorkspaceLinks(repos, repoPaths);
+
+ expect(result.links).toHaveLength(1);
+ expect(result.links[0].contract).toBe('lib::Constants');
+ });
+
+ it('skips repos without Java manifest', async () => {
+ await writeFile('rs-app/Cargo.toml', '[package]\nname = "rapp"\n');
+
+ const repos = { app: 'rapp' };
+ const repoPaths = new Map([['app', path.join(tmpDir, 'rs-app')]]);
+
+ const result = await extractJavaWorkspaceLinks(repos, repoPaths);
+
+ expect(result.links).toHaveLength(0);
+ expect(result.discoveredProjects.size).toBe(0);
+ });
+
+ it('deduplicates identical imports from multiple files', async () => {
+ await writeFile('lib/pom.xml', pomTemplate('com.acme', 'lib'));
+ await writeFile(
+ 'lib/src/main/java/com/acme/lib/Token.java',
+ 'package com.acme.lib;\npublic class Token {}\n',
+ );
+
+ await writeFile('app/pom.xml', pomTemplate('com.acme', 'app', ['com.acme:lib']));
+ await writeFile(
+ 'app/src/main/java/com/acme/app/A.java',
+ 'package com.acme.app;\nimport com.acme.lib.Token;\npublic class A {}\n',
+ );
+ await writeFile(
+ 'app/src/main/java/com/acme/app/B.java',
+ 'package com.acme.app;\nimport com.acme.lib.Token;\npublic class B {}\n',
+ );
+
+ const repos = { lib: 'lib', app: 'app' };
+ const repoPaths = new Map([
+ ['lib', path.join(tmpDir, 'lib')],
+ ['app', path.join(tmpDir, 'app')],
+ ]);
+
+ const result = await extractJavaWorkspaceLinks(repos, repoPaths);
+
+ expect(result.links).toHaveLength(1);
+ });
+
+ it('discovers Kotlin file imports from Java projects', async () => {
+ await writeFile('lib/pom.xml', pomTemplate('com.acme', 'lib'));
+ await writeFile(
+ 'lib/src/main/kotlin/com/acme/lib/Model.kt',
+ 'package com.acme.lib\ndata class Model(val id: Int)\n',
+ );
+
+ await writeFile('app/pom.xml', pomTemplate('com.acme', 'app', ['com.acme:lib']));
+ await writeFile(
+ 'app/src/main/kotlin/com/acme/app/Main.kt',
+ 'package com.acme.app\nimport com.acme.lib.Model\nfun main() {}\n',
+ );
+
+ const repos = { lib: 'lib', app: 'app' };
+ const repoPaths = new Map([
+ ['lib', path.join(tmpDir, 'lib')],
+ ['app', path.join(tmpDir, 'app')],
+ ]);
+
+ const result = await extractJavaWorkspaceLinks(repos, repoPaths);
+
+ expect(result.links).toHaveLength(1);
+ expect(result.links[0].contract).toBe('lib::Model');
+ });
+
+ it('discovers multiple types from the same dependency', async () => {
+ await writeFile('lib/pom.xml', pomTemplate('com.acme', 'lib'));
+ await writeFile(
+ 'lib/src/main/java/com/acme/lib/Request.java',
+ 'package com.acme.lib;\npublic class Request {}\n',
+ );
+ await writeFile(
+ 'lib/src/main/java/com/acme/lib/Response.java',
+ 'package com.acme.lib;\npublic class Response {}\n',
+ );
+
+ await writeFile('app/pom.xml', pomTemplate('com.acme', 'app', ['com.acme:lib']));
+ await writeFile(
+ 'app/src/main/java/com/acme/app/Handler.java',
+ 'package com.acme.app;\nimport com.acme.lib.Request;\nimport com.acme.lib.Response;\npublic class Handler {}\n',
+ );
+
+ const repos = { lib: 'lib', app: 'app' };
+ const repoPaths = new Map([
+ ['lib', path.join(tmpDir, 'lib')],
+ ['app', path.join(tmpDir, 'app')],
+ ]);
+
+ const result = await extractJavaWorkspaceLinks(repos, repoPaths);
+
+ expect(result.links).toHaveLength(2);
+ const contracts = result.links.map((l) => l.contract).sort();
+ expect(contracts).toEqual(['lib::Request', 'lib::Response']);
+ });
+});
diff --git a/gitnexus/test/unit/group/manifest-extractor.test.ts b/gitnexus/test/unit/group/manifest-extractor.test.ts
index 997445f2e..bb4e203fa 100644
--- a/gitnexus/test/unit/group/manifest-extractor.test.ts
+++ b/gitnexus/test/unit/group/manifest-extractor.test.ts
@@ -596,7 +596,7 @@ describe('ManifestExtractor', () => {
[
'engine/thales',
async (_cypher, params) => {
- if (params?.contract === 'Expression') {
+ if (params?.symbolName === 'Expression') {
return [
{
uid: 'uid-expression-struct',
@@ -611,7 +611,7 @@ describe('ManifestExtractor', () => {
[
'parser/mathlex',
async (_cypher, params) => {
- if (params?.contract === 'Expression') {
+ if (params?.symbolName === 'Expression') {
return [
{
uid: 'uid-expression-enum',
@@ -640,6 +640,42 @@ describe('ManifestExtractor', () => {
expect(result.crossLinks[0].matchType).toBe('manifest');
});
+ it('custom contract with qualified name (provider::Symbol) strips prefix before graph query', async () => {
+ const links: GroupManifestLink[] = [
+ {
+ from: 'parser/mathlex',
+ to: 'engine/thales',
+ type: 'custom',
+ contract: 'mathlex::Expression',
+ role: 'provider',
+ },
+ ];
+
+ let capturedParams: Record | undefined;
+ const dbExecutors = new Map<
+ string,
+ (cypher: string, params?: Record) => Promise[]>
+ >([
+ [
+ 'parser/mathlex',
+ async (_cypher, params) => {
+ capturedParams = params;
+ if (params?.symbolName === 'Expression') {
+ return [{ uid: 'uid-expr', name: 'Expression', filePath: 'src/ast.rs' }];
+ }
+ return [];
+ },
+ ],
+ ['engine/thales', async () => []],
+ ]);
+
+ const result = await extractor.extractFromManifest(links, dbExecutors);
+ const provider = result.contracts.find((c) => c.role === 'provider');
+
+ expect(capturedParams?.symbolName).toBe('Expression');
+ expect(provider?.symbolUid).toBe('uid-expr');
+ });
+
it('falls back to synthetic uid when custom symbol not found in graph', async () => {
const links: GroupManifestLink[] = [
{
@@ -714,7 +750,7 @@ describe('ManifestExtractor', () => {
[
'parser/mathlex',
async (_cypher, params) => {
- if (params?.contract === 'Token') {
+ if (params?.symbolName === 'Token') {
return [{ uid: 'uid-token-first', name: 'Token', filePath: 'src/ast.rs' }];
}
return [];
@@ -723,7 +759,7 @@ describe('ManifestExtractor', () => {
[
'engine/thales',
async (_cypher, params) => {
- if (params?.contract === 'Token') {
+ if (params?.symbolName === 'Token') {
return [{ uid: 'uid-token-consumer', name: 'Token', filePath: 'src/lexer.rs' }];
}
return [];
diff --git a/gitnexus/test/unit/group/node-workspace-extractor.test.ts b/gitnexus/test/unit/group/node-workspace-extractor.test.ts
new file mode 100644
index 000000000..163b0be37
--- /dev/null
+++ b/gitnexus/test/unit/group/node-workspace-extractor.test.ts
@@ -0,0 +1,285 @@
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import os from 'node:os';
+import { extractNodeWorkspaceLinks } from '../../../src/core/group/extractors/node-workspace-extractor.js';
+
+describe('NodeWorkspaceExtractor', () => {
+ let tmpDir: string;
+
+ beforeEach(async () => {
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-node-ws-'));
+ });
+
+ afterEach(async () => {
+ await fs.rm(tmpDir, { recursive: true, force: true });
+ });
+
+ async function writeFile(relPath: string, content: string) {
+ const absPath = path.join(tmpDir, relPath);
+ await fs.mkdir(path.dirname(absPath), { recursive: true });
+ await fs.writeFile(absPath, content, 'utf-8');
+ }
+
+ it('discovers cross-package ES imports', async () => {
+ await writeFile(
+ 'pkg-a/package.json',
+ JSON.stringify({ name: '@myorg/shared', version: '1.0.0' }),
+ );
+ await writeFile('pkg-a/src/index.ts', 'export class Config {}\nexport class Logger {}\n');
+
+ await writeFile(
+ 'pkg-b/package.json',
+ JSON.stringify({
+ name: '@myorg/api',
+ version: '1.0.0',
+ dependencies: { '@myorg/shared': 'workspace:*' },
+ }),
+ );
+ await writeFile(
+ 'pkg-b/src/server.ts',
+ "import { Config } from '@myorg/shared';\nconst c = new Config();\n",
+ );
+
+ const repos = {
+ 'libs/shared': '@myorg/shared',
+ 'services/api': '@myorg/api',
+ };
+ const repoPaths = new Map([
+ ['libs/shared', path.join(tmpDir, 'pkg-a')],
+ ['services/api', path.join(tmpDir, 'pkg-b')],
+ ]);
+
+ const result = await extractNodeWorkspaceLinks(repos, repoPaths);
+
+ expect(result.links).toHaveLength(1);
+ expect(result.links[0]).toEqual({
+ from: 'libs/shared',
+ to: 'services/api',
+ type: 'custom',
+ contract: '@myorg/shared::Config',
+ role: 'provider',
+ });
+ });
+
+ it('handles default imports (PascalCase)', async () => {
+ await writeFile(
+ 'ui-lib/package.json',
+ JSON.stringify({ name: 'ui-components', version: '1.0.0' }),
+ );
+ await writeFile('ui-lib/src/index.ts', 'export default class Button {}\n');
+
+ await writeFile(
+ 'app/package.json',
+ JSON.stringify({
+ name: 'web-app',
+ version: '1.0.0',
+ dependencies: { 'ui-components': '^1.0.0' },
+ }),
+ );
+ await writeFile(
+ 'app/src/page.tsx',
+ "import Button from 'ui-components';\nexport default function Page() { return ; }\n",
+ );
+
+ const repos = { 'libs/ui': 'ui-components', 'apps/web': 'web-app' };
+ const repoPaths = new Map([
+ ['libs/ui', path.join(tmpDir, 'ui-lib')],
+ ['apps/web', path.join(tmpDir, 'app')],
+ ]);
+
+ const result = await extractNodeWorkspaceLinks(repos, repoPaths);
+
+ expect(result.links).toHaveLength(1);
+ expect(result.links[0].contract).toBe('ui-components::Button');
+ });
+
+ it('handles CommonJS destructured require', async () => {
+ await writeFile('lib/package.json', JSON.stringify({ name: 'auth-lib', version: '1.0.0' }));
+ await writeFile('lib/src/index.js', 'module.exports = { Authenticator: class {} };\n');
+
+ await writeFile(
+ 'svc/package.json',
+ JSON.stringify({
+ name: 'api-svc',
+ version: '1.0.0',
+ dependencies: { 'auth-lib': 'workspace:*' },
+ }),
+ );
+ await writeFile('svc/src/handler.js', "const { Authenticator } = require('auth-lib');\n");
+
+ const repos = { lib: 'auth-lib', svc: 'api-svc' };
+ const repoPaths = new Map([
+ ['lib', path.join(tmpDir, 'lib')],
+ ['svc', path.join(tmpDir, 'svc')],
+ ]);
+
+ const result = await extractNodeWorkspaceLinks(repos, repoPaths);
+
+ expect(result.links).toHaveLength(1);
+ expect(result.links[0].contract).toBe('auth-lib::Authenticator');
+ });
+
+ it('handles scoped package imports with subpaths', async () => {
+ await writeFile('core/package.json', JSON.stringify({ name: '@acme/core', version: '2.0.0' }));
+ await writeFile('core/src/models.ts', 'export class User {}\n');
+
+ await writeFile(
+ 'web/package.json',
+ JSON.stringify({
+ name: '@acme/web',
+ version: '1.0.0',
+ dependencies: { '@acme/core': 'workspace:*' },
+ }),
+ );
+ await writeFile('web/src/routes.ts', "import { User } from '@acme/core/models';\n");
+
+ const repos = { core: '@acme/core', web: '@acme/web' };
+ const repoPaths = new Map([
+ ['core', path.join(tmpDir, 'core')],
+ ['web', path.join(tmpDir, 'web')],
+ ]);
+
+ const result = await extractNodeWorkspaceLinks(repos, repoPaths);
+
+ expect(result.links).toHaveLength(1);
+ expect(result.links[0].contract).toBe('@acme/core::User');
+ });
+
+ it('ignores camelCase/snake_case imports (non-type exports)', async () => {
+ await writeFile('lib/package.json', JSON.stringify({ name: 'utils', version: '1.0.0' }));
+ await writeFile('lib/src/index.ts', 'export function helper() {}\nexport class Formatter {}\n');
+
+ await writeFile(
+ 'app/package.json',
+ JSON.stringify({
+ name: 'myapp',
+ version: '1.0.0',
+ dependencies: { utils: 'workspace:*' },
+ }),
+ );
+ await writeFile('app/src/main.ts', "import { helper, Formatter } from 'utils';\n");
+
+ const repos = { lib: 'utils', app: 'myapp' };
+ const repoPaths = new Map([
+ ['lib', path.join(tmpDir, 'lib')],
+ ['app', path.join(tmpDir, 'app')],
+ ]);
+
+ const result = await extractNodeWorkspaceLinks(repos, repoPaths);
+
+ expect(result.links).toHaveLength(1);
+ expect(result.links[0].contract).toBe('utils::Formatter');
+ });
+
+ it('skips repos without package.json', async () => {
+ await writeFile('rust-app/Cargo.toml', '[package]\nname = "rapp"\nversion = "0.1.0"\n');
+ await writeFile('rust-app/src/main.rs', 'fn main() {}\n');
+
+ const repos = { app: 'rapp' };
+ const repoPaths = new Map([['app', path.join(tmpDir, 'rust-app')]]);
+
+ const result = await extractNodeWorkspaceLinks(repos, repoPaths);
+
+ expect(result.links).toHaveLength(0);
+ expect(result.discoveredPackages.size).toBe(0);
+ });
+
+ it('deduplicates identical imports from multiple files', async () => {
+ await writeFile('lib/package.json', JSON.stringify({ name: 'shared', version: '1.0.0' }));
+ await writeFile('lib/src/index.ts', 'export class Config {}\n');
+
+ await writeFile(
+ 'app/package.json',
+ JSON.stringify({
+ name: 'myapp',
+ version: '1.0.0',
+ dependencies: { shared: 'workspace:*' },
+ }),
+ );
+ await writeFile('app/src/a.ts', "import { Config } from 'shared';\n");
+ await writeFile('app/src/b.ts', "import { Config } from 'shared';\n");
+
+ const repos = { lib: 'shared', app: 'myapp' };
+ const repoPaths = new Map([
+ ['lib', path.join(tmpDir, 'lib')],
+ ['app', path.join(tmpDir, 'app')],
+ ]);
+
+ const result = await extractNodeWorkspaceLinks(repos, repoPaths);
+
+ expect(result.links).toHaveLength(1);
+ });
+
+ it('handles aliased imports (import { Foo as Bar })', async () => {
+ await writeFile('lib/package.json', JSON.stringify({ name: 'models', version: '1.0.0' }));
+ await writeFile('lib/src/index.ts', 'export class Entity {}\n');
+
+ await writeFile(
+ 'app/package.json',
+ JSON.stringify({
+ name: 'myapp',
+ version: '1.0.0',
+ dependencies: { models: 'workspace:*' },
+ }),
+ );
+ await writeFile('app/src/main.ts', "import { Entity as BaseEntity } from 'models';\n");
+
+ const repos = { lib: 'models', app: 'myapp' };
+ const repoPaths = new Map([
+ ['lib', path.join(tmpDir, 'lib')],
+ ['app', path.join(tmpDir, 'app')],
+ ]);
+
+ const result = await extractNodeWorkspaceLinks(repos, repoPaths);
+
+ expect(result.links).toHaveLength(1);
+ expect(result.links[0].contract).toBe('models::Entity');
+ });
+
+ it('handles multiple packages importing from the same provider', async () => {
+ await writeFile(
+ 'shared/package.json',
+ JSON.stringify({ name: '@org/shared', version: '1.0.0' }),
+ );
+ await writeFile('shared/src/index.ts', 'export class Schema {}\n');
+
+ await writeFile(
+ 'api/package.json',
+ JSON.stringify({
+ name: '@org/api',
+ version: '1.0.0',
+ dependencies: { '@org/shared': 'workspace:*' },
+ }),
+ );
+ await writeFile('api/src/index.ts', "import { Schema } from '@org/shared';\n");
+
+ await writeFile(
+ 'worker/package.json',
+ JSON.stringify({
+ name: '@org/worker',
+ version: '1.0.0',
+ dependencies: { '@org/shared': 'workspace:*' },
+ }),
+ );
+ await writeFile('worker/src/index.ts', "import { Schema } from '@org/shared';\n");
+
+ const repos = {
+ libs: '@org/shared',
+ api: '@org/api',
+ worker: '@org/worker',
+ };
+ const repoPaths = new Map([
+ ['libs', path.join(tmpDir, 'shared')],
+ ['api', path.join(tmpDir, 'api')],
+ ['worker', path.join(tmpDir, 'worker')],
+ ]);
+
+ const result = await extractNodeWorkspaceLinks(repos, repoPaths);
+
+ expect(result.links).toHaveLength(2);
+ const targets = result.links.map((l) => l.to).sort();
+ expect(targets).toEqual(['api', 'worker']);
+ expect(result.links.every((l) => l.contract === '@org/shared::Schema')).toBe(true);
+ });
+});
diff --git a/gitnexus/test/unit/group/python-workspace-extractor.test.ts b/gitnexus/test/unit/group/python-workspace-extractor.test.ts
new file mode 100644
index 000000000..96a8b5e3f
--- /dev/null
+++ b/gitnexus/test/unit/group/python-workspace-extractor.test.ts
@@ -0,0 +1,241 @@
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import os from 'node:os';
+import { extractPythonWorkspaceLinks } from '../../../src/core/group/extractors/python-workspace-extractor.js';
+
+describe('PythonWorkspaceExtractor', () => {
+ let tmpDir: string;
+
+ beforeEach(async () => {
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-py-ws-'));
+ });
+
+ afterEach(async () => {
+ await fs.rm(tmpDir, { recursive: true, force: true });
+ });
+
+ async function writeFile(relPath: string, content: string) {
+ const absPath = path.join(tmpDir, relPath);
+ await fs.mkdir(path.dirname(absPath), { recursive: true });
+ await fs.writeFile(absPath, content, 'utf-8');
+ }
+
+ it('discovers cross-package imports via pyproject.toml', async () => {
+ await writeFile(
+ 'models/pyproject.toml',
+ '[project]\nname = "shared-models"\nversion = "0.1.0"\ndependencies = []\n',
+ );
+ await writeFile('models/shared_models/__init__.py', 'class Schema: pass\n');
+
+ await writeFile(
+ 'api/pyproject.toml',
+ '[project]\nname = "api-server"\nversion = "0.1.0"\ndependencies = [\n "shared-models>=0.1.0",\n]\n',
+ );
+ await writeFile('api/api_server/main.py', 'from shared_models import Schema\n');
+
+ const repos = { models: 'shared-models', api: 'api-server' };
+ const repoPaths = new Map([
+ ['models', path.join(tmpDir, 'models')],
+ ['api', path.join(tmpDir, 'api')],
+ ]);
+
+ const result = await extractPythonWorkspaceLinks(repos, repoPaths);
+
+ expect(result.links).toHaveLength(1);
+ expect(result.links[0]).toEqual({
+ from: 'models',
+ to: 'api',
+ type: 'custom',
+ contract: 'shared-models::Schema',
+ role: 'provider',
+ });
+ });
+
+ it('discovers imports via setup.py', async () => {
+ await writeFile(
+ 'core/setup.py',
+ "from setuptools import setup\nsetup(name='mycore', version='1.0', install_requires=[])\n",
+ );
+ await writeFile('core/mycore/__init__.py', 'class Engine: pass\n');
+
+ await writeFile(
+ 'app/setup.py',
+ "from setuptools import setup\nsetup(name='myapp', version='1.0', install_requires=['mycore>=1.0'])\n",
+ );
+ await writeFile('app/myapp/run.py', 'from mycore import Engine\n');
+
+ const repos = { core: 'mycore', app: 'myapp' };
+ const repoPaths = new Map([
+ ['core', path.join(tmpDir, 'core')],
+ ['app', path.join(tmpDir, 'app')],
+ ]);
+
+ const result = await extractPythonWorkspaceLinks(repos, repoPaths);
+
+ expect(result.links).toHaveLength(1);
+ expect(result.links[0].contract).toBe('mycore::Engine');
+ });
+
+ it('handles hyphenated package names (normalized to underscore in imports)', async () => {
+ await writeFile(
+ 'lib/pyproject.toml',
+ '[project]\nname = "my-utils"\nversion = "0.1.0"\ndependencies = []\n',
+ );
+ await writeFile('lib/my_utils/__init__.py', 'class Helper: pass\n');
+
+ await writeFile(
+ 'svc/pyproject.toml',
+ '[project]\nname = "my-service"\nversion = "0.1.0"\ndependencies = [\n "my-utils",\n]\n',
+ );
+ await writeFile('svc/my_service/main.py', 'from my_utils import Helper\n');
+
+ const repos = { lib: 'my-utils', svc: 'my-service' };
+ const repoPaths = new Map([
+ ['lib', path.join(tmpDir, 'lib')],
+ ['svc', path.join(tmpDir, 'svc')],
+ ]);
+
+ const result = await extractPythonWorkspaceLinks(repos, repoPaths);
+
+ expect(result.links).toHaveLength(1);
+ expect(result.links[0].contract).toBe('my-utils::Helper');
+ });
+
+ it('handles submodule imports (from pkg.sub import Class)', async () => {
+ await writeFile(
+ 'lib/pyproject.toml',
+ '[project]\nname = "datalib"\nversion = "0.1.0"\ndependencies = []\n',
+ );
+ await writeFile('lib/datalib/models.py', 'class Record: pass\n');
+
+ await writeFile(
+ 'app/pyproject.toml',
+ '[project]\nname = "myapp"\nversion = "0.1.0"\ndependencies = [\n "datalib",\n]\n',
+ );
+ await writeFile('app/myapp/main.py', 'from datalib.models import Record\n');
+
+ const repos = { lib: 'datalib', app: 'myapp' };
+ const repoPaths = new Map([
+ ['lib', path.join(tmpDir, 'lib')],
+ ['app', path.join(tmpDir, 'app')],
+ ]);
+
+ const result = await extractPythonWorkspaceLinks(repos, repoPaths);
+
+ expect(result.links).toHaveLength(1);
+ expect(result.links[0].contract).toBe('datalib::Record');
+ });
+
+ it('ignores snake_case imports (functions, not types)', async () => {
+ await writeFile(
+ 'lib/pyproject.toml',
+ '[project]\nname = "utils"\nversion = "0.1.0"\ndependencies = []\n',
+ );
+ await writeFile('lib/utils/__init__.py', 'def helper(): pass\nclass Config: pass\n');
+
+ await writeFile(
+ 'app/pyproject.toml',
+ '[project]\nname = "myapp"\nversion = "0.1.0"\ndependencies = [\n "utils",\n]\n',
+ );
+ await writeFile('app/myapp/main.py', 'from utils import helper, Config\n');
+
+ const repos = { lib: 'utils', app: 'myapp' };
+ const repoPaths = new Map([
+ ['lib', path.join(tmpDir, 'lib')],
+ ['app', path.join(tmpDir, 'app')],
+ ]);
+
+ const result = await extractPythonWorkspaceLinks(repos, repoPaths);
+
+ expect(result.links).toHaveLength(1);
+ expect(result.links[0].contract).toBe('utils::Config');
+ });
+
+ it('skips repos without Python manifest', async () => {
+ await writeFile('js-app/package.json', '{"name": "js-app"}');
+
+ const repos = { app: 'js-app' };
+ const repoPaths = new Map([['app', path.join(tmpDir, 'js-app')]]);
+
+ const result = await extractPythonWorkspaceLinks(repos, repoPaths);
+
+ expect(result.links).toHaveLength(0);
+ expect(result.discoveredPackages.size).toBe(0);
+ });
+
+ it('deduplicates identical imports from multiple files', async () => {
+ await writeFile(
+ 'lib/pyproject.toml',
+ '[project]\nname = "shared"\nversion = "0.1.0"\ndependencies = []\n',
+ );
+ await writeFile('lib/shared/__init__.py', 'class Config: pass\n');
+
+ await writeFile(
+ 'app/pyproject.toml',
+ '[project]\nname = "myapp"\nversion = "0.1.0"\ndependencies = [\n "shared",\n]\n',
+ );
+ await writeFile('app/myapp/a.py', 'from shared import Config\n');
+ await writeFile('app/myapp/b.py', 'from shared import Config\n');
+
+ const repos = { lib: 'shared', app: 'myapp' };
+ const repoPaths = new Map([
+ ['lib', path.join(tmpDir, 'lib')],
+ ['app', path.join(tmpDir, 'app')],
+ ]);
+
+ const result = await extractPythonWorkspaceLinks(repos, repoPaths);
+
+ expect(result.links).toHaveLength(1);
+ });
+
+ it('handles aliased imports (from pkg import Foo as Bar)', async () => {
+ await writeFile(
+ 'lib/pyproject.toml',
+ '[project]\nname = "models"\nversion = "0.1.0"\ndependencies = []\n',
+ );
+ await writeFile('lib/models/__init__.py', 'class Entity: pass\n');
+
+ await writeFile(
+ 'app/pyproject.toml',
+ '[project]\nname = "myapp"\nversion = "0.1.0"\ndependencies = [\n "models",\n]\n',
+ );
+ await writeFile('app/myapp/main.py', 'from models import Entity as BaseEntity\n');
+
+ const repos = { lib: 'models', app: 'myapp' };
+ const repoPaths = new Map([
+ ['lib', path.join(tmpDir, 'lib')],
+ ['app', path.join(tmpDir, 'app')],
+ ]);
+
+ const result = await extractPythonWorkspaceLinks(repos, repoPaths);
+
+ expect(result.links).toHaveLength(1);
+ expect(result.links[0].contract).toBe('models::Entity');
+ });
+
+ it('reads optional-dependencies from pyproject.toml', async () => {
+ await writeFile(
+ 'lib/pyproject.toml',
+ '[project]\nname = "extras"\nversion = "0.1.0"\ndependencies = []\n',
+ );
+ await writeFile('lib/extras/__init__.py', 'class Plugin: pass\n');
+
+ await writeFile(
+ 'app/pyproject.toml',
+ '[project]\nname = "myapp"\nversion = "0.1.0"\ndependencies = []\n\n[project.optional-dependencies]\ndev = [\n "extras>=0.1",\n]\n',
+ );
+ await writeFile('app/myapp/main.py', 'from extras import Plugin\n');
+
+ const repos = { lib: 'extras', app: 'myapp' };
+ const repoPaths = new Map([
+ ['lib', path.join(tmpDir, 'lib')],
+ ['app', path.join(tmpDir, 'app')],
+ ]);
+
+ const result = await extractPythonWorkspaceLinks(repos, repoPaths);
+
+ expect(result.links).toHaveLength(1);
+ expect(result.links[0].contract).toBe('extras::Plugin');
+ });
+});
diff --git a/gitnexus/test/unit/group/sync.test.ts b/gitnexus/test/unit/group/sync.test.ts
index 87a7c6645..a6b6376c0 100644
--- a/gitnexus/test/unit/group/sync.test.ts
+++ b/gitnexus/test/unit/group/sync.test.ts
@@ -25,6 +25,7 @@ describe('syncGroup', () => {
topics: false,
shared_libs: false,
embedding_fallback: false,
+ workspace_deps: false,
},
matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 },
});
@@ -312,8 +313,7 @@ describe('syncGroup', () => {
});
it('writes registry to groupDir when skipWrite is false', async () => {
- const tmpDir = path.join(os.tmpdir(), `gitnexus-sync-write-${Date.now()}`);
- fs.mkdirSync(tmpDir, { recursive: true });
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-sync-write-'));
try {
const config = makeConfig({});
@@ -371,8 +371,7 @@ describe('syncGroup', () => {
});
it('workspace_deps: true discovers Rust crate links through syncGroup', async () => {
- tmpDir = path.join(os.tmpdir(), `gitnexus-sync-ws-${Date.now()}`);
- fs.mkdirSync(tmpDir, { recursive: true });
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-sync-ws-'));
writeFileSync(
'crate-a/Cargo.toml',
@@ -420,9 +419,8 @@ describe('syncGroup', () => {
expect(manifestLinks[0].to.repo).toBe('parser/mathlex');
});
- it('workspace_deps: false skips Rust workspace extraction', async () => {
- tmpDir = path.join(os.tmpdir(), `gitnexus-sync-ws-off-${Date.now()}`);
- fs.mkdirSync(tmpDir, { recursive: true });
+ it('workspace_deps: false skips workspace extraction entirely', async () => {
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-sync-ws-off-'));
writeFileSync(
'crate-a/Cargo.toml',
@@ -454,8 +452,7 @@ describe('syncGroup', () => {
});
it('discovered workspace links merge with explicit manifest links', async () => {
- tmpDir = path.join(os.tmpdir(), `gitnexus-sync-ws-merge-${Date.now()}`);
- fs.mkdirSync(tmpDir, { recursive: true });
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-sync-ws-merge-'));
writeFileSync(
'crate-a/Cargo.toml',
@@ -523,12 +520,59 @@ describe('syncGroup', () => {
});
const manifestLinks = result.crossLinks.filter((cl) => cl.matchType === 'manifest');
- expect(manifestLinks.length).toBeGreaterThanOrEqual(2);
+ expect(manifestLinks).toHaveLength(2);
const contractIds = manifestLinks.map((cl) => cl.contractId);
expect(contractIds).toContain('http::GET::/api/parse');
expect(contractIds).toContain('custom::mathlex::Expression');
});
+
+ it('discovers Node workspace links through syncGroup orchestrator', async () => {
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-sync-ws-node-'));
+
+ writeFileSync('shared/package.json', '{"name": "@myorg/shared", "version": "1.0.0"}');
+ writeFileSync('shared/src/index.ts', 'export class Config {}\n');
+
+ writeFileSync(
+ 'app/package.json',
+ '{"name": "@myorg/app", "version": "1.0.0", "dependencies": {"@myorg/shared": "workspace:*"}}',
+ );
+ writeFileSync('app/src/index.ts', "import { Config } from '@myorg/shared';\n");
+
+ const mockEntries: RegistryEntry[] = [
+ {
+ name: 'shared',
+ path: path.join(tmpDir, 'shared'),
+ storagePath: path.join(tmpDir, 'shared', '.gitnexus'),
+ indexedAt: '',
+ lastCommit: '',
+ },
+ {
+ name: 'app',
+ path: path.join(tmpDir, 'app'),
+ storagePath: path.join(tmpDir, 'app', '.gitnexus'),
+ indexedAt: '',
+ lastCommit: '',
+ },
+ ];
+
+ const repoManager = await import('../../../src/storage/repo-manager.js');
+ vi.spyOn(repoManager, 'readRegistry').mockResolvedValue(mockEntries);
+
+ const config = makeWsConfig({ 'pkg/shared': 'shared', 'pkg/app': 'app' }, true);
+
+ const result = await syncGroup(config, {
+ extractorOverride: async () => [],
+ skipWrite: true,
+ });
+
+ const manifestLinks = result.crossLinks.filter((cl) => cl.matchType === 'manifest');
+ expect(manifestLinks).toHaveLength(1);
+ const nodeLink = manifestLinks.find(
+ (cl) => cl.contractId === 'custom::@myorg/shared::Config',
+ );
+ expect(nodeLink).toBeDefined();
+ });
});
});
diff --git a/gitnexus/test/unit/mro-processor.test.ts b/gitnexus/test/unit/mro-processor.test.ts
index d313fb2e5..c2adf867c 100644
--- a/gitnexus/test/unit/mro-processor.test.ts
+++ b/gitnexus/test/unit/mro-processor.test.ts
@@ -540,10 +540,35 @@ describe('computeMRO', () => {
expect(result).toBeDefined();
});
+ it('returns null for C3 merge-conflict inconsistency (non-cyclic)', () => {
+ // Classic incompatible ordering: A(X,Y) and B(Y,X) → C(A,B) is unresolvable
+ const graph = createKnowledgeGraph();
+ addClass(graph, 'X', 'python');
+ addClass(graph, 'Y', 'python');
+ addClass(graph, 'A', 'python');
+ addClass(graph, 'B', 'python');
+ addClass(graph, 'C', 'python');
+ addExtends(graph, 'A', 'X');
+ addExtends(graph, 'A', 'Y');
+ addExtends(graph, 'B', 'Y');
+ addExtends(graph, 'B', 'X');
+ addExtends(graph, 'C', 'A');
+ addExtends(graph, 'C', 'B');
+ addMethod(graph, 'X', 'foo');
+
+ const result = computeMRO(graph);
+ expect(result).toBeDefined();
+ // C3 fails for C — falls back to BFS ancestors
+ const entryC = result.entries.find((e) => e.className === 'C');
+ expect(entryC).toBeDefined();
+ // BFS fallback still produces an MRO (just not C3-ordered)
+ expect(entryC!.mro.length).toBeGreaterThanOrEqual(2);
+ });
+ });
+
+ // ---- Performance (deep chains) -------------------------------------------
+ describe('performance', () => {
it('handles very deep single-inheritance chain without stack overflow', () => {
- // Chain of 2000 classes: C0 ← C1 ← C2 ← ... ← C1999
- // The iterative c3Linearize handles this without blowing the stack.
- // (The recursive version overflows at ~1K–5K levels depending on platform.)
const graph = createKnowledgeGraph();
const DEPTH = 2000;
for (let i = 0; i < DEPTH; i++) {
@@ -552,12 +577,10 @@ describe('computeMRO', () => {
for (let i = 1; i < DEPTH; i++) {
addExtends(graph, `C${i}`, `C${i - 1}`);
}
- // Add a method on the root so MRO produces an entry
addMethod(graph, 'C0', 'baseMethod');
const result = computeMRO(graph);
expect(result).toBeDefined();
- // The deepest class should have all ancestors in its MRO
const deepest = result.entries.find((e) => e.className === `C${DEPTH - 1}`);
if (deepest) {
expect(deepest.mro.length).toBe(DEPTH - 1);
diff --git a/gitnexus/test/unit/rate-limit.test.ts b/gitnexus/test/unit/rate-limit.test.ts
new file mode 100644
index 000000000..0087cfc87
--- /dev/null
+++ b/gitnexus/test/unit/rate-limit.test.ts
@@ -0,0 +1,250 @@
+/**
+ * Tests for createRouteLimiter and the integration shape used by api.ts.
+ *
+ * Closes the U4 test gap (CodeQL js/missing-rate-limiting). Without these,
+ * a refactor that drops the limiter middleware from any route would silently
+ * regress and CodeQL would re-fire — but no test would fail before reaching
+ * CI.
+ *
+ * Two layers of coverage:
+ * 1. Helper unit tests — createRouteLimiter returns distinct middleware
+ * per call, has the right signature, exposes the right error shape.
+ * 2. Integration tests — mount the same factory on a tiny isolated express
+ * app that does fs.readFile (the exact CodeQL sink class) and prove the
+ * 429 fires after the configured limit. Tight windowMs (100ms) + small
+ * sleep (200ms) keeps the suite fast and resistant to CI scheduling
+ * jitter; each test uses a fresh limiter so counter state never carries
+ * between tests.
+ */
+import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
+import express, { type Express } from 'express';
+import http from 'node:http';
+import path from 'node:path';
+import fs from 'node:fs/promises';
+import os from 'node:os';
+import { createRouteLimiter } from '../../src/server/validation.js';
+
+let tmpFile: string;
+
+beforeAll(async () => {
+ // Real fs.readFile target so the route does the same kind of FS work
+ // the production routes do — keeps the test honest about what it covers.
+ tmpFile = path.join(
+ await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-ratelimit-')),
+ 'fixture.txt',
+ );
+ await fs.writeFile(tmpFile, 'hello\n', 'utf-8');
+});
+
+afterAll(async () => {
+ await fs.rm(path.dirname(tmpFile), { recursive: true, force: true });
+});
+
+// Build a fresh app + server per test so counter state never carries between
+// tests. Tight windowMs keeps the limiter responsive; the 200ms reset sleep
+// in window-rollover tests gives 2x margin even on slow CI.
+const buildApp = (limit: number, windowMs = 100): Express => {
+ const app = express();
+ app.set('trust proxy', 'loopback, linklocal, uniquelocal');
+ app.get('/test/file', createRouteLimiter({ windowMs, limit }), async (_req, res) => {
+ const content = await fs.readFile(tmpFile, 'utf-8');
+ res.json({ content });
+ });
+ return app;
+};
+
+const startServer = (app: Express): Promise<{ server: http.Server; baseUrl: string }> =>
+ new Promise((resolve) => {
+ const server = app.listen(0, '127.0.0.1', () => {
+ const addr = server.address();
+ const baseUrl = typeof addr === 'object' && addr ? `http://127.0.0.1:${addr.port}` : '';
+ resolve({ server, baseUrl });
+ });
+ });
+
+const stopServer = (server: http.Server): Promise =>
+ new Promise((resolve) => server.close(() => resolve()));
+
+describe('createRouteLimiter — defaults', () => {
+ it('returns a different middleware instance per call (independent counters)', () => {
+ const a = createRouteLimiter();
+ const b = createRouteLimiter();
+ expect(a).not.toBe(b);
+ });
+
+ it('produces a callable express RequestHandler', () => {
+ const limiter = createRouteLimiter();
+ expect(typeof limiter).toBe('function');
+ // express middleware signature is (req, res, next) — 3 args.
+ expect(limiter.length).toBe(3);
+ });
+});
+
+describe('createRouteLimiter — integration with a real route', () => {
+ let server: http.Server;
+ let baseUrl: string;
+
+ beforeEach(async () => {
+ ({ server, baseUrl } = await startServer(buildApp(3)));
+ });
+
+ afterEach(async () => {
+ await stopServer(server);
+ });
+
+ // The exact regression guard CodeQL would re-fire if a maintainer
+ // dropped createRouteLimiter from any of the 4 protected routes:
+ // without the limiter, max+1 requests all return 200.
+ it('lets max requests through and rejects the next one with 429', async () => {
+ for (let i = 1; i <= 3; i++) {
+ const res = await fetch(`${baseUrl}/test/file`);
+ expect(res.status).toBe(200);
+ }
+ const res = await fetch(`${baseUrl}/test/file`);
+ expect(res.status).toBe(429);
+ const body = await res.json();
+ expect(body.error).toContain('Too many');
+ });
+
+ it('emits draft-7 RateLimit response header (combined form), not legacy X-RateLimit-*', async () => {
+ const res = await fetch(`${baseUrl}/test/file`);
+ expect(res.status).toBe(200);
+ // draft-7: single combined `RateLimit` header in `limit=N, remaining=N, reset=N` shape,
+ // NO individual `X-RateLimit-*` legacy keys.
+ const rateLimitHeader = res.headers.get('ratelimit');
+ expect(rateLimitHeader).toMatch(/limit=\d+/);
+ expect(rateLimitHeader).toMatch(/remaining=\d+/);
+ expect(rateLimitHeader).toMatch(/reset=\d+/);
+ expect(res.headers.get('x-ratelimit-limit')).toBeNull();
+ });
+
+ it('429 response body uses the project { error } JSON shape', async () => {
+ // Trip the limiter.
+ for (let i = 1; i <= 3; i++) await fetch(`${baseUrl}/test/file`);
+ const res = await fetch(`${baseUrl}/test/file`);
+ expect(res.status).toBe(429);
+ const body = await res.json();
+ expect(body).toEqual({ error: expect.stringContaining('Too many') });
+ });
+
+ it('429 response includes a Retry-After header so clients can back off', async () => {
+ for (let i = 1; i <= 3; i++) await fetch(`${baseUrl}/test/file`);
+ const res = await fetch(`${baseUrl}/test/file`);
+ expect(res.status).toBe(429);
+ const retryAfter = res.headers.get('retry-after');
+ expect(retryAfter).toBeTruthy();
+ // express-rate-limit v8 emits Retry-After in integer-seconds form. The
+ // RFC also allows HTTP-date, but ERL does not use that shape; if a
+ // future version switches, this assertion needs an HTTP-date branch.
+ const seconds = Number(retryAfter);
+ expect(Number.isFinite(seconds) && seconds >= 0).toBe(true);
+ });
+
+ it('window resets after windowMs — counter does not carry across windows', async () => {
+ // Trip the limiter.
+ for (let i = 1; i <= 3; i++) await fetch(`${baseUrl}/test/file`);
+ const tripped = await fetch(`${baseUrl}/test/file`);
+ expect(tripped.status).toBe(429);
+ // Wait for the window to roll over (100ms window + 200ms margin).
+ await new Promise((r) => setTimeout(r, 200));
+ const reset = await fetch(`${baseUrl}/test/file`);
+ expect(reset.status).toBe(200);
+ });
+});
+
+// Behavioral pin replacing the prior `expect(DEFAULT_RATE_LIMIT_RPM).toBe(60)`
+// constant assertion — that test pinned the magic number, this test pins the
+// observable contract that the production default does not 429 at typical
+// interactive load.
+describe('createRouteLimiter — production default', () => {
+ it('default policy permits 60 requests in a minute (no opts override)', async () => {
+ // Build an app that uses the production-default limiter (no opts override).
+ // 60 requests is well under the default 60 rpm/IP, so all should pass.
+ // Going to 61 would 429 but takes the full window to test deterministically;
+ // the contract we want pinned here is "default does not throttle interactive
+ // use" — the 429 path is already covered by the integration tests above.
+ const { server, baseUrl } = await startServer(
+ (() => {
+ const app = express();
+ app.set('trust proxy', 'loopback, linklocal, uniquelocal');
+ app.get('/test/file', createRouteLimiter(), async (_req, res) => {
+ const content = await fs.readFile(tmpFile, 'utf-8');
+ res.json({ content });
+ });
+ return app;
+ })(),
+ );
+ try {
+ // Send 60 requests — all should succeed under the default policy.
+ for (let i = 1; i <= 60; i++) {
+ const res = await fetch(`${baseUrl}/test/file`);
+ if (res.status !== 200) {
+ throw new Error(`request ${i}/60 returned ${res.status} under default policy`);
+ }
+ }
+ } finally {
+ await stopServer(server);
+ }
+ });
+});
+
+// Production-wiring assertions — proves each of the 4 protected routes in
+// api.ts actually has rate-limit middleware. Closes the gap reviewers flagged
+// where a maintainer could drop createRouteLimiter from a route and no test
+// would fail (only CodeQL would re-fire next scan).
+//
+// Walks the express router stack on a real createServer-built app, finds
+// each protected route by method+path, and asserts the middleware chain
+// includes the express-rate-limit handler. This is intentionally a
+// structural check (not behavioral) — the behavioral guarantees are
+// covered by the integration tests above.
+describe('production routes — rate-limit middleware wiring', () => {
+ // Small structural check that does not require booting the full server
+ // (which depends on LadybugDB, MCP transport, fork(), etc.). We grep the
+ // api.ts source for the createRouteLimiter call adjacent to each route
+ // registration. If a future refactor drops the call, the regex no longer
+ // matches and the test fails.
+ //
+ // This is admittedly a light-weight check, but it is enough to catch the
+ // single most likely regression (someone removes the middleware while
+ // editing the route handler) without dragging in the full server boot.
+
+ let apiSource: string;
+
+ beforeAll(async () => {
+ apiSource = await fs.readFile(
+ path.join(__dirname, '..', '..', 'src', 'server', 'api.ts'),
+ 'utf-8',
+ );
+ });
+
+ it('GET /api/file is wired with createRouteLimiter', () => {
+ expect(apiSource).toMatch(/app\.get\('\/api\/file',\s*createRouteLimiter\(/);
+ });
+
+ it('GET /api/grep is wired with createRouteLimiter', () => {
+ expect(apiSource).toMatch(/app\.get\('\/api\/grep',\s*createRouteLimiter\(/);
+ });
+
+ it('DELETE /api/repo is wired with createRouteLimiter', () => {
+ expect(apiSource).toMatch(/app\.delete\('\/api\/repo',\s*createRouteLimiter\(/);
+ });
+
+ it('POST /api/analyze is wired with createRouteLimiter', () => {
+ expect(apiSource).toMatch(/app\.post\('\/api\/analyze',\s*createRouteLimiter\(/);
+ });
+
+ it('POST /api/embed is wired with createRouteLimiter', () => {
+ expect(apiSource).toMatch(/app\.post\('\/api\/embed',\s*createRouteLimiter\(/);
+ });
+
+ it('SPA fallback is wired with createRouteLimiter', () => {
+ expect(apiSource).toMatch(/app\.get\(SPA_FALLBACK_REGEX,\s*createRouteLimiter\(/);
+ });
+
+ it('createServer wires trust proxy to loopback/linklocal/uniquelocal', () => {
+ expect(apiSource).toMatch(
+ /app\.set\(\s*'trust proxy'\s*,\s*'loopback,\s*linklocal,\s*uniquelocal'\s*\)/,
+ );
+ });
+});
diff --git a/gitnexus/test/unit/registry-primary-flag.test.ts b/gitnexus/test/unit/registry-primary-flag.test.ts
index 848672a7a..5f688c732 100644
--- a/gitnexus/test/unit/registry-primary-flag.test.ts
+++ b/gitnexus/test/unit/registry-primary-flag.test.ts
@@ -108,10 +108,9 @@ describe('isRegistryPrimary', () => {
it('isolates flags per-language (one on does not affect others)', () => {
process.env['REGISTRY_PRIMARY_PYTHON'] = 'true';
expect(isRegistryPrimary(SupportedLanguages.Python)).toBe(true);
- // Java and Go are not in MIGRATED_LANGUAGES — default false stays
+ // Java is not in MIGRATED_LANGUAGES — default false stays
// false regardless of Python's flag.
expect(isRegistryPrimary(SupportedLanguages.Java)).toBe(false);
- expect(isRegistryPrimary(SupportedLanguages.Go)).toBe(false);
});
it('respects a mid-process env-var mutation (no stale cache)', () => {
@@ -149,17 +148,18 @@ describe('primaryLanguages', () => {
it('returns exactly the flipped languages (env opts in unmigrated, opts out migrated)', () => {
// Migrated languages are default-on; each must be opted out here when
- // testing explicit env overrides. Go (unmigrated) opts in; Java stays off.
+ // testing explicit env overrides. Java (unmigrated) opts in; Go stays off.
process.env['REGISTRY_PRIMARY_PYTHON'] = 'false';
process.env['REGISTRY_PRIMARY_CSHARP'] = 'false';
process.env['REGISTRY_PRIMARY_TYPESCRIPT'] = 'false';
- process.env['REGISTRY_PRIMARY_GO'] = '1';
+ process.env['REGISTRY_PRIMARY_GO'] = 'false';
+ process.env['REGISTRY_PRIMARY_JAVA'] = '1';
const enabled = primaryLanguages();
expect(enabled.has(SupportedLanguages.Python)).toBe(false);
expect(enabled.has(SupportedLanguages.CSharp)).toBe(false);
- expect(enabled.has(SupportedLanguages.Go)).toBe(true);
- expect(enabled.has(SupportedLanguages.Java)).toBe(false);
- // Only Go is on: migrated defaults overridden off, Go explicitly on.
+ expect(enabled.has(SupportedLanguages.Go)).toBe(false);
+ expect(enabled.has(SupportedLanguages.Java)).toBe(true);
+ // Only Java is on: migrated defaults overridden off, Java explicitly on.
expect(enabled.size).toBe(1);
});
diff --git a/gitnexus/test/unit/repo-manager.test.ts b/gitnexus/test/unit/repo-manager.test.ts
index 7169eb806..bbd0fd50e 100644
--- a/gitnexus/test/unit/repo-manager.test.ts
+++ b/gitnexus/test/unit/repo-manager.test.ts
@@ -870,3 +870,169 @@ describe('assertSafeStoragePath (#1003)', () => {
expect(() => assertSafeStoragePath(entry)).not.toThrow();
});
});
+
+// ─── Worktree-aware registry-name fallback (#1259) ─────────────────────
+//
+// The first @claude review on PR #1296 caught a critical gap: my initial
+// fix only patched the early-return path in `runFullAnalysis`, leaving
+// the full-analysis path (which calls `registerRepo` directly) still
+// using the worktree-slug basename when no `--name` and no remote are
+// configured. This block proves `registerRepo`'s OWN basename fallback
+// now uses the canonical repo root via `getCanonicalRepoRoot` — the
+// regression-guard for the wiring at the registry layer, complementing
+// the helper-level coverage in `git-utils.test.ts`.
+
+describe('registerRepo worktree-aware basename fallback (#1259)', () => {
+ let tmpHome: Awaited>;
+ let tmpRepo: Awaited>;
+ let savedGitnexusHome: string | undefined;
+
+ const meta: RepoMeta = {
+ repoPath: '',
+ lastCommit: 'abc1234',
+ indexedAt: '2026-05-03T00:00:00.000Z',
+ stats: { files: 1, nodes: 1 },
+ };
+
+ beforeEach(async () => {
+ tmpHome = await createTempDir('gitnexus-registry-home-');
+ tmpRepo = await createTempDir('gitnexus-canonical-repo-');
+ savedGitnexusHome = process.env.GITNEXUS_HOME;
+ process.env.GITNEXUS_HOME = tmpHome.dbPath;
+ });
+
+ afterEach(async () => {
+ if (savedGitnexusHome === undefined) delete process.env.GITNEXUS_HOME;
+ else process.env.GITNEXUS_HOME = savedGitnexusHome;
+ await tmpHome.cleanup();
+ await tmpRepo.cleanup();
+ });
+
+ it('registerRepo from a linked worktree uses canonical repo basename, not worktree slug', async () => {
+ // Set up a real git repo with at least one commit (worktree add requires
+ // a non-empty branch). No remote is configured — that's the trigger for
+ // the basename fallback this test guards.
+ execSync('git init -q', { cwd: tmpRepo.dbPath });
+ execSync('git config user.email "test@example.com"', { cwd: tmpRepo.dbPath });
+ execSync('git config user.name "Test"', { cwd: tmpRepo.dbPath });
+ execSync('git commit --allow-empty -q -m "initial"', { cwd: tmpRepo.dbPath });
+
+ const worktreeDir = path.join(tmpRepo.dbPath, 'wt-feature');
+ execSync(`git worktree add -q -b feature "${worktreeDir}"`, { cwd: tmpRepo.dbPath });
+
+ try {
+ // Call registerRepo with the WORKTREE path and NO --name. Pre-fix this
+ // would register under the worktree's basename ("wt-feature"). The
+ // canonical-root fallback in registerRepo now resolves it to the
+ // canonical repo's basename (whatever `tmpRepo`'s temp-dir basename
+ // happens to be).
+ await registerRepo(worktreeDir, meta);
+
+ const entries = await listRegisteredRepos();
+ expect(entries).toHaveLength(1);
+ // The registered name MUST NOT be the worktree slug.
+ expect(entries[0].name).not.toBe('wt-feature');
+ // It MUST match the canonical repo dir's basename. We compare via
+ // basename (not full-path equality) for the same Windows 8.3
+ // short-name reason as the `getCanonicalRepoRoot` helper tests:
+ // git and `fs.realpathSync` may resolve to different long/short
+ // forms of the same path on Windows runners, but both have the
+ // same `basename`.
+ expect(entries[0].name).toBe(path.basename(tmpRepo.dbPath));
+ } finally {
+ // Best-effort worktree teardown before the temp-dir cleanup runs.
+ try {
+ execSync(`git worktree remove -f "${worktreeDir}"`, { cwd: tmpRepo.dbPath });
+ } catch {
+ // Falls through to recursive rm in afterEach.
+ }
+ }
+ });
+
+ // Pinned by the second @claude review on PR #1296: the FIRST review-fix
+ // commit (`7ceb839b`) introduced a regression in `hasCustomAlias`. Once
+ // a worktree is registered with the canonical basename
+ // (`{name: 'repo', path: '/repo/wt-feature'}`), `hasCustomAlias` saw
+ // `'repo' !== path.basename('/repo/wt-feature') = 'wt-feature'` and
+ // wrongly classified the canonical-root name as a sticky user alias.
+ // On re-analyze the duplicate-name guard then fired against the
+ // canonical checkout's entry → `RegistryNameCollisionError` blocking
+ // the primary "per-task worktree, repeated re-analyze" workflow this
+ // PR is supposed to FIX. This test exercises the full sequence:
+ // canonical → worktree → re-worktree, with both paths registered.
+ it('canonical → worktree → re-worktree re-register does not throw collision (#1259 hasCustomAlias regression)', async () => {
+ execSync('git init -q', { cwd: tmpRepo.dbPath });
+ execSync('git config user.email "test@example.com"', { cwd: tmpRepo.dbPath });
+ execSync('git config user.name "Test"', { cwd: tmpRepo.dbPath });
+ execSync('git commit --allow-empty -q -m "initial"', { cwd: tmpRepo.dbPath });
+
+ const worktreeDir = path.join(tmpRepo.dbPath, 'wt-feature');
+ execSync(`git worktree add -q -b feature "${worktreeDir}"`, { cwd: tmpRepo.dbPath });
+
+ try {
+ // 1. Register the canonical checkout — gets the canonical basename.
+ await registerRepo(tmpRepo.dbPath, meta);
+ // 2. Register the worktree — gets the SAME canonical basename
+ // (because of the `resolveRepoIdentityRoot` fix). Two entries
+ // coexist with the same name but different paths; this is the
+ // documented "silent basename collision" behavior, not an error.
+ await registerRepo(worktreeDir, meta);
+ // 3. Re-register the worktree. Pre-`hasCustomAlias`-fix this threw
+ // `RegistryNameCollisionError` because the existing worktree
+ // entry (`{name: 'repo', path: worktreeDir}`) was misclassified
+ // as a custom alias by `hasCustomAlias`, fired the guard
+ // against the canonical entry. With the fix it must complete
+ // without throwing.
+ await expect(registerRepo(worktreeDir, meta)).resolves.toBeDefined();
+
+ // Both registry entries should still be present and named
+ // canonically.
+ const entries = await listRegisteredRepos();
+ expect(entries).toHaveLength(2);
+ const canonicalBasename = path.basename(tmpRepo.dbPath);
+ for (const entry of entries) {
+ expect(entry.name).toBe(canonicalBasename);
+ }
+ } finally {
+ try {
+ execSync(`git worktree remove -f "${worktreeDir}"`, { cwd: tmpRepo.dbPath });
+ } catch {
+ // Falls through to recursive rm in afterEach.
+ }
+ }
+ });
+
+ // Pinned by the third @claude review on PR #1296 (MEDIUM #2): the
+ // `hasGitDir` gate inside `resolveRepoIdentityRoot` is the safeguard
+ // that keeps the #1232/#1233 `--skip-git` behaviour working — an
+ // arbitrary subdir under a parent git repo (no `.git` of its own)
+ // must NOT collapse to the parent's canonical root, otherwise users
+ // who analyze a subdir get the parent repo's basename in the
+ // registry. The COOLIO `--skip-git` integration test in
+ // `skip-git-cli.test.ts` already proves this end-to-end, but no
+ // direct test sat at the `registerRepo` layer to guard the gate
+ // against future refactors. This is that direct test.
+ it('registerRepo on an arbitrary subdir under a git repo preserves the subdir basename (#1232 / #1233 gate)', async () => {
+ execSync('git init -q', { cwd: tmpRepo.dbPath });
+ execSync('git config user.email "test@example.com"', { cwd: tmpRepo.dbPath });
+ execSync('git config user.name "Test"', { cwd: tmpRepo.dbPath });
+ execSync('git commit --allow-empty -q -m "initial"', { cwd: tmpRepo.dbPath });
+
+ // A subdir of the canonical checkout, NOT a worktree (no `.git` file
+ // here — `mkdirSync` only). `resolveRepoIdentityRoot` must keep
+ // returning this exact path (basename used for the registry name)
+ // rather than collapsing to the parent repo's canonical root.
+ const subdir = path.join(tmpRepo.dbPath, 'arbitrary-subdir');
+ await fs.mkdir(subdir, { recursive: true });
+
+ await registerRepo(subdir, meta);
+
+ const entries = await listRegisteredRepos();
+ expect(entries).toHaveLength(1);
+ // Registered name must be the SUBDIR's basename, NOT the parent
+ // canonical repo's basename — the inverse of what worktree
+ // collapse does.
+ expect(entries[0].name).toBe('arbitrary-subdir');
+ expect(entries[0].name).not.toBe(path.basename(tmpRepo.dbPath));
+ });
+});
diff --git a/gitnexus/test/unit/scope-resolution/go/go-captures-smoke.test.ts b/gitnexus/test/unit/scope-resolution/go/go-captures-smoke.test.ts
new file mode 100644
index 000000000..b921bddc4
--- /dev/null
+++ b/gitnexus/test/unit/scope-resolution/go/go-captures-smoke.test.ts
@@ -0,0 +1,65 @@
+import { describe, expect, it } from 'vitest';
+import { emitGoScopeCaptures } from '../../../../src/core/ingestion/languages/go/index.js';
+
+const tagNames = (matches: readonly Record[]) =>
+ matches.flatMap((m) => Object.keys(m));
+
+describe('Go scope captures — smoke', () => {
+ it('emits grouped imports once per import spec', () => {
+ const src = `
+package main
+
+import (
+ "fmt"
+ "os"
+)
+`;
+ const matches = emitGoScopeCaptures(src, 'main.go');
+ const imports = matches
+ .filter((m) => m['@import.source'] !== undefined)
+ .map((m) => m['@import.source']!.text);
+
+ expect(imports).toEqual(['fmt', 'os']);
+ });
+
+ it('emits module, struct, interface, function, method, import, call, read, write captures', () => {
+ const src = `
+package main
+
+import (
+ "example.com/app/internal/models"
+ util "example.com/app/internal/util"
+)
+
+type User struct { Name string }
+type Saver interface { Save() }
+
+func NewUser(name string) *User { return &User{Name: name} }
+
+func (u *User) Save(prefix string) { util.Log(prefix); models.Touch() }
+
+func main() {
+ u := NewUser("alice")
+ u.Save("hello")
+ fmt.Println(u.Name)
+ u.Name = "bob"
+}
+`;
+ const matches = emitGoScopeCaptures(src, 'cmd/main.go');
+ const tags = tagNames(matches);
+
+ expect(tags).toContain('@scope.module');
+ expect(tags).toContain('@scope.class');
+ expect(tags).toContain('@scope.function');
+ expect(tags).toContain('@declaration.struct');
+ expect(tags).toContain('@declaration.interface');
+ expect(tags).toContain('@declaration.function');
+ expect(tags).toContain('@declaration.method');
+ expect(tags).toContain('@import.statement');
+ expect(tags).toContain('@reference.call.free');
+ expect(tags).toContain('@reference.call.member');
+ expect(tags).toContain('@reference.call.constructor');
+ expect(tags).toContain('@reference.read');
+ expect(tags).toContain('@reference.write');
+ });
+});
diff --git a/gitnexus/test/unit/scope-resolution/go/go-hooks.test.ts b/gitnexus/test/unit/scope-resolution/go/go-hooks.test.ts
new file mode 100644
index 000000000..42424a3bd
--- /dev/null
+++ b/gitnexus/test/unit/scope-resolution/go/go-hooks.test.ts
@@ -0,0 +1,130 @@
+import { describe, expect, it } from 'vitest';
+import type { BindingRef, Callsite, SymbolDefinition, Scope } from 'gitnexus-shared';
+import {
+ goArityCompatibility,
+ goMergeBindings,
+ goReceiverBinding,
+} from '../../../../src/core/ingestion/languages/go/index.js';
+
+describe('Go arity compatibility', () => {
+ const makeDef = (overrides: Partial = {}): SymbolDefinition => ({
+ nodeId: 'def:1',
+ filePath: 'a.go',
+ type: 'Function',
+ qualifiedName: 'F',
+ ...overrides,
+ });
+
+ it('returns unknown when no param count info', () => {
+ const def = makeDef();
+ const callsite: Callsite = {
+ name: 'F',
+ inScope: 's',
+ atRange: { startLine: 1, startCol: 1, endLine: 1, endCol: 5 },
+ kind: 'call',
+ arity: 1,
+ };
+ expect(goArityCompatibility(def, callsite)).toBe('unknown');
+ });
+
+ it('exact match is compatible', () => {
+ const def = makeDef({ parameterCount: 2, requiredParameterCount: 2 });
+ const callsite: Callsite = {
+ name: 'F',
+ inScope: 's',
+ atRange: { startLine: 1, startCol: 1, endLine: 1, endCol: 5 },
+ kind: 'call',
+ arity: 2,
+ };
+ expect(goArityCompatibility(def, callsite)).toBe('compatible');
+ });
+
+ it('too few args is incompatible', () => {
+ const def = makeDef({ parameterCount: 2, requiredParameterCount: 2 });
+ const callsite: Callsite = {
+ name: 'F',
+ inScope: 's',
+ atRange: { startLine: 1, startCol: 1, endLine: 1, endCol: 5 },
+ kind: 'call',
+ arity: 1,
+ };
+ expect(goArityCompatibility(def, callsite)).toBe('incompatible');
+ });
+
+ it('variadic accepts extra args', () => {
+ const def = makeDef({
+ parameterCount: 2,
+ requiredParameterCount: 1,
+ parameterTypes: ['string', '...string'],
+ });
+ const callsite: Callsite = {
+ name: 'F',
+ inScope: 's',
+ atRange: { startLine: 1, startCol: 1, endLine: 1, endCol: 5 },
+ kind: 'call',
+ arity: 5,
+ };
+ expect(goArityCompatibility(def, callsite)).toBe('compatible');
+ });
+
+ it('non-variadic rejects extra args', () => {
+ const def = makeDef({ parameterCount: 2, requiredParameterCount: 2 });
+ const callsite: Callsite = {
+ name: 'F',
+ inScope: 's',
+ atRange: { startLine: 1, startCol: 1, endLine: 1, endCol: 5 },
+ kind: 'call',
+ arity: 3,
+ };
+ expect(goArityCompatibility(def, callsite)).toBe('incompatible');
+ });
+});
+
+describe('Go merge bindings', () => {
+ it('local wins over import', () => {
+ const local: BindingRef = {
+ origin: 'local',
+ def: { nodeId: 'def:local', filePath: 'main.go', type: 'Function', qualifiedName: 'Save' },
+ };
+ const imported: BindingRef = {
+ origin: 'import',
+ def: { nodeId: 'def:import', filePath: 'util.go', type: 'Function', qualifiedName: 'Save' },
+ };
+ const merged = goMergeBindings([imported], [local], 'scope:1');
+ expect(merged[0].def.nodeId).toBe('def:local');
+ });
+
+ it('deduplicates by DefId', () => {
+ const a: BindingRef = {
+ origin: 'local',
+ def: { nodeId: 'def:1', filePath: 'a.go', type: 'Function', qualifiedName: 'F' },
+ };
+ const b: BindingRef = {
+ origin: 'local',
+ def: { nodeId: 'def:1', filePath: 'a.go', type: 'Function', qualifiedName: 'F' },
+ };
+ expect(goMergeBindings([], [a, b], 'scope:1').length).toBe(1);
+ });
+});
+
+describe('Go receiver binding', () => {
+ it('reads self type binding from function scope', () => {
+ const scope = {
+ kind: 'Function',
+ typeBindings: new Map([
+ ['u', { rawName: 'User', declaredAtScope: 'scope:1', source: 'self' }],
+ ]),
+ } as unknown as Scope;
+ expect(goReceiverBinding(scope)?.rawName).toBe('User');
+ });
+
+ it('returns null for non-Function scope', () => {
+ const scope = { kind: 'Module', typeBindings: new Map() } as unknown as Scope;
+ expect(goReceiverBinding(scope)).toBeNull();
+ });
+
+ it('returns null when no self binding', () => {
+ const scope = { kind: 'Function', typeBindings: new Map() } as unknown as Scope;
+ expect(goReceiverBinding(scope)).toBeNull();
+ });
+});
diff --git a/gitnexus/test/unit/scope-resolution/go/go-imports.test.ts b/gitnexus/test/unit/scope-resolution/go/go-imports.test.ts
new file mode 100644
index 000000000..1e1e715b3
--- /dev/null
+++ b/gitnexus/test/unit/scope-resolution/go/go-imports.test.ts
@@ -0,0 +1,154 @@
+import { describe, expect, it } from 'vitest';
+import {
+ splitGoImportStatement,
+ interpretGoImport,
+ resolveGoImportTarget,
+} from '../../../../src/core/ingestion/languages/go/index.js';
+import { getGoParser } from '../../../../src/core/ingestion/languages/go/query.js';
+import type { CaptureMatch } from 'gitnexus-shared';
+
+function parseThenSplit(src: string): CaptureMatch[] {
+ const tree = getGoParser().parse(src);
+ const out: CaptureMatch[] = [];
+ for (let i = 0; i < tree.rootNode.namedChildCount; i++) {
+ const child = tree.rootNode.namedChild(i);
+ if (child?.type === 'import_declaration') out.push(...splitGoImportStatement(child as any));
+ }
+ return out;
+}
+
+function capt(name: string, text: string) {
+ return { name, text, range: { startLine: 1, startCol: 1, endLine: 1, endCol: 1 } };
+}
+
+describe('Go import decomposition', () => {
+ it('decomposes single default import', () => {
+ const matches = parseThenSplit('import "fmt"');
+ expect(matches.length).toBe(1);
+ expect(matches[0]['@import.source']?.text).toBe('fmt');
+ expect(matches[0]['@import.kind']?.text).toBe('namespace');
+ expect(matches[0]['@import.name']?.text).toBe('fmt');
+ });
+
+ it('decomposes grouped imports', () => {
+ const src = `import (
+ "fmt"
+ "os"
+)`;
+ const matches = parseThenSplit(src);
+ expect(matches.length).toBe(2);
+ });
+
+ it('decomposes aliased import', () => {
+ const matches = parseThenSplit('import util "example.com/pkg/util"');
+ expect(matches.length).toBe(1);
+ expect(matches[0]['@import.kind']?.text).toBe('alias');
+ expect(matches[0]['@import.name']?.text).toBe('util');
+ expect(matches[0]['@import.source']?.text).toBe('example.com/pkg/util');
+ });
+
+ it('filters blank imports', () => {
+ const matches = parseThenSplit('import _ "example.com/sideeffect"');
+ expect(matches.length).toBe(0);
+ });
+
+ it('handles dot imports', () => {
+ const matches = parseThenSplit('import . "example.com/dsl"');
+ expect(matches.length).toBe(1);
+ expect(matches[0]['@import.kind']?.text).toBe('dot');
+ });
+});
+
+describe('Go import interpretation', () => {
+ it('interprets namespace import', () => {
+ const result = interpretGoImport({
+ '@import.kind': capt('@import.kind', 'namespace'),
+ '@import.name': capt('@import.name', 'models'),
+ '@import.source': capt('@import.source', 'example.com/app/models'),
+ });
+ expect(result).toEqual({
+ kind: 'namespace',
+ localName: 'models',
+ importedName: 'models',
+ targetRaw: 'example.com/app/models',
+ });
+ });
+
+ it('interprets alias import', () => {
+ const result = interpretGoImport({
+ '@import.kind': capt('@import.kind', 'alias'),
+ '@import.name': capt('@import.name', 'util'),
+ '@import.alias': capt('@import.alias', 'util'),
+ '@import.source': capt('@import.source', 'example.com/pkg/util'),
+ });
+ expect(result).toEqual({
+ kind: 'namespace',
+ localName: 'util',
+ importedName: 'util',
+ targetRaw: 'example.com/pkg/util',
+ });
+ });
+
+ it('interprets dot import as wildcard', () => {
+ const result = interpretGoImport({
+ '@import.kind': capt('@import.kind', 'dot'),
+ '@import.name': capt('@import.name', 'dsl'),
+ '@import.source': capt('@import.source', 'example.com/dsl'),
+ });
+ expect(result).toEqual({ kind: 'wildcard', targetRaw: 'example.com/dsl' });
+ });
+});
+
+describe('Go import target resolution', () => {
+ it('resolves module root imports to root package files', () => {
+ const result = resolveGoImportTarget(
+ 'example.com/lib',
+ 'cmd/app/main.go',
+ new Set(['root.go', 'extra.go', 'internal/model/model.go', 'root_test.go']),
+ { modulePath: 'example.com/lib' },
+ );
+
+ expect(result).toEqual(['extra.go', 'root.go']);
+ });
+
+ it('resolves sub-package imports under module root', () => {
+ const result = resolveGoImportTarget(
+ 'example.com/lib/internal/models',
+ 'cmd/app/main.go',
+ new Set(['internal/models/user.go', 'internal/models/repo.go', 'root.go']),
+ { modulePath: 'example.com/lib' },
+ );
+
+ expect(Array.isArray(result)).toBe(true);
+ expect((result as string[]).sort()).toEqual([
+ 'internal/models/repo.go',
+ 'internal/models/user.go',
+ ]);
+ });
+
+ it('rejects single-segment GOPATH suffix that collides with a local dir', () => {
+ // "github.com/other/team/pkg" suffix-stripped would eventually
+ // reach "pkg" which matches the local pkg/ dir — but we require
+ // ≥2 segments in the GOPATH fallback, so it must not resolve.
+ const result = resolveGoImportTarget(
+ 'github.com/other/team/pkg',
+ 'main.go',
+ new Set(['pkg/util.go', 'main.go']),
+ );
+
+ expect(result).toBeNull();
+ });
+
+ it('resolves multi-segment GOPATH suffix that matches local dir', () => {
+ // "github.com/other/team/pkg" where "team/pkg/" exists locally
+ // — the 2-segment suffix "team/pkg" should still resolve.
+ const result = resolveGoImportTarget(
+ 'github.com/other/team/pkg',
+ 'main.go',
+ new Set(['team/pkg/util.go', 'main.go']),
+ );
+
+ expect(Array.isArray(result)).toBe(true);
+ expect(result as string[]).toEqual(['team/pkg/util.go']);
+ });
+});
diff --git a/gitnexus/test/unit/scope-resolution/go/go-package-siblings.test.ts b/gitnexus/test/unit/scope-resolution/go/go-package-siblings.test.ts
new file mode 100644
index 000000000..8a4201745
--- /dev/null
+++ b/gitnexus/test/unit/scope-resolution/go/go-package-siblings.test.ts
@@ -0,0 +1,59 @@
+import { describe, expect, it } from 'vitest';
+import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared';
+import type { ScopeResolutionIndexes } from '../../../../src/core/ingestion/model/scope-resolution-indexes.js';
+import { populateGoPackageSiblings } from '../../../../src/core/ingestion/languages/go/index.js';
+
+describe('Go package siblings', () => {
+ it('augments bindings only for files in the same package directory', () => {
+ const fooDef = def('foo', 'cmd/foo/a.go', 'OnlyFoo');
+ const fooHelperDef = def('foo-helper', 'cmd/foo/b.go', 'OnlyFooHelper');
+ const barDef = def('bar', 'cmd/bar/a.go', 'OnlyBar');
+
+ const parsedFiles: ParsedFile[] = [
+ parsed('cmd/foo/a.go', 'module:foo-a', fooDef),
+ parsed('cmd/foo/b.go', 'module:foo-b', fooHelperDef),
+ parsed('cmd/bar/a.go', 'module:bar-a', barDef),
+ ];
+ const indexes = {
+ moduleScopes: {
+ byFilePath: new Map([
+ ['cmd/foo/a.go', 'module:foo-a'],
+ ['cmd/foo/b.go', 'module:foo-b'],
+ ['cmd/bar/a.go', 'module:bar-a'],
+ ]),
+ },
+ imports: new Map(),
+ bindings: new Map(),
+ bindingAugmentations: new Map(),
+ } as unknown as ScopeResolutionIndexes;
+ const fileContents = new Map([
+ ['cmd/foo/a.go', 'package main\n'],
+ ['cmd/foo/b.go', 'package main\n'],
+ ['cmd/bar/a.go', 'package main\n'],
+ ]);
+
+ populateGoPackageSiblings(parsedFiles, indexes, { fileContents });
+
+ const augmentations = indexes.bindingAugmentations;
+ expect(augmentations.get('module:foo-a')?.get('OnlyFooHelper')?.[0]?.def.nodeId).toBe(
+ 'foo-helper',
+ );
+ expect(augmentations.get('module:foo-a')?.get('OnlyBar')).toBeUndefined();
+ expect(augmentations.get('module:bar-a')?.get('OnlyFoo')).toBeUndefined();
+ });
+});
+
+function def(nodeId: string, filePath: string, name: string): SymbolDefinition {
+ return { nodeId, filePath, type: 'Function', qualifiedName: name };
+}
+
+function parsed(filePath: string, moduleScope: string, localDef: SymbolDefinition): ParsedFile {
+ return {
+ filePath,
+ moduleScope,
+ scopes: [],
+ parsedImports: [],
+ localDefs: [localDef],
+ referenceSites: [],
+ };
+}
diff --git a/gitnexus/test/unit/scope-resolution/go/go-type-binding.test.ts b/gitnexus/test/unit/scope-resolution/go/go-type-binding.test.ts
new file mode 100644
index 000000000..9ea213128
--- /dev/null
+++ b/gitnexus/test/unit/scope-resolution/go/go-type-binding.test.ts
@@ -0,0 +1,114 @@
+import { describe, expect, it } from 'vitest';
+import {
+ synthesizeGoReceiverBinding,
+ synthesizeGoTypeBindings,
+ emitGoScopeCaptures,
+ interpretGoTypeBinding,
+ normalizeGoTypeName,
+} from '../../../../src/core/ingestion/languages/go/index.js';
+import { getGoParser } from '../../../../src/core/ingestion/languages/go/query.js';
+
+describe('Go receiver binding', () => {
+ it('synthesizes receiver type binding for method', () => {
+ const src = 'package main\ntype User struct{}\nfunc (u *User) Save() {}';
+ const tree = getGoParser().parse(src);
+ const methodNode = tree.rootNode.descendantsOfType('method_declaration')[0];
+ const result = synthesizeGoReceiverBinding(methodNode as any)!;
+ expect(result['@type-binding.self']).toBeDefined();
+ expect(result['@type-binding.name']!.text).toBe('u');
+ expect(result['@type-binding.type']!.text).toBe('User');
+ });
+
+ it('returns null for free function', () => {
+ const src = 'package main\nfunc Save() {}';
+ const tree = getGoParser().parse(src);
+ const fnNode = tree.rootNode.descendantsOfType('function_declaration')[0];
+ expect(synthesizeGoReceiverBinding(fnNode as any)).toBeNull();
+ });
+});
+
+describe('Go type binding synthesis — 7 patterns', () => {
+ it('synthesizes new() type binding', () => {
+ const src = 'package main\nfunc main() {\n user := new(User)\n}';
+ const tree = getGoParser().parse(src);
+ const matches = synthesizeGoTypeBindings(tree.rootNode as any);
+ expect(matches.length).toBeGreaterThanOrEqual(1);
+ const newMatch = matches.find((m) => m['@type-binding.new']);
+ expect(newMatch?.['@type-binding.name']?.text).toBe('user');
+ expect(newMatch?.['@type-binding.type']?.text).toBe('User');
+ const parsed = interpretGoTypeBinding(newMatch!);
+ expect(parsed?.rawTypeName).toBe('User');
+ });
+
+ it('synthesizes make([]T) type binding', () => {
+ const src = 'package main\nfunc main() {\n sl := make([]User, 0)\n}';
+ const tree = getGoParser().parse(src);
+ const matches = synthesizeGoTypeBindings(tree.rootNode as any);
+ const makeMatch = matches.find((m) => m['@type-binding.make']);
+ expect(makeMatch?.['@type-binding.name']?.text).toBe('sl');
+ expect(makeMatch?.['@type-binding.type']?.text).toBe('User');
+ });
+
+ it('synthesizes make(map[K]V) type binding', () => {
+ const src = 'package main\nfunc main() {\n m := make(map[string]User)\n}';
+ const tree = getGoParser().parse(src);
+ const matches = synthesizeGoTypeBindings(tree.rootNode as any);
+ const makeMatch = matches.find((m) => m['@type-binding.make']);
+ expect(makeMatch?.['@type-binding.name']?.text).toBe('m');
+ expect(makeMatch?.['@type-binding.type']?.text).toBe('User');
+ });
+
+ it('supplements qualified type constructor: pkg.Foo{}', () => {
+ const src = 'package main\nfunc main() {\n u := models.User{}\n}';
+ const matches = emitGoScopeCaptures(src, 'main.go');
+ const qMatch = matches.find(
+ (m) => m['@type-binding.constructor'] && m['@type-binding.type']?.text === 'models.User',
+ );
+ expect(qMatch).toBeDefined();
+ });
+
+ it('keeps multi-assignment constructor bindings aligned with RHS positions', () => {
+ const src = 'package main\nfunc main() {\n a, b := 42, X{}\n}';
+ const bindings = emitGoScopeCaptures(src, 'main.go')
+ .filter((m) => m['@type-binding.name'] !== undefined)
+ .map((m) => ({
+ name: m['@type-binding.name']!.text,
+ type: m['@type-binding.type']!.text,
+ }));
+
+ expect(bindings).toContainEqual({ name: 'b', type: 'X' });
+ expect(bindings).not.toContainEqual({ name: 'a', type: 'X' });
+ });
+
+ it('interprets assertion type binding', () => {
+ const result = interpretGoTypeBinding({
+ '@type-binding.assertion': {
+ name: '@type-binding.assertion',
+ text: 's.(User)',
+ range: { startLine: 1, startCol: 1, endLine: 1, endCol: 5 },
+ },
+ '@type-binding.name': {
+ name: '@type-binding.name',
+ text: 'user',
+ range: { startLine: 1, startCol: 1, endLine: 1, endCol: 5 },
+ },
+ '@type-binding.type': {
+ name: '@type-binding.type',
+ text: 'User',
+ range: { startLine: 1, startCol: 10, endLine: 1, endCol: 14 },
+ },
+ });
+ expect(result?.rawTypeName).toBe('User');
+ expect(result?.source).toBe('annotation');
+ });
+
+ it('normalizes pointer, slice, map, qualified, generic type names', () => {
+ expect(normalizeGoTypeName('*User')).toBe('User');
+ expect(normalizeGoTypeName('[]string')).toBe('string');
+ expect(normalizeGoTypeName('map[string]int')).toBe('int');
+ expect(normalizeGoTypeName('chan int')).toBe('int');
+ expect(normalizeGoTypeName('func() error')).toBe('error');
+ expect(normalizeGoTypeName('models.User')).toBe('User');
+ expect(normalizeGoTypeName('List[User]')).toBe('List');
+ });
+});
diff --git a/gitnexus/test/unit/server-validation.test.ts b/gitnexus/test/unit/server-validation.test.ts
new file mode 100644
index 000000000..0e6198308
--- /dev/null
+++ b/gitnexus/test/unit/server-validation.test.ts
@@ -0,0 +1,119 @@
+/**
+ * Unit Tests: server validation helpers (gitnexus/src/server/validation.ts)
+ *
+ * Covers U1 of the security remediation plan:
+ * - assertString closes js/type-confusion-through-parameter-tampering by
+ * rejecting array-form HTTP query parameters before they reach a `.length` guard.
+ * - assertSafePath consolidates the path-traversal guard from api.ts:1067-1077
+ * for reuse across other path-injection findings.
+ * - escapeRegExp is the utility for upcoming /api/grep regex-injection fix.
+ */
+import { describe, it, expect } from 'vitest';
+import path from 'node:path';
+import {
+ assertString,
+ assertSafePath,
+ escapeRegExp,
+ BadRequestError,
+ ForbiddenError,
+} from '../../src/server/validation.js';
+
+describe('assertString', () => {
+ it('returns the value when it is a string', () => {
+ expect(assertString('hello', 'name')).toBe('hello');
+ });
+
+ it('returns an empty string as-is (length validation is the caller’s job)', () => {
+ expect(assertString('', 'name')).toBe('');
+ });
+
+ it('rejects an array with a message naming the field', () => {
+ expect(() => assertString(['a', 'b'], 'pattern')).toThrow(BadRequestError);
+ try {
+ assertString(['a', 'b'], 'pattern');
+ } catch (err) {
+ expect(err).toBeInstanceOf(BadRequestError);
+ expect((err as BadRequestError).status).toBe(400);
+ expect((err as Error).message).toContain('pattern');
+ expect((err as Error).message).toContain('array');
+ }
+ });
+
+ it('rejects undefined', () => {
+ expect(() => assertString(undefined, 'name')).toThrow(BadRequestError);
+ });
+
+ it('rejects a number', () => {
+ expect(() => assertString(123, 'name')).toThrow(BadRequestError);
+ });
+
+ it('rejects an object', () => {
+ expect(() => assertString({ key: 'value' }, 'name')).toThrow(BadRequestError);
+ });
+});
+
+describe('assertSafePath', () => {
+ const root = path.resolve('/repos/x');
+
+ it('resolves an in-repo relative path to its absolute form', () => {
+ const result = assertSafePath('src/foo.ts', root);
+ expect(result).toBe(path.join(root, 'src/foo.ts'));
+ });
+
+ it('accepts the root itself', () => {
+ expect(assertSafePath('.', root)).toBe(root);
+ });
+
+ it('rejects a parent-directory traversal with ForbiddenError (status 403)', () => {
+ expect(() => assertSafePath('../../../etc/passwd', root)).toThrow(ForbiddenError);
+ try {
+ assertSafePath('../../../etc/passwd', root);
+ } catch (err) {
+ expect((err as BadRequestError).status).toBe(403);
+ }
+ });
+
+ it('rejects an absolute path that escapes the root', () => {
+ expect(() => assertSafePath('/etc/passwd', root)).toThrow(ForbiddenError);
+ });
+
+ it('rejects an empty path', () => {
+ expect(() => assertSafePath('', root)).toThrow(BadRequestError);
+ });
+
+ it('rejects a path containing a null byte', () => {
+ expect(() => assertSafePath('foo\0bar', root)).toThrow(BadRequestError);
+ });
+
+ it('does not confuse "src/.." with "../" (must not escape root)', () => {
+ // src/.. resolves back to root, which is allowed.
+ expect(assertSafePath('src/..', root)).toBe(root);
+ });
+});
+
+describe('escapeRegExp', () => {
+ it('escapes the dot metacharacter', () => {
+ expect(escapeRegExp('a.b')).toBe('a\\.b');
+ });
+
+ it('escapes all common regex metacharacters', () => {
+ expect(escapeRegExp('a.b*c+d?e^f$g{h}i(j)k|l[m]n\\o')).toBe(
+ 'a\\.b\\*c\\+d\\?e\\^f\\$g\\{h\\}i\\(j\\)k\\|l\\[m\\]n\\\\o',
+ );
+ });
+
+ it('passes through a string with no metacharacters', () => {
+ expect(escapeRegExp('plain text')).toBe('plain text');
+ });
+
+ it('handles an empty string', () => {
+ expect(escapeRegExp('')).toBe('');
+ });
+
+ it('produces a literal-matching regex when fed back to new RegExp', () => {
+ const userInput = 'a.b*c';
+ const re = new RegExp(escapeRegExp(userInput));
+ expect(re.test('a.b*c')).toBe(true);
+ expect(re.test('axbxc')).toBe(false); // confirms the . was treated as literal
+ });
+});
diff --git a/gitnexus/test/unit/setup.test.ts b/gitnexus/test/unit/setup.test.ts
index 4544ddb02..6cab31467 100644
--- a/gitnexus/test/unit/setup.test.ts
+++ b/gitnexus/test/unit/setup.test.ts
@@ -185,4 +185,95 @@ describe('setupClaudeCode', () => {
args: ['-y', 'gitnexus@latest', 'mcp'],
});
});
+
+ it('picks .cmd wrapper from Windows where output (multiple lines)', async () => {
+ setPlatform('win32');
+ // `where gitnexus` on Windows returns the POSIX script first, then .cmd
+ execFileSyncMock.mockReturnValueOnce(
+ 'C:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus\nC:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus.cmd\n',
+ );
+
+ const { setupCommand } = await import('../../src/cli/setup.js');
+ await setupCommand();
+
+ const raw = await fs.readFile(path.join(tempHome, '.claude.json'), 'utf-8');
+ const config = JSON.parse(raw);
+
+ expect(config.mcpServers.gitnexus).toEqual({
+ command: 'C:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus.cmd',
+ args: ['mcp'],
+ });
+ });
+
+ it('handles CRLF line endings from Windows where output', async () => {
+ setPlatform('win32');
+ // Windows `where` produces CRLF line endings
+ execFileSyncMock.mockReturnValueOnce(
+ 'C:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus\r\nC:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus.cmd\r\n',
+ );
+
+ const { setupCommand } = await import('../../src/cli/setup.js');
+ await setupCommand();
+
+ const raw = await fs.readFile(path.join(tempHome, '.claude.json'), 'utf-8');
+ const config = JSON.parse(raw);
+
+ expect(config.mcpServers.gitnexus).toEqual({
+ command: 'C:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus.cmd',
+ args: ['mcp'],
+ });
+ });
+
+ it('picks .bat wrapper when .cmd is not present', async () => {
+ setPlatform('win32');
+ execFileSyncMock.mockReturnValueOnce(
+ 'C:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus\nC:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus.bat\n',
+ );
+
+ const { setupCommand } = await import('../../src/cli/setup.js');
+ await setupCommand();
+
+ const raw = await fs.readFile(path.join(tempHome, '.claude.json'), 'utf-8');
+ const config = JSON.parse(raw);
+
+ expect(config.mcpServers.gitnexus).toEqual({
+ command: 'C:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus.bat',
+ args: ['mcp'],
+ });
+ });
+
+ it('handles uppercase .CMD extension (case-insensitive match)', async () => {
+ setPlatform('win32');
+ execFileSyncMock.mockReturnValueOnce(
+ 'C:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus\nC:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus.CMD\n',
+ );
+
+ const { setupCommand } = await import('../../src/cli/setup.js');
+ await setupCommand();
+
+ const raw = await fs.readFile(path.join(tempHome, '.claude.json'), 'utf-8');
+ const config = JSON.parse(raw);
+
+ expect(config.mcpServers.gitnexus).toEqual({
+ command: 'C:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus.CMD',
+ args: ['mcp'],
+ });
+ });
+
+ it('falls back to first line on Windows when no .cmd/.bat wrapper found', async () => {
+ setPlatform('win32');
+ // Edge case: where returns only the POSIX script (no .cmd wrapper)
+ execFileSyncMock.mockReturnValueOnce('C:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus\n');
+
+ const { setupCommand } = await import('../../src/cli/setup.js');
+ await setupCommand();
+
+ const raw = await fs.readFile(path.join(tempHome, '.claude.json'), 'utf-8');
+ const config = JSON.parse(raw);
+
+ expect(config.mcpServers.gitnexus).toEqual({
+ command: 'C:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus',
+ args: ['mcp'],
+ });
+ });
});
diff --git a/gitnexus/test/unit/web-ui-serving.test.ts b/gitnexus/test/unit/web-ui-serving.test.ts
index 40570b488..d3f085422 100644
--- a/gitnexus/test/unit/web-ui-serving.test.ts
+++ b/gitnexus/test/unit/web-ui-serving.test.ts
@@ -1,7 +1,7 @@
import path from 'node:path';
import http from 'node:http';
import express from 'express';
-import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
+import { describe, expect, it, vi, beforeEach } from 'vitest';
const { accessMock } = vi.hoisted(() => ({
accessMock: vi.fn(),
@@ -17,7 +17,6 @@ import {
resolveWebDistDir,
landingPageHtml,
SPA_FALLBACK_REGEX,
- shouldEnableMcpHttp,
staticCacheControlSetHeaders,
} from '../../src/server/api.js';
@@ -95,22 +94,6 @@ describe('landingPageHtml', () => {
});
});
-describe('shouldEnableMcpHttp', () => {
- afterEach(() => {
- vi.unstubAllEnvs();
- });
-
- it('enables MCP HTTP by default', () => {
- expect(shouldEnableMcpHttp()).toBe(true);
- });
-
- it('disables MCP HTTP when requested by desktop runtime', () => {
- vi.stubEnv('GITNEXUS_DISABLE_MCP_HTTP', '1');
-
- expect(shouldEnableMcpHttp()).toBe(false);
- });
-});
-
describe('SPA fallback regex', () => {
it('allows root path', () => {
expect(SPA_FALLBACK_REGEX.test('/')).toBe(true);