chore: import upstream snapshot with attribution
install-script / powershell-syntax (push) Waiting to run
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
install-script / install (macos-14) (push) Waiting to run
install-script / install (ubuntu-latest) (push) Waiting to run
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Waiting to run
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
install-script / install (macos-14) (push) Waiting to run
install-script / install (ubuntu-latest) (push) Waiting to run
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Report a bug in Gortex
|
||||
title: ''
|
||||
labels: bug
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Run `gortex ...`
|
||||
2. See error
|
||||
|
||||
**Expected behavior**
|
||||
What you expected to happen.
|
||||
|
||||
**Environment:**
|
||||
- OS: [e.g. macOS 15, Ubuntu 24.04]
|
||||
- Go version: [e.g. 1.23]
|
||||
- Gortex version: [e.g. v0.3.0, output of `gortex version`]
|
||||
|
||||
**Additional context**
|
||||
Any other context, logs, or screenshots.
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest a new feature or language support
|
||||
title: ''
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem?**
|
||||
A clear description of the problem.
|
||||
|
||||
**Describe the solution you'd like**
|
||||
What you want to happen.
|
||||
|
||||
**Use case**
|
||||
How would this feature be used? Is it for CLI, MCP tools, or the web UI?
|
||||
|
||||
**For new language support:**
|
||||
- Language: [e.g. Lua]
|
||||
- Tree-sitter grammar available in `go-tree-sitter`: [yes/no]
|
||||
- Key constructs to extract: [e.g. functions, classes, modules, imports]
|
||||
@@ -0,0 +1,20 @@
|
||||
## Summary
|
||||
|
||||
Brief description of what this PR does.
|
||||
|
||||
## Changes
|
||||
|
||||
-
|
||||
|
||||
## Testing
|
||||
|
||||
- [ ] All tests pass (`go test -race ./...`)
|
||||
- [ ] New tests added for new functionality
|
||||
- [ ] Benchmarks run if performance-relevant
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Code follows existing patterns in the codebase
|
||||
- [ ] No unnecessary abstractions added
|
||||
- [ ] Language extractor includes `Meta["methods"]` for interfaces (if applicable)
|
||||
- [ ] Methods have `EdgeMemberOf` edges to their containing type (if applicable)
|
||||
@@ -0,0 +1,62 @@
|
||||
version: 2
|
||||
|
||||
# Dependabot keeps GitHub Actions and Go modules current.
|
||||
#
|
||||
# Scorecard bumps the "Pinned-Dependencies" score when actions are pinned
|
||||
# to full commit SHAs; enabling updates here + allowing Dependabot to
|
||||
# rewrite `@v6` refs to `@<sha>` is the maintainable path to that score.
|
||||
# (Set `.github/.scorecard.yml` or per-action comment later if you want
|
||||
# Scorecard to accept shield-style tag refs.)
|
||||
|
||||
updates:
|
||||
# GitHub Actions across every workflow file (ci.yml, release.yml,
|
||||
# scorecard.yml, bench-arm.yml).
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
time: "05:00"
|
||||
timezone: Etc/UTC
|
||||
commit-message:
|
||||
prefix: ci
|
||||
include: scope
|
||||
labels:
|
||||
- dependencies
|
||||
- github-actions
|
||||
# Group everything non-major into one PR per week to keep the
|
||||
# notification rate sane. Majors get their own PRs so they can be
|
||||
# reviewed carefully (Actions majors occasionally break).
|
||||
groups:
|
||||
actions-minor-patch:
|
||||
patterns: ["*"]
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
|
||||
# Go module dependencies.
|
||||
- package-ecosystem: gomod
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
time: "05:00"
|
||||
timezone: Etc/UTC
|
||||
commit-message:
|
||||
prefix: deps
|
||||
include: scope
|
||||
labels:
|
||||
- dependencies
|
||||
- go
|
||||
# Same strategy — minor/patch grouped, majors separate. The indirect
|
||||
# tree under tree-sitter-* is large and would otherwise drown the
|
||||
# inbox in individual PRs.
|
||||
groups:
|
||||
go-minor-patch:
|
||||
patterns: ["*"]
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
# Open a manageable number of PRs at once — default 5 for gomod is
|
||||
# fine; bumping to 10 covers our broader dep tree without flooding.
|
||||
open-pull-requests-limit: 10
|
||||
@@ -0,0 +1,79 @@
|
||||
name: ARM Benchmarks
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
bench-arm64:
|
||||
name: Benchmark (ARM64)
|
||||
runs-on: ubuntu-24.04-arm
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Run benchmarks
|
||||
run: |
|
||||
go test -bench=. -benchmem -count=3 -benchtime=2s -timeout=20m \
|
||||
-run='^$' \
|
||||
./internal/graph/ \
|
||||
./internal/search/ \
|
||||
./internal/parser/languages/ \
|
||||
./internal/resolver/ \
|
||||
./internal/query/ \
|
||||
./internal/indexer/ \
|
||||
./internal/analysis/ \
|
||||
| tee bench-arm64.txt
|
||||
|
||||
- name: Upload results
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: bench-arm64-${{ github.sha }}
|
||||
path: bench-arm64.txt
|
||||
retention-days: 90
|
||||
|
||||
- name: Compare with main (PR only)
|
||||
if: github.event_name == 'pull_request'
|
||||
run: |
|
||||
go install golang.org/x/perf/cmd/benchstat@v0.0.0-20260409210113-8e83ce0f7b1c
|
||||
# Download baseline from main branch artifact if available
|
||||
echo "Benchmark results uploaded. Compare manually with benchstat."
|
||||
|
||||
bench-amd64:
|
||||
name: Benchmark (AMD64 baseline)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Run benchmarks
|
||||
run: |
|
||||
go test -bench=. -benchmem -count=3 -benchtime=2s -timeout=20m \
|
||||
-run='^$' \
|
||||
./internal/graph/ \
|
||||
./internal/search/ \
|
||||
./internal/parser/languages/ \
|
||||
./internal/resolver/ \
|
||||
./internal/query/ \
|
||||
./internal/indexer/ \
|
||||
./internal/analysis/ \
|
||||
| tee bench-amd64.txt
|
||||
|
||||
- name: Upload results
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: bench-amd64-${{ github.sha }}
|
||||
path: bench-amd64.txt
|
||||
retention-days: 90
|
||||
@@ -0,0 +1,119 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
go-version: ['1.26']
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Build
|
||||
run: go build -o gortex ./cmd/gortex/
|
||||
|
||||
- name: Test
|
||||
run: go test -race -timeout=20m -coverprofile=coverage.out ./...
|
||||
|
||||
- name: Upload coverage
|
||||
if: matrix.os == 'ubuntu-latest' && matrix.go-version == '1.26'
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v6
|
||||
with:
|
||||
files: coverage.out
|
||||
continue-on-error: true
|
||||
|
||||
build-windows:
|
||||
# CGO build smoke-test on native Windows. tree-sitter needs a C/C++
|
||||
# compiler — the GitHub windows runner ships mingw-w64 on PATH, so no
|
||||
# extra toolchain setup is required. Build-only: `go build ./...`
|
||||
# compiles every production package without pulling in the
|
||||
# platform-specific test files a full `go test` would.
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Build CLI
|
||||
run: go build -o gortex.exe ./cmd/gortex/
|
||||
|
||||
- name: Build all packages
|
||||
run: go build ./...
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9
|
||||
with:
|
||||
version: v2.11.4
|
||||
args: --timeout=10m
|
||||
|
||||
build-onnx:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Install ONNX Runtime
|
||||
run: |
|
||||
wget -q https://github.com/microsoft/onnxruntime/releases/download/v1.24.4/onnxruntime-linux-x64-1.24.4.tgz
|
||||
tar xzf onnxruntime-linux-x64-1.24.4.tgz
|
||||
sudo cp onnxruntime-linux-x64-1.24.4/lib/libonnxruntime.so* /usr/local/lib/
|
||||
sudo ldconfig
|
||||
|
||||
- name: Build with ONNX tag
|
||||
run: go build -tags embeddings_onnx -o gortex-onnx ./cmd/gortex/
|
||||
|
||||
- name: Build default (Hugot bundled — no tag)
|
||||
run: go build -o gortex-default ./cmd/gortex/
|
||||
|
||||
- name: Install rust tokenizers library
|
||||
# hugot's XLA session uses the rust tokenizer, statically linked as
|
||||
# -ltokenizers, so the embeddings_gomlx XLA build needs libtokenizers.a
|
||||
# on the linker path. (The pure-Go default and the ONNX build do not.)
|
||||
run: |
|
||||
curl -fsSL https://github.com/daulet/tokenizers/releases/download/v1.27.0/libtokenizers.linux-amd64.tar.gz | tar -xz
|
||||
sudo cp libtokenizers.a /usr/lib/
|
||||
sudo cp libtokenizers.a /usr/local/lib/
|
||||
|
||||
- name: Build with GoMLX + XLA tags
|
||||
run: go build -tags "embeddings_gomlx XLA" -o gortex-gomlx ./cmd/gortex/
|
||||
|
||||
- name: Build with GoMLX tag only (compat — some docs/scripts still pass it alone)
|
||||
run: go build -tags embeddings_gomlx -o gortex-gomlx-noxla ./cmd/gortex/
|
||||
|
||||
benchmark:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request'
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Run benchmarks
|
||||
run: go test -bench=. -benchmem -count=1 -benchtime=1s -timeout=20m ./internal/parser/languages/ ./internal/query/ ./internal/graph/
|
||||
@@ -0,0 +1,71 @@
|
||||
# Per-PR reviewer graph bundle.
|
||||
#
|
||||
# This is a TEMPLATE, not a live workflow. The `.yml.example` suffix means
|
||||
# GitHub Actions does NOT register or run it — copy it to
|
||||
# `.github/workflows/gortex-pr-review.yml` in your own repository to enable it.
|
||||
#
|
||||
# On every pull request it builds gortex, indexes the checked-out repository
|
||||
# into the daemon, runs `gortex prs bundle <N>` to produce the reviewer graph
|
||||
# bundle (changed files + graph-joined blast radius + PR-risk receipt + ranked
|
||||
# reviewers), and uploads that bundle as a CI artifact. A reviewer (or a
|
||||
# downstream agent) can then download one self-contained JSON file instead of
|
||||
# re-deriving the impact from scratch.
|
||||
#
|
||||
# The daemon self-serves PR data from the auto-provided `GITHUB_TOKEN` (mapped
|
||||
# to `GH_TOKEN`), so no extra secret configuration is needed.
|
||||
|
||||
name: Gortex PR review bundle
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
reviewer-bundle:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
# The daemon resolves a forge token from GH_TOKEN (then GITHUB_TOKEN).
|
||||
# GITHUB_TOKEN is injected automatically for pull_request events.
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- name: Check out the PR head
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# Full history so the graph and any base-diff have real commits.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Build gortex
|
||||
# tree-sitter needs CGO; the ubuntu runner ships a C toolchain.
|
||||
run: go build -o gortex ./cmd/gortex/
|
||||
|
||||
- name: Start the daemon and index the repo
|
||||
run: |
|
||||
./gortex daemon start --detach
|
||||
./gortex track .
|
||||
# `index` runs synchronously and prints stats, so the graph is
|
||||
# populated and queryable before the bundle step runs.
|
||||
./gortex index .
|
||||
|
||||
- name: Build the reviewer bundle
|
||||
run: |
|
||||
./gortex prs bundle "${{ github.event.pull_request.number }}" \
|
||||
--out "pr-${{ github.event.pull_request.number }}-bundle.json"
|
||||
|
||||
- name: Upload the reviewer bundle
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: gortex-reviewer-bundle-pr-${{ github.event.pull_request.number }}
|
||||
path: pr-${{ github.event.pull_request.number }}-bundle.json
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Stop the daemon
|
||||
if: always()
|
||||
run: ./gortex daemon stop || true
|
||||
@@ -0,0 +1,120 @@
|
||||
# Smoke-test gortex init's adapter pipeline on PRs that touch it.
|
||||
# The job runs `gortex init --dry-run --json` against a synthetic
|
||||
# HOME with each agent's detection sentinel pre-created, then
|
||||
# parses the JSON report and asserts each adapter reports itself
|
||||
# configured. A schema change that breaks an adapter fails here
|
||||
# before the PR merges.
|
||||
name: init-smoke
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "cmd/gortex/init*.go"
|
||||
- "internal/agents/**"
|
||||
- ".github/workflows/init-smoke.yml"
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "cmd/gortex/init*.go"
|
||||
- "internal/agents/**"
|
||||
|
||||
jobs:
|
||||
dry-run:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Build gortex
|
||||
run: go build -o /tmp/gortex ./cmd/gortex
|
||||
|
||||
- name: Prepare synthetic HOME with every agent's detection sentinel
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
mkdir -p "$RUNNER_TEMP/home"
|
||||
# Claude Code — no sentinel needed, always detected
|
||||
mkdir -p "$RUNNER_TEMP/home/.claude"
|
||||
# Cursor
|
||||
mkdir -p "$RUNNER_TEMP/home/.cursor"
|
||||
mkdir -p "$RUNNER_TEMP/repo/.cursor"
|
||||
# VS Code
|
||||
mkdir -p "$RUNNER_TEMP/repo/.vscode"
|
||||
# Continue
|
||||
mkdir -p "$RUNNER_TEMP/repo/.continue"
|
||||
# Kiro
|
||||
mkdir -p "$RUNNER_TEMP/repo/.kiro"
|
||||
# OpenCode
|
||||
mkdir -p "$RUNNER_TEMP/repo/.opencode"
|
||||
# Windsurf
|
||||
mkdir -p "$RUNNER_TEMP/home/.codeium"
|
||||
# Cline — per-editor globalStorage
|
||||
mkdir -p "$RUNNER_TEMP/home/.config/Code/User/globalStorage/saoudrizwan.claude-dev/settings"
|
||||
# Kilo Code
|
||||
mkdir -p "$RUNNER_TEMP/home/.config/Code/User/globalStorage/kilocode.kilo/settings"
|
||||
# Codex
|
||||
mkdir -p "$RUNNER_TEMP/home/.codex"
|
||||
# Gemini
|
||||
mkdir -p "$RUNNER_TEMP/home/.gemini"
|
||||
touch "$RUNNER_TEMP/home/.gemini/settings.json"
|
||||
# Zed (Linux path)
|
||||
mkdir -p "$RUNNER_TEMP/home/.config/zed"
|
||||
# Aider
|
||||
touch "$RUNNER_TEMP/repo/.aider.conf.yml"
|
||||
# Oh My Pi
|
||||
mkdir -p "$RUNNER_TEMP/repo/.omp"
|
||||
# OpenClaw
|
||||
mkdir -p "$RUNNER_TEMP/home/.openclaw"
|
||||
|
||||
- name: Run gortex init --dry-run --json
|
||||
id: run
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
cd "$RUNNER_TEMP/repo"
|
||||
git init -q
|
||||
HOME="$RUNNER_TEMP/home" /tmp/gortex init --yes --dry-run --json > "$RUNNER_TEMP/report.json"
|
||||
cat "$RUNNER_TEMP/report.json"
|
||||
|
||||
- name: Assert every expected adapter reported detected=true
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
python3 - <<'PY'
|
||||
import json, sys
|
||||
with open("${RUNNER_TEMP}/report.json".replace("${RUNNER_TEMP}", __import__("os").environ["RUNNER_TEMP"])) as f:
|
||||
data = json.load(f)
|
||||
agents = {a["name"]: a for a in data["agents"]}
|
||||
expected = [
|
||||
"claude-code", "aider", "cline", "codex", "continue",
|
||||
"cursor", "gemini", "kilocode", "kiro", "oh-my-pi", "opencode",
|
||||
"openclaw", "vscode", "windsurf", "zed",
|
||||
# "antigravity" always true when HOME is set; not a detection test.
|
||||
]
|
||||
failed = []
|
||||
for name in expected:
|
||||
a = agents.get(name)
|
||||
if not a:
|
||||
failed.append(f"{name}: missing from report")
|
||||
continue
|
||||
if not a.get("detected"):
|
||||
failed.append(f"{name}: detected=false despite sentinel")
|
||||
if failed:
|
||||
print("\n".join(failed))
|
||||
sys.exit(1)
|
||||
print(f"all {len(expected)} adapters detected")
|
||||
PY
|
||||
|
||||
- name: Assert dry-run left no files behind
|
||||
run: |
|
||||
# Dry-run must not touch disk. Allow only the sentinel
|
||||
# directories we created in the "Prepare" step.
|
||||
set -euxo pipefail
|
||||
unexpected="$(find "$RUNNER_TEMP/home" -type f 2>/dev/null | grep -v '.gemini/settings.json$' || true)"
|
||||
if [ -n "$unexpected" ]; then
|
||||
echo "ERROR: dry-run wrote files:"
|
||||
echo "$unexpected"
|
||||
exit 1
|
||||
fi
|
||||
echo "OK — dry-run produced no writes under HOME"
|
||||
@@ -0,0 +1,100 @@
|
||||
# Smoke-test scripts/install.sh on Linux + macOS against the latest published
|
||||
# release. The script is the public install path served at get.gortex.dev, so
|
||||
# any change must prove it still produces a working `gortex` binary. Coverage:
|
||||
# Linux x64 (ubuntu-latest) and macOS arm64 (macos-14). Intel macOS is not
|
||||
# tested here — GitHub retired its Intel (macos-13) runners, and install.sh is
|
||||
# arch-agnostic (only the downloaded artifact differs). Linux arm64 is
|
||||
# exercised in the release flow.
|
||||
name: install-script
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "scripts/install.sh"
|
||||
- "scripts/install.ps1"
|
||||
- ".github/workflows/install-script.yml"
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "scripts/install.sh"
|
||||
- "scripts/install.ps1"
|
||||
|
||||
jobs:
|
||||
posix-syntax:
|
||||
# Cheap pre-check: catch bashisms before we spin up the full matrix.
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Install shellcheck
|
||||
run: sudo apt-get update && sudo apt-get install -y shellcheck
|
||||
- name: shellcheck (sh dialect)
|
||||
run: shellcheck -s sh scripts/install.sh
|
||||
- name: POSIX dash syntax check
|
||||
run: dash -n scripts/install.sh
|
||||
|
||||
install:
|
||||
needs: posix-syntax
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-14]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Run install.sh into an isolated prefix
|
||||
run: |
|
||||
set -eux
|
||||
export GORTEX_INSTALL_DIR="$RUNNER_TEMP/bin"
|
||||
export GORTEX_NO_PATH=1
|
||||
# Don't clobber the runner's real shell rc; HOME points to a tmp dir.
|
||||
export HOME="$RUNNER_TEMP/home"
|
||||
mkdir -p "$HOME"
|
||||
./scripts/install.sh
|
||||
|
||||
- name: Verify binary runs
|
||||
run: |
|
||||
set -eux
|
||||
"$RUNNER_TEMP/bin/gortex" version
|
||||
|
||||
- name: Re-run install (idempotency + backup)
|
||||
run: |
|
||||
set -eux
|
||||
export GORTEX_INSTALL_DIR="$RUNNER_TEMP/bin"
|
||||
export GORTEX_NO_PATH=1
|
||||
export HOME="$RUNNER_TEMP/home"
|
||||
./scripts/install.sh
|
||||
# Previous binary should have been moved aside.
|
||||
test -f "$RUNNER_TEMP/bin/gortex.previous"
|
||||
"$RUNNER_TEMP/bin/gortex" version
|
||||
|
||||
- name: PATH update writes idempotent marker block
|
||||
run: |
|
||||
set -eux
|
||||
export GORTEX_INSTALL_DIR="$RUNNER_TEMP/bin"
|
||||
export HOME="$RUNNER_TEMP/home2"
|
||||
export SHELL=/bin/bash
|
||||
mkdir -p "$HOME"
|
||||
touch "$HOME/.bashrc"
|
||||
./scripts/install.sh
|
||||
./scripts/install.sh
|
||||
# Marker block must appear exactly once.
|
||||
count=$(grep -c '^# >>> gortex installer >>>' "$HOME/.bashrc")
|
||||
test "$count" = "1"
|
||||
|
||||
powershell-syntax:
|
||||
# Parse-check install.ps1 — the PowerShell counterpart of the
|
||||
# posix-syntax job. A full install can only run once a release ships
|
||||
# Windows artifacts, so this validates that the script parses without
|
||||
# errors on every change.
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- name: Parse install.ps1
|
||||
shell: pwsh
|
||||
run: |
|
||||
$errors = $null
|
||||
[System.Management.Automation.Language.Parser]::ParseFile(
|
||||
(Resolve-Path scripts/install.ps1), [ref]$null, [ref]$errors) | Out-Null
|
||||
if ($errors) { $errors; exit 1 }
|
||||
Write-Host "install.ps1 parses cleanly"
|
||||
@@ -0,0 +1,186 @@
|
||||
# publish-claude-plugin syncs the generated marketplace bundle from
|
||||
# this repo to gortexhq/claude-plugin on every gortex release.
|
||||
#
|
||||
# How it works:
|
||||
# 1. Checkout gortex at the released tag
|
||||
# 2. Regenerate the bundle via `make claude-plugin`
|
||||
# 3. Checkout gortexhq/claude-plugin into a sibling directory
|
||||
# 4. rsync the regenerated bundle into the publish-target checkout
|
||||
# 5. If git status is empty (bundle unchanged vs current HEAD of the
|
||||
# publish target) — exit 0, no push, no marketplace notification
|
||||
# 6. Otherwise commit + push + tag
|
||||
#
|
||||
# The "skip when unchanged" step is what gates marketplace update
|
||||
# notifications on real content changes. A gortex bug-fix release that
|
||||
# doesn't touch internal/agents/claudecode/content.go produces no diff
|
||||
# in the regenerated bundle, so the publish target is not updated and
|
||||
# marketplace users are not pinged.
|
||||
#
|
||||
# Auth: CLAUDE_PLUGIN_TOKEN is a fine-grained PAT scoped to
|
||||
# gortexhq/claude-plugin with `contents: write`. Mirrors the
|
||||
# HOMEBREW_TAP_TOKEN pattern used by .goreleaser.yml's homebrew_casks
|
||||
# block — same trust profile.
|
||||
|
||||
name: publish-claude-plugin
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: "Gortex tag (or branch) to publish from. Required for manual runs."
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
# Serialise publishes; two concurrent runs against the same target
|
||||
# repo would race on the push.
|
||||
group: publish-claude-plugin
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
PLUGIN_REPO: gortexhq/claude-plugin
|
||||
# Branch on the publish target repo to push to. Plugin repo is
|
||||
# single-branch by design; tags drive provenance.
|
||||
PLUGIN_BRANCH: main
|
||||
|
||||
steps:
|
||||
- name: Resolve gortex ref
|
||||
id: resolve
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
REF="${{ inputs.ref }}"
|
||||
else
|
||||
REF="${{ github.ref_name }}"
|
||||
fi
|
||||
if [ -z "$REF" ]; then
|
||||
echo "::error::could not resolve gortex ref to publish from"
|
||||
exit 1
|
||||
fi
|
||||
echo "ref=$REF" >> "$GITHUB_OUTPUT"
|
||||
echo "Publishing from gortex ref: $REF"
|
||||
|
||||
- name: Checkout gortex
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ steps.resolve.outputs.ref }}
|
||||
path: gortex
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
|
||||
with:
|
||||
go-version-file: gortex/go.mod
|
||||
cache: true
|
||||
cache-dependency-path: gortex/go.sum
|
||||
|
||||
- name: Regenerate plugin bundle
|
||||
working-directory: gortex
|
||||
run: make claude-plugin
|
||||
|
||||
- name: Checkout publish target
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
repository: ${{ env.PLUGIN_REPO }}
|
||||
ref: ${{ env.PLUGIN_BRANCH }}
|
||||
path: plugin-repo
|
||||
token: ${{ secrets.CLAUDE_PLUGIN_TOKEN }}
|
||||
# Need full history so the tag-already-exists check works
|
||||
# against historical tags, not just HEAD.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Sync bundle into publish target
|
||||
run: |
|
||||
# rsync replaces the publish target's tracked content with
|
||||
# the freshly-regenerated bundle. --delete removes files that
|
||||
# no longer exist in the source (e.g. a removed skill). The
|
||||
# --exclude entries protect repo-level files we don't manage
|
||||
# from the gortex side: .git/, .github/ (publish target may
|
||||
# carry its own readme-only CI), and a top-level README/LICENSE
|
||||
# if the publish target maintains its own.
|
||||
rsync -av --delete \
|
||||
--exclude='.git/' \
|
||||
--exclude='.github/' \
|
||||
gortex/claude-plugin/ \
|
||||
plugin-repo/
|
||||
|
||||
- name: Detect content changes
|
||||
id: diff
|
||||
working-directory: plugin-repo
|
||||
run: |
|
||||
git add -A
|
||||
if git diff --cached --quiet; then
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No content changes vs ${{ env.PLUGIN_REPO }}@${{ env.PLUGIN_BRANCH }}; nothing to publish"
|
||||
else
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
echo "Detected content changes:"
|
||||
git diff --cached --stat
|
||||
fi
|
||||
|
||||
- name: Commit and push
|
||||
if: steps.diff.outputs.changed == 'true'
|
||||
working-directory: plugin-repo
|
||||
env:
|
||||
GORTEX_REF: ${{ steps.resolve.outputs.ref }}
|
||||
run: |
|
||||
git config user.name "gortex-bot"
|
||||
git config user.email "bot@gortex.dev"
|
||||
git commit -m "Sync from gortex ${GORTEX_REF}"
|
||||
# Fully-qualified refspec — fails loudly on any future name
|
||||
# collision instead of pushing the wrong ref ambiguously.
|
||||
git push origin "refs/heads/${{ env.PLUGIN_BRANCH }}:refs/heads/${{ env.PLUGIN_BRANCH }}"
|
||||
|
||||
- name: Tag publish target with gortex version
|
||||
if: steps.diff.outputs.changed == 'true'
|
||||
working-directory: plugin-repo
|
||||
env:
|
||||
GORTEX_REF: ${{ steps.resolve.outputs.ref }}
|
||||
run: |
|
||||
# Tag the publish target with the gortex release tag that
|
||||
# produced the content. Useful for provenance — a marketplace
|
||||
# user can cross-reference the plugin version against the
|
||||
# gortex release that built it.
|
||||
#
|
||||
# Three guards:
|
||||
# 1. Skip when the gortex ref isn't a release tag.
|
||||
# workflow_dispatch is allowed to take a branch (e.g.
|
||||
# "main") for testing, but tagging the publish target
|
||||
# with a branch name would collide with its own branches
|
||||
# of the same name and fail the push with "src refspec
|
||||
# matches more than one".
|
||||
# 2. Skip silently if the tag already exists in the publish
|
||||
# target — re-runs against the same gortex tag that
|
||||
# produced no content change should be no-ops, not
|
||||
# half-pushed states.
|
||||
# 3. Use fully-qualified refspecs on the push so a future
|
||||
# collision (branch and tag with the same name in the
|
||||
# publish target) fails loudly rather than ambiguously.
|
||||
if [[ ! "${GORTEX_REF}" =~ ^v[0-9] ]]; then
|
||||
echo "Ref '${GORTEX_REF}' is not a release tag (does not match ^v[0-9]); skipping publish-target tag"
|
||||
exit 0
|
||||
fi
|
||||
if git rev-parse --verify "refs/tags/${GORTEX_REF}" >/dev/null 2>&1; then
|
||||
echo "Tag ${GORTEX_REF} already exists in ${{ env.PLUGIN_REPO }}; skipping"
|
||||
exit 0
|
||||
fi
|
||||
git tag -a "${GORTEX_REF}" -m "Plugin bundle from gortex ${GORTEX_REF}"
|
||||
git push origin "refs/tags/${GORTEX_REF}:refs/tags/${GORTEX_REF}"
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
{
|
||||
echo "### Publish summary"
|
||||
echo ""
|
||||
echo "- Source ref: \`${{ steps.resolve.outputs.ref }}\`"
|
||||
echo "- Target repo: \`${{ env.PLUGIN_REPO }}\`"
|
||||
echo "- Content changed: \`${{ steps.diff.outputs.changed }}\`"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -0,0 +1,672 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
# id-token: write is required for:
|
||||
# 1. cosign keyless signing via GitHub's OIDC token
|
||||
# 2. SLSA-3 provenance generation (the reusable workflow below also sets it)
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
# darwin is built on a NATIVE macOS runner so Apple's ld sets the
|
||||
# SG_READ_ONLY flag on the __DATA_CONST segment. The cross-compiled cask
|
||||
# shipped without it and aborted under the macOS 15+/Tahoe dyld
|
||||
# ("__DATA_CONST segment missing SG_READ_ONLY flag", issue #176). This job
|
||||
# links arm64 natively + amd64 via `clang -arch x86_64`, codesigns each
|
||||
# Mach-O, smoke-tests the flag (and actually runs the arm64 binary on this
|
||||
# Sequoia runner — the exact `gortex --version` repro), packages the tar.gz
|
||||
# archives in goreleaser's layout, and uploads them for the `release` job to
|
||||
# merge, notarize, sign, and reference from the homebrew cask.
|
||||
build-darwin:
|
||||
runs-on: macos-15
|
||||
# Only reads the repo and hands off an artifact — no release writes here.
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Build darwin binaries (native Apple toolchain)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
# Mirror goreleaser's ldflags so the darwin binaries report the same
|
||||
# version/commit as the linux ones. {{ .Version }} is the tag without
|
||||
# its leading "v".
|
||||
VERSION="${TAG#v}"
|
||||
COMMIT="$(git rev-parse --short HEAD)"
|
||||
DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
LDFLAGS="-s -w -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${DATE}"
|
||||
mkdir -p dist-darwin
|
||||
|
||||
# arm64: native link on this Apple-Silicon runner.
|
||||
CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 \
|
||||
go build -ldflags "$LDFLAGS" -o dist-darwin/gortex_darwin_arm64 ./cmd/gortex/
|
||||
|
||||
# amd64: cross within Apple's clang (the macOS SDK is universal), so
|
||||
# Apple's ld still sets SG_READ_ONLY. CC + CXX both target x86_64
|
||||
# because some tree-sitter scanners are C++.
|
||||
CGO_ENABLED=1 GOOS=darwin GOARCH=amd64 \
|
||||
CC="clang -arch x86_64" CXX="clang++ -arch x86_64" \
|
||||
go build -ldflags "$LDFLAGS" -o dist-darwin/gortex_darwin_amd64 ./cmd/gortex/
|
||||
|
||||
- name: Stage macOS signing material
|
||||
env:
|
||||
P12_B64: ${{ secrets.MACOS_CERTIFICATE_P12 }}
|
||||
P12_PASS: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SIGNING_DIR=/tmp/macos-signing
|
||||
mkdir -p "$SIGNING_DIR"; chmod 700 "$SIGNING_DIR"
|
||||
echo "$P12_B64" | base64 -d > "$SIGNING_DIR/cert.p12"
|
||||
printf '%s' "$P12_PASS" > "$SIGNING_DIR/cert.pass"
|
||||
chmod 600 "$SIGNING_DIR"/cert.*
|
||||
|
||||
# Reuse the exact signing path (scripts/sign-macos-build-hook.sh)
|
||||
# via rcodesign — just the native aarch64-apple-darwin build of the
|
||||
# tool instead of the linux-musl one. Pin version + sha256 together;
|
||||
# the apple-codesign release page publishes both.
|
||||
RCODESIGN_VERSION=0.29.0
|
||||
RCODESIGN_SHA256=d1a532150adaf90048260d76359261aa716abafc45c53c5dc18845029184334a
|
||||
RCODESIGN_TARBALL="apple-codesign-${RCODESIGN_VERSION}-aarch64-apple-darwin.tar.gz"
|
||||
curl -fsSL \
|
||||
"https://github.com/indygreg/apple-platform-rs/releases/download/apple-codesign%2F${RCODESIGN_VERSION}/${RCODESIGN_TARBALL}" \
|
||||
-o "$SIGNING_DIR/$RCODESIGN_TARBALL"
|
||||
echo "$RCODESIGN_SHA256 $SIGNING_DIR/$RCODESIGN_TARBALL" | shasum -a 256 -c -
|
||||
tar -xzf "$SIGNING_DIR/$RCODESIGN_TARBALL" -C "$SIGNING_DIR" --strip-components=1
|
||||
chmod +x "$SIGNING_DIR/rcodesign"
|
||||
rm "$SIGNING_DIR/$RCODESIGN_TARBALL"
|
||||
|
||||
- name: Codesign darwin binaries
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sh scripts/sign-macos-build-hook.sh darwin dist-darwin/gortex_darwin_arm64
|
||||
sh scripts/sign-macos-build-hook.sh darwin dist-darwin/gortex_darwin_amd64
|
||||
|
||||
- name: Smoke-test __DATA_CONST SG_READ_ONLY flag (issue #176)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sh scripts/verify-macho-readonly.sh dist-darwin/gortex_darwin_arm64
|
||||
sh scripts/verify-macho-readonly.sh dist-darwin/gortex_darwin_amd64
|
||||
# End-to-end repro of #176: actually load + run the binary on this
|
||||
# Sequoia runner, whose dyld enforces the flag. A missing flag aborts
|
||||
# here exactly like `gortex --version` did for users on Tahoe.
|
||||
chmod +x dist-darwin/gortex_darwin_arm64
|
||||
./dist-darwin/gortex_darwin_arm64 version
|
||||
# amd64 runs under Rosetta when present; otherwise the static otool
|
||||
# check above already covers it.
|
||||
if arch -x86_64 /usr/bin/true >/dev/null 2>&1; then
|
||||
chmod +x dist-darwin/gortex_darwin_amd64
|
||||
arch -x86_64 ./dist-darwin/gortex_darwin_amd64 version
|
||||
else
|
||||
echo "rosetta unavailable; amd64 covered by the otool flag check"
|
||||
fi
|
||||
|
||||
- name: Package darwin archives
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Match goreleaser's tar.gz layout: the binary as `gortex` at the
|
||||
# archive root, alongside LICENSE/README (goreleaser's default
|
||||
# archive files). install.sh + the cask expect this exact name:
|
||||
# gortex_<os>_<arch>.tar.gz.
|
||||
mkdir -p dist
|
||||
for arch in arm64 amd64; do
|
||||
stage="$(mktemp -d)"
|
||||
cp "dist-darwin/gortex_darwin_${arch}" "$stage/gortex"
|
||||
chmod +x "$stage/gortex"
|
||||
cp LICENSE.md README.md "$stage/"
|
||||
tar -C "$stage" -czf "dist/gortex_darwin_${arch}.tar.gz" gortex LICENSE.md README.md
|
||||
rm -rf "$stage"
|
||||
done
|
||||
ls -la dist/
|
||||
|
||||
- name: Wipe macOS signing material
|
||||
if: always()
|
||||
run: rm -rf /tmp/macos-signing
|
||||
|
||||
- name: Upload darwin archives
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: darwin-archives
|
||||
path: dist/gortex_darwin_*.tar.gz
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
release:
|
||||
needs: build-darwin
|
||||
runs-on: ubuntu-latest
|
||||
# Expose sha256 hashes of every release artifact so the provenance job
|
||||
# can feed them to the SLSA generator.
|
||||
outputs:
|
||||
hashes: ${{ steps.hash.outputs.hashes }}
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
# goreleaser reads the tag list to build the changelog. Without
|
||||
# full history the changelog section will be empty or wrong.
|
||||
fetch-depth: 0
|
||||
|
||||
# cosign is installed on the host (outside the goreleaser-cross
|
||||
# container) so keyless OIDC signing can use the runner's identity
|
||||
# token directly — no need to plumb ACTIONS_ID_TOKEN_REQUEST_* vars
|
||||
# through `docker run`.
|
||||
- uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
|
||||
with:
|
||||
cosign-release: v2.4.1
|
||||
|
||||
# Stage the Apple notary credentials + rcodesign (linux-musl build)
|
||||
# under one 0700 directory for the post-archive "Notarize macOS
|
||||
# binaries" step. The darwin binaries were already codesigned in the
|
||||
# build-darwin job, so the signing cert is NOT needed here — only the
|
||||
# notary API key. The dir is wiped unconditionally at the end of the
|
||||
# job (see "Wipe macOS signing material").
|
||||
- name: Stage macOS notary material
|
||||
env:
|
||||
NOTARY_B64: ${{ secrets.MACOS_NOTARY_API_KEY }}
|
||||
KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }}
|
||||
ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SIGNING_DIR=/tmp/macos-signing
|
||||
mkdir -p "$SIGNING_DIR"
|
||||
chmod 700 "$SIGNING_DIR"
|
||||
|
||||
echo "$NOTARY_B64" | base64 -d > "$SIGNING_DIR/notary.p8"
|
||||
|
||||
# Pin rcodesign to a known-good release. apple-codesign tags use
|
||||
# `apple-codesign/X.Y.Z`; the slash is URL-escaped as %2F. Bump
|
||||
# version + sha256 together — the release page publishes both.
|
||||
RCODESIGN_VERSION=0.29.0
|
||||
RCODESIGN_SHA256=dbe85cedd8ee4217b64e9a0e4c2aef92ab8bcaaa41f20bde99781ff02e600002
|
||||
RCODESIGN_TARBALL="apple-codesign-${RCODESIGN_VERSION}-x86_64-unknown-linux-musl.tar.gz"
|
||||
curl -fsSL \
|
||||
"https://github.com/indygreg/apple-platform-rs/releases/download/apple-codesign%2F${RCODESIGN_VERSION}/${RCODESIGN_TARBALL}" \
|
||||
-o "$SIGNING_DIR/$RCODESIGN_TARBALL"
|
||||
echo "$RCODESIGN_SHA256 $SIGNING_DIR/$RCODESIGN_TARBALL" | sha256sum -c -
|
||||
tar -xzf "$SIGNING_DIR/$RCODESIGN_TARBALL" -C "$SIGNING_DIR" --strip-components=1
|
||||
chmod +x "$SIGNING_DIR/rcodesign"
|
||||
rm "$SIGNING_DIR/$RCODESIGN_TARBALL"
|
||||
|
||||
# rcodesign notary-submit wants a single JSON file with the
|
||||
# issuer/key id and the .p8 contents pre-encoded — done once
|
||||
# here so the post-archive step is just an upload.
|
||||
"$SIGNING_DIR/rcodesign" encode-app-store-connect-api-key \
|
||||
-o "$SIGNING_DIR/notary.json" \
|
||||
"$ISSUER_ID" "$KEY_ID" "$SIGNING_DIR/notary.p8"
|
||||
|
||||
chmod 600 "$SIGNING_DIR"/notary.*
|
||||
|
||||
# Pull the darwin tar.gz archives the build-darwin job produced (signed,
|
||||
# flag-correct, native-linked) into dist-darwin/. They are merged into
|
||||
# dist/ AFTER goreleaser runs — goreleaser's `--clean` wipes dist/, so
|
||||
# staging them there first would lose them.
|
||||
- name: Fetch darwin archives
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: darwin-archives
|
||||
path: dist-darwin
|
||||
|
||||
- name: Run GoReleaser (linux build via Docker)
|
||||
# goreleaser-cross ships the aarch64/x86_64 linux gcc toolchains so the
|
||||
# two linux CGO targets cross-compile from one ubuntu runner. darwin is
|
||||
# built separately (build-darwin job) and windows separately
|
||||
# (release-windows job); goreleaser here owns the linux archives, the
|
||||
# deb/rpm/apk packages, checksums.txt, and the GitHub release. The
|
||||
# homebrew cask is assembled in the "Publish homebrew cask" step below,
|
||||
# once every platform's tarball hash exists.
|
||||
#
|
||||
# Image tag is pinned to the Go major.minor; bump together with
|
||||
# go.mod and the setup-go step elsewhere in the workflows.
|
||||
run: |
|
||||
docker run --rm --privileged \
|
||||
-v "$PWD":/go/src/gortex \
|
||||
-w /go/src/gortex \
|
||||
-e GITHUB_TOKEN \
|
||||
ghcr.io/goreleaser/goreleaser-cross:v1.26 \
|
||||
release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# goreleaser-cross runs as root inside the container, so everything
|
||||
# in dist/ is owned by root:root on the host. The subsequent cosign
|
||||
# and SLSA steps run as the non-root `runner` user and need to write
|
||||
# .sig / .pem files next to the artifacts — reclaim ownership now
|
||||
# before any permission-denied errors surface.
|
||||
- name: Reclaim ownership of dist/ from Docker
|
||||
run: sudo chown -R "$(id -u):$(id -g)" dist
|
||||
|
||||
# Now that goreleaser's --clean has run, fold the darwin tarballs in
|
||||
# alongside the linux ones so the notarize / cosign / checksum / upload
|
||||
# steps below treat all platforms uniformly.
|
||||
- name: Merge darwin archives into dist/
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cp dist-darwin/gortex_darwin_*.tar.gz dist/
|
||||
ls -la dist/
|
||||
|
||||
# The build-darwin job already codesigned each darwin Mach-O. Notarization
|
||||
# is Apple's blessing on that signature — they don't see the binary
|
||||
# again, just hash + signature metadata, so the tarball on disk is
|
||||
# left untouched. cosign + the checksums + SLSA provenance below all
|
||||
# cover those same untouched bytes.
|
||||
#
|
||||
# Bare Mach-O can't be stapled (only .app/.pkg/.dmg can), so we
|
||||
# don't pass --staple. Online macs fetch the ticket from Apple on
|
||||
# first run; offline-first UX would require a signed .pkg.
|
||||
- name: Notarize macOS binaries
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SIGNING_DIR=/tmp/macos-signing
|
||||
shopt -s nullglob
|
||||
for tgz in dist/gortex_darwin_*.tar.gz; do
|
||||
workdir="$(mktemp -d)"
|
||||
tar -xzf "$tgz" -C "$workdir"
|
||||
(cd "$workdir" && zip -q notary.zip gortex)
|
||||
"$SIGNING_DIR/rcodesign" notary-submit \
|
||||
--api-key-file "$SIGNING_DIR/notary.json" \
|
||||
--wait \
|
||||
"$workdir/notary.zip"
|
||||
rm -rf "$workdir"
|
||||
done
|
||||
|
||||
# goreleaser's checksums.txt only hashed its own (linux + nfpm)
|
||||
# artifacts. Append the darwin tarballs so install.sh — which verifies
|
||||
# every download against checksums.txt — and the cask both resolve. Done
|
||||
# before cosign so the signature covers the final checksums.txt. (The
|
||||
# release-windows job appends the windows zip the same way.)
|
||||
- name: Append darwin checksums
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd dist
|
||||
for f in gortex_darwin_*.tar.gz; do
|
||||
grep -q " ${f}$" checksums.txt || sha256sum "$f" >> checksums.txt
|
||||
done
|
||||
sort -o checksums.txt checksums.txt
|
||||
cat checksums.txt
|
||||
|
||||
# Keyless cosign signing: each artifact gets a `.sig` (signature) and
|
||||
# `.pem` (certificate chain binding the signature to this workflow's
|
||||
# GitHub OIDC identity). Consumers verify with:
|
||||
#
|
||||
# cosign verify-blob \
|
||||
# --certificate gortex_linux_amd64.tar.gz.pem \
|
||||
# --signature gortex_linux_amd64.tar.gz.sig \
|
||||
# --certificate-identity-regexp 'https://github\.com/zzet/gortex/.*' \
|
||||
# --certificate-oidc-issuer https://token.actions.githubusercontent.com \
|
||||
# gortex_linux_amd64.tar.gz
|
||||
- name: Sign release artifacts with cosign
|
||||
env:
|
||||
COSIGN_YES: "true"
|
||||
run: |
|
||||
cd dist
|
||||
shopt -s nullglob
|
||||
for f in *.tar.gz *.zip *.deb *.rpm *.apk checksums.txt; do
|
||||
echo "Signing $f..."
|
||||
cosign sign-blob \
|
||||
--output-signature "${f}.sig" \
|
||||
--output-certificate "${f}.pem" \
|
||||
"$f"
|
||||
done
|
||||
ls -la *.sig *.pem
|
||||
|
||||
# goreleaser created the release with the linux artifacts + a linux-only
|
||||
# checksums.txt. Add the darwin tarballs and overwrite checksums.txt with
|
||||
# the darwin-inclusive version (--clobber). The darwin .sig/.pem ride
|
||||
# along in the "Upload signatures" step below.
|
||||
- name: Upload darwin archives + refreshed checksums to release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
gh release upload "$TAG" \
|
||||
dist/gortex_darwin_*.tar.gz \
|
||||
dist/checksums.txt \
|
||||
--clobber
|
||||
|
||||
# Goreleaser already created the release and uploaded the primary
|
||||
# artifacts. Append .sig + .pem files to the same release so
|
||||
# verification instructions in README point at a single URL set.
|
||||
- name: Upload signatures to release
|
||||
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3
|
||||
with:
|
||||
files: |
|
||||
dist/*.sig
|
||||
dist/*.pem
|
||||
# softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3 appends to an existing release
|
||||
# when the tag already exists (it does — goreleaser made it a
|
||||
# moment ago). Never fail_on_unmatched_files here — a failed
|
||||
# signing step should surface as a signing error, not as a
|
||||
# missing-files upload error.
|
||||
fail_on_unmatched_files: false
|
||||
|
||||
- name: Compute artifact hashes for SLSA provenance
|
||||
id: hash
|
||||
run: |
|
||||
cd dist
|
||||
shopt -s nullglob
|
||||
# SLSA reusable generator wants base64-encoded sha256sum output
|
||||
# (one "hash filename" line per artifact). We hash the primary
|
||||
# artifacts only — .sig/.pem are attestations of these files,
|
||||
# they don't need their own provenance.
|
||||
HASHES=$(sha256sum *.tar.gz *.zip *.deb *.rpm *.apk | base64 -w0)
|
||||
echo "hashes=$HASHES" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Assemble the homebrew cask and push it to zzet/homebrew-tap. goreleaser
|
||||
# used to generate this, but the cask references all four os/arch tarballs
|
||||
# and darwin is no longer a goreleaser artifact, so we build it here from
|
||||
# the now-complete checksums.txt. Completions are still generated on the
|
||||
# user's machine at install time (generate_completions_from_executable),
|
||||
# so nothing needs to run at release time. HOMEBREW_TAP_TOKEN is a PAT
|
||||
# with `repo` scope on the tap; GITHUB_TOKEN can only push to this repo.
|
||||
- name: Publish homebrew cask
|
||||
env:
|
||||
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
VERSION="${TAG#v}"
|
||||
|
||||
# Pull each tarball's sha256 out of the darwin-inclusive checksums.txt.
|
||||
sha() { awk -v f="$1" '$2==f || $2=="*"f {print $1; exit}' dist/checksums.txt; }
|
||||
DA_AMD64="$(sha gortex_darwin_amd64.tar.gz)"
|
||||
DA_ARM64="$(sha gortex_darwin_arm64.tar.gz)"
|
||||
LX_AMD64="$(sha gortex_linux_amd64.tar.gz)"
|
||||
LX_ARM64="$(sha gortex_linux_arm64.tar.gz)"
|
||||
for pair in "darwin_amd64:$DA_AMD64" "darwin_arm64:$DA_ARM64" \
|
||||
"linux_amd64:$LX_AMD64" "linux_arm64:$LX_ARM64"; do
|
||||
[ -n "${pair#*:}" ] || { echo "FATAL: no sha256 for gortex_${pair%%:*}.tar.gz in checksums.txt"; exit 1; }
|
||||
done
|
||||
|
||||
# Generated cask. #{version} is Ruby interpolation evaluated by brew —
|
||||
# it must stay literal, so it is NOT shell-expanded here (no leading $).
|
||||
cat > gortex.rb <<EOF
|
||||
# This file is generated by release.yml. DO NOT EDIT.
|
||||
cask "gortex" do
|
||||
version "${VERSION}"
|
||||
|
||||
on_macos do
|
||||
on_intel do
|
||||
sha256 "${DA_AMD64}"
|
||||
url "https://github.com/zzet/gortex/releases/download/v#{version}/gortex_darwin_amd64.tar.gz"
|
||||
end
|
||||
on_arm do
|
||||
sha256 "${DA_ARM64}"
|
||||
url "https://github.com/zzet/gortex/releases/download/v#{version}/gortex_darwin_arm64.tar.gz"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
on_intel do
|
||||
sha256 "${LX_AMD64}"
|
||||
url "https://github.com/zzet/gortex/releases/download/v#{version}/gortex_linux_amd64.tar.gz"
|
||||
end
|
||||
on_arm do
|
||||
sha256 "${LX_ARM64}"
|
||||
url "https://github.com/zzet/gortex/releases/download/v#{version}/gortex_linux_arm64.tar.gz"
|
||||
end
|
||||
end
|
||||
|
||||
name "gortex"
|
||||
desc "Code intelligence engine that indexes repositories into an in-memory knowledge graph."
|
||||
homepage "https://github.com/zzet/gortex"
|
||||
|
||||
livecheck do
|
||||
skip "Auto-generated on release."
|
||||
end
|
||||
|
||||
binary "gortex"
|
||||
|
||||
generate_completions_from_executable "gortex",
|
||||
shell_parameter_format: :cobra
|
||||
|
||||
# No zap stanza required
|
||||
end
|
||||
EOF
|
||||
|
||||
git clone "https://x-access-token:${HOMEBREW_TAP_TOKEN}@github.com/zzet/homebrew-tap.git" tap
|
||||
mkdir -p tap/Casks
|
||||
cp gortex.rb tap/Casks/gortex.rb
|
||||
cd tap
|
||||
git add Casks/gortex.rb
|
||||
if git diff --cached --quiet; then
|
||||
echo "cask already current for ${VERSION}"
|
||||
else
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git commit -m "gortex: ${VERSION}"
|
||||
git push
|
||||
echo "published cask ${VERSION}"
|
||||
fi
|
||||
|
||||
# Always wipe signing material — even on failure — so a P12 / .p8
|
||||
# never lingers on a runner image cache or in actions/upload-artifact
|
||||
# debug bundles. shred clears the inode contents before unlink.
|
||||
- name: Wipe macOS signing material
|
||||
if: always()
|
||||
run: |
|
||||
if [ -d /tmp/macos-signing ]; then
|
||||
find /tmp/macos-signing -type f -exec shred -uf {} \; 2>/dev/null || true
|
||||
rm -rf /tmp/macos-signing
|
||||
fi
|
||||
|
||||
# Windows is built on a NATIVE windows runner: the CGo tree-sitter
|
||||
# bindings need a real C/C++ toolchain (mingw-w64 ships on PATH there),
|
||||
# and goreleaser-cross targets unix only. This job builds a statically
|
||||
# linked, self-contained .exe (no runtime DLLs to ship), zips it,
|
||||
# cosign-signs, and appends the zip to the release the `release` job
|
||||
# already created.
|
||||
release-windows:
|
||||
needs: release
|
||||
runs-on: windows-latest
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
|
||||
with:
|
||||
cosign-release: v2.4.1
|
||||
|
||||
- name: Build gortex.exe (static mingw runtime)
|
||||
shell: bash
|
||||
env:
|
||||
CGO_ENABLED: "1"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VER="${GITHUB_REF#refs/tags/}"
|
||||
# -extldflags=-static folds the mingw C/C++ runtime (libstdc++,
|
||||
# libgcc, libwinpthread) into the .exe so it ships as a single
|
||||
# self-contained binary — nothing to bundle alongside. The C++
|
||||
# stdlib is in the link at all because some tree-sitter grammars
|
||||
# carry C++ external scanners (e.g. go-sitter-forest norg); static
|
||||
# linking just puts it inside the .exe instead of a DLL.
|
||||
go build \
|
||||
-ldflags "-s -w -X main.version=${VER} -X main.commit=$(git rev-parse --short HEAD) -X main.date=$(date -u +%Y-%m-%dT%H:%M:%SZ) -extldflags=-static" \
|
||||
-o gortex.exe ./cmd/gortex/
|
||||
|
||||
- name: Verify gortex.exe is self-contained
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# The static link must leave no dependency on a mingw runtime DLL;
|
||||
# a partially static .exe would fail to start where that DLL is
|
||||
# absent. If objdump is available, fail the release on any leaked
|
||||
# mingw runtime import.
|
||||
objdump=""
|
||||
for cand in objdump x86_64-w64-mingw32-objdump; do
|
||||
command -v "$cand" >/dev/null 2>&1 && { objdump="$cand"; break; }
|
||||
done
|
||||
if [ -n "$objdump" ]; then
|
||||
echo "imported DLLs:"; "$objdump" -p gortex.exe | grep -i 'DLL Name' || true
|
||||
if "$objdump" -p gortex.exe | grep -iqE 'libstdc\+\+|libgcc_s|libwinpthread'; then
|
||||
echo "FATAL: gortex.exe still imports a mingw runtime DLL — static link incomplete"
|
||||
exit 1
|
||||
fi
|
||||
echo "ok: no mingw runtime DLL imports"
|
||||
else
|
||||
echo "WARN: objdump not found; skipping self-containment check"
|
||||
fi
|
||||
|
||||
- name: Zip (gortex_windows_amd64.zip)
|
||||
shell: pwsh
|
||||
run: Compress-Archive -Path gortex.exe -DestinationPath gortex_windows_amd64.zip -Force
|
||||
|
||||
- name: Sign + upload to release
|
||||
shell: bash
|
||||
env:
|
||||
COSIGN_YES: "true"
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cosign sign-blob \
|
||||
--output-signature gortex_windows_amd64.zip.sig \
|
||||
--output-certificate gortex_windows_amd64.zip.pem \
|
||||
gortex_windows_amd64.zip
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
gh release upload "$TAG" \
|
||||
gortex_windows_amd64.zip \
|
||||
gortex_windows_amd64.zip.sig \
|
||||
gortex_windows_amd64.zip.pem \
|
||||
--clobber
|
||||
|
||||
# Append the windows zip's sha256 to the release checksums.txt so
|
||||
# the one-line installer (scripts/install.ps1, which verifies
|
||||
# against checksums.txt) covers windows too — the unix goreleaser
|
||||
# run only hashed its own artifacts. needs:release guarantees
|
||||
# checksums.txt already exists.
|
||||
sha="$(sha256sum gortex_windows_amd64.zip | awk '{print $1}')"
|
||||
gh release download "$TAG" --pattern checksums.txt --clobber 2>/dev/null || : > checksums.txt
|
||||
if ! grep -q "gortex_windows_amd64.zip" checksums.txt; then
|
||||
printf '%s gortex_windows_amd64.zip\n' "$sha" >> checksums.txt
|
||||
gh release upload "$TAG" checksums.txt --clobber
|
||||
fi
|
||||
|
||||
- name: Publish Scoop manifest
|
||||
# Push a refreshed `gortex` manifest to gortexhq/scoop-bucket so
|
||||
# `scoop install gortex` resolves this release. SCOOP_BUCKET_TOKEN is
|
||||
# a PAT with `repo` scope on that bucket (GITHUB_TOKEN can only push
|
||||
# to the source repo). Non-blocking + self-skipping: a bucket hiccup
|
||||
# must not fail a release whose binary already shipped, and a
|
||||
# token-less fork just skips it.
|
||||
continue-on-error: true
|
||||
shell: bash
|
||||
env:
|
||||
SCOOP_BUCKET_TOKEN: ${{ secrets.SCOOP_BUCKET_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${SCOOP_BUCKET_TOKEN:-}" ]; then
|
||||
echo "SCOOP_BUCKET_TOKEN not set; skipping scoop manifest publish"
|
||||
exit 0
|
||||
fi
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
VER="${TAG#v}"
|
||||
URL="https://github.com/${GITHUB_REPOSITORY}/releases/download/${TAG}/gortex_windows_amd64.zip"
|
||||
SHA="$(sha256sum gortex_windows_amd64.zip | awk '{print $1}')"
|
||||
|
||||
# Build the manifest with jq so escaping + validity are guaranteed.
|
||||
# `bin` shims gortex.exe; checkver/autoupdate let scoop's tooling
|
||||
# track future releases (the $version token is literal on purpose).
|
||||
jq -n \
|
||||
--arg version "$VER" \
|
||||
--arg url "$URL" \
|
||||
--arg hash "$SHA" \
|
||||
--arg homepage "https://github.com/${GITHUB_REPOSITORY}" \
|
||||
--arg autourl "https://github.com/${GITHUB_REPOSITORY}/releases/download/v\$version/gortex_windows_amd64.zip" \
|
||||
'{
|
||||
version: $version,
|
||||
description: "Code intelligence engine that indexes repositories into an in-memory knowledge graph.",
|
||||
homepage: $homepage,
|
||||
license: "Apache-2.0",
|
||||
architecture: { "64bit": { url: $url, hash: $hash } },
|
||||
bin: "gortex.exe",
|
||||
checkver: "github",
|
||||
autoupdate: { architecture: { "64bit": { url: $autourl } } }
|
||||
}' > gortex.json
|
||||
|
||||
# Token in the clone URL — GitHub Actions masks the secret in logs.
|
||||
git clone "https://x-access-token:${SCOOP_BUCKET_TOKEN}@github.com/gortexhq/scoop-bucket.git" scoop-bucket
|
||||
cd scoop-bucket
|
||||
# Honour the bucket's layout: scoop reads manifests from the repo
|
||||
# root or a bucket/ subdir. Update in place if one exists, else use
|
||||
# the conventional bucket/ subdir.
|
||||
if [ -f gortex.json ]; then dest="gortex.json"; else mkdir -p bucket; dest="bucket/gortex.json"; fi
|
||||
cp ../gortex.json "$dest"
|
||||
git add "$dest"
|
||||
if git diff --cached --quiet; then
|
||||
echo "scoop manifest already current for ${VER}"
|
||||
else
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git commit -m "gortex: ${VER}"
|
||||
git push
|
||||
echo "published scoop manifest ${VER} -> $dest"
|
||||
fi
|
||||
|
||||
# SLSA-3 provenance via the OpenSSF reusable workflow. This runs in a
|
||||
# separate, isolated job that the `release` job can't tamper with —
|
||||
# that isolation is what elevates us from SLSA-2 to SLSA-3. Output is
|
||||
# a `multiple.intoto.jsonl` file attached to the release that
|
||||
# consumers verify with https://github.com/slsa-framework/slsa-verifier.
|
||||
provenance:
|
||||
needs: release
|
||||
permissions:
|
||||
actions: read
|
||||
id-token: write
|
||||
contents: write
|
||||
# Pinned by tag, not SHA: the SLSA generator verifies its builder
|
||||
# binary against the tag name in its workflow ref, so SHA pinning
|
||||
# breaks the integrity check (exits 27 with SUCCESS=false). Scorecard
|
||||
# exempts slsa-framework reusable workflows from the SHA-pin rule.
|
||||
uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v2.1.0
|
||||
with:
|
||||
base64-subjects: ${{ needs.release.outputs.hashes }}
|
||||
upload-assets: true
|
||||
|
||||
# VirusTotal scan each primary artifact against ~72 AV engines and
|
||||
# append a results badge to the release notes. Needs a VT_API_KEY
|
||||
# repo secret (free tier, 500 requests/day). Non-blocking — VT
|
||||
# outages shouldn't fail a release.
|
||||
virustotal:
|
||||
needs: release
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Download release assets
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
mkdir -p dist
|
||||
gh release download "${GITHUB_REF#refs/tags/}" --dir dist \
|
||||
--pattern '*.tar.gz' \
|
||||
--pattern '*.zip' \
|
||||
--pattern '*.deb' \
|
||||
--pattern '*.rpm' \
|
||||
--pattern '*.apk'
|
||||
|
||||
- name: VirusTotal scan + update release body
|
||||
uses: crazy-max/ghaction-virustotal@936d8c5c00afe97d3d9a1af26d017cfdf26800a2 # v5
|
||||
with:
|
||||
vt_api_key: ${{ secrets.VT_API_KEY }}
|
||||
files: |
|
||||
./dist/*.tar.gz
|
||||
./dist/*.zip
|
||||
./dist/*.deb
|
||||
./dist/*.rpm
|
||||
./dist/*.apk
|
||||
update_release_body: true
|
||||
@@ -0,0 +1,64 @@
|
||||
name: Scorecard supply-chain security
|
||||
|
||||
# OpenSSF Scorecard — automated grading across ~18 security dimensions
|
||||
# (branch protection, pinned deps, SAST, code review, signed releases, etc.).
|
||||
# Runs weekly + on push to main. Publishes results as SARIF to the Security
|
||||
# tab and to the public api.scorecard.dev aggregator that feeds the badge
|
||||
# in README.md.
|
||||
#
|
||||
# Template source: https://github.com/ossf/scorecard-action
|
||||
|
||||
on:
|
||||
branch_protection_rule:
|
||||
# Weekly schedule keeps the badge fresh and surfaces drift (new
|
||||
# unpinned deps, missing branch protection changes, etc.)
|
||||
schedule:
|
||||
- cron: '32 5 * * 1'
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
analysis:
|
||||
name: Scorecard analysis
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
# Needed to upload SARIF results to the Security tab.
|
||||
security-events: write
|
||||
# Needed to publish results to the public Scorecard API so the
|
||||
# shield badge in README.md resolves.
|
||||
id-token: write
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run analysis
|
||||
uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3
|
||||
with:
|
||||
results_file: results.sarif
|
||||
results_format: sarif
|
||||
# publish_results: true is what makes the public badge work.
|
||||
# Requires the repo to be public; fails silently otherwise.
|
||||
publish_results: true
|
||||
|
||||
# Upload to the Actions artifact store so the results are viewable
|
||||
# even before the SARIF lands in the Security tab (GitHub batches
|
||||
# SARIF ingestion on a ~15min cadence).
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: SARIF file
|
||||
path: results.sarif
|
||||
retention-days: 5
|
||||
|
||||
# SARIF into the Security tab so findings show up per-PR.
|
||||
- name: Upload to code-scanning
|
||||
uses: github/codeql-action/upload-sarif@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
@@ -0,0 +1,48 @@
|
||||
name: security
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: '0 6 * * *'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
jobs:
|
||||
govulncheck:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
- name: govulncheck
|
||||
uses: golang/govulncheck-action@b625fbe08f3bccbe446d94fbf87fcc875a4f50ee # v1
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
repo-checkout: false
|
||||
|
||||
trivy-fs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
||||
with:
|
||||
scan-type: fs
|
||||
scan-ref: .
|
||||
severity: CRITICAL,HIGH
|
||||
exit-code: '1'
|
||||
ignore-unfixed: true
|
||||
format: sarif
|
||||
output: trivy-fs.sarif
|
||||
- if: always()
|
||||
uses: github/codeql-action/upload-sarif@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4
|
||||
with:
|
||||
sarif_file: trivy-fs.sarif
|
||||
@@ -0,0 +1,46 @@
|
||||
name: Skill drift
|
||||
|
||||
# Fences every agent integration's generated output against what is
|
||||
# checked in — across all platforms (not Claude-only), on every PR (not
|
||||
# release-only). Two complementary checks run:
|
||||
#
|
||||
# 1. The all-platform render golden (go test) byte-compares every
|
||||
# adapter's rendered MCP config / instructions / hooks / routing
|
||||
# blocks against committed goldens.
|
||||
# 2. A structural --check that every adapter still emits a gortex
|
||||
# registration.
|
||||
#
|
||||
# Both build the pure-Go CLI (no -tags llama), so the gate runs on a
|
||||
# stock runner. When a check fails on an intended change, regenerate and
|
||||
# commit the goldens:
|
||||
# go test ./cmd/gortex -run TestAgentsRenderGolden -update-agent-render
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'internal/agents/**'
|
||||
- 'internal/hooks/**'
|
||||
- 'internal/mcp/**'
|
||||
- 'cmd/gortex/**'
|
||||
- '.github/workflows/skill-drift.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
skill-drift:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Build CLI
|
||||
run: go build -o gortex ./cmd/gortex/
|
||||
|
||||
- name: All-platform render golden
|
||||
run: go test ./cmd/gortex -run TestAgentsRenderGolden -count=1
|
||||
|
||||
- name: All-platform render structural check
|
||||
run: ./gortex agents render --check
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
# Binary
|
||||
./gortex
|
||||
/gortex
|
||||
/gortex-linux
|
||||
/specs/
|
||||
|
||||
# Repo-local, opt-in gortex config (corporate Temporal allow-list, local
|
||||
# providers). Never committed — see AGENTS.md / GORTEX_ALLOW_LOCAL_*.
|
||||
.gortex/
|
||||
|
||||
# Go
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
*.test
|
||||
*.out
|
||||
go.work
|
||||
go.work.sum
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Debug
|
||||
__debug_bin*
|
||||
/debug/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.pyo
|
||||
.venv/
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Release: native-darwin archives the release job stages outside dist/ (which
|
||||
# --clean wipes). Must be ignored or goreleaser aborts on a dirty work tree.
|
||||
dist-darwin/
|
||||
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
.hypothesis/
|
||||
htmlcov/
|
||||
.coverage
|
||||
|
||||
# Eval results (generated at runtime)
|
||||
eval/results/
|
||||
eval/scripts/
|
||||
eval/logs/
|
||||
|
||||
internal_docs/
|
||||
|
||||
# Ad-hoc bench/probe tooling — kept locally, not part of the repo.
|
||||
bench/all-tools-bench/
|
||||
bench/daemon-bench/
|
||||
bench/edge-diff/
|
||||
bench/multi-repo-bench/
|
||||
bench/node-diff/
|
||||
bench/store-bench/
|
||||
bench/unresolved-audit/
|
||||
bench/run-linux.sh
|
||||
bench/run-linux-rest.sh
|
||||
|
||||
# Local CPU-profiling harness (hardcoded local paths; run manually, not in CI)
|
||||
internal/indexer/bench_vscode_test.go
|
||||
@@ -0,0 +1,34 @@
|
||||
# golangci-lint configuration (schema v2).
|
||||
# CI pins golangci-lint to v2.11.4 (.github/workflows/ci.yml).
|
||||
version: "2"
|
||||
|
||||
linters:
|
||||
# Keep the same set CI already enforced before this file existed:
|
||||
# errcheck, govet, ineffassign, staticcheck, unused.
|
||||
default: standard
|
||||
|
||||
settings:
|
||||
errcheck:
|
||||
# Unchecked errors on the fmt print family are noise, not bugs: these
|
||||
# return (n int, err error) whose error is virtually never actionable.
|
||||
# Listed explicitly so the intent is obvious at the call site review;
|
||||
# the std-error-handling preset below also covers them via regex.
|
||||
exclude-functions:
|
||||
- fmt.Print
|
||||
- fmt.Printf
|
||||
- fmt.Println
|
||||
- fmt.Fprint
|
||||
- fmt.Fprintf
|
||||
- fmt.Fprintln
|
||||
- (*bufio.Writer).Flush
|
||||
- (io.Closer).Close
|
||||
|
||||
exclusions:
|
||||
# `lax` skips obviously generated files; we keep it explicit.
|
||||
generated: lax
|
||||
presets:
|
||||
# Drops the well-known stdlib error-handling false positives that
|
||||
# errcheck flags: the fmt print family, (*T).Close / .Flush,
|
||||
# os.Remove(All), os.Setenv / os.Unsetenv, and writes to
|
||||
# os.Stdout / os.Stderr.
|
||||
- std-error-handling
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
version: 2
|
||||
|
||||
# Run inside ghcr.io/goreleaser/goreleaser-cross — the Docker image ships
|
||||
# cross-compile toolchains so CGO (tree-sitter) links cleanly on a single
|
||||
# Linux runner. This config builds the LINUX targets + their deb/rpm/apk
|
||||
# packages, the checksums, and the GitHub release. darwin and windows are
|
||||
# built on NATIVE runners in release.yml (the build-darwin / release-windows
|
||||
# jobs) because their toolchains can't be cross-faked safely:
|
||||
# - darwin: the osxcross ld64 in this image omits the __DATA_CONST
|
||||
# SG_READ_ONLY flag that macOS 15+/Tahoe dyld enforces (issue #176), so
|
||||
# darwin must link with Apple's ld on a macOS runner. The homebrew cask
|
||||
# (which references all four os/arch tarballs) is assembled and pushed by
|
||||
# release.yml once the darwin hashes exist — it is NOT generated here.
|
||||
# - windows: the CGo tree-sitter bindings need a real mingw C/C++ toolchain.
|
||||
before:
|
||||
hooks:
|
||||
- go mod tidy
|
||||
# Tests run in CI on every push. Re-running ./... inside
|
||||
# goreleaser-cross slows the tag-to-artifact loop without catching
|
||||
# anything new — the tag is already on a green commit.
|
||||
|
||||
builds:
|
||||
- id: main
|
||||
main: ./cmd/gortex/
|
||||
binary: gortex
|
||||
ldflags:
|
||||
# main.version + main.commit get parsed at startup into a SemVer 2.0.0
|
||||
# Version (see internal/version). Commit lands in the +build slot so
|
||||
# `gortex version` output round-trips as canonical semver.
|
||||
- -s -w -X main.version={{.Version}} -X main.commit={{.ShortCommit}} -X main.date={{.Date}}
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
goos:
|
||||
- linux
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
# Per-target CC + CXX. goreleaser-cross exposes these cross-toolchains
|
||||
# on PATH; CGO needs both set per target triple because some deps
|
||||
# (tree-sitter yaml scanner, etc.) ship C++. Without CXX, the system
|
||||
# default x86_64-linux-gnu-g++ leaks in.
|
||||
overrides:
|
||||
- goos: linux
|
||||
goarch: amd64
|
||||
env:
|
||||
- CC=x86_64-linux-gnu-gcc
|
||||
- CXX=x86_64-linux-gnu-g++
|
||||
- goos: linux
|
||||
goarch: arm64
|
||||
env:
|
||||
- CC=aarch64-linux-gnu-gcc
|
||||
- CXX=aarch64-linux-gnu-g++
|
||||
|
||||
archives:
|
||||
- formats: [tar.gz]
|
||||
# Windows users expect a .zip, not a .tar.gz — and Scoop only
|
||||
# understands zip archives.
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
formats: [zip]
|
||||
# Version intentionally omitted — `/releases/latest/download/<name>` URLs
|
||||
# require an exact filename, and users linking to the latest release
|
||||
# shouldn't need to know the version. Version is still in the release
|
||||
# tag and in `gortex version`.
|
||||
name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}"
|
||||
|
||||
checksum:
|
||||
name_template: checksums.txt
|
||||
|
||||
changelog:
|
||||
sort: asc
|
||||
filters:
|
||||
exclude:
|
||||
- '^docs:'
|
||||
- '^test:'
|
||||
- '^ci:'
|
||||
|
||||
nfpms:
|
||||
- id: gortex
|
||||
# Match the archive naming — version omitted so `/releases/latest/download`
|
||||
# URLs work without templating. Package metadata still carries the version.
|
||||
file_name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}"
|
||||
ids:
|
||||
- main
|
||||
formats:
|
||||
- deb
|
||||
- rpm
|
||||
- apk
|
||||
vendor: "zzet"
|
||||
homepage: "https://github.com/zzet/gortex"
|
||||
maintainer: "zzet"
|
||||
description: "Code intelligence engine that indexes repositories into an in-memory knowledge graph."
|
||||
license: "Custom"
|
||||
|
||||
# NOTE: the homebrew cask is NOT generated here. goreleaser's `homebrew_casks`
|
||||
# can only reference archives goreleaser itself built, but darwin is now built
|
||||
# on a native macOS runner (issue #176) and the cask references all four
|
||||
# os/arch tarballs (incl. darwin). So the cask is assembled and pushed to
|
||||
# zzet/homebrew-tap by the "Publish homebrew cask" step in release.yml, once
|
||||
# every platform's tarball + sha256 exists. The cask is still install-time
|
||||
# completion-generated (`generate_completions_from_executable`, run on the
|
||||
# user's machine), so no binary needs to run at release time to build it.
|
||||
#
|
||||
# NOTE: goreleaser also does NOT generate the Scoop manifest — windows is built
|
||||
# by the separate `release-windows` job (native runner) and isn't an artifact
|
||||
# of this goreleaser-cross run. That job publishes the manifest to
|
||||
# gortexhq/scoop-bucket itself (see its "Publish Scoop manifest" step).
|
||||
@@ -0,0 +1,45 @@
|
||||
index:
|
||||
languages:
|
||||
- go
|
||||
- typescript
|
||||
- python
|
||||
- rust
|
||||
- java
|
||||
exclude:
|
||||
- "vendor/**"
|
||||
- "node_modules/**"
|
||||
- "**/*_test.go"
|
||||
- "dist/**"
|
||||
- "*.pb.go"
|
||||
- ".venv"
|
||||
- "venv"
|
||||
- "env"
|
||||
- ".pyenv"
|
||||
- ".git"
|
||||
- ".claude/worktrees/**"
|
||||
- "results/**"
|
||||
workers: 8
|
||||
|
||||
watch:
|
||||
enabled: false
|
||||
paths:
|
||||
- "."
|
||||
debounce_ms: 150
|
||||
exclude:
|
||||
- "**/*.tmp"
|
||||
- "**/*.swp"
|
||||
- "**/.git/**"
|
||||
- "**/node_modules/**"
|
||||
- "**/.next/**"
|
||||
- "**/.venv/**"
|
||||
- "venv"
|
||||
- "env"
|
||||
- ".pyenv"
|
||||
|
||||
query:
|
||||
default_depth: 3
|
||||
max_depth: 10
|
||||
|
||||
mcp:
|
||||
transport: stdio
|
||||
port: 8765
|
||||
@@ -0,0 +1,98 @@
|
||||
# AGENTS.md — Temporal dispatch allow-list (для корпоративного агента)
|
||||
|
||||
Этот файл — инструкция агенту, работающему в **корпоративном форке** gortex, по
|
||||
сопровождению распознавания Temporal-диспатча под ваш кодовой базы. Цель: повышать точность
|
||||
графа Temporal **без утечки корпоративных имён** в исходники / upstream.
|
||||
|
||||
> Не путать с `docs/agents.md` (это про agent-адаптеры самого gortex). Здесь — только про
|
||||
> Temporal allow-list и LLM-клининг.
|
||||
|
||||
---
|
||||
|
||||
## 1. Как устроено распознавание (три слоя)
|
||||
|
||||
Имя активности/воркфлоу в `workflow.ExecuteActivity(ctx, <name>, …)` часто приходит не литералом.
|
||||
Форк распознаёт его тремя слоями, по возрастанию доверия:
|
||||
|
||||
1. **Generic-эвристика (recall, скрыто).** Любой хелпер с `env` в имени —
|
||||
`cfg.ActivityFromEnv("KEY", "Default")` — распознаётся структурно: 2-й строковый аргумент
|
||||
берётся как дефолтное имя. Ребро садится на **speculative 0.4** (`temporal_env_source=heuristic`),
|
||||
скрыто из обычных запросов. Вести ничего не нужно — работает само.
|
||||
2. **Allow-list (precision, видимо).** Если имя хелпера в built-in списке (`GetEnvOrDefault`,
|
||||
`GetEnvOrDefaultValue`, `EnvOr`, `GetenvDefault`, `GetEnvDefault`) **или** в вашем
|
||||
репо-локальном allow-list — ребро повышается до **inferred 0.6**, видимо по умолчанию
|
||||
(`temporal_env_source=allowlist`). `os.Getenv(...)` / `cmp.Or(...)` тоже → 0.6.
|
||||
3. **LLM-клининг (опционально).** Проход `gortex analyze --kind temporal_verify` отдаёт каждое
|
||||
рёбро уровня ≤0.65 вашей LLM, заземляя в реальном коде: confirmed → повышает и делает видимым,
|
||||
rejected → гасит (скрывает), uncertain → оставляет как есть. Register-confirmed (0.9) не трогает.
|
||||
|
||||
**Главное:** слой 1 жадный и безвредный (скрыт), слой 2 точечно повышает, слой 3 чистит. Поэтому
|
||||
**не обязательно** перечислять все хелперы — список нужен лишь чтобы **сделать конкретный хелпер
|
||||
видимым по умолчанию**.
|
||||
|
||||
---
|
||||
|
||||
## 2. Как вести allow-list
|
||||
|
||||
Файл **git-ignored** (см. `.gitignore`: `.gortex/`), читается **только** под env-гейтом.
|
||||
|
||||
1. Включить гейт: `export GORTEX_ALLOW_LOCAL_TEMPORAL=1`.
|
||||
2. Создать `.gortex/temporal-allowlist.yaml` в корне репозитория:
|
||||
|
||||
```yaml
|
||||
# Имена ваших env-хелперов, по которым диспатч резолвится на дефолт.
|
||||
# Сопоставление по имени функции (без пакета), регистронезависимо.
|
||||
env_helpers:
|
||||
- GetActivityNameFromEnv
|
||||
- FetchActivityName
|
||||
- resolveActivity # локальные/lowercase тоже годятся
|
||||
```
|
||||
|
||||
3. Проверить эффект: `gortex analyze --kind temporal_orphans --path . --format json` (до/после) —
|
||||
часть `broken_dispatch` должна закрыться, env-сайты получить `temporal_env_source=allowlist`.
|
||||
|
||||
Что добавлять / чего нет:
|
||||
|
||||
- **Добавлять:** имена функций-обёрток «прочитать env и вернуть дефолт». Это generic-инфраструктура,
|
||||
не бизнес-данные.
|
||||
- **НЕ добавлять:** имена активностей/воркфлоу/бизнес-логику. Они тут не нужны (распознаются
|
||||
структурно), и им не место даже в git-ignored файле.
|
||||
|
||||
---
|
||||
|
||||
## 3. Протокол анонимизации (не спалить корпоративное)
|
||||
|
||||
- Файл `.gortex/temporal-allowlist.yaml` **git-ignored** — не коммить его и не добавлять в PR.
|
||||
- Имена env-хелперов сами по себе — generic infra (безопасны), но всё равно держим их **только** в
|
||||
локальном файле, а не в исходниках форка.
|
||||
- Если делаешь фикстуры для передачи OSS-стороне — следуй
|
||||
`docs/temporal-compare/temporal-gap-synthetic-fixtures.md` (протокол: «сохраняем форму, стираем
|
||||
содержание»): repo-пути → `example.com/app`, имена активностей → `ChargeActivity`, env-ключи →
|
||||
`FOO_ACTIVITY_ENV`, тела → выкинуть.
|
||||
|
||||
---
|
||||
|
||||
## 4. LLM-клининг (precision-проход вашей моделью)
|
||||
|
||||
Требует настроенного `llm.provider` (например ваш `custom-provider`) в `.gortex.yaml` /
|
||||
`~/.config/gortex/config.yaml`.
|
||||
|
||||
```bash
|
||||
gortex analyze --kind temporal_verify --path . --format json
|
||||
```
|
||||
|
||||
- Проверяет **только** низкодоверенные рёбра (speculative 0.4 + inferred 0.6) — набор маленький,
|
||||
стоимость ограничена. Register-confirmed 0.9 не трогает.
|
||||
- Вердикты кэшируются в git-ignored `.gortex/temporal-verify-cache.json` по хэшу
|
||||
(модель + имя + исходник вызова + исходник кандидата) → повторный прогон детерминирован и
|
||||
бесплатен (годится для CI). Меняется код или модель — кэш промахивается и перепроверяет.
|
||||
- Вывод: `checked / confirmed / rejected / uncertain / errors` (+ `details` в JSON с причинами).
|
||||
- На каждом ребре остаются `temporal_llm_verdict` и `temporal_llm_reason` для аудита.
|
||||
|
||||
---
|
||||
|
||||
## 5. Что делать с тем, что всё ещё не резолвится
|
||||
|
||||
Для dispatch-шейпов, остающихся `broken_dispatch` и не закрытых ничем выше, —
|
||||
`docs/temporal-compare/temporal-gap-synthetic-fixtures.md`: какие минимальные анонимизированные
|
||||
фикстуры отдать OSS-стороне, чтобы дорезолвить либу.
|
||||
@@ -0,0 +1,93 @@
|
||||
# Gortex on SWE-bench
|
||||
|
||||
This document is the public results template for SWE-bench runs
|
||||
against gortex's MCP-driven agent. The harness lives at
|
||||
`cmd/gortex/eval_swebench.go` and `eval/` (the Python side);
|
||||
running it end-to-end takes multi-day GPU compute on the full
|
||||
benchmark, so we ship the template + reproducibility instructions
|
||||
here and update the numbers section after each substantive run.
|
||||
|
||||
## Results
|
||||
|
||||
**Last run: TBD** — see the "How to reproduce" section to run it
|
||||
yourself; replace this section with your numbers afterward.
|
||||
|
||||
| model | benchmark variant | n_resolved | n_total | resolve_rate | avg tokens | avg runtime |
|
||||
|-------|-------------------|-----------:|--------:|-------------:|-----------:|------------:|
|
||||
| TBD | SWE-bench Lite | — | — | — | — | — |
|
||||
| TBD | SWE-bench Verified| — | — | — | — | — |
|
||||
| TBD | SWE-bench | — | — | — | — | — |
|
||||
|
||||
When a row populates: include the exact model name (e.g.
|
||||
`claude-sonnet-4-20250514`), the harness commit SHA, the run date,
|
||||
and the model card the per-task prompts use. Append a
|
||||
`results/swebench/<run-id>/` directory with per-task JSON + the
|
||||
overall summary so any reviewer can spot-check the count.
|
||||
|
||||
## Methodology
|
||||
|
||||
Gortex's SWE-bench harness is a thin agent that exposes the same
|
||||
MCP tool surface as a regular session (`smart_context`, `search_symbols`,
|
||||
`get_symbol_source`, `edit_file`, `verify_change`, …) and lets the
|
||||
configured LLM provider drive a turn loop. Per-task budget is the
|
||||
same token / wall-clock cap as the upstream SWE-bench harness so
|
||||
results are comparable to other published numbers.
|
||||
|
||||
The runner persists per-task outputs to
|
||||
`results/swebench/<run-id>/<task-id>/` so a failed task can be
|
||||
re-played without re-running the whole benchmark.
|
||||
|
||||
Honest caveats:
|
||||
|
||||
- **Compute envelope.** The full SWE-bench (~2300 tasks) takes
|
||||
multi-day GPU compute even at modest concurrency; SWE-bench
|
||||
Lite (300 tasks) is the practical target for iteration. Don't
|
||||
publish "full SWE-bench" numbers without showing the run-time
|
||||
cost too.
|
||||
- **Dataset license.** SWE-bench is community-maintained; check
|
||||
the upstream licence before redistributing the per-task
|
||||
artifacts.
|
||||
- **Per-model variance.** Run-to-run variance is non-trivial
|
||||
(~2-5 percentage points on resolve rate); a published number
|
||||
is one sample, not a confidence interval. Re-run before citing.
|
||||
|
||||
## How to reproduce
|
||||
|
||||
```sh
|
||||
# 1) Pre-flight: ensure the harness substrate is in place.
|
||||
ls eval/ # Python harness lives here
|
||||
ls cmd/gortex/eval_swebench.go # Go-side CLI entry
|
||||
|
||||
# 2) List available SWE-bench configurations (Lite / Verified /
|
||||
# full / custom subsets).
|
||||
gortex eval swebench --list-configs
|
||||
|
||||
# 3) Run a small smoke against SWE-bench Lite, default config.
|
||||
gortex eval swebench \
|
||||
--config swebench-lite \
|
||||
--model claude-sonnet-4-20250514 \
|
||||
--workdir results/swebench/$(date +%Y%m%d-%H%M%S)/ \
|
||||
--max-tasks 5
|
||||
|
||||
# 4) Full-config run (multi-day; only do this when you mean it).
|
||||
gortex eval swebench \
|
||||
--config swebench-lite \
|
||||
--model claude-sonnet-4-20250514 \
|
||||
--workdir results/swebench/$(date +%Y%m%d-%H%M%S)/
|
||||
|
||||
# 5) Aggregate the per-task JSON into a summary row.
|
||||
python3 eval/scripts/aggregate_swebench.py \
|
||||
--workdir results/swebench/<run-id>/ \
|
||||
--out results/swebench/<run-id>/summary.json
|
||||
|
||||
# 6) Paste the numbers into the table above; commit results/.
|
||||
```
|
||||
|
||||
See `eval/README.md` for the Python-side configuration options
|
||||
(per-task token budgets, retry policy, judge model, etc.).
|
||||
|
||||
## Cross-links
|
||||
|
||||
- Other reproducible benchmarks: [`BENCHMARK.md`](BENCHMARK.md)
|
||||
- Evaluation methodology: `docs/04-evaluation/` (when shipped)
|
||||
- Substrate: `cmd/gortex/eval_swebench.go` + `eval/`
|
||||
+263
@@ -0,0 +1,263 @@
|
||||
# Gortex benchmarks
|
||||
|
||||
This document aggregates the five reproducible benchmark surfaces
|
||||
gortex ships:
|
||||
|
||||
- **Reference-repo perf** — cold-index, search p95, impact p95/p99,
|
||||
incremental reindex, on-disk DB size, daemon resident memory
|
||||
across `gin` / `nestjs` / `react` (+ optional `linux`).
|
||||
- **Token efficiency** — 3-pipeline comparison (ripgrep+full-read,
|
||||
ripgrep+context, gortex `search_symbols` + `get_symbol_source`)
|
||||
plus recall@k by token budget against a hand-curated ground-truth
|
||||
set.
|
||||
- **GCX1 wire-format scorecard** — 20-fixture round-trip of GCX1 vs
|
||||
JSON, scored against both the `cl100k_base` tokenizer (Claude 3 /
|
||||
Opus 4 / Sonnet 4 / Haiku 4.5 / GPT-4o family) and the Claude
|
||||
Opus 4.7 tokenizer.
|
||||
- **Daemon-mode MCP-tool latency** — p50/p95/p99 of the core MCP
|
||||
tools through the production dispatch path.
|
||||
- **`search_symbols` retrieval recall** — R@1/5/20, MRR, and
|
||||
per-tier recall of the retrieval rankers over a curated query
|
||||
fixture.
|
||||
|
||||
Every section below carries: the headline number, the published
|
||||
table, a "How to reproduce" block, and a link to the canonical
|
||||
source artifacts. **Update protocol**: re-run the relevant
|
||||
`gortex bench` subcommand, paste the new table into this file, bump
|
||||
the "Last updated" stamp. The numbers below come from a single
|
||||
operator's machine; reproducing them on your hardware will yield
|
||||
different absolute timings but the same relative shape.
|
||||
|
||||
---
|
||||
|
||||
## 1. Reference-repo perf
|
||||
|
||||
**Last updated: 2026-05-20** · operator hardware: Apple M3 Max
|
||||
|
||||
| repo | LoC | files | nodes | edges | cold-index | search p95 | impact p95 | impact p99 | incremental | DB size | RSS | budget |
|
||||
|------|----:|------:|------:|------:|-----------:|-----------:|-----------:|-----------:|------------:|--------:|----:|:------:|
|
||||
| nestjs (in-tree fixture) | — | 32 | 240 | 414 | 17.8ms | 0.09ms | 0.01ms | 0.01ms | 11.8ms | 92.3KB | 2.4MB | ✓ |
|
||||
|
||||
_The full 3-repo run (gin + nestjs + react) requires network access
|
||||
to clone each repo on first invocation. The fixture row above
|
||||
exercises the same harness path against the in-tree nestjs fixture
|
||||
so the contract is verifiable offline. The sub-millisecond impact
|
||||
analysis claim holds — impact p95 of 0.01ms is 100× under the 1.0ms
|
||||
budget._
|
||||
|
||||
_The **RSS** column is the Go heap retained with the graph, indexer
|
||||
and query engine all live — the `runtime.MemStats` figure
|
||||
`gortex daemon status` reports as daemon memory, sampled after a
|
||||
forced GC so it reflects only the retained graph + search index.
|
||||
True OS resident set adds a fixed Go-runtime overhead (stacks,
|
||||
mcache, code) that does not scale with repo size._
|
||||
|
||||
### How to reproduce
|
||||
|
||||
```sh
|
||||
# Full 3-repo run (clones gin/nestjs/react to ~/.cache/gortex/bench/)
|
||||
gortex bench perf --out-dir bench/results
|
||||
|
||||
# Include the linux kernel preset (multi-GB; off by default)
|
||||
gortex bench perf --include-linux --out-dir bench/results
|
||||
|
||||
# CI gate: fail on any budget violation
|
||||
gortex bench perf --strict
|
||||
```
|
||||
|
||||
Substrate: `bench/perf/` ([README](bench/perf/README.md)). Raw
|
||||
metrics land at `bench/results/perf.{md,json,csv}` when
|
||||
`--out-dir` is set.
|
||||
|
||||
---
|
||||
|
||||
## 2. Token efficiency vs ripgrep+read
|
||||
|
||||
**Last updated: 2026-05-18** · corpus: the gortex repo
|
||||
|
||||
| query | tokens (rg+full) | tokens (rg+ctx) | tokens (gortex) | recall@2k rg+full / rg+ctx / gortex | recall@10k rg+full / rg+ctx / gortex |
|
||||
|-------|----------------:|----------------:|---------------:|------------------------------------|--------------------------------------|
|
||||
| AddObservation | 31,530 | 9,020 | 972 | 0.00 / 0.00 / **1.00** | 0.00 / 1.00 / **1.00** |
|
||||
| IsSymbolQuery | 23,027 | 7,388 | 577 | 0.00 / 0.00 / **1.00** | 0.00 / 1.00 / **1.00** |
|
||||
| FileCoherenceSignal | 14,268 | 6,290 | 151 | 0.00 / 0.00 / **1.00** | 1.00 / 1.00 / **1.00** |
|
||||
| alphaFuse | 14,574 | 5,930 | 534 | 0.00 / 0.00 / **1.00** | 1.00 / 1.00 / **1.00** |
|
||||
| savings dashboard rendering (NL) | 415 | 544 | 1,825 | 0.00 / 0.00 / **1.00** | 0.00 / 0.00 / **1.00** |
|
||||
| rerank pipeline default signals (NL) | 415 | 545 | 97 | 0.00 / 0.00 / **1.00** | 0.00 / 0.00 / **1.00** |
|
||||
| Indexer Index method (NL) | 415 | 544 | 28 | 0.00 / 0.00 / **1.00** | 0.00 / 0.00 / **1.00** |
|
||||
| MCP server start (NL) | 415 | 544 | 372 | 0.00 / 0.00 / **0.50** | 0.00 / 0.00 / **0.50** |
|
||||
|
||||
**Headline**: gortex achieves median **recall@2k = 1.00** vs **0.00**
|
||||
for ripgrep across the identifier-query set, at **3-50× fewer tokens
|
||||
per response**. On natural-language queries ("MCP server start") the
|
||||
ripgrep pipelines return no matches (they need verbatim string hits),
|
||||
inflating gortex's relative cost on the median; the per-row data is
|
||||
the honest picture.
|
||||
|
||||
### How to reproduce
|
||||
|
||||
```sh
|
||||
# Default: against the gortex repo itself
|
||||
gortex bench tokens-efficiency
|
||||
|
||||
# Against a different corpus
|
||||
gortex bench tokens-efficiency --repo /path/to/myrepo \
|
||||
--queries my-queries.json --groundtruth my-truth.json
|
||||
|
||||
# CI gate
|
||||
gortex bench tokens-efficiency --strict --budget-ratio 0.5
|
||||
```
|
||||
|
||||
Substrate: `bench/token-efficiency/`
|
||||
([README](bench/token-efficiency/README.md)). Extend the
|
||||
ground-truth set by adding rows to
|
||||
`bench/token-efficiency/groundtruth.json`.
|
||||
|
||||
---
|
||||
|
||||
## 3. GCX1 wire-format vs JSON
|
||||
|
||||
**Last updated: 2026-05-18**
|
||||
|
||||
### cl100k_base (Claude 3 / Opus 4 / Sonnet 4 / Haiku 4.5 / GPT-4o)
|
||||
|
||||
- **Median token savings: −27.4%**
|
||||
- **Median byte savings: −26.8%**
|
||||
- **Round-trip integrity: 20/20**
|
||||
|
||||
### Claude Opus 4.7 (estimated via ×1.35 scalar; opt-in `--use-api` for exact counts)
|
||||
|
||||
- **Median token savings: −27.3%**
|
||||
|
||||
The wire format's advantage compounds with the tokenizer change
|
||||
rather than being amplified by it. See the full per-fixture table at
|
||||
[`bench/wire-format/scorecard.md`](bench/wire-format/scorecard.md).
|
||||
|
||||
### How to reproduce
|
||||
|
||||
```sh
|
||||
# Both tokenizers, scalar Opus 4.7 estimate (offline)
|
||||
go run ./bench/wire-format
|
||||
|
||||
# Exact Opus 4.7 counts via Anthropic count_tokens (requires
|
||||
# ANTHROPIC_API_KEY; results cached so subsequent runs are
|
||||
# deterministic without re-hitting the API)
|
||||
go run ./bench/wire-format --use-api
|
||||
```
|
||||
|
||||
Substrate: `bench/wire-format/`
|
||||
([README](bench/wire-format/README.md)). See
|
||||
[`docs/wire-format.md`](docs/wire-format.md) for the format spec.
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 4. Daemon-mode MCP-tool latency
|
||||
|
||||
**Last updated: 2026-05-19** · corpus: the gortex repo (71,300 nodes) · operator hardware: Apple M3 Max
|
||||
|
||||
| tool | iters | p50 | p95 | p99 | mean | max |
|
||||
|------|------:|----:|----:|----:|-----:|----:|
|
||||
| graph_stats | 50 | 4.2ms | 5.5ms | 5.9ms | 4.4ms | 5.9ms |
|
||||
| search_symbols | 50 | 1.2ms | 22.4ms | 26.9ms | 5.6ms | 26.9ms |
|
||||
| get_symbol_source | 50 | 0.19ms | 0.90ms | 1.3ms | 0.27ms | 1.3ms |
|
||||
| get_callers | 50 | 0.01ms | 0.02ms | 0.03ms | 0.01ms | 0.03ms |
|
||||
| find_usages | 50 | 0.01ms | 0.01ms | 0.01ms | 0.01ms | 0.01ms |
|
||||
| get_file_summary | 50 | 0.03ms | 0.04ms | 0.05ms | 0.03ms | 0.05ms |
|
||||
| smart_context | 10 | 1.5ms | 24.2ms | 24.2ms | 6.0ms | 24.2ms |
|
||||
| get_repo_outline | 50 | 60.6ms | 217.0ms | 377.0ms | 79.3ms | 377.0ms |
|
||||
|
||||
**Headline**: median p95 across tools is **5.5 ms**, median p99 is
|
||||
**5.9 ms**. The heavy outliers (`smart_context`, `get_repo_outline`)
|
||||
sit at hundreds of ms; everything else is single-digit ms or
|
||||
sub-ms. Numbers measure `Handler.CallToolStrict` end-to-end through
|
||||
the production MCP dispatch path; daemon socket framing adds
|
||||
typically <1 ms on a warm pipe.
|
||||
|
||||
### How to reproduce
|
||||
|
||||
```sh
|
||||
# Quick smoke against the local repo
|
||||
gortex bench daemon-latency
|
||||
|
||||
# Tighter percentiles (more iterations)
|
||||
gortex bench daemon-latency --iter 500
|
||||
|
||||
# Subset of tools (focus tuning)
|
||||
gortex bench daemon-latency --tools graph_stats,search_symbols
|
||||
```
|
||||
|
||||
Substrate: `bench/daemon-latency/` ([README](bench/daemon-latency/README.md)).
|
||||
Raw metrics land at `bench/results/daemon-latency.{md,json,csv}`
|
||||
when `--out-dir` is set.
|
||||
|
||||
---
|
||||
|
||||
## 5. search_symbols retrieval recall
|
||||
|
||||
**Last updated: 2026-05-20** · fixture: `bench/fixtures/retrieval.yaml`
|
||||
(`gortex-seed-v2`, 156 cases) · operator hardware: Apple M3 Max
|
||||
|
||||
Recall@K of the retrieval rankers over a hand-curated query fixture,
|
||||
tiered exact / concept / multi_hop. Recall is any-hit set-level
|
||||
recall against strict gold labels — a paraphrased-but-correct hit
|
||||
that misses the gold ID scores as a miss, so these are lower bounds
|
||||
versus an LLM-judged setup.
|
||||
|
||||
| ranker | R@1 | R@5 | R@20 | MRR | p95 latency |
|
||||
|---------|------:|------:|------:|------:|------------:|
|
||||
| bm25 | 42.3% | 55.1% | 63.5% | 0.479 | 21.3ms |
|
||||
| winnow | 37.8% | 50.0% | 64.1% | 0.439 | 22.9ms |
|
||||
| ripgrep | 0.0% | 17.3% | 29.5% | 0.061 | 162.2ms |
|
||||
|
||||
Per-tier R@5 (bm25): exact **96.8%** · concept 25.4% · multi_hop 30.0%.
|
||||
|
||||
**Headline**: the `search_symbols` text path (`bm25`) lands
|
||||
**R@5 = 55.1%** / **R@20 = 63.5%**, and **96.8%** on exact
|
||||
symbol-name queries — 3.2× ripgrep's R@5 floor. Enabling Porter
|
||||
stemming (`GORTEX_FTS_STEMMING=1`) trades a little exact-tier
|
||||
precision for breadth — R@20 +5.7pp, exact-tier R@5 −3.1pp — so it
|
||||
ships opt-in. The `semantic` and `rrf` rankers require `--embeddings`
|
||||
and are omitted here; the `graph` ranker scores only graph-traversal
|
||||
fixtures.
|
||||
|
||||
### How to reproduce
|
||||
|
||||
```sh
|
||||
# Against the gortex repo itself
|
||||
gortex eval recall --fixture bench/fixtures/retrieval.yaml --format markdown
|
||||
|
||||
# Add the semantic + RRF rankers (local GloVe embedder)
|
||||
gortex eval recall --embeddings
|
||||
|
||||
# Standardized benches (CoIR / SWE-ContextBench / ContextBench)
|
||||
gortex eval stdbench --bench coir --dataset <path>
|
||||
```
|
||||
|
||||
Substrate: `internal/eval/recall/` + `cmd/gortex/eval_recall.go`. The
|
||||
standardized-benchmark loaders live in `internal/eval/stdbench/`.
|
||||
|
||||
---
|
||||
|
||||
## Methodology notes
|
||||
|
||||
- **Hardware sensitivity.** Absolute timings vary 2-5× across
|
||||
machine classes; the budget gates (sub-ms impact, <50% gortex
|
||||
vs ripgrep tokens) are tuned to hold across the range a developer
|
||||
laptop or modest CI runner would produce.
|
||||
- **Network sensitivity.** Reference-repo perf clones gin / nestjs
|
||||
/ react on first invocation (cached afterward). Linux is off by
|
||||
default because the clone alone is multi-GB.
|
||||
- **External dependencies.** Token-efficiency requires `rg`
|
||||
(ripgrep) on PATH for the two baseline pipelines; pass
|
||||
`--skip-ripgrep` to render a gortex-only column when rg is
|
||||
unavailable. Wire-format `--use-api` requires
|
||||
`ANTHROPIC_API_KEY`.
|
||||
- **Ground-truth scope.** Token-efficiency ground truth is curated
|
||||
against the gortex repo. Extending the bench to a new corpus
|
||||
means adding a per-query expected-file map; the harness flags
|
||||
any query with no truth entry as recall=0 by definition (so
|
||||
silently-missing curation surfaces).
|
||||
|
||||
For benchmark-driven CI, see the harness flags above; each
|
||||
subcommand supports `--strict` so a budget violation exits non-zero.
|
||||
@@ -0,0 +1,133 @@
|
||||
# Gortex
|
||||
|
||||
Code intelligence engine written in Go. Indexes repositories into an in-memory knowledge graph and exposes it via CLI and MCP Server.
|
||||
|
||||
## Build & Test
|
||||
|
||||
```bash
|
||||
go build -o gortex ./cmd/gortex/ # requires CGO (tree-sitter C bindings)
|
||||
go test -race ./... # all test packages must pass
|
||||
```
|
||||
|
||||
## Codebase Overview
|
||||
|
||||
- **Languages:** go (primary)
|
||||
- **Entry point:** `cmd/gortex/main.go`
|
||||
- **Source:** 1,338 Go files (728 non-test) across the `cmd/` and `internal/` trees
|
||||
- **Graph size:** ~31k nodes, ~206k edges when the daemon indexes this repo
|
||||
|
||||
## MANDATORY: Use Gortex's graph tools instead of Read/Grep/Glob
|
||||
|
||||
Gortex's workhorse tools are reachable two equivalent ways — over the registered **MCP server**, or via the equivalent **`gortex` CLI verbs** (`gortex edit verify`, `gortex memory surface`, `gortex analyze`, and `gortex call <tool>` for anything else; the verb reference is in `docs/cli.md`). Use whichever your harness has mounted — they are two front doors over the same handlers, and the daemon routes tool calls by name so the CLI reaches the full surface even under the `core` preset.
|
||||
|
||||
Either way, you **MUST** prefer graph queries over file reads on every task in this repo — `search_symbols`, `find_usages`, `get_symbol_source`, `get_editing_context`, `smart_context`, `edit_symbol` / `edit_file` / `rename_symbol` / `batch_edit` (or the matching `gortex` verbs). PreToolUse hooks deny `Read` / `Grep` / `Glob` against indexed source; the deny message names the right tool. The MCP server registers 180+ tools but by default publishes only a curated `core` preset (~34 dev-cycle workhorses) eagerly in `tools/list`, deferring the rest behind the `tools_search` discovery tool (the `core`/`defer` default — see `docs/mcp.md`). Opt into the full eager surface with `GORTEX_TOOLS=full`; `GORTEX_LAZY_TOOLS=1` is the older all-or-nothing defer switch. The cross-project rule tables live in `~/.claude/CLAUDE.md` — neither is restated here. This file carries only project-specific guidance.
|
||||
|
||||
### Discovery (read once, then keep using)
|
||||
|
||||
- **Graph schema** — `gortex://schema` resource (node kinds, edge kinds, what each carries).
|
||||
- **Analyzer rollups** — `gortex://report`, `gortex://surprises`, `gortex://god-nodes`, `gortex://questions`, `gortex://audit`.
|
||||
- **Bootstrap state** — `gortex://stats`, `gortex://index-health`, `gortex://workspace`, `gortex://repos`, `gortex://active-project`.
|
||||
|
||||
### LLM provider (powers `ask` and `search_symbols assist:` modes)
|
||||
|
||||
Selected via `llm.provider` in `.gortex.yaml` or `~/.config/gortex/config.yaml`. The HTTP and subprocess providers are pure Go — available without `-tags llama`.
|
||||
|
||||
| Provider | Backend | Requires |
|
||||
|---|---|---|
|
||||
| `local` (default) | in-process llama.cpp | `-tags llama` build + `llm.local.model` (a `.gguf` path) |
|
||||
| `anthropic` | Messages API | `llm.anthropic.model` + `ANTHROPIC_API_KEY` |
|
||||
| `openai` | Chat Completions | `llm.openai.model` + `OPENAI_API_KEY`. Optional `llm.openai.effort` → `reasoning_effort`. |
|
||||
| `azure` | Azure OpenAI Service | `llm.azure.deployment` + endpoint (`llm.azure.endpoint` or `AZURE_OPENAI_ENDPOINT`) + `AZURE_OPENAI_API_KEY`. Deployment-in-path + `api-version` query + `api-key` header; `llm.azure.api_version` defaults to a recent GA. Same json_schema structured output as `openai`. |
|
||||
| `ollama` | Ollama daemon | `llm.ollama.model` (+ `llm.ollama.host`, default `localhost:11434`) |
|
||||
| `claudecli` | `claude` CLI subprocess | `claude` on `$PATH` (signed in once); optional `llm.claudecli.model` (`sonnet`/`opus`/…). Reuses the user's Claude Code subscription. |
|
||||
| `codex` | OpenAI `codex` CLI subprocess | `codex` on `$PATH` (signed in once); optional `llm.codex.model`. Runs `codex exec` in a read-only sandbox; reuses the user's Codex / ChatGPT sign-in. |
|
||||
| `copilot` | GitHub Copilot CLI subprocess | `copilot` on `$PATH` (signed in via `gh`); optional `llm.copilot.model`. Runs `copilot -p`. |
|
||||
| `cursor` | Cursor Agent CLI subprocess | `cursor-agent` on `$PATH` (signed in once); optional `llm.cursor.model`. Runs `cursor-agent --output-format text -p`. |
|
||||
| `opencode` | opencode CLI subprocess | `opencode` on `$PATH` (signed in once); optional `llm.opencode.model` (`provider/model`). Runs `opencode run`. |
|
||||
| `gemini` | Google Gemini `generateContent` REST | `llm.gemini.model` (default `gemini-2.5-pro`) + `GEMINI_API_KEY`. Structured output via `responseSchema` (`additionalProperties` stripped — Gemini rejects it). |
|
||||
| `bedrock` | AWS Bedrock Converse API (SigV4) | `llm.bedrock.model_id` (e.g. `anthropic.claude-sonnet-4-20250514-v1:0`) + `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY` (+ optional `AWS_SESSION_TOKEN` for STS). Region defaults to `us-east-1` (`llm.bedrock.region`). Structured output via forced `respond` tool. No AWS SDK dependency — SigV4 is implemented in ~100 LOC of stdlib. |
|
||||
| `deepseek` | DeepSeek Chat Completions (OpenAI-compatible) | `llm.deepseek.model` (default `deepseek-chat`) + `DEEPSEEK_API_KEY`. Structured output uses `response_format: json_object` plus a schema hint in the system prompt — DeepSeek does not support strict `json_schema`. |
|
||||
|
||||
`GORTEX_LLM_PROVIDER` / `GORTEX_LLM_MODEL` / `GORTEX_LLM_{CLAUDECLI,CODEX,COPILOT,CURSOR,OPENCODE}_BINARY` / `GORTEX_LLM_BEDROCK_REGION` / `GORTEX_LLM_AZURE_{ENDPOINT,DEPLOYMENT,API_VERSION}` / `GORTEX_LLM_EFFORT` override the file config. `GORTEX_LLM_MODEL` targets the active provider's model field (Gemini → `llm.gemini.model`, Bedrock → `llm.bedrock.model_id`, DeepSeek → `llm.deepseek.model`, etc.). If the active provider can't construct (missing model / API key, `local` without `-tags llama`, `claudecli` / `codex` without `claude` / `codex` on `$PATH`, `bedrock` without AWS credentials), the daemon logs a warning and `ask` stays unregistered — fall through to direct tools.
|
||||
|
||||
**Custom providers.** Any OpenAI-compatible endpoint can be registered by name with `gortex provider add/list/show/remove` (writes `providers.json` next to the config; a repo-local `.gortex/providers.json` loads only under `GORTEX_ALLOW_LOCAL_PROVIDERS=1`). A registered name is selectable like any built-in via `llm.provider` / `GORTEX_LLM_PROVIDER`; entries carry `base_url` + `model` + optional `api_key_env`, `schema_mode` (`json_schema`/`json_object`/`prompt`), headers, and informational pricing.
|
||||
|
||||
**Anthropic tuning.** `llm.anthropic.model` accepts the tier sentinels `claude-haiku` / `claude-sonnet` / `claude-opus`, resolved to the newest live model id (per-auth cache + pinned fallback; override per tier via `GORTEX_LLM_ANTHROPIC_{HAIKU,SONNET,OPUS}_MODEL`). Opt-in `llm.anthropic.prompt_caching` (+ `cache_ttl`) marks the system prompt and structured-output tool as ephemeral cache breakpoints; `llm.anthropic.thinking_mode` (`off`/`auto`/`manual`/`adaptive`) drives extended thinking on freeform requests; `llm.anthropic.effort` / `GORTEX_LLM_EFFORT` sends a model-gated reasoning effort.
|
||||
|
||||
`llm.routing` (off by default) routes the `ask` agent to a cheaper or more capable model by graph-derived task complexity — set `routing.enabled`, `routing.simple_model`, `routing.complex_model`; the chosen `model` / `complexity` ride on the `ask` response.
|
||||
|
||||
`search_symbols assist:` modes: `auto` (default — skips LLM for identifier queries, expands NL queries), `on` (forces expansion+rerank), `off` (pure BM25), `deep` (adds a body-grounded verification pass; +1.5–4 s; quality is highly model-dependent — unreliable on 3B local models, fine on 7B+ or hosted).
|
||||
|
||||
### Non-obvious capabilities worth knowing
|
||||
|
||||
- **`compress_bodies: true`** on `read_file` / `get_symbol_source` / `get_editing_context` elides function bodies to stubs while keeping signatures + doc-comments + structure. ~30–40% of original tokens. 14 languages.
|
||||
- **Overlay sessions** (`overlay_push`, `overlay_list`, `overlay_drop`, `compare_with_overlay`) let editor extensions push unsaved buffers as a per-session shadow graph — every subsequent tool call reads through it without mutating base. Bound to the MCP session lifecycle; idle TTL via `GORTEX_OVERLAY_IDLE_TTL` (default 30m).
|
||||
- **Speculative execution** (`preview_edit`, `simulate_chain`) takes an LSP `WorkspaceEdit` and returns the graph diff + broken callers/implementors + impact rollup + suggested tests + (optional) LSP diagnostics — disk untouched. `simulate_chain` with `keep: true` promotes the final state into a real overlay.
|
||||
- **Change-contract pipeline** (`change_contract`, `symbols_for_ranges`) — one envelope every change source lowers into. `change_contract` takes a WorkspaceEdit, a git diff range (`source:diff base:…`), an explicit symbol set, or file line-ranges, runs LOWER → PREDICT → EVALUATE (guards + architecture + event-boundary rule families) → SCORE → CLASSIFY → EMIT, and returns one verdict `{allow|warn|refuse}` with reasons, risk, a `verification_command`, a checkable `stop_condition`, and an `edit_strategy`. `lens:api` focuses it on public-surface / API drift; `risk_gate:true` requires a TTL'd impact-review ack (`ack:true`, stored as a development memory) for load-bearing symbols. `symbols_for_ranges` is the standalone lowering primitive. The pre-write **parse gate** on `edit_file` / `write_file` refuses an edit that would introduce new tree-sitter parse errors (override with `allow_parse_errors`); `safe_delete_symbol propagate:true` patches surviving call sites; `analyze kind=suggest_boundaries` seeds an `architecture:` block from detected communities.
|
||||
- **MCP 2026 Streamable HTTP** at `POST /mcp` — `gortex server` always mounts it; `gortex daemon --http-addr <addr>` opts the daemon in (non-localhost binds require `--http-auth-token`).
|
||||
- **Session memory** (`save_note`, `query_notes`, `distill_session`) persists agent-authored notes per repo, auto-linked to symbols mentioned in the body. Notes survive daemon restarts and context compactions, scoped to the session's workspace.
|
||||
- **Development memories** (`store_memory`, `query_memories`, `surface_memories`) — cross-session, symbol-linked durable knowledge that compounds the longer a team uses Gortex. Memories carry `kind` (invariant / constraint / convention / gotcha / decision / incident / reference), `importance` (1..5), `confidence` (0..1), and are surfaced *proactively* by `surface_memories` when their anchor symbols / files enter the agent's working set.
|
||||
- **Artifacts** — non-code knowledge files (DB schemas, API specs, ADRs, infra configs) declared in `.gortex.yaml` `artifacts:` are indexed as `artifact` nodes; `search_artifacts` / `get_artifact` surface them and `EdgeReferences` links code to the spec it implements.
|
||||
- **Code search beyond symbols** — `search_text` is a trigram-indexed literal / regex search (the grep replacement for non-symbol strings); `search_ast` runs structural tree-sitter queries; `analyze kind=sast` is a 190-rule, CWE/OWASP-tagged security scan across 8 languages.
|
||||
- **Push notifications** — beyond `notifications/diagnostics`, the server pushes `graph_invalidated` (graph hot-reload), `daemon_health`, `stale_refs`, and `workspace_readiness`. `subscribe_*` once per session instead of polling.
|
||||
- **`get_architecture`** — one-call architectural snapshot (languages, communities, hotspots, processes); pass `resolution` for a hierarchical symbol → file → package → service → system rollup.
|
||||
- **Capability edges** — `reads_env` / `executes_process` / `accesses_field` are first-class traversable edges (in the `walk_graph`/`nav`/`graph_query` surface) synthesised post-resolution, so a supply-chain / least-privilege audit can ask "what reads $AWS_SECRET", "what shells out", "what writes this field" in one hop.
|
||||
- **PR review, end-to-end** — `gortex prs` triages open pull requests from the graph (`gortex prs <N>` for one PR; `--triage` / `--conflicts` / `--worktrees` / `--base` / `--format`; `gortex prs bundle`), and `gortex review [<base>|--diff] [--audience agent|human] [--post]` reviews a diff. The MCP surface mirrors it: `pr_risk` / `list_prs` / `get_pr_impact` / `triage_prs` / `conflicts_prs` / `suggest_reviewers` score and rank PRs against the graph, while `review` / `review_pack` / `post_review` / `pr_review_context` / `suggested_review_questions` / `critique_review` / `suppress_finding` drive the review itself.
|
||||
- **Multimodal + broad ingest** — image files (`KindImage` assets with format/dimensions/sha256) and PDF documents (per-page searchable `KindDoc` nodes) are graph nodes; new first-class extractors cover Terraform/HCL cross-block references, Helm charts/templates, Ansible playbooks, .NET `.sln`/`.csproj`, MCP server configs, Quarto `.qmd`, Luau, COBOL paragraphs + JCL, and C/C++ `#define` macros. Grammar-less languages can register a regex fallback chunker (`index.fallback_chunkers`) or an external extractor plugin (`index.extractor_plugins`) from config — no fork. `gortex db schema --postgres <dsn>` ingests a live database's schema.
|
||||
- **`analyze` is a 61-kind dispatcher** — beyond the structural kinds, it now covers `impact` (composite change-risk score), `bottlenecks` (interprocedural computation-bottleneck risk — cognitive complexity, loop depth, transitive/hidden-O(n^k) loop nesting across calls, unguarded recursion), `health_score` (per-symbol A–F grade), `sast` / `named` / `unsafe_patterns` (security), `clusters`, `suggest_boundaries` (Leiden-community-seeded architecture-layer suggestions), `connectivity_health`, `tests_as_edges`, `synthesizers` (framework-dispatch-synthesized edges, grouped by pass + provenance), `resolution_outcomes` (structured why-unresolved taxonomy), `review` (an idiomatic/correctness rulepack — NPE, thread-safety check-then-act, N+1, logic errors across Go + Python — with a graph-grounded false-positive-reduction pass), and more.
|
||||
|
||||
## MANDATORY: Session memory — save, recall, distill
|
||||
|
||||
The `save_note` / `query_notes` / `distill_session` triplet is the agent's durable scratchpad. The graph remembers code; these tools remember **why you made a call**. Without them, every compaction erases hard-won context.
|
||||
|
||||
Three triggers — not suggestions:
|
||||
|
||||
1. **After a context compaction (or at session start in a touched repo)** — **call** `distill_session` first thing. Returns top symbols, pinned notes, decisions, and recent excerpts from prior sessions in this workspace. Use the digest to seed your mental model before reading any file.
|
||||
2. **At every decision point** — **call** `save_note tags:"decision" body:"<what+why>"` when you pick an approach, reject an alternative, discover a non-obvious constraint, or commit to an invariant. Mention the affected symbol/file by ID (`pkg/foo.go::Bar`) so the auto-linker attaches the note to the graph. Pin (`pinned:true`) anything that should survive the store cap.
|
||||
3. **Before editing a symbol you've touched before** — **call** `query_notes symbol_id:"<id>"`. Prior decisions, bug-fix notes, or "do not change this without …" warnings ride on the symbol's note list and you should see them before re-deriving (or worse, reverting) past work.
|
||||
|
||||
What to save vs. skip:
|
||||
- **Save:** decisions ("chose X over Y because Z"), non-obvious constraints, follow-ups ("revisit when …"), bug reproductions, surprising graph findings, partial-progress hand-offs.
|
||||
- **Skip:** play-by-play of what you just did (the diff says it), code patterns derivable from the graph, anything already in CLAUDE.md.
|
||||
|
||||
Useful tags: `decision`, `bug`, `follow-up`, `gotcha`, `invariant`. `decision`-tagged notes are surfaced in their own section by `distill_session`.
|
||||
|
||||
## MANDATORY: Development memories — store, query, surface
|
||||
|
||||
`save_note` is a **per-session scratchpad**; `store_memory` is the **workspace-wide durable knowledge base**. The two are complementary, not redundant:
|
||||
|
||||
| | `save_note` (session) | `store_memory` (cross-session) |
|
||||
|---|---|---|
|
||||
| Scope | session_id | workspace-wide |
|
||||
| Lifetime | survives compaction | survives daemon restarts, agent changes, team rotation |
|
||||
| Audience | future-you in this session | every future agent in this workspace |
|
||||
| Surfacing | `distill_session` (manual) | `surface_memories` (proactive, ranked) |
|
||||
| Right when | "remember this for the next 30 min" | "every agent touching `Bar` should know this" |
|
||||
|
||||
Three triggers — not suggestions:
|
||||
|
||||
1. **At task start, after `smart_context`** — **call** `surface_memories task:"<task>" symbol_ids:"<top hits from smart_context>"`. Returns memories ranked by anchor symbol overlap, file overlap, task-keyword hits, importance, pinning, recency, and confidence. Memories prefixed with `match_reasons:["symbol:pkg/foo.go::Bar"]` are direct evidence the memory applies to your working set. If `surface_memories` returns nothing, don't probe further.
|
||||
2. **When you discover a durable fact worth teaching the team** — **call** `store_memory kind:"<invariant|gotcha|convention|decision|constraint|incident>" body:"<what+why>" symbol_ids:"pkg/foo.go::Bar" importance:5`. Pin (`pinned:true`) anything load-bearing. Set `kind` honestly: `invariant` means "violating this breaks the system", `gotcha` means "an agent will get this wrong without warning". Title (`title:"..."`) the memory if the body is long — it becomes the headline.
|
||||
3. **When a memory is no longer true** — **call** `store_memory id:"<new>" supersedes:"<old-id>" body:"<corrected fact>"`. The old memory stays in the store (for audit) but is hidden from `surface_memories` by default. Don't delete unless the original was wrong; supersession preserves history.
|
||||
|
||||
What to store vs. skip:
|
||||
- **Store:** invariants ("Bar must hold the lock"), conventions ("this package never uses gob"), incident learnings ("once, doing X under Y crashed prod"), API contracts not enforced by types, debugging traps, cross-cutting decisions with non-obvious rationale.
|
||||
- **Skip:** anything derivable from the code (the graph already knows), session-local play-by-play (use `save_note`), CLAUDE.md content (it's already loaded), one-off observations with no actionable consequence.
|
||||
|
||||
Useful kinds and tags: `invariant`, `constraint`, `convention`, `gotcha`, `decision`, `incident`, `reference`. Tag liberally — `query_memories tag:"<x>"` is the primary lookup path when you don't know the anchor symbol.
|
||||
|
||||
## Required workflow (every task on this repo)
|
||||
|
||||
These are not suggestions — run each step at the trigger.
|
||||
|
||||
1. Confirm the daemon is up with `index_health` (cheap liveness + scope). Call `graph_stats` only when you actually need node/edge counts or `per_repo` orientation — it returns a large payload and can block during warmup.
|
||||
2. If `total_nodes` is 0, **call** `index_repository` with `"."` before anything else.
|
||||
3. In multi-repo mode, **call** `get_active_project` to see scope; use `set_active_project` to switch.
|
||||
4. Open a non-trivial task with `smart_context` for orientation. For a single known symbol or file, go straight to `search_symbols` / `get_symbol_source` — don't front-load `smart_context` before every read.
|
||||
5. Immediately after `smart_context`, **call** `surface_memories task:"<task>" symbol_ids:"<top hits>"` to pick up any cross-session invariants / gotchas / decisions anchored to your working set. Skipping this re-derives knowledge other agents have already recorded.
|
||||
6. Before editing a file, **call** `get_editing_context` on it first.
|
||||
7. Before changing any function signature, **call** `verify_change` to catch broken callers and interface implementors (cross-repo).
|
||||
8. For any refactor, **call** `get_edit_plan` for the dependency-ordered file list, then **`batch_edit`** to apply atomically.
|
||||
9. Verify with the project's real build/test (`go build` / `go test`). Reserve `check_guards` for guard-relevant changes and `get_test_targets` (includes cross-repo tests) to find the tests covering a substantive change — not mechanically after every edit.
|
||||
10. Before committing, **call** `detect_changes` for scope and `diff_context` for graph-enriched review.
|
||||
11. When the task is done, if you used `smart_context`, optionally **call** `feedback action: "record"` to score which suggestions were useful / not needed / missing — it improves future context quality. If the task surfaced a durable invariant / decision / gotcha worth teaching the team, **call** `store_memory` so the next agent inherits the lesson.
|
||||
@@ -0,0 +1,39 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment:
|
||||
|
||||
* Using welcoming and inclusive language
|
||||
* Being respectful of differing viewpoints and experiences
|
||||
* Gracefully accepting constructive criticism
|
||||
* Focusing on what is best for the community
|
||||
* Showing empathy towards other community members
|
||||
|
||||
Examples of unacceptable behavior:
|
||||
|
||||
* The use of sexualized language or imagery and unwelcome sexual attention
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information without explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the project maintainers. All complaints will be reviewed and
|
||||
investigated promptly and fairly.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org),
|
||||
version 2.0.
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
# Contributing to Gortex
|
||||
|
||||
Thank you for considering contributing to Gortex! This guide will help you get started.
|
||||
|
||||
## Licensing of contributions
|
||||
|
||||
Gortex is released under the [Apache License, Version 2.0](LICENSE.md).
|
||||
By submitting a contribution (a pull request, patch, or other work) you
|
||||
agree that it is licensed to the project under the same Apache 2.0 terms,
|
||||
as described in section 5 of the License. You retain copyright in your
|
||||
contribution; the project retains a perpetual, worldwide, royalty-free
|
||||
license to use, modify, and redistribute it as part of Gortex.
|
||||
|
||||
Contributors are listed in [CONTRIBUTORS.md](CONTRIBUTORS.md). Add yourself
|
||||
to that file in the same PR if you'd like to be credited.
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Go 1.21+
|
||||
- CGO enabled (required for tree-sitter C bindings)
|
||||
- Git
|
||||
|
||||
### Building
|
||||
|
||||
```bash
|
||||
git clone https://github.com/zzet/gortex.git
|
||||
cd gortex
|
||||
go build -o gortex ./cmd/gortex/
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
go test -race ./...
|
||||
```
|
||||
|
||||
### Running Benchmarks
|
||||
|
||||
```bash
|
||||
go test -bench=. -benchmem ./internal/parser/languages/
|
||||
go test -bench=. -benchmem ./internal/query/
|
||||
go test -bench=. -benchmem ./internal/indexer/
|
||||
```
|
||||
|
||||
## How to Contribute
|
||||
|
||||
### Reporting Bugs
|
||||
|
||||
- Open an issue with a clear description
|
||||
- Include the output of `gortex version` and `go version`
|
||||
- Provide a minimal reproduction if possible
|
||||
|
||||
### Suggesting Features
|
||||
|
||||
- Open an issue describing the feature and its use case
|
||||
- For language support requests, mention if the tree-sitter grammar is available in `github.com/smacker/go-tree-sitter`
|
||||
|
||||
### Submitting Code
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch (`git checkout -b feature/my-feature`)
|
||||
3. Write tests for your changes
|
||||
4. Ensure all tests pass (`go test -race ./...`)
|
||||
5. Commit with a clear message
|
||||
6. Open a pull request
|
||||
|
||||
### Adding a New Language Extractor
|
||||
|
||||
This is one of the most impactful contributions. Follow these steps:
|
||||
|
||||
1. Check if the tree-sitter grammar exists in `github.com/smacker/go-tree-sitter`
|
||||
2. Create `internal/parser/languages/<language>.go` implementing the `parser.Extractor` interface
|
||||
3. Create `internal/parser/languages/<language>_test.go` with at least 3 tests
|
||||
4. Register it in `internal/parser/languages/register.go`
|
||||
5. Debug the AST first — tree-sitter node types vary between grammars
|
||||
|
||||
**What to extract (in priority order):**
|
||||
- Functions/methods with `EdgeMemberOf` to their class/type
|
||||
- Classes/types/interfaces
|
||||
- Interface method specs in `Meta["methods"]` (enables IMPLEMENTS inference)
|
||||
- Imports
|
||||
- Call sites
|
||||
- Variables/constants
|
||||
|
||||
**Reference implementations:**
|
||||
- `golang.go` — the most complete extractor
|
||||
- `python.go` — simple OOP language
|
||||
- `rust.go` — systems language with impl blocks
|
||||
- `yaml.go` — simple config extractor
|
||||
|
||||
### Code Style
|
||||
|
||||
- Follow standard Go conventions (`gofmt`, `go vet`)
|
||||
- No unnecessary abstractions — three similar lines is better than a premature helper
|
||||
- Tests should be self-contained with inline source snippets
|
||||
- Extractor test helpers (`nodesOfKind`, `edgesOfKind`) are shared across test files
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
cmd/gortex/ CLI entry point and commands
|
||||
internal/
|
||||
analysis/ Community detection, process discovery, impact analysis
|
||||
claudemd/ CLAUDE.md generator
|
||||
config/ Configuration loading
|
||||
graph/ Core graph data structure (Node, Edge, Graph)
|
||||
indexer/ Directory walker, file watcher
|
||||
mcp/ MCP server and tool handlers
|
||||
parser/ Extractor interface, tree-sitter helpers
|
||||
languages/ Per-language extractors (one file each)
|
||||
query/ Query engine (BFS traversal, SubGraph)
|
||||
resolver/ Cross-file reference resolution, IMPLEMENTS inference
|
||||
web/ Web visualization server (Sigma.js)
|
||||
pkg/gortex/ Public API for embedding
|
||||
```
|
||||
|
||||
## Questions?
|
||||
|
||||
Open an issue or start a discussion. We're happy to help!
|
||||
@@ -0,0 +1,26 @@
|
||||
# Contributors
|
||||
|
||||
Thank you to everyone who has contributed to Gortex.
|
||||
|
||||
Gortex is licensed under the Apache License, Version 2.0. See
|
||||
[LICENSE.md](LICENSE.md). Contributions are accepted under the same
|
||||
terms (Apache 2.0, Section 5).
|
||||
|
||||
## Active Contributors
|
||||
|
||||
| Name | GitHub | Added |
|
||||
|------|--------|-------|
|
||||
| Andrey Kumanyaev | [@zzet](https://github.com/zzet) | 2024-01-01 |
|
||||
|
||||
## Past Contributors
|
||||
|
||||
_Contributors who have made valuable contributions in the past. Thank you!_
|
||||
|
||||
| Name | GitHub | Active Period |
|
||||
|------|--------|---------------|
|
||||
|
||||
## How to Become a Contributor
|
||||
|
||||
1. Make meaningful contributions to the project (code, docs, testing, etc.)
|
||||
2. Open a PR or contact the maintainer
|
||||
3. Upon acceptance, you'll be added to the active list
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for describing the origin of the Work and
|
||||
reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2024-2026 Andrey Kumanyaev <me@zzet.org>
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,283 @@
|
||||
BINARY := gortex
|
||||
|
||||
# VERSION defaults to the nearest annotated tag (e.g. v0.1.0) or "dev" when
|
||||
# no tags exist / not a git checkout. COMMIT is the short SHA; DATE is RFC
|
||||
# 3339 UTC. internal/version parses main.version and treats main.commit as
|
||||
# the +build slot, so `gortex version` prints canonical semver.
|
||||
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
||||
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
||||
DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
LDFLAGS := -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(DATE)
|
||||
|
||||
.PHONY: build build-onnx build-gomlx build-hugot build-windows \
|
||||
test bench bench-rpi bench-rpi-quick bench-rpi-profile bench-compare \
|
||||
lint fmt clean install dev-link tag-release \
|
||||
deps-onnx deps-gomlx deps-hugot deps-vectors \
|
||||
claude-plugin claude-plugin-check
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Build variants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
build:
|
||||
go build -ldflags '$(LDFLAGS)' -tags llama -o $(BINARY) ./cmd/gortex/
|
||||
|
||||
build-onnx: deps-onnx
|
||||
go build -tags embeddings_onnx -ldflags '$(LDFLAGS)' -o $(BINARY) ./cmd/gortex/
|
||||
|
||||
build-gomlx: deps-gomlx
|
||||
go build -tags "embeddings_gomlx XLA" -ldflags '$(LDFLAGS)' -o $(BINARY) ./cmd/gortex/
|
||||
|
||||
# Hugot is bundled by default now — this target is kept as a compatibility
|
||||
# alias and also ensures the dep is explicitly recorded in go.mod.
|
||||
build-hugot: deps-hugot
|
||||
go build -ldflags '$(LDFLAGS)' -o $(BINARY) ./cmd/gortex/
|
||||
|
||||
test:
|
||||
go test -race ./...
|
||||
|
||||
bench:
|
||||
go test -bench=. -benchmem -count=1 -benchtime=1s \
|
||||
./internal/parser/languages/ \
|
||||
./internal/graph/ \
|
||||
./internal/search/ \
|
||||
./internal/resolver/ \
|
||||
./internal/query/ \
|
||||
./internal/indexer/ \
|
||||
./internal/analysis/
|
||||
|
||||
# RPi / low-resource device benchmarks
|
||||
BENCH_BASELINE ?= results/bench-baseline.txt
|
||||
|
||||
bench-rpi:
|
||||
./scripts/bench-rpi.sh
|
||||
|
||||
bench-rpi-quick:
|
||||
./scripts/bench-rpi.sh --quick
|
||||
|
||||
bench-rpi-profile:
|
||||
./scripts/bench-rpi.sh --profile
|
||||
|
||||
bench-compare:
|
||||
./scripts/bench-rpi.sh --compare $(BENCH_BASELINE)
|
||||
|
||||
bench-save-baseline:
|
||||
./scripts/bench-rpi.sh
|
||||
@cp $$(ls -t results/bench-*.txt | head -1) $(BENCH_BASELINE)
|
||||
@echo "✓ Baseline saved to $(BENCH_BASELINE)"
|
||||
|
||||
lint:
|
||||
golangci-lint run --timeout=5m
|
||||
|
||||
fmt:
|
||||
gofmt -s -w .
|
||||
|
||||
clean:
|
||||
rm -f $(BINARY) gortex.exe gortex-linux gortex-rpi gortex-rpi32
|
||||
|
||||
install:
|
||||
go install -ldflags '$(LDFLAGS)' ./cmd/gortex/
|
||||
|
||||
# dev-link builds the working tree and points the Homebrew shim at it so
|
||||
# `gortex` on $PATH runs the dev binary. Restarts the daemon so the new
|
||||
# binary takes over (an old daemon keeps a stale in-memory graph). Revert
|
||||
# with `brew reinstall gortex`.
|
||||
HOMEBREW_BIN ?= /opt/homebrew/bin/gortex
|
||||
dev-link: build
|
||||
ln -sfn "$(CURDIR)/$(BINARY)" "$(HOMEBREW_BIN)"
|
||||
|
||||
# tag-release stamps the working copy with a git tag that matches the
|
||||
# version currently in cmd/gortex/main.go. Workflow:
|
||||
#
|
||||
# ./gortex version bump minor # edits main.go
|
||||
# git commit -am "Bump version to v0.2.0"
|
||||
# make tag-release # reads ./gortex, creates tag
|
||||
# git push && git push origin v0.2.0
|
||||
#
|
||||
# Builds without VERSION ldflags on purpose so `gortex version --short`
|
||||
# reflects the literal main.go value (not `git describe` drift). Strips
|
||||
# the +build slot because git tags shouldn't carry build metadata. Emits
|
||||
# a clear error on dev builds, duplicate tags, or a dirty tree so
|
||||
# misfires don't silently create broken releases.
|
||||
tag-release:
|
||||
@go build -o $(BINARY) ./cmd/gortex/
|
||||
@TAG=$$(./$(BINARY) version --short | sed 's/+.*//'); \
|
||||
if [ "$$TAG" = "v0.0.0-dev" ]; then \
|
||||
echo "refusing to tag dev build — run \`./$(BINARY) version bump …\` first"; exit 1; \
|
||||
fi; \
|
||||
if git rev-parse --verify "refs/tags/$$TAG" >/dev/null 2>&1; then \
|
||||
echo "tag $$TAG already exists"; exit 1; \
|
||||
fi; \
|
||||
if ! git diff-index --quiet HEAD --; then \
|
||||
echo "tracked files have uncommitted changes — commit the bump first (run \`git status\` to see them)"; exit 1; \
|
||||
fi; \
|
||||
git tag -a "$$TAG" -m "Release $$TAG"; \
|
||||
echo "Tagged $$TAG. Push with: git push origin $$TAG"
|
||||
|
||||
# Cross-compile for Raspberry Pi (ARM64)
|
||||
build-rpi:
|
||||
CGO_ENABLED=1 GOOS=linux GOARCH=arm64 CC=aarch64-linux-gnu-gcc \
|
||||
go build -ldflags '$(LDFLAGS)' -o gortex-rpi ./cmd/gortex/
|
||||
@echo "✓ Built gortex-rpi (linux/arm64)"
|
||||
|
||||
# Cross-compile for Raspberry Pi (ARMv7 / 32-bit)
|
||||
build-rpi32:
|
||||
CGO_ENABLED=1 GOOS=linux GOARCH=arm GOARM=7 CC=arm-linux-gnueabihf-gcc \
|
||||
go build -ldflags '$(LDFLAGS)' -o gortex-rpi32 ./cmd/gortex/
|
||||
@echo "✓ Built gortex-rpi32 (linux/arm/v7)"
|
||||
|
||||
# Cross-compile for Windows (amd64). Requires the mingw-w64 toolchain
|
||||
# (`brew install mingw-w64` on macOS, `apt install gcc-mingw-w64` on
|
||||
# Debian/Ubuntu). CGO stays on because tree-sitter needs a C/C++
|
||||
# compiler; the llama tag is omitted — the in-process llama.cpp backend
|
||||
# isn't part of the Windows build. `-extldflags -static` links the
|
||||
# mingw-w64 C/C++ runtime (libstdc++, libgcc, winpthread) into the .exe
|
||||
# so it runs on a stock Windows box without bundled DLLs.
|
||||
build-windows:
|
||||
CGO_ENABLED=1 GOOS=windows GOARCH=amd64 \
|
||||
CC=x86_64-w64-mingw32-gcc CXX=x86_64-w64-mingw32-g++ \
|
||||
go build -ldflags '$(LDFLAGS) -extldflags "-static"' -o gortex.exe ./cmd/gortex/
|
||||
@echo "✓ Built gortex.exe (windows/amd64)"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Marketplace plugin bundle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# claude-plugin regenerates the Anthropic Plugin Marketplace bundle at
|
||||
# claude-plugin/. The bundle is checked in so the marketplace's
|
||||
# "git-subdir" source can pull it directly. The CI guard
|
||||
# (claude-plugin-check) asserts that re-running this target produces
|
||||
# no diff against what's checked in — drift means the bundle is stale
|
||||
# vs the source-of-truth content in
|
||||
# internal/agents/claudecode/content.go.
|
||||
claude-plugin: build
|
||||
./$(BINARY) plugin emit --target ./claude-plugin --variant anthropic
|
||||
@echo "✓ Regenerated claude-plugin/ from internal/agents/claudecode content"
|
||||
|
||||
claude-plugin-check: claude-plugin
|
||||
@if ! git diff --exit-code -- claude-plugin >/dev/null 2>&1; then \
|
||||
echo "claude-plugin/ is out of date — run 'make claude-plugin' and commit the result"; \
|
||||
git --no-pager diff --stat -- claude-plugin; \
|
||||
exit 1; \
|
||||
fi
|
||||
@echo "✓ claude-plugin/ matches generated output"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Embedding backend dependencies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# ONNX Runtime — system library required for -tags embeddings_onnx
|
||||
deps-onnx:
|
||||
@echo "=== ONNX Runtime dependency ==="
|
||||
ifeq ($(shell uname -s),Darwin)
|
||||
@command -v brew >/dev/null 2>&1 || { echo "Error: Homebrew required. Install from https://brew.sh"; exit 1; }
|
||||
@brew list onnxruntime >/dev/null 2>&1 || brew install onnxruntime
|
||||
@echo "✓ onnxruntime installed (macOS/Homebrew)"
|
||||
else ifeq ($(shell uname -s),Linux)
|
||||
@dpkg -s libonnxruntime-dev >/dev/null 2>&1 || { echo "Run: sudo apt install libonnxruntime-dev"; exit 1; }
|
||||
@echo "✓ libonnxruntime-dev installed (Linux/apt)"
|
||||
endif
|
||||
go get github.com/yalue/onnxruntime_go@latest
|
||||
@echo "✓ Go ONNX bindings ready"
|
||||
|
||||
# GoMLX — XLA/PJRT plugin auto-downloads on first run (~100MB)
|
||||
deps-gomlx:
|
||||
@echo "=== GoMLX dependency ==="
|
||||
go get github.com/gomlx/gomlx@latest
|
||||
go get github.com/gomlx/onnx-gomlx@latest
|
||||
@echo "=== rust tokenizers (the XLA session links libtokenizers.a statically) ==="
|
||||
@if [ -f /usr/lib/libtokenizers.a ] || [ -f /usr/local/lib/libtokenizers.a ]; then \
|
||||
echo "✓ libtokenizers.a already present"; \
|
||||
else \
|
||||
os=$$(uname -s | tr '[:upper:]' '[:lower:]'); arch=$$(uname -m); \
|
||||
case "$$os-$$arch" in \
|
||||
darwin-arm64) asset=libtokenizers.darwin-arm64.tar.gz; dest=/usr/local/lib ;; \
|
||||
darwin-x86_64) asset=libtokenizers.darwin-x86_64.tar.gz; dest=/usr/local/lib ;; \
|
||||
linux-x86_64|linux-amd64) asset=libtokenizers.linux-amd64.tar.gz; dest=/usr/lib ;; \
|
||||
linux-aarch64|linux-arm64) asset=libtokenizers.linux-arm64.tar.gz; dest=/usr/lib ;; \
|
||||
*) echo "No prebuilt libtokenizers for $$os-$$arch — build it from https://github.com/daulet/tokenizers and place libtokenizers.a on the linker path"; exit 1 ;; \
|
||||
esac; \
|
||||
echo " downloading $$asset -> $$dest"; \
|
||||
curl -fsSL "https://github.com/daulet/tokenizers/releases/download/v1.27.0/$$asset" | tar -xz -C /tmp; \
|
||||
sudo cp /tmp/libtokenizers.a "$$dest/"; \
|
||||
fi
|
||||
@echo "✓ GoMLX + ONNX converter + rust tokenizers installed"
|
||||
@echo " Note: XLA/PJRT plugin will auto-download on first run (~100MB)"
|
||||
|
||||
# Hugot — uses same XLA/PJRT backend as GoMLX
|
||||
deps-hugot:
|
||||
@echo "=== Hugot dependency ==="
|
||||
go get github.com/knights-analytics/hugot@latest
|
||||
@echo "✓ Hugot installed"
|
||||
@echo " Note: XLA/PJRT plugin will auto-download on first run (~100MB)"
|
||||
|
||||
# Prepare GloVe word vectors for built-in static embeddings
|
||||
deps-vectors:
|
||||
@echo "=== Preparing GloVe word vectors ==="
|
||||
@test -f internal/embedding/data/vectors.bin.gz && echo "✓ Vectors already prepared" || bash scripts/prepare_vectors.sh
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Eval framework
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
EVAL_DIR := eval
|
||||
EVAL_VENV := $(EVAL_DIR)/.venv
|
||||
EVAL_PYTHON := $(EVAL_VENV)/bin/python
|
||||
EVAL_PIP := $(EVAL_VENV)/bin/pip
|
||||
EVAL_CLI := $(EVAL_VENV)/bin/gortex-eval
|
||||
EVAL_ANALYZE := $(EVAL_VENV)/bin/gortex-eval-analyze
|
||||
|
||||
MODEL ?= claude-sonnet
|
||||
MODE ?= baseline
|
||||
SLICE ?= 0:5
|
||||
SUBSET ?= lite
|
||||
|
||||
.PHONY: eval-setup eval-test eval-test-all eval-list \
|
||||
eval-single eval-matrix eval-debug eval-summary eval-compare eval-tools
|
||||
|
||||
# Setup: create venv and install deps
|
||||
eval-setup: build
|
||||
@test -d $(EVAL_VENV) || python3 -m venv $(EVAL_VENV)
|
||||
$(EVAL_PIP) install -q -e "$(EVAL_DIR)[dev]"
|
||||
@echo "✓ Eval framework ready. Binary: ./$(BINARY)"
|
||||
|
||||
# Build linux/amd64 binary for container injection (requires podman/docker)
|
||||
eval-build-linux:
|
||||
podman run --rm --platform linux/amd64 -v $(CURDIR):/src -w /src golang:1.26 \
|
||||
bash -c "apt-get update -qq && apt-get install -y -qq libtree-sitter-dev && go build -ldflags '$(LDFLAGS)' -o gortex-linux ./cmd/gortex/"
|
||||
@echo "✓ Built gortex-linux (linux/amd64)"
|
||||
|
||||
# Run Python eval tests
|
||||
eval-test:
|
||||
$(EVAL_PYTHON) -m pytest $(EVAL_DIR)/tests/ -q
|
||||
|
||||
# Run all tests (Go + Python)
|
||||
eval-test-all: test eval-test
|
||||
|
||||
# List available configs
|
||||
eval-list: eval-setup
|
||||
$(EVAL_CLI) list-configs
|
||||
|
||||
# Single (model, mode) run
|
||||
eval-single: eval-setup
|
||||
$(EVAL_CLI) single -m $(MODEL) --mode $(MODE) --subset $(SUBSET) --slice $(SLICE)
|
||||
|
||||
# Full A/B matrix
|
||||
eval-matrix: eval-setup
|
||||
$(EVAL_CLI) matrix --models claude-sonnet claude-haiku \
|
||||
--modes baseline native native_augment \
|
||||
--subset $(SUBSET) --slice $(SLICE)
|
||||
|
||||
# Debug a single instance
|
||||
eval-debug: eval-setup
|
||||
$(EVAL_CLI) debug -m $(MODEL) --mode $(MODE) -i $(INSTANCE)
|
||||
|
||||
# Analyze results
|
||||
eval-summary:
|
||||
$(EVAL_ANALYZE) summary results/
|
||||
|
||||
eval-compare:
|
||||
$(EVAL_ANALYZE) compare-modes results/ -m $(MODEL)
|
||||
|
||||
eval-tools:
|
||||
$(EVAL_ANALYZE) tool-usage results/
|
||||
@@ -0,0 +1,30 @@
|
||||
Gortex
|
||||
Copyright 2024-2026 Andrey Kumanyaev <me@zzet.org>
|
||||
|
||||
This product is licensed under the Apache License, Version 2.0 (see
|
||||
LICENSE.md). It includes software developed by third parties.
|
||||
|
||||
------------------------------------------------------------------------
|
||||
|
||||
Bundled (vendored) dependencies:
|
||||
|
||||
* internal/thirdparty/renameio/
|
||||
Atomic file replacement helpers.
|
||||
Copyright 2018 Google Inc.
|
||||
Licensed under the Apache License, Version 2.0.
|
||||
License: internal/thirdparty/renameio/LICENSE
|
||||
|
||||
* internal/thirdparty/go-pointer/
|
||||
A from-scratch, sharded reimplementation of github.com/mattn/go-pointer
|
||||
written for this project. Module path reuses the upstream identifier so
|
||||
it can drop in via a `replace` directive. Authored under this project's
|
||||
license (Apache 2.0).
|
||||
|
||||
------------------------------------------------------------------------
|
||||
|
||||
External Go-module dependencies are fetched at build time via `go build`
|
||||
and not vendored in this repository. Their licenses are available in the
|
||||
module cache (`go env GOMODCACHE`) and listed in THIRD_PARTY_NOTICES.md.
|
||||
Regenerate that file with:
|
||||
|
||||
go-licenses report ./... > THIRD_PARTY_NOTICES.md
|
||||
@@ -0,0 +1,189 @@
|
||||
|
||||
<div align="center">
|
||||
<p align="center">
|
||||
<img src="assets/wall.svg" alt="Gortex" width="500">
|
||||
</p>
|
||||
|
||||
|
||||
### High-performance and efficient code-intelligence engine for AI agents and IDE
|
||||
#### Indexes code into graph and exposes it via CLI, MCP Server, and web UI. Multi-repository support by default.
|
||||
#### Single static binary for macOS, Linux, and Windows — no dependency chain, simple installation and use.
|
||||
|
||||
---
|
||||
[](https://github.com/zzet/gortex/actions/workflows/ci.yml)
|
||||
[](https://github.com/zzet/gortex/releases/latest)
|
||||
[](docs/installation.md#verifying-releases-supply-chain-security)
|
||||
[](https://slsa.dev/spec/v1.0/levels#build-l3)
|
||||
[](https://www.virustotal.com/gui/url/00e1094b39c9bd7db4d5a179b1d56173f85c915075057fd3cc64bfbb9b735b11/detection)
|
||||
[](#)
|
||||
[](#)
|
||||
[](#)
|
||||
|
||||
[](https://scorecard.dev/viewer/?uri=github.com/zzet/gortex)
|
||||
[](https://pkg.go.dev/github.com/zzet/gortex)
|
||||
<br />
|
||||
<a href="https://trendshift.io/repositories/36832" target="_blank"><img src="https://trendshift.io/api/badge/repositories/36832" alt="zzet%2Fgortex | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
</div>
|
||||
|
||||
High-quality parsing 257 languages/grammars through tree-sitter AST analysis, in-process resolvers, enhanced with [compiler-grade resolution](https://github.com/zzet/gortex/blob/main/docs/lsp.md) for Python, TypeScript / JavaScript, PHP, C#, Go, C, C++, Java, Kotlin, Swift, Zig, Rust, Ruby, Elixir, Ocaml, Haskell, and [others](https://github.com/zzet/gortex/blob/main/docs/languages.md#at-a-glance) - producing a persistent provenance-tiered knowledge graph of functions, classes, call chains, HTTP routes, and cross-service contracts and calls with a strong confidence model. 175 (configurable) MCP tools - use only what you need. Zero dependencies. Plug and play across 18 coding agents. **Up to 50× fewer tokens per response**. Reproducible [benchmarks](BENCHMARK.md).
|
||||
|
||||
|
||||
> 18 AI coding agents (Claude Code, Kiro, Cursor, Windsurf, VS Code / Copilot, Continue.dev, Cline, OpenCode, Antigravity, Codex CLI, Gemini CLI, Zed, Aider, Kilo Code, OpenClaw, Hermes, Oh My Pi, Pi) supported out of the box.
|
||||
>
|
||||
> One install configures every one detected on your machine — see [docs/agents.md](docs/agents.md).
|
||||
|
||||
<details>
|
||||
<summary>Gortex Web UI — force-directed knowledge graph visualization</summary>
|
||||
|
||||

|
||||
|
||||
</details>
|
||||
|
||||
## Why it matters
|
||||
|
||||
- **50× fewer tokens per response** — graph-native lookups beat naive file reads. Agents read **just what they need**, not the full file, not the 500-line file around it.
|
||||
- **Full development cycle** - no 'read file before edit'. Agents ask for sources, tell what to change, and don't waste context of reading noise.
|
||||
- **257 languages/grammars** - every file in the repository reachable; no mix of tools and bloating/hallycinating agents. Three tiers (bespoke tree-sitter, regex, forest-backed signatures) plus Jupyter and Databricks notebooks → [docs/languages.md](docs/languages.md)
|
||||
- **Cross-repo by default** — N repos in one graph; contracts, references, and call chains span repo boundaries with evidence-gated resolution, contract matching, impact analysis, per-session isolation → [docs/multi-repo.md](docs/multi-repo.md)
|
||||
- **Extreamly fast analysis** — a precomputed depth-3 reach index turns blast-radius queries into O(seeds × reach) map lookups. Safe to ask "what breaks if I change this?" on every edit. No dozens of tool calls to grasp context.
|
||||
- **Zero external dependencies** — single binary, everything in-process. No network, no model download to get started. Install, start daemon, use.
|
||||
- **Agent integrations (17)** — `gortex init` configures every detected coding assistant on the machine → [docs/agents.md](docs/agents.md)
|
||||
- **100+ MCP tools, 16 resources, 3 prompts** — symbol lookup, call chains, blast radius, dataflow, clone detection, refactoring, code actions → [docs/mcp.md](docs/mcp.md)
|
||||
- **Semantic search default-on** — baked GloVe-50d (3.8 MB embedded), hybrid BM25 + vector + RRF, zero deps; opt-in MiniLM / Ollama / OpenAI → [docs/semantic-search.md](docs/semantic-search.md)
|
||||
- **Speculative execution** — `preview_edit` / `simulate_chain` answer "what would change if I applied this WorkspaceEdit?" without touching disk
|
||||
- **Live editor overlays** — push unsaved buffers as a shadow graph; tools read through it. Branching for parallel speculative sessions
|
||||
- **GCX1 wire format** — published, round-trippable. **An additional −27% tokens vs JSON** at same fidelity → [docs/wire-format.md](docs/wire-format.md)
|
||||
- **Long-living daemon** — one process serves every IDE window; live fsnotify, on-disk snapshots, restart, OS-supervised lifecycle
|
||||
- **9 LLM providers (optional)** — local llama.cpp, Anthropic, OpenAI, Ollama, Claude / Codex CLI subprocess, Gemini, Bedrock, DeepSeek → [docs/llm.md](docs/llm.md)
|
||||
- **Composable safety** — `verify_change`, `check_guards`, `audit_agent_config` flag broken callers, guard violations, stale docs before they ship
|
||||
- **PR review, end to end** — `gortex prs` triages open PRs (per-PR blast radius, merge-order conflicts via shared communities, AI-ranked queue, reviewer suggestions); `gortex review` emits line-anchored findings with a BLOCK/REVIEW/APPROVE verdict from a graph-grounded rulepack; MCP tools (`pr_risk`, `get_pr_impact`, `review`, `review_pack`, `post_review`, …) expose it to agents → [docs/cli.md](docs/cli.md)
|
||||
- **HTTP server + Web UI** — versioned `/v1/*` API + MCP 2026 Streamable HTTP; standalone Next.js 15 UI with five 3D graph modes → [docs/server.md](docs/server.md)
|
||||
- **Telemetry off by default** — opt-in anonymous tool/command counts only (no code, paths, names, or exact counts); nothing transmitted unless you configure an endpoint. `gortex telemetry on|off|status`; honours `DO_NOT_TRACK` → [docs/telemetry.md](docs/telemetry.md)
|
||||
|
||||
Full catalog of features: [docs/features.md](docs/features.md). Complete CLI reference: [docs/cli.md](docs/cli.md).
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
# macOS / Linux
|
||||
curl -fsSL https://get.gortex.dev | sh
|
||||
|
||||
# Windows (PowerShell)
|
||||
irm https://get.gortex.dev/install.ps1 | iex
|
||||
```
|
||||
|
||||
Detects OS/arch, verifies SHA256 + cosign, installs to PATH. Re-run to upgrade. Homebrew, `.deb` / `.rpm` / `.apk`, scoop, signed binaries, and from-source builds: [docs/installation.md](docs/installation.md).
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
gortex install # one-time machine setup (MCP, skills, slash commands)
|
||||
gortex daemon start --detach # background daemon
|
||||
gortex track ~/projects/myapp # add a repo
|
||||
cd ~/projects/myapp && gortex init # per-repo: .mcp.json, hooks, community routing
|
||||
```
|
||||
|
||||
Your AI assistant now uses graph queries. Full 15-minute walkthrough: [docs/onboarding.md](docs/onboarding.md).
|
||||
|
||||
## Cross-Repo API Contracts
|
||||
|
||||
Gortex auto-detects API contracts across repos and matches providers to consumers, surfaced via the `contracts` MCP tool and the web UI Contracts page.
|
||||
|
||||
| Contract type | Detection | Provider | Consumer |
|
||||
|--------------|-----------|----------|----------|
|
||||
| **HTTP routes** | Framework annotations (gin, Express, FastAPI, Spring, …) | Route handler | HTTP client calls (`fetch`, `http.Get`) |
|
||||
| **gRPC** | Proto service definitions | Service RPC | Client stub calls |
|
||||
| **GraphQL** | Schema type/field definitions | Schema | Query/mutation strings |
|
||||
| **Message topics** | Kafka / RabbitMQ / NATS / Redis pub/sub | Publish calls | Subscribe calls |
|
||||
| **WebSocket** | Event emit/listen patterns | `emit()` | `on()` |
|
||||
| **Env vars** | `os.Getenv`, `process.env`, `.env` files | `Setenv` / `.env` | `Getenv` / `process.env` |
|
||||
| **OpenAPI** | Swagger / OpenAPI spec files | Spec paths | (linked to HTTP routes) |
|
||||
| **Temporal workflows** | Go / Java SDK annotations | Activity / workflow function | `ExecuteActivity` / `ExecuteChildWorkflow` |
|
||||
|
||||
Contracts are normalised to canonical IDs (e.g. `http::GET::/api/users/{id}`) and matched across repos to detect orphan providers / consumers and mismatches. See [docs/contracts.md](docs/contracts.md).
|
||||
|
||||
## Scale — battle-tested on large repos
|
||||
|
||||
Measured on an Apple Silicon laptop with the default CGO build.
|
||||
|
||||
| Repository | Files | Nodes | Edges | Index time | Throughput | Peak heap |
|
||||
| ---------- | ----: | ----: | ----: | ---------: | ---------: | --------: |
|
||||
| [torvalds/linux](https://github.com/torvalds/linux) | 70,333 | 1,690,174 | 6,239,570 | ~3 min | 300 files/s | 5.07 GB |
|
||||
| [microsoft/vscode](https://github.com/microsoft/vscode) | 10,762 | 204,501 | 808,902 | ~1 min | 143 files/s | 580 MB |
|
||||
| zzet/gortex (self) | 430 | 5,583 | 53,830 | 3.4s | 127 files/s | 52 MB |
|
||||
|
||||
Parsing dominates wall time (65–80 %); reference resolution and search-index build scale sub-linearly.
|
||||
|
||||
## Token savings dashboard
|
||||
|
||||
`gortex savings` reports tokens saved vs naive file reads — per-call, per-session, and cumulative across restarts, priced in USD against the headline model.
|
||||
|
||||
```text
|
||||
Gortex Token Savings
|
||||
====================
|
||||
Cost avoided: $168.69 (claude-opus-4) across 1,878 calls · 11,246,094 tokens saved
|
||||
|
||||
Today ████████░░░░░░░░ 50.0% saved 9,200 / 18,400 tokens $0.14
|
||||
Last 7 days ██████████░░░░░░ 62.5% saved 60,100 / 96,200 tokens $0.90
|
||||
All time ███████████████░ 93.3% saved 11,246,094 / 12,050,716 tokens $168.69
|
||||
```
|
||||
|
||||
`--verbose` adds the per-tool breakdown; `--json` is machine-readable. Full reference: [docs/savings.md](docs/savings.md).
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
gortex binary
|
||||
CLI (cobra) ──> MultiIndexer ──> In-Memory Graph (shared, per-repo indexed)
|
||||
MCP (stdio) ──────────────────> Query Engine (repo/project/ref scoping)
|
||||
HTTP /v1/* ──────────────────> same tools + /v1/graph + /v1/events (SSE)
|
||||
Daemon (unix) ──────────────────> shared graph for every MCP client, session isolation
|
||||
MultiWatcher <── filesystem events (fsnotify, per-repo)
|
||||
CrossRepoResolver ──> cross-repo edge creation (type-aware)
|
||||
Persistence ──> gob+gzip snapshot (pluggable backend)
|
||||
```
|
||||
|
||||
Data flow, graph schema (node and edge kinds, multi-repo fields, test taxonomy), persistence model: [docs/architecture.md](docs/architecture.md).
|
||||
|
||||
## Documentation
|
||||
|
||||
| Topic | Reference |
|
||||
| --- | --- |
|
||||
| First-time walkthrough | [onboarding.md](docs/onboarding.md) |
|
||||
| Installation & supply-chain verification | [installation.md](docs/installation.md) |
|
||||
| Full feature catalog | [features.md](docs/features.md) |
|
||||
| CLI reference | [cli.md](docs/cli.md) |
|
||||
| MCP tools, resources, prompts | [mcp.md](docs/mcp.md) |
|
||||
| Multi-repo workspaces | [multi-repo.md](docs/multi-repo.md) |
|
||||
| HTTP server + Web UI + MCP 2026 transport | [server.md](docs/server.md) |
|
||||
| Cross-repo API contracts | [contracts.md](docs/contracts.md) |
|
||||
| Semantic search | [semantic-search.md](docs/semantic-search.md) |
|
||||
| Optional LLM features | [llm.md](docs/llm.md) |
|
||||
| LSP integration | [lsp.md](docs/lsp.md) |
|
||||
| Per-community skills & agent usage | [skills.md](docs/skills.md) |
|
||||
| AI agent adapters (17) | [agents.md](docs/agents.md) |
|
||||
| Supported languages (257) | [languages.md](docs/languages.md) |
|
||||
| Token savings | [savings.md](docs/savings.md) |
|
||||
| GCX1 wire format | [wire-format.md](docs/wire-format.md) |
|
||||
| Architecture & graph schema | [architecture.md](docs/architecture.md) |
|
||||
| Evaluation methodology | [04-evaluation/](docs/04-evaluation/) |
|
||||
| Telemetry & privacy | [telemetry.md](docs/telemetry.md) |
|
||||
| Versioning policy | [versioning.md](docs/versioning.md) |
|
||||
|
||||
## Building from source
|
||||
|
||||
```bash
|
||||
make build # binary with version stamping
|
||||
make test # go test -race ./...
|
||||
make lint # golangci-lint
|
||||
```
|
||||
|
||||
Requires Go 1.26+ and CGO (for tree-sitter C bindings).
|
||||
|
||||
## License
|
||||
|
||||
Apache License 2.0. See [LICENSE.md](LICENSE.md). Copyright 2024-2026 Andrey Kumanyaev <me@zzet.org>.
|
||||
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines on adding features, language extractors, and submitting PRs.
|
||||
@@ -0,0 +1,7 @@
|
||||
# WeHub 来源说明
|
||||
|
||||
- 原始项目:`zzet/gortex`
|
||||
- 原始仓库:https://github.com/zzet/gortex
|
||||
- 导入方式:上游默认分支的最新快照
|
||||
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
|
||||
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
# Security Policy
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
If you discover a security vulnerability in Gortex, please report it responsibly:
|
||||
|
||||
1. **Do not** open a public issue.
|
||||
2. Use GitHub's [private vulnerability reporting](https://github.com/zzet/gortex/security/advisories/new), or email the maintainer directly.
|
||||
3. Include a description, the affected version or commit, and steps to reproduce.
|
||||
|
||||
We aim to acknowledge receipt within 48 hours and will provide a timeline for a fix.
|
||||
|
||||
## Overview
|
||||
|
||||
Gortex is a code-intelligence engine. It indexes repositories into an in-memory
|
||||
knowledge graph and exposes that graph over a CLI and an MCP server. Running
|
||||
locally — on the user's machine, with the user's privileges — is the default and
|
||||
the assumption of this policy, but Gortex can also be deployed remotely (for
|
||||
example, a daemon bound to a non-localhost address), where the network-exposure
|
||||
and authentication considerations below carry more weight.
|
||||
|
||||
The MCP tools are typically driven by an LLM agent, so the agent should be
|
||||
treated as **potentially adversarial**: prompt injection through indexed content
|
||||
(a crafted README, source comment, test fixture, or dependency) can cause the
|
||||
agent to invoke tools with attacker-influenced arguments. The boundaries
|
||||
described below — file-path confinement, opt-in network egress, and explicit
|
||||
process execution — are the security boundary, not the agent's good behavior.
|
||||
|
||||
## Scope
|
||||
|
||||
### File system access
|
||||
|
||||
- Gortex **reads and writes files within indexed repository roots.** Editing is
|
||||
a first-class feature: tools such as `write_file`, `edit_file`, `edit_symbol`,
|
||||
`rename_symbol`, `batch_edit`, `move_inline`, `safe_delete_symbol`, and the LSP
|
||||
code-action tools modify source files in the repositories Gortex has indexed.
|
||||
After a write, the affected file is re-indexed to keep the graph fresh.
|
||||
- File-path resolution is **confined to indexed repository roots.** A path —
|
||||
relative or absolute — is resolved against the roots of the tracked
|
||||
repositories, and access outside every indexed root is refused. Symlinks are
|
||||
resolved before the check so a link cannot be used to escape a root.
|
||||
- Gortex does not require, and does not request, access to files outside the
|
||||
repositories you index.
|
||||
|
||||
### Network access
|
||||
|
||||
- **No telemetry.** Gortex sends no usage data, analytics, or crash reports, and
|
||||
performs no update or "phone-home" checks. With no LLM provider, federation, or
|
||||
forge tooling configured, Gortex makes **no outbound network requests.**
|
||||
- Outbound network access happens only through these **opt-in** features:
|
||||
- **LLM providers** (`llm.provider`): the `ask` agent and `search_symbols`
|
||||
assist modes can call an LLM. The default provider is `local` (in-process,
|
||||
no network). When configured for a hosted provider (Anthropic, OpenAI, Azure
|
||||
OpenAI, Google Gemini, AWS Bedrock, DeepSeek, or a remote Ollama) or a
|
||||
subprocess CLI provider (Claude, Codex, Copilot, Cursor, opencode), prompts
|
||||
**derived from your source code** are sent to that endpoint or third-party
|
||||
tool. No provider is configured by default, and `ask` / assist stay disabled
|
||||
when none is available.
|
||||
- **Federation** (`.gortex.yaml` `federation:` / `gortex proxy`): fans
|
||||
**read-only** graph queries out to other Gortex daemons you configure. It is
|
||||
off unless configured and read-only by default; the `federation.edges`
|
||||
cross-daemon edge feature (which fetches remote subgraphs) is off by default.
|
||||
- **PR / review tooling** (`gortex prs`, `gortex review --post`, and the
|
||||
matching MCP tools): call the GitHub API / the `gh` CLI when you invoke them.
|
||||
- **Inbound HTTP.** `gortex server` mounts a Streamable-HTTP MCP endpoint at
|
||||
`POST /mcp`; the daemon exposes it only when started with `--http-addr`. The
|
||||
listener binds to **localhost by default**; binding to a non-localhost address
|
||||
requires an authentication token (`--http-auth-token`). The default stdio
|
||||
transport communicates only with the parent process.
|
||||
|
||||
### Process execution
|
||||
|
||||
- Gortex executes external programs only for features you opt into:
|
||||
- **Git**, for history-derived features (blame, churn, co-change, diff review).
|
||||
- **Language servers** (e.g. `tsserver`), for cross-file resolution and LSP
|
||||
code actions, when an LSP is configured and available.
|
||||
- **Subprocess LLM providers** and **forge tools** (`claude`, `codex`,
|
||||
`copilot`, `cursor-agent`, `opencode`, `gh`), when configured.
|
||||
- These run with your privileges and may make their own network calls; they are
|
||||
invoked only when the corresponding feature is configured or requested.
|
||||
|
||||
### Data at rest
|
||||
|
||||
- The graph, along with session notes and development memories, is persisted
|
||||
locally under `~/.gortex` (and per-repo `.gortex/`). Notes and memories may
|
||||
contain excerpts of your source. Nothing is transmitted off the machine except
|
||||
through the opt-in network features above.
|
||||
|
||||
### Build / supply chain
|
||||
|
||||
- **CGO.** Tree-sitter grammars are compiled via CGO from
|
||||
`github.com/alexaandru/go-sitter-forest`. The optional in-process LLM (the
|
||||
`local` provider) is compiled only with the `llama` build tag.
|
||||
- SQLite persistence uses the pure-Go `modernc.org/sqlite` driver (no CGO).
|
||||
|
||||
## Hardening checklist
|
||||
|
||||
The following configuration choices increase Gortex's exposure; review them for
|
||||
your environment:
|
||||
|
||||
- Configuring a **hosted or subprocess LLM provider** sends code-derived prompts
|
||||
off the machine.
|
||||
- Enabling **federation / proxy** sends graph queries to the remote daemons you
|
||||
configure.
|
||||
- Binding the HTTP endpoint to a **non-localhost address** exposes the MCP
|
||||
surface to the network — always set `--http-auth-token`, and prefer a
|
||||
localhost bind or an SSH tunnel.
|
||||
- Driving the MCP tools with an agent that ingests **untrusted repository
|
||||
content** widens the prompt-injection surface; keep Gortex pointed at
|
||||
repositories you trust.
|
||||
@@ -0,0 +1,451 @@
|
||||
# Third-Party Notices
|
||||
|
||||
Gortex bundles or depends on third-party software. The list below is
|
||||
generated from the resolved module graph (`go list -m all`) at the time
|
||||
of release. The Gortex binary is statically linked, so transitively
|
||||
depended-upon modules are effectively redistributed in compiled form.
|
||||
|
||||
Each module retains its own license. Per Apache License 2.0 §4(c)-(d),
|
||||
we preserve copyright, patent, trademark, and attribution notices from
|
||||
those modules. The full license text for each module is available in
|
||||
the local Go module cache (`go env GOMODCACHE`) at
|
||||
`<module-path>@<version>/LICENSE` (or equivalent).
|
||||
|
||||
Vendored dependencies (in-tree copies) are documented separately in
|
||||
[NOTICE](NOTICE).
|
||||
|
||||
## Regenerating this file
|
||||
|
||||
```bash
|
||||
go list -m -f '{{.Path}} {{.Version}}' all
|
||||
```
|
||||
|
||||
For an automated license-aware report, run a tool such as
|
||||
`github.com/google/go-licenses` and replace the list below.
|
||||
|
||||
## Modules
|
||||
|
||||
- `cloud.google.com/go` @ v0.65.0
|
||||
- `codeberg.org/go-fonts/liberation` @ v0.5.0
|
||||
- `codeberg.org/go-latex/latex` @ v0.1.0
|
||||
- `codeberg.org/go-pdf/fpdf` @ v0.10.0
|
||||
- `git.sr.ht/~sbinet/gg` @ v0.6.0
|
||||
- `github.com/ajstarks/svgo` @ v0.0.0-20211024235047-1546f124cd8b
|
||||
- `github.com/alexaandru/go-sitter-forest/ada` @ v1.9.0
|
||||
- `github.com/alexaandru/go-sitter-forest/agda` @ v1.9.0
|
||||
- `github.com/alexaandru/go-sitter-forest/aiken` @ v1.9.0
|
||||
- `github.com/alexaandru/go-sitter-forest/al` @ v1.9.14
|
||||
- `github.com/alexaandru/go-sitter-forest/apex` @ v1.9.8
|
||||
- `github.com/alexaandru/go-sitter-forest/asciidoc` @ v1.9.2
|
||||
- `github.com/alexaandru/go-sitter-forest/astro` @ v1.9.3
|
||||
- `github.com/alexaandru/go-sitter-forest/awk` @ v1.9.2
|
||||
- `github.com/alexaandru/go-sitter-forest/beancount` @ v1.9.0
|
||||
- `github.com/alexaandru/go-sitter-forest/bibtex` @ v1.9.3
|
||||
- `github.com/alexaandru/go-sitter-forest/bicep` @ v1.9.1
|
||||
- `github.com/alexaandru/go-sitter-forest/bitbake` @ v1.9.1
|
||||
- `github.com/alexaandru/go-sitter-forest/blade` @ v1.9.3
|
||||
- `github.com/alexaandru/go-sitter-forest/c3` @ v1.9.25
|
||||
- `github.com/alexaandru/go-sitter-forest/caddy` @ v1.9.0
|
||||
- `github.com/alexaandru/go-sitter-forest/capnp` @ v1.9.0
|
||||
- `github.com/alexaandru/go-sitter-forest/cedar` @ v1.9.0
|
||||
- `github.com/alexaandru/go-sitter-forest/cel` @ v1.9.0
|
||||
- `github.com/alexaandru/go-sitter-forest/circom` @ v1.9.1
|
||||
- `github.com/alexaandru/go-sitter-forest/clarity` @ v1.9.5
|
||||
- `github.com/alexaandru/go-sitter-forest/clojure` @ v1.9.1
|
||||
- `github.com/alexaandru/go-sitter-forest/cmake` @ v1.9.5
|
||||
- `github.com/alexaandru/go-sitter-forest/cobol` @ v1.9.1
|
||||
- `github.com/alexaandru/go-sitter-forest/commonlisp` @ v1.9.1
|
||||
- `github.com/alexaandru/go-sitter-forest/cooklang` @ v1.9.0
|
||||
- `github.com/alexaandru/go-sitter-forest/crystal` @ v1.9.29
|
||||
- `github.com/alexaandru/go-sitter-forest/cuda` @ v1.9.1
|
||||
- `github.com/alexaandru/go-sitter-forest/cue` @ v1.9.1
|
||||
- `github.com/alexaandru/go-sitter-forest/d` @ v1.9.0
|
||||
- `github.com/alexaandru/go-sitter-forest/dataweave` @ v1.9.0
|
||||
- `github.com/alexaandru/go-sitter-forest/dbml` @ v1.9.0
|
||||
- `github.com/alexaandru/go-sitter-forest/desktop` @ v1.9.4
|
||||
- `github.com/alexaandru/go-sitter-forest/devicetree` @ v1.9.2
|
||||
- `github.com/alexaandru/go-sitter-forest/dhall` @ v1.9.2
|
||||
- `github.com/alexaandru/go-sitter-forest/djot` @ v1.9.7
|
||||
- `github.com/alexaandru/go-sitter-forest/dot` @ v1.9.0
|
||||
- `github.com/alexaandru/go-sitter-forest/dotenv` @ v1.9.0
|
||||
- `github.com/alexaandru/go-sitter-forest/dtd` @ v1.9.4
|
||||
- `github.com/alexaandru/go-sitter-forest/earthfile` @ v1.9.3
|
||||
- `github.com/alexaandru/go-sitter-forest/editorconfig` @ v1.9.2
|
||||
- `github.com/alexaandru/go-sitter-forest/effekt` @ v1.9.0
|
||||
- `github.com/alexaandru/go-sitter-forest/eiffel` @ v1.9.4
|
||||
- `github.com/alexaandru/go-sitter-forest/elisp` @ v1.9.0
|
||||
- `github.com/alexaandru/go-sitter-forest/elm` @ v1.9.1
|
||||
- `github.com/alexaandru/go-sitter-forest/elvish` @ v1.9.0
|
||||
- `github.com/alexaandru/go-sitter-forest/erlang` @ v1.9.7
|
||||
- `github.com/alexaandru/go-sitter-forest/fennel` @ v1.9.1
|
||||
- `github.com/alexaandru/go-sitter-forest/firrtl` @ v1.9.0
|
||||
- `github.com/alexaandru/go-sitter-forest/fish` @ v1.9.2
|
||||
- `github.com/alexaandru/go-sitter-forest/fortran` @ v1.9.13
|
||||
- `github.com/alexaandru/go-sitter-forest/fsharp` @ v1.9.3
|
||||
- `github.com/alexaandru/go-sitter-forest/gdscript` @ v1.9.6
|
||||
- `github.com/alexaandru/go-sitter-forest/gdshader` @ v1.9.0
|
||||
- `github.com/alexaandru/go-sitter-forest/gherkin` @ v1.9.0
|
||||
- `github.com/alexaandru/go-sitter-forest/git_config` @ v1.9.3
|
||||
- `github.com/alexaandru/go-sitter-forest/gitattributes` @ v1.9.1
|
||||
- `github.com/alexaandru/go-sitter-forest/gitcommit` @ v1.9.4
|
||||
- `github.com/alexaandru/go-sitter-forest/gitignore` @ v1.9.0
|
||||
- `github.com/alexaandru/go-sitter-forest/gleam` @ v1.9.9
|
||||
- `github.com/alexaandru/go-sitter-forest/glimmer` @ v1.9.0
|
||||
- `github.com/alexaandru/go-sitter-forest/glsl` @ v1.9.1
|
||||
- `github.com/alexaandru/go-sitter-forest/gn` @ v1.9.0
|
||||
- `github.com/alexaandru/go-sitter-forest/gnuplot` @ v1.9.2
|
||||
- `github.com/alexaandru/go-sitter-forest/godot_resource` @ v1.9.4
|
||||
- `github.com/alexaandru/go-sitter-forest/gomod` @ v1.9.4
|
||||
- `github.com/alexaandru/go-sitter-forest/gosum` @ v1.9.0
|
||||
- `github.com/alexaandru/go-sitter-forest/gotmpl` @ v1.9.3
|
||||
- `github.com/alexaandru/go-sitter-forest/gowork` @ v1.9.0
|
||||
- `github.com/alexaandru/go-sitter-forest/gpg` @ v1.9.0
|
||||
- `github.com/alexaandru/go-sitter-forest/graphql` @ v1.9.0
|
||||
- `github.com/alexaandru/go-sitter-forest/gren` @ v1.9.3
|
||||
- `github.com/alexaandru/go-sitter-forest/gritql` @ v1.9.1
|
||||
- `github.com/alexaandru/go-sitter-forest/groovy` @ v1.9.4
|
||||
- `github.com/alexaandru/go-sitter-forest/hack` @ v1.9.1
|
||||
- `github.com/alexaandru/go-sitter-forest/haml` @ v1.9.9
|
||||
- `github.com/alexaandru/go-sitter-forest/hare` @ v1.9.0
|
||||
- `github.com/alexaandru/go-sitter-forest/haskell` @ v1.9.2
|
||||
- `github.com/alexaandru/go-sitter-forest/haxe` @ v1.9.0
|
||||
- `github.com/alexaandru/go-sitter-forest/heex` @ v1.9.3
|
||||
- `github.com/alexaandru/go-sitter-forest/hjson` @ v1.9.0
|
||||
- `github.com/alexaandru/go-sitter-forest/hlsl` @ v1.9.1
|
||||
- `github.com/alexaandru/go-sitter-forest/hocon` @ v1.9.0
|
||||
- `github.com/alexaandru/go-sitter-forest/htmldjango` @ v1.9.1
|
||||
- `github.com/alexaandru/go-sitter-forest/hurl` @ v1.9.0
|
||||
- `github.com/alexaandru/go-sitter-forest/hyprlang` @ v1.9.4
|
||||
- `github.com/alexaandru/go-sitter-forest/idris` @ v1.9.5
|
||||
- `github.com/alexaandru/go-sitter-forest/ini` @ v1.9.1
|
||||
- `github.com/alexaandru/go-sitter-forest/ispc` @ v1.9.1
|
||||
- `github.com/alexaandru/go-sitter-forest/janet` @ v1.9.2
|
||||
- `github.com/alexaandru/go-sitter-forest/jasmin` @ v1.9.0
|
||||
- `github.com/alexaandru/go-sitter-forest/jinja` @ v1.9.6
|
||||
- `github.com/alexaandru/go-sitter-forest/jq` @ v1.9.1
|
||||
- `github.com/alexaandru/go-sitter-forest/json5` @ v1.9.0
|
||||
- `github.com/alexaandru/go-sitter-forest/jsonc` @ v1.9.0
|
||||
- `github.com/alexaandru/go-sitter-forest/jsonnet` @ v1.9.1
|
||||
- `github.com/alexaandru/go-sitter-forest/jule` @ v1.9.4
|
||||
- `github.com/alexaandru/go-sitter-forest/julia` @ v1.9.10
|
||||
- `github.com/alexaandru/go-sitter-forest/just` @ v1.9.6
|
||||
- `github.com/alexaandru/go-sitter-forest/kcl` @ v1.9.3
|
||||
- `github.com/alexaandru/go-sitter-forest/kconfig` @ v1.9.2
|
||||
- `github.com/alexaandru/go-sitter-forest/kdl` @ v1.9.1
|
||||
- `github.com/alexaandru/go-sitter-forest/koka` @ v1.9.1
|
||||
- `github.com/alexaandru/go-sitter-forest/kusto` @ v1.9.0
|
||||
- `github.com/alexaandru/go-sitter-forest/latex` @ v1.9.2
|
||||
- `github.com/alexaandru/go-sitter-forest/ledger` @ v1.9.5
|
||||
- `github.com/alexaandru/go-sitter-forest/linkerscript` @ v1.9.0
|
||||
- `github.com/alexaandru/go-sitter-forest/liquid` @ v1.9.3
|
||||
- `github.com/alexaandru/go-sitter-forest/llvm` @ v1.9.2
|
||||
- `github.com/alexaandru/go-sitter-forest/luau` @ v1.9.1
|
||||
- `github.com/alexaandru/go-sitter-forest/matlab` @ v1.9.4
|
||||
- `github.com/alexaandru/go-sitter-forest/mermaid` @ v1.9.0
|
||||
- `github.com/alexaandru/go-sitter-forest/meson` @ v1.9.1
|
||||
- `github.com/alexaandru/go-sitter-forest/mlir` @ v1.9.1
|
||||
- `github.com/alexaandru/go-sitter-forest/moonbit` @ v1.9.26
|
||||
- `github.com/alexaandru/go-sitter-forest/motoko` @ v1.9.0
|
||||
- `github.com/alexaandru/go-sitter-forest/move` @ v1.9.3
|
||||
- `github.com/alexaandru/go-sitter-forest/mustache` @ v1.9.0
|
||||
- `github.com/alexaandru/go-sitter-forest/nftables` @ v1.9.0
|
||||
- `github.com/alexaandru/go-sitter-forest/nickel` @ v1.9.2
|
||||
- `github.com/alexaandru/go-sitter-forest/nim` @ v1.9.1
|
||||
- `github.com/alexaandru/go-sitter-forest/ninja` @ v1.9.0
|
||||
- `github.com/alexaandru/go-sitter-forest/nix` @ v1.9.4
|
||||
- `github.com/alexaandru/go-sitter-forest/norg` @ v1.9.0
|
||||
- `github.com/alexaandru/go-sitter-forest/nu` @ v1.9.34
|
||||
- `github.com/alexaandru/go-sitter-forest/objc` @ v1.9.1
|
||||
- `github.com/alexaandru/go-sitter-forest/ocamllex` @ v1.9.2
|
||||
- `github.com/alexaandru/go-sitter-forest/odin` @ v1.9.2
|
||||
- `github.com/alexaandru/go-sitter-forest/pascal` @ v1.9.2
|
||||
- `github.com/alexaandru/go-sitter-forest/passwd` @ v1.9.0
|
||||
- `github.com/alexaandru/go-sitter-forest/pem` @ v1.9.0
|
||||
- `github.com/alexaandru/go-sitter-forest/perl` @ v1.9.9
|
||||
- `github.com/alexaandru/go-sitter-forest/pgn` @ v1.9.0
|
||||
- `github.com/alexaandru/go-sitter-forest/pioasm` @ v1.9.1
|
||||
- `github.com/alexaandru/go-sitter-forest/pkl` @ v1.9.6
|
||||
- `github.com/alexaandru/go-sitter-forest/plantuml` @ v1.9.0
|
||||
- `github.com/alexaandru/go-sitter-forest/po` @ v1.9.0
|
||||
- `github.com/alexaandru/go-sitter-forest/poe_filter` @ v1.9.2
|
||||
- `github.com/alexaandru/go-sitter-forest/pony` @ v1.9.0
|
||||
- `github.com/alexaandru/go-sitter-forest/powershell` @ v1.9.5
|
||||
- `github.com/alexaandru/go-sitter-forest/prisma` @ v1.9.2
|
||||
- `github.com/alexaandru/go-sitter-forest/promql` @ v1.9.1
|
||||
- `github.com/alexaandru/go-sitter-forest/properties` @ v1.9.2
|
||||
- `github.com/alexaandru/go-sitter-forest/prql` @ v1.9.1
|
||||
- `github.com/alexaandru/go-sitter-forest/psv` @ v1.9.0
|
||||
- `github.com/alexaandru/go-sitter-forest/pug` @ v1.9.1
|
||||
- `github.com/alexaandru/go-sitter-forest/puppet` @ v1.9.1
|
||||
- `github.com/alexaandru/go-sitter-forest/purescript` @ v1.9.0
|
||||
- `github.com/alexaandru/go-sitter-forest/qbe` @ v1.9.0
|
||||
- `github.com/alexaandru/go-sitter-forest/ql` @ v1.9.0
|
||||
- `github.com/alexaandru/go-sitter-forest/quint` @ v1.9.1
|
||||
- `github.com/alexaandru/go-sitter-forest/r` @ v1.9.6
|
||||
- `github.com/alexaandru/go-sitter-forest/racket` @ v1.9.1
|
||||
- `github.com/alexaandru/go-sitter-forest/ralph` @ v1.9.0
|
||||
- `github.com/alexaandru/go-sitter-forest/razor` @ v1.9.3
|
||||
- `github.com/alexaandru/go-sitter-forest/rbs` @ v1.9.1
|
||||
- `github.com/alexaandru/go-sitter-forest/rego` @ v1.9.0
|
||||
- `github.com/alexaandru/go-sitter-forest/requirements` @ v1.9.1
|
||||
- `github.com/alexaandru/go-sitter-forest/rescript` @ v1.9.1
|
||||
- `github.com/alexaandru/go-sitter-forest/robot` @ v1.9.4
|
||||
- `github.com/alexaandru/go-sitter-forest/roc` @ v1.9.6
|
||||
- `github.com/alexaandru/go-sitter-forest/ron` @ v1.9.0
|
||||
- `github.com/alexaandru/go-sitter-forest/scfg` @ v1.9.1
|
||||
- `github.com/alexaandru/go-sitter-forest/scheme` @ v1.9.1
|
||||
- `github.com/alexaandru/go-sitter-forest/scss` @ v1.9.0
|
||||
- `github.com/alexaandru/go-sitter-forest/slim` @ v1.9.2
|
||||
- `github.com/alexaandru/go-sitter-forest/smithy` @ v1.9.2
|
||||
- `github.com/alexaandru/go-sitter-forest/sml` @ v1.9.1
|
||||
- `github.com/alexaandru/go-sitter-forest/snakemake` @ v1.9.2
|
||||
- `github.com/alexaandru/go-sitter-forest/solidity` @ v1.9.3
|
||||
- `github.com/alexaandru/go-sitter-forest/soql` @ v1.9.6
|
||||
- `github.com/alexaandru/go-sitter-forest/sosl` @ v1.9.4
|
||||
- `github.com/alexaandru/go-sitter-forest/sourcepawn` @ v1.9.2
|
||||
- `github.com/alexaandru/go-sitter-forest/sparql` @ v1.9.3
|
||||
- `github.com/alexaandru/go-sitter-forest/ssh_config` @ v1.9.2
|
||||
- `github.com/alexaandru/go-sitter-forest/starlark` @ v1.9.2
|
||||
- `github.com/alexaandru/go-sitter-forest/strace` @ v1.9.0
|
||||
- `github.com/alexaandru/go-sitter-forest/structurizr` @ v1.9.2
|
||||
- `github.com/alexaandru/go-sitter-forest/superhtml` @ v1.9.1
|
||||
- `github.com/alexaandru/go-sitter-forest/surrealql` @ v1.9.10
|
||||
- `github.com/alexaandru/go-sitter-forest/svelte` @ v1.9.2
|
||||
- `github.com/alexaandru/go-sitter-forest/sxhkdrc` @ v1.9.1
|
||||
- `github.com/alexaandru/go-sitter-forest/systemverilog` @ v1.9.6
|
||||
- `github.com/alexaandru/go-sitter-forest/tact` @ v1.9.5
|
||||
- `github.com/alexaandru/go-sitter-forest/tcl` @ v1.9.4
|
||||
- `github.com/alexaandru/go-sitter-forest/templ` @ v1.9.6
|
||||
- `github.com/alexaandru/go-sitter-forest/tera` @ v1.9.3
|
||||
- `github.com/alexaandru/go-sitter-forest/textproto` @ v1.9.0
|
||||
- `github.com/alexaandru/go-sitter-forest/thrift` @ v1.9.1
|
||||
- `github.com/alexaandru/go-sitter-forest/tlaplus` @ v1.9.3
|
||||
- `github.com/alexaandru/go-sitter-forest/tmux` @ v1.9.0
|
||||
- `github.com/alexaandru/go-sitter-forest/todotxt` @ v1.9.0
|
||||
- `github.com/alexaandru/go-sitter-forest/tsv` @ v1.9.0
|
||||
- `github.com/alexaandru/go-sitter-forest/turtle` @ v1.9.0
|
||||
- `github.com/alexaandru/go-sitter-forest/twig` @ v1.9.0
|
||||
- `github.com/alexaandru/go-sitter-forest/typespec` @ v1.9.6
|
||||
- `github.com/alexaandru/go-sitter-forest/typst` @ v1.9.7
|
||||
- `github.com/alexaandru/go-sitter-forest/usd` @ v1.9.0
|
||||
- `github.com/alexaandru/go-sitter-forest/vala` @ v1.9.0
|
||||
- `github.com/alexaandru/go-sitter-forest/vento` @ v1.9.1
|
||||
- `github.com/alexaandru/go-sitter-forest/vhdl` @ v1.9.5
|
||||
- `github.com/alexaandru/go-sitter-forest/vim` @ v1.9.4
|
||||
- `github.com/alexaandru/go-sitter-forest/vrl` @ v1.9.0
|
||||
- `github.com/alexaandru/go-sitter-forest/vue` @ v1.9.0
|
||||
- `github.com/alexaandru/go-sitter-forest/wgsl` @ v1.9.1
|
||||
- `github.com/alexaandru/go-sitter-forest/wing` @ v1.9.1
|
||||
- `github.com/alexaandru/go-sitter-forest/wit` @ v1.9.4
|
||||
- `github.com/alexaandru/go-sitter-forest/xml` @ v1.9.5
|
||||
- `github.com/alexaandru/go-sitter-forest/yang` @ v1.9.0
|
||||
- `github.com/alexaandru/go-sitter-forest/zeek` @ v1.9.8
|
||||
- `github.com/alexaandru/go-sitter-forest/zig` @ v1.9.4
|
||||
- `github.com/alexaandru/go-sitter-forest/ziggy` @ v1.9.1
|
||||
- `github.com/alexaandru/go-sitter-forest/ziggy_schema` @ v1.9.1
|
||||
- `github.com/andybalholm/brotli` @ v1.2.0
|
||||
- `github.com/atotto/clipboard` @ v0.1.4
|
||||
- `github.com/aymanbagabas/go-osc52/v2` @ v2.0.1
|
||||
- `github.com/aymanbagabas/go-udiff` @ v0.3.1
|
||||
- `github.com/bits-and-blooms/bitset` @ v1.24.4
|
||||
- `github.com/blevesearch/bleve_index_api` @ v1.3.11
|
||||
- `github.com/blevesearch/bleve/v2` @ v2.6.0
|
||||
- `github.com/blevesearch/geo` @ v0.2.5
|
||||
- `github.com/blevesearch/go-faiss` @ v1.1.2
|
||||
- `github.com/blevesearch/go-metrics` @ v0.0.0-20201227073835-cf1acfcdf475
|
||||
- `github.com/blevesearch/go-porterstemmer` @ v1.0.3
|
||||
- `github.com/blevesearch/goleveldb` @ v1.0.1
|
||||
- `github.com/blevesearch/gtreap` @ v0.1.1
|
||||
- `github.com/blevesearch/mmap-go` @ v1.2.0
|
||||
- `github.com/blevesearch/scorch_segment_api/v2` @ v2.4.7
|
||||
- `github.com/blevesearch/segment` @ v0.9.1
|
||||
- `github.com/blevesearch/snowball` @ v0.6.1
|
||||
- `github.com/blevesearch/snowballstem` @ v0.9.0
|
||||
- `github.com/blevesearch/stempel` @ v0.2.0
|
||||
- `github.com/blevesearch/upsidedown_store_api` @ v1.0.2
|
||||
- `github.com/blevesearch/vellum` @ v1.2.0
|
||||
- `github.com/blevesearch/zapx/v11` @ v11.4.3
|
||||
- `github.com/blevesearch/zapx/v12` @ v12.4.3
|
||||
- `github.com/blevesearch/zapx/v13` @ v13.4.3
|
||||
- `github.com/blevesearch/zapx/v14` @ v14.4.3
|
||||
- `github.com/blevesearch/zapx/v15` @ v15.4.3
|
||||
- `github.com/blevesearch/zapx/v16` @ v16.3.4
|
||||
- `github.com/blevesearch/zapx/v17` @ v17.1.3
|
||||
- `github.com/campoy/embedmd` @ v1.0.0
|
||||
- `github.com/charmbracelet/bubbles` @ v1.0.0
|
||||
- `github.com/charmbracelet/bubbletea` @ v1.3.10
|
||||
- `github.com/charmbracelet/colorprofile` @ v0.4.3
|
||||
- `github.com/charmbracelet/harmonica` @ v0.2.0
|
||||
- `github.com/charmbracelet/lipgloss` @ v1.1.0
|
||||
- `github.com/charmbracelet/x/ansi` @ v0.11.7
|
||||
- `github.com/charmbracelet/x/cellbuf` @ v0.0.15
|
||||
- `github.com/charmbracelet/x/exp/golden` @ v0.0.0-20241011142426-46044092ad91
|
||||
- `github.com/charmbracelet/x/term` @ v0.2.2
|
||||
- `github.com/chewxy/math32` @ v1.11.2
|
||||
- `github.com/clipperhouse/displaywidth` @ v0.11.0
|
||||
- `github.com/clipperhouse/stringish` @ v0.1.1
|
||||
- `github.com/clipperhouse/uax29/v2` @ v2.7.0
|
||||
- `github.com/coder/hnsw` @ v0.6.1
|
||||
- `github.com/couchbase/ghistogram` @ v0.1.0
|
||||
- `github.com/couchbase/moss` @ v0.2.0
|
||||
- `github.com/cpuguy83/go-md2man/v2` @ v2.0.6
|
||||
- `github.com/daulet/tokenizers` @ v1.27.0
|
||||
- `github.com/davecgh/go-spew` @ v1.1.2-0.20180830191138-d8f796af33cc
|
||||
- `github.com/dlclark/regexp2` @ v1.12.0
|
||||
- `github.com/dmarkham/enumer` @ v1.6.1
|
||||
- `github.com/dustin/go-humanize` @ v1.0.1
|
||||
- `github.com/edsrzf/mmap-go` @ v1.2.0
|
||||
- `github.com/eliben/go-sentencepiece` @ v0.7.0
|
||||
- `github.com/erikgeiser/coninput` @ v0.0.0-20211004153227-1c3628e74d0f
|
||||
- `github.com/erkkah/margaid` @ v0.3.0
|
||||
- `github.com/felixge/fgprof` @ v0.9.5
|
||||
- `github.com/frankban/quicktest` @ v1.14.6
|
||||
- `github.com/fsnotify/fsnotify` @ v1.10.1
|
||||
- `github.com/fwcd/tree-sitter-kotlin` @ v0.0.0-20260411204054-55622a49bd59
|
||||
- `github.com/go-errors/errors` @ v1.5.1
|
||||
- `github.com/go-logr/logr` @ v1.4.3
|
||||
- `github.com/go-viper/mapstructure/v2` @ v2.5.0
|
||||
- `github.com/gofrs/flock` @ v0.13.0
|
||||
- `github.com/gofrs/uuid` @ v4.4.0+incompatible
|
||||
- `github.com/golang/freetype` @ v0.0.0-20170609003504-e2365dfdc4a0
|
||||
- `github.com/golang/protobuf` @ v1.5.0
|
||||
- `github.com/golang/snappy` @ v1.0.0
|
||||
- `github.com/gomlx/bsplines` @ v0.2.0
|
||||
- `github.com/gomlx/exceptions` @ v0.0.3
|
||||
- `github.com/gomlx/go-huggingface` @ v0.3.5
|
||||
- `github.com/gomlx/go-xla` @ v0.2.2
|
||||
- `github.com/gomlx/gomlx` @ v0.27.3
|
||||
- `github.com/gomlx/onnx-gomlx` @ v0.4.2
|
||||
- `github.com/google/go-cmp` @ v0.7.0
|
||||
- `github.com/google/gofuzz` @ v1.2.0
|
||||
- `github.com/google/jsonschema-go` @ v0.4.3
|
||||
- `github.com/google/pprof` @ v0.0.0-20240227163752-401108e1b7e7
|
||||
- `github.com/google/renameio` @ v1.0.1
|
||||
- `github.com/google/uuid` @ v1.6.0
|
||||
- `github.com/inconshreveable/mousetrap` @ v1.1.0
|
||||
- `github.com/janpfeifer/go-benchmarks` @ v0.1.1
|
||||
- `github.com/janpfeifer/gonb` @ v0.11.3
|
||||
- `github.com/janpfeifer/must` @ v0.2.0
|
||||
- `github.com/jedib0t/go-pretty/v6` @ v6.7.10
|
||||
- `github.com/json-iterator/go` @ v1.1.12
|
||||
- `github.com/klauspost/compress` @ v1.18.5
|
||||
- `github.com/klauspost/cpuid/v2` @ v2.3.0
|
||||
- `github.com/knights-analytics/hugot` @ v0.7.3
|
||||
- `github.com/knights-analytics/ortgenai` @ v0.3.1
|
||||
- `github.com/kr/pretty` @ v0.3.1
|
||||
- `github.com/kr/text` @ v0.2.0
|
||||
- `github.com/kylelemons/godebug` @ v1.1.0
|
||||
- `github.com/lucasb-eyer/go-colorful` @ v1.4.0
|
||||
- `github.com/MakeNowJust/heredoc` @ v1.0.0
|
||||
- `github.com/mark3labs/mcp-go` @ v0.54.0
|
||||
- `github.com/mattn/go-isatty` @ v0.0.22
|
||||
- `github.com/mattn/go-localereader` @ v0.0.1
|
||||
- `github.com/mattn/go-pointer` @ v0.0.1
|
||||
- `github.com/mattn/go-runewidth` @ v0.0.23
|
||||
- `github.com/mdempsky/unconvert` @ v0.0.0-20250216222326-4a038b3d31f5
|
||||
- `github.com/MetalBlueberry/go-plotly` @ v0.7.0
|
||||
- `github.com/mitchellh/colorstring` @ v0.0.0-20190213212951-d06e56a500db
|
||||
- `github.com/modern-go/concurrent` @ v0.0.0-20180306012644-bacd9c7ef1dd
|
||||
- `github.com/modern-go/reflect2` @ v1.0.2
|
||||
- `github.com/mschoch/smat` @ v0.2.0
|
||||
- `github.com/muesli/ansi` @ v0.0.0-20230316100256-276c6243b2f6
|
||||
- `github.com/muesli/cancelreader` @ v0.2.2
|
||||
- `github.com/muesli/termenv` @ v0.16.0
|
||||
- `github.com/parquet-go/bitpack` @ v1.0.0
|
||||
- `github.com/parquet-go/jsonlite` @ v1.5.0
|
||||
- `github.com/parquet-go/parquet-go` @ v0.29.0
|
||||
- `github.com/pascaldekloe/name` @ v1.0.0
|
||||
- `github.com/pelletier/go-toml/v2` @ v2.3.1
|
||||
- `github.com/pierrec/lz4/v4` @ v4.1.26
|
||||
- `github.com/pkg/errors` @ v0.9.1
|
||||
- `github.com/pkg/profile` @ v1.7.0
|
||||
- `github.com/pkoukk/tiktoken-go` @ v0.1.8
|
||||
- `github.com/pkoukk/tiktoken-go-loader` @ v0.0.2
|
||||
- `github.com/pmezard/go-difflib` @ v1.0.1-0.20181226105442-5d4384ee4fb2
|
||||
- `github.com/rivo/uniseg` @ v0.4.7
|
||||
- `github.com/RoaringBitmap/roaring/v2` @ v2.18.0
|
||||
- `github.com/rogpeppe/go-internal` @ v1.14.1
|
||||
- `github.com/russross/blackfriday/v2` @ v2.1.0
|
||||
- `github.com/sabhiram/go-gitignore` @ v0.0.0-20210923224102-525f6e181f06
|
||||
- `github.com/sagikazarmark/locafero` @ v0.12.0
|
||||
- `github.com/sahilm/fuzzy` @ v0.1.2
|
||||
- `github.com/santhosh-tekuri/jsonschema/v6` @ v6.0.2
|
||||
- `github.com/schollz/progressbar/v3` @ v3.19.0
|
||||
- `github.com/sgtdi/fswatcher` @ v1.3.0
|
||||
- `github.com/sourcegraph/conc` @ v0.3.1-0.20240121214520-5f936abd7ae8
|
||||
- `github.com/spf13/afero` @ v1.15.0
|
||||
- `github.com/spf13/cast` @ v1.10.0
|
||||
- `github.com/spf13/cobra` @ v1.10.2
|
||||
- `github.com/spf13/pflag` @ v1.0.10
|
||||
- `github.com/spf13/viper` @ v1.21.0
|
||||
- `github.com/streadway/quantile` @ v0.0.0-20220407130108-4246515d968d
|
||||
- `github.com/stretchr/objx` @ v0.5.2
|
||||
- `github.com/stretchr/testify` @ v1.11.1
|
||||
- `github.com/subosito/gotenv` @ v1.6.0
|
||||
- `github.com/toon-format/toon-go` @ v0.0.0-20251202084852-7ca0e27c4e8c
|
||||
- `github.com/tree-sitter-grammars/tree-sitter-hcl` @ v1.2.0
|
||||
- `github.com/tree-sitter-grammars/tree-sitter-lua` @ v0.5.0
|
||||
- `github.com/tree-sitter-grammars/tree-sitter-toml` @ v0.7.0
|
||||
- `github.com/tree-sitter-grammars/tree-sitter-yaml` @ v0.7.2
|
||||
- `github.com/tree-sitter/go-tree-sitter` @ v0.25.0
|
||||
- `github.com/tree-sitter/tree-sitter-bash` @ v0.25.1
|
||||
- `github.com/tree-sitter/tree-sitter-c` @ v0.24.2
|
||||
- `github.com/tree-sitter/tree-sitter-c-sharp` @ v0.23.5
|
||||
- `github.com/tree-sitter/tree-sitter-cpp` @ v0.23.4
|
||||
- `github.com/tree-sitter/tree-sitter-css` @ v0.25.0
|
||||
- `github.com/tree-sitter/tree-sitter-elixir` @ v0.3.5
|
||||
- `github.com/tree-sitter/tree-sitter-embedded-template` @ v0.25.0
|
||||
- `github.com/tree-sitter/tree-sitter-go` @ v0.25.0
|
||||
- `github.com/tree-sitter/tree-sitter-html` @ v0.23.2
|
||||
- `github.com/tree-sitter/tree-sitter-java` @ v0.23.5
|
||||
- `github.com/tree-sitter/tree-sitter-javascript` @ v0.25.0
|
||||
- `github.com/tree-sitter/tree-sitter-json` @ v0.24.8
|
||||
- `github.com/tree-sitter/tree-sitter-ocaml` @ v0.25.0
|
||||
- `github.com/tree-sitter/tree-sitter-php` @ v0.24.2
|
||||
- `github.com/tree-sitter/tree-sitter-python` @ v0.25.0
|
||||
- `github.com/tree-sitter/tree-sitter-ruby` @ v0.23.1
|
||||
- `github.com/tree-sitter/tree-sitter-rust` @ v0.24.2
|
||||
- `github.com/tree-sitter/tree-sitter-scala` @ v0.26.0
|
||||
- `github.com/tree-sitter/tree-sitter-typescript` @ v0.23.2
|
||||
- `github.com/twpayne/go-geom` @ v1.6.1
|
||||
- `github.com/viant/afs` @ v1.30.0
|
||||
- `github.com/viant/toolbox` @ v0.34.6-0.20221112031702-3e7cdde7f888
|
||||
- `github.com/viant/xreflect` @ v0.0.0-20230303201326-f50afb0feb0d
|
||||
- `github.com/viant/xunsafe` @ v0.9.2
|
||||
- `github.com/viterin/partial` @ v1.1.0
|
||||
- `github.com/viterin/vek` @ v0.4.3
|
||||
- `github.com/x448/float16` @ v0.8.4
|
||||
- `github.com/xo/terminfo` @ v0.0.0-20220910002029-abceb7e1c41e
|
||||
- `github.com/yalue/onnxruntime_go` @ v1.30.1
|
||||
- `github.com/yosida95/uritemplate/v3` @ v3.0.2
|
||||
- `github.com/yuin/goldmark` @ v1.4.13
|
||||
- `github.com/zeebo/assert` @ v1.1.0
|
||||
- `github.com/zeebo/blake3` @ v0.2.4
|
||||
- `github.com/zeebo/pcg` @ v1.0.1
|
||||
- `go.etcd.io/bbolt` @ v1.4.3
|
||||
- `go.etcd.io/gofail` @ v0.2.0
|
||||
- `go.uber.org/goleak` @ v1.3.0
|
||||
- `go.uber.org/multierr` @ v1.11.0
|
||||
- `go.uber.org/zap` @ v1.28.0
|
||||
- `go.yaml.in/yaml/v3` @ v3.0.4
|
||||
- `golang.org/x/crypto` @ v0.52.0
|
||||
- `golang.org/x/exp` @ v0.0.0-20260508232706-74f9aab9d74a
|
||||
- `golang.org/x/image` @ v0.41.0
|
||||
- `golang.org/x/mod` @ v0.36.0
|
||||
- `golang.org/x/net` @ v0.54.0
|
||||
- `golang.org/x/sync` @ v0.20.0
|
||||
- `golang.org/x/sys` @ v0.45.0
|
||||
- `golang.org/x/telemetry` @ v0.0.0-20260508192327-42602be52be6
|
||||
- `golang.org/x/term` @ v0.43.0
|
||||
- `golang.org/x/text` @ v0.37.0
|
||||
- `golang.org/x/tools` @ v0.45.0
|
||||
- `golang.org/x/tools/go/expect` @ v0.1.1-deprecated
|
||||
- `golang.org/x/tools/go/packages/packagestest` @ v0.1.1-deprecated
|
||||
- `gonum.org/v1/gonum` @ v0.16.0
|
||||
- `gonum.org/v1/plot` @ v0.15.2
|
||||
- `google.golang.org/protobuf` @ v1.36.11
|
||||
- `gopkg.in/check.v1` @ v1.0.0-20201130134442-10cb98267c6c
|
||||
- `gopkg.in/yaml.v2` @ v2.4.0
|
||||
- `gopkg.in/yaml.v3` @ v3.0.1
|
||||
- `k8s.io/klog/v2` @ v2.140.0
|
||||
- `pgregory.net/rapid` @ v1.2.0
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
+207
@@ -0,0 +1,207 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="105 67 1180 468" width="1180" height="468" role="img" aria-label="Gortex">
|
||||
<title>Gortex — code graph and intelligence engine</title>
|
||||
<defs>
|
||||
<filter id="soft" x="-120%" y="-120%" width="340%" height="340%">
|
||||
<feGaussianBlur stdDeviation="6"/>
|
||||
</filter>
|
||||
<style>
|
||||
/* ---------- palette : light default, dark via prefers-color-scheme ---------- */
|
||||
.ink { fill: #18181A; }
|
||||
.dim { fill: #CBCBCA; }
|
||||
.grn { fill: #4C9655; }
|
||||
.glow { fill: #4C9655; }
|
||||
.edge { stroke: #18181A; stroke-opacity: .15; }
|
||||
.pulse{ fill: #4C9655; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.ink { fill: #ECECEA; }
|
||||
.dim { fill: #3C3C42; }
|
||||
.grn { fill: #5FB36C; }
|
||||
.glow { fill: #5FB36C; }
|
||||
.edge { stroke: #ECECEA; stroke-opacity: .14; }
|
||||
.pulse{ fill: #6FC47B; }
|
||||
}
|
||||
|
||||
.node, .dot, .ghome-pop { transform-box: fill-box; transform-origin: center; }
|
||||
|
||||
/* ---------- node + dot intro : staggered pop-in, plays once ---------- */
|
||||
.node, .dot { opacity: 0; animation: pop .5s cubic-bezier(.34,1.3,.5,1) both; }
|
||||
@keyframes pop { 0%{opacity:0;transform:scale(.2)} 100%{opacity:1;transform:scale(1)} }
|
||||
|
||||
/* ---------- edges draw themselves in once ---------- */
|
||||
.edge { fill:none; stroke-width:2; stroke-linecap:round;
|
||||
stroke-dasharray:100; stroke-dashoffset:100; animation: draw .7s ease-out forwards; }
|
||||
@keyframes draw { to { stroke-dashoffset: 0; } }
|
||||
|
||||
/* ---------- the green light travelling the ring ---------- */
|
||||
.ghome-pop { opacity:0; animation: pop .5s cubic-bezier(.34,1.3,.5,1) 0.3s both; }
|
||||
.c0 { animation: chase0 6.0s linear 1.7s infinite backwards; }
|
||||
.c1 { animation: chase1 6.0s linear 1.7s infinite backwards; }
|
||||
.c2 { animation: chase2 6.0s linear 1.7s infinite backwards; }
|
||||
.c3 { animation: chase3 6.0s linear 1.7s infinite backwards; }
|
||||
.c4 { animation: chase4 6.0s linear 1.7s infinite backwards; }
|
||||
.c5 { animation: chase5 6.0s linear 1.7s infinite backwards; }
|
||||
.c6 { animation: chase6 6.0s linear 1.7s infinite backwards; }
|
||||
.c7 { animation: chase7 6.0s linear 1.7s infinite backwards; }
|
||||
.c8 { animation: chase8 6.0s linear 1.7s infinite backwards; }
|
||||
.c9 { animation: chase9 6.0s linear 1.7s infinite backwards; }
|
||||
.c10 { animation: chase10 6.0s linear 1.7s infinite backwards; }
|
||||
.c11 { animation: chase11 6.0s linear 1.7s infinite backwards; }
|
||||
@keyframes chase0 { 0%{opacity:1} 16.000%{opacity:1} 16.450%{opacity:0} 78.333%{opacity:0} 78.783%{opacity:1} 100%{opacity:1} }
|
||||
@keyframes chase1 { 0%,15.550%{opacity:0} 16.000%{opacity:1} 21.667%{opacity:1} 22.117%,100%{opacity:0} }
|
||||
@keyframes chase2 { 0%,21.217%{opacity:0} 21.667%{opacity:1} 27.333%{opacity:1} 27.783%,100%{opacity:0} }
|
||||
@keyframes chase3 { 0%,26.883%{opacity:0} 27.333%{opacity:1} 33.000%{opacity:1} 33.450%,100%{opacity:0} }
|
||||
@keyframes chase4 { 0%,32.550%{opacity:0} 33.000%{opacity:1} 38.667%{opacity:1} 39.117%,100%{opacity:0} }
|
||||
@keyframes chase5 { 0%,38.217%{opacity:0} 38.667%{opacity:1} 44.333%{opacity:1} 44.783%,100%{opacity:0} }
|
||||
@keyframes chase6 { 0%,43.883%{opacity:0} 44.333%{opacity:1} 50.000%{opacity:1} 50.450%,100%{opacity:0} }
|
||||
@keyframes chase7 { 0%,49.550%{opacity:0} 50.000%{opacity:1} 55.667%{opacity:1} 56.117%,100%{opacity:0} }
|
||||
@keyframes chase8 { 0%,55.217%{opacity:0} 55.667%{opacity:1} 61.333%{opacity:1} 61.783%,100%{opacity:0} }
|
||||
@keyframes chase9 { 0%,60.883%{opacity:0} 61.333%{opacity:1} 67.000%{opacity:1} 67.450%,100%{opacity:0} }
|
||||
@keyframes chase10 { 0%,66.550%{opacity:0} 67.000%{opacity:1} 72.667%{opacity:1} 73.117%,100%{opacity:0} }
|
||||
@keyframes chase11 { 0%,72.217%{opacity:0} 72.667%{opacity:1} 78.333%{opacity:1} 78.783%,100%{opacity:0} }
|
||||
|
||||
/* ---------- query dot : in along the spine, out the same way ---------- */
|
||||
.qpulse { opacity:0; animation: qp 6.0s linear 1.7s infinite; }
|
||||
@keyframes qp { 0%{opacity:0} 3%{opacity:1} 14%{opacity:1} 17%{opacity:0}
|
||||
83%{opacity:0} 86%{opacity:1} 97%{opacity:1} 100%{opacity:0} }
|
||||
|
||||
/* ---------- wordmark fades + rises in once ---------- */
|
||||
.wordmark { opacity:0; transform: translateY(10px); animation: word .8s ease-out .55s forwards; }
|
||||
@keyframes word { to { opacity:1; transform: translateY(0); } }
|
||||
|
||||
/* ---------- reduced motion : settled logo, green at home, no movement ---------- */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.node,.dot,.edge,.ghome-pop,.wordmark { animation:none!important; opacity:1!important;
|
||||
transform:none!important; stroke-dashoffset:0!important; }
|
||||
.c0 { animation:none!important; opacity:1!important; }
|
||||
.c1,.c2,.c3,.c4,.c5,.c6,.c7,.c8,.c9,.c10,.c11,.qpulse { animation:none!important; opacity:0!important; }
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
|
||||
<!-- knowledge-graph edges (the ring the green light travels) -->
|
||||
<g>
|
||||
<path class="edge" d="M190 300 L351 300" pathLength="100" style="animation-delay:0.5s"/>
|
||||
<path class="edge" d="M351 300 L431 300" pathLength="100" style="animation-delay:0.53s"/>
|
||||
<path class="edge" d="M431 300 L511 300" pathLength="100" style="animation-delay:0.56s"/>
|
||||
<path class="edge" d="M511 220 L511 300" pathLength="100" style="animation-delay:0.59s"/>
|
||||
<path class="edge" d="M511 380 L511 300" pathLength="100" style="animation-delay:0.62s"/>
|
||||
<path class="edge" d="M431 140 L511 220" pathLength="100" style="animation-delay:0.65s"/>
|
||||
<path class="edge" d="M431 460 L511 380" pathLength="100" style="animation-delay:0.68s"/>
|
||||
<path class="edge" d="M270 140 L351 140" pathLength="100" style="animation-delay:0.71s"/>
|
||||
<path class="edge" d="M351 140 L431 140" pathLength="100" style="animation-delay:0.74s"/>
|
||||
<path class="edge" d="M270 460 L351 460" pathLength="100" style="animation-delay:0.77s"/>
|
||||
<path class="edge" d="M351 460 L431 460" pathLength="100" style="animation-delay:0.8s"/>
|
||||
<path class="edge" d="M190 220 L190 300" pathLength="100" style="animation-delay:0.83s"/>
|
||||
<path class="edge" d="M190 300 L190 380" pathLength="100" style="animation-delay:0.86s"/>
|
||||
<path class="edge" d="M270 140 L190 220" pathLength="100" style="animation-delay:0.89s"/>
|
||||
<path class="edge" d="M270 460 L190 380" pathLength="100" style="animation-delay:0.92s"/>
|
||||
</g>
|
||||
|
||||
<!-- inactive cells -->
|
||||
<g>
|
||||
<rect class="dot dim" x="182.5" y="132.5" width="15" height="15" style="animation-delay:0.0s"/>
|
||||
<rect class="dot dim" x="503.5" y="132.5" width="15" height="15" style="animation-delay:0.2s"/>
|
||||
<rect class="dot dim" x="262.5" y="212.5" width="15" height="15" style="animation-delay:0.1s"/>
|
||||
<rect class="dot dim" x="343.5" y="212.5" width="15" height="15" style="animation-delay:0.15s"/>
|
||||
<rect class="dot dim" x="423.5" y="212.5" width="15" height="15" style="animation-delay:0.2s"/>
|
||||
<rect class="dot dim" x="262.5" y="292.5" width="15" height="15" style="animation-delay:0.15s"/>
|
||||
<rect class="dot dim" x="262.5" y="372.5" width="15" height="15" style="animation-delay:0.2s"/>
|
||||
<rect class="dot dim" x="343.5" y="372.5" width="15" height="15" style="animation-delay:0.25s"/>
|
||||
<rect class="dot dim" x="423.5" y="372.5" width="15" height="15" style="animation-delay:0.3s"/>
|
||||
<rect class="dot dim" x="182.5" y="452.5" width="15" height="15" style="animation-delay:0.2s"/>
|
||||
<rect class="dot dim" x="503.5" y="452.5" width="15" height="15" style="animation-delay:0.4s"/>
|
||||
</g>
|
||||
|
||||
<!-- nodes (black base) -->
|
||||
<g>
|
||||
<rect class="node ink" x="248" y="118" width="44" height="44" style="animation-delay:0.05s"/>
|
||||
<rect class="node ink" x="329" y="118" width="44" height="44" style="animation-delay:0.1s"/>
|
||||
<rect class="node ink" x="409" y="118" width="44" height="44" style="animation-delay:0.15s"/>
|
||||
<rect class="node ink" x="168" y="198" width="44" height="44" style="animation-delay:0.05s"/>
|
||||
<rect class="node ink" x="489" y="198" width="44" height="44" style="animation-delay:0.25s"/>
|
||||
<rect class="node ink" x="168" y="278" width="44" height="44" style="animation-delay:0.1s"/>
|
||||
<rect class="node ink" x="329" y="278" width="44" height="44" style="animation-delay:0.2s"/>
|
||||
<rect class="node ink" x="409" y="278" width="44" height="44" style="animation-delay:0.25s"/>
|
||||
<rect class="node ink" x="168" y="358" width="44" height="44" style="animation-delay:0.15s"/>
|
||||
<rect class="node ink" x="489" y="358" width="44" height="44" style="animation-delay:0.35s"/>
|
||||
<rect class="node ink" x="248" y="438" width="44" height="44" style="animation-delay:0.25s"/>
|
||||
<rect class="node ink" x="329" y="438" width="44" height="44" style="animation-delay:0.3s"/>
|
||||
<rect class="node ink" x="409" y="438" width="44" height="44" style="animation-delay:0.35s"/>
|
||||
<rect class="node ink" x="489" y="278" width="44" height="44" style="animation-delay:0.3s"/>
|
||||
</g>
|
||||
|
||||
<!-- green light : home + ring positions -->
|
||||
<g class="ghome-pop">
|
||||
<g class="c0">
|
||||
<rect class="glow" x="489" y="278" width="44" height="44" filter="url(#soft)"/>
|
||||
<rect class="grn" x="489" y="278" width="44" height="44"/>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<g class="c1">
|
||||
<rect class="glow" x="489" y="358" width="44" height="44" filter="url(#soft)"/>
|
||||
<rect class="grn" x="489" y="358" width="44" height="44"/>
|
||||
</g>
|
||||
<g class="c2">
|
||||
<rect class="glow" x="409" y="438" width="44" height="44" filter="url(#soft)"/>
|
||||
<rect class="grn" x="409" y="438" width="44" height="44"/>
|
||||
</g>
|
||||
<g class="c3">
|
||||
<rect class="glow" x="329" y="438" width="44" height="44" filter="url(#soft)"/>
|
||||
<rect class="grn" x="329" y="438" width="44" height="44"/>
|
||||
</g>
|
||||
<g class="c4">
|
||||
<rect class="glow" x="248" y="438" width="44" height="44" filter="url(#soft)"/>
|
||||
<rect class="grn" x="248" y="438" width="44" height="44"/>
|
||||
</g>
|
||||
<g class="c5">
|
||||
<rect class="glow" x="168" y="358" width="44" height="44" filter="url(#soft)"/>
|
||||
<rect class="grn" x="168" y="358" width="44" height="44"/>
|
||||
</g>
|
||||
<g class="c6">
|
||||
<rect class="glow" x="168" y="278" width="44" height="44" filter="url(#soft)"/>
|
||||
<rect class="grn" x="168" y="278" width="44" height="44"/>
|
||||
</g>
|
||||
<g class="c7">
|
||||
<rect class="glow" x="168" y="198" width="44" height="44" filter="url(#soft)"/>
|
||||
<rect class="grn" x="168" y="198" width="44" height="44"/>
|
||||
</g>
|
||||
<g class="c8">
|
||||
<rect class="glow" x="248" y="118" width="44" height="44" filter="url(#soft)"/>
|
||||
<rect class="grn" x="248" y="118" width="44" height="44"/>
|
||||
</g>
|
||||
<g class="c9">
|
||||
<rect class="glow" x="329" y="118" width="44" height="44" filter="url(#soft)"/>
|
||||
<rect class="grn" x="329" y="118" width="44" height="44"/>
|
||||
</g>
|
||||
<g class="c10">
|
||||
<rect class="glow" x="409" y="118" width="44" height="44" filter="url(#soft)"/>
|
||||
<rect class="grn" x="409" y="118" width="44" height="44"/>
|
||||
</g>
|
||||
<g class="c11">
|
||||
<rect class="glow" x="489" y="198" width="44" height="44" filter="url(#soft)"/>
|
||||
<rect class="grn" x="489" y="198" width="44" height="44"/>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- query dot -->
|
||||
<circle class="qpulse pulse" r="6" filter="url(#soft)">
|
||||
<animateMotion dur="6.0s" begin="1.7s" repeatCount="indefinite"
|
||||
path="M190 300 L511 300" keyPoints="0;1;1;0" keyTimes="0;0.16;0.84;1"
|
||||
calcMode="spline" keySplines="0.4 0 0.2 1; 0 0 1 1; 0.4 0 0.2 1"/>
|
||||
</circle>
|
||||
|
||||
<!-- wordmark -->
|
||||
<g class="wordmark">
|
||||
<g class="ink" transform="translate(661,245) scale(0.249579)">
|
||||
<g transform="translate(-24.085142,595.789232) scale(0.100000,-0.100000)" stroke="none">
|
||||
<path d="M12970 5950 l-385 -5 -3 -479 c-1 -334 -6 -483 -13 -493 -9 -10 -99 -13 -469 -13 -375 0 -460 -2 -469 -14 -8 -9 -11 -116 -9 -372 l3 -359 468 -5 c401 -4 469 -7 477 -20 6 -9 10 -357 10 -890 1 -907 7 -1090 44 -1185 8 -22 15 -49 16 -60 0 -37 99 -219 157 -292 55 -68 190 -185 243 -211 14 -6 32 -16 40 -20 102 -59 264 -104 440 -122 144 -15 1143 -13 1158 2 18 18 17 711 -1 726 -8 7 -196 11 -544 14 l-531 3 -31 23 c-16 12 -35 22 -40 22 -15 0 -80 81 -104 130 l-22 45 -3 904 c-2 671 0 907 9 917 10 12 120 14 670 14 643 0 659 0 669 20 7 12 9 140 8 372 l-3 353 -675 5 -675 5 -3 485 c-2 442 -4 486 -19 498 -9 6 -19 11 -22 10 -3 -2 -179 -5 -391 -8z"/>
|
||||
<path d="M1285 5009 c-60 -5 -139 -18 -175 -29 -36 -10 -81 -23 -100 -29 -55 -15 -106 -36 -121 -48 -8 -6 -40 -24 -71 -41 -96 -51 -237 -174 -292 -255 -19 -29 -55 -81 -80 -117 -25 -36 -46 -71 -46 -78 0 -8 -7 -26 -14 -40 -63 -114 -116 -322 -136 -528 -12 -127 -12 -917 0 -1054 5 -57 15 -129 21 -160 23 -114 62 -263 76 -293 9 -18 24 -49 34 -69 11 -20 19 -43 19 -50 0 -13 10 -29 102 -166 48 -71 217 -236 266 -259 15 -7 45 -24 67 -37 68 -41 143 -76 162 -76 10 0 41 -9 68 -19 78 -29 311 -55 435 -48 182 11 297 40 440 110 70 35 102 60 185 142 87 86 107 113 148 195 26 52 47 104 47 115 0 11 5 27 10 35 26 39 30 -22 25 -365 -6 -394 -11 -486 -29 -565 -32 -142 -115 -238 -251 -296 -104 -45 -143 -47 -740 -53 l-570 -6 -3 -334 c-2 -261 1 -336 10 -343 19 -12 1018 -10 1165 3 175 14 309 34 362 53 24 9 54 16 68 16 13 0 41 9 61 19 61 31 132 61 145 61 7 0 44 22 82 50 39 27 90 64 115 81 78 54 194 189 229 264 5 12 16 27 25 34 9 7 16 19 16 25 1 6 9 29 20 51 11 22 20 47 20 55 0 8 9 33 20 55 11 22 20 54 20 70 0 17 9 68 20 115 19 83 19 137 20 1907 0 1643 -2 1823 -16 1837 -13 14 -63 16 -397 16 -210 0 -388 -4 -396 -9 -11 -7 -12 -49 -3 -251 6 -133 8 -248 5 -256 -8 -22 -22 -16 -29 14 -4 15 -27 65 -52 112 -58 112 -140 208 -232 274 -42 30 -209 116 -226 116 -6 0 -38 8 -70 19 -54 17 -121 25 -289 35 -33 2 -109 0 -170 -5z m561 -723 c143 -25 280 -108 356 -213 41 -58 92 -162 106 -218 21 -82 32 -266 32 -544 0 -309 -16 -519 -45 -586 -72 -166 -149 -257 -273 -322 -45 -24 -90 -43 -102 -43 -11 0 -37 -7 -58 -16 -52 -22 -269 -22 -331 0 -24 9 -52 16 -61 16 -53 0 -197 99 -261 179 -121 152 -149 296 -149 776 0 480 27 622 148 773 47 59 80 90 122 113 19 10 42 23 50 29 98 61 298 85 466 56z"/>
|
||||
<path d="M9675 5005 c-71 -7 -150 -20 -174 -29 -24 -9 -53 -16 -64 -16 -31 0 -186 -79 -242 -124 -77 -61 -125 -107 -151 -146 -13 -19 -36 -53 -51 -75 -34 -48 -63 -111 -72 -156 -4 -20 -13 -34 -21 -34 -13 0 -16 40 -20 253 -5 236 -6 255 -24 268 -16 11 -84 14 -374 14 -224 0 -360 -4 -373 -10 -19 -11 -19 -41 -19 -1765 0 -1586 2 -1755 16 -1769 14 -14 62 -16 412 -14 l397 3 5 1190 c5 1184 5 1190 27 1265 28 97 58 163 108 231 39 55 125 132 161 145 10 4 26 13 34 20 8 7 51 24 95 38 70 23 98 26 220 26 117 0 151 -4 205 -21 36 -12 74 -27 85 -32 11 -6 36 -19 56 -29 48 -23 145 -112 177 -163 91 -141 112 -219 119 -440 6 -169 7 -180 29 -202 l23 -23 400 0 c386 0 401 1 411 19 13 25 13 230 0 381 -13 156 -29 253 -51 329 -10 35 -19 72 -19 82 0 18 -59 154 -102 234 -52 97 -154 220 -247 297 -53 44 -110 86 -127 93 -17 7 -35 18 -38 24 -4 6 -14 11 -23 11 -9 0 -24 7 -33 15 -18 16 -149 65 -175 65 -8 0 -41 9 -72 19 -35 11 -102 22 -165 25 -59 3 -131 8 -160 10 -29 2 -111 -2 -183 -9z"/>
|
||||
<path d="M16955 5009 c-239 -16 -451 -76 -650 -185 -110 -61 -110 -61 -195 -131 -142 -116 -256 -260 -327 -413 -55 -118 -63 -138 -63 -157 0 -10 -9 -38 -20 -62 -11 -24 -20 -59 -20 -78 0 -19 -8 -63 -17 -97 -16 -55 -18 -122 -18 -706 0 -584 2 -651 18 -706 9 -34 17 -78 17 -97 0 -19 9 -54 20 -78 11 -24 20 -52 20 -62 0 -37 82 -205 146 -302 122 -184 287 -329 484 -425 132 -65 148 -70 340 -124 56 -16 170 -30 336 -41 124 -9 421 12 510 36 38 10 83 19 98 19 15 0 44 9 66 20 22 11 46 20 54 20 23 0 168 67 251 116 22 13 51 29 65 36 21 10 124 90 140 108 3 4 14 13 26 21 32 22 165 180 199 235 45 75 85 155 85 171 0 8 8 30 19 51 10 20 22 56 26 79 4 24 11 45 16 49 18 11 9 44 -13 49 -13 3 -196 4 -407 3 l-384 -3 -28 -50 c-15 -27 -28 -58 -28 -67 -1 -20 -159 -158 -181 -158 -9 0 -24 -6 -35 -13 -11 -7 -51 -24 -90 -37 -60 -20 -95 -24 -250 -27 -162 -4 -186 -2 -244 16 -35 12 -71 21 -81 21 -10 0 -23 6 -29 14 -7 8 -24 17 -38 21 -64 16 -253 206 -253 254 0 9 -7 26 -15 38 -8 12 -27 65 -40 118 -24 90 -25 111 -23 293 l3 197 1069 3 c828 2 1072 5 1084 14 13 11 14 71 10 443 -6 463 -7 470 -70 680 -51 172 -173 370 -313 510 -47 47 -187 155 -230 178 -258 138 -433 188 -715 206 -91 6 -172 10 -180 10 -8 -1 -73 -6 -145 -10z m433 -672 c38 -13 76 -28 83 -34 8 -6 30 -18 49 -26 70 -31 170 -142 213 -237 64 -142 63 -134 81 -340 11 -119 11 -149 1 -166 -13 -20 -22 -21 -407 -28 -528 -8 -945 -9 -958 -1 -6 4 -10 58 -10 140 0 111 4 152 25 231 14 52 32 105 40 117 8 12 15 29 15 38 0 45 161 216 245 259 123 64 216 80 416 74 107 -3 152 -9 207 -27z"/>
|
||||
<path d="M5345 5000 c-60 -4 -141 -15 -180 -24 -123 -30 -219 -57 -260 -76 -96 -43 -202 -97 -233 -117 -18 -13 -35 -23 -38 -23 -4 0 -41 -29 -83 -64 -137 -115 -256 -264 -322 -405 -62 -132 -69 -148 -69 -162 0 -8 -9 -41 -19 -74 -21 -64 -38 -155 -51 -275 -11 -98 -11 -1102 0 -1200 13 -120 30 -211 51 -275 10 -33 19 -66 19 -73 0 -14 9 -37 63 -152 94 -202 292 -419 482 -526 143 -81 271 -128 475 -176 87 -20 121 -22 390 -22 268 0 304 2 390 22 117 27 222 59 275 82 22 10 65 28 95 41 30 13 66 31 80 40 14 9 45 29 70 45 185 119 331 287 431 494 49 101 75 177 111 330 23 94 23 102 23 770 0 668 0 676 -23 770 -36 152 -62 229 -106 320 -125 259 -317 454 -571 578 -135 66 -216 93 -381 128 -127 26 -428 38 -619 24z m469 -745 c91 -30 132 -52 197 -104 49 -39 149 -166 149 -190 0 -6 9 -28 20 -48 11 -21 20 -47 20 -58 0 -11 7 -52 15 -90 12 -57 15 -168 15 -585 0 -417 -3 -528 -15 -585 -8 -38 -15 -79 -15 -90 0 -11 -9 -37 -20 -58 -11 -20 -20 -42 -20 -47 0 -24 -95 -148 -143 -188 -54 -44 -136 -88 -222 -118 -40 -14 -85 -17 -225 -17 -158 -1 -181 2 -240 23 -91 32 -137 56 -195 104 -95 76 -157 170 -197 298 -23 73 -23 76 -23 678 0 602 0 605 23 678 76 241 258 394 502 423 112 13 294 0 374 -26z"/>
|
||||
<path d="M19365 4950 c-4 -6 15 -42 42 -81 26 -38 59 -87 73 -109 14 -21 52 -78 85 -125 33 -48 65 -94 70 -103 6 -9 23 -34 38 -56 47 -69 81 -120 92 -138 5 -9 61 -92 124 -185 133 -195 329 -485 351 -518 8 -13 68 -102 133 -198 127 -189 135 -213 94 -264 -23 -28 -101 -141 -147 -213 -14 -21 -70 -105 -125 -185 -55 -81 -105 -155 -110 -165 -6 -10 -17 -26 -25 -35 -8 -9 -19 -25 -25 -35 -9 -17 -96 -145 -326 -483 -52 -77 -98 -146 -104 -155 -5 -8 -23 -34 -38 -56 -42 -61 -80 -119 -92 -137 -13 -22 -93 -139 -149 -218 -24 -35 -42 -70 -39 -77 4 -12 86 -14 459 -14 420 0 455 1 470 18 16 17 104 151 104 157 0 2 22 36 49 77 43 65 98 150 165 258 13 19 41 63 64 98 23 34 42 66 42 71 0 5 7 15 16 22 9 7 20 22 25 34 5 11 32 54 59 95 28 41 53 82 57 90 3 8 12 22 19 30 7 9 25 38 40 65 81 151 104 197 104 208 0 7 4 18 9 26 15 23 37 -2 83 -93 57 -116 118 -226 156 -280 17 -26 32 -51 32 -55 0 -4 9 -16 20 -26 11 -10 20 -22 20 -27 0 -4 26 -48 57 -96 68 -102 72 -109 97 -152 10 -19 34 -55 52 -82 19 -26 34 -50 34 -53 0 -3 8 -18 18 -33 49 -72 102 -153 102 -157 0 -5 85 -135 110 -168 l22 -27 451 2 452 3 3 23 c2 13 -5 34 -15 45 -17 20 -156 223 -223 327 -14 21 -52 78 -85 125 -33 48 -65 94 -70 103 -6 10 -24 37 -40 61 -17 24 -46 67 -65 96 -19 29 -48 72 -65 96 -16 24 -34 52 -40 61 -9 15 -69 104 -409 606 -72 107 -136 202 -142 213 -6 10 -18 27 -27 37 -19 21 -22 57 -7 67 9 6 28 32 75 106 5 9 23 36 40 60 16 24 34 51 40 61 5 9 55 82 110 163 55 80 111 163 125 185 14 21 124 186 245 365 121 180 227 337 235 350 8 13 66 98 128 191 71 106 112 175 110 188 -3 21 -6 21 -454 24 l-451 2 -25 -37 c-22 -34 -57 -89 -123 -193 -12 -19 -40 -63 -63 -98 -23 -34 -42 -65 -42 -67 0 -3 -15 -27 -34 -53 -18 -27 -42 -63 -52 -82 -26 -45 -81 -133 -134 -210 -9 -14 -19 -32 -23 -40 -4 -8 -20 -35 -36 -60 -17 -25 -44 -74 -62 -110 -18 -36 -37 -72 -44 -80 -7 -8 -15 -26 -18 -40 -15 -60 -77 -102 -77 -52 0 16 -67 153 -116 237 -13 22 -34 57 -46 79 -13 21 -28 47 -34 57 -6 11 -18 27 -27 37 -10 10 -17 22 -17 26 0 4 -18 34 -40 67 -22 33 -40 62 -40 64 0 3 -19 33 -42 67 -23 35 -51 79 -63 98 -13 19 -39 62 -60 95 -21 33 -46 72 -56 87 -11 15 -19 30 -19 33 0 3 -8 18 -19 33 -10 15 -33 50 -51 79 -18 28 -42 54 -54 57 -40 11 -904 7 -911 -4z"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 19 KiB |
@@ -0,0 +1,76 @@
|
||||
# Retrieval baselines bench
|
||||
|
||||
Reproducible per-baseline NDCG@10 + latency table across six
|
||||
candidate retrieval systems. The harness ships **six** adapters
|
||||
behind a single uniform Go-side surface; per-adapter `Available()`
|
||||
checks let `--smoke` verify wiring without paying for the heavy
|
||||
ones.
|
||||
|
||||
Supported adapters:
|
||||
|
||||
| Adapter | Type | Install |
|
||||
|----------------|---------------|--------------------------------------------------------------------|
|
||||
| ripgrep | Go-native | `brew install ripgrep` (or distro equivalent) |
|
||||
| probe | Go-native | `cargo install probe-ai` |
|
||||
| colgrep | Python | `pip install colgrep` (requires CUDA-capable GPU) |
|
||||
| grepai | Python | `pip install grepai-cli` |
|
||||
| coderankembed | Python+model | `pip install sentence-transformers transformers torch` (model ~440MB on first run) |
|
||||
| semble | Python | `pip install semble` |
|
||||
|
||||
## Running
|
||||
|
||||
```sh
|
||||
# Smoke check: report which adapters are available right now
|
||||
go run ./bench/baselines -smoke
|
||||
|
||||
# Full table against the gortex repo itself, all adapters
|
||||
go run ./bench/baselines
|
||||
|
||||
# Just the locally-installable ones (ripgrep + probe)
|
||||
go run ./bench/baselines -against ripgrep,probe
|
||||
|
||||
# JSON output for downstream tooling
|
||||
go run ./bench/baselines -format json -json bench/results/baselines.json
|
||||
```
|
||||
|
||||
Flags:
|
||||
|
||||
- `-repo PATH` — corpus to query (default `.`)
|
||||
- `-queries PATH` — JSON query set (default `queries.json` here)
|
||||
- `-groundtruth PATH` — JSON per-query expected file paths (default
|
||||
`groundtruth.json` here)
|
||||
- `-against LIST` — comma-separated adapter names; unknown names
|
||||
error out so a typo doesn't silently drop a baseline
|
||||
- `-top-k N` — top-K per query (default 10, matches NDCG@10)
|
||||
- `-out PATH` — primary output (default stdout)
|
||||
- `-json PATH` — companion JSON metrics output
|
||||
- `-format markdown|json` — primary format
|
||||
- `-smoke` — skip runs; only probe `Available()` for each adapter
|
||||
- `-timeout DURATION` — per-adapter wall-clock cap (default 5m)
|
||||
|
||||
## How NDCG@10 is computed
|
||||
|
||||
For each query the harness computes:
|
||||
|
||||
```
|
||||
DCG@10 = Σ (gain_i / log2(i + 2)) for i in [0, min(K, |hits|))
|
||||
IDCG@10 = Σ (1 / log2(i + 2)) for i in [0, min(K, |expected|))
|
||||
NDCG@10 = DCG@10 / IDCG@10
|
||||
```
|
||||
|
||||
with `gain_i = 1` when `hits[i]` is in the ground-truth set,
|
||||
`0` otherwise. The reported number is the **mean** across queries
|
||||
with a non-empty ground-truth entry. Queries the adapter failed
|
||||
on (Error set) are skipped from the mean — they're shown in the
|
||||
per-query JSON for debugging but don't penalize the headline.
|
||||
|
||||
## Adding an adapter
|
||||
|
||||
1. Add a struct in `adapters.go` that satisfies the `adapter`
|
||||
interface (`Name`, `Available`, `Run`).
|
||||
2. Register it in `allAdapters()` — that single list drives the CLI,
|
||||
the smoke output, and `adapterByName`.
|
||||
3. For Python baselines: embed `pythonBaselineAdapter` and call
|
||||
`pythonModuleAvailable` / `runPythonModule` (or
|
||||
`runPythonScript` if you need a custom wrapper). Document the
|
||||
install in the table above.
|
||||
@@ -0,0 +1,333 @@
|
||||
// Package main defines the baseline adapter framework: a single Go
|
||||
// surface that uniformly invokes external retrieval baselines
|
||||
// (ripgrep, probe, ColGREP, grepai, CodeRankEmbed Hybrid, semble)
|
||||
// against a shared query set, then reports per-baseline NDCG@10 +
|
||||
// latency in a comparable table.
|
||||
//
|
||||
// Per-adapter contract:
|
||||
//
|
||||
// - Name() canonical baseline name, used in CLI / table cols
|
||||
// - Available() cheap probe: does this baseline run on this box?
|
||||
// - Run(queries) per-query result list; honest empty-on-failure
|
||||
//
|
||||
// Go-native baselines (ripgrep, probe) shell to their respective
|
||||
// binaries. Python-heavy ones (ColGREP, grepai, CodeRankEmbed) shell
|
||||
// to `python3 -m <module>` and require an explicit per-package install
|
||||
// that the harness documents in bench/baselines/README.md but does
|
||||
// NOT attempt automatically. This keeps `gortex eval baselines
|
||||
// --smoke` cheap on a fresh CI runner — it reports each baseline's
|
||||
// availability without trying to install anything.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// adapter is the interface every baseline implementation satisfies.
|
||||
type adapter interface {
|
||||
Name() string
|
||||
Available() (bool, string)
|
||||
Run(ctx context.Context, queries []string, repoRoot string, topK int) []queryResult
|
||||
}
|
||||
|
||||
// queryResult is the per-query outcome from one adapter.
|
||||
type queryResult struct {
|
||||
Query string `json:"query"`
|
||||
Hits []string `json:"hits"`
|
||||
Latency time.Duration `json:"-"`
|
||||
LatencyMs float64 `json:"latency_ms"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// allAdapters returns the canonical adapter set, in stable order.
|
||||
// Adding a new baseline = adding one line here + one adapter
|
||||
// implementation. The smoke check uses this list directly so the
|
||||
// CLI never drifts from the registry.
|
||||
func allAdapters() []adapter {
|
||||
return []adapter{
|
||||
&ripgrepAdapter{},
|
||||
&probeAdapter{},
|
||||
&colgrepAdapter{},
|
||||
&grepaiAdapter{},
|
||||
&coderankAdapter{},
|
||||
&sembleAdapter{},
|
||||
}
|
||||
}
|
||||
|
||||
// adapterByName looks up one adapter by canonical name; nil when no
|
||||
// match (the caller should check before invoking).
|
||||
func adapterByName(name string) adapter {
|
||||
for _, a := range allAdapters() {
|
||||
if strings.EqualFold(a.Name(), name) {
|
||||
return a
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- ripgrep --------------------------------------------------------
|
||||
|
||||
type ripgrepAdapter struct{}
|
||||
|
||||
func (*ripgrepAdapter) Name() string { return "ripgrep" }
|
||||
|
||||
func (*ripgrepAdapter) Available() (bool, string) {
|
||||
if _, err := exec.LookPath("rg"); err != nil {
|
||||
return false, "rg not on PATH"
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
|
||||
func (*ripgrepAdapter) Run(ctx context.Context, queries []string, repoRoot string, topK int) []queryResult {
|
||||
out := make([]queryResult, 0, len(queries))
|
||||
for _, q := range queries {
|
||||
start := time.Now()
|
||||
cmd := exec.CommandContext(ctx, "rg", "--files-with-matches", q, repoRoot)
|
||||
var buf bytes.Buffer
|
||||
cmd.Stdout = &buf
|
||||
cmd.Stderr = io.Discard
|
||||
err := cmd.Run()
|
||||
latency := time.Since(start)
|
||||
r := queryResult{Query: q, Latency: latency, LatencyMs: msFrom(latency)}
|
||||
if err != nil {
|
||||
if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() == 1 {
|
||||
// rg exit code 1 = no matches; not an error.
|
||||
out = append(out, r)
|
||||
continue
|
||||
}
|
||||
r.Error = err.Error()
|
||||
out = append(out, r)
|
||||
continue
|
||||
}
|
||||
hits := parseLines(buf.String(), repoRoot, topK)
|
||||
r.Hits = hits
|
||||
out = append(out, r)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// --- probe ----------------------------------------------------------
|
||||
|
||||
type probeAdapter struct{}
|
||||
|
||||
func (*probeAdapter) Name() string { return "probe" }
|
||||
|
||||
func (*probeAdapter) Available() (bool, string) {
|
||||
if _, err := exec.LookPath("probe"); err != nil {
|
||||
return false, "probe binary not on PATH (install: cargo install probe-ai)"
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
|
||||
func (*probeAdapter) Run(ctx context.Context, queries []string, repoRoot string, topK int) []queryResult {
|
||||
out := make([]queryResult, 0, len(queries))
|
||||
for _, q := range queries {
|
||||
start := time.Now()
|
||||
cmd := exec.CommandContext(ctx, "probe", "search", "--paths-only", q, repoRoot)
|
||||
var buf bytes.Buffer
|
||||
cmd.Stdout = &buf
|
||||
cmd.Stderr = io.Discard
|
||||
err := cmd.Run()
|
||||
latency := time.Since(start)
|
||||
r := queryResult{Query: q, Latency: latency, LatencyMs: msFrom(latency)}
|
||||
if err != nil {
|
||||
r.Error = err.Error()
|
||||
out = append(out, r)
|
||||
continue
|
||||
}
|
||||
r.Hits = parseLines(buf.String(), repoRoot, topK)
|
||||
out = append(out, r)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// --- ColGREP --------------------------------------------------------
|
||||
|
||||
type colgrepAdapter struct{ pythonBaselineAdapter }
|
||||
|
||||
func (*colgrepAdapter) Name() string { return "colgrep" }
|
||||
|
||||
func (a *colgrepAdapter) Available() (bool, string) {
|
||||
return a.pythonModuleAvailable("colgrep",
|
||||
"pip install colgrep (requires CUDA-capable GPU for the bundled ONNX model)")
|
||||
}
|
||||
|
||||
func (a *colgrepAdapter) Run(ctx context.Context, queries []string, repoRoot string, topK int) []queryResult {
|
||||
return a.runPythonModule(ctx, "colgrep", queries, repoRoot, topK)
|
||||
}
|
||||
|
||||
// --- grepai ---------------------------------------------------------
|
||||
|
||||
type grepaiAdapter struct{ pythonBaselineAdapter }
|
||||
|
||||
func (*grepaiAdapter) Name() string { return "grepai" }
|
||||
|
||||
func (a *grepaiAdapter) Available() (bool, string) {
|
||||
return a.pythonModuleAvailable("grepai_cli",
|
||||
"pip install grepai-cli")
|
||||
}
|
||||
|
||||
func (a *grepaiAdapter) Run(ctx context.Context, queries []string, repoRoot string, topK int) []queryResult {
|
||||
return a.runPythonModule(ctx, "grepai_cli", queries, repoRoot, topK)
|
||||
}
|
||||
|
||||
// --- CodeRankEmbed Hybrid -------------------------------------------
|
||||
|
||||
type coderankAdapter struct{ pythonBaselineAdapter }
|
||||
|
||||
func (*coderankAdapter) Name() string { return "coderankembed" }
|
||||
|
||||
func (a *coderankAdapter) Available() (bool, string) {
|
||||
// CodeRankEmbed ships as a model on Hugging Face; the bench
|
||||
// invoker is a small Python script we ship in
|
||||
// bench/baselines/python/coderankembed_runner.py. The script
|
||||
// requires `pip install sentence-transformers transformers
|
||||
// torch` and is documented in bench/baselines/README.md.
|
||||
return a.pythonModuleAvailable("sentence_transformers",
|
||||
"pip install sentence-transformers transformers torch (multi-GB model download on first run)")
|
||||
}
|
||||
|
||||
func (a *coderankAdapter) Run(ctx context.Context, queries []string, repoRoot string, topK int) []queryResult {
|
||||
return a.runPythonScript(ctx, "bench/baselines/python/coderankembed_runner.py", queries, repoRoot, topK)
|
||||
}
|
||||
|
||||
// --- semble ---------------------------------------------------------
|
||||
|
||||
type sembleAdapter struct{ pythonBaselineAdapter }
|
||||
|
||||
func (*sembleAdapter) Name() string { return "semble" }
|
||||
|
||||
func (a *sembleAdapter) Available() (bool, string) {
|
||||
return a.pythonModuleAvailable("semble",
|
||||
"pip install semble")
|
||||
}
|
||||
|
||||
func (a *sembleAdapter) Run(ctx context.Context, queries []string, repoRoot string, topK int) []queryResult {
|
||||
return a.runPythonModule(ctx, "semble.cli", queries, repoRoot, topK)
|
||||
}
|
||||
|
||||
// --- shared Python-adapter plumbing ---------------------------------
|
||||
|
||||
// pythonBaselineAdapter embeds the common shell-out logic that all
|
||||
// Python-based baselines reuse. Per-baseline behaviour comes from
|
||||
// the module name + (optional) wrapper script path.
|
||||
type pythonBaselineAdapter struct{}
|
||||
|
||||
// pythonModuleAvailable runs `python3 -c "import <mod>"`. Returns
|
||||
// true when the import succeeds; otherwise returns false with the
|
||||
// install hint so the smoke output is actionable.
|
||||
func (pythonBaselineAdapter) pythonModuleAvailable(module, installHint string) (bool, string) {
|
||||
pythons := []string{"python3", "python"}
|
||||
var lastErr string
|
||||
for _, py := range pythons {
|
||||
if _, err := exec.LookPath(py); err != nil {
|
||||
continue
|
||||
}
|
||||
cmd := exec.Command(py, "-c", "import "+module)
|
||||
cmd.Stderr = io.Discard
|
||||
if err := cmd.Run(); err == nil {
|
||||
return true, ""
|
||||
} else {
|
||||
lastErr = err.Error()
|
||||
}
|
||||
}
|
||||
if lastErr == "" {
|
||||
return false, "python3 not on PATH"
|
||||
}
|
||||
return false, fmt.Sprintf("python module %q not importable (%s)", module, installHint)
|
||||
}
|
||||
|
||||
// runPythonModule invokes the module's CLI via `python3 -m <module>`,
|
||||
// forwarding the queries on stdin one per line. Mirrors how most
|
||||
// Python retrieval baselines we wrap accept input.
|
||||
func (pythonBaselineAdapter) runPythonModule(ctx context.Context, module string, queries []string, repoRoot string, topK int) []queryResult {
|
||||
return pythonBaselineRun(ctx, []string{"-m", module}, queries, repoRoot, topK)
|
||||
}
|
||||
|
||||
// runPythonScript invokes a wrapper script with the same I/O
|
||||
// convention. The script lives under bench/baselines/python/ and is
|
||||
// shipped with the harness so each adapter has a known-good entry
|
||||
// point.
|
||||
func (pythonBaselineAdapter) runPythonScript(ctx context.Context, scriptPath string, queries []string, repoRoot string, topK int) []queryResult {
|
||||
return pythonBaselineRun(ctx, []string{scriptPath}, queries, repoRoot, topK)
|
||||
}
|
||||
|
||||
// pythonBaselineRun is the shared dispatcher: pipes queries to the
|
||||
// Python process on stdin and parses one-hit-per-line JSON from
|
||||
// stdout. Each baseline's wrapper is responsible for emitting the
|
||||
// canonical JSON shape: {"query": "...", "hits": ["path1", ...]}.
|
||||
func pythonBaselineRun(ctx context.Context, args []string, queries []string, repoRoot string, topK int) []queryResult {
|
||||
out := make([]queryResult, 0, len(queries))
|
||||
for _, q := range queries {
|
||||
start := time.Now()
|
||||
// We invoke the script once per query so a per-query
|
||||
// failure doesn't drag the whole run. Higher overhead than
|
||||
// streaming but fundamentally honest.
|
||||
baseArgs := append([]string{}, args...)
|
||||
baseArgs = append(baseArgs, "--repo", repoRoot, "--top-k", fmt.Sprintf("%d", topK), "--query", q)
|
||||
cmd := exec.CommandContext(ctx, "python3", baseArgs...)
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
err := cmd.Run()
|
||||
latency := time.Since(start)
|
||||
r := queryResult{Query: q, Latency: latency, LatencyMs: msFrom(latency)}
|
||||
if err != nil {
|
||||
r.Error = fmt.Sprintf("%v: %s", err, strings.TrimSpace(stderr.String()))
|
||||
out = append(out, r)
|
||||
continue
|
||||
}
|
||||
r.Hits = parseLines(stdout.String(), repoRoot, topK)
|
||||
out = append(out, r)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// --- helpers --------------------------------------------------------
|
||||
|
||||
// parseLines splits the adapter's stdout into one hit per line,
|
||||
// strips the repo-root prefix so all adapters return repo-relative
|
||||
// paths, dedups while preserving order, and caps at topK.
|
||||
func parseLines(s, repoRoot string, topK int) []string {
|
||||
seen := map[string]bool{}
|
||||
out := []string{}
|
||||
for line := range strings.SplitSeq(s, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
rel := strings.TrimPrefix(line, repoRoot+"/")
|
||||
if seen[rel] {
|
||||
continue
|
||||
}
|
||||
seen[rel] = true
|
||||
out = append(out, rel)
|
||||
if topK > 0 && len(out) >= topK {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func msFrom(d time.Duration) float64 {
|
||||
return float64(d.Microseconds()) / 1000.0
|
||||
}
|
||||
|
||||
// adapterNames returns the names in canonical order for table
|
||||
// rendering / smoke output.
|
||||
func adapterNames() []string {
|
||||
all := allAdapters()
|
||||
names := make([]string, len(all))
|
||||
for i, a := range all {
|
||||
names[i] = a.Name()
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"comment": "Per-query expected file paths against the gortex repo. NDCG@10 = mean over queries of DCG_at_10 / IDCG_at_10 where gain=1 for hits in this list and 0 otherwise. Mirrors the token-efficiency ground truth so the two benches are directly comparable.",
|
||||
"queries": {
|
||||
"AddObservation": [
|
||||
"internal/savings/store.go"
|
||||
],
|
||||
"IsSymbolQuery": [
|
||||
"internal/search/rerank/query_kind.go"
|
||||
],
|
||||
"FileCoherenceSignal": [
|
||||
"internal/search/rerank/signals_file_coherence.go"
|
||||
],
|
||||
"alphaFuse": [
|
||||
"internal/search/hybrid.go"
|
||||
],
|
||||
"savings dashboard rendering": [
|
||||
"cmd/gortex/savings.go",
|
||||
"internal/savings/events.go"
|
||||
],
|
||||
"rerank pipeline default signals": [
|
||||
"internal/search/rerank/pipeline.go"
|
||||
],
|
||||
"Indexer Index method": [
|
||||
"internal/indexer/indexer.go"
|
||||
],
|
||||
"graph build edges": [
|
||||
"internal/graph/graph.go"
|
||||
],
|
||||
"MCP server start": [
|
||||
"internal/mcp/server.go",
|
||||
"cmd/gortex/mcp.go"
|
||||
],
|
||||
"token counting tiktoken": [
|
||||
"internal/tokens/tokens.go"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
// Command baselines runs the per-baseline retrieval comparison:
|
||||
// dispatch the same query set through each registered adapter,
|
||||
// compare the per-query hit lists against a shared ground truth,
|
||||
// and emit a per-adapter NDCG@10 + latency table.
|
||||
//
|
||||
// Smoke mode (`--smoke`) skips actual runs and reports which adapters
|
||||
// are available on the local box — useful from CI to verify wiring
|
||||
// without paying for the heavy Python baselines.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
repo := flag.String("repo", ".", "indexed corpus path")
|
||||
queriesPath := flag.String("queries", "bench/baselines/queries.json", "JSON query set")
|
||||
truthPath := flag.String("groundtruth", "bench/baselines/groundtruth.json", "JSON per-query expected file paths")
|
||||
against := flag.String("against", "ripgrep,probe,colgrep,grepai,coderankembed,semble", "comma-separated adapter names to run (default: all)")
|
||||
topK := flag.Int("top-k", 10, "top-K candidates per query (NDCG@10 uses K=10)")
|
||||
out := flag.String("out", "", "output table path (default stdout)")
|
||||
jsonOut := flag.String("json", "", "optional JSON metrics output")
|
||||
format := flag.String("format", "markdown", "markdown | json")
|
||||
smoke := flag.Bool("smoke", false, "skip runs; only probe availability of each requested adapter")
|
||||
timeout := flag.Duration("timeout", 5*time.Minute, "per-adapter wall-clock cap")
|
||||
flag.Parse()
|
||||
|
||||
requested, err := resolveAdapters(*against)
|
||||
if err != nil {
|
||||
die("%v", err)
|
||||
}
|
||||
|
||||
if *smoke {
|
||||
smokeReport(requested, *format, *out)
|
||||
return
|
||||
}
|
||||
|
||||
absRepo, err := filepath.Abs(*repo)
|
||||
if err != nil {
|
||||
die("repo path: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(absRepo); err != nil {
|
||||
die("repo path: %v", err)
|
||||
}
|
||||
|
||||
queries, err := loadQueries(*queriesPath)
|
||||
if err != nil {
|
||||
die("queries: %v", err)
|
||||
}
|
||||
truth, err := loadGroundTruth(*truthPath)
|
||||
if err != nil {
|
||||
die("groundtruth: %v", err)
|
||||
}
|
||||
|
||||
rows := make([]adapterRow, 0, len(requested))
|
||||
for _, a := range requested {
|
||||
fmt.Fprintf(os.Stderr, "[baselines] %s ... ", a.Name())
|
||||
avail, why := a.Available()
|
||||
if !avail {
|
||||
fmt.Fprintf(os.Stderr, "skipped (%s)\n", why)
|
||||
rows = append(rows, adapterRow{
|
||||
Adapter: a.Name(),
|
||||
Skipped: why,
|
||||
})
|
||||
continue
|
||||
}
|
||||
t0 := time.Now()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
|
||||
results := a.Run(ctx, queries, absRepo, *topK)
|
||||
cancel()
|
||||
elapsed := time.Since(t0)
|
||||
|
||||
row := adapterRow{
|
||||
Adapter: a.Name(),
|
||||
PerQuery: results,
|
||||
TotalDuration: elapsed.String(),
|
||||
}
|
||||
row.MedianLatencyMs = medianLatency(results)
|
||||
row.NDCGAt10 = ndcgAt10(results, truth, *topK)
|
||||
fmt.Fprintf(os.Stderr, "NDCG@10 %.3f · median %.1fms · total %.1fs\n",
|
||||
row.NDCGAt10, row.MedianLatencyMs, elapsed.Seconds())
|
||||
rows = append(rows, row)
|
||||
}
|
||||
|
||||
var primary []byte
|
||||
switch strings.ToLower(*format) {
|
||||
case "markdown", "md":
|
||||
primary = []byte(renderMarkdown(rows))
|
||||
case "json":
|
||||
primary = mustMarshalJSON(rows)
|
||||
default:
|
||||
die("unknown --format %q", *format)
|
||||
}
|
||||
if err := writeOutput(*out, primary); err != nil {
|
||||
die("write output: %v", err)
|
||||
}
|
||||
if *jsonOut != "" {
|
||||
if err := writeOutput(*jsonOut, mustMarshalJSON(rows)); err != nil {
|
||||
die("write json: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// adapterRow is the per-baseline outcome with the columns the
|
||||
// published table cares about.
|
||||
type adapterRow struct {
|
||||
Adapter string `json:"adapter"`
|
||||
NDCGAt10 float64 `json:"ndcg_at_10"`
|
||||
MedianLatencyMs float64 `json:"median_latency_ms"`
|
||||
TotalDuration string `json:"total_duration"`
|
||||
PerQuery []queryResult `json:"per_query,omitempty"`
|
||||
Skipped string `json:"skipped,omitempty"`
|
||||
}
|
||||
|
||||
// resolveAdapters expands the --against flag into the requested
|
||||
// adapter set, preserving the caller's order. Unknown names error
|
||||
// out so a typo doesn't silently drop a baseline.
|
||||
func resolveAdapters(spec string) ([]adapter, error) {
|
||||
if strings.TrimSpace(spec) == "" {
|
||||
return allAdapters(), nil
|
||||
}
|
||||
var out []adapter
|
||||
for tok := range strings.SplitSeq(spec, ",") {
|
||||
name := strings.TrimSpace(tok)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
a := adapterByName(name)
|
||||
if a == nil {
|
||||
return nil, fmt.Errorf("unknown adapter %q (known: %s)",
|
||||
name, strings.Join(adapterNames(), ", "))
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return allAdapters(), nil
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// smokeReport prints one row per requested adapter showing
|
||||
// available / not-available with the install hint. Output respects
|
||||
// --format; default markdown.
|
||||
func smokeReport(adapters []adapter, format, out string) {
|
||||
if strings.ToLower(format) == "json" {
|
||||
rows := make([]map[string]any, 0, len(adapters))
|
||||
for _, a := range adapters {
|
||||
avail, why := a.Available()
|
||||
row := map[string]any{
|
||||
"adapter": a.Name(),
|
||||
"available": avail,
|
||||
}
|
||||
if why != "" {
|
||||
row["why"] = why
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
raw, _ := json.MarshalIndent(rows, "", " ")
|
||||
_ = writeOutput(out, append(raw, '\n'))
|
||||
return
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintln(&b, "# Baseline-adapter smoke check")
|
||||
fmt.Fprintln(&b)
|
||||
fmt.Fprintln(&b, "| adapter | available | reason |")
|
||||
fmt.Fprintln(&b, "|---------|:---------:|--------|")
|
||||
for _, a := range adapters {
|
||||
avail, why := a.Available()
|
||||
mark := "✗"
|
||||
if avail {
|
||||
mark = "✓"
|
||||
}
|
||||
if why == "" {
|
||||
why = "—"
|
||||
}
|
||||
fmt.Fprintf(&b, "| %s | %s | %s |\n", a.Name(), mark, why)
|
||||
}
|
||||
_ = writeOutput(out, []byte(b.String()))
|
||||
}
|
||||
|
||||
// ndcgAt10 computes NDCG@10 across the result set. Per query: gain
|
||||
// is 1 for ground-truth hits, 0 otherwise; DCG = sum gain_i /
|
||||
// log2(i+2); IDCG = DCG of the perfect ordering. Mean across
|
||||
// queries; 0 when the result set is empty.
|
||||
func ndcgAt10(results []queryResult, truth map[string][]string, k int) float64 {
|
||||
if len(results) == 0 {
|
||||
return 0
|
||||
}
|
||||
var sum float64
|
||||
counted := 0
|
||||
for _, r := range results {
|
||||
if r.Error != "" {
|
||||
continue
|
||||
}
|
||||
expected := truth[r.Query]
|
||||
if len(expected) == 0 {
|
||||
continue
|
||||
}
|
||||
expSet := map[string]bool{}
|
||||
for _, e := range expected {
|
||||
expSet[e] = true
|
||||
}
|
||||
// DCG@k
|
||||
var dcg float64
|
||||
hits := r.Hits
|
||||
if len(hits) > k {
|
||||
hits = hits[:k]
|
||||
}
|
||||
for i, h := range hits {
|
||||
if expSet[h] {
|
||||
dcg += 1.0 / math.Log2(float64(i+2))
|
||||
}
|
||||
}
|
||||
// IDCG@k: best case is min(len(expected), k) hits at the top.
|
||||
idealHits := min(len(expected), k)
|
||||
var idcg float64
|
||||
for i := range idealHits {
|
||||
idcg += 1.0 / math.Log2(float64(i+2))
|
||||
}
|
||||
if idcg == 0 {
|
||||
continue
|
||||
}
|
||||
sum += dcg / idcg
|
||||
counted++
|
||||
}
|
||||
if counted == 0 {
|
||||
return 0
|
||||
}
|
||||
return sum / float64(counted)
|
||||
}
|
||||
|
||||
func medianLatency(results []queryResult) float64 {
|
||||
vs := make([]float64, 0, len(results))
|
||||
for _, r := range results {
|
||||
if r.Error == "" {
|
||||
vs = append(vs, r.LatencyMs)
|
||||
}
|
||||
}
|
||||
if len(vs) == 0 {
|
||||
return 0
|
||||
}
|
||||
sort.Float64s(vs)
|
||||
return vs[len(vs)/2]
|
||||
}
|
||||
|
||||
// --- rendering ------------------------------------------------------
|
||||
|
||||
func renderMarkdown(rows []adapterRow) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintln(&b, "# Retrieval-baselines NDCG@10 + speed")
|
||||
fmt.Fprintln(&b)
|
||||
fmt.Fprintln(&b, "_NDCG@10 against the ground-truth set + median per-query latency for each baseline. Adapters reporting `skipped` weren't available on this box; see the install hints in `bench/baselines/README.md`._")
|
||||
fmt.Fprintln(&b)
|
||||
fmt.Fprintln(&b, "| adapter | NDCG@10 | median latency | total | status |")
|
||||
fmt.Fprintln(&b, "|---------|--------:|---------------:|------:|--------|")
|
||||
for _, r := range rows {
|
||||
if r.Skipped != "" {
|
||||
fmt.Fprintf(&b, "| %s | — | — | — | skipped: %s |\n", r.Adapter, r.Skipped)
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&b, "| %s | %.3f | %s | %s | ✓ |\n",
|
||||
r.Adapter, r.NDCGAt10, fmtMs(r.MedianLatencyMs), r.TotalDuration)
|
||||
}
|
||||
fmt.Fprintln(&b)
|
||||
fmt.Fprintln(&b, summaryLine(rows))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func summaryLine(rows []adapterRow) string {
|
||||
ran := 0
|
||||
bestName := ""
|
||||
var bestScore float64
|
||||
for _, r := range rows {
|
||||
if r.Skipped != "" {
|
||||
continue
|
||||
}
|
||||
ran++
|
||||
if r.NDCGAt10 > bestScore {
|
||||
bestScore = r.NDCGAt10
|
||||
bestName = r.Adapter
|
||||
}
|
||||
}
|
||||
if ran == 0 {
|
||||
return "_no adapters available — install at least one baseline (see bench/baselines/README.md)_"
|
||||
}
|
||||
return fmt.Sprintf("**Summary:** %d/%d adapter(s) ran. Best NDCG@10: %s @ %.3f.",
|
||||
ran, len(rows), bestName, bestScore)
|
||||
}
|
||||
|
||||
func fmtMs(v float64) string {
|
||||
if v == 0 {
|
||||
return "—"
|
||||
}
|
||||
if v < 1 {
|
||||
return fmt.Sprintf("%.2fms", v)
|
||||
}
|
||||
if v < 1000 {
|
||||
return fmt.Sprintf("%.1fms", v)
|
||||
}
|
||||
return fmt.Sprintf("%.2fs", v/1000.0)
|
||||
}
|
||||
|
||||
// --- helpers --------------------------------------------------------
|
||||
|
||||
func die(format string, args ...any) {
|
||||
fmt.Fprintf(os.Stderr, "baselines: "+format+"\n", args...)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func writeOutput(path string, body []byte) error {
|
||||
if path == "" {
|
||||
_, err := os.Stdout.Write(body)
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, body, 0o644)
|
||||
}
|
||||
|
||||
func mustMarshalJSON(rows []adapterRow) []byte {
|
||||
b, err := json.MarshalIndent(rows, "", " ")
|
||||
if err != nil {
|
||||
die("marshal json: %v", err)
|
||||
}
|
||||
return append(b, '\n')
|
||||
}
|
||||
|
||||
func loadQueries(path string) ([]string, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var doc struct {
|
||||
Queries []string `json:"queries"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &doc); err != nil {
|
||||
return nil, fmt.Errorf("parse %s: %w", path, err)
|
||||
}
|
||||
return doc.Queries, nil
|
||||
}
|
||||
|
||||
func loadGroundTruth(path string) (map[string][]string, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var doc struct {
|
||||
Queries map[string][]string `json:"queries"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &doc); err != nil {
|
||||
return nil, fmt.Errorf("parse %s: %w", path, err)
|
||||
}
|
||||
if doc.Queries == nil {
|
||||
doc.Queries = map[string][]string{}
|
||||
}
|
||||
return doc.Queries, nil
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAllAdapters_RegisteredInStableOrder(t *testing.T) {
|
||||
got := allAdapters()
|
||||
want := []string{"ripgrep", "probe", "colgrep", "grepai", "coderankembed", "semble"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("adapter count = %d, want %d", len(got), len(want))
|
||||
}
|
||||
for i, a := range got {
|
||||
if a.Name() != want[i] {
|
||||
t.Errorf("adapter[%d] = %q, want %q", i, a.Name(), want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdapterByName(t *testing.T) {
|
||||
if a := adapterByName("ripgrep"); a == nil || a.Name() != "ripgrep" {
|
||||
t.Errorf("adapterByName(ripgrep) = %v", a)
|
||||
}
|
||||
if a := adapterByName("RIPGREP"); a == nil { // case-insensitive
|
||||
t.Error("adapterByName should be case-insensitive")
|
||||
}
|
||||
if a := adapterByName("nonexistent"); a != nil {
|
||||
t.Errorf("adapterByName(nonexistent) should be nil, got %v", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAdapters_EmptyMeansAll(t *testing.T) {
|
||||
got, err := resolveAdapters("")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != len(allAdapters()) {
|
||||
t.Errorf("empty spec = %d adapters, want %d", len(got), len(allAdapters()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAdapters_Subset(t *testing.T) {
|
||||
got, err := resolveAdapters("ripgrep,probe")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 2 || got[0].Name() != "ripgrep" || got[1].Name() != "probe" {
|
||||
t.Errorf("subset = %v, want [ripgrep, probe] in order", names(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAdapters_UnknownErrors(t *testing.T) {
|
||||
if _, err := resolveAdapters("ripgrep,bogus"); err == nil {
|
||||
t.Error("expected error for unknown adapter")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNDCGAt10_PerfectRanking(t *testing.T) {
|
||||
results := []queryResult{
|
||||
{Query: "q", Hits: []string{"a", "b", "c"}},
|
||||
}
|
||||
truth := map[string][]string{"q": {"a", "b"}}
|
||||
got := ndcgAt10(results, truth, 10)
|
||||
// IDCG = 1/log2(2) + 1/log2(3) = 1 + 0.6309 = 1.6309
|
||||
// DCG = 1/log2(2) + 1/log2(3) + 0 = same = 1.6309
|
||||
// NDCG = 1.0
|
||||
if got < 0.99 || got > 1.01 {
|
||||
t.Errorf("perfect ranking NDCG@10 = %.4f, want 1.0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNDCGAt10_WrongOrderPenalized(t *testing.T) {
|
||||
results := []queryResult{
|
||||
{Query: "q", Hits: []string{"miss1", "miss2", "a", "b"}},
|
||||
}
|
||||
truth := map[string][]string{"q": {"a", "b"}}
|
||||
got := ndcgAt10(results, truth, 10)
|
||||
// DCG = 0 + 0 + 1/log2(4) + 1/log2(5) ≈ 0.5 + 0.431 = 0.931
|
||||
// IDCG = 1/log2(2) + 1/log2(3) ≈ 1.0 + 0.631 = 1.631
|
||||
// NDCG = 0.931/1.631 ≈ 0.571
|
||||
if got > 0.7 {
|
||||
t.Errorf("wrong-order NDCG@10 = %.4f, expected <0.7", got)
|
||||
}
|
||||
if got < 0.4 {
|
||||
t.Errorf("wrong-order NDCG@10 = %.4f, expected >0.4", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNDCGAt10_AllMissesScoreZero(t *testing.T) {
|
||||
results := []queryResult{{Query: "q", Hits: []string{"x", "y"}}}
|
||||
truth := map[string][]string{"q": {"a", "b"}}
|
||||
if got := ndcgAt10(results, truth, 10); got != 0 {
|
||||
t.Errorf("all-miss NDCG@10 = %.4f, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNDCGAt10_EmptyTruthSkipped(t *testing.T) {
|
||||
results := []queryResult{
|
||||
{Query: "q1", Hits: []string{"a"}},
|
||||
{Query: "q2", Hits: []string{"a"}},
|
||||
}
|
||||
truth := map[string][]string{"q2": {"a"}}
|
||||
// q1 has no truth → skipped; q2 has 1 hit at position 0 → NDCG=1.
|
||||
got := ndcgAt10(results, truth, 10)
|
||||
if math.Abs(got-1.0) > 0.01 {
|
||||
t.Errorf("mixed-truth NDCG@10 = %.4f, want ~1.0 (q1 should be skipped)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNDCGAt10_ErroredQuerySkipped(t *testing.T) {
|
||||
results := []queryResult{
|
||||
{Query: "q1", Error: "boom"},
|
||||
{Query: "q2", Hits: []string{"a"}},
|
||||
}
|
||||
truth := map[string][]string{"q1": {"a"}, "q2": {"a"}}
|
||||
// q1 errored → skipped; only q2 counts → NDCG=1.
|
||||
got := ndcgAt10(results, truth, 10)
|
||||
if math.Abs(got-1.0) > 0.01 {
|
||||
t.Errorf("errored-skipped NDCG@10 = %.4f, want ~1.0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMedianLatency(t *testing.T) {
|
||||
results := []queryResult{
|
||||
{LatencyMs: 30},
|
||||
{LatencyMs: 10},
|
||||
{LatencyMs: 20},
|
||||
}
|
||||
if got := medianLatency(results); got != 20 {
|
||||
t.Errorf("median = %.2f, want 20", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMedianLatency_ErrorsExcluded(t *testing.T) {
|
||||
results := []queryResult{
|
||||
{LatencyMs: 30, Error: "boom"},
|
||||
{LatencyMs: 10},
|
||||
{LatencyMs: 20},
|
||||
}
|
||||
if got := medianLatency(results); got != 20 {
|
||||
t.Errorf("median (errors excluded) = %.2f, want 20", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLines_DedupAndCap(t *testing.T) {
|
||||
body := `/repo/a.go
|
||||
/repo/b.go
|
||||
/repo/a.go
|
||||
/repo/c.go
|
||||
/repo/d.go
|
||||
`
|
||||
got := parseLines(body, "/repo", 3)
|
||||
want := []string{"a.go", "b.go", "c.go"}
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("got[%d] = %q, want %q", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLines_TopKZeroMeansUnlimited(t *testing.T) {
|
||||
body := "a\nb\nc\nd\ne\nf\ng\n"
|
||||
got := parseLines(body, "", 0)
|
||||
if len(got) != 7 {
|
||||
t.Errorf("got %d lines, want 7 (topK=0 unlimited)", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMarkdown_HasHeaderAndRows(t *testing.T) {
|
||||
rows := []adapterRow{
|
||||
{Adapter: "ripgrep", NDCGAt10: 0.45, MedianLatencyMs: 12.3, TotalDuration: "1.2s"},
|
||||
{Adapter: "colgrep", Skipped: "python module not importable"},
|
||||
}
|
||||
md := renderMarkdown(rows)
|
||||
for _, want := range []string{
|
||||
"# Retrieval-baselines NDCG@10",
|
||||
"| ripgrep |",
|
||||
"0.450",
|
||||
"| colgrep |",
|
||||
"skipped:",
|
||||
"**Summary:**",
|
||||
} {
|
||||
if !strings.Contains(md, want) {
|
||||
t.Errorf("markdown missing %q\n----\n%s", want, md)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSmokeReport_Markdown(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
out := filepath.Join(dir, "smoke.md")
|
||||
smokeReport([]adapter{&ripgrepAdapter{}, &colgrepAdapter{}}, "markdown", out)
|
||||
raw, err := os.ReadFile(out)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := string(raw)
|
||||
for _, want := range []string{
|
||||
"# Baseline-adapter smoke check",
|
||||
"| ripgrep |",
|
||||
"| colgrep |",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("smoke markdown missing %q\n----\n%s", want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSmokeReport_JSON(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
out := filepath.Join(dir, "smoke.json")
|
||||
smokeReport([]adapter{&ripgrepAdapter{}}, "json", out)
|
||||
raw, err := os.ReadFile(out)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got []map[string]any
|
||||
if err := json.Unmarshal(raw, &got); err != nil {
|
||||
t.Fatalf("smoke JSON unparseable: %v\n%s", err, raw)
|
||||
}
|
||||
if len(got) != 1 || got[0]["adapter"] != "ripgrep" {
|
||||
t.Errorf("smoke JSON shape wrong: %v", got)
|
||||
}
|
||||
if _, ok := got[0]["available"]; !ok {
|
||||
t.Errorf("smoke JSON missing 'available' field: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRipgrepAdapter_NoMatchesReturnsEmpty(t *testing.T) {
|
||||
a := &ripgrepAdapter{}
|
||||
avail, _ := a.Available()
|
||||
if !avail {
|
||||
t.Skip("rg not installed on this box")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "f.go"), []byte("package x\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
out := a.Run(ctx, []string{"definitely_not_in_the_corpus_XYZ"}, dir, 10)
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("got %d results, want 1", len(out))
|
||||
}
|
||||
if out[0].Error != "" {
|
||||
t.Errorf("no-match should not error, got %q", out[0].Error)
|
||||
}
|
||||
if len(out[0].Hits) != 0 {
|
||||
t.Errorf("no-match should yield empty hits, got %v", out[0].Hits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadQueries(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "q.json")
|
||||
if err := os.WriteFile(path, []byte(`{"queries":["a","b","c"]}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := loadQueries(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 3 {
|
||||
t.Errorf("got %d, want 3", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadGroundTruth(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "gt.json")
|
||||
body := `{"queries":{"q":["a.go"]}}`
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := loadGroundTruth(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got["q"]) != 1 || got["q"][0] != "a.go" {
|
||||
t.Errorf("got %v, want q:[a.go]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func names(adapters []adapter) []string {
|
||||
out := make([]string, len(adapters))
|
||||
for i, a := range adapters {
|
||||
out[i] = a.Name()
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CodeRankEmbed Hybrid baseline runner.
|
||||
|
||||
Wrapper that lets the Go-side bench/baselines harness invoke
|
||||
CodeRankEmbed Hybrid without per-baseline Go code growing model-
|
||||
download logic. Usage:
|
||||
|
||||
python3 bench/baselines/python/coderankembed_runner.py \\
|
||||
--repo PATH --query "validateToken" --top-k 10
|
||||
|
||||
Emits one repo-relative path per line on stdout. Errors go to
|
||||
stderr; non-zero exit when the model isn't available.
|
||||
|
||||
Install: `pip install sentence-transformers transformers torch`.
|
||||
First run downloads the CodeRankEmbed model (~440 MB).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--repo", required=True, help="indexed corpus path")
|
||||
ap.add_argument("--query", required=True, help="single query string")
|
||||
ap.add_argument("--top-k", type=int, default=10)
|
||||
args = ap.parse_args()
|
||||
|
||||
try:
|
||||
from sentence_transformers import SentenceTransformer
|
||||
except ImportError as e:
|
||||
print(
|
||||
f"coderankembed_runner: missing dependency ({e}). "
|
||||
"pip install sentence-transformers transformers torch",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
model = SentenceTransformer("nomic-ai/CodeRankEmbed", trust_remote_code=True)
|
||||
|
||||
# Index every file under repo (cheap for sub-million LoC; the
|
||||
# ground-truth fixture is the gortex repo itself).
|
||||
repo = Path(args.repo).resolve()
|
||||
paths: list[Path] = []
|
||||
texts: list[str] = []
|
||||
for p in repo.rglob("*"):
|
||||
if not p.is_file():
|
||||
continue
|
||||
if any(seg.startswith(".") for seg in p.relative_to(repo).parts):
|
||||
continue
|
||||
if p.suffix.lower() not in {
|
||||
".go", ".py", ".ts", ".tsx", ".js", ".jsx", ".rs",
|
||||
".java", ".kt", ".swift", ".rb", ".cs", ".cpp", ".c",
|
||||
".h", ".hpp", ".md", ".yaml", ".yml", ".json",
|
||||
}:
|
||||
continue
|
||||
try:
|
||||
text = p.read_text(errors="ignore")
|
||||
except OSError:
|
||||
continue
|
||||
if not text.strip():
|
||||
continue
|
||||
paths.append(p)
|
||||
texts.append(text[:8000]) # truncate to keep the embed cheap
|
||||
|
||||
if not texts:
|
||||
print(
|
||||
"coderankembed_runner: no indexable files under repo",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
embeds = model.encode(texts, show_progress_bar=False, convert_to_numpy=True)
|
||||
qe = model.encode([args.query], show_progress_bar=False, convert_to_numpy=True)[0]
|
||||
|
||||
# Cosine similarity → rank.
|
||||
import numpy as np
|
||||
|
||||
sims = embeds @ qe / (
|
||||
(np.linalg.norm(embeds, axis=1) * np.linalg.norm(qe)) + 1e-12
|
||||
)
|
||||
order = np.argsort(-sims)[: args.top_k]
|
||||
for idx in order:
|
||||
rel = paths[idx].relative_to(repo)
|
||||
print(rel.as_posix())
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"comment": "Query set for the retrieval-baselines NDCG@10 bench. Same shape as the token-efficiency set so the two benches stay comparable: an adapter that scores well here should also produce low tokens-per-response there.",
|
||||
"queries": [
|
||||
"AddObservation",
|
||||
"IsSymbolQuery",
|
||||
"FileCoherenceSignal",
|
||||
"alphaFuse",
|
||||
"savings dashboard rendering",
|
||||
"rerank pipeline default signals",
|
||||
"Indexer Index method",
|
||||
"graph build edges",
|
||||
"MCP server start",
|
||||
"token counting tiktoken"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
# Daemon-mode MCP-tool latency
|
||||
|
||||
Per-tool p50 / p95 / p99 latency for the production MCP dispatch
|
||||
path. Builds an in-process MCP server against a target corpus,
|
||||
fires N `Handler.CallToolStrict` invocations per tool, aggregates
|
||||
latencies into a published table.
|
||||
|
||||
## What it measures
|
||||
|
||||
- **Handler-end-to-end latency** for each MCP tool: JSON arg parse
|
||||
→ tool dispatch → handler logic → response encode. Same code
|
||||
path the production stdio / HTTP / daemon-socket front-ends use.
|
||||
- **Per-tool spread**: cheap tools (`graph_stats`, `get_callers`)
|
||||
separate from heavy ones (`smart_context`, `get_repo_outline`)
|
||||
so the published table shows realistic operating envelope.
|
||||
|
||||
## What it does NOT measure
|
||||
|
||||
- **Stdio framing** (gortex mcp's pipe overhead)
|
||||
- **Daemon socket dispatch** (gortex daemon's UNIX socket / HTTP
|
||||
ingress overhead)
|
||||
- **Network RTT** (if reaching the daemon remotely)
|
||||
|
||||
Each adds a roughly constant ~0.1-1 ms per call on a warm pipe;
|
||||
the handler latency below dominates user-perceived response time.
|
||||
|
||||
## Running
|
||||
|
||||
```sh
|
||||
# Default: index `.` and fire 200 iters per tool
|
||||
go run ./bench/daemon-latency
|
||||
|
||||
# Higher iter count for tighter percentiles
|
||||
go run ./bench/daemon-latency -iter 500
|
||||
|
||||
# Specific subset of tools (useful for tuning one signal)
|
||||
go run ./bench/daemon-latency -tools graph_stats,search_symbols
|
||||
|
||||
# CSV / JSON outputs for downstream tooling
|
||||
go run ./bench/daemon-latency -csv bench/results/dl.csv -json bench/results/dl.json
|
||||
```
|
||||
|
||||
Flags:
|
||||
|
||||
- `-repo PATH` — corpus to index (default `.`)
|
||||
- `-iter N` — iterations per tool (default 200; warm-up of N/10
|
||||
is added on top)
|
||||
- `-tools LIST` — comma-separated subset
|
||||
- `-out PATH` — primary output (default stdout)
|
||||
- `-csv PATH` / `-json PATH` — companion outputs
|
||||
- `-format markdown|csv|json` — primary format
|
||||
|
||||
Or via the CLI surface:
|
||||
|
||||
```sh
|
||||
gortex bench daemon-latency --out-dir bench/results
|
||||
```
|
||||
|
||||
## Tools benchmarked
|
||||
|
||||
| tool | shape |
|
||||
|------|-------|
|
||||
| `graph_stats` | no-arg snapshot; cheap |
|
||||
| `search_symbols` | 1 query arg; rotated through 10 fixtures so a per-query cache doesn't trivially hit |
|
||||
| `get_symbol_source` | 1 id arg; pinned to a sampled function from the indexed graph |
|
||||
| `get_callers` | 1 id arg + limit |
|
||||
| `find_usages` | 1 id arg |
|
||||
| `get_file_summary` | 1 path arg; pinned to a sampled file |
|
||||
| `smart_context` | 1 task arg; expensive, fewer iters per cycle |
|
||||
| `get_repo_outline` | no-arg; walks whole graph |
|
||||
|
||||
Sampled targets are picked once at start so each tool sees the
|
||||
same target across iterations — the per-call latency reflects
|
||||
handler arithmetic, not target lookup.
|
||||
|
||||
## Methodology
|
||||
|
||||
- Warm-up of `iter/10` (min 5) per tool primes any lazy
|
||||
initialisation in the handler / graph before the measured loop
|
||||
starts.
|
||||
- Per-iteration latency captured via `time.Since(start)` with
|
||||
μs precision.
|
||||
- Percentiles computed via the nearest-rank method:
|
||||
`idx = (pct × n) / 100`. For N=200 → p95=sorted[190].
|
||||
- Errors are counted in `error_rate` but their latencies are
|
||||
still measured (an error path that takes 3× the happy-path
|
||||
time is itself a signal).
|
||||
|
||||
## Honest caveats
|
||||
|
||||
- Numbers are operator-machine-specific. Absolute values vary 2-5×
|
||||
across hardware classes; the **relative spread** between tools
|
||||
(cheap vs heavy) is what publishes reproducibly.
|
||||
- Cold-cache effects show up most in `search_symbols` (BM25
|
||||
re-ranks under load) and `smart_context` (assembles fresh
|
||||
context each call). Warm-up reduces but doesn't eliminate them.
|
||||
- Smoke run on the gortex repo (71k nodes, Apple M3 Max):
|
||||
- `graph_stats` p50 4.2ms · p95 5.5ms
|
||||
- `search_symbols` p50 1.2ms · p95 22.4ms
|
||||
- `get_symbol_source` p50 0.19ms · p95 0.9ms
|
||||
- `get_callers` / `find_usages` p50 < 0.02ms (graph lookup)
|
||||
- `smart_context` p50 1.5ms · p95 24ms
|
||||
- `get_repo_outline` p50 60ms · p95 217ms
|
||||
|
||||
Median p95 across tools: 5.5 ms. Median p99: 5.9 ms.
|
||||
@@ -0,0 +1,447 @@
|
||||
// Command daemon-latency measures per-tool MCP dispatch latency.
|
||||
// Builds an in-process MCP server against a target corpus, fires N
|
||||
// `CallTool` invocations per tool, reports p50 / p95 / p99 per tool
|
||||
// and a top-line summary.
|
||||
//
|
||||
// What it measures: tool-handler latency end-to-end through the
|
||||
// real MCP dispatch path (`Handler.CallTool` invoked via the same
|
||||
// `server.MCPServer` the production stdio / HTTP / daemon
|
||||
// front-ends use). What it does NOT measure: stdio framing,
|
||||
// daemon socket dispatch, JSON-RPC envelope overhead. Those add a
|
||||
// small constant per call (typically <1 ms on a warm pipe); the
|
||||
// handler latency dominates the user-perceived response time.
|
||||
//
|
||||
// The bench therefore reflects "daemon-mode handler cost", which
|
||||
// is the load-bearing number for the daemon-latency publication.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/zzet/gortex/internal/config"
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
"github.com/zzet/gortex/internal/indexer"
|
||||
gortexmcp "github.com/zzet/gortex/internal/mcp"
|
||||
"github.com/zzet/gortex/internal/parser"
|
||||
"github.com/zzet/gortex/internal/parser/languages"
|
||||
"github.com/zzet/gortex/internal/query"
|
||||
internalserver "github.com/zzet/gortex/internal/server"
|
||||
)
|
||||
|
||||
// toolCall is one synthetic MCP request the bench fires per
|
||||
// iteration. ArgsFn lets the bench vary the args across iterations
|
||||
// (e.g. different query strings) so the dispatch path isn't
|
||||
// trivially memoised by an upstream cache.
|
||||
type toolCall struct {
|
||||
Tool string
|
||||
ArgsFn func(iter int) map[string]any
|
||||
WarmupN int
|
||||
IterN int
|
||||
// SkipIfMissing lets a tool opt out when its substrate isn't
|
||||
// in the indexed graph (e.g. nothing to call get_callers on).
|
||||
SkipIfMissing func(g *graph.Graph) bool
|
||||
}
|
||||
|
||||
// result captures the per-tool aggregate the bench publishes.
|
||||
type result struct {
|
||||
Tool string `json:"tool"`
|
||||
Iters int `json:"iters"`
|
||||
P50Ms float64 `json:"p50_ms"`
|
||||
P95Ms float64 `json:"p95_ms"`
|
||||
P99Ms float64 `json:"p99_ms"`
|
||||
MeanMs float64 `json:"mean_ms"`
|
||||
MaxMs float64 `json:"max_ms"`
|
||||
ErrorRate float64 `json:"error_rate"`
|
||||
Skipped string `json:"skipped,omitempty"`
|
||||
Started time.Time `json:"-"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
repo := flag.String("repo", ".", "corpus to index for the bench")
|
||||
iter := flag.Int("iter", 200, "iterations per tool (warm-up of iter/10 is added on top)")
|
||||
out := flag.String("out", "", "primary output path (default stdout)")
|
||||
jsonOut := flag.String("json", "", "companion JSON metrics output")
|
||||
csvOut := flag.String("csv", "", "companion CSV output")
|
||||
format := flag.String("format", "markdown", "markdown | json | csv")
|
||||
tools := flag.String("tools", "", "comma-separated subset (default: all known tools)")
|
||||
flag.Parse()
|
||||
|
||||
absRepo, err := filepath.Abs(*repo)
|
||||
if err != nil {
|
||||
die("repo path: %v", err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "[daemon-latency] indexing %s...\n", absRepo)
|
||||
g, srv := buildInProcessServer(absRepo)
|
||||
fmt.Fprintf(os.Stderr, "[daemon-latency] indexed %d nodes\n", len(g.AllNodes()))
|
||||
|
||||
handler := internalserver.NewHandler(srv.MCPServer(), g, "bench", zap.NewNop())
|
||||
|
||||
// Build the call set against the freshly indexed graph so each
|
||||
// synthetic request has at least some structural validity (a
|
||||
// real symbol id, an extant file path).
|
||||
calls := defaultCalls(g, *iter)
|
||||
if *tools != "" {
|
||||
calls = filterCalls(calls, strings.Split(*tools, ","))
|
||||
}
|
||||
|
||||
rows := make([]result, 0, len(calls))
|
||||
for _, c := range calls {
|
||||
if c.SkipIfMissing != nil && c.SkipIfMissing(g) {
|
||||
rows = append(rows, result{Tool: c.Tool, Iters: 0, Skipped: "no eligible substrate in indexed graph"})
|
||||
fmt.Fprintf(os.Stderr, "[daemon-latency] %-22s skipped (no substrate)\n", c.Tool)
|
||||
continue
|
||||
}
|
||||
row := runOne(handler, c)
|
||||
rows = append(rows, row)
|
||||
fmt.Fprintf(os.Stderr, "[daemon-latency] %-22s p50=%6.2fms p95=%6.2fms p99=%6.2fms iters=%d errs=%.0f%%\n",
|
||||
c.Tool, row.P50Ms, row.P95Ms, row.P99Ms, row.Iters, row.ErrorRate*100)
|
||||
}
|
||||
|
||||
var primary []byte
|
||||
switch strings.ToLower(*format) {
|
||||
case "markdown", "md":
|
||||
primary = []byte(renderMarkdown(rows, absRepo, g))
|
||||
case "csv":
|
||||
primary = []byte(renderCSV(rows))
|
||||
case "json":
|
||||
primary = mustMarshalJSON(rows)
|
||||
default:
|
||||
die("unknown --format %q", *format)
|
||||
}
|
||||
if err := writeOutput(*out, primary); err != nil {
|
||||
die("write output: %v", err)
|
||||
}
|
||||
if *csvOut != "" {
|
||||
if err := writeOutput(*csvOut, []byte(renderCSV(rows))); err != nil {
|
||||
die("write csv: %v", err)
|
||||
}
|
||||
}
|
||||
if *jsonOut != "" {
|
||||
if err := writeOutput(*jsonOut, mustMarshalJSON(rows)); err != nil {
|
||||
die("write json: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- in-process server ---------------------------------------------
|
||||
|
||||
// buildInProcessServer wires the same Server the production stdio /
|
||||
// daemon front-ends use, against a fresh in-process graph of repoRoot.
|
||||
// Identical wiring to `cmd/gortex/eval_recall.go`'s indexed-server
|
||||
// path so the bench reflects production handler arithmetic.
|
||||
func buildInProcessServer(repoRoot string) (*graph.Graph, *gortexmcp.Server) {
|
||||
g := graph.New()
|
||||
reg := parser.NewRegistry()
|
||||
languages.RegisterAll(reg)
|
||||
cfg := config.Config{}
|
||||
idx := indexer.New(g, reg, cfg.Index, zap.NewNop())
|
||||
if _, err := idx.Index(repoRoot); err != nil {
|
||||
die("index %s: %v", repoRoot, err)
|
||||
}
|
||||
eng := query.NewEngine(g)
|
||||
eng.SetSearch(idx.Search())
|
||||
srv := gortexmcp.NewServer(eng, g, idx, nil, zap.NewNop(), cfg.Guards.Rules)
|
||||
srv.RunAnalysis()
|
||||
return g, srv
|
||||
}
|
||||
|
||||
// --- call set -------------------------------------------------------
|
||||
|
||||
// defaultCalls returns the canonical bench surface. We focus on
|
||||
// tools agents actually call in production (the headline savings
|
||||
// drivers) — covering both cheap (graph_stats) and expensive
|
||||
// (smart_context) shapes so the published table shows the spread.
|
||||
func defaultCalls(g *graph.Graph, iter int) []toolCall {
|
||||
if iter <= 0 {
|
||||
iter = 200
|
||||
}
|
||||
warmup := max(iter/10, 5)
|
||||
|
||||
// Pick representative symbol IDs / file paths from the indexed
|
||||
// graph so the synthetic requests have real targets.
|
||||
var sampleFnID, sampleFilePath string
|
||||
for _, n := range g.AllNodes() {
|
||||
if n == nil {
|
||||
continue
|
||||
}
|
||||
if sampleFnID == "" && (n.Kind == graph.KindFunction || n.Kind == graph.KindMethod) {
|
||||
sampleFnID = n.ID
|
||||
}
|
||||
if sampleFilePath == "" && n.Kind == graph.KindFile && n.FilePath != "" {
|
||||
sampleFilePath = n.FilePath
|
||||
}
|
||||
if sampleFnID != "" && sampleFilePath != "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
queries := []string{
|
||||
"validateToken", "Indexer", "search", "newServer",
|
||||
"handler", "config", "graph", "rerank", "query", "savings",
|
||||
}
|
||||
|
||||
return []toolCall{
|
||||
{
|
||||
Tool: "graph_stats",
|
||||
ArgsFn: func(_ int) map[string]any { return map[string]any{} },
|
||||
WarmupN: warmup, IterN: iter,
|
||||
},
|
||||
{
|
||||
Tool: "search_symbols",
|
||||
ArgsFn: func(i int) map[string]any {
|
||||
return map[string]any{
|
||||
"query": queries[i%len(queries)],
|
||||
"limit": float64(20),
|
||||
}
|
||||
},
|
||||
WarmupN: warmup, IterN: iter,
|
||||
},
|
||||
{
|
||||
Tool: "get_symbol_source",
|
||||
ArgsFn: func(_ int) map[string]any {
|
||||
return map[string]any{"id": sampleFnID}
|
||||
},
|
||||
WarmupN: warmup, IterN: iter,
|
||||
SkipIfMissing: func(g *graph.Graph) bool { return sampleFnID == "" },
|
||||
},
|
||||
{
|
||||
Tool: "get_callers",
|
||||
ArgsFn: func(_ int) map[string]any {
|
||||
return map[string]any{"id": sampleFnID, "limit": float64(50)}
|
||||
},
|
||||
WarmupN: warmup, IterN: iter,
|
||||
SkipIfMissing: func(g *graph.Graph) bool { return sampleFnID == "" },
|
||||
},
|
||||
{
|
||||
Tool: "find_usages",
|
||||
ArgsFn: func(_ int) map[string]any {
|
||||
return map[string]any{"id": sampleFnID}
|
||||
},
|
||||
WarmupN: warmup, IterN: iter,
|
||||
SkipIfMissing: func(g *graph.Graph) bool { return sampleFnID == "" },
|
||||
},
|
||||
{
|
||||
Tool: "get_file_summary",
|
||||
ArgsFn: func(_ int) map[string]any {
|
||||
return map[string]any{"path": sampleFilePath}
|
||||
},
|
||||
WarmupN: warmup, IterN: iter,
|
||||
SkipIfMissing: func(g *graph.Graph) bool { return sampleFilePath == "" },
|
||||
},
|
||||
{
|
||||
Tool: "smart_context",
|
||||
ArgsFn: func(i int) map[string]any {
|
||||
return map[string]any{"task": "find " + queries[i%len(queries)]}
|
||||
},
|
||||
// smart_context is heavy — fewer iterations so the
|
||||
// whole bench stays reasonable. Still produces a
|
||||
// credible p50/p95 with 30-50 samples.
|
||||
WarmupN: 3, IterN: iter / 5,
|
||||
},
|
||||
{
|
||||
Tool: "get_repo_outline",
|
||||
ArgsFn: func(_ int) map[string]any {
|
||||
return map[string]any{}
|
||||
},
|
||||
WarmupN: warmup, IterN: iter,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func filterCalls(calls []toolCall, names []string) []toolCall {
|
||||
want := map[string]bool{}
|
||||
for _, n := range names {
|
||||
want[strings.TrimSpace(n)] = true
|
||||
}
|
||||
out := make([]toolCall, 0, len(calls))
|
||||
for _, c := range calls {
|
||||
if want[c.Tool] {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// --- run loop -------------------------------------------------------
|
||||
|
||||
func runOne(handler *internalserver.Handler, c toolCall) result {
|
||||
ctx := context.Background()
|
||||
// Warm-up: prime any lazy initialisation in the handler /
|
||||
// graph so the measured iterations are steady-state.
|
||||
for i := range c.WarmupN {
|
||||
_, _ = handler.CallToolStrict(ctx, c.Tool, c.ArgsFn(i))
|
||||
}
|
||||
|
||||
latencies := make([]time.Duration, 0, c.IterN)
|
||||
errors := 0
|
||||
for i := range c.IterN {
|
||||
t := time.Now()
|
||||
_, err := handler.CallToolStrict(ctx, c.Tool, c.ArgsFn(i))
|
||||
latencies = append(latencies, time.Since(t))
|
||||
if err != nil {
|
||||
errors++
|
||||
}
|
||||
}
|
||||
r := result{
|
||||
Tool: c.Tool,
|
||||
Iters: c.IterN,
|
||||
}
|
||||
if len(latencies) > 0 {
|
||||
r.P50Ms = pctMs(latencies, 50)
|
||||
r.P95Ms = pctMs(latencies, 95)
|
||||
r.P99Ms = pctMs(latencies, 99)
|
||||
r.MaxMs = pctMs(latencies, 100)
|
||||
r.MeanMs = meanMs(latencies)
|
||||
r.ErrorRate = float64(errors) / float64(len(latencies))
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func pctMs(xs []time.Duration, pct int) float64 {
|
||||
if len(xs) == 0 {
|
||||
return 0
|
||||
}
|
||||
sorted := make([]time.Duration, len(xs))
|
||||
copy(sorted, xs)
|
||||
slices.Sort(sorted)
|
||||
idx := (pct * len(sorted)) / 100
|
||||
if idx >= len(sorted) {
|
||||
idx = len(sorted) - 1
|
||||
}
|
||||
return float64(sorted[idx].Microseconds()) / 1000.0
|
||||
}
|
||||
|
||||
func meanMs(xs []time.Duration) float64 {
|
||||
if len(xs) == 0 {
|
||||
return 0
|
||||
}
|
||||
var sum time.Duration
|
||||
for _, x := range xs {
|
||||
sum += x
|
||||
}
|
||||
avg := sum / time.Duration(len(xs))
|
||||
return float64(avg.Microseconds()) / 1000.0
|
||||
}
|
||||
|
||||
// --- rendering ------------------------------------------------------
|
||||
|
||||
func renderMarkdown(rows []result, repoRoot string, g *graph.Graph) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintln(&b, "# Daemon-mode MCP-tool latency")
|
||||
fmt.Fprintln(&b)
|
||||
fmt.Fprintf(&b, "_Corpus: `%s` (%d nodes). In-process handler dispatch — measures `Handler.CallToolStrict` end-to-end. Daemon socket overhead adds typically <1 ms on a warm pipe; the handler latency below dominates user-perceived response time._\n",
|
||||
repoRoot, len(g.AllNodes()))
|
||||
fmt.Fprintln(&b)
|
||||
fmt.Fprintln(&b, "| tool | iters | p50 | p95 | p99 | mean | max | errors |")
|
||||
fmt.Fprintln(&b, "|------|------:|----:|----:|----:|-----:|----:|-------:|")
|
||||
for _, r := range rows {
|
||||
if r.Skipped != "" {
|
||||
fmt.Fprintf(&b, "| %s | — | — | — | — | — | — | skipped: %s |\n", r.Tool, r.Skipped)
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&b, "| %s | %d | %s | %s | %s | %s | %s | %.0f%% |\n",
|
||||
r.Tool, r.Iters,
|
||||
fmtMs(r.P50Ms), fmtMs(r.P95Ms), fmtMs(r.P99Ms),
|
||||
fmtMs(r.MeanMs), fmtMs(r.MaxMs),
|
||||
r.ErrorRate*100,
|
||||
)
|
||||
}
|
||||
fmt.Fprintln(&b)
|
||||
fmt.Fprintln(&b, summary(rows))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func renderCSV(rows []result) string {
|
||||
var b strings.Builder
|
||||
w := csv.NewWriter(&b)
|
||||
_ = w.Write([]string{"tool", "iters", "p50_ms", "p95_ms", "p99_ms", "mean_ms", "max_ms", "error_rate", "skipped"})
|
||||
for _, r := range rows {
|
||||
_ = w.Write([]string{
|
||||
r.Tool,
|
||||
fmt.Sprintf("%d", r.Iters),
|
||||
fmt.Sprintf("%.3f", r.P50Ms),
|
||||
fmt.Sprintf("%.3f", r.P95Ms),
|
||||
fmt.Sprintf("%.3f", r.P99Ms),
|
||||
fmt.Sprintf("%.3f", r.MeanMs),
|
||||
fmt.Sprintf("%.3f", r.MaxMs),
|
||||
fmt.Sprintf("%.4f", r.ErrorRate),
|
||||
r.Skipped,
|
||||
})
|
||||
}
|
||||
w.Flush()
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func mustMarshalJSON(rows []result) []byte {
|
||||
b, err := json.MarshalIndent(rows, "", " ")
|
||||
if err != nil {
|
||||
die("marshal json: %v", err)
|
||||
}
|
||||
return append(b, '\n')
|
||||
}
|
||||
|
||||
func summary(rows []result) string {
|
||||
ran := 0
|
||||
var p95s, p99s []float64
|
||||
for _, r := range rows {
|
||||
if r.Skipped != "" {
|
||||
continue
|
||||
}
|
||||
ran++
|
||||
p95s = append(p95s, r.P95Ms)
|
||||
p99s = append(p99s, r.P99Ms)
|
||||
}
|
||||
if ran == 0 {
|
||||
return "_no tools ran (all skipped)_"
|
||||
}
|
||||
sort.Float64s(p95s)
|
||||
sort.Float64s(p99s)
|
||||
medianP95 := p95s[len(p95s)/2]
|
||||
medianP99 := p99s[len(p99s)/2]
|
||||
return fmt.Sprintf("**Summary:** %d/%d tools ran. Median p95 across tools: %s. Median p99: %s.",
|
||||
ran, len(rows), fmtMs(medianP95), fmtMs(medianP99))
|
||||
}
|
||||
|
||||
func fmtMs(v float64) string {
|
||||
switch {
|
||||
case v == 0:
|
||||
return "—"
|
||||
case v < 1.0:
|
||||
return fmt.Sprintf("%.2fms", v)
|
||||
case v < 1000:
|
||||
return fmt.Sprintf("%.1fms", v)
|
||||
default:
|
||||
return fmt.Sprintf("%.2fs", v/1000.0)
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers --------------------------------------------------------
|
||||
|
||||
func die(format string, args ...any) {
|
||||
fmt.Fprintf(os.Stderr, "daemon-latency: "+format+"\n", args...)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func writeOutput(path string, body []byte) error {
|
||||
if path == "" {
|
||||
_, err := os.Stdout.Write(body)
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, body, 0o644)
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/zzet/gortex/internal/graph"
|
||||
)
|
||||
|
||||
func TestPctMs_NearestRank(t *testing.T) {
|
||||
// Build [1, 2, …, 100] ms.
|
||||
xs := make([]time.Duration, 100)
|
||||
for i := range xs {
|
||||
xs[i] = time.Duration(i+1) * time.Millisecond
|
||||
}
|
||||
cases := map[int]float64{
|
||||
50: 51.0, // idx = 50*100/100 = 50 → sorted[50] = 51ms
|
||||
95: 96.0, // idx = 95 → 96ms
|
||||
99: 100.0, // idx = 99 → 100ms
|
||||
}
|
||||
for p, want := range cases {
|
||||
got := pctMs(xs, p)
|
||||
if got != want {
|
||||
t.Errorf("pctMs(%d) = %.2f, want %.2f", p, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPctMs_EmptyReturnsZero(t *testing.T) {
|
||||
if got := pctMs(nil, 50); got != 0 {
|
||||
t.Errorf("pctMs(nil) = %.2f, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPctMs_SingleSampleAllPctReturnIt(t *testing.T) {
|
||||
xs := []time.Duration{5 * time.Millisecond}
|
||||
for _, p := range []int{50, 95, 99} {
|
||||
if got := pctMs(xs, p); got != 5.0 {
|
||||
t.Errorf("pctMs(single, %d) = %.2f, want 5.0", p, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeanMs(t *testing.T) {
|
||||
xs := []time.Duration{
|
||||
1 * time.Millisecond,
|
||||
2 * time.Millisecond,
|
||||
3 * time.Millisecond,
|
||||
4 * time.Millisecond,
|
||||
}
|
||||
if got := meanMs(xs); got != 2.5 {
|
||||
t.Errorf("meanMs = %.2f, want 2.5", got)
|
||||
}
|
||||
if got := meanMs(nil); got != 0 {
|
||||
t.Errorf("meanMs(nil) = %.2f, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFmtMs_Buckets(t *testing.T) {
|
||||
cases := map[float64]string{
|
||||
0: "—",
|
||||
0.25: "0.25ms",
|
||||
3.7: "3.7ms",
|
||||
1500: "1.50s",
|
||||
60_000: "60.00s",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := fmtMs(in); got != want {
|
||||
t.Errorf("fmtMs(%v) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterCalls_KeepsRequestedSubset(t *testing.T) {
|
||||
g := graph.New()
|
||||
all := defaultCalls(g, 10)
|
||||
got := filterCalls(all, []string{"graph_stats", "search_symbols"})
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d, want 2", len(got))
|
||||
}
|
||||
gotNames := map[string]bool{}
|
||||
for _, c := range got {
|
||||
gotNames[c.Tool] = true
|
||||
}
|
||||
if !gotNames["graph_stats"] || !gotNames["search_symbols"] {
|
||||
t.Errorf("got %v, want graph_stats + search_symbols", gotNames)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultCalls_PopulatesSubstrate(t *testing.T) {
|
||||
g := graph.New()
|
||||
g.AddNode(&graph.Node{ID: "f.go::Sym", Name: "Sym", Kind: graph.KindFunction, FilePath: "f.go"})
|
||||
g.AddNode(&graph.Node{ID: "file:f.go", Kind: graph.KindFile, FilePath: "f.go"})
|
||||
|
||||
calls := defaultCalls(g, 50)
|
||||
if len(calls) == 0 {
|
||||
t.Fatal("defaultCalls returned 0 entries")
|
||||
}
|
||||
// At least the headline tools must be present.
|
||||
want := map[string]bool{
|
||||
"graph_stats": true, "search_symbols": true,
|
||||
"get_symbol_source": true, "smart_context": true,
|
||||
}
|
||||
got := map[string]bool{}
|
||||
for _, c := range calls {
|
||||
got[c.Tool] = true
|
||||
}
|
||||
for w := range want {
|
||||
if !got[w] {
|
||||
t.Errorf("default call set missing %q", w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultCalls_SkipsTargetlessSubstrate(t *testing.T) {
|
||||
g := graph.New()
|
||||
// No function / method nodes → get_symbol_source / get_callers /
|
||||
// find_usages should report SkipIfMissing=true.
|
||||
calls := defaultCalls(g, 10)
|
||||
for _, c := range calls {
|
||||
if c.Tool == "get_symbol_source" || c.Tool == "get_callers" || c.Tool == "find_usages" {
|
||||
if c.SkipIfMissing == nil || !c.SkipIfMissing(g) {
|
||||
t.Errorf("%s should skip when no callable symbols available", c.Tool)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMarkdown_HasHeaderAndRows(t *testing.T) {
|
||||
rows := []result{
|
||||
{Tool: "graph_stats", Iters: 200, P50Ms: 1.2, P95Ms: 4.5, P99Ms: 8.9, MeanMs: 2.0, MaxMs: 12.5},
|
||||
{Tool: "smart_context", Iters: 40, P50Ms: 50.0, P95Ms: 200.0, P99Ms: 300.0, MeanMs: 80.0, MaxMs: 350.0},
|
||||
{Tool: "skipped_one", Skipped: "no substrate"},
|
||||
}
|
||||
g := graph.New()
|
||||
md := renderMarkdown(rows, "/tmp/repo", g)
|
||||
for _, want := range []string{
|
||||
"# Daemon-mode MCP-tool latency",
|
||||
"| graph_stats |",
|
||||
"| smart_context |",
|
||||
"| skipped_one |",
|
||||
"skipped:",
|
||||
"**Summary:**",
|
||||
"2/3 tools",
|
||||
} {
|
||||
if !strings.Contains(md, want) {
|
||||
t.Errorf("markdown missing %q\n----\n%s", want, md)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCSV_HasHeaderAndRows(t *testing.T) {
|
||||
rows := []result{
|
||||
{Tool: "graph_stats", Iters: 200, P50Ms: 1.2, P95Ms: 4.5, P99Ms: 8.9},
|
||||
}
|
||||
out := renderCSV(rows)
|
||||
if !strings.HasPrefix(out, "tool,iters,p50_ms,p95_ms,p99_ms,") {
|
||||
t.Errorf("CSV header missing or wrong:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "graph_stats,200,") {
|
||||
t.Errorf("CSV body missing graph_stats row:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMustMarshalJSON_RoundTrip(t *testing.T) {
|
||||
rows := []result{
|
||||
{Tool: "graph_stats", Iters: 100, P95Ms: 5.5, P99Ms: 8.2},
|
||||
}
|
||||
raw := mustMarshalJSON(rows)
|
||||
var got []result
|
||||
if err := json.Unmarshal(raw, &got); err != nil {
|
||||
t.Fatalf("round-trip: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].Tool != "graph_stats" || got[0].P95Ms != 5.5 {
|
||||
t.Errorf("round-trip lost data: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummary_AllSkippedYieldsExplicitMessage(t *testing.T) {
|
||||
rows := []result{{Tool: "x", Skipped: "y"}, {Tool: "z", Skipped: "w"}}
|
||||
got := summary(rows)
|
||||
if !strings.Contains(got, "no tools ran") {
|
||||
t.Errorf("summary should explicitly say no tools ran, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummary_MedianP95AndP99(t *testing.T) {
|
||||
rows := []result{
|
||||
{Tool: "a", P95Ms: 1, P99Ms: 2},
|
||||
{Tool: "b", P95Ms: 5, P99Ms: 10},
|
||||
{Tool: "c", P95Ms: 10, P99Ms: 20},
|
||||
}
|
||||
got := summary(rows)
|
||||
// Median p95 of [1, 5, 10] = 5
|
||||
if !strings.Contains(got, "Median p95 across tools: 5.0ms") {
|
||||
t.Errorf("summary missing median p95=5.0ms; got:\n%s", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "gortex-di-angular-fixture",
|
||||
"private": true,
|
||||
"description": "Minimal Angular fixture for Gortex DI recall evaluation. Not installed — static corpus.",
|
||||
"dependencies": {
|
||||
"@angular/core": "^17.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
# Angular DI-recall fixture
|
||||
#
|
||||
# Modern Angular has two DI shapes:
|
||||
# 1. Constructor injection: `constructor(private svc: Svc) {}` — same
|
||||
# shape as NestJS. Type-aware resolution already handles this.
|
||||
# 2. inject() function: `private svc = inject(Svc)` — field
|
||||
# initializer calls `inject(Target)` to resolve the dependency.
|
||||
# The call site is a function call to `inject`, not to Target.
|
||||
# Without a special pass, the dependency is invisible — same
|
||||
# shape as FastAPI's Depends(target) in that respect.
|
||||
#
|
||||
# Angular's @Injectable({ providedIn: 'root' }) is metadata only —
|
||||
# doesn't affect graph shape, just marks the class as injectable. No
|
||||
# extraction needed for the recall queries in this fixture.
|
||||
name: gortex-angular-di
|
||||
cases:
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Tier 1 — exact: named symbols
|
||||
# ------------------------------------------------------------------
|
||||
- id: exact-UsersService
|
||||
tier: exact
|
||||
query: UsersService
|
||||
expected: [src/app/users/users.service.ts::UsersService]
|
||||
- id: exact-AuthService
|
||||
tier: exact
|
||||
query: AuthService
|
||||
expected: [src/app/auth/auth.service.ts::AuthService]
|
||||
- id: exact-UsersListComponent
|
||||
tier: exact
|
||||
query: UsersListComponent
|
||||
expected: [src/app/users/users.controller.ts::UsersListComponent]
|
||||
- id: exact-findOne
|
||||
tier: exact
|
||||
query: findOne
|
||||
expected: [src/app/users/users.service.ts::UsersService.findOne]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Tier 2 — concept
|
||||
# ------------------------------------------------------------------
|
||||
- id: concept-list-all-users
|
||||
tier: concept
|
||||
query: list every user
|
||||
expected: [src/app/users/users.service.ts::UsersService.findAll]
|
||||
- id: concept-validate-credentials
|
||||
tier: concept
|
||||
query: validate user credentials
|
||||
expected: [src/app/auth/auth.service.ts::AuthService.validate]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Tier 3 — di: classic constructor injection with explicit types
|
||||
# ------------------------------------------------------------------
|
||||
# UsersListComponent has `constructor(private auth: AuthService)`.
|
||||
# `this.auth.validate(...)` in validateAndList resolves via the
|
||||
# parameter-property typing pass — Angular uses the same TS shape
|
||||
# as NestJS here.
|
||||
- id: di-call_chain-validateAndList
|
||||
tier: di
|
||||
query: "call_chain:src/app/users/users.controller.ts::UsersListComponent.validateAndList"
|
||||
expected:
|
||||
- src/app/auth/auth.service.ts::AuthService.validate
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Tier 4 — di_gap: inject() function-style injection
|
||||
# ------------------------------------------------------------------
|
||||
# AuthService uses `private users = inject(UsersService)`. The
|
||||
# `users.findOne(userId)` call site can only resolve to UsersService.findOne
|
||||
# if the graph knows `this.users` has type UsersService. Without
|
||||
# inject()-aware extraction, the field's type is unknown and the
|
||||
# call chain stops here.
|
||||
- id: di_gap-call_chain-AuthService-validate
|
||||
tier: di_gap
|
||||
query: "call_chain:src/app/auth/auth.service.ts::AuthService.validate"
|
||||
expected:
|
||||
- src/app/users/users.service.ts::UsersService.findOne
|
||||
|
||||
# UsersListComponent.displayList calls `this.users.findAll()` where
|
||||
# `users = inject(UsersService)` — same gap.
|
||||
- id: di_gap-call_chain-displayList
|
||||
tier: di_gap
|
||||
query: "call_chain:src/app/users/users.controller.ts::UsersListComponent.displayList"
|
||||
expected:
|
||||
- src/app/users/users.service.ts::UsersService.findAll
|
||||
|
||||
# Direct callers query: who uses UsersService? With inject()
|
||||
# extraction, the answer includes classes that inject() it, not
|
||||
# just constructor-injected consumers.
|
||||
- id: di_gap-callers-UsersService-findAll
|
||||
tier: di_gap
|
||||
query: "callers:src/app/users/users.service.ts::UsersService.findAll"
|
||||
expected:
|
||||
- src/app/users/users.controller.ts::UsersListComponent.displayList
|
||||
|
||||
- id: di_gap-callers-UsersService-findOne
|
||||
tier: di_gap
|
||||
query: "callers:src/app/users/users.service.ts::UsersService.findOne"
|
||||
expected:
|
||||
- src/app/auth/auth.service.ts::AuthService.validate
|
||||
|
||||
# Ambiguous method names: SessionService also has a findOne() method.
|
||||
# SessionService.currentUser calls `this.users.findOne(...)` where
|
||||
# `users = inject(UsersService)`. A name-only fallback would pick
|
||||
# either SessionService.findOne or UsersService.findOne arbitrarily.
|
||||
# This case tests whether inject()-aware type resolution routes the
|
||||
# call correctly to UsersService.findOne.
|
||||
- id: di_gap-call_chain-SessionService-currentUser
|
||||
tier: di_gap
|
||||
query: "call_chain:src/app/auth/session.service.ts::SessionService.currentUser"
|
||||
expected:
|
||||
- src/app/users/users.service.ts::UsersService.findOne
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { UsersService } from '../users/users.service';
|
||||
|
||||
// Modern Angular: `inject()` function-style DI. No constructor required
|
||||
// — the service is resolved via Angular's injector context at field-
|
||||
// initialization time. This is the shape ordinary static analysis
|
||||
// misses entirely, because the dependency edge lives inside an
|
||||
// argument of a generic-looking function call (`inject(X)`) rather
|
||||
// than in a typed constructor param.
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AuthService {
|
||||
private readonly users = inject(UsersService);
|
||||
|
||||
validate(userId: string, token: string): boolean {
|
||||
const u = this.users.findOne(userId);
|
||||
return u !== undefined && token === `tok_${u.id}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { UsersService } from '../users/users.service';
|
||||
|
||||
// Second service with a findOne() method — creates a name-collision
|
||||
// with UsersService.findOne. A resolver that picks up the class field
|
||||
// `this.users = inject(UsersService)` as UsersService-typed will
|
||||
// correctly route `this.users.findOne(...)` to UsersService. A
|
||||
// name-only fallback would arbitrarily pick one of the two findOne
|
||||
// implementations.
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SessionService {
|
||||
private readonly users = inject(UsersService);
|
||||
|
||||
// Same method name as UsersService — deliberate collision so the
|
||||
// call `this.users.findOne(...)` forces type-aware resolution.
|
||||
findOne(sessionId: string): { userId: string } | undefined {
|
||||
return { userId: sessionId };
|
||||
}
|
||||
|
||||
currentUser(sessionId: string): string | undefined {
|
||||
const s = this.findOne(sessionId);
|
||||
if (!s) return undefined;
|
||||
return this.users.findOne(s.userId)?.email;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { UsersService } from './users.service';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
|
||||
// A plain-ish component-like class that mixes both DI styles:
|
||||
// - `inject(UsersService)` — function form
|
||||
// - constructor parameter-property — classic Angular (pre-14)
|
||||
@Component({
|
||||
selector: 'users-list',
|
||||
template: '',
|
||||
})
|
||||
export class UsersListComponent {
|
||||
private readonly users = inject(UsersService);
|
||||
|
||||
constructor(private readonly auth: AuthService) {}
|
||||
|
||||
displayList(): string[] {
|
||||
return this.users.findAll().map((u) => u.email);
|
||||
}
|
||||
|
||||
validateAndList(userId: string, token: string): string[] {
|
||||
this.auth.validate(userId, token);
|
||||
return this.displayList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { User } from './user.model';
|
||||
|
||||
// Modern Angular `providedIn: 'root'` — makes the service available
|
||||
// app-wide without a NgModule providers array. The service is a plain
|
||||
// TypeScript class with a few methods agents would want to trace from
|
||||
// their call sites.
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class UsersService {
|
||||
private readonly store = new Map<string, User>();
|
||||
|
||||
findOne(id: string): User | undefined {
|
||||
return this.store.get(id);
|
||||
}
|
||||
|
||||
findAll(): User[] {
|
||||
return Array.from(this.store.values());
|
||||
}
|
||||
|
||||
create(email: string): User {
|
||||
const id = `usr_${this.store.size + 1}`;
|
||||
const u: User = { id, email };
|
||||
this.store.set(id, u);
|
||||
return u;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "esnext",
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"strict": true,
|
||||
"baseUrl": "./src"
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends
|
||||
|
||||
from app.auth.service import AuthService
|
||||
|
||||
|
||||
# Function-style dependency that wraps a class-based dependency —
|
||||
# standard FastAPI pattern for "require this to be valid before the
|
||||
# handler runs". The returned AuthService instance is what the
|
||||
# handler ends up with when it declares `auth: AuthService = Depends(require_auth)`.
|
||||
def require_auth(auth: AuthService = Depends(AuthService)) -> AuthService:
|
||||
return auth
|
||||
|
||||
|
||||
# PEP 593 / modern FastAPI shorthand — same resolution as the default-
|
||||
# value form but typed via Annotated. Both shapes must resolve to
|
||||
# AuthService.validate for any call-chain query from a handler using
|
||||
# `auth: CurrentAuth` to reach the underlying implementation.
|
||||
CurrentAuth = Annotated[AuthService, Depends(AuthService)]
|
||||
@@ -0,0 +1,19 @@
|
||||
from fastapi import Depends, HTTPException
|
||||
|
||||
from app.users.service import UserService
|
||||
|
||||
|
||||
class AuthService:
|
||||
"""Class-based dependency that ITSELF depends on another service.
|
||||
FastAPI resolves the nested Depends chain automatically; statically
|
||||
the dependency is visible via the __init__ parameter's Depends()
|
||||
default value."""
|
||||
|
||||
def __init__(self, users: UserService = Depends(UserService)) -> None:
|
||||
self._users = users
|
||||
|
||||
def validate(self, user_id: str, token: str) -> bool:
|
||||
u = self._users.find_one(user_id)
|
||||
if u is None or token != f"tok_{u.id}":
|
||||
raise HTTPException(status_code=401)
|
||||
return True
|
||||
@@ -0,0 +1,14 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class Settings:
|
||||
database_url: str = "postgres://localhost/test"
|
||||
feature_flags: dict[str, bool] = None
|
||||
|
||||
|
||||
def get_settings() -> Settings:
|
||||
"""Factory-style dependency. FastAPI will call this once per request
|
||||
(unless cached with @lru_cache) and inject the result into any
|
||||
handler that declares `settings: Settings = Depends(get_settings)`."""
|
||||
return Settings(feature_flags={"beta": True})
|
||||
@@ -0,0 +1,7 @@
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.users.router import router as users_router
|
||||
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(users_router)
|
||||
@@ -0,0 +1,8 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class User:
|
||||
id: str
|
||||
email: str
|
||||
name: str
|
||||
@@ -0,0 +1,51 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.auth.deps import CurrentAuth, require_auth
|
||||
from app.auth.service import AuthService
|
||||
from app.config.settings import Settings, get_settings
|
||||
from app.users.models import User
|
||||
from app.users.service import UserService
|
||||
|
||||
router = APIRouter(prefix="/users")
|
||||
|
||||
|
||||
# Default-value form: `users: UserService = Depends(UserService)`.
|
||||
# Most common shape in real FastAPI code.
|
||||
@router.get("/")
|
||||
def list_users(users: UserService = Depends(UserService)) -> list[User]:
|
||||
return users.find_all()
|
||||
|
||||
|
||||
# Nested / chained Depends: the handler depends on require_auth, which
|
||||
# itself depends on AuthService, which depends on UserService. Whole
|
||||
# chain is discoverable statically — each Depends() call exposes its
|
||||
# target function/class as a default-value expression.
|
||||
@router.get("/{user_id}")
|
||||
def get_user(
|
||||
user_id: str,
|
||||
users: UserService = Depends(UserService),
|
||||
auth: AuthService = Depends(require_auth),
|
||||
) -> User:
|
||||
u = users.find_one(user_id)
|
||||
if u is None:
|
||||
raise ValueError(f"no user {user_id}")
|
||||
# Touch the auth-resolved service so the call chain from get_user
|
||||
# reaches AuthService.validate and by extension UserService.find_one.
|
||||
_ = auth.validate(user_id, f"tok_{u.id}")
|
||||
return u
|
||||
|
||||
|
||||
# Annotated-form Depends (PEP 593): `auth: Annotated[AuthService, Depends(...)]`.
|
||||
# Semantically equivalent to the default-value form; a modern FastAPI
|
||||
# codebase uses it everywhere.
|
||||
@router.post("/")
|
||||
def create_user(
|
||||
email: str,
|
||||
name: str,
|
||||
users: Annotated[UserService, Depends(UserService)],
|
||||
settings: Annotated[Settings, Depends(get_settings)],
|
||||
) -> User:
|
||||
_ = settings.database_url
|
||||
return users.create(email, name)
|
||||
@@ -0,0 +1,24 @@
|
||||
from typing import Optional
|
||||
|
||||
from app.users.models import User
|
||||
|
||||
|
||||
class UserService:
|
||||
"""Plain service class — registered as a FastAPI dependency via
|
||||
class-based `Depends(UserService)`. Its methods are called from
|
||||
route handlers that receive the instance through a parameter."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._users: dict[str, User] = {}
|
||||
|
||||
def find_one(self, user_id: str) -> Optional[User]:
|
||||
return self._users.get(user_id)
|
||||
|
||||
def find_all(self) -> list[User]:
|
||||
return list(self._users.values())
|
||||
|
||||
def create(self, email: str, name: str) -> User:
|
||||
uid = f"usr_{len(self._users) + 1}"
|
||||
u = User(id=uid, email=email, name=name)
|
||||
self._users[uid] = u
|
||||
return u
|
||||
@@ -0,0 +1,8 @@
|
||||
[project]
|
||||
name = "gortex-di-fastapi-fixture"
|
||||
version = "0.0.0"
|
||||
description = "Minimal FastAPI fixture for Gortex DI recall evaluation. Not installed — static corpus for the indexer to parse."
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"fastapi>=0.100",
|
||||
]
|
||||
@@ -0,0 +1,124 @@
|
||||
# FastAPI DI-recall fixture
|
||||
#
|
||||
# FastAPI's dependency injection is less "magic" than NestJS — every
|
||||
# dependency is the default value of a parameter, either via
|
||||
# `= Depends(target)` or `Annotated[T, Depends(target)]`. Target can be
|
||||
# a callable (function) or a class. Static analysis CAN see the target
|
||||
# because it's a value expression at the call site.
|
||||
#
|
||||
# Tiers (same as the nestjs fixture):
|
||||
# exact — symbol-name text queries
|
||||
# concept — natural-language paraphrases
|
||||
# di — shapes that a type-aware Python resolver would already
|
||||
# resolve (explicit class parameter types)
|
||||
# di_gap — shapes that require Depends-aware extraction to traverse
|
||||
# (nested Depends, function-wrapped Depends, Annotated)
|
||||
#
|
||||
# Expected IDs use the repo-stripped path form the Gortex TypeScript
|
||||
# and Python extractors produce, e.g.
|
||||
# app/users/router.py::get_user
|
||||
# app/users/service.py::UserService.find_one
|
||||
name: gortex-fastapi-di
|
||||
cases:
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Tier 1 — exact: can the Python extractor find named symbols?
|
||||
# ------------------------------------------------------------------
|
||||
- id: exact-UserService
|
||||
tier: exact
|
||||
query: UserService
|
||||
expected: [app/users/service.py::UserService]
|
||||
- id: exact-AuthService
|
||||
tier: exact
|
||||
query: AuthService
|
||||
expected: [app/auth/service.py::AuthService]
|
||||
- id: exact-get_settings
|
||||
tier: exact
|
||||
query: get_settings
|
||||
expected: [app/config/settings.py::get_settings]
|
||||
- id: exact-require_auth
|
||||
tier: exact
|
||||
query: require_auth
|
||||
expected: [app/auth/deps.py::require_auth]
|
||||
- id: exact-find_one
|
||||
tier: exact
|
||||
query: find_one
|
||||
expected: [app/users/service.py::UserService.find_one]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Tier 2 — concept: paraphrase queries
|
||||
# ------------------------------------------------------------------
|
||||
- id: concept-look-up-user
|
||||
tier: concept
|
||||
query: look up a user by their id
|
||||
expected: [app/users/service.py::UserService.find_one]
|
||||
- id: concept-validate-token
|
||||
tier: concept
|
||||
query: validate an auth token
|
||||
expected: [app/auth/service.py::AuthService.validate]
|
||||
- id: concept-application-settings
|
||||
tier: concept
|
||||
query: application settings
|
||||
expected: [app/config/settings.py::Settings]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Tier 3 — di: class-based Depends with explicit types
|
||||
# ------------------------------------------------------------------
|
||||
# AuthService's constructor has `users: UserService = Depends(UserService)`.
|
||||
# The declared type `UserService` is explicit Python, so a type-aware
|
||||
# resolver can link `self._users.find_one(...)` to UserService.find_one.
|
||||
- id: di-call_chain-AuthService-validate
|
||||
tier: di
|
||||
query: "call_chain:app/auth/service.py::AuthService.validate"
|
||||
expected: [app/users/service.py::UserService.find_one]
|
||||
|
||||
# Handler with class Depends — `users: UserService = Depends(UserService)`.
|
||||
# Same class-type annotation shape.
|
||||
- id: di-call_chain-list_users
|
||||
tier: di
|
||||
query: "call_chain:app/users/router.py::list_users"
|
||||
expected: [app/users/service.py::UserService.find_all]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Tier 4 — di_gap: shapes requiring Depends-aware extraction
|
||||
# ------------------------------------------------------------------
|
||||
# Function-wrapped Depends: the handler gets AuthService via a
|
||||
# require_auth dependency that itself returns whatever its own
|
||||
# Depends yields. The call chain from get_user should reach both
|
||||
# require_auth and the nested AuthService.validate.
|
||||
- id: di_gap-call_chain-get_user
|
||||
tier: di_gap
|
||||
query: "call_chain:app/users/router.py::get_user"
|
||||
expected:
|
||||
# Body calls (explicit invocation on the injected instance).
|
||||
- app/users/service.py::UserService.find_one
|
||||
- app/auth/service.py::AuthService.validate
|
||||
# Depends chain — the handler effectively calls each target at
|
||||
# request time via FastAPI's resolver.
|
||||
- app/auth/deps.py::require_auth
|
||||
|
||||
# require_auth → AuthService → UserService (two hops of Depends).
|
||||
- id: di_gap-callers-AuthService-validate
|
||||
tier: di_gap
|
||||
query: "callers:app/auth/service.py::AuthService.validate"
|
||||
expected: [app/users/router.py::get_user]
|
||||
|
||||
# Annotated[T, Depends(target)] — modern FastAPI shorthand. The
|
||||
# handler declares `users: Annotated[UserService, Depends(UserService)]`
|
||||
# and calls users.create(...). The Depends target is a class; its
|
||||
# type is resolvable from the annotation; the method call should
|
||||
# link to UserService.create.
|
||||
- id: di_gap-call_chain-create_user
|
||||
tier: di_gap
|
||||
query: "call_chain:app/users/router.py::create_user"
|
||||
expected:
|
||||
- app/users/service.py::UserService.create
|
||||
- app/config/settings.py::get_settings
|
||||
|
||||
# Factory-function Depends: `settings: Annotated[Settings, Depends(get_settings)]`.
|
||||
# get_settings returns Settings; the handler then touches settings.database_url.
|
||||
# With Depends-aware extraction, callers of get_settings should include the handler.
|
||||
- id: di_gap-callers-get_settings
|
||||
tier: di_gap
|
||||
query: "callers:app/config/settings.py::get_settings"
|
||||
expected: [app/users/router.py::create_user]
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Middleware\AdminMiddleware;
|
||||
use App\Http\Middleware\AuthenticateMiddleware;
|
||||
use App\Repositories\UserRepository;
|
||||
use App\Services\Clock;
|
||||
|
||||
class UserController
|
||||
{
|
||||
// Constructor-injected typed dependencies. Laravel's container
|
||||
// autowires from the type hints; UserRepository resolves through
|
||||
// the binding in AppServiceProvider::register() to
|
||||
// EloquentUserRepository.
|
||||
public function __construct(
|
||||
private UserRepository $users,
|
||||
private Clock $clock,
|
||||
) {
|
||||
// Controller middleware: registered imperatively in the
|
||||
// constructor. 'auth' applies to every action; 'admin' is
|
||||
// filtered with ->only(['destroy']), same shape as Rails
|
||||
// before_action + only:.
|
||||
$this->middleware(AuthenticateMiddleware::class);
|
||||
$this->middleware(AdminMiddleware::class)->only(['destroy']);
|
||||
}
|
||||
|
||||
public function index(): array
|
||||
{
|
||||
return $this->users->all();
|
||||
}
|
||||
|
||||
public function show(string $id): ?array
|
||||
{
|
||||
return $this->users->find($id);
|
||||
}
|
||||
|
||||
public function destroy(string $id): string
|
||||
{
|
||||
return 'deleted at ' . $this->clock->now();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
|
||||
class AdminMiddleware
|
||||
{
|
||||
public function handle($request, Closure $next)
|
||||
{
|
||||
if (empty($request->user['admin'])) {
|
||||
return response('forbidden', 403);
|
||||
}
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
|
||||
// Laravel middleware. handle() is what the framework calls before the
|
||||
// controller action runs. There's no explicit call site in the
|
||||
// controller or route definition — the middleware is referenced by
|
||||
// its short alias ("auth") or class name in a call like
|
||||
// $this->middleware('auth').
|
||||
class AuthenticateMiddleware
|
||||
{
|
||||
public function handle($request, Closure $next)
|
||||
{
|
||||
if (empty($request->user)) {
|
||||
return response('unauthorized', 401);
|
||||
}
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Repositories\EloquentUserRepository;
|
||||
use App\Repositories\UserRepository;
|
||||
use App\Services\Clock;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
// register() is Laravel's container-binding entry point. Every
|
||||
// bind/singleton/instance call here declares that when someone
|
||||
// asks the container for the first argument, give them an
|
||||
// instance produced from the second. These bindings are the DI
|
||||
// gap that matters — consumers typed against the interface won't
|
||||
// reach the implementation without this info.
|
||||
public function register(): void
|
||||
{
|
||||
// bind: a fresh EloquentUserRepository per resolve.
|
||||
$this->app->bind(UserRepository::class, EloquentUserRepository::class);
|
||||
|
||||
// singleton: one Clock shared across the app lifetime.
|
||||
$this->app->singleton(Clock::class, function ($app) {
|
||||
return new Clock('UTC');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Repositories;
|
||||
|
||||
// Concrete UserRepository. Service provider binds this to the
|
||||
// interface — without that binding, the Laravel container has no way
|
||||
// to pick an implementation from the interface alone. Same shape as
|
||||
// NestJS useClass / Spring @Bean return-type binding.
|
||||
class EloquentUserRepository implements UserRepository
|
||||
{
|
||||
public function find(string $id): ?array
|
||||
{
|
||||
return ['id' => $id];
|
||||
}
|
||||
|
||||
public function all(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace App\Repositories;
|
||||
|
||||
interface UserRepository
|
||||
{
|
||||
public function find(string $id): ?array;
|
||||
public function all(): array;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
class Clock
|
||||
{
|
||||
public function __construct(private string $timezone = 'UTC') {}
|
||||
|
||||
public function now(): string
|
||||
{
|
||||
return 'now-in-' . $this->timezone;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "gortex/di-laravel-fixture",
|
||||
"type": "project",
|
||||
"description": "Minimal Laravel fixture for Gortex DI recall — not installed.",
|
||||
"require": {
|
||||
"laravel/framework": "^11.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
# Laravel DI-recall fixture
|
||||
#
|
||||
# Two DI gaps matter in real-world Laravel code:
|
||||
# 1. Controller middleware (`$this->middleware('auth')` in the ctor,
|
||||
# optionally filtered with ->only([...]) / ->except([...])).
|
||||
# Same decorator-dispatch shape as NestJS @UseGuards / Rails
|
||||
# before_action / Phoenix plug. Framework invokes middleware's
|
||||
# handle() before the action; no explicit call site.
|
||||
# 2. Service provider bindings (`$this->app->bind(Interface::class,
|
||||
# Impl::class)` and ->singleton(...)). Container-registered
|
||||
# interface-to-implementation or token-to-factory mappings —
|
||||
# same shape as NestJS useClass / Spring @Bean.
|
||||
name: gortex-laravel-di
|
||||
cases:
|
||||
|
||||
# exact
|
||||
- id: exact-UserController
|
||||
tier: exact
|
||||
query: UserController
|
||||
expected: [app/Http/Controllers/UserController.php::UserController]
|
||||
- id: exact-UserRepository
|
||||
tier: exact
|
||||
query: UserRepository
|
||||
expected: [app/Repositories/UserRepository.php::UserRepository]
|
||||
- id: exact-EloquentUserRepository
|
||||
tier: exact
|
||||
query: EloquentUserRepository
|
||||
expected: [app/Repositories/EloquentUserRepository.php::EloquentUserRepository]
|
||||
- id: exact-AuthenticateMiddleware
|
||||
tier: exact
|
||||
query: AuthenticateMiddleware
|
||||
expected: [app/Http/Middleware/AuthenticateMiddleware.php::AuthenticateMiddleware]
|
||||
|
||||
# di — typed constructor injection. Should work via PHP type system
|
||||
# if the extractor captures constructor param types.
|
||||
- id: di-call_chain-index
|
||||
tier: di
|
||||
query: "call_chain:app/Http/Controllers/UserController.php::UserController.index"
|
||||
expected:
|
||||
- app/Repositories/UserRepository.php::UserRepository.all
|
||||
|
||||
# di_gap: middleware dispatch
|
||||
- id: di_gap-callers-AuthenticateMiddleware-handle
|
||||
tier: di_gap
|
||||
query: "callers:app/Http/Middleware/AuthenticateMiddleware.php::AuthenticateMiddleware.handle"
|
||||
expected:
|
||||
- app/Http/Controllers/UserController.php::UserController.index
|
||||
- app/Http/Controllers/UserController.php::UserController.show
|
||||
- app/Http/Controllers/UserController.php::UserController.destroy
|
||||
|
||||
# Admin middleware is only: ['destroy'] — just one action binds it.
|
||||
- id: di_gap-callers-AdminMiddleware-handle
|
||||
tier: di_gap
|
||||
query: "callers:app/Http/Middleware/AdminMiddleware.php::AdminMiddleware.handle"
|
||||
expected:
|
||||
- app/Http/Controllers/UserController.php::UserController.destroy
|
||||
|
||||
- id: di_gap-call_chain-UserController-show
|
||||
tier: di_gap
|
||||
query: "call_chain:app/Http/Controllers/UserController.php::UserController.show"
|
||||
expected:
|
||||
- app/Http/Middleware/AuthenticateMiddleware.php::AuthenticateMiddleware.handle
|
||||
|
||||
# di_gap: service provider bind(Interface, Impl)
|
||||
# $this->app->bind(UserRepository::class, EloquentUserRepository::class)
|
||||
# binds the interface to the impl. usages:UserRepository should
|
||||
# include AppServiceProvider as the binding site.
|
||||
- id: di_gap-usages-UserRepository
|
||||
tier: di_gap
|
||||
query: "usages:app/Repositories/UserRepository.php::UserRepository"
|
||||
expected:
|
||||
- app/Providers/AppServiceProvider.php::AppServiceProvider
|
||||
|
||||
# singleton binding — Clock is bound by a factory closure.
|
||||
- id: di_gap-usages-Clock
|
||||
tier: di_gap
|
||||
query: "usages:app/Services/Clock.php::Clock"
|
||||
expected:
|
||||
- app/Providers/AppServiceProvider.php::AppServiceProvider
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\UserController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::get('/users', [UserController::class, 'index']);
|
||||
Route::get('/users/{id}', [UserController::class, 'show']);
|
||||
Route::delete('/users/{id}', [UserController::class, 'destroy']);
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "gortex-di-nestjs-fixture",
|
||||
"private": true,
|
||||
"description": "Minimal NestJS fixture for Gortex DI recall evaluation. Not installed — purely a static corpus for the indexer to parse.",
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^10.0.0",
|
||||
"@nestjs/core": "^10.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
# NestJS DI-recall fixture
|
||||
#
|
||||
# Measures what Gortex's retrieval stack can find on a minimal NestJS
|
||||
# app *without* DI-aware extraction — i.e. what's reachable from plain
|
||||
# BM25 + engine fallback + raw call graph. Option 1 (extend `contracts`
|
||||
# to DI) should move the "di" tier numbers up without regressing the
|
||||
# other two.
|
||||
#
|
||||
# Tiers
|
||||
# -----
|
||||
# exact — symbol-name / signature queries. Text rankers should ace
|
||||
# these; failure means the TypeScript extractor missed something.
|
||||
# concept — natural-language paraphrases. Baseline expectation similar
|
||||
# to the Go concept tier.
|
||||
# di — relational queries that only return the right answer when a
|
||||
# DI edge exists in the graph. Queries use the GraphRanker's
|
||||
# "callers:<id>" / "call_chain:<id>" format. Expected IDs are
|
||||
# the controllers / services that inject the subject via a
|
||||
# constructor parameter. Today these return 0% because no
|
||||
# such edge is emitted.
|
||||
#
|
||||
# Expected ID format: `<relative-ts-path>::<ClassName>.<method>` —
|
||||
# matches what the in-tree TypeScript extractor produces.
|
||||
name: gortex-nestjs-di
|
||||
cases:
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Tier 1 — exact: can the TS extractor find each symbol by name?
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
- id: exact-UsersService
|
||||
tier: exact
|
||||
query: UsersService
|
||||
expected: [src/users/users.service.ts::UsersService]
|
||||
|
||||
- id: exact-UsersController
|
||||
tier: exact
|
||||
query: UsersController
|
||||
expected: [src/users/users.controller.ts::UsersController]
|
||||
|
||||
- id: exact-AuthService
|
||||
tier: exact
|
||||
query: AuthService
|
||||
expected: [src/auth/auth.service.ts::AuthService]
|
||||
|
||||
- id: exact-AuthGuard
|
||||
tier: exact
|
||||
query: AuthGuard
|
||||
expected: [src/auth/auth.guard.ts::AuthGuard]
|
||||
|
||||
- id: exact-findOne
|
||||
tier: exact
|
||||
query: findOne
|
||||
expected: [src/users/users.service.ts::UsersService.findOne]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Tier 2 — concept: paraphrased / intent-based text queries
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
- id: concept-look-up-user-by-id
|
||||
tier: concept
|
||||
query: look up a user by their id
|
||||
expected: [src/users/users.service.ts::UsersService.findOne]
|
||||
|
||||
- id: concept-http-guard
|
||||
tier: concept
|
||||
query: guard that validates an incoming request
|
||||
expected: [src/auth/auth.guard.ts::AuthGuard.canActivate]
|
||||
|
||||
- id: concept-issue-auth-token
|
||||
tier: concept
|
||||
query: issue an authentication token
|
||||
expected: [src/auth/auth.service.ts::AuthService.issueToken]
|
||||
|
||||
- id: concept-create-new-user
|
||||
tier: concept
|
||||
query: create a new user record
|
||||
expected: [src/users/users.service.ts::UsersService.create]
|
||||
|
||||
- id: concept-list-all-users
|
||||
tier: concept
|
||||
query: list every user
|
||||
expected: [src/users/users.service.ts::UsersService.findAll]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Tier 3 — di: requires a DI edge to answer correctly
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
# Who injects UsersService? Answer: UsersController, AuthService.
|
||||
# Without DI edges, get_callers on the service's methods sees nothing
|
||||
# past the class's own internal references.
|
||||
- id: di-callers-UsersService-findOne
|
||||
tier: di
|
||||
query: "callers:src/users/users.service.ts::UsersService.findOne"
|
||||
expected:
|
||||
- src/users/users.controller.ts::UsersController.getUser
|
||||
- src/auth/auth.service.ts::AuthService.validate
|
||||
- src/auth/auth.service.ts::AuthService.issueToken
|
||||
|
||||
- id: di-callers-UsersService-findAll
|
||||
tier: di
|
||||
query: "callers:src/users/users.service.ts::UsersService.findAll"
|
||||
expected:
|
||||
- src/users/users.controller.ts::UsersController.list
|
||||
|
||||
- id: di-callers-UsersService-create
|
||||
tier: di
|
||||
query: "callers:src/users/users.service.ts::UsersService.create"
|
||||
expected:
|
||||
- src/users/users.controller.ts::UsersController.create
|
||||
|
||||
- id: di-callers-AuthService-validate
|
||||
tier: di
|
||||
query: "callers:src/auth/auth.service.ts::AuthService.validate"
|
||||
expected:
|
||||
- src/auth/auth.guard.ts::AuthGuard.canActivate
|
||||
|
||||
# Call chain FROM a controller method should reach the service method
|
||||
# it injects. Tests forward traversal through DI boundaries.
|
||||
- id: di-call_chain-UsersController-getUser
|
||||
tier: di
|
||||
query: "call_chain:src/users/users.controller.ts::UsersController.getUser"
|
||||
expected:
|
||||
- src/users/users.service.ts::UsersService.findOne
|
||||
|
||||
- id: di-call_chain-AuthGuard-canActivate
|
||||
tier: di
|
||||
query: "call_chain:src/auth/auth.guard.ts::AuthGuard.canActivate"
|
||||
expected:
|
||||
- src/auth/auth.service.ts::AuthService.validate
|
||||
- src/users/users.service.ts::UsersService.findOne
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Tier 4 — di_gap: shapes Tier-2 type resolution cannot handle.
|
||||
# These queries should be near-0% without module-aware extraction;
|
||||
# they're the cases that would justify shipping option 1.
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
# Abstract-class injection: NotificationsController injects Notifier
|
||||
# (abstract), module binds it to EmailNotifier. Finding the caller of
|
||||
# EmailNotifier.notify requires knowing that binding.
|
||||
- id: di_gap-callers-EmailNotifier-notify
|
||||
tier: di_gap
|
||||
query: "callers:src/notifications/email-notifier.service.ts::EmailNotifier.notify"
|
||||
expected:
|
||||
- src/notifications/notifications.controller.ts::NotificationsController.send
|
||||
|
||||
# Call-chain forward from the controller into the concrete method
|
||||
# behind the abstract binding. Same gap, traversing the other way.
|
||||
- id: di_gap-call_chain-NotificationsController-send
|
||||
tier: di_gap
|
||||
query: "call_chain:src/notifications/notifications.controller.ts::NotificationsController.send"
|
||||
expected:
|
||||
# Both are legitimate downstream calls from the handler: the guard
|
||||
# fires before the body via @UseGuards dispatch, the body's
|
||||
# `this.notifier.notify()` resolves through the useClass binding.
|
||||
- src/notifications/email-notifier.service.ts::EmailNotifier.notify
|
||||
- src/auth/auth.guard.ts::AuthGuard.canActivate
|
||||
|
||||
# @Inject(DATABASE_URL) token consumer. There's no type linking
|
||||
# ConfigService's dbUrl param to the `{ provide: DATABASE_URL,
|
||||
# useValue: ... }` entry — only the provider registry knows.
|
||||
- id: di_gap-usages-DATABASE_URL
|
||||
tier: di_gap
|
||||
query: "usages:src/config/config.tokens.ts::DATABASE_URL"
|
||||
expected:
|
||||
- src/config/config.service.ts::ConfigService
|
||||
- src/config/config.module.ts::ConfigModule
|
||||
|
||||
- id: di_gap-usages-FEATURE_FLAGS
|
||||
tier: di_gap
|
||||
query: "usages:src/config/config.tokens.ts::FEATURE_FLAGS"
|
||||
expected:
|
||||
- src/config/config.service.ts::ConfigService
|
||||
- src/config/config.module.ts::ConfigModule
|
||||
|
||||
# --- @UseGuards decorator-dispatch ---
|
||||
# NotificationsController.send is decorated with @UseGuards(AuthGuard).
|
||||
# The framework invokes AuthGuard.canActivate before the handler runs,
|
||||
# but there is no explicit call site — the binding lives in the
|
||||
# decorator metadata. get_callers on canActivate should include the
|
||||
# guarded handler.
|
||||
- id: di_gap-callers-AuthGuard-canActivate
|
||||
tier: di_gap
|
||||
query: "callers:src/auth/auth.guard.ts::AuthGuard.canActivate"
|
||||
expected:
|
||||
- src/notifications/notifications.controller.ts::NotificationsController.send
|
||||
|
||||
# --- Factory provider with inject: [Dep] ---
|
||||
# BillingService injects DB_CONNECTION, which BillingModule binds to a
|
||||
# DatabaseConnection produced by a factory that takes ConfigService.
|
||||
# Two signals to check:
|
||||
# 1. Token-level: usages of DB_CONNECTION.
|
||||
# 2. Call chain: BillingService.charge should reach
|
||||
# DatabaseConnection.query via the factory's produced binding.
|
||||
- id: di_gap-usages-DB_CONNECTION
|
||||
tier: di_gap
|
||||
query: "usages:src/billing/billing.tokens.ts::DB_CONNECTION"
|
||||
expected:
|
||||
- src/billing/billing.service.ts::BillingService
|
||||
- src/billing/billing.module.ts::BillingModule
|
||||
|
||||
- id: di_gap-call_chain-BillingService-charge
|
||||
tier: di_gap
|
||||
query: "call_chain:src/billing/billing.service.ts::BillingService.charge"
|
||||
expected:
|
||||
- src/billing/database.connection.ts::DatabaseConnection.query
|
||||
|
||||
# --- forwardRef circular injection ---
|
||||
# FeatureAService and FeatureBService inject each other via
|
||||
# `forwardRef(() => …)`. Static type resolution can't evaluate the
|
||||
# arrow, so without DI extraction the receiver types of `this.b` and
|
||||
# `this.a` are unknown and call chains across the cycle break.
|
||||
- id: di_gap-call_chain-FeatureAService-doA
|
||||
tier: di_gap
|
||||
query: "call_chain:src/feature/feature-a.service.ts::FeatureAService.doA"
|
||||
expected:
|
||||
- src/feature/feature-b.service.ts::FeatureBService.doB
|
||||
|
||||
- id: di_gap-call_chain-FeatureBService-callA
|
||||
tier: di_gap
|
||||
query: "call_chain:src/feature/feature-b.service.ts::FeatureBService.callA"
|
||||
expected:
|
||||
- src/feature/feature-a.service.ts::FeatureAService.doA
|
||||
|
||||
# --- Property (field) injection ---
|
||||
# NestJS permits @Inject on class fields instead of constructor params.
|
||||
# AuditService uses both the implicit (`@Inject() field!: T`) and
|
||||
# explicit-token (`@Inject(TOKEN) field: ...`) shapes. Without field-
|
||||
# level decorator extraction the call chain stops at AuditService —
|
||||
# the call to `this.users.findOne` has no receiver_type on its edge,
|
||||
# and find_usages on DATABASE_URL misses AuditService entirely.
|
||||
- id: di_gap-call_chain-AuditService-recordLogin
|
||||
tier: di_gap
|
||||
query: "call_chain:src/audit/audit.service.ts::AuditService.recordLogin"
|
||||
expected:
|
||||
- src/users/users.service.ts::UsersService.findOne
|
||||
|
||||
- id: di_gap-usages-DATABASE_URL-includes-audit
|
||||
tier: di_gap
|
||||
query: "usages:src/config/config.tokens.ts::DATABASE_URL"
|
||||
expected:
|
||||
- src/audit/audit.service.ts::AuditService
|
||||
|
||||
# --- Dynamic modules (forRoot / forFeature) ---
|
||||
# CacheModule.forRoot(ttl) returns a DynamicModule whose providers
|
||||
# array binds CACHE_TTL_SECONDS to the runtime ttl value. The binding
|
||||
# lives inside a static method body, not a @Module decorator — so
|
||||
# without dynamic-module extraction find_usages on the token surfaces
|
||||
# only the consumer, never the provider module.
|
||||
- id: di_gap-usages-CACHE_TTL_SECONDS
|
||||
tier: di_gap
|
||||
query: "usages:src/cache/cache.tokens.ts::CACHE_TTL_SECONDS"
|
||||
expected:
|
||||
- src/cache/cache.service.ts::CacheService
|
||||
- src/cache/cache.module.ts::CacheModule
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { UsersModule } from './users/users.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { NotificationsModule } from './notifications/notifications.module';
|
||||
import { ConfigModule } from './config/config.module';
|
||||
import { BillingModule } from './billing/billing.module';
|
||||
import { FeatureModule } from './feature/feature.module';
|
||||
import { AuditModule } from './audit/audit.module';
|
||||
import { CacheModule } from './cache/cache.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
UsersModule,
|
||||
AuthModule,
|
||||
NotificationsModule,
|
||||
ConfigModule,
|
||||
BillingModule,
|
||||
FeatureModule,
|
||||
AuditModule,
|
||||
CacheModule.forRoot(60),
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { ConfigModule } from '../config/config.module';
|
||||
import { AuditService } from './audit.service';
|
||||
|
||||
@Module({
|
||||
imports: [UsersModule, ConfigModule],
|
||||
providers: [AuditService],
|
||||
exports: [AuditService],
|
||||
})
|
||||
export class AuditModule {}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { DATABASE_URL } from '../config/config.tokens';
|
||||
|
||||
// Property injection: NestJS supports @Inject on class fields for cases
|
||||
// where the class can't declare a constructor (or the author prefers it
|
||||
// over parameter properties). Two shapes exercised:
|
||||
// - `@Inject() field!: T` — implicit token, class type drives binding
|
||||
// - `@Inject(TOKEN) field: ...` — explicit token, same as constructor form
|
||||
@Injectable()
|
||||
export class AuditService {
|
||||
@Inject()
|
||||
private readonly users!: UsersService;
|
||||
|
||||
@Inject(DATABASE_URL)
|
||||
private readonly dbUrl!: string;
|
||||
|
||||
async recordLogin(userId: string): Promise<string> {
|
||||
const user = await this.users.findOne(userId);
|
||||
return `audit(${this.dbUrl}): login by ${user.id}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
@Injectable()
|
||||
export class AuthGuard implements CanActivate {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const req = context.switchToHttp().getRequest();
|
||||
const userId = req.headers['x-user-id'];
|
||||
const token = req.headers['x-token'];
|
||||
if (!userId || !token) {
|
||||
throw new UnauthorizedException('missing credentials');
|
||||
}
|
||||
return this.authService.validate(userId, token);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AuthGuard } from './auth.guard';
|
||||
|
||||
@Module({
|
||||
imports: [UsersModule],
|
||||
providers: [AuthService, AuthGuard],
|
||||
exports: [AuthService, AuthGuard],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { UsersService } from '../users/users.service';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(private readonly usersService: UsersService) {}
|
||||
|
||||
async validate(userId: string, token: string): Promise<boolean> {
|
||||
const user = await this.usersService.findOne(userId);
|
||||
if (!user || token !== `tok_${user.id}`) {
|
||||
throw new UnauthorizedException('invalid token');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async issueToken(userId: string): Promise<string> {
|
||||
const user = await this.usersService.findOne(userId);
|
||||
return `tok_${user.id}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '../config/config.module';
|
||||
import { ConfigService } from '../config/config.service';
|
||||
import { DB_CONNECTION } from './billing.tokens';
|
||||
import { DatabaseConnection } from './database.connection';
|
||||
import { BillingService } from './billing.service';
|
||||
|
||||
// Factory provider: the binding DB_CONNECTION → DatabaseConnection is
|
||||
// produced by calling `dbFactory(cfg)` at module bootstrap. `inject:
|
||||
// [ConfigService]` is how Nest passes the factory its dependencies.
|
||||
// There is no `new DatabaseConnection(...)` call anywhere else in the
|
||||
// codebase; any graph analysis that wants to traverse "consumers of
|
||||
// DB_CONNECTION → DatabaseConnection methods" must read this factory.
|
||||
const dbFactory = (cfg: ConfigService) =>
|
||||
new DatabaseConnection(cfg.getDatabaseUrl());
|
||||
|
||||
@Module({
|
||||
imports: [ConfigModule],
|
||||
providers: [
|
||||
{
|
||||
provide: DB_CONNECTION,
|
||||
useFactory: dbFactory,
|
||||
inject: [ConfigService],
|
||||
},
|
||||
BillingService,
|
||||
],
|
||||
exports: [BillingService],
|
||||
})
|
||||
export class BillingModule {}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DatabaseConnection } from './database.connection';
|
||||
import { DB_CONNECTION } from './billing.tokens';
|
||||
|
||||
// Consumer of the factory-provided DatabaseConnection. The `db` param's
|
||||
// declared type IS DatabaseConnection — so with parameter-property typing
|
||||
// the receiver type resolution should work for `this.db.query(...)`. But
|
||||
// the *binding* from the token DB_CONNECTION to DatabaseConnection lives
|
||||
// only inside the factory — a graph walk from any consumer to "who
|
||||
// produces DB_CONNECTION" has no edge to follow without DI extraction.
|
||||
@Injectable()
|
||||
export class BillingService {
|
||||
constructor(
|
||||
@Inject(DB_CONNECTION) private readonly db: DatabaseConnection,
|
||||
) {}
|
||||
|
||||
async charge(userId: string, cents: number): Promise<void> {
|
||||
await this.db.query(`INSERT INTO charges (user_id, amount) VALUES ('${userId}', ${cents})`);
|
||||
}
|
||||
|
||||
async totals(userId: string): Promise<number> {
|
||||
const rows = await this.db.query<{ total: number }>(
|
||||
`SELECT SUM(amount) AS total FROM charges WHERE user_id = '${userId}'`,
|
||||
);
|
||||
return rows[0]?.total ?? 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// Separate file for the injection token so the module import graph
|
||||
// reflects real NestJS practice — consumers import the token without
|
||||
// pulling in the module and its factory provider.
|
||||
export const DB_CONNECTION = 'DB_CONNECTION';
|
||||
@@ -0,0 +1,17 @@
|
||||
// Concrete class wired up through a factory provider. The only place
|
||||
// the binding `"DB_CONNECTION" → DatabaseConnection` appears is inside
|
||||
// a `useFactory` in billing.module.ts — no `new DatabaseConnection(...)`
|
||||
// call survives anywhere else, so Tier-2 type resolution can't reach
|
||||
// this class from consumers that inject `@Inject('DB_CONNECTION')`.
|
||||
export class DatabaseConnection {
|
||||
constructor(public readonly url: string) {}
|
||||
|
||||
async query<T>(sql: string): Promise<T[]> {
|
||||
console.log(`[${this.url}] ${sql}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
// Close the pool in real code.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { DynamicModule, Module } from '@nestjs/common';
|
||||
import { CACHE_TTL_SECONDS } from './cache.tokens';
|
||||
import { CacheService } from './cache.service';
|
||||
|
||||
// Dynamic module: NestJS's forRoot / forFeature pattern returns a
|
||||
// runtime-computed module config. The providers array here is
|
||||
// structurally identical to a @Module providers array — same
|
||||
// { provide: X, useValue: ... } shape — but lives inside a static
|
||||
// method body instead of a decorator.
|
||||
@Module({})
|
||||
export class CacheModule {
|
||||
static forRoot(ttlSeconds: number): DynamicModule {
|
||||
return {
|
||||
module: CacheModule,
|
||||
providers: [
|
||||
{ provide: CACHE_TTL_SECONDS, useValue: ttlSeconds },
|
||||
CacheService,
|
||||
],
|
||||
exports: [CacheService],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { CACHE_TTL_SECONDS } from './cache.tokens';
|
||||
|
||||
// Consumer of a token provided only through CacheModule.forRoot().
|
||||
// Without dynamic-module extraction the graph sees the @Inject here
|
||||
// but no provider, so find_usages(CACHE_TTL_SECONDS) returns only
|
||||
// this consumer and leaves orphan-detection incomplete.
|
||||
@Injectable()
|
||||
export class CacheService {
|
||||
constructor(@Inject(CACHE_TTL_SECONDS) private readonly ttl: number) {}
|
||||
|
||||
ttlSeconds(): number {
|
||||
return this.ttl;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const CACHE_TTL_SECONDS = 'CACHE_TTL_SECONDS';
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from './config.service';
|
||||
|
||||
// Second-hop consumer: ConfigConsumer calls ConfigService, which itself
|
||||
// received its values via @Inject(TOKEN). The test is whether the graph
|
||||
// can reach *from* ConfigConsumer *to* the methods on ConfigService —
|
||||
// standard typed injection, should work — and whether the TOKEN-keyed
|
||||
// providers show up in any cross-reference (they won't, without DI
|
||||
// extraction).
|
||||
@Injectable()
|
||||
export class ConfigConsumer {
|
||||
constructor(private readonly config: ConfigService) {}
|
||||
|
||||
describe(): string {
|
||||
return `db=${this.config.getDatabaseUrl()} flags=${JSON.stringify(
|
||||
this.config.isEnabled('beta'),
|
||||
)}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DATABASE_URL, FEATURE_FLAGS } from './config.tokens';
|
||||
import { ConfigService } from './config.service';
|
||||
import { ConfigConsumer } from './config.consumer';
|
||||
|
||||
@Module({
|
||||
providers: [
|
||||
{ provide: DATABASE_URL, useValue: 'postgres://localhost/test' },
|
||||
{ provide: FEATURE_FLAGS, useValue: { beta: true } },
|
||||
ConfigService,
|
||||
ConfigConsumer,
|
||||
],
|
||||
exports: [ConfigService, ConfigConsumer],
|
||||
})
|
||||
export class ConfigModule {}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DATABASE_URL, FEATURE_FLAGS } from './config.tokens';
|
||||
|
||||
@Injectable()
|
||||
export class ConfigService {
|
||||
// Both constructor params receive their values via string-keyed
|
||||
// @Inject — no type info survives into the graph for the binder to
|
||||
// traverse.
|
||||
constructor(
|
||||
@Inject(DATABASE_URL) private readonly dbUrl: string,
|
||||
@Inject(FEATURE_FLAGS) private readonly flags: Record<string, boolean>,
|
||||
) {}
|
||||
|
||||
getDatabaseUrl(): string {
|
||||
return this.dbUrl;
|
||||
}
|
||||
|
||||
isEnabled(flag: string): boolean {
|
||||
return Boolean(this.flags[flag]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// String-keyed injection tokens. These are the NestJS idiom for
|
||||
// providing values that have no class identity — environment URLs,
|
||||
// configuration primitives, feature flags. A consumer asks for them
|
||||
// via `@Inject(DATABASE_URL)`; the Module provides them via
|
||||
// `{ provide: DATABASE_URL, useValue: '...' }`. No type-aware
|
||||
// resolution can cross this boundary.
|
||||
export const DATABASE_URL = 'DATABASE_URL';
|
||||
export const FEATURE_FLAGS = 'FEATURE_FLAGS';
|
||||
@@ -0,0 +1,20 @@
|
||||
import { forwardRef, Inject, Injectable } from '@nestjs/common';
|
||||
import { FeatureBService } from './feature-b.service';
|
||||
|
||||
// Two services that reference each other. NestJS requires forwardRef()
|
||||
// to break the import cycle; the lazy arrow function `() => FeatureBService`
|
||||
// is evaluated after module init. Static extraction can't call the arrow
|
||||
// at parse time, so the `b` parameter's declared type is effectively
|
||||
// unknown (or `any`) at extraction. Type-aware resolution can't link
|
||||
// `this.b.doB()` to FeatureBService.doB without following the forwardRef.
|
||||
@Injectable()
|
||||
export class FeatureAService {
|
||||
constructor(
|
||||
@Inject(forwardRef(() => FeatureBService))
|
||||
private readonly b: FeatureBService,
|
||||
) {}
|
||||
|
||||
doA(): string {
|
||||
return this.b.doB() + ' from A';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { forwardRef, Inject, Injectable } from '@nestjs/common';
|
||||
import { FeatureAService } from './feature-a.service';
|
||||
|
||||
@Injectable()
|
||||
export class FeatureBService {
|
||||
constructor(
|
||||
@Inject(forwardRef(() => FeatureAService))
|
||||
private readonly a: FeatureAService,
|
||||
) {}
|
||||
|
||||
doB(): string {
|
||||
return 'B';
|
||||
}
|
||||
|
||||
callA(): string {
|
||||
return this.a.doA();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { FeatureAService } from './feature-a.service';
|
||||
import { FeatureBService } from './feature-b.service';
|
||||
|
||||
@Module({
|
||||
providers: [FeatureAService, FeatureBService],
|
||||
exports: [FeatureAService, FeatureBService],
|
||||
})
|
||||
export class FeatureModule {}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Notifier } from './notifier.interface';
|
||||
|
||||
@Injectable()
|
||||
export class EmailNotifier extends Notifier {
|
||||
async notify(userId: string, message: string): Promise<void> {
|
||||
// In real code this would call an SMTP client; here the logic is
|
||||
// deliberately trivial — the fixture only needs the method to exist.
|
||||
console.log(`email to ${userId}: ${message}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
|
||||
import { Notifier } from './notifier.interface';
|
||||
import { AuthGuard } from '../auth/auth.guard';
|
||||
|
||||
@Controller('notifications')
|
||||
export class NotificationsController {
|
||||
// Injected by the abstract base class — the runtime instance will
|
||||
// be EmailNotifier (see notifications.module.ts), but the static
|
||||
// type here is `Notifier` so a type-aware resolver has no way to
|
||||
// pick the right concrete method.
|
||||
constructor(private readonly notifier: Notifier) {}
|
||||
|
||||
// @UseGuards binds the guard's canActivate() to this route. There is
|
||||
// no explicit call site to AuthGuard.canActivate anywhere in source —
|
||||
// the framework invokes it at request time. Without decorator-dispatch
|
||||
// extraction, get_callers on canActivate is empty for this endpoint.
|
||||
@Post('send')
|
||||
@UseGuards(AuthGuard)
|
||||
async send(@Body('userId') userId: string, @Body('message') message: string): Promise<void> {
|
||||
await this.notifier.notify(userId, message);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user