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

This commit is contained in:
wehub-resource-sync
2026-07-13 12:33:42 +08:00
commit a06f331eb8
3186 changed files with 689843 additions and 0 deletions
+26
View File
@@ -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.
+21
View File
@@ -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]
+20
View File
@@ -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)
+62
View File
@@ -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
+79
View File
@@ -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
+119
View File
@@ -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
+120
View File
@@ -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"
+100
View File
@@ -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"
+186
View File
@@ -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"
+672
View File
@@ -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
+64
View File
@@ -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
+48
View File
@@ -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
+46
View File
@@ -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
View File
@@ -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
+34
View File
@@ -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
View File
@@ -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).
+45
View File
@@ -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
+98
View File
@@ -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-стороне, чтобы дорезолвить либу.
+93
View File
@@ -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
View File
@@ -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.
+133
View File
@@ -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.54 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. ~3040% 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 AF 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.
+39
View File
@@ -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
View File
@@ -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!
+26
View File
@@ -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
View File
@@ -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.
+283
View File
@@ -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/
+30
View File
@@ -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
+189
View File
@@ -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.
---
[![CI](https://github.com/zzet/gortex/actions/workflows/ci.yml/badge.svg)](https://github.com/zzet/gortex/actions/workflows/ci.yml)
[![Latest release](https://img.shields.io/github/v/release/zzet/gortex?logo=github&sort=semver)](https://github.com/zzet/gortex/releases/latest)
[![Sigstore signed](https://img.shields.io/badge/sigstore-signed-66D4FF?logo=sigstore&logoColor=white)](docs/installation.md#verifying-releases-supply-chain-security)
[![SLSA 3](https://img.shields.io/badge/SLSA-Level%203-green)](https://slsa.dev/spec/v1.0/levels#build-l3)
[![VirusTotal](https://img.shields.io/badge/VirusTotal-0%2F91-brightgreen?logo=virustotal)](https://www.virustotal.com/gui/url/00e1094b39c9bd7db4d5a179b1d56173f85c915075057fd3cc64bfbb9b735b11/detection)
[![macOS](https://img.shields.io/badge/macOS-supported-blue.svg)](#)
[![Linux](https://img.shields.io/badge/Linux-supported-blue.svg)](#)
[![Windows](https://img.shields.io/badge/Windows-supported-blue.svg)](#)
[![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/zzet/gortex/badge)](https://scorecard.dev/viewer/?uri=github.com/zzet/gortex)
[![Go Reference](https://pkg.go.dev/badge/github.com/zzet/gortex.svg)](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>
![Gortex Web UI — force-directed knowledge graph visualization](assets/graph.png)
</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 (6580 %); 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.
+7
View File
@@ -0,0 +1,7 @@
# WeHub 来源说明
- 原始项目:`zzet/gortex`
- 原始仓库:https://github.com/zzet/gortex
- 导入方式:上游默认分支的最新快照
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
+110
View File
@@ -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.
+451
View File
@@ -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
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

+207
View File
@@ -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

+76
View File
@@ -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.
+333
View File
@@ -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
}
+37
View File
@@ -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"
]
}
}
+367
View File
@@ -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
}
+300
View File
@@ -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())
+15
View File
@@ -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"
]
}
+105
View File
@@ -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.
+447
View File
@@ -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)
}
+200
View File
@@ -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)
}
}
+8
View File
@@ -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"
}
}
+110
View File
@@ -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;
}
}
+11
View File
@@ -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})
+7
View File
@@ -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
+8
View File
@@ -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",
]
+124
View File
@@ -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;
}
}
+8
View File
@@ -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"
}
}
+79
View File
@@ -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
+8
View File
@@ -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']);
+9
View File
@@ -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"
}
}
+256
View File
@@ -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.
}
}
+22
View File
@@ -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],
};
}
}
+15
View File
@@ -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;
}
}
+1
View File
@@ -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