Compare commits

..
Author SHA1 Message Date
github-actions[bot] 1aab471c93 release: v1.6.4-rc.41 2026-05-02 10:52:50 +00:00
156 changed files with 1090 additions and 11369 deletions
-71
View File
@@ -1,71 +0,0 @@
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 }}'
-36
View File
@@ -1,36 +0,0 @@
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
-45
View File
@@ -1,45 +0,0 @@
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
+2 -6
View File
@@ -37,12 +37,8 @@ jobs:
with:
node-version: 20
registry-url: https://registry.npmjs.org
# Hermetic install for the published artifact — no cache carry-over
# from non-tag contexts. setup-node v5+ caches by default when a
# 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
cache: npm
cache-dependency-path: gitnexus/package-lock.json
- name: Build gitnexus-shared
run: npm install && npm run build
working-directory: gitnexus-shared
+2 -5
View File
@@ -137,11 +137,8 @@ jobs:
with:
node-version: 20
registry-url: https://registry.npmjs.org
# 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
cache: npm
cache-dependency-path: gitnexus/package-lock.json
- name: Build gitnexus-shared
run: npm install && npm run build
-58
View File
@@ -1,58 +0,0 @@
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
-73
View File
@@ -1,73 +0,0 @@
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 }}
-58
View File
@@ -1,58 +0,0 @@
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 .
-34
View File
@@ -1,34 +0,0 @@
# 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.
-1
View File
@@ -2,7 +2,6 @@ dist/
coverage/
gitnexus/vendor/
gitnexus/test/fixtures/
gitnexus-web/test/fixtures/
gitnexus-web/playwright-report/
gitnexus-web/test-results/
*.d.ts
-3
View File
@@ -18,9 +18,6 @@
<a href="https://polyformproject.org/licenses/noncommercial/1.0.0/">
<img src="https://img.shields.io/badge/License-PolyForm%20Noncommercial-blue.svg" alt="License: PolyForm Noncommercial"/>
</a>
<a href="https://securityscorecards.dev/viewer/?uri=github.com/abhigyanpatwari/GitNexus">
<img src="https://api.securityscorecards.dev/projects/github.com/abhigyanpatwari/GitNexus/badge" alt="OpenSSF Scorecard"/>
</a>
<p><strong>Enterprise (SaaS & Self-hosted)</strong> - <a href="https://akonlabs.com">akonlabs.com</a></p>
-67
View File
@@ -1,67 +0,0 @@
# 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`.
+23 -62
View File
@@ -1,11 +1,11 @@
import { createReadStream } from 'node:fs';
import { stat } from 'node:fs/promises';
import { createServer } from 'node:http';
import { extname, isAbsolute, normalize, relative, resolve } from 'node:path';
import { extname, join, normalize, sep } from 'node:path';
const host = '0.0.0.0';
const port = Number(process.env.PORT || '4173');
const root = resolve(process.cwd(), 'dist');
const root = join(process.cwd(), 'dist');
const contentTypes = {
'.css': 'text/css; charset=utf-8',
@@ -20,78 +20,39 @@ const contentTypes = {
'.woff2': 'font/woff2',
};
// 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] || '/';
function resolvePath(urlPath) {
let decoded;
try {
decoded = decodeURIComponent(urlPath);
} catch {
res.writeHead(400);
res.end('Bad request');
return;
return null;
}
if (decoded.includes('\0')) {
res.writeHead(400);
res.end('Bad request');
return;
}
if (decoded.includes('\0')) return null;
const cleanPath = normalize(decoded.replace(/^\/+/, ''));
const initialPath = resolve(root, cleanPath);
const candidate = join(root, cleanPath);
if (candidate !== root && !candidate.startsWith(root + sep)) return null;
return candidate;
}
// Sanitizer barrier #1 — guards the first stat() sink.
const initialRel = relative(root, initialPath);
if (initialRel.startsWith('..') || isAbsolute(initialRel)) {
const server = createServer(async (req, res) => {
const requestPath = req.url?.split('?')[0] || '/';
let filePath = resolvePath(requestPath);
if (!filePath) {
res.writeHead(400);
res.end('Bad request');
return;
}
try {
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 fileStat = await stat(filePath).catch(() => null);
if (fileStat?.isDirectory()) {
filePath = join(filePath, 'index.html');
} else if (!fileStat?.isFile()) {
filePath = join(root, 'index.html');
}
// 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);
const finalStat = await stat(filePath).catch(() => null);
if (!finalStat?.isFile()) {
res.writeHead(404);
res.end('Not found');
@@ -99,14 +60,14 @@ const server = createServer(async (req, res) => {
}
res.writeHead(200, {
'Cache-Control': finalPath.includes('/assets/')
'Cache-Control': filePath.includes('/assets/')
? 'public, max-age=31536000, immutable'
: 'no-cache',
'Content-Type': contentTypes[extname(finalPath)] || 'application/octet-stream',
'Content-Type': contentTypes[extname(filePath)] || 'application/octet-stream',
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp',
});
const stream = createReadStream(finalPath);
const stream = createReadStream(filePath);
stream.on('error', () => res.destroy());
stream.pipe(res);
} catch (error) {
-17
View File
@@ -100,23 +100,6 @@ 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');
-1
View File
@@ -14,7 +14,6 @@ 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',
+3 -3
View File
@@ -6,19 +6,19 @@ readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"mini-swe-agent>=2.0.0",
"litellm!=1.82.7,!=1.82.8,>=1.83.7",
"litellm>=1.50.0,!=1.82.7,!=1.82.8",
"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.2.2",
"python-dotenv>=1.0.0",
]
[project.optional-dependencies]
dev = [
"pytest>=9.0.3",
"pytest>=8.0.0",
"ruff>=0.5.0",
"hypothesis>=6.88.0",
"coverage>=7.6.0",
Generated
+114 -114
View File
@@ -21,7 +21,7 @@ wheels = [
[[package]]
name = "aiohttp"
version = "3.13.4"
version = "3.13.3"
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/45/4a/064321452809dae953c1ed6e017504e72551a26b6f5708a5a80e4bf556ff/aiohttp-3.13.4.tar.gz", hash = "sha256:d97a6d09c66087890c2ab5d49069e1e570583f7ac0314ecf98294c1b6aaebd38", size = 7859748, upload-time = "2026-03-28T17:19:40.6Z" }
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" }
wheels = [
{ 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" },
{ 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" },
]
[[package]]
@@ -274,14 +274,14 @@ wheels = [
[[package]]
name = "click"
version = "8.1.8"
version = "8.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
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" }
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" }
wheels = [
{ 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" },
{ 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" },
]
[[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.82.7,!=1.82.8,>=1.83.7" },
{ name = "litellm", specifier = ">=1.50.0" },
{ name = "mini-swe-agent", specifier = ">=2.0.0" },
{ name = "pandas", specifier = ">=2.0.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.3" },
{ name = "python-dotenv", specifier = ">=1.2.2" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
{ name = "python-dotenv", specifier = ">=1.0.0" },
{ 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 = "8.5.0"
version = "9.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "zipp" },
]
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" }
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" }
wheels = [
{ 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" },
{ 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" },
]
[[package]]
@@ -887,7 +887,7 @@ wheels = [
[[package]]
name = "jsonschema"
version = "4.23.0"
version = "4.26.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/38/2e/03362ee4034a4c917f697890ccd4aec0800ccf9ded7f511971c75451deec/jsonschema-4.23.0.tar.gz", hash = "sha256:d71497fef26351a33265337fa77ffeb82423f3ea21283cd9467bb03999266bc4", size = 325778, upload-time = "2024-07-08T18:40:05.546Z" }
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" }
wheels = [
{ 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" },
{ 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" },
]
[[package]]
@@ -926,7 +926,7 @@ wheels = [
[[package]]
name = "litellm"
version = "1.83.14"
version = "1.82.6"
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/8d/7c/c095649380adc96c8630273c1768c2ad1e74aa2ee1dd8dd05d218a60569f/litellm-1.83.14.tar.gz", hash = "sha256:24aef9b47cdc424c833e32f3727f411741c690832cd1fe4405e0077144fe09c9", size = 14836599, upload-time = "2026-04-26T03:16:10.176Z" }
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" }
wheels = [
{ 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" },
{ 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" },
]
[[package]]
@@ -1302,7 +1302,7 @@ wheels = [
[[package]]
name = "openai"
version = "2.24.0"
version = "2.29.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/55/13/17e87641b89b74552ed408a92b231283786523edddc95f3545809fab673c/openai-2.24.0.tar.gz", hash = "sha256:1e5769f540dbd01cb33bc4716a23e67b9d695161a734aff9c5f925e2bf99a673", size = 658717, upload-time = "2026-02-24T20:02:07.958Z" }
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" }
wheels = [
{ 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" },
{ 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" },
]
[[package]]
@@ -1690,7 +1690,7 @@ wheels = [
[[package]]
name = "pytest"
version = "9.0.3"
version = "9.0.2"
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/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
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" }
wheels = [
{ 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" },
{ 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" },
]
[[package]]
@@ -1900,7 +1900,7 @@ wheels = [
[[package]]
name = "requests"
version = "2.33.0"
version = "2.32.5"
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/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232, upload-time = "2026-03-25T15:10:41.586Z" }
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" }
wheels = [
{ 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" },
{ 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" },
]
[[package]]
@@ -2224,7 +2224,7 @@ wheels = [
[[package]]
name = "typer"
version = "0.23.1"
version = "0.24.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/fd/07/b822e1b307d40e263e8253d2384cf98c51aa2368cc7ba9a07e523a1d964b/typer-0.23.1.tar.gz", hash = "sha256:2070374e4d31c83e7b61362fd859aa683576432fd5b026b060ad6b4cd3b86134", size = 120047, upload-time = "2026-02-13T10:04:30.984Z" }
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" }
wheels = [
{ 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" },
{ 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" },
]
[[package]]
@@ -93,7 +93,7 @@ export interface FinalizeHooks {
targetRaw: string,
fromFile: string,
workspaceIndex: WorkspaceIndex,
): string | readonly string[] | null;
): string | null;
/**
* For a wildcard `import * from M`, return the names visible in the
@@ -127,22 +127,20 @@ export interface FinalizedScc {
/**
* Counters reported by `finalize`.
*
* **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
* **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
* 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 `ImportEdgeDraft` records generated (≥ ParsedImport count). */
/** Total `ParsedImport` records seen across all files. */
readonly totalEdges: number;
/**
* `ParsedImport`s whose finalized edge does NOT carry
@@ -181,9 +179,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 draftArray = makeEdgeDrafts(parsed, file, hooks, input.workspaceIndex);
drafts.push(...draftArray);
totalEdges += draftArray.length;
const draft = makeEdgeDraft(parsed, file, hooks, input.workspaceIndex);
drafts.push(draft);
totalEdges++;
}
edgeIndex.set(file.filePath, drafts);
}
@@ -322,12 +320,12 @@ interface ImportEdgeDraft {
finalized: ImportEdge | null;
}
function makeEdgeDrafts(
function makeEdgeDraft(
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 = {
@@ -336,16 +334,14 @@ function makeEdgeDrafts(
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);
@@ -359,16 +355,14 @@ function makeEdgeDrafts(
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
@@ -376,24 +370,21 @@ function makeEdgeDrafts(
// 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 targetFiles = Array.isArray(targetFile) ? targetFile : [targetFile];
const base: ImportEdge = {
localName: extractLocalName(parsed),
targetFile,
targetExportedName: extractExportedName(parsed),
kind: edgeKindFor(parsed),
};
const isFileLevelTerminal = parsed.kind === 'side-effect' || parsed.kind === 'dynamic-resolved';
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,
};
});
return {
source: parsed,
fromFile: file.filePath,
fromScope: file.moduleScope,
targetFile,
base,
finalized: isFileLevelTerminal ? base : null,
};
}
function edgeKindFor(parsed: ParsedImport): ImportEdge['kind'] {
+44 -44
View File
@@ -18,7 +18,7 @@
"@tailwindcss/vite": "^4.2.4",
"axios": "^1.13.2",
"d3": "^7.9.0",
"dompurify": "^3.4.2",
"dompurify": "^3.3.3",
"gitnexus-shared": "file:../gitnexus-shared",
"graphology": "^0.26.0",
"graphology-indices": "^0.17.0",
@@ -1337,6 +1337,29 @@
"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",
@@ -2416,9 +2439,9 @@
}
},
"node_modules/@ts-morph/common/node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -4479,9 +4502,9 @@
"peer": true
},
"node_modules/dompurify": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.2.tgz",
"integrity": "sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA==",
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
"integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
@@ -4822,9 +4845,9 @@
}
},
"node_modules/follow-redirects": {
"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==",
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
@@ -7054,9 +7077,9 @@
}
},
"node_modules/micromatch/node_modules/picomatch": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -7098,44 +7121,21 @@
}
},
"node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"version": "10.1.1",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz",
"integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.5"
"@isaacs/brace-expansion": "^5.0.0"
},
"engines": {
"node": "18 || 20 || >=22"
"node": "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.13",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz",
"integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==",
"version": "7.5.3",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.3.tgz",
"integrity": "sha512-ENg5JUHUm2rDD7IvKNFGzyElLXNjachNLp6RaGf4+JOgxXHkqA+gq81ZAMCUmtMtqBsoU62lcp6S27g1LCYGGQ==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
+1 -1
View File
@@ -29,7 +29,7 @@
"@tailwindcss/vite": "^4.2.4",
"axios": "^1.13.2",
"d3": "^7.9.0",
"dompurify": "^3.4.2",
"dompurify": "^3.3.3",
"graphology": "^0.26.0",
"graphology-indices": "^0.17.0",
"graphology-layout-force": "^0.2.4",
+5 -39
View File
@@ -72,19 +72,7 @@ export class BackendError extends Error {
constructor(
message: string,
public readonly status: number,
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,
public readonly code: 'network' | 'server' | 'client' | 'not_found' | 'timeout',
) {
super(message);
this.name = 'BackendError';
@@ -291,32 +279,10 @@ const assertOk = async (response: Response): Promise<void> => {
const code =
response.status === 404
? 'not_found'
: 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);
: response.status >= 400 && response.status < 500
? 'client'
: 'server';
throw new BackendError(message, response.status, code);
};
const repoParam = (repo?: string): string => (repo ? `repo=${encodeURIComponent(repo)}` : '');
+38 -49
View File
@@ -1,24 +1,23 @@
{
"name": "gitnexus",
"version": "1.6.3",
"version": "1.6.4-rc.41",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gitnexus",
"version": "1.6.3",
"version": "1.6.4-rc.41",
"hasInstallScript": true,
"license": "PolyForm-Noncommercial-1.0.0",
"dependencies": {
"@huggingface/transformers": "^4.1.0",
"@ladybugdb/core": "^0.16.1",
"@ladybugdb/core": "^0.16.0",
"@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",
@@ -615,9 +614,9 @@
}
},
"node_modules/@hono/node-server": {
"version": "1.19.14",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
"integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
"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==",
"license": "MIT",
"engines": {
"node": ">=18.14.1"
@@ -1160,9 +1159,9 @@
}
},
"node_modules/@ladybugdb/core": {
"version": "0.16.1",
"resolved": "https://registry.npmjs.org/@ladybugdb/core/-/core-0.16.1.tgz",
"integrity": "sha512-qwuEcR8CVMKb6tNDaHtq7Ux8hT/XbPC0db+vwutX6JxNAejyx7YomHKPSy9XAKURhYK8mezZe3UN8rf+xpHOjQ==",
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/@ladybugdb/core/-/core-0.16.0.tgz",
"integrity": "sha512-t/t4MPZmBMocFBzG5G3E3iHPwuIiXYEuLeW0CTOloGofkKQ7gHt3JlLzyDn2a+AHNQjr1YqlsodKKYQFhsFZXw==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -1170,17 +1169,17 @@
"node-addon-api": "^6.0.0"
},
"optionalDependencies": {
"@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"
"@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"
}
},
"node_modules/@ladybugdb/core-darwin-arm64": {
"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==",
"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==",
"cpu": [
"arm64"
],
@@ -1190,23 +1189,10 @@
"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.1",
"resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-arm64/-/core-linux-arm64-0.16.1.tgz",
"integrity": "sha512-zkctksev+hsPFrNxHHdq4lYK5OWdLhWfRdQzjzkgDyaHayHU6yCL2fgD6uPGQ8TRQ6/2DxMErb4p3FzGW85Ubw==",
"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==",
"cpu": [
"arm64"
],
@@ -1217,9 +1203,9 @@
]
},
"node_modules/@ladybugdb/core-linux-x64": {
"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==",
"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==",
"cpu": [
"x64"
],
@@ -1230,9 +1216,9 @@
]
},
"node_modules/@ladybugdb/core-win32-x64": {
"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==",
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/@ladybugdb/core-win32-x64/-/core-win32-x64-0.16.0.tgz",
"integrity": "sha512-MyKiELqPgzx9gVHmwxzptnToAcDtCN7dTP5Y4IPMYhc2QpNbZKCihmvdXjbOmdIOfIHW4fBp4vrzT8fVbdAMZw==",
"cpu": [
"x64"
],
@@ -1242,6 +1228,9 @@
"win32"
]
},
"node_modules/@ladybugdb/core/node_modules/@ladybugdb/core-darwin-x64": {
"optional": true
},
"node_modules/@ladybugdb/core/node_modules/node-addon-api": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz",
@@ -3018,9 +3007,9 @@
}
},
"node_modules/express-rate-limit": {
"version": "8.4.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.4.1.tgz",
"integrity": "sha512-NGVYwQSAyEQgzxX1iCM978PP9AdO/hW93gMcF6ZwQCm+rFvLsBH6w4xcXWTcliS8La5EPRN3p9wzItqBwJrfNw==",
"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==",
"license": "MIT",
"dependencies": {
"ip-address": "10.1.0"
@@ -3417,9 +3406,9 @@
}
},
"node_modules/hono": {
"version": "4.12.16",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.16.tgz",
"integrity": "sha512-jN0ZewiNAWSe5khM3EyCmBb250+b40wWbwNILNfEvq84VREWwOIkuUsFONk/3i3nqkz7Oe1PcpM2mwQEK2L9Kg==",
"version": "4.12.9",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.9.tgz",
"integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==",
"license": "MIT",
"engines": {
"node": ">=16.9.0"
@@ -3892,9 +3881,9 @@
"license": "Apache-2.0"
},
"node_modules/lru-cache": {
"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==",
"version": "11.3.5",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz",
"integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==",
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
+2 -3
View File
@@ -1,6 +1,6 @@
{
"name": "gitnexus",
"version": "1.6.3",
"version": "1.6.4-rc.41",
"description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
"author": "Abhigyan Patwari",
"license": "PolyForm-Noncommercial-1.0.0",
@@ -53,14 +53,13 @@
},
"dependencies": {
"@huggingface/transformers": "^4.1.0",
"@ladybugdb/core": "^0.16.1",
"@ladybugdb/core": "^0.16.0",
"@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",
+6 -18
View File
@@ -32,27 +32,15 @@ interface SetupResult {
*/
function resolveGitnexusBin(): string | null {
try {
const isWin = process.platform === 'win32';
const cmd = isWin ? 'where' : 'which';
const output = execFileSync(cmd, ['gitnexus'], {
const cmd = process.platform === 'win32' ? 'where' : 'which';
const resolved = execFileSync(cmd, ['gitnexus'], {
encoding: 'utf-8',
timeout: 5000,
stdio: ['ignore', 'pipe', 'ignore'],
});
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;
})
.split('\n')[0]
.trim();
return resolved || null;
} catch {
return null;
}
+6 -6
View File
@@ -15,6 +15,7 @@ if (!process.env.ORT_LOG_LEVEL) {
}
import { pipeline, env, type FeatureExtractionPipeline } from '@huggingface/transformers';
import os from 'os';
import { existsSync } from 'fs';
import { execFileSync } from 'child_process';
import { join, dirname } from 'path';
@@ -22,7 +23,6 @@ import { createRequire } from 'module';
import { DEFAULT_EMBEDDING_CONFIG, type EmbeddingConfig, type ModelProgress } from './types.js';
import { isHttpMode, getHttpDimensions, httpEmbed } from './http-client.js';
import { resolveEmbeddingConfig } from './config.js';
import { applyHfEnvOverrides } from './hf-env.js';
/**
* Check whether the onnxruntime-node package that @huggingface/transformers
@@ -158,11 +158,11 @@ export const initEmbedder = async (
try {
// Configure transformers.js environment
env.allowLocalModels = false;
// Bridge user-controlled env vars to transformers.js: HF_HOME →
// env.cacheDir, HF_ENDPOINT → env.remoteHost (#1205). Centralised in
// applyHfEnvOverrides so the MCP embedder entry point behaves
// identically.
applyHfEnvOverrides(env);
// Default cache to user-writable location. transformers.js defaults to
// ./node_modules/.cache inside its own install dir, which is unwritable
// when gitnexus is installed globally (e.g. /usr/lib/node_modules/).
// Respect HF_HOME if set, otherwise fall back to ~/.cache/huggingface.
env.cacheDir = process.env.HF_HOME ?? join(os.homedir(), '.cache', 'huggingface');
const isDev = process.env.NODE_ENV === 'development';
if (isDev) {
-62
View File
@@ -1,62 +0,0 @@
import os from 'node:os';
import { join } from 'node:path';
/**
* @internal Exported only for unit tests and the two embedder entry points
* (`core/embeddings/embedder.ts` + `mcp/core/embedder.ts`). Not part of the
* public package API.
*
* Minimal subset of `@huggingface/transformers`' `env` object that gitnexus
* mutates. Defining a local structural type keeps this helper free of a
* transitive dependency on transformers' generated `.d.ts` while still
* giving full type-checking on the two fields we actually touch.
*/
export interface HfEnvSubset {
cacheDir: string;
remoteHost: string;
}
/**
* @internal Exported only for unit tests and the two embedder entry points
* (`core/embeddings/embedder.ts` + `mcp/core/embedder.ts`). Not part of the
* public package API.
*
* Apply user-controlled HuggingFace environment overrides to the
* `@huggingface/transformers` `env` object. Centralises the two env-var
* bridges so every gitnexus embedder entry point (the analyze pipeline
* and the MCP server) behaves identically.
*
* - **`HF_HOME`** → `env.cacheDir` (default: `~/.cache/huggingface`).
* transformers.js otherwise defaults to `./node_modules/.cache` inside
* its own install dir, which is unwritable when gitnexus is installed
* globally (e.g. `/usr/lib/node_modules/`).
*
* - **`HF_ENDPOINT`** → `env.remoteHost` (#1205). transformers.js does
* not read `HF_ENDPOINT` on its own — it reads `env.remoteHost` —
* even though `HF_ENDPOINT` is the standard env var the upstream
* `huggingface_hub` Python client and the official HF mirror docs
* tell users to set. Bridging the two unblocks `--embeddings` for
* users behind networks where `huggingface.co` is unreachable
* (corporate proxies, the GFW, air-gapped mirrors). The trailing
* slash is normalised because transformers.js builds URLs by string
* concatenation and a missing slash silently falls through to its
* default `huggingface.co/...` host.
*
* Mutation rather than return-and-apply because callers already hold a
* reference to the live `env` object imported from
* `@huggingface/transformers` — passing the same reference in keeps the
* call site a single line at each entry point.
*/
export function applyHfEnvOverrides(env: HfEnvSubset): void {
env.cacheDir = process.env.HF_HOME ?? join(os.homedir(), '.cache', 'huggingface');
// `.trim()` guards against the common copy-paste failure mode of
// `HF_ENDPOINT=" https://hf-mirror.com "` (leading/trailing whitespace
// from shell scripts or docs) — without it, a whitespace-only value
// would be truthy and produce an invalid `env.remoteHost = ' /'` that
// silently misroutes downloads. Empty string remains falsy in JS so the
// truthy guard already handles the unset/empty cases.
const endpoint = process.env.HF_ENDPOINT?.trim();
if (endpoint) {
env.remoteHost = endpoint.endsWith('/') ? endpoint : endpoint + '/';
}
}
+4 -5
View File
@@ -6,7 +6,7 @@
import { execFileSync } from 'node:child_process';
import path from 'path';
import { readRegistry, type RegistryEntry, type CwdMatch } from '../storage/repo-manager.js';
import { findGitRootByDotGit, getCurrentCommit, getRemoteUrl } from '../storage/git.js';
import { getGitRoot, getCurrentCommit, getRemoteUrl } from '../storage/git.js';
export interface StalenessInfo {
isStale: boolean;
@@ -101,10 +101,9 @@ export async function checkCwdMatch(cwd: string): Promise<CwdMatch> {
}
if (bestPath) return { match: 'path', entry: bestPath };
// 2) Sibling-by-remote: locate the cwd's git root using only ancestor
// `.git` checks before shelling out. This keeps MCP startup from
// running git in an unrelated launch cwd such as $HOME (#1138).
const cwdGitRoot = findGitRootByDotGit(cwdResolved);
// 2) Sibling-by-remote: locate the cwd's git root, get its remote
// URL, and look for any registered entry with the same fingerprint.
const cwdGitRoot = getGitRoot(cwdResolved);
if (!cwdGitRoot) return { match: 'none' };
const cwdRemote = getRemoteUrl(cwdGitRoot);
-1
View File
@@ -13,7 +13,6 @@ const DEFAULT_DETECT = {
topics: true,
shared_libs: true,
embedding_fallback: true,
workspace_deps: false,
};
const DEFAULT_MATCHING = {
@@ -1,253 +0,0 @@
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<string, string>,
): Promise<ImportedModule[]> {
const results: ImportedModule[] = [];
const sourceFiles = await findElixirFiles(repoPath);
for (const relFile of sourceFiles) {
const absPath = path.join(repoPath, relFile);
let content: string;
try {
content = await fs.readFile(absPath, 'utf-8');
} catch {
continue;
}
// alias MyApp.SomeModule
// alias MyApp.SomeModule, as: Short
// alias MyApp.{ModA, ModB}
const aliasRegex = /^\s*alias\s+([A-Z]\w+(?:\.[A-Z]\w+)*(?:\.\{[^}]+\})?)/gm;
let match;
while ((match = aliasRegex.exec(content)) !== null) {
const aliasExpr = match[1];
const modules = expandAlias(aliasExpr);
for (const mod of modules) {
const appName = matchModuleToApp(mod, knownApps);
if (appName) {
results.push({ appName, moduleName: mod, filePath: relFile });
}
}
}
// Direct module reference: MyApp.Module.func() or MyApp.Module
// Strip comment lines and string literals to avoid false positives
const codeOnly = content
.split('\n')
.filter((line) => !line.trimStart().startsWith('#'))
.join('\n');
for (const [prefix, appName] of knownApps) {
const refRegex = new RegExp(
`\\b(${escapeRegex(prefix)}\\.[A-Z][A-Za-z0-9]*(?:\\.[A-Z][A-Za-z0-9]*)*)`,
'g',
);
while ((match = refRegex.exec(codeOnly)) !== null) {
const mod = match[1];
if (!results.some((r) => r.moduleName === mod && r.filePath === relFile)) {
results.push({ appName, moduleName: mod, filePath: relFile });
}
}
}
}
return results;
}
function expandAlias(expr: string): string[] {
const braceMatch = expr.match(/^([A-Z][\w.]*)\.\{([^}]+)\}$/);
if (braceMatch) {
const prefix = braceMatch[1];
return braceMatch[2]
.split(',')
.map((s) => s.trim())
.filter(Boolean)
.map((s) => `${prefix}.${s}`);
}
return [expr];
}
function matchModuleToApp(moduleName: string, knownApps: Map<string, string>): string | null {
for (const [prefix, appName] of knownApps) {
if (moduleName === prefix || moduleName.startsWith(prefix + '.')) {
return appName;
}
}
return null;
}
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function extractTopModule(moduleName: string, prefix: string): string {
const rest = moduleName.slice(prefix.length);
if (!rest || rest === '.') return moduleName;
const afterDot = rest.startsWith('.') ? rest.slice(1) : rest;
const parts = afterDot.split('.');
return `${prefix}.${parts[0]}`;
}
async function findElixirFiles(repoPath: string): Promise<string[]> {
const results: string[] = [];
const ig = await loadIgnoreRules(repoPath);
async function walk(dir: string, rel: string): Promise<void> {
let entries;
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const childRel = rel ? `${rel}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
if (shouldIgnorePath(childRel)) continue;
if (ig && ig.ignores(childRel + '/')) continue;
await walk(path.join(dir, entry.name), childRel);
} else if (entry.name.endsWith('.ex') || entry.name.endsWith('.exs')) {
if (entry.name === 'mix.exs' || entry.name === 'mix.lock') continue;
if (shouldIgnorePath(childRel)) continue;
if (ig && ig.ignores(childRel)) continue;
results.push(childRel);
}
}
}
await walk(repoPath, '');
return results;
}
export interface ElixirWorkspaceResult {
links: GroupManifestLink[];
discoveredApps: Map<string, ElixirAppMeta>;
}
export async function extractElixirWorkspaceLinks(
repos: Record<string, string>,
repoPaths: Map<string, string>,
_dbExecutors?: Map<string, CypherExecutor>,
): Promise<ElixirWorkspaceResult> {
const appsByName = new Map<string, ElixirAppMeta>();
const appsByGroupPath = new Map<string, ElixirAppMeta>();
for (const [groupPath] of Object.entries(repos)) {
const repoPath = repoPaths.get(groupPath);
if (!repoPath) continue;
const manifest = await parseMixExs(repoPath);
if (!manifest) continue;
const meta: ElixirAppMeta = {
appName: manifest.appName,
modulePrefix: manifest.modulePrefix,
groupPath,
repoPath,
deps: manifest.deps,
};
const existing = appsByName.get(manifest.appName);
if (existing) {
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<string>();
for (const [, app] of appsByGroupPath) {
const groupDeps = app.deps.filter((d) => appsByName.has(d));
if (groupDeps.length === 0) continue;
const knownApps = new Map<string, string>();
for (const dep of groupDeps) {
const depMeta = appsByName.get(dep);
if (depMeta) knownApps.set(depMeta.modulePrefix, dep);
}
const imports = await scanElixirImports(app.repoPath, knownApps);
for (const imp of imports) {
const providerApp = appsByName.get(imp.appName);
if (!providerApp) continue;
const topModule = extractTopModule(imp.moduleName, providerApp.modulePrefix);
const key = `${app.groupPath}→${providerApp.groupPath}::${topModule}`;
if (seen.has(key)) continue;
seen.add(key);
// V1: Elixir contracts use the full module name (e.g. "Core.Schema") without
// an "appName::" prefix. resolveSymbol will query the graph with this full
// string — resolution depends on Elixir indexer storing fully-qualified names.
const link: GroupManifestLink = {
from: providerApp.groupPath,
to: app.groupPath,
type: 'custom',
contract: topModule,
role: 'provider' as ContractRole,
};
links.push(link);
}
}
return { links, discoveredApps: appsByGroupPath };
}
@@ -1,258 +0,0 @@
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<string, string>,
): Promise<ImportedSymbol[]> {
const results: ImportedSymbol[] = [];
const sourceFiles = await findGoFiles(repoPath);
for (const relFile of sourceFiles) {
const absPath = path.join(repoPath, relFile);
let content: string;
try {
content = await fs.readFile(absPath, 'utf-8');
} catch {
continue;
}
const importPaths = extractImportPaths(content);
for (const importPath of importPaths) {
const matchedModule = findMatchingModule(importPath, knownModules);
if (!matchedModule) continue;
const symbols = extractUsedTypes(content, importPath);
for (const sym of symbols) {
results.push({ modulePath: matchedModule, symbolName: sym, filePath: relFile });
}
}
}
return results;
}
function extractImportPaths(content: string): string[] {
const paths: string[] = [];
// Single: import "path"
const singleImports = content.matchAll(/^import\s+"([^"]+)"/gm);
for (const m of singleImports) paths.push(m[1]);
// Single aliased: import alias "path"
const aliasedImports = content.matchAll(/^import\s+\w+\s+"([^"]+)"/gm);
for (const m of aliasedImports) paths.push(m[1]);
// Block: import ( ... )
const blockImports = content.matchAll(/^import\s*\(\s*\n([\s\S]*?)\)/gm);
for (const block of blockImports) {
const lines = block[1].split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('//')) continue;
const pathMatch = trimmed.match(/"([^"]+)"/);
if (pathMatch) paths.push(pathMatch[1]);
}
}
return [...new Set(paths)];
}
function findMatchingModule(importPath: string, knownModules: Map<string, string>): string | null {
for (const [modPath] of knownModules) {
if (importPath === modPath || importPath.startsWith(modPath + '/')) {
return modPath;
}
}
return null;
}
function extractUsedTypes(content: string, importPath: string): string[] {
const pkgName = importPath.split('/').pop() || '';
if (!pkgName) return [];
// Match pkg.TypeName where TypeName is PascalCase (exported)
const typeRegex = new RegExp(`\\b${escapeRegex(pkgName)}\\.([A-Z][A-Za-z0-9]*)`, 'g');
const types = new Set<string>();
let match;
while ((match = typeRegex.exec(content)) !== null) {
types.add(match[1]);
}
return [...types];
}
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
async function findGoFiles(repoPath: string): Promise<string[]> {
const results: string[] = [];
const ig = await loadIgnoreRules(repoPath);
async function walk(dir: string, rel: string): Promise<void> {
let entries;
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const childRel = rel ? `${rel}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
if (shouldIgnorePath(childRel)) continue;
if (ig && ig.ignores(childRel + '/')) continue;
await walk(path.join(dir, entry.name), childRel);
} else if (entry.name.endsWith('.go') && !entry.name.endsWith('_test.go')) {
if (shouldIgnorePath(childRel)) continue;
if (ig && ig.ignores(childRel)) continue;
results.push(childRel);
}
}
}
await walk(repoPath, '');
return results;
}
export interface GoWorkspaceResult {
links: GroupManifestLink[];
discoveredModules: Map<string, GoModuleMeta>;
}
export async function extractGoWorkspaceLinks(
repos: Record<string, string>,
repoPaths: Map<string, string>,
_dbExecutors?: Map<string, CypherExecutor>,
): Promise<GoWorkspaceResult> {
const modulesByPath = new Map<string, GoModuleMeta>();
const modulesByGroupPath = new Map<string, GoModuleMeta>();
for (const [groupPath] of Object.entries(repos)) {
const repoPath = repoPaths.get(groupPath);
if (!repoPath) continue;
const manifest = await parseGoMod(repoPath);
if (!manifest) continue;
const meta: GoModuleMeta = {
modulePath: manifest.modulePath,
groupPath,
repoPath,
requires: manifest.requires,
};
const existing = modulesByPath.get(manifest.modulePath);
if (existing) {
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<string>();
for (const [, mod] of modulesByGroupPath) {
const groupModDeps = mod.requires.filter((r) => modulesByPath.has(r));
if (groupModDeps.length === 0) continue;
const knownModules = new Map<string, string>();
for (const dep of groupModDeps) {
knownModules.set(dep, dep);
}
const imports = await scanGoImports(mod.repoPath, knownModules);
for (const imp of imports) {
const providerMod = modulesByPath.get(imp.modulePath);
if (!providerMod) continue;
const qualifiedContract = `${imp.modulePath}::${imp.symbolName}`;
const key = `${mod.groupPath}→${providerMod.groupPath}::${qualifiedContract}`;
if (seen.has(key)) continue;
seen.add(key);
const link: GroupManifestLink = {
from: providerMod.groupPath,
to: mod.groupPath,
type: 'custom',
contract: qualifiedContract,
role: 'provider' as ContractRole,
};
links.push(link);
}
}
return { links, discoveredModules: modulesByGroupPath };
}
@@ -1,261 +0,0 @@
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(/<project[^>]*>[\s\S]*?<groupId>([^<]+)<\/groupId>/);
const projectArtifactMatch = content.match(
/<project[^>]*>[\s\S]*?<artifactId>([^<]+)<\/artifactId>/,
);
if (!projectGroupMatch || !projectArtifactMatch) return null;
const groupId = projectGroupMatch[1].trim();
const artifactId = projectArtifactMatch[1].trim();
const deps: string[] = [];
const depBlocks = content.matchAll(/<dependency>\s*([\s\S]*?)<\/dependency>/g);
for (const block of depBlocks) {
const gMatch = block[1].match(/<groupId>([^<]+)<\/groupId>/);
const aMatch = block[1].match(/<artifactId>([^<]+)<\/artifactId>/);
if (gMatch && aMatch) {
deps.push(`${gMatch[1].trim()}:${aMatch[1].trim()}`);
}
}
return { groupId, artifactId, deps: [...new Set(deps)] };
}
function parseGradle(
content: string,
repoPath: string,
): { groupId: string; artifactId: string; deps: string[] } | null {
const groupMatch = content.match(/group\s*=\s*['"]([^'"]+)['"]/);
const dirName = path.basename(repoPath);
const groupId = groupMatch ? groupMatch[1] : '';
if (!groupId) return null;
const artifactId = dirName;
const deps: string[] = [];
// implementation("group:artifact:version") or api("group:artifact:version")
const depMatches = content.matchAll(
/(?:implementation|api|compileOnly|runtimeOnly)\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
);
for (const m of depMatches) {
const parts = m[1].split(':');
if (parts.length >= 2) {
deps.push(`${parts[0]}:${parts[1]}`);
}
}
// implementation(project(":subproject"))
const projDeps = content.matchAll(
/(?:implementation|api)\s*\(\s*project\s*\(\s*['"]([^'"]+)['"]\s*\)\s*\)/g,
);
for (const m of projDeps) {
const subName = m[1].replace(/^:/, '');
deps.push(`${groupId}:${subName}`);
}
return { groupId, artifactId, deps: [...new Set(deps)] };
}
function deriveBasePackage(groupId: string, artifactId: string): string {
const sanitized = artifactId.replace(/-/g, '.');
if (groupId.endsWith(`.${sanitized}`) || groupId === sanitized) {
return groupId;
}
return `${groupId}.${sanitized}`;
}
async function scanJavaImports(
repoPath: string,
knownPackages: Map<string, string>,
): Promise<ImportedSymbol[]> {
const results: ImportedSymbol[] = [];
const sourceFiles = await findJavaFiles(repoPath);
for (const relFile of sourceFiles) {
const absPath = path.join(repoPath, relFile);
let content: string;
try {
content = await fs.readFile(absPath, 'utf-8');
} catch {
continue;
}
const importRegex = /^import\s+(?:static\s+)?([a-zA-Z][\w.]*\.[A-Z]\w*)/gm;
let match;
while ((match = importRegex.exec(content)) !== null) {
const fullImport = match[1];
for (const [basePkg, artifactKey] of knownPackages) {
if (fullImport.startsWith(basePkg + '.') || fullImport === basePkg) {
const parts = fullImport.split('.');
const className = parts[parts.length - 1];
if (isPascalCase(className)) {
results.push({
artifactKey,
symbolName: className,
filePath: relFile,
});
}
break;
}
}
}
}
return results;
}
function isPascalCase(name: string): boolean {
return /^[A-Z][A-Za-z0-9]*$/.test(name);
}
async function findJavaFiles(repoPath: string): Promise<string[]> {
const results: string[] = [];
const ig = await loadIgnoreRules(repoPath);
async function walk(dir: string, rel: string): Promise<void> {
let entries;
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const childRel = rel ? `${rel}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
if (shouldIgnorePath(childRel)) continue;
if (ig && ig.ignores(childRel + '/')) continue;
await walk(path.join(dir, entry.name), childRel);
} else if (entry.name.endsWith('.java') || entry.name.endsWith('.kt')) {
if (shouldIgnorePath(childRel)) continue;
if (ig && ig.ignores(childRel)) continue;
results.push(childRel);
}
}
}
await walk(repoPath, '');
return results;
}
export interface JavaWorkspaceResult {
links: GroupManifestLink[];
discoveredProjects: Map<string, JavaProjectMeta>;
}
export async function extractJavaWorkspaceLinks(
repos: Record<string, string>,
repoPaths: Map<string, string>,
_dbExecutors?: Map<string, CypherExecutor>,
): Promise<JavaWorkspaceResult> {
const projectsByKey = new Map<string, JavaProjectMeta>();
const projectsByGroupPath = new Map<string, JavaProjectMeta>();
for (const [groupPath] of Object.entries(repos)) {
const repoPath = repoPaths.get(groupPath);
if (!repoPath) continue;
const manifest = await parseJavaManifest(repoPath);
if (!manifest) continue;
const key = `${manifest.groupId}:${manifest.artifactId}`;
const meta: JavaProjectMeta = {
groupId: manifest.groupId,
artifactId: manifest.artifactId,
basePackage: deriveBasePackage(manifest.groupId, manifest.artifactId),
groupPath,
repoPath,
deps: manifest.deps,
};
const existing = projectsByKey.get(key);
if (existing) {
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<string>();
for (const [, proj] of projectsByGroupPath) {
const groupDeps = proj.deps.filter((d) => projectsByKey.has(d));
if (groupDeps.length === 0) continue;
const knownPackages = new Map<string, string>();
for (const dep of groupDeps) {
const depMeta = projectsByKey.get(dep);
if (depMeta) knownPackages.set(depMeta.basePackage, dep);
}
const imports = await scanJavaImports(proj.repoPath, knownPackages);
for (const imp of imports) {
const providerProj = projectsByKey.get(imp.artifactKey);
if (!providerProj) continue;
const qualifiedContract = `${providerProj.artifactId}::${imp.symbolName}`;
const dedupKey = `${proj.groupPath}→${providerProj.groupPath}::${qualifiedContract}`;
if (seen.has(dedupKey)) continue;
seen.add(dedupKey);
const link: GroupManifestLink = {
from: providerProj.groupPath,
to: proj.groupPath,
type: 'custom',
contract: qualifiedContract,
role: 'provider' as ContractRole,
};
links.push(link);
}
}
return { links, discoveredProjects: projectsByGroupPath };
}
@@ -268,21 +268,6 @@ export class ManifestExtractor {
LIMIT 1`,
{ contract: link.contract },
);
} else if (link.type === 'custom') {
// Workspace extractors produce qualified contracts like "mathlex::Expression".
// Graph nodes store the unqualified symbol name ("Expression"), so strip
// 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 = $symbolName
RETURN n.id AS uid, n.name AS name, n.filePath AS filePath
ORDER BY n.filePath ASC
LIMIT 1`,
{ symbolName },
);
} else {
return null;
}
@@ -1,248 +0,0 @@
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<string, unknown>;
try {
pkg = JSON.parse(content);
} catch {
return null;
}
const name = typeof pkg.name === 'string' ? pkg.name : '';
if (!name) return null;
const deps: string[] = [];
for (const field of ['dependencies', 'devDependencies', 'peerDependencies']) {
const section = pkg[field];
if (section && typeof section === 'object') {
deps.push(...Object.keys(section as Record<string, unknown>));
}
}
return { name, workspaceDeps: [...new Set(deps)] };
}
async function scanImports(
repoPath: string,
knownPackages: Set<string>,
): Promise<ImportedSymbol[]> {
const results: ImportedSymbol[] = [];
const sourceFiles = await findSourceFiles(repoPath);
for (const relFile of sourceFiles) {
const absPath = path.join(repoPath, relFile);
let content: string;
try {
content = await fs.readFile(absPath, 'utf-8');
} catch {
continue;
}
// ES import: import { Foo, Bar } from '<pkg>'
// Also: import { Foo as Baz } from '<pkg>'
const esImportRegex = /^import\s+\{([^}]+)\}\s+from\s+['"]([^'"]+)['"]/gm;
let match;
while ((match = esImportRegex.exec(content)) !== null) {
const importClause = match[1];
const modulePath = match[2];
const pkgName = resolvePackageName(modulePath);
if (!pkgName || !knownPackages.has(pkgName)) continue;
const symbols = parseImportClause(importClause);
for (const sym of symbols) {
if (isExportedName(sym)) {
results.push({ packageName: pkgName, symbolName: sym, filePath: relFile });
}
}
}
// ES import default: import Foo from '<pkg>'
const defaultImportRegex = /^import\s+([A-Z][A-Za-z0-9]*)\s+from\s+['"]([^'"]+)['"]/gm;
while ((match = defaultImportRegex.exec(content)) !== null) {
const symbolName = match[1];
const modulePath = match[2];
const pkgName = resolvePackageName(modulePath);
if (!pkgName || !knownPackages.has(pkgName)) continue;
if (isExportedName(symbolName)) {
results.push({ packageName: pkgName, symbolName, filePath: relFile });
}
}
// CommonJS: const { Foo, Bar } = require('<pkg>')
const cjsRegex = /(?:const|let|var)\s+\{([^}]+)\}\s*=\s*require\s*\(\s*['"]([^'"]+)['"]\s*\)/gm;
while ((match = cjsRegex.exec(content)) !== null) {
const importClause = match[1];
const modulePath = match[2];
const pkgName = resolvePackageName(modulePath);
if (!pkgName || !knownPackages.has(pkgName)) continue;
const symbols = parseImportClause(importClause);
for (const sym of symbols) {
if (isExportedName(sym)) {
results.push({ packageName: pkgName, symbolName: sym, filePath: relFile });
}
}
}
}
return results;
}
function resolvePackageName(modulePath: string): string | null {
if (modulePath.startsWith('.') || modulePath.startsWith('/')) return null;
// Scoped: @scope/pkg or @scope/pkg/sub
if (modulePath.startsWith('@')) {
const parts = modulePath.split('/');
if (parts.length >= 2) return `${parts[0]}/${parts[1]}`;
return null;
}
// Unscoped: pkg or pkg/sub
return modulePath.split('/')[0];
}
function parseImportClause(clause: string): string[] {
return clause
.split(',')
.map((s) => {
const trimmed = s.trim();
// Handle `Foo as Bar` — use the original export name
const asMatch = trimmed.match(/^(\S+)\s+as\s+/);
return asMatch ? asMatch[1] : trimmed;
})
.filter(Boolean);
}
function isExportedName(name: string): boolean {
return /^[A-Z][A-Za-z0-9]*$/.test(name);
}
async function findSourceFiles(repoPath: string): Promise<string[]> {
const results: string[] = [];
const EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.mts', '.cts']);
const ig = await loadIgnoreRules(repoPath);
async function walk(dir: string, rel: string): Promise<void> {
let entries;
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const childRel = rel ? `${rel}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
if (shouldIgnorePath(childRel)) continue;
if (ig && ig.ignores(childRel + '/')) continue;
await walk(path.join(dir, entry.name), childRel);
} else {
const ext = path.extname(entry.name);
if (EXTENSIONS.has(ext)) {
if (shouldIgnorePath(childRel)) continue;
if (ig && ig.ignores(childRel)) continue;
results.push(childRel);
}
}
}
}
await walk(repoPath, '');
return results;
}
export interface NodeWorkspaceResult {
links: GroupManifestLink[];
discoveredPackages: Map<string, PackageMeta>;
}
export async function extractNodeWorkspaceLinks(
repos: Record<string, string>,
repoPaths: Map<string, string>,
_dbExecutors?: Map<string, CypherExecutor>,
): Promise<NodeWorkspaceResult> {
const packagesByName = new Map<string, PackageMeta>();
const packagesByGroupPath = new Map<string, PackageMeta>();
for (const [groupPath] of Object.entries(repos)) {
const repoPath = repoPaths.get(groupPath);
if (!repoPath) continue;
const manifest = await parsePackageManifest(repoPath);
if (!manifest) continue;
const meta: PackageMeta = {
name: manifest.name,
groupPath,
repoPath,
workspaceDeps: manifest.workspaceDeps,
};
const existing = packagesByName.get(manifest.name);
if (existing) {
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<string>();
for (const [, pkg] of packagesByGroupPath) {
const groupPkgDeps = pkg.workspaceDeps.filter((d) => packagesByName.has(d));
if (groupPkgDeps.length === 0) continue;
const knownPackages = new Set(groupPkgDeps);
const imports = await scanImports(pkg.repoPath, knownPackages);
for (const imp of imports) {
const providerPkg = packagesByName.get(imp.packageName);
if (!providerPkg) continue;
const qualifiedContract = `${imp.packageName}::${imp.symbolName}`;
const key = `${pkg.groupPath}→${providerPkg.groupPath}::${qualifiedContract}`;
if (seen.has(key)) continue;
seen.add(key);
const link: GroupManifestLink = {
from: providerPkg.groupPath,
to: pkg.groupPath,
type: 'custom',
contract: qualifiedContract,
role: 'provider' as ContractRole,
};
links.push(link);
}
}
return { links, discoveredPackages: packagesByGroupPath };
}
@@ -1,254 +0,0 @@
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<string, string>,
): Promise<ImportedSymbol[]> {
const results: ImportedSymbol[] = [];
const sourceFiles = await findPythonFiles(repoPath);
for (const relFile of sourceFiles) {
const absPath = path.join(repoPath, relFile);
let content: string;
try {
content = await fs.readFile(absPath, 'utf-8');
} catch {
continue;
}
// from <pkg> import Foo, Bar
// from <pkg>.module import Foo
const fromImportRegex = /^from\s+(\w[\w.]*)\s+import\s+(.+)/gm;
let match;
while ((match = fromImportRegex.exec(content)) !== null) {
const modulePath = match[1];
const importClause = match[2];
const rootModule = modulePath.split('.')[0];
const originalName = knownPackages.get(rootModule);
if (!originalName) continue;
if (importClause.trim() === '(') continue;
const symbols = importClause
.replace(/\(|\)/g, '')
.split(',')
.map((s) => {
const trimmed = s.trim();
const asMatch = trimmed.match(/^(\S+)\s+as\s+/);
return asMatch ? asMatch[1] : trimmed;
})
.filter(Boolean);
for (const sym of symbols) {
if (isPascalCase(sym)) {
results.push({ packageName: originalName, symbolName: sym, filePath: relFile });
}
}
}
}
return results;
}
function isPascalCase(name: string): boolean {
return /^[A-Z][A-Za-z0-9]*$/.test(name);
}
async function findPythonFiles(repoPath: string): Promise<string[]> {
const results: string[] = [];
const ig = await loadIgnoreRules(repoPath);
async function walk(dir: string, rel: string): Promise<void> {
let entries;
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const childRel = rel ? `${rel}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
if (shouldIgnorePath(childRel)) continue;
if (ig && ig.ignores(childRel + '/')) continue;
await walk(path.join(dir, entry.name), childRel);
} else if (entry.name.endsWith('.py')) {
if (shouldIgnorePath(childRel)) continue;
if (ig && ig.ignores(childRel)) continue;
results.push(childRel);
}
}
}
await walk(repoPath, '');
return results;
}
export interface PythonWorkspaceResult {
links: GroupManifestLink[];
discoveredPackages: Map<string, PythonPackageMeta>;
}
export async function extractPythonWorkspaceLinks(
repos: Record<string, string>,
repoPaths: Map<string, string>,
_dbExecutors?: Map<string, CypherExecutor>,
): Promise<PythonWorkspaceResult> {
const packagesByImportName = new Map<string, PythonPackageMeta>();
const packagesByGroupPath = new Map<string, PythonPackageMeta>();
for (const [groupPath] of Object.entries(repos)) {
const repoPath = repoPaths.get(groupPath);
if (!repoPath) continue;
const manifest = await parsePythonManifest(repoPath);
if (!manifest) continue;
const meta: PythonPackageMeta = {
name: manifest.name,
importName: manifest.importName,
groupPath,
repoPath,
workspaceDeps: manifest.deps,
};
const existing = packagesByImportName.get(manifest.importName);
if (existing) {
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<string>();
for (const [, pkg] of packagesByGroupPath) {
const normalizedDeps = pkg.workspaceDeps.map((d) => d.replace(/-/g, '_'));
const groupPkgDeps = normalizedDeps.filter((d) => packagesByImportName.has(d));
if (groupPkgDeps.length === 0) continue;
const knownPackages = new Map<string, string>();
for (const dep of groupPkgDeps) {
const meta = packagesByImportName.get(dep);
if (meta) knownPackages.set(dep, meta.name);
}
const imports = await scanPythonImports(pkg.repoPath, knownPackages);
for (const imp of imports) {
const providerImportName = imp.packageName.replace(/-/g, '_');
const providerPkg = packagesByImportName.get(providerImportName);
if (!providerPkg) continue;
const qualifiedContract = `${providerPkg.name}::${imp.symbolName}`;
const key = `${pkg.groupPath}→${providerPkg.groupPath}::${qualifiedContract}`;
if (seen.has(key)) continue;
seen.add(key);
const link: GroupManifestLink = {
from: providerPkg.groupPath,
to: pkg.groupPath,
type: 'custom',
contract: qualifiedContract,
role: 'provider' as ContractRole,
};
links.push(link);
}
}
return { links, discoveredPackages: packagesByGroupPath };
}
@@ -1,270 +0,0 @@
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 } from '../../../config/ignore-service.js';
import { loadIgnoreRules } from '../../../config/ignore-service.js';
/**
* Discover cross-crate contracts in a Rust workspace by reading each
* member's `Cargo.toml` dependencies and scanning source files for
* `use <workspace_dep>::<Type>` imports.
*
* Emits `GroupManifestLink[]` with `type: 'custom'` that feed into the
* existing ManifestExtractor pipeline — no new matching logic needed.
*
* Designed for the group-level sync pipeline: it receives all repos in
* a group and produces cross-repo links between them.
*/
interface CrateMeta {
name: string;
groupPath: string;
repoPath: string;
workspaceDeps: string[];
}
interface ImportedSymbol {
crateName: string;
symbolName: string;
filePath: string;
}
/**
* Parse a Cargo.toml to extract the crate name and workspace dependency
* names. Uses simple line-based parsing — no TOML library needed for
* the subset we care about.
*/
async function parseCrateManifest(
repoPath: string,
): Promise<{ name: string; workspaceDeps: string[] } | null> {
const cargoPath = path.join(repoPath, 'Cargo.toml');
let content: string;
try {
content = await fs.readFile(cargoPath, 'utf-8');
} catch {
return null;
}
let name = '';
const workspaceDeps: string[] = [];
const nameMatch = content.match(/^\[package\]\s*\n(?:[^\[]*?\n)*?name\s*=\s*"([^"]+)"/m);
if (nameMatch) name = nameMatch[1];
// Match dependencies that use workspace = true, which indicates they
// are workspace-internal deps:
// dep_name = { workspace = true }
// dep_name.workspace = true
//
// Also match plain path dependencies:
// dep_name = { path = "../other" }
const depSections = content.matchAll(
/\[(dependencies|dev-dependencies|build-dependencies)\]\s*\n([\s\S]*?)(?=\n\[|$)/g,
);
for (const section of depSections) {
const sectionBody = section[2];
// workspace = true style
const wsMatches = sectionBody.matchAll(
/^(\w[\w-]*)\s*=\s*\{[^}]*workspace\s*=\s*true[^}]*\}/gm,
);
for (const m of wsMatches) workspaceDeps.push(m[1]);
// dotted workspace style: dep_name.workspace = true
const dottedMatches = sectionBody.matchAll(/^(\w[\w-]*)\.workspace\s*=\s*true/gm);
for (const m of dottedMatches) workspaceDeps.push(m[1]);
// path = "../other" style (local path deps within workspace)
const pathMatches = sectionBody.matchAll(
/^(\w[\w-]*)\s*=\s*\{[^}]*path\s*=\s*"[^"]*"[^}]*\}/gm,
);
for (const m of pathMatches) workspaceDeps.push(m[1]);
}
if (!name) return null;
return { name, workspaceDeps: [...new Set(workspaceDeps)] };
}
/**
* Scan Rust source files for `use <crate>::<path>::<Symbol>` patterns
* where <crate> is a known workspace dependency.
*/
async function scanImports(repoPath: string, knownCrates: Set<string>): Promise<ImportedSymbol[]> {
const results: ImportedSymbol[] = [];
const normalizedCrates = new Map<string, string>();
for (const c of knownCrates) {
normalizedCrates.set(c.replace(/-/g, '_'), c);
}
const sourceFiles = await findRustFiles(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;
}
// Match patterns:
// use crate_name::Type;
// use crate_name::module::Type;
// use crate_name::{Type1, Type2};
// use crate_name::module::{Type1, Type2};
const useRegex = /^use\s+(\w+)::(.+);/gm;
let match;
while ((match = useRegex.exec(content)) !== null) {
const crateName = match[1];
const originalCrateName = normalizedCrates.get(crateName);
if (!originalCrateName) continue;
const importPath = match[2].trim();
// Handle grouped imports: {Type1, Type2, module::Type3}
const braceMatch = importPath.match(/\{([^}]+)\}/);
if (braceMatch) {
const items = braceMatch[1].split(',').map((s) => s.trim());
for (const item of items) {
const symbolName = extractSymbolName(item);
if (symbolName && isTypeName(symbolName)) {
results.push({ crateName: originalCrateName, symbolName, filePath: relFile });
}
}
} else {
const symbolName = extractSymbolName(importPath);
if (symbolName && isTypeName(symbolName)) {
results.push({ crateName: originalCrateName, symbolName, filePath: relFile });
}
}
}
}
return results;
}
/** Extract the final symbol name from a path like `module::submod::TypeName`. */
function extractSymbolName(importPath: string): string | null {
const trimmed = importPath.trim();
if (!trimmed || trimmed === '*' || trimmed === 'self') return null;
const parts = trimmed.split('::');
return parts[parts.length - 1].trim() || null;
}
/**
* Heuristic: in Rust, types (structs, enums, traits) are PascalCase.
* Functions and modules are snake_case. We only want types as cross-crate
* contracts — functions are too granular and modules too broad.
*/
function isTypeName(name: string): boolean {
return /^[A-Z][A-Za-z0-9]*$/.test(name);
}
async function findRustFiles(repoPath: string): Promise<string[]> {
const results: string[] = [];
const ig = await loadIgnoreRules(repoPath);
async function walk(dir: string, rel: string): Promise<void> {
let entries;
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const childRel = rel ? `${rel}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
if (shouldIgnorePath(childRel)) continue;
if (ig && ig.ignores(childRel + '/')) continue;
await walk(path.join(dir, entry.name), childRel);
} else if (entry.name.endsWith('.rs')) {
if (shouldIgnorePath(childRel)) continue;
if (ig && ig.ignores(childRel)) continue;
results.push(childRel);
}
}
}
await walk(repoPath, '');
return results;
}
export interface RustWorkspaceResult {
links: GroupManifestLink[];
discoveredCrates: Map<string, CrateMeta>;
}
/**
* Discover cross-crate contracts across all Rust repos in a group.
*
* Returns `GroupManifestLink[]` ready to feed into `ManifestExtractor`.
*/
export async function extractRustWorkspaceLinks(
repos: Record<string, string>,
repoPaths: Map<string, string>,
_dbExecutors?: Map<string, CypherExecutor>,
): Promise<RustWorkspaceResult> {
// Phase 1: Parse all Cargo.toml files to build crate registry
const cratesByName = new Map<string, CrateMeta>();
const cratesByGroupPath = new Map<string, CrateMeta>();
for (const [groupPath] of Object.entries(repos)) {
const repoPath = repoPaths.get(groupPath);
if (!repoPath) continue;
const manifest = await parseCrateManifest(repoPath);
if (!manifest) continue;
const meta: CrateMeta = {
name: manifest.name,
groupPath,
repoPath,
workspaceDeps: manifest.workspaceDeps,
};
const existing = cratesByName.get(manifest.name);
if (existing) {
console.warn(
`[rust-workspace-extractor] duplicate crate name "${manifest.name}" in "${groupPath}" and "${existing.groupPath}" — skipping "${groupPath}"`,
);
continue;
}
cratesByName.set(manifest.name, meta);
cratesByGroupPath.set(groupPath, meta);
}
// Phase 2: For each crate, identify which of its workspace deps are
// also in this group (i.e., repos we can link to)
const links: GroupManifestLink[] = [];
const seen = new Set<string>();
for (const [, crate] of cratesByGroupPath) {
const groupCrateDeps = crate.workspaceDeps.filter((d) => cratesByName.has(d));
if (groupCrateDeps.length === 0) continue;
// Phase 3: Scan source files for imports from workspace deps
const knownCrates = new Set(groupCrateDeps);
const imports = await scanImports(crate.repoPath, knownCrates);
for (const imp of imports) {
const providerCrate = cratesByName.get(imp.crateName);
if (!providerCrate) continue;
const qualifiedContract = `${imp.crateName}::${imp.symbolName}`;
const key = `${crate.groupPath}→${providerCrate.groupPath}::${qualifiedContract}`;
if (seen.has(key)) continue;
seen.add(key);
const link: GroupManifestLink = {
from: providerCrate.groupPath,
to: crate.groupPath,
type: 'custom',
contract: qualifiedContract,
role: 'provider' as ContractRole,
};
links.push(link);
}
}
return { links, discoveredCrates: cratesByGroupPath };
}
@@ -1,90 +0,0 @@
import type { CypherExecutor } from '../contract-extractor.js';
import type { GroupManifestLink } from '../types.js';
import { extractRustWorkspaceLinks } from './rust-workspace-extractor.js';
import { extractNodeWorkspaceLinks } from './node-workspace-extractor.js';
import { extractPythonWorkspaceLinks } from './python-workspace-extractor.js';
import { extractGoWorkspaceLinks } from './go-workspace-extractor.js';
import { extractJavaWorkspaceLinks } from './java-workspace-extractor.js';
import { extractElixirWorkspaceLinks } from './elixir-workspace-extractor.js';
export interface WorkspaceDiscoveryResult {
links: GroupManifestLink[];
stats: WorkspaceExtractorStats[];
}
interface WorkspaceExtractorStats {
ecosystem: string;
linkCount: number;
projectCount: number;
}
export async function discoverWorkspaceLinks(
repos: Record<string, string>,
repoPaths: Map<string, string>,
dbExecutors?: Map<string, CypherExecutor>,
): Promise<WorkspaceDiscoveryResult> {
const links: GroupManifestLink[] = [];
const stats: WorkspaceExtractorStats[] = [];
const rustResult = await extractRustWorkspaceLinks(repos, repoPaths, dbExecutors);
if (rustResult.links.length > 0) {
links.push(...rustResult.links);
stats.push({
ecosystem: 'Rust',
linkCount: rustResult.links.length,
projectCount: rustResult.discoveredCrates.size,
});
}
const nodeResult = await extractNodeWorkspaceLinks(repos, repoPaths, dbExecutors);
if (nodeResult.links.length > 0) {
links.push(...nodeResult.links);
stats.push({
ecosystem: 'Node',
linkCount: nodeResult.links.length,
projectCount: nodeResult.discoveredPackages.size,
});
}
const pyResult = await extractPythonWorkspaceLinks(repos, repoPaths, dbExecutors);
if (pyResult.links.length > 0) {
links.push(...pyResult.links);
stats.push({
ecosystem: 'Python',
linkCount: pyResult.links.length,
projectCount: pyResult.discoveredPackages.size,
});
}
const goResult = await extractGoWorkspaceLinks(repos, repoPaths, dbExecutors);
if (goResult.links.length > 0) {
links.push(...goResult.links);
stats.push({
ecosystem: 'Go',
linkCount: goResult.links.length,
projectCount: goResult.discoveredModules.size,
});
}
const javaResult = await extractJavaWorkspaceLinks(repos, repoPaths, dbExecutors);
if (javaResult.links.length > 0) {
links.push(...javaResult.links);
stats.push({
ecosystem: 'Java',
linkCount: javaResult.links.length,
projectCount: javaResult.discoveredProjects.size,
});
}
const elixirResult = await extractElixirWorkspaceLinks(repos, repoPaths, dbExecutors);
if (elixirResult.links.length > 0) {
links.push(...elixirResult.links);
stats.push({
ecosystem: 'Elixir',
linkCount: elixirResult.links.length,
projectCount: elixirResult.discoveredApps.size,
});
}
return { links, stats };
}
+9 -35
View File
@@ -8,7 +8,6 @@ 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 { 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';
@@ -85,14 +84,12 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
let autoContracts: StoredContract[] = [];
let manifestCrossLinks: CrossLink[] = [];
let dbExecutors: Map<string, CypherExecutor> | undefined;
let registryEntries: RegistryEntry[] | undefined;
const eo = opts?.extractorOverride;
if (eo && eo.length === 0) {
autoContracts = await (eo as () => Promise<StoredContract[]>)();
} else {
registryEntries = await readRegistry();
const entries = registryEntries;
const entries = await readRegistry();
const resolve = opts?.resolveRepoHandle ?? defaultResolveHandle(entries);
const httpEx = new HttpRouteExtractor();
const grpcEx = new GrpcExtractor();
@@ -180,41 +177,18 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
}
}
// Auto-discover workspace dependency contracts (Rust Cargo workspaces, etc.)
// and merge them with explicit manifest links. Discovered links use the same
// ManifestExtractor pipeline as hand-written links in group.yaml.
let allLinks = [...config.links];
if (config.detect.workspace_deps) {
const repoPaths = new Map<string, string>();
if (!registryEntries) registryEntries = await readRegistry();
for (const [groupPath, regName] of Object.entries(config.repos)) {
const e = registryEntries.find((en) => en.name === regName);
if (e) repoPaths.set(groupPath, e.path);
}
const wsResult = await discoverWorkspaceLinks(config.repos, repoPaths, dbExecutors);
if (wsResult.links.length > 0) {
allLinks = [...allLinks, ...wsResult.links];
if (opts?.verbose) {
for (const s of wsResult.stats) {
console.log(
` workspace-deps: discovered ${s.linkCount} cross-${s.ecosystem.toLowerCase()} links from ${s.projectCount} ${s.ecosystem} projects`,
);
}
}
}
}
// Process manifest links declared in group.yaml (plus any auto-discovered).
// Process manifest links declared in group.yaml.
// ManifestExtractor is fully implemented but was never wired into this
// pipeline — config.links were parsed and validated but silently dropped.
// Placed after the DB try/finally: resolveSymbol falls back to synthetic
// UIDs when dbExecutors is undefined or a pool is closed, so cross-links
// are always generated regardless of whether real DB executors are available.
if (allLinks.length > 0) {
if (config.links.length > 0) {
// Warn about dangling links that reference repos not declared in config.repos.
// They still generate cross-links via synthetic UIDs (determinism is preserved),
// but the operator probably meant something that now silently does nothing useful.
const knownRepos = new Set(Object.keys(config.repos));
for (const link of allLinks) {
for (const link of config.links) {
const dangling = [link.from, link.to].filter((r) => !knownRepos.has(r));
if (dangling.length > 0) {
console.warn(
@@ -224,12 +198,12 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
}
const manifestEx = new ManifestExtractor();
const manifestResult = await manifestEx.extractFromManifest(allLinks, dbExecutors);
const manifestResult = await manifestEx.extractFromManifest(config.links, dbExecutors);
autoContracts.push(...manifestResult.contracts);
manifestCrossLinks = manifestResult.crossLinks;
if (opts?.verbose) {
console.log(
` manifest: ${manifestCrossLinks.length} cross-links from ${allLinks.length} links (${config.links.length} declared + ${allLinks.length - config.links.length} discovered)`,
` manifest: ${manifestCrossLinks.length} cross-links from ${config.links.length} declared links`,
);
}
}
-1
View File
@@ -27,7 +27,6 @@ export interface DetectConfig {
topics: boolean;
shared_libs: boolean;
embedding_fallback: boolean;
workspace_deps: boolean;
}
export interface MatchingConfig {
@@ -12,15 +12,18 @@
import { detectFrameworkFromPath } from './framework-detection.js';
import { SupportedLanguages } from 'gitnexus-shared';
import { providers } from './languages/index.js';
// ============================================================================
// NAME PATTERNS
// NAME PATTERNS - All 13 supported languages
// ============================================================================
/**
* Universal entry point naming patterns shared across all languages.
* Per-language patterns live on each LanguageProvider.entryPointPatterns.
* Common entry point naming patterns by language.
* These patterns indicate functions that are likely feature entry points.
*
* Universal patterns are separated from per-language patterns so the per-language
* table can use `satisfies Record<SupportedLanguages, RegExp[]>` for compile-time
* exhaustiveness — the compiler catches any missing language entry.
*/
const UNIVERSAL_ENTRY_POINT_PATTERNS: RegExp[] = [
/^(main|init|bootstrap|start|run|setup|configure)$/i,
@@ -37,11 +40,201 @@ const UNIVERSAL_ENTRY_POINT_PATTERNS: RegExp[] = [
/^emit[A-Z]/, // emitEvent
];
/** Pre-computed merged patterns (universal + language-specific) from providers. */
export const ENTRY_POINT_PATTERNS = {
// JavaScript/TypeScript
[SupportedLanguages.JavaScript]: [
/^use[A-Z]/, // React hooks (useEffect, etc.)
],
[SupportedLanguages.TypeScript]: [
/^use[A-Z]/, // React hooks
],
// Python
[SupportedLanguages.Python]: [
/^app$/, // Flask/FastAPI app
/^(get|post|put|delete|patch)_/i, // REST conventions
/^api_/, // API functions
/^view_/, // Django views
],
// Java
[SupportedLanguages.Java]: [
/^do[A-Z]/, // doGet, doPost (Servlets)
/^create[A-Z]/, // Factory patterns
/^build[A-Z]/, // Builder patterns
/Service$/, // UserService
],
// Kotlin
[SupportedLanguages.Kotlin]: [
/^on(Create|Start|Resume|Pause|Stop|Destroy)$/, // Android lifecycle
/^do[A-Z]/, // doGet, doPost (shared JVM Servlet pattern)
/^create[A-Z]/, // Factory patterns
/^build[A-Z]/, // Builder patterns
/ViewModel$/, // MVVM pattern (Android)
/^module$/, // Ktor module entry point
/Service$/, // Service classes
],
// C#
[SupportedLanguages.CSharp]: [
/^(Get|Post|Put|Delete|Patch)/, // ASP.NET action methods
/Action$/, // MVC actions
/^On[A-Z]/, // Event handlers / Blazor lifecycle
/Async$/, // Async entry points
/^Configure$/, // Startup.Configure
/^ConfigureServices$/, // Startup.ConfigureServices
/^Handle$/, // MediatR / generic handler
/^Execute$/, // Command pattern
/^Invoke$/, // Middleware Invoke
/^Map[A-Z]/, // Minimal API MapGet, MapPost
/Service$/, // Service classes
/^Seed/, // Database seeding
],
// Go
[SupportedLanguages.Go]: [
/Handler$/, // http.Handler pattern
/^Serve/, // ServeHTTP
/^New[A-Z]/, // Constructor pattern (returns new instance)
/^Make[A-Z]/, // Make functions
],
// Rust
[SupportedLanguages.Rust]: [
/^(get|post|put|delete)_handler$/i,
/^handle_/, // handle_request
/^new$/, // Constructor pattern
/^run$/, // run entry point
/^spawn/, // Async spawn
],
// C - explicit main() boost plus common C entry point conventions
[SupportedLanguages.C]: [
/^main$/, // THE entry point
/^init_/, // init_server, init_client
/_init$/, // module_init, server_init
/^start_/, // start_server
/_start$/, // thread_start
/^run_/, // run_loop
/_run$/, // event_run
/^stop_/, // stop_server
/_stop$/, // service_stop
/^open_/, // open_connection
/_open$/, // file_open
/^close_/, // close_connection
/_close$/, // socket_close
/^create_/, // create_session
/_create$/, // object_create
/^destroy_/, // destroy_session
/_destroy$/, // object_destroy
/^handle_/, // handle_request
/_handler$/, // signal_handler
/_callback$/, // event_callback
/^cmd_/, // tmux: cmd_new_window, cmd_attach_session
/^server_/, // server_start, server_loop
/^client_/, // client_connect
/^session_/, // session_create
/^window_/, // window_resize (tmux)
/^key_/, // key_press
/^input_/, // input_parse
/^output_/, // output_write
/^notify_/, // notify_client
/^control_/, // control_start
],
// C++ - same as C plus OOP/template patterns
[SupportedLanguages.CPlusPlus]: [
/^main$/, // THE entry point
/^init_/,
/_init$/,
/^Create[A-Z]/, // Factory patterns
/^create_/,
/^Run$/, // Run methods
/^run$/,
/^Start$/, // Start methods
/^start$/,
/^handle_/,
/_handler$/,
/_callback$/,
/^OnEvent/, // Event callbacks
/^on_/,
/::Run$/, // Class::Run
/::Start$/, // Class::Start
/::Init$/, // Class::Init
/::Execute$/, // Class::Execute
],
// Swift / iOS
[SupportedLanguages.Swift]: [
/^viewDidLoad$/, // UIKit lifecycle
/^viewWillAppear$/, // UIKit lifecycle
/^viewDidAppear$/, // UIKit lifecycle
/^viewWillDisappear$/, // UIKit lifecycle
/^viewDidDisappear$/, // UIKit lifecycle
/^application\(/, // AppDelegate methods
/^scene\(/, // SceneDelegate methods
/^body$/, // SwiftUI View.body
/Coordinator$/, // Coordinator pattern
/^sceneDidBecomeActive$/, // SceneDelegate lifecycle
/^sceneWillResignActive$/, // SceneDelegate lifecycle
/^didFinishLaunchingWithOptions$/, // AppDelegate
/ViewController$/, // ViewController classes
/^configure[A-Z]/, // Configuration methods
/^setup[A-Z]/, // Setup methods
/^makeBody$/, // SwiftUI ViewModifier
],
// PHP / Laravel
[SupportedLanguages.PHP]: [
/Controller$/, // UserController (class name convention)
/^handle$/, // Job::handle(), Listener::handle()
/^execute$/, // Command::execute()
/^boot$/, // ServiceProvider::boot()
/^register$/, // ServiceProvider::register()
/^__invoke$/, // Invokable controllers/actions
/^(index|show|store|update|destroy|create|edit)$/, // RESTful resource methods
/^(get|post|put|delete|patch)[A-Z]/, // Explicit HTTP method actions
/^run$/, // Command/Job run()
/^fire$/, // Event fire()
/^dispatch$/, // Dispatchable jobs
/Service$/, // UserService (Service layer)
/Repository$/, // UserRepository (Repository pattern)
/^find$/, // Repository::find()
/^findAll$/, // Repository::findAll()
/^save$/, // Repository::save()
/^delete$/, // Repository::delete()
],
// Ruby
[SupportedLanguages.Ruby]: [
/^call$/, // Service objects (MyService.call)
/^perform$/, // Background jobs (Sidekiq, ActiveJob)
/^execute$/, // Command pattern
],
// Dart / Flutter
[SupportedLanguages.Dart]: [
/^main$/, // App entry
/^build$/, // Widget.build — fundamental Flutter render entry point
/^createState$/, // StatefulWidget.createState
/^initState$/, // State lifecycle initialization
/^dispose$/, // State lifecycle teardown
/^didChangeDependencies$/, // State lifecycle — InheritedWidget changes
/^didUpdateWidget$/, // State lifecycle — widget rebuild with new config
/^runApp$/, // App entry point
/^onEvent$/, // BLoC event handler
/^mapEventToState$/, // Legacy BLoC pattern
],
[SupportedLanguages.Vue]: [], // Vue uses TypeScript queries — entry points handled via TS patterns
[SupportedLanguages.Cobol]: [], // Standalone regex processor — no tree-sitter entry points
} satisfies Record<SupportedLanguages, RegExp[]>;
/** Pre-computed merged patterns (universal + language-specific) to avoid per-call array allocation. */
const MERGED_ENTRY_POINT_PATTERNS = Object.fromEntries(
Object.entries(providers).map(([lang, provider]) => [
Object.values(SupportedLanguages).map((lang) => [
lang,
[...UNIVERSAL_ENTRY_POINT_PATTERNS, ...(provider.entryPointPatterns ?? [])],
[...UNIVERSAL_ENTRY_POINT_PATTERNS, ...(ENTRY_POINT_PATTERNS[lang] ?? [])],
]),
) as Record<SupportedLanguages, RegExp[]>;
@@ -11,8 +11,6 @@
*/
import { SupportedLanguages } from 'gitnexus-shared';
import type { AstFrameworkPatternConfig } from './language-provider.js';
import { providers } from './languages/index.js';
// ============================================================================
// TYPES
@@ -520,14 +518,395 @@ export function detectFrameworkFromPath(filePath: string): FrameworkHint | null
// AST-BASED FRAMEWORK DETECTION
// ============================================================================
/** Pre-lowercased patterns for O(1) pattern matching at runtime — built from providers. */
const AST_PATTERNS_LOWERED: Record<string, AstFrameworkPatternConfig[]> = Object.fromEntries(
Object.entries(providers).map(([lang, provider]) => [
/**
* Patterns that indicate framework entry points within code definitions.
* These are matched against AST node text (class/method/function declaration text).
*/
export const FRAMEWORK_AST_PATTERNS = {
// JavaScript/TypeScript decorators
nestjs: ['@Controller', '@Get', '@Post', '@Put', '@Delete', '@Patch'],
'expo-router': [
'router.push',
'router.replace',
'router.navigate',
'useRouter',
'useLocalSearchParams',
'useSegments',
'expo-router',
],
express: ['app.get', 'app.post', 'app.put', 'app.delete', 'router.get', 'router.post'],
// Python decorators
fastapi: ['@app.get', '@app.post', '@app.put', '@app.delete', '@router.get'],
flask: ['@app.route', '@blueprint.route'],
// Java annotations
spring: ['@RestController', '@Controller', '@GetMapping', '@PostMapping', '@RequestMapping'],
jaxrs: ['@Path', '@GET', '@POST', '@PUT', '@DELETE'],
// C# attributes
aspnet: [
'[ApiController]',
'[HttpGet]',
'[HttpPost]',
'[HttpPut]',
'[HttpDelete]',
'[Route]',
'[Authorize]',
'[AllowAnonymous]',
],
signalr: ['[HubMethodName]', ': Hub', ': Hub<'],
blazor: ['@page', '[Parameter]', '@inject'],
efcore: ['DbContext', 'DbSet<', 'OnModelCreating'],
// Go patterns (function signatures include framework types)
'go-http': [
'http.Handler',
'http.HandlerFunc',
'ServeHTTP',
'http.ResponseWriter',
'http.Request',
],
gin: ['gin.Context', 'gin.Default', 'gin.New'],
echo: ['echo.Context', 'echo.New'],
fiber: ['fiber.Ctx', 'fiber.New', 'fiber.App'],
'go-grpc': ['grpc.Server', 'RegisterServer', 'pb.Unimplemented'],
// ORM patterns
prisma: ['prisma.', 'PrismaClient', '@prisma/client'],
supabase: ['supabase.from', 'createClient', '@supabase/supabase-js'],
// PHP/Laravel
laravel: [
'Route::get',
'Route::post',
'Route::put',
'Route::delete',
'Route::resource',
'Route::apiResource',
'#[Route(',
],
// Rust macros (proc-macro attributes in definition text)
actix: ['#[get', '#[post', '#[put', '#[delete', '#[actix_web', 'HttpRequest', 'HttpResponse'],
axum: ['Router::new', 'axum::extract', 'axum::routing'],
rocket: ['#[get', '#[post', '#[launch', 'rocket::'],
tokio: ['#[tokio::main]', '#[tokio::test]'],
// C++ patterns (Qt, Boost)
qt: [
'Q_OBJECT',
'Q_INVOKABLE',
'Q_PROPERTY',
'Q_SIGNALS',
'Q_SLOTS',
'Q_SIGNAL',
'Q_SLOT',
'QWidget',
'QApplication',
],
// Swift/iOS
uikit: [
'viewDidLoad',
'viewWillAppear',
'viewDidAppear',
'UIViewController',
'@IBOutlet',
'@IBAction',
'@objc',
],
swiftui: [
'@main',
'WindowGroup',
'ContentView',
'@StateObject',
'@ObservedObject',
'@EnvironmentObject',
'@Published',
],
vapor: ['app.get', 'app.post', 'req.content.decode', 'Vapor'],
// Ruby patterns (class-level macros in definition text)
rails: [
'ApplicationController',
'ApplicationRecord',
'ActiveRecord::Base',
'before_action',
'after_action',
'has_many',
'belongs_to',
'has_one',
'validates',
],
sinatra: ['Sinatra::Base', 'Sinatra::Application'],
// Dart/Flutter
flutter: [
'StatelessWidget',
'StatefulWidget',
'BuildContext',
'Widget build',
'ChangeNotifier',
'GetxController',
'Cubit<',
'Bloc<',
'ConsumerWidget',
],
riverpod: ['@riverpod', 'ref.watch', 'ref.read', 'AsyncNotifier', 'Notifier'],
};
interface AstFrameworkPatternConfig {
framework: string;
entryPointMultiplier: number;
reason: string;
patterns: string[];
}
export const AST_FRAMEWORK_PATTERNS_BY_LANGUAGE = {
[SupportedLanguages.JavaScript]: [
{
framework: 'nestjs',
entryPointMultiplier: 3.2,
reason: 'nestjs-decorator',
patterns: FRAMEWORK_AST_PATTERNS.nestjs,
},
{
framework: 'expo-router',
entryPointMultiplier: 2.5,
reason: 'expo-router-navigation',
patterns: FRAMEWORK_AST_PATTERNS['expo-router'],
},
],
[SupportedLanguages.TypeScript]: [
{
framework: 'nestjs',
entryPointMultiplier: 3.2,
reason: 'nestjs-decorator',
patterns: FRAMEWORK_AST_PATTERNS.nestjs,
},
{
framework: 'expo-router',
entryPointMultiplier: 2.5,
reason: 'expo-router-navigation',
patterns: FRAMEWORK_AST_PATTERNS['expo-router'],
},
],
[SupportedLanguages.Python]: [
{
framework: 'fastapi',
entryPointMultiplier: 3.0,
reason: 'fastapi-decorator',
patterns: FRAMEWORK_AST_PATTERNS.fastapi,
},
{
framework: 'flask',
entryPointMultiplier: 2.8,
reason: 'flask-decorator',
patterns: FRAMEWORK_AST_PATTERNS.flask,
},
],
[SupportedLanguages.Java]: [
{
framework: 'spring',
entryPointMultiplier: 3.2,
reason: 'spring-annotation',
patterns: FRAMEWORK_AST_PATTERNS.spring,
},
{
framework: 'jaxrs',
entryPointMultiplier: 3.0,
reason: 'jaxrs-annotation',
patterns: FRAMEWORK_AST_PATTERNS.jaxrs,
},
],
[SupportedLanguages.Kotlin]: [
{
framework: 'spring-kotlin',
entryPointMultiplier: 3.2,
reason: 'spring-kotlin-annotation',
patterns: FRAMEWORK_AST_PATTERNS.spring,
},
{
framework: 'jaxrs',
entryPointMultiplier: 3.0,
reason: 'jaxrs-annotation',
patterns: FRAMEWORK_AST_PATTERNS.jaxrs,
},
{
framework: 'ktor',
entryPointMultiplier: 2.8,
reason: 'ktor-routing',
patterns: ['routing', 'embeddedServer', 'Application.module'],
},
{
framework: 'android-kotlin',
entryPointMultiplier: 2.5,
reason: 'android-annotation',
patterns: ['@AndroidEntryPoint', 'AppCompatActivity', 'Fragment('],
},
],
[SupportedLanguages.CSharp]: [
{
framework: 'aspnet',
entryPointMultiplier: 3.2,
reason: 'aspnet-attribute',
patterns: FRAMEWORK_AST_PATTERNS.aspnet,
},
{
framework: 'signalr',
entryPointMultiplier: 2.8,
reason: 'signalr-attribute',
patterns: FRAMEWORK_AST_PATTERNS.signalr,
},
{
framework: 'blazor',
entryPointMultiplier: 2.5,
reason: 'blazor-attribute',
patterns: FRAMEWORK_AST_PATTERNS.blazor,
},
{
framework: 'efcore',
entryPointMultiplier: 2.0,
reason: 'efcore-pattern',
patterns: FRAMEWORK_AST_PATTERNS.efcore,
},
],
[SupportedLanguages.PHP]: [
{
framework: 'laravel',
entryPointMultiplier: 3.0,
reason: 'php-route-attribute',
patterns: FRAMEWORK_AST_PATTERNS.laravel,
},
],
[SupportedLanguages.Go]: [
{
framework: 'go-http',
entryPointMultiplier: 2.5,
reason: 'go-http-handler',
patterns: FRAMEWORK_AST_PATTERNS['go-http'],
},
{
framework: 'gin',
entryPointMultiplier: 3.0,
reason: 'gin-handler',
patterns: FRAMEWORK_AST_PATTERNS.gin,
},
{
framework: 'echo',
entryPointMultiplier: 3.0,
reason: 'echo-handler',
patterns: FRAMEWORK_AST_PATTERNS.echo,
},
{
framework: 'fiber',
entryPointMultiplier: 3.0,
reason: 'fiber-handler',
patterns: FRAMEWORK_AST_PATTERNS.fiber,
},
{
framework: 'go-grpc',
entryPointMultiplier: 2.8,
reason: 'grpc-service',
patterns: FRAMEWORK_AST_PATTERNS['go-grpc'],
},
],
[SupportedLanguages.Rust]: [
{
framework: 'actix-web',
entryPointMultiplier: 3.0,
reason: 'actix-attribute',
patterns: FRAMEWORK_AST_PATTERNS.actix,
},
{
framework: 'axum',
entryPointMultiplier: 3.0,
reason: 'axum-routing',
patterns: FRAMEWORK_AST_PATTERNS.axum,
},
{
framework: 'rocket',
entryPointMultiplier: 3.0,
reason: 'rocket-attribute',
patterns: FRAMEWORK_AST_PATTERNS.rocket,
},
{
framework: 'tokio',
entryPointMultiplier: 2.5,
reason: 'tokio-runtime',
patterns: FRAMEWORK_AST_PATTERNS.tokio,
},
],
[SupportedLanguages.C]: [], // C has no framework-specific AST patterns (POSIX/socket patterns are in entry-point-scoring)
[SupportedLanguages.CPlusPlus]: [
{
framework: 'qt',
entryPointMultiplier: 2.8,
reason: 'qt-macro',
patterns: FRAMEWORK_AST_PATTERNS.qt,
},
],
[SupportedLanguages.Swift]: [
{
framework: 'uikit',
entryPointMultiplier: 2.5,
reason: 'uikit-lifecycle',
patterns: FRAMEWORK_AST_PATTERNS.uikit,
},
{
framework: 'swiftui',
entryPointMultiplier: 2.8,
reason: 'swiftui-pattern',
patterns: FRAMEWORK_AST_PATTERNS.swiftui,
},
{
framework: 'vapor',
entryPointMultiplier: 3.0,
reason: 'vapor-routing',
patterns: FRAMEWORK_AST_PATTERNS.vapor,
},
],
[SupportedLanguages.Ruby]: [
{
framework: 'rails',
entryPointMultiplier: 3.0,
reason: 'rails-pattern',
patterns: FRAMEWORK_AST_PATTERNS.rails,
},
{
framework: 'sinatra',
entryPointMultiplier: 2.8,
reason: 'sinatra-pattern',
patterns: FRAMEWORK_AST_PATTERNS.sinatra,
},
],
[SupportedLanguages.Dart]: [
{
framework: 'flutter',
entryPointMultiplier: 2.5,
reason: 'flutter-widget',
patterns: FRAMEWORK_AST_PATTERNS.flutter,
},
{
framework: 'riverpod',
entryPointMultiplier: 2.8,
reason: 'riverpod-pattern',
patterns: FRAMEWORK_AST_PATTERNS.riverpod,
},
],
[SupportedLanguages.Vue]: [], // Vue uses TypeScript AST framework detection
[SupportedLanguages.Cobol]: [], // Standalone regex processor — no AST framework patterns
} satisfies Record<SupportedLanguages, AstFrameworkPatternConfig[]>;
/** Pre-lowercased patterns for O(1) pattern matching at runtime */
const AST_PATTERNS_LOWERED: Record<
string,
Array<{ framework: string; entryPointMultiplier: number; reason: string; patterns: string[] }>
> = Object.fromEntries(
Object.entries(AST_FRAMEWORK_PATTERNS_BY_LANGUAGE).map(([lang, cfgs]) => [
lang,
(provider.astFrameworkPatterns ?? []).map((cfg) => ({
...cfg,
patterns: cfg.patterns.map((p) => p.toLowerCase()),
})),
cfgs.map((cfg) => ({ ...cfg, patterns: cfg.patterns.map((p) => p.toLowerCase()) })),
]),
);
@@ -78,14 +78,6 @@ export type ImportSemantics =
| 'namespace'
| 'explicit-reexport';
/** Configuration for AST-based framework detection patterns. */
export interface AstFrameworkPatternConfig {
framework: string;
entryPointMultiplier: number;
reason: string;
patterns: string[];
}
/**
* Everything a language needs to provide.
* Required fields must be explicitly set; optional fields have defaults
@@ -97,16 +89,6 @@ interface LanguageProviderConfig {
/** File extensions that map to this language (e.g., ['.ts', '.tsx']) */
readonly extensions: readonly string[];
/** Entry-point function name patterns specific to this language.
* Merged with universal patterns at runtime for process detection scoring.
* Default: [] (only universal patterns apply). */
readonly entryPointPatterns?: readonly RegExp[];
/** AST-based framework detection patterns for this language.
* Used by detectFrameworkFromAST to identify framework entry points.
* Default: [] (no AST framework detection for this language). */
readonly astFrameworkPatterns?: readonly AstFrameworkPatternConfig[];
// ── Parser ────────────────────────────────────────────────────────
/** Parse strategy: 'tree-sitter' (default) uses AST parsing via tree-sitter.
* 'standalone' means the language has its own regex-based processor and
@@ -12,7 +12,6 @@ import { SupportedLanguages } from 'gitnexus-shared';
import { createClassExtractor } from '../class-extractors/generic.js';
import { cClassConfig, cppClassConfig } from '../class-extractors/configs/c-cpp.js';
import { defineLanguage } from '../language-provider.js';
import type { AstFrameworkPatternConfig } from '../language-provider.js';
import { typeConfig as cCppConfig } from '../type-extractors/c-cpp.js';
import { cCppExportChecker } from '../export-detection.js';
import { createImportResolver } from '../import-resolvers/resolver-factory.js';
@@ -318,38 +317,6 @@ const cppLabelOverride: NonNullable<LanguageProvider['labelOverride']> = (
export const cProvider = defineLanguage({
id: SupportedLanguages.C,
extensions: ['.c'],
entryPointPatterns: [
/^main$/,
/^init_/,
/_init$/,
/^start_/,
/_start$/,
/^run_/,
/_run$/,
/^stop_/,
/_stop$/,
/^open_/,
/_open$/,
/^close_/,
/_close$/,
/^create_/,
/_create$/,
/^destroy_/,
/_destroy$/,
/^handle_/,
/_handler$/,
/_callback$/,
/^cmd_/,
/^server_/,
/^client_/,
/^session_/,
/^window_/,
/^key_/,
/^input_/,
/^output_/,
/^notify_/,
/^control_/,
],
treeSitterQueries: C_QUERIES,
typeConfig: cCppConfig,
exportChecker: cCppExportChecker,
@@ -371,44 +338,6 @@ export const cProvider = defineLanguage({
export const cppProvider = defineLanguage({
id: SupportedLanguages.CPlusPlus,
extensions: ['.cpp', '.cc', '.cxx', '.h', '.hpp', '.hxx', '.hh'],
entryPointPatterns: [
/^main$/,
/^init_/,
/_init$/,
/^Create[A-Z]/,
/^create_/,
/^Run$/,
/^run$/,
/^Start$/,
/^start$/,
/^handle_/,
/_handler$/,
/_callback$/,
/^OnEvent/,
/^on_/,
/::Run$/,
/::Start$/,
/::Init$/,
/::Execute$/,
],
astFrameworkPatterns: [
{
framework: 'qt',
entryPointMultiplier: 2.8,
reason: 'qt-macro',
patterns: [
'Q_OBJECT',
'Q_INVOKABLE',
'Q_PROPERTY',
'Q_SIGNALS',
'Q_SLOTS',
'Q_SIGNAL',
'Q_SLOT',
'QWidget',
'QApplication',
],
},
] satisfies AstFrameworkPatternConfig[],
treeSitterQueries: CPP_QUERIES,
typeConfig: cCppConfig,
exportChecker: cCppExportChecker,
@@ -16,8 +16,6 @@ export const cobolProvider = defineLanguage({
id: SupportedLanguages.Cobol,
parseStrategy: 'standalone',
extensions: [], // COBOL files detected by cobol-processor's isCobolFile/isJclFile
entryPointPatterns: [],
astFrameworkPatterns: [],
treeSitterQueries: '',
typeConfig: {
declarationNodeTypes: new Set(),
@@ -16,7 +16,6 @@ import { createImportResolver } from '../import-resolvers/resolver-factory.js';
import { csharpImportConfig } from '../import-resolvers/configs/csharp.js';
import { extractCSharpNamedBindings } from '../named-bindings/csharp.js';
import { CSHARP_QUERIES } from '../tree-sitter-queries.js';
import type { AstFrameworkPatternConfig } from '../language-provider.js';
import { createCallExtractor } from '../call-extractors/generic.js';
import { csharpCallConfig } from '../call-extractors/configs/csharp.js';
import { createFieldExtractor } from '../field-extractors/generic.js';
@@ -136,55 +135,6 @@ const BUILT_INS: ReadonlySet<string> = new Set([
export const csharpProvider = defineLanguage({
id: SupportedLanguages.CSharp,
extensions: ['.cs'],
entryPointPatterns: [
/^(Get|Post|Put|Delete|Patch)/,
/Action$/,
/^On[A-Z]/,
/Async$/,
/^Configure$/,
/^ConfigureServices$/,
/^Handle$/,
/^Execute$/,
/^Invoke$/,
/^Map[A-Z]/,
/Service$/,
/^Seed/,
],
astFrameworkPatterns: [
{
framework: 'aspnet',
entryPointMultiplier: 3.2,
reason: 'aspnet-attribute',
patterns: [
'[ApiController]',
'[HttpGet]',
'[HttpPost]',
'[HttpPut]',
'[HttpDelete]',
'[Route]',
'[Authorize]',
'[AllowAnonymous]',
],
},
{
framework: 'signalr',
entryPointMultiplier: 2.8,
reason: 'signalr-attribute',
patterns: ['[HubMethodName]', ': Hub', ': Hub<'],
},
{
framework: 'blazor',
entryPointMultiplier: 2.5,
reason: 'blazor-attribute',
patterns: ['@page', '[Parameter]', '@inject'],
},
{
framework: 'efcore',
entryPointMultiplier: 2.0,
reason: 'efcore-pattern',
patterns: ['DbContext', 'DbSet<', 'OnModelCreating'],
},
] satisfies AstFrameworkPatternConfig[],
treeSitterQueries: CSHARP_QUERIES,
typeConfig: csharpConfig,
exportChecker: csharpExportChecker,
@@ -17,7 +17,6 @@ import { SupportedLanguages } from 'gitnexus-shared';
import { createClassExtractor } from '../class-extractors/generic.js';
import { dartClassConfig } from '../class-extractors/configs/dart.js';
import { defineLanguage } from '../language-provider.js';
import type { AstFrameworkPatternConfig } from '../language-provider.js';
import { typeConfig as dartConfig } from '../type-extractors/dart.js';
import { dartExportChecker } from '../export-detection.js';
import { createImportResolver } from '../import-resolvers/resolver-factory.js';
@@ -94,42 +93,6 @@ const BUILT_INS: ReadonlySet<string> = new Set([
export const dartProvider = defineLanguage({
id: SupportedLanguages.Dart,
extensions: ['.dart'],
entryPointPatterns: [
/^main$/,
/^build$/,
/^createState$/,
/^initState$/,
/^dispose$/,
/^didChangeDependencies$/,
/^didUpdateWidget$/,
/^runApp$/,
/^onEvent$/,
/^mapEventToState$/,
],
astFrameworkPatterns: [
{
framework: 'flutter',
entryPointMultiplier: 2.5,
reason: 'flutter-widget',
patterns: [
'StatelessWidget',
'StatefulWidget',
'BuildContext',
'Widget build',
'ChangeNotifier',
'GetxController',
'Cubit<',
'Bloc<',
'ConsumerWidget',
],
},
{
framework: 'riverpod',
entryPointMultiplier: 2.8,
reason: 'riverpod-pattern',
patterns: ['@riverpod', 'ref.watch', 'ref.read', 'AsyncNotifier', 'Notifier'],
},
] satisfies AstFrameworkPatternConfig[],
treeSitterQueries: DART_QUERIES,
typeConfig: dartConfig,
exportChecker: dartExportChecker,
@@ -18,7 +18,6 @@ import { goExportChecker } from '../export-detection.js';
import { createImportResolver } from '../import-resolvers/resolver-factory.js';
import { goImportConfig } from '../import-resolvers/configs/go.js';
import { GO_QUERIES } from '../tree-sitter-queries.js';
import type { AstFrameworkPatternConfig } from '../language-provider.js';
import { createFieldExtractor } from '../field-extractors/generic.js';
import { goConfig as goFieldConfig } from '../field-extractors/configs/go.js';
import { createMethodExtractor } from '../method-extractors/generic.js';
@@ -29,58 +28,10 @@ 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,
extensions: ['.go'],
entryPointPatterns: [/Handler$/, /^Serve/, /^New[A-Z]/, /^Make[A-Z]/],
astFrameworkPatterns: [
{
framework: 'go-http',
entryPointMultiplier: 2.5,
reason: 'go-http-handler',
patterns: [
'http.Handler',
'http.HandlerFunc',
'ServeHTTP',
'http.ResponseWriter',
'http.Request',
],
},
{
framework: 'gin',
entryPointMultiplier: 3.0,
reason: 'gin-handler',
patterns: ['gin.Context', 'gin.Default', 'gin.New'],
},
{
framework: 'echo',
entryPointMultiplier: 3.0,
reason: 'echo-handler',
patterns: ['echo.Context', 'echo.New'],
},
{
framework: 'fiber',
entryPointMultiplier: 3.0,
reason: 'fiber-handler',
patterns: ['fiber.Ctx', 'fiber.New', 'fiber.App'],
},
{
framework: 'go-grpc',
entryPointMultiplier: 2.8,
reason: 'grpc-service',
patterns: ['grpc.Server', 'RegisterServer', 'pb.Unimplemented'],
},
] satisfies AstFrameworkPatternConfig[],
treeSitterQueries: GO_QUERIES,
typeConfig: goConfig,
exportChecker: goExportChecker,
@@ -92,15 +43,4 @@ 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.
});
@@ -1,46 +0,0 @@
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;
}
@@ -1,16 +0,0 @@
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';
}
@@ -1,18 +0,0 @@
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;
}
@@ -1,168 +0,0 @@
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<ReturnType<typeof getGoParser>['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<string, Capture> = {};
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<string, Capture>,
): 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
);
}
@@ -1,99 +0,0 @@
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<ScopeId, Map<string, BindingRef[]>>;
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<string, BindingRef[]>();
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 ?? '';
}
@@ -1,51 +0,0 @@
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!) } : {}),
},
];
}
@@ -1,83 +0,0 @@
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<string>,
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>): 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<string>, 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<string>;
readonly goModule?: GoModuleConfig;
}
@@ -1,17 +0,0 @@
/**
* 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';
@@ -1,87 +0,0 @@
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<string, string[]> {
// 1. Collect interface defs → method names (from scope.ownedDefs)
const interfaceMethods = new Map<string, Set<string>>();
const interfaceDefsById = new Map<string, SymbolDefinition>();
// 2. Collect struct defs → method names
const structMethods = new Map<string, Set<string>>();
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<string>();
// 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<string>();
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<string, string[]>();
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<string>, subset: Set<string>): boolean {
for (const item of subset) {
if (!superset.has(item)) return false;
}
return true;
}
@@ -1,124 +0,0 @@
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;
}
@@ -1,27 +0,0 @@
import type { BindingRef } from 'gitnexus-shared';
const TIER: Record<BindingRef['origin'], number> = {
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<string>();
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;
});
}
@@ -1,108 +0,0 @@
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<string, string> },
): void {
const filesByPackage = new Map<string, ParsedFile[]>();
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<string, string>(); // 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);
}
@@ -1,59 +0,0 @@
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<string, string[]>();
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<string, TypeRef>).set(name, terminal);
}
}
}
}
}
@@ -1,101 +0,0 @@
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<string, string> },
): 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<string, string>();
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<string, { filePath: string; defs: SymbolDefinition[] }[]>();
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<ScopeId, Map<string, BindingRef[]>>;
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, Map<string, BindingRef[]>>,
scopeId: ScopeId,
name: string,
): BindingRef[] {
let scopeBindings = augmentations.get(scopeId);
if (scopeBindings === undefined) {
scopeBindings = new Map<string, BindingRef[]>();
augmentations.set(scopeId, scopeBindings);
}
let bucketArr = scopeBindings.get(name);
if (bucketArr === undefined) {
bucketArr = [];
scopeBindings.set(name, bucketArr);
}
return bucketArr;
}
@@ -1,211 +0,0 @@
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<Parser['setLanguage']>[0]);
}
return _parser;
}
export function getGoScopeQuery(): Parser.Query {
if (_query === null) {
_query = new Parser.Query(Go as Parameters<Parser['setLanguage']>[0], GO_SCOPE_QUERY);
}
return _query;
}
@@ -1,133 +0,0 @@
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<string, string>;
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<typeof parser.parse> | 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<string, TypeRef>;
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<string, Scope>,
): 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;
}
@@ -1,21 +0,0 @@
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),
};
}
@@ -1,55 +0,0 @@
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,
};
@@ -1,38 +0,0 @@
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;
}
@@ -1,285 +0,0 @@
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<string, string>();
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;
}
@@ -11,7 +11,6 @@ import { SupportedLanguages } from 'gitnexus-shared';
import { createClassExtractor } from '../class-extractors/generic.js';
import { javaClassConfig } from '../class-extractors/configs/jvm.js';
import { defineLanguage } from '../language-provider.js';
import type { AstFrameworkPatternConfig } from '../language-provider.js';
import { javaTypeConfig } from '../type-extractors/jvm.js';
import { javaExportChecker } from '../export-detection.js';
import { createImportResolver } from '../import-resolvers/resolver-factory.js';
@@ -31,27 +30,6 @@ import { createHeritageExtractor } from '../heritage-extractors/generic.js';
export const javaProvider = defineLanguage({
id: SupportedLanguages.Java,
extensions: ['.java'],
entryPointPatterns: [/^do[A-Z]/, /^create[A-Z]/, /^build[A-Z]/, /Service$/],
astFrameworkPatterns: [
{
framework: 'spring',
entryPointMultiplier: 3.2,
reason: 'spring-annotation',
patterns: [
'@RestController',
'@Controller',
'@GetMapping',
'@PostMapping',
'@RequestMapping',
],
},
{
framework: 'jaxrs',
entryPointMultiplier: 3.0,
reason: 'jaxrs-annotation',
patterns: ['@Path', '@GET', '@POST', '@PUT', '@DELETE'],
},
] satisfies AstFrameworkPatternConfig[],
treeSitterQueries: JAVA_QUERIES,
typeConfig: javaTypeConfig,
exportChecker: javaExportChecker,
@@ -18,7 +18,6 @@ import { kotlinImportConfig } from '../import-resolvers/configs/jvm.js';
import { extractKotlinNamedBindings } from '../named-bindings/kotlin.js';
import { appendKotlinWildcard } from '../import-resolvers/jvm.js';
import { KOTLIN_QUERIES } from '../tree-sitter-queries.js';
import type { AstFrameworkPatternConfig } from '../language-provider.js';
import type { SyntaxNode } from '../utils/ast-helpers.js';
import { createCallExtractor } from '../call-extractors/generic.js';
import { kotlinCallConfig } from '../call-extractors/configs/jvm.js';
@@ -106,47 +105,6 @@ const BUILT_INS: ReadonlySet<string> = new Set([
export const kotlinProvider = defineLanguage({
id: SupportedLanguages.Kotlin,
extensions: ['.kt', '.kts'],
entryPointPatterns: [
/^on(Create|Start|Resume|Pause|Stop|Destroy)$/,
/^do[A-Z]/,
/^create[A-Z]/,
/^build[A-Z]/,
/ViewModel$/,
/^module$/,
/Service$/,
],
astFrameworkPatterns: [
{
framework: 'spring-kotlin',
entryPointMultiplier: 3.2,
reason: 'spring-kotlin-annotation',
patterns: [
'@RestController',
'@Controller',
'@GetMapping',
'@PostMapping',
'@RequestMapping',
],
},
{
framework: 'jaxrs',
entryPointMultiplier: 3.0,
reason: 'jaxrs-annotation',
patterns: ['@Path', '@GET', '@POST', '@PUT', '@DELETE'],
},
{
framework: 'ktor',
entryPointMultiplier: 2.8,
reason: 'ktor-routing',
patterns: ['routing', 'embeddedServer', 'Application.module'],
},
{
framework: 'android-kotlin',
entryPointMultiplier: 2.5,
reason: 'android-annotation',
patterns: ['@AndroidEntryPoint', 'AppCompatActivity', 'Fragment('],
},
] satisfies AstFrameworkPatternConfig[],
treeSitterQueries: KOTLIN_QUERIES,
typeConfig: kotlinTypeConfig,
exportChecker: kotlinExportChecker,
@@ -10,7 +10,6 @@ import { SupportedLanguages } from 'gitnexus-shared';
import { createClassExtractor } from '../class-extractors/generic.js';
import { phpClassConfig } from '../class-extractors/configs/php.js';
import { defineLanguage } from '../language-provider.js';
import type { AstFrameworkPatternConfig } from '../language-provider.js';
import { typeConfig as phpConfig } from '../type-extractors/php.js';
import { phpExportChecker } from '../export-detection.js';
import { createImportResolver } from '../import-resolvers/resolver-factory.js';
@@ -240,41 +239,6 @@ function isPhpRouteFile(filePath: string): boolean {
export const phpProvider = defineLanguage({
id: SupportedLanguages.PHP,
extensions: ['.php', '.phtml', '.php3', '.php4', '.php5', '.php8'],
entryPointPatterns: [
/Controller$/,
/^handle$/,
/^execute$/,
/^boot$/,
/^register$/,
/^__invoke$/,
/^(index|show|store|update|destroy|create|edit)$/,
/^(get|post|put|delete|patch)[A-Z]/,
/^run$/,
/^fire$/,
/^dispatch$/,
/Service$/,
/Repository$/,
/^find$/,
/^findAll$/,
/^save$/,
/^delete$/,
],
astFrameworkPatterns: [
{
framework: 'laravel',
entryPointMultiplier: 3.0,
reason: 'php-route-attribute',
patterns: [
'Route::get',
'Route::post',
'Route::put',
'Route::delete',
'Route::resource',
'Route::apiResource',
'#[Route(',
],
},
] satisfies AstFrameworkPatternConfig[],
treeSitterQueries: PHP_QUERIES,
typeConfig: phpConfig,
exportChecker: phpExportChecker,
@@ -15,7 +15,6 @@ import { SupportedLanguages } from 'gitnexus-shared';
import { createClassExtractor } from '../class-extractors/generic.js';
import { pythonClassConfig } from '../class-extractors/configs/python.js';
import { defineLanguage } from '../language-provider.js';
import type { AstFrameworkPatternConfig } from '../language-provider.js';
import { typeConfig as pythonConfig } from '../type-extractors/python.js';
import { pythonExportChecker } from '../export-detection.js';
import { createImportResolver } from '../import-resolvers/resolver-factory.js';
@@ -107,21 +106,6 @@ function normalizePythonStringLiteral(text: string): string | undefined {
export const pythonProvider = defineLanguage({
id: SupportedLanguages.Python,
extensions: ['.py'],
entryPointPatterns: [/^app$/, /^(get|post|put|delete|patch)_/i, /^api_/, /^view_/],
astFrameworkPatterns: [
{
framework: 'fastapi',
entryPointMultiplier: 3.0,
reason: 'fastapi-decorator',
patterns: ['@app.get', '@app.post', '@app.put', '@app.delete', '@router.get'],
},
{
framework: 'flask',
entryPointMultiplier: 2.8,
reason: 'flask-decorator',
patterns: ['@app.route', '@blueprint.route'],
},
] satisfies AstFrameworkPatternConfig[],
treeSitterQueries: PYTHON_QUERIES,
typeConfig: pythonConfig,
exportChecker: pythonExportChecker,
@@ -12,7 +12,6 @@ import type { NodeLabel } from 'gitnexus-shared';
import { createClassExtractor } from '../class-extractors/generic.js';
import { rubyClassConfig } from '../class-extractors/configs/ruby.js';
import { defineLanguage } from '../language-provider.js';
import type { AstFrameworkPatternConfig } from '../language-provider.js';
import type { SyntaxNode } from '../utils/ast-helpers.js';
import { typeConfig as rubyConfig } from '../type-extractors/ruby.js';
import { routeRubyCall } from '../call-routing.js';
@@ -152,31 +151,6 @@ const rubyResolveEnclosingOwner = (node: SyntaxNode): SyntaxNode | null => {
export const rubyProvider = defineLanguage({
id: SupportedLanguages.Ruby,
extensions: ['.rb', '.rake', '.gemspec'],
entryPointPatterns: [/^call$/, /^perform$/, /^execute$/],
astFrameworkPatterns: [
{
framework: 'rails',
entryPointMultiplier: 3.0,
reason: 'rails-pattern',
patterns: [
'ApplicationController',
'ApplicationRecord',
'ActiveRecord::Base',
'before_action',
'after_action',
'has_many',
'belongs_to',
'has_one',
'validates',
],
},
{
framework: 'sinatra',
entryPointMultiplier: 2.8,
reason: 'sinatra-pattern',
patterns: ['Sinatra::Base', 'Sinatra::Application'],
},
] satisfies AstFrameworkPatternConfig[],
treeSitterQueries: RUBY_QUERIES,
typeConfig: rubyConfig,
exportChecker: rubyExportChecker,
@@ -22,7 +22,6 @@ import { createImportResolver } from '../import-resolvers/resolver-factory.js';
import { rustImportConfig } from '../import-resolvers/configs/rust.js';
import { extractRustNamedBindings } from '../named-bindings/rust.js';
import { RUST_QUERIES } from '../tree-sitter-queries.js';
import type { AstFrameworkPatternConfig } from '../language-provider.js';
import { createFieldExtractor } from '../field-extractors/generic.js';
import { rustConfig as rustFieldConfig } from '../field-extractors/configs/rust.js';
import { createMethodExtractor } from '../method-extractors/generic.js';
@@ -122,41 +121,6 @@ const BUILT_INS: ReadonlySet<string> = new Set([
export const rustProvider = defineLanguage({
id: SupportedLanguages.Rust,
extensions: ['.rs'],
entryPointPatterns: [/^(get|post|put|delete)_handler$/i, /^handle_/, /^new$/, /^run$/, /^spawn/],
astFrameworkPatterns: [
{
framework: 'actix-web',
entryPointMultiplier: 3.0,
reason: 'actix-attribute',
patterns: [
'#[get',
'#[post',
'#[put',
'#[delete',
'#[actix_web',
'HttpRequest',
'HttpResponse',
],
},
{
framework: 'axum',
entryPointMultiplier: 3.0,
reason: 'axum-routing',
patterns: ['Router::new', 'axum::extract', 'axum::routing'],
},
{
framework: 'rocket',
entryPointMultiplier: 3.0,
reason: 'rocket-attribute',
patterns: ['#[get', '#[post', '#[launch', 'rocket::'],
},
{
framework: 'tokio',
entryPointMultiplier: 2.5,
reason: 'tokio-runtime',
patterns: ['#[tokio::main]', '#[tokio::test]'],
},
] satisfies AstFrameworkPatternConfig[],
treeSitterQueries: RUST_QUERIES,
typeConfig: rustConfig,
exportChecker: rustExportChecker,
@@ -15,7 +15,6 @@ import type { NodeLabel, SymbolDefinition } from 'gitnexus-shared';
import { createClassExtractor } from '../class-extractors/generic.js';
import { swiftClassConfig } from '../class-extractors/configs/swift.js';
import { defineLanguage } from '../language-provider.js';
import type { AstFrameworkPatternConfig } from '../language-provider.js';
import { typeConfig as swiftConfig } from '../type-extractors/swift.js';
import { swiftExportChecker } from '../export-detection.js';
import { createImportResolver } from '../import-resolvers/resolver-factory.js';
@@ -260,60 +259,6 @@ const BUILT_INS: ReadonlySet<string> = new Set([
export const swiftProvider = defineLanguage({
id: SupportedLanguages.Swift,
extensions: ['.swift'],
entryPointPatterns: [
/^viewDidLoad$/,
/^viewWillAppear$/,
/^viewDidAppear$/,
/^viewWillDisappear$/,
/^viewDidDisappear$/,
/^application\(/,
/^scene\(/,
/^body$/,
/Coordinator$/,
/^sceneDidBecomeActive$/,
/^sceneWillResignActive$/,
/^didFinishLaunchingWithOptions$/,
/ViewController$/,
/^configure[A-Z]/,
/^setup[A-Z]/,
/^makeBody$/,
],
astFrameworkPatterns: [
{
framework: 'uikit',
entryPointMultiplier: 2.5,
reason: 'uikit-lifecycle',
patterns: [
'viewDidLoad',
'viewWillAppear',
'viewDidAppear',
'UIViewController',
'@IBOutlet',
'@IBAction',
'@objc',
],
},
{
framework: 'swiftui',
entryPointMultiplier: 2.8,
reason: 'swiftui-pattern',
patterns: [
'@main',
'WindowGroup',
'ContentView',
'@StateObject',
'@ObservedObject',
'@EnvironmentObject',
'@Published',
],
},
{
framework: 'vapor',
entryPointMultiplier: 3.0,
reason: 'vapor-routing',
patterns: ['app.get', 'app.post', 'req.content.decode', 'Vapor'],
},
] satisfies AstFrameworkPatternConfig[],
treeSitterQueries: SWIFT_QUERIES,
typeConfig: swiftConfig,
exportChecker: swiftExportChecker,
@@ -10,7 +10,6 @@
import { SupportedLanguages } from 'gitnexus-shared';
import type { NodeLabel } from 'gitnexus-shared';
import { defineLanguage } from '../language-provider.js';
import type { AstFrameworkPatternConfig } from '../language-provider.js';
import { createClassExtractor } from '../class-extractors/generic.js';
import {
typescriptClassConfig,
@@ -67,21 +66,11 @@ import {
* - `{ addItem: (item) => ... }` (pair / property_assignment) → "addItem"
* Covers Zustand stores, TanStack Query factories, React Context
* providers, and most other HOF-heavy idioms (issue #1166).
* - `const X = HOC((args) => { ... })` (arguments → call_expression →
* variable_declarator) → "X". Covers `React.forwardRef`, `memo`,
* `useCallback`, `useMemo`, `observer`, `debounce`, and other HOC
* factories that wrap their behaviour-defining arrow. Without this
* branch, every shadcn/Radix UI component (`const Button =
* React.forwardRef(...)`) registered as an anonymous arrow with
* calls inside falling back to File-level attribution. The same
* applied to all `useCallback` / `useMemo` callbacks bound to a
* const — the sole way to give them a named caller anchor.
*
* Returns `null` for funcName when the arrow lives in a context that has
* no static name — bare call arguments (not bound to a const), computed
* keys, return-from-arrow positions. The parent walk in
* findEnclosingFunctionId then continues up to the next named ancestor
* (or to the file).
* no static name — call arguments, computed keys, return-from-arrow
* positions. The parent walk in findEnclosingFunctionId then continues
* up to the next named ancestor (or to the file).
*/
const tsExtractFunctionName = (
node: SyntaxNode,
@@ -125,37 +114,6 @@ const tsExtractFunctionName = (
return { funcName: null, label: 'Function' };
}
// HOC-wrapped variable declarations: `const Button = forwardRef((p, r) => { ... })`,
// `const handleClick = useCallback(() => doStuff(), [deps])`,
// `const Card = React.memo((props) => { ... })`. The arrow's `parent` is
// `arguments`, grandparent is `call_expression`, great-grandparent is
// `variable_declarator`. Walk the chain up and take the variable's name
// — the meaningful identifier the developer wrote on the LHS. Mirrors
// the four registry-primary patterns in `typescript/query.ts`. The
// wrapping callee (`forwardRef`, `memo`, `React.memo`, `useCallback`,
// user-defined HOCs) is intentionally NOT constrained: any function
// call whose result is bound to a const and whose first/positional
// argument is an arrow takes the const's name. Chained array-method
// calls (`const x = arr.find((y) => p(y))`) match too and produce a
// mostly-harmless `Function:x` (consumed as a value, never invoked),
// accepted as a small false-positive cost vs. the much larger gain of
// capturing the React UI-component idiom.
if (parent.type === 'arguments') {
const callExpr = parent.parent;
if (!callExpr || callExpr.type !== 'call_expression') {
return { funcName: null, label: 'Function' };
}
const declarator = callExpr.parent;
if (!declarator || declarator.type !== 'variable_declarator') {
return { funcName: null, label: 'Function' };
}
const nameNode = declarator.childForFieldName?.('name');
if (nameNode?.type === 'identifier') {
return { funcName: nameNode.text, label: 'Function' };
}
return { funcName: null, label: 'Function' };
}
return { funcName: null, label: 'Function' };
};
@@ -259,29 +217,6 @@ export const BUILT_INS: ReadonlySet<string> = new Set([
export const typescriptProvider = defineLanguage({
id: SupportedLanguages.TypeScript,
extensions: ['.ts', '.tsx'],
entryPointPatterns: [/^use[A-Z]/],
astFrameworkPatterns: [
{
framework: 'nestjs',
entryPointMultiplier: 3.2,
reason: 'nestjs-decorator',
patterns: ['@Controller', '@Get', '@Post', '@Put', '@Delete', '@Patch'],
},
{
framework: 'expo-router',
entryPointMultiplier: 2.5,
reason: 'expo-router-navigation',
patterns: [
'router.push',
'router.replace',
'router.navigate',
'useRouter',
'useLocalSearchParams',
'useSegments',
'expo-router',
],
},
] satisfies AstFrameworkPatternConfig[],
treeSitterQueries: TYPESCRIPT_QUERIES,
typeConfig: typescriptConfig,
exportChecker: tsExportChecker,
@@ -321,29 +256,6 @@ export const typescriptProvider = defineLanguage({
export const javascriptProvider = defineLanguage({
id: SupportedLanguages.JavaScript,
extensions: ['.js', '.jsx'],
entryPointPatterns: [/^use[A-Z]/],
astFrameworkPatterns: [
{
framework: 'nestjs',
entryPointMultiplier: 3.2,
reason: 'nestjs-decorator',
patterns: ['@Controller', '@Get', '@Post', '@Put', '@Delete', '@Patch'],
},
{
framework: 'expo-router',
entryPointMultiplier: 2.5,
reason: 'expo-router-navigation',
patterns: [
'router.push',
'router.replace',
'router.navigate',
'useRouter',
'useLocalSearchParams',
'useSegments',
'expo-router',
],
},
] satisfies AstFrameworkPatternConfig[],
treeSitterQueries: JAVASCRIPT_QUERIES,
typeConfig: typescriptConfig,
exportChecker: tsExportChecker,
@@ -214,95 +214,6 @@ const TYPESCRIPT_SCOPE_QUERY = `
key: (string (string_fragment) @declaration.name)
value: (function_expression) @declaration.function)
;; HOC-wrapped variable declarations: \`const X = HOC((args) => { ... })\`.
;;
;; Covers the dominant React UI idiom (\`React.forwardRef\`, \`React.memo\`,
;; bare \`forwardRef\` / \`memo\` / \`observer\`), Hook callbacks
;; (\`useCallback\`, \`useMemo\`), and library-wrapper factories (\`debounce\`,
;; \`throttle\`, user-defined \`withErrorBoundary\` / \`createHook\`, etc.).
;; All produce the same AST shape:
;;
;; lexical_declaration
;; variable_declarator
;; name: identifier "X" ← we want this name
;; value: call_expression
;; function: identifier | member_expression ← any callee
;; arguments: arguments
;; arrow_function | function_expression ← the actual code
;;
;; The pre-fix \`tsExtractFunctionName\` only handled \`variable_declarator\`
;; and \`pair\` parents, so HOC-wrapped arrows fell through anonymous. The
;; registry-primary \`query.ts\` had no pattern for this shape either —
;; \`const Button = forwardRef((p, r) => { ... })\` registered as a
;; \`Variable\` with no \`Function\` def, and every call inside the arrow
;; body lost caller attribution: \`resolveCallerGraphId\` walked up past
;; the empty arrow scope to the module's File fallback. Sourcerer-fe alone
;; has ~296 such declarations (57 forwardRef + 21 memo + 161 useCallback
;; + 57 useMemo) — all invisible to \`gitnexus_context\` /
;; \`gitnexus_impact\` for outgoing edges before this fix.
;;
;; Anchor discipline: same as the \`lexical_declaration\` / \`pair\` blocks
;; above — on the INNER \`arrow_function\` / \`function_expression\`, NOT
;; the outer \`call_expression\`. The arrow's range matches its own
;; \`@scope.function\` range, so \`pass2AttachDeclarations.atPosition\`
;; resolves \`innermost\` to the arrow's own scope and
;; \`rangesEqual(anchor.range, innermost.range)\` triggers the auto-hoist
;; that promotes the binding to the parent scope (where \`const X\`
;; lives).
;;
;; Trade-off — chained array-method form: \`const x = arr.find((y) => p(y))\`
;; has the same syntactic shape and would also match, naming the
;; \`.find\` callback as \`x\`. The resulting \`Function:x\` is mostly
;; harmless: \`x\` is consumed as a value (\`if (x) { ... }\`), never
;; invoked as a function, so it gets zero incoming \`CALLS\` edges. The
;; one outgoing edge \`Function:x → p\` is a minor mis-attribution that
;; could in principle be fixed by adding a \`function: [(identifier)
;; (member_expression)]\` predicate that excludes property-identifiers
;; matching a known array-method blocklist (\`map\` / \`filter\` / \`find\`
;; / \`reduce\` / \`forEach\` / \`some\` / \`every\`). We don't do that here
;; because (a) the false-positive cost is negligible, (b) the blocklist
;; would need maintenance, and (c) any user-defined fluent-API method
;; with a callback argument would still false-positive — there's no
;; clean syntactic line.
;;
;; Trade-off — multi-arrow arguments: \`const x = call(arrow1, arrow2)\`
;; would emit TWO matches with the same name \`x\`. tree-sitter-query
;; iterates all arrow_function direct children of \`arguments\`, so each
;; emits its own \`(name=x, function=...)\` pair. \`pass2AttachDeclarations\`
;; pushes both \`Function:x\` defs into the same arrow scopes (each in
;; its own arrow's \`ownedDefs\`) and hoists both bindings to the parent.
;; The downstream registry's qualified-name dedup then collapses them
;; via \`(filePath, type, qualifiedName)\` — second wins. Acceptable;
;; multi-arrow-callback APIs are rare (\`new Promise(executor)\` is the
;; main one and takes a single executor).
(lexical_declaration
(variable_declarator
name: (identifier) @declaration.name
value: (call_expression
arguments: (arguments
(arrow_function) @declaration.function))))
(lexical_declaration
(variable_declarator
name: (identifier) @declaration.name
value: (call_expression
arguments: (arguments
(function_expression) @declaration.function))))
(variable_declaration
(variable_declarator
name: (identifier) @declaration.name
value: (call_expression
arguments: (arguments
(arrow_function) @declaration.function))))
(variable_declaration
(variable_declarator
name: (identifier) @declaration.name
value: (call_expression
arguments: (arguments
(function_expression) @declaration.function))))
;; Method definitions — regular + private (#field) methods.
(method_definition
name: (property_identifier) @declaration.name) @declaration.method
@@ -68,8 +68,6 @@ const vueClassExtractor = createClassExtractor(vueClassConfig);
export const vueProvider = defineLanguage({
id: SupportedLanguages.Vue,
extensions: ['.vue'],
entryPointPatterns: [],
astFrameworkPatterns: [],
treeSitterQueries: TYPESCRIPT_QUERIES,
typeConfig: typescriptConfig,
exportChecker: tsExportChecker,
+13 -30
View File
@@ -157,27 +157,19 @@ 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<string, number>();
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 (remaining > 0) {
while (sequences.some((s) => s.length > 0)) {
// Find a good head: one that doesn't appear in the tail of any other sequence
let head: string | null = null;
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) {
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) {
head = candidate;
break;
}
@@ -190,19 +182,10 @@ export function c3Linearize(
result.push(head);
// 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);
}
// Remove the chosen head from all sequences
for (const seq of sequences) {
if (seq.length > 0 && seq[0] === head) {
seq.shift();
}
}
}
@@ -70,7 +70,6 @@ export const MIGRATED_LANGUAGES: ReadonlySet<SupportedLanguages> = new Set<Suppo
SupportedLanguages.Python,
SupportedLanguages.CSharp,
SupportedLanguages.TypeScript,
SupportedLanguages.Go,
]);
/**
@@ -541,8 +541,6 @@ function buildDefFromDeclarationMatch(
const parameterCount = parseIntCapture(match['@declaration.parameter-count']);
const requiredParameterCount = parseIntCapture(match['@declaration.required-parameter-count']);
const parameterTypes = parseJsonStringArrayCapture(match['@declaration.parameter-types']);
const declaredType = match['@declaration.field-type']?.text;
const returnType = match['@declaration.return-type']?.text;
return {
nodeId: makeDefId(filePath, anchor.range, type, nameCap.text),
@@ -552,8 +550,6 @@ function buildDefFromDeclarationMatch(
...(parameterCount !== undefined ? { parameterCount } : {}),
...(requiredParameterCount !== undefined ? { requiredParameterCount } : {}),
...(parameterTypes !== undefined ? { parameterTypes } : {}),
...(declaredType !== undefined ? { declaredType } : {}),
...(returnType !== undefined ? { returnType } : {}),
};
}
@@ -260,7 +260,6 @@ import type { KnowledgeGraph } from '../../../graph/types.js';
import type { GraphNodeLookup } from '../graph-bridge/node-lookup.js';
import { LanguageProvider } from '../../language-provider.js';
import { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
import type { SemanticModel } from '../../model/semantic-model.js';
/** A LinearizeStrategy receives the full ancestor map so C3-style
* algorithms (which need to merge each parent's MRO) can implement
@@ -315,17 +314,7 @@ export interface ScopeResolver {
fromFile: string,
allFilePaths: ReadonlySet<string>,
resolutionConfig?: unknown,
): 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[];
): string | null;
/**
* Optional one-shot loader for cross-file import-resolution config
@@ -395,18 +384,6 @@ 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<string, string> },
) => void;
/**
* Recognize a `super(...)`-style receiver text. Python returns
* `/^super\s*\(/.test(t)`. Java returns `t === 'super'`. C++ may
@@ -464,14 +441,6 @@ 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
@@ -516,52 +485,4 @@ 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<interface_DefId, implementing_struct_DefId[]>.
* Default: undefined (no structural interface detection).
*/
readonly detectInterfaceImplementations?: (
parsedFiles: readonly ParsedFile[],
indexes: ScopeResolutionIndexes,
model: SemanticModel,
) => Map<string, string[]>;
/**
* 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<string, string>;
readonly treeCache?: { get(filePath: string): unknown };
},
) => void;
}
@@ -21,6 +21,7 @@ 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
@@ -36,7 +36,6 @@ export function emitFreeCallFallback(
handledSites: Set<string>,
model: SemanticModel,
workspaceIndex: WorkspaceResolutionIndex,
options: { readonly allowGlobalFallback?: boolean } = {},
): number {
let emitted = 0;
const seen = new Set<string>();
@@ -68,13 +67,6 @@ 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;
@@ -103,51 +95,6 @@ export function emitFreeCallFallback(
return emitted;
}
function pickUniqueGlobalCallable(
name: string,
model: SemanticModel,
scopes: ScopeResolutionIndexes,
): SymbolDefinition | undefined {
const scopeDefs: SymbolDefinition[] = [];
const scopeSeen = new Set<string>();
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<string>();
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
@@ -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`. */
export function followChainPostFinalize(
function followChainPostFinalize(
start: TypeRef,
fromScopeId: ScopeId,
scopes: ScopeResolutionIndexes,
@@ -24,7 +24,6 @@ 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`.
@@ -59,7 +58,7 @@ export function buildMro(
const defIdByGraphId = new Map<string, string>();
for (const parsed of parsedFiles) {
for (const def of parsed.localDefs) {
if (!isClassLike(def.type)) continue;
if (def.type !== 'Class') continue;
const graphId = resolveDefGraphId(parsed.filePath, def, nodeLookup);
if (graphId !== undefined) defIdByGraphId.set(graphId, def.nodeId);
}
@@ -46,7 +46,6 @@ import {
findExportedDef,
findOwnedMember,
findReceiverTypeBinding,
isClassLike,
} from '../scope/walkers.js';
import { tryEmitEdge } from '../graph-bridge/edges.js';
import { resolveCompoundReceiverClass } from '../passes/compound-receiver.js';
@@ -250,30 +249,25 @@ export function emitReceiverBoundCalls(
}
// ── Case 1: namespace receiver ───────────────────────────────
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;
}
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;
}
if (found) continue;
}
// ── Case 2: class-name receiver ──────────────────────────────
@@ -315,33 +309,28 @@ export function emitReceiverBoundCalls(
if (typeRef !== undefined && typeRef.rawName.includes('.')) {
const [nsName, ...classNameParts] = typeRef.rawName.split('.');
const className = classNameParts.join('.');
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;
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);
}
continue;
}
}
if (found3) continue;
}
}
@@ -405,20 +394,10 @@ 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 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.
// 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.
if (ownerDef === undefined) {
ownerDef = resolveCompoundReceiverClass(
receiverName,
@@ -14,7 +14,6 @@ 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
@@ -27,5 +26,4 @@ export const SCOPE_RESOLVERS: ReadonlyMap<SupportedLanguages, ScopeResolver> = n
[SupportedLanguages.Python, pythonScopeResolver],
[SupportedLanguages.CSharp, csharpScopeResolver],
[SupportedLanguages.TypeScript, typescriptScopeResolver],
[SupportedLanguages.Go, goScopeResolver],
]);
@@ -90,14 +90,6 @@ 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<string, string> | undefined;
const getFileContents = (): Map<string, string> => {
if (fileContents === undefined) {
fileContents = new Map<string, string>();
for (const f of files) fileContents.set(f.path, f.content);
}
return fileContents;
};
// ── Phase 1: extract each file → ParsedFile ────────────────────────────
const parsedFiles: ParsedFile[] = [];
@@ -119,7 +111,6 @@ 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
@@ -158,8 +149,6 @@ 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),
},
@@ -189,21 +178,16 @@ 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<string, string>();
for (const f of files) fileContents.set(f.path, f.content);
provider.populateNamespaceSiblings(parsedFiles, indexes, {
fileContents: getFileContents(),
fileContents,
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)
@@ -212,13 +196,6 @@ 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
@@ -260,7 +237,6 @@ export function runScopeResolution(
handledSites,
readonlyModel,
workspaceIndex,
{ allowGlobalFallback: provider.allowGlobalFreeCallFallback === true },
);
const { emitted, skipped } = emitReferencesViaLookup(
graph,
@@ -38,8 +38,8 @@ import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexe
export function collectNamespaceTargets(
parsed: ParsedFile,
scopes: ScopeResolutionIndexes,
): Map<string, string[]> {
const out = new Map<string, string[]>();
): Map<string, string> {
const out = new Map<string, string>();
const moduleEdges = scopes.imports.get(parsed.moduleScope);
if (moduleEdges === undefined) return out;
@@ -51,12 +51,7 @@ export function collectNamespaceTargets(
for (const edge of moduleEdges) {
if (edge.targetFile === null) continue;
if (!namespaceLocals.has(edge.localName)) continue;
let targets = out.get(edge.localName);
if (targets === undefined) {
targets = [];
out.set(edge.localName, targets);
}
if (!targets.includes(edge.targetFile)) targets.push(edge.targetFile);
out.set(edge.localName, edge.targetFile);
}
return out;
}
@@ -187,27 +187,6 @@ 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;
}
@@ -84,63 +84,6 @@ export const TYPESCRIPT_QUERIES = `
key: (string (string_fragment) @name)
value: (function_expression)) @definition.function
; HOC-wrapped variable declarations: \`const X = HOC((args) => { ... })\`.
; Mirrors the registry-primary patterns in \`languages/typescript/query.ts\`
; so the legacy Call-Resolution DAG and the registry-primary pipeline
; produce the same set of \`Function\` nodes — required for the CI parity
; gate. Covers React.forwardRef / memo / useCallback / useMemo / observer
; / debounce / user-defined HOC factories. The \`var X = HOC(...)\` form is
; mirrored too (registry-primary has it) so that codebases mixing \`var\` and
; \`const\` see identical attribution on both pipelines. See
; \`tsExtractFunctionName\` for the resolution logic and the \`query.ts\`
; comment for the full anchor-discipline rationale and the chained-
; array-method trade-off.
(lexical_declaration
(variable_declarator
name: (identifier) @name
value: (call_expression
arguments: (arguments
(arrow_function))))) @definition.function
(lexical_declaration
(variable_declarator
name: (identifier) @name
value: (call_expression
arguments: (arguments
(function_expression))))) @definition.function
(export_statement
declaration: (lexical_declaration
(variable_declarator
name: (identifier) @name
value: (call_expression
arguments: (arguments
(arrow_function)))))) @definition.function
(export_statement
declaration: (lexical_declaration
(variable_declarator
name: (identifier) @name
value: (call_expression
arguments: (arguments
(function_expression)))))) @definition.function
; \`var X = HOC(...)\` parity with registry-primary. Legacy code (and any
; transpiler output that downlevels \`const\` to \`var\`) hits this shape.
(variable_declaration
(variable_declarator
name: (identifier) @name
value: (call_expression
arguments: (arguments
(arrow_function))))) @definition.function
(variable_declaration
(variable_declarator
name: (identifier) @name
value: (call_expression
arguments: (arguments
(function_expression))))) @definition.function
; Variable/constant declarations (non-function values).
; Overlap with @definition.function patterns is handled by parse-worker dedup.
(lexical_declaration
@@ -317,57 +260,6 @@ export const JAVASCRIPT_QUERIES = `
key: (string (string_fragment) @name)
value: (function_expression)) @definition.function
; HOC-wrapped variable declarations: \`const X = HOC((args) => { ... })\`.
; See TYPESCRIPT_QUERIES section above for the full rationale (issue #1166
; follow-up — covers forwardRef / memo / useCallback / useMemo / observer
; / debounce / user-defined HOC factories). Both \`const\` and \`var\` forms
; are mirrored so JS code that uses \`var\` (or transpiler output) gets the
; same attribution as the registry-primary path.
(lexical_declaration
(variable_declarator
name: (identifier) @name
value: (call_expression
arguments: (arguments
(arrow_function))))) @definition.function
(lexical_declaration
(variable_declarator
name: (identifier) @name
value: (call_expression
arguments: (arguments
(function_expression))))) @definition.function
(export_statement
declaration: (lexical_declaration
(variable_declarator
name: (identifier) @name
value: (call_expression
arguments: (arguments
(arrow_function)))))) @definition.function
(export_statement
declaration: (lexical_declaration
(variable_declarator
name: (identifier) @name
value: (call_expression
arguments: (arguments
(function_expression)))))) @definition.function
; \`var X = HOC(...)\` parity with registry-primary.
(variable_declaration
(variable_declarator
name: (identifier) @name
value: (call_expression
arguments: (arguments
(arrow_function))))) @definition.function
(variable_declaration
(variable_declarator
name: (identifier) @name
value: (call_expression
arguments: (arguments
(function_expression))))) @definition.function
; Variable/constant declarations (non-function values).
; Overlap with @definition.function patterns is handled by parse-worker dedup.
(lexical_declaration
@@ -332,22 +332,11 @@ export const createWorkerPool = (
if (!settled) {
settled = true;
cleanup();
activeWorkers--;
inFlightProgress[workerIndex] = 0;
const shouldContinue = requeueAfterTimeout(workerIndex, job, lastProgress);
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--;
}
if (!shouldContinue) return;
await replaceWorker(workerIndex);
reportProgress();
runWorker(workerIndex);
maybeDone();
-20
View File
@@ -294,14 +294,6 @@ const ensureLbugInitialized = async (dbPath: string) => {
const doInitLbug = async (dbPath: string) => {
// Different database requested — close the old one first
if (conn || db) {
// CHECKPOINT before close so WAL contents are flushed into the main
// database file (mirrors the closeLbug() contract; same LadybugDB
// 0.16.0 WAL-flush issue on Windows applies here).
try {
if (conn) await conn.query('CHECKPOINT');
} catch {
/* ignore */
}
try {
if (conn) await conn.close();
} catch {}
@@ -1057,18 +1049,6 @@ export const fetchExistingEmbeddingHashes = async (
export const closeLbug = async (): Promise<void> => {
if (conn) {
// CHECKPOINT before close so WAL contents are flushed into the main
// database file. Without this, LadybugDB 0.16.0's non-blocking
// checkpoint thread can outlive the close call and leave sidecar pages
// pending on disk, which makes a subsequent read-side open race with
// the WAL replay or trip the database-id check — triggering the
// UNREACHABLE_CODE assertion seen on Windows after analyze --embeddings.
// CHECKPOINT is a no-op when nothing is pending, so it's always safe.
try {
await conn.query('CHECKPOINT');
} catch {
/* ignore — older LadybugDB or in-memory DB may not accept it */
}
try {
await conn.close();
} catch {}
+3 -27
View File
@@ -30,13 +30,7 @@ import {
registerRepo,
cleanupOldKuzuFiles,
} from '../storage/repo-manager.js';
import {
getCurrentCommit,
getRemoteUrl,
hasGitDir,
getInferredRepoName,
resolveRepoIdentityRoot,
} from '../storage/git.js';
import { getCurrentCommit, getRemoteUrl, hasGitDir, getInferredRepoName } 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';
@@ -174,13 +168,7 @@ export async function runFullAnalysis(
if (currentCommit !== '') {
await ensureGitNexusIgnored(repoPath);
return {
// `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)),
repoName: options.registryName ?? getInferredRepoName(repoPath) ?? path.basename(repoPath),
repoPath,
stats: existingMeta.stats ?? {},
alreadyUpToDate: true,
@@ -357,19 +345,7 @@ export async function runFullAnalysis(
}
const { readServerMapping } = await import('./embeddings/server-mapping.js');
// 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 projectName = path.basename(repoPath);
const serverName = await readServerMapping(projectName);
const embeddingResult = await runEmbeddingPipeline(
executeQuery,
+7 -6
View File
@@ -6,13 +6,14 @@
*/
import { pipeline, env, type FeatureExtractionPipeline } from '@huggingface/transformers';
import os from 'os';
import { join } from 'path';
import {
isHttpMode,
getHttpDimensions,
httpEmbedQuery,
} from '../../core/embeddings/http-client.js';
import { resolveEmbeddingConfig } from '../../core/embeddings/config.js';
import { applyHfEnvOverrides } from '../../core/embeddings/hf-env.js';
import { silenceStdout, restoreStdout, realStderrWrite } from '../../core/lbug/pool-adapter.js';
// Model config
@@ -44,11 +45,11 @@ export const initEmbedder = async (): Promise<FeatureExtractionPipeline> => {
initPromise = (async () => {
try {
env.allowLocalModels = false;
// Bridge user-controlled env vars to transformers.js: HF_HOME →
// env.cacheDir, HF_ENDPOINT → env.remoteHost (#1205). Centralised in
// applyHfEnvOverrides so this MCP entry point behaves identically to
// the analyze pipeline embedder.
applyHfEnvOverrides(env);
// Default cache to user-writable location. transformers.js defaults to
// ./node_modules/.cache inside its own install dir, which is unwritable
// when gitnexus is installed globally (e.g. /usr/lib/node_modules/).
// Respect HF_HOME if set, otherwise fall back to ~/.cache/huggingface.
env.cacheDir = process.env.HF_HOME ?? join(os.homedir(), '.cache', 'huggingface');
const embeddingConfig = resolveEmbeddingConfig();
console.error('GitNexus: Loading embedding model (first search may take a moment)...');
-1
View File
@@ -158,7 +158,6 @@ export function createMCPServer(backend: LocalBackend): Server {
name: tool.name,
description: tool.description,
inputSchema: tool.inputSchema,
annotations: tool.annotations,
})),
}));
-39
View File
@@ -5,12 +5,9 @@
* All tools support an optional `repo` parameter for multi-repo setups.
*/
import type { ToolAnnotations } from '@modelcontextprotocol/sdk/types.js';
export interface ToolDefinition {
name: string;
description: string;
annotations: ToolAnnotations;
inputSchema: {
type: 'object';
properties: Record<
@@ -30,27 +27,6 @@ export interface ToolDefinition {
};
}
const READ_ONLY_TOOL_ANNOTATIONS: ToolAnnotations = {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
};
const QUERY_TOOL_ANNOTATIONS: ToolAnnotations = {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
};
const DESTRUCTIVE_TOOL_ANNOTATIONS: ToolAnnotations = {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: false,
openWorldHint: false,
};
export const GITNEXUS_TOOLS: ToolDefinition[] = [
{
name: 'list_repos',
@@ -63,7 +39,6 @@ AFTER THIS: READ gitnexus://repo/{name}/context for the repo you want to work wi
When multiple repos are indexed, you MUST specify the "repo" parameter
on other tools (query, context, impact, etc.) to target the correct one.`,
annotations: READ_ONLY_TOOL_ANNOTATIONS,
inputSchema: {
type: 'object',
properties: {},
@@ -88,7 +63,6 @@ Hybrid ranking: BM25 keyword + semantic vector search, ranked by Reciprocal Rank
GROUP MODE: set "repo" to "@<groupName>" to search all member repos in that group (merged via RRF), or "@<groupName>/<groupRepoPath>" to run against a single member (same path keys as in group.yaml). If you use "@<groupName>" only, the member repo defaults to the lexicographically first key in group.yaml "repos". Prefer resources for contracts/status (see migration from legacy group_* tools).
SERVICE: optional monorepo path prefix (POSIX-style, case-sensitive segments). When "repo" starts with "@", only processes whose symbols fall under that prefix are included. For a normal indexed repo name (no leading @), this field is currently ignored by the server.`,
annotations: QUERY_TOOL_ANNOTATIONS,
inputSchema: {
type: 'object',
properties: {
@@ -182,7 +156,6 @@ TIPS:
- Community = auto-detected functional area (Leiden algorithm). Properties: heuristicLabel, cohesion, symbolCount, keywords, description, enrichedBy
- Process = execution flow trace from entry point to terminal. Properties: heuristicLabel, processType, stepCount, communities, entryPointId, terminalId
- Use heuristicLabel (not label) for human-readable community/process names`,
annotations: READ_ONLY_TOOL_ANNOTATIONS,
inputSchema: {
type: 'object',
properties: {
@@ -210,7 +183,6 @@ NOTE: ACCESSES edges (field read/write tracking) are included in context results
GROUP MODE: set "repo" to "@<groupName>" to run context in each member repo (aggregated list), or "@<groupName>/<groupRepoPath>" for one member. If you use "@<groupName>" only, the member defaults to the lexicographically first key in group.yaml "repos".
SERVICE: optional monorepo path prefix (case-sensitive path segments). When "repo" starts with "@", prefix-matches resolved symbol file paths; when a hit is outside the prefix, that member returns an empty payload for the symbol. Ignored for a normal indexed repo name.`,
annotations: READ_ONLY_TOOL_ANNOTATIONS,
inputSchema: {
type: 'object',
properties: {
@@ -254,7 +226,6 @@ WHEN TO USE: Before committing — to understand what your changes affect. Pre-c
AFTER THIS: Review affected processes. Use context() on high-risk symbols. READ gitnexus://repo/{name}/process/{name} for full traces.
Returns: changed symbols, affected processes, and a risk summary.`,
annotations: READ_ONLY_TOOL_ANNOTATIONS,
inputSchema: {
type: 'object',
properties: {
@@ -287,7 +258,6 @@ AFTER THIS: Run detect_changes() to verify no unexpected side effects.
Each edit is tagged with confidence:
- "graph": found via knowledge graph relationships (high confidence, safe to accept)
- "text_search": found via regex text search (lower confidence, review carefully)`,
annotations: DESTRUCTIVE_TOOL_ANNOTATIONS,
inputSchema: {
type: 'object',
properties: {
@@ -341,7 +311,6 @@ Confidence: 1.0 = certain, <0.8 = fuzzy match
GROUP MODE: set "repo" to "@<groupName>" for cross-repo impact anchored at the default member (lexicographically first key in group.yaml "repos"), or "@<groupName>/<groupRepoPath>" to choose the member (same path keys as in group.yaml). Phase-1 walk runs in that member; cross-boundary fan-out uses the group bridge.
SERVICE: optional monorepo path prefix (case-sensitive path segments). When "repo" starts with "@", scopes the local impact walk and cross-repo symbol paths to files under that prefix; ignored for a normal indexed repo name.`,
annotations: READ_ONLY_TOOL_ANNOTATIONS,
inputSchema: {
type: 'object',
properties: {
@@ -435,7 +404,6 @@ WHEN TO USE: Understanding API consumption patterns, finding orphaned routes. Fo
AFTER THIS: Use impact() on specific route handlers to see full blast radius.
Returns: route nodes with their handlers, middleware wrapper chains (e.g., withAuth, withRateLimit), and consumers.`,
annotations: READ_ONLY_TOOL_ANNOTATIONS,
inputSchema: {
type: 'object',
properties: {
@@ -458,7 +426,6 @@ Returns: route nodes with their handlers, middleware wrapper chains (e.g., withA
WHEN TO USE: Understanding tool APIs, finding tool implementations, impact analysis for tool changes.
Returns: tool nodes with their handler files and descriptions.`,
annotations: READ_ONLY_TOOL_ANNOTATIONS,
inputSchema: {
type: 'object',
properties: {
@@ -476,7 +443,6 @@ WHEN TO USE: Detecting mismatches between what an API route returns and what con
REQUIRES: Route nodes with responseKeys (extracted from .json({...}) calls during indexing).
Returns routes that have both detected response keys AND consumers. Shows top-level keys each endpoint returns (e.g., data, pagination, error) and what keys each consumer accesses. Reports MISMATCH status when a consumer accesses keys not present in the route's response shape.`,
annotations: READ_ONLY_TOOL_ANNOTATIONS,
inputSchema: {
type: 'object',
properties: {
@@ -501,7 +467,6 @@ WHEN TO USE: BEFORE modifying any API route handler. Shows what consumers depend
Risk levels: LOW (0-3 consumers), MEDIUM (4-9 or any mismatches), HIGH (10+ consumers or mismatches with 4+ consumers). Mismatches with confidence "low" indicate the consumer file fetches multiple routes — property attribution is approximate.
Returns: single route object when one match, or { routes: [...], total: N } for multiple matches. Combines route_map, shape_check, and impact data.`,
annotations: READ_ONLY_TOOL_ANNOTATIONS,
inputSchema: {
type: 'object',
properties: {
@@ -517,7 +482,6 @@ Returns: single route object when one match, or { routes: [...], total: N } for
description: `List all configured repository groups, or return details for one group (repos, manifest links).
WHEN TO USE: Discover groups before group_sync. Optional "name" returns a single group's config.`,
annotations: READ_ONLY_TOOL_ANNOTATIONS,
inputSchema: {
type: 'object',
properties: {
@@ -531,9 +495,6 @@ WHEN TO USE: Discover groups before group_sync. Optional "name" returns a single
description: `Rebuild the Contract Registry (contracts.json) for a group: extract HTTP contracts, apply manifest links, exact-match cross-links.
WHEN TO USE: After changing group.yaml or re-indexing member repos.`,
// Writes contracts.json on every call; conservatively non-idempotent
// even though output is deterministic for identical input.
annotations: DESTRUCTIVE_TOOL_ANNOTATIONS,
inputSchema: {
type: 'object',
properties: {
+71 -177
View File
@@ -33,7 +33,6 @@ import { mountMCPEndpoints } from './mcp-http.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);
@@ -217,19 +216,7 @@ 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.
// 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) => {
app.get(SPA_FALLBACK_REGEX, (_req, res) => {
res.sendFile(path.join(staticDir, 'index.html'));
});
} else {
@@ -519,9 +506,6 @@ 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;
@@ -539,112 +523,10 @@ 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<void> => {
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,
@@ -862,10 +744,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
});
// Delete a repo — removes index, clone dir (if any), and unregisters it
// 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) => {
app.delete('/api/repo', async (req, res) => {
try {
const repoName = requestedRepo(req);
if (!repoName) {
@@ -896,26 +775,15 @@ 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/.
// 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;
// 2. Delete the cloned repo dir if it lives under ~/.gitnexus/repos/
const cloneDir = getCloneDir(entry.name);
try {
cloneDir = getCloneDir(entry.name);
} catch {
/* 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 */
const stat = await fs.stat(cloneDir);
if (stat.isDirectory()) {
await fs.rm(cloneDir, { recursive: true, force: true });
}
} catch {
/* clone dir may not exist (local repos) */
}
// 3. Unregister from the global registry
@@ -1178,58 +1046,84 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
});
// Read file — with path traversal guard
// 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)
// 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) => {
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;
}
// 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' });
const filePath = req.query.path as string;
if (!filePath) {
res.status(400).json({ error: 'Missing path' });
return;
}
const pattern = assertString(rawPattern, 'pattern');
if (pattern.length === 0) {
// 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' });
}
}
});
// 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) => {
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) {
res.status(400).json({ error: 'Missing "pattern" query parameter' });
return;
}
// Length cap: applies to both literal and regex modes as a defense-in-depth
// bound against pathological input.
// ReDoS protection: reject overly long or dangerous patterns
if (pattern.length > 200) {
res.status(400).json({ error: 'Pattern too long (max 200 characters)' });
return;
}
// 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)
// Validate regex syntax
let regex: RegExp;
try {
regex = new RegExp(effectivePattern, 'gim');
regex = new RegExp(pattern, 'gim');
} catch {
res.status(400).json({ error: 'Invalid regex pattern' });
return;
@@ -1277,7 +1171,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
res.json({ results });
} catch (err: any) {
res.status(statusFromError(err)).json({ error: err.message || 'Grep failed' });
res.status(500).json({ error: err.message || 'Grep failed' });
}
});
@@ -1348,7 +1242,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', createRouteLimiter({ limit: 10 }), async (req, res) => {
app.post('/api/analyze', async (req, res) => {
try {
const { url: repoUrl, path: repoLocalPath, force, embeddings, dropEmbeddings } = req.body;
@@ -1615,7 +1509,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', createRouteLimiter({ limit: 20 }), async (req, res) => {
app.post('/api/embed', async (req, res) => {
try {
const entry = await resolveRepo(requestedRepo(req));
if (!entry) {
+12 -244
View File
@@ -11,47 +11,16 @@ import os from 'os';
import fs from 'fs/promises';
import { isIP } from 'net';
/** 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.
*/
/** Extract the repository name from a git URL (HTTPS or SSH). */
export function extractRepoName(url: string): string {
// 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;
const cleaned = url.replace(/\/+$/, '');
const lastSegment = cleaned.split(/[/:]/).pop() || 'unknown';
return lastSegment.replace(/\.git$/, '');
}
/** Get the clone target directory for a repo name. */
export function getCloneDir(repoName: string): string {
// 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);
return path.join(os.homedir(), '.gitnexus', 'repos', repoName);
}
// Cloud metadata hostnames that must never be reachable via user-supplied URLs
@@ -170,39 +139,6 @@ 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 {
@@ -227,200 +163,32 @@ 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<string | null> {
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<void> {
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 (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).
* If targetDir exists with .git: git pull --ff-only
*/
export async function cloneOrPull(
url: string,
targetDir: string,
onProgress?: (progress: CloneProgress) => void,
): Promise<string> {
// 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(
const exists = await fs.access(path.join(targetDir, '.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'], safeTarget);
await runGit(['pull', '--ff-only'], targetDir);
} else {
await fs.mkdir(path.dirname(safeTarget), { recursive: true });
validateGitUrl(url);
await fs.mkdir(path.dirname(targetDir), { recursive: true });
onProgress?.({ phase: 'cloning', message: `Cloning ${url}...` });
await runGit(buildCloneArgs(url, safeTarget));
await runGit(['clone', '--depth', '1', url, targetDir]);
}
return safeTarget;
return targetDir;
}
function runGit(args: string[], cwd?: string): Promise<void> {
-158
View File
@@ -1,158 +0,0 @@
/**
* 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<RateLimitOptions>` 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,
});
}
-102
View File
@@ -93,108 +93,6 @@ export const getGitRoot = (fromPath: string): string | null => {
return 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 `<repo>/.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.
*
* Unlike `getGitRoot`, this does not spawn `git`, so MCP can cheaply decide
* whether a launch cwd is a worktree before running any subprocess there.
*/
export const findGitRootByDotGit = (fromPath: string): string | null => {
let current = path.resolve(fromPath);
try {
if (!statSync(current).isDirectory()) {
current = path.dirname(current);
}
} catch {
return null;
}
while (true) {
try {
statSync(path.join(current, '.git'));
return current;
} catch {
const parent = path.dirname(current);
if (parent === current) return null;
current = parent;
}
}
};
/**
* Check whether a directory contains a .git entry (file or folder).
*

Some files were not shown because too many files have changed in this diff Show More