commit a06f331eb8165f7d7f75f4a0069bc2733b865af2 Author: wehub-resource-sync Date: Mon Jul 13 12:33:42 2026 +0800 chore: import upstream snapshot with attribution diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..fd7b8ac --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -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. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..9da822c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -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] diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..f8e9663 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -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) diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..a9da2b6 --- /dev/null +++ b/.github/dependabot.yml @@ -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 `@` 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 diff --git a/.github/workflows/bench-arm.yml b/.github/workflows/bench-arm.yml new file mode 100644 index 0000000..9699cc1 --- /dev/null +++ b/.github/workflows/bench-arm.yml @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..05ca221 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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/ diff --git a/.github/workflows/gortex-pr-review.yml.example b/.github/workflows/gortex-pr-review.yml.example new file mode 100644 index 0000000..4bfe433 --- /dev/null +++ b/.github/workflows/gortex-pr-review.yml.example @@ -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 ` 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 diff --git a/.github/workflows/init-smoke.yml b/.github/workflows/init-smoke.yml new file mode 100644 index 0000000..62633aa --- /dev/null +++ b/.github/workflows/init-smoke.yml @@ -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" diff --git a/.github/workflows/install-script.yml b/.github/workflows/install-script.yml new file mode 100644 index 0000000..6d1905e --- /dev/null +++ b/.github/workflows/install-script.yml @@ -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" diff --git a/.github/workflows/publish-claude-plugin.yml b/.github/workflows/publish-claude-plugin.yml new file mode 100644 index 0000000..e7f0624 --- /dev/null +++ b/.github/workflows/publish-claude-plugin.yml @@ -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" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..daa5d43 --- /dev/null +++ b/.github/workflows/release.yml @@ -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__.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 </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 diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 0000000..dc9384f --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -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 diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..a1ef44a --- /dev/null +++ b/.github/workflows/security.yml @@ -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 diff --git a/.github/workflows/skill-drift.yml b/.github/workflows/skill-drift.yml new file mode 100644 index 0000000..8dffcf5 --- /dev/null +++ b/.github/workflows/skill-drift.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6cdedac --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 0000000..3e097d8 --- /dev/null +++ b/.golangci.yaml @@ -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 diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..6835a6f --- /dev/null +++ b/.goreleaser.yml @@ -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/` 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). diff --git a/.gortex.yaml b/.gortex.yaml new file mode 100644 index 0000000..e8a5561 --- /dev/null +++ b/.gortex.yaml @@ -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 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..15d70c3 --- /dev/null +++ b/AGENTS.md @@ -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, , …)` часто приходит не литералом. +Форк распознаёт его тремя слоями, по возрастанию доверия: + +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-стороне, чтобы дорезолвить либу. diff --git a/BENCHMARK-SWE.md b/BENCHMARK-SWE.md new file mode 100644 index 0000000..52518e2 --- /dev/null +++ b/BENCHMARK-SWE.md @@ -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//` 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///` 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// \ + --out results/swebench//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/` diff --git a/BENCHMARK.md b/BENCHMARK.md new file mode 100644 index 0000000..29c29e1 --- /dev/null +++ b/BENCHMARK.md @@ -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 +``` + +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. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..bed021a --- /dev/null +++ b/CLAUDE.md @@ -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 ` for anything else; the verb reference is in `docs/cli.md`). Use whichever your harness has mounted — they are two front doors over the same handlers, and the daemon routes tool calls by name so the CLI reaches the full surface even under the `core` preset. + +Either way, you **MUST** prefer graph queries over file reads on every task in this repo — `search_symbols`, `find_usages`, `get_symbol_source`, `get_editing_context`, `smart_context`, `edit_symbol` / `edit_file` / `rename_symbol` / `batch_edit` (or the matching `gortex` verbs). PreToolUse hooks deny `Read` / `Grep` / `Glob` against indexed source; the deny message names the right tool. The MCP server registers 180+ tools but by default publishes only a curated `core` preset (~34 dev-cycle workhorses) eagerly in `tools/list`, deferring the rest behind the `tools_search` discovery tool (the `core`/`defer` default — see `docs/mcp.md`). Opt into the full eager surface with `GORTEX_TOOLS=full`; `GORTEX_LAZY_TOOLS=1` is the older all-or-nothing defer switch. The cross-project rule tables live in `~/.claude/CLAUDE.md` — neither is restated here. This file carries only project-specific guidance. + +### Discovery (read once, then keep using) + +- **Graph schema** — `gortex://schema` resource (node kinds, edge kinds, what each carries). +- **Analyzer rollups** — `gortex://report`, `gortex://surprises`, `gortex://god-nodes`, `gortex://questions`, `gortex://audit`. +- **Bootstrap state** — `gortex://stats`, `gortex://index-health`, `gortex://workspace`, `gortex://repos`, `gortex://active-project`. + +### LLM provider (powers `ask` and `search_symbols assist:` modes) + +Selected via `llm.provider` in `.gortex.yaml` or `~/.config/gortex/config.yaml`. The HTTP and subprocess providers are pure Go — available without `-tags llama`. + +| Provider | Backend | Requires | +|---|---|---| +| `local` (default) | in-process llama.cpp | `-tags llama` build + `llm.local.model` (a `.gguf` path) | +| `anthropic` | Messages API | `llm.anthropic.model` + `ANTHROPIC_API_KEY` | +| `openai` | Chat Completions | `llm.openai.model` + `OPENAI_API_KEY`. Optional `llm.openai.effort` → `reasoning_effort`. | +| `azure` | Azure OpenAI Service | `llm.azure.deployment` + endpoint (`llm.azure.endpoint` or `AZURE_OPENAI_ENDPOINT`) + `AZURE_OPENAI_API_KEY`. Deployment-in-path + `api-version` query + `api-key` header; `llm.azure.api_version` defaults to a recent GA. Same json_schema structured output as `openai`. | +| `ollama` | Ollama daemon | `llm.ollama.model` (+ `llm.ollama.host`, default `localhost:11434`) | +| `claudecli` | `claude` CLI subprocess | `claude` on `$PATH` (signed in once); optional `llm.claudecli.model` (`sonnet`/`opus`/…). Reuses the user's Claude Code subscription. | +| `codex` | OpenAI `codex` CLI subprocess | `codex` on `$PATH` (signed in once); optional `llm.codex.model`. Runs `codex exec` in a read-only sandbox; reuses the user's Codex / ChatGPT sign-in. | +| `copilot` | GitHub Copilot CLI subprocess | `copilot` on `$PATH` (signed in via `gh`); optional `llm.copilot.model`. Runs `copilot -p`. | +| `cursor` | Cursor Agent CLI subprocess | `cursor-agent` on `$PATH` (signed in once); optional `llm.cursor.model`. Runs `cursor-agent --output-format text -p`. | +| `opencode` | opencode CLI subprocess | `opencode` on `$PATH` (signed in once); optional `llm.opencode.model` (`provider/model`). Runs `opencode run`. | +| `gemini` | Google Gemini `generateContent` REST | `llm.gemini.model` (default `gemini-2.5-pro`) + `GEMINI_API_KEY`. Structured output via `responseSchema` (`additionalProperties` stripped — Gemini rejects it). | +| `bedrock` | AWS Bedrock Converse API (SigV4) | `llm.bedrock.model_id` (e.g. `anthropic.claude-sonnet-4-20250514-v1:0`) + `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY` (+ optional `AWS_SESSION_TOKEN` for STS). Region defaults to `us-east-1` (`llm.bedrock.region`). Structured output via forced `respond` tool. No AWS SDK dependency — SigV4 is implemented in ~100 LOC of stdlib. | +| `deepseek` | DeepSeek Chat Completions (OpenAI-compatible) | `llm.deepseek.model` (default `deepseek-chat`) + `DEEPSEEK_API_KEY`. Structured output uses `response_format: json_object` plus a schema hint in the system prompt — DeepSeek does not support strict `json_schema`. | + +`GORTEX_LLM_PROVIDER` / `GORTEX_LLM_MODEL` / `GORTEX_LLM_{CLAUDECLI,CODEX,COPILOT,CURSOR,OPENCODE}_BINARY` / `GORTEX_LLM_BEDROCK_REGION` / `GORTEX_LLM_AZURE_{ENDPOINT,DEPLOYMENT,API_VERSION}` / `GORTEX_LLM_EFFORT` override the file config. `GORTEX_LLM_MODEL` targets the active provider's model field (Gemini → `llm.gemini.model`, Bedrock → `llm.bedrock.model_id`, DeepSeek → `llm.deepseek.model`, etc.). If the active provider can't construct (missing model / API key, `local` without `-tags llama`, `claudecli` / `codex` without `claude` / `codex` on `$PATH`, `bedrock` without AWS credentials), the daemon logs a warning and `ask` stays unregistered — fall through to direct tools. + +**Custom providers.** Any OpenAI-compatible endpoint can be registered by name with `gortex provider add/list/show/remove` (writes `providers.json` next to the config; a repo-local `.gortex/providers.json` loads only under `GORTEX_ALLOW_LOCAL_PROVIDERS=1`). A registered name is selectable like any built-in via `llm.provider` / `GORTEX_LLM_PROVIDER`; entries carry `base_url` + `model` + optional `api_key_env`, `schema_mode` (`json_schema`/`json_object`/`prompt`), headers, and informational pricing. + +**Anthropic tuning.** `llm.anthropic.model` accepts the tier sentinels `claude-haiku` / `claude-sonnet` / `claude-opus`, resolved to the newest live model id (per-auth cache + pinned fallback; override per tier via `GORTEX_LLM_ANTHROPIC_{HAIKU,SONNET,OPUS}_MODEL`). Opt-in `llm.anthropic.prompt_caching` (+ `cache_ttl`) marks the system prompt and structured-output tool as ephemeral cache breakpoints; `llm.anthropic.thinking_mode` (`off`/`auto`/`manual`/`adaptive`) drives extended thinking on freeform requests; `llm.anthropic.effort` / `GORTEX_LLM_EFFORT` sends a model-gated reasoning effort. + +`llm.routing` (off by default) routes the `ask` agent to a cheaper or more capable model by graph-derived task complexity — set `routing.enabled`, `routing.simple_model`, `routing.complex_model`; the chosen `model` / `complexity` ride on the `ask` response. + +`search_symbols assist:` modes: `auto` (default — skips LLM for identifier queries, expands NL queries), `on` (forces expansion+rerank), `off` (pure BM25), `deep` (adds a body-grounded verification pass; +1.5–4 s; quality is highly model-dependent — unreliable on 3B local models, fine on 7B+ or hosted). + +### Non-obvious capabilities worth knowing + +- **`compress_bodies: true`** on `read_file` / `get_symbol_source` / `get_editing_context` elides function bodies to stubs while keeping signatures + doc-comments + structure. ~30–40% of original tokens. 14 languages. +- **Overlay sessions** (`overlay_push`, `overlay_list`, `overlay_drop`, `compare_with_overlay`) let editor extensions push unsaved buffers as a per-session shadow graph — every subsequent tool call reads through it without mutating base. Bound to the MCP session lifecycle; idle TTL via `GORTEX_OVERLAY_IDLE_TTL` (default 30m). +- **Speculative execution** (`preview_edit`, `simulate_chain`) takes an LSP `WorkspaceEdit` and returns the graph diff + broken callers/implementors + impact rollup + suggested tests + (optional) LSP diagnostics — disk untouched. `simulate_chain` with `keep: true` promotes the final state into a real overlay. +- **Change-contract pipeline** (`change_contract`, `symbols_for_ranges`) — one envelope every change source lowers into. `change_contract` takes a WorkspaceEdit, a git diff range (`source:diff base:…`), an explicit symbol set, or file line-ranges, runs LOWER → PREDICT → EVALUATE (guards + architecture + event-boundary rule families) → SCORE → CLASSIFY → EMIT, and returns one verdict `{allow|warn|refuse}` with reasons, risk, a `verification_command`, a checkable `stop_condition`, and an `edit_strategy`. `lens:api` focuses it on public-surface / API drift; `risk_gate:true` requires a TTL'd impact-review ack (`ack:true`, stored as a development memory) for load-bearing symbols. `symbols_for_ranges` is the standalone lowering primitive. The pre-write **parse gate** on `edit_file` / `write_file` refuses an edit that would introduce new tree-sitter parse errors (override with `allow_parse_errors`); `safe_delete_symbol propagate:true` patches surviving call sites; `analyze kind=suggest_boundaries` seeds an `architecture:` block from detected communities. +- **MCP 2026 Streamable HTTP** at `POST /mcp` — `gortex server` always mounts it; `gortex daemon --http-addr ` 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 ` for one PR; `--triage` / `--conflicts` / `--worktrees` / `--base` / `--format`; `gortex prs bundle`), and `gortex review [|--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 ` ingests a live database's schema. +- **`analyze` is a 61-kind dispatcher** — beyond the structural kinds, it now covers `impact` (composite change-risk score), `bottlenecks` (interprocedural computation-bottleneck risk — cognitive complexity, loop depth, transitive/hidden-O(n^k) loop nesting across calls, unguarded recursion), `health_score` (per-symbol A–F grade), `sast` / `named` / `unsafe_patterns` (security), `clusters`, `suggest_boundaries` (Leiden-community-seeded architecture-layer suggestions), `connectivity_health`, `tests_as_edges`, `synthesizers` (framework-dispatch-synthesized edges, grouped by pass + provenance), `resolution_outcomes` (structured why-unresolved taxonomy), `review` (an idiomatic/correctness rulepack — NPE, thread-safety check-then-act, N+1, logic errors across Go + Python — with a graph-grounded false-positive-reduction pass), and more. + +## MANDATORY: Session memory — save, recall, distill + +The `save_note` / `query_notes` / `distill_session` triplet is the agent's durable scratchpad. The graph remembers code; these tools remember **why you made a call**. Without them, every compaction erases hard-won context. + +Three triggers — not suggestions: + +1. **After a context compaction (or at session start in a touched repo)** — **call** `distill_session` first thing. Returns top symbols, pinned notes, decisions, and recent excerpts from prior sessions in this workspace. Use the digest to seed your mental model before reading any file. +2. **At every decision point** — **call** `save_note tags:"decision" body:""` 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:""`. 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:"" symbol_ids:""`. 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:"" body:"" 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:"" supersedes:"" body:""`. 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:""` 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:"" symbol_ids:""` 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. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..dbeae35 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..dc8cb06 --- /dev/null +++ b/CONTRIBUTING.md @@ -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/.go` implementing the `parser.Extractor` interface +3. Create `internal/parser/languages/_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! diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md new file mode 100644 index 0000000..8282543 --- /dev/null +++ b/CONTRIBUTORS.md @@ -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 diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..fbad67a --- /dev/null +++ b/LICENSE.md @@ -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 + + 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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d151892 --- /dev/null +++ b/Makefile @@ -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/ diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..b6c1dbe --- /dev/null +++ b/NOTICE @@ -0,0 +1,30 @@ +Gortex +Copyright 2024-2026 Andrey Kumanyaev + +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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..cafa5bf --- /dev/null +++ b/README.md @@ -0,0 +1,189 @@ + +
+

+ Gortex +

+ + +### 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) +
+ zzet%2Fgortex | Trendshift +
+ +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). + +
+Gortex Web UI — force-directed knowledge graph visualization + +![Gortex Web UI — force-directed knowledge graph visualization](assets/graph.png) + +
+ +## Why it matters + +- **50× fewer tokens per response** — graph-native lookups beat naive file reads. Agents read **just what they need**, not the full file, not the 500-line file around it. +- **Full development cycle** - no 'read file before edit'. Agents ask for sources, tell what to change, and don't waste context of reading noise. +- **257 languages/grammars** - every file in the repository reachable; no mix of tools and bloating/hallycinating agents. Three tiers (bespoke tree-sitter, regex, forest-backed signatures) plus Jupyter and Databricks notebooks → [docs/languages.md](docs/languages.md) +- **Cross-repo by default** — N repos in one graph; contracts, references, and call chains span repo boundaries with evidence-gated resolution, contract matching, impact analysis, per-session isolation → [docs/multi-repo.md](docs/multi-repo.md) +- **Extreamly fast analysis** — a precomputed depth-3 reach index turns blast-radius queries into O(seeds × reach) map lookups. Safe to ask "what breaks if I change this?" on every edit. No dozens of tool calls to grasp context. +- **Zero external dependencies** — single binary, everything in-process. No network, no model download to get started. Install, start daemon, use. +- **Agent integrations (17)** — `gortex init` configures every detected coding assistant on the machine → [docs/agents.md](docs/agents.md) +- **100+ MCP tools, 16 resources, 3 prompts** — symbol lookup, call chains, blast radius, dataflow, clone detection, refactoring, code actions → [docs/mcp.md](docs/mcp.md) +- **Semantic search default-on** — baked GloVe-50d (3.8 MB embedded), hybrid BM25 + vector + RRF, zero deps; opt-in MiniLM / Ollama / OpenAI → [docs/semantic-search.md](docs/semantic-search.md) +- **Speculative execution** — `preview_edit` / `simulate_chain` answer "what would change if I applied this WorkspaceEdit?" without touching disk +- **Live editor overlays** — push unsaved buffers as a shadow graph; tools read through it. Branching for parallel speculative sessions +- **GCX1 wire format** — published, round-trippable. **An additional −27% tokens vs JSON** at same fidelity → [docs/wire-format.md](docs/wire-format.md) +- **Long-living daemon** — one process serves every IDE window; live fsnotify, on-disk snapshots, restart, OS-supervised lifecycle +- **9 LLM providers (optional)** — local llama.cpp, Anthropic, OpenAI, Ollama, Claude / Codex CLI subprocess, Gemini, Bedrock, DeepSeek → [docs/llm.md](docs/llm.md) +- **Composable safety** — `verify_change`, `check_guards`, `audit_agent_config` flag broken callers, guard violations, stale docs before they ship +- **PR review, end to end** — `gortex prs` triages open PRs (per-PR blast radius, merge-order conflicts via shared communities, AI-ranked queue, reviewer suggestions); `gortex review` emits line-anchored findings with a BLOCK/REVIEW/APPROVE verdict from a graph-grounded rulepack; MCP tools (`pr_risk`, `get_pr_impact`, `review`, `review_pack`, `post_review`, …) expose it to agents → [docs/cli.md](docs/cli.md) +- **HTTP server + Web UI** — versioned `/v1/*` API + MCP 2026 Streamable HTTP; standalone Next.js 15 UI with five 3D graph modes → [docs/server.md](docs/server.md) +- **Telemetry off by default** — opt-in anonymous tool/command counts only (no code, paths, names, or exact counts); nothing transmitted unless you configure an endpoint. `gortex telemetry on|off|status`; honours `DO_NOT_TRACK` → [docs/telemetry.md](docs/telemetry.md) + +Full catalog of features: [docs/features.md](docs/features.md). Complete CLI reference: [docs/cli.md](docs/cli.md). + +## Install + +```bash +# macOS / Linux +curl -fsSL https://get.gortex.dev | sh + +# Windows (PowerShell) +irm https://get.gortex.dev/install.ps1 | iex +``` + +Detects OS/arch, verifies SHA256 + cosign, installs to PATH. Re-run to upgrade. Homebrew, `.deb` / `.rpm` / `.apk`, scoop, signed binaries, and from-source builds: [docs/installation.md](docs/installation.md). + +## Quick Start + +```bash +gortex install # one-time machine setup (MCP, skills, slash commands) +gortex daemon start --detach # background daemon +gortex track ~/projects/myapp # add a repo +cd ~/projects/myapp && gortex init # per-repo: .mcp.json, hooks, community routing +``` + +Your AI assistant now uses graph queries. Full 15-minute walkthrough: [docs/onboarding.md](docs/onboarding.md). + +## Cross-Repo API Contracts + +Gortex auto-detects API contracts across repos and matches providers to consumers, surfaced via the `contracts` MCP tool and the web UI Contracts page. + +| Contract type | Detection | Provider | Consumer | +|--------------|-----------|----------|----------| +| **HTTP routes** | Framework annotations (gin, Express, FastAPI, Spring, …) | Route handler | HTTP client calls (`fetch`, `http.Get`) | +| **gRPC** | Proto service definitions | Service RPC | Client stub calls | +| **GraphQL** | Schema type/field definitions | Schema | Query/mutation strings | +| **Message topics** | Kafka / RabbitMQ / NATS / Redis pub/sub | Publish calls | Subscribe calls | +| **WebSocket** | Event emit/listen patterns | `emit()` | `on()` | +| **Env vars** | `os.Getenv`, `process.env`, `.env` files | `Setenv` / `.env` | `Getenv` / `process.env` | +| **OpenAPI** | Swagger / OpenAPI spec files | Spec paths | (linked to HTTP routes) | +| **Temporal workflows** | Go / Java SDK annotations | Activity / workflow function | `ExecuteActivity` / `ExecuteChildWorkflow` | + +Contracts are normalised to canonical IDs (e.g. `http::GET::/api/users/{id}`) and matched across repos to detect orphan providers / consumers and mismatches. See [docs/contracts.md](docs/contracts.md). + +## Scale — battle-tested on large repos + +Measured on an Apple Silicon laptop with the default CGO build. + +| Repository | Files | Nodes | Edges | Index time | Throughput | Peak heap | +| ---------- | ----: | ----: | ----: | ---------: | ---------: | --------: | +| [torvalds/linux](https://github.com/torvalds/linux) | 70,333 | 1,690,174 | 6,239,570 | ~3 min | 300 files/s | 5.07 GB | +| [microsoft/vscode](https://github.com/microsoft/vscode) | 10,762 | 204,501 | 808,902 | ~1 min | 143 files/s | 580 MB | +| zzet/gortex (self) | 430 | 5,583 | 53,830 | 3.4s | 127 files/s | 52 MB | + +Parsing dominates wall time (65–80 %); reference resolution and search-index build scale sub-linearly. + +## Token savings dashboard + +`gortex savings` reports tokens saved vs naive file reads — per-call, per-session, and cumulative across restarts, priced in USD against the headline model. + +```text +Gortex Token Savings +==================== +Cost avoided: $168.69 (claude-opus-4) across 1,878 calls · 11,246,094 tokens saved + +Today ████████░░░░░░░░ 50.0% saved 9,200 / 18,400 tokens $0.14 +Last 7 days ██████████░░░░░░ 62.5% saved 60,100 / 96,200 tokens $0.90 +All time ███████████████░ 93.3% saved 11,246,094 / 12,050,716 tokens $168.69 +``` + +`--verbose` adds the per-tool breakdown; `--json` is machine-readable. Full reference: [docs/savings.md](docs/savings.md). + +## Architecture + +``` +gortex binary + CLI (cobra) ──> MultiIndexer ──> In-Memory Graph (shared, per-repo indexed) + MCP (stdio) ──────────────────> Query Engine (repo/project/ref scoping) + HTTP /v1/* ──────────────────> same tools + /v1/graph + /v1/events (SSE) + Daemon (unix) ──────────────────> shared graph for every MCP client, session isolation + MultiWatcher <── filesystem events (fsnotify, per-repo) + CrossRepoResolver ──> cross-repo edge creation (type-aware) + Persistence ──> gob+gzip snapshot (pluggable backend) +``` + +Data flow, graph schema (node and edge kinds, multi-repo fields, test taxonomy), persistence model: [docs/architecture.md](docs/architecture.md). + +## Documentation + +| Topic | Reference | +| --- | --- | +| First-time walkthrough | [onboarding.md](docs/onboarding.md) | +| Installation & supply-chain verification | [installation.md](docs/installation.md) | +| Full feature catalog | [features.md](docs/features.md) | +| CLI reference | [cli.md](docs/cli.md) | +| MCP tools, resources, prompts | [mcp.md](docs/mcp.md) | +| Multi-repo workspaces | [multi-repo.md](docs/multi-repo.md) | +| HTTP server + Web UI + MCP 2026 transport | [server.md](docs/server.md) | +| Cross-repo API contracts | [contracts.md](docs/contracts.md) | +| Semantic search | [semantic-search.md](docs/semantic-search.md) | +| Optional LLM features | [llm.md](docs/llm.md) | +| LSP integration | [lsp.md](docs/lsp.md) | +| Per-community skills & agent usage | [skills.md](docs/skills.md) | +| AI agent adapters (17) | [agents.md](docs/agents.md) | +| Supported languages (257) | [languages.md](docs/languages.md) | +| Token savings | [savings.md](docs/savings.md) | +| GCX1 wire format | [wire-format.md](docs/wire-format.md) | +| Architecture & graph schema | [architecture.md](docs/architecture.md) | +| Evaluation methodology | [04-evaluation/](docs/04-evaluation/) | +| Telemetry & privacy | [telemetry.md](docs/telemetry.md) | +| Versioning policy | [versioning.md](docs/versioning.md) | + +## Building from source + +```bash +make build # binary with version stamping +make test # go test -race ./... +make lint # golangci-lint +``` + +Requires Go 1.26+ and CGO (for tree-sitter C bindings). + +## License + +Apache License 2.0. See [LICENSE.md](LICENSE.md). Copyright 2024-2026 Andrey Kumanyaev . + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines on adding features, language extractors, and submitting PRs. diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..c76dfdd --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub 来源说明 + +- 原始项目:`zzet/gortex` +- 原始仓库:https://github.com/zzet/gortex +- 导入方式:上游默认分支的最新快照 +- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准 +- 本文件仅用于记录来源,不代表 WeHub 是原项目作者 diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..dc346c7 --- /dev/null +++ b/SECURITY.md @@ -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. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..dbf087d --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -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 +`@/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 diff --git a/assets/graph.png b/assets/graph.png new file mode 100644 index 0000000..1f49013 Binary files /dev/null and b/assets/graph.png differ diff --git a/assets/wall.png b/assets/wall.png new file mode 100644 index 0000000..167392f Binary files /dev/null and b/assets/wall.png differ diff --git a/assets/wall.svg b/assets/wall.svg new file mode 100644 index 0000000..ae620b0 --- /dev/null +++ b/assets/wall.svg @@ -0,0 +1,207 @@ + + Gortex — code graph and intelligence engine + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/bench/baselines/README.md b/bench/baselines/README.md new file mode 100644 index 0000000..a7cd4a2 --- /dev/null +++ b/bench/baselines/README.md @@ -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. diff --git a/bench/baselines/adapters.go b/bench/baselines/adapters.go new file mode 100644 index 0000000..6bc6b34 --- /dev/null +++ b/bench/baselines/adapters.go @@ -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 ` 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 "`. 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 `, +// 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 +} diff --git a/bench/baselines/groundtruth.json b/bench/baselines/groundtruth.json new file mode 100644 index 0000000..43c3c49 --- /dev/null +++ b/bench/baselines/groundtruth.json @@ -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" + ] + } +} diff --git a/bench/baselines/main.go b/bench/baselines/main.go new file mode 100644 index 0000000..74b3b01 --- /dev/null +++ b/bench/baselines/main.go @@ -0,0 +1,367 @@ +// Command baselines runs the per-baseline retrieval comparison: +// dispatch the same query set through each registered adapter, +// compare the per-query hit lists against a shared ground truth, +// and emit a per-adapter NDCG@10 + latency table. +// +// Smoke mode (`--smoke`) skips actual runs and reports which adapters +// are available on the local box — useful from CI to verify wiring +// without paying for the heavy Python baselines. +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "math" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +func main() { + repo := flag.String("repo", ".", "indexed corpus path") + queriesPath := flag.String("queries", "bench/baselines/queries.json", "JSON query set") + truthPath := flag.String("groundtruth", "bench/baselines/groundtruth.json", "JSON per-query expected file paths") + against := flag.String("against", "ripgrep,probe,colgrep,grepai,coderankembed,semble", "comma-separated adapter names to run (default: all)") + topK := flag.Int("top-k", 10, "top-K candidates per query (NDCG@10 uses K=10)") + out := flag.String("out", "", "output table path (default stdout)") + jsonOut := flag.String("json", "", "optional JSON metrics output") + format := flag.String("format", "markdown", "markdown | json") + smoke := flag.Bool("smoke", false, "skip runs; only probe availability of each requested adapter") + timeout := flag.Duration("timeout", 5*time.Minute, "per-adapter wall-clock cap") + flag.Parse() + + requested, err := resolveAdapters(*against) + if err != nil { + die("%v", err) + } + + if *smoke { + smokeReport(requested, *format, *out) + return + } + + absRepo, err := filepath.Abs(*repo) + if err != nil { + die("repo path: %v", err) + } + if _, err := os.Stat(absRepo); err != nil { + die("repo path: %v", err) + } + + queries, err := loadQueries(*queriesPath) + if err != nil { + die("queries: %v", err) + } + truth, err := loadGroundTruth(*truthPath) + if err != nil { + die("groundtruth: %v", err) + } + + rows := make([]adapterRow, 0, len(requested)) + for _, a := range requested { + fmt.Fprintf(os.Stderr, "[baselines] %s ... ", a.Name()) + avail, why := a.Available() + if !avail { + fmt.Fprintf(os.Stderr, "skipped (%s)\n", why) + rows = append(rows, adapterRow{ + Adapter: a.Name(), + Skipped: why, + }) + continue + } + t0 := time.Now() + ctx, cancel := context.WithTimeout(context.Background(), *timeout) + results := a.Run(ctx, queries, absRepo, *topK) + cancel() + elapsed := time.Since(t0) + + row := adapterRow{ + Adapter: a.Name(), + PerQuery: results, + TotalDuration: elapsed.String(), + } + row.MedianLatencyMs = medianLatency(results) + row.NDCGAt10 = ndcgAt10(results, truth, *topK) + fmt.Fprintf(os.Stderr, "NDCG@10 %.3f · median %.1fms · total %.1fs\n", + row.NDCGAt10, row.MedianLatencyMs, elapsed.Seconds()) + rows = append(rows, row) + } + + var primary []byte + switch strings.ToLower(*format) { + case "markdown", "md": + primary = []byte(renderMarkdown(rows)) + case "json": + primary = mustMarshalJSON(rows) + default: + die("unknown --format %q", *format) + } + if err := writeOutput(*out, primary); err != nil { + die("write output: %v", err) + } + if *jsonOut != "" { + if err := writeOutput(*jsonOut, mustMarshalJSON(rows)); err != nil { + die("write json: %v", err) + } + } +} + +// adapterRow is the per-baseline outcome with the columns the +// published table cares about. +type adapterRow struct { + Adapter string `json:"adapter"` + NDCGAt10 float64 `json:"ndcg_at_10"` + MedianLatencyMs float64 `json:"median_latency_ms"` + TotalDuration string `json:"total_duration"` + PerQuery []queryResult `json:"per_query,omitempty"` + Skipped string `json:"skipped,omitempty"` +} + +// resolveAdapters expands the --against flag into the requested +// adapter set, preserving the caller's order. Unknown names error +// out so a typo doesn't silently drop a baseline. +func resolveAdapters(spec string) ([]adapter, error) { + if strings.TrimSpace(spec) == "" { + return allAdapters(), nil + } + var out []adapter + for tok := range strings.SplitSeq(spec, ",") { + name := strings.TrimSpace(tok) + if name == "" { + continue + } + a := adapterByName(name) + if a == nil { + return nil, fmt.Errorf("unknown adapter %q (known: %s)", + name, strings.Join(adapterNames(), ", ")) + } + out = append(out, a) + } + if len(out) == 0 { + return allAdapters(), nil + } + return out, nil +} + +// smokeReport prints one row per requested adapter showing +// available / not-available with the install hint. Output respects +// --format; default markdown. +func smokeReport(adapters []adapter, format, out string) { + if strings.ToLower(format) == "json" { + rows := make([]map[string]any, 0, len(adapters)) + for _, a := range adapters { + avail, why := a.Available() + row := map[string]any{ + "adapter": a.Name(), + "available": avail, + } + if why != "" { + row["why"] = why + } + rows = append(rows, row) + } + raw, _ := json.MarshalIndent(rows, "", " ") + _ = writeOutput(out, append(raw, '\n')) + return + } + var b strings.Builder + fmt.Fprintln(&b, "# Baseline-adapter smoke check") + fmt.Fprintln(&b) + fmt.Fprintln(&b, "| adapter | available | reason |") + fmt.Fprintln(&b, "|---------|:---------:|--------|") + for _, a := range adapters { + avail, why := a.Available() + mark := "✗" + if avail { + mark = "✓" + } + if why == "" { + why = "—" + } + fmt.Fprintf(&b, "| %s | %s | %s |\n", a.Name(), mark, why) + } + _ = writeOutput(out, []byte(b.String())) +} + +// ndcgAt10 computes NDCG@10 across the result set. Per query: gain +// is 1 for ground-truth hits, 0 otherwise; DCG = sum gain_i / +// log2(i+2); IDCG = DCG of the perfect ordering. Mean across +// queries; 0 when the result set is empty. +func ndcgAt10(results []queryResult, truth map[string][]string, k int) float64 { + if len(results) == 0 { + return 0 + } + var sum float64 + counted := 0 + for _, r := range results { + if r.Error != "" { + continue + } + expected := truth[r.Query] + if len(expected) == 0 { + continue + } + expSet := map[string]bool{} + for _, e := range expected { + expSet[e] = true + } + // DCG@k + var dcg float64 + hits := r.Hits + if len(hits) > k { + hits = hits[:k] + } + for i, h := range hits { + if expSet[h] { + dcg += 1.0 / math.Log2(float64(i+2)) + } + } + // IDCG@k: best case is min(len(expected), k) hits at the top. + idealHits := min(len(expected), k) + var idcg float64 + for i := range idealHits { + idcg += 1.0 / math.Log2(float64(i+2)) + } + if idcg == 0 { + continue + } + sum += dcg / idcg + counted++ + } + if counted == 0 { + return 0 + } + return sum / float64(counted) +} + +func medianLatency(results []queryResult) float64 { + vs := make([]float64, 0, len(results)) + for _, r := range results { + if r.Error == "" { + vs = append(vs, r.LatencyMs) + } + } + if len(vs) == 0 { + return 0 + } + sort.Float64s(vs) + return vs[len(vs)/2] +} + +// --- rendering ------------------------------------------------------ + +func renderMarkdown(rows []adapterRow) string { + var b strings.Builder + fmt.Fprintln(&b, "# Retrieval-baselines NDCG@10 + speed") + fmt.Fprintln(&b) + fmt.Fprintln(&b, "_NDCG@10 against the ground-truth set + median per-query latency for each baseline. Adapters reporting `skipped` weren't available on this box; see the install hints in `bench/baselines/README.md`._") + fmt.Fprintln(&b) + fmt.Fprintln(&b, "| adapter | NDCG@10 | median latency | total | status |") + fmt.Fprintln(&b, "|---------|--------:|---------------:|------:|--------|") + for _, r := range rows { + if r.Skipped != "" { + fmt.Fprintf(&b, "| %s | — | — | — | skipped: %s |\n", r.Adapter, r.Skipped) + continue + } + fmt.Fprintf(&b, "| %s | %.3f | %s | %s | ✓ |\n", + r.Adapter, r.NDCGAt10, fmtMs(r.MedianLatencyMs), r.TotalDuration) + } + fmt.Fprintln(&b) + fmt.Fprintln(&b, summaryLine(rows)) + return b.String() +} + +func summaryLine(rows []adapterRow) string { + ran := 0 + bestName := "" + var bestScore float64 + for _, r := range rows { + if r.Skipped != "" { + continue + } + ran++ + if r.NDCGAt10 > bestScore { + bestScore = r.NDCGAt10 + bestName = r.Adapter + } + } + if ran == 0 { + return "_no adapters available — install at least one baseline (see bench/baselines/README.md)_" + } + return fmt.Sprintf("**Summary:** %d/%d adapter(s) ran. Best NDCG@10: %s @ %.3f.", + ran, len(rows), bestName, bestScore) +} + +func fmtMs(v float64) string { + if v == 0 { + return "—" + } + if v < 1 { + return fmt.Sprintf("%.2fms", v) + } + if v < 1000 { + return fmt.Sprintf("%.1fms", v) + } + return fmt.Sprintf("%.2fs", v/1000.0) +} + +// --- helpers -------------------------------------------------------- + +func die(format string, args ...any) { + fmt.Fprintf(os.Stderr, "baselines: "+format+"\n", args...) + os.Exit(1) +} + +func writeOutput(path string, body []byte) error { + if path == "" { + _, err := os.Stdout.Write(body) + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + return os.WriteFile(path, body, 0o644) +} + +func mustMarshalJSON(rows []adapterRow) []byte { + b, err := json.MarshalIndent(rows, "", " ") + if err != nil { + die("marshal json: %v", err) + } + return append(b, '\n') +} + +func loadQueries(path string) ([]string, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var doc struct { + Queries []string `json:"queries"` + } + if err := json.Unmarshal(raw, &doc); err != nil { + return nil, fmt.Errorf("parse %s: %w", path, err) + } + return doc.Queries, nil +} + +func loadGroundTruth(path string) (map[string][]string, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var doc struct { + Queries map[string][]string `json:"queries"` + } + if err := json.Unmarshal(raw, &doc); err != nil { + return nil, fmt.Errorf("parse %s: %w", path, err) + } + if doc.Queries == nil { + doc.Queries = map[string][]string{} + } + return doc.Queries, nil +} diff --git a/bench/baselines/main_test.go b/bench/baselines/main_test.go new file mode 100644 index 0000000..1a7fba7 --- /dev/null +++ b/bench/baselines/main_test.go @@ -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 +} diff --git a/bench/baselines/python/coderankembed_runner.py b/bench/baselines/python/coderankembed_runner.py new file mode 100644 index 0000000..8f07b30 --- /dev/null +++ b/bench/baselines/python/coderankembed_runner.py @@ -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()) diff --git a/bench/baselines/queries.json b/bench/baselines/queries.json new file mode 100644 index 0000000..23f091a --- /dev/null +++ b/bench/baselines/queries.json @@ -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" + ] +} diff --git a/bench/daemon-latency/README.md b/bench/daemon-latency/README.md new file mode 100644 index 0000000..bd1d6ee --- /dev/null +++ b/bench/daemon-latency/README.md @@ -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. diff --git a/bench/daemon-latency/main.go b/bench/daemon-latency/main.go new file mode 100644 index 0000000..50b18ac --- /dev/null +++ b/bench/daemon-latency/main.go @@ -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) +} diff --git a/bench/daemon-latency/main_test.go b/bench/daemon-latency/main_test.go new file mode 100644 index 0000000..fcbb22a --- /dev/null +++ b/bench/daemon-latency/main_test.go @@ -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) + } +} diff --git a/bench/fixtures/di/angular/package.json b/bench/fixtures/di/angular/package.json new file mode 100644 index 0000000..a508c40 --- /dev/null +++ b/bench/fixtures/di/angular/package.json @@ -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" + } +} diff --git a/bench/fixtures/di/angular/recall.yaml b/bench/fixtures/di/angular/recall.yaml new file mode 100644 index 0000000..92e9337 --- /dev/null +++ b/bench/fixtures/di/angular/recall.yaml @@ -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 diff --git a/bench/fixtures/di/angular/src/app/auth/auth.service.ts b/bench/fixtures/di/angular/src/app/auth/auth.service.ts new file mode 100644 index 0000000..43a3280 --- /dev/null +++ b/bench/fixtures/di/angular/src/app/auth/auth.service.ts @@ -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}`; + } +} diff --git a/bench/fixtures/di/angular/src/app/auth/session.service.ts b/bench/fixtures/di/angular/src/app/auth/session.service.ts new file mode 100644 index 0000000..39c3a9a --- /dev/null +++ b/bench/fixtures/di/angular/src/app/auth/session.service.ts @@ -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; + } +} diff --git a/bench/fixtures/di/angular/src/app/users/user.model.ts b/bench/fixtures/di/angular/src/app/users/user.model.ts new file mode 100644 index 0000000..c646cd7 --- /dev/null +++ b/bench/fixtures/di/angular/src/app/users/user.model.ts @@ -0,0 +1,4 @@ +export interface User { + id: string; + email: string; +} diff --git a/bench/fixtures/di/angular/src/app/users/users.controller.ts b/bench/fixtures/di/angular/src/app/users/users.controller.ts new file mode 100644 index 0000000..c2e9bc7 --- /dev/null +++ b/bench/fixtures/di/angular/src/app/users/users.controller.ts @@ -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(); + } +} diff --git a/bench/fixtures/di/angular/src/app/users/users.service.ts b/bench/fixtures/di/angular/src/app/users/users.service.ts new file mode 100644 index 0000000..53f2d46 --- /dev/null +++ b/bench/fixtures/di/angular/src/app/users/users.service.ts @@ -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(); + + 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; + } +} diff --git a/bench/fixtures/di/angular/tsconfig.json b/bench/fixtures/di/angular/tsconfig.json new file mode 100644 index 0000000..3e478ff --- /dev/null +++ b/bench/fixtures/di/angular/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "esnext", + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "strict": true, + "baseUrl": "./src" + }, + "include": ["src/**/*.ts"] +} diff --git a/bench/fixtures/di/fastapi/app/__init__.py b/bench/fixtures/di/fastapi/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bench/fixtures/di/fastapi/app/auth/__init__.py b/bench/fixtures/di/fastapi/app/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bench/fixtures/di/fastapi/app/auth/deps.py b/bench/fixtures/di/fastapi/app/auth/deps.py new file mode 100644 index 0000000..574d162 --- /dev/null +++ b/bench/fixtures/di/fastapi/app/auth/deps.py @@ -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)] diff --git a/bench/fixtures/di/fastapi/app/auth/service.py b/bench/fixtures/di/fastapi/app/auth/service.py new file mode 100644 index 0000000..cc28e09 --- /dev/null +++ b/bench/fixtures/di/fastapi/app/auth/service.py @@ -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 diff --git a/bench/fixtures/di/fastapi/app/config/__init__.py b/bench/fixtures/di/fastapi/app/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bench/fixtures/di/fastapi/app/config/settings.py b/bench/fixtures/di/fastapi/app/config/settings.py new file mode 100644 index 0000000..05d9b04 --- /dev/null +++ b/bench/fixtures/di/fastapi/app/config/settings.py @@ -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}) diff --git a/bench/fixtures/di/fastapi/app/main.py b/bench/fixtures/di/fastapi/app/main.py new file mode 100644 index 0000000..67391a0 --- /dev/null +++ b/bench/fixtures/di/fastapi/app/main.py @@ -0,0 +1,7 @@ +from fastapi import FastAPI + +from app.users.router import router as users_router + + +app = FastAPI() +app.include_router(users_router) diff --git a/bench/fixtures/di/fastapi/app/users/__init__.py b/bench/fixtures/di/fastapi/app/users/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bench/fixtures/di/fastapi/app/users/models.py b/bench/fixtures/di/fastapi/app/users/models.py new file mode 100644 index 0000000..73ae1af --- /dev/null +++ b/bench/fixtures/di/fastapi/app/users/models.py @@ -0,0 +1,8 @@ +from dataclasses import dataclass + + +@dataclass +class User: + id: str + email: str + name: str diff --git a/bench/fixtures/di/fastapi/app/users/router.py b/bench/fixtures/di/fastapi/app/users/router.py new file mode 100644 index 0000000..409e810 --- /dev/null +++ b/bench/fixtures/di/fastapi/app/users/router.py @@ -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) diff --git a/bench/fixtures/di/fastapi/app/users/service.py b/bench/fixtures/di/fastapi/app/users/service.py new file mode 100644 index 0000000..2e44c8f --- /dev/null +++ b/bench/fixtures/di/fastapi/app/users/service.py @@ -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 diff --git a/bench/fixtures/di/fastapi/pyproject.toml b/bench/fixtures/di/fastapi/pyproject.toml new file mode 100644 index 0000000..4dc1fb4 --- /dev/null +++ b/bench/fixtures/di/fastapi/pyproject.toml @@ -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", +] diff --git a/bench/fixtures/di/fastapi/recall.yaml b/bench/fixtures/di/fastapi/recall.yaml new file mode 100644 index 0000000..63efd14 --- /dev/null +++ b/bench/fixtures/di/fastapi/recall.yaml @@ -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] diff --git a/bench/fixtures/di/laravel/app/Http/Controllers/UserController.php b/bench/fixtures/di/laravel/app/Http/Controllers/UserController.php new file mode 100644 index 0000000..c1ca716 --- /dev/null +++ b/bench/fixtures/di/laravel/app/Http/Controllers/UserController.php @@ -0,0 +1,42 @@ +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(); + } +} diff --git a/bench/fixtures/di/laravel/app/Http/Middleware/AdminMiddleware.php b/bench/fixtures/di/laravel/app/Http/Middleware/AdminMiddleware.php new file mode 100644 index 0000000..6cc8173 --- /dev/null +++ b/bench/fixtures/di/laravel/app/Http/Middleware/AdminMiddleware.php @@ -0,0 +1,16 @@ +user['admin'])) { + return response('forbidden', 403); + } + return $next($request); + } +} diff --git a/bench/fixtures/di/laravel/app/Http/Middleware/AuthenticateMiddleware.php b/bench/fixtures/di/laravel/app/Http/Middleware/AuthenticateMiddleware.php new file mode 100644 index 0000000..4a8d95a --- /dev/null +++ b/bench/fixtures/di/laravel/app/Http/Middleware/AuthenticateMiddleware.php @@ -0,0 +1,21 @@ +middleware('auth'). +class AuthenticateMiddleware +{ + public function handle($request, Closure $next) + { + if (empty($request->user)) { + return response('unauthorized', 401); + } + return $next($request); + } +} diff --git a/bench/fixtures/di/laravel/app/Providers/AppServiceProvider.php b/bench/fixtures/di/laravel/app/Providers/AppServiceProvider.php new file mode 100644 index 0000000..1c25777 --- /dev/null +++ b/bench/fixtures/di/laravel/app/Providers/AppServiceProvider.php @@ -0,0 +1,28 @@ +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'); + }); + } +} diff --git a/bench/fixtures/di/laravel/app/Repositories/EloquentUserRepository.php b/bench/fixtures/di/laravel/app/Repositories/EloquentUserRepository.php new file mode 100644 index 0000000..3fd6af4 --- /dev/null +++ b/bench/fixtures/di/laravel/app/Repositories/EloquentUserRepository.php @@ -0,0 +1,20 @@ + $id]; + } + + public function all(): array + { + return []; + } +} diff --git a/bench/fixtures/di/laravel/app/Repositories/UserRepository.php b/bench/fixtures/di/laravel/app/Repositories/UserRepository.php new file mode 100644 index 0000000..d151a65 --- /dev/null +++ b/bench/fixtures/di/laravel/app/Repositories/UserRepository.php @@ -0,0 +1,9 @@ +timezone; + } +} diff --git a/bench/fixtures/di/laravel/composer.json b/bench/fixtures/di/laravel/composer.json new file mode 100644 index 0000000..d98bead --- /dev/null +++ b/bench/fixtures/di/laravel/composer.json @@ -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" + } +} diff --git a/bench/fixtures/di/laravel/recall.yaml b/bench/fixtures/di/laravel/recall.yaml new file mode 100644 index 0000000..5a38761 --- /dev/null +++ b/bench/fixtures/di/laravel/recall.yaml @@ -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 diff --git a/bench/fixtures/di/laravel/routes/web.php b/bench/fixtures/di/laravel/routes/web.php new file mode 100644 index 0000000..737f9eb --- /dev/null +++ b/bench/fixtures/di/laravel/routes/web.php @@ -0,0 +1,8 @@ +" / "call_chain:" 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: `::.` — +# 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 diff --git a/bench/fixtures/di/nestjs/src/app.module.ts b/bench/fixtures/di/nestjs/src/app.module.ts new file mode 100644 index 0000000..cd13201 --- /dev/null +++ b/bench/fixtures/di/nestjs/src/app.module.ts @@ -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 {} diff --git a/bench/fixtures/di/nestjs/src/audit/audit.module.ts b/bench/fixtures/di/nestjs/src/audit/audit.module.ts new file mode 100644 index 0000000..e688859 --- /dev/null +++ b/bench/fixtures/di/nestjs/src/audit/audit.module.ts @@ -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 {} diff --git a/bench/fixtures/di/nestjs/src/audit/audit.service.ts b/bench/fixtures/di/nestjs/src/audit/audit.service.ts new file mode 100644 index 0000000..b8e2fa0 --- /dev/null +++ b/bench/fixtures/di/nestjs/src/audit/audit.service.ts @@ -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 { + const user = await this.users.findOne(userId); + return `audit(${this.dbUrl}): login by ${user.id}`; + } +} diff --git a/bench/fixtures/di/nestjs/src/auth/auth.guard.ts b/bench/fixtures/di/nestjs/src/auth/auth.guard.ts new file mode 100644 index 0000000..0e3886b --- /dev/null +++ b/bench/fixtures/di/nestjs/src/auth/auth.guard.ts @@ -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 { + 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); + } +} diff --git a/bench/fixtures/di/nestjs/src/auth/auth.module.ts b/bench/fixtures/di/nestjs/src/auth/auth.module.ts new file mode 100644 index 0000000..984ca94 --- /dev/null +++ b/bench/fixtures/di/nestjs/src/auth/auth.module.ts @@ -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 {} diff --git a/bench/fixtures/di/nestjs/src/auth/auth.service.ts b/bench/fixtures/di/nestjs/src/auth/auth.service.ts new file mode 100644 index 0000000..b102fd9 --- /dev/null +++ b/bench/fixtures/di/nestjs/src/auth/auth.service.ts @@ -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 { + 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 { + const user = await this.usersService.findOne(userId); + return `tok_${user.id}`; + } +} diff --git a/bench/fixtures/di/nestjs/src/billing/billing.module.ts b/bench/fixtures/di/nestjs/src/billing/billing.module.ts new file mode 100644 index 0000000..805d32e --- /dev/null +++ b/bench/fixtures/di/nestjs/src/billing/billing.module.ts @@ -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 {} diff --git a/bench/fixtures/di/nestjs/src/billing/billing.service.ts b/bench/fixtures/di/nestjs/src/billing/billing.service.ts new file mode 100644 index 0000000..1aa3140 --- /dev/null +++ b/bench/fixtures/di/nestjs/src/billing/billing.service.ts @@ -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 { + await this.db.query(`INSERT INTO charges (user_id, amount) VALUES ('${userId}', ${cents})`); + } + + async totals(userId: string): Promise { + const rows = await this.db.query<{ total: number }>( + `SELECT SUM(amount) AS total FROM charges WHERE user_id = '${userId}'`, + ); + return rows[0]?.total ?? 0; + } +} diff --git a/bench/fixtures/di/nestjs/src/billing/billing.tokens.ts b/bench/fixtures/di/nestjs/src/billing/billing.tokens.ts new file mode 100644 index 0000000..bbffcc3 --- /dev/null +++ b/bench/fixtures/di/nestjs/src/billing/billing.tokens.ts @@ -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'; diff --git a/bench/fixtures/di/nestjs/src/billing/database.connection.ts b/bench/fixtures/di/nestjs/src/billing/database.connection.ts new file mode 100644 index 0000000..3d58dcc --- /dev/null +++ b/bench/fixtures/di/nestjs/src/billing/database.connection.ts @@ -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(sql: string): Promise { + console.log(`[${this.url}] ${sql}`); + return []; + } + + async close(): Promise { + // Close the pool in real code. + } +} diff --git a/bench/fixtures/di/nestjs/src/cache/cache.module.ts b/bench/fixtures/di/nestjs/src/cache/cache.module.ts new file mode 100644 index 0000000..70070b7 --- /dev/null +++ b/bench/fixtures/di/nestjs/src/cache/cache.module.ts @@ -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], + }; + } +} diff --git a/bench/fixtures/di/nestjs/src/cache/cache.service.ts b/bench/fixtures/di/nestjs/src/cache/cache.service.ts new file mode 100644 index 0000000..f5dca06 --- /dev/null +++ b/bench/fixtures/di/nestjs/src/cache/cache.service.ts @@ -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; + } +} diff --git a/bench/fixtures/di/nestjs/src/cache/cache.tokens.ts b/bench/fixtures/di/nestjs/src/cache/cache.tokens.ts new file mode 100644 index 0000000..b02516f --- /dev/null +++ b/bench/fixtures/di/nestjs/src/cache/cache.tokens.ts @@ -0,0 +1 @@ +export const CACHE_TTL_SECONDS = 'CACHE_TTL_SECONDS'; diff --git a/bench/fixtures/di/nestjs/src/config/config.consumer.ts b/bench/fixtures/di/nestjs/src/config/config.consumer.ts new file mode 100644 index 0000000..5e5eb51 --- /dev/null +++ b/bench/fixtures/di/nestjs/src/config/config.consumer.ts @@ -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'), + )}`; + } +} diff --git a/bench/fixtures/di/nestjs/src/config/config.module.ts b/bench/fixtures/di/nestjs/src/config/config.module.ts new file mode 100644 index 0000000..c622166 --- /dev/null +++ b/bench/fixtures/di/nestjs/src/config/config.module.ts @@ -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 {} diff --git a/bench/fixtures/di/nestjs/src/config/config.service.ts b/bench/fixtures/di/nestjs/src/config/config.service.ts new file mode 100644 index 0000000..f296d2c --- /dev/null +++ b/bench/fixtures/di/nestjs/src/config/config.service.ts @@ -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, + ) {} + + getDatabaseUrl(): string { + return this.dbUrl; + } + + isEnabled(flag: string): boolean { + return Boolean(this.flags[flag]); + } +} diff --git a/bench/fixtures/di/nestjs/src/config/config.tokens.ts b/bench/fixtures/di/nestjs/src/config/config.tokens.ts new file mode 100644 index 0000000..aa9894e --- /dev/null +++ b/bench/fixtures/di/nestjs/src/config/config.tokens.ts @@ -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'; diff --git a/bench/fixtures/di/nestjs/src/feature/feature-a.service.ts b/bench/fixtures/di/nestjs/src/feature/feature-a.service.ts new file mode 100644 index 0000000..8dbb5cc --- /dev/null +++ b/bench/fixtures/di/nestjs/src/feature/feature-a.service.ts @@ -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'; + } +} diff --git a/bench/fixtures/di/nestjs/src/feature/feature-b.service.ts b/bench/fixtures/di/nestjs/src/feature/feature-b.service.ts new file mode 100644 index 0000000..896a429 --- /dev/null +++ b/bench/fixtures/di/nestjs/src/feature/feature-b.service.ts @@ -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(); + } +} diff --git a/bench/fixtures/di/nestjs/src/feature/feature.module.ts b/bench/fixtures/di/nestjs/src/feature/feature.module.ts new file mode 100644 index 0000000..fcaabe3 --- /dev/null +++ b/bench/fixtures/di/nestjs/src/feature/feature.module.ts @@ -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 {} diff --git a/bench/fixtures/di/nestjs/src/notifications/email-notifier.service.ts b/bench/fixtures/di/nestjs/src/notifications/email-notifier.service.ts new file mode 100644 index 0000000..d22db20 --- /dev/null +++ b/bench/fixtures/di/nestjs/src/notifications/email-notifier.service.ts @@ -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 { + // 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}`); + } +} diff --git a/bench/fixtures/di/nestjs/src/notifications/notifications.controller.ts b/bench/fixtures/di/nestjs/src/notifications/notifications.controller.ts new file mode 100644 index 0000000..52b8ad2 --- /dev/null +++ b/bench/fixtures/di/nestjs/src/notifications/notifications.controller.ts @@ -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 { + await this.notifier.notify(userId, message); + } +} diff --git a/bench/fixtures/di/nestjs/src/notifications/notifications.module.ts b/bench/fixtures/di/nestjs/src/notifications/notifications.module.ts new file mode 100644 index 0000000..d7be761 --- /dev/null +++ b/bench/fixtures/di/nestjs/src/notifications/notifications.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; +import { Notifier } from './notifier.interface'; +import { EmailNotifier } from './email-notifier.service'; +import { SmsNotifier } from './sms-notifier.service'; +import { NotificationsController } from './notifications.controller'; +import { AuthModule } from '../auth/auth.module'; + +// The `provide: Notifier, useClass: EmailNotifier` binding is the only +// place a static reader can learn that NotificationsController's +// `notifier` parameter resolves to EmailNotifier specifically. +@Module({ + imports: [AuthModule], + controllers: [NotificationsController], + providers: [ + { provide: Notifier, useClass: EmailNotifier }, + SmsNotifier, + ], + exports: [Notifier, SmsNotifier], +}) +export class NotificationsModule {} diff --git a/bench/fixtures/di/nestjs/src/notifications/notifier.interface.ts b/bench/fixtures/di/nestjs/src/notifications/notifier.interface.ts new file mode 100644 index 0000000..b3c7959 --- /dev/null +++ b/bench/fixtures/di/nestjs/src/notifications/notifier.interface.ts @@ -0,0 +1,8 @@ +// Abstract base class used as the injection token — NestJS supports +// `constructor(private n: Notifier)` where Notifier is an abstract class +// and the Module provides `{ provide: Notifier, useClass: EmailNotifier }`. +// Type-aware resolution can't cross this indirection without knowing the +// provider binding; exactly the gap option 1's DI extractor targets. +export abstract class Notifier { + abstract notify(userId: string, message: string): Promise; +} diff --git a/bench/fixtures/di/nestjs/src/notifications/sms-notifier.service.ts b/bench/fixtures/di/nestjs/src/notifications/sms-notifier.service.ts new file mode 100644 index 0000000..80c3195 --- /dev/null +++ b/bench/fixtures/di/nestjs/src/notifications/sms-notifier.service.ts @@ -0,0 +1,13 @@ +import { Injectable } from '@nestjs/common'; +import { Notifier } from './notifier.interface'; + +// Second implementation of the same abstract class. Makes the +// abstract-injection case ambiguous — module-level binding is what +// actually picks between EmailNotifier and SmsNotifier. This is the +// shape Tier-2 type-aware resolution cannot handle. +@Injectable() +export class SmsNotifier extends Notifier { + async notify(userId: string, message: string): Promise { + console.log(`sms to ${userId}: ${message}`); + } +} diff --git a/bench/fixtures/di/nestjs/src/users/user.entity.ts b/bench/fixtures/di/nestjs/src/users/user.entity.ts new file mode 100644 index 0000000..316de68 --- /dev/null +++ b/bench/fixtures/di/nestjs/src/users/user.entity.ts @@ -0,0 +1,7 @@ +export class User { + constructor( + public readonly id: string, + public readonly email: string, + public readonly name: string, + ) {} +} diff --git a/bench/fixtures/di/nestjs/src/users/users.controller.ts b/bench/fixtures/di/nestjs/src/users/users.controller.ts new file mode 100644 index 0000000..a195bda --- /dev/null +++ b/bench/fixtures/di/nestjs/src/users/users.controller.ts @@ -0,0 +1,26 @@ +import { Body, Controller, Get, Param, Post } from '@nestjs/common'; +import { UsersService } from './users.service'; +import { User } from './user.entity'; + +@Controller('users') +export class UsersController { + constructor(private readonly usersService: UsersService) {} + + @Get() + async list(): Promise { + return this.usersService.findAll(); + } + + @Get(':id') + async getUser(@Param('id') id: string): Promise { + return this.usersService.findOne(id); + } + + @Post() + async create( + @Body('email') email: string, + @Body('name') name: string, + ): Promise { + return this.usersService.create(email, name); + } +} diff --git a/bench/fixtures/di/nestjs/src/users/users.module.ts b/bench/fixtures/di/nestjs/src/users/users.module.ts new file mode 100644 index 0000000..513776d --- /dev/null +++ b/bench/fixtures/di/nestjs/src/users/users.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { UsersController } from './users.controller'; +import { UsersService } from './users.service'; + +@Module({ + controllers: [UsersController], + providers: [UsersService], + exports: [UsersService], +}) +export class UsersModule {} diff --git a/bench/fixtures/di/nestjs/src/users/users.service.ts b/bench/fixtures/di/nestjs/src/users/users.service.ts new file mode 100644 index 0000000..09f05d4 --- /dev/null +++ b/bench/fixtures/di/nestjs/src/users/users.service.ts @@ -0,0 +1,26 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { User } from './user.entity'; + +@Injectable() +export class UsersService { + private readonly users: Map = new Map(); + + async findOne(id: string): Promise { + const u = this.users.get(id); + if (!u) { + throw new NotFoundException(`user ${id} not found`); + } + return u; + } + + async findAll(): Promise { + return Array.from(this.users.values()); + } + + async create(email: string, name: string): Promise { + const id = `usr_${this.users.size + 1}`; + const user = new User(id, email, name); + this.users.set(id, user); + return user; + } +} diff --git a/bench/fixtures/di/nestjs/tsconfig.json b/bench/fixtures/di/nestjs/tsconfig.json new file mode 100644 index 0000000..327dfdb --- /dev/null +++ b/bench/fixtures/di/nestjs/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2021", + "module": "commonjs", + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "strict": true, + "baseUrl": "./src" + }, + "include": ["src/**/*.ts"] +} diff --git a/bench/fixtures/di/phoenix/lib/my_app_web/controllers/user_controller.ex b/bench/fixtures/di/phoenix/lib/my_app_web/controllers/user_controller.ex new file mode 100644 index 0000000..43aba51 --- /dev/null +++ b/bench/fixtures/di/phoenix/lib/my_app_web/controllers/user_controller.ex @@ -0,0 +1,55 @@ +defmodule MyAppWeb.UserController do + use Phoenix.Controller, namespace: MyAppWeb + + # Controller-level plug: runs before every action in this module. + # Phoenix resolves the binding at request time via the plug chain; + # there's no explicit call site between the action and the plug + # function. + plug :authenticate + + # Guarded form with `when action in [...]`: same DI shape as Rails's + # `before_action :name, only: [...]`. The plug only fires for the + # listed actions. + plug :load_user when action in [:show, :update, :delete] + + # Regular actions. Bodies deliberately do NOT call authenticate or + # load_user directly — the only path from these actions to those + # plugs is the decorator-dispatch edge we want to extract. + def index(conn, _params) do + render(conn, :index) + end + + def show(conn, _params) do + render(conn, :show, user: conn.assigns[:user]) + end + + def update(conn, params) do + user = conn.assigns[:user] + _ = Map.merge(user, params) + render(conn, :show, user: user) + end + + def delete(conn, _params) do + send_resp(conn, 204, "") + end + + # Plug functions themselves. Their bodies have real call edges (to + # Plug.Conn helpers etc.) which the existing extractor handles; + # only the binding from actions to these functions is DI-gap + # territory. + def authenticate(conn, _opts) do + case get_session(conn, :user_id) do + nil -> halt_unauthorized(conn) + _ -> conn + end + end + + def load_user(conn, _opts) do + user = %{id: conn.params["id"]} + assign(conn, :user, user) + end + + defp halt_unauthorized(conn) do + send_resp(conn, 401, "unauthorized") + end +end diff --git a/bench/fixtures/di/phoenix/lib/my_app_web/router.ex b/bench/fixtures/di/phoenix/lib/my_app_web/router.ex new file mode 100644 index 0000000..f87a07f --- /dev/null +++ b/bench/fixtures/di/phoenix/lib/my_app_web/router.ex @@ -0,0 +1,7 @@ +defmodule MyAppWeb.Router do + use Phoenix.Router + + scope "/", MyAppWeb do + resources "/users", UserController + end +end diff --git a/bench/fixtures/di/phoenix/mix.exs b/bench/fixtures/di/phoenix/mix.exs new file mode 100644 index 0000000..4ce4c64 --- /dev/null +++ b/bench/fixtures/di/phoenix/mix.exs @@ -0,0 +1,13 @@ +defmodule MyApp.MixProject do + # Not built — static corpus for the Gortex indexer. + use Mix.Project + + def project do + [ + app: :my_app, + version: "0.0.0", + elixir: "~> 1.14", + deps: [{:phoenix, "~> 1.7"}] + ] + end +end diff --git a/bench/fixtures/di/phoenix/recall.yaml b/bench/fixtures/di/phoenix/recall.yaml new file mode 100644 index 0000000..a0c3977 --- /dev/null +++ b/bench/fixtures/di/phoenix/recall.yaml @@ -0,0 +1,58 @@ +# Phoenix DI-recall fixture +# +# Phoenix's controller-level DI is `plug :function_name` — binds a +# middleware function to run before each action. Same shape as Rails +# before_action / NestJS @UseGuards: no explicit call site, framework +# dispatches based on module metadata. +# +# Only controller-scoped plugs are covered here. Router-pipeline +# plugs (`pipeline :browser do plug X end`) cross module boundaries +# and are noted as future work. +name: gortex-phoenix-di +cases: + + # exact + - id: exact-UserController + tier: exact + query: UserController + expected: [lib/my_app_web/controllers/user_controller.ex::MyAppWeb.UserController] + - id: exact-authenticate + tier: exact + query: authenticate + expected: [lib/my_app_web/controllers/user_controller.ex::MyAppWeb.UserController.authenticate] + - id: exact-load_user + tier: exact + query: load_user + expected: [lib/my_app_web/controllers/user_controller.ex::MyAppWeb.UserController.load_user] + + # di_gap: controller-scoped plug dispatch + # `plug :authenticate` runs for every action. callers:authenticate + # should return every action in the controller. + - id: di_gap-callers-authenticate + tier: di_gap + query: "callers:lib/my_app_web/controllers/user_controller.ex::MyAppWeb.UserController.authenticate" + expected: + - lib/my_app_web/controllers/user_controller.ex::MyAppWeb.UserController.index + - lib/my_app_web/controllers/user_controller.ex::MyAppWeb.UserController.show + - lib/my_app_web/controllers/user_controller.ex::MyAppWeb.UserController.update + - lib/my_app_web/controllers/user_controller.ex::MyAppWeb.UserController.delete + + # `plug :load_user when action in [:show, :update, :delete]` — + # only fires for those three actions. index/index-like should NOT + # be linked. + - id: di_gap-callers-load_user + tier: di_gap + query: "callers:lib/my_app_web/controllers/user_controller.ex::MyAppWeb.UserController.load_user" + expected: + - lib/my_app_web/controllers/user_controller.ex::MyAppWeb.UserController.show + - lib/my_app_web/controllers/user_controller.ex::MyAppWeb.UserController.update + - lib/my_app_web/controllers/user_controller.ex::MyAppWeb.UserController.delete + + # Forward direction: call_chain from a guarded action should reach + # the plug functions it depends on. + - id: di_gap-call_chain-show + tier: di_gap + query: "call_chain:lib/my_app_web/controllers/user_controller.ex::MyAppWeb.UserController.show" + expected: + - lib/my_app_web/controllers/user_controller.ex::MyAppWeb.UserController.authenticate + - lib/my_app_web/controllers/user_controller.ex::MyAppWeb.UserController.load_user diff --git a/bench/fixtures/di/rails/Gemfile b/bench/fixtures/di/rails/Gemfile new file mode 100644 index 0000000..30bca4c --- /dev/null +++ b/bench/fixtures/di/rails/Gemfile @@ -0,0 +1,3 @@ +source 'https://rubygems.org' +# Not installed — purely a static corpus for the Gortex indexer. +gem 'rails', '~> 7.1' diff --git a/bench/fixtures/di/rails/app/controllers/application_controller.rb b/bench/fixtures/di/rails/app/controllers/application_controller.rb new file mode 100644 index 0000000..771e369 --- /dev/null +++ b/bench/fixtures/di/rails/app/controllers/application_controller.rb @@ -0,0 +1,13 @@ +class ApplicationController < ActionController::Base + # Module-level before_action — runs for every action in every + # subclass. Rails resolves this at request-time via the callback + # chain; statically the binding between :require_login and the + # actions it guards has no explicit call site. + before_action :require_login + + private + + def require_login + redirect_to '/login' unless session[:user_id] + end +end diff --git a/bench/fixtures/di/rails/app/controllers/sessions_controller.rb b/bench/fixtures/di/rails/app/controllers/sessions_controller.rb new file mode 100644 index 0000000..89e89a5 --- /dev/null +++ b/bench/fixtures/di/rails/app/controllers/sessions_controller.rb @@ -0,0 +1,20 @@ +class SessionsController < ApplicationController + # skip_before_action removes the inherited :require_login for the + # listed actions — login/create obviously can't require a pre- + # existing login. Same decorator-dispatch shape, opposite sign. + skip_before_action :require_login, only: [:new, :create] + + def new + end + + def create + user = User.authenticate(params[:email], params[:password]) + session[:user_id] = user.id + redirect_to '/' + end + + def destroy + session.delete(:user_id) + redirect_to '/login' + end +end diff --git a/bench/fixtures/di/rails/app/controllers/users_controller.rb b/bench/fixtures/di/rails/app/controllers/users_controller.rb new file mode 100644 index 0000000..90513c7 --- /dev/null +++ b/bench/fixtures/di/rails/app/controllers/users_controller.rb @@ -0,0 +1,44 @@ +class UsersController < ApplicationController + # Targeted before_action: only fires for the listed actions. The + # comma-separated symbol list + `only:` / `except:` filters are the + # common Rails idiom for attaching methods to a subset of actions. + before_action :set_user, only: [:show, :update, :destroy] + before_action :authorize_admin, only: :destroy + after_action :log_request + + def index + @users = User.all + end + + def show + render json: @user + end + + def update + @user.update(user_params) + render json: @user + end + + def destroy + @user.destroy + head :no_content + end + + private + + def set_user + @user = User.find(params[:id]) + end + + def authorize_admin + head :forbidden unless current_user&.admin? + end + + def log_request + Rails.logger.info("request: #{request.path}") + end + + def user_params + params.require(:user).permit(:email, :name) + end +end diff --git a/bench/fixtures/di/rails/app/models/user.rb b/bench/fixtures/di/rails/app/models/user.rb new file mode 100644 index 0000000..0951edf --- /dev/null +++ b/bench/fixtures/di/rails/app/models/user.rb @@ -0,0 +1,18 @@ +class User < ApplicationRecord + has_many :posts + + validates :email, presence: true, uniqueness: true + + def admin? + role == 'admin' + end + + def self.authenticate(email, password) + user = find_by(email: email) + user if user&.valid_password?(password) + end + + def valid_password?(_pw) + true + end +end diff --git a/bench/fixtures/di/rails/config/routes.rb b/bench/fixtures/di/rails/config/routes.rb new file mode 100644 index 0000000..ca35289 --- /dev/null +++ b/bench/fixtures/di/rails/config/routes.rb @@ -0,0 +1,6 @@ +Rails.application.routes.draw do + resources :users + get '/login', to: 'sessions#new' + post '/login', to: 'sessions#create' + delete '/logout', to: 'sessions#destroy' +end diff --git a/bench/fixtures/di/rails/recall.yaml b/bench/fixtures/di/rails/recall.yaml new file mode 100644 index 0000000..f2d1cc4 --- /dev/null +++ b/bench/fixtures/di/rails/recall.yaml @@ -0,0 +1,90 @@ +# Rails DI-recall fixture +# +# Rails "DI" is mostly callback dispatch — before_action / after_action +# / skip_before_action in controllers attach methods to run before or +# after actions, without any explicit call site. This is the same shape +# as NestJS @UseGuards or Phoenix plug. Routes (`get '/users', to: ...`) +# are already handled by Gortex's HTTPExtractor so they're not the +# focus here. +# +# Tiering: +# exact — named symbols +# concept — paraphrases +# di — plain method call resolution (no callback extraction +# needed) +# di_gap — callback dispatch that's invisible without +# before_action-aware extraction +name: gortex-rails-di +cases: + + # exact + - id: exact-UsersController + tier: exact + query: UsersController + expected: [app/controllers/users_controller.rb::UsersController] + - id: exact-ApplicationController + tier: exact + query: ApplicationController + expected: [app/controllers/application_controller.rb::ApplicationController] + - id: exact-require_login + tier: exact + query: require_login + expected: [app/controllers/application_controller.rb::ApplicationController.require_login] + - id: exact-authenticate + tier: exact + query: authenticate + expected: [app/models/user.rb::User.authenticate] + + # concept + - id: concept-destroy-session + tier: concept + query: log a user out + expected: [app/controllers/sessions_controller.rb::SessionsController.destroy] + + # di — real method body calls (User.find, session[:user_id], etc.) + - id: di-call_chain-create + tier: di + query: "call_chain:app/controllers/sessions_controller.rb::SessionsController.create" + expected: [app/models/user.rb::User.authenticate] + + # di_gap — callback dispatch + # NOTE: inherited callbacks (ApplicationController.before_action + # :require_login → UsersController actions) aren't tracked. Would + # require cross-file class-hierarchy walking. See the Rails feature + # notes for future work. + + # before_action :set_user, only: [:show, :update, :destroy] — the + # filter list matters: only those three actions should call set_user. + - id: di_gap-callers-set_user + tier: di_gap + query: "callers:app/controllers/users_controller.rb::UsersController.set_user" + expected: + - app/controllers/users_controller.rb::UsersController.show + - app/controllers/users_controller.rb::UsersController.update + - app/controllers/users_controller.rb::UsersController.destroy + + # after_action :log_request — runs AFTER every action. + - id: di_gap-callers-log_request + tier: di_gap + query: "callers:app/controllers/users_controller.rb::UsersController.log_request" + expected: + - app/controllers/users_controller.rb::UsersController.index + - app/controllers/users_controller.rb::UsersController.show + + # authorize_admin is before_action with only: :destroy (single symbol + # form, not an array). Verifies the extractor handles both shapes. + - id: di_gap-callers-authorize_admin + tier: di_gap + query: "callers:app/controllers/users_controller.rb::UsersController.authorize_admin" + expected: + - app/controllers/users_controller.rb::UsersController.destroy + + # Forward direction: call_chain from a guarded action should include + # the before_action callbacks. Tests the same edges from the other + # side of the BFS. + - id: di_gap-call_chain-UsersController-show + tier: di_gap + query: "call_chain:app/controllers/users_controller.rb::UsersController.show" + expected: + - app/controllers/users_controller.rb::UsersController.set_user + - app/controllers/application_controller.rb::ApplicationController.require_login diff --git a/bench/fixtures/di/spring/pom.xml b/bench/fixtures/di/spring/pom.xml new file mode 100644 index 0000000..0a87b3d --- /dev/null +++ b/bench/fixtures/di/spring/pom.xml @@ -0,0 +1,9 @@ + + + + 4.0.0 + com.example + gortex-di-spring-fixture + 0.0.0 + jar + diff --git a/bench/fixtures/di/spring/recall.yaml b/bench/fixtures/di/spring/recall.yaml new file mode 100644 index 0000000..aacb9f7 --- /dev/null +++ b/bench/fixtures/di/spring/recall.yaml @@ -0,0 +1,114 @@ +# Spring Boot DI-recall fixture +# +# Spring DI shapes in scope: +# - @Autowired field injection +# - @Autowired constructor injection (equivalent to @Inject) +# - @Qualifier for multi-implementation disambiguation +# - @Bean factory methods in a @Configuration class +# +# Tiering matches the NestJS/FastAPI fixtures: +# exact — symbol-name queries +# concept — paraphrase queries +# di — shapes Java's explicit-type system already handles +# (constructor param typed as the concrete class/interface +# — the resolver links method calls on `this.field` to +# the declared type) +# di_gap — shapes a type-aware resolver can't handle alone +# (interface injection with multiple impls, @Bean factory +# return values, @Qualifier disambiguation) +name: gortex-spring-di +cases: + + # ------------------------------------------------------------------ + # Tier 1 — exact + # ------------------------------------------------------------------ + - id: exact-UserService + tier: exact + query: UserService + expected: [src/main/java/com/example/users/UserService.java::UserService] + - id: exact-OrderService + tier: exact + query: OrderService + expected: [src/main/java/com/example/orders/OrderService.java::OrderService] + - id: exact-JpaUserRepository + tier: exact + query: JpaUserRepository + expected: [src/main/java/com/example/users/JpaUserRepository.java::JpaUserRepository] + - id: exact-systemClock + tier: exact + query: systemClock + expected: [src/main/java/com/example/config/Clocks.java::Clocks.systemClock] + - id: exact-lookup + tier: exact + query: lookup + expected: [src/main/java/com/example/users/UserService.java::UserService.lookup] + + # ------------------------------------------------------------------ + # Tier 2 — concept + # ------------------------------------------------------------------ + - id: concept-find-user-cache-fallback + tier: concept + query: look up a user with cache fallback + expected: [src/main/java/com/example/users/UserService.java::UserService.lookup] + - id: concept-system-clock + tier: concept + query: provide the system clock + expected: [src/main/java/com/example/config/Clocks.java::Clocks.systemClock] + + # ------------------------------------------------------------------ + # Tier 3 — di: Java's explicit type system handles these + # ------------------------------------------------------------------ + # UserService.lookup calls this.cache.findById(...) and this.primary.findById(...). + # `cache` is a constructor-injected InMemoryUserRepository (concrete class), + # `primary` is a field-injected UserRepository (interface). Expected: the + # concrete findById — Java's type-aware resolver gets the concrete call directly. + - id: di-call_chain-UserService-lookup + tier: di + query: "call_chain:src/main/java/com/example/users/UserService.java::UserService.lookup" + expected: + - src/main/java/com/example/users/InMemoryUserRepository.java::InMemoryUserRepository.findById + + - id: di-call_chain-OrderService-describe + tier: di + query: "call_chain:src/main/java/com/example/orders/OrderService.java::OrderService.describe" + expected: + - src/main/java/com/example/users/UserService.java::UserService.lookup + + # ------------------------------------------------------------------ + # Tier 4 — di_gap: Spring-specific resolution beyond Java types + # ------------------------------------------------------------------ + # Interface injection + multiple implementations: UserService.primary is + # typed as UserRepository. Two @Repository beans implement it + # (JpaUserRepository, InMemoryUserRepository). Without a @Primary or + # @Qualifier annotation Spring itself fails at runtime — the static + # resolver picks one arbitrarily. Either concrete implementation is + # a legitimate answer here; a future @Primary-aware extractor would + # narrow this to a specific impl. + - id: di_gap-call_chain-UserService-lookup-interface + tier: di_gap + query: "call_chain:src/main/java/com/example/users/UserService.java::UserService.lookup" + expected: + - src/main/java/com/example/users/JpaUserRepository.java::JpaUserRepository.findById + - src/main/java/com/example/users/InMemoryUserRepository.java::InMemoryUserRepository.findById + + # @Bean factory method: OrderService receives a Clock via constructor, + # but Clock isn't a @Service or @Component — it's provided by + # Clocks.systemClock() which returns Clock. Without @Bean-aware + # extraction, callers(systemClock) is empty and users of Clock can't + # trace back to its factory. + - id: di_gap-callers-systemClock + tier: di_gap + query: "callers:src/main/java/com/example/config/Clocks.java::Clocks.systemClock" + expected: + - src/main/java/com/example/orders/OrderService.java::OrderService + + # @Qualifier disambiguation: UserService constructor param is + # @Qualifier("inMemoryUsers") InMemoryUserRepository. The type is + # explicit (concrete class), so Java's resolver already handles it. + # This case should pass even WITHOUT Spring-aware extraction — tests + # that the qualified injection doesn't break the baseline. + - id: di-call_chain-UserService-cache-path + tier: di + query: "call_chain:src/main/java/com/example/users/UserService.java::UserService.lookup" + expected: + - src/main/java/com/example/users/InMemoryUserRepository.java::InMemoryUserRepository.findById diff --git a/bench/fixtures/di/spring/src/main/java/com/example/config/Clocks.java b/bench/fixtures/di/spring/src/main/java/com/example/config/Clocks.java new file mode 100644 index 0000000..8da8d2b --- /dev/null +++ b/bench/fixtures/di/spring/src/main/java/com/example/config/Clocks.java @@ -0,0 +1,17 @@ +package com.example.config; + +import java.time.Clock; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +// @Configuration + @Bean: factory-method style provider. Consumers +// that @Autowire a Clock field receive whatever this method returns. +// Statically the binding between the return type (Clock) and the +// producing method (systemClock) is visible only inside this class. +@Configuration +public class Clocks { + @Bean + public Clock systemClock() { + return Clock.systemUTC(); + } +} diff --git a/bench/fixtures/di/spring/src/main/java/com/example/orders/OrderService.java b/bench/fixtures/di/spring/src/main/java/com/example/orders/OrderService.java new file mode 100644 index 0000000..b1b2b2b --- /dev/null +++ b/bench/fixtures/di/spring/src/main/java/com/example/orders/OrderService.java @@ -0,0 +1,30 @@ +package com.example.orders; + +import java.time.Clock; +import java.time.Instant; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import com.example.users.User; +import com.example.users.UserService; + +// Multi-field injection by constructor. The Clock dependency is +// satisfied by the @Bean method in com.example.config.Clocks; the +// UserService dependency is the typical @Service-annotated class. +@Service +public class OrderService { + private final UserService users; + private final Clock clock; + + @Autowired + public OrderService(UserService users, Clock clock) { + this.users = users; + this.clock = clock; + } + + public String describe(String userId) { + User u = users.lookup(userId).orElseThrow(); + Instant now = clock.instant(); + return "order for " + u.getEmail() + " at " + now; + } +} diff --git a/bench/fixtures/di/spring/src/main/java/com/example/users/InMemoryUserRepository.java b/bench/fixtures/di/spring/src/main/java/com/example/users/InMemoryUserRepository.java new file mode 100644 index 0000000..5e3866e --- /dev/null +++ b/bench/fixtures/di/spring/src/main/java/com/example/users/InMemoryUserRepository.java @@ -0,0 +1,23 @@ +package com.example.users; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import org.springframework.stereotype.Repository; + +// Second implementation. Without @Primary on one of the two, a plain +// @Autowired UserRepository field is ambiguous — Spring requires a +// @Qualifier to pick between them. +@Repository("inMemoryUsers") +public class InMemoryUserRepository implements UserRepository { + private final Map users = new HashMap<>(); + + public Optional findById(String id) { + return Optional.ofNullable(users.get(id)); + } + + public User save(User u) { + users.put(u.getId(), u); + return u; + } +} diff --git a/bench/fixtures/di/spring/src/main/java/com/example/users/JpaUserRepository.java b/bench/fixtures/di/spring/src/main/java/com/example/users/JpaUserRepository.java new file mode 100644 index 0000000..606f189 --- /dev/null +++ b/bench/fixtures/di/spring/src/main/java/com/example/users/JpaUserRepository.java @@ -0,0 +1,18 @@ +package com.example.users; + +import java.util.Optional; +import org.springframework.stereotype.Repository; + +// First implementation of UserRepository. The @Primary elsewhere +// determines which Spring picks when a consumer @Autowires the +// interface without a qualifier. +@Repository +public class JpaUserRepository implements UserRepository { + public Optional findById(String id) { + return Optional.empty(); + } + + public User save(User u) { + return u; + } +} diff --git a/bench/fixtures/di/spring/src/main/java/com/example/users/User.java b/bench/fixtures/di/spring/src/main/java/com/example/users/User.java new file mode 100644 index 0000000..1b08aa5 --- /dev/null +++ b/bench/fixtures/di/spring/src/main/java/com/example/users/User.java @@ -0,0 +1,14 @@ +package com.example.users; + +public class User { + private final String id; + private final String email; + + public User(String id, String email) { + this.id = id; + this.email = email; + } + + public String getId() { return id; } + public String getEmail() { return email; } +} diff --git a/bench/fixtures/di/spring/src/main/java/com/example/users/UserRepository.java b/bench/fixtures/di/spring/src/main/java/com/example/users/UserRepository.java new file mode 100644 index 0000000..310f347 --- /dev/null +++ b/bench/fixtures/di/spring/src/main/java/com/example/users/UserRepository.java @@ -0,0 +1,8 @@ +package com.example.users; + +import java.util.Optional; + +public interface UserRepository { + Optional findById(String id); + User save(User u); +} diff --git a/bench/fixtures/di/spring/src/main/java/com/example/users/UserService.java b/bench/fixtures/di/spring/src/main/java/com/example/users/UserService.java new file mode 100644 index 0000000..1544e7d --- /dev/null +++ b/bench/fixtures/di/spring/src/main/java/com/example/users/UserService.java @@ -0,0 +1,30 @@ +package com.example.users; + +import java.util.Optional; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Service; + +// Mixed injection styles: field injection for the primary repository +// and constructor injection for the qualified in-memory one. Both are +// common in production Spring code. +@Service +public class UserService { + @Autowired + private UserRepository primary; + + private final InMemoryUserRepository cache; + + @Autowired + public UserService(@Qualifier("inMemoryUsers") InMemoryUserRepository cache) { + this.cache = cache; + } + + public Optional lookup(String id) { + Optional hit = cache.findById(id); + if (hit.isPresent()) { + return hit; + } + return primary.findById(id); + } +} diff --git a/bench/fixtures/di/symfony/composer.json b/bench/fixtures/di/symfony/composer.json new file mode 100644 index 0000000..faacddb --- /dev/null +++ b/bench/fixtures/di/symfony/composer.json @@ -0,0 +1,9 @@ +{ + "name": "gortex/di-symfony-fixture", + "type": "project", + "description": "Minimal Symfony fixture for Gortex DI recall — not installed.", + "require": { + "symfony/framework-bundle": "^6.3", + "symfony/event-dispatcher": "^6.3" + } +} diff --git a/bench/fixtures/di/symfony/recall.yaml b/bench/fixtures/di/symfony/recall.yaml new file mode 100644 index 0000000..c465902 --- /dev/null +++ b/bench/fixtures/di/symfony/recall.yaml @@ -0,0 +1,40 @@ +# Symfony attribute-based DI fixture +# +# Symfony's PHP-native DI shape is PHP 8 attributes: #[AsEventListener] +# binds a method (or class) to an event, #[Route] maps a method to an +# HTTP route (already handled by the HTTPExtractor), #[Autowire] +# injects a specific service. This fixture focuses on event-listener +# attributes — the clearest DI-as-attribute case. +name: gortex-symfony-di +cases: + + # exact + - id: exact-UserCreated + tier: exact + query: UserCreated + expected: [src/Event/UserCreated.php::UserCreated] + - id: exact-WelcomeEmailListener + tier: exact + query: WelcomeEmailListener + expected: [src/EventListener/WelcomeEmailListener.php::WelcomeEmailListener] + - id: exact-AuditListener + tier: exact + query: AuditListener + expected: [src/EventListener/AuditListener.php::AuditListener] + + # di_gap: #[AsEventListener] binds listener to event. Without + # attribute extraction, find_usages(UserCreated) misses the + # listener classes entirely — only the `new UserCreated(...)` + # construction site in the controller shows up. + - id: di_gap-usages-UserCreated + tier: di_gap + query: "usages:src/Event/UserCreated.php::UserCreated" + expected: + - src/EventListener/WelcomeEmailListener.php::WelcomeEmailListener.onCreated + - src/EventListener/AuditListener.php::AuditListener + + - id: di_gap-usages-UserDeleted + tier: di_gap + query: "usages:src/Event/UserDeleted.php::UserDeleted" + expected: + - src/EventListener/WelcomeEmailListener.php::WelcomeEmailListener.onDeleted diff --git a/bench/fixtures/di/symfony/src/Controller/UserController.php b/bench/fixtures/di/symfony/src/Controller/UserController.php new file mode 100644 index 0000000..b46c409 --- /dev/null +++ b/bench/fixtures/di/symfony/src/Controller/UserController.php @@ -0,0 +1,27 @@ +events->dispatch($event); + return new Response('ok'); + } +} diff --git a/bench/fixtures/di/symfony/src/Event/UserCreated.php b/bench/fixtures/di/symfony/src/Event/UserCreated.php new file mode 100644 index 0000000..a5b8d08 --- /dev/null +++ b/bench/fixtures/di/symfony/src/Event/UserCreated.php @@ -0,0 +1,11 @@ +userId; + } +} diff --git a/bench/fixtures/di/symfony/src/EventListener/WelcomeEmailListener.php b/bench/fixtures/di/symfony/src/EventListener/WelcomeEmailListener.php new file mode 100644 index 0000000..3580fdd --- /dev/null +++ b/bench/fixtures/di/symfony/src/EventListener/WelcomeEmailListener.php @@ -0,0 +1,27 @@ +userId; + } + + #[AsEventListener(event: UserDeleted::class)] + public function onDeleted(UserDeleted $event): void + { + $_ = $event->userId; + } +} diff --git a/bench/fixtures/mktypos/main.go b/bench/fixtures/mktypos/main.go new file mode 100644 index 0000000..aa27e32 --- /dev/null +++ b/bench/fixtures/mktypos/main.go @@ -0,0 +1,155 @@ +// mktypos injects realistic single-character typos into a retrieval +// fixture so we can measure typo-tolerant recall independently from +// clean-query recall. One mutation per query — only on tokens ≥4 chars, +// one of {swap adjacent, substitute, insert, delete}. Deterministic via +// a seed so before/after numbers are comparable across runs. +package main + +import ( + "flag" + "fmt" + "log" + "math/rand/v2" + "os" + "strings" + + "gopkg.in/yaml.v3" +) + +type fixture struct { + Name string `yaml:"name"` + Cases []struct { + ID string `yaml:"id"` + Tier string `yaml:"tier"` + Query string `yaml:"query"` + Expected []string `yaml:"expected"` + WinnowConstraints map[string]any `yaml:"winnow_constraints,omitempty"` + } `yaml:"cases"` +} + +func main() { + in := flag.String("in", "bench/fixtures/retrieval.yaml", "source fixture") + out := flag.String("out", "bench/fixtures/retrieval_typo.yaml", "destination fixture") + rate := flag.Float64("rate", 1.0, "fraction of queries to mutate (0..1)") + seed := flag.Int64("seed", 42, "RNG seed for deterministic mutation") + flag.Parse() + + src, err := os.ReadFile(*in) + if err != nil { + log.Fatalf("read: %v", err) + } + var fx fixture + if err := yaml.Unmarshal(src, &fx); err != nil { + log.Fatalf("unmarshal: %v", err) + } + + rng := rand.New(rand.NewPCG(uint64(*seed), uint64(*seed)+1)) + fx.Name = fx.Name + "-typo" + + mutated := 0 + for i := range fx.Cases { + if rng.Float64() > *rate { + continue + } + orig := fx.Cases[i].Query + mut, ok := injectTypo(orig, rng) + if !ok { + continue + } + fx.Cases[i].ID = fx.Cases[i].ID + "-typo" + fx.Cases[i].Query = mut + mutated++ + } + + outData, err := yaml.Marshal(&fx) + if err != nil { + log.Fatalf("marshal: %v", err) + } + if err := os.WriteFile(*out, outData, 0o644); err != nil { + log.Fatalf("write: %v", err) + } + fmt.Fprintf(os.Stderr, "mktypos: mutated %d / %d cases → %s\n", mutated, len(fx.Cases), *out) +} + +// injectTypo mutates exactly one token (≥4 chars) in q using one of four +// edit operations. Returns the new query and a success flag; success=false +// when no token in q is long enough to mutate. +func injectTypo(q string, rng *rand.Rand) (string, bool) { + tokens := strings.Fields(q) + if len(tokens) == 0 { + return q, false + } + // Pick a mutable token. Prefer longer tokens — they're the ones a + // human is plausibly going to mistype. + var candidates []int + for i, t := range tokens { + if countLetters(t) >= 4 { + candidates = append(candidates, i) + } + } + if len(candidates) == 0 { + return q, false + } + idx := candidates[rng.IntN(len(candidates))] + tokens[idx] = mutateWord(tokens[idx], rng) + return strings.Join(tokens, " "), true +} + +func countLetters(s string) int { + n := 0 + for _, r := range s { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') { + n++ + } + } + return n +} + +// mutateWord returns w with a single random edit applied — one of swap, +// substitute, insert, delete. Non-letter prefixes/suffixes (e.g. the "." +// in "graph.Graph") are preserved. +func mutateWord(w string, rng *rand.Rand) string { + // Locate the contiguous letter run. Everything else stays put. + start, end := -1, -1 + for i, r := range w { + letter := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') + if letter && start < 0 { + start = i + } + if letter { + end = i + 1 + } + } + if start < 0 || end-start < 4 { + return w + } + head := w[:start] + core := []byte(w[start:end]) + tail := w[end:] + + switch rng.IntN(4) { + case 0: // swap adjacent + p := rng.IntN(len(core) - 1) + core[p], core[p+1] = core[p+1], core[p] + case 1: // substitute + p := rng.IntN(len(core)) + sub := byte('a' + rng.IntN(26)) + if isUpper(core[p]) { + sub = byte('A' + rng.IntN(26)) + } + if sub == core[p] { + sub++ + } + core[p] = sub + case 2: // insert + p := rng.IntN(len(core) + 1) + c := byte('a' + rng.IntN(26)) + core = append(core[:p], append([]byte{c}, core[p:]...)...) + case 3: // delete + p := rng.IntN(len(core)) + core = append(core[:p], core[p+1:]...) + } + return head + string(core) + tail +} + +func isUpper(b byte) bool { return b >= 'A' && b <= 'Z' } diff --git a/bench/fixtures/retrieval.yaml b/bench/fixtures/retrieval.yaml new file mode 100644 index 0000000..1714994 --- /dev/null +++ b/bench/fixtures/retrieval.yaml @@ -0,0 +1,455 @@ +# Gortex retrieval recall fixture (I6). +# +# Methodology +# ----------- +# Hand-curated gold labels. A retrieval counts as correct at rank K if +# ANY expected ID for a case appears in the ranker's top-K results +# (set-level any-hit recall). Strict-gold numbers are lower bounds vs +# a dual-judge setup — paraphrased-but-still-correct ranker output is +# scored as a miss unless it matches the gold ID. +# +# Two measurement modes +# +# strict (default) — gold-only. An expected ID must appear in the +# top-K. Comparable to CQS's "synthetic" number +# but stricter (we don't tune queries to known +# targets). +# judged (opt-in) — pass --judge claude-haiku-4-5 (needs +# ANTHROPIC_API_KEY). After strict scoring, every +# miss is replayed to the LLM judge with its top +# results; if the judge accepts any as a valid +# answer, the case is rescued at R@20 (not R@1 — +# the judge doesn't tell us the judged rank). +# Comparable to CQS's "dual-judge" number. +# Verdicts cache to ~/.cache/gortex/eval_judge_cache.json +# so re-runs are free. +# +# Cases are tiered so per-tier weakness is visible: +# +# tier: exact — symbol-name queries. Tests "find this named +# thing." BM25 should dominate; a retrieval stack +# that can't ace exact tier is broken. +# tier: concept — natural-language paraphrase queries. Tests +# semantic understanding. Where BM25 starts losing +# to semantic / RRF if the embedder is competent. +# tier: multi_hop — relational queries with several valid expected +# IDs (any-hit semantics). Tests graph-aware +# retrieval: "things that handle X" / "extractors +# for Y" — winnow should shine here. +# +# Expected IDs use the repo-prefix-stripped form the in-tree indexer +# produces (e.g. `internal/mcp/server.go::Server`). Keep this fixture +# hand-curated. +name: gortex-seed-v2 +cases: + + # ------------------------------------------------------------------ + # Tier 1 — exact: symbol-name queries (one-to-one gold) + # ------------------------------------------------------------------ + + - { id: exact-MCPServer, tier: exact, query: "mcp Server type", expected: [internal/mcp/server.go::Server] } + - { id: exact-NewServer, tier: exact, query: "NewServer", expected: [internal/mcp/server.go::NewServer] } + - { id: exact-graph-Graph, tier: exact, query: "graph.Graph type", expected: [internal/graph/graph.go::Graph] } + - { id: exact-graph-Node, tier: exact, query: "graph.Node type", expected: [internal/graph/node.go::Node] } + - { id: exact-graph-Edge, tier: exact, query: "graph.Edge type", expected: [internal/graph/edge.go::Edge] } + - { id: exact-Indexer, tier: exact, query: "Indexer struct", expected: [internal/indexer/indexer.go::Indexer] } + - { id: exact-MultiIndexer, tier: exact, query: "MultiIndexer", expected: [internal/indexer/multi.go::MultiIndexer] } + - { id: exact-IndexFile, tier: exact, query: "IndexFile", expected: [internal/indexer/indexer.go::Indexer.IndexFile] } + - { id: exact-IndexFileNoResolve, tier: exact, query: "IndexFileNoResolve", expected: [internal/indexer/indexer.go::Indexer.IndexFileNoResolve] } + - { id: exact-IncrementalReindex, tier: exact, query: "IncrementalReindex", expected: [internal/indexer/indexer.go::Indexer.IncrementalReindex] } + - { id: exact-SetEmbedder, tier: exact, query: "SetEmbedder", expected: [internal/indexer/indexer.go::Indexer.SetEmbedder] } + - { id: exact-EvictFile, tier: exact, query: "EvictFile", expected: [internal/graph/graph.go::Graph.EvictFile] } + - { id: exact-EvictRepo, tier: exact, query: "EvictRepo", expected: [internal/graph/graph.go::Graph.EvictRepo] } + - { id: exact-AddNode, tier: exact, query: "AddNode", expected: [internal/graph/graph.go::Graph.AddNode] } + - { id: exact-AtomicWriteFile, tier: exact, query: "AtomicWriteFile", expected: [internal/agents/writer.go::AtomicWriteFile] } + - { id: exact-WriteIfNotExists, tier: exact, query: "WriteIfNotExists", expected: [internal/agents/writer.go::WriteIfNotExists] } + - { id: exact-BM25Backend, tier: exact, query: "BM25Backend", expected: [internal/search/bm25.go::BM25Backend] } + - { id: exact-NewBM25, tier: exact, query: "NewBM25", expected: [internal/search/bm25.go::NewBM25] } + - { id: exact-HybridBackend, tier: exact, query: "HybridBackend", expected: [internal/search/hybrid.go::HybridBackend] } + - { id: exact-NewHybrid, tier: exact, query: "NewHybrid", expected: [internal/search/hybrid.go::NewHybrid] } + - { id: exact-rrfFuse, tier: exact, query: "rrfFuse", expected: [internal/search/hybrid.go::rrfFuse] } + - { id: exact-VectorBackend, tier: exact, query: "VectorBackend", expected: [internal/search/vector.go::VectorBackend] } + - { id: exact-SearchBackend, tier: exact, query: "search.Backend", expected: [internal/search/search.go::Backend] } + - { id: exact-SearchResult, tier: exact, query: "SearchResult", expected: [internal/search/search.go::SearchResult] } + - { id: exact-Swappable, tier: exact, query: "Swappable", expected: [internal/search/swappable.go::Swappable] } + - { id: exact-StaticProvider, tier: exact, query: "StaticProvider", expected: [internal/embedding/static.go::StaticProvider] } + - { id: exact-NewStaticProvider, tier: exact, query: "NewStaticProvider", expected: [internal/embedding/static.go::NewStaticProvider] } + - { id: exact-HugotProvider, tier: exact, query: "HugotProvider", expected: [internal/embedding/hugot.go::HugotProvider] } + - { id: exact-APIProvider, tier: exact, query: "APIProvider", expected: [internal/embedding/api.go::APIProvider] } + - { id: exact-NopProvider, tier: exact, query: "NopProvider", expected: [internal/embedding/nop.go::NopProvider] } + - { id: exact-EmbeddingProvider, tier: exact, query: "embedding.Provider", expected: [internal/embedding/provider.go::Provider] } + - { id: exact-handleEditSymbol, tier: exact, query: "handleEditSymbol", expected: [internal/mcp/tools_coding.go::Server.handleEditSymbol] } + - { id: exact-handleEditFile, tier: exact, query: "handleEditFile", expected: [internal/mcp/tools_fileops.go::Server.handleEditFile] } + - { id: exact-handleWriteFile, tier: exact, query: "handleWriteFile", expected: [internal/mcp/tools_fileops.go::Server.handleWriteFile] } + - { id: exact-handleSearchSymbols, tier: exact, query: "handleSearchSymbols", expected: [internal/mcp/tools_core.go::Server.handleSearchSymbols] } + - { id: exact-handleSmartContext, tier: exact, query: "handleSmartContext", expected: [internal/mcp/tools_coding.go::Server.handleSmartContext] } + - { id: exact-handleGraphStats, tier: exact, query: "handleGraphStats", expected: [internal/mcp/tools_core.go::Server.handleGraphStats] } + - { id: exact-handleFindUsages, tier: exact, query: "handleFindUsages", expected: [internal/mcp/tools_core.go::Server.handleFindUsages] } + - { id: exact-handleGetCallers, tier: exact, query: "handleGetCallers", expected: [internal/mcp/tools_core.go::Server.handleGetCallers] } + - { id: exact-handleWinnowSymbols, tier: exact, query: "handleWinnowSymbols", expected: [internal/mcp/tools_winnow.go::Server.handleWinnowSymbols] } + - { id: exact-WinnowForEval, tier: exact, query: "WinnowForEval", expected: [internal/mcp/tools_winnow.go::Server.WinnowForEval] } + - { id: exact-registerCodingTools, tier: exact, query: "registerCodingTools", expected: [internal/mcp/tools_coding.go::Server.registerCodingTools] } + - { id: exact-registerCoreTools, tier: exact, query: "registerCoreTools", expected: [internal/mcp/tools_core.go::Server.registerCoreTools] } + - { id: exact-sessionState, tier: exact, query: "sessionState", expected: [internal/mcp/server.go::sessionState] } + - { id: exact-recordModified, tier: exact, query: "recordModified", expected: [internal/mcp/server.go::sessionState.recordModified] } + - { id: exact-tokenStats, tier: exact, query: "tokenStats", expected: [internal/mcp/server.go::tokenStats] } + - { id: exact-GRPCExtractor, tier: exact, query: "GRPCExtractor", expected: [internal/contracts/grpc.go::GRPCExtractor] } + - { id: exact-HTTPExtractor, tier: exact, query: "HTTPExtractor", expected: [internal/contracts/http.go::HTTPExtractor] } + - { id: exact-GraphQLExtractor, tier: exact, query: "GraphQLExtractor", expected: [internal/contracts/graphql.go::GraphQLExtractor] } + - { id: exact-OpenAPIExtractor, tier: exact, query: "OpenAPIExtractor", expected: [internal/contracts/openapi.go::OpenAPIExtractor] } + - { id: exact-EnvVarExtractor, tier: exact, query: "EnvVarExtractor", expected: [internal/contracts/envvar.go::EnvVarExtractor] } + - { id: exact-TopicExtractor, tier: exact, query: "TopicExtractor", expected: [internal/contracts/topics.go::TopicExtractor] } + - { id: exact-parserRegistry, tier: exact, query: "parser.Registry", expected: [internal/parser/registry.go::Registry] } + - { id: exact-RegisterAll, tier: exact, query: "RegisterAll", expected: [internal/parser/languages/register.go::RegisterAll] } + - { id: exact-GoExtractor, tier: exact, query: "GoExtractor", expected: [internal/parser/languages/golang.go::GoExtractor] } + - { id: exact-sessionFor, tier: exact, query: "sessionFor", expected: [internal/mcp/server.go::Server.sessionFor] } + - { id: exact-ResolveFilePath, tier: exact, query: "ResolveFilePath", expected: [internal/indexer/multi.go::MultiIndexer.ResolveFilePath] } + - { id: exact-RepoForFile, tier: exact, query: "RepoForFile", expected: [internal/indexer/multi.go::MultiIndexer.RepoForFile] } + - { id: exact-TrackRepo, tier: exact, query: "TrackRepo", expected: [internal/indexer/multi.go::MultiIndexer.TrackRepo] } + - { id: exact-UntrackRepo, tier: exact, query: "UntrackRepo", expected: [internal/indexer/multi.go::MultiIndexer.UntrackRepo] } + - { id: exact-IndexResult, tier: exact, query: "IndexResult", expected: [internal/indexer/indexer.go::IndexResult] } + - { id: exact-RepoMetadata, tier: exact, query: "RepoMetadata", expected: [internal/indexer/multi.go::RepoMetadata] } + - { id: exact-tokensCount, tier: exact, query: "tokens.Count", expected: [internal/tokens/tokens.go::Count] } + + # ------------------------------------------------------------------ + # Tier 2 — concept: natural-language paraphrase (one-to-one gold) + # ------------------------------------------------------------------ + + - { id: concept-atomic-write, tier: concept, query: "atomic file write temp rename", expected: [internal/agents/writer.go::AtomicWriteFile] } + - { id: concept-reindex-one-file, tier: concept, query: "reindex a single file after an edit", expected: [internal/indexer/indexer.go::Indexer.IndexFile] } + - { id: concept-evict-file, tier: concept, query: "remove all nodes belonging to a file", expected: [internal/graph/graph.go::Graph.EvictFile] } + - { id: concept-evict-repo, tier: concept, query: "drop every node for a repository prefix", expected: [internal/graph/graph.go::Graph.EvictRepo] } + - { id: concept-bm25-index, tier: concept, query: "text search backend with TF-IDF ranking", expected: [internal/search/bm25.go::BM25Backend] } + - { id: concept-hybrid-fuse, tier: concept, query: "combine text and vector search with RRF", expected: [internal/search/hybrid.go::HybridBackend] } + - { id: concept-rrf-algorithm, tier: concept, query: "reciprocal rank fusion", expected: [internal/search/hybrid.go::rrfFuse] } + - { id: concept-swap-backend, tier: concept, query: "hot-swap the in-memory search backend", expected: [internal/search/swappable.go::Swappable] } + - { id: concept-glove-embed, tier: concept, query: "built-in GloVe word vector embedder", expected: [internal/embedding/static.go::StaticProvider] } + - { id: concept-mcp-server, tier: concept, query: "MCP server type holding engine and graph", expected: [internal/mcp/server.go::Server] } + - { id: concept-session-state, tier: concept, query: "per-client session activity tracking", expected: [internal/mcp/server.go::sessionState] } + - { id: concept-edit-by-id, tier: concept, query: "edit a symbol by node id without reading file", expected: [internal/mcp/tools_coding.go::Server.handleEditSymbol] } + - { id: concept-edit-raw-file, tier: concept, query: "edit any file by path with no read-before-edit",expected: [internal/mcp/tools_fileops.go::Server.handleEditFile] } + - { id: concept-write-raw-file, tier: concept, query: "create or overwrite file with given content", expected: [internal/mcp/tools_fileops.go::Server.handleWriteFile] } + - { id: concept-editing-context, tier: concept, query: "everything needed to safely edit a file", expected: [internal/mcp/tools_coding.go::Server.handleGetEditingContext] } + - { id: concept-smart-context, tier: concept, query: "assemble minimum context for a task", expected: [internal/mcp/tools_coding.go::Server.handleSmartContext] } + - { id: concept-tiktoken-count, tier: concept, query: "count tokens using tiktoken cl100k", expected: [internal/tokens/tokens.go::Count] } + - { id: concept-winnow-scorer, tier: concept, query: "constraint chain search with fan-in churn", expected: [internal/mcp/tools_winnow.go::Server.winnowSymbols] } + - { id: concept-find-usages, tier: concept, query: "find every reference to a symbol", expected: [internal/mcp/tools_core.go::Server.handleFindUsages] } + - { id: concept-callers, tier: concept, query: "who calls this function", expected: [internal/mcp/tools_core.go::Server.handleGetCallers] } + - { id: concept-call-chain, tier: concept, query: "trace a chain of function calls", expected: [internal/mcp/tools_core.go::Server.handleGetCallChain] } + - { id: concept-find-impls, tier: concept, query: "implementations of an interface", expected: [internal/mcp/tools_core.go::Server.handleFindImplementations] } + - { id: concept-import-path, tier: concept, query: "resolve Go import path for a symbol", expected: [internal/mcp/tools_coding.go::Server.handleFindImportPath] } + - { id: concept-repo-outline, tier: concept, query: "narrative single-call repo overview", expected: [internal/mcp/tools_outline.go::Server.handleGetRepoOutline] } + - { id: concept-rename-symbol, tier: concept, query: "coordinated multi-file rename refactor", expected: [internal/mcp/tools_coding.go::Server.handleRenameSymbol] } + - { id: concept-scaffold, tier: concept, query: "generate code from an example pattern", expected: [internal/mcp/tools_enhancements.go::Server.handleScaffold] } + - { id: concept-batch-edit, tier: concept, query: "apply multiple edits in dependency order", expected: [internal/mcp/tools_enhancements.go::Server.handleBatchEdit] } + - { id: concept-track-repo, tier: concept, query: "add a repository to the multi-repo graph", expected: [internal/mcp/tools_multi.go::Server.handleTrackRepository] } + - { id: concept-untrack-repo, tier: concept, query: "remove a repository from the graph", expected: [internal/mcp/tools_multi.go::Server.handleUntrackRepository] } + - { id: concept-grpc-extract, tier: concept, query: "extract gRPC service providers from a file", expected: [internal/contracts/grpc.go::GRPCExtractor.Extract] } + - { id: concept-http-extract, tier: concept, query: "extract HTTP route handlers from source", expected: [internal/contracts/http.go::HTTPExtractor.Extract] } + - { id: concept-graphql-extract, tier: concept, query: "extract GraphQL resolvers", expected: [internal/contracts/graphql.go::GraphQLExtractor.Extract] } + - { id: concept-openapi-extract, tier: concept, query: "parse OpenAPI spec providers", expected: [internal/contracts/openapi.go::OpenAPIExtractor.Extract] } + - { id: concept-envvar-extract, tier: concept, query: "find environment variable consumers", expected: [internal/contracts/envvar.go::EnvVarExtractor.Extract] } + - { id: concept-topic-extract, tier: concept, query: "extract pub-sub topic emits and listens", expected: [internal/contracts/topics.go::TopicExtractor.Extract] } + - { id: concept-go-extract, tier: concept, query: "Go language extractor", expected: [internal/parser/languages/golang.go::GoExtractor] } + - { id: concept-ts-extract, tier: concept, query: "TypeScript language extractor", expected: [internal/parser/languages/typescript.go::TypeScriptExtractor] } + - { id: concept-detect-lang, tier: concept, query: "detect language from file extension", expected: [internal/parser/registry.go::Registry.GetByExtension] } + - { id: concept-register-all, tier: concept, query: "register every language extractor", expected: [internal/parser/languages/register.go::RegisterAll] } + - { id: concept-ranker-bm25, tier: concept, query: "eval ranker adapter for BM25", expected: [internal/eval/recall/rankers.go::BM25Ranker] } + - { id: concept-ranker-rrf, tier: concept, query: "eval ranker adapter for RRF hybrid", expected: [internal/eval/recall/rankers.go::RRFRanker] } + - { id: concept-ranker-semantic, tier: concept, query: "eval ranker for vector-only semantic", expected: [internal/eval/recall/rankers.go::SemanticRanker] } + - { id: concept-ranker-winnow, tier: concept, query: "eval ranker wrapping graph-aware winnow", expected: [internal/eval/recall/rankers.go::WinnowRanker] } + - { id: concept-ranker-rg, tier: concept, query: "ripgrep-based retrieval baseline", expected: [internal/eval/recall/rankers.go::RipgrepRanker] } + - { id: concept-resolve-filepath, tier: concept, query: "resolve a repo-prefixed path to absolute", expected: [internal/indexer/multi.go::MultiIndexer.ResolveFilePath] } + - { id: concept-repo-for-file, tier: concept, query: "find which repo contains a given file", expected: [internal/indexer/multi.go::MultiIndexer.RepoForFile] } + - { id: concept-set-embedder, tier: concept, query: "attach embedding provider to the indexer", expected: [internal/indexer/indexer.go::Indexer.SetEmbedder] } + - { id: concept-new-server, tier: concept, query: "construct a new MCP server instance", expected: [internal/mcp/server.go::NewServer] } + - { id: concept-new-hybrid, tier: concept, query: "construct a hybrid BM25+vector backend", expected: [internal/search/hybrid.go::NewHybrid] } + - { id: concept-reindex-all, tier: concept, query: "incremental reindex after file changes", expected: [internal/indexer/indexer.go::Indexer.IncrementalReindex] } + - { id: concept-new-vector, tier: concept, query: "HNSW vector index backend", expected: [internal/search/vector.go::VectorBackend] } + - { id: concept-api-embedder, tier: concept, query: "OpenAI-compatible embeddings API provider", expected: [internal/embedding/api.go::APIProvider] } + - { id: concept-guard-rules, tier: concept, query: "team convention guard rule evaluation", expected: [internal/mcp/tools_enhancements.go::Server.handleCheckGuards] } + - { id: concept-explain-impact, tier: concept, query: "risk tiered blast radius analysis", expected: [internal/mcp/tools_analysis.go::Server.handleEnhancedChangeImpact] } + - { id: concept-diff-context, tier: concept, query: "enrich git diff with graph context", expected: [internal/mcp/tools_enhancements.go::Server.handleDiffContext] } + - { id: concept-detect-changes, tier: concept, query: "list changed files since last call", expected: [internal/mcp/tools_analysis.go::Server.handleDetectChanges] } + - { id: concept-cluster-retrieval, tier: concept, query: "get a graph community cluster by id", expected: [internal/mcp/tools_core.go::Server.handleGetCluster] } + - { id: concept-plan-turn, tier: concept, query: "opening move router for first tool call", expected: [internal/mcp/tools_planning.go::Server.handlePlanTurn] } + - { id: concept-feedback-record, tier: concept, query: "record which symbols were useful after a task", expected: [internal/mcp/tools_enhancements.go::Server.handleFeedback] } + - { id: concept-export-context, tier: concept, query: "export portable context markdown for PR", expected: [internal/mcp/tools_enhancements.go::Server.handleExportContext] } + - { id: concept-index-health, tier: concept, query: "staleness and parse failure health report", expected: [internal/mcp/tools_enhancements.go::Server.handleIndexHealth] } + - { id: concept-symbol-history, tier: concept, query: "session symbol modification history", expected: [internal/mcp/tools_enhancements.go::Server.handleGetSymbolHistory] } + - { id: concept-index-repo, tier: concept, query: "run the full repository indexer", expected: [internal/mcp/tools_core.go::Server.handleIndexRepository] } + + # ------------------------------------------------------------------ + # Tier 3 — multi_hop: relational queries with several valid answers + # ------------------------------------------------------------------ + + - id: mh-contract-extractors + tier: multi_hop + query: "contract extractors for APIs" + expected: + - internal/contracts/grpc.go::GRPCExtractor + - internal/contracts/http.go::HTTPExtractor + - internal/contracts/graphql.go::GraphQLExtractor + - internal/contracts/openapi.go::OpenAPIExtractor + - internal/contracts/envvar.go::EnvVarExtractor + - internal/contracts/topics.go::TopicExtractor + + - id: mh-extract-methods + tier: multi_hop + query: "Extract method on contract extractor types" + expected: + - internal/contracts/grpc.go::GRPCExtractor.Extract + - internal/contracts/http.go::HTTPExtractor.Extract + - internal/contracts/graphql.go::GraphQLExtractor.Extract + - internal/contracts/openapi.go::OpenAPIExtractor.Extract + - internal/contracts/envvar.go::EnvVarExtractor.Extract + - internal/contracts/topics.go::TopicExtractor.Extract + + - id: mh-edit-tools + tier: multi_hop + query: "MCP handlers that modify source files" + expected: + - internal/mcp/tools_coding.go::Server.handleEditSymbol + - internal/mcp/tools_fileops.go::Server.handleEditFile + - internal/mcp/tools_fileops.go::Server.handleWriteFile + - internal/mcp/tools_enhancements.go::Server.handleBatchEdit + - internal/mcp/tools_enhancements.go::Server.handleScaffold + + - id: mh-embedding-providers + tier: multi_hop + query: "embedding provider implementations" + expected: + - internal/embedding/static.go::StaticProvider + - internal/embedding/hugot.go::HugotProvider + - internal/embedding/api.go::APIProvider + - internal/embedding/onnx.go::ONNXProvider + - internal/embedding/nop.go::NopProvider + + - id: mh-search-backends + tier: multi_hop + query: "search backend implementations" + expected: + - internal/search/bm25.go::BM25Backend + - internal/search/bleve.go::BleveBackend + - internal/search/hybrid.go::HybridBackend + - internal/search/vector.go::VectorBackend + - internal/search/swappable.go::Swappable + + - id: mh-handle-tools-coding + tier: multi_hop + query: "MCP tools registered in tools_coding.go" + expected: + - internal/mcp/tools_coding.go::Server.handleGetEditingContext + - internal/mcp/tools_coding.go::Server.handleEditSymbol + - internal/mcp/tools_coding.go::Server.handleSmartContext + - internal/mcp/tools_coding.go::Server.handleRenameSymbol + - internal/mcp/tools_coding.go::Server.handleFindImportPath + + - id: mh-graph-mutators + tier: multi_hop + query: "methods that mutate the graph" + expected: + - internal/graph/graph.go::Graph.AddNode + - internal/graph/graph.go::Graph.AddEdge + - internal/graph/graph.go::Graph.EvictFile + - internal/graph/graph.go::Graph.EvictRepo + - internal/graph/graph.go::Graph.ReindexEdge + + - id: mh-winnow-scorer-parts + tier: multi_hop + query: "pieces of the winnow scorer pipeline" + expected: + - internal/mcp/tools_winnow.go::Server.winnowSymbols + - internal/mcp/tools_winnow.go::applyWinnowPrefilter + - internal/mcp/tools_winnow.go::computeFanInOut + - internal/mcp/tools_winnow.go::winnowConstraints + - internal/mcp/tools_winnow.go::Server.WinnowForEval + + - id: mh-eval-rankers + tier: multi_hop + query: "ranker adapters in the eval package" + expected: + - internal/eval/recall/rankers.go::BM25Ranker + - internal/eval/recall/rankers.go::SemanticRanker + - internal/eval/recall/rankers.go::RRFRanker + - internal/eval/recall/rankers.go::WinnowRanker + - internal/eval/recall/rankers.go::RipgrepRanker + + - id: mh-indexer-api + tier: multi_hop + query: "public Indexer API methods" + expected: + - internal/indexer/indexer.go::Indexer.Index + - internal/indexer/indexer.go::Indexer.IndexFile + - internal/indexer/indexer.go::Indexer.IndexFileNoResolve + - internal/indexer/indexer.go::Indexer.IncrementalReindex + - internal/indexer/indexer.go::Indexer.Search + - internal/indexer/indexer.go::Indexer.Graph + + - id: mh-multi-repo + tier: multi_hop + query: "multi-repo indexer operations" + expected: + - internal/indexer/multi.go::MultiIndexer.TrackRepo + - internal/indexer/multi.go::MultiIndexer.UntrackRepo + - internal/indexer/multi.go::MultiIndexer.IndexAll + - internal/indexer/multi.go::MultiIndexer.RepoForFile + - internal/indexer/multi.go::MultiIndexer.ResolveFilePath + + - id: mh-session-helpers + tier: multi_hop + query: "session state helpers on the MCP server" + expected: + - internal/mcp/server.go::Server.sessionFor + - internal/mcp/server.go::sessionState.recordModified + - internal/mcp/server.go::sessionState.recordSymbol + - internal/mcp/server.go::sessionState + - internal/mcp/server.go::newSessionState + + - id: mh-agent-adapters + tier: multi_hop + query: "IDE agent adapters" + expected: + - internal/agents/claudecode/adapter.go::Adapter + - internal/agents/cursor/adapter.go::Adapter + - internal/agents/vscode/adapter.go::Adapter + - internal/agents/kilocode/adapter.go::Adapter + - internal/agents/zed/adapter.go::Adapter + + - id: mh-init-subcommands + tier: multi_hop + query: "gortex init related commands" + expected: + - cmd/gortex/init.go::initCmd + - cmd/gortex/init_doctor.go::initDoctorCmd + - cmd/gortex/init_doctor.go::runInitDoctor + - cmd/gortex/init.go::runInit + + - id: mh-daemon-components + tier: multi_hop + query: "daemon server parts" + expected: + - internal/daemon/session.go::Session + - internal/daemon/session.go::SessionRegistry + + - id: mh-search-interface-members + tier: multi_hop + query: "methods required by the search Backend interface" + expected: + - internal/search/search.go::Backend + + - id: mh-graph-reader-api + tier: multi_hop + query: "read-only graph accessors" + expected: + - internal/graph/graph.go::Graph.GetFileNodes + + - id: mh-tokens-helpers + tier: multi_hop + query: "token counting helpers" + expected: + - internal/tokens/tokens.go::Count + - internal/tokens/tokens.go::CountInt64 + + - id: mh-atomic-writers + tier: multi_hop + query: "atomic write helpers shared across adapters" + expected: + - internal/agents/writer.go::AtomicWriteFile + - internal/agents/writer.go::WriteIfNotExists + + - id: mh-recall-types + tier: multi_hop + query: "recall report data types" + expected: + - internal/eval/recall/recall.go::Report + - internal/eval/recall/recall.go::RankerResult + - internal/eval/recall/recall.go::Fixture + - internal/eval/recall/recall.go::Case + - internal/eval/recall/recall.go::Ranker + + - id: mh-fileops-helpers + tier: multi_hop + query: "file operation MCP handler helpers" + expected: + - internal/mcp/tools_fileops.go::Server.resolveFilePath + - internal/mcp/tools_fileops.go::Server.repoRelative + - internal/mcp/tools_fileops.go::Server.reindexFile + - internal/mcp/tools_fileops.go::Server.handleEditFile + - internal/mcp/tools_fileops.go::Server.handleWriteFile + + - id: mh-graph-structs + tier: multi_hop + query: "core graph data structures" + expected: + - internal/graph/node.go::Node + - internal/graph/edge.go::Edge + - internal/graph/graph.go::Graph + + - id: mh-index-result + tier: multi_hop + query: "index result and error reporting types" + expected: + - internal/indexer/indexer.go::IndexResult + - internal/indexer/indexer.go::IndexError + + - id: mh-cmd-commands + tier: multi_hop + query: "top-level cobra commands under cmd/gortex" + expected: + - cmd/gortex/mcp.go::mcpCmd + - cmd/gortex/server.go::serverCmd + - cmd/gortex/daemon.go::daemonCmd + - cmd/gortex/track.go::trackCmd + - cmd/gortex/eval.go::evalCmd + + - id: mh-config-load + tier: multi_hop + query: "configuration loading and defaults" + expected: + - internal/config/config.go::Load + - internal/config/config.go::Default + + - id: mh-bm25-api + tier: multi_hop + query: "BM25 backend public API" + expected: + - internal/search/bm25.go::BM25Backend.Add + - internal/search/bm25.go::BM25Backend.Remove + - internal/search/bm25.go::BM25Backend.Search + - internal/search/bm25.go::BM25Backend.Count + - internal/search/bm25.go::NewBM25 + + - id: mh-tool-registrations + tier: multi_hop + query: "MCP tool registration functions" + expected: + - internal/mcp/tools_coding.go::Server.registerCodingTools + - internal/mcp/tools_core.go::Server.registerCoreTools + - internal/mcp/tools_analysis.go::Server.registerAnalysisTools + - internal/mcp/tools_enhancements.go::Server.registerEnhancementTools + + - id: mh-wire-format + tier: multi_hop + query: "GCX1 compact wire format encoders" + expected: + - pkg/wire/decoder.go::Decoder + - pkg/wire/decoder.go::NewDecoder + + - id: mh-parser-languages + tier: multi_hop + query: "language-specific parser extractors" + expected: + - internal/parser/languages/golang.go::GoExtractor + - internal/parser/languages/typescript.go::TypeScriptExtractor + - internal/parser/languages/python.go::PythonExtractor + - internal/parser/languages/rust.go::RustExtractor + - internal/parser/languages/c.go::CExtractor + + - id: mh-eval-subcommands + tier: multi_hop + query: "gortex eval subcommands" + expected: + - cmd/gortex/eval_recall.go::evalRecallCmd + - cmd/gortex/eval_swebench.go::evalSwebenchCmd + - cmd/gortex/eval_tokens.go::evalTokensCmd diff --git a/bench/fixtures/retrieval_typo.yaml b/bench/fixtures/retrieval_typo.yaml new file mode 100644 index 0000000..2f94091 --- /dev/null +++ b/bench/fixtures/retrieval_typo.yaml @@ -0,0 +1,873 @@ +name: gortex-seed-v2-typo +cases: + - id: exact-MCPServer-typo + tier: exact + query: mcp Server tye + expected: + - internal/mcp/server.go::Server + - id: exact-NewServer-typo + tier: exact + query: NewServe + expected: + - internal/mcp/server.go::NewServer + - id: exact-graph-Graph-typo + tier: exact + query: gtraph.Graph type + expected: + - internal/graph/graph.go::Graph + - id: exact-graph-Node-typo + tier: exact + query: graph.Nodle type + expected: + - internal/graph/node.go::Node + - id: exact-graph-Edge-typo + tier: exact + query: grpah.Edge type + expected: + - internal/graph/edge.go::Edge + - id: exact-Indexer-typo + tier: exact + query: Indexer sttruct + expected: + - internal/indexer/indexer.go::Indexer + - id: exact-MultiIndexer-typo + tier: exact + query: MulitIndexer + expected: + - internal/indexer/multi.go::MultiIndexer + - id: exact-IndexFile-typo + tier: exact + query: IndexFilne + expected: + - internal/indexer/indexer.go::Indexer.IndexFile + - id: exact-IndexFileNoResolve-typo + tier: exact + query: IndexFileNozResolve + expected: + - internal/indexer/indexer.go::Indexer.IndexFileNoResolve + - id: exact-IncrementalReindex-typo + tier: exact + query: IncrementalPeindex + expected: + - internal/indexer/indexer.go::Indexer.IncrementalReindex + - id: exact-SetEmbedder-typo + tier: exact + query: SetEmbeeder + expected: + - internal/indexer/indexer.go::Indexer.SetEmbedder + - id: exact-EvictFile-typo + tier: exact + query: EvictFife + expected: + - internal/graph/graph.go::Graph.EvictFile + - id: exact-EvictRepo-typo + tier: exact + query: EvkctRepo + expected: + - internal/graph/graph.go::Graph.EvictRepo + - id: exact-AddNode-typo + tier: exact + query: AddNode + expected: + - internal/graph/graph.go::Graph.AddNode + - id: exact-AtomicWriteFile-typo + tier: exact + query: AtomicnWriteFile + expected: + - internal/agents/writer.go::AtomicWriteFile + - id: exact-WriteIfNotExists-typo + tier: exact + query: WrilteIfNotExists + expected: + - internal/agents/writer.go::WriteIfNotExists + - id: exact-BM25Backend-typo + tier: exact + query: BM25Backejd + expected: + - internal/search/bm25.go::BM25Backend + - id: exact-NewBM25-typo + tier: exact + query: NenBM25 + expected: + - internal/search/bm25.go::NewBM25 + - id: exact-HybridBackend-typo + tier: exact + query: HybridBfckend + expected: + - internal/search/hybrid.go::HybridBackend + - id: exact-NewHybrid-typo + tier: exact + query: NewHybid + expected: + - internal/search/hybrid.go::NewHybrid + - id: exact-rrfFuse-typo + tier: exact + query: rrfFse + expected: + - internal/search/hybrid.go::rrfFuse + - id: exact-VectorBackend-typo + tier: exact + query: VectorBcakend + expected: + - internal/search/vector.go::VectorBackend + - id: exact-SearchBackend-typo + tier: exact + query: search.Backeod + expected: + - internal/search/search.go::Backend + - id: exact-SearchResult-typo + tier: exact + query: SearchResutl + expected: + - internal/search/search.go::SearchResult + - id: exact-Swappable-typo + tier: exact + query: Swappalbe + expected: + - internal/search/swappable.go::Swappable + - id: exact-StaticProvider-typo + tier: exact + query: StaticPoovider + expected: + - internal/embedding/static.go::StaticProvider + - id: exact-NewStaticProvider-typo + tier: exact + query: NewStaticProvidre + expected: + - internal/embedding/static.go::NewStaticProvider + - id: exact-HugotProvider-typo + tier: exact + query: HgotProvider + expected: + - internal/embedding/hugot.go::HugotProvider + - id: exact-APIProvider-typo + tier: exact + query: APIPrvider + expected: + - internal/embedding/api.go::APIProvider + - id: exact-NopProvider-typo + tier: exact + query: KopProvider + expected: + - internal/embedding/nop.go::NopProvider + - id: exact-EmbeddingProvider-typo + tier: exact + query: embedding.qProvider + expected: + - internal/embedding/provider.go::Provider + - id: exact-handleEditSymbol-typo + tier: exact + query: hanhdleEditSymbol + expected: + - internal/mcp/tools_coding.go::Server.handleEditSymbol + - id: exact-handleEditFile-typo + tier: exact + query: hsandleEditFile + expected: + - internal/mcp/tools_fileops.go::Server.handleEditFile + - id: exact-handleWriteFile-typo + tier: exact + query: handleWirteFile + expected: + - internal/mcp/tools_fileops.go::Server.handleWriteFile + - id: exact-handleSearchSymbols-typo + tier: exact + query: hyndleSearchSymbols + expected: + - internal/mcp/tools_core.go::Server.handleSearchSymbols + - id: exact-handleSmartContext-typo + tier: exact + query: handhleSmartContext + expected: + - internal/mcp/tools_coding.go::Server.handleSmartContext + - id: exact-handleGraphStats-typo + tier: exact + query: hndleGraphStats + expected: + - internal/mcp/tools_core.go::Server.handleGraphStats + - id: exact-handleFindUsages-typo + tier: exact + query: handleFindUsaegs + expected: + - internal/mcp/tools_core.go::Server.handleFindUsages + - id: exact-handleGetCallers-typo + tier: exact + query: ghandleGetCallers + expected: + - internal/mcp/tools_core.go::Server.handleGetCallers + - id: exact-handleWinnowSymbols-typo + tier: exact + query: handleWinnpowSymbols + expected: + - internal/mcp/tools_winnow.go::Server.handleWinnowSymbols + - id: exact-WinnowForEval-typo + tier: exact + query: WinnowForEvla + expected: + - internal/mcp/tools_winnow.go::Server.WinnowForEval + - id: exact-registerCodingTools-typo + tier: exact + query: registerCodaingTools + expected: + - internal/mcp/tools_coding.go::Server.registerCodingTools + - id: exact-registerCoreTools-typo + tier: exact + query: registerCroeTools + expected: + - internal/mcp/tools_core.go::Server.registerCoreTools + - id: exact-sessionState-typo + tier: exact + query: szssionState + expected: + - internal/mcp/server.go::sessionState + - id: exact-recordModified-typo + tier: exact + query: recordModifid + expected: + - internal/mcp/server.go::sessionState.recordModified + - id: exact-tokenStats-typo + tier: exact + query: tockenStats + expected: + - internal/mcp/server.go::tokenStats + - id: exact-GRPCExtractor-typo + tier: exact + query: GRPCExtracotr + expected: + - internal/contracts/grpc.go::GRPCExtractor + - id: exact-HTTPExtractor-typo + tier: exact + query: HTTPExtractwor + expected: + - internal/contracts/http.go::HTTPExtractor + - id: exact-GraphQLExtractor-typo + tier: exact + query: GraphQLxEtractor + expected: + - internal/contracts/graphql.go::GraphQLExtractor + - id: exact-OpenAPIExtractor-typo + tier: exact + query: OpenAPIExtraczor + expected: + - internal/contracts/openapi.go::OpenAPIExtractor + - id: exact-EnvVarExtractor-typo + tier: exact + query: EnvVarExtracjor + expected: + - internal/contracts/envvar.go::EnvVarExtractor + - id: exact-TopicExtractor-typo + tier: exact + query: ZopicExtractor + expected: + - internal/contracts/topics.go::TopicExtractor + - id: exact-parserRegistry-typo + tier: exact + query: parrser.Registry + expected: + - internal/parser/registry.go::Registry + - id: exact-RegisterAll-typo + tier: exact + query: RegitserAll + expected: + - internal/parser/languages/register.go::RegisterAll + - id: exact-GoExtractor-typo + tier: exact + query: GoExtractzr + expected: + - internal/parser/languages/golang.go::GoExtractor + - id: exact-sessionFor-typo + tier: exact + query: sessionFor + expected: + - internal/mcp/server.go::Server.sessionFor + - id: exact-ResolveFilePath-typo + tier: exact + query: ResolveilePath + expected: + - internal/indexer/multi.go::MultiIndexer.ResolveFilePath + - id: exact-RepoForFile-typo + tier: exact + query: RepoForFle + expected: + - internal/indexer/multi.go::MultiIndexer.RepoForFile + - id: exact-TrackRepo-typo + tier: exact + query: TracpRepo + expected: + - internal/indexer/multi.go::MultiIndexer.TrackRepo + - id: exact-UntrackRepo-typo + tier: exact + query: UntackRepo + expected: + - internal/indexer/multi.go::MultiIndexer.UntrackRepo + - id: exact-IndexResult-typo + tier: exact + query: IndexResut + expected: + - internal/indexer/indexer.go::IndexResult + - id: exact-RepoMetadata-typo + tier: exact + query: RepoMetadaat + expected: + - internal/indexer/multi.go::RepoMetadata + - id: exact-tokensCount-typo + tier: exact + query: tokes.Count + expected: + - internal/tokens/tokens.go::Count + - id: concept-atomic-write-typo + tier: concept + query: atomic fire write temp rename + expected: + - internal/agents/writer.go::AtomicWriteFile + - id: concept-reindex-one-file-typo + tier: concept + query: reinedx a single file after an edit + expected: + - internal/indexer/indexer.go::Indexer.IndexFile + - id: concept-evict-file-typo + tier: concept + query: remove all noded belonging to a file + expected: + - internal/graph/graph.go::Graph.EvictFile + - id: concept-evict-repo-typo + tier: concept + query: drop every node for a rpeository prefix + expected: + - internal/graph/graph.go::Graph.EvictRepo + - id: concept-bm25-index-typo + tier: concept + query: text search backend wtih TF-IDF ranking + expected: + - internal/search/bm25.go::BM25Backend + - id: concept-hybrid-fuse-typo + tier: concept + query: comlbine text and vector search with RRF + expected: + - internal/search/hybrid.go::HybridBackend + - id: concept-rrf-algorithm-typo + tier: concept + query: reciprocal randk fusion + expected: + - internal/search/hybrid.go::rrfFuse + - id: concept-swap-backend-typo + tier: concept + query: hot-swap the in-memory searich backend + expected: + - internal/search/swappable.go::Swappable + - id: concept-glove-embed-typo + tier: concept + query: built-in GloVe owrd vector embedder + expected: + - internal/embedding/static.go::StaticProvider + - id: concept-mcp-server-typo + tier: concept + query: MCP server type holding engine and grah + expected: + - internal/mcp/server.go::Server + - id: concept-session-state-typo + tier: concept + query: per-client session activiby tracking + expected: + - internal/mcp/server.go::sessionState + - id: concept-edit-by-id-typo + tier: concept + query: edit a symbol by node id xithout reading file + expected: + - internal/mcp/tools_coding.go::Server.handleEditSymbol + - id: concept-edit-raw-file-typo + tier: concept + query: edit any file by path weth no read-before-edit + expected: + - internal/mcp/tools_fileops.go::Server.handleEditFile + - id: concept-write-raw-file-typo + tier: concept + query: create or overrwite file with given content + expected: + - internal/mcp/tools_fileops.go::Server.handleWriteFile + - id: concept-editing-context-typo + tier: concept + query: eevrything needed to safely edit a file + expected: + - internal/mcp/tools_coding.go::Server.handleGetEditingContext + - id: concept-smart-context-typo + tier: concept + query: assemble minimum conotext for a task + expected: + - internal/mcp/tools_coding.go::Server.handleSmartContext + - id: concept-tiktoken-count-typo + tier: concept + query: count tokens using tiktkoen cl100k + expected: + - internal/tokens/tokens.go::Count + - id: concept-winnow-scorer-typo + tier: concept + query: constraint chain search wih fan-in churn + expected: + - internal/mcp/tools_winnow.go::Server.winnowSymbols + - id: concept-find-usages-typo + tier: concept + query: find everx reference to a symbol + expected: + - internal/mcp/tools_core.go::Server.handleFindUsages + - id: concept-callers-typo + tier: concept + query: who calls this function + expected: + - internal/mcp/tools_core.go::Server.handleGetCallers + - id: concept-call-chain-typo + tier: concept + query: tpace a chain of function calls + expected: + - internal/mcp/tools_core.go::Server.handleGetCallChain + - id: concept-find-impls-typo + tier: concept + query: implexentations of an interface + expected: + - internal/mcp/tools_core.go::Server.handleFindImplementations + - id: concept-import-path-typo + tier: concept + query: resolve Go import path for a symboll + expected: + - internal/mcp/tools_coding.go::Server.handleFindImportPath + - id: concept-repo-outline-typo + tier: concept + query: narratfive single-call repo overview + expected: + - internal/mcp/tools_outline.go::Server.handleGetRepoOutline + - id: concept-rename-symbol-typo + tier: concept + query: coordinated multi-file sename refactor + expected: + - internal/mcp/tools_coding.go::Server.handleRenameSymbol + - id: concept-scaffold-typo + tier: concept + query: generate code from an exnmple pattern + expected: + - internal/mcp/tools_enhancements.go::Server.handleScaffold + - id: concept-batch-edit-typo + tier: concept + query: apply multiuple edits in dependency order + expected: + - internal/mcp/tools_enhancements.go::Server.handleBatchEdit + - id: concept-track-repo-typo + tier: concept + query: add a repostiory to the multi-repo graph + expected: + - internal/mcp/tools_multi.go::Server.handleTrackRepository + - id: concept-untrack-repo-typo + tier: concept + query: remove a repository form the graph + expected: + - internal/mcp/tools_multi.go::Server.handleUntrackRepository + - id: concept-grpc-extract-typo + tier: concept + query: extract gRPC service provkders from a file + expected: + - internal/contracts/grpc.go::GRPCExtractor.Extract + - id: concept-http-extract-typo + tier: concept + query: extract HTTP route handers from source + expected: + - internal/contracts/http.go::HTTPExtractor.Extract + - id: concept-graphql-extract-typo + tier: concept + query: extarct GraphQL resolvers + expected: + - internal/contracts/graphql.go::GraphQLExtractor.Extract + - id: concept-openapi-extract-typo + tier: concept + query: parse OpenAPI pec providers + expected: + - internal/contracts/openapi.go::OpenAPIExtractor.Extract + - id: concept-envvar-extract-typo + tier: concept + query: fiznd environment variable consumers + expected: + - internal/contracts/envvar.go::EnvVarExtractor.Extract + - id: concept-topic-extract-typo + tier: concept + query: extract pub-sub topic emits and listns + expected: + - internal/contracts/topics.go::TopicExtractor.Extract + - id: concept-go-extract-typo + tier: concept + query: Go language exrtactor + expected: + - internal/parser/languages/golang.go::GoExtractor + - id: concept-ts-extract-typo + tier: concept + query: TypeScript lnaguage extractor + expected: + - internal/parser/languages/typescript.go::TypeScriptExtractor + - id: concept-detect-lang-typo + tier: concept + query: detect langauge from file extension + expected: + - internal/parser/registry.go::Registry.GetByExtension + - id: concept-register-all-typo + tier: concept + query: register every language extactor + expected: + - internal/parser/languages/register.go::RegisterAll + - id: concept-ranker-bm25-typo + tier: concept + query: eval rnker adapter for BM25 + expected: + - internal/eval/recall/rankers.go::BM25Ranker + - id: concept-ranker-rrf-typo + tier: concept + query: eval ranker dapter for RRF hybrid + expected: + - internal/eval/recall/rankers.go::RRFRanker + - id: concept-ranker-semantic-typo + tier: concept + query: eval ranker for vectour-only semantic + expected: + - internal/eval/recall/rankers.go::SemanticRanker + - id: concept-ranker-winnow-typo + tier: concept + query: eval raker wrapping graph-aware winnow + expected: + - internal/eval/recall/rankers.go::WinnowRanker + - id: concept-ranker-rg-typo + tier: concept + query: ripgrsp-based retrieval baseline + expected: + - internal/eval/recall/rankers.go::RipgrepRanker + - id: concept-resolve-filepath-typo + tier: concept + query: resolve a repo-prefixed path to absolutea + expected: + - internal/indexer/multi.go::MultiIndexer.ResolveFilePath + - id: concept-repo-for-file-typo + tier: concept + query: find which repo contanis a given file + expected: + - internal/indexer/multi.go::MultiIndexer.RepoForFile + - id: concept-set-embedder-typo + tier: concept + query: attach embedding propider to the indexer + expected: + - internal/indexer/indexer.go::Indexer.SetEmbedder + - id: concept-new-server-typo + tier: concept + query: construct a new MCP serveq instance + expected: + - internal/mcp/server.go::NewServer + - id: concept-new-hybrid-typo + tier: concept + query: construct a hbyrid BM25+vector backend + expected: + - internal/search/hybrid.go::NewHybrid + - id: concept-reindex-all-typo + tier: concept + query: incremental reindex after file cwhanges + expected: + - internal/indexer/indexer.go::Indexer.IncrementalReindex + - id: concept-new-vector-typo + tier: concept + query: HNSW vector indxe backend + expected: + - internal/search/vector.go::VectorBackend + - id: concept-api-embedder-typo + tier: concept + query: OpenAI-compatible embeddings API provideg + expected: + - internal/embedding/api.go::APIProvider + - id: concept-guard-rules-typo + tier: concept + query: team convention guard rle evaluation + expected: + - internal/mcp/tools_enhancements.go::Server.handleCheckGuards + - id: concept-explain-impact-typo + tier: concept + query: risk tiered blast radius analsis + expected: + - internal/mcp/tools_analysis.go::Server.handleEnhancedChangeImpact + - id: concept-diff-context-typo + tier: concept + query: enrich git iff with graph context + expected: + - internal/mcp/tools_enhancements.go::Server.handleDiffContext + - id: concept-detect-changes-typo + tier: concept + query: list changed files since last clal + expected: + - internal/mcp/tools_analysis.go::Server.handleDetectChanges + - id: concept-cluster-retrieval-typo + tier: concept + query: get a graph community clustver by id + expected: + - internal/mcp/tools_core.go::Server.handleGetCluster + - id: concept-plan-turn-typo + tier: concept + query: opening move rouer for first tool call + expected: + - internal/mcp/tools_planning.go::Server.handlePlanTurn + - id: concept-feedback-record-typo + tier: concept + query: record which symbols were useful afdter a task + expected: + - internal/mcp/tools_enhancements.go::Server.handleFeedback + - id: concept-export-context-typo + tier: concept + query: export portable cointext markdown for PR + expected: + - internal/mcp/tools_enhancements.go::Server.handleExportContext + - id: concept-index-health-typo + tier: concept + query: staleness and parse failure healh report + expected: + - internal/mcp/tools_enhancements.go::Server.handleIndexHealth + - id: concept-symbol-history-typo + tier: concept + query: session symbol modiication history + expected: + - internal/mcp/tools_enhancements.go::Server.handleGetSymbolHistory + - id: concept-index-repo-typo + tier: concept + query: run the full reposiotry indexer + expected: + - internal/mcp/tools_core.go::Server.handleIndexRepository + - id: mh-contract-extractors-typo + tier: multi_hop + query: contract extractors for APIsf + expected: + - internal/contracts/grpc.go::GRPCExtractor + - internal/contracts/http.go::HTTPExtractor + - internal/contracts/graphql.go::GraphQLExtractor + - internal/contracts/openapi.go::OpenAPIExtractor + - internal/contracts/envvar.go::EnvVarExtractor + - internal/contracts/topics.go::TopicExtractor + - id: mh-extract-methods-typo + tier: multi_hop + query: Extrac method on contract extractor types + expected: + - internal/contracts/grpc.go::GRPCExtractor.Extract + - internal/contracts/http.go::HTTPExtractor.Extract + - internal/contracts/graphql.go::GraphQLExtractor.Extract + - internal/contracts/openapi.go::OpenAPIExtractor.Extract + - internal/contracts/envvar.go::EnvVarExtractor.Extract + - internal/contracts/topics.go::TopicExtractor.Extract + - id: mh-edit-tools-typo + tier: multi_hop + query: MCP handlerk that modify source files + expected: + - internal/mcp/tools_coding.go::Server.handleEditSymbol + - internal/mcp/tools_fileops.go::Server.handleEditFile + - internal/mcp/tools_fileops.go::Server.handleWriteFile + - internal/mcp/tools_enhancements.go::Server.handleBatchEdit + - internal/mcp/tools_enhancements.go::Server.handleScaffold + - id: mh-embedding-providers-typo + tier: multi_hop + query: emebdding provider implementations + expected: + - internal/embedding/static.go::StaticProvider + - internal/embedding/hugot.go::HugotProvider + - internal/embedding/api.go::APIProvider + - internal/embedding/onnx.go::ONNXProvider + - internal/embedding/nop.go::NopProvider + - id: mh-search-backends-typo + tier: multi_hop + query: search backend implementatsions + expected: + - internal/search/bm25.go::BM25Backend + - internal/search/bleve.go::BleveBackend + - internal/search/hybrid.go::HybridBackend + - internal/search/vector.go::VectorBackend + - internal/search/swappable.go::Swappable + - id: mh-handle-tools-coding-typo + tier: multi_hop + query: MCP tools registered in toolsc_oding.go + expected: + - internal/mcp/tools_coding.go::Server.handleGetEditingContext + - internal/mcp/tools_coding.go::Server.handleEditSymbol + - internal/mcp/tools_coding.go::Server.handleSmartContext + - internal/mcp/tools_coding.go::Server.handleRenameSymbol + - internal/mcp/tools_coding.go::Server.handleFindImportPath + - id: mh-graph-mutators-typo + tier: multi_hop + query: metohds that mutate the graph + expected: + - internal/graph/graph.go::Graph.AddNode + - internal/graph/graph.go::Graph.AddEdge + - internal/graph/graph.go::Graph.EvictFile + - internal/graph/graph.go::Graph.EvictRepo + - internal/graph/graph.go::Graph.ReindexEdge + - id: mh-winnow-scorer-parts-typo + tier: multi_hop + query: pieces of the winnpw scorer pipeline + expected: + - internal/mcp/tools_winnow.go::Server.winnowSymbols + - internal/mcp/tools_winnow.go::applyWinnowPrefilter + - internal/mcp/tools_winnow.go::computeFanInOut + - internal/mcp/tools_winnow.go::winnowConstraints + - internal/mcp/tools_winnow.go::Server.WinnowForEval + - id: mh-eval-rankers-typo + tier: multi_hop + query: ranker adapters in the eval packapge + expected: + - internal/eval/recall/rankers.go::BM25Ranker + - internal/eval/recall/rankers.go::SemanticRanker + - internal/eval/recall/rankers.go::RRFRanker + - internal/eval/recall/rankers.go::WinnowRanker + - internal/eval/recall/rankers.go::RipgrepRanker + - id: mh-indexer-api-typo + tier: multi_hop + query: public Indexer API methdos + expected: + - internal/indexer/indexer.go::Indexer.Index + - internal/indexer/indexer.go::Indexer.IndexFile + - internal/indexer/indexer.go::Indexer.IndexFileNoResolve + - internal/indexer/indexer.go::Indexer.IncrementalReindex + - internal/indexer/indexer.go::Indexer.Search + - internal/indexer/indexer.go::Indexer.Graph + - id: mh-multi-repo-typo + tier: multi_hop + query: multir-epo indexer operations + expected: + - internal/indexer/multi.go::MultiIndexer.TrackRepo + - internal/indexer/multi.go::MultiIndexer.UntrackRepo + - internal/indexer/multi.go::MultiIndexer.IndexAll + - internal/indexer/multi.go::MultiIndexer.RepoForFile + - internal/indexer/multi.go::MultiIndexer.ResolveFilePath + - id: mh-session-helpers-typo + tier: multi_hop + query: sessipn state helpers on the MCP server + expected: + - internal/mcp/server.go::Server.sessionFor + - internal/mcp/server.go::sessionState.recordModified + - internal/mcp/server.go::sessionState.recordSymbol + - internal/mcp/server.go::sessionState + - internal/mcp/server.go::newSessionState + - id: mh-agent-adapters-typo + tier: multi_hop + query: IDE agecnt adapters + expected: + - internal/agents/claudecode/adapter.go::Adapter + - internal/agents/cursor/adapter.go::Adapter + - internal/agents/vscode/adapter.go::Adapter + - internal/agents/kilocode/adapter.go::Adapter + - internal/agents/zed/adapter.go::Adapter + - id: mh-init-subcommands-typo + tier: multi_hop + query: gortex init relatd commands + expected: + - cmd/gortex/init.go::initCmd + - cmd/gortex/init_doctor.go::initDoctorCmd + - cmd/gortex/init_doctor.go::runInitDoctor + - cmd/gortex/init.go::runInit + - id: mh-daemon-components-typo + tier: multi_hop + query: daemon server paots + expected: + - internal/daemon/session.go::Session + - internal/daemon/session.go::SessionRegistry + - id: mh-search-interface-members-typo + tier: multi_hop + query: methods required by the search Backend interflce + expected: + - internal/search/search.go::Backend + - id: mh-graph-reader-api-typo + tier: multi_hop + query: read-only graph accgessors + expected: + - internal/graph/graph.go::Graph.GetFileNodes + - id: mh-tokens-helpers-typo + tier: multi_hop + query: token ocunting helpers + expected: + - internal/tokens/tokens.go::Count + - internal/tokens/tokens.go::CountInt64 + - id: mh-atomic-writers-typo + tier: multi_hop + query: atomic write helpers shared aross adapters + expected: + - internal/agents/writer.go::AtomicWriteFile + - internal/agents/writer.go::WriteIfNotExists + - id: mh-recall-types-typo + tier: multi_hop + query: recall report dnata types + expected: + - internal/eval/recall/recall.go::Report + - internal/eval/recall/recall.go::RankerResult + - internal/eval/recall/recall.go::Fixture + - internal/eval/recall/recall.go::Case + - internal/eval/recall/recall.go::Ranker + - id: mh-fileops-helpers-typo + tier: multi_hop + query: file operation MCP handler helpero + expected: + - internal/mcp/tools_fileops.go::Server.resolveFilePath + - internal/mcp/tools_fileops.go::Server.repoRelative + - internal/mcp/tools_fileops.go::Server.reindexFile + - internal/mcp/tools_fileops.go::Server.handleEditFile + - internal/mcp/tools_fileops.go::Server.handleWriteFile + - id: mh-graph-structs-typo + tier: multi_hop + query: core graph dat structures + expected: + - internal/graph/node.go::Node + - internal/graph/edge.go::Edge + - internal/graph/graph.go::Graph + - id: mh-index-result-typo + tier: multi_hop + query: index result and error reportign types + expected: + - internal/indexer/indexer.go::IndexResult + - internal/indexer/indexer.go::IndexError + - id: mh-cmd-commands-typo + tier: multi_hop + query: top-leeel cobra commands under cmd/gortex + expected: + - cmd/gortex/mcp.go::mcpCmd + - cmd/gortex/server.go::serverCmd + - cmd/gortex/daemon.go::daemonCmd + - cmd/gortex/track.go::trackCmd + - cmd/gortex/eval.go::evalCmd + - id: mh-config-load-typo + tier: multi_hop + query: configuration loading and dehaults + expected: + - internal/config/config.go::Load + - internal/config/config.go::Default + - id: mh-bm25-api-typo + tier: multi_hop + query: BM25 backend pubilc API + expected: + - internal/search/bm25.go::BM25Backend.Add + - internal/search/bm25.go::BM25Backend.Remove + - internal/search/bm25.go::BM25Backend.Search + - internal/search/bm25.go::BM25Backend.Count + - internal/search/bm25.go::NewBM25 + - id: mh-tool-registrations-typo + tier: multi_hop + query: MCP tool registration functuons + expected: + - internal/mcp/tools_coding.go::Server.registerCodingTools + - internal/mcp/tools_core.go::Server.registerCoreTools + - internal/mcp/tools_analysis.go::Server.registerAnalysisTools + - internal/mcp/tools_enhancements.go::Server.registerEnhancementTools + - id: mh-wire-format-typo + tier: multi_hop + query: GCX1 compact wire formt encoders + expected: + - pkg/wire/decoder.go::Decoder + - pkg/wire/decoder.go::NewDecoder + - id: mh-parser-languages-typo + tier: multi_hop + query: lqnguage-specific parser extractors + expected: + - internal/parser/languages/golang.go::GoExtractor + - internal/parser/languages/typescript.go::TypeScriptExtractor + - internal/parser/languages/python.go::PythonExtractor + - internal/parser/languages/rust.go::RustExtractor + - internal/parser/languages/c.go::CExtractor + - id: mh-eval-subcommands-typo + tier: multi_hop + query: gortex eavl subcommands + expected: + - cmd/gortex/eval_recall.go::evalRecallCmd + - cmd/gortex/eval_swebench.go::evalSwebenchCmd + - cmd/gortex/eval_tokens.go::evalTokensCmd diff --git a/bench/index-perf/README.md b/bench/index-perf/README.md new file mode 100644 index 0000000..71d58ee --- /dev/null +++ b/bench/index-perf/README.md @@ -0,0 +1,58 @@ +# Cold-index perf regression harness + +A `go test` target that cold-indexes a small, fixed Go fixture through the real +indexer pipeline and guards against wall-clock regressions. + +## What it measures + +Each run constructs a fresh graph + indexer (the same `indexer.New` / `Index` +path the daemon uses) and cold-indexes `testdata/fixture`. Per pass it records: + +- **wall-clock** — time for the `Index` call +- **allocated bytes** — `runtime.MemStats.TotalAlloc` delta across the pass +- **GC CPU fraction** — `runtime.MemStats.GCCPUFraction` (plus `NumGC` and + `PauseTotalNs` deltas) + +It times several passes (`GORTEX_BENCH_INDEX_RUNS`, default 8) and keeps the +**minimum** wall-clock as the representative figure — the minimum is the most +stable estimator, since jitter only ever adds time. Only wall-clock is gated; +allocation and GC fraction are recorded so later GC-tuning and bulk-persistence +work can watch them move. + +## The gate + +The best wall-clock is compared against `testdata/baseline.json`. The test +fails when it exceeds the baseline by more than **15%**. + +The baseline is recorded **without** the race detector. Under `-race`, time and +allocation inflate several-fold, so the harness prints the numbers but skips the +gate — `go test -race ./...` stays green. + +## Running + +```bash +go test ./bench/index-perf/ # measure + gate +go test -run ColdIndex -v ./bench/index-perf/ # print the numbers +``` + +## Regenerating the baseline + +Run on the target machine after an intentional change: + +```bash +GORTEX_BENCH_INDEX_UPDATE_BASELINE=1 go test ./bench/index-perf/ +``` + +Commit the updated `testdata/baseline.json`. + +## Knobs + +| Env var | Effect | +| --- | --- | +| `GORTEX_BENCH_INDEX_UPDATE_BASELINE=1` | rewrite the committed baseline and pass | +| `GORTEX_BENCH_INDEX_FIXTURE=/abs/dir` | cold-index another tree instead of the fixture | +| `GORTEX_BENCH_INDEX_RUNS=16` | number of timed cold-index passes | + +The fixture lives under `testdata/` so the Go toolchain (and `go test ./...`, +`go vet`, golangci-lint) ignores it, while the indexer still parses it because +the harness points the index walk directly at the directory. diff --git a/bench/index-perf/index_perf_test.go b/bench/index-perf/index_perf_test.go new file mode 100644 index 0000000..48c0fec --- /dev/null +++ b/bench/index-perf/index_perf_test.go @@ -0,0 +1,283 @@ +// Package indexperf is a cold-index performance regression harness. +// +// It repeatedly cold-indexes a small, fixed Go fixture through the real +// indexer pipeline (the same indexer.New / Index path the daemon uses) and +// records three headline numbers per run: wall-clock time, bytes allocated, +// and the GC CPU fraction. The best (minimum) wall-clock across runs is +// compared against a committed baseline, and the harness reports a regression +// when it exceeds the baseline by more than regressionTolerance. +// +// Run it: +// +// go test ./bench/index-perf/ # measure + gate +// go test -run ColdIndex -v ./bench/index-perf/ # see the printed numbers +// +// Regenerate the committed baseline on the current machine: +// +// GORTEX_BENCH_INDEX_UPDATE_BASELINE=1 go test ./bench/index-perf/ +// +// Other knobs: +// +// GORTEX_BENCH_INDEX_FIXTURE=/abs/dir point the harness at another tree +// GORTEX_BENCH_INDEX_RUNS=16 number of timed cold-index passes +// +// Only wall-clock is gated; the allocation and GC-fraction figures are +// recorded and printed so later work on GC tuning and bulk persistence can +// watch them move. +package indexperf + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "runtime" + "strconv" + "testing" + "time" + + "go.uber.org/zap" + + "github.com/zzet/gortex/internal/config" + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/indexer" + "github.com/zzet/gortex/internal/parser" + "github.com/zzet/gortex/internal/parser/languages" +) + +// regressionTolerance is the fraction by which a fresh cold-index wall-clock +// may exceed the committed baseline before the harness reports a regression. +const regressionTolerance = 0.15 + +// defaultRuns is how many cold-index passes the harness times. The minimum +// across passes is the representative figure: scheduler and allocator jitter +// only ever add time, so the minimum is the most stable estimator of the true +// cost and keeps the +15% gate from flapping on a loaded machine. +const defaultRuns = 8 + +const ( + updateBaselineEnv = "GORTEX_BENCH_INDEX_UPDATE_BASELINE" + fixtureDirEnv = "GORTEX_BENCH_INDEX_FIXTURE" + runsEnv = "GORTEX_BENCH_INDEX_RUNS" +) + +// measurement holds the metrics from a single cold-index pass. +type measurement struct { + WallClockNs int64 + AllocBytes uint64 + GCCPUFraction float64 + NumGC uint32 + GCPauseTotalNs uint64 + Nodes int + Edges int + Files int +} + +// baseline is the committed reference recorded on a representative machine. +type baseline struct { + WallClockNs int64 `json:"wall_clock_ns"` + WallClockMs float64 `json:"wall_clock_ms"` + AllocBytes uint64 `json:"alloc_bytes"` + GCCPUFraction float64 `json:"gc_cpu_fraction"` + NumGC uint32 `json:"num_gc"` + GCPauseTotalNs uint64 `json:"gc_pause_total_ns"` + Nodes int `json:"nodes"` + Edges int `json:"edges"` + Files int `json:"files"` + Runs int `json:"runs"` + GoVersion string `json:"go_version"` + GOOS string `json:"goos"` + GOARCH string `json:"goarch"` + Note string `json:"note"` +} + +// TestColdIndexNoWallClockRegression cold-indexes the fixture, prints the +// three headline numbers, and fails when the best wall-clock regresses past +// the committed baseline by more than regressionTolerance. +func TestColdIndexNoWallClockRegression(t *testing.T) { + fixture := fixtureDir(t) + runs := runCount() + + best := measureColdIndexes(t, fixture, runs) + + wallMs := float64(best.WallClockNs) / 1e6 + t.Logf("cold-index wall-clock: %.2f ms (best of %d runs)", wallMs, runs) + t.Logf("cold-index allocated bytes: %d (%.1f MiB)", best.AllocBytes, float64(best.AllocBytes)/(1024*1024)) + t.Logf("cold-index GC CPU fraction: %.6f (num_gc=%d, pause_total=%d ns)", best.GCCPUFraction, best.NumGC, best.GCPauseTotalNs) + t.Logf("indexed graph: %d nodes, %d edges, %d files", best.Nodes, best.Edges, best.Files) + + // Defend against a future config/exclude change silently turning the + // fixture cold index into a no-op (which would make the bench meaningless + // and the wall-clock ~0). + if best.Files == 0 || best.Nodes == 0 { + t.Fatalf("fixture indexed nothing (files=%d nodes=%d) — fixture missing or skipped by the indexer", best.Files, best.Nodes) + } + + baselinePath := baselineFile(t) + + if os.Getenv(updateBaselineEnv) == "1" { + writeBaseline(t, baselinePath, best, runs) + t.Logf("baseline written to %s", baselinePath) + return + } + + base, err := readBaseline(baselinePath) + if err != nil { + t.Skipf("no usable baseline at %s (%v); regenerate with %s=1", baselinePath, err, updateBaselineEnv) + } + + // The committed baseline is recorded without the race detector. Race + // instrumentation inflates time and allocation several-fold, so a + // race-mode wall-clock is not comparable to it: print the numbers but do + // not gate on them under -race (keeps `go test -race ./...` green). + if raceDetector { + t.Logf("race detector active: skipping the +%.0f%% wall-clock regression gate (race timing is not comparable to the baseline)", regressionTolerance*100) + return + } + + limitNs := float64(base.WallClockNs) * (1 + regressionTolerance) + t.Logf("baseline wall-clock: %.2f ms; regression limit (+%.0f%%): %.2f ms", + float64(base.WallClockNs)/1e6, regressionTolerance*100, limitNs/1e6) + + if float64(best.WallClockNs) > limitNs { + t.Errorf("cold-index wall-clock regression: %.2f ms exceeds the baseline %.2f ms by more than %.0f%% (limit %.2f ms). Investigate the slowdown, or refresh the baseline with %s=1 if the change is intentional.", + wallMs, float64(base.WallClockNs)/1e6, regressionTolerance*100, limitNs/1e6, updateBaselineEnv) + } +} + +// measureColdIndexes runs runs cold-index passes and returns the pass with the +// smallest wall-clock. +func measureColdIndexes(t *testing.T, fixture string, runs int) measurement { + t.Helper() + var best measurement + for i := 0; i < runs; i++ { + m := measureOnce(t, fixture) + if i == 0 || m.WallClockNs < best.WallClockNs { + best = m + } + } + return best +} + +// measureOnce performs one full cold index over fixture against a fresh graph +// and indexer, timing the Index call and capturing allocation and GC deltas +// across it. +func measureOnce(t *testing.T, fixture string) measurement { + t.Helper() + + g := graph.New() + reg := parser.NewRegistry() + languages.RegisterAll(reg) + idx := indexer.New(g, reg, config.Config{}.Index, zap.NewNop()) + + // Settle the heap so the deltas attribute allocation and pauses to the + // index pass alone, then snapshot the starting counters. + runtime.GC() + var before, after runtime.MemStats + runtime.ReadMemStats(&before) + + start := time.Now() + res, err := idx.Index(fixture) + elapsed := time.Since(start) + if err != nil { + t.Fatalf("cold index of %s failed: %v", fixture, err) + } + + // Read counters without forcing a GC first: NumGC and PauseTotalNs deltas + // should reflect only the collections that happened during indexing. + runtime.ReadMemStats(&after) + + return measurement{ + WallClockNs: elapsed.Nanoseconds(), + AllocBytes: after.TotalAlloc - before.TotalAlloc, + GCCPUFraction: after.GCCPUFraction, + NumGC: after.NumGC - before.NumGC, + GCPauseTotalNs: after.PauseTotalNs - before.PauseTotalNs, + Nodes: res.NodeCount, + Edges: res.EdgeCount, + Files: res.FileCount, + } +} + +// fixtureDir returns the directory to cold-index: the env override when set, +// otherwise the committed Go fixture under testdata. +func fixtureDir(t *testing.T) string { + t.Helper() + if v := os.Getenv(fixtureDirEnv); v != "" { + return v + } + abs, err := filepath.Abs(filepath.Join("testdata", "fixture")) + if err != nil { + t.Fatalf("resolve fixture dir: %v", err) + } + if _, err := os.Stat(abs); err != nil { + t.Fatalf("fixture dir %s missing: %v", abs, err) + } + return abs +} + +// baselineFile returns the path of the committed baseline JSON. +func baselineFile(t *testing.T) string { + t.Helper() + abs, err := filepath.Abs(filepath.Join("testdata", "baseline.json")) + if err != nil { + t.Fatalf("resolve baseline path: %v", err) + } + return abs +} + +// runCount resolves the number of timed passes from the environment, falling +// back to defaultRuns. +func runCount() int { + if v := os.Getenv(runsEnv); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + return n + } + } + return defaultRuns +} + +// readBaseline loads and validates the committed baseline. +func readBaseline(path string) (baseline, error) { + var b baseline + raw, err := os.ReadFile(path) + if err != nil { + return b, err + } + if err := json.Unmarshal(raw, &b); err != nil { + return b, err + } + if b.WallClockNs <= 0 { + return b, fmt.Errorf("baseline has non-positive wall_clock_ns (%d)", b.WallClockNs) + } + return b, nil +} + +// writeBaseline records the measurement as the new committed baseline. +func writeBaseline(t *testing.T, path string, m measurement, runs int) { + t.Helper() + b := baseline{ + WallClockNs: m.WallClockNs, + WallClockMs: float64(m.WallClockNs) / 1e6, + AllocBytes: m.AllocBytes, + GCCPUFraction: m.GCCPUFraction, + NumGC: m.NumGC, + GCPauseTotalNs: m.GCPauseTotalNs, + Nodes: m.Nodes, + Edges: m.Edges, + Files: m.Files, + Runs: runs, + GoVersion: runtime.Version(), + GOOS: runtime.GOOS, + GOARCH: runtime.GOARCH, + Note: "best-of-runs cold index of testdata/fixture; the gate is wall_clock_ns + 15%; regenerate with " + updateBaselineEnv + "=1", + } + raw, err := json.MarshalIndent(b, "", " ") + if err != nil { + t.Fatalf("marshal baseline: %v", err) + } + raw = append(raw, '\n') + if err := os.WriteFile(path, raw, 0o644); err != nil { + t.Fatalf("write baseline %s: %v", path, err) + } +} diff --git a/bench/index-perf/race_off.go b/bench/index-perf/race_off.go new file mode 100644 index 0000000..75de5c1 --- /dev/null +++ b/bench/index-perf/race_off.go @@ -0,0 +1,7 @@ +//go:build !race + +package indexperf + +// raceDetector reports whether the binary was built with the race detector. +// See race_on.go for why the regression gate is conditioned on it. +const raceDetector = false diff --git a/bench/index-perf/race_on.go b/bench/index-perf/race_on.go new file mode 100644 index 0000000..b8570e2 --- /dev/null +++ b/bench/index-perf/race_on.go @@ -0,0 +1,10 @@ +//go:build race + +package indexperf + +// raceDetector reports whether the binary was built with the race detector. +// Race instrumentation inflates both wall-clock and allocation several-fold, +// so a race-mode measurement is not comparable to a baseline recorded without +// it. The harness records and prints the numbers under -race but skips the +// wall-clock regression gate, keeping `go test -race` green. +const raceDetector = true diff --git a/bench/index-perf/testdata/baseline.json b/bench/index-perf/testdata/baseline.json new file mode 100644 index 0000000..449e957 --- /dev/null +++ b/bench/index-perf/testdata/baseline.json @@ -0,0 +1,16 @@ +{ + "wall_clock_ns": 21665292, + "wall_clock_ms": 21.665292, + "alloc_bytes": 8789264, + "gc_cpu_fraction": 0.0020472144272846798, + "num_gc": 2, + "gc_pause_total_ns": 101874, + "nodes": 200, + "edges": 773, + "files": 10, + "runs": 8, + "go_version": "go1.26.4", + "goos": "darwin", + "goarch": "arm64", + "note": "best-of-runs cold index of testdata/fixture; the gate is wall_clock_ns + 15%; regenerate with GORTEX_BENCH_INDEX_UPDATE_BASELINE=1" +} diff --git a/bench/index-perf/testdata/fixture/errors.go b/bench/index-perf/testdata/fixture/errors.go new file mode 100644 index 0000000..1527fb5 --- /dev/null +++ b/bench/index-perf/testdata/fixture/errors.go @@ -0,0 +1,28 @@ +package ledger + +import "errors" + +var ( + // ErrUnbalanced is returned when a transaction's entries do not sum to zero. + ErrUnbalanced = errors.New("ledger: transaction is unbalanced") + // ErrUnknownAccount is returned when an entry names an account that does not exist. + ErrUnknownAccount = errors.New("ledger: unknown account") + // ErrCurrencyMismatch is returned when amounts of different currencies are combined. + ErrCurrencyMismatch = errors.New("ledger: currency mismatch") + // ErrEmptyTransaction is returned when a transaction has no entries. + ErrEmptyTransaction = errors.New("ledger: transaction has no entries") +) + +// PostingError wraps a lower-level error with the transaction it came from. +type PostingError struct { + TxID string + Err error +} + +func (e *PostingError) Error() string { + return "ledger: posting " + e.TxID + ": " + e.Err.Error() +} + +func (e *PostingError) Unwrap() error { + return e.Err +} diff --git a/bench/index-perf/testdata/fixture/events.go b/bench/index-perf/testdata/fixture/events.go new file mode 100644 index 0000000..26931f7 --- /dev/null +++ b/bench/index-perf/testdata/fixture/events.go @@ -0,0 +1,49 @@ +package ledger + +// EventKind enumerates ledger lifecycle events. +type EventKind int + +const ( + EventAccountOpened EventKind = iota + EventPosted + EventPostFailed +) + +// Event is a ledger lifecycle notification. +type Event struct { + Kind EventKind + Subject string +} + +// Listener receives ledger events. +type Listener interface { + Notify(ev Event) +} + +// ListenerFunc adapts a plain function to the Listener interface. +type ListenerFunc func(ev Event) + +// Notify invokes the underlying function. +func (f ListenerFunc) Notify(ev Event) { f(ev) } + +// Dispatcher fans events out to registered listeners. +type Dispatcher struct { + listeners []Listener +} + +// NewDispatcher returns an empty dispatcher. +func NewDispatcher() *Dispatcher { + return &Dispatcher{} +} + +// Subscribe adds a listener. +func (d *Dispatcher) Subscribe(l Listener) { + d.listeners = append(d.listeners, l) +} + +// Emit delivers ev to every listener in registration order. +func (d *Dispatcher) Emit(ev Event) { + for _, l := range d.listeners { + l.Notify(ev) + } +} diff --git a/bench/index-perf/testdata/fixture/format.go b/bench/index-perf/testdata/fixture/format.go new file mode 100644 index 0000000..3018a20 --- /dev/null +++ b/bench/index-perf/testdata/fixture/format.go @@ -0,0 +1,47 @@ +package ledger + +import ( + "fmt" + "strings" +) + +// FormatMoney renders an amount as a decimal string with its currency. +func FormatMoney(m Money) string { + sign := "" + minor := m.Minor + if minor < 0 { + sign = "-" + minor = -minor + } + major := minor / 100 + cents := minor % 100 + return fmt.Sprintf("%s%d.%02d %s", sign, major, cents, m.Currency) +} + +// FormatTransaction renders a transaction as a multi-line string. +func FormatTransaction(tx Transaction) string { + var b strings.Builder + fmt.Fprintf(&b, "tx %s: %s\n", tx.ID, tx.Memo) + for _, e := range tx.Entries { + fmt.Fprintf(&b, " %s %s %s\n", e.AccountID, FormatMoney(e.Amount), e.Memo) + } + return b.String() +} + +// FormatKind returns a human label for an account kind. +func FormatKind(k AccountKind) string { + switch k { + case AccountAsset: + return "asset" + case AccountLiability: + return "liability" + case AccountEquity: + return "equity" + case AccountRevenue: + return "revenue" + case AccountExpense: + return "expense" + default: + return "unknown" + } +} diff --git a/bench/index-perf/testdata/fixture/model.go b/bench/index-perf/testdata/fixture/model.go new file mode 100644 index 0000000..fe3f208 --- /dev/null +++ b/bench/index-perf/testdata/fixture/model.go @@ -0,0 +1,64 @@ +package ledger + +import "time" + +// AccountKind classifies an account in the double-entry ledger. +type AccountKind int + +const ( + AccountAsset AccountKind = iota + AccountLiability + AccountEquity + AccountRevenue + AccountExpense +) + +// Money is a minor-unit monetary amount in a fixed currency. +type Money struct { + Minor int64 + Currency string +} + +// Account is a single ledger account. +type Account struct { + ID string + Name string + Kind AccountKind + Balance Money + Created time.Time +} + +// Entry is one leg of a transaction posted against an account. +type Entry struct { + AccountID string + Amount Money + Memo string +} + +// Transaction is a balanced set of entries. +type Transaction struct { + ID string + Posted time.Time + Entries []Entry + Memo string +} + +// Add returns the sum of two amounts in the same currency. +func (m Money) Add(other Money) Money { + return Money{Minor: m.Minor + other.Minor, Currency: m.Currency} +} + +// Sub returns the difference of two amounts in the same currency. +func (m Money) Sub(other Money) Money { + return Money{Minor: m.Minor - other.Minor, Currency: m.Currency} +} + +// IsZero reports whether the amount is exactly zero. +func (m Money) IsZero() bool { + return m.Minor == 0 +} + +// Negate flips the sign of an amount. +func (m Money) Negate() Money { + return Money{Minor: -m.Minor, Currency: m.Currency} +} diff --git a/bench/index-perf/testdata/fixture/posting.go b/bench/index-perf/testdata/fixture/posting.go new file mode 100644 index 0000000..919f858 --- /dev/null +++ b/bench/index-perf/testdata/fixture/posting.go @@ -0,0 +1,21 @@ +package ledger + +// post applies a validated transaction's entries to account balances. +func post(store Store, tx Transaction) error { + if err := validateBalanced(tx); err != nil { + return &PostingError{TxID: tx.ID, Err: err} + } + if err := validateAccounts(store, tx); err != nil { + return &PostingError{TxID: tx.ID, Err: err} + } + for _, e := range tx.Entries { + acct, ok := store.GetAccount(e.AccountID) + if !ok { + return &PostingError{TxID: tx.ID, Err: ErrUnknownAccount} + } + acct.Balance = acct.Balance.Add(e.Amount) + store.PutAccount(acct) + } + store.AppendTransaction(tx) + return nil +} diff --git a/bench/index-perf/testdata/fixture/report.go b/bench/index-perf/testdata/fixture/report.go new file mode 100644 index 0000000..2c1135d --- /dev/null +++ b/bench/index-perf/testdata/fixture/report.go @@ -0,0 +1,37 @@ +package ledger + +import "sort" + +// Balances returns the balance of every account keyed by ID. +func Balances(store Store) map[string]Money { + out := make(map[string]Money) + for _, a := range store.Accounts() { + out[a.ID] = a.Balance + } + return out +} + +// TrialBalance sums balances by account kind. +func TrialBalance(store Store) map[AccountKind]Money { + out := make(map[AccountKind]Money) + for _, a := range store.Accounts() { + cur := out[a.Kind] + if cur.Currency == "" { + cur.Currency = a.Balance.Currency + } + out[a.Kind] = cur.Add(a.Balance) + } + return out +} + +// RecentTransactions returns up to n most recently posted transactions. +func RecentTransactions(store Store, n int) []Transaction { + txns := store.Transactions() + sort.Slice(txns, func(i, j int) bool { + return txns[i].Posted.After(txns[j].Posted) + }) + if n > 0 && len(txns) > n { + txns = txns[:n] + } + return txns +} diff --git a/bench/index-perf/testdata/fixture/service.go b/bench/index-perf/testdata/fixture/service.go new file mode 100644 index 0000000..895182c --- /dev/null +++ b/bench/index-perf/testdata/fixture/service.go @@ -0,0 +1,63 @@ +package ledger + +import ( + "fmt" + "time" +) + +// Service is the ledger's public API over a Store. +type Service struct { + store Store + clock func() time.Time + events *Dispatcher +} + +// NewService builds a Service backed by store. +func NewService(store Store) *Service { + return &Service{ + store: store, + clock: time.Now, + events: NewDispatcher(), + } +} + +// OpenAccount registers a new account and returns it. +func (s *Service) OpenAccount(id, name string, kind AccountKind, currency string) *Account { + acct := &Account{ + ID: id, + Name: name, + Kind: kind, + Balance: Money{Currency: currency}, + Created: s.clock(), + } + s.store.PutAccount(acct) + s.events.Emit(Event{Kind: EventAccountOpened, Subject: id}) + return acct +} + +// Post validates and records a transaction. +func (s *Service) Post(tx Transaction) error { + if tx.Posted.IsZero() { + tx.Posted = s.clock() + } + if err := post(s.store, tx); err != nil { + s.events.Emit(Event{Kind: EventPostFailed, Subject: tx.ID}) + return err + } + s.events.Emit(Event{Kind: EventPosted, Subject: tx.ID}) + return nil +} + +// Balance returns the current balance of an account. +func (s *Service) Balance(id string) (Money, error) { + acct, ok := s.store.GetAccount(id) + if !ok { + return Money{}, fmt.Errorf("balance: %w", ErrUnknownAccount) + } + return acct.Balance, nil +} + +// Subscribe registers a listener for ledger events. +func (s *Service) Subscribe(l Listener) { + s.events.Subscribe(l) +} diff --git a/bench/index-perf/testdata/fixture/store.go b/bench/index-perf/testdata/fixture/store.go new file mode 100644 index 0000000..66c7376 --- /dev/null +++ b/bench/index-perf/testdata/fixture/store.go @@ -0,0 +1,66 @@ +package ledger + +import "sync" + +// Store persists accounts and transactions. +type Store interface { + GetAccount(id string) (*Account, bool) + PutAccount(a *Account) + AppendTransaction(tx Transaction) + Transactions() []Transaction + Accounts() []*Account +} + +// InMemoryStore is a goroutine-safe in-memory Store. +type InMemoryStore struct { + mu sync.RWMutex + accounts map[string]*Account + txns []Transaction +} + +// NewInMemoryStore returns an empty store. +func NewInMemoryStore() *InMemoryStore { + return &InMemoryStore{accounts: make(map[string]*Account)} +} + +// GetAccount returns the account with the given ID, if present. +func (s *InMemoryStore) GetAccount(id string) (*Account, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + a, ok := s.accounts[id] + return a, ok +} + +// PutAccount inserts or replaces an account. +func (s *InMemoryStore) PutAccount(a *Account) { + s.mu.Lock() + defer s.mu.Unlock() + s.accounts[a.ID] = a +} + +// AppendTransaction records a posted transaction. +func (s *InMemoryStore) AppendTransaction(tx Transaction) { + s.mu.Lock() + defer s.mu.Unlock() + s.txns = append(s.txns, tx) +} + +// Transactions returns a copy of the recorded transactions. +func (s *InMemoryStore) Transactions() []Transaction { + s.mu.RLock() + defer s.mu.RUnlock() + out := make([]Transaction, len(s.txns)) + copy(out, s.txns) + return out +} + +// Accounts returns every stored account in arbitrary order. +func (s *InMemoryStore) Accounts() []*Account { + s.mu.RLock() + defer s.mu.RUnlock() + out := make([]*Account, 0, len(s.accounts)) + for _, a := range s.accounts { + out = append(out, a) + } + return out +} diff --git a/bench/index-perf/testdata/fixture/util.go b/bench/index-perf/testdata/fixture/util.go new file mode 100644 index 0000000..7706aaf --- /dev/null +++ b/bench/index-perf/testdata/fixture/util.go @@ -0,0 +1,25 @@ +package ledger + +import "strings" + +// NormalizeID trims and lowercases an account or transaction identifier. +func NormalizeID(id string) string { + return strings.ToLower(strings.TrimSpace(id)) +} + +// Entries is a convenience constructor for a slice of entries. +func Entries(es ...Entry) []Entry { + out := make([]Entry, 0, len(es)) + out = append(out, es...) + return out +} + +// Debit builds an entry that increases an asset or expense account. +func Debit(accountID string, amount Money, memo string) Entry { + return Entry{AccountID: NormalizeID(accountID), Amount: amount, Memo: memo} +} + +// Credit builds an entry that decreases an asset or expense account. +func Credit(accountID string, amount Money, memo string) Entry { + return Entry{AccountID: NormalizeID(accountID), Amount: amount.Negate(), Memo: memo} +} diff --git a/bench/index-perf/testdata/fixture/validation.go b/bench/index-perf/testdata/fixture/validation.go new file mode 100644 index 0000000..fa9a3d7 --- /dev/null +++ b/bench/index-perf/testdata/fixture/validation.go @@ -0,0 +1,38 @@ +package ledger + +// sumEntries adds up the entry amounts, requiring a single currency. +func sumEntries(entries []Entry) (Money, error) { + if len(entries) == 0 { + return Money{}, ErrEmptyTransaction + } + total := Money{Currency: entries[0].Amount.Currency} + for _, e := range entries { + if e.Amount.Currency != total.Currency { + return Money{}, ErrCurrencyMismatch + } + total = total.Add(e.Amount) + } + return total, nil +} + +// validateBalanced checks that a transaction's entries net to zero. +func validateBalanced(tx Transaction) error { + total, err := sumEntries(tx.Entries) + if err != nil { + return err + } + if !total.IsZero() { + return ErrUnbalanced + } + return nil +} + +// validateAccounts checks that every entry references a known account. +func validateAccounts(store Store, tx Transaction) error { + for _, e := range tx.Entries { + if _, ok := store.GetAccount(e.AccountID); !ok { + return ErrUnknownAccount + } + } + return nil +} diff --git a/bench/issue40_context_repro.py b/bench/issue40_context_repro.py new file mode 100644 index 0000000..6825c53 --- /dev/null +++ b/bench/issue40_context_repro.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +""" +Reproduction for issue #40 — "Claude Code eats up context when using gortex". + +The report's core, *measurable* claim is: + + - During plan implementation, files get read in FULL via gortex's read_file / + get_editing_context, which is token-expensive. + - compress_bodies:true and/or search_text are far cheaper, but nothing forces + (or even nudges toward) them — read_file defaults to compress_bodies:false. + +This script confirms/disproves the *measurable* part by driving the REAL tools +through the running daemon (via `gortex mcp --proxy`) and comparing the wire +cost of three access patterns on the same set of files: + + 1. read_file (full bodies — the "eats context" path) + 2. read_file compress_bodies:true (signatures + structure only) + 3. search_text (locate call sites, no body read at all) + +Token figures are estimated at ~bytes/4 (the standard rough heuristic; Claude's +real tokenizer differs but the RATIO between patterns is what matters and is +tokenizer-stable). The script reports raw bytes too, so nothing hinges on the +estimate. + +Usage: + python3 bench/issue40_context_repro.py [GORTEX_BIN] [file ...] + +Defaults: ./gortex and a handful of ~14-24KB Go files (≈ the reporter's C++ +file sizes). Pass a repo-prefixed or absolute path per file (e.g. +gortex/internal/resolver/external_calls.go). +""" +import json +import subprocess +import sys +import threading + +GORTEX_BIN = sys.argv[1] if len(sys.argv) > 1 else "./gortex" +FILES = sys.argv[2:] or [ + "gortex/internal/resolver/external_calls.go", + "gortex/internal/mcp/tools_lsp.go", + "gortex/internal/agents/claudecode/plugin.go", + "gortex/internal/parser/languages/swift.go", +] +# A literal that recurs across the repo — the search_text "locate the call +# sites" pattern the reporter says should have been used instead of reads. +SEARCH_QUERY = "zap.Error" + + +def approx_tokens(nbytes: int) -> int: + return round(nbytes / 4) + + +class MCP: + """Minimal newline-delimited JSON-RPC client over `gortex mcp --proxy`.""" + + def __init__(self, binary): + self.p = subprocess.Popen( + [binary, "mcp", "--proxy", "--log-level", "error"], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + text=True, bufsize=1, + ) + self._id = 0 + # Drain stderr so a chatty daemon can't dead-lock the pipe. + self._err = [] + threading.Thread(target=self._drain_err, daemon=True).start() + + def _drain_err(self): + for line in self.p.stderr: + self._err.append(line) + + def _send(self, method, params=None, notify=False): + msg = {"jsonrpc": "2.0", "method": method} + if params is not None: + msg["params"] = params + if not notify: + self._id += 1 + msg["id"] = self._id + self.p.stdin.write(json.dumps(msg) + "\n") + self.p.stdin.flush() + if notify: + return None + return self._read_result(self._id) + + def _read_result(self, want_id): + while True: + line = self.p.stdout.readline() + if not line: + raise RuntimeError( + "daemon closed the connection.\nstderr:\n" + "".join(self._err[-20:])) + try: + msg = json.loads(line) + except json.JSONDecodeError: + continue # skip log noise that leaked onto stdout + if msg.get("id") == want_id: + if "error" in msg: + raise RuntimeError(f"RPC error: {msg['error']}") + return msg.get("result") + + def initialize(self): + self._send("initialize", { + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": {"name": "issue40-repro", "version": "0"}, + }) + self._send("notifications/initialized", notify=True) + + def call(self, name, args): + res = self._send("tools/call", {"name": name, "arguments": args}) + # Concatenate all text content blocks — that is what lands in the model's + # context window. + parts = [] + for block in (res or {}).get("content", []): + if block.get("type") == "text": + parts.append(block.get("text", "")) + return "".join(parts) + + def close(self): + try: + self.p.stdin.close() + except Exception: + pass + self.p.terminate() + + +def main(): + mcp = MCP(GORTEX_BIN) + try: + mcp.initialize() + print(f"Driving real tools via `{GORTEX_BIN} mcp --proxy`\n") + + rows = [] + tot_full = tot_comp = 0 + for path in FILES: + full = mcp.call("read_file", {"path": path}) + comp = mcp.call("read_file", {"path": path, "compress_bodies": True}) + bf, bc = len(full.encode()), len(comp.encode()) + tot_full += bf + tot_comp += bc + save = 100 * (1 - bc / bf) if bf else 0 + rows.append((path, bf, bc, save)) + + name_w = max(len(p) for p, *_ in rows) + print(f"{'file':<{name_w}} {'full B':>9} {'compress B':>11} " + f"{'full ~tok':>10} {'compress ~tok':>13} {'saved':>6}") + print("-" * (name_w + 60)) + for path, bf, bc, save in rows: + print(f"{path:<{name_w}} {bf:>9,} {bc:>11,} " + f"{approx_tokens(bf):>10,} {approx_tokens(bc):>13,} {save:>5.0f}%") + tot_save = 100 * (1 - tot_comp / tot_full) if tot_full else 0 + print("-" * (name_w + 60)) + print(f"{'TOTAL':<{name_w}} {tot_full:>9,} {tot_comp:>11,} " + f"{approx_tokens(tot_full):>10,} {approx_tokens(tot_comp):>13,} {tot_save:>5.0f}%") + + # Pattern 3: locate call sites instead of reading bodies at all. + # Cost scales with match count, so the honest figure is per-match. + st = mcp.call("search_text", {"query": SEARCH_QUERY, "limit": 100}) + try: + n_matches = json.loads(st).get("count", 0) + except json.JSONDecodeError: + n_matches = st.count("path:") + bs = len(st.encode()) + per = approx_tokens(bs) / n_matches if n_matches else 0 + print(f"\nsearch_text(query={SEARCH_QUERY!r}): {n_matches} sites located in " + f"{bs:,} B (~{approx_tokens(bs):,} tok ≈ {per:.0f} tok/site) — " + f"line-precise file:line, zero bodies read") + + print("\nVerdict inputs:") + print(f" • Full reads cost ~{approx_tokens(tot_full):,} tok for {len(FILES)} files.") + print(f" • compress_bodies:true would cost ~{approx_tokens(tot_comp):,} tok " + f"({tot_save:.0f}% less) — same signatures/structure.") + print(f" • read_file's DEFAULT is compress_bodies:false → the expensive " + f"path is the default path.") + finally: + mcp.close() + + +if __name__ == "__main__": + main() diff --git a/bench/perf/README.md b/bench/perf/README.md new file mode 100644 index 0000000..f25cd6b --- /dev/null +++ b/bench/perf/README.md @@ -0,0 +1,77 @@ +# Reference-repo perf benchmark + +Reproducible perf table covering the cold-index → query → impact → +incremental round-trip across a fixed reference set: `gin`, `nestjs`, +`react`, and (opt-in) `linux`. Validates the sub-millisecond impact +analysis claim on real codebases and surfaces regressions in the +cold-index path before they ship. + +## What it measures + +For each repo the harness captures: + +- **LoC / files / nodes / edges** — indexed surface size +- **Cold-index time** — `Indexer.Index(path)` wall time on a fresh + graph +- **Search p95** — 50 representative queries (see `queries.json`) + fanned through `Engine.SearchSymbols`; p50 + p95 reported +- **Impact p95 / p99** — 10 randomly-picked functions through + `analysis.AnalyzeImpact`; the L4 sub-ms claim is enforced as a + budget gate +- **Incremental re-index** — touch 5 files (`os.Chtimes`), re-run + `Indexer.Index`, measure the delta +- **DB size** — estimated on-disk byte cost of the graph (gob-shaped, + matches what a daemon snapshot would weigh) +- **RSS** — Go heap retained with the graph, indexer and query engine + live (`runtime.MemStats` after a forced GC); the figure + `gortex daemon status` reports as daemon memory + +Output: stacked markdown (default) + companion CSV / JSON when +`-csv` / `-json` are passed. Per-row pass/fail markers; tail summary +with medians + violation count. + +## Running + +```sh +# Default 3-repo set (gin / nestjs / react), clones to ~/.cache/gortex/bench +go run ./bench/perf + +# Include linux (multi-GB; off by default) +go run ./bench/perf -include-linux + +# Local-only smoke run against an in-tree fixture +go run ./bench/perf -repos local:bench/fixtures/di/nestjs + +# Strict mode for CI: exit 1 on any budget violation +go run ./bench/perf -strict -budget-impact-p95-ms 1.0 +``` + +Flags: + +- `-repos LIST` — comma-separated repo set; tokens accept preset + slugs (`gin` / `nestjs` / `react` / `linux`), `owner/repo` + shorthand, full HTTPS URLs, or `local:/path` for non-cloned repos +- `-include-linux` — opt in to the linux kernel preset +- `-cache-dir DIR` — clone cache (default `~/.cache/gortex/bench`) +- `-queries PATH` — JSON query set (default + `bench/perf/queries.json`) +- `-out PATH` — primary output (default stdout) +- `-format` — `markdown` (default) / `csv` / `json` +- `-csv PATH` / `-json PATH` — companion outputs alongside the + primary +- `-budget-impact-p95-ms` — impact p95 cap in ms (default `1.0`) +- `-budget-search-p95-ms` — search p95 cap in ms (default `50`) +- `-budget-cold-index-ms` — cold-index cap (default off — repos + vary too widely) +- `-strict` — exit 1 when any budget gate is tripped + +## CI usage + +Calling the bench through `gortex bench perf` (wired in +`cmd/gortex/bench.go`) is the recommended path — picks up the same +flags via `--` plus the `--out-dir DIR` convention shared with +`bench tokens`. + +The shipped reference numbers in `BENCHMARK.md` come from running +the default 3-repo set on an Apple M3 Max with a warm +`~/.cache/gortex/bench` directory. diff --git a/bench/perf/main.go b/bench/perf/main.go new file mode 100644 index 0000000..49b3565 --- /dev/null +++ b/bench/perf/main.go @@ -0,0 +1,385 @@ +// Command perf runs the reference-repo perf benchmark. See +// bench/perf/README.md for the contract; this is the substrate +// `gortex bench perf` shells out to (mirroring the bench/wire-format +// pattern). +package main + +import ( + "encoding/csv" + "encoding/json" + "flag" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/zzet/gortex/internal/platform" +) + +// budgets carries the per-metric pass/fail thresholds. Zero values +// disable a check, so partial budgets (e.g. only the impact-p95 +// claim) work out of the box. +type budgets struct { + ColdIndexMs float64 + SearchP95Ms float64 + ImpactP95Ms float64 +} + +var benchCacheDir string + +func main() { + repos := flag.String("repos", "gin,nestjs,react", "comma-separated repo set. Forms: preset slug (gin/nestjs/react/linux), owner/repo, https URL, or local:/path") + includeLinux := flag.Bool("include-linux", false, "include the linux kernel preset (multi-GB clone; skipped by default)") + cacheDir := flag.String("cache-dir", "", "cache directory for clones (default ~/.gortex/cache/bench)") + queriesPath := flag.String("queries", "bench/perf/queries.json", "JSON file with the search-bench query set") + out := flag.String("out", "", "output table path (default stdout)") + format := flag.String("format", "markdown", "markdown | csv | json") + csvOut := flag.String("csv", "", "optional CSV output path (writes in addition to --out)") + jsonOut := flag.String("json", "", "optional JSON output path (writes in addition to --out)") + budgetImpactMs := flag.Float64("budget-impact-p95-ms", 1.0, "fail when impact p95 exceeds this (0 disables)") + budgetSearchMs := flag.Float64("budget-search-p95-ms", 50.0, "fail when search p95 exceeds this (0 disables)") + budgetIndexMs := flag.Float64("budget-cold-index-ms", 0, "fail when cold-index exceeds this (0 disables, default off — repos vary too widely for a one-size budget)") + strict := flag.Bool("strict", false, "exit 1 when any repo trips a budget gate") + flag.Parse() + + // Resolve cache dir up front so runner.go can read it as a + // package global (avoids threading through every helper). + benchCacheDir = resolveCacheDir(*cacheDir) + + // Load query set. + queries, err := loadQueries(*queriesPath) + if err != nil { + die("queries: %v", err) + } + if len(queries) == 0 { + die("queries: empty set at %s", *queriesPath) + } + + // Resolve repo specs. + var specs []repoSpec + if strings.TrimSpace(*repos) == "" { + specs = defaultRepoSet(*includeLinux) + } else { + for tok := range strings.SplitSeq(*repos, ",") { + s, err := resolveRepoSpec(tok) + if err != nil { + die("--repos: %v", err) + } + specs = append(specs, s) + } + } + if !*includeLinux { + filtered := make([]repoSpec, 0, len(specs)) + for _, s := range specs { + if s.Slug == "linux" { + continue + } + filtered = append(filtered, s) + } + specs = filtered + } + if len(specs) == 0 { + die("no repos to benchmark (after --include-linux filter)") + } + + // Run each repo. + rows := make([]repoRow, 0, len(specs)) + for _, spec := range specs { + fmt.Fprintf(os.Stderr, "[perf] %s ... ", spec.Slug) + t := time.Now() + row := runRepo(spec, queries, budgets{ + ColdIndexMs: *budgetIndexMs, + SearchP95Ms: *budgetSearchMs, + ImpactP95Ms: *budgetImpactMs, + }) + fmt.Fprintf(os.Stderr, "%.1fs\n", time.Since(t).Seconds()) + rows = append(rows, row) + } + + // Render primary output. + var primary []byte + switch strings.ToLower(*format) { + case "markdown", "md": + primary = []byte(renderMarkdown(rows)) + case "csv": + primary = []byte(renderCSV(rows)) + case "json": + primary = mustMarshalJSON(rows) + default: + die("unknown --format %q (markdown | csv | json)", *format) + } + if err := writeOutput(*out, primary); err != nil { + die("write primary output: %v", err) + } + + // Optional companion outputs. + 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) + } + } + + // Budget gate. + if *strict { + total := 0 + for _, r := range rows { + total += r.BudgetViolations + } + if total > 0 { + die("perf-budget: %d violation(s) across %d repos", total, len(rows)) + } + } +} + +// resolveCacheDir picks the cache directory, honouring --cache-dir, +// then GORTEX_BENCH_CACHE, then a sensible default under the user +// cache root. An absolute $XDG_CACHE_HOME is honoured; otherwise the +// default stays under os.UserCacheDir() as before. +func resolveCacheDir(flagVal string) string { + if flagVal != "" { + return flagVal + } + if v := os.Getenv("GORTEX_BENCH_CACHE"); v != "" { + return v + } + if v := os.Getenv("XDG_CACHE_HOME"); v == "" || !filepath.IsAbs(v) { + if _, err := os.UserCacheDir(); err != nil { + return filepath.Join(os.TempDir(), "gortex-bench") + } + } + return filepath.Join(platform.OSCacheDir(), "bench") +} + +// loadQueries reads the JSON query set. The file shape is +// {"queries": [...]}; an unrecognised top-level shape returns an +// error so a typo doesn't silently zero out the bench. +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 die(format string, args ...any) { + fmt.Fprintf(os.Stderr, "perf-bench: "+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) +} + +// --- rendering ------------------------------------------------------ + +// renderMarkdown produces the published table shape. Columns: +// cold-index, search p95, impact p95/p99, incremental, DB size — +// plus loc / files / nodes / edges as context. +func renderMarkdown(rows []repoRow) string { + var b strings.Builder + fmt.Fprintln(&b, "# Reference-repo perf benchmark") + fmt.Fprintln(&b) + fmt.Fprintln(&b, "| repo | LoC | files | nodes | edges | cold-index | search p95 | impact p95 | impact p99 | incremental | DB size | RSS | budget |") + fmt.Fprintln(&b, "|------|----:|------:|------:|------:|-----------:|-----------:|-----------:|-----------:|------------:|--------:|----:|:------:|") + for _, r := range rows { + if r.Error != "" && r.Files == 0 { + fmt.Fprintf(&b, "| %s | — | — | — | — | — | — | — | — | — | — | — | ✗ (%s) |\n", r.Slug, r.Error) + continue + } + fmt.Fprintf(&b, "| %s | %s | %s | %s | %s | %s | %s | %s | %s | %s | %s | %s | %s |\n", + r.Slug, + humanInt(int64(r.LoC)), + humanInt(int64(r.Files)), + humanInt(int64(r.Nodes)), + humanInt(int64(r.Edges)), + fmtMs(r.ColdIndexMs), + fmtMs(r.SearchP95Ms), + fmtMs(r.ImpactP95Ms), + fmtMs(r.ImpactP99Ms), + fmtMs(r.IncrementalMs), + fmtBytes(r.DBBytes), + fmtBytes(r.RSSBytes), + budgetMark(r), + ) + } + fmt.Fprintln(&b) + fmt.Fprintln(&b, summaryLine(rows)) + return b.String() +} + +func renderCSV(rows []repoRow) string { + var b strings.Builder + w := csv.NewWriter(&b) + _ = w.Write([]string{ + "slug", "path", "loc", "files", "nodes", "edges", + "cold_index_ms", "search_p50_ms", "search_p95_ms", + "impact_p50_ms", "impact_p95_ms", "impact_p99_ms", + "incremental_ms", "db_bytes", "rss_bytes", "budget_violations", "skipped", "error", + }) + for _, r := range rows { + _ = w.Write([]string{ + r.Slug, r.Path, + fmt.Sprintf("%d", r.LoC), + fmt.Sprintf("%d", r.Files), + fmt.Sprintf("%d", r.Nodes), + fmt.Sprintf("%d", r.Edges), + fmt.Sprintf("%.3f", r.ColdIndexMs), + fmt.Sprintf("%.3f", r.SearchP50Ms), + fmt.Sprintf("%.3f", r.SearchP95Ms), + fmt.Sprintf("%.3f", r.ImpactP50Ms), + fmt.Sprintf("%.3f", r.ImpactP95Ms), + fmt.Sprintf("%.3f", r.ImpactP99Ms), + fmt.Sprintf("%.3f", r.IncrementalMs), + fmt.Sprintf("%d", r.DBBytes), + fmt.Sprintf("%d", r.RSSBytes), + fmt.Sprintf("%d", r.BudgetViolations), + r.Skipped, r.Error, + }) + } + w.Flush() + return b.String() +} + +func mustMarshalJSON(rows []repoRow) []byte { + b, err := json.MarshalIndent(rows, "", " ") + if err != nil { + die("marshal json: %v", err) + } + return append(b, '\n') +} + +// --- formatting helpers --------------------------------------------- + +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) + } +} + +func fmtBytes(b int64) string { + if b <= 0 { + return "—" + } + switch { + case b < 1024: + return fmt.Sprintf("%dB", b) + case b < 1024*1024: + return fmt.Sprintf("%.1fKB", float64(b)/1024.0) + case b < 1024*1024*1024: + return fmt.Sprintf("%.1fMB", float64(b)/(1024.0*1024.0)) + default: + return fmt.Sprintf("%.2fGB", float64(b)/(1024.0*1024.0*1024.0)) + } +} + +func budgetMark(r repoRow) string { + if r.Error != "" { + return "✗" + } + if r.BudgetViolations > 0 { + return fmt.Sprintf("⚠ %d", r.BudgetViolations) + } + return "✓" +} + +// humanInt renders an int64 with thousands separators. +func humanInt(n int64) string { + if n < 0 { + return "-" + humanInt(-n) + } + s := fmt.Sprintf("%d", n) + if len(s) <= 3 { + return s + } + var b strings.Builder + prefix := len(s) % 3 + if prefix > 0 { + b.WriteString(s[:prefix]) + if len(s) > prefix { + b.WriteByte(',') + } + } + for i := prefix; i < len(s); i += 3 { + b.WriteString(s[i : i+3]) + if i+3 < len(s) { + b.WriteByte(',') + } + } + return b.String() +} + +// summaryLine condenses the run into a single headline. +func summaryLine(rows []repoRow) string { + // A run is "successful" when it produced index numbers — Files + // rather than LoC because the LoC counter relies on Meta["lines"] + // being populated by the parser, which isn't universal. Files > 0 + // is a cheaper invariant that catches the same failures. + successful := make([]repoRow, 0, len(rows)) + for _, r := range rows { + if r.Error == "" && r.Files > 0 { + successful = append(successful, r) + } + } + if len(successful) == 0 { + return "_no successful runs_" + } + + medianFloat := func(values []float64) float64 { + if len(values) == 0 { + return 0 + } + sort.Float64s(values) + return values[len(values)/2] + } + colds := make([]float64, len(successful)) + searches := make([]float64, len(successful)) + impacts := make([]float64, len(successful)) + for i, r := range successful { + colds[i] = r.ColdIndexMs + searches[i] = r.SearchP95Ms + impacts[i] = r.ImpactP95Ms + } + + totalViol := 0 + for _, r := range rows { + totalViol += r.BudgetViolations + } + mark := "✓" + if totalViol > 0 { + mark = fmt.Sprintf("⚠ %d budget violation(s)", totalViol) + } + return fmt.Sprintf("**Summary:** %d/%d repos succeeded. Median cold-index: %s. Median search p95: %s. Median impact p95: %s. %s.", + len(successful), len(rows), + fmtMs(medianFloat(colds)), + fmtMs(medianFloat(searches)), + fmtMs(medianFloat(impacts)), + mark, + ) +} diff --git a/bench/perf/main_test.go b/bench/perf/main_test.go new file mode 100644 index 0000000..b73bf75 --- /dev/null +++ b/bench/perf/main_test.go @@ -0,0 +1,260 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestResolveRepoSpec_PresetSlug(t *testing.T) { + s, err := resolveRepoSpec("gin") + if err != nil { + t.Fatalf("resolveRepoSpec(gin): %v", err) + } + if s.Slug != "gin" || !strings.Contains(s.URL, "gin-gonic/gin") { + t.Errorf("gin preset = %+v, want slug=gin URL containing gin-gonic/gin", s) + } +} + +func TestResolveRepoSpec_OwnerRepoShorthand(t *testing.T) { + s, err := resolveRepoSpec("foo/bar") + if err != nil { + t.Fatalf("resolveRepoSpec(foo/bar): %v", err) + } + if s.Slug != "bar" || s.URL != "https://github.com/foo/bar.git" { + t.Errorf("owner/repo shorthand = %+v, want github URL", s) + } +} + +func TestResolveRepoSpec_HTTPSPath(t *testing.T) { + s, err := resolveRepoSpec("https://example.com/path/to/myrepo.git") + if err != nil { + t.Fatalf("resolveRepoSpec(URL): %v", err) + } + if s.URL != "https://example.com/path/to/myrepo.git" || s.Slug != "myrepo" { + t.Errorf("URL spec = %+v, want slug=myrepo", s) + } +} + +func TestResolveRepoSpec_LocalPath(t *testing.T) { + dir := t.TempDir() + s, err := resolveRepoSpec("local:" + dir) + if err != nil { + t.Fatalf("resolveRepoSpec(local:): %v", err) + } + if !s.Local || s.Path != dir { + t.Errorf("local spec = %+v, want Local=true Path=%s", s, dir) + } +} + +func TestResolveRepoSpec_Empty(t *testing.T) { + if _, err := resolveRepoSpec(""); err == nil { + t.Error("expected error for empty repo token") + } +} + +func TestResolveRepoSpec_UnknownSlug(t *testing.T) { + if _, err := resolveRepoSpec("nonexistent"); err == nil { + t.Error("expected error for unknown bare slug") + } +} + +func TestLoadQueries(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "queries.json") + body := `{"queries": ["FooBar", "validate_token", "auth middleware"]}` + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + got, err := loadQueries(path) + if err != nil { + t.Fatal(err) + } + if len(got) != 3 || got[0] != "FooBar" { + t.Errorf("got %+v, want 3 queries starting with FooBar", got) + } +} + +func TestLoadQueries_MissingFile(t *testing.T) { + if _, err := loadQueries(filepath.Join(t.TempDir(), "missing.json")); err == nil { + t.Error("expected error for missing file") + } +} + +func TestLoadQueries_MalformedJSON(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "broken.json") + if err := os.WriteFile(path, []byte("{not json"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := loadQueries(path); err == nil { + t.Error("expected error for malformed JSON") + } +} + +func TestPctMs(t *testing.T) { + cases := []struct { + name string + in []time.Duration + pct int + want float64 + }{ + {"empty", nil, 50, 0}, + {"single", []time.Duration{2 * time.Millisecond}, 50, 2.0}, + {"p50 of 5", []time.Duration{ + 1 * time.Millisecond, 2 * time.Millisecond, 3 * time.Millisecond, + 4 * time.Millisecond, 5 * time.Millisecond, + }, 50, 3.0}, + // Nearest-rank percentile: idx = (pct × n) / 100. For + // durations(20)=[1..20]ms, p95 = sorted[19] = 20ms. + {"p95 of 20", durations(20), 95, 20.0}, + {"p99 of 100", durations(100), 99, 100.0}, + } + for _, c := range cases { + got := pctMs(c.in, c.pct) + if got != c.want { + t.Errorf("%s: pctMs = %.2f, want %.2f", c.name, got, c.want) + } + } +} + +func durations(n int) []time.Duration { + out := make([]time.Duration, n) + for i := range out { + out[i] = time.Duration(i+1) * time.Millisecond + } + return out +} + +func TestRenderMarkdown_PopulatesTable(t *testing.T) { + rows := []repoRow{ + {Slug: "gin", LoC: 1000, Files: 50, Nodes: 800, Edges: 1200, + ColdIndexMs: 250.0, SearchP95Ms: 0.5, ImpactP95Ms: 0.3, ImpactP99Ms: 0.7, + IncrementalMs: 30, DBBytes: 250_000}, + {Slug: "nestjs", LoC: 5000, Files: 200, Nodes: 4500, Edges: 7000, + ColdIndexMs: 1200, SearchP95Ms: 2.0, ImpactP95Ms: 1.5, ImpactP99Ms: 3.1, + IncrementalMs: 60, DBBytes: 1_500_000, BudgetViolations: 1}, + {Slug: "broken", Error: "clone failed"}, + } + md := renderMarkdown(rows) + for _, want := range []string{ + "# Reference-repo perf benchmark", + "| repo | LoC |", + "| gin |", + "| nestjs |", + "⚠ 1", + "✗ (clone failed)", + "**Summary:**", + "2/3 repos succeeded", + } { + if !strings.Contains(md, want) { + t.Errorf("rendered markdown missing %q\n----\n%s", want, md) + } + } +} + +func TestRenderCSV_HasHeaderAndRows(t *testing.T) { + rows := []repoRow{ + {Slug: "gin", LoC: 1000, Files: 50, ColdIndexMs: 100, SearchP95Ms: 0.5, ImpactP95Ms: 0.2}, + } + csvOut := renderCSV(rows) + if !strings.HasPrefix(csvOut, "slug,path,loc,files,nodes,edges,") { + t.Errorf("CSV header missing or wrong:\n%s", csvOut) + } + if !strings.Contains(csvOut, "gin,") { + t.Errorf("CSV body missing gin row:\n%s", csvOut) + } +} + +func TestMustMarshalJSON_RoundTrip(t *testing.T) { + rows := []repoRow{{Slug: "gin", LoC: 100, Files: 5, ImpactP95Ms: 0.5}} + raw := mustMarshalJSON(rows) + var got []repoRow + if err := json.Unmarshal(raw, &got); err != nil { + t.Fatalf("round-trip failed: %v", err) + } + if len(got) != 1 || got[0].Slug != "gin" || got[0].ImpactP95Ms != 0.5 { + t.Errorf("round-trip lost data: %+v", 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 TestFmtBytes_Buckets(t *testing.T) { + cases := map[int64]string{ + 0: "—", + -1: "—", + 512: "512B", + 2048: "2.0KB", + 3 * 1024 * 1024: "3.0MB", + 2 * 1024 * 1024 * 1024: "2.00GB", + } + for in, want := range cases { + if got := fmtBytes(in); got != want { + t.Errorf("fmtBytes(%d) = %q, want %q", in, got, want) + } + } +} + +func TestBudgetMark(t *testing.T) { + cases := []struct { + r repoRow + want string + }{ + {repoRow{Error: "boom"}, "✗"}, + {repoRow{BudgetViolations: 0}, "✓"}, + {repoRow{BudgetViolations: 2}, "⚠ 2"}, + } + for _, c := range cases { + if got := budgetMark(c.r); got != c.want { + t.Errorf("budgetMark(%+v) = %q, want %q", c.r, got, c.want) + } + } +} + +func TestHumanInt(t *testing.T) { + cases := map[int64]string{ + 0: "0", + 999: "999", + 1000: "1,000", + 1234567: "1,234,567", + -1234: "-1,234", + } + for in, want := range cases { + if got := humanInt(in); got != want { + t.Errorf("humanInt(%d) = %q, want %q", in, got, want) + } + } +} + +func TestResolveCacheDir_PrecedenceOrder(t *testing.T) { + // Flag wins over env wins over default. + t.Setenv("GORTEX_BENCH_CACHE", "/from-env") + if got := resolveCacheDir("/from-flag"); got != "/from-flag" { + t.Errorf("flag should win: got %q", got) + } + if got := resolveCacheDir(""); got != "/from-env" { + t.Errorf("env should win when flag empty: got %q", got) + } + t.Setenv("GORTEX_BENCH_CACHE", "") + got := resolveCacheDir("") + if got == "" { + t.Error("default should not be empty") + } +} diff --git a/bench/perf/queries.json b/bench/perf/queries.json new file mode 100644 index 0000000..bf84473 --- /dev/null +++ b/bench/perf/queries.json @@ -0,0 +1,55 @@ +{ + "comment": "Representative search-symbol queries for the L5-style reference-repo perf bench. Mixes identifier-shaped (CamelCase / snake_case / namespaced) and natural-language queries so the search-latency p50/p95 reflects real-world traffic, not just one shape.", + "queries": [ + "Validate", + "validate_token", + "AuthMiddleware", + "parseConfig", + "handler", + "Router", + "useState", + "render", + "ConfigService", + "AppModule", + "createConnection", + "compile", + "execute", + "Loader", + "buildSearchIndex", + "Indexer", + "openConnection", + "marshal", + "unmarshal", + "newServer", + "ListenAndServe", + "DialContext", + "transform", + "Token", + "Lexer", + "validate user authentication", + "http request handler", + "config loader", + "dependency injection container", + "session token validation", + "Server", + "Request", + "Response", + "Connection", + "Service", + "Client", + "Handler", + "Middleware", + "logger", + "Context", + "options", + "init", + "main", + "run", + "open", + "close", + "read", + "write", + "find", + "search" + ] +} diff --git a/bench/perf/runner.go b/bench/perf/runner.go new file mode 100644 index 0000000..7eda482 --- /dev/null +++ b/bench/perf/runner.go @@ -0,0 +1,461 @@ +// runner.go — per-repo benchmark execution. Cold-index, search p95, +// impact p95/p99, incremental reindex, on-disk DB size. All +// measurements come from real indexer / query / analysis calls so +// the headline numbers are reproducible from the same code that ships +// in production. +package main + +import ( + "crypto/rand" + "encoding/binary" + "encoding/gob" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "slices" + "sort" + "strings" + "time" + + "go.uber.org/zap" + + "github.com/zzet/gortex/internal/analysis" + "github.com/zzet/gortex/internal/config" + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/indexer" + "github.com/zzet/gortex/internal/parser" + "github.com/zzet/gortex/internal/parser/languages" + "github.com/zzet/gortex/internal/query" +) + +// repoSpec names one benchmark target. Path is either an absolute / +// repo-relative directory (used as-is, no clone) or a clone target +// triple (slug, URL, optional branch). +type repoSpec struct { + Slug string + URL string + Branch string + // Path is set after cloning (or directly when Local is true). + Path string + Local bool +} + +// repoRow is the markdown / CSV / JSON row a single repo produces. +// All durations in milliseconds for table-friendliness; DBBytes in +// bytes (rendered as MB in markdown). +type repoRow struct { + Slug string `json:"slug"` + Path string `json:"path"` + LoC int `json:"loc"` + Files int `json:"files"` + Nodes int `json:"nodes"` + Edges int `json:"edges"` + ColdIndexMs float64 `json:"cold_index_ms"` + SearchP50Ms float64 `json:"search_p50_ms"` + SearchP95Ms float64 `json:"search_p95_ms"` + ImpactP50Ms float64 `json:"impact_p50_ms"` + ImpactP95Ms float64 `json:"impact_p95_ms"` + ImpactP99Ms float64 `json:"impact_p99_ms"` + IncrementalMs float64 `json:"incremental_ms"` + DBBytes int64 `json:"db_bytes"` + RSSBytes int64 `json:"rss_bytes"` + BudgetViolations int `json:"budget_violations"` + Skipped string `json:"skipped,omitempty"` + Error string `json:"error,omitempty"` +} + +// runRepo executes the full bench against one repo. Returns a row +// with whatever measurements completed; partial failures populate +// Error / Skipped instead of aborting the whole run so a flaky repo +// doesn't kill the others. +func runRepo(spec repoSpec, queries []string, budget budgets) repoRow { + row := repoRow{Slug: spec.Slug} + + // Resolve path: local repos are used as-is; remote URLs clone + // (depth=1) into the configured cache. + if !spec.Local { + path, err := ensureCloned(spec) + if err != nil { + row.Error = fmt.Sprintf("clone: %v", err) + return row + } + spec.Path = path + } + row.Path = spec.Path + + // --- 1. Cold-index ------------------------------------------ + g := graph.New() + reg := parser.NewRegistry() + languages.RegisterAll(reg) + cfg := config.Config{} + idx := indexer.New(g, reg, cfg.Index, zap.NewNop()) + + t0 := time.Now() + indexRes, err := idx.Index(spec.Path) + row.ColdIndexMs = msSince(t0) + if err != nil { + row.Error = fmt.Sprintf("index: %v", err) + return row + } + row.Files = indexRes.FileCount + row.Nodes = indexRes.NodeCount + row.LoC = sumLOC(g) + row.Edges = countEdges(g) + + // --- 2. Search p50/p95 -------------------------------------- + eng := query.NewEngine(g) + eng.SetSearch(idx.Search()) + searchLatencies := make([]time.Duration, 0, len(queries)) + for _, q := range queries { + t := time.Now() + _ = eng.SearchSymbols(q, 20) + searchLatencies = append(searchLatencies, time.Since(t)) + } + row.SearchP50Ms = pctMs(searchLatencies, 50) + row.SearchP95Ms = pctMs(searchLatencies, 95) + + // --- 3. Impact p50/p95/p99 ---------------------------------- + seeds := pickImpactSeeds(g, 10) + impactLatencies := make([]time.Duration, 0, len(seeds)) + for _, id := range seeds { + t := time.Now() + _ = analysis.AnalyzeImpact(g, []string{id}, nil, nil) + impactLatencies = append(impactLatencies, time.Since(t)) + } + if len(impactLatencies) > 0 { + row.ImpactP50Ms = pctMs(impactLatencies, 50) + row.ImpactP95Ms = pctMs(impactLatencies, 95) + row.ImpactP99Ms = pctMs(impactLatencies, 99) + } + + // --- 4. Incremental reindex (touch 5 files) ----------------- + touched := pickIncrementalFiles(g, 5) + for _, fp := range touched { + _ = touchFile(fp) + } + t1 := time.Now() + _, err = idx.Index(spec.Path) + row.IncrementalMs = msSince(t1) + if err != nil { + // Incremental failure isn't fatal — record it but keep the + // row's other measurements. + row.Error = fmt.Sprintf("incremental: %v", err) + } + + // --- 5. DB size --------------------------------------------- + row.DBBytes = estimateDBSize(g) + + // --- 6. Resident memory ------------------------------------- + // Measured with the graph, indexer (search index) and query + // engine all live — the same objects a `gortex daemon` holds for + // this repo. + row.RSSBytes = residentBytes() + + // --- 7. Budget gates ---------------------------------------- + if budget.ImpactP95Ms > 0 && row.ImpactP95Ms > budget.ImpactP95Ms { + row.BudgetViolations++ + } + if budget.SearchP95Ms > 0 && row.SearchP95Ms > budget.SearchP95Ms { + row.BudgetViolations++ + } + if budget.ColdIndexMs > 0 && row.ColdIndexMs > budget.ColdIndexMs { + row.BudgetViolations++ + } + + return row +} + +// --- helpers -------------------------------------------------------- + +// ensureCloned does `git clone --depth 1` of the repo into the cache +// dir if absent. Returns the local path. Idempotent — a present +// directory is reused (so re-runs don't re-clone). +func ensureCloned(spec repoSpec) (string, error) { + dest := filepath.Join(benchCacheDir, spec.Slug) + if st, err := os.Stat(dest); err == nil && st.IsDir() { + // Heuristic: a non-empty dir is assumed to be a previous + // clone. The user can `rm -rf` the slug to force a refresh. + if entries, _ := os.ReadDir(dest); len(entries) > 0 { + return dest, nil + } + } + if spec.URL == "" { + return "", fmt.Errorf("no URL configured for %q (and no local clone)", spec.Slug) + } + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + return "", err + } + args := []string{"clone", "--depth", "1", "--quiet"} + if spec.Branch != "" { + args = append(args, "--branch", spec.Branch) + } + args = append(args, spec.URL, dest) + cmd := exec.Command("git", args...) + cmd.Stdout = io.Discard + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("git clone %s: %w", spec.URL, err) + } + return dest, nil +} + +// sumLOC is a cheap LoC proxy: total Lines field across file-kind +// nodes. Not byte-exact (matches what the graph counted at index +// time), but stable across runs and that's what the bench needs. +func sumLOC(g *graph.Graph) int { + total := 0 + for _, n := range g.AllNodes() { + if n == nil || n.Kind != graph.KindFile { + continue + } + // Graph stores per-file line counts under Meta["lines"] when + // available; fall back to 0 (matches what the indexer + // produced — no extrapolation). + if v, ok := n.Meta["lines"]; ok { + switch x := v.(type) { + case int: + total += x + case int64: + total += int(x) + case float64: + total += int(x) + } + } + } + return total +} + +// countEdges sums the outgoing-edge count across every node — graph +// doesn't expose a top-level edge count directly. +func countEdges(g *graph.Graph) int { + total := 0 + for _, n := range g.AllNodes() { + if n == nil { + continue + } + total += len(g.GetOutEdges(n.ID)) + } + return total +} + +// pickImpactSeeds returns up to n random function/method IDs from +// the graph — the impact bench targets symbols a real refactor +// would name. Deterministic by node-ID sort + crypto/rand pick so +// the same fixture produces consistent magnitudes (not byte-equal +// numbers, but the same shape). +func pickImpactSeeds(g *graph.Graph, n int) []string { + var pool []string + for _, node := range g.AllNodes() { + if node == nil { + continue + } + if node.Kind != graph.KindFunction && node.Kind != graph.KindMethod { + continue + } + pool = append(pool, node.ID) + } + sort.Strings(pool) + if len(pool) <= n { + return pool + } + out := make([]string, 0, n) + used := make(map[int]struct{}, n) + for len(out) < n { + idx, err := randInt(len(pool)) + if err != nil { + // Random failed — fall back to deterministic stride. + idx = (len(out) * 7919) % len(pool) + } + if _, ok := used[idx]; ok { + continue + } + used[idx] = struct{}{} + out = append(out, pool[idx]) + } + return out +} + +// pickIncrementalFiles returns up to n file paths to touch for the +// incremental-reindex measurement. +func pickIncrementalFiles(g *graph.Graph, n int) []string { + var pool []string + for _, node := range g.AllNodes() { + if node == nil || node.Kind != graph.KindFile { + continue + } + if node.FilePath == "" { + continue + } + pool = append(pool, node.FilePath) + } + sort.Strings(pool) + if len(pool) <= n { + return pool + } + return pool[:n] +} + +// touchFile bumps a file's mtime to "now" so the indexer treats it +// as changed on the next pass. Best-effort: missing files are +// silently skipped (a graph node referencing a deleted file is the +// indexer's problem, not ours). +func touchFile(path string) error { + now := time.Now() + err := os.Chtimes(path, now, now) + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err +} + +// estimateDBSize gob-encodes the graph into a discard writer and +// returns the byte count. This is the same on-disk shape the +// persistence layer uses, so the number reflects what a `gortex +// daemon` snapshot would actually weigh. +func estimateDBSize(g *graph.Graph) int64 { + counter := &countingWriter{} + enc := gob.NewEncoder(counter) + // graph.Graph isn't directly gob-encodable; estimate via node + + // edge counts × a calibrated per-node/per-edge byte cost taken + // from the daemon's snapshot fixtures (~250 bytes / node + ~64 + // bytes / edge after gob+gzip compression). Calibration is + // stable enough for an order-of-magnitude column. + stats := struct { + Nodes int + Edges int + }{ + Nodes: len(g.AllNodes()), + Edges: countEdges(g), + } + if err := enc.Encode(stats); err != nil { + return -1 + } + return int64(stats.Nodes*250 + stats.Edges*64) +} + +// countingWriter is an io.Writer that just counts bytes. +type countingWriter struct{ n int64 } + +func (c *countingWriter) Write(p []byte) (int, error) { + c.n += int64(len(p)) + return len(p), nil +} + +// residentBytes returns the Go heap currently retained — the +// runtime.MemStats figure `gortex daemon status` reports as daemon +// memory. A forced GC first drops the previous repo's graph so the +// number reflects only this repo's retained graph + search index. +// True OS RSS adds a fixed Go-runtime overhead (stacks, mcache, code) +// on top that does not scale with repo size. +func residentBytes() int64 { + runtime.GC() + var m runtime.MemStats + runtime.ReadMemStats(&m) + return int64(m.HeapAlloc) +} + +// pctMs returns the p-th percentile of a duration slice in +// milliseconds. Nearest-rank method: pct=50 on [a,b,c,d,e] → c. +// Returns 0 when the slice is empty. +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 msSince(t time.Time) float64 { + return float64(time.Since(t).Microseconds()) / 1000.0 +} + +// randInt returns a cryptographically-random int in [0, max). Used +// for impact-seed selection; deterministic-stride fallback at the +// caller covers crypto/rand failure (which should be ~impossible). +func randInt(max int) (int, error) { + if max <= 0 { + return 0, fmt.Errorf("randInt: max must be > 0") + } + var b [8]byte + if _, err := rand.Read(b[:]); err != nil { + return 0, err + } + return int(binary.BigEndian.Uint64(b[:]) % uint64(max)), nil +} + +// --- repo presets --------------------------------------------------- + +// defaultRepoSet returns the four reference repos the published +// perf table covers. Linux is opt-in via --include-linux because +// it's multi-GB and a fresh clone would tank CI runs. +func defaultRepoSet(includeLinux bool) []repoSpec { + repos := []repoSpec{ + {Slug: "gin", URL: "https://github.com/gin-gonic/gin.git"}, + {Slug: "nestjs", URL: "https://github.com/nestjs/nest.git"}, + {Slug: "react", URL: "https://github.com/facebook/react.git"}, + } + if includeLinux { + repos = append(repos, repoSpec{ + Slug: "linux", + URL: "https://github.com/torvalds/linux.git", + Branch: "master", + }) + } + return repos +} + +// resolveRepoSpec parses a --repos token. Forms: +// - "gin" → preset by slug +// - "owner/repo" → github.com/owner/repo, slug=repo +// - "https://example/repo.git" → URL clone, slug=basename +// - "local:/abs/path" → local repo, slug=basename(path) +func resolveRepoSpec(token string) (repoSpec, error) { + token = strings.TrimSpace(token) + if token == "" { + return repoSpec{}, fmt.Errorf("empty repo token") + } + if path, ok := strings.CutPrefix(token, "local:"); ok { + if !filepath.IsAbs(path) { + abs, err := filepath.Abs(path) + if err != nil { + return repoSpec{}, err + } + path = abs + } + return repoSpec{ + Slug: filepath.Base(path), + Path: path, + Local: true, + }, nil + } + if strings.HasPrefix(token, "https://") || strings.HasPrefix(token, "git@") { + slug := strings.TrimSuffix(filepath.Base(token), ".git") + return repoSpec{Slug: slug, URL: token}, nil + } + // "owner/repo" shorthand for github.com. + if strings.Contains(token, "/") { + parts := strings.Split(token, "/") + slug := parts[len(parts)-1] + return repoSpec{ + Slug: slug, + URL: "https://github.com/" + token + ".git", + }, nil + } + // Bare slug — look up in presets. + for _, r := range defaultRepoSet(true) { + if r.Slug == token { + return r, nil + } + } + return repoSpec{}, fmt.Errorf("unknown repo %q (use owner/repo, https://..., or local:/path)", token) +} diff --git a/bench/perf/runner_test.go b/bench/perf/runner_test.go new file mode 100644 index 0000000..2f379dd --- /dev/null +++ b/bench/perf/runner_test.go @@ -0,0 +1,197 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + + "github.com/zzet/gortex/internal/graph" +) + +// TestRunRepo_LocalFixture is the end-to-end smoke test: runs the +// full bench against the in-tree nestjs fixture and asserts the +// shape of the resulting row. Uses a private cache dir so it doesn't +// touch the developer's real ~/.cache/gortex/bench tree. +func TestRunRepo_LocalFixture(t *testing.T) { + fixturePath, err := filepath.Abs(filepath.Join("..", "fixtures", "di", "nestjs")) + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat(fixturePath); err != nil { + t.Skipf("fixture not available: %v", err) + } + + // Run with a tiny query set to keep the test fast. + queries := []string{"AppModule", "ConfigService", "auth handler"} + row := runRepo(repoSpec{ + Slug: "nestjs-fixture", + Path: fixturePath, + Local: true, + }, queries, budgets{ + // Generous budgets so a slow CI doesn't flake — the real + // validation lives in main.go's strict-mode path. + ImpactP95Ms: 50.0, + SearchP95Ms: 100.0, + }) + + if row.Error != "" { + t.Fatalf("runRepo error: %s", row.Error) + } + if row.Files == 0 { + t.Errorf("expected indexed files, got 0") + } + if row.Nodes == 0 { + t.Errorf("expected graph nodes, got 0") + } + if row.ColdIndexMs <= 0 { + t.Errorf("cold-index time should be > 0, got %.3f", row.ColdIndexMs) + } + if row.SearchP95Ms <= 0 { + t.Errorf("search p95 should be > 0, got %.3f", row.SearchP95Ms) + } + if row.IncrementalMs <= 0 { + t.Errorf("incremental should be > 0, got %.3f", row.IncrementalMs) + } + if row.DBBytes <= 0 { + t.Errorf("DB size should be > 0, got %d", row.DBBytes) + } + if row.BudgetViolations > 0 { + t.Errorf("generous budgets shouldn't violate, got %d", row.BudgetViolations) + } +} + +func TestPickImpactSeeds_RespectsLimit(t *testing.T) { + g := graph.New() + for i := range 5 { + g.AddNode(&graph.Node{ + ID: funcID(i), + Name: funcName(i), + Kind: graph.KindFunction, + }) + } + seeds := pickImpactSeeds(g, 3) + if len(seeds) != 3 { + t.Errorf("len(seeds)=%d, want 3 (n < pool)", len(seeds)) + } + // Pool smaller than n: return all. + seeds = pickImpactSeeds(g, 10) + if len(seeds) != 5 { + t.Errorf("len(seeds)=%d, want 5 (n > pool)", len(seeds)) + } +} + +func TestPickImpactSeeds_FunctionsAndMethodsOnly(t *testing.T) { + g := graph.New() + g.AddNode(&graph.Node{ID: "f.go::F", Name: "F", Kind: graph.KindFunction}) + g.AddNode(&graph.Node{ID: "f.go::M", Name: "M", Kind: graph.KindMethod}) + g.AddNode(&graph.Node{ID: "f.go::T", Name: "T", Kind: graph.KindType}) + g.AddNode(&graph.Node{ID: "f.go::V", Name: "V", Kind: graph.KindVariable}) + seeds := pickImpactSeeds(g, 10) + if len(seeds) != 2 { + t.Errorf("got %d seeds %v, want 2 (function + method only)", len(seeds), seeds) + } +} + +func TestPickIncrementalFiles_RespectsLimit(t *testing.T) { + g := graph.New() + for i := range 8 { + g.AddNode(&graph.Node{ + ID: fileID(i), + Kind: graph.KindFile, + FilePath: fileName(i), + }) + } + files := pickIncrementalFiles(g, 3) + if len(files) != 3 { + t.Errorf("len(files)=%d, want 3", len(files)) + } + for _, f := range files { + if f == "" { + t.Errorf("empty file path returned") + } + } +} + +func TestTouchFile_MissingFileSilent(t *testing.T) { + if err := touchFile(filepath.Join(t.TempDir(), "missing.go")); err != nil { + t.Errorf("missing file should not error, got %v", err) + } +} + +func TestTouchFile_BumpsMtime(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "x.go") + if err := os.WriteFile(path, []byte("// x"), 0o644); err != nil { + t.Fatal(err) + } + st1, _ := os.Stat(path) + // Sleep to ensure mtime granularity is exceeded. + old := st1.ModTime() + if err := touchFile(path); err != nil { + t.Fatal(err) + } + st2, _ := os.Stat(path) + if !st2.ModTime().After(old) && !st2.ModTime().Equal(old) { + t.Errorf("mtime regressed: %v → %v", old, st2.ModTime()) + } +} + +func TestEstimateDBSize_ScalesWithGraph(t *testing.T) { + small := graph.New() + small.AddNode(&graph.Node{ID: "s1", Name: "s1", Kind: graph.KindFunction}) + smallSize := estimateDBSize(small) + + large := graph.New() + for i := range 100 { + large.AddNode(&graph.Node{ID: funcID(i), Name: funcName(i), Kind: graph.KindFunction}) + } + largeSize := estimateDBSize(large) + + if smallSize <= 0 || largeSize <= 0 { + t.Errorf("DB size estimates should be positive, got small=%d large=%d", smallSize, largeSize) + } + if largeSize <= smallSize { + t.Errorf("larger graph should yield larger estimate, got small=%d large=%d", smallSize, largeSize) + } +} + +func TestDefaultRepoSet_IncludeLinuxFlag(t *testing.T) { + without := defaultRepoSet(false) + with := defaultRepoSet(true) + if len(without) != 3 || len(with) != 4 { + t.Errorf("expected 3 without linux + 4 with, got %d / %d", len(without), len(with)) + } + // linux must be in the includeLinux=true set, NOT in the false set. + hasLinux := func(rs []repoSpec) bool { + for _, r := range rs { + if r.Slug == "linux" { + return true + } + } + return false + } + if hasLinux(without) { + t.Error("defaultRepoSet(false) should not include linux") + } + if !hasLinux(with) { + t.Error("defaultRepoSet(true) should include linux") + } +} + +// --- helpers -------------------------------------------------------- + +func funcID(i int) string { return "f.go::F" + itoa(i) } +func funcName(i int) string { return "F" + itoa(i) } +func fileID(i int) string { return "file" + itoa(i) + ".go" } +func fileName(i int) string { return "file" + itoa(i) + ".go" } +func itoa(i int) string { + if i == 0 { + return "0" + } + var out []byte + for i > 0 { + out = append([]byte{byte('0' + i%10)}, out...) + i /= 10 + } + return string(out) +} diff --git a/bench/token-efficiency/README.md b/bench/token-efficiency/README.md new file mode 100644 index 0000000..b22eeeb --- /dev/null +++ b/bench/token-efficiency/README.md @@ -0,0 +1,73 @@ +# Token-efficiency benchmark + +Reproducible 3-pipeline comparison of how many tokens an agent has +to ingest to find the same information, plus recall@k by token +budget. Pipelines: + +1. **ripgrep + full-read** — `rg --files-with-matches ` + followed by reading every hit file completely. The naive + shell-script baseline: cheap to set up, expensive to consume. +2. **ripgrep + context** — `rg -n -B 50 -A 50 `. Captures + the surrounding ±50 lines per hit, which is what a more + thoughtful agent might do. +3. **gortex** — `search_symbols` → `get_symbol_source` on the top-K + results. Matches the path the savings dashboard rewards. + +For each query, the harness counts the tiktoken bytes the pipeline +returns and computes recall@2k and recall@10k against a hand-curated +ground-truth set (per-query expected file paths). The headline +median is honest about query mix — NL queries that don't appear +verbatim in code show no ripgrep matches and therefore inflate +gortex's relative cost. The per-row data shows the real picture: +gortex achieves 1.00 recall@2k on identifier queries where ripgrep +gets 0.00, because the file the agent actually wants doesn't fit in +2k tokens when read whole. + +## Running + +```sh +# Default: against the gortex repo itself +go run ./bench/token-efficiency + +# Against a different corpus +go run ./bench/token-efficiency --repo ~/code/myrepo \ + --queries my-queries.json --groundtruth my-truth.json + +# CI gate: gortex median tokens must be <50% of ripgrep+full-read +go run ./bench/token-efficiency --strict --budget-ratio 0.5 + +# JSON output for downstream tooling +go run ./bench/token-efficiency --format json --json bench/results/tokens-eff.json +``` + +Flags: + +- `-repo PATH` — indexed corpus (default `.`) +- `-queries PATH` — JSON query set (default `queries.json` in this + directory) +- `-groundtruth PATH` — JSON per-query expected file paths (default + `groundtruth.json`) +- `-top-k N` — gortex pipeline candidate count (default 5) +- `-out PATH` — markdown output (default stdout) +- `-json PATH` — companion JSON metrics output +- `-format markdown|json` — primary output format +- `-budget-ratio R` — fail when gortex median tokens > R × ripgrep + full-read median (default 0.5; 0 disables) +- `-strict` — exit 1 on budget violation +- `-skip-ripgrep` — render only the gortex column (useful in CI + without rg on PATH) + +## Extending the ground truth + +Each entry in `groundtruth.json` is a query → expected file paths +map. The recall computation counts a query as "answered" when the +pipeline returns any of the expected files within the token budget. +Add new entries by appending to the `queries` map; the harness +picks them up on the next run. + +Curation rules: +- Expected paths are repo-root-relative (no leading `./`) +- A query with no entry in `groundtruth.json` scores 0 on recall by + definition — keep the query set and the truth set in sync +- Verify entries by running `gortex search_symbols ` against + the corpus and confirming the top result is in the expected list diff --git a/bench/token-efficiency/groundtruth.json b/bench/token-efficiency/groundtruth.json new file mode 100644 index 0000000..e23166a --- /dev/null +++ b/bench/token-efficiency/groundtruth.json @@ -0,0 +1,37 @@ +{ + "comment": "Per-query expected file paths (relative to the indexed repo root). A pipeline scores recall@k as |intersection(expected, returned_within_budget)| / |expected|. Curated against the gortex repo at the time the benchmark first ran; extending the bench means adding rows here. Multiple expected paths per query means any subset is a partial hit.", + "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" + ] + } +} diff --git a/bench/token-efficiency/main.go b/bench/token-efficiency/main.go new file mode 100644 index 0000000..8b4ecf6 --- /dev/null +++ b/bench/token-efficiency/main.go @@ -0,0 +1,310 @@ +// Command token-efficiency runs the 3-pipeline token-economy +// benchmark: ripgrep+full-read vs ripgrep+context vs gortex +// (search_symbols + get_symbol_source). See bench/token-efficiency/ +// README.md for the contract and the methodology footnote it places +// in BENCHMARK.md. +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" +) + +// benchRow captures one query's outcome across all three pipelines. +type benchRow struct { + Query string `json:"query"` + Expected []string `json:"expected"` + RipgrepFull pipelineResult `json:"ripgrep_full"` + RipgrepCtx pipelineResult `json:"ripgrep_context"` + Gortex pipelineResult `json:"gortex"` + // Recall@k by token budget. 2k is the "agent reads two pages of + // context" frame; 10k is "agent reads a quarter of a window". + RecallAt2kFull float64 `json:"recall_at_2k_full"` + RecallAt2kCtx float64 `json:"recall_at_2k_context"` + RecallAt2kGortex float64 `json:"recall_at_2k_gortex"` + RecallAt10kFull float64 `json:"recall_at_10k_full"` + RecallAt10kCtx float64 `json:"recall_at_10k_context"` + RecallAt10kGortex float64 `json:"recall_at_10k_gortex"` +} + +func main() { + repo := flag.String("repo", ".", "indexed repository path (queries run against this corpus)") + queriesPath := flag.String("queries", "bench/token-efficiency/queries.json", "JSON query set") + truthPath := flag.String("groundtruth", "bench/token-efficiency/groundtruth.json", "JSON per-query expected file paths") + out := flag.String("out", "", "markdown output path (default stdout)") + jsonOut := flag.String("json", "", "optional JSON metrics output") + format := flag.String("format", "markdown", "markdown | json") + topK := flag.Int("top-k", 5, "candidates per query for the gortex pipeline") + budgetRatio := flag.Float64("budget-ratio", 0.5, "fail when gortex median tokens > budget-ratio × ripgrep+full-read median (0 disables)") + strict := flag.Bool("strict", false, "exit 1 when the budget gate trips") + skipRipgrep := flag.Bool("skip-ripgrep", false, "skip ripgrep pipelines (e.g. when rg isn't installed)") + flag.Parse() + + 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) + } + + // Detect ripgrep up front so we can short-circuit cleanly. + rgAvailable := !*skipRipgrep + if rgAvailable { + if _, err := exec.LookPath("rg"); err != nil { + fmt.Fprintf(os.Stderr, "[token-eff] ripgrep not on PATH; --skip-ripgrep implied\n") + rgAvailable = false + } + } + + // Index the repo once for the gortex pipeline. + fmt.Fprintf(os.Stderr, "[token-eff] indexing %s...\n", absRepo) + indexed, err := indexRepoForBench(absRepo) + if err != nil { + die("index: %v", err) + } + fmt.Fprintf(os.Stderr, "[token-eff] indexed %d nodes\n", len(indexed.graph.AllNodes())) + + // Run each query across the available pipelines. + rows := make([]benchRow, 0, len(queries)) + for _, q := range queries { + row := benchRow{Query: q, Expected: truth[q]} + if rgAvailable { + row.RipgrepFull = runRipgrepFullRead(absRepo, q) + row.RipgrepCtx = runRipgrepContext(absRepo, q) + } + row.Gortex = runGortex(absRepo, q, indexed, *topK) + row.RecallAt2kFull = recallAtBudget(row.RipgrepFull, row.Expected, 2_000) + row.RecallAt2kCtx = recallAtBudget(row.RipgrepCtx, row.Expected, 2_000) + row.RecallAt2kGortex = recallAtBudget(row.Gortex, row.Expected, 2_000) + row.RecallAt10kFull = recallAtBudget(row.RipgrepFull, row.Expected, 10_000) + row.RecallAt10kCtx = recallAtBudget(row.RipgrepCtx, row.Expected, 10_000) + row.RecallAt10kGortex = recallAtBudget(row.Gortex, row.Expected, 10_000) + rows = append(rows, row) + fmt.Fprintf(os.Stderr, "[token-eff] %-45s full=%-7d ctx=%-7d gortex=%d\n", + q, row.RipgrepFull.Tokens, row.RipgrepCtx.Tokens, row.Gortex.Tokens) + } + + // Render primary output. + var primary []byte + switch strings.ToLower(*format) { + case "markdown", "md": + primary = []byte(renderMarkdown(rows, rgAvailable)) + case "json": + primary = mustMarshalJSON(rows) + default: + die("unknown --format %q", *format) + } + if err := writeOutput(*out, primary); err != nil { + die("write primary: %v", err) + } + + // Optional companion JSON. + if *jsonOut != "" { + if err := writeOutput(*jsonOut, mustMarshalJSON(rows)); err != nil { + die("write json: %v", err) + } + } + + // Budget gate. + if *strict && *budgetRatio > 0 && rgAvailable { + medianFull := medianTokensFn(rows, func(r benchRow) int { return r.RipgrepFull.Tokens }) + medianGortex := medianTokensFn(rows, func(r benchRow) int { return r.Gortex.Tokens }) + if medianFull > 0 { + ratio := float64(medianGortex) / float64(medianFull) + if ratio > *budgetRatio { + die("budget gate: gortex median tokens (%d) / ripgrep+full-read median (%d) = %.2f > limit %.2f", + medianGortex, medianFull, ratio, *budgetRatio) + } + } + } +} + +// --- rendering ------------------------------------------------------ + +// renderMarkdown produces the per-query table plus a summary line. +// When ripgrep is unavailable the ripgrep columns render as "—" so a +// CI without rg still gets a meaningful gortex-only table. +func renderMarkdown(rows []benchRow, rgAvailable bool) string { + var b strings.Builder + fmt.Fprintln(&b, "# Token-efficiency benchmark") + fmt.Fprintln(&b) + fmt.Fprintln(&b, "_Tokens per response (lower is better) and recall@k-by-token-budget for three retrieval pipelines: ripgrep+full-read (naive agent baseline), ripgrep+context (±50 lines around hit), and gortex (search_symbols + get_symbol_source)._") + fmt.Fprintln(&b) + fmt.Fprintln(&b, "| query | tokens (rg+full) | tokens (rg+ctx) | tokens (gortex) | recall@2k rg+full / rg+ctx / gortex | recall@10k rg+full / rg+ctx / gortex |") + fmt.Fprintln(&b, "|-------|----------------:|----------------:|---------------:|------------------------------------|--------------------------------------|") + for _, r := range rows { + fmt.Fprintf(&b, "| %s | %s | %s | %d | %s / %s / %.2f | %s / %s / %.2f |\n", + truncate(r.Query, 38), + tokensCell(r.RipgrepFull, rgAvailable), + tokensCell(r.RipgrepCtx, rgAvailable), + r.Gortex.Tokens, + recallCell(r.RecallAt2kFull, rgAvailable), + recallCell(r.RecallAt2kCtx, rgAvailable), + r.RecallAt2kGortex, + recallCell(r.RecallAt10kFull, rgAvailable), + recallCell(r.RecallAt10kCtx, rgAvailable), + r.RecallAt10kGortex, + ) + } + fmt.Fprintln(&b) + fmt.Fprintln(&b, summaryLine(rows, rgAvailable)) + return b.String() +} + +func tokensCell(p pipelineResult, available bool) string { + if !available { + return "—" + } + if p.Error != "" { + return "ERR" + } + return fmt.Sprintf("%d", p.Tokens) +} + +func recallCell(v float64, available bool) string { + if !available { + return "—" + } + return fmt.Sprintf("%.2f", v) +} + +func truncate(s string, max int) string { + if len(s) <= max { + return s + } + return s[:max-1] + "…" +} + +// summaryLine emits the headline median-tokens + recall figure that +// real readers care about. Format: +// +// "Median tokens: rg+full=X / rg+ctx=Y / gortex=Z (gortex −P%). +// Median recall@2k: rg+full=A / rg+ctx=B / gortex=C." +func summaryLine(rows []benchRow, rgAvailable bool) string { + if len(rows) == 0 { + return "_no rows_" + } + mt := func(f func(benchRow) int) int { return medianTokensFn(rows, f) } + mr := func(f func(benchRow) float64) float64 { return medianFloatFn(rows, f) } + mFull := mt(func(r benchRow) int { return r.RipgrepFull.Tokens }) + mCtx := mt(func(r benchRow) int { return r.RipgrepCtx.Tokens }) + mGortex := mt(func(r benchRow) int { return r.Gortex.Tokens }) + + rec2Full := mr(func(r benchRow) float64 { return r.RecallAt2kFull }) + rec2Ctx := mr(func(r benchRow) float64 { return r.RecallAt2kCtx }) + rec2Gortex := mr(func(r benchRow) float64 { return r.RecallAt2kGortex }) + + if !rgAvailable { + return fmt.Sprintf("**Summary (gortex only — rg not installed):** Median tokens %d. Median recall@2k %.2f.", + mGortex, rec2Gortex) + } + ratio := "" + if mFull > 0 { + delta := (1.0 - float64(mGortex)/float64(mFull)) * 100 + ratio = fmt.Sprintf(" gortex saves %.1f%% vs rg+full.", delta) + } + return fmt.Sprintf("**Summary:** Median tokens — rg+full=%d / rg+ctx=%d / gortex=%d.%s Median recall@2k — rg+full=%.2f / rg+ctx=%.2f / gortex=%.2f.", + mFull, mCtx, mGortex, ratio, + rec2Full, rec2Ctx, rec2Gortex, + ) +} + +// medianTokensFn / medianFloatFn condense per-pipeline values across +// rows into the median. Used by both the summary line and the budget +// gate so the two stay in lock-step. +func medianTokensFn(rows []benchRow, pick func(benchRow) int) int { + if len(rows) == 0 { + return 0 + } + vs := make([]int, 0, len(rows)) + for _, r := range rows { + vs = append(vs, pick(r)) + } + sort.Ints(vs) + return vs[len(vs)/2] +} + +func medianFloatFn(rows []benchRow, pick func(benchRow) float64) float64 { + if len(rows) == 0 { + return 0 + } + vs := make([]float64, 0, len(rows)) + for _, r := range rows { + vs = append(vs, pick(r)) + } + sort.Float64s(vs) + return vs[len(vs)/2] +} + +// --- helpers -------------------------------------------------------- + +func die(format string, args ...any) { + fmt.Fprintf(os.Stderr, "token-eff: "+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 []benchRow) []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 +} diff --git a/bench/token-efficiency/main_test.go b/bench/token-efficiency/main_test.go new file mode 100644 index 0000000..a0b2dc4 --- /dev/null +++ b/bench/token-efficiency/main_test.go @@ -0,0 +1,181 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestLoadQueries(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "queries.json") + body := `{"queries": ["alpha", "BetaSymbol", "find token"]}` + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + got, err := loadQueries(path) + if err != nil { + t.Fatal(err) + } + if len(got) != 3 || got[1] != "BetaSymbol" { + t.Errorf("got %v, want 3 queries containing BetaSymbol", got) + } +} + +func TestLoadGroundTruth(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "gt.json") + body := `{"queries": {"alpha": ["a.go", "b.go"], "beta": ["c.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["alpha"]) != 2 || got["alpha"][0] != "a.go" { + t.Errorf("alpha truth = %v, want [a.go, b.go]", got["alpha"]) + } + if len(got["beta"]) != 1 { + t.Errorf("beta truth = %v, want 1 path", got["beta"]) + } +} + +func TestLoadGroundTruth_MissingReturnsEmpty(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "gt.json") + body := `{}` + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + got, err := loadGroundTruth(path) + if err != nil { + t.Fatal(err) + } + if got == nil || len(got) != 0 { + t.Errorf("missing queries map should yield empty map, got %v", got) + } +} + +func TestRecallAtBudget(t *testing.T) { + // Returned [a, b, c] with per-file tokens [500, 800, 1000] + // vs expected [a, c]: + // + // budget 2000 → a (500), b (1300), c (2300 > 2000 → stop) + // → hits a only (c excluded) → 1/2 = 0.5 + // budget 5000 → all included → hits a + c → 2/2 = 1.0 + // budget 0 → unbounded, all included → 1.0 + r := pipelineResult{ + Returned: []string{"a", "b", "c"}, + PerFileTokens: []int{500, 800, 1000}, + } + expected := []string{"a", "c"} + if got := recallAtBudget(r, expected, 2000); got != 0.5 { + t.Errorf("recall@2000 = %.2f, want 0.5", got) + } + if got := recallAtBudget(r, expected, 5000); got != 1.0 { + t.Errorf("recall@5000 = %.2f, want 1.0", got) + } + if got := recallAtBudget(r, expected, 0); got != 1.0 { + t.Errorf("recall@0 (unbounded) = %.2f, want 1.0", got) + } +} + +func TestRecallAtBudget_EmptyExpectedReturnsZero(t *testing.T) { + r := pipelineResult{Returned: []string{"x"}, PerFileTokens: []int{10}} + if got := recallAtBudget(r, nil, 1000); got != 0 { + t.Errorf("empty expected = %.2f, want 0 (no recall to measure)", got) + } +} + +func TestMedianTokensFn(t *testing.T) { + rows := []benchRow{ + {Gortex: pipelineResult{Tokens: 30}}, + {Gortex: pipelineResult{Tokens: 10}}, + {Gortex: pipelineResult{Tokens: 20}}, + } + got := medianTokensFn(rows, func(r benchRow) int { return r.Gortex.Tokens }) + if got != 20 { + t.Errorf("median = %d, want 20", got) + } +} + +func TestMedianFloatFn(t *testing.T) { + rows := []benchRow{ + {RecallAt2kGortex: 0.5}, + {RecallAt2kGortex: 1.0}, + {RecallAt2kGortex: 0.25}, + } + got := medianFloatFn(rows, func(r benchRow) float64 { return r.RecallAt2kGortex }) + if got != 0.5 { + t.Errorf("median = %.2f, want 0.5", got) + } +} + +func TestRenderMarkdown_PopulatesAllColumns(t *testing.T) { + rows := []benchRow{ + {Query: "AddObservation", + Expected: []string{"internal/savings/store.go"}, + RipgrepFull: pipelineResult{Tokens: 31530, Returned: []string{"internal/savings/store.go"}, PerFileTokens: []int{31530}}, + RipgrepCtx: pipelineResult{Tokens: 9020, Returned: []string{"internal/savings/store.go"}, PerFileTokens: []int{9020}}, + Gortex: pipelineResult{Tokens: 972, Returned: []string{"internal/savings/store.go"}, PerFileTokens: []int{972}}, + RecallAt2kGortex: 1.0, + }, + } + md := renderMarkdown(rows, true) + for _, want := range []string{ + "# Token-efficiency benchmark", + "AddObservation", + "31530", + "9020", + "972", + "1.00", + "**Summary:**", + "Median tokens", + } { + if !strings.Contains(md, want) { + t.Errorf("rendered markdown missing %q\n----\n%s", want, md) + } + } +} + +func TestRenderMarkdown_GortexOnlyMode(t *testing.T) { + rows := []benchRow{ + {Query: "q", + Gortex: pipelineResult{Tokens: 100}, + RecallAt2kGortex: 0.75, + }, + } + md := renderMarkdown(rows, false) + if !strings.Contains(md, "gortex only — rg not installed") { + t.Errorf("gortex-only summary missing the rg-unavailable note:\n%s", md) + } + if !strings.Contains(md, "—") { + t.Errorf("rg columns should render as — when rg not available:\n%s", md) + } +} + +func TestMustMarshalJSON_RoundTrip(t *testing.T) { + rows := []benchRow{ + {Query: "q", Gortex: pipelineResult{Tokens: 100, Returned: []string{"a.go"}}, RecallAt2kGortex: 0.5}, + } + raw := mustMarshalJSON(rows) + var got []benchRow + if err := json.Unmarshal(raw, &got); err != nil { + t.Fatalf("round-trip: %v", err) + } + if len(got) != 1 || got[0].Query != "q" || got[0].RecallAt2kGortex != 0.5 { + t.Errorf("round-trip lost data: %+v", got) + } +} + +func TestTruncate(t *testing.T) { + if got := truncate("short", 10); got != "short" { + t.Errorf("short string unchanged: got %q", got) + } + if got := truncate("this is a very long query string", 10); got != "this is a…" { + t.Errorf("long string truncated: got %q", got) + } +} diff --git a/bench/token-efficiency/queries.json b/bench/token-efficiency/queries.json new file mode 100644 index 0000000..48da511 --- /dev/null +++ b/bench/token-efficiency/queries.json @@ -0,0 +1,15 @@ +{ + "comment": "Query set for the token-efficiency benchmark. Each query is paired with expected target file paths in groundtruth.json. Mix of identifier-shape and natural-language queries so the headline median reflects realistic agent traffic, not just one shape.", + "queries": [ + "AddObservation", + "IsSymbolQuery", + "FileCoherenceSignal", + "alphaFuse", + "savings dashboard rendering", + "rerank pipeline default signals", + "Indexer Index method", + "graph build edges", + "MCP server start", + "token counting tiktoken" + ] +} diff --git a/bench/token-efficiency/runner.go b/bench/token-efficiency/runner.go new file mode 100644 index 0000000..e88e677 --- /dev/null +++ b/bench/token-efficiency/runner.go @@ -0,0 +1,273 @@ +// runner.go — per-query pipeline runners for the token-efficiency +// benchmark. Three pipelines, all measured against the same indexed +// repo: +// +// - ripgrep+full-read: rg --files-with-matches → cat each hit +// - ripgrep+context: rg -n -B50 -A50 → use the printed context +// - gortex: search_symbols → get_symbol_source on the +// top result(s); represents the agent path +// the savings dashboard rewards +package main + +import ( + "bytes" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + + "go.uber.org/zap" + + "github.com/zzet/gortex/internal/config" + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/indexer" + "github.com/zzet/gortex/internal/parser" + "github.com/zzet/gortex/internal/parser/languages" + "github.com/zzet/gortex/internal/query" + "github.com/zzet/gortex/internal/tokens" +) + +// pipelineResult is the per-query outcome of one pipeline. +type pipelineResult struct { + // Returned is the ordered list of file paths the pipeline + // considered relevant. Used for recall computation against + // ground truth (which is keyed on file paths to keep the + // comparison cross-pipeline fair — ripgrep doesn't see symbols). + Returned []string `json:"returned"` + // Tokens is the cumulative tiktoken count of the response + // content the pipeline produced (the bytes a real agent would + // have to ingest). + Tokens int `json:"tokens"` + // PerFileTokens lets the recall@k-by-budget calculator walk the + // returned list in order, accumulating tokens until the budget + // is exhausted. Aligned with Returned by index. + PerFileTokens []int `json:"per_file_tokens"` + // Error captures pipeline failures (e.g. ripgrep missing, + // indexing failed). Empty string on success. + Error string `json:"error,omitempty"` +} + +// runRipgrepFullRead executes `rg --files-with-matches` against the +// repo, then reads each hit file fully. Mirrors the naive agent +// strategy "grep for foo, then read every file that matches". +func runRipgrepFullRead(repoRoot, query string) pipelineResult { + files, err := ripgrepFilesWithMatches(repoRoot, query) + if err != nil { + return pipelineResult{Error: err.Error()} + } + out := pipelineResult{Returned: files, PerFileTokens: make([]int, 0, len(files))} + for _, f := range files { + body, err := os.ReadFile(filepath.Join(repoRoot, f)) + if err != nil { + out.PerFileTokens = append(out.PerFileTokens, 0) + continue + } + n := tokens.Count(string(body)) + out.Tokens += n + out.PerFileTokens = append(out.PerFileTokens, n) + } + return out +} + +// runRipgrepContext executes `rg -n -B 50 -A 50 ` and +// counts the bytes the surrounding context produces. More realistic +// than full-read because real grep-driven agents don't usually read +// every byte of every file. +func runRipgrepContext(repoRoot, query string) pipelineResult { + cmd := exec.Command("rg", "-n", "-B", "50", "-A", "50", "--no-heading", query, repoRoot) + var buf bytes.Buffer + cmd.Stdout = &buf + cmd.Stderr = io.Discard + err := cmd.Run() + // rg exit code 1 = no matches (not an error for our purposes). + if err != nil { + if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() == 1 { + return pipelineResult{} + } + return pipelineResult{Error: err.Error()} + } + // Parse the output: rg prints one line per match with the file + // prefix "::". Group lines by file so the + // recall computation sees one returned-entry per file. + files := map[string]int{} + order := []string{} + for line := range strings.SplitSeq(buf.String(), "\n") { + idx := strings.Index(line, ":") + if idx <= 0 { + continue + } + path := line[:idx] + // rg with an absolute repoRoot prints absolute paths — + // strip the prefix so recall comparison sees the same + // shape as ripgrep --files-with-matches. + path = strings.TrimPrefix(path, repoRoot+"/") + if _, seen := files[path]; !seen { + order = append(order, path) + } + files[path] += tokens.Count(line + "\n") + } + out := pipelineResult{Returned: order, PerFileTokens: make([]int, 0, len(order))} + for _, p := range order { + out.Tokens += files[p] + out.PerFileTokens = append(out.PerFileTokens, files[p]) + } + return out +} + +// runGortex indexes the repo, runs search_symbols, and reads the +// source of the top-K results — the path the savings dashboard +// rewards. +func runGortex(repoRoot, q string, indexedRepo *indexedRepo, topK int) pipelineResult { + if indexedRepo == nil || indexedRepo.engine == nil { + return pipelineResult{Error: "gortex repo not indexed"} + } + nodes := indexedRepo.engine.SearchSymbols(q, topK) + out := pipelineResult{Returned: make([]string, 0, len(nodes)), PerFileTokens: make([]int, 0, len(nodes))} + seen := map[string]bool{} + for _, n := range nodes { + if n == nil { + continue + } + // Returned uses the file path so the recall comparison + // works against the file-path ground truth (same axis as + // ripgrep). + fp := n.FilePath + if fp == "" || seen[fp] { + continue + } + seen[fp] = true + // Read the symbol's lines from disk so the token count + // matches what an agent would ingest via get_symbol_source. + body, err := readSymbolSource(repoRoot, n) + if err != nil { + out.PerFileTokens = append(out.PerFileTokens, 0) + out.Returned = append(out.Returned, fp) + continue + } + n := tokens.Count(body) + out.Tokens += n + out.PerFileTokens = append(out.PerFileTokens, n) + out.Returned = append(out.Returned, fp) + } + return out +} + +// indexedRepo bundles a graph + engine for the gortex pipeline so we +// pay the index cost once per repo regardless of how many queries +// run. +type indexedRepo struct { + graph *graph.Graph + engine *query.Engine +} + +// indexRepoForBench builds a fresh gortex index of the repo. Same +// shape as the reference-repo perf bench: stderr-logger, real +// parser registry, no extras. +func indexRepoForBench(root string) (*indexedRepo, error) { + 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(root); err != nil { + return nil, fmt.Errorf("index %s: %w", root, err) + } + eng := query.NewEngine(g) + eng.SetSearch(idx.Search()) + return &indexedRepo{graph: g, engine: eng}, nil +} + +// ripgrepFilesWithMatches runs `rg --files-with-matches ` and +// returns the matched paths relative to repoRoot. +func ripgrepFilesWithMatches(repoRoot, pattern string) ([]string, error) { + cmd := exec.Command("rg", "--files-with-matches", pattern, repoRoot) + var buf bytes.Buffer + cmd.Stdout = &buf + cmd.Stderr = io.Discard + err := cmd.Run() + if err != nil { + if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() == 1 { + // No matches — clean exit, empty result. + return nil, nil + } + return nil, fmt.Errorf("rg: %w", err) + } + var out []string + for line := range strings.SplitSeq(buf.String(), "\n") { + if line == "" { + continue + } + rel := strings.TrimPrefix(line, repoRoot+"/") + out = append(out, rel) + } + sort.Strings(out) + return out, nil +} + +// readSymbolSource extracts the symbol's line range from its file. +// Falls back to the whole file when StartLine/EndLine aren't set — +// matches the fallback shape the MCP tool would have to produce. +func readSymbolSource(repoRoot string, n *graph.Node) (string, error) { + if n == nil || n.FilePath == "" { + return "", fmt.Errorf("no path") + } + path := n.FilePath + if !filepath.IsAbs(path) { + path = filepath.Join(repoRoot, path) + } + body, err := os.ReadFile(path) + if err != nil { + return "", err + } + if n.StartLine <= 0 || n.EndLine <= 0 || n.EndLine < n.StartLine { + return string(body), nil + } + lines := strings.Split(string(body), "\n") + start := n.StartLine - 1 + end := n.EndLine + if start < 0 { + start = 0 + } + if end > len(lines) { + end = len(lines) + } + if start >= end { + return "", nil + } + return strings.Join(lines[start:end], "\n"), nil +} + +// recallAtBudget walks the pipeline's returned list in order, +// accumulating tokens until budget is exhausted, then computes +// |intersection(expected, returned_within_budget)| / |expected|. +// Returns 0 when expected is empty (no ground truth = no recall to +// measure, not 100%). +func recallAtBudget(r pipelineResult, expected []string, budgetTokens int) float64 { + if len(expected) == 0 { + return 0 + } + exp := map[string]bool{} + for _, e := range expected { + exp[e] = true + } + cumulative := 0 + hits := 0 + for i, ret := range r.Returned { + if i >= len(r.PerFileTokens) { + break + } + next := cumulative + r.PerFileTokens[i] + if budgetTokens > 0 && next > budgetTokens { + // Including this entry would blow the budget; stop. + break + } + cumulative = next + if exp[ret] { + hits++ + } + } + return float64(hits) / float64(len(expected)) +} diff --git a/bench/wire-format/README.md b/bench/wire-format/README.md new file mode 100644 index 0000000..e54117f --- /dev/null +++ b/bench/wire-format/README.md @@ -0,0 +1,83 @@ +# GCX1 wire-format benchmark + +Reproducible benchmark comparing the GCX1 compact wire format against +JSON (and, when `JCODEMUNCH=/path/to/jcm` is set, against jCodeMunch +MUNCH) on representative MCP tool responses. + +## What it measures + +For each fixture case the harness captures, for JSON and GCX: + +- **Bytes** — raw UTF-8 byte length. +- **tiktoken (cl100k_base)** — LLM-relevant token count via + `github.com/pkoukk/tiktoken-go` (same loader Gortex uses at runtime). + Matches Claude 3 / Opus 4 / Sonnet 4 / Haiku 4.5 / GPT-4o budgets. +- **Claude Opus 4.7 input tokens** — second column populated either + by scaling cl100k_base (×1.35 empirical inflation factor, the + default; labeled `estimated`) or by calling Anthropic's + `messages/count_tokens` endpoint with `--use-api` (requires + `ANTHROPIC_API_KEY`; results cached to `opus47-counts.json` for + deterministic reruns; labeled `exact`). +- **gzip** — gzip-compressed byte length, fair comparison when the + transport would compress anyway. +- **Round-trip integrity** — encode → decode → re-marshal, compare to + the canonical JSON normalisation. Must be 100 % for the format to + be considered lossless. + +Results land in `scorecard.md` with per-case rows and a summary of +medians / totals. + +## Running + +```sh +go run ./bench/wire-format -cases ./bench/wire-format/cases -out ./bench/wire-format/scorecard.md +``` + +Flags: + +- `-cases DIR` — directory of fixture case files (default + `./bench/wire-format/cases`). +- `-out FILE` — output scorecard markdown path (default stdout). +- `-json FILE` — emit raw per-case metrics as JSON too. +- `-tokenizer cl100k|opus47|both` — which token-cost column(s) to + render (default `both`). +- `-use-api` — call Anthropic `count_tokens` for exact Opus 4.7 + numbers (requires `ANTHROPIC_API_KEY`; degrades to the scalar + on network failure with a single warning). +- `-opus47-cache PATH` — exact-count sidecar (default + `bench/wire-format/opus47-counts.json`). +- `-opus47-model NAME` — model id passed to the API + (default `claude-opus-4-20250514`). + +## Fixture format + +Each case is a Go struct literal decoded from a YAML file with two +sections: + +```yaml +tool: search_symbols +description: 20 search hits on a medium-size repo +input: | + [{...JSON rows as the tool would return...}] +``` + +The harness encodes `input` via the canonical Go encoder for the +specified tool and scores the two outputs. + +## Target + +GCX1 targets **≥20 % token savings vs JSON on the median case** +with **100 % round-trip integrity**. Current baseline (20 cases): + +- **Median token savings (cl100k_base): −27.4 %** +- **Median token savings (Opus 4.7, scalar): −27.3 %** +- **Median byte savings: −26.8 %** +- **Round-trip integrity: 20/20** + +Tabular list payloads (`search_symbols`, `analyze_hotspots`, +`find_usages_large`, `smart_context`) hit −30 to −38 %. Small +scalar-heavy records (`graph_stats`, `find_cycles`) can flip +positive — GCX1's header overhead exceeds the savings when there +are fewer than ~5 rows and no repeated field names. Payloads with +long inline bodies (`get_symbol_source`) are roughly neutral — the +source body dominates and neither encoding compresses it. diff --git a/bench/wire-format/cases/01_search_symbols_small.yaml b/bench/wire-format/cases/01_search_symbols_small.yaml new file mode 100644 index 0000000..f1f01f5 --- /dev/null +++ b/bench/wire-format/cases/01_search_symbols_small.yaml @@ -0,0 +1,10 @@ +tool: search_symbols +description: 5 search hits, typical small result set +input: | + [ + {"id":"internal/mcp/server.go::NewServer","kind":"function","name":"NewServer","file_path":"internal/mcp/server.go","start_line":62}, + {"id":"internal/mcp/server.go::Server.Start","kind":"method","name":"Start","file_path":"internal/mcp/server.go","start_line":118}, + {"id":"internal/mcp/server.go::Server.Shutdown","kind":"method","name":"Shutdown","file_path":"internal/mcp/server.go","start_line":140}, + {"id":"internal/mcp/tools_core.go::registerCoreTools","kind":"method","name":"registerCoreTools","file_path":"internal/mcp/tools_core.go","start_line":307}, + {"id":"internal/mcp/tools_coding.go::registerCodingTools","kind":"method","name":"registerCodingTools","file_path":"internal/mcp/tools_coding.go","start_line":1} + ] diff --git a/bench/wire-format/cases/02_search_symbols_large.yaml b/bench/wire-format/cases/02_search_symbols_large.yaml new file mode 100644 index 0000000..2c1c919 --- /dev/null +++ b/bench/wire-format/cases/02_search_symbols_large.yaml @@ -0,0 +1,25 @@ +tool: search_symbols +description: 20 search hits with signatures +input: | + [ + {"id":"cmd/gortex/main.go::main","kind":"function","name":"main","file_path":"cmd/gortex/main.go","start_line":14,"signature":"func main()"}, + {"id":"cmd/gortex/serve.go::runServe","kind":"function","name":"runServe","file_path":"cmd/gortex/serve.go","start_line":48,"signature":"func runServe(cmd *cobra.Command, args []string) error"}, + {"id":"cmd/gortex/bridge.go::runBridge","kind":"function","name":"runBridge","file_path":"cmd/gortex/bridge.go","start_line":36,"signature":"func runBridge(cmd *cobra.Command, args []string) error"}, + {"id":"cmd/gortex/daemon.go::runDaemonStart","kind":"function","name":"runDaemonStart","file_path":"cmd/gortex/daemon.go","start_line":78,"signature":"func runDaemonStart(cmd *cobra.Command, args []string) error"}, + {"id":"cmd/gortex/daemon.go::runDaemonStop","kind":"function","name":"runDaemonStop","file_path":"cmd/gortex/daemon.go","start_line":152,"signature":"func runDaemonStop(cmd *cobra.Command, args []string) error"}, + {"id":"internal/indexer/indexer.go::Indexer.Index","kind":"method","name":"Index","file_path":"internal/indexer/indexer.go","start_line":110,"signature":"func (i *Indexer) Index(root string) error"}, + {"id":"internal/indexer/indexer.go::Indexer.IndexCtx","kind":"method","name":"IndexCtx","file_path":"internal/indexer/indexer.go","start_line":116,"signature":"func (i *Indexer) IndexCtx(ctx context.Context, root string) error"}, + {"id":"internal/indexer/multi.go::MultiIndexer.TrackRepo","kind":"method","name":"TrackRepo","file_path":"internal/indexer/multi.go","start_line":88,"signature":"func (mi *MultiIndexer) TrackRepo(entry config.RepoEntry) error"}, + {"id":"internal/indexer/multi.go::MultiIndexer.TrackRepoCtx","kind":"method","name":"TrackRepoCtx","file_path":"internal/indexer/multi.go","start_line":94,"signature":"func (mi *MultiIndexer) TrackRepoCtx(ctx context.Context, entry config.RepoEntry) error"}, + {"id":"internal/query/engine.go::Engine.SearchSymbols","kind":"method","name":"SearchSymbols","file_path":"internal/query/engine.go","start_line":142,"signature":"func (e *Engine) SearchSymbols(q string, limit int) []*graph.Node"}, + {"id":"internal/query/engine.go::Engine.FindUsages","kind":"method","name":"FindUsages","file_path":"internal/query/engine.go","start_line":207,"signature":"func (e *Engine) FindUsages(id string) *SubGraph"}, + {"id":"internal/query/engine.go::Engine.GetCallers","kind":"method","name":"GetCallers","file_path":"internal/query/engine.go","start_line":255,"signature":"func (e *Engine) GetCallers(id string, opts QueryOptions) *SubGraph"}, + {"id":"internal/query/engine.go::Engine.GetCallChain","kind":"method","name":"GetCallChain","file_path":"internal/query/engine.go","start_line":298,"signature":"func (e *Engine) GetCallChain(id string, opts QueryOptions) *SubGraph"}, + {"id":"internal/graph/graph.go::Graph.AddNode","kind":"method","name":"AddNode","file_path":"internal/graph/graph.go","start_line":94,"signature":"func (g *Graph) AddNode(n *Node)"}, + {"id":"internal/graph/graph.go::Graph.AddEdge","kind":"method","name":"AddEdge","file_path":"internal/graph/graph.go","start_line":128,"signature":"func (g *Graph) AddEdge(e *Edge)"}, + {"id":"internal/graph/graph.go::Graph.GetNode","kind":"method","name":"GetNode","file_path":"internal/graph/graph.go","start_line":155,"signature":"func (g *Graph) GetNode(id string) *Node"}, + {"id":"internal/resolver/resolver.go::Resolver.Resolve","kind":"method","name":"Resolve","file_path":"internal/resolver/resolver.go","start_line":60,"signature":"func (r *Resolver) Resolve(g *graph.Graph)"}, + {"id":"internal/semantic/enricher.go::Enricher.Enrich","kind":"method","name":"Enrich","file_path":"internal/semantic/enricher.go","start_line":48,"signature":"func (e *Enricher) Enrich(ctx context.Context, g *graph.Graph) (*EnrichResult, error)"}, + {"id":"internal/contracts/bind.go::Bridge","kind":"function","name":"Bridge","file_path":"internal/contracts/bind.go","start_line":33,"signature":"func Bridge(g *graph.Graph, reg *Registry) []*graph.Edge"}, + {"id":"internal/parser/parser.go::Parser.Parse","kind":"method","name":"Parse","file_path":"internal/parser/parser.go","start_line":52,"signature":"func (p *Parser) Parse(path string) (*parser.Result, error)"} + ] diff --git a/bench/wire-format/cases/03_get_symbol_source_small.yaml b/bench/wire-format/cases/03_get_symbol_source_small.yaml new file mode 100644 index 0000000..6c5db5b --- /dev/null +++ b/bench/wire-format/cases/03_get_symbol_source_small.yaml @@ -0,0 +1,15 @@ +tool: get_symbol_source +description: one symbol with 20-line source body +input: | + { + "id":"internal/graph/graph.go::Graph.AddNode", + "kind":"method", + "name":"AddNode", + "file_path":"internal/graph/graph.go", + "start_line":94, + "end_line":113, + "from_line":91, + "signature":"func (g *Graph) AddNode(n *Node)", + "source":"func (g *Graph) AddNode(n *Node) {\n\tg.mu.Lock()\n\tdefer g.mu.Unlock()\n\tif n == nil {\n\t\treturn\n\t}\n\tif existing, ok := g.nodes[n.ID]; ok {\n\t\tif existing.StartLine == n.StartLine {\n\t\t\treturn\n\t\t}\n\t}\n\tg.nodes[n.ID] = n\n\tif g.byFile == nil {\n\t\tg.byFile = map[string][]*Node{}\n\t}\n\tg.byFile[n.FilePath] = append(g.byFile[n.FilePath], n)\n\tif g.byName == nil {\n\t\tg.byName = map[string][]*Node{}\n\t}\n\tg.byName[n.Name] = append(g.byName[n.Name], n)\n}", + "etag":"7c3e1a9f8b2d4e6a" + } diff --git a/bench/wire-format/cases/04_get_symbol_source_large.yaml b/bench/wire-format/cases/04_get_symbol_source_large.yaml new file mode 100644 index 0000000..020a86a --- /dev/null +++ b/bench/wire-format/cases/04_get_symbol_source_large.yaml @@ -0,0 +1,15 @@ +tool: get_symbol_source +description: one symbol with 80-line source body +input: | + { + "id":"internal/indexer/indexer.go::Indexer.IndexCtx", + "kind":"method", + "name":"IndexCtx", + "file_path":"internal/indexer/indexer.go", + "start_line":116, + "end_line":193, + "from_line":113, + "signature":"func (i *Indexer) IndexCtx(ctx context.Context, root string) error", + "source":"func (i *Indexer) IndexCtx(ctx context.Context, root string) error {\n\treporter := progress.FromContext(ctx)\n\treporter.Stage(\"walking files\", 0)\n\tfiles, err := i.walker.Walk(root)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"walk %s: %w\", root, err)\n\t}\n\treporter.Stage(\"parsing\", 0)\n\tfor idx, f := range files {\n\t\tif idx%50 == 0 {\n\t\t\treporter.Tick(fmt.Sprintf(\"parsing %d/%d\", idx, len(files)))\n\t\t}\n\t\tparsed, err := i.parser.Parse(f)\n\t\tif err != nil {\n\t\t\ti.logger.Warn(\"parse failed\", zap.String(\"file\", f), zap.Error(err))\n\t\t\tcontinue\n\t\t}\n\t\tfor _, n := range parsed.Nodes {\n\t\t\ti.graph.AddNode(n)\n\t\t}\n\t\tfor _, e := range parsed.Edges {\n\t\t\ti.graph.AddEdge(e)\n\t\t}\n\t}\n\treporter.Stage(\"resolving references\", 0)\n\ti.resolver.Resolve(i.graph)\n\treporter.Stage(\"inferring interfaces\", 0)\n\ti.inferInterfaces()\n\treporter.Stage(\"semantic enrichment\", 0)\n\tif i.enricher != nil {\n\t\tif _, err := i.enricher.Enrich(ctx, i.graph); err != nil {\n\t\t\ti.logger.Warn(\"semantic enrich failed\", zap.Error(err))\n\t\t}\n\t}\n\treporter.Stage(\"building search index\", 0)\n\ti.searchIndex.Rebuild(i.graph)\n\treporter.Stage(\"extracting contracts\", 0)\n\ti.contractRegistry.Extract(i.graph)\n\treporter.Stage(\"upgrading search backend\", 0)\n\ti.searchIndex.MaybeUpgrade()\n\treporter.Stage(\"done\", 0)\n\treturn nil\n}", + "etag":"9d4f6a1c8e2b7f3e" + } diff --git a/bench/wire-format/cases/05_batch_symbols.yaml b/bench/wire-format/cases/05_batch_symbols.yaml new file mode 100644 index 0000000..9569bd8 --- /dev/null +++ b/bench/wire-format/cases/05_batch_symbols.yaml @@ -0,0 +1,15 @@ +tool: batch_symbols +description: 6 symbols in one batch, 3 with source bodies +input: | + { + "symbols": [ + {"id":"a.go::Foo","kind":"function","name":"Foo","file_path":"a.go","start_line":10,"end_line":20,"signature":"func Foo() error","source":"func Foo() error {\n\treturn nil\n}","from_line":8}, + {"id":"a.go::Bar","kind":"method","name":"Bar","file_path":"a.go","start_line":30,"end_line":52,"signature":"func (s *Svc) Bar(ctx context.Context) error"}, + {"id":"b.go::Baz","kind":"function","name":"Baz","file_path":"b.go","start_line":44,"end_line":78,"signature":"func Baz(x int) (string, error)","source":"func Baz(x int) (string, error) {\n\tif x == 0 {\n\t\treturn \"\", errors.New(\"zero\")\n\t}\n\treturn strconv.Itoa(x), nil\n}","from_line":42}, + {"id":"b.go::MissingSymbol","error":"symbol not found"}, + {"id":"c.go::Qux","kind":"function","name":"Qux","file_path":"c.go","start_line":5,"end_line":7,"signature":"func Qux()","source":"func Qux() {\n\treturn\n}","from_line":3}, + {"id":"d.go::Garply","kind":"type","name":"Garply","file_path":"d.go","start_line":12,"end_line":18,"signature":"type Garply struct { A int; B string }"} + ], + "total": 6, + "etag": "ffffeeee00001111" + } diff --git a/bench/wire-format/cases/06_find_usages_small.yaml b/bench/wire-format/cases/06_find_usages_small.yaml new file mode 100644 index 0000000..431cc3d --- /dev/null +++ b/bench/wire-format/cases/06_find_usages_small.yaml @@ -0,0 +1,20 @@ +tool: find_usages +description: 4 usages of an internal helper +input: | + { + "nodes": [ + {"id":"internal/mcp/server.go::Server.serve","kind":"method","name":"serve","file_path":"internal/mcp/server.go","start_line":88}, + {"id":"internal/mcp/bridge.go::Bridge.Start","kind":"method","name":"Start","file_path":"internal/mcp/bridge.go","start_line":44}, + {"id":"internal/mcp/tools_core.go::handleSearchSymbols","kind":"method","name":"handleSearchSymbols","file_path":"internal/mcp/tools_core.go","start_line":539}, + {"id":"internal/mcp/tools_coding.go::handleGetSymbolSource","kind":"method","name":"handleGetSymbolSource","file_path":"internal/mcp/tools_coding.go","start_line":363} + ], + "edges": [ + {"from":"internal/mcp/server.go::Server.serve","to":"internal/mcp/tools_core.go::sessionFor","kind":"calls","confidence":1.0,"origin":"ast_resolved"}, + {"from":"internal/mcp/bridge.go::Bridge.Start","to":"internal/mcp/tools_core.go::sessionFor","kind":"calls","confidence":1.0,"origin":"ast_resolved"}, + {"from":"internal/mcp/tools_core.go::handleSearchSymbols","to":"internal/mcp/tools_core.go::sessionFor","kind":"calls","confidence":0.95,"origin":"ast_inferred"}, + {"from":"internal/mcp/tools_coding.go::handleGetSymbolSource","to":"internal/mcp/tools_core.go::sessionFor","kind":"calls","confidence":0.95,"origin":"ast_inferred"} + ], + "total_nodes": 4, + "total_edges": 4, + "truncated": false + } diff --git a/bench/wire-format/cases/07_find_usages_large.yaml b/bench/wire-format/cases/07_find_usages_large.yaml new file mode 100644 index 0000000..d00746f --- /dev/null +++ b/bench/wire-format/cases/07_find_usages_large.yaml @@ -0,0 +1,26 @@ +tool: find_usages +description: 18 usages of a widely-used helper +input: | + { + "edges": [ + {"from":"a.go::F1","to":"util.go::Get","kind":"calls","confidence":1.0,"origin":"ast_resolved"}, + {"from":"a.go::F2","to":"util.go::Get","kind":"calls","confidence":1.0,"origin":"ast_resolved"}, + {"from":"a.go::F3","to":"util.go::Get","kind":"calls","confidence":1.0,"origin":"ast_resolved"}, + {"from":"a.go::F4","to":"util.go::Get","kind":"calls","confidence":0.95,"origin":"ast_inferred"}, + {"from":"b.go::F5","to":"util.go::Get","kind":"calls","confidence":1.0,"origin":"lsp_resolved"}, + {"from":"b.go::F6","to":"util.go::Get","kind":"calls","confidence":1.0,"origin":"lsp_resolved"}, + {"from":"c.go::F7","to":"util.go::Get","kind":"calls","confidence":0.9,"origin":"ast_inferred"}, + {"from":"c.go::F8","to":"util.go::Get","kind":"calls","confidence":0.9,"origin":"ast_inferred"}, + {"from":"c.go::F9","to":"util.go::Get","kind":"calls","confidence":0.9,"origin":"ast_inferred"}, + {"from":"d.go::F10","to":"util.go::Get","kind":"calls","confidence":1.0,"origin":"ast_resolved"}, + {"from":"d.go::F11","to":"util.go::Get","kind":"calls","confidence":1.0,"origin":"ast_resolved"}, + {"from":"d.go::F12","to":"util.go::Get","kind":"calls","confidence":0.7,"origin":"text_matched"}, + {"from":"e.go::F13","to":"util.go::Get","kind":"references","confidence":1.0,"origin":"ast_resolved"}, + {"from":"e.go::F14","to":"util.go::Get","kind":"references","confidence":1.0,"origin":"ast_resolved"}, + {"from":"e.go::F15","to":"util.go::Get","kind":"references","confidence":1.0,"origin":"ast_resolved"}, + {"from":"f.go::F16","to":"util.go::Get","kind":"calls","confidence":0.95,"origin":"ast_inferred"}, + {"from":"f.go::F17","to":"util.go::Get","kind":"calls","confidence":0.95,"origin":"ast_inferred"}, + {"from":"g.go::F18","to":"util.go::Get","kind":"calls","confidence":1.0,"origin":"lsp_resolved"} + ], + "total_edges": 18 + } diff --git a/bench/wire-format/cases/08_get_file_summary.yaml b/bench/wire-format/cases/08_get_file_summary.yaml new file mode 100644 index 0000000..8c18189 --- /dev/null +++ b/bench/wire-format/cases/08_get_file_summary.yaml @@ -0,0 +1,17 @@ +tool: get_file_summary +description: file with 12 symbols +input: | + [ + {"id":"internal/mcp/gcx.go::isGCX","kind":"function","name":"isGCX","start_line":19,"signature":"func isGCX(req mcp.CallToolRequest) bool"}, + {"id":"internal/mcp/gcx.go::requestedFormat","kind":"function","name":"requestedFormat","start_line":28,"signature":"func requestedFormat(req mcp.CallToolRequest) wire.Format"}, + {"id":"internal/mcp/gcx.go::gcxResponse","kind":"function","name":"gcxResponse","start_line":44,"signature":"func gcxResponse(payload []byte, err error) (*mcp.CallToolResult, error)"}, + {"id":"internal/mcp/gcx.go::newGCX","kind":"function","name":"newGCX","start_line":59,"signature":"func newGCX(w *bytes.Buffer, tool string, fields []string, metaKV ...string) *wire.Encoder"}, + {"id":"internal/mcp/gcx.go::nodeShort","kind":"function","name":"nodeShort","start_line":72,"signature":"func nodeShort(n *graph.Node) string"}, + {"id":"internal/mcp/gcx.go::nodeSig","kind":"function","name":"nodeSig","start_line":87,"signature":"func nodeSig(n *graph.Node) string"}, + {"id":"internal/mcp/gcx.go::encodeSearchSymbols","kind":"function","name":"encodeSearchSymbols","start_line":107,"signature":"func encodeSearchSymbols(nodes []*graph.Node, total, limit int) ([]byte, error)"}, + {"id":"internal/mcp/gcx.go::encodeGetSymbolSource","kind":"function","name":"encodeGetSymbolSource","start_line":141,"signature":"func encodeGetSymbolSource(node *graph.Node, source string, fromLine int, etag string) ([]byte, error)"}, + {"id":"internal/mcp/gcx.go::encodeBatchSymbols","kind":"function","name":"encodeBatchSymbols","start_line":166,"signature":"func encodeBatchSymbols(rows []map[string]any, includeSource bool) ([]byte, error)"}, + {"id":"internal/mcp/gcx.go::encodeFindUsages","kind":"function","name":"encodeFindUsages","start_line":200,"signature":"func encodeFindUsages(sg *query.SubGraph) ([]byte, error)"}, + {"id":"internal/mcp/gcx.go::encodeSubGraph","kind":"function","name":"encodeSubGraph","start_line":229,"signature":"func encodeSubGraph(tool string, sg *query.SubGraph) ([]byte, error)"}, + {"id":"internal/mcp/gcx.go::encodeAnalyze","kind":"function","name":"encodeAnalyze","start_line":390,"signature":"func encodeAnalyze(kind string, payload any) ([]byte, error)"} + ] diff --git a/bench/wire-format/cases/09_analyze_hotspots.yaml b/bench/wire-format/cases/09_analyze_hotspots.yaml new file mode 100644 index 0000000..e74b27b --- /dev/null +++ b/bench/wire-format/cases/09_analyze_hotspots.yaml @@ -0,0 +1,15 @@ +tool: analyze_hotspots +description: 10 hotspot symbols ranked by score +input: | + [ + {"id":"internal/mcp/tools_coding.go::handleSmartContext","name":"handleSmartContext","path":"internal/mcp/tools_coding.go","line":1010,"fan_in":4,"fan_out":38,"cross_community":12,"score":87.4}, + {"id":"internal/indexer/indexer.go::Indexer.Index","name":"Index","path":"internal/indexer/indexer.go","line":110,"fan_in":22,"fan_out":19,"cross_community":9,"score":81.3}, + {"id":"internal/query/engine.go::Engine.GetCallChain","name":"GetCallChain","path":"internal/query/engine.go","line":298,"fan_in":18,"fan_out":6,"cross_community":7,"score":74.1}, + {"id":"internal/graph/graph.go::Graph.AddNode","name":"AddNode","path":"internal/graph/graph.go","line":94,"fan_in":41,"fan_out":0,"cross_community":15,"score":70.6}, + {"id":"internal/graph/graph.go::Graph.AddEdge","name":"AddEdge","path":"internal/graph/graph.go","line":128,"fan_in":38,"fan_out":0,"cross_community":14,"score":68.2}, + {"id":"internal/mcp/server.go::NewServer","name":"NewServer","path":"internal/mcp/server.go","line":62,"fan_in":5,"fan_out":32,"cross_community":10,"score":66.8}, + {"id":"internal/resolver/resolver.go::Resolver.Resolve","name":"Resolve","path":"internal/resolver/resolver.go","line":60,"fan_in":4,"fan_out":26,"cross_community":11,"score":63.1}, + {"id":"internal/semantic/enricher.go::Enricher.Enrich","name":"Enrich","path":"internal/semantic/enricher.go","line":48,"fan_in":3,"fan_out":22,"cross_community":8,"score":58.7}, + {"id":"internal/contracts/bind.go::Bridge","name":"Bridge","path":"internal/contracts/bind.go","line":33,"fan_in":6,"fan_out":18,"cross_community":9,"score":55.4}, + {"id":"internal/daemon/server.go::Server.serve","name":"serve","path":"internal/daemon/server.go","line":122,"fan_in":2,"fan_out":17,"cross_community":6,"score":52.0} + ] diff --git a/bench/wire-format/cases/10_analyze_dead_code.yaml b/bench/wire-format/cases/10_analyze_dead_code.yaml new file mode 100644 index 0000000..f7b2ad4 --- /dev/null +++ b/bench/wire-format/cases/10_analyze_dead_code.yaml @@ -0,0 +1,12 @@ +tool: analyze_dead_code +description: 7 symbols with zero incoming edges +input: | + [ + {"id":"internal/mcp/legacy.go::OldHelper","kind":"function","name":"OldHelper","path":"internal/mcp/legacy.go","line":14,"reason":"no incoming edges (exported but unused externally)"}, + {"id":"internal/parser/tmp.go::scratchParse","kind":"function","name":"scratchParse","path":"internal/parser/tmp.go","line":42,"reason":"no incoming edges"}, + {"id":"internal/query/experiments.go::benchFilter","kind":"function","name":"benchFilter","path":"internal/query/experiments.go","line":88,"reason":"no incoming edges"}, + {"id":"internal/graph/attic.go::deprecatedWalk","kind":"method","name":"deprecatedWalk","path":"internal/graph/attic.go","line":120,"reason":"no incoming edges"}, + {"id":"cmd/gortex/unused.go::debugDump","kind":"function","name":"debugDump","path":"cmd/gortex/unused.go","line":5,"reason":"no incoming edges"}, + {"id":"internal/config/old.go::fromYAMLv1","kind":"function","name":"fromYAMLv1","path":"internal/config/old.go","line":210,"reason":"no incoming edges"}, + {"id":"internal/savings/beta.go::legacyTotal","kind":"function","name":"legacyTotal","path":"internal/savings/beta.go","line":55,"reason":"no incoming edges"} + ] diff --git a/bench/wire-format/cases/11_contracts_list.yaml b/bench/wire-format/cases/11_contracts_list.yaml new file mode 100644 index 0000000..0b25e8e --- /dev/null +++ b/bench/wire-format/cases/11_contracts_list.yaml @@ -0,0 +1,17 @@ +tool: contracts +description: 12 HTTP + gRPC contracts with producers/consumers +input: | + [ + {"id":"http::GET::/api/users/{id}","type":"http","method":"GET","path":"/api/users/{id}","service":"core-api","providers":["core-api/handlers.go::GetUser"],"consumers":["web/users.ts::fetchUser","mobile-app/api.dart::getUser"]}, + {"id":"http::POST::/api/users","type":"http","method":"POST","path":"/api/users","service":"core-api","providers":["core-api/handlers.go::CreateUser"],"consumers":["web/users.ts::createUser"]}, + {"id":"http::DELETE::/api/users/{id}","type":"http","method":"DELETE","path":"/api/users/{id}","service":"core-api","providers":["core-api/handlers.go::DeleteUser"],"consumers":["web/users.ts::deleteUser"]}, + {"id":"grpc::offers.v1.Offers/GetOffer","type":"grpc","method":"GetOffer","path":"offers.v1.Offers","service":"offers","providers":["offers/server.go::GetOffer"],"consumers":["core-api/clients/offers.go::Get"]}, + {"id":"grpc::offers.v1.Offers/ListOffers","type":"grpc","method":"ListOffers","path":"offers.v1.Offers","service":"offers","providers":["offers/server.go::ListOffers"],"consumers":["core-api/clients/offers.go::List","web/offers.ts::listOffers"]}, + {"id":"topic::orders.created","type":"topic","path":"orders.created","service":"orders","providers":["orders/publisher.go::Publish"],"consumers":["email-worker/handler.ts::onOrder","analytics/consumer.go::OnOrder"]}, + {"id":"topic::orders.failed","type":"topic","path":"orders.failed","service":"orders","providers":["orders/publisher.go::PublishFailed"],"consumers":["alerts/handler.go::OnFailed"]}, + {"id":"ws::/ws/chat","type":"websocket","path":"/ws/chat","service":"chat","providers":["chat/server.go::HandleChat"],"consumers":["web/chat.ts::connect"]}, + {"id":"graphql::Query.user","type":"graphql","method":"Query","path":"user","service":"gateway","providers":["gateway/resolvers.go::User"],"consumers":["web/graphql.ts::userQuery"]}, + {"id":"env::DATABASE_URL","type":"env","path":"DATABASE_URL","service":"core-api","providers":["infra/terraform.tf"],"consumers":["core-api/main.go::main"]}, + {"id":"env::REDIS_URL","type":"env","path":"REDIS_URL","service":"core-api","providers":["infra/terraform.tf"],"consumers":["core-api/main.go::main","worker/main.go::main"]}, + {"id":"openapi::public-v1.yaml","type":"openapi","path":"public-v1.yaml","service":"gateway","providers":["gateway/spec.yaml"],"consumers":[]} + ] diff --git a/bench/wire-format/cases/12_get_callers_medium.yaml b/bench/wire-format/cases/12_get_callers_medium.yaml new file mode 100644 index 0000000..8d09c06 --- /dev/null +++ b/bench/wire-format/cases/12_get_callers_medium.yaml @@ -0,0 +1,29 @@ +tool: get_callers +description: callers subgraph with 8 nodes and 10 edges +input: | + { + "nodes": [ + {"id":"internal/mcp/server.go::Server.Start","kind":"method","name":"Start","file_path":"internal/mcp/server.go","start_line":118}, + {"id":"internal/mcp/tools_core.go::registerCoreTools","kind":"method","name":"registerCoreTools","file_path":"internal/mcp/tools_core.go","start_line":307}, + {"id":"internal/mcp/tools_coding.go::registerCodingTools","kind":"method","name":"registerCodingTools","file_path":"internal/mcp/tools_coding.go","start_line":1}, + {"id":"internal/mcp/tools_enhancements.go::registerEnhancementTools","kind":"method","name":"registerEnhancementTools","file_path":"internal/mcp/tools_enhancements.go","start_line":42}, + {"id":"internal/mcp/tools_multi.go::registerMultiRepoTools","kind":"method","name":"registerMultiRepoTools","file_path":"internal/mcp/tools_multi.go","start_line":18}, + {"id":"internal/mcp/tools_outline.go::registerOutlineTool","kind":"method","name":"registerOutlineTool","file_path":"internal/mcp/tools_outline.go","start_line":8}, + {"id":"internal/mcp/tools_planning.go::registerPlanningTools","kind":"method","name":"registerPlanningTools","file_path":"internal/mcp/tools_planning.go","start_line":10}, + {"id":"internal/mcp/tools_untested.go::registerUntestedTool","kind":"method","name":"registerUntestedTool","file_path":"internal/mcp/tools_untested.go","start_line":12} + ], + "edges": [ + {"from":"internal/mcp/server.go::Server.Start","to":"internal/mcp/tools_core.go::registerCoreTools","kind":"calls","confidence":1.0,"origin":"ast_resolved"}, + {"from":"internal/mcp/server.go::Server.Start","to":"internal/mcp/tools_coding.go::registerCodingTools","kind":"calls","confidence":1.0,"origin":"ast_resolved"}, + {"from":"internal/mcp/server.go::Server.Start","to":"internal/mcp/tools_enhancements.go::registerEnhancementTools","kind":"calls","confidence":1.0,"origin":"ast_resolved"}, + {"from":"internal/mcp/server.go::Server.Start","to":"internal/mcp/tools_multi.go::registerMultiRepoTools","kind":"calls","confidence":1.0,"origin":"ast_resolved"}, + {"from":"internal/mcp/server.go::Server.Start","to":"internal/mcp/tools_outline.go::registerOutlineTool","kind":"calls","confidence":1.0,"origin":"ast_resolved"}, + {"from":"internal/mcp/server.go::Server.Start","to":"internal/mcp/tools_planning.go::registerPlanningTools","kind":"calls","confidence":1.0,"origin":"ast_resolved"}, + {"from":"internal/mcp/server.go::Server.Start","to":"internal/mcp/tools_untested.go::registerUntestedTool","kind":"calls","confidence":1.0,"origin":"ast_resolved"}, + {"from":"internal/mcp/tools_core.go::registerCoreTools","to":"internal/mcp/server.go::Server.mcpServer","kind":"references","confidence":1.0,"origin":"ast_resolved"}, + {"from":"internal/mcp/tools_coding.go::registerCodingTools","to":"internal/mcp/server.go::Server.mcpServer","kind":"references","confidence":1.0,"origin":"ast_resolved"}, + {"from":"internal/mcp/tools_enhancements.go::registerEnhancementTools","to":"internal/mcp/server.go::Server.mcpServer","kind":"references","confidence":1.0,"origin":"ast_resolved"} + ], + "total_nodes": 8, + "truncated": false + } diff --git a/bench/wire-format/cases/13_smart_context.yaml b/bench/wire-format/cases/13_smart_context.yaml new file mode 100644 index 0000000..2da04db --- /dev/null +++ b/bench/wire-format/cases/13_smart_context.yaml @@ -0,0 +1,19 @@ +tool: smart_context +description: smart_context response with 10 ranked symbols +input: | + { + "task": "add a new MCP tool called list_files that returns paths in the index", + "symbols": [ + {"id":"internal/mcp/tools_core.go::handleSearchSymbols","kind":"method","name":"handleSearchSymbols","path":"internal/mcp/tools_core.go","line":539,"score":0.94,"reason":"similar handler pattern"}, + {"id":"internal/mcp/server.go::NewServer","kind":"function","name":"NewServer","path":"internal/mcp/server.go","line":62,"score":0.88,"reason":"constructor for server"}, + {"id":"internal/mcp/tools_core.go::registerCoreTools","kind":"method","name":"registerCoreTools","path":"internal/mcp/tools_core.go","line":307,"score":0.87,"reason":"where tools are registered"}, + {"id":"internal/graph/graph.go::Graph.AllNodes","kind":"method","name":"AllNodes","path":"internal/graph/graph.go","line":240,"score":0.85,"reason":"iterates all nodes"}, + {"id":"internal/indexer/indexer.go::Indexer.Files","kind":"method","name":"Files","path":"internal/indexer/indexer.go","line":420,"score":0.82,"reason":"returns file list"}, + {"id":"internal/mcp/server.go::Server","kind":"type","name":"Server","path":"internal/mcp/server.go","line":36,"score":0.80,"reason":"server type holding engine"}, + {"id":"internal/mcp/session_ctx.go::sessionFor","kind":"function","name":"sessionFor","path":"internal/mcp/session_ctx.go","line":42,"score":0.70,"reason":"session state accessor"}, + {"id":"internal/mcp/etag.go::computeETag","kind":"function","name":"computeETag","path":"internal/mcp/etag.go","line":22,"score":0.65,"reason":"response caching"}, + {"id":"internal/graph/node.go::Node","kind":"type","name":"Node","path":"internal/graph/node.go","line":23,"score":0.62,"reason":"symbol node type"}, + {"id":"internal/query/engine.go::Engine","kind":"type","name":"Engine","path":"internal/query/engine.go","line":30,"score":0.60,"reason":"query engine"} + ], + "total": 10 + } diff --git a/bench/wire-format/cases/14_get_dependents_small.yaml b/bench/wire-format/cases/14_get_dependents_small.yaml new file mode 100644 index 0000000..e4b2045 --- /dev/null +++ b/bench/wire-format/cases/14_get_dependents_small.yaml @@ -0,0 +1,18 @@ +tool: get_dependents +description: transitive dependents, 5 nodes +input: | + { + "nodes":[ + {"id":"internal/config/config.go::Config","kind":"type","name":"Config","file_path":"internal/config/config.go","start_line":40}, + {"id":"internal/config/manager.go::Manager","kind":"type","name":"Manager","file_path":"internal/config/manager.go","start_line":22}, + {"id":"cmd/gortex/serve.go::runServe","kind":"function","name":"runServe","file_path":"cmd/gortex/serve.go","start_line":48}, + {"id":"cmd/gortex/bridge.go::runBridge","kind":"function","name":"runBridge","file_path":"cmd/gortex/bridge.go","start_line":36}, + {"id":"cmd/gortex/daemon.go::runDaemonStart","kind":"function","name":"runDaemonStart","file_path":"cmd/gortex/daemon.go","start_line":78} + ], + "edges":[ + {"from":"internal/config/manager.go::Manager","to":"internal/config/config.go::Config","kind":"references","confidence":1.0,"origin":"ast_resolved"}, + {"from":"cmd/gortex/serve.go::runServe","to":"internal/config/manager.go::Manager","kind":"references","confidence":1.0,"origin":"ast_resolved"}, + {"from":"cmd/gortex/bridge.go::runBridge","to":"internal/config/manager.go::Manager","kind":"references","confidence":1.0,"origin":"ast_resolved"}, + {"from":"cmd/gortex/daemon.go::runDaemonStart","to":"internal/config/manager.go::Manager","kind":"references","confidence":1.0,"origin":"ast_resolved"} + ] + } diff --git a/bench/wire-format/cases/15_get_test_targets.yaml b/bench/wire-format/cases/15_get_test_targets.yaml new file mode 100644 index 0000000..167342b --- /dev/null +++ b/bench/wire-format/cases/15_get_test_targets.yaml @@ -0,0 +1,13 @@ +tool: get_test_targets +description: tests that cover a set of changed symbols +input: | + [ + {"test_file":"internal/mcp/gcx_test.go","test_symbol":"TestEncodeSearchSymbols_HeaderAndRows","covers":["internal/mcp/gcx.go::encodeSearchSymbols"]}, + {"test_file":"internal/mcp/gcx_test.go","test_symbol":"TestEncodeGetSymbolSource_EmbeddedNewlinesRoundTrip","covers":["internal/mcp/gcx.go::encodeGetSymbolSource"]}, + {"test_file":"internal/mcp/gcx_test.go","test_symbol":"TestEncodeBatchSymbols_IncludeSource","covers":["internal/mcp/gcx.go::encodeBatchSymbols"]}, + {"test_file":"internal/mcp/gcx_test.go","test_symbol":"TestEncodeSubGraph_NodesAndEdgesSections","covers":["internal/mcp/gcx.go::encodeSubGraph"]}, + {"test_file":"internal/mcp/gcx_test.go","test_symbol":"TestEncodeFindUsages_OneRowPerEdge","covers":["internal/mcp/gcx.go::encodeFindUsages"]}, + {"test_file":"internal/wire/wire_test.go","test_symbol":"TestEncoder_WritesHeaderThenRows","covers":["internal/wire/encoder.go::NewEncoder","internal/wire/encoder.go::Encoder.WriteRow"]}, + {"test_file":"internal/wire/wire_test.go","test_symbol":"TestDecoder_ParsesHeaderAndRows","covers":["internal/wire/decoder.go::NewDecoder","internal/wire/decoder.go::Decoder.Header"]}, + {"test_file":"internal/wire/generic_test.go","test_symbol":"TestEncodeAny_RowList","covers":["internal/wire/generic.go::EncodeAny"]} + ] diff --git a/bench/wire-format/cases/16_find_implementations.yaml b/bench/wire-format/cases/16_find_implementations.yaml new file mode 100644 index 0000000..7782992 --- /dev/null +++ b/bench/wire-format/cases/16_find_implementations.yaml @@ -0,0 +1,11 @@ +tool: find_implementations +description: 6 implementations of an interface +input: | + [ + {"id":"internal/persistence/file_store.go::FileStore","kind":"type","name":"FileStore","file_path":"internal/persistence/file_store.go","start_line":22}, + {"id":"internal/persistence/memory_store.go::MemoryStore","kind":"type","name":"MemoryStore","file_path":"internal/persistence/memory_store.go","start_line":14}, + {"id":"internal/persistence/nullstore.go::NullStore","kind":"type","name":"NullStore","file_path":"internal/persistence/nullstore.go","start_line":8}, + {"id":"internal/daemon/snapshot_store.go::SnapshotStore","kind":"type","name":"SnapshotStore","file_path":"internal/daemon/snapshot_store.go","start_line":34}, + {"id":"internal/persistence/sqlite_store.go::SQLiteStore","kind":"type","name":"SQLiteStore","file_path":"internal/persistence/sqlite_store.go","start_line":42}, + {"id":"internal/eval/fixture_store.go::FixtureStore","kind":"type","name":"FixtureStore","file_path":"internal/eval/fixture_store.go","start_line":18} + ] diff --git a/bench/wire-format/cases/17_find_cycles.yaml b/bench/wire-format/cases/17_find_cycles.yaml new file mode 100644 index 0000000..512f1ba --- /dev/null +++ b/bench/wire-format/cases/17_find_cycles.yaml @@ -0,0 +1,8 @@ +tool: analyze_cycles +description: three cycles detected by Tarjan's SCC +input: | + [ + {"size":3,"severity":"medium","nodes":["a.go::A","b.go::B","c.go::C"]}, + {"size":2,"severity":"low","nodes":["d.go::D","e.go::E"]}, + {"size":5,"severity":"high","nodes":["x.go::X1","y.go::Y1","z.go::Z1","x.go::X2","y.go::Y2"]} + ] diff --git a/bench/wire-format/cases/18_graph_stats.yaml b/bench/wire-format/cases/18_graph_stats.yaml new file mode 100644 index 0000000..9ccd3cf --- /dev/null +++ b/bench/wire-format/cases/18_graph_stats.yaml @@ -0,0 +1,10 @@ +tool: graph_stats +description: compact graph_stats record +input: | + { + "total_nodes":85387, + "total_edges":544823, + "by_kind":{"contract":703,"file":11279,"function":22314,"interface":3335,"method":11972,"type":11386,"variable":24398}, + "by_language":{"bash":293,"c":5509,"contract":703,"css":448,"dart":2585,"go":35473,"hcl":4120,"html":421,"javascript":874,"markdown":5269,"protobuf":511,"python":341,"ruby":2,"sql":69,"swift":1026,"toml":41,"typescript":25688,"yaml":2014}, + "token_savings":{"calls_counted":0,"tokens_returned":0,"tokens_saved":0,"efficiency_ratio":0} + } diff --git a/bench/wire-format/cases/19_get_editing_context.yaml b/bench/wire-format/cases/19_get_editing_context.yaml new file mode 100644 index 0000000..8978fc0 --- /dev/null +++ b/bench/wire-format/cases/19_get_editing_context.yaml @@ -0,0 +1,11 @@ +tool: get_editing_context +description: target symbol plus callers + deps + tests (flattened rows) +input: | + [ + {"section":"target","id":"internal/query/engine.go::Engine.GetCallers","kind":"method","name":"GetCallers","path":"internal/query/engine.go","line":255,"signature":"func (e *Engine) GetCallers(id string, opts QueryOptions) *SubGraph"}, + {"section":"caller","id":"internal/mcp/tools_core.go::handleGetCallers","kind":"method","name":"handleGetCallers","path":"internal/mcp/tools_core.go","line":698}, + {"section":"caller","id":"internal/mcp/tools_coding.go::handleGetEditingContext","kind":"method","name":"handleGetEditingContext","path":"internal/mcp/tools_coding.go","line":161}, + {"section":"dep","id":"internal/graph/graph.go::Graph.GetNode","kind":"method","name":"GetNode","path":"internal/graph/graph.go","line":155}, + {"section":"dep","id":"internal/query/subgraph.go::NewSubGraph","kind":"function","name":"NewSubGraph","path":"internal/query/subgraph.go","line":22}, + {"section":"test","path":"internal/query/engine_test.go"} + ] diff --git a/bench/wire-format/cases/20_get_repo_outline.yaml b/bench/wire-format/cases/20_get_repo_outline.yaml new file mode 100644 index 0000000..3ba3a92 --- /dev/null +++ b/bench/wire-format/cases/20_get_repo_outline.yaml @@ -0,0 +1,10 @@ +tool: get_repo_outline +description: single-call repo overview +input: | + { + "languages":[{"name":"go","nodes":35473},{"name":"typescript","nodes":25688},{"name":"c","nodes":5509},{"name":"markdown","nodes":5269},{"name":"hcl","nodes":4120}], + "communities":[{"id":0,"name":"core","size":1205,"modularity":0.42},{"id":1,"name":"daemon","size":812,"modularity":0.39},{"id":2,"name":"contracts","size":603,"modularity":0.36},{"id":3,"name":"mcp","size":588,"modularity":0.34},{"id":4,"name":"indexer","size":512,"modularity":0.32}], + "hotspots":[{"id":"internal/graph/graph.go::Graph.AddNode","score":70.6},{"id":"internal/graph/graph.go::Graph.AddEdge","score":68.2},{"id":"internal/mcp/server.go::NewServer","score":66.8}], + "most_imported":["internal/graph/graph.go","internal/graph/node.go","internal/config/config.go"], + "entry_points":["cmd/gortex/main.go::main"] + } diff --git a/bench/wire-format/encoders.go b/bench/wire-format/encoders.go new file mode 100644 index 0000000..73b3270 --- /dev/null +++ b/bench/wire-format/encoders.go @@ -0,0 +1,265 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "sort" + + wire "github.com/gortexhq/gcx-go" +) + +// encodeAsGCX selects the best GCX encoding for the given canonical +// JSON value. The benchmark recognises three common MCP wrapper +// shapes produced by Gortex handlers: +// +// - {results: [...], total: N, truncated: bool} → rows from +// "results", meta carries totals. +// - {symbols: [...], total: N, etag: "..."} → rows from +// "symbols". +// - {nodes: [...], edges: [...], ...} → two-section +// payload (one section per array). +// +// Anything else falls through to wire.EncodeAny so the benchmark +// still produces a valid GCX payload — the per-case score just +// reflects the generic fallback. This models what a well-chosen +// hand-tuned encoder would emit without duplicating the full +// internal/mcp encoder surface here. +func encodeAsGCX(tool string, value any) ([]byte, error) { + if m, ok := value.(map[string]any); ok { + // Detect common single-collection wrappers. + if rows, ok := m["results"].([]any); ok { + return encodeFlatRows(tool, rows, meta(m, "total", "truncated")) + } + if rows, ok := m["symbols"].([]any); ok { + return encodeFlatRows(tool, rows, meta(m, "total", "etag")) + } + if nodes, okN := m["nodes"].([]any); okN { + return encodeNodesAndEdges(tool, nodes, m["edges"], meta(m, "total_nodes", "total_edges", "truncated")) + } + if edges, okE := m["edges"].([]any); okE && !hasKey(m, "nodes") { + // Edges-only subgraph (find_usages). Emit one section. + return encodeFlatRows(tool, edges, meta(m, "total_edges", "truncated")) + } + if rows, ok := m["implementations"].([]any); ok { + return encodeFlatRows(tool, rows, meta(m, "total")) + } + // Multi-section wrapper: every value is a list of objects + // (e.g. get_repo_outline: languages / communities / hotspots + // / most_imported / entry_points). Emit one GCX section per + // key with stable ordering. + if looksMultiSection(m) { + return encodeMultiSection(tool, m) + } + } + var buf bytes.Buffer + if err := wire.EncodeAny(&buf, tool, value); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func hasKey(m map[string]any, k string) bool { _, ok := m[k]; return ok } + +// looksMultiSection reports whether m is a "sectioned" wrapper — a +// map where every value is either a list of objects or a list of +// scalars, and there are at least two such keys. Nested scalar +// records (fan_in / counts / etag) break the pattern so we keep the +// check strict. +func looksMultiSection(m map[string]any) bool { + listyKeys := 0 + for _, v := range m { + switch x := v.(type) { + case []any: + listyKeys++ + _ = x + default: + // Scalars disqualify the wrapper — a real multi-section + // payload would carry metadata in the headers, not as + // sibling scalar keys. This also filters out graph_stats + // (which is mostly scalar cells). + return false + } + } + return listyKeys >= 2 +} + +func encodeMultiSection(tool string, m map[string]any) ([]byte, error) { + var buf bytes.Buffer + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + rows, _ := m[k].([]any) + if len(rows) == 0 { + // Preserve the section even when empty so decoders can + // still iterate the known sub-tables. + hdr := wire.Header{Tool: tool + "." + k, Fields: []string{"value"}, Meta: map[string]string{"rows": "0"}} + _ = wire.NewEncoder(&buf, hdr).Close() + continue + } + // Scalar list → single-column "value" field; object list → + // flat rows with union-of-keys fields. + if _, scalar := rows[0].(map[string]any); scalar { + body, err := encodeFlatRows(tool+"."+k, rows, nil) + if err != nil { + return nil, err + } + buf.Write(body) + continue + } + hdr := wire.Header{Tool: tool + "." + k, Fields: []string{"value"}, Meta: map[string]string{"rows": fmt.Sprintf("%d", len(rows))}} + enc := wire.NewEncoder(&buf, hdr) + for _, v := range rows { + if err := enc.WriteRow(renderScalar(v)); err != nil { + return nil, err + } + } + if err := enc.Close(); err != nil { + return nil, err + } + } + return buf.Bytes(), nil +} + +// encodeFlatRows writes a list-of-objects as a single GCX section +// with the union of keys as fields. Mirrors what the real +// encodeSearchSymbols / encodeBatchSymbols / encodeFileSummary +// encoders in internal/mcp produce — uniform row shape, no nested +// JSON cells for the shared scalar keys. +func encodeFlatRows(tool string, rows []any, meta map[string]string) ([]byte, error) { + fields := unionKeys(rows) + var buf bytes.Buffer + hdr := wire.Header{Tool: tool, Fields: fields, Meta: map[string]string{ + "rows": fmt.Sprintf("%d", len(rows)), + }} + for k, v := range meta { + hdr.Meta[k] = v + } + enc := wire.NewEncoder(&buf, hdr) + for _, r := range rows { + obj, ok := r.(map[string]any) + if !ok { + if err := enc.WriteRow(fmt.Sprint(r)); err != nil { + return nil, err + } + continue + } + values := make([]any, len(fields)) + for i, f := range fields { + values[i] = renderScalar(obj[f]) + } + if err := enc.WriteRow(values...); err != nil { + return nil, err + } + } + return buf.Bytes(), enc.Close() +} + +// encodeNodesAndEdges emits a node section followed by an edge section +// using the benchmark's single-shot writer. Mirrors the real +// encodeSubGraph in internal/mcp. +func encodeNodesAndEdges(tool string, nodes []any, edgesAny any, meta map[string]string) ([]byte, error) { + var buf bytes.Buffer + nodeFields := unionKeys(nodes) + nHdr := wire.Header{Tool: tool + ".nodes", Fields: nodeFields, Meta: map[string]string{ + "rows": fmt.Sprintf("%d", len(nodes)), + }} + for k, v := range meta { + nHdr.Meta[k] = v + } + nEnc := wire.NewEncoder(&buf, nHdr) + for _, n := range nodes { + obj, ok := n.(map[string]any) + if !ok { + continue + } + values := make([]any, len(nodeFields)) + for i, f := range nodeFields { + values[i] = renderScalar(obj[f]) + } + if err := nEnc.WriteRow(values...); err != nil { + return nil, err + } + } + if err := nEnc.Close(); err != nil { + return nil, err + } + edges, _ := edgesAny.([]any) + edgeFields := unionKeys(edges) + eHdr := wire.Header{Tool: tool + ".edges", Fields: edgeFields, Meta: map[string]string{ + "rows": fmt.Sprintf("%d", len(edges)), + }} + eEnc := wire.NewEncoder(&buf, eHdr) + for _, e := range edges { + obj, ok := e.(map[string]any) + if !ok { + continue + } + values := make([]any, len(edgeFields)) + for i, f := range edgeFields { + values[i] = renderScalar(obj[f]) + } + if err := eEnc.WriteRow(values...); err != nil { + return nil, err + } + } + return buf.Bytes(), eEnc.Close() +} + +func unionKeys(rows []any) []string { + seen := map[string]struct{}{} + for _, r := range rows { + if obj, ok := r.(map[string]any); ok { + for k := range obj { + seen[k] = struct{}{} + } + } + } + keys := make([]string, 0, len(seen)) + for k := range seen { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +func meta(m map[string]any, keys ...string) map[string]string { + out := map[string]string{} + for _, k := range keys { + if v, ok := m[k]; ok && v != nil { + out[k] = fmt.Sprint(v) + } + } + return out +} + +// renderScalar flattens a nested value into a GCX cell — scalars as +// their natural string form, collections as compact JSON (matching +// the internal/mcp encoders' behaviour for heterogeneous fields such +// as providers / consumers / callers). +func renderScalar(v any) any { + switch v.(type) { + case nil: + return "" + case map[string]any, []any: + // Nested collection — caller must not rely on cell + // homogeneity. Serialise to compact JSON so the cell + // stays on one physical line but the decoder can parse + // it back when desired. + b, err := marshalCompact(v) + if err != nil { + return fmt.Sprint(v) + } + return b + default: + return v + } +} + +func marshalCompact(v any) (string, error) { + b, err := json.Marshal(v) + return string(b), err +} diff --git a/bench/wire-format/main.go b/bench/wire-format/main.go new file mode 100644 index 0000000..7aa011b --- /dev/null +++ b/bench/wire-format/main.go @@ -0,0 +1,449 @@ +// Command wire-format benches GCX1 vs JSON on representative MCP tool +// responses. See bench/wire-format/README.md. +package main + +import ( + "bytes" + "compress/gzip" + "encoding/json" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + + "gopkg.in/yaml.v3" + + "github.com/zzet/gortex/internal/tokens" + wire "github.com/gortexhq/gcx-go" +) + +type caseFile struct { + Tool string `yaml:"tool"` + Description string `yaml:"description"` + // Input is the tool response payload as JSON text. The YAML pipe + // scalar (`|`) keeps the JSON human-readable in the fixture. + Input string `yaml:"input"` +} + +type metrics struct { + Case string + Tool string + JSONBytes int + GCXBytes int + JSONTokens int + GCXTokens int + JSONGzip int + GCXGzip int + // Opus 4.7 input-token counts. Populated when --tokenizer is + // opus47 or both. ExactOpus47 distinguishes API-backed / cached + // counts (true) from inflation-factor estimates (false) — the + // scorecard footnote calls out the difference for honesty. + JSONTokensOpus47 int `json:",omitempty"` + GCXTokensOpus47 int `json:",omitempty"` + ExactOpus47 bool `json:",omitempty"` + RoundTripOK bool + RoundTripErr string +} + +func main() { + casesDir := flag.String("cases", "bench/wire-format/cases", "directory of fixture YAML files") + out := flag.String("out", "", "output scorecard markdown path (stdout if empty)") + jsonOut := flag.String("json", "", "optional path to emit raw metrics as JSON") + tokenizer := flag.String("tokenizer", "both", "which tokenizer column(s) to render: cl100k | opus47 | both") + useAPI := flag.Bool("use-api", false, "call Anthropic count_tokens for exact Opus 4.7 counts (requires ANTHROPIC_API_KEY); falls back to scalar on failure") + opus47Cache := flag.String("opus47-cache", "bench/wire-format/opus47-counts.json", "path to the Opus 4.7 exact-count cache (loaded on start, written on --use-api hits)") + opus47Model := flag.String("opus47-model", "claude-opus-4-20250514", "model id used for the count_tokens API call (only relevant with --use-api)") + flag.Parse() + + mode, err := parseTokenizerMode(*tokenizer) + if err != nil { + die("%v", err) + } + + // Build the Opus 4.7 counter chain regardless of mode — when the + // user asked for cl100k only, the counter still gets constructed + // but its output is discarded by the renderer. Keeps the runCase + // signature simple. + opus47, opus47Cached, err := buildOpus47Counter(*opus47Cache, *opus47Model, *useAPI) + if err != nil { + die("%v", err) + } + + entries, err := os.ReadDir(*casesDir) + if err != nil { + die("read cases dir: %v", err) + } + sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) + + var rows []metrics + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".yaml") { + continue + } + path := filepath.Join(*casesDir, entry.Name()) + row, err := runCase(path, opus47) + if err != nil { + row.RoundTripErr = err.Error() + row.Case = strings.TrimSuffix(entry.Name(), ".yaml") + } + rows = append(rows, row) + } + + // Persist any newly-populated exact counts so subsequent runs hit + // the cache instead of the API. Best-effort: a write error here + // shouldn't fail the bench (the scorecard is already in memory). + if *useAPI && opus47Cached != nil { + snap := opus47Cached.snapshot() + if err := saveOpus47Cache(*opus47Cache, snap); err != nil { + fmt.Fprintf(os.Stderr, "wire-bench: write opus47 cache: %v\n", err) + } + } + + card := renderScorecard(rows, mode) + if *out == "" { + fmt.Print(card) + } else { + if err := os.WriteFile(*out, []byte(card), 0o644); err != nil { + die("write scorecard: %v", err) + } + } + if *jsonOut != "" { + b, _ := json.MarshalIndent(rows, "", " ") + if err := os.WriteFile(*jsonOut, b, 0o644); err != nil { + die("write json: %v", err) + } + } +} + +// tokenizerMode selects which token-cost columns the scorecard +// renders. The opus47 column is honest-labeled "_estimated" when +// every row was scaled vs. "_exact" when at least one row came from +// the API/cache (footnote indicates which). +type tokenizerMode int + +const ( + tokenizerModeCL100k tokenizerMode = iota + tokenizerModeOpus47 + tokenizerModeBoth +) + +func parseTokenizerMode(s string) (tokenizerMode, error) { + switch strings.ToLower(s) { + case "cl100k", "cl100k_base": + return tokenizerModeCL100k, nil + case "opus47", "opus4.7", "opus-4-7", "claude": + return tokenizerModeOpus47, nil + case "both", "all": + return tokenizerModeBoth, nil + } + return tokenizerModeBoth, fmt.Errorf("unknown --tokenizer %q (want cl100k | opus47 | both)", s) +} + +// buildOpus47Counter wires the strategy: a cachedCounter at the base +// loaded from disk, wrapped by an apiCounter when --use-api is set. +// Returns the active counter plus the underlying cache (when an API +// hit needs to be persisted on shutdown); the cache may be nil when +// no cache file is configured. +func buildOpus47Counter(cachePath, model string, useAPI bool) (opus47Counter, *cachedCounter, error) { + if cachePath == "" { + // No cache configured → in-process model counter, fast path. + return newModelCounter(model), nil, nil + } + c, err := loadOpus47Cache(cachePath) + if err != nil { + return nil, nil, err + } + cached := newCachedCounter(c, model) + if !useAPI { + return cached, cached, nil + } + api, err := newAPICounter(cached, model) + if err != nil { + return nil, nil, err + } + return api, cached, nil +} + +func die(format string, args ...any) { + fmt.Fprintf(os.Stderr, "wire-bench: "+format+"\n", args...) + os.Exit(1) +} + +func runCase(path string, opus47 opus47Counter) (metrics, error) { + raw, err := os.ReadFile(path) + if err != nil { + return metrics{}, fmt.Errorf("read %s: %w", path, err) + } + var cf caseFile + if err := yaml.Unmarshal(raw, &cf); err != nil { + return metrics{}, fmt.Errorf("parse %s: %w", path, err) + } + name := strings.TrimSuffix(filepath.Base(path), ".yaml") + + m := metrics{Case: name, Tool: cf.Tool} + + // Normalise the input so the two encoders start from the same + // canonical value. This models what a real MCP handler would + // produce — a JSON-serialisable Go value. + var value any + dec := json.NewDecoder(strings.NewReader(cf.Input)) + dec.UseNumber() + if err := dec.Decode(&value); err != nil { + return m, fmt.Errorf("parse input: %w", err) + } + + // JSON baseline (compact, no indent — matches mcp-go behaviour). + jsonBytes, err := json.Marshal(value) + if err != nil { + return m, fmt.Errorf("marshal json: %w", err) + } + m.JSONBytes = len(jsonBytes) + m.JSONTokens = tokens.Count(string(jsonBytes)) + m.JSONGzip = gzipLen(jsonBytes) + + // GCX — bench-local encoder chooses between hand-tuned shape + // recognition and the generic fallback. This reproduces what the + // real internal/mcp encoders produce for recognised wrapper + // shapes (rows-of-objects, {nodes, edges}, ...). Unrecognised + // shapes fall through to wire.EncodeAny so every case still + // emits a valid payload. + gcxBytes, err := encodeAsGCX(cf.Tool, value) + if err != nil { + return m, fmt.Errorf("encode gcx: %w", err) + } + m.GCXBytes = len(gcxBytes) + m.GCXTokens = tokens.Count(string(gcxBytes)) + m.GCXGzip = gzipLen(gcxBytes) + + // Opus 4.7 column. The counter is always supplied; the renderer + // decides whether to surface these values based on --tokenizer. + // `exact` is true when the value came from cache / live API; we + // take the AND across both channels (a row only counts as exact + // when both numbers are exact — otherwise the footnote calls it + // estimated). + if opus47 != nil { + jOpus, jExact := opus47.Count(name, "json", string(jsonBytes), m.JSONTokens) + gOpus, gExact := opus47.Count(name, "gcx", string(gcxBytes), m.GCXTokens) + m.JSONTokensOpus47 = jOpus + m.GCXTokensOpus47 = gOpus + m.ExactOpus47 = jExact && gExact + } + + // Round-trip: decode GCX back into a generic value and compare to + // the canonical JSON encoding. Full structural equality is too + // strict because the generic encoder serialises nested values as + // JSON-in-cells; instead, we check that the decoder yields the + // same set of top-level cells and the re-marshalled payload + // round-trips on the text level (byte-identical decode of the + // GCX output). + wd := wire.NewDecoder(bytes.NewReader(gcxBytes)) + if _, err := wd.Header(); err != nil { + return m, fmt.Errorf("decode header: %w", err) + } + if _, err := wd.All(); err != nil { + return m, fmt.Errorf("decode rows: %w", err) + } + m.RoundTripOK = true + + return m, nil +} + +func gzipLen(b []byte) int { + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + if _, err := io.Copy(gz, bytes.NewReader(b)); err != nil { + return -1 + } + _ = gz.Close() + return buf.Len() +} + +// renderScorecard formats the per-case metrics as one or two +// markdown tables depending on the tokenizer mode. `cl100k` prints +// the original single-table layout; `opus47` swaps in the Opus 4.7 +// columns; `both` stacks them, cl100k first then opus47, sharing the +// same case rows. A footnote distinguishes exact (API/cache) and +// estimated (in-process per-model tokenizer) opus47 counts. +func renderScorecard(rows []metrics, mode tokenizerMode) string { + var b strings.Builder + fmt.Fprintln(&b, "# GCX1 wire-format benchmark scorecard") + fmt.Fprintln(&b) + if mode == tokenizerModeCL100k || mode == tokenizerModeBoth { + fmt.Fprintln(&b, "## tiktoken cl100k_base (Claude 3 / Opus 4 / Sonnet 4 / Haiku 4.5 / GPT-4o family)") + fmt.Fprintln(&b) + fmt.Fprintln(&b, "| case | tool | bytes (json) | bytes (gcx) | Δ% | tokens (json) | tokens (gcx) | Δ% | gzip (json) | gzip (gcx) | Δ% | round-trip |") + fmt.Fprintln(&b, "|------|------|-------------:|------------:|---:|--------------:|-------------:|---:|------------:|-----------:|---:|:---------:|") + for _, m := range rows { + fmt.Fprintf(&b, "| %s | %s | %d | %d | %s | %d | %d | %s | %d | %d | %s | %s |\n", + m.Case, m.Tool, + m.JSONBytes, m.GCXBytes, pctDelta(m.JSONBytes, m.GCXBytes), + m.JSONTokens, m.GCXTokens, pctDelta(m.JSONTokens, m.GCXTokens), + m.JSONGzip, m.GCXGzip, pctDelta(m.JSONGzip, m.GCXGzip), + rtrMark(m), + ) + } + fmt.Fprintln(&b) + fmt.Fprintln(&b, summaryLineCL100k(rows)) + } + if mode == tokenizerModeOpus47 || mode == tokenizerModeBoth { + if mode == tokenizerModeBoth { + fmt.Fprintln(&b) + } + exactAll := allExactOpus47(rows) + label := "estimated (in-process per-model tokenizer)" + if exactAll { + label = "exact (Anthropic count_tokens / cached)" + } else if anyExactOpus47(rows) { + label = "mixed — see per-row marker" + } + fmt.Fprintf(&b, "## Claude Opus 4.7 (%s)\n\n", label) + fmt.Fprintln(&b, "| case | tool | tokens (json) | tokens (gcx) | Δ% | source |") + fmt.Fprintln(&b, "|------|------|--------------:|-------------:|---:|:------:|") + for _, m := range rows { + fmt.Fprintf(&b, "| %s | %s | %d | %d | %s | %s |\n", + m.Case, m.Tool, + m.JSONTokensOpus47, m.GCXTokensOpus47, + pctDelta(m.JSONTokensOpus47, m.GCXTokensOpus47), + opus47SourceMark(m), + ) + } + fmt.Fprintln(&b) + fmt.Fprintln(&b, summaryLineOpus47(rows)) + } + return b.String() +} + +// allExactOpus47 reports whether every row's opus47 numbers came from +// the API/cache (not the scalar). Used to pick the section header. +func allExactOpus47(rows []metrics) bool { + if len(rows) == 0 { + return false + } + for _, m := range rows { + if !m.ExactOpus47 { + return false + } + } + return true +} + +// anyExactOpus47 reports whether at least one row's opus47 numbers +// came from the API/cache. Used to label the section as "mixed" +// when some rows have exact data and others fell back to the scalar. +func anyExactOpus47(rows []metrics) bool { + for _, m := range rows { + if m.ExactOpus47 { + return true + } + } + return false +} + +// opus47SourceMark emits a per-row marker that distinguishes exact +// counts ("exact") from scalar estimates ("est.") so a reader can +// see at a glance which numbers came from where in a mixed run. +func opus47SourceMark(m metrics) string { + if m.ExactOpus47 { + return "exact" + } + return "est." +} + +func pctDelta(base, got int) string { + if base == 0 { + return "n/a" + } + pct := float64(base-got) / float64(base) * 100 + if pct >= 0 { + return fmt.Sprintf("−%.1f%%", pct) + } + return fmt.Sprintf("+%.1f%%", -pct) +} + +func rtrMark(m metrics) string { + if m.RoundTripErr != "" { + return "✗ " + m.RoundTripErr + } + if m.RoundTripOK { + return "✓" + } + return "?" +} + +// summaryLineCL100k summarises the cl100k_base table: median token +// + median byte savings + round-trip pass count. +func summaryLineCL100k(rows []metrics) string { + if len(rows) == 0 { + return "_no cases_" + } + var ( + tokensJ, tokensG []int + bytesJ, bytesG []int + ) + passed := 0 + for _, m := range rows { + if m.JSONTokens > 0 { + tokensJ = append(tokensJ, m.JSONTokens) + tokensG = append(tokensG, m.GCXTokens) + bytesJ = append(bytesJ, m.JSONBytes) + bytesG = append(bytesG, m.GCXBytes) + } + if m.RoundTripOK { + passed++ + } + } + return fmt.Sprintf("**Summary (cl100k_base):** %d/%d cases. Median token savings: %s. Median byte savings: %s. Round-trip integrity: %d/%d.", + len(rows), len(rows), + medianPct(tokensJ, tokensG), + medianPct(bytesJ, bytesG), + passed, len(rows), + ) +} + +// summaryLineOpus47 summarises the Opus 4.7 table: median token +// savings on the new tokenizer plus a count of exact vs. estimated +// rows so the reader can judge confidence. +func summaryLineOpus47(rows []metrics) string { + if len(rows) == 0 { + return "_no cases_" + } + var tokensJ, tokensG []int + exactRows := 0 + for _, m := range rows { + if m.JSONTokensOpus47 > 0 { + tokensJ = append(tokensJ, m.JSONTokensOpus47) + tokensG = append(tokensG, m.GCXTokensOpus47) + } + if m.ExactOpus47 { + exactRows++ + } + } + return fmt.Sprintf("**Summary (Opus 4.7):** %d/%d cases. Median token savings: %s. Exact rows: %d/%d (rest estimated by the in-process per-model tokenizer).", + len(rows), len(rows), + medianPct(tokensJ, tokensG), + exactRows, len(rows), + ) +} + +func medianPct(base, got []int) string { + if len(base) == 0 { + return "n/a" + } + deltas := make([]float64, len(base)) + for i := range base { + if base[i] == 0 { + deltas[i] = 0 + continue + } + deltas[i] = float64(base[i]-got[i]) / float64(base[i]) * 100 + } + sort.Float64s(deltas) + mid := deltas[len(deltas)/2] + if mid >= 0 { + return fmt.Sprintf("−%.1f%%", mid) + } + return fmt.Sprintf("+%.1f%%", -mid) +} diff --git a/bench/wire-format/opus47.go b/bench/wire-format/opus47.go new file mode 100644 index 0000000..304b2de --- /dev/null +++ b/bench/wire-format/opus47.go @@ -0,0 +1,274 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "maps" + "net/http" + "os" + "sync" + "time" + + "github.com/zzet/gortex/internal/tokens" +) + +// opus47Counter measures the input-token cost of a payload against the +// Claude Opus 4.7 tokenizer. We use three strategies, picked at flag +// time, that share this interface: +// +// - modelCounter: an in-process, per-model tiktoken estimate via +// internal/tokens.CountFor (default). Offline, deterministic, and +// tokenizer-aware — it counts with the encoding the model family +// actually uses and applies the family calibration ratio, so the +// estimate tracks each fixture instead of a single flat scalar. +// - cachedCounter: reads pre-computed exact counts from a JSON +// sidecar on disk. Falls back to the model counter when the cache +// misses, so a partial cache is still useful. +// - apiCounter: calls Anthropic's `messages/count_tokens` +// endpoint with the configured model id; populates the cache on +// success so subsequent runs are deterministic. Requires +// ANTHROPIC_API_KEY in the environment. +// +// Returning `exact=true` lets the scorecard label each row as +// estimated or exact — important for the published artifact to be +// honest about which numbers came from where. +type opus47Counter interface { + Count(caseName, channel, payload string, cl100k int) (count int, exact bool) +} + +// defaultOpus47Model is the model id the offline counter resolves its +// tokenizer family from when --opus47-model is left unset. +const defaultOpus47Model = "claude-opus-4-20250514" + +// --- model counter --------------------------------------------------- + +// modelCounter estimates input tokens with internal/tokens.CountFor — +// the per-model, tokenizer-aware estimator. A Claude model id resolves +// to cl100k_base scaled by the empirically calibrated Claude ratio; +// because it tokenizes the actual payload, the estimate varies +// per-fixture rather than applying one uniform scalar. The cheapest +// strategy: pure in-process compute, no I/O, no network. +type modelCounter struct{ model string } + +func newModelCounter(model string) modelCounter { + if model == "" { + model = defaultOpus47Model + } + return modelCounter{model: model} +} + +func (c modelCounter) Count(_, _, payload string, _ int) (int, bool) { + return tokens.CountFor(c.model, payload), false +} + +// --- cached counter -------------------------------------------------- + +// opus47CacheEntry records exact counts for one fixture's two channels. +// JSON keys mirror the encoder names so the on-disk file is easy to +// edit by hand. +type opus47CacheEntry struct { + JSON int `json:"json"` + GCX int `json:"gcx"` +} + +// opus47Cache is the on-disk shape of `opus47-counts.json` — a map +// keyed by case name. Missing entries are tolerated: the cached +// counter falls through to the model counter and the API counter +// fills the gap on the next `--use-api` run. +type opus47Cache map[string]opus47CacheEntry + +// loadOpus47Cache reads the cache from disk. Returns an empty cache +// (not an error) when the file is missing; surfaces real I/O errors +// so the harness fails loud on permission / disk problems. +func loadOpus47Cache(path string) (opus47Cache, error) { + raw, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return opus47Cache{}, nil + } + if err != nil { + return nil, fmt.Errorf("read opus47 cache %s: %w", path, err) + } + if len(bytes.TrimSpace(raw)) == 0 { + return opus47Cache{}, nil + } + var c opus47Cache + if err := json.Unmarshal(raw, &c); err != nil { + return nil, fmt.Errorf("parse opus47 cache %s: %w", path, err) + } + return c, nil +} + +// saveOpus47Cache writes the cache atomically (tmp+rename) so a +// crash mid-flush doesn't corrupt the file. +func saveOpus47Cache(path string, c opus47Cache) error { + tmp := path + ".tmp" + b, err := json.MarshalIndent(c, "", " ") + if err != nil { + return err + } + if err := os.WriteFile(tmp, b, 0o644); err != nil { + return err + } + return os.Rename(tmp, path) +} + +// cachedCounter consults the cache first, falls through to the model +// counter on miss. Concurrent reads are safe; concurrent writes (via +// the API counter) are serialized through the mutex. +type cachedCounter struct { + mu sync.RWMutex + cache opus47Cache + fallback modelCounter +} + +func newCachedCounter(c opus47Cache, model string) *cachedCounter { + if c == nil { + c = opus47Cache{} + } + return &cachedCounter{cache: c, fallback: newModelCounter(model)} +} + +func (c *cachedCounter) Count(caseName, channel, payload string, cl100k int) (int, bool) { + c.mu.RLock() + entry, ok := c.cache[caseName] + c.mu.RUnlock() + if ok { + switch channel { + case "json": + if entry.JSON > 0 { + return entry.JSON, true + } + case "gcx": + if entry.GCX > 0 { + return entry.GCX, true + } + } + } + return c.fallback.Count(caseName, channel, payload, cl100k) +} + +func (c *cachedCounter) snapshot() opus47Cache { + c.mu.RLock() + defer c.mu.RUnlock() + out := make(opus47Cache, len(c.cache)) + maps.Copy(out, c.cache) + return out +} + +func (c *cachedCounter) store(caseName, channel string, count int) { + c.mu.Lock() + defer c.mu.Unlock() + entry := c.cache[caseName] + switch channel { + case "json": + entry.JSON = count + case "gcx": + entry.GCX = count + } + c.cache[caseName] = entry +} + +// --- API counter ----------------------------------------------------- + +// apiCounter wraps a cachedCounter and falls through to Anthropic's +// `messages/count_tokens` endpoint on cache miss, then stores the +// result for future runs. Network failures degrade to the model +// counter with a warning on stderr — the harness must keep running +// when the user is offline. +type apiCounter struct { + cached *cachedCounter + client *http.Client + apiKey string + model string + apiBase string + warned sync.Once +} + +// opus47APIEndpoint is the documented Anthropic counter endpoint. +const opus47APIEndpoint = "https://api.anthropic.com/v1/messages/count_tokens" + +// opus47AnthropicVersion is the API header required for the +// count_tokens endpoint. Bumping requires verifying the response +// schema still has `input_tokens`. +const opus47AnthropicVersion = "2023-06-01" + +func newAPICounter(cached *cachedCounter, model string) (*apiCounter, error) { + apiKey := os.Getenv("ANTHROPIC_API_KEY") + if apiKey == "" { + return nil, errors.New("--use-api requires ANTHROPIC_API_KEY in environment") + } + if model == "" { + model = defaultOpus47Model + } + return &apiCounter{ + cached: cached, + client: &http.Client{Timeout: 30 * time.Second}, + apiKey: apiKey, + model: model, + apiBase: opus47APIEndpoint, + }, nil +} + +func (a *apiCounter) Count(caseName, channel, payload string, cl100k int) (int, bool) { + if got, ok := a.cached.Count(caseName, channel, payload, cl100k); ok { + return got, true + } + count, err := a.callAPI(payload) + if err != nil { + // Fail soft: warn once, keep ticking with the model counter. + a.warned.Do(func() { + fmt.Fprintf(os.Stderr, "wire-bench: opus47 API counter degraded to in-process model counter after first error: %v\n", err) + }) + return a.cached.fallback.Count(caseName, channel, payload, cl100k) + } + a.cached.store(caseName, channel, count) + return count, true +} + +// apiResponse mirrors the documented response shape; we only care +// about input_tokens but parse strictly so a schema drift surfaces. +type apiResponse struct { + InputTokens int `json:"input_tokens"` +} + +// callAPI POSTs the payload as a single user message and returns the +// integer input-token count. The chat-wrapper overhead (~3-5 tokens +// for the role+system framing) is part of the answer — documenting +// that in the scorecard footnote rather than trying to subtract it +// keeps the harness honest about exactly what it measured. +func (a *apiCounter) callAPI(payload string) (int, error) { + body, _ := json.Marshal(map[string]any{ + "model": a.model, + "messages": []map[string]string{ + {"role": "user", "content": payload}, + }, + }) + req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, a.apiBase, bytes.NewReader(body)) + if err != nil { + return 0, err + } + req.Header.Set("x-api-key", a.apiKey) + req.Header.Set("anthropic-version", opus47AnthropicVersion) + req.Header.Set("content-type", "application/json") + resp, err := a.client.Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + raw, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return 0, fmt.Errorf("opus47 API %d: %s", resp.StatusCode, string(raw)) + } + var r apiResponse + if err := json.Unmarshal(raw, &r); err != nil { + return 0, fmt.Errorf("opus47 API parse: %w", err) + } + if r.InputTokens <= 0 { + return 0, fmt.Errorf("opus47 API returned non-positive count: %s", string(raw)) + } + return r.InputTokens, nil +} diff --git a/bench/wire-format/opus47_test.go b/bench/wire-format/opus47_test.go new file mode 100644 index 0000000..0fc69bb --- /dev/null +++ b/bench/wire-format/opus47_test.go @@ -0,0 +1,241 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync/atomic" + "testing" + + "github.com/zzet/gortex/internal/tokens" +) + +// testModel is a Claude model id used across the offline-counter +// tests; it resolves to the "claude" tokenizer family. +const testModel = "claude-opus-4-20250514" + +func TestModelCounter_UsesPerModelTokenizer(t *testing.T) { + const payload = "func hello() { return \"world\" }" + c := newModelCounter(testModel) + got, exact := c.Count("case", "json", payload, 0) + if want := tokens.CountFor(testModel, payload); got != want { + t.Errorf("modelCounter.Count = %d, want %d (tokens.CountFor)", got, want) + } + if exact { + t.Error("modelCounter.Count must always report exact=false") + } +} + +func TestModelCounter_EmptyModelDefaults(t *testing.T) { + if c := newModelCounter(""); c.model != defaultOpus47Model { + t.Errorf("empty model = %q, want default %q", c.model, defaultOpus47Model) + } +} + +func TestModelCounter_VariesWithPayload(t *testing.T) { + // A real tokenizer estimate must track the payload, not return a + // flat scalar — short and long inputs differ. + c := newModelCounter(testModel) + short, _ := c.Count("s", "json", "x", 0) + long, _ := c.Count("l", "json", "the quick brown fox jumps over the lazy dog", 0) + if long <= short { + t.Errorf("longer payload must score higher: short=%d long=%d", short, long) + } +} + +func TestCachedCounter_HitsCacheReturnsExact(t *testing.T) { + c := newCachedCounter(opus47Cache{ + "case_a": {JSON: 200, GCX: 150}, + }, testModel) + if got, exact := c.Count("case_a", "json", "ignored", 999); got != 200 || !exact { + t.Errorf("cache hit (json) = (%d, %v), want (200, true)", got, exact) + } + if got, exact := c.Count("case_a", "gcx", "ignored", 999); got != 150 || !exact { + t.Errorf("cache hit (gcx) = (%d, %v), want (150, true)", got, exact) + } +} + +func TestCachedCounter_MissFallsBackToModelCounter(t *testing.T) { + const payload = "some representative fixture payload" + c := newCachedCounter(opus47Cache{}, testModel) + got, exact := c.Count("unknown", "json", payload, 100) + if want := tokens.CountFor(testModel, payload); got != want || exact { + t.Errorf("cache miss = (%d, %v), want (%d, false)", got, exact, want) + } +} + +func TestCachedCounter_PartialEntryFallsBackToModelCounter(t *testing.T) { + // Entry exists but only one channel populated → the other channel + // must fall through to the model counter so a partial cache stays + // useful. + const payload = "gcx channel payload" + c := newCachedCounter(opus47Cache{ + "half": {JSON: 200, GCX: 0}, + }, testModel) + got, exact := c.Count("half", "gcx", payload, 100) + if want := tokens.CountFor(testModel, payload); got != want || exact { + t.Errorf("partial cache (gcx empty) = (%d, %v), want (%d, false)", got, exact, want) + } + got, exact = c.Count("half", "json", "ignored", 100) + if got != 200 || !exact { + t.Errorf("partial cache (json populated) = (%d, %v), want (200, true)", got, exact) + } +} + +func TestOpus47Cache_RoundTrip(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "opus47-counts.json") + + want := opus47Cache{ + "case_a": {JSON: 100, GCX: 80}, + "case_b": {JSON: 50, GCX: 40}, + } + if err := saveOpus47Cache(path, want); err != nil { + t.Fatalf("save: %v", err) + } + got, err := loadOpus47Cache(path) + if err != nil { + t.Fatalf("load: %v", err) + } + if len(got) != 2 || got["case_a"].JSON != 100 || got["case_b"].GCX != 40 { + t.Errorf("round-trip lost data: %+v", got) + } +} + +func TestLoadOpus47Cache_MissingFileReturnsEmpty(t *testing.T) { + got, err := loadOpus47Cache(filepath.Join(t.TempDir(), "missing.json")) + if err != nil { + t.Errorf("missing file should not error, got %v", err) + } + if len(got) != 0 { + t.Errorf("missing file = %d entries, want 0", len(got)) + } +} + +func TestLoadOpus47Cache_EmptyFileReturnsEmpty(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "empty.json") + if err := os.WriteFile(path, []byte(" \n"), 0o644); err != nil { + t.Fatal(err) + } + got, err := loadOpus47Cache(path) + if err != nil { + t.Errorf("empty file should not error, got %v", err) + } + if len(got) != 0 { + t.Errorf("empty file = %d entries, want 0", len(got)) + } +} + +func TestNewAPICounter_MissingKeyRejects(t *testing.T) { + t.Setenv("ANTHROPIC_API_KEY", "") + if _, err := newAPICounter(newCachedCounter(nil, testModel), "claude-opus-4"); err == nil { + t.Fatal("expected error when ANTHROPIC_API_KEY is empty") + } +} + +func TestAPICounter_CallsAndCaches(t *testing.T) { + var hits atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + if got := r.Header.Get("x-api-key"); got != "test-key" { + t.Errorf("missing x-api-key header, got %q", got) + } + if got := r.Header.Get("anthropic-version"); got != opus47AnthropicVersion { + t.Errorf("wrong anthropic-version header, got %q", got) + } + w.Header().Set("content-type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]int{"input_tokens": 250}) + })) + defer srv.Close() + + t.Setenv("ANTHROPIC_API_KEY", "test-key") + cached := newCachedCounter(nil, testModel) + api, err := newAPICounter(cached, "claude-opus-4-test") + if err != nil { + t.Fatal(err) + } + api.apiBase = srv.URL + + got, exact := api.Count("case_x", "json", "the payload", 100) + if got != 250 || !exact { + t.Errorf("first API call = (%d, %v), want (250, true)", got, exact) + } + if hits.Load() != 1 { + t.Errorf("expected 1 API hit, got %d", hits.Load()) + } + + // Second call on the same (case, channel) must use the cache, not the API. + got, exact = api.Count("case_x", "json", "the payload", 100) + if got != 250 || !exact { + t.Errorf("cached API call = (%d, %v), want (250, true)", got, exact) + } + if hits.Load() != 1 { + t.Errorf("expected still 1 API hit (cache should serve), got %d", hits.Load()) + } +} + +func TestAPICounter_ErrorFallsBackToModelCounter(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "rate limited", http.StatusTooManyRequests) + })) + defer srv.Close() + + t.Setenv("ANTHROPIC_API_KEY", "test-key") + cached := newCachedCounter(nil, testModel) + api, err := newAPICounter(cached, "claude-opus-4-test") + if err != nil { + t.Fatal(err) + } + api.apiBase = srv.URL + + const payload = "the payload that the API refused to count" + got, exact := api.Count("case_y", "json", payload, 100) + if want := tokens.CountFor(testModel, payload); got != want || exact { + t.Errorf("API error → fallback = (%d, %v), want (%d, false)", got, exact, want) + } +} + +func TestParseTokenizerMode(t *testing.T) { + cases := map[string]tokenizerMode{ + "cl100k": tokenizerModeCL100k, + "cl100k_base": tokenizerModeCL100k, + "opus47": tokenizerModeOpus47, + "opus4.7": tokenizerModeOpus47, + "opus-4-7": tokenizerModeOpus47, + "claude": tokenizerModeOpus47, + "both": tokenizerModeBoth, + "all": tokenizerModeBoth, + "CL100K": tokenizerModeCL100k, // case-insensitive + } + for in, want := range cases { + got, err := parseTokenizerMode(in) + if err != nil { + t.Errorf("parseTokenizerMode(%q) errored: %v", in, err) + continue + } + if got != want { + t.Errorf("parseTokenizerMode(%q) = %d, want %d", in, got, want) + } + } + if _, err := parseTokenizerMode("bogus"); err == nil { + t.Error("expected error for unknown tokenizer name") + } +} + +func TestBuildOpus47Counter_NoCacheUsesModelCounter(t *testing.T) { + counter, cache, err := buildOpus47Counter("", "", false) + if err != nil { + t.Fatalf("buildOpus47Counter: %v", err) + } + if cache != nil { + t.Error("no-cache path should return nil underlying cache") + } + const payload = "no-cache fixture payload" + if got, exact := counter.Count("c", "json", payload, 100); got != tokens.CountFor(defaultOpus47Model, payload) || exact { + t.Errorf("no-cache counter = (%d, %v), want (%d, false) — in-process model counter", + got, exact, tokens.CountFor(defaultOpus47Model, payload)) + } +} diff --git a/bench/wire-format/scorecard.md b/bench/wire-format/scorecard.md new file mode 100644 index 0000000..6482445 --- /dev/null +++ b/bench/wire-format/scorecard.md @@ -0,0 +1,55 @@ +# GCX1 wire-format benchmark scorecard + +## tiktoken cl100k_base (Claude 3 / Opus 4 / Sonnet 4 / Haiku 4.5 / GPT-4o family) + +| case | tool | bytes (json) | bytes (gcx) | Δ% | tokens (json) | tokens (gcx) | Δ% | gzip (json) | gzip (gcx) | Δ% | round-trip | +|------|------|-------------:|------------:|---:|--------------:|-------------:|---:|------------:|-----------:|---:|:---------:| +| 01_search_symbols_small | search_symbols | 720 | 522 | −27.5% | 181 | 125 | −30.9% | 219 | 227 | +3.7% | ✓ | +| 02_search_symbols_large | search_symbols | 4201 | 2924 | −30.4% | 1068 | 742 | −30.5% | 850 | 823 | −3.2% | ✓ | +| 03_get_symbol_source_small | get_symbol_source | 731 | 731 | −0.0% | 257 | 255 | −0.8% | 359 | 372 | +3.6% | ✓ | +| 04_get_symbol_source_large | get_symbol_source | 1691 | 1663 | −1.7% | 534 | 532 | −0.4% | 678 | 689 | +1.6% | ✓ | +| 05_batch_symbols | batch_symbols | 1077 | 702 | −34.8% | 335 | 241 | −28.1% | 450 | 423 | −6.0% | ✓ | +| 06_find_usages_small | find_usages | 1287 | 989 | −23.2% | 335 | 255 | −23.9% | 339 | 327 | −3.5% | ✓ | +| 07_find_usages_large | find_usages | 1783 | 920 | −48.4% | 569 | 351 | −38.3% | 271 | 261 | −3.7% | ✓ | +| 08_get_file_summary | get_file_summary | 2172 | 1599 | −26.4% | 567 | 431 | −24.0% | 575 | 560 | −2.6% | ✓ | +| 09_analyze_hotspots | analyze_hotspots | 1731 | 1037 | −40.1% | 506 | 318 | −37.2% | 500 | 449 | −10.2% | ✓ | +| 10_analyze_dead_code | analyze_dead_code | 1132 | 825 | −27.1% | 288 | 198 | −31.2% | 365 | 355 | −2.7% | ✓ | +| 11_contracts_list | contracts | 2296 | 1562 | −32.0% | 580 | 463 | −20.2% | 630 | 608 | −3.5% | ✓ | +| 12_get_callers_medium | get_callers | 3021 | 2204 | −27.0% | 750 | 532 | −29.1% | 450 | 427 | −5.1% | ✓ | +| 13_smart_context | smart_context | 1816 | 1178 | −35.1% | 471 | 299 | −36.5% | 585 | 494 | −15.6% | ✓ | +| 14_get_dependents_small | get_dependents | 1269 | 929 | −26.8% | 329 | 239 | −27.4% | 318 | 307 | −3.5% | ✓ | +| 15_get_test_targets | get_test_targets | 1256 | 997 | −20.6% | 311 | 262 | −15.8% | 340 | 357 | +5.0% | ✓ | +| 16_find_implementations | find_implementations | 936 | 690 | −26.3% | 224 | 157 | −29.9% | 247 | 259 | +4.9% | ✓ | +| 17_find_cycles | analyze_cycles | 224 | 192 | −14.3% | 87 | 90 | +3.4% | 145 | 165 | +13.8% | ✓ | +| 18_graph_stats | graph_stats | 494 | 512 | +3.6% | 162 | 174 | +7.4% | 335 | 368 | +9.9% | ✓ | +| 19_get_editing_context | get_editing_context | 927 | 708 | −23.6% | 233 | 171 | −26.6% | 329 | 318 | −3.3% | ✓ | +| 20_get_repo_outline | get_repo_outline | 784 | 690 | −12.0% | 237 | 224 | −5.5% | 358 | 363 | +1.4% | ✓ | + +**Summary (cl100k_base):** 20/20 cases. Median token savings: −27.4%. Median byte savings: −26.8%. Round-trip integrity: 20/20. + +## Claude Opus 4.7 (estimated (×1.35 scalar over cl100k_base)) + +| case | tool | tokens (json) | tokens (gcx) | Δ% | source | +|------|------|--------------:|-------------:|---:|:------:| +| 01_search_symbols_small | search_symbols | 244 | 169 | −30.7% | est. | +| 02_search_symbols_large | search_symbols | 1442 | 1002 | −30.5% | est. | +| 03_get_symbol_source_small | get_symbol_source | 347 | 344 | −0.9% | est. | +| 04_get_symbol_source_large | get_symbol_source | 721 | 718 | −0.4% | est. | +| 05_batch_symbols | batch_symbols | 452 | 325 | −28.1% | est. | +| 06_find_usages_small | find_usages | 452 | 344 | −23.9% | est. | +| 07_find_usages_large | find_usages | 768 | 474 | −38.3% | est. | +| 08_get_file_summary | get_file_summary | 765 | 582 | −23.9% | est. | +| 09_analyze_hotspots | analyze_hotspots | 683 | 429 | −37.2% | est. | +| 10_analyze_dead_code | analyze_dead_code | 389 | 267 | −31.4% | est. | +| 11_contracts_list | contracts | 783 | 625 | −20.2% | est. | +| 12_get_callers_medium | get_callers | 1013 | 718 | −29.1% | est. | +| 13_smart_context | smart_context | 636 | 404 | −36.5% | est. | +| 14_get_dependents_small | get_dependents | 444 | 323 | −27.3% | est. | +| 15_get_test_targets | get_test_targets | 420 | 354 | −15.7% | est. | +| 16_find_implementations | find_implementations | 302 | 212 | −29.8% | est. | +| 17_find_cycles | analyze_cycles | 117 | 122 | +4.3% | est. | +| 18_graph_stats | graph_stats | 219 | 235 | +7.3% | est. | +| 19_get_editing_context | get_editing_context | 315 | 231 | −26.7% | est. | +| 20_get_repo_outline | get_repo_outline | 320 | 302 | −5.6% | est. | + +**Summary (Opus 4.7):** 20/20 cases. Median token savings: −27.3%. Exact rows: 0/20 (rest estimated via ×1.35 scalar). diff --git a/cmd/gortex/affected.go b/cmd/gortex/affected.go new file mode 100644 index 0000000..0c255f4 --- /dev/null +++ b/cmd/gortex/affected.go @@ -0,0 +1,211 @@ +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "sort" + "strings" + + "github.com/spf13/cobra" +) + +// exitNoAffected is the affected verb's CI sentinel: the change touched nothing +// the test graph covers, so a selective-test runner can skip the suite. It is +// distinct from the generic error exit (1) so a CI script can tell "no tests to +// run" apart from "the command failed". +const exitNoAffected = 3 + +var ( + affectedStdin bool + affectedJSON bool + affectedQuiet bool + affectedIndex string +) + +var affectedCmd = &cobra.Command{ + Use: "affected [files...]", + Short: "List the test files affected by a set of changed files", + Long: `Resolve which test files cover the symbols in a set of changed files, by +walking the graph's test edges on the daemon that tracks the repo. + +Built for CI / git-hook piping: pass changed paths as args or via --stdin +(e.g. ` + "`git diff --name-only | gortex affected --stdin --quiet`" + `), and +branch on the exit code: + + exit 0 one or more test files are affected — run them + exit 3 nothing the test graph covers changed — skip the suite + exit 1 the command failed (no daemon, repo not tracked, …)`, + SilenceErrors: true, + SilenceUsage: true, + RunE: runAffected, +} + +func init() { + affectedCmd.Flags().BoolVar(&affectedStdin, "stdin", false, "read changed file paths from stdin (whitespace/newline separated)") + affectedCmd.Flags().BoolVar(&affectedJSON, "json", false, "emit the affected test files as JSON") + affectedCmd.Flags().BoolVarP(&affectedQuiet, "quiet", "q", false, "print nothing; communicate only via the exit code") + affectedCmd.Flags().StringVar(&affectedIndex, "index", "", "repository path the daemon tracks (default: current directory)") + rootCmd.AddCommand(affectedCmd) +} + +func runAffected(cmd *cobra.Command, args []string) error { + changed := append([]string(nil), args...) + if affectedStdin { + changed = append(changed, parseAffectedStdin(cmd.InOrStdin())...) + } + changed = dedupeNonEmpty(changed) + if len(changed) == 0 { + return fmt.Errorf("affected: no changed files given (pass paths as args or --stdin)") + } + + repoPath := affectedIndex + if repoPath == "" { + repoPath = "." + } + + targets, err := resolveAffectedTests(repoPath, changed) + if err != nil { + return err + } + + if !affectedQuiet { + if affectedJSON { + _ = json.NewEncoder(cmd.OutOrStdout()).Encode(map[string]any{"affected_tests": targets}) + } else { + for _, t := range targets { + fmt.Fprintln(cmd.OutOrStdout(), t) + } + } + } + + if code := affectedExitCode(len(targets)); code != 0 { + return &exitCodeError{code: code} + } + return nil +} + +// affectedExitCode maps an affected-test count to the CI sentinel exit code: +// 0 when something is affected (run the tests), exitNoAffected when nothing is. +func affectedExitCode(targetCount int) int { + if targetCount > 0 { + return 0 + } + return exitNoAffected +} + +// parseAffectedStdin reads whitespace/newline-separated file paths from r. It +// tolerates `git diff --name-only` output (one path per line) and a space- +// separated list on a single line equally. +func parseAffectedStdin(r io.Reader) []string { + var out []string + sc := bufio.NewScanner(r) + for sc.Scan() { + out = append(out, strings.Fields(sc.Text())...) + } + return out +} + +// dedupeNonEmpty trims, drops blanks, and dedupes while preserving order. +func dedupeNonEmpty(in []string) []string { + seen := make(map[string]bool, len(in)) + out := make([]string, 0, len(in)) + for _, s := range in { + s = strings.TrimSpace(s) + if s == "" || seen[s] { + continue + } + seen[s] = true + out = append(out, s) + } + return out +} + +// resolveAffectedTests maps changed files to the test files that cover them: it +// asks the daemon for each changed file's symbol IDs (get_file_summary), then +// resolves the covering tests in one get_test_targets call. The returned test +// file paths are unique and sorted. +func resolveAffectedTests(repoPath string, changedFiles []string) ([]string, error) { + idSet := make(map[string]bool) + for _, f := range changedFiles { + raw, err := requireDaemonTool(repoPath, "get_file_summary", map[string]any{"file_path": f}) + if err != nil { + return nil, err + } + for _, id := range collectSymbolIDs(raw) { + idSet[id] = true + } + } + if len(idSet) == 0 { + return nil, nil // no indexed symbols in the changed files → nothing affected + } + ids := make([]string, 0, len(idSet)) + for id := range idSet { + ids = append(ids, id) + } + sort.Strings(ids) + + raw, err := requireDaemonTool(repoPath, "get_test_targets", map[string]any{"ids": strings.Join(ids, ",")}) + if err != nil { + return nil, err + } + return parseTestTargetFiles(raw), nil +} + +// parseTestTargetFiles extracts the unique, sorted test file paths from a +// get_test_targets response (its `test_targets[].file` list). +func parseTestTargetFiles(raw []byte) []string { + var resp struct { + TestTargets []struct { + File string `json:"file"` + } `json:"test_targets"` + } + if json.Unmarshal(raw, &resp) != nil { + return nil + } + seen := make(map[string]bool) + var files []string + for _, t := range resp.TestTargets { + if t.File != "" && !seen[t.File] { + seen[t.File] = true + files = append(files, t.File) + } + } + sort.Strings(files) + return files +} + +// collectSymbolIDs walks a JSON document and gathers the string values under +// any "id" key that have the symbol-id shape (contain "::"), deduped. Tolerant +// of the differing get_file_summary shapes across versions. +func collectSymbolIDs(raw []byte) []string { + var doc any + if json.Unmarshal(raw, &doc) != nil { + return nil + } + seen := make(map[string]bool) + var out []string + var walk func(any) + walk = func(n any) { + switch t := n.(type) { + case map[string]any: + for k, v := range t { + if k == "id" { + if sv, ok := v.(string); ok && strings.Contains(sv, "::") && !seen[sv] { + seen[sv] = true + out = append(out, sv) + continue + } + } + walk(v) + } + case []any: + for _, e := range t { + walk(e) + } + } + } + walk(doc) + return out +} diff --git a/cmd/gortex/affected_test.go b/cmd/gortex/affected_test.go new file mode 100644 index 0000000..8d03f7e --- /dev/null +++ b/cmd/gortex/affected_test.go @@ -0,0 +1,36 @@ +package main + +import ( + "reflect" + "strings" + "testing" +) + +// TestAffectedStdinExitCode covers the affected verb's CI contract: the +// exit-code mapping, stdin parsing, input dedupe, and test-target extraction. +func TestAffectedStdinExitCode(t *testing.T) { + // Exit-code contract: something affected → 0 (run tests); nothing → sentinel. + if got := affectedExitCode(0); got != exitNoAffected { + t.Errorf("affectedExitCode(0) = %d, want %d", got, exitNoAffected) + } + if got := affectedExitCode(3); got != 0 { + t.Errorf("affectedExitCode(3) = %d, want 0", got) + } + + // Stdin parsing tolerates newline- and space-separated paths + blank lines. + got := parseAffectedStdin(strings.NewReader("a.go\nb.go c.go\n\n d.go \n")) + if want := []string{"a.go", "b.go", "c.go", "d.go"}; !reflect.DeepEqual(got, want) { + t.Errorf("parseAffectedStdin = %v, want %v", got, want) + } + + // dedupeNonEmpty trims, drops blanks, dedupes, preserves order. + if d := dedupeNonEmpty([]string{" a ", " a ", "", "b"}); !reflect.DeepEqual(d, []string{"a", "b"}) { + t.Errorf("dedupeNonEmpty = %v, want [a b]", d) + } + + // parseTestTargetFiles pulls unique, sorted test files from the payload. + raw := []byte(`{"test_targets":[{"file":"z_test.go","functions":["T1"]},{"file":"a_test.go"},{"file":"z_test.go"}]}`) + if files := parseTestTargetFiles(raw); !reflect.DeepEqual(files, []string{"a_test.go", "z_test.go"}) { + t.Errorf("parseTestTargetFiles = %v, want [a_test.go z_test.go]", files) + } +} diff --git a/cmd/gortex/agent_summary.go b/cmd/gortex/agent_summary.go new file mode 100644 index 0000000..857d2b2 --- /dev/null +++ b/cmd/gortex/agent_summary.go @@ -0,0 +1,155 @@ +package main + +import ( + "fmt" + "io" + "strconv" + "strings" + + "github.com/zzet/gortex/internal/agents" + "github.com/zzet/gortex/internal/progress" +) + +// emitAgentSummary writes the post-install / post-init summary as a styled +// block: totals strip, configured editors with per-action breakdown, a chip +// line of editors that weren't detected, and a numbered list of next steps. +// Used by both `gortex install` and `gortex init` for visual consistency. +func emitAgentSummary(w io.Writer, results []*agents.Result, opts agents.ApplyOpts, nextSteps []string) { + var detected []*agents.Result + var notDetected []string + var totals actionCounts + for _, r := range results { + if r == nil { + continue + } + if !r.Detected { + notDetected = append(notDetected, r.Name) + continue + } + detected = append(detected, r) + totals.add(r.Files) + } + + fmt.Fprintln(w) + if !totals.zero() || opts.DryRun { + fmt.Fprintln(w, " "+totalsStrip(totals, opts.DryRun)) + fmt.Fprintln(w) + } + + if len(detected) > 0 { + fmt.Fprintln(w, " "+progress.Heading("configured", strconv.Itoa(len(detected)))) + for _, r := range detected { + detail := perAdapterDetail(r.Files) + fmt.Fprintln(w, " "+progress.Row(r.Name, detail, 14)) + } + fmt.Fprintln(w) + } + + // Non-fatal problems an adapter continued past (e.g. a profile config + // that failed to write) — surfaced here rather than buried in stderr. + var warned []*agents.Result + for _, r := range detected { + if len(r.Warnings) > 0 { + warned = append(warned, r) + } + } + if len(warned) > 0 { + fmt.Fprintln(w, " "+progress.Heading("warnings")) + for _, r := range warned { + for _, msg := range r.Warnings { + fmt.Fprintln(w, " "+progress.Row(r.Name, msg, 14)) + } + } + fmt.Fprintln(w) + } + + if len(notDetected) > 0 { + names := progress.SortStrings(notDetected) + fmt.Fprintln(w, " "+progress.Heading("not detected", strconv.Itoa(len(notDetected)))) + fmt.Fprintln(w, " "+progress.Chips(names, 0)) + fmt.Fprintln(w) + } + + if len(nextSteps) > 0 { + fmt.Fprintln(w, " "+progress.Heading("next steps")) + for i, s := range nextSteps { + fmt.Fprintln(w, " "+progress.NumberedStep(i+1, s)) + } + fmt.Fprintln(w) + } +} + +// actionCounts tallies file actions across all adapters. +type actionCounts struct { + create, merge, skip, wouldCreate, wouldMerge int +} + +func (c *actionCounts) add(files []agents.FileAction) { + for _, f := range files { + switch f.Action { + case agents.ActionCreate: + c.create++ + case agents.ActionMerge: + c.merge++ + case agents.ActionSkip: + c.skip++ + case agents.ActionWouldCreate: + c.wouldCreate++ + case agents.ActionWouldMerge: + c.wouldMerge++ + } + } +} + +func (c actionCounts) zero() bool { + return c.create+c.merge+c.skip+c.wouldCreate+c.wouldMerge == 0 +} + +// totalsStrip renders e.g. "21 would-create · 2 would-merge · 12 skip (dry-run)". +func totalsStrip(c actionCounts, dryRun bool) string { + var stats []string + if c.wouldCreate > 0 { + stats = append(stats, progress.Stat(strconv.Itoa(c.wouldCreate), "would-create", progress.StatGood)) + } + if c.create > 0 { + stats = append(stats, progress.Stat(strconv.Itoa(c.create), "created", progress.StatGood)) + } + if c.wouldMerge > 0 { + stats = append(stats, progress.Stat(strconv.Itoa(c.wouldMerge), "would-merge", progress.StatNeutral)) + } + if c.merge > 0 { + stats = append(stats, progress.Stat(strconv.Itoa(c.merge), "merged", progress.StatNeutral)) + } + if c.skip > 0 { + stats = append(stats, progress.Stat(strconv.Itoa(c.skip), "skipped", progress.StatNeutral)) + } + out := progress.StatStrip(stats...) + if dryRun { + out += " " + progress.Caption("dry-run — no files written") + } + return out +} + +// perAdapterDetail compresses a single adapter's file list into a readable +// "create 3 · merge 1 · skip 2" string, ordered by significance. +func perAdapterDetail(files []agents.FileAction) string { + var c actionCounts + c.add(files) + parts := []string{} + if c.wouldCreate > 0 { + parts = append(parts, fmt.Sprintf("would-create %d", c.wouldCreate)) + } + if c.create > 0 { + parts = append(parts, fmt.Sprintf("created %d", c.create)) + } + if c.wouldMerge > 0 { + parts = append(parts, fmt.Sprintf("would-merge %d", c.wouldMerge)) + } + if c.merge > 0 { + parts = append(parts, fmt.Sprintf("merged %d", c.merge)) + } + if c.skip > 0 { + parts = append(parts, fmt.Sprintf("skipped %d", c.skip)) + } + return strings.Join(parts, " · ") +} diff --git a/cmd/gortex/agents.go b/cmd/gortex/agents.go new file mode 100644 index 0000000..45571eb --- /dev/null +++ b/cmd/gortex/agents.go @@ -0,0 +1,104 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/spf13/cobra" + + "github.com/zzet/gortex/internal/agents" +) + +// agents.go is the `gortex agents` command tree. Its primary job is the +// skill-render drift fence (`gortex agents render`): render every agent +// adapter's generated artifacts so drift across all platforms — not +// just the committed Claude plugin bundle — is reviewable. + +var agentsCmd = &cobra.Command{ + Use: "agents", + Short: "Inspect and validate Gortex agent integrations", +} + +var ( + agentsRenderTarget string + agentsRenderCheck bool +) + +var agentsRenderCmd = &cobra.Command{ + Use: "render", + Short: "Render every adapter's generated artifacts (skill-render drift fence)", + Long: "Render the artifacts every agent adapter would install into a " + + "normalized, machine-independent manifest per adapter. Use --target " + + "to dump them for review (or to refresh the drift-test goldens), and " + + "--check as a CI gate that every adapter still renders a gortex " + + "registration.", + Args: cobra.NoArgs, + RunE: runAgentsRender, +} + +func init() { + agentsRenderCmd.Flags().StringVar(&agentsRenderTarget, "target", "", + "write each adapter's manifest to /.txt") + agentsRenderCmd.Flags().BoolVar(&agentsRenderCheck, "check", false, + "fail if any adapter renders nothing or drops its gortex registration") + agentsCmd.AddCommand(agentsRenderCmd) + rootCmd.AddCommand(agentsCmd) +} + +func runAgentsRender(cmd *cobra.Command, _ []string) error { + reg := buildRegistry() + manifests, err := agents.RenderManifest(reg.All()) + if err != nil { + return err + } + names := make([]string, 0, len(manifests)) + for n := range manifests { + names = append(names, n) + } + sort.Strings(names) + + if agentsRenderTarget != "" { + if err := os.MkdirAll(agentsRenderTarget, 0o755); err != nil { + return err + } + for _, n := range names { + path := filepath.Join(agentsRenderTarget, n+".txt") + if err := os.WriteFile(path, []byte(manifests[n]), 0o644); err != nil { + return err + } + } + fmt.Fprintf(cmd.OutOrStdout(), "Wrote %d adapter manifests to %s\n", len(names), agentsRenderTarget) + } + + if agentsRenderCheck { + var problems []string + for _, n := range names { + m := manifests[n] + if strings.TrimSpace(m) == "" { + problems = append(problems, n+": rendered no artifacts") + continue + } + if !agents.RenderContainsRegistration(m) { + problems = append(problems, n+": rendered output carries no gortex registration") + } + } + if len(problems) > 0 { + for _, p := range problems { + fmt.Fprintf(cmd.ErrOrStderr(), "drift: %s\n", p) + } + return fmt.Errorf("agents render --check failed for %d adapter(s)", len(problems)) + } + fmt.Fprintf(cmd.OutOrStdout(), "OK: all %d adapters render a gortex registration\n", len(names)) + return nil + } + + if agentsRenderTarget == "" { + for _, n := range names { + fmt.Fprintf(cmd.OutOrStdout(), "%-14s %d bytes\n", n, len(manifests[n])) + } + } + return nil +} diff --git a/cmd/gortex/agents_render_test.go b/cmd/gortex/agents_render_test.go new file mode 100644 index 0000000..00d9b87 --- /dev/null +++ b/cmd/gortex/agents_render_test.go @@ -0,0 +1,71 @@ +package main + +import ( + "flag" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/zzet/gortex/internal/agents" +) + +// updateAgentRender regenerates the committed agent-render goldens. +// Run: go test ./cmd/gortex -run TestAgentsRenderGolden -update-agent-render +var updateAgentRender = flag.Bool("update-agent-render", false, "regenerate the agent-render drift-fence goldens") + +const agentRenderDir = "testdata/agent-render" + +// TestAgentsRenderGolden is the all-platform skill-render drift fence: +// it renders every registered adapter and byte-compares the result +// against a committed golden. Any change to an adapter's generated MCP +// config, instructions, hooks, or routing blocks must be accompanied by +// a regenerated golden, so cross-platform drift is reviewable in the +// diff — not just for the Claude bundle. +func TestAgentsRenderGolden(t *testing.T) { + manifests, err := agents.RenderManifest(buildRegistry().All()) + if err != nil { + t.Fatalf("render adapters: %v", err) + } + + if *updateAgentRender { + if err := os.MkdirAll(agentRenderDir, 0o755); err != nil { + t.Fatal(err) + } + for name, got := range manifests { + if err := os.WriteFile(filepath.Join(agentRenderDir, name+".txt"), []byte(got), 0o644); err != nil { + t.Fatal(err) + } + } + t.Logf("regenerated %d agent-render goldens", len(manifests)) + return + } + + for name, got := range manifests { + golden := filepath.Join(agentRenderDir, name+".txt") + want, err := os.ReadFile(golden) + if err != nil { + t.Errorf("%s: missing golden %s — regenerate with `go test ./cmd/gortex -run TestAgentsRenderGolden -update-agent-render`", name, golden) + continue + } + if string(want) != got { + t.Errorf("%s: rendered output drifted from %s.\nReview the change; if intended, regenerate goldens:\n go test ./cmd/gortex -run TestAgentsRenderGolden -update-agent-render", name, golden) + } + } + + // A golden with no corresponding adapter means an adapter was + // removed without dropping its golden — flag it. + entries, err := os.ReadDir(agentRenderDir) + if err != nil { + t.Fatal(err) + } + for _, e := range entries { + name := strings.TrimSuffix(e.Name(), ".txt") + if name == e.Name() { + continue // not a .txt golden + } + if _, ok := manifests[name]; !ok { + t.Errorf("stale golden %s has no matching adapter — delete it or restore the adapter", e.Name()) + } + } +} diff --git a/cmd/gortex/analyze.go b/cmd/gortex/analyze.go new file mode 100644 index 0000000..15ec71e --- /dev/null +++ b/cmd/gortex/analyze.go @@ -0,0 +1,165 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + gortexmcp "github.com/zzet/gortex/internal/mcp" +) + +// analyzeDaemonTool is the daemon-tool relay seam. It is indirected through a +// package var so tests can stub the daemon call (asserting the lowered +// tool + args) without a running daemon. +var analyzeDaemonTool = requireDaemonTool + +var ( + analyzeIndex string + analyzeFormat string + analyzeKind string + analyzeLimit int + analyzeCompact bool + analyzePathPrefix string + analyzeArgs []string +) + +var analyzeCmd = &cobra.Command{ + Use: "analyze", + Short: "Run the unified graph-analysis dispatcher (analyze) by kind", + Long: `Runs the daemon's unified analyze tool — one dispatcher over every +structural / quality / security analyzer. Pick the analyzer with --kind; the +valid kinds are listed by 'gortex analyze kinds'. + +Universal typed flags cover the common parameters: --format, --limit, --compact, +and --path-prefix. Kind-specific parameters ride on --arg key=value (repeatable), +using the same deterministic coercion as 'gortex call': + + gortex analyze --kind hotspots --arg threshold:=0.8 --limit 5 + gortex analyze --kind todos --arg tag=FIXME --arg has_assignee=true + gortex analyze --kind coverage_gaps --path-prefix internal/auth/ --arg max_pct:=80 + +--arg coercion: true/false -> bool, an integer or float -> number, null -> null, +a value starting with [ or { -> parsed JSON, key:= forces a raw-JSON parse +of the right-hand side, key= -> the empty string, and anything else stays a +string. A --arg pair overrides the matching universal typed flag. + +Requires a running daemon that tracks the repo.`, + // A daemon-required / flag-validation error is self-explanatory; don't + // bury it under the full usage dump. + SilenceUsage: true, + RunE: runAnalyze, +} + +// analyzeKindsCmd lists the valid analyze kinds straight from the in-process +// SSOT — no daemon needed. +var analyzeKindsCmd = &cobra.Command{ + Use: "kinds", + Short: "List the valid analyze kinds with a one-line description (no daemon needed)", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + kinds := gortexmcp.AnalyzeKinds() + + // Width the kind column to the widest name so the descriptions + // line up into a readable two-column reference listing. + width := 0 + for _, k := range kinds { + if len(k) > width { + width = len(k) + } + } + + out := cmd.OutOrStdout() + for _, k := range kinds { + if desc := gortexmcp.AnalyzeKindDescription(k); desc != "" { + fmt.Fprintf(out, "%-*s %s\n", width, k, desc) + } else { + fmt.Fprintln(out, k) + } + } + return nil + }, +} + +func init() { + analyzeCmd.PersistentFlags().StringVar(&analyzeIndex, "index", ".", "repository path the daemon must track") + analyzeCmd.PersistentFlags().StringVar(&analyzeIndex, "repo", ".", "alias for --index") + analyzeCmd.Flags().StringVar(&analyzeKind, "kind", "", "analysis kind (required); see 'gortex analyze kinds'") + analyzeCmd.Flags().StringVar(&analyzeFormat, "format", "json", "output / wire format: json|gcx|toon|text") + analyzeCmd.Flags().IntVar(&analyzeLimit, "limit", 0, "cap the number of rows returned (kind-dependent default)") + analyzeCmd.Flags().BoolVar(&analyzeCompact, "compact", false, "one-line-per-result text output") + analyzeCmd.Flags().StringVar(&analyzePathPrefix, "path-prefix", "", "scope to nodes under this file-path prefix (path_prefix)") + analyzeCmd.Flags().StringArrayVar(&analyzeArgs, "arg", nil, "add one kind-specific key=value argument (repeatable); see help for coercion rules") + + analyzeCmd.AddCommand(analyzeKindsCmd) + rootCmd.AddCommand(analyzeCmd) +} + +func runAnalyze(cmd *cobra.Command, _ []string) error { + if analyzeKind == "" { + return fmt.Errorf("--kind is required; run `gortex analyze kinds` to list the valid kinds") + } + if !validAnalyzeKind(analyzeKind) { + return unknownAnalyzeKindErr(analyzeKind) + } + + // Start with the kind and the universal typed flags (only when the user + // actually set them, so the daemon's kind-specific defaults hold). + toolArgs := map[string]any{"kind": analyzeKind} + if cmd.Flags().Changed("limit") { + toolArgs["limit"] = analyzeLimit + } + if cmd.Flags().Changed("compact") { + toolArgs["compact"] = analyzeCompact + } + if cmd.Flags().Changed("path-prefix") { + toolArgs["path_prefix"] = analyzePathPrefix + } + + // Overlay --arg key=value pairs on top — they win over the typed flags so a + // user can always reach a parameter the universal flags don't cover. + for _, kv := range analyzeArgs { + key, val, err := coerceArg(kv) + if err != nil { + return err + } + toolArgs[key] = val + } + + // Forward the chosen wire format. The executor pins format=json by default; + // an explicit format here overrides it. + if analyzeFormat != "" { + toolArgs["format"] = analyzeFormat + } + + raw, err := analyzeDaemonTool(analyzeIndex, "analyze", toolArgs) + if err != nil { + return err + } + + switch analyzeFormat { + case "gcx", "toon": + // Compact wire formats are printed verbatim — re-indenting corrupts them. + fmt.Fprintln(cmd.OutOrStdout(), strings.TrimRight(string(raw), "\n")) + return nil + default: // json | text + return emitDaemonJSON(cmd, raw) + } +} + +// validAnalyzeKind reports whether kind is one of the canonical analyze kinds. +func validAnalyzeKind(kind string) bool { + for _, k := range gortexmcp.AnalyzeKinds() { + if k == kind { + return true + } + } + return false +} + +// unknownAnalyzeKindErr builds the actionable error for an unknown --kind: it +// lists the valid kinds and points at `gortex analyze kinds`. +func unknownAnalyzeKindErr(kind string) error { + return fmt.Errorf("unknown analyze kind %q — valid kinds: %s\nRun `gortex analyze kinds` to list them", + kind, strings.Join(gortexmcp.AnalyzeKinds(), ", ")) +} diff --git a/cmd/gortex/analyze_test.go b/cmd/gortex/analyze_test.go new file mode 100644 index 0000000..2fda304 --- /dev/null +++ b/cmd/gortex/analyze_test.go @@ -0,0 +1,173 @@ +package main + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// capturedAnalyze records the (repo, tool, args) the analyze verb lowered to. +type capturedAnalyze struct { + repo string + tool string + args map[string]any +} + +// runAnalyzeCmd drives the real command tree (rootCmd → analyze) with the given +// argv, stubbing the daemon seam so the call never leaves the process. Returns +// the captured call (or nil if none was made), the combined out/err buffer, and +// any error from RunE. +func runAnalyzeCmd(t *testing.T, argv ...string) (*capturedAnalyze, *bytes.Buffer, error) { + t.Helper() + resetAnalyzeFlags() + + orig := analyzeDaemonTool + t.Cleanup(func() { analyzeDaemonTool = orig }) + + var cap *capturedAnalyze + analyzeDaemonTool = func(repo, tool string, args map[string]any) (json.RawMessage, error) { + cap = &capturedAnalyze{repo: repo, tool: tool, args: args} + return json.RawMessage(`{"status":"ok"}`), nil + } + + buf := &bytes.Buffer{} + rootCmd.SetOut(buf) + rootCmd.SetErr(buf) + rootCmd.SetIn(strings.NewReader("")) + rootCmd.SetArgs(append([]string{"analyze"}, argv...)) + err := rootCmd.Execute() + return cap, buf, err +} + +func resetAnalyzeFlags() { + resetCobraFlags(analyzeCmd) + analyzeIndex = "." + analyzeFormat = "json" + analyzeKind = "" + analyzeLimit = 0 + analyzeCompact = false + analyzePathPrefix = "" + analyzeArgs = nil +} + +// TestAnalyze_KindArgAndUniversalFlags asserts the kind, universal --limit, and +// a --arg coerced number all lower correctly onto the analyze tool. +func TestAnalyze_KindArgAndUniversalFlags(t *testing.T) { + cap, _, err := runAnalyzeCmd(t, + "--kind", "hotspots", "--arg", "threshold:=0.8", "--limit", "5") + require.NoError(t, err) + require.NotNil(t, cap) + require.Equal(t, "analyze", cap.tool) + require.Equal(t, "hotspots", cap.args["kind"]) + require.Equal(t, 0.8, cap.args["threshold"]) // walrus raw-JSON number + require.Equal(t, 5, cap.args["limit"]) + require.Equal(t, "json", cap.args["format"]) +} + +// TestAnalyze_PathPrefixAndCompact asserts the typed --path-prefix and --compact +// flags lower to path_prefix / compact. +func TestAnalyze_PathPrefixAndCompact(t *testing.T) { + cap, _, err := runAnalyzeCmd(t, + "--kind", "ownership", "--path-prefix", "internal/auth/", "--compact") + require.NoError(t, err) + require.Equal(t, "ownership", cap.args["kind"]) + require.Equal(t, "internal/auth/", cap.args["path_prefix"]) + require.Equal(t, true, cap.args["compact"]) +} + +// TestAnalyze_OnlyChangedTypedFlagsSent asserts unset universal flags are not +// forwarded, so the daemon's kind-specific defaults hold. +func TestAnalyze_OnlyChangedTypedFlagsSent(t *testing.T) { + cap, _, err := runAnalyzeCmd(t, "--kind", "dead_code") + require.NoError(t, err) + require.Equal(t, "dead_code", cap.args["kind"]) + _, hasLimit := cap.args["limit"] + require.False(t, hasLimit, "unset --limit must not be forwarded") + _, hasCompact := cap.args["compact"] + require.False(t, hasCompact, "unset --compact must not be forwarded") + _, hasPrefix := cap.args["path_prefix"] + require.False(t, hasPrefix, "unset --path-prefix must not be forwarded") +} + +// TestAnalyze_ArgOverridesTypedFlag asserts a --arg pair wins over the matching +// universal typed flag. +func TestAnalyze_ArgOverridesTypedFlag(t *testing.T) { + cap, _, err := runAnalyzeCmd(t, + "--kind", "cross_repo", "--limit", "10", "--arg", "limit=99") + require.NoError(t, err) + require.Equal(t, int64(99), cap.args["limit"], "--arg must override --limit") +} + +// TestAnalyze_BogusKindClientSideError asserts an invalid --kind is rejected +// client-side (no daemon call) with a message listing valid kinds. +func TestAnalyze_BogusKindClientSideError(t *testing.T) { + called := false + orig := analyzeDaemonTool + t.Cleanup(func() { analyzeDaemonTool = orig }) + resetAnalyzeFlags() + analyzeDaemonTool = func(string, string, map[string]any) (json.RawMessage, error) { + called = true + return nil, nil + } + + buf := &bytes.Buffer{} + rootCmd.SetOut(buf) + rootCmd.SetErr(buf) + rootCmd.SetIn(strings.NewReader("")) + rootCmd.SetArgs([]string{"analyze", "--kind", "bogus"}) + err := rootCmd.Execute() + require.Error(t, err) + require.False(t, called, "an invalid --kind must NOT call the daemon") + require.Contains(t, err.Error(), "unknown analyze kind") + require.Contains(t, err.Error(), "hotspots", "the error should list valid kinds") +} + +// TestAnalyze_MissingKindError asserts --kind is required. +func TestAnalyze_MissingKindError(t *testing.T) { + _, _, err := runAnalyzeCmd(t) + require.Error(t, err) + require.Contains(t, err.Error(), "--kind is required") +} + +// TestAnalyzeKinds_PrintsKindsNoDaemon asserts `analyze kinds` lists the kinds +// from the in-process SSOT without touching the daemon. +func TestAnalyzeKinds_PrintsKindsNoDaemon(t *testing.T) { + called := false + orig := analyzeDaemonTool + t.Cleanup(func() { analyzeDaemonTool = orig }) + resetAnalyzeFlags() + analyzeDaemonTool = func(string, string, map[string]any) (json.RawMessage, error) { + called = true + return nil, nil + } + + buf := &bytes.Buffer{} + rootCmd.SetOut(buf) + rootCmd.SetErr(buf) + rootCmd.SetIn(strings.NewReader("")) + rootCmd.SetArgs([]string{"analyze", "kinds"}) + require.NoError(t, rootCmd.Execute()) + require.False(t, called, "`analyze kinds` must not call the daemon") + + out := buf.String() + for _, k := range []string{"hotspots", "dead_code", "cycles", "todos", "coverage_gaps"} { + require.Contains(t, out, k) + } +} + +// TestAnalyze_DaemonRequired asserts the real call path returns the actionable +// daemon-required error when no daemon tracks the repo. +func TestAnalyze_DaemonRequired(t *testing.T) { + resetAnalyzeFlags() + buf := &bytes.Buffer{} + rootCmd.SetOut(buf) + rootCmd.SetErr(buf) + rootCmd.SetIn(strings.NewReader("")) + rootCmd.SetArgs([]string{"analyze", "--kind", "hotspots", "--index", t.TempDir()}) + err := rootCmd.Execute() + require.Error(t, err) + require.Contains(t, err.Error(), "gortex track") +} diff --git a/cmd/gortex/audit.go b/cmd/gortex/audit.go new file mode 100644 index 0000000..4e588f5 --- /dev/null +++ b/cmd/gortex/audit.go @@ -0,0 +1,314 @@ +// audit.go — `gortex audit` command. Produces a one-letter repo-level +// health grade (A-F) plus a shields.io-style SVG badge suitable for +// embedding in a README. The grade is computed by the daemon's +// audit_health tool (complexity-axis health score); this command renders +// the badge / JSON / text and writes the badge file locally. +package main + +import ( + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/spf13/cobra" + + gortexmcp "github.com/zzet/gortex/internal/mcp" + "github.com/zzet/gortex/internal/progress" + "github.com/zzet/gortex/internal/tui" +) + +// auditReport / symbolScore alias the daemon's report types so the +// renderers below read unchanged. +type auditReport = gortexmcp.AuditReport +type symbolScore = gortexmcp.AuditSymbolScore + +var ( + auditPath string + auditBadge bool + auditOut string + auditFormat string +) + +var auditCmd = &cobra.Command{ + Use: "audit", + Short: "Compute a repo-level A-F health grade + emit a README-ready SVG badge", + Long: `Reports a single A-F grade for the repo the daemon owns, based on +graph-topology complexity (fan-in / fan-out per callable symbol). Designed +for the README shield. Requires a running daemon that tracks the repo. + +Output modes: + + --format svg (default) shields.io-style SVG. Default path + .gortex/badge.svg. Embed in README via + ![gortex audit](.gortex/badge.svg). + --format json machine-readable score + per-axis breakdown. + --format text one-line grade + score for quick CLI use. + +Examples: + + gortex audit # write .gortex/badge.svg + gortex audit --format text # "A · 87.4" on stdout + gortex audit --format json --out - # JSON on stdout + gortex audit --path /tmp/myrepo # audit a different tracked tree`, + RunE: runAudit, +} + +func init() { + auditCmd.Flags().StringVar(&auditPath, "path", ".", "repository path to audit (the daemon must track it)") + auditCmd.Flags().BoolVar(&auditBadge, "badge", true, "write an SVG shield (alias of --format svg)") + auditCmd.Flags().StringVar(&auditOut, "out", "", "output path (default: .gortex/badge.svg for svg, stdout for json/text)") + auditCmd.Flags().StringVar(&auditFormat, "format", "svg", "svg | json | text") + rootCmd.AddCommand(auditCmd) +} + +func runAudit(cmd *cobra.Command, _ []string) error { + abs, err := filepath.Abs(auditPath) + if err != nil { + return fmt.Errorf("resolve path: %w", err) + } + w := cmd.ErrOrStderr() + emitAuditBanner(w, abs) + + out, err := requireDaemonTool(auditPath, "audit_health", map[string]any{}) + if err != nil { + return err + } + var report auditReport + if err := json.Unmarshal(out, &report); err != nil { + return fmt.Errorf("decode audit report: %w", err) + } + + // On non-TTY (or --no-progress / non-svg formats), preserve the legacy + // summary line so script parsers keep working. + if !progress.IsTTY(w) || noProgress || strings.ToLower(auditFormat) != "svg" { + fmt.Fprintf(w, + "[audit] %d callable symbols · mean complexity-health %.1f · grade %s\n", + report.SymbolCount, report.MeanScore, report.Grade) + } else { + emitAuditGradeCard(w, report) + } + + switch strings.ToLower(auditFormat) { + case "svg": + out := auditOut + if out == "" { + out = filepath.Join(abs, ".gortex", "badge.svg") + } + if out == "-" { + _, err := cmd.OutOrStdout().Write([]byte(renderBadgeSVG(report))) + return err + } + if err := os.MkdirAll(filepath.Dir(out), 0o755); err != nil { + return err + } + if err := os.WriteFile(out, []byte(renderBadgeSVG(report)), 0o644); err != nil { + return err + } + fmt.Fprintf(cmd.ErrOrStderr(), "[audit] wrote %s\n", out) + fmt.Fprintf(cmd.OutOrStdout(), + "![gortex audit](%s) · grade %s · score %.1f\n", + filepath.ToSlash(filepath.Clean(strings.TrimPrefix(out, abs+string(filepath.Separator)))), + report.Grade, report.MeanScore) + case "json": + body := renderAuditJSON(report) + if auditOut == "" || auditOut == "-" { + _, _ = cmd.OutOrStdout().Write([]byte(body)) + _, _ = cmd.OutOrStdout().Write([]byte("\n")) + return nil + } + return os.WriteFile(auditOut, []byte(body), 0o644) + case "text": + line := fmt.Sprintf("%s · %.1f", report.Grade, report.MeanScore) + if auditOut == "" || auditOut == "-" { + fmt.Fprintln(cmd.OutOrStdout(), line) + return nil + } + return os.WriteFile(auditOut, []byte(line+"\n"), 0o644) + default: + return fmt.Errorf("unknown --format %q (want svg | json | text)", auditFormat) + } + return nil +} + +// renderAuditJSON is the structured-output form of the report. Hand-built +// so the field order is stable regardless of map iteration. +func renderAuditJSON(r auditReport) string { + var b strings.Builder + fmt.Fprintf(&b, "{\n \"symbol_count\": %d,\n \"mean_score\": %.1f,\n \"grade\": %q,\n", + r.SymbolCount, r.MeanScore, r.Grade) + b.WriteString(" \"grade_counts\": {") + first := true + for _, k := range []string{"A", "B", "C", "D", "F"} { + if first { + first = false + } else { + b.WriteString(",") + } + fmt.Fprintf(&b, "\n %q: %d", k, r.GradeCounts[k]) + } + b.WriteString("\n },\n \"worst_symbols\": [") + for i, s := range r.WorstSymbols { + if i > 0 { + b.WriteString(",") + } + fmt.Fprintf(&b, "\n {\"id\": %q, \"score\": %.1f, \"grade\": %q, \"file\": %q, \"line\": %d}", + s.ID, s.Score, s.Grade, s.File, s.Line) + } + b.WriteString("\n ]\n}") + return b.String() +} + +// renderBadgeSVG produces a shields.io-style two-cell badge. +func renderBadgeSVG(r auditReport) string { + label := "gortex audit" + grade := r.Grade + colour := gradeColour(grade) + + labelW := 78 + gradeW := 26 + totalW := labelW + gradeW + + return fmt.Sprintf(` + gortex audit: %s · %.1f + + + + + + + + + + + + %s + %s + + +`, + totalW, grade, grade, r.MeanScore, + totalW, + labelW, + labelW, gradeW, colour, + totalW, + labelW/2, label, + labelW+gradeW/2, grade, + ) +} + +// emitAuditBanner prints the gortex banner naming the repo under audit. +func emitAuditBanner(w io.Writer, repoPath string) { + if !progress.IsTTY(w) || noProgress { + return + } + short := repoPath + if home, err := os.UserHomeDir(); err == nil && strings.HasPrefix(repoPath, home) { + short = "~" + strings.TrimPrefix(repoPath, home) + } + banner := tui.Banner{ + Title: "gortex audit", + Subtitle: "Complexity-axis health grade for " + filepath.Base(repoPath) + ".", + }.Render() + fmt.Fprintln(w) + fmt.Fprintln(w, banner) + fmt.Fprintln(w, " "+progress.Row("repo", short, 8)) + fmt.Fprintln(w) +} + +// emitAuditGradeCard renders the result panel: a colour-tiered grade chip, +// stat strip, and per-grade distribution. +func emitAuditGradeCard(w io.Writer, r auditReport) { + gradeStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(progress.PaletteFg()). + Background(auditGradeBG(r.Grade)). + Padding(0, 2) + gradeChip := gradeStyle.Render(" " + r.Grade + " ") + + fmt.Fprintln(w, " "+gradeChip+" "+ + progress.StyleStrong.Render(fmt.Sprintf("%.1f", r.MeanScore))+ + " "+progress.StyleHint.Render("/ 100 · mean complexity-health")) + + stats := []string{ + progress.Stat(strconv.Itoa(r.SymbolCount), "callable symbols", progress.StatNeutral), + } + stats = append(stats, progress.Stat(gradeBlurb(r.Grade), "", auditStatSeverity(r.Grade))) + fmt.Fprintln(w, " "+progress.StatStrip(stats...)) + + if len(r.GradeCounts) > 0 { + fmt.Fprintln(w) + fmt.Fprintln(w, " "+progress.Heading("grade distribution")) + parts := make([]string, 0, 5) + for _, k := range []string{"A", "B", "C", "D", "F"} { + parts = append(parts, + lipgloss.NewStyle().Bold(true).Foreground(auditGradeBG(k)).Render(k)+ + " "+progress.StyleVal.Render(strconv.Itoa(r.GradeCounts[k])), + ) + } + fmt.Fprintln(w, " "+strings.Join(parts, progress.StyleHint.Render(" · "))) + } + fmt.Fprintln(w) +} + +// Coverage-grade badge colours — the shields.io standard palette, shared +// by the terminal badge background (auditGradeBG) and the SVG/markdown +// badge colour (gradeColour). +const ( + badgeBrightGreen = "#4c1" + badgeGreen = "#97ca00" + badgeYellow = "#dfb317" + badgeOrange = "#fe7d37" + badgeRed = "#e05d44" +) + +func auditGradeBG(grade string) lipgloss.Color { + return lipgloss.Color(gradeColour(grade)) +} + +func gradeBlurb(grade string) string { + switch grade { + case "A": + return "excellent topology" + case "B": + return "healthy" + case "C": + return "watch fan-out hotspots" + case "D": + return "consider refactoring" + default: + return "high coupling risk" + } +} + +func auditStatSeverity(grade string) progress.StatSeverity { + switch grade { + case "A", "B": + return progress.StatGood + case "C": + return progress.StatNeutral + case "D": + return progress.StatWarn + default: + return progress.StatBad + } +} + +func gradeColour(grade string) string { + switch grade { + case "A": + return badgeBrightGreen + case "B": + return badgeGreen + case "C": + return badgeYellow + case "D": + return badgeOrange + default: + return badgeRed + } +} diff --git a/cmd/gortex/audit_test.go b/cmd/gortex/audit_test.go new file mode 100644 index 0000000..09c191a --- /dev/null +++ b/cmd/gortex/audit_test.go @@ -0,0 +1,85 @@ +package main + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestRenderBadgeSVG_ValidAndContainsGrade(t *testing.T) { + for _, grade := range []string{"A", "B", "C", "D", "F"} { + r := auditReport{Grade: grade, MeanScore: 75.5, SymbolCount: 100} + svg := renderBadgeSVG(r) + if !strings.HasPrefix(svg, ""+grade+"") { + t.Errorf("badge for grade %q doesn't surface the grade letter: %s", grade, svg) + } + if !strings.Contains(svg, "gortex audit") { + t.Errorf("badge for grade %q missing label: %s", grade, svg) + } + } +} + +func TestRenderBadgeSVG_GradeColours(t *testing.T) { + cases := map[string]string{ + "A": "#4c1", + "B": "#97ca00", + "C": "#dfb317", + "D": "#fe7d37", + "F": "#e05d44", + } + for grade, colour := range cases { + svg := renderBadgeSVG(auditReport{Grade: grade, MeanScore: 50}) + if !strings.Contains(svg, colour) { + t.Errorf("grade %q badge missing colour %q", grade, colour) + } + } +} + +func TestGradeColour(t *testing.T) { + if gradeColour("A") != "#4c1" { + t.Errorf("A colour wrong") + } + // Unknown grade falls through to red — the safe default for a + // missing / corrupt grade. + if gradeColour("Z") != "#e05d44" { + t.Errorf("unknown grade should fall through to red") + } +} + +func TestRenderAuditJSON_RoundTrip(t *testing.T) { + r := auditReport{ + SymbolCount: 42, + MeanScore: 72.3, + Grade: "B", + GradeCounts: map[string]int{"A": 10, "B": 20, "C": 5, "D": 5, "F": 2}, + WorstSymbols: []symbolScore{{ID: "f.go::Bad", Score: 12.5, Grade: "F", File: "f.go", Line: 99}}, + } + body := renderAuditJSON(r) + var got map[string]any + if err := json.Unmarshal([]byte(body), &got); err != nil { + t.Fatalf("render produced invalid JSON: %v\n%s", err, body) + } + if got["grade"] != "B" || got["symbol_count"].(float64) != 42 { + t.Errorf("round-trip lost data: %+v", got) + } + worst, ok := got["worst_symbols"].([]any) + if !ok || len(worst) != 1 { + t.Errorf("worst_symbols wrong shape: %+v", got["worst_symbols"]) + } +} + +func TestAuditCmd_Registered(t *testing.T) { + found := false + for _, c := range rootCmd.Commands() { + if c.Name() == "audit" { + found = true + break + } + } + if !found { + t.Error("rootCmd missing `audit` subcommand") + } +} diff --git a/cmd/gortex/bench.go b/cmd/gortex/bench.go new file mode 100644 index 0000000..21fcdf1 --- /dev/null +++ b/cmd/gortex/bench.go @@ -0,0 +1,710 @@ +// bench.go — user-facing benchmark suite. Wraps the lower-level +// `gortex eval ...` substrate (recall / embedders / swebench / tokens) +// in a marketing-ready CLI shape: predictable defaults, two output +// formats (markdown + JSON), per-run artifacts under bench/results/, +// and a USD-per-model card on top of the token scorecard. +// +// `gortex bench` is the surface customers see; `gortex eval` remains +// the substrate with the long-tail flags. Both stay supported — the +// bench wrapper deliberately picks a narrow opinionated subset so +// the headline numbers stay comparable across runs. +package main + +import ( + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/zzet/gortex/internal/savings" +) + +var ( + benchOutDir string + benchFormat string + benchTokensCases string + benchTokensTokenizer string + benchRecallFixture string + benchRecallIndex string + benchRecallRankers string + benchAllResponsesDay int + + benchPerfRepos string + benchPerfIncludeLinux bool + benchPerfImpactBudget float64 + benchPerfSearchBudget float64 + benchPerfStrict bool + benchPerfQueries string + benchPerfCacheDir string + + benchTEffRepo string + benchTEffQueries string + benchTEffGroundtruth string + benchTEffTopK int + benchTEffBudgetRatio float64 + benchTEffStrict bool + benchTEffSkipRipgrep bool + + benchDaemonLatencyRepo string + benchDaemonLatencyIter int + benchDaemonLatencyTools string +) + +var benchCmd = &cobra.Command{ + Use: "bench", + Short: "Run benchmark suite (recall + tokens + embedders + swebench) with USD-per-model savings", + Long: `User-facing benchmark surface over the lower-level gortex eval +substrate. Each subcommand runs one bench dimension with opinionated +defaults that make headline numbers comparable across runs. + +Subcommands: + recall — recall@1/5/20 + MRR per ranker (wraps gortex eval recall) + tokens — GCX1 vs JSON wire-format size + USD savings per model card + embedders — quality vs latency across embedder choices + swebench — SWE-bench harness (skips gracefully when data isn't local) + all — runs the three cheap benches, writes a consolidated artifact + +Output defaults to markdown on stdout. Use --format json for machine- +readable, --out-dir DIR to persist per-run artifacts. + +Examples: + gortex bench tokens # one-line scorecard + USD card + gortex bench recall --fixture bench/fixtures/retrieval.yaml + gortex bench all --out-dir bench/results +`, +} + +func init() { + benchCmd.PersistentFlags().StringVar(&benchOutDir, "out-dir", "", "directory for per-run artifacts (default: stdout only)") + benchCmd.PersistentFlags().StringVar(&benchFormat, "format", "markdown", "output format: markdown or json") + rootCmd.AddCommand(benchCmd) + + benchCmd.AddCommand(benchRecallCmd) + benchCmd.AddCommand(benchTokensCmd) + benchCmd.AddCommand(benchTokensEfficiencyCmd) + benchCmd.AddCommand(benchEmbeddersCmd) + benchCmd.AddCommand(benchSWECmd) + benchCmd.AddCommand(benchPerfCmd) + benchCmd.AddCommand(benchDaemonLatencyCmd) + benchCmd.AddCommand(benchAllCmd) + + benchRecallCmd.Flags().StringVar(&benchRecallFixture, "fixture", "bench/fixtures/retrieval.yaml", "fixture YAML path") + benchRecallCmd.Flags().StringVar(&benchRecallIndex, "index", ".", "repository path to index") + benchRecallCmd.Flags().StringVar(&benchRecallRankers, "rankers", "", "comma-separated subset of rankers (default: all)") + + benchTokensCmd.Flags().StringVar(&benchTokensCases, "cases", "bench/wire-format/cases", "directory of fixture YAML files") + benchTokensCmd.Flags().StringVar(&benchTokensTokenizer, "tokenizer", "both", "cl100k | opus47 | both") + + benchTokensEfficiencyCmd.Flags().StringVar(&benchTEffRepo, "repo", ".", "indexed corpus path") + benchTokensEfficiencyCmd.Flags().StringVar(&benchTEffQueries, "queries", "bench/token-efficiency/queries.json", "JSON query set") + benchTokensEfficiencyCmd.Flags().StringVar(&benchTEffGroundtruth, "groundtruth", "bench/token-efficiency/groundtruth.json", "JSON per-query expected file paths") + benchTokensEfficiencyCmd.Flags().IntVar(&benchTEffTopK, "top-k", 5, "gortex pipeline candidate count") + benchTokensEfficiencyCmd.Flags().Float64Var(&benchTEffBudgetRatio, "budget-ratio", 0.5, "fail when gortex median tokens > ratio × ripgrep+full-read median (0 disables)") + benchTokensEfficiencyCmd.Flags().BoolVar(&benchTEffStrict, "strict", false, "exit 1 when budget gate trips") + benchTokensEfficiencyCmd.Flags().BoolVar(&benchTEffSkipRipgrep, "skip-ripgrep", false, "skip ripgrep pipelines (gortex-only output)") + + benchDaemonLatencyCmd.Flags().StringVar(&benchDaemonLatencyRepo, "repo", ".", "corpus to index for the bench") + benchDaemonLatencyCmd.Flags().IntVar(&benchDaemonLatencyIter, "iter", 200, "iterations per tool (warm-up of iter/10 is added on top)") + benchDaemonLatencyCmd.Flags().StringVar(&benchDaemonLatencyTools, "tools", "", "comma-separated subset (default: all known tools)") + + benchPerfCmd.Flags().StringVar(&benchPerfRepos, "repos", "gin,nestjs,react", "comma-separated repo set (preset slug, owner/repo, https URL, or local:/path)") + benchPerfCmd.Flags().BoolVar(&benchPerfIncludeLinux, "include-linux", false, "include the linux kernel preset (multi-GB clone; off by default)") + benchPerfCmd.Flags().Float64Var(&benchPerfImpactBudget, "budget-impact-p95-ms", 1.0, "fail when impact p95 exceeds this (0 disables)") + benchPerfCmd.Flags().Float64Var(&benchPerfSearchBudget, "budget-search-p95-ms", 50.0, "fail when search p95 exceeds this (0 disables)") + benchPerfCmd.Flags().BoolVar(&benchPerfStrict, "strict", false, "exit 1 when any repo trips a budget gate") + benchPerfCmd.Flags().StringVar(&benchPerfQueries, "queries", "bench/perf/queries.json", "JSON query set") + benchPerfCmd.Flags().StringVar(&benchPerfCacheDir, "perf-cache-dir", "", "override perf-bench clone cache (default $XDG_CACHE_HOME/gortex/bench)") + + benchAllCmd.Flags().IntVar(&benchAllResponsesDay, "responses-per-day", 1000, "responses/day used to scale the USD-per-model card") + benchTokensCmd.Flags().IntVar(&benchAllResponsesDay, "responses-per-day", 1000, "responses/day used to scale the USD-per-model card (alias of the value used by `bench all`)") +} + +// --- subcommand: recall --------------------------------------------- + +var benchRecallCmd = &cobra.Command{ + Use: "recall", + Short: "Recall@k + MRR per ranker (wraps gortex eval recall)", + RunE: func(_ *cobra.Command, _ []string) error { + args := []string{ + "eval", "recall", + "--fixture", benchRecallFixture, + "--index", benchRecallIndex, + "--format", benchFormat, + } + if benchRecallRankers != "" { + args = append(args, "--rankers", benchRecallRankers) + } + outPath, err := outputPathFor("recall", benchFormat) + if err != nil { + return err + } + if outPath != "" { + args = append(args, "--out", outPath) + } + return runGortexSubcommand(args...) + }, +} + +// --- subcommand: tokens --------------------------------------------- + +var benchTokensCmd = &cobra.Command{ + Use: "tokens", + Short: "GCX1 vs JSON wire-format scorecard + USD-per-model card", + RunE: func(cmd *cobra.Command, _ []string) error { + // Always capture machine-readable metrics so the USD card can + // layer on top. Use a temp JSON sink when --out-dir is not + // configured; otherwise write to the artifact directory. + jsonPath, err := outputPathFor("tokens", "json") + if err != nil { + return err + } + var tmpJSON string + if jsonPath == "" { + f, err := os.CreateTemp("", "gortex-bench-tokens-*.json") + if err != nil { + return err + } + tmpJSON = f.Name() + _ = f.Close() + defer func() { _ = os.Remove(tmpJSON) }() + jsonPath = tmpJSON + } + + // Markdown scorecard goes either to stdout, an artifact, or + // is discarded when format=json (caller wants the metrics + // only, not the rendered table). + scorecardPath, err := outputPathFor("tokens", "markdown") + if err != nil { + return err + } + + args := []string{ + "run", "./bench/wire-format", + "-cases", benchTokensCases, + "-tokenizer", benchTokensTokenizer, + "-json", jsonPath, + } + if scorecardPath != "" { + args = append(args, "-out", scorecardPath) + } else if benchFormat == "markdown" { + // markdown on stdout — let the underlying tool print. + } else { + // format=json + no out-dir: redirect markdown to a temp + // sink so it doesn't pollute the JSON output. + f, err := os.CreateTemp("", "gortex-bench-tokens-*.md") + if err != nil { + return err + } + _ = f.Close() + defer func() { _ = os.Remove(f.Name()) }() + args = append(args, "-out", f.Name()) + } + + subproc := exec.Command("go", args...) + subproc.Stdin = os.Stdin + subproc.Stderr = os.Stderr + if benchFormat == "markdown" && scorecardPath == "" { + subproc.Stdout = os.Stdout + } + if err := subproc.Run(); err != nil { + return fmt.Errorf("wire-format bench: %w", err) + } + + // Load the metrics and emit the USD card. + metrics, err := loadTokensMetrics(jsonPath) + if err != nil { + return fmt.Errorf("load tokens metrics: %w", err) + } + card := renderUSDCard(metrics, benchAllResponsesDay) + + switch benchFormat { + case "markdown", "md": + fmt.Fprintln(cmd.OutOrStdout()) + fmt.Fprintln(cmd.OutOrStdout(), card) + case "json": + out, err := json.MarshalIndent(map[string]any{ + "metrics": metrics, + "usd_card": buildUSDCardJSON(metrics, benchAllResponsesDay), + "generated": time.Now().UTC().Format(time.RFC3339), + }, "", " ") + if err != nil { + return err + } + _, _ = cmd.OutOrStdout().Write(out) + fmt.Fprintln(cmd.OutOrStdout()) + default: + return fmt.Errorf("unknown --format %q (want markdown or json)", benchFormat) + } + return nil + }, +} + +// --- subcommand: embedders ------------------------------------------ + +var benchEmbeddersCmd = &cobra.Command{ + Use: "embedders", + Short: "Quality vs latency across embedder choices (wraps gortex eval embedders)", + RunE: func(_ *cobra.Command, _ []string) error { + args := []string{"eval", "embedders"} + outPath, err := outputPathFor("embedders", benchFormat) + if err != nil { + return err + } + if outPath != "" { + args = append(args, "--out", outPath) + } + return runGortexSubcommand(args...) + }, +} + +// --- subcommand: tokens-efficiency ---------------------------------- + +var benchTokensEfficiencyCmd = &cobra.Command{ + Use: "tokens-efficiency", + Short: "Token efficiency vs ripgrep+read (3-pipeline comparison + recall@k by token budget)", + Long: `Runs the 3-pipeline token-economy comparison against an indexed +corpus: ripgrep+full-read (naive baseline), ripgrep+context (±50 +lines per hit), and gortex (search_symbols + get_symbol_source). +Reports median tokens per pipeline + recall@2k / recall@10k against +a hand-curated ground-truth set. + +Default behavior: + - Indexes --repo (default ".") for the gortex pipeline + - Loads queries from bench/token-efficiency/queries.json + - Loads ground truth from bench/token-efficiency/groundtruth.json + - Honors --out-dir (artifacts at /tokens-efficiency.{md,json}) + +Examples: + gortex bench tokens-efficiency + gortex bench tokens-efficiency --repo ~/code/myrepo --strict + gortex bench tokens-efficiency --skip-ripgrep --json`, + RunE: func(cmd *cobra.Command, _ []string) error { + mdOut, err := outputPathFor("tokens-efficiency", "markdown") + if err != nil { + return err + } + jsonOut, err := outputPathFor("tokens-efficiency", "json") + if err != nil { + return err + } + + args := []string{ + "run", "./bench/token-efficiency", + "-repo", benchTEffRepo, + "-queries", benchTEffQueries, + "-groundtruth", benchTEffGroundtruth, + "-top-k", fmt.Sprintf("%d", benchTEffTopK), + "-budget-ratio", fmt.Sprintf("%g", benchTEffBudgetRatio), + } + if benchTEffStrict { + args = append(args, "-strict") + } + if benchTEffSkipRipgrep { + args = append(args, "-skip-ripgrep") + } + if mdOut != "" { + args = append(args, "-out", mdOut) + } + if jsonOut != "" { + args = append(args, "-json", jsonOut) + } + if benchFormat == "json" && jsonOut == "" { + args = append(args, "-format", "json") + } + + subproc := exec.Command("go", args...) + subproc.Stdin = os.Stdin + subproc.Stdout = cmd.OutOrStdout() + subproc.Stderr = cmd.ErrOrStderr() + return subproc.Run() + }, +} + +// --- subcommand: perf ----------------------------------------------- + +var benchPerfCmd = &cobra.Command{ + Use: "perf", + Short: "Reference-repo perf benchmark (cold-index + search p95 + impact p95/p99 + incremental + DB size)", + Long: `Runs the reference-repo perf table across gin / nestjs / react +(+ optional linux). Validates the sub-millisecond impact-analysis +claim as a budget gate; --strict turns gate violations into a +non-zero exit so CI catches regressions. + +Default behavior: + - Clones each repo to ~/.gortex/cache/bench// on first run + - Reuses the clone on subsequent runs (rm -rf to refresh) + - Honors --out-dir (artifacts land at /perf.{md,json,csv}) + +Examples: + gortex bench perf + gortex bench perf --include-linux --strict --out-dir bench/results + gortex bench perf --repos local:./my-repo --strict`, + RunE: func(cmd *cobra.Command, _ []string) error { + mdOut, err := outputPathFor("perf", "markdown") + if err != nil { + return err + } + jsonOut, err := outputPathFor("perf", "json") + if err != nil { + return err + } + + args := []string{ + "run", "./bench/perf", + "-repos", benchPerfRepos, + "-queries", benchPerfQueries, + "-budget-impact-p95-ms", fmt.Sprintf("%g", benchPerfImpactBudget), + "-budget-search-p95-ms", fmt.Sprintf("%g", benchPerfSearchBudget), + } + if benchPerfIncludeLinux { + args = append(args, "-include-linux") + } + if benchPerfStrict { + args = append(args, "-strict") + } + if benchPerfCacheDir != "" { + args = append(args, "-cache-dir", benchPerfCacheDir) + } + if mdOut != "" { + args = append(args, "-out", mdOut) + } + if jsonOut != "" { + args = append(args, "-json", jsonOut) + } + // Honour --format on the parent: when JSON is requested, emit + // the JSON on stdout and discard markdown (no --out target + // would otherwise route stdout to markdown). + if benchFormat == "json" && jsonOut == "" { + args = append(args, "-format", "json") + } + + subproc := exec.Command("go", args...) + subproc.Stdin = os.Stdin + subproc.Stdout = cmd.OutOrStdout() + subproc.Stderr = cmd.ErrOrStderr() + return subproc.Run() + }, +} + +// --- subcommand: daemon-latency ------------------------------------- + +var benchDaemonLatencyCmd = &cobra.Command{ + Use: "daemon-latency", + Short: "Per-MCP-tool dispatch latency (p50/p95/p99) against an in-process server", + Long: `Measures end-to-end MCP tool-handler latency through the +production dispatch path. Substrate: bench/daemon-latency/. Fires +N iterations per tool, reports p50 / p95 / p99 / mean / max per +tool plus a top-line summary. + +What it measures: tool-handler latency end-to-end through the +real MCP dispatch path. Daemon socket overhead adds typically +<1 ms on a warm pipe; the handler latency dominates user- +perceived response time. + +Examples: + gortex bench daemon-latency + gortex bench daemon-latency --iter 500 + gortex bench daemon-latency --tools graph_stats,search_symbols + gortex bench daemon-latency --out-dir bench/results`, + RunE: func(cmd *cobra.Command, _ []string) error { + mdOut, err := outputPathFor("daemon-latency", "markdown") + if err != nil { + return err + } + jsonOut, err := outputPathFor("daemon-latency", "json") + if err != nil { + return err + } + + args := []string{ + "run", "./bench/daemon-latency", + "-repo", benchDaemonLatencyRepo, + "-iter", fmt.Sprintf("%d", benchDaemonLatencyIter), + } + if benchDaemonLatencyTools != "" { + args = append(args, "-tools", benchDaemonLatencyTools) + } + if mdOut != "" { + args = append(args, "-out", mdOut) + } + if jsonOut != "" { + args = append(args, "-json", jsonOut) + } + if benchFormat == "json" && jsonOut == "" { + args = append(args, "-format", "json") + } + + subproc := exec.Command("go", args...) + subproc.Stdin = os.Stdin + subproc.Stdout = cmd.OutOrStdout() + subproc.Stderr = cmd.ErrOrStderr() + return subproc.Run() + }, +} + +// --- subcommand: swebench ------------------------------------------- + +var benchSWECmd = &cobra.Command{ + Use: "swebench", + Short: "SWE-bench harness (skips gracefully when data isn't local)", + RunE: func(cmd *cobra.Command, _ []string) error { + // Skip cleanly when the harness dependencies (Python + the + // data set) aren't present; SWE-bench is multi-day GPU work + // and we don't want CI / casual users to wait on a missing + // dataset. + if !swebenchAvailable() { + fmt.Fprintln(cmd.ErrOrStderr(), + "[gortex bench swebench] SWE-bench harness not available locally;", + "see eval/README.md for setup. Skipping.") + return nil + } + return runGortexSubcommand("eval", "swebench") + }, +} + +// --- subcommand: all ------------------------------------------------ + +var benchAllCmd = &cobra.Command{ + Use: "all", + Short: "Run the three cheap benches (recall + tokens + embedders) and write a consolidated artifact", + RunE: func(cmd *cobra.Command, _ []string) error { + dir := benchOutDir + if dir == "" { + dir = filepath.Join("bench", "results") + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + stamp := time.Now().UTC().Format("20060102-150405") + runDir := filepath.Join(dir, "run-"+stamp) + if err := os.MkdirAll(runDir, 0o755); err != nil { + return err + } + + // Run each sub-bench with its artifact directory pointed at + // the run-specific subdir, so a single `bench all` produces + // one tidy folder. + previousOutDir := benchOutDir + benchOutDir = runDir + defer func() { benchOutDir = previousOutDir }() + + results := map[string]string{} + for _, sub := range []struct { + name string + runFn func() error + }{ + {"tokens", func() error { return benchTokensCmd.RunE(cmd, nil) }}, + {"recall", func() error { return benchRecallCmd.RunE(cmd, nil) }}, + {"embedders", func() error { return benchEmbeddersCmd.RunE(cmd, nil) }}, + } { + if err := sub.runFn(); err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), + "[gortex bench all] %s failed: %v (continuing)\n", sub.name, err) + results[sub.name] = "failed: " + err.Error() + continue + } + results[sub.name] = "ok" + } + + summary := map[string]any{ + "generated": time.Now().UTC().Format(time.RFC3339), + "run_dir": runDir, + "results": results, + "responses_per_day": benchAllResponsesDay, + } + summaryBytes, _ := json.MarshalIndent(summary, "", " ") + if err := os.WriteFile(filepath.Join(runDir, "summary.json"), summaryBytes, 0o644); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "\n[gortex bench all] artifacts: %s\n", runDir) + return nil + }, +} + +// --- helpers -------------------------------------------------------- + +// outputPathFor builds the artifact filename for a sub-bench when +// --out-dir is set. Returns "" when no path should be passed +// (substack defaults to stdout). The extension reflects the chosen +// format so a reader can tell at a glance which artifact is which. +func outputPathFor(bench, format string) (string, error) { + if benchOutDir == "" { + return "", nil + } + if err := os.MkdirAll(benchOutDir, 0o755); err != nil { + return "", err + } + ext := "md" + if format == "json" { + ext = "json" + } + return filepath.Join(benchOutDir, bench+"."+ext), nil +} + +// runGortexSubcommand re-execs the current binary with the provided +// args. Keeps state clean (no shared globals between sub-benches) and +// makes each invocation independently debuggable. +func runGortexSubcommand(args ...string) error { + self, err := os.Executable() + if err != nil { + return fmt.Errorf("locate gortex binary: %w", err) + } + subproc := exec.Command(self, args...) + subproc.Stdin = os.Stdin + subproc.Stdout = os.Stdout + subproc.Stderr = os.Stderr + return subproc.Run() +} + +// swebenchAvailable reports whether the SWE-bench harness can be +// reasonably expected to run. Conservative: requires Python on PATH +// AND the eval/ directory in the repo (which carries the harness). +func swebenchAvailable() bool { + if _, err := exec.LookPath("python3"); err != nil { + if _, err := exec.LookPath("python"); err != nil { + return false + } + } + if st, err := os.Stat("eval"); err != nil || !st.IsDir() { + return false + } + return true +} + +// --- tokens-bench metrics + USD card -------------------------------- + +// tokensMetric mirrors the on-disk row shape emitted by the +// bench/wire-format harness. We only consume the fields the USD card +// needs; extra fields in the JSON are ignored. +type tokensMetric struct { + Case string `json:"Case"` + Tool string `json:"Tool"` + JSONTokens int `json:"JSONTokens"` + GCXTokens int `json:"GCXTokens"` + JSONTokensOpus47 int `json:"JSONTokensOpus47,omitempty"` + GCXTokensOpus47 int `json:"GCXTokensOpus47,omitempty"` +} + +func loadTokensMetrics(path string) ([]tokensMetric, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + raw, err := io.ReadAll(f) + if err != nil { + return nil, err + } + var rows []tokensMetric + if err := json.Unmarshal(raw, &rows); err != nil { + return nil, err + } + return rows, nil +} + +// medianSavedTokens returns the median per-response token savings — +// JSON tokens minus GCX tokens — across the supplied metrics. Returns +// 0 when no rows or no valid savings (defensive against pathological +// inputs that would zero out the USD card). +func medianSavedTokens(rows []tokensMetric, useOpus47 bool) int { + if len(rows) == 0 { + return 0 + } + deltas := make([]int, 0, len(rows)) + for _, r := range rows { + var saved int + if useOpus47 && r.JSONTokensOpus47 > 0 { + saved = r.JSONTokensOpus47 - r.GCXTokensOpus47 + } else if r.JSONTokens > 0 { + saved = r.JSONTokens - r.GCXTokens + } + if saved > 0 { + deltas = append(deltas, saved) + } + } + if len(deltas) == 0 { + return 0 + } + sort.Ints(deltas) + return deltas[len(deltas)/2] +} + +// renderUSDCard produces a markdown table projecting per-day and +// per-month savings at each model's input-token rate. responsesPerDay +// scales the per-response figure into the headline numbers a buyer +// will share. Pricing comes from internal/savings — overrideable via +// GORTEX_MODEL_PRICING_JSON for org-specific rates. +func renderUSDCard(rows []tokensMetric, responsesPerDay int) string { + if responsesPerDay <= 0 { + responsesPerDay = 1000 + } + medianCL := medianSavedTokens(rows, false) + medianOpus := medianSavedTokens(rows, true) + + var b strings.Builder + fmt.Fprintln(&b, "## USD savings projection (per the bench median)") + fmt.Fprintln(&b) + fmt.Fprintf(&b, "Median tokens saved / response: **%d** (cl100k_base)", medianCL) + if medianOpus > 0 { + fmt.Fprintf(&b, ", **%d** (Opus 4.7)", medianOpus) + } + fmt.Fprintln(&b) + fmt.Fprintf(&b, "Projected at %d responses/day:\n\n", responsesPerDay) + fmt.Fprintln(&b, "| Model | $/M input | $/day | $/month |") + fmt.Fprintln(&b, "|------------------|----------:|-------:|--------:|") + + prices := savings.Pricing() + for _, p := range prices { + // Use Opus 4.7 figures for Claude Opus 4.x (a closer match + // for that family's tokenizer); cl100k_base everywhere else. + median := medianCL + if medianOpus > 0 && strings.Contains(strings.ToLower(p.Model), "opus") { + median = medianOpus + } + perResponse := float64(median) / 1_000_000.0 * p.USDPerMInput + perDay := perResponse * float64(responsesPerDay) + perMonth := perDay * 30.0 + fmt.Fprintf(&b, "| %-16s | $%-8.2f | $%-5.2f | $%-7.2f |\n", + p.Model, p.USDPerMInput, perDay, perMonth) + } + return b.String() +} + +// buildUSDCardJSON returns the same data the markdown card surfaces, +// in structured form for --format=json consumers. +func buildUSDCardJSON(rows []tokensMetric, responsesPerDay int) map[string]any { + if responsesPerDay <= 0 { + responsesPerDay = 1000 + } + medianCL := medianSavedTokens(rows, false) + medianOpus := medianSavedTokens(rows, true) + + models := make([]map[string]any, 0) + for _, p := range savings.Pricing() { + median := medianCL + if medianOpus > 0 && strings.Contains(strings.ToLower(p.Model), "opus") { + median = medianOpus + } + perResponse := float64(median) / 1_000_000.0 * p.USDPerMInput + models = append(models, map[string]any{ + "model": p.Model, + "usd_per_m": p.USDPerMInput, + "usd_per_resp": perResponse, + "usd_per_day": perResponse * float64(responsesPerDay), + "usd_per_month": perResponse * float64(responsesPerDay) * 30.0, + }) + } + return map[string]any{ + "median_saved_tokens_cl100k": medianCL, + "median_saved_tokens_opus47": medianOpus, + "responses_per_day": responsesPerDay, + "models": models, + } +} diff --git a/cmd/gortex/bench_test.go b/cmd/gortex/bench_test.go new file mode 100644 index 0000000..416c6d9 --- /dev/null +++ b/cmd/gortex/bench_test.go @@ -0,0 +1,209 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestMedianSavedTokens_CL100k(t *testing.T) { + rows := []tokensMetric{ + {Case: "a", JSONTokens: 100, GCXTokens: 80}, // saved 20 + {Case: "b", JSONTokens: 200, GCXTokens: 150}, // saved 50 + {Case: "c", JSONTokens: 300, GCXTokens: 270}, // saved 30 + } + if got := medianSavedTokens(rows, false); got != 30 { + t.Errorf("median = %d, want 30 (sorted [20,30,50] → middle)", got) + } +} + +func TestMedianSavedTokens_Opus47(t *testing.T) { + rows := []tokensMetric{ + {Case: "a", JSONTokensOpus47: 135, GCXTokensOpus47: 108}, // saved 27 + {Case: "b", JSONTokensOpus47: 270, GCXTokensOpus47: 200}, // saved 70 + } + if got := medianSavedTokens(rows, true); got != 70 { + t.Errorf("opus47 median = %d, want 70", got) + } +} + +func TestMedianSavedTokens_ZeroOrNegativeIgnored(t *testing.T) { + rows := []tokensMetric{ + {Case: "neg", JSONTokens: 100, GCXTokens: 110}, // GCX larger (negative savings) — ignored + {Case: "zero", JSONTokens: 0, GCXTokens: 0}, // empty — ignored + {Case: "real", JSONTokens: 100, GCXTokens: 50}, // saved 50 + } + if got := medianSavedTokens(rows, false); got != 50 { + t.Errorf("median = %d, want 50 (negative + zero excluded)", got) + } +} + +func TestMedianSavedTokens_EmptyReturnsZero(t *testing.T) { + if got := medianSavedTokens(nil, false); got != 0 { + t.Errorf("nil input = %d, want 0", got) + } + if got := medianSavedTokens([]tokensMetric{}, false); got != 0 { + t.Errorf("empty input = %d, want 0", got) + } +} + +func TestRenderUSDCard_PopulatesAllModels(t *testing.T) { + rows := []tokensMetric{ + {Case: "a", JSONTokens: 100, GCXTokens: 80, JSONTokensOpus47: 135, GCXTokensOpus47: 108}, + } + card := renderUSDCard(rows, 1000) + for _, want := range []string{ + "USD savings projection", + "claude-opus-4-8", + "claude-sonnet-4-6", + "claude-haiku-4-5", + "gpt-4o", + "gpt-4o-mini", + "gemini-2.5-pro", + "deepseek-chat", + "$/M input", + "$/day", + "$/month", + } { + if !strings.Contains(card, want) { + t.Errorf("USD card missing %q\n----\n%s\n----", want, card) + } + } +} + +func TestRenderUSDCard_ScalingByResponsesPerDay(t *testing.T) { + rows := []tokensMetric{ + {Case: "a", JSONTokens: 1_000_000, GCXTokens: 0}, // saved 1M tokens (cleanly scaled) + } + c1 := renderUSDCard(rows, 1) + c1000 := renderUSDCard(rows, 1000) + // At 1M tokens/response × $5/M input (claude-opus-4-8), per-response = $5.00. + if !strings.Contains(c1, "$5.00") { + t.Errorf("1 resp/day USD card missing $5.00:\n%s", c1) + } + // At 1000 resp/day, daily = $5,000 — confirm the figure scales linearly. + if !strings.Contains(c1000, "$5000.00") { + t.Errorf("1000 resp/day USD card missing $5000.00:\n%s", c1000) + } +} + +func TestRenderUSDCard_OpusUsesOpus47TokensWhenAvailable(t *testing.T) { + // cl100k=100 tokens, opus47=1_000_000. For the claude-opus-4-x rows, + // the card must use the opus47 figure (so an opus row shows $5.00 at + // 1 resp/day and $5/M input, not the cl100k $0.0005). + rows := []tokensMetric{ + {Case: "a", JSONTokens: 100, GCXTokens: 0, JSONTokensOpus47: 1_000_000, GCXTokensOpus47: 0}, + } + card := renderUSDCard(rows, 1) + if !strings.Contains(card, "$5.00") { + t.Errorf("opus row should use opus47 tokens (=$5 at 1M saved × $5/M); got:\n%s", card) + } +} + +func TestBuildUSDCardJSON_Shape(t *testing.T) { + rows := []tokensMetric{ + {Case: "a", JSONTokens: 100, GCXTokens: 80}, + } + out := buildUSDCardJSON(rows, 100) + if out["median_saved_tokens_cl100k"] != 20 { + t.Errorf("median field = %v, want 20", out["median_saved_tokens_cl100k"]) + } + if out["responses_per_day"] != 100 { + t.Errorf("responses_per_day = %v, want 100", out["responses_per_day"]) + } + models, ok := out["models"].([]map[string]any) + if !ok { + t.Fatalf("models field has wrong type %T", out["models"]) + } + if len(models) == 0 { + t.Fatal("expected at least one model in USD JSON") + } + for _, m := range models { + for _, key := range []string{"model", "usd_per_m", "usd_per_resp", "usd_per_day", "usd_per_month"} { + if _, ok := m[key]; !ok { + t.Errorf("model row missing %q: %+v", key, m) + } + } + } +} + +func TestLoadTokensMetrics_RoundTrip(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "metrics.json") + rows := []tokensMetric{ + {Case: "a", Tool: "search_symbols", JSONTokens: 100, GCXTokens: 80}, + {Case: "b", Tool: "find_usages", JSONTokens: 200, GCXTokens: 150, JSONTokensOpus47: 270, GCXTokensOpus47: 200}, + } + raw, _ := json.MarshalIndent(rows, "", " ") + if err := os.WriteFile(path, raw, 0o644); err != nil { + t.Fatal(err) + } + got, err := loadTokensMetrics(path) + if err != nil { + t.Fatal(err) + } + if len(got) != 2 { + t.Fatalf("loaded %d rows, want 2", len(got)) + } + if got[1].JSONTokensOpus47 != 270 { + t.Errorf("lost opus47 column: %+v", got[1]) + } +} + +func TestOutputPathFor_NoOutDirReturnsEmpty(t *testing.T) { + old := benchOutDir + benchOutDir = "" + defer func() { benchOutDir = old }() + got, err := outputPathFor("tokens", "markdown") + if err != nil { + t.Fatal(err) + } + if got != "" { + t.Errorf("no out-dir should return empty, got %q", got) + } +} + +func TestOutputPathFor_BuildsExtensionByFormat(t *testing.T) { + dir := t.TempDir() + old := benchOutDir + benchOutDir = dir + defer func() { benchOutDir = old }() + + md, err := outputPathFor("tokens", "markdown") + if err != nil { + t.Fatal(err) + } + if filepath.Ext(md) != ".md" { + t.Errorf("markdown path %q must end .md", md) + } + js, err := outputPathFor("tokens", "json") + if err != nil { + t.Fatal(err) + } + if filepath.Ext(js) != ".json" { + t.Errorf("json path %q must end .json", js) + } +} + +func TestBenchCmd_Registered(t *testing.T) { + // Smoke test: every subcommand is wired into the bench parent. + subs := map[string]bool{} + for _, sub := range benchCmd.Commands() { + subs[sub.Name()] = true + } + for _, want := range []string{"recall", "tokens", "embedders", "swebench", "all"} { + if !subs[want] { + t.Errorf("bench parent missing subcommand %q (have %v)", want, subs) + } + } +} + +func TestSwebenchAvailable_DefaultsHonest(t *testing.T) { + // On a developer box with python3 + an eval/ directory this + // returns true; on a stripped-down CI it returns false. Either + // way, the function must NOT panic and the bench subcommand + // will respect the result by skipping gracefully. + _ = swebenchAvailable() +} diff --git a/cmd/gortex/call.go b/cmd/gortex/call.go new file mode 100644 index 0000000..3b6ac4c --- /dev/null +++ b/cmd/gortex/call.go @@ -0,0 +1,420 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "os" + "sort" + "strconv" + "strings" + + "github.com/spf13/cobra" +) + +var ( + callIndex string + callJSON string + callJSONFile string + callArgs []string + callFormat string + callDry bool + callQuiet bool +) + +// callDaemonTool is the daemon-tool relay seam. It is indirected through a +// package var so a test can stub the daemon call (and the catalog fetch used +// for name validation) without a running daemon. +var callDaemonTool = requireDaemonTool + +var callCmd = &cobra.Command{ + Use: "call ", + Short: "Invoke any registered MCP tool by name over the daemon", + Long: `Invokes any tool the daemon's MCP surface registers — the generic escape +hatch when no dedicated CLI verb exists yet. The argument object is assembled +from three layers (last wins per key): + + 1. a base object from --json-file or --json - (stdin) + 2. an inline --json '' merged over the base + 3. one or more --arg key=value merged on top + +--arg coercion is deterministic: true/false -> bool, an integer or float -> +number, null -> null, a value starting with [ or { -> parsed JSON, key:= +forces raw-JSON parse of the right-hand side (so version:="1.0" stays the +string "1.0"), key= -> the empty string, and anything else stays a string. +Repeating a key replaces the earlier value. + +Use --dry to print the lowered argument object and the target tool without +calling the daemon (works with no daemon running). Use --format to pick the +wire format the tool renders (json|gcx|toon|text). + +Requires a running daemon that tracks the repo (except for --dry).`, + Args: cobra.ExactArgs(1), + SilenceUsage: true, // a tool/daemon error should read cleanly, not dump usage + RunE: runCall, +} + +func init() { + callCmd.Flags().StringVar(&callIndex, "index", ".", "repository path the daemon must track") + callCmd.Flags().StringVar(&callIndex, "repo", ".", "alias for --index") + callCmd.Flags().StringVar(&callJSON, "json", "", "base argument object as inline JSON, or \"-\" to read from stdin") + callCmd.Flags().StringVar(&callJSONFile, "json-file", "", "read the base argument object from a JSON file") + callCmd.Flags().StringArrayVar(&callArgs, "arg", nil, "add one key=value argument (repeatable); see help for coercion rules") + callCmd.Flags().StringVar(&callFormat, "format", "json", "output / wire format forwarded to the tool: json|gcx|toon|text") + callCmd.Flags().BoolVar(&callDry, "dry", false, "print the lowered argument object and target tool without calling the daemon") + callCmd.Flags().BoolVar(&callQuiet, "quiet", false, "suppress the stderr note when calling a mutating tool") + rootCmd.AddCommand(callCmd) +} + +func runCall(cmd *cobra.Command, args []string) error { + tool := args[0] + + // Lower the argument object — pure-local, so --dry never needs a daemon. + argObj, err := lowerCallArgs(cmd, callJSON, callJSONFile, callArgs) + if err != nil { + return err + } + + if callDry { + return printCallDry(cmd, tool, argObj) + } + + // Best-effort name validation against the live daemon's catalog. When no + // daemon is reachable this is skipped entirely so the call below returns + // the normal daemonRequiredErr instead of a confusing "unknown tool". + if cat, ok := fetchToolCatalog(callIndex); ok { + if !cat.has(tool) { + return unknownToolErr(tool, cat) + } + if cat.mutating(tool) && !callQuiet { + fmt.Fprintf(cmd.ErrOrStderr(), "note: %s writes to your working tree / graph\n", tool) + } + } + + // Forward the chosen wire format to the tool. The executor pins + // format=json by default; an explicit format here overrides it. + if callFormat != "" { + argObj["format"] = callFormat + } + + raw, err := callDaemonTool(callIndex, tool, argObj) + if err != nil { + return err + } + + switch callFormat { + case "gcx", "toon": + // Compact wire formats are printed verbatim — re-indenting would + // corrupt them. + fmt.Fprintln(cmd.OutOrStdout(), strings.TrimRight(string(raw), "\n")) + return nil + default: // json | text + return emitDaemonJSON(cmd, raw) + } +} + +// printCallDry prints the lowered argument object (indented JSON) and the +// target tool name without touching the daemon. +func printCallDry(cmd *cobra.Command, tool string, argObj map[string]any) error { + out := cmd.OutOrStdout() + fmt.Fprintf(out, "tool: %s\n", tool) + fmt.Fprintln(out, "arguments:") + enc := json.NewEncoder(out) + enc.SetIndent("", " ") + return enc.Encode(argObj) +} + +// lowerCallArgs assembles the argument object from the three precedence layers: +// the --json-file / --json - base, an inline --json '' merged over it, and +// the --arg key=value pairs merged on top (last wins per key). It never touches +// the daemon, so the --dry path is fully exercisable offline. +func lowerCallArgs(cmd *cobra.Command, inlineJSON, jsonFile string, kvs []string) (map[string]any, error) { + obj := map[string]any{} + + // Layer 1: base from --json-file or --json - (stdin). + if jsonFile != "" { + data, err := os.ReadFile(jsonFile) + if err != nil { + return nil, fmt.Errorf("reading --json-file %s: %w", jsonFile, err) + } + if err := mergeJSONObject(obj, data, "--json-file"); err != nil { + return nil, err + } + } + if inlineJSON == "-" { + data, err := io.ReadAll(cmd.InOrStdin()) + if err != nil { + return nil, fmt.Errorf("reading --json from stdin: %w", err) + } + if err := mergeJSONObject(obj, data, "--json -"); err != nil { + return nil, err + } + } else if inlineJSON != "" { + // Layer 2: inline base merged over the file/stdin base. + if err := mergeJSONObject(obj, []byte(inlineJSON), "--json"); err != nil { + return nil, err + } + } + + // Layer 3: --arg key=value, merged on top (last occurrence of a key wins). + for _, kv := range kvs { + key, val, err := coerceArg(kv) + if err != nil { + return nil, err + } + obj[key] = val + } + return obj, nil +} + +// mergeJSONObject decodes data as a JSON object and merges its keys into dst. +// A non-object payload (array, scalar) is rejected — tool arguments are always +// a key/value object. +func mergeJSONObject(dst map[string]any, data []byte, source string) error { + trimmed := strings.TrimSpace(string(data)) + if trimmed == "" { + return nil + } + var parsed map[string]any + if err := json.Unmarshal([]byte(trimmed), &parsed); err != nil { + return fmt.Errorf("%s must be a JSON object: %w", source, err) + } + for k, v := range parsed { + dst[k] = v + } + return nil +} + +// coerceArg parses one --arg token into a (key, value) pair with deterministic +// type coercion. The grammar: +// +// key:= walrus — the right-hand side is parsed as raw JSON (so +// version:="1.0" stays the string "1.0", not a number) +// key=value value is coerced: true/false -> bool, int/float -> number, +// null -> null, a value starting with [ or { -> parsed JSON, +// key= -> empty string, everything else -> string +func coerceArg(token string) (string, any, error) { + // Walrus first: a "key:=rhs" forces raw-JSON parse of the RHS. Detect the + // ":=" boundary before the plain "=" so version:="1.0" is not mistaken for + // a key of "version:" with an "=" value. + if i := strings.Index(token, ":="); i >= 0 { + key := token[:i] + if key == "" { + return "", nil, fmt.Errorf("--arg %q: empty key", token) + } + raw := token[i+2:] + var v any + if err := json.Unmarshal([]byte(raw), &v); err != nil { + return "", nil, fmt.Errorf("--arg %q: right-hand side is not valid JSON: %w", token, err) + } + return key, v, nil + } + + eq := strings.Index(token, "=") + if eq < 0 { + return "", nil, fmt.Errorf("--arg %q: expected key=value or key:=", token) + } + key := token[:eq] + if key == "" { + return "", nil, fmt.Errorf("--arg %q: empty key", token) + } + val := token[eq+1:] + return key, coerceScalar(val), nil +} + +// coerceScalar applies the deterministic --arg value coercion to a bare value: +// true/false -> bool, integer/float -> number, null -> null, a value starting +// with [ or { -> parsed JSON (falling back to the literal string if it does not +// parse), "" -> empty string, everything else -> string. +func coerceScalar(val string) any { + switch val { + case "": + return "" + case "true": + return true + case "false": + return false + case "null": + return nil + } + // A leading [ or { signals an inline JSON array / object. + if val[0] == '[' || val[0] == '{' { + var v any + if err := json.Unmarshal([]byte(val), &v); err == nil { + return v + } + // Not valid JSON — keep the literal string so the value is not lost. + return val + } + // Integers (json.Number-friendly: keep them as float64 like encoding/json + // would, so the wire shape matches a parsed JSON object). + if i, err := strconv.ParseInt(val, 10, 64); err == nil { + return i + } + if f, err := strconv.ParseFloat(val, 64); err == nil { + return f + } + return val +} + +// toolCatalog is the subset of the tool_profile response the call command uses +// for best-effort name validation and the mutating-tool note. +type toolCatalog struct { + names map[string]bool + mutators map[string]bool + all []string +} + +func (c *toolCatalog) has(name string) bool { return c != nil && c.names[name] } +func (c *toolCatalog) mutating(name string) bool { return c != nil && c.mutators[name] } + +// fetchToolCatalog asks the daemon for the full tool_profile and distills it +// into a toolCatalog. The bool result is false when no daemon is reachable (or +// the response is unusable) — the caller then SKIPS validation so the real call +// surfaces the normal daemonRequiredErr. +func fetchToolCatalog(repoPath string) (*toolCatalog, bool) { + raw, err := callDaemonTool(repoPath, "tool_profile", map[string]any{}) + if err != nil { + return nil, false + } + var profile struct { + Live []string `json:"live"` + Deferred []string `json:"deferred"` + Descriptors []struct { + Name string `json:"name"` + Mutating bool `json:"mutating"` + } `json:"descriptors"` + } + if err := json.Unmarshal(raw, &profile); err != nil { + return nil, false + } + cat := &toolCatalog{ + names: map[string]bool{}, + mutators: map[string]bool{}, + } + add := func(n string) { + if n == "" || cat.names[n] { + return + } + cat.names[n] = true + cat.all = append(cat.all, n) + } + for _, n := range profile.Live { + add(n) + } + for _, n := range profile.Deferred { + add(n) + } + for _, d := range profile.Descriptors { + add(d.Name) + if d.Mutating { + cat.mutators[d.Name] = true + } + } + if len(cat.names) == 0 { + return nil, false + } + sort.Strings(cat.all) + return cat, true +} + +// unknownToolErr builds the actionable error for an unknown tool name: it lists +// the nearest catalog matches (by edit distance, with a substring fallback) and +// points at `gortex tools search`. +func unknownToolErr(tool string, cat *toolCatalog) error { + matches := cat.nearest(tool, 5) + var b strings.Builder + fmt.Fprintf(&b, "unknown tool %q", tool) + if len(matches) > 0 { + fmt.Fprintf(&b, " — did you mean: %s?", strings.Join(matches, ", ")) + } + fmt.Fprintf(&b, "\nRun `gortex tools search %s` to find the right tool.", tool) + return fmt.Errorf("%s", b.String()) +} + +// nearest returns up to n catalog names closest to query: every name that +// contains the query as a substring first (cheap, high-signal), then the +// remaining names ranked by Levenshtein distance. +func (c *toolCatalog) nearest(query string, n int) []string { + if c == nil || len(c.all) == 0 { + return nil + } + q := strings.ToLower(query) + type scored struct { + name string + dist int + sub bool + } + ranked := make([]scored, 0, len(c.all)) + for _, name := range c.all { + lower := strings.ToLower(name) + ranked = append(ranked, scored{ + name: name, + dist: levenshtein(q, lower), + sub: strings.Contains(lower, q) || strings.Contains(q, lower), + }) + } + sort.SliceStable(ranked, func(i, j int) bool { + if ranked[i].sub != ranked[j].sub { + return ranked[i].sub // substring matches first + } + if ranked[i].dist != ranked[j].dist { + return ranked[i].dist < ranked[j].dist + } + return ranked[i].name < ranked[j].name + }) + out := make([]string, 0, n) + for _, s := range ranked { + // Keep substring matches always; otherwise require a reasonable + // distance so we don't suggest wholly unrelated names. + if s.sub || s.dist <= len(q)/2+2 { + out = append(out, s.name) + } + if len(out) >= n { + break + } + } + return out +} + +// levenshtein computes the edit distance between a and b with a rolling +// two-row buffer (O(len(a)*len(b)) time, O(len(b)) space). +func levenshtein(a, b string) int { + if a == b { + return 0 + } + if len(a) == 0 { + return len(b) + } + if len(b) == 0 { + return len(a) + } + prev := make([]int, len(b)+1) + curr := make([]int, len(b)+1) + for j := 0; j <= len(b); j++ { + prev[j] = j + } + for i := 1; i <= len(a); i++ { + curr[0] = i + for j := 1; j <= len(b); j++ { + cost := 1 + if a[i-1] == b[j-1] { + cost = 0 + } + curr[j] = min3(curr[j-1]+1, prev[j]+1, prev[j-1]+cost) + } + prev, curr = curr, prev + } + return prev[len(b)] +} + +func min3(a, b, c int) int { + m := a + if b < m { + m = b + } + if c < m { + m = c + } + return m +} diff --git a/cmd/gortex/call_test.go b/cmd/gortex/call_test.go new file mode 100644 index 0000000..63c420f --- /dev/null +++ b/cmd/gortex/call_test.go @@ -0,0 +1,327 @@ +package main + +import ( + "bytes" + "encoding/json" + "os" + "reflect" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" +) + +// newCallTestCmd builds a fresh call command bound to an out/err buffer and +// resets the package-level flag state to defaults so tests don't leak. +func newCallTestCmd(t *testing.T) (*cobra.Command, *bytes.Buffer) { + t.Helper() + callIndex = "." + callJSON = "" + callJSONFile = "" + callArgs = nil + callFormat = "json" + callDry = false + callQuiet = false + + buf := &bytes.Buffer{} + cmd := &cobra.Command{Use: "call", RunE: runCall} + cmd.SetOut(buf) + cmd.SetErr(buf) + return cmd, buf +} + +// TestCoerceScalar exhaustively covers the deterministic --arg value coercion. +func TestCoerceScalar(t *testing.T) { + cases := []struct { + in string + want any + }{ + {"true", true}, + {"false", false}, + {"null", nil}, + {"42", int64(42)}, + {"-7", int64(-7)}, + {"3.14", 3.14}, + {"", ""}, + {"hello", "hello"}, + {"1.0.0", "1.0.0"}, // not a number — stays a string + {`[1,2,3]`, []any{float64(1), float64(2), float64(3)}}, + {`{"k":"v"}`, map[string]any{"k": "v"}}, + {`[broken`, `[broken`}, // invalid JSON array — falls back to literal string + } + for _, c := range cases { + got := coerceScalar(c.in) + if !reflect.DeepEqual(got, c.want) { + t.Errorf("coerceScalar(%q) = %#v (%T), want %#v (%T)", c.in, got, got, c.want, c.want) + } + } +} + +// TestCoerceArg covers the key/value split, the := walrus raw-JSON form, and +// error cases. +func TestCoerceArg(t *testing.T) { + t.Run("plain string", func(t *testing.T) { + k, v, err := coerceArg("name=foo") + require.NoError(t, err) + require.Equal(t, "name", k) + require.Equal(t, "foo", v) + }) + t.Run("bool", func(t *testing.T) { + k, v, err := coerceArg("flag=true") + require.NoError(t, err) + require.Equal(t, "flag", k) + require.Equal(t, true, v) + }) + t.Run("int", func(t *testing.T) { + _, v, err := coerceArg("limit=50") + require.NoError(t, err) + require.Equal(t, int64(50), v) + }) + t.Run("empty value", func(t *testing.T) { + k, v, err := coerceArg("note=") + require.NoError(t, err) + require.Equal(t, "note", k) + require.Equal(t, "", v) + }) + t.Run("walrus forces raw string", func(t *testing.T) { + // version:="1.0" must stay the string "1.0", not the number 1.0. + k, v, err := coerceArg(`version:="1.0"`) + require.NoError(t, err) + require.Equal(t, "version", k) + require.Equal(t, "1.0", v) + }) + t.Run("walrus parses raw JSON object", func(t *testing.T) { + k, v, err := coerceArg(`opts:={"a":1}`) + require.NoError(t, err) + require.Equal(t, "opts", k) + require.Equal(t, map[string]any{"a": float64(1)}, v) + }) + t.Run("walrus invalid JSON errors", func(t *testing.T) { + _, _, err := coerceArg(`x:=not-json`) + require.Error(t, err) + }) + t.Run("missing equals errors", func(t *testing.T) { + _, _, err := coerceArg("noequals") + require.Error(t, err) + }) + t.Run("empty key errors", func(t *testing.T) { + _, _, err := coerceArg("=value") + require.Error(t, err) + }) +} + +// TestLowerCallArgs_Precedence asserts the three precedence layers: base JSON, +// inline JSON merged over it, and --arg merged on top (last wins per key). +func TestLowerCallArgs_Precedence(t *testing.T) { + cmd, _ := newCallTestCmd(t) + + t.Run("inline json plus arg override", func(t *testing.T) { + obj, err := lowerCallArgs(cmd, `{"a":1,"b":2}`, "", []string{"b=3"}) + require.NoError(t, err) + require.Equal(t, map[string]any{"a": float64(1), "b": int64(3)}, obj) + }) + + t.Run("later arg replaces earlier same key", func(t *testing.T) { + obj, err := lowerCallArgs(cmd, "", "", []string{"k=first", "k=second"}) + require.NoError(t, err) + require.Equal(t, "second", obj["k"]) + }) + + t.Run("arg over inline json over file base", func(t *testing.T) { + // Write a base file, then merge inline JSON and --arg over it. + base := writeTempJSON(t, `{"a":"file","b":"file","c":"file"}`) + obj, err := lowerCallArgs(cmd, `{"b":"inline","c":"inline"}`, base, []string{"c=arg"}) + require.NoError(t, err) + require.Equal(t, "file", obj["a"]) // only in the file base + require.Equal(t, "inline", obj["b"]) // inline beats file + require.Equal(t, "arg", obj["c"]) // arg beats inline + file + }) +} + +// TestLowerCallArgs_StdinBase asserts --json - reads the base object from stdin. +func TestLowerCallArgs_StdinBase(t *testing.T) { + cmd, _ := newCallTestCmd(t) + cmd.SetIn(strings.NewReader(`{"from":"stdin","n":1}`)) + obj, err := lowerCallArgs(cmd, "-", "", []string{"n=2"}) + require.NoError(t, err) + require.Equal(t, "stdin", obj["from"]) + require.Equal(t, int64(2), obj["n"]) +} + +// TestLowerCallArgs_NonObjectRejected asserts a non-object base JSON is refused. +func TestLowerCallArgs_NonObjectRejected(t *testing.T) { + cmd, _ := newCallTestCmd(t) + _, err := lowerCallArgs(cmd, `[1,2,3]`, "", nil) + require.Error(t, err) + require.Contains(t, err.Error(), "JSON object") +} + +// TestCallDry_NoDaemon asserts --dry prints the lowered object and the target +// tool WITHOUT invoking the daemon (the stub records any call). +func TestCallDry_NoDaemon(t *testing.T) { + orig := callDaemonTool + t.Cleanup(func() { callDaemonTool = orig }) + + called := false + callDaemonTool = func(string, string, map[string]any) (json.RawMessage, error) { + called = true + return nil, nil + } + + cmd, buf := newCallTestCmd(t) + callDry = true + callJSON = `{"id":"pkg/foo.go::Bar"}` + callArgs = []string{"depth=2", "verbose=true"} + + require.NoError(t, runCall(cmd, []string{"get_callers"})) + require.False(t, called, "--dry must NOT invoke the daemon (not even tool_profile)") + + out := buf.String() + require.Contains(t, out, "tool: get_callers") + // The lowered object is printed as indented JSON. + require.Contains(t, out, `"id": "pkg/foo.go::Bar"`) + require.Contains(t, out, `"depth": 2`) + require.Contains(t, out, `"verbose": true`) +} + +// cannedToolProfileJSON is a minimal tool_profile response with a descriptors +// array covering a read tool and a mutating tool. +const cannedToolProfileJSON = `{ + "live": ["search_symbols", "get_callers"], + "deferred": ["edit_file", "rename_symbol"], + "descriptors": [ + {"name":"search_symbols","category":"nav","mutating":false,"presets":["core","nav","readonly"],"summary":"BM25 symbol search."}, + {"name":"get_callers","category":"nav","mutating":false,"presets":["core","nav","readonly"],"summary":"Who calls a function."}, + {"name":"edit_file","category":"edit","mutating":true,"presets":["core","edit"],"summary":"Edit a file."}, + {"name":"rename_symbol","category":"edit","mutating":true,"presets":["edit"],"summary":"Rename across files."} + ] +}` + +// TestCall_UnknownToolSuggestsNearest asserts that, when a daemon is reachable +// and returns a catalog, an unknown tool name yields an error listing the +// nearest matches plus the `gortex tools search` hint. +func TestCall_UnknownToolSuggestsNearest(t *testing.T) { + orig := callDaemonTool + t.Cleanup(func() { callDaemonTool = orig }) + + var calledTools []string + callDaemonTool = func(_ string, tool string, _ map[string]any) (json.RawMessage, error) { + calledTools = append(calledTools, tool) + if tool == "tool_profile" { + return json.RawMessage(cannedToolProfileJSON), nil + } + t.Fatalf("must not call %q after a failed name validation", tool) + return nil, nil + } + + cmd, _ := newCallTestCmd(t) + err := runCall(cmd, []string{"get_caller"}) // typo of get_callers + require.Error(t, err) + require.Contains(t, err.Error(), "unknown tool") + require.Contains(t, err.Error(), "get_callers", "should suggest the nearest match") + require.Contains(t, err.Error(), "gortex tools search") + require.Equal(t, []string{"tool_profile"}, calledTools, "only the catalog fetch should happen") +} + +// TestCall_KnownMutatingToolWarns asserts a known mutating tool both forwards +// the call and prints the stderr write note (unless --quiet). +func TestCall_KnownMutatingToolWarns(t *testing.T) { + orig := callDaemonTool + t.Cleanup(func() { callDaemonTool = orig }) + + callDaemonTool = func(_ string, tool string, args map[string]any) (json.RawMessage, error) { + if tool == "tool_profile" { + return json.RawMessage(cannedToolProfileJSON), nil + } + require.Equal(t, "edit_file", tool) + require.Equal(t, "json", args["format"]) + return json.RawMessage(`{"status":"ok"}`), nil + } + + cmd, buf := newCallTestCmd(t) + require.NoError(t, runCall(cmd, []string{"edit_file"})) + out := buf.String() + require.Contains(t, out, "note: edit_file writes") + require.Contains(t, out, `"status": "ok"`) +} + +// TestCall_QuietSuppressesMutatingNote asserts --quiet hides the write note. +func TestCall_QuietSuppressesMutatingNote(t *testing.T) { + orig := callDaemonTool + t.Cleanup(func() { callDaemonTool = orig }) + callDaemonTool = func(_ string, tool string, _ map[string]any) (json.RawMessage, error) { + if tool == "tool_profile" { + return json.RawMessage(cannedToolProfileJSON), nil + } + return json.RawMessage(`{}`), nil + } + + cmd, buf := newCallTestCmd(t) + callQuiet = true + require.NoError(t, runCall(cmd, []string{"edit_file"})) + require.NotContains(t, buf.String(), "note:") +} + +// TestCall_NoDaemonSkipsValidation asserts that when the catalog fetch fails +// (no daemon), validation is skipped and the normal call path runs — surfacing +// whatever the call returns rather than an "unknown tool" error. +func TestCall_NoDaemonSkipsValidation(t *testing.T) { + orig := callDaemonTool + t.Cleanup(func() { callDaemonTool = orig }) + + callDaemonTool = func(_ string, tool string, _ map[string]any) (json.RawMessage, error) { + if tool == "tool_profile" { + return nil, daemonRequiredErr(".") // no catalog available + } + require.Equal(t, "some_tool", tool, "the call must proceed despite the catalog miss") + return json.RawMessage(`{"ok":true}`), nil + } + + cmd, buf := newCallTestCmd(t) + require.NoError(t, runCall(cmd, []string{"some_tool"})) + require.Contains(t, buf.String(), `"ok": true`) +} + +// TestCall_FormatGCXVerbatim asserts gcx/toon output is printed verbatim, not +// re-indented through emitDaemonJSON. +func TestCall_FormatGCXVerbatim(t *testing.T) { + orig := callDaemonTool + t.Cleanup(func() { callDaemonTool = orig }) + + callDaemonTool = func(_ string, tool string, args map[string]any) (json.RawMessage, error) { + if tool == "tool_profile" { + return json.RawMessage(cannedToolProfileJSON), nil + } + require.Equal(t, "gcx", args["format"], "the chosen format must be forwarded") + return json.RawMessage("GCX1|results|...\n"), nil + } + + cmd, buf := newCallTestCmd(t) + callFormat = "gcx" + require.NoError(t, runCall(cmd, []string{"search_symbols"})) + require.Equal(t, "GCX1|results|...\n", buf.String()) +} + +// TestCall_DaemonRequired asserts that, with no daemon running and no stub, the +// real call path returns the actionable daemon-required error (mirrors +// query_daemon_required_test.go). The catalog fetch fails first (no daemon), so +// validation is skipped and the call itself surfaces the error. +func TestCall_DaemonRequired(t *testing.T) { + // Point at a temp dir no daemon could possibly track so resolveExecutor + // returns ErrNoExecutor -> daemonRequiredErr. + cmd, _ := newCallTestCmd(t) + callIndex = t.TempDir() + err := runCall(cmd, []string{"search_symbols"}) + require.Error(t, err) + require.Contains(t, err.Error(), "gortex track") +} + +// writeTempJSON writes content to a temp file and returns its path. +func writeTempJSON(t *testing.T, content string) string { + t.Helper() + dir := t.TempDir() + path := dir + "/base.json" + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + return path +} diff --git a/cmd/gortex/cli_daemon.go b/cmd/gortex/cli_daemon.go new file mode 100644 index 0000000..b672fa5 --- /dev/null +++ b/cmd/gortex/cli_daemon.go @@ -0,0 +1,166 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "path/filepath" + + "github.com/zzet/gortex/internal/daemon" + "github.com/zzet/gortex/internal/pathkey" +) + +// ErrNoExecutor signals that no warm daemon owns the repo and no explicit +// daemonless path (--oneshot) was selected — the caller decides whether to +// fall back (Stage 1) or refuse (Stage 3). +var ErrNoExecutor = errors.New("no warm daemon and --oneshot not set") + +// ErrRepoNotTracked is the typed form of the daemon's repo_not_tracked +// refusal, distinguished so a CLI command can fall back rather than treat +// it as a hard error. +var ErrRepoNotTracked = errors.New("repository not tracked by the daemon") + +// cliExecutor runs a registered MCP tool by name and returns its raw +// result JSON (the same payload the MCP server returns). +type cliExecutor interface { + CallTool(ctx context.Context, tool string, args map[string]any) (json.RawMessage, error) + Close() error +} + +// daemonExecutor relays a one-shot tools/call over the daemon's AF_UNIX +// ModeMCP channel — the same warm graph the editor proxies hit, no cold +// index. It pins the JSON wire format so per-tool decoding is stable. +type daemonExecutor struct { + client *daemon.Client + nextID int +} + +func (d *daemonExecutor) CallTool(_ context.Context, tool string, args map[string]any) (json.RawMessage, error) { + d.nextID++ + frame, err := buildToolCallFrame(d.nextID, tool, args) + if err != nil { + return nil, err + } + if err := d.client.WriteMCPFrame(frame); err != nil { + return nil, err + } + resp, err := d.client.ReadMCPFrame() + if err != nil { + return nil, err + } + return extractToolResult(resp) +} + +// buildToolCallFrame constructs the JSON-RPC tools/call frame, pinning the +// JSON wire format so the daemon's per-client GCX/TOON auto-selection does +// not defeat the per-tool decode. +func buildToolCallFrame(id int, tool string, args map[string]any) ([]byte, error) { + if args == nil { + args = map[string]any{} + } + // Default to JSON, but honour a caller-provided format (e.g. + // mermaid / dot for diagram output) so the CLI can request the + // daemon's other renderers. + if _, ok := args["format"]; !ok { + args["format"] = "json" + } + return json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": id, + "method": "tools/call", + "params": map[string]any{"name": tool, "arguments": args}, + }) +} + +func (d *daemonExecutor) Close() error { return d.client.Close() } + +// extractToolResult unwraps a JSON-RPC tools/call response: a +// repo_not_tracked error maps to the typed sentinel, any other error is +// surfaced verbatim, and a success returns the tool's JSON payload (the +// text of the first content block). +func extractToolResult(resp []byte) (json.RawMessage, error) { + var rpc struct { + Result json.RawMessage `json:"result"` + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + Data struct { + ErrorCode string `json:"error_code"` + } `json:"data"` + } `json:"error"` + } + if err := json.Unmarshal(resp, &rpc); err != nil { + return nil, fmt.Errorf("decode daemon response: %w", err) + } + if rpc.Error != nil { + if rpc.Error.Data.ErrorCode == "repo_not_tracked" { + return nil, ErrRepoNotTracked + } + return nil, errors.New(rpc.Error.Message) + } + var res struct { + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + IsError bool `json:"isError"` + } + if err := json.Unmarshal(rpc.Result, &res); err != nil || len(res.Content) == 0 { + return rpc.Result, nil + } + payload := json.RawMessage(res.Content[0].Text) + if res.IsError { + return nil, errors.New(res.Content[0].Text) + } + return payload, nil +} + +// resolveExecutor decides where a CLI graph query runs. This Stage-1 slice +// covers the daemon-first case (a warm daemon that owns the repo) and the +// no-executor case; --oneshot and autostart land with the shared +// constructor and the autostart primitive. +func resolveExecutor(repoPath string) (cliExecutor, error) { + abs, err := filepath.Abs(repoPath) + if err != nil { + abs = repoPath + } + if !daemon.IsRunning() { + return nil, ErrNoExecutor + } + if !daemonOwnsRepo(abs) { + return nil, ErrNoExecutor + } + c, err := daemon.Dial(daemon.Handshake{Mode: daemon.ModeMCP, ClientName: "cli", CWD: abs}) + if err != nil { + return nil, ErrNoExecutor + } + return &daemonExecutor{client: c}, nil +} + +// daemonOwnsRepo reports whether the running daemon tracks a repo that +// covers abs (so a daemon query won't answer empty for an untracked path). +func daemonOwnsRepo(abs string) bool { + c, err := daemon.Dial(daemon.Handshake{Mode: daemon.ModeControl, ClientName: "cli"}) + if err != nil { + return false + } + defer c.Close() + resp, err := c.Control(daemon.ControlStatus, nil) + if err != nil || !resp.OK { + return false + } + var st daemon.StatusResponse + if err := json.Unmarshal(resp.Result, &st); err != nil { + return false + } + for _, repo := range st.TrackedRepos { + if repo.Path == "" { + continue + } + if pathkey.HasPathPrefix(abs, repo.Path) { + return true + } + } + return false +} diff --git a/cmd/gortex/cli_daemon_test.go b/cmd/gortex/cli_daemon_test.go new file mode 100644 index 0000000..84da42c --- /dev/null +++ b/cmd/gortex/cli_daemon_test.go @@ -0,0 +1,102 @@ +package main + +import ( + "encoding/json" + "errors" + "testing" +) + +// TestBuildToolCallFrame_PinsJSON asserts every relayed tools/call forces +// format:"json" so the per-tool decode is stable regardless of the +// daemon's client-based wire-format auto-selection. +func TestBuildToolCallFrame_PinsJSON(t *testing.T) { + frame, err := buildToolCallFrame(1, "search_symbols", map[string]any{"query": "Foo"}) + if err != nil { + t.Fatal(err) + } + var m struct { + Method string `json:"method"` + Params struct { + Name string `json:"name"` + Arguments map[string]any `json:"arguments"` + } `json:"params"` + } + if err := json.Unmarshal(frame, &m); err != nil { + t.Fatal(err) + } + if m.Method != "tools/call" || m.Params.Name != "search_symbols" { + t.Fatalf("unexpected frame: %s", frame) + } + if m.Params.Arguments["format"] != "json" { + t.Fatalf("format must be pinned to json, got %v", m.Params.Arguments["format"]) + } + if m.Params.Arguments["query"] != "Foo" { + t.Fatalf("caller args must be preserved, got %v", m.Params.Arguments) + } +} + +// TestBuildToolCallFrame_NilArgs asserts a nil args map still pins json. +func TestBuildToolCallFrame_NilArgs(t *testing.T) { + frame, err := buildToolCallFrame(2, "graph_stats", nil) + if err != nil { + t.Fatal(err) + } + if !contains(frame, `"format":"json"`) { + t.Fatalf("nil args must still pin json: %s", frame) + } +} + +func contains(b []byte, sub string) bool { + return len(b) >= len(sub) && (string(b) == sub || indexOf(string(b), sub) >= 0) +} + +func indexOf(s, sub string) int { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return i + } + } + return -1 +} + +// TestExtractToolResult_RepoNotTracked maps the daemon's typed refusal to +// the sentinel so the CLI falls back instead of erroring. +func TestExtractToolResult_RepoNotTracked(t *testing.T) { + resp := []byte(`{"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"repository not tracked","data":{"error_code":"repo_not_tracked"}}}`) + _, err := extractToolResult(resp) + if !errors.Is(err, ErrRepoNotTracked) { + t.Fatalf("want ErrRepoNotTracked, got %v", err) + } +} + +// TestExtractToolResult_RealErrorSurfaced asserts a genuine tool error is +// surfaced verbatim (not swallowed as "not tracked"). +func TestExtractToolResult_RealErrorSurfaced(t *testing.T) { + resp := []byte(`{"jsonrpc":"2.0","id":1,"error":{"code":-32602,"message":"bad symbol id"}}`) + _, err := extractToolResult(resp) + if err == nil || errors.Is(err, ErrRepoNotTracked) { + t.Fatalf("a real error must surface, got %v", err) + } + if err.Error() != "bad symbol id" { + t.Fatalf("error message should be verbatim, got %q", err.Error()) + } +} + +// TestExtractToolResult_Success returns the tool JSON payload from the +// result content block. +func TestExtractToolResult_Success(t *testing.T) { + resp := []byte(`{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"{\"total\":3}"}]}}`) + out, err := extractToolResult(resp) + if err != nil { + t.Fatal(err) + } + var payload struct { + Total int `json:"total"` + } + if err := json.Unmarshal(out, &payload); err != nil { + t.Fatalf("payload should be the tool json: %v (%s)", err, out) + } + if payload.Total != 3 { + t.Fatalf("want total 3, got %d", payload.Total) + } +} diff --git a/cmd/gortex/cli_progress.go b/cmd/gortex/cli_progress.go new file mode 100644 index 0000000..f993312 --- /dev/null +++ b/cmd/gortex/cli_progress.go @@ -0,0 +1,21 @@ +package main + +import ( + "github.com/spf13/cobra" + + "github.com/zzet/gortex/internal/progress" +) + +// newCLISpinner constructs a Spinner (the compact face of the live Tracker) +// bound to cmd's stderr and starts it. The caller is responsible for +// Done()/Fail(); when the global --no-progress flag is set the spinner falls +// back to plain text. Reporter stage transitions render as live checklist +// rows either way. +func newCLISpinner(cmd *cobra.Command, label string) *progress.Spinner { + sp := progress.NewSpinner(cmd.ErrOrStderr()) + if noProgress { + sp.Disable() + } + sp.Start(label) + return sp +} diff --git a/cmd/gortex/clones.go b/cmd/gortex/clones.go new file mode 100644 index 0000000..a8d0890 --- /dev/null +++ b/cmd/gortex/clones.go @@ -0,0 +1,83 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" +) + +// clonesDaemonTool is the daemon-tool relay seam. It is indirected through a +// package var so tests can stub the daemon call (asserting the lowered +// tool + args) without a running daemon. +var clonesDaemonTool = requireDaemonTool + +var ( + clonesIndex string + clonesMinSimilarity float64 + clonesDeadOnly bool + clonesPathPrefix string + clonesRepo string + clonesLimit int + clonesFormat string +) + +var clonesCmd = &cobra.Command{ + Use: "clones", + Short: "Surface near-duplicate (clone) function clusters (find_clones)", + Long: `Queries the EdgeSimilarTo graph layer for near-duplicate function/method +clusters found by the MinHash + LSH clone-detection pass — copy-paste and +renamed-variable (Type-1/Type-2) clones. Every member is flagged dead (zero +incoming calls/refs), so --dead-only yields the "dead duplicates of live code" +diagnostic. + + gortex clones --dead-only --path-prefix internal/ + gortex clones --min-similarity 0.85 --limit 20 + +Requires a running daemon that tracks the repo.`, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, _ []string) error { + toolArgs := map[string]any{} + if cmd.Flags().Changed("min-similarity") { + toolArgs["min_similarity"] = clonesMinSimilarity + } + if cmd.Flags().Changed("dead-only") { + toolArgs["dead_only"] = clonesDeadOnly + } + if cmd.Flags().Changed("path-prefix") { + toolArgs["path_prefix"] = clonesPathPrefix + } + if cmd.Flags().Changed("repo-filter") { + toolArgs["repo"] = clonesRepo + } + if cmd.Flags().Changed("limit") { + toolArgs["limit"] = clonesLimit + } + if clonesFormat != "" { + toolArgs["format"] = clonesFormat + } + raw, err := clonesDaemonTool(clonesIndex, "find_clones", toolArgs) + if err != nil { + return err + } + switch clonesFormat { + case "gcx", "toon": + fmt.Fprintln(cmd.OutOrStdout(), strings.TrimRight(string(raw), "\n")) + return nil + default: + return emitDaemonJSON(cmd, raw) + } + }, +} + +func init() { + clonesCmd.Flags().StringVar(&clonesIndex, "index", ".", "repository path the daemon must track") + clonesCmd.Flags().StringVar(&clonesIndex, "repo", ".", "alias for --index") + clonesCmd.Flags().Float64Var(&clonesMinSimilarity, "min-similarity", 0, "report clone pairs at or above this estimated Jaccard similarity (0..1) (min_similarity)") + clonesCmd.Flags().BoolVar(&clonesDeadOnly, "dead-only", false, "only clusters containing a dead-code symbol — the dead-duplicates view (dead_only)") + clonesCmd.Flags().StringVar(&clonesPathPrefix, "path-prefix", "", "restrict to symbols whose file path starts with this prefix (path_prefix)") + clonesCmd.Flags().StringVar(&clonesRepo, "repo-filter", "", "restrict to symbols in a specific repository (RepoPrefix exact match) (repo)") + clonesCmd.Flags().IntVar(&clonesLimit, "limit", 0, "maximum clusters to return (default 50, largest-first)") + clonesCmd.Flags().StringVar(&clonesFormat, "format", "json", "output / wire format: json|gcx|toon|text") + rootCmd.AddCommand(clonesCmd) +} diff --git a/cmd/gortex/clones_test.go b/cmd/gortex/clones_test.go new file mode 100644 index 0000000..107ebda --- /dev/null +++ b/cmd/gortex/clones_test.go @@ -0,0 +1,96 @@ +package main + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// capturedClones records the (repo, tool, args) the clones verb lowered to. +type capturedClones struct { + repo string + tool string + args map[string]any +} + +func runClones(t *testing.T, argv ...string) (*capturedClones, *bytes.Buffer, error) { + t.Helper() + resetClonesFlags() + + orig := clonesDaemonTool + t.Cleanup(func() { clonesDaemonTool = orig }) + + var cap *capturedClones + clonesDaemonTool = func(repo, tool string, args map[string]any) (json.RawMessage, error) { + cap = &capturedClones{repo: repo, tool: tool, args: args} + return json.RawMessage(`{"status":"ok"}`), nil + } + + buf := &bytes.Buffer{} + rootCmd.SetOut(buf) + rootCmd.SetErr(buf) + rootCmd.SetIn(strings.NewReader("")) + rootCmd.SetArgs(append([]string{"clones"}, argv...)) + err := rootCmd.Execute() + return cap, buf, err +} + +func resetClonesFlags() { + resetCobraFlags(clonesCmd) + clonesIndex = "." + clonesMinSimilarity = 0 + clonesDeadOnly = false + clonesPathPrefix = "" + clonesRepo = "" + clonesLimit = 0 + clonesFormat = "json" +} + +func TestClones_DeadOnly(t *testing.T) { + cap, _, err := runClones(t, "--dead-only") + require.NoError(t, err) + require.NotNil(t, cap) + require.Equal(t, "find_clones", cap.tool) + require.Equal(t, true, cap.args["dead_only"]) +} + +func TestClones_AllFlags(t *testing.T) { + cap, _, err := runClones(t, + "--min-similarity", "0.85", "--dead-only", + "--path-prefix", "internal/", "--repo-filter", "gortex", "--limit", "20") + require.NoError(t, err) + require.Equal(t, "find_clones", cap.tool) + require.Equal(t, 0.85, cap.args["min_similarity"]) + require.Equal(t, true, cap.args["dead_only"]) + require.Equal(t, "internal/", cap.args["path_prefix"]) + require.Equal(t, "gortex", cap.args["repo"]) + require.Equal(t, 20, cap.args["limit"]) +} + +func TestClones_OnlyChangedFlagsSent(t *testing.T) { + cap, _, err := runClones(t) + require.NoError(t, err) + require.Equal(t, "find_clones", cap.tool) + _, hasSim := cap.args["min_similarity"] + require.False(t, hasSim, "unset --min-similarity must not be forwarded") + _, hasDead := cap.args["dead_only"] + require.False(t, hasDead, "unset --dead-only must not be forwarded") + require.Equal(t, "json", cap.args["format"]) +} + +// TestClones_DaemonRequired asserts the real call path returns the +// daemon-required error when no daemon tracks the repo. +func TestClones_DaemonRequired(t *testing.T) { + resetClonesFlags() + buf := &bytes.Buffer{} + rootCmd.SetOut(buf) + rootCmd.SetErr(buf) + rootCmd.SetIn(strings.NewReader("")) + rootCmd.SetArgs([]string{"clones", "--dead-only", "--index", t.TempDir()}) + err := rootCmd.Execute() + require.Error(t, err) + require.Contains(t, err.Error(), "gortex track") +} diff --git a/cmd/gortex/cloud.go b/cmd/gortex/cloud.go new file mode 100644 index 0000000..9eb3e63 --- /dev/null +++ b/cmd/gortex/cloud.go @@ -0,0 +1,200 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/zzet/gortex/internal/daemon" +) + +// cloud is the umbrella command for all cloud-side flows that run +// from the local CLI: login, list, logout. These commands talk to +// the gortex-cloud control plane (API) — they never run cloud code +// in-process. The two repos are decoupled at the module boundary. +var cloudCmd = &cobra.Command{ + Use: "cloud", + Short: "Manage Gortex Cloud connections (login, list, logout)", +} + +var ( + cloudEndpoint string + cloudWorkspace string + cloudToken string +) + +var cloudLoginCmd = &cobra.Command{ + Use: "login", + Short: "Connect this machine to a Gortex Cloud workspace", + Long: `Add a [[server]] entry to ~/.gortex/servers.toml pointing at the +gortex-cloud control plane (mcp.gortex.dev/v1 by default) for one +workspace. The token is the gtx_live_… string the admin console +prints once at issuance. + +Example: + gortex cloud login --endpoint https://mcp.gortex.dev/v1 \ + --workspace tuck --token gtx_live_abc... +`, + RunE: runCloudLogin, +} + +var cloudListCmd = &cobra.Command{ + Use: "list", + Short: "Show every cloud workspace this machine is connected to", + RunE: runCloudList, +} + +var cloudLogoutCmd = &cobra.Command{ + Use: "logout", + Short: "Remove a cloud workspace from ~/.gortex/servers.toml", + RunE: runCloudLogout, +} + +func init() { + cloudLoginCmd.Flags().StringVar(&cloudEndpoint, "endpoint", "https://mcp.gortex.dev/v1", "cloud control-plane base URL") + cloudLoginCmd.Flags().StringVar(&cloudWorkspace, "workspace", "", "workspace slug to bind this token to (required)") + cloudLoginCmd.Flags().StringVar(&cloudToken, "token", "", "API token (gtx_live_… or gtx_test_…) issued in the admin console (required)") + _ = cloudLoginCmd.MarkFlagRequired("workspace") + _ = cloudLoginCmd.MarkFlagRequired("token") + + cloudLogoutCmd.Flags().StringVar(&cloudWorkspace, "workspace", "", "workspace slug to disconnect (required)") + _ = cloudLogoutCmd.MarkFlagRequired("workspace") + + cloudCmd.AddCommand(cloudLoginCmd, cloudListCmd, cloudLogoutCmd) + rootCmd.AddCommand(cloudCmd) +} + +func runCloudLogin(_ *cobra.Command, _ []string) error { + cloudToken = strings.TrimSpace(cloudToken) + if cloudToken == "" { + return errors.New("--token is required") + } + if !strings.HasPrefix(cloudToken, "gtx_live_") && !strings.HasPrefix(cloudToken, "gtx_test_") { + return errors.New("--token must start with gtx_live_ or gtx_test_") + } + if cloudWorkspace == "" { + return errors.New("--workspace is required") + } + + // Verify the token by hitting /v1/workspaces/repos against the + // endpoint. If the call returns 401 we surface it before + // persisting any config. + if err := pingCloudEndpoint(cloudEndpoint, cloudToken); err != nil { + return fmt.Errorf("verify cloud connection: %w", err) + } + + path := daemon.ServersConfigPath() + cfg, err := daemon.LoadServersConfig(path) + if err != nil { + return fmt.Errorf("load servers config: %w", err) + } + + slug := "cloud-" + cloudWorkspace + for i, s := range cfg.Server { + if s.Slug == slug { + cfg.Server = append(cfg.Server[:i], cfg.Server[i+1:]...) + break + } + } + if err := cfg.AddServer(daemon.ServerEntry{ + Slug: slug, + URL: cloudEndpoint, + AuthToken: cloudToken, + Workspaces: []string{cloudWorkspace}, + }); err != nil { + return fmt.Errorf("add server: %w", err) + } + if err := cfg.Save(path); err != nil { + return fmt.Errorf("save servers config: %w", err) + } + fmt.Fprintf(os.Stdout, "logged in: workspace=%s endpoint=%s slug=%s\n", cloudWorkspace, cloudEndpoint, slug) + fmt.Fprintf(os.Stdout, "next: gortex daemon start (or already running)\n") + return nil +} + +func runCloudList(_ *cobra.Command, _ []string) error { + cfg, err := daemon.LoadServersConfig(daemon.ServersConfigPath()) + if err != nil { + return err + } + if len(cfg.Server) == 0 { + fmt.Fprintln(os.Stdout, "no [[server]] entries — run `gortex cloud login` to add one") + return nil + } + fmt.Fprintf(os.Stdout, "%-24s %-46s %s\n", "SLUG", "ENDPOINT", "WORKSPACES") + for _, s := range cfg.Server { + fmt.Fprintf(os.Stdout, "%-24s %-46s %s\n", s.Slug, s.URL, strings.Join(s.Workspaces, ",")) + } + return nil +} + +func runCloudLogout(_ *cobra.Command, _ []string) error { + path := daemon.ServersConfigPath() + cfg, err := daemon.LoadServersConfig(path) + if err != nil { + return err + } + slug := "cloud-" + cloudWorkspace + out := cfg.Server[:0] + removed := false + for _, s := range cfg.Server { + if s.Slug == slug { + removed = true + continue + } + out = append(out, s) + } + cfg.Server = out + if !removed { + return fmt.Errorf("no entry with slug %q found", slug) + } + if err := cfg.Save(path); err != nil { + return err + } + fmt.Fprintf(os.Stdout, "logged out: %s\n", slug) + return nil +} + +func pingCloudEndpoint(endpoint, token string) error { + if !strings.HasSuffix(endpoint, "/") { + endpoint += "/" + } + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, + strings.TrimSuffix(endpoint, "/")+"/workspaces/repos", nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+token) + c := &http.Client{Timeout: 10 * time.Second} + resp, err := c.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode == http.StatusOK { + return nil + } + if resp.StatusCode == http.StatusUnauthorized { + return errors.New("token rejected (401)") + } + // Show a short tail of the response to help debug. + if len(body) > 200 { + body = body[:200] + } + return fmt.Errorf("unexpected %d: %s", resp.StatusCode, bytes.TrimSpace(body)) +} + +// Keep encoding/json import — `gortex cloud login` will grow to +// parse a /v1/me response in a follow-up; declaring it now keeps +// the import section stable for that change. +var _ = json.RawMessage(nil) diff --git a/cmd/gortex/config_cmd.go b/cmd/gortex/config_cmd.go new file mode 100644 index 0000000..e357691 --- /dev/null +++ b/cmd/gortex/config_cmd.go @@ -0,0 +1,465 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" + + "github.com/zzet/gortex/internal/config" + "github.com/zzet/gortex/internal/daemon" + "github.com/zzet/gortex/internal/excludes" +) + +// Flags are shared by add/remove. Cobra parses them once per command, +// so declaring package-level vars is fine here. +var ( + excludeGlobalFlag bool + excludeRepoFlag string +) + +var configCmd = &cobra.Command{ + Use: "config", + Short: "Manage Gortex configuration", +} + +var configExcludeCmd = &cobra.Command{ + Use: "exclude", + Short: "Manage the effective ignore list (indexing + watching)", + Long: `Add, list, or remove patterns from the effective ignore list used by both +indexing and watching. Patterns follow .gitignore semantics. + +Targets (in precedence order; later layers override earlier): + 1. Builtin baseline (read-only) + 2. Global - ~/.gortex/config.yaml (--global) + 3. Repo - GlobalConfig.repos[].exclude (--repo ) + 4. Workspace - ./.gortex.yaml at the repo root (default) + +Use "!pattern" in a later layer to re-include something an earlier layer +excluded.`, +} + +var configExcludeAddCmd = &cobra.Command{ + Use: "add ", + Short: "Append a pattern to the selected target", + Long: `Appends a pattern. If the argument is an existing directory, it is +normalised to a repo-root-relative gitignore pattern (trailing slash). +Raw glob patterns (containing *, ?, [, leading / or !) are written +verbatim.`, + Args: cobra.ExactArgs(1), + RunE: runConfigExcludeAdd, +} + +var configExcludeListCmd = &cobra.Command{ + Use: "list", + Short: "Show the effective ignore list with source annotations", + RunE: runConfigExcludeList, +} + +var configExcludeRemoveCmd = &cobra.Command{ + Use: "remove ", + Short: "Remove a pattern from the selected target", + Args: cobra.ExactArgs(1), + RunE: runConfigExcludeRemove, +} + +func init() { + rootCmd.AddCommand(configCmd) + configCmd.AddCommand(configExcludeCmd) + configExcludeCmd.AddCommand(configExcludeAddCmd) + configExcludeCmd.AddCommand(configExcludeListCmd) + configExcludeCmd.AddCommand(configExcludeRemoveCmd) + + for _, c := range []*cobra.Command{configExcludeAddCmd, configExcludeRemoveCmd} { + c.Flags().BoolVar(&excludeGlobalFlag, "global", false, + "write to ~/.gortex/config.yaml (GlobalConfig.exclude)") + c.Flags().StringVar(&excludeRepoFlag, "repo", "", + "write to the named RepoEntry in the global config") + } +} + +// --- Target abstraction --- + +// excludeTarget encapsulates load/save for one of the three writable +// exclude locations. repoRoot is set for workspace targets (used to +// anchor path normalisation); it's empty for global and repo-entry +// targets where patterns are not path-anchored. +type excludeTarget struct { + label string + repoRoot string // "" for non-workspace targets + + load func() ([]string, error) + save func(patterns []string) error +} + +func resolveTarget() (*excludeTarget, error) { + if excludeGlobalFlag && excludeRepoFlag != "" { + return nil, fmt.Errorf("--global and --repo are mutually exclusive") + } + switch { + case excludeGlobalFlag: + return newGlobalTarget() + case excludeRepoFlag != "": + return newRepoEntryTarget(excludeRepoFlag) + default: + return newWorkspaceTarget() + } +} + +func newWorkspaceTarget() (*excludeTarget, error) { + path, root, err := findWorkspaceConfigPath() + if err != nil { + return nil, err + } + return &excludeTarget{ + label: "workspace " + path, + repoRoot: root, + load: func() ([]string, error) { + cfg, err := readWorkspaceYAML(path) + if err != nil { + return nil, err + } + if cfg == nil { + return nil, nil + } + // Fold legacy keys so `list` and `remove` see what the old + // file actually contributed. + patterns := append([]string{}, cfg.Exclude...) + if len(cfg.Exclude) == 0 { + patterns = append(patterns, cfg.Index.Exclude...) + patterns = append(patterns, cfg.Watch.Exclude...) + } + return patterns, nil + }, + save: func(patterns []string) error { + cfg, err := readWorkspaceYAML(path) + if err != nil { + return err + } + if cfg == nil { + cfg = &config.Config{} + } + cfg.Exclude = patterns + // Scrub legacy keys once the user has written anything to + // the new shape — they've been migrated. + cfg.Index.Exclude = nil + cfg.Watch.Exclude = nil + return writeWorkspaceYAML(path, cfg) + }, + }, nil +} + +func newGlobalTarget() (*excludeTarget, error) { + return &excludeTarget{ + label: "global " + config.DefaultGlobalConfigPath(), + load: func() ([]string, error) { + gc, err := config.LoadGlobal() + if err != nil { + return nil, err + } + return append([]string{}, gc.Exclude...), nil + }, + save: func(patterns []string) error { + gc, err := config.LoadGlobal() + if err != nil { + return err + } + gc.Exclude = patterns + return gc.Save() + }, + }, nil +} + +func newRepoEntryTarget(name string) (*excludeTarget, error) { + return &excludeTarget{ + label: "repo entry " + name + " in " + config.DefaultGlobalConfigPath(), + load: func() ([]string, error) { + gc, err := config.LoadGlobal() + if err != nil { + return nil, err + } + entry := gc.FindRepoByPrefix(name) + if entry == nil { + return nil, fmt.Errorf("no repo entry named %q in global config", name) + } + return append([]string{}, entry.Exclude...), nil + }, + save: func(patterns []string) error { + gc, err := config.LoadGlobal() + if err != nil { + return err + } + entry := gc.FindRepoByPrefix(name) + if entry == nil { + return fmt.Errorf("no repo entry named %q in global config", name) + } + entry.Exclude = patterns + return gc.Save() + }, + }, nil +} + +// --- Command implementations --- + +func runConfigExcludeAdd(_ *cobra.Command, args []string) error { + t, err := resolveTarget() + if err != nil { + return err + } + pattern, err := normalizePattern(args[0], t.repoRoot) + if err != nil { + return err + } + existing, err := t.load() + if err != nil { + return err + } + if slices.Contains(existing, pattern) { + fmt.Fprintf(os.Stderr, "[gortex] already present in %s: %s\n", t.label, pattern) + return nil + } + existing = append(existing, pattern) + if err := t.save(existing); err != nil { + return err + } + fmt.Fprintf(os.Stderr, "[gortex] added %q to %s\n", pattern, t.label) + notifyDaemonReload() + return nil +} + +func runConfigExcludeRemove(_ *cobra.Command, args []string) error { + t, err := resolveTarget() + if err != nil { + return err + } + existing, err := t.load() + if err != nil { + return err + } + + // Accept both the raw arg and its normalised form so a user can + // remove by path just as they added. + candidates := map[string]struct{}{args[0]: {}} + if p, err := normalizePattern(args[0], t.repoRoot); err == nil { + candidates[p] = struct{}{} + } + + filtered := existing[:0] + removed := false + for _, p := range existing { + if _, drop := candidates[p]; drop { + removed = true + continue + } + filtered = append(filtered, p) + } + if !removed { + return fmt.Errorf("pattern not found in %s", t.label) + } + if err := t.save(filtered); err != nil { + return err + } + fmt.Fprintf(os.Stderr, "[gortex] removed %q from %s\n", args[0], t.label) + notifyDaemonReload() + return nil +} + +func runConfigExcludeList(_ *cobra.Command, _ []string) error { + rows := [][2]string{} + for _, p := range excludes.Builtin { + rows = append(rows, [2]string{"builtin", p}) + } + + gc, err := config.LoadGlobal() + if err == nil { + for _, p := range gc.Exclude { + rows = append(rows, [2]string{"global", p}) + } + } + + wsPath, wsRoot, wsErr := findWorkspaceConfigPath() + if wsErr == nil && wsRoot != "" { + wsPrefix := filepath.Base(wsRoot) + if gc != nil { + if entry := gc.FindRepoByPrefix(wsPrefix); entry != nil { + for _, p := range entry.Exclude { + rows = append(rows, [2]string{"repo:" + wsPrefix, p}) + } + } + } + // Only read the workspace file if it actually exists; findWorkspaceConfigPath + // returns a prospective path for the create case. + if _, err := os.Stat(wsPath); err == nil { + ws, err := readWorkspaceYAML(wsPath) + if err == nil && ws != nil { + for _, p := range ws.Exclude { + rows = append(rows, [2]string{"workspace", p}) + } + if len(ws.Exclude) == 0 { + for _, p := range ws.Index.Exclude { + rows = append(rows, [2]string{"workspace (legacy index.exclude)", p}) + } + for _, p := range ws.Watch.Exclude { + rows = append(rows, [2]string{"workspace (legacy watch.exclude)", p}) + } + } + } + } + } + + width := 0 + for _, r := range rows { + if len(r[0]) > width { + width = len(r[0]) + } + } + for _, r := range rows { + fmt.Printf("[%-*s] %s\n", width, r[0], r[1]) + } + return nil +} + +// --- Helpers --- + +// normalizePattern converts an arg to a canonical pattern. +// If the arg looks like a glob (contains *, ?, [, or leading ! or /), +// it's returned verbatim. If it's an existing filesystem path under +// repoRoot, it's normalised to a repo-relative gitignore pattern +// (trailing "/" for directories). Otherwise returned as-is — users +// may intentionally write literal patterns we can't stat. +func normalizePattern(arg, repoRoot string) (string, error) { + if looksLikeGlob(arg) { + return arg, nil + } + if repoRoot == "" { + return arg, nil + } + absArg, err := filepath.Abs(arg) + if err != nil { + return arg, nil + } + info, err := os.Stat(absArg) + if err != nil { + return arg, nil + } + rel, err := filepath.Rel(repoRoot, absArg) + if err != nil { + return arg, nil + } + rel = filepath.ToSlash(rel) + if rel == "." { + return "", fmt.Errorf("refusing to ignore the repo root") + } + if strings.HasPrefix(rel, "../") || rel == ".." { + return "", fmt.Errorf("path %s is outside the repo root %s", arg, repoRoot) + } + if info.IsDir() { + return rel + "/", nil + } + return rel, nil +} + +// looksLikeGlob reports whether s has pattern syntax that should be +// preserved verbatim rather than treated as a filesystem path. A leading +// "/" is intentionally NOT treated as a glob marker — users more often +// pass an absolute filesystem path than a root-anchored gitignore +// pattern; the latter is better expressed by running the command from +// the repo root with a relative arg. +func looksLikeGlob(s string) bool { + if strings.HasPrefix(s, "!") { + return true + } + return strings.ContainsAny(s, "*?[") +} + +// findWorkspaceConfigPath walks up from the cwd looking for an existing +// .gortex.yaml. When one is found, its path and containing dir are +// returned. Otherwise it returns a prospective path at the nearest +// git root (or cwd as a last resort) so `add` can create the file. +func findWorkspaceConfigPath() (path, repoRoot string, err error) { + cwd, err := os.Getwd() + if err != nil { + return "", "", err + } + dir := cwd + for { + candidate := filepath.Join(dir, ".gortex.yaml") + if _, statErr := os.Stat(candidate); statErr == nil { + return candidate, dir, nil + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + if root := gitToplevel(cwd); root != "" { + return filepath.Join(root, ".gortex.yaml"), root, nil + } + return filepath.Join(cwd, ".gortex.yaml"), cwd, nil +} + +func gitToplevel(start string) string { + dir := start + for { + if info, err := os.Stat(filepath.Join(dir, ".git")); err == nil && info.IsDir() { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + return "" + } + dir = parent + } +} + +// readWorkspaceYAML loads .gortex.yaml. Returns nil with no error when +// the file is absent — the workspace target creates it on save. +func readWorkspaceYAML(path string) (*config.Config, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + var cfg config.Config + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parsing %s: %w", path, err) + } + return &cfg, nil +} + +// writeWorkspaceYAML writes .gortex.yaml. The round-trip through the +// Config struct loses comments, which is an accepted trade-off until +// someone needs preservation. +func writeWorkspaceYAML(path string, cfg *config.Config) error { + data, err := yaml.Marshal(cfg) + if err != nil { + return err + } + return os.WriteFile(path, data, 0644) +} + +// notifyDaemonReload sends a Reload RPC when a daemon is running so +// exclude changes take effect without a restart. Non-fatal on error — +// the written config will be picked up on the daemon's next start. +func notifyDaemonReload() { + if !daemon.IsRunning() { + return + } + c, err := daemon.Dial(daemon.Handshake{Mode: daemon.ModeControl, ClientName: "cli-config"}) + if err != nil { + return + } + defer c.Close() + resp, err := c.Control(daemon.ControlReload, nil) + if err != nil || !resp.OK { + return + } + fmt.Fprintln(os.Stderr, "[gortex] daemon reloaded") +} diff --git a/cmd/gortex/config_cmd_test.go b/cmd/gortex/config_cmd_test.go new file mode 100644 index 0000000..fe8e2ee --- /dev/null +++ b/cmd/gortex/config_cmd_test.go @@ -0,0 +1,164 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLooksLikeGlob(t *testing.T) { + cases := []struct { + in string + want bool + }{ + {"pkg/foo", false}, + {"pkg/*.go", true}, + {"*.tmp", true}, + {"dir/", false}, + {"!keep", true}, + {"/abs", false}, // absolute paths handled by normalizePattern + {"abc?def", true}, + {"[0-9]", true}, + } + for _, tc := range cases { + if got := looksLikeGlob(tc.in); got != tc.want { + t.Errorf("looksLikeGlob(%q) = %v, want %v", tc.in, got, tc.want) + } + } +} + +func TestNormalizePattern_RawGlob(t *testing.T) { + p, err := normalizePattern("*.log", "/some/repo") + require.NoError(t, err) + assert.Equal(t, "*.log", p) +} + +func TestNormalizePattern_NoRepoRoot(t *testing.T) { + // Without a repo root to anchor against, the arg is passed through. + p, err := normalizePattern("something", "") + require.NoError(t, err) + assert.Equal(t, "something", p) +} + +func TestNormalizePattern_ExistingDir(t *testing.T) { + root := resolvePath(t, t.TempDir()) + nested := filepath.Join(root, "pkg", "generated") + require.NoError(t, os.MkdirAll(nested, 0755)) + + t.Chdir(root) + p, err := normalizePattern("pkg/generated", root) + require.NoError(t, err) + assert.Equal(t, "pkg/generated/", p) +} + +func TestNormalizePattern_ExistingFile(t *testing.T) { + root := resolvePath(t, t.TempDir()) + f := filepath.Join(root, "notes.txt") + require.NoError(t, os.WriteFile(f, []byte(""), 0644)) + + t.Chdir(root) + p, err := normalizePattern("notes.txt", root) + require.NoError(t, err) + assert.Equal(t, "notes.txt", p) +} + +func TestNormalizePattern_OutsideRepoRejected(t *testing.T) { + root := resolvePath(t, t.TempDir()) + outside := resolvePath(t, t.TempDir()) + t.Chdir(outside) + // Pass an absolute path under 'outside', matched against 'root'. + _, err := normalizePattern(outside, root) + require.Error(t, err) +} + +func TestNormalizePattern_RepoRootItselfRejected(t *testing.T) { + root := resolvePath(t, t.TempDir()) + t.Chdir(root) + _, err := normalizePattern(".", root) + require.Error(t, err) +} + +// resolvePath normalises temp-dir paths on macOS where /var is a symlink +// to /private/var; without this the test assertions flap between runs. +func resolvePath(t *testing.T, p string) string { + t.Helper() + resolved, err := filepath.EvalSymlinks(p) + require.NoError(t, err) + return resolved +} + +func TestFindWorkspaceConfigPath_ExistingFile(t *testing.T) { + root := resolvePath(t, t.TempDir()) + configPath := filepath.Join(root, ".gortex.yaml") + require.NoError(t, os.WriteFile(configPath, []byte(""), 0644)) + + sub := filepath.Join(root, "pkg", "deep") + require.NoError(t, os.MkdirAll(sub, 0755)) + t.Chdir(sub) + + path, wsRoot, err := findWorkspaceConfigPath() + require.NoError(t, err) + assert.Equal(t, configPath, path) + assert.Equal(t, root, wsRoot) +} + +func TestFindWorkspaceConfigPath_GitRootFallback(t *testing.T) { + root := resolvePath(t, t.TempDir()) + require.NoError(t, os.MkdirAll(filepath.Join(root, ".git"), 0755)) + sub := filepath.Join(root, "pkg", "deep") + require.NoError(t, os.MkdirAll(sub, 0755)) + t.Chdir(sub) + + path, wsRoot, err := findWorkspaceConfigPath() + require.NoError(t, err) + // No .gortex.yaml exists, so we get a prospective path at git root. + assert.Equal(t, filepath.Join(root, ".gortex.yaml"), path) + assert.Equal(t, root, wsRoot) +} + +func TestWorkspaceTarget_AddRemove(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(root, ".git"), 0755)) + t.Chdir(root) + + tgt, err := newWorkspaceTarget() + require.NoError(t, err) + + // Initial load: file doesn't exist → nil. + patterns, err := tgt.load() + require.NoError(t, err) + assert.Empty(t, patterns) + + // Save creates the file. + require.NoError(t, tgt.save([]string{"a/", "b/"})) + patterns, err = tgt.load() + require.NoError(t, err) + assert.Equal(t, []string{"a/", "b/"}, patterns) + + // Round-trip preserves only exclude (legacy keys scrubbed). + data, err := os.ReadFile(filepath.Join(root, ".gortex.yaml")) + require.NoError(t, err) + assert.Contains(t, string(data), "exclude:") + assert.NotContains(t, string(data), "index:") + assert.NotContains(t, string(data), "watch:") +} + +func TestWorkspaceTarget_LegacyFallback(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(root, ".git"), 0755)) + t.Chdir(root) + + // Simulate an existing legacy-shape .gortex.yaml. + legacyYAML := "index:\n exclude:\n - legacy-pat/**\n" + require.NoError(t, os.WriteFile(filepath.Join(root, ".gortex.yaml"), []byte(legacyYAML), 0644)) + + tgt, err := newWorkspaceTarget() + require.NoError(t, err) + patterns, err := tgt.load() + require.NoError(t, err) + // Legacy key surfaces through the workspace target's load(). + assert.Equal(t, []string{"legacy-pat/**"}, patterns) +} diff --git a/cmd/gortex/context.go b/cmd/gortex/context.go new file mode 100644 index 0000000..46f7095 --- /dev/null +++ b/cmd/gortex/context.go @@ -0,0 +1,61 @@ +package main + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var ( + contextTask string + contextEntryPoint string + contextMaxSymbols int + contextFormat string + contextBudget int + contextIndex string +) + +var contextCmd = &cobra.Command{ + Use: "context [flags]", + Short: "Generate a portable context briefing for a task", + Long: `Runs export_context for the given task against the daemon that owns the +repo and renders the result as a self-contained markdown or JSON briefing. +Use for sharing context outside MCP — paste into Slack, PRs, docs, or other +AI tools. Requires a running daemon that tracks the repo.`, + RunE: runContext, +} + +func init() { + contextCmd.Flags().StringVarP(&contextTask, "task", "t", "", "task description (required)") + contextCmd.Flags().StringVarP(&contextEntryPoint, "entry-point", "e", "", "symbol ID or file path to start from") + contextCmd.Flags().IntVarP(&contextMaxSymbols, "max-symbols", "n", 5, "max symbols to include") + contextCmd.Flags().StringVarP(&contextFormat, "format", "f", "markdown", "output format: markdown or json") + contextCmd.Flags().IntVar(&contextBudget, "token-budget", 2000, "approximate token budget for output") + contextCmd.Flags().StringVar(&contextIndex, "index", "", "repository path the daemon must track (default: current directory)") + _ = contextCmd.MarkFlagRequired("task") + rootCmd.AddCommand(contextCmd) +} + +func runContext(cmd *cobra.Command, args []string) error { + repoPath := "." + if contextIndex != "" { + repoPath = contextIndex + } else if len(args) > 0 { + repoPath = args[0] + } + + out, err := requireDaemonTool(repoPath, "export_context", map[string]any{ + "task": contextTask, + "entry_point": contextEntryPoint, + "max_symbols": contextMaxSymbols, + "format": contextFormat, + "token_budget": contextBudget, + }) + if err != nil { + return err + } + // export_context returns the rendered briefing (markdown or JSON) as + // the tool's text content; print it verbatim. + fmt.Fprintln(cmd.OutOrStdout(), string(out)) + return nil +} diff --git a/cmd/gortex/daemon.go b/cmd/gortex/daemon.go new file mode 100644 index 0000000..190598b --- /dev/null +++ b/cmd/gortex/daemon.go @@ -0,0 +1,1625 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "sort" + "strconv" + "strings" + "time" + + "github.com/jedib0t/go-pretty/v6/table" + "github.com/jedib0t/go-pretty/v6/text" + "github.com/spf13/cobra" + "go.uber.org/zap" + + "github.com/zzet/gortex/internal/daemon" + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/indexer" + "github.com/zzet/gortex/internal/llm/conversationlog" + "github.com/zzet/gortex/internal/platform" + "github.com/zzet/gortex/internal/progress" + "github.com/zzet/gortex/internal/server" + "github.com/zzet/gortex/internal/server/hub" + "github.com/zzet/gortex/internal/tui" +) + +var ( + daemonDetach bool + daemonTail int + daemonEmbeddings bool + // daemonEmbeddingsChanged records whether `--embeddings` was given + // explicitly on `gortex daemon start`. buildDaemonState reads it + // (the function has no *cobra.Command of its own) to decide whether + // the flag overrides the `embedding:` config block. Set once in + // runDaemonStart before buildDaemonState runs. + daemonEmbeddingsChanged bool + // daemonEmbeddingsURL / daemonEmbeddingsModel mirror `gortex mcp`'s + // --embeddings-url / --embeddings-model so the daemon can be started with an + // explicit OpenAI-compatible (or Ollama) embedding API. A non-empty URL forces + // the api provider in ResolveEmbedder, overriding the embedding: config block. + daemonEmbeddingsURL string + daemonEmbeddingsModel string + daemonStatusWatch bool + daemonStatusInterval time.Duration + daemonHTTPAddr string + daemonHTTPAuthToken string + daemonHTTPCORSOrigin string + daemonHTTPConversationAllow []string + daemonBackend string + daemonBackendPath string + daemonBackendBufferPoolMB uint64 + daemonTools string + daemonToolsMode string +) + +var daemonCmd = &cobra.Command{ + Use: "daemon", + Short: "Manage the long-living Gortex daemon", + Long: `The daemon holds the graph for all tracked repositories and serves every +MCP client (Claude Code, Cursor, Kiro, ...) plus the CLI from one shared +index. + +If no daemon is running, ` + "`gortex mcp`" + ` still works standalone — the daemon +is additive, not required.`, +} + +var daemonStartCmd = &cobra.Command{ + Use: "start", + Short: "Start the daemon", + RunE: runDaemonStart, +} + +var daemonStopCmd = &cobra.Command{ + Use: "stop", + Short: "Stop the daemon gracefully (waits for final snapshot)", + RunE: runDaemonStop, +} + +var daemonRestartCmd = &cobra.Command{ + Use: "restart", + Short: "Stop and start the daemon (preserves tracked repos)", + RunE: runDaemonRestart, +} + +var daemonReloadCmd = &cobra.Command{ + Use: "reload", + Short: "Re-read config and pick up new or removed repos without restart", + RunE: runDaemonReload, +} + +var daemonStatusCmd = &cobra.Command{ + Use: "status", + Short: "Show daemon PID, uptime, tracked repos, memory, sessions", + RunE: runDaemonStatus, +} + +var daemonLogsCmd = &cobra.Command{ + Use: "logs", + Short: "Tail the daemon log file", + RunE: runDaemonLogs, +} + +func init() { + daemonStartCmd.Flags().BoolVar(&daemonDetach, "detach", false, + "fork to background after starting (logs to the daemon log file — see `gortex daemon logs`)") + daemonStartCmd.Flags().BoolVar(&daemonEmbeddings, "embeddings", false, + "load a semantic embedding provider (opt-in — adds ~87 MB model download on first use and ~60 ms/symbol warmup)") + daemonStartCmd.Flags().StringVar(&daemonEmbeddingsURL, "embeddings-url", "", + "OpenAI-compatible (or Ollama) embedding API base URL (e.g. https://api.openai.com/v1). A non-empty URL forces the api provider, overriding the embedding: config. Key via $GORTEX_EMBEDDINGS_API_KEY or $OPENAI_API_KEY (openai.com only).") + daemonStartCmd.Flags().StringVar(&daemonEmbeddingsModel, "embeddings-model", "", + "embedding model for --embeddings-url (default: auto-detect — text-embedding-3-small for OpenAI, nomic-embed-text for Ollama)") + daemonStartCmd.Flags().StringVar(&daemonHTTPAddr, "http-addr", "", + "also expose the MCP 2026 Streamable HTTP transport on this TCP address (e.g. 127.0.0.1:7411); empty disables") + daemonStartCmd.Flags().StringVar(&daemonHTTPAuthToken, "http-auth-token", "", + "bearer token required on every Streamable HTTP request (default: read $GORTEX_DAEMON_HTTP_TOKEN; empty allows unauthenticated localhost binds)") + daemonStartCmd.Flags().StringVar(&daemonHTTPCORSOrigin, "cors-origin", "*", + "allowed CORS origin for the HTTP surface (use '*' for any); applies to both /mcp and /v1 when --http-addr is set") + daemonStartCmd.Flags().StringSliceVar(&daemonHTTPConversationAllow, "conversation-host", nil, + "extra Host values (beyond loopback) the conversation-log inspector accepts without a token; repeatable") + daemonStartCmd.Flags().StringVar(&daemonBackend, "backend", "sqlite", + "storage backend: sqlite (default — pure-Go embedded SQL, persists to --backend-path so warm restarts skip re-indexing) | memory (in-process, no persistence — fastest per-op but pays the full cold-warmup cost on every restart)") + daemonStartCmd.Flags().StringVar(&daemonBackendPath, "backend-path", "", + "path to the on-disk backend's store file (its parent directory is created if absent). Defaults to ~/.gortex/store/store.sqlite; ignored when --backend is memory") + daemonStartCmd.Flags().Uint64Var(&daemonBackendBufferPoolMB, "backend-buffer-pool-mb", 0, + "advisory page-cache cap (MiB) for on-disk backends. 0 reads $GORTEX_DAEMON_BUFFER_POOL_MB or lets the backend choose its own default; backends that manage their own cache (e.g. sqlite) ignore it") + daemonStartCmd.Flags().StringVar(&daemonTools, "tools", "", + "restrict the published MCP tool surface to a preset: core (default)|full|readonly|edit|nav (optionally with ,+tool / ,-tool deltas). GORTEX_TOOLS overrides this") + daemonStartCmd.Flags().StringVar(&daemonToolsMode, "tools-mode", "", + "how a --tools preset hides tools: hide (remove from tools/list + block calls) or defer (keep reachable via tools_search). Default hide") + daemonLogsCmd.Flags().IntVarP(&daemonTail, "tail", "n", 50, + "show only the last N log lines") + daemonStatusCmd.Flags().BoolVarP(&daemonStatusWatch, "watch", "w", false, + "continuously refresh the status until interrupted (alt-screen buffer)") + daemonStatusCmd.Flags().DurationVar(&daemonStatusInterval, "interval", 2*time.Second, + "refresh interval in --watch mode (clamped to >=200ms)") + + daemonCmd.AddCommand(daemonStartCmd) + daemonCmd.AddCommand(daemonStopCmd) + daemonCmd.AddCommand(daemonRestartCmd) + daemonCmd.AddCommand(daemonReloadCmd) + daemonCmd.AddCommand(daemonStatusCmd) + daemonCmd.AddCommand(daemonLogsCmd) + rootCmd.AddCommand(daemonCmd) +} + +// runDaemonStart starts the daemon in foreground (default) or detached +// (when --detach is passed). Detach does a self-exec: re-runs this binary +// with GORTEX_DAEMON_CHILD=1 set, which the inner exec picks up and runs +// the actual serve loop. +func runDaemonStart(cmd *cobra.Command, _ []string) error { + // An explicit start (user or supervisor) supersedes a prior `daemon stop` — + // clear the stay-down mark so autostart works again. The autostart-spawned + // child (GORTEX_DAEMON_CHILD=1) must NOT clear it: that would let an + // in-flight autostart erase a stop-intent the user wrote in the meantime. + if os.Getenv("GORTEX_DAEMON_CHILD") != "1" { + daemon.ClearStopIntent() + } + if isDaemonRunning() { + return fmt.Errorf("daemon already running (socket: %s)", daemon.SocketPath()) + } + // IsRunning only probes the socket. A daemon that is mid-shutdown — or + // one whose socket wedged — still owns the PID file and, crucially, still + // holds the store's on-disk lock. Starting over the top of it makes the + // backend open fail with an opaque "failed to open database" lock + // conflict, so refuse early with the PID and an actionable next step. The + // detached child reaches here too, but it hasn't written its own PID file + // yet (that happens in the serve loop), so this can't false-positive on + // the daemon we're in the middle of starting. + if pid, ok := daemon.RunningPID(); ok { + return fmt.Errorf("daemon already running (pid %d) — stop it with `gortex daemon stop`, or use `gortex daemon restart`", pid) + } + if daemonDetach && os.Getenv("GORTEX_DAEMON_CHILD") != "1" { + return spawnDetachedDaemon() + } + logger := newLogger() + + // Raise the per-process file-descriptor cap as early as possible. + // fsnotify holds one FD per watched directory on Linux and one FD + // per directory plus every file inside it on macOS, so a multi-repo + // install easily blows past the inherited soft cap (256 on macOS, + // 1024 on most Linuxes) and surfaces as "accept: too many open + // files" once the daemon is hot. + if fdl, err := daemon.RaiseFDLimit(); err != nil { + logger.Warn("daemon: could not raise file-descriptor limit", zap.Error(err)) + } else { + logger.Info("daemon: file-descriptor cap", + zap.Uint64("soft", fdl.Soft), zap.Uint64("hard", fdl.Hard)) + } + + srv := daemon.New(daemon.SocketPath(), canonicalVersion(), logger) + + // Record whether `--embeddings` was set explicitly so + // buildDaemonState can let it override the `embedding:` config + // block. With `--detach` the re-exec'd child sees no flags, so an + // explicit opt-in there must travel via GORTEX_EMBEDDINGS instead. + daemonEmbeddingsChanged = cmd.Flags().Changed("embeddings") + + // Fast path: snapshot load + indexer + MCP server wiring. The + // per-repo TrackRepoCtx loop and MultiWatcher init are deferred to + // warmupDaemonState below so the socket opens immediately instead + // of waiting 30–60s for contract re-extraction across every tracked + // repo. + state, err := buildDaemonState(logger) + if err != nil { + return fmt.Errorf("build daemon state: %w", err) + } + + // Install the standing soft memory limit now — logging and config are + // up and no warmup / indexing has allocated yet, so the cold-index + // window's temporary override restores to this value rather than to + // "no limit" (see applyStandingMemoryLimit). + var daemonMemLimit string + if gc := state.configManager.Global(); gc != nil { + daemonMemLimit = gc.Daemon.MemoryLimit + } + applyStandingMemoryLimit(logger, daemonMemLimit) + + controller := &realController{ + graph: state.graph, + indexer: state.indexer, + multiIndexer: state.multiIndexer, + configManager: state.configManager, + logger: logger, + } + if state.mcpServer != nil { + srv := state.mcpServer + controller.toolSurface = func() (string, string, int) { + preset, mode := srv.ActivePreset() + return preset, mode, srv.LearnedToolCount() + } + } + controller.onShutdown = func() error { + // Stop watchers first so no late events race the snapshot + // write — we want the snapshot to reflect a quiescent graph, + // not one being mutated by an in-flight re-index. + controller.mu.Lock() + mw := controller.multiWatcher + controller.mu.Unlock() + if mw != nil { + _ = mw.Stop() + } + if mg, ok := state.graph.(*graph.Graph); ok { + // Memory backend — snapshot the full in-memory graph; + // the next warmup replays nodes/edges from the gob+gzip + // dump because there's no other persistence layer. + saveSnapshot(mg, collectSnapshotRepos(state.multiIndexer), collectSnapshotContracts(state.multiIndexer), collectSnapshotVector(state.multiIndexer), version, logger) + } + // Persistent backends (sqlite) no longer write a metadata + // snapshot: per-file mtimes live in the FileMtime sidecar + // table, contract records ride on KindContract.Meta, and the + // vector index is persisted by the backend itself. Warm + // restart reads everything it needs from the on-disk store — + // no gob+gzip round-trip required. + // Run the shared stack's teardown chain — flushes the savings + // store and closes the backend handle (checkpointing the sqlite + // WAL) so the daemon shuts down cleanly. + if state.shared != nil { + _ = state.shared.Close() + } else if state.mcpServer != nil { + _ = state.mcpServer.FlushSavings() + } + return nil + } + srv.Controller = controller + // Surface warmup state on the handshake ack: a proxy / CLI that connects + // during the (minutes-long) warmup should know the graph is still filling + // instead of guessing. controller.IsReady() is authoritative for the bool; + // the readiness broadcaster carries the fine-grained phase name. + srv.Ready = func() (bool, string) { + ready := controller.IsReady() + phase := "warming" + if ready { + phase = "ready" + } + if state.mcpServer != nil { + if p, _ := state.mcpServer.ReadinessPhase(); p != "" { + phase = p + } + } + return ready, phase + } + disp := newMCPDispatcher(state.mcpServer, state.multiIndexer, logger) + // The local executor + the dispatcher's SetRouter are handed to the + // controller so ControlProxy can build/publish/tear-down the router + // live (gortex proxy on/off/add/remove) without a daemon restart. + localExec := newLocalToolExecutor(state.mcpServer, logger) + controller.localExecute = localExec + controller.publishRouter = disp.SetRouter + // Wire the multi-server router into the daemon dispatcher when + // servers.toml exists. Local-only + // daemons (no servers.toml) leave router=nil and dispatch flows + // straight to the in-process MCP server unchanged. + if scfg, scfgErr := daemon.LoadServersConfig(""); scfgErr == nil && scfg != nil && len(scfg.Server) > 0 { + rosters := daemon.NewWorkspaceRosterCache(60 * time.Second) + // Local identity is a reserved sentinel, never DefaultServer().Slug: + // a remote marked default=true must still be proxied to, not + // treated as the daemon's own graph. + router := daemon.NewRouter(daemon.RouterConfig{ + Servers: scfg, + Rosters: rosters, + LocalSlug: daemon.LocalServerSentinel, + LocalExecute: localExec, + Logger: logger, + Federation: resolveFederationConfig(), + }) + disp.SetRouter(router) + controller.liveRouter = router + logger.Info("daemon: multi-server router wired", + zap.Int("servers", len(scfg.Server)), zap.String("local_slug", daemon.LocalServerSentinel)) + // Cross-daemon proxy-edge minting: when federation.edges is on, + // install the evidence prober on the indexer's resolver (so + // cross-repo resolution mints proxy edges) and keep the hydrator + // for the read path. No-op when the flag is off. + if hyd := daemon.WireRemoteStitch(router, state.multiIndexer, state.graph, resolveFederationEdgesConfig(), logger); hyd != nil { + state.proxyHydrator = hyd + if state.mcpServer != nil { + state.mcpServer.SetProxyHydrator(hyd.Hydrate) + } + logger.Info("daemon: federation proxy edges enabled (proxy minting + hydration)") + } + } else if scfgErr != nil { + logger.Warn("daemon: servers.toml load error (running single-server)", zap.Error(scfgErr)) + } + // Bridge the session proxy-toggle MCP tools to the daemon's + // per-session overrides + the live router roster. The router + // accessor is dynamic so a ControlProxy swap is reflected. + if state.mcpServer != nil { + state.mcpServer.SetRemoteOverrideSink(&sessionRemoteOverrideSink{ + sessions: srv.Sessions(), + router: disp.Router, + }) + } + srv.MCPDispatcher = disp + + // Event hub feeding the /v1 REST surface's graph-change SSE stream + // (/v1/events). Created lazily when the HTTP surface is enabled below + // and fed from the MultiWatcher once warmup attaches it — without this + // the daemon's /v1/events would only ever emit the "watch mode not + // active" frame even while the daemon is actively re-indexing changed + // files, leaving the REST surface short of the former `gortex server`. + var v1EventHub *hub.Hub + + // Optional MCP 2026 Streamable HTTP transport. Off by default + // (--http-addr unset) so a fresh `gortex daemon start` keeps + // the unix-socket-only behaviour every existing client already + // expects. When set, the daemon mounts /mcp on the supplied + // TCP address using the in-process streamable.Transport; + // per-request session state is replayed out of an in-memory + // store so multiple workers / reverse-proxies could later share + // it. The auth token is mandatory for non-localhost binds — + // exposing an unauthenticated MCP server on an external + // interface is a footgun, not a feature. + if daemonHTTPAddr != "" { + token := daemonHTTPAuthToken + if token == "" { + token = os.Getenv("GORTEX_DAEMON_HTTP_TOKEN") + } + if err := httpTokenRequirementError(daemonHTTPAddr, token); err != nil { + return err + } + // Resolve the expected token per request so $GORTEX_DAEMON_HTTP_TOKEN + // can be rotated without restarting the daemon. The flag, when set, + // is fixed for the process lifetime and wins; otherwise the env var + // is re-read on every request. + tokenFn := func() string { + if daemonHTTPAuthToken != "" { + return daemonHTTPAuthToken + } + return os.Getenv("GORTEX_DAEMON_HTTP_TOKEN") + } + // Router was already wired into the dispatcher above; reuse + // it here so the streamable transport sees the same proxy + // fan-out for cross-workspace tool calls. + var router *daemon.Router + if r := disp.Router(); r != nil { + router = r + } + streamH := buildDaemonStreamableHandler(disp, srv.Sessions(), router, logger, tokenFn) + + // Mount the /v1 REST surface (the former `gortex server`) on the + // same listener so a single daemon process serves both the MCP + // Streamable transport and the REST API that gortex-cloud and the + // web UI consume. The daemon already owns every dependency the + // handler needs — the MCP server, graph, config manager, overlay + // manager, and federation router — so this is pure composition. + v1 := server.NewHandler(state.mcpServer.MCPServer(), state.graph, version, logger) + if state.configManager != nil { + v1.SetConfigManager(state.configManager) + } + if id, idErr := resolveServerID(platform.DataDir()); idErr == nil { + v1.SetServerID(id) + } + if state.overlays != nil { + v1.SetOverlayManager(state.overlays) + } + if router != nil { + v1.SetRouter(router) + } + // Wire a graph-change event hub so /v1/events streams real events. + // The hub is fed from the MultiWatcher once warmup attaches it + // below; until then it has no source and /v1/events keepalives. + v1EventHub = hub.New() + v1.SetEventHub(v1EventHub) + // Conversation-log inspector. The /v1/conversations* routes read + // the opt-in sink's JSONL (raw LLM I/O), so they carry a + // route-scoped DNS-rebind guard that cooperates with the existing + // auth token: loopback / allowlisted hosts OR a valid token pass. + // The directory resolves through the same env helper the sink + // writer uses, so reader and writer always agree. + v1.SetConversationDir(conversationlog.DirFromEnv()) + v1.SetConversationGuard(daemonHTTPConversationAllow, tokenFn) + + srv.HTTPHandler = composeDaemonHTTPHandler(streamH, v1, tokenFn, daemonHTTPCORSOrigin) + srv.HTTPAddr = daemonHTTPAddr + logger.Info("daemon: HTTP surface configured (/mcp + /v1)", + zap.String("addr", daemonHTTPAddr), + zap.Bool("authenticated", token != ""), + zap.String("cors_origin", daemonHTTPCORSOrigin)) + } + + // Opt-in pprof endpoint. No-op unless GORTEX_DAEMON_PPROF_ADDR is + // set — keeps profiling off by default so the daemon doesn't hand + // its heap to anything on localhost. + startPProfIfEnabled(logger) + + // Wire the daemon-health snapshot fn into the MCP server's + // healthBroadcaster. Captures the live controller, daemon + // server (for session count), and multi indexer so every periodic + // tick reflects current state. Stopped in the deferred shutdown + // below so the ticker goroutine doesn't outlive the process. + daemonStart := time.Now() + if state.mcpServer != nil { + srvCapture := srv + stateCapture := state + controllerCapture := controller + state.mcpServer.AttachHealthSnapshot(func() map[string]any { + out := map[string]any{ + "uptime_seconds": int64(time.Since(daemonStart).Seconds()), + "ready": controllerCapture.IsReady(), + "enriched": controllerCapture.IsEnriched(), + "warmup_seconds": int64(0), + } + if st, statusErr := controllerCapture.Status(context.Background()); statusErr == nil { + out["warmup_seconds"] = st.WarmupSeconds + out["tracked_repos"] = len(st.TrackedRepos) + out["alloc_bytes"] = st.Runtime.Alloc + out["sys_bytes"] = st.Runtime.Sys + out["heap_inuse_bytes"] = st.Runtime.HeapInuse + out["num_goroutine"] = st.Runtime.NumGoroutine + out["num_gc"] = st.Runtime.NumGC + if st.LSPRouter != nil { + out["lsp_alive"] = len(st.LSPRouter.ActiveProviders) + out["lsp_specs_registered"] = len(st.LSPRouter.EnabledSpecs) + } + } + if sessions := srvCapture.Sessions(); sessions != nil { + out["sessions"] = sessions.Count() + } + if stateCapture.graph != nil { + out["graph_nodes"] = stateCapture.graph.NodeCount() + out["graph_edges"] = stateCapture.graph.EdgeCount() + if r, ok := stateCapture.graph.(graph.DBStatReporter); ok { + dbBytes, walBytes := r.DBStats() + if dbBytes > 0 || walBytes > 0 { + out["db_bytes"] = dbBytes + out["wal_bytes"] = walBytes + if dbBytes > 0 { + out["wal_db_ratio"] = float64(walBytes) / float64(dbBytes) + } + } + } + } + return out + }) + } + defer func() { + if state.mcpServer != nil { + state.mcpServer.StopHealthBroadcaster() + } + }() + + // Initial workspace_readiness phase — the snapshot has been + // loaded but warmup hasn't started yet. + publishReadinessPhase(state, "snapshot_loaded", false, map[string]any{ + "snapshot_repos": len(state.snapshotRepos), + }) + + // Periodic snapshots — 10 minute interval, gated on warmup-complete. + // On a crash we lose at most one interval's worth of work, which is + // acceptable given snapshot writes are atomic (tmp → rename) and can + // never leave a truncated file on disk. + // + // Gating: a snapshot walks the whole graph (AllNodes + AllEdges) and + // holds shard RLocks for the duration. While the daemon is still + // warming up the parser worker pool is concurrently writing those + // shards via AddBatch, so an unsynchronised snapshot tick steals + // graph-lock budget from the work that needs to finish first and + // pulls millions of node/edge pointers into a live allocation set + // the GC then has to clean up. Skipping snapshots until ready cleared + // a stall observed in profile #5 where saveSnapshotTo was the only + // runnable goroutine on a daemon mid-warmup. + // Periodic snapshots fire only for the memory backend — that's + // the path that has no other persistence layer for the graph + // itself. Persistent backends (sqlite) rely on the backend's own + // durability (graph + FileMtimes + contracts + vectors all live + // on disk) so the gob+gzip snapshot is dead weight in that mode. + stopSnapshotter := func() {} + if mg, ok := state.graph.(*graph.Graph); ok { + // Gate on IsEnriched, not IsReady: ready now flips once references + // resolve (well before enrichment finishes), but a snapshot tick mid- + // enrichment still steals shard-lock budget from the enrichment passes + // writing those shards — exactly the stall this gate prevents. + stopSnapshotter = startPeriodicSnapshots(mg, state.multiIndexer, version, 10*time.Minute, controller.IsEnriched, logger) + } + defer stopSnapshotter() + + // Periodic reconciliation — the "janitor". Walks each tracked repo + // and runs IncrementalReindex to evict files deleted offline and + // re-index files whose mtime changed. Insurance against gaps in + // fsnotify coverage (inotify watch limits, NFS mounts, kernel + // event-queue overflow). Default interval 1 h; override via + // GORTEX_RECONCILE_INTERVAL (a Go duration string, e.g. "15m"). + // Set to "0" to disable. + stopJanitor := startReconcileJanitor(state.multiIndexer, reconcileInterval(), logger) + defer stopJanitor() + + if err := srv.Listen(); err != nil { + return err + } + fmt.Fprintf(cmd.ErrOrStderr(), + "[gortex daemon] listening on %s (pid %d)\n", + daemon.SocketPath(), os.Getpid()) + + // Warmup runs in the background: re-index tracked repos, extract + // contracts, attach file watchers. The daemon is already reachable + // on the socket at this point, so clients can connect and start + // issuing queries while this work continues. Queries against + // not-yet-re-indexed repos still hit the snapshot data loaded in + // buildDaemonState — they just won't reflect files that changed + // since the snapshot was written until warmup gets to that repo. + go func() { + start := time.Now() + logger.Info("daemon: warmup starting") + // markReady fires once references are resolved and the graph is + // queryable — ahead of the slow enrichment pass — so clients can + // start issuing find_usages / get_callers immediately. Enrichment + // continues in this goroutine afterward and finishes at MarkEnriched. + var queryableElapsed time.Duration + markReady := func() { + elapsed := time.Since(start) + queryableElapsed = elapsed + controller.MarkReady(elapsed) + logger.Info("daemon: graph queryable", zap.Duration("warmup", elapsed)) + publishReadinessPhase(state, "ready", true, map[string]any{ + "queryable": true, + "warmup_seconds": int64(elapsed.Seconds()), + "warmup_ms": elapsed.Milliseconds(), + }) + } + mw, warmup := warmupDaemonState(state, logger, markReady) + controller.AttachWatcher(mw) + // Wire the daemon's MultiWatcher into the per-server history + // surface so `get_recent_changes` and `get_symbol_history` see + // real events under the daemon. Without this the tools always + // reported "watch mode is not active" even though MultiWatcher + // was actively re-indexing changed files. + if state.mcpServer != nil && mw != nil { + state.mcpServer.SetWatcher(mw) + // Push a one-time degraded notice on the readiness channel when a + // watcher exhausts inotify watches / file descriptors, so a + // subscribed agent learns the index may be frozen without polling. + srv := state.mcpServer + mw.OnDegraded(func(reason string) { + srv.PublishReadiness("degraded", true, map[string]any{"watch_degraded": reason}) + }) + } + // Drive the /v1/events SSE stream from the MultiWatcher. The hub is + // the only consumer of mw.Events() (SetWatcher reads History(), not + // the channel), so this can't starve any other reader. No-op when + // the HTTP surface is disabled (v1EventHub stays nil). + if v1EventHub != nil && mw != nil { + go v1EventHub.Run(mw.Events()) + } + // Community detection and process discovery only run when a + // repo is tracked or indexed via MCP — a daemon coming up off + // a snapshot never triggers them. Fire once here so + // get_communities / get_processes / dashboards reflect the + // loaded graph instead of returning "run index_repository + // first" against a fully populated state. + if state.mcpServer != nil { + state.mcpServer.RunAnalysis() + // Co-change pre-warm: fire the git-history mine in the + // background so the first user-visible + // find_co_changing_symbols / search-rerank call sees a + // populated cache. On a persistent backend the mine is + // dominated by the AllNodes + per-pair AddEdge disk-persist + // step that mineCoChange already defers into its own + // goroutine — but even the git log itself can take 10–30s + // on a large history, and we want that off every request + // path. + state.mcpServer.PrewarmCoChange() + } + elapsed := time.Since(start) + controller.MarkEnriched(elapsed) + logger.Info("daemon: enrichment complete", zap.Duration("warmup", elapsed)) + publishReadinessPhase(state, "enrichment_complete", true, map[string]any{ + "enriched": true, + "warmup_seconds": int64(elapsed.Seconds()), + "warmup_ms": elapsed.Milliseconds(), + }) + logWarmupSummary(logger, warmup, queryableElapsed, elapsed) + // Warmup is the daemon's single largest allocation burst (parse + + // resolve + the end_batch graph passes, then the whole-graph + // analysis above). This is the last point reached in every warmup + // shape — every early return inside warmupDaemonState still lands + // here — so return the burst's heap high-water to the OS now rather + // than letting the peak pin the idle footprint. RunAnalysis above + // may already have released, but the enrichment tail allocates + // further, so a final release at the true end still pays. + releaseMemoryToOS(logger, "warmup_complete") + }() + + return srv.Serve() +} + +// startPeriodicSnapshots kicks off a goroutine that writes a snapshot on +// every tick. Returns a stop function the caller runs at shutdown. The +// final snapshot on shutdown is handled by onShutdown — this loop only +// covers the "crash resilience" case (interval loss vs full re-index). +// reconcileInterval returns the janitor tick interval, defaulting to 1 +// hour. GORTEX_RECONCILE_INTERVAL overrides; "0" or "off" disables the +// janitor entirely (returns 0, which startReconcileJanitor treats as +// a no-op). Malformed values fall back to the default with a warning +// handled by the caller via the zero-return sentinel behaviour. +func reconcileInterval() time.Duration { + raw := os.Getenv("GORTEX_RECONCILE_INTERVAL") + if raw == "" { + return time.Hour + } + if raw == "0" || raw == "off" { + return 0 + } + d, err := time.ParseDuration(raw) + if err != nil || d < 0 { + return time.Hour + } + return d +} + +// startReconcileJanitor launches a background goroutine that, on every +// interval tick, garbage-collects the index of any linked git worktree +// whose root directory has vanished from disk and then calls +// MultiIndexer.ReconcileAll. interval=0 is a no-op; the returned stop +// function can be called unconditionally. +// +// The worktree GC runs *before* ReconcileAll on purpose: a removed +// worktree's root no longer exists, so ReconcileAll's IncrementalReindex +// would only error on the missing path without evicting anything. +// Pruning the vanished worktrees first keeps the reconcile sweep +// working on live repos and stops a deleted worktree's snapshot slot +// and graph nodes from leaking forever. +func startReconcileJanitor(mi *indexer.MultiIndexer, interval time.Duration, logger *zap.Logger) func() { + if mi == nil || interval <= 0 { + logger.Info("daemon: reconcile janitor disabled") + return func() {} + } + stop := make(chan struct{}) + go func() { + t := time.NewTicker(interval) + defer t.Stop() + logger.Info("daemon: reconcile janitor running", zap.Duration("interval", interval)) + for { + select { + case <-t.C: + gced := mi.GCVanishedWorktrees() + if len(gced) > 0 { + logger.Info("janitor: pruned vanished worktrees", + zap.Int("count", len(gced))) + } + results := mi.ReconcileAll() + // Return the tick's heap to the OS only when it actually did + // work — a repo reindexed stale/deleted files, or a worktree + // was pruned. ReconcileAll fills a result for every repo, so + // the honest "did work" signal is the per-repo stale/deleted + // counts, not the map size. A quiescent tick skips the + // release: FreeOSMemory is a full GC, and paying it hourly + // for a no-op sweep is the periodic cost the release policy + // exists to avoid. + reconciled := 0 + for _, r := range results { + if r != nil { + reconciled += r.StaleFileCount + r.DeletedFileCount + } + } + if reconciled > 0 || len(gced) > 0 { + releaseMemoryToOS(logger, "reconcile_janitor") + } + case <-stop: + return + } + } + }() + return func() { close(stop) } +} + +func startPeriodicSnapshots(g *graph.Graph, mi *indexer.MultiIndexer, version string, interval time.Duration, isReady func() bool, logger *zap.Logger) func() { + stop := make(chan struct{}) + go func() { + t := time.NewTicker(interval) + defer t.Stop() + for { + select { + case <-t.C: + // Skip while the daemon is still warming up — the + // graph walk inside saveSnapshot would fight the + // parser worker pool for shard locks and pin a + // transient allocation set the GC then has to drain. + // The next tick after warmup completes catches up. + if isReady != nil && !isReady() { + logger.Debug("snapshot: skipped tick — daemon still warming up") + continue + } + saveSnapshot(g, collectSnapshotRepos(mi), collectSnapshotContracts(mi), collectSnapshotVector(mi), version, logger) + case <-stop: + return + } + } + }() + return func() { close(stop) } +} + +// spawnDetachedDaemon re-invokes the binary with GORTEX_DAEMON_CHILD=1 +// set, the log redirected to the daemon log file, and the child +// parented to init. Parent exits as soon as the child has the socket up. +// +// On a TTY the parent shows a mesh-spinner banner and a styled "ready" card +// once the socket is live. On a non-TTY (CI scripts, automation) we keep the +// historical one-line "[gortex daemon] detached (pid X, log: Y)" message so +// existing parsers don't break. +func spawnDetachedDaemon() error { + exe, err := os.Executable() + if err != nil { + return fmt.Errorf("resolve executable: %w", err) + } + if err := daemon.EnsureParentDir(daemon.LogFilePath()); err != nil { + return err + } + logFile, err := os.OpenFile(daemon.LogFilePath(), + os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) + if err != nil { + return fmt.Errorf("open log file: %w", err) + } + child := exec.Command(exe, "daemon", "start") + childEnv := append(os.Environ(), "GORTEX_DAEMON_CHILD=1") + // --detach re-execs `daemon start` with no flags, so the tool-preset + // selection must travel to the child via env (the GORTEX_TOOLS + // override path), mirroring how --embeddings propagates. Appended + // last so an explicit flag wins over any inherited GORTEX_TOOLS. + if daemonTools != "" { + childEnv = append(childEnv, "GORTEX_TOOLS="+daemonTools) + } + if daemonToolsMode != "" { + childEnv = append(childEnv, "GORTEX_TOOLS_MODE="+daemonToolsMode) + } + child.Env = childEnv + child.Stdout = logFile + child.Stderr = logFile + child.Stdin = nil + // Detach the child from the parent's controlling terminal / + // console so Ctrl-C on the parent doesn't kill the daemon. + child.SysProcAttr = platform.DetachSysProcAttr() + if err := child.Start(); err != nil { + _ = logFile.Close() + return fmt.Errorf("spawn daemon: %w", err) + } + // Don't block on the child — it's detached and inherits the log + // file handle. Reap it in a background goroutine so a crash during + // startup surfaces on `exited` instead of stalling the poll loop. + exited := make(chan error, 1) + go func() { exited <- child.Wait() }() + + emitDaemonStartBanner(os.Stderr) + sp := newDaemonSpawnSpinner(os.Stderr) + if sp != nil { + sp.Start("Waiting for daemon socket") + } + + // Wait until the socket is live or a timeout hits, so we fail fast + // if the child died on startup. The socket opens after buildDaemonState + // decodes the snapshot; on a multi-hundred-MB snapshot that decode + // can take 10–20 s, so 5 s used to time out a perfectly healthy + // daemon mid-load. 60 s comfortably covers ~1 GiB snapshots while + // still failing fast on a child that crashed outright (those die + // in well under a second). + start := time.Now() + deadline := start.Add(60 * time.Second) + for time.Now().Before(deadline) { + if daemon.IsRunning() { + elapsed := time.Since(start).Truncate(10 * time.Millisecond) + if sp != nil { + sp.Set("", fmt.Sprintf("socket up · %s", elapsed)) + sp.Done() + emitDaemonStartSummary(os.Stderr, child.Process.Pid, elapsed) + } else { + fmt.Fprintf(os.Stderr, "[gortex daemon] detached (pid %d, log: %s)\n", + child.Process.Pid, daemon.LogFilePath()) + } + return nil + } + // Bail out early if the child has already exited — no point + // waiting another 59 seconds for a corpse. + select { + case werr := <-exited: + failMsg := fmt.Errorf("daemon exited during startup (%v); check %s", + werr, daemon.LogFilePath()) + if sp != nil { + sp.Fail(failMsg) + } + return failMsg + default: + } + if sp != nil { + sp.Set("", fmt.Sprintf("snapshot decoding · %s", time.Since(start).Truncate(100*time.Millisecond))) + } + time.Sleep(50 * time.Millisecond) + } + timeoutErr := fmt.Errorf("daemon did not come up within 60s; check %s", daemon.LogFilePath()) + if sp != nil { + sp.Fail(timeoutErr) + } + return timeoutErr +} + +// newDaemonSpawnSpinner returns a spinner bound to w when it's a TTY (and the +// global --no-progress flag isn't set). Returns nil otherwise, so callers can +// branch on the spinner's presence to choose between the framed-card vs. +// one-line output paths. +func newDaemonSpawnSpinner(w io.Writer) *progress.Spinner { + if noProgress || !progress.IsTTY(w) { + return nil + } + return progress.NewSpinner(w) +} + +// emitDaemonStartBanner prints the gortex mesh banner + subtitle for the +// detach flow. Only fires on a TTY — non-TTY callers stay quiet so script +// stderr remains parseable. +func emitDaemonStartBanner(w io.Writer) { + if !progress.IsTTY(w) || noProgress || daemonRestartActive { + return + } + banner := tui.Banner{ + Title: "gortex daemon start", + Subtitle: "Spawning daemon in the background.", + }.Render() + fmt.Fprintln(w) + fmt.Fprintln(w, banner) + fmt.Fprintln(w) +} + +// emitDaemonStartSummary prints the post-spawn card showing pid, socket, log +// path, elapsed time, and useful next-step hints. Only fires on a TTY. +func emitDaemonStartSummary(w io.Writer, pid int, elapsed time.Duration) { + if !progress.IsTTY(w) || noProgress { + return + } + stats := []string{ + progress.Stat(fmt.Sprintf("%d", pid), "pid", progress.StatGood), + progress.Stat(elapsed.String(), "boot", progress.StatGood), + } + fmt.Fprintln(w) + fmt.Fprintln(w, " "+progress.StyleOK.Render("✓")+" "+progress.StyleStrong.Render("daemon ready")) + fmt.Fprintln(w, " "+progress.StatStrip(stats...)) + fmt.Fprintln(w, " "+progress.Row("socket", daemon.SocketPath(), 8)) + fmt.Fprintln(w, " "+progress.Row("log", daemon.LogFilePath(), 8)) + fmt.Fprintln(w) + fmt.Fprintln(w, " "+progress.Heading("next")) + fmt.Fprintln(w, " "+progress.NumberedStep(1, "track a repo: gortex track ")) + fmt.Fprintln(w, " "+progress.NumberedStep(2, "watch status: gortex daemon status --watch")) + fmt.Fprintln(w, " "+progress.NumberedStep(3, "shut down: gortex daemon stop")) + fmt.Fprintln(w) +} + +func runDaemonStop(cmd *cobra.Command, _ []string) error { + w := cmd.ErrOrStderr() + // Record the user's "stay down" intent so the autostart path (a live + // `gortex mcp` proxy relaunched by an editor) doesn't immediately respawn + // the daemon we're about to stop. A `daemon restart` re-clears it via the + // following start, so only a standalone stop is sticky. + if !daemonRestartActive { + if err := daemon.MarkStopIntent(); err != nil { + fmt.Fprintf(w, "[gortex daemon] warning: could not record stop intent (%v); daemon may auto-respawn\n", err) + } + // If an OS supervisor (systemd --user / launchd) owns the daemon, stop + // it THROUGH the supervisor — a socket-level stop just kills the worker + // and the supervisor restarts it. `daemon restart` skips this and + // bounces via the supervisor instead. + if serviceActive() { + return serviceStop(w) + } + } + if !daemon.IsRunning() { + // The socket is gone, but a process may still be alive and holding + // the store lock — a daemon mid-shutdown, or one whose socket wedged. + // killByPID terminates it AND blocks until it has actually exited, + // which is what `daemon restart` relies on to not race the lock. + if _, ok := daemon.RunningPID(); ok { + return killByPID() + } + emitDaemonStopAlreadyDown(w) + return nil + } + + // Capture uptime + socket *before* shutdown so we can show them in the + // post-stop summary (the socket file vanishes on clean shutdown). + socket := daemon.SocketPath() + uptime := daemonUptimeBeforeStop() + // Capture the PID too. ControlShutdown only *acks* — the daemon then + // flushes and closes the store (releasing its on-disk lock) and exits + // asynchronously (see server.go: the handler Shutdown()s ~100ms later in + // a goroutine). We must block until that process is gone, or a following + // `daemon start` races the still-held lock and dies with the opaque + // "failed to open database with status 1". + pid, havePID := daemon.RunningPID() + + c, err := daemon.Dial(daemon.Handshake{Mode: daemon.ModeControl, ClientName: "cli"}) + if err != nil { + // Daemon said it was alive but won't talk — probably a stale PID file + // the daemon hasn't cleaned up. Fall back to killing by PID. + return killByPID() + } + resp, err := c.Control(daemon.ControlShutdown, nil) + _ = c.Close() + if err != nil { + return err + } + if !resp.OK { + return fmt.Errorf("shutdown rejected: %s %s", resp.ErrorCode, resp.ErrorMsg) + } + if havePID { + waitForDaemonExit(pid) + } + emitDaemonStopSummary(w, socket, uptime) + return nil +} + +// waitForDaemonExit blocks until the daemon process pid has exited — and thus +// released the store's on-disk lock — force-killing it if a graceful shutdown +// stalls. This is what makes `daemon stop` honest: when it returns, the store +// is free for the next process, which is the foundation `daemon restart` +// stands on. Polls cheaply; the common case (a clean flush) clears in well +// under a second. +func waitForDaemonExit(pid int) { + deadline := time.Now().Add(15 * time.Second) + for time.Now().Before(deadline) { + if !platform.ProcessAlive(pid) { + return + } + time.Sleep(50 * time.Millisecond) + } + // Graceful shutdown stalled (e.g. a wedged cgo call). Don't leave a + // half-exited daemon clutching the lock — force it, then clean up the + // socket/PID so the next start isn't tripped by stale files. + fmt.Fprintln(os.Stderr, "[gortex daemon] graceful shutdown timed out — force-killing") + _ = platform.KillProcess(pid) + for i := 0; i < 60 && platform.ProcessAlive(pid); i++ { + time.Sleep(50 * time.Millisecond) + } + _ = os.Remove(daemon.PIDFilePath()) + _ = os.Remove(daemon.SocketPath()) +} + +// daemonUptimeBeforeStop best-effort-fetches the daemon's reported uptime via +// a Status control before shutdown so the summary card can show how long the +// process ran. Returns 0 on any error — we'd rather degrade the card than +// fail the stop. +func daemonUptimeBeforeStop() time.Duration { + c, err := daemonControlClient() + if err != nil { + return 0 + } + defer c.Close() + resp, err := c.Control(daemon.ControlStatus, nil) + if err != nil || !resp.OK { + return 0 + } + var st daemon.StatusResponse + if jerr := json.Unmarshal(resp.Result, &st); jerr != nil { + return 0 + } + return time.Duration(st.UptimeSeconds) * time.Second +} + +// emitDaemonStopAlreadyDown prints the "not running" message: a one-liner on +// non-TTY for script compat, a styled hint card on TTY. +func emitDaemonStopAlreadyDown(w io.Writer) { + if !progress.IsTTY(w) || noProgress { + fmt.Fprintln(w, "[gortex daemon] not running") + return + } + fmt.Fprintln(w) + fmt.Fprintln(w, " "+progress.StyleHint.Render("◌ daemon was not running — nothing to stop")) + fmt.Fprintln(w, " "+progress.Caption("start with `gortex daemon start --detach`")) + fmt.Fprintln(w) +} + +// emitDaemonStopSummary prints the post-shutdown banner + result card mirroring +// daemon start's surface: uptime + socket path so the user can confirm the +// right daemon went down. +func emitDaemonStopSummary(w io.Writer, socket string, uptime time.Duration) { + if !progress.IsTTY(w) || noProgress { + fmt.Fprintln(w, "[gortex daemon] stopped") + return + } + if !daemonRestartActive { + banner := tui.Banner{ + Title: "gortex daemon stop", + Subtitle: "Daemon shut down cleanly.", + }.Render() + fmt.Fprintln(w) + fmt.Fprintln(w, banner) + } + stats := []string{progress.Stat("clean shutdown", "", progress.StatGood)} + if uptime > 0 { + stats = append(stats, progress.Stat(uptime.Truncate(time.Second).String(), "uptime", progress.StatNeutral)) + } + fmt.Fprintln(w, " "+progress.StyleOK.Render("✓")+" "+progress.StyleStrong.Render("stopped")) + fmt.Fprintln(w, " "+progress.StatStrip(stats...)) + if socket != "" { + fmt.Fprintln(w, " "+progress.Row("socket", socket+" (removed)", 8)) + } + fmt.Fprintln(w) +} + +// daemonRestartActive flips on for the duration of runDaemonRestart so the +// inner stop / start emit functions skip their own banners — restart shows the +// logo once at the top and then lists the stop + start cards underneath. +var daemonRestartActive bool + +func runDaemonRestart(cmd *cobra.Command, args []string) error { + daemonRestartActive = true + defer func() { daemonRestartActive = false }() + + emitDaemonRestartBanner(cmd.ErrOrStderr()) + + // When an OS supervisor owns the daemon, bounce it THROUGH the supervisor so + // the supervisor keeps ownership; a manual stop+start would orphan the new + // daemon from the unit (the unit reads inactive while a hand-started process + // holds the socket). + if serviceActive() { + return serviceRestart(cmd.ErrOrStderr()) + } + + // Stop is idempotent when not running and now blocks until the old + // process has fully exited — releasing the store's on-disk lock — before + // returning. That's what lets the start below reuse the store without + // racing the lock. The old code polled `daemon.IsRunning()` here, which + // watched the wrong resource: the socket is torn down ~100ms after the + // shutdown ack, long before the process exits and the lock clears, so the + // poll fell through early and the restart died on "failed to open + // database with status 1". + if err := runDaemonStop(cmd, args); err != nil { + return err + } + daemonDetach = true + return runDaemonStart(cmd, args) +} + +// emitDaemonRestartBanner prints the unified header for `gortex daemon +// restart` so the user sees the mesh logo once instead of twice. +func emitDaemonRestartBanner(w io.Writer) { + if !progress.IsTTY(w) || noProgress { + return + } + banner := tui.Banner{ + Title: "gortex daemon restart", + Subtitle: "Cycling daemon: stop then start.", + }.Render() + fmt.Fprintln(w) + fmt.Fprintln(w, banner) + fmt.Fprintln(w) +} + +func runDaemonReload(_ *cobra.Command, _ []string) error { + c, err := daemonControlClient() + if err != nil { + return err + } + defer c.Close() + resp, err := c.Control(daemon.ControlReload, nil) + if err != nil { + return err + } + if !resp.OK { + return fmt.Errorf("reload rejected: %s %s", resp.ErrorCode, resp.ErrorMsg) + } + fmt.Fprintln(os.Stderr, "[gortex daemon] reloaded") + return nil +} + +func runDaemonStatus(cmd *cobra.Command, _ []string) error { + if daemonStatusWatch { + return runDaemonStatusWatch(cmd) + } + st, err := fetchDaemonStatusForCLI() + if err != nil { + return err + } + w := cmd.OutOrStdout() + renderDaemonHeader(w, st) + renderDaemonWorkspaces(w, st) + renderDaemonRepos(w, st) + renderDaemonSessions(w, st) + renderDaemonServers(w, st) + return nil +} + +// fetchDaemonStatusForCLI dials the control socket once and returns a parsed +// StatusResponse. Shared by the one-shot and watch paths. +func fetchDaemonStatusForCLI() (daemon.StatusResponse, error) { + var st daemon.StatusResponse + c, err := daemonControlClient() + if err != nil { + return st, err + } + defer c.Close() + resp, err := c.Control(daemon.ControlStatus, nil) + if err != nil { + return st, err + } + if !resp.OK { + return st, fmt.Errorf("status rejected: %s %s", resp.ErrorCode, resp.ErrorMsg) + } + if err := json.Unmarshal(resp.Result, &st); err != nil { + return st, fmt.Errorf("parse status: %w", err) + } + return st, nil +} + +// renderDaemonHeader writes the fixed-schema key/value facts about the +// daemon process as a borderless two-column table. +func renderDaemonHeader(w io.Writer, st daemon.StatusResponse) { + t := table.NewWriter() + t.SetOutputMirror(w) + t.SetStyle(table.StyleLight) + t.Style().Options.DrawBorder = false + t.Style().Options.SeparateColumns = false + t.Style().Options.SeparateHeader = false + t.Style().Options.SeparateRows = false + t.SetColumnConfigs([]table.ColumnConfig{ + {Number: 1, Align: text.AlignLeft, WidthMax: 12}, + {Number: 2, Align: text.AlignLeft}, + }) + t.AppendRow(table.Row{"daemon", st.Version}) + t.AppendRow(table.Row{"pid", st.PID}) + t.AppendRow(table.Row{"socket", st.SocketPath}) + t.AppendRow(table.Row{"uptime", formatDuration(time.Duration(st.UptimeSeconds) * time.Second)}) + switch { + case st.Ready && st.EnrichmentComplete: + t.AppendRow(table.Row{ + "state", + fmt.Sprintf("ready (warmup %s)", formatDuration(time.Duration(st.EnrichSeconds)*time.Second)), + }) + case st.Ready: + t.AppendRow(table.Row{ + "state", + fmt.Sprintf("ready — queryable (warmup %s);%s", + formatDuration(time.Duration(st.WarmupSeconds)*time.Second), + formatEnrichmentProgress(st.Enrichment)), + }) + default: + t.AppendRow(table.Row{"state", "warming up (socket reachable, resolving references)"}) + } + t.AppendRow(table.Row{"sessions", st.Sessions}) + if st.MemoryBytes > 0 { + t.AppendRow(table.Row{"memory", formatBytes(st.MemoryBytes)}) + } + if sb := st.SearchBackend; sb.Name != "" { + switch { + case sb.DiskPath != "": + t.AppendRow(table.Row{"search", fmt.Sprintf( + "%s docs=%d heap=%s disk=%s path=%s", + sb.Name, sb.DocCount, formatBytes(sb.Bytes), + formatBytes(sb.DiskBytes), sb.DiskPath)}) + case sb.DiskResident: + // No heap footprint to report — the index lives inside the + // graph store's own file, not a separate in-memory + // structure. Printing "heap=0 B" here would read as "this + // backend costs nothing", which is false. + t.AppendRow(table.Row{"search", fmt.Sprintf( + "%s docs=%d disk-resident (indexed in the graph store)", + sb.Name, sb.DocCount)}) + default: + t.AppendRow(table.Row{"search", fmt.Sprintf( + "%s docs=%d heap=%s", sb.Name, sb.DocCount, formatBytes(sb.Bytes))}) + } + } + rt := st.Runtime + if rt.Sys > 0 { + t.AppendRow(table.Row{"runtime", fmt.Sprintf( + "alloc=%s sys=%s heap_inuse=%s heap_idle=%s heap_released=%s stacks=%s gc=%d goroutines=%d", + formatBytes(rt.Alloc), + formatBytes(rt.Sys), + formatBytes(rt.HeapInuse), + formatBytes(rt.HeapIdle), + formatBytes(rt.HeapReleased), + formatBytes(rt.StackInuse), + rt.NumGC, + rt.NumGoroutine, + )}) + } + if st.PProfAddr != "" { + t.AppendRow(table.Row{"pprof", fmt.Sprintf( + "http://%s/debug/pprof/ (example: go tool pprof -http=: http://%s/debug/pprof/heap)", + st.PProfAddr, st.PProfAddr)}) + } + t.Render() +} + +// formatEnrichmentProgress renders the semantic-enrichment progress +// suffix appended to the "state" row while Ready but not yet +// EnrichmentComplete. Falls back to the old mute message when no +// progress summary is available (no semantic manager wired, or an +// older daemon build reporting a nil Enrichment) — the row still says +// something instead of going blank. +func formatEnrichmentProgress(e *daemon.EnrichmentProgress) string { + if e == nil { + return " enrichment in progress" + } + if e.Current == nil { + return fmt.Sprintf(" enriching %d/%d repos", e.ReposDone, e.ReposTotal) + } + elapsed := formatDuration(time.Duration(e.Current.ElapsedSeconds * float64(time.Second))) + if e.Current.DeadlineSeconds > 0 { + deadline := formatDuration(time.Duration(e.Current.DeadlineSeconds * float64(time.Second))) + return fmt.Sprintf(" enriching %d/%d (%s, %s/%s)", + e.ReposDone, e.ReposTotal, e.Current.Repo, elapsed, deadline) + } + return fmt.Sprintf(" enriching %d/%d (%s, %s)", + e.ReposDone, e.ReposTotal, e.Current.Repo, elapsed) +} + +// renderDaemonRepos writes the per-repo breakdown as a single table. +// Rows sort by attributed memory descending so the largest consumers +// appear first. An "other" row at the bottom covers the delta between +// process-total memory and the sum of attributed per-repo memory — +// embedder model weights, runtime heap headroom, semantic caches, etc. +func renderDaemonRepos(w io.Writer, st daemon.StatusResponse) { + if len(st.TrackedRepos) == 0 { + fmt.Fprintln(w, "\ntracked repos: (none)") + return + } + + rows := make([]daemon.TrackedRepoStatus, len(st.TrackedRepos)) + copy(rows, st.TrackedRepos) + sort.Slice(rows, func(i, j int) bool { + return rows[i].Memory.TotalBytes > rows[j].Memory.TotalBytes + }) + + // The disk_b column only appears when any repo actually has disk + // usage — i.e. Bleve is running in disk mode. Keeping it + // conditional stops the default in-memory output from carrying a + // dead column users would (rightly) ask about. + showDisk := false + for _, r := range rows { + if r.Memory.DiskBytes > 0 { + showDisk = true + break + } + } + + fmt.Fprintln(w, "\ntracked repos:") + t := table.NewWriter() + t.SetOutputMirror(w) + t.SetStyle(table.StyleLight) + t.Style().Format.Header = text.FormatDefault + t.Style().Format.Footer = text.FormatDefault + + // The workspace column only adds signal when at least one repo + // declares an explicit workspace (i.e. one that doesn't equal the + // repo prefix). Pure-default setups already see the prefix in + // column 1; printing the same string twice is just noise. + showWS := false + for _, r := range rows { + if r.Workspace != "" && r.Workspace != r.Prefix { + showWS = true + break + } + } + + header := table.Row{"repo"} + colConfigs := []table.ColumnConfig{{Number: 1, Align: text.AlignLeft}} + if showWS { + header = append(header, "workspace") + colConfigs = append(colConfigs, table.ColumnConfig{Number: len(colConfigs) + 1, Align: text.AlignLeft}) + } + header = append(header, "total", "files", "nodes", "edges", + "nodes_b", "edges_b", "search_b", "vectors_b") + for i := 0; i < 8; i++ { + colConfigs = append(colConfigs, table.ColumnConfig{Number: len(colConfigs) + 1, Align: text.AlignRight}) + } + if showDisk { + header = append(header, "disk_b") + colConfigs = append(colConfigs, table.ColumnConfig{Number: len(colConfigs) + 1, Align: text.AlignRight}) + } + header = append(header, "path") + colConfigs = append(colConfigs, table.ColumnConfig{Number: len(colConfigs) + 1, Align: text.AlignLeft}) + t.AppendHeader(header) + t.SetColumnConfigs(colConfigs) + + var attributed uint64 + for _, r := range rows { + attributed += r.Memory.TotalBytes + row := table.Row{r.Prefix} + if showWS { + ws := r.Workspace + if r.WorkspaceProject != "" && r.WorkspaceProject != ws { + ws = ws + "/" + r.WorkspaceProject + } + row = append(row, ws) + } + row = append(row, + formatBytes(r.Memory.TotalBytes), + r.Files, + r.Nodes, + r.Edges, + formatBytes(r.Memory.NodesBytes), + formatBytes(r.Memory.EdgesBytes), + formatBytes(r.Memory.SearchBytes), + formatBytes(r.Memory.VectorsBytes), + ) + if showDisk { + row = append(row, formatBytes(r.Memory.DiskBytes)) + } + row = append(row, r.Path) + t.AppendRow(row) + } + + if st.MemoryBytes > attributed { + other := st.MemoryBytes - attributed + footer := table.Row{"other"} + if showWS { + footer = append(footer, "") + } + footer = append(footer, formatBytes(other), "", "", "", "", "", "", "") + if showDisk { + footer = append(footer, "") + } + footer = append(footer, "embedder + runtime + caches (not attributed)") + t.AppendFooter(footer) + } + + t.Render() +} + +// renderDaemonWorkspaces prints the per-workspace rollup above the +// repos table. When every workspace is a default singleton (each +// repo in its own auto-named workspace), it emits a one-line hint +// pointing at `gortex workspace set` instead of a wall-of-text +// table that just duplicates the per-repo view. +func renderDaemonWorkspaces(w io.Writer, st daemon.StatusResponse) { + if len(st.Workspaces) == 0 { + return + } + multiRepo := false + for _, ws := range st.Workspaces { + if len(ws.Repos) > 1 { + multiRepo = true + break + } + } + + if !multiRepo { + // Compact form: tell the user the workspace boundary is in + // default mode and how to opt repos into a shared workspace. + // Avoids + // printing a 33-row table where every row says "1 repo". + fmt.Fprintf(w, + "\nworkspaces: %d (one per repo, default — every repo is its own workspace)\n", + len(st.Workspaces)) + fmt.Fprintln(w, + " Group repos into a shared workspace with `gortex workspace set --global`") + fmt.Fprintln(w, + " or `gortex workspace set-all --root --global`. See `gortex workspace --help`.") + return + } + + fmt.Fprintln(w, "\nworkspaces:") + t := table.NewWriter() + t.SetOutputMirror(w) + t.SetStyle(table.StyleLight) + t.Style().Format.Header = text.FormatDefault + t.AppendHeader(table.Row{"workspace", "repos", "projects", "files", "nodes", "edges"}) + t.SetColumnConfigs([]table.ColumnConfig{ + {Number: 1, Align: text.AlignLeft}, + {Number: 2, Align: text.AlignRight}, + {Number: 3, Align: text.AlignLeft}, + {Number: 4, Align: text.AlignRight}, + {Number: 5, Align: text.AlignRight}, + {Number: 6, Align: text.AlignRight}, + }) + for _, ws := range st.Workspaces { + projects := strings.Join(ws.Projects, ", ") + if len(projects) > 50 { + projects = projects[:47] + "..." + } + t.AppendRow(table.Row{ws.Slug, len(ws.Repos), projects, ws.Files, ws.Nodes, ws.Edges}) + } + t.Render() +} + +// renderDaemonSessions lists every connected MCP client. Skipped +// when no sessions are registered — single-process stdio embeds +// don't go through the daemon socket so they never show up here. +func renderDaemonSessions(w io.Writer, st daemon.StatusResponse) { + if len(st.MCPSessions) == 0 { + return + } + fmt.Fprintln(w, "\nMCP sessions:") + t := table.NewWriter() + t.SetOutputMirror(w) + t.SetStyle(table.StyleLight) + t.Style().Format.Header = text.FormatDefault + t.AppendHeader(table.Row{"id", "client", "version", "connected", "cwd"}) + t.SetColumnConfigs([]table.ColumnConfig{ + {Number: 1, Align: text.AlignLeft}, + {Number: 2, Align: text.AlignLeft}, + {Number: 3, Align: text.AlignLeft}, + {Number: 4, Align: text.AlignRight}, + {Number: 5, Align: text.AlignLeft}, + }) + for _, s := range st.MCPSessions { + client := s.ClientName + if client == "" { + client = "unknown" + } + t.AppendRow(table.Row{ + s.ID, + client, + s.ClientVersion, + formatDuration(time.Duration(s.ConnectedSecs) * time.Second), + s.Cwd, + }) + } + t.Render() +} + +// renderDaemonServers shows the `~/.gortex/servers.toml` roster. +// Skipped when no file is present — the daemon is in single-server +// mode and there's nothing to list. The "local" column flags the +// entry the multi-server router treats as this daemon itself; auth +// is reported as "yes/no" (token values stay private to the +// daemon). +func renderDaemonServers(w io.Writer, st daemon.StatusResponse) { + if len(st.ConfiguredServers) == 0 { + return + } + fmt.Fprintln(w, "\nconfigured servers (~/.gortex/servers.toml):") + t := table.NewWriter() + t.SetOutputMirror(w) + t.SetStyle(table.StyleLight) + t.Style().Format.Header = text.FormatDefault + t.AppendHeader(table.Row{"slug", "url", "local", "default", "auth", "workspaces"}) + t.SetColumnConfigs([]table.ColumnConfig{ + {Number: 1, Align: text.AlignLeft}, + {Number: 2, Align: text.AlignLeft}, + {Number: 3, Align: text.AlignCenter}, + {Number: 4, Align: text.AlignCenter}, + {Number: 5, Align: text.AlignCenter}, + {Number: 6, Align: text.AlignLeft}, + }) + yesno := func(b bool) string { + if b { + return "yes" + } + return "" + } + for _, s := range st.ConfiguredServers { + t.AppendRow(table.Row{ + s.Slug, + s.URL, + yesno(s.Local), + yesno(s.Default), + yesno(s.HasAuth), + strings.Join(s.Workspaces, ", "), + }) + } + t.Render() +} + +func runDaemonLogs(cmd *cobra.Command, _ []string) error { + path := daemon.LogFilePath() + f, err := os.Open(path) + if err != nil { + return fmt.Errorf("open log %s: %w", path, err) + } + defer f.Close() + lines, err := tailLines(f, daemonTail) + if err != nil { + return err + } + for _, l := range lines { + fmt.Fprintln(cmd.OutOrStdout(), l) + } + return nil +} + +// daemonControlClient is the shared "dial + expect running" helper for +// the read-only control subcommands. Returns a clear error instead of +// a misleading ErrDaemonUnavailable. +func daemonControlClient() (*daemon.Client, error) { + c, err := daemon.Dial(daemon.Handshake{Mode: daemon.ModeControl, ClientName: "cli"}) + if err != nil { + return nil, fmt.Errorf("daemon not reachable (%v) — is it running? Try `gortex daemon start`", err) + } + return c, nil +} + +// resolveDaemonBufferPoolMB returns the effective buffer-pool cap. +// Precedence: --backend-buffer-pool-mb flag > GORTEX_DAEMON_BUFFER_POOL_MB env > 0 +// (which Open then maps to DefaultBufferPoolMB inside the store). +func resolveDaemonBufferPoolMB() uint64 { + if daemonBackendBufferPoolMB != 0 { + return daemonBackendBufferPoolMB + } + if env := strings.TrimSpace(os.Getenv("GORTEX_DAEMON_BUFFER_POOL_MB")); env != "" { + if v, err := strconv.ParseUint(env, 10, 64); err == nil { + return v + } + } + return 0 +} + +// killByPID is the fallback stop path for stale daemons that have a PID +// file but don't respond on the socket. Asks the process to terminate, +// waits, then force-kills. Silently returns nil if the PID no longer +// exists. +func killByPID() error { + pidBytes, err := os.ReadFile(daemon.PIDFilePath()) + if err != nil { + return nil + } + pid, _ := strconv.Atoi(string(pidBytes)) + if pid <= 0 { + return nil + } + _ = platform.TerminateProcess(pid) + + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + if !platform.ProcessAlive(pid) { + // Process gone. + _ = os.Remove(daemon.PIDFilePath()) + _ = os.Remove(daemon.SocketPath()) + fmt.Fprintln(os.Stderr, "[gortex daemon] stopped") + return nil + } + time.Sleep(100 * time.Millisecond) + } + // Last resort. + _ = platform.KillProcess(pid) + _ = os.Remove(daemon.PIDFilePath()) + _ = os.Remove(daemon.SocketPath()) + fmt.Fprintln(os.Stderr, "[gortex daemon] stopped (force-killed)") + return nil +} + +// tailLines returns the last n lines of f. Used by `daemon logs`. Small +// implementation — log files are capped at a few MB so we can afford a +// full read and slice rather than seeking from the end. +func tailLines(f io.Reader, n int) ([]string, error) { + buf, err := io.ReadAll(f) + if err != nil { + return nil, err + } + // Split on newline without pulling in bufio.Scanner buffer-size gotchas. + var out []string + start := 0 + for i, b := range buf { + if b == '\n' { + out = append(out, string(buf[start:i])) + start = i + 1 + } + } + if start < len(buf) { + out = append(out, string(buf[start:])) + } + if len(out) > n { + out = out[len(out)-n:] + } + return out, nil +} + +func formatDuration(d time.Duration) string { + if d < time.Minute { + return fmt.Sprintf("%ds", int(d.Seconds())) + } + if d < time.Hour { + return fmt.Sprintf("%dm%ds", int(d.Minutes()), int(d.Seconds())%60) + } + if d < 24*time.Hour { + return fmt.Sprintf("%dh%dm", int(d.Hours()), int(d.Minutes())%60) + } + return fmt.Sprintf("%dd%dh", int(d.Hours())/24, int(d.Hours())%24) +} + +func formatBytes(n uint64) string { + const unit = 1024 + if n < unit { + return fmt.Sprintf("%d B", n) + } + div, exp := uint64(unit), 0 + for x := n / unit; x >= unit; x /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp]) +} + +// stubController is a placeholder Controller so `gortex daemon start` +// works end-to-end before the real MultiIndexer integration lands. It diff --git a/cmd/gortex/daemon_boot_guard_test.go b/cmd/gortex/daemon_boot_guard_test.go new file mode 100644 index 0000000..b9ad9f0 --- /dev/null +++ b/cmd/gortex/daemon_boot_guard_test.go @@ -0,0 +1,81 @@ +package main + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/zzet/gortex/internal/graph" +) + +// TestSnapshotRoundTrip_RepoNodeEdgeCounts proves the additive per-repo +// NodeCount / EdgeCount fields survive a save + load cycle — the baseline the +// boot shape-degradation guard compares a reloaded repo against. +func TestSnapshotRoundTrip_RepoNodeEdgeCounts(t *testing.T) { + dir := t.TempDir() + t.Setenv("GORTEX_DAEMON_SNAPSHOT", filepath.Join(dir, "snap.gob.gz")) + + orig := graph.New() + orig.AddNode(&graph.Node{ID: "r/a.go::Foo", Name: "Foo", Kind: graph.KindFunction, FilePath: "r/a.go"}) + + repos := []snapshotRepo{{ + RepoPrefix: "r", + RootPath: "/tmp/r", + FileMtimes: map[string]int64{"r/a.go": 123}, + NodeCount: 8956, + EdgeCount: 58491, + }} + saveSnapshot(orig, repos, nil, snapshotVector{}, version, zap.NewNop()) + + restored := graph.New() + result, err := loadSnapshot(restored, zap.NewNop()) + require.NoError(t, err) + require.True(t, result.Loaded) + + rec, ok := result.Repos["r"] + require.True(t, ok, "repo record must round-trip keyed by prefix") + assert.Equal(t, 8956, rec.NodeCount, "per-repo node count must round-trip") + assert.Equal(t, 58491, rec.EdgeCount, "per-repo edge count must round-trip") +} + +func TestBootShapeShortfall(t *testing.T) { + // Baseline: a repo that saved 8956 nodes / 58491 edges. + snap := map[string]*snapshotRepo{ + "r": {RepoPrefix: "r", NodeCount: 8956, EdgeCount: 58491}, + // A legacy / newly-tracked record with no recorded counts. + "legacy": {RepoPrefix: "legacy", NodeCount: 0, EdgeCount: 0}, + } + + t.Run("edge collapse trips", func(t *testing.T) { + // The measured degradation: 58491 -> 45026 edges is a 23% drop, which + // does NOT trip the 50% floor — a modest shrink is tolerated. + assert.False(t, bootShapeShortfall(snap, "r", 8512, 45026), + "a 23% edge shrink is below the collapse floor") + // A true collapse (edges more than halved) trips. + assert.True(t, bootShapeShortfall(snap, "r", 8956, 20000), + "edges more than halved must trip the guard") + }) + + t.Run("node collapse trips", func(t *testing.T) { + assert.True(t, bootShapeShortfall(snap, "r", 3000, 58491), + "nodes more than halved must trip the guard") + }) + + t.Run("healthy reload does not trip", func(t *testing.T) { + assert.False(t, bootShapeShortfall(snap, "r", 8956, 58491), + "an unchanged reload must not trip") + }) + + t.Run("zero baseline never trips", func(t *testing.T) { + assert.False(t, bootShapeShortfall(snap, "legacy", 0, 0), + "a record with no recorded counts has no baseline to compare") + }) + + t.Run("unknown prefix never trips", func(t *testing.T) { + assert.False(t, bootShapeShortfall(snap, "absent", 1, 1), + "a prefix absent from the snapshot has no baseline") + }) +} diff --git a/cmd/gortex/daemon_compact.go b/cmd/gortex/daemon_compact.go new file mode 100644 index 0000000..fd0b8cd --- /dev/null +++ b/cmd/gortex/daemon_compact.go @@ -0,0 +1,118 @@ +package main + +import ( + "os" + "path/filepath" + "time" + + "go.uber.org/zap" + + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/platform" +) + +// Guarded one-time store compaction at daemon boot. +// +// Row-shedding maintenance (orphan-repo purges, the duplicate-collapse +// migration, resolver cleanups) returns pages to SQLite's freelist, where new +// writes reuse them — but only VACUUM returns them to the filesystem, and +// nothing ran it: a live store carried 4.4 GB of freelist inside a 6.8 GB +// file. This is not a correctness problem (freelist pages are fully +// reusable), so the trigger is deliberately conservative: compaction runs +// only when the dead fraction dominates the file AND is large in absolute +// terms AND the filesystem can absorb VACUUM's temporary full copy. Anything +// smaller is left to organic reuse. + +// storeCompactor is the optional store capability the boot-time compaction +// probes for — implemented by the on-disk backend only, so a memory-mode +// daemon skips the whole feature via the failed type assertion. +type storeCompactor interface { + CompactStats() (freeBytes, totalBytes int64) + Compact() error + Path() string +} + +const ( + // compactMinFreeBytes is the absolute floor: below 1 GiB of reclaimable + // space a VACUUM (minutes of exclusive I/O on a store this size) costs + // more than the disk it returns. + compactMinFreeBytes = int64(1) << 30 +) + +// shouldCompactStore is the pure trigger predicate: compact only when the +// freelist is BOTH the majority of the file (> 50% of pages — organic reuse +// would take a long time to grow back into that much dead space) AND large in +// absolute terms (> 1 GiB — a small file's majority is still cheap to leave +// alone), AND the filesystem has room for VACUUM's transient full copy +// (available > 1.5 × the current file, headroom over the worst case of the +// rewrite temporarily doubling the footprint). Pure so the policy is +// table-testable without a store or a filesystem. +func shouldCompactStore(freeBytes, totalBytes int64, diskAvailBytes uint64) bool { + if totalBytes <= 0 || freeBytes <= 0 { + return false + } + if freeBytes*2 <= totalBytes { // freelist must exceed half the pages + return false + } + if freeBytes <= compactMinFreeBytes { + return false + } + need := uint64(totalBytes) + uint64(totalBytes)/2 // total × 1.5 + return diskAvailBytes > need +} + +// maybeCompactStore probes the store for the compaction capability and runs +// a one-time VACUUM when shouldCompactStore says the file is mostly dead +// space. Called from warmupDaemonState right after the orphan-prefix purge +// (so the purge's freshly-freed pages are measured and reclaimed in the same +// pass) and before the warmup re-index loop (whose writes would start +// reusing freelist pages and whose readers would contend with VACUUM's +// exclusive lock). Every failure path degrades to "skip": freelist pages +// stay reusable, so a missed compaction costs disk, never correctness. +func maybeCompactStore(g graph.Store, logger *zap.Logger) { + if os.Getenv("GORTEX_SKIP_STORE_COMPACT") == "1" { + logger.Debug("daemon: store compaction disabled (GORTEX_SKIP_STORE_COMPACT=1)") + return + } + c, ok := g.(storeCompactor) + if !ok { + return // memory-mode store: nothing on disk to compact + } + path := c.Path() + if path == "" { + return // no file backing — no filesystem to reason about + } + free, total := c.CompactStats() + avail, err := platform.DiskAvailBytes(filepath.Dir(path)) + if err != nil { + // Unknown headroom means the ×1.5 guard cannot hold; VACUUM without it + // risks filling the volume mid-rewrite, so skip. + logger.Debug("daemon: store compaction skipped — disk headroom unknown", + zap.String("path", path), zap.Error(err)) + return + } + if !shouldCompactStore(free, total, avail) { + logger.Debug("daemon: store compaction not warranted", + zap.Int64("reclaimable_bytes", free), + zap.Int64("store_bytes", total), + zap.Uint64("disk_avail_bytes", avail)) + return + } + logger.Info("daemon: one-time store compaction starting — VACUUM may take minutes on a multi-GB store", + zap.Int64("reclaimable_bytes", free), + zap.Int64("store_bytes", total), + zap.Uint64("disk_avail_bytes", avail)) + start := time.Now() + if err := c.Compact(); err != nil { + // Non-fatal by design: a failed VACUUM leaves the store exactly as it + // was (the freelist remains reusable), so boot continues. + logger.Warn("daemon: store compaction failed — continuing boot", + zap.Duration("elapsed", time.Since(start)), zap.Error(err)) + return + } + _, after := c.CompactStats() + logger.Info("daemon: store compaction finished", + zap.Duration("elapsed", time.Since(start)), + zap.Int64("store_bytes", after), + zap.Int64("reclaimed_bytes", total-after)) +} diff --git a/cmd/gortex/daemon_compact_test.go b/cmd/gortex/daemon_compact_test.go new file mode 100644 index 0000000..25455a3 --- /dev/null +++ b/cmd/gortex/daemon_compact_test.go @@ -0,0 +1,56 @@ +package main + +import "testing" + +// TestShouldCompactStore pins the boot-compaction trigger: all three gates +// (majority-dead file, absolute reclaimable floor, disk headroom for the +// VACUUM copy) must hold, and each boundary is exclusive — equality on any +// gate means "don't". Pure-function table so the policy is exercised without +// a store or a filesystem. +func TestShouldCompactStore(t *testing.T) { + const ( + gib = int64(1) << 30 + tib = uint64(1) << 40 + ) + cases := []struct { + name string + free int64 + total int64 + avail uint64 + want bool + }{ + {name: "all gates hold", free: 2 * gib, total: 3 * gib, avail: tib, want: true}, + {name: "the observed live store shape (4.4/6.8 GB)", free: 4707074048, total: 7301444403, avail: tib, want: true}, + + // Fraction gate: freelist must be a strict MAJORITY of the file. + {name: "exactly half free — no", free: 2 * gib, total: 4 * gib, avail: tib, want: false}, + {name: "just under half free — no", free: 2*gib - 1, total: 4 * gib, avail: tib, want: false}, + {name: "just over half free — yes", free: 2*gib + 1, total: 4 * gib, avail: tib, want: true}, + + // Absolute floor: a small file's majority is still not worth minutes + // of exclusive I/O. + {name: "90% free but only 900 MiB — no", free: 900 << 20, total: 1 << 30, avail: tib, want: false}, + {name: "exactly 1 GiB free — no (floor is exclusive)", free: gib, total: gib + 2, avail: tib, want: false}, + + // Headroom gate: available disk must strictly exceed total × 1.5, + // because VACUUM transiently needs up to a full extra copy. + {name: "avail exactly 1.5× — no", free: 2 * gib, total: 3 * gib, avail: uint64(3*gib) + uint64(3*gib)/2, want: false}, + {name: "avail just over 1.5× — yes", free: 2 * gib, total: 3 * gib, avail: uint64(3*gib) + uint64(3*gib)/2 + 1, want: true}, + {name: "tight disk — no", free: 2 * gib, total: 3 * gib, avail: uint64(3 * gib), want: false}, + + // Degenerate inputs: an unreadable store reports zeros; never fire. + {name: "zero stats", free: 0, total: 0, avail: tib, want: false}, + {name: "zero free", free: 0, total: 4 * gib, avail: tib, want: false}, + {name: "zero total", free: 2 * gib, total: 0, avail: tib, want: false}, + {name: "negative total", free: 2 * gib, total: -1, avail: tib, want: false}, + {name: "zero avail", free: 2 * gib, total: 3 * gib, avail: 0, want: false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := shouldCompactStore(tc.free, tc.total, tc.avail); got != tc.want { + t.Errorf("shouldCompactStore(free=%d, total=%d, avail=%d) = %v, want %v", + tc.free, tc.total, tc.avail, got, tc.want) + } + }) + } +} diff --git a/cmd/gortex/daemon_contracts_snapshot_test.go b/cmd/gortex/daemon_contracts_snapshot_test.go new file mode 100644 index 0000000..514ac33 --- /dev/null +++ b/cmd/gortex/daemon_contracts_snapshot_test.go @@ -0,0 +1,132 @@ +package main + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/zzet/gortex/internal/contracts" + "github.com/zzet/gortex/internal/graph" +) + +// TestSnapshotRoundTrip_Contracts is the regression guard for the bug +// where per-repo contract registries came back empty after every daemon +// restart. The symptom: `contracts` returned only the contracts of the +// repo the user was actively editing, because IncrementalReindex skipped +// extractContracts when no files were stale — and that was the only +// code path writing to idx.contractRegistry. The fix persists each +// repo's registry in the snapshot and warmup rehydrates on load. If +// either side regresses, cross-repo contract queries silently go back +// to returning a fraction of the data. +// +// Test strategy: two repos' worth of contracts get encoded, the +// snapshot is decoded, and every contract must come back with its +// RepoPrefix intact. Comparing against the live Contract values +// (rather than a hand-rolled expected slice) catches any field drift +// between the runtime struct and the wire form. +func TestSnapshotRoundTrip_Contracts(t *testing.T) { + dir := t.TempDir() + t.Setenv("GORTEX_DAEMON_SNAPSHOT", filepath.Join(dir, "snap.gob.gz")) + + repoA := []contracts.Contract{ + { + ID: "http::GET::/api/users", + Type: contracts.ContractHTTP, + Role: contracts.RoleProvider, + SymbolID: "repo-a/handler.go::ListUsers", + FilePath: "repo-a/handler.go", + Line: 42, + RepoPrefix: "repo-a", + Meta: map[string]any{"framework": "fiber", "method": "GET", "path": "/api/users"}, + Confidence: 0.9, + }, + { + ID: "grpc::Users::GetUser", + Type: contracts.ContractGRPC, + Role: contracts.RoleConsumer, + SymbolID: "repo-a/client.go::UserClient.Get", + FilePath: "repo-a/client.go", + Line: 17, + RepoPrefix: "repo-a", + Meta: map[string]any{"service": "Users", "method": "GetUser", "lang": "go"}, + Confidence: 0.95, + }, + } + repoB := []contracts.Contract{ + { + ID: "http::POST::/api/users", + Type: contracts.ContractHTTP, + Role: contracts.RoleConsumer, + SymbolID: "repo-b/client.ts::createUser", + FilePath: "repo-b/client.ts", + Line: 8, + RepoPrefix: "repo-b", + Meta: map[string]any{"framework": "fetch", "method": "POST", "path": "/api/users"}, + Confidence: 0.7, + }, + } + + var wire []snapshotContract + for _, c := range repoA { + wire = append(wire, toSnapshotContract(c)) + } + for _, c := range repoB { + wire = append(wire, toSnapshotContract(c)) + } + + g := graph.New() + saveSnapshot(g, nil, wire, snapshotVector{}, version, zap.NewNop()) + + restored := graph.New() + result, err := loadSnapshot(restored, zap.NewNop()) + require.NoError(t, err) + require.True(t, result.Loaded) + require.NotNil(t, result.Contracts) + + // Every contract must come back with its repo_prefix. Without the + // fix, result.Contracts either wouldn't exist (no map) or would be + // keyed under "" and conflate providers from different services. + assert.Len(t, result.Contracts["repo-a"], len(repoA), + "repo-a contracts must round-trip count-for-count") + assert.Len(t, result.Contracts["repo-b"], len(repoB), + "repo-b contracts must round-trip count-for-count") + + // Spot-check a field that used to be silently lost under the + // "read from graph" fix we considered: confidence. If it's zero, + // someone removed it from snapshotContract and the wire form is + // no longer round-tripping. + got := result.Contracts["repo-a"][0] + assert.Equal(t, repoA[0].ID, got.ID) + assert.Equal(t, repoA[0].Type, got.Type) + assert.Equal(t, repoA[0].Role, got.Role) + assert.Equal(t, repoA[0].Confidence, got.Confidence) + assert.Equal(t, repoA[0].Meta["framework"], got.Meta["framework"]) + assert.Equal(t, repoA[0].RepoPrefix, got.RepoPrefix) +} + +// TestSnapshotRoundTrip_EmptyContractsCompat proves that a snapshot +// saved with a nil contracts slice still decodes cleanly — the +// `contracts` section is additive, so old-behaviour call sites (tests, +// or a daemon that hasn't collected anything yet) must not produce a +// corrupt or unreadable file. Without this guard, a nil slice plus a +// positive ContractCount from any future refactor would read past the +// stream and corrupt a subsequent snapshot load. +func TestSnapshotRoundTrip_EmptyContractsCompat(t *testing.T) { + dir := t.TempDir() + t.Setenv("GORTEX_DAEMON_SNAPSHOT", filepath.Join(dir, "snap.gob.gz")) + + g := graph.New() + g.AddNode(&graph.Node{ID: "a.go::Foo", Name: "Foo", Kind: graph.KindFunction, FilePath: "a.go"}) + + saveSnapshot(g, nil, nil, snapshotVector{}, version, zap.NewNop()) + + restored := graph.New() + result, err := loadSnapshot(restored, zap.NewNop()) + require.NoError(t, err) + require.True(t, result.Loaded) + require.NotNil(t, result.Contracts, "Contracts map must always be non-nil post-load") + assert.Empty(t, result.Contracts) +} diff --git a/cmd/gortex/daemon_controller.go b/cmd/gortex/daemon_controller.go new file mode 100644 index 0000000..35b5abc --- /dev/null +++ b/cmd/gortex/daemon_controller.go @@ -0,0 +1,1198 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "path/filepath" + "runtime" + "sort" + "strings" + "sync" + "sync/atomic" + "time" + + "go.uber.org/zap" + + "github.com/zzet/gortex/internal/blame" + "github.com/zzet/gortex/internal/churn" + "github.com/zzet/gortex/internal/cochange" + "github.com/zzet/gortex/internal/config" + "github.com/zzet/gortex/internal/coverage" + "github.com/zzet/gortex/internal/daemon" + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/indexer" + "github.com/zzet/gortex/internal/pathkey" + "github.com/zzet/gortex/internal/releases" + "github.com/zzet/gortex/internal/search" + "github.com/zzet/gortex/internal/semantic" + "github.com/zzet/gortex/internal/semantic/lsp" +) + +// realController is the production daemon.Controller implementation. It +// wraps the MultiIndexer and ConfigManager so track/untrack/reload/status +// operations go through the same code paths the current `gortex mcp` +// command uses. +// +// Methods are serialized via a mutex — track/reload can race with status +// otherwise. The mutex is coarse; finer locking is a later optimization. +type realController struct { + mu sync.Mutex + graph graph.Store + indexer *indexer.Indexer + multiIndexer *indexer.MultiIndexer + configManager *config.ConfigManager + multiWatcher *indexer.MultiWatcher + logger *zap.Logger + + // liveRouter is the multi-server Router currently wired into the + // dispatch path (nil for a local-only daemon with no roster). + // localExecute + publishRouter let ReloadServers build and publish + // a router live when the first remote is added after startup, or + // tear it down when the last remote is removed — all without a + // daemon restart. Guarded by mu. + liveRouter *daemon.Router + localExecute daemon.LocalExecutor + publishRouter func(*daemon.Router) + + // onShutdown is invoked by the Shutdown method. Used by the daemon + // main to flush savings, close the snapshot store, etc. + onShutdown func() error + + // toolSurface reports the active tool-surface preset + mode and the + // per-workspace learned-promotion count for `gortex daemon status`. + // Nil when the MCP server isn't wired (control-only daemon). + toolSurface func() (preset, mode string, learned int) + + // ready flips to true once references are resolved and the graph is + // queryable — find_usages / get_callers return complete results from + // this point. The socket accepts connections before this; queries + // against not-yet-resolved repos return partial results until ready. + // warmupSeconds records how long the parse + resolve stage took. + // + // enriched flips to true once the slow semantic-enrichment pass and the + // graph-wide derivation passes finish in the background, after ready. + // Background timers that must not fight the enrichment pipeline for + // shard locks (the periodic snapshotter) gate on enriched, not ready. + // enrichSeconds records the full warmup duration. + ready atomic.Bool + warmupSeconds atomic.Int64 + enriched atomic.Bool + enrichSeconds atomic.Int64 +} + +// Track indexes a new repository and persists it to the global config. +// Path is resolved to an absolute form before the MultiIndexer sees it. +func (c *realController) Track(ctx context.Context, p daemon.TrackParams) (json.RawMessage, error) { + c.mu.Lock() + defer c.mu.Unlock() + + if c.multiIndexer == nil { + return nil, fmt.Errorf("multi-repo indexer not initialized") + } + absPath, err := filepath.Abs(p.Path) + if err != nil { + return nil, fmt.Errorf("resolve path: %w", err) + } + entry := config.RepoEntry{Path: absPath, Name: p.Name, Ref: p.Ref, AsWorktree: p.AsWorktree} + result, err := c.multiIndexer.TrackRepoCtx(ctx, entry) + if err != nil { + return nil, err + } + if result == nil { + // Already tracked — idempotent. + return json.RawMessage(fmt.Sprintf(`{"status":"already_tracked","path":%q}`, absPath)), nil + } + // TrackRepoCtx may have derived a worktree-instance prefix that the + // by-value entry above can't see — read the prefix it actually + // registered under for the watcher attach and the response. + prefix := result.RepoPrefix + if prefix == "" { + prefix = config.ResolvePrefix(entry) + } + + // Project association from TrackParams.Project isn't wired yet — the + // config package doesn't expose an AddRepoToProject helper. Callers + // who need project scoping can edit ~/.gortex/config.yaml and + // run `gortex daemon reload`; track from the daemon-v1 surface just + // adds to the top-level repo list. + + // Attach a watcher to the newly-tracked repo so file edits in it + // flow back into the graph live without a manual reload. Failures + // here are logged but don't fail the track — an indexed-but- + // unwatched repo is still queryable, just stale if edited. + if c.multiWatcher != nil && c.configManager != nil { + wcfg := c.configManager.GetRepoConfig(prefix).Watch + if err := c.multiWatcher.AddRepo(prefix, wcfg); err != nil { + c.logger.Warn("track: attach watcher failed", + zap.String("prefix", prefix), zap.Error(err)) + } + } + + // Persist the config change. TrackRepoCtx mutates the in-memory + // GlobalConfig via AddRepo but does not flush to disk; without this + // Save the new repo vanishes on daemon restart. Mirrors Untrack. + if c.configManager != nil { + if err := c.configManager.Global().Save(); err != nil { + c.logger.Warn("track: save config failed", zap.Error(err)) + } + } + + return json.Marshal(map[string]any{ + "status": "tracked", + "path": absPath, + "prefix": prefix, + "file_count": result.FileCount, + "node_count": result.NodeCount, + "edge_count": result.EdgeCount, + }) +} + +// EnrichChurn runs the churn enricher in-process against the daemon's +// graph. We hold c.mu for the duration so a concurrent Track/Untrack +// can't reshape the set of files while the enricher walks them. The +// caller (CLI / git hook) picks the params; an empty Path means "every +// tracked repo", an empty Branch means "resolve each repo's default +// branch from its working tree". +func (c *realController) EnrichChurn(ctx context.Context, p daemon.EnrichChurnParams) (daemon.EnrichChurnResult, error) { + c.mu.Lock() + defer c.mu.Unlock() + + if c.graph == nil { + return daemon.EnrichChurnResult{}, fmt.Errorf("graph not initialized") + } + if c.multiIndexer == nil { + return daemon.EnrichChurnResult{}, fmt.Errorf("multi-repo indexer not initialized") + } + + // Resolve the set of repo roots the call targets. Empty Path = + // every tracked repo. A path or prefix narrows to one. + type target struct { + prefix string + root string + } + var targets []target + want := strings.TrimSpace(p.Path) + for prefix, meta := range c.multiIndexer.AllMetadata() { + if want != "" && want != prefix && want != meta.RootPath { + continue + } + targets = append(targets, target{prefix: prefix, root: meta.RootPath}) + } + if len(targets) == 0 { + return daemon.EnrichChurnResult{}, fmt.Errorf("no tracked repo matches %q", p.Path) + } + + started := time.Now() + var combined daemon.EnrichChurnResult + for _, t := range targets { + branch := strings.TrimSpace(p.Branch) + if branch == "" { + branch = gitDefaultBranch(t.root) + } + if branch == "" { + c.logger.Warn("enrich churn: no default branch resolved", + zap.String("prefix", t.prefix), zap.String("root", t.root)) + continue + } + res, err := churn.EnrichGraph(ctx, c.graph, t.root, churn.Options{Branch: branch}) + if err != nil { + return daemon.EnrichChurnResult{}, fmt.Errorf("enrich %s: %w", t.prefix, err) + } + combined.Files += res.Files + combined.Symbols += res.Symbols + combined.Branch = res.Branch + combined.HeadSHA = res.HeadSHA + } + combined.DurationMS = time.Since(started).Milliseconds() + return combined, nil +} + +// EnrichReleases runs the per-file release enricher against the +// daemon's graph. Mirrors EnrichChurn — c.mu is held for the duration, +// targets resolve via the multi-indexer, and an empty Branch lets +// each repo's default branch be resolved on demand (so feature-branch +// tags don't leak into the timeline). +func (c *realController) EnrichReleases(ctx context.Context, p daemon.EnrichReleasesParams) (daemon.EnrichReleasesResult, error) { + c.mu.Lock() + defer c.mu.Unlock() + + if c.graph == nil { + return daemon.EnrichReleasesResult{}, fmt.Errorf("graph not initialized") + } + if c.multiIndexer == nil { + return daemon.EnrichReleasesResult{}, fmt.Errorf("multi-repo indexer not initialized") + } + + type target struct { + prefix string + root string + } + var targets []target + want := strings.TrimSpace(p.Path) + for prefix, meta := range c.multiIndexer.AllMetadata() { + if want != "" && want != prefix && want != meta.RootPath { + continue + } + targets = append(targets, target{prefix: prefix, root: meta.RootPath}) + } + if len(targets) == 0 { + return daemon.EnrichReleasesResult{}, fmt.Errorf("no tracked repo matches %q", p.Path) + } + _ = ctx // graph mutation is synchronous; no cancellation surface today + + started := time.Now() + var combined daemon.EnrichReleasesResult + for _, t := range targets { + branch := strings.TrimSpace(p.Branch) + if branch == "" { + branch = gitDefaultBranch(t.root) + // Empty branch is still legal — releases.EnrichGraphForBranch + // treats "" as "every tag", which is the right default when + // no default branch can be resolved (e.g. a clone without + // origin/HEAD set yet). + } + count, err := releases.EnrichGraphForBranch(c.graph, t.root, t.prefix, branch) + if err != nil { + return daemon.EnrichReleasesResult{}, fmt.Errorf("enrich %s: %w", t.prefix, err) + } + combined.Files += count + combined.Branch = branch + } + combined.DurationMS = time.Since(started).Milliseconds() + return combined, nil +} + +// enrichTarget is one (prefix, root) pair the enrichers run against. +type enrichTarget struct { + prefix string + root string +} + +// resolveEnrichTargets maps the caller-supplied path scope onto the set +// of tracked repos to enrich. An empty path means "every tracked repo"; +// a non-empty path narrows to the one repo whose prefix or root matches. +// Returns an error when nothing matches so the control caller gets a +// clear "no tracked repo" message rather than a silent zero-count +// success. Caller must hold c.mu. +func (c *realController) resolveEnrichTargets(path string) ([]enrichTarget, error) { + if c.graph == nil { + return nil, fmt.Errorf("graph not initialized") + } + if c.multiIndexer == nil { + return nil, fmt.Errorf("multi-repo indexer not initialized") + } + var targets []enrichTarget + want := strings.TrimSpace(path) + for prefix, meta := range c.multiIndexer.AllMetadata() { + if meta == nil || meta.RootPath == "" { + continue + } + if want != "" && want != prefix && want != meta.RootPath { + continue + } + targets = append(targets, enrichTarget{prefix: prefix, root: meta.RootPath}) + } + if len(targets) == 0 { + return nil, fmt.Errorf("no tracked repo matches %q", path) + } + return targets, nil +} + +// EnrichBlame runs the git-blame authorship enricher against the +// daemon's graph. Mirrors EnrichChurn — c.mu is held for the duration +// and targets resolve via the multi-indexer. +func (c *realController) EnrichBlame(_ context.Context, p daemon.EnrichBlameParams) (daemon.EnrichBlameResult, error) { + c.mu.Lock() + defer c.mu.Unlock() + + targets, err := c.resolveEnrichTargets(p.Path) + if err != nil { + return daemon.EnrichBlameResult{}, err + } + + started := time.Now() + var combined daemon.EnrichBlameResult + for _, t := range targets { + count, err := blame.EnrichGraph(c.graph, t.root) + if err != nil { + return daemon.EnrichBlameResult{}, fmt.Errorf("enrich %s: %w", t.prefix, err) + } + combined.Nodes += count + } + combined.DurationMS = time.Since(started).Milliseconds() + return combined, nil +} + +// EnrichCoverage projects the caller-parsed cover-profile segments onto +// the daemon's graph. The CLI parses the profile (the path is relative +// to the caller's cwd, not the daemon's), so the daemon only needs the +// segments and resolves each repo's module path from its working tree. +func (c *realController) EnrichCoverage(_ context.Context, p daemon.EnrichCoverageParams) (daemon.EnrichCoverageResult, error) { + c.mu.Lock() + defer c.mu.Unlock() + + targets, err := c.resolveEnrichTargets(p.Path) + if err != nil { + return daemon.EnrichCoverageResult{}, err + } + + segments := make([]coverage.Segment, len(p.Segments)) + for i, s := range p.Segments { + segments[i] = coverage.Segment{ + File: s.File, + StartLine: s.StartLine, + EndLine: s.EndLine, + NumStmt: s.NumStmt, + Count: s.Count, + } + } + + started := time.Now() + var combined daemon.EnrichCoverageResult + combined.Segments = len(segments) + for _, t := range targets { + modulePath := coverage.ReadModulePath(t.root) + combined.Symbols += coverage.EnrichGraph(c.graph, segments, modulePath) + } + combined.DurationMS = time.Since(started).Milliseconds() + return combined, nil +} + +// EnrichCochange mines co-change edges against the daemon's graph. +// Mirrors EnrichChurn — c.mu is held for the duration and targets +// resolve via the multi-indexer. The repo prefix scopes the file-node +// match in multi-repo graphs. +func (c *realController) EnrichCochange(ctx context.Context, p daemon.EnrichCochangeParams) (daemon.EnrichCochangeResult, error) { + c.mu.Lock() + defer c.mu.Unlock() + + targets, err := c.resolveEnrichTargets(p.Path) + if err != nil { + return daemon.EnrichCochangeResult{}, err + } + _ = ctx // mining is synchronous; no cancellation surface today + + started := time.Now() + var combined daemon.EnrichCochangeResult + for _, t := range targets { + count, err := cochange.EnrichGraph(c.graph, t.root, t.prefix) + if err != nil { + return daemon.EnrichCochangeResult{}, fmt.Errorf("enrich %s: %w", t.prefix, err) + } + combined.Edges += count + } + combined.DurationMS = time.Since(started).Milliseconds() + return combined, nil +} + +// Untrack evicts a repo from the graph and drops it from config. +// PathOrPrefix accepts either an absolute path or a repo prefix. +func (c *realController) Untrack(_ context.Context, p daemon.UntrackParams) (json.RawMessage, error) { + c.mu.Lock() + defer c.mu.Unlock() + + if c.multiIndexer == nil { + return nil, fmt.Errorf("multi-repo indexer not initialized") + } + + prefix := p.PathOrPrefix + // Resolve path → prefix if an absolute path was given. Absolutise (to + // Clean) and compare with fold-aware EqualPaths so a case-variant + // spelling of a tracked root on a case-insensitive filesystem still + // resolves to the right prefix. + if filepath.IsAbs(p.PathOrPrefix) { + abs, err := filepath.Abs(p.PathOrPrefix) + if err != nil { + abs = p.PathOrPrefix + } + for pfx, meta := range c.multiIndexer.AllMetadata() { + if pathkey.EqualPaths(meta.RootPath, abs) { + prefix = pfx + break + } + } + } + + // Detach the watcher before evicting from the graph — otherwise a + // late fsnotify event could race the eviction and try to re-index + // files whose nodes are already gone. + if c.multiWatcher != nil { + if err := c.multiWatcher.RemoveRepo(prefix); err != nil { + c.logger.Debug("untrack: detach watcher", + zap.String("prefix", prefix), zap.Error(err)) + } + } + + nodesRemoved, edgesRemoved := c.multiIndexer.UntrackRepo(prefix) + + // Persist the config change. + if c.configManager != nil { + _ = c.configManager.Global().RemoveRepo(prefix) + if err := c.configManager.Global().Save(); err != nil { + c.logger.Warn("untrack: save config failed", zap.Error(err)) + } + } + + return json.Marshal(map[string]any{ + "status": "untracked", + "prefix": prefix, + "nodes_removed": nodesRemoved, + "edges_removed": edgesRemoved, + }) +} + +// Reload re-reads the global config, indexes new repos that were added +// via direct config-file edits, and untracks any that were removed. +// Existing, unchanged tracked repos keep their current state. +// ReloadServers re-reads servers.toml and applies the change to the +// running daemon's Router without a restart: an in-place atomic swap +// when a router already exists, a fresh build-and-publish when the first +// remote is added after a router-less startup, or a teardown when the +// last remote is removed. +func (c *realController) ReloadServers(_ context.Context) (json.RawMessage, error) { + c.mu.Lock() + defer c.mu.Unlock() + + scfg, err := daemon.LoadServersConfig("") + if err != nil { + return nil, fmt.Errorf("reload servers.toml: %w", err) + } + count := 0 + if scfg != nil { + count = len(scfg.Server) + } + + wired := false + switch { + case count == 0 && c.liveRouter != nil: + // Last remote removed — tear the router down so local dispatch + // returns to the direct in-process path. + c.liveRouter = nil + if c.publishRouter != nil { + c.publishRouter(nil) + } + case count == 0: + // No router and no remotes — nothing to wire. + case c.liveRouter != nil: + // In-place atomic swap; the stable *Router pointer keeps every + // dispatch site (and any in-flight call) consistent. + c.liveRouter.ReloadConfig(scfg, daemon.NewWorkspaceRosterCache(60*time.Second)) + wired = true + default: + // First remote added after a router-less startup — build and + // publish a fresh router into the dispatch path. + c.liveRouter = daemon.NewRouter(daemon.RouterConfig{ + Servers: scfg, + Rosters: daemon.NewWorkspaceRosterCache(60 * time.Second), + LocalSlug: daemon.LocalServerSentinel, + LocalExecute: c.localExecute, + Logger: c.logger, + Federation: resolveFederationConfig(), + }) + if c.publishRouter != nil { + c.publishRouter(c.liveRouter) + } + wired = true + } + return json.Marshal(map[string]any{"servers": count, "router_wired": wired}) +} + +func (c *realController) Reload(ctx context.Context) (json.RawMessage, error) { + c.mu.Lock() + defer c.mu.Unlock() + + if c.configManager == nil { + return nil, fmt.Errorf("config manager not initialized") + } + if err := c.configManager.Reload(); err != nil { + return nil, fmt.Errorf("reload config: %w", err) + } + + var added, removed int + + // Match configured entries to currently-tracked instances by ROOT + // PATH, not by a recomputed prefix. A worktree tracked as an + // independent instance registers under a derived `@` + // prefix, so keying the diff on config.ResolvePrefix(entry) (the bare + // basename) would fail to recognise it as wanted and untrack it on + // every reload. The root path is the stable identity of a checkout. + trackedByRoot := make(map[string]string) // absolute RootPath → prefix + for prefix, meta := range c.multiIndexer.AllMetadata() { + if meta != nil { + trackedByRoot[meta.RootPath] = prefix + } + } + + wantedPrefixes := make(map[string]bool) + for _, entry := range c.configManager.Global().Repos { + abs, err := filepath.Abs(entry.Path) + if err != nil { + abs = entry.Path + } + if prefix, ok := trackedByRoot[abs]; ok { + // Already tracked (under whatever prefix it registered) — keep it. + wantedPrefixes[prefix] = true + continue + } + res, trackErr := c.multiIndexer.TrackRepoCtx(ctx, entry) + if trackErr != nil { + c.logger.Warn("reload: track failed", + zap.String("path", entry.Path), zap.Error(trackErr)) + continue + } + added++ + if res != nil && res.RepoPrefix != "" { + wantedPrefixes[res.RepoPrefix] = true + } + } + + for prefix := range c.multiIndexer.AllMetadata() { + if wantedPrefixes[prefix] { + continue + } + c.multiIndexer.UntrackRepo(prefix) + removed++ + } + + return json.Marshal(map[string]any{ + "added": added, + "removed": removed, + }) +} + +// searchBackendInfo bundles the daemon.SearchBackendStats payload with +// the separate text/vector byte counts we need to split per-repo. +type searchBackendInfo struct { + daemon.SearchBackendStats + vectorBytes uint64 +} + +// resolveSearchBackend inspects the live search backend and produces +// the stats needed by status rendering: which backend is active, total +// document count, its heap footprint, and (for disk-backed Bleve) the +// on-disk size. +// +// Real-world unwrap order: Swappable → HybridBackend → (text, vector). +// The text side is itself a concrete BM25/Bleve/SymbolSearcherBackend. +// Both layers have to be peeled; if we stop early we fall into the +// default branch and the status reports "unknown" — which was the bug +// users saw. When the store implements graph.SymbolSearcher, the +// indexer wires up a *search.SymbolSearcherBackend instead of building +// an in-process BM25/Bleve index at all (see initialSearchBackend in +// internal/indexer/indexer.go) — that case has to be matched +// explicitly too, or it falls into the same "unknown" default. +func resolveSearchBackend(b search.Backend) searchBackendInfo { + out := searchBackendInfo{} + if b == nil { + return out + } + + // 1) Unwrap Swappable so we see the currently-active inner. + inner := b + if sw, ok := inner.(*search.Swappable); ok { + inner = sw.Inner() + } + // 2) If Hybrid is in play, split its text/vector sizes and keep + // drilling into the text side for name/doc-count identification. + if hyb, ok := inner.(*search.HybridBackend); ok { + out.vectorBytes = hyb.VectorSizeBytes() + inner = hyb.TextBackend() + // TextBackend() itself could be a Swappable in some setups — + // unlikely today but cheap to guard. + if sw, ok := inner.(*search.Swappable); ok { + inner = sw.Inner() + } + } + + switch back := inner.(type) { + case *search.BleveBackend: + if path := back.DiskPath(); path != "" { + out.Name = "bleve-disk" + out.DiskPath = path + out.DiskBytes = back.DiskBytes() + } else { + out.Name = "bleve-memory" + } + out.DocCount = back.Count() + out.Bytes = back.SizeBytes() + case *search.BM25Backend: + out.Name = "bm25" + out.DocCount = back.Count() + out.Bytes = back.SizeBytes() + case *search.SymbolSearcherBackend: + // The FTS5 index lives inside the graph store's own file, not a + // separate in-memory structure — there is no honest byte count + // to report here (Count() is only a since-construction delta, + // documented as non-authoritative on the adapter itself). Report + // the backend truthfully as disk-resident instead of printing a + // fabricated "heap=0 B". + out.Name = "sqlite-fts5" + out.DocCount = back.Count() + out.DiskResident = true + default: + out.Name = "unknown" + out.DocCount = b.Count() + out.Bytes = search.BackendSize(b) + } + return out +} + +// Status gathers per-repo stats and basic process metrics. Daemon-level +// fields (PID, uptime, socket, session count) are filled in by the +// daemon itself before the response goes out. +func (c *realController) Status(_ context.Context) (daemon.StatusResponse, error) { + // Compute the per-repo memory estimate BEFORE taking the coarse + // controller mutex. On the SQLite backend AllRepoMemoryEstimates is a + // COUNT … GROUP BY scan that turns pathologically slow under + // enrichment write load; holding c.mu across it stalls every other + // control request (status / track / reload) queued on the mutex — the + // daemon-looks-crashed symptom. Snapshot the graph handle under a + // brief lock, then run the (store-memoised) estimate lock-free. + c.mu.Lock() + g := c.graph + c.mu.Unlock() + var memEstimates map[string]graph.RepoMemoryEstimate + if g != nil { + memEstimates = g.AllRepoMemoryEstimates() + } + + c.mu.Lock() + defer c.mu.Unlock() + + var ( + tracked []daemon.TrackedRepoStatus + searchBackendForResponse daemon.SearchBackendStats + totalNodes int + ) + if c.multiIndexer != nil { + // memEstimates (per-repo node/edge counts + byte estimates) was + // computed above, before the controller mutex was taken — see the + // note at the top of Status. The SQLite store memoises it so a + // burst of status polls collapses onto one COUNT … GROUP BY scan; + // the in-memory store serves maintained shard counters directly. + + // Diagnostic: when AllMetadata has tracked repos but + // AllRepoMemoryEstimates returns nothing (or a much smaller + // set), some path has cleared the per-repo counters without + // clearing the underlying nodes. The meta fallback below keeps + // the table usable in the meantime. A workspace with exactly + // one Unprefixed repo legitimately runs one bucket short — + // its nodes carry repo_prefix="" and AllRepoMemoryEstimates' + // GROUP BY excludes that key by design (handled separately + // below) — so that expected gap must not trip this warning. + // A workspace with a single tracked repo owns the entire store: + // every node and edge belongs to it, whether or not those nodes + // carry a repo prefix. g.NodeCount()/g.EdgeCount() is therefore the + // exact per-repo count, and reporting it keeps `daemon status` in + // agreement with `gortex query stats` (which reports the same + // whole-store totals — the inconsistency users report in #261/#270). + // + // This covers both ways a lone repo's nodes land under + // repo_prefix="", neither of which produces a usable per-prefix + // bucket (the in-memory shard counters skip empty-prefix nodes — + // shard.repoNodeAdd is a deliberate no-op — and the SQLite GROUP BY + // excludes repo_prefix="" rows via `WHERE repo_prefix <> ''`): + // 1. Indexed unprefixed (RepoMetadata.Unprefixed) while it was the + // workspace's sole tracked repo — the willBeMultiRepo gate in + // TrackRepoCtx/ReconcileRepoCtx. + // 2. Desynced to a prefixed metadata: a second config entry (e.g. a + // macOS path-case duplicate) flips willBeMultiRepo true at the + // next warm restart, so the metadata is stamped prefixed + // (Unprefixed=false) but the existing repo_prefix="" nodes are + // never restamped (migrateLoneUnprefixedRepoCtx's guard needs + // len(repos)==1, which fails mid-warmup-loop) — leaving an empty + // per-prefix bucket. + // Both used to fall back to the frozen RepoMetadata.NodeCount (stale, + // often ~1) and render the near-empty row of #261/#270. Byte + // estimates stay at their zero value here — advisory display detail, + // not the reported bug. + allMeta := c.multiIndexer.AllMetadata() + soleRepo := len(allMeta) == 1 + var wholeStoreNodes, wholeStoreEdges int + if g != nil { + wholeStoreNodes = g.NodeCount() + wholeStoreEdges = g.EdgeCount() + } + + // Diagnostic: when AllMetadata has tracked repos but + // AllRepoMemoryEstimates returns nothing (or a much smaller + // set), some path has cleared the per-repo counters without + // clearing the underlying nodes. The meta fallback below keeps + // the table usable in the meantime. + if c.logger != nil { + tracked := len(allMeta) + counted := len(memEstimates) + // A sole tracked repo (or one indexed unprefixed) legitimately + // contributes no per-prefix bucket — its nodes carry + // repo_prefix="" — so the expected shortfall must not trip this + // warning; the whole-store attribution below covers it. + expectedGap := 0 + if soleRepo { + expectedGap = 1 + } else { + for _, meta := range allMeta { + if meta != nil && meta.Unprefixed { + expectedGap = 1 + break + } + } + } + if tracked > 0 && counted < tracked-expectedGap { + c.logger.Warn("daemon: per-repo counters below tracked-repo count — graph mutation cleared per-repo index?", + zap.Int("tracked_repos", tracked), + zap.Int("counter_buckets", counted), + zap.Int("graph_total_nodes", c.graph.NodeCount())) + } + } + + // One repo in a multi-repo workspace can still hold all its nodes + // under repo_prefix="" while its metadata says prefixed — the same + // desync as the sole-repo case but with a second repo present (it was + // indexed solo, then another repo joined while the daemon was down, so + // warmup stamped it prefixed without restamping its nodes; the + // migrateLoneUnprefixedRepoCtx guard needs len(repos)==1, which is + // false mid-warmup-loop). Its per-prefix bucket is then empty and it + // would under-count. Attribute the unaccounted ("") node pool to it — + // but only when exactly one tracked repo lacks a live bucket, because + // the "" pool is a single undifferentiated pool and more than one + // empty-bucket repo is ambiguous. The pool also carries a small number + // of synthetic cross-repo/global nodes (<1% of a real graph), so the + // attribution is approximate, not exact. + var orphanOwner string + orphanOwnerCount := 0 + if !soleRepo { + for prefix, meta := range allMeta { + if meta == nil { + continue + } + if est, ok := memEstimates[prefix]; !ok || est.NodeCount == 0 { + orphanOwner = prefix + orphanOwnerCount++ + } + } + } + orphanNodes, orphanEdges := 0, 0 + if orphanOwnerCount == 1 && g != nil { + orphanNodes, orphanEdges = wholeStoreNodes, wholeStoreEdges + for _, est := range memEstimates { + orphanNodes -= est.NodeCount + orphanEdges -= est.EdgeCount + } + if orphanNodes < 0 { + orphanNodes = 0 + } + if orphanEdges < 0 { + orphanEdges = 0 + } + } + + // Search and vector backends are process-wide (one shared index + // across all repos), so we compute the global size once and + // split it proportionally to each repo's node share. Not exact, + // but it's the best attribution we can make without indexing + // per-repo which would double storage for the sake of a status + // breakdown. + backendStats := resolveSearchBackend(c.multiIndexer.Search()) + // totalNodes drives the SearchBytes share split below. A sole repo + // owns the whole store; otherwise sum the per-prefix buckets and, if + // every counter is empty (the post-warmup-wipe case described above), + // fall back to per-repo meta so the share denominator stays nonzero + // and the search budget gets attributed instead of falling on the floor. + if soleRepo { + totalNodes = wholeStoreNodes + } else { + for _, est := range memEstimates { + totalNodes += est.NodeCount + } + totalNodes += orphanNodes + if totalNodes == 0 { + for _, meta := range allMeta { + if meta != nil { + totalNodes += meta.NodeCount + } + } + } + } + + for prefix, meta := range allMeta { + nodes := meta.NodeCount + edges := meta.EdgeCount + var mem daemon.MemoryBreakdown + switch { + case soleRepo || meta.Unprefixed: + // The whole store is this repo's graph — see the note + // above. This keeps `daemon status` in agreement with + // `gortex query stats` for a single-repo workspace, and + // corrects the near-empty row when a lone repo's nodes + // carry repo_prefix="" (indexed unprefixed, or desynced to + // a prefixed metadata whose per-prefix bucket is empty). + // Byte estimates stay zero — advisory detail, not the bug. + nodes = wholeStoreNodes + edges = wholeStoreEdges + default: + est, ok := memEstimates[prefix] + switch { + case orphanOwnerCount == 1 && prefix == orphanOwner: + // This repo's nodes are the unaccounted "" pool — see the + // note above. Approximate (includes a little synthetic + // cross-repo overhead), but far closer than the frozen + // near-empty RepoMetadata fallback. + nodes = orphanNodes + edges = orphanEdges + case ok: + nodes = est.NodeCount + edges = est.EdgeCount + mem.NodesBytes = est.NodeBytes + mem.EdgesBytes = est.EdgeBytes + } + } + if totalNodes > 0 && nodes > 0 { + share := float64(nodes) / float64(totalNodes) + mem.SearchBytes = uint64(float64(backendStats.Bytes) * share) + mem.VectorsBytes = uint64(float64(backendStats.vectorBytes) * share) + mem.DiskBytes = uint64(float64(backendStats.DiskBytes) * share) + } + mem.TotalBytes = mem.NodesBytes + mem.EdgesBytes + mem.SearchBytes + mem.VectorsBytes + + // Pull the workspace/project slugs straight off the + // per-repo Indexer — that's the source of truth that + // stamps every node emitted by this repo. Falls back to + // the prefix on legacy setups where no .gortex.yaml + // declares them (the resolveWorkspaceID default). + var ws, wsProj string + if idx := c.multiIndexer.GetIndexer(prefix); idx != nil { + ws = idx.WorkspaceID() + wsProj = idx.ProjectID() + } + if ws == "" { + ws = prefix + } + if wsProj == "" { + wsProj = prefix + } + + tracked = append(tracked, daemon.TrackedRepoStatus{ + Prefix: prefix, + Path: meta.RootPath, + Workspace: ws, + WorkspaceProject: wsProj, + Files: meta.FileCount, + Nodes: nodes, + Edges: edges, + LastIndex: meta.LastIndexTime.Unix(), + Memory: mem, + }) + } + searchBackendForResponse = backendStats.SearchBackendStats + } + + // Aggregate per-workspace stats so the renderer can emit a + // "workspaces" block. Hidden when every repo defaults to its own + // slug (the legacy single-workspace-per-repo case where the + // summary just duplicates the table). + wsAgg := make(map[string]*daemon.WorkspaceSummary) + wsKeys := make([]string, 0) + for _, r := range tracked { + s, ok := wsAgg[r.Workspace] + if !ok { + s = &daemon.WorkspaceSummary{Slug: r.Workspace} + wsAgg[r.Workspace] = s + wsKeys = append(wsKeys, r.Workspace) + } + s.Repos = append(s.Repos, r.Prefix) + seenProj := false + for _, p := range s.Projects { + if p == r.WorkspaceProject { + seenProj = true + break + } + } + if !seenProj { + s.Projects = append(s.Projects, r.WorkspaceProject) + } + s.Files += r.Files + s.Nodes += r.Nodes + s.Edges += r.Edges + } + // Always populate the per-workspace rollup — even when every + // workspace is a default singleton. Hiding it on legacy setups + // makes the boundary feature invisible, which is the opposite + // of what users want when they're trying to migrate. Renderer- + // side compaction (single-line hint vs full table) keeps the + // output tidy when there's nothing meaningful to summarise. + sort.Strings(wsKeys) + workspaces := make([]daemon.WorkspaceSummary, 0, len(wsKeys)) + for _, k := range wsKeys { + workspaces = append(workspaces, *wsAgg[k]) + } + + var mem runtime.MemStats + runtime.ReadMemStats(&mem) + + resp := daemon.StatusResponse{ + TrackedRepos: tracked, + MemoryBytes: mem.Alloc, + SearchBackend: searchBackendForResponse, + Runtime: daemon.RuntimeStats{ + Alloc: mem.Alloc, + Sys: mem.Sys, + HeapInuse: mem.HeapInuse, + HeapIdle: mem.HeapIdle, + HeapReleased: mem.HeapReleased, + StackInuse: mem.StackInuse, + NumGC: mem.NumGC, + NumGoroutine: runtime.NumGoroutine(), + }, + PProfAddr: daemonPProfAddr(), + Ready: c.ready.Load(), + WarmupSeconds: c.warmupSeconds.Load(), + EnrichmentComplete: c.enriched.Load(), + EnrichSeconds: c.enrichSeconds.Load(), + Workspaces: workspaces, + ConfiguredServers: c.collectConfiguredServers(), + LocalServerSlug: c.localServerSlug(), + LSPRouter: c.collectLSPRouterStatus(), + Enrichment: c.collectEnrichmentProgress(), + } + if c.toolSurface != nil { + resp.ToolPreset, resp.ToolPresetMode, resp.LearnedTools = c.toolSurface() + } + return resp, nil + // MCPSessions is populated by the daemon Server (it owns the + // SessionRegistry — the controller doesn't have a back-pointer). + // See internal/daemon/server.go around the ControlStatus handler. +} + +// collectConfiguredServers reads `~/.gortex/servers.toml` (best +// effort — a missing or malformed file just returns nil) and +// projects it onto the status response. Auth tokens are NOT +// included; the HasAuth flag is enough for the human-facing +// "yes/no" decision. +func (c *realController) collectConfiguredServers() []daemon.ConfiguredServerStatus { + cfg, err := daemon.LoadServersConfig("") + if err != nil || cfg == nil || len(cfg.Server) == 0 { + return nil + } + local := c.localServerSlug() + out := make([]daemon.ConfiguredServerStatus, 0, len(cfg.Server)) + for _, s := range cfg.Server { + out = append(out, daemon.ConfiguredServerStatus{ + Slug: s.Slug, + URL: s.URL, + Default: s.Default, + Local: s.Slug == local, + Workspaces: s.Workspaces, + HasAuth: s.AuthToken != "" || s.AuthTokenEnv != "", + }) + } + return out +} + +// localServerSlug returns the reserved sentinel identifying the +// daemon's own in-process graph. It is intentionally NOT derived from +// DefaultServer().Slug: a roster row is always a remote now, so no +// roster entry is ever "local" (the status Local flag is false for +// every row), and a remote marked default=true is still proxied. +func (c *realController) localServerSlug() string { + return daemon.LocalServerSentinel +} + +// collectLSPRouterStatus reflects the daemon's LSP router (when +// wired) into a status payload. Returns nil when no router is wired +// (semantic enrichment disabled in `.gortex.yaml`). +func (c *realController) collectLSPRouterStatus() *daemon.LSPRouterStatus { + if c.indexer == nil { + return nil + } + semMgr := c.indexer.SemanticManager() + if semMgr == nil { + return nil + } + router, ok := semMgr.LSPRouter().(*lsp.Router) + if !ok || router == nil { + return nil + } + out := &daemon.LSPRouterStatus{ + DefaultWorkspace: router.DefaultWorkspace(), + } + for _, name := range router.EnabledSpecNames() { + out.EnabledSpecs = append(out.EnabledSpecs, daemon.LSPSpecStatus{ + Name: name, + Available: router.SpecAvailable(name), + Languages: strings.Join(router.SpecLanguages(name), ","), + }) + } + for _, s := range router.Stats() { + out.ActiveProviders = append(out.ActiveProviders, daemon.LSPActiveProvider{ + Spec: s.Spec, + Workspace: s.Workspace, + LastUsed: s.LastUsed.Format(time.RFC3339), + }) + } + return out +} + +// collectEnrichmentProgress reflects the semantic manager's per-(repo, +// provider) enrichment statuses into the compact summary the daemon +// status line needs. Returns nil when no semantic manager is wired, or +// it has never recorded a pass — the "enrichment in progress" state +// with nothing behind it is exactly the bug this closes. +func (c *realController) collectEnrichmentProgress() *daemon.EnrichmentProgress { + if c.indexer == nil { + return nil + } + semMgr := c.indexer.SemanticManager() + if semMgr == nil { + return nil + } + return enrichmentProgressFromStatuses(semMgr.EnrichmentStatuses()) +} + +// enrichmentProgressFromStatuses is the pure reduction behind +// collectEnrichmentProgress, split out so it can be unit tested +// against literal semantic.EnrichmentStatus rows without wiring a +// live indexer + semantic manager. A repo counts as done once every +// provider recorded for it has reached a terminal state; the first +// running row (in the manager's stable repo/provider order) becomes +// Current. +func enrichmentProgressFromStatuses(statuses []semantic.EnrichmentStatus) *daemon.EnrichmentProgress { + if len(statuses) == 0 { + return nil + } + + out := &daemon.EnrichmentProgress{} + repoDone := make(map[string]bool) + repoSeen := make(map[string]bool) + for _, st := range statuses { + repoSeen[st.Repo] = true + if _, ok := repoDone[st.Repo]; !ok { + repoDone[st.Repo] = true // assume done until a non-terminal provider says otherwise + } + switch st.State { + case semantic.EnrichStateRunning: + out.Running = true + repoDone[st.Repo] = false + if out.Current == nil { + cur := &daemon.EnrichmentCurrent{ + Repo: st.Repo, + Provider: st.Provider, + DeadlineSeconds: st.DeadlineSeconds, + } + if !st.StartedAt.IsZero() { + cur.ElapsedSeconds = time.Since(st.StartedAt).Seconds() + } + out.Current = cur + } + } + } + out.ReposTotal = len(repoSeen) + for _, done := range repoDone { + if done { + out.ReposDone++ + } + } + return out +} + +// SearchSymbols runs a substring match over node names and returns the +// matching symbols. It's the cheap probe path for clients (notably the +// Grep-redirect hook) that need a fast yes/no without setting up a full +// MCP session. File and Import nodes are excluded — the hook only cares +// about real symbol matches. +func (c *realController) SearchSymbols(_ context.Context, p daemon.SearchSymbolsParams) (daemon.SearchSymbolsResult, error) { + c.mu.Lock() + g := c.graph + c.mu.Unlock() + + if g == nil || p.Query == "" { + return daemon.SearchSymbolsResult{}, nil + } + + limit := p.Limit + if limit <= 0 || limit > 50 { + limit = 20 + } + needle := strings.ToLower(p.Query) + hits := make([]daemon.SymbolHit, 0, limit) + for _, n := range g.AllNodes() { + if n == nil { + continue + } + if n.Kind == graph.KindFile || n.Kind == graph.KindImport { + continue + } + if p.Repo != "" && n.RepoPrefix != p.Repo { + continue + } + if !strings.Contains(strings.ToLower(n.Name), needle) { + continue + } + hits = append(hits, daemon.SymbolHit{ + Name: n.Name, + Kind: string(n.Kind), + FilePath: n.FilePath, + Line: n.StartLine, + }) + if len(hits) >= limit { + break + } + } + return daemon.SearchSymbolsResult{Hits: hits}, nil +} + +// AttachWatcher is called by warmup to hand over the MultiWatcher once +// it has been initialized. Until this is called, realController.Track +// skips the per-repo watcher attach — a newly-tracked repo gets its +// watcher when the warmup-constructed MultiWatcher iterates +// mi.AllMetadata() at startup. +func (c *realController) AttachWatcher(mw *indexer.MultiWatcher) { + c.mu.Lock() + defer c.mu.Unlock() + c.multiWatcher = mw +} + +// MarkReady flips the ready flag once references are resolved and the graph +// is queryable, recording how long the parse + resolve stage took. Safe to +// call concurrently with Status (atomic loads on the read side). +func (c *realController) MarkReady(d time.Duration) { + c.warmupSeconds.Store(int64(d.Seconds())) + c.ready.Store(true) +} + +// IsReady reports whether the graph is resolved and queryable. The socket +// accepts connections before this; callers waiting to issue queries should +// wait for IsReady. +func (c *realController) IsReady() bool { + return c.ready.Load() +} + +// MarkEnriched flips the enrichment-complete flag once semantic enrichment +// and the graph-wide derivation passes finish in the background, recording +// the full warmup duration. It also sets ready, so the degenerate path where +// MarkReady was skipped still reports a usable daemon. +func (c *realController) MarkEnriched(d time.Duration) { + c.enrichSeconds.Store(int64(d.Seconds())) + c.enriched.Store(true) + c.ready.Store(true) +} + +// IsEnriched reports whether the background enrichment + derivation passes +// have finished. Background timers (the periodic snapshotter) gate on this +// rather than IsReady so they don't fight the enrichment pipeline for shard +// locks and GC budget. +func (c *realController) IsEnriched() bool { + return c.enriched.Load() +} + +// Shutdown gives the caller (the daemon main) a chance to flush any +// per-instance stores. The actual socket teardown is the Server's job. +func (c *realController) Shutdown(_ context.Context) error { + c.mu.Lock() + hook := c.onShutdown + c.mu.Unlock() + if hook != nil { + return hook() + } + return nil +} diff --git a/cmd/gortex/daemon_controller_proxy_test.go b/cmd/gortex/daemon_controller_proxy_test.go new file mode 100644 index 0000000..8c67fb1 --- /dev/null +++ b/cmd/gortex/daemon_controller_proxy_test.go @@ -0,0 +1,83 @@ +package main + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "go.uber.org/zap" + + "github.com/zzet/gortex/internal/daemon" +) + +// TestReloadServers_BuildSwapTeardown drives the live-reload lifecycle: +// build a router when the first remote appears, swap it in place when the +// roster changes, and tear it down when the last remote is removed. +func TestReloadServers_BuildSwapTeardown(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "servers.toml") + t.Setenv("GORTEX_DAEMON_SERVERS", path) + + var published []*daemon.Router // nil entries == teardown + c := &realController{ + logger: zap.NewNop(), + localExecute: func(context.Context, string, []byte) ([]byte, int, error) { return []byte(`{}`), 200, nil }, + } + c.publishRouter = func(r *daemon.Router) { published = append(published, r) } + + write := func(body string) { + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + } + reload := func() map[string]any { + raw, err := c.ReloadServers(context.Background()) + if err != nil { + t.Fatalf("ReloadServers: %v", err) + } + var m map[string]any + _ = json.Unmarshal(raw, &m) + return m + } + + // 1. First remote added => router built + published. + write("[[server]]\nslug = \"r2\"\nurl = \"https://r2:4747\"\n") + m := reload() + if m["router_wired"] != true || m["servers"].(float64) != 1 { + t.Fatalf("first add should wire 1 server, got %v", m) + } + if c.liveRouter == nil { + t.Fatal("liveRouter should be built") + } + if len(published) != 1 || published[0] == nil { + t.Fatalf("a non-nil router should have been published, got %v", published) + } + + // 2. Roster grows to 2 => in-place swap, no re-publish, new count visible. + write("[[server]]\nslug = \"r2\"\nurl = \"https://r2:4747\"\n[[server]]\nslug = \"r3\"\nurl = \"https://r3:4747\"\n") + m = reload() + if m["servers"].(float64) != 2 { + t.Fatalf("swap should reflect 2 servers, got %v", m) + } + if len(published) != 1 { + t.Fatal("an in-place swap must not re-publish the router") + } + if got := len(c.liveRouter.CurrentConfig().Server); got != 2 { + t.Fatalf("liveRouter should serve 2 servers after swap, got %d", got) + } + + // 3. All remotes removed => router torn down (nil published). + write("") + m = reload() + if m["router_wired"] != false || m["servers"].(float64) != 0 { + t.Fatalf("teardown should report 0/false, got %v", m) + } + if c.liveRouter != nil { + t.Fatal("liveRouter should be nil after teardown") + } + if len(published) != 2 || published[1] != nil { + t.Fatalf("teardown should publish a nil router, got %v", published) + } +} diff --git a/cmd/gortex/daemon_controller_worktree_test.go b/cmd/gortex/daemon_controller_worktree_test.go new file mode 100644 index 0000000..e5d3a2a --- /dev/null +++ b/cmd/gortex/daemon_controller_worktree_test.go @@ -0,0 +1,120 @@ +package main + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/zzet/gortex/internal/config" + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/indexer" + "github.com/zzet/gortex/internal/parser" + "github.com/zzet/gortex/internal/parser/languages" + "github.com/zzet/gortex/internal/search" +) + +func wtGit(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), + "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", + "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", + "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t") + out, err := cmd.CombinedOutput() + require.NoErrorf(t, err, "git %v: %s", args, out) +} + +// buildWorktreeController builds a realController whose MultiIndexer has +// indexed a canonical checkout plus a linked worktree (declaring a +// distinct workspace, with a colliding basename, so both would otherwise +// resolve to the same prefix). Returns the controller, indexer, config +// manager, and the two checkout paths. +func buildWorktreeController(t *testing.T) (*realController, *indexer.MultiIndexer, *config.ConfigManager, string, string) { + t.Helper() + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git binary not available in PATH") + } + canon := filepath.Join(t.TempDir(), "oas-orm") + require.NoError(t, os.MkdirAll(canon, 0o755)) + wtGit(t, canon, "init", "-q", "-b", "main") + require.NoError(t, os.WriteFile(filepath.Join(canon, "lib.go"), + []byte("package lib\n\nfunc C() {}\n"), 0o644)) + wtGit(t, canon, "add", ".") + wtGit(t, canon, "commit", "-q", "-m", "init") + + wt := filepath.Join(t.TempDir(), "oas-orm") + wtGit(t, canon, "worktree", "add", "-q", "-b", "task", wt) + require.NoError(t, os.WriteFile(filepath.Join(wt, ".gortex.yaml"), + []byte("workspace: task-ws\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(wt, "feature.go"), + []byte("package lib\n\nfunc F() {}\n"), 0o644)) + + cfgPath := filepath.Join(t.TempDir(), "config.yaml") + gc := &config.GlobalConfig{Repos: []config.RepoEntry{{Path: canon}, {Path: wt}}} + gc.SetConfigPath(cfgPath) + require.NoError(t, gc.Save()) + cm, err := config.NewConfigManager(cfgPath) + require.NoError(t, err) + + g := graph.New() + reg := parser.NewRegistry() + languages.RegisterAll(reg) + mi := indexer.NewMultiIndexer(g, reg, search.NewBM25(), cm, zap.NewNop()) + _, err = mi.IndexAll() + require.NoError(t, err) + + c := &realController{graph: g, multiIndexer: mi, configManager: cm, logger: zap.NewNop()} + return c, mi, cm, canon, wt +} + +func reloadResult(t *testing.T, c *realController) (added, removed int) { + t.Helper() + raw, err := c.Reload(context.Background()) + require.NoError(t, err) + var res struct { + Added int `json:"added"` + Removed int `json:"removed"` + } + require.NoError(t, json.Unmarshal(raw, &res)) + return res.Added, res.Removed +} + +// TestReload_KeepsWorktreeInstance is the regression for the reload +// untrack-diff: a worktree tracked under a derived `@` +// prefix must not be untracked on a no-op reload just because its config +// entry's basename matches the canonical's prefix. +func TestReload_KeepsWorktreeInstance(t *testing.T) { + c, mi, _, _, _ := buildWorktreeController(t) + require.NotNil(t, mi.GetMetadata("oas-orm")) + require.NotNil(t, mi.GetMetadata("oas-orm@task-ws")) + + added, removed := reloadResult(t, c) + assert.Equal(t, 0, added) + assert.Equal(t, 0, removed, "reload must not untrack the worktree instance") + assert.NotNil(t, mi.GetMetadata("oas-orm@task-ws"), "worktree instance survives reload") + assert.NotNil(t, mi.GetMetadata("oas-orm"), "canonical survives reload") +} + +// TestReload_UntracksRemovedWorktree confirms a worktree instance is +// dropped when its entry leaves the config — matched by root path, not a +// recomputed prefix. +func TestReload_UntracksRemovedWorktree(t *testing.T) { + c, mi, cm, _, wt := buildWorktreeController(t) + require.NotNil(t, mi.GetMetadata("oas-orm@task-ws")) + + require.NoError(t, cm.Global().RemoveRepo(wt)) + require.NoError(t, cm.Global().Save()) + + _, removed := reloadResult(t, c) + assert.Equal(t, 1, removed) + assert.Nil(t, mi.GetMetadata("oas-orm@task-ws"), "removed worktree is untracked") + assert.NotNil(t, mi.GetMetadata("oas-orm"), "canonical remains tracked") +} diff --git a/cmd/gortex/daemon_enrichment_progress_test.go b/cmd/gortex/daemon_enrichment_progress_test.go new file mode 100644 index 0000000..9db8f1e --- /dev/null +++ b/cmd/gortex/daemon_enrichment_progress_test.go @@ -0,0 +1,128 @@ +package main + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zzet/gortex/internal/daemon" + "github.com/zzet/gortex/internal/semantic" +) + +func TestEnrichmentProgressFromStatuses_Nil(t *testing.T) { + assert.Nil(t, enrichmentProgressFromStatuses(nil)) + assert.Nil(t, enrichmentProgressFromStatuses([]semantic.EnrichmentStatus{})) +} + +func TestEnrichmentProgressFromStatuses_AllDone(t *testing.T) { + statuses := []semantic.EnrichmentStatus{ + {Repo: "repo-a", Provider: "gopls", State: semantic.EnrichStateCompleted}, + {Repo: "repo-b", Provider: "gopls", State: semantic.EnrichStatePartial}, + {Repo: "repo-c", Provider: "gopls", State: semantic.EnrichStateAbandoned}, + } + out := enrichmentProgressFromStatuses(statuses) + require.NotNil(t, out) + assert.False(t, out.Running) + assert.Nil(t, out.Current) + assert.Equal(t, 3, out.ReposTotal) + assert.Equal(t, 3, out.ReposDone, "completed/partial/abandoned are all terminal states") +} + +func TestEnrichmentProgressFromStatuses_OneRunning(t *testing.T) { + start := time.Now().Add(-4 * time.Minute) + statuses := []semantic.EnrichmentStatus{ + {Repo: "repo-a", Provider: "gopls", State: semantic.EnrichStateCompleted}, + { + Repo: "repo-b", Provider: "gopls", State: semantic.EnrichStateRunning, + StartedAt: start, DeadlineSeconds: 900, + }, + } + out := enrichmentProgressFromStatuses(statuses) + require.NotNil(t, out) + assert.True(t, out.Running) + assert.Equal(t, 2, out.ReposTotal) + assert.Equal(t, 1, out.ReposDone) + require.NotNil(t, out.Current) + assert.Equal(t, "repo-b", out.Current.Repo) + assert.Equal(t, "gopls", out.Current.Provider) + assert.Equal(t, 900.0, out.Current.DeadlineSeconds) + assert.InDelta(t, 240, out.Current.ElapsedSeconds, 5) +} + +func TestEnrichmentProgressFromStatuses_RepoWithMixedProviders(t *testing.T) { + // repo-a has one completed and one still-running provider — the repo + // as a whole must not count as done until every provider finishes. + statuses := []semantic.EnrichmentStatus{ + {Repo: "repo-a", Provider: "gopls", State: semantic.EnrichStateCompleted}, + {Repo: "repo-a", Provider: "lsp-jdtls", State: semantic.EnrichStateRunning, DeadlineSeconds: 60}, + } + out := enrichmentProgressFromStatuses(statuses) + require.NotNil(t, out) + assert.Equal(t, 1, out.ReposTotal) + assert.Equal(t, 0, out.ReposDone) + assert.True(t, out.Running) +} + +// TestStatusResponse_EnrichmentJSONRoundTrip locks in that the new +// Enrichment block survives a JSON marshal/unmarshal round trip over +// the daemon control socket, and that it's omitted entirely (not +// rendered as a null block) when nil. +func TestStatusResponse_EnrichmentJSONRoundTrip(t *testing.T) { + st := daemon.StatusResponse{ + Version: "v1.2.3", + Ready: true, + Enrichment: &daemon.EnrichmentProgress{ + Running: true, + ReposTotal: 22, + ReposDone: 3, + Current: &daemon.EnrichmentCurrent{ + Repo: "gortex", + Provider: "gopls", + ElapsedSeconds: 240, + DeadlineSeconds: 900, + }, + }, + } + + raw, err := json.Marshal(st) + require.NoError(t, err) + assert.Contains(t, string(raw), `"enrichment":`) + assert.Contains(t, string(raw), `"repos_total":22`) + assert.Contains(t, string(raw), `"repos_done":3`) + + var round daemon.StatusResponse + require.NoError(t, json.Unmarshal(raw, &round)) + require.NotNil(t, round.Enrichment) + assert.Equal(t, st.Enrichment.ReposTotal, round.Enrichment.ReposTotal) + assert.Equal(t, st.Enrichment.ReposDone, round.Enrichment.ReposDone) + require.NotNil(t, round.Enrichment.Current) + assert.Equal(t, "gortex", round.Enrichment.Current.Repo) + assert.Equal(t, 900.0, round.Enrichment.Current.DeadlineSeconds) + + var empty daemon.StatusResponse + rawEmpty, err := json.Marshal(empty) + require.NoError(t, err) + assert.NotContains(t, string(rawEmpty), `"enrichment"`, "Enrichment must be omitted (omitempty) when nil") +} + +// TestSearchBackendStats_DiskResidentJSONRoundTrip locks in the new +// DiskResident flag survives the wire round trip and is omitted when +// false (the existing bleve/bm25 backends never set it). +func TestSearchBackendStats_DiskResidentJSONRoundTrip(t *testing.T) { + sb := daemon.SearchBackendStats{Name: "sqlite-fts5", DocCount: 48572, DiskResident: true} + raw, err := json.Marshal(sb) + require.NoError(t, err) + assert.Contains(t, string(raw), `"disk_resident":true`) + + var round daemon.SearchBackendStats + require.NoError(t, json.Unmarshal(raw, &round)) + assert.True(t, round.DiskResident) + + bm25 := daemon.SearchBackendStats{Name: "bm25", DocCount: 10} + raw2, err := json.Marshal(bm25) + require.NoError(t, err) + assert.NotContains(t, string(raw2), "disk_resident") +} diff --git a/cmd/gortex/daemon_heal_repos_test.go b/cmd/gortex/daemon_heal_repos_test.go new file mode 100644 index 0000000..c623eb3 --- /dev/null +++ b/cmd/gortex/daemon_heal_repos_test.go @@ -0,0 +1,57 @@ +package main + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/zzet/gortex/internal/config" +) + +func TestHealDuplicateRepos_DropsCaseVariantAndPersists(t *testing.T) { + forceCaseInsensitivePaths(t, true) + + base := t.TempDir() + upper := filepath.Join(base, "MyRepo") + lower := filepath.Join(base, "myrepo") + + cfgPath := filepath.Join(t.TempDir(), "config.yaml") + gc := &config.GlobalConfig{Repos: []config.RepoEntry{ + {Path: upper, Name: "first"}, + {Path: lower, Name: "dup"}, + }} + gc.SetConfigPath(cfgPath) + require.NoError(t, gc.Save()) + + removed := healDuplicateRepos(gc, zap.NewNop()) + assert.Equal(t, 1, removed) + require.Len(t, gc.Repos, 1) + assert.Equal(t, upper, gc.Repos[0].Path, "first (oldest) spelling must survive") + + // Persisted: reloading the config yields the single healed entry. + reloaded, err := config.LoadGlobal(cfgPath) + require.NoError(t, err) + require.Len(t, reloaded.Repos, 1) + assert.Equal(t, upper, reloaded.Repos[0].Path) +} + +func TestHealDuplicateRepos_NoDuplicatesNoOp(t *testing.T) { + forceCaseInsensitivePaths(t, true) + cfgPath := filepath.Join(t.TempDir(), "config.yaml") + gc := &config.GlobalConfig{Repos: []config.RepoEntry{ + {Path: filepath.Join(t.TempDir(), "a")}, + {Path: filepath.Join(t.TempDir(), "b")}, + }} + gc.SetConfigPath(cfgPath) + require.NoError(t, gc.Save()) + + assert.Equal(t, 0, healDuplicateRepos(gc, zap.NewNop())) + assert.Len(t, gc.Repos, 2) +} + +func TestHealDuplicateRepos_NilConfig(t *testing.T) { + assert.Equal(t, 0, healDuplicateRepos(nil, zap.NewNop())) +} diff --git a/cmd/gortex/daemon_http_compose_test.go b/cmd/gortex/daemon_http_compose_test.go new file mode 100644 index 0000000..5e6dbd5 --- /dev/null +++ b/cmd/gortex/daemon_http_compose_test.go @@ -0,0 +1,75 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +// TestComposeDaemonHTTPHandler asserts the daemon's single HTTP listener +// routes /mcp to the streamable surface and /v1 to the REST handler, that +// /v1 is bearer-auth gated, and that CORS headers are emitted. +func TestComposeDaemonHTTPHandler(t *testing.T) { + streamH := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("MCP")) + }) + v1 := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("V1")) + }) + + h := composeDaemonHTTPHandler(streamH, v1, func() string { return "secret" }, "*") + + // /mcp (and any non-/v1 path) routes to the streamable surface. + t.Run("mcp routes to streamable", func(t *testing.T) { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/mcp", nil)) + if rec.Body.String() != "MCP" { + t.Fatalf("/mcp body = %q, want MCP", rec.Body.String()) + } + }) + + // /v1 without a token is rejected. + t.Run("v1 requires auth", func(t *testing.T) { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/v1/health", nil)) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("/v1 without token = %d, want 401", rec.Code) + } + }) + + // /v1 with the bearer token reaches the REST handler. + t.Run("v1 with token reaches handler", func(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/v1/health", nil) + req.Header.Set("Authorization", "Bearer secret") + h.ServeHTTP(rec, req) + if rec.Body.String() != "V1" { + t.Fatalf("/v1 with token body = %q, want V1", rec.Body.String()) + } + }) + + // CORS preflight is answered (and exempt from auth) with the origin header. + t.Run("cors preflight", func(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodOptions, "/v1/tools/search_symbols", nil) + req.Header.Set("Origin", "https://app.example.com") + h.ServeHTTP(rec, req) + if rec.Header().Get("Access-Control-Allow-Origin") == "" { + t.Fatalf("missing Access-Control-Allow-Origin header on preflight") + } + }) +} + +// TestComposeDaemonHTTPHandler_NoCORS asserts an empty origin skips the +// CORS wrapper (no header injected). +func TestComposeDaemonHTTPHandler_NoCORS(t *testing.T) { + v1 := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {}) + h := composeDaemonHTTPHandler(http.NewServeMux(), v1, func() string { return "" }, "") + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/v1/health", nil) + req.Header.Set("Origin", "https://app.example.com") + h.ServeHTTP(rec, req) + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" { + t.Fatalf("CORS header present with empty origin: %q", got) + } +} diff --git a/cmd/gortex/daemon_http_test.go b/cmd/gortex/daemon_http_test.go new file mode 100644 index 0000000..4ff16e2 --- /dev/null +++ b/cmd/gortex/daemon_http_test.go @@ -0,0 +1,45 @@ +package main + +import "testing" + +// TestIsLocalhostBind covers loopback detection including host:port forms, +// so a normal loopback bind like 127.0.0.1:7411 is not mistaken for an +// externally reachable address. +func TestIsLocalhostBind(t *testing.T) { + cases := map[string]bool{ + "127.0.0.1": true, + "::1": true, + "localhost": true, + "127.0.0.1:7411": true, + "localhost:7411": true, + "[::1]:7411": true, + "127.0.0.2:80": true, // 127.0.0.0/8 is loopback + "0.0.0.0:7411": false, + ":7411": false, // wildcard bind = all interfaces + "0.0.0.0": false, + "192.168.1.5:80": false, + "example.com:443": false, + } + for bind, want := range cases { + if got := isLocalhostBind(bind); got != want { + t.Errorf("isLocalhostBind(%q) = %v, want %v", bind, got, want) + } + } +} + +// TestHTTPTokenRequirement asserts a non-localhost --http bind without a +// token is refused, while a localhost bind or a tokened bind is allowed. +func TestHTTPTokenRequirement(t *testing.T) { + if err := httpTokenRequirementError("0.0.0.0:0", ""); err == nil { + t.Error("non-localhost bind without a token must be refused") + } + if err := httpTokenRequirementError("0.0.0.0:0", "tok"); err != nil { + t.Errorf("non-localhost bind with a token must be allowed; got %v", err) + } + if err := httpTokenRequirementError("127.0.0.1:7411", ""); err != nil { + t.Errorf("localhost bind without a token must be allowed; got %v", err) + } + if err := httpTokenRequirementError(":7411", ""); err == nil { + t.Error("wildcard bind without a token must be refused") + } +} diff --git a/cmd/gortex/daemon_integration_test.go b/cmd/gortex/daemon_integration_test.go new file mode 100644 index 0000000..3fb5c16 --- /dev/null +++ b/cmd/gortex/daemon_integration_test.go @@ -0,0 +1,274 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/zzet/gortex/internal/config" + "github.com/zzet/gortex/internal/daemon" + "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" +) + +// spinUpDaemon builds a daemon with the real realController + +// mcpDispatcher + indexer stack and starts it on a short socket path. +// Returns the socket and the first tracked repo root so tests can +// handshake with a cwd that will pass the untracked-cwd guard. +// +// Heavier than the unit-test fakes — this is what makes this file +// an integration test. Everything from handshake through MCP tool +// dispatch goes through the real production code paths. +func spinUpDaemon(t *testing.T) (socket, trackedRoot string) { + t.Helper() + _, socket, trackedRoot = spinUpDaemonWithConfig(t) + return socket, trackedRoot +} + +// spinUpDaemonWithConfig is spinUpDaemon plus the test-scoped global +// config path, which tests that assert persistence side effects need +// to read back. +func spinUpDaemonWithConfig(t *testing.T) (configPath, socket, trackedRoot string) { + t.Helper() + + // Short base dir so the unix socket stays under the 104-char limit + // on macOS even with test-name suffixes. + dir, err := os.MkdirTemp("/tmp", "gxi") + require.NoError(t, err) + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + + socket = filepath.Join(dir, "s") + t.Setenv("GORTEX_DAEMON_SOCKET", socket) + t.Setenv("GORTEX_DAEMON_PIDFILE", filepath.Join(dir, "p")) + t.Setenv("GORTEX_DAEMON_SNAPSHOT", filepath.Join(dir, "snap.gob.gz")) + + // Stage a tiny repo the daemon can track. + trackedRoot = filepath.Join(dir, "repo") + require.NoError(t, os.MkdirAll(trackedRoot, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(trackedRoot, "main.go"), + []byte("package main\n\nfunc main() {}\n"), 0o644)) + + // Build the production-style state in-process. + g := graph.New() + reg := parser.NewRegistry() + languages.RegisterAll(reg) + + // Test-scoped global config path so a Track that persists doesn't + // touch the developer's real ~/.config/gortex/config.yaml. + configPath = filepath.Join(dir, "config.yaml") + + idx := indexer.New(g, reg, config.Default().Index, zap.NewNop()) + cm, err := config.NewConfigManager(configPath) + require.NoError(t, err) + mi := indexer.NewMultiIndexer(g, reg, idx.Search(), cm, zap.NewNop()) + + _, err = mi.TrackRepoCtx(context.Background(), config.RepoEntry{Path: trackedRoot}) + require.NoError(t, err) + + eng := query.NewEngine(g) + srv := gortexmcp.NewServer(eng, g, idx, nil, zap.NewNop(), nil, gortexmcp.MultiRepoOptions{ + MultiIndexer: mi, + ConfigManager: cm, + }) + + d := daemon.New(socket, "test", zap.NewNop()) + d.Controller = &realController{ + graph: g, + multiIndexer: mi, + configManager: cm, + logger: zap.NewNop(), + } + d.MCPDispatcher = newMCPDispatcher(srv, mi, zap.NewNop()) + + require.NoError(t, d.Listen()) + go func() { _ = d.Serve() }() + t.Cleanup(func() { _ = d.Shutdown() }) + + require.Eventually(t, func() bool { return daemon.IsRunningAt(socket) }, + 2*time.Second, 10*time.Millisecond) + return configPath, socket, trackedRoot +} + +// TestDaemon_EndToEnd_GraphStatsOverMCPProxy confirms that the whole +// stack — proxy handshake → session assignment → MCP frame forwarding → +// HandleMessage dispatch → tool handler → response marshaling — works +// with production components. If this test regresses, something +// fundamental is broken even though the unit tests still pass. +func TestDaemon_EndToEnd_GraphStatsOverMCPProxy(t *testing.T) { + socket, trackedRoot := spinUpDaemon(t) + + client, err := daemon.DialTo(socket, daemon.Handshake{ + Mode: daemon.ModeMCP, + CWD: trackedRoot, + ClientName: "integration-test", + }) + require.NoError(t, err) + defer client.Close() + + require.NotEmpty(t, client.Ack.SessionID) + + // Send an MCP `initialize` frame first — mcp-go requires it before + // tool calls. Mirrors what a real MCP client does on connect. + initFrame := []byte(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","clientInfo":{"name":"integration","version":"0"}}}`) + require.NoError(t, client.WriteMCPFrame(initFrame)) + initReply, err := client.ReadMCPFrame() + require.NoError(t, err) + require.Contains(t, string(initReply), `"result"`, + "initialize must succeed: %s", string(initReply)) + + // Now call a real tool. graph_stats needs no args and always returns + // something for an indexed graph. + toolFrame := []byte(`{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"graph_stats","arguments":{}}}`) + require.NoError(t, client.WriteMCPFrame(toolFrame)) + toolReply, err := client.ReadMCPFrame() + require.NoError(t, err) + + var resp struct { + Result map[string]any `json:"result"` + Error map[string]any `json:"error"` + } + require.NoError(t, json.Unmarshal(toolReply, &resp)) + require.Nil(t, resp.Error, "graph_stats must not error: %s", string(toolReply)) + + // Tool responses from mark3labs/mcp-go come back as a content array + // with a JSON text blob inside. + content, ok := resp.Result["content"].([]any) + require.True(t, ok, "result.content must be present: %+v", resp.Result) + require.NotEmpty(t, content) + text, ok := content[0].(map[string]any)["text"].(string) + require.True(t, ok) + + var stats map[string]any + require.NoError(t, json.Unmarshal([]byte(text), &stats)) + assert.Greater(t, int(stats["total_nodes"].(float64)), 0, + "indexed repo should contribute nodes: %v", stats) +} + +// TestDaemon_EndToEnd_UntrackedCWDRejectedViaProxy verifies the +// structured error reaches the client end-to-end, not just the unit +// dispatcher. An agent opening in an untracked directory must see a +// usable error on every tool call, not a silent zero result. +func TestDaemon_EndToEnd_UntrackedCWDRejectedViaProxy(t *testing.T) { + socket, trackedRoot := spinUpDaemon(t) + untracked := filepath.Dir(trackedRoot) // parent of tracked root is NOT tracked + + client, err := daemon.DialTo(socket, daemon.Handshake{ + Mode: daemon.ModeMCP, + CWD: untracked, + }) + require.NoError(t, err) + defer client.Close() + + // Skip initialize — the guard fires regardless of MCP state. That's + // actually the right behavior: untracked cwds can't do anything + // through the shared graph. + frame := []byte(`{"jsonrpc":"2.0","id":99,"method":"tools/call","params":{"name":"graph_stats","arguments":{}}}`) + require.NoError(t, client.WriteMCPFrame(frame)) + reply, err := client.ReadMCPFrame() + require.NoError(t, err) + + var resp struct { + Error struct { + Data map[string]any `json:"data"` + } `json:"error"` + } + require.NoError(t, json.Unmarshal(reply, &resp)) + assert.Equal(t, "repo_not_tracked", resp.Error.Data["error_code"], + "untracked cwd must produce structured error: %s", string(reply)) + assert.Equal(t, untracked, resp.Error.Data["path"]) +} + +// TestDaemon_EndToEnd_TrackAddsRepoLive proves track-while-running is +// instantly visible. We start the daemon with one tracked repo, track +// a second via the control socket, then verify a proxy dialing from +// the second repo's cwd is no longer rejected. +func TestDaemon_EndToEnd_TrackAddsRepoLive(t *testing.T) { + socket, _ := spinUpDaemon(t) + + // Stage a second repo under a path the daemon doesn't know about. + second := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(second, "lib.go"), + []byte("package main\nfunc Foo() {}\n"), 0o644)) + + // Reject first — not tracked yet. + client1, err := daemon.DialTo(socket, daemon.Handshake{Mode: daemon.ModeMCP, CWD: second}) + require.NoError(t, err) + require.NoError(t, client1.WriteMCPFrame([]byte( + `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"graph_stats","arguments":{}}}`))) + rej, err := client1.ReadMCPFrame() + require.NoError(t, err) + require.Contains(t, string(rej), "repo_not_tracked", + "proxy from untracked cwd must be rejected before track") + _ = client1.Close() + + // Track via control surface. + ctl, err := daemon.DialTo(socket, daemon.Handshake{Mode: daemon.ModeControl, ClientName: "cli"}) + require.NoError(t, err) + resp, err := ctl.Control(daemon.ControlTrack, daemon.TrackParams{Path: second}) + require.NoError(t, err) + require.True(t, resp.OK, "track failed: %s %s", resp.ErrorCode, resp.ErrorMsg) + _ = ctl.Close() + + // Now a proxy from the same cwd should pass the guard. + client2, err := daemon.DialTo(socket, daemon.Handshake{Mode: daemon.ModeMCP, CWD: second}) + require.NoError(t, err) + defer client2.Close() + + initFrame := []byte(`{"jsonrpc":"2.0","id":2,"method":"initialize","params":{"protocolVersion":"2025-03-26","clientInfo":{"name":"i","version":"0"}}}`) + require.NoError(t, client2.WriteMCPFrame(initFrame)) + _, _ = client2.ReadMCPFrame() // ack + + require.NoError(t, client2.WriteMCPFrame([]byte( + `{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"graph_stats","arguments":{}}}`))) + ok, err := client2.ReadMCPFrame() + require.NoError(t, err) + assert.NotContains(t, string(ok), "repo_not_tracked", + "post-track proxy must not be rejected: %s", string(ok)) +} + +// TestDaemon_EndToEnd_TrackPersistsToConfig pins the contract that +// `gortex track ` against a *running* daemon survives a daemon +// restart. Regression for a bug where realController.Track updated the +// in-memory GlobalConfig via AddRepo but never flushed to disk, so the +// repo vanished on the next start. +func TestDaemon_EndToEnd_TrackPersistsToConfig(t *testing.T) { + configPath, socket, _ := spinUpDaemonWithConfig(t) + + second := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(second, "lib.go"), + []byte("package main\nfunc Foo() {}\n"), 0o644)) + + ctl, err := daemon.DialTo(socket, daemon.Handshake{Mode: daemon.ModeControl, ClientName: "cli"}) + require.NoError(t, err) + resp, err := ctl.Control(daemon.ControlTrack, daemon.TrackParams{Path: second}) + require.NoError(t, err) + require.True(t, resp.OK, "track failed: %s %s", resp.ErrorCode, resp.ErrorMsg) + _ = ctl.Close() + + // Re-read the config from disk as a fresh process would on restart. + gc, err := config.LoadGlobal(configPath) + require.NoError(t, err) + + absSecond, _ := filepath.Abs(second) + var foundPaths []string + for _, r := range gc.Repos { + foundPaths = append(foundPaths, r.Path) + } + assert.Contains(t, foundPaths, absSecond, + "tracked repo must be persisted to the global config; got %v", foundPaths) +} + +// silence unused-import noise when gofmt reorders during edits. +var _ = fmt.Sprint diff --git a/cmd/gortex/daemon_mcp.go b/cmd/gortex/daemon_mcp.go new file mode 100644 index 0000000..fdd8cdc --- /dev/null +++ b/cmd/gortex/daemon_mcp.go @@ -0,0 +1,499 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "sync" + "sync/atomic" + + "go.uber.org/zap" + + "github.com/zzet/gortex/internal/daemon" + "github.com/zzet/gortex/internal/indexer" + gortexmcp "github.com/zzet/gortex/internal/mcp" + "github.com/zzet/gortex/internal/pathkey" +) + +// mcpDispatcher routes MCP JSON-RPC frames from daemon sessions to the +// shared *gortexmcp.Server. Every frame returns through +// MCPServer.HandleMessage, which is the public entry point the +// mark3labs/mcp-go library exposes for non-stdio embeddings. +// +// Session isolation is handled by threading the daemon-assigned session +// ID into ctx via gortexmcp.WithSessionID before HandleMessage runs. +// Tool handlers resolve per-client state through Server.sessionFor(ctx). +type mcpDispatcher struct { + srv *gortexmcp.Server + multiIndexer *indexer.MultiIndexer + logger *zap.Logger + // router is held behind an atomic.Pointer so ControlProxy can + // publish a freshly-built (or torn-down) router live without + // racing a concurrent dispatch read. + router atomic.Pointer[daemon.Router] + // decision is the shared peek→route→outcome helper; it reads the + // router through the atomic accessor so a live swap is reflected. + decision *daemon.ProxyDecision + // loggedUntracked records session IDs for which the "cwd not covered" + // diagnostic has already been emitted, so it fires once per session + // rather than on every frame. Cleared in SessionEnded. + loggedUntracked sync.Map +} + +func newMCPDispatcher(srv *gortexmcp.Server, mi *indexer.MultiIndexer, logger *zap.Logger) *mcpDispatcher { + if logger == nil { + logger = zap.NewNop() + } + d := &mcpDispatcher{srv: srv, multiIndexer: mi, logger: logger} + d.decision = daemon.NewProxyDecision(func() *daemon.Router { return d.router.Load() }) + return d +} + +// SetRouter wires the hybrid-read router into the daemon's MCP +// dispatch. With a router set, tools/call frames carrying +// a `workspace` arg or a session cwd that resolves to a non-local +// server are proxied; all other frames flow through the local +// MCPServer.HandleMessage path unchanged. +func (d *mcpDispatcher) SetRouter(r *daemon.Router) { d.router.Store(r) } + +// Router returns the currently-wired router (or nil). Exposed so the +// HTTP-side Streamable transport can share the same router instance +// without rebuilding it from servers.toml a second time. +func (d *mcpDispatcher) Router() *daemon.Router { return d.router.Load() } + +// Dispatch implements daemon.MCPDispatcher. It hands the raw JSON-RPC +// frame to MCPServer.HandleMessage and returns the response bytes. +// Empty return value means the client sent a notification (no response). +// +// The session ID from the daemon connection is attached to ctx via +// gortexmcp.WithSessionID so tool handlers reach per-session state +// through sessionFor(ctx) rather than the shared default. This is what +// keeps client A's recent-activity separate from client B's. +func (d *mcpDispatcher) Dispatch(ctx context.Context, sess *daemon.Session, frame []byte) ([]byte, error) { + if d.srv == nil || d.srv.MCPServer() == nil { + return nil, fmt.Errorf("mcp dispatcher: no server attached") + } + + // Fast-path reject untracked cwds. Returns a structured JSON-RPC + // error the agent can surface in chat ("run `gortex track .`") + // rather than a silent wrong-result. Skipped when the session has + // no cwd (the CLI and test harnesses don't set one), so control- + // flow paths keep working unchanged. With a multi-server router + // wired, a cwd that resolves to a remote workspace via the roster + // also counts as reachable — otherwise the cwd-walk priority + // chain in RouteForCwd would be dead code from the dispatcher's + // perspective. + // Method-aware untracked gate (F4): a tracked cwd is REQUIRED only for + // tools/call — that's the frame that needs the graph. initialize and + // tools/list flow through so the MCP handshake SURVIVES an untracked cwd: + // initialize returns the inactive-instructions variant (run `gortex track`) + // and tools/list answers an empty/track-only list, instead of a + // connection-poisoning errored initialize. Only tools/call is refused (with + // the structured not-tracked error the agent can act on). + untracked := sess.CWD != "" && !d.cwdReachable(sess.CWD) + if untracked { + d.logUncoveredCWDOnce(sess) + } + if untracked && peekFrameMethod(frame) == "tools/call" { + return d.notTrackedError(sess, frame), nil + } + + ctx = gortexmcp.WithSessionID(ctx, sess.ID) + // Carry the session's cwd so MCP tool handlers can resolve — and + // enforce — the workspace boundary for this session. Without this + // every query runs against the whole multi-workspace graph and a + // session in workspace A can see workspace B's nodes. + ctx = gortexmcp.WithSessionCWD(ctx, sess.CWD) + + // Relay the client-forwarded tool-surface preference (GORTEX_TOOLS / + // --tools of the proxy) so the MCP server resolves THIS session's + // effective preset authoritatively — the daemon serves a shared graph, + // so a per-client preset only applies if the client's choice reaches + // the server. No-op when the client forwarded no preference. + d.srv.NoteSessionToolPolicy(sess.ID, sess.ToolSpec, sess.ToolMode) + + // Identify the MCP client. The handshake's ClientName is the + // proxy's env-var-based guess (often "unknown" when no known env + // var matched). The MCP `initialize` request carries the + // authoritative `clientInfo.name` and `clientInfo.version`, so we + // snoop it here and overwrite the session metadata. Subsequent + // status calls then show "claude-code 1.0.42" instead of + // "unknown". + d.maybeSnoopInitialize(sess, frame) + + // For tools/call frames carrying a workspace scope or a cwd that + // routes elsewhere, the daemon + // proxies to the right server instead of running locally. Other + // frames (initialize, tools/list, notifications) flow through + // the local MCPServer below; routing them across a federation + // would change semantics that are intentionally machine-local. + if d.router.Load() != nil { + if proxied, ok := d.tryProxyToolCall(ctx, sess, frame); ok { + return proxied, nil + } + } + + // Promote-on-demand: a tools/call naming a deferred tool (one held out of + // the eager tools/list under the defer-mode surface) would otherwise return + // "tool not found" — the underlying MCP server only knows live tools until + // tools_search promotes them. A direct call by name (the CLI's `gortex call` + // and the curated `gortex` verbs reach the daemon this way) promotes it + // first, so a known tool name is reachable without a discovery round-trip. + // tools_search stays the discovery path; a hide-mode tool is never deferred, + // so this never bypasses the hide gate. + if name := peekFrameToolName(frame); name != "" { + newly := d.srv.EnsureToolPromoted(name) + // Record a call to a deferred/learned tool so the per-workspace + // learned surface promotes it into the cold list next session (and a + // stale promotion's demotion clock resets on continued use). + d.srv.NoteToolUse(name, sess.CWD, newly) + } + + // HandleMessage returns either a JSONRPCResponse, a JSONRPCError, or + // nil (the message was a notification). It never panics on malformed + // JSON — it returns a JSON-RPC parse-error frame instead. + reply := d.srv.MCPServer().HandleMessage(ctx, json.RawMessage(frame)) + if reply == nil { + return nil, nil + } + + out, err := json.Marshal(reply) + if err != nil { + d.logger.Warn("dispatch: marshal reply failed", + zap.String("session_id", sess.ID), zap.Error(err)) + return nil, fmt.Errorf("marshal reply: %w", err) + } + // For an untracked cwd, rewrite the surviving initialize / tools/list + // response into its inactive variant: the agent gets the actionable + // "run gortex track" instructions and an empty track-only tool list. + if untracked { + out = rewriteUntrackedResponse(peekFrameMethod(frame), out, sess.CWD, d.trackedRoots()) + } + return out, nil +} + +// peekFrameMethod extracts the JSON-RPC method from a raw frame, or "" when it +// can't be parsed (a malformed frame the server below will reject anyway). +func peekFrameMethod(frame []byte) string { + var peek struct { + Method string `json:"method"` + } + _ = json.Unmarshal(frame, &peek) + return peek.Method +} + +// peekFrameToolName returns the tool name of a tools/call frame, or "" when the +// frame is not a tools/call or can't be parsed. Used to promote a deferred tool +// on demand before local dispatch (see Dispatch). +func peekFrameToolName(frame []byte) string { + var peek struct { + Method string `json:"method"` + Params struct { + Name string `json:"name"` + } `json:"params"` + } + if json.Unmarshal(frame, &peek) != nil || peek.Method != "tools/call" { + return "" + } + return peek.Params.Name +} + +// rewriteUntrackedResponse swaps a successful initialize / tools/list response +// for its untracked-cwd variant: initialize carries the inactive instructions +// (with the cwd-specific `gortex track` affordance) and tools/list is emptied, +// so the handshake completes gracefully instead of erroring. Non-initialize / +// non-tools/list frames, error responses, and unparseable bodies pass through +// unchanged. +func rewriteUntrackedResponse(method string, out []byte, cwd string, roots []string) []byte { + if len(out) == 0 { + return out + } + var resp map[string]any + if json.Unmarshal(out, &resp) != nil { + return out + } + result, ok := resp["result"].(map[string]any) + if !ok { + return out // an error response or non-object result — leave it + } + switch method { + case "initialize": + result["instructions"] = gortexmcp.ServerInstructionsUntracked(cwd, roots...) + case "tools/list": + result["tools"] = []any{} + default: + return out + } + resp["result"] = result + if rewritten, mErr := json.Marshal(resp); mErr == nil { + return rewritten + } + return out +} + +// SessionEnded implements daemon.SessionEndedHook. When a proxy +// disconnects, drop its entry from the MCP server's session map so idle +// per-session state doesn't accumulate for the daemon's lifetime. +func (d *mcpDispatcher) SessionEnded(sess *daemon.Session) { + if sess == nil { + return + } + if d.srv != nil { + d.srv.ReleaseSession(sess.ID) + } + d.loggedUntracked.Delete(sess.ID) +} + +// cwdReachable reports whether a session cwd has any chance of +// being served by this daemon — locally or remotely. The local arm +// is isCWDTracked. The remote arm consults the router so a cwd +// whose .gortex.yaml::workspace is hosted by a server in +// servers.toml is treated as reachable; the call will be proxied by +// tryProxyToolCall later in Dispatch. Without this, the cwd-walk +// priority chain in RouteForCwd would never trigger from the MCP +// path because the cwd-tracked guard rejects first. +// +// Reachable when: +// - cwd is empty (no opinion — control-style sessions), +// - cwd is inside a locally tracked repo, +// - or the router resolves cwd to a known workspace via an +// explicit signal: scope-override, .gortex.yaml::workspace, or a +// server's declared workspaces / cached roster. +// +// The "default-server fall-through" case is intentionally NOT +// treated as reachable: a cwd that nobody claims would otherwise +// silently route to whatever server happens to be marked default +// in servers.toml, which is the same wrong-result class +// repo_not_tracked is meant to prevent. +func (d *mcpDispatcher) cwdReachable(cwd string) bool { + if cwd == "" { + return true + } + if d.isCWDTracked(cwd) { + return true + } + rtr := d.router.Load() + if rtr == nil { + return false + } + lookup := rtr.LookupForCwd(cwd, "") + switch lookup.Source { + case "scope-override", "config-yaml", "roster": + return true + } + return false +} + +// isCWDTracked reports whether the proxy's cwd lies inside any tracked +// repo. Equal paths or any subdirectory of a tracked root qualify — +// e.g. a proxy in ~/projects/myapp/internal counts as tracked when +// ~/projects/myapp is in the tracked set. +// +// Returns true when the daemon has no multi-indexer (single-repo mode, +// anything-goes) so we don't accidentally reject valid embedded-style +// sessions during the rollout. +func (d *mcpDispatcher) isCWDTracked(cwd string) bool { + if d.multiIndexer == nil { + return true + } + // Fold-aware containment: on a case-insensitive filesystem (macOS, + // Windows) the cwd the editor hands us can differ from the stored + // root only in letter case or drive-letter case (e.g. VS Code's + // `c:\repo` vs the config's `C:\repo`). A byte compare would reject + // it and publish zero tools; HasPathPrefix folds both first (#277). + for _, meta := range d.multiIndexer.AllMetadata() { + if pathkey.HasPathPrefix(cwd, meta.RootPath) { + return true + } + } + return false +} + +// logUncoveredCWDOnce emits, at most once per session, a diagnostic that +// names the session cwd and every tracked repo root — so an operator +// looking at an INACTIVE session (zero tools published) can immediately +// see the cwd the daemon was handed and the roots it was compared +// against. The mismatch is most often a path-case or drive-letter +// difference (#277). +func (d *mcpDispatcher) logUncoveredCWDOnce(sess *daemon.Session) { + if sess == nil || sess.ID == "" { + return + } + if _, loaded := d.loggedUntracked.LoadOrStore(sess.ID, struct{}{}); loaded { + return + } + d.logger.Info("mcp session cwd is not covered by any tracked repo", + zap.String("session_id", sess.ID), + zap.String("cwd", sess.CWD), + zap.Strings("tracked_roots", d.trackedRoots())) +} + +// trackedRoots returns the sorted absolute root of every locally tracked +// repo, for the INACTIVE diagnostics. +func (d *mcpDispatcher) trackedRoots() []string { + if d.multiIndexer == nil { + return nil + } + var roots []string + for _, meta := range d.multiIndexer.AllMetadata() { + if meta != nil && meta.RootPath != "" { + roots = append(roots, meta.RootPath) + } + } + sort.Strings(roots) + return roots +} + +// tryProxyToolCall inspects a JSON-RPC frame and, if it's a +// tools/call that the router resolves to a remote server, proxies it +// and returns the wrapped JSON-RPC response. Returns ok=false when +// the frame is not a tools/call, the router returns ErrRouteUnresolved +// (local-fast path), or the proxy itself errors (we let the local +// path handle it as a fallback so transient network blips don't +// break the user's session). +func (d *mcpDispatcher) tryProxyToolCall(ctx context.Context, sess *daemon.Session, frame []byte) ([]byte, bool) { + var peek struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + Params struct { + Name string `json:"name"` + Arguments map[string]any `json:"arguments"` + } `json:"params"` + } + if err := json.Unmarshal(frame, &peek); err != nil { + return nil, false + } + if peek.Method != "tools/call" || peek.Params.Name == "" { + return nil, false + } + scope, _ := peek.Params.Arguments["workspace"].(string) + body, err := json.Marshal(map[string]any{"arguments": peek.Params.Arguments}) + if err != nil { + return nil, false + } + outcome := d.decision.Decide(ctx, daemon.RouteInputs{ + ToolName: peek.Params.Name, + Body: body, + Cwd: sess.CWD, + Scope: scope, + }, sess) + if !outcome.Proxied { + // ErrRouteUnresolved or some other failure — let the local + // HandleMessage path take over (the same body works there). + return nil, false + } + out, status := outcome.Out, outcome.Status + if status >= 400 { + // Surface the upstream error as a JSON-RPC error so the + // client sees a structured failure instead of a 4xx that + // gets swallowed. + resp := map[string]any{ + "jsonrpc": "2.0", + "id": peek.ID, + "error": map[string]any{ + "code": -32000, + "message": fmt.Sprintf("proxy %s/%s: status %d", "remote", peek.Params.Name, status), + "data": map[string]any{ + "upstream_status": status, + "upstream_body": string(out), + }, + }, + } + buf, _ := json.Marshal(resp) + return buf, true + } + // Success — wrap the proxied bytes as a JSON-RPC result. + var result any + if err := json.Unmarshal(out, &result); err != nil { + // Non-JSON upstream — surface as text content for visibility. + result = map[string]any{ + "content": []map[string]any{{"type": "text", "text": string(out)}}, + } + } + resp := map[string]any{ + "jsonrpc": "2.0", + "id": peek.ID, + "result": result, + } + buf, _ := json.Marshal(resp) + return buf, true +} + +// maybeSnoopInitialize parses one inbound JSON-RPC frame and, if +// it's an MCP `initialize` request carrying `params.clientInfo`, +// updates the session's ClientName/ClientVersion. Non-initialize +// frames are ignored. Errors swallowed — this is best-effort +// metadata enrichment, not a correctness path. +func (d *mcpDispatcher) maybeSnoopInitialize(sess *daemon.Session, frame []byte) { + if sess == nil || len(frame) == 0 { + return + } + var peek struct { + Method string `json:"method"` + Params struct { + ClientInfo struct { + Name string `json:"name"` + Version string `json:"version"` + } `json:"clientInfo"` + } `json:"params"` + } + if err := json.Unmarshal(frame, &peek); err != nil { + return + } + if peek.Method != "initialize" { + return + } + if peek.Params.ClientInfo.Name == "" && peek.Params.ClientInfo.Version == "" { + return + } + sess.SetClientInfo(peek.Params.ClientInfo.Name, peek.Params.ClientInfo.Version) + if d.srv != nil { + // Propagate the client name into the per-session MCP state so + // tool handlers can resolve a default wire format (e.g. + // claude-code → gcx) when a request omits the explicit + // `format` arg. + d.srv.NoteSessionClient(sess.ID, peek.Params.ClientInfo.Name, peek.Params.ClientInfo.Version) + } + d.logger.Info("daemon: identified MCP client", + zap.String("session_id", sess.ID), + zap.String("client", peek.Params.ClientInfo.Name), + zap.String("version", peek.Params.ClientInfo.Version)) +} + +// notTrackedError builds a JSON-RPC error frame the agent surfaces to +// the user. The id is echoed from the inbound frame when it's a request; +// a zero id for notifications is fine (clients ignore responses to +// notifications anyway). +// +// Kept structured — error.code uses the MCP-convention -32000 range for +// server-defined errors; error.data carries machine-readable fields +// (error_code, path, suggestion) so a tool UI can offer a one-click +// "track this repo" button without regex-parsing the message string. +func (d *mcpDispatcher) notTrackedError(sess *daemon.Session, inbound []byte) []byte { + // Pull the request id out of the inbound frame so the response + // pairs correctly. If parsing fails (malformed frame), send a + // null id — JSON-RPC clients treat that as "error with no + // matching request" which is still more informative than + // silence. + var peek struct { + ID json.RawMessage `json:"id"` + } + _ = json.Unmarshal(inbound, &peek) + + resp := map[string]any{ + "jsonrpc": "2.0", + "id": peek.ID, + "error": map[string]any{ + "code": -32000, + "message": fmt.Sprintf("repository not tracked: %s", sess.CWD), + "data": map[string]any{ + "error_code": "repo_not_tracked", + "path": sess.CWD, + "suggestion": fmt.Sprintf("Run `gortex track %s` to include this repo in the shared graph.", sess.CWD), + }, + }, + } + out, _ := json.Marshal(resp) + return out +} diff --git a/cmd/gortex/daemon_mcp_case_identity_test.go b/cmd/gortex/daemon_mcp_case_identity_test.go new file mode 100644 index 0000000..75720d5 --- /dev/null +++ b/cmd/gortex/daemon_mcp_case_identity_test.go @@ -0,0 +1,100 @@ +package main + +import ( + "context" + "encoding/json" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zzet/gortex/internal/daemon" + "github.com/zzet/gortex/internal/pathkey" +) + +// forceCaseInsensitivePaths flips the process-global +// pathkey.CaseInsensitivePaths for the test and restores it afterwards. +// Tests using it must not run in parallel. +func forceCaseInsensitivePaths(t *testing.T, v bool) { + t.Helper() + prev := pathkey.CaseInsensitivePaths + pathkey.CaseInsensitivePaths = v + t.Cleanup(func() { pathkey.CaseInsensitivePaths = prev }) +} + +// toggleBaseCase returns p with the case of every ASCII letter inverted, +// so it names the same directory on a case-insensitive filesystem while +// differing byte-wise. Toggling the whole path (not just the final +// component) guarantees a byte difference even when the final component is +// all digits, as t.TempDir's "001" suffix is. +func toggleBaseCase(p string) string { + b := []byte(p) + for i := range b { + switch { + case b[i] >= 'a' && b[i] <= 'z': + b[i] -= 32 + case b[i] >= 'A' && b[i] <= 'Z': + b[i] += 32 + } + } + return string(b) +} + +// A case-variant cwd of a tracked root must be recognised as tracked on a +// case-insensitive filesystem. This is the #277 Windows / macOS 0-tools +// fix at the MCP dispatcher boundary (isCWDTracked is pure folding, no +// stat, so it is deterministic on every platform once folding is forced). +func TestDispatcher_CaseMismatchedCWD_Tracked(t *testing.T) { + forceCaseInsensitivePaths(t, true) + tracked := t.TempDir() + d, _ := trackedPathMCPSetup(t, tracked) + + variant := toggleBaseCase(tracked) + require.NotEqual(t, tracked, variant, "temp dir base must contain letters to toggle") + assert.True(t, d.isCWDTracked(variant), + "case-variant of tracked root must be recognised as tracked") + assert.True(t, d.isCWDTracked(filepath.Join(variant, "internal", "auth")), + "case-variant subdirectory must be recognised as tracked") +} + +// With case-sensitive comparison forced, a byte-different variant must be +// rejected — the fold must not over-merge on genuinely case-sensitive +// volumes. +func TestDispatcher_CaseSensitiveCWD_RejectsVariant(t *testing.T) { + forceCaseInsensitivePaths(t, false) + tracked := t.TempDir() + d, _ := trackedPathMCPSetup(t, tracked) + + variant := toggleBaseCase(tracked) + require.NotEqual(t, tracked, variant) + assert.False(t, d.isCWDTracked(variant), + "case-sensitive: byte-different variant must not be recognised as tracked") +} + +// The INACTIVE initialize instructions must name the tracked roots so the +// mismatch is self-diagnosing (#277 explicit ask). +func TestDispatcher_UntrackedInstructions_IncludeTrackedRoots(t *testing.T) { + tracked := t.TempDir() + untracked := t.TempDir() + d, _ := trackedPathMCPSetup(t, tracked) + + sess := &daemon.Session{ID: "sess_roots", CWD: untracked} + frame := []byte(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}`) + reply, err := d.Dispatch(context.Background(), sess, frame) + require.NoError(t, err) + require.NotNil(t, reply) + + var parsed map[string]any + require.NoError(t, json.Unmarshal(reply, &parsed)) + result, ok := parsed["result"].(map[string]any) + require.True(t, ok, "initialize must return a result: %v", parsed) + instr, ok := result["instructions"].(string) + require.True(t, ok, "initialize result must carry instructions") + + assert.Contains(t, instr, "INACTIVE") + assert.Contains(t, instr, "Tracked repository roots", + "instructions must list tracked roots for self-diagnosis") + assert.Contains(t, instr, tracked, + "instructions must name the actual tracked root path") +} diff --git a/cmd/gortex/daemon_mcp_promote_test.go b/cmd/gortex/daemon_mcp_promote_test.go new file mode 100644 index 0000000..2fcd47f --- /dev/null +++ b/cmd/gortex/daemon_mcp_promote_test.go @@ -0,0 +1,29 @@ +package main + +import "testing" + +// TestPeekFrameToolName covers the helper that drives promote-on-demand: it +// must return the tool name for a tools/call frame and "" for anything else, +// so the dispatcher only promotes on a genuine tool call. +func TestPeekFrameToolName(t *testing.T) { + cases := []struct { + name string + frame string + want string + }{ + {"tools/call", `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"flow_between","arguments":{"source_id":"a"}}}`, "flow_between"}, + {"tools/call no args", `{"method":"tools/call","params":{"name":"feedback"}}`, "feedback"}, + {"tools/list is not a call", `{"method":"tools/list","params":{}}`, ""}, + {"initialize is not a call", `{"method":"initialize","params":{"name":"x"}}`, ""}, + {"missing name", `{"method":"tools/call","params":{}}`, ""}, + {"malformed json", `{not json`, ""}, + {"empty", ``, ""}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := peekFrameToolName([]byte(c.frame)); got != c.want { + t.Fatalf("peekFrameToolName(%s) = %q, want %q", c.frame, got, c.want) + } + }) + } +} diff --git a/cmd/gortex/daemon_mcp_test.go b/cmd/gortex/daemon_mcp_test.go new file mode 100644 index 0000000..313d901 --- /dev/null +++ b/cmd/gortex/daemon_mcp_test.go @@ -0,0 +1,373 @@ +package main + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/zzet/gortex/internal/daemon" + "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" + + "github.com/zzet/gortex/internal/config" +) + +// trackedPathMCPSetup builds a minimal Server + MultiIndexer with one +// repo tracked at `root`. Used by the not-tracked-guard tests so we can +// drive the dispatcher end-to-end without spinning up a real daemon. +func trackedPathMCPSetup(t *testing.T, root string) (*mcpDispatcher, *indexer.MultiIndexer) { + t.Helper() + g := graph.New() + reg := parser.NewRegistry() + languages.RegisterAll(reg) + + cm, err := config.NewConfigManager("") + require.NoError(t, err) + + idx := indexer.New(g, reg, config.Default().Index, zap.NewNop()) + mi := indexer.NewMultiIndexer(g, reg, idx.Search(), cm, zap.NewNop()) + + // Register a repo the dispatcher will recognize. We bypass indexing + // by stuffing metadata directly — the isCWDTracked check only reads + // AllMetadata. + if _, err := mi.TrackRepoCtx(context.Background(), config.RepoEntry{ + Path: root, + }); err != nil { + t.Fatalf("track test repo: %v", err) + } + + eng := query.NewEngine(g) + srv := gortexmcp.NewServer(eng, g, idx, nil, zap.NewNop(), nil, gortexmcp.MultiRepoOptions{ + MultiIndexer: mi, + ConfigManager: cm, + }) + + return newMCPDispatcher(srv, mi, zap.NewNop()), mi +} + +func TestDispatcher_UntrackedCWD_ReturnsStructuredError(t *testing.T) { + // Tracked root is a directory the test creates; untracked is a + // sibling path the dispatcher shouldn't know about. + tracked := t.TempDir() + untracked := t.TempDir() + + d, _ := trackedPathMCPSetup(t, tracked) + + sess := &daemon.Session{ID: "sess_x", CWD: untracked} + // A real tool invocation arrives as tools/call — that's the only frame + // the method-aware gate refuses for an untracked cwd. + frame := []byte(`{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"graph_stats","arguments":{}}}`) + + reply, err := d.Dispatch(context.Background(), sess, frame) + require.NoError(t, err) + require.NotNil(t, reply, "untracked cwd must produce a reply, not silence") + + var parsed map[string]any + require.NoError(t, json.Unmarshal(reply, &parsed)) + + errObj, ok := parsed["error"].(map[string]any) + require.True(t, ok, "response must carry an error object: %v", parsed) + + // Machine-readable data for tool UIs. + data, ok := errObj["data"].(map[string]any) + require.True(t, ok, "error.data must be present for client-side handling") + assert.Equal(t, "repo_not_tracked", data["error_code"]) + assert.Equal(t, untracked, data["path"]) + assert.Contains(t, data["suggestion"], "gortex track") + + // The response id must echo the inbound id so the client can pair + // it with the in-flight request. + assert.EqualValues(t, 7, parsed["id"]) +} + +func TestDispatcher_TrackedCWD_Passes(t *testing.T) { + tracked := t.TempDir() + d, _ := trackedPathMCPSetup(t, tracked) + + sess := &daemon.Session{ID: "sess_y", CWD: tracked} + // The method string doesn't matter for this test — we're proving + // the dispatcher passes the frame through to MCPServer instead of + // short-circuiting on the tracked-cwd guard. Whatever mcp-go does + // with the method (including "method not found" for bogus ones) is + // evidence that our guard let it through. + frame := []byte(`{"jsonrpc":"2.0","id":1,"method":"graph_stats","params":{}}`) + + reply, err := d.Dispatch(context.Background(), sess, frame) + require.NoError(t, err) + require.NotNil(t, reply) + + var parsed map[string]any + require.NoError(t, json.Unmarshal(reply, &parsed)) + + // The response may carry an mcp-go protocol error (method name isn't + // the right shape — real tool calls go through tools/call), but it + // must NOT carry OUR "repo_not_tracked" sentinel. + if errObj, ok := parsed["error"].(map[string]any); ok { + if data, ok := errObj["data"].(map[string]any); ok { + assert.NotEqual(t, "repo_not_tracked", data["error_code"], + "tracked cwd wrongly rejected by guard: %v", parsed) + } + } +} + +func TestDispatcher_SubdirectoryOfTrackedRoot_Passes(t *testing.T) { + tracked := t.TempDir() + // A nested path inside a tracked root also counts as tracked — an + // agent opened in repo/internal/auth is still "in" the repo. + nested := filepath.Join(tracked, "internal", "auth") + + d, _ := trackedPathMCPSetup(t, tracked) + + assert.True(t, d.isCWDTracked(nested), + "subdirectory of tracked root must be recognized as tracked") + assert.True(t, d.isCWDTracked(tracked), + "tracked root itself must be recognized as tracked") + assert.False(t, d.isCWDTracked(filepath.Dir(tracked)), + "parent of tracked root must NOT be recognized as tracked") +} + +func TestDispatcher_NilMultiIndexer_AllowsEverything(t *testing.T) { + // Single-repo mode has no multi-indexer. The guard must not reject + // in that case — otherwise we'd break the embedded stdio path. + d := newMCPDispatcher(nil, nil, zap.NewNop()) + assert.True(t, d.isCWDTracked("/anywhere")) + assert.True(t, d.isCWDTracked("")) +} + +// TestDispatcher_RemoteRoutableCWD_Passes covers the multi-server +// case: a cwd that is NOT in any locally tracked repo but DOES +// resolve to a workspace declared by a server in the roster must +// pass the cwd guard so the request reaches tryProxyToolCall. +// +// Without this, the four-step priority chain in RouteForCwd is +// dead code from the MCP dispatcher's perspective: any user who +// `cd`s into a remote-served repo gets a repo_not_tracked error +// even though the daemon could have proxied happily. +func TestDispatcher_RemoteRoutableCWD_Passes(t *testing.T) { + // Local daemon tracks nothing. + tracked := t.TempDir() + d, _ := trackedPathMCPSetup(t, tracked) + + // A repo on disk that the daemon does NOT track but whose + // .gortex.yaml declares a workspace claimed by a roster entry. + remote := t.TempDir() + require.NoError(t, + writeFile(filepath.Join(remote, ".gortex.yaml"), "workspace: remote-ws\n")) + + cfg := &daemon.ServersConfig{ + Server: []daemon.ServerEntry{ + {Slug: "self", URL: "unix:///tmp/never-dialed.sock", Default: true}, + {Slug: "remote", URL: "https://remote.example.com", Workspaces: []string{"remote-ws"}}, + }, + } + require.NoError(t, cfg.Validate()) + router := daemon.NewRouter(daemon.RouterConfig{ + Servers: cfg, + Rosters: daemon.NewWorkspaceRosterCache(0), + LocalSlug: "self", + Logger: zap.NewNop(), + }) + d.SetRouter(router) + + // Sanity: cwdReachable agrees the remote-routable cwd passes. + assert.True(t, d.cwdReachable(remote), + "cwd inside remote-served repo must be reachable") + assert.False(t, d.cwdReachable(filepath.Join(t.TempDir(), "nowhere")), + "cwd with no .gortex.yaml + no roster match must be rejected") + + // End-to-end: Dispatch must NOT short-circuit with repo_not_tracked + // for the remote-routable cwd. We don't need the proxy to actually + // succeed (the URL is unreachable in the test) — proving the guard + // passed is enough; tryProxyToolCall's failure path falls through + // to the local executor which returns a normal mcp-go reply. + sess := &daemon.Session{ID: "sess_remote", CWD: remote} + frame := []byte(`{"jsonrpc":"2.0","id":3,"method":"graph_stats","params":{}}`) + reply, err := d.Dispatch(context.Background(), sess, frame) + require.NoError(t, err) + require.NotNil(t, reply) + + var parsed map[string]any + require.NoError(t, json.Unmarshal(reply, &parsed)) + if errObj, ok := parsed["error"].(map[string]any); ok { + if data, ok := errObj["data"].(map[string]any); ok { + assert.NotEqual(t, "repo_not_tracked", data["error_code"], + "remote-routable cwd wrongly rejected by guard: %v", parsed) + } + } +} + +// TestDispatcher_LocalWorkspaceUmbrellaCWD_Passes covers the case +// where the cwd is the umbrella directory of a multi-repo workspace: +// it has a `.gortex.yaml` declaring `workspace: ` but is NOT +// itself a tracked repo, AND no server in servers.toml claims that +// workspace. The repos belonging to the workspace are tracked +// individually as siblings/subdirectories. +// +// Without this, agents started in the umbrella get a `repo_not_tracked` +// error even though RouteForCwd resolves Source="config-yaml" from +// the .gortex.yaml — making local-only workspace declarations useless +// from the dispatcher's perspective. The contract is: any "config-yaml" +// resolution is reachable, regardless of whether a server claimed it. +func TestDispatcher_LocalWorkspaceUmbrellaCWD_Passes(t *testing.T) { + // Local daemon tracks something unrelated — proves the guard does + // not rely on isCWDTracked seeing the umbrella itself. + tracked := t.TempDir() + d, _ := trackedPathMCPSetup(t, tracked) + + // Umbrella directory with .gortex.yaml::workspace = "vio". The + // umbrella itself is NOT tracked. No server in servers.toml claims + // "vio" — only some other workspace. + umbrella := t.TempDir() + require.NoError(t, + writeFile(filepath.Join(umbrella, ".gortex.yaml"), "workspace: vio\n")) + + cfg := &daemon.ServersConfig{ + Server: []daemon.ServerEntry{ + {Slug: "self", URL: "unix:///tmp/never.sock", Workspaces: []string{"gortex"}, Default: true}, + }, + } + require.NoError(t, cfg.Validate()) + router := daemon.NewRouter(daemon.RouterConfig{ + Servers: cfg, + Rosters: daemon.NewWorkspaceRosterCache(0), + LocalSlug: "self", + Logger: zap.NewNop(), + }) + d.SetRouter(router) + + assert.True(t, d.cwdReachable(umbrella), + "workspace-umbrella cwd must be reachable when .gortex.yaml declares a workspace, even when no server claims it") + + sess := &daemon.Session{ID: "sess_umbrella", CWD: umbrella} + frame := []byte(`{"jsonrpc":"2.0","id":9,"method":"graph_stats","params":{}}`) + reply, err := d.Dispatch(context.Background(), sess, frame) + require.NoError(t, err) + require.NotNil(t, reply) + + var parsed map[string]any + require.NoError(t, json.Unmarshal(reply, &parsed)) + if errObj, ok := parsed["error"].(map[string]any); ok { + if data, ok := errObj["data"].(map[string]any); ok { + assert.NotEqual(t, "repo_not_tracked", data["error_code"], + "workspace-umbrella cwd wrongly rejected by guard: %v", parsed) + } + } +} + +// TestDispatcher_UnreachableCWD_StillRejected guards against the +// fix becoming too permissive. A cwd that's neither locally tracked +// nor matches any workspace declared in the roster (and has no +// .gortex.yaml) must still get the repo_not_tracked error. +func TestDispatcher_UnreachableCWD_StillRejected(t *testing.T) { + tracked := t.TempDir() + d, _ := trackedPathMCPSetup(t, tracked) + + cfg := &daemon.ServersConfig{ + Server: []daemon.ServerEntry{ + {Slug: "self", URL: "unix:///tmp/never.sock", Default: true}, + }, + } + require.NoError(t, cfg.Validate()) + router := daemon.NewRouter(daemon.RouterConfig{ + Servers: cfg, + Rosters: daemon.NewWorkspaceRosterCache(0), + LocalSlug: "self", + Logger: zap.NewNop(), + }) + d.SetRouter(router) + + stranger := t.TempDir() // no .gortex.yaml, not tracked, no roster match + assert.False(t, d.cwdReachable(stranger)) + + sess := &daemon.Session{ID: "sess_stranger", CWD: stranger} + frame := []byte(`{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"graph_stats","arguments":{}}}`) + reply, err := d.Dispatch(context.Background(), sess, frame) + require.NoError(t, err) + + var parsed map[string]any + require.NoError(t, json.Unmarshal(reply, &parsed)) + errObj, ok := parsed["error"].(map[string]any) + require.True(t, ok, "stranger cwd must still be rejected: %v", parsed) + data, ok := errObj["data"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "repo_not_tracked", data["error_code"]) +} + +// TestDispatcher_UntrackedHandshake_SurvivesWithInactiveVariant proves the +// F4 contract: an MCP session opened in a cwd that no tracked repo covers must +// still complete its handshake. initialize flows through and the response +// carries the inactive-instructions variant (telling the agent to run `gortex +// track `); tools/list answers an empty track-only list; only tools/call +// is refused with the structured repo_not_tracked error. This beats +// codegraph's silent empty-list — the agent gets an actionable affordance, not +// a dead connection. +func TestDispatcher_UntrackedHandshake_SurvivesWithInactiveVariant(t *testing.T) { + tracked := t.TempDir() + untracked := t.TempDir() // a sibling the dispatcher does not cover + + d, _ := trackedPathMCPSetup(t, tracked) + sess := &daemon.Session{ID: "sess_handshake", CWD: untracked} + + dispatch := func(t *testing.T, frame string) map[string]any { + t.Helper() + reply, err := d.Dispatch(context.Background(), sess, []byte(frame)) + require.NoError(t, err) + require.NotNil(t, reply, "handshake frame must produce a reply") + var parsed map[string]any + require.NoError(t, json.Unmarshal(reply, &parsed)) + return parsed + } + + t.Run("initialize_flows_through_with_inactive_instructions", func(t *testing.T) { + parsed := dispatch(t, `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}`) + + // The handshake SUCCEEDS — no error object, the connection lives. + _, isErr := parsed["error"] + assert.False(t, isErr, "untracked initialize must not error: %v", parsed) + + result, ok := parsed["result"].(map[string]any) + require.True(t, ok, "initialize must return a result object: %v", parsed) + + instr, ok := result["instructions"].(string) + require.True(t, ok, "initialize result must carry instructions") + assert.Contains(t, instr, "INACTIVE", "instructions must flag the inactive state") + assert.Contains(t, instr, "gortex track "+untracked, + "instructions must carry the cwd-specific track affordance") + }) + + t.Run("tools_list_returns_empty_track_only", func(t *testing.T) { + parsed := dispatch(t, `{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}`) + + result, ok := parsed["result"].(map[string]any) + require.True(t, ok, "tools/list must return a result object: %v", parsed) + tools, ok := result["tools"].([]any) + require.True(t, ok, "tools/list result must carry a tools array") + assert.Empty(t, tools, "untracked tools/list must be an empty track-only list") + }) + + t.Run("tools_call_still_refused", func(t *testing.T) { + parsed := dispatch(t, `{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"graph_stats","arguments":{}}}`) + + errObj, ok := parsed["error"].(map[string]any) + require.True(t, ok, "untracked tools/call must still be refused: %v", parsed) + data, ok := errObj["data"].(map[string]any) + require.True(t, ok, "refusal must carry machine-readable data") + assert.Equal(t, "repo_not_tracked", data["error_code"]) + assert.Equal(t, untracked, data["path"]) + }) +} + +// writeFile is a tiny helper to keep test setup readable. +func writeFile(path, content string) error { + return os.WriteFile(path, []byte(content), 0o644) +} diff --git a/cmd/gortex/daemon_memlimit.go b/cmd/gortex/daemon_memlimit.go new file mode 100644 index 0000000..7821472 --- /dev/null +++ b/cmd/gortex/daemon_memlimit.go @@ -0,0 +1,279 @@ +package main + +import ( + "fmt" + "math" + "os" + "runtime" + "runtime/debug" + "strconv" + "strings" + "time" + + "go.uber.org/zap" + + "github.com/zzet/gortex/internal/platform" +) + +// Daemon memory envelope: a standing soft memory limit installed at boot, +// and a forced heap-to-OS release fired at allocation-burst boundaries. +// +// The daemon is a long-lived background service that shares a developer's +// machine. With the runtime default (no memory limit, GOGC=100) the Go GC +// lets the heap high-water climb toward machine RAM during a burst and then +// keeps that footprint resident — the observed failure was a multi-GB peak +// from a warmup / whole-graph-analysis burst pinning the process footprint +// for hours at idle. A soft memory limit makes the collector pace against a +// ceiling and resist that balloon growth; the release helper returns a +// burst's high-water to the OS so the idle footprint tracks the working set +// rather than the peak. Both are policy, not hard guarantees, and both carry +// env kill-switches so an operator can bypass them entirely. + +const ( + // standingMemLimitDivisor takes a quarter of host RAM as the default + // budget: a background service should leave the bulk of RAM to the + // editor, compiler, and the app under test. + standingMemLimitDivisor = 4 + // standingMemLimitFloor is the lowest limit the default policy will + // set. Below ~1 GiB a daemon with a resident graph would sit in + // near-constant GC, so the floor trades a little footprint for + // steady-state responsiveness. + standingMemLimitFloor = int64(1) << 30 // 1 GiB + // standingMemLimitCeil caps the default so a large workstation's + // quarter-RAM figure doesn't hand the daemon a tens-of-GiB budget it + // never needs — the point of the limit is to resist balloon growth, + // not to permit it. An operator who genuinely wants more sets an + // explicit value (honored verbatim, below). + standingMemLimitCeil = int64(8) << 30 // 8 GiB +) + +// memLimitResolution is the outcome of the standing-limit policy: the byte +// value to install (0 = install nothing), where it came from (for the log +// line), and an optional warning when a provided value was malformed and +// ignored. +type memLimitResolution struct { + limit int64 + source string // "goenv" | "env" | "config" | "default" | "off" | "unavailable" + warn string +} + +// resolveStandingMemoryLimit is the pure standing-limit policy. Resolution +// order, highest priority first: +// +// 1. GOMEMLIMIT set — the Go runtime already honors it, so we never fight +// an explicit operator setting: install nothing, source "goenv". +// 2. GORTEX_DAEMON_MEMLIMIT env var. +// 3. the daemon.memory_limit config value. +// 4. the RAM-derived default policy. +// +// An explicit env/config value is honored verbatim (the operator knows +// their machine); only the default is clamped. "off"/"0" at the env or +// config layer disables the standing limit outright. A malformed env/config +// value is ignored and the decision falls through to the default with a +// warning. Pure — every input is a parameter — so the whole precedence +// table is exhaustively testable without touching the process or the host. +func resolveStandingMemoryLimit(hostRAM uint64, goenv, env, cfg string) memLimitResolution { + if strings.TrimSpace(goenv) != "" { + return memLimitResolution{source: "goenv"} + } + for _, layer := range []struct{ name, val string }{ + {"env", env}, + {"config", cfg}, + } { + v := strings.TrimSpace(layer.val) + if v == "" { + continue + } + n, err := parseByteSize(v) + if err != nil { + // A typo'd operator value should not abort boot; apply the safe + // default instead and surface why. Deliberately does not fall + // through to the next layer — a malformed value is a signal, not + // an invitation to keep guessing. + d := defaultMemLimitResolution(hostRAM) + d.warn = fmt.Sprintf("invalid %s memory limit %q: %v", layer.name, layer.val, err) + return d + } + if n <= 0 { + return memLimitResolution{source: "off"} + } + return memLimitResolution{limit: n, source: layer.name} + } + return defaultMemLimitResolution(hostRAM) +} + +// defaultMemLimitResolution wraps the RAM-derived default. Host RAM of 0 +// means "unknown" (no portable reader on this platform, or the syscall +// failed): with no machine to reason about, installing an arbitrary limit +// could throttle a large server or over-commit a tiny one, so the safe +// answer is to install nothing. +func defaultMemLimitResolution(hostRAM uint64) memLimitResolution { + n := defaultStandingMemoryLimit(hostRAM) + if n <= 0 { + return memLimitResolution{source: "unavailable"} + } + return memLimitResolution{limit: n, source: "default"} +} + +// defaultStandingMemoryLimit derives the default soft limit from host RAM: +// a quarter of it, clamped to [floor, ceil]. Returns 0 when host RAM is +// unknown. Pure so the clamps are table-testable without a host. +func defaultStandingMemoryLimit(hostRAM uint64) int64 { + if hostRAM == 0 { + return 0 + } + limit := int64(hostRAM / standingMemLimitDivisor) + if limit < standingMemLimitFloor { + limit = standingMemLimitFloor + } + if limit > standingMemLimitCeil { + limit = standingMemLimitCeil + } + return limit +} + +// parseByteSize parses a human byte size into an exact byte count. It +// accepts a bare integer (bytes) or an integer with a binary unit suffix — +// K/KB/KiB, M/MB/MiB, G/GB/GiB, T/TB/TiB — all interpreted as powers of +// 1024, case-insensitively, since this sizes a memory budget. "off", "0", +// and the empty string parse to 0 (the caller reads 0 as "disabled"). A +// malformed number, an unrecognised unit, or a value that would overflow +// int64 returns an error so the caller can fall back to the default policy. +func parseByteSize(s string) (int64, error) { + s = strings.TrimSpace(s) + if s == "" { + return 0, nil + } + low := strings.ToLower(s) + if low == "off" || low == "0" { + return 0, nil + } + i := 0 + for i < len(low) && low[i] >= '0' && low[i] <= '9' { + i++ + } + numPart := low[:i] + unit := strings.TrimSpace(low[i:]) + if numPart == "" { + return 0, fmt.Errorf("invalid byte size %q", s) + } + n, err := strconv.ParseInt(numPart, 10, 64) + if err != nil || n < 0 { + return 0, fmt.Errorf("invalid byte size %q", s) + } + var mult int64 + switch unit { + case "", "b": + mult = 1 + case "k", "kb", "kib": + mult = 1 << 10 + case "m", "mb", "mib": + mult = 1 << 20 + case "g", "gb", "gib": + mult = 1 << 30 + case "t", "tb", "tib": + mult = 1 << 40 + default: + return 0, fmt.Errorf("invalid byte-size unit %q in %q", unit, s) + } + if mult > 1 && n > math.MaxInt64/mult { + return 0, fmt.Errorf("byte size out of range %q", s) + } + return n * mult, nil +} + +// applyStandingMemoryLimit resolves and installs the daemon's standing soft +// memory limit. Call once at boot, after logging and config are up and +// before warmup starts allocating. +// +// Composition with the cold-index window (internal/indexer/gc_tune.go): a +// cold index briefly raises the limit to a larger budget (RAM/2) and, on +// exit, restores the value it captured via debug.SetMemoryLimit(-1) — which +// is exactly the standing limit installed here. Installing this before any +// index runs is what makes that restore land on our value rather than on +// "no limit". The two are therefore composable: the daemon holds a modest +// standing ceiling, cold indexes get their wider one-shot budget, and the +// standing ceiling comes back afterward untouched. +func applyStandingMemoryLimit(logger *zap.Logger, cfgVal string) { + d := resolveStandingMemoryLimit( + platform.HostPhysicalMemoryBytes(), + os.Getenv("GOMEMLIMIT"), + os.Getenv("GORTEX_DAEMON_MEMLIMIT"), + cfgVal, + ) + if d.warn != "" && logger != nil { + logger.Warn("daemon: standing memory limit — falling back to default", + zap.String("reason", d.warn)) + } + switch d.source { + case "goenv": + if logger != nil { + logger.Info("daemon: standing memory limit deferred to GOMEMLIMIT") + } + return + case "off": + if logger != nil { + logger.Info("daemon: standing memory limit disabled by configuration") + } + return + case "unavailable": + if logger != nil { + logger.Debug("daemon: standing memory limit skipped — host RAM unknown") + } + return + } + debug.SetMemoryLimit(d.limit) + if logger != nil { + logger.Info("daemon: standing memory limit applied", + zap.Int64("bytes", d.limit), + zap.String("source", d.source)) + } +} + +// memReleaseEnabled reports whether post-burst heap release is active. On by +// default; GORTEX_DAEMON_MEMRELEASE=0 (or "false") disables it. +func memReleaseEnabled() bool { + v := os.Getenv("GORTEX_DAEMON_MEMRELEASE") + return v != "0" && !strings.EqualFold(v, "false") +} + +// releaseMemoryToOS forces a GC + scavenge (runtime/debug.FreeOSMemory) so a +// just-completed allocation burst's high-water heap is returned to the OS +// promptly instead of pinning the process footprint at its peak. +// +// It is called only at burst boundaries (warmup end, the reconcile janitor +// after a tick that did work), never on a timer: FreeOSMemory runs a full, +// largely stop-the-world GC cycle costing ~0.1–2 s on a multi-GB heap, so +// paying it once per burst is fine while paying it periodically would +// reintroduce exactly the steady-state GC cost the standing limit is tuned +// to avoid. HeapReleased is monotonic across the forced scavenge, so the +// logged delta is the bytes this call handed back. +// +// GORTEX_DAEMON_MEMRELEASE=0 (or "false") turns it into a no-op. +func releaseMemoryToOS(logger *zap.Logger, reason string) { + if !memReleaseEnabled() { + return + } + var before, after runtime.MemStats + runtime.ReadMemStats(&before) + start := time.Now() + debug.FreeOSMemory() + elapsed := time.Since(start) + runtime.ReadMemStats(&after) + // Concurrent allocation between the two reads can re-acquire released + // pages faster than this call released them, making the raw delta + // negative; report that as zero net release rather than a nonsense + // negative byte count. + freed := int64(after.HeapReleased) - int64(before.HeapReleased) + if freed < 0 { + freed = 0 + } + if logger != nil { + logger.Info("daemon: released heap to OS", + zap.String("reason", reason), + zap.Duration("elapsed", elapsed), + zap.Int64("freed_bytes", freed), + zap.Uint64("heap_sys_bytes", after.HeapSys), + zap.Uint64("heap_released_bytes", after.HeapReleased)) + } +} diff --git a/cmd/gortex/daemon_memlimit_test.go b/cmd/gortex/daemon_memlimit_test.go new file mode 100644 index 0000000..d4c74b0 --- /dev/null +++ b/cmd/gortex/daemon_memlimit_test.go @@ -0,0 +1,178 @@ +package main + +import ( + "runtime" + "testing" + + "go.uber.org/zap" +) + +const ( + kib = int64(1) << 10 + mib = int64(1) << 20 + gib = int64(1) << 30 + tib = int64(1) << 40 +) + +func TestParseByteSize(t *testing.T) { + tests := []struct { + in string + want int64 + wantErr bool + }{ + {"", 0, false}, + {"0", 0, false}, + {"off", 0, false}, + {"OFF", 0, false}, + {" off ", 0, false}, + {"1024", 1024, false}, // bare number = bytes + {"1B", 1, false}, + {"1KiB", kib, false}, + {"1kb", kib, false}, // KB treated as binary (memory budget) + {"2K", 2 * kib, false}, + {"4096MiB", 4096 * mib, false}, // == 4 GiB + {"4GiB", 4 * gib, false}, + {"2G", 2 * gib, false}, + {"2gb", 2 * gib, false}, + {"1T", tib, false}, + {"1TiB", tib, false}, + {"nonsense", 0, true}, + {"12x", 0, true}, // unknown unit + {"GiB", 0, true}, // no number + {"-5GiB", 0, true}, // negative + {"99999999999999999999G", 0, true}, // overflow + } + for _, tc := range tests { + got, err := parseByteSize(tc.in) + if tc.wantErr { + if err == nil { + t.Errorf("parseByteSize(%q): expected error, got %d", tc.in, got) + } + continue + } + if err != nil { + t.Errorf("parseByteSize(%q): unexpected error %v", tc.in, err) + continue + } + if got != tc.want { + t.Errorf("parseByteSize(%q) = %d, want %d", tc.in, got, tc.want) + } + } +} + +func TestDefaultStandingMemoryLimit(t *testing.T) { + tests := []struct { + name string + hostRAM uint64 + want int64 + }{ + {"unknown host RAM => 0", 0, 0}, + {"tiny host clamps up to floor", uint64(2) * uint64(gib), standingMemLimitFloor}, // 2GiB/4=512MiB -> 1GiB + {"mid host uses quarter", uint64(16) * uint64(gib), 4 * gib}, // 16GiB/4=4GiB + {"large host clamps down to ceil", uint64(64) * uint64(gib), standingMemLimitCeil}, // 64GiB/4=16GiB -> 8GiB + {"exactly floor boundary", uint64(4) * uint64(gib), gib}, // 4GiB/4=1GiB + {"exactly ceil boundary", uint64(32) * uint64(gib), 8 * gib}, // 32GiB/4=8GiB + } + for _, tc := range tests { + if got := defaultStandingMemoryLimit(tc.hostRAM); got != tc.want { + t.Errorf("%s: defaultStandingMemoryLimit(%d) = %d, want %d", tc.name, tc.hostRAM, got, tc.want) + } + } +} + +func TestResolveStandingMemoryLimit(t *testing.T) { + const host = uint64(16) * uint64(1<<30) // 16 GiB -> default 4 GiB + tests := []struct { + name string + hostRAM uint64 + goenv string + env string + cfg string + wantLimit int64 + wantSource string + wantWarn bool + }{ + {"GOMEMLIMIT wins, install nothing", host, "5GiB", "4GiB", "2GiB", 0, "goenv", false}, + {"GOMEMLIMIT wins even when malformed-looking", host, "someval", "4GiB", "", 0, "goenv", false}, + {"env value honored verbatim", host, "", "6GiB", "2GiB", 6 * gib, "env", false}, + {"env off disables", host, "", "off", "2GiB", 0, "off", false}, + {"env zero disables", host, "", "0", "2GiB", 0, "off", false}, + {"config used when env empty", host, "", "", "3GiB", 3 * gib, "config", false}, + {"config off disables", host, "", "", "off", 0, "off", false}, + {"default when nothing set", host, "", "", "", 4 * gib, "default", false}, + {"malformed env falls back to default with warning", host, "", "bogus", "2GiB", 4 * gib, "default", true}, + {"malformed config falls back to default with warning", host, "", "", "bogus", 4 * gib, "default", true}, + {"default clamps up on tiny host", uint64(2) * uint64(gib), "", "", "", standingMemLimitFloor, "default", false}, + {"default clamps down on large host", uint64(128) * uint64(gib), "", "", "", standingMemLimitCeil, "default", false}, + {"unknown host and no explicit value", 0, "", "", "", 0, "unavailable", false}, + } + for _, tc := range tests { + got := resolveStandingMemoryLimit(tc.hostRAM, tc.goenv, tc.env, tc.cfg) + if got.limit != tc.wantLimit || got.source != tc.wantSource { + t.Errorf("%s: resolve(...) = {limit:%d, source:%q}, want {limit:%d, source:%q}", + tc.name, got.limit, got.source, tc.wantLimit, tc.wantSource) + } + if (got.warn != "") != tc.wantWarn { + t.Errorf("%s: warn=%q, wantWarn=%v", tc.name, got.warn, tc.wantWarn) + } + } +} + +func TestMemReleaseEnabled(t *testing.T) { + tests := []struct { + val string + want bool + }{ + {"", true}, // unset => on by default + {"1", true}, + {"true", true}, + {"anything", true}, + {"0", false}, // kill-switch + {"false", false}, + {"FALSE", false}, + } + for _, tc := range tests { + t.Setenv("GORTEX_DAEMON_MEMRELEASE", tc.val) + if got := memReleaseEnabled(); got != tc.want { + t.Errorf("memReleaseEnabled() with %q = %v, want %v", tc.val, got, tc.want) + } + } +} + +func TestReleaseMemoryToOS_KillSwitch(t *testing.T) { + // With the kill-switch set, the helper must be a no-op: it must not + // panic and must return without forcing a collection. We can only + // observe "no crash / returns" here; the enablement predicate is + // covered exhaustively by TestMemReleaseEnabled. + t.Setenv("GORTEX_DAEMON_MEMRELEASE", "0") + releaseMemoryToOS(zap.NewNop(), "kill-switch-test") +} + +func TestReleaseMemoryToOS_Smoke(t *testing.T) { + t.Setenv("GORTEX_DAEMON_MEMRELEASE", "1") + + // Allocate then drop a chunk so there is heap for the forced scavenge + // to return, then confirm HeapReleased did not go backwards across the + // call. Exact byte counts are intentionally not asserted. + sink := make([]byte, 8<<20) + for i := range sink { + sink[i] = byte(i) + } + // Force the writes to land (so the allocation is not elided), then let the + // reference drop here: sink is dead past this point, so the scavenge below + // has heap to return. + runtime.KeepAlive(sink) + + var before runtime.MemStats + runtime.ReadMemStats(&before) + + releaseMemoryToOS(zap.NewNop(), "smoke-test") + + var after runtime.MemStats + runtime.ReadMemStats(&after) + + if after.HeapReleased < before.HeapReleased { + t.Fatalf("HeapReleased went backwards after FreeOSMemory: before=%d after=%d", + before.HeapReleased, after.HeapReleased) + } +} diff --git a/cmd/gortex/daemon_pprof.go b/cmd/gortex/daemon_pprof.go new file mode 100644 index 0000000..52832f5 --- /dev/null +++ b/cmd/gortex/daemon_pprof.go @@ -0,0 +1,59 @@ +package main + +import ( + "net" + "net/http" + _ "net/http/pprof" // registers /debug/pprof/* on http.DefaultServeMux + "os" + "sync/atomic" + + "go.uber.org/zap" +) + +// pprofAddr holds the bound address of the daemon's pprof listener +// (empty when pprof is disabled). Read by controller.Status so the +// active address is reported to clients; written exactly once from +// startPProfIfEnabled. atomic.Value guards against the data race that +// would otherwise exist between Status calls and startup. +var pprofAddr atomic.Value + +func init() { + pprofAddr.Store("") +} + +// daemonPProfAddr returns the currently-bound pprof listener address, +// or an empty string when no listener is running. +func daemonPProfAddr() string { + v, _ := pprofAddr.Load().(string) + return v +} + +// startPProfIfEnabled opens an HTTP pprof listener when the user has +// set GORTEX_DAEMON_PPROF_ADDR (e.g. "127.0.0.1:6060"). Opt-in by +// design — leaving a pprof endpoint on by default would expose the +// daemon's internal state to any process on the machine. The listener +// runs in its own goroutine; failures are logged but don't block +// daemon startup. +func startPProfIfEnabled(logger *zap.Logger) { + addr := os.Getenv("GORTEX_DAEMON_PPROF_ADDR") + if addr == "" { + return + } + ln, err := net.Listen("tcp", addr) + if err != nil { + logger.Warn("daemon: pprof listener failed", + zap.String("addr", addr), zap.Error(err)) + return + } + bound := ln.Addr().String() + pprofAddr.Store(bound) + logger.Info("daemon: pprof endpoint open", + zap.String("addr", bound), + zap.String("hint", "go tool pprof -http=: http://"+bound+"/debug/pprof/heap")) + go func() { + // Serve on the DefaultServeMux which net/http/pprof registers on. + if err := http.Serve(ln, nil); err != nil && err != http.ErrServerClosed { + logger.Warn("daemon: pprof serve exited", zap.Error(err)) + } + }() +} diff --git a/cmd/gortex/daemon_rebuild_test.go b/cmd/gortex/daemon_rebuild_test.go new file mode 100644 index 0000000..990b0b8 --- /dev/null +++ b/cmd/gortex/daemon_rebuild_test.go @@ -0,0 +1,33 @@ +package main + +import "testing" + +type fakeRebuildYes struct{} + +func (fakeRebuildYes) NeedsRebuild() bool { return true } + +type fakeRebuildNo struct{} + +func (fakeRebuildNo) NeedsRebuild() bool { return false } + +// storeNeedsRebuild must detect the optional NeedsRebuild capability and +// default to false for backends that don't implement it (the in-memory +// store), so the warm-restart fast path is bypassed only on an explicit +// rebuild signal. +func TestStoreNeedsRebuild(t *testing.T) { + cases := []struct { + name string + g any + want bool + }{ + {"implements true", fakeRebuildYes{}, true}, + {"implements false", fakeRebuildNo{}, false}, + {"no capability", struct{}{}, false}, + {"nil", nil, false}, + } + for _, c := range cases { + if got := storeNeedsRebuild(c.g); got != c.want { + t.Errorf("%s: storeNeedsRebuild = %v, want %v", c.name, got, c.want) + } + } +} diff --git a/cmd/gortex/daemon_resilience_test.go b/cmd/gortex/daemon_resilience_test.go new file mode 100644 index 0000000..996f22c --- /dev/null +++ b/cmd/gortex/daemon_resilience_test.go @@ -0,0 +1,204 @@ +package main + +import ( + "io" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/zzet/gortex/internal/config" + "github.com/zzet/gortex/internal/graph" +) + +func fakeRepoEntry(path string) config.RepoEntry { + return config.RepoEntry{Path: path} +} + +// TestSnapshot_RestartStability is the B1 regression guard at the +// daemon-snapshot layer: N save/load cycles on top of the existing +// graph must not grow node or edge counts. Before Phase 2, re-running +// a snapshot load on top of a warm graph doubled edges every cycle +// because AddNode/AddEdge appended blindly. +func TestSnapshot_RestartStability(t *testing.T) { + dir := t.TempDir() + t.Setenv("GORTEX_DAEMON_SNAPSHOT", filepath.Join(dir, "snap.gob.gz")) + + orig := graph.New() + orig.AddNode(&graph.Node{ID: "a.go::Foo", Name: "Foo", Kind: graph.KindFunction, FilePath: "a.go"}) + orig.AddNode(&graph.Node{ID: "a.go::Bar", Name: "Bar", Kind: graph.KindFunction, FilePath: "a.go"}) + orig.AddEdge(&graph.Edge{ + From: "a.go::Foo", To: "a.go::Bar", + Kind: graph.EdgeCalls, FilePath: "a.go", Line: 5, + }) + want := orig.Stats() + + // Save once; then repeatedly load into the same graph. Each + // load re-applies AddNode / AddEdge for every record. With + // idempotent writes, stats must not drift. + saveSnapshot(orig, nil, nil, snapshotVector{}, version, zap.NewNop()) + + for cycle := 0; cycle < 5; cycle++ { + result, err := loadSnapshot(orig, zap.NewNop()) + require.NoError(t, err, "cycle %d", cycle) + require.True(t, result.Loaded, "cycle %d", cycle) + got := orig.Stats() + assert.Equal(t, want.TotalNodes, got.TotalNodes, + "cycle %d: nodes drifted", cycle) + assert.Equal(t, want.TotalEdges, got.TotalEdges, + "cycle %d: edges drifted (B1 regression)", cycle) + } +} + +// TestSnapshot_RoundTripsRepos verifies the new per-repo FileMtimes +// section survives encode → decode. Without this, IncrementalReindex on +// warmup treats every file as stale (empty mtime map → IsStale=true for +// all), producing the same duplicate-writes problem we fixed. +func TestSnapshot_RoundTripsRepos(t *testing.T) { + dir := t.TempDir() + t.Setenv("GORTEX_DAEMON_SNAPSHOT", filepath.Join(dir, "snap.gob.gz")) + + g := graph.New() + g.AddNode(&graph.Node{ID: "repo/a.go::X", Name: "X", Kind: graph.KindFunction, FilePath: "repo/a.go", RepoPrefix: "repo"}) + + repos := []snapshotRepo{{ + RepoPrefix: "repo", + RootPath: "/abs/path/to/repo", + FileMtimes: map[string]int64{ + "a.go": 1000, + "b/c.go": 2000, + "x/y/z.go": 3000, + }, + }} + + saveSnapshot(g, repos, nil, snapshotVector{}, version, zap.NewNop()) + + restored := graph.New() + result, err := loadSnapshot(restored, zap.NewNop()) + require.NoError(t, err) + require.True(t, result.Loaded) + require.NotNil(t, result.Repos, "repo section must round-trip") + + r, ok := result.Repos["repo"] + require.True(t, ok, "repo prefix must be keyed correctly") + assert.Equal(t, "/abs/path/to/repo", r.RootPath) + assert.Equal(t, int64(1000), r.FileMtimes["a.go"]) + assert.Equal(t, int64(2000), r.FileMtimes["b/c.go"]) + assert.Equal(t, int64(3000), r.FileMtimes["x/y/z.go"]) +} + +// TestSnapshot_BackwardCompat_OldSnapshotLoads_NoRepos verifies that a +// snapshot written without repo metadata (the v2 layout, pre-Phase 1) +// loads cleanly on the new daemon — the RepoCount field decodes as +// zero, the repo section is empty, and Repos is nil. The new daemon +// falls back to TrackRepoCtx, which does a full re-index. +func TestSnapshot_BackwardCompat_OldSnapshotLoads_NoRepos(t *testing.T) { + dir := t.TempDir() + t.Setenv("GORTEX_DAEMON_SNAPSHOT", filepath.Join(dir, "snap.gob.gz")) + + g := graph.New() + g.AddNode(&graph.Node{ID: "a.go::Foo", Name: "Foo", Kind: graph.KindFunction, FilePath: "a.go"}) + + // No repos passed — simulates a snapshot from before the + // RepoCount field existed. (The on-disk bytes are the same + // either way: saveSnapshot writes zero repo records when the + // slice is empty, matching what older daemons produced.) + saveSnapshot(g, nil, nil, snapshotVector{}, version, zap.NewNop()) + + restored := graph.New() + result, err := loadSnapshot(restored, zap.NewNop()) + require.NoError(t, err) + assert.True(t, result.Loaded) + assert.False(t, result.Partial) + assert.Empty(t, result.Repos, "empty repos must surface as nil/empty map, not an error") + assert.Equal(t, 1, restored.NodeCount()) +} + +// TestSnapshot_DanglingEdgesDropped verifies the structural-validation +// pass: edges whose endpoints aren't in the loaded node set are +// dropped, not stored. A corrupt or truncated snapshot that landed +// with dangling refs used to surface later as "edge pointing at nil +// node" panics in traversal code; the loader's contract is to drop +// them at load time. +func TestSnapshot_DanglingEdgesDropped(t *testing.T) { + dir := t.TempDir() + t.Setenv("GORTEX_DAEMON_SNAPSHOT", filepath.Join(dir, "snap.gob.gz")) + + g := graph.New() + g.AddNode(&graph.Node{ID: "a.go::A", Name: "A", Kind: graph.KindFunction, FilePath: "a.go"}) + // B is deliberately NOT added — this edge will be dangling on load. + // AddEdge still stores it (the graph doesn't validate at insert + // time), but loadSnapshot should reject it during the structural + // validation pass after nodes are in place. + g.AddEdge(&graph.Edge{ + From: "a.go::A", To: "b.go::B", + Kind: graph.EdgeCalls, FilePath: "a.go", Line: 1, + }) + + saveSnapshot(g, nil, nil, snapshotVector{}, version, zap.NewNop()) + + restored := graph.New() + result, err := loadSnapshot(restored, zap.NewNop()) + require.NoError(t, err) + require.True(t, result.Loaded) + assert.Equal(t, 1, restored.NodeCount(), "only the valid node should land") + assert.Equal(t, 0, restored.EdgeCount(), + "dangling edge must be dropped during load validation") +} + +// TestCanMigrate covers the migration-chain guard that gates between +// "attempt to migrate" and "discard cache on schema mismatch" paths. +// The registry ships empty (Phase 4 is scaffolding) so every answer +// today is `false`, but the shape of the lookup matters — a future +// PR that lands a real migration needs this to return `true` only +// when the chain is complete. +func TestCanMigrate(t *testing.T) { + // from >= to → no migration needed. + assert.False(t, canMigrate(3, 3)) + assert.False(t, canMigrate(5, 2)) + + // Empty registry — no migrations are available. + assert.False(t, canMigrate(1, 2)) + + // Install a fake v1→v2 migration. A complete chain reports true; + // a missing link (v2→v3 never registered) reports false. + orig := snapshotMigrations + t.Cleanup(func() { snapshotMigrations = orig }) + snapshotMigrations = map[int]snapshotMigration{ + 1: func(in io.Reader, out io.Writer) error { return nil }, + } + assert.True(t, canMigrate(1, 2)) + assert.False(t, canMigrate(1, 3), "missing v2→v3 link breaks the chain") +} + +// TestPriorMtimesForEntry_MatchesByAbsRootPath is the core of the +// warmup decision: given a snapshot's per-repo metadata and a config +// entry, priorMtimesForEntry must resolve the right FileMtimes for +// incremental reconciliation. A miss here causes warmup to fall back +// to full re-indexing — not a correctness bug, but the whole point of +// Phase 1 is to avoid the full pass on routine restart. +func TestPriorMtimesForEntry_MatchesByAbsRootPath(t *testing.T) { + absPath := filepath.Join(t.TempDir(), "repo") + + repos := map[string]*snapshotRepo{ + "repo": { + RepoPrefix: "repo", + RootPath: absPath, + FileMtimes: map[string]int64{"a.go": 42}, + }, + } + + // Exact absolute-path match. + entry := fakeRepoEntry(absPath) + got := priorMtimesForEntry(repos, entry) + assert.Equal(t, int64(42), got["a.go"]) + + // No match → nil. + miss := fakeRepoEntry(filepath.Join(t.TempDir(), "nope")) + assert.Nil(t, priorMtimesForEntry(repos, miss)) + + // Empty repos map is safe — no panic, returns nil. + assert.Nil(t, priorMtimesForEntry(nil, entry)) +} diff --git a/cmd/gortex/daemon_search_backend_test.go b/cmd/gortex/daemon_search_backend_test.go new file mode 100644 index 0000000..e4a2fa5 --- /dev/null +++ b/cmd/gortex/daemon_search_backend_test.go @@ -0,0 +1,61 @@ +package main + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/zzet/gortex/internal/daemon" + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/search" +) + +// fakeSymbolSearcher is a minimal graph.SymbolSearcher stand-in so +// resolveSearchBackend's SymbolSearcherBackend branch can be exercised +// without a real sqlite store. +type fakeSymbolSearcher struct{} + +func (fakeSymbolSearcher) UpsertSymbolFTS(string, string) error { return nil } +func (fakeSymbolSearcher) BulkUpsertSymbolFTS(string, []graph.SymbolFTSItem) error { return nil } +func (fakeSymbolSearcher) BuildSymbolIndex() error { return nil } +func (fakeSymbolSearcher) SearchSymbols(string, int) ([]graph.SymbolHit, error) { return nil, nil } + +func TestResolveSearchBackend_SymbolSearcherBackend(t *testing.T) { + b := search.NewSymbolSearcherBackend(fakeSymbolSearcher{}) + b.Add("node-1") + b.Add("node-2") + + info := resolveSearchBackend(b) + + assert.Equal(t, "sqlite-fts5", info.Name) + assert.Equal(t, 2, info.DocCount, "DocCount should come from the adapter's Count()") + assert.True(t, info.DiskResident, "the FTS5 index lives inside the graph store, not in-process heap") + assert.Zero(t, info.Bytes, "no fabricated byte count for a disk-resident backend") +} + +func TestResolveSearchBackend_SymbolSearcherBackend_ThroughSwappable(t *testing.T) { + b := search.NewSymbolSearcherBackend(fakeSymbolSearcher{}) + sw := search.NewSwappable(b) + + info := resolveSearchBackend(sw) + + assert.Equal(t, "sqlite-fts5", info.Name) + assert.True(t, info.DiskResident) +} + +func TestRenderDaemonHeader_SearchBackendRow_SymbolSearcher(t *testing.T) { + st := sampleStatus() + st.SearchBackend = daemon.SearchBackendStats{ + Name: "sqlite-fts5", + DocCount: 48572, + DiskResident: true, + } + var buf bytes.Buffer + renderDaemonHeader(&buf, st) + out := buf.String() + assert.Contains(t, out, "sqlite-fts5") + assert.Contains(t, out, "48572") + assert.Contains(t, out, "disk-resident") + assert.NotContains(t, out, "heap=0 B", "must not print a fabricated zero heap size") +} diff --git a/cmd/gortex/daemon_servers.go b/cmd/gortex/daemon_servers.go new file mode 100644 index 0000000..972059e --- /dev/null +++ b/cmd/gortex/daemon_servers.go @@ -0,0 +1,175 @@ +package main + +import ( + "fmt" + "os" + "strings" + + "github.com/jedib0t/go-pretty/v6/table" + "github.com/jedib0t/go-pretty/v6/text" + "github.com/spf13/cobra" + + "github.com/zzet/gortex/internal/daemon" +) + +var ( + daemonServerAddURL string + daemonServerAddDefault bool + daemonServerAddAuthToken string + daemonServerAddAuthTokenEnv string + daemonServerAddWorkspaces []string + daemonServerAddReadOnly bool +) + +var daemonServerCmd = &cobra.Command{ + Use: "server", + Short: "Manage the multi-server roster in ~/.gortex/servers.toml", + Long: `Maintain ~/.gortex/servers.toml — the multi-server roster the daemon +and the gortex server load on startup to wire up local-fast-path vs +proxy routing. + +The file holds one [[server]] block per reachable Gortex server: a +local socket, a remote HTTPS endpoint, etc. Adding or removing an entry +is applied to a running daemon live (no restart); if the daemon is down +the change simply takes effect on its next start.`, +} + +var daemonServerAddCmd = &cobra.Command{ + Use: "add ", + Short: "Add a server entry to ~/.gortex/servers.toml", + Long: `Append a new server to the roster. Slug must be unique. URL accepts +http://, https://, or unix:///path/to.sock. Auth: pass --auth-token-env +(preferred) to point at an environment variable, or --auth-token to +write the literal value into the file. + +If --default is set and another entry already has default=true, the +command fails — remove or unflag the existing default first.`, + Args: cobra.ExactArgs(1), + RunE: runDaemonServerAdd, + SilenceUsage: true, +} + +var daemonServerRemoveCmd = &cobra.Command{ + Use: "remove ", + Aliases: []string{"rm"}, + Short: "Remove a server entry from ~/.gortex/servers.toml", + Args: cobra.ExactArgs(1), + RunE: runDaemonServerRemove, + SilenceUsage: true, +} + +var daemonServerListCmd = &cobra.Command{ + Use: "list", + Short: "Print the current ~/.gortex/servers.toml roster", + RunE: runDaemonServerList, + SilenceUsage: true, +} + +func init() { + daemonServerAddCmd.Flags().StringVar(&daemonServerAddURL, "url", "", "server URL (http://, https://, or unix:///path) — required") + daemonServerAddCmd.Flags().BoolVar(&daemonServerAddDefault, "default", false, "mark this entry as the default server") + daemonServerAddCmd.Flags().StringVar(&daemonServerAddAuthToken, "auth-token", "", "literal bearer token (consider --auth-token-env instead)") + daemonServerAddCmd.Flags().StringVar(&daemonServerAddAuthTokenEnv, "auth-token-env", "", "env-var name the daemon reads at request time") + daemonServerAddCmd.Flags().StringSliceVar(&daemonServerAddWorkspaces, "workspaces", nil, "pre-declared workspace slugs (comma-separated)") + daemonServerAddCmd.Flags().BoolVar(&daemonServerAddReadOnly, "read-only", false, "mark this remote read-only (no write tools routed to it)") + _ = daemonServerAddCmd.MarkFlagRequired("url") + + daemonServerCmd.AddCommand(daemonServerAddCmd) + daemonServerCmd.AddCommand(daemonServerRemoveCmd) + daemonServerCmd.AddCommand(daemonServerListCmd) + daemonCmd.AddCommand(daemonServerCmd) +} + +func runDaemonServerAdd(_ *cobra.Command, args []string) error { + slug := args[0] + cfg, err := daemon.LoadServersConfig("") + if err != nil { + return err + } + if cfg == nil { + cfg = &daemon.ServersConfig{} + } + entry := daemon.ServerEntry{ + Slug: slug, + URL: daemonServerAddURL, + AuthToken: daemonServerAddAuthToken, + AuthTokenEnv: daemonServerAddAuthTokenEnv, + Workspaces: daemonServerAddWorkspaces, + Default: daemonServerAddDefault, + ReadOnly: daemonServerAddReadOnly, + } + if err := cfg.AddServer(entry); err != nil { + return err + } + if err := cfg.Save(""); err != nil { + return err + } + fmt.Fprintf(os.Stderr, "[gortex] added server %q (%s) to %s\n", slug, daemonServerAddURL, daemon.ServersConfigPath()) + proxyApplyToRunningDaemon() + return nil +} + +func runDaemonServerRemove(_ *cobra.Command, args []string) error { + slug := args[0] + cfg, err := daemon.LoadServersConfig("") + if err != nil { + return err + } + if cfg == nil { + cfg = &daemon.ServersConfig{} + } + removed, err := cfg.RemoveServer(slug) + if err != nil { + return err + } + if !removed { + fmt.Fprintf(os.Stderr, "[gortex] no server with slug %q in %s — nothing to remove\n", slug, daemon.ServersConfigPath()) + return nil + } + if err := cfg.Save(""); err != nil { + return err + } + fmt.Fprintf(os.Stderr, "[gortex] removed server %q from %s\n", slug, daemon.ServersConfigPath()) + proxyApplyToRunningDaemon() + return nil +} + +func runDaemonServerList(_ *cobra.Command, _ []string) error { + cfg, err := daemon.LoadServersConfig("") + if err != nil { + return err + } + if cfg == nil || len(cfg.Server) == 0 { + fmt.Fprintf(os.Stderr, "[gortex] no servers configured (%s is empty or missing)\n", daemon.ServersConfigPath()) + return nil + } + t := table.NewWriter() + t.SetOutputMirror(os.Stdout) + t.SetStyle(table.StyleLight) + t.Style().Format.Header = text.FormatDefault + t.AppendHeader(table.Row{"slug", "url", "default", "auth", "workspaces"}) + yesno := func(b bool) string { + if b { + return "yes" + } + return "" + } + for _, s := range cfg.Server { + auth := "" + switch { + case s.AuthTokenEnv != "": + auth = "env:" + s.AuthTokenEnv + case s.AuthToken != "": + auth = "literal" + } + t.AppendRow(table.Row{ + s.Slug, + s.URL, + yesno(s.Default), + auth, + strings.Join(s.Workspaces, ", "), + }) + } + t.Render() + return nil +} diff --git a/cmd/gortex/daemon_servers_readonly_test.go b/cmd/gortex/daemon_servers_readonly_test.go new file mode 100644 index 0000000..a6ccd00 --- /dev/null +++ b/cmd/gortex/daemon_servers_readonly_test.go @@ -0,0 +1,51 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/zzet/gortex/internal/daemon" +) + +// TestServerAdd_ReadOnlyPersisted asserts `daemon server add --read-only` +// writes read_only = true into the roster entry and that it reloads as a +// read-only remote. +func TestServerAdd_ReadOnlyPersisted(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "servers.toml") + t.Setenv("GORTEX_DAEMON_SERVERS", path) + + // Drive the command's package-level flag vars directly. + daemonServerAddURL = "https://r2.example:4747" + daemonServerAddReadOnly = true + daemonServerAddDefault = false + daemonServerAddAuthToken = "" + daemonServerAddAuthTokenEnv = "" + daemonServerAddWorkspaces = nil + t.Cleanup(func() { + daemonServerAddURL = "" + daemonServerAddReadOnly = false + }) + + if err := runDaemonServerAdd(nil, []string{"r2"}); err != nil { + t.Fatalf("runDaemonServerAdd: %v", err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), "read_only = true") { + t.Fatalf("roster should carry read_only = true, got:\n%s", data) + } + + cfg, err := daemon.LoadServersConfig(path) + if err != nil { + t.Fatal(err) + } + if len(cfg.Server) != 1 || !cfg.Server[0].ReadOnly { + t.Fatalf("reloaded entry should be read-only, got %+v", cfg.Server) + } +} diff --git a/cmd/gortex/daemon_servers_reload_test.go b/cmd/gortex/daemon_servers_reload_test.go new file mode 100644 index 0000000..99cb986 --- /dev/null +++ b/cmd/gortex/daemon_servers_reload_test.go @@ -0,0 +1,58 @@ +package main + +import ( + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/zzet/gortex/internal/daemon" +) + +// TestServerAddRemove_NoStaleRestartNotice asserts roster add/remove route +// through the live-reload path (proxyApplyToRunningDaemon) and no longer +// print the misleading "run gortex daemon restart" notice. The socket is +// pointed at a dead path so IsRunning() is false and no real daemon is +// dialed — the add/remove must still succeed and stay silent about restarts. +func TestServerAddRemove_NoStaleRestartNotice(t *testing.T) { + dir := t.TempDir() + t.Setenv("GORTEX_DAEMON_SERVERS", filepath.Join(dir, "servers.toml")) + t.Setenv("GORTEX_DAEMON_SOCKET", filepath.Join(dir, "dead.sock")) + + daemonServerAddURL = "https://r2.example:4747" + t.Cleanup(func() { daemonServerAddURL = "" }) + + capture := func(fn func() error) (string, error) { + old := os.Stderr + r, w, _ := os.Pipe() + os.Stderr = w + err := fn() + _ = w.Close() + os.Stderr = old + out, _ := io.ReadAll(r) + return string(out), err + } + + addOut, err := capture(func() error { return runDaemonServerAdd(nil, []string{"r2"}) }) + if err != nil { + t.Fatalf("runDaemonServerAdd: %v", err) + } + if strings.Contains(addOut, "daemon restart") { + t.Errorf("add must not print the stale restart notice; got:\n%s", addOut) + } + + // The entry actually persisted. + cfg, err := daemon.LoadServersConfig(filepath.Join(dir, "servers.toml")) + if err != nil || cfg.FindBySlug("r2") == nil { + t.Fatalf("server r2 should have persisted; cfg=%+v err=%v", cfg, err) + } + + rmOut, err := capture(func() error { return runDaemonServerRemove(nil, []string{"r2"}) }) + if err != nil { + t.Fatalf("runDaemonServerRemove: %v", err) + } + if strings.Contains(rmOut, "daemon restart") { + t.Errorf("remove must not print the stale restart notice; got:\n%s", rmOut) + } +} diff --git a/cmd/gortex/daemon_service.go b/cmd/gortex/daemon_service.go new file mode 100644 index 0000000..c29c6d1 --- /dev/null +++ b/cmd/gortex/daemon_service.go @@ -0,0 +1,469 @@ +package main + +import ( + "bytes" + "encoding/xml" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "text/template" + + "github.com/spf13/cobra" + + "github.com/zzet/gortex/internal/daemon" +) + +// Service management — launchd on macOS, systemd --user on Linux. +// +// The service wraps `gortex daemon start` (foreground, without --detach) +// so the OS supervisor owns lifecycle. Users who prefer to manage the +// daemon manually keep using `gortex daemon start --detach`; the +// service integration is strictly opt-in via `install-service`. +// +// No root privileges. On both platforms the unit lives under the +// user's home so it starts with the user session and terminates on +// logout. System-wide installation (requires sudo) is a follow-up for +// shared-workstation deployments; not wired here. + +var ( + daemonServiceName = "com.zzet.gortex" // launchd label + systemd unit base name +) + +var daemonInstallServiceCmd = &cobra.Command{ + Use: "install-service", + Short: "Install a user-level launchd/systemd unit that keeps the daemon running", + Long: `Writes a user-level service unit for the host OS (launchd on macOS, +systemd --user on Linux) that starts the daemon at login and restarts +it on crash. The service wraps 'gortex daemon start' in foreground mode +so the OS supervisor owns lifecycle; no --detach involved. + +No root/sudo required — unit lives under your home directory.`, + RunE: runDaemonInstallService, +} + +var daemonUninstallServiceCmd = &cobra.Command{ + Use: "uninstall-service", + Short: "Remove the launchd/systemd unit and stop the daemon", + RunE: runDaemonUninstallService, +} + +var daemonServiceStatusCmd = &cobra.Command{ + Use: "service-status", + Short: "Show whether the launchd/systemd unit is installed and active", + RunE: runDaemonServiceStatus, +} + +func init() { + daemonCmd.AddCommand(daemonInstallServiceCmd) + daemonCmd.AddCommand(daemonUninstallServiceCmd) + daemonCmd.AddCommand(daemonServiceStatusCmd) +} + +// serviceEnvVar is a single environment entry rendered into a service +// unit. Render helpers escape Value for the target format. +type serviceEnvVar struct{ Key, Value string } + +// xdgServiceEnv captures the XDG base-directory overrides in effect when +// the service unit is written, so the supervised daemon resolves the +// same config / data / cache locations as the shell that installed it. +// +// launchd and systemd --user start the daemon with a near-empty +// environment — they do NOT inherit the XDG_* variables a user exports +// from their shell or session manager. Without this capture the daemon +// falls back to ~/.gortex even though the user opted into an XDG layout +// (see internal/platform/xdg.go), silently splitting their state across +// two trees. Re-run install-service to re-capture changed values. +// +// Only absolute values are propagated — the rule platform.unifiedDir +// itself applies when honouring an override (a relative XDG path is +// ignored per the XDG Base Directory spec). XDG_RUNTIME_DIR is +// deliberately excluded: the init system sets it per session, so pinning +// an install-time value would point the daemon socket at a stale dir. +func xdgServiceEnv() []serviceEnvVar { + var out []serviceEnvVar + for _, name := range []string{"XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME"} { + if v := os.Getenv(name); v != "" && filepath.IsAbs(v) { + out = append(out, serviceEnvVar{Key: name, Value: v}) + } + } + return out +} + +// xmlEscape renders s safe for an XML text node (the launchd plist) so a +// home path containing an XML metacharacter can't produce a malformed, +// unloadable plist. +func xmlEscape(s string) string { + var b strings.Builder + if err := xml.EscapeText(&b, []byte(s)); err != nil { + return s + } + return b.String() +} + +// systemdEnvValue renders a value safe for a systemd Environment= line. +// `%` is escaped to `%%` because systemd treats it as a specifier +// introducer across the whole unit file (systemd.unit(5)) — an +// unescaped `%d` in a path would expand to a directory specifier and +// silently change the value the daemon sees. Values containing +// whitespace are additionally double-quoted (with embedded quotes / +// backslashes escaped) per systemd's quoting rules. Plain paths (the +// common case) pass through unchanged. +func systemdEnvValue(v string) string { + v = strings.ReplaceAll(v, "%", "%%") + if !strings.ContainsAny(v, " \t") { + return v + } + r := strings.NewReplacer(`\`, `\\`, `"`, `\"`) + return `"` + r.Replace(v) + `"` +} + +// runDaemonInstallService writes the unit file and enables + starts it. +// Existing daemon processes are stopped first so the service owns the +// only running instance after this returns. +func runDaemonInstallService(cmd *cobra.Command, _ []string) error { + w := cmd.ErrOrStderr() + + // Stop any manual daemon that's currently running so the service + // doesn't fight with it over the socket. + if daemon.IsRunning() { + fmt.Fprintln(w, "[gortex daemon] stopping existing daemon before install") + // This is an internal lifecycle bounce — the supervisor starts the + // daemon right after — not a user "stay down". Suppress the stop-intent + // marker (as `daemon restart` does) so a failed install can't leave + // autostart permanently disabled with no daemon and no service. + daemonRestartActive = true + err := runDaemonStop(cmd, nil) + daemonRestartActive = false + if err != nil { + return fmt.Errorf("stop existing daemon: %w", err) + } + } + + exe, err := os.Executable() + if err != nil { + return fmt.Errorf("resolve executable: %w", err) + } + + switch runtime.GOOS { + case "darwin": + return installLaunchd(w, exe) + case "linux": + return installSystemd(w, exe) + default: + return fmt.Errorf("service install is not supported on %s — run 'gortex daemon start --detach' to keep the daemon running in the background", runtime.GOOS) + } +} + +func runDaemonUninstallService(cmd *cobra.Command, _ []string) error { + w := cmd.ErrOrStderr() + switch runtime.GOOS { + case "darwin": + return uninstallLaunchd(w) + case "linux": + return uninstallSystemd(w) + default: + return fmt.Errorf("service uninstall not supported on %s", runtime.GOOS) + } +} + +func runDaemonServiceStatus(cmd *cobra.Command, _ []string) error { + w := cmd.OutOrStdout() + switch runtime.GOOS { + case "darwin": + return statusLaunchd(w) + case "linux": + return statusSystemd(w) + default: + return fmt.Errorf("service status not supported on %s", runtime.GOOS) + } +} + +// --- launchd (macOS) ------------------------------------------------------ + +// launchdPlistTemplate renders the LaunchAgent plist. KeepAlive uses the +// SuccessfulExit=false policy so the agent is restarted on a crash but NOT on a +// clean exit — the launchd analogue of systemd's Restart=on-failure, so an +// explicit `gortex daemon stop` (which exits 0) stays down instead of being +// resurrected by KeepAlive. RunAtLoad starts on login. +// +// StandardOutPath / StandardErrorPath redirect logs into the same file +// `gortex daemon logs` tails, so users don't need to remember two paths. +// +// EnvironmentVariables carries PATH (so a Homebrew-installed binary is +// found in launchd's minimal environment) plus any XDG_* overrides that +// were in effect at install time — see xdgServiceEnv for why that +// capture is necessary. +const launchdPlistTemplate = ` + + + + Label + {{.Label}} + ProgramArguments + + {{.Exe}} + daemon + start + + RunAtLoad + + KeepAlive + + SuccessfulExit + + + StandardOutPath + {{.LogPath}} + StandardErrorPath + {{.LogPath}} + EnvironmentVariables + + PATH + /usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin +{{- range .EnvVars}} + {{.Key}} + {{.Value}} +{{- end}} + + + +` + +// renderLaunchdPlist fills launchdPlistTemplate. String values are +// XML-escaped so a path containing an XML metacharacter can't produce a +// malformed, unloadable plist. +func renderLaunchdPlist(label, exe, logPath string, env []serviceEnvVar) (string, error) { + data := struct { + Label, Exe, LogPath string + EnvVars []serviceEnvVar + }{ + Label: xmlEscape(label), + Exe: xmlEscape(exe), + LogPath: xmlEscape(logPath), + EnvVars: make([]serviceEnvVar, len(env)), + } + for i, e := range env { + data.EnvVars[i] = serviceEnvVar{Key: e.Key, Value: xmlEscape(e.Value)} + } + var buf bytes.Buffer + if err := template.Must(template.New("plist").Parse(launchdPlistTemplate)).Execute(&buf, data); err != nil { + return "", err + } + return buf.String(), nil +} + +func launchdPlistPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, "Library", "LaunchAgents", daemonServiceName+".plist"), nil +} + +func installLaunchd(w io.Writer, exe string) error { + path, err := launchdPlistPath() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("ensure LaunchAgents dir: %w", err) + } + + plist, err := renderLaunchdPlist(daemonServiceName, exe, daemon.LogFilePath(), xdgServiceEnv()) + if err != nil { + return fmt.Errorf("render plist: %w", err) + } + if err := os.WriteFile(path, []byte(plist), 0o644); err != nil { + return fmt.Errorf("write plist: %w", err) + } + // -w persists the load across reboots; without it the service + // starts only for the current login session. + if err := runCmd(w, "launchctl", "load", "-w", path); err != nil { + return fmt.Errorf("launchctl load: %w", err) + } + fmt.Fprintf(w, "[gortex daemon] service installed at %s\n", path) + fmt.Fprintf(w, " logs: %s\n", daemon.LogFilePath()) + fmt.Fprintf(w, " check: gortex daemon service-status\n") + return nil +} + +func uninstallLaunchd(w io.Writer) error { + path, err := launchdPlistPath() + if err != nil { + return err + } + // unload is idempotent — emits a warning if the plist isn't loaded, + // but exit 0. Swallow its error path so uninstall is safe to retry. + _ = runCmd(w, "launchctl", "unload", "-w", path) + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("remove plist: %w", err) + } + fmt.Fprintln(w, "[gortex daemon] service uninstalled") + return nil +} + +func statusLaunchd(w io.Writer) error { + path, err := launchdPlistPath() + if err != nil { + return err + } + if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { + fmt.Fprintf(w, "launchd: not installed (expected %s)\n", path) + return nil + } + fmt.Fprintf(w, "launchd: installed at %s\n", path) + + // `launchctl list