mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-10 03:27:59 +00:00
Merge origin/main into feat/multi-repo-unified-graph
This commit is contained in:
commit
f848f8e4c2
97 changed files with 6637 additions and 335 deletions
71
.github/workflows/codeql.yml
vendored
Normal file
71
.github/workflows/codeql.yml
vendored
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
name: CodeQL
|
||||
|
||||
# Static analysis (SAST) for TypeScript/JavaScript and Python sources.
|
||||
# Findings upload to the GitHub Security tab as SARIF.
|
||||
#
|
||||
# Advisory only on first introduction — see docs/plans/2026-05-03-001-feat-automated-security-scans-plan.md.
|
||||
# Promote to a required check after baseline triage (operator decision).
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths-ignore: ['**.md', 'docs/**', 'LICENSE']
|
||||
push:
|
||||
branches: [main]
|
||||
schedule:
|
||||
# Weekly Monday 06:00 UTC — catches advisories newly published against
|
||||
# already-merged code without waiting for the next PR.
|
||||
- cron: '0 6 * * 1'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze (${{ matrix.language }})
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
# security-events:write is what enables SARIF upload to the Security tab.
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [javascript-typescript, python]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# Don't leave GITHUB_TOKEN in .git/config for downstream steps to read.
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@0daab03d71ff584ef619d027a3fd9146679c5d84 # v3.35.3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: security-and-quality
|
||||
# Exclude generated/vendored code; tune after first-run signal.
|
||||
# gitnexus/vendor/ holds tree-sitter-proto sources (regenerated, not authored).
|
||||
# CodeQL path filters use .gitignore-style globs and do NOT support
|
||||
# brace expansion — list each generated parser file separately.
|
||||
config: |
|
||||
paths-ignore:
|
||||
- '**/dist/**'
|
||||
- '**/node_modules/**'
|
||||
- 'gitnexus/vendor/**'
|
||||
- 'gitnexus/src/core/parsing/**/parser.c'
|
||||
- 'gitnexus/src/core/parsing/**/parser.js'
|
||||
# Test fixtures are intentionally synthetic inputs (broken/unused
|
||||
# code, malformed samples) used to exercise the analyzer. CodeQL
|
||||
# findings here are noise, not real bugs.
|
||||
- '**/test/fixtures/**'
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@0daab03d71ff584ef619d027a3fd9146679c5d84 # v3.35.3
|
||||
with:
|
||||
category: '/language:${{ matrix.language }}'
|
||||
36
.github/workflows/dependency-review.yml
vendored
Normal file
36
.github/workflows/dependency-review.yml
vendored
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
name: Dependency Review
|
||||
|
||||
# Blocks PRs that introduce dependencies with high/critical known vulnerabilities.
|
||||
# Reads the dependency graph diff between PR head and base.
|
||||
#
|
||||
# This is a required-check candidate after one week of clean runs
|
||||
# (operator decision — see docs/plans/2026-05-03-001-feat-automated-security-scans-plan.md).
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
review:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
# pull-requests:write enables the inline summary comment on failure.
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Dependency Review
|
||||
uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.9.0
|
||||
with:
|
||||
fail-on-severity: high
|
||||
comment-summary-in-pr: on-failure
|
||||
45
.github/workflows/gitleaks.yml
vendored
Normal file
45
.github/workflows/gitleaks.yml
vendored
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
name: Gitleaks
|
||||
|
||||
# Deterministic in-CI secret scanning. Defense-in-depth on top of GitHub's
|
||||
# native secret-scanning push protection (which is a repo Settings toggle —
|
||||
# see SECURITY.md for the recommended admin action).
|
||||
#
|
||||
# PR runs scan the diff (fast); main pushes scan full history.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
gitleaks:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# Full history needed for the on-push full-history scan; on PRs the
|
||||
# action diffs against the base ref so the cost is bounded by the PR.
|
||||
fetch-depth: 0
|
||||
# Don't bake the token into the cloned .git/config; downstream
|
||||
# steps (and Gitleaks itself) don't need it for repo operations.
|
||||
persist-credentials: false
|
||||
|
||||
# No GITLEAKS_LICENSE secret is required for OSS / public-repo usage.
|
||||
# If this repo becomes private, the action will require a license key.
|
||||
- name: Gitleaks
|
||||
uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2.3.9
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITLEAKS_ENABLE_UPLOAD_ARTIFACT: true
|
||||
GITLEAKS_ENABLE_SUMMARY: true
|
||||
8
.github/workflows/publish.yml
vendored
8
.github/workflows/publish.yml
vendored
|
|
@ -37,8 +37,12 @@ jobs:
|
|||
with:
|
||||
node-version: 20
|
||||
registry-url: https://registry.npmjs.org
|
||||
cache: npm
|
||||
cache-dependency-path: gitnexus/package-lock.json
|
||||
# Hermetic install for the published artifact — no cache carry-over
|
||||
# from non-tag contexts. setup-node v5+ caches by default when a
|
||||
# packageManager field is present in package.json, so the explicit
|
||||
# opt-out is required to clear the zizmor cache-poisoning audit.
|
||||
# ~30s slower per release; runs rarely.
|
||||
package-manager-cache: false
|
||||
- name: Build gitnexus-shared
|
||||
run: npm install && npm run build
|
||||
working-directory: gitnexus-shared
|
||||
|
|
|
|||
7
.github/workflows/release-candidate.yml
vendored
7
.github/workflows/release-candidate.yml
vendored
|
|
@ -137,8 +137,11 @@ jobs:
|
|||
with:
|
||||
node-version: 20
|
||||
registry-url: https://registry.npmjs.org
|
||||
cache: npm
|
||||
cache-dependency-path: gitnexus/package-lock.json
|
||||
# Hermetic install — release-candidate produces shipped artifacts.
|
||||
# setup-node v5+ caches by default when a packageManager field is
|
||||
# present in package.json; explicit opt-out is required to clear
|
||||
# the zizmor cache-poisoning audit. See cache-poisoning audit.
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Build gitnexus-shared
|
||||
run: npm install && npm run build
|
||||
|
|
|
|||
58
.github/workflows/scorecard.yml
vendored
Normal file
58
.github/workflows/scorecard.yml
vendored
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
name: Scorecard
|
||||
|
||||
# OpenSSF Scorecard supply-chain posture check. Runs weekly + on main push +
|
||||
# branch_protection_rule changes. SARIF uploads to the Security tab; the public
|
||||
# badge URL resolves once the first scheduled run lands (see README badge wiring).
|
||||
|
||||
on:
|
||||
branch_protection_rule:
|
||||
schedule:
|
||||
- cron: '0 7 * * 1'
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
analysis:
|
||||
name: Scorecard analysis
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
# Needed to upload SARIF results to the Security tab.
|
||||
security-events: write
|
||||
# Needed for the publish_results badge flow (OIDC).
|
||||
id-token: write
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run Scorecard
|
||||
uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3
|
||||
with:
|
||||
results_file: results.sarif
|
||||
results_format: sarif
|
||||
# publish_results enables the public Scorecard badge.
|
||||
publish_results: true
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: SARIF file
|
||||
path: results.sarif
|
||||
retention-days: 5
|
||||
|
||||
- name: Upload to Security tab
|
||||
uses: github/codeql-action/upload-sarif@0daab03d71ff584ef619d027a3fd9146679c5d84 # v3.35.3
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
73
.github/workflows/trivy.yml
vendored
Normal file
73
.github/workflows/trivy.yml
vendored
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
name: Trivy Image Scan
|
||||
|
||||
# Builds Dockerfile.cli and Dockerfile.web, then scans the resulting images
|
||||
# for OS-package and language-package CVEs at HIGH/CRITICAL severity.
|
||||
# Findings upload to the Security tab; record-only (does not block merges).
|
||||
#
|
||||
# NOT triggered on PRs — image builds are slow and base-image CVE churn
|
||||
# shouldn't gate feature delivery.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: '0 8 * * 1'
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
scan:
|
||||
name: Trivy (${{ matrix.image.name }})
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
image:
|
||||
- { dockerfile: Dockerfile.cli, name: gitnexus-cli }
|
||||
- { dockerfile: Dockerfile.web, name: gitnexus-web }
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Build image (load locally for scan)
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
context: .
|
||||
file: ${{ matrix.image.dockerfile }}
|
||||
load: true
|
||||
push: false
|
||||
tags: scan-target:${{ matrix.image.name }}
|
||||
|
||||
# aquasecurity/trivy-action versions < 0.35.0 are flagged by
|
||||
# GHSA-69fq-xp46-6x23 (briefly compromised supply chain). Pinned to
|
||||
# v0.36.0 (post-incident clean release) by commit SHA.
|
||||
- name: Run Trivy
|
||||
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
||||
with:
|
||||
image-ref: scan-target:${{ matrix.image.name }}
|
||||
format: sarif
|
||||
output: trivy-${{ matrix.image.name }}.sarif
|
||||
severity: HIGH,CRITICAL
|
||||
# Hides CVEs with no available fix in the base image.
|
||||
ignore-unfixed: true
|
||||
exit-code: '0'
|
||||
|
||||
- name: Upload to Security tab
|
||||
uses: github/codeql-action/upload-sarif@0daab03d71ff584ef619d027a3fd9146679c5d84 # v3.35.3
|
||||
with:
|
||||
sarif_file: trivy-${{ matrix.image.name }}.sarif
|
||||
category: trivy-${{ matrix.image.name }}
|
||||
58
.github/workflows/workflow-lint.yml
vendored
Normal file
58
.github/workflows/workflow-lint.yml
vendored
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
name: Workflow Lint (zizmor)
|
||||
|
||||
# Lints .github/workflows/** for known GitHub Actions security misconfigurations:
|
||||
# unpinned Actions, dangerous ${{ ... }} interpolation in run: blocks,
|
||||
# missing per-job permissions:, etc.
|
||||
#
|
||||
# Scoped to PRs that touch .github/** only — keeps off the typical PR critical path.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- '.github/**'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
zizmor:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Install zizmor
|
||||
# Pinned — resolves to whatever's latest on PyPI otherwise.
|
||||
# Bump via Dependabot pip ecosystem (see .github/dependabot.yml).
|
||||
run: pipx install zizmor==1.24.1
|
||||
|
||||
# Initial threshold: medium. High+ findings fail the job; medium findings
|
||||
# appear in the Security tab without blocking. Tune after first run.
|
||||
# Per-rule exemptions for pre-existing intentional patterns live in
|
||||
# .github/zizmor.yml (each carries a documented mitigation).
|
||||
- name: Run zizmor
|
||||
run: zizmor --config .github/zizmor.yml --format sarif --min-severity medium . > zizmor.sarif
|
||||
continue-on-error: true
|
||||
|
||||
- name: Upload SARIF
|
||||
uses: github/codeql-action/upload-sarif@0daab03d71ff584ef619d027a3fd9146679c5d84 # v3.35.3
|
||||
with:
|
||||
sarif_file: zizmor.sarif
|
||||
category: zizmor
|
||||
|
||||
- name: Fail on high+ findings
|
||||
run: zizmor --config .github/zizmor.yml --min-severity high .
|
||||
34
.github/zizmor.yml
vendored
Normal file
34
.github/zizmor.yml
vendored
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# zizmor config — pre-existing intentional patterns flagged on initial introduction.
|
||||
# Each ignore below has a documented mitigation. Re-evaluate when the source workflow changes.
|
||||
#
|
||||
# To run zizmor locally with this config:
|
||||
# zizmor --config .github/zizmor.yml .
|
||||
|
||||
rules:
|
||||
dangerous-triggers:
|
||||
ignore:
|
||||
# workflow_run is REQUIRED to post sticky comments on fork PRs — the
|
||||
# default-branch privileged token isn't accessible from `pull_request`
|
||||
# on a fork. Mitigated by: read-only `actions:read` + `contents:read`
|
||||
# for artifact download; `pull-requests:write` is the only write scope;
|
||||
# no checkout of fork code occurs. Header comment in the file documents.
|
||||
- ci-report.yml
|
||||
|
||||
# pull_request_target needed by claude-code-action to access secrets
|
||||
# and post review comments on fork PRs. Mitigated by: PR checkouts pin
|
||||
# the fork's HEAD SHA (not the branch ref) to prevent TOCTOU races,
|
||||
# and claude-code-action sandboxes execution. Header comment documents.
|
||||
- claude.yml
|
||||
|
||||
# pull_request_target on the autolabel job needs `pull-requests:write`
|
||||
# to apply labels. Mitigated by: release-drafter runs with `dry-run:
|
||||
# true`, reads only `.github/release-drafter.yml` from the BASE ref,
|
||||
# and the validate-title job (which runs untrusted `pull_request`
|
||||
# context) holds no write permissions. Header comment documents.
|
||||
- pr-labeler.yml
|
||||
|
||||
# Note: cache-poisoning is NOT exempted. The two prior findings in
|
||||
# publish.yml and release-candidate.yml were fixed structurally by
|
||||
# dropping `cache: npm` from those workflows (matches the pattern used
|
||||
# by PyO3/maturin for the same audit). See the commit that added this
|
||||
# file for the rationale.
|
||||
|
|
@ -2,6 +2,7 @@ dist/
|
|||
coverage/
|
||||
gitnexus/vendor/
|
||||
gitnexus/test/fixtures/
|
||||
gitnexus-web/test/fixtures/
|
||||
gitnexus-web/playwright-report/
|
||||
gitnexus-web/test-results/
|
||||
*.d.ts
|
||||
|
|
|
|||
|
|
@ -18,6 +18,9 @@
|
|||
<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
SECURITY.md
Normal file
67
SECURITY.md
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
GitNexus is developed on `main`. Security fixes are applied to the latest released minor on npm (`gitnexus`) and to the published Docker images (`Dockerfile.cli`, `Dockerfile.web`). Older minors are not back-patched.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
**Please do not open a public GitHub issue for security reports.**
|
||||
|
||||
Use **GitHub Private Vulnerability Reporting** for this repository:
|
||||
|
||||
→ https://github.com/abhigyanpatwari/GitNexus/security/advisories/new
|
||||
|
||||
Please include:
|
||||
|
||||
- A description of the issue and its potential impact
|
||||
- Steps to reproduce (a minimal repro repo or commit hash if possible)
|
||||
- The affected version(s) — `npm view gitnexus version`, image digest, or commit SHA
|
||||
- Any suggested mitigation
|
||||
|
||||
### What to expect
|
||||
|
||||
- **Acknowledgement:** best-effort within 5 business days, subject to maintainer capacity.
|
||||
- **Triage:** we will confirm whether the report is in scope, request clarifications if needed, and propose a fix timeline.
|
||||
- **Disclosure:** coordinated. We will agree on a disclosure date with you before publishing an advisory.
|
||||
|
||||
### Scope
|
||||
|
||||
In scope:
|
||||
|
||||
- The `gitnexus` CLI and MCP server (`gitnexus/`)
|
||||
- The `gitnexus-web` thin client (`gitnexus-web/`)
|
||||
- The `gitnexus-shared` types package (`gitnexus-shared/`)
|
||||
- The published Docker images (`Dockerfile.cli`, `Dockerfile.web`)
|
||||
- GitHub Actions workflows in `.github/workflows/`
|
||||
|
||||
Out of scope:
|
||||
|
||||
- Vulnerabilities in third-party dependencies that we have no influence over (please report upstream; if a viable mitigation exists at the GitNexus layer, that's in scope).
|
||||
- Issues requiring physical access to a developer machine or a compromised local environment.
|
||||
- Theoretical attacks without a practical exploit against a default GitNexus deployment.
|
||||
|
||||
## Recommended Hardening for Forks and Self-Hosted Deployments
|
||||
|
||||
If you fork GitNexus or self-host it, we recommend enabling the following in your repository's **Settings → Code security and analysis**:
|
||||
|
||||
- **Private vulnerability reporting** — the channel described above.
|
||||
- **Dependabot alerts** — alerts on advisories affecting your dependencies.
|
||||
- **Dependabot security updates** — automated PRs for security patches (this repo's `.github/dependabot.yml` already covers version updates).
|
||||
- **Secret scanning** and **Push protection** — blocks pushes that introduce known secret patterns. Defense-in-depth on top of the in-CI Gitleaks scan documented below.
|
||||
- **Code scanning** — surfaces SARIF results from CodeQL, Trivy, Scorecard, and zizmor in one place.
|
||||
|
||||
## Automated Scans Running in CI
|
||||
|
||||
This repository runs the following scans automatically. Findings appear under the repository's **Security → Code scanning** tab.
|
||||
|
||||
| Scan | Tool | Trigger | Action on finding |
|
||||
|------|------|---------|-------------------|
|
||||
| Static analysis (JS/TS, Python) | [CodeQL](https://github.com/github/codeql-action) | PR, `main` push, weekly | Advisory (Security tab) |
|
||||
| Dependency vulnerabilities (PR diff) | [`dependency-review-action`](https://github.com/actions/dependency-review-action) | PR | **Blocks PR** at `high+` severity |
|
||||
| Secret scanning | [Gitleaks](https://github.com/gitleaks/gitleaks-action) | PR, `main` push | **Blocks PR** on default rules |
|
||||
| Supply-chain posture | [OpenSSF Scorecard](https://github.com/ossf/scorecard-action) | Weekly, `main` push | Advisory (Security tab + public badge) |
|
||||
| Workflow lint | [zizmor](https://github.com/woodruffw/zizmor) | PR (touching `.github/**`) | **Blocks PR** at `high+` severity |
|
||||
| Container image scan | [Trivy](https://github.com/aquasecurity/trivy-action) | Weekly, `main` push | Advisory (Security tab) |
|
||||
|
||||
Dependency version updates are managed separately by Dependabot — see `.github/dependabot.yml`.
|
||||
|
|
@ -14,6 +14,7 @@ export default [
|
|||
'gitnexus/vendor/**',
|
||||
'gitnexus-web/src/vendor/**',
|
||||
'gitnexus/test/fixtures/**',
|
||||
'gitnexus-web/test/fixtures/**',
|
||||
'gitnexus-web/playwright-report/**',
|
||||
'gitnexus-web/test-results/**',
|
||||
'**/*.d.ts',
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ readme = "README.md"
|
|||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"mini-swe-agent>=2.0.0",
|
||||
"litellm>=1.50.0,!=1.82.7,!=1.82.8",
|
||||
"litellm!=1.82.7,!=1.82.8,>=1.83.7",
|
||||
"datasets>=3.0.0",
|
||||
"typer>=0.12.0",
|
||||
"rich>=13.0.0",
|
||||
|
|
@ -18,7 +18,7 @@ dependencies = [
|
|||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0.0",
|
||||
"pytest>=9.0.3",
|
||||
"ruff>=0.5.0",
|
||||
"hypothesis>=6.88.0",
|
||||
"coverage>=7.6.0",
|
||||
|
|
|
|||
232
eval/uv.lock
generated
232
eval/uv.lock
generated
|
|
@ -21,7 +21,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "aiohttp"
|
||||
version = "3.13.3"
|
||||
version = "3.13.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohappyeyeballs" },
|
||||
|
|
@ -32,93 +32,93 @@ dependencies = [
|
|||
{ name = "propcache" },
|
||||
{ name = "yarl" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/4c/a164164834f03924d9a29dc3acd9e7ee58f95857e0b467f6d04298594ebb/aiohttp-3.13.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5b6073099fb654e0a068ae678b10feff95c5cae95bbfcbfa7af669d361a8aa6b", size = 746051, upload-time = "2026-01-03T17:29:43.287Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/71/d5c31390d18d4f58115037c432b7e0348c60f6f53b727cad33172144a112/aiohttp-3.13.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cb93e166e6c28716c8c6aeb5f99dfb6d5ccf482d29fe9bf9a794110e6d0ab64", size = 499234, upload-time = "2026-01-03T17:29:44.822Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/c9/741f8ac91e14b1d2e7100690425a5b2b919a87a5075406582991fb7de920/aiohttp-3.13.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:28e027cf2f6b641693a09f631759b4d9ce9165099d2b5d92af9bd4e197690eea", size = 494979, upload-time = "2026-01-03T17:29:46.405Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/b5/31d4d2e802dfd59f74ed47eba48869c1c21552c586d5e81a9d0d5c2ad640/aiohttp-3.13.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b61b7169ababd7802f9568ed96142616a9118dd2be0d1866e920e77ec8fa92a", size = 1748297, upload-time = "2026-01-03T17:29:48.083Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/3e/eefad0ad42959f226bb79664826883f2687d602a9ae2941a18e0484a74d3/aiohttp-3.13.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:80dd4c21b0f6237676449c6baaa1039abae86b91636b6c91a7f8e61c87f89540", size = 1707172, upload-time = "2026-01-03T17:29:49.648Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/3a/54a64299fac2891c346cdcf2aa6803f994a2e4beeaf2e5a09dcc54acc842/aiohttp-3.13.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65d2ccb7eabee90ce0503c17716fc77226be026dcc3e65cce859a30db715025b", size = 1805405, upload-time = "2026-01-03T17:29:51.244Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/70/ddc1b7169cf64075e864f64595a14b147a895a868394a48f6a8031979038/aiohttp-3.13.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b179331a481cb5529fca8b432d8d3c7001cb217513c94cd72d668d1248688a3", size = 1899449, upload-time = "2026-01-03T17:29:53.938Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/7e/6815aab7d3a56610891c76ef79095677b8b5be6646aaf00f69b221765021/aiohttp-3.13.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d4c940f02f49483b18b079d1c27ab948721852b281f8b015c058100e9421dd1", size = 1748444, upload-time = "2026-01-03T17:29:55.484Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/f2/073b145c4100da5511f457dc0f7558e99b2987cf72600d42b559db856fbc/aiohttp-3.13.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9444f105664c4ce47a2a7171a2418bce5b7bae45fb610f4e2c36045d85911d3", size = 1606038, upload-time = "2026-01-03T17:29:57.179Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/c1/778d011920cae03ae01424ec202c513dc69243cf2db303965615b81deeea/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:694976222c711d1d00ba131904beb60534f93966562f64440d0c9d41b8cdb440", size = 1724156, upload-time = "2026-01-03T17:29:58.914Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/cb/3419eabf4ec1e9ec6f242c32b689248365a1cf621891f6f0386632525494/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f33ed1a2bf1997a36661874b017f5c4b760f41266341af36febaf271d179f6d7", size = 1722340, upload-time = "2026-01-03T17:30:01.962Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/e5/76cf77bdbc435bf233c1f114edad39ed4177ccbfab7c329482b179cff4f4/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e636b3c5f61da31a92bf0d91da83e58fdfa96f178ba682f11d24f31944cdd28c", size = 1783041, upload-time = "2026-01-03T17:30:03.609Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/d4/dd1ca234c794fd29c057ce8c0566b8ef7fd6a51069de5f06fa84b9a1971c/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5d2d94f1f5fcbe40838ac51a6ab5704a6f9ea42e72ceda48de5e6b898521da51", size = 1596024, upload-time = "2026-01-03T17:30:05.132Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/58/4345b5f26661a6180afa686c473620c30a66afdf120ed3dd545bbc809e85/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2be0e9ccf23e8a94f6f0650ce06042cefc6ac703d0d7ab6c7a917289f2539ad4", size = 1804590, upload-time = "2026-01-03T17:30:07.135Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/06/05950619af6c2df7e0a431d889ba2813c9f0129cec76f663e547a5ad56f2/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9af5e68ee47d6534d36791bbe9b646d2a7c7deb6fc24d7943628edfbb3581f29", size = 1740355, upload-time = "2026-01-03T17:30:09.083Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/80/958f16de79ba0422d7c1e284b2abd0c84bc03394fbe631d0a39ffa10e1eb/aiohttp-3.13.3-cp311-cp311-win32.whl", hash = "sha256:a2212ad43c0833a873d0fb3c63fa1bacedd4cf6af2fee62bf4b739ceec3ab239", size = 433701, upload-time = "2026-01-03T17:30:10.869Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/f2/27cdf04c9851712d6c1b99df6821a6623c3c9e55956d4b1e318c337b5a48/aiohttp-3.13.3-cp311-cp311-win_amd64.whl", hash = "sha256:642f752c3eb117b105acbd87e2c143de710987e09860d674e068c4c2c441034f", size = 457678, upload-time = "2026-01-03T17:30:12.719Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/be/4fc11f202955a69e0db803a12a062b8379c970c7c84f4882b6da17337cc1/aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c", size = 739732, upload-time = "2026-01-03T17:30:14.23Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/2c/621d5b851f94fa0bb7430d6089b3aa970a9d9b75196bc93bb624b0db237a/aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168", size = 494293, upload-time = "2026-01-03T17:30:15.96Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/43/4be01406b78e1be8320bb8316dc9c42dbab553d281c40364e0f862d5661c/aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d", size = 493533, upload-time = "2026-01-03T17:30:17.431Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/a8/5a35dc56a06a2c90d4742cbf35294396907027f80eea696637945a106f25/aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29", size = 1737839, upload-time = "2026-01-03T17:30:19.422Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/62/4b9eeb331da56530bf2e198a297e5303e1c1ebdceeb00fe9b568a65c5a0c/aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3", size = 1703932, upload-time = "2026-01-03T17:30:21.756Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/f6/af16887b5d419e6a367095994c0b1332d154f647e7dc2bd50e61876e8e3d/aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d", size = 1771906, upload-time = "2026-01-03T17:30:23.932Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/83/397c634b1bcc24292fa1e0c7822800f9f6569e32934bdeef09dae7992dfb/aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463", size = 1871020, upload-time = "2026-01-03T17:30:26Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/f6/a62cbbf13f0ac80a70f71b1672feba90fdb21fd7abd8dbf25c0105fb6fa3/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc", size = 1755181, upload-time = "2026-01-03T17:30:27.554Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/87/20a35ad487efdd3fba93d5843efdfaa62d2f1479eaafa7453398a44faf13/aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf", size = 1561794, upload-time = "2026-01-03T17:30:29.254Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/95/8fd69a66682012f6716e1bc09ef8a1a2a91922c5725cb904689f112309c4/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033", size = 1697900, upload-time = "2026-01-03T17:30:31.033Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/66/7b94b3b5ba70e955ff597672dad1691333080e37f50280178967aff68657/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f", size = 1728239, upload-time = "2026-01-03T17:30:32.703Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/71/6f72f77f9f7d74719692ab65a2a0252584bf8d5f301e2ecb4c0da734530a/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679", size = 1740527, upload-time = "2026-01-03T17:30:34.695Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/b4/75ec16cbbd5c01bdaf4a05b19e103e78d7ce1ef7c80867eb0ace42ff4488/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423", size = 1554489, upload-time = "2026-01-03T17:30:36.864Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/8f/bc518c0eea29f8406dcf7ed1f96c9b48e3bc3995a96159b3fc11f9e08321/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce", size = 1767852, upload-time = "2026-01-03T17:30:39.433Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/f2/a07a75173124f31f11ea6f863dc44e6f09afe2bca45dd4e64979490deab1/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a", size = 1722379, upload-time = "2026-01-03T17:30:41.081Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/4a/1a3fee7c21350cac78e5c5cef711bac1b94feca07399f3d406972e2d8fcd/aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046", size = 428253, upload-time = "2026-01-03T17:30:42.644Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/b7/76175c7cb4eb73d91ad63c34e29fc4f77c9386bba4a65b53ba8e05ee3c39/aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57", size = 455407, upload-time = "2026-01-03T17:30:44.195Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/8a/12ca489246ca1faaf5432844adbfce7ff2cc4997733e0af120869345643a/aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c", size = 734190, upload-time = "2026-01-03T17:30:45.832Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/08/de43984c74ed1fca5c014808963cc83cb00d7bb06af228f132d33862ca76/aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9", size = 491783, upload-time = "2026-01-03T17:30:47.466Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/f8/8dd2cf6112a5a76f81f81a5130c57ca829d101ad583ce57f889179accdda/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3", size = 490704, upload-time = "2026-01-03T17:30:49.373Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/40/a46b03ca03936f832bc7eaa47cfbb1ad012ba1be4790122ee4f4f8cba074/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf", size = 1720652, upload-time = "2026-01-03T17:30:50.974Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/7e/917fe18e3607af92657e4285498f500dca797ff8c918bd7d90b05abf6c2a/aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6", size = 1692014, upload-time = "2026-01-03T17:30:52.729Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/b6/cefa4cbc00d315d68973b671cf105b21a609c12b82d52e5d0c9ae61d2a09/aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d", size = 1759777, upload-time = "2026-01-03T17:30:54.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/e3/e06ee07b45e59e6d81498b591fc589629be1553abb2a82ce33efe2a7b068/aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261", size = 1861276, upload-time = "2026-01-03T17:30:56.512Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/24/75d274228acf35ceeb2850b8ce04de9dd7355ff7a0b49d607ee60c29c518/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0", size = 1743131, upload-time = "2026-01-03T17:30:58.256Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/98/3d21dde21889b17ca2eea54fdcff21b27b93f45b7bb94ca029c31ab59dc3/aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730", size = 1556863, upload-time = "2026-01-03T17:31:00.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/84/da0c3ab1192eaf64782b03971ab4055b475d0db07b17eff925e8c93b3aa5/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91", size = 1682793, upload-time = "2026-01-03T17:31:03.024Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/0f/5802ada182f575afa02cbd0ec5180d7e13a402afb7c2c03a9aa5e5d49060/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3", size = 1716676, upload-time = "2026-01-03T17:31:04.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/8c/714d53bd8b5a4560667f7bbbb06b20c2382f9c7847d198370ec6526af39c/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4", size = 1733217, upload-time = "2026-01-03T17:31:06.868Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/79/e2176f46d2e963facea939f5be2d26368ce543622be6f00a12844d3c991f/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998", size = 1552303, upload-time = "2026-01-03T17:31:08.958Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/6a/28ed4dea1759916090587d1fe57087b03e6c784a642b85ef48217b0277ae/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0", size = 1763673, upload-time = "2026-01-03T17:31:10.676Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/35/4a3daeb8b9fab49240d21c04d50732313295e4bd813a465d840236dd0ce1/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591", size = 1721120, upload-time = "2026-01-03T17:31:12.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/9f/d643bb3c5fb99547323e635e251c609fbbc660d983144cfebec529e09264/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf", size = 427383, upload-time = "2026-01-03T17:31:14.382Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899, upload-time = "2026-01-03T17:31:15.958Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/36/5b6514a9f5d66f4e2597e40dea2e3db271e023eb7a5d22defe96ba560996/aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808", size = 737238, upload-time = "2026-01-03T17:31:17.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/49/459327f0d5bcd8c6c9ca69e60fdeebc3622861e696490d8674a6d0cb90a6/aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415", size = 492292, upload-time = "2026-01-03T17:31:19.919Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/0b/b97660c5fd05d3495b4eb27f2d0ef18dc1dc4eff7511a9bf371397ff0264/aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f", size = 493021, upload-time = "2026-01-03T17:31:21.636Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/d4/438efabdf74e30aeceb890c3290bbaa449780583b1270b00661126b8aae4/aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6", size = 1717263, upload-time = "2026-01-03T17:31:23.296Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/f2/7bddc7fd612367d1459c5bcf598a9e8f7092d6580d98de0e057eb42697ad/aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687", size = 1669107, upload-time = "2026-01-03T17:31:25.334Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/5a/1aeaecca40e22560f97610a329e0e5efef5e0b5afdf9f857f0d93839ab2e/aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26", size = 1760196, upload-time = "2026-01-03T17:31:27.394Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/f8/0ff6992bea7bd560fc510ea1c815f87eedd745fe035589c71ce05612a19a/aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a", size = 1843591, upload-time = "2026-01-03T17:31:29.238Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/d1/e30e537a15f53485b61f5be525f2157da719819e8377298502aebac45536/aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1", size = 1720277, upload-time = "2026-01-03T17:31:31.053Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/45/23f4c451d8192f553d38d838831ebbc156907ea6e05557f39563101b7717/aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25", size = 1548575, upload-time = "2026-01-03T17:31:32.87Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/ed/0a42b127a43712eda7807e7892c083eadfaf8429ca8fb619662a530a3aab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603", size = 1679455, upload-time = "2026-01-03T17:31:34.76Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/b5/c05f0c2b4b4fe2c9d55e73b6d3ed4fd6c9dc2684b1d81cbdf77e7fad9adb/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a", size = 1687417, upload-time = "2026-01-03T17:31:36.699Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/6b/915bc5dad66aef602b9e459b5a973529304d4e89ca86999d9d75d80cbd0b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926", size = 1729968, upload-time = "2026-01-03T17:31:38.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/3b/e84581290a9520024a08640b63d07673057aec5ca548177a82026187ba73/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba", size = 1545690, upload-time = "2026-01-03T17:31:40.57Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/04/0c3655a566c43fd647c81b895dfe361b9f9ad6d58c19309d45cff52d6c3b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c", size = 1746390, upload-time = "2026-01-03T17:31:42.857Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/53/71165b26978f719c3419381514c9690bd5980e764a09440a10bb816ea4ab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43", size = 1702188, upload-time = "2026-01-03T17:31:44.984Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/a7/cbe6c9e8e136314fa1980da388a59d2f35f35395948a08b6747baebb6aa6/aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1", size = 433126, upload-time = "2026-01-03T17:31:47.463Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/56/982704adea7d3b16614fc5936014e9af85c0e34b58f9046655817f04306e/aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984", size = 459128, upload-time = "2026-01-03T17:31:49.2Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/2a/3c79b638a9c3d4658d345339d22070241ea341ed4e07b5ac60fb0f418003/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c", size = 769512, upload-time = "2026-01-03T17:31:51.134Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/b9/3e5014d46c0ab0db8707e0ac2711ed28c4da0218c358a4e7c17bae0d8722/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592", size = 506444, upload-time = "2026-01-03T17:31:52.85Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/03/c1d4ef9a054e151cd7839cdc497f2638f00b93cbe8043983986630d7a80c/aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f", size = 510798, upload-time = "2026-01-03T17:31:54.91Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/76/8c1e5abbfe8e127c893fe7ead569148a4d5a799f7cf958d8c09f3eedf097/aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29", size = 1868835, upload-time = "2026-01-03T17:31:56.733Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/ac/984c5a6f74c363b01ff97adc96a3976d9c98940b8969a1881575b279ac5d/aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc", size = 1720486, upload-time = "2026-01-03T17:31:58.65Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/9a/b7039c5f099c4eb632138728828b33428585031a1e658d693d41d07d89d1/aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2", size = 1847951, upload-time = "2026-01-03T17:32:00.989Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/02/3bec2b9a1ba3c19ff89a43a19324202b8eb187ca1e928d8bdac9bbdddebd/aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587", size = 1941001, upload-time = "2026-01-03T17:32:03.122Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/df/d879401cedeef27ac4717f6426c8c36c3091c6e9f08a9178cc87549c537f/aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8", size = 1797246, upload-time = "2026-01-03T17:32:05.255Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/15/be122de1f67e6953add23335c8ece6d314ab67c8bebb3f181063010795a7/aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632", size = 1627131, upload-time = "2026-01-03T17:32:07.607Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/12/70eedcac9134cfa3219ab7af31ea56bc877395b1ac30d65b1bc4b27d0438/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64", size = 1795196, upload-time = "2026-01-03T17:32:09.59Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/11/b30e1b1cd1f3054af86ebe60df96989c6a414dd87e27ad16950eee420bea/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0", size = 1782841, upload-time = "2026-01-03T17:32:11.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/0d/d98a9367b38912384a17e287850f5695c528cff0f14f791ce8ee2e4f7796/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56", size = 1795193, upload-time = "2026-01-03T17:32:13.705Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/a5/a2dfd1f5ff5581632c7f6a30e1744deda03808974f94f6534241ef60c751/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72", size = 1621979, upload-time = "2026-01-03T17:32:15.965Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/f0/12973c382ae7c1cccbc4417e129c5bf54c374dfb85af70893646e1f0e749/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df", size = 1822193, upload-time = "2026-01-03T17:32:18.219Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/5f/24155e30ba7f8c96918af1350eb0663e2430aad9e001c0489d89cd708ab1/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa", size = 1769801, upload-time = "2026-01-03T17:32:20.25Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/f8/7314031ff5c10e6ece114da79b338ec17eeff3a079e53151f7e9f43c4723/aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767", size = 466523, upload-time = "2026-01-03T17:32:22.215Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/63/278a98c715ae467624eafe375542d8ba9b4383a016df8fdefe0ae28382a7/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344", size = 499694, upload-time = "2026-01-03T17:32:24.546Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/f5/a20c4ac64aeaef1679e25c9983573618ff765d7aa829fa2b84ae7573169e/aiohttp-3.13.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ab7229b6f9b5c1ba4910d6c41a9eb11f543eadb3f384df1b4c293f4e73d44d6", size = 757513, upload-time = "2026-03-31T21:57:02.146Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/0a/39fa6c6b179b53fcb3e4b3d2b6d6cad0180854eda17060c7218540102bef/aiohttp-3.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8f14c50708bb156b3a3ca7230b3d820199d56a48e3af76fa21c2d6087190fe3d", size = 506748, upload-time = "2026-03-31T21:57:04.275Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/ec/e38ce072e724fd7add6243613f8d1810da084f54175353d25ccf9f9c7e5a/aiohttp-3.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e7d2f8616f0ff60bd332022279011776c3ac0faa0f1b463f7bb12326fbc97a1c", size = 501673, upload-time = "2026-03-31T21:57:06.208Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/ba/3bc7525d7e2beaa11b309a70d48b0d3cfc3c2089ec6a7d0820d59c657053/aiohttp-3.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2567b72e1ffc3ab25510db43f355b29eeada56c0a622e58dcdb19530eb0a3cb", size = 1763757, upload-time = "2026-03-31T21:57:07.882Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/ab/e87744cf18f1bd78263aba24924d4953b41086bd3a31d22452378e9028a0/aiohttp-3.13.5-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fb0540c854ac9c0c5ad495908fdfd3e332d553ec731698c0e29b1877ba0d2ec6", size = 1720152, upload-time = "2026-03-31T21:57:09.946Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/f3/ed17a6f2d742af17b50bae2d152315ed1b164b07a5fd5cc1754d99e4dfa5/aiohttp-3.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9883051c6972f58bfc4ebb2116345ee2aa151178e99c3f2b2bbe2af712abd13", size = 1818010, upload-time = "2026-03-31T21:57:12.157Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/06/ecbc63dc937192e2a5cb46df4d3edb21deb8225535818802f210a6ea5816/aiohttp-3.13.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2294172ce08a82fb7c7273485895de1fa1186cc8294cfeb6aef4af42ad261174", size = 1907251, upload-time = "2026-03-31T21:57:14.023Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/a5/0521aa32c1ddf3aa1e71dcc466be0b7db2771907a13f18cddaa45967d97b/aiohttp-3.13.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3a807cabd5115fb55af198b98178997a5e0e57dead43eb74a93d9c07d6d4a7dc", size = 1759969, upload-time = "2026-03-31T21:57:16.146Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/78/a38f8c9105199dd3b9706745865a8a59d0041b6be0ca0cc4b2ccf1bab374/aiohttp-3.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa6d0d932e0f39c02b80744273cd5c388a2d9bc07760a03164f229c8e02662f6", size = 1616871, upload-time = "2026-03-31T21:57:17.856Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/41/27392a61ead8ab38072105c71aa44ff891e71653fe53d576a7067da2b4e8/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:60869c7ac4aaabe7110f26499f3e6e5696eae98144735b12a9c3d9eae2b51a49", size = 1739844, upload-time = "2026-03-31T21:57:19.679Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/55/5564e7ae26d94f3214250009a0b1c65a0c6af4bf88924ccb6fdab901de28/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:26d2f8546f1dfa75efa50c3488215a903c0168d253b75fba4210f57ab77a0fb8", size = 1731969, upload-time = "2026-03-31T21:57:22.006Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/c5/705a3929149865fc941bcbdd1047b238e4a72bcb215a9b16b9d7a2e8d992/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1162a1492032c82f14271e831c8f4b49f2b6078f4f5fc74de2c912fa225d51d", size = 1795193, upload-time = "2026-03-31T21:57:24.256Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/19/edabed62f718d02cff7231ca0db4ef1c72504235bc467f7b67adb1679f48/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8b14eb3262fad0dc2f89c1a43b13727e709504972186ff6a99a3ecaa77102b6c", size = 1606477, upload-time = "2026-03-31T21:57:26.364Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/fc/76f80ef008675637d88d0b21584596dc27410a990b0918cb1e5776545b5b/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ca9ac61ac6db4eb6c2a0cd1d0f7e1357647b638ccc92f7e9d8d133e71ed3c6ac", size = 1813198, upload-time = "2026-03-31T21:57:28.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/67/5b3ac26b80adb20ea541c487f73730dc8fa107d632c998f25bbbab98fcda/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7996023b2ed59489ae4762256c8516df9820f751cf2c5da8ed2fb20ee50abab3", size = 1752321, upload-time = "2026-03-31T21:57:30.549Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/06/e4a2e49255ea23fa4feeb5ab092d90240d927c15e47b5b5c48dff5a9ce29/aiohttp-3.13.5-cp311-cp311-win32.whl", hash = "sha256:77dfa48c9f8013271011e51c00f8ada19851f013cde2c48fca1ba5e0caf5bb06", size = 439069, upload-time = "2026-03-31T21:57:32.388Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/43/8c7163a596dab4f8be12c190cf467a1e07e4734cf90eebb39f7f5d53fc6a/aiohttp-3.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:d3a4834f221061624b8887090637db9ad4f61752001eae37d56c52fddade2dc8", size = 462859, upload-time = "2026-03-31T21:57:34.455Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/6f/353954c29e7dcce7cf00280a02c75f30e133c00793c7a2ed3776d7b2f426/aiohttp-3.13.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:023ecba036ddd840b0b19bf195bfae970083fd7024ce1ac22e9bba90464620e9", size = 748876, upload-time = "2026-03-31T21:57:36.319Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/1b/428a7c64687b3b2e9cd293186695affc0e1e54a445d0361743b231f11066/aiohttp-3.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15c933ad7920b7d9a20de151efcd05a6e38302cbf0e10c9b2acb9a42210a2416", size = 499557, upload-time = "2026-03-31T21:57:38.236Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/47/7be41556bfbb6917069d6a6634bb7dd5e163ba445b783a90d40f5ac7e3a7/aiohttp-3.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab2899f9fa2f9f741896ebb6fa07c4c883bfa5c7f2ddd8cf2aafa86fa981b2d2", size = 500258, upload-time = "2026-03-31T21:57:39.923Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/84/c9ecc5828cb0b3695856c07c0a6817a99d51e2473400f705275a2b3d9239/aiohttp-3.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60eaa2d440cd4707696b52e40ed3e2b0f73f65be07fd0ef23b6b539c9c0b0b4", size = 1749199, upload-time = "2026-03-31T21:57:41.938Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/d3/3c6d610e66b495657622edb6ae7c7fd31b2e9086b4ec50b47897ad6042a9/aiohttp-3.13.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:55b3bdd3292283295774ab585160c4004f4f2f203946997f49aac032c84649e9", size = 1721013, upload-time = "2026-03-31T21:57:43.904Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/a0/24409c12217456df0bae7babe3b014e460b0b38a8e60753d6cb339f6556d/aiohttp-3.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2b2355dc094e5f7d45a7bb262fe7207aa0460b37a0d87027dcf21b5d890e7d5", size = 1781501, upload-time = "2026-03-31T21:57:46.285Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/9d/b65ec649adc5bccc008b0957a9a9c691070aeac4e41cea18559fef49958b/aiohttp-3.13.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b38765950832f7d728297689ad78f5f2cf79ff82487131c4d26fe6ceecdc5f8e", size = 1878981, upload-time = "2026-03-31T21:57:48.734Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/d8/8d44036d7eb7b6a8ec4c5494ea0c8c8b94fbc0ed3991c1a7adf230df03bf/aiohttp-3.13.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b18f31b80d5a33661e08c89e202edabf1986e9b49c42b4504371daeaa11b47c1", size = 1767934, upload-time = "2026-03-31T21:57:51.171Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/04/d3f8211f273356f158e3464e9e45484d3fb8c4ce5eb2f6fe9405c3273983/aiohttp-3.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33add2463dde55c4f2d9635c6ab33ce154e5ecf322bd26d09af95c5f81cfa286", size = 1566671, upload-time = "2026-03-31T21:57:53.326Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/db/073e4ebe00b78e2dfcacff734291651729a62953b48933d765dc513bf798/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:327cc432fdf1356fb4fbc6fe833ad4e9f6aacb71a8acaa5f1855e4b25910e4a9", size = 1705219, upload-time = "2026-03-31T21:57:55.385Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/45/7dfba71a2f9fd97b15c95c06819de7eb38113d2cdb6319669195a7d64270/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7c35b0bf0b48a70b4cb4fc5d7bed9b932532728e124874355de1a0af8ec4bc88", size = 1743049, upload-time = "2026-03-31T21:57:57.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/71/901db0061e0f717d226386a7f471bb59b19566f2cae5f0d93874b017271f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:df23d57718f24badef8656c49743e11a89fd6f5358fa8a7b96e728fda2abf7d3", size = 1749557, upload-time = "2026-03-31T21:57:59.626Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/d5/41eebd16066e59cd43728fe74bce953d7402f2b4ddfdfef2c0e9f17ca274/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:02e048037a6501a5ec1f6fc9736135aec6eb8a004ce48838cb951c515f32c80b", size = 1558931, upload-time = "2026-03-31T21:58:01.972Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/e6/4a799798bf05740e66c3a1161079bda7a3dd8e22ca392481d7a7f9af82a6/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31cebae8b26f8a615d2b546fee45d5ffb76852ae6450e2a03f42c9102260d6fe", size = 1774125, upload-time = "2026-03-31T21:58:04.007Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/63/7749337c90f92bc2cb18f9560d67aa6258c7060d1397d21529b8004fcf6f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:888e78eb5ca55a615d285c3c09a7a91b42e9dd6fc699b166ebd5dee87c9ccf14", size = 1732427, upload-time = "2026-03-31T21:58:06.337Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/de/cf2f44ff98d307e72fb97d5f5bbae3bfcb442f0ea9790c0bf5c5c2331404/aiohttp-3.13.5-cp312-cp312-win32.whl", hash = "sha256:8bd3ec6376e68a41f9f95f5ed170e2fcf22d4eb27a1f8cb361d0508f6e0557f3", size = 433534, upload-time = "2026-03-31T21:58:08.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/ca/eadf6f9c8fa5e31d40993e3db153fb5ed0b11008ad5d9de98a95045bed84/aiohttp-3.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:110e448e02c729bcebb18c60b9214a87ba33bac4a9fa5e9a5f139938b56c6cb1", size = 460446, upload-time = "2026-03-31T21:58:10.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/e9/d76bf503005709e390122d34e15256b88f7008e246c4bdbe915cd4f1adce/aiohttp-3.13.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5029cc80718bbd545123cd8fe5d15025eccaaaace5d0eeec6bd556ad6163d61", size = 742930, upload-time = "2026-03-31T21:58:13.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/00/4b7b70223deaebd9bb85984d01a764b0d7bd6526fcdc73cca83bcbe7243e/aiohttp-3.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4bb6bf5811620003614076bdc807ef3b5e38244f9d25ca5fe888eaccea2a9832", size = 496927, upload-time = "2026-03-31T21:58:15.073Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/f5/0fb20fb49f8efdcdce6cd8127604ad2c503e754a8f139f5e02b01626523f/aiohttp-3.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a84792f8631bf5a94e52d9cc881c0b824ab42717165a5579c760b830d9392ac9", size = 497141, upload-time = "2026-03-31T21:58:17.009Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/86/b7c870053e36a94e8951b803cb5b909bfbc9b90ca941527f5fcafbf6b0fa/aiohttp-3.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57653eac22c6a4c13eb22ecf4d673d64a12f266e72785ab1c8b8e5940d0e8090", size = 1732476, upload-time = "2026-03-31T21:58:18.925Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/e5/4e161f84f98d80c03a238671b4136e6530453d65262867d989bbe78244d0/aiohttp-3.13.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5e5f7debc7a57af53fdf5c5009f9391d9f4c12867049d509bf7bb164a6e295b", size = 1706507, upload-time = "2026-03-31T21:58:21.094Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/56/ea11a9f01518bd5a2a2fcee869d248c4b8a0cfa0bb13401574fa31adf4d4/aiohttp-3.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c719f65bebcdf6716f10e9eff80d27567f7892d8988c06de12bbbd39307c6e3a", size = 1773465, upload-time = "2026-03-31T21:58:23.159Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/40/333ca27fb74b0383f17c90570c748f7582501507307350a79d9f9f3c6eb1/aiohttp-3.13.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d97f93fdae594d886c5a866636397e2bcab146fd7a132fd6bb9ce182224452f8", size = 1873523, upload-time = "2026-03-31T21:58:25.59Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/d2/e2f77eef1acb7111405433c707dc735e63f67a56e176e72e9e7a2cd3f493/aiohttp-3.13.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df334e39d4c2f899a914f1dba283c1aadc311790733f705182998c6f7cae665", size = 1754113, upload-time = "2026-03-31T21:58:27.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/56/3f653d7f53c89669301ec9e42c95233e2a0c0a6dd051269e6e678db4fdb0/aiohttp-3.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe6970addfea9e5e081401bcbadf865d2b6da045472f58af08427e108d618540", size = 1562351, upload-time = "2026-03-31T21:58:29.918Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/a6/9b3e91eb8ae791cce4ee736da02211c85c6f835f1bdfac0594a8a3b7018c/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7becdf835feff2f4f335d7477f121af787e3504b48b449ff737afb35869ba7bb", size = 1693205, upload-time = "2026-03-31T21:58:32.214Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/fc/bfb437a99a2fcebd6b6eaec609571954de2ed424f01c352f4b5504371dd3/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:676e5651705ad5d8a70aeb8eb6936c436d8ebbd56e63436cb7dd9bb36d2a9a46", size = 1730618, upload-time = "2026-03-31T21:58:34.728Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/b6/c8534862126191a034f68153194c389addc285a0f1347d85096d349bbc15/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:9b16c653d38eb1a611cc898c41e76859ca27f119d25b53c12875fd0474ae31a8", size = 1745185, upload-time = "2026-03-31T21:58:36.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/93/4ca8ee2ef5236e2707e0fd5fecb10ce214aee1ff4ab307af9c558bda3b37/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:999802d5fa0389f58decd24b537c54aa63c01c3219ce17d1214cbda3c2b22d2d", size = 1557311, upload-time = "2026-03-31T21:58:39.38Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/ae/76177b15f18c5f5d094f19901d284025db28eccc5ae374d1d254181d33f4/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ec707059ee75732b1ba130ed5f9580fe10ff75180c812bc267ded039db5128c6", size = 1773147, upload-time = "2026-03-31T21:58:41.476Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/a4/62f05a0a98d88af59d93b7fcac564e5f18f513cb7471696ac286db970d6a/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2d6d44a5b48132053c2f6cd5c8cb14bc67e99a63594e336b0f2af81e94d5530c", size = 1730356, upload-time = "2026-03-31T21:58:44.049Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/85/fc8601f59dfa8c9523808281f2da571f8b4699685f9809a228adcc90838d/aiohttp-3.13.5-cp313-cp313-win32.whl", hash = "sha256:329f292ed14d38a6c4c435e465f48bebb47479fd676a0411936cc371643225cc", size = 432637, upload-time = "2026-03-31T21:58:46.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/1b/ac685a8882896acf0f6b31d689e3792199cfe7aba37969fa91da63a7fa27/aiohttp-3.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:69f571de7500e0557801c0b51f4780482c0ec5fe2ac851af5a92cfce1af1cb83", size = 458896, upload-time = "2026-03-31T21:58:48.119Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/ce/46572759afc859e867a5bc8ec3487315869013f59281ce61764f76d879de/aiohttp-3.13.5-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:eb4639f32fd4a9904ab8fb45bf3383ba71137f3d9d4ba25b3b3f3109977c5b8c", size = 745721, upload-time = "2026-03-31T21:58:50.229Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/fe/8a2efd7626dbe6049b2ef8ace18ffda8a4dfcbe1bcff3ac30c0c7575c20b/aiohttp-3.13.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:7e5dc4311bd5ac493886c63cbf76ab579dbe4641268e7c74e48e774c74b6f2be", size = 497663, upload-time = "2026-03-31T21:58:52.232Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/91/cc8cc78a111826c54743d88651e1687008133c37e5ee615fee9b57990fac/aiohttp-3.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:756c3c304d394977519824449600adaf2be0ccee76d206ee339c5e76b70ded25", size = 499094, upload-time = "2026-03-31T21:58:54.566Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/33/a8362cb15cf16a3af7e86ed11962d5cd7d59b449202dc576cdc731310bde/aiohttp-3.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecc26751323224cf8186efcf7fbcbc30f4e1d8c7970659daf25ad995e4032a56", size = 1726701, upload-time = "2026-03-31T21:58:56.864Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/0c/c091ac5c3a17114bd76cbf85d674650969ddf93387876cf67f754204bd77/aiohttp-3.13.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10a75acfcf794edf9d8db50e5a7ec5fc818b2a8d3f591ce93bc7b1210df016d2", size = 1683360, upload-time = "2026-03-31T21:58:59.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/73/bcee1c2b79bc275e964d1446c55c54441a461938e70267c86afaae6fba27/aiohttp-3.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f7a18f258d124cd678c5fe072fe4432a4d5232b0657fca7c1847f599233c83a", size = 1773023, upload-time = "2026-03-31T21:59:01.776Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/ef/720e639df03004fee2d869f771799d8c23046dec47d5b81e396c7cda583a/aiohttp-3.13.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df6104c009713d3a89621096f3e3e88cc323fd269dbd7c20afe18535094320be", size = 1853795, upload-time = "2026-03-31T21:59:04.568Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/c9/989f4034fb46841208de7aeeac2c6d8300745ab4f28c42f629ba77c2d916/aiohttp-3.13.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:241a94f7de7c0c3b616627aaad530fe2cb620084a8b144d3be7b6ecfe95bae3b", size = 1730405, upload-time = "2026-03-31T21:59:07.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/75/ee1fd286ca7dc599d824b5651dad7b3be7ff8d9a7e7b3fe9820d9180f7db/aiohttp-3.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c974fb66180e58709b6fc402846f13791240d180b74de81d23913abe48e96d94", size = 1558082, upload-time = "2026-03-31T21:59:09.484Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/20/1e9e6650dfc436340116b7aa89ff8cb2bbdf0abc11dfaceaad8f74273a10/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6e27ea05d184afac78aabbac667450c75e54e35f62238d44463131bd3f96753d", size = 1692346, upload-time = "2026-03-31T21:59:12.068Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/40/8ebc6658d48ea630ac7903912fe0dd4e262f0e16825aa4c833c56c9f1f56/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a79a6d399cef33a11b6f004c67bb07741d91f2be01b8d712d52c75711b1e07c7", size = 1698891, upload-time = "2026-03-31T21:59:14.552Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/78/ea0ae5ec8ba7a5c10bdd6e318f1ba5e76fcde17db8275188772afc7917a4/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c632ce9c0b534fbe25b52c974515ed674937c5b99f549a92127c85f771a78772", size = 1742113, upload-time = "2026-03-31T21:59:17.068Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/66/9d308ed71e3f2491be1acb8769d96c6f0c47d92099f3bc9119cada27b357/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fceedde51fbd67ee2bcc8c0b33d0126cc8b51ef3bbde2f86662bd6d5a6f10ec5", size = 1553088, upload-time = "2026-03-31T21:59:19.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/a6/6cc25ed8dfc6e00c90f5c6d126a98e2cf28957ad06fa1036bd34b6f24a2c/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f92995dfec9420bb69ae629abf422e516923ba79ba4403bc750d94fb4a6c68c1", size = 1757976, upload-time = "2026-03-31T21:59:22.311Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/2b/cce5b0ffe0de99c83e5e36d8f828e4161e415660a9f3e58339d07cce3006/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20ae0ff08b1f2c8788d6fb85afcb798654ae6ba0b747575f8562de738078457b", size = 1712444, upload-time = "2026-03-31T21:59:24.635Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/cf/9e1795b4160c58d29421eafd1a69c6ce351e2f7c8d3c6b7e4ca44aea1a5b/aiohttp-3.13.5-cp314-cp314-win32.whl", hash = "sha256:b20df693de16f42b2472a9c485e1c948ee55524786a0a34345511afdd22246f3", size = 438128, upload-time = "2026-03-31T21:59:27.291Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/4d/eaedff67fc805aeba4ba746aec891b4b24cebb1a7d078084b6300f79d063/aiohttp-3.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:f85c6f327bf0b8c29da7d93b1cabb6363fb5e4e160a32fa241ed2dce21b73162", size = 464029, upload-time = "2026-03-31T21:59:29.429Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/11/c27d9332ee20d68dd164dc12a6ecdef2e2e35ecc97ed6cf0d2442844624b/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:1efb06900858bb618ff5cee184ae2de5828896c448403d51fb633f09e109be0a", size = 778758, upload-time = "2026-03-31T21:59:31.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/fb/377aead2e0a3ba5f09b7624f702a964bdf4f08b5b6728a9799830c80041e/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:fee86b7c4bd29bdaf0d53d14739b08a106fdda809ca5fe032a15f52fae5fe254", size = 512883, upload-time = "2026-03-31T21:59:34.098Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/a6/aa109a33671f7a5d3bd78b46da9d852797c5e665bfda7d6b373f56bff2ec/aiohttp-3.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:20058e23909b9e65f9da62b396b77dfa95965cbe840f8def6e572538b1d32e36", size = 516668, upload-time = "2026-03-31T21:59:36.497Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/b3/ca078f9f2fa9563c36fb8ef89053ea2bb146d6f792c5104574d49d8acb63/aiohttp-3.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cf20a8d6868cb15a73cab329ffc07291ba8c22b1b88176026106ae39aa6df0f", size = 1883461, upload-time = "2026-03-31T21:59:38.723Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/e3/a7ad633ca1ca497b852233a3cce6906a56c3225fb6d9217b5e5e60b7419d/aiohttp-3.13.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:330f5da04c987f1d5bdb8ae189137c77139f36bd1cb23779ca1a354a4b027800", size = 1747661, upload-time = "2026-03-31T21:59:41.187Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/b9/cd6fe579bed34a906d3d783fe60f2fa297ef55b27bb4538438ee49d4dc41/aiohttp-3.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f1cbf0c7926d315c3c26c2da41fd2b5d2fe01ac0e157b78caefc51a782196cf", size = 1863800, upload-time = "2026-03-31T21:59:43.84Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/3f/2c1e2f5144cefa889c8afd5cf431994c32f3b29da9961698ff4e3811b79a/aiohttp-3.13.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:53fc049ed6390d05423ba33103ded7281fe897cf97878f369a527070bd95795b", size = 1958382, upload-time = "2026-03-31T21:59:46.187Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/1d/f31ec3f1013723b3babe3609e7f119c2c2fb6ef33da90061a705ef3e1bc8/aiohttp-3.13.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:898703aa2667e3c5ca4c54ca36cd73f58b7a38ef87a5606414799ebce4d3fd3a", size = 1803724, upload-time = "2026-03-31T21:59:48.656Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/b4/57712dfc6f1542f067daa81eb61da282fab3e6f1966fca25db06c4fc62d5/aiohttp-3.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0494a01ca9584eea1e5fbd6d748e61ecff218c51b576ee1999c23db7066417d8", size = 1640027, upload-time = "2026-03-31T21:59:51.284Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/3c/734c878fb43ec083d8e31bf029daae1beafeae582d1b35da234739e82ee7/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6cf81fe010b8c17b09495cbd15c1d35afbc8fb405c0c9cf4738e5ae3af1d65be", size = 1806644, upload-time = "2026-03-31T21:59:53.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/a5/f671e5cbec1c21d044ff3078223f949748f3a7f86b14e34a365d74a5d21f/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:c564dd5f09ddc9d8f2c2d0a301cd30a79a2cc1b46dd1a73bef8f0038863d016b", size = 1791630, upload-time = "2026-03-31T21:59:56.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/63/fb8d0ad63a0b8a99be97deac8c04dacf0785721c158bdf23d679a87aa99e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2994be9f6e51046c4f864598fd9abeb4fba6e88f0b2152422c9666dcd4aea9c6", size = 1809403, upload-time = "2026-03-31T21:59:59.103Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/0c/bfed7f30662fcf12206481c2aac57dedee43fe1c49275e85b3a1e1742294/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:157826e2fa245d2ef46c83ea8a5faf77ca19355d278d425c29fda0beb3318037", size = 1634924, upload-time = "2026-03-31T22:00:02.116Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/d6/fd518d668a09fd5a3319ae5e984d4d80b9a4b3df4e21c52f02251ef5a32e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a8aca50daa9493e9e13c0f566201a9006f080e7c50e5e90d0b06f53146a54500", size = 1836119, upload-time = "2026-03-31T22:00:04.756Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/b7/15fb7a9d52e112a25b621c67b69c167805cb1f2ab8f1708a5c490d1b52fe/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3b13560160d07e047a93f23aaa30718606493036253d5430887514715b67c9d9", size = 1772072, upload-time = "2026-03-31T22:00:07.494Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/df/57ba7f0c4a553fc2bd8b6321df236870ec6fd64a2a473a8a13d4f733214e/aiohttp-3.13.5-cp314-cp314t-win32.whl", hash = "sha256:9a0f4474b6ea6818b41f82172d799e4b3d29e22c2c520ce4357856fced9af2f8", size = 471819, upload-time = "2026-03-31T22:00:10.277Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/29/2f8418269e46454a26171bfdd6a055d74febf32234e474930f2f60a17145/aiohttp-3.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:18a2f6c1182c51baa1d28d68fea51513cb2a76612f038853c0ad3c145423d3d9", size = 505441, upload-time = "2026-03-31T22:00:12.791Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -274,14 +274,14 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.3.1"
|
||||
version = "8.1.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload-time = "2024-12-21T18:38:41.666Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -644,10 +644,10 @@ requires-dist = [
|
|||
{ name = "coverage", marker = "extra == 'dev'", specifier = ">=7.6.0" },
|
||||
{ name = "datasets", specifier = ">=3.0.0" },
|
||||
{ name = "hypothesis", marker = "extra == 'dev'", specifier = ">=6.88.0" },
|
||||
{ name = "litellm", specifier = ">=1.50.0" },
|
||||
{ name = "litellm", specifier = "!=1.82.7,!=1.82.8,>=1.83.7" },
|
||||
{ name = "mini-swe-agent", specifier = ">=2.0.0" },
|
||||
{ name = "pandas", specifier = ">=2.0.0" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.3" },
|
||||
{ name = "python-dotenv", specifier = ">=1.0.0" },
|
||||
{ name = "pyyaml", specifier = ">=6.0" },
|
||||
{ name = "rich", specifier = ">=13.0.0" },
|
||||
|
|
@ -769,14 +769,14 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "importlib-metadata"
|
||||
version = "9.0.0"
|
||||
version = "8.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "zipp" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cd/12/33e59336dca5be0c398a7482335911a33aa0e20776128f038019f1a95f1b/importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7", size = 55304, upload-time = "2024-09-11T14:56:08.937Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7", size = 27789, upload-time = "2026-03-20T06:42:55.665Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/d9/a1e041c5e7caa9a05c925f4bdbdfb7f006d1f74996af53467bc394c97be7/importlib_metadata-8.5.0-py3-none-any.whl", hash = "sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b", size = 26514, upload-time = "2024-09-11T14:56:07.019Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -887,7 +887,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "jsonschema"
|
||||
version = "4.26.0"
|
||||
version = "4.23.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "attrs" },
|
||||
|
|
@ -895,9 +895,9 @@ dependencies = [
|
|||
{ name = "referencing" },
|
||||
{ name = "rpds-py" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/38/2e/03362ee4034a4c917f697890ccd4aec0800ccf9ded7f511971c75451deec/jsonschema-4.23.0.tar.gz", hash = "sha256:d71497fef26351a33265337fa77ffeb82423f3ea21283cd9467bb03999266bc4", size = 325778, upload-time = "2024-07-08T18:40:05.546Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/4a/4f9dbeb84e8850557c02365a0eee0649abe5eb1d84af92a25731c6c0f922/jsonschema-4.23.0-py3-none-any.whl", hash = "sha256:fbadb6f8b144a8f8cf9f0b89ba94501d143e50411a1278633f56a7acf7fd5566", size = 88462, upload-time = "2024-07-08T18:40:00.165Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -926,7 +926,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "litellm"
|
||||
version = "1.82.6"
|
||||
version = "1.83.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
|
@ -942,9 +942,9 @@ dependencies = [
|
|||
{ name = "tiktoken" },
|
||||
{ name = "tokenizers" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/29/75/1c537aa458426a9127a92bc2273787b2f987f4e5044e21f01f2eed5244fd/litellm-1.82.6.tar.gz", hash = "sha256:2aa1c2da21fe940c33613aa447119674a3ad4d2ad5eb064e4d5ce5ee42420136", size = 17414147, upload-time = "2026-03-22T06:36:00.452Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/77/2b/b58bf6bbcbc3d0e55d0a84fdf9128e5b1436517f46fce89b1cd8948ebb81/litellm-1.83.7.tar.gz", hash = "sha256:e2f2cb99df2e2b2eab63f1354faa45c88dd7c8d40c18eb648afb1b349c689633", size = 17791694, upload-time = "2026-04-13T17:35:01.606Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/02/6c/5327667e6dbe9e98cbfbd4261c8e91386a52e38f41419575854248bbab6a/litellm-1.82.6-py3-none-any.whl", hash = "sha256:164a3ef3e19f309e3cabc199bef3d2045212712fefdfa25fc7f75884a5b5b205", size = 15591595, upload-time = "2026-03-22T06:35:56.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/80/caeb4cdcad96451ba83ad3ba2a9da08b1e1a915fa845c489f56ea044488b/litellm-1.83.7-py3-none-any.whl", hash = "sha256:5784a1d9a9a4a8acd6ca1e347003a5e2e1b3c749b4d41e7da4904577adade111", size = 16069807, upload-time = "2026-04-13T17:34:58.36Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1302,7 +1302,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "openai"
|
||||
version = "2.29.0"
|
||||
version = "2.30.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
|
|
@ -1314,9 +1314,9 @@ dependencies = [
|
|||
{ name = "tqdm" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b4/15/203d537e58986b5673e7f232453a2a2f110f22757b15921cbdeea392e520/openai-2.29.0.tar.gz", hash = "sha256:32d09eb2f661b38d3edd7d7e1a2943d1633f572596febe64c0cd370c86d52bec", size = 671128, upload-time = "2026-03-17T17:53:49.599Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/88/15/52580c8fbc16d0675d516e8749806eda679b16de1e4434ea06fb6feaa610/openai-2.30.0.tar.gz", hash = "sha256:92f7661c990bda4b22a941806c83eabe4896c3094465030dd882a71abe80c885", size = 676084, upload-time = "2026-03-25T22:08:59.96Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/b1/35b6f9c8cf9318e3dbb7146cc82dab4cf61182a8d5406fc9b50864362895/openai-2.29.0-py3-none-any.whl", hash = "sha256:b7c5de513c3286d17c5e29b92c4c98ceaf0d775244ac8159aeb1bddf840eb42a", size = 1141533, upload-time = "2026-03-17T17:53:47.348Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/9e/5bfa2270f902d5b92ab7d41ce0475b8630572e71e349b2a4996d14bdda93/openai-2.30.0-py3-none-any.whl", hash = "sha256:9a5ae616888eb2748ec5e0c5b955a51592e0b201a11f4262db920f2a78c5231d", size = 1146656, upload-time = "2026-03-25T22:08:58.2Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1690,7 +1690,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.2"
|
||||
version = "9.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
|
|
@ -1699,9 +1699,9 @@ dependencies = [
|
|||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1718,11 +1718,11 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "python-dotenv"
|
||||
version = "1.2.2"
|
||||
version = "1.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115, upload-time = "2024-01-23T06:33:00.505Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863, upload-time = "2024-01-23T06:32:58.246Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1900,7 +1900,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.32.5"
|
||||
version = "2.33.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
|
|
@ -1908,9 +1908,9 @@ dependencies = [
|
|||
{ name = "idna" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232, upload-time = "2026-03-25T15:10:41.586Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017, upload-time = "2026-03-25T15:10:40.382Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2224,7 +2224,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "typer"
|
||||
version = "0.24.1"
|
||||
version = "0.23.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-doc" },
|
||||
|
|
@ -2232,9 +2232,9 @@ dependencies = [
|
|||
{ name = "rich" },
|
||||
{ name = "shellingham" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f5/24/cb09efec5cc954f7f9b930bf8279447d24618bb6758d4f6adf2574c41780/typer-0.24.1.tar.gz", hash = "sha256:e39b4732d65fbdcde189ae76cf7cd48aeae72919dea1fdfc16593be016256b45", size = 118613, upload-time = "2026-02-21T16:54:40.609Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fd/07/b822e1b307d40e263e8253d2384cf98c51aa2368cc7ba9a07e523a1d964b/typer-0.23.1.tar.gz", hash = "sha256:2070374e4d31c83e7b61362fd859aa683576432fd5b026b060ad6b4cd3b86134", size = 120047, upload-time = "2026-02-13T10:04:30.984Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e", size = 56085, upload-time = "2026-02-21T16:54:41.616Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/91/9b286ab899c008c2cb05e8be99814807e7fbbd33f0c0c960470826e5ac82/typer-0.23.1-py3-none-any.whl", hash = "sha256:3291ad0d3c701cbf522012faccfbb29352ff16ad262db2139e6b01f15781f14e", size = 56813, upload-time = "2026-02-13T10:04:32.008Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ export interface FinalizeHooks {
|
|||
targetRaw: string,
|
||||
fromFile: string,
|
||||
workspaceIndex: WorkspaceIndex,
|
||||
): string | null;
|
||||
): string | readonly string[] | null;
|
||||
|
||||
/**
|
||||
* For a wildcard `import * from M`, return the names visible in the
|
||||
|
|
@ -127,20 +127,22 @@ export interface FinalizedScc {
|
|||
/**
|
||||
* Counters reported by `finalize`.
|
||||
*
|
||||
* **Counting granularity** — all edge counters are **per-`ParsedImport`**,
|
||||
* not per-materialized-`ImportEdge`. A single `wildcard` ParsedImport that
|
||||
* expands to N exports counts as one linked edge in these stats; the
|
||||
* materialized output (`FinalizeOutput.imports`) will have N edges for
|
||||
* that input. `dynamic-unresolved` ParsedImports count as linked (they
|
||||
* pass through with no `linkStatus`), so `linkedEdges` ≠ "has a
|
||||
* **Counting granularity** — `totalEdges` is **per-generated-`ImportEdgeDraft`**,
|
||||
* which may exceed the number of `ParsedImport` records when
|
||||
* `resolveImportTarget` returns a multi-file array (e.g. Go package-scoped
|
||||
* imports fan out to every `.go` file in the target directory). A single
|
||||
* `wildcard` ParsedImport that expands to N exports also counts as one
|
||||
* linked edge here; the materialized output (`FinalizeOutput.imports`) will
|
||||
* have N edges for that input. `dynamic-unresolved` ParsedImports count as
|
||||
* linked (they pass through with no `linkStatus`), so `linkedEdges` ≠ "has a
|
||||
* BindingRef" — use the `bindings` map for that.
|
||||
*
|
||||
* In other words: `totalEdges === input.parsedImports.length` summed
|
||||
* In other words: `totalEdges >= input.parsedImports.length` summed
|
||||
* across files, and `linkedEdges + unresolvedEdges === totalEdges`.
|
||||
*/
|
||||
export interface FinalizeStats {
|
||||
readonly totalFiles: number;
|
||||
/** Total `ParsedImport` records seen across all files. */
|
||||
/** Total `ImportEdgeDraft` records generated (≥ ParsedImport count). */
|
||||
readonly totalEdges: number;
|
||||
/**
|
||||
* `ParsedImport`s whose finalized edge does NOT carry
|
||||
|
|
@ -179,9 +181,9 @@ export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOu
|
|||
for (const file of input.files) {
|
||||
const drafts: ImportEdgeDraft[] = [];
|
||||
for (const parsed of file.parsedImports) {
|
||||
const draft = makeEdgeDraft(parsed, file, hooks, input.workspaceIndex);
|
||||
drafts.push(draft);
|
||||
totalEdges++;
|
||||
const draftArray = makeEdgeDrafts(parsed, file, hooks, input.workspaceIndex);
|
||||
drafts.push(...draftArray);
|
||||
totalEdges += draftArray.length;
|
||||
}
|
||||
edgeIndex.set(file.filePath, drafts);
|
||||
}
|
||||
|
|
@ -320,12 +322,12 @@ interface ImportEdgeDraft {
|
|||
finalized: ImportEdge | null;
|
||||
}
|
||||
|
||||
function makeEdgeDraft(
|
||||
function makeEdgeDrafts(
|
||||
parsed: ParsedImport,
|
||||
file: FinalizeFile,
|
||||
hooks: FinalizeHooks,
|
||||
workspace: WorkspaceIndex,
|
||||
): ImportEdgeDraft {
|
||||
): ImportEdgeDraft[] {
|
||||
// Dynamic-unresolved passes through — no `BindingRef`, no target file.
|
||||
if (parsed.kind === 'dynamic-unresolved') {
|
||||
const base: ImportEdge = {
|
||||
|
|
@ -334,14 +336,16 @@ function makeEdgeDraft(
|
|||
targetExportedName: '',
|
||||
kind: 'dynamic-unresolved',
|
||||
};
|
||||
return {
|
||||
source: parsed,
|
||||
fromFile: file.filePath,
|
||||
fromScope: file.moduleScope,
|
||||
targetFile: null,
|
||||
base,
|
||||
finalized: base, // already fully finalized
|
||||
};
|
||||
return [
|
||||
{
|
||||
source: parsed,
|
||||
fromFile: file.filePath,
|
||||
fromScope: file.moduleScope,
|
||||
targetFile: null,
|
||||
base,
|
||||
finalized: base, // already fully finalized
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const targetFile = hooks.resolveImportTarget(parsed.targetRaw ?? '', file.filePath, workspace);
|
||||
|
|
@ -355,14 +359,16 @@ function makeEdgeDraft(
|
|||
kind: edgeKindFor(parsed),
|
||||
linkStatus: 'unresolved',
|
||||
};
|
||||
return {
|
||||
source: parsed,
|
||||
fromFile: file.filePath,
|
||||
fromScope: file.moduleScope,
|
||||
targetFile: null,
|
||||
base,
|
||||
finalized: base,
|
||||
};
|
||||
return [
|
||||
{
|
||||
source: parsed,
|
||||
fromFile: file.filePath,
|
||||
fromScope: file.moduleScope,
|
||||
targetFile: null,
|
||||
base,
|
||||
finalized: base,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// Resolvable at the file level; intra-SCC fixpoint may still fail to fill
|
||||
|
|
@ -370,21 +376,24 @@ function makeEdgeDraft(
|
|||
// and resolved-dynamic imports are terminal at the file level — no
|
||||
// `targetDefId` needed since they materialize no `BindingRef`. Pre-
|
||||
// finalize them here so the fixpoint loop skips them entirely.
|
||||
const base: ImportEdge = {
|
||||
localName: extractLocalName(parsed),
|
||||
targetFile,
|
||||
targetExportedName: extractExportedName(parsed),
|
||||
kind: edgeKindFor(parsed),
|
||||
};
|
||||
const targetFiles = Array.isArray(targetFile) ? targetFile : [targetFile];
|
||||
const isFileLevelTerminal = parsed.kind === 'side-effect' || parsed.kind === 'dynamic-resolved';
|
||||
return {
|
||||
source: parsed,
|
||||
fromFile: file.filePath,
|
||||
fromScope: file.moduleScope,
|
||||
targetFile,
|
||||
base,
|
||||
finalized: isFileLevelTerminal ? base : null,
|
||||
};
|
||||
return targetFiles.map((tf) => {
|
||||
const base: ImportEdge = {
|
||||
localName: extractLocalName(parsed),
|
||||
targetFile: tf,
|
||||
targetExportedName: extractExportedName(parsed),
|
||||
kind: edgeKindFor(parsed),
|
||||
};
|
||||
return {
|
||||
source: parsed,
|
||||
fromFile: file.filePath,
|
||||
fromScope: file.moduleScope,
|
||||
targetFile: tf,
|
||||
base,
|
||||
finalized: isFileLevelTerminal ? base : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function edgeKindFor(parsed: ParsedImport): ImportEdge['kind'] {
|
||||
|
|
|
|||
88
gitnexus-web/package-lock.json
generated
88
gitnexus-web/package-lock.json
generated
|
|
@ -18,7 +18,7 @@
|
|||
"@tailwindcss/vite": "^4.2.4",
|
||||
"axios": "^1.13.2",
|
||||
"d3": "^7.9.0",
|
||||
"dompurify": "^3.3.3",
|
||||
"dompurify": "^3.4.2",
|
||||
"gitnexus-shared": "file:../gitnexus-shared",
|
||||
"graphology": "^0.26.0",
|
||||
"graphology-indices": "^0.17.0",
|
||||
|
|
@ -1337,29 +1337,6 @@
|
|||
"mlly": "^1.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/balanced-match": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
|
||||
"integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/brace-expansion": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz",
|
||||
"integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@isaacs/balanced-match": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/fs-minipass": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
|
||||
|
|
@ -2439,9 +2416,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@ts-morph/common/node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
|
|
@ -4502,9 +4479,9 @@
|
|||
"peer": true
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
|
||||
"integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
|
||||
"version": "3.4.2",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.2.tgz",
|
||||
"integrity": "sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
|
|
@ -4845,9 +4822,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
|
|
@ -7077,9 +7054,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/micromatch/node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
|
@ -7121,21 +7098,44 @@
|
|||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "10.1.1",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz",
|
||||
"integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==",
|
||||
"version": "10.2.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"@isaacs/brace-expansion": "^5.0.0"
|
||||
"brace-expansion": "^5.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch/node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch/node_modules/brace-expansion": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
|
|
@ -8242,9 +8242,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/tar": {
|
||||
"version": "7.5.3",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.3.tgz",
|
||||
"integrity": "sha512-ENg5JUHUm2rDD7IvKNFGzyElLXNjachNLp6RaGf4+JOgxXHkqA+gq81ZAMCUmtMtqBsoU62lcp6S27g1LCYGGQ==",
|
||||
"version": "7.5.13",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz",
|
||||
"integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@
|
|||
"@tailwindcss/vite": "^4.2.4",
|
||||
"axios": "^1.13.2",
|
||||
"d3": "^7.9.0",
|
||||
"dompurify": "^3.3.3",
|
||||
"dompurify": "^3.4.2",
|
||||
"graphology": "^0.26.0",
|
||||
"graphology-indices": "^0.17.0",
|
||||
"graphology-layout-force": "^0.2.4",
|
||||
|
|
|
|||
12
gitnexus/package-lock.json
generated
12
gitnexus/package-lock.json
generated
|
|
@ -614,9 +614,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@hono/node-server": {
|
||||
"version": "1.19.11",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz",
|
||||
"integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==",
|
||||
"version": "1.19.14",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
|
||||
"integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.14.1"
|
||||
|
|
@ -3406,9 +3406,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.12.9",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.9.tgz",
|
||||
"integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==",
|
||||
"version": "4.12.16",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.16.tgz",
|
||||
"integrity": "sha512-jN0ZewiNAWSe5khM3EyCmBb250+b40wWbwNILNfEvq84VREWwOIkuUsFONk/3i3nqkz7Oe1PcpM2mwQEK2L9Kg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
|
|
|
|||
|
|
@ -32,15 +32,27 @@ interface SetupResult {
|
|||
*/
|
||||
function resolveGitnexusBin(): string | null {
|
||||
try {
|
||||
const cmd = process.platform === 'win32' ? 'where' : 'which';
|
||||
const resolved = execFileSync(cmd, ['gitnexus'], {
|
||||
const isWin = process.platform === 'win32';
|
||||
const cmd = isWin ? 'where' : 'which';
|
||||
const output = execFileSync(cmd, ['gitnexus'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
})
|
||||
.split('\n')[0]
|
||||
.trim();
|
||||
return resolved || null;
|
||||
});
|
||||
const lines = output
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (isWin) {
|
||||
// On Windows, `where` returns multiple entries (e.g. the POSIX shell
|
||||
// script AND the .cmd/.bat wrapper). Prefer the wrapper because
|
||||
// child_process.spawn() cannot execute a shell script directly.
|
||||
const cmdLine = lines.find((l) => /\.(cmd|bat)$/i.test(l));
|
||||
return cmdLine || lines[0] || null;
|
||||
}
|
||||
|
||||
return lines[0] || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ const DEFAULT_DETECT = {
|
|||
topics: true,
|
||||
shared_libs: true,
|
||||
embedding_fallback: true,
|
||||
workspace_deps: true,
|
||||
workspace_deps: false,
|
||||
};
|
||||
|
||||
const DEFAULT_MATCHING = {
|
||||
|
|
|
|||
253
gitnexus/src/core/group/extractors/elixir-workspace-extractor.ts
Normal file
253
gitnexus/src/core/group/extractors/elixir-workspace-extractor.ts
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type { CypherExecutor } from '../contract-extractor.js';
|
||||
import type { GroupManifestLink, ContractRole } from '../types.js';
|
||||
import { shouldIgnorePath, loadIgnoreRules } from '../../../config/ignore-service.js';
|
||||
|
||||
interface ElixirAppMeta {
|
||||
appName: string;
|
||||
modulePrefix: string;
|
||||
groupPath: string;
|
||||
repoPath: string;
|
||||
deps: string[];
|
||||
}
|
||||
|
||||
interface ImportedModule {
|
||||
appName: string;
|
||||
moduleName: string;
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
async function parseMixExs(
|
||||
repoPath: string,
|
||||
): Promise<{ appName: string; modulePrefix: string; deps: string[] } | null> {
|
||||
const mixPath = path.join(repoPath, 'mix.exs');
|
||||
let content: string;
|
||||
try {
|
||||
content = await fs.readFile(mixPath, 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
// app: :my_app
|
||||
const appMatch = content.match(/app:\s*:(\w+)/);
|
||||
if (!appMatch) return null;
|
||||
const appName = appMatch[1];
|
||||
|
||||
// Derive module prefix: my_app -> MyApp
|
||||
const modulePrefix = appName
|
||||
.split('_')
|
||||
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
|
||||
.join('');
|
||||
|
||||
const deps: string[] = [];
|
||||
|
||||
// {:dep_name, "~> 1.0"} or {:dep_name, in_umbrella: true}
|
||||
// {:dep_name, git: "..."} or {:dep_name, path: "..."}
|
||||
const depMatches = content.matchAll(
|
||||
/\{:(\w+)\s*,\s*(?:"[^"]*"|~[^}]*|[^}]*(?:in_umbrella|path|git)\s*:[^}]*)\}/g,
|
||||
);
|
||||
for (const m of depMatches) {
|
||||
deps.push(m[1]);
|
||||
}
|
||||
|
||||
return { appName, modulePrefix, deps: [...new Set(deps)] };
|
||||
}
|
||||
|
||||
async function scanElixirImports(
|
||||
repoPath: string,
|
||||
knownApps: Map<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 };
|
||||
}
|
||||
258
gitnexus/src/core/group/extractors/go-workspace-extractor.ts
Normal file
258
gitnexus/src/core/group/extractors/go-workspace-extractor.ts
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type { CypherExecutor } from '../contract-extractor.js';
|
||||
import type { GroupManifestLink, ContractRole } from '../types.js';
|
||||
import { shouldIgnorePath, loadIgnoreRules } from '../../../config/ignore-service.js';
|
||||
|
||||
interface GoModuleMeta {
|
||||
modulePath: string;
|
||||
groupPath: string;
|
||||
repoPath: string;
|
||||
requires: string[];
|
||||
}
|
||||
|
||||
interface ImportedSymbol {
|
||||
modulePath: string;
|
||||
symbolName: string;
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
async function parseGoMod(
|
||||
repoPath: string,
|
||||
): Promise<{ modulePath: string; requires: string[] } | null> {
|
||||
const goModPath = path.join(repoPath, 'go.mod');
|
||||
let content: string;
|
||||
try {
|
||||
content = await fs.readFile(goModPath, 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const moduleMatch = content.match(/^module\s+(\S+)/m);
|
||||
if (!moduleMatch) return null;
|
||||
const modulePath = moduleMatch[1];
|
||||
|
||||
const requires: string[] = [];
|
||||
|
||||
// Single-line: require github.com/org/repo v1.2.3
|
||||
const singleReqs = content.matchAll(/^require\s+(\S+)\s+/gm);
|
||||
for (const m of singleReqs) requires.push(m[1]);
|
||||
|
||||
// Block: require ( ... )
|
||||
const blockReqs = content.matchAll(/^require\s*\(\s*\n([\s\S]*?)\)/gm);
|
||||
for (const block of blockReqs) {
|
||||
const lines = block[1].split('\n');
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('//')) continue;
|
||||
const parts = trimmed.split(/\s+/);
|
||||
if (parts[0]) requires.push(parts[0]);
|
||||
}
|
||||
}
|
||||
|
||||
// replace directives (local path deps)
|
||||
const replaceLines = content.matchAll(/^replace\s+(\S+)\s+=>\s+\.\//gm);
|
||||
for (const m of replaceLines) {
|
||||
if (!requires.includes(m[1])) requires.push(m[1]);
|
||||
}
|
||||
|
||||
const replaceBlocks = content.matchAll(/^replace\s*\(\s*\n([\s\S]*?)\)/gm);
|
||||
for (const block of replaceBlocks) {
|
||||
const lines = block[1].split('\n');
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('//')) continue;
|
||||
const match = trimmed.match(/^(\S+)\s+=>\s+\.\//);
|
||||
if (match && !requires.includes(match[1])) requires.push(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return { modulePath, requires: [...new Set(requires)] };
|
||||
}
|
||||
|
||||
async function scanGoImports(
|
||||
repoPath: string,
|
||||
knownModules: Map<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 };
|
||||
}
|
||||
261
gitnexus/src/core/group/extractors/java-workspace-extractor.ts
Normal file
261
gitnexus/src/core/group/extractors/java-workspace-extractor.ts
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type { CypherExecutor } from '../contract-extractor.js';
|
||||
import type { GroupManifestLink, ContractRole } from '../types.js';
|
||||
import { shouldIgnorePath, loadIgnoreRules } from '../../../config/ignore-service.js';
|
||||
|
||||
interface JavaProjectMeta {
|
||||
groupId: string;
|
||||
artifactId: string;
|
||||
basePackage: string;
|
||||
groupPath: string;
|
||||
repoPath: string;
|
||||
deps: string[];
|
||||
}
|
||||
|
||||
interface ImportedSymbol {
|
||||
artifactKey: string;
|
||||
symbolName: string;
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
async function parseJavaManifest(
|
||||
repoPath: string,
|
||||
): Promise<{ groupId: string; artifactId: string; deps: string[] } | null> {
|
||||
const pomPath = path.join(repoPath, 'pom.xml');
|
||||
try {
|
||||
const content = await fs.readFile(pomPath, 'utf-8');
|
||||
return parsePom(content);
|
||||
} catch {
|
||||
// fall through to Gradle
|
||||
}
|
||||
|
||||
for (const name of ['build.gradle.kts', 'build.gradle']) {
|
||||
const gradlePath = path.join(repoPath, name);
|
||||
try {
|
||||
const content = await fs.readFile(gradlePath, 'utf-8');
|
||||
return parseGradle(content, repoPath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parsePom(content: string): { groupId: string; artifactId: string; deps: string[] } | null {
|
||||
const projectGroupMatch = content.match(/<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 };
|
||||
}
|
||||
|
|
@ -269,17 +269,19 @@ export class ManifestExtractor {
|
|||
{ contract: link.contract },
|
||||
);
|
||||
} else if (link.type === 'custom') {
|
||||
// V1: exact name-only match on code-definition nodes.
|
||||
// Positive allowlist mirrors other contract types. If multiple code
|
||||
// symbols share the same name, ORDER BY filePath ASC LIMIT 1 picks
|
||||
// the alphabetically-first occurrence deterministically.
|
||||
// Workspace extractors produce qualified contracts like "mathlex::Expression".
|
||||
// Graph nodes store the unqualified symbol name ("Expression"), so strip
|
||||
// the "provider::" prefix before querying.
|
||||
const symbolName = link.contract.includes('::')
|
||||
? link.contract.split('::').pop()!
|
||||
: link.contract;
|
||||
rows = await executor(
|
||||
`MATCH (n:Function|Method|Class|Interface|Struct|Enum|Trait|Constructor|TypeAlias|Impl|Macro|Union|Typedef|Property|Record|Delegate|Annotation|Template|Const|Static|CodeElement)
|
||||
WHERE n.name = $contract
|
||||
WHERE n.name = $symbolName
|
||||
RETURN n.id AS uid, n.name AS name, n.filePath AS filePath
|
||||
ORDER BY n.filePath ASC
|
||||
LIMIT 1`,
|
||||
{ contract: link.contract },
|
||||
{ symbolName },
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
|
|
|
|||
248
gitnexus/src/core/group/extractors/node-workspace-extractor.ts
Normal file
248
gitnexus/src/core/group/extractors/node-workspace-extractor.ts
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type { CypherExecutor } from '../contract-extractor.js';
|
||||
import type { GroupManifestLink, ContractRole } from '../types.js';
|
||||
import { shouldIgnorePath, loadIgnoreRules } from '../../../config/ignore-service.js';
|
||||
|
||||
interface PackageMeta {
|
||||
name: string;
|
||||
groupPath: string;
|
||||
repoPath: string;
|
||||
workspaceDeps: string[];
|
||||
}
|
||||
|
||||
interface ImportedSymbol {
|
||||
packageName: string;
|
||||
symbolName: string;
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
async function parsePackageManifest(
|
||||
repoPath: string,
|
||||
): Promise<{ name: string; workspaceDeps: string[] } | null> {
|
||||
const pkgPath = path.join(repoPath, 'package.json');
|
||||
let content: string;
|
||||
try {
|
||||
content = await fs.readFile(pkgPath, 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
let pkg: Record<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 };
|
||||
}
|
||||
254
gitnexus/src/core/group/extractors/python-workspace-extractor.ts
Normal file
254
gitnexus/src/core/group/extractors/python-workspace-extractor.ts
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type { CypherExecutor } from '../contract-extractor.js';
|
||||
import type { GroupManifestLink, ContractRole } from '../types.js';
|
||||
import { shouldIgnorePath, loadIgnoreRules } from '../../../config/ignore-service.js';
|
||||
|
||||
interface PythonPackageMeta {
|
||||
name: string;
|
||||
importName: string;
|
||||
groupPath: string;
|
||||
repoPath: string;
|
||||
workspaceDeps: string[];
|
||||
}
|
||||
|
||||
interface ImportedSymbol {
|
||||
packageName: string;
|
||||
symbolName: string;
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
async function parsePythonManifest(
|
||||
repoPath: string,
|
||||
): Promise<{ name: string; importName: string; deps: string[] } | null> {
|
||||
const pyprojectPath = path.join(repoPath, 'pyproject.toml');
|
||||
let content: string | null = null;
|
||||
try {
|
||||
content = await fs.readFile(pyprojectPath, 'utf-8');
|
||||
} catch {
|
||||
// fall through to setup.py
|
||||
}
|
||||
|
||||
if (content) return parsePyproject(content);
|
||||
|
||||
const setupPyPath = path.join(repoPath, 'setup.py');
|
||||
try {
|
||||
content = await fs.readFile(setupPyPath, 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return parseSetupPy(content);
|
||||
}
|
||||
|
||||
function parsePyproject(
|
||||
content: string,
|
||||
): { name: string; importName: string; deps: string[] } | null {
|
||||
const nameMatch = content.match(/^\[project\]\s*\n(?:[^\n\[]*\n)*?name\s*=\s*"([^"]+)"/m);
|
||||
if (!nameMatch) return null;
|
||||
const name = nameMatch[1];
|
||||
const importName = name.replace(/-/g, '_');
|
||||
|
||||
const deps: string[] = [];
|
||||
const depsMatch = content.match(/^\[project\]\s*\n[\s\S]*?dependencies\s*=\s*\[([\s\S]*?)\]/m);
|
||||
if (depsMatch) {
|
||||
const depLines = depsMatch[1].matchAll(/"([^"]+)"/g);
|
||||
for (const m of depLines) {
|
||||
deps.push(extractPepName(m[1]));
|
||||
}
|
||||
}
|
||||
|
||||
const optMatch = content.match(/\[project\.optional-dependencies\]\s*\n([\s\S]*?)(?=\n\[|$)/);
|
||||
if (optMatch) {
|
||||
const optDeps = optMatch[1].matchAll(/"([^"]+)"/g);
|
||||
for (const m of optDeps) {
|
||||
deps.push(extractPepName(m[1]));
|
||||
}
|
||||
}
|
||||
|
||||
return { name, importName, deps: [...new Set(deps)] };
|
||||
}
|
||||
|
||||
function parseSetupPy(
|
||||
content: string,
|
||||
): { name: string; importName: string; deps: string[] } | null {
|
||||
const nameMatch = content.match(/name\s*=\s*['"]([^'"]+)['"]/);
|
||||
if (!nameMatch) return null;
|
||||
const name = nameMatch[1];
|
||||
const importName = name.replace(/-/g, '_');
|
||||
|
||||
const deps: string[] = [];
|
||||
const installMatch = content.match(/install_requires\s*=\s*\[([\s\S]*?)\]/);
|
||||
if (installMatch) {
|
||||
const depLines = installMatch[1].matchAll(/['"]([^'"]+)['"]/g);
|
||||
for (const m of depLines) {
|
||||
deps.push(extractPepName(m[1]));
|
||||
}
|
||||
}
|
||||
|
||||
return { name, importName, deps: [...new Set(deps)] };
|
||||
}
|
||||
|
||||
function extractPepName(spec: string): string {
|
||||
return spec.split(/[><=!~;\[]/)[0].trim();
|
||||
}
|
||||
|
||||
async function scanPythonImports(
|
||||
repoPath: string,
|
||||
knownPackages: Map<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 };
|
||||
}
|
||||
90
gitnexus/src/core/group/extractors/workspace-extractor.ts
Normal file
90
gitnexus/src/core/group/extractors/workspace-extractor.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import type { CypherExecutor } from '../contract-extractor.js';
|
||||
import type { GroupManifestLink } from '../types.js';
|
||||
import { extractRustWorkspaceLinks } from './rust-workspace-extractor.js';
|
||||
import { extractNodeWorkspaceLinks } from './node-workspace-extractor.js';
|
||||
import { extractPythonWorkspaceLinks } from './python-workspace-extractor.js';
|
||||
import { extractGoWorkspaceLinks } from './go-workspace-extractor.js';
|
||||
import { extractJavaWorkspaceLinks } from './java-workspace-extractor.js';
|
||||
import { extractElixirWorkspaceLinks } from './elixir-workspace-extractor.js';
|
||||
|
||||
export interface WorkspaceDiscoveryResult {
|
||||
links: GroupManifestLink[];
|
||||
stats: WorkspaceExtractorStats[];
|
||||
}
|
||||
|
||||
interface WorkspaceExtractorStats {
|
||||
ecosystem: string;
|
||||
linkCount: number;
|
||||
projectCount: number;
|
||||
}
|
||||
|
||||
export async function discoverWorkspaceLinks(
|
||||
repos: Record<string, string>,
|
||||
repoPaths: Map<string, string>,
|
||||
dbExecutors?: Map<string, CypherExecutor>,
|
||||
): Promise<WorkspaceDiscoveryResult> {
|
||||
const links: GroupManifestLink[] = [];
|
||||
const stats: WorkspaceExtractorStats[] = [];
|
||||
|
||||
const rustResult = await extractRustWorkspaceLinks(repos, repoPaths, dbExecutors);
|
||||
if (rustResult.links.length > 0) {
|
||||
links.push(...rustResult.links);
|
||||
stats.push({
|
||||
ecosystem: 'Rust',
|
||||
linkCount: rustResult.links.length,
|
||||
projectCount: rustResult.discoveredCrates.size,
|
||||
});
|
||||
}
|
||||
|
||||
const nodeResult = await extractNodeWorkspaceLinks(repos, repoPaths, dbExecutors);
|
||||
if (nodeResult.links.length > 0) {
|
||||
links.push(...nodeResult.links);
|
||||
stats.push({
|
||||
ecosystem: 'Node',
|
||||
linkCount: nodeResult.links.length,
|
||||
projectCount: nodeResult.discoveredPackages.size,
|
||||
});
|
||||
}
|
||||
|
||||
const pyResult = await extractPythonWorkspaceLinks(repos, repoPaths, dbExecutors);
|
||||
if (pyResult.links.length > 0) {
|
||||
links.push(...pyResult.links);
|
||||
stats.push({
|
||||
ecosystem: 'Python',
|
||||
linkCount: pyResult.links.length,
|
||||
projectCount: pyResult.discoveredPackages.size,
|
||||
});
|
||||
}
|
||||
|
||||
const goResult = await extractGoWorkspaceLinks(repos, repoPaths, dbExecutors);
|
||||
if (goResult.links.length > 0) {
|
||||
links.push(...goResult.links);
|
||||
stats.push({
|
||||
ecosystem: 'Go',
|
||||
linkCount: goResult.links.length,
|
||||
projectCount: goResult.discoveredModules.size,
|
||||
});
|
||||
}
|
||||
|
||||
const javaResult = await extractJavaWorkspaceLinks(repos, repoPaths, dbExecutors);
|
||||
if (javaResult.links.length > 0) {
|
||||
links.push(...javaResult.links);
|
||||
stats.push({
|
||||
ecosystem: 'Java',
|
||||
linkCount: javaResult.links.length,
|
||||
projectCount: javaResult.discoveredProjects.size,
|
||||
});
|
||||
}
|
||||
|
||||
const elixirResult = await extractElixirWorkspaceLinks(repos, repoPaths, dbExecutors);
|
||||
if (elixirResult.links.length > 0) {
|
||||
links.push(...elixirResult.links);
|
||||
stats.push({
|
||||
ecosystem: 'Elixir',
|
||||
linkCount: elixirResult.links.length,
|
||||
projectCount: elixirResult.discoveredApps.size,
|
||||
});
|
||||
}
|
||||
|
||||
return { links, stats };
|
||||
}
|
||||
|
|
@ -10,7 +10,7 @@ import { TopicExtractor } from './extractors/topic-extractor.js';
|
|||
import { ManifestExtractor } from './extractors/manifest-extractor.js';
|
||||
import { CodeDepExtractor } from './extractors/code-dep-extractor.js';
|
||||
import { readNpmManifest } from './extractors/manifest-reader.js';
|
||||
import { extractRustWorkspaceLinks } from './extractors/rust-workspace-extractor.js';
|
||||
import { discoverWorkspaceLinks } from './extractors/workspace-extractor.js';
|
||||
import { runExactMatch } from './matching.js';
|
||||
import { detectServiceBoundaries, assignService } from './service-boundary-detector.js';
|
||||
import type { CypherExecutor } from './contract-extractor.js';
|
||||
|
|
@ -235,13 +235,15 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
|
|||
if (e) repoPaths.set(groupPath, e.path);
|
||||
}
|
||||
|
||||
const wsResult = await extractRustWorkspaceLinks(config.repos, repoPaths, dbExecutors);
|
||||
const wsResult = await discoverWorkspaceLinks(config.repos, repoPaths, dbExecutors);
|
||||
if (wsResult.links.length > 0) {
|
||||
allLinks = [...allLinks, ...wsResult.links];
|
||||
if (opts?.verbose) {
|
||||
console.log(
|
||||
` workspace-deps: discovered ${wsResult.links.length} cross-crate links from ${wsResult.discoveredCrates.size} Rust crates`,
|
||||
);
|
||||
for (const s of wsResult.stats) {
|
||||
console.log(
|
||||
` workspace-deps: discovered ${s.linkCount} cross-${s.ecosystem.toLowerCase()} links from ${s.projectCount} ${s.ecosystem} projects`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,15 @@ import { createCallExtractor } from '../call-extractors/generic.js';
|
|||
import { goCallConfig } from '../call-extractors/configs/go.js';
|
||||
import { createHeritageExtractor } from '../heritage-extractors/generic.js';
|
||||
import { goHeritageConfig } from '../heritage-extractors/configs/go.js';
|
||||
import {
|
||||
emitGoScopeCaptures,
|
||||
goArityCompatibility,
|
||||
goBindingScopeFor,
|
||||
goImportOwningScope,
|
||||
goReceiverBinding,
|
||||
interpretGoImport,
|
||||
interpretGoTypeBinding,
|
||||
} from './go/index.js';
|
||||
|
||||
export const goProvider = defineLanguage({
|
||||
id: SupportedLanguages.Go,
|
||||
|
|
@ -83,4 +92,15 @@ export const goProvider = defineLanguage({
|
|||
variableExtractor: createVariableExtractor(goVariableConfig),
|
||||
classExtractor: createClassExtractor(goClassConfig),
|
||||
heritageExtractor: createHeritageExtractor(goHeritageConfig),
|
||||
|
||||
// ── RFC #909 Ring 3: scope-based resolution hooks ──────────
|
||||
emitScopeCaptures: emitGoScopeCaptures,
|
||||
interpretImport: interpretGoImport,
|
||||
interpretTypeBinding: interpretGoTypeBinding,
|
||||
bindingScopeFor: goBindingScopeFor,
|
||||
importOwningScope: goImportOwningScope,
|
||||
receiverBinding: goReceiverBinding,
|
||||
arityCompatibility: goArityCompatibility,
|
||||
// resolveImportTarget lives on ScopeResolver (4-param signature),
|
||||
// not on LanguageProvider (2-param signature). See go/scope-resolver.ts.
|
||||
});
|
||||
|
|
|
|||
46
gitnexus/src/core/ingestion/languages/go/arity-metadata.ts
Normal file
46
gitnexus/src/core/ingestion/languages/go/arity-metadata.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
|
||||
export interface GoArityMetadata {
|
||||
readonly parameterCount?: number;
|
||||
readonly requiredParameterCount?: number;
|
||||
readonly parameterTypes?: readonly string[];
|
||||
}
|
||||
|
||||
export function computeGoDeclarationArity(node: SyntaxNode): GoArityMetadata {
|
||||
const params = node.childForFieldName('parameters');
|
||||
if (params === null) return {};
|
||||
|
||||
let count = 0;
|
||||
let required = 0;
|
||||
const types: string[] = [];
|
||||
|
||||
for (let i = 0; i < params.namedChildCount; i++) {
|
||||
const param = params.namedChild(i);
|
||||
if (param === null) continue;
|
||||
if (param.type === 'parameter_declaration') {
|
||||
const typeNode = param.childForFieldName('type');
|
||||
const typeName = typeNode === null ? '' : typeNode.text;
|
||||
const names = param.namedChildren.filter((c) => c.type === 'identifier');
|
||||
const n = Math.max(1, names.length);
|
||||
for (let j = 0; j < n; j++) {
|
||||
count++;
|
||||
required++;
|
||||
types.push(typeName);
|
||||
}
|
||||
}
|
||||
if (param.type === 'variadic_parameter_declaration') {
|
||||
const typeNode = param.childForFieldName('type');
|
||||
const typeName = typeNode === null ? '...' : `...${typeNode.text}`;
|
||||
count++;
|
||||
types.push(typeName);
|
||||
}
|
||||
}
|
||||
|
||||
return { parameterCount: count, requiredParameterCount: required, parameterTypes: types };
|
||||
}
|
||||
|
||||
export function computeGoCallArity(callNode: SyntaxNode): number {
|
||||
const args = callNode.childForFieldName('arguments');
|
||||
if (args === null) return 0;
|
||||
return args.namedChildCount;
|
||||
}
|
||||
16
gitnexus/src/core/ingestion/languages/go/arity.ts
Normal file
16
gitnexus/src/core/ingestion/languages/go/arity.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import type { Callsite, SymbolDefinition } from 'gitnexus-shared';
|
||||
|
||||
export function goArityCompatibility(
|
||||
def: SymbolDefinition,
|
||||
callsite: Callsite,
|
||||
): 'compatible' | 'unknown' | 'incompatible' {
|
||||
const max = def.parameterCount;
|
||||
const min = def.requiredParameterCount;
|
||||
if (max === undefined && min === undefined) return 'unknown';
|
||||
if (!Number.isFinite(callsite.arity) || callsite.arity < 0) return 'unknown';
|
||||
|
||||
const variadic = def.parameterTypes?.some((t) => t.startsWith('...')) ?? false;
|
||||
if (min !== undefined && callsite.arity < min) return 'incompatible';
|
||||
if (max !== undefined && callsite.arity > max && !variadic) return 'incompatible';
|
||||
return 'compatible';
|
||||
}
|
||||
18
gitnexus/src/core/ingestion/languages/go/cache-stats.ts
Normal file
18
gitnexus/src/core/ingestion/languages/go/cache-stats.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
let hits = 0;
|
||||
let misses = 0;
|
||||
|
||||
export function recordGoCacheHit(): void {
|
||||
hits++;
|
||||
}
|
||||
export function recordGoCacheMiss(): void {
|
||||
misses++;
|
||||
}
|
||||
|
||||
export function getGoCaptureCacheStats(): { readonly hits: number; readonly misses: number } {
|
||||
return { hits, misses };
|
||||
}
|
||||
|
||||
export function resetGoCaptureCacheStats(): void {
|
||||
hits = 0;
|
||||
misses = 0;
|
||||
}
|
||||
168
gitnexus/src/core/ingestion/languages/go/captures.ts
Normal file
168
gitnexus/src/core/ingestion/languages/go/captures.ts
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
import type { Capture, CaptureMatch } from 'gitnexus-shared';
|
||||
import {
|
||||
findNodeAtRange,
|
||||
nodeToCapture,
|
||||
syntheticCapture,
|
||||
type SyntaxNode,
|
||||
} from '../../utils/ast-helpers.js';
|
||||
import { getGoParser, getGoScopeQuery } from './query.js';
|
||||
import { recordGoCacheHit, recordGoCacheMiss } from './cache-stats.js';
|
||||
import { computeGoCallArity, computeGoDeclarationArity } from './arity-metadata.js';
|
||||
import { splitGoImportStatement } from './import-decomposer.js';
|
||||
import { synthesizeGoReceiverBinding } from './receiver-binding.js';
|
||||
import { synthesizeGoTypeBindings } from './type-binding.js';
|
||||
import { getTreeSitterBufferSize } from '../../constants.js';
|
||||
|
||||
export function emitGoScopeCaptures(
|
||||
sourceText: string,
|
||||
_filePath: string,
|
||||
cachedTree?: unknown,
|
||||
): readonly CaptureMatch[] {
|
||||
let tree = cachedTree as ReturnType<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
|
||||
);
|
||||
}
|
||||
99
gitnexus/src/core/ingestion/languages/go/expand-wildcards.ts
Normal file
99
gitnexus/src/core/ingestion/languages/go/expand-wildcards.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import type { BindingRef, ParsedFile, ScopeId, SymbolDefinition } from 'gitnexus-shared';
|
||||
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
|
||||
|
||||
/**
|
||||
* Expand Go dot imports (`import . "pkg"`) into binding augmentations.
|
||||
*
|
||||
* Go dot imports are treated as wildcard imports in the scope model.
|
||||
* The shared `expandsWildcardTo` hook defaults to returning `[]` for Go
|
||||
* because it can't easily access the target module's exported defs
|
||||
* (it only receives a `ScopeId`). Instead we post-process wildcard
|
||||
* import edges and augment bindings with the target file's exported
|
||||
* (uppercase) defs — the same augmentation channel used by
|
||||
* `populateGoPackageSiblings` for same-package cross-file visibility.
|
||||
*/
|
||||
export function expandGoDotImports(
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
indexes: ScopeResolutionIndexes,
|
||||
): void {
|
||||
const augmentations = indexes.bindingAugmentations as Map<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 ?? '';
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import type { CaptureMatch } from 'gitnexus-shared';
|
||||
import { syntheticCapture } from '../../utils/ast-helpers.js';
|
||||
import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
|
||||
export function splitGoImportStatement(node: SyntaxNode): CaptureMatch[] {
|
||||
if (node.type === 'import_declaration') {
|
||||
const out: CaptureMatch[] = [];
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (child?.type === 'import_spec') out.push(...splitGoImportStatement(child));
|
||||
if (child?.type === 'import_spec_list') {
|
||||
for (let j = 0; j < child.namedChildCount; j++) {
|
||||
const spec = child.namedChild(j);
|
||||
if (spec?.type === 'import_spec') out.push(...splitGoImportStatement(spec));
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
if (node.type !== 'import_spec') return [];
|
||||
const pathNode = node.childForFieldName('path');
|
||||
if (pathNode === null) return [];
|
||||
|
||||
const rawPath = pathNode.text.replace(/^"|"$/g, '').replace(/^`|`$/g, '');
|
||||
const nameNode = node.childForFieldName('name');
|
||||
const alias = nameNode?.text;
|
||||
const leaf = rawPath.split('/').filter(Boolean).pop() ?? rawPath;
|
||||
const kind =
|
||||
alias === '.' ? 'dot' : alias === '_' ? 'blank' : alias === undefined ? 'namespace' : 'alias';
|
||||
|
||||
// Blank imports (import _ "pkg") are dropped in V1 — they represent
|
||||
// side-effect registrations (e.g. database drivers), but emitting
|
||||
// side-effect edges is deferred. See test: go-imports.test.ts.
|
||||
if (kind === 'blank') return [];
|
||||
|
||||
const aliased = alias !== undefined && alias !== '.' && alias !== '_';
|
||||
return [
|
||||
{
|
||||
'@import.statement': syntheticCapture('@import.statement', node, node.text),
|
||||
'@import.kind': syntheticCapture('@import.kind', node, kind),
|
||||
'@import.source': syntheticCapture('@import.source', pathNode, rawPath),
|
||||
'@import.name': syntheticCapture(
|
||||
'@import.name',
|
||||
nameNode ?? pathNode,
|
||||
aliased ? alias! : leaf,
|
||||
),
|
||||
...(aliased ? { '@import.alias': syntheticCapture('@import.alias', nameNode!, alias!) } : {}),
|
||||
},
|
||||
];
|
||||
}
|
||||
83
gitnexus/src/core/ingestion/languages/go/import-target.ts
Normal file
83
gitnexus/src/core/ingestion/languages/go/import-target.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import type { GoModuleConfig } from '../../language-config.js';
|
||||
|
||||
/**
|
||||
* Resolve a Go import path to ALL .go files in the matching package directory.
|
||||
*
|
||||
* Go packages are directory-scoped: one import statement brings in every
|
||||
* (non-test) .go file in the package directory. Return all matching files so
|
||||
* the shared finalize pass creates one ImportEdge per file — enabling both
|
||||
* IMPORTS edge fanout AND binding materialization for every exported symbol in
|
||||
* the package.
|
||||
*
|
||||
* Strategy (first match wins):
|
||||
* 1. go.mod-based: strip module prefix, match package directory
|
||||
* 2. Non-go.mod / GOPATH: progressively shorter directory suffixes
|
||||
*/
|
||||
export function resolveGoImportTarget(
|
||||
targetRaw: string,
|
||||
_fromFile: string,
|
||||
allFilePaths: ReadonlySet<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;
|
||||
}
|
||||
17
gitnexus/src/core/ingestion/languages/go/index.ts
Normal file
17
gitnexus/src/core/ingestion/languages/go/index.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
/**
|
||||
* Go scope-resolution hooks (RFC #909 Ring 3).
|
||||
*/
|
||||
export { emitGoScopeCaptures } from './captures.js';
|
||||
export { getGoCaptureCacheStats, resetGoCaptureCacheStats } from './cache-stats.js';
|
||||
export { interpretGoImport, interpretGoTypeBinding, normalizeGoTypeName } from './interpret.js';
|
||||
export { splitGoImportStatement } from './import-decomposer.js';
|
||||
export { synthesizeGoReceiverBinding } from './receiver-binding.js';
|
||||
export { synthesizeGoTypeBindings } from './type-binding.js';
|
||||
export { goArityCompatibility } from './arity.js';
|
||||
export { goMergeBindings } from './merge-bindings.js';
|
||||
export { goBindingScopeFor, goImportOwningScope, goReceiverBinding } from './simple-hooks.js';
|
||||
export { resolveGoImportTarget, type GoResolveContext } from './import-target.js';
|
||||
export { populateGoPackageSiblings } from './package-siblings.js';
|
||||
export { populateGoRangeBindings } from './range-binding.js';
|
||||
export { detectGoInterfaceImplementations } from './interface-impls.js';
|
||||
export { mirrorGoNamespaceTypeBindings } from './namespace-mirror.js';
|
||||
87
gitnexus/src/core/ingestion/languages/go/interface-impls.ts
Normal file
87
gitnexus/src/core/ingestion/languages/go/interface-impls.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared';
|
||||
import type { SemanticModel } from '../../model/semantic-model.js';
|
||||
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
|
||||
|
||||
export function detectGoInterfaceImplementations(
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
_indexes: ScopeResolutionIndexes,
|
||||
_model: SemanticModel,
|
||||
): Map<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;
|
||||
}
|
||||
124
gitnexus/src/core/ingestion/languages/go/interpret.ts
Normal file
124
gitnexus/src/core/ingestion/languages/go/interpret.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import type { CaptureMatch, ParsedImport, ParsedTypeBinding, TypeRef } from 'gitnexus-shared';
|
||||
|
||||
export function interpretGoImport(captures: CaptureMatch): ParsedImport | null {
|
||||
const kind = captures['@import.kind']?.text;
|
||||
const source = captures['@import.source']?.text;
|
||||
const name = captures['@import.name']?.text;
|
||||
const alias = captures['@import.alias']?.text;
|
||||
if (kind === undefined || source === undefined) return null;
|
||||
|
||||
if (kind === 'dot') return { kind: 'wildcard', targetRaw: source };
|
||||
if (kind === 'alias') {
|
||||
if (alias === undefined || name === undefined) return null;
|
||||
return { kind: 'namespace', localName: alias, importedName: name, targetRaw: source };
|
||||
}
|
||||
if (kind === 'namespace') {
|
||||
if (name === undefined) return null;
|
||||
return { kind: 'namespace', localName: name, importedName: name, targetRaw: source };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function interpretGoTypeBinding(captures: CaptureMatch): ParsedTypeBinding | null {
|
||||
const name = captures['@type-binding.name']?.text;
|
||||
const type = captures['@type-binding.type']?.text;
|
||||
if (name === undefined || type === undefined) return null;
|
||||
|
||||
let source: TypeRef['source'] = 'annotation';
|
||||
let normalizedType: string;
|
||||
if (captures['@type-binding.self'] !== undefined) {
|
||||
source = 'self';
|
||||
normalizedType = normalizeGoTypeName(type);
|
||||
} else if (captures['@type-binding.constructor'] !== undefined) {
|
||||
source = 'constructor-inferred';
|
||||
normalizedType = normalizeGoTypeName(type);
|
||||
} else if (captures['@type-binding.call-return'] !== undefined) {
|
||||
source = 'constructor-inferred';
|
||||
normalizedType = normalizeGoTypeName(type);
|
||||
} else if (captures['@type-binding.assertion'] !== undefined) {
|
||||
source = 'annotation';
|
||||
normalizedType = normalizeGoTypeName(type);
|
||||
} else if (captures['@type-binding.new'] !== undefined) {
|
||||
source = 'constructor-inferred';
|
||||
normalizedType = normalizeGoTypeName(type);
|
||||
} else if (captures['@type-binding.field'] !== undefined) {
|
||||
source = 'assignment-inferred';
|
||||
normalizedType = normalizeGoTypeName(type);
|
||||
} else if (captures['@type-binding.range'] !== undefined) {
|
||||
source = 'constructor-inferred';
|
||||
normalizedType = normalizeGoTypeName(type);
|
||||
} else if (captures['@type-binding.index'] !== undefined) {
|
||||
source = 'constructor-inferred';
|
||||
normalizedType = normalizeGoTypeName(type);
|
||||
} else if (captures['@type-binding.multi-assign'] !== undefined) {
|
||||
source = 'constructor-inferred';
|
||||
normalizedType = normalizeGoTypeName(type);
|
||||
} else if (captures['@type-binding.make'] !== undefined) {
|
||||
source = 'constructor-inferred';
|
||||
normalizedType = normalizeGoTypeName(type);
|
||||
} else if (captures['@type-binding.return'] !== undefined) {
|
||||
// Preserve dotted names for cross-package return-type chains.
|
||||
source = 'return-annotation';
|
||||
normalizedType = normalizeGoReturnType(type);
|
||||
} else if (captures['@type-binding.alias'] !== undefined) {
|
||||
source = 'assignment-inferred';
|
||||
normalizedType = normalizeGoTypeName(type);
|
||||
} else if (captures['@type-binding.assignment'] !== undefined) {
|
||||
source = 'assignment-inferred';
|
||||
normalizedType = normalizeGoTypeName(type);
|
||||
} else if (captures['@type-binding.parameter'] !== undefined) {
|
||||
source = 'parameter-annotation';
|
||||
normalizedType = normalizeGoTypeName(type);
|
||||
} else {
|
||||
normalizedType = normalizeGoTypeName(type);
|
||||
}
|
||||
|
||||
return { boundName: name, rawTypeName: normalizedType, source };
|
||||
}
|
||||
|
||||
export function normalizeGoTypeName(text: string): string {
|
||||
let t = text.trim();
|
||||
while (t.startsWith('*')) t = t.slice(1).trim();
|
||||
if (t.startsWith('[]')) t = t.slice(2).trim();
|
||||
const mapMatch = t.match(/^map\[[^\]]+\]\s*(.+)$/);
|
||||
if (mapMatch) t = mapMatch[1].trim();
|
||||
t = t.replace(/^(?:<-)?chan\s+/, '');
|
||||
if (t.startsWith('func(')) {
|
||||
const retMatch = t.match(/^func\([^)]*\)\s*(.*)$/);
|
||||
if (retMatch) t = retMatch[1].trim();
|
||||
}
|
||||
const dot = t.lastIndexOf('.');
|
||||
if (dot !== -1) t = t.slice(dot + 1);
|
||||
const bracket = t.indexOf('[');
|
||||
if (bracket !== -1) t = t.slice(0, bracket);
|
||||
return t;
|
||||
}
|
||||
|
||||
/**
|
||||
* Like `normalizeGoTypeName` but preserves dotted package-prefix
|
||||
* (`models.User` stays `models.User`). Used for return-type
|
||||
* annotations so cross-package type chains can resolve through
|
||||
* QualifiedNameIndex (which carries `pkg.Type` entries).
|
||||
*/
|
||||
export function normalizeGoReturnType(text: string): string {
|
||||
let t = text.trim();
|
||||
// Multi-return syntax: (*T, error) → extract first type. Handles
|
||||
// the common Go pattern where functions return (value, error).
|
||||
if (t.startsWith('(') && t.includes(',')) {
|
||||
const closeIdx = t.indexOf(',');
|
||||
t = t.slice(1, closeIdx).trim();
|
||||
}
|
||||
while (t.startsWith('*')) t = t.slice(1).trim();
|
||||
if (t.startsWith('[]')) t = t.slice(2).trim();
|
||||
const mapMatch = t.match(/^map\[[^\]]+\]\s*(.+)$/);
|
||||
if (mapMatch) t = mapMatch[1].trim();
|
||||
t = t.replace(/^(?:<-)?chan\s+/, '');
|
||||
if (t.startsWith('func(')) {
|
||||
const retMatch = t.match(/^func\([^)]*\)\s*(.*)$/);
|
||||
if (retMatch) t = retMatch[1].trim();
|
||||
}
|
||||
// Preserve dotted qualified names for cross-package resolution.
|
||||
const bracket = t.indexOf('[');
|
||||
if (bracket !== -1) t = t.slice(0, bracket);
|
||||
return t;
|
||||
}
|
||||
27
gitnexus/src/core/ingestion/languages/go/merge-bindings.ts
Normal file
27
gitnexus/src/core/ingestion/languages/go/merge-bindings.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
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;
|
||||
});
|
||||
}
|
||||
108
gitnexus/src/core/ingestion/languages/go/method-owners.ts
Normal file
108
gitnexus/src/core/ingestion/languages/go/method-owners.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import type { ParsedFile } from 'gitnexus-shared';
|
||||
import { isClassLike, populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js';
|
||||
|
||||
/**
|
||||
* Populate `ownerId` on Go Method defs by matching receiver types
|
||||
* extracted from `@type-binding.self` captures against struct defs in
|
||||
* the module scope.
|
||||
*
|
||||
* Go method declarations are top-level (`func (r *T) M()`), not nested
|
||||
* inside a struct body. The generic `populateClassOwnedMembers` requires
|
||||
* the method's parent scope to be a `Class` scope, which never matches
|
||||
* Go. This pass bridges the gap by reading the self typeBinding that
|
||||
* `synthesizeGoReceiverBinding` creates, locating the matching struct
|
||||
* def, and stamping `ownerId` onto the Method def.
|
||||
*/
|
||||
export function populateGoOwners(parsed: ParsedFile): void {
|
||||
// 1. Standard nested-class pass — stamps ownerId on Property/Method defs
|
||||
// inside Class scopes. With Class scopes now created for Go
|
||||
// struct/interface declarations, this handles struct field ownership.
|
||||
populateClassOwnedMembers(parsed);
|
||||
|
||||
populateGoOwnersInPackage([parsed]);
|
||||
}
|
||||
|
||||
export function populateGoWorkspaceOwners(
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
ctx: { readonly fileContents: ReadonlyMap<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);
|
||||
}
|
||||
59
gitnexus/src/core/ingestion/languages/go/namespace-mirror.ts
Normal file
59
gitnexus/src/core/ingestion/languages/go/namespace-mirror.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import type { ParsedFile, TypeRef } from 'gitnexus-shared';
|
||||
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
|
||||
import type { WorkspaceResolutionIndex } from '../../scope-resolution/workspace-index.js';
|
||||
import { followChainPostFinalize } from '../../scope-resolution/passes/imported-return-types.js';
|
||||
|
||||
/**
|
||||
* Mirror exported typeBindings from namespace-import target modules
|
||||
* into the importer's module scope.
|
||||
*
|
||||
* Go uses namespace imports (`import "pkg"`) where the target package's
|
||||
* exported symbols are visible as `pkg.Func`. For cross-package return-type
|
||||
* resolution to work, the importer needs the target package's exported
|
||||
* typeBindings (e.g. `NewUser → User`) mirrored into its own module scope.
|
||||
*
|
||||
* Exported-symbol filter: Go uses uppercase first letter for exported names.
|
||||
*/
|
||||
export function mirrorGoNamespaceTypeBindings(
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
indexes: ScopeResolutionIndexes,
|
||||
workspaceIndex: WorkspaceResolutionIndex,
|
||||
): void {
|
||||
const moduleScopeByFile = workspaceIndex.moduleScopeByFile;
|
||||
|
||||
for (const parsed of parsedFiles) {
|
||||
const importerModule = moduleScopeByFile.get(parsed.filePath);
|
||||
if (importerModule === undefined) continue;
|
||||
|
||||
const moduleEdges = indexes.imports.get(importerModule.id);
|
||||
if (moduleEdges === undefined) continue;
|
||||
|
||||
const nsTargets = new Map<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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
101
gitnexus/src/core/ingestion/languages/go/package-siblings.ts
Normal file
101
gitnexus/src/core/ingestion/languages/go/package-siblings.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import type { BindingRef, ParsedFile, ScopeId, SymbolDefinition } from 'gitnexus-shared';
|
||||
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
|
||||
|
||||
import { expandGoDotImports } from './expand-wildcards.js';
|
||||
|
||||
/**
|
||||
* O(n²×d) where n = files per package, d = defs per file.
|
||||
* Acceptable for V1 since Go packages are typically small (< 20 files).
|
||||
* Future optimization: build a name→def inverted index per package to reduce
|
||||
* to O(n×d).
|
||||
*/
|
||||
export function populateGoPackageSiblings(
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
indexes: ScopeResolutionIndexes,
|
||||
ctx: { readonly fileContents: ReadonlyMap<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;
|
||||
}
|
||||
211
gitnexus/src/core/ingestion/languages/go/query.ts
Normal file
211
gitnexus/src/core/ingestion/languages/go/query.ts
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
import Parser from 'tree-sitter';
|
||||
import Go from 'tree-sitter-go';
|
||||
|
||||
const GO_SCOPE_QUERY = `
|
||||
;; Scopes
|
||||
(source_file) @scope.module
|
||||
(type_declaration
|
||||
(type_spec
|
||||
type: [(struct_type) (interface_type)])) @scope.class
|
||||
(function_declaration) @scope.function
|
||||
(method_declaration) @scope.function
|
||||
(func_literal) @scope.function
|
||||
(block) @scope.block
|
||||
(if_statement) @scope.block
|
||||
(for_statement) @scope.block
|
||||
(select_statement) @scope.block
|
||||
(expression_switch_statement) @scope.block
|
||||
(type_switch_statement) @scope.block
|
||||
(expression_case) @scope.block
|
||||
(default_case) @scope.block
|
||||
(type_case) @scope.block
|
||||
(communication_case) @scope.block
|
||||
|
||||
;; Declarations — struct
|
||||
(type_declaration
|
||||
(type_spec name: (type_identifier) @declaration.name
|
||||
type: (struct_type))) @declaration.struct
|
||||
|
||||
;; Declarations — interface
|
||||
(type_declaration
|
||||
(type_spec name: (type_identifier) @declaration.name
|
||||
type: (interface_type))) @declaration.interface
|
||||
|
||||
;; Declarations — function
|
||||
(function_declaration
|
||||
name: (identifier) @declaration.name) @declaration.function
|
||||
|
||||
;; Declarations — method
|
||||
(method_declaration
|
||||
name: (field_identifier) @declaration.name) @declaration.method
|
||||
|
||||
;; Declarations — struct fields
|
||||
(struct_type
|
||||
(field_declaration_list
|
||||
(field_declaration
|
||||
name: (field_identifier) @declaration.name
|
||||
type: (_) @declaration.field-type))) @declaration.field
|
||||
|
||||
;; Declarations — variables
|
||||
(var_declaration
|
||||
(var_spec
|
||||
name: (identifier) @declaration.name)) @declaration.variable
|
||||
|
||||
(const_declaration
|
||||
(const_spec
|
||||
name: (identifier) @declaration.name)) @declaration.const
|
||||
|
||||
(short_var_declaration
|
||||
left: (expression_list (identifier) @declaration.name)) @declaration.variable
|
||||
|
||||
;; Imports
|
||||
(import_spec) @import.statement
|
||||
|
||||
;; Type bindings — parameter annotations
|
||||
(function_declaration
|
||||
name: (identifier) @_fn_name
|
||||
parameters: (parameter_list
|
||||
(parameter_declaration
|
||||
name: (identifier) @type-binding.name
|
||||
type: [(type_identifier) (qualified_type) (pointer_type) (slice_type) (map_type)] @type-binding.type))) @type-binding.parameter
|
||||
|
||||
(method_declaration
|
||||
name: (field_identifier) @_fn_name
|
||||
parameters: (parameter_list
|
||||
(parameter_declaration
|
||||
name: (identifier) @type-binding.name
|
||||
type: [(type_identifier) (qualified_type) (pointer_type) (slice_type) (map_type)] @type-binding.type))) @type-binding.parameter
|
||||
|
||||
;; Type bindings — constructor-inferred (:= T{})
|
||||
(short_var_declaration
|
||||
left: (expression_list (identifier) @type-binding.name)
|
||||
right: (expression_list
|
||||
(composite_literal
|
||||
type: [(type_identifier) (qualified_type)] @type-binding.type))) @type-binding.constructor
|
||||
|
||||
;; Type bindings — pointer constructor (:= &T{})
|
||||
(short_var_declaration
|
||||
left: (expression_list (identifier) @type-binding.name)
|
||||
right: (expression_list
|
||||
(unary_expression
|
||||
"&"
|
||||
operand: (composite_literal
|
||||
type: [(type_identifier) (qualified_type)] @type-binding.type)))) @type-binding.constructor
|
||||
|
||||
;; Type bindings — type assertion (:= s.(T))
|
||||
(short_var_declaration
|
||||
left: (expression_list (identifier) @type-binding.name)
|
||||
right: (expression_list
|
||||
(type_assertion_expression
|
||||
type: (_) @type-binding.type))) @type-binding.assertion
|
||||
|
||||
(var_declaration
|
||||
(var_spec
|
||||
name: (identifier) @type-binding.name
|
||||
value: (expression_list
|
||||
(type_assertion_expression
|
||||
type: (_) @type-binding.type)))) @type-binding.assertion
|
||||
|
||||
;; Type bindings — explicit var type
|
||||
(var_declaration
|
||||
(var_spec
|
||||
name: (identifier) @type-binding.name
|
||||
type: (_) @type-binding.type)) @type-binding.assignment
|
||||
|
||||
;; Type bindings — call-return inference (:= Func(args))
|
||||
(short_var_declaration
|
||||
left: (expression_list (identifier) @type-binding.name)
|
||||
right: (expression_list (call_expression
|
||||
function: (identifier) @type-binding.type))) @type-binding.call-return
|
||||
|
||||
;; Type bindings — call-return inference qualified (:= pkg.Func(args))
|
||||
(short_var_declaration
|
||||
left: (expression_list (identifier) @type-binding.name)
|
||||
right: (expression_list (call_expression
|
||||
function: (selector_expression
|
||||
field: (field_identifier) @type-binding.type)))) @type-binding.call-return
|
||||
|
||||
;; Type bindings — return type annotation (func Foo() *Type)
|
||||
(function_declaration
|
||||
name: (identifier) @type-binding.name
|
||||
result: (_) @type-binding.type) @type-binding.return
|
||||
|
||||
;; Type bindings — method return type (func (r *T) Method() *Type)
|
||||
(method_declaration
|
||||
name: (field_identifier) @type-binding.name
|
||||
result: (_) @type-binding.type) @type-binding.return
|
||||
|
||||
;; Type bindings — variable alias (y := x)
|
||||
(short_var_declaration
|
||||
left: (expression_list (identifier) @type-binding.name)
|
||||
right: (expression_list (identifier) @type-binding.type)) @type-binding.alias
|
||||
|
||||
;; Type bindings — variable alias var form (var x = y)
|
||||
(var_declaration
|
||||
(var_spec
|
||||
name: (identifier) @type-binding.name
|
||||
value: (expression_list (identifier) @type-binding.type))) @type-binding.alias
|
||||
|
||||
;; Type bindings — call-return var form (var x = Func())
|
||||
(var_declaration
|
||||
(var_spec
|
||||
name: (identifier) @type-binding.name
|
||||
value: (expression_list (call_expression
|
||||
function: (identifier) @type-binding.type)))) @type-binding.call-return
|
||||
|
||||
;; References — free calls
|
||||
(call_expression
|
||||
function: (identifier) @reference.name) @reference.call.free
|
||||
|
||||
;; References — member calls
|
||||
(call_expression
|
||||
function: (selector_expression
|
||||
operand: (_) @reference.receiver
|
||||
field: (field_identifier) @reference.name)) @reference.call.member
|
||||
|
||||
;; References — constructor calls (T{})
|
||||
(composite_literal
|
||||
type: [(type_identifier) (qualified_type)] @reference.name) @reference.call.constructor
|
||||
|
||||
;; References — field reads
|
||||
(selector_expression
|
||||
operand: (_) @reference.receiver
|
||||
field: (field_identifier) @reference.name) @reference.read
|
||||
|
||||
;; References — field writes (assignment)
|
||||
(assignment_statement
|
||||
left: (expression_list
|
||||
(selector_expression
|
||||
operand: (_) @reference.receiver
|
||||
field: (field_identifier) @reference.name))) @reference.write
|
||||
|
||||
;; References — field writes (inc: obj.Field++)
|
||||
(inc_statement
|
||||
(selector_expression
|
||||
operand: (_) @reference.receiver
|
||||
field: (field_identifier) @reference.name)) @reference.write
|
||||
|
||||
;; References — field writes (dec: obj.Field--)
|
||||
(dec_statement
|
||||
(selector_expression
|
||||
operand: (_) @reference.receiver
|
||||
field: (field_identifier) @reference.name)) @reference.write
|
||||
`;
|
||||
|
||||
let _parser: Parser | null = null;
|
||||
let _query: Parser.Query | null = null;
|
||||
|
||||
export function getGoParser(): Parser {
|
||||
if (_parser === null) {
|
||||
_parser = new Parser();
|
||||
_parser.setLanguage(Go as Parameters<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;
|
||||
}
|
||||
133
gitnexus/src/core/ingestion/languages/go/range-binding.ts
Normal file
133
gitnexus/src/core/ingestion/languages/go/range-binding.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import type { ParsedFile, Scope, TypeRef } from 'gitnexus-shared';
|
||||
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
|
||||
import { getGoParser } from './query.js';
|
||||
import { getTreeSitterBufferSize } from '../../constants.js';
|
||||
|
||||
export function populateGoRangeBindings(
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
_indexes: ScopeResolutionIndexes,
|
||||
ctx: {
|
||||
readonly fileContents: ReadonlyMap<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;
|
||||
}
|
||||
21
gitnexus/src/core/ingestion/languages/go/receiver-binding.ts
Normal file
21
gitnexus/src/core/ingestion/languages/go/receiver-binding.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import type { CaptureMatch } from 'gitnexus-shared';
|
||||
import { syntheticCapture } from '../../utils/ast-helpers.js';
|
||||
import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
|
||||
export function synthesizeGoReceiverBinding(fnNode: SyntaxNode): CaptureMatch | null {
|
||||
if (fnNode.type !== 'method_declaration') return null;
|
||||
const receiver = fnNode.childForFieldName('receiver');
|
||||
if (receiver === null) return null;
|
||||
const param = receiver.namedChildren.find((c) => c.type === 'parameter_declaration');
|
||||
if (param === undefined) return null;
|
||||
const nameNode = param.childForFieldName('name');
|
||||
const typeNode = param.childForFieldName('type');
|
||||
if (nameNode === null || typeNode === null) return null;
|
||||
const typeName = typeNode.text.replace(/^\*/, '');
|
||||
|
||||
return {
|
||||
'@type-binding.self': syntheticCapture('@type-binding.self', fnNode, nameNode.text),
|
||||
'@type-binding.name': syntheticCapture('@type-binding.name', nameNode, nameNode.text),
|
||||
'@type-binding.type': syntheticCapture('@type-binding.type', typeNode, typeName),
|
||||
};
|
||||
}
|
||||
55
gitnexus/src/core/ingestion/languages/go/scope-resolver.ts
Normal file
55
gitnexus/src/core/ingestion/languages/go/scope-resolver.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import type { ParsedFile } from 'gitnexus-shared';
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js';
|
||||
import { populateGoOwners, populateGoWorkspaceOwners } from './method-owners.js';
|
||||
import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js';
|
||||
import { loadGoModulePath } from '../../language-config.js';
|
||||
import { goProvider } from '../go.js';
|
||||
import {
|
||||
goArityCompatibility,
|
||||
goMergeBindings,
|
||||
populateGoPackageSiblings,
|
||||
resolveGoImportTarget,
|
||||
mirrorGoNamespaceTypeBindings,
|
||||
} from './index.js';
|
||||
import { detectGoInterfaceImplementations } from './interface-impls.js';
|
||||
import { populateGoRangeBindings } from './range-binding.js';
|
||||
import { expandGoWildcardNames } from './expand-wildcards.js';
|
||||
|
||||
export const goScopeResolver: ScopeResolver = {
|
||||
language: SupportedLanguages.Go,
|
||||
languageProvider: goProvider,
|
||||
importEdgeReason: 'go-scope: import',
|
||||
|
||||
loadResolutionConfig: (repoPath: string) => loadGoModulePath(repoPath),
|
||||
|
||||
resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig) =>
|
||||
resolveGoImportTarget(targetRaw, fromFile, allFilePaths, resolutionConfig),
|
||||
|
||||
expandsWildcardTo: (targetModuleScope, parsedFiles) =>
|
||||
expandGoWildcardNames(targetModuleScope, parsedFiles),
|
||||
|
||||
mergeBindings: (existing, incoming, scopeId) => goMergeBindings(existing, incoming, scopeId),
|
||||
|
||||
arityCompatibility: (callsite, def) => goArityCompatibility(def, callsite),
|
||||
|
||||
buildMro: (graph, parsedFiles, nodeLookup) =>
|
||||
buildMro(graph, parsedFiles, nodeLookup, defaultLinearize),
|
||||
|
||||
populateOwners: (parsed: ParsedFile) => populateGoOwners(parsed),
|
||||
populateWorkspaceOwners: (parsedFiles, ctx) => populateGoWorkspaceOwners(parsedFiles, ctx),
|
||||
|
||||
isSuperReceiver: () => false,
|
||||
|
||||
fieldFallbackOnMethodLookup: false,
|
||||
hoistTypeBindingsToModule: true,
|
||||
propagatesReturnTypesAcrossImports: true,
|
||||
allowGlobalFreeCallFallback: true,
|
||||
|
||||
populateNamespaceSiblings: populateGoPackageSiblings,
|
||||
mirrorNamespaceTypeBindings: mirrorGoNamespaceTypeBindings,
|
||||
// Staged/V2: registered but not yet wired in run.ts — method-name-only
|
||||
// matching produces false IMPLEMENTS edges; awaits signature-level comparison.
|
||||
detectInterfaceImplementations: detectGoInterfaceImplementations,
|
||||
populateRangeBindings: populateGoRangeBindings,
|
||||
};
|
||||
38
gitnexus/src/core/ingestion/languages/go/simple-hooks.ts
Normal file
38
gitnexus/src/core/ingestion/languages/go/simple-hooks.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import type {
|
||||
CaptureMatch,
|
||||
ParsedImport,
|
||||
Scope,
|
||||
ScopeId,
|
||||
ScopeTree,
|
||||
TypeRef,
|
||||
} from 'gitnexus-shared';
|
||||
|
||||
export function goBindingScopeFor(
|
||||
decl: CaptureMatch,
|
||||
innermost: Scope,
|
||||
_tree: ScopeTree,
|
||||
): ScopeId | null {
|
||||
// Keep self typeBindings in the method's Function scope (prevent
|
||||
// auto-hoist to Module) so populateGoOwners can match Method defs
|
||||
// to their receiver types by inspecting each Function scope.
|
||||
if (decl['@type-binding.self'] !== undefined) {
|
||||
return innermost.id;
|
||||
}
|
||||
return null; // default auto-hoist for other bindings
|
||||
}
|
||||
|
||||
export function goImportOwningScope(
|
||||
_imp: ParsedImport,
|
||||
_innermost: Scope,
|
||||
_tree: ScopeTree,
|
||||
): ScopeId | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function goReceiverBinding(functionScope: Scope): TypeRef | null {
|
||||
if (functionScope.kind !== 'Function') return null;
|
||||
for (const binding of functionScope.typeBindings.values()) {
|
||||
if (binding.source === 'self') return binding;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
285
gitnexus/src/core/ingestion/languages/go/type-binding.ts
Normal file
285
gitnexus/src/core/ingestion/languages/go/type-binding.ts
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
import type { CaptureMatch } from 'gitnexus-shared';
|
||||
import { syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
|
||||
export function synthesizeGoTypeBindings(rootNode: SyntaxNode): CaptureMatch[] {
|
||||
const out: CaptureMatch[] = [];
|
||||
|
||||
for (const node of rootNode.descendantsOfType('short_var_declaration')) {
|
||||
const right = node.childForFieldName('right');
|
||||
if (right === null) continue;
|
||||
const lhs = node.childForFieldName('left');
|
||||
if (lhs === null) continue;
|
||||
|
||||
// Multi-assignment: pair LHS identifiers positionally with RHS
|
||||
// expressions. The tree-sitter query produces all LHS/RHS
|
||||
// combinations; emit only the positions whose RHS carries an
|
||||
// inferable type.
|
||||
const lhsIds = lhs.namedChildren.filter((c) => c.type === 'identifier');
|
||||
const rhsExprs = right.namedChildren;
|
||||
if (lhsIds.length >= 2 && rhsExprs.length >= 2) {
|
||||
for (let i = 0; i < Math.min(lhsIds.length, rhsExprs.length); i++) {
|
||||
const lhsId = lhsIds[i]!;
|
||||
const rhsExpr = rhsExprs[i]!;
|
||||
const typeNode = extractTypeNode(rhsExpr);
|
||||
if (typeNode === null) continue;
|
||||
const typeName = extractSimpleTypeNameText(typeNode);
|
||||
out.push({
|
||||
'@type-binding.multi-assign': syntheticCapture(
|
||||
'@type-binding.multi-assign',
|
||||
node,
|
||||
lhsId.text,
|
||||
),
|
||||
'@type-binding.name': syntheticCapture('@type-binding.name', lhsId, lhsId.text),
|
||||
'@type-binding.type': syntheticCapture(
|
||||
'@type-binding.type',
|
||||
typeNode ?? rhsExpr,
|
||||
typeName,
|
||||
),
|
||||
});
|
||||
}
|
||||
continue; // synthesized matches replace tree-sitter combinations
|
||||
}
|
||||
|
||||
// Walk the expression_list for call_expression function == "new" or "make"
|
||||
for (let i = 0; i < right.namedChildCount; i++) {
|
||||
const expr = right.namedChild(i);
|
||||
if (expr?.type === 'call_expression') {
|
||||
const fn = expr.childForFieldName('function');
|
||||
const args = expr.childForFieldName('arguments');
|
||||
if (fn?.type === 'identifier' && fn.text === 'new' && args !== null) {
|
||||
const typeArg = args.namedChildren.find((c) =>
|
||||
['type_identifier', 'qualified_type'].includes(c.type),
|
||||
);
|
||||
if (typeArg !== null) {
|
||||
const typeName = extractSimpleTypeNameText(typeArg);
|
||||
const nameNodes = lhs.namedChildren.filter((c) => c.type === 'identifier');
|
||||
if (nameNodes.length > 0) {
|
||||
out.push({
|
||||
'@type-binding.new': syntheticCapture('@type-binding.new', node, 'new'),
|
||||
'@type-binding.name': syntheticCapture(
|
||||
'@type-binding.name',
|
||||
nameNodes[0],
|
||||
nameNodes[0].text,
|
||||
),
|
||||
'@type-binding.type': syntheticCapture('@type-binding.type', typeArg, typeName),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (fn?.type === 'identifier' && fn.text === 'make' && args !== null) {
|
||||
const sliceOrMap = args.namedChildren.find((c) =>
|
||||
// V1: channel_type not handled — make(chan T) produces no typeBinding.
|
||||
['slice_type', 'map_type'].includes(c.type),
|
||||
);
|
||||
if (sliceOrMap !== null) {
|
||||
let typeName = '';
|
||||
if (sliceOrMap.type === 'slice_type') {
|
||||
const elem = sliceOrMap.namedChildren.find((c) =>
|
||||
['type_identifier', 'qualified_type'].includes(c.type),
|
||||
);
|
||||
if (elem !== null) typeName = extractSimpleTypeNameText(elem);
|
||||
} else if (sliceOrMap.type === 'map_type') {
|
||||
const typeChildren = sliceOrMap.namedChildren.filter((c) =>
|
||||
['type_identifier', 'qualified_type'].includes(c.type),
|
||||
);
|
||||
const valueType = typeChildren[1] ?? typeChildren[0];
|
||||
if (valueType !== undefined) typeName = extractSimpleTypeNameText(valueType);
|
||||
}
|
||||
if (typeName !== '') {
|
||||
const nameNodes = lhs.namedChildren.filter((c) => c.type === 'identifier');
|
||||
if (nameNodes.length > 0) {
|
||||
out.push({
|
||||
'@type-binding.make': syntheticCapture('@type-binding.make', node, 'make'),
|
||||
'@type-binding.name': syntheticCapture(
|
||||
'@type-binding.name',
|
||||
nameNodes[0],
|
||||
nameNodes[0].text,
|
||||
),
|
||||
'@type-binding.type': syntheticCapture(
|
||||
'@type-binding.type',
|
||||
sliceOrMap,
|
||||
typeName,
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Synthesize typeBindings for for-range loop variables and index
|
||||
// expressions (sl[0], m["key"]) so member calls on them resolve.
|
||||
synthesizeElementAccessBindings(rootNode, out);
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function synthesizeElementAccessBindings(rootNode: SyntaxNode, out: CaptureMatch[]): void {
|
||||
// Build a map of variable → element type from make/new/range.
|
||||
const varElementType = new Map<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;
|
||||
}
|
||||
|
|
@ -157,19 +157,27 @@ export function c3Linearize(
|
|||
|
||||
// Add the direct parents list as the final sequence
|
||||
const sequences = [...parentLinearizations, [...directParents]];
|
||||
const heads = new Uint32Array(sequences.length); // head pointer per sequence
|
||||
const result: string[] = [];
|
||||
|
||||
// Tail-count map: how many sequences contain this id at index > head.
|
||||
// O(1) membership check replaces O(n) indexOf scans.
|
||||
const tailCount = new Map<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 (sequences.some((s) => s.length > 0)) {
|
||||
// Find a good head: one that doesn't appear in the tail of any other sequence
|
||||
|
||||
while (remaining > 0) {
|
||||
let head: string | null = null;
|
||||
for (const seq of sequences) {
|
||||
if (seq.length === 0) continue;
|
||||
const candidate = seq[0];
|
||||
const inTail = sequences.some(
|
||||
(other) => other.length > 1 && other.indexOf(candidate, 1) !== -1,
|
||||
);
|
||||
if (!inTail) {
|
||||
for (let si = 0; si < sequences.length; si++) {
|
||||
if (heads[si] >= sequences[si].length) continue;
|
||||
const candidate = sequences[si][heads[si]];
|
||||
if ((tailCount.get(candidate) ?? 0) === 0) {
|
||||
head = candidate;
|
||||
break;
|
||||
}
|
||||
|
|
@ -182,10 +190,19 @@ export function c3Linearize(
|
|||
|
||||
result.push(head);
|
||||
|
||||
// Remove the chosen head from all sequences
|
||||
for (const seq of sequences) {
|
||||
if (seq.length > 0 && seq[0] === head) {
|
||||
seq.shift();
|
||||
// Advance head pointers past the chosen head; update tail counts
|
||||
for (let si = 0; si < sequences.length; si++) {
|
||||
if (heads[si] >= sequences[si].length) continue;
|
||||
if (sequences[si][heads[si]] === head) {
|
||||
heads[si]++;
|
||||
remaining--;
|
||||
// promoted was in this sequence's active tail; now it's the new head — remove from tailCount
|
||||
if (heads[si] < sequences[si].length) {
|
||||
const promoted = sequences[si][heads[si]];
|
||||
const prev = tailCount.get(promoted)!;
|
||||
if (prev <= 1) tailCount.delete(promoted);
|
||||
else tailCount.set(promoted, prev - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ export const MIGRATED_LANGUAGES: ReadonlySet<SupportedLanguages> = new Set<Suppo
|
|||
SupportedLanguages.Python,
|
||||
SupportedLanguages.CSharp,
|
||||
SupportedLanguages.TypeScript,
|
||||
SupportedLanguages.Go,
|
||||
]);
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -541,6 +541,8 @@ 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),
|
||||
|
|
@ -550,6 +552,8 @@ function buildDefFromDeclarationMatch(
|
|||
...(parameterCount !== undefined ? { parameterCount } : {}),
|
||||
...(requiredParameterCount !== undefined ? { requiredParameterCount } : {}),
|
||||
...(parameterTypes !== undefined ? { parameterTypes } : {}),
|
||||
...(declaredType !== undefined ? { declaredType } : {}),
|
||||
...(returnType !== undefined ? { returnType } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -260,6 +260,7 @@ 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
|
||||
|
|
@ -314,7 +315,17 @@ export interface ScopeResolver {
|
|||
fromFile: string,
|
||||
allFilePaths: ReadonlySet<string>,
|
||||
resolutionConfig?: unknown,
|
||||
): string | null;
|
||||
): string | readonly string[] | null;
|
||||
|
||||
/**
|
||||
* Enumerate names visible through a wildcard import after the target
|
||||
* module scope has been linked. Languages that do not support
|
||||
* wildcard-style imports leave this undefined.
|
||||
*/
|
||||
readonly expandsWildcardTo?: (
|
||||
targetModuleScope: ScopeId,
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
) => readonly string[];
|
||||
|
||||
/**
|
||||
* Optional one-shot loader for cross-file import-resolution config
|
||||
|
|
@ -384,6 +395,18 @@ export interface ScopeResolver {
|
|||
*/
|
||||
populateOwners(parsed: ParsedFile): void;
|
||||
|
||||
/**
|
||||
* Optional workspace-wide ownership reconciliation for languages whose
|
||||
* member owner can be declared in a different file from the owner type.
|
||||
* Runs after every file has had `populateOwners(parsed)` applied, but
|
||||
* still before `reconcileOwnership`, so stamped ownerIds are copied into
|
||||
* the semantic model registries.
|
||||
*/
|
||||
readonly populateWorkspaceOwners?: (
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
ctx: { readonly fileContents: ReadonlyMap<string, string> },
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* Recognize a `super(...)`-style receiver text. Python returns
|
||||
* `/^super\s*\(/.test(t)`. Java returns `t === 'super'`. C++ may
|
||||
|
|
@ -441,6 +464,14 @@ export interface ScopeResolver {
|
|||
*/
|
||||
readonly collapseMemberCallsByCallerTarget?: boolean;
|
||||
|
||||
/**
|
||||
* Allow free-call emission to fall back to a unique workspace-wide
|
||||
* callable match when lexical/import bindings miss. Kept opt-in
|
||||
* because this mirrors legacy resolver behavior for some languages
|
||||
* but is too loose as a default for strict module systems.
|
||||
*/
|
||||
readonly allowGlobalFreeCallFallback?: boolean;
|
||||
|
||||
/**
|
||||
* Optional post-finalize hook to inject cross-file bindings that
|
||||
* aren't modeled via explicit imports. Runs after
|
||||
|
|
@ -485,4 +516,52 @@ export interface ScopeResolver {
|
|||
* level bindings.
|
||||
*/
|
||||
readonly hoistTypeBindingsToModule?: boolean;
|
||||
|
||||
/**
|
||||
* Optional: detect structural (duck-typing) interface implementations.
|
||||
* Languages like Go use structural typing — a struct satisfies an
|
||||
* interface if its method set is a superset, without an explicit
|
||||
* `implements` keyword. Runs after finalize, before resolution passes.
|
||||
* Returns: Map<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,7 +21,6 @@ import type { NodeLabel, ScopeId, SymbolDefinition } from 'gitnexus-shared';
|
|||
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
|
||||
import { generateId } from '../../../../lib/utils.js';
|
||||
import { qualifiedKey, simpleKey, type GraphNodeLookup } from '../graph-bridge/node-lookup.js';
|
||||
|
||||
/**
|
||||
* Labels that may legitimately ANCHOR a CALLS/ACCESSES edge as the
|
||||
* source ("caller"). A Variable / Property can be the TARGET of an
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ export function emitFreeCallFallback(
|
|||
handledSites: Set<string>,
|
||||
model: SemanticModel,
|
||||
workspaceIndex: WorkspaceResolutionIndex,
|
||||
options: { readonly allowGlobalFallback?: boolean } = {},
|
||||
): number {
|
||||
let emitted = 0;
|
||||
const seen = new Set<string>();
|
||||
|
|
@ -67,6 +68,13 @@ export function emitFreeCallFallback(
|
|||
if (fnDef === undefined) {
|
||||
fnDef = findCallableBindingInScope(site.inScope, site.name, scopes);
|
||||
}
|
||||
// V1: pickUniqueGlobalCallable ignores import context — resolves to any
|
||||
// globally-unique callable. False cross-package edges are possible when
|
||||
// the caller does not import the target package. Same-package calls are
|
||||
// caught by findCallableBindingInScope above before reaching here.
|
||||
if (fnDef === undefined && options.allowGlobalFallback === true) {
|
||||
fnDef = pickUniqueGlobalCallable(site.name, model, scopes);
|
||||
}
|
||||
if (fnDef === undefined) continue;
|
||||
const callerGraphId = resolveCallerGraphId(site.inScope, scopes, nodeLookup);
|
||||
if (callerGraphId === undefined) continue;
|
||||
|
|
@ -95,6 +103,51 @@ export function emitFreeCallFallback(
|
|||
return emitted;
|
||||
}
|
||||
|
||||
function pickUniqueGlobalCallable(
|
||||
name: string,
|
||||
model: SemanticModel,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
): SymbolDefinition | undefined {
|
||||
const scopeDefs: SymbolDefinition[] = [];
|
||||
const scopeSeen = new Set<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`. */
|
||||
function followChainPostFinalize(
|
||||
export function followChainPostFinalize(
|
||||
start: TypeRef,
|
||||
fromScopeId: ScopeId,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import type { KnowledgeGraph } from '../../../graph/types.js';
|
|||
import type { GraphNodeLookup } from '../graph-bridge/node-lookup.js';
|
||||
import type { LinearizeStrategy } from '../contract/scope-resolver.js';
|
||||
import { resolveDefGraphId } from '../graph-bridge/ids.js';
|
||||
import { isClassLike } from '../scope/walkers.js';
|
||||
|
||||
/**
|
||||
* Build an MRO map keyed by scope-resolution Class `DefId`.
|
||||
|
|
@ -58,7 +59,7 @@ export function buildMro(
|
|||
const defIdByGraphId = new Map<string, string>();
|
||||
for (const parsed of parsedFiles) {
|
||||
for (const def of parsed.localDefs) {
|
||||
if (def.type !== 'Class') continue;
|
||||
if (!isClassLike(def.type)) continue;
|
||||
const graphId = resolveDefGraphId(parsed.filePath, def, nodeLookup);
|
||||
if (graphId !== undefined) defIdByGraphId.set(graphId, def.nodeId);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ import {
|
|||
findExportedDef,
|
||||
findOwnedMember,
|
||||
findReceiverTypeBinding,
|
||||
isClassLike,
|
||||
} from '../scope/walkers.js';
|
||||
import { tryEmitEdge } from '../graph-bridge/edges.js';
|
||||
import { resolveCompoundReceiverClass } from '../passes/compound-receiver.js';
|
||||
|
|
@ -249,25 +250,30 @@ export function emitReceiverBoundCalls(
|
|||
}
|
||||
|
||||
// ── Case 1: namespace receiver ───────────────────────────────
|
||||
const targetFile = namespaceTargets.get(receiverName);
|
||||
if (targetFile !== undefined) {
|
||||
const memberDef = findExportedDef(targetFile, memberName, index);
|
||||
if (memberDef !== undefined) {
|
||||
const ok = tryEmitEdge(
|
||||
graph,
|
||||
scopes,
|
||||
nodeLookup,
|
||||
site,
|
||||
memberDef,
|
||||
memberDef.filePath !== parsed.filePath ? 'import-resolved' : 'global',
|
||||
seen,
|
||||
0.85,
|
||||
collapse,
|
||||
);
|
||||
if (ok) emitted++;
|
||||
handledSites.add(siteKey);
|
||||
continue;
|
||||
const targetFiles = namespaceTargets.get(receiverName);
|
||||
if (targetFiles !== undefined) {
|
||||
let found = false;
|
||||
for (const targetFile of targetFiles) {
|
||||
const memberDef = findExportedDef(targetFile, memberName, index);
|
||||
if (memberDef !== undefined) {
|
||||
const ok = tryEmitEdge(
|
||||
graph,
|
||||
scopes,
|
||||
nodeLookup,
|
||||
site,
|
||||
memberDef,
|
||||
memberDef.filePath !== parsed.filePath ? 'import-resolved' : 'global',
|
||||
seen,
|
||||
0.85,
|
||||
collapse,
|
||||
);
|
||||
if (ok) emitted++;
|
||||
handledSites.add(siteKey);
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (found) continue;
|
||||
}
|
||||
|
||||
// ── Case 2: class-name receiver ──────────────────────────────
|
||||
|
|
@ -309,28 +315,33 @@ export function emitReceiverBoundCalls(
|
|||
if (typeRef !== undefined && typeRef.rawName.includes('.')) {
|
||||
const [nsName, ...classNameParts] = typeRef.rawName.split('.');
|
||||
const className = classNameParts.join('.');
|
||||
const targetFile3 = namespaceTargets.get(nsName);
|
||||
if (targetFile3 !== undefined && className.length > 0) {
|
||||
const classDef3 = findExportedDef(targetFile3, className, index);
|
||||
if (classDef3 !== undefined) {
|
||||
const memberDef = findOwnedMember(classDef3.nodeId, memberName, model);
|
||||
if (memberDef !== undefined) {
|
||||
const ok = tryEmitEdge(
|
||||
graph,
|
||||
scopes,
|
||||
nodeLookup,
|
||||
site,
|
||||
memberDef,
|
||||
memberDef.filePath !== parsed.filePath ? 'import-resolved' : 'global',
|
||||
seen,
|
||||
);
|
||||
if (ok) {
|
||||
emitted++;
|
||||
handledSites.add(siteKey);
|
||||
const targetFiles3 = namespaceTargets.get(nsName);
|
||||
if (targetFiles3 !== undefined && className.length > 0) {
|
||||
let found3 = false;
|
||||
for (const targetFile3 of targetFiles3) {
|
||||
const classDef3 = findExportedDef(targetFile3, className, index);
|
||||
if (classDef3 !== undefined) {
|
||||
const memberDef = findOwnedMember(classDef3.nodeId, memberName, model);
|
||||
if (memberDef !== undefined) {
|
||||
const ok = tryEmitEdge(
|
||||
graph,
|
||||
scopes,
|
||||
nodeLookup,
|
||||
site,
|
||||
memberDef,
|
||||
memberDef.filePath !== parsed.filePath ? 'import-resolved' : 'global',
|
||||
seen,
|
||||
);
|
||||
if (ok) {
|
||||
emitted++;
|
||||
handledSites.add(siteKey);
|
||||
}
|
||||
found3 = true;
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (found3) continue;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -394,10 +405,20 @@ export function emitReceiverBoundCalls(
|
|||
if (typeRef !== undefined && !typeRef.rawName.includes('.')) {
|
||||
let ownerDef = findClassBindingInScope(site.inScope, typeRef.rawName, scopes);
|
||||
// `findClassBindingInScope(..., typeRef.rawName)` only works when
|
||||
// rawName is itself a class symbol. Map for-of tuple bindings
|
||||
// (`__MAP_TUPLE_i__:mapId`), callable aliases (`getUser` → User),
|
||||
// and other compound-friendly shapes need the compound resolver
|
||||
// keyed by the receiver identifier.
|
||||
// rawName is itself a class symbol reachable through scope bindings.
|
||||
// For languages with namespace-style imports (Go), imported types
|
||||
// don't create bindings. Fall back to QualifiedNameIndex — single-
|
||||
// match wins; ambiguous/missing falls through.
|
||||
if (ownerDef === undefined) {
|
||||
const qnameIds = scopes.qualifiedNames.get(typeRef.rawName);
|
||||
if (qnameIds.length === 1) {
|
||||
const qdef = scopes.defs.get(qnameIds[0]!);
|
||||
if (qdef !== undefined && isClassLike(qdef.type)) ownerDef = qdef;
|
||||
}
|
||||
}
|
||||
// Map for-of tuple bindings (`__MAP_TUPLE_i__:mapId`), callable
|
||||
// aliases (`getUser` → User), and other compound-friendly shapes
|
||||
// need the compound resolver keyed by the receiver identifier.
|
||||
if (ownerDef === undefined) {
|
||||
ownerDef = resolveCompoundReceiverClass(
|
||||
receiverName,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import type { ScopeResolver } from '../contract/scope-resolver.js';
|
|||
import { pythonScopeResolver } from '../../languages/python/scope-resolver.js';
|
||||
import { csharpScopeResolver } from '../../languages/csharp/scope-resolver.js';
|
||||
import { typescriptScopeResolver } from '../../languages/typescript/scope-resolver.js';
|
||||
import { goScopeResolver } from '../../languages/go/scope-resolver.js';
|
||||
|
||||
/** Map of `SupportedLanguages` → `ScopeResolver`. The phase iterates
|
||||
* this map intersected with `MIGRATED_LANGUAGES` (the per-language
|
||||
|
|
@ -26,4 +27,5 @@ export const SCOPE_RESOLVERS: ReadonlyMap<SupportedLanguages, ScopeResolver> = n
|
|||
[SupportedLanguages.Python, pythonScopeResolver],
|
||||
[SupportedLanguages.CSharp, csharpScopeResolver],
|
||||
[SupportedLanguages.TypeScript, typescriptScopeResolver],
|
||||
[SupportedLanguages.Go, goScopeResolver],
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -90,6 +90,14 @@ export function runScopeResolution(
|
|||
const onWarn = input.onWarn ?? (() => {});
|
||||
const PROF = process.env.PROF_SCOPE_RESOLUTION === '1';
|
||||
const tStart = PROF ? process.hrtime.bigint() : 0n;
|
||||
let fileContents: Map<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[] = [];
|
||||
|
|
@ -111,6 +119,7 @@ export function runScopeResolution(
|
|||
provider.populateOwners(parsed);
|
||||
parsedFiles.push(parsed);
|
||||
}
|
||||
provider.populateWorkspaceOwners?.(parsedFiles, { fileContents: getFileContents() });
|
||||
|
||||
// Reconcile scope-resolution's ownership view into the SemanticModel.
|
||||
// See `reconcile-ownership.ts` for the full rationale (Contract
|
||||
|
|
@ -147,8 +156,17 @@ export function runScopeResolution(
|
|||
const resolutionConfig = input.resolutionConfig;
|
||||
const finalized = finalizeScopeModel(parsedFiles, {
|
||||
hooks: {
|
||||
resolveImportTarget: (targetRaw, fromFile) =>
|
||||
provider.resolveImportTarget(targetRaw, fromFile, allFilePaths, resolutionConfig),
|
||||
resolveImportTarget: (targetRaw, fromFile) => {
|
||||
const resolved = provider.resolveImportTarget(
|
||||
targetRaw,
|
||||
fromFile,
|
||||
allFilePaths,
|
||||
resolutionConfig,
|
||||
);
|
||||
return Array.isArray(resolved) ? (resolved[0] ?? null) : resolved;
|
||||
},
|
||||
expandsWildcardTo: (targetModuleScope) =>
|
||||
provider.expandsWildcardTo?.(targetModuleScope, parsedFiles) ?? [],
|
||||
mergeBindings: (existing, incoming, scopeId) =>
|
||||
provider.mergeBindings(existing, incoming, scopeId),
|
||||
},
|
||||
|
|
@ -178,16 +196,21 @@ export function runScopeResolution(
|
|||
// The hook writes to `bindingAugmentations` only; finalized
|
||||
// `indexes.bindings` remains immutable post-finalize (I8).
|
||||
if (provider.populateNamespaceSiblings !== undefined) {
|
||||
const fileContents = new Map<string, string>();
|
||||
for (const f of files) fileContents.set(f.path, f.content);
|
||||
provider.populateNamespaceSiblings(parsedFiles, indexes, {
|
||||
fileContents,
|
||||
fileContents: getFileContents(),
|
||||
treeCache,
|
||||
});
|
||||
}
|
||||
|
||||
const tFinalize = PROF ? process.hrtime.bigint() : 0n;
|
||||
|
||||
// Cross-package namespace typeBinding mirroring. Runs before
|
||||
// propagateImportedReturnTypes so the SCC-ordered pass sees the
|
||||
// mirrored bindings.
|
||||
if (provider.mirrorNamespaceTypeBindings !== undefined) {
|
||||
provider.mirrorNamespaceTypeBindings(parsedFiles, indexes, workspaceIndex);
|
||||
}
|
||||
|
||||
// Cross-file return-type propagation (Contract Invariant I3 timing:
|
||||
// after finalize, before resolve). Split-timed separately so the
|
||||
// SCC-ordered pass's cost is observable (PR #1050 made this O(files)
|
||||
|
|
@ -196,6 +219,13 @@ export function runScopeResolution(
|
|||
if (provider.propagatesReturnTypesAcrossImports !== false) {
|
||||
propagateImportedReturnTypes(parsedFiles, indexes, workspaceIndex);
|
||||
}
|
||||
|
||||
if (provider.populateRangeBindings !== undefined) {
|
||||
provider.populateRangeBindings(parsedFiles, indexes, {
|
||||
fileContents: getFileContents(),
|
||||
treeCache,
|
||||
});
|
||||
}
|
||||
const tPropagate = PROF ? process.hrtime.bigint() : 0n;
|
||||
|
||||
// Opt-in I8 invariant guard. Runs once after all post-finalize hooks
|
||||
|
|
@ -237,6 +267,7 @@ 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,7 +51,12 @@ export function collectNamespaceTargets(
|
|||
for (const edge of moduleEdges) {
|
||||
if (edge.targetFile === null) continue;
|
||||
if (!namespaceLocals.has(edge.localName)) continue;
|
||||
out.set(edge.localName, edge.targetFile);
|
||||
let targets = out.get(edge.localName);
|
||||
if (targets === undefined) {
|
||||
targets = [];
|
||||
out.set(edge.localName, targets);
|
||||
}
|
||||
if (!targets.includes(edge.targetFile)) targets.push(edge.targetFile);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -187,6 +187,27 @@ export function findClassBindingInScope(
|
|||
|
||||
currentId = scope.parent;
|
||||
}
|
||||
// Fallback for languages (Go) where namespace-style imports don't
|
||||
// create scope bindings: resolve via QualifiedNameIndex. Only fires
|
||||
// when the scope-chain walk found nothing; single-match wins.
|
||||
const qnames = scopes.qualifiedNames.get(receiverName);
|
||||
if (qnames.length === 1) {
|
||||
const def = scopes.defs.get(qnames[0]!);
|
||||
if (def !== undefined && isClassLike(def.type)) return def;
|
||||
}
|
||||
// Second fallback: dotted names like "models.User" — try the simple
|
||||
// name (tail after last dot) for languages where defs are indexed by
|
||||
// simple name (Go). Only when the dotted lookup fails.
|
||||
if (receiverName.includes('.')) {
|
||||
const simple = receiverName.slice(receiverName.lastIndexOf('.') + 1);
|
||||
if (simple.length > 0 && simple !== receiverName) {
|
||||
const simpleIds = scopes.qualifiedNames.get(simple);
|
||||
if (simpleIds.length === 1) {
|
||||
const def = scopes.defs.get(simpleIds[0]!);
|
||||
if (def !== undefined && isClassLike(def.type)) return def;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ 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 } from './validation.js';
|
||||
import { extractRepoName, getCloneDir, cloneOrPull } from './git-clone.js';
|
||||
|
||||
const _require = createRequire(import.meta.url);
|
||||
|
|
@ -506,6 +507,9 @@ const mountSSEProgress = (app: express.Express, routePath: string, jm: JobManage
|
|||
};
|
||||
|
||||
const statusFromError = (err: any): number => {
|
||||
// Validation helpers throw BadRequestError / ForbiddenError with a typed
|
||||
// .status field — honor it before falling back to message-string matching.
|
||||
if (err instanceof BadRequestError) return err.status;
|
||||
const msg = String(err?.message ?? '');
|
||||
if (msg.includes('No indexed repositories') || msg.includes('not found')) return 404;
|
||||
if (msg.includes('Multiple repositories')) return 400;
|
||||
|
|
@ -1236,22 +1240,36 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|||
res.status(404).json({ error: 'Repository not found' });
|
||||
return;
|
||||
}
|
||||
const pattern = req.query.pattern as string;
|
||||
if (!pattern) {
|
||||
// Type-confusion guard (CodeQL js/type-confusion-through-parameter-tampering):
|
||||
// req.query.pattern is `string | string[] | ParsedQs` — without an explicit
|
||||
// type check, the `.length` guard below counts array elements instead of
|
||||
// characters, allowing arbitrarily long patterns through.
|
||||
const rawPattern = req.query.pattern;
|
||||
if (rawPattern === undefined) {
|
||||
res.status(400).json({ error: 'Missing "pattern" query parameter' });
|
||||
return;
|
||||
}
|
||||
const pattern = assertString(rawPattern, 'pattern');
|
||||
if (pattern.length === 0) {
|
||||
res.status(400).json({ error: 'Missing "pattern" query parameter' });
|
||||
return;
|
||||
}
|
||||
|
||||
// ReDoS protection: reject overly long or dangerous patterns
|
||||
// Length cap: applies to both literal and regex modes as a defense-in-depth
|
||||
// bound against pathological input.
|
||||
if (pattern.length > 200) {
|
||||
res.status(400).json({ error: 'Pattern too long (max 200 characters)' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate regex syntax
|
||||
// Treat user input as a literal substring in all cases to prevent
|
||||
// regex-injection/ReDoS via attacker-controlled regex syntax.
|
||||
const effectivePattern = escapeRegExp(pattern);
|
||||
|
||||
// Validate regex syntax (catches both opt-in user regex and any escapeRegExp bug)
|
||||
let regex: RegExp;
|
||||
try {
|
||||
regex = new RegExp(pattern, 'gim');
|
||||
regex = new RegExp(effectivePattern, 'gim');
|
||||
} catch {
|
||||
res.status(400).json({ error: 'Invalid regex pattern' });
|
||||
return;
|
||||
|
|
@ -1299,7 +1317,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|||
|
||||
res.json({ results });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message || 'Grep failed' });
|
||||
res.status(statusFromError(err)).json({ error: err.message || 'Grep failed' });
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -139,6 +139,39 @@ function assertNotPrivateIPv6(ip: string): void {
|
|||
if (lower.includes(':ffff:')) {
|
||||
throw new Error('Cloning from private/internal addresses is not allowed');
|
||||
}
|
||||
|
||||
// IPv4-compatible IPv6 (RFC 4291 § 2.5.5.1, deprecated form: ::w.x.y.z).
|
||||
// Node's URL parser collapses http://[::127.0.0.1]/ to "::7f00:1" — the IPv4
|
||||
// is hidden in the last 32 bits without the ::ffff: marker, so the check
|
||||
// above misses it. The form is still routable to the embedded IPv4 on most
|
||||
// network stacks, so any address compressed to ::xxxx[:yyyy] must be blocked.
|
||||
if (/^::[0-9a-f]{1,4}(:[0-9a-f]{1,4})?$/.test(lower)) {
|
||||
throw new Error('Cloning from private/internal addresses is not allowed');
|
||||
}
|
||||
|
||||
// NAT64 well-known prefix (RFC 6052 § 2.1: 64:ff9b::/96, plus the local
|
||||
// 64:ff9b:1::/48 from RFC 8215). Maps any IPv4 address — including private
|
||||
// ranges — into IPv6, so a host with NAT64 can reach the embedded IPv4 via
|
||||
// e.g. 64:ff9b::7f00:1 → 127.0.0.1.
|
||||
// The check intentionally covers the full 64:ff9b::/32 block (broader than
|
||||
// the two cited ranges): IANA reserves it for IPv4-IPv6 translation, so
|
||||
// blocking the whole prefix is defensively sound and prevents a narrower
|
||||
// CIDR check from quietly re-opening the bypass for 64:ff9b:1::/48 or any
|
||||
// future translation assignment.
|
||||
if (lower.startsWith('64:ff9b:')) {
|
||||
throw new Error('Cloning from private/internal addresses is not allowed');
|
||||
}
|
||||
|
||||
// 6to4 (RFC 3056, 2002::/16). Encodes an IPv4 address in bits 17-48, so
|
||||
// 2002:7f00:0001::1 routes to 127.0.0.1 on 6to4-capable stacks. The
|
||||
// protocol was deprecated by RFC 7526 and the public relay anycast
|
||||
// (192.88.99.1) has been retired, so broad-blocking the prefix has near-
|
||||
// zero false-positive cost while closing the IPv4-embedded bypass.
|
||||
// Teredo (2001::/32) embeds IPv4 obfuscated by XOR; precise blocking is
|
||||
// impractical and is out of scope here.
|
||||
if (lower.startsWith('2002:')) {
|
||||
throw new Error('Cloning from private/internal addresses is not allowed');
|
||||
}
|
||||
}
|
||||
|
||||
function assertNotPrivateIPv4(ip: string): void {
|
||||
|
|
|
|||
97
gitnexus/src/server/validation.ts
Normal file
97
gitnexus/src/server/validation.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
/**
|
||||
* 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';
|
||||
|
||||
/**
|
||||
* 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, '\\$&');
|
||||
}
|
||||
3
gitnexus/test/fixtures/lang-resolution/go-aliased-package-import/go.mod
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/go-aliased-package-import/go.mod
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
module example.com/aliasimport
|
||||
|
||||
go 1.21
|
||||
3
gitnexus/test/fixtures/lang-resolution/go-aliased-package-import/internal/util/log.go
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/go-aliased-package-import/internal/util/log.go
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
package util
|
||||
|
||||
func Log() {}
|
||||
7
gitnexus/test/fixtures/lang-resolution/go-aliased-package-import/main.go
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/go-aliased-package-import/main.go
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
package main
|
||||
|
||||
import util "example.com/aliasimport/internal/util"
|
||||
|
||||
func main() {
|
||||
util.Log()
|
||||
}
|
||||
3
gitnexus/test/fixtures/lang-resolution/go-same-package-factory/go.mod
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/go-same-package-factory/go.mod
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
module example.com/samefactory
|
||||
|
||||
go 1.21
|
||||
10
gitnexus/test/fixtures/lang-resolution/go-same-package-factory/main.go
vendored
Normal file
10
gitnexus/test/fixtures/lang-resolution/go-same-package-factory/main.go
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
package main
|
||||
|
||||
func NewUser() *User {
|
||||
return &User{}
|
||||
}
|
||||
|
||||
func processUser() {
|
||||
user := NewUser()
|
||||
user.Save()
|
||||
}
|
||||
7
gitnexus/test/fixtures/lang-resolution/go-same-package-factory/repo.go
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/go-same-package-factory/repo.go
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
package main
|
||||
|
||||
type Repo struct{}
|
||||
|
||||
func (r *Repo) Save() bool {
|
||||
return true
|
||||
}
|
||||
7
gitnexus/test/fixtures/lang-resolution/go-same-package-factory/user.go
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/go-same-package-factory/user.go
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
package main
|
||||
|
||||
type User struct{}
|
||||
|
||||
func (u *User) Save() bool {
|
||||
return true
|
||||
}
|
||||
3
gitnexus/test/fixtures/lang-resolution/go-split-method-owner/go.mod
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/go-split-method-owner/go.mod
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
module example.com/splitowner
|
||||
|
||||
go 1.21
|
||||
6
gitnexus/test/fixtures/lang-resolution/go-split-method-owner/main.go
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/go-split-method-owner/main.go
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
package main
|
||||
|
||||
func process() {
|
||||
user := User{}
|
||||
user.Save()
|
||||
}
|
||||
7
gitnexus/test/fixtures/lang-resolution/go-split-method-owner/repo.go
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/go-split-method-owner/repo.go
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
package main
|
||||
|
||||
type Repo struct{}
|
||||
|
||||
func (r *Repo) Save() bool {
|
||||
return true
|
||||
}
|
||||
5
gitnexus/test/fixtures/lang-resolution/go-split-method-owner/save.go
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/go-split-method-owner/save.go
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
package main
|
||||
|
||||
func (u *User) Save() bool {
|
||||
return true
|
||||
}
|
||||
3
gitnexus/test/fixtures/lang-resolution/go-split-method-owner/user.go
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/go-split-method-owner/user.go
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
package main
|
||||
|
||||
type User struct{}
|
||||
|
|
@ -1,11 +1,12 @@
|
|||
/**
|
||||
* Go: package imports + cross-package calls + ambiguous struct disambiguation
|
||||
*/
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import { describe, expect, beforeAll } from 'vitest';
|
||||
import path from 'path';
|
||||
import {
|
||||
FIXTURES,
|
||||
CROSS_FILE_FIXTURES,
|
||||
createResolverParityIt,
|
||||
getRelationships,
|
||||
getNodesByLabel,
|
||||
getNodesByLabelFull,
|
||||
|
|
@ -14,6 +15,8 @@ import {
|
|||
type PipelineResult,
|
||||
} from './helpers.js';
|
||||
|
||||
const it = createResolverParityIt('go');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Heritage: package imports + cross-package calls (exercises PackageMap)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -610,6 +613,30 @@ describe('Go return type inference via explicit function return type', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('Go same-package factory return type inference', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(path.join(FIXTURES, 'go-same-package-factory'), () => {});
|
||||
}, 60000);
|
||||
|
||||
it('resolves user.Save() through same-package NewUser() return type', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const userSave = calls.find(
|
||||
(c) => c.target === 'Save' && c.source === 'processUser' && c.targetFilePath === 'user.go',
|
||||
);
|
||||
expect(userSave).toBeDefined();
|
||||
});
|
||||
|
||||
it('does not resolve user.Save() to Repo.Save', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const repoSave = calls.find(
|
||||
(c) => c.target === 'Save' && c.source === 'processUser' && c.targetFilePath === 'repo.go',
|
||||
);
|
||||
expect(repoSave).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Go multi-return factory inference: user, err := NewUser("alice"); user.Save()
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -1261,6 +1288,47 @@ describe('Go cross-file binding propagation', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('Go aliased package selector resolution', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(path.join(FIXTURES, 'go-aliased-package-import'), () => {});
|
||||
}, 60000);
|
||||
|
||||
it('resolves util.Log() through an aliased package import', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const logCall = calls.find(
|
||||
(c) =>
|
||||
c.target === 'Log' && c.source === 'main' && c.targetFilePath === 'internal/util/log.go',
|
||||
);
|
||||
expect(logCall).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Go method owner resolution across package files', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(path.join(FIXTURES, 'go-split-method-owner'), () => {});
|
||||
}, 60000);
|
||||
|
||||
it('resolves user.Save() to the method whose receiver type is declared in another package file', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const userSave = calls.find(
|
||||
(c) => c.target === 'Save' && c.source === 'process' && c.targetFilePath === 'save.go',
|
||||
);
|
||||
expect(userSave).toBeDefined();
|
||||
});
|
||||
|
||||
it('does not resolve user.Save() to Repo.Save', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const repoSave = calls.find(
|
||||
(c) => c.target === 'Save' && c.source === 'process' && c.targetFilePath === 'repo.go',
|
||||
);
|
||||
expect(repoSave).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Go cmd/ helper files should NOT get entry-point multiplier (P0-1 fix)
|
||||
// Only main.go files should get the 3.0 entry-point boost, not arbitrary
|
||||
|
|
|
|||
|
|
@ -12,6 +12,13 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly<Record<string, Readonly
|
|||
csharp: new Set([
|
||||
'emits the using-import edge App/Program.cs -> Models/User.cs through the scope-resolution path',
|
||||
]),
|
||||
go: new Set([
|
||||
// The legacy DAG path does not resolve method calls when the method is
|
||||
// defined in a different file from the receiver type (go-split-method-owner
|
||||
// fixture). This requires scope-based cross-file package-sibling resolution
|
||||
// which is only available in the registry-primary path.
|
||||
'resolves user.Save() to the method whose receiver type is declared in another package file',
|
||||
]),
|
||||
python: new Set([
|
||||
// Suffix-fallback lex tiebreak depends on the registry-primary
|
||||
// resolver's deterministic sort. The legacy resolver returns the
|
||||
|
|
|
|||
|
|
@ -96,8 +96,73 @@ describe('git-clone', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('does not block valid public IPs', () => {
|
||||
it('blocks IPv4-compatible IPv6 (RFC 4291 deprecated, ::w.x.y.z)', () => {
|
||||
// Node's URL parser collapses ::127.0.0.1 to ::7f00:1 — no ::ffff: marker,
|
||||
// but still routable to 127.0.0.1 on most stacks.
|
||||
expect(() => validateGitUrl('http://[::127.0.0.1]/repo.git')).toThrow('private/internal');
|
||||
expect(() => validateGitUrl('http://[::7f00:1]/repo.git')).toThrow('private/internal');
|
||||
// 169.254.169.254 (cloud metadata) embedded as IPv4-compatible
|
||||
expect(() => validateGitUrl('http://[::a9fe:a9fe]/repo.git')).toThrow('private/internal');
|
||||
});
|
||||
|
||||
it('blocks IPv4-compatible IPv6 in expanded / zero-padded forms', () => {
|
||||
// The compressed-form check above relies on the WHATWG URL parser
|
||||
// normalising fully-expanded inputs to ::xxxx[:yyyy]. These cases pin
|
||||
// that assumption: if a future Node release stops collapsing them, a
|
||||
// bypass would silently re-open without these tests catching it.
|
||||
expect(() => validateGitUrl('http://[0:0:0:0:0:0:7f00:1]/repo.git')).toThrow(
|
||||
'private/internal',
|
||||
);
|
||||
expect(() =>
|
||||
validateGitUrl('http://[0000:0000:0000:0000:0000:0000:7f00:0001]/repo.git'),
|
||||
).toThrow('private/internal');
|
||||
// Mixed notation: trailing IPv4 quad in an otherwise expanded address.
|
||||
expect(() => validateGitUrl('http://[0:0:0:0:0:0:127.0.0.1]/repo.git')).toThrow(
|
||||
'private/internal',
|
||||
);
|
||||
});
|
||||
|
||||
it('blocks NAT64 well-known prefix (64:ff9b::/96)', () => {
|
||||
// 64:ff9b::7f00:1 → 127.0.0.1 via NAT64 translation
|
||||
expect(() => validateGitUrl('http://[64:ff9b::7f00:1]/repo.git')).toThrow('private/internal');
|
||||
expect(() => validateGitUrl('http://[64:ff9b::a9fe:a9fe]/repo.git')).toThrow(
|
||||
'private/internal',
|
||||
);
|
||||
// RFC 8215 local NAT64 prefix
|
||||
expect(() => validateGitUrl('http://[64:ff9b:1::1]/repo.git')).toThrow('private/internal');
|
||||
});
|
||||
|
||||
it('blocks NAT64 with embedded RFC1918 addresses', () => {
|
||||
// The startsWith('64:ff9b:') check covers any embedded IPv4. These
|
||||
// explicit RFC1918 cases document SSRF coverage for the full private
|
||||
// IPv4 surface — not just loopback and cloud metadata.
|
||||
expect(() => validateGitUrl('http://[64:ff9b::a00:1]/repo.git')).toThrow('private/internal'); // 10.0.0.1
|
||||
expect(() => validateGitUrl('http://[64:ff9b::ac10:1]/repo.git')).toThrow('private/internal'); // 172.16.0.1
|
||||
expect(() => validateGitUrl('http://[64:ff9b::c0a8:101]/repo.git')).toThrow(
|
||||
'private/internal',
|
||||
); // 192.168.1.1
|
||||
});
|
||||
|
||||
it('blocks 6to4 prefix (2002::/16, RFC 3056)', () => {
|
||||
// 6to4 encodes an IPv4 address in bits 17-48, so 2002:WWXX:YYZZ::*
|
||||
// routes to W.X.Y.Z on 6to4-capable stacks. The protocol is deprecated
|
||||
// (RFC 7526), so the entire 2002::/16 block is defensively rejected.
|
||||
expect(() => validateGitUrl('http://[2002:7f00:1::1]/repo.git')).toThrow('private/internal'); // 127.0.0.1
|
||||
expect(() => validateGitUrl('http://[2002:a9fe:a9fe::1]/repo.git')).toThrow(
|
||||
'private/internal',
|
||||
); // 169.254.169.254
|
||||
expect(() => validateGitUrl('http://[2002:c0a8:101::1]/repo.git')).toThrow(
|
||||
'private/internal',
|
||||
); // 192.168.1.1
|
||||
});
|
||||
|
||||
it('does not block valid public IPs (IPv4 and IPv6)', () => {
|
||||
expect(() => validateGitUrl('https://140.82.121.4/repo.git')).not.toThrow();
|
||||
// Regression guard against over-blocking legitimate public IPv6.
|
||||
// Cloudflare DNS (2606:4700::/32) and Google DNS (2001:4860::/32) —
|
||||
// chosen because their prefixes don't collide with any block above.
|
||||
expect(() => validateGitUrl('https://[2606:4700:4700::1111]/repo.git')).not.toThrow();
|
||||
expect(() => validateGitUrl('https://[2001:4860:4860::8888]/repo.git')).not.toThrow();
|
||||
});
|
||||
|
||||
it('blocks CGN range (100.64.0.0/10)', () => {
|
||||
|
|
|
|||
261
gitnexus/test/unit/group/elixir-workspace-extractor.test.ts
Normal file
261
gitnexus/test/unit/group/elixir-workspace-extractor.test.ts
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { extractElixirWorkspaceLinks } from '../../../src/core/group/extractors/elixir-workspace-extractor.js';
|
||||
|
||||
describe('ElixirWorkspaceExtractor', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-ex-ws-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function writeFile(relPath: string, content: string) {
|
||||
const absPath = path.join(tmpDir, relPath);
|
||||
await fs.mkdir(path.dirname(absPath), { recursive: true });
|
||||
await fs.writeFile(absPath, content, 'utf-8');
|
||||
}
|
||||
|
||||
it('discovers cross-app alias imports', async () => {
|
||||
await writeFile(
|
||||
'core/mix.exs',
|
||||
'defmodule Core.MixProject do\n use Mix.Project\n def project do\n [app: :core, version: "0.1.0"]\n end\nend\n',
|
||||
);
|
||||
await writeFile('core/lib/core/schema.ex', 'defmodule Core.Schema do\nend\n');
|
||||
|
||||
await writeFile(
|
||||
'web/mix.exs',
|
||||
'defmodule Web.MixProject do\n use Mix.Project\n def project do\n [app: :web, version: "0.1.0"]\n end\n defp deps do\n [{:core, in_umbrella: true}]\n end\nend\n',
|
||||
);
|
||||
await writeFile(
|
||||
'web/lib/web/controller.ex',
|
||||
'defmodule Web.Controller do\n alias Core.Schema\nend\n',
|
||||
);
|
||||
|
||||
const repos = { core: 'core', web: 'web' };
|
||||
const repoPaths = new Map([
|
||||
['core', path.join(tmpDir, 'core')],
|
||||
['web', path.join(tmpDir, 'web')],
|
||||
]);
|
||||
|
||||
const result = await extractElixirWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
expect(result.links[0]).toEqual({
|
||||
from: 'core',
|
||||
to: 'web',
|
||||
type: 'custom',
|
||||
contract: 'Core.Schema',
|
||||
role: 'provider',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles grouped alias (alias MyApp.{ModA, ModB})', async () => {
|
||||
await writeFile(
|
||||
'shared/mix.exs',
|
||||
'defmodule Shared.MixProject do\n use Mix.Project\n def project do\n [app: :shared, version: "0.1.0"]\n end\nend\n',
|
||||
);
|
||||
await writeFile('shared/lib/shared/config.ex', 'defmodule Shared.Config do\nend\n');
|
||||
await writeFile('shared/lib/shared/logger.ex', 'defmodule Shared.Logger do\nend\n');
|
||||
|
||||
await writeFile(
|
||||
'app/mix.exs',
|
||||
'defmodule App.MixProject do\n use Mix.Project\n def project do\n [app: :app, version: "0.1.0"]\n end\n defp deps do\n [{:shared, "~> 0.1"}]\n end\nend\n',
|
||||
);
|
||||
await writeFile(
|
||||
'app/lib/app/main.ex',
|
||||
'defmodule App.Main do\n alias Shared.{Config, Logger}\nend\n',
|
||||
);
|
||||
|
||||
const repos = { shared: 'shared', app: 'app' };
|
||||
const repoPaths = new Map([
|
||||
['shared', path.join(tmpDir, 'shared')],
|
||||
['app', path.join(tmpDir, 'app')],
|
||||
]);
|
||||
|
||||
const result = await extractElixirWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(2);
|
||||
const contracts = result.links.map((l) => l.contract).sort();
|
||||
expect(contracts).toEqual(['Shared.Config', 'Shared.Logger']);
|
||||
});
|
||||
|
||||
it('handles direct module references (no alias)', async () => {
|
||||
await writeFile(
|
||||
'auth/mix.exs',
|
||||
'defmodule Auth.MixProject do\n use Mix.Project\n def project do\n [app: :auth, version: "0.1.0"]\n end\nend\n',
|
||||
);
|
||||
await writeFile('auth/lib/auth/token.ex', 'defmodule Auth.Token do\nend\n');
|
||||
|
||||
await writeFile(
|
||||
'api/mix.exs',
|
||||
'defmodule Api.MixProject do\n use Mix.Project\n def project do\n [app: :api, version: "0.1.0"]\n end\n defp deps do\n [{:auth, path: "../auth"}]\n end\nend\n',
|
||||
);
|
||||
await writeFile(
|
||||
'api/lib/api/handler.ex',
|
||||
'defmodule Api.Handler do\n def verify do\n Auth.Token.verify()\n end\nend\n',
|
||||
);
|
||||
|
||||
const repos = { auth: 'auth', api: 'api' };
|
||||
const repoPaths = new Map([
|
||||
['auth', path.join(tmpDir, 'auth')],
|
||||
['api', path.join(tmpDir, 'api')],
|
||||
]);
|
||||
|
||||
const result = await extractElixirWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
expect(result.links[0].contract).toBe('Auth.Token');
|
||||
});
|
||||
|
||||
it('handles underscore app names (my_app -> MyApp)', async () => {
|
||||
await writeFile(
|
||||
'data-store/mix.exs',
|
||||
'defmodule DataStore.MixProject do\n use Mix.Project\n def project do\n [app: :data_store, version: "0.1.0"]\n end\nend\n',
|
||||
);
|
||||
await writeFile('data-store/lib/data_store/repo.ex', 'defmodule DataStore.Repo do\nend\n');
|
||||
|
||||
await writeFile(
|
||||
'web/mix.exs',
|
||||
'defmodule Web.MixProject do\n use Mix.Project\n def project do\n [app: :web, version: "0.1.0"]\n end\n defp deps do\n [{:data_store, in_umbrella: true}]\n end\nend\n',
|
||||
);
|
||||
await writeFile('web/lib/web/page.ex', 'defmodule Web.Page do\n alias DataStore.Repo\nend\n');
|
||||
|
||||
const repos = { store: 'data_store', web: 'web' };
|
||||
const repoPaths = new Map([
|
||||
['store', path.join(tmpDir, 'data-store')],
|
||||
['web', path.join(tmpDir, 'web')],
|
||||
]);
|
||||
|
||||
const result = await extractElixirWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
expect(result.links[0].contract).toBe('DataStore.Repo');
|
||||
});
|
||||
|
||||
it('skips repos without mix.exs', async () => {
|
||||
await writeFile('js-app/package.json', '{"name": "js-app"}');
|
||||
|
||||
const repos = { app: 'js-app' };
|
||||
const repoPaths = new Map([['app', path.join(tmpDir, 'js-app')]]);
|
||||
|
||||
const result = await extractElixirWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(0);
|
||||
expect(result.discoveredApps.size).toBe(0);
|
||||
});
|
||||
|
||||
it('deduplicates identical module refs from multiple files', async () => {
|
||||
await writeFile(
|
||||
'lib/mix.exs',
|
||||
'defmodule Lib.MixProject do\n use Mix.Project\n def project do\n [app: :lib, version: "0.1.0"]\n end\nend\n',
|
||||
);
|
||||
await writeFile('lib/lib/lib/config.ex', 'defmodule Lib.Config do\nend\n');
|
||||
|
||||
await writeFile(
|
||||
'app/mix.exs',
|
||||
'defmodule App.MixProject do\n use Mix.Project\n def project do\n [app: :app, version: "0.1.0"]\n end\n defp deps do\n [{:lib, "~> 0.1"}]\n end\nend\n',
|
||||
);
|
||||
await writeFile('app/lib/app/a.ex', 'defmodule App.A do\n alias Lib.Config\nend\n');
|
||||
await writeFile('app/lib/app/b.ex', 'defmodule App.B do\n alias Lib.Config\nend\n');
|
||||
|
||||
const repos = { lib: 'lib', app: 'app' };
|
||||
const repoPaths = new Map([
|
||||
['lib', path.join(tmpDir, 'lib')],
|
||||
['app', path.join(tmpDir, 'app')],
|
||||
]);
|
||||
|
||||
const result = await extractElixirWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('collapses nested submodules to top-level module contract', async () => {
|
||||
await writeFile(
|
||||
'core/mix.exs',
|
||||
'defmodule Core.MixProject do\n use Mix.Project\n def project do\n [app: :core, version: "0.1.0"]\n end\nend\n',
|
||||
);
|
||||
await writeFile('core/lib/core/auth/token.ex', 'defmodule Core.Auth.Token do\nend\n');
|
||||
await writeFile('core/lib/core/auth/session.ex', 'defmodule Core.Auth.Session do\nend\n');
|
||||
|
||||
await writeFile(
|
||||
'web/mix.exs',
|
||||
'defmodule Web.MixProject do\n use Mix.Project\n def project do\n [app: :web, version: "0.1.0"]\n end\n defp deps do\n [{:core, in_umbrella: true}]\n end\nend\n',
|
||||
);
|
||||
await writeFile(
|
||||
'web/lib/web/ctrl.ex',
|
||||
'defmodule Web.Ctrl do\n alias Core.Auth.Token\n alias Core.Auth.Session\nend\n',
|
||||
);
|
||||
|
||||
const repos = { core: 'core', web: 'web' };
|
||||
const repoPaths = new Map([
|
||||
['core', path.join(tmpDir, 'core')],
|
||||
['web', path.join(tmpDir, 'web')],
|
||||
]);
|
||||
|
||||
const result = await extractElixirWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
expect(result.links[0].contract).toBe('Core.Auth');
|
||||
});
|
||||
|
||||
it('does not produce false positives from module references in comments', async () => {
|
||||
await writeFile(
|
||||
'auth/mix.exs',
|
||||
'defmodule Auth.MixProject do\n use Mix.Project\n def project do\n [app: :auth, version: "0.1.0"]\n end\nend\n',
|
||||
);
|
||||
await writeFile('auth/lib/auth/token.ex', 'defmodule Auth.Token do\nend\n');
|
||||
|
||||
await writeFile(
|
||||
'api/mix.exs',
|
||||
'defmodule Api.MixProject do\n use Mix.Project\n def project do\n [app: :api, version: "0.1.0"]\n end\n defp deps do\n [{:auth, path: "../auth"}]\n end\nend\n',
|
||||
);
|
||||
await writeFile(
|
||||
'api/lib/api/handler.ex',
|
||||
'defmodule Api.Handler do\n # See Auth.Token for details\n # Auth.Token.verify() is deprecated\n def handle, do: :ok\nend\n',
|
||||
);
|
||||
|
||||
const repos = { auth: 'auth', api: 'api' };
|
||||
const repoPaths = new Map([
|
||||
['auth', path.join(tmpDir, 'auth')],
|
||||
['api', path.join(tmpDir, 'api')],
|
||||
]);
|
||||
|
||||
const result = await extractElixirWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('handles git and path deps alongside umbrella deps', async () => {
|
||||
await writeFile(
|
||||
'utils/mix.exs',
|
||||
'defmodule Utils.MixProject do\n use Mix.Project\n def project do\n [app: :utils, version: "0.1.0"]\n end\nend\n',
|
||||
);
|
||||
await writeFile('utils/lib/utils/helper.ex', 'defmodule Utils.Helper do\nend\n');
|
||||
|
||||
await writeFile(
|
||||
'svc/mix.exs',
|
||||
'defmodule Svc.MixProject do\n use Mix.Project\n def project do\n [app: :svc, version: "0.1.0"]\n end\n defp deps do\n [{:utils, git: "https://github.com/org/utils.git"}]\n end\nend\n',
|
||||
);
|
||||
await writeFile(
|
||||
'svc/lib/svc/worker.ex',
|
||||
'defmodule Svc.Worker do\n alias Utils.Helper\nend\n',
|
||||
);
|
||||
|
||||
const repos = { utils: 'utils', svc: 'svc' };
|
||||
const repoPaths = new Map([
|
||||
['utils', path.join(tmpDir, 'utils')],
|
||||
['svc', path.join(tmpDir, 'svc')],
|
||||
]);
|
||||
|
||||
const result = await extractElixirWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
expect(result.links[0].contract).toBe('Utils.Helper');
|
||||
});
|
||||
});
|
||||
244
gitnexus/test/unit/group/go-workspace-extractor.test.ts
Normal file
244
gitnexus/test/unit/group/go-workspace-extractor.test.ts
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { extractGoWorkspaceLinks } from '../../../src/core/group/extractors/go-workspace-extractor.js';
|
||||
|
||||
describe('GoWorkspaceExtractor', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-go-ws-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function writeFile(relPath: string, content: string) {
|
||||
const absPath = path.join(tmpDir, relPath);
|
||||
await fs.mkdir(path.dirname(absPath), { recursive: true });
|
||||
await fs.writeFile(absPath, content, 'utf-8');
|
||||
}
|
||||
|
||||
it('discovers cross-module type usage via require', async () => {
|
||||
await writeFile('models/go.mod', 'module github.com/org/models\n\ngo 1.21\n');
|
||||
await writeFile('models/schema.go', 'package models\n\ntype Schema struct {}\n');
|
||||
|
||||
await writeFile(
|
||||
'api/go.mod',
|
||||
'module github.com/org/api\n\ngo 1.21\n\nrequire github.com/org/models v0.1.0\n',
|
||||
);
|
||||
await writeFile(
|
||||
'api/main.go',
|
||||
'package main\n\nimport "github.com/org/models"\n\nfunc main() {\n\tvar s models.Schema\n\t_ = s\n}\n',
|
||||
);
|
||||
|
||||
const repos = {
|
||||
'libs/models': 'models',
|
||||
'services/api': 'api',
|
||||
};
|
||||
const repoPaths = new Map([
|
||||
['libs/models', path.join(tmpDir, 'models')],
|
||||
['services/api', path.join(tmpDir, 'api')],
|
||||
]);
|
||||
|
||||
const result = await extractGoWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
expect(result.links[0]).toEqual({
|
||||
from: 'libs/models',
|
||||
to: 'services/api',
|
||||
type: 'custom',
|
||||
contract: 'github.com/org/models::Schema',
|
||||
role: 'provider',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles block require syntax', async () => {
|
||||
await writeFile('auth/go.mod', 'module github.com/org/auth\n\ngo 1.21\n');
|
||||
await writeFile('auth/token.go', 'package auth\n\ntype Token struct {}\n');
|
||||
|
||||
await writeFile(
|
||||
'svc/go.mod',
|
||||
'module github.com/org/svc\n\ngo 1.21\n\nrequire (\n\tgithub.com/org/auth v1.0.0\n)\n',
|
||||
);
|
||||
await writeFile(
|
||||
'svc/main.go',
|
||||
'package main\n\nimport (\n\t"github.com/org/auth"\n)\n\nfunc handle() auth.Token { return auth.Token{} }\n',
|
||||
);
|
||||
|
||||
const repos = { auth: 'auth', svc: 'svc' };
|
||||
const repoPaths = new Map([
|
||||
['auth', path.join(tmpDir, 'auth')],
|
||||
['svc', path.join(tmpDir, 'svc')],
|
||||
]);
|
||||
|
||||
const result = await extractGoWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
expect(result.links[0].contract).toBe('github.com/org/auth::Token');
|
||||
});
|
||||
|
||||
it('handles subpackage imports (module/pkg)', async () => {
|
||||
await writeFile('core/go.mod', 'module github.com/org/core\n\ngo 1.21\n');
|
||||
await writeFile('core/types/entity.go', 'package types\n\ntype Entity struct {}\n');
|
||||
|
||||
await writeFile(
|
||||
'app/go.mod',
|
||||
'module github.com/org/app\n\ngo 1.21\n\nrequire github.com/org/core v0.1.0\n',
|
||||
);
|
||||
await writeFile(
|
||||
'app/main.go',
|
||||
'package main\n\nimport "github.com/org/core/types"\n\nvar e types.Entity\n',
|
||||
);
|
||||
|
||||
const repos = { core: 'core', app: 'app' };
|
||||
const repoPaths = new Map([
|
||||
['core', path.join(tmpDir, 'core')],
|
||||
['app', path.join(tmpDir, 'app')],
|
||||
]);
|
||||
|
||||
const result = await extractGoWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
expect(result.links[0].contract).toBe('github.com/org/core::Entity');
|
||||
});
|
||||
|
||||
it('handles replace directive with local paths', async () => {
|
||||
await writeFile('lib/go.mod', 'module github.com/org/lib\n\ngo 1.21\n');
|
||||
await writeFile('lib/config.go', 'package lib\n\ntype Config struct {}\n');
|
||||
|
||||
await writeFile(
|
||||
'app/go.mod',
|
||||
'module github.com/org/app\n\ngo 1.21\n\nrequire github.com/org/lib v0.0.0\n\nreplace github.com/org/lib => ./lib\n',
|
||||
);
|
||||
await writeFile(
|
||||
'app/main.go',
|
||||
'package main\n\nimport "github.com/org/lib"\n\nvar c lib.Config\n',
|
||||
);
|
||||
|
||||
const repos = { lib: 'lib', app: 'app' };
|
||||
const repoPaths = new Map([
|
||||
['lib', path.join(tmpDir, 'lib')],
|
||||
['app', path.join(tmpDir, 'app')],
|
||||
]);
|
||||
|
||||
const result = await extractGoWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
expect(result.links[0].contract).toBe('github.com/org/lib::Config');
|
||||
});
|
||||
|
||||
it('ignores unexported (lowercase) identifiers', async () => {
|
||||
await writeFile('lib/go.mod', 'module github.com/org/lib\n\ngo 1.21\n');
|
||||
await writeFile('lib/util.go', 'package lib\n\nfunc helper() {}\ntype Config struct {}\n');
|
||||
|
||||
await writeFile(
|
||||
'app/go.mod',
|
||||
'module github.com/org/app\n\ngo 1.21\n\nrequire github.com/org/lib v0.1.0\n',
|
||||
);
|
||||
await writeFile(
|
||||
'app/main.go',
|
||||
'package main\n\nimport "github.com/org/lib"\n\nvar c lib.Config\n',
|
||||
);
|
||||
|
||||
const repos = { lib: 'lib', app: 'app' };
|
||||
const repoPaths = new Map([
|
||||
['lib', path.join(tmpDir, 'lib')],
|
||||
['app', path.join(tmpDir, 'app')],
|
||||
]);
|
||||
|
||||
const result = await extractGoWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
expect(result.links[0].contract).toBe('github.com/org/lib::Config');
|
||||
});
|
||||
|
||||
it('does not produce links for aliased imports (V1 false-negative limitation)', async () => {
|
||||
await writeFile('shared/go.mod', 'module github.com/org/shared\n\ngo 1.21\n');
|
||||
await writeFile('shared/config.go', 'package shared\n\ntype Config struct {}\n');
|
||||
|
||||
await writeFile(
|
||||
'app/go.mod',
|
||||
'module github.com/org/app\n\ngo 1.21\n\nrequire github.com/org/shared v0.1.0\n',
|
||||
);
|
||||
await writeFile(
|
||||
'app/main.go',
|
||||
'package main\n\nimport cfg "github.com/org/shared"\n\nvar c cfg.Config\n',
|
||||
);
|
||||
|
||||
const repos = { shared: 'shared', app: 'app' };
|
||||
const repoPaths = new Map([
|
||||
['shared', path.join(tmpDir, 'shared')],
|
||||
['app', path.join(tmpDir, 'app')],
|
||||
]);
|
||||
|
||||
const result = await extractGoWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('skips repos without go.mod', async () => {
|
||||
await writeFile('js-app/package.json', '{"name": "js-app"}');
|
||||
|
||||
const repos = { app: 'js-app' };
|
||||
const repoPaths = new Map([['app', path.join(tmpDir, 'js-app')]]);
|
||||
|
||||
const result = await extractGoWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(0);
|
||||
expect(result.discoveredModules.size).toBe(0);
|
||||
});
|
||||
|
||||
it('deduplicates identical type usage from multiple files', async () => {
|
||||
await writeFile('lib/go.mod', 'module github.com/org/lib\n\ngo 1.21\n');
|
||||
await writeFile('lib/model.go', 'package lib\n\ntype Model struct {}\n');
|
||||
|
||||
await writeFile(
|
||||
'app/go.mod',
|
||||
'module github.com/org/app\n\ngo 1.21\n\nrequire github.com/org/lib v0.1.0\n',
|
||||
);
|
||||
await writeFile('app/a.go', 'package main\n\nimport "github.com/org/lib"\n\nvar x lib.Model\n');
|
||||
await writeFile('app/b.go', 'package main\n\nimport "github.com/org/lib"\n\nvar y lib.Model\n');
|
||||
|
||||
const repos = { lib: 'lib', app: 'app' };
|
||||
const repoPaths = new Map([
|
||||
['lib', path.join(tmpDir, 'lib')],
|
||||
['app', path.join(tmpDir, 'app')],
|
||||
]);
|
||||
|
||||
const result = await extractGoWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('discovers multiple types from the same module', async () => {
|
||||
await writeFile('lib/go.mod', 'module github.com/org/lib\n\ngo 1.21\n');
|
||||
await writeFile(
|
||||
'lib/types.go',
|
||||
'package lib\n\ntype Request struct {}\ntype Response struct {}\n',
|
||||
);
|
||||
|
||||
await writeFile(
|
||||
'app/go.mod',
|
||||
'module github.com/org/app\n\ngo 1.21\n\nrequire github.com/org/lib v0.1.0\n',
|
||||
);
|
||||
await writeFile(
|
||||
'app/main.go',
|
||||
'package main\n\nimport "github.com/org/lib"\n\nfunc handle(r lib.Request) lib.Response { return lib.Response{} }\n',
|
||||
);
|
||||
|
||||
const repos = { lib: 'lib', app: 'app' };
|
||||
const repoPaths = new Map([
|
||||
['lib', path.join(tmpDir, 'lib')],
|
||||
['app', path.join(tmpDir, 'app')],
|
||||
]);
|
||||
|
||||
const result = await extractGoWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(2);
|
||||
const contracts = result.links.map((l) => l.contract).sort();
|
||||
expect(contracts).toEqual(['github.com/org/lib::Request', 'github.com/org/lib::Response']);
|
||||
});
|
||||
});
|
||||
240
gitnexus/test/unit/group/java-workspace-extractor.test.ts
Normal file
240
gitnexus/test/unit/group/java-workspace-extractor.test.ts
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { extractJavaWorkspaceLinks } from '../../../src/core/group/extractors/java-workspace-extractor.js';
|
||||
|
||||
describe('JavaWorkspaceExtractor', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-java-ws-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function writeFile(relPath: string, content: string) {
|
||||
const absPath = path.join(tmpDir, relPath);
|
||||
await fs.mkdir(path.dirname(absPath), { recursive: true });
|
||||
await fs.writeFile(absPath, content, 'utf-8');
|
||||
}
|
||||
|
||||
const pomTemplate = (g: string, a: string, deps: string[] = []) => {
|
||||
const depXml = deps
|
||||
.map((d) => {
|
||||
const [gid, aid] = d.split(':');
|
||||
return `<dependency><groupId>${gid}</groupId><artifactId>${aid}</artifactId></dependency>`;
|
||||
})
|
||||
.join('\n');
|
||||
return `<project><groupId>${g}</groupId><artifactId>${a}</artifactId><dependencies>${depXml}</dependencies></project>`;
|
||||
};
|
||||
|
||||
it('discovers cross-project imports via Maven pom.xml', async () => {
|
||||
await writeFile('models/pom.xml', pomTemplate('com.acme', 'models'));
|
||||
await writeFile(
|
||||
'models/src/main/java/com/acme/models/User.java',
|
||||
'package com.acme.models;\npublic class User {}\n',
|
||||
);
|
||||
|
||||
await writeFile('api/pom.xml', pomTemplate('com.acme', 'api', ['com.acme:models']));
|
||||
await writeFile(
|
||||
'api/src/main/java/com/acme/api/UserService.java',
|
||||
'package com.acme.api;\nimport com.acme.models.User;\npublic class UserService {}\n',
|
||||
);
|
||||
|
||||
const repos = { models: 'models', api: 'api' };
|
||||
const repoPaths = new Map([
|
||||
['models', path.join(tmpDir, 'models')],
|
||||
['api', path.join(tmpDir, 'api')],
|
||||
]);
|
||||
|
||||
const result = await extractJavaWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
expect(result.links[0]).toEqual({
|
||||
from: 'models',
|
||||
to: 'api',
|
||||
type: 'custom',
|
||||
contract: 'models::User',
|
||||
role: 'provider',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles Gradle build files', async () => {
|
||||
await writeFile('core/build.gradle.kts', 'group = "com.acme"\nversion = "1.0"\n');
|
||||
await writeFile(
|
||||
'core/src/main/java/com/acme/core/Config.java',
|
||||
'package com.acme.core;\npublic class Config {}\n',
|
||||
);
|
||||
|
||||
await writeFile(
|
||||
'svc/build.gradle.kts',
|
||||
'group = "com.acme"\nversion = "1.0"\ndependencies {\n implementation("com.acme:core:1.0")\n}\n',
|
||||
);
|
||||
await writeFile(
|
||||
'svc/src/main/java/com/acme/svc/App.java',
|
||||
'package com.acme.svc;\nimport com.acme.core.Config;\npublic class App {}\n',
|
||||
);
|
||||
|
||||
const repos = { core: 'core', svc: 'svc' };
|
||||
const repoPaths = new Map([
|
||||
['core', path.join(tmpDir, 'core')],
|
||||
['svc', path.join(tmpDir, 'svc')],
|
||||
]);
|
||||
|
||||
const result = await extractJavaWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
expect(result.links[0].contract).toBe('core::Config');
|
||||
});
|
||||
|
||||
it('handles Gradle project dependencies', async () => {
|
||||
await writeFile('common/build.gradle', "group = 'com.org'\nversion = '1.0'\n");
|
||||
await writeFile(
|
||||
'common/src/main/java/com/org/common/Entity.java',
|
||||
'package com.org.common;\npublic class Entity {}\n',
|
||||
);
|
||||
|
||||
await writeFile(
|
||||
'app/build.gradle',
|
||||
"group = 'com.org'\nversion = '1.0'\ndependencies {\n implementation(project(':common'))\n}\n",
|
||||
);
|
||||
await writeFile(
|
||||
'app/src/main/java/com/org/app/Main.java',
|
||||
'package com.org.app;\nimport com.org.common.Entity;\npublic class Main {}\n',
|
||||
);
|
||||
|
||||
const repos = { common: 'common', app: 'app' };
|
||||
const repoPaths = new Map([
|
||||
['common', path.join(tmpDir, 'common')],
|
||||
['app', path.join(tmpDir, 'app')],
|
||||
]);
|
||||
|
||||
const result = await extractJavaWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
expect(result.links[0].contract).toBe('common::Entity');
|
||||
});
|
||||
|
||||
it('handles static imports', async () => {
|
||||
await writeFile('lib/pom.xml', pomTemplate('com.acme', 'lib'));
|
||||
await writeFile(
|
||||
'lib/src/main/java/com/acme/lib/Constants.java',
|
||||
'package com.acme.lib;\npublic class Constants {}\n',
|
||||
);
|
||||
|
||||
await writeFile('app/pom.xml', pomTemplate('com.acme', 'app', ['com.acme:lib']));
|
||||
await writeFile(
|
||||
'app/src/main/java/com/acme/app/Main.java',
|
||||
'package com.acme.app;\nimport static com.acme.lib.Constants;\npublic class Main {}\n',
|
||||
);
|
||||
|
||||
const repos = { lib: 'lib', app: 'app' };
|
||||
const repoPaths = new Map([
|
||||
['lib', path.join(tmpDir, 'lib')],
|
||||
['app', path.join(tmpDir, 'app')],
|
||||
]);
|
||||
|
||||
const result = await extractJavaWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
expect(result.links[0].contract).toBe('lib::Constants');
|
||||
});
|
||||
|
||||
it('skips repos without Java manifest', async () => {
|
||||
await writeFile('rs-app/Cargo.toml', '[package]\nname = "rapp"\n');
|
||||
|
||||
const repos = { app: 'rapp' };
|
||||
const repoPaths = new Map([['app', path.join(tmpDir, 'rs-app')]]);
|
||||
|
||||
const result = await extractJavaWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(0);
|
||||
expect(result.discoveredProjects.size).toBe(0);
|
||||
});
|
||||
|
||||
it('deduplicates identical imports from multiple files', async () => {
|
||||
await writeFile('lib/pom.xml', pomTemplate('com.acme', 'lib'));
|
||||
await writeFile(
|
||||
'lib/src/main/java/com/acme/lib/Token.java',
|
||||
'package com.acme.lib;\npublic class Token {}\n',
|
||||
);
|
||||
|
||||
await writeFile('app/pom.xml', pomTemplate('com.acme', 'app', ['com.acme:lib']));
|
||||
await writeFile(
|
||||
'app/src/main/java/com/acme/app/A.java',
|
||||
'package com.acme.app;\nimport com.acme.lib.Token;\npublic class A {}\n',
|
||||
);
|
||||
await writeFile(
|
||||
'app/src/main/java/com/acme/app/B.java',
|
||||
'package com.acme.app;\nimport com.acme.lib.Token;\npublic class B {}\n',
|
||||
);
|
||||
|
||||
const repos = { lib: 'lib', app: 'app' };
|
||||
const repoPaths = new Map([
|
||||
['lib', path.join(tmpDir, 'lib')],
|
||||
['app', path.join(tmpDir, 'app')],
|
||||
]);
|
||||
|
||||
const result = await extractJavaWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('discovers Kotlin file imports from Java projects', async () => {
|
||||
await writeFile('lib/pom.xml', pomTemplate('com.acme', 'lib'));
|
||||
await writeFile(
|
||||
'lib/src/main/kotlin/com/acme/lib/Model.kt',
|
||||
'package com.acme.lib\ndata class Model(val id: Int)\n',
|
||||
);
|
||||
|
||||
await writeFile('app/pom.xml', pomTemplate('com.acme', 'app', ['com.acme:lib']));
|
||||
await writeFile(
|
||||
'app/src/main/kotlin/com/acme/app/Main.kt',
|
||||
'package com.acme.app\nimport com.acme.lib.Model\nfun main() {}\n',
|
||||
);
|
||||
|
||||
const repos = { lib: 'lib', app: 'app' };
|
||||
const repoPaths = new Map([
|
||||
['lib', path.join(tmpDir, 'lib')],
|
||||
['app', path.join(tmpDir, 'app')],
|
||||
]);
|
||||
|
||||
const result = await extractJavaWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
expect(result.links[0].contract).toBe('lib::Model');
|
||||
});
|
||||
|
||||
it('discovers multiple types from the same dependency', async () => {
|
||||
await writeFile('lib/pom.xml', pomTemplate('com.acme', 'lib'));
|
||||
await writeFile(
|
||||
'lib/src/main/java/com/acme/lib/Request.java',
|
||||
'package com.acme.lib;\npublic class Request {}\n',
|
||||
);
|
||||
await writeFile(
|
||||
'lib/src/main/java/com/acme/lib/Response.java',
|
||||
'package com.acme.lib;\npublic class Response {}\n',
|
||||
);
|
||||
|
||||
await writeFile('app/pom.xml', pomTemplate('com.acme', 'app', ['com.acme:lib']));
|
||||
await writeFile(
|
||||
'app/src/main/java/com/acme/app/Handler.java',
|
||||
'package com.acme.app;\nimport com.acme.lib.Request;\nimport com.acme.lib.Response;\npublic class Handler {}\n',
|
||||
);
|
||||
|
||||
const repos = { lib: 'lib', app: 'app' };
|
||||
const repoPaths = new Map([
|
||||
['lib', path.join(tmpDir, 'lib')],
|
||||
['app', path.join(tmpDir, 'app')],
|
||||
]);
|
||||
|
||||
const result = await extractJavaWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(2);
|
||||
const contracts = result.links.map((l) => l.contract).sort();
|
||||
expect(contracts).toEqual(['lib::Request', 'lib::Response']);
|
||||
});
|
||||
});
|
||||
|
|
@ -596,7 +596,7 @@ describe('ManifestExtractor', () => {
|
|||
[
|
||||
'engine/thales',
|
||||
async (_cypher, params) => {
|
||||
if (params?.contract === 'Expression') {
|
||||
if (params?.symbolName === 'Expression') {
|
||||
return [
|
||||
{
|
||||
uid: 'uid-expression-struct',
|
||||
|
|
@ -611,7 +611,7 @@ describe('ManifestExtractor', () => {
|
|||
[
|
||||
'parser/mathlex',
|
||||
async (_cypher, params) => {
|
||||
if (params?.contract === 'Expression') {
|
||||
if (params?.symbolName === 'Expression') {
|
||||
return [
|
||||
{
|
||||
uid: 'uid-expression-enum',
|
||||
|
|
@ -640,6 +640,42 @@ describe('ManifestExtractor', () => {
|
|||
expect(result.crossLinks[0].matchType).toBe('manifest');
|
||||
});
|
||||
|
||||
it('custom contract with qualified name (provider::Symbol) strips prefix before graph query', async () => {
|
||||
const links: GroupManifestLink[] = [
|
||||
{
|
||||
from: 'parser/mathlex',
|
||||
to: 'engine/thales',
|
||||
type: 'custom',
|
||||
contract: 'mathlex::Expression',
|
||||
role: 'provider',
|
||||
},
|
||||
];
|
||||
|
||||
let capturedParams: Record<string, unknown> | undefined;
|
||||
const dbExecutors = new Map<
|
||||
string,
|
||||
(cypher: string, params?: Record<string, unknown>) => Promise<Record<string, unknown>[]>
|
||||
>([
|
||||
[
|
||||
'parser/mathlex',
|
||||
async (_cypher, params) => {
|
||||
capturedParams = params;
|
||||
if (params?.symbolName === 'Expression') {
|
||||
return [{ uid: 'uid-expr', name: 'Expression', filePath: 'src/ast.rs' }];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
],
|
||||
['engine/thales', async () => []],
|
||||
]);
|
||||
|
||||
const result = await extractor.extractFromManifest(links, dbExecutors);
|
||||
const provider = result.contracts.find((c) => c.role === 'provider');
|
||||
|
||||
expect(capturedParams?.symbolName).toBe('Expression');
|
||||
expect(provider?.symbolUid).toBe('uid-expr');
|
||||
});
|
||||
|
||||
it('falls back to synthetic uid when custom symbol not found in graph', async () => {
|
||||
const links: GroupManifestLink[] = [
|
||||
{
|
||||
|
|
@ -714,7 +750,7 @@ describe('ManifestExtractor', () => {
|
|||
[
|
||||
'parser/mathlex',
|
||||
async (_cypher, params) => {
|
||||
if (params?.contract === 'Token') {
|
||||
if (params?.symbolName === 'Token') {
|
||||
return [{ uid: 'uid-token-first', name: 'Token', filePath: 'src/ast.rs' }];
|
||||
}
|
||||
return [];
|
||||
|
|
@ -723,7 +759,7 @@ describe('ManifestExtractor', () => {
|
|||
[
|
||||
'engine/thales',
|
||||
async (_cypher, params) => {
|
||||
if (params?.contract === 'Token') {
|
||||
if (params?.symbolName === 'Token') {
|
||||
return [{ uid: 'uid-token-consumer', name: 'Token', filePath: 'src/lexer.rs' }];
|
||||
}
|
||||
return [];
|
||||
|
|
|
|||
285
gitnexus/test/unit/group/node-workspace-extractor.test.ts
Normal file
285
gitnexus/test/unit/group/node-workspace-extractor.test.ts
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { extractNodeWorkspaceLinks } from '../../../src/core/group/extractors/node-workspace-extractor.js';
|
||||
|
||||
describe('NodeWorkspaceExtractor', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-node-ws-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function writeFile(relPath: string, content: string) {
|
||||
const absPath = path.join(tmpDir, relPath);
|
||||
await fs.mkdir(path.dirname(absPath), { recursive: true });
|
||||
await fs.writeFile(absPath, content, 'utf-8');
|
||||
}
|
||||
|
||||
it('discovers cross-package ES imports', async () => {
|
||||
await writeFile(
|
||||
'pkg-a/package.json',
|
||||
JSON.stringify({ name: '@myorg/shared', version: '1.0.0' }),
|
||||
);
|
||||
await writeFile('pkg-a/src/index.ts', 'export class Config {}\nexport class Logger {}\n');
|
||||
|
||||
await writeFile(
|
||||
'pkg-b/package.json',
|
||||
JSON.stringify({
|
||||
name: '@myorg/api',
|
||||
version: '1.0.0',
|
||||
dependencies: { '@myorg/shared': 'workspace:*' },
|
||||
}),
|
||||
);
|
||||
await writeFile(
|
||||
'pkg-b/src/server.ts',
|
||||
"import { Config } from '@myorg/shared';\nconst c = new Config();\n",
|
||||
);
|
||||
|
||||
const repos = {
|
||||
'libs/shared': '@myorg/shared',
|
||||
'services/api': '@myorg/api',
|
||||
};
|
||||
const repoPaths = new Map([
|
||||
['libs/shared', path.join(tmpDir, 'pkg-a')],
|
||||
['services/api', path.join(tmpDir, 'pkg-b')],
|
||||
]);
|
||||
|
||||
const result = await extractNodeWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
expect(result.links[0]).toEqual({
|
||||
from: 'libs/shared',
|
||||
to: 'services/api',
|
||||
type: 'custom',
|
||||
contract: '@myorg/shared::Config',
|
||||
role: 'provider',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles default imports (PascalCase)', async () => {
|
||||
await writeFile(
|
||||
'ui-lib/package.json',
|
||||
JSON.stringify({ name: 'ui-components', version: '1.0.0' }),
|
||||
);
|
||||
await writeFile('ui-lib/src/index.ts', 'export default class Button {}\n');
|
||||
|
||||
await writeFile(
|
||||
'app/package.json',
|
||||
JSON.stringify({
|
||||
name: 'web-app',
|
||||
version: '1.0.0',
|
||||
dependencies: { 'ui-components': '^1.0.0' },
|
||||
}),
|
||||
);
|
||||
await writeFile(
|
||||
'app/src/page.tsx',
|
||||
"import Button from 'ui-components';\nexport default function Page() { return <Button />; }\n",
|
||||
);
|
||||
|
||||
const repos = { 'libs/ui': 'ui-components', 'apps/web': 'web-app' };
|
||||
const repoPaths = new Map([
|
||||
['libs/ui', path.join(tmpDir, 'ui-lib')],
|
||||
['apps/web', path.join(tmpDir, 'app')],
|
||||
]);
|
||||
|
||||
const result = await extractNodeWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
expect(result.links[0].contract).toBe('ui-components::Button');
|
||||
});
|
||||
|
||||
it('handles CommonJS destructured require', async () => {
|
||||
await writeFile('lib/package.json', JSON.stringify({ name: 'auth-lib', version: '1.0.0' }));
|
||||
await writeFile('lib/src/index.js', 'module.exports = { Authenticator: class {} };\n');
|
||||
|
||||
await writeFile(
|
||||
'svc/package.json',
|
||||
JSON.stringify({
|
||||
name: 'api-svc',
|
||||
version: '1.0.0',
|
||||
dependencies: { 'auth-lib': 'workspace:*' },
|
||||
}),
|
||||
);
|
||||
await writeFile('svc/src/handler.js', "const { Authenticator } = require('auth-lib');\n");
|
||||
|
||||
const repos = { lib: 'auth-lib', svc: 'api-svc' };
|
||||
const repoPaths = new Map([
|
||||
['lib', path.join(tmpDir, 'lib')],
|
||||
['svc', path.join(tmpDir, 'svc')],
|
||||
]);
|
||||
|
||||
const result = await extractNodeWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
expect(result.links[0].contract).toBe('auth-lib::Authenticator');
|
||||
});
|
||||
|
||||
it('handles scoped package imports with subpaths', async () => {
|
||||
await writeFile('core/package.json', JSON.stringify({ name: '@acme/core', version: '2.0.0' }));
|
||||
await writeFile('core/src/models.ts', 'export class User {}\n');
|
||||
|
||||
await writeFile(
|
||||
'web/package.json',
|
||||
JSON.stringify({
|
||||
name: '@acme/web',
|
||||
version: '1.0.0',
|
||||
dependencies: { '@acme/core': 'workspace:*' },
|
||||
}),
|
||||
);
|
||||
await writeFile('web/src/routes.ts', "import { User } from '@acme/core/models';\n");
|
||||
|
||||
const repos = { core: '@acme/core', web: '@acme/web' };
|
||||
const repoPaths = new Map([
|
||||
['core', path.join(tmpDir, 'core')],
|
||||
['web', path.join(tmpDir, 'web')],
|
||||
]);
|
||||
|
||||
const result = await extractNodeWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
expect(result.links[0].contract).toBe('@acme/core::User');
|
||||
});
|
||||
|
||||
it('ignores camelCase/snake_case imports (non-type exports)', async () => {
|
||||
await writeFile('lib/package.json', JSON.stringify({ name: 'utils', version: '1.0.0' }));
|
||||
await writeFile('lib/src/index.ts', 'export function helper() {}\nexport class Formatter {}\n');
|
||||
|
||||
await writeFile(
|
||||
'app/package.json',
|
||||
JSON.stringify({
|
||||
name: 'myapp',
|
||||
version: '1.0.0',
|
||||
dependencies: { utils: 'workspace:*' },
|
||||
}),
|
||||
);
|
||||
await writeFile('app/src/main.ts', "import { helper, Formatter } from 'utils';\n");
|
||||
|
||||
const repos = { lib: 'utils', app: 'myapp' };
|
||||
const repoPaths = new Map([
|
||||
['lib', path.join(tmpDir, 'lib')],
|
||||
['app', path.join(tmpDir, 'app')],
|
||||
]);
|
||||
|
||||
const result = await extractNodeWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
expect(result.links[0].contract).toBe('utils::Formatter');
|
||||
});
|
||||
|
||||
it('skips repos without package.json', async () => {
|
||||
await writeFile('rust-app/Cargo.toml', '[package]\nname = "rapp"\nversion = "0.1.0"\n');
|
||||
await writeFile('rust-app/src/main.rs', 'fn main() {}\n');
|
||||
|
||||
const repos = { app: 'rapp' };
|
||||
const repoPaths = new Map([['app', path.join(tmpDir, 'rust-app')]]);
|
||||
|
||||
const result = await extractNodeWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(0);
|
||||
expect(result.discoveredPackages.size).toBe(0);
|
||||
});
|
||||
|
||||
it('deduplicates identical imports from multiple files', async () => {
|
||||
await writeFile('lib/package.json', JSON.stringify({ name: 'shared', version: '1.0.0' }));
|
||||
await writeFile('lib/src/index.ts', 'export class Config {}\n');
|
||||
|
||||
await writeFile(
|
||||
'app/package.json',
|
||||
JSON.stringify({
|
||||
name: 'myapp',
|
||||
version: '1.0.0',
|
||||
dependencies: { shared: 'workspace:*' },
|
||||
}),
|
||||
);
|
||||
await writeFile('app/src/a.ts', "import { Config } from 'shared';\n");
|
||||
await writeFile('app/src/b.ts', "import { Config } from 'shared';\n");
|
||||
|
||||
const repos = { lib: 'shared', app: 'myapp' };
|
||||
const repoPaths = new Map([
|
||||
['lib', path.join(tmpDir, 'lib')],
|
||||
['app', path.join(tmpDir, 'app')],
|
||||
]);
|
||||
|
||||
const result = await extractNodeWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('handles aliased imports (import { Foo as Bar })', async () => {
|
||||
await writeFile('lib/package.json', JSON.stringify({ name: 'models', version: '1.0.0' }));
|
||||
await writeFile('lib/src/index.ts', 'export class Entity {}\n');
|
||||
|
||||
await writeFile(
|
||||
'app/package.json',
|
||||
JSON.stringify({
|
||||
name: 'myapp',
|
||||
version: '1.0.0',
|
||||
dependencies: { models: 'workspace:*' },
|
||||
}),
|
||||
);
|
||||
await writeFile('app/src/main.ts', "import { Entity as BaseEntity } from 'models';\n");
|
||||
|
||||
const repos = { lib: 'models', app: 'myapp' };
|
||||
const repoPaths = new Map([
|
||||
['lib', path.join(tmpDir, 'lib')],
|
||||
['app', path.join(tmpDir, 'app')],
|
||||
]);
|
||||
|
||||
const result = await extractNodeWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
expect(result.links[0].contract).toBe('models::Entity');
|
||||
});
|
||||
|
||||
it('handles multiple packages importing from the same provider', async () => {
|
||||
await writeFile(
|
||||
'shared/package.json',
|
||||
JSON.stringify({ name: '@org/shared', version: '1.0.0' }),
|
||||
);
|
||||
await writeFile('shared/src/index.ts', 'export class Schema {}\n');
|
||||
|
||||
await writeFile(
|
||||
'api/package.json',
|
||||
JSON.stringify({
|
||||
name: '@org/api',
|
||||
version: '1.0.0',
|
||||
dependencies: { '@org/shared': 'workspace:*' },
|
||||
}),
|
||||
);
|
||||
await writeFile('api/src/index.ts', "import { Schema } from '@org/shared';\n");
|
||||
|
||||
await writeFile(
|
||||
'worker/package.json',
|
||||
JSON.stringify({
|
||||
name: '@org/worker',
|
||||
version: '1.0.0',
|
||||
dependencies: { '@org/shared': 'workspace:*' },
|
||||
}),
|
||||
);
|
||||
await writeFile('worker/src/index.ts', "import { Schema } from '@org/shared';\n");
|
||||
|
||||
const repos = {
|
||||
libs: '@org/shared',
|
||||
api: '@org/api',
|
||||
worker: '@org/worker',
|
||||
};
|
||||
const repoPaths = new Map([
|
||||
['libs', path.join(tmpDir, 'shared')],
|
||||
['api', path.join(tmpDir, 'api')],
|
||||
['worker', path.join(tmpDir, 'worker')],
|
||||
]);
|
||||
|
||||
const result = await extractNodeWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(2);
|
||||
const targets = result.links.map((l) => l.to).sort();
|
||||
expect(targets).toEqual(['api', 'worker']);
|
||||
expect(result.links.every((l) => l.contract === '@org/shared::Schema')).toBe(true);
|
||||
});
|
||||
});
|
||||
241
gitnexus/test/unit/group/python-workspace-extractor.test.ts
Normal file
241
gitnexus/test/unit/group/python-workspace-extractor.test.ts
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { extractPythonWorkspaceLinks } from '../../../src/core/group/extractors/python-workspace-extractor.js';
|
||||
|
||||
describe('PythonWorkspaceExtractor', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-py-ws-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function writeFile(relPath: string, content: string) {
|
||||
const absPath = path.join(tmpDir, relPath);
|
||||
await fs.mkdir(path.dirname(absPath), { recursive: true });
|
||||
await fs.writeFile(absPath, content, 'utf-8');
|
||||
}
|
||||
|
||||
it('discovers cross-package imports via pyproject.toml', async () => {
|
||||
await writeFile(
|
||||
'models/pyproject.toml',
|
||||
'[project]\nname = "shared-models"\nversion = "0.1.0"\ndependencies = []\n',
|
||||
);
|
||||
await writeFile('models/shared_models/__init__.py', 'class Schema: pass\n');
|
||||
|
||||
await writeFile(
|
||||
'api/pyproject.toml',
|
||||
'[project]\nname = "api-server"\nversion = "0.1.0"\ndependencies = [\n "shared-models>=0.1.0",\n]\n',
|
||||
);
|
||||
await writeFile('api/api_server/main.py', 'from shared_models import Schema\n');
|
||||
|
||||
const repos = { models: 'shared-models', api: 'api-server' };
|
||||
const repoPaths = new Map([
|
||||
['models', path.join(tmpDir, 'models')],
|
||||
['api', path.join(tmpDir, 'api')],
|
||||
]);
|
||||
|
||||
const result = await extractPythonWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
expect(result.links[0]).toEqual({
|
||||
from: 'models',
|
||||
to: 'api',
|
||||
type: 'custom',
|
||||
contract: 'shared-models::Schema',
|
||||
role: 'provider',
|
||||
});
|
||||
});
|
||||
|
||||
it('discovers imports via setup.py', async () => {
|
||||
await writeFile(
|
||||
'core/setup.py',
|
||||
"from setuptools import setup\nsetup(name='mycore', version='1.0', install_requires=[])\n",
|
||||
);
|
||||
await writeFile('core/mycore/__init__.py', 'class Engine: pass\n');
|
||||
|
||||
await writeFile(
|
||||
'app/setup.py',
|
||||
"from setuptools import setup\nsetup(name='myapp', version='1.0', install_requires=['mycore>=1.0'])\n",
|
||||
);
|
||||
await writeFile('app/myapp/run.py', 'from mycore import Engine\n');
|
||||
|
||||
const repos = { core: 'mycore', app: 'myapp' };
|
||||
const repoPaths = new Map([
|
||||
['core', path.join(tmpDir, 'core')],
|
||||
['app', path.join(tmpDir, 'app')],
|
||||
]);
|
||||
|
||||
const result = await extractPythonWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
expect(result.links[0].contract).toBe('mycore::Engine');
|
||||
});
|
||||
|
||||
it('handles hyphenated package names (normalized to underscore in imports)', async () => {
|
||||
await writeFile(
|
||||
'lib/pyproject.toml',
|
||||
'[project]\nname = "my-utils"\nversion = "0.1.0"\ndependencies = []\n',
|
||||
);
|
||||
await writeFile('lib/my_utils/__init__.py', 'class Helper: pass\n');
|
||||
|
||||
await writeFile(
|
||||
'svc/pyproject.toml',
|
||||
'[project]\nname = "my-service"\nversion = "0.1.0"\ndependencies = [\n "my-utils",\n]\n',
|
||||
);
|
||||
await writeFile('svc/my_service/main.py', 'from my_utils import Helper\n');
|
||||
|
||||
const repos = { lib: 'my-utils', svc: 'my-service' };
|
||||
const repoPaths = new Map([
|
||||
['lib', path.join(tmpDir, 'lib')],
|
||||
['svc', path.join(tmpDir, 'svc')],
|
||||
]);
|
||||
|
||||
const result = await extractPythonWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
expect(result.links[0].contract).toBe('my-utils::Helper');
|
||||
});
|
||||
|
||||
it('handles submodule imports (from pkg.sub import Class)', async () => {
|
||||
await writeFile(
|
||||
'lib/pyproject.toml',
|
||||
'[project]\nname = "datalib"\nversion = "0.1.0"\ndependencies = []\n',
|
||||
);
|
||||
await writeFile('lib/datalib/models.py', 'class Record: pass\n');
|
||||
|
||||
await writeFile(
|
||||
'app/pyproject.toml',
|
||||
'[project]\nname = "myapp"\nversion = "0.1.0"\ndependencies = [\n "datalib",\n]\n',
|
||||
);
|
||||
await writeFile('app/myapp/main.py', 'from datalib.models import Record\n');
|
||||
|
||||
const repos = { lib: 'datalib', app: 'myapp' };
|
||||
const repoPaths = new Map([
|
||||
['lib', path.join(tmpDir, 'lib')],
|
||||
['app', path.join(tmpDir, 'app')],
|
||||
]);
|
||||
|
||||
const result = await extractPythonWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
expect(result.links[0].contract).toBe('datalib::Record');
|
||||
});
|
||||
|
||||
it('ignores snake_case imports (functions, not types)', async () => {
|
||||
await writeFile(
|
||||
'lib/pyproject.toml',
|
||||
'[project]\nname = "utils"\nversion = "0.1.0"\ndependencies = []\n',
|
||||
);
|
||||
await writeFile('lib/utils/__init__.py', 'def helper(): pass\nclass Config: pass\n');
|
||||
|
||||
await writeFile(
|
||||
'app/pyproject.toml',
|
||||
'[project]\nname = "myapp"\nversion = "0.1.0"\ndependencies = [\n "utils",\n]\n',
|
||||
);
|
||||
await writeFile('app/myapp/main.py', 'from utils import helper, Config\n');
|
||||
|
||||
const repos = { lib: 'utils', app: 'myapp' };
|
||||
const repoPaths = new Map([
|
||||
['lib', path.join(tmpDir, 'lib')],
|
||||
['app', path.join(tmpDir, 'app')],
|
||||
]);
|
||||
|
||||
const result = await extractPythonWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
expect(result.links[0].contract).toBe('utils::Config');
|
||||
});
|
||||
|
||||
it('skips repos without Python manifest', async () => {
|
||||
await writeFile('js-app/package.json', '{"name": "js-app"}');
|
||||
|
||||
const repos = { app: 'js-app' };
|
||||
const repoPaths = new Map([['app', path.join(tmpDir, 'js-app')]]);
|
||||
|
||||
const result = await extractPythonWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(0);
|
||||
expect(result.discoveredPackages.size).toBe(0);
|
||||
});
|
||||
|
||||
it('deduplicates identical imports from multiple files', async () => {
|
||||
await writeFile(
|
||||
'lib/pyproject.toml',
|
||||
'[project]\nname = "shared"\nversion = "0.1.0"\ndependencies = []\n',
|
||||
);
|
||||
await writeFile('lib/shared/__init__.py', 'class Config: pass\n');
|
||||
|
||||
await writeFile(
|
||||
'app/pyproject.toml',
|
||||
'[project]\nname = "myapp"\nversion = "0.1.0"\ndependencies = [\n "shared",\n]\n',
|
||||
);
|
||||
await writeFile('app/myapp/a.py', 'from shared import Config\n');
|
||||
await writeFile('app/myapp/b.py', 'from shared import Config\n');
|
||||
|
||||
const repos = { lib: 'shared', app: 'myapp' };
|
||||
const repoPaths = new Map([
|
||||
['lib', path.join(tmpDir, 'lib')],
|
||||
['app', path.join(tmpDir, 'app')],
|
||||
]);
|
||||
|
||||
const result = await extractPythonWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('handles aliased imports (from pkg import Foo as Bar)', async () => {
|
||||
await writeFile(
|
||||
'lib/pyproject.toml',
|
||||
'[project]\nname = "models"\nversion = "0.1.0"\ndependencies = []\n',
|
||||
);
|
||||
await writeFile('lib/models/__init__.py', 'class Entity: pass\n');
|
||||
|
||||
await writeFile(
|
||||
'app/pyproject.toml',
|
||||
'[project]\nname = "myapp"\nversion = "0.1.0"\ndependencies = [\n "models",\n]\n',
|
||||
);
|
||||
await writeFile('app/myapp/main.py', 'from models import Entity as BaseEntity\n');
|
||||
|
||||
const repos = { lib: 'models', app: 'myapp' };
|
||||
const repoPaths = new Map([
|
||||
['lib', path.join(tmpDir, 'lib')],
|
||||
['app', path.join(tmpDir, 'app')],
|
||||
]);
|
||||
|
||||
const result = await extractPythonWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
expect(result.links[0].contract).toBe('models::Entity');
|
||||
});
|
||||
|
||||
it('reads optional-dependencies from pyproject.toml', async () => {
|
||||
await writeFile(
|
||||
'lib/pyproject.toml',
|
||||
'[project]\nname = "extras"\nversion = "0.1.0"\ndependencies = []\n',
|
||||
);
|
||||
await writeFile('lib/extras/__init__.py', 'class Plugin: pass\n');
|
||||
|
||||
await writeFile(
|
||||
'app/pyproject.toml',
|
||||
'[project]\nname = "myapp"\nversion = "0.1.0"\ndependencies = []\n\n[project.optional-dependencies]\ndev = [\n "extras>=0.1",\n]\n',
|
||||
);
|
||||
await writeFile('app/myapp/main.py', 'from extras import Plugin\n');
|
||||
|
||||
const repos = { lib: 'extras', app: 'myapp' };
|
||||
const repoPaths = new Map([
|
||||
['lib', path.join(tmpDir, 'lib')],
|
||||
['app', path.join(tmpDir, 'app')],
|
||||
]);
|
||||
|
||||
const result = await extractPythonWorkspaceLinks(repos, repoPaths);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
expect(result.links[0].contract).toBe('extras::Plugin');
|
||||
});
|
||||
});
|
||||
|
|
@ -25,6 +25,7 @@ describe('syncGroup', () => {
|
|||
topics: false,
|
||||
shared_libs: false,
|
||||
embedding_fallback: false,
|
||||
workspace_deps: false,
|
||||
},
|
||||
matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 },
|
||||
});
|
||||
|
|
@ -312,8 +313,7 @@ describe('syncGroup', () => {
|
|||
});
|
||||
|
||||
it('writes registry to groupDir when skipWrite is false', async () => {
|
||||
const tmpDir = path.join(os.tmpdir(), `gitnexus-sync-write-${Date.now()}`);
|
||||
fs.mkdirSync(tmpDir, { recursive: true });
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-sync-write-'));
|
||||
|
||||
try {
|
||||
const config = makeConfig({});
|
||||
|
|
@ -371,8 +371,7 @@ describe('syncGroup', () => {
|
|||
});
|
||||
|
||||
it('workspace_deps: true discovers Rust crate links through syncGroup', async () => {
|
||||
tmpDir = path.join(os.tmpdir(), `gitnexus-sync-ws-${Date.now()}`);
|
||||
fs.mkdirSync(tmpDir, { recursive: true });
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-sync-ws-'));
|
||||
|
||||
writeFileSync(
|
||||
'crate-a/Cargo.toml',
|
||||
|
|
@ -420,9 +419,8 @@ describe('syncGroup', () => {
|
|||
expect(manifestLinks[0].to.repo).toBe('parser/mathlex');
|
||||
});
|
||||
|
||||
it('workspace_deps: false skips Rust workspace extraction', async () => {
|
||||
tmpDir = path.join(os.tmpdir(), `gitnexus-sync-ws-off-${Date.now()}`);
|
||||
fs.mkdirSync(tmpDir, { recursive: true });
|
||||
it('workspace_deps: false skips workspace extraction entirely', async () => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-sync-ws-off-'));
|
||||
|
||||
writeFileSync(
|
||||
'crate-a/Cargo.toml',
|
||||
|
|
@ -454,8 +452,7 @@ describe('syncGroup', () => {
|
|||
});
|
||||
|
||||
it('discovered workspace links merge with explicit manifest links', async () => {
|
||||
tmpDir = path.join(os.tmpdir(), `gitnexus-sync-ws-merge-${Date.now()}`);
|
||||
fs.mkdirSync(tmpDir, { recursive: true });
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-sync-ws-merge-'));
|
||||
|
||||
writeFileSync(
|
||||
'crate-a/Cargo.toml',
|
||||
|
|
@ -523,12 +520,59 @@ describe('syncGroup', () => {
|
|||
});
|
||||
|
||||
const manifestLinks = result.crossLinks.filter((cl) => cl.matchType === 'manifest');
|
||||
expect(manifestLinks.length).toBeGreaterThanOrEqual(2);
|
||||
expect(manifestLinks).toHaveLength(2);
|
||||
|
||||
const contractIds = manifestLinks.map((cl) => cl.contractId);
|
||||
expect(contractIds).toContain('http::GET::/api/parse');
|
||||
expect(contractIds).toContain('custom::mathlex::Expression');
|
||||
});
|
||||
|
||||
it('discovers Node workspace links through syncGroup orchestrator', async () => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-sync-ws-node-'));
|
||||
|
||||
writeFileSync('shared/package.json', '{"name": "@myorg/shared", "version": "1.0.0"}');
|
||||
writeFileSync('shared/src/index.ts', 'export class Config {}\n');
|
||||
|
||||
writeFileSync(
|
||||
'app/package.json',
|
||||
'{"name": "@myorg/app", "version": "1.0.0", "dependencies": {"@myorg/shared": "workspace:*"}}',
|
||||
);
|
||||
writeFileSync('app/src/index.ts', "import { Config } from '@myorg/shared';\n");
|
||||
|
||||
const mockEntries: RegistryEntry[] = [
|
||||
{
|
||||
name: 'shared',
|
||||
path: path.join(tmpDir, 'shared'),
|
||||
storagePath: path.join(tmpDir, 'shared', '.gitnexus'),
|
||||
indexedAt: '',
|
||||
lastCommit: '',
|
||||
},
|
||||
{
|
||||
name: 'app',
|
||||
path: path.join(tmpDir, 'app'),
|
||||
storagePath: path.join(tmpDir, 'app', '.gitnexus'),
|
||||
indexedAt: '',
|
||||
lastCommit: '',
|
||||
},
|
||||
];
|
||||
|
||||
const repoManager = await import('../../../src/storage/repo-manager.js');
|
||||
vi.spyOn(repoManager, 'readRegistry').mockResolvedValue(mockEntries);
|
||||
|
||||
const config = makeWsConfig({ 'pkg/shared': 'shared', 'pkg/app': 'app' }, true);
|
||||
|
||||
const result = await syncGroup(config, {
|
||||
extractorOverride: async () => [],
|
||||
skipWrite: true,
|
||||
});
|
||||
|
||||
const manifestLinks = result.crossLinks.filter((cl) => cl.matchType === 'manifest');
|
||||
expect(manifestLinks).toHaveLength(1);
|
||||
const nodeLink = manifestLinks.find(
|
||||
(cl) => cl.contractId === 'custom::@myorg/shared::Config',
|
||||
);
|
||||
expect(nodeLink).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -540,10 +540,35 @@ describe('computeMRO', () => {
|
|||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
it('returns null for C3 merge-conflict inconsistency (non-cyclic)', () => {
|
||||
// Classic incompatible ordering: A(X,Y) and B(Y,X) → C(A,B) is unresolvable
|
||||
const graph = createKnowledgeGraph();
|
||||
addClass(graph, 'X', 'python');
|
||||
addClass(graph, 'Y', 'python');
|
||||
addClass(graph, 'A', 'python');
|
||||
addClass(graph, 'B', 'python');
|
||||
addClass(graph, 'C', 'python');
|
||||
addExtends(graph, 'A', 'X');
|
||||
addExtends(graph, 'A', 'Y');
|
||||
addExtends(graph, 'B', 'Y');
|
||||
addExtends(graph, 'B', 'X');
|
||||
addExtends(graph, 'C', 'A');
|
||||
addExtends(graph, 'C', 'B');
|
||||
addMethod(graph, 'X', 'foo');
|
||||
|
||||
const result = computeMRO(graph);
|
||||
expect(result).toBeDefined();
|
||||
// C3 fails for C — falls back to BFS ancestors
|
||||
const entryC = result.entries.find((e) => e.className === 'C');
|
||||
expect(entryC).toBeDefined();
|
||||
// BFS fallback still produces an MRO (just not C3-ordered)
|
||||
expect(entryC!.mro.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Performance (deep chains) -------------------------------------------
|
||||
describe('performance', () => {
|
||||
it('handles very deep single-inheritance chain without stack overflow', () => {
|
||||
// Chain of 2000 classes: C0 ← C1 ← C2 ← ... ← C1999
|
||||
// The iterative c3Linearize handles this without blowing the stack.
|
||||
// (The recursive version overflows at ~1K–5K levels depending on platform.)
|
||||
const graph = createKnowledgeGraph();
|
||||
const DEPTH = 2000;
|
||||
for (let i = 0; i < DEPTH; i++) {
|
||||
|
|
@ -552,12 +577,10 @@ describe('computeMRO', () => {
|
|||
for (let i = 1; i < DEPTH; i++) {
|
||||
addExtends(graph, `C${i}`, `C${i - 1}`);
|
||||
}
|
||||
// Add a method on the root so MRO produces an entry
|
||||
addMethod(graph, 'C0', 'baseMethod');
|
||||
|
||||
const result = computeMRO(graph);
|
||||
expect(result).toBeDefined();
|
||||
// The deepest class should have all ancestors in its MRO
|
||||
const deepest = result.entries.find((e) => e.className === `C${DEPTH - 1}`);
|
||||
if (deepest) {
|
||||
expect(deepest.mro.length).toBe(DEPTH - 1);
|
||||
|
|
|
|||
|
|
@ -108,10 +108,9 @@ describe('isRegistryPrimary', () => {
|
|||
it('isolates flags per-language (one on does not affect others)', () => {
|
||||
process.env['REGISTRY_PRIMARY_PYTHON'] = 'true';
|
||||
expect(isRegistryPrimary(SupportedLanguages.Python)).toBe(true);
|
||||
// Java and Go are not in MIGRATED_LANGUAGES — default false stays
|
||||
// Java is not in MIGRATED_LANGUAGES — default false stays
|
||||
// false regardless of Python's flag.
|
||||
expect(isRegistryPrimary(SupportedLanguages.Java)).toBe(false);
|
||||
expect(isRegistryPrimary(SupportedLanguages.Go)).toBe(false);
|
||||
});
|
||||
|
||||
it('respects a mid-process env-var mutation (no stale cache)', () => {
|
||||
|
|
@ -149,17 +148,18 @@ describe('primaryLanguages', () => {
|
|||
|
||||
it('returns exactly the flipped languages (env opts in unmigrated, opts out migrated)', () => {
|
||||
// Migrated languages are default-on; each must be opted out here when
|
||||
// testing explicit env overrides. Go (unmigrated) opts in; Java stays off.
|
||||
// testing explicit env overrides. Java (unmigrated) opts in; Go stays off.
|
||||
process.env['REGISTRY_PRIMARY_PYTHON'] = 'false';
|
||||
process.env['REGISTRY_PRIMARY_CSHARP'] = 'false';
|
||||
process.env['REGISTRY_PRIMARY_TYPESCRIPT'] = 'false';
|
||||
process.env['REGISTRY_PRIMARY_GO'] = '1';
|
||||
process.env['REGISTRY_PRIMARY_GO'] = 'false';
|
||||
process.env['REGISTRY_PRIMARY_JAVA'] = '1';
|
||||
const enabled = primaryLanguages();
|
||||
expect(enabled.has(SupportedLanguages.Python)).toBe(false);
|
||||
expect(enabled.has(SupportedLanguages.CSharp)).toBe(false);
|
||||
expect(enabled.has(SupportedLanguages.Go)).toBe(true);
|
||||
expect(enabled.has(SupportedLanguages.Java)).toBe(false);
|
||||
// Only Go is on: migrated defaults overridden off, Go explicitly on.
|
||||
expect(enabled.has(SupportedLanguages.Go)).toBe(false);
|
||||
expect(enabled.has(SupportedLanguages.Java)).toBe(true);
|
||||
// Only Java is on: migrated defaults overridden off, Java explicitly on.
|
||||
expect(enabled.size).toBe(1);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,65 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { emitGoScopeCaptures } from '../../../../src/core/ingestion/languages/go/index.js';
|
||||
|
||||
const tagNames = (matches: readonly Record<string, unknown>[]) =>
|
||||
matches.flatMap((m) => Object.keys(m));
|
||||
|
||||
describe('Go scope captures — smoke', () => {
|
||||
it('emits grouped imports once per import spec', () => {
|
||||
const src = `
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
`;
|
||||
const matches = emitGoScopeCaptures(src, 'main.go');
|
||||
const imports = matches
|
||||
.filter((m) => m['@import.source'] !== undefined)
|
||||
.map((m) => m['@import.source']!.text);
|
||||
|
||||
expect(imports).toEqual(['fmt', 'os']);
|
||||
});
|
||||
|
||||
it('emits module, struct, interface, function, method, import, call, read, write captures', () => {
|
||||
const src = `
|
||||
package main
|
||||
|
||||
import (
|
||||
"example.com/app/internal/models"
|
||||
util "example.com/app/internal/util"
|
||||
)
|
||||
|
||||
type User struct { Name string }
|
||||
type Saver interface { Save() }
|
||||
|
||||
func NewUser(name string) *User { return &User{Name: name} }
|
||||
|
||||
func (u *User) Save(prefix string) { util.Log(prefix); models.Touch() }
|
||||
|
||||
func main() {
|
||||
u := NewUser("alice")
|
||||
u.Save("hello")
|
||||
fmt.Println(u.Name)
|
||||
u.Name = "bob"
|
||||
}
|
||||
`;
|
||||
const matches = emitGoScopeCaptures(src, 'cmd/main.go');
|
||||
const tags = tagNames(matches);
|
||||
|
||||
expect(tags).toContain('@scope.module');
|
||||
expect(tags).toContain('@scope.class');
|
||||
expect(tags).toContain('@scope.function');
|
||||
expect(tags).toContain('@declaration.struct');
|
||||
expect(tags).toContain('@declaration.interface');
|
||||
expect(tags).toContain('@declaration.function');
|
||||
expect(tags).toContain('@declaration.method');
|
||||
expect(tags).toContain('@import.statement');
|
||||
expect(tags).toContain('@reference.call.free');
|
||||
expect(tags).toContain('@reference.call.member');
|
||||
expect(tags).toContain('@reference.call.constructor');
|
||||
expect(tags).toContain('@reference.read');
|
||||
expect(tags).toContain('@reference.write');
|
||||
});
|
||||
});
|
||||
130
gitnexus/test/unit/scope-resolution/go/go-hooks.test.ts
Normal file
130
gitnexus/test/unit/scope-resolution/go/go-hooks.test.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import type { BindingRef, Callsite, SymbolDefinition, Scope } from 'gitnexus-shared';
|
||||
import {
|
||||
goArityCompatibility,
|
||||
goMergeBindings,
|
||||
goReceiverBinding,
|
||||
} from '../../../../src/core/ingestion/languages/go/index.js';
|
||||
|
||||
describe('Go arity compatibility', () => {
|
||||
const makeDef = (overrides: Partial<SymbolDefinition> = {}): SymbolDefinition => ({
|
||||
nodeId: 'def:1',
|
||||
filePath: 'a.go',
|
||||
type: 'Function',
|
||||
qualifiedName: 'F',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('returns unknown when no param count info', () => {
|
||||
const def = makeDef();
|
||||
const callsite: Callsite = {
|
||||
name: 'F',
|
||||
inScope: 's',
|
||||
atRange: { startLine: 1, startCol: 1, endLine: 1, endCol: 5 },
|
||||
kind: 'call',
|
||||
arity: 1,
|
||||
};
|
||||
expect(goArityCompatibility(def, callsite)).toBe('unknown');
|
||||
});
|
||||
|
||||
it('exact match is compatible', () => {
|
||||
const def = makeDef({ parameterCount: 2, requiredParameterCount: 2 });
|
||||
const callsite: Callsite = {
|
||||
name: 'F',
|
||||
inScope: 's',
|
||||
atRange: { startLine: 1, startCol: 1, endLine: 1, endCol: 5 },
|
||||
kind: 'call',
|
||||
arity: 2,
|
||||
};
|
||||
expect(goArityCompatibility(def, callsite)).toBe('compatible');
|
||||
});
|
||||
|
||||
it('too few args is incompatible', () => {
|
||||
const def = makeDef({ parameterCount: 2, requiredParameterCount: 2 });
|
||||
const callsite: Callsite = {
|
||||
name: 'F',
|
||||
inScope: 's',
|
||||
atRange: { startLine: 1, startCol: 1, endLine: 1, endCol: 5 },
|
||||
kind: 'call',
|
||||
arity: 1,
|
||||
};
|
||||
expect(goArityCompatibility(def, callsite)).toBe('incompatible');
|
||||
});
|
||||
|
||||
it('variadic accepts extra args', () => {
|
||||
const def = makeDef({
|
||||
parameterCount: 2,
|
||||
requiredParameterCount: 1,
|
||||
parameterTypes: ['string', '...string'],
|
||||
});
|
||||
const callsite: Callsite = {
|
||||
name: 'F',
|
||||
inScope: 's',
|
||||
atRange: { startLine: 1, startCol: 1, endLine: 1, endCol: 5 },
|
||||
kind: 'call',
|
||||
arity: 5,
|
||||
};
|
||||
expect(goArityCompatibility(def, callsite)).toBe('compatible');
|
||||
});
|
||||
|
||||
it('non-variadic rejects extra args', () => {
|
||||
const def = makeDef({ parameterCount: 2, requiredParameterCount: 2 });
|
||||
const callsite: Callsite = {
|
||||
name: 'F',
|
||||
inScope: 's',
|
||||
atRange: { startLine: 1, startCol: 1, endLine: 1, endCol: 5 },
|
||||
kind: 'call',
|
||||
arity: 3,
|
||||
};
|
||||
expect(goArityCompatibility(def, callsite)).toBe('incompatible');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Go merge bindings', () => {
|
||||
it('local wins over import', () => {
|
||||
const local: BindingRef = {
|
||||
origin: 'local',
|
||||
def: { nodeId: 'def:local', filePath: 'main.go', type: 'Function', qualifiedName: 'Save' },
|
||||
};
|
||||
const imported: BindingRef = {
|
||||
origin: 'import',
|
||||
def: { nodeId: 'def:import', filePath: 'util.go', type: 'Function', qualifiedName: 'Save' },
|
||||
};
|
||||
const merged = goMergeBindings([imported], [local], 'scope:1');
|
||||
expect(merged[0].def.nodeId).toBe('def:local');
|
||||
});
|
||||
|
||||
it('deduplicates by DefId', () => {
|
||||
const a: BindingRef = {
|
||||
origin: 'local',
|
||||
def: { nodeId: 'def:1', filePath: 'a.go', type: 'Function', qualifiedName: 'F' },
|
||||
};
|
||||
const b: BindingRef = {
|
||||
origin: 'local',
|
||||
def: { nodeId: 'def:1', filePath: 'a.go', type: 'Function', qualifiedName: 'F' },
|
||||
};
|
||||
expect(goMergeBindings([], [a, b], 'scope:1').length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Go receiver binding', () => {
|
||||
it('reads self type binding from function scope', () => {
|
||||
const scope = {
|
||||
kind: 'Function',
|
||||
typeBindings: new Map([
|
||||
['u', { rawName: 'User', declaredAtScope: 'scope:1', source: 'self' }],
|
||||
]),
|
||||
} as unknown as Scope;
|
||||
expect(goReceiverBinding(scope)?.rawName).toBe('User');
|
||||
});
|
||||
|
||||
it('returns null for non-Function scope', () => {
|
||||
const scope = { kind: 'Module', typeBindings: new Map() } as unknown as Scope;
|
||||
expect(goReceiverBinding(scope)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when no self binding', () => {
|
||||
const scope = { kind: 'Function', typeBindings: new Map() } as unknown as Scope;
|
||||
expect(goReceiverBinding(scope)).toBeNull();
|
||||
});
|
||||
});
|
||||
154
gitnexus/test/unit/scope-resolution/go/go-imports.test.ts
Normal file
154
gitnexus/test/unit/scope-resolution/go/go-imports.test.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
splitGoImportStatement,
|
||||
interpretGoImport,
|
||||
resolveGoImportTarget,
|
||||
} from '../../../../src/core/ingestion/languages/go/index.js';
|
||||
import { getGoParser } from '../../../../src/core/ingestion/languages/go/query.js';
|
||||
import type { CaptureMatch } from 'gitnexus-shared';
|
||||
|
||||
function parseThenSplit(src: string): CaptureMatch[] {
|
||||
const tree = getGoParser().parse(src);
|
||||
const out: CaptureMatch[] = [];
|
||||
for (let i = 0; i < tree.rootNode.namedChildCount; i++) {
|
||||
const child = tree.rootNode.namedChild(i);
|
||||
if (child?.type === 'import_declaration') out.push(...splitGoImportStatement(child as any));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function capt(name: string, text: string) {
|
||||
return { name, text, range: { startLine: 1, startCol: 1, endLine: 1, endCol: 1 } };
|
||||
}
|
||||
|
||||
describe('Go import decomposition', () => {
|
||||
it('decomposes single default import', () => {
|
||||
const matches = parseThenSplit('import "fmt"');
|
||||
expect(matches.length).toBe(1);
|
||||
expect(matches[0]['@import.source']?.text).toBe('fmt');
|
||||
expect(matches[0]['@import.kind']?.text).toBe('namespace');
|
||||
expect(matches[0]['@import.name']?.text).toBe('fmt');
|
||||
});
|
||||
|
||||
it('decomposes grouped imports', () => {
|
||||
const src = `import (
|
||||
"fmt"
|
||||
"os"
|
||||
)`;
|
||||
const matches = parseThenSplit(src);
|
||||
expect(matches.length).toBe(2);
|
||||
});
|
||||
|
||||
it('decomposes aliased import', () => {
|
||||
const matches = parseThenSplit('import util "example.com/pkg/util"');
|
||||
expect(matches.length).toBe(1);
|
||||
expect(matches[0]['@import.kind']?.text).toBe('alias');
|
||||
expect(matches[0]['@import.name']?.text).toBe('util');
|
||||
expect(matches[0]['@import.source']?.text).toBe('example.com/pkg/util');
|
||||
});
|
||||
|
||||
it('filters blank imports', () => {
|
||||
const matches = parseThenSplit('import _ "example.com/sideeffect"');
|
||||
expect(matches.length).toBe(0);
|
||||
});
|
||||
|
||||
it('handles dot imports', () => {
|
||||
const matches = parseThenSplit('import . "example.com/dsl"');
|
||||
expect(matches.length).toBe(1);
|
||||
expect(matches[0]['@import.kind']?.text).toBe('dot');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Go import interpretation', () => {
|
||||
it('interprets namespace import', () => {
|
||||
const result = interpretGoImport({
|
||||
'@import.kind': capt('@import.kind', 'namespace'),
|
||||
'@import.name': capt('@import.name', 'models'),
|
||||
'@import.source': capt('@import.source', 'example.com/app/models'),
|
||||
});
|
||||
expect(result).toEqual({
|
||||
kind: 'namespace',
|
||||
localName: 'models',
|
||||
importedName: 'models',
|
||||
targetRaw: 'example.com/app/models',
|
||||
});
|
||||
});
|
||||
|
||||
it('interprets alias import', () => {
|
||||
const result = interpretGoImport({
|
||||
'@import.kind': capt('@import.kind', 'alias'),
|
||||
'@import.name': capt('@import.name', 'util'),
|
||||
'@import.alias': capt('@import.alias', 'util'),
|
||||
'@import.source': capt('@import.source', 'example.com/pkg/util'),
|
||||
});
|
||||
expect(result).toEqual({
|
||||
kind: 'namespace',
|
||||
localName: 'util',
|
||||
importedName: 'util',
|
||||
targetRaw: 'example.com/pkg/util',
|
||||
});
|
||||
});
|
||||
|
||||
it('interprets dot import as wildcard', () => {
|
||||
const result = interpretGoImport({
|
||||
'@import.kind': capt('@import.kind', 'dot'),
|
||||
'@import.name': capt('@import.name', 'dsl'),
|
||||
'@import.source': capt('@import.source', 'example.com/dsl'),
|
||||
});
|
||||
expect(result).toEqual({ kind: 'wildcard', targetRaw: 'example.com/dsl' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Go import target resolution', () => {
|
||||
it('resolves module root imports to root package files', () => {
|
||||
const result = resolveGoImportTarget(
|
||||
'example.com/lib',
|
||||
'cmd/app/main.go',
|
||||
new Set(['root.go', 'extra.go', 'internal/model/model.go', 'root_test.go']),
|
||||
{ modulePath: 'example.com/lib' },
|
||||
);
|
||||
|
||||
expect(result).toEqual(['extra.go', 'root.go']);
|
||||
});
|
||||
|
||||
it('resolves sub-package imports under module root', () => {
|
||||
const result = resolveGoImportTarget(
|
||||
'example.com/lib/internal/models',
|
||||
'cmd/app/main.go',
|
||||
new Set(['internal/models/user.go', 'internal/models/repo.go', 'root.go']),
|
||||
{ modulePath: 'example.com/lib' },
|
||||
);
|
||||
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect((result as string[]).sort()).toEqual([
|
||||
'internal/models/repo.go',
|
||||
'internal/models/user.go',
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects single-segment GOPATH suffix that collides with a local dir', () => {
|
||||
// "github.com/other/team/pkg" suffix-stripped would eventually
|
||||
// reach "pkg" which matches the local pkg/ dir — but we require
|
||||
// ≥2 segments in the GOPATH fallback, so it must not resolve.
|
||||
const result = resolveGoImportTarget(
|
||||
'github.com/other/team/pkg',
|
||||
'main.go',
|
||||
new Set(['pkg/util.go', 'main.go']),
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('resolves multi-segment GOPATH suffix that matches local dir', () => {
|
||||
// "github.com/other/team/pkg" where "team/pkg/" exists locally
|
||||
// — the 2-segment suffix "team/pkg" should still resolve.
|
||||
const result = resolveGoImportTarget(
|
||||
'github.com/other/team/pkg',
|
||||
'main.go',
|
||||
new Set(['team/pkg/util.go', 'main.go']),
|
||||
);
|
||||
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result as string[]).toEqual(['team/pkg/util.go']);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared';
|
||||
import type { ScopeResolutionIndexes } from '../../../../src/core/ingestion/model/scope-resolution-indexes.js';
|
||||
import { populateGoPackageSiblings } from '../../../../src/core/ingestion/languages/go/index.js';
|
||||
|
||||
describe('Go package siblings', () => {
|
||||
it('augments bindings only for files in the same package directory', () => {
|
||||
const fooDef = def('foo', 'cmd/foo/a.go', 'OnlyFoo');
|
||||
const fooHelperDef = def('foo-helper', 'cmd/foo/b.go', 'OnlyFooHelper');
|
||||
const barDef = def('bar', 'cmd/bar/a.go', 'OnlyBar');
|
||||
|
||||
const parsedFiles: ParsedFile[] = [
|
||||
parsed('cmd/foo/a.go', 'module:foo-a', fooDef),
|
||||
parsed('cmd/foo/b.go', 'module:foo-b', fooHelperDef),
|
||||
parsed('cmd/bar/a.go', 'module:bar-a', barDef),
|
||||
];
|
||||
const indexes = {
|
||||
moduleScopes: {
|
||||
byFilePath: new Map([
|
||||
['cmd/foo/a.go', 'module:foo-a'],
|
||||
['cmd/foo/b.go', 'module:foo-b'],
|
||||
['cmd/bar/a.go', 'module:bar-a'],
|
||||
]),
|
||||
},
|
||||
imports: new Map(),
|
||||
bindings: new Map(),
|
||||
bindingAugmentations: new Map(),
|
||||
} as unknown as ScopeResolutionIndexes;
|
||||
const fileContents = new Map([
|
||||
['cmd/foo/a.go', 'package main\n'],
|
||||
['cmd/foo/b.go', 'package main\n'],
|
||||
['cmd/bar/a.go', 'package main\n'],
|
||||
]);
|
||||
|
||||
populateGoPackageSiblings(parsedFiles, indexes, { fileContents });
|
||||
|
||||
const augmentations = indexes.bindingAugmentations;
|
||||
expect(augmentations.get('module:foo-a')?.get('OnlyFooHelper')?.[0]?.def.nodeId).toBe(
|
||||
'foo-helper',
|
||||
);
|
||||
expect(augmentations.get('module:foo-a')?.get('OnlyBar')).toBeUndefined();
|
||||
expect(augmentations.get('module:bar-a')?.get('OnlyFoo')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
function def(nodeId: string, filePath: string, name: string): SymbolDefinition {
|
||||
return { nodeId, filePath, type: 'Function', qualifiedName: name };
|
||||
}
|
||||
|
||||
function parsed(filePath: string, moduleScope: string, localDef: SymbolDefinition): ParsedFile {
|
||||
return {
|
||||
filePath,
|
||||
moduleScope,
|
||||
scopes: [],
|
||||
parsedImports: [],
|
||||
localDefs: [localDef],
|
||||
referenceSites: [],
|
||||
};
|
||||
}
|
||||
114
gitnexus/test/unit/scope-resolution/go/go-type-binding.test.ts
Normal file
114
gitnexus/test/unit/scope-resolution/go/go-type-binding.test.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
synthesizeGoReceiverBinding,
|
||||
synthesizeGoTypeBindings,
|
||||
emitGoScopeCaptures,
|
||||
interpretGoTypeBinding,
|
||||
normalizeGoTypeName,
|
||||
} from '../../../../src/core/ingestion/languages/go/index.js';
|
||||
import { getGoParser } from '../../../../src/core/ingestion/languages/go/query.js';
|
||||
|
||||
describe('Go receiver binding', () => {
|
||||
it('synthesizes receiver type binding for method', () => {
|
||||
const src = 'package main\ntype User struct{}\nfunc (u *User) Save() {}';
|
||||
const tree = getGoParser().parse(src);
|
||||
const methodNode = tree.rootNode.descendantsOfType('method_declaration')[0];
|
||||
const result = synthesizeGoReceiverBinding(methodNode as any)!;
|
||||
expect(result['@type-binding.self']).toBeDefined();
|
||||
expect(result['@type-binding.name']!.text).toBe('u');
|
||||
expect(result['@type-binding.type']!.text).toBe('User');
|
||||
});
|
||||
|
||||
it('returns null for free function', () => {
|
||||
const src = 'package main\nfunc Save() {}';
|
||||
const tree = getGoParser().parse(src);
|
||||
const fnNode = tree.rootNode.descendantsOfType('function_declaration')[0];
|
||||
expect(synthesizeGoReceiverBinding(fnNode as any)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Go type binding synthesis — 7 patterns', () => {
|
||||
it('synthesizes new() type binding', () => {
|
||||
const src = 'package main\nfunc main() {\n user := new(User)\n}';
|
||||
const tree = getGoParser().parse(src);
|
||||
const matches = synthesizeGoTypeBindings(tree.rootNode as any);
|
||||
expect(matches.length).toBeGreaterThanOrEqual(1);
|
||||
const newMatch = matches.find((m) => m['@type-binding.new']);
|
||||
expect(newMatch?.['@type-binding.name']?.text).toBe('user');
|
||||
expect(newMatch?.['@type-binding.type']?.text).toBe('User');
|
||||
const parsed = interpretGoTypeBinding(newMatch!);
|
||||
expect(parsed?.rawTypeName).toBe('User');
|
||||
});
|
||||
|
||||
it('synthesizes make([]T) type binding', () => {
|
||||
const src = 'package main\nfunc main() {\n sl := make([]User, 0)\n}';
|
||||
const tree = getGoParser().parse(src);
|
||||
const matches = synthesizeGoTypeBindings(tree.rootNode as any);
|
||||
const makeMatch = matches.find((m) => m['@type-binding.make']);
|
||||
expect(makeMatch?.['@type-binding.name']?.text).toBe('sl');
|
||||
expect(makeMatch?.['@type-binding.type']?.text).toBe('User');
|
||||
});
|
||||
|
||||
it('synthesizes make(map[K]V) type binding', () => {
|
||||
const src = 'package main\nfunc main() {\n m := make(map[string]User)\n}';
|
||||
const tree = getGoParser().parse(src);
|
||||
const matches = synthesizeGoTypeBindings(tree.rootNode as any);
|
||||
const makeMatch = matches.find((m) => m['@type-binding.make']);
|
||||
expect(makeMatch?.['@type-binding.name']?.text).toBe('m');
|
||||
expect(makeMatch?.['@type-binding.type']?.text).toBe('User');
|
||||
});
|
||||
|
||||
it('supplements qualified type constructor: pkg.Foo{}', () => {
|
||||
const src = 'package main\nfunc main() {\n u := models.User{}\n}';
|
||||
const matches = emitGoScopeCaptures(src, 'main.go');
|
||||
const qMatch = matches.find(
|
||||
(m) => m['@type-binding.constructor'] && m['@type-binding.type']?.text === 'models.User',
|
||||
);
|
||||
expect(qMatch).toBeDefined();
|
||||
});
|
||||
|
||||
it('keeps multi-assignment constructor bindings aligned with RHS positions', () => {
|
||||
const src = 'package main\nfunc main() {\n a, b := 42, X{}\n}';
|
||||
const bindings = emitGoScopeCaptures(src, 'main.go')
|
||||
.filter((m) => m['@type-binding.name'] !== undefined)
|
||||
.map((m) => ({
|
||||
name: m['@type-binding.name']!.text,
|
||||
type: m['@type-binding.type']!.text,
|
||||
}));
|
||||
|
||||
expect(bindings).toContainEqual({ name: 'b', type: 'X' });
|
||||
expect(bindings).not.toContainEqual({ name: 'a', type: 'X' });
|
||||
});
|
||||
|
||||
it('interprets assertion type binding', () => {
|
||||
const result = interpretGoTypeBinding({
|
||||
'@type-binding.assertion': {
|
||||
name: '@type-binding.assertion',
|
||||
text: 's.(User)',
|
||||
range: { startLine: 1, startCol: 1, endLine: 1, endCol: 5 },
|
||||
},
|
||||
'@type-binding.name': {
|
||||
name: '@type-binding.name',
|
||||
text: 'user',
|
||||
range: { startLine: 1, startCol: 1, endLine: 1, endCol: 5 },
|
||||
},
|
||||
'@type-binding.type': {
|
||||
name: '@type-binding.type',
|
||||
text: 'User',
|
||||
range: { startLine: 1, startCol: 10, endLine: 1, endCol: 14 },
|
||||
},
|
||||
});
|
||||
expect(result?.rawTypeName).toBe('User');
|
||||
expect(result?.source).toBe('annotation');
|
||||
});
|
||||
|
||||
it('normalizes pointer, slice, map, qualified, generic type names', () => {
|
||||
expect(normalizeGoTypeName('*User')).toBe('User');
|
||||
expect(normalizeGoTypeName('[]string')).toBe('string');
|
||||
expect(normalizeGoTypeName('map[string]int')).toBe('int');
|
||||
expect(normalizeGoTypeName('chan int')).toBe('int');
|
||||
expect(normalizeGoTypeName('func() error')).toBe('error');
|
||||
expect(normalizeGoTypeName('models.User')).toBe('User');
|
||||
expect(normalizeGoTypeName('List[User]')).toBe('List');
|
||||
});
|
||||
});
|
||||
119
gitnexus/test/unit/server-validation.test.ts
Normal file
119
gitnexus/test/unit/server-validation.test.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
/**
|
||||
* Unit Tests: server validation helpers (gitnexus/src/server/validation.ts)
|
||||
*
|
||||
* Covers U1 of the security remediation plan:
|
||||
* - assertString closes js/type-confusion-through-parameter-tampering by
|
||||
* rejecting array-form HTTP query parameters before they reach a `.length` guard.
|
||||
* - assertSafePath consolidates the path-traversal guard from api.ts:1067-1077
|
||||
* for reuse across other path-injection findings.
|
||||
* - escapeRegExp is the utility for upcoming /api/grep regex-injection fix.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
assertString,
|
||||
assertSafePath,
|
||||
escapeRegExp,
|
||||
BadRequestError,
|
||||
ForbiddenError,
|
||||
} from '../../src/server/validation.js';
|
||||
|
||||
describe('assertString', () => {
|
||||
it('returns the value when it is a string', () => {
|
||||
expect(assertString('hello', 'name')).toBe('hello');
|
||||
});
|
||||
|
||||
it('returns an empty string as-is (length validation is the caller’s job)', () => {
|
||||
expect(assertString('', 'name')).toBe('');
|
||||
});
|
||||
|
||||
it('rejects an array with a message naming the field', () => {
|
||||
expect(() => assertString(['a', 'b'], 'pattern')).toThrow(BadRequestError);
|
||||
try {
|
||||
assertString(['a', 'b'], 'pattern');
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(BadRequestError);
|
||||
expect((err as BadRequestError).status).toBe(400);
|
||||
expect((err as Error).message).toContain('pattern');
|
||||
expect((err as Error).message).toContain('array');
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects undefined', () => {
|
||||
expect(() => assertString(undefined, 'name')).toThrow(BadRequestError);
|
||||
});
|
||||
|
||||
it('rejects a number', () => {
|
||||
expect(() => assertString(123, 'name')).toThrow(BadRequestError);
|
||||
});
|
||||
|
||||
it('rejects an object', () => {
|
||||
expect(() => assertString({ key: 'value' }, 'name')).toThrow(BadRequestError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertSafePath', () => {
|
||||
const root = path.resolve('/repos/x');
|
||||
|
||||
it('resolves an in-repo relative path to its absolute form', () => {
|
||||
const result = assertSafePath('src/foo.ts', root);
|
||||
expect(result).toBe(path.join(root, 'src/foo.ts'));
|
||||
});
|
||||
|
||||
it('accepts the root itself', () => {
|
||||
expect(assertSafePath('.', root)).toBe(root);
|
||||
});
|
||||
|
||||
it('rejects a parent-directory traversal with ForbiddenError (status 403)', () => {
|
||||
expect(() => assertSafePath('../../../etc/passwd', root)).toThrow(ForbiddenError);
|
||||
try {
|
||||
assertSafePath('../../../etc/passwd', root);
|
||||
} catch (err) {
|
||||
expect((err as BadRequestError).status).toBe(403);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects an absolute path that escapes the root', () => {
|
||||
expect(() => assertSafePath('/etc/passwd', root)).toThrow(ForbiddenError);
|
||||
});
|
||||
|
||||
it('rejects an empty path', () => {
|
||||
expect(() => assertSafePath('', root)).toThrow(BadRequestError);
|
||||
});
|
||||
|
||||
it('rejects a path containing a null byte', () => {
|
||||
expect(() => assertSafePath('foo\0bar', root)).toThrow(BadRequestError);
|
||||
});
|
||||
|
||||
it('does not confuse "src/.." with "../" (must not escape root)', () => {
|
||||
// src/.. resolves back to root, which is allowed.
|
||||
expect(assertSafePath('src/..', root)).toBe(root);
|
||||
});
|
||||
});
|
||||
|
||||
describe('escapeRegExp', () => {
|
||||
it('escapes the dot metacharacter', () => {
|
||||
expect(escapeRegExp('a.b')).toBe('a\\.b');
|
||||
});
|
||||
|
||||
it('escapes all common regex metacharacters', () => {
|
||||
expect(escapeRegExp('a.b*c+d?e^f$g{h}i(j)k|l[m]n\\o')).toBe(
|
||||
'a\\.b\\*c\\+d\\?e\\^f\\$g\\{h\\}i\\(j\\)k\\|l\\[m\\]n\\\\o',
|
||||
);
|
||||
});
|
||||
|
||||
it('passes through a string with no metacharacters', () => {
|
||||
expect(escapeRegExp('plain text')).toBe('plain text');
|
||||
});
|
||||
|
||||
it('handles an empty string', () => {
|
||||
expect(escapeRegExp('')).toBe('');
|
||||
});
|
||||
|
||||
it('produces a literal-matching regex when fed back to new RegExp', () => {
|
||||
const userInput = 'a.b*c';
|
||||
const re = new RegExp(escapeRegExp(userInput));
|
||||
expect(re.test('a.b*c')).toBe(true);
|
||||
expect(re.test('axbxc')).toBe(false); // confirms the . was treated as literal
|
||||
});
|
||||
});
|
||||
|
|
@ -185,4 +185,95 @@ describe('setupClaudeCode', () => {
|
|||
args: ['-y', 'gitnexus@latest', 'mcp'],
|
||||
});
|
||||
});
|
||||
|
||||
it('picks .cmd wrapper from Windows where output (multiple lines)', async () => {
|
||||
setPlatform('win32');
|
||||
// `where gitnexus` on Windows returns the POSIX script first, then .cmd
|
||||
execFileSyncMock.mockReturnValueOnce(
|
||||
'C:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus\nC:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus.cmd\n',
|
||||
);
|
||||
|
||||
const { setupCommand } = await import('../../src/cli/setup.js');
|
||||
await setupCommand();
|
||||
|
||||
const raw = await fs.readFile(path.join(tempHome, '.claude.json'), 'utf-8');
|
||||
const config = JSON.parse(raw);
|
||||
|
||||
expect(config.mcpServers.gitnexus).toEqual({
|
||||
command: 'C:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus.cmd',
|
||||
args: ['mcp'],
|
||||
});
|
||||
});
|
||||
|
||||
it('handles CRLF line endings from Windows where output', async () => {
|
||||
setPlatform('win32');
|
||||
// Windows `where` produces CRLF line endings
|
||||
execFileSyncMock.mockReturnValueOnce(
|
||||
'C:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus\r\nC:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus.cmd\r\n',
|
||||
);
|
||||
|
||||
const { setupCommand } = await import('../../src/cli/setup.js');
|
||||
await setupCommand();
|
||||
|
||||
const raw = await fs.readFile(path.join(tempHome, '.claude.json'), 'utf-8');
|
||||
const config = JSON.parse(raw);
|
||||
|
||||
expect(config.mcpServers.gitnexus).toEqual({
|
||||
command: 'C:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus.cmd',
|
||||
args: ['mcp'],
|
||||
});
|
||||
});
|
||||
|
||||
it('picks .bat wrapper when .cmd is not present', async () => {
|
||||
setPlatform('win32');
|
||||
execFileSyncMock.mockReturnValueOnce(
|
||||
'C:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus\nC:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus.bat\n',
|
||||
);
|
||||
|
||||
const { setupCommand } = await import('../../src/cli/setup.js');
|
||||
await setupCommand();
|
||||
|
||||
const raw = await fs.readFile(path.join(tempHome, '.claude.json'), 'utf-8');
|
||||
const config = JSON.parse(raw);
|
||||
|
||||
expect(config.mcpServers.gitnexus).toEqual({
|
||||
command: 'C:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus.bat',
|
||||
args: ['mcp'],
|
||||
});
|
||||
});
|
||||
|
||||
it('handles uppercase .CMD extension (case-insensitive match)', async () => {
|
||||
setPlatform('win32');
|
||||
execFileSyncMock.mockReturnValueOnce(
|
||||
'C:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus\nC:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus.CMD\n',
|
||||
);
|
||||
|
||||
const { setupCommand } = await import('../../src/cli/setup.js');
|
||||
await setupCommand();
|
||||
|
||||
const raw = await fs.readFile(path.join(tempHome, '.claude.json'), 'utf-8');
|
||||
const config = JSON.parse(raw);
|
||||
|
||||
expect(config.mcpServers.gitnexus).toEqual({
|
||||
command: 'C:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus.CMD',
|
||||
args: ['mcp'],
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to first line on Windows when no .cmd/.bat wrapper found', async () => {
|
||||
setPlatform('win32');
|
||||
// Edge case: where returns only the POSIX script (no .cmd wrapper)
|
||||
execFileSyncMock.mockReturnValueOnce('C:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus\n');
|
||||
|
||||
const { setupCommand } = await import('../../src/cli/setup.js');
|
||||
await setupCommand();
|
||||
|
||||
const raw = await fs.readFile(path.join(tempHome, '.claude.json'), 'utf-8');
|
||||
const config = JSON.parse(raw);
|
||||
|
||||
expect(config.mcpServers.gitnexus).toEqual({
|
||||
command: 'C:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus',
|
||||
args: ['mcp'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue