chore: import upstream snapshot with attribution
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:33:42 +08:00
commit a06f331eb8
3186 changed files with 689843 additions and 0 deletions
+211
View File
@@ -0,0 +1,211 @@
// Package agents defines the contract Gortex uses to wire itself into
// external AI coding assistants (Claude Code, Cursor, Kiro, Continue.dev,
// Cline, Windsurf, OpenCode, VS Code / Copilot, Antigravity, …).
//
// Each integration lives in its own sub-package and implements Adapter.
// `gortex init` iterates over a Registry of adapters and for each one
// calls Detect → Plan → Apply. The split lets CI exercise planning
// without touching disk (Plan + --dry-run) and makes every writer
// funnel through a single code path (writer.go), so atomic-rename,
// dry-run reporting, and golden-fixture testing stay uniform across
// agents.
package agents
import "io"
// Adapter is the contract every agent integration implements.
//
// Semantics:
// - Name is stable across releases; users reference it via
// --agents=<csv>. Lowercase kebab-case.
// - DocsURL points to the agent's *own* MCP/hook documentation
// (not Gortex docs) so --json consumers can trace what schema
// we're targeting.
// - Detect never modifies disk. False means "skip" — not an error.
// - Plan is pure: it returns what Apply *would* do for a given
// Env, without writing. Callers use it to power --dry-run and
// `gortex init doctor`.
// - Apply executes the plan. It must respect ApplyOpts.DryRun
// (return planned actions without writing) and ApplyOpts.Force
// (overwrite merge-preserved keys).
type Adapter interface {
Name() string
DocsURL() string
Detect(env Env) (bool, error)
Plan(env Env) (*Plan, error)
Apply(env Env, opts ApplyOpts) (*Result, error)
}
// Mode selects between per-repo and user-level installation.
// `gortex init` runs adapters in ModeProject; `gortex install` runs
// them in ModeGlobal. Adapters branch on this to choose between
// project-local paths (.mcp.json, .cursor/mcp.json, CLAUDE.md, …) and
// user-level paths (~/.claude.json, ~/.gemini/settings.json, …).
type Mode int
const (
// ModeProject writes project-local files (.mcp.json, .cursor/mcp.json, …).
// Used by `gortex init`.
ModeProject Mode = iota
// ModeGlobal writes user-level files (~/.claude.json, ~/.gemini/settings.json, …).
// Used by `gortex install`.
ModeGlobal
)
// Env bundles the inputs every adapter needs: where to write, which
// binary to reference in hook commands, user home for home-rooted
// integrations, and a stderr-like writer for progress messages. Test
// code swaps Stderr for a buffer to assert on messages without
// capturing process stderr.
type Env struct {
// Root is the absolute path to the repository. Adapters join
// relative paths under this.
Root string
// Home is the user's home directory, resolved once by the caller
// so adapters don't each call os.UserHomeDir().
Home string
// HookCommand is the shell command to bake into agent hook
// config (e.g. "/usr/local/bin/gortex hook"). Resolved once by
// the caller so every adapter writes the same string.
HookCommand string
// Mode is per-repo vs user-level. See ModeProject / ModeGlobal.
Mode Mode
// InstallHooks is false when the user passed --no-hooks or
// answered "no" to the wizard. Hook-capable adapters use it to
// skip their lifecycle hook surfaces while still installing MCP.
InstallHooks bool
// HookMode is the posture for the PreToolUse / PostToolUse hook
// integration: "deny" (default — redirect by deny on the
// PreToolUse side, no PostToolUse) or "enrich" (never deny,
// install PostToolUse that augments tool output with graph
// context). Empty falls back to "deny". Only the Claude Code
// adapter currently honours it; other adapters ignore the field.
HookMode string
// InstallGlobalInstructions toggles whether `gortex install`
// merges the rule block into ~/.claude/CLAUDE.md. Only honoured
// in ModeGlobal; ignored elsewhere. Default true so a fresh
// install delivers full enforcement; set false by --no-claude-md.
InstallGlobalInstructions bool
// InstructionsDir overrides where the generated instruction
// profiles (internal/profiles) are materialised and where the
// CLAUDE.md pointer block resolves its @-include. Empty means
// the machine default (profiles.DefaultDir()); tests set a temp
// directory for hermeticity.
InstructionsDir string
// AnalyzeRepo is true when the caller wants a dynamic CLAUDE.md
// preamble built from a fresh index. Only Claude Code uses it.
AnalyzeRepo bool
// AnalyzedOverview, if non-empty, is a pre-built dynamic
// CLAUDE.md preamble the Claude Code adapter will prepend to
// its static block. Indexing is driven by the caller to keep
// the agents package free of the indexer dependency.
AnalyzedOverview string
// SkillsRouting, if non-empty, is the pre-rendered
// community-routing block each adapter writes into its
// per-repo instructions surface between CommunitiesStartMarker
// and CommunitiesEndMarker. Callers set this after running the
// community generator; adapters treat it as an opaque markdown
// payload.
SkillsRouting string
// GeneratedSkills, if non-empty, lists per-community skill
// files. The Claude Code adapter materialises these under
// .claude/skills/generated/<DirName>/SKILL.md. Other adapters
// rely on SkillsRouting alone.
GeneratedSkills []GeneratedSkill
// Stderr receives progress messages. nil means discard.
Stderr io.Writer
}
// GeneratedSkill is a small, package-local mirror of the skills
// generator's output — kept here so the agents package doesn't
// depend on internal/skills. The caller populates this from
// internal/skills.Generator output.
type GeneratedSkill struct {
CommunityID string
Label string // kebab-case, e.g. "mcp-server"
DirName string // e.g. "gortex-mcp-server"
Content string // full SKILL.md content
}
// ApplyOpts controls Apply's runtime behaviour. Plan doesn't take
// these — plans are observational.
type ApplyOpts struct {
// DryRun reports what Apply *would* do without writing.
// Actions in the returned Result use Would* variants.
DryRun bool
// Force overwrites keys we would otherwise preserve during a
// merge (e.g. a custom autoApprove list the user edited). Does
// not bypass the "skip if already configured" fast path.
Force bool
// ForceDetect makes an adapter render its artifacts even when the
// target tool isn't detected on this machine. Used by the
// skill-render drift fence (`gortex agents render`) to exercise
// every adapter regardless of which agents are installed; never set
// during a normal `gortex init`.
ForceDetect bool
}
// ActionKind tags what happened (or would happen) for a single file.
// "would-*" variants are emitted under DryRun; plain "create" /
// "merge" / "skip" are emitted by Apply when it actually wrote.
type ActionKind string
const (
ActionCreate ActionKind = "create"
ActionMerge ActionKind = "merge"
ActionSkip ActionKind = "skip"
ActionDelete ActionKind = "delete"
ActionWouldCreate ActionKind = "would-create"
ActionWouldMerge ActionKind = "would-merge"
ActionWouldDelete ActionKind = "would-delete"
)
// FileAction describes one file write (real or planned). Keys is the
// set of top-level JSON/YAML/TOML keys touched during a merge — used
// by the doctor subcommand to diff intended vs actual state.
type FileAction struct {
Path string `json:"path"`
Action ActionKind `json:"action"`
Keys []string `json:"keys,omitempty"`
Reason string `json:"reason,omitempty"`
}
// Plan is the declarative output of Adapter.Plan. Every file the
// adapter *would* touch is listed with a predicted Action. For
// never-overwrite files the prediction is ActionWouldCreate when the
// file is absent and ActionSkip when it already exists.
type Plan struct {
Files []FileAction `json:"files,omitempty"`
}
// Result is what Apply returned. Detected mirrors what Detect
// returned (captured on the result so callers don't need to
// re-invoke detection). Configured is true when at least one write
// succeeded (or would succeed under DryRun). Warnings carries
// non-fatal problems an adapter chose to continue past — e.g. a single
// profile config that failed to write while the rest succeeded — so the
// summary / --json report can surface them instead of burying them in a
// stderr log line.
type Result struct {
Name string `json:"name"`
Detected bool `json:"detected"`
Configured bool `json:"configured"`
Files []FileAction `json:"files,omitempty"`
Warnings []string `json:"warnings,omitempty"`
DocsURL string `json:"docs_url,omitempty"`
}
+182
View File
@@ -0,0 +1,182 @@
// Package agentstest provides shared helpers for writing
// golden-fixture-style tests against Adapter implementations.
// Every adapter test follows the same three-phase contract:
//
// 1. Create — Apply into an empty tmpdir. Expect N file creations.
// 2. Idempotent — Apply a second time into the same tmpdir. Expect
// N skip actions (no writes, no merges).
// 3. Merge — Apply into a tmpdir that already contains a user file
// with unrelated keys. Expect our content to be added without
// clobbering the user's keys.
//
// The harness asserts on the agents.Result each phase returns so
// behaviour regressions surface with clear diffs rather than byte-
// level noise about map ordering.
package agentstest
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"sort"
"testing"
"github.com/zzet/gortex/internal/agents"
yaml "gopkg.in/yaml.v3"
)
// NewEnv returns an Env pointed at a temporary Root and Home.
// Stderr is a bytes.Buffer so tests can assert on progress lines;
// HookCommand is fixed so hook-writing adapters produce deterministic
// output.
func NewEnv(t *testing.T) (agents.Env, *bytes.Buffer) {
t.Helper()
root := t.TempDir()
home := t.TempDir()
buf := &bytes.Buffer{}
return agents.Env{
Root: root,
Home: home,
HookCommand: "/tmp/test-gortex hook",
Mode: agents.ModeProject,
InstallHooks: true,
// Hermetic instruction-profile dir: global installs generate
// profile files and read the active profile from here instead
// of the developer's real ~/.gortex.
InstructionsDir: filepath.Join(home, ".gortex", "instructions"),
// SkillsRouting + GeneratedSkills are seeded so per-adapter
// tests exercise the community-routing write path — which
// is the only reason adapters touch per-repo instructions
// files now. A real `gortex init` run sets these after
// community generation; NewEnv fakes them with minimal
// stubs so every adapter's test path stays realistic.
SkillsRouting: StubSkillsRouting,
GeneratedSkills: []agents.GeneratedSkill{{
CommunityID: "test",
Label: "stub",
DirName: "gortex-stub",
Content: "---\nname: gortex-stub\ndescription: test stub\n---\n# Stub community\n",
}},
Stderr: buf,
}, buf
}
// StubSkillsRouting is the fake routing payload NewEnv seeds. Kept
// small but with the marker-guarded wrapper contract preserved so
// tests that grep for the block shape stay realistic.
const StubSkillsRouting = `## Gortex Community Skills (test stub)
- stub: routes to gortex-stub/
`
// AssertCountsByAction fails the test when the result's action
// counts differ from the expected map. Used to declare "phase 1
// should produce 7 creates" in a readable way.
func AssertCountsByAction(t *testing.T, res *agents.Result, want map[agents.ActionKind]int) {
t.Helper()
got := make(map[agents.ActionKind]int)
for _, f := range res.Files {
got[f.Action]++
}
if len(got) != len(want) {
t.Fatalf("unexpected action kinds: got %v, want %v", got, want)
}
for k, v := range want {
if got[k] != v {
t.Fatalf("%s: got %d, want %d (full: got %v, want %v)", k, got[k], v, got, want)
}
}
}
// AssertIdempotent re-runs Apply against the same env and checks
// that every returned action is a skip. This is the contract every
// adapter must honour: re-running `gortex init` never rewrites files
// that are already configured.
func AssertIdempotent(t *testing.T, a agents.Adapter, env agents.Env) {
t.Helper()
res, err := a.Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("second Apply: %v", err)
}
for _, f := range res.Files {
if f.Action != agents.ActionSkip {
t.Fatalf("expected all skips on re-run, got %s for %s", f.Action, f.Path)
}
}
}
// ReadJSON parses a JSON file into a map — convenience for golden
// tests that want to assert on top-level keys without wrestling
// with the any type.
func ReadJSON(t *testing.T, path string) map[string]any {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
var out map[string]any
if err := json.Unmarshal(data, &out); err != nil {
t.Fatalf("parse %s: %v", path, err)
}
return out
}
// WriteJSON writes a pretty-printed map to path — for seeding
// "pre-populated" scenarios in merge tests.
func WriteJSON(t *testing.T, path string, obj map[string]any) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
data, err := json.MarshalIndent(obj, "", " ")
if err != nil {
t.Fatalf("marshal: %v", err)
}
if err := os.WriteFile(path, data, 0o644); err != nil {
t.Fatalf("write: %v", err)
}
}
// ReadYAML parses a YAML file into a map — the YAML cousin of
// ReadJSON for adapters whose config lives in YAML (Hermes, Aider).
func ReadYAML(t *testing.T, path string) map[string]any {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
var out map[string]any
if err := yaml.Unmarshal(data, &out); err != nil {
t.Fatalf("parse %s: %v", path, err)
}
return out
}
// WriteYAML writes obj to path as YAML — for seeding "pre-populated"
// scenarios in merge tests.
func WriteYAML(t *testing.T, path string, obj map[string]any) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
data, err := yaml.Marshal(obj)
if err != nil {
t.Fatalf("marshal: %v", err)
}
if err := os.WriteFile(path, data, 0o644); err != nil {
t.Fatalf("write: %v", err)
}
}
// SortedFilePaths returns the Paths from a result's Files in sorted
// order. Makes golden assertions invariant to adapter iteration
// order over maps.
func SortedFilePaths(res *agents.Result) []string {
out := make([]string, 0, len(res.Files))
for _, f := range res.Files {
out = append(out, f.Path)
}
sort.Strings(out)
return out
}
+167
View File
@@ -0,0 +1,167 @@
// Package aider implements the Gortex init integration for Aider.
// Aider's CLI does not speak MCP natively today; we support it
// through two lightweight artifacts:
//
// - .aiderignore additions pointing at Gortex cache directories
// so Aider doesn't re-index them on every chat.
// - A brief pointer block in .aider.conf.yml documenting how to
// call Gortex as an external tool from an Aider session.
//
// This adapter is intentionally thin — when Aider adds native MCP
// support (or when the community aider-mcp-server bridge stabilises)
// the integration can be expanded.
//
// Docs: https://aider.chat/docs/config/aider_conf.html
package aider
import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/internalutil"
)
const Name = "aider"
const DocsURL = "https://aider.chat/docs/config/aider_conf.html"
type Adapter struct{}
func New() *Adapter { return &Adapter{} }
func (a *Adapter) Name() string { return Name }
func (a *Adapter) DocsURL() string { return DocsURL }
// aiderIgnoreLines is the set of cache paths Aider should never
// ingest as source. Keeping them out of the chat avoids wasting
// tokens on Gortex's own binary index and Bleve scorer data.
var aiderIgnoreLines = []string{
"# Added by `gortex init` — Gortex cache artifacts are not source",
".gortex/",
"*.gortex-cache",
}
func (a *Adapter) Detect(env agents.Env) (bool, error) {
if p, err := exec.LookPath("aider"); err == nil && p != "" {
return true, nil
}
for _, p := range []string{
filepath.Join(env.Root, ".aider.conf.yml"),
filepath.Join(env.Root, ".aiderignore"),
} {
if _, err := os.Stat(p); err == nil {
return true, nil
}
}
if env.Home != "" {
if _, err := os.Stat(filepath.Join(env.Home, ".aider.conf.yml")); err == nil {
return true, nil
}
}
return false, nil
}
func (a *Adapter) Plan(env agents.Env) (*agents.Plan, error) {
p := &agents.Plan{Files: []agents.FileAction{
{Path: filepath.Join(env.Root, ".aiderignore"), Action: agents.ActionWouldMerge, Keys: []string{"gortex-ignore-block"}},
}}
if env.Mode != agents.ModeGlobal && env.SkillsRouting != "" {
p.Files = append(p.Files, agents.FileAction{
Path: filepath.Join(env.Root, "CONVENTIONS.md"), Action: agents.ActionWouldMerge,
Keys: []string{"communities-block"},
})
}
return p, nil
}
func (a *Adapter) Apply(env agents.Env, opts agents.ApplyOpts) (*agents.Result, error) {
res := &agents.Result{Name: Name, DocsURL: DocsURL}
// Aider has no user-level MCP surface today — all artifacts
// are per-repo, so ModeGlobal is a no-op.
if env.Mode == agents.ModeGlobal {
return res, nil
}
detected, _ := a.Detect(env)
res.Detected = detected
if !detected && !opts.ForceDetect {
internalutil.Logf(env.Stderr, "[gortex init] skip Aider setup (aider not detected)")
return res, nil
}
internalutil.Logf(env.Stderr, "[gortex init] setting up Aider integration...")
action, err := appendAiderIgnoreBlock(env.Stderr, filepath.Join(env.Root, ".aiderignore"), opts)
if err != nil {
return res, err
}
res.Files = append(res.Files, action)
// CONVENTIONS.md is the file aider users canonically `/read`
// (or wire into .aider.conf.yml's `read:` list). Write a
// marker-guarded community-routing block there when skills
// were generated. Otherwise leave the file alone so we don't
// create an empty CONVENTIONS.md users have to delete.
if env.SkillsRouting != "" {
conventionsPath := filepath.Join(env.Root, "CONVENTIONS.md")
routingAction, err := agents.UpsertMarkedBlock(env.Stderr, conventionsPath, env.SkillsRouting,
agents.CommunitiesStartMarker, agents.CommunitiesEndMarker, opts)
if err != nil {
return res, err
}
res.Files = append(res.Files, routingAction)
if routingAction.Action == agents.ActionCreate {
internalutil.Logf(env.Stderr, "[gortex init] add CONVENTIONS.md to your .aider.conf.yml `read:` list to load it on every aider session")
}
}
res.Configured = true
return res, nil
}
// appendAiderIgnoreBlock appends our lines to .aiderignore unless
// they're already present. We key idempotency on the comment
// sentinel — if a user ran init once already, they see it on line
// one of our block.
func appendAiderIgnoreBlock(w interface {
Write(p []byte) (n int, err error)
}, path string, opts agents.ApplyOpts) (agents.FileAction, error) {
existing, readErr := os.ReadFile(path)
if readErr != nil && !errors.Is(readErr, os.ErrNotExist) {
return agents.FileAction{}, readErr
}
existed := readErr == nil
sentinel := aiderIgnoreLines[0]
if existed && strings.Contains(string(existing), sentinel) {
return agents.FileAction{Path: path, Action: agents.ActionSkip, Reason: "block-present"}, nil
}
if opts.DryRun {
action := agents.ActionWouldMerge
if !existed {
action = agents.ActionWouldCreate
}
return agents.FileAction{Path: path, Action: action}, nil
}
prefix := ""
if existed && len(existing) > 0 && !strings.HasSuffix(string(existing), "\n") {
prefix = "\n"
}
block := prefix + strings.Join(aiderIgnoreLines, "\n") + "\n"
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return agents.FileAction{}, err
}
defer f.Close()
if _, err := f.WriteString(block); err != nil {
return agents.FileAction{}, err
}
fmt.Fprintf(w, "[gortex init] appended Gortex ignore block to %s\n", path)
action := agents.ActionMerge
if !existed {
action = agents.ActionCreate
}
return agents.FileAction{Path: path, Action: action}, nil
}
+107
View File
@@ -0,0 +1,107 @@
package antigravity
import (
"fmt"
"path/filepath"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/internalutil"
)
// Name identifies the Antigravity adapter for --agents=<name>.
const Name = "antigravity"
// DocsURL is the current public docs entry point for MCP in
// Antigravity. As of 2026 Antigravity supports both MCP server
// registration (at ~/.gemini/antigravity/mcp_config.json) and the
// older Knowledge Item mechanism. We write both today: MCP for
// runtime tool access, KI for the in-editor instructions panel.
const DocsURL = "https://antigravity.google/docs/mcp"
type Adapter struct{}
func New() *Adapter { return &Adapter{} }
func (a *Adapter) Name() string { return Name }
func (a *Adapter) DocsURL() string { return DocsURL }
// Detect returns true whenever a Home directory is resolvable.
// Both artifacts are cheap to install and harmless on machines
// without Antigravity installed — a user who installs later picks
// up the config automatically. The Step 4 audit will tighten this
// to a proper install check once Antigravity ships a PATH-visible
// CLI.
func (a *Adapter) Detect(env agents.Env) (bool, error) {
return env.Home != "", nil
}
func (a *Adapter) Plan(env agents.Env) (*agents.Plan, error) {
if env.Home == "" {
return &agents.Plan{}, nil
}
kiDir := filepath.Join(env.Home, ".gemini", "antigravity", "knowledge", "gortex-workflow")
return &agents.Plan{Files: []agents.FileAction{
{Path: filepath.Join(env.Home, ".gemini", "antigravity", "mcp_config.json"), Action: agents.ActionWouldMerge, Keys: []string{"mcpServers"}},
{Path: filepath.Join(env.Home, ".gemini", "settings.json"), Action: agents.ActionWouldMerge, Keys: []string{"hooks"}},
{Path: filepath.Join(kiDir, "metadata.json"), Action: agents.ActionWouldCreate},
{Path: filepath.Join(kiDir, "artifacts", "gortex-instructions.md"), Action: agents.ActionWouldCreate},
}}, nil
}
func (a *Adapter) Apply(env agents.Env, opts agents.ApplyOpts) (*agents.Result, error) {
res := &agents.Result{Name: Name, DocsURL: DocsURL}
detected, _ := a.Detect(env)
res.Detected = detected
if !detected && !opts.ForceDetect {
return res, nil
}
if env.Home == "" {
return res, fmt.Errorf("antigravity: requires a resolved home directory")
}
internalutil.Logf(env.Stderr, "[gortex init] setting up Antigravity integration...")
// 1. Native MCP registration — new in 2026.
mcpPath := filepath.Join(env.Home, ".gemini", "antigravity", "mcp_config.json")
mcpAction, err := agents.MergeJSON(env.Stderr, mcpPath, func(root map[string]any, _ bool) (bool, error) {
return agents.UpsertMCPServer(root, "gortex", agents.DefaultGortexMCPEntry(), opts), nil
}, opts)
if err != nil {
return res, err
}
res.Files = append(res.Files, mcpAction)
// 1b. Lifecycle hooks — Antigravity reads Gemini CLI's
// ~/.gemini/settings.json hooks, so install SessionStart +
// AfterTool there (the handler is agent-agnostic). If the Gemini
// adapter already added a gortex hook, UpsertGeminiHooks is a
// no-op, so the two adapters never double-register.
hooksPath := filepath.Join(env.Home, ".gemini", "settings.json")
hooksAction, err := agents.MergeJSON(env.Stderr, hooksPath, func(root map[string]any, _ bool) (bool, error) {
return agents.UpsertGeminiHooks(root, Name, opts), nil
}, opts)
if err != nil {
return res, err
}
res.Files = append(res.Files, hooksAction)
// 2. Knowledge Item — kept as a secondary artifact. Teaches
// Antigravity *how to use* Gortex via run_command, which is
// useful even when the MCP server is registered (the KI
// gives the model intent / workflow guidance that a plain
// tool registration doesn't).
kiDir := filepath.Join(env.Home, ".gemini", "antigravity", "knowledge", "gortex-workflow")
metaAction, err := agents.WriteIfNotExists(env.Stderr, filepath.Join(kiDir, "metadata.json"), Metadata, opts)
if err != nil {
return res, err
}
res.Files = append(res.Files, metaAction)
instrAction, err := agents.WriteIfNotExists(env.Stderr, filepath.Join(kiDir, "artifacts", "gortex-instructions.md"), Instructions, opts)
if err != nil {
return res, err
}
res.Files = append(res.Files, instrAction)
res.Configured = true
return res, nil
}
@@ -0,0 +1,54 @@
package antigravity
import (
"os"
"path/filepath"
"testing"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/agentstest"
)
// TestAntigravityRegistersMCPAndWritesKI is the acceptance test for
// the 2026 audit fix: we must now write *both* the native MCP
// config (new) and the Knowledge Item (existing). A regression to
// KI-only would silently remove the runtime tool access.
func TestAntigravityRegistersMCPAndWritesKI(t *testing.T) {
env, _ := agentstest.NewEnv(t)
a := New()
res, err := a.Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
if !res.Configured {
t.Fatal("expected Configured=true")
}
// 1. Native MCP stanza under ~/.gemini/antigravity/mcp_config.json.
mcpPath := filepath.Join(env.Home, ".gemini", "antigravity", "mcp_config.json")
if _, err := os.Stat(mcpPath); err != nil {
t.Fatalf("mcp_config.json missing: %v", err)
}
cfg := agentstest.ReadJSON(t, mcpPath)
servers, ok := cfg["mcpServers"].(map[string]any)
if !ok {
t.Fatalf("mcpServers missing: %v", cfg)
}
if _, ok := servers["gortex"]; !ok {
t.Fatalf("gortex server missing from mcpServers: %v", servers)
}
// 2. Knowledge Item artifacts.
kiBase := filepath.Join(env.Home, ".gemini", "antigravity", "knowledge", "gortex-workflow")
for _, p := range []string{
filepath.Join(kiBase, "metadata.json"),
filepath.Join(kiBase, "artifacts", "gortex-instructions.md"),
} {
if _, err := os.Stat(p); err != nil {
t.Errorf("KI artifact missing: %s (%v)", p, err)
}
}
agentstest.AssertIdempotent(t, a, env)
}
+66
View File
@@ -0,0 +1,66 @@
// Package antigravity implements the Gortex init integration for
// Google's Antigravity. Today we write a Knowledge Item at
// ~/.gemini/antigravity/knowledge/gortex-workflow/ — the official
// documented mechanism for teaching Antigravity about project-
// specific tooling. The Step 3 audit will add true MCP registration
// alongside the KI once Antigravity's MCP config path is verified.
package antigravity
// Metadata is the KI manifest. Antigravity reads it to show the KI
// in its UI and to locate the artifact files it references.
const Metadata = `{
"summary": "MANDATORY: Instructions on how to use the local gortex engine CLI to significantly improve codebase intelligence. Antigravity must use run_command with gortex query over standard file read commands.",
"references": ["artifacts/gortex-instructions.md"]
}
`
// Instructions is the KI body — a Knowledge Item in Antigravity's
// frontmatter-first markdown format. Lists the run_command
// invocations Antigravity should prefer over grep_search / view_file.
const Instructions = `---
type: "Knowledge Item"
description: "Gortex Workflow and Tools for Antigravity"
---
# Gortex Engine Usage
This codebase is indexed by Gortex, an in-memory knowledge graph code intelligence engine.
**MANDATORY:** You MUST prioritize using the Gortex CLI via ` + "`run_command`" + ` instead of using standard text searches (` + "`grep_search`" + `) or full file reads (` + "`view_file`" + `). This drastically improves token efficiency and prevents hallucination.
## Setup
If the daemon is not tracking your target repos, register the current repo so the graph tools activate:
` + "```bash" + `
gortex track .
` + "```" + `
## Standard Workflow Translation
| Instead of... | You MUST use... (via ` + "`run_command`" + `) |
|---|---|
| ` + "`grep_search`" + ` to find a class or function | ` + "`./gortex query symbol <name> --format text`" + ` (AST-aware search) |
| ` + "`grep_search`" + ` to find all references | ` + "`./gortex query usages <id>`" + ` (zero false positives) |
| ` + "`view_file`" + ` to read a whole file to find a method | ` + "`./gortex query symbol <name>`" + ` or ` + "`./gortex query callers <func_id>`" + ` |
| Guessing what breaks during a refactor | ` + "`./gortex query dependents <id>`" + ` (impact analysis) |
| Creating circular dependencies | Evaluate ` + "`./gortex query deps <id>`" + ` first |
## Example Usage
### 1. View Architecture and Communities
` + "```bash" + `
./gortex query stats
` + "```" + `
### 2. Find specific symbol definition
` + "```bash" + `
./gortex query symbol MyController
` + "```" + `
### 3. Trace blast radius
If you are modifying ` + "`core/parser.go::Parse`" + `, check what will break:
` + "```bash" + `
./gortex query dependents core/parser.go::Parse --depth 2
` + "```" + `
This gives you perfectly accurate AST-level analysis, guaranteeing safe edits.
`
+837
View File
@@ -0,0 +1,837 @@
package claudecode
import (
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/profiles"
)
// Name is the stable identifier for this adapter, matching the
// --agents=<name> CLI flag.
const Name = "claude-code"
// DocsURL is the page we point users at when something about our
// Claude Code integration surprises them. Used in the --json report.
const DocsURL = "https://docs.claude.com/en/docs/claude-code/overview"
// Adapter implements agents.Adapter for Claude Code.
type Adapter struct{}
// New returns the Claude Code adapter. Callers register it via
// `agents.Registry.Register(claudecode.New())`.
func New() *Adapter { return &Adapter{} }
func (a *Adapter) Name() string { return Name }
func (a *Adapter) DocsURL() string { return DocsURL }
// Detect always returns true. Claude Code is the "home" agent for
// `gortex init` — a project may not be opened in Claude Code today
// but we always want the integration files on disk so the team's
// next contributor is set up. Other adapters gate on detection
// because their artifacts make no sense without the IDE installed.
func (a *Adapter) Detect(env agents.Env) (bool, error) {
return true, nil
}
// Plan reports the full set of files the adapter would touch for
// the given Env. Mode branches between project (`gortex init`) and
// global (`gortex install`) surfaces; InstallHooks elides hook files
// without affecting anything else.
func (a *Adapter) Plan(env agents.Env) (*agents.Plan, error) {
p := &agents.Plan{}
if env.Mode == agents.ModeGlobal {
// User-level artifacts — machine-wide. Slash commands and
// curated Gortex tool-usage skills both belong here: they're
// codebase-agnostic, so duplicating them into every repo is
// wasted disk and drift risk.
p.Files = append(p.Files, agents.FileAction{Path: userClaudeJSONPath(env.Home), Action: agents.ActionWouldMerge, Keys: []string{"mcpServers"}})
p.Files = append(p.Files, agents.FileAction{Path: userSettingsPath(env.Home), Action: agents.ActionWouldMerge, Keys: []string{"permissions"}})
if env.InstallHooks {
p.Files = append(p.Files, agents.FileAction{Path: userSettingsLocalPath(env.Home), Action: agents.ActionWouldMerge, Keys: []string{"hooks"}})
}
if env.InstallGlobalInstructions {
p.Files = append(p.Files, agents.FileAction{Path: userClaudeMdPath(env.Home), Action: agents.ActionWouldMerge, Keys: []string{"gortex-rules-block"}})
}
configDir := userClaudeConfigDir(env.Home)
if configDir != "" {
for name := range GlobalSkills {
p.Files = append(p.Files, agents.FileAction{Path: filepath.Join(configDir, "skills", name, "SKILL.md"), Action: agents.ActionWouldCreate})
}
for name := range SlashCommands {
p.Files = append(p.Files, agents.FileAction{Path: filepath.Join(configDir, "commands", name), Action: agents.ActionWouldCreate})
}
for name := range SubAgents {
p.Files = append(p.Files, agents.FileAction{Path: filepath.Join(configDir, "agents", name), Action: agents.ActionWouldCreate})
}
}
return p, nil
}
// Project mode — only genuinely repo-specific artifacts. No
// tool-usage duplication: that lives at ~/.claude/skills/
// (installed by `gortex install`). CLAUDE.md gets a
// marker-guarded block only when --analyze or --skills produce
// codebase-derived content.
p.Files = append(p.Files, agents.FileAction{Path: filepath.Join(env.Root, ".mcp.json"), Action: agents.ActionWouldCreate, Keys: []string{"mcpServers"}})
p.Files = append(p.Files, agents.FileAction{Path: filepath.Join(env.Root, ".claude", "settings.json"), Action: agents.ActionWouldMerge, Keys: []string{"permissions"}})
if env.InstallHooks {
p.Files = append(p.Files, agents.FileAction{Path: filepath.Join(env.Root, ".claude", "settings.local.json"), Action: agents.ActionWouldMerge, Keys: []string{"hooks"}})
}
if env.AnalyzedOverview != "" || env.SkillsRouting != "" {
p.Files = append(p.Files, agents.FileAction{Path: filepath.Join(env.Root, "CLAUDE.md"), Action: agents.ActionWouldMerge, Keys: []string{"communities-block"}})
}
for _, s := range env.GeneratedSkills {
p.Files = append(p.Files, agents.FileAction{Path: filepath.Join(env.Root, ".claude", "skills", "generated", s.DirName, "SKILL.md"), Action: agents.ActionWouldCreate})
}
return p, nil
}
// Apply performs the actual writes. Errors mid-way do not abort the
// whole adapter — we log each failure and continue, matching the
// pre-refactor behaviour where Kiro/Cursor/etc. setup failures only
// emit warnings. Claude Code's .mcp.json, CLAUDE.md, and hook install
// are the exception: they're the core integration and propagate
// failures upward.
func (a *Adapter) Apply(env agents.Env, opts agents.ApplyOpts) (*agents.Result, error) {
w := env.Stderr
res := &agents.Result{Name: Name, Detected: true, DocsURL: DocsURL}
if env.Mode == agents.ModeGlobal {
if err := a.applyGlobal(env, opts, res); err != nil {
return res, err
}
res.Configured = true
return res, nil
}
// 1. Project .mcp.json — create if absent, skip otherwise.
//
// If gortex is already registered at user scope (~/.claude.json), a
// project .mcp.json adds a second registration under the same name.
// Claude Code keys OAuth tokens per endpoint and flags this as a
// "conflicting scopes" diagnostic. The user-scope entry already
// serves this repo machine-wide, so skip the project file unless
// --force. (A pre-existing .mcp.json is left in place — we never
// delete it — but we warn so the user can resolve the duplication.)
mcpPath := filepath.Join(env.Root, ".mcp.json")
if !opts.Force && env.Home != "" && userScopeGortexRegistered(env.Home) && !pathExists(mcpPath) {
logWarn(w, "gortex is already registered at user scope (%s); skipping project .mcp.json to avoid a Claude Code \"conflicting scopes\" warning. Re-run with --force to write it anyway (e.g. for teammates without a global install).", userClaudeJSONPath(env.Home))
res.Files = append(res.Files, agents.FileAction{Path: mcpPath, Action: agents.ActionSkip, Reason: "gortex already registered at user scope"})
} else {
if !opts.Force && env.Home != "" && userScopeGortexRegistered(env.Home) && pathExists(mcpPath) {
logWarn(w, "gortex is registered at both user scope (%s) and project scope (%s); Claude Code may warn about conflicting scopes — keep one with `claude mcp remove gortex -s user` or `-s project`.", userClaudeJSONPath(env.Home), mcpPath)
}
mcpAction, err := agents.WriteIfNotExists(w, mcpPath, ProjectMCPJSON, opts)
if err != nil {
return res, fmt.Errorf(".mcp.json: %w", err)
}
res.Files = append(res.Files, mcpAction)
}
// 2. MCP permissions in .claude/settings.json — merge, not create.
permAction, err := installPermissions(w, filepath.Join(env.Root, ".claude", "settings.json"), opts)
if err != nil {
logWarn(w, "could not install permissions: %v", err)
}
res.Files = append(res.Files, permAction)
// 3. Hooks in .claude/settings.local.json — merge with healing.
if env.InstallHooks {
hookAction, err := InstallHookWithMode(w, filepath.Join(env.Root, ".claude", "settings.local.json"), env.HookMode, opts)
if err != nil {
logWarn(w, "could not install hook: %v", err)
}
res.Files = append(res.Files, hookAction)
} else {
logf(w, "[gortex init] skipping hook installation (--no-hooks)")
}
// 4. CLAUDE.md — only written when there's genuinely
// codebase-specific content to place there: either the
// --analyze overview, the --skills community routing, or both.
// Generic tool-usage moved to user-level ~/.claude/skills/
// (installed by `gortex install`).
if env.AnalyzedOverview != "" || env.SkillsRouting != "" {
claudeMdPath := filepath.Join(env.Root, "CLAUDE.md")
var body strings.Builder
if env.AnalyzedOverview != "" {
body.WriteString(env.AnalyzedOverview)
if !strings.HasSuffix(env.AnalyzedOverview, "\n") {
body.WriteString("\n")
}
}
if env.SkillsRouting != "" {
if body.Len() > 0 {
body.WriteString("\n")
}
body.WriteString(env.SkillsRouting)
}
claudeAction, err := agents.UpsertMarkedBlock(w, claudeMdPath, body.String(),
agents.CommunitiesStartMarker, agents.CommunitiesEndMarker, opts)
if err != nil {
return res, fmt.Errorf("CLAUDE.md: %w", err)
}
res.Files = append(res.Files, claudeAction)
}
// 5. Generated community skills — per-community SKILL.md files
// under .claude/skills/generated/. Claude Code auto-discovers
// them next to the repo-local CLAUDE.md. Regenerated each init
// run so they track the current graph.
for _, s := range env.GeneratedSkills {
path := filepath.Join(env.Root, ".claude", "skills", "generated", s.DirName, "SKILL.md")
action, err := agents.WriteOwnedFile(w, path, s.Content, opts)
if err != nil {
logWarn(w, "could not write generated skill %s: %v", s.DirName, err)
continue
}
res.Files = append(res.Files, action)
}
res.Configured = true
return res, nil
}
// applyGlobal handles Mode=ModeGlobal writes (entered via `gortex
// install`). Everything here is codebase-agnostic user-level
// machinery: MCP config pointing at `gortex mcp`, user-level
// hooks, curated Gortex tool-usage skills, and Gortex slash
// commands. No per-repo artifacts.
func (a *Adapter) applyGlobal(env agents.Env, opts agents.ApplyOpts, res *agents.Result) error {
w := env.Stderr
if env.Home == "" {
return fmt.Errorf("global mode requires a resolved home directory")
}
// 1. ~/.claude.json — MCP stanza pointing at `gortex mcp`.
mcpPath := userClaudeJSONPath(env.Home)
action, err := upsertGlobalMCPConfig(w, mcpPath, opts)
if err != nil {
return fmt.Errorf("global MCP config: %w", err)
}
res.Files = append(res.Files, action)
// 2. ~/.claude/settings.json — user-level MCP permission allowlist.
// Mirrors the project-mode call so `mcp__gortex__*` is auto-allowed
// machine-wide; without this every Gortex tool call shows an
// approval prompt until the user adds the rule by hand.
permAction, err := installPermissions(w, userSettingsPath(env.Home), opts)
if err != nil {
logWarn(w, "could not install global permissions: %v", err)
}
res.Files = append(res.Files, permAction)
// 3. ~/.claude/settings.local.json — user-level hooks.
if env.InstallHooks {
hookAction, err := InstallHookWithMode(w, userSettingsLocalPath(env.Home), env.HookMode, opts)
if err != nil {
return fmt.Errorf("global hooks: %w", err)
}
res.Files = append(res.Files, hookAction)
}
// 4. Instruction profiles + ~/.claude/CLAUDE.md pointer block.
// The generated profiles live under the gortex home; CLAUDE.md
// carries only a thin marker-fenced block that @-includes the
// active profile, so `gortex instructions switch` changes
// guidance depth without ever rewriting CLAUDE.md. Without the
// block, the rule only surfaces at deny-time (PreToolUse) which
// is late: the agent has already wasted a turn on a forbidden
// tool.
insDir := instructionsDir(env)
if env.InstallGlobalInstructions {
insAction := agents.FileAction{Path: insDir, Action: agents.ActionMerge, Keys: []string{"instruction-profiles"}}
if opts.DryRun {
insAction.Action = agents.ActionWouldMerge
res.Files = append(res.Files, insAction)
} else if err := profiles.Generate(insDir); err != nil {
logWarn(w, "could not generate instruction profiles: %v", err)
} else {
logf(w, "[gortex install] wrote instruction profiles to %s", insDir)
res.Files = append(res.Files, insAction)
}
claudeMdPath := userClaudeMdPath(env.Home)
mdAction, err := agents.UpsertMarkedBlock(w, claudeMdPath, agents.GlobalPointerBody(insDir),
agents.GlobalRulesStartMarker, agents.GlobalRulesEndMarker, opts)
if err != nil {
logWarn(w, "could not install global CLAUDE.md: %v", err)
} else {
logf(w, "[gortex install] wrote rule block to %s", claudeMdPath)
}
// UpsertMarkedBlock is shared with the per-repo communities
// block, so it labels every action with "communities-block".
// Relabel here so the install report distinguishes the two.
if mdAction.Keys != nil {
mdAction.Keys = []string{"gortex-rules-block"}
}
res.Files = append(res.Files, mdAction)
}
// 3. ~/.claude/skills/gortex-*/SKILL.md — curated tool-usage
// skills, reconciled against the active instruction profile's
// subset (nil = all shipped skills). Existing files are never
// overwritten so user edits survive; out-of-profile skills are
// removed only when they still match a shipped body.
skillActions, err := SyncGlobalSkills(w, env.Home, profiles.Active(insDir).Skills, opts)
if err != nil {
logWarn(w, "could not install user-level skills: %v", err)
}
res.Files = append(res.Files, skillActions...)
// 4. ~/.claude/commands/gortex-*.md — slash commands, also
// codebase-agnostic and user-level. Claude Code discovers
// user-level commands alongside project-level ones.
cmdActions, err := installGlobalSlashCommands(w, env.Home, opts)
if err != nil {
logWarn(w, "could not install user-level slash commands: %v", err)
}
res.Files = append(res.Files, cmdActions...)
// 5. ~/.claude/agents/gortex-*.md — sub-agent definitions. Claude
// Code auto-routes user prompts to sub-agents based on their
// frontmatter description, so shipping these gives gortex a
// delegation surface beyond skills + slash commands. The tool
// allowlist in each file pins sub-agents to gortex graph tools
// only — Bash / Grep / Glob are unavailable by construction.
agentActions, err := installGlobalSubAgents(w, env.Home, opts)
if err != nil {
logWarn(w, "could not install user-level sub-agents: %v", err)
}
res.Files = append(res.Files, agentActions...)
return nil
}
// RemoveGlobal undoes applyGlobal: it strips the Gortex footprint
// from the user-level Claude Code config — the MCP server stanza, the
// permission allow-entry, the hook entries, the CLAUDE.md rule block,
// and the curated skills / commands / sub-agents. Merged files keep
// every non-Gortex key (other MCP servers, the user's own
// permissions / hooks, hand-written CLAUDE.md prose); owned files
// (skills / commands / agents) are deleted outright. The config root
// honors the --claude-config-dir override / $CLAUDE_CONFIG_DIR /
// the ~/.claude default. Returns the number of artifacts
// removed-or-cleaned and any per-artifact failures — a partial clean
// still reports rather than aborting. Invoked by `gortex uninstall
// --global`.
func (a *Adapter) RemoveGlobal(env agents.Env, opts agents.ApplyOpts) (removed int, failures []string) {
w := env.Stderr
if env.Home == "" {
return 0, []string{"global cleanup requires a resolved home directory"}
}
count := func(action agents.FileAction, err error, label string) {
if err != nil {
failures = append(failures, fmt.Sprintf("%s: %v", label, err))
return
}
if action.Action != agents.ActionSkip {
removed++
}
}
// 1. ~/.claude.json — drop the "gortex" MCP server stanza.
mcpPath := userClaudeJSONPath(env.Home)
mcpAction, err := removeGlobalMCPConfig(w, mcpPath, opts)
count(mcpAction, err, mcpPath)
// 2. settings.json — drop the mcp__gortex__* permission entry.
settingsPath := userSettingsPath(env.Home)
permAction, err := removeGlobalPermissions(w, settingsPath, opts)
count(permAction, err, settingsPath)
// 3. settings.local.json — drop the Gortex hook entries.
localPath := userSettingsLocalPath(env.Home)
hookAction, err := removeGlobalHooks(w, localPath, opts)
count(hookAction, err, localPath)
// 4. CLAUDE.md — strip the marker-fenced rule block. An empty
// body makes UpsertMarkedBlock remove the block in place,
// preserving any surrounding user prose.
mdPath := userClaudeMdPath(env.Home)
mdAction, err := agents.UpsertMarkedBlock(w, mdPath, "",
agents.GlobalRulesStartMarker, agents.GlobalRulesEndMarker, opts)
count(mdAction, err, mdPath)
// 5. skills / commands / agents — delete the owned files.
ownedRemoved, ownedFailures := removeGlobalOwnedFiles(w, env.Home, opts)
removed += ownedRemoved
failures = append(failures, ownedFailures...)
// 6. Generated instruction profiles — gortex-owned generated
// files (plus the tiny active-state record), safe to delete.
insDir := instructionsDir(env)
if _, err := os.Stat(insDir); err == nil {
if opts.DryRun {
removed++
} else if err := profiles.Remove(insDir); err != nil {
failures = append(failures, fmt.Sprintf("%s: %v", insDir, err))
} else {
logf(w, "[gortex] removed instruction profiles at %s", insDir)
removed++
}
}
return removed, failures
}
// GlobalArtifacts returns the user-level Claude Code paths gortex
// install manages that currently carry a Gortex footprint — the MCP
// config, settings files, and CLAUDE.md when they contain Gortex
// entries, plus every installed skill / command / sub-agent. Sorted
// for stable output. The config root honors the --claude-config-dir
// override / $CLAUDE_CONFIG_DIR. Used by `gortex uninstall --global`
// to preview the blast radius before deleting.
func GlobalArtifacts(home string) []string {
configDir := userClaudeConfigDir(home)
var present []string
// Merged files: list only when they actually carry a Gortex
// footprint, so the preview (and its count) matches what
// RemoveGlobal will really touch.
if fileContains(userClaudeJSONPath(home), `"gortex"`) {
present = append(present, userClaudeJSONPath(home))
}
if fileContains(userSettingsPath(home), "mcp__gortex__") {
present = append(present, userSettingsPath(home))
}
if fileContains(userSettingsLocalPath(home), "gortex") {
present = append(present, userSettingsLocalPath(home))
}
if fileContains(userClaudeMdPath(home), agents.GlobalRulesStartMarker) {
present = append(present, userClaudeMdPath(home))
}
// Owned files: present on disk == installed by us.
for name := range GlobalSkills {
if p := filepath.Join(configDir, "skills", name); pathExists(p) {
present = append(present, p)
}
}
for name := range SlashCommands {
if p := filepath.Join(configDir, "commands", name); pathExists(p) {
present = append(present, p)
}
}
for name := range SubAgents {
if p := filepath.Join(configDir, "agents", name); pathExists(p) {
present = append(present, p)
}
}
sort.Strings(present)
return present
}
// removeGlobalMCPConfig drops the gortex MCP server from a
// {"mcpServers": {...}} config, leaving the user's other servers
// intact. A missing file or absent stanza is a no-op skip.
func removeGlobalMCPConfig(w io.Writer, path string, opts agents.ApplyOpts) (agents.FileAction, error) {
return agents.MergeJSON(w, path, func(root map[string]any, _ bool) (bool, error) {
return agents.RemoveMCPServer(root, "gortex"), nil
}, opts)
}
// removeGlobalPermissions drops the mcp__gortex__* entry from
// permissions.allow, pruning the allow list / permissions map when
// removal leaves them empty. User-added allow entries survive.
func removeGlobalPermissions(w io.Writer, settingsPath string, opts agents.ApplyOpts) (agents.FileAction, error) {
return agents.MergeJSON(w, settingsPath, func(settings map[string]any, _ bool) (bool, error) {
perms, ok := settings["permissions"].(map[string]any)
if !ok {
return false, nil
}
allow, ok := perms["allow"].([]any)
if !ok {
return false, nil
}
kept := make([]any, 0, len(allow))
removedAny := false
for _, entry := range allow {
if s, ok := entry.(string); ok && strings.Contains(s, "mcp__gortex__") {
removedAny = true
continue
}
kept = append(kept, entry)
}
if !removedAny {
return false, nil
}
if len(kept) == 0 {
delete(perms, "allow")
} else {
perms["allow"] = kept
}
if len(perms) == 0 {
delete(settings, "permissions")
}
return true, nil
}, opts)
}
// removeGlobalHooks drops every Gortex-owned hook entry across the
// events the installer writes, pruning the hooks map when removal
// leaves it empty. Hooks owned by other tools survive.
func removeGlobalHooks(w io.Writer, settingsPath string, opts agents.ApplyOpts) (agents.FileAction, error) {
return agents.MergeJSON(w, settingsPath, func(settings map[string]any, _ bool) (bool, error) {
hooks, ok := settings["hooks"].(map[string]any)
if !ok {
return false, nil
}
removed := 0
for _, event := range []string{"PreToolUse", "PreCompact", "PostToolUse", "Stop", "SessionStart", "UserPromptSubmit"} {
removed += removeGortexHookEntries(hooks, event)
}
if removed == 0 {
return false, nil
}
if len(hooks) == 0 {
delete(settings, "hooks")
}
return true, nil
}, opts)
}
// removeGlobalOwnedFiles deletes the user-level skills / commands /
// sub-agents the installer owns. Skills are directories
// ($DIR/skills/<name>/); commands and agents are single files. A
// not-yet-installed artifact is silently skipped; DryRun counts the
// target without touching disk.
func removeGlobalOwnedFiles(w io.Writer, home string, opts agents.ApplyOpts) (removed int, failures []string) {
configDir := userClaudeConfigDir(home)
type target struct {
path string
dir bool
}
var targets []target
for name := range GlobalSkills {
targets = append(targets, target{filepath.Join(configDir, "skills", name), true})
}
for name := range SlashCommands {
targets = append(targets, target{filepath.Join(configDir, "commands", name), false})
}
for name := range SubAgents {
targets = append(targets, target{filepath.Join(configDir, "agents", name), false})
}
for _, t := range targets {
if !pathExists(t.path) {
continue // not installed — nothing to remove
}
if opts.DryRun {
removed++
continue
}
var err error
if t.dir {
err = os.RemoveAll(t.path)
} else {
err = os.Remove(t.path)
}
if err != nil {
failures = append(failures, fmt.Sprintf("%s: %v", t.path, err))
continue
}
logf(w, "[gortex uninstall] removed %s", t.path)
removed++
}
return removed, failures
}
func fileContains(path, needle string) bool {
data, err := os.ReadFile(path)
if err != nil {
return false
}
return strings.Contains(string(data), needle)
}
func pathExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// installPermissions merges an {"permissions": {"allow":
// ["mcp__gortex__*"]}} stanza into settings.json. Preserves any
// user-added entries; short-circuits when a gortex rule is already
// present.
func installPermissions(w io.Writer, settingsPath string, opts agents.ApplyOpts) (agents.FileAction, error) {
return agents.MergeJSON(w, settingsPath, func(settings map[string]any, _ bool) (bool, error) {
// Bail early if a gortex rule is already present.
if perms, ok := settings["permissions"].(map[string]any); ok {
if allow, ok := perms["allow"].([]any); ok {
for _, entry := range allow {
if s, ok := entry.(string); ok && strings.Contains(s, "mcp__gortex__") {
return false, nil
}
}
}
}
if _, ok := settings["permissions"]; !ok {
settings["permissions"] = make(map[string]any)
}
perms := settings["permissions"].(map[string]any)
if _, ok := perms["allow"]; !ok {
perms["allow"] = []any{}
}
allow := perms["allow"].([]any)
perms["allow"] = append(allow, "mcp__gortex__*")
return true, nil
}, opts)
}
// instructionsDir resolves where the generated instruction profiles
// live for this install run: the Env override (tests) or the machine
// default shared with the daemon and the `gortex instructions` verb.
func instructionsDir(env agents.Env) string {
if env.InstructionsDir != "" {
return env.InstructionsDir
}
return profiles.DefaultDir()
}
// SyncGlobalSkills reconciles ~/.claude/skills/gortex-* with the
// allowed subset (nil = every shipped skill):
//
// - allowed skills are installed when missing; an existing file is
// never overwritten, so user edits survive;
// - shipped skills OUTSIDE the subset are removed only when their
// on-disk SKILL.md is still byte-identical to the shipped body —
// a customised copy is kept (ActionSkip) with a warning, because
// deleting user work to enforce a profile is never worth it.
//
// Also used by `gortex instructions switch` to re-shape the installed
// skill surface when the active profile changes.
func SyncGlobalSkills(w io.Writer, home string, allowed []string, opts agents.ApplyOpts) ([]agents.FileAction, error) {
var allowedSet map[string]bool
if allowed != nil {
allowedSet = make(map[string]bool, len(allowed))
for _, name := range allowed {
allowedSet[name] = true
}
}
out := make([]agents.FileAction, 0, len(GlobalSkills))
skillsDir := filepath.Join(userClaudeConfigDir(home), "skills")
for name, content := range GlobalSkills {
dir := filepath.Join(skillsDir, name)
path := filepath.Join(dir, "SKILL.md")
if allowedSet != nil && !allowedSet[name] {
existing, err := os.ReadFile(path)
if err != nil {
// Not installed (or unreadable): nothing to prune.
out = append(out, agents.FileAction{Path: path, Action: agents.ActionSkip, Reason: "outside-profile"})
continue
}
if string(existing) != content {
logWarn(w, "keeping customised skill %s (outside the active profile)", path)
out = append(out, agents.FileAction{Path: path, Action: agents.ActionSkip, Reason: "customised"})
continue
}
if opts.DryRun {
out = append(out, agents.FileAction{Path: path, Action: agents.ActionWouldDelete, Keys: []string{"skill"}})
continue
}
if err := os.RemoveAll(dir); err != nil {
return out, err
}
logf(w, "[gortex] removed skill %s (outside the active profile)", path)
out = append(out, agents.FileAction{Path: path, Action: agents.ActionDelete, Keys: []string{"skill"}})
continue
}
action, err := agents.WriteIfNotExists(w, path, content, opts)
if err != nil {
return out, err
}
out = append(out, action)
}
return out, nil
}
// installGlobalSlashCommands writes ~/.claude/commands/gortex-*.md
// for each entry in SlashCommands. Skips existing files so users
// keep any local tweaks. Mirrors installGlobalSkills — both are
// user-level, codebase-agnostic artifacts installed by
// `gortex install`.
func installGlobalSlashCommands(w io.Writer, home string, opts agents.ApplyOpts) ([]agents.FileAction, error) {
out := make([]agents.FileAction, 0, len(SlashCommands))
dir := filepath.Join(userClaudeConfigDir(home), "commands")
for name, content := range SlashCommands {
path := filepath.Join(dir, name)
action, err := agents.WriteIfNotExists(w, path, content, opts)
if err != nil {
return out, err
}
out = append(out, action)
}
return out, nil
}
// installGlobalSubAgents writes ~/.claude/agents/gortex-*.md for each
// entry in SubAgents. Skips existing files so user tweaks to the
// frontmatter (description tuning, tool allowlist edits) survive
// re-installs. Mirrors installGlobalSkills / installGlobalSlashCommands.
func installGlobalSubAgents(w io.Writer, home string, opts agents.ApplyOpts) ([]agents.FileAction, error) {
out := make([]agents.FileAction, 0, len(SubAgents))
dir := filepath.Join(userClaudeConfigDir(home), "agents")
for name, content := range SubAgents {
path := filepath.Join(dir, name)
action, err := agents.WriteIfNotExists(w, path, content, opts)
if err != nil {
return out, err
}
out = append(out, action)
}
return out, nil
}
// upsertGlobalMCPConfig is the user-level (~/.claude.json) MCP stanza
// installer. Unlike the project-level .mcp.json we never write from
// scratch with a static string here — we always merge so we don't
// clobber a user's other MCP servers or their permissions. If the
// existing file is malformed JSON, it's backed up before we
// overwrite.
func upsertGlobalMCPConfig(w io.Writer, path string, opts agents.ApplyOpts) (agents.FileAction, error) {
// Prefer the bare "gortex" command when it resolves on PATH to the
// binary we're running, so this user-scope entry matches the
// portable project .mcp.json template byte-for-byte. Claude Code
// keys OAuth tokens per endpoint, so a user-scope stanza that
// disagrees with a project-scope one trips its "conflicting scopes"
// diagnostic. Falls back to the absolute path only when gortex is
// not on PATH (e.g. a Windows install whose dir isn't on PATH).
entry := map[string]any{
"command": agents.ResolveGortexCommand(),
"args": []string{"mcp"},
"env": map[string]any{},
}
// Try a direct merge first. MergeJSON handles malformed JSON with a
// timestamped backup already. UpsertMCPServerWithMigration rewrites
// a stale Gortex-authored stanza (including the older absolute-path
// form) in place without clobbering a user's hand-rolled wrapper.
action, err := agents.MergeJSON(w, path, func(root map[string]any, existed bool) (bool, error) {
_ = existed
return agents.UpsertMCPServerWithMigration(root, "gortex", entry, agents.ApplyOpts{Force: opts.Force}), nil
}, opts)
if err != nil {
return agents.FileAction{}, err
}
// Historical behaviour: if the file existed but was malformed,
// rename it to a .bak-<ts> to preserve the original. MergeJSON
// uses a plain ".bak" name; we mirror the timestamp-suffix
// convention for global mode because the user is more likely
// to have edited ~/.claude.json than a project .mcp.json.
if existing, statErr := os.Stat(path + ".bak"); statErr == nil && !existing.IsDir() {
if err := os.Rename(path+".bak", fmt.Sprintf("%s.bak-%d", path, time.Now().Unix())); err != nil {
logWarn(w, "could not timestamp malformed-config backup: %v", err)
}
}
return action, nil
}
// userScopeGortexRegistered reports whether ~/.claude.json already
// registers a "gortex" MCP server at user scope. A project .mcp.json
// written on top of that produces a second registration under the same
// name, which Claude Code flags as a "conflicting scopes" warning
// because it stores OAuth tokens per endpoint. A missing or malformed
// file is treated as "not registered" — we only suppress the project
// write on positive evidence of a user-scope entry.
func userScopeGortexRegistered(home string) bool {
data, err := os.ReadFile(userClaudeJSONPath(home))
if err != nil {
return false
}
var root map[string]any
if err := json.Unmarshal(data, &root); err != nil {
return false
}
servers, ok := root["mcpServers"].(map[string]any)
if !ok {
return false
}
_, ok = servers["gortex"]
return ok
}
// Paths — user-level files.
func userClaudeJSONPath(home string) string {
if dir := claudeConfigDirOverride(); dir != "" {
return filepath.Join(dir, ".claude.json")
}
return filepath.Join(home, ".claude.json")
}
func userSettingsLocalPath(home string) string {
return filepath.Join(userClaudeConfigDir(home), "settings.local.json")
}
// userSettingsPath is the user-level counterpart to
// `.claude/settings.json` in a project. Permissions live here (not
// in settings.local.json) so they survive when the user wipes the
// "local" overrides file.
func userSettingsPath(home string) string {
return filepath.Join(userClaudeConfigDir(home), "settings.json")
}
// userClaudeMdPath is the machine-wide CLAUDE.md Claude Code reads on
// every session, regardless of cwd. We merge a marker-fenced rule
// block into it so the agent sees the Gortex rules from turn one.
func userClaudeMdPath(home string) string {
return filepath.Join(userClaudeConfigDir(home), "CLAUDE.md")
}
// UserClaudeMdPath is the resolved user-level CLAUDE.md path Claude
// Code reads every session, honoring the --claude-config-dir override
// / $CLAUDE_CONFIG_DIR / the ~/.claude default. Exported so the
// installer banner names the real destination instead of a hardcoded
// ~/.claude path.
func UserClaudeMdPath(home string) string { return userClaudeMdPath(home) }
func userClaudeConfigDir(home string) string {
if dir := claudeConfigDirOverride(); dir != "" {
return dir
}
return filepath.Join(home, ".claude")
}
// configDirOverride, when non-empty, pins the Claude Code config root
// for the rest of the process regardless of $CLAUDE_CONFIG_DIR. It is
// set by `gortex install --claude-config-dir` / `gortex uninstall
// --global --claude-config-dir` so an operator can target a
// non-active profile or a CI sandbox without exporting the env var.
// Precedence: flag override > $CLAUDE_CONFIG_DIR > ~/.claude default.
var configDirOverride string
// SetConfigDirOverride pins the Claude Code config root for the rest
// of this process. An empty string clears the override so the env var
// / default resume.
func SetConfigDirOverride(dir string) { configDirOverride = strings.TrimSpace(dir) }
func claudeConfigDirOverride() string {
if configDirOverride != "" {
return configDirOverride
}
return strings.TrimSpace(os.Getenv("CLAUDE_CONFIG_DIR"))
}
func logf(w io.Writer, format string, args ...any) {
if w == nil {
return
}
fmt.Fprintf(w, format+"\n", args...)
}
func logWarn(w io.Writer, format string, args ...any) {
if w == nil {
return
}
fmt.Fprintf(w, "[gortex init] warning: "+format+"\n", args...)
}
+504
View File
@@ -0,0 +1,504 @@
package claudecode
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/agentstest"
)
// TestClaudeCodeProjectModeCreatesCanonicalArtifacts is the
// acceptance test for the most important adapter. It asserts that
// a fresh project gets:
// - .mcp.json with our server stanza
// - .claude/settings.json with MCP permissions
// - .claude/settings.local.json with the three hook events
// - CLAUDE.md with the marker-guarded communities block (since
// the test env seeds SkillsRouting)
// - .claude/skills/generated/<DirName>/SKILL.md (one per
// GeneratedSkill)
//
// Slash commands and the curated GlobalSkills are NOT written in
// project mode anymore — they're user-level artifacts installed by
// `gortex install`. TestClaudeCodeGlobalModeWritesUserFiles covers
// those.
//
// Re-running must be a no-op (idempotent contract).
func TestClaudeCodeProjectModeCreatesCanonicalArtifacts(t *testing.T) {
env, _ := agentstest.NewEnv(t)
a := New()
res, err := a.Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
if !res.Configured {
t.Fatal("expected Configured=true")
}
expected := []string{
filepath.Join(env.Root, ".mcp.json"),
filepath.Join(env.Root, ".claude", "settings.json"),
filepath.Join(env.Root, ".claude", "settings.local.json"),
filepath.Join(env.Root, "CLAUDE.md"),
}
for _, s := range env.GeneratedSkills {
expected = append(expected, filepath.Join(env.Root, ".claude", "skills", "generated", s.DirName, "SKILL.md"))
}
for _, p := range expected {
if _, err := os.Stat(p); err != nil {
t.Errorf("missing artifact %s: %v", p, err)
}
}
// Project-mode must NOT touch the user-level slash commands or
// curated skills — those live in install mode now.
for name := range SlashCommands {
if _, err := os.Stat(filepath.Join(env.Home, ".claude", "commands", name)); err == nil {
t.Errorf("project mode unexpectedly wrote user-level slash command %s", name)
}
if _, err := os.Stat(filepath.Join(env.Root, ".claude", "commands", name)); err == nil {
t.Errorf("project mode unexpectedly wrote project-level slash command %s", name)
}
}
for name := range GlobalSkills {
if _, err := os.Stat(filepath.Join(env.Home, ".claude", "skills", name, "SKILL.md")); err == nil {
t.Errorf("project mode unexpectedly wrote user-level skill %s", name)
}
}
for name := range SubAgents {
if _, err := os.Stat(filepath.Join(env.Home, ".claude", "agents", name)); err == nil {
t.Errorf("project mode unexpectedly wrote user-level sub-agent %s", name)
}
if _, err := os.Stat(filepath.Join(env.Root, ".claude", "agents", name)); err == nil {
t.Errorf("project mode unexpectedly wrote project-level sub-agent %s", name)
}
}
// CLAUDE.md must contain the communities-block markers (since
// the stub SkillsRouting routes through UpsertMarkedBlock).
claudeMd, _ := os.ReadFile(filepath.Join(env.Root, "CLAUDE.md"))
if !strings.Contains(string(claudeMd), agents.CommunitiesStartMarker) {
t.Fatalf("CLAUDE.md missing communities start marker: %s", claudeMd)
}
// Hooks file must reference our test hook command.
hooksFile, _ := os.ReadFile(filepath.Join(env.Root, ".claude", "settings.local.json"))
if !strings.Contains(string(hooksFile), "PreToolUse") {
t.Fatalf("settings.local.json missing PreToolUse: %s", hooksFile)
}
// Idempotent re-run: every file should report skip.
res2, err := a.Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("second apply: %v", err)
}
for _, f := range res2.Files {
if f.Action != agents.ActionSkip {
t.Errorf("expected skip on re-run for %s, got %s", f.Path, f.Action)
}
}
}
// TestClaudeCodeGlobalModeWritesUserFiles verifies that global mode
// (entered via `gortex install`) writes to ~/.claude.json, user-level
// hooks, and the user-level slash-commands + curated skills trees,
// while leaving the per-repo tree alone.
func TestClaudeCodeGlobalModeWritesUserFiles(t *testing.T) {
env, _ := agentstest.NewEnv(t)
env.Mode = agents.ModeGlobal
env.InstallGlobalInstructions = true
a := New()
res, err := a.Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
if !res.Configured {
t.Fatal("expected Configured=true in global mode")
}
// User-level files exist.
expected := []string{
filepath.Join(env.Home, ".claude.json"),
filepath.Join(env.Home, ".claude", "settings.json"),
filepath.Join(env.Home, ".claude", "settings.local.json"),
}
for name := range SlashCommands {
expected = append(expected, filepath.Join(env.Home, ".claude", "commands", name))
}
for name := range GlobalSkills {
expected = append(expected, filepath.Join(env.Home, ".claude", "skills", name, "SKILL.md"))
}
for name := range SubAgents {
expected = append(expected, filepath.Join(env.Home, ".claude", "agents", name))
}
for _, p := range expected {
if _, err := os.Stat(p); err != nil {
t.Errorf("missing user-level artifact %s: %v", p, err)
}
}
// settings.json must contain the mcp__gortex__* permission rule
// so MCP tool calls don't prompt for approval each session.
settingsPath := filepath.Join(env.Home, ".claude", "settings.json")
body, err := os.ReadFile(settingsPath)
if err != nil {
t.Fatalf("read %s: %v", settingsPath, err)
}
if !strings.Contains(string(body), "mcp__gortex__*") {
t.Errorf("expected mcp__gortex__* in %s, got:\n%s", settingsPath, body)
}
// CLAUDE.md must contain the marker-fenced rule block when
// InstallGlobalInstructions is true.
claudeMd := filepath.Join(env.Home, ".claude", "CLAUDE.md")
mdBody, err := os.ReadFile(claudeMd)
if err != nil {
t.Fatalf("read %s: %v", claudeMd, err)
}
if !strings.Contains(string(mdBody), agents.GlobalRulesStartMarker) ||
!strings.Contains(string(mdBody), agents.GlobalRulesEndMarker) {
t.Errorf("expected gortex marker block in %s, got:\n%s", claudeMd, mdBody)
}
if !strings.Contains(string(mdBody), "MANDATORY: Use Gortex MCP tools") {
t.Errorf("expected rule body in %s, got:\n%s", claudeMd, mdBody)
}
// Per-repo files should *not* exist under global mode.
for _, p := range []string{
filepath.Join(env.Root, ".mcp.json"),
filepath.Join(env.Root, "CLAUDE.md"),
filepath.Join(env.Root, ".claude", "settings.local.json"),
} {
if _, err := os.Stat(p); err == nil {
t.Errorf("global mode unexpectedly wrote per-repo file %s", p)
}
}
}
func TestClaudeCodeGlobalModeHonorsClaudeConfigDir(t *testing.T) {
env, _ := agentstest.NewEnv(t)
env.Mode = agents.ModeGlobal
env.InstallGlobalInstructions = true
configDir := filepath.Join(t.TempDir(), "work-profile")
t.Setenv("CLAUDE_CONFIG_DIR", configDir)
a := New()
res, err := a.Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
if !res.Configured {
t.Fatal("expected Configured=true in global mode")
}
expected := []string{
filepath.Join(configDir, ".claude.json"),
filepath.Join(configDir, "settings.json"),
filepath.Join(configDir, "settings.local.json"),
filepath.Join(configDir, "CLAUDE.md"),
}
for name := range SlashCommands {
expected = append(expected, filepath.Join(configDir, "commands", name))
}
for name := range GlobalSkills {
expected = append(expected, filepath.Join(configDir, "skills", name, "SKILL.md"))
}
for name := range SubAgents {
expected = append(expected, filepath.Join(configDir, "agents", name))
}
for _, p := range expected {
if _, err := os.Stat(p); err != nil {
t.Errorf("missing CLAUDE_CONFIG_DIR artifact %s: %v", p, err)
}
}
for _, p := range []string{
filepath.Join(env.Home, ".claude.json"),
filepath.Join(env.Home, ".claude"),
} {
if _, err := os.Stat(p); err == nil {
t.Errorf("global mode unexpectedly wrote HOME artifact %s", p)
}
}
plan, err := a.Plan(env)
if err != nil {
t.Fatalf("plan: %v", err)
}
for _, f := range plan.Files {
if !strings.HasPrefix(f.Path, configDir+string(os.PathSeparator)) {
t.Errorf("planned path %s did not use CLAUDE_CONFIG_DIR %s", f.Path, configDir)
}
}
}
// TestClaudeCodeConfigDirOverrideBeatsEnv verifies the explicit
// override (set by `gortex install --claude-config-dir`) wins over
// $CLAUDE_CONFIG_DIR, and that neither the env dir nor HOME is touched
// when the override is active.
func TestClaudeCodeConfigDirOverrideBeatsEnv(t *testing.T) {
env, _ := agentstest.NewEnv(t)
env.Mode = agents.ModeGlobal
env.InstallGlobalInstructions = true
envDir := filepath.Join(t.TempDir(), "env-profile")
overrideDir := filepath.Join(t.TempDir(), "flag-profile")
t.Setenv("CLAUDE_CONFIG_DIR", envDir)
SetConfigDirOverride(overrideDir)
t.Cleanup(func() { SetConfigDirOverride("") })
a := New()
if _, err := a.Apply(env, agents.ApplyOpts{}); err != nil {
t.Fatalf("apply: %v", err)
}
for _, p := range []string{
filepath.Join(overrideDir, ".claude.json"),
filepath.Join(overrideDir, "settings.json"),
filepath.Join(overrideDir, "CLAUDE.md"),
} {
if _, err := os.Stat(p); err != nil {
t.Errorf("missing override artifact %s: %v", p, err)
}
}
if _, err := os.Stat(envDir); err == nil {
t.Errorf("env-profile dir should be untouched when the override is set")
}
if _, err := os.Stat(filepath.Join(env.Home, ".claude")); err == nil {
t.Errorf("HOME .claude should be untouched when the override is set")
}
}
// TestClaudeCodeRemoveGlobal is the round-trip contract for `gortex
// uninstall --global`: after an install, RemoveGlobal must delete the
// owned skills/commands/agents and strip the Gortex portion of every
// merged file while preserving the user's own content (other MCP
// servers, personal CLAUDE.md prose). GlobalArtifacts must report an
// empty footprint afterward.
func TestClaudeCodeRemoveGlobal(t *testing.T) {
env, _ := agentstest.NewEnv(t)
env.Mode = agents.ModeGlobal
env.InstallGlobalInstructions = true
configDir := filepath.Join(env.Home, ".claude")
if err := os.MkdirAll(configDir, 0o755); err != nil {
t.Fatal(err)
}
// A non-gortex MCP server and personal CLAUDE.md prose must survive.
if err := os.WriteFile(filepath.Join(env.Home, ".claude.json"),
[]byte(`{"mcpServers":{"other":{"command":"x"}}}`), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(configDir, "CLAUDE.md"),
[]byte("# My rules\n\nBe terse.\n"), 0o644); err != nil {
t.Fatal(err)
}
a := New()
if _, err := a.Apply(env, agents.ApplyOpts{}); err != nil {
t.Fatalf("apply: %v", err)
}
if got := GlobalArtifacts(env.Home); len(got) == 0 {
t.Fatal("expected a non-empty footprint after install")
}
removed, failures := a.RemoveGlobal(env, agents.ApplyOpts{})
if len(failures) != 0 {
t.Fatalf("RemoveGlobal failures: %v", failures)
}
if removed == 0 {
t.Fatal("expected RemoveGlobal to remove at least one artifact")
}
// Owned files deleted.
for name := range GlobalSkills {
if _, err := os.Stat(filepath.Join(configDir, "skills", name)); err == nil {
t.Errorf("skill %s not removed", name)
}
}
for name := range SlashCommands {
if _, err := os.Stat(filepath.Join(configDir, "commands", name)); err == nil {
t.Errorf("command %s not removed", name)
}
}
for name := range SubAgents {
if _, err := os.Stat(filepath.Join(configDir, "agents", name)); err == nil {
t.Errorf("sub-agent %s not removed", name)
}
}
// CLAUDE.md: gortex block stripped, personal prose preserved.
md, _ := os.ReadFile(filepath.Join(configDir, "CLAUDE.md"))
if strings.Contains(string(md), agents.GlobalRulesStartMarker) {
t.Errorf("CLAUDE.md still carries the gortex marker:\n%s", md)
}
if !strings.Contains(string(md), "Be terse.") {
t.Errorf("CLAUDE.md lost personal content:\n%s", md)
}
// settings.json: gortex permission gone.
if settings, _ := os.ReadFile(filepath.Join(configDir, "settings.json")); strings.Contains(string(settings), "mcp__gortex__") {
t.Errorf("settings.json still allows mcp__gortex__:\n%s", settings)
}
// settings.local.json: gortex hooks gone.
if local, _ := os.ReadFile(filepath.Join(configDir, "settings.local.json")); strings.Contains(string(local), "gortex") {
t.Errorf("settings.local.json still references gortex:\n%s", local)
}
// .claude.json: gortex server gone, the user's other server kept.
mcp, _ := os.ReadFile(filepath.Join(env.Home, ".claude.json"))
if strings.Contains(string(mcp), `"gortex"`) {
t.Errorf(".claude.json still has the gortex server:\n%s", mcp)
}
if !strings.Contains(string(mcp), `"other"`) {
t.Errorf(".claude.json lost the user's other server:\n%s", mcp)
}
// Nothing left to clean.
if got := GlobalArtifacts(env.Home); len(got) != 0 {
t.Errorf("GlobalArtifacts should be empty after RemoveGlobal, got %v", got)
}
}
// TestClaudeCodeGlobalMode_NoClaudeMd skips the rule block when the
// caller opts out via InstallGlobalInstructions=false (i.e.
// `gortex install --no-claude-md`).
func TestClaudeCodeGlobalMode_NoClaudeMd(t *testing.T) {
env, _ := agentstest.NewEnv(t)
env.Mode = agents.ModeGlobal
env.InstallGlobalInstructions = false
a := New()
if _, err := a.Apply(env, agents.ApplyOpts{}); err != nil {
t.Fatalf("apply: %v", err)
}
claudeMd := filepath.Join(env.Home, ".claude", "CLAUDE.md")
if _, err := os.Stat(claudeMd); err == nil {
t.Errorf("--no-claude-md ⇒ %s should not exist", claudeMd)
}
}
// TestClaudeCodeGlobalMode_PreservesUserContent verifies the rule
// block is merged with marker fences without clobbering anything
// that already lives in ~/.claude/CLAUDE.md.
func TestClaudeCodeGlobalMode_PreservesUserContent(t *testing.T) {
env, _ := agentstest.NewEnv(t)
env.Mode = agents.ModeGlobal
env.InstallGlobalInstructions = true
claudeMd := filepath.Join(env.Home, ".claude", "CLAUDE.md")
if err := os.MkdirAll(filepath.Dir(claudeMd), 0o755); err != nil {
t.Fatal(err)
}
pre := "# My personal Claude rules\n\nAlways respond in haiku.\n"
if err := os.WriteFile(claudeMd, []byte(pre), 0o644); err != nil {
t.Fatal(err)
}
a := New()
if _, err := a.Apply(env, agents.ApplyOpts{}); err != nil {
t.Fatalf("apply: %v", err)
}
body, err := os.ReadFile(claudeMd)
if err != nil {
t.Fatalf("read: %v", err)
}
if !strings.Contains(string(body), "Always respond in haiku.") {
t.Errorf("user content was clobbered, got:\n%s", body)
}
if !strings.Contains(string(body), agents.GlobalRulesStartMarker) {
t.Errorf("rule block missing, got:\n%s", body)
}
}
// TestClaudeCodeDryRunWritesNothing is the contract for --dry-run:
// Result must still classify every would-be write, but no bytes
// touch disk.
func TestClaudeCodeDryRunWritesNothing(t *testing.T) {
env, _ := agentstest.NewEnv(t)
a := New()
res, err := a.Apply(env, agents.ApplyOpts{DryRun: true})
if err != nil {
t.Fatalf("apply: %v", err)
}
if len(res.Files) == 0 {
t.Fatal("dry-run should still enumerate planned files")
}
// No actual files created.
for _, f := range res.Files {
if _, err := os.Stat(f.Path); err == nil {
t.Errorf("dry-run wrote %s", f.Path)
}
}
}
// TestProjectModeSkipsMCPWhenUserScopeRegistered is the regression
// guard for issue #201: a project `gortex init` must NOT create a
// project .mcp.json when gortex is already registered at user scope —
// that double-registration is what trips Claude Code's "conflicting
// scopes" diagnostic. --force overrides the skip (so a maintainer can
// still commit a .mcp.json for teammates without a global install).
func TestProjectModeSkipsMCPWhenUserScopeRegistered(t *testing.T) {
SetConfigDirOverride("")
env, buf := agentstest.NewEnv(t)
a := New()
// Seed a user-scope gortex registration in ~/.claude.json.
claudeJSON := userClaudeJSONPath(env.Home)
if err := os.MkdirAll(filepath.Dir(claudeJSON), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
seed := `{"mcpServers":{"gortex":{"command":"gortex","args":["mcp"],"env":{}}}}`
if err := os.WriteFile(claudeJSON, []byte(seed), 0o644); err != nil {
t.Fatalf("seed user config: %v", err)
}
mcpPath := filepath.Join(env.Root, ".mcp.json")
// Without --force: the project .mcp.json must be skipped and the
// user warned.
if _, err := a.Apply(env, agents.ApplyOpts{}); err != nil {
t.Fatalf("apply: %v", err)
}
if _, err := os.Stat(mcpPath); err == nil {
t.Errorf("project .mcp.json was written despite a user-scope gortex registration")
}
if !strings.Contains(buf.String(), "already registered at user scope") {
t.Errorf("expected a conflicting-scopes warning, got: %q", buf.String())
}
// With --force: the project .mcp.json is written anyway.
if _, err := a.Apply(env, agents.ApplyOpts{Force: true}); err != nil {
t.Fatalf("apply --force: %v", err)
}
if _, err := os.Stat(mcpPath); err != nil {
t.Errorf("--force should still write project .mcp.json: %v", err)
}
}
// TestProjectModeWritesMCPWhenNoUserScope confirms the common path is
// unchanged: with no user-scope gortex registration, the project
// .mcp.json is created as before.
func TestProjectModeWritesMCPWhenNoUserScope(t *testing.T) {
SetConfigDirOverride("")
env, _ := agentstest.NewEnv(t)
a := New()
if _, err := a.Apply(env, agents.ApplyOpts{}); err != nil {
t.Fatalf("apply: %v", err)
}
if _, err := os.Stat(filepath.Join(env.Root, ".mcp.json")); err != nil {
t.Errorf("project .mcp.json should be written when no user-scope entry exists: %v", err)
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,45 @@
package claudecode
import (
"strings"
"testing"
)
// TestCommandPRReviewAgent_ShellsTheReviewVerb asserts the agent-review skill
// instructs a coding agent to shell the review verb in its terse audience mode.
// The whole point of the skill is to replace hand-walking the review gates with
// a single `gortex review --audience agent` call, so that exact invocation must
// appear in the generated content.
func TestCommandPRReviewAgent_ShellsTheReviewVerb(t *testing.T) {
for _, want := range []string{
"gortex review --audience agent",
"--format json",
"VERDICT:",
"file:line",
} {
if !strings.Contains(commandPRReviewAgent, want) {
t.Errorf("commandPRReviewAgent must reference %q so the agent shells the verb and parses its output", want)
}
}
}
// TestCommandPRReviewAgent_Registered asserts the agent-review skill is wired
// into both the slash-command registry and the global-skill registry under
// matching names, so the plugin emitter and every adapter pick it up.
func TestCommandPRReviewAgent_Registered(t *testing.T) {
if got := SlashCommands["gortex-pr-review-agent.md"]; got != commandPRReviewAgent {
t.Error("gortex-pr-review-agent.md must map to commandPRReviewAgent in SlashCommands")
}
skill, ok := GlobalSkills["gortex-pr-review-agent"]
if !ok {
t.Fatal("gortex-pr-review-agent must be registered in GlobalSkills")
}
// The skill body is the frontmatter + the command body; the command body
// must be present verbatim so a drift edit can't silently desync them.
if !strings.Contains(skill, commandPRReviewAgent) {
t.Error("gortex-pr-review-agent skill body must embed commandPRReviewAgent")
}
if !strings.HasPrefix(skill, "---\nname: gortex-pr-review-agent\n") {
t.Error("gortex-pr-review-agent skill must carry matching frontmatter")
}
}
+627
View File
@@ -0,0 +1,627 @@
package claudecode
import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"slices"
"strings"
"github.com/zzet/gortex/internal/agents"
)
// CurrentPreToolUseMatcher is the canonical matcher pattern we bake
// into Claude Code's PreToolUse hook. Older versions used
// "Read|Grep", "Read|Grep|Glob", "Read|Grep|Glob|Task",
// "Read|Grep|Glob|Task|Bash", or "Read|Grep|Glob|Task|Bash|Edit|Write";
// upgradeGortexMatcher rewrites those in place. Edit and Write are
// included so the hook can redirect whole-file rewrites of indexed
// source to the Gortex MCP edit tools (gated by GORTEX_HOOK_BLOCK_EDIT
// in the hook itself). The two mcp__gortex__ read tools are included so
// the hook can also nudge a full-body read_file / get_editing_context
// toward compress_bodies / search_text — the one gap that fires once
// the agent is already inside a Gortex tool (gated by
// GORTEX_HOOK_FORCE_COMPRESS for the hard-deny posture).
const CurrentPreToolUseMatcher = "Read|Grep|Glob|Task|Bash|Edit|Write|mcp__gortex__read_file|mcp__gortex__get_editing_context"
// CurrentPostToolUseMatcher names the tools whose response the
// PostToolUse hook augments. Only the read-shaped tools have an obvious
// "enrich this output with graph context" payload — Bash / Edit / Write
// don't benefit from a post-call graph snapshot, so they're omitted.
const CurrentPostToolUseMatcher = "Read|Grep|Glob"
// HookModeDeny / HookModeEnrich / HookModeConsultUnlock /
// HookModeAdaptiveNudge are the posture strings the installer accepts.
// They mirror hooks.Mode without importing it (the claudecode package
// is a leaf of the agents adapter tree and must stay import-free of
// hooks).
const (
HookModeDeny = "deny"
HookModeEnrich = "enrich"
HookModeConsultUnlock = "consult-unlock"
HookModeAdaptiveNudge = "nudge"
)
// normalizeHookMode maps user input to a canonical mode. Empty or
// unknown values fall through to deny so existing installs and shell
// typos preserve the original behavior. "adaptive-nudge" is accepted
// as an alias for the canonical "nudge".
func normalizeHookMode(mode string) string {
switch strings.ToLower(strings.TrimSpace(mode)) {
case HookModeEnrich:
return HookModeEnrich
case HookModeConsultUnlock:
return HookModeConsultUnlock
case HookModeAdaptiveNudge, "adaptive-nudge":
return HookModeAdaptiveNudge
default:
return HookModeDeny
}
}
// hookCommandWithMode appends `--mode=<mode>` to the base hook command
// when mode is non-default. The deny mode is the historical default —
// emitting it bare keeps existing settings.json diffs minimal during an
// upgrade. Every other posture is emitted explicitly so the installed
// command unambiguously declares itself.
func hookCommandWithMode(base, mode string) string {
switch normalizeHookMode(mode) {
case HookModeEnrich:
return base + " --mode=enrich"
case HookModeConsultUnlock:
return base + " --mode=consult-unlock"
case HookModeAdaptiveNudge:
return base + " --mode=nudge"
default:
return base
}
}
// ResolveHookCommand returns the shell command to bake into Claude
// Code's hook config. It routes through agents.ResolveGortexHookBinary,
// which shares the same same-file decision core as the MCP server
// stanza (agents.ResolveGortexCommand): the hook and the MCP stanza
// must resolve to the same binary file whenever the running binary is
// usable, so a side-by-side install can never point the hook at a
// different daemon than the one serving the session's graph tools. The
// hook keeps an absolute-path preference (it avoids PATH-at-fire-time
// fragility); it falls back to bare "gortex hook" only when no stable
// binary is resolvable at all.
//
// A warning is written to w on the bare fallback because it relies on
// PATH resolution at hook-fire time — fragile when the user's shell
// environment differs between Claude Code and a terminal.
func ResolveHookCommand(w io.Writer) string {
bin := agents.ResolveGortexHookBinary()
if bin == "gortex" && w != nil {
fmt.Fprintln(w,
"[gortex init] warning: `gortex` not found on PATH; "+
"writing bare \"gortex hook\" into settings — install gortex to PATH for a stable hook command")
}
return shellSafeHookBinary(bin) + " hook"
}
// shellSafeHookBinary normalizes a resolved binary path into a form
// safe to embed in a shell-executed hook command. Claude Code runs
// hooks through a shell; on Windows that shell is Git Bash, which
// treats backslashes as escape characters — a native path like
// C:\Users\me\gortex.exe is mangled to C:Usersmegortex.exe (\U, \m, …
// swallowed) and the hook fails with "command not found". Forward
// slashes survive: Git Bash maps C:/Users/... back to a native path
// when it spawns the .exe. The replacement is unconditional rather
// than Windows-guarded — any backslash in an unquoted shell command is
// a bug regardless of OS, and a real Unix binary path never contains
// one as a separator.
func shellSafeHookBinary(path string) string {
return strings.ReplaceAll(path, "\\", "/")
}
// HookCommandPathIsEphemeral reports whether cmd's binary path lives
// in a location that is wiped between sessions (system tmpdirs, the
// macOS go-build cache) or no longer exists on disk. Used by
// healStaleHookCommands to detect settings.json entries that
// outlived their backing binary.
func HookCommandPathIsEphemeral(cmd string) bool {
fields := strings.Fields(cmd)
if len(fields) == 0 {
return false
}
bin := fields[0]
ephemeralPrefixes := []string{"/tmp/", "/var/folders/", "/private/tmp/", "/private/var/folders/"}
for _, p := range ephemeralPrefixes {
if strings.HasPrefix(bin, p) {
return true
}
}
// An absolute path that no longer resolves to a file is also stale.
if filepath.IsAbs(bin) {
if _, err := os.Stat(bin); err != nil {
return true
}
}
return false
}
// healStaleHookCommands rewrites Gortex hook entries whose command
// points at an ephemeral or missing binary path. Returns the number
// of entries rewritten. Non-Gortex entries are left alone; Gortex
// entries whose path is healthy are also left alone.
func healStaleHookCommands(hooks map[string]any, newCommand string) int {
healed := 0
for _, event := range []string{"PreToolUse", "PreCompact", "Stop", "SessionStart", "UserPromptSubmit"} {
list, ok := hooks[event].([]any)
if !ok {
continue
}
for _, h := range list {
hm, ok := h.(map[string]any)
if !ok {
continue
}
inner, ok := hm["hooks"].([]any)
if !ok {
continue
}
for _, e := range inner {
em, ok := e.(map[string]any)
if !ok {
continue
}
cmd, _ := em["command"].(string)
if !commandInvokesGortexHook(cmd) {
continue
}
if !HookCommandPathIsEphemeral(cmd) {
continue
}
em["command"] = newCommand
healed++
}
}
}
return healed
}
func appendHookEntry(hooks map[string]any, event string, entry map[string]any) {
if _, ok := hooks[event]; !ok {
hooks[event] = []any{}
}
list := hooks[event].([]any)
hooks[event] = append(list, entry)
}
// upgradeGortexMatcher rewrites older PreToolUse matchers to the
// current CurrentPreToolUseMatcher. Returns true when a change was
// made. Handles every historical matcher we've shipped; anything
// not in that set is left alone.
func upgradeGortexMatcher(hooks map[string]any) bool {
pre, ok := hooks["PreToolUse"].([]any)
if !ok {
return false
}
legacyMatchers := map[string]bool{
"Read|Grep": true,
"Read|Grep|Glob": true,
"Read|Grep|Glob|Task": true,
"Read|Grep|Glob|Task|Bash": true,
"Read|Grep|Glob|Task|Bash|Edit|Write": true,
}
upgraded := false
for _, h := range pre {
hm, ok := h.(map[string]any)
if !ok {
continue
}
matcher, _ := hm["matcher"].(string)
if !legacyMatchers[matcher] {
continue
}
if !entryInvokesGortexHook(hm) {
continue
}
hm["matcher"] = CurrentPreToolUseMatcher
upgraded = true
}
return upgraded
}
// entryInvokesGortexHook returns true when any hooks[*].command
// looks like a Gortex hook invocation.
func entryInvokesGortexHook(entry map[string]any) bool {
inner, ok := entry["hooks"].([]any)
if !ok {
return false
}
for _, e := range inner {
em, ok := e.(map[string]any)
if !ok {
continue
}
cmd, _ := em["command"].(string)
if commandInvokesGortexHook(cmd) {
return true
}
}
return false
}
// dedupGortexEntries collapses duplicate Gortex hook entries inside
// hooks[event] down to the first one. Non-Gortex entries are
// preserved in order. Returns the number of duplicates removed.
func dedupGortexEntries(hooks map[string]any, event string) int {
list, ok := hooks[event].([]any)
if !ok {
return 0
}
seenGortex := false
kept := make([]any, 0, len(list))
removed := 0
for _, h := range list {
hm, ok := h.(map[string]any)
if !ok {
kept = append(kept, h)
continue
}
if !entryInvokesGortexHook(hm) {
kept = append(kept, h)
continue
}
if seenGortex {
removed++
continue
}
seenGortex = true
kept = append(kept, h)
}
if removed > 0 {
hooks[event] = kept
}
return removed
}
// commandInvokesGortexHook returns true when cmd is a Gortex hook
// invocation. Splits on whitespace and checks that "hook" is a
// standalone token and that "gortex" appears in the binary path
// component.
func commandInvokesGortexHook(cmd string) bool {
fields := strings.Fields(cmd)
if len(fields) < 2 {
return false
}
if !strings.Contains(strings.ToLower(fields[0]), "gortex") {
return false
}
return slices.Contains(fields[1:], "hook")
}
// rewriteGortexHookMode rewrites every Gortex hook entry's command
// across all events so it matches newCommand. Used when the install
// posture changes (deny ↔ enrich) — the existing entries already
// invoke `gortex hook` but with the wrong `--mode=...` suffix; we
// re-stamp them in place instead of removing + re-adding so user-added
// fields (timeout, statusMessage) are preserved. Returns the count of
// rewritten entries.
func rewriteGortexHookMode(hooks map[string]any, newCommand string) int {
rewritten := 0
for _, event := range []string{"PreToolUse", "PostToolUse", "PreCompact", "Stop", "SessionStart", "UserPromptSubmit"} {
list, ok := hooks[event].([]any)
if !ok {
continue
}
for _, h := range list {
hm, ok := h.(map[string]any)
if !ok {
continue
}
inner, ok := hm["hooks"].([]any)
if !ok {
continue
}
for _, e := range inner {
em, ok := e.(map[string]any)
if !ok {
continue
}
cmd, _ := em["command"].(string)
if !commandInvokesGortexHook(cmd) {
continue
}
if cmd == newCommand {
continue
}
em["command"] = newCommand
rewritten++
}
}
}
return rewritten
}
// removeGortexHookEntries drops every entry under hooks[event] that
// invokes `gortex hook`, preserving entries owned by other tools.
// Returns the number of entries removed. Used to clean up PostToolUse
// when the installer switches back from enrich to deny mode.
func removeGortexHookEntries(hooks map[string]any, event string) int {
list, ok := hooks[event].([]any)
if !ok {
return 0
}
removed := 0
kept := make([]any, 0, len(list))
for _, h := range list {
hm, ok := h.(map[string]any)
if !ok {
kept = append(kept, h)
continue
}
if entryInvokesGortexHook(hm) {
removed++
continue
}
kept = append(kept, h)
}
if removed > 0 {
if len(kept) == 0 {
delete(hooks, event)
} else {
hooks[event] = kept
}
}
return removed
}
// hasGortexHookEntry returns true when the given event already has a
// hook entry that invokes `gortex hook`.
func hasGortexHookEntry(hooks map[string]any, event string) bool {
existing, ok := hooks[event].([]any)
if !ok {
return false
}
for _, h := range existing {
hm, ok := h.(map[string]any)
if !ok {
continue
}
if entryInvokesGortexHook(hm) {
return true
}
}
return false
}
// InstallHook is the top-level "make settings.local.json hooks
// match the current Gortex config" operation. It reads the file,
// heals stale commands, upgrades old matchers, dedupes repeat
// entries, then installs any missing Gortex hooks (PreToolUse,
// PreCompact, Stop, SessionStart, and — in enrich mode — PostToolUse).
// Writes back atomically via the shared helper.
//
// This function intentionally accepts a plain filesystem path
// rather than an Env — the same helper is used for project-level
// (.claude/settings.local.json) and user-level (~/.claude/…) files.
//
// Delegates to InstallHookWithMode with the deny posture for callers
// that don't care about hook mode (mostly tests and back-compat paths).
func InstallHook(w io.Writer, settingsPath string, opts agents.ApplyOpts) (agents.FileAction, error) {
return InstallHookWithMode(w, settingsPath, HookModeDeny, opts)
}
// InstallHookWithMode is the mode-aware variant. mode is one of
// HookModeDeny (default — install PreToolUse with deny behavior, no
// PostToolUse) or HookModeEnrich (install PreToolUse in soft-context
// mode plus a PostToolUse entry that augments tool output with graph
// context). Switching modes between installs rewrites the existing
// Gortex hook command in place and adds or removes the PostToolUse
// entry to match.
func InstallHookWithMode(w io.Writer, settingsPath string, mode string, opts agents.ApplyOpts) (agents.FileAction, error) {
mode = normalizeHookMode(mode)
var settings map[string]any
existed := false
if data, err := os.ReadFile(settingsPath); err == nil {
existed = true
if err := json.Unmarshal(data, &settings); err != nil {
settings = make(map[string]any)
}
} else if !errors.Is(err, os.ErrNotExist) {
return agents.FileAction{}, fmt.Errorf("read %s: %w", settingsPath, err)
} else {
settings = make(map[string]any)
}
baseCommand := ResolveHookCommand(w)
hookCommand := hookCommandWithMode(baseCommand, mode)
if _, ok := settings["hooks"]; !ok {
settings["hooks"] = make(map[string]any)
}
hooks := settings["hooks"].(map[string]any)
healedCount := healStaleHookCommands(hooks, hookCommand)
matcherUpgraded := upgradeGortexMatcher(hooks)
modeRewriteCount := rewriteGortexHookMode(hooks, hookCommand)
dedupedCount := dedupGortexEntries(hooks, "PreToolUse") +
dedupGortexEntries(hooks, "PreCompact") +
dedupGortexEntries(hooks, "PostToolUse") +
dedupGortexEntries(hooks, "Stop") +
dedupGortexEntries(hooks, "SessionStart") +
dedupGortexEntries(hooks, "UserPromptSubmit")
// PostToolUse is only present when mode=enrich. Removing it on a
// switch to any other posture keeps settings.json clean — agents
// won't fire a no-op hook on every Read/Grep response.
postToolUseRemoved := 0
if mode != HookModeEnrich {
postToolUseRemoved = removeGortexHookEntries(hooks, "PostToolUse")
}
preToolUseInstalled := hasGortexHookEntry(hooks, "PreToolUse")
preCompactInstalled := hasGortexHookEntry(hooks, "PreCompact")
stopInstalled := hasGortexHookEntry(hooks, "Stop")
sessionStartInstalled := hasGortexHookEntry(hooks, "SessionStart")
userPromptSubmitInstalled := hasGortexHookEntry(hooks, "UserPromptSubmit")
postToolUseInstalled := hasGortexHookEntry(hooks, "PostToolUse")
if !preToolUseInstalled {
appendHookEntry(hooks, "PreToolUse", map[string]any{
"matcher": CurrentPreToolUseMatcher,
"hooks": []any{
map[string]any{
"type": "command",
"command": hookCommand,
"timeout": 3000,
"statusMessage": "Enriching with Gortex graph context...",
},
},
})
}
if !preCompactInstalled {
appendHookEntry(hooks, "PreCompact", map[string]any{
"hooks": []any{
map[string]any{
"type": "command",
"command": hookCommand,
"timeout": 3000,
"statusMessage": "Injecting Gortex orientation snapshot...",
},
},
})
}
if !stopInstalled {
appendHookEntry(hooks, "Stop", map[string]any{
"hooks": []any{
map[string]any{
"type": "command",
"command": hookCommand,
"timeout": 5000,
"statusMessage": "Running Gortex post-task diagnostics...",
},
},
})
}
if !sessionStartInstalled {
// SessionStart fires at the start of a new or resumed session
// — a perfect moment to inject the Gortex orientation snapshot
// so the first turn doesn't have to call graph_stats. It
// complements PreCompact (which fires on summary boundaries).
appendHookEntry(hooks, "SessionStart", map[string]any{
"hooks": []any{
map[string]any{
"type": "command",
"command": hookCommand,
"timeout": 3000,
"statusMessage": "Loading Gortex graph orientation...",
},
},
})
}
if !userPromptSubmitInstalled {
// UserPromptSubmit fires before every user turn — the moment to
// proactively inject graph symbols relevant to the prompt so the
// model reaches for Gortex instead of grepping. It is best-effort
// and time-bounded; a miss is a silent no-op.
appendHookEntry(hooks, "UserPromptSubmit", map[string]any{
"hooks": []any{
map[string]any{
"type": "command",
"command": hookCommand,
"timeout": 3000,
"statusMessage": "Surfacing relevant Gortex symbols...",
},
},
})
}
// PostToolUse is only installed in enrich mode. It augments
// Grep / Glob / Read responses with graph context (enclosing
// symbols, file footprints) so the agent sees the graph value
// adjacent to the raw output instead of via a deny redirect.
postToolUseAdded := false
if mode == HookModeEnrich && !postToolUseInstalled {
appendHookEntry(hooks, "PostToolUse", map[string]any{
"matcher": CurrentPostToolUseMatcher,
"hooks": []any{
map[string]any{
"type": "command",
"command": hookCommand,
"timeout": 3000,
"statusMessage": "Layering Gortex graph context onto tool output...",
},
},
})
postToolUseAdded = true
}
allPresent := preToolUseInstalled && preCompactInstalled && stopInstalled && sessionStartInstalled &&
userPromptSubmitInstalled && (mode != HookModeEnrich || postToolUseInstalled)
noChanges := allPresent && !matcherUpgraded && dedupedCount == 0 && healedCount == 0 &&
modeRewriteCount == 0 && postToolUseRemoved == 0 && !postToolUseAdded
if noChanges {
if w != nil {
fmt.Fprintf(w, "[gortex init] all hooks already present in %s\n", settingsPath)
}
return agents.FileAction{Path: settingsPath, Action: agents.ActionSkip, Reason: "already-configured"}, nil
}
if opts.DryRun {
action := agents.ActionWouldCreate
if existed {
action = agents.ActionWouldMerge
}
return agents.FileAction{Path: settingsPath, Action: action, Keys: []string{"hooks"}}, nil
}
data, err := json.MarshalIndent(settings, "", " ")
if err != nil {
return agents.FileAction{}, err
}
if err := agents.AtomicWriteFile(settingsPath, data, 0o644); err != nil {
return agents.FileAction{}, err
}
// Report exactly what changed — helpful for the doctor subcommand
// and for reassuring users during `gortex init` re-runs.
var changes []string
if matcherUpgraded {
changes = append(changes, "upgraded PreToolUse matcher")
}
if dedupedCount > 0 {
changes = append(changes, fmt.Sprintf("removed %d duplicate entries", dedupedCount))
}
if healedCount > 0 {
changes = append(changes, fmt.Sprintf("rewrote %d stale hook path(s)", healedCount))
}
if !preToolUseInstalled {
changes = append(changes, "installed PreToolUse")
}
if !preCompactInstalled {
changes = append(changes, "installed PreCompact")
}
if !stopInstalled {
changes = append(changes, "installed Stop")
}
if !sessionStartInstalled {
changes = append(changes, "installed SessionStart")
}
if !userPromptSubmitInstalled {
changes = append(changes, "installed UserPromptSubmit")
}
if postToolUseAdded {
changes = append(changes, "installed PostToolUse (enrich mode)")
}
if postToolUseRemoved > 0 {
changes = append(changes, fmt.Sprintf("removed PostToolUse (switched to %s mode)", mode))
}
if modeRewriteCount > 0 {
changes = append(changes, fmt.Sprintf("rewrote %d hook command(s) for mode=%s", modeRewriteCount, mode))
}
if w != nil {
fmt.Fprintf(w, "[gortex init] %s in %s\n", strings.Join(changes, ", "), settingsPath)
}
action := agents.ActionCreate
if existed {
action = agents.ActionMerge
}
return agents.FileAction{Path: settingsPath, Action: action, Keys: []string{"hooks"}}, nil
}
+522
View File
@@ -0,0 +1,522 @@
package claudecode
import (
"encoding/json"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zzet/gortex/internal/agents"
)
// agentsApplyOptsZero is the zero value of agents.ApplyOpts. Helper so
// the mode-switching tests don't drag the agents import into every
// call site.
func agentsApplyOptsZero() agents.ApplyOpts { return agents.ApplyOpts{} }
// readSettingsHooks reads settings.local.json from path and returns
// the hooks map (or fails the test if the file is missing / malformed).
// The mode-switching tests assert on hook structure between InstallHook
// calls, so we centralise the read-and-shape-check here.
func readSettingsHooks(t *testing.T, path string) map[string]any {
t.Helper()
data, err := os.ReadFile(path)
require.NoError(t, err, "read %s", path)
var settings map[string]any
require.NoError(t, json.Unmarshal(data, &settings))
hooks, ok := settings["hooks"].(map[string]any)
require.True(t, ok, "settings.hooks missing or wrong type")
return hooks
}
// TestHookCommandPathIsEphemeral covers every branch of the
// ephemeral-path detector because this function's output directly
// decides whether a user's settings.local.json gets rewritten on
// re-run. A false positive here would thrash the user's hook
// config; a false negative leaves them with stale /tmp paths.
func TestHookCommandPathIsEphemeral(t *testing.T) {
// /bin/sh is a stable POSIX path, not under any ephemeral root.
// os.Executable() would land in /private/var/folders under go
// test, which is itself ephemeral — hence hard-coding.
const existing = "/bin/sh"
if _, err := os.Stat(existing); err != nil {
t.Skipf("test fixture %s not present: %v", existing, err)
}
missing := filepath.Join("/nonexistent-root-for-gortex-test", "ghost-binary")
cases := []struct {
name string
cmd string
want bool
comment string
}{
{"empty", "", false, "no fields to inspect"},
{"bareName", "gortex hook", false, "PATH lookup happens at fire time"},
{"relative", "./gortex hook", false, "relative paths are user choice, not ephemeral"},
{"tmp", "/tmp/gortex-hook-fix hook", true, "/tmp is wiped between sessions"},
{"varFolders", "/var/folders/x/y/z/gortex hook", true, "macOS go-build cache"},
{"privateTmp", "/private/tmp/gortex hook", true, "macOS resolves /tmp via /private/tmp"},
{"privateVarFolders", "/private/var/folders/x/y/z/gortex hook", true, "fully resolved go-build cache"},
{"missingAbsolute", missing + " hook", true, "absolute path that no longer exists"},
{"healthyAbsolute", existing + " hook", false, "absolute path that exists on disk"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := HookCommandPathIsEphemeral(tc.cmd)
assert.Equal(t, tc.want, got, tc.comment)
})
}
}
func TestHealStaleHookCommands(t *testing.T) {
const newCmd = "/opt/homebrew/bin/gortex hook"
t.Run("emptyHooks", func(t *testing.T) {
hooks := map[string]any{}
got := healStaleHookCommands(hooks, newCmd)
assert.Equal(t, 0, got)
})
t.Run("noGortexEntries", func(t *testing.T) {
hooks := map[string]any{
"PreToolUse": []any{
makeHookEntry("Read", "/usr/local/bin/some-other-tool run"),
},
}
got := healStaleHookCommands(hooks, newCmd)
assert.Equal(t, 0, got)
entries := hooks["PreToolUse"].([]any)
inner := entries[0].(map[string]any)["hooks"].([]any)
cmd := inner[0].(map[string]any)["command"].(string)
assert.Equal(t, "/usr/local/bin/some-other-tool run", cmd)
})
t.Run("healthyGortexEntryUntouched", func(t *testing.T) {
hooks := map[string]any{
"PreToolUse": []any{makeHookEntry("Read", "./gortex hook")},
}
got := healStaleHookCommands(hooks, newCmd)
assert.Equal(t, 0, got)
assert.Equal(t, "./gortex hook", extractCmd(t, hooks, "PreToolUse", 0))
})
t.Run("staleEntryRewritten", func(t *testing.T) {
hooks := map[string]any{
"Stop": []any{makeHookEntry("", "/tmp/gortex-hook-fix hook")},
}
got := healStaleHookCommands(hooks, newCmd)
assert.Equal(t, 1, got)
assert.Equal(t, newCmd, extractCmd(t, hooks, "Stop", 0))
})
t.Run("multipleEventsAndMixed", func(t *testing.T) {
hooks := map[string]any{
"PreToolUse": []any{
makeHookEntry("Read", "./gortex hook"),
makeHookEntry("Read", "/usr/local/bin/lint --strict"),
},
"PreCompact": []any{makeHookEntry("", "/tmp/gortex-hook-fix hook")},
"Stop": []any{makeHookEntry("", "/tmp/gortex-hook-fix hook")},
}
got := healStaleHookCommands(hooks, newCmd)
assert.Equal(t, 2, got)
assert.Equal(t, "./gortex hook", extractCmd(t, hooks, "PreToolUse", 0))
assert.Equal(t, "/usr/local/bin/lint --strict", extractCmd(t, hooks, "PreToolUse", 1))
assert.Equal(t, newCmd, extractCmd(t, hooks, "PreCompact", 0))
assert.Equal(t, newCmd, extractCmd(t, hooks, "Stop", 0))
})
}
func TestResolveHookCommand(t *testing.T) {
t.Run("foundOnPath", func(t *testing.T) {
dir := t.TempDir()
fake := filepath.Join(dir, "gortex")
require.NoError(t, os.WriteFile(fake, []byte("#!/bin/sh\nexit 0\n"), 0o755))
t.Setenv("PATH", dir)
got := ResolveHookCommand(io.Discard)
assert.Equal(t, fake+" hook", got, "should resolve to absolute path on PATH")
})
t.Run("notFoundFallsBackToBare", func(t *testing.T) {
t.Setenv("PATH", t.TempDir())
got := ResolveHookCommand(io.Discard)
assert.Equal(t, "gortex hook", got, "fallback to bare name keeps init working in sandboxes")
})
t.Run("commandNeverContainsBackslash", func(t *testing.T) {
dir := t.TempDir()
fake := filepath.Join(dir, "gortex")
require.NoError(t, os.WriteFile(fake, []byte("#!/bin/sh\nexit 0\n"), 0o755))
t.Setenv("PATH", dir)
got := ResolveHookCommand(io.Discard)
assert.NotContains(t, got, "\\",
"baked hook command must use forward slashes — Git Bash on Windows mangles backslash paths")
})
t.Run("agreesWithMCPStanzaBinary", func(t *testing.T) {
// The hook command and the MCP server stanza must launch the same
// gortex binary; a side-by-side install must never split them
// across two daemons. With a gortex on PATH the hook pins the
// absolute path while the stanza collapses to the bare name — both
// resolve to the same file.
dir := t.TempDir()
fake := filepath.Join(dir, "gortex")
require.NoError(t, os.WriteFile(fake, []byte("#!/bin/sh\nexit 0\n"), 0o755))
t.Setenv("PATH", dir)
hookBin := strings.TrimSuffix(ResolveHookCommand(io.Discard), " hook")
mcpCmd := agents.ResolveGortexCommand()
// Map the bare name to the PATH gortex it launches, then compare
// the concrete on-disk binaries.
launched := func(cmd string) string {
if cmd == "gortex" {
return fake
}
return cmd
}
assert.Equal(t, launched(mcpCmd), launched(hookBin),
"hook command and MCP stanza must resolve to the same gortex binary")
})
}
// TestShellSafeHookBinary pins the Windows-path fix: a resolved binary
// path with backslashes (as exec.LookPath returns on Windows) must be
// rewritten to forward slashes so Claude Code's shell-run hook does not
// swallow the separators. Runs on every platform because the helper is
// a pure string transform.
func TestShellSafeHookBinary(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{"windowsAbsolute", `C:\Users\Borislav\AppData\Local\Programs\gortex\gortex.exe`, "C:/Users/Borislav/AppData/Local/Programs/gortex/gortex.exe"},
{"windowsMixed", `C:\Users\me/gortex.exe`, "C:/Users/me/gortex.exe"},
{"unixAbsolute", "/opt/homebrew/bin/gortex", "/opt/homebrew/bin/gortex"},
{"bareName", "gortex", "gortex"},
{"forwardSlashesUnchanged", "C:/Users/me/gortex.exe", "C:/Users/me/gortex.exe"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := shellSafeHookBinary(tc.in)
assert.Equal(t, tc.want, got)
assert.NotContains(t, got, "\\", "result must be free of backslashes")
})
}
}
func makeHookEntry(matcher, command string) map[string]any {
entry := map[string]any{
"hooks": []any{
map[string]any{
"type": "command",
"command": command,
"timeout": 3000,
},
},
}
if matcher != "" {
entry["matcher"] = matcher
}
return entry
}
func extractCmd(t *testing.T, hooks map[string]any, event string, idx int) string {
t.Helper()
list, ok := hooks[event].([]any)
require.True(t, ok, "event %q missing", event)
require.Greater(t, len(list), idx, "event %q has fewer than %d entries", event, idx+1)
entry, ok := list[idx].(map[string]any)
require.True(t, ok)
inner, ok := entry["hooks"].([]any)
require.True(t, ok)
require.NotEmpty(t, inner)
em, ok := inner[0].(map[string]any)
require.True(t, ok)
cmd, _ := em["command"].(string)
return cmd
}
// ---------------------------------------------------------------------------
// Mode parsing + command rendering
// ---------------------------------------------------------------------------
func TestNormalizeHookMode(t *testing.T) {
cases := map[string]string{
"": HookModeDeny,
"deny": HookModeDeny,
"DENY": HookModeDeny,
" deny ": HookModeDeny,
"enrich": HookModeEnrich,
"Enrich": HookModeEnrich,
"consult-unlock": HookModeConsultUnlock,
"Consult-Unlock": HookModeConsultUnlock,
"nudge": HookModeAdaptiveNudge,
"NUDGE": HookModeAdaptiveNudge,
"adaptive-nudge": HookModeAdaptiveNudge, // accepted alias
"unknown": HookModeDeny, // safe fallback
"off": HookModeDeny, // install flag handles disable separately
}
for input, want := range cases {
t.Run(input, func(t *testing.T) {
assert.Equal(t, want, normalizeHookMode(input))
})
}
}
func TestHookCommandWithMode(t *testing.T) {
base := "/usr/local/bin/gortex hook"
assert.Equal(t, base, hookCommandWithMode(base, HookModeDeny),
"deny mode must NOT append --mode flag (back-compat with settings written before --hook-mode existed)")
assert.Equal(t, base+" --mode=enrich", hookCommandWithMode(base, HookModeEnrich),
"enrich mode must append --mode=enrich")
assert.Equal(t, base+" --mode=consult-unlock", hookCommandWithMode(base, HookModeConsultUnlock),
"consult-unlock mode must append --mode=consult-unlock")
assert.Equal(t, base+" --mode=nudge", hookCommandWithMode(base, HookModeAdaptiveNudge),
"nudge mode must append --mode=nudge")
assert.Equal(t, base+" --mode=nudge", hookCommandWithMode(base, "adaptive-nudge"),
"adaptive-nudge alias must normalize to --mode=nudge")
assert.Equal(t, base, hookCommandWithMode(base, ""),
"empty mode defaults to deny — no flag")
}
// ---------------------------------------------------------------------------
// removeGortexHookEntries — used when switching enrich → deny
// ---------------------------------------------------------------------------
func TestRemoveGortexHookEntries(t *testing.T) {
hooks := map[string]any{
"PostToolUse": []any{
makeHookEntry("Read|Grep|Glob", "/opt/gortex hook --mode=enrich"),
map[string]any{
"hooks": []any{
map[string]any{"type": "command", "command": "/usr/bin/other-tool"},
},
},
},
}
removed := removeGortexHookEntries(hooks, "PostToolUse")
assert.Equal(t, 1, removed)
list := hooks["PostToolUse"].([]any)
require.Len(t, list, 1, "non-Gortex entry must be preserved")
em := list[0].(map[string]any)
inner := em["hooks"].([]any)
cmd := inner[0].(map[string]any)["command"]
assert.Equal(t, "/usr/bin/other-tool", cmd)
}
func TestRemoveGortexHookEntries_DeletesEmptyEvent(t *testing.T) {
hooks := map[string]any{
"PostToolUse": []any{
makeHookEntry("Read|Grep|Glob", "/opt/gortex hook --mode=enrich"),
},
}
removed := removeGortexHookEntries(hooks, "PostToolUse")
assert.Equal(t, 1, removed)
_, exists := hooks["PostToolUse"]
assert.False(t, exists, "empty event should be deleted, not left as []")
}
func TestRemoveGortexHookEntries_NoOpOnMissingEvent(t *testing.T) {
hooks := map[string]any{}
assert.Equal(t, 0, removeGortexHookEntries(hooks, "PostToolUse"))
}
// ---------------------------------------------------------------------------
// rewriteGortexHookMode — switches mode without touching user fields
// ---------------------------------------------------------------------------
func TestRewriteGortexHookMode_UpdatesCommandPreservingMeta(t *testing.T) {
hooks := map[string]any{
"PreToolUse": []any{
map[string]any{
"matcher": "Read|Grep",
"hooks": []any{
map[string]any{
"type": "command",
"command": "/opt/gortex hook",
"timeout": 3000,
"statusMessage": "custom user-set message",
},
},
},
},
}
rewritten := rewriteGortexHookMode(hooks, "/opt/gortex hook --mode=enrich")
assert.Equal(t, 1, rewritten)
list := hooks["PreToolUse"].([]any)
em := list[0].(map[string]any)["hooks"].([]any)[0].(map[string]any)
assert.Equal(t, "/opt/gortex hook --mode=enrich", em["command"])
assert.Equal(t, "custom user-set message", em["statusMessage"],
"rewrite must preserve user-added fields like statusMessage")
}
func TestRewriteGortexHookMode_NoOpOnIdenticalCommand(t *testing.T) {
hooks := map[string]any{
"PreToolUse": []any{makeHookEntry("Read|Grep|Glob", "/opt/gortex hook")},
}
assert.Equal(t, 0, rewriteGortexHookMode(hooks, "/opt/gortex hook"))
}
func TestRewriteGortexHookMode_IgnoresNonGortexEntries(t *testing.T) {
hooks := map[string]any{
"PreToolUse": []any{makeHookEntry("Bash", "/usr/bin/other --thing")},
}
assert.Equal(t, 0, rewriteGortexHookMode(hooks, "/opt/gortex hook --mode=enrich"))
cmd := extractCmd(t, hooks, "PreToolUse", 0)
assert.Equal(t, "/usr/bin/other --thing", cmd,
"non-Gortex entries must NOT be touched even when we're rewriting")
}
// TestUpgradeGortexMatcher_RewritesPreviousCurrent verifies the
// migration path: an existing install pinned to the previous canonical
// matcher (no MCP read tools) upgrades in place so the read-discipline
// nudge starts firing without the user touching settings.json.
func TestUpgradeGortexMatcher_RewritesPreviousCurrent(t *testing.T) {
hooks := map[string]any{
"PreToolUse": []any{makeHookEntry("Read|Grep|Glob|Task|Bash|Edit|Write", "/opt/gortex hook")},
}
assert.True(t, upgradeGortexMatcher(hooks), "previous-current matcher should upgrade")
got := hooks["PreToolUse"].([]any)[0].(map[string]any)["matcher"].(string)
assert.Equal(t, CurrentPreToolUseMatcher, got)
assert.Contains(t, got, "mcp__gortex__read_file",
"upgraded matcher must include the gortex read tools")
}
func TestUpgradeGortexMatcher_NoOpOnCurrent(t *testing.T) {
hooks := map[string]any{
"PreToolUse": []any{makeHookEntry(CurrentPreToolUseMatcher, "/opt/gortex hook")},
}
assert.False(t, upgradeGortexMatcher(hooks), "already-current matcher must not be rewritten")
}
func TestUpgradeGortexMatcher_IgnoresNonGortexEntries(t *testing.T) {
hooks := map[string]any{
"PreToolUse": []any{makeHookEntry("Read|Grep|Glob|Task|Bash|Edit|Write", "/usr/bin/other thing")},
}
assert.False(t, upgradeGortexMatcher(hooks), "a non-Gortex entry must not be upgraded")
}
// ---------------------------------------------------------------------------
// InstallHookWithMode — end-to-end: deny → enrich → deny round-trip
// ---------------------------------------------------------------------------
func TestInstallHookWithMode_DenyMode(t *testing.T) {
dir := t.TempDir()
settingsPath := filepath.Join(dir, "settings.local.json")
t.Setenv("PATH", t.TempDir()) // force fallback to bare "gortex hook"
_, err := InstallHookWithMode(io.Discard, settingsPath, HookModeDeny, agentsApplyOptsZero())
require.NoError(t, err)
hooks := readSettingsHooks(t, settingsPath)
assert.NotContains(t, hooks, "PostToolUse",
"deny mode must NOT install a PostToolUse entry")
cmd := extractCmd(t, hooks, "PreToolUse", 0)
assert.Equal(t, "gortex hook", cmd,
"deny mode keeps the bare command — no --mode flag")
}
func TestInstallHook_InstallsUserPromptSubmit(t *testing.T) {
dir := t.TempDir()
settingsPath := filepath.Join(dir, "settings.local.json")
t.Setenv("PATH", t.TempDir()) // force fallback to bare "gortex hook"
_, err := InstallHookWithMode(io.Discard, settingsPath, HookModeDeny, agentsApplyOptsZero())
require.NoError(t, err)
hooks := readSettingsHooks(t, settingsPath)
require.Contains(t, hooks, "UserPromptSubmit",
"UserPromptSubmit hook must be installed for per-turn context injection")
cmd := extractCmd(t, hooks, "UserPromptSubmit", 0)
assert.Equal(t, "gortex hook", cmd)
}
func TestInstallHookWithMode_EnrichMode(t *testing.T) {
dir := t.TempDir()
settingsPath := filepath.Join(dir, "settings.local.json")
t.Setenv("PATH", t.TempDir())
_, err := InstallHookWithMode(io.Discard, settingsPath, HookModeEnrich, agentsApplyOptsZero())
require.NoError(t, err)
hooks := readSettingsHooks(t, settingsPath)
require.Contains(t, hooks, "PostToolUse")
postCmd := extractCmd(t, hooks, "PostToolUse", 0)
assert.Equal(t, "gortex hook --mode=enrich", postCmd)
preCmd := extractCmd(t, hooks, "PreToolUse", 0)
assert.Equal(t, "gortex hook --mode=enrich", preCmd,
"PreToolUse must use the same --mode=enrich command so it falls back to soft-context")
// PostToolUse uses the matcher restricted to read-shaped tools.
post := hooks["PostToolUse"].([]any)[0].(map[string]any)
assert.Equal(t, CurrentPostToolUseMatcher, post["matcher"])
}
func TestInstallHookWithMode_DenyToEnrichRoundTrip(t *testing.T) {
dir := t.TempDir()
settingsPath := filepath.Join(dir, "settings.local.json")
t.Setenv("PATH", t.TempDir())
// Initial deny install.
_, err := InstallHookWithMode(io.Discard, settingsPath, HookModeDeny, agentsApplyOptsZero())
require.NoError(t, err)
hooks := readSettingsHooks(t, settingsPath)
require.NotContains(t, hooks, "PostToolUse")
assert.Equal(t, "gortex hook", extractCmd(t, hooks, "PreToolUse", 0))
// Switch to enrich.
_, err = InstallHookWithMode(io.Discard, settingsPath, HookModeEnrich, agentsApplyOptsZero())
require.NoError(t, err)
hooks = readSettingsHooks(t, settingsPath)
require.Contains(t, hooks, "PostToolUse", "enrich install must add PostToolUse")
assert.Equal(t, "gortex hook --mode=enrich", extractCmd(t, hooks, "PreToolUse", 0),
"PreToolUse command must be rewritten on mode switch")
assert.Equal(t, "gortex hook --mode=enrich", extractCmd(t, hooks, "PostToolUse", 0))
// Switch back to deny — PostToolUse must be removed, PreToolUse rewritten.
_, err = InstallHookWithMode(io.Discard, settingsPath, HookModeDeny, agentsApplyOptsZero())
require.NoError(t, err)
hooks = readSettingsHooks(t, settingsPath)
assert.NotContains(t, hooks, "PostToolUse", "switch back to deny must drop PostToolUse")
assert.Equal(t, "gortex hook", extractCmd(t, hooks, "PreToolUse", 0),
"switch back to deny must restore bare command on PreToolUse")
}
func TestInstallHookWithMode_EnrichIdempotent(t *testing.T) {
dir := t.TempDir()
settingsPath := filepath.Join(dir, "settings.local.json")
t.Setenv("PATH", t.TempDir())
for i := range 3 {
_, err := InstallHookWithMode(io.Discard, settingsPath, HookModeEnrich, agentsApplyOptsZero())
require.NoError(t, err, "iteration %d", i)
}
hooks := readSettingsHooks(t, settingsPath)
// PostToolUse should have exactly one entry — re-running install
// must not append duplicates.
post := hooks["PostToolUse"].([]any)
assert.Len(t, post, 1, "repeated install must not duplicate the PostToolUse entry")
}
// InstallHook (the back-compat wrapper) must default to deny mode.
func TestInstallHook_DefaultsToDeny(t *testing.T) {
dir := t.TempDir()
settingsPath := filepath.Join(dir, "settings.local.json")
t.Setenv("PATH", t.TempDir())
_, err := InstallHook(io.Discard, settingsPath, agentsApplyOptsZero())
require.NoError(t, err)
hooks := readSettingsHooks(t, settingsPath)
assert.NotContains(t, hooks, "PostToolUse",
"InstallHook (no mode) must behave as deny — no PostToolUse entry")
}
@@ -0,0 +1,164 @@
package claudecode
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/agentstest"
"github.com/zzet/gortex/internal/profiles"
)
// Byte ceilings for the installed ambient layer. Approximated at ~4.4 B/token:
// - 6.5 KiB global section (pointer block + the @-included default
// profile body) ≈ 1.5k tokens
// - 2.5 KiB skills-eager total ≈ 0.57k tokens
//
// Blowing either (a future rule-block or description balloon) fails loudly
// instead of silently re-inflating the per-session tax. Per-profile body
// ceilings live in internal/profiles.
const (
globalSectionByteCeiling = 6656 // 6.5 KiB
skillsEagerByteCeiling = 2560 // 2.5 KiB
)
// frontmatterOf returns the YAML frontmatter block (--- … ---) of a skill /
// sub-agent definition — the part a client loads eagerly to route. Empty when
// the body has no leading frontmatter.
func frontmatterOf(s string) string {
s = strings.TrimLeft(s, "\n")
if !strings.HasPrefix(s, "---\n") {
return ""
}
rest := s[len("---\n"):]
end := strings.Index(rest, "\n---")
if end < 0 {
return ""
}
return "---\n" + rest[:end+len("\n---")]
}
// TestInstalledAmbientByteCeilings is the permanent measurement gate: it prints
// the byte cost of the installed ambient layer and asserts the global rule
// block and the skills-eager total stay inside their ceilings.
func TestInstalledAmbientByteCeilings(t *testing.T) {
// What a session actually pays at the machine level: the pointer
// block in ~/.claude/CLAUDE.md plus the @-included active profile
// body (default profile on a fresh install).
def, _ := profiles.ByName(profiles.DefaultName)
global := len(agents.GlobalPointerBody("/home/user/.gortex/instructions")) + len(def.Body())
skillsFM := 0
for _, body := range GlobalSkills {
skillsFM += len(frontmatterOf(body))
}
subFM := 0
for _, body := range SubAgents {
subFM += len(frontmatterOf(body))
}
t.Logf("installed ambient byte cost:")
t.Logf(" global section (pointer+default profile): %d bytes (ceiling %d)", global, globalSectionByteCeiling)
t.Logf(" skills eager frontmatter : %d bytes (ceiling %d, %d skills)", skillsFM, skillsEagerByteCeiling, len(GlobalSkills))
t.Logf(" sub-agent eager frontmatter: %d bytes (%d agents)", subFM, len(SubAgents))
t.Logf(" projected installed eager: %d bytes", global+skillsFM+subFM)
if global > globalSectionByteCeiling {
t.Errorf("global section (pointer + default profile body) is %d bytes, over the %d ceiling", global, globalSectionByteCeiling)
}
if skillsFM > skillsEagerByteCeiling {
t.Errorf("skills eager frontmatter is %d bytes, over the %d ceiling", skillsFM, skillsEagerByteCeiling)
}
}
// TestGlobalInstall_FatToSlimReplacement is the migration gate: a user
// upgrading from a fat pre-diet rule block gets the thin pointer block
// on re-install (the policy body moves to the @-included instruction
// profile), with the user's own prose around the marker block preserved
// and no duplicate block. Exercises the marker-fenced merge on the real
// install path.
func TestGlobalInstall_FatToSlimReplacement(t *testing.T) {
env, _ := agentstest.NewEnv(t)
env.Mode = agents.ModeGlobal
env.InstallGlobalInstructions = true
claudeMd := filepath.Join(env.Home, ".claude", "CLAUDE.md")
if err := os.MkdirAll(filepath.Dir(claudeMd), 0o755); err != nil {
t.Fatal(err)
}
// Seed a realistic pre-diet file: user prose, then a FAT marker block
// carrying content the diet relocates (provider matrix, capabilities
// catalog), then more user prose.
const userAbove = "# My machine notes\n\nAlways run tests with -race.\n"
const userBelow = "\n## My other tooling\n\nUse ripgrep for logs.\n"
fatBlock := agents.GlobalRulesStartMarker + "\n" +
"## MANDATORY: Use Gortex MCP tools instead of Read/Grep/Glob\n\n" +
"### LLM provider\n" +
"one of `local` / `anthropic` / `openai` / `azure` / `ollama` / `claudecli` / `codex` / `copilot` / `cursor` / `opencode` / `gemini` / `bedrock` / `deepseek`\n\n" +
"### Non-obvious capabilities worth knowing\n" +
"- analyze is a 61-kind dispatcher: dead_code, hotspots, cycles via Tarjan's SCC…\n" +
"- TOON tabular text is a compact tabular text, lossy fallback\n" +
agents.GlobalRulesEndMarker + "\n"
seed := userAbove + "\n" + fatBlock + userBelow
if err := os.WriteFile(claudeMd, []byte(seed), 0o644); err != nil {
t.Fatal(err)
}
if _, err := New().Apply(env, agents.ApplyOpts{}); err != nil {
t.Fatalf("apply: %v", err)
}
got, err := os.ReadFile(claudeMd)
if err != nil {
t.Fatal(err)
}
text := string(got)
// User prose around the block survives verbatim.
if !strings.Contains(text, userAbove) {
t.Error("user content above the block was clobbered")
}
if !strings.Contains(text, strings.TrimLeft(userBelow, "\n")) {
t.Error("user content below the block was clobbered")
}
// Exactly one marker block — the fat one was replaced in place, not
// appended alongside.
if n := strings.Count(text, agents.GlobalRulesStartMarker); n != 1 {
t.Errorf("expected exactly 1 rule block, found %d", n)
}
// The block is now the thin pointer: it @-includes the active
// profile copy from the hermetic instructions dir…
activePath := filepath.Join(env.InstructionsDir, profiles.ActiveFileName)
if !strings.Contains(text, "@"+activePath) {
t.Errorf("re-installed block does not @-include the active profile (%s)", activePath)
}
if !strings.Contains(text, "gortex instructions switch") {
t.Error("re-installed block lost the switch verb")
}
// …and the slim policy core moved into the profile file itself.
activeBody, err := os.ReadFile(activePath)
if err != nil {
t.Fatalf("active profile copy was not generated: %v", err)
}
for _, token := range []string{"gortex://guide", "distill_session", "surface_memories", "smart_context"} {
if !strings.Contains(string(activeBody), token) {
t.Errorf("active profile body is missing the policy core token %q", token)
}
}
// …and the relocated fat content is gone.
for _, gone := range []string{
"Non-obvious capabilities worth knowing",
"61-kind dispatcher",
"compact tabular text, lossy",
"`local` / `anthropic` / `openai` / `azure` / `ollama` / `claudecli` / `codex` / `copilot` / `cursor` / `opencode` / `gemini` / `bedrock` / `deepseek`",
} {
if strings.Contains(text, gone) {
t.Errorf("re-installed block still carries relocated fat content %q", gone)
}
}
}
+397
View File
@@ -0,0 +1,397 @@
package claudecode
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
)
// PluginBundleSpec describes one generation of the marketplace
// plugin bundle. The same emitter drives the Anthropic
// Plugin Marketplace artifact today; the Cursor variant in a follow-up
// will use the same struct with a different LayoutVariant.
type PluginBundleSpec struct {
// TargetDir is the directory to emit into. The directory is created
// if it does not exist; existing files within it are overwritten.
TargetDir string
// Version is the gortex SemVer string to bake into plugin.json
// (e.g. "0.18.2"). Marketplace entries pin a release ref; this
// string identifies what's inside.
Version string
// LayoutVariant chooses the output shape. Today only
// LayoutVariantAnthropic is implemented; the Cursor variant will
// arrive when its marketplace schema settles.
LayoutVariant LayoutVariant
}
// LayoutVariant enumerates the marketplace layouts the emitter knows
// how to write.
type LayoutVariant string
const (
// LayoutVariantAnthropic targets the Anthropic Plugin Marketplace
// schema (https://anthropic.com/claude-code/marketplace.schema.json
// and the per-plugin layout discovered via the example-plugin
// reference shipped in claude-plugins-official).
LayoutVariantAnthropic LayoutVariant = "anthropic"
)
// pluginAuthorName / pluginAuthorEmail / pluginHomepage are the
// fields baked into every plugin.json. They are package-level so the
// marketplace.json submission script can read the same values
// without re-deriving them.
const (
pluginName = "gortex"
pluginDescription = "Your AI's live map of your codebase — every call, dependency, and contract indexed across your repos, shared across every running agent via a local daemon. Gives Claude Code, Cursor, and any MCP-aware client 52 graph-aware tools so they answer \"what calls this?\", \"what breaks if I rename UserStore?\", or \"which services consume this endpoint?\" in one call instead of grepping for minutes. Real AST parsing across 92 languages — deterministic, zero LLM calls during indexing. Useful before refactoring, when tracing bugs across services, exploring unfamiliar code, or any time your agent reaches for Grep or Read. Returns precise answers tagged with confidence tiers (compiler-grade vs heuristic). Local-first, Apache 2.0 licensed."
pluginAuthorName = "Andrey Kumanyaev"
pluginAuthorEmail = "support@gortex.dev"
pluginHomepage = "https://gortex.dev"
)
// pluginMCPJSON is the .mcp.json content for the marketplace plugin.
// Stdio form with `gortex mcp --proxy`: the marketplace user installs
// the plugin, the binary is on PATH (via the get.gortex.dev installer
// or Homebrew), and `--proxy` auto-detects the daemon and falls back
// to spawning a local MCPServer when no daemon is reachable.
//
// Indentation matches Claude Code's reference plugins (2 spaces, no
// trailing newline beyond the final brace's).
const pluginMCPJSON = `{
"gortex": {
"command": "gortex",
"args": ["mcp"]
}
}
`
// pluginHooksJSON is the hooks/hooks.json content for the
// marketplace plugin. Four event bindings (PreToolUse, PreCompact,
// Stop, SessionStart) all dispatch through ${CLAUDE_PLUGIN_ROOT}/hooks-handlers/gortex-hook.sh
// — a thin wrapper that locates the gortex binary and invokes
// `gortex hook`. The wrapper's job is:
//
// - Fail soft when gortex is missing (print to stderr, exit 0) so
// a marketplace user without the binary doesn't see hard errors
// on every Claude Code action.
// - Forward stdin / stderr / argv unchanged so all today's
// `gortex hook` event-dispatcher logic in internal/hooks/dispatch.go
// keeps working byte-for-byte.
//
// The %s is filled with CurrentPreToolUseMatcher at emit time so the
// marketplace plugin and the `gortex init` settings.json never drift.
const pluginHooksJSON = `{
"description": "Gortex graph-aware hooks: PreToolUse routing, PreCompact orientation, post-task diagnostics, SessionStart cold briefing.",
"hooks": {
"PreToolUse": [
{
"matcher": "%s",
"hooks": [
{
"type": "command",
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks-handlers/gortex-hook.sh\"",
"timeout": 3000,
"statusMessage": "Enriching with Gortex graph context..."
}
]
}
],
"PreCompact": [
{
"hooks": [
{
"type": "command",
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks-handlers/gortex-hook.sh\"",
"timeout": 3000,
"statusMessage": "Injecting Gortex orientation snapshot..."
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks-handlers/gortex-hook.sh\"",
"timeout": 5000,
"statusMessage": "Running Gortex post-task diagnostics..."
}
]
}
],
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks-handlers/gortex-hook.sh\"",
"timeout": 3000,
"statusMessage": "Loading Gortex graph orientation..."
}
]
}
]
}
}
`
// pluginHookHandler is the bash wrapper script that every hook event
// invokes. It locates the gortex binary on PATH, runs `gortex hook`
// with stdin/stderr/argv forwarded, and degrades gracefully when the
// binary is missing — print one warning to stderr and exit 0 so a
// missing binary never blocks the user's Claude Code session.
const pluginHookHandler = `#!/usr/bin/env bash
# gortex marketplace-plugin hook wrapper.
# Locates the gortex binary on PATH and forwards the Claude Code hook
# event to ` + "`gortex hook`" + `. Falls back to a soft-fail (warn-and-exit-0)
# when gortex is not installed, so a missing binary never blocks the
# user's session — the marketplace plugin can always be installed in
# advance of the binary.
#
# Install gortex via: curl -fsSL https://get.gortex.dev | sh
set -u
if ! command -v gortex >/dev/null 2>&1; then
echo "gortex binary not found on PATH — install via 'curl -fsSL https://get.gortex.dev | sh' to enable graph-aware hooks." >&2
exit 0
fi
exec gortex hook "$@"
`
const pluginReadmeBody = `# Gortex — Claude Code Plugin
Gortex is a graph-based code intelligence engine. This plugin gives Claude
Code 52 MCP tools for navigation, refactoring, contracts, impact analysis,
and search across 92 languages — backed by a shared-graph daemon that
keeps multiple agents and editor sessions in sync.
## Install
This plugin assumes the ` + "`gortex`" + ` binary is on PATH. Install it via:
` + "```sh\ncurl -fsSL https://get.gortex.dev | sh\n```" + `
The installer fetches a signed release tarball, verifies cosign + SHA256,
installs to ` + "`~/.local/bin`" + ` (or ` + "`/usr/local/bin`" + `), and runs ` + "`gortex install`" + `
+ ` + "`gortex init`" + `. Homebrew users on macOS can also use:
` + "```sh\nbrew install zzet/tap/gortex\n```" + `
## What this plugin adds
| Surface | What you get |
|---------|--------------|
| **MCP server** | 52 tools (` + "`search_symbols`" + `, ` + "`find_usages`" + `, ` + "`get_call_chain`" + `, ` + "`explain_change_impact`" + `, ` + "`rename_symbol`" + `, ` + "`scaffold`" + `, ` + "`contracts`" + `, …) over stdio via ` + "`gortex mcp --proxy`" + ` |
| **Slash commands** | Discovery: ` + "`/gortex-guide`" + `, ` + "`/gortex-explore`" + `, ` + "`/gortex-debug`" + `, ` + "`/gortex-impact`" + `, ` + "`/gortex-dataflow-trace`" + `, ` + "`/gortex-cross-repo-usage`" + `, ` + "`/gortex-co-change`" + `, ` + "`/gortex-onboarding`" + ` · Edit & refactor: ` + "`/gortex-refactor`" + `, ` + "`/gortex-safe-edit`" + `, ` + "`/gortex-rename`" + `, ` + "`/gortex-extract-function`" + `, ` + "`/gortex-fix-all`" + `, ` + "`/gortex-add-test`" + ` · Review & operate: ` + "`/gortex-pr-review`" + `, ` + "`/gortex-pr-review-agent`" + `, ` + "`/gortex-architecture-review`" + `, ` + "`/gortex-quality-audit`" + `, ` + "`/gortex-incident-investigation`" + `, ` + "`/gortex-episode-replay`" + ` |
| **Skills** | Twenty model-invoked skills that activate by task-shape. Discovery (7): architecture exploration, debugging, blast-radius analysis, dataflow tracing, cross-repo usage, co-change analysis, onboarding tour. Edit & refactor (6): general refactor, safe-edit (` + "`preview_edit`" + ` / ` + "`simulate_chain`" + ` before disk), cross-file rename, LSP-driven extract function, LSP ` + "`source.fixAll`" + `, coverage-led add-test. Review & operate (6): PR review, PR review as a sub-agent (shell ` + "`gortex review --audience agent`" + `), architecture review, quality audit, incident investigation, episode replay. Plus the tool-reference guide. The edit skills enforce the speculative-execution + LSP code-actions order so agents do not bypass safety steps; the review & operate skills wrap the discovery + impact + memory surfaces into ordered playbooks so postmortems and reviews are graph-grounded. |
| **Hooks** | PreToolUse routing (Read → ` + "`get_symbol_source`" + `, Grep → ` + "`search_symbols`" + ` / ` + "`find_usages`" + `), PreCompact orientation snapshot, Stop post-task diagnostics, SessionStart cold briefing |
## First run
After install, point Claude Code at any code repository and ask a task that
involves understanding code structure ("how does authentication work?",
"what breaks if I rename ` + "`UserStore`" + `?"). The hooks redirect Read/Grep/Glob
toward graph queries; the slash commands give you guided workflows.
If ` + "`gortex daemon`" + ` is running (` + "`gortex daemon start --detach`" + ` to start it),
all your editor sessions share one in-memory graph. Otherwise this plugin
spawns a one-shot MCP server per session — same tools, slower cold start.
## Links
- Homepage: https://gortex.dev
- Source: https://github.com/zzet/gortex
- License: https://github.com/zzet/gortex/blob/main/LICENSE.md (Apache 2.0)
`
// pluginLicenseBody is the LICENSE shipped inside the plugin
// directory. Plugins under claude-plugins-official tend to ship the
// project's own license rather than a marketplace-imposed one — we
// follow that pattern by emitting a one-line pointer to the canonical
// LICENSE.md in the upstream repo. Keeps the plugin directory small
// and avoids drift from the source-of-truth license.
const pluginLicenseBody = `Gortex is licensed under the Apache License, Version 2.0. The full
license text lives in LICENSE.md at the root of the upstream repository:
https://github.com/zzet/gortex/blob/main/LICENSE.md
Copyright 2024-2026 Andrey Kumanyaev <me@zzet.org>
`
// EmitPluginBundle writes a marketplace plugin layout under
// spec.TargetDir. The directory is created if missing; existing files
// within it are overwritten so re-runs converge on a deterministic
// output. Returns the list of paths written, relative to TargetDir,
// in stable order.
//
// Single source of truth: skills come from GlobalSkills, slash
// commands from SlashCommands. Both are reused from
// content.go without duplication, so a change to a skill's body
// flows through both the user-level install (~/.claude/skills/) and
// the marketplace plugin (claude-plugin/skills/) on the next emit.
func EmitPluginBundle(spec PluginBundleSpec) ([]string, error) {
if spec.LayoutVariant == "" {
spec.LayoutVariant = LayoutVariantAnthropic
}
if spec.LayoutVariant != LayoutVariantAnthropic {
return nil, fmt.Errorf("unsupported plugin layout variant %q (only %q is implemented)", spec.LayoutVariant, LayoutVariantAnthropic)
}
if spec.TargetDir == "" {
return nil, fmt.Errorf("EmitPluginBundle: TargetDir is required")
}
if spec.Version == "" {
return nil, fmt.Errorf("EmitPluginBundle: Version is required (e.g. \"0.18.2\")")
}
if err := os.MkdirAll(spec.TargetDir, 0o755); err != nil {
return nil, fmt.Errorf("create target dir: %w", err)
}
written := make([]string, 0, 16)
write := func(rel string, body []byte, mode os.FileMode) error {
full := filepath.Join(spec.TargetDir, rel)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
return fmt.Errorf("mkdir %s: %w", filepath.Dir(rel), err)
}
if err := os.WriteFile(full, body, mode); err != nil {
return fmt.Errorf("write %s: %w", rel, err)
}
written = append(written, rel)
return nil
}
// 1. .claude-plugin/plugin.json
manifest := map[string]any{
"name": pluginName,
"description": pluginDescription,
"version": spec.Version,
"author": map[string]any{
"name": pluginAuthorName,
"email": pluginAuthorEmail,
},
"homepage": pluginHomepage,
}
manifestJSON, err := marshalJSONStable(manifest)
if err != nil {
return nil, fmt.Errorf("marshal plugin.json: %w", err)
}
if err := write(".claude-plugin/plugin.json", manifestJSON, 0o644); err != nil {
return nil, err
}
// 2. README.md
if err := write("README.md", []byte(pluginReadmeBody), 0o644); err != nil {
return nil, err
}
// 3. LICENSE
if err := write("LICENSE", []byte(pluginLicenseBody), 0o644); err != nil {
return nil, err
}
// 4. .mcp.json
if err := write(".mcp.json", []byte(pluginMCPJSON), 0o644); err != nil {
return nil, err
}
// 5. commands/gortex-*.md — sorted for deterministic output.
for _, name := range sortedKeys(SlashCommands) {
body := SlashCommands[name]
if err := write(filepath.Join("commands", name), []byte(body), 0o644); err != nil {
return nil, err
}
}
// 6. skills/<name>/SKILL.md — sorted for deterministic output.
for _, name := range sortedKeys(GlobalSkills) {
body := GlobalSkills[name]
if err := write(filepath.Join("skills", name, "SKILL.md"), []byte(body), 0o644); err != nil {
return nil, err
}
}
// 7. agents/<name> — sub-agent definitions, sorted for
// deterministic output. Mirrors the on-disk path Claude Code
// expects under .claude/agents/ when the plugin is installed.
for _, name := range sortedKeys(SubAgents) {
body := SubAgents[name]
if err := write(filepath.Join("agents", name), []byte(body), 0o644); err != nil {
return nil, err
}
}
// 8. hooks/hooks.json + hooks-handlers/gortex-hook.sh
if err := write("hooks/hooks.json", fmt.Appendf(nil, pluginHooksJSON, CurrentPreToolUseMatcher), 0o644); err != nil {
return nil, err
}
if err := write("hooks-handlers/gortex-hook.sh", []byte(pluginHookHandler), 0o755); err != nil {
return nil, err
}
return written, nil
}
// marshalJSONStable returns indented JSON with sorted keys at every
// level. Two-space indent matches Claude Code's reference plugins;
// trailing newline keeps the file POSIX-text-friendly and avoids
// editor diff noise.
func marshalJSONStable(v any) ([]byte, error) {
out, err := json.MarshalIndent(v, "", " ")
if err != nil {
return nil, err
}
return append(out, '\n'), nil
}
// sortedKeys returns the keys of m in lexicographic order. Used so
// the on-disk emit order is independent of map iteration order, which
// keeps the directory diff-stable across Go versions.
func sortedKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
// PluginManifestPath / PluginMCPPath / PluginHooksPath return the
// in-bundle paths the emitter writes. Exposed so callers (e.g. the
// CI drift guard) can refer to them without hardcoding path strings.
func PluginManifestPath() string { return filepath.Join(".claude-plugin", "plugin.json") }
func PluginMCPPath() string { return ".mcp.json" }
func PluginHooksPath() string { return filepath.Join("hooks", "hooks.json") }
// PluginCommandPaths returns the in-bundle paths of all slash command
// files in stable order.
func PluginCommandPaths() []string {
out := make([]string, 0, len(SlashCommands))
for _, name := range sortedKeys(SlashCommands) {
out = append(out, filepath.Join("commands", name))
}
return out
}
// PluginSkillPaths returns the in-bundle paths of all SKILL.md files
// in stable order.
func PluginSkillPaths() []string {
out := make([]string, 0, len(GlobalSkills))
for _, name := range sortedKeys(GlobalSkills) {
out = append(out, filepath.Join("skills", name, "SKILL.md"))
}
return out
}
// PluginSubAgentPaths returns the in-bundle paths of all sub-agent
// files in stable order.
func PluginSubAgentPaths() []string {
out := make([]string, 0, len(SubAgents))
for _, name := range sortedKeys(SubAgents) {
out = append(out, filepath.Join("agents", name))
}
return out
}
+390
View File
@@ -0,0 +1,390 @@
package claudecode
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"sort"
"strings"
"testing"
)
func TestEmitPluginBundle_Layout(t *testing.T) {
dir := t.TempDir()
written, err := EmitPluginBundle(PluginBundleSpec{
TargetDir: dir,
Version: "0.18.2",
})
if err != nil {
t.Fatalf("EmitPluginBundle: %v", err)
}
// The emitter should write exactly: 1 plugin.json + 1 README +
// 1 LICENSE + 1 .mcp.json + N commands + N skills + 1 hooks.json
// + 1 hook handler script.
wantPaths := []string{
".claude-plugin/plugin.json",
".mcp.json",
"LICENSE",
"README.md",
"hooks-handlers/gortex-hook.sh",
"hooks/hooks.json",
}
for _, name := range sortedKeys(SlashCommands) {
wantPaths = append(wantPaths, filepath.Join("commands", name))
}
for _, name := range sortedKeys(GlobalSkills) {
wantPaths = append(wantPaths, filepath.Join("skills", name, "SKILL.md"))
}
for _, name := range sortedKeys(SubAgents) {
wantPaths = append(wantPaths, filepath.Join("agents", name))
}
sort.Strings(wantPaths)
got := append([]string(nil), written...)
sort.Strings(got)
if len(got) != len(wantPaths) {
t.Fatalf("file count mismatch: got %d, want %d.\ngot=%v\nwant=%v", len(got), len(wantPaths), got, wantPaths)
}
for i := range got {
if got[i] != wantPaths[i] {
t.Errorf("path[%d] mismatch: got %q, want %q", i, got[i], wantPaths[i])
}
}
// Every path the emitter claimed to write must exist on disk.
for _, rel := range written {
if _, err := os.Stat(filepath.Join(dir, rel)); err != nil {
t.Errorf("missing file %s: %v", rel, err)
}
}
}
func TestEmitPluginBundle_PluginManifestShape(t *testing.T) {
dir := t.TempDir()
if _, err := EmitPluginBundle(PluginBundleSpec{
TargetDir: dir,
Version: "0.18.2",
}); err != nil {
t.Fatal(err)
}
body, err := os.ReadFile(filepath.Join(dir, ".claude-plugin", "plugin.json"))
if err != nil {
t.Fatal(err)
}
var manifest map[string]any
if err := json.Unmarshal(body, &manifest); err != nil {
t.Fatalf("plugin.json: invalid JSON: %v", err)
}
// Required keys per the marketplace schema we observed in
// claude-plugins-official: name, description, author. We add
// version + homepage for our own metadata.
for _, key := range []string{"name", "description", "author", "version", "homepage"} {
if _, ok := manifest[key]; !ok {
t.Errorf("plugin.json missing required key %q", key)
}
}
if got := manifest["name"]; got != "gortex" {
t.Errorf("plugin.json name = %v, want gortex", got)
}
if got := manifest["version"]; got != "0.18.2" {
t.Errorf("plugin.json version = %v, want 0.18.2", got)
}
if !strings.HasSuffix(string(body), "\n") {
t.Errorf("plugin.json should end with a newline (got %q)", string(body[len(body)-3:]))
}
}
func TestEmitPluginBundle_MCPJSONShape(t *testing.T) {
dir := t.TempDir()
if _, err := EmitPluginBundle(PluginBundleSpec{
TargetDir: dir,
Version: "0.18.2",
}); err != nil {
t.Fatal(err)
}
body, err := os.ReadFile(filepath.Join(dir, ".mcp.json"))
if err != nil {
t.Fatal(err)
}
var mcp map[string]any
if err := json.Unmarshal(body, &mcp); err != nil {
t.Fatalf(".mcp.json: invalid JSON: %v", err)
}
gx, ok := mcp["gortex"].(map[string]any)
if !ok {
t.Fatalf(".mcp.json: missing 'gortex' entry, got %v", mcp)
}
if got := gx["command"]; got != "gortex" {
t.Errorf(".mcp.json command = %v, want gortex", got)
}
args, ok := gx["args"].([]any)
if !ok || len(args) != 1 || args[0] != "mcp" {
t.Errorf(".mcp.json args = %v, want [mcp]", args)
}
}
func TestEmitPluginBundle_HooksShape(t *testing.T) {
dir := t.TempDir()
if _, err := EmitPluginBundle(PluginBundleSpec{
TargetDir: dir,
Version: "0.18.2",
}); err != nil {
t.Fatal(err)
}
body, err := os.ReadFile(filepath.Join(dir, "hooks", "hooks.json"))
if err != nil {
t.Fatal(err)
}
var doc map[string]any
if err := json.Unmarshal(body, &doc); err != nil {
t.Fatalf("hooks.json: invalid JSON: %v", err)
}
hooks, ok := doc["hooks"].(map[string]any)
if !ok {
t.Fatalf("hooks.json: missing top-level 'hooks' object: %v", doc)
}
// All four event kinds must be present; each must reference the
// hooks-handlers script via ${CLAUDE_PLUGIN_ROOT}.
for _, event := range []string{"PreToolUse", "PreCompact", "Stop", "SessionStart"} {
entries, ok := hooks[event].([]any)
if !ok || len(entries) == 0 {
t.Errorf("hooks.json: event %s missing or empty", event)
continue
}
entry := entries[0].(map[string]any)
inner, ok := entry["hooks"].([]any)
if !ok || len(inner) == 0 {
t.Errorf("hooks.json: %s has no inner hooks", event)
continue
}
first := inner[0].(map[string]any)
cmd, _ := first["command"].(string)
if !strings.Contains(cmd, "${CLAUDE_PLUGIN_ROOT}") {
t.Errorf("hooks.json: %s command does not reference ${CLAUDE_PLUGIN_ROOT}: %q", event, cmd)
}
if !strings.Contains(cmd, "gortex-hook.sh") {
t.Errorf("hooks.json: %s command does not invoke gortex-hook.sh: %q", event, cmd)
}
}
// PreToolUse should carry the canonical matcher.
pre := hooks["PreToolUse"].([]any)
matcher, _ := pre[0].(map[string]any)["matcher"].(string)
if matcher != CurrentPreToolUseMatcher {
t.Errorf("hooks.json: PreToolUse matcher = %q, want %q", matcher, CurrentPreToolUseMatcher)
}
}
func TestEmitPluginBundle_HookHandlerExecBit(t *testing.T) {
dir := t.TempDir()
if _, err := EmitPluginBundle(PluginBundleSpec{
TargetDir: dir,
Version: "0.18.2",
}); err != nil {
t.Fatal(err)
}
info, err := os.Stat(filepath.Join(dir, "hooks-handlers", "gortex-hook.sh"))
if err != nil {
t.Fatal(err)
}
if info.Mode()&0o111 == 0 {
t.Errorf("hooks-handlers/gortex-hook.sh should be executable (mode=%v)", info.Mode())
}
}
func TestEmitPluginBundle_SkillBodiesMatchSource(t *testing.T) {
dir := t.TempDir()
if _, err := EmitPluginBundle(PluginBundleSpec{
TargetDir: dir,
Version: "0.18.2",
}); err != nil {
t.Fatal(err)
}
// Single-source-of-truth check: the bytes written under
// skills/<name>/SKILL.md must equal GlobalSkills[<name>] exactly.
// Drift means content.go was edited without re-running the
// emitter; the CI guard catches that case but we double-check
// here in unit tests.
for name, want := range GlobalSkills {
got, err := os.ReadFile(filepath.Join(dir, "skills", name, "SKILL.md"))
if err != nil {
t.Errorf("read skill %s: %v", name, err)
continue
}
if string(got) != want {
t.Errorf("skill %s body drift: bytes do not match GlobalSkills[%q]", name, name)
}
}
}
func TestEmitPluginBundle_CommandBodiesMatchSource(t *testing.T) {
dir := t.TempDir()
if _, err := EmitPluginBundle(PluginBundleSpec{
TargetDir: dir,
Version: "0.18.2",
}); err != nil {
t.Fatal(err)
}
for name, want := range SlashCommands {
got, err := os.ReadFile(filepath.Join(dir, "commands", name))
if err != nil {
t.Errorf("read command %s: %v", name, err)
continue
}
if string(got) != want {
t.Errorf("command %s body drift", name)
}
}
}
func TestEmitPluginBundle_Idempotent(t *testing.T) {
dir := t.TempDir()
first, err := EmitPluginBundle(PluginBundleSpec{TargetDir: dir, Version: "0.18.2"})
if err != nil {
t.Fatal(err)
}
second, err := EmitPluginBundle(PluginBundleSpec{TargetDir: dir, Version: "0.18.2"})
if err != nil {
t.Fatalf("second emit: %v", err)
}
if len(first) != len(second) {
t.Fatalf("file count drift between runs: %d vs %d", len(first), len(second))
}
// Every file's bytes must be identical across the two runs.
for _, rel := range first {
path := filepath.Join(dir, rel)
body1, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
// Re-emit and re-read: same path, must equal first body.
body2, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(body1) != string(body2) {
t.Errorf("non-idempotent output at %s", rel)
}
}
}
func TestEmitPluginBundle_RejectsBadInputs(t *testing.T) {
cases := map[string]PluginBundleSpec{
"missing-target": {Version: "0.18.2"},
"missing-version": {TargetDir: t.TempDir()},
"bad-variant": {TargetDir: t.TempDir(), Version: "0.18.2", LayoutVariant: "weird"},
}
for name, spec := range cases {
_, err := EmitPluginBundle(spec)
if err == nil {
t.Errorf("%s: expected error, got nil", name)
}
}
}
func TestEmitPluginBundle_VariantDefaultsToAnthropic(t *testing.T) {
dir := t.TempDir()
// Empty LayoutVariant should fall through to LayoutVariantAnthropic.
_, err := EmitPluginBundle(PluginBundleSpec{TargetDir: dir, Version: "0.18.2"})
if err != nil {
t.Fatalf("empty variant: %v", err)
}
// Verify by checking that the Anthropic-shaped manifest exists.
if _, err := os.Stat(filepath.Join(dir, ".claude-plugin", "plugin.json")); err != nil {
t.Errorf("default variant did not produce .claude-plugin/plugin.json: %v", err)
}
}
func TestEmitPluginBundle_OverwritesExistingFiles(t *testing.T) {
dir := t.TempDir()
// Pre-seed an old README to ensure overwrite works.
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("OLD\n"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := EmitPluginBundle(PluginBundleSpec{TargetDir: dir, Version: "0.18.2"}); err != nil {
t.Fatal(err)
}
body, err := os.ReadFile(filepath.Join(dir, "README.md"))
if err != nil {
t.Fatal(err)
}
if string(body) == "OLD\n" {
t.Errorf("emitter did not overwrite existing README.md")
}
}
func TestPluginPathHelpers(t *testing.T) {
if PluginManifestPath() != filepath.Join(".claude-plugin", "plugin.json") {
t.Errorf("PluginManifestPath wrong: %s", PluginManifestPath())
}
if PluginMCPPath() != ".mcp.json" {
t.Errorf("PluginMCPPath wrong: %s", PluginMCPPath())
}
if PluginHooksPath() != filepath.Join("hooks", "hooks.json") {
t.Errorf("PluginHooksPath wrong: %s", PluginHooksPath())
}
cmds := PluginCommandPaths()
if len(cmds) != len(SlashCommands) {
t.Errorf("PluginCommandPaths count: got %d, want %d", len(cmds), len(SlashCommands))
}
skills := PluginSkillPaths()
if len(skills) != len(GlobalSkills) {
t.Errorf("PluginSkillPaths count: got %d, want %d", len(skills), len(GlobalSkills))
}
subagents := PluginSubAgentPaths()
if len(subagents) != len(SubAgents) {
t.Errorf("PluginSubAgentPaths count: got %d, want %d", len(subagents), len(SubAgents))
}
}
func TestEmitPluginBundle_SubAgentBodiesMatchSource(t *testing.T) {
dir := t.TempDir()
if _, err := EmitPluginBundle(PluginBundleSpec{
TargetDir: dir,
Version: "0.18.2",
}); err != nil {
t.Fatal(err)
}
for name, want := range SubAgents {
got, err := os.ReadFile(filepath.Join(dir, "agents", name))
if err != nil {
t.Errorf("read sub-agent %s: %v", name, err)
continue
}
if string(got) != want {
t.Errorf("sub-agent %s body drift: bytes do not match SubAgents[%q]", name, name)
}
}
}
func TestEmitPluginBundle_MissingTargetParent(t *testing.T) {
// MkdirAll handles non-existent parents — verify that.
dir := filepath.Join(t.TempDir(), "deep", "nested", "path")
_, err := EmitPluginBundle(PluginBundleSpec{TargetDir: dir, Version: "0.18.2"})
if err != nil {
t.Errorf("EmitPluginBundle should create parent dirs: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, ".claude-plugin", "plugin.json")); err != nil {
if errors.Is(err, os.ErrNotExist) {
t.Errorf("emitter did not create plugin.json at deep target")
}
}
}
+104
View File
@@ -0,0 +1,104 @@
package claudecode
import "strings"
// SubAgents maps the filename under .claude/agents/ to the markdown
// body of a Claude Code sub-agent definition.
//
// Sub-agents are a distinct Claude Code ship channel from skills and
// slash commands: the parent agent spawns a sub-agent in a fresh
// context window with a scoped tool allowlist, and the sub-agent
// returns a single summary message. That makes them the right
// container for long-running search and impact-analysis loops — the
// parent's context stays clean and the tool allowlist enforces
// graph-only access by construction.
//
// Like GlobalSkills and SlashCommands, sub-agents are codebase-agnostic
// user-level artifacts: installed by `gortex install` into
// ~/.claude/agents/ and emitted into the marketplace plugin under
// agents/ — never written in project mode.
var SubAgents = map[string]string{
"gortex-search.md": subagentSearch,
"gortex-impact.md": subagentImpact,
}
// SubAgentTools parses the `tools:` allowlist out of a sub-agent definition's
// YAML frontmatter, returning the declared MCP tool names in order (nil when
// the definition declares no tools line). Claude Code spawns each sub-agent in
// a fresh context restricted to exactly this allowlist, so the list is how
// Gortex propagates its MCP tools to sub-agents — and how it guarantees a
// sub-agent stays graph-only (no Bash/Grep/Glob escape).
func SubAgentTools(def string) []string {
for _, line := range strings.Split(def, "\n") {
t := strings.TrimSpace(line)
if !strings.HasPrefix(t, "tools:") {
continue
}
rest := strings.TrimSpace(strings.TrimPrefix(t, "tools:"))
var out []string
for _, name := range strings.Split(rest, ",") {
if n := strings.TrimSpace(name); n != "" {
out = append(out, n)
}
}
return out
}
return nil
}
// subagentSearch handles exploratory codebase questions: locating
// symbols, tracing call paths, mapping architecture. Restricting the
// tool allowlist to gortex graph tools (+ read_file as a fallback for
// non-indexed assets) forces the sub-agent onto the graph — no Bash,
// no Grep, no Glob escape.
const subagentSearch = `---
name: gortex-search
description: "Locate code, trace call paths, or map architecture in a fresh context."
tools: mcp__gortex__smart_context, mcp__gortex__surface_memories, mcp__gortex__search_symbols, mcp__gortex__search_text, mcp__gortex__get_symbol_source, mcp__gortex__get_editing_context, mcp__gortex__get_file_summary, mcp__gortex__find_usages, mcp__gortex__get_callers, mcp__gortex__get_call_chain, mcp__gortex__get_dependencies, mcp__gortex__get_dependents, mcp__gortex__get_repo_outline, mcp__gortex__get_architecture, mcp__gortex__graph_stats, mcp__gortex__get_symbol, mcp__gortex__read_file
---
You are the Gortex search sub-agent. The parent agent delegated a code-locating, exploration, or call-tracing question to you. You return a single summary message — the parent does not see your tool calls.
Your tool surface is graph-only. There is no Bash, Grep, or Glob. Use the graph:
1. Call ` + "`smart_context`" + ` with the task description first. One call replaces 5-10 read-and-search round trips.
2. Call ` + "`surface_memories`" + ` with the symbols smart_context surfaced, to pick up invariants / gotchas the team has recorded.
3. For "where is X" — ` + "`search_symbols`" + ` then ` + "`get_symbol_source`" + `. For a raw string / literal that is not a symbol name — ` + "`search_text`" + ` (trigram-indexed; the grep replacement).
4. For "what calls X" — ` + "`get_callers`" + ` or ` + "`get_call_chain`" + ` (NOT ` + "`find_usages`" + `, which over-returns text matches; callers gives you the precise call graph).
5. For "what does X depend on" — ` + "`get_dependencies`" + ` (out) and ` + "`get_dependents`" + ` (in).
6. For architecture / "how does the system fit together" — ` + "`get_architecture`" + ` (one-shot snapshot; pass ` + "`resolution`" + ` for a hierarchical rollup) or ` + "`get_repo_outline`" + `, then drill into the named communities with ` + "`smart_context`" + `.
Pass ` + "`format: \"gcx\"`" + ` to any list-shaped call (search_symbols, find_usages, smart_context, get_callers, get_dependencies, get_dependents) — round-trippable compact wire format, ~27% fewer tokens.
When you reply to the parent: give the answer first (symbol IDs, file:line, the call chain), then the evidence (one or two key source fragments), then any caveats. Do not dump raw tool output. The parent paid for context isolation — earn it by summarising.
`
// subagentImpact handles "what breaks if I change X" questions.
// Returns a small, high-signal answer (caller count, broken interface
// implementors, suggested tests) from a heavy graph traversal that
// would otherwise blow up the parent's context.
const subagentImpact = `---
name: gortex-impact
description: "Assess a change's blast radius — broken callers, contracts, tests."
tools: mcp__gortex__smart_context, mcp__gortex__surface_memories, mcp__gortex__search_symbols, mcp__gortex__get_symbol_source, mcp__gortex__find_usages, mcp__gortex__get_callers, mcp__gortex__get_call_chain, mcp__gortex__get_dependents, mcp__gortex__verify_change, mcp__gortex__explain_change_impact, mcp__gortex__analyze, mcp__gortex__simulate_chain, mcp__gortex__preview_edit, mcp__gortex__check_guards, mcp__gortex__get_test_targets, mcp__gortex__contracts, mcp__gortex__read_file
---
You are the Gortex impact-analysis sub-agent. The parent delegated a "what will break" question to you. You return a single summary message.
Your job is to convert a proposed change into a small, actionable impact report. The graph already knows callers and contracts — your output should be the conclusion, not the traversal.
Workflow:
1. ` + "`smart_context`" + ` on the affected symbols to get the working set.
2. ` + "`surface_memories`" + ` to pick up invariants on the touched symbols (memories often warn about why a signature is load-bearing).
3. If the change is a signature edit — ` + "`verify_change`" + ` with the proposed new_signature. Returns every broken caller and interface implementor.
4. If the change is more structural (deletion, contract revision) — ` + "`get_callers`" + ` + ` + "`get_dependents`" + ` + ` + "`contracts`" + `.
5. For a speculative WorkspaceEdit — ` + "`preview_edit`" + ` or ` + "`simulate_chain`" + `. The graph diff is computed on disk-untouched state.
6. ` + "`check_guards`" + ` against the changed symbol IDs to surface team rules.
7. ` + "`get_test_targets`" + ` to enumerate tests that exercise the change.
8. When the parent wants a single risk number rather than a caller list — ` + "`analyze`" + ` with ` + "`kind: \"impact\"`" + ` returns a composite 0-100 change-impact score + risk label over five axes (PageRank, reach, complexity, co-change, community span).
Pass ` + "`format: \"gcx\"`" + ` on list-shaped responses for ~27% fewer tokens.
Reply structure: (a) one-line verdict — safe / risky / breaking — (b) the broken callers / contracts / guards if any, with file:line, (c) the test targets the parent should run. Do not paste full source. The parent wants the conclusion plus enough breadcrumbs to verify; the heavy lifting stays in your context.
`
@@ -0,0 +1,50 @@
package claudecode
import (
"strings"
"testing"
"github.com/stretchr/testify/require"
)
// TestSubAgentToolPropagation locks in the invariant that every Gortex
// sub-agent declares an explicit, graph-only MCP tool allowlist — the
// mechanism by which Gortex propagates its tools to sub-agents and prevents a
// sub-agent from escaping to Bash/Grep/Glob.
func TestSubAgentToolPropagation(t *testing.T) {
require.Contains(t, SubAgents, "gortex-search.md")
require.Contains(t, SubAgents, "gortex-impact.md")
for name, def := range SubAgents {
t.Run(name, func(t *testing.T) {
tools := SubAgentTools(def)
require.NotEmpty(t, tools,
"%s must declare a tools allowlist so the sub-agent inherits Gortex tools", name)
seen := map[string]bool{}
for _, tool := range tools {
require.Truef(t, strings.HasPrefix(tool, "mcp__gortex__"),
"%s lists non-gortex tool %q — the allowlist must be graph-only (no Bash/Grep/Glob escape)", name, tool)
require.Falsef(t, seen[tool], "%s lists duplicate tool %q", name, tool)
seen[tool] = true
}
// smart_context is the shared entry point every sub-agent should hold.
require.Truef(t, seen["mcp__gortex__smart_context"],
"%s should grant smart_context (the one-call working-set entry point)", name)
})
}
}
// TestSubAgentFrontmatterNameMatchesFile guards against a rename drifting the
// frontmatter `name:` away from the installed filename.
func TestSubAgentFrontmatterNameMatchesFile(t *testing.T) {
for name, def := range SubAgents {
base := strings.TrimSuffix(name, ".md")
require.Containsf(t, def, "name: "+base,
"%s frontmatter name must match its filename", name)
}
}
func TestSubAgentToolsParsesEmpty(t *testing.T) {
require.Nil(t, SubAgentTools("---\nname: x\ndescription: no tools line\n---\nbody"))
}
+144
View File
@@ -0,0 +1,144 @@
// Package cline implements the Gortex init integration for Cline
// (formerly Claude Dev — extension ID saoudrizwan.claude-dev). Cline
// stores its MCP config under the host editor's globalStorage
// directory; we probe both the VS Code and Cursor variants on each
// OS. The Step 3 audit will add JetBrains support and verify whether
// Cline has renamed alwaysAllow → autoApprove.
package cline
import (
"os"
"path/filepath"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/internalutil"
)
const Name = "cline"
const DocsURL = "https://docs.cline.bot/mcp/mcp-overview"
type Adapter struct{}
func New() *Adapter { return &Adapter{} }
func (a *Adapter) Name() string { return Name }
func (a *Adapter) DocsURL() string { return DocsURL }
// settingsPaths returns candidate cline_mcp_settings.json locations
// across OS/editor combos. Order doesn't matter — we write to every
// path whose parent directory exists.
func settingsPaths(home string) []string {
var paths []string
vscodeBases := []string{
filepath.Join(home, "Library", "Application Support", "Code", "User", "globalStorage", "saoudrizwan.claude-dev", "settings"),
filepath.Join(home, ".config", "Code", "User", "globalStorage", "saoudrizwan.claude-dev", "settings"),
}
cursorBases := []string{
filepath.Join(home, "Library", "Application Support", "Cursor", "User", "globalStorage", "saoudrizwan.claude-dev", "settings"),
filepath.Join(home, ".config", "Cursor", "User", "globalStorage", "saoudrizwan.claude-dev", "settings"),
}
for _, dir := range append(vscodeBases, cursorBases...) {
paths = append(paths, filepath.Join(dir, "cline_mcp_settings.json"))
}
return paths
}
// alwaysAllow is Cline's auto-approve allow-list. Matches the
// Gortex tool surface so Cline doesn't ask the user on every query.
var alwaysAllow = []string{
"graph_stats", "search_symbols", "winnow_symbols", "get_symbol", "get_file_summary",
"get_editing_context", "get_dependencies", "get_dependents",
"get_call_chain", "get_callers", "find_implementations", "find_usages",
"get_cluster", "get_symbol_signature", "get_symbol_source", "batch_symbols",
"find_import_path", "explain_change_impact", "get_recent_changes",
"smart_context", "get_edit_plan", "get_test_targets", "suggest_pattern",
"get_communities", "get_community", "get_processes", "get_process",
"detect_changes", "index_repository", "reindex_repository",
"verify_change", "check_guards", "prefetch_context",
"find_dead_code", "find_hotspots", "find_cycles", "would_create_cycle",
"diff_context", "index_health", "get_symbol_history",
"scaffold", "batch_edit",
"flow_between", "taint_paths",
"find_clones",
}
func (a *Adapter) Detect(env agents.Env) (bool, error) {
if env.Home == "" {
return false, nil
}
for _, p := range settingsPaths(env.Home) {
if _, err := os.Stat(filepath.Dir(p)); err == nil {
return true, nil
}
}
return false, nil
}
func (a *Adapter) Plan(env agents.Env) (*agents.Plan, error) {
p := &agents.Plan{}
if env.Home == "" {
return p, nil
}
for _, path := range settingsPaths(env.Home) {
if _, err := os.Stat(filepath.Dir(path)); err == nil {
p.Files = append(p.Files, agents.FileAction{
Path: path,
Action: agents.ActionWouldMerge,
Keys: []string{"mcpServers"},
})
}
}
if env.Mode != agents.ModeGlobal && env.SkillsRouting != "" {
p.Files = append(p.Files, agents.FileAction{
Path: filepath.Join(env.Root, ".clinerules", "gortex-communities.md"),
Action: agents.ActionWouldCreate,
Keys: []string{"communities-rule"},
})
}
return p, nil
}
func (a *Adapter) Apply(env agents.Env, opts agents.ApplyOpts) (*agents.Result, error) {
res := &agents.Result{Name: Name, DocsURL: DocsURL}
detected, _ := a.Detect(env)
res.Detected = detected
if !detected && !opts.ForceDetect {
internalutil.Logf(env.Stderr, "[gortex init] skip Cline setup (Cline not detected)")
return res, nil
}
internalutil.Logf(env.Stderr, "[gortex init] setting up Cline integration...")
for _, path := range settingsPaths(env.Home) {
if _, err := os.Stat(filepath.Dir(path)); err != nil {
continue
}
entry := agents.DefaultGortexMCPEntry()
entry["alwaysAllow"] = alwaysAllow
action, err := agents.MergeJSON(env.Stderr, path, func(root map[string]any, _ bool) (bool, error) {
return agents.UpsertMCPServer(root, "gortex", entry, opts), nil
}, opts)
if err != nil {
internalutil.Warnf(env.Stderr, "could not configure Cline at %s: %v", path, err)
continue
}
res.Files = append(res.Files, action)
}
// Cline reads .clinerules/*.md as project-scoped instructions
// on every chat turn. The community-routing file is ours
// end-to-end — regenerated each `gortex init` run so the
// listing tracks the current graph. Skipped in global mode
// (file is per-repo) and when --no-skills / no communities
// qualify.
if env.Mode != agents.ModeGlobal && env.SkillsRouting != "" {
rulesPath := filepath.Join(env.Root, ".clinerules", "gortex-communities.md")
body := agents.CommunitiesStartMarker + "\n" + env.SkillsRouting + "\n" + agents.CommunitiesEndMarker + "\n"
ruleAction, err := agents.WriteOwnedFile(env.Stderr, rulesPath, body, opts)
if err != nil {
internalutil.Warnf(env.Stderr, "could not write Cline community rules at %s: %v", rulesPath, err)
} else {
res.Files = append(res.Files, ruleAction)
}
}
res.Configured = len(res.Files) > 0
return res, nil
}
+453
View File
@@ -0,0 +1,453 @@
// Package codex implements the Gortex init integration for the
// OpenAI Codex CLI. Codex stores MCP server definitions in a TOML
// file — ~/.codex/config.toml for the default scope — under the
// [mcp_servers.<name>] table:
//
// [mcp_servers.gortex]
// command = "gortex"
// args = ["mcp", "--index", ".", "--watch"]
// [mcp_servers.gortex.env]
// GORTEX_INDEX_WORKERS = "8"
//
// Docs: https://github.com/openai/codex/blob/main/docs/config.md
package codex
import (
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/internalutil"
)
const Name = "codex"
const DocsURL = "https://developers.openai.com/codex/mcp"
const codexSessionStartMatcher = "startup|resume|clear|compact"
const codexSessionStartMessage = "IMPORTANT: Prefer Gortex MCP tools (search_symbols, get_callers, get_file_summary, edit_file) over Read/Grep/Glob/Edit."
const codexSessionStartCommand = "printf '%s\\n' '" + codexSessionStartMessage + "'"
const codexSessionStartWindowsCommand = "powershell -NoProfile -Command \"Write-Output '" + codexSessionStartMessage + "'\""
const codexPreToolUseMatcher = "^Bash$"
const codexMCPReadPreToolUseMatcher = "^mcp__gortex__(read_file|get_editing_context)$"
const codexPostToolUseMatcher = "^Bash$"
const codexHookTimeoutSeconds = 5
type Adapter struct{}
func New() *Adapter { return &Adapter{} }
func (a *Adapter) Name() string { return Name }
func (a *Adapter) DocsURL() string { return DocsURL }
// Detect checks for the codex CLI on PATH or ~/.codex/.
func (a *Adapter) Detect(env agents.Env) (bool, error) {
if p, err := exec.LookPath("codex"); err == nil && p != "" {
return true, nil
}
if env.Home == "" {
return false, nil
}
if _, err := os.Stat(filepath.Join(env.Home, ".codex")); err == nil {
return true, nil
}
return false, nil
}
func (a *Adapter) Plan(env agents.Env) (*agents.Plan, error) {
p := &agents.Plan{}
if env.Home != "" {
keys := []string{"mcp_servers"}
if env.InstallHooks {
keys = append(keys, "hooks")
}
p.Files = append(p.Files, agents.FileAction{
Path: filepath.Join(env.Home, ".codex", "config.toml"),
Action: agents.ActionWouldMerge,
Keys: keys,
})
}
if env.Mode != agents.ModeGlobal && env.SkillsRouting != "" {
p.Files = append(p.Files, agents.FileAction{
Path: filepath.Join(env.Root, "AGENTS.md"), Action: agents.ActionWouldMerge,
Keys: []string{"communities-block"},
})
}
return p, nil
}
func (a *Adapter) Apply(env agents.Env, opts agents.ApplyOpts) (*agents.Result, error) {
res := &agents.Result{Name: Name, DocsURL: DocsURL}
detected, _ := a.Detect(env)
res.Detected = detected
if !detected && !opts.ForceDetect {
internalutil.Logf(env.Stderr, "[gortex init] skip Codex setup (codex not detected)")
return res, nil
}
if env.Home == "" {
return res, fmt.Errorf("codex: requires a resolved home directory")
}
internalutil.Logf(env.Stderr, "[gortex init] setting up OpenAI Codex CLI integration...")
path := filepath.Join(env.Home, ".codex", "config.toml")
action, err := agents.MergeTOML(env.Stderr, path, func(root map[string]any, _ bool) (bool, error) {
changed := false
servers, ok := root["mcp_servers"].(map[string]any)
if !ok {
servers = make(map[string]any)
}
if _, exists := servers["gortex"]; !exists || opts.Force {
servers["gortex"] = map[string]any{
"command": "gortex",
"args": []string{"mcp"},
"env": map[string]any{
"GORTEX_INDEX_WORKERS": "8",
},
}
root["mcp_servers"] = servers
changed = true
}
if env.InstallHooks {
if upsertCodexHooks(root, env, opts) {
changed = true
}
}
return changed, nil
}, opts)
if err != nil {
return res, err
}
res.Files = append(res.Files, action)
// Repo-local community routing → AGENTS.md (also read by
// OpenCode; both adapters upsert the same marker-guarded block,
// so repeat runs converge). Skipped in global mode (AGENTS.md
// is per-repo) and when no communities were generated.
if env.Mode != agents.ModeGlobal && env.SkillsRouting != "" {
agentsMdPath := filepath.Join(env.Root, "AGENTS.md")
routingAction, err := agents.UpsertMarkedBlock(env.Stderr, agentsMdPath, env.SkillsRouting,
agents.CommunitiesStartMarker, agents.CommunitiesEndMarker, opts)
if err != nil {
return res, err
}
res.Files = append(res.Files, routingAction)
}
res.Configured = true
return res, nil
}
func upsertSessionStartHook(root map[string]any, opts agents.ApplyOpts) bool {
return upsertCodexHook(root, "SessionStart", codexHookEntryIsGortexSessionStart, codexSessionStartHookEntry(), opts)
}
func upsertPreToolUseHook(root map[string]any, env agents.Env, opts agents.ApplyOpts) bool {
desired := []map[string]any{
codexPreToolUseHookEntry(env),
codexMCPReadPreToolUseHookEntry(env),
}
return upsertCodexHookSet(root, "PreToolUse", codexHookEntryIsGortexPreToolUse, desired, opts)
}
func upsertPostToolUseHook(root map[string]any, env agents.Env, opts agents.ApplyOpts) bool {
return upsertCodexHook(root, "PostToolUse", codexHookEntryIsGortexPostToolUse, codexPostToolUseHookEntry(env), opts)
}
func upsertUserPromptSubmitHook(root map[string]any, env agents.Env, opts agents.ApplyOpts) bool {
return upsertCodexHook(root, "UserPromptSubmit", codexHookEntryIsGortexUserPromptSubmit, codexUserPromptSubmitHookEntry(env), opts)
}
// InstallHooksOnly refreshes the Codex lifecycle hooks in configPath without
// touching MCP server entries, AGENTS.md, or any other Codex adapter surface.
func InstallHooksOnly(w io.Writer, configPath string, env agents.Env, opts agents.ApplyOpts) (agents.FileAction, error) {
action, err := agents.MergeTOML(w, configPath, func(root map[string]any, _ bool) (bool, error) {
return upsertCodexHooks(root, env, opts), nil
}, opts)
if err != nil {
return agents.FileAction{}, err
}
if action.Action != agents.ActionSkip {
action.Keys = []string{"hooks"}
}
return action, nil
}
func upsertCodexHooks(root map[string]any, env agents.Env, opts agents.ApplyOpts) bool {
sessionChanged := upsertSessionStartHook(root, opts)
preChanged := upsertPreToolUseHook(root, env, opts)
postChanged := upsertPostToolUseHook(root, env, opts)
promptChanged := upsertUserPromptSubmitHook(root, env, opts)
return sessionChanged || preChanged || postChanged || promptChanged
}
func upsertCodexHook(root map[string]any, event string, isGortex func(any) bool, desired map[string]any, opts agents.ApplyOpts) bool {
hooks, ok := root["hooks"].(map[string]any)
if !ok {
if _, exists := root["hooks"]; exists {
return false
}
hooks = make(map[string]any)
}
entries, ok := codexHookList(hooks[event])
if !ok {
return false
}
found := false
kept := make([]any, 0, len(entries)+1)
for _, entry := range entries {
if isGortex(entry) {
found = true
if opts.Force {
continue
}
}
kept = append(kept, entry)
}
if found && !opts.Force {
return false
}
hooks[event] = append(kept, desired)
root["hooks"] = hooks
return true
}
func upsertCodexHookSet(root map[string]any, event string, isGortex func(any) bool, desired []map[string]any, opts agents.ApplyOpts) bool {
hooks, ok := root["hooks"].(map[string]any)
if !ok {
if _, exists := root["hooks"]; exists {
return false
}
hooks = make(map[string]any)
}
entries, ok := codexHookList(hooks[event])
if !ok {
return false
}
found := make([]bool, len(desired))
kept := make([]any, 0, len(entries)+len(desired))
for _, entry := range entries {
if isGortex(entry) {
if opts.Force {
continue
}
for i, want := range desired {
if !found[i] && codexHookEntryMatchesDesired(entry, want) {
found[i] = true
break
}
}
}
kept = append(kept, entry)
}
changed := false
for i, want := range desired {
if opts.Force || !found[i] {
kept = append(kept, want)
changed = true
}
}
if !changed {
return false
}
hooks[event] = kept
root["hooks"] = hooks
return true
}
func codexHookEntryMatchesDesired(entry any, desired map[string]any) bool {
matcher, _ := desired["matcher"].(string)
return codexHookEntryHasMatcher(entry, matcher) && codexHookEntryInvokesCodexHook(entry)
}
func codexHookEntryHasMatcher(entry any, matcher string) bool {
group, ok := entry.(map[string]any)
if !ok {
return false
}
got, _ := group["matcher"].(string)
return got == matcher
}
func codexHookList(v any) ([]any, bool) {
if v == nil {
return nil, true
}
switch list := v.(type) {
case []any:
return append([]any(nil), list...), true
case []map[string]any:
out := make([]any, 0, len(list))
for _, entry := range list {
out = append(out, entry)
}
return out, true
default:
return nil, false
}
}
func codexHookEntryIsGortexSessionStart(entry any) bool {
group, ok := entry.(map[string]any)
if !ok {
return false
}
handlers, ok := codexHookList(group["hooks"])
if !ok {
return false
}
for _, handler := range handlers {
hm, ok := handler.(map[string]any)
if !ok {
continue
}
if cmd, _ := hm["command"].(string); cmd == codexSessionStartCommand {
return true
}
if cmd, _ := hm["command_windows"].(string); cmd == codexSessionStartWindowsCommand {
return true
}
}
return false
}
func codexHookEntryIsGortexPreToolUse(entry any) bool {
return codexHookEntryInvokesCodexHook(entry)
}
func codexHookEntryIsGortexPostToolUse(entry any) bool {
return codexHookEntryInvokesCodexHook(entry)
}
func codexHookEntryIsGortexUserPromptSubmit(entry any) bool {
return codexHookEntryInvokesCodexHook(entry)
}
func codexHookEntryInvokesCodexHook(entry any) bool {
group, ok := entry.(map[string]any)
if !ok {
return false
}
handlers, ok := codexHookList(group["hooks"])
if !ok {
return false
}
for _, handler := range handlers {
hm, ok := handler.(map[string]any)
if !ok {
continue
}
if cmd, _ := hm["command"].(string); codexCommandInvokesCodexHook(cmd) {
return true
}
}
return false
}
func codexCommandInvokesCodexHook(cmd string) bool {
cmd = strings.TrimSpace(cmd)
if cmd == "" {
return false
}
lower := strings.ToLower(cmd)
if !strings.Contains(lower, "gortex") || !strings.Contains(lower, "hook") {
return false
}
return strings.Contains(cmd, "--agent=codex") || strings.Contains(cmd, "--agent codex")
}
func codexSessionStartHookEntry() map[string]any {
return map[string]any{
"matcher": codexSessionStartMatcher,
"hooks": []any{
map[string]any{
"type": "command",
"command": codexSessionStartCommand,
"command_windows": codexSessionStartWindowsCommand,
"timeout": codexHookTimeoutSeconds,
"statusMessage": "Loading Gortex graph orientation...",
},
},
}
}
func codexPreToolUseHookEntry(env agents.Env) map[string]any {
return map[string]any{
"matcher": codexPreToolUseMatcher,
"hooks": []any{
map[string]any{
"type": "command",
"command": codexPreToolUseCommand(env),
"timeout": codexHookTimeoutSeconds,
"statusMessage": "Loading Gortex Bash guidance...",
},
},
}
}
func codexMCPReadPreToolUseHookEntry(env agents.Env) map[string]any {
return map[string]any{
"matcher": codexMCPReadPreToolUseMatcher,
"hooks": []any{
map[string]any{
"type": "command",
"command": codexPreToolUseCommand(env),
"timeout": codexHookTimeoutSeconds,
"statusMessage": "Loading Gortex read guidance...",
},
},
}
}
func codexPostToolUseHookEntry(env agents.Env) map[string]any {
return map[string]any{
"matcher": codexPostToolUseMatcher,
"hooks": []any{
map[string]any{
"type": "command",
"command": codexHookCommand(env),
"timeout": codexHookTimeoutSeconds,
"statusMessage": "Loading Gortex Bash output context...",
},
},
}
}
// codexUserPromptSubmitHookEntry fires on every user turn — Codex's
// UserPromptSubmit event takes no matcher (it can't filter by tool name), so
// the entry omits one. The handler probes the graph for symbols relevant to
// the prompt and injects them as additionalContext, re-surfacing Gortex on
// every turn instead of relying on the SessionStart orientation to persist.
func codexUserPromptSubmitHookEntry(env agents.Env) map[string]any {
return map[string]any{
"hooks": []any{
map[string]any{
"type": "command",
"command": codexHookCommand(env),
"timeout": codexHookTimeoutSeconds,
"statusMessage": "Surfacing Gortex graph context for your prompt...",
},
},
}
}
func codexPreToolUseCommand(env agents.Env) string {
return codexHookCommand(env)
}
func codexHookCommand(env agents.Env) string {
base := strings.TrimSpace(env.HookCommand)
if base == "" {
base = "gortex hook"
}
return base + " --agent=codex --mode=enrich"
}
+820
View File
@@ -0,0 +1,820 @@
package codex
import (
"os"
"path/filepath"
"strings"
"testing"
toml "github.com/pelletier/go-toml/v2"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/agentstest"
)
const testCodexHookCommand = "/tmp/test-gortex hook --agent=codex --mode=enrich"
// TestCodexWritesMcpServersTOMLTable verifies we produce the
// documented [mcp_servers.gortex] table — not a legacy
// [mcp.gortex] or [mcpServers.gortex].
func TestCodexWritesMcpServersTOMLTable(t *testing.T) {
env, _ := agentstest.NewEnv(t)
// Detection sentinel: ~/.codex/ exists.
if err := os.MkdirAll(filepath.Join(env.Home, ".codex"), 0o755); err != nil {
t.Fatal(err)
}
a := New()
res, err := a.Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
// Two creates: ~/.codex/config.toml for MCP plus AGENTS.md, the
// per-repo instructions file Codex CLI reads on every task.
agentstest.AssertCountsByAction(t, res, map[agents.ActionKind]int{agents.ActionCreate: 2})
data, err := os.ReadFile(filepath.Join(env.Home, ".codex", "config.toml"))
if err != nil {
t.Fatalf("read config.toml: %v", err)
}
got := string(data)
if !strings.Contains(got, "mcp_servers") {
t.Fatalf("expected mcp_servers table: %s", got)
}
if !strings.Contains(got, "gortex") {
t.Fatalf("expected gortex entry: %s", got)
}
cfg := readCodexConfig(t, env)
if count := gortexSessionStartHookCount(t, cfg); count != 1 {
t.Fatalf("expected one Gortex SessionStart hook, got %d: %#v", count, cfg["hooks"])
}
assertGortexPreToolUseHooks(t, cfg)
if count := gortexPostToolUseHookCount(t, cfg); count != 1 {
t.Fatalf("expected one Gortex PostToolUse hook, got %d: %#v", count, cfg["hooks"])
}
agentstest.AssertIdempotent(t, a, env)
}
func TestCodexInstallsSessionStartHook(t *testing.T) {
env := codexGlobalEnv(t)
a := New()
res, err := a.Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
agentstest.AssertCountsByAction(t, res, map[agents.ActionKind]int{agents.ActionCreate: 1})
cfg := readCodexConfig(t, env)
entries := sessionStartEntries(t, cfg)
if len(entries) != 1 {
t.Fatalf("SessionStart entries=%d want 1: %#v", len(entries), entries)
}
entry := entries[0].(map[string]any)
if entry["matcher"] != codexSessionStartMatcher {
t.Fatalf("matcher=%v want %q", entry["matcher"], codexSessionStartMatcher)
}
handlers, ok := codexHookList(entry["hooks"])
if !ok || len(handlers) != 1 {
t.Fatalf("handlers=%#v", entry["hooks"])
}
handler := handlers[0].(map[string]any)
if handler["type"] != "command" {
t.Errorf("hook type=%v want command", handler["type"])
}
if handler["command"] != codexSessionStartCommand {
t.Errorf("command=%v want %q", handler["command"], codexSessionStartCommand)
}
if handler["command_windows"] != codexSessionStartWindowsCommand {
t.Errorf("command_windows=%v want %q", handler["command_windows"], codexSessionStartWindowsCommand)
}
command := handler["command"].(string)
if !strings.Contains(command, "IMPORTANT: Prefer Gortex MCP tools") {
t.Errorf("command should emit the graph-tools orientation: %v", handler["command"])
}
if !strings.Contains(command, "edit_file") {
t.Errorf("command should mention edit_file: %v", handler["command"])
}
if strings.Contains(command, "[Gortex]") {
t.Errorf("command should not duplicate the Gortex label: %v", handler["command"])
}
}
func TestCodexInstallsPreToolUseHook(t *testing.T) {
env := codexGlobalEnv(t)
a := New()
res, err := a.Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
agentstest.AssertCountsByAction(t, res, map[agents.ActionKind]int{agents.ActionCreate: 1})
cfg := readCodexConfig(t, env)
entries := preToolUseEntries(t, cfg)
if len(entries) != 2 {
t.Fatalf("PreToolUse entries=%d want Bash+MCP read entries: %#v", len(entries), entries)
}
assertGortexPreToolUseHooks(t, cfg)
bashHandler := requireHookEntry(t, cfg, "PreToolUse", codexPreToolUseMatcher, testCodexHookCommand)
mcpHandler := requireHookEntry(t, cfg, "PreToolUse", codexMCPReadPreToolUseMatcher, testCodexHookCommand)
for name, handler := range map[string]map[string]any{"Bash": bashHandler, "MCP read": mcpHandler} {
if handler["type"] != "command" {
t.Errorf("%s hook type=%v want command", name, handler["type"])
}
if handler["timeout"] != int64(codexHookTimeoutSeconds) {
t.Errorf("%s timeout=%v want %d", name, handler["timeout"], codexHookTimeoutSeconds)
}
}
if bashHandler["statusMessage"] != "Loading Gortex Bash guidance..." {
t.Errorf("Bash statusMessage=%v", bashHandler["statusMessage"])
}
if mcpHandler["statusMessage"] != "Loading Gortex read guidance..." {
t.Errorf("MCP read statusMessage=%v", mcpHandler["statusMessage"])
}
}
func TestCodexInstallsPostToolUseHook(t *testing.T) {
env := codexGlobalEnv(t)
a := New()
res, err := a.Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
agentstest.AssertCountsByAction(t, res, map[agents.ActionKind]int{agents.ActionCreate: 1})
cfg := readCodexConfig(t, env)
entries := postToolUseEntries(t, cfg)
if len(entries) != 1 {
t.Fatalf("PostToolUse entries=%d want 1: %#v", len(entries), entries)
}
entry := entries[0].(map[string]any)
if entry["matcher"] != codexPostToolUseMatcher {
t.Fatalf("matcher=%v want %q", entry["matcher"], codexPostToolUseMatcher)
}
handlers, ok := codexHookList(entry["hooks"])
if !ok || len(handlers) != 1 {
t.Fatalf("handlers=%#v", entry["hooks"])
}
handler := handlers[0].(map[string]any)
if handler["type"] != "command" {
t.Errorf("hook type=%v want command", handler["type"])
}
command := handler["command"].(string)
if command != testCodexHookCommand {
t.Errorf("command=%v want test hook command with --agent=codex --mode=enrich", command)
}
if handler["timeout"] != int64(codexHookTimeoutSeconds) {
t.Errorf("timeout=%v want %d", handler["timeout"], codexHookTimeoutSeconds)
}
}
func TestCodexInstallsUserPromptSubmitHook(t *testing.T) {
env := codexGlobalEnv(t)
a := New()
res, err := a.Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
agentstest.AssertCountsByAction(t, res, map[agents.ActionKind]int{agents.ActionCreate: 1})
cfg := readCodexConfig(t, env)
entries := userPromptSubmitEntries(t, cfg)
if len(entries) != 1 {
t.Fatalf("UserPromptSubmit entries=%d want 1: %#v", len(entries), entries)
}
entry := entries[0].(map[string]any)
// UserPromptSubmit takes no matcher — Codex can't filter it by tool name.
if _, hasMatcher := entry["matcher"]; hasMatcher {
t.Fatalf("UserPromptSubmit entry should carry no matcher: %#v", entry)
}
handlers, ok := codexHookList(entry["hooks"])
if !ok || len(handlers) != 1 {
t.Fatalf("handlers=%#v", entry["hooks"])
}
handler := handlers[0].(map[string]any)
if handler["type"] != "command" {
t.Errorf("hook type=%v want command", handler["type"])
}
if handler["command"] != testCodexHookCommand {
t.Errorf("command=%v want %q", handler["command"], testCodexHookCommand)
}
if handler["timeout"] != int64(codexHookTimeoutSeconds) {
t.Errorf("timeout=%v want %d", handler["timeout"], codexHookTimeoutSeconds)
}
if count := gortexUserPromptSubmitHookCount(t, cfg); count != 1 {
t.Fatalf("Gortex UserPromptSubmit hooks=%d want 1", count)
}
}
func TestCodexInstallHooksOnlyCreatesOnlyHooks(t *testing.T) {
env := codexGlobalEnv(t)
path := codexConfigPath(env)
action, err := InstallHooksOnly(env.Stderr, path, env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("install hooks only: %v", err)
}
if action.Action != agents.ActionCreate {
t.Fatalf("action=%s want create", action.Action)
}
if len(action.Keys) != 1 || action.Keys[0] != "hooks" {
t.Fatalf("keys=%#v want hooks only", action.Keys)
}
cfg := readCodexConfig(t, env)
if _, ok := cfg["mcp_servers"]; ok {
t.Fatalf("hooks-only should not write mcp_servers: %#v", cfg["mcp_servers"])
}
if _, err := os.Stat(filepath.Join(env.Root, "AGENTS.md")); !os.IsNotExist(err) {
t.Fatalf("hooks-only should not write AGENTS.md, stat err=%v", err)
}
if count := gortexSessionStartHookCount(t, cfg); count != 1 {
t.Fatalf("SessionStart hooks=%d want 1", count)
}
assertGortexPreToolUseHooks(t, cfg)
if count := gortexPostToolUseHookCount(t, cfg); count != 1 {
t.Fatalf("PostToolUse hooks=%d want 1", count)
}
action, err = InstallHooksOnly(env.Stderr, path, env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("second install hooks only: %v", err)
}
if action.Action != agents.ActionSkip {
t.Fatalf("second action=%s want skip", action.Action)
}
}
func TestCodexInstallHooksOnlyPreservesExistingConfig(t *testing.T) {
env := codexGlobalEnv(t)
path := codexConfigPath(env)
seed := `model = "gpt-5-codex"
[mcp_servers.gortex]
command = "custom-gortex"
args = ["mcp", "--custom"]
[mcp_servers.other]
command = "other"
[[hooks.PreToolUse]]
matcher = "^Bash$"
[[hooks.PreToolUse.hooks]]
type = "command"
command = "echo user-pretooluse"
statusMessage = "User PreToolUse"
[[hooks.PostToolUse]]
matcher = "^Bash$"
[[hooks.PostToolUse.hooks]]
type = "command"
command = "echo user-posttooluse"
statusMessage = "User PostToolUse"
`
if err := os.WriteFile(path, []byte(seed), 0o644); err != nil {
t.Fatalf("seed config: %v", err)
}
action, err := InstallHooksOnly(env.Stderr, path, env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("install hooks only: %v", err)
}
if action.Action != agents.ActionMerge {
t.Fatalf("action=%s want merge", action.Action)
}
if len(action.Keys) != 1 || action.Keys[0] != "hooks" {
t.Fatalf("keys=%#v want hooks only", action.Keys)
}
cfg := readCodexConfig(t, env)
if cfg["model"] != "gpt-5-codex" {
t.Fatalf("unrelated top-level key was clobbered: %#v", cfg)
}
servers := cfg["mcp_servers"].(map[string]any)
gortexServer := servers["gortex"].(map[string]any)
if gortexServer["command"] != "custom-gortex" {
t.Fatalf("hooks-only rewrote mcp_servers.gortex: %#v", gortexServer)
}
if _, ok := servers["other"]; !ok {
t.Fatalf("existing MCP server was clobbered: %#v", servers)
}
if !hasHookCommand(t, cfg, "PreToolUse", "echo user-pretooluse") {
t.Fatalf("user PreToolUse hook was not preserved: %#v", preToolUseEntries(t, cfg))
}
if !hasHookCommand(t, cfg, "PostToolUse", "echo user-posttooluse") {
t.Fatalf("user PostToolUse hook was not preserved: %#v", postToolUseEntries(t, cfg))
}
if count := gortexSessionStartHookCount(t, cfg); count != 1 {
t.Fatalf("SessionStart hooks=%d want 1", count)
}
assertGortexPreToolUseHooks(t, cfg)
if count := gortexPostToolUseHookCount(t, cfg); count != 1 {
t.Fatalf("PostToolUse hooks=%d want 1", count)
}
}
func TestCodexInstallHooksOnlyForceReplacesOnlyGortexHooks(t *testing.T) {
env := codexGlobalEnv(t)
path := codexConfigPath(env)
seed := `[[hooks.PreToolUse]]
matcher = "^Bash$"
[[hooks.PreToolUse.hooks]]
type = "command"
command = "echo user-pretooluse"
statusMessage = "User PreToolUse"
[[hooks.PreToolUse]]
matcher = "^Bash$"
[[hooks.PreToolUse.hooks]]
type = "command"
command = "/tmp/old-gortex hook --agent=codex --mode=enrich"
statusMessage = "Old Gortex PreToolUse"
[[hooks.PreToolUse]]
matcher = "^mcp__gortex__(read_file|get_editing_context)$"
[[hooks.PreToolUse.hooks]]
type = "command"
command = "/tmp/old-gortex hook --agent=codex --mode=enrich"
statusMessage = "Old Gortex MCP Read PreToolUse"
[[hooks.PostToolUse]]
matcher = "^Bash$"
[[hooks.PostToolUse.hooks]]
type = "command"
command = "echo user-posttooluse"
statusMessage = "User PostToolUse"
[[hooks.PostToolUse]]
matcher = "^Bash$"
[[hooks.PostToolUse.hooks]]
type = "command"
command = "/tmp/old-gortex hook --agent=codex --mode=enrich"
statusMessage = "Old Gortex PostToolUse"
`
if err := os.WriteFile(path, []byte(seed), 0o644); err != nil {
t.Fatalf("seed config: %v", err)
}
if _, err := InstallHooksOnly(env.Stderr, path, env, agents.ApplyOpts{Force: true}); err != nil {
t.Fatalf("install hooks only: %v", err)
}
cfg := readCodexConfig(t, env)
if !hasHookCommand(t, cfg, "PreToolUse", "echo user-pretooluse") {
t.Fatalf("Force removed user PreToolUse hook: %#v", preToolUseEntries(t, cfg))
}
if !hasHookCommand(t, cfg, "PostToolUse", "echo user-posttooluse") {
t.Fatalf("Force removed user PostToolUse hook: %#v", postToolUseEntries(t, cfg))
}
if hasHookCommand(t, cfg, "PreToolUse", "/tmp/old-gortex hook --agent=codex --mode=enrich") {
t.Fatalf("Force kept stale Gortex PreToolUse hook: %#v", preToolUseEntries(t, cfg))
}
if hasHookCommand(t, cfg, "PostToolUse", "/tmp/old-gortex hook --agent=codex --mode=enrich") {
t.Fatalf("Force kept stale Gortex PostToolUse hook: %#v", postToolUseEntries(t, cfg))
}
if !hasHookCommand(t, cfg, "PreToolUse", testCodexHookCommand) {
t.Fatalf("Force did not install current Gortex PreToolUse hook: %#v", preToolUseEntries(t, cfg))
}
if !hasHookCommand(t, cfg, "PostToolUse", testCodexHookCommand) {
t.Fatalf("Force did not install current Gortex PostToolUse hook: %#v", postToolUseEntries(t, cfg))
}
assertGortexPreToolUseHooks(t, cfg)
if count := gortexPostToolUseHookCount(t, cfg); count != 1 {
t.Fatalf("Gortex PostToolUse hooks=%d want 1", count)
}
}
func TestCodexInstallHooksOnlyDryRunDoesNotWrite(t *testing.T) {
env := codexGlobalEnv(t)
path := codexConfigPath(env)
action, err := InstallHooksOnly(env.Stderr, path, env, agents.ApplyOpts{DryRun: true})
if err != nil {
t.Fatalf("install hooks only dry-run: %v", err)
}
if action.Action != agents.ActionWouldCreate {
t.Fatalf("action=%s want would-create", action.Action)
}
if len(action.Keys) != 1 || action.Keys[0] != "hooks" {
t.Fatalf("keys=%#v want hooks only", action.Keys)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("dry-run should not write config.toml, stat err=%v", err)
}
}
func TestCodexPreToolUseCommandFallsBackToGortexHook(t *testing.T) {
command := codexPreToolUseCommand(agents.Env{})
if command != "gortex hook --agent=codex --mode=enrich" {
t.Fatalf("fallback command=%q", command)
}
}
func TestCodexSessionStartHookIdempotent(t *testing.T) {
env := codexGlobalEnv(t)
a := New()
if _, err := a.Apply(env, agents.ApplyOpts{}); err != nil {
t.Fatalf("first apply: %v", err)
}
res, err := a.Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("second apply: %v", err)
}
agentstest.AssertCountsByAction(t, res, map[agents.ActionKind]int{agents.ActionSkip: 1})
cfg := readCodexConfig(t, env)
if count := gortexSessionStartHookCount(t, cfg); count != 1 {
t.Fatalf("re-run duplicated Gortex SessionStart hook: got %d", count)
}
assertGortexPreToolUseHooks(t, cfg)
if count := gortexPostToolUseHookCount(t, cfg); count != 1 {
t.Fatalf("re-run duplicated Gortex PostToolUse hook: got %d", count)
}
}
func TestCodexSessionStartHookPreservesExistingConfig(t *testing.T) {
env := codexGlobalEnv(t)
path := codexConfigPath(env)
seed := `model = "gpt-5-codex"
[mcp_servers.other]
command = "other"
[[hooks.SessionStart]]
matcher = "startup"
[[hooks.SessionStart.hooks]]
type = "command"
command = "echo user-session-start"
statusMessage = "User hook"
[[hooks.PreToolUse]]
matcher = "^Bash$"
[[hooks.PreToolUse.hooks]]
type = "command"
command = "echo user-pretooluse"
statusMessage = "User PreToolUse"
[[hooks.PostToolUse]]
matcher = "^Bash$"
[[hooks.PostToolUse.hooks]]
type = "command"
command = "echo user-posttooluse"
statusMessage = "User PostToolUse"
`
if err := os.WriteFile(path, []byte(seed), 0o644); err != nil {
t.Fatalf("seed config: %v", err)
}
a := New()
res, err := a.Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
agentstest.AssertCountsByAction(t, res, map[agents.ActionKind]int{agents.ActionMerge: 1})
cfg := readCodexConfig(t, env)
if cfg["model"] != "gpt-5-codex" {
t.Fatalf("unrelated top-level key was clobbered: %#v", cfg)
}
servers := cfg["mcp_servers"].(map[string]any)
if _, ok := servers["other"]; !ok {
t.Fatalf("existing MCP server was clobbered: %#v", servers)
}
if _, ok := servers["gortex"]; !ok {
t.Fatalf("gortex MCP server missing after merge: %#v", servers)
}
entries := sessionStartEntries(t, cfg)
if len(entries) != 2 {
t.Fatalf("SessionStart entries=%d want user+gortex entries: %#v", len(entries), entries)
}
if !hasSessionStartCommand(t, cfg, "echo user-session-start") {
t.Fatalf("user SessionStart hook was not preserved: %#v", entries)
}
if count := gortexSessionStartHookCount(t, cfg); count != 1 {
t.Fatalf("Gortex SessionStart hooks=%d want 1", count)
}
preEntries := preToolUseEntries(t, cfg)
if len(preEntries) != 3 {
t.Fatalf("PreToolUse entries=%d want user+Bash+MCP read entries: %#v", len(preEntries), preEntries)
}
if !hasHookCommand(t, cfg, "PreToolUse", "echo user-pretooluse") {
t.Fatalf("user PreToolUse hook was not preserved: %#v", preEntries)
}
assertGortexPreToolUseHooks(t, cfg)
postEntries := postToolUseEntries(t, cfg)
if len(postEntries) != 2 {
t.Fatalf("PostToolUse entries=%d want user+gortex entries: %#v", len(postEntries), postEntries)
}
if !hasHookCommand(t, cfg, "PostToolUse", "echo user-posttooluse") {
t.Fatalf("user PostToolUse hook was not preserved: %#v", postEntries)
}
if count := gortexPostToolUseHookCount(t, cfg); count != 1 {
t.Fatalf("Gortex PostToolUse hooks=%d want 1", count)
}
}
func TestCodexForceReplacesOnlyGortexPreToolUseHook(t *testing.T) {
env := codexGlobalEnv(t)
path := codexConfigPath(env)
seed := `[[hooks.PreToolUse]]
matcher = "^Bash$"
[[hooks.PreToolUse.hooks]]
type = "command"
command = "echo user-pretooluse"
statusMessage = "User PreToolUse"
[[hooks.PreToolUse]]
matcher = "^Bash$"
[[hooks.PreToolUse.hooks]]
type = "command"
command = "/tmp/old-gortex hook --agent=codex --mode=enrich"
statusMessage = "Old Gortex PreToolUse"
[[hooks.PreToolUse]]
matcher = "^mcp__gortex__(read_file|get_editing_context)$"
[[hooks.PreToolUse.hooks]]
type = "command"
command = "/tmp/old-gortex hook --agent=codex --mode=enrich"
statusMessage = "Old Gortex MCP Read PreToolUse"
`
if err := os.WriteFile(path, []byte(seed), 0o644); err != nil {
t.Fatalf("seed config: %v", err)
}
a := New()
res, err := a.Apply(env, agents.ApplyOpts{Force: true})
if err != nil {
t.Fatalf("apply: %v", err)
}
agentstest.AssertCountsByAction(t, res, map[agents.ActionKind]int{agents.ActionMerge: 1})
cfg := readCodexConfig(t, env)
preEntries := preToolUseEntries(t, cfg)
if len(preEntries) != 3 {
t.Fatalf("PreToolUse entries=%d want user+Bash+MCP read entries: %#v", len(preEntries), preEntries)
}
if !hasHookCommand(t, cfg, "PreToolUse", "echo user-pretooluse") {
t.Fatalf("Force removed user PreToolUse hook: %#v", preEntries)
}
if hasHookCommand(t, cfg, "PreToolUse", "/tmp/old-gortex hook --agent=codex --mode=enrich") {
t.Fatalf("Force kept stale Gortex PreToolUse hook: %#v", preEntries)
}
if !hasHookCommand(t, cfg, "PreToolUse", testCodexHookCommand) {
t.Fatalf("Force did not install current Gortex PreToolUse hook: %#v", preEntries)
}
assertGortexPreToolUseHooks(t, cfg)
}
func TestCodexNoHooksSkipsSessionStartHook(t *testing.T) {
env := codexGlobalEnv(t)
env.InstallHooks = false
a := New()
if _, err := a.Apply(env, agents.ApplyOpts{}); err != nil {
t.Fatalf("apply: %v", err)
}
cfg := readCodexConfig(t, env)
if _, ok := cfg["hooks"]; ok {
t.Fatalf("--no-hooks should not write Codex hooks: %#v", cfg["hooks"])
}
if _, ok := cfg["mcp_servers"].(map[string]any)["gortex"]; !ok {
t.Fatal("mcp_servers.gortex should still be written under --no-hooks")
}
plan, err := a.Plan(env)
if err != nil {
t.Fatalf("plan: %v", err)
}
if len(plan.Files) != 1 {
t.Fatalf("plan files=%d want 1", len(plan.Files))
}
for _, key := range plan.Files[0].Keys {
if key == "hooks" {
t.Fatalf("Plan should not report hooks under --no-hooks: %#v", plan.Files[0].Keys)
}
}
}
func codexGlobalEnv(t *testing.T) agents.Env {
t.Helper()
env, _ := agentstest.NewEnv(t)
env.Mode = agents.ModeGlobal
if err := os.MkdirAll(filepath.Join(env.Home, ".codex"), 0o755); err != nil {
t.Fatal(err)
}
return env
}
func codexConfigPath(env agents.Env) string {
return filepath.Join(env.Home, ".codex", "config.toml")
}
func readCodexConfig(t *testing.T, env agents.Env) map[string]any {
t.Helper()
data, err := os.ReadFile(codexConfigPath(env))
if err != nil {
t.Fatalf("read config.toml: %v", err)
}
var out map[string]any
if err := toml.Unmarshal(data, &out); err != nil {
t.Fatalf("parse config.toml: %v\n%s", err, data)
}
return out
}
func sessionStartEntries(t *testing.T, cfg map[string]any) []any {
t.Helper()
return hookEntries(t, cfg, "SessionStart")
}
func preToolUseEntries(t *testing.T, cfg map[string]any) []any {
t.Helper()
return hookEntries(t, cfg, "PreToolUse")
}
func postToolUseEntries(t *testing.T, cfg map[string]any) []any {
t.Helper()
return hookEntries(t, cfg, "PostToolUse")
}
func userPromptSubmitEntries(t *testing.T, cfg map[string]any) []any {
t.Helper()
return hookEntries(t, cfg, "UserPromptSubmit")
}
func hookEntries(t *testing.T, cfg map[string]any, event string) []any {
t.Helper()
hooks, ok := cfg["hooks"].(map[string]any)
if !ok {
t.Fatalf("missing hooks map: %#v", cfg)
}
entries, ok := codexHookList(hooks[event])
if !ok {
t.Fatalf("hooks.%s has unexpected shape: %#v", event, hooks[event])
}
return entries
}
func gortexSessionStartHookCount(t *testing.T, cfg map[string]any) int {
t.Helper()
count := 0
for _, entry := range sessionStartEntries(t, cfg) {
if codexHookEntryIsGortexSessionStart(entry) {
count++
}
}
return count
}
func gortexPreToolUseHookCount(t *testing.T, cfg map[string]any) int {
t.Helper()
count := 0
for _, entry := range preToolUseEntries(t, cfg) {
if codexHookEntryIsGortexPreToolUse(entry) {
count++
}
}
return count
}
func assertGortexPreToolUseHooks(t *testing.T, cfg map[string]any) {
t.Helper()
if count := gortexPreToolUseHookCount(t, cfg); count != 2 {
t.Fatalf("Gortex PreToolUse hooks=%d want Bash+MCP read hooks: %#v", count, preToolUseEntries(t, cfg))
}
if count := hookMatcherCommandCount(t, cfg, "PreToolUse", codexPreToolUseMatcher, testCodexHookCommand); count != 1 {
t.Fatalf("Bash PreToolUse hook count=%d want 1: %#v", count, preToolUseEntries(t, cfg))
}
if count := hookMatcherCommandCount(t, cfg, "PreToolUse", codexMCPReadPreToolUseMatcher, testCodexHookCommand); count != 1 {
t.Fatalf("MCP read PreToolUse hook count=%d want 1: %#v", count, preToolUseEntries(t, cfg))
}
}
func gortexPostToolUseHookCount(t *testing.T, cfg map[string]any) int {
t.Helper()
count := 0
for _, entry := range postToolUseEntries(t, cfg) {
if codexHookEntryIsGortexPostToolUse(entry) {
count++
}
}
return count
}
func gortexUserPromptSubmitHookCount(t *testing.T, cfg map[string]any) int {
t.Helper()
count := 0
for _, entry := range userPromptSubmitEntries(t, cfg) {
if codexHookEntryIsGortexUserPromptSubmit(entry) {
count++
}
}
return count
}
func requireHookEntry(t *testing.T, cfg map[string]any, event, matcher, command string) map[string]any {
t.Helper()
for _, entry := range hookEntries(t, cfg, event) {
group, ok := entry.(map[string]any)
if !ok {
continue
}
gotMatcher, _ := group["matcher"].(string)
if gotMatcher != matcher {
continue
}
handlers, ok := codexHookList(group["hooks"])
if !ok {
continue
}
for _, handler := range handlers {
hm, ok := handler.(map[string]any)
if !ok {
continue
}
if got, _ := hm["command"].(string); got == command {
return hm
}
}
}
t.Fatalf("missing %s hook matcher=%q command=%q in %#v", event, matcher, command, hookEntries(t, cfg, event))
return nil
}
func hookMatcherCommandCount(t *testing.T, cfg map[string]any, event, matcher, command string) int {
t.Helper()
count := 0
for _, entry := range hookEntries(t, cfg, event) {
group, ok := entry.(map[string]any)
if !ok {
continue
}
gotMatcher, _ := group["matcher"].(string)
if gotMatcher != matcher {
continue
}
handlers, ok := codexHookList(group["hooks"])
if !ok {
continue
}
for _, handler := range handlers {
hm, ok := handler.(map[string]any)
if !ok {
continue
}
if got, _ := hm["command"].(string); got == command {
count++
}
}
}
return count
}
func hasSessionStartCommand(t *testing.T, cfg map[string]any, command string) bool {
t.Helper()
return hasHookCommand(t, cfg, "SessionStart", command)
}
func hasHookCommand(t *testing.T, cfg map[string]any, event string, command string) bool {
t.Helper()
for _, entry := range hookEntries(t, cfg, event) {
group, ok := entry.(map[string]any)
if !ok {
continue
}
handlers, ok := codexHookList(group["hooks"])
if !ok {
continue
}
for _, handler := range handlers {
hm, ok := handler.(map[string]any)
if !ok {
continue
}
if got, _ := hm["command"].(string); got == command {
return true
}
}
}
return false
}
+90
View File
@@ -0,0 +1,90 @@
// Package continuedev implements the Gortex init integration for
// Continue.dev. Today we write .continue/mcpServers/gortex.json
// (the legacy split-file layout); the Step 3 audit revisits whether
// Continue's current canonical form is an inline block inside
// config.yaml.
package continuedev
import (
"os"
"path/filepath"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/internalutil"
)
const Name = "continue"
const DocsURL = "https://docs.continue.dev/customize/deep-dives/mcp"
type Adapter struct{}
func New() *Adapter { return &Adapter{} }
func (a *Adapter) Name() string { return Name }
func (a *Adapter) DocsURL() string { return DocsURL }
func (a *Adapter) Detect(env agents.Env) (bool, error) {
if _, err := os.Stat(filepath.Join(env.Root, ".continue")); err == nil {
return true, nil
}
if env.Home != "" {
if _, err := os.Stat(filepath.Join(env.Home, ".continue")); err == nil {
return true, nil
}
}
return false, nil
}
func (a *Adapter) Plan(env agents.Env) (*agents.Plan, error) {
p := &agents.Plan{Files: []agents.FileAction{
{Path: filepath.Join(env.Root, ".continue", "mcpServers", "gortex.json"), Action: agents.ActionWouldMerge, Keys: []string{"mcpServers"}},
}}
if env.Mode != agents.ModeGlobal && env.SkillsRouting != "" {
p.Files = append(p.Files, agents.FileAction{
Path: filepath.Join(env.Root, ".continue", "rules", "gortex-communities.md"), Action: agents.ActionWouldCreate,
Keys: []string{"communities-rule"},
})
}
return p, nil
}
func (a *Adapter) Apply(env agents.Env, opts agents.ApplyOpts) (*agents.Result, error) {
res := &agents.Result{Name: Name, DocsURL: DocsURL}
// Global mode: Continue.dev's MCP config is project-scoped only;
// nothing to install user-wide today.
if env.Mode == agents.ModeGlobal {
return res, nil
}
detected, _ := a.Detect(env)
res.Detected = detected
if !detected && !opts.ForceDetect {
internalutil.Logf(env.Stderr, "[gortex init] skip Continue.dev setup (Continue not detected)")
return res, nil
}
internalutil.Logf(env.Stderr, "[gortex init] setting up Continue.dev integration...")
path := filepath.Join(env.Root, ".continue", "mcpServers", "gortex.json")
action, err := agents.MergeJSON(env.Stderr, path, func(root map[string]any, _ bool) (bool, error) {
return agents.UpsertMCPServer(root, "gortex", agents.DefaultGortexMCPEntry(), opts), nil
}, opts)
if err != nil {
return res, err
}
res.Files = append(res.Files, action)
// Continue reads .continue/rules/*.md on every chat turn. The
// community-routing file is ours end-to-end — regenerated each
// `gortex init` run so the listing tracks the current graph.
// Skipped when --no-skills / no communities qualify.
if env.SkillsRouting != "" {
rulesPath := filepath.Join(env.Root, ".continue", "rules", "gortex-communities.md")
body := agents.CommunitiesStartMarker + "\n" + env.SkillsRouting + "\n" + agents.CommunitiesEndMarker + "\n"
ruleAction, err := agents.WriteOwnedFile(env.Stderr, rulesPath, body, opts)
if err != nil {
return res, err
}
res.Files = append(res.Files, ruleAction)
}
res.Configured = true
return res, nil
}
@@ -0,0 +1,27 @@
package continuedev
import (
"os"
"path/filepath"
"testing"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/agentstest"
)
func TestContinueCreatesAndIsIdempotent(t *testing.T) {
env, _ := agentstest.NewEnv(t)
if err := os.MkdirAll(filepath.Join(env.Root, ".continue"), 0o755); err != nil {
t.Fatal(err)
}
a := New()
res, err := a.Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
// Two creates: the MCP server JSON plus .continue/rules/gortex.md,
// the per-rule file Continue reads on every chat turn.
agentstest.AssertCountsByAction(t, res, map[agents.ActionKind]int{agents.ActionCreate: 2})
agentstest.AssertIdempotent(t, a, env)
}
+198
View File
@@ -0,0 +1,198 @@
// Package cursor implements the Gortex init integration for
// Cursor. Writes .cursor/mcp.json (project-level) and, when --global
// is effective, ~/.cursor/mcp.json (user-level).
//
// Schema: standard {"mcpServers": {<name>: {command, args, env}}}.
package cursor
import (
"os"
"os/exec"
"path/filepath"
"runtime"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/internalutil"
)
const Name = "cursor"
const DocsURL = "https://docs.cursor.com/en/context/mcp"
type Adapter struct{}
func New() *Adapter { return &Adapter{} }
func (a *Adapter) Name() string { return Name }
func (a *Adapter) DocsURL() string { return DocsURL }
// cursorUserDataDir is the OS-default directory where Cursor stores
// settings when the CLI has never created ~/.cursor/. Detecting it
// avoids false negatives for users who use the app daily but have no
// `cursor` shell helper on PATH yet.
func cursorUserDataDir(home string) string {
if home == "" {
return ""
}
switch runtime.GOOS {
case "darwin":
return filepath.Join(home, "Library", "Application Support", "Cursor")
case "windows":
return filepath.Join(home, "AppData", "Roaming", "Cursor")
default:
// Linux and other Unix targets Cursor ships for.
return filepath.Join(home, ".config", "Cursor")
}
}
// Detect succeeds when any of: project has .cursor/, user has
// ~/.cursor/, Cursor's application data directory exists, or the
// `cursor` CLI is on PATH.
func (a *Adapter) Detect(env agents.Env) (bool, error) {
if _, err := os.Stat(filepath.Join(env.Root, ".cursor")); err == nil {
return true, nil
}
if env.Home != "" {
if _, err := os.Stat(filepath.Join(env.Home, ".cursor")); err == nil {
return true, nil
}
if dir := cursorUserDataDir(env.Home); dir != "" {
if _, err := os.Stat(dir); err == nil {
return true, nil
}
}
}
if p, err := exec.LookPath("cursor"); err == nil && p != "" {
return true, nil
}
return false, nil
}
func (a *Adapter) Plan(env agents.Env) (*agents.Plan, error) {
p := &agents.Plan{Files: []agents.FileAction{
{Path: mcpConfigPath(env), Action: agents.ActionWouldMerge, Keys: []string{"mcpServers"}},
}}
if env.Mode != agents.ModeGlobal {
p.Files = append(p.Files, agents.FileAction{
Path: workflowRulePath(env), Action: agents.ActionWouldCreate,
Keys: []string{"workflow-rule"},
})
}
if env.Mode != agents.ModeGlobal && env.SkillsRouting != "" {
p.Files = append(p.Files, agents.FileAction{
Path: communitiesRulePath(env), Action: agents.ActionWouldCreate,
Keys: []string{"communities-rule"},
})
}
return p, nil
}
// mcpConfigPath returns the mcp.json path for the given mode.
// Project mode: .cursor/mcp.json; global mode: ~/.cursor/mcp.json.
// Cursor reads both and prefers project when a key is defined in
// both.
func mcpConfigPath(env agents.Env) string {
if env.Mode == agents.ModeGlobal && env.Home != "" {
return filepath.Join(env.Home, ".cursor", "mcp.json")
}
return filepath.Join(env.Root, ".cursor", "mcp.json")
}
// communitiesRulePath returns the project-scoped MDC file carrying
// the regenerated community-routing block. Gortex owns this file
// end-to-end; each `gortex init` overwrites it.
//
// Cursor does not support user-level MDC rules (those live in the
// app's Settings UI), so this is always project-scoped.
func communitiesRulePath(env agents.Env) string {
return filepath.Join(env.Root, ".cursor", "rules", "gortex-communities.mdc")
}
// workflowRulePath is the stable Cursor rule that steers the agent
// toward Gortex MCP tools. Regenerated on every `gortex init` so
// wording stays aligned with shipped analyzers.
func workflowRulePath(env agents.Env) string {
return filepath.Join(env.Root, ".cursor", "rules", "gortex-workflow.mdc")
}
// workflowRuleBody is intentionally concise: Cursor injects this on
// every turn (alwaysApply) and we want high signal without drowning
// project-specific rules.
const workflowRuleBody = `## Gortex in Cursor
This repository wires the **gortex** MCP server via .cursor/mcp.json (merge-managed by Gortex).
**MANDATORY: use graph tools, not blind file reads**
You **MUST** prefer Gortex graph queries over text search and whole-file opens on every task. These are not suggestions.
- **Start** a new chat with **index_health** to confirm the daemon/index (cheap); use **graph_stats** only when you need node/edge counts or multi-repo orientation.
- **Use** **search_symbols**, **get_symbol_source**, **get_file_summary**, **get_call_chain**, **find_usages**, and **smart_context** instead of opening whole files or guessing with text search.
- Before any signature or API change, **run** **verify_change**; for test selection **run** **get_test_targets**.
**MANDATORY: session memory**
- **At session start**, call **distill_session** to recover decisions, pinned notes, and recent excerpts saved in prior sessions in this workspace.
- **At every decision point** (picking an approach, rejecting an alternative, spotting a non-obvious constraint), call **save_note** with ` + "`tags:\"decision\"`" + ` and mention affected symbol IDs in the body — they auto-link.
- **Before editing a symbol you've touched before**, call **query_notes** with ` + "`symbol_id:\"<id>\"`" + ` to surface prior warnings and decisions.
**MANDATORY: development memories (cross-session)**
- **Immediately after smart_context** on every task, call **surface_memories** with ` + "`task:\"<task>\"`" + ` and ` + "`symbol_ids:\"<top hits>\"`" + ` — returns memories ranked by anchor overlap, importance, pinning, recency.
- **When you find a durable invariant, gotcha, or decision worth teaching the team**, call **store_memory** with ` + "`kind:\"<invariant|gotcha|convention|decision>\"`" + `, ` + "`symbol_ids:\"<id>\"`" + `, ` + "`importance:5`" + `. Pin load-bearing memories. Use ` + "`supersedes:\"<old-id>\"`" + ` when newer knowledge replaces older.
- Memories are workspace-wide and outlive sessions, agents, and teammates — every future agent inherits them.
`
func (a *Adapter) Apply(env agents.Env, opts agents.ApplyOpts) (*agents.Result, error) {
res := &agents.Result{Name: Name, DocsURL: DocsURL}
detected, _ := a.Detect(env)
res.Detected = detected
if !detected && !opts.ForceDetect {
internalutil.Logf(env.Stderr, "[gortex init] skip Cursor setup (Cursor not detected)")
return res, nil
}
internalutil.Logf(env.Stderr, "[gortex init] setting up Cursor IDE integration...")
path := mcpConfigPath(env)
action, err := agents.MergeJSON(env.Stderr, path, func(root map[string]any, _ bool) (bool, error) {
// Global ~/.cursor/mcp.json is read by Cursor across every
// project, so cwd at MCP-launch time is unrelated to the open
// repo (typically $HOME). Use the daemon-proxy entry; never
// the legacy `--index .` shape that fails the entry-point
// handshake on home. See gortexhq/gortex#19.
if env.Mode == agents.ModeGlobal {
return agents.UpsertMCPServerWithMigration(root, "gortex", agents.GlobalGortexMCPEntry(), opts), nil
}
return agents.UpsertMCPServer(root, "gortex", agents.DefaultGortexMCPEntry(), opts), nil
}, opts)
if err != nil {
return res, err
}
res.Files = append(res.Files, action)
// Workflow MDC — project-local only (Cursor has no user-level MDC
// on disk). Tells the agent to reach for MCP graph tools first.
if env.Mode != agents.ModeGlobal {
wfBody := agents.CursorMDCFrontmatter(workflowRuleBody)
wfAction, err := agents.WriteOwnedFile(env.Stderr, workflowRulePath(env), wfBody, opts)
if err != nil {
return res, err
}
res.Files = append(res.Files, wfAction)
}
// Community-routing MDC file — always written fresh on init
// so the routing tracks the current graph. Skipped in global
// mode (file is per-repo) and when --no-skills / no
// communities qualify.
if env.Mode != agents.ModeGlobal && env.SkillsRouting != "" {
body := agents.CursorMDCFrontmatter(
agents.CommunitiesStartMarker + "\n" + env.SkillsRouting + "\n" + agents.CommunitiesEndMarker + "\n")
ruleAction, err := agents.WriteOwnedFile(env.Stderr, communitiesRulePath(env), body, opts)
if err != nil {
return res, err
}
res.Files = append(res.Files, ruleAction)
}
res.Configured = true
return res, nil
}
+197
View File
@@ -0,0 +1,197 @@
package cursor
import (
"os"
"path/filepath"
"testing"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/agentstest"
)
// TestCursorCreatesMergesAndSkips covers the three behavioural
// phases every adapter must honour:
// - initial create on an empty project (via .cursor/ sentinel)
// - re-run is a no-op (idempotent)
// - merge into an existing mcp.json preserves user keys
func TestCursorCreatesMergesAndSkips(t *testing.T) {
env, _ := agentstest.NewEnv(t)
// Sentinel: create .cursor/ so Detect reports true. Without
// this the adapter would skip with "not detected".
if err := os.MkdirAll(filepath.Join(env.Root, ".cursor"), 0o755); err != nil {
t.Fatal(err)
}
a := New()
// Phase 1 — create.
res, err := a.Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
if !res.Detected {
t.Fatal("expected Detected=true after creating .cursor/")
}
if !res.Configured {
t.Fatal("expected Configured=true after first apply")
}
// Three creates: .cursor/mcp.json, gortex-workflow.mdc (always-on MCP
// guidance), and gortex-communities.mdc (routing from stub SkillsRouting).
agentstest.AssertCountsByAction(t, res, map[agents.ActionKind]int{agents.ActionCreate: 3})
mcp := agentstest.ReadJSON(t, filepath.Join(env.Root, ".cursor", "mcp.json"))
servers := mcp["mcpServers"].(map[string]any)
if _, ok := servers["gortex"]; !ok {
t.Fatalf("gortex server missing: %v", servers)
}
// Phase 2 — idempotent re-run.
agentstest.AssertIdempotent(t, a, env)
}
// TestCursorGlobalWritesProxyEntry confirms that user-level installs
// (`gortex install`, env.Mode == ModeGlobal) emit the daemon-proxy
// entry — never the legacy `--index . --watch` shape that resolves
// against the launch cwd and fails Cursor's global-config handshake.
// See gortexhq/gortex#19.
func TestCursorGlobalWritesProxyEntry(t *testing.T) {
env, _ := agentstest.NewEnv(t)
env.Mode = agents.ModeGlobal
// Sentinel: ~/.cursor exists so Detect succeeds in global mode.
if err := os.MkdirAll(filepath.Join(env.Home, ".cursor"), 0o755); err != nil {
t.Fatal(err)
}
res, err := New().Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
if !res.Configured {
t.Fatal("expected Configured=true for global install")
}
mcp := agentstest.ReadJSON(t, filepath.Join(env.Home, ".cursor", "mcp.json"))
servers := mcp["mcpServers"].(map[string]any)
entry, ok := servers["gortex"].(map[string]any)
if !ok {
t.Fatalf("gortex entry missing in global mcp.json: %v", servers)
}
args, _ := entry["args"].([]any)
if len(args) == 0 || args[0] != "mcp" {
t.Fatalf("args[0] = %v, want \"mcp\"; full args=%v", args, args)
}
// Canonical shape is bare ["mcp"] — no --index, no --proxy.
if len(args) != 1 {
t.Errorf("global cursor entry must emit canonical [mcp], got %v", args)
}
for _, a := range args {
if s, _ := a.(string); s == "--index" || s == "--proxy" {
t.Errorf("global cursor entry must not pass %q: %v", s, args)
}
}
}
// TestCursorGlobalMigratesLegacyIndexEntry exercises the migration
// path. A user upgrading from a version that wrote the legacy
// `--index . --watch` shape should get their global config rewritten
// to the daemon-proxy form on the next `gortex install`, without
// having to pass --force.
func TestCursorGlobalMigratesLegacyIndexEntry(t *testing.T) {
env, _ := agentstest.NewEnv(t)
env.Mode = agents.ModeGlobal
cursorDir := filepath.Join(env.Home, ".cursor")
if err := os.MkdirAll(cursorDir, 0o755); err != nil {
t.Fatal(err)
}
// Seed the legacy entry that issue 19 reported in the wild.
mcpPath := filepath.Join(cursorDir, "mcp.json")
agentstest.WriteJSON(t, mcpPath, map[string]any{
"mcpServers": map[string]any{
"gortex": map[string]any{
"command": "gortex",
"args": []any{"mcp", "--index", ".", "--watch"},
"env": map[string]any{"GORTEX_INDEX_WORKERS": "8"},
},
},
})
res, err := New().Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
if !res.Configured {
t.Fatal("expected Configured=true after migration")
}
after := agentstest.ReadJSON(t, mcpPath)
servers := after["mcpServers"].(map[string]any)
entry := servers["gortex"].(map[string]any)
args, _ := entry["args"].([]any)
for _, a := range args {
if s, _ := a.(string); s == "--index" {
t.Fatalf("migration left legacy --index in args: %v", args)
}
}
}
// TestCursorGlobalPreservesUserCustomization keeps the migration
// honest: a user who pointed their global gortex entry at a custom
// wrapper script must not have it silently overwritten.
func TestCursorGlobalPreservesUserCustomization(t *testing.T) {
env, _ := agentstest.NewEnv(t)
env.Mode = agents.ModeGlobal
cursorDir := filepath.Join(env.Home, ".cursor")
if err := os.MkdirAll(cursorDir, 0o755); err != nil {
t.Fatal(err)
}
mcpPath := filepath.Join(cursorDir, "mcp.json")
agentstest.WriteJSON(t, mcpPath, map[string]any{
"mcpServers": map[string]any{
"gortex": map[string]any{
"command": "/opt/wrappers/my-gortex.sh",
"args": []any{"mcp"},
},
},
})
if _, err := New().Apply(env, agents.ApplyOpts{}); err != nil {
t.Fatalf("apply: %v", err)
}
after := agentstest.ReadJSON(t, mcpPath)
servers := after["mcpServers"].(map[string]any)
entry := servers["gortex"].(map[string]any)
if entry["command"] != "/opt/wrappers/my-gortex.sh" {
t.Fatalf("user-customized command was overwritten: %v", entry)
}
}
// TestCursorMergeIntoExistingPreservesUserKeys confirms that the
// adapter writes alongside — not over — a user's pre-existing MCP
// server entry. This is the load-bearing property of MergeJSON.
func TestCursorMergeIntoExistingPreservesUserKeys(t *testing.T) {
env, _ := agentstest.NewEnv(t)
if err := os.MkdirAll(filepath.Join(env.Root, ".cursor"), 0o755); err != nil {
t.Fatal(err)
}
mcpPath := filepath.Join(env.Root, ".cursor", "mcp.json")
agentstest.WriteJSON(t, mcpPath, map[string]any{
"mcpServers": map[string]any{
"user-server": map[string]any{"command": "user-tool"},
},
})
res, err := New().Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
// mcp.json pre-exists → merge; workflow + communities rules are new.
agentstest.AssertCountsByAction(t, res, map[agents.ActionKind]int{agents.ActionMerge: 1, agents.ActionCreate: 2})
after := agentstest.ReadJSON(t, mcpPath)
servers := after["mcpServers"].(map[string]any)
if _, ok := servers["user-server"]; !ok {
t.Fatalf("user-server was clobbered: %v", servers)
}
if _, ok := servers["gortex"]; !ok {
t.Fatalf("gortex missing after merge: %v", servers)
}
}
+114
View File
@@ -0,0 +1,114 @@
// Package gemini implements the Gortex init integration for the
// Gemini CLI. Gemini reads ~/.gemini/settings.json (user-level) and
// .gemini/settings.json (project-level); both accept the standard
// {"mcpServers": {...}} shape.
//
// Note: this adapter is distinct from the antigravity adapter —
// Gemini CLI and Google Antigravity are different products that
// happen to share the ~/.gemini/ root directory.
//
// Docs: https://geminicli.com/docs/tools/mcp-server/
package gemini
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/internalutil"
)
const Name = "gemini"
const DocsURL = "https://geminicli.com/docs/tools/mcp-server/"
type Adapter struct{}
func New() *Adapter { return &Adapter{} }
func (a *Adapter) Name() string { return Name }
func (a *Adapter) DocsURL() string { return DocsURL }
// Detect checks for the gemini CLI on PATH or an existing user-level
// settings.json. We avoid colliding with the antigravity adapter's
// detection by looking at ~/.gemini/settings.json specifically
// rather than the whole ~/.gemini/ tree.
func (a *Adapter) Detect(env agents.Env) (bool, error) {
if p, err := exec.LookPath("gemini"); err == nil && p != "" {
return true, nil
}
if env.Home == "" {
return false, nil
}
if _, err := os.Stat(filepath.Join(env.Home, ".gemini", "settings.json")); err == nil {
return true, nil
}
return false, nil
}
// configPath returns the right settings.json for the current Env.
// Project mode writes .gemini/settings.json; global mode writes
// ~/.gemini/settings.json.
func configPath(env agents.Env) string {
if env.Mode == agents.ModeGlobal && env.Home != "" {
return filepath.Join(env.Home, ".gemini", "settings.json")
}
return filepath.Join(env.Root, ".gemini", "settings.json")
}
func (a *Adapter) Plan(env agents.Env) (*agents.Plan, error) {
p := &agents.Plan{Files: []agents.FileAction{
{Path: configPath(env), Action: agents.ActionWouldMerge, Keys: []string{"mcpServers", "hooks"}},
}}
if env.Mode != agents.ModeGlobal && env.SkillsRouting != "" {
p.Files = append(p.Files, agents.FileAction{
Path: filepath.Join(env.Root, "GEMINI.md"), Action: agents.ActionWouldMerge,
Keys: []string{"communities-block"},
})
}
return p, nil
}
func (a *Adapter) Apply(env agents.Env, opts agents.ApplyOpts) (*agents.Result, error) {
res := &agents.Result{Name: Name, DocsURL: DocsURL}
detected, _ := a.Detect(env)
res.Detected = detected
if !detected && !opts.ForceDetect {
internalutil.Logf(env.Stderr, "[gortex init] skip Gemini CLI setup (gemini not detected)")
return res, nil
}
if env.Home == "" {
return res, fmt.Errorf("gemini: requires a resolved home directory")
}
internalutil.Logf(env.Stderr, "[gortex init] setting up Gemini CLI integration...")
action, err := agents.MergeJSON(env.Stderr, configPath(env), func(root map[string]any, _ bool) (bool, error) {
mcp := agents.UpsertMCPServer(root, "gortex", agents.DefaultGortexMCPEntry(), opts)
// Install SessionStart + AfterTool lifecycle hooks alongside the
// MCP registration so Gemini gets the same orientation and
// stale-index hints Claude does.
hk := agents.UpsertGeminiHooks(root, Name, opts)
return mcp || hk, nil
}, opts)
if err != nil {
return res, err
}
res.Files = append(res.Files, action)
// GEMINI.md gets a marker-guarded community-routing block when
// skills were generated (--skills, default on in `gortex init`).
// Skipped in global mode (the file is per-repo) and when no
// communities met the min-size threshold.
if env.Mode != agents.ModeGlobal && env.SkillsRouting != "" {
geminiMdPath := filepath.Join(env.Root, "GEMINI.md")
routingAction, err := agents.UpsertMarkedBlock(env.Stderr, geminiMdPath, env.SkillsRouting,
agents.CommunitiesStartMarker, agents.CommunitiesEndMarker, opts)
if err != nil {
return res, err
}
res.Files = append(res.Files, routingAction)
}
res.Configured = true
return res, nil
}
+40
View File
@@ -0,0 +1,40 @@
package gemini
import (
"os"
"path/filepath"
"testing"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/agentstest"
)
func TestGeminiGlobalModeWritesUserSettings(t *testing.T) {
env, _ := agentstest.NewEnv(t)
env.Mode = agents.ModeGlobal
if err := os.MkdirAll(filepath.Join(env.Home, ".gemini"), 0o755); err != nil {
t.Fatal(err)
}
if f, err := os.Create(filepath.Join(env.Home, ".gemini", "settings.json")); err != nil {
t.Fatal(err)
} else {
_ = f.Close()
}
a := New()
res, err := a.Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
if !res.Configured {
t.Fatal("expected Configured=true")
}
cfg := agentstest.ReadJSON(t, filepath.Join(env.Home, ".gemini", "settings.json"))
servers, ok := cfg["mcpServers"].(map[string]any)
if !ok {
t.Fatalf("missing mcpServers: %v", cfg)
}
if _, ok := servers["gortex"]; !ok {
t.Fatalf("gortex missing: %v", servers)
}
}
+289
View File
@@ -0,0 +1,289 @@
// Package hermes implements the Gortex init/install integration for
// NousResearch Hermes (https://github.com/NousResearch/hermes-agent),
// a CLI agent-orchestrator that consumes MCP servers.
//
// Hermes is a user-level agent, not a repo-scoped IDE: it stores all
// state under ~/.hermes/ and the gortex daemon already resolves the
// active workspace per MCP session, so one global server entry serves
// every repo. We therefore write user-level artifacts in both
// `gortex init` (ModeProject) and `gortex install` (ModeGlobal), the
// same as the openclaw / antigravity adapters — the writes are
// idempotent, so running both is harmless.
//
// Three surfaces are configured:
//
// 1. Global ~/.hermes/config.yaml — upsert a `gortex` stdio server
// under the snake_case `mcp_servers` map, comment-preservingly
// (the config is hand-edited and comment-rich).
// 2. Every existing ~/.hermes/profiles/<name>/config.yaml — Hermes
// profiles can re-declare their own `mcp_servers` block rather
// than inheriting the global one, so we upsert the gortex stanza
// into each profile config that already exists. This guarantees
// every profile resolves the gortex tools regardless of the
// global↔profile merge semantics. (We never create new profiles.)
// 3. A user-level skill at ~/.hermes/skills/gortex/SKILL.md teaching
// the agent to prefer gortex graph tools — Hermes' equivalent of
// the Claude Code / Antigravity user-level instruction surface.
package hermes
import (
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/internalutil"
yaml "gopkg.in/yaml.v3"
)
const Name = "hermes"
const DocsURL = "https://hermes-agent.nousresearch.com/docs/user-guide/features/mcp"
type Adapter struct{}
func New() *Adapter { return &Adapter{} }
func (a *Adapter) Name() string { return Name }
func (a *Adapter) DocsURL() string { return DocsURL }
// Detect returns true when Hermes is installed or its home directory
// exists. False means "skip", not an error — a machine without Hermes
// gets no ~/.hermes writes.
func (a *Adapter) Detect(env agents.Env) (bool, error) {
if p, err := exec.LookPath("hermes"); err == nil && p != "" {
return true, nil
}
if env.Home == "" {
return false, nil
}
if _, err := os.Stat(hermesDir(env.Home)); err == nil {
return true, nil
}
return false, nil
}
func (a *Adapter) Plan(env agents.Env) (*agents.Plan, error) {
if env.Home == "" {
return &agents.Plan{}, nil
}
// The global config carries mcp_servers always, and the gortex
// pre_tool_call / pre_llm_call hooks when hook installation is on —
// so `init doctor` (which runs Plan) reports the hooks surface too.
globalKeys := []string{"mcp_servers"}
if env.InstallHooks {
globalKeys = append(globalKeys, "hooks")
}
files := []agents.FileAction{
{Path: globalConfigPath(env.Home), Action: agents.ActionWouldMerge, Keys: globalKeys},
}
for _, p := range profileConfigPaths(env.Home) {
files = append(files, agents.FileAction{Path: p, Action: agents.ActionWouldMerge, Keys: []string{"mcp_servers"}})
}
files = append(files, agents.FileAction{Path: skillPath(env.Home, SkillName), Action: agents.ActionWouldCreate})
for _, name := range RoutingSkillNames() {
files = append(files, agents.FileAction{Path: skillPath(env.Home, name), Action: agents.ActionWouldCreate})
}
return &agents.Plan{Files: files}, nil
}
func (a *Adapter) Apply(env agents.Env, opts agents.ApplyOpts) (*agents.Result, error) {
res := &agents.Result{Name: Name, DocsURL: DocsURL}
detected, _ := a.Detect(env)
res.Detected = detected
if !detected && !opts.ForceDetect {
internalutil.Logf(env.Stderr, "[gortex init] skip Hermes setup (hermes not detected)")
return res, nil
}
if env.Home == "" {
return res, fmt.Errorf("hermes: requires a resolved home directory")
}
internalutil.Logf(env.Stderr, "[gortex init] setting up Hermes integration...")
command := resolveGortexCommand()
// 1. Global config — the entry every profile inherits when it
// doesn't re-declare its own server map, plus (when hooks are
// enabled) the gortex pre_tool_call / pre_llm_call hooks. Both
// ride one comment-preserving merge of ~/.hermes/config.yaml.
globalAction, err := upsertGlobalConfig(env.Stderr, globalConfigPath(env.Home), command, env.HookMode, env.InstallHooks, opts)
if err != nil {
return res, fmt.Errorf("hermes global config: %w", err)
}
res.Files = append(res.Files, globalAction)
if env.InstallHooks {
internalutil.Logf(env.Stderr, "[gortex init] wired Hermes pre_tool_call + pre_llm_call hooks (posture: %s)", hermesHookModeLabel(env.HookMode))
} else {
internalutil.Logf(env.Stderr, "[gortex init] skipping Hermes hook installation (--no-hooks)")
}
// 2. Per-profile configs — Hermes profiles may carry their own
// mcp_servers block, so upsert into each existing one too. A
// failure on one profile is a warning, not fatal: the global
// entry still covers profiles that do inherit.
for _, profilePath := range profileConfigPaths(env.Home) {
profileAction, perr := upsertGortexServer(env.Stderr, profilePath, command, opts)
if perr != nil {
// Non-fatal: the global stanza still covers profiles that
// inherit. But this profile does NOT inherit, so record the
// failure on the result — not just stderr — otherwise a
// Configured=true silently masks a profile left unconfigured.
internalutil.Warnf(env.Stderr, "hermes profile %s: %v", profilePath, perr)
res.Warnings = append(res.Warnings, fmt.Sprintf("profile %s not configured: %v", profilePath, perr))
continue
}
res.Files = append(res.Files, profileAction)
}
// 3. User-level skills — the master `gortex` guide plus the
// per-task routing playbooks (explore / impact / refactor / …),
// mirroring the Claude Code user-level skill set. Each is skipped
// when it already exists so user edits survive a re-install.
masterAction, err := agents.WriteIfNotExists(env.Stderr, skillPath(env.Home, SkillName), SkillBody(), opts)
if err != nil {
return res, fmt.Errorf("hermes skill: %w", err)
}
res.Files = append(res.Files, masterAction)
routing := RoutingSkills()
for _, name := range RoutingSkillNames() {
action, rerr := agents.WriteIfNotExists(env.Stderr, skillPath(env.Home, name), routing[name], opts)
if rerr != nil {
internalutil.Warnf(env.Stderr, "hermes skill %s: %v", name, rerr)
continue
}
res.Files = append(res.Files, action)
}
res.Configured = true
return res, nil
}
// upsertGortexServer merges the gortex stdio stanza into the
// `mcp_servers` map of a Hermes YAML config, preserving comments and
// unrelated keys. Used for per-profile configs, which carry only the
// server stanza.
func upsertGortexServer(w io.Writer, path, command string, opts agents.ApplyOpts) (agents.FileAction, error) {
return agents.MergeYAML(w, path, func(root *yaml.Node, _ bool) (bool, error) {
return agents.UpsertYAMLMapEntry(root, "mcp_servers", gortexServerName, gortexMCPEntry(command), opts.Force)
}, opts)
}
// upsertGlobalConfig merges the gortex MCP server stanza — and, when
// hook installation is enabled, the gortex pre_tool_call / pre_llm_call
// hook entries — into the global ~/.hermes/config.yaml in a single
// comment-preserving pass. Hooks are global-only: unlike mcp_servers
// (which a profile can re-declare), Hermes documents shell hooks only at
// global scope, so this is the one place they're written.
func upsertGlobalConfig(w io.Writer, path, command, hookMode string, installHooks bool, opts agents.ApplyOpts) (agents.FileAction, error) {
return agents.MergeYAML(w, path, func(root *yaml.Node, _ bool) (bool, error) {
serverChanged, err := agents.UpsertYAMLMapEntry(root, "mcp_servers", gortexServerName, gortexMCPEntry(command), opts.Force)
if err != nil {
return false, err
}
// Enable the gortex CLI platform toolset so the agent can shell the
// gortex verbs alongside the MCP server.
toolsetChanged, err := enablePlatformToolsetCLI(root, opts.Force)
if err != nil {
return false, err
}
if !installHooks {
return serverChanged || toolsetChanged, nil
}
hooksChanged, err := upsertGortexHooks(root, hookMode, opts.Force)
if err != nil {
return false, err
}
return serverChanged || toolsetChanged || hooksChanged, nil
}, opts)
}
// enablePlatformToolsetCLI ensures Hermes' `platform_toolsets.cli` toolset is
// turned on, so the gortex CLI verbs the agent can shell out to are available
// alongside the MCP server. force overwrites an explicit user value; otherwise
// an existing entry is left untouched.
func enablePlatformToolsetCLI(root *yaml.Node, force bool) (bool, error) {
return agents.UpsertYAMLMapEntry(root, "platform_toolsets", "cli",
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!bool", Value: "true"}, force)
}
// hermesHookModeLabel renders the posture name for the install log,
// normalising the empty / unknown default to "deny".
func hermesHookModeLabel(mode string) string {
m := strings.ToLower(strings.TrimSpace(mode))
switch m {
case "enrich", "consult-unlock", "nudge":
return m
case "adaptive-nudge":
return "nudge"
default:
return "deny"
}
}
// resolveGortexCommand returns the command Hermes should launch for the
// gortex MCP server. It prefers a stable absolute path so the entry
// works regardless of how Hermes' subprocess PATH is set up:
//
// 1. os.Executable() — but only when it actually points at an installed
// `gortex` binary. Under `go run`, os.Executable() is a temp build
// that is deleted on exit (and may even be *named* gortex), so we
// additionally reject any path under the temp dir.
// 2. exec.LookPath("gortex") — a stable PATH install (homebrew / go
// install).
// 3. the bare "gortex" name as a last resort.
func resolveGortexCommand() string {
if exe, err := os.Executable(); err == nil && exe != "" {
base := filepath.Base(exe)
base = strings.TrimSuffix(base, filepath.Ext(base)) // drop .exe on Windows
underTemp := strings.HasPrefix(exe, filepath.Clean(os.TempDir())+string(os.PathSeparator))
if base == "gortex" && !underTemp {
return exe
}
}
if p, err := exec.LookPath("gortex"); err == nil && p != "" {
return p
}
return "gortex"
}
// hermesDir is the ~/.hermes root.
func hermesDir(home string) string { return filepath.Join(home, ".hermes") }
// globalConfigPath is ~/.hermes/config.yaml.
func globalConfigPath(home string) string { return filepath.Join(hermesDir(home), "config.yaml") }
// skillPath is ~/.hermes/skills/<category>/<name>/SKILL.md. Hermes
// discovers SKILL.md files recursively, and its convention is to group
// skills under a category folder rather than at the skills root.
func skillPath(home, name string) string {
return filepath.Join(hermesDir(home), "skills", skillCategory(name), name, "SKILL.md")
}
// skillCategory returns the ~/.hermes/skills subdirectory a gortex skill
// lives under. We reuse the routing-skill taxonomy so each playbook
// lands in its topical folder (navigation / analysis / debugging / …)
// and the master guide under code-intelligence — keeping the skills root
// uncluttered and matching how Hermes' own skills are organised.
func skillCategory(name string) string {
if name == SkillName {
return masterSkillCategory
}
_, category := routingSkillTaxonomy(name)
return category
}
// profileConfigPaths returns the config.yaml of every existing Hermes
// profile under ~/.hermes/profiles/<name>/, sorted for a stable
// install report and deterministic tests. Returns nil when the
// profiles directory is absent.
func profileConfigPaths(home string) []string {
matches, err := filepath.Glob(filepath.Join(hermesDir(home), "profiles", "*", "config.yaml"))
if err != nil || len(matches) == 0 {
return nil
}
sort.Strings(matches)
return matches
}
+385
View File
@@ -0,0 +1,385 @@
package hermes
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/agentstest"
yaml "gopkg.in/yaml.v3"
)
// frontmatterOf returns the YAML frontmatter block of a SKILL.md
// (between the opening `---` and the next `---`), failing the test
// when the body isn't frontmatter-fenced.
func frontmatterOf(t *testing.T, name, body string) string {
t.Helper()
const open = "---\n"
if !strings.HasPrefix(body, open) {
t.Fatalf("%s: SKILL.md does not start with frontmatter:\n%s", name, body)
}
rest := body[len(open):]
fm, _, found := strings.Cut(rest, "\n---\n")
if !found {
t.Fatalf("%s: unterminated frontmatter", name)
}
return fm
}
// seedHermesHome creates an empty ~/.hermes so Detect() passes without
// depending on a `hermes` binary being on the test machine's PATH.
func seedHermesHome(t *testing.T, home string) {
t.Helper()
if err := os.MkdirAll(filepath.Join(home, ".hermes"), 0o755); err != nil {
t.Fatalf("seed ~/.hermes: %v", err)
}
}
// TestHermesApplyWritesGlobalConfigAndSkill is the acceptance test:
// `gortex install` must write the global mcp_servers.gortex stanza and
// a user-level skill, and a re-run must be a pure no-op.
func TestHermesApplyWritesGlobalConfigAndSkill(t *testing.T) {
env, _ := agentstest.NewEnv(t)
seedHermesHome(t, env.Home)
a := New()
res, err := a.Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
if !res.Configured {
t.Fatal("expected Configured=true")
}
// Global config: mcp_servers.gortex with command + args:[mcp] + timeouts.
cfg := agentstest.ReadYAML(t, globalConfigPath(env.Home))
servers, ok := cfg["mcp_servers"].(map[string]any)
if !ok {
t.Fatalf("mcp_servers missing or wrong type: %#v", cfg)
}
gortex, ok := servers["gortex"].(map[string]any)
if !ok {
t.Fatalf("gortex server missing: %#v", servers)
}
if cmd, _ := gortex["command"].(string); cmd == "" {
t.Errorf("gortex command empty: %#v", gortex)
}
args, ok := gortex["args"].([]any)
if !ok || len(args) != 1 || args[0] != "mcp" {
t.Errorf("gortex args = %#v, want [mcp]", gortex["args"])
}
if gortex["connect_timeout"] != connectTimeoutSecs {
t.Errorf("connect_timeout = %v, want %d", gortex["connect_timeout"], connectTimeoutSecs)
}
if gortex["timeout"] != requestTimeoutSecs {
t.Errorf("timeout = %v, want %d", gortex["timeout"], requestTimeoutSecs)
}
// Skill present, with Hermes frontmatter.
skill, err := os.ReadFile(skillPath(env.Home, SkillName))
if err != nil {
t.Fatalf("skill missing: %v", err)
}
for _, want := range []string{
"name: gortex", "metadata:", "hermes:", "set_active_project",
"platforms: [linux, macos, windows]", // standard Hermes frontmatter
"related_skills:", // links to the routing playbooks
"## Task playbooks", // slash-command discoverability
"/gortex-explore", // a representative routing command
} {
if !strings.Contains(string(skill), want) {
t.Errorf("skill missing %q", want)
}
}
agentstest.AssertIdempotent(t, a, env)
}
// TestHermesInstallsRoutingSkills covers the Claude Code parity skill
// set: every routing playbook is installed with valid Hermes
// frontmatter, and gortex-guide is excluded in favour of the native
// master `gortex` skill.
func TestHermesInstallsRoutingSkills(t *testing.T) {
env, _ := agentstest.NewEnv(t)
seedHermesHome(t, env.Home)
a := New()
if _, err := a.Apply(env, agents.ApplyOpts{}); err != nil {
t.Fatalf("apply: %v", err)
}
names := RoutingSkillNames()
if len(names) == 0 {
t.Fatal("no routing skills derived")
}
for _, name := range names {
if name == "gortex-guide" {
t.Errorf("gortex-guide should be excluded (native master covers it)")
}
p := skillPath(env.Home, name)
data, err := os.ReadFile(p)
if err != nil {
t.Errorf("routing skill %s missing: %v", name, err)
continue
}
// The frontmatter block must be valid YAML with the Hermes
// fields Hermes needs to discover and route the skill — Hermes
// silently ignores a skill whose frontmatter won't parse.
fm := frontmatterOf(t, name, string(data))
var meta struct {
Name string `yaml:"name"`
Description string `yaml:"description"`
Version string `yaml:"version"`
Metadata struct {
Hermes struct {
Tags []string `yaml:"tags"`
Category string `yaml:"category"`
Platforms []string `yaml:"platforms"`
RelatedSkills []string `yaml:"related_skills"`
} `yaml:"hermes"`
} `yaml:"metadata"`
}
if err := yaml.Unmarshal([]byte(fm), &meta); err != nil {
t.Errorf("%s: frontmatter is not valid YAML: %v\n%s", name, err, fm)
continue
}
if meta.Name != name {
t.Errorf("%s: frontmatter name = %q", name, meta.Name)
}
if meta.Description == "" || meta.Version == "" || meta.Metadata.Hermes.Category == "" || len(meta.Metadata.Hermes.Tags) == 0 {
t.Errorf("%s: incomplete Hermes frontmatter: %+v", name, meta)
}
if len(meta.Metadata.Hermes.Platforms) == 0 || len(meta.Metadata.Hermes.RelatedSkills) == 0 {
t.Errorf("%s: missing platforms/related_skills: %+v", name, meta.Metadata.Hermes)
}
}
// A couple of representative routing skills must be present.
for _, want := range []string{"gortex-explore", "gortex-impact", "gortex-pr-review"} {
if _, err := os.Stat(skillPath(env.Home, want)); err != nil {
t.Errorf("expected routing skill %s: %v", want, err)
}
}
// The excluded guide must not be installed under its own name.
if _, err := os.Stat(skillPath(env.Home, "gortex-guide")); err == nil {
t.Error("gortex-guide should not be installed")
}
}
// TestHermesUpsertsEveryProfileConfig covers the "extra" robustness:
// Hermes profiles can re-declare their own mcp_servers block, so the
// adapter must upsert the gortex stanza into every existing profile
// config, not just the global one.
func TestHermesUpsertsEveryProfileConfig(t *testing.T) {
env, _ := agentstest.NewEnv(t)
seedHermesHome(t, env.Home)
// Two profiles that already declare their own servers.
profiles := []string{"work", "personal"}
for _, name := range profiles {
p := filepath.Join(env.Home, ".hermes", "profiles", name, "config.yaml")
agentstest.WriteYAML(t, p, map[string]any{
"mcp_servers": map[string]any{
"github": map[string]any{"command": "npx", "args": []string{"-y", "server-github"}},
},
})
}
a := New()
if _, err := a.Apply(env, agents.ApplyOpts{}); err != nil {
t.Fatalf("apply: %v", err)
}
for _, name := range profiles {
p := filepath.Join(env.Home, ".hermes", "profiles", name, "config.yaml")
cfg := agentstest.ReadYAML(t, p)
servers, ok := cfg["mcp_servers"].(map[string]any)
if !ok {
t.Fatalf("profile %s: mcp_servers missing: %#v", name, cfg)
}
if _, ok := servers["gortex"]; !ok {
t.Errorf("profile %s: gortex not upserted: %#v", name, servers)
}
if _, ok := servers["github"]; !ok {
t.Errorf("profile %s: pre-existing github server dropped: %#v", name, servers)
}
}
agentstest.AssertIdempotent(t, a, env)
}
// TestHermesProfileFailureSurfacedAsWarning verifies that a profile that
// fails to write is reported on the Result (not just stderr): the global
// stanza covers inheriting profiles, but a non-inheriting one left
// unconfigured must not hide behind Configured=true.
func TestHermesProfileFailureSurfacedAsWarning(t *testing.T) {
env, _ := agentstest.NewEnv(t)
seedHermesHome(t, env.Home)
// A profile whose config.yaml is actually a directory — the merge's
// read/write fails, exercising the per-profile error path.
badProfile := filepath.Join(env.Home, ".hermes", "profiles", "broken", "config.yaml")
if err := os.MkdirAll(badProfile, 0o755); err != nil {
t.Fatalf("seed bad profile: %v", err)
}
a := New()
res, err := a.Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
if len(res.Warnings) == 0 {
t.Fatal("expected a warning for the failed profile, got none")
}
found := false
for _, w := range res.Warnings {
if strings.Contains(w, "broken") {
found = true
}
}
if !found {
t.Errorf("warning should name the failed profile: %#v", res.Warnings)
}
}
// TestHermesPreservesGlobalConfigComments guards the comment-rich
// merge: a hand-edited config keeps its comments and unrelated keys.
func TestHermesPreservesGlobalConfigComments(t *testing.T) {
env, _ := agentstest.NewEnv(t)
seedHermesHome(t, env.Home)
cfgPath := globalConfigPath(env.Home)
original := `# my hermes config
model: hermes-4 # the good one
mcp_servers:
# filesystem access
filesystem:
command: npx
args: ["-y", "@modelcontextprotocol/server-filesystem"]
`
if err := os.WriteFile(cfgPath, []byte(original), 0o644); err != nil {
t.Fatalf("seed config: %v", err)
}
a := New()
if _, err := a.Apply(env, agents.ApplyOpts{}); err != nil {
t.Fatalf("apply: %v", err)
}
out, err := os.ReadFile(cfgPath)
if err != nil {
t.Fatalf("read back: %v", err)
}
got := string(out)
for _, want := range []string{"# my hermes config", "# filesystem access", "the good one"} {
if !strings.Contains(got, want) {
t.Errorf("lost comment %q:\n%s", want, got)
}
}
cfg := agentstest.ReadYAML(t, cfgPath)
servers := cfg["mcp_servers"].(map[string]any)
if _, ok := servers["filesystem"]; !ok {
t.Error("pre-existing filesystem server dropped")
}
if _, ok := servers["gortex"]; !ok {
t.Error("gortex server not added")
}
if cfg["model"] != "hermes-4" {
t.Errorf("unrelated key model clobbered: %v", cfg["model"])
}
}
// TestHermesForceOverwritesEntry verifies --force replaces a stale
// gortex entry instead of skipping.
func TestHermesForceOverwritesEntry(t *testing.T) {
env, _ := agentstest.NewEnv(t)
seedHermesHome(t, env.Home)
cfgPath := globalConfigPath(env.Home)
agentstest.WriteYAML(t, cfgPath, map[string]any{
"mcp_servers": map[string]any{
"gortex": map[string]any{"command": "OLD", "args": []string{"stale"}},
},
})
a := New()
res, err := a.Apply(env, agents.ApplyOpts{Force: true})
if err != nil {
t.Fatalf("apply --force: %v", err)
}
if !res.Configured {
t.Fatal("expected Configured=true")
}
cfg := agentstest.ReadYAML(t, cfgPath)
gortex := cfg["mcp_servers"].(map[string]any)["gortex"].(map[string]any)
if gortex["command"] == "OLD" {
t.Errorf("force did not overwrite stale entry: %#v", gortex)
}
args, _ := gortex["args"].([]any)
if len(args) != 1 || args[0] != "mcp" {
t.Errorf("force did not rewrite args: %#v", gortex["args"])
}
}
// TestHermesDetect checks the home-directory gate. The PATH branch is
// covered implicitly by exec.LookPath and not asserted here so the
// test is independent of what's installed on the runner.
func TestHermesDetect(t *testing.T) {
a := New()
// ~/.hermes present → detect.
home := t.TempDir()
seedHermesHome(t, home)
if detected, _ := a.Detect(agents.Env{Home: home}); !detected {
t.Error("expected detection when ~/.hermes exists")
}
// Empty Home and no detectable hermes → skip (unless the runner
// happens to have hermes on PATH, in which case true is correct).
if detected, _ := a.Detect(agents.Env{Home: ""}); detected {
if _, err := exec.LookPath("hermes"); err != nil {
t.Error("detected Hermes with empty Home and no hermes on PATH")
}
}
}
// TestHermesDryRunWritesNothing verifies the plan-only path.
func TestHermesDryRunWritesNothing(t *testing.T) {
env, _ := agentstest.NewEnv(t)
seedHermesHome(t, env.Home)
a := New()
if _, err := a.Apply(env, agents.ApplyOpts{DryRun: true}); err != nil {
t.Fatalf("dry-run apply: %v", err)
}
if _, err := os.Stat(globalConfigPath(env.Home)); !os.IsNotExist(err) {
t.Error("dry-run wrote the global config")
}
if _, err := os.Stat(skillPath(env.Home, SkillName)); !os.IsNotExist(err) {
t.Error("dry-run wrote the skill")
}
}
// TestHermesToolsetEnable proves F16's platform_toolsets.cli enablement: the
// gortex CLI toolset is turned on in the Hermes global config.
func TestHermesToolsetEnable(t *testing.T) {
root := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
changed, err := enablePlatformToolsetCLI(root, false)
if err != nil {
t.Fatalf("enablePlatformToolsetCLI: %v", err)
}
if !changed {
t.Fatal("enabling the cli toolset on an empty config must report a change")
}
pt := agents.YAMLMapValue(root, "platform_toolsets")
if pt == nil {
t.Fatal("platform_toolsets mapping must be created")
}
cli := agents.YAMLMapValue(pt, "cli")
if cli == nil || cli.Value != "true" {
t.Fatalf("platform_toolsets.cli = %v, want scalar true", cli)
}
}
+341
View File
@@ -0,0 +1,341 @@
package hermes
import (
"sort"
"strconv"
"strings"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/claudecode"
yaml "gopkg.in/yaml.v3"
)
// gortexServerName is the key the gortex stanza lives under in the
// Hermes `mcp_servers` map. Stable across releases so re-installs
// upsert in place rather than duplicating.
const gortexServerName = "gortex"
// connectTimeoutSecs / requestTimeoutSecs match a real-world working
// Hermes ↔ gortex setup: the daemon-backed MCP server can take a
// moment to hand off on first connect and graph-heavy tools (smart_
// context, analyze) occasionally run longer than Hermes' tight
// defaults, so we give both a generous ceiling out of the box.
const (
connectTimeoutSecs = 60
requestTimeoutSecs = 120
)
// gortexMCPEntry builds the stdio MCP stanza Hermes expects under
// `mcp_servers.gortex`. It mirrors the shape Hermes uses for every
// other stdio server (command + args + the two timeout knobs):
//
// gortex:
// command: /abs/path/to/gortex
// args: [mcp]
// connect_timeout: 60
// timeout: 120
//
// `gortex mcp` (no flags) connects to a running daemon and resolves
// the active workspace per MCP session, so one global stanza serves
// every repo Hermes is pointed at — no cwd-relative state to trip on.
func gortexMCPEntry(command string) *yaml.Node {
entry := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
agents.YAMLSetMapValue(entry, "command", agents.YAMLScalar(command))
// Flow style — `args: [mcp]` — to match the canonical Hermes
// example and keep the inserted block as compact as the rest of
// a hand-written config.
args := &yaml.Node{
Kind: yaml.SequenceNode,
Tag: "!!seq",
Style: yaml.FlowStyle,
Content: []*yaml.Node{agents.YAMLScalar("mcp")},
}
agents.YAMLSetMapValue(entry, "args", args)
agents.YAMLSetMapValue(entry, "connect_timeout", yamlInt(connectTimeoutSecs))
agents.YAMLSetMapValue(entry, "timeout", yamlInt(requestTimeoutSecs))
return entry
}
// yamlInt builds an integer scalar node. Kept here rather than in the
// agents package because the generic YAMLScalar helper only covers
// strings and Hermes is the only adapter that needs typed YAML
// scalars today.
func yamlInt(n int) *yaml.Node {
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!int", Value: strconv.Itoa(n)}
}
// SkillName is the directory under ~/.hermes/skills/ that holds the
// gortex skill. Hermes discovers SKILL.md files recursively under the
// skills root, so a single `gortex/SKILL.md` is picked up regardless
// of the per-version layout.
const SkillName = "gortex"
// masterSkillCategory is the Hermes skills category the master gortex
// guide is filed under (skills/<category>/gortex/SKILL.md). Shared with
// skillCategory so the on-disk folder and the frontmatter agree.
const masterSkillCategory = "code-intelligence"
// masterSkillRaw is the static body of the user-level Hermes master
// skill: it teaches the agent to prefer gortex graph tools over raw file
// reads / text search, mirrors the Claude Code / Antigravity user-level
// instruction surface, and documents multi-repo scoping. It follows the
// Hermes SKILL.md frontmatter schema (name / description / version /
// metadata.hermes) and the documented section order. SkillBody() wraps
// it with the dynamic frontmatter fields (platforms, related_skills) and
// the slash-command index, both derived from RoutingSkillNames() so they
// never drift from the installed routing set.
const masterSkillRaw = `---
name: gortex
description: "Use for any task on a codebase indexed by the gortex daemon — searching symbols, finding usages/callers, reading code, tracing impact, refactoring, and multi-repo navigation. Prefer these graph tools over raw file reads or text search."
version: 1.0.0
metadata:
hermes:
tags: [code-intelligence, code-search, navigation, refactoring, mcp]
category: code-intelligence
---
# Gortex Code Intelligence
Gortex indexes repositories into an in-memory knowledge graph and serves it over MCP. On any indexed codebase its graph tools are faster, cheaper, and more accurate than reading whole files or grepping — they return exactly the symbol, caller set, or blast radius you asked for, with zero false positives.
## When to Use
- Searching for a symbol, function, type, or where something is referenced.
- Reading a single function/method without pulling its whole file.
- Understanding architecture, tracing call chains, or checking what a change breaks.
- Refactoring: renames, extractions, and multi-file edits that must stay consistent.
- Working across more than one repository from a single session.
## Prerequisites
- The ` + "`gortex`" + ` MCP server is registered in ` + "`~/.hermes/config.yaml`" + ` under ` + "`mcp_servers.gortex`" + ` (gortex's installer wires this for you).
- The gortex daemon is running and tracking the repo: check with ` + "`gortex daemon status`" + ` in a terminal, start it with ` + "`gortex daemon start --detach`" + `, and track a repo with ` + "`gortex init`" + ` (or ` + "`gortex track <path>`" + `).
- Confirm the graph is live at the start of a task by calling the ` + "`graph_stats`" + ` tool. If ` + "`total_nodes`" + ` is 0, call ` + "`index_repository`" + ` with ` + "`path: \".\"`" + ` first.
## How to Run
Call the gortex MCP tools directly. Translate the instinct to read or grep into the matching graph query. If you only have the terminal (no MCP tools), every tool below is reachable as ` + "`gortex call <tool> --arg k=v`" + ` (e.g. ` + "`gortex call read_file --arg path=<file>`" + `) — there is no bare ` + "`gortex <tool>`" + ` verb.
### Search and navigation
| Instead of... | Use the gortex tool... |
|------------------------------------------|----------------------------------------------|
| Grepping for a symbol | ` + "`search_symbols`" + ` (BM25 + camelCase-aware) |
| Grepping for references | ` + "`find_usages`" + ` (zero false positives) |
| Hunting for callers | ` + "`get_callers`" + ` / ` + "`get_call_chain`" + ` |
| Globbing source files (` + "`**/*.go`" + `) | ` + "`get_repo_outline`" + ` / ` + "`search_symbols`" + ` |
| Many file reads to orient on a task | ` + "`smart_context`" + ` (one call assembles the working set) |
| Literal / regex text the symbol index misses | ` + "`search_text`" + ` (trigram-accelerated grep) |
### Reading source
| Instead of... | Use the gortex tool... |
|------------------------------------------|----------------------------------------------|
| Reading a whole file for one function | ` + "`get_symbol_source`" + ` (≈80% fewer tokens) |
| Reading a file to understand it | ` + "`get_file_summary`" + ` / ` + "`get_editing_context`" + ` |
| Reading a file to check a signature | ` + "`get_symbol`" + ` (signature in ` + "`meta.signature`" + `) |
| Reading a non-indexed / raw file | ` + "`read_file`" + ` (atomic, overlay-aware) |
### Editing and refactoring
| Instead of... | Use the gortex tool... |
|------------------------------------------|----------------------------------------------|
| A whole-file string-match edit | ` + "`edit_file`" + ` (no pre-read; atomic; auto-reindex) |
| A read→edit roundtrip for one symbol | ` + "`edit_symbol`" + ` (edit by ID) |
| Manual find-and-replace for a rename | ` + "`rename_symbol`" + ` (updates cross-file references) |
| Sequencing multi-file edits by hand | ` + "`batch_edit`" + ` (dependency-ordered, atomic) |
| Guessing what a change breaks | ` + "`verify_change`" + ` / ` + "`get_dependents`" + ` (blast radius) |
### Analysis
` + "`analyze`" + ` is a unified dispatcher — pass ` + "`kind`" + ` for one of ` + "`dead_code`" + `, ` + "`hotspots`" + `, ` + "`cycles`" + `, ` + "`coverage_gaps`" + `, ` + "`todos`" + `, ` + "`sast`" + `, ` + "`impact`" + `, ` + "`cross_repo`" + `, and ~50 more. ` + "`get_architecture`" + ` gives a one-call architectural snapshot.
## Multi-repo scoping
The daemon can track several repositories at once. Scope your queries so results come from the right project:
- Call ` + "`get_active_project`" + ` to see the current scope and ` + "`set_active_project`" + ` to switch the session default.
- Most list/search tools accept a ` + "`repo`" + ` or ` + "`project`" + ` argument to target one repository for a single call without changing the session default.
- ` + "`list_repos`" + ` enumerates everything the daemon tracks; ` + "`track_repository`" + ` adds a new one.
- ` + "`analyze kind: \"cross_repo\"`" + ` and a ` + "`find_usages`" + ` partitioned by repo answer "who consumes this across all our services?".
## Quick Reference
1. ` + "`index_health`" + ` — confirm the daemon is up and oriented (` + "`graph_stats`" + ` for node/edge counts).
2. ` + "`smart_context`" + ` with the task description — assemble the minimal working set.
3. ` + "`search_symbols`" + ` / ` + "`find_usages`" + ` / ` + "`get_symbol_source`" + ` — navigate and read.
4. ` + "`get_editing_context`" + ` then ` + "`edit_symbol`" + ` / ` + "`edit_file`" + ` / ` + "`rename_symbol`" + ` / ` + "`batch_edit`" + ` — edit safely.
5. ` + "`verify_change`" + ` / ` + "`get_test_targets`" + ` — check the blast radius before and after.
## Token economy
For list-shaped responses (` + "`search_symbols`" + `, ` + "`find_usages`" + `, ` + "`analyze`" + `, ` + "`get_callers`" + `, ` + "`get_editing_context`" + `, ` + "`smart_context`" + `, …) pass ` + "`format: \"gcx\"`" + ` for the GCX1 compact wire format — round-trippable, ~27% fewer tokens. For reading source, pass ` + "`compress_bodies: true`" + ` to ` + "`read_file`" + ` / ` + "`get_symbol_source`" + ` / ` + "`get_editing_context`" + ` to elide function bodies to signatures (~3040% of original tokens).
## Pitfalls
- Don't fall back to raw file reads / shell grep on an indexed repo "just to be quick" — the graph tools are both faster and more precise, and they keep your context budget intact.
- An empty result from ` + "`search_symbols`" + ` usually means the daemon hasn't finished warming or isn't tracking this repo — check ` + "`graph_stats`" + ` / ` + "`index_health`" + ` rather than assuming the symbol is absent.
- In a multi-repo session, an unexpected result set is often a scoping issue — verify ` + "`get_active_project`" + ` or pass an explicit ` + "`repo`" + ` argument.
## Verification
After edits, call ` + "`verify_change`" + ` (broken callers + interface implementors, cross-repo) and ` + "`get_test_targets`" + ` (the tests that cover what you touched) before declaring the task done.
`
// SkillBody renders the master gortex skill: the static guide
// (masterSkillRaw) with the dynamic frontmatter fields (platforms,
// related_skills) injected and the slash-command index appended. The
// dynamic parts derive from RoutingSkillNames() so they stay in sync
// with the routing skills actually installed.
func SkillBody() string {
const fmClose = " category: " + masterSkillCategory + "\n---\n"
inject := " category: " + masterSkillCategory + "\n" +
" platforms: [linux, macos, windows]\n" +
" related_skills: [" + strings.Join(RoutingSkillNames(), ", ") + "]\n---\n"
return strings.Replace(masterSkillRaw, fmClose, inject, 1) + masterSkillCommands()
}
// masterSkillCommands renders a discoverability section listing the
// /gortex-* slash commands that `gortex install` registers (Hermes turns
// every installed skill into a /<name> command). Derived from
// RoutingSkillNames() so the list can't drift from what is installed.
func masterSkillCommands() string {
names := RoutingSkillNames()
if len(names) == 0 {
return ""
}
var b strings.Builder
b.WriteString("\n## Task playbooks (slash commands)\n\n")
b.WriteString("`gortex install` also registers these per-task playbooks as Hermes slash commands. Reach for the one that matches your task:\n\n")
for _, n := range names {
b.WriteString("- `/" + n + "`\n")
}
return b.String()
}
// RoutingSkills returns the per-task routing skills, keyed by the
// directory under ~/.hermes/skills/. They mirror Claude Code's curated
// user-level skill set so a Hermes user gets the same task-routing
// surface — explore / impact / debug / refactor / rename / safe-edit /
// add-test / pr-review / … — that `gortex install` gives Claude Code.
//
// The bodies are the single source of truth in
// internal/agents/claudecode (reused verbatim so the two agents never
// drift) re-wrapped with Hermes frontmatter. Hermes also turns every
// installed skill into a `/skill-name` slash command, so the
// `/gortex-explore`-style cross-references in the bodies resolve to the
// sibling skills installed here.
//
// gortex-guide is excluded: the native master `gortex` skill
// (SkillBody) already fills the guide role, and shipping both would be
// a redundant entry in Hermes' skill picker.
func RoutingSkills() map[string]string {
out := make(map[string]string, len(claudecode.GlobalSkills))
for name, claudeBody := range claudecode.GlobalSkills {
if name == "gortex-guide" {
continue
}
out[name] = hermesSkillFromClaude(name, claudeBody)
}
return out
}
// RoutingSkillNames returns the routing skill directory names, sorted,
// for a stable Plan / install report and deterministic tests.
func RoutingSkillNames() []string {
skills := RoutingSkills()
names := make([]string, 0, len(skills))
for name := range skills {
names = append(names, name)
}
sort.Strings(names)
return names
}
// hermesSkillFromClaude re-frames one Claude Code skill as a Hermes
// skill: it keeps the body verbatim and swaps the Claude frontmatter
// (name + description) for Hermes frontmatter (name + description +
// version + metadata.hermes.{tags,category}).
func hermesSkillFromClaude(name, claudeContent string) string {
desc := claudeFrontmatterField(claudeContent, "description")
body := stripClaudeFrontmatter(claudeContent)
tags, category := routingSkillTaxonomy(name)
var b strings.Builder
b.WriteString("---\n")
b.WriteString("name: " + name + "\n")
if desc != "" {
b.WriteString("description: " + desc + "\n")
}
b.WriteString("version: 1.0.0\n")
b.WriteString("metadata:\n")
b.WriteString(" hermes:\n")
b.WriteString(" tags: [" + strings.Join(tags, ", ") + "]\n")
b.WriteString(" category: " + category + "\n")
b.WriteString(" platforms: [linux, macos, windows]\n")
b.WriteString(" related_skills: [" + SkillName + "]\n")
b.WriteString("---\n")
b.WriteString(body)
return b.String()
}
// stripClaudeFrontmatter drops the leading `---`-fenced YAML block from
// a Claude skill body, returning just the markdown. The skill bodies
// always start with `---\n`; the first `\n---\n` after it closes the
// frontmatter.
func stripClaudeFrontmatter(s string) string {
const open = "---\n"
if !strings.HasPrefix(s, open) {
return s
}
rest := s[len(open):]
if _, after, found := strings.Cut(rest, "\n---\n"); found {
return after
}
return s
}
// claudeFrontmatterField extracts a single-line scalar field from the
// leading frontmatter block, value verbatim (quotes and inner escapes
// preserved — the Claude descriptions are already valid YAML double-
// quoted scalars). Returns "" when the field is absent.
func claudeFrontmatterField(s, field string) string {
const open = "---\n"
if !strings.HasPrefix(s, open) {
return ""
}
block := s[len(open):]
if before, _, found := strings.Cut(block, "\n---\n"); found {
block = before
}
prefix := field + ":"
for line := range strings.SplitSeq(block, "\n") {
if rest, ok := strings.CutPrefix(line, prefix); ok {
return strings.TrimSpace(rest)
}
}
return ""
}
// routingSkillTaxonomy assigns Hermes discovery tags + a category to a
// routing skill from its name. The per-skill topic tag (the name minus
// the `gortex-` prefix) plus a broad category make the skill findable
// in Hermes' skills_list without hand-maintaining a table per skill.
func routingSkillTaxonomy(name string) (tags []string, category string) {
topic := strings.TrimPrefix(name, "gortex-")
category = "code-intelligence"
switch topic {
case "explore", "onboarding", "cross-repo-usage", "dataflow-trace":
category = "navigation"
case "impact", "co-change", "architecture-review", "quality-audit", "pr-review", "pr-review-agent", "episode-replay":
category = "analysis"
case "debug", "incident-investigation":
category = "debugging"
case "refactor", "rename", "extract-function", "safe-edit", "fix-all":
category = "refactoring"
case "add-test":
category = "testing"
}
return []string{"code-intelligence", "mcp", topic}, category
}
+243
View File
@@ -0,0 +1,243 @@
package hermes
import (
"strconv"
"strings"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/claudecode"
yaml "gopkg.in/yaml.v3"
)
// Hermes shell-hook wiring. The Claude Code adapter installs PreToolUse
// (+ UserPromptSubmit / SessionStart) hooks that redirect Grep / Glob /
// Read of indexed source to graph tools; Hermes exposes the equivalent
// primitives under the `hooks:` block of ~/.hermes/config.yaml, so this
// file gives Hermes the same graph-first enforcement.
//
// Two events are wired (the only two that can affect a turn — Hermes'
// on_session_start is observer-only and post_tool_call is
// fire-and-forget, so neither can inject context or block):
//
// - pre_tool_call (matcher "read_file|terminal"): the block/redirect
// hook. read_file covers whole-file reads of indexed source;
// terminal covers shell grep / find / cat (Hermes has no separate
// Grep / Glob tool — every shell command rides the one terminal
// tool). Honours the posture flag the same way Claude Code does.
// - pre_llm_call (no matcher): context injection. It does double duty
// — the Gortex orientation briefing on the first turn (Claude's
// SessionStart equivalent, which Hermes lacks) and relevant-symbol
// surfacing on every later turn (Claude's UserPromptSubmit).
//
// Both events are written only to the GLOBAL ~/.hermes/config.yaml.
// Unlike `mcp_servers` (which a profile can re-declare, so we upsert
// per-profile too), Hermes documents shell hooks only at global scope.
const (
// hermesPreToolEvent / hermesPreLLMEvent are the snake_case event
// keys under the config's `hooks:` map.
hermesPreToolEvent = "pre_tool_call"
hermesPreLLMEvent = "pre_llm_call"
// hermesToolMatcher is the regex Hermes matches against tool_name
// for the pre_tool_call hook. It must agree with the tool names the
// hooks.RunHermes dispatcher inspects (read_file + terminal).
hermesToolMatcher = "read_file|terminal"
// hermesHookTimeoutSecs bounds each hook invocation. Hermes hook
// timeouts are in SECONDS (default 60, max 300) — unlike Claude
// Code's millisecond timeouts. 5s is generous for a localhost daemon
// probe yet well under the ceiling, so a wedged daemon can't stall a
// turn for long.
hermesHookTimeoutSecs = 5
)
// upsertGortexHooks merges the gortex pre_tool_call + pre_llm_call
// entries into the `hooks:` block of an already-open config root,
// preserving comments and unrelated hooks. It is called from inside the
// global-config MergeYAML mutate so the whole config.yaml (mcp_servers +
// hooks) is written in a single comment-preserving pass. Returns whether
// it changed anything.
func upsertGortexHooks(root *yaml.Node, mode string, force bool) (bool, error) {
hooksNode, err := ensureHooksMapping(root)
if err != nil {
return false, err
}
binary := hermesHookBinary()
preToolCmd := binary + " hook --agent hermes" + hermesModeSuffix(mode)
preLLMCmd := binary + " hook --agent hermes"
// pre_tool_call carries the matcher + posture; pre_llm_call is the
// matcher-less injection hook (its posture is irrelevant — it never
// blocks).
changedTool := upsertHookEvent(hooksNode, hermesPreToolEvent, hermesToolMatcher, preToolCmd, force)
changedLLM := upsertHookEvent(hooksNode, hermesPreLLMEvent, "", preLLMCmd, force)
return changedTool || changedLLM, nil
}
// ensureHooksMapping returns the `hooks:` mapping node, creating it when
// absent or replacing an explicit-null value (`hooks:` with nothing, or
// `hooks: {}` decoded to null) in place so its leading comment survives.
// A non-mapping `hooks:` value is refused rather than clobbered.
func ensureHooksMapping(root *yaml.Node) (*yaml.Node, error) {
node := agents.YAMLMapValue(root, "hooks")
switch {
case node == nil, yamlIsNull(node):
node = &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
agents.YAMLSetMapValue(root, "hooks", node)
case node.Kind == yaml.MappingNode:
// Reuse in place.
default:
return nil, &hooksShapeError{}
}
return node, nil
}
// upsertHookEvent ensures hooks[event] is a sequence holding exactly one
// gortex entry with the desired matcher + command + timeout. An existing
// gortex entry is re-stamped in place when it drifts (binary path moved,
// posture switched) so user-added fields and comments survive; a missing
// one is appended; an up-to-date one is left untouched unless force.
// Non-gortex entries in the same sequence are never touched.
func upsertHookEvent(hooksNode *yaml.Node, event, matcher, command string, force bool) bool {
seq := agents.YAMLMapValue(hooksNode, event)
if seq == nil || yamlIsNull(seq) {
seq = &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"}
agents.YAMLSetMapValue(hooksNode, event, seq)
} else if seq.Kind != yaml.SequenceNode {
// `event:` holds a scalar/mapping rather than a list — not a
// shape we can splice an entry into. Leave it alone.
return false
}
existing := findGortexHookEntry(seq)
if existing == nil {
seq.Content = append(seq.Content, hermesHookEntryNode(matcher, command))
return true
}
if hookEntryMatches(existing, matcher, command) && !force {
return false
}
// Re-stamp the existing entry's contents in place (keeps node
// identity, so any HeadComment the user attached to it survives).
desired := hermesHookEntryNode(matcher, command)
existing.Content = desired.Content
return true
}
// findGortexHookEntry returns the first sequence item that is a gortex
// hook entry (its `command` invokes `gortex hook --agent hermes`), or
// nil. Identifies our entry by the command string so a re-install
// updates it in place rather than appending a duplicate.
func findGortexHookEntry(seq *yaml.Node) *yaml.Node {
if seq == nil || seq.Kind != yaml.SequenceNode {
return nil
}
for _, item := range seq.Content {
if item.Kind != yaml.MappingNode {
continue
}
cmd := agents.YAMLMapValue(item, "command")
if cmd != nil && cmd.Kind == yaml.ScalarNode && commandIsGortexHermesHook(cmd.Value) {
return item
}
}
return nil
}
// commandIsGortexHermesHook reports whether a hook command string is a
// gortex Hermes hook invocation: it runs the `hook` subcommand of a
// gortex binary with the hermes agent protocol selected. Robust to the
// binary being an absolute path and to `--agent hermes` vs
// `--agent=hermes`.
func commandIsGortexHermesHook(cmd string) bool {
lower := strings.ToLower(cmd)
if !strings.Contains(lower, "gortex") || !strings.Contains(lower, "hook") {
return false
}
return strings.Contains(cmd, "--agent hermes") || strings.Contains(cmd, "--agent=hermes")
}
// hookEntryMatches reports whether an existing hook entry already has
// the desired matcher + command + timeout, so an idempotent re-run is a
// no-op. A missing matcher (pre_llm_call) must mean no matcher key.
func hookEntryMatches(entry *yaml.Node, matcher, command string) bool {
if scalarValue(agents.YAMLMapValue(entry, "command")) != command {
return false
}
if scalarValue(agents.YAMLMapValue(entry, "matcher")) != matcher {
return false
}
return scalarValue(agents.YAMLMapValue(entry, "timeout")) == strconv.Itoa(hermesHookTimeoutSecs)
}
// hermesHookEntryNode builds one `hooks:` list entry: an optional
// matcher (omitted when empty — pre_llm_call takes no matcher), the
// command, and the timeout. matcher + command are double-quoted to
// match the documented Hermes example shape and to survive any future
// special characters in a binary path.
func hermesHookEntryNode(matcher, command string) *yaml.Node {
entry := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
if matcher != "" {
agents.YAMLSetMapValue(entry, "matcher", yamlQuoted(matcher))
}
agents.YAMLSetMapValue(entry, "command", yamlQuoted(command))
agents.YAMLSetMapValue(entry, "timeout", yamlInt(hermesHookTimeoutSecs))
return entry
}
// hermesHookBinary resolves the gortex binary path for the hook command
// and normalises backslashes to forward slashes. Hermes runs hook
// commands through a shell; on Windows that shell is Git Bash, which
// mangles backslash path separators — forward slashes survive there and
// are correct everywhere else. Same treatment the Claude Code adapter
// applies to its hook command.
func hermesHookBinary() string {
return strings.ReplaceAll(resolveGortexCommand(), "\\", "/")
}
// hermesModeSuffix renders the `--mode=<mode>` suffix for the
// pre_tool_call command. deny is the historical default and emitted bare
// so a deny install carries no suffix; every other posture is explicit.
// Reuses the Claude Code adapter's canonical mode strings so the two
// adapters can't drift on what a posture is called.
func hermesModeSuffix(mode string) string {
switch strings.ToLower(strings.TrimSpace(mode)) {
case claudecode.HookModeEnrich:
return " --mode=enrich"
case claudecode.HookModeConsultUnlock:
return " --mode=consult-unlock"
case claudecode.HookModeAdaptiveNudge, "adaptive-nudge":
return " --mode=nudge"
default:
return ""
}
}
// yamlIsNull reports whether n is a YAML null — an explicit null scalar
// (`~`, `null`) or an empty value (`key:` with nothing after it). A
// local copy of the agents-package predicate, which is unexported.
func yamlIsNull(n *yaml.Node) bool {
return n != nil && n.Kind == yaml.ScalarNode && (n.Tag == "!!null" || n.Value == "")
}
// yamlQuoted builds a double-quoted string scalar node.
func yamlQuoted(s string) *yaml.Node {
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Style: yaml.DoubleQuotedStyle, Value: s}
}
// scalarValue returns a scalar node's value, or "" for a nil / non-scalar.
func scalarValue(n *yaml.Node) string {
if n == nil || n.Kind != yaml.ScalarNode {
return ""
}
return n.Value
}
// hooksShapeError is returned when `hooks:` holds a non-mapping value we
// refuse to overwrite.
type hooksShapeError struct{}
func (*hooksShapeError) Error() string {
return "hermes: `hooks` is not a mapping; refusing to overwrite"
}
+267
View File
@@ -0,0 +1,267 @@
package hermes
import (
"os"
"slices"
"strings"
"testing"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/agentstest"
yaml "gopkg.in/yaml.v3"
)
// hookEntries returns the list of hook entries under hooks[event] in a
// parsed Hermes config, or nil. Each entry is a map[string]any.
func hookEntries(t *testing.T, cfg map[string]any, event string) []map[string]any {
t.Helper()
hooks, ok := cfg["hooks"].(map[string]any)
if !ok {
return nil
}
raw, ok := hooks[event].([]any)
if !ok {
return nil
}
out := make([]map[string]any, 0, len(raw))
for _, e := range raw {
if m, ok := e.(map[string]any); ok {
out = append(out, m)
}
}
return out
}
// gortexHookEntry returns the single gortex hook entry under
// hooks[event], failing if absent or duplicated.
func gortexHookEntry(t *testing.T, cfg map[string]any, event string) map[string]any {
t.Helper()
var found map[string]any
count := 0
for _, e := range hookEntries(t, cfg, event) {
if cmd, _ := e["command"].(string); strings.Contains(cmd, "--agent hermes") {
found = e
count++
}
}
if count != 1 {
t.Fatalf("hooks[%s]: expected exactly 1 gortex entry, got %d", event, count)
}
return found
}
// hookEnv returns a global-mode env with hook installation enabled at
// the given posture.
func hookEnv(t *testing.T, mode string) agents.Env {
t.Helper()
env, _ := agentstest.NewEnv(t)
seedHermesHome(t, env.Home)
env.InstallHooks = true
env.HookMode = mode
return env
}
// TestHermesInstallsHooks is the acceptance test for issue #51:
// `gortex install --agents=hermes --hooks` writes a valid pre_tool_call
// (matcher read_file|terminal) and pre_llm_call (no matcher) entry, and
// a re-run is a pure no-op.
func TestHermesInstallsHooks(t *testing.T) {
env := hookEnv(t, "deny")
a := New()
if _, err := a.Apply(env, agents.ApplyOpts{}); err != nil {
t.Fatalf("apply: %v", err)
}
cfg := agentstest.ReadYAML(t, globalConfigPath(env.Home))
// pre_tool_call: matcher + gortex hook command + a seconds timeout.
pt := gortexHookEntry(t, cfg, hermesPreToolEvent)
if pt["matcher"] != hermesToolMatcher {
t.Errorf("pre_tool_call matcher = %v, want %q", pt["matcher"], hermesToolMatcher)
}
cmd, _ := pt["command"].(string)
if !strings.Contains(cmd, "hook --agent hermes") {
t.Errorf("pre_tool_call command should invoke the hermes hook: %q", cmd)
}
if strings.Contains(cmd, "--mode") {
t.Errorf("deny posture should be emitted bare (no --mode): %q", cmd)
}
if pt["timeout"] != hermesHookTimeoutSecs {
t.Errorf("pre_tool_call timeout = %v, want %d", pt["timeout"], hermesHookTimeoutSecs)
}
// pre_llm_call: NO matcher (Hermes rejects matchers on non-tool events).
pl := gortexHookEntry(t, cfg, hermesPreLLMEvent)
if _, hasMatcher := pl["matcher"]; hasMatcher {
t.Errorf("pre_llm_call must not carry a matcher: %#v", pl)
}
if cmd, _ := pl["command"].(string); !strings.Contains(cmd, "hook --agent hermes") {
t.Errorf("pre_llm_call command should invoke the hermes hook: %q", cmd)
}
// The MCP server stanza must still be present — hooks ride the same merge.
if _, ok := cfg["mcp_servers"].(map[string]any)["gortex"]; !ok {
t.Error("mcp_servers.gortex lost when hooks were added")
}
agentstest.AssertIdempotent(t, a, env)
}
// TestHermesNoHooksSkips verifies --no-hooks leaves the hooks block
// untouched while still writing the MCP server stanza.
func TestHermesNoHooksSkips(t *testing.T) {
env, _ := agentstest.NewEnv(t)
seedHermesHome(t, env.Home)
env.InstallHooks = false
a := New()
if _, err := a.Apply(env, agents.ApplyOpts{}); err != nil {
t.Fatalf("apply: %v", err)
}
cfg := agentstest.ReadYAML(t, globalConfigPath(env.Home))
if _, ok := cfg["hooks"]; ok {
t.Errorf("--no-hooks should not write a hooks block: %#v", cfg["hooks"])
}
if _, ok := cfg["mcp_servers"].(map[string]any)["gortex"]; !ok {
t.Error("mcp_servers.gortex should still be written under --no-hooks")
}
}
// TestHermesHookModeSwitch verifies switching posture re-stamps the
// pre_tool_call command in place (deny → enrich adds --mode=enrich)
// without duplicating the entry.
func TestHermesHookModeSwitch(t *testing.T) {
env := hookEnv(t, "deny")
a := New()
if _, err := a.Apply(env, agents.ApplyOpts{}); err != nil {
t.Fatalf("apply deny: %v", err)
}
env.HookMode = "enrich"
if _, err := a.Apply(env, agents.ApplyOpts{}); err != nil {
t.Fatalf("apply enrich: %v", err)
}
cfg := agentstest.ReadYAML(t, globalConfigPath(env.Home))
pt := gortexHookEntry(t, cfg, hermesPreToolEvent) // also asserts no duplicate
cmd, _ := pt["command"].(string)
if !strings.Contains(cmd, "--mode=enrich") {
t.Errorf("posture switch should re-stamp command with --mode=enrich: %q", cmd)
}
}
// TestHermesHooksPreserveExistingEntriesAndComments guards the
// comment-rich merge: a hand-edited config keeps its comments, its
// unrelated keys, and any non-gortex hook entries.
func TestHermesHooksPreserveExistingEntriesAndComments(t *testing.T) {
env := hookEnv(t, "deny")
cfgPath := globalConfigPath(env.Home)
original := `# my hermes config
model: hermes-4 # the good one
hooks:
pre_tool_call:
# block dangerous shell commands
- matcher: terminal
command: ~/.hermes/agent-hooks/guard.sh
timeout: 10
hooks_auto_accept: true
`
if err := os.WriteFile(cfgPath, []byte(original), 0o644); err != nil {
t.Fatalf("seed config: %v", err)
}
a := New()
if _, err := a.Apply(env, agents.ApplyOpts{}); err != nil {
t.Fatalf("apply: %v", err)
}
out, err := os.ReadFile(cfgPath)
if err != nil {
t.Fatalf("read back: %v", err)
}
got := string(out)
for _, want := range []string{"# my hermes config", "the good one", "block dangerous shell commands"} {
if !strings.Contains(got, want) {
t.Errorf("lost comment %q:\n%s", want, got)
}
}
cfg := agentstest.ReadYAML(t, cfgPath)
if cfg["hooks_auto_accept"] != true {
t.Errorf("unrelated key hooks_auto_accept clobbered: %v", cfg["hooks_auto_accept"])
}
// The pre-existing guard.sh hook survives alongside the gortex one.
entries := hookEntries(t, cfg, hermesPreToolEvent)
var hasGuard, hasGortex bool
for _, e := range entries {
cmd, _ := e["command"].(string)
if strings.Contains(cmd, "guard.sh") {
hasGuard = true
}
if strings.Contains(cmd, "--agent hermes") {
hasGortex = true
}
}
if !hasGuard {
t.Error("pre-existing guard.sh hook was dropped")
}
if !hasGortex {
t.Error("gortex hook was not added alongside the existing one")
}
}
// TestHermesPlanReportsHooks verifies Plan lists the hooks key when hook
// installation is enabled (so `init doctor` reports it) and omits it
// otherwise.
func TestHermesPlanReportsHooks(t *testing.T) {
a := New()
withHooks := hookEnv(t, "deny")
plan, err := a.Plan(withHooks)
if err != nil {
t.Fatalf("plan: %v", err)
}
if !planGlobalHasKey(plan, withHooks.Home, "hooks") {
t.Error("Plan should report the hooks key when InstallHooks is set")
}
noHooks, _ := agentstest.NewEnv(t)
seedHermesHome(t, noHooks.Home)
noHooks.InstallHooks = false
plan, err = a.Plan(noHooks)
if err != nil {
t.Fatalf("plan: %v", err)
}
if planGlobalHasKey(plan, noHooks.Home, "hooks") {
t.Error("Plan should not report the hooks key under --no-hooks")
}
}
// planGlobalHasKey reports whether the global-config FileAction in plan
// lists key among its merge keys.
func planGlobalHasKey(plan *agents.Plan, home, key string) bool {
target := globalConfigPath(home)
for _, f := range plan.Files {
if f.Path != target {
continue
}
if slices.Contains(f.Keys, key) {
return true
}
}
return false
}
// TestUpsertHookEvent_RefusesNonSequence verifies that a malformed
// `hooks.pre_tool_call` scalar is left untouched rather than clobbered.
func TestUpsertHookEvent_RefusesNonSequence(t *testing.T) {
var doc yaml.Node
if err := yaml.Unmarshal([]byte("pre_tool_call: nonsense\n"), &doc); err != nil {
t.Fatalf("unmarshal: %v", err)
}
hooksNode := doc.Content[0]
if changed := upsertHookEvent(hooksNode, "pre_tool_call", "m", "cmd", false); changed {
t.Error("non-sequence event value should be left untouched (no change)")
}
}
+115
View File
@@ -0,0 +1,115 @@
package agents
import (
"slices"
"strings"
)
// hooks.go installs Gortex lifecycle hooks into the Gemini-CLI /
// Antigravity settings.json. These agents share Claude Code's
// hookSpecificOutput.additionalContext wire shape, so a gortex hook can
// inject the same session orientation (SessionStart) and stale-index
// hints (AfterTool) it gives Claude — closing the gap where the
// non-Claude adapters wrote MCP config but no lifecycle hooks.
// GortexHookName is the identifier stamped on every hook entry Gortex
// installs, so a re-run detects and skips (or, with Force, replaces)
// its own entries without disturbing the user's other hooks.
const GortexHookName = "gortex"
// geminiHookTimeoutMS is the per-hook timeout. Gemini CLI measures hook
// timeouts in milliseconds (Claude Code uses seconds), so this is 10s.
const geminiHookTimeoutMS = 10000
// UpsertGeminiHooks installs Gortex SessionStart + AfterTool lifecycle
// hooks into a Gemini-CLI / Antigravity settings.json root, politely
// merging with any existing user hooks (existing entries are preserved;
// gortex's own entry is added once and is idempotent on re-run).
// agentFlag is the value passed to `gortex hook --agent <agentFlag>`.
// Returns true when the root was modified.
func UpsertGeminiHooks(root map[string]any, agentFlag string, opts ApplyOpts) (changed bool) {
hooks, ok := root["hooks"].(map[string]any)
if !ok {
hooks = map[string]any{}
}
cmd := "gortex hook --agent " + agentFlag
// SessionStart: full session orientation (no tool matcher).
if upsertHookEvent(hooks, "SessionStart", "", cmd, "Gortex session orientation", opts) {
changed = true
}
// AfterTool: a concise stale-index hint after the tools where the
// index can drift (shell, search, glob).
if upsertHookEvent(hooks, "AfterTool", "run_shell_command|search_file_content|glob", cmd, "Gortex graph context + stale-index hint", opts) {
changed = true
}
if changed {
root["hooks"] = hooks
}
return changed
}
// upsertHookEvent appends a gortex hook group to one event's array,
// preserving existing user entries. When a gortex entry is already
// present it is a no-op (idempotent) unless opts.Force, which replaces
// it. Returns true when the array was modified.
func upsertHookEvent(hooks map[string]any, event, matcher, cmd, desc string, opts ApplyOpts) bool {
arr, _ := hooks[event].([]any)
hasGortex := slices.ContainsFunc(arr, hookGroupIsGortex)
if hasGortex && !opts.Force {
return false
}
if hasGortex && opts.Force {
// Drop existing gortex groups; keep the user's.
kept := make([]any, 0, len(arr))
for _, e := range arr {
if !hookGroupIsGortex(e) {
kept = append(kept, e)
}
}
arr = kept
}
inner := map[string]any{
"type": "command",
"command": cmd,
"name": GortexHookName,
"timeout": geminiHookTimeoutMS,
"description": desc,
}
group := map[string]any{"hooks": []any{inner}}
if matcher != "" {
group["matcher"] = matcher
}
hooks[event] = append(arr, group)
return true
}
// hookGroupIsGortex reports whether a hook group was installed by
// Gortex — either tagged with GortexHookName or carrying a
// `gortex hook` command.
func hookGroupIsGortex(e any) bool {
g, ok := e.(map[string]any)
if !ok {
return false
}
inner, ok := g["hooks"].([]any)
if !ok {
return false
}
for _, h := range inner {
hm, ok := h.(map[string]any)
if !ok {
continue
}
if name, _ := hm["name"].(string); name == GortexHookName {
return true
}
if cmd, _ := hm["command"].(string); strings.Contains(cmd, "gortex hook") {
return true
}
}
return false
}
+108
View File
@@ -0,0 +1,108 @@
package agents
import "testing"
// gortexGroupCount counts the gortex-authored hook groups under one
// event in a settings root.
func gortexGroupCount(root map[string]any, event string) int {
hooks, _ := root["hooks"].(map[string]any)
arr, _ := hooks[event].([]any)
n := 0
for _, e := range arr {
if hookGroupIsGortex(e) {
n++
}
}
return n
}
func TestUpsertGeminiHooks_InstallsSessionStartAndAfterTool(t *testing.T) {
root := map[string]any{}
if !UpsertGeminiHooks(root, "gemini", ApplyOpts{}) {
t.Fatal("expected the first install to report a change")
}
hooks, ok := root["hooks"].(map[string]any)
if !ok {
t.Fatal("hooks key not written")
}
for _, event := range []string{"SessionStart", "AfterTool"} {
arr, ok := hooks[event].([]any)
if !ok || len(arr) != 1 {
t.Fatalf("%s: want one group, got %v", event, hooks[event])
}
group := arr[0].(map[string]any)
inner := group["hooks"].([]any)[0].(map[string]any)
if inner["command"] != "gortex hook --agent gemini" {
t.Errorf("%s command=%v", event, inner["command"])
}
if inner["timeout"] != geminiHookTimeoutMS {
t.Errorf("%s timeout=%v want %d (milliseconds)", event, inner["timeout"], geminiHookTimeoutMS)
}
}
// AfterTool carries a tool matcher; SessionStart does not.
if _, ok := hooks["AfterTool"].([]any)[0].(map[string]any)["matcher"]; !ok {
t.Error("AfterTool group should carry a matcher")
}
if _, ok := hooks["SessionStart"].([]any)[0].(map[string]any)["matcher"]; ok {
t.Error("SessionStart group should not carry a matcher")
}
}
func TestUpsertGeminiHooks_Idempotent(t *testing.T) {
root := map[string]any{}
UpsertGeminiHooks(root, "gemini", ApplyOpts{})
if UpsertGeminiHooks(root, "gemini", ApplyOpts{}) {
t.Error("a second install without Force should be a no-op")
}
if got := gortexGroupCount(root, "SessionStart"); got != 1 {
t.Errorf("SessionStart gortex groups=%d want 1 (no duplicate)", got)
}
}
func TestUpsertGeminiHooks_PreservesExistingUserHooks(t *testing.T) {
root := map[string]any{
"hooks": map[string]any{
"AfterTool": []any{
map[string]any{"hooks": []any{map[string]any{"type": "command", "command": "my-own-linter", "name": "user"}}},
},
},
}
UpsertGeminiHooks(root, "gemini", ApplyOpts{})
arr := root["hooks"].(map[string]any)["AfterTool"].([]any)
if len(arr) != 2 {
t.Fatalf("expected the user's hook to be preserved alongside gortex's, got %d groups", len(arr))
}
// The user's entry must survive untouched.
first := arr[0].(map[string]any)["hooks"].([]any)[0].(map[string]any)
if first["command"] != "my-own-linter" {
t.Errorf("user hook clobbered: %v", first)
}
}
func TestUpsertGeminiHooks_ForceReplacesGortexOnly(t *testing.T) {
root := map[string]any{}
UpsertGeminiHooks(root, "gemini", ApplyOpts{})
// Add a user hook to AfterTool, then force-reinstall.
hooks := root["hooks"].(map[string]any)
hooks["AfterTool"] = append(hooks["AfterTool"].([]any),
map[string]any{"hooks": []any{map[string]any{"type": "command", "command": "user-linter", "name": "user"}}})
if !UpsertGeminiHooks(root, "gemini", ApplyOpts{Force: true}) {
t.Fatal("Force reinstall should report a change")
}
if got := gortexGroupCount(root, "AfterTool"); got != 1 {
t.Errorf("Force should leave exactly one gortex group, got %d", got)
}
// The user's hook still survives.
arr := hooks["AfterTool"].([]any)
foundUser := false
for _, e := range arr {
inner := e.(map[string]any)["hooks"].([]any)[0].(map[string]any)
if inner["command"] == "user-linter" {
foundUser = true
}
}
if !foundUser {
t.Error("Force must not remove the user's own hook")
}
}
+273
View File
@@ -0,0 +1,273 @@
// Instructions shared across every doc-aware adapter. Centralising the
// body here avoids per-adapter drift: Cursor's .cursor/rules file,
// Copilot's .github/copilot-instructions.md, Codex's AGENTS.md, and
// Claude Code's CLAUDE.md all read from the same constant, so when the
// "prefer Gortex over Read/Grep" story evolves we update it once and
// every agent sees the change on the next `gortex init`.
//
// The claudecode adapter extends this body with its own slash-commands
// appendix — that part is Claude-Code-specific and lives in
// claudecode/content.go, keyed off the same sentinel so idempotency
// checks line up across adapters.
package agents
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/zzet/gortex/internal/profiles"
)
// InstructionsSentinel is the substring every doc-aware adapter checks
// for when deciding whether to append the instructions block. If it's
// already present (wherever it came from — a prior `gortex init`, a
// user-copied block, another adapter writing to a shared rules file
// like AGENTS.md) we skip to stay idempotent.
const InstructionsSentinel = "## MANDATORY: Use Gortex MCP tools"
// CommunitiesStartMarker / CommunitiesEndMarker fence the generated
// community-routing block that `gortex init` writes into per-repo
// instructions files. Fenced (not just start-only) because this block
// is regenerated on every `init` re-run as the codebase evolves, so
// we need to identify and overwrite it precisely without clobbering
// user edits around it.
const (
CommunitiesStartMarker = "<!-- gortex:communities:start -->"
CommunitiesEndMarker = "<!-- gortex:communities:end -->"
)
// GlobalRulesStartMarker / GlobalRulesEndMarker fence the rule block
// that `gortex install` merges into ~/.claude/CLAUDE.md. The block is
// idempotent (re-running install replaces it in place) and removable
// (user can delete the marked region by hand without other side
// effects). Distinct from the communities markers above because this
// block lives at user level and survives every project init.
const (
GlobalRulesStartMarker = "<!-- gortex:rules:start -->"
GlobalRulesEndMarker = "<!-- gortex:rules:end -->"
)
// GlobalPointerBody renders the thin machine-level rule block
// `gortex install` merges into ~/.claude/CLAUDE.md. The rule content
// itself lives in <instructionsDir>/active.md — an atomic byte copy of
// the selected instruction profile (internal/profiles) — and is pulled
// in through an @-include, so switching guidance depth never rewrites
// CLAUDE.md. The heading stays here as the idempotency sentinel and as
// a functional minimum for readers that do not expand @-includes.
func GlobalPointerBody(instructionsDir string) string {
active := filepath.Join(instructionsDir, profiles.ActiveFileName)
return "## MANDATORY: Use Gortex MCP tools instead of Read/Grep/Glob\n\n" +
"The machine-wide Gortex rules load from the active instruction profile, imported below:\n\n" +
"@" + active + "\n\n" +
"Switch guidance depth with `gortex instructions switch <core|localization|full>` (`list` shows all) — applies to NEW sessions only.\n"
}
// InstructionsBody is the shared rule block every adapter writes to
// its agent's instructions file. Tool names in the tables (Read, Grep)
// are Claude-Code-specific flavour; models outside Claude Code read
// them as "any file-reading tool" — the principle stays the same so
// we keep one body rather than branch by agent.
const InstructionsBody = `## MANDATORY: Use Gortex MCP tools instead of Read/Grep
Gortex runs as an MCP server for this repository. You MUST prefer graph queries over file reads on every task — PreToolUse hooks deny ` + "`" + `Read` + "`" + ` / ` + "`" + `Grep` + "`" + ` / ` + "`" + `Glob` + "`" + ` against indexed source, and the deny message names the right tool.
**Start every task with ` + "`" + `explore` + "`" + `.** Describe the request in plain words (paste the whole issue — it is distilled server-side; you do not have to hand-craft a query) and it returns the ranked localization neighborhood — the likely-involved symbols with their source, call paths, and the files to change — in ONE call. Answer or start editing from its output; the source it returned is already in context, so do not re-open those files with ` + "`" + `Read` + "`" + ` / ` + "`" + `Glob` + "`" + ` — read more of a listed symbol with ` + "`" + `get_symbol_source` + "`" + ` / ` + "`" + `batch_symbols` + "`" + ` on its ` + "`" + `id:` + "`" + `.
| Instead of... | Use... |
|-------------------------------------|------------------------------------------|
| Localizing a task / bug / "where is X" | ` + "`" + `explore` + "`" + ` (one call: ranked neighborhood + source + call paths) |
| ` + "`" + `Grep` + "`" + ` for a symbol | ` + "`" + `search_symbols` + "`" + ` (BM25 + camelCase-aware) |
| ` + "`" + `Grep` + "`" + ` for references | ` + "`" + `find_usages` + "`" + ` (zero false positives) |
| Reading / grepping to find callers | ` + "`" + `get_callers` + "`" + ` / ` + "`" + `get_call_chain` + "`" + ` |
| ` + "`" + `Read` + "`" + ` a file for one symbol | ` + "`" + `get_symbol_source` + "`" + ` (` + "`" + `compress_bodies:true` + "`" + ` for the signature only) |
| ` + "`" + `Read` + "`" + ` to understand a file | ` + "`" + `get_file_summary` + "`" + ` / ` + "`" + `get_editing_context` + "`" + ` |
| ` + "`" + `Read` + "`" + ` a non-indexed / raw file | ` + "`" + `read_file` + "`" + ` |
| ` + "`" + `Read` + "`" + ` several symbols' bodies at once | ` + "`" + `batch_symbols` + "`" + ` (one call, many bodies) |
| ` + "`" + `Edit` + "`" + ` / ` + "`" + `Write` + "`" + ` source | ` + "`" + `edit_file` + "`" + ` / ` + "`" + `write_file` + "`" + ` / ` + "`" + `edit_symbol` + "`" + ` / ` + "`" + `rename_symbol` + "`" + ` / ` + "`" + `batch_edit` + "`" + ` |
**CLI fallback (no MCP):** every tool above is reachable from a shell as ` + "`" + `gortex call <tool> --arg k=v` + "`" + ` (e.g. ` + "`" + `gortex call read_file --arg path=<file>` + "`" + `) — there is no bare ` + "`" + `gortex <tool>` + "`" + ` verb.
The graph narrows scope; read the real body with ` + "`" + `get_symbol_source` + "`" + ` before you change or depend on a symbol — especially behavior-critical code (migrations, retry / fallback, concurrency), where ` + "`" + `compress_bodies:true` + "`" + ` would elide the risky branches. ` + "`" + `format:"gcx"` + "`" + ` and ` + "`" + `compress_bodies:true` + "`" + ` exist on the read / list tools — the parameter legend is in the MCP server instructions.
**Memory workflow** (behavior-critical; the full triggers live in the global ` + "`" + `~/.claude/CLAUDE.md` + "`" + ` policy and in ` + "`" + `gortex://guide` + "`" + `): ` + "`" + `distill_session` + "`" + ` at session start; ` + "`" + `surface_memories` + "`" + ` right after ` + "`" + `smart_context` + "`" + `; ` + "`" + `save_note tags:"decision"` + "`" + ` at each decision; ` + "`" + `store_memory` + "`" + ` for durable invariants / gotchas the team should inherit; ` + "`" + `query_notes` + "`" + ` / ` + "`" + `query_memories` + "`" + ` before re-touching a symbol.
**Reference:** ` + "`" + `gortex://guide` + "`" + ` (or ` + "`" + `gortex guide [topic]` + "`" + `) carries the full detail — provider matrix, capabilities, analyze / search_ast catalogs, token-economy, MCP resources. The server publishes a lean tool preset eagerly; call ` + "`" + `tools_search` + "`" + ` to discover and load any other tool by keyword.
`
// AppendInstructions appends body to path, creating the file if
// missing. Idempotent: when `sentinel` is already present anywhere in
// the file we skip with ActionSkip and log the reason. Callers pass
// the adapter's ApplyOpts through so --dry-run / --global / --force
// all flow to the right FileAction status.
//
// Not atomic. Rules files are plaintext a human edits, matching the
// historical CLAUDE.md append behaviour — a concurrent external writer
// during init is extraordinarily unlikely and atomic rename of a file
// a human is editing would fight their editor.
func AppendInstructions(w io.Writer, path, body, sentinel string, opts ApplyOpts) (FileAction, error) {
existing, readErr := os.ReadFile(path)
if readErr != nil && !errors.Is(readErr, os.ErrNotExist) {
return FileAction{}, fmt.Errorf("read %s: %w", path, readErr)
}
existed := readErr == nil
if existed && strings.Contains(string(existing), sentinel) {
if w != nil {
fmt.Fprintf(w, "[gortex init] skip %s (Gortex block already present)\n", path)
}
return FileAction{Path: path, Action: ActionSkip, Reason: "block-present"}, nil
}
if opts.DryRun {
action := ActionWouldMerge
if !existed {
action = ActionWouldCreate
}
return FileAction{Path: path, Action: action, Keys: []string{"gortex-block"}}, nil
}
// Two blank lines between existing content and the block so the
// appended section reads as a separate document and doesn't glue
// onto the last paragraph the user wrote.
prefix := ""
if existed && len(existing) > 0 {
prefix = "\n\n"
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return FileAction{}, err
}
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return FileAction{}, err
}
defer f.Close()
if _, err := f.WriteString(prefix + body); err != nil {
return FileAction{}, err
}
if w != nil {
fmt.Fprintf(w, "[gortex init] appended Gortex block to %s\n", path)
}
action := ActionMerge
if !existed {
action = ActionCreate
}
return FileAction{Path: path, Action: action, Keys: []string{"gortex-block"}}, nil
}
// CursorMDCFrontmatter wraps the instructions body in the YAML
// frontmatter Cursor expects for MDC rules files. Cursor reads
// `alwaysApply: true` rules on every chat turn — which is what we
// want for the MANDATORY-prefer-Gortex block.
//
// Kept separate from AppendInstructions because MDC files are
// one-rule-per-file (Cursor owns the filename, not the content), so
// they use WriteIfNotExists semantics, not append.
func CursorMDCFrontmatter(body string) string {
return `---
description: Gortex code intelligence — prefer graph tools over file reads
alwaysApply: true
---
` + body
}
// UpsertMarkedBlock writes `body` into `path` between `startMarker`
// and `endMarker`. Unlike AppendInstructions, this is idempotent AND
// regeneratable: if the markers already exist the block between them
// is replaced; otherwise the block is appended with a blank-line gap
// to existing content. If `body` is empty and the markers exist, the
// block is removed (migration use case). Creates the file if missing.
//
// Designed for the per-repo community-routing block which regenerates
// on every `gortex init` run as the graph evolves.
func UpsertMarkedBlock(w io.Writer, path, body, startMarker, endMarker string, opts ApplyOpts) (FileAction, error) {
existing, readErr := os.ReadFile(path)
if readErr != nil && !errors.Is(readErr, os.ErrNotExist) {
return FileAction{}, fmt.Errorf("read %s: %w", path, readErr)
}
existed := readErr == nil
text := ""
if existed {
text = string(existing)
}
hasBlock := existed && strings.Contains(text, startMarker) && strings.Contains(text, endMarker)
empty := strings.TrimSpace(body) == ""
// Nothing to do: empty body and no existing block.
if empty && !hasBlock {
return FileAction{Path: path, Action: ActionSkip, Reason: "no-communities"}, nil
}
fenced := startMarker + "\n" + body + "\n" + endMarker + "\n"
var next string
switch {
case hasBlock:
start := strings.Index(text, startMarker)
end := strings.Index(text, endMarker) + len(endMarker)
// Trim trailing newline after the end marker so we don't
// accumulate blank lines on repeated re-runs.
if end < len(text) && text[end] == '\n' {
end++
}
if empty {
next = text[:start] + text[end:]
} else {
next = text[:start] + fenced + text[end:]
}
case !existed:
next = fenced
default:
prefix := ""
if len(text) > 0 {
if !strings.HasSuffix(text, "\n") {
prefix = "\n\n"
} else if !strings.HasSuffix(text, "\n\n") {
prefix = "\n"
}
}
next = text + prefix + fenced
}
// Skip when the file would end up byte-identical to what's
// already there — important for AssertIdempotent semantics and
// for avoiding spurious mtime bumps on `gortex init` re-runs
// when the graph hasn't changed.
if existed && next == text {
return FileAction{Path: path, Action: ActionSkip, Reason: "unchanged"}, nil
}
if opts.DryRun {
switch {
case !existed:
return FileAction{Path: path, Action: ActionWouldCreate, Keys: []string{"communities-block"}}, nil
case hasBlock:
return FileAction{Path: path, Action: ActionWouldMerge, Keys: []string{"communities-block"}}, nil
default:
return FileAction{Path: path, Action: ActionWouldMerge, Keys: []string{"communities-block"}}, nil
}
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return FileAction{}, err
}
if err := os.WriteFile(path, []byte(next), 0o644); err != nil {
return FileAction{}, err
}
if w != nil {
verb := "updated"
if !existed {
verb = "wrote"
}
fmt.Fprintf(w, "[gortex init] %s %s (communities block)\n", verb, path)
}
action := ActionMerge
if !existed {
action = ActionCreate
}
return FileAction{Path: path, Action: action, Keys: []string{"communities-block"}}, nil
}
+196
View File
@@ -0,0 +1,196 @@
package agents
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestAppendInstructions_CreateThenSkip pins the behaviour every
// doc-aware adapter depends on: first call creates the file, second
// call with the same body is a no-op ActionSkip. If this regresses,
// running `gortex init` twice would append the block twice to every
// agent's rules file — the user-visible pain we extracted this helper
// to eliminate.
func TestAppendInstructions_CreateThenSkip(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "nested", "rules.md")
var buf bytes.Buffer
action, err := AppendInstructions(&buf, path, InstructionsBody, InstructionsSentinel, ApplyOpts{})
require.NoError(t, err)
assert.Equal(t, ActionCreate, action.Action)
contents, err := os.ReadFile(path)
require.NoError(t, err)
assert.Contains(t, string(contents), InstructionsSentinel,
"first write must land the full sentinel-bearing block")
// Second call is idempotent — no duplicate append.
action, err = AppendInstructions(&buf, path, InstructionsBody, InstructionsSentinel, ApplyOpts{})
require.NoError(t, err)
assert.Equal(t, ActionSkip, action.Action)
assert.Equal(t, "block-present", action.Reason)
after, err := os.ReadFile(path)
require.NoError(t, err)
assert.Equal(t, len(contents), len(after),
"second call must not grow the file")
}
// TestAppendInstructions_PreservesExistingContent guards the merge
// path — the helper must not clobber a hand-written file, it must
// append the block after the user's content with a blank-line gap.
func TestAppendInstructions_PreservesExistingContent(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "AGENTS.md")
existing := "# Team conventions\n\nUse tabs, not spaces.\n"
require.NoError(t, os.WriteFile(path, []byte(existing), 0o644))
action, err := AppendInstructions(nil, path, InstructionsBody, InstructionsSentinel, ApplyOpts{})
require.NoError(t, err)
assert.Equal(t, ActionMerge, action.Action)
data, err := os.ReadFile(path)
require.NoError(t, err)
text := string(data)
assert.True(t, strings.HasPrefix(text, existing),
"user content must remain at the top of the file")
assert.Contains(t, text, InstructionsSentinel,
"block must be appended below the user's content")
}
// TestAppendInstructions_SharedSentinelAcrossAdapters is the scenario
// that matters when two adapters target the same file (Codex and
// Opencode both write AGENTS.md). The second adapter must detect the
// first adapter's write via the shared InstructionsSentinel and skip,
// rather than duplicating the block. This is why the sentinel lives
// in the shared package, not each adapter.
func TestAppendInstructions_SharedSentinelAcrossAdapters(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "AGENTS.md")
// Simulate Codex writing first.
_, err := AppendInstructions(nil, path, InstructionsBody, InstructionsSentinel, ApplyOpts{})
require.NoError(t, err)
// Simulate Opencode running afterwards against the same repo.
action, err := AppendInstructions(nil, path, InstructionsBody, InstructionsSentinel, ApplyOpts{})
require.NoError(t, err)
assert.Equal(t, ActionSkip, action.Action,
"second adapter targeting the same file must skip, not append again")
}
// TestAppendInstructions_DryRunReportsAction verifies --dry-run never
// touches the filesystem and reports ActionWouldCreate / ActionWouldMerge
// correctly. Users rely on the planning output to preview what init
// will do; a silent write during dry-run would be a real footgun.
func TestAppendInstructions_DryRunReportsAction(t *testing.T) {
dir := t.TempDir()
newPath := filepath.Join(dir, "NEW.md")
existingPath := filepath.Join(dir, "EXISTING.md")
require.NoError(t, os.WriteFile(existingPath, []byte("preexisting\n"), 0o644))
action, err := AppendInstructions(nil, newPath, InstructionsBody, InstructionsSentinel, ApplyOpts{DryRun: true})
require.NoError(t, err)
assert.Equal(t, ActionWouldCreate, action.Action)
_, err = os.Stat(newPath)
assert.True(t, os.IsNotExist(err), "dry-run must not create the file")
action, err = AppendInstructions(nil, existingPath, InstructionsBody, InstructionsSentinel, ApplyOpts{DryRun: true})
require.NoError(t, err)
assert.Equal(t, ActionWouldMerge, action.Action)
data, _ := os.ReadFile(existingPath)
assert.Equal(t, "preexisting\n", string(data),
"dry-run must not mutate an existing file")
}
// TestCursorMDCFrontmatter proves the MDC wrapper emits the two keys
// Cursor needs — `description` so users can see the rule in the UI
// and `alwaysApply: true` so it attaches to every chat turn. Without
// alwaysApply Cursor gates rules on keyword heuristics and the
// Gortex-preference block would fire only sporadically.
func TestCursorMDCFrontmatter(t *testing.T) {
out := CursorMDCFrontmatter("BODY")
assert.True(t, strings.HasPrefix(out, "---\n"),
"MDC file must start with YAML frontmatter fence")
assert.Contains(t, out, "alwaysApply: true",
"MDC block must opt into always-apply so Cursor attaches it on every turn")
assert.Contains(t, out, "description:")
assert.Contains(t, out, "BODY")
}
// Single-home markers: content that must live in exactly one place. These
// strings anchor the reference blocks that were relocated OUT of the CLAUDE.md
// sections into the guide (provider matrix, analyze catalog) or the server
// instructions (wire-format deep-dive). Their absence from the slim bodies is
// the enforcement side of the single-home principle; their presence in the
// guide / server-instructions is asserted in the mcp package.
const (
providerMatrixMarker = "`local` / `anthropic` / `openai` / `azure` / `ollama` / `claudecli` / `codex` / `copilot` / `cursor` / `opencode` / `gemini` / `bedrock` / `deepseek`"
analyzeCatalogMarker = "Tarjan's SCC" // from the analyze `cycles` kind doc
formatDeepDiveMarker = "compact tabular text, lossy"
)
// TestInstructionsBody_PolicyCoreAndSingleHome smoke-tests the slim project
// rule block: it keeps the mandatory graph-tools mapping + the memory-workflow
// pointers, and it does NOT re-carry the relocated reference content.
func TestInstructionsBody_PolicyCoreAndSingleHome(t *testing.T) {
for _, token := range []string{
// Graph-tools policy core.
"search_symbols", "find_usages", "get_callers",
"get_symbol_source", "get_editing_context", "get_file_summary",
"read_file", "smart_context", "edit_file", "compress_bodies",
// Memory workflow (pointer form).
"distill_session", "surface_memories", "save_note", "store_memory",
"query_notes", "query_memories",
// Discovery pointers.
"tools_search", "gortex://guide",
} {
if !strings.Contains(InstructionsBody, token) {
t.Errorf("InstructionsBody no longer mentions %q — policy core regression", token)
}
}
for _, banned := range []string{providerMatrixMarker, analyzeCatalogMarker, formatDeepDiveMarker} {
if strings.Contains(InstructionsBody, banned) {
t.Errorf("InstructionsBody re-carries relocated content %q — single-home violation", banned)
}
}
}
// TestGlobalPointerBody_ShapeAndSentinel locks in the thin pointer
// block `gortex install` writes into ~/.claude/CLAUDE.md: it must keep
// the mandatory-rule heading (the cross-adapter idempotency sentinel),
// @-include the active profile copy from the given directory, name the
// switch verb with its next-session caveat, and stay tiny — the full
// policy body lives in the profile file, not here.
func TestGlobalPointerBody_ShapeAndSentinel(t *testing.T) {
const dir = "/home/user/.gortex/instructions"
body := GlobalPointerBody(dir)
if !strings.Contains(body, InstructionsSentinel) {
t.Error("pointer block lost the idempotency sentinel heading")
}
if !strings.Contains(body, "@"+dir+"/active.md") {
t.Errorf("pointer block does not @-include the active profile: %q", body)
}
if !strings.Contains(body, "gortex instructions switch") {
t.Error("pointer block lost the switch verb")
}
if !strings.Contains(body, "NEW sessions only") {
t.Error("pointer block lost the next-session caveat")
}
if strings.Contains(body, "@~") {
t.Error("pointer block must embed a resolved path, not a ~ shorthand")
}
const pointerByteCeiling = 512
if len(body) > pointerByteCeiling {
t.Errorf("pointer block is %d bytes, over the %d ceiling — the body belongs in the profile file", len(body), pointerByteCeiling)
}
}
+28
View File
@@ -0,0 +1,28 @@
// Package internalutil holds helpers shared across agent adapters.
// Kept separate from `agents` itself so adapters don't create
// import cycles by depending on logging helpers that in turn import
// something agent-specific.
package internalutil
import (
"fmt"
"io"
)
// Logf writes a progress line to w when w is non-nil. Adapter
// packages use this for consistency — every "[gortex init] …"
// message goes through the same helper.
func Logf(w io.Writer, format string, args ...any) {
if w == nil {
return
}
fmt.Fprintf(w, format+"\n", args...)
}
// Warnf writes a "[gortex init] warning: …" prefixed line.
func Warnf(w io.Writer, format string, args ...any) {
if w == nil {
return
}
fmt.Fprintf(w, "[gortex init] warning: "+format+"\n", args...)
}
+176
View File
@@ -0,0 +1,176 @@
// Package kilocode implements the Gortex init integration for the
// Kilo Code VS Code extension. Kilo Code stores MCP servers at the
// VS Code extension's globalStorage directory:
//
// macOS/Linux: ~/.config/Code/User/globalStorage/kilocode.kilo/mcp_settings.json
// (variants exist under Library/Application Support on macOS)
//
// The schema is the canonical {"mcpServers": {<name>: {...}}}
// plus an `alwaysAllow` list that mirrors the Cline pattern.
// Project-level config is also supported at .kilocode/mcp.json.
//
// Docs: https://kilo.ai/docs/features/mcp/using-mcp-in-kilo-code
package kilocode
import (
"os"
"path/filepath"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/internalutil"
)
const Name = "kilocode"
const DocsURL = "https://kilo.ai/docs/features/mcp/using-mcp-in-kilo-code"
type Adapter struct{}
func New() *Adapter { return &Adapter{} }
func (a *Adapter) Name() string { return Name }
func (a *Adapter) DocsURL() string { return DocsURL }
// alwaysAllow is Kilo Code's auto-approve list, matching the Cline
// equivalent — Kilo Code is a Cline fork and uses the same field.
var alwaysAllow = []string{
"graph_stats", "search_symbols", "winnow_symbols", "get_symbol", "get_file_summary",
"get_editing_context", "get_dependencies", "get_dependents",
"get_call_chain", "get_callers", "find_implementations", "find_usages",
"get_cluster", "get_symbol_source", "batch_symbols",
"find_import_path", "explain_change_impact", "get_recent_changes",
"smart_context", "get_edit_plan", "get_test_targets", "suggest_pattern",
"get_communities", "get_processes",
"detect_changes", "index_repository", "reindex_repository",
"verify_change", "check_guards", "prefetch_context",
"analyze", "diff_context", "index_health", "get_symbol_history",
"scaffold", "batch_edit", "contracts", "feedback",
"flow_between", "taint_paths",
}
// globalStoragePaths returns candidate paths for Kilo Code's MCP
// settings across VS Code install flavours (VS Code stable,
// VS Code Insiders, VSCodium, Cursor). We write to every candidate
// whose parent globalStorage directory exists.
func globalStoragePaths(home string) []string {
bases := []string{
filepath.Join(home, "Library", "Application Support", "Code", "User", "globalStorage", "kilocode.kilo", "settings"),
filepath.Join(home, ".config", "Code", "User", "globalStorage", "kilocode.kilo", "settings"),
filepath.Join(home, "Library", "Application Support", "Cursor", "User", "globalStorage", "kilocode.kilo", "settings"),
filepath.Join(home, ".config", "Cursor", "User", "globalStorage", "kilocode.kilo", "settings"),
}
paths := make([]string, 0, len(bases)+1)
for _, b := range bases {
paths = append(paths, filepath.Join(b, "mcp_settings.json"))
}
return paths
}
func (a *Adapter) Detect(env agents.Env) (bool, error) {
// Project-level hint: .kilocode/ in the repo.
if _, err := os.Stat(filepath.Join(env.Root, ".kilocode")); err == nil {
return true, nil
}
if env.Home == "" {
return false, nil
}
for _, p := range globalStoragePaths(env.Home) {
if _, err := os.Stat(filepath.Dir(p)); err == nil {
return true, nil
}
}
return false, nil
}
func (a *Adapter) Plan(env agents.Env) (*agents.Plan, error) {
p := &agents.Plan{}
// Project-level .kilocode/mcp.json when we're in project mode.
if env.Mode != agents.ModeGlobal {
p.Files = append(p.Files, agents.FileAction{
Path: filepath.Join(env.Root, ".kilocode", "mcp.json"),
Action: agents.ActionWouldMerge,
Keys: []string{"mcpServers"},
})
if env.SkillsRouting != "" {
p.Files = append(p.Files, agents.FileAction{
Path: filepath.Join(env.Root, ".kilocoderules"),
Action: agents.ActionWouldMerge,
Keys: []string{"communities-block"},
})
}
}
if env.Home != "" {
for _, path := range globalStoragePaths(env.Home) {
if _, err := os.Stat(filepath.Dir(path)); err == nil {
p.Files = append(p.Files, agents.FileAction{
Path: path,
Action: agents.ActionWouldMerge,
Keys: []string{"mcpServers"},
})
}
}
}
return p, nil
}
func (a *Adapter) Apply(env agents.Env, opts agents.ApplyOpts) (*agents.Result, error) {
res := &agents.Result{Name: Name, DocsURL: DocsURL}
detected, _ := a.Detect(env)
res.Detected = detected
if !detected && !opts.ForceDetect {
internalutil.Logf(env.Stderr, "[gortex init] skip Kilo Code setup (kilo-code not detected)")
return res, nil
}
internalutil.Logf(env.Stderr, "[gortex init] setting up Kilo Code integration...")
entry := agents.DefaultGortexMCPEntry()
entry["alwaysAllow"] = alwaysAllow
merge := func(root map[string]any, _ bool) (bool, error) {
return agents.UpsertMCPServer(root, "gortex", entry, opts), nil
}
// Project-level first, if a .kilocode/ dir already exists.
if env.Mode != agents.ModeGlobal {
projectPath := filepath.Join(env.Root, ".kilocode", "mcp.json")
if _, err := os.Stat(filepath.Join(env.Root, ".kilocode")); err == nil {
action, err := agents.MergeJSON(env.Stderr, projectPath, merge, opts)
if err != nil {
return res, err
}
res.Files = append(res.Files, action)
}
}
// Global-storage paths — write to every one whose parent exists.
if env.Home != "" {
for _, path := range globalStoragePaths(env.Home) {
if _, err := os.Stat(filepath.Dir(path)); err != nil {
continue
}
action, err := agents.MergeJSON(env.Stderr, path, merge, opts)
if err != nil {
internalutil.Warnf(env.Stderr, "could not configure Kilo Code at %s: %v", path, err)
continue
}
res.Files = append(res.Files, action)
}
}
// .kilocoderules is the project-scoped instructions file Kilo
// Code reads on every task (inherited from the Cline lineage).
// Write a marker-guarded community-routing block when skills
// were generated. Skipped in global mode and when --no-skills /
// no communities qualify.
if env.Mode != agents.ModeGlobal && env.SkillsRouting != "" {
rulesPath := filepath.Join(env.Root, ".kilocoderules")
routingAction, err := agents.UpsertMarkedBlock(env.Stderr, rulesPath, env.SkillsRouting,
agents.CommunitiesStartMarker, agents.CommunitiesEndMarker, opts)
if err != nil {
internalutil.Warnf(env.Stderr, "could not update %s: %v", rulesPath, err)
} else {
res.Files = append(res.Files, routingAction)
}
}
res.Configured = len(res.Files) > 0
return res, nil
}
+318
View File
@@ -0,0 +1,318 @@
// Package kimi implements the Gortex init/install integration for Kimi Code
// CLI. Kimi keeps MCP server declarations in mcp.json and user-level lifecycle
// hooks in config.toml:
//
// - project MCP: .kimi-code/mcp.json
// - user MCP: $KIMI_CODE_HOME/mcp.json, defaulting to ~/.kimi-code/mcp.json
// - user hooks: $KIMI_CODE_HOME/config.toml, defaulting to ~/.kimi-code/config.toml
//
// Kimi's hooks are user-level today, so project-mode `gortex init` only writes
// the repo-local MCP file. Machine-wide `gortex install` writes user MCP and,
// when hooks are enabled, UserPromptSubmit / PreToolUse / Stop / SubagentStart
// hooks that shell `gortex hook --agent=kimi` for pre-turn context injection,
// graph-aware tool redirects, post-turn self-correction, and subagent briefing.
//
// Docs: https://www.kimi.com/code/docs/en/kimi-code-cli/customization/hooks.html
package kimi
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/internalutil"
)
const Name = "kimi"
const DocsURL = "https://www.kimi.com/code/docs/en/kimi-code-cli/customization/hooks.html"
// Per-event hook timeouts (Kimi allows 1600s). UserPromptSubmit and
// PreToolUse sit on the turn's critical path, so they stay snappy; the Stop and
// SubagentStart briefings fan out to several graph tools and run off the hot
// path, so they get more headroom before Kimi's fail-open kill.
const (
kimiHookTimeoutSeconds = 5
kimiStopHookTimeoutSeconds = 30
kimiSubagentHookTimeoutSeconds = 15
)
type Adapter struct{}
func New() *Adapter { return &Adapter{} }
func (a *Adapter) Name() string { return Name }
func (a *Adapter) DocsURL() string { return DocsURL }
// Detect checks for the kimi CLI on PATH or an existing Kimi Code home.
func (a *Adapter) Detect(env agents.Env) (bool, error) {
if p, err := exec.LookPath("kimi"); err == nil && p != "" {
return true, nil
}
if home := kimiConfigRoot(env); home != "" {
if _, err := os.Stat(home); err == nil {
return true, nil
}
}
return false, nil
}
func (a *Adapter) Plan(env agents.Env) (*agents.Plan, error) {
p := &agents.Plan{}
if env.Mode == agents.ModeGlobal {
home := kimiConfigRoot(env)
if home == "" {
return p, nil
}
p.Files = append(p.Files, agents.FileAction{
Path: filepath.Join(home, "mcp.json"),
Action: agents.ActionWouldMerge,
Keys: []string{"mcpServers"},
})
if env.InstallHooks {
p.Files = append(p.Files, agents.FileAction{
Path: filepath.Join(home, "config.toml"),
Action: agents.ActionWouldMerge,
Keys: []string{"hooks"},
})
}
return p, nil
}
p.Files = append(p.Files, agents.FileAction{
Path: projectMCPPath(env),
Action: agents.ActionWouldMerge,
Keys: []string{"mcpServers"},
})
return p, nil
}
func (a *Adapter) Apply(env agents.Env, opts agents.ApplyOpts) (*agents.Result, error) {
res := &agents.Result{Name: Name, DocsURL: DocsURL}
detected, _ := a.Detect(env)
res.Detected = detected
if !detected && !opts.ForceDetect {
internalutil.Logf(env.Stderr, "[gortex init] skip Kimi Code CLI setup (kimi not detected)")
return res, nil
}
if env.Mode == agents.ModeGlobal {
return a.applyGlobal(env, opts, res)
}
return a.applyProject(env, opts, res)
}
func (a *Adapter) applyProject(env agents.Env, opts agents.ApplyOpts, res *agents.Result) (*agents.Result, error) {
if env.Root == "" {
return res, fmt.Errorf("kimi: requires a repository root")
}
internalutil.Logf(env.Stderr, "[gortex init] setting up Kimi Code CLI project MCP integration...")
action, err := agents.MergeJSON(env.Stderr, projectMCPPath(env), func(root map[string]any, _ bool) (bool, error) {
return agents.UpsertMCPServer(root, "gortex", agents.DefaultGortexMCPEntry(), opts), nil
}, opts)
if err != nil {
return res, err
}
res.Files = append(res.Files, action)
res.Configured = true
return res, nil
}
func (a *Adapter) applyGlobal(env agents.Env, opts agents.ApplyOpts, res *agents.Result) (*agents.Result, error) {
home := kimiConfigRoot(env)
if home == "" {
return res, fmt.Errorf("kimi: requires KIMI_CODE_HOME or a resolved home directory")
}
internalutil.Logf(env.Stderr, "[gortex install] setting up Kimi Code CLI user integration...")
mcpAction, err := agents.MergeJSON(env.Stderr, filepath.Join(home, "mcp.json"), func(root map[string]any, _ bool) (bool, error) {
return agents.UpsertMCPServer(root, "gortex", agents.DefaultGortexMCPEntry(), opts), nil
}, opts)
if err != nil {
return res, err
}
res.Files = append(res.Files, mcpAction)
if env.InstallHooks {
hookAction, err := agents.MergeTOML(env.Stderr, filepath.Join(home, "config.toml"), func(root map[string]any, _ bool) (bool, error) {
return upsertKimiHooks(root, env, opts), nil
}, opts)
if err != nil {
return res, err
}
if hookAction.Action != agents.ActionSkip {
hookAction.Keys = []string{"hooks"}
}
res.Files = append(res.Files, hookAction)
}
res.Configured = true
return res, nil
}
func projectMCPPath(env agents.Env) string {
return filepath.Join(env.Root, ".kimi-code", "mcp.json")
}
func kimiConfigRoot(env agents.Env) string {
if home := strings.TrimSpace(os.Getenv("KIMI_CODE_HOME")); home != "" {
return home
}
if env.Home == "" {
return ""
}
return filepath.Join(env.Home, ".kimi-code")
}
func upsertKimiHooks(root map[string]any, env agents.Env, opts agents.ApplyOpts) bool {
existing, ok := kimiHookList(root["hooks"])
if !ok {
return false
}
found := make(map[string]bool)
kept := make([]any, 0, len(existing)+2)
for _, entry := range existing {
if event, ok := kimiHookEntryInvokesKimi(entry); ok {
found[event] = true
if opts.Force {
continue
}
}
kept = append(kept, entry)
}
changed := false
for _, hook := range kimiHooks(env) {
event, _ := hook["event"].(string)
if found[event] && !opts.Force {
continue
}
kept = append(kept, hook)
changed = true
}
if !changed {
return false
}
root["hooks"] = kept
return true
}
func kimiHookList(v any) ([]any, bool) {
if v == nil {
return nil, true
}
switch list := v.(type) {
case []any:
return append([]any(nil), list...), true
case []map[string]any:
out := make([]any, 0, len(list))
for _, entry := range list {
out = append(out, entry)
}
return out, true
default:
return nil, false
}
}
func kimiHooks(env agents.Env) []map[string]any {
return []map[string]any{
kimiUserPromptSubmitHook(env),
kimiPreToolUseHook(env),
kimiStopHook(env),
kimiSubagentStartHook(env),
}
}
func kimiUserPromptSubmitHook(env agents.Env) map[string]any {
return map[string]any{
"event": "UserPromptSubmit",
"command": kimiHookCommand(env),
"timeout": kimiHookTimeoutSeconds,
}
}
func kimiPreToolUseHook(env agents.Env) map[string]any {
// Do not set matcher here: Kimi currently exposes MCP tool names as the
// underlying tool name without a documented server namespace, and the
// native Read/Grep/Glob redirects want to see every tool call. The
// dispatcher does the filtering and silently ignores anything it doesn't
// enrich.
return map[string]any{
"event": "PreToolUse",
"command": kimiHookCommand(env),
"timeout": kimiHookTimeoutSeconds,
}
}
// kimiStopHook runs the post-turn diagnostics (changed symbols → test targets,
// guards, dead code, coverage, contracts) and feeds them back so the agent can
// self-correct before handoff.
func kimiStopHook(env agents.Env) map[string]any {
return map[string]any{
"event": "Stop",
"command": kimiHookCommand(env),
"timeout": kimiStopHookTimeoutSeconds,
}
}
// kimiSubagentStartHook briefs a spawned subagent with task-scoped graph
// context and the tool-swap table so it doesn't default to raw Read/Grep.
func kimiSubagentStartHook(env agents.Env) map[string]any {
return map[string]any{
"event": "SubagentStart",
"command": kimiHookCommand(env),
"timeout": kimiSubagentHookTimeoutSeconds,
}
}
func kimiHookCommand(env agents.Env) string {
base := strings.TrimSpace(env.HookCommand)
if base == "" {
base = "gortex hook"
}
return base + " --agent=kimi"
}
func kimiHookEntryInvokesKimi(entry any) (string, bool) {
m, ok := entry.(map[string]any)
if !ok {
return "", false
}
event, _ := m["event"].(string)
switch event {
case "UserPromptSubmit", "PreToolUse", "Stop", "SubagentStart":
default:
return "", false
}
cmd, _ := m["command"].(string)
return event, kimiCommandInvokesKimiHook(cmd)
}
func kimiCommandInvokesKimiHook(cmd string) bool {
fields := strings.Fields(cmd)
if len(fields) < 3 {
return false
}
if !strings.Contains(strings.ToLower(fields[0]), "gortex") {
return false
}
hasHook := false
hasAgent := false
for i, f := range fields[1:] {
if f == "hook" {
hasHook = true
}
if f == "--agent=kimi" {
hasAgent = true
}
if f == "--agent" && i+2 < len(fields) && fields[i+2] == "kimi" {
hasAgent = true
}
}
return hasHook && hasAgent
}
+225
View File
@@ -0,0 +1,225 @@
package kimi
import (
"os"
"path/filepath"
"testing"
toml "github.com/pelletier/go-toml/v2"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/agentstest"
)
const testKimiHookCommand = "/tmp/test-gortex hook --agent=kimi"
func TestKimiProjectWritesOnlyProjectMCP(t *testing.T) {
env := kimiTestEnv(t)
a := New()
res, err := a.Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
agentstest.AssertCountsByAction(t, res, map[agents.ActionKind]int{agents.ActionCreate: 1})
cfg := agentstest.ReadJSON(t, projectMCPPath(env))
if _, ok := cfg["mcpServers"].(map[string]any)["gortex"]; !ok {
t.Fatalf("missing gortex MCP entry: %#v", cfg)
}
if _, err := os.Stat(filepath.Join(kimiConfigRoot(env), "config.toml")); !os.IsNotExist(err) {
t.Fatalf("project mode should not write user config.toml, stat err=%v", err)
}
agentstest.AssertIdempotent(t, a, env)
}
func TestKimiGlobalWritesMCPAndHooks(t *testing.T) {
env := kimiTestEnv(t)
env.Mode = agents.ModeGlobal
a := New()
res, err := a.Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
agentstest.AssertCountsByAction(t, res, map[agents.ActionKind]int{agents.ActionCreate: 2})
mcp := agentstest.ReadJSON(t, filepath.Join(kimiConfigRoot(env), "mcp.json"))
if _, ok := mcp["mcpServers"].(map[string]any)["gortex"]; !ok {
t.Fatalf("missing global gortex MCP entry: %#v", mcp)
}
hooks := readKimiHooks(t, env)
if len(hooks) != 4 {
t.Fatalf("hooks=%#v want 4", hooks)
}
assertKimiGortexHooks(t, hooks, 0)
agentstest.AssertIdempotent(t, a, env)
}
func TestKimiGlobalNoHooksWritesOnlyMCP(t *testing.T) {
env := kimiTestEnv(t)
env.Mode = agents.ModeGlobal
env.InstallHooks = false
a := New()
res, err := a.Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
agentstest.AssertCountsByAction(t, res, map[agents.ActionKind]int{agents.ActionCreate: 1})
if _, err := os.Stat(filepath.Join(kimiConfigRoot(env), "config.toml")); !os.IsNotExist(err) {
t.Fatalf("--no-hooks should not write config.toml, stat err=%v", err)
}
}
func TestKimiHookMergePreservesExistingHooks(t *testing.T) {
env := kimiTestEnv(t)
env.Mode = agents.ModeGlobal
path := filepath.Join(kimiConfigRoot(env), "config.toml")
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
seed := `default_model = "kimi-code/kimi-for-coding"
[[hooks]]
event = "Notification"
matcher = "task\\.completed"
command = "echo user"
timeout = 5
`
if err := os.WriteFile(path, []byte(seed), 0o644); err != nil {
t.Fatal(err)
}
res, err := New().Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
agentstest.AssertCountsByAction(t, res, map[agents.ActionKind]int{agents.ActionCreate: 1, agents.ActionMerge: 1})
cfg := readKimiConfig(t, env)
if cfg["default_model"] != "kimi-code/kimi-for-coding" {
t.Fatalf("default_model was not preserved: %#v", cfg)
}
hooks := readKimiHooks(t, env)
if len(hooks) != 5 {
t.Fatalf("hooks=%#v want user+gortex hooks", hooks)
}
if hooks[0]["event"] != "Notification" {
t.Fatalf("user hook was not preserved first: %#v", hooks)
}
assertKimiGortexHooks(t, hooks, 1)
}
func TestKimiForceReplacesOnlyGortexHook(t *testing.T) {
env := kimiTestEnv(t)
env.Mode = agents.ModeGlobal
path := filepath.Join(kimiConfigRoot(env), "config.toml")
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
seed := `[[hooks]]
event = "Notification"
command = "echo user"
[[hooks]]
event = "UserPromptSubmit"
command = "/tmp/old-gortex hook --agent=kimi"
timeout = 30
[[hooks]]
event = "PreToolUse"
command = "/tmp/old-gortex hook --agent=kimi"
timeout = 30
`
if err := os.WriteFile(path, []byte(seed), 0o644); err != nil {
t.Fatal(err)
}
if _, err := New().Apply(env, agents.ApplyOpts{Force: true}); err != nil {
t.Fatalf("apply force: %v", err)
}
hooks := readKimiHooks(t, env)
if len(hooks) != 5 {
t.Fatalf("hooks=%#v want user+current gortex hooks", hooks)
}
if hooks[0]["command"] != "echo user" {
t.Fatalf("user hook was not preserved: %#v", hooks)
}
assertKimiGortexHooks(t, hooks, 1)
}
func kimiTestEnv(t *testing.T) agents.Env {
t.Helper()
t.Setenv("KIMI_CODE_HOME", "")
env, _ := agentstest.NewEnv(t)
if err := os.MkdirAll(kimiConfigRoot(env), 0o755); err != nil {
t.Fatal(err)
}
return env
}
func readKimiConfig(t *testing.T, env agents.Env) map[string]any {
t.Helper()
data, err := os.ReadFile(filepath.Join(kimiConfigRoot(env), "config.toml"))
if err != nil {
t.Fatalf("read config.toml: %v", err)
}
var cfg map[string]any
if err := toml.Unmarshal(data, &cfg); err != nil {
t.Fatalf("parse config.toml: %v", err)
}
return cfg
}
func readKimiHooks(t *testing.T, env agents.Env) []map[string]any {
t.Helper()
cfg := readKimiConfig(t, env)
raw, ok := cfg["hooks"].([]map[string]any)
if ok {
return raw
}
anyList, ok := cfg["hooks"].([]any)
if !ok {
t.Fatalf("hooks has unexpected shape: %#v", cfg["hooks"])
}
out := make([]map[string]any, 0, len(anyList))
for _, item := range anyList {
m, ok := item.(map[string]any)
if !ok {
t.Fatalf("hook has unexpected shape: %#v", item)
}
out = append(out, m)
}
return out
}
func assertKimiHook(t *testing.T, hook map[string]any, event string, timeout int) {
t.Helper()
if hook["event"] != event {
t.Errorf("event=%v want %s", hook["event"], event)
}
if hook["command"] != testKimiHookCommand {
t.Errorf("command=%v want %q", hook["command"], testKimiHookCommand)
}
if hook["timeout"] != int64(timeout) {
t.Errorf("timeout=%v want %d", hook["timeout"], timeout)
}
if _, ok := hook["matcher"]; ok {
t.Errorf("%s hook should not write matcher: %#v", event, hook)
}
}
// assertKimiGortexHooks checks the four gortex-managed hooks appear, in order,
// starting at hooks[offset].
func assertKimiGortexHooks(t *testing.T, hooks []map[string]any, offset int) {
t.Helper()
assertKimiHook(t, hooks[offset+0], "UserPromptSubmit", kimiHookTimeoutSeconds)
assertKimiHook(t, hooks[offset+1], "PreToolUse", kimiHookTimeoutSeconds)
assertKimiHook(t, hooks[offset+2], "Stop", kimiStopHookTimeoutSeconds)
assertKimiHook(t, hooks[offset+3], "SubagentStart", kimiSubagentHookTimeoutSeconds)
}
+141
View File
@@ -0,0 +1,141 @@
package kiro
import (
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"github.com/zzet/gortex/internal/agents"
)
const Name = "kiro"
const DocsURL = "https://kiro.dev/docs/mcp"
type Adapter struct{}
func New() *Adapter { return &Adapter{} }
func (a *Adapter) Name() string { return Name }
func (a *Adapter) DocsURL() string { return DocsURL }
// Detect reports true when any of: the project already has .kiro/,
// the user has ~/.kiro, or "kiro" is on PATH. A single hit is
// enough — we'd rather over-provision than silently skip a user who
// happens to open the repo in Kiro later.
func (a *Adapter) Detect(env agents.Env) (bool, error) {
if _, err := os.Stat(filepath.Join(env.Root, ".kiro")); err == nil {
return true, nil
}
if env.Home != "" {
if _, err := os.Stat(filepath.Join(env.Home, ".kiro")); err == nil {
return true, nil
}
}
if p, err := exec.LookPath("kiro"); err == nil && p != "" {
return true, nil
}
return false, nil
}
func (a *Adapter) Plan(env agents.Env) (*agents.Plan, error) {
p := &agents.Plan{}
p.Files = append(p.Files, agents.FileAction{
Path: mcpConfigPath(env),
Action: agents.ActionWouldMerge,
Keys: []string{"mcpServers"},
})
// Steering docs and hooks only make sense per-project — Kiro's
// agent-hook engine only fires in the workspace that owns them.
if env.Mode == agents.ModeGlobal {
return p, nil
}
for name := range SteeringFiles {
p.Files = append(p.Files, agents.FileAction{
Path: filepath.Join(env.Root, ".kiro", "steering", name),
Action: agents.ActionWouldCreate,
})
}
for name := range HookFiles {
p.Files = append(p.Files, agents.FileAction{
Path: filepath.Join(env.Root, ".kiro", "hooks", name),
Action: agents.ActionWouldCreate,
})
}
return p, nil
}
func (a *Adapter) Apply(env agents.Env, opts agents.ApplyOpts) (*agents.Result, error) {
res := &agents.Result{Name: Name, DocsURL: DocsURL}
detected, _ := a.Detect(env)
res.Detected = detected
if !detected && !opts.ForceDetect {
logf(env.Stderr, "[gortex init] skip Kiro setup (Kiro not detected)")
return res, nil
}
logf(env.Stderr, "[gortex init] setting up Kiro IDE integration...")
// 1. MCP config with Kiro-specific extras (autoApprove list and
// an explicit disabled:false flag Kiro's UI expects). Kiro
// supports both workspace (.kiro/settings/mcp.json) and user
// (~/.kiro/settings/mcp.json) paths — the global mode writes
// to the user-level one so every project the user opens
// picks up Gortex automatically.
mcpPath := mcpConfigPath(env)
action, err := agents.MergeJSON(env.Stderr, mcpPath, func(root map[string]any, _ bool) (bool, error) {
entry := agents.DefaultGortexMCPEntry()
entry["disabled"] = false
entry["autoApprove"] = AutoApproveTools
return agents.UpsertMCPServer(root, "gortex", entry, opts), nil
}, opts)
if err != nil {
return res, fmt.Errorf("kiro mcp.json: %w", err)
}
res.Files = append(res.Files, action)
if env.Mode == agents.ModeGlobal {
// Steering docs and agent hooks are project-scoped and
// irrelevant at the user level. Stop here.
res.Configured = true
return res, nil
}
// 2. Steering docs — static, created if absent.
for name, content := range SteeringFiles {
action, err := agents.WriteIfNotExists(env.Stderr, filepath.Join(env.Root, ".kiro", "steering", name), content, opts)
if err != nil {
return res, err
}
res.Files = append(res.Files, action)
}
// 3. Agent hooks — static JSON, created if absent.
for name, content := range HookFiles {
action, err := agents.WriteIfNotExists(env.Stderr, filepath.Join(env.Root, ".kiro", "hooks", name), content, opts)
if err != nil {
return res, err
}
res.Files = append(res.Files, action)
}
res.Configured = true
return res, nil
}
// mcpConfigPath returns the mcp.json path for the given Env's
// mode. Workspace mode writes .kiro/settings/mcp.json; global mode
// writes ~/.kiro/settings/mcp.json. Kiro merges the two with
// workspace taking precedence.
func mcpConfigPath(env agents.Env) string {
if env.Mode == agents.ModeGlobal && env.Home != "" {
return filepath.Join(env.Home, ".kiro", "settings", "mcp.json")
}
return filepath.Join(env.Root, ".kiro", "settings", "mcp.json")
}
func logf(w io.Writer, format string, args ...any) {
if w == nil {
return
}
fmt.Fprintf(w, format+"\n", args...)
}
+49
View File
@@ -0,0 +1,49 @@
package kiro
import (
"os"
"path/filepath"
"testing"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/agentstest"
)
func TestKiroCreatesAllArtifactsAndIsIdempotent(t *testing.T) {
env, _ := agentstest.NewEnv(t)
// Sentinel: create .kiro/ so Detect returns true.
if err := os.MkdirAll(filepath.Join(env.Root, ".kiro"), 0o755); err != nil {
t.Fatal(err)
}
a := New()
res, err := a.Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
// 1 mcp.json + len(SteeringFiles) + len(HookFiles).
want := 1 + len(SteeringFiles) + len(HookFiles)
got := 0
for _, f := range res.Files {
if f.Action == agents.ActionCreate {
got++
}
}
if got != want {
t.Fatalf("expected %d creates, got %d (%v)", want, got, res.Files)
}
// autoApprove list is baked into the mcp.json entry — verify.
mcp := agentstest.ReadJSON(t, filepath.Join(env.Root, ".kiro", "settings", "mcp.json"))
servers := mcp["mcpServers"].(map[string]any)
gortex := servers["gortex"].(map[string]any)
approvals, ok := gortex["autoApprove"].([]any)
if !ok || len(approvals) == 0 {
t.Fatalf("autoApprove missing or empty: %v", gortex)
}
if gortex["disabled"] != false {
t.Fatalf("disabled should be false: %v", gortex)
}
agentstest.AssertIdempotent(t, a, env)
}
+359
View File
@@ -0,0 +1,359 @@
// Package kiro implements the Gortex init integration for Kiro
// IDE. Writes:
//
// - .kiro/settings/mcp.json (MCP server stanza, merged)
// - .kiro/steering/gortex-*.md (steering docs, static)
// - .kiro/hooks/gortex-*.json (agent hooks, static)
package kiro
// SteeringFiles maps filename → content under .kiro/steering/. The
// "workflow" doc has inclusion: always; the others are manual
// (surfaced only when the user asks for them).
var SteeringFiles = map[string]string{
"gortex-workflow.md": steeringWorkflow,
"gortex-explore.md": steeringExplore,
"gortex-debug.md": steeringDebug,
"gortex-impact.md": steeringImpact,
"gortex-refactor.md": steeringRefactor,
}
// HookFiles maps filename → content under .kiro/hooks/. Each file
// is a Kiro agent-hook definition (when+then JSON).
var HookFiles = map[string]string{
"gortex-smart-context.json": hookSmartContext,
"gortex-post-edit.json": hookPostEdit,
"gortex-pre-read.json": hookPreRead,
}
const steeringWorkflow = `---
inclusion: always
---
# Gortex Code Intelligence
Gortex is running as an MCP server. It indexes this repository into an in-memory knowledge graph and exposes tools for code navigation, impact analysis, and refactoring.
## MANDATORY: Use Gortex tools instead of file reads
You **MUST** prefer Gortex graph queries over file reads on every task in this repo. These are not suggestions — the tools below replace the corresponding read/grep flows. From a shell (no MCP tools), every tool below is reachable as ` + "`gortex call <tool> --arg k=v`" + ` (e.g. ` + "`gortex call read_file --arg path=<file>`" + `) — there is no bare ` + "`gortex <tool>`" + ` verb.
### Navigation and Reading
| Instead of... | Use... |
|---------------------------------------|------------------------------------------|
| Reading a whole file for one function | ` + "`get_symbol_source`" + ` with ` + "`id: \"path/to/file.go::SymbolName\"`" + ` (methods carry the receiver: ` + "`\"path/to/file.go::Recv.Name\"`" + `; 80% fewer tokens) — use ` + "`get_file_summary`" + ` first if you don't know the symbol name |
| Reading to find a function | ` + "`get_symbol`" + ` or ` + "`get_editing_context`" + ` |
| Multiple ` + "`get_symbol`" + ` calls | ` + "`batch_symbols`" + ` (one call for N symbols) |
| Searching for references | ` + "`find_usages`" + ` (zero false positives) |
| Searching to find a symbol by name | ` + "`search_symbols`" + ` (BM25 + camelCase) |
| Filtering ` + "`search_symbols`" + ` by hand | ` + "`winnow_symbols`" + ` — structured constraint chain (kind, language, community, path_prefix, min_fan_in, min_churn) with per-axis score contributions |
| Reading to understand a file | ` + "`get_file_summary`" + ` or ` + "`get_editing_context`" + ` |
| Reading multiple files to trace calls | ` + "`get_call_chain`" + ` / ` + "`get_callers`" + ` |
| Guessing an import path | ` + "`find_import_path`" + ` |
| Reading to check a function signature | ` + "`get_symbol_signature`" + ` |
| 5-10 calls to explore for a task | ` + "`smart_context`" + ` (one call) |
### Impact Analysis and Safety
| Instead of... | Use... |
|---------------------------------------|------------------------------------------|
| Reading files to assess change scope | ` + "`explain_change_impact`" + ` (includes cross-community warnings) |
| Guessing which tests to run | ` + "`get_test_targets`" + ` |
| Manual dependency ordering | ` + "`get_edit_plan`" + ` |
| Hoping signature changes are safe | ` + "`verify_change`" + ` — checks callers and interface implementors |
| Manually checking team conventions | ` + "`check_guards`" + ` — evaluates guard rules from .gortex.yaml |
| Wondering if a new dep creates a cycle| ` + "`analyze`" + ` with ` + "`kind: \"would_create_cycle\"`" + ` — checks before you add it |
### Structural Code Search
| Instead of... | Use... |
|------------------------------------------|------------------------------------------|
| Grep for an anti-pattern in this repo | ` + "`search_ast`" + ` with ` + "`detector: \"<name>\"`" + ` (` + "`error-not-wrapped`" + ` / ` + "`sql-string-concat`" + ` / ` + "`weak-crypto`" + ` / ` + "`panic-in-library`" + ` / ` + "`goroutine-without-recover`" + ` / ` + "`http-client-no-timeout`" + ` / ` + "`hardcoded-secret`" + ` / ` + "`empty-catch`" + ` / ` + "`java-string-equality`" + ` / ` + "`python-mutable-default-arg`" + `). Cross-language; matches enriched with enclosing ` + "`symbol_id`" + `. |
| Grep for a code shape | ` + "`search_ast`" + ` with ` + "`pattern: \"...\"`" + ` + ` + "`language`" + ` (tree-sitter S-expression; capture with ` + "`@name`" + `, anchor with ` + "`@match`" + `). |
| Scoping audit to important code | Pass ` + "`min_fan_in_of_enclosing_func: <N>`" + ` — drops matches in functions with fewer than N callers. |
### Diagnostics and Code Actions
| Instead of... | Use... |
|------------------------------------------|------------------------------------------|
| Polling for diagnostics after every edit | ` + "`subscribe_diagnostics`" + ` — opt into push ` + "`notifications/diagnostics`" + `. Initial state replays as ` + "`initial_replay: true`" + `; thereafter delta-changed files only. ` + "`min_severity`" + ` / ` + "`path_prefix`" + ` filters scope the stream. |
| Manual diagnostics fetch | ` + "`get_diagnostics`" + ` — last stored ` + "`publishDiagnostics`" + ` for a file; ` + "`wait`" + ` + ` + "`timeout_ms`" + ` block until the first publish. |
| Forgetting to opt out | ` + "`unsubscribe_diagnostics`" + ` — idempotent; auto-fires on session disconnect. |
| Hand-applying compiler suggestions | ` + "`get_code_actions`" + ` then ` + "`apply_code_action`" + ` (atomic temp+rename, both ` + "`changes`" + ` and ` + "`documentChanges`" + `). |
| Walking a file to apply every fix | ` + "`fix_all_in_file`" + ` — one-shot ` + "`source.fixAll`" + ` for the whole file. |
### Code Quality and Analysis
| Instead of... | Use... |
|---------------------------------------|------------------------------------------|
| Manually hunting unused code | ` + "`analyze`" + ` with ` + "`kind: \"dead_code\"`" + ` — zero incoming edges (excludes entry points, tests, exports) |
| Guessing which symbols are over-coupled| ` + "`analyze`" + ` with ` + "`kind: \"hotspots\"`" + ` — ranks by fan-in, fan-out, community crossings |
| Manually scanning for circular deps | ` + "`analyze`" + ` with ` + "`kind: \"cycles\"`" + ` — Tarjan's SCC with severity classification |
| Surveying K8s manifests in the repo | ` + "`analyze`" + ` with ` + "`kind: \"k8s_resources\"`" + ` — KindResource fan-out (depends_on / configures / mounts / exposes / uses_env); ` + "`k8s_kind`" + ` / ` + "`namespace`" + ` / ` + "`name`" + ` filters |
| Listing container images in use | ` + "`analyze`" + ` with ` + "`kind: \"images\"`" + ` — KindImage with consumer count (Dockerfile FROM + K8s ` + "`container.image`" + `); ` + "`role`" + ` / ` + "`ref`" + ` / ` + "`tag`" + ` filters |
| Mapping the Kustomize overlay tree | ` + "`analyze`" + ` with ` + "`kind: \"kustomize\"`" + ` — KindKustomization with base / resource fan-out; ` + "`dir`" + ` filter |
| Auditing what crosses repo boundaries | ` + "`analyze`" + ` with ` + "`kind: \"cross_repo\"`" + ` — calls / implements / extends edges crossing repo boundaries, grouped by source → target repo; ` + "`repo`" + ` / ` + "`base_kind`" + ` / ` + "`path_prefix`" + ` filters |
| Surveying dbt / SQLMesh models | ` + "`analyze`" + ` with ` + "`kind: \"dbt_models\"`" + ` — dbt / SQLMesh models, seeds, snapshots, sources with column count + lineage fan-in/out; ` + "`framework`" + ` / ` + "`type`" + ` / ` + "`materialized`" + ` / ` + "`name`" + ` filters |
| Checking if the index is stale | ` + "`index_health`" + ` — health score, parse failures, stale files |
| Wondering what changed this session | ` + "`get_symbol_history`" + ` — modification counts, flags churning (3+ edits) |
### Code Generation and Editing
| Instead of... | Use... |
|---------------------------------------|------------------------------------------|
| Reading files to learn a pattern | ` + "`suggest_pattern`" + ` |
| Manually scaffolding from a pattern | ` + "`scaffold`" + ` — generates code, wiring, and test stubs from an example |
| Sequencing multi-file edits yourself | ` + "`batch_edit`" + ` — applies edits in dependency order, re-indexes between steps |
| Reading a diff without graph context | ` + "`diff_context`" + ` — enriches git diff with callers, callees, community, risk |
| Guessing what context you need next | ` + "`prefetch_context`" + ` — predicts needed symbols from task + recent activity |
### Dataflow (CPG-lite)
| Instead of... | Use... |
|---------------------------------------|------------------------------------------|
| Hand-tracing a value through helpers | ` + "`flow_between(source_id, sink_id, max_depth=8)`" + ` — ranked dataflow paths over ` + "`value_flow`" + ` ` + "`arg_of`" + ` ` + "`returns_to`" + ` |
| Grepping for sources / sinks | ` + "`taint_paths(source_pattern, sink_pattern)`" + ` — pattern sweep. Patterns: bare = name substring; ` + "`exact:Foo`" + `; ` + "`path:dir/`" + `; ` + "`kind:method`" + `. Sinks auto-expand functions to params. |
### Clone Detection
| Instead of... | Use... |
|---------------------------------------|------------------------------------------|
| Eyeballing the repo for copy-paste | ` + "`find_clones`" + ` — near-duplicate function/method clusters from the ` + "`similar_to`" + ` graph layer (MinHash + LSH; catches renamed-variable clones) |
| Finding safe-to-delete duplicates | ` + "`find_clones`" + ` with ` + "`dead_only: true`" + ` — clusters containing a dead-code symbol ("dead duplicates of live code") |
### Multi-Repo Management
| Instead of... | Use... |
|---------------------------------------|------------------------------------------|
| Manually adding a repo to config | ` + "`track_repository`" + ` — indexes immediately, persists to config |
| Manually removing a repo from config | ` + "`untrack_repository`" + ` — evicts nodes/edges, persists to config |
| Refreshing the graph after edits | ` + "`reindex_repository`" + ` — incremental re-index of changed files only; pass ` + "`paths`" + ` to scope |
| Wondering which project is active | ` + "`get_active_project`" + ` — returns project name and repo list |
| Switching project context | ` + "`set_active_project`" + ` — re-scopes all subsequent queries |
| Scoping a query to one repo | Pass ` + "`repo`" + ` param to ` + "`search_symbols`" + `, ` + "`find_usages`" + `, etc. |
| Scoping a query to a project | Pass ` + "`project`" + ` param to any query tool |
| Filtering by reference tag | Pass ` + "`ref`" + ` param to any query tool |
### Session Memory (save_note / query_notes / distill_session)
Gortex remembers code; this triplet remembers **why you made a call**. Notes persist per-repo across daemon restarts and context compactions, are scoped to the session's workspace, and are auto-linked to symbols mentioned in the body.
| Trigger | Use |
|----------------------------------------------------------|--------------------------------------------------------------------------------|
| Session start in a touched repo (after a compaction or on a fresh run) | ` + "`distill_session`" + ` — returns top symbols, pinned notes, decisions, recent excerpts. Seed your mental model before reading any file. |
| Making a decision, rejecting an alternative, hitting a non-obvious constraint, committing to an invariant | ` + "`save_note tags:\"decision\" body:\"<what+why>\"`" + ` — mention symbol IDs (` + "`pkg/foo.go::Bar`" + `) in the body for auto-linking; pin (` + "`pinned:true`" + `) anything load-bearing. |
| Before editing a symbol you've touched before | ` + "`query_notes symbol_id:\"<id>\"`" + ` — surfaces prior decisions and warnings attached to that symbol. |
Save: decisions, non-obvious constraints, follow-ups, bug reproductions, surprising findings, partial-progress hand-offs. Skip: play-by-play of what you just did (the diff says it), patterns derivable from the graph, anything already in the steering docs. Canonical tags: ` + "`decision`" + `, ` + "`bug`" + `, ` + "`follow-up`" + `, ` + "`gotcha`" + `, ` + "`invariant`" + `.
### Development Memories (store_memory / query_memories / surface_memories)
` + "`save_note`" + ` is a **per-session scratchpad**; ` + "`store_memory`" + ` is the **workspace-wide durable knowledge base** — entries outlive sessions, agents, and teammates so every future agent in the workspace inherits them.
| Trigger | Use |
|----------------------------------------------------------|--------------------------------------------------------------------------------|
| Immediately after ` + "`smart_context`" + ` (every new task) | ` + "`surface_memories task:\"<task>\" symbol_ids:\"<top hits>\"`" + ` — memories ranked by anchor overlap, importance, pinning, recency. Each hit carries ` + "`match_reasons`" + `. |
| You discover a durable invariant / gotcha / decision worth teaching the team | ` + "`store_memory kind:\"<invariant|gotcha|convention|decision>\" body:\"<what+why>\" symbol_ids:\"<id>\" importance:5`" + ` — pin load-bearing memories. |
| A memory is no longer true | ` + "`store_memory body:\"<corrected>\" supersedes:\"<old-id>\"`" + ` — preserves audit trail; old memory hidden by default. |
Store: invariants, conventions, incident learnings, API contracts not enforced by types, debugging traps, cross-cutting decisions. Skip: anything derivable from code, session-local play-by-play (use ` + "`save_note`" + `), steering-doc content.
## Session workflow
1. Call ` + "`index_health`" + ` to confirm Gortex is running and indexed (` + "`graph_stats`" + ` for counts). If ` + "`total_nodes`" + ` is 0, call ` + "`index_repository`" + ` with path ` + "`\".\"`" + `.
2. Call ` + "`distill_session`" + ` to recover prior session memory for this workspace.
3. In multi-repo mode, call ` + "`get_active_project`" + ` to check scope. Use ` + "`set_active_project`" + ` to switch if needed.
4. For a new task, call ` + "`smart_context`" + ` with the task description. Immediately after, call ` + "`surface_memories`" + ` with the same task description and the top symbol hits.
5. Before editing any file, call ` + "`get_editing_context`" + ` first. If you've touched the symbol before, also call ` + "`query_notes symbol_id:\"<id>\"`" + ` and ` + "`query_memories symbol_id:\"<id>\"`" + `.
6. Before changing a function signature, call ` + "`verify_change`" + ` to catch contract violations — checks callers across all repos.
7. Before any refactor, call ` + "`get_edit_plan`" + ` for dependency-ordered file list. Use ` + "`batch_edit`" + ` to apply atomically.
8. Verify with the project's real build/test. Reserve ` + "`check_guards`" + ` for guard-relevant changes and ` + "`get_test_targets`" + ` to find the tests covering a substantive change (includes cross-repo test files).
9. After making a meaningful decision or hitting a non-obvious constraint, call ` + "`save_note`" + ` so the next session can recover it. If the discovery is workspace-wide and worth teaching the team, call ` + "`store_memory`" + ` instead — that compounds across sessions.
10. Before committing, call ` + "`detect_changes`" + ` to verify scope. Use ` + "`diff_context`" + ` for graph-enriched review.
`
const steeringExplore = `---
inclusion: manual
---
# Exploring Codebases with Gortex
## Workflow
1. ` + "`index_health`" + ` — confirm the index is ready (` + "`graph_stats`" + ` for node/edge counts)
2. ` + "`get_communities`" + ` — see functional clusters (architecture overview)
3. ` + "`search_symbols({query: \"<concept>\"})`" + ` — find symbols related to a concept
4. ` + "`get_processes`" + ` — discover execution flows
5. ` + "`get_process({id: \"<process-id>\"})`" + ` — trace a specific flow step by step
6. ` + "`get_editing_context({path: \"<file>\"})`" + ` — deep dive on a specific file
## When to use
- "How does authentication work?"
- "What's the project structure?"
- "Show me the main components"
- Understanding code you haven't seen before
## Key tools
- ` + "`get_communities`" + ` for architectural overview (functional clusters with cohesion scores)
- ` + "`get_processes`" + ` for execution flow discovery (entry points to call chains)
- ` + "`search_symbols`" + ` for concept-based symbol search (BM25 + camelCase-aware)
- ` + "`get_editing_context`" + ` for 360-degree file view (symbols, callers, callees, imports)
`
const steeringDebug = `---
inclusion: manual
---
# Debugging with Gortex
## Workflow
1. ` + "`search_symbols({query: \"<error or suspect>\"})`" + ` — find related symbols
2. ` + "`get_callers({id: \"<suspect>\"})`" + ` — who calls it?
3. ` + "`get_call_chain({id: \"<suspect>\"})`" + ` — what does it call?
4. ` + "`get_editing_context({path: \"<file>\"})`" + ` — full file context
5. ` + "`get_process({id: \"<process>\"})`" + ` — trace execution flow
## Debugging patterns
| Symptom | Gortex Approach |
| -------------------- | --------------- |
| Error message | ` + "`search_symbols`" + ` for error-related names, then ` + "`get_callers`" + ` on throw sites |
| Wrong return value | ` + "`get_call_chain`" + ` on the function, trace callees for data flow |
| Intermittent failure | ` + "`get_editing_context`" + `, look for external calls and async deps |
| Performance issue | ` + "`find_usages`" + `, find symbols with many callers (hot paths) |
| Recent regression | ` + "`detect_changes`" + `, see what your changes affect |
`
const steeringImpact = `---
inclusion: manual
---
# Impact Analysis with Gortex
## Workflow
1. ` + "`search_symbols({query: \"X\"})`" + ` — find the symbol ID
2. ` + "`explain_change_impact({ids: \"<id1>, <id2>\"})`" + ` — risk-tiered blast radius
3. ` + "`get_dependents({id: \"<symbol-id>\", depth: 3})`" + ` — detailed dependent tree
4. ` + "`detect_changes({scope: \"staged\"})`" + ` — pre-commit check
## Risk tiers
| Depth | Risk Level | Meaning |
| ----- | -------------- | ------------------------ |
| d=1 | WILL BREAK | Direct callers/importers |
| d=2 | LIKELY AFFECTED| Indirect dependencies |
| d=3 | MAY NEED TESTING| Transitive effects |
## Before any non-trivial change
- Call ` + "`explain_change_impact`" + ` with all symbols you plan to modify
- Review the risk level (LOW/MEDIUM/HIGH/CRITICAL)
- Check ` + "`by_depth`" + `: d=1 items WILL BREAK
- Note ` + "`affected_processes`" + ` and ` + "`affected_communities`" + `
- Check ` + "`test_files`" + ` that need re-running
- Before commit: ` + "`detect_changes`" + ` to verify scope
`
const steeringRefactor = `---
inclusion: manual
---
# Refactoring with Gortex
## Workflow
1. ` + "`search_symbols({query: \"X\"})`" + ` — find the symbol ID
2. ` + "`explain_change_impact({ids: \"<id>\"})`" + ` — map blast radius
3. ` + "`get_editing_context({path: \"<file>\"})`" + ` — see all symbols and relationships
4. ` + "`find_usages({id: \"<id>\"})`" + ` — every reference to change
5. ` + "`get_edit_plan({ids: \"<ids>\"})`" + ` — dependency-ordered edit sequence
6. Edit in order: interfaces -> implementations -> callers -> tests
7. ` + "`detect_changes({scope: \"all\"})`" + ` — verify after changes
## Rename symbol
- ` + "`find_usages`" + ` to get every reference location
- ` + "`explain_change_impact`" + ` to assess blast radius
- Edit in dependency order: definition, then callers, then tests
## Extract module
- ` + "`get_editing_context`" + ` on the source file to see all symbols
- ` + "`get_dependents`" + ` on symbols to extract to find external callers
- ` + "`find_import_path`" + ` for correct import paths in the new location
## Split function/service
- ` + "`get_call_chain`" + ` to understand all callees
- ` + "`get_callers`" + ` to map all call sites that need updating
- ` + "`explain_change_impact`" + ` for full blast radius
`
const hookSmartContext = `{
"name": "Gortex: Smart Context on Prompt",
"version": "1.0.0",
"description": "On each new prompt, calls smart_context to assemble task-relevant code context from the knowledge graph in one shot.",
"when": {
"type": "promptSubmit"
},
"then": {
"type": "askAgent",
"prompt": "If the user's message describes a coding task (adding a feature, fixing a bug, refactoring, understanding code), call Gortex's smart_context tool with the task description to get relevant symbols, source code, relationships, and an edit plan in one call. Skip this for non-coding questions or simple chat."
}
}
`
const hookPostEdit = `{
"name": "Gortex: Post-Edit Impact Check",
"version": "1.0.0",
"description": "After saving a source file, runs detect_changes and get_test_targets to show blast radius and which tests to run.",
"when": {
"type": "fileEdited",
"patterns": ["**/*.go", "**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx", "**/*.py", "**/*.rs", "**/*.java", "**/*.kt", "**/*.scala", "**/*.swift", "**/*.rb", "**/*.cs", "**/*.php"]
},
"then": {
"type": "askAgent",
"prompt": "A source file was just edited. Call Gortex detect_changes with scope unstaged to see which symbols were affected and the risk level. If symbols were changed, also call get_test_targets with those symbol IDs to identify which tests should be run. Briefly report the risk level and test commands."
}
}
`
const hookPreRead = `{
"name": "Gortex: Enrich File Reads",
"version": "1.0.0",
"description": "Before reading a source file, calls get_editing_context to inject symbol context, callers, callees, and imports.",
"when": {
"type": "preToolUse",
"toolTypes": ["read"]
},
"then": {
"type": "askAgent",
"prompt": "SKIP this hook entirely (do nothing, proceed with the read) if ANY of these are true: (1) the file path contains .kiro/, .claude/, .github/, .vscode/, or node_modules/, (2) the file extension is .md, .json, .yaml, .yml, .toml, .txt, .lock, .sum, .mod, .env, .gitignore, .html, .css, or .svg, (3) the file is not a source code file. ONLY for source code files (.go, .ts, .tsx, .js, .jsx, .py, .rs, .java, .kt, .cs, .rb, .php, .swift, .scala, .c, .cpp, .h): call get_editing_context or get_file_summary for the file to get symbol context before reading it."
}
}
`
// AutoApproveTools is the list of Gortex MCP tools Kiro should
// auto-approve without prompting. Baked into the mcp.json entry so
// the user isn't interrupted for every query call.
var AutoApproveTools = []string{
"graph_stats", "search_symbols", "winnow_symbols", "get_symbol", "get_file_summary",
"get_editing_context", "get_dependencies", "get_dependents",
"get_call_chain", "get_callers", "find_implementations", "find_usages",
"get_cluster", "get_symbol_source", "batch_symbols",
"find_import_path", "explain_change_impact", "get_recent_changes",
"smart_context", "get_edit_plan", "get_test_targets", "suggest_pattern",
"get_communities", "get_processes",
"detect_changes", "index_repository", "reindex_repository",
"verify_change", "check_guards", "prefetch_context",
"analyze",
"diff_context", "index_health", "get_symbol_history",
"scaffold", "batch_edit",
"contracts", "feedback",
"save_note", "query_notes", "distill_session",
"store_memory", "query_memories", "surface_memories",
}
+319
View File
@@ -0,0 +1,319 @@
package agents
import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
)
// UpsertMCPServer merges a gortex-flavored MCP server stanza into a
// map that follows the standard {"mcpServers": {<name>: {...}}}
// shape used by Claude Code, Cursor, VS Code, Continue.dev, Cline,
// and Kiro. Returns true when the map was modified (false when a
// gortex stanza was already present and opts.Force is off).
//
// serverName is the key under mcpServers (canonically "gortex").
// entry is the stanza value — adapters produce their own variant
// when the target client uses a different shape (e.g. Cline's
// alwaysAllow list, Kiro's autoApprove list).
func UpsertMCPServer(root map[string]any, serverName string, entry map[string]any, opts ApplyOpts) (changed bool) {
servers, ok := root["mcpServers"].(map[string]any)
if !ok {
servers = make(map[string]any)
}
if _, exists := servers[serverName]; exists && !opts.Force {
return false
}
servers[serverName] = entry
root["mcpServers"] = servers
return true
}
// RemoveMCPServer deletes serverName from the {"mcpServers": {...}}
// map, pruning the parent key when removal leaves it empty. Returns
// true when the map was modified. Counterpart to UpsertMCPServer for
// uninstall paths — all other servers (and the user's other config)
// are left untouched.
func RemoveMCPServer(root map[string]any, serverName string) (changed bool) {
servers, ok := root["mcpServers"].(map[string]any)
if !ok {
return false
}
if _, exists := servers[serverName]; !exists {
return false
}
delete(servers, serverName)
if len(servers) == 0 {
delete(root, "mcpServers")
} else {
root["mcpServers"] = servers
}
return true
}
// UpsertMCPServerWithMigration is like UpsertMCPServer but also
// rewrites entries that look Gortex-authored (any `gortex mcp ...`
// stanza) even without opts.Force. This lets `gortex install` swap
// the legacy `mcp --index . --watch` shape — which fails Cursor's
// global-cwd handshake — for the daemon-proxy shape on next install.
//
// If the existing entry is already byte-identical to `entry`, returns
// false so the caller reports "already-configured" instead of a noisy
// rewrite. User-customized entries (anything that doesn't look like
// Gortex authored it) are left untouched unless opts.Force is set.
func UpsertMCPServerWithMigration(root map[string]any, serverName string, entry map[string]any, opts ApplyOpts) (changed bool) {
servers, ok := root["mcpServers"].(map[string]any)
if !ok {
servers = make(map[string]any)
}
if existing, exists := servers[serverName]; exists {
if MCPEntriesEqual(existing, entry) {
return false
}
if !opts.Force && !IsGortexAuthoredMCPEntry(existing) {
return false
}
}
servers[serverName] = entry
root["mcpServers"] = servers
return true
}
// IsGortexAuthoredMCPEntry returns true for MCP server stanzas that
// look like Gortex wrote them — a command naming the gortex binary
// (bare "gortex" or an absolute path whose basename is gortex) and an
// args list starting with the `mcp` subcommand. Used by global-mode
// installers to migrate their own legacy stanzas — including the older
// absolute-path form — without clobbering user-customized servers.
func IsGortexAuthoredMCPEntry(entry any) bool {
m, ok := entry.(map[string]any)
if !ok {
return false
}
cmd, _ := m["command"].(string)
if !commandIsGortex(cmd) {
return false
}
args, ok := m["args"].([]any)
if !ok || len(args) == 0 {
return false
}
first, _ := args[0].(string)
return first == "mcp"
}
// commandIsGortex reports whether an MCP stanza's command string names
// the gortex binary — either the bare "gortex"/"gortex.exe" name or an
// absolute path whose basename is gortex (the legacy os.Executable()
// form a pre-fix `gortex install` baked into ~/.claude.json). Lets the
// global installer recognize and migrate its own older stanza in place
// instead of leaving a stale absolute path that disagrees with the
// bare-`gortex` project .mcp.json template.
func commandIsGortex(cmd string) bool {
return gortexCommandBase(cmd) == "gortex"
}
// gortexCommandBase extracts the binary base name from a command
// string, tolerating both / and \ separators so a path written on one
// OS is still recognized when parsed on another (matters for
// cross-platform tests; in production the path is always native). The
// trailing extension (.exe on Windows) is stripped.
func gortexCommandBase(cmd string) string {
if cmd == "" {
return ""
}
base := cmd
if i := strings.LastIndexAny(base, `/\`); i >= 0 {
base = base[i+1:]
}
return strings.TrimSuffix(base, filepath.Ext(base))
}
// ResolveGortexCommand returns the command string an installer should
// bake into a gortex MCP server stanza. It prefers the bare "gortex"
// name — portable across machines and byte-identical to the project
// .mcp.json template — but only when "gortex" on PATH resolves to the
// same binary that is currently running. Matching the project template
// matters for Claude Code specifically: it stores OAuth tokens per
// endpoint (command + args), so a user-scope entry that disagrees with
// a project-scope entry trips its "conflicting scopes" diagnostic.
//
// When the running binary is not reachable on PATH (e.g. a Windows
// install whose program directory is not on PATH) it falls back to the
// absolute os.Executable() path so the entry still launches. Under
// `go run` (a transient temp build) or any other ambiguity it falls
// back to the bare name rather than bake a path that won't exist later.
func ResolveGortexCommand() string {
exe, exeErr := os.Executable()
lp, lpErr := exec.LookPath("gortex")
return resolveGortexCommandFrom(exe, exeErr, lp, lpErr, sameFile)
}
// resolveGortexCommandFrom is the pure decision core of
// ResolveGortexCommand, split out so the PATH/executable inputs can be
// injected in tests.
func resolveGortexCommandFrom(exe string, exeErr error, lookPath string, lookErr error, same func(a, b string) bool) string {
exeUsable := exeErr == nil && exe != "" && gortexCommandBase(exe) == "gortex" && !isUnderTempDir(exe)
if lookErr == nil && lookPath != "" {
// On PATH: collapse to the bare name when it points at the
// binary we are running (or we cannot trust os.Executable),
// so the entry matches the portable project template.
if !exeUsable || same(lookPath, exe) {
return "gortex"
}
}
if exeUsable {
// Not on PATH, but we know exactly where we live: pin the
// absolute path so the entry launches.
return exe
}
return "gortex"
}
// ResolveGortexHookBinary returns the gortex binary reference a Claude
// Code hook command should embed. It shares ResolveGortexCommand's
// same-file decision core so the hook process and the MCP server/daemon
// never resolve to different binaries — a side-by-side install (CI
// sandbox, a downloaded release run before linking, a dev build) would
// otherwise bake the running binary into the MCP stanza but a stray
// PATH gortex into the hook, and the hook could then consult a
// different daemon than the one serving the session's graph tools.
//
// It differs from ResolveGortexCommand only in output preference: a
// hook fires in a shell whose PATH may not match install time, so it
// pins an absolute path whenever the running binary is usable rather
// than the bare name. The returned value is a bare binary reference
// with no subcommand — callers append " hook" (and any --mode suffix).
func ResolveGortexHookBinary() string {
exe, exeErr := os.Executable()
lp, lpErr := exec.LookPath("gortex")
return resolveGortexHookBinaryFrom(exe, exeErr, lp, lpErr, sameFile)
}
// resolveGortexHookBinaryFrom is the pure decision core of
// ResolveGortexHookBinary, split out so the PATH/executable inputs can
// be injected in tests. It resolves to the same on-disk binary as
// resolveGortexCommandFrom in every case where the running binary is
// usable (both pin the running exe, or the bare name that PATH resolves
// back to it); it only prefers an absolute path over the bare name.
//
// The ephemeral guard is deliberately on the running binary alone
// (exeUsable's !isUnderTempDir(exe)): a `go run` transient build must
// never be baked into a long-lived hook, so we fall back to the PATH
// gortex — the same file the MCP stanza collapses to — instead. A
// PATH lookup that itself points into a temp dir is the user's own
// PATH and is left to resolve, matching resolveGortexCommandFrom.
func resolveGortexHookBinaryFrom(exe string, exeErr error, lookPath string, lookErr error, same func(a, b string) bool) string {
exeUsable := exeErr == nil && exe != "" && gortexCommandBase(exe) == "gortex" && !isUnderTempDir(exe)
if exeUsable {
// The running binary is a stable, non-ephemeral gortex: pin it.
// This is the same file the MCP stanza launches — whether that
// stanza used the bare name (PATH gortex IS this binary) or the
// absolute exe (off-PATH, or a different gortex on PATH).
return exe
}
if lookErr == nil && lookPath != "" {
// Running binary isn't a bakeable path (go run temp build,
// unstattable, or not named gortex). Fall back to PATH's gortex
// — the file the MCP stanza collapses to — as an absolute path.
return lookPath
}
return "gortex"
}
// isUnderTempDir reports whether p lives under the OS temp directory —
// the tell-tale of a `go run` / `go test` transient build that must not
// be baked into a long-lived config.
func isUnderTempDir(p string) bool {
return strings.HasPrefix(p, filepath.Clean(os.TempDir())+string(os.PathSeparator))
}
// sameFile reports whether two paths reference the same on-disk binary,
// resolving symlinks and falling back to os.SameFile. A pure string
// match short-circuits the stat calls.
func sameFile(a, b string) bool {
if a == b {
return true
}
if ra, err := filepath.EvalSymlinks(a); err == nil {
if rb, err := filepath.EvalSymlinks(b); err == nil && ra == rb {
return true
}
}
fa, e1 := os.Stat(a)
fb, e2 := os.Stat(b)
return e1 == nil && e2 == nil && os.SameFile(fa, fb)
}
// MCPEntriesEqual compares two MCP stanzas by their JSON-marshaled
// form. Round-tripping is the simplest way to handle the []string vs
// []any drift between freshly-built entries and entries decoded from
// an existing mcp.json on disk. Exported so the doctor can compare an
// on-disk stanza against DefaultGortexMCPEntry() to flag a stale entry.
func MCPEntriesEqual(a, b any) bool {
aj, err := json.Marshal(a)
if err != nil {
return false
}
bj, err := json.Marshal(b)
if err != nil {
return false
}
var av, bv any
if err := json.Unmarshal(aj, &av); err != nil {
return false
}
if err := json.Unmarshal(bj, &bv); err != nil {
return false
}
return reflect.DeepEqual(av, bv)
}
// DefaultGortexMCPEntry returns the shared {command, args, env}
// stanza most clients accept for project-local MCP configs (where
// the editor launches the process with cwd set to the project root).
// Adapters that want extra keys wrap this and add them (e.g. Cline's
// alwaysAllow, Kiro's autoApprove).
//
// The command intentionally points at the bare "gortex" binary on
// PATH rather than os.Executable() — users who installed via
// Homebrew or `go install` get a stable path, and installers that
// run `go build -o /tmp/...` don't bake the transient path into
// long-lived configs.
// gortexMCPArgs is the one canonical args list every adapter emits.
// Both project and global configs use it: the daemon resolves the active
// workspace per session from the request handshake, so no cwd-relative
// flag (--index/--watch) and no proxy flag (--proxy) is needed. `gortex
// mcp` proxies to (and auto-starts) the daemon and falls back to an
// embedded server on its own.
func gortexMCPArgs() []string { return []string{"mcp"} }
func DefaultGortexMCPEntry() map[string]any {
return map[string]any{
"command": "gortex",
"args": gortexMCPArgs(),
"env": map[string]string{"GORTEX_INDEX_WORKERS": "8"},
}
}
// GlobalGortexMCPEntry returns the daemon-proxy MCP entry suitable
// for user-level (global) configs, where the editor may launch the
// MCP process with cwd set to the user's home directory rather than
// any open project — Cursor's global-mcp behaviour reported in
// gortexhq/gortex#19.
//
// The proxy shape carries no cwd-relative state: the daemon resolves
// the active workspace per session from the request handshake, so
// the global config never trips the strict entry-point check on the
// home directory. If no daemon is running, `gortex mcp --proxy` exits
// with a clear "no daemon" error rather than silently falling back
// to a broken indexer.
// GlobalGortexMCPEntry is now identical to DefaultGortexMCPEntry — the
// canonical `["mcp"]` shape carries no cwd-relative or proxy flag. Kept
// as a named function so existing call sites compile.
func GlobalGortexMCPEntry() map[string]any {
return DefaultGortexMCPEntry()
}
+47
View File
@@ -0,0 +1,47 @@
package agents
import "testing"
// TestMCPArgsConvergeOnMcp asserts the shared MCP entries emit the single
// canonical ["mcp"] args shape (no --index/--watch/--proxy).
func TestMCPArgsConvergeOnMcp(t *testing.T) {
for name, entry := range map[string]map[string]any{
"default": DefaultGortexMCPEntry(),
"global": GlobalGortexMCPEntry(),
} {
args, _ := entry["args"].([]string)
if len(args) != 1 || args[0] != "mcp" {
t.Errorf("%s entry args = %v, want [mcp]", name, args)
}
}
}
// TestUpsertMigratesLegacyShapes asserts the upsert rewrites every legacy
// on-disk args shape to the canonical ["mcp"] on first run, and is a no-op
// on the second run (idempotent, self-healing).
func TestUpsertMigratesLegacyShapes(t *testing.T) {
legacy := map[string][]any{
"index-watch": {"mcp", "--index", ".", "--watch"},
"proxy": {"mcp", "--proxy"},
}
for name, args := range legacy {
t.Run(name, func(t *testing.T) {
root := map[string]any{
"mcpServers": map[string]any{
"gortex": map[string]any{"command": "gortex", "args": args},
},
}
if changed := UpsertMCPServerWithMigration(root, "gortex", DefaultGortexMCPEntry(), ApplyOpts{}); !changed {
t.Fatal("first upsert should migrate a legacy stanza")
}
got := root["mcpServers"].(map[string]any)["gortex"].(map[string]any)["args"]
gotArgs, _ := got.([]string)
if len(gotArgs) != 1 || gotArgs[0] != "mcp" {
t.Fatalf("migrated args = %v, want [mcp]", got)
}
if changed := UpsertMCPServerWithMigration(root, "gortex", DefaultGortexMCPEntry(), ApplyOpts{}); changed {
t.Fatal("second upsert must be a no-op (already canonical)")
}
})
}
}
+358
View File
@@ -0,0 +1,358 @@
package agents
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"testing"
)
// TestGlobalGortexMCPEntryUsesProxy verifies that the user-level MCP
// entry is daemon-only — no cwd-relative `--index .` arg. This is the
// load-bearing property for gortexhq/gortex#19: Cursor launches the
// global config with cwd=$HOME, so any cwd-relative indexing would
// fail the entry-point handshake.
func TestGlobalGortexMCPEntryUsesProxy(t *testing.T) {
entry := GlobalGortexMCPEntry()
cmd, _ := entry["command"].(string)
if cmd != "gortex" {
t.Fatalf("command = %q, want %q", cmd, "gortex")
}
args, _ := entry["args"].([]string)
if len(args) == 0 || args[0] != "mcp" {
t.Fatalf("args[0] = %v, want mcp; full args=%v", args, args)
}
// The canonical shape is bare ["mcp"]: no cwd-relative --index/--watch
// (the daemon resolves the workspace per session) and no --proxy
// (gortex mcp proxies to / auto-starts the daemon on its own).
if len(args) != 1 || args[0] != "mcp" {
t.Errorf("global entry must emit canonical [mcp], got %v", args)
}
}
// TestIsGortexAuthoredMCPEntry checks the heuristic used by
// UpsertMCPServerWithMigration to decide which existing stanzas are
// safe to overwrite during a global-install migration.
func TestIsGortexAuthoredMCPEntry(t *testing.T) {
cases := []struct {
name string
raw string
want bool
}{
{
name: "legacy --index . shape (the bug from issue 19)",
raw: `{"command":"gortex","args":["mcp","--index",".","--watch"]}`,
want: true,
},
{
name: "current --proxy shape",
raw: `{"command":"gortex","args":["mcp","--proxy"]}`,
want: true,
},
{
name: "user wrapper that shells gortex through a script",
raw: `{"command":"/usr/local/bin/wrap-gortex.sh","args":["mcp"]}`,
want: false,
},
{
name: "user's own MCP server, not gortex at all",
raw: `{"command":"other-tool","args":["serve"]}`,
want: false,
},
{
name: "gortex command but a different subcommand",
raw: `{"command":"gortex","args":["daemon","start"]}`,
want: false,
},
{
name: "empty args list",
raw: `{"command":"gortex","args":[]}`,
want: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var v any
if err := json.Unmarshal([]byte(tc.raw), &v); err != nil {
t.Fatalf("unmarshal: %v", err)
}
got := IsGortexAuthoredMCPEntry(v)
if got != tc.want {
t.Errorf("IsGortexAuthoredMCPEntry(%s) = %v, want %v", tc.raw, got, tc.want)
}
})
}
}
// TestUpsertMCPServerWithMigrationReplacesLegacy exercises the
// migration path: a user with an old global config containing
// `--index . --watch` should get rewritten to the proxy entry on next
// `gortex install`, even though opts.Force is off.
func TestUpsertMCPServerWithMigrationReplacesLegacy(t *testing.T) {
root := map[string]any{
"mcpServers": map[string]any{
"gortex": map[string]any{
"command": "gortex",
"args": []any{"mcp", "--index", ".", "--watch"},
"env": map[string]any{"GORTEX_INDEX_WORKERS": "8"},
},
},
}
want := GlobalGortexMCPEntry()
changed := UpsertMCPServerWithMigration(root, "gortex", want, ApplyOpts{})
if !changed {
t.Fatalf("expected migration to rewrite legacy entry, got changed=false")
}
servers, _ := root["mcpServers"].(map[string]any)
got, _ := servers["gortex"].(map[string]any)
if got["command"] != "gortex" {
t.Fatalf("command lost during migration: %v", got)
}
args, _ := got["args"].([]string)
for _, a := range args {
if a == "--index" {
t.Fatalf("migration left legacy --index in args: %v", got)
}
}
}
// TestUpsertMCPServerWithMigrationIdempotent verifies the second run
// is a no-op once the config is already in the desired shape — no
// spurious rewrite, no noisy "modified" log.
func TestUpsertMCPServerWithMigrationIdempotent(t *testing.T) {
// Use the on-disk shape (args as []any) to mirror what comes back
// out of json.Unmarshal on an existing mcp.json.
root := map[string]any{
"mcpServers": map[string]any{
"gortex": map[string]any{
"command": "gortex",
"args": []any{"mcp"},
"env": map[string]any{"GORTEX_INDEX_WORKERS": "8"},
},
},
}
if changed := UpsertMCPServerWithMigration(root, "gortex", GlobalGortexMCPEntry(), ApplyOpts{}); changed {
t.Errorf("expected no rewrite when entry already matches; got changed=true")
}
}
// TestUpsertMCPServerWithMigrationPreservesUserCustomization confirms
// the migration is fingerprint-gated: a user who hand-rolled their
// own gortex wrapper (non-`gortex` command, or a different subcommand)
// should not have their config silently overwritten.
func TestUpsertMCPServerWithMigrationPreservesUserCustomization(t *testing.T) {
root := map[string]any{
"mcpServers": map[string]any{
"gortex": map[string]any{
"command": "/opt/scripts/my-gortex.sh",
"args": []any{"mcp"},
},
},
}
if changed := UpsertMCPServerWithMigration(root, "gortex", GlobalGortexMCPEntry(), ApplyOpts{}); changed {
t.Errorf("user-customized entry was overwritten without --force")
}
// And with --force the same call should overwrite — that's the
// escape hatch users have when they want Gortex to take over.
if changed := UpsertMCPServerWithMigration(root, "gortex", GlobalGortexMCPEntry(), ApplyOpts{Force: true}); !changed {
t.Errorf("opts.Force must overwrite user-customized entry")
}
}
// TestIsGortexAuthoredMCPEntryAbsolutePath verifies the broadened
// detection: the legacy absolute-path command form a pre-fix `gortex
// install` baked into ~/.claude.json (via os.Executable()) is now
// recognized as Gortex-authored, so the next install can migrate it to
// the canonical bare-`gortex` shape. A user's own wrapper script is
// still left alone. Both / and \ separators are handled regardless of
// the host OS.
func TestIsGortexAuthoredMCPEntryAbsolutePath(t *testing.T) {
cases := []struct {
name string
raw string
want bool
}{
{
name: "unix absolute path to gortex (legacy os.Executable form)",
raw: `{"command":"/usr/local/bin/gortex","args":["mcp"]}`,
want: true,
},
{
name: "windows absolute path to gortex.exe (the issue #201 form)",
raw: `{"command":"C:\\Users\\daoti\\AppData\\Local\\Programs\\gortex\\gortex.exe","args":["mcp"]}`,
want: true,
},
{
name: "absolute path to a non-gortex binary",
raw: `{"command":"/usr/local/bin/something-else","args":["mcp"]}`,
want: false,
},
{
name: "user wrapper whose basename merely contains gortex",
raw: `{"command":"/opt/scripts/my-gortex.sh","args":["mcp"]}`,
want: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var v any
if err := json.Unmarshal([]byte(tc.raw), &v); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if got := IsGortexAuthoredMCPEntry(v); got != tc.want {
t.Errorf("IsGortexAuthoredMCPEntry(%s) = %v, want %v", tc.raw, got, tc.want)
}
})
}
}
// TestResolveGortexCommandFrom pins the command-resolution policy that
// keeps the user-scope ~/.claude.json entry in agreement with the
// project .mcp.json template (and so avoids Claude Code's "conflicting
// scopes" diagnostic) while staying robust when gortex isn't on PATH.
func TestResolveGortexCommandFrom(t *testing.T) {
sameTrue := func(a, b string) bool { return true }
sameFalse := func(a, b string) bool { return false }
notFound := errors.New("executable file not found in $PATH")
cases := []struct {
name string
exe string
exeErr error
lookPath string
lookErr error
same func(a, b string) bool
want string
}{
{
name: "on PATH and same binary -> bare gortex (matches project template)",
exe: "/usr/local/bin/gortex",
lookPath: "/usr/local/bin/gortex",
same: sameTrue,
want: "gortex",
},
{
name: "installed but not on PATH -> pin absolute path",
exe: `C:\Users\daoti\AppData\Local\Programs\gortex\gortex.exe`,
lookPath: "",
lookErr: notFound,
same: sameFalse,
want: `C:\Users\daoti\AppData\Local\Programs\gortex\gortex.exe`,
},
{
name: "on PATH but a different gortex -> pin the running binary, not the PATH one",
exe: "/opt/build/gortex",
lookPath: "/usr/local/bin/gortex",
same: sameFalse,
want: "/opt/build/gortex",
},
{
name: "go run transient temp build + gortex on PATH -> bare gortex",
exe: filepath.Join(os.TempDir(), "go-build1234", "agents.test"),
lookPath: "/usr/local/bin/gortex",
same: sameFalse,
want: "gortex",
},
{
name: "nothing resolvable -> bare gortex last resort",
exe: "",
exeErr: errors.New("os.Executable failed"),
lookErr: notFound,
same: sameFalse,
want: "gortex",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := resolveGortexCommandFrom(tc.exe, tc.exeErr, tc.lookPath, tc.lookErr, tc.same)
if got != tc.want {
t.Errorf("resolveGortexCommandFrom() = %q, want %q", got, tc.want)
}
})
}
}
// TestResolveGortexHookBinaryFrom mirrors TestResolveGortexCommandFrom's
// table for the hook resolver, and additionally asserts the load-bearing
// invariant: the hook binary and the MCP stanza launch the SAME on-disk
// file in every case. The hook only prefers an absolute path over the
// bare name (it fires in a shell whose PATH may differ from install
// time); it must never point at a different gortex than the MCP server.
func TestResolveGortexHookBinaryFrom(t *testing.T) {
sameTrue := func(a, b string) bool { return true }
sameFalse := func(a, b string) bool { return false }
notFound := errors.New("executable file not found in $PATH")
cases := []struct {
name string
exe string
exeErr error
lookPath string
lookErr error
same func(a, b string) bool
want string
}{
{
name: "on PATH and same binary -> pin absolute (hook avoids PATH at fire time)",
exe: "/usr/local/bin/gortex",
lookPath: "/usr/local/bin/gortex",
same: sameTrue,
want: "/usr/local/bin/gortex",
},
{
name: "installed but not on PATH -> pin absolute path",
exe: `C:\Users\daoti\AppData\Local\Programs\gortex\gortex.exe`,
lookPath: "",
lookErr: notFound,
same: sameFalse,
want: `C:\Users\daoti\AppData\Local\Programs\gortex\gortex.exe`,
},
{
name: "on PATH but a different gortex -> pin the running binary, not the PATH one",
exe: "/opt/build/gortex",
lookPath: "/usr/local/bin/gortex",
same: sameFalse,
want: "/opt/build/gortex",
},
{
name: "go run transient temp build + gortex on PATH -> PATH gortex, never the temp build",
exe: filepath.Join(os.TempDir(), "go-build1234", "agents.test"),
lookPath: "/usr/local/bin/gortex",
same: sameFalse,
want: "/usr/local/bin/gortex",
},
{
name: "nothing resolvable -> bare gortex last resort",
exe: "",
exeErr: errors.New("os.Executable failed"),
lookErr: notFound,
same: sameFalse,
want: "gortex",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := resolveGortexHookBinaryFrom(tc.exe, tc.exeErr, tc.lookPath, tc.lookErr, tc.same)
if got != tc.want {
t.Errorf("resolveGortexHookBinaryFrom() = %q, want %q", got, tc.want)
}
// Map each resolver output to the concrete file it launches:
// an absolute path is itself; the bare name resolves via PATH
// to lookPath. The two must never disagree.
launched := func(resolved string) string {
if resolved == "gortex" {
return tc.lookPath
}
return resolved
}
mcp := resolveGortexCommandFrom(tc.exe, tc.exeErr, tc.lookPath, tc.lookErr, tc.same)
if hf, mf := launched(got), launched(mcp); hf != mf {
t.Errorf("hook/MCP disagree on binary file: hook %q -> %q, mcp %q -> %q", got, hf, mcp, mf)
}
})
}
}
+96
View File
@@ -0,0 +1,96 @@
// Package ohmypi implements the Gortex init integration for
// Oh My Pi (omp). Oh My Pi stores project-level MCP servers
// at .omp/mcp.json using the canonical mcpServers schema:
//
// {
// "mcpServers": {
// "gortex": {
// "command": "gortex",
// "args": ["mcp"],
// "env": {"GORTEX_INDEX_WORKERS": "8"}
// }
// }
// }
//
// The MCP client name sent during the initialize handshake is
// "omp-coding-agent"; Gortex uses this to default the response
// format to GCX1.
package ohmypi
import (
"os"
"os/exec"
"path/filepath"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/internalutil"
)
const Name = "oh-my-pi"
const DocsURL = "https://github.com/can1357/oh-my-pi/blob/main/docs/mcp-config.md"
type Adapter struct{}
func New() *Adapter { return &Adapter{} }
func (a *Adapter) Name() string { return Name }
func (a *Adapter) DocsURL() string { return DocsURL }
func (a *Adapter) Detect(env agents.Env) (bool, error) {
if env.Mode == agents.ModeGlobal {
return false, nil
}
if _, err := os.Stat(filepath.Join(env.Root, ".omp")); err == nil {
return true, nil
}
if p, err := exec.LookPath("omp"); err == nil && p != "" {
return true, nil
}
return false, nil
}
func (a *Adapter) Plan(env agents.Env) (*agents.Plan, error) {
if env.Mode == agents.ModeGlobal {
return &agents.Plan{}, nil
}
return &agents.Plan{Files: []agents.FileAction{
{Path: filepath.Join(env.Root, ".omp", "mcp.json"), Action: agents.ActionWouldMerge, Keys: []string{"mcpServers"}},
}}, nil
}
func (a *Adapter) Apply(env agents.Env, opts agents.ApplyOpts) (*agents.Result, error) {
res := &agents.Result{Name: Name, DocsURL: DocsURL}
if env.Mode == agents.ModeGlobal {
return res, nil
}
detected, _ := a.Detect(env)
res.Detected = detected
if !detected && !opts.ForceDetect {
internalutil.Logf(env.Stderr, "[gortex init] skip Oh My Pi setup (oh-my-pi not detected)")
return res, nil
}
internalutil.Logf(env.Stderr, "[gortex init] setting up Oh My Pi integration...")
path := filepath.Join(env.Root, ".omp", "mcp.json")
action, err := agents.MergeJSON(env.Stderr, path, func(root map[string]any, _ bool) (bool, error) {
servers, ok := root["mcpServers"].(map[string]any)
if !ok {
servers = make(map[string]any)
}
if _, exists := servers["gortex"]; exists && !opts.Force {
return false, nil
}
servers["gortex"] = map[string]any{
"command": "gortex",
"args": []string{"mcp"},
"env": map[string]string{"GORTEX_INDEX_WORKERS": "8"},
}
root["mcpServers"] = servers
return true, nil
}, opts)
if err != nil {
return res, err
}
res.Files = append(res.Files, action)
res.Configured = true
return res, nil
}
+84
View File
@@ -0,0 +1,84 @@
package ohmypi
import (
"os"
"path/filepath"
"testing"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/agentstest"
)
func TestOhMyPiCreatesAndMerges(t *testing.T) {
env, _ := agentstest.NewEnv(t)
// Create sentinel .omp directory so Detect returns true.
if err := os.MkdirAll(filepath.Join(env.Root, ".omp"), 0o755); err != nil {
t.Fatal(err)
}
a := New()
// Apply should write the MCP config to .omp/mcp.json.
res, err := a.Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
if !res.Detected {
t.Fatal("expected Detected=true after creating .omp/")
}
if !res.Configured {
t.Fatal("expected Configured=true after apply")
}
agentstest.AssertCountsByAction(t, res, map[agents.ActionKind]int{agents.ActionCreate: 1})
mcp := agentstest.ReadJSON(t, filepath.Join(env.Root, ".omp", "mcp.json"))
servers := mcp["mcpServers"].(map[string]any)
if _, ok := servers["gortex"]; !ok {
t.Fatalf("gortex server missing: %v", servers)
}
// Assert idempotence on re-run.
agentstest.AssertIdempotent(t, a, env)
}
func TestOhMyPiGlobalIsNoOp(t *testing.T) {
env, _ := agentstest.NewEnv(t)
env.Mode = agents.ModeGlobal
// Even if .omp directory exists, Detect should return false in ModeGlobal.
if err := os.MkdirAll(filepath.Join(env.Root, ".omp"), 0o755); err != nil {
t.Fatal(err)
}
a := New()
detected, err := a.Detect(env)
if err != nil {
t.Fatalf("detect: %v", err)
}
if detected {
t.Fatal("expected Detect to return false in ModeGlobal")
}
// Plan should be empty.
plan, err := a.Plan(env)
if err != nil {
t.Fatalf("plan: %v", err)
}
if len(plan.Files) > 0 {
t.Fatalf("expected empty plan in ModeGlobal, got: %v", plan.Files)
}
// Apply should return early and not write files.
res, err := a.Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
if res.Configured {
t.Fatal("expected Configured=false in ModeGlobal")
}
if len(res.Files) > 0 {
t.Fatalf("expected no files touched in ModeGlobal, got: %v", res.Files)
}
}
+107
View File
@@ -0,0 +1,107 @@
// Package openclaw implements the Gortex init integration for
// OpenClaw. OpenClaw's config lives at ~/.openclaw/openclaw.json
// in JSON5 format; MCP servers go under the nested "mcp.servers"
// table:
//
// {
// "mcp": {
// "servers": {
// "gortex": {"command": "gortex", "args": [...], "env": {...}}
// }
// }
// }
//
// We emit plain JSON — OpenClaw's JSON5 parser accepts vanilla
// JSON, and writing strict JSON sidesteps dependency on a JSON5
// encoder. Users can hand-edit the file afterwards and JSON5
// features (comments, trailing commas) won't be stripped because
// we never read back; we always overwrite the gortex entry only.
//
// Docs: https://docs.openclaw.ai/gateway/configuration-reference.md
package openclaw
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/internalutil"
)
const Name = "openclaw"
const DocsURL = "https://docs.openclaw.ai/cli/mcp"
type Adapter struct{}
func New() *Adapter { return &Adapter{} }
func (a *Adapter) Name() string { return Name }
func (a *Adapter) DocsURL() string { return DocsURL }
func (a *Adapter) Detect(env agents.Env) (bool, error) {
if p, err := exec.LookPath("openclaw"); err == nil && p != "" {
return true, nil
}
if env.Home == "" {
return false, nil
}
if _, err := os.Stat(filepath.Join(env.Home, ".openclaw")); err == nil {
return true, nil
}
return false, nil
}
func (a *Adapter) Plan(env agents.Env) (*agents.Plan, error) {
if env.Home == "" {
return &agents.Plan{}, nil
}
return &agents.Plan{Files: []agents.FileAction{{
Path: filepath.Join(env.Home, ".openclaw", "openclaw.json"),
Action: agents.ActionWouldMerge,
Keys: []string{"mcp"},
}}}, nil
}
func (a *Adapter) Apply(env agents.Env, opts agents.ApplyOpts) (*agents.Result, error) {
res := &agents.Result{Name: Name, DocsURL: DocsURL}
detected, _ := a.Detect(env)
res.Detected = detected
if !detected && !opts.ForceDetect {
internalutil.Logf(env.Stderr, "[gortex init] skip OpenClaw setup (openclaw not detected)")
return res, nil
}
if env.Home == "" {
return res, fmt.Errorf("openclaw: requires a resolved home directory")
}
internalutil.Logf(env.Stderr, "[gortex init] setting up OpenClaw integration...")
path := filepath.Join(env.Home, ".openclaw", "openclaw.json")
action, err := agents.MergeJSON(env.Stderr, path, func(root map[string]any, _ bool) (bool, error) {
mcp, ok := root["mcp"].(map[string]any)
if !ok {
mcp = make(map[string]any)
}
servers, ok := mcp["servers"].(map[string]any)
if !ok {
servers = make(map[string]any)
}
if _, exists := servers["gortex"]; exists && !opts.Force {
return false, nil
}
servers["gortex"] = map[string]any{
"command": "gortex",
"args": []string{"mcp"},
"env": map[string]string{"GORTEX_INDEX_WORKERS": "8"},
}
mcp["servers"] = servers
root["mcp"] = mcp
return true, nil
}, opts)
if err != nil {
return res, err
}
res.Files = append(res.Files, action)
res.Configured = true
return res, nil
}
+155
View File
@@ -0,0 +1,155 @@
// Package opencode implements the Gortex init integration for
// OpenCode. OpenCode reads its project config from `opencode.json` or
// `opencode.jsonc` at the repo root (and `~/.config/opencode/` for the
// global config); it does NOT read `.opencode/config.json` — Gortex
// wrote there historically and OpenCode silently ignored it, so the MCP
// server was never registered. We now write the MCP stanza to a root
// `opencode.json` (or merge into an existing `opencode.json` /
// `opencode.jsonc`).
//
// OpenCode uses a different MCP schema than the canonical
// Claude / Cursor shape:
//
// {
// "$schema": "https://opencode.ai/config.json",
// "mcp": {
// "gortex": {
// "type": "local",
// "command": ["gortex", "mcp", ...],
// "environment": {...},
// "enabled": true
// }
// }
// }
//
// We preserve any existing $schema reference and add one if absent.
package opencode
import (
"os"
"os/exec"
"path/filepath"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/internalutil"
)
const Name = "opencode"
const DocsURL = "https://opencode.ai/docs/mcp"
const SchemaURL = "https://opencode.ai/config.json"
type Adapter struct{}
func New() *Adapter { return &Adapter{} }
func (a *Adapter) Name() string { return Name }
func (a *Adapter) DocsURL() string { return DocsURL }
func (a *Adapter) Detect(env agents.Env) (bool, error) {
if _, err := os.Stat(filepath.Join(env.Root, ".opencode")); err == nil {
return true, nil
}
if p, err := exec.LookPath("opencode"); err == nil && p != "" {
return true, nil
}
if env.Home != "" {
if _, err := os.Stat(filepath.Join(env.Home, ".config", "opencode")); err == nil {
return true, nil
}
}
return false, nil
}
// projectConfigPath resolves the file the MCP stanza should be written
// into. OpenCode reads `opencode.json` / `opencode.jsonc` from the repo
// root, so we merge into an existing one (preferring `.jsonc`, since a
// hand-authored, comment-bearing config keeps its extension) and
// otherwise default to a fresh `opencode.json`.
func projectConfigPath(root string) string {
for _, name := range []string{"opencode.jsonc", "opencode.json"} {
p := filepath.Join(root, name)
if _, err := os.Stat(p); err == nil {
return p
}
}
return filepath.Join(root, "opencode.json")
}
func (a *Adapter) Plan(env agents.Env) (*agents.Plan, error) {
p := &agents.Plan{Files: []agents.FileAction{
{Path: projectConfigPath(env.Root), Action: agents.ActionWouldMerge, Keys: []string{"mcp"}},
}}
if env.Mode != agents.ModeGlobal && env.SkillsRouting != "" {
p.Files = append(p.Files, agents.FileAction{
Path: filepath.Join(env.Root, "AGENTS.md"), Action: agents.ActionWouldMerge,
Keys: []string{"communities-block"},
})
}
return p, nil
}
func (a *Adapter) Apply(env agents.Env, opts agents.ApplyOpts) (*agents.Result, error) {
res := &agents.Result{Name: Name, DocsURL: DocsURL}
if env.Mode == agents.ModeGlobal {
return res, nil
}
detected, _ := a.Detect(env)
res.Detected = detected
if !detected && !opts.ForceDetect {
internalutil.Logf(env.Stderr, "[gortex init] skip OpenCode setup (OpenCode not detected)")
return res, nil
}
internalutil.Logf(env.Stderr, "[gortex init] setting up OpenCode integration...")
path := projectConfigPath(env.Root)
// Gortex used to write `.opencode/config.json`, which OpenCode does
// not read. If that stale file is still around, point the user at
// the config we're actually writing now.
if legacy := filepath.Join(env.Root, ".opencode", "config.json"); legacy != path {
if _, err := os.Stat(legacy); err == nil {
internalutil.Logf(env.Stderr, "[gortex init] note: %s is no longer read by OpenCode; writing the MCP config to %s instead", legacy, filepath.Base(path))
}
}
action, err := agents.MergeJSON(env.Stderr, path, func(root map[string]any, _ bool) (bool, error) {
mcpSection, ok := root["mcp"].(map[string]any)
if !ok {
mcpSection = make(map[string]any)
}
if _, exists := mcpSection["gortex"]; exists && !opts.Force {
return false, nil
}
mcpSection["gortex"] = map[string]any{
"type": "local",
"command": []string{"gortex", "mcp"},
"environment": map[string]string{
"GORTEX_INDEX_WORKERS": "8",
},
"enabled": true,
}
root["mcp"] = mcpSection
if _, hasSchema := root["$schema"]; !hasSchema {
root["$schema"] = SchemaURL
}
return true, nil
}, opts)
if err != nil {
return res, err
}
res.Files = append(res.Files, action)
// AGENTS.md gets a marker-guarded community-routing block when
// skills were generated (--skills, default on in `gortex init`).
// The codex adapter targets the same file with the same markers
// so a repo running both adapters converges on one block.
if env.SkillsRouting != "" {
agentsMdPath := filepath.Join(env.Root, "AGENTS.md")
routingAction, err := agents.UpsertMarkedBlock(env.Stderr, agentsMdPath, env.SkillsRouting,
agents.CommunitiesStartMarker, agents.CommunitiesEndMarker, opts)
if err != nil {
return res, err
}
res.Files = append(res.Files, routingAction)
}
res.Configured = true
return res, nil
}
+152
View File
@@ -0,0 +1,152 @@
package opencode
import (
"os"
"path/filepath"
"testing"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/agentstest"
)
// TestOpenCodeUsesMCPSectionKey verifies we write under "mcp",
// not "mcpServers" — OpenCode's schema differs from the canonical
// Claude / Cursor shape — and that the config lands in a root
// `opencode.json`, the file OpenCode actually reads (not the legacy
// `.opencode/config.json`).
func TestOpenCodeUsesMCPSectionKey(t *testing.T) {
env, _ := agentstest.NewEnv(t)
// `.opencode/` is OpenCode's detection sentinel (it holds agents,
// commands, skills) — present, but the MCP config does not live in it.
if err := os.MkdirAll(filepath.Join(env.Root, ".opencode"), 0o755); err != nil {
t.Fatal(err)
}
a := New()
res, err := a.Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
// Two creates: opencode.json for MCP plus AGENTS.md for the
// instructions block OpenCode reads on every task.
agentstest.AssertCountsByAction(t, res, map[agents.ActionKind]int{agents.ActionCreate: 2})
// The MCP config must be the root opencode.json, not the ignored
// .opencode/config.json.
if _, err := os.Stat(filepath.Join(env.Root, ".opencode", "config.json")); err == nil {
t.Fatalf("must not write the legacy .opencode/config.json (OpenCode ignores it)")
}
cfg := agentstest.ReadJSON(t, filepath.Join(env.Root, "opencode.json"))
if _, ok := cfg["mcpServers"]; ok {
t.Fatalf("should not write mcpServers (that's Claude/Cursor shape): %v", cfg)
}
mcp, ok := cfg["mcp"].(map[string]any)
if !ok {
t.Fatalf("missing 'mcp' section: %v", cfg)
}
gortex, ok := mcp["gortex"].(map[string]any)
if !ok {
t.Fatalf("missing 'mcp.gortex': %v", mcp)
}
// command must be an array (OpenCode-specific)
if _, ok := gortex["command"].([]any); !ok {
t.Fatalf("command should be an array: %v", gortex)
}
if cfg["$schema"] != SchemaURL {
t.Fatalf("expected $schema=%q, got %v", SchemaURL, cfg["$schema"])
}
agentstest.AssertIdempotent(t, a, env)
}
// TestOpenCodeMergesIntoExistingJSON verifies that when a project
// already has an opencode.json, we merge into it without clobbering
// the user's own keys, rather than creating a competing file.
func TestOpenCodeMergesIntoExistingJSON(t *testing.T) {
env, _ := agentstest.NewEnv(t)
cfgPath := filepath.Join(env.Root, "opencode.json")
agentstest.WriteJSON(t, cfgPath, map[string]any{
"$schema": SchemaURL,
"theme": "tokyonight",
"mcp": map[string]any{
"other": map[string]any{
"type": "local",
"command": []string{"other-server"},
"enabled": true,
},
},
})
a := New()
if _, err := a.Apply(env, agents.ApplyOpts{ForceDetect: true}); err != nil {
t.Fatalf("apply: %v", err)
}
cfg := agentstest.ReadJSON(t, cfgPath)
if cfg["theme"] != "tokyonight" {
t.Fatalf("merge clobbered user's top-level key: %v", cfg)
}
mcp, ok := cfg["mcp"].(map[string]any)
if !ok {
t.Fatalf("missing 'mcp' section: %v", cfg)
}
if _, ok := mcp["other"]; !ok {
t.Fatalf("merge clobbered existing 'other' server: %v", mcp)
}
if _, ok := mcp["gortex"]; !ok {
t.Fatalf("merge didn't add 'gortex': %v", mcp)
}
agentstest.AssertIdempotent(t, a, env)
}
// TestOpenCodeMergesIntoExistingJSONC verifies that an existing
// opencode.jsonc — comments and trailing commas included — is the file
// we merge into, the user's data survives, and no competing
// opencode.json is created.
func TestOpenCodeMergesIntoExistingJSONC(t *testing.T) {
env, _ := agentstest.NewEnv(t)
jsoncPath := filepath.Join(env.Root, "opencode.jsonc")
const jsonc = `{
// OpenCode project config
"$schema": "https://opencode.ai/config.json",
"theme": "tokyonight", /* dark theme */
"mcp": {
"other": { "type": "local", "command": ["other-server"], "enabled": true },
},
}`
if err := os.WriteFile(jsoncPath, []byte(jsonc), 0o644); err != nil {
t.Fatal(err)
}
a := New()
if _, err := a.Apply(env, agents.ApplyOpts{ForceDetect: true}); err != nil {
t.Fatalf("apply: %v", err)
}
// We must have merged into the .jsonc, not created a sibling .json.
if _, err := os.Stat(filepath.Join(env.Root, "opencode.json")); err == nil {
t.Fatalf("must not create opencode.json when opencode.jsonc already exists")
}
// A commented config is valid JSONC, not malformed — no backup.
if _, err := os.Stat(jsoncPath + ".bak"); err == nil {
t.Fatalf("a valid commented .jsonc must not be backed up as malformed")
}
cfg := agentstest.ReadJSON(t, jsoncPath)
if cfg["theme"] != "tokyonight" {
t.Fatalf("merge dropped user's data keys: %v", cfg)
}
mcp, ok := cfg["mcp"].(map[string]any)
if !ok {
t.Fatalf("missing 'mcp' section: %v", cfg)
}
if _, ok := mcp["other"]; !ok {
t.Fatalf("merge clobbered existing 'other' server: %v", mcp)
}
if _, ok := mcp["gortex"]; !ok {
t.Fatalf("merge didn't add 'gortex': %v", mcp)
}
agentstest.AssertIdempotent(t, a, env)
}
+223
View File
@@ -0,0 +1,223 @@
// Package pi implements the Gortex init integration for the Pi coding
// agent (earendil-works/pi, a.k.a. pi-mono).
//
// Pi is unlike every other adapter in this tree for two reasons:
//
// 1. Pi has no MCP support — by design. Instead we ship a TypeScript
// extension that registers Gortex's graph tools natively (each
// shelling `gortex call <tool>`) and re-creates the Claude-Code
// read-discipline enforcement by bridging Pi lifecycle events to
// `gortex hook --agent=pi`.
//
// 2. It is therefore has to ship executable code (the embedded
// `extension/index.ts`) and use `go:embed` in the agents tree.
// The extension carries four templated sentinels the adapter
// fills in at write time — see renderExtension.
//
// Read-discipline rules are injected by the extension at runtime, not
// written to an instructions file. AGENTS.md is touched only for the
// community-routing block, and only with --skills.
//
// File layout written by the adapter:
//
// ModeProject: <root>/.pi/extensions/gortex/index.ts (+ AGENTS.md routing block when --skills)
// ModeGlobal: <home>/.pi/agent/extensions/gortex/index.ts
package pi
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
_ "embed"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/internalutil"
)
const Name = "pi"
const DocsURL = "https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/extensions.md"
//go:embed extension/index.ts
var extensionSource string
// Templated sentinels in extension/index.ts. Each is replaced with a JSON
// literal so values containing spaces or backslashes survive intact.
const (
sentinelBin = "{{GORTEX_BIN}}"
sentinelArgv = "{{GORTEX_HOOK_ARGV}}"
sentinelEnforce = "{{GORTEX_ENFORCE}}"
sentinelToolsPreset = "{{GORTEX_TOOLS_PRESET}}"
)
// defaultToolsPreset is the eager tool preset baked into the extension. It
// mirrors the daemon's own default surface (corePresetTools, in defer
// mode); GORTEX_TOOLS in the environment overrides it at runtime, the same
// override the daemon honours. Kept a constant (not read from the
// environment at render time) so the rendered extension is deterministic.
const defaultToolsPreset = "core"
type Adapter struct{}
func New() *Adapter { return &Adapter{} }
func (a *Adapter) Name() string { return Name }
func (a *Adapter) DocsURL() string { return DocsURL }
// Detect reports whether Pi is in use: a project-local `.pi/` dir, a
// user-level `~/.pi/`, or the `pi` CLI on PATH.
func (a *Adapter) Detect(env agents.Env) (bool, error) {
if env.Mode == agents.ModeProject && env.Root != "" {
if _, err := os.Stat(filepath.Join(env.Root, ".pi")); err == nil {
return true, nil
}
}
if env.Home != "" {
if _, err := os.Stat(filepath.Join(env.Home, ".pi")); err == nil {
return true, nil
}
}
for _, bin := range []string{"pi", "pi-coding-agent"} {
if p, err := exec.LookPath(bin); err == nil && p != "" {
return true, nil
}
}
return false, nil
}
// extensionPath returns where the extension file lands for the given mode.
func extensionPath(env agents.Env) string {
if env.Mode == agents.ModeGlobal {
return filepath.Join(env.Home, ".pi", "agent", "extensions", "gortex", "index.ts")
}
return filepath.Join(env.Root, ".pi", "extensions", "gortex", "index.ts")
}
func (a *Adapter) Plan(env agents.Env) (*agents.Plan, error) {
p := &agents.Plan{}
p.Files = append(p.Files, agents.FileAction{
Path: extensionPath(env),
Action: agents.ActionWouldCreate,
})
// AGENTS.md gets the community-routing block, and only with skills
// (project mode). Read-discipline rules ride the extension, not this file.
if env.Mode != agents.ModeGlobal && env.Root != "" && env.SkillsRouting != "" {
p.Files = append(p.Files, agents.FileAction{
Path: filepath.Join(env.Root, "AGENTS.md"),
Action: agents.ActionWouldMerge, Keys: []string{"communities-block"},
})
}
return p, nil
}
func (a *Adapter) Apply(env agents.Env, opts agents.ApplyOpts) (*agents.Result, error) {
res := &agents.Result{Name: Name, DocsURL: DocsURL}
detected, _ := a.Detect(env)
res.Detected = detected
if !detected && !opts.ForceDetect {
internalutil.Logf(env.Stderr, "[gortex init] skip Pi setup (pi not detected)")
return res, nil
}
if env.Mode == agents.ModeGlobal && env.Home == "" {
return res, fmt.Errorf("pi: global mode requires a resolved home directory")
}
internalutil.Logf(env.Stderr, "[gortex init] setting up Pi integration...")
// 1. The extension (Gortex owns it end-to-end → overwrite on re-run).
extAction, err := agents.WriteOwnedFile(env.Stderr, extensionPath(env), renderExtension(env), opts)
if err != nil {
return res, err
}
res.Files = append(res.Files, extAction)
// 2. Community routing → AGENTS.md (project mode, skills enabled only).
// Read-discipline rules ride the extension, so AGENTS.md is left alone
// unless there's a routing block to merge.
if env.Mode != agents.ModeGlobal && env.Root != "" && env.SkillsRouting != "" {
agentsMd := filepath.Join(env.Root, "AGENTS.md")
routingAction, err := agents.UpsertMarkedBlock(env.Stderr, agentsMd, env.SkillsRouting,
agents.CommunitiesStartMarker, agents.CommunitiesEndMarker, opts)
if err != nil {
return res, err
}
res.Files = append(res.Files, routingAction)
}
res.Configured = true
return res, nil
}
// renderExtension fills the embedded TypeScript template with the resolved
// gortex binary path, the hook argv, and the enforcement flag.
func renderExtension(env agents.Env) string {
bin := resolveGortexBin(env)
argv := []string{bin, "hook", "--agent=pi"}
if mode := normalizeMode(env.HookMode); mode != "" && mode != "deny" {
argv = append(argv, "--mode="+mode)
}
src := extensionSource
src = substituteSentinel(src, sentinelBin, jsonString(bin))
src = substituteSentinel(src, sentinelArgv, jsonValue(argv))
src = substituteSentinel(src, sentinelEnforce, jsonValue(env.InstallHooks))
src = substituteSentinel(src, sentinelToolsPreset, jsonString(defaultToolsPreset))
return src
}
// resolveGortexBin prefers an explicit HookCommand binary, then a `gortex`
// on PATH, then the bare name. We only need the binary path here — the
// hook subcommand/args are appended by renderExtension.
func resolveGortexBin(env agents.Env) string {
if env.HookCommand != "" {
if fields := strings.Fields(env.HookCommand); len(fields) > 0 {
return fields[0]
}
}
if p, err := exec.LookPath("gortex"); err == nil && p != "" {
return p
}
return "gortex"
}
// normalizeMode mirrors the hook posture strings the daemon accepts; any
// unknown value (including empty) collapses to the deny default.
func normalizeMode(mode string) string {
switch strings.ToLower(strings.TrimSpace(mode)) {
case "enrich":
return "enrich"
case "consult-unlock":
return "consult-unlock"
case "nudge", "adaptive-nudge":
return "nudge"
default:
return "deny"
}
}
// substituteSentinel replaces a {{NAME}} placeholder with value, tolerant
// of inner whitespace. The embedded extension is plain TypeScript and may
// be run through a formatter (Prettier reflows `{{NAME}}` to `{{ NAME }}`),
// so an exact-string match would silently miss a formatted sentinel and
// ship an un-substituted template. The replacement is literal so a value
// containing `$` is not treated as a regexp expansion.
func substituteSentinel(src, sentinel, value string) string {
name := strings.TrimSuffix(strings.TrimPrefix(sentinel, "{{"), "}}")
re := regexp.MustCompile(`\{\{\s*` + regexp.QuoteMeta(name) + `\s*\}\}`)
return re.ReplaceAllLiteralString(src, value)
}
// jsonString / jsonValue emit JSON literals for templating into the TS
// source. jsonString is for a bare string; jsonValue for any value.
func jsonString(s string) string { return jsonValue(s) }
func jsonValue(v any) string {
b, err := json.Marshal(v)
if err != nil {
return "null"
}
return string(b)
}
+345
View File
@@ -0,0 +1,345 @@
package pi
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/agentstest"
)
// readExtension returns the rendered extension file content for the env.
func writeAndRead(t *testing.T, env agents.Env) string {
t.Helper()
a := New()
if _, err := a.Apply(env, agents.ApplyOpts{ForceDetect: true}); err != nil {
t.Fatalf("apply: %v", err)
}
data, err := os.ReadFile(extensionPath(env))
if err != nil {
t.Fatalf("read extension: %v", err)
}
return string(data)
}
func TestPiDetect(t *testing.T) {
t.Run("project .pi dir", func(t *testing.T) {
env, _ := agentstest.NewEnv(t)
// NewEnv's Home is a fresh temp dir with no .pi, so detection
// hinges on the project marker (PATH may or may not have pi).
if err := os.MkdirAll(filepath.Join(env.Root, ".pi"), 0o755); err != nil {
t.Fatal(err)
}
ok, err := New().Detect(env)
if err != nil || !ok {
t.Fatalf("expected detect=true with .pi/, got %v (err %v)", ok, err)
}
})
t.Run("home .pi dir", func(t *testing.T) {
env, _ := agentstest.NewEnv(t)
if err := os.MkdirAll(filepath.Join(env.Home, ".pi"), 0o755); err != nil {
t.Fatal(err)
}
ok, err := New().Detect(env)
if err != nil || !ok {
t.Fatalf("expected detect=true with ~/.pi/, got %v (err %v)", ok, err)
}
})
}
func TestPiApplyWritesExtensionAndRouting(t *testing.T) {
env, _ := agentstest.NewEnv(t) // NewEnv seeds SkillsRouting → routing block written.
if err := os.MkdirAll(filepath.Join(env.Root, ".pi"), 0o755); err != nil {
t.Fatal(err)
}
a := New()
res, err := a.Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
if !res.Detected || !res.Configured {
t.Fatalf("expected detected+configured, got %+v", res)
}
// Extension written.
ext := filepath.Join(env.Root, ".pi", "extensions", "gortex", "index.ts")
data, err := os.ReadFile(ext)
if err != nil {
t.Fatalf("extension not written: %v", err)
}
src := string(data)
// Sentinels must be fully substituted — no template left behind.
for _, sentinel := range []string{sentinelBin, sentinelArgv, sentinelEnforce, sentinelToolsPreset} {
if strings.Contains(src, sentinel) {
t.Errorf("unsubstituted sentinel %q remains in extension", sentinel)
}
}
// NewEnv sets InstallHooks=true → ENFORCE true.
if !strings.Contains(src, "const ENFORCE: boolean = true;") {
t.Errorf("expected ENFORCE true with InstallHooks=true")
}
// argv must carry the pi agent flag.
if !strings.Contains(src, `"--agent=pi"`) {
t.Errorf("expected --agent=pi in hook argv; got source without it")
}
// AGENTS.md carries the community-routing block — but NOT the
// read-discipline rules: those are injected by the extension at runtime,
// not persisted to an instructions file (mirrors opencode).
agentsMd, err := os.ReadFile(filepath.Join(env.Root, "AGENTS.md"))
if err != nil {
t.Fatalf("AGENTS.md not written: %v", err)
}
if !strings.Contains(string(agentsMd), agents.CommunitiesStartMarker) {
t.Errorf("AGENTS.md missing communities block")
}
if strings.Contains(string(agentsMd), agents.InstructionsSentinel) {
t.Errorf("AGENTS.md must NOT carry the read-discipline rules block — the extension injects them at runtime")
}
// Idempotent re-run.
agentstest.AssertIdempotent(t, a, env)
}
// Without skills routing, the adapter writes the extension only and never
// touches AGENTS.md — the read-discipline rules ride the extension's
// `context` hook, so there's nothing to persist to an instructions file.
func TestPiApplyNoSkillsLeavesAgentsMdUntouched(t *testing.T) {
env, _ := agentstest.NewEnv(t)
env.SkillsRouting = ""
if err := os.MkdirAll(filepath.Join(env.Root, ".pi"), 0o755); err != nil {
t.Fatal(err)
}
if _, err := New().Apply(env, agents.ApplyOpts{}); err != nil {
t.Fatalf("apply: %v", err)
}
if _, err := os.Stat(filepath.Join(env.Root, "AGENTS.md")); err == nil {
t.Errorf("AGENTS.md should not be written when skills routing is empty")
}
}
func TestPiNoHooksDisablesEnforcement(t *testing.T) {
env, _ := agentstest.NewEnv(t)
env.InstallHooks = false
if err := os.MkdirAll(filepath.Join(env.Root, ".pi"), 0o755); err != nil {
t.Fatal(err)
}
src := writeAndRead(t, env)
if !strings.Contains(src, "const ENFORCE: boolean = false;") {
t.Errorf("expected ENFORCE false with InstallHooks=false")
}
// Tools are still registered regardless of enforcement.
if !strings.Contains(src, "registerGortexTools") {
t.Errorf("tool registration should remain present with --no-hooks")
}
}
func TestPiEnrichModeAppendsModeFlag(t *testing.T) {
env, _ := agentstest.NewEnv(t)
env.HookMode = "enrich"
if err := os.MkdirAll(filepath.Join(env.Root, ".pi"), 0o755); err != nil {
t.Fatal(err)
}
var sawEnrich bool
for _, arg := range parseArgv(t, writeAndRead(t, env)) {
if arg == "--mode=enrich" {
sawEnrich = true
}
}
if !sawEnrich {
t.Errorf("expected --mode=enrich in hook argv for enrich posture")
}
// Default (deny) posture must NOT append a --mode flag to the argv.
// (Check the parsed argv, not the raw source — the template's doc
// comment legitimately mentions "--mode=<mode>".)
env2, _ := agentstest.NewEnv(t)
if err := os.MkdirAll(filepath.Join(env2.Root, ".pi"), 0o755); err != nil {
t.Fatal(err)
}
for _, arg := range parseArgv(t, writeAndRead(t, env2)) {
if strings.HasPrefix(arg, "--mode=") {
t.Errorf("deny posture should not append a --mode flag, got argv arg %q", arg)
}
}
}
func TestPiGlobalMode(t *testing.T) {
env, _ := agentstest.NewEnv(t)
env.Mode = agents.ModeGlobal
if err := os.MkdirAll(filepath.Join(env.Home, ".pi"), 0o755); err != nil {
t.Fatal(err)
}
a := New()
res, err := a.Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
if !res.Configured {
t.Fatal("expected configured in global mode")
}
// Global extension lands under ~/.pi/agent/extensions; no AGENTS.md.
if _, err := os.ReadFile(filepath.Join(env.Home, ".pi", "agent", "extensions", "gortex", "index.ts")); err != nil {
t.Fatalf("global extension not written: %v", err)
}
if _, err := os.Stat(filepath.Join(env.Root, "AGENTS.md")); err == nil {
t.Errorf("global mode should not write repo AGENTS.md")
}
}
func TestPiApplyDryRun(t *testing.T) {
env, _ := agentstest.NewEnv(t)
if err := os.MkdirAll(filepath.Join(env.Root, ".pi"), 0o755); err != nil {
t.Fatal(err)
}
res, err := New().Apply(env, agents.ApplyOpts{DryRun: true})
if err != nil {
t.Fatalf("apply dry-run: %v", err)
}
// Nothing written to disk under dry-run.
if _, err := os.Stat(extensionPath(env)); err == nil {
t.Errorf("dry-run must not write the extension file")
}
if len(res.Files) == 0 {
t.Errorf("dry-run should still report planned actions")
}
}
// parseArgv extracts and JSON-decodes the HOOK_ARGV literal templated into
// the rendered extension. It doubles as a guard that the argv sentinel
// round-trips into a valid string array (so the TS `HOOK_ARGV[0]` access
// is sound).
func parseArgv(t *testing.T, src string) []string {
t.Helper()
const marker = "const HOOK_ARGV: string[] = "
i := strings.Index(src, marker)
if i < 0 {
t.Fatal("HOOK_ARGV line not found")
}
rest := src[i+len(marker):]
end := strings.Index(rest, ";")
if end < 0 {
t.Fatal("HOOK_ARGV line not terminated")
}
var argv []string
if err := json.Unmarshal([]byte(rest[:end]), &argv); err != nil {
t.Fatalf("HOOK_ARGV is not valid JSON array: %v", err)
}
return argv
}
func TestPiRendersSearchToolAndPreset(t *testing.T) {
env, _ := agentstest.NewEnv(t)
// Render-time default must not depend on the caller's environment.
t.Setenv("GORTEX_TOOLS", "")
src := renderExtension(env)
// B: the on-demand discovery meta-tool that mirrors tools_search.
if !strings.Contains(src, "registerSearchTool(pi)") {
t.Error("expected registerSearchTool to be wired into the entry point")
}
if !strings.Contains(src, `"tools", "search", query`) {
t.Error("expected the meta-tool to shell `gortex tools search`")
}
// Every Gortex tool (incl. the meta-tool) is namespaced under gortex_ so
// it can't silently clobber a user's own tool of the same name.
if !strings.Contains(src, `const TOOL_PREFIX = "gortex_"`) {
t.Error("expected a gortex_ tool-name prefix")
}
if !strings.Contains(src, `piToolName("tools_search")`) {
t.Error("expected the search meta-tool name to be derived via the gortex_ prefix")
}
// C: the eager preset is configurable; the baked default is core and
// the runtime honours GORTEX_TOOLS.
if !strings.Contains(src, `const TOOLS_PRESET: string = ((process.env && process.env.GORTEX_TOOLS) || "core").trim();`) {
t.Error("expected TOOLS_PRESET to default to \"core\" and honour GORTEX_TOOLS at runtime")
}
if strings.Contains(src, `"--preset", "core", "--format"`) {
t.Error("discovery must no longer hardcode --preset core; it should route through presetListArgs()")
}
}
// TestPiRegistersToolsPerSession guards the /new regression: Pi resets the
// session tool registry on every session_start, so tools must be
// (re)registered there — registering only at factory load loses them after
// the first session. The persistent name guard must be cleared first or
// re-registration is suppressed into the new (empty) registry.
func TestPiRegistersToolsPerSession(t *testing.T) {
env, _ := agentstest.NewEnv(t)
src := renderExtension(env)
// Registration happens inside a session_start handler.
sIdx := strings.Index(src, `pi.on("session_start"`)
if sIdx < 0 {
t.Fatal("expected a session_start handler")
}
// Bound the search to the handler body so we assert these calls live
// *inside* session_start, not merely somewhere in the file.
body := src[sIdx:]
if end := strings.Index(body, "pi.on(\"before_agent_start\""); end > 0 {
body = body[:end]
}
for _, want := range []string{"gortexToolNames.clear()", "registerGortexTools(pi)", "registerSearchTool(pi)", "ensureDaemon()"} {
if !strings.Contains(body, want) {
t.Errorf("expected %q inside the session_start handler (per-session re-registration)", want)
}
}
}
// TestPiInjectsOrientationViaContextHook guards the cache-safe injection
// contract: the orientation rides a `context`-hook tail user message, never
// a systemPrompt mutation (which would bust prefix prompt caching).
func TestPiInjectsOrientationViaContextHook(t *testing.T) {
env, _ := agentstest.NewEnv(t)
src := renderExtension(env)
// A `context` hook must exist and push a user message.
if !strings.Contains(src, `pi.on("context"`) {
t.Error("expected a context hook to inject the orientation")
}
if !strings.Contains(src, `event.messages.push`) {
t.Error("expected the context hook to append a message to event.messages")
}
// The decision field is `orientation`, matching the Go PiDecision.
if !strings.Contains(src, "decision.orientation") {
t.Error("expected the extension to read decision.orientation")
}
// before_agent_start must NOT fold the orientation into systemPrompt —
// that path is what we removed for cache safety.
if strings.Contains(src, "systemPrompt: (event") {
t.Error("orientation must not be appended to systemPrompt (breaks prompt caching)")
}
// Orientation injection is unconditional; only tool_call enforcement is
// gated by ENFORCE. Assert the `if (!ENFORCE) return;` guard sits AFTER
// the before_agent_start + context registrations and BEFORE tool_call —
// so --no-hooks still teaches the model how to use Gortex.
guard := strings.Index(src, "if (!ENFORCE) return;")
beforeAgent := strings.Index(src, `pi.on("before_agent_start"`)
context := strings.Index(src, `pi.on("context"`)
toolCall := strings.Index(src, `pi.on("tool_call"`)
if guard < 0 || beforeAgent < 0 || context < 0 || toolCall < 0 {
t.Fatalf("missing a required handler/guard (guard=%d before_agent_start=%d context=%d tool_call=%d)",
guard, beforeAgent, context, toolCall)
}
if beforeAgent >= guard || context >= guard || guard >= toolCall {
t.Errorf("ENFORCE guard misplaced: orientation hooks must precede it and tool_call must follow it "+
"(before_agent_start=%d context=%d guard=%d tool_call=%d)", beforeAgent, context, guard, toolCall)
}
}
func TestRenderedExtensionParsesArgv(t *testing.T) {
env, _ := agentstest.NewEnv(t)
argv := parseArgv(t, renderExtension(env))
if len(argv) < 3 || argv[1] != "hook" || argv[2] != "--agent=pi" {
t.Errorf("unexpected argv: %v", argv)
}
}
+485
View File
@@ -0,0 +1,485 @@
// Gortex extension for the Pi coding agent (earendil-works/pi).
//
// Pi has no MCP support by design — the extension API's registerTool
// covers it. This extension does two things:
//
// 1. Exposes Gortex's graph tools as native Pi tools. On every
// session_start the daemon is brought up (ensureDaemon) and the tools
// are (re)registered — Pi has no MCP, so the extension does what an MCP
// client's `gortex mcp` proxy does for it, and Pi resets the session's
// tool registry on each /new, so registration must happen per session.
// Each registered tool then shells `gortex call <name>`, which
// relays to the daemon. This mirrors the daemon's own core/defer tool
// surface: the configured eager preset (GORTEX_TOOLS, default `core`)
// is discovered from `gortex tools list` and registered up front, and
// the deferred remainder of the full (~175-tool) catalogue is reached
// on demand via the `gortex_tools_search` meta-tool, which runs the
// BM25 catalogue search and dynamically registers the matches — the
// analogue of MCP's tools_search + server-side promotion. If the
// daemon is unreachable at session_start no graph tools are
// registered; discovery re-runs on the next session.
//
// 2. Re-creates the Claude-Code "prefer graph tools over raw file
// reads" enforcement. Rather than re-implementing the deny logic in
// TypeScript, every relevant lifecycle event is marshalled into a
// normalized envelope and handed to `gortex hook --agent=pi`, whose
// decision is applied here. The Go side owns the deny / enrich /
// consult-unlock / nudge postures, identical to every other agent.
//
// This file is written verbatim by the `pi` agent adapter, except for four
// templated sentinels the adapter replaces at install time:
//
// {{GORTEX_BIN}} -> a JSON string: the resolved `gortex` binary path
// {{GORTEX_HOOK_ARGV}} -> a JSON array: the full hook argv, e.g.
// ["/usr/local/bin/gortex","hook","--agent=pi"]
// (with a trailing "--mode=<mode>" when non-deny)
// {{GORTEX_ENFORCE}} -> a JSON boolean: whether to wire the read-discipline
// enforcement (false when installed with --no-hooks;
// the graph tools + orientation are still wired)
// {{GORTEX_TOOLS_PRESET}} -> a JSON string: the default eager tool preset
// (core|edit|nav|readonly|full); GORTEX_TOOLS in the
// environment overrides it at runtime.
//
// Each is emitted as a JSON literal so values containing spaces survive.
//
// NOTE: Pi is pre-1.0. The ExtensionAPI surface, event names, and the Pi
// built-in tool names mapped in normalizeToolCall() below are pinned to
// Pi's documented API (packages/coding-agent/docs/extensions.md). If a Pi
// upgrade renames an event or a built-in tool, adjust the small maps here
// — the Go side (the wire contract) stays unchanged.
// Zero runtime dependencies: only Node built-ins. The tool `parameters`
// below are plain JSON Schema objects rather than TypeBox schemas — Pi's
// tool layer accepts a plain JSON-schema object. This is deliberate:
// a single-file auto-discovered extension has no package.json / node_modules,
// so importing `typebox` would throw at load and take the whole extension
// (tools AND enforcement) down with it.
import { execFileSync, spawn } from "node:child_process";
const GORTEX_BIN: string = {{ GORTEX_BIN }};
const HOOK_ARGV: string[] = {{ GORTEX_HOOK_ARGV }};
const ENFORCE: boolean = {{ GORTEX_ENFORCE }};
// Which eager preset to expose as native Pi tools, mirroring the Gortex
// daemon's own tool surface. The daemon defaults to the `core` preset in
// `defer` mode and lets GORTEX_TOOLS override it — so we honour the same
// env at runtime, falling back to the value baked at install time.
// Recognised: core | edit | nav | readonly | full (full = the whole
// catalogue eagerly). The deferred remainder is reachable via
// `gortex_tools_search` below.
const TOOLS_PRESET: string = ((process.env && process.env.GORTEX_TOOLS) || {{ GORTEX_TOOLS_PRESET }}).trim();
// Names (all gortex_-prefixed) of the Gortex tools registered into Pi.
const gortexToolNames = new Set<string>();
// Every Gortex tool is registered under a `gortex_` prefix so it can never
// silently clobber a Pi built-in or another extension's tool of the same
// name.
// The execute() closure still calls the daemon with the bare name.
const TOOL_PREFIX = "gortex_";
function piToolName(bare: string): string {
return bare.startsWith(TOOL_PREFIX) ? bare : TOOL_PREFIX + bare;
}
// ---------------------------------------------------------------------------
// Subprocess helpers
// ---------------------------------------------------------------------------
function runGortex(args: string[], input?: string): string {
return execFileSync(GORTEX_BIN, args, {
input,
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
timeout: 30_000,
}).trim();
}
// ensureDaemon brings the shared Gortex daemon up, the way an MCP host's
// `gortex mcp` does for other agents.
// Idempotent, fire-and-forget (we don't block on the launcher's ≤60s
// readiness poll), and never throws — a missing binary or spawn failure
// must not take the extension down. The first tool calls either find it
// ready or degrade gracefully.
function ensureDaemon(): void {
try {
const child = spawn(GORTEX_BIN, ["daemon", "start", "--detach"], {
stdio: "ignore",
detached: true,
});
child.on("error", () => { }); // binary missing / spawn failure — swallow
// No teardown counterpart, by design: the daemon is shared, long-lived
// infrastructure that outlives the session — like `gortex mcp`.
child.unref();
} catch {
}
}
interface PiDecision {
block?: boolean;
reason?: string;
additional_context?: string;
orientation?: string;
}
// callHook sends a normalized event envelope to `gortex hook --agent=pi`
// and parses the PiDecision it writes back. Fail-open: any error returns
// an empty decision so the extension never blocks Pi's flow on a hook
// hiccup (daemon down, parse error, timeout).
function callHook(envelope: Record<string, unknown>): PiDecision {
try {
const out = execFileSync(HOOK_ARGV[0], HOOK_ARGV.slice(1), {
input: JSON.stringify(envelope),
encoding: "utf8",
maxBuffer: 16 * 1024 * 1024,
timeout: 5_000,
}).trim();
if (!out) return {};
return JSON.parse(out) as PiDecision;
} catch {
return {};
}
}
// ---------------------------------------------------------------------------
// Tool-call normalization (Pi vocabulary -> canonical Claude-Code vocabulary)
// ---------------------------------------------------------------------------
//
// The Go enrich() classifier switches on Claude-Code tool names
// ("Read"/"Grep"/"Glob"/"Bash"/"Edit"/"Write") and reads canonical input
// keys ("file_path"/"pattern"/"command"). Pi uses its own lowercase names
// and input shapes, so we translate here — keeping all Pi-specific
// knowledge in this Pi-specific file.
const toolNameMap: Record<string, string> = {
read: "Read",
grep: "Grep",
find: "Glob",
ls: "Glob",
bash: "Bash",
edit: "Edit",
write: "Write",
};
function firstString(obj: Record<string, unknown>, keys: string[]): string | undefined {
for (const k of keys) {
const v = obj[k];
if (typeof v === "string" && v !== "") return v;
}
return undefined;
}
function normalizeToolCall(
piName: string,
piInput: Record<string, unknown>,
): { tool_name: string; tool_input: Record<string, unknown> } {
const canonical = toolNameMap[piName.toLowerCase()] ?? piName;
const out: Record<string, unknown> = { ...piInput };
const path = firstString(piInput, ["file_path", "path", "file", "filepath", "absolute_path"]);
if (path !== undefined) out.file_path = path;
let pattern = firstString(piInput, ["pattern", "glob", "query", "regex", "name"]);
if (pattern === undefined && canonical === "Glob") pattern = path;
if (pattern !== undefined) out.pattern = pattern;
const command = firstString(piInput, ["command", "cmd", "script"]);
if (command !== undefined) out.command = command;
return { tool_name: canonical, tool_input: out };
}
// ---------------------------------------------------------------------------
// Dynamic tool registration
// ---------------------------------------------------------------------------
interface ToolDescriptor {
name: string;
summary?: string;
description?: string;
}
// presetListArgs maps the configured preset to `gortex tools list` flags.
// `full`/`all`/empty list the whole catalogue (no --preset filter); any
// other value is passed straight through so a preset added upstream works
// with no change here — the CLI is the source of truth for valid names. An
// unrecognised name simply matches nothing, and discoverTools falls back to
// the static set; the full catalogue stays reachable via search regardless.
function presetListArgs(): string[] {
const base = ["tools", "list", "--format", "json"];
const p = TOOLS_PRESET.toLowerCase();
if (p === "" || p === "full" || p === "all") return base;
return [...base, "--preset", p];
}
// discoverTools asks the daemon for the eager tool roster. Returns [] when
// the daemon is unreachable.
// TODO: poll for the daemon coming online mid-session and register then.
function discoverTools(): ToolDescriptor[] {
try {
const raw = runGortex(presetListArgs());
const parsed = JSON.parse(raw) as ToolDescriptor[];
if (Array.isArray(parsed) && parsed.length > 0) return parsed;
} catch {
// daemon down / not tracking — no tools registered this session.
}
return [];
}
// safeRegister registers a Pi tool, tolerating a redundant registration —
// a config reload can re-fire session_start without resetting the session's
// tool registry, in which case the name is already live and Pi may throw.
function safeRegister(pi: any, def: any): void {
try {
pi.registerTool(def);
} catch {
// already live this session — the existing registration stands.
}
}
// registerOneTool registers a single Gortex tool as a native Pi tool named
// `gortex_<bare>`, whose execute() shells `gortex call <bare>`. Idempotent —
// a name already registered is skipped. Adds the prefixed name to
// gortexToolNames so the read-discipline postures treat a call to it as a
// graph query, not a raw file read. Used both for the eager preset and for
// tools promoted later by gortex_tools_search.
function registerOneTool(pi: any, desc: ToolDescriptor): void {
const bare = desc.name;
if (!bare) return;
const name = piToolName(bare);
if (gortexToolNames.has(name)) return;
gortexToolNames.add(name);
safeRegister(pi, {
name,
label: name,
description: (desc.description || desc.summary || name).trim(),
// Pass-through arguments: the model supplies whatever the underlying
// Gortex tool accepts; `gortex call` validates names + args server
// side and suggests near-matches on a typo.
parameters: { type: "object", additionalProperties: true, properties: {} },
async execute(_id: string, params: Record<string, unknown>) {
try {
const out = runGortex([
"call",
bare,
"--json",
JSON.stringify(params ?? {}),
"--format",
"gcx",
]);
return { content: [{ type: "text", text: out }], details: {} };
} catch (err: any) {
const msg = err?.stderr?.toString?.() || err?.message || String(err);
throw new Error(`gortex call ${bare} failed: ${msg}`);
}
},
});
}
function registerGortexTools(pi: any): void {
for (const desc of discoverTools()) registerOneTool(pi, desc);
}
// ---------------------------------------------------------------------------
// On-demand tool discovery (mirrors MCP tools_search + promotion)
// ---------------------------------------------------------------------------
//
// MCP clients on the default core/defer surface see only the eager preset;
// the rest of the catalogue is deferred behind the tools_search meta-tool,
// which returns a match's schema and PROMOTES it into the live server
// (firing notifications/tools/list_changed) so it becomes callable. Pi has
// no server-side promotion, so we replicate it: a single
// `gortex_tools_search` meta-tool runs the BM25 catalogue search and
// dynamically registers each match as a native Pi tool — Pi supports
// registering tools after session init.
const SEARCH_TOOL_NAME = piToolName("tools_search");
// firstSentence returns the leading sentence of a description, up to and
// including the first ". " boundary.
function firstSentence(desc: string): string {
const t = desc.trim();
if (!t) return "";
const i = t.indexOf(". ");
return i >= 0 ? t.slice(0, i + 1).trim() : t;
}
// parseToolSearchJSON parses `gortex tools search --format json` output —
// an array of {name, description}.
function parseToolSearchJSON(raw: string): ToolDescriptor[] {
let parsed: Array<{ name: string; description?: string }>;
try {
parsed = JSON.parse(raw);
} catch {
return [];
}
if (!Array.isArray(parsed)) return [];
return parsed
.filter((e) => e && typeof e.name === "string" && e.name)
.map((e) => ({
name: e.name,
description: e.description ?? "",
summary: firstSentence(e.description ?? ""),
}));
}
function searchTools(query: string, limit: number): ToolDescriptor[] {
return parseToolSearchJSON(
runGortex(["tools", "search", query, "--limit", String(limit), "--format", "json"]),
);
}
function registerSearchTool(pi: any): void {
if (gortexToolNames.has(SEARCH_TOOL_NAME)) return;
gortexToolNames.add(SEARCH_TOOL_NAME);
safeRegister(pi, {
name: SEARCH_TOOL_NAME,
label: SEARCH_TOOL_NAME,
description:
"Search the full Gortex tool catalogue and register the matches as callable tools. " +
"Use this when the eagerly-loaded set doesn't cover what you need — e.g. taint_paths, " +
"flow_between, contracts, find_clones, pr_risk, overlay/simulation tools. Returns the " +
"names + summaries now available; call them directly on the next turn. (Gortex graph tool.)",
parameters: {
type: "object",
additionalProperties: false,
properties: {
query: {
type: "string",
description: "What you want to do, e.g. 'trace taint to a sink' or 'detect duplicate code'.",
},
limit: {
type: "number",
description: "Maximum number of tools to register (default 5).",
},
},
required: ["query"],
},
async execute(_id: string, params: Record<string, unknown>) {
const query = typeof params?.query === "string" ? params.query.trim() : "";
if (!query) {
return {
content: [{ type: "text", text: "Provide a 'query' describing the tool you need." }],
details: {},
};
}
const rawLimit = typeof params?.limit === "number" ? Math.floor(params.limit) : 5;
const limit = rawLimit > 0 ? rawLimit : 5;
let matches: ToolDescriptor[];
try {
matches = searchTools(query, limit);
} catch (err: any) {
const msg = err?.stderr?.toString?.() || err?.message || String(err);
throw new Error(`gortex tools search failed: ${msg}`);
}
const newlyAvailable: string[] = [];
for (const desc of matches) {
const name = desc.name ? piToolName(desc.name) : "";
if (!name || name === SEARCH_TOOL_NAME) continue;
if (!gortexToolNames.has(name)) newlyAvailable.push(name);
registerOneTool(pi, desc); // idempotent
}
// Report the prefixed names — that's what the model calls them by.
const lines = matches
.filter((d) => d.name && piToolName(d.name) !== SEARCH_TOOL_NAME)
.map((d) => `- ${piToolName(d.name)}${d.summary ? `${d.summary}` : ""}`);
const header =
matches.length === 0
? `No tools matched "${query}".`
: newlyAvailable.length > 0
? `Registered ${newlyAvailable.length} tool(s) — now callable: ${newlyAvailable.join(", ")}.`
: "Matching tools are already registered and callable.";
return { content: [{ type: "text", text: [header, ...lines].join("\n") }], details: {} };
},
});
}
// ---------------------------------------------------------------------------
// Extension entry point
// ---------------------------------------------------------------------------
export default function (pi: any) {
let orientationInjected = false;
// Orientation awaiting injection into the next LLM call. The `context`
// hook appends it as a tail user message rather than mutating
// systemPrompt: a systemPrompt change sits at messages[0] and invalidates
// prefix prompt caching. Computed once per session.
let pendingOrientation = "";
// Pi resets the session's tool registry on every session_start, so
// (re)register here. Clear the name guard first — it persists across
// sessions and would otherwise suppress re-registration.
pi.on("session_start", () => {
orientationInjected = false;
pendingOrientation = "";
ensureDaemon();
gortexToolNames.clear();
registerGortexTools(pi);
registerSearchTool(pi);
});
// Fires before the agent loop's first LLM call. It can't mutate messages
// itself, so it just parks the orientation for the `context` hook.
pi.on("before_agent_start", () => {
if (orientationInjected) return;
const decision = callHook({ event: "session_start", cwd: pi?.cwd ?? process.cwd() });
if (decision.orientation) {
pendingOrientation = decision.orientation;
orientationInjected = true;
}
return;
});
// Fires before each LLM call with a mutable message array. Append the
// parked orientation once, then clear it so it isn't repeated each call.
pi.on("context", (event: any) => {
if (!pendingOrientation) return;
const text = pendingOrientation;
pendingOrientation = "";
try {
if (event && Array.isArray(event.messages)) {
event.messages.push({ role: "user", content: text });
return { messages: event.messages };
}
} catch {
// best effort — never break context assembly.
}
return;
});
if (!ENFORCE) return;
// Enforcement: every non-Gortex tool call is checked against the Go hook.
pi.on("tool_call", async (event: any, ctx: any) => {
const piName: string = event?.toolName ?? "";
const piInput: Record<string, unknown> = event?.input ?? {};
const isGortexTool = gortexToolNames.has(piName);
const norm = normalizeToolCall(piName, piInput);
const decision = callHook({
event: "tool_call",
tool_name: norm.tool_name,
tool_input: norm.tool_input,
cwd: ctx?.cwd ?? pi?.cwd ?? process.cwd(),
session_id: ctx?.sessionManager?.sessionId ?? "",
is_gortex_tool: isGortexTool,
});
if (decision.block) {
return { block: true, reason: decision.reason ?? "[Gortex] blocked — prefer graph tools." };
}
if (decision.additional_context) {
// Soft guidance: surface it without blocking the call.
try {
pi.sendMessage(
{ customType: "gortex", content: decision.additional_context, display: true },
{ deliverAs: "steer" },
);
} catch {
// sendMessage shape can vary across Pi versions; never fatal.
}
}
return;
});
}
+44
View File
@@ -0,0 +1,44 @@
package agents
import (
"encoding/json"
"fmt"
"io"
"strings"
)
// PrintConfig writes a dry-run view of the configuration a single named adapter
// would install — the files it plans to touch, their action, and the keys it
// sets — as indented JSON, without writing anything to disk. It is the
// machine-readable companion to `init doctor`, scoped to one agent. Returns an
// error naming the registered agents when `name` matches none.
func PrintConfig(w io.Writer, reg *Registry, name string, env Env) error {
var found Adapter
var available []string
for _, a := range reg.All() {
available = append(available, a.Name())
if a.Name() == name {
found = a
}
}
if found == nil {
return fmt.Errorf("unknown agent %q; available: %s", name, strings.Join(available, ", "))
}
plan, err := found.Plan(env)
if err != nil {
return fmt.Errorf("plan %s: %w", found.Name(), err)
}
files := []FileAction{}
if plan != nil && plan.Files != nil {
files = plan.Files
}
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(map[string]any{
"agent": found.Name(),
"docs_url": found.DocsURL(),
"files": files,
})
}
+61
View File
@@ -0,0 +1,61 @@
package agents
import (
"bytes"
"encoding/json"
"testing"
)
// planStubAdapter is a stub adapter whose Plan returns a known file set so the
// PrintConfig output can be asserted.
type planStubAdapter struct{ name string }
func (p planStubAdapter) Name() string { return p.name }
func (p planStubAdapter) DocsURL() string { return "https://example.test/" + p.name }
func (p planStubAdapter) Detect(Env) (bool, error) {
return true, nil
}
func (p planStubAdapter) Plan(Env) (*Plan, error) {
return &Plan{Files: []FileAction{
{Path: ".cursor/mcp.json", Action: ActionMerge, Keys: []string{"mcpServers.gortex"}},
}}, nil
}
func (p planStubAdapter) Apply(Env, ApplyOpts) (*Result, error) {
return &Result{Name: p.name}, nil
}
// TestPrintConfig proves the --print-config core: a known agent's planned files
// are rendered as JSON, and an unknown agent errors with the available list.
func TestPrintConfig(t *testing.T) {
reg := NewRegistry()
reg.Register(planStubAdapter{name: "cursor"})
var buf bytes.Buffer
if err := PrintConfig(&buf, reg, "cursor", Env{}); err != nil {
t.Fatalf("PrintConfig(cursor): %v", err)
}
var out struct {
Agent string `json:"agent"`
DocsURL string `json:"docs_url"`
Files []FileAction `json:"files"`
}
if err := json.Unmarshal(buf.Bytes(), &out); err != nil {
t.Fatalf("output is not JSON: %v\n%s", err, buf.String())
}
if out.Agent != "cursor" {
t.Errorf("agent = %q, want cursor", out.Agent)
}
if len(out.Files) != 1 || out.Files[0].Path != ".cursor/mcp.json" {
t.Errorf("files = %+v, want the planned mcp.json", out.Files)
}
// An unknown agent errors and names the registered agents.
err := PrintConfig(&bytes.Buffer{}, reg, "nonexistent", Env{})
if err == nil {
t.Fatal("an unknown agent must error")
}
if !bytes.Contains([]byte(err.Error()), []byte("unknown agent")) ||
!bytes.Contains([]byte(err.Error()), []byte("cursor")) {
t.Errorf("error should name the unknown agent and the available list; got %v", err)
}
}
+178
View File
@@ -0,0 +1,178 @@
package agents
import (
"fmt"
"sort"
"strings"
"sync"
)
// Registry holds the set of adapters `gortex init` will run. Callers
// Register each adapter explicitly (from cmd/gortex/init.go) rather
// than via sub-package init() hooks so that unit tests can build a
// registry with a controlled subset without a global side-effect.
//
// Methods are safe for concurrent reads; Register should only be
// called during startup.
type Registry struct {
mu sync.RWMutex
adapters []Adapter
byName map[string]Adapter
}
// NewRegistry returns an empty Registry.
func NewRegistry() *Registry {
return &Registry{byName: make(map[string]Adapter)}
}
// Register adds an adapter. Panics on duplicate names — duplicates
// indicate a programming error (two packages registering the same
// name), not a user mistake, so a panic is appropriate.
func (r *Registry) Register(a Adapter) {
r.mu.Lock()
defer r.mu.Unlock()
name := a.Name()
if _, dup := r.byName[name]; dup {
panic(fmt.Sprintf("agents.Registry: duplicate adapter name %q", name))
}
r.byName[name] = a
r.adapters = append(r.adapters, a)
}
// All returns adapters in registration order. Callers should not
// mutate the returned slice.
func (r *Registry) All() []Adapter {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]Adapter, len(r.adapters))
copy(out, r.adapters)
return out
}
// Names returns the sorted list of registered adapter names. Used by
// the --agents flag's help text and the unknown-name error message.
func (r *Registry) Names() []string {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]string, 0, len(r.byName))
for name := range r.byName {
out = append(out, name)
}
sort.Strings(out)
return out
}
// Lookup returns the adapter with the given name, or nil when no
// adapter is registered under that name.
func (r *Registry) Lookup(name string) Adapter {
r.mu.RLock()
defer r.mu.RUnlock()
return r.byName[name]
}
// Filter returns the subset of adapters selected by allow/skip lists.
// allowCSV:
// - "" (empty) or "auto" selects every registered adapter (the
// default: let Detect decide which ones run)
// - comma-separated list of names selects only those
// - any unknown name produces an error
//
// skipCSV removes names from the selection after allowCSV applies.
// Unknown skip names are also hard errors — silent skips mask typos.
//
// The returned slice preserves registration order so the user sees a
// stable run sequence.
func (r *Registry) Filter(allowCSV, skipCSV string) ([]Adapter, error) {
r.mu.RLock()
defer r.mu.RUnlock()
allow := parseCSV(allowCSV)
skip := parseCSV(skipCSV)
// Validate names up front; we'd rather report all typos at once.
var unknown []string
for _, n := range allow {
if n == "auto" {
continue
}
if _, ok := r.byName[n]; !ok {
unknown = append(unknown, n)
}
}
for _, n := range skip {
if _, ok := r.byName[n]; !ok {
unknown = append(unknown, n)
}
}
if len(unknown) > 0 {
sort.Strings(unknown)
return nil, fmt.Errorf("unknown agent name(s): %s; available: %s",
strings.Join(unknown, ", "),
strings.Join(r.sortedNamesLocked(), ", "))
}
// Build the allowed set. Empty allowCSV or the "auto" sentinel
// means "every registered adapter" — Detect() then decides which
// ones actually run.
var allowSet map[string]struct{}
autoMode := len(allow) == 0
for _, n := range allow {
if n == "auto" {
autoMode = true
continue
}
}
if !autoMode {
allowSet = make(map[string]struct{}, len(allow))
for _, n := range allow {
allowSet[n] = struct{}{}
}
}
skipSet := make(map[string]struct{}, len(skip))
for _, n := range skip {
skipSet[n] = struct{}{}
}
out := make([]Adapter, 0, len(r.adapters))
for _, a := range r.adapters {
name := a.Name()
if allowSet != nil {
if _, ok := allowSet[name]; !ok {
continue
}
}
if _, skipped := skipSet[name]; skipped {
continue
}
out = append(out, a)
}
return out, nil
}
func (r *Registry) sortedNamesLocked() []string {
out := make([]string, 0, len(r.byName))
for name := range r.byName {
out = append(out, name)
}
sort.Strings(out)
return out
}
// parseCSV splits a comma-separated list and trims whitespace. Empty
// input and all-whitespace input yield a nil slice.
func parseCSV(s string) []string {
s = strings.TrimSpace(s)
if s == "" {
return nil
}
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}
+182
View File
@@ -0,0 +1,182 @@
package agents
import (
"fmt"
"io"
"io/fs"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"sync"
)
// render.go is the engine behind the skill-render drift fence. It runs
// every adapter against an isolated sandbox (a throwaway HOME + repo
// root) with ForceDetect on — so the adapter renders regardless of which
// tools are installed — then serialises the produced file tree into a
// stable, machine-independent text manifest. The manifest is
// byte-compared against committed goldens by the drift test and the
// `gortex agents render` command, so any change to an adapter's
// generated MCP config, instructions, hooks, or skills surfaces as a
// reviewable diff across every platform, not just Claude.
// RenderManifest renders each adapter into its own sandbox and returns a
// normalised manifest per adapter, keyed by adapter name.
func RenderManifest(adapters []Adapter) (map[string]string, error) {
out := make(map[string]string, len(adapters))
for _, a := range adapters {
m, err := renderOne(a)
if err != nil {
return nil, fmt.Errorf("render %s: %w", a.Name(), err)
}
out[a.Name()] = m
}
return out, nil
}
// renderOne applies a single adapter in a fresh sandbox and returns its
// manifest. The HOME and repo-root temp dirs are removed afterwards.
func renderOne(a Adapter) (string, error) {
home, err := os.MkdirTemp("", "gortex-render-home-")
if err != nil {
return "", err
}
defer func() { _ = os.RemoveAll(home) }()
root, err := os.MkdirTemp("", "gortex-render-root-")
if err != nil {
return "", err
}
defer func() { _ = os.RemoveAll(root) }()
// Project mode is the default `gortex init` path and the one that
// renders the per-repo skill / instruction surfaces (where content
// drift lives). A fixed SkillsRouting payload makes the
// community-routing blocks render deterministically.
env := Env{
Root: root,
Home: home,
Mode: ModeProject,
HookCommand: "gortex hook",
SkillsRouting: "- [example-community](.claude/skills/example/SKILL.md) — example routing block\n",
Stderr: io.Discard,
}
if _, err := a.Apply(env, ApplyOpts{ForceDetect: true}); err != nil {
return "", err
}
return manifestForDirs(home, root)
}
// manifestForDirs walks the sandbox HOME and repo root and builds a
// sorted, normalised manifest. Each file becomes a `=== <key> ===`
// header followed by its (sandbox-path-stripped) content; entries are
// sorted by key so the manifest is deterministic.
func manifestForDirs(home, root string) (string, error) {
type entry struct{ key, content string }
var entries []entry
collect := func(base, prefix string) error {
return filepath.WalkDir(base, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
rel, err := filepath.Rel(base, path)
if err != nil {
return err
}
entries = append(entries, entry{
key: canonicalManifestKey(prefix + filepath.ToSlash(rel)),
content: normalizeRender(string(data), home, root),
})
return nil
})
}
if err := collect(home, "home/"); err != nil {
return "", err
}
if err := collect(root, "root/"); err != nil {
return "", err
}
sort.Slice(entries, func(i, j int) bool { return entries[i].key < entries[j].key })
var b strings.Builder
for _, e := range entries {
fmt.Fprintf(&b, "=== %s ===\n%s\n", e.key, strings.TrimRight(e.content, "\n"))
}
return b.String(), nil
}
// gortexBinaryPaths memoizes the absolute paths an adapter might bake
// into its config for the gortex binary — the running process
// (os.Executable, as the hermes adapter uses) and a PATH lookup. They
// are normalised to bare "gortex" so the manifest doesn't depend on
// where this build happens to live (dev box vs CI vs `go test` binary).
var gortexBinaryPaths = sync.OnceValue(func() []string {
seen := map[string]struct{}{}
var paths []string
add := func(p string) {
if p == "" || p == "gortex" {
return
}
if _, dup := seen[p]; dup {
return
}
seen[p] = struct{}{}
paths = append(paths, p)
}
if exe, err := os.Executable(); err == nil {
add(exe)
if abs, e := filepath.Abs(exe); e == nil {
add(abs)
}
}
if p, err := exec.LookPath("gortex"); err == nil {
add(p)
}
return paths
})
// canonicalManifestKey rewrites the OS-specific user-config locations
// in a manifest path to a single canonical (Linux/XDG) form, so the
// manifest is identical regardless of the OS the render runs on.
// Without this an adapter that uses OS-specific config dirs makes the
// golden non-portable (e.g. zed writes ~/Library/Application Support/Zed
// on macOS, ~/AppData/Roaming/Zed on Windows, and ~/.config/zed on
// Linux). Only manifest keys are folded; file contents are compared
// verbatim.
func canonicalManifestKey(key string) string {
key = strings.ReplaceAll(key, "Library/Application Support/", ".config/")
key = strings.ReplaceAll(key, "AppData/Roaming/", ".config/")
// zed capitalises its directory on macOS/Windows but not on Linux.
key = strings.ReplaceAll(key, ".config/Zed/", ".config/zed/")
return key
}
// normalizeRender replaces machine-specific absolutes — the sandbox
// HOME / repo root and the resolved gortex binary path — with stable
// placeholders so the manifest is identical on every machine.
func normalizeRender(s, home, root string) string {
s = strings.ReplaceAll(s, home, "$HOME")
s = strings.ReplaceAll(s, root, "$ROOT")
for _, p := range gortexBinaryPaths() {
s = strings.ReplaceAll(s, p, "gortex")
}
return s
}
// RenderContainsRegistration reports whether a rendered manifest still
// wires Gortex in — an MCP server stanza, a gortex hook, a community
// routing block, or instruction prose all reference "gortex". The drift
// CLI uses it as a structural sanity check (independent of the byte
// golden) that an adapter didn't silently stop emitting gortex content.
func RenderContainsRegistration(manifest string) bool {
return strings.Contains(strings.ToLower(manifest), "gortex")
}
+71
View File
@@ -0,0 +1,71 @@
package agents
import (
"errors"
"fmt"
"io"
"os"
toml "github.com/pelletier/go-toml/v2"
)
// MergeTOML is the TOML cousin of MergeJSON: read an existing
// TOML file (if any), pass the decoded map to mutate, and write
// back atomically when mutate reports a change. Used by the Codex
// CLI adapter — Codex config lives in ~/.codex/config.toml and no
// other adapter currently needs TOML support.
//
// Malformed TOML is preserved as a .bak sibling before we
// overwrite, same policy as MergeJSON.
func MergeTOML(w io.Writer, path string, mutate func(root map[string]any, existed bool) (changed bool, err error), opts ApplyOpts) (FileAction, error) {
existed := false
root := make(map[string]any)
var backupPath string
if data, err := os.ReadFile(path); err == nil {
existed = true
if err := toml.Unmarshal(data, &root); err != nil {
backupPath = path + ".bak"
_ = os.WriteFile(backupPath, data, 0o644)
root = make(map[string]any)
}
} else if !errors.Is(err, os.ErrNotExist) {
return FileAction{}, fmt.Errorf("read %s: %w", path, err)
}
changed, err := mutate(root, existed)
if err != nil {
return FileAction{}, err
}
if !changed {
return FileAction{Path: path, Action: ActionSkip, Reason: "already-configured"}, nil
}
keys := sortedMapKeys(root)
if opts.DryRun {
action := ActionWouldCreate
if existed {
action = ActionWouldMerge
}
return FileAction{Path: path, Action: action, Keys: keys}, nil
}
out, err := toml.Marshal(root)
if err != nil {
return FileAction{}, fmt.Errorf("marshal %s: %w", path, err)
}
if err := AtomicWriteFile(path, out, 0o644); err != nil {
return FileAction{}, err
}
action := ActionCreate
if existed {
action = ActionMerge
}
if backupPath != "" {
logf(w, "[gortex init] %s was malformed TOML; backup saved to %s", path, backupPath)
}
logf(w, "[gortex init] %s %s", actionVerb(action), path)
return FileAction{Path: path, Action: action, Keys: keys}, nil
}
+129
View File
@@ -0,0 +1,129 @@
// Package vscode implements the Gortex init integration for
// Visual Studio Code's native MCP runtime (1.102+). VS Code's
// schema differs from the Claude / Cursor canonical shape in two
// ways:
//
// - top-level key is "servers" (not "mcpServers")
// - stdio is the inferred default; no "type" field is required
//
// The VS Code docs document both project-level (.vscode/mcp.json)
// and user-level configurations; the user-level file lives under
// the platform-specific VS Code profile directory and is accessed
// via the "MCP: Open User Configuration" command. We write the
// project-level file today and leave user-level to a follow-up once
// the official per-OS paths are confirmed.
//
// Docs: https://code.visualstudio.com/docs/copilot/chat/mcp-servers
package vscode
import (
"os"
"os/exec"
"path/filepath"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/internalutil"
)
const Name = "vscode"
const DocsURL = "https://code.visualstudio.com/docs/copilot/chat/mcp-servers"
type Adapter struct{}
func New() *Adapter { return &Adapter{} }
func (a *Adapter) Name() string { return Name }
func (a *Adapter) DocsURL() string { return DocsURL }
func (a *Adapter) Detect(env agents.Env) (bool, error) {
if _, err := os.Stat(filepath.Join(env.Root, ".vscode")); err == nil {
return true, nil
}
if p, err := exec.LookPath("code"); err == nil && p != "" {
return true, nil
}
if env.Home != "" {
candidates := []string{
filepath.Join(env.Home, "Library", "Application Support", "Code"),
filepath.Join(env.Home, ".config", "Code"),
filepath.Join(env.Home, ".vscode"),
}
for _, dir := range candidates {
if _, err := os.Stat(dir); err == nil {
return true, nil
}
}
}
return false, nil
}
func (a *Adapter) Plan(env agents.Env) (*agents.Plan, error) {
p := &agents.Plan{Files: []agents.FileAction{
{Path: filepath.Join(env.Root, ".vscode", "mcp.json"), Action: agents.ActionWouldMerge, Keys: []string{"servers"}},
}}
if env.Mode != agents.ModeGlobal && env.SkillsRouting != "" {
p.Files = append(p.Files, agents.FileAction{
Path: filepath.Join(env.Root, ".github", "copilot-instructions.md"), Action: agents.ActionWouldMerge,
Keys: []string{"communities-block"},
})
}
return p, nil
}
func (a *Adapter) Apply(env agents.Env, opts agents.ApplyOpts) (*agents.Result, error) {
res := &agents.Result{Name: Name, DocsURL: DocsURL}
// Global mode skips this adapter — user-level VS Code MCP
// config lives at a platform-specific path (via the "MCP: Open
// User Configuration" command) which we don't resolve today.
if env.Mode == agents.ModeGlobal {
return res, nil
}
detected, _ := a.Detect(env)
res.Detected = detected
if !detected && !opts.ForceDetect {
internalutil.Logf(env.Stderr, "[gortex init] skip VS Code / Copilot setup (VS Code not detected)")
return res, nil
}
internalutil.Logf(env.Stderr, "[gortex init] setting up VS Code / GitHub Copilot integration...")
path := filepath.Join(env.Root, ".vscode", "mcp.json")
action, err := agents.MergeJSON(env.Stderr, path, func(root map[string]any, _ bool) (bool, error) {
servers, ok := root["servers"].(map[string]any)
if !ok {
servers = make(map[string]any)
}
if _, exists := servers["gortex"]; exists && !opts.Force {
return false, nil
}
// VS Code's native MCP runtime (1.102+) infers stdio from
// command/args presence, so no "type" field is needed. Env
// is optional — we set it for parity with other adapters.
servers["gortex"] = map[string]any{
"command": "gortex",
"args": []string{"mcp"},
"env": map[string]string{"GORTEX_INDEX_WORKERS": "8"},
}
root["servers"] = servers
return true, nil
}, opts)
if err != nil {
return res, err
}
res.Files = append(res.Files, action)
// Copilot reads .github/copilot-instructions.md on every chat
// turn. Write a marker-guarded community-routing block there
// when skills were generated — codebase-specific navigation
// that MCP tool descriptions can't carry.
if env.SkillsRouting != "" {
copilotPath := filepath.Join(env.Root, ".github", "copilot-instructions.md")
routingAction, err := agents.UpsertMarkedBlock(env.Stderr, copilotPath, env.SkillsRouting,
agents.CommunitiesStartMarker, agents.CommunitiesEndMarker, opts)
if err != nil {
return res, err
}
res.Files = append(res.Files, routingAction)
}
res.Configured = true
return res, nil
}
+78
View File
@@ -0,0 +1,78 @@
package vscode
import (
"os"
"path/filepath"
"testing"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/agentstest"
)
// TestVSCodeUsesServersKeyNotMcpServers locks in the VS Code
// 1.102+ schema: top-level key is "servers", not the legacy
// "mcpServers". A regression here would be silently invisible to
// users — VS Code would just not load the server.
func TestVSCodeUsesServersKeyNotMcpServers(t *testing.T) {
env, _ := agentstest.NewEnv(t)
if err := os.MkdirAll(filepath.Join(env.Root, ".vscode"), 0o755); err != nil {
t.Fatal(err)
}
a := New()
res, err := a.Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
// Two creates: .vscode/mcp.json for MCP plus
// .github/copilot-instructions.md for the Copilot rules block
// that tells Copilot Chat to prefer Gortex over file reads.
agentstest.AssertCountsByAction(t, res, map[agents.ActionKind]int{agents.ActionCreate: 2})
cfg := agentstest.ReadJSON(t, filepath.Join(env.Root, ".vscode", "mcp.json"))
if _, ok := cfg["mcpServers"]; ok {
t.Fatalf("wrote legacy 'mcpServers' key; VS Code 1.102+ expects 'servers': %v", cfg)
}
servers, ok := cfg["servers"].(map[string]any)
if !ok {
t.Fatalf("missing 'servers' key: %v", cfg)
}
if _, ok := servers["gortex"]; !ok {
t.Fatalf("servers.gortex missing: %v", servers)
}
agentstest.AssertIdempotent(t, a, env)
}
// TestVSCodeMergePreservesUserServers confirms we don't clobber a
// user's existing "servers" entry during the merge.
func TestVSCodeMergePreservesUserServers(t *testing.T) {
env, _ := agentstest.NewEnv(t)
if err := os.MkdirAll(filepath.Join(env.Root, ".vscode"), 0o755); err != nil {
t.Fatal(err)
}
path := filepath.Join(env.Root, ".vscode", "mcp.json")
agentstest.WriteJSON(t, path, map[string]any{
"servers": map[string]any{
"playwright": map[string]any{"command": "npx"},
},
})
res, err := New().Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
// mcp.json pre-exists → merge. The copilot-instructions.md file
// does not exist in this fixture, so the instructions helper
// creates it.
agentstest.AssertCountsByAction(t, res, map[agents.ActionKind]int{agents.ActionMerge: 1, agents.ActionCreate: 1})
cfg := agentstest.ReadJSON(t, path)
servers := cfg["servers"].(map[string]any)
if _, ok := servers["playwright"]; !ok {
t.Fatalf("playwright was clobbered: %v", servers)
}
if _, ok := servers["gortex"]; !ok {
t.Fatalf("gortex missing: %v", servers)
}
}
+144
View File
@@ -0,0 +1,144 @@
// Package windsurf implements the Gortex init integration for
// Codeium's Windsurf editor. The canonical config path is
// ~/.codeium/mcp_config.json (documented 2026-04). Earlier Windsurf
// releases used ~/.codeium/windsurf/mcp_config.json; we detect both
// and prefer the new path, leaving the old file alone so users can
// uninstall a stale install cleanly. --force upgrades by removing
// the legacy file.
//
// Docs: https://docs.windsurf.com/plugins/cascade/mcp
package windsurf
import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/internalutil"
)
const Name = "windsurf"
const DocsURL = "https://docs.windsurf.com/plugins/cascade/mcp"
type Adapter struct{}
func New() *Adapter { return &Adapter{} }
func (a *Adapter) Name() string { return Name }
func (a *Adapter) DocsURL() string { return DocsURL }
// currentConfigPath is the path Windsurf reads today.
func currentConfigPath(home string) string {
return filepath.Join(home, ".codeium", "mcp_config.json")
}
// legacyConfigPath is the pre-2026 path Windsurf used.
func legacyConfigPath(home string) string {
return filepath.Join(home, ".codeium", "windsurf", "mcp_config.json")
}
func (a *Adapter) Detect(env agents.Env) (bool, error) {
if p, err := exec.LookPath("windsurf"); err == nil && p != "" {
return true, nil
}
if env.Home == "" {
return false, nil
}
for _, p := range []string{
filepath.Join(env.Home, ".codeium"),
filepath.Join(env.Home, ".codeium", "windsurf"),
} {
if _, err := os.Stat(p); err == nil {
return true, nil
}
}
return false, nil
}
func (a *Adapter) Plan(env agents.Env) (*agents.Plan, error) {
p := &agents.Plan{}
if env.Home != "" {
p.Files = append(p.Files, agents.FileAction{
Path: currentConfigPath(env.Home),
Action: agents.ActionWouldMerge,
Keys: []string{"mcpServers"},
})
}
if env.Mode != agents.ModeGlobal && env.SkillsRouting != "" {
p.Files = append(p.Files, agents.FileAction{
Path: filepath.Join(env.Root, ".windsurfrules"), Action: agents.ActionWouldMerge,
Keys: []string{"communities-block"},
})
}
return p, nil
}
func (a *Adapter) Apply(env agents.Env, opts agents.ApplyOpts) (*agents.Result, error) {
res := &agents.Result{Name: Name, DocsURL: DocsURL}
detected, _ := a.Detect(env)
res.Detected = detected
if !detected && !opts.ForceDetect {
internalutil.Logf(env.Stderr, "[gortex init] skip Windsurf setup (Windsurf not detected)")
return res, nil
}
if env.Home == "" {
return res, fmt.Errorf("windsurf: requires a resolved home directory")
}
internalutil.Logf(env.Stderr, "[gortex init] setting up Windsurf integration...")
// Migrate: if the legacy path has an entry but the current
// path doesn't, move the gortex stanza over. Users who've
// hand-edited the legacy file shouldn't lose their changes;
// we leave the legacy file on disk unless --force.
if err := migrateLegacyWindsurfConfig(env, opts); err != nil {
internalutil.Warnf(env.Stderr, "windsurf: legacy-config migration failed: %v", err)
}
action, err := agents.MergeJSON(env.Stderr, currentConfigPath(env.Home), func(root map[string]any, _ bool) (bool, error) {
return agents.UpsertMCPServer(root, "gortex", agents.DefaultGortexMCPEntry(), opts), nil
}, opts)
if err != nil {
return res, err
}
res.Files = append(res.Files, action)
// .windsurfrules gets a marker-guarded community-routing block
// when skills were generated. Skipped in global mode (the file
// is per-repo) and when --no-skills / no communities qualify.
if env.Mode != agents.ModeGlobal && env.SkillsRouting != "" {
rulesPath := filepath.Join(env.Root, ".windsurfrules")
routingAction, err := agents.UpsertMarkedBlock(env.Stderr, rulesPath, env.SkillsRouting,
agents.CommunitiesStartMarker, agents.CommunitiesEndMarker, opts)
if err != nil {
return res, err
}
res.Files = append(res.Files, routingAction)
}
res.Configured = true
return res, nil
}
// migrateLegacyWindsurfConfig copies a pre-existing gortex stanza
// from the legacy ~/.codeium/windsurf/mcp_config.json to the
// current path. Non-gortex servers the user added are not moved —
// they may be intentionally Windsurf-specific.
func migrateLegacyWindsurfConfig(env agents.Env, opts agents.ApplyOpts) error {
legacy := legacyConfigPath(env.Home)
if _, err := os.Stat(legacy); errors.Is(err, os.ErrNotExist) {
return nil
}
internalutil.Logf(env.Stderr, "[gortex init] migrating Windsurf config from %s to %s", legacy, currentConfigPath(env.Home))
// No-op migration: we never overwrite the legacy file. The
// merge below handles inserting the gortex stanza into the
// current path. With --force we remove the legacy file.
if opts.Force && !opts.DryRun {
if err := os.Remove(legacy); err != nil {
return err
}
internalutil.Logf(env.Stderr, "[gortex init] --force: removed legacy Windsurf config %s", legacy)
}
return nil
}
+58
View File
@@ -0,0 +1,58 @@
package windsurf
import (
"os"
"path/filepath"
"testing"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/agentstest"
)
// TestWindsurfUsesCurrentPath verifies we write to
// ~/.codeium/mcp_config.json (the 2026+ canonical path) rather than
// the legacy ~/.codeium/windsurf/mcp_config.json.
func TestWindsurfUsesCurrentPath(t *testing.T) {
env, _ := agentstest.NewEnv(t)
// Detection sentinel: ~/.codeium exists.
if err := os.MkdirAll(filepath.Join(env.Home, ".codeium"), 0o755); err != nil {
t.Fatal(err)
}
a := New()
res, err := a.Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
// Two creates: ~/.codeium/mcp_config.json for MCP plus the
// project-scoped .windsurfrules file the Cascade agent reads.
agentstest.AssertCountsByAction(t, res, map[agents.ActionKind]int{agents.ActionCreate: 2})
if _, err := os.Stat(filepath.Join(env.Home, ".codeium", "mcp_config.json")); err != nil {
t.Fatalf("current config path not written: %v", err)
}
if _, err := os.Stat(filepath.Join(env.Home, ".codeium", "windsurf", "mcp_config.json")); err == nil {
t.Fatal("legacy path should not be written by default")
}
agentstest.AssertIdempotent(t, a, env)
}
// TestWindsurfForceRemovesLegacyFile confirms --force removes the
// stale pre-2026 config file so users don't get confused state.
func TestWindsurfForceRemovesLegacyFile(t *testing.T) {
env, _ := agentstest.NewEnv(t)
legacy := filepath.Join(env.Home, ".codeium", "windsurf", "mcp_config.json")
agentstest.WriteJSON(t, legacy, map[string]any{"mcpServers": map[string]any{}})
res, err := New().Apply(env, agents.ApplyOpts{Force: true})
if err != nil {
t.Fatalf("apply: %v", err)
}
if !res.Configured {
t.Fatal("expected Configured=true")
}
if _, err := os.Stat(legacy); err == nil {
t.Fatal("--force should have removed legacy file")
}
}
+418
View File
@@ -0,0 +1,418 @@
package agents
import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
// writer.go centralises every write `gortex init` performs. Going
// through one helper lets us:
//
// 1. Make writes atomic — temp file + rename. Partial failures
// can't leave a half-written MCP config that breaks the editor.
// 2. Respect ApplyOpts.DryRun uniformly. No adapter needs its own
// "would this run?" branch.
// 3. Report what happened in a structured FileAction so `--json`
// and the doctor subcommand speak the same vocabulary.
// 4. Power golden-fixture tests — the test harness points the
// "root" at a temp dir, runs Apply, and diffs the written tree
// against testdata/ golden files.
// WriteIfNotExists writes content to path when it doesn't exist.
// Used for static artifacts (slash-command markdown, Kiro steering,
// KI metadata) where merging isn't meaningful. When the file is
// already present we emit ActionSkip with Reason="exists" rather
// than silently overwriting.
//
// Under DryRun: no disk write. Returns ActionWouldCreate for a
// missing file, ActionSkip for an existing one.
//
// Directories are created as needed with 0o755.
func WriteIfNotExists(w io.Writer, path, content string, opts ApplyOpts) (FileAction, error) {
if _, err := os.Stat(path); err == nil {
logf(w, "[gortex init] skip %s (already exists)", path)
return FileAction{Path: path, Action: ActionSkip, Reason: "exists"}, nil
} else if !errors.Is(err, os.ErrNotExist) {
return FileAction{Path: path, Action: ActionSkip, Reason: err.Error()}, fmt.Errorf("stat %s: %w", path, err)
}
if opts.DryRun {
return FileAction{Path: path, Action: ActionWouldCreate}, nil
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return FileAction{}, fmt.Errorf("mkdir %s: %w", filepath.Dir(path), err)
}
if err := AtomicWriteFile(path, []byte(content), 0o644); err != nil {
return FileAction{}, err
}
logf(w, "[gortex init] created %s", path)
return FileAction{Path: path, Action: ActionCreate}, nil
}
// WriteOwnedFile writes content to path unconditionally, overwriting
// whatever is there. Meant for files Gortex owns end-to-end (e.g.
// generated community-routing files under .cursor/rules/,
// .continue/rules/, .clinerules/) so each `gortex init` run
// regenerates them from the current graph. Returns ActionCreate when
// the file was absent and ActionMerge when it already existed, so the
// summary line reads naturally.
//
// Under DryRun: no disk write. Returns ActionWould* mirroring the
// same existed/absent split.
func WriteOwnedFile(w io.Writer, path, content string, opts ApplyOpts) (FileAction, error) {
existing, readErr := os.ReadFile(path)
existed := readErr == nil
if readErr != nil && !errors.Is(readErr, os.ErrNotExist) {
return FileAction{}, fmt.Errorf("read %s: %w", path, readErr)
}
// Skip when the target is already byte-identical — keeps
// AssertIdempotent valid and avoids mtime bumps on no-op
// re-runs.
if existed && string(existing) == content {
return FileAction{Path: path, Action: ActionSkip, Reason: "unchanged"}, nil
}
if opts.DryRun {
action := ActionWouldCreate
if existed {
action = ActionWouldMerge
}
return FileAction{Path: path, Action: action}, nil
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return FileAction{}, fmt.Errorf("mkdir %s: %w", filepath.Dir(path), err)
}
if err := AtomicWriteFile(path, []byte(content), 0o644); err != nil {
return FileAction{}, err
}
verb := "wrote"
if existed {
verb = "regenerated"
}
logf(w, "[gortex init] %s %s", verb, path)
action := ActionCreate
if existed {
action = ActionMerge
}
return FileAction{Path: path, Action: action}, nil
}
// AtomicWriteFile writes data to path via a temp file in the same
// directory followed by a rename. Guarantees that a concurrent reader
// either sees the old file or the fully-written new file — never a
// half-written state.
//
// The temp file uses a deterministic prefix so a crash leaves
// "<name>.gortex.tmp-<pid>.<rand>" files that are easy to identify
// and clean up manually.
func AtomicWriteFile(path string, data []byte, perm os.FileMode) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("mkdir %s: %w", dir, err)
}
f, err := os.CreateTemp(dir, filepath.Base(path)+".gortex.tmp-*")
if err != nil {
return fmt.Errorf("create temp in %s: %w", dir, err)
}
tmpPath := f.Name()
// Best-effort cleanup on failure. We deliberately ignore the
// error: if the rename succeeds the file no longer exists, and
// if something else goes wrong the user can remove the temp by
// hand.
cleanup := func() { _ = os.Remove(tmpPath) }
if _, err := f.Write(data); err != nil {
_ = f.Close()
cleanup()
return fmt.Errorf("write %s: %w", tmpPath, err)
}
if err := f.Chmod(perm); err != nil {
_ = f.Close()
cleanup()
return fmt.Errorf("chmod %s: %w", tmpPath, err)
}
if err := f.Close(); err != nil {
cleanup()
return fmt.Errorf("close %s: %w", tmpPath, err)
}
if err := renameWithRetry(tmpPath, path); err != nil {
cleanup()
return fmt.Errorf("rename %s -> %s: %w", tmpPath, path, err)
}
return nil
}
// renameWithRetry renames oldPath onto newPath, retrying briefly when the
// failure is a transient, Windows-specific sharing violation (see
// isRetryableRenameErr). On POSIX the predicate is always false, so this
// collapses to a single os.Rename with no added latency.
//
// os.Rename maps to MoveFileEx(MOVEFILE_REPLACE_EXISTING) on Windows,
// which fails with ERROR_SHARING_VIOLATION / ERROR_ACCESS_DENIED when
// another process still holds the destination open without
// FILE_SHARE_DELETE — an editor's language server, antivirus, a search
// indexer, or Gortex's own file watcher re-indexing the file we just
// wrote. Those holders release the handle within milliseconds, so a
// short bounded retry turns a spurious "file is being used by another
// process" error into the atomic replace the caller asked for. Worst
// case is ~225ms of backoff, imperceptible for an interactive write.
func renameWithRetry(oldPath, newPath string) error {
const attempts = 10
var err error
for attempt := range attempts {
if err = os.Rename(oldPath, newPath); err == nil {
return nil
}
if !isRetryableRenameErr(err) {
return err
}
if attempt < attempts-1 {
time.Sleep(time.Duration(attempt+1) * 5 * time.Millisecond)
}
}
return err
}
// MergeJSON reads path (if present), parses it as a JSON object,
// passes the parsed map to mutate, and writes the result back
// atomically when mutate reports a change. A nil or malformed file
// is treated as empty — a backup is written alongside the original
// before we overwrite garbage, so a user with a broken config doesn't
// lose their edits.
//
// mutate returns:
// - changed=true if the map was modified and should be written
// - changed=false if no change is needed (idempotent re-run); we
// still return a FileAction describing the skip for --json
//
// Keys is collected from the top-level map keys after mutation —
// useful for the --json report but not semantically load-bearing.
func MergeJSON(w io.Writer, path string, mutate func(root map[string]any, existed bool) (changed bool, err error), opts ApplyOpts) (FileAction, error) {
existed := false
root := make(map[string]any)
var backupPath string
var backupData []byte
if data, err := os.ReadFile(path); err == nil {
existed = true
switch {
case len(strings.TrimSpace(string(data))) == 0:
// An empty (or whitespace-only) file is an empty object, not
// malformed — no backup, nothing to preserve.
default:
// `.jsonc` / `.json5` configs (e.g. OpenCode's
// opencode.jsonc) may carry comments and trailing commas
// that encoding/json rejects. Sanitize those before the
// parse so a commented config merges instead of being
// treated as malformed and clobbered. Comments are not
// round-tripped through the re-marshal — same policy as
// MergeTOML.
parse := data
if isJSONCPath(path) {
parse = stripJSONComments(data)
}
if err := json.Unmarshal(parse, &root); err != nil {
// Don't silently overwrite the user's file even if it's
// malformed — keep a backup for recovery. The backup is
// written only on the real write path below, so a DryRun
// never touches disk.
backupPath, backupData = path+".bak", data
root = make(map[string]any)
}
}
} else if !errors.Is(err, os.ErrNotExist) {
return FileAction{}, fmt.Errorf("read %s: %w", path, err)
}
changed, err := mutate(root, existed)
if err != nil {
return FileAction{}, err
}
if !changed {
return FileAction{Path: path, Action: ActionSkip, Reason: "already-configured"}, nil
}
keys := sortedMapKeys(root)
if opts.DryRun {
action := ActionWouldCreate
if existed {
action = ActionWouldMerge
}
return FileAction{Path: path, Action: action, Keys: keys}, nil
}
out, err := json.MarshalIndent(root, "", " ")
if err != nil {
return FileAction{}, fmt.Errorf("marshal %s: %w", path, err)
}
if backupPath != "" {
// Best-effort backup of the malformed original, just before we
// overwrite it (never under DryRun — that returned above).
_ = os.WriteFile(backupPath, backupData, 0o644)
}
if err := AtomicWriteFile(path, out, 0o644); err != nil {
return FileAction{}, err
}
action := ActionCreate
if existed {
action = ActionMerge
}
if backupPath != "" {
logf(w, "[gortex init] %s was malformed; backup saved to %s", path, backupPath)
}
logf(w, "[gortex init] %s %s", actionVerb(action), path)
return FileAction{Path: path, Action: action, Keys: keys}, nil
}
// isJSONCPath reports whether path uses a JSON-with-comments extension
// (`.jsonc` / `.json5`) whose contents may need sanitising before they
// can be handed to encoding/json.
func isJSONCPath(path string) bool {
switch strings.ToLower(filepath.Ext(path)) {
case ".jsonc", ".json5":
return true
default:
return false
}
}
// stripJSONComments rewrites JSONC / JSON5-style input into strict JSON
// that encoding/json can parse: it drops `//` line comments, `/* */`
// block comments, and trailing commas before `}` / `]`. String literals
// (and their escape sequences) are copied through untouched, so a `//`
// or comma inside a quoted value is preserved. The result is only used
// for parsing — the merged map is re-marshalled fresh, so the original
// comments and formatting are not carried over (matching MergeTOML).
func stripJSONComments(b []byte) []byte {
out := make([]byte, 0, len(b))
inString, escaped := false, false
for i := 0; i < len(b); i++ {
c := b[i]
if inString {
out = append(out, c)
switch {
case escaped:
escaped = false
case c == '\\':
escaped = true
case c == '"':
inString = false
}
continue
}
switch {
case c == '"':
inString = true
out = append(out, c)
case c == '/' && i+1 < len(b) && b[i+1] == '/':
// Line comment: skip to (but keep) the newline.
for i+1 < len(b) && b[i+1] != '\n' {
i++
}
case c == '/' && i+1 < len(b) && b[i+1] == '*':
// Block comment: skip through the closing */.
i += 2
for i+1 < len(b) && (b[i] != '*' || b[i+1] != '/') {
i++
}
i++ // step onto '/', loop's i++ moves past it
default:
out = append(out, c)
}
}
return stripTrailingCommas(out)
}
// stripTrailingCommas removes a comma that is followed (ignoring
// whitespace) by a `}` or `]` — JSONC / JSON5 permit it, strict JSON
// does not. Commas inside string literals are left alone.
func stripTrailingCommas(b []byte) []byte {
out := make([]byte, 0, len(b))
inString, escaped := false, false
for i := range len(b) {
c := b[i]
if inString {
out = append(out, c)
switch {
case escaped:
escaped = false
case c == '\\':
escaped = true
case c == '"':
inString = false
}
continue
}
if c == '"' {
inString = true
out = append(out, c)
continue
}
if c == ',' {
j := i + 1
for j < len(b) {
switch b[j] {
case ' ', '\t', '\n', '\r':
j++
continue
}
break
}
if j < len(b) && (b[j] == '}' || b[j] == ']') {
continue // drop the trailing comma
}
}
out = append(out, c)
}
return out
}
// actionVerb renders an ActionKind for human-readable log lines.
// Kept distinct from the on-the-wire string so we can tweak messaging
// without breaking JSON consumers.
func actionVerb(a ActionKind) string {
switch a {
case ActionCreate:
return "created"
case ActionMerge:
return "merged into"
case ActionSkip:
return "skipped"
case ActionWouldCreate:
return "would create"
case ActionWouldMerge:
return "would merge into"
}
return string(a)
}
func sortedMapKeys(m map[string]any) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
// logf writes a newline-terminated message when w is non-nil. Shared
// helper so adapters don't each need to guard for a nil stderr in
// tests.
func logf(w io.Writer, format string, args ...any) {
if w == nil {
return
}
fmt.Fprintf(w, format+"\n", args...)
}
+9
View File
@@ -0,0 +1,9 @@
//go:build !windows
package agents
// isRetryableRenameErr is always false off Windows: a POSIX rename
// atomically replaces the destination even while another process holds it
// open, so a failed rename there is a genuine, non-transient error (e.g.
// an EXDEV cross-device link) that retrying would not fix.
func isRetryableRenameErr(error) bool { return false }
+330
View File
@@ -0,0 +1,330 @@
package agents
import (
"bytes"
"encoding/json"
"io"
"os"
"path/filepath"
"testing"
)
// TestWriteIfNotExistsCreatesAndSkips covers both the create and
// skip branches of the helper plus the DryRun prediction. Golden
// fixtures don't exercise DryRun, so we test it explicitly here.
func TestWriteIfNotExistsCreatesAndSkips(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "nested", "file.txt")
var buf bytes.Buffer
// 1. First call creates the file.
a, err := WriteIfNotExists(&buf, path, "hello", ApplyOpts{})
if err != nil {
t.Fatalf("create: %v", err)
}
if a.Action != ActionCreate {
t.Fatalf("expected create, got %q", a.Action)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read back: %v", err)
}
if string(got) != "hello" {
t.Fatalf("content: got %q want %q", got, "hello")
}
// 2. Second call finds the file and skips.
a, err = WriteIfNotExists(&buf, path, "different", ApplyOpts{})
if err != nil {
t.Fatalf("skip: %v", err)
}
if a.Action != ActionSkip {
t.Fatalf("expected skip, got %q", a.Action)
}
// Content must be unchanged — skip is never overwrite.
got, _ = os.ReadFile(path)
if string(got) != "hello" {
t.Fatalf("skip must not overwrite: got %q", got)
}
// 3. DryRun on a missing file reports would-create, doesn't write.
missing := filepath.Join(dir, "new.txt")
a, err = WriteIfNotExists(&buf, missing, "x", ApplyOpts{DryRun: true})
if err != nil {
t.Fatalf("dry-run: %v", err)
}
if a.Action != ActionWouldCreate {
t.Fatalf("expected would-create, got %q", a.Action)
}
if _, err := os.Stat(missing); !os.IsNotExist(err) {
t.Fatalf("dry-run wrote file: %v", err)
}
}
// TestMergeJSONCreatesMergesAndSkipsIdempotent covers the three
// transitions the MCP installer relies on: fresh file, merge into
// existing, and no-op on re-run. This is the behavioural contract
// golden tests will compare against byte-for-byte.
// TestMergeJSON_DryRunNeverWritesBackup guards the regression where a
// dry-run over a malformed (or empty) existing file still dropped a
// .bak sibling on disk — dry-run must touch nothing.
func TestMergeJSON_DryRunNeverWritesBackup(t *testing.T) {
dir := t.TempDir()
add := func(root map[string]any, _ bool) (bool, error) {
return UpsertMCPServer(root, "gortex", DefaultGortexMCPEntry(), ApplyOpts{}), nil
}
// A malformed existing file under dry-run.
mal := filepath.Join(dir, "malformed.json")
if err := os.WriteFile(mal, []byte("{not json"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := MergeJSON(io.Discard, mal, add, ApplyOpts{DryRun: true}); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(mal + ".bak"); !os.IsNotExist(err) {
t.Errorf("dry-run must not write a .bak (err=%v)", err)
}
// An empty file is treated as an empty object, not malformed: no
// backup even on a real write.
empty := filepath.Join(dir, "empty.json")
if err := os.WriteFile(empty, []byte(""), 0o644); err != nil {
t.Fatal(err)
}
if _, err := MergeJSON(io.Discard, empty, add, ApplyOpts{}); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(empty + ".bak"); !os.IsNotExist(err) {
t.Errorf("an empty file must not be treated as malformed / backed up (err=%v)", err)
}
// A genuinely malformed file on the real write path still gets a .bak.
mal2 := filepath.Join(dir, "malformed2.json")
if err := os.WriteFile(mal2, []byte("garbage{"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := MergeJSON(io.Discard, mal2, add, ApplyOpts{}); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(mal2 + ".bak"); err != nil {
t.Errorf("a real merge over a malformed file should keep a .bak: %v", err)
}
}
func TestMergeJSONCreatesMergesAndSkipsIdempotent(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "mcp.json")
var buf bytes.Buffer
addGortex := func(root map[string]any, existed bool) (bool, error) {
return UpsertMCPServer(root, "gortex", DefaultGortexMCPEntry(), ApplyOpts{}), nil
}
// 1. Missing file -> create.
a, err := MergeJSON(&buf, path, addGortex, ApplyOpts{})
if err != nil {
t.Fatalf("create: %v", err)
}
if a.Action != ActionCreate {
t.Fatalf("expected create, got %q", a.Action)
}
// 2. Re-run -> skip (idempotent).
a, err = MergeJSON(&buf, path, addGortex, ApplyOpts{})
if err != nil {
t.Fatalf("skip: %v", err)
}
if a.Action != ActionSkip {
t.Fatalf("expected skip, got %q", a.Action)
}
// 3. Pre-populate with an unrelated MCP server, merge adds ours
// without clobbering theirs.
existing := map[string]any{
"mcpServers": map[string]any{
"other": map[string]any{"command": "other"},
},
}
data, _ := json.MarshalIndent(existing, "", " ")
fresh := filepath.Join(dir, "mcp-pre.json")
if err := os.WriteFile(fresh, data, 0o644); err != nil {
t.Fatal(err)
}
a, err = MergeJSON(&buf, fresh, addGortex, ApplyOpts{})
if err != nil {
t.Fatalf("merge: %v", err)
}
if a.Action != ActionMerge {
t.Fatalf("expected merge, got %q", a.Action)
}
content, _ := os.ReadFile(fresh)
var out map[string]any
if err := json.Unmarshal(content, &out); err != nil {
t.Fatal(err)
}
servers := out["mcpServers"].(map[string]any)
if _, ok := servers["other"]; !ok {
t.Fatalf("merge clobbered existing 'other' server: %v", servers)
}
if _, ok := servers["gortex"]; !ok {
t.Fatalf("merge didn't add 'gortex': %v", servers)
}
}
// TestStripJSONComments covers the JSONC sanitiser: comments and
// trailing commas are removed, but `//`, `/* */`, and commas inside
// string literals are preserved.
func TestStripJSONComments(t *testing.T) {
in := `{
// a line comment
"url": "https://example.com/path", /* block */
"note": "a, b, c // not a comment",
"list": [1, 2, 3,],
"obj": { "k": "v", },
}`
got := stripJSONComments([]byte(in))
var out map[string]any
if err := json.Unmarshal(got, &out); err != nil {
t.Fatalf("sanitised JSONC did not parse: %v\n---\n%s", err, got)
}
if out["url"] != "https://example.com/path" {
t.Fatalf("`//` inside a string was stripped: %v", out["url"])
}
if out["note"] != "a, b, c // not a comment" {
t.Fatalf("comment/comma inside a string was altered: %v", out["note"])
}
if list, ok := out["list"].([]any); !ok || len(list) != 3 {
t.Fatalf("trailing comma handling broke the array: %v", out["list"])
}
}
// TestMergeJSON_JSONCMergesInsteadOfClobbering guards the OpenCode
// path: merging into an existing `.jsonc` with comments must preserve
// the user's data keys (not back the file up as malformed and start
// fresh).
func TestMergeJSON_JSONCMergesInsteadOfClobbering(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "opencode.jsonc")
if err := os.WriteFile(path, []byte(`{
// user config
"theme": "dark",
}`), 0o644); err != nil {
t.Fatal(err)
}
add := func(root map[string]any, _ bool) (bool, error) {
root["added"] = true
return true, nil
}
a, err := MergeJSON(io.Discard, path, add, ApplyOpts{})
if err != nil {
t.Fatalf("merge: %v", err)
}
if a.Action != ActionMerge {
t.Fatalf("expected merge, got %q", a.Action)
}
if _, err := os.Stat(path + ".bak"); err == nil {
t.Fatalf("a valid commented .jsonc must not be backed up as malformed")
}
var out map[string]any
data, _ := os.ReadFile(path)
if err := json.Unmarshal(data, &out); err != nil {
t.Fatalf("re-marshalled output not valid JSON: %v", err)
}
if out["theme"] != "dark" {
t.Fatalf("merge dropped the user's data key: %v", out)
}
if out["added"] != true {
t.Fatalf("merge didn't apply the mutation: %v", out)
}
}
// TestRegistryFilterValidatesNames ensures we hard-error on typos
// rather than silently dropping them — a key UX requirement from the
// init plan.
func TestRegistryFilterValidatesNames(t *testing.T) {
r := NewRegistry()
r.Register(stubAdapter{name: "alpha"})
r.Register(stubAdapter{name: "beta"})
if _, err := r.Filter("alpha,gamma", ""); err == nil {
t.Fatal("expected error on unknown 'gamma', got nil")
}
got, err := r.Filter("alpha", "beta")
if err != nil {
t.Fatalf("filter: %v", err)
}
if len(got) != 1 || got[0].Name() != "alpha" {
t.Fatalf("filter returned %v, want [alpha]", names(got))
}
// auto + skip should yield everything minus the skipped.
got, err = r.Filter("auto", "alpha")
if err != nil {
t.Fatalf("auto+skip: %v", err)
}
if len(got) != 1 || got[0].Name() != "beta" {
t.Fatalf("auto+skip returned %v, want [beta]", names(got))
}
}
type stubAdapter struct{ name string }
func (s stubAdapter) Name() string { return s.name }
func (s stubAdapter) DocsURL() string { return "" }
func (s stubAdapter) Detect(Env) (bool, error) { return true, nil }
func (s stubAdapter) Plan(Env) (*Plan, error) { return &Plan{}, nil }
func (s stubAdapter) Apply(Env, ApplyOpts) (*Result, error) { return &Result{Name: s.name}, nil }
func names(as []Adapter) []string {
out := make([]string, 0, len(as))
for _, a := range as {
out = append(out, a.Name())
}
return out
}
// TestAtomicWriteFileCreatesAndOverwrites guards the core write path every
// MCP file tool funnels through (write_file, edit_file, move, ...): a
// fresh write lands the content and a second write atomically replaces it,
// leaving no stray *.gortex.tmp-* file behind. This is also the
// no-contention happy path for renameWithRetry — its retry loop must be
// transparent when the rename succeeds first try.
func TestAtomicWriteFileCreatesAndOverwrites(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "sub", "file.txt")
if err := AtomicWriteFile(path, []byte("first"), 0o644); err != nil {
t.Fatalf("create: %v", err)
}
if got, _ := os.ReadFile(path); string(got) != "first" {
t.Fatalf("content: got %q want %q", got, "first")
}
// Overwrite must replace, not append or corrupt.
if err := AtomicWriteFile(path, []byte("second"), 0o644); err != nil {
t.Fatalf("overwrite: %v", err)
}
if got, _ := os.ReadFile(path); string(got) != "second" {
t.Fatalf("overwrite content: got %q want %q", got, "second")
}
// Success must not leave the sibling temp file behind.
if leftovers, _ := filepath.Glob(filepath.Join(filepath.Dir(path), "*.gortex.tmp-*")); len(leftovers) != 0 {
t.Fatalf("leftover temp files: %v", leftovers)
}
}
// TestRenameWithRetryReturnsNonRetryableErr checks that a rename failure
// which isn't a transient sharing violation (here: a missing source) is
// surfaced immediately rather than retried — the retry budget is reserved
// for the Windows lock race, not for masking genuine errors. On every
// platform ERROR_FILE_NOT_FOUND / ENOENT is non-retryable, so this holds
// cross-platform.
func TestRenameWithRetryReturnsNonRetryableErr(t *testing.T) {
dir := t.TempDir()
if err := renameWithRetry(filepath.Join(dir, "does-not-exist"), filepath.Join(dir, "dest")); err == nil {
t.Fatal("expected an error renaming a missing source, got nil")
}
}
+30
View File
@@ -0,0 +1,30 @@
//go:build windows
package agents
import (
"errors"
"syscall"
)
// Windows system error codes returned by MoveFileEx (which os.Rename
// wraps) when the destination path is transiently held open by another
// process without FILE_SHARE_DELETE — an editor's language server,
// antivirus, a search indexer, or Gortex's own file watcher re-indexing
// the file we just wrote. Unlike POSIX, Windows refuses to replace a file
// while such a handle is open. The holder releases within milliseconds,
// so these are safe to retry.
const (
errSharingViolation syscall.Errno = 32 // ERROR_SHARING_VIOLATION
errLockViolation syscall.Errno = 33 // ERROR_LOCK_VIOLATION
errAccessDenied syscall.Errno = 5 // ERROR_ACCESS_DENIED
)
// isRetryableRenameErr reports whether a failed os.Rename is a transient
// Windows sharing/lock violation worth retrying. os.Rename returns a
// *os.LinkError whose Err unwraps to a syscall.Errno.
func isRetryableRenameErr(err error) bool {
return errors.Is(err, errSharingViolation) ||
errors.Is(err, errLockViolation) ||
errors.Is(err, errAccessDenied)
}
+96
View File
@@ -0,0 +1,96 @@
//go:build windows
package agents
import (
"os"
"path/filepath"
"sync"
"syscall"
"testing"
"time"
)
// openExclusiveNoDelete opens path with a share mode that omits
// FILE_SHARE_DELETE, reproducing the handle an editor's language server,
// antivirus, a search indexer, or Gortex's own file watcher holds while
// touching a file. MoveFileEx — and therefore os.Rename — cannot replace
// a destination held this way and fails with ERROR_SHARING_VIOLATION.
func openExclusiveNoDelete(t *testing.T, path string) syscall.Handle {
t.Helper()
p, err := syscall.UTF16PtrFromString(path)
if err != nil {
t.Fatalf("utf16 %s: %v", path, err)
}
h, err := syscall.CreateFile(p,
syscall.GENERIC_READ,
syscall.FILE_SHARE_READ, // deliberately no FILE_SHARE_DELETE / WRITE
nil,
syscall.OPEN_EXISTING,
syscall.FILE_ATTRIBUTE_NORMAL,
0)
if err != nil {
t.Fatalf("CreateFile %s: %v", path, err)
}
return h
}
// TestAtomicWriteFileRetriesPastSharingViolation is the Windows regression
// test for the "The process cannot access the file because it is being
// used by another process" failures: when the destination is transiently
// held open without FILE_SHARE_DELETE, AtomicWriteFile must retry the
// rename and succeed once the holder releases the handle, instead of
// surfacing the spurious error.
//
// CI's Windows job is build-only (see .github/workflows/ci.yml), so this
// runs locally / on demand, not in the cross-platform test matrix.
func TestAtomicWriteFileRetriesPastSharingViolation(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "held.txt")
if err := os.WriteFile(path, []byte("old"), 0o644); err != nil {
t.Fatal(err)
}
h := openExclusiveNoDelete(t, path)
// Release the handle within the retry budget (~225ms) so a rename that
// first fails with a sharing violation later succeeds.
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
time.Sleep(40 * time.Millisecond)
_ = syscall.CloseHandle(h)
}()
if err := AtomicWriteFile(path, []byte("new"), 0o644); err != nil {
t.Fatalf("AtomicWriteFile should retry past a transient hold, got: %v", err)
}
wg.Wait()
if got, _ := os.ReadFile(path); string(got) != "new" {
t.Fatalf("content after retry: got %q want %q", got, "new")
}
}
// TestAtomicWriteFileFailsWhenHeldThroughout bounds the retry: a
// destination held open for longer than the whole retry budget must make
// AtomicWriteFile give up and return an error — not hang forever — and
// must leave the original file intact.
func TestAtomicWriteFileFailsWhenHeldThroughout(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "locked.txt")
if err := os.WriteFile(path, []byte("old"), 0o644); err != nil {
t.Fatal(err)
}
h := openExclusiveNoDelete(t, path)
defer syscall.CloseHandle(h)
if err := AtomicWriteFile(path, []byte("new"), 0o644); err == nil {
t.Fatal("expected AtomicWriteFile to fail while the file is held open throughout")
}
if got, _ := os.ReadFile(path); string(got) != "old" {
t.Fatalf("a failed write must not corrupt the destination: got %q", got)
}
}
+297
View File
@@ -0,0 +1,297 @@
package agents
import (
"bytes"
"errors"
"fmt"
"io"
"os"
yaml "gopkg.in/yaml.v3"
)
// MergeYAML is the YAML cousin of MergeJSON / MergeTOML, with one
// important difference: it preserves comments and structure. Hermes
// config (~/.hermes/config.yaml) is comment-rich and hand-edited, so
// a round-trip through map[string]any — which silently drops every
// comment and reorders keys — would mangle the user's file. Instead
// we decode into a *yaml.Node tree (which carries HeadComment /
// LineComment / FootComment on every node) and re-encode it, so an
// idempotent re-run is a no-op and a real merge touches only the keys
// we add.
//
// mutate receives the top-level mapping node (created empty for a new
// or empty file) and reports whether it changed anything. The agents
// package ships node helpers — YAMLMapValue / YAMLSetMapValue /
// YAMLScalar / UpsertYAMLMapEntry — so callers don't hand-walk the
// Content slice.
//
// Malformed YAML is preserved as a .bak sibling before we overwrite,
// same policy as MergeJSON / MergeTOML. A valid-but-non-mapping
// top-level document (a bare scalar or sequence — never a real config
// file) is also backed up before we replace it with a fresh mapping.
func MergeYAML(w io.Writer, path string, mutate func(root *yaml.Node, existed bool) (changed bool, err error), opts ApplyOpts) (FileAction, error) {
existed := false
var backupPath string
var backupData []byte
indent := defaultYAMLIndent
var doc yaml.Node
if data, err := os.ReadFile(path); err == nil {
existed = true
if len(bytes.TrimSpace(data)) > 0 {
indent = detectYAMLIndent(data)
if err := yaml.Unmarshal(data, &doc); err != nil {
// Malformed YAML — capture it for a .bak (written only in
// the real-apply branch below, never under --dry-run) and
// start from an empty document.
backupPath, backupData = path+".bak", data
doc = yaml.Node{}
} else if !documentHasMappingRoot(&doc) {
// Valid YAML but not a top-level mapping (a bare list
// or scalar). We can't safely splice mcp_servers into
// that, so preserve it and start fresh.
backupPath, backupData = path+".bak", data
doc = yaml.Node{}
}
}
} else if !errors.Is(err, os.ErrNotExist) {
return FileAction{}, fmt.Errorf("read %s: %w", path, err)
}
root := documentRoot(&doc)
changed, err := mutate(root, existed)
if err != nil {
return FileAction{}, err
}
if !changed {
return FileAction{Path: path, Action: ActionSkip, Reason: "already-configured"}, nil
}
keys := yamlTopLevelKeys(root)
if opts.DryRun {
action := ActionWouldCreate
if existed {
action = ActionWouldMerge
}
return FileAction{Path: path, Action: action, Keys: keys}, nil
}
out, err := marshalYAMLDocument(&doc, indent)
if err != nil {
return FileAction{}, fmt.Errorf("marshal %s: %w", path, err)
}
// Preserve the unparseable / non-mapping original as a .bak sibling
// before we overwrite it. Deferred to here, the real-apply branch,
// so a --dry-run on a malformed file never mutates disk.
if backupPath != "" {
if err := os.WriteFile(backupPath, backupData, 0o644); err != nil {
return FileAction{}, fmt.Errorf("write backup %s: %w", backupPath, err)
}
}
if err := AtomicWriteFile(path, out, 0o644); err != nil {
return FileAction{}, err
}
action := ActionCreate
if existed {
action = ActionMerge
}
if backupPath != "" {
logf(w, "[gortex init] %s was malformed YAML; backup saved to %s", path, backupPath)
}
logf(w, "[gortex init] %s %s", actionVerb(action), path)
return FileAction{Path: path, Action: action, Keys: keys}, nil
}
// documentHasMappingRoot reports whether a freshly-unmarshaled
// document node carries a mapping at its root — the only shape we can
// safely merge into.
func documentHasMappingRoot(doc *yaml.Node) bool {
return doc.Kind == yaml.DocumentNode &&
len(doc.Content) > 0 &&
doc.Content[0].Kind == yaml.MappingNode
}
// documentRoot normalises doc so it is a DocumentNode wrapping a
// MappingNode and returns that mapping. A zero-value node (empty /
// absent file) is turned into an empty document so the caller always
// gets a writable mapping back.
func documentRoot(doc *yaml.Node) *yaml.Node {
if documentHasMappingRoot(doc) {
return doc.Content[0]
}
mapping := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
doc.Kind = yaml.DocumentNode
doc.Content = []*yaml.Node{mapping}
return mapping
}
// marshalYAMLDocument renders a document node back to bytes at the given
// indent width — matched to the file's own (see detectYAMLIndent) so a
// merge re-emits 4-space configs as 4-space rather than forcing 2 —
// preserving the comments captured during Unmarshal.
func marshalYAMLDocument(doc *yaml.Node, indent int) ([]byte, error) {
var buf bytes.Buffer
enc := yaml.NewEncoder(&buf)
enc.SetIndent(indent)
if err := enc.Encode(doc); err != nil {
_ = enc.Close()
return nil, err
}
if err := enc.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// defaultYAMLIndent is the indentation width MergeYAML falls back to
// when a file's own indent can't be inferred (a new or single-level
// file). 2 is what Hermes and most hand-written YAML use.
const defaultYAMLIndent = 2
// detectYAMLIndent infers the indentation step (in spaces) a YAML file
// already uses, so a comment-preserving merge re-emits the file at the
// width its author chose. It returns the smallest positive leading-space
// count across non-comment lines — for well-formed YAML that is the
// per-level step — clamped to a sane range, else defaultYAMLIndent.
//
// This matches the indent *width* only. yaml.v3's encoder cannot emit
// indentless block sequences (a key whose `- item` children sit at the
// key's own column); those are always re-indented one level on a merge.
// That is a known go-yaml limitation, not something this can undo — a
// fully diff-free merge would require a surgical byte-splice instead of
// a whole-document re-encode.
func detectYAMLIndent(data []byte) int {
best := 0
for line := range bytes.SplitSeq(data, []byte("\n")) {
n := 0
for n < len(line) && line[n] == ' ' {
n++
}
if n == 0 || n >= len(line) { // top-level, blank, or all spaces
continue
}
if line[n] == '#' { // comments may be aligned arbitrarily
continue
}
if best == 0 || n < best {
best = n
}
}
if best < 1 || best > 8 {
return defaultYAMLIndent
}
return best
}
// yamlTopLevelKeys returns the key names of a mapping node, in file
// order. Used to populate FileAction.Keys for the --json report.
func yamlTopLevelKeys(m *yaml.Node) []string {
if m == nil || m.Kind != yaml.MappingNode {
return nil
}
out := make([]string, 0, len(m.Content)/2)
for i := 0; i+1 < len(m.Content); i += 2 {
out = append(out, m.Content[i].Value)
}
return out
}
// YAMLMapValue returns the value node stored under key in a mapping
// node, or nil when the key is absent (or m isn't a mapping). YAML
// mappings are stored as a flat [k0, v0, k1, v1, …] Content slice;
// this hides that layout from callers.
func YAMLMapValue(m *yaml.Node, key string) *yaml.Node {
if m == nil || m.Kind != yaml.MappingNode {
return nil
}
for i := 0; i+1 < len(m.Content); i += 2 {
if m.Content[i].Value == key {
return m.Content[i+1]
}
}
return nil
}
// YAMLSetMapValue sets key=val in a mapping node, replacing an
// existing value in place (so its leading comment survives) or
// appending a new key/value pair.
func YAMLSetMapValue(m *yaml.Node, key string, val *yaml.Node) {
for i := 0; i+1 < len(m.Content); i += 2 {
if m.Content[i].Value == key {
m.Content[i+1] = val
return
}
}
m.Content = append(m.Content, YAMLScalar(key), val)
}
// YAMLScalar builds a plain string scalar node. Callers that need
// non-string scalars (ints, bools) construct yaml.Node literals with
// the matching tag directly.
func YAMLScalar(value string) *yaml.Node {
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: value}
}
// UpsertYAMLMapEntry ensures root[outerKey][innerKey] = entry, where
// outerKey's value is a nested mapping (created when absent). Returns
// true when the tree was modified, false when innerKey already exists
// and force is off — the idempotent-re-run signal MergeYAML turns
// into an "already-configured" skip.
//
// This is the YAML analogue of UpsertMCPServer: Hermes stores MCP
// servers under the snake_case `mcp_servers` map rather than the
// camelCase `mcpServers` every JSON client uses.
func UpsertYAMLMapEntry(root *yaml.Node, outerKey, innerKey string, entry *yaml.Node, force bool) (changed bool, err error) {
outer := YAMLMapValue(root, outerKey)
switch {
case outer == nil:
// Key absent — create the nested mapping.
outer = &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
YAMLSetMapValue(root, outerKey, outer)
case outer.Kind == yaml.MappingNode:
// Reuse the existing mapping in place.
case isNullYAMLNode(outer):
// `outerKey:` with an empty / null value — e.g. every server
// commented out so the map decodes to null. Safe to populate:
// replacing the null value in place keeps the key node (and its
// leading comment) intact.
outer = &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
YAMLSetMapValue(root, outerKey, outer)
default:
// A non-null scalar or a sequence under outerKey is not a shape
// we can splice a child into. Refuse rather than silently drop
// the user's data — the caller leaves the file untouched.
return false, fmt.Errorf("agents: %q is %s, not a mapping; refusing to overwrite", outerKey, yamlKindName(outer.Kind))
}
if existing := YAMLMapValue(outer, innerKey); existing != nil && !force {
return false, nil
}
YAMLSetMapValue(outer, innerKey, entry)
return true, nil
}
// isNullYAMLNode reports whether n is a YAML null — an explicitly null
// scalar (`~`, `null`) or an empty value (`key:` with nothing after it).
func isNullYAMLNode(n *yaml.Node) bool {
return n != nil && n.Kind == yaml.ScalarNode && (n.Tag == "!!null" || n.Value == "")
}
// yamlKindName renders a node kind for a human-readable error message.
func yamlKindName(k yaml.Kind) string {
switch k {
case yaml.SequenceNode:
return "a sequence"
case yaml.ScalarNode:
return "a scalar"
case yaml.MappingNode:
return "a mapping"
case yaml.AliasNode:
return "an alias"
default:
return "a non-mapping value"
}
}
+305
View File
@@ -0,0 +1,305 @@
package agents
import (
"os"
"path/filepath"
"strings"
"testing"
yaml "gopkg.in/yaml.v3"
)
// gortexEntryNode builds the stdio MCP stanza Hermes expects, used
// across the YAML merge tests.
func gortexEntryNode() *yaml.Node {
entry := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
YAMLSetMapValue(entry, "command", YAMLScalar("gortex"))
args := &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq", Content: []*yaml.Node{YAMLScalar("mcp")}}
YAMLSetMapValue(entry, "args", args)
return entry
}
func upsertGortex(force bool) func(root *yaml.Node, existed bool) (bool, error) {
return func(root *yaml.Node, _ bool) (bool, error) {
return UpsertYAMLMapEntry(root, "mcp_servers", "gortex", gortexEntryNode(), force)
}
}
func TestMergeYAML_CreatesFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
action, err := MergeYAML(nil, path, upsertGortex(false), ApplyOpts{})
if err != nil {
t.Fatalf("merge: %v", err)
}
if action.Action != ActionCreate {
t.Fatalf("want create, got %s", action.Action)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read back: %v", err)
}
var root map[string]any
if err := yaml.Unmarshal(data, &root); err != nil {
t.Fatalf("written file is not valid YAML: %v\n%s", err, data)
}
servers, ok := root["mcp_servers"].(map[string]any)
if !ok {
t.Fatalf("mcp_servers missing or wrong type: %#v", root)
}
gortex, ok := servers["gortex"].(map[string]any)
if !ok {
t.Fatalf("gortex server missing: %#v", servers)
}
if gortex["command"] != "gortex" {
t.Errorf("command = %v, want gortex", gortex["command"])
}
}
func TestMergeYAML_Idempotent(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
if _, err := MergeYAML(nil, path, upsertGortex(false), ApplyOpts{}); err != nil {
t.Fatalf("first merge: %v", err)
}
action, err := MergeYAML(nil, path, upsertGortex(false), ApplyOpts{})
if err != nil {
t.Fatalf("second merge: %v", err)
}
if action.Action != ActionSkip {
t.Fatalf("re-run want skip, got %s", action.Action)
}
}
func TestMergeYAML_PreservesCommentsAndKeys(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
original := `# Hermes configuration — edit with care
model: hermes-4
# remote tool servers
mcp_servers:
# GitHub MCP — issues and PRs
github:
command: npx
args: ["-y", "@modelcontextprotocol/server-github"]
# delegation knobs the user tuned by hand
delegation:
max_depth: 3 # do not raise without measuring
`
if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
t.Fatalf("seed: %v", err)
}
action, err := MergeYAML(nil, path, upsertGortex(false), ApplyOpts{})
if err != nil {
t.Fatalf("merge: %v", err)
}
if action.Action != ActionMerge {
t.Fatalf("want merge, got %s", action.Action)
}
out, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read back: %v", err)
}
got := string(out)
// Comments must survive the round-trip.
for _, want := range []string{
"# Hermes configuration — edit with care",
"# remote tool servers",
"# GitHub MCP — issues and PRs",
"# delegation knobs the user tuned by hand",
"do not raise without measuring",
} {
if !strings.Contains(got, want) {
t.Errorf("lost comment %q after merge:\n%s", want, got)
}
}
// Existing servers + unrelated keys must survive, gortex must be added.
var root map[string]any
if err := yaml.Unmarshal(out, &root); err != nil {
t.Fatalf("merged file is not valid YAML: %v", err)
}
if root["model"] != "hermes-4" {
t.Errorf("unrelated key model clobbered: %v", root["model"])
}
servers := root["mcp_servers"].(map[string]any)
if _, ok := servers["github"]; !ok {
t.Errorf("pre-existing github server dropped: %#v", servers)
}
if _, ok := servers["gortex"]; !ok {
t.Errorf("gortex server not added: %#v", servers)
}
}
func TestMergeYAML_MalformedBacksUp(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
// Tab indentation is a hard YAML parse error.
bad := "mcp_servers:\n\t\tgithub: bad\n : : :\n"
if err := os.WriteFile(path, []byte(bad), 0o644); err != nil {
t.Fatalf("seed: %v", err)
}
action, err := MergeYAML(nil, path, upsertGortex(false), ApplyOpts{})
if err != nil {
t.Fatalf("merge: %v", err)
}
if action.Action != ActionMerge {
t.Fatalf("want merge (file existed), got %s", action.Action)
}
if _, err := os.Stat(path + ".bak"); err != nil {
t.Errorf("expected .bak of malformed input: %v", err)
}
out, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read back: %v", err)
}
var root map[string]any
if err := yaml.Unmarshal(out, &root); err != nil {
t.Fatalf("recovered file is not valid YAML: %v\n%s", err, out)
}
if _, ok := root["mcp_servers"].(map[string]any)["gortex"]; !ok {
t.Errorf("gortex not written into recovered file: %#v", root)
}
}
func TestMergeYAML_DryRunNoWrite(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
action, err := MergeYAML(nil, path, upsertGortex(false), ApplyOpts{DryRun: true})
if err != nil {
t.Fatalf("merge: %v", err)
}
if action.Action != ActionWouldCreate {
t.Fatalf("want would-create, got %s", action.Action)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Errorf("dry-run must not write the file")
}
}
// TestMergeYAML_DryRunMalformedNoBackup guards that --dry-run never
// mutates disk even when the existing file is malformed: the .bak of a
// malformed input is deferred to a real apply, so a plan-only run leaves
// both the file and its sibling untouched.
func TestMergeYAML_DryRunMalformedNoBackup(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
bad := "mcp_servers:\n\t\tgithub: bad\n : : :\n"
if err := os.WriteFile(path, []byte(bad), 0o644); err != nil {
t.Fatalf("seed: %v", err)
}
action, err := MergeYAML(nil, path, upsertGortex(false), ApplyOpts{DryRun: true})
if err != nil {
t.Fatalf("merge: %v", err)
}
if action.Action != ActionWouldMerge {
t.Fatalf("want would-merge, got %s", action.Action)
}
if _, err := os.Stat(path + ".bak"); !os.IsNotExist(err) {
t.Errorf("dry-run must not write a .bak (stat err = %v)", err)
}
out, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read back: %v", err)
}
if string(out) != bad {
t.Errorf("dry-run mutated the original file:\n%q", out)
}
}
// TestMergeYAML_NonMapOuterErrors guards that a non-null, non-mapping
// value under the outer key (a stray scalar or sequence) is treated as
// schema-invalid and refused — never silently swapped for a fresh map
// that drops the user's data.
func TestMergeYAML_NonMapOuterErrors(t *testing.T) {
for _, tc := range []struct {
name string
body string
}{
{"scalar", "mcp_servers: nonsense\n"},
{"sequence", "mcp_servers:\n - a\n - b\n"},
} {
t.Run(tc.name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
if err := os.WriteFile(path, []byte(tc.body), 0o644); err != nil {
t.Fatalf("seed: %v", err)
}
if _, err := MergeYAML(nil, path, upsertGortex(false), ApplyOpts{}); err == nil {
t.Fatal("expected an error for non-mapping mcp_servers, got nil")
}
out, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read back: %v", err)
}
if string(out) != tc.body {
t.Errorf("file mutated despite refusal:\n%q", out)
}
if _, err := os.Stat(path + ".bak"); !os.IsNotExist(err) {
t.Errorf("refusal must not write a .bak")
}
})
}
}
// TestMergeYAML_NullOuterPopulated covers the benign case: an empty /
// null `mcp_servers:` (e.g. every server commented out) is populated in
// place rather than erroring, and the key's leading comment survives.
func TestMergeYAML_NullOuterPopulated(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
original := "# servers (all disabled for now)\nmcp_servers:\n"
if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
t.Fatalf("seed: %v", err)
}
action, err := MergeYAML(nil, path, upsertGortex(false), ApplyOpts{})
if err != nil {
t.Fatalf("merge: %v", err)
}
if action.Action != ActionMerge {
t.Fatalf("want merge, got %s", action.Action)
}
out, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read back: %v", err)
}
if !strings.Contains(string(out), "# servers (all disabled for now)") {
t.Errorf("lost leading comment on null key:\n%s", out)
}
var root map[string]any
if err := yaml.Unmarshal(out, &root); err != nil {
t.Fatalf("not valid YAML: %v\n%s", err, out)
}
servers, ok := root["mcp_servers"].(map[string]any)
if !ok {
t.Fatalf("mcp_servers not populated: %#v", root)
}
if _, ok := servers["gortex"]; !ok {
t.Errorf("gortex not added: %#v", servers)
}
}
// TestMergeYAML_PreservesIndentWidth verifies a 4-space config is
// re-emitted at 4 spaces rather than forced to 2 (detectYAMLIndent).
func TestMergeYAML_PreservesIndentWidth(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
original := "mcp_servers:\n github:\n command: npx\n"
if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
t.Fatalf("seed: %v", err)
}
if _, err := MergeYAML(nil, path, upsertGortex(false), ApplyOpts{}); err != nil {
t.Fatalf("merge: %v", err)
}
out, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read back: %v", err)
}
got := string(out)
if !strings.Contains(got, "\n github:") {
t.Errorf("4-space indent not preserved:\n%s", got)
}
if strings.Contains(got, "\n github:") {
t.Errorf("indent collapsed to 2 spaces:\n%s", got)
}
}
+150
View File
@@ -0,0 +1,150 @@
// Package zed implements the Gortex init integration for the Zed
// editor. Zed calls its MCP registry `context_servers` (not
// `mcpServers`) and stores it in a platform-specific settings.json:
//
// macOS: ~/Library/Application Support/Zed/settings.json
// Linux: ~/.config/zed/settings.json
// Windows: %APPDATA%\Zed\settings.json
//
// The server entry shape:
//
// {
// "context_servers": {
// "gortex": {
// "source": "custom",
// "command": "gortex",
// "args": ["mcp", "--index", ".", "--watch"],
// "env": {"GORTEX_INDEX_WORKERS": "8"}
// }
// }
// }
//
// Docs: https://zed.dev/docs/ai/mcp
package zed
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/internalutil"
)
const Name = "zed"
const DocsURL = "https://zed.dev/docs/ai/mcp"
type Adapter struct{}
func New() *Adapter { return &Adapter{} }
func (a *Adapter) Name() string { return Name }
func (a *Adapter) DocsURL() string { return DocsURL }
// userSettingsPath returns the platform-specific Zed settings file.
// Returns "" when Home is unset or the OS is unsupported.
func userSettingsPath(home string) string {
if home == "" {
return ""
}
switch runtime.GOOS {
case "darwin":
return filepath.Join(home, "Library", "Application Support", "Zed", "settings.json")
case "linux":
return filepath.Join(home, ".config", "zed", "settings.json")
case "windows":
// %APPDATA% is usually ~\AppData\Roaming on Windows.
return filepath.Join(home, "AppData", "Roaming", "Zed", "settings.json")
default:
return ""
}
}
// Detect checks for the zed CLI on PATH or the platform-specific
// settings.json directory.
func (a *Adapter) Detect(env agents.Env) (bool, error) {
if p, err := exec.LookPath("zed"); err == nil && p != "" {
return true, nil
}
path := userSettingsPath(env.Home)
if path == "" {
return false, nil
}
if _, err := os.Stat(filepath.Dir(path)); err == nil {
return true, nil
}
return false, nil
}
func (a *Adapter) Plan(env agents.Env) (*agents.Plan, error) {
p := &agents.Plan{}
if settings := userSettingsPath(env.Home); settings != "" {
p.Files = append(p.Files, agents.FileAction{
Path: settings,
Action: agents.ActionWouldMerge,
Keys: []string{"context_servers"},
})
}
if env.Mode != agents.ModeGlobal && env.SkillsRouting != "" {
p.Files = append(p.Files, agents.FileAction{
Path: filepath.Join(env.Root, ".rules"), Action: agents.ActionWouldMerge,
Keys: []string{"communities-block"},
})
}
return p, nil
}
func (a *Adapter) Apply(env agents.Env, opts agents.ApplyOpts) (*agents.Result, error) {
res := &agents.Result{Name: Name, DocsURL: DocsURL}
detected, _ := a.Detect(env)
res.Detected = detected
if !detected && !opts.ForceDetect {
internalutil.Logf(env.Stderr, "[gortex init] skip Zed setup (zed not detected)")
return res, nil
}
path := userSettingsPath(env.Home)
if path == "" {
return res, fmt.Errorf("zed: no user settings path known for %s", runtime.GOOS)
}
internalutil.Logf(env.Stderr, "[gortex init] setting up Zed integration...")
action, err := agents.MergeJSON(env.Stderr, path, func(root map[string]any, _ bool) (bool, error) {
servers, ok := root["context_servers"].(map[string]any)
if !ok {
servers = make(map[string]any)
}
if _, exists := servers["gortex"]; exists && !opts.Force {
return false, nil
}
servers["gortex"] = map[string]any{
"source": "custom",
"command": "gortex",
"args": []string{"mcp"},
"env": map[string]string{"GORTEX_INDEX_WORKERS": "8"},
}
root["context_servers"] = servers
return true, nil
}, opts)
if err != nil {
return res, err
}
res.Files = append(res.Files, action)
// Zed's Agent panel reads `.rules` at the project root on every
// turn. Write a marker-guarded community-routing block there
// when skills were generated. Skipped in global mode (the file
// is per-repo).
if env.Mode != agents.ModeGlobal && env.SkillsRouting != "" {
rulesPath := filepath.Join(env.Root, ".rules")
routingAction, err := agents.UpsertMarkedBlock(env.Stderr, rulesPath, env.SkillsRouting,
agents.CommunitiesStartMarker, agents.CommunitiesEndMarker, opts)
if err != nil {
return res, err
}
res.Files = append(res.Files, routingAction)
}
res.Configured = true
return res, nil
}
+51
View File
@@ -0,0 +1,51 @@
package zed
import (
"os"
"path/filepath"
"testing"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/agentstest"
)
// TestZedUsesContextServersKey verifies Zed's idiosyncratic schema:
// top-level key is "context_servers" (not mcpServers or servers).
// A regression here would silently break the integration.
func TestZedUsesContextServersKey(t *testing.T) {
env, _ := agentstest.NewEnv(t)
// Detection: create the per-OS settings dir so Detect() succeeds.
p := userSettingsPath(env.Home)
if p == "" {
t.Skip("unsupported OS for zed test")
}
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
a := New()
res, err := a.Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
// Two creates: Zed's settings.json for the context_servers stanza
// plus the project-root .rules file the Agent panel reads on
// every turn.
agentstest.AssertCountsByAction(t, res, map[agents.ActionKind]int{agents.ActionCreate: 2})
cfg := agentstest.ReadJSON(t, p)
if _, ok := cfg["mcpServers"]; ok {
t.Fatalf("should not write mcpServers — Zed uses context_servers: %v", cfg)
}
servers, ok := cfg["context_servers"].(map[string]any)
if !ok {
t.Fatalf("missing context_servers key: %v", cfg)
}
gortex, ok := servers["gortex"].(map[string]any)
if !ok {
t.Fatalf("gortex entry missing: %v", servers)
}
if gortex["source"] != "custom" {
t.Fatalf("expected source=custom, got %v", gortex["source"])
}
}
+317
View File
@@ -0,0 +1,317 @@
package analysis
import (
"sort"
"github.com/zzet/gortex/internal/graph"
)
// AdjacencySnapshot is a compact, immutable CSR-style view of the
// call / reference graph, built once per analysis pass and reused by
// seeded random-walk queries so they never re-scan AllNodes / AllEdges.
//
// Only EdgeCalls and EdgeReferences participate — the same edge set
// ComputePageRank walks — and each edge rides its graph.ProvenanceWeight
// so a seeded walk attenuates over-represented LSP-dispatch fan-outs
// identically to the global PageRank.
//
// Layout (forward adjacency, From -> To):
//
// - ids[i] the node ID at dense index i
// - offsets[i]..[i+1] the slice of out-neighbours of node i
// - neighbors[k] dense index of the k-th out-neighbour
// - weights[k] provenance weight of the edge to neighbors[k]
// - outWeight[i] sum of weights of node i's out-edges
//
// The snapshot is read-only after construction; PersonalizedPageRank
// allocates only its own score vectors, so concurrent walks over one
// snapshot are safe without locking.
type AdjacencySnapshot struct {
ids []string
index map[string]int
offsets []int32
neighbors []int32
weights []float64
outWeight []float64
// pkgRoots maps a package directory (the dir of a node's file path,
// ≈ a Go package) to a content hash of that package's contribution
// to the walk: every member node's stable ID plus its out-edges
// (neighbour IDs + weights). The hash is index-shift invariant — it
// uses string IDs, not dense indices — so a package whose subgraph
// did not change keeps the same root even when nodes are added or
// removed in OTHER packages. This is the per-package Merkle root the
// walk cache keys on, so only walks touching a changed package miss.
// Empty when the snapshot is empty.
pkgRoots map[string]uint64
}
// NodeCount returns the number of nodes in the snapshot.
func (a *AdjacencySnapshot) NodeCount() int {
if a == nil {
return 0
}
return len(a.ids)
}
// EdgeCount returns the number of directed call / reference edges
// captured in the snapshot.
func (a *AdjacencySnapshot) EdgeCount() int {
if a == nil {
return 0
}
return len(a.neighbors)
}
// BuildAdjacencySnapshot constructs the CSR adjacency over the call /
// reference graph. Nodes are densely indexed in sorted ID order so the
// snapshot — and therefore every seeded walk over it — is deterministic
// regardless of the backend's node / edge enumeration order. An edge
// whose endpoint is not a real graph node (an unresolved or dangling
// target) is skipped so the dense index stays consistent.
func BuildAdjacencySnapshot(g graph.Store) *AdjacencySnapshot {
snap := &AdjacencySnapshot{index: map[string]int{}}
if g == nil {
return snap
}
nodes := g.AllNodes()
if len(nodes) == 0 {
return snap
}
ids := make([]string, 0, len(nodes))
for _, n := range nodes {
if n == nil || n.ID == "" {
continue
}
ids = append(ids, n.ID)
}
sort.Strings(ids)
index := make(map[string]int, len(ids))
for i, id := range ids {
index[id] = i
}
// First pass: bucket out-edges per source so the CSR offsets can be
// laid out contiguously. Only call / reference edges with both
// endpoints in the dense index participate.
type link struct {
to int
w float64
}
adj := make([][]link, len(ids))
// Meta-less kind-scoped scan (see LightEdgeScanner): the CSR build reads only
// e.Kind, endpoints, and graph.ProvenanceWeight.
for _, e := range graph.EdgesForKindsLight(g, graph.EdgeCalls, graph.EdgeReferences) {
if e == nil {
continue
}
if e.Kind != graph.EdgeCalls && e.Kind != graph.EdgeReferences {
continue
}
from, ok := index[e.From]
if !ok {
continue
}
to, ok := index[e.To]
if !ok {
continue
}
adj[from] = append(adj[from], link{to: to, w: graph.ProvenanceWeight(e)})
}
offsets := make([]int32, len(ids)+1)
var total int
for i := range adj {
offsets[i] = int32(total)
total += len(adj[i])
}
offsets[len(ids)] = int32(total)
neighbors := make([]int32, 0, total)
weights := make([]float64, 0, total)
outWeight := make([]float64, len(ids))
for i := range adj {
// Sort each node's out-neighbours by dense index so the CSR row
// order is deterministic (AllEdges order is backend-specific).
row := adj[i]
sort.Slice(row, func(a, b int) bool { return row[a].to < row[b].to })
for _, l := range row {
neighbors = append(neighbors, int32(l.to))
weights = append(weights, l.w)
outWeight[i] += l.w
}
}
snap.ids = ids
snap.index = index
snap.offsets = offsets
snap.neighbors = neighbors
snap.weights = weights
snap.outWeight = outWeight
snap.pkgRoots = computePackageRoots(ids, offsets, neighbors, weights)
return snap
}
// pprDefaultRestart is the restart probability a seeded walk uses when
// the caller passes a non-positive value. 0.15 mirrors the canonical
// 0.85 PageRank damping (restart = 1 - damping).
const pprDefaultRestart = 0.15
// pprIterations is fixed rather than convergence-tested: the graph is
// small enough that the ranking order stabilises well within this many
// power-iteration steps, and a fixed count keeps the result
// deterministic and the cost bounded at O(iters * edges).
const pprIterations = 40
// PersonalizedPageRank runs a seeded random-walk-with-restart over the
// snapshot. Restart mass returns to the seed set (not uniformly across
// the graph), so the stationary distribution concentrates on nodes that
// are reachable from the seeds along many short, high-provenance paths.
//
// restart is the per-step restart probability; a non-positive value
// uses pprDefaultRestart. Score flows along an edge in proportion to
// its weight / the source's out-weight — identical to ComputePageRank —
// and dangling mass (a node with no out-edges) is returned to the seed
// set so no probability leaks. The result maps node ID to its proximity
// score; an empty map is returned when no seed resolves to a snapshot
// node.
func (a *AdjacencySnapshot) PersonalizedPageRank(seeds []string, restart float64) map[string]float64 {
return a.PersonalizedPageRankTopK(seeds, restart, 0)
}
// PersonalizedPageRankTopK is PersonalizedPageRank restricted to the k
// nodes with the highest stationary score. The seeded walk concentrates
// almost all of its mass on a small neighbourhood of the seeds, so the
// long tail of near-floor scores carries no usable ranking signal: every
// consumer either looks a candidate's score up by ID (absent → 0, exactly
// as a zero score already is) or max-normalises against the top entry,
// which top-k always retains. Capping the result turns a cached walk from
// a full-graph-sized map (~len(ids) entries) into at most k entries, which
// is what bounds the PPR walk cache's memory. k <= 0 (or k >= the number
// of scored nodes) returns the full dense map, identical to
// PersonalizedPageRank.
func (a *AdjacencySnapshot) PersonalizedPageRankTopK(seeds []string, restart float64, k int) map[string]float64 {
score := a.personalizedPageRankScores(seeds, restart)
if score == nil {
return map[string]float64{}
}
if k <= 0 {
out := make(map[string]float64, len(score))
for i, v := range score {
if v != 0 {
out[a.ids[i]] = v
}
}
return out
}
// Gather the non-zero (index, score) pairs, then keep the k largest.
pairs := make([]pprScore, 0, len(score))
for i, v := range score {
if v != 0 {
pairs = append(pairs, pprScore{idx: i, score: v})
}
}
if k >= len(pairs) {
out := make(map[string]float64, len(pairs))
for _, p := range pairs {
out[a.ids[p.idx]] = p.score
}
return out
}
// Partial selection by descending score; ties broken by index so the
// retained set is deterministic across runs.
sort.Slice(pairs, func(i, j int) bool {
if pairs[i].score != pairs[j].score {
return pairs[i].score > pairs[j].score
}
return pairs[i].idx < pairs[j].idx
})
out := make(map[string]float64, k)
for _, p := range pairs[:k] {
out[a.ids[p.idx]] = p.score
}
return out
}
// pprScore pairs a snapshot node index with its stationary score for the
// top-k partial selection in PersonalizedPageRankTopK.
type pprScore struct {
idx int
score float64
}
// personalizedPageRankScores runs the seeded random-walk-with-restart and
// returns the raw stationary score array aligned to a.ids (score[i] is the
// proximity of a.ids[i]). It returns nil when the snapshot is empty or no
// seed resolves to a snapshot node — the public wrappers then return an
// empty map.
func (a *AdjacencySnapshot) personalizedPageRankScores(seeds []string, restart float64) []float64 {
if a == nil || len(a.ids) == 0 {
return nil
}
if restart <= 0 || restart >= 1 {
restart = pprDefaultRestart
}
n := len(a.ids)
// Restart distribution: uniform over the in-snapshot seeds. A seed
// absent from the snapshot contributes nothing.
seedIdx := make([]int, 0, len(seeds))
seen := make(map[int]bool, len(seeds))
for _, s := range seeds {
if i, ok := a.index[s]; ok && !seen[i] {
seen[i] = true
seedIdx = append(seedIdx, i)
}
}
if len(seedIdx) == 0 {
return nil
}
restartVec := make([]float64, n)
seedMass := 1.0 / float64(len(seedIdx))
for _, i := range seedIdx {
restartVec[i] = seedMass
}
// Initialise the walk at the seed distribution.
score := make([]float64, n)
copy(score, restartVec)
for iter := 0; iter < pprIterations; iter++ {
next := make([]float64, n)
// Push each node's score forward along its out-edges, weighted
// by w/outWeight. Dangling nodes pool their score and return it
// to the seed set, conserving total mass.
var dangling float64
for i := 0; i < n; i++ {
ow := a.outWeight[i]
if ow == 0 {
dangling += score[i]
continue
}
s := score[i]
if s == 0 {
continue
}
start, end := a.offsets[i], a.offsets[i+1]
for k := start; k < end; k++ {
next[a.neighbors[k]] += s * a.weights[k] / ow
}
}
// Combine the walk step, the restart, and the dangling pool.
// (1-restart) of the walked mass plus restart mass to the seeds;
// dangling mass also returns to the seeds so it never leaks.
for i := 0; i < n; i++ {
next[i] = (1-restart)*next[i] + (restart+(1-restart)*dangling)*restartVec[i]
}
score = next
}
return score
}
+153
View File
@@ -0,0 +1,153 @@
package analysis
import (
"math"
"sort"
"strconv"
"strings"
)
// Content-addressed cache keying for seeded random walks.
//
// A Personalized-PageRank result over a seed set depends on the
// reachable subgraph. Recomputing it on every query is wasteful when
// the graph — or the part of it the walk touches — has not changed.
// These helpers derive a per-package Merkle root for the snapshot and a
// content-addressed cache key for a walk, so the MCP server's walk cache
// (see internal/mcp/ppr_cache.go) can serve an unchanged walk instantly
// and only recompute when a package the walk depends on actually
// changed — the incremental-RWR property a whole-graph version number
// (NodeCount/EdgeCount) cannot provide.
const (
fnvOffset64 uint64 = 14695981039346656037
fnvPrime64 uint64 = 1099511628211
)
// fnvStr folds a string into a running FNV-1a hash.
func fnvStr(h uint64, s string) uint64 {
for i := 0; i < len(s); i++ {
h ^= uint64(s[i])
h *= fnvPrime64
}
return h
}
// fnvU64 folds a uint64 into a running FNV-1a hash.
func fnvU64(h, v uint64) uint64 {
for i := 0; i < 8; i++ {
h ^= (v >> (8 * i)) & 0xff
h *= fnvPrime64
}
return h
}
// packageOfID returns the package directory for a node ID — the
// directory of the file-path portion (everything before "::"). For
// "gortex/internal/mcp/server.go::NewServer" that is
// "gortex/internal/mcp". A path with no slash maps to "" (repo root).
// This is the granularity at which the walk cache is invalidated.
func packageOfID(id string) string {
path := id
if i := strings.Index(path, "::"); i >= 0 {
path = path[:i]
}
if i := strings.LastIndexByte(path, '/'); i >= 0 {
return path[:i]
}
return ""
}
// computePackageRoots builds the per-package content roots from the CSR.
// For each node it folds the node's stable ID plus its out-edges
// (neighbour string IDs + weights) into the node's content hash, then
// sums those hashes per package. Summation is commutative, so the root
// is independent of node iteration order; it uses string IDs (not dense
// indices), so a package's root is invariant to index shifts caused by
// edits in OTHER packages — the property that makes the cache truly
// incremental.
func computePackageRoots(ids []string, offsets []int32, neighbors []int32, weights []float64) map[string]uint64 {
roots := make(map[string]uint64, len(ids)/8+1)
for i := 0; i < len(ids); i++ {
h := fnvStr(fnvOffset64, ids[i])
h ^= 0 // separator marker folded below
h = fnvU64(h, 0x1f)
start, end := offsets[i], offsets[i+1]
for k := start; k < end; k++ {
h = fnvStr(h, ids[neighbors[k]])
h = fnvU64(h, math.Float64bits(weights[k]))
}
roots[packageOfID(ids[i])] += h
}
return roots
}
// WalkCacheKey derives a content-addressed cache key for a seeded walk.
// The key folds: the resolved (in-snapshot) seed IDs, the restart
// probability, and the per-package roots of the packages the walk
// depends on — the seed packages plus their 1-hop out-neighbour
// packages. Two walks with the same seeds and restart over graph states
// whose relevant packages are byte-for-byte identical produce the same
// key (cache hit); a change to any depended-on package changes that
// package's root and therefore the key (cache miss → recompute).
//
// Returns "" when the snapshot has no package roots (cache disabled) or
// no seed resolves to a snapshot node — the caller then computes
// uncached. The resolved-seed set matches exactly what
// PersonalizedPageRank walks, so the key and the result stay coherent.
func (a *AdjacencySnapshot) WalkCacheKey(seeds []string, restart float64) string {
if a == nil || len(a.pkgRoots) == 0 || len(seeds) == 0 {
return ""
}
if restart <= 0 || restart >= 1 {
restart = pprDefaultRestart
}
resolved := make([]string, 0, len(seeds))
seen := make(map[int]bool, len(seeds))
pkgSet := make(map[string]struct{}, len(seeds)*4)
for _, s := range seeds {
i, ok := a.index[s]
if !ok || seen[i] {
continue
}
seen[i] = true
resolved = append(resolved, s)
pkgSet[packageOfID(s)] = struct{}{}
// 1-hop forward neighbours: their packages also influence the
// walk's first step, so fold them into the dependency set.
for k := a.offsets[i]; k < a.offsets[i+1]; k++ {
pkgSet[packageOfID(a.ids[a.neighbors[k]])] = struct{}{}
}
}
if len(resolved) == 0 {
return ""
}
sort.Strings(resolved)
pkgs := make([]string, 0, len(pkgSet))
for p := range pkgSet {
pkgs = append(pkgs, p)
}
sort.Strings(pkgs)
h := fnvStr(fnvOffset64, "ppr\x00")
for _, s := range resolved {
h = fnvStr(h, s)
h = fnvU64(h, 0x1f)
}
h = fnvU64(h, math.Float64bits(restart))
for _, p := range pkgs {
h = fnvStr(h, p)
h = fnvU64(h, a.pkgRoots[p])
}
return strconv.FormatUint(h, 16)
}
// PackageRootCount returns the number of distinct packages with a
// content root. Exposed for diagnostics / tests.
func (a *AdjacencySnapshot) PackageRootCount() int {
if a == nil {
return 0
}
return len(a.pkgRoots)
}
+124
View File
@@ -0,0 +1,124 @@
package analysis
import (
"testing"
"github.com/zzet/gortex/internal/graph"
)
// buildEdgeGraph constructs a small in-memory graph from (from,to) call
// edges. Node IDs follow the "<pkg>/file.go::Sym" shape so packageOfID
// resolves a meaningful package directory.
func buildEdgeGraph(t *testing.T, edges [][2]string) graph.Store {
t.Helper()
g := graph.New()
seen := map[string]bool{}
add := func(id string) {
if seen[id] {
return
}
seen[id] = true
path := id
if i := indexOfSep(id); i >= 0 {
path = id[:i]
}
g.AddNode(&graph.Node{ID: id, Kind: graph.KindFunction, Name: id, FilePath: path})
}
for _, e := range edges {
add(e[0])
add(e[1])
}
for _, e := range edges {
g.AddEdge(&graph.Edge{From: e[0], To: e[1], Kind: graph.EdgeCalls})
}
return g
}
func indexOfSep(s string) int {
for i := 0; i+1 < len(s); i++ {
if s[i] == ':' && s[i+1] == ':' {
return i
}
}
return -1
}
func TestWalkCacheKeyDeterministic(t *testing.T) {
g := buildEdgeGraph(t, [][2]string{
{"pkg/a.go::A", "pkg/b.go::B"},
{"pkg/b.go::B", "pkg/c.go::C"},
})
s1 := BuildAdjacencySnapshot(g)
s2 := BuildAdjacencySnapshot(g)
k1 := s1.WalkCacheKey([]string{"pkg/a.go::A"}, 0)
k2 := s2.WalkCacheKey([]string{"pkg/a.go::A"}, 0)
if k1 == "" {
t.Fatal("expected non-empty key")
}
if k1 != k2 {
t.Fatalf("key not deterministic across identical snapshots: %q vs %q", k1, k2)
}
// Seed order must not matter (sorted internally).
if got := s1.WalkCacheKey([]string{"pkg/a.go::A", "pkg/b.go::B"}, 0); got == k1 {
t.Fatal("different seed set should produce a different key")
}
}
func TestWalkCacheKeyPackageInvalidation(t *testing.T) {
base := [][2]string{
{"pkg/a.go::A", "pkg/b.go::B"},
{"other/x.go::X", "other/y.go::Y"},
}
g1 := buildEdgeGraph(t, base)
s1 := BuildAdjacencySnapshot(g1)
seeds := []string{"other/x.go::X"}
k1 := s1.WalkCacheKey(seeds, 0)
// Change a package UNRELATED to the seed's dependency set (pkg/*).
// The seed walk (other/*) must keep the same key — the incremental
// property: an edit to pkg/ doesn't bust an other/ walk.
g2 := buildEdgeGraph(t, append([][2]string{
{"pkg/a.go::A", "pkg/b.go::B2new"},
}, base...))
s2 := BuildAdjacencySnapshot(g2)
k2 := s2.WalkCacheKey(seeds, 0)
if k1 != k2 {
t.Fatalf("unrelated-package edit must not change the walk key: %q vs %q", k1, k2)
}
// Now change the seed's OWN package: the key must change (miss).
g3 := buildEdgeGraph(t, [][2]string{
{"pkg/a.go::A", "pkg/b.go::B"},
{"other/x.go::X", "other/y.go::Ynew"}, // seed's out-edge target changed
})
s3 := BuildAdjacencySnapshot(g3)
k3 := s3.WalkCacheKey(seeds, 0)
if k1 == k3 {
t.Fatal("changing the seed's dependency package must change the walk key")
}
}
func TestPackageRootsPopulated(t *testing.T) {
g := buildEdgeGraph(t, [][2]string{
{"pkg/a.go::A", "pkg/b.go::B"},
{"other/x.go::X", "other/y.go::Y"},
})
s := BuildAdjacencySnapshot(g)
if s.PackageRootCount() < 2 {
t.Fatalf("expected >=2 package roots, got %d", s.PackageRootCount())
}
}
func TestPackageOfID(t *testing.T) {
cases := map[string]string{
"gortex/internal/mcp/server.go::NewServer": "gortex/internal/mcp",
"pkg/a.go::A": "pkg",
"top.go::Sym": "",
"a/b/c/f.go::S": "a/b/c",
}
for id, want := range cases {
if got := packageOfID(id); got != want {
t.Errorf("packageOfID(%q) = %q, want %q", id, got, want)
}
}
}
+140
View File
@@ -0,0 +1,140 @@
package analysis
import (
"testing"
"github.com/zzet/gortex/internal/graph"
)
func TestBuildAdjacencySnapshot_Empty(t *testing.T) {
snap := BuildAdjacencySnapshot(graph.New())
if snap.NodeCount() != 0 {
t.Errorf("empty graph should have 0 nodes, got %d", snap.NodeCount())
}
if snap.EdgeCount() != 0 {
t.Errorf("empty graph should have 0 edges, got %d", snap.EdgeCount())
}
// A nil-safe PersonalizedPageRank on an empty snapshot returns an
// empty map, not a panic.
if got := snap.PersonalizedPageRank([]string{"x"}, 0); len(got) != 0 {
t.Errorf("expected empty result on empty snapshot, got %v", got)
}
}
func TestBuildAdjacencySnapshot_OnlyCallAndReferenceEdges(t *testing.T) {
g := graph.New()
for _, id := range []string{"a", "b", "c", "d"} {
g.AddNode(&graph.Node{ID: id, Kind: graph.KindFunction, Name: id})
}
// a -> b (calls), a -> c (references), a -> d (imports — excluded).
g.AddEdge(&graph.Edge{From: "a", To: "b", Kind: graph.EdgeCalls})
g.AddEdge(&graph.Edge{From: "a", To: "c", Kind: graph.EdgeReferences})
g.AddEdge(&graph.Edge{From: "a", To: "d", Kind: graph.EdgeImports})
snap := BuildAdjacencySnapshot(g)
if snap.NodeCount() != 4 {
t.Fatalf("expected 4 nodes, got %d", snap.NodeCount())
}
// Only the calls + references edges are captured.
if snap.EdgeCount() != 2 {
t.Errorf("expected 2 captured edges (calls+references), got %d", snap.EdgeCount())
}
}
func TestBuildAdjacencySnapshot_SkipsDanglingEndpoints(t *testing.T) {
g := graph.New()
g.AddNode(&graph.Node{ID: "a", Kind: graph.KindFunction, Name: "a"})
// Edge to a node that does not exist in the graph — must be skipped
// so the dense index stays consistent.
g.AddEdge(&graph.Edge{From: "a", To: "ghost", Kind: graph.EdgeCalls})
snap := BuildAdjacencySnapshot(g)
if snap.NodeCount() != 1 {
t.Fatalf("expected 1 node, got %d", snap.NodeCount())
}
if snap.EdgeCount() != 0 {
t.Errorf("dangling edge must be skipped, got %d edges", snap.EdgeCount())
}
}
func TestPersonalizedPageRank_ConcentratesNearSeed(t *testing.T) {
g := graph.New()
// Two disjoint chains. Seed one of them; the seeded chain must
// score far above the unrelated chain.
for _, id := range []string{"s", "s1", "s2", "u", "u1", "u2"} {
g.AddNode(&graph.Node{ID: id, Kind: graph.KindFunction, Name: id})
}
g.AddEdge(&graph.Edge{From: "s", To: "s1", Kind: graph.EdgeCalls})
g.AddEdge(&graph.Edge{From: "s1", To: "s2", Kind: graph.EdgeCalls})
g.AddEdge(&graph.Edge{From: "u", To: "u1", Kind: graph.EdgeCalls})
g.AddEdge(&graph.Edge{From: "u1", To: "u2", Kind: graph.EdgeCalls})
snap := BuildAdjacencySnapshot(g)
scores := snap.PersonalizedPageRank([]string{"s"}, 0)
// The seed and its reachable neighbours carry mass; the unrelated
// chain is never reached from the seed, so it stays at zero.
if scores["s"] <= 0 {
t.Errorf("seed should carry positive mass, got %f", scores["s"])
}
if scores["s1"] <= 0 {
t.Errorf("seed-reachable node should carry mass, got %f", scores["s1"])
}
if scores["u"] != 0 || scores["u1"] != 0 || scores["u2"] != 0 {
t.Errorf("the unrelated chain should be unreachable from the seed: u=%f u1=%f u2=%f",
scores["u"], scores["u1"], scores["u2"])
}
// The seed itself, kept warm by restart, outranks its downstream.
if scores["s"] <= scores["s2"] {
t.Errorf("restart should keep the seed (%f) above its downstream (%f)", scores["s"], scores["s2"])
}
}
func TestPersonalizedPageRank_MultiSeedSharesRestartMass(t *testing.T) {
g := graph.New()
for _, id := range []string{"p", "q", "x"} {
g.AddNode(&graph.Node{ID: id, Kind: graph.KindFunction, Name: id})
}
// Both seeds call a shared node x.
g.AddEdge(&graph.Edge{From: "p", To: "x", Kind: graph.EdgeCalls})
g.AddEdge(&graph.Edge{From: "q", To: "x", Kind: graph.EdgeCalls})
snap := BuildAdjacencySnapshot(g)
scores := snap.PersonalizedPageRank([]string{"p", "q"}, 0)
if scores["p"] <= 0 || scores["q"] <= 0 {
t.Errorf("both seeds should carry mass: p=%f q=%f", scores["p"], scores["q"])
}
// x is fed by both seeds.
if scores["x"] <= 0 {
t.Errorf("the shared downstream node should accumulate mass, got %f", scores["x"])
}
}
func TestPersonalizedPageRank_NoSeedInSnapshot(t *testing.T) {
g := graph.New()
g.AddNode(&graph.Node{ID: "a", Kind: graph.KindFunction, Name: "a"})
snap := BuildAdjacencySnapshot(g)
// A seed absent from the snapshot yields an empty result (no walk
// to run), never a panic.
if got := snap.PersonalizedPageRank([]string{"absent"}, 0); len(got) != 0 {
t.Errorf("expected empty result for absent seed, got %v", got)
}
}
func TestPersonalizedPageRank_ProvenanceWeightingMatchesPageRankEdgeSet(t *testing.T) {
g := graph.New()
for _, id := range []string{"a", "b"} {
g.AddNode(&graph.Node{ID: id, Kind: graph.KindFunction, Name: id})
}
// A high-confidence AST-resolved call: it must be captured with a
// non-zero weight so the seeded walk can flow along it.
g.AddEdge(&graph.Edge{From: "a", To: "b", Kind: graph.EdgeCalls, Origin: graph.OriginASTResolved})
snap := BuildAdjacencySnapshot(g)
scores := snap.PersonalizedPageRank([]string{"a"}, 0)
if scores["b"] <= 0 {
t.Errorf("mass should flow from seed a to b along the weighted edge, got b=%f", scores["b"])
}
}
+93
View File
@@ -0,0 +1,93 @@
package analysis
import (
"sort"
"testing"
"github.com/zzet/gortex/internal/graph"
)
// chainSnapshot builds a single call chain seed -> n1 -> n2 -> ... so the
// seeded walk produces strictly decreasing, easy-to-order scores.
func chainSnapshot(t *testing.T, ids ...string) *AdjacencySnapshot {
t.Helper()
g := graph.New()
for _, id := range ids {
g.AddNode(&graph.Node{ID: id, Kind: graph.KindFunction, Name: id})
}
for i := 0; i+1 < len(ids); i++ {
g.AddEdge(&graph.Edge{From: ids[i], To: ids[i+1], Kind: graph.EdgeCalls})
}
return BuildAdjacencySnapshot(g)
}
func TestPersonalizedPageRankTopK_KZeroIsDense(t *testing.T) {
snap := chainSnapshot(t, "s", "a", "b", "c", "d")
dense := snap.PersonalizedPageRank([]string{"s"}, 0)
// k <= 0 must be byte-for-byte identical to the dense walk.
if topk := snap.PersonalizedPageRankTopK([]string{"s"}, 0, 0); !sameScores(dense, topk) {
t.Fatalf("k=0 should equal dense walk\n dense=%v\n topk =%v", dense, topk)
}
// k >= reachable count also returns the full dense map.
if topk := snap.PersonalizedPageRankTopK([]string{"s"}, 0, 100); !sameScores(dense, topk) {
t.Fatalf("k>=len should equal dense walk\n dense=%v\n topk =%v", dense, topk)
}
}
func TestPersonalizedPageRankTopK_KeepsHighestScorers(t *testing.T) {
snap := chainSnapshot(t, "s", "a", "b", "c", "d")
dense := snap.PersonalizedPageRank([]string{"s"}, 0)
if len(dense) <= 3 {
t.Fatalf("test precondition: need >3 reachable nodes, got %d", len(dense))
}
// Expected top-3 IDs, computed from the dense walk.
type kv struct {
id string
v float64
}
pairs := make([]kv, 0, len(dense))
for id, v := range dense {
pairs = append(pairs, kv{id, v})
}
sort.Slice(pairs, func(i, j int) bool { return pairs[i].v > pairs[j].v })
wantTop := map[string]bool{pairs[0].id: true, pairs[1].id: true, pairs[2].id: true}
got := snap.PersonalizedPageRankTopK([]string{"s"}, 0, 3)
if len(got) != 3 {
t.Fatalf("expected exactly 3 entries, got %d: %v", len(got), got)
}
for id, v := range got {
if !wantTop[id] {
t.Errorf("top-3 retained an out-of-top-3 node %q", id)
}
// Truncation must not alter the surviving values.
if v != dense[id] {
t.Errorf("score for %q changed under truncation: dense=%f topk=%f", id, dense[id], v)
}
}
// The max-scoring node (used for normalisation by the rerank consumer)
// must always survive truncation.
if _, ok := got[pairs[0].id]; !ok {
t.Errorf("the max-scoring node %q must be retained", pairs[0].id)
}
}
func TestPersonalizedPageRankTopK_NoSeedIsEmpty(t *testing.T) {
snap := chainSnapshot(t, "s", "a")
if got := snap.PersonalizedPageRankTopK([]string{"absent"}, 0, 4); len(got) != 0 {
t.Errorf("absent seed should yield empty result, got %v", got)
}
}
func sameScores(a, b map[string]float64) bool {
if len(a) != len(b) {
return false
}
for k, v := range a {
if bv, ok := b[k]; !ok || bv != v {
return false
}
}
return true
}
+168
View File
@@ -0,0 +1,168 @@
package analysis
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zzet/gortex/internal/graph"
)
func buildTestGraph() *graph.Graph {
g := graph.New()
// auth module
g.AddNode(&graph.Node{ID: "auth.go", Kind: graph.KindFile, Name: "auth.go", FilePath: "pkg/auth/auth.go", Language: "go"})
g.AddNode(&graph.Node{ID: "auth.go::ValidateToken", Kind: graph.KindFunction, Name: "ValidateToken", FilePath: "pkg/auth/auth.go", Language: "go", StartLine: 10, EndLine: 30})
g.AddNode(&graph.Node{ID: "auth.go::ParseClaims", Kind: graph.KindFunction, Name: "ParseClaims", FilePath: "pkg/auth/auth.go", Language: "go", StartLine: 32, EndLine: 50})
// handler module
g.AddNode(&graph.Node{ID: "handler.go", Kind: graph.KindFile, Name: "handler.go", FilePath: "pkg/api/handler.go", Language: "go"})
g.AddNode(&graph.Node{ID: "handler.go::HandleLogin", Kind: graph.KindFunction, Name: "HandleLogin", FilePath: "pkg/api/handler.go", Language: "go", StartLine: 5, EndLine: 40})
g.AddNode(&graph.Node{ID: "handler.go::HandleLogout", Kind: graph.KindFunction, Name: "HandleLogout", FilePath: "pkg/api/handler.go", Language: "go", StartLine: 42, EndLine: 60})
// main
g.AddNode(&graph.Node{ID: "main.go", Kind: graph.KindFile, Name: "main.go", FilePath: "cmd/main.go", Language: "go"})
g.AddNode(&graph.Node{ID: "main.go::main", Kind: graph.KindFunction, Name: "main", FilePath: "cmd/main.go", Language: "go", StartLine: 1, EndLine: 20})
// db module
g.AddNode(&graph.Node{ID: "db.go::QueryUser", Kind: graph.KindFunction, Name: "QueryUser", FilePath: "pkg/db/db.go", Language: "go", StartLine: 5, EndLine: 20})
// Edges
g.AddEdge(&graph.Edge{From: "main.go::main", To: "handler.go::HandleLogin", Kind: graph.EdgeCalls})
g.AddEdge(&graph.Edge{From: "main.go::main", To: "handler.go::HandleLogout", Kind: graph.EdgeCalls})
g.AddEdge(&graph.Edge{From: "handler.go::HandleLogin", To: "auth.go::ValidateToken", Kind: graph.EdgeCalls})
g.AddEdge(&graph.Edge{From: "handler.go::HandleLogin", To: "db.go::QueryUser", Kind: graph.EdgeCalls})
g.AddEdge(&graph.Edge{From: "auth.go::ValidateToken", To: "auth.go::ParseClaims", Kind: graph.EdgeCalls})
g.AddEdge(&graph.Edge{From: "handler.go::HandleLogout", To: "auth.go::ValidateToken", Kind: graph.EdgeCalls})
return g
}
func TestDetectCommunities(t *testing.T) {
g := buildTestGraph()
result := DetectCommunities(g)
require.NotNil(t, result)
// With this small graph, we should get at least 1 community
assert.GreaterOrEqual(t, len(result.Communities), 1)
assert.NotEmpty(t, result.NodeToComm)
// Check that communities have members
for _, c := range result.Communities {
assert.NotEmpty(t, c.ID)
assert.NotEmpty(t, c.Members)
assert.Greater(t, c.Size, 1)
assert.GreaterOrEqual(t, c.Cohesion, 0.0)
assert.LessOrEqual(t, c.Cohesion, 1.0)
}
}
func TestDiscoverProcesses(t *testing.T) {
g := buildTestGraph()
result := DiscoverProcesses(g)
require.NotNil(t, result)
// main and HandleLogin should be discovered as entry points
assert.GreaterOrEqual(t, len(result.Processes), 1)
// Check process structure
for _, p := range result.Processes {
assert.NotEmpty(t, p.ID)
assert.NotEmpty(t, p.Name)
assert.NotEmpty(t, p.EntryPoint)
assert.GreaterOrEqual(t, p.StepCount, 2)
assert.NotEmpty(t, p.Files)
}
// main should trace through handler → auth → parseclaims
found := false
for _, p := range result.Processes {
if p.EntryPoint == "main.go::main" {
found = true
assert.GreaterOrEqual(t, p.StepCount, 3)
break
}
}
assert.True(t, found, "main should be discovered as a process entry point")
}
func TestAnalyzeImpact(t *testing.T) {
g := buildTestGraph()
communities := DetectCommunities(g)
processes := DiscoverProcesses(g)
result := AnalyzeImpact(g, []string{"auth.go::ValidateToken"}, communities, processes)
require.NotNil(t, result)
assert.NotEmpty(t, result.Risk)
assert.NotEmpty(t, result.Summary)
// ValidateToken is called by HandleLogin and HandleLogout (depth 1)
d1 := result.ByDepth[1]
assert.GreaterOrEqual(t, len(d1), 1, "should have at least 1 direct dependent")
// At depth 2, main should appear
d2 := result.ByDepth[2]
assert.GreaterOrEqual(t, len(d2), 0)
assert.Greater(t, result.TotalAffected, 0)
}
// TestAnalyzeImpact_DropsHeuristicNoiseAtTransitiveDepths pins the
// regression: a leaf symbol used to surface as "90 transitively
// affected" because a heuristic / text-matched edge at d=1 fanned
// out through high-confidence d=2 edges into hundreds of unrelated
// downstream methods. Now d=2 and d=3 drop entries whose
// representative edge has confidence == 0 + label "INFERRED"
// (resolution edges without type info — the noise class). d=1
// stays untouched.
func TestAnalyzeImpact_DropsHeuristicNoiseAtTransitiveDepths(t *testing.T) {
g := graph.New()
target := &graph.Node{ID: "p/target.go::Target", Name: "Target", Kind: graph.KindFunction, FilePath: "p/target.go"}
caller := &graph.Node{ID: "p/caller.go::Caller", Name: "Caller", Kind: graph.KindFunction, FilePath: "p/caller.go"}
intermediate := &graph.Node{ID: "p/middle.go::Middle", Name: "Middle", Kind: graph.KindFunction, FilePath: "p/middle.go"}
heuristic := &graph.Node{ID: "p/noise.go::Noise", Name: "Noise", Kind: graph.KindFunction, FilePath: "p/noise.go"}
g.AddNode(target)
g.AddNode(caller)
g.AddNode(intermediate)
g.AddNode(heuristic)
// Direct caller: real call, high-conf.
g.AddEdge(&graph.Edge{From: caller.ID, To: target.ID, Kind: graph.EdgeCalls, Confidence: 0.95})
// d=2 caller arriving via a CONFIDENT edge.
g.AddEdge(&graph.Edge{From: intermediate.ID, To: caller.ID, Kind: graph.EdgeCalls, Confidence: 0.95})
// d=2 NOISE entry: heuristic edge with no type info — exactly
// the shape the user reported as flooding the response.
g.AddEdge(&graph.Edge{From: heuristic.ID, To: caller.ID, Kind: graph.EdgeCalls, Confidence: 0})
result := AnalyzeImpact(g, []string{target.ID}, nil, nil)
require.NotNil(t, result)
require.GreaterOrEqual(t, len(result.ByDepth[1]), 1, "direct caller must survive")
for _, e := range result.ByDepth[2] {
require.False(t, e.EdgeConfidence == 0 && e.ConfidenceLabel == "INFERRED",
"d=2 must not include heuristic / text-matched edges (got %s via confidence=%v label=%s)",
e.ID, e.EdgeConfidence, e.ConfidenceLabel)
}
}
func TestAnalyzeImpact_RiskLevels(t *testing.T) {
assert.Equal(t, RiskLow, assessRisk(0, 0))
assert.Equal(t, RiskLow, assessRisk(1, 1))
assert.Equal(t, RiskMedium, assessRisk(2, 3))
assert.Equal(t, RiskHigh, assessRisk(5, 5))
assert.Equal(t, RiskCritical, assessRisk(10, 10))
}
func TestScoreEntryPoint(t *testing.T) {
main := &graph.Node{Name: "main", Language: "go", Kind: graph.KindFunction}
handler := &graph.Node{Name: "HandleLogin", Language: "go", Kind: graph.KindFunction}
getter := &graph.Node{Name: "getName", Language: "go", Kind: graph.KindFunction}
// main with 3 callees, 0 callers should score high
mainScore := scoreEntryPoint(main, 3, 0)
handlerScore := scoreEntryPoint(handler, 2, 1)
getterScore := scoreEntryPoint(getter, 1, 5)
assert.Greater(t, mainScore, handlerScore)
assert.Greater(t, handlerScore, getterScore)
}
+324
View File
@@ -0,0 +1,324 @@
package analysis
import (
"fmt"
"path"
"sort"
"strings"
"github.com/zzet/gortex/internal/config"
"github.com/zzet/gortex/internal/graph"
)
// EvaluateArchitecture checks the declarative architecture DSL — named
// layers with directional allow/deny constraints — against a set of
// changed symbols.
//
// For each changed symbol it resolves the symbol's layer, walks its
// outgoing call / reference edges, resolves each target's layer, and
// reports a violation when a cross-layer dependency breaks the source
// layer's allow/deny rules. Symbols in no declared layer, and edges
// to such symbols, are unconstrained.
func EvaluateArchitecture(g graph.Store, arch config.ArchitectureConfig, changedSymbolIDs []string) []GuardViolation {
if g == nil || arch.IsEmpty() {
return nil
}
names := sortedLayerNames(arch.Layers)
var violations []GuardViolation
seen := make(map[string]bool)
for _, id := range changedSymbolIDs {
n := g.GetNode(id)
if n == nil {
continue
}
fromLayer := layerOf(effectivePath(n), arch.Layers, names)
if fromLayer == "" {
continue
}
for _, e := range g.GetOutEdges(id) {
if e.Kind != graph.EdgeCalls && e.Kind != graph.EdgeReferences {
continue
}
target := g.GetNode(e.To)
if target == nil {
continue
}
toLayer := layerOf(effectivePath(target), arch.Layers, names)
if toLayer == "" || toLayer == fromLayer {
continue
}
ok, reason := layerAllows(arch.Layers[fromLayer], fromLayer, toLayer)
if ok {
continue
}
key := id + "\x00" + e.To
if seen[key] {
continue
}
seen[key] = true
violations = append(violations, GuardViolation{
RuleName: "layer:" + fromLayer,
Kind: "layer",
Description: fmt.Sprintf("%s (layer %s) %s %s (layer %s): %s",
n.ID, fromLayer, e.Kind, target.ID, toLayer, reason),
Violator: n.ID,
LayerFrom: fromLayer,
LayerTo: toLayer,
EdgeType: string(e.Kind),
Severity: ruleSeverity(arch.Severity),
})
}
}
violations = append(violations, evaluateArchRules(g, arch, changedSymbolIDs, names)...)
return violations
}
// evaluateArchRules checks the per-layer / per-pattern dependency-cone
// rules — fan-out caps and caller-boundary restrictions — for a set
// of changed symbols.
func evaluateArchRules(g graph.Store, arch config.ArchitectureConfig, changedSymbolIDs, layerNames []string) []GuardViolation {
if len(arch.Rules) == 0 {
return nil
}
var violations []GuardViolation
for _, id := range changedSymbolIDs {
n := g.GetNode(id)
if n == nil {
continue
}
ep := effectivePath(n)
nodeLayer := layerOf(ep, arch.Layers, layerNames)
for _, rule := range arch.Rules {
if !ruleApplies(rule, ep, nodeLayer) {
continue
}
if matchesAnyGlob(ep, rule.Except) {
continue
}
label := archRuleLabel(rule)
if rule.MaxFanOut > 0 {
if fan := distinctCallTargets(g, id); fan > rule.MaxFanOut {
violations = append(violations, GuardViolation{
RuleName: label,
Kind: "fan_out",
Description: ruleMessage(rule, fmt.Sprintf(
"%s has dependency fan-out %d, exceeding the limit of %d",
n.ID, fan, rule.MaxFanOut)),
Violator: n.ID,
Severity: ruleSeverity(rule.Severity),
})
}
}
if len(rule.DenyCallersOutside) > 0 {
seen := make(map[string]bool)
for _, e := range g.GetInEdges(id) {
if e.Kind != graph.EdgeCalls && e.Kind != graph.EdgeReferences {
continue
}
caller := g.GetNode(e.From)
if caller == nil || seen[caller.ID] {
continue
}
seen[caller.ID] = true
cp := effectivePath(caller)
if callerWithinBoundary(cp, rule, layerOf(cp, arch.Layers, layerNames)) {
continue
}
violations = append(violations, GuardViolation{
RuleName: label,
Kind: "caller_boundary",
Description: ruleMessage(rule, fmt.Sprintf(
"%s calls into %s from outside the permitted set", caller.ID, n.ID)),
Violator: caller.ID,
EdgeType: string(e.Kind),
Severity: ruleSeverity(rule.Severity),
})
}
}
}
}
return violations
}
// ruleApplies reports whether an architecture rule is scoped to a
// symbol. A rule with neither a Layer nor a Pattern selector matches
// nothing; when both are set the symbol must satisfy both.
func ruleApplies(rule config.ArchRule, effPath, nodeLayer string) bool {
if rule.Layer == "" && rule.Pattern == "" {
return false
}
if rule.Layer != "" && nodeLayer != rule.Layer {
return false
}
if rule.Pattern != "" && !globMatch(rule.Pattern, effPath) {
return false
}
return true
}
// callerWithinBoundary reports whether a caller is permitted to depend
// on a symbol guarded by a deny_callers_outside rule. The guarded set
// may always call within itself; every other caller must match one of
// the allowlist globs.
func callerWithinBoundary(callerPath string, rule config.ArchRule, callerLayer string) bool {
if ruleApplies(rule, callerPath, callerLayer) {
return true
}
for _, allow := range rule.DenyCallersOutside {
if globMatch(allow, callerPath) {
return true
}
}
return false
}
// distinctCallTargets counts the distinct symbols a node calls or
// references — the dependency-cone size.
func distinctCallTargets(g graph.Store, id string) int {
seen := make(map[string]bool)
for _, e := range g.GetOutEdges(id) {
if e.Kind != graph.EdgeCalls && e.Kind != graph.EdgeReferences {
continue
}
seen[e.To] = true
}
return len(seen)
}
// archRuleLabel derives a stable rule name for a violation.
func archRuleLabel(rule config.ArchRule) string {
switch {
case rule.Name != "":
return rule.Name
case rule.Layer != "":
return "arch:layer:" + rule.Layer
case rule.Pattern != "":
return "arch:pattern:" + rule.Pattern
default:
return "arch:rule"
}
}
// ruleMessage prefixes a rule's configured Message onto the derived
// description when one is set.
func ruleMessage(rule config.ArchRule, detail string) string {
if rule.Message != "" {
return rule.Message + ": " + detail
}
return detail
}
// layerAllows reports whether a dependency from one layer to another
// is permitted, and the human-readable reason when it is not.
//
// Precedence: an explicit deny of the target layer always wins; then
// a non-empty Allow whitelist requires the target to be listed; with
// no whitelist a wildcard deny ("*") blocks every cross-layer edge.
func layerAllows(rule config.LayerRule, from, to string) (bool, string) {
for _, d := range rule.Deny {
if d == to {
return false, fmt.Sprintf("layer %q denies dependencies on %q", from, to)
}
}
if len(rule.Allow) > 0 {
for _, a := range rule.Allow {
if a == "*" || a == to {
return true, ""
}
}
return false, fmt.Sprintf("layer %q may depend only on %s, not %q",
from, strings.Join(rule.Allow, ", "), to)
}
for _, d := range rule.Deny {
if d == "*" {
return false, fmt.Sprintf("layer %q denies all cross-layer dependencies", from)
}
}
return true, ""
}
// layerOf returns the name of the layer a file belongs to, or "" when
// no layer claims it. names must be sorted so an ambiguous file
// (matched by two layers) resolves deterministically to the first.
func layerOf(filePath string, layers map[string]config.LayerRule, names []string) string {
for _, name := range names {
rule := layers[name]
if len(rule.Paths) > 0 {
for _, p := range rule.Paths {
if globMatch(p, filePath) {
return name
}
}
continue
}
// A layer with no explicit paths claims files that carry the
// layer name as a path segment — supports the terse config form.
if pathHasSegment(filePath, name) {
return name
}
}
return ""
}
// sortedLayerNames returns the layer names in a stable order.
func sortedLayerNames(layers map[string]config.LayerRule) []string {
names := make([]string, 0, len(layers))
for name := range layers {
names = append(names, name)
}
sort.Strings(names)
return names
}
// effectivePath strips the multi-repo prefix from a node's file path
// so per-repo architecture globs match in both single- and multi-repo
// graphs.
func effectivePath(n *graph.Node) string {
if n.RepoPrefix != "" {
return strings.TrimPrefix(n.FilePath, n.RepoPrefix+"/")
}
return n.FilePath
}
// pathHasSegment reports whether seg appears as a full path segment of
// filePath (e.g. seg "domain" matches "internal/domain/user.go").
func pathHasSegment(filePath, seg string) bool {
for _, part := range strings.Split(filePath, "/") {
if part == seg {
return true
}
}
return false
}
// globMatch reports whether path matches a glob pattern. "**" matches
// any number of path segments (including zero); "*" and "?" match
// within a single segment via the stdlib path.Match rules.
func globMatch(pattern, p string) bool {
return matchSegments(strings.Split(pattern, "/"), strings.Split(p, "/"))
}
func matchSegments(pat, seg []string) bool {
for len(pat) > 0 {
if pat[0] == "**" {
if len(pat) == 1 {
return true
}
for i := 0; i <= len(seg); i++ {
if matchSegments(pat[1:], seg[i:]) {
return true
}
}
return false
}
if len(seg) == 0 {
return false
}
if ok, err := path.Match(pat[0], seg[0]); err != nil || !ok {
return false
}
pat, seg = pat[1:], seg[1:]
}
return len(seg) == 0
}
+204
View File
@@ -0,0 +1,204 @@
package analysis
import (
"testing"
"github.com/zzet/gortex/internal/config"
"github.com/zzet/gortex/internal/graph"
)
func archTestGraph() *graph.Graph {
g := graph.New()
add := func(id, file string) {
g.AddNode(&graph.Node{ID: id, Kind: graph.KindFunction, Name: id, FilePath: file})
}
add("dom", "internal/domain/user.go")
add("inf", "internal/infra/db.go")
add("api", "internal/api/handler.go")
add("free", "cmd/tool/main.go") // belongs to no layer
return g
}
func archConfig() config.ArchitectureConfig {
return config.ArchitectureConfig{
Layers: map[string]config.LayerRule{
"domain": {Paths: []string{"internal/domain/**"}, Deny: []string{"*"}},
"infra": {Paths: []string{"internal/infra/**"}, Allow: []string{"domain"}},
"api": {Paths: []string{"internal/api/**"}, Allow: []string{"domain", "infra"}},
},
}
}
func TestEvaluateArchitecture_AllowedDependency(t *testing.T) {
g := archTestGraph()
g.AddEdge(&graph.Edge{From: "api", To: "dom", Kind: graph.EdgeCalls})
g.AddEdge(&graph.Edge{From: "api", To: "inf", Kind: graph.EdgeCalls})
v := EvaluateArchitecture(g, archConfig(), []string{"api"})
if len(v) != 0 {
t.Fatalf("api -> domain/infra are allowed, got violations: %+v", v)
}
}
func TestEvaluateArchitecture_DenyWildcard(t *testing.T) {
g := archTestGraph()
// domain denies "*" — any cross-layer dependency is a violation.
g.AddEdge(&graph.Edge{From: "dom", To: "inf", Kind: graph.EdgeCalls})
v := EvaluateArchitecture(g, archConfig(), []string{"dom"})
if len(v) != 1 {
t.Fatalf("expected 1 violation for domain -> infra, got %d: %+v", len(v), v)
}
if v[0].Kind != "layer" || v[0].LayerFrom != "domain" || v[0].LayerTo != "infra" {
t.Errorf("unexpected violation shape: %+v", v[0])
}
if v[0].Violator != "dom" || v[0].EdgeType != string(graph.EdgeCalls) {
t.Errorf("violator/edge_type wrong: %+v", v[0])
}
}
func TestEvaluateArchitecture_AllowWhitelistMiss(t *testing.T) {
g := archTestGraph()
// infra may depend only on domain — infra -> api violates.
g.AddEdge(&graph.Edge{From: "inf", To: "api", Kind: graph.EdgeCalls})
v := EvaluateArchitecture(g, archConfig(), []string{"inf"})
if len(v) != 1 {
t.Fatalf("expected 1 violation for infra -> api, got %d: %+v", len(v), v)
}
if v[0].LayerFrom != "infra" || v[0].LayerTo != "api" {
t.Errorf("unexpected violation: %+v", v[0])
}
}
func TestEvaluateArchitecture_UnlayeredUnconstrained(t *testing.T) {
g := archTestGraph()
// free belongs to no layer; edges to/from it are unconstrained.
g.AddEdge(&graph.Edge{From: "free", To: "dom", Kind: graph.EdgeCalls})
g.AddEdge(&graph.Edge{From: "dom", To: "free", Kind: graph.EdgeCalls})
if v := EvaluateArchitecture(g, archConfig(), []string{"free", "dom"}); len(v) != 0 {
t.Errorf("unlayered files must not produce violations, got %+v", v)
}
}
func TestEvaluateArchitecture_EmptyConfigIsNoop(t *testing.T) {
g := archTestGraph()
g.AddEdge(&graph.Edge{From: "dom", To: "inf", Kind: graph.EdgeCalls})
if v := EvaluateArchitecture(g, config.ArchitectureConfig{}, []string{"dom"}); v != nil {
t.Errorf("empty architecture config must yield no violations, got %+v", v)
}
}
func TestEvaluateArchitecture_NameSegmentFallback(t *testing.T) {
g := graph.New()
g.AddNode(&graph.Node{ID: "d", Kind: graph.KindFunction, FilePath: "internal/domain/x.go"})
g.AddNode(&graph.Node{ID: "i", Kind: graph.KindFunction, FilePath: "internal/infra/y.go"})
g.AddEdge(&graph.Edge{From: "d", To: "i", Kind: graph.EdgeCalls})
// Layers declare no Paths — membership falls back to the name
// appearing as a path segment.
arch := config.ArchitectureConfig{
Layers: map[string]config.LayerRule{
"domain": {Deny: []string{"*"}},
"infra": {},
},
}
if v := EvaluateArchitecture(g, arch, []string{"d"}); len(v) != 1 {
t.Errorf("name-segment fallback should detect domain -> infra, got %+v", v)
}
}
func TestGlobMatch(t *testing.T) {
cases := []struct {
pattern, path string
want bool
}{
{"internal/domain/**", "internal/domain/user.go", true},
{"internal/domain/**", "internal/domain/sub/user.go", true},
{"internal/domain/**", "internal/infra/db.go", false},
{"internal/**/handler.go", "internal/api/v2/handler.go", true},
{"**/*_test.go", "internal/api/handler_test.go", true},
{"cmd/*/main.go", "cmd/tool/main.go", true},
{"cmd/*/main.go", "cmd/a/b/main.go", false},
{"**", "anything/at/all.go", true},
}
for _, c := range cases {
if got := globMatch(c.pattern, c.path); got != c.want {
t.Errorf("globMatch(%q, %q) = %v, want %v", c.pattern, c.path, got, c.want)
}
}
}
func TestEvaluateArchitecture_MaxFanOut(t *testing.T) {
g := graph.New()
g.AddNode(&graph.Node{ID: "hub", Kind: graph.KindFunction, FilePath: "internal/domain/hub.go"})
for i, id := range []string{"t1", "t2", "t3", "t4", "t5", "t6"} {
g.AddNode(&graph.Node{ID: id, Kind: graph.KindFunction, FilePath: "internal/domain/t.go", StartLine: i})
g.AddEdge(&graph.Edge{From: "hub", To: id, Kind: graph.EdgeCalls})
}
arch := config.ArchitectureConfig{
Layers: map[string]config.LayerRule{
"domain": {Paths: []string{"internal/domain/**"}},
},
Rules: []config.ArchRule{
{Layer: "domain", MaxFanOut: 5},
},
}
v := EvaluateArchitecture(g, arch, []string{"hub"})
if len(v) != 1 || v[0].Kind != "fan_out" {
t.Fatalf("expected 1 fan_out violation, got %+v", v)
}
if v[0].Violator != "hub" {
t.Errorf("violator = %q, want hub", v[0].Violator)
}
// Within the limit — no violation.
arch.Rules[0].MaxFanOut = 10
if v := EvaluateArchitecture(g, arch, []string{"hub"}); len(v) != 0 {
t.Errorf("fan-out under the limit should not violate, got %+v", v)
}
}
func TestEvaluateArchitecture_DenyCallersOutside(t *testing.T) {
g := graph.New()
g.AddNode(&graph.Node{ID: "secret", Kind: graph.KindFunction, FilePath: "internal/secret/key.go"})
g.AddNode(&graph.Node{ID: "peer", Kind: graph.KindFunction, FilePath: "internal/secret/other.go"})
g.AddNode(&graph.Node{ID: "sec", Kind: graph.KindFunction, FilePath: "internal/security/auth.go"})
g.AddNode(&graph.Node{ID: "api", Kind: graph.KindFunction, FilePath: "internal/api/handler.go"})
g.AddEdge(&graph.Edge{From: "peer", To: "secret", Kind: graph.EdgeCalls}) // intra-set: allowed
g.AddEdge(&graph.Edge{From: "sec", To: "secret", Kind: graph.EdgeCalls}) // allowlisted: allowed
g.AddEdge(&graph.Edge{From: "api", To: "secret", Kind: graph.EdgeCalls}) // outside: violation
arch := config.ArchitectureConfig{
Rules: []config.ArchRule{
{
Name: "secret-boundary",
Pattern: "internal/secret/**",
DenyCallersOutside: []string{"internal/security/**"},
},
},
}
v := EvaluateArchitecture(g, arch, []string{"secret"})
if len(v) != 1 {
t.Fatalf("expected exactly one caller-boundary violation, got %d: %+v", len(v), v)
}
if v[0].Kind != "caller_boundary" || v[0].Violator != "api" {
t.Errorf("unexpected violation: %+v", v[0])
}
if v[0].RuleName != "secret-boundary" {
t.Errorf("rule name = %q, want secret-boundary", v[0].RuleName)
}
}
func TestEvaluateArchitecture_RuleWithoutSelectorMatchesNothing(t *testing.T) {
g := graph.New()
g.AddNode(&graph.Node{ID: "x", Kind: graph.KindFunction, FilePath: "internal/x.go"})
g.AddNode(&graph.Node{ID: "y", Kind: graph.KindFunction, FilePath: "internal/y.go"})
g.AddEdge(&graph.Edge{From: "x", To: "y", Kind: graph.EdgeCalls})
arch := config.ArchitectureConfig{
Rules: []config.ArchRule{{MaxFanOut: 0, Message: "no selector"}},
}
if v := EvaluateArchitecture(g, arch, []string{"x"}); len(v) != 0 {
t.Errorf("a rule with no layer/pattern selector must match nothing, got %+v", v)
}
}
@@ -0,0 +1,266 @@
package analysis
import (
"fmt"
"testing"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/query"
)
// buildSyntheticGraph creates a graph with the given number of function nodes
// and approximately 2*nodeCount random-ish call edges for benchmarking.
func buildSyntheticGraph(b *testing.B, nodeCount int) *graph.Graph {
b.Helper()
g := graph.New()
for i := 0; i < nodeCount; i++ {
g.AddNode(&graph.Node{
ID: fmt.Sprintf("pkg/file%d.go::Func%d", i/100, i),
Kind: graph.KindFunction,
Name: fmt.Sprintf("Func%d", i),
FilePath: fmt.Sprintf("pkg/file%d.go", i/100),
StartLine: (i % 100) + 1,
EndLine: (i % 100) + 10,
Language: "go",
})
}
// Add ~2x edges with a deterministic pattern
for i := 0; i < nodeCount; i++ {
// Each node calls the next 2 nodes (wrapping)
t1 := (i + 1) % nodeCount
t2 := (i + 7) % nodeCount
g.AddEdge(&graph.Edge{
From: fmt.Sprintf("pkg/file%d.go::Func%d", i/100, i),
To: fmt.Sprintf("pkg/file%d.go::Func%d", t1/100, t1),
Kind: graph.EdgeCalls,
})
g.AddEdge(&graph.Edge{
From: fmt.Sprintf("pkg/file%d.go::Func%d", i/100, i),
To: fmt.Sprintf("pkg/file%d.go::Func%d", t2/100, t2),
Kind: graph.EdgeCalls,
})
}
return g
}
// buildCommunities creates a simple CommunityResult assigning nodes to communities
// based on their file grouping (every 100 nodes = 1 community).
func buildCommunities(g *graph.Graph) *CommunityResult {
nodes := g.AllNodes()
result := &CommunityResult{
NodeToComm: make(map[string]string),
}
commMap := make(map[string][]string)
for _, n := range nodes {
commID := fmt.Sprintf("community-%s", n.FilePath)
result.NodeToComm[n.ID] = commID
commMap[commID] = append(commMap[commID], n.ID)
}
for id, members := range commMap {
result.Communities = append(result.Communities, Community{
ID: id,
Label: id,
Members: members,
Size: len(members),
})
}
return result
}
// --- BenchmarkDetectCycles ---
func BenchmarkDetectCycles_1k(b *testing.B) {
g := buildSyntheticGraph(b, 1000)
comms := buildCommunities(g)
b.ResetTimer()
for b.Loop() {
DetectCycles(g, comms, "")
}
}
func BenchmarkDetectCycles_10k(b *testing.B) {
g := buildSyntheticGraph(b, 10000)
comms := buildCommunities(g)
b.ResetTimer()
for b.Loop() {
DetectCycles(g, comms, "")
}
}
func BenchmarkDetectCycles_100k(b *testing.B) {
g := buildSyntheticGraph(b, 100000)
comms := buildCommunities(g)
b.ResetTimer()
for b.Loop() {
DetectCycles(g, comms, "")
}
}
// --- BenchmarkFindDeadCode ---
func BenchmarkFindDeadCode_1k(b *testing.B) {
g := buildSyntheticGraph(b, 1000)
procs := &ProcessResult{NodeToProcs: make(map[string][]string)}
b.ResetTimer()
for b.Loop() {
FindDeadCode(g, procs, nil)
}
}
func BenchmarkFindDeadCode_10k(b *testing.B) {
g := buildSyntheticGraph(b, 10000)
procs := &ProcessResult{NodeToProcs: make(map[string][]string)}
b.ResetTimer()
for b.Loop() {
FindDeadCode(g, procs, nil)
}
}
func BenchmarkFindDeadCode_100k(b *testing.B) {
g := buildSyntheticGraph(b, 100000)
procs := &ProcessResult{NodeToProcs: make(map[string][]string)}
b.ResetTimer()
for b.Loop() {
FindDeadCode(g, procs, nil)
}
}
// --- BenchmarkFindHotspots ---
func BenchmarkFindHotspots_1k(b *testing.B) {
g := buildSyntheticGraph(b, 1000)
comms := buildCommunities(g)
b.ResetTimer()
for b.Loop() {
FindHotspots(g, comms, 0)
}
}
func BenchmarkFindHotspots_10k(b *testing.B) {
g := buildSyntheticGraph(b, 10000)
comms := buildCommunities(g)
b.ResetTimer()
for b.Loop() {
FindHotspots(g, comms, 0)
}
}
func BenchmarkFindHotspots_100k(b *testing.B) {
g := buildSyntheticGraph(b, 100000)
comms := buildCommunities(g)
b.ResetTimer()
for b.Loop() {
FindHotspots(g, comms, 0)
}
}
// --- BenchmarkVerifyChanges ---
// buildGraphWithCallers creates a graph with one target function and N callers.
func buildGraphWithCallers(b *testing.B, callerCount int) (*graph.Graph, *query.Engine) {
b.Helper()
g := graph.New()
targetID := "pkg/target.go::Target"
g.AddNode(&graph.Node{
ID: targetID,
Kind: graph.KindFunction,
Name: "Target",
FilePath: "pkg/target.go",
StartLine: 1,
EndLine: 10,
Language: "go",
Meta: map[string]any{"signature": "func(ctx context.Context, id string) error"},
})
for i := 0; i < callerCount; i++ {
callerID := fmt.Sprintf("pkg/caller%d.go::Caller%d", i, i)
g.AddNode(&graph.Node{
ID: callerID,
Kind: graph.KindFunction,
Name: fmt.Sprintf("Caller%d", i),
FilePath: fmt.Sprintf("pkg/caller%d.go", i),
StartLine: 1,
EndLine: 5,
Language: "go",
})
g.AddEdge(&graph.Edge{
From: callerID,
To: targetID,
Kind: graph.EdgeCalls,
})
}
eng := query.NewEngine(g)
return g, eng
}
func BenchmarkVerifyChanges_10callers(b *testing.B) {
g, eng := buildGraphWithCallers(b, 10)
changes := []SignatureChange{{SymbolID: "pkg/target.go::Target", NewSignature: "func(ctx context.Context, id string, extra int) error"}}
b.ResetTimer()
for b.Loop() {
VerifyChanges(g, eng, changes)
}
}
func BenchmarkVerifyChanges_100callers(b *testing.B) {
g, eng := buildGraphWithCallers(b, 100)
changes := []SignatureChange{{SymbolID: "pkg/target.go::Target", NewSignature: "func(ctx context.Context, id string, extra int) error"}}
b.ResetTimer()
for b.Loop() {
VerifyChanges(g, eng, changes)
}
}
func BenchmarkVerifyChanges_1000callers(b *testing.B) {
g, eng := buildGraphWithCallers(b, 1000)
changes := []SignatureChange{{SymbolID: "pkg/target.go::Target", NewSignature: "func(ctx context.Context, id string, extra int) error"}}
b.ResetTimer()
for b.Loop() {
VerifyChanges(g, eng, changes)
}
}
// --- BenchmarkEnsureFresh ---
// ensureFresh is in the mcp package, so we benchmark the IsStale check pattern
// which is the core of the ensureFresh hot path.
func BenchmarkIsStaleCheck_100files(b *testing.B) {
benchmarkIsStalePattern(b, 100)
}
func BenchmarkIsStaleCheck_1000files(b *testing.B) {
benchmarkIsStalePattern(b, 1000)
}
func BenchmarkIsStaleCheck_10000files(b *testing.B) {
benchmarkIsStalePattern(b, 10000)
}
// benchmarkIsStalePattern simulates the ensureFresh file-staleness check loop.
// It builds a map of file mtimes and iterates over N files checking staleness.
func benchmarkIsStalePattern(b *testing.B, fileCount int) {
b.Helper()
// Build a mtime map simulating the indexer's fileMtimes
mtimes := make(map[string]int64, fileCount)
files := make([]string, fileCount)
for i := 0; i < fileCount; i++ {
fp := fmt.Sprintf("pkg/dir%d/file%d.go", i/100, i)
files[i] = fp
mtimes[fp] = int64(i * 1000000) // fake mtime
}
b.ResetTimer()
for b.Loop() {
// Simulate ensureFresh: check up to 5 stale files
limit := 5
refreshed := 0
for _, fp := range files {
if refreshed >= limit {
break
}
// Simulate IsStale check: lookup in map
if _, ok := mtimes[fp]; ok {
refreshed++
}
}
}
}
+153
View File
@@ -0,0 +1,153 @@
package analysis
import (
"fmt"
"runtime"
"testing"
"github.com/zzet/gortex/internal/graph"
)
// =============================================================================
// Memory-focused analysis benchmarks for low-resource devices (RPi)
// =============================================================================
// buildSyntheticGraphForMem creates a graph without b.Helper for standalone use.
func buildSyntheticGraphForMem(nodeCount int) *graph.Graph {
g := graph.New()
for i := range nodeCount {
g.AddNode(&graph.Node{
ID: fmt.Sprintf("pkg/file%d.go::Func%d", i/100, i),
Kind: graph.KindFunction,
Name: fmt.Sprintf("Func%d", i),
FilePath: fmt.Sprintf("pkg/file%d.go", i/100),
StartLine: (i % 100) + 1,
EndLine: (i % 100) + 10,
Language: "go",
})
}
for i := range nodeCount {
t1 := (i + 1) % nodeCount
t2 := (i + 7) % nodeCount
g.AddEdge(&graph.Edge{
From: fmt.Sprintf("pkg/file%d.go::Func%d", i/100, i),
To: fmt.Sprintf("pkg/file%d.go::Func%d", t1/100, t1),
Kind: graph.EdgeCalls,
})
g.AddEdge(&graph.Edge{
From: fmt.Sprintf("pkg/file%d.go::Func%d", i/100, i),
To: fmt.Sprintf("pkg/file%d.go::Func%d", t2/100, t2),
Kind: graph.EdgeCalls,
})
}
return g
}
// BenchmarkDetectCommunities_Memory measures heap allocation for community detection.
func BenchmarkDetectCommunities_Memory(b *testing.B) {
sizes := []struct {
name string
nodes int
}{
{"500_nodes", 500},
{"2K_nodes", 2_000},
{"5K_nodes", 5_000},
}
for _, sz := range sizes {
b.Run(sz.name, func(b *testing.B) {
b.ReportAllocs()
g := buildSyntheticGraphForMem(sz.nodes)
b.ResetTimer()
for b.Loop() {
DetectCommunities(g)
}
})
}
}
// BenchmarkDiscoverProcesses_Memory measures heap allocation for process discovery.
func BenchmarkDiscoverProcesses_Memory(b *testing.B) {
sizes := []struct {
name string
nodes int
}{
{"500_nodes", 500},
{"2K_nodes", 2_000},
{"5K_nodes", 5_000},
}
for _, sz := range sizes {
b.Run(sz.name, func(b *testing.B) {
b.ReportAllocs()
g := buildSyntheticGraphForMem(sz.nodes)
b.ResetTimer()
for b.Loop() {
DiscoverProcesses(g)
}
})
}
}
// BenchmarkAnalyzeImpact_Memory measures impact analysis memory usage.
func BenchmarkAnalyzeImpact_Memory(b *testing.B) {
sizes := []struct {
name string
nodes int
}{
{"500_nodes", 500},
{"2K_nodes", 2_000},
}
for _, sz := range sizes {
b.Run(sz.name, func(b *testing.B) {
b.ReportAllocs()
g := buildSyntheticGraphForMem(sz.nodes)
symbolIDs := []string{
"pkg/file0.go::Func0",
"pkg/file1.go::Func100",
}
b.ResetTimer()
for b.Loop() {
AnalyzeImpact(g, symbolIDs, nil, nil)
}
})
}
}
// BenchmarkAnalysis_HeapFootprint measures total heap consumed by each analysis pass.
func BenchmarkAnalysis_HeapFootprint(b *testing.B) {
g := buildSyntheticGraphForMem(3000)
analyses := []struct {
name string
fn func()
}{
{"DetectCommunities", func() { DetectCommunities(g) }},
{"DiscoverProcesses", func() { DiscoverProcesses(g) }},
{"AnalyzeImpact", func() { AnalyzeImpact(g, []string{"pkg/file0.go::Func0"}, nil, nil) }},
{"FindDeadCode", func() { FindDeadCode(g, nil, nil) }},
}
for _, a := range analyses {
b.Run(a.name, func(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
b.StopTimer()
runtime.GC()
var before runtime.MemStats
runtime.ReadMemStats(&before)
b.StartTimer()
a.fn()
b.StopTimer()
runtime.GC()
var after runtime.MemStats
runtime.ReadMemStats(&after)
b.ReportMetric(float64(after.TotalAlloc-before.TotalAlloc), "total-alloc-bytes")
b.StartTimer()
}
})
}
}
+42
View File
@@ -0,0 +1,42 @@
package analysis
import (
"testing"
"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"
)
func buildGraph(b *testing.B) *graph.Graph {
b.Helper()
g := graph.New()
reg := parser.NewRegistry()
languages.RegisterAll(reg)
idx := indexer.New(g, reg, config.IndexConfig{}, zap.NewNop())
_, err := idx.Index("../..")
if err != nil {
b.Fatal(err)
}
return g
}
func BenchmarkDetectCommunities(b *testing.B) {
g := buildGraph(b)
b.ResetTimer()
for b.Loop() {
DetectCommunities(g)
}
}
func BenchmarkDiscoverProcesses(b *testing.B) {
g := buildGraph(b)
b.ResetTimer()
for b.Loop() {
DiscoverProcesses(g)
}
}
+274
View File
@@ -0,0 +1,274 @@
package analysis
import (
"math/rand"
"sort"
"github.com/zzet/gortex/internal/graph"
)
// BetweennessResult holds per-node betweenness-centrality scores.
//
// Betweenness measures how often a node lies on a shortest path
// between two other nodes — a high score marks a structural
// bottleneck that load flows through. It complements PageRank
// (which measures being depended-on) by measuring being a conduit.
type BetweennessResult struct {
// Scores maps node ID to its betweenness value. Larger means the
// node sits on more shortest paths; values are best read relative
// to Max.
Scores map[string]float64
// Max is the largest score in Scores — the normaliser callers use
// to project centrality onto a 0..1 / 0..100 scale.
Max float64
// Sampled reports whether the sampled-pivot fast path was used.
// False means every node was a source (exact Brandes').
Sampled bool
// Pivots is the number of source nodes the accumulation ran from.
// Equals the node count on the exact path.
Pivots int
}
// ScoreOf returns the betweenness score for a node, or 0 when absent.
func (r *BetweennessResult) ScoreOf(id string) float64 {
if r == nil {
return 0
}
return r.Scores[id]
}
// Betweenness tuning.
//
// betweennessExactThreshold is the node-count cutoff between the two
// paths: at or below it every node is a source (exact Brandes',
// O(V*E)); above it the sampled fast path runs from a bounded number
// of pivots (O(k*E)) and rescales by V/k.
//
// betweennessPivots bounds k on the sampled path. ~256 source pivots
// give a stable ranking on graphs into the hundreds of thousands of
// nodes — betweenness is needed for ordering hotspots, not for an
// exact path count, and the sampling error shrinks with graph size.
//
// betweennessSeed fixes the pivot-sampling RNG so a given graph
// yields the same scores on every run.
const (
betweennessExactThreshold = 2000
betweennessPivots = 256
betweennessSeed = 0x6f7274
)
// ComputeBetweenness runs betweenness centrality over the call /
// reference graph. It adapts to graph size: small graphs get exact
// Brandes' (every node a source); large graphs switch to the
// sampled-pivot fast path — accumulate from k randomly chosen
// sources and scale the result by V/k. Both paths share the same
// single-source accumulation kernel, so the only difference is which
// sources feed it.
//
// Only EdgeCalls and EdgeReferences participate, matching
// ComputePageRank: structural edges (defines, member_of, imports)
// would swamp the dependency signal. Edges are treated as unweighted
// and directed — shortest paths are hop counts found by BFS.
//
// Pivot sampling is seeded with a fixed seed, so results are
// reproducible run to run.
func ComputeBetweenness(g graph.Store) *BetweennessResult {
if g == nil {
return &BetweennessResult{Scores: map[string]float64{}}
}
// Betweenness measures shortest-path centrality across the
// call / reference subgraph; only function and method nodes carry
// those edges. The scoring kernel only ever touches node IDs, so
// the unfiltered AllNodes() pull was wasted on the other 90% of
// the node table AND on the 9 unused columns of every retained
// row. NodeIDsByKinds returns just the id column from a single
// query; NodesByKindsScanner is the legacy fallback for
// backends that haven't shipped the id projection yet.
betweennessKinds := []graph.EdgeKind{graph.EdgeCalls, graph.EdgeReferences}
bcNodeKinds := []graph.NodeKind{graph.KindFunction, graph.KindMethod}
var ids []string
if scan, ok := g.(graph.NodeIDsByKinds); ok {
ids = scan.NodeIDsByKinds(bcNodeKinds)
} else if scan, ok := g.(graph.NodesByKindsScanner); ok {
ns := scan.NodesByKinds(bcNodeKinds)
ids = make([]string, 0, len(ns))
for _, nd := range ns {
ids = append(ids, nd.ID)
}
} else {
all := g.AllNodes()
ids = make([]string, 0, len(all))
for _, nd := range all {
if nd.Kind == graph.KindFunction || nd.Kind == graph.KindMethod {
ids = append(ids, nd.ID)
}
}
}
// Federation proxy nodes carry real kinds, so the kind filter above
// keeps them — drop them here so a remote stub is never a pivot.
ids = excludeProxyIDs(ids)
n := len(ids)
if n == 0 {
return &BetweennessResult{Scores: map[string]float64{}}
}
// Stable node ordering: betweenness itself is order-independent,
// but a deterministic order makes the sampled pivot pick
// reproducible regardless of the iteration order
// NodeIDsByKinds happens to return.
sort.Strings(ids)
// Forward adjacency over the call / reference subgraph.
// EdgeAdjacencyForKinds returns only the (from, to) projection of
// function/method endpoints — the disk path collapses to one
// join with both endpoint kinds enforced in the store, so
// neither the cross-kind edges nor the ~10 unused columns are
// ever materialized. Falls back to EdgesByKinds (and then
// EdgesByKind per kind) on backends that don't implement the
// adjacency capability.
adj := make(map[string][]string, n)
if adjScan, ok := g.(graph.EdgeAdjacencyForKinds); ok {
for pair := range adjScan.EdgeAdjacencyForKinds(betweennessKinds, bcNodeKinds) {
adj[pair[0]] = append(adj[pair[0]], pair[1])
}
} else if es, ok := g.(graph.EdgesByKindsScanner); ok {
for e := range es.EdgesByKinds(betweennessKinds) {
if e == nil {
continue
}
adj[e.From] = append(adj[e.From], e.To)
}
} else {
for _, kind := range betweennessKinds {
for e := range g.EdgesByKind(kind) {
if e == nil {
continue
}
adj[e.From] = append(adj[e.From], e.To)
}
}
}
score := make(map[string]float64, n)
for _, id := range ids {
score[id] = 0
}
// Adaptive fast path: exact for small graphs, sampled otherwise.
sources := ids
sampled := false
if n > betweennessExactThreshold {
sampled = true
sources = samplePivots(ids, betweennessPivots)
}
for _, src := range sources {
brandesAccumulate(src, ids, adj, score)
}
// Rescale the sampled estimate up to a full-source equivalent so
// the magnitude is comparable to the exact path.
if sampled && len(sources) > 0 {
scale := float64(n) / float64(len(sources))
for id := range score {
score[id] *= scale
}
}
var max float64
for _, v := range score {
if v > max {
max = v
}
}
return &BetweennessResult{
Scores: score,
Max: max,
Sampled: sampled,
Pivots: len(sources),
}
}
// samplePivots picks k distinct source nodes from ids using a
// fixed-seed RNG, so the chosen pivots — and therefore the resulting
// scores — are identical on every run. When k is at or above the
// node count the whole set is returned (the caller would not be on
// the sampled path in that case, but the guard keeps samplePivots
// total).
func samplePivots(ids []string, k int) []string {
if k >= len(ids) {
out := make([]string, len(ids))
copy(out, ids)
return out
}
rng := rand.New(rand.NewSource(betweennessSeed))
perm := rng.Perm(len(ids))
out := make([]string, k)
for i := range k {
out[i] = ids[perm[i]]
}
return out
}
// brandesAccumulate runs one single-source pass of Brandes'
// algorithm: a BFS from src counts shortest paths, then a reverse
// dependency-accumulation sweep folds this source's contribution
// into score. Intermediate vertices (everything but src and the
// target) collect the credit, which is exactly betweenness.
//
// The graph is unweighted, so a plain BFS yields shortest paths and
// the per-source cost is O(V+E); summed over all sources this is the
// O(V*E) exact bound, or O(k*E) when only k sources feed it.
func brandesAccumulate(src string, ids []string, adj map[string][]string, score map[string]float64) {
// sigma: number of shortest paths from src to a node.
// dist: hop distance from src (-1 = unreached).
// preds: shortest-path predecessors of a node.
// order: nodes in non-decreasing distance — the BFS visitation
// order, replayed in reverse for the accumulation sweep.
sigma := make(map[string]float64, len(ids))
dist := make(map[string]int, len(ids))
preds := make(map[string][]string, len(ids))
for _, id := range ids {
dist[id] = -1
}
sigma[src] = 1
dist[src] = 0
queue := []string{src}
order := make([]string, 0, len(ids))
for len(queue) > 0 {
v := queue[0]
queue = queue[1:]
order = append(order, v)
for _, w := range adj[v] {
// First time w is reached — record its distance and
// enqueue it.
if dist[w] < 0 {
dist[w] = dist[v] + 1
queue = append(queue, w)
}
// w found again along another shortest path — add v's
// path count and register v as a predecessor.
if dist[w] == dist[v]+1 {
sigma[w] += sigma[v]
preds[w] = append(preds[w], v)
}
}
}
// Reverse sweep: pop nodes farthest-first and push their
// accumulated dependency back onto their predecessors.
delta := make(map[string]float64, len(ids))
for i := len(order) - 1; i >= 0; i-- {
w := order[i]
for _, v := range preds[w] {
if sigma[w] != 0 {
delta[v] += (sigma[v] / sigma[w]) * (1 + delta[w])
}
}
// The source itself is never an intermediate vertex.
if w != src {
score[w] += delta[w]
}
}
}
+411
View File
@@ -0,0 +1,411 @@
package analysis
import (
"fmt"
"math"
"testing"
"github.com/zzet/gortex/internal/graph"
)
// pathGraph builds a directed path a0 -> a1 -> ... -> a(n-1) over
// EdgeCalls. On a path of length n the interior node at index i has
// an analytic betweenness of i * (n-1-i): every source at index < i
// reaches every target at index > i through it.
func pathGraph(n int) *graph.Graph {
g := graph.New()
for i := 0; i < n; i++ {
id := fmt.Sprintf("p%d", i)
g.AddNode(&graph.Node{ID: id, Kind: graph.KindFunction, Name: id})
}
for i := 0; i < n-1; i++ {
g.AddEdge(&graph.Edge{
From: fmt.Sprintf("p%d", i),
To: fmt.Sprintf("p%d", i+1),
Kind: graph.EdgeCalls,
})
}
return g
}
// relayStar builds a directed star where every leaf calls the hub and
// the hub calls every leaf. The only path between two distinct leaves
// runs leaf -> hub -> leaf, so the hub's analytic betweenness is
// k*(k-1) for k leaves and every leaf scores 0.
func relayStar(leaves int) *graph.Graph {
g := graph.New()
g.AddNode(&graph.Node{ID: "hub", Kind: graph.KindFunction, Name: "hub"})
for i := 0; i < leaves; i++ {
id := fmt.Sprintf("leaf%d", i)
g.AddNode(&graph.Node{ID: id, Kind: graph.KindFunction, Name: id})
g.AddEdge(&graph.Edge{From: id, To: "hub", Kind: graph.EdgeCalls})
g.AddEdge(&graph.Edge{From: "hub", To: id, Kind: graph.EdgeCalls})
}
return g
}
func TestComputeBetweenness_EmptyGraph(t *testing.T) {
r := ComputeBetweenness(graph.New())
if len(r.Scores) != 0 {
t.Errorf("empty graph should yield no scores, got %d", len(r.Scores))
}
if r.Max != 0 {
t.Errorf("Max = %f, want 0", r.Max)
}
if r.Sampled {
t.Errorf("empty graph should not report Sampled")
}
}
func TestComputeBetweenness_NilGraph(t *testing.T) {
r := ComputeBetweenness(nil)
if r == nil || len(r.Scores) != 0 {
t.Fatalf("nil graph should yield an empty result, got %+v", r)
}
}
// TestComputeBetweenness_ExactPathGraph checks exact Brandes' against
// the closed-form betweenness of a directed path. Every node's score
// is hand-checkable: index i on a path of n nodes scores i*(n-1-i).
func TestComputeBetweenness_ExactPathGraph(t *testing.T) {
tests := []struct {
name string
n int
want map[string]float64
}{
{
name: "path of 5",
n: 5,
// p0,p4 are endpoints (0). p1: 1*3=3. p2: 2*2=4. p3: 3*1=3.
want: map[string]float64{"p0": 0, "p1": 3, "p2": 4, "p3": 3, "p4": 0},
},
{
name: "path of 4",
n: 4,
// p1: 1*2=2. p2: 2*1=2.
want: map[string]float64{"p0": 0, "p1": 2, "p2": 2, "p3": 0},
},
{
name: "path of 3",
n: 3,
// only p1 is interior: 1*1=1.
want: map[string]float64{"p0": 0, "p1": 1, "p2": 0},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := ComputeBetweenness(pathGraph(tt.n))
if r.Sampled {
t.Fatalf("small graph (%d nodes) must use the exact path", tt.n)
}
if r.Pivots != tt.n {
t.Errorf("exact path should run from every node: Pivots=%d, want %d", r.Pivots, tt.n)
}
for id, want := range tt.want {
if got := r.ScoreOf(id); math.Abs(got-want) > 1e-9 {
t.Errorf("betweenness(%s) = %v, want %v", id, got, want)
}
}
})
}
}
// TestComputeBetweenness_ExactStarGraph checks exact Brandes' against
// the closed-form betweenness of a relay star: the hub scores
// k*(k-1), every leaf scores 0.
func TestComputeBetweenness_ExactStarGraph(t *testing.T) {
tests := []struct {
name string
leaves int
wantHub float64
}{
{name: "3 leaves", leaves: 3, wantHub: 6}, // 3*2
{name: "4 leaves", leaves: 4, wantHub: 12}, // 4*3
{name: "6 leaves", leaves: 6, wantHub: 30}, // 6*5
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := ComputeBetweenness(relayStar(tt.leaves))
if r.Sampled {
t.Fatalf("small graph must use the exact path")
}
if got := r.ScoreOf("hub"); math.Abs(got-tt.wantHub) > 1e-9 {
t.Errorf("hub betweenness = %v, want %v", got, tt.wantHub)
}
if r.Max != r.ScoreOf("hub") {
t.Errorf("hub should hold the max score: max=%v hub=%v", r.Max, r.ScoreOf("hub"))
}
for i := 0; i < tt.leaves; i++ {
leaf := fmt.Sprintf("leaf%d", i)
if got := r.ScoreOf(leaf); math.Abs(got) > 1e-9 {
t.Errorf("leaf %s should have zero betweenness, got %v", leaf, got)
}
}
})
}
}
// TestComputeBetweenness_AdaptiveThreshold verifies the fast path
// switch: at or below betweennessExactThreshold every node is a
// source; above it the sampled path runs from a bounded pivot set.
func TestComputeBetweenness_AdaptiveThreshold(t *testing.T) {
tests := []struct {
name string
nodes int
wantSampled bool
wantPivots int
}{
{name: "below threshold stays exact", nodes: betweennessExactThreshold - 1, wantSampled: false, wantPivots: betweennessExactThreshold - 1},
{name: "at threshold stays exact", nodes: betweennessExactThreshold, wantSampled: false, wantPivots: betweennessExactThreshold},
{name: "above threshold goes sampled", nodes: betweennessExactThreshold + 1, wantSampled: true, wantPivots: betweennessPivots},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := ComputeBetweenness(pathGraph(tt.nodes))
if r.Sampled != tt.wantSampled {
t.Errorf("Sampled = %v, want %v (nodes=%d)", r.Sampled, tt.wantSampled, tt.nodes)
}
if r.Pivots != tt.wantPivots {
t.Errorf("Pivots = %d, want %d (nodes=%d)", r.Pivots, tt.wantPivots, tt.nodes)
}
})
}
}
// TestComputeBetweenness_SampledApproximatesExact builds a graph past
// the exact threshold and checks the sampled estimate tracks the
// analytic betweenness of a long directed path. On a path the score
// of index i is i*(n-1-i); the sampled, V/k-rescaled estimate should
// land within a modest relative tolerance for the high-centrality
// interior nodes.
func TestComputeBetweenness_SampledApproximatesExact(t *testing.T) {
n := betweennessExactThreshold + 1500
g := pathGraph(n)
r := ComputeBetweenness(g)
if !r.Sampled {
t.Fatalf("graph of %d nodes should use the sampled path", n)
}
// Check the middle of the path, where betweenness is largest and
// the relative sampling error is smallest.
mid := n / 2
id := fmt.Sprintf("p%d", mid)
want := float64(mid) * float64(n-1-mid)
got := r.ScoreOf(id)
relErr := math.Abs(got-want) / want
const tolerance = 0.20 // 20% — a 256-pivot sample on a 3500-node path
if relErr > tolerance {
t.Errorf("sampled betweenness(%s) = %.0f, want ~%.0f (rel err %.3f > %.2f)",
id, got, want, relErr, tolerance)
}
// The endpoints are never intermediate — they must stay at zero
// regardless of which pivots were sampled.
if got := r.ScoreOf("p0"); got != 0 {
t.Errorf("path endpoint p0 betweenness = %v, want 0", got)
}
if got := r.ScoreOf(fmt.Sprintf("p%d", n-1)); got != 0 {
t.Errorf("path endpoint p%d betweenness = %v, want 0", n-1, got)
}
}
// TestComputeBetweenness_SampledIsDeterministic verifies the
// fixed-seed pivot sampling produces byte-identical scores across
// repeated runs on the same graph.
func TestComputeBetweenness_SampledIsDeterministic(t *testing.T) {
n := betweennessExactThreshold + 800
g := pathGraph(n)
first := ComputeBetweenness(g)
if !first.Sampled {
t.Fatalf("graph of %d nodes should use the sampled path", n)
}
for run := 0; run < 5; run++ {
again := ComputeBetweenness(g)
if again.Pivots != first.Pivots {
t.Fatalf("run %d: Pivots = %d, want %d", run, again.Pivots, first.Pivots)
}
if again.Max != first.Max {
t.Errorf("run %d: Max = %v, want %v", run, again.Max, first.Max)
}
for id, want := range first.Scores {
if got := again.Scores[id]; got != want {
t.Errorf("run %d: score(%s) = %v, want %v — sampling not deterministic", run, id, got, want)
}
}
}
}
// TestComputeBetweenness_LargeGraphCompletes builds a graph well past
// the exact threshold and asserts the sampled fast path returns a
// well-formed result. This exercises the O(k*E) structural fast path
// without a wall-clock bound.
func TestComputeBetweenness_LargeGraphCompletes(t *testing.T) {
n := 12000
g := graph.New()
for i := 0; i < n; i++ {
id := fmt.Sprintf("n%d", i)
g.AddNode(&graph.Node{ID: id, Kind: graph.KindFunction, Name: id})
}
// A wide directed mesh: each node calls the next three. Plenty of
// shortest paths cross the interior so betweenness is non-trivial.
for i := 0; i < n; i++ {
for d := 1; d <= 3 && i+d < n; d++ {
g.AddEdge(&graph.Edge{
From: fmt.Sprintf("n%d", i),
To: fmt.Sprintf("n%d", i+d),
Kind: graph.EdgeCalls,
})
}
}
r := ComputeBetweenness(g)
if !r.Sampled {
t.Fatalf("graph of %d nodes should use the sampled path", n)
}
if r.Pivots != betweennessPivots {
t.Errorf("Pivots = %d, want %d", r.Pivots, betweennessPivots)
}
if len(r.Scores) != n {
t.Errorf("Scores should cover every node: got %d, want %d", len(r.Scores), n)
}
if r.Max <= 0 {
t.Errorf("a connected mesh should have a positive max betweenness, got %v", r.Max)
}
}
// TestComputeBetweenness_OnlyCallAndReferenceEdges verifies that
// structural edges are ignored — a path wired with EdgeDefines
// carries no betweenness.
func TestComputeBetweenness_OnlyCallAndReferenceEdges(t *testing.T) {
g := graph.New()
for _, id := range []string{"x", "y", "z"} {
g.AddNode(&graph.Node{ID: id, Kind: graph.KindFunction, Name: id})
}
g.AddEdge(&graph.Edge{From: "x", To: "y", Kind: graph.EdgeDefines})
g.AddEdge(&graph.Edge{From: "y", To: "z", Kind: graph.EdgeDefines})
r := ComputeBetweenness(g)
if r.Max != 0 {
t.Errorf("structural edges should carry no betweenness, max=%v", r.Max)
}
// References participate exactly like calls.
g2 := graph.New()
for _, id := range []string{"x", "y", "z"} {
g2.AddNode(&graph.Node{ID: id, Kind: graph.KindFunction, Name: id})
}
g2.AddEdge(&graph.Edge{From: "x", To: "y", Kind: graph.EdgeReferences})
g2.AddEdge(&graph.Edge{From: "y", To: "z", Kind: graph.EdgeReferences})
r2 := ComputeBetweenness(g2)
if got := r2.ScoreOf("y"); math.Abs(got-1) > 1e-9 {
t.Errorf("reference-edge path: betweenness(y) = %v, want 1", got)
}
}
// TestFindHotspots_BetweennessComponent verifies the hotspot scorer
// surfaces a pure bottleneck. The relay hub has modest fan-in/out
// relative to a separately wired high-fan-in node, but it sits on
// every leaf-to-leaf shortest path — its Betweenness field must be
// populated and non-zero, and it must rank as a hotspot.
func TestFindHotspots_BetweennessComponent(t *testing.T) {
g := relayStar(8)
// Pad with extra unrelated functions so the graph clears the
// 10-symbol floor the MCP handler enforces.
for i := 0; i < 6; i++ {
id := fmt.Sprintf("extra%d", i)
g.AddNode(&graph.Node{ID: id, Kind: graph.KindFunction, Name: id})
}
communities := &CommunityResult{NodeToComm: map[string]string{}}
result := FindHotspots(g, communities, 0)
var hub *HotspotEntry
for i := range result {
if result[i].ID == "hub" {
hub = &result[i]
break
}
}
if hub == nil {
t.Fatalf("relay hub should be reported as a hotspot, got %d entries", len(result))
}
if hub.Betweenness <= 0 {
t.Errorf("relay hub should carry a positive betweenness component, got %v", hub.Betweenness)
}
// The hub is the single highest-betweenness node, so its
// normalized betweenness should be the 0-100 ceiling.
if math.Abs(hub.Betweenness-100) > 0.01 {
t.Errorf("relay hub betweenness = %v, want 100 (it holds the graph max)", hub.Betweenness)
}
// A leaf is never an intermediate vertex — if it surfaces at all
// its betweenness component is zero.
for i := range result {
if result[i].ID == "leaf0" && result[i].Betweenness != 0 {
t.Errorf("leaf0 betweenness = %v, want 0", result[i].Betweenness)
}
}
}
// TestFindHotspots_BetweennessRaisesRank verifies the betweenness
// term augments — not replaces — the legacy fan-in/out signal: adding
// a bottleneck role to a node strictly raises its complexity score.
func TestFindHotspots_BetweennessRaisesRank(t *testing.T) {
// Baseline: a plain 3-hop chain bridge -> via -> sink, plus
// padding to clear the symbol floor.
build := func(withBottleneck bool) []HotspotEntry {
g := graph.New()
ids := []string{"src", "via", "sink"}
for _, id := range ids {
g.AddNode(&graph.Node{ID: id, Kind: graph.KindFunction, Name: id})
}
g.AddEdge(&graph.Edge{From: "src", To: "via", Kind: graph.EdgeCalls})
g.AddEdge(&graph.Edge{From: "via", To: "sink", Kind: graph.EdgeCalls})
if withBottleneck {
// Route extra callers and callees through `via` so it
// becomes a genuine shortest-path bottleneck.
for i := 0; i < 4; i++ {
in := fmt.Sprintf("in%d", i)
out := fmt.Sprintf("out%d", i)
g.AddNode(&graph.Node{ID: in, Kind: graph.KindFunction, Name: in})
g.AddNode(&graph.Node{ID: out, Kind: graph.KindFunction, Name: out})
g.AddEdge(&graph.Edge{From: in, To: "via", Kind: graph.EdgeCalls})
g.AddEdge(&graph.Edge{From: "via", To: out, Kind: graph.EdgeCalls})
}
}
for i := 0; i < 8; i++ {
id := fmt.Sprintf("pad%d", i)
g.AddNode(&graph.Node{ID: id, Kind: graph.KindFunction, Name: id})
}
return FindHotspots(g, &CommunityResult{NodeToComm: map[string]string{}}, 0)
}
scoreOf := func(entries []HotspotEntry, id string) (float64, bool) {
for _, e := range entries {
if e.ID == id {
return e.ComplexityScore, true
}
}
return 0, false
}
withBottleneck := build(true)
viaScore, ok := scoreOf(withBottleneck, "via")
if !ok {
t.Fatalf("bottleneck node `via` should be reported as a hotspot")
}
if viaScore <= 0 {
t.Errorf("bottleneck node should have a positive complexity score, got %v", viaScore)
}
// The bottleneck node carries both fan-in/out and betweenness, so
// it must outrank the inert padding functions.
if padScore, ok := scoreOf(withBottleneck, "pad0"); ok && viaScore <= padScore {
t.Errorf("bottleneck node (%.2f) should outrank inert padding (%.2f)", viaScore, padScore)
}
}
+872
View File
@@ -0,0 +1,872 @@
package analysis
import (
"fmt"
"math"
"path/filepath"
"sort"
"strconv"
"strings"
"github.com/zzet/gortex/internal/graph"
)
// Community represents a discovered functional cluster in the codebase.
type Community struct {
ID string `json:"id"`
Label string `json:"label"`
Members []string `json:"members"` // node IDs
Files []string `json:"files"` // unique file paths
Size int `json:"size"` // member count
Cohesion float64 `json:"cohesion"` // internal edge density (0-1)
// Hub is the in-cluster-highest-degree member's symbol name —
// the function or type everything else in the cluster connects
// through. Strong semantic disambiguator: "parser/languages ·
// GoExtractor" tells you what the cluster does at a glance,
// where a file-basename like "golang" leaves you guessing.
Hub string `json:"hub,omitempty"`
// ParentID points at the super-community this cluster belongs to
// after the second Louvain pass. Sibling clusters under the same
// parent are typically tightly related (e.g. three
// parser/languages sub-clusters that each specialise around a
// different AST primitive). Empty for top-level / singleton
// communities that have no sibling at the same modularity level.
ParentID string `json:"parent_id,omitempty"`
}
// CommunityResult is the output of community detection.
type CommunityResult struct {
Communities []Community `json:"communities"`
NodeToComm map[string]string `json:"node_to_community"` // nodeID → communityID
Modularity float64 `json:"modularity"`
}
// DetectCommunities runs community detection on the graph. As of
// the Leiden switchover this is a thin wrapper around
// DetectCommunitiesLeiden — the Leiden algorithm delivered 66%
// fewer communities, +25% modularity, and 61% less sibling
// fragmentation on the live gortex graph compared to the legacy
// Louvain implementation, at the cost of ~15% extra CPU time.
//
// The Louvain implementation is preserved as
// DetectCommunitiesLouvain so we can benchmark, A/B, or fall back
// without re-deriving the algorithm.
func DetectCommunities(g graph.Store) *CommunityResult {
return DetectCommunitiesLeiden(g)
}
// DetectCommunitiesLouvain is the original Louvain implementation,
// retained for benchmarking and as a known-good fallback.
func DetectCommunitiesLouvain(g graph.Store) *CommunityResult {
nodes := g.AllNodes()
edges := g.AllEdges()
// Filter to symbol nodes only (skip file and import nodes, and
// cross-daemon federation proxy nodes — they stand in for remote symbols
// and must not form or join local communities).
symbolNodes := make(map[string]bool)
for _, n := range nodes {
if graph.IsProxyNode(n) {
continue
}
// Content sections are leaf knowledge, not code structure — they
// must not form or join code communities (which seed the skills
// router and architecture layers).
if graph.IsContentNode(n) {
continue
}
if n.Kind != graph.KindFile && n.Kind != graph.KindImport {
symbolNodes[n.ID] = true
}
}
// Build adjacency with weights for clustering-relevant edges
type edgeKey struct{ a, b string }
weights := make(map[edgeKey]float64)
for _, e := range edges {
if !symbolNodes[e.From] || !symbolNodes[e.To] {
continue
}
w := edgeWeight(e.Kind)
if w == 0 {
continue
}
// Undirected: add both directions
k1 := edgeKey{e.From, e.To}
k2 := edgeKey{e.To, e.From}
weights[k1] += w
weights[k2] += w
}
// Build neighbor lists
neighbors := make(map[string]map[string]float64)
for k, w := range weights {
if neighbors[k.a] == nil {
neighbors[k.a] = make(map[string]float64)
}
neighbors[k.a][k.b] = w
}
// Total edge weight
var totalWeight float64
for _, w := range weights {
totalWeight += w
}
totalWeight /= 2 // each edge counted twice
if totalWeight == 0 {
return &CommunityResult{NodeToComm: make(map[string]string)}
}
// Weighted degree per node
degree := make(map[string]float64)
for id := range symbolNodes {
for _, w := range neighbors[id] {
degree[id] += w
}
}
// Louvain Phase 1: local moves over the raw symbol graph. Each
// node starts in its own singleton community; we move nodes
// greedily until no move improves modularity.
commIDs := make([]string, 0, len(symbolNodes))
for id := range symbolNodes {
commIDs = append(commIDs, id)
}
sort.Strings(commIDs) // deterministic visitation
comm, commNodes := louvainLocalMoves(commIDs, neighbors, degree, totalWeight)
return finaliseCommunityPartition(nodes, comm, commNodes, neighbors, degree, totalWeight)
}
// disambiguateLabels makes every cluster label unique. The
// passes cascade from most-meaningful to last-resort:
//
// 1. Append the cluster's hub symbol — the highest-in-cluster-degree
// member. "parser/languages · GoExtractor" describes what the
// cluster centers on; "parser/languages · golang" (a file
// basename) leaves you guessing. The hub is what code calls.
//
// 2. Append a file basename when the hub is missing or also
// colliding. The first file (alphabetical) is the fallback.
//
// 3. Size suffix when files match too.
//
// 4. Ordinal tiebreaker for the pathological case where multiple
// clusters truly share modal dir + hub + first file + size.
//
// Deterministic across reruns: the hub is the same when in-cluster
// degrees are stable, files are sorted, and Louvain produces
// communities in a stable order.
func disambiguateLabels(communities []Community) {
appendChip := func(c *Community, chip string) {
if chip == "" {
return
}
c.Label = c.Label + " · " + chip
}
fileBasename := func(c *Community, idx int) string {
if idx >= len(c.Files) {
return ""
}
sample := filepath.Base(c.Files[idx])
if dot := strings.LastIndex(sample, "."); dot > 0 {
sample = sample[:dot]
}
return sample
}
// Stage 1: hub-symbol disambiguation.
{
counts := make(map[string]int)
for _, c := range communities {
counts[c.Label]++
}
for i := range communities {
if counts[communities[i].Label] > 1 {
appendChip(&communities[i], cleanHubName(communities[i].Hub))
}
}
}
// Stages 2a/2b: file-basename disambiguation (first then second
// file) for any label still colliding after the hub pass.
for pass := 0; pass < 2; pass++ {
counts := make(map[string]int)
for _, c := range communities {
counts[c.Label]++
}
for i := range communities {
if counts[communities[i].Label] > 1 {
appendChip(&communities[i], fileBasename(&communities[i], pass))
}
}
}
// Stage 3: size suffix for any label that's still shared. Two
// clusters of different sizes become distinguishable here.
{
counts := make(map[string]int)
for _, c := range communities {
counts[c.Label]++
}
for i := range communities {
if counts[communities[i].Label] > 1 {
communities[i].Label = fmt.Sprintf("%s (%d)", communities[i].Label, communities[i].Size)
}
}
}
// Stage 4: ordinal tiebreaker. Truly identical clusters
// (same dir, same hub, same first file, same size) get a numeric
// suffix so the UI never shows two cards with the same label.
{
counts := make(map[string]int)
for _, c := range communities {
counts[c.Label]++
}
seen := make(map[string]int)
for i := range communities {
lbl := communities[i].Label
if counts[lbl] > 1 {
seen[lbl]++
communities[i].Label = fmt.Sprintf("%s #%d", lbl, seen[lbl])
}
}
}
}
// findHub returns the symbol name of the member with the highest
// in-cluster weighted degree — the "centre" of the cluster.
// In-cluster degree (rather than total degree) matters because we
// want the symbol others in *this* cluster connect to, not the
// most-called function in the entire codebase.
func findHub(members []string, nodeMap map[string]*graph.Node, neighbors map[string]map[string]float64) string {
if len(members) == 0 {
return ""
}
memberSet := make(map[string]bool, len(members))
for _, m := range members {
memberSet[m] = true
}
var hubID string
var hubDeg float64
for _, m := range members {
var deg float64
for n, w := range neighbors[m] {
if memberSet[n] {
deg += w
}
}
// Tie-break on lexicographic ID so the pick is deterministic
// when several members share the top in-cluster degree.
if deg > hubDeg || (deg == hubDeg && hubID == "") || (deg == hubDeg && m < hubID) {
hubDeg = deg
hubID = m
}
}
if hubID == "" {
return ""
}
n := nodeMap[hubID]
if n == nil {
return ""
}
return n.Name
}
// cleanHubName trims a symbol name down to a tag-friendly form.
// Strips Go method-receiver wrapping ("(*Foo).Bar" → "Foo.Bar") and
// caps length so chips don't blow out the card.
func cleanHubName(name string) string {
if name == "" {
return ""
}
// "(*Foo).Bar" → "Foo.Bar"
if strings.HasPrefix(name, "(*") {
if end := strings.Index(name, ")."); end > 2 {
name = name[2:end] + name[end+1:]
}
}
if strings.HasPrefix(name, "(") {
if end := strings.Index(name, ")."); end > 1 {
name = name[1:end] + name[end+1:]
}
}
const max = 32
if len(name) > max {
name = name[:max-1] + "…"
}
return name
}
// louvainLocalMoves runs the inner loop of Louvain phase 1. Used by
// the raw-node pass and again by the phase-2 aggregation pass —
// they're algorithmically identical, only the graph differs.
//
// Inputs:
// - nodeIDs: deterministic visitation order
// - neighbors: adjacency with weights (undirected, both directions stored)
// - degree: weighted degree per node
// - totalWeight: sum of all edge weights / 2 (each edge counted twice in neighbors)
//
// Returns:
// - nodeID → communityID (just the surviving membership)
// - communityID → list of member nodeIDs
//
// We seed each node into its own community and iterate up to ten
// passes, stopping early once no node finds a beneficial move.
func louvainLocalMoves(
nodeIDs []string,
neighbors map[string]map[string]float64,
degree map[string]float64,
totalWeight float64,
) (map[string]string, map[string][]string) {
comm := make(map[string]string, len(nodeIDs))
commNodes := make(map[string][]string, len(nodeIDs))
sigmaIn := make(map[string]float64, len(nodeIDs))
sigmaTot := make(map[string]float64, len(nodeIDs))
for _, id := range nodeIDs {
comm[id] = id
commNodes[id] = []string{id}
sigmaTot[id] = degree[id]
}
improved := true
for pass := 0; pass < 10 && improved; pass++ {
improved = false
for _, id := range nodeIDs {
currentComm := comm[id]
bestComm := currentComm
bestGain := 0.0
commWeights := make(map[string]float64)
for neighbor, w := range neighbors[id] {
commWeights[comm[neighbor]] += w
}
ki := degree[id]
kiIn := commWeights[currentComm]
removeDelta := kiIn - sigmaTot[currentComm]*ki/(2*totalWeight)
for c, wc := range commWeights {
if c == currentComm {
continue
}
gain := wc - sigmaTot[c]*ki/(2*totalWeight) - removeDelta
if gain > bestGain {
bestGain = gain
bestComm = c
}
}
if bestComm != currentComm {
improved = true
old := commNodes[currentComm]
for i, nid := range old {
if nid == id {
commNodes[currentComm] = append(old[:i], old[i+1:]...)
break
}
}
sigmaIn[currentComm] -= 2 * kiIn
sigmaTot[currentComm] -= ki
comm[id] = bestComm
commNodes[bestComm] = append(commNodes[bestComm], id)
sigmaIn[bestComm] += 2 * commWeights[bestComm]
sigmaTot[bestComm] += ki
if len(commNodes[currentComm]) == 0 {
delete(commNodes, currentComm)
delete(sigmaIn, currentComm)
delete(sigmaTot, currentComm)
}
}
}
}
return comm, commNodes
}
// assignDirectoryParents groups peer communities that share their
// directory head (the substring before the first " ·" or " +N dirs"
// disambiguator). Clusters whose head matches no other cluster get
// no parent — they're already singular on the canvas.
//
// Parent ids are stable across reruns because they're derived from
// the head string itself, not from any incidental hash or counter.
func assignDirectoryParents(communities []Community) {
headCount := make(map[string]int)
for _, c := range communities {
headCount[labelHead(c.Label)]++
}
for i := range communities {
head := labelHead(communities[i].Label)
if headCount[head] >= 2 {
communities[i].ParentID = "group/" + head
}
}
}
// labelHead pulls the directory-prefix part out of a fully-formatted
// disambiguated label. We always insert " · " or " +N dirs" between
// the head and any disambiguator, so the head ends right before the
// first occurrence of either.
func labelHead(label string) string {
// First " · " marks where the disambiguator chips start.
if i := strings.Index(label, " · "); i > 0 {
label = label[:i]
}
// " +N dirs" marks the "spread" annotation; the head is what's
// before it.
if i := strings.Index(label, " +"); i > 0 {
label = label[:i]
}
// Trailing " (N)" size or " #N" ordinal disambiguators.
if i := strings.Index(label, " ("); i > 0 {
label = label[:i]
}
if i := strings.Index(label, " #"); i > 0 {
label = label[:i]
}
return label
}
func edgeWeight(kind graph.EdgeKind) float64 {
switch kind {
case graph.EdgeCalls, graph.EdgeSpawns:
return 3.0
case graph.EdgeMemberOf, graph.EdgeParamOf:
return 2.0
case graph.EdgeReferences, graph.EdgeReturns, graph.EdgeTypedAs:
return 1.5
case graph.EdgeImplements, graph.EdgeExtends,
graph.EdgeAliases, graph.EdgeComposes:
return 2.0
case graph.EdgeImports, graph.EdgeDependsOnModule:
return 0.5
case graph.EdgeInstantiates:
return 1.0
default:
// Domain-specific edges (queries, config, flag toggles, emits,
// owns, licensed_as, generated_by, …) deliberately do not
// influence community formation — they pull symbols toward
// per-domain hubs (the flag node, the table node) which is
// noise for code-cluster detection.
return 0
}
}
func computeCohesion(members []string, neighbors map[string]map[string]float64) float64 {
memberSet := make(map[string]bool, len(members))
for _, m := range members {
memberSet[m] = true
}
var internal, total float64
for _, m := range members {
for n, w := range neighbors[m] {
total += w
if memberSet[n] {
internal += w
}
}
}
if total == 0 {
return 0
}
return math.Round(internal/total*100) / 100
}
func computeModularity(comm map[string]string, neighbors map[string]map[string]float64, degree map[string]float64, totalWeight float64) float64 {
if totalWeight == 0 {
return 0
}
var q float64
for i, ci := range comm {
for j, w := range neighbors[i] {
if comm[j] == ci {
q += w - degree[i]*degree[j]/(2*totalWeight)
}
}
}
return math.Round(q/(2*totalWeight)*1000) / 1000
}
// inferCommunityLabel produces a human-meaningful name for a
// Louvain cluster.
//
// The earlier heuristic tallied the *basename* of each file's parent
// directory and picked the modal one. That collapsed structurally
// distinct clusters into duplicate labels — a cluster with 60 files
// scattered across parser/, graph/, dataflow/, mcp/ would still be
// called "languages" if a handful of files happened to live under
// .../parser/languages/. The dashboard then showed dozens of
// "languages" cards that looked identical at a glance.
//
// New strategy:
//
// 1. Find the longest directory prefix shared by every file in the
// cluster. If that prefix is deeper than the repo head + a
// well-known plumbing segment (internal/src/lib/pkg), the
// cluster is "pure" and we name it by the trailing two segments
// of that prefix (e.g. "parser/languages").
//
// 2. Otherwise the cluster spans multiple subdirectories. Pick the
// directory holding the most files and label it
// "<modalDir> +N dirs" so the reader can immediately tell this
// is a wiring/mixed cluster — different from the pure case and
// different from other mixed clusters as long as their modal
// directory or spread differs.
//
// 3. Fall back to the shared-name-prefix heuristic only when the
// file-based path produces nothing meaningful, and finally to a
// numeric cluster id.
func inferCommunityLabel(members []string, nodeMap map[string]*graph.Node, files []string) string {
if len(files) == 0 {
return fmt.Sprintf("cluster-%d", len(members))
}
if pure := pureClusterLabel(files); pure != "" {
return pure
}
if mixed := mixedClusterLabel(files); mixed != "" {
return mixed
}
if np := namePrefixLabel(members, nodeMap); np != "" {
return np
}
return filepath.Dir(files[0])
}
// pureClusterLabel returns a name for clusters whose files share a
// meaningful directory ancestor (deeper than repo/plumbing). Returns
// "" when no such ancestor exists, signalling a mixed cluster.
func pureClusterLabel(files []string) string {
pfx := longestCommonDirPrefix(files)
if pfx == "" {
return ""
}
trimmed := stripPlumbingPrefix(pfx)
if trimmed == "" {
// The shared ancestor was just the repo head or a generic
// plumbing wrapper — not informative.
return ""
}
return trailingPathSegments(trimmed, 2)
}
// mixedClusterLabel names a cluster whose files spread across many
// directories. We surface the modal directory plus a spread count
// so two mixed clusters with different modes don't look identical.
func mixedClusterLabel(files []string) string {
dirCount := make(map[string]int)
for _, f := range files {
dirCount[filepath.Dir(f)]++
}
if len(dirCount) == 0 {
return ""
}
var bestDir string
var bestCount int
for d, c := range dirCount {
if c > bestCount || (c == bestCount && d < bestDir) {
bestCount = c
bestDir = d
}
}
if bestDir == "" {
return ""
}
trimmed := stripPlumbingPrefix(bestDir)
if trimmed == "" {
trimmed = bestDir
}
name := trailingPathSegments(trimmed, 2)
if name == "" {
name = trimmed
}
if len(dirCount) > 1 {
return fmt.Sprintf("%s +%d dirs", name, len(dirCount)-1)
}
return name
}
// longestCommonDirPrefix returns the longest directory path shared
// by every file path. Returns "" when no shared ancestor exists
// (different repo heads, etc.).
func longestCommonDirPrefix(paths []string) string {
if len(paths) == 0 {
return ""
}
pfx := filepath.Dir(paths[0])
for _, p := range paths[1:] {
dir := filepath.Dir(p)
for pfx != "" && !isPathPrefix(dir, pfx) {
cut := strings.LastIndex(pfx, "/")
if cut < 0 {
pfx = ""
break
}
pfx = pfx[:cut]
}
if pfx == "" {
return ""
}
}
return pfx
}
// isPathPrefix reports whether `pfx` is a directory ancestor of
// (or equal to) `p`, treating "/"-bounded segments to avoid the
// "foo" / "foobar" false positive.
func isPathPrefix(p, pfx string) bool {
if p == pfx {
return true
}
return strings.HasPrefix(p, pfx+"/")
}
// stripPlumbingPrefix drops the repo head segment and any well-known
// plumbing segment (internal/src/lib/pkg) that carries no signal.
// Returns "" when nothing meaningful remains.
func stripPlumbingPrefix(p string) string {
if i := strings.Index(p, "/"); i >= 0 {
p = p[i+1:]
} else {
return ""
}
for _, plumb := range []string{"internal/", "src/", "lib/", "pkg/"} {
if strings.HasPrefix(p, plumb) {
p = p[len(plumb):]
break
}
}
if p == "internal" || p == "src" || p == "lib" || p == "pkg" {
return ""
}
return p
}
// trailingPathSegments returns the last n non-empty segments of a
// "/"-joined path.
func trailingPathSegments(p string, n int) string {
parts := strings.Split(p, "/")
out := parts[:0]
for _, s := range parts {
if s != "" {
out = append(out, s)
}
}
if len(out) <= n {
return strings.Join(out, "/")
}
return strings.Join(out[len(out)-n:], "/")
}
// namePrefixLabel preserves the legacy "shared identifier prefix"
// heuristic ("HandleUser", "HandleAuth" → "handle") used when the
// file-based paths don't yield anything useful.
func namePrefixLabel(members []string, nodeMap map[string]*graph.Node) string {
prefixCount := make(map[string]int)
for _, mid := range members {
n := nodeMap[mid]
if n == nil {
continue
}
name := n.Name
for i := 1; i < len(name); i++ {
if name[i] >= 'A' && name[i] <= 'Z' {
prefix := strings.ToLower(name[:i])
if len(prefix) >= 3 {
prefixCount[prefix]++
}
break
}
}
}
var bestPrefix string
var bestPrefixCount int
for p, c := range prefixCount {
if c > bestPrefixCount && c >= 3 {
bestPrefixCount = c
bestPrefix = p
}
}
return bestPrefix
}
// finaliseCommunityPartition converts a (nodeID → community label)
// partition into a fully-shaped CommunityResult: renumbered IDs,
// per-cluster files / cohesion / hub, label disambiguation, and
// sibling-group parent assignment. Shared by the in-process Louvain
// path (which builds the partition itself) and the backend-delegated
// path (DetectCommunitiesLouvainBackend, which takes the partition
// from graph.CommunityDetector).
//
// commNodes can be nil; when it is, the function inverts comm to
// recover the per-community member list (one extra pass — only used
// on the backend path where commNodes isn't pre-built).
func finaliseCommunityPartition(
nodes []*graph.Node,
comm map[string]string,
commNodes map[string][]string,
neighbors map[string]map[string]float64,
degree map[string]float64,
totalWeight float64,
) *CommunityResult {
if commNodes == nil {
commNodes = make(map[string][]string, len(comm))
for nid, cid := range comm {
commNodes[cid] = append(commNodes[cid], nid)
}
}
nodeMap := make(map[string]*graph.Node, len(nodes))
for _, n := range nodes {
nodeMap[n.ID] = n
}
result := &CommunityResult{
NodeToComm: make(map[string]string),
}
// Renumber: keep clusters of size >= 2, sort old labels for
// determinism, mint sequential "community-N" names.
oldIDs := make([]string, 0, len(commNodes))
for cid := range commNodes {
if len(commNodes[cid]) >= 2 {
oldIDs = append(oldIDs, cid)
}
}
sort.Strings(oldIDs)
commRemap := make(map[string]string, len(oldIDs))
for i, cid := range oldIDs {
commRemap[cid] = fmt.Sprintf("community-%d", i)
}
for nodeID, cid := range comm {
if newID, ok := commRemap[cid]; ok {
result.NodeToComm[nodeID] = newID
}
}
for oldID, members := range commNodes {
newID, ok := commRemap[oldID]
if !ok {
continue
}
fileSet := make(map[string]bool)
for _, mid := range members {
if n, ok := nodeMap[mid]; ok {
fileSet[n.FilePath] = true
}
}
files := make([]string, 0, len(fileSet))
for f := range fileSet {
files = append(files, f)
}
sort.Strings(files)
c := Community{
ID: newID,
Label: inferCommunityLabel(members, nodeMap, files),
Members: members,
Files: files,
Size: len(members),
Cohesion: computeCohesion(members, neighbors),
Hub: findHub(members, nodeMap, neighbors),
}
result.Communities = append(result.Communities, c)
}
disambiguateLabels(result.Communities)
assignDirectoryParents(result.Communities)
sort.Slice(result.Communities, func(i, j int) bool {
return result.Communities[i].Size > result.Communities[j].Size
})
result.Modularity = computeModularity(comm, neighbors, degree, totalWeight)
return result
}
// DetectCommunitiesLouvainBackend runs Louvain via the backend's
// engine-native implementation (graph.CommunityDetector) and threads
// the resulting partition through
// the same post-processing the in-process DetectCommunitiesLouvain
// uses. The output is shape-identical: every Community label,
// hub, cohesion, parent, and modularity field is populated from
// the partition, so downstream consumers (UI, rerank pipeline)
// can't tell which path produced it.
//
// Returns nil when the backend errors — callers should fall
// through to the in-process path rather than surface a half-done
// CommunityResult.
func DetectCommunitiesLouvainBackend(g graph.Store, cd graph.CommunityDetector) *CommunityResult {
if g == nil || cd == nil {
return nil
}
hits, err := cd.Louvain(graph.CommunityOpts{})
if err != nil || len(hits) == 0 {
return nil
}
nodes := g.AllNodes()
symbolNodes := make(map[string]bool, len(nodes))
for _, n := range nodes {
if n.Kind != graph.KindFile && n.Kind != graph.KindImport {
symbolNodes[n.ID] = true
}
}
// Rebuild the same weighted neighbor view DetectCommunitiesLouvain
// uses — needed for cohesion / hub / modularity. The work is
// O(V + E) per call; small relative to the engine-native
// partitioning save.
type edgeKey struct{ a, b string }
weights := make(map[edgeKey]float64)
for _, e := range g.AllEdges() {
if !symbolNodes[e.From] || !symbolNodes[e.To] {
continue
}
w := edgeWeight(e.Kind)
if w == 0 {
continue
}
weights[edgeKey{e.From, e.To}] += w
weights[edgeKey{e.To, e.From}] += w
}
neighbors := make(map[string]map[string]float64)
for k, w := range weights {
if neighbors[k.a] == nil {
neighbors[k.a] = make(map[string]float64)
}
neighbors[k.a][k.b] = w
}
var totalWeight float64
for _, w := range weights {
totalWeight += w
}
totalWeight /= 2
degree := make(map[string]float64, len(symbolNodes))
for id := range symbolNodes {
for _, w := range neighbors[id] {
degree[id] += w
}
}
comm := make(map[string]string, len(hits))
for _, h := range hits {
if !symbolNodes[h.NodeID] {
continue
}
comm[h.NodeID] = strconv.FormatInt(h.CommunityID, 10)
}
if len(comm) == 0 {
return nil
}
return finaliseCommunityPartition(nodes, comm, nil, neighbors, degree, totalWeight)
}
+236
View File
@@ -0,0 +1,236 @@
package analysis
import "testing"
func TestPureClusterLabel(t *testing.T) {
tests := []struct {
name string
files []string
want string
}{
{
name: "all files under one deep dir",
files: []string{
"gortex/internal/parser/languages/cpp.go",
"gortex/internal/parser/languages/dart.go",
"gortex/internal/parser/languages/python.go",
},
want: "parser/languages",
},
{
name: "all files under repo + plumbing only",
files: []string{
"gortex/internal/parser/extractor.go",
"gortex/internal/graph/edge.go",
},
want: "",
},
{
name: "single file → plumbing stripped from directory",
files: []string{
"gortex/internal/server/dashboard.go",
},
want: "server",
},
{
name: "empty",
files: []string{},
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := pureClusterLabel(tt.files)
if got != tt.want {
t.Errorf("pureClusterLabel(%v) = %q; want %q", tt.files, got, tt.want)
}
})
}
}
func TestMixedClusterLabel(t *testing.T) {
tests := []struct {
name string
files []string
want string
}{
{
name: "spread across many top-level dirs surfaces spread",
files: []string{
"gortex/internal/parser/languages/abap.go",
"gortex/internal/parser/languages/actionscript.go",
"gortex/internal/parser/languages/ada.go",
"gortex/internal/parser/extractor.go",
"gortex/internal/dataflow/dataflow.go",
"gortex/internal/graph/edge.go",
"gortex/internal/graph/node.go",
"gortex/internal/mcp/tools_ast.go",
"gortex/internal/llm/daemon_backend.go",
},
want: "parser/languages +5 dirs",
},
{
name: "two dirs, modal wins with +1 dirs",
files: []string{
"gortex/internal/foo/a.go",
"gortex/internal/foo/b.go",
"gortex/internal/bar/x.go",
},
want: "foo +1 dirs",
},
{
name: "single dir → no spread annotation",
files: []string{
"gortex/internal/foo/a.go",
"gortex/internal/foo/b.go",
},
want: "foo",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := mixedClusterLabel(tt.files)
if got != tt.want {
t.Errorf("mixedClusterLabel = %q; want %q", got, tt.want)
}
})
}
}
func TestDisambiguateLabelsCascadesToSizeSuffix(t *testing.T) {
// Two clusters share modal dir AND first-file AND second-file —
// only the size suffix breaks the tie.
communities := []Community{
{ID: "a", Label: "server", Size: 50, Files: []string{"server/h.go", "server/m.go"}},
{ID: "b", Label: "server", Size: 70, Files: []string{"server/h.go", "server/m.go"}},
}
disambiguateLabels(communities)
if communities[0].Label == communities[1].Label {
t.Fatalf("size tiebreaker failed: both %q", communities[0].Label)
}
}
func TestDisambiguateLabelsOrdinalTiebreaker(t *testing.T) {
// Three clusters with the same dir, same first two files, AND
// the same size — only the ordinal tiebreaker can split them.
communities := []Community{
{ID: "a", Label: "mcp", Size: 15, Files: []string{"mcp/memories.go", "mcp/notes.go"}},
{ID: "b", Label: "mcp", Size: 15, Files: []string{"mcp/memories.go", "mcp/notes.go"}},
{ID: "c", Label: "mcp", Size: 15, Files: []string{"mcp/memories.go", "mcp/notes.go"}},
}
disambiguateLabels(communities)
labels := map[string]bool{}
for _, c := range communities {
if labels[c.Label] {
t.Fatalf("ordinal tiebreaker failed: duplicate %q", c.Label)
}
labels[c.Label] = true
}
}
func TestDisambiguateLabelsUsesHubFirst(t *testing.T) {
// Two pure clusters under the same directory should get their
// hub-symbol names appended — the most meaningful disambiguator.
// File basenames are only the fallback when the hub is missing.
communities := []Community{
{
ID: "community-1",
Label: "parser/languages",
Hub: "GoExtractor",
Files: []string{
"gortex/internal/parser/languages/cpp.go",
"gortex/internal/parser/languages/csharp.go",
},
},
{
ID: "community-2",
Label: "parser/languages",
Hub: "DartExtractor",
Files: []string{
"gortex/internal/parser/languages/dart.go",
"gortex/internal/parser/languages/flutter.go",
},
},
{
ID: "community-3",
Label: "server",
Hub: "RunServer",
Files: []string{"gortex/internal/server/handler.go"},
},
}
disambiguateLabels(communities)
wantA := "parser/languages · GoExtractor"
wantB := "parser/languages · DartExtractor"
if communities[0].Label != wantA {
t.Errorf("community[0] = %q; want %q", communities[0].Label, wantA)
}
if communities[1].Label != wantB {
t.Errorf("community[1] = %q; want %q", communities[1].Label, wantB)
}
if communities[2].Label != "server" {
t.Errorf("unique label was modified: %q", communities[2].Label)
}
}
func TestDisambiguateFallsBackToFileWhenNoHub(t *testing.T) {
// When the hub is missing (e.g. legacy data, no member with a
// resolvable name), the cascade falls back to file basenames.
communities := []Community{
{
ID: "a", Label: "parser/languages", Hub: "",
Files: []string{"gortex/internal/parser/languages/cpp.go"},
},
{
ID: "b", Label: "parser/languages", Hub: "",
Files: []string{"gortex/internal/parser/languages/dart.go"},
},
}
disambiguateLabels(communities)
if communities[0].Label == communities[1].Label {
t.Fatalf("fallback failed: both %q", communities[0].Label)
}
}
func TestCleanHubName(t *testing.T) {
cases := map[string]string{
"": "",
"runServer": "runServer",
"(*Extractor).Extract": "Extractor.Extract",
"(Receiver).Method": "Receiver.Method",
"WayTooLongFunctionNameOfAReallyLongType.WithAMethod": "WayTooLongFunctionNameOfAReally…",
}
for in, want := range cases {
got := cleanHubName(in)
if got != want {
t.Errorf("cleanHubName(%q) = %q; want %q", in, got, want)
}
}
}
func TestInferCommunityLabelDistinguishesSameBasename(t *testing.T) {
// Two clusters whose basenames collide ("languages") but whose
// contents differ should produce different labels.
pureFiles := []string{
"gortex/internal/parser/languages/cpp.go",
"gortex/internal/parser/languages/dart.go",
"gortex/internal/parser/languages/python.go",
}
mixedFiles := []string{
"gortex/internal/parser/languages/abap.go",
"gortex/internal/parser/extractor.go",
"gortex/internal/dataflow/dataflow.go",
"gortex/internal/graph/edge.go",
"gortex/internal/mcp/tools_ast.go",
}
pure := inferCommunityLabel(nil, nil, pureFiles)
mixed := inferCommunityLabel(nil, nil, mixedFiles)
if pure == mixed {
t.Fatalf("two structurally distinct clusters share label %q (regression — naming collision is back)", pure)
}
if pure != "parser/languages" {
t.Errorf("pure cluster label = %q; want %q", pure, "parser/languages")
}
if mixed == "languages" || mixed == "parser/languages" {
t.Errorf("mixed cluster label = %q; should surface its spread", mixed)
}
}
@@ -0,0 +1,68 @@
package analysis
import (
"testing"
)
func TestAssignDirectoryParents_GroupsSiblings(t *testing.T) {
// Three sibling clusters under parser/languages should share a
// parent. A solo cluster with a different head gets no parent.
communities := []Community{
{ID: "community-1", Label: "parser/languages +5 dirs · GoExtractor"},
{ID: "community-2", Label: "parser/languages +4 dirs · DartExtractor"},
{ID: "community-3", Label: "parser/languages · cssExtractor"},
{ID: "community-4", Label: "contracts · resolveTypeInFile"},
}
assignDirectoryParents(communities)
if communities[0].ParentID == "" || communities[0].ParentID != communities[1].ParentID || communities[0].ParentID != communities[2].ParentID {
t.Errorf("parser/languages siblings should share a parent; got %q / %q / %q",
communities[0].ParentID, communities[1].ParentID, communities[2].ParentID)
}
if communities[3].ParentID != "" {
t.Errorf("solo contracts cluster got an invented parent: %q", communities[3].ParentID)
}
}
func TestAssignDirectoryParents_StripsAllDisambiguators(t *testing.T) {
// labelHead must peel off every disambiguator suffix the
// disambiguation cascade can produce: " · sample", " +N dirs",
// " (N)", " #N".
cases := map[string]string{
"parser/languages": "parser/languages",
"parser/languages +5 dirs": "parser/languages",
"parser/languages · GoExtractor": "parser/languages",
"parser/languages +5 dirs · GoExtractor": "parser/languages",
"parser/languages · GoExtractor (152)": "parser/languages",
"parser/languages · GoExtractor (152) #3": "parser/languages",
"contracts · resolve": "contracts",
}
for input, want := range cases {
got := labelHead(input)
if got != want {
t.Errorf("labelHead(%q) = %q; want %q", input, got, want)
}
}
}
func TestAssignDirectoryParents_ParentIdsAreStable(t *testing.T) {
// Parent ids should be a pure function of the head string, so
// reruns produce identical ids. Important because the UI keys
// off them and we don't want them shifting on a refresh.
pre := []Community{
{ID: "a", Label: "parser/languages +1 dirs · A"},
{ID: "b", Label: "parser/languages · B"},
}
post := []Community{
{ID: "a", Label: "parser/languages +1 dirs · A"},
{ID: "b", Label: "parser/languages · B"},
}
assignDirectoryParents(pre)
assignDirectoryParents(post)
if pre[0].ParentID != post[0].ParentID || pre[1].ParentID != post[1].ParentID {
t.Errorf("parent ids drifted across runs: %q/%q vs %q/%q",
pre[0].ParentID, pre[1].ParentID, post[0].ParentID, post[1].ParentID)
}
if pre[0].ParentID != "group/parser/languages" {
t.Errorf("parent id format unexpected: %q (want 'group/parser/languages')", pre[0].ParentID)
}
}
+294
View File
@@ -0,0 +1,294 @@
package analysis
import (
"sort"
"github.com/zzet/gortex/internal/graph"
)
// ComponentResult is one connected component returned by
// ComputeWCC / ComputeSCC. Members are sorted ascending so the
// output is deterministic across runs.
type ComponentResult struct {
ID int `json:"id"`
Members []string `json:"members"`
Size int `json:"size"`
}
// ComponentOptions filters the working set the algorithm runs
// against. Empty NodeKinds / EdgeKinds means "all kinds".
type ComponentOptions struct {
NodeKinds []graph.NodeKind
EdgeKinds []graph.EdgeKind
// MinSize trims trivial singleton components from the
// response — common for SCC where every non-cyclic symbol
// is its own 1-element SCC.
MinSize int
}
// ComputeWCC returns the weakly connected components of g — pairs
// of nodes reachable from each other when every edge is treated
// as undirected. Components are sorted by size descending; ties
// broken by member ID for determinism.
//
// O(V + E). Used as the fallback when the backing graph.Store
// does not implement graph.ComponentFinder.
func ComputeWCC(g graph.Store, opts ComponentOptions) []ComponentResult {
if g == nil {
return nil
}
nodeAllow := makeComponentKindAllow(opts.NodeKinds)
edgeAllow := makeComponentEdgeAllow(opts.EdgeKinds)
// Build a dense int index over allowed nodes.
nodes := g.AllNodes()
idx := make(map[string]int, len(nodes))
dense := make([]string, 0, len(nodes))
for _, n := range nodes {
if n == nil || !nodeAllow(n.Kind) {
continue
}
idx[n.ID] = len(dense)
dense = append(dense, n.ID)
}
if len(dense) == 0 {
return nil
}
// Undirected adjacency over allowed edges.
adj := make([][]int, len(dense))
for _, e := range g.AllEdges() {
if e == nil || !edgeAllow(e.Kind) {
continue
}
i, ok1 := idx[e.From]
j, ok2 := idx[e.To]
if !ok1 || !ok2 || i == j {
continue
}
adj[i] = append(adj[i], j)
adj[j] = append(adj[j], i)
}
// Union-find equivalence: BFS from each unseen node, mark
// every reachable node with the same component label.
comp := make([]int, len(dense))
for i := range comp {
comp[i] = -1
}
next := 0
queue := make([]int, 0, 64)
for i := range dense {
if comp[i] != -1 {
continue
}
label := next
next++
comp[i] = label
queue = append(queue[:0], i)
for len(queue) > 0 {
cur := queue[0]
queue = queue[1:]
for _, nb := range adj[cur] {
if comp[nb] == -1 {
comp[nb] = label
queue = append(queue, nb)
}
}
}
}
return collectComponents(dense, comp, opts.MinSize)
}
// ComputeSCC returns the strongly connected components of g —
// pairs of nodes mutually reachable along directed edges. Uses
// an iterative Tarjan's algorithm to avoid blowing the recursion
// stack on a deep call graph. O(V + E).
func ComputeSCC(g graph.Store, opts ComponentOptions) []ComponentResult {
if g == nil {
return nil
}
nodeAllow := makeComponentKindAllow(opts.NodeKinds)
edgeAllow := makeComponentEdgeAllow(opts.EdgeKinds)
nodes := g.AllNodes()
idx := make(map[string]int, len(nodes))
dense := make([]string, 0, len(nodes))
for _, n := range nodes {
if n == nil || !nodeAllow(n.Kind) {
continue
}
idx[n.ID] = len(dense)
dense = append(dense, n.ID)
}
if len(dense) == 0 {
return nil
}
// Directed adjacency. Only out-edges — SCC walks one way.
adj := make([][]int, len(dense))
for _, e := range g.AllEdges() {
if e == nil || !edgeAllow(e.Kind) {
continue
}
i, ok1 := idx[e.From]
j, ok2 := idx[e.To]
if !ok1 || !ok2 {
continue
}
adj[i] = append(adj[i], j)
}
// Iterative Tarjan. State arrays sized to the dense node
// count; the call stack is replaced by an explicit (node,
// neighbour-iteration-index) stack.
n := len(dense)
const undefined = -1
idxArr := make([]int, n)
lowlink := make([]int, n)
onStack := make([]bool, n)
for i := range idxArr {
idxArr[i] = undefined
}
stack := make([]int, 0, n)
type frame struct {
v int
ni int // next-neighbour index to visit
}
work := make([]frame, 0, n)
var index int
comp := make([]int, n)
for i := range comp {
comp[i] = -1
}
nextComp := 0
for start := 0; start < n; start++ {
if idxArr[start] != undefined {
continue
}
// Initialise the explicit DFS for this root.
idxArr[start] = index
lowlink[start] = index
index++
stack = append(stack, start)
onStack[start] = true
work = append(work, frame{v: start, ni: 0})
for len(work) > 0 {
top := &work[len(work)-1]
v := top.v
neighbors := adj[v]
if top.ni < len(neighbors) {
w := neighbors[top.ni]
top.ni++
if idxArr[w] == undefined {
// Descend into w.
idxArr[w] = index
lowlink[w] = index
index++
stack = append(stack, w)
onStack[w] = true
work = append(work, frame{v: w, ni: 0})
} else if onStack[w] {
if idxArr[w] < lowlink[v] {
lowlink[v] = idxArr[w]
}
}
continue
}
// All neighbours consumed; pop the frame and propagate
// the lowlink upward.
work = work[:len(work)-1]
if len(work) > 0 {
parent := &work[len(work)-1]
if lowlink[v] < lowlink[parent.v] {
lowlink[parent.v] = lowlink[v]
}
}
// Emit an SCC if v is its lowlink root.
if lowlink[v] == idxArr[v] {
label := nextComp
nextComp++
for {
w := stack[len(stack)-1]
stack = stack[:len(stack)-1]
onStack[w] = false
comp[w] = label
if w == v {
break
}
}
}
}
}
return collectComponents(dense, comp, opts.MinSize)
}
// collectComponents groups dense node IDs by component label,
// applies MinSize, sorts members for determinism, and returns
// the slice ordered by size descending.
func collectComponents(dense []string, comp []int, minSize int) []ComponentResult {
groups := make(map[int][]string)
for i, id := range dense {
c := comp[i]
if c < 0 {
continue
}
groups[c] = append(groups[c], id)
}
out := make([]ComponentResult, 0, len(groups))
for c, members := range groups {
if minSize > 0 && len(members) < minSize {
continue
}
sort.Strings(members)
out = append(out, ComponentResult{ID: c, Members: members, Size: len(members)})
}
sort.Slice(out, func(i, j int) bool {
if out[i].Size != out[j].Size {
return out[i].Size > out[j].Size
}
if len(out[i].Members) > 0 && len(out[j].Members) > 0 {
return out[i].Members[0] < out[j].Members[0]
}
return out[i].ID < out[j].ID
})
// Renumber sequentially so the output IDs are 0..N-1 in
// size-descending order. Stable for snapshot tests.
for i := range out {
out[i].ID = i
}
return out
}
func makeComponentKindAllow(kinds []graph.NodeKind) func(graph.NodeKind) bool {
if len(kinds) == 0 {
return func(graph.NodeKind) bool { return true }
}
set := make(map[graph.NodeKind]struct{}, len(kinds))
for _, k := range kinds {
set[k] = struct{}{}
}
return func(k graph.NodeKind) bool {
_, ok := set[k]
return ok
}
}
func makeComponentEdgeAllow(kinds []graph.EdgeKind) func(graph.EdgeKind) bool {
if len(kinds) == 0 {
return func(graph.EdgeKind) bool { return true }
}
set := make(map[graph.EdgeKind]struct{}, len(kinds))
for _, k := range kinds {
set[k] = struct{}{}
}
return func(k graph.EdgeKind) bool {
_, ok := set[k]
return ok
}
}
+107
View File
@@ -0,0 +1,107 @@
package analysis
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zzet/gortex/internal/graph"
)
// seedComponentTestGraph builds a hub-and-spoke graph: two SCC
// triangles + one hub every node points at. Gives predictable
// WCC + SCC answers.
func seedComponentTestGraph() *graph.Graph {
g := graph.New()
for _, id := range []string{"a", "b", "c", "d", "e", "f", "hub"} {
g.AddNode(&graph.Node{ID: id, Kind: graph.KindFunction, Name: id, FilePath: id + ".go"})
}
edges := [][2]string{
{"a", "b"}, {"b", "c"}, {"c", "a"}, // triangle 1
{"d", "e"}, {"e", "f"}, {"f", "d"}, // triangle 2
{"c", "d"}, // bridge
{"a", "hub"}, {"b", "hub"}, {"c", "hub"},
{"d", "hub"}, {"e", "hub"}, {"f", "hub"},
}
for _, e := range edges {
g.AddEdge(&graph.Edge{From: e[0], To: e[1], Kind: graph.EdgeCalls, FilePath: "x.go"})
}
return g
}
func TestComputeWCC_OneComponent(t *testing.T) {
g := seedComponentTestGraph()
res := ComputeWCC(g, ComponentOptions{})
require.Len(t, res, 1, "all 7 nodes form one WCC; got %v", res)
assert.Equal(t, 7, res[0].Size)
}
func TestComputeWCC_HonoursEdgeFilter(t *testing.T) {
g := seedComponentTestGraph()
// Filter out the call edges entirely → no surviving edges → every node
// becomes its own singleton component.
res := ComputeWCC(g, ComponentOptions{
EdgeKinds: []graph.EdgeKind{graph.EdgeReferences},
})
assert.Len(t, res, 7,
"with no surviving edges every node should be a singleton; got %v", res)
}
func TestComputeSCC_ThreeComponents(t *testing.T) {
g := seedComponentTestGraph()
res := ComputeSCC(g, ComponentOptions{})
// 7 SCCs: {a,b,c}, {d,e,f}, {hub} (singleton). But the hub is
// trivial — without MinSize, expect 3 with sizes [3, 3, 1].
require.GreaterOrEqual(t, len(res), 3)
bySize := map[int]int{}
for _, r := range res {
bySize[r.Size]++
}
assert.Equal(t, 2, bySize[3], "should find two 3-node SCCs (the triangles); got %v", res)
}
func TestComputeSCC_MinSize_DropsSingletons(t *testing.T) {
g := seedComponentTestGraph()
res := ComputeSCC(g, ComponentOptions{MinSize: 2})
for _, r := range res {
assert.GreaterOrEqual(t, r.Size, 2,
"MinSize=2 should drop singleton SCCs; got %v", r)
}
}
// TestComputeSCC_Iterative_NoStackOverflow constructs a deep
// straight-line graph (1 -> 2 -> 3 -> ... -> N) to make sure the
// iterative Tarjan stays in heap and doesn't blow the goroutine
// call stack. N = 10k; recursive Tarjan would fall over.
func TestComputeSCC_Iterative_NoStackOverflow(t *testing.T) {
const n = 10000
g := graph.New()
for i := 0; i < n; i++ {
id := charID(i)
g.AddNode(&graph.Node{ID: id, Kind: graph.KindFunction, Name: id, FilePath: "x.go"})
}
for i := 0; i < n-1; i++ {
g.AddEdge(&graph.Edge{
From: charID(i), To: charID(i + 1), Kind: graph.EdgeCalls, FilePath: "x.go",
})
}
res := ComputeSCC(g, ComponentOptions{})
// A DAG of N nodes has N singleton SCCs.
assert.Equal(t, n, len(res))
}
func charID(i int) string {
// fmt.Sprintf is fine but we want zero allocs in the loop body — just
// build a deterministic string ID.
const hex = "0123456789abcdef"
out := make([]byte, 0, 8)
for x := i; ; x /= 16 {
out = append([]byte{hex[x%16]}, out...)
if x < 16 {
break
}
}
return "n_" + string(out)
}
+289
View File
@@ -0,0 +1,289 @@
package analysis
import (
"sort"
"github.com/zzet/gortex/internal/graph"
)
// connectivity.go reports the connectivity *health of the graph itself* —
// a diagnostic for extraction/indexing quality, not a code-quality
// finding.
//
// This is deliberately DISTINCT from dead-code analysis (FindDeadCode):
//
// - Dead-code analysis reports symbols with zero *incoming usage*
// edges — genuinely unreachable code. Such a symbol is still a
// normally extracted node: its file `defines` it, a method is
// `member_of` its type. The finding is actionable — the code is
// unused and can be removed.
//
// - This analyzer reports *isolated* nodes — nodes with zero edges of
// *any* kind, structural edges included. A normally extracted
// function or method always carries at least the structural edge
// from its file (`defines`); a method additionally a `member_of`
// edge to its type. A node with zero total edges therefore almost
// never reflects "unused code" — it reflects that the extractor
// never processed the symbol (or its file). The finding is a graph
// *quality* signal: localise the extraction gap, do not delete the
// code.
//
// The isolated/leaf classification reuses graph.ClassifyZeroEdge — the
// same zero-edge classification used for per-symbol caveats — so the
// definition of "isolated" stays in lockstep with the rest of Gortex.
// ConnectivityFileEntry attributes dead-weight (isolated + leaf) nodes
// to a single source file, so an extraction gap can be localised.
type ConnectivityFileEntry struct {
FilePath string `json:"file_path"`
// Isolated is the count of zero-edge nodes contributed by this file.
Isolated int `json:"isolated"`
// Leaf is the count of degree-1 nodes contributed by this file.
Leaf int `json:"leaf"`
// DeadWeight is Isolated+Leaf — the rank key.
DeadWeight int `json:"dead_weight"`
}
// ConnectivityKindEntry breaks the isolated/leaf counts down by node
// kind, so a gap concentrated in one kind (e.g. only methods) is
// visible.
type ConnectivityKindEntry struct {
Kind string `json:"kind"`
Total int `json:"total"`
Isolated int `json:"isolated"`
Leaf int `json:"leaf"`
}
// GraphConnectivityReport is the structured connectivity-health report
// for a set of graph nodes.
type GraphConnectivityReport struct {
// NominalNodes is the total node count — the graph's reported size.
NominalNodes int `json:"nominal_nodes"`
// EffectiveNodes is the count of nodes with at least one edge — the
// graph's *connected* size. The two diverge when the extractor
// dropped edges.
EffectiveNodes int `json:"effective_nodes"`
// EffectiveRatio is EffectiveNodes/NominalNodes (1.0 when every node
// is connected, 0.0 for an empty graph).
EffectiveRatio float64 `json:"effective_ratio"`
// Isolated is the count of nodes with zero edges of any kind —
// structural edges included. The headline extraction-gap signal.
Isolated int `json:"isolated"`
// Leaf is the count of degree-1 nodes (exactly one edge, in or out).
Leaf int `json:"leaf"`
// SourceOnly is the count of nodes with only outgoing edges.
SourceOnly int `json:"source_only"`
// SinkOnly is the count of nodes with only incoming edges.
SinkOnly int `json:"sink_only"`
// ByKind breaks the totals down by node kind (only kinds that
// contributed at least one node are listed).
ByKind []ConnectivityKindEntry `json:"by_kind"`
// DeadWeightByFile ranks source files by their isolated+leaf node
// contribution, so an extraction gap can be localised.
DeadWeightByFile []ConnectivityFileEntry `json:"dead_weight_by_file"`
// Note explains, in human-readable form, how this report differs
// from a dead-code finding — so a reader does not mistake an
// isolated node for unused code.
Note string `json:"note"`
}
// connectivityNote is the standing human-readable caveat distinguishing
// this analyzer from dead-code analysis.
const connectivityNote = "Connectivity health is a graph-EXTRACTION diagnostic, not a " +
"code-quality finding. Isolated nodes have zero edges of ANY kind " +
"(structural `defines`/`member_of` included) — a normally extracted " +
"symbol always has at least a structural edge, so an isolated node " +
"signals the indexer mis-extracted the symbol, NOT that the code is " +
"unused. This is distinct from dead code (analyze kind=dead_code), " +
"which reports symbols with zero INCOMING usage edges — genuinely " +
"unreachable code that is safe to remove."
// GraphConnectivity computes the connectivity-health report over the
// supplied nodes. The caller passes the node slice (e.g. a
// workspace-scoped slice) and the graph the nodes belong to; edge
// lookups go through g so the report reflects the live edge set.
//
// fileLimit caps how many files DeadWeightByFile carries — files are
// ranked by dead-weight descending, ties broken by path; pass 0 or a
// negative value for no cap.
//
// Backends that implement graph.NodeDegreeAggregator serve every
// per-node count from one bulk pass; the fallback path runs
// the legacy per-node GetInEdges + GetOutEdges + ClassifyZeroEdge
// trio. The arithmetic is identical either way — the capability
// inlines ClassifyZeroEdge's "no incoming usage edge" check into the
// same row.
func GraphConnectivity(g graph.Store, nodes []*graph.Node, fileLimit int) GraphConnectivityReport {
report := GraphConnectivityReport{Note: connectivityNote}
if g == nil {
return report
}
type kindAgg struct {
total int
isolated int
leaf int
}
type fileAgg struct {
isolated int
leaf int
}
byKind := map[graph.NodeKind]*kindAgg{}
byFile := map[string]*fileAgg{}
// Bulk per-node count fetch when the backend supports it; one
// bulk pair vs. 3N per-node round-trips for the legacy path
// (the killer on a disk backend — see the NodeDegreeAggregator doc-comment
// for the workspace-scale numbers). Returns a map keyed on node ID
// or nil when the capability isn't available; the fallback path
// re-queries per node via the closure below.
counts := collectConnectivityCounts(g, nodes)
for _, n := range nodes {
if n == nil {
continue
}
report.NominalNodes++
ka := byKind[n.Kind]
if ka == nil {
ka = &kindAgg{}
byKind[n.Kind] = ka
}
ka.total++
var inCount, outCount int
if counts != nil {
row := counts[n.ID]
inCount = row.InCount
outCount = row.OutCount
} else {
inCount = len(g.GetInEdges(n.ID))
outCount = len(g.GetOutEdges(n.ID))
}
degree := inCount + outCount
if degree > 0 {
report.EffectiveNodes++
}
// Isolated == zero edges of any kind. ClassifyZeroEdge returns
// ZeroEdgePossibleExtractionGap for exactly this case (for a
// known node), so the "isolated" definition stays bound to the
// shared zero-edge classification used for per-symbol caveats.
// We derive it from the counts directly; the underlying
// classifier's check is in == 0 && out == 0 for a known id.
isolated := degree == 0
leaf := degree == 1
if isolated {
report.Isolated++
ka.isolated++
}
if leaf {
report.Leaf++
ka.leaf++
}
if degree > 0 && inCount == 0 {
report.SourceOnly++
}
if degree > 0 && outCount == 0 {
report.SinkOnly++
}
// Dead-weight attribution: an isolated or leaf node is a
// candidate extraction gap; tally it against its source file
// so the gap can be localised.
if isolated || leaf {
fa := byFile[n.FilePath]
if fa == nil {
fa = &fileAgg{}
byFile[n.FilePath] = fa
}
if isolated {
fa.isolated++
}
if leaf {
fa.leaf++
}
}
}
if report.NominalNodes > 0 {
report.EffectiveRatio = float64(report.EffectiveNodes) / float64(report.NominalNodes)
}
// Per-kind breakdown — only kinds that contributed a node, sorted
// by kind name for deterministic output.
report.ByKind = make([]ConnectivityKindEntry, 0, len(byKind))
for kind, agg := range byKind {
report.ByKind = append(report.ByKind, ConnectivityKindEntry{
Kind: string(kind),
Total: agg.total,
Isolated: agg.isolated,
Leaf: agg.leaf,
})
}
sort.Slice(report.ByKind, func(i, j int) bool {
return report.ByKind[i].Kind < report.ByKind[j].Kind
})
// Dead-weight attribution by file — ranked by dead-weight
// descending, ties broken by path so output is deterministic.
report.DeadWeightByFile = make([]ConnectivityFileEntry, 0, len(byFile))
for path, agg := range byFile {
report.DeadWeightByFile = append(report.DeadWeightByFile, ConnectivityFileEntry{
FilePath: path,
Isolated: agg.isolated,
Leaf: agg.leaf,
DeadWeight: agg.isolated + agg.leaf,
})
}
sort.Slice(report.DeadWeightByFile, func(i, j int) bool {
if report.DeadWeightByFile[i].DeadWeight != report.DeadWeightByFile[j].DeadWeight {
return report.DeadWeightByFile[i].DeadWeight > report.DeadWeightByFile[j].DeadWeight
}
return report.DeadWeightByFile[i].FilePath < report.DeadWeightByFile[j].FilePath
})
if fileLimit > 0 && len(report.DeadWeightByFile) > fileLimit {
report.DeadWeightByFile = report.DeadWeightByFile[:fileLimit]
}
return report
}
// collectConnectivityCounts returns per-node in/out/usage counts for
// the supplied node slice via the backend's NodeDegreeAggregator
// capability. Returns nil when the backend doesn't implement the
// capability — GraphConnectivity then falls back to the legacy
// per-node g.GetInEdges/g.GetOutEdges path so semantics never differ.
//
// We pass UsageInboundEdgeKinds so the server fills UsageInCount —
// today GraphConnectivity only consumes In/Out totals, but the usage
// count rides on the same row at no extra round-trip cost and makes
// the capability self-contained for callers that need it next.
func collectConnectivityCounts(g graph.Store, nodes []*graph.Node) map[string]graph.NodeDegreeRow {
agg, ok := g.(graph.NodeDegreeAggregator)
if !ok {
return nil
}
ids := make([]string, 0, len(nodes))
for _, n := range nodes {
if n == nil || n.ID == "" {
continue
}
ids = append(ids, n.ID)
}
if len(ids) == 0 {
return map[string]graph.NodeDegreeRow{}
}
rows := agg.NodeDegreeCounts(ids, graph.UsageInboundEdgeKinds())
out := make(map[string]graph.NodeDegreeRow, len(rows))
for _, r := range rows {
out[r.NodeID] = r
}
return out
}
+257
View File
@@ -0,0 +1,257 @@
package analysis
import (
"math"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zzet/gortex/internal/graph"
)
// connNode is a single node in a connectivity test fixture.
type connNode struct {
id string
kind graph.NodeKind
file string
}
// connEdge is a single directed edge in a connectivity test fixture.
type connEdge struct {
from string
to string
}
// buildConnGraph assembles a graph from a node/edge fixture so each
// test pins exactly which nodes are isolated / leaf / connected.
func buildConnGraph(nodes []connNode, edges []connEdge) (*graph.Graph, []*graph.Node) {
g := graph.New()
for _, n := range nodes {
g.AddNode(&graph.Node{ID: n.id, Kind: n.kind, Name: n.id, FilePath: n.file, Language: "go"})
}
for _, e := range edges {
g.AddEdge(&graph.Edge{From: e.from, To: e.to, Kind: graph.EdgeDefines, Confidence: 1})
}
return g, g.AllNodes()
}
func TestGraphConnectivity(t *testing.T) {
tests := []struct {
name string
// fixture
nodes []connNode
edges []connEdge
// expectations
wantNominal int
wantEffective int
wantRatio float64
wantIsolated int
wantLeaf int
wantSourceOnly int
wantSinkOnly int
}{
{
name: "empty graph",
nodes: nil,
edges: nil,
wantNominal: 0,
wantEffective: 0,
wantRatio: 0,
},
{
// file.go defines fn — fn is a leaf (one edge) and
// sink-only (only incoming); file.go is source-only.
// No node is isolated.
name: "fully connected pair",
nodes: []connNode{
{"a.go", graph.KindFile, "a.go"},
{"a.go::Fn", graph.KindFunction, "a.go"},
},
edges: []connEdge{{"a.go", "a.go::Fn"}},
wantNominal: 2,
wantEffective: 2,
wantRatio: 1.0,
wantIsolated: 0,
wantLeaf: 2, // both ends of the single edge have degree 1
wantSourceOnly: 1, // a.go
wantSinkOnly: 1, // a.go::Fn
},
{
// One isolated function: zero edges of any kind. This is
// the headline extraction-gap signal — NOT dead code.
name: "one isolated node",
nodes: []connNode{
{"a.go", graph.KindFile, "a.go"},
{"a.go::Fn", graph.KindFunction, "a.go"},
{"orphan.go::Lost", graph.KindFunction, "orphan.go"},
},
edges: []connEdge{{"a.go", "a.go::Fn"}},
wantNominal: 3,
wantEffective: 2,
wantRatio: 2.0 / 3.0,
wantIsolated: 1, // orphan.go::Lost
wantLeaf: 2, // a.go and a.go::Fn
wantSourceOnly: 1,
wantSinkOnly: 1,
},
{
// A chain a -> b -> c. b has in+out (degree 2, neither
// leaf nor source/sink-only). a is source-only, c is
// sink-only; both are leaves.
name: "three-node chain",
nodes: []connNode{
{"a.go::A", graph.KindFunction, "a.go"},
{"a.go::B", graph.KindFunction, "a.go"},
{"a.go::C", graph.KindFunction, "a.go"},
},
edges: []connEdge{
{"a.go::A", "a.go::B"},
{"a.go::B", "a.go::C"},
},
wantNominal: 3,
wantEffective: 3,
wantRatio: 1.0,
wantIsolated: 0,
wantLeaf: 2, // A and C
wantSourceOnly: 1, // A
wantSinkOnly: 1, // C
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
g, nodes := buildConnGraph(tt.nodes, tt.edges)
report := GraphConnectivity(g, nodes, 0)
assert.Equal(t, tt.wantNominal, report.NominalNodes, "nominal_nodes")
assert.Equal(t, tt.wantEffective, report.EffectiveNodes, "effective_nodes")
assert.InDelta(t, tt.wantRatio, report.EffectiveRatio, 1e-9, "effective_ratio")
assert.Equal(t, tt.wantIsolated, report.Isolated, "isolated")
assert.Equal(t, tt.wantLeaf, report.Leaf, "leaf")
assert.Equal(t, tt.wantSourceOnly, report.SourceOnly, "source_only")
assert.Equal(t, tt.wantSinkOnly, report.SinkOnly, "sink_only")
assert.NotEmpty(t, report.Note, "report must carry the extraction-vs-dead-code note")
})
}
}
// TestGraphConnectivity_DeadWeightByFile asserts the per-file
// dead-weight attribution ranks files by isolated+leaf contribution
// so an extraction gap can be localised.
func TestGraphConnectivity_DeadWeightByFile(t *testing.T) {
// gappy.go contributes 3 isolated nodes; ok.go contributes a
// single connected pair (two leaves, zero isolated).
nodes := []connNode{
{"ok.go", graph.KindFile, "ok.go"},
{"ok.go::Fn", graph.KindFunction, "ok.go"},
{"gappy.go::L1", graph.KindFunction, "gappy.go"},
{"gappy.go::L2", graph.KindFunction, "gappy.go"},
{"gappy.go::L3", graph.KindFunction, "gappy.go"},
}
edges := []connEdge{{"ok.go", "ok.go::Fn"}}
g, allNodes := buildConnGraph(nodes, edges)
report := GraphConnectivity(g, allNodes, 0)
require.Len(t, report.DeadWeightByFile, 2, "both files contribute dead-weight nodes")
// gappy.go ranks first: 3 isolated nodes > ok.go's 2 leaf nodes.
top := report.DeadWeightByFile[0]
assert.Equal(t, "gappy.go", top.FilePath)
assert.Equal(t, 3, top.Isolated)
assert.Equal(t, 0, top.Leaf)
assert.Equal(t, 3, top.DeadWeight)
second := report.DeadWeightByFile[1]
assert.Equal(t, "ok.go", second.FilePath)
assert.Equal(t, 0, second.Isolated)
assert.Equal(t, 2, second.Leaf)
assert.Equal(t, 2, second.DeadWeight)
}
// TestGraphConnectivity_FileLimit asserts the fileLimit argument
// truncates the dead-weight ranking to the top-N files.
func TestGraphConnectivity_FileLimit(t *testing.T) {
nodes := []connNode{
{"a.go::A", graph.KindFunction, "a.go"},
{"b.go::B", graph.KindFunction, "b.go"},
{"c.go::C", graph.KindFunction, "c.go"},
}
g, allNodes := buildConnGraph(nodes, nil) // all three isolated
report := GraphConnectivity(g, allNodes, 2)
assert.Len(t, report.DeadWeightByFile, 2, "fileLimit=2 must cap the ranking at 2 files")
assert.Equal(t, 3, report.Isolated, "the isolated count is not affected by fileLimit")
}
// TestGraphConnectivity_ByKind asserts the per-node-kind breakdown
// tallies isolated / leaf counts separately per kind.
func TestGraphConnectivity_ByKind(t *testing.T) {
// One connected file->function pair, plus an isolated type.
nodes := []connNode{
{"a.go", graph.KindFile, "a.go"},
{"a.go::Fn", graph.KindFunction, "a.go"},
{"a.go::Orphan", graph.KindType, "a.go"},
}
edges := []connEdge{{"a.go", "a.go::Fn"}}
g, allNodes := buildConnGraph(nodes, edges)
report := GraphConnectivity(g, allNodes, 0)
byKind := map[string]ConnectivityKindEntry{}
for _, k := range report.ByKind {
byKind[k.Kind] = k
}
require.Contains(t, byKind, "type")
assert.Equal(t, 1, byKind["type"].Total)
assert.Equal(t, 1, byKind["type"].Isolated, "the orphan type is isolated")
assert.Equal(t, 0, byKind["type"].Leaf)
require.Contains(t, byKind, "function")
assert.Equal(t, 1, byKind["function"].Total)
assert.Equal(t, 0, byKind["function"].Isolated)
assert.Equal(t, 1, byKind["function"].Leaf, "the defined function has degree 1")
}
// TestGraphConnectivity_IsolatedIsNotDeadCode pins the load-bearing
// distinction: an isolated node (zero edges of ANY kind) is an
// extraction-gap signal, whereas a dead-code node still carries a
// structural edge. A node that is `defines`-linked from its file but
// has no incoming usage edge is dead code, NOT isolated — this
// analyzer must not count it as isolated.
func TestGraphConnectivity_IsolatedIsNotDeadCode(t *testing.T) {
nodes := []connNode{
{"a.go", graph.KindFile, "a.go"},
// Defined by its file but never used — classic dead code.
{"a.go::Unused", graph.KindFunction, "a.go"},
// No edges at all — an extraction gap.
{"b.go::Missing", graph.KindFunction, "b.go"},
}
edges := []connEdge{{"a.go", "a.go::Unused"}}
g, allNodes := buildConnGraph(nodes, edges)
report := GraphConnectivity(g, allNodes, 0)
// Only the zero-edge node counts as isolated; the dead-code node
// has a structural edge and does not.
assert.Equal(t, 1, report.Isolated, "only the zero-edge node is isolated")
// Cross-check against the shared classifier the analyzer reuses.
assert.Equal(t, graph.ZeroEdgePossibleExtractionGap,
graph.ClassifyZeroEdge(g, "b.go::Missing"),
"the zero-edge node classifies as an extraction gap")
assert.NotEqual(t, graph.ZeroEdgePossibleExtractionGap,
graph.ClassifyZeroEdge(g, "a.go::Unused"),
"the structurally-linked dead-code node is NOT an extraction gap")
}
// TestGraphConnectivity_NilGraph asserts a nil graph yields a zero
// report rather than panicking.
func TestGraphConnectivity_NilGraph(t *testing.T) {
report := GraphConnectivity(nil, nil, 0)
assert.Equal(t, 0, report.NominalNodes)
assert.Equal(t, 0, report.EffectiveNodes)
assert.True(t, math.IsNaN(report.EffectiveRatio) == false, "ratio stays a real number")
}
+462
View File
@@ -0,0 +1,462 @@
package analysis
import (
"fmt"
"strings"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/query"
)
// SignatureChange describes a proposed change to a symbol's signature.
type SignatureChange struct {
SymbolID string `json:"symbol_id"`
NewSignature string `json:"new_signature"` // e.g. "func(ctx context.Context, id string) (Result, error)"
}
// ContractViolation describes a single contract violation found during verification.
type ContractViolation struct {
SymbolID string `json:"symbol_id"`
Name string `json:"name"`
FilePath string `json:"file_path"`
Line int `json:"line"`
Kind string `json:"kind"` // "caller_mismatch", "interface_violation", "removed_param"
Description string `json:"description"`
RepoPrefix string `json:"repo_prefix,omitempty"`
}
// VerifyResult is the output of contract violation verification.
type VerifyResult struct {
Violations []ContractViolation `json:"violations"`
CheckedCallers int `json:"checked_callers"`
CheckedImpls int `json:"checked_impls"`
Clean bool `json:"clean"`
Errors []string `json:"errors,omitempty"`
CrossRepoViolations bool `json:"cross_repo_violations,omitempty"`
ReturnUsage []ReturnUsageSummary `json:"return_usage,omitempty"`
}
// ReturnUsageSummary aggregates how the call sites of one changed
// function or method consume its return value — the "who actually uses
// the return?" answer an agent needs before changing a return
// signature. Counts come from the extractor-stamped return-usage label
// on each incoming call edge; call sites the classifier could not
// place are reported as unclassified rather than guessed.
type ReturnUsageSummary struct {
SymbolID string `json:"symbol_id"`
CallSites int `json:"call_sites"`
Counts map[string]int `json:"counts,omitempty"`
Unclassified int `json:"unclassified,omitempty"`
}
// summarizeReturnUsage builds the return-usage distribution for one
// function/method's incoming call edges. Returns nil when the symbol
// has no call sites at all (nothing to report).
func summarizeReturnUsage(g graph.Store, node *graph.Node) *ReturnUsageSummary {
if node == nil || (node.Kind != graph.KindFunction && node.Kind != graph.KindMethod) {
return nil
}
summary := &ReturnUsageSummary{SymbolID: node.ID}
for _, e := range g.GetInEdges(node.ID) {
if e.Kind != graph.EdgeCalls {
continue
}
// Skip speculative dispatch edges: the read surfaces (find_usages
// and the rest) hide them by default, so counting them here would
// make this distribution disagree with the call sites a user
// actually sees.
if e.IsSpeculative() {
continue
}
summary.CallSites++
if usage := graph.ReturnUsageOf(e); usage != "" {
if summary.Counts == nil {
summary.Counts = map[string]int{}
}
summary.Counts[usage]++
} else {
summary.Unclassified++
}
}
if summary.CallSites == 0 {
return nil
}
return summary
}
// parsedSignature holds the extracted parameter and return type info from a signature string.
type parsedSignature struct {
Params []string
Returns []string
}
// VerifyChanges checks proposed signature changes against all callers and interface
// implementors, returning any contract violations found.
func VerifyChanges(g graph.Store, engine *query.Engine, changes []SignatureChange) *VerifyResult {
result := &VerifyResult{}
for _, change := range changes {
node := g.GetNode(change.SymbolID)
if node == nil {
// Report error for missing symbol, continue checking others
result.Errors = append(result.Errors, fmt.Sprintf("symbol not found: %s", change.SymbolID))
continue
}
newSig := parseSignature(change.NewSignature)
// Get old signature from node metadata
var oldSig parsedSignature
if meta := node.Meta; meta != nil {
if sig, ok := meta["signature"].(string); ok {
oldSig = parseSignature(sig)
}
}
// For a function/method, summarise how its call sites consume
// the return value — the sites that bind / return / branch on the
// result are the ones a return-type change touches. The discarded
// count is not a blanket all-clear: a discarded label also folds
// every-sink-blank multi-assignment (Go `_, _ = f()`), which still
// breaks on a return-arity change because the blank list must
// match the result count. Read it as "the value is unused here",
// not "this site is safe to change".
if summary := summarizeReturnUsage(g, node); summary != nil {
result.ReturnUsage = append(result.ReturnUsage, *summary)
}
// Check callers for parameter mismatches
callerSG := engine.GetCallers(change.SymbolID, query.QueryOptions{Depth: 2, Limit: 500})
for _, callerNode := range callerSG.Nodes {
if callerNode.ID == change.SymbolID {
continue
}
result.CheckedCallers++
if len(oldSig.Params) != len(newSig.Params) {
result.Violations = append(result.Violations, ContractViolation{
SymbolID: callerNode.ID,
Name: callerNode.Name,
FilePath: callerNode.FilePath,
Line: callerNode.StartLine,
Kind: "caller_mismatch",
Description: fmt.Sprintf("parameter count changed from %d to %d in %s", len(oldSig.Params), len(newSig.Params), change.SymbolID),
RepoPrefix: callerNode.RepoPrefix,
})
} else {
// Check for type changes in parameters
for i := range oldSig.Params {
if oldSig.Params[i] != newSig.Params[i] {
result.Violations = append(result.Violations, ContractViolation{
SymbolID: callerNode.ID,
Name: callerNode.Name,
FilePath: callerNode.FilePath,
Line: callerNode.StartLine,
Kind: "caller_mismatch",
Description: fmt.Sprintf("parameter %d type changed from %s to %s in %s", i+1, oldSig.Params[i], newSig.Params[i], change.SymbolID),
RepoPrefix: callerNode.RepoPrefix,
})
break // one violation per caller is enough
}
}
}
}
// Check for removed parameters specifically
if len(newSig.Params) < len(oldSig.Params) {
for i := len(newSig.Params); i < len(oldSig.Params); i++ {
// Find callers that pass the removed parameter
for _, callerNode := range callerSG.Nodes {
if callerNode.ID == change.SymbolID {
continue
}
result.Violations = append(result.Violations, ContractViolation{
SymbolID: callerNode.ID,
Name: callerNode.Name,
FilePath: callerNode.FilePath,
Line: callerNode.StartLine,
Kind: "removed_param",
Description: fmt.Sprintf("parameter %d (%s) removed from %s", i+1, oldSig.Params[i], change.SymbolID),
RepoPrefix: callerNode.RepoPrefix,
})
}
}
}
// Check interface implementors
checkInterfaceViolations(g, engine, node, &newSig, result)
}
// Deduplicate violations by (SymbolID, Kind, Description)
result.Violations = deduplicateViolations(result.Violations)
// Detect cross-repo violations: check if any violation comes from
// a different repo than the changed symbol.
changedRepos := make(map[string]bool)
for _, change := range changes {
if n := g.GetNode(change.SymbolID); n != nil && n.RepoPrefix != "" {
changedRepos[n.RepoPrefix] = true
}
}
for _, v := range result.Violations {
if v.RepoPrefix != "" && !changedRepos[v.RepoPrefix] {
result.CrossRepoViolations = true
break
}
}
result.Clean = len(result.Violations) == 0
return result
}
// checkInterfaceViolations checks if the changed symbol is a method that belongs to
// an interface, and if so, verifies all other implementors still conform.
// Traversal: EdgeMemberOf → parent type → EdgeImplements → interface → all implementors
func checkInterfaceViolations(g graph.Store, engine *query.Engine, node *graph.Node, newSig *parsedSignature, result *VerifyResult) {
if node.Kind != graph.KindMethod {
return
}
// Find parent type via EdgeMemberOf
outEdges := g.GetOutEdges(node.ID)
for _, edge := range outEdges {
if edge.Kind != graph.EdgeMemberOf {
continue
}
parentNode := g.GetNode(edge.To)
if parentNode == nil {
continue
}
// Find interfaces this parent type implements via EdgeImplements
parentOutEdges := g.GetOutEdges(parentNode.ID)
for _, implEdge := range parentOutEdges {
if implEdge.Kind != graph.EdgeImplements {
continue
}
interfaceNode := g.GetNode(implEdge.To)
if interfaceNode == nil {
continue
}
// Find all other types that implement this interface
implNodes := engine.FindImplementations(interfaceNode.ID)
for _, implNode := range implNodes {
if implNode.ID == parentNode.ID {
continue // skip the type we're changing
}
result.CheckedImpls++
// Find the corresponding method on this implementor
implMethods := findMemberMethods(g, implNode.ID)
for _, method := range implMethods {
if method.Name != node.Name {
continue
}
// Compare signatures
var implSig parsedSignature
if meta := method.Meta; meta != nil {
if sig, ok := meta["signature"].(string); ok {
implSig = parseSignature(sig)
}
}
if len(implSig.Params) != len(newSig.Params) {
result.Violations = append(result.Violations, ContractViolation{
SymbolID: method.ID,
Name: method.Name,
FilePath: method.FilePath,
Line: method.StartLine,
Kind: "interface_violation",
Description: fmt.Sprintf("implementor %s has %d params but interface method now requires %d", implNode.Name, len(implSig.Params), len(newSig.Params)),
})
} else {
for i := range implSig.Params {
if implSig.Params[i] != newSig.Params[i] {
result.Violations = append(result.Violations, ContractViolation{
SymbolID: method.ID,
Name: method.Name,
FilePath: method.FilePath,
Line: method.StartLine,
Kind: "interface_violation",
Description: fmt.Sprintf("implementor %s param %d is %s but interface method now requires %s", implNode.Name, i+1, implSig.Params[i], newSig.Params[i]),
})
break
}
}
}
}
}
}
}
}
// findMemberMethods returns all method nodes that are members of the given type.
func findMemberMethods(g graph.Store, typeID string) []*graph.Node {
inEdges := g.GetInEdges(typeID)
var methods []*graph.Node
for _, edge := range inEdges {
if edge.Kind != graph.EdgeMemberOf {
continue
}
n := g.GetNode(edge.From)
if n != nil && n.Kind == graph.KindMethod {
methods = append(methods, n)
}
}
return methods
}
// parseSignature extracts parameter types and return types from a Go-style
// function signature string like "func(ctx context.Context, id string) (Result, error)".
func parseSignature(sig string) parsedSignature {
result := parsedSignature{}
sig = strings.TrimSpace(sig)
if sig == "" {
return result
}
// Strip leading "func" keyword if present
if strings.HasPrefix(sig, "func") {
sig = strings.TrimPrefix(sig, "func")
sig = strings.TrimSpace(sig)
}
// Find the parameter list: first balanced parentheses
params, rest := extractBalancedParens(sig)
if params != "" {
result.Params = parseParamList(params)
}
// Find return types: remaining balanced parentheses or single type
rest = strings.TrimSpace(rest)
if rest != "" {
if strings.HasPrefix(rest, "(") {
returns, _ := extractBalancedParens(rest)
if returns != "" {
result.Returns = parseParamList(returns)
}
} else {
// Single return type
result.Returns = []string{strings.TrimSpace(rest)}
}
}
return result
}
// extractBalancedParens extracts the content of the first balanced parentheses
// and returns the content (without parens) and the remaining string.
func extractBalancedParens(s string) (content, rest string) {
start := strings.IndexByte(s, '(')
if start < 0 {
return "", s
}
depth := 0
for i := start; i < len(s); i++ {
switch s[i] {
case '(':
depth++
case ')':
depth--
if depth == 0 {
return s[start+1 : i], s[i+1:]
}
}
}
// Unbalanced — return what we have
return s[start+1:], ""
}
// parseParamList splits a comma-separated parameter list and extracts the types.
// Handles named params like "ctx context.Context, id string" and unnamed like "int, string".
func parseParamList(params string) []string {
params = strings.TrimSpace(params)
if params == "" {
return nil
}
// Split by comma, respecting nested generics/parens
parts := splitParams(params)
var types []string
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
typ := extractType(part)
types = append(types, typ)
}
return types
}
// splitParams splits a parameter string by commas, respecting nested brackets.
func splitParams(s string) []string {
var parts []string
depth := 0
start := 0
for i := 0; i < len(s); i++ {
switch s[i] {
case '(', '[', '{':
depth++
case ')', ']', '}':
depth--
case ',':
if depth == 0 {
parts = append(parts, s[start:i])
start = i + 1
}
}
}
parts = append(parts, s[start:])
return parts
}
// extractType extracts the type from a parameter declaration.
// "ctx context.Context" → "context.Context"
// "id string" → "string"
// "string" → "string" (unnamed)
// "...string" → "...string" (variadic)
func extractType(param string) string {
param = strings.TrimSpace(param)
// Split by whitespace
fields := strings.Fields(param)
if len(fields) == 0 {
return ""
}
// If only one field, it's the type itself (unnamed parameter)
if len(fields) == 1 {
return fields[0]
}
// Last field is the type (handles "ctx context.Context" and "x, y int" patterns)
return fields[len(fields)-1]
}
// deduplicateViolations removes duplicate violations based on (SymbolID, Kind, Description).
func deduplicateViolations(violations []ContractViolation) []ContractViolation {
type key struct {
symbolID string
kind string
description string
}
seen := make(map[key]bool)
var result []ContractViolation
for _, v := range violations {
k := key{symbolID: v.SymbolID, kind: v.Kind, description: v.Description}
if !seen[k] {
seen[k] = true
result = append(result, v)
}
}
return result
}
+489
View File
@@ -0,0 +1,489 @@
package analysis
import (
"fmt"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/query"
"pgregory.net/rapid"
)
// Feature: gortex-enhancements, Property 1: Contract violation detection completeness
// --- Generators ---
// genParamType generates a random Go parameter type.
func genParamType() *rapid.Generator[string] {
return rapid.SampledFrom([]string{
"string", "int", "bool", "float64", "error",
"context.Context", "[]byte", "map[string]any",
"*http.Request", "io.Reader",
})
}
// genSignature builds a "func(...) ..." string from a list of param types.
func buildSignature(params []string) string {
if len(params) == 0 {
return "func()"
}
named := make([]string, len(params))
for i, p := range params {
named[i] = fmt.Sprintf("p%d %s", i, p)
}
return fmt.Sprintf("func(%s)", strings.Join(named, ", "))
}
// callerGraphResult holds a generated graph with known callers for a target function.
type callerGraphResult struct {
Graph *graph.Graph
Engine *query.Engine
TargetID string
OldSig string
OldParams []string
CallerIDs []string
}
// genCallerGraph generates a graph with a target function and N callers.
func genCallerGraph() *rapid.Generator[callerGraphResult] {
return rapid.Custom(func(t *rapid.T) callerGraphResult {
g := graph.New()
// Generate 1-5 old params for the target function
numOldParams := rapid.IntRange(1, 5).Draw(t, "numOldParams")
oldParams := make([]string, numOldParams)
for i := range numOldParams {
oldParams[i] = genParamType().Draw(t, fmt.Sprintf("oldParam%d", i))
}
oldSig := buildSignature(oldParams)
targetID := "pkg/target.go::TargetFunc"
g.AddNode(&graph.Node{
ID: targetID,
Kind: graph.KindFunction,
Name: "TargetFunc",
FilePath: "pkg/target.go",
StartLine: 10,
EndLine: 30,
Language: "go",
Meta: map[string]any{"signature": oldSig},
})
// Generate 1-8 callers
numCallers := rapid.IntRange(1, 8).Draw(t, "numCallers")
callerIDs := make([]string, numCallers)
for i := range numCallers {
callerID := fmt.Sprintf("pkg/caller%d.go::Caller%d", i, i)
callerIDs[i] = callerID
g.AddNode(&graph.Node{
ID: callerID,
Kind: graph.KindFunction,
Name: fmt.Sprintf("Caller%d", i),
FilePath: fmt.Sprintf("pkg/caller%d.go", i),
StartLine: 1,
EndLine: 20,
Language: "go",
})
g.AddEdge(&graph.Edge{
From: callerID,
To: targetID,
Kind: graph.EdgeCalls,
})
}
engine := query.NewEngine(g)
return callerGraphResult{
Graph: g,
Engine: engine,
TargetID: targetID,
OldSig: oldSig,
OldParams: oldParams,
CallerIDs: callerIDs,
}
})
}
// interfaceGraphResult holds a generated graph with interface implementations.
type interfaceGraphResult struct {
Graph *graph.Graph
Engine *query.Engine
MethodID string
InterfaceID string
ParentTypeID string
OldSig string
OldParams []string
ImplTypeIDs []string
ImplMethodIDs []string
}
// genInterfaceGraph generates a graph with an interface, a type implementing it,
// and additional implementor types whose methods should be flagged on signature change.
func genInterfaceGraph() *rapid.Generator[interfaceGraphResult] {
return rapid.Custom(func(t *rapid.T) interfaceGraphResult {
g := graph.New()
// Generate 1-4 old params
numOldParams := rapid.IntRange(1, 4).Draw(t, "numOldParams")
oldParams := make([]string, numOldParams)
for i := range numOldParams {
oldParams[i] = genParamType().Draw(t, fmt.Sprintf("oldParam%d", i))
}
oldSig := buildSignature(oldParams)
// Interface
interfaceID := "pkg/iface.go::MyInterface"
g.AddNode(&graph.Node{
ID: interfaceID,
Kind: graph.KindInterface,
Name: "MyInterface",
FilePath: "pkg/iface.go",
Language: "go",
})
// Primary type that implements the interface
parentTypeID := "pkg/impl.go::MyType"
g.AddNode(&graph.Node{
ID: parentTypeID,
Kind: graph.KindType,
Name: "MyType",
FilePath: "pkg/impl.go",
Language: "go",
})
g.AddEdge(&graph.Edge{
From: parentTypeID,
To: interfaceID,
Kind: graph.EdgeImplements,
})
// Method on the primary type (this is the one being changed)
methodID := "pkg/impl.go::MyType.DoWork"
g.AddNode(&graph.Node{
ID: methodID,
Kind: graph.KindMethod,
Name: "DoWork",
FilePath: "pkg/impl.go",
StartLine: 10,
EndLine: 20,
Language: "go",
Meta: map[string]any{"signature": oldSig},
})
g.AddEdge(&graph.Edge{
From: methodID,
To: parentTypeID,
Kind: graph.EdgeMemberOf,
})
// Generate 1-5 additional implementor types
numImpls := rapid.IntRange(1, 5).Draw(t, "numImpls")
implTypeIDs := make([]string, numImpls)
implMethodIDs := make([]string, numImpls)
for i := range numImpls {
implTypeID := fmt.Sprintf("pkg/impl%d.go::ImplType%d", i, i)
implTypeIDs[i] = implTypeID
g.AddNode(&graph.Node{
ID: implTypeID,
Kind: graph.KindType,
Name: fmt.Sprintf("ImplType%d", i),
FilePath: fmt.Sprintf("pkg/impl%d.go", i),
Language: "go",
})
g.AddEdge(&graph.Edge{
From: implTypeID,
To: interfaceID,
Kind: graph.EdgeImplements,
})
// Method on this implementor with the OLD signature
implMethodID := fmt.Sprintf("pkg/impl%d.go::ImplType%d.DoWork", i, i)
implMethodIDs[i] = implMethodID
g.AddNode(&graph.Node{
ID: implMethodID,
Kind: graph.KindMethod,
Name: "DoWork",
FilePath: fmt.Sprintf("pkg/impl%d.go", i),
StartLine: 5,
EndLine: 15,
Language: "go",
Meta: map[string]any{"signature": oldSig},
})
g.AddEdge(&graph.Edge{
From: implMethodID,
To: implTypeID,
Kind: graph.EdgeMemberOf,
})
}
engine := query.NewEngine(g)
return interfaceGraphResult{
Graph: g,
Engine: engine,
MethodID: methodID,
InterfaceID: interfaceID,
ParentTypeID: parentTypeID,
OldSig: oldSig,
OldParams: oldParams,
ImplTypeIDs: implTypeIDs,
ImplMethodIDs: implMethodIDs,
}
})
}
// --- Property Tests ---
// TestPropertyContractViolation_CallerParamCountChange verifies that when the
// parameter count changes, every caller is reported as a violation.
func TestPropertyContractViolation_CallerParamCountChange(t *testing.T) {
rapid.Check(t, func(rt *rapid.T) {
cg := genCallerGraph().Draw(rt, "callerGraph")
// Generate a new signature with a DIFFERENT param count
newParamCount := rapid.IntRange(0, 6).Draw(rt, "newParamCount")
// Ensure it's different from old count
for newParamCount == len(cg.OldParams) {
newParamCount = rapid.IntRange(0, 6).Draw(rt, "newParamCountRetry")
}
newParams := make([]string, newParamCount)
for i := range newParamCount {
newParams[i] = genParamType().Draw(rt, fmt.Sprintf("newParam%d", i))
}
newSig := buildSignature(newParams)
changes := []SignatureChange{{
SymbolID: cg.TargetID,
NewSignature: newSig,
}}
result := VerifyChanges(cg.Graph, cg.Engine, changes)
// Every caller should be reported as a violation
violatedCallerIDs := make(map[string]bool)
for _, v := range result.Violations {
violatedCallerIDs[v.SymbolID] = true
}
for _, callerID := range cg.CallerIDs {
if !violatedCallerIDs[callerID] {
rt.Errorf("caller %s was not reported as a violation when param count changed from %d to %d",
callerID, len(cg.OldParams), newParamCount)
}
}
// Should not be clean
if result.Clean {
rt.Errorf("result should not be clean when param count changed")
}
// CheckedCallers should match actual caller count
if result.CheckedCallers != len(cg.CallerIDs) {
rt.Errorf("CheckedCallers = %d, want %d", result.CheckedCallers, len(cg.CallerIDs))
}
})
}
// TestPropertyContractViolation_CallerTypeChange verifies that when a parameter
// type changes (but count stays the same), every caller is reported as a violation.
func TestPropertyContractViolation_CallerTypeChange(t *testing.T) {
rapid.Check(t, func(rt *rapid.T) {
cg := genCallerGraph().Draw(rt, "callerGraph")
// Build new params with same count but at least one different type
newParams := make([]string, len(cg.OldParams))
copy(newParams, cg.OldParams)
// Pick a random index to change
changeIdx := rapid.IntRange(0, len(cg.OldParams)-1).Draw(rt, "changeIdx")
newType := genParamType().Draw(rt, "newType")
// Ensure it's actually different
for newType == cg.OldParams[changeIdx] {
newType = genParamType().Draw(rt, "newTypeRetry")
}
newParams[changeIdx] = newType
newSig := buildSignature(newParams)
changes := []SignatureChange{{
SymbolID: cg.TargetID,
NewSignature: newSig,
}}
result := VerifyChanges(cg.Graph, cg.Engine, changes)
// Every caller should be reported as a violation
violatedCallerIDs := make(map[string]bool)
for _, v := range result.Violations {
violatedCallerIDs[v.SymbolID] = true
}
for _, callerID := range cg.CallerIDs {
if !violatedCallerIDs[callerID] {
rt.Errorf("caller %s was not reported as a violation when param type changed at index %d",
callerID, changeIdx)
}
}
if result.Clean {
rt.Errorf("result should not be clean when param type changed")
}
})
}
// TestPropertyContractViolation_NoChange verifies that when the signature
// doesn't change, Clean is true and CheckedCallers matches actual count.
func TestPropertyContractViolation_NoChange(t *testing.T) {
rapid.Check(t, func(rt *rapid.T) {
cg := genCallerGraph().Draw(rt, "callerGraph")
// Use the same signature — no change
changes := []SignatureChange{{
SymbolID: cg.TargetID,
NewSignature: cg.OldSig,
}}
result := VerifyChanges(cg.Graph, cg.Engine, changes)
if !result.Clean {
rt.Errorf("result should be clean when signature is unchanged, got %d violations", len(result.Violations))
for _, v := range result.Violations {
rt.Logf(" violation: %s %s: %s", v.SymbolID, v.Kind, v.Description)
}
}
if result.CheckedCallers != len(cg.CallerIDs) {
rt.Errorf("CheckedCallers = %d, want %d", result.CheckedCallers, len(cg.CallerIDs))
}
})
}
// TestPropertyContractViolation_InterfaceParamCountChange verifies that when
// a method's param count changes, all other implementors are flagged.
func TestPropertyContractViolation_InterfaceParamCountChange(t *testing.T) {
rapid.Check(t, func(rt *rapid.T) {
ig := genInterfaceGraph().Draw(rt, "interfaceGraph")
// Generate a new signature with a DIFFERENT param count
newParamCount := rapid.IntRange(0, 5).Draw(rt, "newParamCount")
for newParamCount == len(ig.OldParams) {
newParamCount = rapid.IntRange(0, 5).Draw(rt, "newParamCountRetry")
}
newParams := make([]string, newParamCount)
for i := range newParamCount {
newParams[i] = genParamType().Draw(rt, fmt.Sprintf("newParam%d", i))
}
newSig := buildSignature(newParams)
changes := []SignatureChange{{
SymbolID: ig.MethodID,
NewSignature: newSig,
}}
result := VerifyChanges(ig.Graph, ig.Engine, changes)
// Every implementor's method should be reported as an interface_violation
violatedMethodIDs := make(map[string]bool)
for _, v := range result.Violations {
if v.Kind == "interface_violation" {
violatedMethodIDs[v.SymbolID] = true
}
}
for _, implMethodID := range ig.ImplMethodIDs {
if !violatedMethodIDs[implMethodID] {
rt.Errorf("implementor method %s was not reported as interface_violation when param count changed from %d to %d",
implMethodID, len(ig.OldParams), newParamCount)
}
}
// CheckedImpls should match the number of other implementors
if result.CheckedImpls != len(ig.ImplTypeIDs) {
rt.Errorf("CheckedImpls = %d, want %d", result.CheckedImpls, len(ig.ImplTypeIDs))
}
if result.Clean {
rt.Errorf("result should not be clean when interface method param count changed")
}
})
}
// TestPropertyContractViolation_InterfaceNoChange verifies that when a method's
// signature doesn't change, no interface violations are reported and CheckedImpls
// matches the actual implementor count.
func TestPropertyContractViolation_InterfaceNoChange(t *testing.T) {
rapid.Check(t, func(rt *rapid.T) {
ig := genInterfaceGraph().Draw(rt, "interfaceGraph")
// Use the same signature — no change
changes := []SignatureChange{{
SymbolID: ig.MethodID,
NewSignature: ig.OldSig,
}}
result := VerifyChanges(ig.Graph, ig.Engine, changes)
// No interface violations should be reported
for _, v := range result.Violations {
if v.Kind == "interface_violation" {
rt.Errorf("unexpected interface_violation for %s when signature unchanged: %s",
v.SymbolID, v.Description)
}
}
if result.CheckedImpls != len(ig.ImplTypeIDs) {
rt.Errorf("CheckedImpls = %d, want %d", result.CheckedImpls, len(ig.ImplTypeIDs))
}
if !result.Clean {
rt.Errorf("result should be clean when signature is unchanged, got %d violations", len(result.Violations))
}
})
}
// --- Unit Test for missing symbol ID ---
func TestVerifyChanges_MissingSymbolContinues(t *testing.T) {
g := graph.New()
// Add a real function with a caller
g.AddNode(&graph.Node{
ID: "pkg/real.go::RealFunc",
Kind: graph.KindFunction,
Name: "RealFunc",
FilePath: "pkg/real.go",
StartLine: 1,
EndLine: 10,
Language: "go",
Meta: map[string]any{"signature": "func(x int)"},
})
g.AddNode(&graph.Node{
ID: "pkg/caller.go::Caller",
Kind: graph.KindFunction,
Name: "Caller",
FilePath: "pkg/caller.go",
StartLine: 1,
EndLine: 10,
Language: "go",
})
g.AddEdge(&graph.Edge{
From: "pkg/caller.go::Caller",
To: "pkg/real.go::RealFunc",
Kind: graph.EdgeCalls,
})
engine := query.NewEngine(g)
changes := []SignatureChange{
{SymbolID: "nonexistent::Missing", NewSignature: "func(x int)"},
{SymbolID: "pkg/real.go::RealFunc", NewSignature: "func(x int, y string)"},
}
result := VerifyChanges(g, engine, changes)
// Should have an error for the missing symbol
assert.Len(t, result.Errors, 1)
assert.Contains(t, result.Errors[0], "nonexistent::Missing")
// Should still detect violations for the real function
assert.NotEmpty(t, result.Violations)
assert.False(t, result.Clean)
assert.Equal(t, 1, result.CheckedCallers)
}
+254
View File
@@ -0,0 +1,254 @@
package analysis
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"pgregory.net/rapid"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/query"
)
// Feature: multi-repo-support, Property 18: Cross-repo impact traversal
//
// For any graph containing Cross_Repo_Edges, AnalyzeImpact SHALL follow those
// edges during BFS traversal and include affected symbols from other repositories
// in the result. The result SHALL group affected symbols by RepoPrefix and set
// cross_repo_impact to true when symbols from multiple repos are affected.
func TestPropertyCrossRepoImpactTraversal(t *testing.T) {
rapid.Check(t, func(rt *rapid.T) {
g := graph.New()
// Generate 2 repo prefixes.
repoA := "repo-a"
repoB := "repo-b"
// Generate a random number of functions per repo (1-3).
numFuncsA := rapid.IntRange(1, 3).Draw(rt, "numFuncsA")
numFuncsB := rapid.IntRange(1, 3).Draw(rt, "numFuncsB")
var repoAFuncs []string
var repoBFuncs []string
// Create repo-a functions.
for i := 0; i < numFuncsA; i++ {
id := fmt.Sprintf("%s/src/a.go::FuncA%d", repoA, i)
g.AddNode(&graph.Node{
ID: id,
Kind: graph.KindFunction,
Name: fmt.Sprintf("FuncA%d", i),
FilePath: fmt.Sprintf("%s/src/a.go", repoA),
Language: "go",
StartLine: i*10 + 1,
EndLine: i*10 + 9,
RepoPrefix: repoA,
})
repoAFuncs = append(repoAFuncs, id)
}
// Create repo-b functions.
for i := 0; i < numFuncsB; i++ {
id := fmt.Sprintf("%s/src/b.go::FuncB%d", repoB, i)
g.AddNode(&graph.Node{
ID: id,
Kind: graph.KindFunction,
Name: fmt.Sprintf("FuncB%d", i),
FilePath: fmt.Sprintf("%s/src/b.go", repoB),
Language: "go",
StartLine: i*10 + 1,
EndLine: i*10 + 9,
RepoPrefix: repoB,
})
repoBFuncs = append(repoBFuncs, id)
}
// Create a cross-repo edge: repo-b's first function calls repo-a's first function.
g.AddEdge(&graph.Edge{
From: repoBFuncs[0],
To: repoAFuncs[0],
Kind: graph.EdgeCalls,
CrossRepo: true,
})
// Optionally add intra-repo edges in repo-a.
if numFuncsA > 1 {
g.AddEdge(&graph.Edge{
From: repoAFuncs[1],
To: repoAFuncs[0],
Kind: graph.EdgeCalls,
})
}
// Analyze impact starting from repo-a's first function.
result := AnalyzeImpact(g, []string{repoAFuncs[0]}, nil, nil)
require.NotNil(t, result)
// The BFS should follow the cross-repo edge and find repo-b's function.
foundCrossRepo := false
for _, entries := range result.ByDepth {
for _, entry := range entries {
if entry.RepoPrefix == repoB {
foundCrossRepo = true
}
}
}
if !foundCrossRepo {
rt.Error("cross-repo edge was not followed: expected repo-b symbols in impact results")
}
// cross_repo_impact should be true since symbols from multiple repos are affected.
if !result.CrossRepoImpact {
rt.Error("CrossRepoImpact should be true when symbols from multiple repos are affected")
}
// ByRepo should group symbols by repo prefix.
if result.ByRepo == nil {
rt.Error("ByRepo should be populated when cross-repo impact is detected")
}
if len(result.ByRepo[repoB]) == 0 {
rt.Error("ByRepo should contain repo-b entries")
}
})
}
// Feature: multi-repo-support, Property 19: Cross-repo safety checks
//
// For any signature change on a symbol that has callers in other repositories,
// VerifyChanges SHALL report violations for those cross-repo callers.
// For any changed symbol that has transitive test dependents in other repositories,
// get_test_targets SHALL include those cross-repo test files in the result.
func TestPropertyCrossRepoSafetyChecks(t *testing.T) {
rapid.Check(t, func(rt *rapid.T) {
g := graph.New()
repoA := "repo-a"
repoB := "repo-b"
// Create a function in repo-a with a signature.
targetID := fmt.Sprintf("%s/lib.go::DoWork", repoA)
g.AddNode(&graph.Node{
ID: targetID,
Kind: graph.KindFunction,
Name: "DoWork",
FilePath: fmt.Sprintf("%s/lib.go", repoA),
Language: "go",
StartLine: 1,
EndLine: 10,
RepoPrefix: repoA,
Meta: map[string]any{"signature": "func DoWork(x int, y string)"},
})
// Create a caller in repo-b (cross-repo).
callerID := fmt.Sprintf("%s/main.go::UsesDoWork", repoB)
g.AddNode(&graph.Node{
ID: callerID,
Kind: graph.KindFunction,
Name: "UsesDoWork",
FilePath: fmt.Sprintf("%s/main.go", repoB),
Language: "go",
StartLine: 5,
EndLine: 15,
RepoPrefix: repoB,
})
// Cross-repo call edge.
g.AddEdge(&graph.Edge{
From: callerID,
To: targetID,
Kind: graph.EdgeCalls,
CrossRepo: true,
})
// Create a test file in repo-b that depends on the caller.
testID := fmt.Sprintf("%s/main_test.go::TestUsesDoWork", repoB)
g.AddNode(&graph.Node{
ID: testID,
Kind: graph.KindFunction,
Name: "TestUsesDoWork",
FilePath: fmt.Sprintf("%s/main_test.go", repoB),
Language: "go",
StartLine: 1,
EndLine: 10,
RepoPrefix: repoB,
})
g.AddEdge(&graph.Edge{
From: testID,
To: callerID,
Kind: graph.EdgeCalls,
})
// Generate a random parameter count change.
newParamCount := rapid.IntRange(0, 1).Draw(rt, "newParamCount")
var newSig string
if newParamCount == 0 {
newSig = "func DoWork()"
} else {
newSig = "func DoWork(x int)"
}
// Verify changes: the cross-repo caller should be reported as a violation.
engine := query.NewEngine(g)
result := VerifyChanges(g, engine, []SignatureChange{
{SymbolID: targetID, NewSignature: newSig},
})
require.NotNil(t, result)
// Should have violations from the cross-repo caller.
foundCrossRepoCaller := false
for _, v := range result.Violations {
if v.RepoPrefix == repoB {
foundCrossRepoCaller = true
}
}
if !foundCrossRepoCaller {
rt.Error("VerifyChanges should report violations for cross-repo callers")
}
// CrossRepoViolations flag should be set.
if !result.CrossRepoViolations {
rt.Error("CrossRepoViolations should be true when violations come from other repos")
}
// Test targets: AnalyzeImpact should include cross-repo test files.
impact := AnalyzeImpact(g, []string{targetID}, nil, nil)
require.NotNil(t, impact)
foundTestFile := false
for _, tf := range impact.TestFiles {
if tf == fmt.Sprintf("%s/main_test.go", repoB) {
foundTestFile = true
}
}
if !foundTestFile {
rt.Error("get_test_targets should include cross-repo test files")
}
})
}
// TestCrossRepoImpact_Unit is a focused unit test for cross-repo impact analysis.
func TestCrossRepoImpact_Unit(t *testing.T) {
g := graph.New()
// Repo A: library function
g.AddNode(&graph.Node{ID: "repo-a/lib.go::Helper", Kind: graph.KindFunction, Name: "Helper",
FilePath: "repo-a/lib.go", Language: "go", StartLine: 1, EndLine: 10, RepoPrefix: "repo-a"})
// Repo B: consumer
g.AddNode(&graph.Node{ID: "repo-b/app.go::UseHelper", Kind: graph.KindFunction, Name: "UseHelper",
FilePath: "repo-b/app.go", Language: "go", StartLine: 1, EndLine: 10, RepoPrefix: "repo-b"})
// Cross-repo edge
g.AddEdge(&graph.Edge{From: "repo-b/app.go::UseHelper", To: "repo-a/lib.go::Helper", Kind: graph.EdgeCalls, CrossRepo: true})
result := AnalyzeImpact(g, []string{"repo-a/lib.go::Helper"}, nil, nil)
require.NotNil(t, result)
assert.True(t, result.CrossRepoImpact)
assert.Contains(t, result.ByRepo, "repo-b")
assert.Len(t, result.ByRepo["repo-b"], 1)
assert.Equal(t, "repo-b/app.go::UseHelper", result.ByRepo["repo-b"][0].ID)
}
+330
View File
@@ -0,0 +1,330 @@
package analysis
import (
"sort"
"strings"
"github.com/zzet/gortex/internal/graph"
)
// edgePair identifies a directed edge between two nodes.
type edgePair struct{ from, to string }
// Cycle represents a detected dependency cycle in the graph.
type Cycle struct {
Path []string `json:"path"` // ordered symbol IDs forming the cycle
Kind string `json:"kind"` // "import-cycle", "call-cycle", "cross-community-cycle"
Severity int `json:"severity"` // 3=import, 2=cross-community, 1=call
}
// DetectCycles finds all dependency cycles in the graph using Tarjan's SCC algorithm.
// If scope is non-empty, only nodes whose FilePath starts with scope are considered.
// Cycles are classified by edge type and community membership, then sorted by severity descending.
func DetectCycles(g graph.Store, communities *CommunityResult, scope string) []Cycle {
nodes := g.AllNodes()
// Build set of in-scope node IDs
inScope := make(map[string]bool, len(nodes))
for _, n := range nodes {
if n.Kind == graph.KindFile || n.Kind == graph.KindImport {
continue
}
if scope != "" && !strings.HasPrefix(n.FilePath, scope) {
continue
}
inScope[n.ID] = true
}
// Build adjacency list and track edge kinds between pairs.
//
// Edge collection streams only EdgeImports + EdgeCalls via
// EdgesByKind (two MATCH (...)-[e:Edge {kind: $kind}]->(...) on
// disk backends) instead of materialising every edge in the graph
// just to filter for two kinds -- ~500k edge rows over cgo dropped
// to the import-and-call subset (a few tens of thousands on the
// gortex workspace).
adj := make(map[string][]string)
edgeKinds := make(map[edgePair][]graph.EdgeKind)
collect := func(kind graph.EdgeKind) {
for e := range g.EdgesByKind(kind) {
if e == nil {
continue
}
if !inScope[e.From] || !inScope[e.To] {
continue
}
pair := edgePair{e.From, e.To}
// Avoid duplicate adjacency entries
if _, exists := edgeKinds[pair]; !exists {
adj[e.From] = append(adj[e.From], e.To)
}
edgeKinds[pair] = append(edgeKinds[pair], kind)
}
}
collect(graph.EdgeImports)
collect(graph.EdgeCalls)
// Run Tarjan's SCC
sccs := tarjanSCC(inScope, adj)
// Convert SCCs to cycles
var cycles []Cycle
for _, scc := range sccs {
if len(scc) < 2 {
continue
}
// Order the cycle path by following edges within the SCC
path := orderCyclePath(scc, adj)
// Classify the cycle
kind, severity := classifyCycle(path, edgeKinds, communities)
cycles = append(cycles, Cycle{
Path: path,
Kind: kind,
Severity: severity,
})
}
// Sort by severity descending
sort.Slice(cycles, func(i, j int) bool {
return cycles[i].Severity > cycles[j].Severity
})
return cycles
}
// WouldCreateCycle checks if adding an edge from fromID to toID would create a cycle.
// It performs DFS from toID to see if fromID is reachable. If so, adding fromID→toID
// would close a cycle. Returns the cycle path from toID to fromID when found.
func WouldCreateCycle(g graph.Store, fromID, toID string) (bool, []string) {
edges := g.AllEdges()
// Build adjacency from calls and imports edges
adj := make(map[string][]string)
seen := make(map[string]map[string]bool)
for _, e := range edges {
if e.Kind != graph.EdgeImports && e.Kind != graph.EdgeCalls {
continue
}
if seen[e.From] == nil {
seen[e.From] = make(map[string]bool)
}
if !seen[e.From][e.To] {
seen[e.From][e.To] = true
adj[e.From] = append(adj[e.From], e.To)
}
}
// DFS from toID looking for fromID
visited := make(map[string]bool)
parent := make(map[string]string)
found := false
var dfs func(node string)
dfs = func(node string) {
if found {
return
}
if node == fromID {
found = true
return
}
visited[node] = true
for _, next := range adj[node] {
if found {
return
}
if !visited[next] {
parent[next] = node
dfs(next)
}
}
}
parent[toID] = ""
dfs(toID)
if !found {
return false, nil
}
// Reconstruct path from toID to fromID
var path []string
current := fromID
for current != toID {
path = append(path, current)
current = parent[current]
}
path = append(path, toID)
// Reverse to get toID → ... → fromID order
for i, j := 0, len(path)-1; i < j; i, j = i+1, j-1 {
path[i], path[j] = path[j], path[i]
}
return true, path
}
// tarjanSCC runs Tarjan's strongly connected components algorithm.
// Returns a list of SCCs, each being a slice of node IDs.
func tarjanSCC(nodeSet map[string]bool, adj map[string][]string) [][]string {
index := 0
stack := make([]string, 0)
onStack := make(map[string]bool)
indices := make(map[string]int)
lowlinks := make(map[string]int)
defined := make(map[string]bool)
var sccs [][]string
var strongConnect func(v string)
strongConnect = func(v string) {
indices[v] = index
lowlinks[v] = index
index++
defined[v] = true
stack = append(stack, v)
onStack[v] = true
for _, w := range adj[v] {
if !nodeSet[w] {
continue
}
if !defined[w] {
strongConnect(w)
if lowlinks[w] < lowlinks[v] {
lowlinks[v] = lowlinks[w]
}
} else if onStack[w] {
if indices[w] < lowlinks[v] {
lowlinks[v] = indices[w]
}
}
}
// If v is a root node, pop the SCC
if lowlinks[v] == indices[v] {
var scc []string
for {
w := stack[len(stack)-1]
stack = stack[:len(stack)-1]
onStack[w] = false
scc = append(scc, w)
if w == v {
break
}
}
sccs = append(sccs, scc)
}
}
for id := range nodeSet {
if !defined[id] {
strongConnect(id)
}
}
return sccs
}
// orderCyclePath finds a valid cycle within the SCC members by following edges.
// The returned path guarantees that for every consecutive pair (path[i], path[i+1])
// and the closing pair (path[last], path[0]), a directed edge exists in adj.
// Note: the path may not include all SCC members if no Hamiltonian cycle exists.
func orderCyclePath(scc []string, adj map[string][]string) []string {
sccSet := make(map[string]bool, len(scc))
for _, id := range scc {
sccSet[id] = true
}
// Find a cycle using DFS from the first node.
// In an SCC with size >= 2, there is always at least one cycle.
start := scc[0]
visited := make(map[string]bool)
parent := make(map[string]string)
var cyclePath []string
var dfs func(node string) bool
dfs = func(node string) bool {
visited[node] = true
for _, next := range adj[node] {
if !sccSet[next] {
continue
}
if next == start && node != start {
// Found a cycle back to start — reconstruct
cyclePath = []string{start}
cur := node
var stack []string
for cur != start {
stack = append(stack, cur)
cur = parent[cur]
}
// Reverse stack to get start -> ... -> node order
for i := len(stack) - 1; i >= 0; i-- {
cyclePath = append(cyclePath, stack[i])
}
return true
}
if !visited[next] {
parent[next] = node
if dfs(next) {
return true
}
}
}
return false
}
parent[start] = ""
dfs(start)
if len(cyclePath) >= 2 {
return cyclePath
}
// Fallback: return the SCC members in original order (should not happen for valid SCC >= 2)
return scc
}
// classifyCycle determines the kind and severity of a cycle based on edge types
// and community membership.
func classifyCycle(path []string, edgeKinds map[edgePair][]graph.EdgeKind, communities *CommunityResult) (string, int) {
// Check if any edge in the cycle is an import edge
hasImport := false
for i := 0; i < len(path); i++ {
from := path[i]
to := path[(i+1)%len(path)]
pair := edgePair{from, to}
for _, kind := range edgeKinds[pair] {
if kind == graph.EdgeImports {
hasImport = true
break
}
}
if hasImport {
break
}
}
if hasImport {
return "import-cycle", 3
}
// Check if cycle spans multiple communities
if communities != nil && communities.NodeToComm != nil {
communitySet := make(map[string]bool)
for _, id := range path {
if comm, ok := communities.NodeToComm[id]; ok {
communitySet[comm] = true
}
}
if len(communitySet) > 1 {
return "cross-community-cycle", 2
}
}
return "call-cycle", 1
}
+540
View File
@@ -0,0 +1,540 @@
package analysis
import (
"fmt"
"strings"
"testing"
"github.com/zzet/gortex/internal/graph"
"pgregory.net/rapid"
)
// Feature: gortex-enhancements, Property 8: Detected cycles are valid cycles
// --- Generators ---
// cycleGraphResult holds a generated graph with known cycles for validation.
type cycleGraphResult struct {
Graph *graph.Graph
Communities *CommunityResult
Scope string // optional scope prefix; empty means no scope
ScopeNodes []string // node IDs that are in scope
}
// genCycleGraph generates a random directed graph with known cycles.
// It creates nodes across different file paths and adds edges that form cycles,
// plus some acyclic edges for noise.
func genCycleGraph() *rapid.Generator[cycleGraphResult] {
return rapid.Custom(func(t *rapid.T) cycleGraphResult {
g := graph.New()
// Choose 2-3 package prefixes
prefixes := []string{"pkg/alpha", "pkg/beta", "pkg/gamma"}
numPrefixes := rapid.IntRange(2, 3).Draw(t, "numPrefixes")
usedPrefixes := prefixes[:numPrefixes]
nodeToComm := make(map[string]string)
var allIDs []string
// Create 3-8 function nodes spread across prefixes
numNodes := rapid.IntRange(3, 8).Draw(t, "numNodes")
for i := range numNodes {
prefixIdx := rapid.IntRange(0, numPrefixes-1).Draw(t, fmt.Sprintf("prefix%d", i))
prefix := usedPrefixes[prefixIdx]
id := fmt.Sprintf("%s/mod%d.go::func%d", prefix, i, i)
allIDs = append(allIDs, id)
nodeToComm[id] = fmt.Sprintf("community-%d", prefixIdx)
g.AddNode(&graph.Node{
ID: id,
Kind: graph.KindFunction,
Name: fmt.Sprintf("func%d", i),
FilePath: fmt.Sprintf("%s/mod%d.go", prefix, i),
StartLine: 1,
EndLine: 10,
Language: "go",
})
}
// Add 1-3 cycles by creating directed edges that form loops
numCycles := rapid.IntRange(1, 3).Draw(t, "numCycles")
for c := 0; c < numCycles; c++ {
// Pick 2-4 nodes to form a cycle
cycleLen := rapid.IntRange(2, min(4, numNodes)).Draw(t, fmt.Sprintf("cycleLen%d", c))
// Pick distinct node indices
indices := pickDistinct(t, numNodes, cycleLen, fmt.Sprintf("cycle%d", c))
// Choose edge kind: calls or imports
useImports := rapid.Bool().Draw(t, fmt.Sprintf("useImports%d", c))
edgeKind := graph.EdgeCalls
if useImports {
edgeKind = graph.EdgeImports
}
// Create cycle: indices[0] -> indices[1] -> ... -> indices[n-1] -> indices[0]
for i := 0; i < cycleLen; i++ {
from := allIDs[indices[i]]
to := allIDs[indices[(i+1)%cycleLen]]
g.AddEdge(&graph.Edge{
From: from,
To: to,
Kind: edgeKind,
})
}
}
// Add some acyclic edges for noise
numAcyclic := rapid.IntRange(0, numNodes).Draw(t, "numAcyclic")
for i := 0; i < numAcyclic; i++ {
fromIdx := rapid.IntRange(0, numNodes-1).Draw(t, fmt.Sprintf("acyclicFrom%d", i))
toIdx := rapid.IntRange(0, numNodes-1).Draw(t, fmt.Sprintf("acyclicTo%d", i))
if fromIdx == toIdx {
continue
}
edgeKind := graph.EdgeCalls
if rapid.Bool().Draw(t, fmt.Sprintf("acyclicImport%d", i)) {
edgeKind = graph.EdgeImports
}
g.AddEdge(&graph.Edge{
From: allIDs[fromIdx],
To: allIDs[toIdx],
Kind: edgeKind,
})
}
communities := &CommunityResult{
NodeToComm: nodeToComm,
}
return cycleGraphResult{
Graph: g,
Communities: communities,
Scope: "",
ScopeNodes: allIDs,
}
})
}
// genScopedCycleGraph generates a graph with nodes in different file paths
// and a scope prefix, to test scope filtering.
func genScopedCycleGraph() *rapid.Generator[cycleGraphResult] {
return rapid.Custom(func(t *rapid.T) cycleGraphResult {
g := graph.New()
inScopePrefix := "pkg/inscope"
outScopePrefix := "pkg/outscope"
nodeToComm := make(map[string]string)
var inScopeIDs []string
// Create 3-5 in-scope nodes
numInScope := rapid.IntRange(3, 5).Draw(t, "numInScope")
for i := range numInScope {
id := fmt.Sprintf("%s/mod%d.go::inFunc%d", inScopePrefix, i, i)
inScopeIDs = append(inScopeIDs, id)
nodeToComm[id] = "community-0"
g.AddNode(&graph.Node{
ID: id,
Kind: graph.KindFunction,
Name: fmt.Sprintf("inFunc%d", i),
FilePath: fmt.Sprintf("%s/mod%d.go", inScopePrefix, i),
StartLine: 1,
EndLine: 10,
Language: "go",
})
}
// Create 2-4 out-of-scope nodes
numOutScope := rapid.IntRange(2, 4).Draw(t, "numOutScope")
var outScopeIDs []string
for i := range numOutScope {
id := fmt.Sprintf("%s/mod%d.go::outFunc%d", outScopePrefix, i, i)
outScopeIDs = append(outScopeIDs, id)
nodeToComm[id] = "community-1"
g.AddNode(&graph.Node{
ID: id,
Kind: graph.KindFunction,
Name: fmt.Sprintf("outFunc%d", i),
FilePath: fmt.Sprintf("%s/mod%d.go", outScopePrefix, i),
StartLine: 1,
EndLine: 10,
Language: "go",
})
}
// Add a cycle among in-scope nodes
for i := 0; i < numInScope; i++ {
g.AddEdge(&graph.Edge{
From: inScopeIDs[i],
To: inScopeIDs[(i+1)%numInScope],
Kind: graph.EdgeCalls,
})
}
// Add a cycle among out-of-scope nodes
for i := 0; i < numOutScope; i++ {
g.AddEdge(&graph.Edge{
From: outScopeIDs[i],
To: outScopeIDs[(i+1)%numOutScope],
Kind: graph.EdgeCalls,
})
}
// Optionally add cross-scope edges (these should not appear in scoped results)
if rapid.Bool().Draw(t, "addCrossScope") {
g.AddEdge(&graph.Edge{
From: inScopeIDs[0],
To: outScopeIDs[0],
Kind: graph.EdgeCalls,
})
}
communities := &CommunityResult{
NodeToComm: nodeToComm,
}
return cycleGraphResult{
Graph: g,
Communities: communities,
Scope: inScopePrefix,
ScopeNodes: inScopeIDs,
}
})
}
// pickDistinct picks n distinct indices from [0, max).
func pickDistinct(t *rapid.T, max, n int, label string) []int {
if n > max {
n = max
}
// Generate a permutation and take first n
perm := make([]int, max)
for i := range perm {
perm[i] = i
}
// Fisher-Yates shuffle (partial)
for i := 0; i < n; i++ {
j := rapid.IntRange(i, max-1).Draw(t, fmt.Sprintf("%s_idx%d", label, i))
perm[i], perm[j] = perm[j], perm[i]
}
return perm[:n]
}
// --- Property Tests ---
// TestPropertyCycleValidity verifies that every cycle returned by DetectCycles
// is a valid cycle: following the edges in path order from the first symbol
// eventually returns to the first symbol. All consecutive pairs in the path
// must have a directed edge between them in the graph.
func TestPropertyCycleValidity(t *testing.T) {
rapid.Check(t, func(rt *rapid.T) {
tc := genCycleGraph().Draw(rt, "cycleGraph")
cycles := DetectCycles(tc.Graph, tc.Communities, tc.Scope)
// Build adjacency set from the graph's actual edges (calls + imports only)
edgeSet := buildEdgeSet(tc.Graph)
for i, cycle := range cycles {
if len(cycle.Path) < 2 {
rt.Errorf("cycle %d has fewer than 2 nodes: %v", i, cycle.Path)
continue
}
// Verify each consecutive pair has an edge
for j := 0; j < len(cycle.Path); j++ {
from := cycle.Path[j]
to := cycle.Path[(j+1)%len(cycle.Path)]
pair := edgePair{from, to}
if !edgeSet[pair] {
rt.Errorf("cycle %d: no edge from %s to %s (path index %d -> %d)",
i, from, to, j, (j+1)%len(cycle.Path))
}
}
// Verify cycle kind is valid
validKinds := map[string]bool{
"import-cycle": true,
"call-cycle": true,
"cross-community-cycle": true,
}
if !validKinds[cycle.Kind] {
rt.Errorf("cycle %d has invalid kind: %q", i, cycle.Kind)
}
// Verify severity matches kind
expectedSeverity := map[string]int{
"import-cycle": 3,
"call-cycle": 1,
"cross-community-cycle": 2,
}
if cycle.Severity != expectedSeverity[cycle.Kind] {
rt.Errorf("cycle %d: kind=%q severity=%d, expected %d",
i, cycle.Kind, cycle.Severity, expectedSeverity[cycle.Kind])
}
// Verify all nodes in the cycle actually exist in the graph
for _, nodeID := range cycle.Path {
if tc.Graph.GetNode(nodeID) == nil {
rt.Errorf("cycle %d contains non-existent node: %s", i, nodeID)
}
}
}
})
}
// TestPropertyCycleScopeFiltering verifies that when a scope is specified,
// all symbols in every reported cycle have file paths matching the scope prefix.
func TestPropertyCycleScopeFiltering(t *testing.T) {
rapid.Check(t, func(rt *rapid.T) {
tc := genScopedCycleGraph().Draw(rt, "scopedCycleGraph")
cycles := DetectCycles(tc.Graph, tc.Communities, tc.Scope)
for i, cycle := range cycles {
for _, nodeID := range cycle.Path {
node := tc.Graph.GetNode(nodeID)
if node == nil {
rt.Errorf("cycle %d contains non-existent node: %s", i, nodeID)
continue
}
if !strings.HasPrefix(node.FilePath, tc.Scope) {
rt.Errorf("cycle %d: node %s has file path %q which does not match scope %q",
i, nodeID, node.FilePath, tc.Scope)
}
}
}
// Verify that at least one cycle is found (we always add a cycle among in-scope nodes)
if len(cycles) == 0 {
rt.Errorf("expected at least one cycle in scope %q, got none", tc.Scope)
}
})
}
// TestPropertyCycleSeverityOrdering verifies that cycles are sorted by severity descending.
func TestPropertyCycleSeverityOrdering(t *testing.T) {
rapid.Check(t, func(rt *rapid.T) {
tc := genCycleGraph().Draw(rt, "cycleGraph")
cycles := DetectCycles(tc.Graph, tc.Communities, "")
for i := 1; i < len(cycles); i++ {
if cycles[i].Severity > cycles[i-1].Severity {
rt.Errorf("cycles not sorted by severity descending: cycle %d severity=%d > cycle %d severity=%d",
i, cycles[i].Severity, i-1, cycles[i-1].Severity)
}
}
})
}
// Feature: gortex-enhancements, Property 9: Would-create-cycle matches reachability
// --- Generators ---
// wouldCycleGraphResult holds a generated graph and a pair of nodes to test.
type wouldCycleGraphResult struct {
Graph *graph.Graph
FromID string
ToID string
// Whether toID can reach fromID via directed paths (calls/imports edges)
ExpectCycle bool
}
// genWouldCycleGraph generates a random graph and picks a pair of nodes,
// computing the expected reachability via reference DFS.
func genWouldCycleGraph() *rapid.Generator[wouldCycleGraphResult] {
return rapid.Custom(func(t *rapid.T) wouldCycleGraphResult {
g := graph.New()
// Create 4-10 nodes
numNodes := rapid.IntRange(4, 10).Draw(t, "numNodes")
ids := make([]string, numNodes)
for i := range numNodes {
id := fmt.Sprintf("pkg/mod%d.go::func%d", i, i)
ids[i] = id
g.AddNode(&graph.Node{
ID: id,
Kind: graph.KindFunction,
Name: fmt.Sprintf("func%d", i),
FilePath: fmt.Sprintf("pkg/mod%d.go", i),
StartLine: 1,
EndLine: 10,
Language: "go",
})
}
// Add random edges (calls and imports)
numEdges := rapid.IntRange(2, numNodes*2).Draw(t, "numEdges")
seen := make(map[string]bool)
adj := make(map[string][]string) // for reference DFS
for e := 0; e < numEdges; e++ {
fromIdx := rapid.IntRange(0, numNodes-1).Draw(t, fmt.Sprintf("from%d", e))
toIdx := rapid.IntRange(0, numNodes-1).Draw(t, fmt.Sprintf("to%d", e))
if fromIdx == toIdx {
continue
}
key := fmt.Sprintf("%d->%d", fromIdx, toIdx)
if seen[key] {
continue
}
seen[key] = true
edgeKind := graph.EdgeCalls
if rapid.Bool().Draw(t, fmt.Sprintf("isImport%d", e)) {
edgeKind = graph.EdgeImports
}
g.AddEdge(&graph.Edge{
From: ids[fromIdx],
To: ids[toIdx],
Kind: edgeKind,
})
adj[ids[fromIdx]] = append(adj[ids[fromIdx]], ids[toIdx])
}
// Pick two distinct nodes
fromIdx := rapid.IntRange(0, numNodes-1).Draw(t, "fromIdx")
toIdx := rapid.IntRange(0, numNodes-2).Draw(t, "toIdx")
if toIdx >= fromIdx {
toIdx++
}
fromID := ids[fromIdx]
toID := ids[toIdx]
// Reference DFS: check if toID can reach fromID
expectCycle := referenceDFS(adj, toID, fromID)
return wouldCycleGraphResult{
Graph: g,
FromID: fromID,
ToID: toID,
ExpectCycle: expectCycle,
}
})
}
// referenceDFS performs a simple DFS from start looking for target.
func referenceDFS(adj map[string][]string, start, target string) bool {
visited := make(map[string]bool)
var dfs func(node string) bool
dfs = func(node string) bool {
if node == target {
return true
}
visited[node] = true
for _, next := range adj[node] {
if !visited[next] {
if dfs(next) {
return true
}
}
}
return false
}
return dfs(start)
}
// --- Property Tests ---
// TestPropertyWouldCreateCycle_MatchesReachability verifies that
// WouldCreateCycle(from_id, to_id) returns true if and only if there exists
// a directed path from to_id to from_id in the current graph.
func TestPropertyWouldCreateCycle_MatchesReachability(t *testing.T) {
rapid.Check(t, func(rt *rapid.T) {
tc := genWouldCycleGraph().Draw(rt, "wouldCycleGraph")
wouldCycle, path := WouldCreateCycle(tc.Graph, tc.FromID, tc.ToID)
if wouldCycle != tc.ExpectCycle {
rt.Errorf("WouldCreateCycle(%s, %s) = %v, expected %v (based on reference DFS reachability)",
tc.FromID, tc.ToID, wouldCycle, tc.ExpectCycle)
}
if wouldCycle {
// Verify the returned path is valid: from toID to fromID
if len(path) < 2 {
rt.Errorf("cycle path should have at least 2 nodes, got %d: %v", len(path), path)
return
}
// Path should start at toID and end at fromID
if path[0] != tc.ToID {
rt.Errorf("cycle path should start at toID=%s, got %s", tc.ToID, path[0])
}
if path[len(path)-1] != tc.FromID {
rt.Errorf("cycle path should end at fromID=%s, got %s", tc.FromID, path[len(path)-1])
}
// Build adjacency for path validation
edgeSet := buildEdgeSet(tc.Graph)
// Verify each consecutive pair in the path has an edge
for i := 0; i < len(path)-1; i++ {
pair := edgePair{path[i], path[i+1]}
if !edgeSet[pair] {
rt.Errorf("path has no edge from %s to %s (index %d -> %d)",
path[i], path[i+1], i, i+1)
}
}
} else {
// When no cycle, path should be nil/empty
if len(path) > 0 {
rt.Errorf("WouldCreateCycle returned false but path is non-empty: %v", path)
}
}
})
}
// TestPropertyWouldCreateCycle_SelfLoop verifies that WouldCreateCycle(a, a)
// always returns true (adding a->a is trivially a cycle since a reaches a).
func TestPropertyWouldCreateCycle_SelfLoop(t *testing.T) {
rapid.Check(t, func(rt *rapid.T) {
g := graph.New()
numNodes := rapid.IntRange(1, 5).Draw(rt, "numNodes")
ids := make([]string, numNodes)
for i := range numNodes {
id := fmt.Sprintf("pkg/mod%d.go::func%d", i, i)
ids[i] = id
g.AddNode(&graph.Node{
ID: id,
Kind: graph.KindFunction,
Name: fmt.Sprintf("func%d", i),
FilePath: fmt.Sprintf("pkg/mod%d.go", i),
StartLine: 1,
EndLine: 10,
Language: "go",
})
}
// Pick a random node
idx := rapid.IntRange(0, numNodes-1).Draw(rt, "selfIdx")
nodeID := ids[idx]
// WouldCreateCycle(a, a): DFS from a looking for a.
// The DFS starts at toID (which is a) and looks for fromID (which is also a).
// Since start == target, it should immediately find it.
wouldCycle, _ := WouldCreateCycle(g, nodeID, nodeID)
if !wouldCycle {
rt.Errorf("WouldCreateCycle(%s, %s) should be true (self-loop)", nodeID, nodeID)
}
})
}
// --- Helpers ---
// buildEdgeSet creates a set of directed edge pairs from the graph's calls and imports edges.
func buildEdgeSet(g *graph.Graph) map[edgePair]bool {
edges := g.AllEdges()
set := make(map[edgePair]bool)
for _, e := range edges {
if e.Kind == graph.EdgeCalls || e.Kind == graph.EdgeImports {
set[edgePair{e.From, e.To}] = true
}
}
return set
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,63 @@
package analysis
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/zzet/gortex/internal/graph"
)
// TestDeadCode_SyntheticExternalNodesExcluded verifies that the synthetic
// external-symbol / stub nodes the resolver materialises (stdlib::*, dep::*,
// external::* with Meta["external"]=true, and the "<kind>::*" stub ids) are
// never reported as dead code — they are imported third-party / stdlib
// symbols, not first-party code, and by construction have zero incoming
// usage edges. A real unexported function with no callers must STILL be
// reported, so the filter is specific rather than blanket.
func TestDeadCode_SyntheticExternalNodesExcluded(t *testing.T) {
g := graph.New()
// Synthetic external-call attribution nodes: KindFunction, lowercase
// (unexported) names so the only thing that could exclude them is the
// new external/stub filter — not the exported-symbol skip.
g.AddNode(&graph.Node{
ID: "stdlib::fmt::lowerStdlib", Kind: graph.KindFunction,
Name: "lowerStdlib", Language: "go",
Meta: map[string]any{"external": true},
})
g.AddNode(&graph.Node{
ID: "dep::github.com/x/y::lowerDep", Kind: graph.KindFunction,
Name: "lowerDep", Language: "go",
Meta: map[string]any{"external": true},
})
g.AddNode(&graph.Node{
ID: "external::os::lowerExternal", Kind: graph.KindFunction,
Name: "lowerExternal", Language: "go",
Meta: map[string]any{"external": true},
})
// A stub-id node WITHOUT the Meta flag — caught by graph.IsStub on the
// id prefix alone (the CGo / stub-layer form, e.g. stdlib::C::foo).
g.AddNode(&graph.Node{
ID: "stdlib::C::lbug_thing", Kind: graph.KindFunction,
Name: "lbug_thing", Language: "go",
})
g.AddNode(&graph.Node{
ID: "gortex::stdlib::C::repo_prefixed_stub", Kind: graph.KindFunction,
Name: "repo_prefixed_stub", Language: "go",
})
// Control: a genuine first-party unexported function with no callers.
g.AddNode(&graph.Node{
ID: "pkg/x.go::deadHelper", Kind: graph.KindFunction,
Name: "deadHelper", FilePath: "pkg/x.go", StartLine: 10, EndLine: 20, Language: "go",
})
result := FindDeadCode(g, nil, nil)
if assert.Len(t, result, 1, "only the real first-party dead function should be reported") {
assert.Equal(t, "pkg/x.go::deadHelper", result[0].ID)
}
for _, e := range result {
assert.False(t, graph.IsStub(e.ID), "no stub id should appear: %s", e.ID)
}
}
+91
View File
@@ -0,0 +1,91 @@
package analysis
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/zzet/gortex/internal/graph"
)
// addJavaMethod adds a Java method node with a recorded visibility and
// no incoming edges (i.e. uncalled in the indexed graph).
func addJavaMethod(g *graph.Graph, file, name, vis string) string {
id := file + "::" + name
g.AddNode(&graph.Node{
ID: id, Kind: graph.KindMethod, Name: name,
FilePath: file, Language: "java", StartLine: 1, EndLine: 3,
Meta: map[string]any{"visibility": vis},
})
return id
}
// TestFindDeadCode_JavaVisibility is the parity fix: dead-code must read
// Java's recorded public/private modifier instead of the Go-style /
// underscore name heuristic (which marked every Java symbol exported and
// skipped the whole language).
func TestFindDeadCode_JavaVisibility(t *testing.T) {
g := graph.New()
g.AddNode(&graph.Node{ID: "Svc.java", Kind: graph.KindFile, FilePath: "Svc.java", Language: "java"})
privDead := addJavaMethod(g, "Svc.java", "computeInternal", "private")
pkgDead := addJavaMethod(g, "Svc.java", "helperPkg", "package")
pubAPI := addJavaMethod(g, "Svc.java", "doWork", "public")
protAPI := addJavaMethod(g, "Svc.java", "onInit", "protected")
dead := map[string]bool{}
for _, d := range FindDeadCode(g, nil, nil) {
dead[d.ID] = true
}
require.True(t, dead[privDead], "an uncalled private method is genuinely dead")
require.True(t, dead[pkgDead], "an uncalled package-private method is genuinely dead")
require.False(t, dead[pubAPI], "a public method is API surface, not dead")
require.False(t, dead[protAPI], "a protected method is subclass API, not dead")
}
// TestFindDeadCode_JavaOverrideRescued guards the false positive the
// visibility fix would otherwise introduce: a non-public @Override
// implements a supertype contract and is reached through it, so it must
// not be reported even with no direct caller.
func TestFindDeadCode_JavaOverrideRescued(t *testing.T) {
g := graph.New()
g.AddNode(&graph.Node{ID: "Cmp.java", Kind: graph.KindFile, FilePath: "Cmp.java", Language: "java"})
plainPkg := addJavaMethod(g, "Cmp.java", "unusedPkgHelper", "package")
overridePkg := addJavaMethod(g, "Cmp.java", "compareTo", "package")
g.AddEdge(&graph.Edge{From: overridePkg, To: javaOverrideAnnoID, Kind: graph.EdgeAnnotated})
dead := map[string]bool{}
for _, d := range FindDeadCode(g, nil, nil) {
dead[d.ID] = true
}
require.True(t, dead[plainPkg], "a plain uncalled package-private method is dead")
require.False(t, dead[overridePkg], "a package-private @Override is reached via its contract")
}
// TestFindDeadCode_JavaEntryPointRescued confirms a framework-stamped
// entry point (e.g. a package-private @PostConstruct callback) is not
// flagged once visibility makes non-public methods eligible.
func TestFindDeadCode_JavaEntryPointRescued(t *testing.T) {
g := graph.New()
g.AddNode(&graph.Node{ID: "Bean.java", Kind: graph.KindFile, FilePath: "Bean.java", Language: "java"})
// Stamp it like entrypoints.detectJava would for @PostConstruct.
callback := "Bean.java::init"
g.AddNode(&graph.Node{
ID: callback, Kind: graph.KindMethod, Name: "init",
FilePath: "Bean.java", Language: "java", StartLine: 1, EndLine: 3,
Meta: map[string]any{
"visibility": "package",
"entry_point": true,
"entry_point_kind": "lifecycle:init",
},
})
dead := map[string]bool{}
for _, d := range FindDeadCode(g, nil, nil) {
dead[d.ID] = true
}
require.False(t, dead[callback], "a stamped framework callback is a live root")
}
File diff suppressed because it is too large Load Diff

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