diff --git a/cmd/gortex/call.go b/cmd/gortex/call.go index 3b6ac4ccd..a7389cac1 100644 --- a/cmd/gortex/call.go +++ b/cmd/gortex/call.go @@ -10,6 +10,9 @@ import ( "strings" "github.com/spf13/cobra" + + "github.com/zzet/gortex/internal/daemon" + gortexmcp "github.com/zzet/gortex/internal/mcp" ) var ( @@ -20,6 +23,7 @@ var ( callFormat string callDry bool callQuiet bool + callLegacy bool ) // callDaemonTool is the daemon-tool relay seam. It is indirected through a @@ -27,6 +31,16 @@ var ( // for name validation) without a running daemon. var callDaemonTool = requireDaemonTool +// callFacadeDaemonTool uses the same shell argument lowering and output path as +// callDaemonTool, but negotiates the compact facade surface for this one daemon +// connection. Kept as a separate seam so tests prove surface selection without +// a running daemon. +var callFacadeDaemonTool = func(repoPath, tool string, args map[string]any) (json.RawMessage, error) { + return requireDaemonToolWithSurface( + repoPath, tool, args, gortexmcp.FacadeSurfaceVersion, "hide", + ) +} + var callCmd = &cobra.Command{ Use: "call ", Short: "Invoke any registered MCP tool by name over the daemon", @@ -48,6 +62,14 @@ Use --dry to print the lowered argument object and the target tool without calling the daemon (works with no daemon running). Use --format to pick the wire format the tool renders (json|gcx|toon|text). +When is one of the compact public tool names, this command automatically +selects the compact surface for that connection and accepts the exact MCP +argument object. Other names explicitly use the legacy-compatible surface. +Use --legacy to preserve the old handler contract for shared names such as +analyze, explore, review, and ask. +Discover compact operations with: + gortex call capabilities --arg domain=read --arg operation=source --arg detail=schema + Requires a running daemon that tracks the repo (except for --dry).`, Args: cobra.ExactArgs(1), SilenceUsage: true, // a tool/daemon error should read cleanly, not dump usage @@ -63,6 +85,7 @@ func init() { callCmd.Flags().StringVar(&callFormat, "format", "json", "output / wire format forwarded to the tool: json|gcx|toon|text") callCmd.Flags().BoolVar(&callDry, "dry", false, "print the lowered argument object and target tool without calling the daemon") callCmd.Flags().BoolVar(&callQuiet, "quiet", false, "suppress the stderr note when calling a mutating tool") + callCmd.Flags().BoolVar(&callLegacy, "legacy", false, "use the legacy MCP contract for a shared tool name") rootCmd.AddCommand(callCmd) } @@ -75,34 +98,62 @@ func runCall(cmd *cobra.Command, args []string) error { return err } + compactCall := gortexmcp.IsFacadeToolName(tool) && !callLegacy + formatExplicit := callFormat != "json" + if flag := cmd.Flags().Lookup("format"); flag != nil && flag.Changed { + formatExplicit = true + } + // Legacy handlers historically receive the CLI's json default. Compact + // calls mirror the supplied MCP object exactly unless --format was explicit. + if compactCall { + if formatExplicit { + if err := mergeCompactOutputFormat(argObj, callFormat); err != nil { + return err + } + } + } else if callFormat != "" { + argObj["format"] = callFormat + } if callDry { return printCallDry(cmd, tool, argObj) } + facadeEffectful := compactCall && daemon.IsEffectful(tool) + if facadeEffectful && !callQuiet { + fmt.Fprintf(cmd.ErrOrStderr(), "note: %s can change %s\n", tool, describeToolEffects(daemon.EffectOf(tool))) + } // Best-effort name validation against the live daemon's catalog. When no // daemon is reachable this is skipped entirely so the call below returns // the normal daemonRequiredErr instead of a confusing "unknown tool". - if cat, ok := fetchToolCatalog(callIndex); ok { - if !cat.has(tool) { - return unknownToolErr(tool, cat) - } - if cat.mutating(tool) && !callQuiet { - fmt.Fprintf(cmd.ErrOrStderr(), "note: %s writes to your working tree / graph\n", tool) + if !compactCall { + if cat, ok := fetchToolCatalog(callIndex); ok { + if !cat.has(tool) { + return unknownToolErr(tool, cat) + } + if cat.mutating(tool) && !callQuiet { + fmt.Fprintf(cmd.ErrOrStderr(), "note: %s writes to your working tree / graph\n", tool) + } } } - // Forward the chosen wire format to the tool. The executor pins - // format=json by default; an explicit format here overrides it. - if callFormat != "" { - argObj["format"] = callFormat + relay := callDaemonTool + if compactCall { + relay = callFacadeDaemonTool } - - raw, err := callDaemonTool(callIndex, tool, argObj) + raw, err := relay(callIndex, tool, argObj) if err != nil { return err } - switch callFormat { + responseFormat := callFormat + if compactCall && !formatExplicit { + if output, ok := argObj["output"].(map[string]any); ok { + if requested, ok := output["format"].(string); ok && requested != "" { + responseFormat = requested + } + } + } + switch responseFormat { case "gcx", "toon": // Compact wire formats are printed verbatim — re-indenting would // corrupt them. @@ -113,6 +164,47 @@ func runCall(cmd *cobra.Command, args []string) error { } } +func describeToolEffects(effect daemon.ToolEffect) string { + var boundaries []string + if effect&daemon.EffectFilesystemWrite != 0 { + boundaries = append(boundaries, "local files") + } + if effect&daemon.EffectGraphWrite != 0 { + boundaries = append(boundaries, "the indexed graph") + } + if effect&daemon.EffectConfigWrite != 0 { + boundaries = append(boundaries, "configuration") + } + if effect&daemon.EffectSessionWrite != 0 { + boundaries = append(boundaries, "session state") + } + if effect&daemon.EffectExternalWrite != 0 { + boundaries = append(boundaries, "external systems") + } + if len(boundaries) == 0 { + return "state" + } + return strings.Join(boundaries, ", ") +} + +func mergeCompactOutputFormat(args map[string]any, format string) error { + if format == "" { + return nil + } + delete(args, "format") // an explicit CLI flag normalizes the legacy spelling + raw, exists := args["output"] + if !exists || raw == nil { + args["output"] = map[string]any{"format": format} + return nil + } + output, ok := raw.(map[string]any) + if !ok { + return fmt.Errorf("compact output must be a JSON object, got %T", raw) + } + output["format"] = format + return nil +} + // printCallDry prints the lowered argument object (indented JSON) and the // target tool name without touching the daemon. func printCallDry(cmd *cobra.Command, tool string, argObj map[string]any) error { diff --git a/cmd/gortex/call_test.go b/cmd/gortex/call_test.go index 63c420f88..cbd04299f 100644 --- a/cmd/gortex/call_test.go +++ b/cmd/gortex/call_test.go @@ -10,6 +10,8 @@ import ( "github.com/spf13/cobra" "github.com/stretchr/testify/require" + + gortexmcp "github.com/zzet/gortex/internal/mcp" ) // newCallTestCmd builds a fresh call command bound to an out/err buffer and @@ -23,6 +25,7 @@ func newCallTestCmd(t *testing.T) (*cobra.Command, *bytes.Buffer) { callFormat = "json" callDry = false callQuiet = false + callLegacy = false buf := &bytes.Buffer{} cmd := &cobra.Command{Use: "call", RunE: runCall} @@ -185,6 +188,192 @@ func TestCallDry_NoDaemon(t *testing.T) { require.Contains(t, out, `"verbose": true`) } +func TestCall_AllCompactNamesUseFacadeRelay(t *testing.T) { + origLegacy, origFacade := callDaemonTool, callFacadeDaemonTool + t.Cleanup(func() { + callDaemonTool, callFacadeDaemonTool = origLegacy, origFacade + }) + + for _, name := range gortexmcp.FacadeToolNames() { + t.Run(name, func(t *testing.T) { + var facadeTool string + callDaemonTool = func(_ string, tool string, _ map[string]any) (json.RawMessage, error) { + t.Fatalf("compact call %q touched legacy relay with %q", name, tool) + return nil, nil + } + callFacadeDaemonTool = func(_ string, tool string, _ map[string]any) (json.RawMessage, error) { + facadeTool = tool + return json.RawMessage(`{"ok":true}`), nil + } + + cmd, _ := newCallTestCmd(t) + require.NoError(t, runCall(cmd, []string{name})) + require.Equal(t, name, facadeTool) + }) + } +} + +func TestCall_HelpUsesOneClientNeutralShellContract(t *testing.T) { + help := strings.ToLower(callCmd.Long) + require.Contains(t, help, "gortex call capabilities") + require.NotContains(t, help, "facade-v1") + for _, client := range []string{"codex", "claude", "cursor", "vscode"} { + require.NotContains(t, help, client) + } + require.False(t, isKnownRootCommand("facade"), "compact calls must not create a second top-level CLI contract") +} + +func TestCall_CompactRequestObjectIsForwardedUnchanged(t *testing.T) { + origLegacy, origFacade := callDaemonTool, callFacadeDaemonTool + t.Cleanup(func() { + callDaemonTool, callFacadeDaemonTool = origLegacy, origFacade + }) + callDaemonTool = func(string, string, map[string]any) (json.RawMessage, error) { + t.Fatal("compact call must not use legacy relay") + return nil, nil + } + var got map[string]any + callFacadeDaemonTool = func(_ string, tool string, args map[string]any) (json.RawMessage, error) { + require.Equal(t, "read", tool) + got = args + return json.RawMessage(`{"ok":true}`), nil + } + + cmd, _ := newCallTestCmd(t) + callArgs = []string{"operation=file", `target:={"file":"internal/mcp/server.go"}`} + require.NoError(t, runCall(cmd, []string{"read"})) + require.Equal(t, "file", got["operation"]) + require.Equal(t, map[string]any{"file": "internal/mcp/server.go"}, got["target"]) + require.NotContains(t, got, "format") + require.NotContains(t, got, "output", "the default CLI format must not rewrite an exact compact MCP request") +} + +func TestCall_CompactDefaultPreservesRequestOutputFormat(t *testing.T) { + origLegacy, origFacade := callDaemonTool, callFacadeDaemonTool + t.Cleanup(func() { callDaemonTool, callFacadeDaemonTool = origLegacy, origFacade }) + var got map[string]any + callFacadeDaemonTool = func(_ string, _ string, args map[string]any) (json.RawMessage, error) { + got = args + return json.RawMessage("TOON|results|...\n"), nil + } + + cmd, buf := newCallTestCmd(t) + callJSON = `{"operation":"symbols","query":"Server","output":{"format":"toon","limit":7}}` + require.NoError(t, runCall(cmd, []string{"search"})) + output := got["output"].(map[string]any) + require.Equal(t, "toon", output["format"]) + require.Equal(t, float64(7), output["limit"]) + require.Equal(t, "TOON|results|...\n", buf.String()) +} + +func TestCall_CompactDryRunShowsFinalExplicitFormat(t *testing.T) { + cmd, buf := newCallTestCmd(t) + callDry = true + callFormat = "gcx" + callJSON = `{"operation":"symbols","query":"Server","output":{"format":"toon"}}` + require.NoError(t, runCall(cmd, []string{"search"})) + require.Contains(t, buf.String(), `"format": "gcx"`) + require.NotContains(t, buf.String(), `"format": "toon"`) +} + +func TestCall_CompactFormatMergesIntoExistingOutput(t *testing.T) { + origLegacy, origFacade := callDaemonTool, callFacadeDaemonTool + t.Cleanup(func() { + callDaemonTool, callFacadeDaemonTool = origLegacy, origFacade + }) + callDaemonTool = func(string, string, map[string]any) (json.RawMessage, error) { + t.Fatal("compact call must not use legacy relay") + return nil, nil + } + var got map[string]any + callFacadeDaemonTool = func(_ string, _ string, args map[string]any) (json.RawMessage, error) { + got = args + return json.RawMessage(`{"ok":true}`), nil + } + + cmd, _ := newCallTestCmd(t) + callFormat = "gcx" + callJSON = `{"operation":"symbols","query":"Server","output":{"limit":7,"fields":"id,name","format":"toon"}}` + require.NoError(t, runCall(cmd, []string{"search"})) + output, ok := got["output"].(map[string]any) + require.True(t, ok) + require.Equal(t, "gcx", output["format"], "CLI --format must win") + require.Equal(t, float64(7), output["limit"]) + require.Equal(t, "id,name", output["fields"]) + require.NotContains(t, got, "format") +} + +func TestCall_EffectfulCompactToolWarnsWithoutCatalog(t *testing.T) { + origLegacy, origFacade := callDaemonTool, callFacadeDaemonTool + t.Cleanup(func() { + callDaemonTool, callFacadeDaemonTool = origLegacy, origFacade + }) + callDaemonTool = func(string, string, map[string]any) (json.RawMessage, error) { + t.Fatal("compact call must not use legacy relay") + return nil, nil + } + callFacadeDaemonTool = func(string, string, map[string]any) (json.RawMessage, error) { + return json.RawMessage(`{"ok":true}`), nil + } + + cmd, buf := newCallTestCmd(t) + require.NoError(t, runCall(cmd, []string{"session"})) + require.Contains(t, buf.String(), "can change session state") + + cmd, buf = newCallTestCmd(t) + callQuiet = true + require.NoError(t, runCall(cmd, []string{"session"})) + require.NotContains(t, buf.String(), "note:") +} + +func TestCall_LegacyNameUsesLegacyRelay(t *testing.T) { + origLegacy, origFacade := callDaemonTool, callFacadeDaemonTool + t.Cleanup(func() { + callDaemonTool, callFacadeDaemonTool = origLegacy, origFacade + }) + var called []string + callDaemonTool = func(_ string, tool string, _ map[string]any) (json.RawMessage, error) { + called = append(called, tool) + if tool == "tool_profile" { + return json.RawMessage(cannedToolProfileJSON), nil + } + return json.RawMessage(`{"ok":true}`), nil + } + callFacadeDaemonTool = func(string, string, map[string]any) (json.RawMessage, error) { + t.Fatal("legacy name must not use the compact relay") + return nil, nil + } + + cmd, _ := newCallTestCmd(t) + require.NoError(t, runCall(cmd, []string{"search_symbols"})) + require.Equal(t, []string{"tool_profile", "search_symbols"}, called) +} + +func TestCall_LegacyEscapePreservesSharedAnalyzeContract(t *testing.T) { + origLegacy, origFacade := callDaemonTool, callFacadeDaemonTool + t.Cleanup(func() { callDaemonTool, callFacadeDaemonTool = origLegacy, origFacade }) + var called []string + callDaemonTool = func(_ string, tool string, args map[string]any) (json.RawMessage, error) { + called = append(called, tool) + if tool == "tool_profile" { + return json.RawMessage(`{"live":["analyze"],"descriptors":[{"name":"analyze","mutating":false}]}`), nil + } + require.Equal(t, "analyze", tool) + require.Equal(t, "temporal_verify", args["kind"]) + return json.RawMessage(`{"ok":true}`), nil + } + callFacadeDaemonTool = func(string, string, map[string]any) (json.RawMessage, error) { + t.Fatal("--legacy must not negotiate the compact surface") + return nil, nil + } + + cmd, _ := newCallTestCmd(t) + callLegacy = true + callArgs = []string{"kind=temporal_verify"} + require.NoError(t, runCall(cmd, []string{"analyze"})) + require.Equal(t, []string{"tool_profile", "analyze"}, called) +} + // cannedToolProfileJSON is a minimal tool_profile response with a descriptors // array covering a read tool and a mutating tool. const cannedToolProfileJSON = `{ diff --git a/cmd/gortex/cli_daemon.go b/cmd/gortex/cli_daemon.go index b672fa5ab..85af1f8a0 100644 --- a/cmd/gortex/cli_daemon.go +++ b/cmd/gortex/cli_daemon.go @@ -8,6 +8,7 @@ import ( "path/filepath" "github.com/zzet/gortex/internal/daemon" + gortexmcp "github.com/zzet/gortex/internal/mcp" "github.com/zzet/gortex/internal/pathkey" ) @@ -21,6 +22,11 @@ var ErrNoExecutor = errors.New("no warm daemon and --oneshot not set") // it as a hard error. var ErrRepoNotTracked = errors.New("repository not tracked by the daemon") +const ( + cliLegacyToolSurface = "core" + cliLegacyToolMode = "defer" +) + // cliExecutor runs a registered MCP tool by name and returns its raw // result JSON (the same payload the MCP server returns). type cliExecutor interface { @@ -32,13 +38,14 @@ type cliExecutor interface { // ModeMCP channel — the same warm graph the editor proxies hit, no cold // index. It pins the JSON wire format so per-tool decoding is stable. type daemonExecutor struct { - client *daemon.Client - nextID int + client *daemon.Client + nextID int + pinJSONDefault bool } func (d *daemonExecutor) CallTool(_ context.Context, tool string, args map[string]any) (json.RawMessage, error) { d.nextID++ - frame, err := buildToolCallFrame(d.nextID, tool, args) + frame, err := buildToolCallFrameWithDefault(d.nextID, tool, args, d.pinJSONDefault) if err != nil { return nil, err } @@ -56,13 +63,17 @@ func (d *daemonExecutor) CallTool(_ context.Context, tool string, args map[strin // JSON wire format so the daemon's per-client GCX/TOON auto-selection does // not defeat the per-tool decode. func buildToolCallFrame(id int, tool string, args map[string]any) ([]byte, error) { + return buildToolCallFrameWithDefault(id, tool, args, true) +} + +func buildToolCallFrameWithDefault(id int, tool string, args map[string]any, pinJSON bool) ([]byte, error) { if args == nil { args = map[string]any{} } // Default to JSON, but honour a caller-provided format (e.g. // mermaid / dot for diagram output) so the CLI can request the // daemon's other renderers. - if _, ok := args["format"]; !ok { + if _, ok := args["format"]; pinJSON && !ok { args["format"] = "json" } return json.Marshal(map[string]any{ @@ -121,6 +132,15 @@ func extractToolResult(resp []byte) (json.RawMessage, error) { // no-executor case; --oneshot and autostart land with the shared // constructor and the autostart primitive. func resolveExecutor(repoPath string) (cliExecutor, error) { + return resolveExecutorWithToolSurface(repoPath, cliLegacyToolSurface, cliLegacyToolMode) +} + +// resolveExecutorWithToolSurface is the daemon-first executor with an +// optional per-connection MCP surface. Ordinary CLI verbs explicitly request +// core/defer so legacy tool names keep their historical semantics regardless +// of daemon/client defaults; compact calls request facade-v1/hide. Neither path +// changes the shared daemon or any other session. +func resolveExecutorWithToolSurface(repoPath, tools, toolsMode string) (cliExecutor, error) { abs, err := filepath.Abs(repoPath) if err != nil { abs = repoPath @@ -131,11 +151,20 @@ func resolveExecutor(repoPath string) (cliExecutor, error) { if !daemonOwnsRepo(abs) { return nil, ErrNoExecutor } - c, err := daemon.Dial(daemon.Handshake{Mode: daemon.ModeMCP, ClientName: "cli", CWD: abs}) + c, err := daemon.Dial(daemon.Handshake{ + Mode: daemon.ModeMCP, + ClientName: "cli", + CWD: abs, + Tools: tools, + ToolsMode: toolsMode, + }) if err != nil { return nil, ErrNoExecutor } - return &daemonExecutor{client: c}, nil + return &daemonExecutor{ + client: c, + pinJSONDefault: tools != gortexmcp.FacadeSurfaceVersion, + }, nil } // daemonOwnsRepo reports whether the running daemon tracks a repo that diff --git a/cmd/gortex/cli_daemon_test.go b/cmd/gortex/cli_daemon_test.go index 84da42cff..3a8a73ddd 100644 --- a/cmd/gortex/cli_daemon_test.go +++ b/cmd/gortex/cli_daemon_test.go @@ -46,6 +46,32 @@ func TestBuildToolCallFrame_NilArgs(t *testing.T) { } } +func TestBuildToolCallFrame_CompactKeepsPublicOutputShape(t *testing.T) { + args := map[string]any{ + "operation": "symbols", + "output": map[string]any{"format": "json", "limit": 5}, + } + frame, err := buildToolCallFrameWithDefault(3, "search", args, false) + if err != nil { + t.Fatal(err) + } + var m struct { + Params struct { + Arguments map[string]any `json:"arguments"` + } `json:"params"` + } + if err := json.Unmarshal(frame, &m); err != nil { + t.Fatal(err) + } + if _, leaked := m.Params.Arguments["format"]; leaked { + t.Fatalf("compact frame leaked legacy top-level format: %v", m.Params.Arguments) + } + output, ok := m.Params.Arguments["output"].(map[string]any) + if !ok || output["format"] != "json" || output["limit"] != float64(5) { + t.Fatalf("compact output shaping was not preserved: %v", m.Params.Arguments) + } +} + func contains(b []byte, sub string) bool { return len(b) >= len(sub) && (string(b) == sub || indexOf(string(b), sub) >= 0) } diff --git a/cmd/gortex/daemon.go b/cmd/gortex/daemon.go index 190598b65..de751bc53 100644 --- a/cmd/gortex/daemon.go +++ b/cmd/gortex/daemon.go @@ -23,6 +23,7 @@ import ( "github.com/zzet/gortex/internal/llm/conversationlog" "github.com/zzet/gortex/internal/platform" "github.com/zzet/gortex/internal/progress" + "github.com/zzet/gortex/internal/runtimeactivity" "github.com/zzet/gortex/internal/server" "github.com/zzet/gortex/internal/server/hub" "github.com/zzet/gortex/internal/tui" @@ -438,6 +439,9 @@ func runDaemonStart(cmd *cobra.Command, _ []string) error { stateCapture := state controllerCapture := controller state.mcpServer.AttachHealthSnapshot(func() map[string]any { + runtimeactivity.Begin("health_snapshot") + defer runtimeactivity.End("health_snapshot") + out := map[string]any{ "uptime_seconds": int64(time.Since(daemonStart).Seconds()), "ready": controllerCapture.IsReady(), @@ -450,6 +454,9 @@ func runDaemonStart(cmd *cobra.Command, _ []string) error { out["alloc_bytes"] = st.Runtime.Alloc out["sys_bytes"] = st.Runtime.Sys out["heap_inuse_bytes"] = st.Runtime.HeapInuse + out["heap_idle_bytes"] = st.Runtime.HeapIdle + out["heap_released_bytes"] = st.Runtime.HeapReleased + out["stack_inuse_bytes"] = st.Runtime.StackInuse out["num_goroutine"] = st.Runtime.NumGoroutine out["num_gc"] = st.Runtime.NumGC if st.LSPRouter != nil { @@ -457,6 +464,13 @@ func runDaemonStart(cmd *cobra.Command, _ []string) error { out["lsp_specs_registered"] = len(st.LSPRouter.EnabledSpecs) } } + activity := runtimeactivity.Current() + out["activity_active"] = activity.Active + out["activity_epoch"] = activity.Epoch + out["activity_quiet_ms"] = activity.QuietFor(time.Now()).Milliseconds() + if len(activity.ByKind) > 0 { + out["activity_by_kind"] = activity.ByKind + } if sessions := srvCapture.Sessions(); sessions != nil { out["sessions"] = sessions.Count() } @@ -543,6 +557,12 @@ func runDaemonStart(cmd *cobra.Command, _ []string) error { // buildDaemonState — they just won't reflect files that changed // since the snapshot was written until warmup gets to that repo. go func() { + runtimeactivity.Begin("warmup") + defer func() { + runtimeactivity.End("warmup") + releaseMemoryToOS(logger, "warmup_complete") + }() + start := time.Now() logger.Info("daemon: warmup starting") // markReady fires once references are resolved and the graph is @@ -563,21 +583,6 @@ func runDaemonStart(cmd *cobra.Command, _ []string) error { } mw, warmup := warmupDaemonState(state, logger, markReady) controller.AttachWatcher(mw) - // Wire the daemon's MultiWatcher into the per-server history - // surface so `get_recent_changes` and `get_symbol_history` see - // real events under the daemon. Without this the tools always - // reported "watch mode is not active" even though MultiWatcher - // was actively re-indexing changed files. - if state.mcpServer != nil && mw != nil { - state.mcpServer.SetWatcher(mw) - // Push a one-time degraded notice on the readiness channel when a - // watcher exhausts inotify watches / file descriptors, so a - // subscribed agent learns the index may be frozen without polling. - srv := state.mcpServer - mw.OnDegraded(func(reason string) { - srv.PublishReadiness("degraded", true, map[string]any{"watch_degraded": reason}) - }) - } // Drive the /v1/events SSE stream from the MultiWatcher. The hub is // the only consumer of mw.Events() (SetWatcher reads History(), not // the channel), so this can't starve any other reader. No-op when @@ -592,7 +597,13 @@ func runDaemonStart(cmd *cobra.Command, _ []string) error { // loaded graph instead of returning "run index_repository // first" against a fully populated state. if state.mcpServer != nil { + analysisStart := time.Now() + publishReadinessPhase(state, "analysis", true, nil) state.mcpServer.RunAnalysis() + warmup.analysis = time.Since(analysisStart) + publishReadinessPhase(state, "analysis_done", true, map[string]any{ + "elapsed_ms": warmup.analysis.Milliseconds(), + }) // Co-change pre-warm: fire the git-history mine in the // background so the first user-visible // find_co_changing_symbols / search-rerank call sees a @@ -613,15 +624,6 @@ func runDaemonStart(cmd *cobra.Command, _ []string) error { "warmup_ms": elapsed.Milliseconds(), }) logWarmupSummary(logger, warmup, queryableElapsed, elapsed) - // Warmup is the daemon's single largest allocation burst (parse + - // resolve + the end_batch graph passes, then the whole-graph - // analysis above). This is the last point reached in every warmup - // shape — every early return inside warmupDaemonState still lands - // here — so return the burst's heap high-water to the OS now rather - // than letting the peak pin the idle footprint. RunAnalysis above - // may already have released, but the enrichment tail allocates - // further, so a final release at the true end still pays. - releaseMemoryToOS(logger, "warmup_complete") }() return srv.Serve() @@ -676,27 +678,27 @@ func startReconcileJanitor(mi *indexer.MultiIndexer, interval time.Duration, log for { select { case <-t.C: - gced := mi.GCVanishedWorktrees() - if len(gced) > 0 { - logger.Info("janitor: pruned vanished worktrees", - zap.Int("count", len(gced))) - } - results := mi.ReconcileAll() - // Return the tick's heap to the OS only when it actually did - // work — a repo reindexed stale/deleted files, or a worktree - // was pruned. ReconcileAll fills a result for every repo, so - // the honest "did work" signal is the per-repo stale/deleted - // counts, not the map size. A quiescent tick skips the - // release: FreeOSMemory is a full GC, and paying it hourly - // for a no-op sweep is the periodic cost the release policy - // exists to avoid. - reconciled := 0 - for _, r := range results { - if r != nil { - reconciled += r.StaleFileCount + r.DeletedFileCount + gcedCount, reconciled := func() (int, int) { + runtimeactivity.Begin("reconcile") + defer runtimeactivity.End("reconcile") + + gced := mi.GCVanishedWorktrees() + if len(gced) > 0 { + logger.Info("janitor: pruned vanished worktrees", + zap.Int("count", len(gced))) } - } - if reconciled > 0 || len(gced) > 0 { + results := mi.ReconcileAll() + reconciled := 0 + for _, r := range results { + if r != nil { + reconciled += r.StaleFileCount + r.DeletedFileCount + } + } + return len(gced), reconciled + }() + // Only a tick that changed the graph schedules reclamation. The + // process-wide quiet gate postpones it if another subsystem is busy. + if reconciled > 0 || gcedCount > 0 { releaseMemoryToOS(logger, "reconcile_janitor") } case <-stop: @@ -724,7 +726,12 @@ func startPeriodicSnapshots(g *graph.Graph, mi *indexer.MultiIndexer, version st logger.Debug("snapshot: skipped tick — daemon still warming up") continue } - saveSnapshot(g, collectSnapshotRepos(mi), collectSnapshotContracts(mi), collectSnapshotVector(mi), version, logger) + func() { + runtimeactivity.Begin("snapshot") + defer runtimeactivity.End("snapshot") + saveSnapshot(g, collectSnapshotRepos(mi), collectSnapshotContracts(mi), collectSnapshotVector(mi), version, logger) + }() + releaseMemoryToOS(logger, "periodic_snapshot") case <-stop: return } diff --git a/cmd/gortex/daemon_integration_test.go b/cmd/gortex/daemon_integration_test.go index 3fb5c161a..981877351 100644 --- a/cmd/gortex/daemon_integration_test.go +++ b/cmd/gortex/daemon_integration_test.go @@ -113,6 +113,8 @@ func TestDaemon_EndToEnd_GraphStatsOverMCPProxy(t *testing.T) { Mode: daemon.ModeMCP, CWD: trackedRoot, ClientName: "integration-test", + Tools: cliLegacyToolSurface, + ToolsMode: cliLegacyToolMode, }) require.NoError(t, err) defer client.Close() @@ -270,5 +272,22 @@ func TestDaemon_EndToEnd_TrackPersistsToConfig(t *testing.T) { "tracked repo must be persisted to the global config; got %v", foundPaths) } +func TestUntrackedBootstrapCallsAreNarrow(t *testing.T) { + capabilities := []byte(`{"jsonrpc":"2.0","method":"tools/call","params":{"name":"capabilities","arguments":{}}}`) + track := []byte(`{"jsonrpc":"2.0","method":"tools/call","params":{"name":"workspace_admin","arguments":{"operation":"track","arguments":{"path":"."}}}}`) + reindex := []byte(`{"jsonrpc":"2.0","method":"tools/call","params":{"name":"workspace_admin","arguments":{"operation":"reindex"}}}`) + read := []byte(`{"jsonrpc":"2.0","method":"tools/call","params":{"name":"read","arguments":{"operation":"file","target":{"file":"main.go"}}}}`) + require.True(t, untrackedBootstrapCall(capabilities)) + require.True(t, untrackedBootstrapCall(track)) + require.False(t, untrackedBootstrapCall(reindex)) + require.False(t, untrackedBootstrapCall(read)) + require.False(t, untrackedBootstrapCall([]byte(`not-json`))) +} + +func TestUntrackedToolsListPreservesFacadeSurface(t *testing.T) { + response := []byte(`{"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"capabilities"},{"name":"read"},{"name":"workspace_admin"}]}}`) + require.Equal(t, response, rewriteUntrackedResponse("tools/list", response, "/tmp/untracked", []string{"/tmp/tracked"})) +} + // silence unused-import noise when gofmt reorders during edits. var _ = fmt.Sprint diff --git a/cmd/gortex/daemon_mcp.go b/cmd/gortex/daemon_mcp.go index fdd8cdcc9..6ace2271d 100644 --- a/cmd/gortex/daemon_mcp.go +++ b/cmd/gortex/daemon_mcp.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "sort" + "strings" "sync" "sync/atomic" @@ -95,7 +96,7 @@ func (d *mcpDispatcher) Dispatch(ctx context.Context, sess *daemon.Session, fram if untracked { d.logUncoveredCWDOnce(sess) } - if untracked && peekFrameMethod(frame) == "tools/call" { + if untracked && peekFrameMethod(frame) == "tools/call" && !untrackedBootstrapCall(frame) { return d.notTrackedError(sess, frame), nil } @@ -140,14 +141,17 @@ func (d *mcpDispatcher) Dispatch(ctx context.Context, sess *daemon.Session, fram // tools_search promotes them. A direct call by name (the CLI's `gortex call` // and the curated `gortex` verbs reach the daemon this way) promotes it // first, so a known tool name is reachable without a discovery round-trip. - // tools_search stays the discovery path; a hide-mode tool is never deferred, - // so this never bypasses the hide gate. + // tools_search stays the discovery path. Check the effective session surface + // before touching the process-global lazy registry: otherwise a facade-v1 + // client hard-calling a hidden legacy name could promote it (and emit + // list_changed) before the MCP surface filter rejected the call. if name := peekFrameToolName(frame); name != "" { - newly := d.srv.EnsureToolPromoted(name) - // Record a call to a deferred/learned tool so the per-workspace - // learned surface promotes it into the cold list next session (and a - // stale promotion's demotion clock resets on continued use). - d.srv.NoteToolUse(name, sess.CWD, newly) + if d.srv.IsToolEnabledForSession(ctx, name) { + newly := d.srv.EnsureToolPromotedForSession(ctx, name) + // Record only permitted calls to deferred/learned tools so a rejected + // hidden call cannot refresh learned-surface state. + d.srv.NoteToolUse(name, sess.CWD, newly) + } } // HandleMessage returns either a JSONRPCResponse, a JSONRPCError, or @@ -199,12 +203,29 @@ func peekFrameToolName(frame []byte) string { return peek.Params.Name } -// rewriteUntrackedResponse swaps a successful initialize / tools/list response -// for its untracked-cwd variant: initialize carries the inactive instructions -// (with the cwd-specific `gortex track` affordance) and tools/list is emptied, -// so the handshake completes gracefully instead of erroring. Non-initialize / -// non-tools/list frames, error responses, and unparseable bodies pass through -// unchanged. +// untrackedBootstrapCall permits only the two facade calls that can explain or +// repair an uncovered cwd. Every graph-backed call remains fail-closed. +func untrackedBootstrapCall(frame []byte) bool { + var peek struct { + Method string `json:"method"` + Params struct { + Name string `json:"name"` + Arguments map[string]any `json:"arguments"` + } `json:"params"` + } + if json.Unmarshal(frame, &peek) != nil || peek.Method != "tools/call" { + return false + } + if peek.Params.Name == "capabilities" { + return true + } + return peek.Params.Name == "workspace_admin" && strings.EqualFold(strings.TrimSpace(fmt.Sprint(peek.Params.Arguments["operation"])), "track") +} + +// rewriteUntrackedResponse swaps a successful initialize response for its +// untracked-cwd variant. tools/list is deliberately preserved so facade clients +// can discover capabilities and workspace_admin.track without reconnecting; +// every other tools/call remains blocked by Dispatch until tracking succeeds. func rewriteUntrackedResponse(method string, out []byte, cwd string, roots []string) []byte { if len(out) == 0 { return out @@ -217,14 +238,10 @@ func rewriteUntrackedResponse(method string, out []byte, cwd string, roots []str if !ok { return out // an error response or non-object result — leave it } - switch method { - case "initialize": - result["instructions"] = gortexmcp.ServerInstructionsUntracked(cwd, roots...) - case "tools/list": - result["tools"] = []any{} - default: + if method != "initialize" { return out } + result["instructions"] = gortexmcp.ServerInstructionsUntracked(cwd, roots...) resp["result"] = result if rewritten, mErr := json.Marshal(resp); mErr == nil { return rewritten diff --git a/cmd/gortex/daemon_mcp_test.go b/cmd/gortex/daemon_mcp_test.go index 313d90142..b9fd3b428 100644 --- a/cmd/gortex/daemon_mcp_test.go +++ b/cmd/gortex/daemon_mcp_test.go @@ -120,6 +120,49 @@ func TestDispatcher_TrackedCWD_Passes(t *testing.T) { } } +func TestDispatcher_FacadeHiddenDeferredCallDoesNotPromote(t *testing.T) { + t.Setenv("GORTEX_LAZY_TOOLS", "1") + tracked := t.TempDir() + d, _ := trackedPathMCPSetup(t, tracked) + sess := &daemon.Session{ + ID: "sess_facade_no_promote", + CWD: tracked, + ToolSpec: "facade-v1", + } + + initFrame := []byte(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"codex","version":"1.0"}}}`) + initReply, err := d.Dispatch(context.Background(), sess, initFrame) + require.NoError(t, err) + require.NotNil(t, initReply) + + const hidden = "get_architecture" + _, liveBefore := d.srv.MCPServer().ListTools()[hidden] + require.False(t, liveBefore, "fixture must start with the legacy tool deferred") + + hiddenFrame := []byte(`{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get_architecture","arguments":{}}}`) + hiddenReply, err := d.Dispatch(context.Background(), sess, hiddenFrame) + require.NoError(t, err) + require.NotNil(t, hiddenReply) + var rejected struct { + Error any `json:"error"` + } + require.NoError(t, json.Unmarshal(hiddenReply, &rejected)) + require.NotNil(t, rejected.Error, "hidden legacy call must be rejected by the facade surface") + + _, liveAfter := d.srv.MCPServer().ListTools()[hidden] + require.False(t, liveAfter, + "dispatcher must not globally promote a hidden legacy tool before rejecting the call") + + // The supported facade route reaches the captured cold handler directly + // and likewise leaves the legacy schema deferred. + facadeFrame := []byte(`{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"analyze","arguments":{"kind":"architecture"}}}`) + facadeReply, err := d.Dispatch(context.Background(), sess, facadeFrame) + require.NoError(t, err) + require.NotNil(t, facadeReply) + _, liveAfterFacade := d.srv.MCPServer().ListTools()[hidden] + require.False(t, liveAfterFacade, "facade dispatch must not promote the captured legacy schema") +} + func TestDispatcher_SubdirectoryOfTrackedRoot_Passes(t *testing.T) { tracked := t.TempDir() // A nested path inside a tracked root also counts as tracked — an @@ -307,10 +350,11 @@ func TestDispatcher_UnreachableCWD_StillRejected(t *testing.T) { // F4 contract: an MCP session opened in a cwd that no tracked repo covers must // still complete its handshake. initialize flows through and the response // carries the inactive-instructions variant (telling the agent to run `gortex -// track `); tools/list answers an empty track-only list; only tools/call -// is refused with the structured repo_not_tracked error. This beats -// codegraph's silent empty-list — the agent gets an actionable affordance, not -// a dead connection. +// track `); tools/list preserves the stable facade so clients can discover +// capabilities and the track operation before a graph exists. Graph-dependent +// tools/call requests are still refused with the structured repo_not_tracked +// error. This beats a silent empty-list — the agent gets an actionable +// affordance, not a dead connection. func TestDispatcher_UntrackedHandshake_SurvivesWithInactiveVariant(t *testing.T) { tracked := t.TempDir() untracked := t.TempDir() // a sibling the dispatcher does not cover @@ -345,18 +389,28 @@ func TestDispatcher_UntrackedHandshake_SurvivesWithInactiveVariant(t *testing.T) "instructions must carry the cwd-specific track affordance") }) - t.Run("tools_list_returns_empty_track_only", func(t *testing.T) { + t.Run("tools_list_preserves_facade_surface", func(t *testing.T) { parsed := dispatch(t, `{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}`) result, ok := parsed["result"].(map[string]any) require.True(t, ok, "tools/list must return a result object: %v", parsed) tools, ok := result["tools"].([]any) require.True(t, ok, "tools/list result must carry a tools array") - assert.Empty(t, tools, "untracked tools/list must be an empty track-only list") + names := make(map[string]bool, len(tools)) + for _, raw := range tools { + tool, ok := raw.(map[string]any) + require.True(t, ok, "tool entry must be an object: %v", raw) + name, _ := tool["name"].(string) + names[name] = true + } + assert.True(t, names["capabilities"], "bootstrap discovery must remain visible") + assert.True(t, names["workspace_admin"], "workspace_admin.track must remain discoverable") + assert.True(t, names["explore"], "the stable facade must not drift when the cwd is untracked") + assert.False(t, names["graph_stats"], "legacy tools must remain hidden") }) t.Run("tools_call_still_refused", func(t *testing.T) { - parsed := dispatch(t, `{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"graph_stats","arguments":{}}}`) + parsed := dispatch(t, `{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"explore","arguments":{"task":"locate startup"}}}`) errObj, ok := parsed["error"].(map[string]any) require.True(t, ok, "untracked tools/call must still be refused: %v", parsed) diff --git a/cmd/gortex/daemon_memlimit.go b/cmd/gortex/daemon_memlimit.go index 7821472b9..7962546e1 100644 --- a/cmd/gortex/daemon_memlimit.go +++ b/cmd/gortex/daemon_memlimit.go @@ -4,14 +4,13 @@ import ( "fmt" "math" "os" - "runtime" "runtime/debug" "strconv" "strings" - "time" "go.uber.org/zap" + gortexmcp "github.com/zzet/gortex/internal/mcp" "github.com/zzet/gortex/internal/platform" ) @@ -186,14 +185,12 @@ func parseByteSize(s string) (int64, error) { // memory limit. Call once at boot, after logging and config are up and // before warmup starts allocating. // -// Composition with the cold-index window (internal/indexer/gc_tune.go): a -// cold index briefly raises the limit to a larger budget (RAM/2) and, on -// exit, restores the value it captured via debug.SetMemoryLimit(-1) — which -// is exactly the standing limit installed here. Installing this before any -// index runs is what makes that restore land on our value rather than on -// "no limit". The two are therefore composable: the daemon holds a modest -// standing ceiling, cold indexes get their wider one-shot budget, and the -// standing ceiling comes back afterward untouched. +// Composition with the cold-index window (internal/indexer/gc_tune.go): the +// indexer captures this limit and may install a tighter host/cgroup-derived +// budget, but it never raises an already-lower standing limit. On exit it +// restores the exact captured value. Installing this before any index runs +// therefore keeps both cold and steady-state allocation inside the daemon's +// configured envelope. func applyStandingMemoryLimit(logger *zap.Logger, cfgVal string) { d := resolveStandingMemoryLimit( platform.HostPhysicalMemoryBytes(), @@ -254,26 +251,9 @@ func releaseMemoryToOS(logger *zap.Logger, reason string) { if !memReleaseEnabled() { return } - var before, after runtime.MemStats - runtime.ReadMemStats(&before) - start := time.Now() - debug.FreeOSMemory() - elapsed := time.Since(start) - runtime.ReadMemStats(&after) - // Concurrent allocation between the two reads can re-acquire released - // pages faster than this call released them, making the raw delta - // negative; report that as zero net release rather than a nonsense - // negative byte count. - freed := int64(after.HeapReleased) - int64(before.HeapReleased) - if freed < 0 { - freed = 0 - } - if logger != nil { - logger.Info("daemon: released heap to OS", - zap.String("reason", reason), - zap.Duration("elapsed", elapsed), - zap.Int64("freed_bytes", freed), - zap.Uint64("heap_sys_bytes", after.HeapSys), - zap.Uint64("heap_released_bytes", after.HeapReleased)) - } + // MCP calls and daemon background work share one process, activity gate, + // quiet window, cooldown, and scheduler. Scheduling here avoids a + // stop-the-world GC on the warmup/janitor goroutine and guarantees that a + // new tool call or another background job postpones reclamation. + gortexmcp.ScheduleMemoryReleaseAfterBurst(logger, reason) } diff --git a/cmd/gortex/daemon_state.go b/cmd/gortex/daemon_state.go index 46a5ba17e..8be54e123 100644 --- a/cmd/gortex/daemon_state.go +++ b/cmd/gortex/daemon_state.go @@ -204,8 +204,10 @@ func buildDaemonState(logger *zap.Logger) (*daemonState, error) { type warmupTimings struct { parse time.Duration resolve time.Duration + enrich time.Duration globalResolve time.Duration endBatch time.Duration + analysis time.Duration // reposChanged is the number of tracked repos whose reindex actually did // work this warmup (cold track, or a reconcile with stale/deleted files). reposChanged int @@ -675,6 +677,7 @@ func warmupDaemonState(state *daemonState, logger *zap.Logger, markReady func()) phaseStart = time.Now() publishReadinessPhase(state, "deferred_passes_all", true, nil) timings.enrichScheduled = state.multiIndexer.RunDeferredPassesAll(ctx) + timings.enrich = time.Since(phaseStart) logger.Info("daemon: warmup phase done", zap.String("phase", "deferred_passes_all"), zap.Duration("elapsed", time.Since(phaseStart))) @@ -834,6 +837,17 @@ func warmupDaemonState(state *daemonState, logger *zap.Logger, markReady func()) logger.Warn("daemon: multi-watcher start failed", zap.Error(err)) return nil, timings } + // Attach the live watcher before publishing watcher_started. This makes + // readiness truthful: once clients observe the phase, mutation handlers + // can already enqueue path-scoped work instead of falling back to a costly + // synchronous IndexFile call. + if state.mcpServer != nil { + state.mcpServer.SetWatcher(mw) + srv := state.mcpServer + mw.OnDegraded(func(reason string) { + srv.PublishReadiness("degraded", true, map[string]any{"watch_degraded": reason}) + }) + } logger.Info("daemon: watching", zap.Int("repos", len(watchCfgs))) publishReadinessPhase(state, "watcher_started", true, map[string]any{ "watched_repos": len(watchCfgs), @@ -855,8 +869,10 @@ func logWarmupSummary(logger *zap.Logger, warmup *warmupTimings, queryable, tota logger.Info("daemon: warmup summary", zap.Float64("parse_s", warmup.parse.Seconds()), zap.Float64("resolve_s", warmup.resolve.Seconds()), + zap.Float64("enrich_s", warmup.enrich.Seconds()), zap.Float64("global_resolve_s", warmup.globalResolve.Seconds()), zap.Float64("end_batch_s", warmup.endBatch.Seconds()), + zap.Float64("analysis_s", warmup.analysis.Seconds()), zap.Float64("queryable_s", queryable.Seconds()), zap.Int("repos_changed", warmup.reposChanged), zap.Int("files_reindexed", warmup.filesReindexed), diff --git a/cmd/gortex/executor_daemonfirst_test.go b/cmd/gortex/executor_daemonfirst_test.go index ebc25a7c5..898115c38 100644 --- a/cmd/gortex/executor_daemonfirst_test.go +++ b/cmd/gortex/executor_daemonfirst_test.go @@ -26,11 +26,12 @@ type stubDaemonServer struct { ln net.Listener trackedRepos []string - mu sync.Mutex - lastTool string - lastArgs map[string]any - mcpResult json.RawMessage // raw result payload echoed in the content block - mcpError *stubRPCError // when set, the MCP reply is a JSON-RPC error + mu sync.Mutex + lastTool string + lastArgs map[string]any + lastMCPHandshake daemon.Handshake + mcpResult json.RawMessage // raw result payload echoed in the content block + mcpError *stubRPCError // when set, the MCP reply is a JSON-RPC error } type stubRPCError struct { @@ -89,6 +90,11 @@ func (s *stubDaemonServer) handleConn(conn net.Conn) { if err := json.Unmarshal(hsLine, &hs); err != nil { return } + if hs.Mode == daemon.ModeMCP { + s.mu.Lock() + s.lastMCPHandshake = hs + s.mu.Unlock() + } if err := daemon.WriteJSONLine(conn, daemon.HandshakeAck{OK: true, DaemonVersion: "stub"}); err != nil { return } @@ -181,6 +187,12 @@ func (s *stubDaemonServer) seenTool() (string, map[string]any) { return s.lastTool, s.lastArgs } +func (s *stubDaemonServer) seenMCPHandshake() daemon.Handshake { + s.mu.Lock() + defer s.mu.Unlock() + return s.lastMCPHandshake +} + // TestResolveExecutor_DaemonFirst asserts resolveExecutor is daemon-first: // when a warm daemon owns the repo it returns a daemonExecutor that relays // the tool call over the daemon's MCP channel (the same warm graph the @@ -224,6 +236,11 @@ func TestResolveExecutor_DaemonFirst(t *testing.T) { if args["x"] != "y" { t.Fatalf("caller args must be relayed, daemon saw %v", args) } + hs := stub.seenMCPHandshake() + if hs.Tools != cliLegacyToolSurface || hs.ToolsMode != cliLegacyToolMode { + t.Fatalf("legacy CLI handshake = tools:%q mode:%q, want %q/%q", + hs.Tools, hs.ToolsMode, cliLegacyToolSurface, cliLegacyToolMode) + } }) t.Run("no daemon reachable -> ErrNoExecutor", func(t *testing.T) { @@ -245,6 +262,27 @@ func TestResolveExecutor_DaemonFirst(t *testing.T) { }) } +func TestCallFacadeDaemonTool_SurfaceIsConnectionScoped(t *testing.T) { + repo := t.TempDir() + stub := startStubDaemon(t, []string{repo}) + + if _, err := callFacadeDaemonTool(repo, "read", map[string]any{ + "operation": "file", + "target": map[string]any{"file": "main.go"}, + }); err != nil { + t.Fatalf("facade relay: %v", err) + } + + hs := stub.seenMCPHandshake() + if hs.ClientName != "cli" || hs.Tools != "facade-v1" || hs.ToolsMode != "hide" { + t.Fatalf("facade handshake = client:%q tools:%q mode:%q", hs.ClientName, hs.Tools, hs.ToolsMode) + } + tool, _ := stub.seenTool() + if tool != "read" { + t.Fatalf("daemon saw tool %q, want read", tool) + } +} + // TestDaemonExecutor_ErrDistinct asserts the daemonExecutor relay // distinguishes the daemon's typed refusals (ErrRepoNotTracked) and the // daemon-unavailable case (ErrNoExecutor) from a genuine tool error, which diff --git a/cmd/gortex/hook.go b/cmd/gortex/hook.go index 64e69f545..a8ef700f1 100644 --- a/cmd/gortex/hook.go +++ b/cmd/gortex/hook.go @@ -35,10 +35,9 @@ var hookCmd = &cobra.Command{ hooks.RunPi(hookPort, hooks.ParseMode(hookMode)) return case "codex": - // Codex support is intentionally soft-only: the adapter installs - // Bash PreToolUse/PostToolUse plus a UserPromptSubmit hook that emit - // additionalContext without ever denying the tool call. - hooks.RunCodex(hookPort) + // Codex defaults to advisory context. Explicit postures add hard + // deny, conservative input rewrite, or PostToolUse result replacement. + hooks.RunCodex(hookPort, hooks.ParseCodexMode(hookMode)) return case "kimi": // Kimi Code CLI: UserPromptSubmit / PreToolUse / Stop / @@ -59,9 +58,9 @@ var hookCmd = &cobra.Command{ func init() { hookCmd.Flags().IntVar(&hookPort, "port", 8765, "Gortex web server port") - hookCmd.Flags().StringVar(&hookMode, "mode", "deny", - "hook posture: 'deny' (redirect Grep/Glob/Read of indexed source), 'enrich' (never deny; PostToolUse appends graph context), 'consult-unlock' (deny fallback reads until the graph is queried once this session), or 'nudge' (soft-deny once per burst of non-symbolic calls)") + hookCmd.Flags().StringVar(&hookMode, "mode", "", + "hook posture: Claude defaults to deny and accepts deny|enrich|consult-unlock|nudge; Codex defaults to enrich and accepts enrich|deny|rewrite|suppress") hookCmd.Flags().StringVar(&hookAgent, "agent", "", - "hook wire protocol: empty/'claude' (Claude Code PreToolUse/UserPromptSubmit), 'codex' (Codex Bash PreToolUse/PostToolUse + UserPromptSubmit soft context), 'kimi' (Kimi Code CLI UserPromptSubmit/PreToolUse/Stop/SubagentStart; plain-stdout context, permissionDecision deny for indexed reads), 'hermes' (NousResearch hermes-agent pre_tool_call/pre_llm_call), 'pi' (earendil-works/pi extension bridge — normalized PiEvent envelope in, PiDecision out), or 'gemini'/'antigravity' (emits hookSpecificOutput.additionalContext). Default (empty) is the Claude Code format.") + "hook wire protocol: empty/'claude' (Claude Code lifecycle hooks), 'codex' (Codex Bash/MCP/apply_patch hooks), 'kimi' (Kimi Code CLI hooks), 'hermes' (NousResearch hermes-agent hooks), 'pi' (Pi extension bridge), or 'gemini'/'antigravity'. Default (empty) is Claude Code.") rootCmd.AddCommand(hookCmd) } diff --git a/cmd/gortex/init.go b/cmd/gortex/init.go index a94b8778d..e24a595b2 100644 --- a/cmd/gortex/init.go +++ b/cmd/gortex/init.go @@ -480,7 +480,7 @@ func emitJSONReport(w io.Writer, results []*agents.Result, opts agents.ApplyOpts // emitHumanSummary prints the per-agent file counts to stderr. func emitHumanSummary(w io.Writer, results []*agents.Result, opts agents.ApplyOpts) { emitAgentSummary(w, results, opts, []string{ - "if your editor uses MCP, enable the gortex server there (and reload the window) when tools do not appear after the first init", + "if this first init ran inside an active editor/agent session, start a new session so the host loads the generated MCP config; no separate MCP enablement is normally required, though host trust or admin policy may still require approval", "commit the generated files your team relies on (.mcp.json, .claude/, .cursor/, CLAUDE.md, and other adapter outputs)", "run `gortex install` once per machine to wire user-level integration", }) diff --git a/cmd/gortex/init_integration_test.go b/cmd/gortex/init_integration_test.go index 479b79363..8fa4d59cc 100644 --- a/cmd/gortex/init_integration_test.go +++ b/cmd/gortex/init_integration_test.go @@ -9,8 +9,23 @@ import ( "testing" toml "github.com/pelletier/go-toml/v2" + + "github.com/zzet/gortex/internal/agents" ) +func TestInitSummaryDoesNotRequireManualMCPEnablement(t *testing.T) { + var out bytes.Buffer + emitHumanSummary(&out, nil, agents.ApplyOpts{}) + + got := out.String() + if !strings.Contains(got, "no separate MCP enablement is normally required") { + t.Fatalf("init summary must describe automatic MCP registration:\n%s", got) + } + if strings.Contains(got, "enable the gortex server") { + t.Fatalf("init summary still asks for manual MCP enablement:\n%s", got) + } +} + // TestInitDryRunJSONReportShape is the end-to-end contract test for // the non-interactive init flags. It runs runInit with --yes // --dry-run --json --agents=claude-code against a clean temp dir diff --git a/cmd/gortex/proxy.go b/cmd/gortex/proxy.go index aa01810f7..97424173c 100644 --- a/cmd/gortex/proxy.go +++ b/cmd/gortex/proxy.go @@ -1,15 +1,14 @@ package main import ( - "bufio" "context" + "crypto/rand" + "encoding/hex" "encoding/json" "errors" "fmt" - "io" "os" "path/filepath" - "sync" "time" "github.com/zzet/gortex/internal/daemon" @@ -98,20 +97,19 @@ func runProxy(ctx context.Context, surface *gortexmcp.ToolSurface) (ran bool, er } toolSpec, toolMode := clientToolPreference() h := daemon.Handshake{ - Mode: daemon.ModeMCP, - CWD: cwd, - ClientName: detectClientName(), - Tools: toolSpec, - ToolsMode: toolMode, + Mode: daemon.ModeMCP, + CWD: cwd, + ClientName: detectClientName(), + PID: os.Getpid(), + LogicalSessionID: newProxyLogicalSessionID(), + Tools: toolSpec, + ToolsMode: toolMode, } client, recoverable, err := dialDaemonWithRetry(ctx, h) if err != nil && !recoverable { return false, fmt.Errorf("dial daemon: %w", err) } if client == nil { - // The daemon isn't reachable (even after the retry window) or it's - // running a mismatched protocol version — both are recoverable by - // falling back to the embedded in-process server. if errors.Is(err, daemon.ErrProtocolVersionMismatch) { fmt.Fprintln(os.Stderr, "[gortex mcp] daemon protocol mismatch; falling back to embedded server") } else { @@ -119,58 +117,12 @@ func runProxy(ctx context.Context, surface *gortexmcp.ToolSurface) (ran bool, er } return false, nil } - defer client.Close() - // A daemon that is still warming up acks the handshake immediately and - // serves whatever the graph holds so far, filling in as warmup completes — - // so staying connected is strictly better than dead-ending on an empty - // embedded server. Surface the state so the launch log isn't misleading. - if client.Ack.Warming { - fmt.Fprintf(os.Stderr, - "[gortex mcp] proxying to daemon (session %s, daemon warming up — phase %q; graph still filling)\n", - client.Ack.SessionID, client.Ack.WarmupPhase) - } else { - fmt.Fprintf(os.Stderr, - "[gortex mcp] proxying to daemon (session %s, default_repo=%q)\n", - client.Ack.SessionID, client.Ack.DefaultRepo) - } - - // Bidirectional pump: - // stdin → socket (MCP requests from the client) - // socket → stdout (MCP responses + notifications) - // - // We run both on goroutines and exit when either side hits EOF. - errCh := make(chan error, 2) - var wg sync.WaitGroup - var outMu sync.Mutex // serialises the two writers into os.Stdout - wg.Add(2) - - if surface.Active() { + logProxyConnection(os.Stderr, client, false) + if surface != nil && surface.Active() { fmt.Fprintf(os.Stderr, "[gortex mcp] tool surface restricted (preset %q)\n", surface.Preset()) - go func() { - defer wg.Done() - errCh <- pumpRequestsFiltered(os.Stdin, client.Conn, os.Stdout, &outMu, surface) - }() - go func() { - defer wg.Done() - errCh <- pumpResponsesFiltered(client.Conn, os.Stdout, &outMu, surface) - }() - } else { - go func() { - defer wg.Done() - errCh <- pumpLines(os.Stdin, client.Conn) - }() - go func() { - defer wg.Done() - errCh <- pumpLines(client.Conn, os.Stdout) - }() } - // Orphan watchdog: if our parent (the MCP client) dies, stdin EOF is the - // normal shutdown signal — but a client that is SIGKILLed, or whose stdin - // pipe is inherited and held open elsewhere, can leave this proxy wedged - // forever, pinning a daemon session. Poll the parent PID and unblock the - // select when we get reparented (to init or a subreaper). orphanCh := make(chan struct{}, 1) watchCtx, cancelWatch := context.WithCancel(ctx) defer cancelWatch() @@ -182,35 +134,25 @@ func runProxy(ctx context.Context, surface *gortexmcp.ToolSurface) (ran bool, er } }) - // Wait for first completion; exit on context cancellation or orphaning too. - select { - case pumpErr := <-errCh: - if pumpErr != nil && !errors.Is(pumpErr, io.EOF) { - return true, fmt.Errorf("proxy pump: %w", pumpErr) - } - case <-orphanCh: - case <-ctx.Done(): - } - cancelWatch() - _ = client.Close() - // Bound the drain: a pump blocked reading a never-closing stdin (the exact - // orphan case) must not pin shutdown — the process is exiting regardless. - drained := make(chan struct{}) - go func() { wg.Wait(); close(drained) }() - select { - case <-drained: - case <-time.After(proxyDrainTimeout): + if err := relayProxySession(ctx, h, client, os.Stdin, os.Stdout, os.Stderr, surface, orphanCh); err != nil { + return true, fmt.Errorf("proxy relay: %w", err) } return true, nil } +func newProxyLogicalSessionID() string { + var raw [16]byte + if _, err := rand.Read(raw[:]); err == nil { + return hex.EncodeToString(raw[:]) + } + // crypto/rand failure should not make MCP unavailable. PID + monotonic + // wall-clock entropy is sufficient for the user-local daemon namespace. + return fmt.Sprintf("proxy-%d-%d", os.Getpid(), time.Now().UnixNano()) +} + // orphanPollInterval is how often the proxy checks whether its parent -// process is still alive; proxyDrainTimeout bounds the post-close drain. -// Both are vars so tests can shorten them. -var ( - orphanPollInterval = 5 * time.Second - proxyDrainTimeout = 2 * time.Second -) +// process is still alive. It is a var so tests can shorten it. +var orphanPollInterval = 5 * time.Second // dialDaemon is the seam runProxy dials through. A package var so tests can // substitute a fake without a real socket. @@ -295,27 +237,6 @@ func orphanWatch(ctx context.Context, interval time.Duration, getppid func() int } } -// pumpLines copies newline-delimited frames from src to dst. Uses a -// line-aware scanner so partial reads don't split a single MCP message -// between two writes (which would confuse the peer's parser). -func pumpLines(src io.Reader, dst io.Writer) error { - r := bufio.NewReaderSize(src, 1<<20) // 1 MB — some MCP replies are chunky - for { - line, err := r.ReadBytes('\n') - if len(line) > 0 { - if _, werr := dst.Write(line); werr != nil { - return werr - } - } - if err != nil { - if errors.Is(err, io.EOF) { - return nil - } - return err - } - } -} - // detectClientName makes a best-effort guess at which MCP client spawned // us. Purely for the initial handshake telemetry — the authoritative // answer comes from the MCP `initialize` request's clientInfo, which diff --git a/cmd/gortex/proxy_filter.go b/cmd/gortex/proxy_filter.go index d9a1ab53e..ce30a116d 100644 --- a/cmd/gortex/proxy_filter.go +++ b/cmd/gortex/proxy_filter.go @@ -1,14 +1,10 @@ package main import ( - "bufio" "encoding/json" - "errors" - "io" "os" "strconv" "strings" - "sync" gortexmcp "github.com/zzet/gortex/internal/mcp" ) @@ -159,54 +155,3 @@ func filterToolsListFrame(line []byte, surface *gortexmcp.ToolSurface) []byte { } return append(out, '\n') } - -// pumpRequestsFiltered relays client→daemon frames, gating disallowed -// tools/call frames with a synthesized error reply written to clientOut. -func pumpRequestsFiltered(src io.Reader, daemonW, clientOut io.Writer, outMu *sync.Mutex, surface *gortexmcp.ToolSurface) error { - r := bufio.NewReaderSize(src, 1<<20) - for { - line, err := r.ReadBytes('\n') - if len(line) > 0 { - if reply, gated := gateToolCallFrame(line, surface); gated { - outMu.Lock() - _, werr := clientOut.Write(reply) - outMu.Unlock() - if werr != nil { - return werr - } - } else if _, werr := daemonW.Write(line); werr != nil { - return werr - } - } - if err != nil { - if errors.Is(err, io.EOF) { - return nil - } - return err - } - } -} - -// pumpResponsesFiltered relays daemon→client frames, filtering tools/list -// results down to the surface. -func pumpResponsesFiltered(src io.Reader, clientOut io.Writer, outMu *sync.Mutex, surface *gortexmcp.ToolSurface) error { - r := bufio.NewReaderSize(src, 1<<20) - for { - line, err := r.ReadBytes('\n') - if len(line) > 0 { - out := filterToolsListFrame(line, surface) - outMu.Lock() - _, werr := clientOut.Write(out) - outMu.Unlock() - if werr != nil { - return werr - } - } - if err != nil { - if errors.Is(err, io.EOF) { - return nil - } - return err - } - } -} diff --git a/cmd/gortex/proxy_relay.go b/cmd/gortex/proxy_relay.go new file mode 100644 index 000000000..3705aedd7 --- /dev/null +++ b/cmd/gortex/proxy_relay.go @@ -0,0 +1,644 @@ +package main + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "time" + + "github.com/zzet/gortex/internal/daemon" + gortexmcp "github.com/zzet/gortex/internal/mcp" +) + +const ( + proxyDaemonUnavailableCode = -32098 + proxySessionResetCode = -32097 +) + +var proxyReinitializeTimeout = 5 * time.Second + +type proxyFrameEvent struct { + frame []byte + err error +} + +type proxyPendingRequest struct { + id json.RawMessage + key string + frame []byte + idempotent bool + definitelyNotDelivered bool + replayed bool +} + +type proxyRelayState struct { + ctx context.Context + handshake daemon.Handshake + stdout io.Writer + stderr io.Writer + surface *gortexmcp.ToolSurface + + client *daemon.Client + responses <-chan proxyFrameEvent + pending map[string]*proxyPendingRequest + daemonInstance string + initialize []byte + initialized []byte +} + +func relayProxySession( + ctx context.Context, + h daemon.Handshake, + initial *daemon.Client, + stdin io.Reader, + stdout, stderr io.Writer, + surface *gortexmcp.ToolSurface, + orphan <-chan struct{}, +) error { + relayCtx, cancel := context.WithCancel(ctx) + defer cancel() + + state := &proxyRelayState{ + ctx: relayCtx, + handshake: h, + stdout: stdout, + stderr: stderr, + surface: surface, + pending: make(map[string]*proxyPendingRequest), + } + state.attach(initial) + if initial != nil { + state.daemonInstance = initial.Ack.DaemonInstance + } + defer state.closeClient() + + inbound := streamProxyFrames(relayCtx, stdin) + for { + select { + case event, ok := <-inbound: + if !ok { + return nil + } + if event.err != nil { + if errors.Is(event.err, io.EOF) { + return nil + } + return event.err + } + if state.surface != nil && state.surface.Active() { + if reply, gated := gateToolCallFrame(event.frame, state.surface); gated { + if _, err := writeProxyFrame(state.stdout, reply); err != nil { + return err + } + continue + } + } + + if state.client == nil { + preserved, err := state.connect() + if err != nil { + if state.ctx.Err() != nil { + return nil + } + if err := state.replyUnavailable(event.frame, err); err != nil { + return err + } + continue + } + if !preserved { + if err := state.replySessionReset(event.frame); err != nil { + return err + } + continue + } + } + + state.noteProtocolFrame(event.frame) + pending, hasID := pendingRequest(event.frame) + if hasID { + state.pending[pending.key] = pending + } + n, writeErr := writeProxyFrame(state.client.Conn, event.frame) + if writeErr == nil { + continue + } + if hasID && n == 0 { + // No byte reached the old socket. Mutations are still never + // replayed automatically, but the error may tell the host that an + // explicit retry is safe. + pending.definitelyNotDelivered = true + } + if err := state.recover(writeErr); err != nil { + return err + } + + case event, ok := <-state.responses: + if !ok { + if state.client != nil { + if err := state.recover(io.EOF); err != nil { + return err + } + } + continue + } + if event.err != nil { + if err := state.recover(event.err); err != nil { + return err + } + continue + } + if key, ok := responseIDKey(event.frame); ok { + delete(state.pending, key) + } + out := event.frame + if state.surface != nil && state.surface.Active() { + out = filterToolsListFrame(out, state.surface) + } + if _, err := writeProxyFrame(state.stdout, out); err != nil { + return err + } + + case <-orphan: + return nil + case <-ctx.Done(): + return nil + } + } +} + +func (s *proxyRelayState) attach(client *daemon.Client) { + s.client = client + if client == nil || client.Conn == nil { + s.responses = nil + return + } + s.responses = streamProxyFrames(s.ctx, client.Conn) +} + +func (s *proxyRelayState) connect() (bool, error) { + client, _, err := dialDaemonWithRetry(s.ctx, s.handshake) + if client == nil { + if err == nil { + err = daemon.ErrDaemonUnavailable + } + return false, err + } + + previousInstance := s.daemonInstance + preserved := previousInstance != "" && + client.Ack.DaemonInstance == previousInstance && + s.handshake.LogicalSessionID != "" && + client.Ack.SessionID == s.handshake.LogicalSessionID + if !preserved { + if err := s.restoreProtocolState(client); err != nil { + _ = client.Close() + return false, fmt.Errorf("restore MCP protocol state: %w", err) + } + } + + s.attach(client) + s.daemonInstance = client.Ack.DaemonInstance + logProxyConnection(s.stderr, client, true) + if !preserved { + if err := s.notifySessionReset(previousInstance, client.Ack.DaemonInstance); err != nil { + s.closeClient() + return false, err + } + } + return preserved, nil +} + +func (s *proxyRelayState) closeClient() { + if s.client != nil { + _ = s.client.Close() + } + s.client = nil + s.responses = nil +} + +func (s *proxyRelayState) recover(cause error) error { + s.closeClient() + if s.stderr != nil { + fmt.Fprintf(s.stderr, "[gortex mcp] daemon connection lost (%v); keeping MCP stdio active\n", cause) + } + + retry := make([]*proxyPendingRequest, 0, len(s.pending)) + for key, pending := range s.pending { + if pending.idempotent && !pending.replayed { + retry = append(retry, pending) + continue + } + retryable := pending.idempotent || pending.definitelyNotDelivered + deliveryUnknown := !pending.definitelyNotDelivered + if err := s.writeUnavailable(pending.id, cause, retryable, deliveryUnknown); err != nil { + return err + } + delete(s.pending, key) + } + if len(retry) == 0 { + return nil + } + + preserved, err := s.connect() + if err != nil { + for _, pending := range retry { + if writeErr := s.writeUnavailable(pending.id, err, true, !pending.definitelyNotDelivered); writeErr != nil { + return writeErr + } + delete(s.pending, pending.key) + } + return nil + } + if !preserved { + for _, pending := range retry { + if writeErr := s.writeSessionReset(pending.id); writeErr != nil { + return writeErr + } + delete(s.pending, pending.key) + } + return nil + } + + for _, pending := range retry { + pending.replayed = true + if _, err := writeProxyFrame(s.client.Conn, pending.frame); err != nil { + s.closeClient() + for _, failed := range retry { + if writeErr := s.writeUnavailable(failed.id, err, true, true); writeErr != nil { + return writeErr + } + delete(s.pending, failed.key) + } + return nil + } + } + return nil +} + +func (s *proxyRelayState) noteProtocolFrame(frame []byte) { + var envelope struct { + Method string `json:"method"` + } + if json.Unmarshal(frame, &envelope) != nil { + return + } + switch envelope.Method { + case "initialize": + s.initialize = append(s.initialize[:0], frame...) + case "notifications/initialized": + s.initialized = append(s.initialized[:0], frame...) + } +} + +func (s *proxyRelayState) restoreProtocolState(client *daemon.Client) (err error) { + if len(s.initialize) == 0 { + return nil + } + frame, responseKey, err := reconnectInitializeFrame(s.initialize) + if err != nil { + return err + } + if client.Conn != nil { + if deadlineErr := client.Conn.SetDeadline(time.Now().Add(proxyReinitializeTimeout)); deadlineErr != nil { + return fmt.Errorf("set protocol restore deadline: %w", deadlineErr) + } + defer func() { + if clearErr := client.Conn.SetDeadline(time.Time{}); clearErr != nil && err == nil { + err = fmt.Errorf("clear protocol restore deadline: %w", clearErr) + } + }() + } + if err := client.WriteMCPFrame(frame); err != nil { + return err + } + var response []byte + for { + response, err = client.ReadMCPFrame() + if err != nil { + return err + } + gotKey, ok := responseIDKey(response) + if ok && gotKey == responseKey { + break + } + // MCP notifications and server-initiated requests may legally race the + // initialize response. Preserve their ordering on the host transport + // instead of treating the first unrelated frame as a reconnect failure. + if _, writeErr := writeProxyFrame(s.stdout, response); writeErr != nil { + return fmt.Errorf("forward protocol-restore frame: %w", writeErr) + } + } + var envelope struct { + Error json.RawMessage `json:"error"` + } + if json.Unmarshal(response, &envelope) != nil { + return fmt.Errorf("invalid initialize response") + } + if len(envelope.Error) > 0 && string(envelope.Error) != "null" { + return fmt.Errorf("initialize rejected: %s", envelope.Error) + } + initialized := bytes.TrimSpace(s.initialized) + if len(initialized) == 0 { + initialized = []byte(`{"jsonrpc":"2.0","method":"notifications/initialized"}`) + } + return client.WriteMCPFrame(initialized) +} + +func reconnectInitializeFrame(frame []byte) ([]byte, string, error) { + var envelope map[string]json.RawMessage + if err := json.Unmarshal(frame, &envelope); err != nil { + return nil, "", err + } + id, err := json.Marshal("gortex-reconnect-initialize") + if err != nil { + return nil, "", err + } + envelope["id"] = id + out, err := json.Marshal(envelope) + if err != nil { + return nil, "", err + } + key, ok := canonicalJSONRPCID(id) + if !ok { + return nil, "", fmt.Errorf("invalid reconnect initialize id") + } + return out, key, nil +} + +func (s *proxyRelayState) notifySessionReset(previous, current string) error { + message := map[string]any{ + "jsonrpc": "2.0", + "method": "notifications/message", + "params": map[string]any{ + "level": "warning", + "logger": "gortex", + "data": map[string]any{ + "code": "gortex_session_reset", + "message": "Gortex daemon restarted; daemon-owned MCP session state was reset and protocol initialization was restored. Re-orient before continuing.", + "previous_daemon_instance": previous, + "daemon_instance": current, + "logical_session_id": s.handshake.LogicalSessionID, + }, + }, + } + frame, err := json.Marshal(message) + if err != nil { + return err + } + if _, err := writeProxyFrame(s.stdout, append(frame, '\n')); err != nil { + return err + } + _, err = writeProxyFrame(s.stdout, []byte("{\"jsonrpc\":\"2.0\",\"method\":\"notifications/tools/list_changed\"}\n")) + return err +} + +func (s *proxyRelayState) replySessionReset(frame []byte) error { + pending, ok := pendingRequest(frame) + if !ok { + return nil + } + return s.writeSessionReset(pending.id) +} + +func (s *proxyRelayState) writeSessionReset(id json.RawMessage) error { + reply, err := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": id, + "error": map[string]any{ + "code": proxySessionResetCode, + "message": "Gortex daemon restarted and daemon-owned MCP session state was reset. The request was not executed; re-orient and retry.", + "data": map[string]any{ + "retryable": true, + "session_reset": true, + "required_action": "reorient_then_retry", + }, + }, + }) + if err != nil { + return err + } + _, err = writeProxyFrame(s.stdout, append(reply, '\n')) + return err +} + +func (s *proxyRelayState) replyUnavailable(frame []byte, cause error) error { + pending, ok := pendingRequest(frame) + if !ok { + // JSON-RPC notifications never receive responses. Dropping one while + // disconnected is preferable to inventing protocol traffic or + // replaying an operation whose delivery is unknown. + if s.stderr != nil { + fmt.Fprintf(s.stderr, "[gortex mcp] daemon unavailable; notification not forwarded: %v\n", cause) + } + return nil + } + return s.writeUnavailable(pending.id, cause, true, false) +} + +func (s *proxyRelayState) writeUnavailable(id json.RawMessage, cause error, retryable, deliveryUnknown bool) error { + message := "Gortex daemon is unavailable; the MCP transport remains active." + if deliveryUnknown && !retryable { + message += " Request delivery is unknown; do not retry automatically. Inspect mutation state first." + } else if retryable { + message += " Retry after the daemon recovers." + } + data := map[string]any{ + "retryable": retryable, + "delivery_unknown": deliveryUnknown, + } + if cause != nil { + data["cause"] = cause.Error() + } + reply, err := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": id, + "error": map[string]any{ + "code": proxyDaemonUnavailableCode, + "message": message, + "data": data, + }, + }) + if err != nil { + return err + } + _, err = writeProxyFrame(s.stdout, append(reply, '\n')) + return err +} + +func pendingRequest(frame []byte) (*proxyPendingRequest, bool) { + var envelope struct { + Method string `json:"method"` + ID json.RawMessage `json:"id"` + } + if json.Unmarshal(frame, &envelope) != nil || envelope.Method == "" || missingJSONRPCID(envelope.ID) { + return nil, false + } + key, ok := canonicalJSONRPCID(envelope.ID) + if !ok { + return nil, false + } + idempotent := retryableProxyRequest(frame) + return &proxyPendingRequest{ + id: append(json.RawMessage(nil), envelope.ID...), + key: key, + frame: append([]byte(nil), frame...), + idempotent: idempotent, + }, true +} + +func responseIDKey(frame []byte) (string, bool) { + var envelope struct { + Method string `json:"method"` + ID json.RawMessage `json:"id"` + } + if json.Unmarshal(frame, &envelope) != nil || envelope.Method != "" || missingJSONRPCID(envelope.ID) { + return "", false + } + return canonicalJSONRPCID(envelope.ID) +} + +func missingJSONRPCID(id json.RawMessage) bool { + // RawMessage is nil only when the object field was absent. Explicit null + // has non-zero raw bytes and is a request ID that must be echoed verbatim. + return len(id) == 0 +} + +func canonicalJSONRPCID(id json.RawMessage) (string, bool) { + raw := bytes.TrimSpace(id) + if len(raw) == 0 || !json.Valid(raw) { + return "", false + } + if bytes.Equal(raw, []byte("null")) { + return "null", true + } + if raw[0] == '"' { + // Canonicalize string escapes while keeping string and numeric ID + // namespaces disjoint ("1" must never correlate with 1). + var value string + if json.Unmarshal(raw, &value) != nil { + return "", false + } + canonical, err := json.Marshal(value) + if err != nil { + return "", false + } + return "s:" + string(canonical), true + } + + // Decode with UseNumber so integers above 2^53 retain every digit. Using + // interface{} + json.Unmarshal here would round through float64 and could + // collapse adjacent request IDs into one pending-map entry. + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + var value any + if decoder.Decode(&value) != nil { + return "", false + } + number, ok := value.(json.Number) + if !ok { + return "", false + } + return "n:" + number.String(), true +} + +func retryableProxyRequest(frame []byte) bool { + var envelope struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + Params struct { + Name string `json:"name"` + Arguments struct { + Operation string `json:"operation"` + } `json:"arguments"` + } `json:"params"` + } + if json.Unmarshal(frame, &envelope) != nil { + return false + } + // The daemon can deduplicate a replay only when it admitted the exact raw ID + // to its bounded response cache. Keep proxy replay admission identical. + if len(bytes.TrimSpace(envelope.ID)) > daemon.MCPResponseCacheMaxIDBytes { + return false + } + switch envelope.Method { + case "initialize", "ping", "tools/list", "resources/list", "resources/templates/list", "prompts/list": + return true + case "tools/call": + switch envelope.Params.Name { + case "capabilities", "explore", "search", "read", "relations", "trace", "analyze", "workspace", "recall": + return true + } + } + return false +} + +func streamProxyFrames(ctx context.Context, src io.Reader) <-chan proxyFrameEvent { + out := make(chan proxyFrameEvent, 16) + go func() { + defer close(out) + reader := bufio.NewReaderSize(src, 1<<20) + for { + frame, err := reader.ReadBytes('\n') + if len(frame) > 0 { + select { + case out <- proxyFrameEvent{frame: frame}: + case <-ctx.Done(): + return + } + } + if err != nil { + if !errors.Is(err, io.EOF) { + select { + case out <- proxyFrameEvent{err: err}: + case <-ctx.Done(): + } + } else if src != nil { + select { + case out <- proxyFrameEvent{err: io.EOF}: + case <-ctx.Done(): + } + } + return + } + } + }() + return out +} + +func writeProxyFrame(dst io.Writer, frame []byte) (int, error) { + written := 0 + for len(frame) > 0 { + n, err := dst.Write(frame) + written += n + frame = frame[n:] + if err != nil { + return written, err + } + if n == 0 { + return written, io.ErrShortWrite + } + } + return written, nil +} + +func logProxyConnection(stderr io.Writer, client *daemon.Client, reconnected bool) { + if stderr == nil || client == nil { + return + } + verb := "proxying" + if reconnected { + verb = "reconnected" + } + if client.Ack.Warming { + fmt.Fprintf(stderr, + "[gortex mcp] %s to daemon (session %s, daemon warming up — phase %q; graph still filling)\n", + verb, client.Ack.SessionID, client.Ack.WarmupPhase) + return + } + fmt.Fprintf(stderr, "[gortex mcp] %s to daemon (session %s, default_repo=%q)\n", + verb, client.Ack.SessionID, client.Ack.DefaultRepo) +} diff --git a/cmd/gortex/proxy_relay_cache_admission_test.go b/cmd/gortex/proxy_relay_cache_admission_test.go new file mode 100644 index 000000000..0cfae0f25 --- /dev/null +++ b/cmd/gortex/proxy_relay_cache_admission_test.go @@ -0,0 +1,57 @@ +package main + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/zzet/gortex/internal/daemon" +) + +func TestRetryableProxyRequestMirrorsDaemonRawIDBoundary(t *testing.T) { + t.Parallel() + + atLimit := `"` + strings.Repeat("a", daemon.MCPResponseCacheMaxIDBytes-2) + `"` + if got := len(bytes.TrimSpace(json.RawMessage(atLimit))); got != daemon.MCPResponseCacheMaxIDBytes { + t.Fatalf("boundary raw ID length = %d, want %d", got, daemon.MCPResponseCacheMaxIDBytes) + } + if !retryableProxyRequest(proxyReadRequestWithRawID(atLimit)) { + t.Fatal("read request at daemon cache ID limit was not replayable") + } + + overLimit := `"` + strings.Repeat("a", daemon.MCPResponseCacheMaxIDBytes-1) + `"` + if got := len(bytes.TrimSpace(json.RawMessage(overLimit))); got != daemon.MCPResponseCacheMaxIDBytes+1 { + t.Fatalf("oversized raw ID length = %d, want %d", got, daemon.MCPResponseCacheMaxIDBytes+1) + } + if retryableProxyRequest(proxyReadRequestWithRawID(overLimit)) { + t.Fatal("read request above daemon cache ID limit was replayable") + } +} + +func TestRetryableProxyRequestMeasuresEscapedIDBeforeDecoding(t *testing.T) { + t.Parallel() + + rawID := `"` + strings.Repeat(`\u0061`, daemon.MCPResponseCacheMaxIDBytes/6+1) + `"` + var decoded string + if err := json.Unmarshal([]byte(rawID), &decoded); err != nil { + t.Fatalf("decode test ID: %v", err) + } + if len(rawID) <= daemon.MCPResponseCacheMaxIDBytes { + t.Fatalf("raw escaped ID length = %d, want over %d", len(rawID), daemon.MCPResponseCacheMaxIDBytes) + } + if len(decoded) >= daemon.MCPResponseCacheMaxIDBytes { + t.Fatalf("decoded ID length = %d, test must distinguish raw from decoded", len(decoded)) + } + if retryableProxyRequest(proxyReadRequestWithRawID(rawID)) { + t.Fatal("raw oversized escaped ID was replayable after decoding shorter") + } +} + +func proxyReadRequestWithRawID(rawID string) []byte { + frame := make([]byte, 0, len(rawID)+128) + frame = append(frame, `{"jsonrpc":"2.0","id": `...) + frame = append(frame, rawID...) + frame = append(frame, ` ,"method":"tools/call","params":{"name":"read","arguments":{"operation":"source"}}}`...) + return frame +} diff --git a/cmd/gortex/proxy_relay_restart_test.go b/cmd/gortex/proxy_relay_restart_test.go new file mode 100644 index 000000000..ccffa9156 --- /dev/null +++ b/cmd/gortex/proxy_relay_restart_test.go @@ -0,0 +1,273 @@ +package main + +import ( + "context" + "encoding/json" + "io" + "os" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/zzet/gortex/internal/daemon" + "go.uber.org/zap" +) + +type proxyRestartDispatcher struct { + mu sync.Mutex + methods []string + blockEditingContext bool + editingContextStarted chan struct{} + editingContextOnce sync.Once +} + +func (d *proxyRestartDispatcher) Dispatch(ctx context.Context, _ *daemon.Session, frame []byte) ([]byte, error) { + var request struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + Params struct { + Name string `json:"name"` + Arguments struct { + Operation string `json:"operation"` + } `json:"arguments"` + } `json:"params"` + } + if err := json.Unmarshal(frame, &request); err != nil { + return nil, err + } + d.mu.Lock() + d.methods = append(d.methods, request.Method) + d.mu.Unlock() + if d.blockEditingContext && request.Method == "tools/call" && request.Params.Name == "read" && request.Params.Arguments.Operation == "editing_context" { + d.editingContextOnce.Do(func() { + if d.editingContextStarted != nil { + close(d.editingContextStarted) + } + }) + <-ctx.Done() + return nil, ctx.Err() + } + if len(request.ID) == 0 { + return nil, nil + } + result := any(map[string]any{"content": []any{}}) + if request.Method == "initialize" { + result = map[string]any{ + "protocolVersion": "2025-03-26", + "capabilities": map[string]any{"tools": map[string]any{"listChanged": true}}, + "serverInfo": map[string]any{"name": "gortex-test", "version": "test"}, + } + } + return json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": request.ID, + "result": result, + }) +} + +func (d *proxyRestartDispatcher) snapshot() []string { + d.mu.Lock() + defer d.mu.Unlock() + return append([]string(nil), d.methods...) +} + +func TestRelayProxySessionRestoresProtocolAgainstRealRestartedDaemon(t *testing.T) { + oldDial := dialDaemon + oldWindow := proxyDialRetryWindow + oldInterval := proxyDialRetryInterval + t.Cleanup(func() { + dialDaemon = oldDial + proxyDialRetryWindow = oldWindow + proxyDialRetryInterval = oldInterval + }) + proxyDialRetryWindow = 2 * time.Second + proxyDialRetryInterval = time.Millisecond + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + + dir, err := os.MkdirTemp("/tmp", "gxpr") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + socket := filepath.Join(dir, "s") + h := daemon.Handshake{ + Version: daemon.ProtocolVersion, + Mode: daemon.ModeMCP, + PID: os.Getpid(), + LogicalSessionID: testLogicalSessionID, + CWD: t.TempDir(), + } + + firstDispatcher := &proxyRestartDispatcher{ + blockEditingContext: true, + editingContextStarted: make(chan struct{}), + } + firstServer, firstDone := startProxyRestartDaemon(t, socket, firstDispatcher) + initial, err := daemon.DialTo(socket, h) + if err != nil { + t.Fatal(err) + } + firstInstance := initial.Ack.DaemonInstance + dialDaemon = func(handshake daemon.Handshake) (*daemon.Client, error) { + return daemon.DialTo(socket, handshake) + } + + ctx, cancel := context.WithCancel(context.Background()) + stdinR, stdinW := io.Pipe() + stdoutR, stdoutW := io.Pipe() + done := make(chan error, 1) + go func() { + done <- relayProxySession(ctx, h, initial, stdinR, stdoutW, io.Discard, nil, nil) + }() + + initialize := []byte(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","clientInfo":{"name":"restart-test","version":"1"},"capabilities":{}}}` + "\n") + mustWriteTestFrame(t, stdinW, initialize) + assertTestResponse(t, mustReadTestFrame(t, stdoutR), 1, false) + mustWriteTestFrame(t, stdinW, []byte(`{"jsonrpc":"2.0","method":"notifications/initialized"}`+"\n")) + waitForProxyMethods(t, firstDispatcher, 2) + + // Interrupt a real read(editing_context) while it is executing. Closing + // the first daemon socket must not close the host's stdin/stdout transport. + interrupted := []byte(`{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"read","arguments":{"operation":"editing_context","target":{"file":"internal/mcp/tools_explore.go"}}}}` + "\n") + mustWriteTestFrame(t, stdinW, interrupted) + select { + case <-firstDispatcher.editingContextStarted: + case <-time.After(2 * time.Second): + t.Fatal("editing_context did not start before daemon restart") + } + + stopProxyRestartDaemon(t, firstServer, firstDone) + secondDispatcher := &proxyRestartDispatcher{} + secondServer, secondDone := startProxyRestartDaemon(t, socket, secondDispatcher) + t.Cleanup(func() { stopProxyRestartDaemon(t, secondServer, secondDone) }) + + warning := mustReadTestFrame(t, stdoutR) + listChanged := mustReadTestFrame(t, stdoutR) + resetError := mustReadTestFrame(t, stdoutR) + if !jsonFrameMethod(listChanged, "notifications/tools/list_changed") { + t.Fatalf("restart tools/list_changed notification missing: %s", listChanged) + } + var resetNotification struct { + Method string `json:"method"` + Params struct { + Data struct { + Code string `json:"code"` + PreviousInstance string `json:"previous_daemon_instance"` + CurrentInstance string `json:"daemon_instance"` + } `json:"data"` + } `json:"params"` + } + if err := json.Unmarshal(warning, &resetNotification); err != nil || + resetNotification.Method != "notifications/message" || + resetNotification.Params.Data.Code != "gortex_session_reset" || + resetNotification.Params.Data.PreviousInstance != firstInstance || + resetNotification.Params.Data.CurrentInstance == "" || + resetNotification.Params.Data.CurrentInstance == firstInstance { + t.Fatalf("machine-readable daemon instance reset missing: %s (%v)", warning, err) + } + assertTestResponse(t, resetError, 2, true) + if initial.Ack.DaemonInstance == "" || initial.Ack.DaemonInstance != firstInstance { + t.Fatal("initial daemon instance was not stable") + } + methods := waitForProxyMethods(t, secondDispatcher, 2) + if methods[0] != "initialize" || methods[1] != "notifications/initialized" { + t.Fatalf("protocol state replay order = %v", methods) + } + for _, method := range methods { + if method == "tools/call" { + t.Fatalf("triggering request executed despite session reset: %v", methods) + } + } + + retry := []byte(`{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"read","arguments":{"operation":"editing_context","target":{"file":"internal/mcp/tools_explore.go"}}}}` + "\n") + mustWriteTestFrame(t, stdinW, retry) + assertTestResponse(t, mustReadTestFrame(t, stdoutR), 3, false) + methods = waitForProxyMethods(t, secondDispatcher, 3) + if methods[2] != "tools/call" { + t.Fatalf("explicit retry was not dispatched: %v", methods) + } + + cancel() + _ = stdinW.Close() + waitRelay(t, done) +} + +func startProxyRestartDaemon(t *testing.T, socket string, dispatcher daemon.MCPDispatcher) (*daemon.Server, <-chan error) { + t.Helper() + server := daemon.New(socket, "test", zap.NewNop()) + server.MCPDispatcher = dispatcher + if err := server.Listen(); err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- server.Serve() }() + return server, done +} + +func testProtocolRestoreClient(t *testing.T) (*daemon.Client, func()) { + t.Helper() + dir, err := os.MkdirTemp("/tmp", "gxpd") + if err != nil { + t.Fatal(err) + } + socket := filepath.Join(dir, "s") + server, done := startProxyRestartDaemon(t, socket, &proxyRestartDispatcher{}) + client, err := daemon.DialTo(socket, daemon.Handshake{ + Version: daemon.ProtocolVersion, + Mode: daemon.ModeMCP, + PID: os.Getpid(), + LogicalSessionID: testLogicalSessionID, + CWD: t.TempDir(), + }) + if err != nil { + stopProxyRestartDaemon(t, server, done) + _ = os.RemoveAll(dir) + t.Fatal(err) + } + cleanup := func() { + _ = client.Close() + stopProxyRestartDaemon(t, server, done) + _ = os.RemoveAll(dir) + } + return client, cleanup +} + +func stopProxyRestartDaemon(t *testing.T, server *daemon.Server, done <-chan error) { + t.Helper() + if server == nil { + return + } + if err := server.Shutdown(); err != nil { + t.Fatal(err) + } + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(2 * time.Second): + t.Fatal("daemon did not stop") + } +} + +func waitForProxyMethods(t *testing.T, dispatcher *proxyRestartDispatcher, want int) []string { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + methods := dispatcher.snapshot() + if len(methods) >= want { + return methods + } + time.Sleep(time.Millisecond) + } + t.Fatalf("dispatcher methods = %v, want at least %d", dispatcher.snapshot(), want) + return nil +} + +func jsonFrameMethod(frame []byte, want string) bool { + var envelope struct { + Method string `json:"method"` + } + return json.Unmarshal(frame, &envelope) == nil && envelope.Method == want +} diff --git a/cmd/gortex/proxy_relay_test.go b/cmd/gortex/proxy_relay_test.go new file mode 100644 index 000000000..2e835833b --- /dev/null +++ b/cmd/gortex/proxy_relay_test.go @@ -0,0 +1,670 @@ +package main + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/zzet/gortex/internal/daemon" +) + +func TestRelayProxySessionReplaysSafeReadAfterDaemonRestart(t *testing.T) { + oldDial := dialDaemon + oldWindow := proxyDialRetryWindow + oldInterval := proxyDialRetryInterval + t.Cleanup(func() { + dialDaemon = oldDial + proxyDialRetryWindow = oldWindow + proxyDialRetryInterval = oldInterval + }) + proxyDialRetryWindow = 50 * time.Millisecond + proxyDialRetryInterval = time.Millisecond + + initialClient, initialDaemon := testDaemonPipe() + recoveredClient, recoveredDaemon := testDaemonPipe() + var dialCalls atomic.Int32 + dialDaemon = func(daemon.Handshake) (*daemon.Client, error) { + dialCalls.Add(1) + return recoveredClient, nil + } + + ctx, cancel := context.WithCancel(context.Background()) + stdinR, stdinW := io.Pipe() + stdoutR, stdoutW := io.Pipe() + var stderr bytes.Buffer + done := make(chan error, 1) + go func() { + done <- relayProxySession(ctx, testProxyHandshake(), initialClient, stdinR, stdoutW, &stderr, nil, nil) + }() + + request := []byte(`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"read","arguments":{"operation":"source"}}}` + "\n") + mustWriteTestFrame(t, stdinW, request) + if got := mustReadTestFrame(t, initialDaemon); !bytes.Equal(got, request) { + t.Fatalf("initial request mismatch:\n got %s\nwant %s", got, request) + } + if err := initialDaemon.Close(); err != nil { + t.Fatal(err) + } + + if got := mustReadTestFrame(t, recoveredDaemon); !bytes.Equal(got, request) { + t.Fatalf("safe read was not replayed after reconnect:\n got %s\nwant %s", got, request) + } + mustWriteTestFrame(t, recoveredDaemon, []byte(`{"jsonrpc":"2.0","id":1,"result":{"content":[]}}`+"\n")) + response := mustReadTestFrame(t, stdoutR) + assertTestResponse(t, response, 1, false) + if got := dialCalls.Load(); got != 1 { + t.Fatalf("dial calls = %d, want 1", got) + } + assertRelayRunning(t, done) + + cancel() + _ = stdinW.Close() + _ = recoveredDaemon.Close() + waitRelay(t, done) +} + +func TestRelayProxySessionKeepsTransportAfterEditingContextInterruptedByRestart(t *testing.T) { + oldDial := dialDaemon + oldWindow := proxyDialRetryWindow + oldInterval := proxyDialRetryInterval + t.Cleanup(func() { + dialDaemon = oldDial + proxyDialRetryWindow = oldWindow + proxyDialRetryInterval = oldInterval + }) + proxyDialRetryWindow = 10 * time.Millisecond + proxyDialRetryInterval = time.Millisecond + + initialClient, initialDaemon := testDaemonPipe() + recoveredClient, recoveredDaemon := testDaemonPipe() + recoveredClient.Ack.DaemonInstance = "daemon-instance-b" + var daemonRecovered atomic.Bool + var recoveredClientTaken atomic.Bool + dialDaemon = func(daemon.Handshake) (*daemon.Client, error) { + if !daemonRecovered.Load() { + return nil, daemon.ErrDaemonUnavailable + } + if recoveredClientTaken.Swap(true) { + return nil, daemon.ErrDaemonUnavailable + } + return recoveredClient, nil + } + + ctx, cancel := context.WithCancel(context.Background()) + stdinR, stdinW := io.Pipe() + stdoutR, stdoutW := io.Pipe() + var stderr bytes.Buffer + done := make(chan error, 1) + go func() { + done <- relayProxySession(ctx, testProxyHandshake(), initialClient, stdinR, stdoutW, &stderr, nil, nil) + }() + + interrupted := []byte(`{"jsonrpc":"2.0","id":41,"method":"tools/call","params":{"name":"read","arguments":{"operation":"editing_context","target":{"file":"internal/mcp/tools_explore.go"},"context":{"compress_bodies":true}}}}` + "\n") + mustWriteTestFrame(t, stdinW, interrupted) + if got := mustReadTestFrame(t, initialDaemon); !bytes.Equal(got, interrupted) { + t.Fatalf("initial editing-context request mismatch:\n got %s\nwant %s", got, interrupted) + } + if err := initialDaemon.Close(); err != nil { + t.Fatal(err) + } + assertTestResponse(t, mustReadTestFrame(t, stdoutR), 41, true) + assertRelayRunning(t, done) + + daemonRecovered.Store(true) + restartTrigger := []byte(`{"jsonrpc":"2.0","id":42,"method":"tools/call","params":{"name":"read","arguments":{"operation":"editing_context","target":{"file":"internal/mcp/facade_tools.go"},"context":{"compress_bodies":true}}}}` + "\n") + mustWriteTestFrame(t, stdinW, restartTrigger) + warning := mustReadTestFrame(t, stdoutR) + var notification struct { + Method string `json:"method"` + Params struct { + Data struct { + Code string `json:"code"` + } `json:"data"` + } `json:"params"` + } + if err := json.Unmarshal(warning, ¬ification); err != nil || notification.Method != "notifications/message" || notification.Params.Data.Code != "gortex_session_reset" { + t.Fatalf("missing restart warning: %s (%v)", warning, err) + } + listChanged := mustReadTestFrame(t, stdoutR) + var listNotification struct { + Method string `json:"method"` + } + if err := json.Unmarshal(listChanged, &listNotification); err != nil || listNotification.Method != "notifications/tools/list_changed" { + t.Fatalf("missing tools/list_changed after restart: %s (%v)", listChanged, err) + } + resetError := mustReadTestFrame(t, stdoutR) + assertTestResponse(t, resetError, 42, true) + if !bytes.Contains(resetError, []byte(`"session_reset":true`)) { + t.Fatalf("restart error is not machine-detectable: %s", resetError) + } + assertRelayRunning(t, done) + + // The request that discovers a new daemon instance is deliberately not + // forwarded. An explicit retry succeeds on the same host stdio transport. + retry := []byte(`{"jsonrpc":"2.0","id":43,"method":"tools/call","params":{"name":"read","arguments":{"operation":"editing_context","target":{"file":"internal/mcp/facade_tools.go"},"context":{"compress_bodies":true}}}}` + "\n") + mustWriteTestFrame(t, stdinW, retry) + if got := mustReadTestFrame(t, recoveredDaemon); !bytes.Equal(got, retry) { + t.Fatalf("post-restart retry mismatch:\n got %s\nwant %s", got, retry) + } + mustWriteTestFrame(t, recoveredDaemon, []byte(`{"jsonrpc":"2.0","id":43,"result":{"content":[]}}`+"\n")) + assertTestResponse(t, mustReadTestFrame(t, stdoutR), 43, false) + assertRelayRunning(t, done) + + cancel() + _ = stdinW.Close() + _ = recoveredDaemon.Close() + waitRelay(t, done) +} + +func TestRelayProxySessionKeepsTransportAcrossUnavailableDaemon(t *testing.T) { + oldDial := dialDaemon + oldWindow := proxyDialRetryWindow + oldInterval := proxyDialRetryInterval + t.Cleanup(func() { + dialDaemon = oldDial + proxyDialRetryWindow = oldWindow + proxyDialRetryInterval = oldInterval + }) + proxyDialRetryWindow = 10 * time.Millisecond + proxyDialRetryInterval = time.Millisecond + + initialClient, initialDaemon := testDaemonPipe() + recoveredClient, recoveredDaemon := testDaemonPipe() + var daemonRecovered atomic.Bool + var dialCalls atomic.Int32 + var recoveredClientTaken atomic.Bool + dialDaemon = func(daemon.Handshake) (*daemon.Client, error) { + dialCalls.Add(1) + if !daemonRecovered.Load() { + return nil, daemon.ErrDaemonUnavailable + } + if recoveredClientTaken.Swap(true) { + return nil, daemon.ErrDaemonUnavailable + } + return recoveredClient, nil + } + + ctx, cancel := context.WithCancel(context.Background()) + stdinR, stdinW := io.Pipe() + stdoutR, stdoutW := io.Pipe() + var stderr bytes.Buffer + done := make(chan error, 1) + go func() { + done <- relayProxySession(ctx, testProxyHandshake(), initialClient, stdinR, stdoutW, &stderr, nil, nil) + }() + + mutation := []byte(`{"jsonrpc":"2.0","id":null,"method":"tools/call","params":{"name":"edit","arguments":{"operation":"file"}}}` + "\n") + mustWriteTestFrame(t, stdinW, mutation) + if got := mustReadTestFrame(t, initialDaemon); !bytes.Equal(got, mutation) { + t.Fatalf("initial mutation mismatch:\n got %s\nwant %s", got, mutation) + } + if err := initialDaemon.Close(); err != nil { + t.Fatal(err) + } + mutationError := mustReadTestFrame(t, stdoutR) + assertNullIDError(t, mutationError, false, true) + if got := dialCalls.Load(); got != 0 { + t.Fatalf("mutation was replayed: dial calls = %d, want 0", got) + } + assertRelayRunning(t, done) + + // Notifications never receive synthetic JSON-RPC responses. While the + // daemon is down this one is dropped, then the following request receives + // the first output frame and proves stdout was not polluted. + notification := []byte(`{"jsonrpc":"2.0","method":"notifications/initialized"}` + "\n") + mustWriteTestFrame(t, stdinW, notification) + unavailable := []byte(`{"jsonrpc":"2.0","id":11,"method":"tools/call","params":{"name":"search","arguments":{"operation":"symbols","query":"x"}}}` + "\n") + mustWriteTestFrame(t, stdinW, unavailable) + unavailableError := mustReadTestFrame(t, stdoutR) + assertTestResponse(t, unavailableError, 11, true) + assertRelayRunning(t, done) + + daemonRecovered.Store(true) + recovered := []byte(`{"jsonrpc":"2.0","id":12,"method":"tools/call","params":{"name":"search","arguments":{"operation":"symbols","query":"y"}}}` + "\n") + mustWriteTestFrame(t, stdinW, recovered) + if got := mustReadTestFrame(t, recoveredDaemon); !bytes.Equal(got, recovered) { + t.Fatalf("post-restart request mismatch or earlier unsafe frame replayed:\n got %s\nwant %s", got, recovered) + } + mustWriteTestFrame(t, recoveredDaemon, []byte(`{"jsonrpc":"2.0","id":12,"result":{"content":[]}}`+"\n")) + response := mustReadTestFrame(t, stdoutR) + assertTestResponse(t, response, 12, false) + assertRelayRunning(t, done) + + cancel() + _ = stdinW.Close() + _ = recoveredDaemon.Close() + waitRelay(t, done) +} + +func TestRelayProxySessionZeroByteMutationIsRetryableButNotReplayed(t *testing.T) { + oldDial := dialDaemon + t.Cleanup(func() { dialDaemon = oldDial }) + var dialCalls atomic.Int32 + dialDaemon = func(daemon.Handshake) (*daemon.Client, error) { + dialCalls.Add(1) + return nil, daemon.ErrDaemonUnavailable + } + + conn := &zeroWriteConn{closed: make(chan struct{})} + initial := &daemon.Client{ + Conn: conn, + Ack: daemon.HandshakeAck{ + SessionID: testLogicalSessionID, + DaemonInstance: testDaemonInstance, + }, + } + ctx, cancel := context.WithCancel(context.Background()) + stdinR, stdinW := io.Pipe() + stdoutR, stdoutW := io.Pipe() + done := make(chan error, 1) + go func() { + done <- relayProxySession(ctx, testProxyHandshake(), initial, stdinR, stdoutW, io.Discard, nil, nil) + }() + + request := []byte(`{"jsonrpc":"2.0","id":30,"method":"tools/call","params":{"name":"edit"}}` + "\n") + mustWriteTestFrame(t, stdinW, request) + response := mustReadTestFrame(t, stdoutR) + var envelope struct { + ID int `json:"id"` + Error struct { + Data struct { + Retryable bool `json:"retryable"` + DeliveryUnknown bool `json:"delivery_unknown"` + } `json:"data"` + } `json:"error"` + } + if err := json.Unmarshal(response, &envelope); err != nil { + t.Fatal(err) + } + if envelope.ID != 30 || !envelope.Error.Data.Retryable || envelope.Error.Data.DeliveryUnknown { + t.Fatalf("zero-byte mutation response = %s", response) + } + if dialCalls.Load() != 0 { + t.Fatalf("zero-byte mutation was auto-replayed: dial calls=%d", dialCalls.Load()) + } + assertRelayRunning(t, done) + + cancel() + _ = stdinW.Close() + waitRelay(t, done) +} + +type zeroWriteConn struct { + closed chan struct{} + once sync.Once +} + +func (c *zeroWriteConn) Read([]byte) (int, error) { + <-c.closed + return 0, io.EOF +} +func (c *zeroWriteConn) Write([]byte) (int, error) { return 0, io.ErrClosedPipe } +func (c *zeroWriteConn) Close() error { + c.once.Do(func() { close(c.closed) }) + return nil +} +func (*zeroWriteConn) LocalAddr() net.Addr { return &net.TCPAddr{} } +func (*zeroWriteConn) RemoteAddr() net.Addr { return &net.TCPAddr{} } +func (*zeroWriteConn) SetDeadline(time.Time) error { return nil } +func (*zeroWriteConn) SetReadDeadline(time.Time) error { return nil } +func (*zeroWriteConn) SetWriteDeadline(time.Time) error { return nil } + +type deadlineErrorConn struct { + net.Conn + setErr error + clearErr error +} + +func (c *deadlineErrorConn) SetDeadline(deadline time.Time) error { + if deadline.IsZero() { + return c.clearErr + } + return c.setErr +} + +func TestRestoreProtocolStateRejectsDeadlineFailures(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + initialize := []byte(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`) + t.Run("set", func(t *testing.T) { + sentinel := errors.New("set deadline") + proxyConn, daemonConn := net.Pipe() + defer proxyConn.Close() + defer daemonConn.Close() + client := &daemon.Client{Conn: &deadlineErrorConn{Conn: proxyConn, setErr: sentinel}} + state := &proxyRelayState{initialize: initialize} + if err := state.restoreProtocolState(client); !errors.Is(err, sentinel) { + t.Fatalf("restore error = %v, want wrapped set-deadline error", err) + } + }) + + t.Run("clear", func(t *testing.T) { + sentinel := errors.New("clear deadline") + client, cleanup := testProtocolRestoreClient(t) + defer cleanup() + client.Conn = &deadlineErrorConn{Conn: client.Conn, clearErr: sentinel} + state := &proxyRelayState{initialize: initialize} + if err := state.restoreProtocolState(client); !errors.Is(err, sentinel) { + t.Fatalf("restore error = %v, want wrapped clear-deadline error", err) + } + }) +} + +func TestRestoreProtocolStateForwardsInterleavedNotification(t *testing.T) { + proxyConn, daemonConn := net.Pipe() + defer proxyConn.Close() + defer daemonConn.Close() + + client := &daemon.Client{Conn: proxyConn} + daemonSide := &daemon.Client{Conn: daemonConn} + initialize := []byte(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`) + notification := []byte(`{"jsonrpc":"2.0","method":"notifications/tools/list_changed"}`) + response := []byte(`{"jsonrpc":"2.0","id":"gortex-reconnect-initialize","result":{"protocolVersion":"2025-06-18"}}`) + var stdout bytes.Buffer + + done := make(chan error, 1) + go func() { + if _, err := daemonSide.ReadMCPFrame(); err != nil { + done <- err + return + } + if err := daemonSide.WriteMCPFrame(notification); err != nil { + done <- err + return + } + if err := daemonSide.WriteMCPFrame(response); err != nil { + done <- err + return + } + _, err := daemonSide.ReadMCPFrame() // notifications/initialized + done <- err + }() + + state := &proxyRelayState{initialize: initialize, stdout: &stdout} + if err := state.restoreProtocolState(client); err != nil { + t.Fatalf("restore protocol state: %v", err) + } + if err := <-done; err != nil { + t.Fatalf("daemon side: %v", err) + } + if got := bytes.TrimSpace(stdout.Bytes()); !bytes.Equal(got, notification) { + t.Fatalf("forwarded frame = %s, want %s", got, notification) + } +} + +func TestRelayProxySessionReportsDaemonProcessReset(t *testing.T) { + oldDial := dialDaemon + oldWindow := proxyDialRetryWindow + oldInterval := proxyDialRetryInterval + t.Cleanup(func() { + dialDaemon = oldDial + proxyDialRetryWindow = oldWindow + proxyDialRetryInterval = oldInterval + }) + proxyDialRetryWindow = 20 * time.Millisecond + proxyDialRetryInterval = time.Millisecond + + initialClient, initialDaemon := testDaemonPipe() + recoveredClient, recoveredDaemon := testDaemonPipe() + recoveredClient.Ack.DaemonInstance = "daemon-instance-b" + var taken atomic.Bool + dialDaemon = func(daemon.Handshake) (*daemon.Client, error) { + if taken.Swap(true) { + return nil, daemon.ErrDaemonUnavailable + } + return recoveredClient, nil + } + + ctx, cancel := context.WithCancel(context.Background()) + stdinR, stdinW := io.Pipe() + stdoutR, stdoutW := io.Pipe() + var stderr bytes.Buffer + done := make(chan error, 1) + go func() { + done <- relayProxySession(ctx, testProxyHandshake(), initialClient, stdinR, stdoutW, &stderr, nil, nil) + }() + + // Disconnect without an in-flight request, then let the next call trigger + // a reconnect to a different daemon process. + if err := initialDaemon.Close(); err != nil { + t.Fatal(err) + } + request := []byte(`{"jsonrpc":"2.0","id":null,"method":"tools/call","params":{"name":"search"}}` + "\n") + mustWriteTestFrame(t, stdinW, request) + + warning := mustReadTestFrame(t, stdoutR) + var notification struct { + Method string `json:"method"` + Params struct { + Data struct { + Code string `json:"code"` + } `json:"data"` + } `json:"params"` + } + if err := json.Unmarshal(warning, ¬ification); err != nil || notification.Method != "notifications/message" || notification.Params.Data.Code != "gortex_session_reset" { + t.Fatalf("missing machine-detectable reset warning: %s (%v)", warning, err) + } + listChanged := mustReadTestFrame(t, stdoutR) + var listNotification struct { + Method string `json:"method"` + } + if err := json.Unmarshal(listChanged, &listNotification); err != nil || listNotification.Method != "notifications/tools/list_changed" { + t.Fatalf("missing tools/list_changed after reset: %s (%v)", listChanged, err) + } + resetError := mustReadTestFrame(t, stdoutR) + assertNullIDError(t, resetError, true, false) + if bytes.Contains(resetError, []byte(`"session_reset":true`)) == false { + t.Fatalf("reset error is not machine-detectable: %s", resetError) + } + assertRelayRunning(t, done) + + // The triggering request was not executed. A later, explicit retry uses + // the fresh daemon session normally. + retry := []byte(`{"jsonrpc":"2.0","id":22,"method":"tools/call","params":{"name":"search"}}` + "\n") + mustWriteTestFrame(t, stdinW, retry) + if got := mustReadTestFrame(t, recoveredDaemon); !bytes.Equal(got, retry) { + t.Fatalf("fresh-session retry mismatch:\n got %s\nwant %s", got, retry) + } + mustWriteTestFrame(t, recoveredDaemon, []byte(`{"jsonrpc":"2.0","id":22,"result":{}}`+"\n")) + assertTestResponse(t, mustReadTestFrame(t, stdoutR), 22, false) + + cancel() + _ = stdinW.Close() + _ = recoveredDaemon.Close() + waitRelay(t, done) +} + +func TestRetryableProxyRequestIsConservative(t *testing.T) { + tests := []struct { + name string + frame string + want bool + }{ + {name: "read facade", frame: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"read"}}`, want: true}, + {name: "explore task", frame: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"explore","arguments":{"operation":"task"}}}`, want: true}, + {name: "explore localize uses daemon response dedup", frame: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"explore","arguments":{"operation":" Localize "}}}`, want: true}, + {name: "capabilities", frame: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"capabilities"}}`, want: true}, + {name: "edit mutation", frame: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"edit"}}`, want: false}, + {name: "change deliberately not replayed", frame: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"change"}}`, want: false}, + {name: "ask deliberately not replayed", frame: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"ask"}}`, want: false}, + {name: "unknown tool", frame: `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"custom_mutation"}}`, want: false}, + {name: "notification", frame: `{"jsonrpc":"2.0","method":"notifications/initialized"}`, want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := retryableProxyRequest([]byte(tt.frame)); got != tt.want { + t.Fatalf("retryableProxyRequest() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestPendingRequestIDsPreserveLargeIntegersAndTypes(t *testing.T) { + requests := [][]byte{ + []byte(`{"jsonrpc":"2.0","id":9007199254740992,"method":"tools/call","params":{"name":"read"}}`), + []byte(`{"jsonrpc":"2.0","id":9007199254740993,"method":"tools/call","params":{"name":"read"}}`), + []byte(`{"jsonrpc":"2.0","id":"9007199254740992","method":"tools/call","params":{"name":"read"}}`), + []byte(`{"jsonrpc":"2.0","id":null,"method":"tools/call","params":{"name":"read"}}`), + } + responses := [][]byte{ + []byte(`{"jsonrpc":"2.0","id":9007199254740992,"result":{}}`), + []byte(`{"jsonrpc":"2.0","id":9007199254740993,"result":{}}`), + []byte(`{"jsonrpc":"2.0","id":"9007199254740992","result":{}}`), + []byte(`{"jsonrpc":"2.0","id":null,"result":{}}`), + } + + pending := make(map[string]*proxyPendingRequest) + for i, frame := range requests { + request, ok := pendingRequest(frame) + if !ok { + t.Fatalf("request %d did not produce a pending ID", i) + } + if _, collision := pending[request.key]; collision { + t.Fatalf("request %d collided on key %q", i, request.key) + } + pending[request.key] = request + responseKey, ok := responseIDKey(responses[i]) + if !ok || responseKey != request.key { + t.Fatalf("response %d key = %q, want %q", i, responseKey, request.key) + } + } + if len(pending) != 4 { + t.Fatalf("pending IDs = %d, want 4 distinct entries", len(pending)) + } +} + +const ( + testLogicalSessionID = "0123456789abcdef0123456789abcdef" + testDaemonInstance = "daemon-instance-a" +) + +func testProxyHandshake() daemon.Handshake { + return daemon.Handshake{ + Mode: daemon.ModeMCP, + PID: 42, + LogicalSessionID: testLogicalSessionID, + } +} + +func testDaemonPipe() (*daemon.Client, net.Conn) { + proxyConn, daemonConn := net.Pipe() + return &daemon.Client{ + Conn: proxyConn, + Ack: daemon.HandshakeAck{ + SessionID: testLogicalSessionID, + DaemonInstance: testDaemonInstance, + }, + }, daemonConn +} + +func mustWriteTestFrame(t *testing.T, dst io.Writer, frame []byte) { + t.Helper() + done := make(chan error, 1) + go func() { + _, err := dst.Write(frame) + done <- err + }() + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(2 * time.Second): + t.Fatalf("timed out writing frame: %s", frame) + } +} + +func mustReadTestFrame(t *testing.T, src io.Reader) []byte { + t.Helper() + type result struct { + frame []byte + err error + } + done := make(chan result, 1) + go func() { + frame, err := bufio.NewReader(src).ReadBytes('\n') + done <- result{frame: frame, err: err} + }() + select { + case got := <-done: + if got.err != nil { + t.Fatal(got.err) + } + return got.frame + case <-time.After(2 * time.Second): + t.Fatal("timed out reading frame") + return nil + } +} + +func assertTestResponse(t *testing.T, frame []byte, wantID int, wantError bool) { + t.Helper() + var response struct { + ID int `json:"id"` + Error json.RawMessage `json:"error"` + } + if err := json.Unmarshal(frame, &response); err != nil { + t.Fatalf("invalid JSON-RPC response %q: %v", frame, err) + } + if response.ID != wantID { + t.Fatalf("response id = %d, want %d; frame=%s", response.ID, wantID, frame) + } + if gotError := len(response.Error) != 0 && string(response.Error) != "null"; gotError != wantError { + t.Fatalf("response error presence = %v, want %v; frame=%s", gotError, wantError, frame) + } +} + +func assertNullIDError(t *testing.T, frame []byte, wantRetryable, wantDeliveryUnknown bool) { + t.Helper() + var response struct { + ID json.RawMessage `json:"id"` + Error struct { + Code int `json:"code"` + Data struct { + Retryable bool `json:"retryable"` + DeliveryUnknown bool `json:"delivery_unknown"` + } `json:"data"` + } `json:"error"` + } + if err := json.Unmarshal(frame, &response); err != nil { + t.Fatalf("invalid JSON-RPC response %q: %v", frame, err) + } + if string(bytes.TrimSpace(response.ID)) != "null" { + t.Fatalf("response id = %s, want explicit null; frame=%s", response.ID, frame) + } + if response.Error.Code == 0 { + t.Fatalf("missing error response: %s", frame) + } + if response.Error.Data.Retryable != wantRetryable || response.Error.Data.DeliveryUnknown != wantDeliveryUnknown { + t.Fatalf("error delivery flags = retryable:%v delivery_unknown:%v, want %v/%v; frame=%s", + response.Error.Data.Retryable, response.Error.Data.DeliveryUnknown, wantRetryable, wantDeliveryUnknown, frame) + } +} + +func assertRelayRunning(t *testing.T, done <-chan error) { + t.Helper() + select { + case err := <-done: + t.Fatalf("relay exited while stdio remained open: %v", err) + default: + } +} + +func waitRelay(t *testing.T, done <-chan error) { + t.Helper() + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(2 * time.Second): + t.Fatal("relay did not stop after cancellation") + } +} diff --git a/cmd/gortex/query.go b/cmd/gortex/query.go index 528b68159..ee27987ef 100644 --- a/cmd/gortex/query.go +++ b/cmd/gortex/query.go @@ -47,7 +47,14 @@ func init() { // does not track the repo, this returns an actionable error rather than // silently building a second-class in-process index. func requireDaemonTool(repoPath, tool string, args map[string]any) (json.RawMessage, error) { - exec, err := resolveExecutor(repoPath) + return requireDaemonToolWithSurface(repoPath, tool, args, cliLegacyToolSurface, cliLegacyToolMode) +} + +// requireDaemonToolWithSurface runs one tool through a per-connection MCP +// surface without changing daemon-global configuration. It shares the normal +// daemon-required and repo-not-tracked error behavior. +func requireDaemonToolWithSurface(repoPath, tool string, args map[string]any, tools, toolsMode string) (json.RawMessage, error) { + exec, err := resolveExecutorWithToolSurface(repoPath, tools, toolsMode) if err != nil { if errors.Is(err, ErrNoExecutor) { return nil, daemonRequiredErr(repoPath) diff --git a/cmd/gortex/testdata/agent-render/antigravity.txt b/cmd/gortex/testdata/agent-render/antigravity.txt index c167abcc9..50477327f 100644 --- a/cmd/gortex/testdata/agent-render/antigravity.txt +++ b/cmd/gortex/testdata/agent-render/antigravity.txt @@ -1,53 +1,31 @@ === home/.gemini/antigravity/knowledge/gortex-workflow/artifacts/gortex-instructions.md === --- type: "Knowledge Item" -description: "Gortex Workflow and Tools for Antigravity" +description: "Mandatory Gortex public-tool workflow" --- -# Gortex Engine Usage +## MANDATORY: Use Gortex MCP tools instead of Read/Grep -This codebase is indexed by Gortex, an in-memory knowledge graph code intelligence engine. +For every coding task: -**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. +1. Call `explore` first with the complete task. Work from the returned source and call paths; do not reopen them with file or shell tools. +2. Inspect indexed code only with `search`, `read`, `relations`, and `trace`. Never use Read/Grep/Glob or shell equivalents for indexed source. +3. Before mutation, call `change(operation:"impact")`; for a signature change, also call `change(operation:"verify")` with the proposed signature. Mutate only with `edit` or `refactor`. After mutation, call `change(operation:"detect")`, then use its symbol IDs with `change(operation:"tests")`, `change(operation:"guards")`, and `change(operation:"contract")`. +4. Call `capabilities` only when you need the exact fields for an operation. -## Setup -If the daemon is not tracking your target repos, register the current repo so the graph tools activate: -```bash -gortex track . -``` +Common calls: -## Standard Workflow Translation +- `search({operation:"symbols", query:""})` +- `read({target:{symbol:"::"}})` +- `relations({operation:"usages", target:{symbol:""}})` +- `edit({target:{file:""}, match:"", replacement:""})` -| Instead of... | You MUST use... (via `run_command`) | -|---|---| -| `grep_search` to find a class or function | `./gortex query symbol --format text` (AST-aware search) | -| `grep_search` to find all references | `./gortex query usages ` (zero false positives) | -| `view_file` to read a whole file to find a method | `./gortex query symbol ` or `./gortex query callers ` | -| Guessing what breaks during a refactor | `./gortex query dependents ` (impact analysis) | -| Creating circular dependencies | Evaluate `./gortex query deps ` first | +If the Gortex server is configured but these tools are missing from the callable MCP tools, report a Gortex MCP integration failure and stop. Do not start a daemon or switch to a CLI/shell fallback. -## 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. +Use `recall` before revisiting prior work and `remember` immediately for durable decisions, invariants, or gotchas. === home/.gemini/antigravity/knowledge/gortex-workflow/metadata.json === { - "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.", + "summary": "MANDATORY: Use native Gortex MCP tools for indexed code. If configured tools are missing, report an MCP integration failure; do not start a daemon or use a CLI fallback.", "references": ["artifacts/gortex-instructions.md"] } === home/.gemini/antigravity/mcp_config.json === diff --git a/cmd/gortex/testdata/agent-render/claude-code.txt b/cmd/gortex/testdata/agent-render/claude-code.txt index 52c146176..b69f96e1d 100644 --- a/cmd/gortex/testdata/agent-render/claude-code.txt +++ b/cmd/gortex/testdata/agent-render/claude-code.txt @@ -2,7 +2,17 @@ { "permissions": { "allow": [ - "mcp__gortex__*" + "mcp__gortex__analyze", + "mcp__gortex__capabilities", + "mcp__gortex__change", + "mcp__gortex__explore", + "mcp__gortex__read", + "mcp__gortex__recall", + "mcp__gortex__relations", + "mcp__gortex__response", + "mcp__gortex__search", + "mcp__gortex__trace", + "mcp__gortex__workspace" ] } } diff --git a/cmd/gortex/testdata/agent-render/codex.txt b/cmd/gortex/testdata/agent-render/codex.txt index 4108b53d2..2a7ed6d7c 100644 --- a/cmd/gortex/testdata/agent-render/codex.txt +++ b/cmd/gortex/testdata/agent-render/codex.txt @@ -1,8 +1,14 @@ === home/.codex/config.toml === +[features] +[features.code_mode] +direct_only_tool_namespaces = ['mcp__gortex', 'gortex'] + [mcp_servers] [mcp_servers.gortex] args = ['mcp'] command = 'gortex' +required = true +startup_timeout_sec = 90 [mcp_servers.gortex.env] GORTEX_INDEX_WORKERS = '8' diff --git a/cmd/gortex/testdata/agent-render/cursor.txt b/cmd/gortex/testdata/agent-render/cursor.txt index a81e16cbb..3fd95caac 100644 --- a/cmd/gortex/testdata/agent-render/cursor.txt +++ b/cmd/gortex/testdata/agent-render/cursor.txt @@ -30,24 +30,14 @@ alwaysApply: true ## Gortex in Cursor -This repository wires the **gortex** MCP server via .cursor/mcp.json (merge-managed by Gortex). +Use Gortex MCP tools for indexed code. This is mandatory. -**MANDATORY: use graph tools, not blind file reads** +1. Start every coding task with **explore** using `operation: "task"` and the user's task text. +2. Use **search**, **read**, **relations**, and **trace** instead of text search or whole-file source reads. +3. Before mutation, call **change** with `operation: "impact"`; for a signature change, also call operation `verify` with the proposed signature. +4. Mutate only with **edit** or **refactor**. After mutation, call **change** operations `detect`, `tests`, `guards`, and `contract`. +5. Call **capabilities** with `domain`, `operation`, and `detail: "schema"` when exact arguments are not visible. -You **MUST** prefer Gortex graph queries over text search and whole-file opens on every task. These are not suggestions. +If the configured Gortex tools are missing from the callable MCP tools, report a Gortex MCP integration failure and stop. Do not start a daemon or use a CLI/shell fallback. -- **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:""` to surface prior warnings and decisions. - -**MANDATORY: development memories (cross-session)** - -- **Immediately after smart_context** on every task, call **surface_memories** with `task:""` and `symbol_ids:""` — 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:""`, `symbol_ids:""`, `importance:5`. Pin load-bearing memories. Use `supersedes:""` when newer knowledge replaces older. -- Memories are workspace-wide and outlive sessions, agents, and teammates — every future agent inherits them. +Use **recall** before editing known code and **remember** for durable decisions or invariants. diff --git a/cmd/gortex/testdata/agent-render/hermes.txt b/cmd/gortex/testdata/agent-render/hermes.txt index 5e98ff1e7..996da96d1 100644 --- a/cmd/gortex/testdata/agent-render/hermes.txt +++ b/cmd/gortex/testdata/agent-render/hermes.txt @@ -19,102 +19,21 @@ metadata: platforms: [linux, macos, windows] related_skills: [gortex] --- -# Architecture Review with Gortex +# Review Architecture with Gortex -Use this when the user wants a graph-grounded architectural read of a repo / system — "what does the architecture actually look like, where is the design under stress, what should we refactor before it breaks." Distinct from `/gortex-quality-audit` (which produces a punch list) — this produces a *narrative* with diagrams. +1. Build the map with `explore({operation: "outline"})` and `analyze` kinds `architecture` and `communities`. +2. Measure boundaries with `analyze({kind: "coupling"})` and cycles with `analyze({kind: "cycles"})`. +3. Trace representative paths with `trace` and inspect public boundaries with `analyze({kind: "contracts", options: {action: "list"}})`. +4. Deliver observed structure, intended structure, violations, consequences, and prioritized changes. Cite graph evidence for every finding. -## Workflow (do not skip steps) +## Required behavior -``` -1. graph_stats -> Orient -2. get_repo_outline -> Narrative single-call overview -3. get_communities -> Functional clusters via Louvain — the de facto module boundaries -4. get_processes -> Discovered execution flows — the de facto use cases -5. analyze({kind: "components"}) -> UI component tree (when applicable) — render hierarchy fan-in/out -6. analyze({kind: "routes"}) -> The API surface (HTTP / gRPC / GraphQL / WS / topic) -7. analyze({kind: "models"}) -> ORM models → tables — the data surface -8. analyze({kind: "k8s_resources"}) + analyze({kind: "images"}) -> Deployment topology (when applicable) -9. analyze({kind: "hotspots", top: 20}) -> Symbols under coupling stress -10. analyze({kind: "cycles"}) -> Architectural cycles (Tarjan SCC with severity) -11. analyze({kind: "would_create_cycle", from: "", to: ""}) -> Pre-flight before proposing a new dep in the review -12. analyze({kind: "cross_repo", base_kind: "calls"}) -> Inter-repo coupling (when multi-repo) -13. contracts({action: "list"}) -> Provider-side wire surface -14. contracts({action: "check"}) -> Provider ↔ consumer match across repos -15. get_class_hierarchy({id: ""}) -> Inheritance / implementation depth on load-bearing interfaces -16. get_dependencies / get_dependents at the community / file level -> Layering walk — does the dependency direction match the intent? -17. analyze({kind: "pubsub"}) + analyze({kind: "channel_ops"}) -> Async + concurrency topology -18. analyze({kind: "field_writers"}) + analyze({kind: "race_writes"}) -> Shared-state hotspots — the architecture's hidden mutable surface -19. analyze({kind: "stale_code", older_than: 365}) -> Strata that haven't moved in a year — likely the "stable core" -20. analyze({kind: "ownership"}) -> Per-author footprint per community — Conway's law check -21. export_context({format: "markdown", -> The review deliverable - sections: ["overview", "modules", "processes", - "wire_surface", "data_surface", "concurrency", - "cross_repo", "stress_points", "recommendations"]}) -``` - -## What the architecture review answers - -| Question | Tool | -| -------------------------------------------------------------- | ----------------------------------------------------------------------------- | -| **What are the de facto modules?** (not what's claimed) | `get_communities` — Louvain finds the actual cluster structure | -| **What are the de facto use cases?** | `get_processes` — discovered execution flows | -| **Where is the design under coupling stress?** | `analyze kind=hotspots top=20` — fan-in + fan-out + community crossings | -| **Are there cycles?** | `analyze kind=cycles` — Tarjan SCC with severity classification | -| **Does the dependency direction match the intent?** | Walk `get_dependencies` / `get_dependents` at the community / file level | -| **What is the wire surface?** | `analyze kind=routes` + `contracts({action: list})` | -| **What is the data surface?** | `analyze kind=models` + `analyze kind=orphan_tables` / `unreferenced_tables` | -| **What is the deployment topology?** | `analyze kind=k8s_resources` + `analyze kind=images` | -| **Where does shared state live?** | `analyze kind=field_writers` + `race_writes` — the mutable-state surface | -| **Where does the async + pub/sub topology lead?** | `analyze kind=pubsub` + `channel_ops` + `goroutine_spawns` + `unclosed_channels` | -| **Does the team's structure match the code's structure? (Conway)** | `analyze kind=ownership` projected onto `get_communities` — author overlap per community | -| **Where is the stable core?** | `analyze kind=stale_code older_than=365` — strata that haven't moved in a year | -| **Where would adding a new edge create a cycle?** | `analyze kind=would_create_cycle from=A to=B` — pre-flight for proposed deps | - -## Stress points - -Stress points are graph signals that the design is fighting itself. Surface them explicitly in the review: - -- **Cyclic dependency in the load-bearing layer** — `analyze kind=cycles severity=severe` ∩ a community with high `hotspots` rank -- **Architecture-spanning hotspot** — a symbol whose `community_crossings` is > 50% of its fan-in (the symbol "is" the integration layer, often by accident) -- **Mutable shared field reachable from a goroutine without a lock** — `analyze kind=race_writes` on a high-fan-in field -- **Contract orphan** — `contracts({action: check})` returns a provider with no consumer or a consumer with no provider, especially cross-repo -- **Inverted dependency** — a "lower" layer importing a "higher" one. Walk `get_dependencies` from each community to confirm directionality -- **Deployment ↔ code drift** — `analyze kind=images role=base ref=latest` (unpinned), `k8s_resources k8s_kind=ConfigMap` orphans - -## Multi-repo architecture - -When `get_active_project` shows >1 member: - -- `analyze kind=cross_repo base_kind=calls` — typed cross-repo edges; the count is the *contract surface* the architecture must hold stable -- `contracts({action: check})` partitioned by repo pair — orphan providers / consumers across the boundary -- For each cross-repo edge, `get_test_targets` returns the cross-repo tests that exercise it; the absence of those tests is itself an architecture finding - -## Review deliverable - -The output of an architecture review is a markdown narrative with embedded diagrams (Mermaid / DOT) generated from the graph: - -``` -export_context({ - task: "architecture review of ", - format: "markdown", - sections: [...] -}) -``` - -The packet rides the same surface as `/gortex-quality-audit` but is structured as a *narrative* (modules, processes, surfaces, stress, recommendations) rather than a flat punch list. - -## Checklist - -- `graph_stats` + `get_active_project` before any analyzer -- `get_repo_outline` for the narrative skeleton -- `get_communities` + `get_processes` are the architectural primitives — pin the review on these -- `analyze kind=hotspots` + `cycles` for stress points -- Wire surface = `analyze kind=routes` + `contracts list`; data surface = `analyze kind=models` + `orphan_tables`; deployment = `k8s_resources` + `images` -- Concurrency / async = `pubsub` + `channel_ops` + `goroutine_spawns` + `race_writes` + `unclosed_channels` -- Multi-repo: `cross_repo` + `contracts check` partitioned by repo pair -- `ownership` ∩ `communities` for Conway's law alignment -- `export_context format=markdown` for the narrative -- `store_memory({kind: "decision"})` for every architectural decision the review surfaces — the next agent inherits the rationale +- Native Gortex MCP is mandatory for this MCP-configured workflow. If its callable tools are missing, report a Gortex MCP integration failure and stop; do not start a daemon or switch to a CLI/shell fallback. +- Do not substitute shell reads, file search, or Git plumbing. +- Start task-shaped work with `explore`. For one already-known symbol, start with `search`. +- Use `change` before a mutation and again after it. Write only through `edit` or `refactor`. +- Call `capabilities({domain: "", operation: "", detail: "schema"})` only when an operation's exact arguments are unclear. +- Report graph-backed paths and symbol IDs. Never invent a result when an operation returns no match. === home/.hermes/skills/analysis/gortex-co-change/SKILL.md === --- name: gortex-co-change @@ -127,69 +46,21 @@ metadata: platforms: [linux, macos, windows] related_skills: [gortex] --- -# Co-change Analysis with Gortex +# Analyze Co-Change with Gortex -Use this when the user asks "what tends to change together with this?" — for refactor planning, ownership decisions, ADR rationale, or to find hidden coupling the graph's static edges don't capture (two files that always co-modify but have no import / call edge between them). Built on git blame + history + the symbol-churn surface `get_symbol_history` + the existing graph dependents view. +1. Resolve the anchor with `search({operation: "symbols", query: ""})`. +2. Call symbol-scoped `analyze({kind: "co_change", target: {symbol: ""}})` and repository-wide `analyze({kind: "churn"})`. +3. Compare structural coupling through `relations` operations `cluster` and `dependents`. +4. Report strong historical pairs, graph-confirmed dependencies, likely hidden coupling, and an actionable boundary or guard. -## Workflow (do not skip steps) +## Required behavior -``` -1. graph_stats -> Orient -2. gortex enrich blame all (CLI; once per session) -> Stamp last_authored / last_commit_at on every node -3. search_symbols({query: ""}) -> Resolve the anchor -4. get_recent_changes({since_ts: }) -> Per-file change frequency baseline -5. get_symbol_history -> Symbol-level churn this session (live hotspot signal) -6. analyze({kind: "blame", path_prefix: "/"}) -> Per-symbol last-author rollup; cross-ref with churn -7. analyze({kind: "ownership", path_prefix: "/"}) -> Per-author symbol / file counts — proxy for who-touches-it-most -8. analyze({kind: "hotspots", path_prefix: "/"}) -> Coupling-by-fan-in/out — graph-level vs git-level coupling -9. get_dependents({id: "", depth: 2}) -> Static dependent set (graph coupling) -10. find_clones({path_prefix: "/", dead_only: false}) -> Near-duplicate functions — copy-paste co-evolution signal -11. detect_changes({scope: "all"}) -> If a diff already exists, project it onto the graph -12. diff_context({scope: "all"}) -> Per-file risk + per-symbol callers/callees overlay -``` - -## Co-change vs graph coupling - -The two signals are complementary: - -| Signal | What it tells you | Tool | -| --------------------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------------- | -| **Graph coupling** | Static structure: who imports / calls / extends / implements whom | `get_dependents` / `get_dependencies` / `analyze kind=hotspots` | -| **Co-change coupling** | Historical / temporal: who tends to co-modify even without a static edge | `get_recent_changes` + `analyze kind=blame` + `get_symbol_history` | -| **Both align** | Healthy module — the structure reflects how the team edits it | Use either; co-change adds confidence | -| **Co-change > graph** | **Hidden coupling** — two files always co-modify but have no graph edge. Smell: missing abstraction, parallel hierarchies, copy-paste | `find_clones` is the corroborating evidence | -| **Graph > co-change** | Dead coupling — graph edge exists but the files don't co-evolve. Often safe to break | `analyze kind=dead_code` may flag the dependent | - -## Driving a refactor with co-change - -1. Pick the anchor (file / symbol the user wants to refactor) -2. Compute co-change set: symbols whose `meta.last_authored` clusters with the anchor's, AND whose churn (`get_symbol_history` + `get_recent_changes`) overlaps the anchor's -3. Compute graph-coupling set: `get_dependents` + `get_dependencies` (depth 2) -4. Diff the two sets — the **co-change ∖ graph** delta is the hidden-coupling list and the highest ROI for "extract a shared abstraction" -5. Pair with `find_clones` on the co-change set — clone clusters that span the boundary are the copy-paste hidden coupling -6. `store_memory({kind: "convention", body: "X always changes with Y because Z"})` so the next agent inherits the invariant rather than re-discovering it - -## Ownership signal - -`analyze kind=ownership` projected onto the co-change set tells you who-pings-who for review. Two files that co-modify under different primary authors is a *coordination* signal — the team is implicitly maintaining a contract neither file's CODEOWNERS captures. - -## Crossing the repo boundary - -When the anchor lives in a multi-repo project (`get_active_project` shows >1 member): - -- `analyze kind=cross_repo base_kind=calls repo=` gives the static cross-repo coupling -- For the temporal signal across repos, run `analyze kind=blame` per repo and look for authors who appear in both — that's the implicit cross-repo co-change channel - -## Checklist - -- `gortex enrich blame all` (CLI) at least once per session — the temporal analyzers need `meta.last_authored` -- `get_recent_changes` + `get_symbol_history` for the live churn signal -- `analyze kind=blame` + `ownership` for the per-symbol / per-author rollup -- `get_dependents` (graph coupling) **and** the churn surface (temporal coupling) — diff them; the delta is the hidden coupling -- `find_clones` to corroborate copy-paste-style hidden coupling -- `analyze kind=hotspots` for the graph-side coupling-by-fan-in/out view -- For multi-repo work: `analyze kind=cross_repo` on the static side + per-repo blame on the temporal side -- `store_memory({kind: "convention"})` when you discover a stable co-change relationship — saves the next agent from re-deriving it +- Native Gortex MCP is mandatory for this MCP-configured workflow. If its callable tools are missing, report a Gortex MCP integration failure and stop; do not start a daemon or switch to a CLI/shell fallback. +- Do not substitute shell reads, file search, or Git plumbing. +- Start task-shaped work with `explore`. For one already-known symbol, start with `search`. +- Use `change` before a mutation and again after it. Write only through `edit` or `refactor`. +- Call `capabilities({domain: "", operation: "", detail: "schema"})` only when an operation's exact arguments are unclear. +- Report graph-backed paths and symbol IDs. Never invent a result when an operation returns no match. === home/.hermes/skills/analysis/gortex-episode-replay/SKILL.md === --- name: gortex-episode-replay @@ -202,70 +73,22 @@ metadata: platforms: [linux, macos, windows] related_skills: [gortex] --- -# Episode Replay with Gortex (timeline reconstruction) +# Replay a Change Episode with Gortex -Use this when the user wants to reconstruct what happened in a specific window — for a postmortem, a "what shipped between v1.2 and v1.3", a "what did Alice touch last week", or "walk me through what this PR actually changed". Pairs git history with the graph so the replay shows blast radius alongside the diff, not just the file list. - -## Workflow (do not skip steps) - -``` -1. graph_stats -> Orient -2. gortex enrich blame all (CLI; once per session) -> Stamp meta.last_authored / meta.last_commit_at / meta.added_in -3. analyze({kind: "releases"}) -> Tag boundaries → maps a tag to the symbols added in it -4. analyze({kind: "blame", path_prefix: "/"}) -> Per-symbol last-author rollup -5. get_recent_changes({since_ts: }) -> Symbols touched in the window -6. analyze({kind: "ownership", path_prefix: "/"}) -> Per-author symbol/file counts in the window -7. detect_changes({scope: ""}) -> The change-set's graph projection -8. diff_context({scope: ""}) -> Graph-enriched diff: callers, callees, communities, per-file risk -9. For each material symbol in the window: - get_symbol_history({id: ""}) -> Per-session edit count (regression hotspot) - explain_change_impact({ids: ""}) -> Blast radius of that one change -10. query_notes({since: }) -> Session decisions / bug notes recorded in the window -11. query_memories({tag: "decision", since: }) -> Cross-session decisions in the window -12. export_context({format: "markdown", -> Hand the replay packet to PR / Slack / wiki - sections: ["changes", "impact", "decisions"]}) -``` +1. Run `analyze({kind: "replay", options: {from: "", to: ""}})` for the requested window. +2. Project the relevant diff with `change({operation: "detect", source: {scope: ""}})`. +3. Surface decisions with `recall({operation: "surface", arguments: {task: ""}})`. +4. Trace important before/after paths using `trace` and identify which change altered behavior. +5. Produce a timestamped narrative with evidence links, impact, missed signals, and remaining uncertainty. -## Replay shapes +## Required behavior -| Goal | Driver query | -| ----------------------------------------------------- | ------------------------------------------------------------------------ | -| "What shipped in v1.3?" | `analyze kind=releases` → symbols where `meta.added_in == "v1.3"`; then `diff_context` between v1.2..v1.3 tags | -| "What did Alice change last week?" | `analyze kind=ownership` filtered to Alice → cross-ref with `get_recent_changes since_ts=now-7d` | -| "What did this incident touch?" | Start with `/gortex-incident-investigation` → use its root-cause symbol set as the seed; replay walks `explain_change_impact` on each | -| "Postmortem narrative for a one-day window" | Full chain above + `query_notes` + `query_memories` so commentary rides next to code in the markdown | -| "What did this PR actually change beyond the diff?" | `detect_changes scope=staged` + `diff_context` + `explain_change_impact` per symbol → second-order blast that the literal diff hides | - -## Reading the replay output - -- `detect_changes` is the change-set's *graph* projection — every node a touched file owns, plus every directly-affected dependent. It is broader than `git diff --stat` because it captures symbols whose *meaning* changed, not just files whose *bytes* changed. -- `diff_context` adds the callers / callees / community / processes / per-file risk overlay. This is the artifact you embed in the PR description or postmortem. -- Per-symbol `explain_change_impact` surfaces the *second-order* blast (what depends on the things that changed). The replay is incomplete without this for any change touching a fan-in-heavy symbol. - -## Crossing the merge boundary - -For replays that span a merge or rebase, prefer `detect_changes scope=since-tag` over a literal commit-range diff. The graph projection survives rebases that the literal diff doesn't. - -When the replay is across a release boundary, `analyze kind=releases` is the bridge: it stamps `meta.added_in` from git tags onto file nodes, so "symbols introduced in v1.3" is one filter away. - -## Annotating the replay with prior context - -`query_notes` and `query_memories` are how prior session decisions and cross-session invariants enter the timeline: - -- `query_notes({since: })` — every session note authored in the window; tag-filterable (`decision` / `bug` / `gotcha` / `follow-up`). -- `query_memories({tag: "decision", since: })` — durable cross-session decisions stamped in the window. - -These two surfaces are how a postmortem reads as a story rather than a list of file paths. - -## Checklist - -- `gortex enrich blame all` (CLI) at least once per session — the blame / releases / ownership analyzers need `meta.last_authored` to be populated -- `analyze kind=releases` for release-boundary replays -- `get_recent_changes` + `analyze kind=ownership` for the window-scoped change set -- `detect_changes` + `diff_context` to project the diff onto the graph -- `explain_change_impact` on every material symbol — second-order blast or it didn't happen -- `query_notes` + `query_memories` to ride commentary alongside the code timeline -- `export_context format=markdown` so the replay leaves the session as a shareable artifact +- Native Gortex MCP is mandatory for this MCP-configured workflow. If its callable tools are missing, report a Gortex MCP integration failure and stop; do not start a daemon or switch to a CLI/shell fallback. +- Do not substitute shell reads, file search, or Git plumbing. +- Start task-shaped work with `explore`. For one already-known symbol, start with `search`. +- Use `change` before a mutation and again after it. Write only through `edit` or `refactor`. +- Call `capabilities({domain: "", operation: "", detail: "schema"})` only when an operation's exact arguments are unclear. +- Report graph-backed paths and symbol IDs. Never invent a result when an operation returns no match. === home/.hermes/skills/analysis/gortex-impact/SKILL.md === --- name: gortex-impact @@ -278,51 +101,22 @@ metadata: platforms: [linux, macos, windows] related_skills: [gortex] --- -# Impact Analysis with Gortex +# Assess Change Impact with Gortex -## Workflow +1. Resolve the target with `search({operation: "symbols", query: ""})`. +2. Query `relations` operations `usages`, `dependents`, and `implementations` for the resolved symbol. +3. Call `change({operation: "impact", source: {symbols: [""]}})`; add operation `api_impact` for a public API. +4. For a signature change, require `change({operation: "verify", source: {changes: [{symbol_id: "", new_signature: ""}]}})`. +5. Report direct callers, transitive risk, interfaces, contracts, and the tests returned by `change({operation: "tests", source: {symbols: [""]}})`. -``` -1. search_symbols({query: "X"}) -> Find the symbol ID -2. explain_change_impact({ids: ", "}) -> Risk-tiered blast radius -3. get_dependents({id: "", depth: 3}) -> Detailed dependent tree -4. analyze({kind: "ownership", path_prefix: "/"}) -> Who owns this area (review pinging) -5. verify_change({id: "", new_signature: "..."}) -> Check callers + interface implementors for signature-level breaks -6. contracts({action: "check"}) -> Cross-repo API breakage (HTTP/gRPC/GraphQL/topics) -7. analyze({kind: "would_create_cycle", from: "", to: ""}) -> Before adding a new dep -8. analyze({kind: "error_surface", path_prefix: "/"}) -> What error surface does this area produce — widening risk -9. get_test_targets({ids: ["", ""]}) -> Tests to re-run (includes cross-repo) -10. analyze({kind: "coverage_gaps", path_prefix: "/"}) -> Undertested code in the change area — extra-risky refactor zones -11. check_guards({ids: [""]}) -> Project guard rules from .gortex.yaml -12. flow_between({source_id, sink_id}) -> Ranked dataflow paths between two symbols — catches consumers reached through helpers that get_dependents misses -13. taint_paths({source_pattern, sink_pattern}) -> Pattern-driven dataflow sweep — every flow from a matching source to a matching sink -14. detect_changes({scope: "staged"}) -> Pre-commit scope check -15. diff_context({scope: "staged"}) -> Graph-enriched diff for review -``` +## Required behavior -## Understanding Output - -| Depth | Risk Level | Meaning | -| ----- | ---------------- | ------------------------ | -| d=1 | **WILL BREAK** | Direct callers/importers | -| d=2 | LIKELY AFFECTED | Indirect dependencies | -| d=3 | MAY NEED TESTING | Transitive effects | - -## Checklist - -- search_symbols to find exact symbol IDs -- explain_change_impact with all symbols you plan to change -- Review risk level (LOW/MEDIUM/HIGH/CRITICAL) -- Check by_depth: d=1 items WILL BREAK -- Note affected_processes and affected_communities -- analyze kind=ownership path_prefix=/ — who should review (pinging policy without CODEOWNERS) -- verify_change for every signature change (catches contract violations across repos) -- contracts action=check when changing HTTP routes, gRPC methods, topics, env contracts -- analyze kind=error_surface path_prefix=/ — confirm the change does not widen the error surface -- analyze kind=coverage_gaps path_prefix=/ — areas with weak coverage need extra scrutiny -- check_guards so team conventions from .gortex.yaml block bad changes early -- get_test_targets to see which test files need re-running -- Before commit: detect_changes to verify scope, diff_context for graph-enriched review +- Native Gortex MCP is mandatory for this MCP-configured workflow. If its callable tools are missing, report a Gortex MCP integration failure and stop; do not start a daemon or switch to a CLI/shell fallback. +- Do not substitute shell reads, file search, or Git plumbing. +- Start task-shaped work with `explore`. For one already-known symbol, start with `search`. +- Use `change` before a mutation and again after it. Write only through `edit` or `refactor`. +- Call `capabilities({domain: "", operation: "", detail: "schema"})` only when an operation's exact arguments are unclear. +- Report graph-backed paths and symbol IDs. Never invent a result when an operation returns no match. === home/.hermes/skills/analysis/gortex-pr-review-agent/SKILL.md === --- name: gortex-pr-review-agent @@ -335,65 +129,21 @@ metadata: platforms: [linux, macos, windows] related_skills: [gortex] --- -# PR Review as a sub-agent (shell the review verb) +# Review a Change as a Sub-Agent -Use this when you are a coding agent (Codex / Claude Code / any CLI agent) and need a graph-grounded review verdict on a pending change **without** hand-walking the ten review gates yourself. Instead of orchestrating `detect_changes` / `diff_context` / `verify_change` / … one call at a time, shell the `gortex review` verb once and act on its terse, machine-first output. +## MCP-capable harness -The review engine runs the deterministic correctness rulepack, grounds every finding to an exact `file:line`, optionally folds in LLM findings, gates by confidence / severity, and prints a verdict — all server-side. You consume the result. +Native Gortex MCP is mandatory. Call `review({operation: "run", source: {scope: ""}})`, then use `change` operations `guards`, `tests`, and `contract`. When the proposed signature is known, run operation `verify` before mutation. If the configured callable tools are missing, report a Gortex MCP integration failure and stop; do not start a daemon or use the Bash path below. -## Run the verb +## Bash-only harness -```bash -# Terse, prose-free summary an agent can parse (one-line verdict + compact findings + cost): -gortex review --audience agent +Use this path only when the harness has no MCP transport by design: -# The same review as structured JSON when you want to branch on fields programmatically: +```bash gortex review --audience agent --format json ``` -Select the changeset the same way a human would: - -- `--scope unstaged|staged|all|compare` — which working-tree changes to review (default `unstaged`) -- `--base ` — review everything since a base ref (shorthand for compare) -- `--diff ` (or `--diff -` for stdin) — review a pasted unified diff instead of git -- `--use-llm` — additionally fold in LLM-found findings (needs a configured provider) -- `--repo ` — the repo the daemon tracks (default: current directory) - -A daemon that tracks the repo must be running (`gortex daemon status`). The verb relays to the daemon; you never start the engine yourself. - -## Read the terse output - -`--audience agent` prints exactly three things, no narrative: - -``` -VERDICT: block (1 critical, 2 error) -findings: - internal/svc/handler.go:7 error go-inverted-err-check — inverted error check - internal/svc/loop.go:12 warning go-loop-query-call — query in loop -cost: in=1234 out=456 cache_r=2000 cache_w=0 usd=0.012000 elapsed=4.2s -``` - -- **Line 1** is the verdict — `block` / `review` / `approve` — with the kept-finding severity histogram in parentheses. -- The **findings** block has one compact line per finding: `file:line severity rule — message`. Each is anchored to a real new-side line, so you can open the file at that line directly. -- The **cost** line is the per-review token + USD accounting. - -## Act on the verdict - -| Verdict | What to do | -| ------- | ---------- | -| `block` | Do **not** merge / proceed. Fix every `critical` / `error` finding at its `file:line`, then re-run `gortex review --audience agent` until the verdict clears. | -| `review` | Address the `warning` findings or justify each one in the PR thread before merging. | -| `approve` | No blocking findings — proceed. | - -When you want the full reasoning behind a verdict (per-file risk, contracts, guards, coverage), drop the `--audience agent` flag for the readable human packet, or use the `/gortex-pr-review` playbook to walk the ten gates by hand. - -## Checklist - -- `gortex daemon status` first — the verb needs a daemon tracking the repo -- `gortex review --audience agent` for the terse, parseable summary; add `--format json` to branch on fields -- Open each finding at its printed `file:line` — the anchor is exact, not approximate -- On `block`, fix and **re-run the verb** until it clears; do not merge a blocking verdict -- Escalate to the human packet (drop `--audience agent`) or `/gortex-pr-review` when you need the full gate-by-gate reasoning +In either mode, return `VERDICT: clean` or `VERDICT: findings` followed by actionable findings with severity and `file:line`. Do not replace graph-backed review with ad-hoc `git diff` inspection. === home/.hermes/skills/analysis/gortex-pr-review/SKILL.md === --- name: gortex-pr-review @@ -406,116 +156,22 @@ metadata: platforms: [linux, macos, windows] related_skills: [gortex] --- -# PR Review with Gortex (graph-grounded change review) - -Use this when the user wants a code-review pass on a pending change — local staged diff, a branch about to merge, or a PR they're reading. The review walks the diff through the graph so the comments are grounded in real callers / contracts / coverage / guards, not surface-level style nitpicks. - -## Workflow (do not skip steps) - -``` -1. graph_stats -> Orient -2. detect_changes({scope: "staged"}) -> The change-set's graph projection (use "all" / "since-tag" as needed) -3. diff_context({scope: "staged"}) -> Graph-enriched diff: callers, callees, communities, processes, per-file risk -4. For each changed symbol: - explain_change_impact({ids: ""}) -> Risk-tiered blast radius - verify_change({id: "", new_signature: ""}) -> Catch interface / contract breaks -5. contracts({action: "check"}) -> Provider ↔ consumer match across repos (HTTP / gRPC / topics / env / OpenAPI) -6. check_guards({ids: []}) -> Team conventions from .gortex.yaml -7. analyze({kind: "would_create_cycle", from: "", to: ""}) -> If the diff adds a new import edge, pre-flight it -8. analyze({kind: "coverage_gaps", path_prefix: "/", -> Did the change touch undertested code? - min_pct: 0, max_pct: 50}) -9. get_untested_symbols({path_prefix: "/"}) -> Symbols still uncovered after the change -10. get_test_targets({ids: []}) -> Tests to re-run (cross-repo aware) -11. find_clones({path_prefix: "/", dead_only: true}) -> Did the change leave dead duplicates behind? -12. analyze({kind: "error_surface", path_prefix: "/"}) -> Did the change widen what gets thrown? -13. analyze({kind: "cross_repo", base_kind: "calls"}) -> Cross-repo blast (when multi-repo) -14. For high-risk changed symbols (d=1 in explain_change_impact): - preview_edit({edit: }) -> Speculative apply on the shadow graph; reports broken_callers / broken_implementors -15. surface_memories({task: "review ", symbol_ids: [...]}) -> Cross-session invariants on the touched symbols -16. export_context({format: "markdown", -> Review packet for the PR thread - sections: ["scope", "impact", "contracts", - "guards", "coverage", "tests", - "recommendations"]}) -``` - -## Review priorities - -Walk every PR through these gates, in order. A failure at gate N is a blocker — do not move to gate N+1 until N is addressed: +# Review a Change with Gortex -| Gate | Tool | Blocker if … | -| ---- | ----------------------------------------------- | ------------ | -| **1. Scope** | `detect_changes` | Touches files / symbols the PR description doesn't mention | -| **2. Signature safety** | `verify_change` per signature change | Callers / implementors break | -| **3. Contract safety** | `contracts({action: check})` | Orphan providers / consumers; cross-repo wire drift | -| **4. Convention compliance** | `check_guards` | Project rules from `.gortex.yaml` violated | -| **5. Coupling sanity** | `analyze kind=would_create_cycle` | New import edge introduces a cycle | -| **6. Coverage hygiene** | `analyze kind=coverage_gaps` + `get_untested_symbols` | Changed code is uncovered or under-covered | -| **7. Test discoverability** | `get_test_targets` | The PR adds a symbol with no covering test target | -| **8. Dead duplication** | `find_clones dead_only=true` | Refactor left a dead duplicate of live code | -| **9. Blast verification** | `preview_edit` on high-risk changes | Speculative apply reports `broken_callers` or `broken_implementors` that the diff doesn't address | -| **10. Memory check** | `surface_memories` on touched symbols | Diff contradicts a pinned invariant / decision / gotcha | +1. Project the working tree with `change({operation: "detect", source: {scope: "unstaged"}})`; use `staged` or `compare` only when that is the requested review scope. +2. Build review context with `review({operation: "run", source: {scope: ""}})` and operation `diff_context` when per-file context is needed. +3. Require `change` operations `guards`, `tests`, and `contract`. When the proposed signature is known, run operation `verify` before mutation. +4. Check wire boundaries with `analyze({kind: "contracts", options: {action: "check"}})` when APIs or events changed. +5. Report only actionable findings with severity, `file:line`, evidence, consequence, and correction. State an explicit clean verdict when none remain. -## Cross-repo PRs +## Required behavior -When the PR touches a multi-repo project: - -- `detect_changes` only sees the local repo's changes; also run `contracts({action: check})` to flag wire-side breakage in consumer repos -- `analyze kind=cross_repo base_kind=calls repo=` lists every consumer call that crosses out of the changed repo -- `get_test_targets` returns the cross-repo tests that exercise the affected wire path — those tests must be on the reviewer's run list - -## Review deliverable - -Hand the PR author a structured comment block, not a stream-of-consciousness comment: - -```markdown -## Review — - -### Scope -- ✅ / ⚠ — `detect_changes` matches the PR description -- Touched: N files, M symbols, K cross-repo edges - -### Impact -- High-risk: · d=1 blast — see `explain_change_impact` -- Medium: … - -### Contracts -- ✅ / ❌ — `contracts({action: check})` summary - -### Guards -- ✅ / ❌ — `check_guards` summary - -### Coverage -- Uncovered after this PR: (`get_untested_symbols`) -- Coverage gap deltas: … - -### Tests -- `get_test_targets` recommends: … - -### Speculative apply (for high-risk changes) -- `preview_edit` flagged: broken_callers=…, broken_implementors=… - -### Recommendations -1. … -2. … -``` - -`export_context format=markdown sections=[...]` produces this skeleton automatically. - -## When the diff is already on disk - -If the diff is in the working tree (or a feature branch checked out), `detect_changes` + `diff_context` see it directly. If the user pasted in a unified diff or a GitHub URL, parse it into a synthetic `WorkspaceEdit` and drive `preview_edit` to get the speculative report — same blast / broken-callers / broken-implementors signal without touching disk. - -## Checklist - -- `index_health` — confirm Gortex sees the working tree (`graph_stats` for counts) -- `detect_changes` + `diff_context` produce the graph-grounded scope + per-file risk -- Walk the **10 gates** above in order; do not skip ahead on a blocker -- `contracts({action: check})` is **mandatory** for any PR that touches a provider symbol — the orphan check catches wire drift the diff doesn't -- `check_guards` runs the team's `.gortex.yaml` rules — these encode conventions a reviewer would otherwise have to remember -- For high-risk changes, `preview_edit` the diff as a `WorkspaceEdit` and read `broken_callers` / `broken_implementors`; the speculative report is the highest-confidence signal in the review -- `surface_memories` on the touched symbols before finalising the review — a diff that contradicts a pinned invariant is a blocker, not a nit -- `export_context format=markdown` for the structured review block; never hand the user a stream-of-consciousness comment -- Cross-repo PRs: `contracts({action: check})` + `analyze kind=cross_repo` + `get_test_targets` (cross-repo aware) +- Native Gortex MCP is mandatory for this MCP-configured workflow. If its callable tools are missing, report a Gortex MCP integration failure and stop; do not start a daemon or switch to a CLI/shell fallback. +- Do not substitute shell reads, file search, or Git plumbing. +- Start task-shaped work with `explore`. For one already-known symbol, start with `search`. +- Use `change` before a mutation and again after it. Write only through `edit` or `refactor`. +- Call `capabilities({domain: "", operation: "", detail: "schema"})` only when an operation's exact arguments are unclear. +- Report graph-backed paths and symbol IDs. Never invent a result when an operation returns no match. === home/.hermes/skills/analysis/gortex-quality-audit/SKILL.md === --- name: gortex-quality-audit @@ -528,118 +184,25 @@ metadata: platforms: [linux, macos, windows] related_skills: [gortex] --- -# Quality Audit with Gortex (repo health scan) - -Use this when the user wants a structured pass over a repo / directory looking for code-quality issues at scale — dead code, hotspots, cycles, churn, todos, coverage gaps, clones, anti-pattern smells, configuration drift. The output is a prioritised punch list, not a verdict; each finding is grounded in a graph query the user can re-run. - -## Workflow (do not skip steps) - -``` -1. graph_stats -> Orient -2. gortex enrich blame coverage releases all (CLI; once) -> Stamp the metadata the temporal + coverage analyzers need -3. analyze({kind: "dead_code", path_prefix: "/"}) -> Symbols with zero incoming edges (excludes entry points + tests) -4. find_clones({path_prefix: "/", dead_only: true}) -> Dead duplicates of live code — the segment-unique diagnostic -5. analyze({kind: "hotspots", path_prefix: "/"}) -> Over-coupled symbols by fan-in / fan-out / community crossings -6. analyze({kind: "cycles", path_prefix: "/"}) -> Tarjan SCCs with severity -7. analyze({kind: "todos", path_prefix: "/"}) -> TODO / FIXME / HACK nodes -8. analyze({kind: "stale_code", older_than: 365, path_prefix: "/"}) -> Code untouched 1y+ — refactor / delete candidates -9. analyze({kind: "stale_flags", older_than: 180}) -> Feature flags whose every toggler is older than 6mo -10. analyze({kind: "coverage_summary", path_prefix: "/"}) -> Per-directory coverage rollup -11. analyze({kind: "coverage_gaps", path_prefix: "/", -> Undertested symbols - min_pct: 0, max_pct: 50}) -12. get_untested_symbols({path_prefix: "/"}) -> Symbols with zero covering tests -13. analyze({kind: "error_surface", path_prefix: "/"}) -> Functions and what they throw — risk concentration -14. analyze({kind: "field_writers"}) -> Mutability hotspots — fields ranked by write count -15. analyze({kind: "race_writes"}) -> Cross-language goroutine-reachable writes w/o lock -16. analyze({kind: "unclosed_channels"}) -> Channels with sends but no close() -17. analyze({kind: "orphan_tables"}) -> Tables queried but missing a migration -18. analyze({kind: "unreferenced_tables"}) -> Tables provided by a migration with zero readers -19. analyze({kind: "stale_flags"}) -> Dead-rollout flag candidates (rerun with smaller window if noisy) -20. analyze({kind: "sast", path_prefix: "/", severity: "high"}) -> CWE/OWASP-tagged security scan — 190+ rules across 8 languages -21. analyze({kind: "named", name: ""}) -> Named query bundles — sql-injection, hardcoded-secrets, ssrf, xxe, weak-crypto, … -22. analyze({kind: "unsafe_patterns", path_prefix: "/"}) -> Panic-prone / undefined-behavior primitives across all languages -23. search_ast({detector: ""}) -> Targeted structural anti-pattern sweep (see list_inspections for the menu) -24. analyze({kind: "health_score", path_prefix: "/", -> Composite per-file health grade — ranks the worst files first - roll_up: "file"}) -25. audit_agent_config -> Stale references in CLAUDE.md / AGENTS.md / .cursor/rules — config drift -26. contracts({action: "check"}) -> Orphan providers / consumers; HTTP / gRPC / topics / env drift -27. analyze({kind: "ownership", path_prefix: "/"}) -> Per-author rollup — who to ping per finding -28. export_context({format: "markdown", -> Hand the audit packet to PR / Slack / wiki - sections: ["findings", "ownership", "priorities"]}) -``` - -## Prioritising findings - -Findings are not equal. Rank by: - -| Tier | Filter | Why this matters first | -| ---- | ---------------------------------------------------------------------------------------- | ---------------------- | -| **P0** | `analyze kind=sast severity=high` / `analyze kind=named` / `race_writes` / `unclosed_channels` / `weak-crypto` / `hardcoded-secret` / `http-client-no-timeout` | Correctness / security | -| **P1** | `cycles severity=severe` / `orphan_tables` / `contracts({action: check})` orphans / `audit_agent_config` stale refs | Real bugs latent in the graph | -| **P2** | `dead_code` ∩ `find_clones dead_only=true` / `stale_flags` / `stale_code` | Deletion candidates — easy ROI | -| **P3** | `hotspots top=20` / `coverage_gaps min_pct=0 max_pct=20` / `error_surface` widening | Refactor targets | -| **P4** | `todos` / `field_writers` heavy churn | Backlog signal | - -Always pair every P0 / P1 finding with `analyze kind=ownership` on its path so the audit packet pings the right reviewer. - -## Security & anti-pattern sweep - -`analyze kind=sast` is the comprehensive pass — 190+ CWE/OWASP-tagged rules across 8 languages, filterable by `severity` / `cwe` / `tag`. `analyze kind=named` runs focused query bundles (sql-injection, hardcoded-secrets, ssrf, xxe, …). `search_ast` then targets specific structural smells — a representative subset of its bundled detectors: - -| Detector | Languages | -| --------------------------------- | ------------------------------------ | -| `error-not-wrapped` | Go | -| `sql-string-concat` | Go / Python / JS / TS / Ruby | -| `weak-crypto` | Go / Python | -| `panic-in-library` | Go | -| `goroutine-without-recover` | Go | -| `http-client-no-timeout` | Go | -| `hardcoded-secret` | Go / Python / JS / TS / Ruby | -| `empty-catch` | Java / JS / TS / Python | -| `java-string-equality` | Java | -| `python-mutable-default-arg` | Python | - -Run each `search_ast detector=` once; the per-match `symbol_id` chains directly into `find_usages` / `apply_code_action` for follow-up. - -## Audit deliverable - -The output of a quality audit is a prioritised markdown packet, not a one-line "score": - -``` -# audit — - -## P0 — Correctness / security -- · · owner: · evidence: +# Audit Repository Quality with Gortex -## P1 — Real bugs latent in the graph -… +1. Establish scope with `workspace({operation: "info"})` and `explore({operation: "outline"})`. +2. Run `analyze` for `health`, `dead_code`, `hotspots`, `cycles`, and `clones`. +3. Validate high-risk findings with `read` and `relations`; do not report an unverified heuristic as a fact. +4. Rank findings by evidence, impact, and remediation cost. Include exact paths, symbol IDs, and a smallest-first action plan. -## P2 — Deletion candidates -… +## Required behavior -## P3 — Refactor targets -… - -## P4 — Backlog signal -… -``` - -`export_context format=markdown` is the producer. - -## Checklist - -- `gortex enrich blame coverage releases all` (CLI) before the audit — temporal + coverage analyzers need `meta.last_authored` / `meta.coverage_pct` / `meta.added_in` -- Walk the analyzers in the order above — earlier ones surface highest-priority findings -- Pair every finding with `analyze kind=ownership` so the audit packet has a routing column -- `analyze kind=sast` is the security backbone; add `search_ast` detectors for the language-specific smells it doesn't cover -- `audit_agent_config` catches stale references in the team's CLAUDE.md / AGENTS.md / IDE config — config drift is a real-world finding -- Rank by the P0..P4 tiers; do not hand the user a flat list -- `export_context format=markdown` for the packet -- `store_memory({kind: "reference", title: " audit YYYY-MM", body: "..."})` so the next audit can diff against this baseline +- Native Gortex MCP is mandatory for this MCP-configured workflow. If its callable tools are missing, report a Gortex MCP integration failure and stop; do not start a daemon or switch to a CLI/shell fallback. +- Do not substitute shell reads, file search, or Git plumbing. +- Start task-shaped work with `explore`. For one already-known symbol, start with `search`. +- Use `change` before a mutation and again after it. Write only through `edit` or `refactor`. +- Call `capabilities({domain: "", operation: "", detail: "schema"})` only when an operation's exact arguments are unclear. +- Report graph-backed paths and symbol IDs. Never invent a result when an operation returns no match. === home/.hermes/skills/code-intelligence/gortex-cli/SKILL.md === --- name: gortex-cli -description: "Drive Gortex from the shell via the gortex CLI, no MCP surface." +description: "Bash-only mirror of Gortex MCP tools for harnesses without MCP support." version: 1.0.0 metadata: hermes: @@ -648,102 +211,33 @@ metadata: platforms: [linux, macos, windows] related_skills: [gortex] --- +# Gortex from a Bash-Only Harness -# Gortex from the CLI (no MCP surface) - -Every Gortex capability is reachable from the `gortex` command, so you can work the graph from a plain shell — no MCP server mounted, no extra tools in your context. Reach for this when the MCP surface is unavailable or impractical (a CI job, a low-context budget, a shell-only agent), or when you simply prefer the command line. - -This is a **toolkit, not a mandate**: pick the verbs your task needs. The sequence below is a reference safety workflow for changing code — run the steps that apply and skip the rest. Most verbs accept `--format json` for machine-readable output and `--repo ` to target a tracked repo other than the cwd. They all talk to a running daemon, so `gortex daemon status` first if anything errors. - -## Orient - -```bash -gortex query stats -``` - -Confirms the daemon is up and prints node / edge counts for the active repo — the CLI analogue of a session-start orientation. +Use this skill only in a harness that has no MCP transport by design. If a Gortex MCP server is configured but its callable tools are missing, report an integration failure; do not use this skill as a fallback. -## Assemble the working set +Every public MCP tool has the same name through `gortex call`: ```bash -gortex explore "make the indexer skip vendored files" +gortex call explore --arg task='locate the authentication flow' +gortex call search --arg operation=symbols --arg query=UserStore +gortex call read --arg target='{"symbol":"internal/store.go::UserStore"}' +gortex call relations --arg operation=usages --arg target='{"symbol":"internal/store.go::UserStore"}' +gortex call change --arg operation=impact --arg target='{"symbol":"internal/store.go::UserStore"}' +gortex call edit --arg target='{"file":"internal/store.go"}' --arg match='old text' --arg replacement='new text' +gortex call change --arg operation=detect --arg source='{"scope":"all"}' ``` -Shells smart_context: one call returns the minimal relevant symbols, sources, and an edit plan for a task description. Start here instead of reading files one at a time. Note the symbol IDs it surfaces — later verbs take them as `--ids` / `--symbols`. - -## Pick up prior knowledge - -```bash -gortex memory surface --task "make the indexer skip vendored files" --symbols internal/indexer/indexer.go::IndexFile,internal/indexer/skip.go::shouldSkip -``` - -Surfaces cross-session invariants, decisions, and gotchas anchored to your working set, ranked by overlap. If it returns nothing, move on — don't probe further. - -## Before editing a file +For an exact operation schema: ```bash -gortex edit context internal/indexer/indexer.go +gortex call capabilities --arg domain=read --arg operation=source --arg detail=schema ``` -Shows the file's symbols, signatures, callers, and callees so you edit with the blast radius in view. - -## Before a signature change - -```bash -gortex edit verify --change 'internal/indexer/indexer.go::IndexFile=func(path string, opts Opts) error' -``` - -Checks every caller and interface implementor against the proposed signature and reports what would break. `--change` is repeatable (`id=new_signature`); pass a raw JSON array via `--changes` / `--changes-file` for bulk edits. - -## Plan a multi-symbol refactor - -```bash -gortex edit plan --ids internal/indexer/indexer.go::IndexFile,internal/indexer/skip.go::shouldSkip -``` - -Returns a dependency-ordered list of files and symbols to touch, so a multi-file change goes in the right order. - -## Apply edits atomically - -```bash -gortex edit batch --edits-file edits.json -``` - -Applies multiple edits in one atomic, dependency-ordered pass. Build `edits.json` as the edits array (or pass it inline with `--edits`, or on stdin with `--edits -`); add `--dry-run` to preview the unified diff first. - -## After editing - -```bash -gortex edit guards --ids internal/indexer/indexer.go::IndexFile -gortex edit tests --ids internal/indexer/indexer.go::IndexFile -``` - -`guards` evaluates team rules against the changed symbols; `tests` enumerates the test files and functions that exercise them so you know what to run. - -## Close the loop - -```bash -gortex feedback record --task "make the indexer skip vendored files" --useful internal/indexer/skip.go::shouldSkip -gortex memory store --kind decision --body "skip lives in shouldSkip; IndexFile stays signature-stable for the daemon hot path" --symbols internal/indexer/skip.go::shouldSkip -``` - -`feedback record` scores which `explore` suggestions were useful / not needed / missing, improving future context quality. `memory store` persists a durable invariant / decision / gotcha so the next agent inherits the lesson — set `--kind` honestly and anchor it with `--symbols`. - -## When you need more - -These verbs cover the dev-cycle workhorses. For the long tail of the catalog, discover and invoke any tool directly: - -```bash -gortex tools list # the whole surface, by category + read/write class -gortex tools search "dataflow taint" # find a tool by keyword -gortex call analyze --arg kind=dead_code --arg path=internal/indexer -``` - -`gortex call --arg key=value` is the generic escape hatch — it invokes any registered tool by name, so nothing in the MCP surface is out of reach from the shell. There are also dedicated verb groups worth knowing: `gortex analyze`, `gortex flow` / `gortex taint`, `gortex clones`, and the rest of the `gortex edit` and `gortex memory` families. +The required order is `explore` → targeted `search/read/relations/trace` → pre-change `change` → `edit/refactor` → post-change `change`. Do not use shell file reads or search as a substitute. === home/.hermes/skills/code-intelligence/gortex/SKILL.md === --- 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." +description: "Use Gortex for indexed-code exploration, reads, relationships, impact checks, edits, and refactors." version: 1.0.0 metadata: hermes: @@ -753,92 +247,20 @@ metadata: related_skills: [gortex-add-test, gortex-architecture-review, gortex-cli, gortex-co-change, gortex-cross-repo-usage, gortex-dataflow-trace, gortex-debug, gortex-episode-replay, gortex-explore, gortex-extract-function, gortex-fix-all, gortex-impact, gortex-incident-investigation, gortex-onboarding, gortex-pr-review, gortex-pr-review-agent, gortex-quality-audit, gortex-refactor, gortex-rename, gortex-safe-edit] --- -# 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 `). -- 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 --arg k=v` (e.g. `gortex call read_file --arg path=`) — there is no bare `gortex ` 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 (~30–40% of original tokens). +# Gortex code intelligence -## Pitfalls +Use Gortex MCP tools for indexed code. This is mandatory. -- 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. +1. Start every coding task with `explore` using `operation: "task"` and the task text. +2. Use `search` for symbols, text, files, or AST shapes. Use `read` for source, summaries, files, or editing context. +3. Use `relations` for usages, callers, dependencies, dependents, and implementations. Use `trace` for call chains and dataflow. +4. Before mutation, call `change` with `operation: "impact"`; for a signature change, also call operation `verify` with the proposed signature. +5. Mutate only with `edit` or `refactor`. After mutation, call `change` operations `detect`, `tests`, `guards`, and `contract`. +6. Call `capabilities` with `domain`, `operation`, and `detail: "schema"` when exact arguments are not visible. -## Verification +Do not replace graph reads or searches with terminal commands. If the configured Gortex tools are missing from the callable MCP tools, report a Gortex MCP integration failure and stop; do not start a daemon or use a CLI/shell fallback. -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. +Use `workspace` for local index, repository, and project state. Use `workspace_admin` only when the user asks to change that state. Use `recall` before editing known code and `remember` for durable decisions or invariants. ## Task playbooks (slash commands) @@ -876,42 +298,22 @@ metadata: platforms: [linux, macos, windows] related_skills: [gortex] --- -# Debugging with Gortex +# Debug with Gortex -## Workflow +1. Localize the exact symptom with `explore({operation: "task", task: ""})`. +2. For a literal error, call `search({operation: "text", query: ""})`. For a name, use operation `symbols`. +3. Walk toward the cause with `relations({operation: "callers", target: {symbol: ""}})` and `trace({operation: "call_chain", target: {symbol: ""}})`. Use `flow` or `taint` only after resolving both `target` and `to` endpoints. +4. Confirm repository-wide error propagation with `analyze({kind: "error_surface"})`; use `options.repo` to narrow a multi-repository workspace. +5. Before fixing, run `change({operation: "impact", source: {symbols: [""]}})`. After fixing, run `change` operations `detect`, `tests`, and `guards`. -``` -1. search_symbols({query: ""}) -> Find related symbols -2. get_callers({id: ""}) -> Who calls it? -3. get_call_chain({id: ""}) -> What does it call? -4. get_editing_context({path: ""}) -> Full file context -5. get_processes({id: ""}) -> Trace execution flow -6. get_symbol_history -> Symbols churning this session (regression hotspot) -7. explain_change_impact({ids: ""}) -> Who else will feel the fix -``` +## Required behavior -## Debugging Patterns - -| Symptom | Gortex Approach | -| ----------------------- | --------------- | -| Error message | search_symbols for error-related names -> get_callers on throw sites; analyze kind=error_surface to map who throws what | -| Wrong return value | get_call_chain on the function -> trace callees for data flow; flow_between({source_id, sink_id}) when you suspect the wrong value flows through helpers | -| Trace bad value to its origin | flow_between({source_id: producer, sink_id: consumer}) — ranked dataflow paths over value_flow / arg_of / returns_to. Faster than reading source for "where did this value come from?" | -| Find every taint into a sink | taint_paths({source_pattern: "name:Source", sink_pattern: "name:Sink"}) — every flow from any matching source to any matching sink (functions auto-expand to their params on the sink side) | -| Intermittent failure | get_editing_context -> look for external calls, async deps; analyze kind=goroutine_spawns to find unowned background work | -| Channel deadlock | analyze kind=channel_ops -> channels with sends but no receivers (or vice versa) | -| Performance issue | find_usages -> find symbols with many callers (hot paths) | -| Recent regression | detect_changes -> see what your changes affect. get_symbol_history flags symbols edited 3+ times this session | -| Flaky test | get_untested_symbols near the suspect -> find coverage gaps the flake may hide | -| Stale index suspect | index_health -> parse failures and stale files can mask the real bug | -| Stale-flag suspect | analyze kind=stale_flags -> flags with every caller untouched for `older_than` days are dead-rollout candidates | -| Config drift | analyze kind=config_readers -> who reads this env/viper key? Surfaces forgotten readers | -| Event/log volume spike | analyze kind=event_emitters with level=error -> find every site that logs an error | -| Mutation race suspicion | analyze kind=field_writers id= -> every function that writes the contended field | -| Annotation drift | analyze kind=annotation_users name=Deprecated -> every site still using a deprecated API | -| Env var read/write mismatch | find_usages on cfg::env:: -> Resources/Dockerfile stages declaring it (EdgeUsesEnv) plus code-side os.Getenv consumers via the shared config_key node | -| K8s manifest blast radius | analyze kind=k8s_resources k8s_kind=ConfigMap -> orphan ConfigMaps. find_usages on a ConfigMap Resource ID surfaces every workload that envFroms or mounts it | -| Container image audit | analyze kind=images role=base -> every external image and how many Dockerfile stages / K8s Resources pull it. Filter by tag=latest to find the unpinned ones | +- Native Gortex MCP is mandatory for this MCP-configured workflow. If its callable tools are missing, report a Gortex MCP integration failure and stop; do not start a daemon or switch to a CLI/shell fallback. +- Do not substitute shell reads, file search, or Git plumbing. +- Start task-shaped work with `explore`. For one already-known symbol, start with `search`. +- Use `change` before a mutation and again after it. Write only through `edit` or `refactor`. +- Call `capabilities({domain: "", operation: "", detail: "schema"})` only when an operation's exact arguments are unclear. +- Report graph-backed paths and symbol IDs. Never invent a result when an operation returns no match. === home/.hermes/skills/debugging/gortex-incident-investigation/SKILL.md === --- name: gortex-incident-investigation @@ -924,75 +326,22 @@ metadata: platforms: [linux, macos, windows] related_skills: [gortex] --- -# Incident Investigation with Gortex - -Use this when an alert fired, a deploy regressed, or production is misbehaving and the user needs to walk back from the symptom (log line, error message, broken endpoint, failing test in CI) to the root cause. Wraps the debug + impact + recent-changes paths into one ordered drill so the investigator never loses the thread. +# Investigate an Incident with Gortex -## Workflow (do not skip steps) +1. Paste the exact symptom into `explore({operation: "task", task: ""})`. +2. Search exact error text with `search({operation: "text", query: ""})`. +3. Walk `relations` operation `callers` and `trace` operations `call_chain`, `flow`, or `taint` from symptom toward cause. +4. Correlate repository-wide `analyze({kind: "recent_changes"})` with symbol-scoped `recall({operation: "surface", arguments: {symbol_ids: "", task: ""}})`. +5. Separate evidence, hypothesis, and unknowns. Gate any fix through `change` before and after the mutation. -``` -1. graph_stats -> Confirm index + orient -2. surface_memories({task: ""}) -> Prior incidents / invariants on the same area -3. search_symbols({query: ""}) -> Resolve the suspect symbol -4. get_recent_changes({since_ts: }) -> Files/symbols changed in the suspect window -5. get_symbol_history -> Symbols churning this session (regression hotspot) -6. analyze({kind: "error_surface", path_prefix: "/"}) -> Throw sites that match the symptom -7. analyze({kind: "event_emitters", level: "error", -> Every log/metric site that could have produced the alert - path_prefix: "/", name: ""}) -8. get_callers({id: ""}) -> Who calls the suspect — narrow blast -9. get_call_chain({id: ""}) -> What the suspect calls — downstream contributors -10. flow_between({source_id: "", sink_id: ""}) -> Trace the bad value's path (when applicable) -11. taint_paths({source_pattern: "name:", -> Every flow from a kind of source to the suspect - sink_pattern: "name:"}) -12. analyze({kind: "field_writers", id: ""}) -> Race / mutation suspects -13. analyze({kind: "channel_ops"}) -> Channel deadlock / orphan recv (Go) -14. analyze({kind: "goroutine_spawns"}) -> Unowned background work -15. explain_change_impact({ids: ""}) -> Blast radius of the proposed fix -16. get_test_targets({ids: [""]}) -> Tests to add / re-run as regression coverage -17. store_memory({kind: "incident", title: "", -> Persist root cause + fix + affected symbols - body: "...", symbol_ids: [""], importance: 5}) -``` +## Required behavior -## Triage by symptom - -| Symptom | First-stop tools | -| -------------------------------------------------------- | ------------------------------------------------------------ | -| New 5xx / panic / crash log | `search_symbols` on the error → `analyze kind=error_surface` → `get_callers` on throw sites | -| Latency spike on one endpoint | `analyze kind=routes path=` → `get_call_chain` on the handler → `analyze kind=external_calls` for hot stdlib / module hops | -| Background worker silently broken | `analyze kind=goroutine_spawns` / `channel_ops` → `get_callers` on the spawned target | -| Pub/sub event missing | `analyze kind=pubsub name=` → publisher + subscriber sides; `taint_paths` from publisher to subscriber handler | -| Config-driven misbehavior after a deploy | `analyze kind=config_readers name=` → callers; `analyze kind=stale_flags` if it's a feature flag | -| Data corruption / wrong value at a sink | `flow_between` from suspected source to sink; `taint_paths` when multiple sources are plausible | -| Cross-service / contract drift | `contracts({action: check})` → orphan providers/consumers; pair with `/gortex-cross-repo-usage` | -| Test regression after a refactor | `detect_changes` over the merge → `diff_context` for graph-enriched view → `get_test_targets` for the missing test signal | - -## Walking the timeline - -When the symptom appears tied to a window (between two deploys, between two commits, since N hours ago): - -1. `analyze kind=blame` to stamp `meta.last_authored` on every blame-eligible symbol (one-time per session; cheap thereafter) -2. `get_recent_changes since_ts=` to enumerate files / symbols touched -3. `detect_changes scope=all` to project the diff onto blast radius -4. Pair each touched symbol with `explain_change_impact` — anything in the d=1/d=2 buckets that overlaps the symptom area is your root-cause candidate -5. `get_symbol_history` flags symbols edited 3+ times *in this session*; combine with blame for "edited 3+ times in the suspect window" - -## Closing the loop (mandatory) - -After root-cause is found and the fix is shipped: -- `store_memory({kind: "incident", ...})` so the next on-call sees this pattern surfaced via `surface_memories` -- If the incident exposed an invariant the code lacked guard for (lock missing, contract unenforced), also `store_memory({kind: "invariant", importance: 5, pinned: true})` -- `save_note({tags: "incident", body: ...})` for the session-local timeline before context compacts - -## Checklist - -- `graph_stats` + `surface_memories` before any search — prior incident memories are the highest-value signal -- `search_symbols` on the actual error string / symbol from the alert — do not paraphrase -- `get_recent_changes` + `get_symbol_history` to scope the window -- `analyze kind=error_surface` + `event_emitters` to find the production sites of the symptom -- `get_callers` / `get_call_chain` / `flow_between` / `taint_paths` to walk from symptom toward root cause -- Concurrency-shape symptoms → `channel_ops` / `goroutine_spawns` / `field_writers` / `race_writes` / `unclosed_channels` -- `explain_change_impact` on the candidate fix; `get_test_targets` for regression coverage -- `store_memory({kind: "incident"})` is **mandatory** before declaring the investigation closed +- Native Gortex MCP is mandatory for this MCP-configured workflow. If its callable tools are missing, report a Gortex MCP integration failure and stop; do not start a daemon or switch to a CLI/shell fallback. +- Do not substitute shell reads, file search, or Git plumbing. +- Start task-shaped work with `explore`. For one already-known symbol, start with `search`. +- Use `change` before a mutation and again after it. Write only through `edit` or `refactor`. +- Call `capabilities({domain: "", operation: "", detail: "schema"})` only when an operation's exact arguments are unclear. +- Report graph-backed paths and symbol IDs. Never invent a result when an operation returns no match. === home/.hermes/skills/navigation/gortex-cross-repo-usage/SKILL.md === --- name: gortex-cross-repo-usage @@ -1005,65 +354,22 @@ metadata: platforms: [linux, macos, windows] related_skills: [gortex] --- -# Cross-Repo Usage with Gortex - -Use this when the user needs to see who uses a symbol **across every consumer repo**, not just the one they happen to be in. Wraps `find_usages` + the cross-repo edge layer (`cross_repo_calls` / `cross_repo_implements` / `cross_repo_extends`) so the answer is partitioned by repo and includes contract-level consumers (HTTP / gRPC / topics) that wouldn't show up as a direct call edge. - -## Workflow (do not skip steps) - -``` -1. get_active_project -> See current scope (single repo or multi-repo project) -2. list_repos -> See which repos are tracked -3. For each consumer repo not yet tracked: - track_repository({path: ""}) -> Index immediately, persist to config -4. set_active_project({name: ""}) -> Switch scope so subsequent queries span repos -5. search_symbols({query: "X"}) -> Resolve the symbol ID in the providing repo -6. find_usages({id: ""}) -> All references; group by repo prefix -7. analyze({kind: "cross_repo", -> Repo-boundary-crossing edges with relation - base_kind: "calls|implements|extends", - repo: ""}) -8. contracts({action: "check"}) -> Contract-level consumers (HTTP routes, gRPC methods, topics, env, OpenAPI) -9. get_test_targets({ids: []}) -> Cross-repo tests that exercise this symbol -10. export_context({format: "markdown"}) -> Per-repo report for PR / Slack / docs -``` - -## Partitioning `find_usages` by repo - -The graph stores repo as a prefix on each node ID. Group results by the leading path segment so the user sees one section per consumer: - -| Repo | References | -| ------------------------- | ---------- | -| my-org/api-gateway | 7 sites | -| my-org/billing | 3 sites | -| my-org/notifications | 1 site | - -`analyze kind=cross_repo` complements this — it reports the typed edges (calls / implements / extends) that physically cross the boundary, which is the count that matters for "is this safe to change without coordinating with consumer teams." +# Find Cross-Repository Usage with Gortex -## Contract-level consumers +1. Confirm tracked repositories with `workspace({operation: "repos"})`. +2. Resolve the provider symbol with `search({operation: "symbols", query: "", options: {repo: ""}})`. +3. Call `relations({operation: "usages", target: {symbol: ""}})` and group results by repository. +4. Add `analyze({kind: "cross_repo", options: {repo: ""}})` for repository boundaries and `analyze({kind: "contracts", options: {action: "bridge", mode: "impact", symbol: ""}})` for wire-level consumers. +5. Report indexed coverage and name any untracked repositories as an explicit gap. -For published API surfaces, `find_usages` won't show consumers that go through the wire. `contracts({action: check})` catches: -- HTTP route consumers (client-side `fetch` / `http.Get` / generated SDK methods) -- gRPC method consumers (generated client stubs across repos) -- Pub/sub subscribers (NATS / Kafka / RabbitMQ / Redis / EventEmitter / Socket.IO) -- Env-var consumers (one process writes, another reads) -- OpenAPI / GraphQL schema consumers +## Required behavior -## Onboarding a consumer repo - -If a known consumer is missing from the tracked-repo list: -1. `track_repository` with its path — indexes immediately, persists to config -2. Re-run `find_usages` and `analyze kind=cross_repo` — the new edges materialise -3. `untrack_repository` only when you're done; leaving it tracked keeps subsequent cross-repo queries cheap - -## Checklist - -- `get_active_project` first — you may already be in multi-repo scope -- `track_repository` every consumer repo you care about; `find_usages` only sees what's indexed -- Partition the `find_usages` rows by repo prefix; the per-repo breakdown is the deliverable -- `analyze kind=cross_repo` for the typed-edge view (calls / implements / extends) -- `contracts({action: check})` for wire-level consumers that `find_usages` cannot see -- `get_test_targets` returns cross-repo tests too — run them, not just the local ones -- Hand the per-repo report to the user via `export_context` for PR descriptions / Slack +- Native Gortex MCP is mandatory for this MCP-configured workflow. If its callable tools are missing, report a Gortex MCP integration failure and stop; do not start a daemon or switch to a CLI/shell fallback. +- Do not substitute shell reads, file search, or Git plumbing. +- Start task-shaped work with `explore`. For one already-known symbol, start with `search`. +- Use `change` before a mutation and again after it. Write only through `edit` or `refactor`. +- Call `capabilities({domain: "", operation: "", detail: "schema"})` only when an operation's exact arguments are unclear. +- Report graph-backed paths and symbol IDs. Never invent a result when an operation returns no match. === home/.hermes/skills/navigation/gortex-dataflow-trace/SKILL.md === --- name: gortex-dataflow-trace @@ -1076,68 +382,22 @@ metadata: platforms: [linux, macos, windows] related_skills: [gortex] --- -# Dataflow Trace with Gortex (CPG-lite) +# Trace Data Flow with Gortex -Use this when the user asks where a value flows — through assignments, function args, returns, channels, event buses, or pub/sub topics. Built on the CPG-lite edge layer (`value_flow` ∪ `arg_of` ∪ `returns_to`) plus the pub/sub + channel + emit layers, so flows survive crossing function and process boundaries that a plain caller-graph walk would lose. +1. Call `explore({operation: "task", task: "trace from to "})`. +2. Resolve endpoints with `search({operation: "symbols", query: ""})`. +3. Use `trace({operation: "flow", target: {symbol: ""}, to: {symbol: ""}})`. Use operation `taint` when source/sink security semantics matter. +4. Cross-check control flow with operation `call_chain` when the data-flow graph has a gap. +5. Report each hop with its symbol ID and confidence; distinguish no path from incomplete indexing. -## Workflow (do not skip steps) +## Required behavior -``` -1. smart_context({task: ""}) -> Working-set bundle -2. search_symbols({query: ""}) -> Resolve the producing symbol ID -3. search_symbols({query: ""}) -> Resolve the consuming symbol ID -4. flow_between({source_id: "", sink_id: "", -> Ranked paths over value_flow ∪ arg_of ∪ returns_to - max_depth: 8, max_paths: 10}) -5. taint_paths({source_pattern: "", -> Every flow from any matching source to any matching sink - sink_pattern: "", - max_depth: 8}) -6. analyze({kind: "channel_ops"}) -> Channel producer/consumer mismatch (Go) -7. analyze({kind: "pubsub", -> Pub/sub topics with publishers + subscribers - transport: "nats|kafka|rabbitmq|redis|eventemitter|socketio", - name: "", - role: "publish|subscribe"}) -8. analyze({kind: "event_emitters", level: "error"}) -> Log/metric/span emit sites -9. get_call_chain({id: ""}) + get_callers({id: ""}) -> Caller-graph cross-check for any path flow_between missed -10. export_context({format: "markdown"}) -> Hand the trace to a PR / Slack / doc -``` - -## Pattern syntax for `taint_paths` - -Each pattern is one or more clauses combined with AND: - -| Clause | Meaning | Example | -| ------------------ | ------------------------------------------------ | ----------------------------- | -| `` | Substring match on the symbol name | `Decode` | -| `exact:` | Exact name match | `exact:HandleRequest` | -| `path:` | Path prefix on the file the symbol lives in | `path:internal/http/` | -| `kind:` | Restrict to `function` / `method` / `field` / etc. | `kind:method` | - -Functions auto-expand to their params on the sink side, so `taint_paths` with a function-shaped sink pattern reports flows into any of its parameters. - -## Crossing process / transport boundaries - -`flow_between` walks intra-process edges. To follow a value across a wire: - -| Boundary | Tool | -| --------------------------------- | ---------------------------------------------------- | -| Channel (Go) | `analyze kind=channel_ops` — find the matching `recv` site, then `flow_between` from there | -| Pub/sub topic | `analyze kind=pubsub name=` — get every subscriber; `flow_between` from each | -| HTTP / gRPC / GraphQL contract | `contracts({action: check})` — match providers to consumers; the contract ID is your bridge node | -| Env var (one process writes, another reads) | `analyze kind=config_readers name=` -> consumers; `find_usages` on `cfg::env::` | -| Cross-repo call | `analyze kind=cross_repo base_kind=calls` — typed boundary-crossing edges | - -## Reading a ranked path - -Each row from `flow_between` is a sequence of edges with the kind annotated (`value_flow` / `arg_of` / `returns_to`). The rank reflects path length + edge confidence. Treat the top-3 paths as the load-bearing ones; the long tail tends to be incidental cross-references. - -## Checklist - -- `smart_context` first — pick up the working set before asking flow_between for a path -- Resolve **both** source and sink to symbol IDs before calling `flow_between` (path search needs anchors) -- Use `taint_paths` when "every flow from a kind of source to a kind of sink" matters more than one specific pair -- Cross process boundaries with the right analyzer (`channel_ops` / `pubsub` / `config_readers` / `contracts`) — `flow_between` alone won't follow a value across the wire -- Cross-check with `get_call_chain` / `get_callers` when `flow_between` returns zero paths for a flow you're sure exists — the producer or consumer may not yet be in the dataflow layer -- `export_context` to hand the trace to a PR / Slack / doc without losing the structure +- Native Gortex MCP is mandatory for this MCP-configured workflow. If its callable tools are missing, report a Gortex MCP integration failure and stop; do not start a daemon or switch to a CLI/shell fallback. +- Do not substitute shell reads, file search, or Git plumbing. +- Start task-shaped work with `explore`. For one already-known symbol, start with `search`. +- Use `change` before a mutation and again after it. Write only through `edit` or `refactor`. +- Call `capabilities({domain: "", operation: "", detail: "schema"})` only when an operation's exact arguments are unclear. +- Report graph-backed paths and symbol IDs. Never invent a result when an operation returns no match. === home/.hermes/skills/navigation/gortex-explore/SKILL.md === --- name: gortex-explore @@ -1150,33 +410,21 @@ metadata: platforms: [linux, macos, windows] related_skills: [gortex] --- -# Exploring Codebases with Gortex +# Explore a Codebase with Gortex -## Workflow +1. Call `explore({operation: "task", task: ""})`. +2. Read only a returned anchor with `read({target: {symbol: ""}})`. +3. Choose the needed relationship (`callers`, `usages`, or `dependencies`), for example `relations({operation: "callers", target: {symbol: ""}})`; use `trace({operation: "call_chain", target: {symbol: ""}})` for execution order. +4. Answer with the execution path, file locations, symbol IDs, and any graph uncertainty. -``` -1. graph_stats -> Confirm index, get node/edge counts -2. smart_context({task: ""}) -> One-call exploration bundle (start here) -3. get_communities -> See functional clusters (architecture overview) -4. search_symbols({query: ""}) -> Find symbols related to a concept -5. get_processes -> Discover execution flows -6. get_processes({id: ""}) -> Trace a specific flow step by step -7. get_file_summary({path: ""}) -> Symbols + imports for one file -8. get_editing_context({path: ""}) -> Deep dive on a file (callers + callees) -9. export_context({...}) -> Share findings as markdown/JSON (PRs, Slack, docs) -``` +## Required behavior -## Checklist - -- Call index_health to confirm Gortex is running (graph_stats only when you need node/edge counts) -- Call smart_context first — one call replaces 5-10 exploration calls -- Call get_communities for architecture overview when smart_context is not enough -- Call search_symbols for the concept you want to understand -- Call get_processes to discover execution flows -- Call get_processes with id on relevant flows for step-by-step traces -- Call get_editing_context on key files for full symbol context -- Call export_context to hand a findings packet outside the session -- Read source files only for implementation details you actually need to edit +- Native Gortex MCP is mandatory for this MCP-configured workflow. If its callable tools are missing, report a Gortex MCP integration failure and stop; do not start a daemon or switch to a CLI/shell fallback. +- Do not substitute shell reads, file search, or Git plumbing. +- Start task-shaped work with `explore`. For one already-known symbol, start with `search`. +- Use `change` before a mutation and again after it. Write only through `edit` or `refactor`. +- Call `capabilities({domain: "", operation: "", detail: "schema"})` only when an operation's exact arguments are unclear. +- Report graph-backed paths and symbol IDs. Never invent a result when an operation returns no match. === home/.hermes/skills/navigation/gortex-onboarding/SKILL.md === --- name: gortex-onboarding @@ -1189,97 +437,21 @@ metadata: platforms: [linux, macos, windows] related_skills: [gortex] --- -# Onboarding with Gortex (30-minute repo tour) - -Use this when the user is new to a repo (or returning to one cold) and wants a structured tour: where to read first, what the architecture looks like, who owns what, what's load-bearing, and where to safely make a first edit. Composes the discovery-tier tools into a single ordered walk so the agent doesn't waste the user's first hour on undirected `Read` calls. +# Onboard to a Repository with Gortex -## Workflow (do not skip steps) +1. Call `explore({operation: "outline"})`, then `explore({operation: "task", task: "explain the repository's main responsibilities and entry points"})`. +2. Run `analyze` with kinds `architecture`, `communities`, and `processes`. +3. Trace one representative request or job with `trace({operation: "call_chain", target: {symbol: ""}})`. +4. Read only the key returned symbols. Deliver a concise map of entry points, boundaries, data flow, tests, and operational risks. -``` -1. graph_stats -> Confirm Gortex is indexed; per-language / per-kind counts -2. get_active_project -> Is this single-repo or part of a multi-repo project? -3. list_repos (when multi-repo) -> See sibling repos in the project -4. get_repo_outline -> Narrative single-call codebase overview (entry points, top communities, hot paths) -5. surface_memories({task: "onboarding "}) -> Cross-session invariants / conventions already captured -6. distill_session -> Prior session digest for this workspace (decisions, pinned notes) -7. get_communities -> Functional clusters via Louvain — the implicit "modules" -8. get_processes -> Discovered execution flows (the implicit "use cases") -9. analyze({kind: "hotspots"}) -> Over-coupled symbols — where most edits should be careful -10. analyze({kind: "components"}) -> Component fan-in/out (UI projects) -11. analyze({kind: "routes"}) -> HTTP / gRPC / GraphQL / WS endpoints — the API surface -12. analyze({kind: "models"}) -> ORM models → tables — the data surface -13. analyze({kind: "ownership", path_prefix: "/"}) -> Per-author rollup — who to ping for what -14. contracts({action: "list"}) -> Detected API contracts (provider side) -15. audit_agent_config -> Project CLAUDE.md / AGENTS.md / .cursor/rules — read what the team told the agent -16. ask({question: "What does this repo do and how is it organised?"}) -> LLM summary grounded in the working set (only when llm provider configured) -``` +## Required behavior -## The five-minute version (when the user is in a hurry) - -``` -1. graph_stats -2. get_repo_outline -3. get_communities (top 5) -4. surface_memories({task: "onboarding"}) -5. audit_agent_config -``` - -Stop here unless the user asks for more depth. The outline + communities + memories triple covers "what is this repo / how is it carved up / what should I know before editing." - -## The full tour (when the user wants 30 minutes) - -The full `Workflow` above, plus per-community drilldowns: - -1. From `get_communities`, pick the top 3 by node count -2. For each, `get_communities({id: })` returns its members -3. `get_file_summary` on the community's central file (highest fan-in by symbol count) -4. `get_processes` on a process that crosses the community -5. `get_callers` / `get_call_chain` on the community's entry-point symbol - -Result: the user has a concrete mental model of one community per drilldown. - -## Where to make a first edit safely - -After the tour, the user usually asks "where can I make my first change without breaking things?" Answer with: - -1. `analyze kind=todos` — every TODO / FIXME node, filterable by author / tag — pick a small one in a low-fan-in symbol -2. `analyze kind=coverage_gaps min_pct=0 max_pct=50 path_prefix=/` — undertested code where a test addition is a safe first PR -3. `analyze kind=stale_code older_than=180` — code untouched for 6+ months in a community the user just explored — usually safe to add a small clarifying refactor - -Either path: hand off to `/gortex-safe-edit` (or `/gortex-add-test` for the coverage-gap path) for the actual change. - -## Multi-repo onboarding - -When `get_active_project` shows multiple members: - -- `list_repos` first; pick the *primary* repo for the tour (usually the largest by node count or the one with the most `KindContract` provider nodes) -- `analyze kind=cross_repo base_kind=calls` shows the cross-repo coupling — onboarding must cover *who calls into* + *who is called from* the primary -- `contracts({action: list})` partitioned by repo shows the wire surface each repo exposes - -## Onboarding artefact - -The deliverable for an onboarding session is a markdown packet the user can keep: - -``` -export_context({ - task: "onboarding tour of ", - format: "markdown", - sections: ["outline", "communities", "processes", "hotspots", "routes", "models", "memories"] -}) -``` - -Hand that to the user; they can paste it into their notes / wiki / agent memory. - -## Checklist - -- `graph_stats` + `get_active_project` before any tool that reads the graph — confirm scope first -- `get_repo_outline` is the single-call narrative; if it's missing fields the user needs, fall back to the per-analyzer walks -- `surface_memories` + `distill_session` — prior agents may have left invariants / conventions / decisions that shape the tour -- `get_communities` + `get_processes` for the architectural skeleton -- `audit_agent_config` reads the team's own CLAUDE.md / AGENTS.md so the tour respects whatever conventions the team wrote down -- For multi-repo: pick the primary, then `analyze kind=cross_repo` for the boundary view -- End with `export_context format=markdown` so the user keeps the tour -- Suggest a safe first edit via `analyze kind=todos` / `coverage_gaps` / `stale_code` and hand off to `/gortex-safe-edit` or `/gortex-add-test` +- Native Gortex MCP is mandatory for this MCP-configured workflow. If its callable tools are missing, report a Gortex MCP integration failure and stop; do not start a daemon or switch to a CLI/shell fallback. +- Do not substitute shell reads, file search, or Git plumbing. +- Start task-shaped work with `explore`. For one already-known symbol, start with `search`. +- Use `change` before a mutation and again after it. Write only through `edit` or `refactor`. +- Call `capabilities({domain: "", operation: "", detail: "schema"})` only when an operation's exact arguments are unclear. +- Report graph-backed paths and symbol IDs. Never invent a result when an operation returns no match. === home/.hermes/skills/refactoring/gortex-extract-function/SKILL.md === --- name: gortex-extract-function @@ -1292,60 +464,22 @@ metadata: platforms: [linux, macos, windows] related_skills: [gortex] --- -# Extract Function with Gortex (LSP refactor path) +# Extract a Function with Gortex -Use this when the user wants to extract code into a new function / method / variable / constant via the **language server's refactor actions**, not by hand-editing braces. Maps onto LSP `refactor.extract.*` code actions; works wherever the underlying server supports them (gopls / tsserver / pyright / rust-analyzer / jdtls / kotlin-language-server / omnisharp / and others). - -## Workflow (do not skip steps) - -``` -1. get_editing_context({path: ""}) -> See enclosing symbol, callers, callees -2. (optional) find_usages({id: ""}) -> Who calls the function you are about to split -3. get_code_actions({path: "", range: {start, end}, -> Menu filtered to refactor.extract.* actions - only: ["refactor.extract.function", - "refactor.extract.method", - "refactor.extract.variable", - "refactor.extract.constant"]}) -4. preview_edit({edit: }) -> Speculative apply - -> Inspect: touched / added / renamed / broken_callers / broken_implementors / impact -5. If the extraction crosses files (e.g. moving the helper to a sibling file): - simulate_chain({steps: [...]}) -> Ordered chain with broken-caller carry-over -6. apply_code_action({path: "", action_id: ""}) -> Atomic temp+rename, UTF-16 column math -7. check_guards({ids: []}) + verify_change -> Convention + signature gate -8. get_test_targets({ids: [, ]}) -> Run tests for both the caller and the extract -9. detect_changes + diff_context -> Final scope check -``` +1. Inspect the file with `read({operation: "editing_context", target: {file: ""}})`. +2. Resolve the selected range with `change({operation: "ranges", source: {file: "", range: {...}}})`. +3. Request extraction actions with `change({operation: "code_actions", source: {file: "", range: {...}}})`. +4. Apply the selected action through `refactor({operation: "apply_code_action", target: {file: ""}, options: {...}})`. +5. Run `change` operations `detect`, `tests`, `guards`, and `contract`. -## Choosing the right extract action +## Required behavior -| User intent | Code-action kind | Notes | -| ------------------------------------------ | --------------------------------- | ----- | -| Pull a block of statements into a helper | `refactor.extract.function` | Most servers infer params + return type from the selection. | -| Move statements into a method on a type | `refactor.extract.method` | Receiver / this binding chosen by the server. | -| Extract a sub-expression to a local | `refactor.extract.variable` | Best for naming repeated sub-expressions. | -| Extract a literal to a package-level const | `refactor.extract.constant` | gopls and tsserver expose this; rust-analyzer uses `refactor.extract.module` for similar moves. | - -If you don't know which kinds the server offers, call `get_code_actions` **without** `only` and inspect the menu first. - -## Range selection - -LSP positions are line + UTF-16 character offsets. The `apply_code_action` mapper handles this correctly, but when you compose the range yourself: -1. Get the file body via `get_symbol_source` (compress_bodies:false) or `get_file_summary` -2. Use the symbol's line numbers as anchors; expand to the precise statement boundaries -3. Pass `range: {start: {line, character}, end: {line, character}}` to `get_code_actions` - -## When the server has no extract action - -Some servers (yaml-language-server, json-language-server, bash-language-server) don't ship refactor actions. Fall back to `/gortex-safe-edit` and author the extract by hand — but still `preview_edit` first. - -## Checklist - -- `get_editing_context` to understand the enclosing symbol before selecting a range -- `get_code_actions` with `only: refactor.extract.*` — pick from the menu, don't invent -- `preview_edit` before `apply_code_action` (broken callers / blast radius) -- `simulate_chain` if the extract moves the helper to a new file with follow-on rename of call sites -- `check_guards`, `verify_change`, `get_test_targets` after the apply -- `get_symbol` on the new symbol to confirm Gortex indexed it under the expected ID +- Native Gortex MCP is mandatory for this MCP-configured workflow. If its callable tools are missing, report a Gortex MCP integration failure and stop; do not start a daemon or switch to a CLI/shell fallback. +- Do not substitute shell reads, file search, or Git plumbing. +- Start task-shaped work with `explore`. For one already-known symbol, start with `search`. +- Use `change` before a mutation and again after it. Write only through `edit` or `refactor`. +- Call `capabilities({domain: "", operation: "", detail: "schema"})` only when an operation's exact arguments are unclear. +- Report graph-backed paths and symbol IDs. Never invent a result when an operation returns no match. === home/.hermes/skills/refactoring/gortex-fix-all/SKILL.md === --- name: gortex-fix-all @@ -1358,57 +492,21 @@ metadata: platforms: [linux, macos, windows] related_skills: [gortex] --- -# Fix LSP Diagnostics with Gortex (LSP code-actions path) - -Use this when the user wants errors / warnings cleared — for one symbol, one file, or the whole project. Bridges Gortex to the 18-language LSP coverage (gopls / tsserver / pyright / rust-analyzer / clangd / jdtls / kotlin-language-server / omnisharp / ruby-lsp / phpactor / lua-language-server / sourcekit-lsp / haskell-language-server / elixir-ls / ocamllsp / zls / terraform-ls / yaml-language-server / json-language-server / bash-language-server). No manual `Edit` of error messages. +# Fix Diagnostics with Gortex -## Workflow (do not skip steps) +1. Read diagnostics using `change({operation: "diagnostics", source: {file: ""}})`. +2. Fetch applicable fixes with `change({operation: "code_actions", source: {file: ""}})`. +3. Apply one reviewed action with `refactor({operation: "apply_code_action", target: {file: ""}, options: {...}})`, or use operation `fix_all` only when the user asked for all safe fixes. +4. Re-run diagnostics, then run `change` operations `detect` and `tests`. -``` -1. subscribe_diagnostics({min_severity: 1, path_prefix: "/"}) -> Push notifications, replays initial state once -2. get_diagnostics({path: "", wait: true, timeout_ms: 2000}) -> Poll form when you need a synchronous snapshot -3. get_code_actions({path: "", range: {...}}) -> Menu of fixes / refactors / source actions -4. For each chosen action: - preview_edit({edit: }) -> Speculative apply on the shadow graph - apply_code_action({path, action_id}) -> Atomic on-disk apply (UTF-16 column math) -5. fix_all_in_file({path: ""}) -> One-shot source.fixAll over an entire file -6. get_diagnostics({path: ""}) -> Confirm the file is now clean -7. check_guards({ids: []}) + get_test_targets({ids}) -> Post-fix guardrails -8. unsubscribe_diagnostics -> Only when narrowing scope; auto-fires on disconnect -``` - -## Diagnostic-fix patterns - -| Situation | Tool ordering | Notes | -| ------------------------------------------------------ | ------------- | ----- | -| One specific error | `get_code_actions` at the diagnostic range -> pick one -> `apply_code_action` | Smallest possible edit. | -| Every error in a file | `fix_all_in_file` | Bundles every server-suggested fix in a single round-trip. | -| Refactor offered as a code action (`refactor.*`) | `get_code_actions` -> pick by `kind` (e.g. `refactor.extract`) -> `preview_edit` first | See `/gortex-extract-function`. | -| Source action (`source.organizeImports`, `source.fixAll`) | `get_code_actions` with the right `only` kind, or `fix_all_in_file` | Whole-file scope. | -| Watching diagnostics during a long edit session | `subscribe_diagnostics` with `min_severity` + `path_prefix` filters | SHA-suppressed delta payloads; only changed files reach you. | - -## Reading the diagnostic stream - -- `initial_replay: true` on the first push — that's the synchronous snapshot of the current LSP state, not a new event. -- Every subsequent push carries only files whose `publishDiagnostics` SHA changed. -- `min_severity` 1 = error, 2 = warning, 3 = info, 4 = hint. Default to 1 unless you specifically need warnings. -- `path_prefix` is your scope filter — pin it to the area you're working on so the rest of the project doesn't drown the stream. - -## When LSP cannot fix it - -If `get_code_actions` returns an empty menu, the server doesn't know how to fix this diagnostic. Fall back to: -1. `search_symbols` for the symbol the diagnostic references -2. `get_editing_context` on the file -3. `/gortex-safe-edit` to author the edit by hand, with `preview_edit` to verify - -## Checklist +## Required behavior -- `subscribe_diagnostics` once per session, with `min_severity` + `path_prefix` scoped to the area you're touching -- `get_code_actions` is the menu — never invent fixes -- `preview_edit` (or `apply_code_action` directly when the action is small and well-known) before any disk write -- `fix_all_in_file` for whole-file source.fixAll — one round-trip beats N targeted fixes -- Re-run `get_diagnostics` after the fix; assume nothing -- `check_guards` and `get_test_targets` after the diagnostics are clean — fixing the LSP error is not the same as fixing the test +- Native Gortex MCP is mandatory for this MCP-configured workflow. If its callable tools are missing, report a Gortex MCP integration failure and stop; do not start a daemon or switch to a CLI/shell fallback. +- Do not substitute shell reads, file search, or Git plumbing. +- Start task-shaped work with `explore`. For one already-known symbol, start with `search`. +- Use `change` before a mutation and again after it. Write only through `edit` or `refactor`. +- Call `capabilities({domain: "", operation: "", detail: "schema"})` only when an operation's exact arguments are unclear. +- Report graph-backed paths and symbol IDs. Never invent a result when an operation returns no match. === home/.hermes/skills/refactoring/gortex-refactor/SKILL.md === --- name: gortex-refactor @@ -1421,61 +519,21 @@ metadata: platforms: [linux, macos, windows] related_skills: [gortex] --- -# Refactoring with Gortex +# Refactor with Gortex -## Workflow +1. Call `explore({operation: "task", task: ""})` and resolve every target with `search`. +2. Run `change({operation: "impact", source: {symbols: [""]}})`. Before altering a signature, also run `change({operation: "verify", source: {changes: [{symbol_id: "", new_signature: ""}]}})`. +3. Choose exactly one `refactor` operation: `rename`, `move`, `inline`, `delete`, or `apply_code_action`. For example: `refactor({operation: "rename", target: {symbol: ""}, new_name: ""})`. +4. Require `change` operations `detect`, `tests`, `guards`, and `contract` after the mutation. Resolve every violation before finishing. -``` -1. search_symbols({query: "X"}) -> Find the symbol ID -2. explain_change_impact({ids: ""}) -> Map blast radius -3. analyze({kind: "ownership", path_prefix: "/"}) -> Who should review (pinging policy) -4. analyze({kind: "coverage_gaps", path_prefix: "/"}) -> Where is the refactor risky (poor coverage) -5. verify_change({id: "", new_signature: "..."}) -> Catch contract violations in callers + implementors -6. get_editing_context({path: ""}) -> See all symbols and relationships -7. find_usages({id: ""}) -> Every reference to change -8. get_edit_plan({ids: ["", ""]}) -> Dependency-ordered file list -9. batch_edit({edits: [...]}) -> Apply edits in order, re-indexing between steps -10. check_guards({ids: [...]}) -> Post-edit: team conventions from .gortex.yaml -11. get_test_targets({ids: [...]}) -> Tests to re-run (cross-repo aware) -12. detect_changes({scope: "all"}) -> Verify scope; diff_context for review -``` +## Required behavior -## Rename Symbol - -- search_symbols to find the symbol ID -- explain_change_impact to assess blast radius -- verify_change before signature-changing renames — fails fast on interface breaks -- rename_symbol({id: "", new_name: ""}) — generates edits for definition + all references -- Review the generated edits, apply via batch_edit or edit_symbol (no Read→Edit roundtrip) -- check_guards, then detect_changes to verify only expected files changed - -## Extract Module - -- get_editing_context on the source file — see all symbols -- get_dependents on symbols to extract — find external callers -- explain_change_impact on symbols being moved -- analyze({kind: "would_create_cycle", from: "", to: ""}) before wiring imports -- suggest_pattern + scaffold from a comparable existing module — generates code, wiring, test stubs -- Extract code, update imports (find_import_path for correct paths) -- get_edit_plan + batch_edit for dependency-ordered atomic application -- check_guards, detect_changes to verify affected scope - -## Split Function/Service - -- get_call_chain on the function — understand all callees -- Group callees by responsibility -- get_callers to map all call sites that need updating -- find_implementations when splitting along an interface -- explain_change_impact for full blast radius -- Create new functions/services (scaffold from a similar example) -- Update callers (find_usages for precise locations, batch_edit to apply in order) -- check_guards, detect_changes to verify affected scope - -## API Contract Changes - -- Before changing an HTTP route, gRPC method, topic, env, or OpenAPI contract: contracts({action: "check"}) to find cross-repo consumers -- verify_change on the provider signature -- Coordinate consumer-side edits in the same batch_edit when repos are tracked together +- Native Gortex MCP is mandatory for this MCP-configured workflow. If its callable tools are missing, report a Gortex MCP integration failure and stop; do not start a daemon or switch to a CLI/shell fallback. +- Do not substitute shell reads, file search, or Git plumbing. +- Start task-shaped work with `explore`. For one already-known symbol, start with `search`. +- Use `change` before a mutation and again after it. Write only through `edit` or `refactor`. +- Call `capabilities({domain: "", operation: "", detail: "schema"})` only when an operation's exact arguments are unclear. +- Report graph-backed paths and symbol IDs. Never invent a result when an operation returns no match. === home/.hermes/skills/refactoring/gortex-rename/SKILL.md === --- name: gortex-rename @@ -1488,58 +546,22 @@ metadata: platforms: [linux, macos, windows] related_skills: [gortex] --- -# Rename Symbol with Gortex (cross-file coordinated rename) - -Use this when the user wants to rename a function / method / type / variable / package and have every reference (definition, callers, tests, cross-repo consumers, doc-comments where applicable) updated atomically. Picks the right tool for the job — graph-coordinated `rename_symbol` for Gortex-known symbols, LSP `textDocument/rename` (surfaced as a `refactor.rename` code action where supported) for server-driven cases. - -## Workflow (do not skip steps) - -``` -1. search_symbols({query: ""}) -> Resolve the symbol ID(s) -2. winnow_symbols({...}) -> Disambiguate if multiple matches survive -3. explain_change_impact({ids: ""}) -> Blast radius (callers, implementors, processes) -4. find_usages({id: ""}) -> Every reference (BM25-free; zero false positives) -5. verify_change({id: "", new_signature: ""}) -> Catches interface breaks early -6. rename_symbol({id: "", new_name: ""}) -> Generates dependency-ordered WorkspaceEdit (Gortex path) - OR get_code_actions({path, range, only: ["refactor.rename"]}) -> LSP-driven path (when you want server-side rename semantics) -7. preview_edit({edit: }) -> Speculative apply; check broken_callers / broken_implementors -8. batch_edit({edits: [...]}) -> Atomic on-disk apply, re-index between steps - OR apply_code_action({...}) -> If you went the LSP route -9. check_guards({ids: []}) -> Naming conventions, banned terms from .gortex.yaml -10. get_test_targets({ids: []}) -> Tests to re-run; cross-repo aware -11. detect_changes + diff_context -> Confirm no stray reference survived -``` - -## Choosing the right rename tool - -| Situation | Tool | Why | -| ---------------------------------------------------------- | ----------------------------- | --- | -| Symbol is in the graph and rename is mechanical | `rename_symbol` | Generates the WorkspaceEdit from edges; deterministic, fast, no LSP round-trip. | -| Symbol semantics depend on the server (TS shadowed locals, Java generics, Rust trait resolution) | `get_code_actions` + `apply_code_action` with `refactor.rename` | Server resolves identifier scoping the parser can't fully replicate. | -| Cross-repo rename (consumer repos tracked together) | `rename_symbol` + `contracts({action: check})` | Graph carries cross-repo edges; `contracts` flags HTTP / gRPC / topic-side breakage. | -| Rename of a published API surface | `rename_symbol` **plus** `contracts({action: check})` and a deprecation shim | Pair with `/gortex-cross-repo-usage` to find consumer repos. | +# Rename a Symbol with Gortex -## Cross-repo renames +1. Resolve exactly one symbol with `search({operation: "symbols", query: ""})`. +2. Enumerate references and implementations with `relations` operations `usages` and `implementations`. +3. Run `change({operation: "impact", source: {symbols: [""]}})`. For a public signature rename, also run `change({operation: "verify", source: {changes: [{symbol_id: "", new_signature: ""}]}})`. +4. Preview with `refactor({operation: "rename", target: {symbol: ""}, new_name: "", dry_run: true})`, then repeat it with `dry_run: false` to apply. +5. Require `change` operations `detect`, `guards`, and `tests`; confirm no old usages remain. -`rename_symbol` covers every repo the graph knows about — track consumer repos first (`track_repository`) and switch `set_active_project` to the multi-repo scope, then run the rename. `/gortex-cross-repo-usage` is the partition view that confirms every consumer repo was touched. +## Required behavior -## Renaming through interfaces - -If the renamed symbol is part of an interface contract: -1. `find_implementations` on the interface — every implementor must be renamed in the same chain -2. `verify_change` against the interface's signature with the new name -3. `simulate_chain` with one step per implementor file so `broken_implementors` carries forward correctly -4. Apply via `batch_edit` (or `apply_code_action` if you went the LSP route per-implementor) - -## Checklist - -- `search_symbols` + `winnow_symbols` until exactly one ID resolves to the symbol you mean -- `explain_change_impact` + `find_usages` before authoring any edit -- `verify_change` on the new signature — catches interface breaks before anything is written -- Pick `rename_symbol` (graph path) or LSP `refactor.rename` (server path); never hand-author cross-file renames -- `preview_edit` / `simulate_chain` on the generated edits before disk -- `batch_edit` is dependency-ordered + re-indexes between steps — atomic in the sense that matters for the graph -- `check_guards`, `get_test_targets`, `diff_context` after the apply +- Native Gortex MCP is mandatory for this MCP-configured workflow. If its callable tools are missing, report a Gortex MCP integration failure and stop; do not start a daemon or switch to a CLI/shell fallback. +- Do not substitute shell reads, file search, or Git plumbing. +- Start task-shaped work with `explore`. For one already-known symbol, start with `search`. +- Use `change` before a mutation and again after it. Write only through `edit` or `refactor`. +- Call `capabilities({domain: "", operation: "", detail: "schema"})` only when an operation's exact arguments are unclear. +- Report graph-backed paths and symbol IDs. Never invent a result when an operation returns no match. === home/.hermes/skills/refactoring/gortex-safe-edit/SKILL.md === --- name: gortex-safe-edit @@ -1552,58 +574,21 @@ metadata: platforms: [linux, macos, windows] related_skills: [gortex] --- -# Safe Edit with Gortex (speculative-execution path) +# Make a Safe Edit with Gortex -Use this **before** you touch `edit_file` / `edit_symbol` / `batch_edit` / `Edit` / `Write`. It runs the edit against the in-memory shadow graph first, reports broken callers + broken interface implementors + blast-radius rollup + suggested tests, and only promotes to disk once those signals are clean. +1. Localize with `explore` and inspect `read({operation: "editing_context", target: {file: ""}})`. +2. Run `change({operation: "impact", source: {symbols: [""]}})` before editing. +3. Choose `edit` operation `file`, `symbol`, or `batch`. Preview with `dry_run: true`; after review, repeat the same guarded request with `dry_run: false`. Use `change({operation: "simulate", source: {steps: ""}})` for a multi-step semantic edit. +4. Run `change` operations `detect`, `tests`, `guards`, and `contract`. Signature verification belongs before mutation with the proposed signature. -## Workflow (do not skip steps) +## Required behavior -``` -1. smart_context({task: ""}) -> Working-set bundle -2. surface_memories({task, symbol_ids}) -> Cross-session invariants on the same symbols -3. get_editing_context({path: ""}) -> Pre-edit signature + caller map -4. preview_edit({edit: }) -> Single-shot speculative apply on the shadow graph - -> Inspect: touched / added / removed / renamed / broken_callers / broken_implementors / impact / suggested_tests -5. simulate_chain({steps: [{edit: }, {edit: }, ...], -> Multi-step ordered simulation - inherit_overlay: true, stop_on_error: true, - keep: false}) - -> Use when the change is more than one WorkspaceEdit (cross-file refactor, signature + every caller, etc.) -6. If broken_callers or broken_implementors is non-empty -> Fix the chain first, re-run preview_edit / simulate_chain -7. check_guards({ids: []}) -> Team conventions from .gortex.yaml -8. verify_change({id: , new_signature: "..."}) -> Final signature check before disk write -9. batch_edit({edits: [...]}) -> Apply the same edits on disk in dependency order -10. detect_changes({scope: "all"}) + diff_context({scope: "all"}) -> Confirm the on-disk diff matches the simulated one -11. get_test_targets({ids: []}) -> Tests to re-run (cross-repo aware) -``` - -## When to use `preview_edit` vs `simulate_chain` - -| Situation | Tool | Why | -| ------------------------------------------------------ | ---------------- | --- | -| One self-contained `WorkspaceEdit` (e.g. one rename) | preview_edit | Single round-trip, simplest. | -| Several dependent edits that must be applied in order | simulate_chain | First-failing-step semantics; `inherit_overlay` carries state forward. | -| You want the simulated state to become the live overlay so subsequent queries see it | simulate_chain with `keep: true` | Promotes the final shadow state into a real overlay session — graph queries see post-edit reality without writing disk. | -| Optional LSP diagnostics on the simulated content | Either, with `with_diagnostics: true` | Drives `didChange(simulated) → wait → didChange(original)` round-trip; concurrent sessions never observe simulated state as authoritative. | - -## Reading the speculative report - -- **broken_callers** — callers whose call sites won't type-check against the new signature. Fix or update every one before disk. -- **broken_implementors** — interface implementors that no longer satisfy the interface contract. Either widen the contract, update implementors, or revert. -- **impact** — `analysis.AnalyzeImpact` blast-radius rollup. The same risk tiers `/gortex-impact` reports. -- **suggested_tests** — what to feed to `get_test_targets` after the apply. -- **added / removed / renamed** — non-trivial-signature unambiguous-candidate heuristic; bare `func ()` voids reject as ambiguous, so you may see fewer "renamed" entries than you expect — that is correct behaviour. - -## Checklist - -- `smart_context` to orient a non-trivial task (skip for a single known symbol — use `search_symbols` / `get_symbol_source`) -- `surface_memories` on the same working set — pick up cross-session decisions / gotchas -- `preview_edit` (single edit) or `simulate_chain` (ordered chain) **before** any on-disk write -- Treat `broken_callers` / `broken_implementors` as blockers, not warnings -- `check_guards` + `verify_change` after the simulated diff is clean, before `batch_edit` -- `batch_edit` (dependency-ordered, atomic re-index between steps) over manual sequencing -- `detect_changes` + `diff_context` to confirm the on-disk diff matches the simulation -- `get_test_targets` to run the right tests, cross-repo aware -- If the edit is durable knowledge ("X must hold the lock", "this package never uses gob"), `store_memory` so the next agent inherits the lesson +- Native Gortex MCP is mandatory for this MCP-configured workflow. If its callable tools are missing, report a Gortex MCP integration failure and stop; do not start a daemon or switch to a CLI/shell fallback. +- Do not substitute shell reads, file search, or Git plumbing. +- Start task-shaped work with `explore`. For one already-known symbol, start with `search`. +- Use `change` before a mutation and again after it. Write only through `edit` or `refactor`. +- Call `capabilities({domain: "", operation: "", detail: "schema"})` only when an operation's exact arguments are unclear. +- Report graph-backed paths and symbol IDs. Never invent a result when an operation returns no match. === home/.hermes/skills/testing/gortex-add-test/SKILL.md === --- name: gortex-add-test @@ -1616,60 +601,19 @@ metadata: platforms: [linux, macos, windows] related_skills: [gortex] --- -# Add Tests with Gortex (coverage-led) - -Use this when the user wants tests added for under-covered code, untested symbols, or a specific regression. Walks the coverage analyzers first so the new test goes where it matters; uses `suggest_pattern` + `scaffold` to author the new test in the style of an existing one rather than from scratch. - -## Workflow (do not skip steps) - -``` -1. gortex enrich coverage (CLI) -> Stamp meta.coverage_pct on executable symbols from cover.out -2. analyze({kind: "coverage_summary", path_prefix: "/"}) -> Per-directory rollup (avg / covered / partial / uncovered) -3. analyze({kind: "coverage_gaps", -> Symbols inside [min_pct, max_pct) — the candidate list - path_prefix: "/", - min_pct: 0, max_pct: 50}) -4. get_untested_symbols({path_prefix: "/"}) -> Symbols with no covering test -5. For the chosen target symbol: - get_editing_context({path: ""}) -> Signature, callers, callees - get_callers({id: ""}) -> How the symbol is invoked in real code (drives test inputs) -6. suggest_pattern({example_id: ""}) -> Extracts the test-authoring pattern (source + registration + tests) -7. scaffold({kind: "test", from: "", target: ""}) -> Generate test stub + wiring -8. preview_edit({edit: }) -> apply / batch_edit -> Speculative apply, then on-disk -9. get_test_targets({ids: []}) -> Confirms the new test file is discovered + maps to a run command -10. Run the test command; if green, re-check `analyze kind=coverage_gaps` to confirm the gap closed -11. check_guards + detect_changes + diff_context -> Standard post-edit gates -``` - -## Picking the right target - -| Source of priority | Tool | Use when | -| ----------------------------------- | ----------------------------------------------------------- | -------- | -| User named the symbol | `search_symbols` -> `get_untested_symbols` (confirm gap) | Direct request. | -| Recent regression | `detect_changes` -> `analyze kind=coverage_gaps` on the change set | "Add a test for what just broke." | -| Hot path with weak coverage | `analyze kind=hotspots` -> filter by `coverage_pct` < 50 | High-impact gaps. | -| Whole directory | `analyze kind=coverage_summary` -> walk lowest-coverage subdirs | "Cover this package." | -| Mutation hotspot without tests | `analyze kind=field_writers` + `get_untested_symbols` | Race / state-mutation risk. | - -## Authoring the test - -`suggest_pattern` returns the **shape** of a comparable test in the project (table-driven, parallel, golden-file, etc.) so the new test feels native. `scaffold` then materialises the file with the right imports, package, and registration. Hand-edit only the bits `scaffold` couldn't infer (inputs, expected values, edge cases). - -When the target is interface-implementing or LSP-driven, also run `find_implementations` and ensure each implementor has at least one test in the new file (table-driven test with one row per implementor is idiomatic in this repo). - -## Closing the loop +# Add Tests with Gortex -After the new test runs green: -1. `gortex enrich coverage` (CLI) to refresh `meta.coverage_pct` -2. Re-run `analyze kind=coverage_gaps` over the same scope -3. Confirm the symbol is no longer in the gap list; if it still is, the test exercises a different branch — add another row +1. Localize the behavior with `explore`. +2. Confirm the specific gap with `change({operation: "tests", source: {symbols: [""]}})`. Use repository-wide `analyze({kind: "untested"})` or path-scoped `analyze({kind: "coverage_gaps", options: {path_prefix: ""}})` only for broader coverage discovery. +3. Inspect callers with `relations({operation: "callers", target: {symbol: ""}})` and request targets with `change({operation: "tests", source: {symbols: [""]}})`. +4. Add the narrowest test with `edit({operation: "file", target: {file: ""}, ...})`; use operation `write` for a new test file. +5. Run the test, then require `change` operations `detect` and `guards`. -## Checklist +## Required behavior -- Run `gortex enrich coverage` (CLI) at least once per session so the coverage analyzers have data to work with -- `analyze kind=coverage_summary` for the per-directory rollup; `analyze kind=coverage_gaps` for the per-symbol list -- `get_untested_symbols` is the truth for "zero covering tests" — coverage_pct=0 ≠ "no test"; some tests register but don't execute -- `suggest_pattern` + `scaffold` for the test body — author in the project's style, not from scratch -- `preview_edit` even for new files; the speculative report flags broken_callers if you accidentally add a name that collides -- `get_test_targets` after the apply — the test must be discoverable + mapped to a run command -- Re-run `analyze kind=coverage_gaps` once the test is green; confirm the gap actually closed (a passing test that doesn't exercise the gap is theatre) -- If the test encodes a project-wide invariant ("Bar must hold the lock"), `store_memory kind:invariant` so the next agent inherits the constraint +- Native Gortex MCP is mandatory for this MCP-configured workflow. If its callable tools are missing, report a Gortex MCP integration failure and stop; do not start a daemon or switch to a CLI/shell fallback. +- Do not substitute shell reads, file search, or Git plumbing. +- Start task-shaped work with `explore`. For one already-known symbol, start with `search`. +- Use `change` before a mutation and again after it. Write only through `edit` or `refactor`. +- Call `capabilities({domain: "", operation: "", detail: "schema"})` only when an operation's exact arguments are unclear. +- Report graph-backed paths and symbol IDs. Never invent a result when an operation returns no match. diff --git a/cmd/gortex/testdata/agent-render/kiro.txt b/cmd/gortex/testdata/agent-render/kiro.txt index 5659f8a94..d74424b07 100644 --- a/cmd/gortex/testdata/agent-render/kiro.txt +++ b/cmd/gortex/testdata/agent-render/kiro.txt @@ -1,42 +1,37 @@ === root/.kiro/hooks/gortex-post-edit.json === { - "name": "Gortex: Post-Edit Impact Check", + "name": "Gortex: Post-Edit 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.", + "description": "Check impact and tests after a source edit.", "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." + "prompt": "Call Gortex change with operation detect for unstaged changes. Use its affected symbol IDs with operations tests, guards, and contract; run the selected tests and report every result." } } === root/.kiro/hooks/gortex-pre-read.json === { - "name": "Gortex: Enrich File Reads", + "name": "Gortex: Enrich Source Read", "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"] - }, + "description": "Use indexed source context before a raw source-file read.", + "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." + "prompt": "For indexed source code, use Gortex read with operation editing_context or source instead of a raw file read. Skip generated files, metadata, documentation, configuration, and non-source assets." } } === root/.kiro/hooks/gortex-smart-context.json === { - "name": "Gortex: Smart Context on Prompt", + "name": "Gortex: Task Context", "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" - }, + "description": "Localize each coding task with the graph before opening files.", + "when": {"type": "userTriggered"}, "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." + "prompt": "For a coding task, call Gortex explore with operation task and the user's task text before reading or searching files. Skip this only for non-coding conversation." } } === root/.kiro/settings/mcp.json === @@ -47,50 +42,17 @@ "mcp" ], "autoApprove": [ - "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" + "capabilities", + "change", + "explore", + "read", + "recall", + "relations", + "response", + "search", + "trace", + "workspace" ], "command": "gortex", "disabled": false, @@ -105,261 +67,65 @@ inclusion: manual --- -# Debugging with Gortex - -## Workflow - -1. `search_symbols({query: ""})` — find related symbols -2. `get_callers({id: ""})` — who calls it? -3. `get_call_chain({id: ""})` — what does it call? -4. `get_editing_context({path: ""})` — full file context -5. `get_process({id: ""})` — trace execution flow - -## Debugging patterns +# Debug with Gortex -| 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 | +1. Localize the symptom with `explore`. +2. Use `search` with `operation:"text"` for an exact error and `operation:"symbols"` for named code. +3. Use `relations({operation:"callers", ...})` and `trace({operation:"call_chain", ...})` to follow execution. +4. Use `trace` with `flow` or `taint` when the bug concerns values crossing helpers. +5. Read only the suspect symbols, then state the root cause and evidence. === root/.kiro/steering/gortex-explore.md === --- inclusion: manual --- -# Exploring Codebases with Gortex +# Explore 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: ""})` — find symbols related to a concept -4. `get_processes` — discover execution flows -5. `get_process({id: ""})` — trace a specific flow step by step -6. `get_editing_context({path: ""})` — 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) +1. Call `explore({operation:"task", task:""})`. +2. Narrow names with `search({operation:"symbols", query:""})` or literals with `operation:"text"`. +3. Read only the needed source with `read` (`source`/`summary`/`editing_context`). +4. Prove relationships with `relations` or execution paths with `trace`. +5. Return symbol IDs, file:line locations, and the shortest evidence needed. === root/.kiro/steering/gortex-impact.md === --- inclusion: manual --- -# Impact Analysis with Gortex - -## Workflow +# Assess change impact with Gortex -1. `search_symbols({query: "X"})` — find the symbol ID -2. `explain_change_impact({ids: ", "})` — risk-tiered blast radius -3. `get_dependents({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 +1. Resolve the target with `explore` or `search`. +2. Call `change` with `operation:"impact"`. +3. For signature changes, call `change` with `operation:"verify"`. +4. Check `relations` usages/dependents and `analyze` contracts when boundaries are involved. +5. Call `change` with `operation:"tests"` and report concrete tests. === root/.kiro/steering/gortex-refactor.md === --- inclusion: manual --- -# Refactoring with Gortex - -## Workflow - -1. `search_symbols({query: "X"})` — find the symbol ID -2. `explain_change_impact({ids: ""})` — map blast radius -3. `get_editing_context({path: ""})` — see all symbols and relationships -4. `find_usages({id: ""})` — every reference to change -5. `get_edit_plan({ids: ""})` — dependency-ordered edit sequence -6. Edit in order: interfaces -> implementations -> callers -> tests -7. `detect_changes({scope: "all"})` — verify after changes - -## Rename symbol +# Refactor with Gortex -- `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 +1. Start with `explore` and read the target through `read`. +2. Call `change` operations `impact` and `edit_plan` before writing; for a signature change, also call `verify` with the proposed signature. +3. Use `refactor` for rename, move, inline, delete, or code actions. Use `edit` for file, symbol, batch, or new-file changes. +4. After writing, call `change` operations `detect`, `tests`, `guards`, and `contract`. +5. Run the project tests selected by the graph. === root/.kiro/steering/gortex-workflow.md === --- 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 --arg k=v` (e.g. `gortex call read_file --arg path=`) — there is no bare `gortex ` 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: ""` (`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: ` — 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:""` — 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:""` — 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. +# Gortex workflow -| Trigger | Use | -|----------------------------------------------------------|--------------------------------------------------------------------------------| -| Immediately after `smart_context` (every new task) | `surface_memories task:"" symbol_ids:""` — 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:"" body:"" symbol_ids:"" importance:5` — pin load-bearing memories. | -| A memory is no longer true | `store_memory body:"" supersedes:""` — preserves audit trail; old memory hidden by default. | +Use Gortex MCP tools for indexed code. This is mandatory. -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. +1. Start every coding task with `explore` using `operation: "task"` and the user's task text. +2. Use `search` for symbols, text, files, or AST shapes; use `read` for file, source, summary, or editing context. +3. Use `relations` for usages, callers, dependencies, dependents, and implementations; use `trace` for call chains and dataflow. +4. Before mutation, call `change` with `operation: "impact"`; for a signature change, also call operation `verify` with the proposed signature. +5. Mutate only with `edit` or `refactor`. After mutation, call `change` operations `detect`, `tests`, `guards`, and `contract`. +6. Call `capabilities` with `domain`, `operation`, and `detail: "schema"` when exact arguments are not already visible. -## Session workflow +Do not replace graph reads or searches with shell commands. If the configured Gortex tools are missing from the callable MCP tools, report a Gortex MCP integration failure and stop; do not start a daemon or use a CLI/shell fallback. -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:""` and `query_memories symbol_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. +For durable context, use `recall` (`surface`/`notes`/`memories`) before editing known code and `remember` (`note`/`memory`) for decisions and invariants. diff --git a/cmd/gortex/testdata/agent-render/pi.txt b/cmd/gortex/testdata/agent-render/pi.txt index a81c20566..1a05e5300 100644 --- a/cmd/gortex/testdata/agent-render/pi.txt +++ b/cmd/gortex/testdata/agent-render/pi.txt @@ -4,21 +4,15 @@ // 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 +// 1. Exposes Gortex's compact public 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 `, 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. +// relays the exact request object to the daemon. The fixed public roster +// keeps Pi aligned with every MCP host without exposing implementation +// aliases or requiring runtime catalogue discovery. // // 2. Re-creates the Claude-Code "prefer graph tools over raw file // reads" enforcement. Rather than re-implementing the deny logic in @@ -37,9 +31,8 @@ // false -> a JSON boolean: whether to wire the read-discipline // enforcement (false when installed with --no-hooks; // the graph tools + orientation are still wired) -// "core" -> a JSON string: the default eager tool preset -// (core|edit|nav|readonly|full); GORTEX_TOOLS in the -// environment overrides it at runtime. +// "## MANDATORY: Use the Gortex public CLI mirror instead of raw source reads/searches\n\nThis harness has no native MCP transport. Invoke public Gortex tools only as `gortex call \u003ctool\u003e`; never invent a bare `gortex \u003ctool\u003e` command.\n\n1. Start every coding task with `gortex call explore --arg task=\"\u003ctask\u003e\"`.\n2. Inspect with `gortex call search`, `gortex call read`, `gortex call relations`, or `gortex call trace` instead of Read/Grep/Glob or shell equivalents.\n3. Before mutation call `gortex call change --arg operation=impact`. Mutate only through `gortex call edit` or `gortex call refactor`; afterward run change operations `detect`, `tests`, `guards`, and `contract`.\n4. Use `gortex call capabilities` only when exact operation fields are unknown.\n" -> a JSON string: the shared mandatory public-tool +// workflow injected once per session. // // Each is emitted as a JSON literal so values containing spaces survive. // @@ -60,15 +53,7 @@ import { execFileSync, spawn } from "node:child_process"; const GORTEX_BIN: string = "gortex"; const HOOK_ARGV: string[] = ["gortex","hook","--agent=pi"]; const ENFORCE: boolean = false; - -// 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) || "core").trim(); +const GORTEX_INSTRUCTIONS: string = "## MANDATORY: Use the Gortex public CLI mirror instead of raw source reads/searches\n\nThis harness has no native MCP transport. Invoke public Gortex tools only as `gortex call \u003ctool\u003e`; never invent a bare `gortex \u003ctool\u003e` command.\n\n1. Start every coding task with `gortex call explore --arg task=\"\u003ctask\u003e\"`.\n2. Inspect with `gortex call search`, `gortex call read`, `gortex call relations`, or `gortex call trace` instead of Read/Grep/Glob or shell equivalents.\n3. Before mutation call `gortex call change --arg operation=impact`. Mutate only through `gortex call edit` or `gortex call refactor`; afterward run change operations `detect`, `tests`, `guards`, and `contract`.\n4. Use `gortex call capabilities` only when exact operation fields are unknown.\n"; // Names (all gortex_-prefixed) of the Gortex tools registered into Pi. const gortexToolNames = new Set(); @@ -189,41 +174,40 @@ function normalizeToolCall( } // --------------------------------------------------------------------------- -// Dynamic tool registration +// Compact public-tool registration // --------------------------------------------------------------------------- interface ToolDescriptor { name: string; - summary?: string; - description?: 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 []; -} +// Keep this roster closed and agent-neutral. The bare names are the exact +// public CLI/MCP names; Pi adds only its collision-avoidance prefix at the +// registration boundary. +const PUBLIC_TOOLS: ToolDescriptor[] = [ + { name: "explore", description: "Localize a task and return ranked source plus call paths. Call first." }, + { name: "search", description: "Search indexed symbols, text, files, or AST shapes." }, + { name: "read", description: "Read indexed files, symbols, summaries, or editing context." }, + { name: "relations", description: "Find usages, callers, dependencies, dependents, or implementations." }, + { name: "trace", description: "Trace call chains, value flow, or taint paths." }, + { name: "analyze", description: "Run a graph analysis; use kind help to list analyses." }, + { name: "ask", description: "Answer a codebase question from graph evidence." }, + { name: "change", description: "Plan and check mutations: impact, verify, detect, tests, guards, or contract." }, + { name: "edit", description: "Apply file, symbol, batch, or new-file edits." }, + { name: "refactor", description: "Rename, move, inline, delete, or apply code actions." }, + { name: "review", description: "Review changes with graph context." }, + { name: "publish_review", description: "Publish prepared review feedback." }, + { name: "pr", description: "Inspect pull-request changes, context, or risk." }, + { name: "recall", description: "Retrieve notes or memories relevant to the work." }, + { name: "remember", description: "Store durable decisions, invariants, or gotchas." }, + { name: "workspace", description: "Inspect workspace and repository state." }, + { name: "workspace_admin", description: "Perform explicit workspace administration." }, + { name: "overlay", description: "Compare or manage session overlay state." }, + { name: "session", description: "Manage session state or subscriptions." }, + { name: "response", description: "Control response encoding or pagination." }, + { name: "capabilities", description: "Get exact operations and request schemas only when needed." }, +]; // safeRegister registers a Pi tool, tolerating a redundant registration — // a config reload can re-fire session_start without resetting the session's @@ -236,12 +220,10 @@ function safeRegister(pi: any, def: any): void { } } -// registerOneTool registers a single Gortex tool as a native Pi tool named -// `gortex_`, whose execute() shells `gortex call `. 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. +// registerOneTool registers one public Gortex tool as a native Pi tool named +// `gortex_`, whose execute() shells `gortex call `. The `--json` +// value is the model's exact request object; the adapter does not add output +// fields or translate operation names. function registerOneTool(pi: any, desc: ToolDescriptor): void { const bare = desc.name; if (!bare) return; @@ -251,7 +233,7 @@ function registerOneTool(pi: any, desc: ToolDescriptor): void { safeRegister(pi, { name, label: name, - description: (desc.description || desc.summary || name).trim(), + description: desc.description, // 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. @@ -263,8 +245,6 @@ function registerOneTool(pi: any, desc: ToolDescriptor): void { bare, "--json", JSON.stringify(params ?? {}), - "--format", - "gcx", ]); return { content: [{ type: "text", text: out }], details: {} }; } catch (err: any) { @@ -276,124 +256,7 @@ function registerOneTool(pi: any, desc: ToolDescriptor): void { } 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) { - 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: {} }; - }, - }); + for (const desc of PUBLIC_TOOLS) registerOneTool(pi, desc); } // --------------------------------------------------------------------------- @@ -402,22 +265,22 @@ function registerSearchTool(pi: any): void { 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 + // Mandatory workflow and hook orientation awaiting injection into the next + // LLM call. The `context` hook appends them 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 = ""; + let pendingOrientation = GORTEX_INSTRUCTIONS; // 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 = ""; + pendingOrientation = GORTEX_INSTRUCTIONS; ensureDaemon(); gortexToolNames.clear(); registerGortexTools(pi); - registerSearchTool(pi); }); // Fires before the agent loop's first LLM call. It can't mutate messages @@ -426,9 +289,9 @@ export default function (pi: any) { if (orientationInjected) return; const decision = callHook({ event: "session_start", cwd: pi?.cwd ?? process.cwd() }); if (decision.orientation) { - pendingOrientation = decision.orientation; - orientationInjected = true; + pendingOrientation = [GORTEX_INSTRUCTIONS, decision.orientation].filter(Boolean).join("\n\n"); } + orientationInjected = true; return; }); diff --git a/cmd/gortex/tool_did_you_mean.go b/cmd/gortex/tool_did_you_mean.go index 40554803e..6c7587f78 100644 --- a/cmd/gortex/tool_did_you_mean.go +++ b/cmd/gortex/tool_did_you_mean.go @@ -11,11 +11,10 @@ import ( // maybeToolInvocationHint intercepts the `gortex ` misuse: an agent that // saw a bare MCP tool name and tried to run it as a top-level verb (e.g. -// `gortex read_file foo.go`). There is no such verb — the tool is reachable -// only as `gortex call --arg …`. Cobra would print a bare "unknown -// command" with no recovery path; instead, when the first positional argument -// names a registered MCP tool that is NOT already a cobra subcommand/alias, -// print a did-you-mean and return true so the caller exits nonzero. +// `gortex read_file foo.go`). There is no such verb: every tool uses `gortex +// call `, which selects the correct legacy or compact surface for that +// connection. Cobra would otherwise print a bare "unknown command" with no +// recovery path. // // Cheap and daemon-free: the fast rejects (flag, known verb) run first, so the // tool registry is only consulted for an argument that is otherwise an unknown @@ -28,10 +27,15 @@ func maybeToolInvocationHint(w io.Writer, args []string) bool { if isKnownRootCommand(verb) { return false // a real cobra verb — let cobra route it } + if gortexmcp.IsFacadeToolName(verb) { + fmt.Fprintf(w, "gortex: %q is a compact Gortex MCP tool, not a bare command.\n", verb) + fmt.Fprintf(w, "Run it from a shell with:\n %s\n", cliInvocationForTool(verb)) + fmt.Fprintln(w, "The argument object is the same as the compact MCP schema; use `gortex call capabilities --arg domain=` to discover operations.") + return true + } if gortexmcp.IsRegisteredToolName(verb) { - fmt.Fprintf(w, "gortex: %q is not a gortex command, but it is a Gortex MCP tool.\n", verb) - fmt.Fprintf(w, "Run it from a shell with:\n %s\n", toolref.CLIFallback(verb)) - fmt.Fprintln(w, "General form: gortex call --arg k=v (there is no bare `gortex ` verb).") + fmt.Fprintf(w, "gortex: %q is a legacy Gortex MCP name, not a bare command.\n", verb) + fmt.Fprintf(w, "Use the public operation, or discover its exact schema first:\n %s\n", cliInvocationForTool(verb)) return true } @@ -45,12 +49,40 @@ func maybeToolInvocationHint(w io.Writer, args []string) bool { } fmt.Fprintf(w, "gortex: unknown command %q. The closest Gortex MCP tools:\n", verb) for _, c := range candidates { - fmt.Fprintf(w, " %s\n", toolref.CLIFallback(c)) + fmt.Fprintf(w, " %s\n", cliInvocationForTool(c)) } fmt.Fprintln(w, "General form: gortex call --arg k=v (there is no bare `gortex ` verb).") return true } +func cliInvocationForTool(tool string) string { + if gortexmcp.IsFacadeToolName(tool) { + switch tool { + case "analyze": + return "gortex call analyze --arg kind=''" + case "ask": + return "gortex call ask --arg question=''" + case "capabilities": + return "gortex call capabilities --arg domain=''" + case "explore": + return "gortex call explore --arg task=''" + case "read": + return `gortex call read --arg target='{"file":""}'` + case "search": + return "gortex call search --arg operation=symbols --arg query=''" + default: + return "gortex call " + tool + " --arg operation=''" + } + } + if example, ok := toolref.ConcreteCLIFallback(tool); ok { + return example + } + if domain, operation, ok := gortexmcp.PublicOperationForLegacy(tool); ok { + return "gortex call capabilities --arg domain='" + domain + "' --arg operation='" + operation + "' --arg detail=schema" + } + return toolref.CLIFallback(tool) +} + // fuzzyMinVerbLen gates the fuzzy tool match: shorter fragments are too // ambiguous to suggest anything for, so they fall through to cobra. const fuzzyMinVerbLen = 4 diff --git a/cmd/gortex/tool_did_you_mean_test.go b/cmd/gortex/tool_did_you_mean_test.go index 37601feea..b88685ebb 100644 --- a/cmd/gortex/tool_did_you_mean_test.go +++ b/cmd/gortex/tool_did_you_mean_test.go @@ -18,31 +18,49 @@ func TestMaybeToolInvocationHint(t *testing.T) { name: "read_file tool name", args: []string{"read_file", "foo.go"}, wantHint: true, - wantContains: []string{"gortex call read_file --arg path=", "not a gortex command"}, + wantContains: []string{"gortex call read --arg target='{\"file\":\"\"}'", "legacy Gortex MCP name"}, + }, + { + name: "long-tail legacy name maps to public discovery", + args: []string{"flow_between"}, + wantHint: true, + wantContains: []string{"gortex call capabilities --arg domain='trace' --arg operation='flow'", "legacy Gortex MCP name"}, }, { name: "get_symbol_source tool name", args: []string{"get_symbol_source"}, wantHint: true, - wantContains: []string{"gortex call get_symbol_source --arg"}, + wantContains: []string{"gortex call read --arg target='{\"symbol\":\"::\"}'"}, + }, + { + name: "dedicated facade tool name", + args: []string{"read"}, + wantHint: true, + wantContains: []string{"gortex call read --arg target='{\"file\":\"\"}'", "gortex call capabilities"}, }, { name: "tool name behind leading global flag", args: []string{"--config", "x.yaml", "read_file", "foo.go"}, wantHint: true, - wantContains: []string{"gortex call read_file --arg path="}, + wantContains: []string{"gortex call read --arg target='{\"file\":\"\"}'"}, }, { name: "fuzzy: reindex suggests reindex_repository", args: []string{"reindex"}, wantHint: true, - wantContains: []string{`unknown command "reindex"`, "gortex call reindex_repository --arg path="}, + wantContains: []string{`unknown command "reindex"`, "gortex call workspace_admin --arg operation=reindex"}, }, { name: "fuzzy: index suggests index_repository", args: []string{"index", "."}, wantHint: true, - wantContains: []string{`unknown command "index"`, "gortex call index_repository --arg path="}, + wantContains: []string{`unknown command "index"`, "gortex call workspace_admin --arg operation=index"}, + }, + { + name: "fuzzy facade uses negotiating command", + args: []string{"publish"}, + wantHint: true, + wantContains: []string{`unknown command "publish"`, "gortex call publish_review --arg operation=''"}, }, { name: "real cobra command is not intercepted", @@ -102,3 +120,11 @@ func TestMaybeToolInvocationHint(t *testing.T) { }) } } + +func TestCompactCLIInvocationQuotesShellPlaceholders(t *testing.T) { + for _, tool := range []string{"analyze", "ask", "capabilities", "explore", "search", "relations"} { + if got := cliInvocationForTool(tool); strings.Contains(got, "=<") { + t.Errorf("cliInvocationForTool(%q) contains shell redirection syntax: %s", tool, got) + } + } +} diff --git a/cmd/gortex/tool_preset_flags.go b/cmd/gortex/tool_preset_flags.go index 81fe38512..8e1cd6616 100644 --- a/cmd/gortex/tool_preset_flags.go +++ b/cmd/gortex/tool_preset_flags.go @@ -15,6 +15,7 @@ func applyToolPresetFlags(cfg *config.Config, flagTools, flagMode string) { return } if flagTools != "" { + cfg.MCP.Tools.Explicit = true preset, allow, deny := gortexmcp.ParseToolSpec(flagTools) if preset != "" { cfg.MCP.Tools.Preset = preset @@ -23,6 +24,7 @@ func applyToolPresetFlags(cfg *config.Config, flagTools, flagMode string) { cfg.MCP.Tools.Deny = append(cfg.MCP.Tools.Deny, deny...) } if flagMode != "" { + cfg.MCP.Tools.Explicit = true cfg.MCP.Tools.Mode = flagMode } } diff --git a/cmd/gortex/tool_preset_flags_test.go b/cmd/gortex/tool_preset_flags_test.go new file mode 100644 index 000000000..63f1ab7e0 --- /dev/null +++ b/cmd/gortex/tool_preset_flags_test.go @@ -0,0 +1,18 @@ +package main + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/zzet/gortex/internal/config" +) + +func TestApplyToolPresetFlagsMarksExplicitRollback(t *testing.T) { + cfg := config.Default() + require.False(t, cfg.MCP.Tools.Explicit) + applyToolPresetFlags(cfg, "core", "defer") + require.True(t, cfg.MCP.Tools.Explicit) + require.Equal(t, "core", cfg.MCP.Tools.Preset) + require.Equal(t, "defer", cfg.MCP.Tools.Mode) +} diff --git a/cmd/gortex/tools_cmd.go b/cmd/gortex/tools_cmd.go index b149697dd..4f89cfcb4 100644 --- a/cmd/gortex/tools_cmd.go +++ b/cmd/gortex/tools_cmd.go @@ -59,7 +59,7 @@ func init() { toolsListCmd.Flags().StringVar(&toolsListCategory, "category", "", "show only tools in this functional category") toolsListCmd.Flags().BoolVar(&toolsListMutating, "mutating", false, "show only tools that write to the working tree / graph") - toolsListCmd.Flags().StringVar(&toolsListPreset, "preset", "", "show only tools published by this builtin preset (core|edit|nav|readonly)") + toolsListCmd.Flags().StringVar(&toolsListPreset, "preset", "", "show only tools published by this builtin preset (compact|core|edit|nav|readonly)") toolsListCmd.Flags().StringVar(&toolsListFormat, "format", "text", "output format: text or json") toolsSearchCmd.Flags().IntVar(&toolsSearchLimit, "limit", 10, "max number of tools to return") @@ -122,6 +122,9 @@ func runToolsList(cmd *cobra.Command, _ []string) error { // descriptorHasPreset reports whether the descriptor lists the given preset // (case-insensitive). func descriptorHasPreset(d toolDescriptorCLI, preset string) bool { + if strings.EqualFold(strings.TrimSpace(preset), "compact") { + preset = "facade-v1" + } for _, p := range d.Presets { if strings.EqualFold(p, preset) { return true diff --git a/docs/agents.md b/docs/agents.md index 8929e3ee3..cad01829c 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -24,7 +24,7 @@ commands accept `--agents=` to constrain setup and | `aider` | `.aiderignore` block, `CONVENTIONS.md` communities block | project | https://aider.chat/docs/config/aider_conf.html | | `antigravity` | `~/.gemini/antigravity/mcp_config.json` + Knowledge Item | user | https://antigravity.google/docs/mcp | | `cline` | `cline_mcp_settings.json` (per VS Code / Cursor globalStorage), `.clinerules/gortex-communities.md` | both | https://docs.cline.bot/mcp/mcp-overview | -| `codex` | `~/.codex/config.toml` (`[mcp_servers.gortex]` + `SessionStart` / Bash + Gortex MCP read-tool `PreToolUse` / Bash `PostToolUse` hooks), `AGENTS.md` communities block | both | https://developers.openai.com/codex/mcp | +| `codex` | `~/.codex/config.toml` (`[mcp_servers.gortex]` + `SessionStart`, `UserPromptSubmit`, Bash/Gortex-read `PreToolUse`, and Bash/`apply_patch` `PostToolUse` hooks), `AGENTS.md` communities block | both | https://developers.openai.com/codex/mcp | | `continue` | `.continue/mcpServers/gortex.json`, `.continue/rules/gortex-communities.md` | project | https://docs.continue.dev/customize/deep-dives/mcp | | `cursor` | `.cursor/mcp.json` (project) or `~/.cursor/mcp.json`, `.cursor/rules/gortex-communities.mdc` | both | https://docs.cursor.com/en/context/mcp | | `gemini` | `.gemini/settings.json` or `~/.gemini/settings.json`, `GEMINI.md` communities block | both | https://geminicli.com/docs/tools/mcp-server/ | @@ -45,13 +45,44 @@ Mode legend: **project** writes inside the repo (`gortex init` only); the adapter splits: `gortex install` writes the user-level pieces and `gortex init` writes the repo-level pieces. -Tool-usage guidance (how to prefer graph tools over `Read`/`Grep`) no -longer gets duplicated into every repo. For Claude Code and -Antigravity — the two adapters whose upstream tool exposes a -user-level instructions surface — the guidance lives once per user -(installed by `gortex install`). For the other 13, MCP tool -descriptions carry the teaching. Only codebase-derived community -routing lands in per-repo instructions files. +Tool-usage guidance is agent-neutral. Every named MCP connection receives the +same mandatory, compact workflow in the server initialization response. +Adapters also install that workflow where their host exposes a persistent +rules, skill, or extension surface; the wording names only the 21 public tools +and the exact `gortex call` fallback. Codebase-derived community routing still +lives in per-repo instruction files because it is workspace-specific. + +## Compact MCP surface + +Every MCP connection with a non-empty `clientInfo.name` receives the +same 21-tool compact surface in `hide` mode by default. The complete +static roster is documented in [`mcp.md`](mcp.md#compact-mcp-surface). +Empty and pre-initialize sessions retain the server default. Explicit +forwarded, operator, or instruction-profile policy wins over the +named-client default. Wire-format negotiation is independent, so an +unknown named client remains on JSON unless it is separately +GCX-capable. + +An MCP-capable host MUST call the compact tools directly. In +particular, use `read({target:{file:"..."}})` for a +known source file; it MUST NOT spawn a shell command merely to relay +the same request. A harness that exposes Bash but no native Gortex MCP +functions MUST use the exact CLI mirror instead: + +```bash +gortex call read \ + --arg target='{"file":"internal/mcp/server.go"}' + +gortex call search \ + --arg operation=symbols \ + --arg query=Server.addTool +``` + +The CLI accepts the same compact tool names and argument objects; it +does not translate them to legacy names. See the +[`gortex call` reference](cli.md#compact-surface-mirror-for-bash-only-harnesses) +for mutation previews, schema discovery, repository selection, and +the distinction between tool `dry_run` and CLI `--dry`. ## Subagent tool propagation @@ -197,27 +228,56 @@ directory that exists. Auto-approval field is `alwaysAllow` (not OpenAI Codex CLI stores config in `~/.codex/config.toml`. We upsert a `[mcp_servers.gortex]` table there. When hooks are enabled -(the default), Codex receives user-level hooks that keep the integration -soft-only: they add graph context or read-shaping guidance without -denying tools, rewriting input, or suppressing output. +(the default), Codex receives user-level hooks. The default posture remains +advisory. A team can opt into `deny`, `rewrite`, or `suppress` by setting +`GORTEX_CODEX_HOOK_MODE` while running `gortex init --hooks-only`; the selected +posture is written into the managed hook commands. Current Codex hook coverage: | Surface | Coverage | | ------- | -------- | | `SessionStart` | Matches `startup|resume|clear|compact` and emits graph-tools orientation for new, resumed, cleared, and compacted sessions. | -| Bash `PreToolUse` | Soft graph guidance for shell search/read/list shapes. | -| Gortex MCP read-tool `PreToolUse` | `read_file` and `get_editing_context` guidance that nudges source reads toward `compress_bodies`. | -| Bash `PostToolUse` | Output graph enrichment for Bash-wrapped grep/search, source-read, and file-list shapes. | +| Bash `PreToolUse` | Advises by default; `deny` hard-blocks graph-detectable fallback reads/searches; `rewrite` converts only an unambiguous indexed `cat ` into the exact public `gortex call read` mirror. Compound or ambiguous commands remain advisory. | +| Gortex MCP `read` `PreToolUse` | Advises broad file/editing-context reads by default; `deny` blocks them; `rewrite` preserves the request and adds `options.compress_bodies=true`. Selector-driven reads with no explicit operation are covered. | +| Bash `PostToolUse` | Adds graph context for grep/search, source reads, and conservative file-list shapes: `find -name`, `fd`, `ls`, `tree -fi`, and `git ls-files`; bounded `sed`/`awk` reads get file graph context. Execution-capable or ambiguous forms are no-ops. | +| `apply_patch` `PostToolUse` | Runs `detect_changes`, extracts affected symbol IDs, then reports tests, guards, and contracts. The completed mutation is never rolled back by the hook. | +| `UserPromptSubmit` | Re-surfaces prompt-relevant indexed symbols on every turn so long sessions do not rely on the initial reminder alone. | | `gortex init --hooks-only` | Refreshes Codex hooks without rewriting the MCP server config, `AGENTS.md`, or other adapter surfaces. | -We do not install separate Codex `PreCompact` or `PostCompact` hooks -today: Codex ignores plain text stdout for those events, while the -`SessionStart` `compact` source already provides the orientation path -after compaction. We also intentionally do not install `Stop`; Codex -`Stop` can continue a turn, which expands the behavior surface and -should be tracked separately if needed. `apply_patch` remains out of -scope for Codex hooks and should be handled by a dedicated follow-up. +Postures: + +```bash +# Existing behavior (default). +GORTEX_CODEX_HOOK_MODE=enrich gortex init --hooks-only + +# Enforce graph-detectable fallback reads/searches. +GORTEX_CODEX_HOOK_MODE=deny gortex init --hooks-only + +# Rewrite only requests whose semantics can be preserved. +GORTEX_CODEX_HOOK_MODE=rewrite gortex init --hooks-only + +# Replace enrichable raw PostToolUse results with graph feedback. +GORTEX_CODEX_HOOK_MODE=suppress gortex init --hooks-only +``` + +Current [Codex hook behavior](https://developers.openai.com/codex/hooks) +implements deny and `updatedInput`. It does not implement the nominal +`suppressOutput` field; the `suppress` posture therefore uses the supported +PostToolUse result-replacement decision and never emits +`suppressOutput`. + +Intentional Codex lifecycle gaps are tracked separately from missing tool +coverage: + +| Surface | Intentional status | +| ------- | ------------------ | +| `PreCompact` / `PostCompact` | Not installed. `SessionStart(source="compact")` restores the mandatory workflow after compaction without paying for two more lifecycle hooks. | +| `Stop` | Not installed. A Stop hook can continue a turn, so adding it is a separate policy decision rather than implicit mutation handling. | +| Native output-suppression fields | Not emitted while Codex rejects `suppressOutput` and `updatedMCPToolOutput`; the opt-in `suppress` posture uses supported PostToolUse result replacement. | + +`apply_patch` mutation handling and opt-in deny/rewrite/suppress postures are +implemented and are no longer intentional gaps. ### continue @@ -264,7 +324,7 @@ machine-wide user hook stays inert elsewhere): | Surface | Coverage | | ------- | -------- | | `UserPromptSubmit` | Injects graph symbols relevant to the prompt before the model runs. | -| `PreToolUse` | Redirects native `Read`/`Grep`/`Glob`/`Bash` to graph tools — a hard `deny` for an indexed whole-file read, soft plain-stdout guidance otherwise — plus the `read_file`/`get_editing_context` `compress_bodies` nudge. | +| `PreToolUse` | Redirects native `Read`/`Grep`/`Glob`/`Bash` to graph tools — a hard `deny` for an indexed whole-file read, soft plain-stdout guidance otherwise — plus compact `read` shaping for broad file/editing-context operations. | | `Stop` | Runs post-turn diagnostics (changed symbols → test targets, guards, dead code, coverage, contracts) and feeds them back so the agent self-corrects before handoff. | | `SubagentStart` | Briefs a spawned subagent with `smart_context` results and the tool-swap table so it doesn't default to raw `Read`/`Grep`. | @@ -304,10 +364,11 @@ under `mcp.servers.`. **Pi has no MCP support — by design**, so instead of an `mcpServers` stanza this adapter ships a self-contained TypeScript extension at `.pi/extensions/gortex/index.ts` (project) or -`~/.pi/agent/extensions/gortex/index.ts` (global). It registers Gortex's -graph tools natively and re-creates the same read-discipline enforcement -the other agents get (skip it with `--no-hooks`). `GORTEX_TOOLS` selects -the eagerly-registered preset (default `core`), matching the daemon. +`~/.pi/agent/extensions/gortex/index.ts` (global). It registers the same fixed +21 public tools natively through exact `gortex call` request forwarding and +re-creates the same read-discipline enforcement the other clients get (skip it +with `--no-hooks`). It does not expose legacy discovery or depend on dynamic +tool promotion. The read-discipline rules are injected by the extension at runtime. diff --git a/docs/cli.md b/docs/cli.md index bca251251..e540cb477 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -171,20 +171,78 @@ The generic relay: invoke any tool the daemon's MCP surface registers, even one | `--format json\|gcx\|toon\|text` | wire format forwarded to the tool (default `json`) | | `--dry` | print the lowered argument object + target tool **without** calling the daemon (works offline) | | `--quiet` | suppress the mutating-tool stderr note | +| `--legacy` | use the historical handler contract for a name shared by the compact and legacy surfaces (`analyze`, `explore`, `review`, `ask`) | + +### Compact-surface mirror for Bash-only harnesses + +Use this path only when a coding harness exposes Bash but does not +expose Gortex MCP functions natively. An MCP-capable host MUST call the +compact MCP tools directly. A Bash-only harness MUST use `gortex call`, +which accepts the same compact tool names and argument objects without +translating them to legacy names. + +The 21 public names select the compact contract by default. Existing scripts +that call a shared legacy name can retain its historical schema with +`gortex call --legacy analyze ...`; dedicated legacy names already select the +legacy-compatible connection automatically. + +```bash +# Read an uncompressed source file. +gortex call read \ + --arg target='{"file":"internal/mcp/server.go"}' + +# Search for a symbol. +gortex call search \ + --arg operation=symbols \ + --arg query=Server.addTool + +# Discover the exact schema for one operation. +gortex call capabilities \ + --arg domain=read \ + --arg operation=file \ + --arg detail=schema + +# Preview an edit without writing the file. +gortex call edit \ + --arg target='{"file":"README.md"}' \ + --arg 'match=old text' \ + --arg 'replacement=new text' \ + --arg dry_run=true +``` + +Because `target` starts with `{`, normal `--arg` coercion parses it as +a JSON object. `--json` can supply the whole argument object instead. +The edit example's `--arg dry_run=true` calls the tool and asks the +tool to preview the mutation. CLI `--dry` is different: it only prints +the lowered request and never contacts the daemon. + +Non-dry calls require a running daemon that tracks the repository. Use +the shared `--index`/`--repo ` selector when the target repository +is not the current directory. The direct mirror covers all 21 compact +names listed in [`mcp.md`](mcp.md#compact-mcp-surface) and negotiates +the compact surface for that daemon connection without changing global +configuration. Operations under `session` are connection-scoped; a +one-shot `gortex call` connection closes after the result, so use a +persistent native MCP session when later calls must observe that state +or receive subscriptions. + +### Legacy and full-catalog examples + +The same relay can call a legacy or deferred tool explicitly when a +compatibility workflow requires it: ```bash -gortex call explore --arg task="login handler 500s when the session cookie is expired" gortex call smart_context --arg task="add rate limiting to the login handler" gortex call find_usages --arg id="internal/auth/login.go::Login" --format gcx gortex call overlay_push --json-file ./buffer.json # reach a deferred tool by name -gortex call edit_file --arg path=README.md --arg old_string=foo --arg new_string=bar --dry +gortex call edit_file --arg path=README.md --arg old_string=foo --arg new_string=bar --arg dry_run=true ``` ### `gortex tools …` — discover & describe the surface | Verb | MCP tool | Key flags | |---|---|---| -| `tools list` | `tool_profile` | `--category `, `--mutating`, `--preset core\|edit\|nav\|readonly`, `--format text\|json` | +| `tools list` | `tool_profile` | `--category `, `--mutating`, `--preset compact\|core\|edit\|nav\|readonly`, `--format text\|json` | | `tools search ` | `tools_search` | `--limit ` (default 10) — ranks the deferred surface | | `tools describe ` | `tools_search select:` | prints the tool's full parameter schema | | `tools receipt` | `tool_profile` | `--format yaml\|json` (default `yaml`) | diff --git a/docs/mcp-facade-v1.md b/docs/mcp-facade-v1.md new file mode 100644 index 000000000..a63efaedc --- /dev/null +++ b/docs/mcp-facade-v1.md @@ -0,0 +1,653 @@ +# Compact MCP surface specification + +| Field | Value | +| --- | --- | +| Status | Implemented | +| Surface/config identifier | `facade-v1` | +| Specification version | `1.0.1` | +| Last updated | 2026-07-12 | +| Replaces | No existing API; this is an additive facade over the legacy MCP tools | + +## 1. Summary + +Gortex exposes a broad code-intelligence API, but its flat MCP namespace makes clients and harnesses choose among many tools that differ only by operation or target. This specification introduces a small, stable set of **effect-homogeneous domain tools** over the existing handlers. + +The compact surface is a compatibility layer, not a rewrite. Existing handlers remain the implementation substrate, existing CLI and HTTP entry points keep their contracts, and legacy MCP names remain available during migration. Every named MCP client receives a complete eager coding loop without depending on deferred-tool promotion or `notifications/tools/list_changed`. + +The core workflow becomes: + +```text +explore -> search / read / relations -> change(impact; verify a proposed signature) + -> edit / refactor -> change(detect -> tests / guards / contract) +``` + +The facade MUST NOT collapse all behavior into one universal tool. Tool names are authorization boundaries in MCP hosts, so operations with different effects remain separate even when their input shapes are similar. + +The key words **MUST**, **MUST NOT**, **SHOULD**, **SHOULD NOT**, and **MAY** in this document are normative requirements. + +## 2. Goals + +The compact surface MUST: + +1. Reduce the initial MCP description and schema cost for every MCP client and Bash-only harness. +2. Remove unnecessary choices such as `get_symbol` versus `get_symbol_source`, or `edit_file` versus `edit_symbol`. +3. Publish an eager surface that supports the complete locate/read/edit/verify loop. +4. Keep authorization, planning-mode, federation, and approval boundaries correct. +5. Use stable facade request shapes and additive response metadata so adding an operation rarely changes a tool schema or its legacy payload. +6. Preserve legacy MCP, CLI, and HTTP behavior throughout a measured migration. +7. Make the effective per-session surface observable and testable across named MCP clients and harnesses. +8. Generate policy, documentation, compatibility aliases, and introspection from one canonical operation registry. +9. Produce privacy-safe telemetry that detects discovery failures, schema inflation, invalid facade calls, and fallback to non-Gortex source access. + +## 3. Non-goals + +The compact surface does not: + +- Rewrite the graph algorithms or existing handler implementations. +- Remove legacy tool names in the release that introduces the facade. +- Replace existing CLI or HTTP entry points; the compact `gortex call` mirror is additive. +- Put every operation schema into a single large `oneOf` union. +- Depend on a model understanding a dynamic tool-promotion protocol. +- Weaken stale-write guards, dry-run support, planning mode, or remote-write restrictions. +- Combine read, local-write, durable control-write, session-write, and external-write operations under one tool name. +- Wrap or normalize legacy handler payloads; facade responses preserve them and add routing metadata only. + +## 4. Current-state evidence + +The implementation snapshot used for this specification registers **178 tools**. `tool_profile` reports 87 live and 91 deferred tools for the audited core/defer session. The category classifier is best-effort rather than a security boundary, but it illustrates the size and fragmentation of the surface: + +| Current category | Tool count | +| --- | ---: | +| Navigation | 28 | +| Other / context | 31 | +| Overlay | 19 | +| Analysis | 15 | +| Edit | 14 | +| Admin | 14 | +| Memory | 13 | +| Subscription | 10 | +| Workspace | 9 | +| Review | 9 | +| Read | 8 | +| PR | 6 | +| Enrichment | 2 | +| **Total** | **178** | + +The checked-in `tools/list` budget tests record these reference points: + +| Surface | Reference payload | +| --- | ---: | +| Full preset baseline | 289,808 bytes | +| Core preset baseline | 95,060 bytes | +| Agent preset measured payload | 27,883 bytes | +| Agent preset ceiling | 28,200 bytes | + +The legacy agent preset has a 20-tool floor, plus the always-kept discovery and introspection tools. Even that curated surface contains several decisions that are implementation details rather than user intent. The full descriptions also repeat the same server guidance for each tool; in one audited agent-host rendering, the repeated common prefix accounted for approximately 66% of the description characters. + +The audit also exposed a general discovery failure mode: a server-side preset contained `read_file` while one agent bridge did not expose it as callable. `tool_profile` reported global live state rather than the exact client-visible session surface. The compact surface reduces the number of opportunities for this mismatch, but it also requires direct protocol and bridge-exposure regression tests; consolidation alone is not sufficient. + +## 5. Design principles + +### 5.1 Consolidate by intent + +A caller selects a domain verb and supplies intent, not an implementation handler. High-frequency defaults are inferred from selectors: `read(target={symbol:"Server.addTool"})` selects source, while `edit(target={symbol:"Server.addTool"}, ...)` selects symbol editing. An explicit operation remains available for less common views. + +### 5.2 Preserve effect boundaries + +Every facade tool has exactly one effect class. If two operations need different authorization, planning, egress, or persistence treatment, they MUST use different facade tools. + +This rule intentionally splits: + +- Local review from review publication. +- Local deterministic analysis from LLM-backed research. +- Memory reads from memory writes. +- Workspace reads from indexing, tracking, and scope mutation. +- Overlay inspection from overlay-session mutation and disk application. +- Code-action discovery from code-action application. +- Inline generation from writing generated output. + +### 5.3 Keep the common schema small + +Common, high-frequency fields remain typed in the advertised MCP schema. Rare operation-specific fields live under `options` and are validated from the operation registry. `capabilities` returns the exact schema for an operation when needed. + +The server MUST NOT advertise a union of all legacy input schemas on every turn. + +### 5.4 Keep ordinary coding static and eager + +An agent MUST be able to explore, search, read, inspect relations, edit, refactor, and verify from the first `tools/list`. Ordinary coding MUST NOT require `tools_search`, inline schema extraction, or a `tools/list_changed` notification. + +### 5.5 Adapt before deleting + +Facade handlers delegate to existing handlers. Compatibility adapters remain until usage and parity gates show that removal is safe, and removal follows the repository's versioning policy. + +## 6. Public facade surface + +The compact surface defines 21 public tools. The effect is a property of the tool, not merely of an individual operation. + +| Facade | Effect | Purpose | Typical operations | +| --- | --- | --- | --- | +| `explore` | read | Task-shaped localization and context assembly | `task`, `context`, `closure`, `outline`, `plan`, `prefetch`, `suggest`, `wakeup` | +| `search` | read | Search by domain | `symbols`, `text`, `files`, `ast`, `artifacts`, `completion`, `winnow` | +| `read` | read | Read a target; file/symbol/batch/artifact defaults are selector-driven | `file`, `source`, `symbols`, `summary`, `editing_context`, `history`, `artifact` | +| `relations` | read | Fixed-purpose graph relationships | `usages`, `callers`, `dependencies`, `dependents`, `implementations`, `overrides`, `hierarchy`, `cluster`, `declaration`, `import_path`, `references` | +| `trace` | read | Advanced traversal, execution, control, and data flow | `call_chain`, `walk`, `path`, `graph`, `flow`, `taint`, `cfg` | +| `analyze` | read | Deterministic graph analysis | `help`, `todos`, `hotspots`, `dead_code`, `contracts`, `architecture`, and the other read-only registered kinds | +| `ask` | read, open-world | LLM-backed research | `research` | +| `change` | read | Change detection, diagnostics, impact, planning, preview, and verification | `contract`, `detect`, `diagnostics`, `impact`, `preview`, `simulate`, `verify`, `guards`, `tests`, `code_actions`, overlay comparisons | +| `edit` | local write | Textual/file mutation and application of generated or overlay content | `file`, `symbol`, `write`, `batch`, `scaffold`, `docs`, `skill`, `wiki`, `export_graph`, `apply_overlay` | +| `refactor` | local write | Semantic or LSP-backed mutation | `rename`, `move`, `inline`, `delete`, `apply_code_action`, `fix_all` | +| `review` | read, open-world | Review and review context | `run`, `pack`, `critique`, `diff_context`, `sibling_context`, `pr_context`, `questions` | +| `publish_review` | external write | Publish review results to a forge | `post` | +| `pr` | read, open-world | Read and analyze forge pull requests | `list`, `impact`, `risk`, `triage`, `conflicts`, `reviewers` | +| `recall` | read | Read notes, memories, and notebooks | `memories`, `surface`, `notes`, `notebook_find`, `notebook_list`, `notebook_show`, `onboarding`, `distill` | +| `remember` | local write | Store or update durable knowledge | `memory`, `note`, `edit_memory`, `rename_memory`, `notebook`, `notebook_used`, `risk_ack`, `suppress_finding` | +| `workspace` | read | Inspect repositories, projects, scopes, index, and proxy state | `info`, `repos`, `project`, `scopes`, `index`, `graph`, `active_project`, `proxy` | +| `workspace_admin` | control write | Mutate durable repository, project, scope, index, enrichment, feedback, or persisted analysis state | `track`, `untrack`, `index`, `reindex`, `set_active_project`, `save_scope`, `delete_scope`, `enrich_churn`, `enrich_releases`, `blame`, `coverage`, `sql_rebuild`, `temporal_verify`, `feedback` | +| `overlay` | session write | Mutate speculative overlay sessions without writing to disk | `register`, `push`, `delete`, `drop`, `keepalive`, `fork`, `switch`, `drop_branch`, `simulate`, `merge` | +| `session` | session write | Mutate connection-lifetime planning, workflow, proxy, navigation, coordination, or notification state | `planning_mode`, `workflow`, `cursor`, `proxy_enable`, `proxy_disable`, `agents`, `subscribe`, `unsubscribe` | +| `response` | read | Re-cut or export buffered tool responses | `stats`, `grep`, `slice`, `peek`, `export_context` | +| `capabilities` | read | List public domains/operations or return an operation schema | `detail=summary`, `detail=schema` | + +### 6.1 Effect rules + +The canonical effect classes are: + +```text +read +local_write +session_write +control_write +external_write +``` + +The following invariants apply: + +- A facade MUST resolve to one effect class across all of its operations. +- Effects describe externally observable, authorizable behavior. Bounded memoization that cannot change a result's semantics is not an effect; persisted verdicts, graph enrichment, durable memories, and session mutations are effects. +- The effect describes mutation posture. Open-world behavior is represented independently by the MCP tool annotation: `ask`, `pr`, and `review` are read operations with `openWorldHint`; `publish_review` is both an external write and open-world. +- Read operations MUST NOT persist semantic state. Mutations route through `local_write`, `session_write`, `control_write`, or `external_write` facades. `recall(operation="surface")` therefore forces `mark_accessed=false`; explicit legacy calls retain their historical access-counter behavior. +- `blame`, `coverage`, `sql_rebuild`, and `temporal_verify` persist graph or cache state. The compact surface MUST reject all four under `analyze` and expose them only through the matching `workspace_admin` operation. +- Read-only adapters MUST neutralize optional effects without changing legacy defaults: `search.symbols` fixes `assist=off`; `analyze.concepts` fixes `use_llm=false`; `analyze.co_change` fixes `refresh=false`; native `impact` fixes `refresh_cochange=false`; native `sql_call_sites` fixes `materialize=false`. +- `change.contract` fixes `ack=false`; durable risk acknowledgement uses `remember(operation="risk_ack")` with `ack=true` fixed by the server. +- Forge reads MUST route through `pr`; forge writes MUST route through `publish_review`. +- Generation and graph export operations route through the local-write `edit` facade because their legacy handlers can write output. `edit(operation="wiki")` forces `enhance=false`, so this local-write boundary cannot invoke an LLM; enhanced wiki generation remains on the explicit legacy compatibility surface. +- Overlay comparison and preview are reads under `change`; overlay mutation uses `overlay`; applying overlay content to disk uses `edit`. +- Effect-split overlay operations are fixed-safe: `change.simulate` forces `keep=false`, `overlay.simulate` forces `keep=true`, `overlay.merge` forces `to_disk=false`, and `edit.apply_overlay` forces `to_disk=true`. Caller arguments cannot override these values. +- Proxy reads use `workspace`; cursor navigation, agent registry operations, and proxy/planning/workflow changes use `session`. Listing or comparing overlays uses `change`; mutating overlays uses `overlay`. +- Event subscription lifecycle uses `session(operation="subscribe"|"unsubscribe", channel=)`, separate from durable workspace mutation. +- Planning-mode and federation policy MUST be generated from the effect class instead of a hand-maintained tool-name list. + +## 7. Eager surface and client defaults + +Every MCP connection with a non-empty `clientInfo.name` defaults to the compact surface in `hide` mode unless a higher-precedence forwarded, operator, or instruction-profile policy selects another surface. Its first `tools/list` advertises exactly all 21 compact names and no legacy tool names. Direct calls to hidden legacy tools are hard-blocked; compact dispatch reaches their captured handlers internally. + +The 21 names are static for the connection. `read` is therefore always directly callable, and neither ordinary nor rare facade operations depend on `tools_search`, inline promotion, or `notifications/tools/list_changed`. `capabilities` discovers operation summaries and schemas without changing `tools/list`. + +Empty or pre-initialize sessions retain the server default. Client identity and wire format are separate: an unrecognized non-empty client name still gets the compact tools but keeps JSON unless it is independently GCX-capable. `facade-v1` is the stable internal config identifier; the neutral `compact` alias is accepted by operator-facing preset selection. Operators may instead select `agent`, `core`, or `full` when compatibility requires the old surface. The compact contract is closed and ignores `allow`/`deny` deltas so `tools/list` and the hard call gate always agree on the same 21 names. Full precedence is forwarded selection (`GORTEX_TOOLS`/`--tools`) > operator-pinned `mcp.tools` config > active instruction profile > named-client default > server default. + +## 8. Common protocol model + +### 8.1 Request envelope + +Each facade advertises only its high-frequency stable fields. A read request illustrates the common shape: + +```json +{ + "target": {"file": "internal/mcp/server.go"}, + "context": {"start_line": 100, "end_line": 180}, + "options": {"repo": "gortex"}, + "output": { + "format": "json", + "max_bytes": 20000, + "cursor": "opaque", + "fields": "path,content" + } +} +``` + +The dispatcher flattens the advertised `arguments`, `options`, `source`, `context`, `guard`, and `output` objects into the selected legacy handler's arguments. Common top-level fields win where the adapter defines a friendly alias. Operation-specific fields that are not worth mounting in every model turn remain discoverable through `capabilities`. + +`operation` is a normalized string rather than an ever-growing schema enum. It MAY be omitted when a selector uniquely determines the common read/edit default. The public description lists common operations, while `capabilities(domain=, operation=, detail="schema")` returns both the exact operation-specific public input schema and a `request_shape`. The dispatcher MUST reject unknown values and return the valid operation names. Capability lists use the public keys `domains` and `domain`; compatibility terminology is not exposed to callers. + +### 8.2 Target references + +When a request supplies `target`, it MUST contain exactly one direct selector: + +| Field | Meaning | +| --- | --- | +| `file` | Repository-relative or absolute file path | +| `symbol` | One symbol identifier or name | +| `symbols` | Array of symbol identifiers | +| `query` | Search/query target used by an operation | +| `artifact` | Artifact identifier or path | +| `repo` | Repository selector | + +Examples are `{"file":"internal/mcp/server.go"}` and `{"symbol":"Server.addTool"}`. There is no nested `kind`, `path`, or `id` discriminator. The server rejects an unknown selector, a non-object target, or a target containing zero or multiple non-empty selectors. Individual operations may omit `target` and use their other advertised fields instead. + +### 8.3 Operation arguments and output + +High-frequency fields are direct and typed. In particular, `edit` advertises `match`, `replacement`, `content`, `guard`, `changes`, and `dry_run` at the top level. `refactor` similarly advertises `new_name`, `destination`, and `dry_run`. The adapter translates friendly fields such as `match`/`replacement` into the selected legacy handler's vocabulary. + +Cold domain tools accept an `arguments` object; common tools may additionally accept `options`, `source`, or `context`. Repository/project/scope fields use the operation schema returned by `capabilities` and are passed through to the handler. `output` is a stable open object for response shaping such as `format`, `max_bytes`, `limit`, `cursor`, and `fields`; adding another response control does not change the outer tool schema. Cursors remain opaque. + +### 8.4 Response compatibility and metadata + +Facade dispatch MUST return the selected legacy handler's `CallToolResult` unchanged: text/structured content, error state, truncation fields, cursors, and existing metadata retain their established shape. The adapter adds only `_meta.gortex_facade`: + +```json +{ + "content": [{"type": "text", "text": ""}], + "_meta": { + "gortex_facade": { + "surface_version": "facade-v1", + "facade": "read", + "operation": "file", + "canonical_tool": "read_file" + } + } +} +``` + +The metadata is additive and is not a mandatory `ok/data/warnings` wrapper. Existing consumers can ignore it. Facade validation failures use the existing Gortex structured-error mechanism and include stable error codes plus relevant data such as `valid_operations` or `valid_selectors`; they do not masquerade as a legacy handler payload. + +### 8.5 Representative calls + +Read a file without selecting a legacy reader: + +```json +{ + "operation": "file", + "target": {"file": "internal/mcp/server.go"}, + "context": {"start_line": 100, "end_line": 180} +} +``` + +Read one symbol's source: + +```json +{ + "target": {"symbol": "Server.addTool"}, + "context": {"context_lines": 3} +} +``` + +The result is the existing `get_symbol_source` payload, including location, signature, and the metadata that handler already returns. The narrower legacy `get_symbol` metadata-only view is intentionally not a public operation. + +Preview a guarded textual file edit: + +```json +{ + "target": {"file": "internal/mcp/server.go"}, + "match": "old text", + "replacement": "new text", + "guard": {"base_sha": "observed-blob-sha"}, + "dry_run": true +} +``` + +Write complete file content: + +```json +{ + "target": {"file": "docs/example.md"}, + "content": "# Example\n", + "dry_run": true +} +``` + +Preview a semantic refactor: + +```json +{ + "operation": "rename", + "target": {"symbol": "oldName"}, + "new_name": "newName", + "dry_run": true +} +``` + +Detect a working-tree change independently of how it was applied: + +```json +{ + "operation": "detect", + "source": {"base": "HEAD"} +} +``` + +Ask for the impact of one known symbol with the same selector shape used by +`read` and `relations`: + +```json +{ + "operation": "impact", + "target": {"symbol": "Server.addTool"} +} +``` + +`target.symbols` is the batch form. `change` translates both forms to the +captured impact handler's identifier list; callers do not need to know its +legacy `options.ids` spelling. + +The `change` facade applies equally to mutations made through `edit`, `refactor`, `apply_patch`, an IDE, or another tool. After mutation, run `detect` and use its symbol IDs with `tests`, `guards`, and `contract`. Run `verify` before a signature mutation with the proposed signature. Use `capabilities` when an exact operation schema is needed. + +## 9. Capability discovery + +`capabilities` replaces public-client dependence on `tool_profile` and `tools_search`. + +All 21 facade tool names are already present in `tools/list`. `capabilities` discovers operations and their schemas; it MUST NOT introduce, promote, or dynamically register tool names. + +Its stable arguments are: + +- `domain`: facade name; omit it to list all facade domains. +- `operation`: operation name; omit it to list the domain's operations. +- `detail`: `summary` (default) or `schema`. + +Fetch the exact public operation schema for reading a file: + +```json +{ + "domain": "read", + "operation": "file", + "detail": "schema" +} +``` + +The result includes `surface_version`, `operation`, `effect`, `available`, and `summary`; schema detail additionally includes the operation-specific public `input_schema`, an actionable `request_shape`, fixed server arguments, and `schema_hash`. Unified analysis schemas are filtered by kind and express conditional requirements such as coverage profiles and cycle endpoints. Availability reflects whether the underlying handler was registered in this server configuration. + +## 10. Canonical operation registry + +Facade routing and legacy compatibility are driven by one per-server operation registry. Its implemented contract is equivalent to: + +```go +type facadeOperationSpec struct { + Facade string // stable MCP facade name + Operation string // stable request discriminator + Legacy string // captured legacy tool/handler + Effect facadeEffect + Fixed map[string]any // trusted arguments injected after caller input +} +``` + +The registry: + +1. Facade MCP registration and dispatch. +2. Captures the existing legacy tool definition and handler when it is registered. +3. Supplies operation availability and summaries, then projects captured handler fields into the stable public facade envelope returned by `capabilities`; captured implementation schemas are never returned as the public contract. +4. Supplies facade/operation/canonical-tool metadata and privacy-safe telemetry dimensions. +5. Provides the complete mapping used by coverage, effect-parity, schema-budget, and dispatch tests. + +Validation and tests MUST fail when: + +- A facade contains operations with different effects. +- Two entries claim the same `(facade, operation)` pair. +- Any registered legacy tool is absent from the mapping; the v1 baseline requires all 178. +- A legacy mutator maps to a read effect. +- The 21-name facade preset and registry disagree. +- An effect-sensitive legacy variant is not made safe with a fixed argument or a separate facade operation. + +Fixed arguments are applied after caller arguments and cannot be overridden. They enforce overlay splits; `edit.wiki(enhance=false)`; local search and analysis postures; `change.contract(ack=false)` versus `remember.risk_ack(ack=true)`; normalized native analysis kinds; and the four admin-only analysis kinds. `capabilities` reports them as `fixed_arguments`. Planning, mutation, open-world, and federation tests MUST remain in parity with the registry effects. + +## 11. Compatibility and versioning + +### 11.1 Surface selection + +The server exposes the compact surface under the `facade-v1` operator/config preset alongside the existing legacy presets: + +| Selection | Advertised tools | Intended use | +| --- | --- | --- | +| `facade-v1` (`compact`, `facade`, `agent-v2`) | Exactly 21 compact tools in `hide` mode | Every named MCP client and explicitly pinned sessions | +| `agent`, `core`, `full`, and specialist presets | Existing legacy tool definitions and behavior | Explicit compatibility/rollback and empty or pre-initialize server defaults | + +Every connection with a non-empty MCP `clientInfo.name` selects the compact surface in `hide` mode when no higher-precedence surface is selected. Empty or pre-initialize sessions retain the server default. `GORTEX_TOOLS` or `--tools` has the highest precedence, so `GORTEX_TOOLS=full` (or another legacy preset) deliberately restores that connection's legacy surface. + +Several facade names intentionally reuse strong existing names, including `explore`, `analyze`, `ask`, and `review`. Tool definitions are therefore session-surface-specific: + +- In a legacy preset, a reused name advertises and accepts its legacy schema. +- In a compact session, the same name advertises its compact definition and routes through the operation registry (`analyze` retains `kind` as its primary discriminator). +- A connection MUST select its surface before the first `tools/list`, and a name's schema MUST NOT change during that connection. + +Legacy clients that require an old colliding schema select a legacy preset. The underlying handler remains the same; only the session-visible definition and adapter differ. + +### 11.2 Legacy behavior + +- Introducing the compact surface is additive. +- Legacy MCP names continue to accept their existing arguments and return their existing responses. +- Legacy CLI and HTTP routes remain available and continue to delegate to the same handlers. +- Compact `hide` mode advertises no legacy names and hard-blocks direct legacy calls. Its dispatchers call the captured legacy handlers internally without promoting or exposing their schemas. +- An explicit legacy preset restores legacy MCP compatibility for that connection. +- Exact aliases such as `grep_results` and `head_results` are deprecated first; their canonical legacy handlers remain reachable until the compatibility window closes. +- Removing a legacy tool, required argument, or response field follows `docs/versioning.md`. It is a breaking change after Gortex reaches 1.0 and requires the repository's documented pre-1.0 migration treatment before then. + +### 11.3 Facade evolution + +- Adding an operation or optional field is backward compatible. +- Removing or changing the meaning of an operation requires a new surface version. +- The facade tool names remain unversioned; `capabilities` and `_meta.gortex_facade.surface_version` carry the version. +- `capabilities(detail="schema")` returns a per-operation `schema_hash` derived from the captured legacy input schema. +- Clients MAY cache schemas by `(surface_version, domain, operation, schema_hash)` and invalidate them when the hash changes. + +## 12. Named-client and harness discovery contract + +An integration is correct only when the server, stdio proxy, client bridge, and session introspection agree on the visible surface for every named MCP client and harness. + +The following sequence is normative: + +```text +initialize(clientInfo.name = "") + -> resolve the compact / hide policy for this session + -> tools/list returns exactly 21 static compact names and no legacy names + -> read is present + -> capabilities(domain="read", operation="file", detail="schema") + describes read_file without promoting it +``` + +Requirements: + +1. Raw protocol tests MUST initialize with representative canonical client IDs, supported host aliases, and a deliberately unknown non-empty client name, then assert the exact tool roster. +2. Each named-client test MUST assert exactly 21 compact names, `read` included, and zero legacy-name leakage. A separate empty/pre-initialize test MUST assert that the server default is retained. +3. `read(target={file:"..."})` MUST infer the file operation and return source without daemon CLI fallback. +4. `capabilities(domain="read", operation="file", detail="schema")` MUST return the captured schema without promoting `read_file`. +5. A direct hidden legacy call MUST remain blocked while facade dispatch to the same captured handler succeeds. +6. The stdio proxy MUST apply the same per-connection hide policy from the first `tools/list`. +7. The ordinary coding loop MUST work when the client ignores `notifications/tools/list_changed`. +8. Each MCP-capable integration harness SHOULD compare the raw MCP roster with the functions exposed by its client bridge. If the bridge drops a tool, telemetry and diagnostics MUST identify the missing facade rather than recommend an uncallable name. +9. A Bash-only harness with no native MCP function exposure MUST be able to invoke the same compact names and argument objects through `gortex call`; this CLI path is a direct mirror, not a translation to legacy tool names. +10. An agent adapter MUST use the host's supported eager/direct namespace control when the host otherwise defers MCP tools. Discovery in a settings screen or a successful raw `tools/list` is not sufficient: the facade functions MUST be present in the model-visible callable registry on the first turn. +11. An agent adapter SHOULD mark Gortex as required when the host supports required MCP servers. Startup or discovery failure must fail visibly instead of silently leaving the agent with source-access guidance that it cannot follow. +12. Guidance installed for an MCP-capable host MUST NOT recommend daemon startup or the Bash mirror when a Gortex callable handle is missing. That state is a host integration failure and MUST be surfaced. `gortex call` remains the mirror only for a harness that genuinely has no MCP transport. + +For Codex 0.142.0 and newer, `gortex init` adds the current `mcp__gortex` +namespace and its non-prefixed `gortex` form to +`features.code_mode.direct_only_tool_namespaces` without removing +user-configured namespaces. This is the host-supported bypass for deferral: the +active Gortex namespace remains a direct model tool even when other MCP +namespaces are available only through tool search. The adapter also writes +`required = true` and a startup timeout long enough for Gortex's bounded daemon +autostart. Older Codex releases reject that field, so the adapter version-gates +it rather than invalidating their config. These are transport/host settings, +not agent instructions and not part of the facade request schema. + +## 13. Telemetry and privacy + +Facade telemetry follows the consent, endpoint, bucketing, and privacy requirements in `docs/telemetry.md`. It remains opt-in and off by default. No query, code, path, symbol, repository name, diff, prompt, response body, or exact high-cardinality identifier may be recorded. + +The implementation distinguishes local diagnostic observations from anonymous aggregate telemetry. Any newly transmitted metric key MUST be added to the hard allow-list and documented before release. + +Hook-effectiveness observations are local diagnostics, not anonymous aggregate +telemetry: they are written to a separate cache JSONL, are never uploaded, and +contain no command, query, prompt, path, symbol, source, or output text. + +At minimum, privacy-safe buckets SHOULD cover: + +| Event | Safe dimensions | +| --- | --- | +| Surface initialized | Client family from a fixed allow-list, surface version, live-tool-count bucket, `tools/list` byte bucket, schema hash prefix | +| Facade call | Bounded registered `facade.operation` dimension; overlong values use a deterministic safe suffix | +| Validation failure | Facade, operation, stable error code | +| Discovery | `capabilities` domain/detail, legacy `tools_search` use, promotion success/failure | +| Workflow effectiveness | Calls-to-first-useful-result bucket, direct-read success, verification-after-mutation flag | +| Integration fallback | Gortex read available, Gortex read used, shell/raw-read fallback observed | +| Hook effectiveness | Local-only allow-listed event, emitted-context flag, daemon reachability, capped alternation-segment count, and latency | + +Success and regression dashboards SHOULD track: + +- Cold `tools/list` bytes and tool count by client family. +- Facade versus legacy call share. +- Invalid-argument rate by facade operation. +- Calls to first useful source/context result. +- Direct MCP source-read success rate. +- Deferred-promotion attempts in compact sessions; the expected value is zero for ordinary coding. +- Shell/raw-read fallback rate when a Gortex read facade is available. +- Mutation followed by `detect`, `tests`, `guards`, and `contract` checks. +- Adapter overhead and end-to-end latency buckets. + +## 14. Rollout plan + +### Phase 0: establish truthful diagnostics + +- Make tool-surface introspection session-aware. +- Add raw `initialize -> tools/list` protocol tests for representative known names, an unknown non-empty name, and an empty/pre-initialize session. +- Capture current tool counts, schema bytes, call sequences, invalid arguments, and legacy usage. +- Complete the effect audit, including code actions, fix-all, memories, overlays, generated files, enrichment, proxy control, and review publication. + +### Phase 1: introduce the registry + +- Add the canonical operation registry without changing externally visible behavior. +- Generate legacy descriptors, policy classifications, and completeness tests from it. +- Require all 178 audited legacy tools to be mapped or explicitly justified as legacy-only. + +### Phase 2: add facade adapters + +- Implement facade dispatch as thin adapters over existing handlers. +- Preserve legacy `CallToolResult` payloads and attach `_meta.gortex_facade` routing metadata. +- Add operation-level validation and structured errors. +- Add golden parity tests comparing facade operations with their legacy handler results. + +### Phase 3: add compact profiles + +- Publish the 21-tool compact surface under the `facade-v1` config identifier, with the 11-tool coding core protected in restricted coding profiles. +- Keep all existing legacy presets available as explicit compatibility/rollback selections. +- Remove repeated common guidance from individual facade descriptions and publish it once as server instructions or a resource. +- Add the permanent `tools/list` byte ceiling. + +### Phase 4: canary named clients and harnesses + +- Default named MCP clients and harnesses to the compact surface in `hide` mode while preserving explicit `GORTEX_TOOLS` rollback. +- Run task-level evaluations for localization, source reading, editing, refactoring, verification, review, memory, workspace, and overlay workflows. +- Compare success, round trips, invalid arguments, latency, and fallback against the legacy surface. + +### Phase 5: stabilize named-client defaults + +- Verify representative known client IDs, supported host aliases, and unknown non-empty names against all acceptance gates. +- Keep empty and pre-initialize sessions on the server default. +- Keep explicit legacy rollback configuration. +- Publish migration documentation and deprecation warnings for exact aliases. + +### Phase 6: retire legacy advertisement + +- Hide unused legacy names by default after the compatibility window. +- Keep CLI/HTTP compatibility longer where it has independent users. +- Remove legacy names only in a release permitted by `docs/versioning.md` and only after telemetry and repository search show negligible use. + +## 15. Acceptance criteria + +### 15.1 Completeness and parity + +- Every one of the 178 baseline tools is represented in the registry; no registered legacy tool is silently excluded. +- No legacy tool maps to more than one effectful operation without an explicit effect split. +- Facade dispatch preserves each legacy handler payload and adds `_meta.gortex_facade` with `surface_version`, `facade`, `operation`, and `canonical_tool`. +- Facade/legacy contract tests cover every mapped operation family. +- Existing legacy MCP, CLI, and HTTP tests remain green. + +### 15.2 Context economy + +- A default initialization with any non-empty `clientInfo.name` resolves the compact surface in `hide` mode and publishes exactly the 21 tools defined in this specification and no legacy tools. +- Its cold serialized `tools/list` is at most **15,000 bytes**. +- Common server guidance appears once and contributes no more than 10% repeated text across facade descriptions. +- Adding a rare operation does not increase `tools/list` unless its common summary changes. + +### 15.3 Agent effectiveness + +- Task-level success does not regress against the legacy agent preset. +- Median calls to the first useful source/context result do not increase. +- A known file is readable with `read(target={file:"..."})`; known symbol source is readable with `read(target={symbol:"..."})`. Explicit operations remain valid. +- Target validation accepts exactly one of `file`, `symbol`, `symbols`, `query`, `artifact`, or `repo` and rejects ambiguous/unknown selectors. +- A guarded edit, semantic refactor preview, and post-mutation verification complete without legacy-tool discovery. +- A natural-language localization query cannot have its complete result head monopolized by repeated, same-named data leaves; the highest-ranked leaf remains, while callable/type targets remain discoverable. Literal symbol queries retain their exact-name ordering. +- Invalid-argument and raw-file fallback rates are no worse than the legacy baseline and trend downward during canarying. + +### 15.4 Named-client and harness correctness + +- Representative known client IDs, supported host aliases, and an unknown non-empty name return the exact expected compact roster from `initialize -> tools/list`. +- An empty or pre-initialize session retains the server default unless an explicit policy selects the compact surface. +- Wire-format negotiation remains independent: an unknown named client remains on JSON unless it is separately GCX-capable. +- `read` is present, directly callable, and can return uncompressed source. +- `capabilities(domain="read", operation="file", detail="schema")` returns an available operation, its operation-specific public `input_schema`, canonical `request_shape`, and `schema_hash`; the shape validates against both that schema and the static `read` facade schema, while captured selector names and fixed server arguments stay out of caller input. +- Direct calls to hidden legacy tools are blocked in the same session. +- The integration passes when `tools/list_changed` is ignored. +- A bridge-exposure regression fails loudly instead of silently recommending a missing tool. +- A Bash-only harness can call the same compact tool names with the same argument objects through `gortex call`, without legacy-name translation. +- Host integrations that support namespace deferral controls expose Gortex directly on the first model turn; an MCP settings screen and raw `tools/list` alone do not satisfy this criterion. +- Host integrations that support required MCP servers fail startup visibly when Gortex cannot initialize or enumerate tools. +- MCP-profile hook guidance reports a missing callable handle as an integration failure and never tells the agent to start a daemon or switch to `gortex call`; Bash-only generated guidance continues to document the CLI mirror. + +### 15.5 Safety + +- Registry validation proves every facade is effect-homogeneous. +- Planning/read-only mode blocks every write facade. +- Federation and remote routing deny every non-permitted effect by generated policy. +- `publish_review` is the only facade that writes to a forge. +- `edit` and `refactor` retain stale-write guards and dry-run behavior where supported. +- Applying external mutations follows the same `change` pipeline: `detect`, then `tests`, `guards`, and `contract` using the detected symbol IDs. The Codex `apply_patch` PostToolUse adapter runs this pipeline automatically; other harnesses can call the public operations directly. +- `change(operation="impact", target={symbol:...})` and its `target.symbols` batch form produce the same result as the canonical impact handler selector. +- A reach-index entry is usable only after its generation and completeness marker are published with all distance tiers. An incomplete or concurrently replaced entry MUST fall back to a live graph walk; repeated identical impact queries MUST NOT decrease to a false-safe zero result while the graph is unchanged. + +### 15.6 Observability + +- Surface size, facade calls, legacy alias calls, validation errors, discovery use, latency, direct-read success, and fallback are measurable without collecting source identifiers. +- The local, non-transmitted hook-effectiveness JSONL exposes emitted-context rate, daemon reachability, capped alternation count, and latency by allow-listed event without recording commands, source, paths, prompts, or symbols. +- Telemetry remains off by default and its hard allow-list and documentation agree. + +## 16. Legacy migration table + +This table is grouped by destination operation family. The operation registry is the executable source of truth; tests MUST compare its complete legacy-name set with the registered legacy tool set. + +| Legacy tool(s) | Compact-surface destination | Notes | +| --- | --- | --- | +| `explore`, `smart_context`, `context_closure`, `get_repo_outline`, `plan_turn`, `prefetch_context`, `suggest_queries`, `gortex_wakeup` | `explore` | Operations: `task`, `context`, `closure`, `outline`, `plan`, `prefetch`, `suggest`, `wakeup` | +| `search_artifacts`, `search_ast`, `graph_completion_search`, `find_files`, `search_symbols`, `search_text`, `winnow_symbols` | `search` | Operations: `artifacts`, `ast`, `completion`, `files`, `symbols`, `text`, `winnow` | +| `get_artifact`, `get_editing_context`, `read_file`, `get_symbol_history`, `get_symbol_source`, `get_file_summary`, `batch_symbols` | `read` | Operations: `artifact`, `editing_context`, `file`, `history`, `source`, `summary`, `symbols`; selector-driven defaults cover file, symbol source, symbol batches, and artifacts | +| `get_symbol` | hidden compatibility mapping | Public symbol reads use `read.source`, which already includes location and signature | +| `get_callers`, `get_cluster`, `find_declaration`, `get_dependencies`, `get_dependents`, `get_class_hierarchy`, `find_implementations`, `find_import_path`, `find_overrides`, `check_references`, `find_usages` | `relations` | Operations: `callers`, `cluster`, `declaration`, `dependencies`, `dependents`, `hierarchy`, `implementations`, `import_path`, `overrides`, `references`, `usages` | +| `get_call_chain`, `get_cfg`, `flow_between`, `graph_query`, `trace_path`, `taint_paths`, `walk_graph` | `trace` | Operations: `call_chain`, `cfg`, `flow`, `graph`, `path`, `taint`, `walk` | +| `audit_agent_config`, `get_architecture`, `verify_citation`, `find_clones`, `find_co_changing_symbols`, `get_communities`, `contracts`, `get_coupling_metrics`, `get_extraction_candidates`, `analyze`, `audit_health`, `run_inspections`, `list_inspections`, `get_knowledge_gaps`, `lint_file`, `get_processes`, `get_recent_changes`, `replay_episode`, `get_surprising_connections`, `get_untested_symbols`, `why`, `get_churn_rate` | `analyze` | Read operations and native read-only kinds; `help` is the safe default, and effect-sensitive queries carry fixed no-refresh/no-LLM arguments | +| `ask` | `ask(operation="research")` | Optional open-world research handler | +| `api_impact`, `get_code_actions`, `compare_branches`, `compare_with_overlay`, `change_contract`, `detect_changes`, `get_diagnostics`, `get_edit_plan`, `check_guards`, `explain_change_impact`, `overlay_branches`, `overlay_list`, `suggest_pattern`, `preview_edit`, `symbols_for_ranges`, `get_test_targets`, `verify_change`, `simulate_chain` | `change` | Read operations; `simulate` fixes `keep=false`, and `contract` fixes `ack=false` | +| `overlay_merge`, `batch_edit`, `generate_docs`, `export_graph`, `edit_file`, `scaffold`, `generate_skill`, `edit_symbol`, `generate_wiki`, `write_file` | `edit` | Operations: `apply_overlay`, `batch`, `docs`, `export_graph`, `file`, `scaffold`, `skill`, `symbol`, `wiki`, `write`; `apply_overlay` fixes `to_disk=true` | +| `apply_code_action`, `safe_delete_symbol`, `fix_all_in_file`, `inline_symbol`, `move_symbol`, `rename_symbol` | `refactor` | Operations: `apply_code_action`, `delete`, `fix_all`, `inline`, `move`, `rename` | +| `critique_review`, `diff_context`, `review_pack`, `pr_review_context`, `suggested_review_questions`, `review`, `sibling_diff_context` | `review` | Operations: `critique`, `diff_context`, `pack`, `pr_context`, `questions`, `run`, `sibling_context` | +| `post_review` | `publish_review(operation="post")` | Separate external-write authorization boundary | +| `conflicts_prs`, `get_pr_impact`, `list_prs`, `suggest_reviewers`, `pr_risk`, `triage_prs` | `pr` | Operations: `conflicts`, `impact`, `list`, `reviewers`, `risk`, `triage` | +| `distill_session`, `query_memories`, `notebook_find`, `notebook_list`, `notebook_show`, `query_notes`, `check_onboarding_performed`, `surface_memories` | `recall` | Operations: `distill`, `memories`, notebook reads, `notes`, `onboarding`, `surface` | +| `edit_memory`, `store_memory`, `save_note`, `notebook_save`, `notebook_used`, `rename_memory`, `suppress_finding`, `change_contract(ack=true)` | `remember` | Local-write operations for durable knowledge; risk acknowledgement uses `operation="risk_ack"` with fixed `ack=true` | +| `get_active_project`, `graph_stats`, `index_health`, `workspace_info`, `query_project`, `proxy_status`, `list_repos`, `list_scopes` | `workspace` | Read operations: `active_project`, `graph`, `index`, `info`, `project`, `proxy`, `repos`, `scopes` | +| `delete_scope`, `enrich_churn`, `enrich_releases`, `feedback`, `index_repository`, `reindex_repository`, `save_scope`, `set_active_project`, `track_repository`, `untrack_repository` | `workspace_admin` | Durable control-write operations for workspace, graph, configuration, feedback, and enrichment state | +| `analyze` kinds `blame`, `coverage`, `sql_rebuild`, `temporal_verify` | matching `workspace_admin` operation | The kind is fixed by the server; all four are rejected under read-only `analyze` because they persist graph or cache state | +| `overlay_delete`, `overlay_drop`, `overlay_drop_branch`, `overlay_fork`, `overlay_keepalive`, `overlay_push`, `overlay_register`, `overlay_switch`, `simulate_chain`, `overlay_merge` | `overlay` | Session-write operations; `simulate` fixes `keep=true` and `merge` fixes `to_disk=false`. Disk application remains `edit(operation="apply_overlay")` | +| `nav`, `agent_registry`, `set_planning_mode`, `workflow`, `proxy_enable`, `proxy_disable`, `subscribe_daemon_health`, `unsubscribe_daemon_health`, `subscribe_diagnostics`, `unsubscribe_diagnostics`, `subscribe_graph_invalidated`, `unsubscribe_graph_invalidated`, `subscribe_stale_refs`, `unsubscribe_stale_refs`, `subscribe_workspace_readiness`, `unsubscribe_workspace_readiness` | `session` | Session-only state. `nav` becomes `cursor`; notification calls use `subscribe`/`unsubscribe` plus `channel` | +| `export_context`, `ctx_grep`, `ctx_peek`, `ctx_slice`, `ctx_stats` | `response` | Operations: `export_context`, `grep`, `peek`, `slice`, `stats` | +| `grep_results`, `head_results` | `response` compatibility operations | Exact legacy aliases retained as `grep_compat` and `head_compat` mappings | +| `tool_profile`, `tools_search` | `capabilities` compatibility mappings | Recorded as `legacy_profile` and `legacy_search`; compact clients use `domain`/`operation`/`detail` without promotion | + +Any registered legacy name absent from this table is a specification defect unless its registry entry is explicitly marked `legacy_only` with a rationale and removal plan. + +## 17. Revision history + +| Version | Date | Change | +| --- | --- | --- | +| `1.0.1` | 2026-07-12 | Required direct host exposure where supported; projected every capability schema into the public envelope; normalized `change.impact` selectors; made repeated impact results safe against incomplete reach publication; kept natural-language explore results diverse; and made missing MCP handles fail visibly instead of triggering a CLI fallback | +| `1.0.0` | 2026-07-12 | Generalized the default across named MCP clients and Bash-only harnesses; added selector defaults, exact CLI mirroring, per-operation schemas, and audited effect splits | +| `1.0.0-draft.3` | 2026-07-11 | Replaced the notification-only facade with the broader session-only `session` facade and kept durable mutation under `workspace_admin` | +| `1.0.0-draft.2` | 2026-07-11 | Aligned selectors, operation calls, capability discovery, response metadata, and hide-mode behavior with the implementation | +| `1.0.0-draft.1` | 2026-07-11 | Initial compact-surface design based on the 178-tool audit and a client discovery investigation | diff --git a/docs/mcp.md b/docs/mcp.md index ef1ef3f3c..147c3c461 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2,6 +2,7 @@ Gortex exposes a knowledge-graph query surface over the [Model Context Protocol](https://modelcontextprotocol.io): **100+ tools, 18 resources, 3 prompts**. Agents call the same surface from stdio, the daemon Unix socket, or the MCP 2026 Streamable HTTP endpoint. +- [Compact MCP surface](#compact-mcp-surface) - [Tool discovery (lazy mode)](#tool-discovery-lazy-mode) - [Restricting the tool surface (presets)](#restricting-the-tool-surface-presets) - [Core navigation](#core-navigation) @@ -25,9 +26,34 @@ Gortex exposes a knowledge-graph query surface over the [Model Context Protocol] - [MCP resources (18)](#mcp-resources-18) - [MCP prompts (3)](#mcp-prompts-3) +## Compact MCP surface + +The compact surface consolidates the legacy catalogue into 21 domain tools with compact, stable schemas. Every MCP connection with a non-empty `clientInfo.name` selects it automatically in `hide` mode unless a higher-precedence forwarded, operator, or instruction-profile policy overrides it. Empty and pre-initialize sessions retain the server default. Select it explicitly with the neutral `compact` preset alias: + +```bash +GORTEX_TOOLS=compact gortex mcp +``` + +With that surface, the client receives all 21 names in its first `tools/list`: `explore`, `search`, `read`, `relations`, `trace`, `analyze`, `ask`, `change`, `review`, `pr`, `recall`, `workspace`, `response`, `capabilities`, `edit`, `refactor`, `remember`, `workspace_admin`, `overlay`, `session`, and `publish_review`. They are static for the session—there is no `tools_search` promotion or `tools/list_changed` dependency. `capabilities` discovers operation schemas, not additional tool names. Session-lifetime controls use `session`, for example `{"operation":"subscribe","channel":"diagnostics"}`; durable workspace changes remain under `workspace_admin`. + +```jsonc +// Read a source file. +{"name":"read","arguments":{"target":{"file":"internal/mcp/server.go"}}} + +// Preview a file edit; omit dry_run (or set false) to apply it. +{"name":"edit","arguments":{"target":{"file":"internal/mcp/server.go"},"match":"old text","replacement":"new text","dry_run":true}} + +// Fetch the exact schema for a read operation. +{"name":"capabilities","arguments":{"domain":"read","operation":"file","detail":"schema"}} +``` + +The compact surface delegates to the existing handlers. Existing `agent`, `core`, `full`, and specialist presets retain their legacy schemas; the CLI, HTTP routes, and legacy MCP names remain compatible. Names shared by both surfaces (such as `explore`, `analyze`, and `review`) advertise the compact schema only in a compact session. See the [compact MCP surface specification](mcp-facade-v1.md) for effects, schemas, migration, and acceptance gates. + +Authorization follows observable effects. `analyze` is strictly read-only: `blame`, coverage enrichment, SQL rebuild, and Temporal verification are exposed through `workspace_admin`; model-assisted concepts/search and lazy graph enrichment are fixed off on local read operations. Stateful `nav` is exposed as `session(operation="cursor")`. `change.contract` cannot acknowledge risk; durable acknowledgement is explicit through `remember(operation="risk_ack")`. + ## Tool discovery (lazy mode) -By default the server ships a curated **`core`** preset in **`defer`** mode (see [presets](#restricting-the-tool-surface-presets)): ~34 dev-cycle workhorse tools are published eagerly in the initial `tools/list`, and the rest of the ~180-tool catalogue is deferred — fetched on demand through `tools_search`. A cold session therefore pays for the workhorse schemas, not the whole surface. Opt back into the full eager surface with preset `full` (`GORTEX_TOOLS=full`). +The fallback server default is a curated **`core`** preset in **`defer`** mode (see [presets](#restricting-the-tool-surface-presets)): ~34 dev-cycle workhorse tools are published eagerly in the initial `tools/list`, and the rest of the ~180-tool catalogue is deferred—fetched on demand through `tools_search`. Named MCP clients instead default to the static compact surface described above. Opt into the full eager surface with preset `full` (`GORTEX_TOOLS=full`). `tools_search` returns each deferred tool's schema **inline** (in a `{…}` block) and promotes it into `tools/list`, firing `notifications/tools/list_changed`. Clients that honour that notification (or read the inline schema) reach deferred tools transparently. `GORTEX_LAZY_TOOLS=1` is the older, all-or-nothing switch that defers everything except a hard-coded hot set regardless of preset; the `core`/`defer` default supersedes it for the common case. @@ -54,28 +80,29 @@ Returned tools are auto-promoted (`promote:false` opts out) and the server fires The full ~180-tool surface is more than many agents need. A **tool preset** picks what the server publishes — the basis both for the lean shipped default and for a minimal, headless editing harness (an agent on a trusted box driving a remote daemon through a small, fixed tool set). -Seven built-in presets: +Eight built-in presets: | Preset | Surface | |--------|---------| -| `agent` (**default for known coding-agent clients**) | the lean coding-agent working set (~20 tools): `explore` (the one-shot localization verb) + search/navigate + read (incl. `batch_symbols`) + orient + edit/verify. Parameter descriptions are compacted (the full prose is one `tools_search` / `full` hop away). Aliases: `coding-agent` | -| `core` (**default for editors / unknown clients**) | the curated dev-cycle set (~35 tools): orient (incl. `explore`) + search/navigate + read + edit + verify/test + `analyze` + review + the memory workflow. Aliases: `default`, `classic` | +| `facade-v1` (**default for named MCP clients**) | Stable config identifier for the 21 static, effect-homogeneous domain tools; operation schemas are discovered through `capabilities`, with no tool promotion. Aliases: `compact`, `facade`, `agent-v2` | +| `agent` | Legacy lean coding-agent working set (~20 tools): `explore` (the one-shot localization verb) + search/navigate + read (incl. `batch_symbols`) + orient + edit/verify. Parameter descriptions are compacted (the full prose is one `tools_search` / `full` hop away). Aliases: `coding-agent` | +| `core` (**fallback server default**) | the curated dev-cycle set (~35 tools): orient (incl. `explore`) + search/navigate + read + edit + verify/test + `analyze` + review + the memory workflow. Aliases: `default`, `classic` | | `full` | every tool (the pre-`core` behaviour — opt back in here) | | `readonly` | everything except the mutating tools (`edit_file`, `write_file`, `index_repository`, …) | | `edit` | the minimal headless editing set — orient + navigate + mutate + verify (`smart_context`, `search_symbols`, `find_files`, `edit_file`, `verify_change`, `get_test_targets`, …) | | `nav` | read-only navigation / exploration; no editors | | `localization` | the diet "where is the code that does X" set (~10 tools, read-only, compacted descriptions): `smart_context` + search + trace + read. The eager list is sourced from the instruction-profile table, so this surface and the `localization` profile's instructions body cannot drift. Aliases: `locate`, `find` | -`tool_profile` and `tools_search` are always kept. Layer per-tool deltas on any preset with `allow` / `deny`. +For legacy presets, `tool_profile` and `tools_search` are always kept. The compact surface uses `capabilities` instead and is closed: it always contains exactly its 21 public tools, so `allow` / `deny` deltas are ignored for `facade-v1`. Select a legacy or custom surface when per-tool deltas are required. -**Client-aware default.** With no `GORTEX_TOOLS` / config preset, the server picks the default per connection: a **known coding-agent client** (the same set that defaults the wire format to GCX — `claude-code`, `cursor`, `vscode`, `zed`, `aider`, `kilocode`, `opencode`, `openclaw`, `codex`, `omp-coding-agent`) gets `agent`; every other client keeps `core`. `GORTEX_TOOLS` always overrides. The `gortex mcp` proxy forwards its `GORTEX_TOOLS` / `--tools` to the daemon in the handshake, so a client's preset applies over the shared daemon (it can both narrow and widen the surface, not just subtract). +**Client-aware default.** With no higher-precedence selection, every connection with a non-empty MCP `clientInfo.name` gets the compact 21-tool surface in `hide` mode. Empty and pre-initialize sessions retain the server default. Client identity and wire format are separate: an unknown named client still gets the compact tools but remains on JSON unless it is independently GCX-capable. `GORTEX_TOOLS` always overrides. The `gortex mcp` proxy forwards its `GORTEX_TOOLS` / `--tools` to the daemon in the handshake, so a client's preset applies over the shared daemon (it can both narrow and widen the surface, not just subtract). **Instruction profiles.** The machine's active instruction profile (`gortex instructions switch ` — see [`cli.md`](cli.md#gortex-instructions--instruction-profiles)) can carry a tool preset; sessions pick it up between the forwarded spec and the client-aware default. Full precedence: **forwarded spec (`GORTEX_TOOLS` / `--tools`) > operator-pinned `mcp.tools` config > active instruction profile > client-aware default > server default**. The shipped `core` profile carries no preset, so nothing changes until a machine explicitly switches; profile changes apply to new sessions only. **Two modes** (`mode`): - `defer` (the default mode for `core`) — non-allowed tools are kept out of the cold `tools/list` but stay reachable through `tools_search`, which returns their schema inline and promotes them (firing `notifications/tools/list_changed`). The lean-but-complete surface: nothing is lost, the rare tool is one discovery call away. -- `hide` (the default mode for the explicit `edit` / `nav` / `readonly` harness presets) — non-allowed tools are removed from `tools/list` **and** calls to them are hard-blocked. The locked-down surface; works identically on every client. +- `hide` (the default for `facade-v1` and the explicit `edit` / `nav` / `readonly` harness presets) — non-allowed tools are removed from `tools/list` **and** calls to them are hard-blocked. The locked-down surface; works identically on every client. Select a preset three ways (precedence: **env > flag > config > default**): @@ -83,7 +110,7 @@ Select a preset three ways (precedence: **env > flag > config > default**): # .gortex.yaml — config file mcp: tools: - preset: full # agent | core (default) | full | readonly | edit | nav + preset: full # compact | agent | core (default) | full | readonly | edit | nav mode: defer # defer | hide allow: [find_files] # add tools on top of the preset deny: [write_file] # remove tools from the preset diff --git a/docs/telemetry.md b/docs/telemetry.md index 1ef02ffef..236d6ecb0 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -1,6 +1,6 @@ # Telemetry & privacy -Gortex can collect **anonymous usage statistics** — coarse, bucketed counts of *which* tools and commands run. It is **opt-in and OFF by default**: nothing is recorded, buffered, or sent until you explicitly enable it, and even when enabled nothing is transmitted unless an ingest endpoint is configured. Telemetry never sees your code, file paths, file names, symbol names, or repository names. +Gortex can collect **anonymous usage statistics** — coarse, bucketed counts of *which* tools and commands run. That anonymous rollup is **opt-in and OFF by default**: nothing is buffered or sent until you explicitly enable it, and even when enabled nothing is transmitted unless an ingest endpoint is configured. Separate, non-transmitted hook diagnostics may be written locally as described below. Neither path records code, file paths, file names, symbol names, or repository names. ## Quick control @@ -31,19 +31,51 @@ An unrecognised or empty value at a rung falls through to the next rung rather t ## What is collected -Exactly **four** metric keys can ever be recorded — a hard allow-list; the aggregator physically cannot record anything else: +Only the following metric keys can ever be recorded — a hard allow-list; the aggregator physically cannot record anything else: | Key | Meaning | Dimension | | --- | --- | --- | | `mcp_tool_call` | an MCP tool was invoked | tool name (e.g. `search_symbols`) | +| `mcp_facade_call` | a compact-surface operation was attempted | registered `facade.operation` or a fixed `unknown` sentinel | +| `mcp_facade_status` | coarse facade result status | registered `facade.operation.ok|error` | +| `mcp_facade_outcome` | bounded facade result class | registered `facade.operation` plus `success`, `invalid_operation`, `invalid_argument`, `blocked`, `unavailable`, `tool_error`, `handler_error`, or `empty_result` | +| `mcp_facade_invalid` | facade validation failed | registered `facade.operation.invalid_argument` | +| `mcp_facade_latency` | facade end-to-end latency | registered `facade.operation` plus a duration bucket | | `cli_command` | a CLI subcommand ran | dotted command path (e.g. `daemon.start`, `review`) | | `index` | an index pass completed | file-count bucket | +| `index_lang` | a language occurred in an index pass | language name from the fixed parser registry | | `daemon_session` | a daemon session started | backend kind (e.g. `sqlite`) | +| `install` | an agent integration was installed | fixed agent target/scope | +| `uninstall` | an agent integration was removed | fixed agent target/scope | +| `client` | an MCP client connected | bounded client family | A recorded counter is `key` or `key:dimension` (e.g. `mcp_tool_call:search_symbols`, `index:1k-10k`). Values are **bucketed**, never exact — exact counts can narrow identification: - File counts → `<100`, `100-1k`, `1k-10k`, `10k+` -- Durations → `<10s`, `10-60s`, `1-5m`, `5m+` +- Facade latencies → `<1ms`, `1-10ms`, `10-100ms`, `100ms-1s`, `1-10s`, `10s+` + +Facade dimensions are admitted only from the canonical operation registry (or fixed sentinels). Caller-provided operation/domain values never become a metric dimension, even in hashed form. + +### Local hook-effectiveness diagnostics + +Hooks also keep a local, non-transmitted JSONL diagnostic at +`~/.gortex/cache/hook-effectiveness.jsonl` (override with +`GORTEX_HOOK_EFFECTIVENESS_LOG`). One bounded record is written for every +Claude-compatible or Codex event handled by the shared hook dispatcher, +including no-op events, so the denominator cannot disappear. +Each record contains only: + +- an allow-listed event name; +- whether the hook emitted model-visible context or a decision reason; +- whether the local daemon was reachable; +- a capped alternation-segment count (`0` when not applicable); and +- hook latency in milliseconds. + +Commands, grep patterns, prompts, source, paths, symbols, repositories, and +tool output are never written to this log. This diagnostic is separate from +the opt-in anonymous rollup above and is never uploaded. Together with the +existing local `hook-decisions.jsonl`, it directly exposes emitted-context +rate and makes another high-skip regression visible. A dimension guard (`^[A-Za-z0-9_.<>+-]{1,32}$`) drops any token containing a path separator, whitespace, or over 32 characters, so even a caller that mistakenly passed a path or symbol name as a dimension cannot leak it — only the bare metric key is recorded. diff --git a/docs/versioning.md b/docs/versioning.md index 07ed3b237..814b501ba 100644 --- a/docs/versioning.md +++ b/docs/versioning.md @@ -98,6 +98,22 @@ v, err := version.Parse("v1.2.3-rc.1+abc1234") The daemon exposes its running version in the handshake ACK as `DaemonVersion` and on the control surface's `status` response — clients can feature-gate or warn on mismatch. +## Agent-integration migration fingerprints + +Exact `v0.60.0` configuration and generated-artifact fingerprints are retained +through the complete `v0.62.x` line so upgrades can replace untouched Gortex +output without overwriting user customizations. Their earliest removal is +`v0.63.0`, and removal is permitted only when both conditions hold: + +1. The supported direct-upgrade floor is `v0.61.0` or newer. +2. The `v0.63.0` release notes tell `v0.60.x` users to upgrade through an + intermediate `v0.61.x` or `v0.62.x` release. + +Versioned identifiers such as `v060AlwaysAllow` and `v060GlobalSkillHashes` +make this temporary compatibility scope visible. Long-lived compatibility +types and fields (for example the MCP facade's `Legacy` handler mapping) are +not governed by this retirement gate. + ## 0.x caveat Until Gortex reaches `v1.0.0`, the MAJOR rule relaxes slightly: breaking changes may ship in a MINOR bump when they fall under a clearly-communicated rework (with changelog entry). That's the standard SemVer 0.x behavior. From `v1.0.0` onward, the rules above apply strictly. diff --git a/internal/agents/antigravity/adapter.go b/internal/agents/antigravity/adapter.go index 3e8205cce..978196d30 100644 --- a/internal/agents/antigravity/adapter.go +++ b/internal/agents/antigravity/adapter.go @@ -1,7 +1,10 @@ package antigravity import ( + "errors" "fmt" + "io" + "os" "path/filepath" "github.com/zzet/gortex/internal/agents" @@ -83,20 +86,19 @@ func (a *Adapter) Apply(env agents.Env, opts agents.ApplyOpts) (*agents.Result, } 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). + // 2. Knowledge Item — kept as a secondary artifact. It makes the native + // MCP public workflow mandatory and treats absent callable handles as an + // integration failure. Exact shipped v0.60.0 content is migrated in place; + // any customized KI is preserved. kiDir := filepath.Join(env.Home, ".gemini", "antigravity", "knowledge", "gortex-workflow") - metaAction, err := agents.WriteIfNotExists(env.Stderr, filepath.Join(kiDir, "metadata.json"), Metadata, opts) + metaAction, err := writeKnowledgeItem(env.Stderr, filepath.Join(kiDir, "metadata.json"), Metadata, v060Metadata, 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) + instrAction, err := writeKnowledgeItem(env.Stderr, filepath.Join(kiDir, "artifacts", "gortex-instructions.md"), Instructions, v060Instructions, opts) if err != nil { return res, err } @@ -105,3 +107,26 @@ func (a *Adapter) Apply(env agents.Env, opts agents.ApplyOpts) (*agents.Result, res.Configured = true return res, nil } + +// writeKnowledgeItem creates a missing artifact or replaces it only when its +// bytes match a Gortex-shipped migration fingerprint. A different existing file is +// user-authored policy and is never overwritten, including under --force. +func writeKnowledgeItem(w io.Writer, path, current, migration string, opts agents.ApplyOpts) (agents.FileAction, error) { + existing, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return agents.WriteIfNotExists(w, path, current, opts) + } + if err != nil { + return agents.FileAction{}, fmt.Errorf("read %s: %w", path, err) + } + + switch string(existing) { + case current: + return agents.FileAction{Path: path, Action: agents.ActionSkip, Reason: "unchanged"}, nil + case migration: + return agents.WriteOwnedFile(w, path, current, opts) + default: + internalutil.Logf(w, "[gortex init] skip %s (customized Knowledge Item)", path) + return agents.FileAction{Path: path, Action: agents.ActionSkip, Reason: "customized"}, nil + } +} diff --git a/internal/agents/antigravity/adapter_test.go b/internal/agents/antigravity/adapter_test.go index 14788316e..32df57ef8 100644 --- a/internal/agents/antigravity/adapter_test.go +++ b/internal/agents/antigravity/adapter_test.go @@ -3,6 +3,7 @@ package antigravity import ( "os" "path/filepath" + "strings" "testing" "github.com/zzet/gortex/internal/agents" @@ -49,6 +50,92 @@ func TestAntigravityRegistersMCPAndWritesKI(t *testing.T) { t.Errorf("KI artifact missing: %s (%v)", p, err) } } - + instructionsPath := filepath.Join(kiBase, "artifacts", "gortex-instructions.md") + instructions, err := os.ReadFile(instructionsPath) + if err != nil { + t.Fatalf("read instructions: %v", err) + } + text := string(instructions) + for _, want := range []string{ + agents.InstructionsSentinel, + "Call `explore` first", + "change(operation:\"impact\")", + "Gortex MCP integration failure", + } { + if !strings.Contains(text, want) { + t.Errorf("Antigravity instructions missing %q", want) + } + } + for _, forbidden := range []string{"./gortex query", "facade-v1", "tools_search", "gortex call", "daemon start"} { + if strings.Contains(text, forbidden) { + t.Errorf("Antigravity instructions contain obsolete agent vocabulary %q", forbidden) + } + } agentstest.AssertIdempotent(t, a, env) } + +func TestAntigravityMigratesExactV060KI(t *testing.T) { + env, _ := agentstest.NewEnv(t) + kiBase := filepath.Join(env.Home, ".gemini", "antigravity", "knowledge", "gortex-workflow") + metadataPath := filepath.Join(kiBase, "metadata.json") + instructionsPath := filepath.Join(kiBase, "artifacts", "gortex-instructions.md") + for path, content := range map[string]string{ + metadataPath: v060Metadata, + instructionsPath: v060Instructions, + } { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + + if _, err := New().Apply(env, agents.ApplyOpts{}); err != nil { + t.Fatalf("apply: %v", err) + } + for path, want := range map[string]string{ + metadataPath: Metadata, + instructionsPath: Instructions, + } { + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(got) != want { + t.Errorf("v0.60.0 artifact %s was not migrated", path) + } + } +} + +func TestAntigravityPreservesCustomizedKI(t *testing.T) { + env, _ := agentstest.NewEnv(t) + kiBase := filepath.Join(env.Home, ".gemini", "antigravity", "knowledge", "gortex-workflow") + metadataPath := filepath.Join(kiBase, "metadata.json") + instructionsPath := filepath.Join(kiBase, "artifacts", "gortex-instructions.md") + custom := map[string]string{ + metadataPath: `{"summary":"my policy"}`, + instructionsPath: "# Keep my custom workflow\n", + } + for path, content := range custom { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + + if _, err := New().Apply(env, agents.ApplyOpts{Force: true}); err != nil { + t.Fatalf("apply: %v", err) + } + for path, want := range custom { + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(got) != want { + t.Errorf("customized artifact %s was overwritten", path) + } + } +} diff --git a/internal/agents/antigravity/content.go b/internal/agents/antigravity/content.go index c7b743947..3ba8de307 100644 --- a/internal/agents/antigravity/content.go +++ b/internal/agents/antigravity/content.go @@ -1,24 +1,43 @@ // Package antigravity implements the Gortex init integration for -// Google's Antigravity. Today we write a Knowledge Item at +// Google's Antigravity. We register native MCP and 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. +// mechanism for teaching Antigravity the mandatory public-tool workflow. 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. +import "github.com/zzet/gortex/internal/agents" + +// Metadata is the KI manifest. Antigravity reads it to show the KI in its UI +// and locate the public-tool workflow. The summary is deliberately transport +// specific: this adapter registers native MCP, so missing handles are an +// integration failure rather than permission to change transports. 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.", + "summary": "MANDATORY: Use native Gortex MCP tools for indexed code. If configured tools are missing, report an MCP integration failure; do not start a daemon or use a CLI fallback.", "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. +// Instructions reuses the same compact, agent-neutral workflow installed by +// every other MCP adapter. Antigravity's native MCP registration makes direct +// tool calls mandatory. const Instructions = `--- type: "Knowledge Item" +description: "Mandatory Gortex public-tool workflow" +--- + +` + agents.InstructionsBody + +// These exact artifacts shipped in gortex v0.60.0. They remain solely as +// byte-for-byte migration fingerprints; customized KI files never match them +// and are therefore preserved. The retirement gate is in docs/versioning.md. +const v060Metadata = `{ + "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"] +} +` + +// v060Instructions is the v0.60.0 CLI-first KI body. +const v060Instructions = `--- +type: "Knowledge Item" description: "Gortex Workflow and Tools for Antigravity" --- diff --git a/internal/agents/claudecode/adapter.go b/internal/agents/claudecode/adapter.go index 8c9b57b96..4191afa10 100644 --- a/internal/agents/claudecode/adapter.go +++ b/internal/agents/claudecode/adapter.go @@ -564,16 +564,6 @@ func pathExists(path string) bool { // 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) } @@ -582,7 +572,43 @@ func installPermissions(w io.Writer, settingsPath string, opts agents.ApplyOpts) perms["allow"] = []any{} } allow := perms["allow"].([]any) - perms["allow"] = append(allow, "mcp__gortex__*") + hasGortexRule := false + hasPreCompactWildcard := false + for _, entry := range allow { + s, ok := entry.(string) + if !ok || !strings.HasPrefix(s, "mcp__gortex__") { + continue + } + hasGortexRule = true + hasPreCompactWildcard = hasPreCompactWildcard || s == "mcp__gortex__*" + } + // Exact existing entries are user policy. Do not widen them. The one + // legacy wildcard is Gortex's old shipped rule and is safe to migrate. + if hasGortexRule && !hasPreCompactWildcard { + return false, nil + } + if hasPreCompactWildcard { + filtered := allow[:0] + for _, entry := range allow { + if entry != "mcp__gortex__*" { + filtered = append(filtered, entry) + } + } + allow = filtered + } + seen := make(map[string]bool, len(allow)) + for _, entry := range allow { + if value, ok := entry.(string); ok { + seen[value] = true + } + } + for _, tool := range agents.CompactMCPAutoApproveTools() { + permission := "mcp__gortex__" + tool + if !seen[permission] { + allow = append(allow, permission) + } + } + perms["allow"] = allow return true, nil }, opts) } @@ -629,7 +655,7 @@ func SyncGlobalSkills(w io.Writer, home string, allowed []string, opts agents.Ap out = append(out, agents.FileAction{Path: path, Action: agents.ActionSkip, Reason: "outside-profile"}) continue } - if string(existing) != content { + if !isShippedAgentArtifact(existing, content, v060GlobalSkillHashes[name]) { logWarn(w, "keeping customised skill %s (outside the active profile)", path) out = append(out, agents.FileAction{Path: path, Action: agents.ActionSkip, Reason: "customised"}) continue @@ -645,7 +671,7 @@ func SyncGlobalSkills(w io.Writer, home string, allowed []string, opts agents.Ap out = append(out, agents.FileAction{Path: path, Action: agents.ActionDelete, Keys: []string{"skill"}}) continue } - action, err := agents.WriteIfNotExists(w, path, content, opts) + action, err := writeAgentArtifact(w, path, content, v060GlobalSkillHashes[name], opts) if err != nil { return out, err } @@ -664,7 +690,7 @@ func installGlobalSlashCommands(w io.Writer, home string, opts agents.ApplyOpts) dir := filepath.Join(userClaudeConfigDir(home), "commands") for name, content := range SlashCommands { path := filepath.Join(dir, name) - action, err := agents.WriteIfNotExists(w, path, content, opts) + action, err := writeAgentArtifact(w, path, content, v060SlashCommandHashes[name], opts) if err != nil { return out, err } @@ -682,7 +708,7 @@ func installGlobalSubAgents(w io.Writer, home string, opts agents.ApplyOpts) ([] dir := filepath.Join(userClaudeConfigDir(home), "agents") for name, content := range SubAgents { path := filepath.Join(dir, name) - action, err := agents.WriteIfNotExists(w, path, content, opts) + action, err := writeAgentArtifact(w, path, content, v060SubAgentHashes[name], opts) if err != nil { return out, err } diff --git a/internal/agents/claudecode/adapter_test.go b/internal/agents/claudecode/adapter_test.go index fdb012ddd..6ca9d46b8 100644 --- a/internal/agents/claudecode/adapter_test.go +++ b/internal/agents/claudecode/adapter_test.go @@ -1,6 +1,7 @@ package claudecode import ( + "io" "os" "path/filepath" "strings" @@ -10,6 +11,43 @@ import ( "github.com/zzet/gortex/internal/agents/agentstest" ) +func TestInstallPermissionsMigratesWildcardButPreservesCustomPolicy(t *testing.T) { + t.Run("legacy wildcard", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "settings.json") + if err := os.WriteFile(path, []byte(`{"permissions":{"allow":["mcp__gortex__*"]}}`), 0o644); err != nil { + t.Fatal(err) + } + if _, err := installPermissions(io.Discard, path, agents.ApplyOpts{}); err != nil { + t.Fatal(err) + } + body, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + text := string(body) + if strings.Contains(text, "mcp__gortex__*") || !strings.Contains(text, "mcp__gortex__read") || strings.Contains(text, "mcp__gortex__edit") { + t.Fatalf("unsafe wildcard migration: %s", text) + } + }) + + t.Run("custom narrow list", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "settings.json") + if err := os.WriteFile(path, []byte(`{"permissions":{"allow":["mcp__gortex__read"]}}`), 0o644); err != nil { + t.Fatal(err) + } + if _, err := installPermissions(io.Discard, path, agents.ApplyOpts{}); err != nil { + t.Fatal(err) + } + body, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if strings.Count(string(body), "mcp__gortex__") != 1 || strings.Contains(string(body), "mcp__gortex__search") { + t.Fatalf("custom permission list was widened: %s", body) + } + }) +} + // TestClaudeCodeProjectModeCreatesCanonicalArtifacts is the // acceptance test for the most important adapter. It asserts that // a fresh project gets: @@ -142,15 +180,15 @@ func TestClaudeCodeGlobalModeWritesUserFiles(t *testing.T) { } } - // settings.json must contain the mcp__gortex__* permission rule - // so MCP tool calls don't prompt for approval each session. + // settings.json auto-approves only the safe compact read surface; mutation, + // session control, and external writes still require host approval. 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) + if !strings.Contains(string(body), "mcp__gortex__read") || strings.Contains(string(body), "mcp__gortex__*") || strings.Contains(string(body), "mcp__gortex__edit") { + t.Errorf("expected the safe compact permission set in %s, got:\n%s", settingsPath, body) } // CLAUDE.md must contain the marker-fenced rule block when diff --git a/internal/agents/claudecode/content.go b/internal/agents/claudecode/content.go index c2df951a9..903d690ab 100644 --- a/internal/agents/claudecode/content.go +++ b/internal/agents/claudecode/content.go @@ -56,7 +56,7 @@ Refactor & edit (enforce tool-call order): ` + "`/gortex-refactor`" + `, ` + "`/ Review & operate: ` + "`/gortex-pr-review`" + `, ` + "`/gortex-architecture-review`" + `, ` + "`/gortex-quality-audit`" + `, ` + "`/gortex-incident-investigation`" + `, ` + "`/gortex-episode-replay`" + ` -The edit and refactor skills enforce a tool-call order — they exist to keep you on the speculative-execution path (` + "`preview_edit`" + ` → ` + "`simulate_chain`" + ` → ` + "`batch_edit`" + `) and the LSP code-actions path (` + "`get_code_actions`" + ` → ` + "`apply_code_action`" + `) instead of going straight to ` + "`Edit`" + ` / ` + "`Write`" + `. The review & operate skills wrap the discovery + impact + memory surfaces into ordered playbooks so postmortems, audits, and PR reviews are graph-grounded. +Follow each command's ordered MCP workflow. Use ` + "`explore`" + ` first for task-shaped work, ` + "`change`" + ` before and after mutations, and ` + "`edit`" + ` or ` + "`refactor`" + ` for writes. Call ` + "`capabilities`" + ` only when an operation's exact arguments are unclear. ` // ClaudeMdSentinel is the substring used to detect whether @@ -224,1644 +224,225 @@ description: "Coding-agent review verdict via the gortex review verb." "gortex-cli": skillGortexCLI, } -const commandGuide = `# Gortex Guide - -Quick reference for all Gortex MCP tools and the knowledge graph schema. - -## Always Start Here - -1. **Call ` + "`index_health`" + `** — confirm Gortex is running (cheap); use ` + "`graph_stats`" + ` when you actually need node/edge counts -2. **Match your task to a command below** -3. **Follow the command's workflow** - -> If ` + "`total_nodes`" + ` is 0, call ` + "`index_repository`" + ` with ` + "`path: \".\"`" + ` first. - -## Commands - -### Discovery & analysis - -| Task | Command | -| ------------------------------------------------------------ | ----------------------------- | -| Understand architecture / "How does X work?" | /gortex-explore | -| Blast radius / "What breaks if I change X?" | /gortex-impact | -| Trace bugs / "Why is X failing?" | /gortex-debug | -| Trace dataflow / "Where does this value end up?" | /gortex-dataflow-trace | -| Cross-repo usage / "Who uses this across all our repos?" | /gortex-cross-repo-usage | -| Co-change / "What changes together with X?" | /gortex-co-change | -| Onboarding / "Give me a tour of this repo" | /gortex-onboarding | -| Tools, schema reference | /gortex-guide (this) | - -### Refactor & edit (enforce tool-call order) - -These wrap the speculative-execution + LSP-code-actions plumbing so you do not bypass the safety steps by calling ` + "`Edit`" + ` / ` + "`Write`" + ` directly. - -| Task | Command | -| ------------------------------------------------------------ | ----------------------------- | -| Safe edit / "Apply this WorkspaceEdit but verify first" | /gortex-safe-edit | -| Rename / extract / split / restructure | /gortex-refactor | -| Rename one symbol everywhere | /gortex-rename | -| Extract a function / method / variable via LSP refactor | /gortex-extract-function | -| Apply LSP quick-fixes / source.fixAll | /gortex-fix-all | -| Add tests for under-covered code | /gortex-add-test | - -### Review & operate (graph-grounded playbooks) - -These wrap the discovery + impact + memory surfaces into ordered playbooks so postmortems, audits, and PR reviews are graph-grounded, not stream-of-consciousness. - -| Task | Command | -| ------------------------------------------------------------ | ----------------------------- | -| Review a PR / staged diff | /gortex-pr-review | -| PR review as a sub-agent (shell ` + "`gortex review --audience agent`" + `) | /gortex-pr-review-agent | -| Architecture review (narrative + diagrams) | /gortex-architecture-review | -| Quality audit (prioritised findings packet) | /gortex-quality-audit | -| Incident investigation (symptom → root cause) | /gortex-incident-investigation| -| Episode replay (postmortem / release / window timeline) | /gortex-episode-replay | - -## Tools Reference - -> The server registers 120+ MCP tools, but ` + "`tools/list`" + ` shows only a core set — the rest load lazily. Call ` + "`tools_search`" + ` to discover and load any tool by keyword; ` + "`tool_profile`" + ` reports the active profile. The tables below curate the high-traffic set. - -### Core Navigation -| Tool | What it gives you | -|------|-------------------| -| graph_stats | Node/edge counts by kind and language — session start orientation | -| search_symbols | Find symbols by keyword (BM25 + camelCase-aware). Use instead of Grep | -| search_text | Trigram-accelerated literal / regex text search — the fast grep replacement for raw string matches the symbol index won't catch | -| winnow_symbols | Structured constraint chain: kind, language, community, path_prefix, min_fan_in, min_churn — returns ranked rows with per-axis score contributions. Use when free-text search is too coarse | -| get_symbol | Single symbol: location, signature, edges. Use instead of Read | -| get_file_summary | All symbols + imports in a file. Use instead of Read | -| get_editing_context | **Primary pre-edit tool.** Symbols, signatures, callers, callees for a file | -| get_architecture | One-shot architectural snapshot — languages, communities, hotspots, processes. Pass ` + "`resolution`" + ` (symbol/file/package/service/system) for a hierarchical multi-resolution rollup | -| gortex_wakeup | Paste-ready ~500-token markdown digest of the codebase — fastest cold-start orientation | - -### Graph Traversal -| Tool | What it gives you | -|------|-------------------| -| get_dependencies | What a symbol depends on (forward: imports, calls, refs) | -| get_dependents | What depends on a symbol (backward: blast radius) | -| get_call_chain | Forward call graph from a function | -| get_callers | Reverse call graph to a function | -| find_usages | Every reference to a symbol; each carries its reference context and accepts a context: filter (parameter_type / return_type / field / value / type / attribute / call). Use instead of Grep | -| find_implementations | All types implementing an interface | -| get_cluster | Bidirectional neighborhood around a node | - -### Coding Workflow -| Tool | What it gives you | -|------|-------------------| -| get_symbol_source | Source code of a single symbol — use instead of Read. Requires a symbol ID like ` + "`path/to/file.go::SymbolName`" + ` — for a method include the receiver: ` + "`path/to/file.go::Recv.Name`" + ` (call ` + "`get_file_summary`" + ` first if you only have a file path). Pass ` + "`if_none_match`" + ` with previous ` + "`etag`" + ` to get ` + "`not_modified`" + ` (skip re-reading unchanged source) | -| batch_symbols | Multiple symbols with source/callers/callees in one call | -| find_import_path | Correct import path for a symbol in a target file | -| explain_change_impact | Risk-tiered blast radius with affected processes/communities | -| edit_symbol | Edit symbol source by ID — no Read needed, resolves file + lines | -| edit_file | String-replace any file (markdown / config / spec / source) by absolute or repo-relative path. No pre-Read required. Atomic write (temp+rename), auto-reindex. ` + "`replace_all`" + ` for many occurrences; ` + "`dry_run`" + ` to preview. | -| write_file | Create or overwrite any file by absolute or repo-relative path. No pre-Read required. Atomic write, creates parent dirs, auto-reindex. ` + "`dry_run`" + ` to preview. | -| rename_symbol | Coordinated rename: generates edits for definition + all references | -| safe_delete_symbol | Deletion with a pre-flight safety check — refuses (or warns) when live callers / implementors still reference the symbol | -| get_recent_changes | Files/symbols changed since timestamp (watch mode) | - -### Agent-Optimized (token efficiency) -| Tool | What it gives you | -|------|-------------------| -| smart_context | Task-aware minimal context bundle — replaces 5-10 exploration calls | -| plan_turn | Suggested next tool calls for the current task — orchestrator for one turn | -| prefetch_context | Predicts needed symbols from task description + recent activity | -| get_edit_plan | Dependency-ordered edit sequence for multi-file refactors | -| get_test_targets | Maps changed symbols to test files and run commands | -| get_untested_symbols | Lists symbols with no covering test — candidates for new tests | -| suggest_pattern | Extracts code pattern from an example — source, registration, tests | -| export_context | Portable markdown/JSON briefing — share context outside MCP (Slack, PRs, docs) | - -### Analysis -| Tool | What it gives you | -|------|-------------------| -| get_communities | Functional clusters via Louvain community detection (with id: returns single community details) | -| get_processes | Discovered execution flows (with id: returns single process step-by-step trace) | -| detect_changes | Git diff -> affected symbols -> blast radius | -| get_surprising_connections | Edges ranked by an anomaly score — the unexpected couplings worth a second look | -| get_knowledge_gaps | Under-documented / under-tested areas the graph can see but the docs can't | -| get_coupling_metrics | Per-symbol coupling metrics (afferent / efferent, instability) | -| get_churn_rate | Per-symbol git-commit density — how often a symbol actually changes | -| get_extraction_candidates | Functions ranked by extract-function value — long / complex / duplicated bodies | - -### Proactive Safety -| Tool | What it gives you | -|------|-------------------| -| verify_change | Checks proposed signature changes against all callers and interface implementors | -| check_guards | Evaluates project guard rules against changed symbols — co-change / boundary rules, declarative architecture layers (allow / deny), and dependency-cone rules (max_fan_out, deny_callers_outside) from ` + "`.gortex.yaml`" + ` | - -### Dataflow (CPG-lite) -| Tool | What it gives you | -|------|-------------------| -| flow_between | Ranked dataflow paths between two symbol IDs. Walks ` + "`value_flow`" + ` (intra-procedural) ∪ ` + "`arg_of`" + ` (caller arg → callee param) ∪ ` + "`returns_to`" + ` (callee → assignment). Pass ` + "`max_depth`" + ` (default 8) and ` + "`max_paths`" + ` (default 10). | -| taint_paths | Pattern-driven dataflow sweep — every flow from a matching source to a matching sink. Patterns: bare token = name substring; ` + "`exact:Foo`" + `; ` + "`path:dir/`" + `; ` + "`kind:method`" + ` (clauses combine with AND). Sinks expand functions to their params automatically. | -| get_cfg | Per-function control-flow graph for one function/method ID — basic blocks, labeled edges (seq/true/false/loop_back/break/continue/return/case/exception/finally), per-statement def/use sets, and statement-granular reaching-definition chains. Optional ` + "`mermaid`" + ` rendering. Go / Python / JS / TS / Java / Rust / Ruby. | - -### Structural Code Search -| Tool | What it gives you | -|------|-------------------| -| search_ast (detector mode) | Bundled cross-language anti-pattern detectors — representative subset below; ` + "`analyze kind=sast`" + ` runs the full CWE/OWASP-tagged security rule library and ` + "`list_inspections`" + ` shows the complete menu. Pass ` + "`detector: \"\"`" + ` for one of: ` + "`error-not-wrapped`" + ` (Go), ` + "`sql-string-concat`" + ` (Go/Python/JS/TS/Ruby), ` + "`weak-crypto`" + ` (Go/Python), ` + "`panic-in-library`" + ` (Go), ` + "`goroutine-without-recover`" + ` (Go), ` + "`http-client-no-timeout`" + ` (Go), ` + "`hardcoded-secret`" + ` (Go/Python/JS/TS/Ruby), ` + "`empty-catch`" + ` (Java/JS/TS/Python), ` + "`java-string-equality`" + ` (Java), ` + "`python-mutable-default-arg`" + ` (Python). Each match returns the enclosing ` + "`symbol_id`" + ` so you can chain into ` + "`find_usages`" + ` / ` + "`apply_code_action`" + `. Test files excluded by default. | -| search_ast (raw pattern) | Tree-sitter S-expression queries. Pass ` + "`pattern: \"...\"`" + ` + ` + "`language: \"...\"`" + `. Capture nodes with ` + "`@name`" + `, anchor with ` + "`@match`" + `, predicates ` + "`(#eq? @x \"literal\")`" + ` / ` + "`(#match? @x \"regex\")`" + `. Example: ` + "`((call_expression function: (identifier) @fn) @match (#eq? @fn \"panic\"))`" + ` finds every direct ` + "`panic()`" + ` call. | -| search_ast (graph filters) | Combine the structural match with graph predicates ast-grep can't express: ` + "`path_prefix`" + ` / ` + "`repo`" + ` / ` + "`project`" + ` / ` + "`ref`" + ` / ` + "`min_fan_in_of_enclosing_func`" + `. The last narrows results to load-bearing code by dropping matches in functions with few callers. | - -### Clone Detection -| Tool | What it gives you | -|------|-------------------| -| find_clones | Near-duplicate function/method clusters from the ` + "`similar_to`" + ` graph layer (MinHash + LSH over normalised tokens — catches copy-paste and renamed-variable clones). Every member is flagged ` + "`is_dead`" + `; pass ` + "`dead_only: true`" + ` for the "dead duplicates of live code" diagnostic. Filters: ` + "`min_similarity`" + `, ` + "`path_prefix`" + `, ` + "`repo`" + `, ` + "`limit`" + `. | - -### Diagnostics & Code Actions -| Tool | What it gives you | -|------|-------------------| -| subscribe_diagnostics | Opt the session into push ` + "`notifications/diagnostics`" + ` from every running language server. Initial state replays as ` + "`initial_replay: true`" + `; thereafter only delta-changed files are pushed (sha256-suppressed). ` + "`min_severity`" + ` (1=error, 2=warning, 3=info, 4=hint) and ` + "`path_prefix`" + ` filters scope what reaches the session. Eliminates the poll-after-edit loop. | -| unsubscribe_diagnostics | Opt out of push notifications. Idempotent; fires automatically on session disconnect, so explicit calls are only needed when narrowing scope. | -| get_diagnostics | Latest stored ` + "`publishDiagnostics`" + ` for a file (the polling form). Pass ` + "`wait: true`" + ` + ` + "`timeout_ms`" + ` to block on the first publish — useful right after ` + "`didOpen`" + ` when no event has fired yet. | -| get_code_actions | LSP code actions for a file (and optional range). Returns the menu of fixes / refactors / source actions the language server offers. | -| apply_code_action | Apply a single CodeAction → WorkspaceEdit on disk. Atomic temp+rename; supports both legacy ` + "`changes`" + ` and modern ` + "`documentChanges`" + ` shapes; UTF-16 column math correctly maps LSP positions onto the byte offset in the source. | -| fix_all_in_file | One-shot ` + "`source.fixAll`" + ` over an entire file. Bundles every server-suggested fix in a single round-trip. | - -### Notifications (session push topics) -| Tool | What it gives you | -|------|-------------------| -| subscribe_diagnostics / unsubscribe_diagnostics | Push ` + "`notifications/diagnostics`" + ` from running language servers (see above) | -| subscribe_graph_invalidated / unsubscribe_graph_invalidated | Push ` + "`notifications/graph_invalidated`" + ` when the graph is rebuilt — re-run stale queries instead of polling | -| subscribe_daemon_health / unsubscribe_daemon_health | Push ` + "`notifications/daemon_health`" + ` — daemon readiness / warmup / memory transitions | -| subscribe_stale_refs / unsubscribe_stale_refs | Push ` + "`notifications/stale_refs`" + ` when watched files drift the graph out from under cached symbol IDs | -| subscribe_workspace_readiness / unsubscribe_workspace_readiness | Push ` + "`notifications/workspace_readiness`" + ` as repos finish indexing in multi-repo mode | - -### Knowledge & Memory -| Tool | What it gives you | -|------|-------------------| -| save_note / query_notes / distill_session | Per-session scratchpad — decisions and findings that survive context compaction | -| store_memory / query_memories / surface_memories | Cross-session, symbol-linked development memory — invariants / gotchas / decisions every future agent in the workspace inherits. ` + "`surface_memories`" + ` ranks them against your working set | -| edit_memory / rename_memory | Amend or re-anchor an existing development memory | -| notebook_save / notebook_show / notebook_list / notebook_find | Repository-local persistent notebook for longer-form notes | -| search_artifacts / get_artifact | Search and fetch non-code knowledge files (DB schemas, API specs, ADRs, infra configs) registered via the ` + "`.gortex.yaml`" + ` ` + "`artifacts:`" + ` manifest and indexed as ` + "`artifact`" + ` nodes | - -### Code Quality -| Tool | What it gives you | -|------|-------------------| -| analyze | Unified graph-analysis dispatcher (60 kinds). Structural: dead_code, hotspots, cycles, would_create_cycle, clusters, concepts, role, connectivity_health, edge_audit, constructors_missing_fields. Quality / security: health_score, impact, sast, hygiene, unsafe_patterns, named, review (idiomatic / correctness rulepack — NPE, thread-safety check-then-act, N+1, logic-error; Go + Python — with a graph-grounded false-positive-reduction pass). Churn / ownership: todos, stale_code, ownership, fixes_history, blame. Coverage / releases: coverage, coverage_gaps, coverage_summary, releases. Schema / SQL: orphan_tables, unreferenced_tables, sql_call_sites, sql_rebuild, dbt_models, models. Flags / interop: stale_flags, cgo_users, wasm_users. Edge-driven: channel_ops, race_writes, unclosed_channels, goroutine_spawns, field_writers, annotation_users, config_readers, env_var_users, event_emitters, log_events, string_emitters, error_surface, external_calls, tests_as_edges. Web / infra: routes, components, k8s_resources, images, kustomize, pubsub. Cross-repo: cross_repo. Dataflow: def_use (per-function reaching-definition def→use chains over the on-demand CFG; pairs with the get_cfg tool). Provenance / resolution: synthesizers (framework-dispatch edges grouped by pass), resolution_outcomes (why a call/ref edge stayed unresolved). Extensible: domain | -| analyze kind=dead_code | Symbols with zero incoming edges (excludes entry points, tests, exports) | -| analyze kind=hotspots | Over-coupled symbols ranked by fan-in, fan-out, and community crossings | -| analyze kind=cycles | Tarjan's SCC with severity classification | -| analyze kind=would_create_cycle | Pre-flight check before adding a new dependency | -| analyze kind=todos | KindTodo nodes; filter by tag/assignee/ticket | -| analyze kind=blame | Stamps meta.last_authored on every blame-eligible node | -| analyze kind=coverage | Stamps meta.coverage_pct on executable symbols from cover.out | -| analyze kind=stale_code | Symbols whose last-author timestamp is older than ` + "`older_than`" + ` days | -| analyze kind=ownership | Per-author rollup with symbol/file counts and oldest/newest TS | -| analyze kind=coverage_gaps | Symbols inside [min_pct, max_pct) — undertested code | -| analyze kind=coverage_summary | Per-directory coverage rollup (avg, covered, partial, uncovered) | -| analyze kind=stale_flags | Feature flags whose every toggling caller is older than ` + "`older_than`" + ` days | -| analyze kind=releases | Stamps meta.added_in on file nodes from git tags | -| analyze kind=cgo_users / wasm_users | Files that import C / use #[wasm_bindgen] | -| analyze kind=orphan_tables | Tables queried (EdgeQueries) but missing a migration (EdgeProvides) | -| analyze kind=unreferenced_tables | Tables provided by a migration but with zero EdgeQueries | -| analyze kind=channel_ops | Channels grouped by EdgeSends / EdgeRecvs — producer/consumer mismatches | -| analyze kind=goroutine_spawns | EdgeSpawns grouped by spawned target + mode (goroutine/async/promise) | -| analyze kind=field_writers | Mutability hotspots — fields ranked by EdgeWrites; pass ` + "`id`" + ` for one field | -| analyze kind=annotation_users | EdgeAnnotated rollup; pass ` + "`id`" + ` or ` + "`name`" + ` to scope (e.g. @Deprecated) | -| analyze kind=config_readers | config_key nodes grouped by EdgeReadsConfig; ` + "`name`" + ` filter | -| analyze kind=event_emitters | Event/log/metric emit sites grouped by EdgeEmits; ` + "`level`" + `, ` + "`name`" + ` filters | -| analyze kind=pubsub | Event pub/sub topics with publishers (EdgeEmits) + subscribers (EdgeListensOn) — NATS / Kafka / RabbitMQ / Redis / EventEmitter / Socket.IO; ` + "`transport`" + ` / ` + "`name`" + ` / ` + "`role`" + ` filters | -| analyze kind=error_surface | Function/method nodes with their EdgeThrows targets — refactor blast radius | -| analyze kind=external_calls | Stdlib / module-cache attribution — KindModule rollup of call/symbol counts; pass ` + "`id`" + ` for per-symbol detail, ` + "`module_kind`" + ` to filter stdlib vs module_cache | -| analyze kind=routes | Handler↔route pairs from the EdgeHandlesRoute layer (HTTP/gRPC/WS/GraphQL/topic); ` + "`method`" + ` / ` + "`path`" + ` / ` + "`type`" + ` filters | -| analyze kind=models | Model→table edges from EdgeModelsTable across gorm / SQLAlchemy / Django / ActiveRecord / JPA / TypeORM / Ecto; ` + "`orm`" + ` / ` + "`table`" + ` / ` + "`model`" + ` filters | -| analyze kind=components | Parent↔child fan-in/out from EdgeRendersChild (JSX/TSX + Phoenix HEEx); pass ` + "`id`" + ` for per-component child list | -| analyze kind=k8s_resources | KindResource fan-out (depends_on / configures / mounts / exposes / uses_env); ` + "`k8s_kind`" + ` / ` + "`namespace`" + ` / ` + "`name`" + ` filters | -| analyze kind=images | Container images (Dockerfile FROM target or K8s ` + "`container.image`" + `) with consumer count; ` + "`role`" + ` (base/stage) / ` + "`ref`" + ` / ` + "`tag`" + ` filters | -| analyze kind=kustomize | KindKustomization overlay tree with base / resource fan-out; ` + "`dir`" + ` filter | -| analyze kind=cross_repo | Repo-boundary-crossing calls / implements / extends edges grouped by (source repo → target repo, relation); ` + "`repo`" + ` / ` + "`base_kind`" + ` / ` + "`path_prefix`" + ` filters | -| analyze kind=dbt_models | dbt / SQLMesh models, seeds, snapshots, sources (KindTable) with column count + EdgeDependsOn lineage fan-in/out; ` + "`framework`" + ` / ` + "`type`" + ` / ` + "`materialized`" + ` / ` + "`name`" + ` filters | -| analyze kind=impact | Composite 0-100 change-impact score + risk label over 5 axes (PageRank, reach, complexity, co-change, community span); ` + "`ids`" + ` / ` + "`path_prefix`" + ` / ` + "`min_score`" + ` filters | -| analyze kind=health_score | Composite per-symbol health 0-100 + A-F grade (coverage / complexity / recency / churn); ` + "`grade`" + ` filter, ` + "`roll_up`" + ` file or repo | -| analyze kind=sast / hygiene | Bandit-parity SAST library — 190+ rules across 8 languages, CWE + OWASP tagged; ` + "`severity`" + ` / ` + "`cwe`" + ` / ` + "`tag`" + ` / ` + "`detector`" + ` filters | -| analyze kind=unsafe_patterns | Panic-prone / undefined-behavior primitive scan across all languages | -| analyze kind=review | Idiomatic / correctness review rulepack — NPE, thread-safety check-then-act, N+1, logic-error (Go + Python) with a graph-grounded false-positive-reduction pass. The engine behind ` + "`/gortex-pr-review-agent`" + ` and the ` + "`gortex review`" + ` verb | -| analyze kind=named | Runs a named query bundle — built-ins cover sql-injection, command-injection, hardcoded-secrets, weak-crypto, xss, ssrf, xxe, path-traversal, unsafe-deserialization, debug-leftovers; repo bundles come from ` + "`.gortex.yaml`" + ` ` + "`queries`" + ` | -| analyze kind=clusters | Community detection as an analyzer — ` + "`algorithm`" + ` = leiden / louvain / spectral, ` + "`min_size`" + ` | -| analyze kind=concepts / role | Concept clusters mined over the graph; per-symbol architectural-role classification | -| analyze kind=connectivity_health | Graph-extraction quality — isolated nodes, leaf / source / sink counts, dead-weight-by-file. Distinct from dead_code (which is symbol-level reachability) | -| analyze kind=edge_audit | Graph-completeness / edge-sanity diagnostic — missing or suspect edges | -| analyze kind=constructors_missing_fields | Constructors that leave one or more struct fields unset | -| analyze kind=race_writes / unclosed_channels | Concurrent-write race detection; channels that are opened but never closed | -| analyze kind=env_var_users | EdgeReadsConfig restricted to env-var keys, grouped by variable | -| analyze kind=sql_call_sites / sql_rebuild | EdgeQueries grouped by calling symbol with table read/write split; sql_rebuild re-derives the SQL table layer from the string-literal registry | -| analyze kind=log_events / string_emitters | Logging-call sites and string-literal emission sites surfaced as events | -| analyze kind=fixes_history | Mines git for bug-fix commits and ranks fix-prone files | -| analyze kind=tests_as_edges | View over the test->code EdgeTests layer; ` + "`group_by`" + ` symbol or test | -| analyze kind=domain | Results of pluggable TOML domain-extractor rules — project-specific node/edge kinds | -| index_health | Health score, parse failures, stale files, language coverage | -| get_symbol_history | Symbols modified this session with counts; flags churning (3+ edits) | -| gortex enrich blame\|coverage\|releases\|all (CLI) | Bulk-stamp the graph with the metadata that stale_*/coverage_*/ownership/releases analyzers need | -| list_inspections | Lists the uniform inspection rules available — the menu for ` + "`run_inspections`" + ` | -| run_inspections | Runs a chosen set of inspection rules over the graph and returns ranked findings | - -### Code Generation -| Tool | What it gives you | -|------|-------------------| -| scaffold | Generates code, registration wiring, and test stubs from an example symbol | -| batch_edit | Applies multiple edits in dependency order, re-indexes between steps | -| diff_context | Git diff enriched with callers, callees, community, processes, per-file risk | - -### API Contracts -| Tool | What it gives you | -|------|-------------------| -| contracts | API contracts: action=list (default) lists detected contracts; action=check matches providers/consumers and reports orphans across repos; action=bridge ranks matched provider↔consumer groups (RRF over text / path-repo / adjacency / degree signals; mode=impact for a symbol's cross-service blast radius). Scope any action with ` + "`repo`" + `, ` + "`project`" + `, or ` + "`ref`" + ` | - -### Config Hygiene -| Tool | What it gives you | -|------|-------------------| -| audit_agent_config | Graph-validates backticked symbols in CLAUDE.md / AGENTS.md / ` + "`.cursor/rules`" + ` / Copilot / Windsurf / Antigravity configs — flags stale refs, dead paths, bloat | - -### Agent Learning -| Tool | What it gives you | -|------|-------------------| -| feedback (action=record) | Report which symbols from ` + "`smart_context`" + ` were useful / not_needed / missing after a task — improves future bundles | -| feedback (action=query) | Aggregated stats: most useful, most missed, context accuracy over time | - -### Multi-Repo -| Tool | What it gives you | -|------|-------------------| -| index_repository | Index a repository path into the graph | -| reindex_repository | Incremental re-index — re-parses only changed files (optionally scoped to ` + "`paths`" + `); much cheaper than a full index_repository | -| track_repository | Add a repo to the workspace, index immediately, persist to config | -| untrack_repository | Remove a repo, evict its nodes/edges, persist to config | -| get_active_project | Current project name and member repository list | -| set_active_project | Switch project scope — re-scopes all subsequent queries | - -## Graph Schema - -**Node kinds:** -- Code structure: file, package, function, method, type, interface, field, variable, constant, import, contract, param, closure, enum_member, generic_param -- Coverage extensions: module (ecosystem deps), table / column (db schema), config_key (env/viper/cli), flag (feature flags), event (logs/metrics/spans), migration, fixture (test data), todo (TODO/FIXME comments), team (CODEOWNERS), license, release (tag boundaries) -- Knowledge & infra: artifact (non-code knowledge files — DB schemas / API specs / ADRs / infra configs registered via the ` + "`.gortex.yaml`" + ` ` + "`artifacts:`" + ` manifest), string (string-literal registry), image (container images), resource (Kubernetes / infra resources) - -**Edge kinds:** -- Calls / structure: calls, imports, defines, implements, extends, references, member_of, instantiates, provides, consumes, composes, aliases, typed_as, returns, captures, param_of -- Modules: depends_on_module, package_workspace_member (package-manager workspace root → member package) -- Concurrency: spawns (goroutine/async/promise), sends / recvs (channels) -- Mutation: reads / writes (fields), reads_config / writes_config -- Dataflow (CPG-lite, ` + "`flow_between`" + ` / ` + "`taint_paths`" + `): value_flow (intra-procedural assignment / return / range), arg_of (caller arg → callee param), returns_to (callee → assignment LHS) -- Metadata: annotated (decorators), emits (events + pub/sub publish), listens_on (pub/sub subscribe), throws (errors), queries (SQL), reads_col / writes_col, toggles_flag, matches (fixtures), generated_by, tests (test → tested symbol), covered_by, owns (CODEOWNERS), authored, licensed_as -- Similarity: similar_to (function/method near-duplicate — MinHash + LSH clone detection, ` + "`find_clones`" + `) -- Cross-repo: cross_repo_calls / cross_repo_implements / cross_repo_extends (parallel edges materialised when a calls/implements/extends edge crosses a repo boundary, ` + "`analyze kind=cross_repo`" + `) - -Edge **provenance** (which extractor minted an edge) is part of edge identity — two edges between the same nodes with different origins are distinct. ` + "`graph_stats`" + ` surfaces ` + "`edge_identity_revisions`" + ` as a tamper-evidence counter, and navigation results carry concurrency-safety hints where the graph can infer them. -` - -const commandExplore = `# Exploring Codebases with Gortex - -## Workflow - -` + "```" + ` -1. graph_stats -> Confirm index, get node/edge counts -2. smart_context({task: ""}) -> One-call exploration bundle (start here) -3. get_communities -> See functional clusters (architecture overview) -4. search_symbols({query: ""}) -> Find symbols related to a concept -5. get_processes -> Discover execution flows -6. get_processes({id: ""}) -> Trace a specific flow step by step -7. get_file_summary({path: ""}) -> Symbols + imports for one file -8. get_editing_context({path: ""}) -> Deep dive on a file (callers + callees) -9. export_context({...}) -> Share findings as markdown/JSON (PRs, Slack, docs) -` + "```" + ` - -## Checklist - -- Call index_health to confirm Gortex is running (graph_stats only when you need node/edge counts) -- Call smart_context first — one call replaces 5-10 exploration calls -- Call get_communities for architecture overview when smart_context is not enough -- Call search_symbols for the concept you want to understand -- Call get_processes to discover execution flows -- Call get_processes with id on relevant flows for step-by-step traces -- Call get_editing_context on key files for full symbol context -- Call export_context to hand a findings packet outside the session -- Read source files only for implementation details you actually need to edit -` - -const commandDebug = `# Debugging with Gortex - -## Workflow - -` + "```" + ` -1. search_symbols({query: ""}) -> Find related symbols -2. get_callers({id: ""}) -> Who calls it? -3. get_call_chain({id: ""}) -> What does it call? -4. get_editing_context({path: ""}) -> Full file context -5. get_processes({id: ""}) -> Trace execution flow -6. get_symbol_history -> Symbols churning this session (regression hotspot) -7. explain_change_impact({ids: ""}) -> Who else will feel the fix -` + "```" + ` - -## Debugging Patterns - -| Symptom | Gortex Approach | -| ----------------------- | --------------- | -| Error message | search_symbols for error-related names -> get_callers on throw sites; analyze kind=error_surface to map who throws what | -| Wrong return value | get_call_chain on the function -> trace callees for data flow; flow_between({source_id, sink_id}) when you suspect the wrong value flows through helpers | -| Trace bad value to its origin | flow_between({source_id: producer, sink_id: consumer}) — ranked dataflow paths over value_flow / arg_of / returns_to. Faster than reading source for "where did this value come from?" | -| Find every taint into a sink | taint_paths({source_pattern: "name:Source", sink_pattern: "name:Sink"}) — every flow from any matching source to any matching sink (functions auto-expand to their params on the sink side) | -| Intermittent failure | get_editing_context -> look for external calls, async deps; analyze kind=goroutine_spawns to find unowned background work | -| Channel deadlock | analyze kind=channel_ops -> channels with sends but no receivers (or vice versa) | -| Performance issue | find_usages -> find symbols with many callers (hot paths) | -| Recent regression | detect_changes -> see what your changes affect. get_symbol_history flags symbols edited 3+ times this session | -| Flaky test | get_untested_symbols near the suspect -> find coverage gaps the flake may hide | -| Stale index suspect | index_health -> parse failures and stale files can mask the real bug | -| Stale-flag suspect | analyze kind=stale_flags -> flags with every caller untouched for ` + "`older_than`" + ` days are dead-rollout candidates | -| Config drift | analyze kind=config_readers -> who reads this env/viper key? Surfaces forgotten readers | -| Event/log volume spike | analyze kind=event_emitters with level=error -> find every site that logs an error | -| Mutation race suspicion | analyze kind=field_writers id= -> every function that writes the contended field | -| Annotation drift | analyze kind=annotation_users name=Deprecated -> every site still using a deprecated API | -| Env var read/write mismatch | find_usages on cfg::env:: -> Resources/Dockerfile stages declaring it (EdgeUsesEnv) plus code-side os.Getenv consumers via the shared config_key node | -| K8s manifest blast radius | analyze kind=k8s_resources k8s_kind=ConfigMap -> orphan ConfigMaps. find_usages on a ConfigMap Resource ID surfaces every workload that envFroms or mounts it | -| Container image audit | analyze kind=images role=base -> every external image and how many Dockerfile stages / K8s Resources pull it. Filter by tag=latest to find the unpinned ones | -` - -const commandImpact = `# Impact Analysis with Gortex - -## Workflow - -` + "```" + ` -1. search_symbols({query: "X"}) -> Find the symbol ID -2. explain_change_impact({ids: ", "}) -> Risk-tiered blast radius -3. get_dependents({id: "", depth: 3}) -> Detailed dependent tree -4. analyze({kind: "ownership", path_prefix: "/"}) -> Who owns this area (review pinging) -5. verify_change({id: "", new_signature: "..."}) -> Check callers + interface implementors for signature-level breaks -6. contracts({action: "check"}) -> Cross-repo API breakage (HTTP/gRPC/GraphQL/topics) -7. analyze({kind: "would_create_cycle", from: "", to: ""}) -> Before adding a new dep -8. analyze({kind: "error_surface", path_prefix: "/"}) -> What error surface does this area produce — widening risk -9. get_test_targets({ids: ["", ""]}) -> Tests to re-run (includes cross-repo) -10. analyze({kind: "coverage_gaps", path_prefix: "/"}) -> Undertested code in the change area — extra-risky refactor zones -11. check_guards({ids: [""]}) -> Project guard rules from .gortex.yaml -12. flow_between({source_id, sink_id}) -> Ranked dataflow paths between two symbols — catches consumers reached through helpers that get_dependents misses -13. taint_paths({source_pattern, sink_pattern}) -> Pattern-driven dataflow sweep — every flow from a matching source to a matching sink -14. detect_changes({scope: "staged"}) -> Pre-commit scope check -15. diff_context({scope: "staged"}) -> Graph-enriched diff for review -` + "```" + ` - -## Understanding Output - -| Depth | Risk Level | Meaning | -| ----- | ---------------- | ------------------------ | -| d=1 | **WILL BREAK** | Direct callers/importers | -| d=2 | LIKELY AFFECTED | Indirect dependencies | -| d=3 | MAY NEED TESTING | Transitive effects | - -## Checklist - -- search_symbols to find exact symbol IDs -- explain_change_impact with all symbols you plan to change -- Review risk level (LOW/MEDIUM/HIGH/CRITICAL) -- Check by_depth: d=1 items WILL BREAK -- Note affected_processes and affected_communities -- analyze kind=ownership path_prefix=/ — who should review (pinging policy without CODEOWNERS) -- verify_change for every signature change (catches contract violations across repos) -- contracts action=check when changing HTTP routes, gRPC methods, topics, env contracts -- analyze kind=error_surface path_prefix=/ — confirm the change does not widen the error surface -- analyze kind=coverage_gaps path_prefix=/ — areas with weak coverage need extra scrutiny -- check_guards so team conventions from .gortex.yaml block bad changes early -- get_test_targets to see which test files need re-running -- Before commit: detect_changes to verify scope, diff_context for graph-enriched review -` - -const commandRefactor = `# Refactoring with Gortex - -## Workflow - -` + "```" + ` -1. search_symbols({query: "X"}) -> Find the symbol ID -2. explain_change_impact({ids: ""}) -> Map blast radius -3. analyze({kind: "ownership", path_prefix: "/"}) -> Who should review (pinging policy) -4. analyze({kind: "coverage_gaps", path_prefix: "/"}) -> Where is the refactor risky (poor coverage) -5. verify_change({id: "", new_signature: "..."}) -> Catch contract violations in callers + implementors -6. get_editing_context({path: ""}) -> See all symbols and relationships -7. find_usages({id: ""}) -> Every reference to change -8. get_edit_plan({ids: ["", ""]}) -> Dependency-ordered file list -9. batch_edit({edits: [...]}) -> Apply edits in order, re-indexing between steps -10. check_guards({ids: [...]}) -> Post-edit: team conventions from .gortex.yaml -11. get_test_targets({ids: [...]}) -> Tests to re-run (cross-repo aware) -12. detect_changes({scope: "all"}) -> Verify scope; diff_context for review -` + "```" + ` - -## Rename Symbol - -- search_symbols to find the symbol ID -- explain_change_impact to assess blast radius -- verify_change before signature-changing renames — fails fast on interface breaks -- rename_symbol({id: "", new_name: ""}) — generates edits for definition + all references -- Review the generated edits, apply via batch_edit or edit_symbol (no Read→Edit roundtrip) -- check_guards, then detect_changes to verify only expected files changed - -## Extract Module - -- get_editing_context on the source file — see all symbols -- get_dependents on symbols to extract — find external callers -- explain_change_impact on symbols being moved -- analyze({kind: "would_create_cycle", from: "", to: ""}) before wiring imports -- suggest_pattern + scaffold from a comparable existing module — generates code, wiring, test stubs -- Extract code, update imports (find_import_path for correct paths) -- get_edit_plan + batch_edit for dependency-ordered atomic application -- check_guards, detect_changes to verify affected scope - -## Split Function/Service - -- get_call_chain on the function — understand all callees -- Group callees by responsibility -- get_callers to map all call sites that need updating -- find_implementations when splitting along an interface -- explain_change_impact for full blast radius -- Create new functions/services (scaffold from a similar example) -- Update callers (find_usages for precise locations, batch_edit to apply in order) -- check_guards, detect_changes to verify affected scope - -## API Contract Changes - -- Before changing an HTTP route, gRPC method, topic, env, or OpenAPI contract: contracts({action: "check"}) to find cross-repo consumers -- verify_change on the provider signature -- Coordinate consumer-side edits in the same batch_edit when repos are tracked together -` - -const commandSafeEdit = `# Safe Edit with Gortex (speculative-execution path) - -Use this **before** you touch ` + "`edit_file`" + ` / ` + "`edit_symbol`" + ` / ` + "`batch_edit`" + ` / ` + "`Edit`" + ` / ` + "`Write`" + `. It runs the edit against the in-memory shadow graph first, reports broken callers + broken interface implementors + blast-radius rollup + suggested tests, and only promotes to disk once those signals are clean. - -## Workflow (do not skip steps) - -` + "```" + ` -1. smart_context({task: ""}) -> Working-set bundle -2. surface_memories({task, symbol_ids}) -> Cross-session invariants on the same symbols -3. get_editing_context({path: ""}) -> Pre-edit signature + caller map -4. preview_edit({edit: }) -> Single-shot speculative apply on the shadow graph - -> Inspect: touched / added / removed / renamed / broken_callers / broken_implementors / impact / suggested_tests -5. simulate_chain({steps: [{edit: }, {edit: }, ...], -> Multi-step ordered simulation - inherit_overlay: true, stop_on_error: true, - keep: false}) - -> Use when the change is more than one WorkspaceEdit (cross-file refactor, signature + every caller, etc.) -6. If broken_callers or broken_implementors is non-empty -> Fix the chain first, re-run preview_edit / simulate_chain -7. check_guards({ids: []}) -> Team conventions from .gortex.yaml -8. verify_change({id: , new_signature: "..."}) -> Final signature check before disk write -9. batch_edit({edits: [...]}) -> Apply the same edits on disk in dependency order -10. detect_changes({scope: "all"}) + diff_context({scope: "all"}) -> Confirm the on-disk diff matches the simulated one -11. get_test_targets({ids: []}) -> Tests to re-run (cross-repo aware) -` + "```" + ` - -## When to use ` + "`preview_edit`" + ` vs ` + "`simulate_chain`" + ` - -| Situation | Tool | Why | -| ------------------------------------------------------ | ---------------- | --- | -| One self-contained ` + "`WorkspaceEdit`" + ` (e.g. one rename) | preview_edit | Single round-trip, simplest. | -| Several dependent edits that must be applied in order | simulate_chain | First-failing-step semantics; ` + "`inherit_overlay`" + ` carries state forward. | -| You want the simulated state to become the live overlay so subsequent queries see it | simulate_chain with ` + "`keep: true`" + ` | Promotes the final shadow state into a real overlay session — graph queries see post-edit reality without writing disk. | -| Optional LSP diagnostics on the simulated content | Either, with ` + "`with_diagnostics: true`" + ` | Drives ` + "`didChange(simulated) → wait → didChange(original)`" + ` round-trip; concurrent sessions never observe simulated state as authoritative. | - -## Reading the speculative report - -- **broken_callers** — callers whose call sites won't type-check against the new signature. Fix or update every one before disk. -- **broken_implementors** — interface implementors that no longer satisfy the interface contract. Either widen the contract, update implementors, or revert. -- **impact** — ` + "`analysis.AnalyzeImpact`" + ` blast-radius rollup. The same risk tiers ` + "`/gortex-impact`" + ` reports. -- **suggested_tests** — what to feed to ` + "`get_test_targets`" + ` after the apply. -- **added / removed / renamed** — non-trivial-signature unambiguous-candidate heuristic; bare ` + "`func ()`" + ` voids reject as ambiguous, so you may see fewer "renamed" entries than you expect — that is correct behaviour. - -## Checklist - -- ` + "`smart_context`" + ` to orient a non-trivial task (skip for a single known symbol — use ` + "`search_symbols`" + ` / ` + "`get_symbol_source`" + `) -- ` + "`surface_memories`" + ` on the same working set — pick up cross-session decisions / gotchas -- ` + "`preview_edit`" + ` (single edit) or ` + "`simulate_chain`" + ` (ordered chain) **before** any on-disk write -- Treat ` + "`broken_callers`" + ` / ` + "`broken_implementors`" + ` as blockers, not warnings -- ` + "`check_guards`" + ` + ` + "`verify_change`" + ` after the simulated diff is clean, before ` + "`batch_edit`" + ` -- ` + "`batch_edit`" + ` (dependency-ordered, atomic re-index between steps) over manual sequencing -- ` + "`detect_changes`" + ` + ` + "`diff_context`" + ` to confirm the on-disk diff matches the simulation -- ` + "`get_test_targets`" + ` to run the right tests, cross-repo aware -- If the edit is durable knowledge ("X must hold the lock", "this package never uses gob"), ` + "`store_memory`" + ` so the next agent inherits the lesson -` - -const commandFixAll = `# Fix LSP Diagnostics with Gortex (LSP code-actions path) - -Use this when the user wants errors / warnings cleared — for one symbol, one file, or the whole project. Bridges Gortex to the 18-language LSP coverage (gopls / tsserver / pyright / rust-analyzer / clangd / jdtls / kotlin-language-server / omnisharp / ruby-lsp / phpactor / lua-language-server / sourcekit-lsp / haskell-language-server / elixir-ls / ocamllsp / zls / terraform-ls / yaml-language-server / json-language-server / bash-language-server). No manual ` + "`Edit`" + ` of error messages. - -## Workflow (do not skip steps) - -` + "```" + ` -1. subscribe_diagnostics({min_severity: 1, path_prefix: "/"}) -> Push notifications, replays initial state once -2. get_diagnostics({path: "", wait: true, timeout_ms: 2000}) -> Poll form when you need a synchronous snapshot -3. get_code_actions({path: "", range: {...}}) -> Menu of fixes / refactors / source actions -4. For each chosen action: - preview_edit({edit: }) -> Speculative apply on the shadow graph - apply_code_action({path, action_id}) -> Atomic on-disk apply (UTF-16 column math) -5. fix_all_in_file({path: ""}) -> One-shot source.fixAll over an entire file -6. get_diagnostics({path: ""}) -> Confirm the file is now clean -7. check_guards({ids: []}) + get_test_targets({ids}) -> Post-fix guardrails -8. unsubscribe_diagnostics -> Only when narrowing scope; auto-fires on disconnect -` + "```" + ` - -## Diagnostic-fix patterns - -| Situation | Tool ordering | Notes | -| ------------------------------------------------------ | ------------- | ----- | -| One specific error | ` + "`get_code_actions`" + ` at the diagnostic range -> pick one -> ` + "`apply_code_action`" + ` | Smallest possible edit. | -| Every error in a file | ` + "`fix_all_in_file`" + ` | Bundles every server-suggested fix in a single round-trip. | -| Refactor offered as a code action (` + "`refactor.*`" + `) | ` + "`get_code_actions`" + ` -> pick by ` + "`kind`" + ` (e.g. ` + "`refactor.extract`" + `) -> ` + "`preview_edit`" + ` first | See ` + "`/gortex-extract-function`" + `. | -| Source action (` + "`source.organizeImports`" + `, ` + "`source.fixAll`" + `) | ` + "`get_code_actions`" + ` with the right ` + "`only`" + ` kind, or ` + "`fix_all_in_file`" + ` | Whole-file scope. | -| Watching diagnostics during a long edit session | ` + "`subscribe_diagnostics`" + ` with ` + "`min_severity`" + ` + ` + "`path_prefix`" + ` filters | SHA-suppressed delta payloads; only changed files reach you. | - -## Reading the diagnostic stream - -- ` + "`initial_replay: true`" + ` on the first push — that's the synchronous snapshot of the current LSP state, not a new event. -- Every subsequent push carries only files whose ` + "`publishDiagnostics`" + ` SHA changed. -- ` + "`min_severity`" + ` 1 = error, 2 = warning, 3 = info, 4 = hint. Default to 1 unless you specifically need warnings. -- ` + "`path_prefix`" + ` is your scope filter — pin it to the area you're working on so the rest of the project doesn't drown the stream. - -## When LSP cannot fix it - -If ` + "`get_code_actions`" + ` returns an empty menu, the server doesn't know how to fix this diagnostic. Fall back to: -1. ` + "`search_symbols`" + ` for the symbol the diagnostic references -2. ` + "`get_editing_context`" + ` on the file -3. ` + "`/gortex-safe-edit`" + ` to author the edit by hand, with ` + "`preview_edit`" + ` to verify - -## Checklist - -- ` + "`subscribe_diagnostics`" + ` once per session, with ` + "`min_severity`" + ` + ` + "`path_prefix`" + ` scoped to the area you're touching -- ` + "`get_code_actions`" + ` is the menu — never invent fixes -- ` + "`preview_edit`" + ` (or ` + "`apply_code_action`" + ` directly when the action is small and well-known) before any disk write -- ` + "`fix_all_in_file`" + ` for whole-file source.fixAll — one round-trip beats N targeted fixes -- Re-run ` + "`get_diagnostics`" + ` after the fix; assume nothing -- ` + "`check_guards`" + ` and ` + "`get_test_targets`" + ` after the diagnostics are clean — fixing the LSP error is not the same as fixing the test -` - -const commandExtractFunction = `# Extract Function with Gortex (LSP refactor path) - -Use this when the user wants to extract code into a new function / method / variable / constant via the **language server's refactor actions**, not by hand-editing braces. Maps onto LSP ` + "`refactor.extract.*`" + ` code actions; works wherever the underlying server supports them (gopls / tsserver / pyright / rust-analyzer / jdtls / kotlin-language-server / omnisharp / and others). - -## Workflow (do not skip steps) - -` + "```" + ` -1. get_editing_context({path: ""}) -> See enclosing symbol, callers, callees -2. (optional) find_usages({id: ""}) -> Who calls the function you are about to split -3. get_code_actions({path: "", range: {start, end}, -> Menu filtered to refactor.extract.* actions - only: ["refactor.extract.function", - "refactor.extract.method", - "refactor.extract.variable", - "refactor.extract.constant"]}) -4. preview_edit({edit: }) -> Speculative apply - -> Inspect: touched / added / renamed / broken_callers / broken_implementors / impact -5. If the extraction crosses files (e.g. moving the helper to a sibling file): - simulate_chain({steps: [...]}) -> Ordered chain with broken-caller carry-over -6. apply_code_action({path: "", action_id: ""}) -> Atomic temp+rename, UTF-16 column math -7. check_guards({ids: []}) + verify_change -> Convention + signature gate -8. get_test_targets({ids: [, ]}) -> Run tests for both the caller and the extract -9. detect_changes + diff_context -> Final scope check -` + "```" + ` - -## Choosing the right extract action - -| User intent | Code-action kind | Notes | -| ------------------------------------------ | --------------------------------- | ----- | -| Pull a block of statements into a helper | ` + "`refactor.extract.function`" + ` | Most servers infer params + return type from the selection. | -| Move statements into a method on a type | ` + "`refactor.extract.method`" + ` | Receiver / this binding chosen by the server. | -| Extract a sub-expression to a local | ` + "`refactor.extract.variable`" + ` | Best for naming repeated sub-expressions. | -| Extract a literal to a package-level const | ` + "`refactor.extract.constant`" + ` | gopls and tsserver expose this; rust-analyzer uses ` + "`refactor.extract.module`" + ` for similar moves. | - -If you don't know which kinds the server offers, call ` + "`get_code_actions`" + ` **without** ` + "`only`" + ` and inspect the menu first. - -## Range selection - -LSP positions are line + UTF-16 character offsets. The ` + "`apply_code_action`" + ` mapper handles this correctly, but when you compose the range yourself: -1. Get the file body via ` + "`get_symbol_source`" + ` (compress_bodies:false) or ` + "`get_file_summary`" + ` -2. Use the symbol's line numbers as anchors; expand to the precise statement boundaries -3. Pass ` + "`range: {start: {line, character}, end: {line, character}}`" + ` to ` + "`get_code_actions`" + ` - -## When the server has no extract action - -Some servers (yaml-language-server, json-language-server, bash-language-server) don't ship refactor actions. Fall back to ` + "`/gortex-safe-edit`" + ` and author the extract by hand — but still ` + "`preview_edit`" + ` first. - -## Checklist - -- ` + "`get_editing_context`" + ` to understand the enclosing symbol before selecting a range -- ` + "`get_code_actions`" + ` with ` + "`only: refactor.extract.*`" + ` — pick from the menu, don't invent -- ` + "`preview_edit`" + ` before ` + "`apply_code_action`" + ` (broken callers / blast radius) -- ` + "`simulate_chain`" + ` if the extract moves the helper to a new file with follow-on rename of call sites -- ` + "`check_guards`" + `, ` + "`verify_change`" + `, ` + "`get_test_targets`" + ` after the apply -- ` + "`get_symbol`" + ` on the new symbol to confirm Gortex indexed it under the expected ID -` - -const commandRename = `# Rename Symbol with Gortex (cross-file coordinated rename) - -Use this when the user wants to rename a function / method / type / variable / package and have every reference (definition, callers, tests, cross-repo consumers, doc-comments where applicable) updated atomically. Picks the right tool for the job — graph-coordinated ` + "`rename_symbol`" + ` for Gortex-known symbols, LSP ` + "`textDocument/rename`" + ` (surfaced as a ` + "`refactor.rename`" + ` code action where supported) for server-driven cases. - -## Workflow (do not skip steps) - -` + "```" + ` -1. search_symbols({query: ""}) -> Resolve the symbol ID(s) -2. winnow_symbols({...}) -> Disambiguate if multiple matches survive -3. explain_change_impact({ids: ""}) -> Blast radius (callers, implementors, processes) -4. find_usages({id: ""}) -> Every reference (BM25-free; zero false positives) -5. verify_change({id: "", new_signature: ""}) -> Catches interface breaks early -6. rename_symbol({id: "", new_name: ""}) -> Generates dependency-ordered WorkspaceEdit (Gortex path) - OR get_code_actions({path, range, only: ["refactor.rename"]}) -> LSP-driven path (when you want server-side rename semantics) -7. preview_edit({edit: }) -> Speculative apply; check broken_callers / broken_implementors -8. batch_edit({edits: [...]}) -> Atomic on-disk apply, re-index between steps - OR apply_code_action({...}) -> If you went the LSP route -9. check_guards({ids: []}) -> Naming conventions, banned terms from .gortex.yaml -10. get_test_targets({ids: []}) -> Tests to re-run; cross-repo aware -11. detect_changes + diff_context -> Confirm no stray reference survived -` + "```" + ` - -## Choosing the right rename tool - -| Situation | Tool | Why | -| ---------------------------------------------------------- | ----------------------------- | --- | -| Symbol is in the graph and rename is mechanical | ` + "`rename_symbol`" + ` | Generates the WorkspaceEdit from edges; deterministic, fast, no LSP round-trip. | -| Symbol semantics depend on the server (TS shadowed locals, Java generics, Rust trait resolution) | ` + "`get_code_actions`" + ` + ` + "`apply_code_action`" + ` with ` + "`refactor.rename`" + ` | Server resolves identifier scoping the parser can't fully replicate. | -| Cross-repo rename (consumer repos tracked together) | ` + "`rename_symbol`" + ` + ` + "`contracts({action: check})`" + ` | Graph carries cross-repo edges; ` + "`contracts`" + ` flags HTTP / gRPC / topic-side breakage. | -| Rename of a published API surface | ` + "`rename_symbol`" + ` **plus** ` + "`contracts({action: check})`" + ` and a deprecation shim | Pair with ` + "`/gortex-cross-repo-usage`" + ` to find consumer repos. | - -## Cross-repo renames - -` + "`rename_symbol`" + ` covers every repo the graph knows about — track consumer repos first (` + "`track_repository`" + `) and switch ` + "`set_active_project`" + ` to the multi-repo scope, then run the rename. ` + "`/gortex-cross-repo-usage`" + ` is the partition view that confirms every consumer repo was touched. - -## Renaming through interfaces - -If the renamed symbol is part of an interface contract: -1. ` + "`find_implementations`" + ` on the interface — every implementor must be renamed in the same chain -2. ` + "`verify_change`" + ` against the interface's signature with the new name -3. ` + "`simulate_chain`" + ` with one step per implementor file so ` + "`broken_implementors`" + ` carries forward correctly -4. Apply via ` + "`batch_edit`" + ` (or ` + "`apply_code_action`" + ` if you went the LSP route per-implementor) - -## Checklist +const nativeMCPRules = ` +## Required behavior -- ` + "`search_symbols`" + ` + ` + "`winnow_symbols`" + ` until exactly one ID resolves to the symbol you mean -- ` + "`explain_change_impact`" + ` + ` + "`find_usages`" + ` before authoring any edit -- ` + "`verify_change`" + ` on the new signature — catches interface breaks before anything is written -- Pick ` + "`rename_symbol`" + ` (graph path) or LSP ` + "`refactor.rename`" + ` (server path); never hand-author cross-file renames -- ` + "`preview_edit`" + ` / ` + "`simulate_chain`" + ` on the generated edits before disk -- ` + "`batch_edit`" + ` is dependency-ordered + re-indexes between steps — atomic in the sense that matters for the graph -- ` + "`check_guards`" + `, ` + "`get_test_targets`" + `, ` + "`diff_context`" + ` after the apply +- Native Gortex MCP is mandatory for this MCP-configured workflow. If its callable tools are missing, report a Gortex MCP integration failure and stop; do not start a daemon or switch to a CLI/shell fallback. +- Do not substitute shell reads, file search, or Git plumbing. +- Start task-shaped work with ` + "`explore`" + `. For one already-known symbol, start with ` + "`search`" + `. +- Use ` + "`change`" + ` before a mutation and again after it. Write only through ` + "`edit`" + ` or ` + "`refactor`" + `. +- Call ` + "`capabilities({domain: \"\", operation: \"\", detail: \"schema\"})`" + ` only when an operation's exact arguments are unclear. +- Report graph-backed paths and symbol IDs. Never invent a result when an operation returns no match. ` -const commandCrossRepoUsage = `# Cross-Repo Usage with Gortex - -Use this when the user needs to see who uses a symbol **across every consumer repo**, not just the one they happen to be in. Wraps ` + "`find_usages`" + ` + the cross-repo edge layer (` + "`cross_repo_calls`" + ` / ` + "`cross_repo_implements`" + ` / ` + "`cross_repo_extends`" + `) so the answer is partitioned by repo and includes contract-level consumers (HTTP / gRPC / topics) that wouldn't show up as a direct call edge. - -## Workflow (do not skip steps) - -` + "```" + ` -1. get_active_project -> See current scope (single repo or multi-repo project) -2. list_repos -> See which repos are tracked -3. For each consumer repo not yet tracked: - track_repository({path: ""}) -> Index immediately, persist to config -4. set_active_project({name: ""}) -> Switch scope so subsequent queries span repos -5. search_symbols({query: "X"}) -> Resolve the symbol ID in the providing repo -6. find_usages({id: ""}) -> All references; group by repo prefix -7. analyze({kind: "cross_repo", -> Repo-boundary-crossing edges with relation - base_kind: "calls|implements|extends", - repo: ""}) -8. contracts({action: "check"}) -> Contract-level consumers (HTTP routes, gRPC methods, topics, env, OpenAPI) -9. get_test_targets({ids: []}) -> Cross-repo tests that exercise this symbol -10. export_context({format: "markdown"}) -> Per-repo report for PR / Slack / docs -` + "```" + ` - -## Partitioning ` + "`find_usages`" + ` by repo - -The graph stores repo as a prefix on each node ID. Group results by the leading path segment so the user sees one section per consumer: - -| Repo | References | -| ------------------------- | ---------- | -| my-org/api-gateway | 7 sites | -| my-org/billing | 3 sites | -| my-org/notifications | 1 site | - -` + "`analyze kind=cross_repo`" + ` complements this — it reports the typed edges (calls / implements / extends) that physically cross the boundary, which is the count that matters for "is this safe to change without coordinating with consumer teams." - -## Contract-level consumers - -For published API surfaces, ` + "`find_usages`" + ` won't show consumers that go through the wire. ` + "`contracts({action: check})`" + ` catches: -- HTTP route consumers (client-side ` + "`fetch`" + ` / ` + "`http.Get`" + ` / generated SDK methods) -- gRPC method consumers (generated client stubs across repos) -- Pub/sub subscribers (NATS / Kafka / RabbitMQ / Redis / EventEmitter / Socket.IO) -- Env-var consumers (one process writes, another reads) -- OpenAPI / GraphQL schema consumers - -## Onboarding a consumer repo - -If a known consumer is missing from the tracked-repo list: -1. ` + "`track_repository`" + ` with its path — indexes immediately, persists to config -2. Re-run ` + "`find_usages`" + ` and ` + "`analyze kind=cross_repo`" + ` — the new edges materialise -3. ` + "`untrack_repository`" + ` only when you're done; leaving it tracked keeps subsequent cross-repo queries cheap - -## Checklist - -- ` + "`get_active_project`" + ` first — you may already be in multi-repo scope -- ` + "`track_repository`" + ` every consumer repo you care about; ` + "`find_usages`" + ` only sees what's indexed -- Partition the ` + "`find_usages`" + ` rows by repo prefix; the per-repo breakdown is the deliverable -- ` + "`analyze kind=cross_repo`" + ` for the typed-edge view (calls / implements / extends) -- ` + "`contracts({action: check})`" + ` for wire-level consumers that ` + "`find_usages`" + ` cannot see -- ` + "`get_test_targets`" + ` returns cross-repo tests too — run them, not just the local ones -- Hand the per-repo report to the user via ` + "`export_context`" + ` for PR descriptions / Slack -` - -const commandDataflowTrace = `# Dataflow Trace with Gortex (CPG-lite) - -Use this when the user asks where a value flows — through assignments, function args, returns, channels, event buses, or pub/sub topics. Built on the CPG-lite edge layer (` + "`value_flow`" + ` ∪ ` + "`arg_of`" + ` ∪ ` + "`returns_to`" + `) plus the pub/sub + channel + emit layers, so flows survive crossing function and process boundaries that a plain caller-graph walk would lose. - -## Workflow (do not skip steps) - -` + "```" + ` -1. smart_context({task: ""}) -> Working-set bundle -2. search_symbols({query: ""}) -> Resolve the producing symbol ID -3. search_symbols({query: ""}) -> Resolve the consuming symbol ID -4. flow_between({source_id: "", sink_id: "", -> Ranked paths over value_flow ∪ arg_of ∪ returns_to - max_depth: 8, max_paths: 10}) -5. taint_paths({source_pattern: "", -> Every flow from any matching source to any matching sink - sink_pattern: "", - max_depth: 8}) -6. analyze({kind: "channel_ops"}) -> Channel producer/consumer mismatch (Go) -7. analyze({kind: "pubsub", -> Pub/sub topics with publishers + subscribers - transport: "nats|kafka|rabbitmq|redis|eventemitter|socketio", - name: "", - role: "publish|subscribe"}) -8. analyze({kind: "event_emitters", level: "error"}) -> Log/metric/span emit sites -9. get_call_chain({id: ""}) + get_callers({id: ""}) -> Caller-graph cross-check for any path flow_between missed -10. export_context({format: "markdown"}) -> Hand the trace to a PR / Slack / doc -` + "```" + ` - -## Pattern syntax for ` + "`taint_paths`" + ` - -Each pattern is one or more clauses combined with AND: - -| Clause | Meaning | Example | -| ------------------ | ------------------------------------------------ | ----------------------------- | -| ` + "``" + ` | Substring match on the symbol name | ` + "`Decode`" + ` | -| ` + "`exact:`" + ` | Exact name match | ` + "`exact:HandleRequest`" + ` | -| ` + "`path:`" + ` | Path prefix on the file the symbol lives in | ` + "`path:internal/http/`" + ` | -| ` + "`kind:`" + ` | Restrict to ` + "`function`" + ` / ` + "`method`" + ` / ` + "`field`" + ` / etc. | ` + "`kind:method`" + ` | - -Functions auto-expand to their params on the sink side, so ` + "`taint_paths`" + ` with a function-shaped sink pattern reports flows into any of its parameters. - -## Crossing process / transport boundaries - -` + "`flow_between`" + ` walks intra-process edges. To follow a value across a wire: - -| Boundary | Tool | -| --------------------------------- | ---------------------------------------------------- | -| Channel (Go) | ` + "`analyze kind=channel_ops`" + ` — find the matching ` + "`recv`" + ` site, then ` + "`flow_between`" + ` from there | -| Pub/sub topic | ` + "`analyze kind=pubsub name=`" + ` — get every subscriber; ` + "`flow_between`" + ` from each | -| HTTP / gRPC / GraphQL contract | ` + "`contracts({action: check})`" + ` — match providers to consumers; the contract ID is your bridge node | -| Env var (one process writes, another reads) | ` + "`analyze kind=config_readers name=`" + ` -> consumers; ` + "`find_usages`" + ` on ` + "`cfg::env::`" + ` | -| Cross-repo call | ` + "`analyze kind=cross_repo base_kind=calls`" + ` — typed boundary-crossing edges | - -## Reading a ranked path - -Each row from ` + "`flow_between`" + ` is a sequence of edges with the kind annotated (` + "`value_flow`" + ` / ` + "`arg_of`" + ` / ` + "`returns_to`" + `). The rank reflects path length + edge confidence. Treat the top-3 paths as the load-bearing ones; the long tail tends to be incidental cross-references. - -## Checklist - -- ` + "`smart_context`" + ` first — pick up the working set before asking flow_between for a path -- Resolve **both** source and sink to symbol IDs before calling ` + "`flow_between`" + ` (path search needs anchors) -- Use ` + "`taint_paths`" + ` when "every flow from a kind of source to a kind of sink" matters more than one specific pair -- Cross process boundaries with the right analyzer (` + "`channel_ops`" + ` / ` + "`pubsub`" + ` / ` + "`config_readers`" + ` / ` + "`contracts`" + `) — ` + "`flow_between`" + ` alone won't follow a value across the wire -- Cross-check with ` + "`get_call_chain`" + ` / ` + "`get_callers`" + ` when ` + "`flow_between`" + ` returns zero paths for a flow you're sure exists — the producer or consumer may not yet be in the dataflow layer -- ` + "`export_context`" + ` to hand the trace to a PR / Slack / doc without losing the structure -` - -const commandAddTest = `# Add Tests with Gortex (coverage-led) - -Use this when the user wants tests added for under-covered code, untested symbols, or a specific regression. Walks the coverage analyzers first so the new test goes where it matters; uses ` + "`suggest_pattern`" + ` + ` + "`scaffold`" + ` to author the new test in the style of an existing one rather than from scratch. - -## Workflow (do not skip steps) - -` + "```" + ` -1. gortex enrich coverage (CLI) -> Stamp meta.coverage_pct on executable symbols from cover.out -2. analyze({kind: "coverage_summary", path_prefix: "/"}) -> Per-directory rollup (avg / covered / partial / uncovered) -3. analyze({kind: "coverage_gaps", -> Symbols inside [min_pct, max_pct) — the candidate list - path_prefix: "/", - min_pct: 0, max_pct: 50}) -4. get_untested_symbols({path_prefix: "/"}) -> Symbols with no covering test -5. For the chosen target symbol: - get_editing_context({path: ""}) -> Signature, callers, callees - get_callers({id: ""}) -> How the symbol is invoked in real code (drives test inputs) -6. suggest_pattern({example_id: ""}) -> Extracts the test-authoring pattern (source + registration + tests) -7. scaffold({kind: "test", from: "", target: ""}) -> Generate test stub + wiring -8. preview_edit({edit: }) -> apply / batch_edit -> Speculative apply, then on-disk -9. get_test_targets({ids: []}) -> Confirms the new test file is discovered + maps to a run command -10. Run the test command; if green, re-check ` + "`analyze kind=coverage_gaps`" + ` to confirm the gap closed -11. check_guards + detect_changes + diff_context -> Standard post-edit gates -` + "```" + ` - -## Picking the right target - -| Source of priority | Tool | Use when | -| ----------------------------------- | ----------------------------------------------------------- | -------- | -| User named the symbol | ` + "`search_symbols`" + ` -> ` + "`get_untested_symbols`" + ` (confirm gap) | Direct request. | -| Recent regression | ` + "`detect_changes`" + ` -> ` + "`analyze kind=coverage_gaps`" + ` on the change set | "Add a test for what just broke." | -| Hot path with weak coverage | ` + "`analyze kind=hotspots`" + ` -> filter by ` + "`coverage_pct`" + ` < 50 | High-impact gaps. | -| Whole directory | ` + "`analyze kind=coverage_summary`" + ` -> walk lowest-coverage subdirs | "Cover this package." | -| Mutation hotspot without tests | ` + "`analyze kind=field_writers`" + ` + ` + "`get_untested_symbols`" + ` | Race / state-mutation risk. | - -## Authoring the test - -` + "`suggest_pattern`" + ` returns the **shape** of a comparable test in the project (table-driven, parallel, golden-file, etc.) so the new test feels native. ` + "`scaffold`" + ` then materialises the file with the right imports, package, and registration. Hand-edit only the bits ` + "`scaffold`" + ` couldn't infer (inputs, expected values, edge cases). - -When the target is interface-implementing or LSP-driven, also run ` + "`find_implementations`" + ` and ensure each implementor has at least one test in the new file (table-driven test with one row per implementor is idiomatic in this repo). - -## Closing the loop - -After the new test runs green: -1. ` + "`gortex enrich coverage`" + ` (CLI) to refresh ` + "`meta.coverage_pct`" + ` -2. Re-run ` + "`analyze kind=coverage_gaps`" + ` over the same scope -3. Confirm the symbol is no longer in the gap list; if it still is, the test exercises a different branch — add another row - -## Checklist - -- Run ` + "`gortex enrich coverage`" + ` (CLI) at least once per session so the coverage analyzers have data to work with -- ` + "`analyze kind=coverage_summary`" + ` for the per-directory rollup; ` + "`analyze kind=coverage_gaps`" + ` for the per-symbol list -- ` + "`get_untested_symbols`" + ` is the truth for "zero covering tests" — coverage_pct=0 ≠ "no test"; some tests register but don't execute -- ` + "`suggest_pattern`" + ` + ` + "`scaffold`" + ` for the test body — author in the project's style, not from scratch -- ` + "`preview_edit`" + ` even for new files; the speculative report flags broken_callers if you accidentally add a name that collides -- ` + "`get_test_targets`" + ` after the apply — the test must be discoverable + mapped to a run command -- Re-run ` + "`analyze kind=coverage_gaps`" + ` once the test is green; confirm the gap actually closed (a passing test that doesn't exercise the gap is theatre) -- If the test encodes a project-wide invariant ("Bar must hold the lock"), ` + "`store_memory kind:invariant`" + ` so the next agent inherits the constraint -` - -const commandIncidentInvestigation = `# Incident Investigation with Gortex - -Use this when an alert fired, a deploy regressed, or production is misbehaving and the user needs to walk back from the symptom (log line, error message, broken endpoint, failing test in CI) to the root cause. Wraps the debug + impact + recent-changes paths into one ordered drill so the investigator never loses the thread. - -## Workflow (do not skip steps) - -` + "```" + ` -1. graph_stats -> Confirm index + orient -2. surface_memories({task: ""}) -> Prior incidents / invariants on the same area -3. search_symbols({query: ""}) -> Resolve the suspect symbol -4. get_recent_changes({since_ts: }) -> Files/symbols changed in the suspect window -5. get_symbol_history -> Symbols churning this session (regression hotspot) -6. analyze({kind: "error_surface", path_prefix: "/"}) -> Throw sites that match the symptom -7. analyze({kind: "event_emitters", level: "error", -> Every log/metric site that could have produced the alert - path_prefix: "/", name: ""}) -8. get_callers({id: ""}) -> Who calls the suspect — narrow blast -9. get_call_chain({id: ""}) -> What the suspect calls — downstream contributors -10. flow_between({source_id: "", sink_id: ""}) -> Trace the bad value's path (when applicable) -11. taint_paths({source_pattern: "name:", -> Every flow from a kind of source to the suspect - sink_pattern: "name:"}) -12. analyze({kind: "field_writers", id: ""}) -> Race / mutation suspects -13. analyze({kind: "channel_ops"}) -> Channel deadlock / orphan recv (Go) -14. analyze({kind: "goroutine_spawns"}) -> Unowned background work -15. explain_change_impact({ids: ""}) -> Blast radius of the proposed fix -16. get_test_targets({ids: [""]}) -> Tests to add / re-run as regression coverage -17. store_memory({kind: "incident", title: "", -> Persist root cause + fix + affected symbols - body: "...", symbol_ids: [""], importance: 5}) -` + "```" + ` - -## Triage by symptom - -| Symptom | First-stop tools | -| -------------------------------------------------------- | ------------------------------------------------------------ | -| New 5xx / panic / crash log | ` + "`search_symbols`" + ` on the error → ` + "`analyze kind=error_surface`" + ` → ` + "`get_callers`" + ` on throw sites | -| Latency spike on one endpoint | ` + "`analyze kind=routes path=`" + ` → ` + "`get_call_chain`" + ` on the handler → ` + "`analyze kind=external_calls`" + ` for hot stdlib / module hops | -| Background worker silently broken | ` + "`analyze kind=goroutine_spawns`" + ` / ` + "`channel_ops`" + ` → ` + "`get_callers`" + ` on the spawned target | -| Pub/sub event missing | ` + "`analyze kind=pubsub name=`" + ` → publisher + subscriber sides; ` + "`taint_paths`" + ` from publisher to subscriber handler | -| Config-driven misbehavior after a deploy | ` + "`analyze kind=config_readers name=`" + ` → callers; ` + "`analyze kind=stale_flags`" + ` if it's a feature flag | -| Data corruption / wrong value at a sink | ` + "`flow_between`" + ` from suspected source to sink; ` + "`taint_paths`" + ` when multiple sources are plausible | -| Cross-service / contract drift | ` + "`contracts({action: check})`" + ` → orphan providers/consumers; pair with ` + "`/gortex-cross-repo-usage`" + ` | -| Test regression after a refactor | ` + "`detect_changes`" + ` over the merge → ` + "`diff_context`" + ` for graph-enriched view → ` + "`get_test_targets`" + ` for the missing test signal | - -## Walking the timeline - -When the symptom appears tied to a window (between two deploys, between two commits, since N hours ago): - -1. ` + "`analyze kind=blame`" + ` to stamp ` + "`meta.last_authored`" + ` on every blame-eligible symbol (one-time per session; cheap thereafter) -2. ` + "`get_recent_changes since_ts=`" + ` to enumerate files / symbols touched -3. ` + "`detect_changes scope=all`" + ` to project the diff onto blast radius -4. Pair each touched symbol with ` + "`explain_change_impact`" + ` — anything in the d=1/d=2 buckets that overlaps the symptom area is your root-cause candidate -5. ` + "`get_symbol_history`" + ` flags symbols edited 3+ times *in this session*; combine with blame for "edited 3+ times in the suspect window" - -## Closing the loop (mandatory) - -After root-cause is found and the fix is shipped: -- ` + "`store_memory({kind: \"incident\", ...})`" + ` so the next on-call sees this pattern surfaced via ` + "`surface_memories`" + ` -- If the incident exposed an invariant the code lacked guard for (lock missing, contract unenforced), also ` + "`store_memory({kind: \"invariant\", importance: 5, pinned: true})`" + ` -- ` + "`save_note({tags: \"incident\", body: ...})`" + ` for the session-local timeline before context compacts - -## Checklist - -- ` + "`graph_stats`" + ` + ` + "`surface_memories`" + ` before any search — prior incident memories are the highest-value signal -- ` + "`search_symbols`" + ` on the actual error string / symbol from the alert — do not paraphrase -- ` + "`get_recent_changes`" + ` + ` + "`get_symbol_history`" + ` to scope the window -- ` + "`analyze kind=error_surface`" + ` + ` + "`event_emitters`" + ` to find the production sites of the symptom -- ` + "`get_callers`" + ` / ` + "`get_call_chain`" + ` / ` + "`flow_between`" + ` / ` + "`taint_paths`" + ` to walk from symptom toward root cause -- Concurrency-shape symptoms → ` + "`channel_ops`" + ` / ` + "`goroutine_spawns`" + ` / ` + "`field_writers`" + ` / ` + "`race_writes`" + ` / ` + "`unclosed_channels`" + ` -- ` + "`explain_change_impact`" + ` on the candidate fix; ` + "`get_test_targets`" + ` for regression coverage -- ` + "`store_memory({kind: \"incident\"})`" + ` is **mandatory** before declaring the investigation closed -` - -const commandEpisodeReplay = `# Episode Replay with Gortex (timeline reconstruction) - -Use this when the user wants to reconstruct what happened in a specific window — for a postmortem, a "what shipped between v1.2 and v1.3", a "what did Alice touch last week", or "walk me through what this PR actually changed". Pairs git history with the graph so the replay shows blast radius alongside the diff, not just the file list. - -## Workflow (do not skip steps) - -` + "```" + ` -1. graph_stats -> Orient -2. gortex enrich blame all (CLI; once per session) -> Stamp meta.last_authored / meta.last_commit_at / meta.added_in -3. analyze({kind: "releases"}) -> Tag boundaries → maps a tag to the symbols added in it -4. analyze({kind: "blame", path_prefix: "/"}) -> Per-symbol last-author rollup -5. get_recent_changes({since_ts: }) -> Symbols touched in the window -6. analyze({kind: "ownership", path_prefix: "/"}) -> Per-author symbol/file counts in the window -7. detect_changes({scope: ""}) -> The change-set's graph projection -8. diff_context({scope: ""}) -> Graph-enriched diff: callers, callees, communities, per-file risk -9. For each material symbol in the window: - get_symbol_history({id: ""}) -> Per-session edit count (regression hotspot) - explain_change_impact({ids: ""}) -> Blast radius of that one change -10. query_notes({since: }) -> Session decisions / bug notes recorded in the window -11. query_memories({tag: "decision", since: }) -> Cross-session decisions in the window -12. export_context({format: "markdown", -> Hand the replay packet to PR / Slack / wiki - sections: ["changes", "impact", "decisions"]}) -` + "```" + ` - -## Replay shapes - -| Goal | Driver query | -| ----------------------------------------------------- | ------------------------------------------------------------------------ | -| "What shipped in v1.3?" | ` + "`analyze kind=releases`" + ` → symbols where ` + "`meta.added_in == \"v1.3\"`" + `; then ` + "`diff_context`" + ` between v1.2..v1.3 tags | -| "What did Alice change last week?" | ` + "`analyze kind=ownership`" + ` filtered to Alice → cross-ref with ` + "`get_recent_changes since_ts=now-7d`" + ` | -| "What did this incident touch?" | Start with ` + "`/gortex-incident-investigation`" + ` → use its root-cause symbol set as the seed; replay walks ` + "`explain_change_impact`" + ` on each | -| "Postmortem narrative for a one-day window" | Full chain above + ` + "`query_notes`" + ` + ` + "`query_memories`" + ` so commentary rides next to code in the markdown | -| "What did this PR actually change beyond the diff?" | ` + "`detect_changes scope=staged`" + ` + ` + "`diff_context`" + ` + ` + "`explain_change_impact`" + ` per symbol → second-order blast that the literal diff hides | - -## Reading the replay output - -- ` + "`detect_changes`" + ` is the change-set's *graph* projection — every node a touched file owns, plus every directly-affected dependent. It is broader than ` + "`git diff --stat`" + ` because it captures symbols whose *meaning* changed, not just files whose *bytes* changed. -- ` + "`diff_context`" + ` adds the callers / callees / community / processes / per-file risk overlay. This is the artifact you embed in the PR description or postmortem. -- Per-symbol ` + "`explain_change_impact`" + ` surfaces the *second-order* blast (what depends on the things that changed). The replay is incomplete without this for any change touching a fan-in-heavy symbol. - -## Crossing the merge boundary - -For replays that span a merge or rebase, prefer ` + "`detect_changes scope=since-tag`" + ` over a literal commit-range diff. The graph projection survives rebases that the literal diff doesn't. - -When the replay is across a release boundary, ` + "`analyze kind=releases`" + ` is the bridge: it stamps ` + "`meta.added_in`" + ` from git tags onto file nodes, so "symbols introduced in v1.3" is one filter away. - -## Annotating the replay with prior context - -` + "`query_notes`" + ` and ` + "`query_memories`" + ` are how prior session decisions and cross-session invariants enter the timeline: - -- ` + "`query_notes({since: })`" + ` — every session note authored in the window; tag-filterable (` + "`decision`" + ` / ` + "`bug`" + ` / ` + "`gotcha`" + ` / ` + "`follow-up`" + `). -- ` + "`query_memories({tag: \"decision\", since: })`" + ` — durable cross-session decisions stamped in the window. - -These two surfaces are how a postmortem reads as a story rather than a list of file paths. - -## Checklist - -- ` + "`gortex enrich blame all`" + ` (CLI) at least once per session — the blame / releases / ownership analyzers need ` + "`meta.last_authored`" + ` to be populated -- ` + "`analyze kind=releases`" + ` for release-boundary replays -- ` + "`get_recent_changes`" + ` + ` + "`analyze kind=ownership`" + ` for the window-scoped change set -- ` + "`detect_changes`" + ` + ` + "`diff_context`" + ` to project the diff onto the graph -- ` + "`explain_change_impact`" + ` on every material symbol — second-order blast or it didn't happen -- ` + "`query_notes`" + ` + ` + "`query_memories`" + ` to ride commentary alongside the code timeline -- ` + "`export_context format=markdown`" + ` so the replay leaves the session as a shareable artifact -` - -const commandCoChange = `# Co-change Analysis with Gortex - -Use this when the user asks "what tends to change together with this?" — for refactor planning, ownership decisions, ADR rationale, or to find hidden coupling the graph's static edges don't capture (two files that always co-modify but have no import / call edge between them). Built on git blame + history + the symbol-churn surface ` + "`get_symbol_history`" + ` + the existing graph dependents view. - -## Workflow (do not skip steps) - -` + "```" + ` -1. graph_stats -> Orient -2. gortex enrich blame all (CLI; once per session) -> Stamp last_authored / last_commit_at on every node -3. search_symbols({query: ""}) -> Resolve the anchor -4. get_recent_changes({since_ts: }) -> Per-file change frequency baseline -5. get_symbol_history -> Symbol-level churn this session (live hotspot signal) -6. analyze({kind: "blame", path_prefix: "/"}) -> Per-symbol last-author rollup; cross-ref with churn -7. analyze({kind: "ownership", path_prefix: "/"}) -> Per-author symbol / file counts — proxy for who-touches-it-most -8. analyze({kind: "hotspots", path_prefix: "/"}) -> Coupling-by-fan-in/out — graph-level vs git-level coupling -9. get_dependents({id: "", depth: 2}) -> Static dependent set (graph coupling) -10. find_clones({path_prefix: "/", dead_only: false}) -> Near-duplicate functions — copy-paste co-evolution signal -11. detect_changes({scope: "all"}) -> If a diff already exists, project it onto the graph -12. diff_context({scope: "all"}) -> Per-file risk + per-symbol callers/callees overlay -` + "```" + ` - -## Co-change vs graph coupling - -The two signals are complementary: - -| Signal | What it tells you | Tool | -| --------------------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------------- | -| **Graph coupling** | Static structure: who imports / calls / extends / implements whom | ` + "`get_dependents`" + ` / ` + "`get_dependencies`" + ` / ` + "`analyze kind=hotspots`" + ` | -| **Co-change coupling** | Historical / temporal: who tends to co-modify even without a static edge | ` + "`get_recent_changes`" + ` + ` + "`analyze kind=blame`" + ` + ` + "`get_symbol_history`" + ` | -| **Both align** | Healthy module — the structure reflects how the team edits it | Use either; co-change adds confidence | -| **Co-change > graph** | **Hidden coupling** — two files always co-modify but have no graph edge. Smell: missing abstraction, parallel hierarchies, copy-paste | ` + "`find_clones`" + ` is the corroborating evidence | -| **Graph > co-change** | Dead coupling — graph edge exists but the files don't co-evolve. Often safe to break | ` + "`analyze kind=dead_code`" + ` may flag the dependent | - -## Driving a refactor with co-change - -1. Pick the anchor (file / symbol the user wants to refactor) -2. Compute co-change set: symbols whose ` + "`meta.last_authored`" + ` clusters with the anchor's, AND whose churn (` + "`get_symbol_history`" + ` + ` + "`get_recent_changes`" + `) overlaps the anchor's -3. Compute graph-coupling set: ` + "`get_dependents`" + ` + ` + "`get_dependencies`" + ` (depth 2) -4. Diff the two sets — the **co-change ∖ graph** delta is the hidden-coupling list and the highest ROI for "extract a shared abstraction" -5. Pair with ` + "`find_clones`" + ` on the co-change set — clone clusters that span the boundary are the copy-paste hidden coupling -6. ` + "`store_memory({kind: \"convention\", body: \"X always changes with Y because Z\"})`" + ` so the next agent inherits the invariant rather than re-discovering it - -## Ownership signal - -` + "`analyze kind=ownership`" + ` projected onto the co-change set tells you who-pings-who for review. Two files that co-modify under different primary authors is a *coordination* signal — the team is implicitly maintaining a contract neither file's CODEOWNERS captures. - -## Crossing the repo boundary - -When the anchor lives in a multi-repo project (` + "`get_active_project`" + ` shows >1 member): - -- ` + "`analyze kind=cross_repo base_kind=calls repo=`" + ` gives the static cross-repo coupling -- For the temporal signal across repos, run ` + "`analyze kind=blame`" + ` per repo and look for authors who appear in both — that's the implicit cross-repo co-change channel - -## Checklist - -- ` + "`gortex enrich blame all`" + ` (CLI) at least once per session — the temporal analyzers need ` + "`meta.last_authored`" + ` -- ` + "`get_recent_changes`" + ` + ` + "`get_symbol_history`" + ` for the live churn signal -- ` + "`analyze kind=blame`" + ` + ` + "`ownership`" + ` for the per-symbol / per-author rollup -- ` + "`get_dependents`" + ` (graph coupling) **and** the churn surface (temporal coupling) — diff them; the delta is the hidden coupling -- ` + "`find_clones`" + ` to corroborate copy-paste-style hidden coupling -- ` + "`analyze kind=hotspots`" + ` for the graph-side coupling-by-fan-in/out view -- For multi-repo work: ` + "`analyze kind=cross_repo`" + ` on the static side + per-repo blame on the temporal side -- ` + "`store_memory({kind: \"convention\"})`" + ` when you discover a stable co-change relationship — saves the next agent from re-deriving it -` - -const commandOnboarding = `# Onboarding with Gortex (30-minute repo tour) - -Use this when the user is new to a repo (or returning to one cold) and wants a structured tour: where to read first, what the architecture looks like, who owns what, what's load-bearing, and where to safely make a first edit. Composes the discovery-tier tools into a single ordered walk so the agent doesn't waste the user's first hour on undirected ` + "`Read`" + ` calls. - -## Workflow (do not skip steps) - -` + "```" + ` -1. graph_stats -> Confirm Gortex is indexed; per-language / per-kind counts -2. get_active_project -> Is this single-repo or part of a multi-repo project? -3. list_repos (when multi-repo) -> See sibling repos in the project -4. get_repo_outline -> Narrative single-call codebase overview (entry points, top communities, hot paths) -5. surface_memories({task: "onboarding "}) -> Cross-session invariants / conventions already captured -6. distill_session -> Prior session digest for this workspace (decisions, pinned notes) -7. get_communities -> Functional clusters via Louvain — the implicit "modules" -8. get_processes -> Discovered execution flows (the implicit "use cases") -9. analyze({kind: "hotspots"}) -> Over-coupled symbols — where most edits should be careful -10. analyze({kind: "components"}) -> Component fan-in/out (UI projects) -11. analyze({kind: "routes"}) -> HTTP / gRPC / GraphQL / WS endpoints — the API surface -12. analyze({kind: "models"}) -> ORM models → tables — the data surface -13. analyze({kind: "ownership", path_prefix: "/"}) -> Per-author rollup — who to ping for what -14. contracts({action: "list"}) -> Detected API contracts (provider side) -15. audit_agent_config -> Project CLAUDE.md / AGENTS.md / .cursor/rules — read what the team told the agent -16. ask({question: "What does this repo do and how is it organised?"}) -> LLM summary grounded in the working set (only when llm provider configured) -` + "```" + ` - -## The five-minute version (when the user is in a hurry) - -` + "```" + ` -1. graph_stats -2. get_repo_outline -3. get_communities (top 5) -4. surface_memories({task: "onboarding"}) -5. audit_agent_config -` + "```" + ` - -Stop here unless the user asks for more depth. The outline + communities + memories triple covers "what is this repo / how is it carved up / what should I know before editing." - -## The full tour (when the user wants 30 minutes) - -The full ` + "`Workflow`" + ` above, plus per-community drilldowns: - -1. From ` + "`get_communities`" + `, pick the top 3 by node count -2. For each, ` + "`get_communities({id: })`" + ` returns its members -3. ` + "`get_file_summary`" + ` on the community's central file (highest fan-in by symbol count) -4. ` + "`get_processes`" + ` on a process that crosses the community -5. ` + "`get_callers`" + ` / ` + "`get_call_chain`" + ` on the community's entry-point symbol - -Result: the user has a concrete mental model of one community per drilldown. - -## Where to make a first edit safely - -After the tour, the user usually asks "where can I make my first change without breaking things?" Answer with: - -1. ` + "`analyze kind=todos`" + ` — every TODO / FIXME node, filterable by author / tag — pick a small one in a low-fan-in symbol -2. ` + "`analyze kind=coverage_gaps min_pct=0 max_pct=50 path_prefix=/`" + ` — undertested code where a test addition is a safe first PR -3. ` + "`analyze kind=stale_code older_than=180`" + ` — code untouched for 6+ months in a community the user just explored — usually safe to add a small clarifying refactor - -Either path: hand off to ` + "`/gortex-safe-edit`" + ` (or ` + "`/gortex-add-test`" + ` for the coverage-gap path) for the actual change. - -## Multi-repo onboarding - -When ` + "`get_active_project`" + ` shows multiple members: - -- ` + "`list_repos`" + ` first; pick the *primary* repo for the tour (usually the largest by node count or the one with the most ` + "`KindContract`" + ` provider nodes) -- ` + "`analyze kind=cross_repo base_kind=calls`" + ` shows the cross-repo coupling — onboarding must cover *who calls into* + *who is called from* the primary -- ` + "`contracts({action: list})`" + ` partitioned by repo shows the wire surface each repo exposes - -## Onboarding artefact - -The deliverable for an onboarding session is a markdown packet the user can keep: - -` + "```" + ` -export_context({ - task: "onboarding tour of ", - format: "markdown", - sections: ["outline", "communities", "processes", "hotspots", "routes", "models", "memories"] -}) -` + "```" + ` - -Hand that to the user; they can paste it into their notes / wiki / agent memory. - -## Checklist - -- ` + "`graph_stats`" + ` + ` + "`get_active_project`" + ` before any tool that reads the graph — confirm scope first -- ` + "`get_repo_outline`" + ` is the single-call narrative; if it's missing fields the user needs, fall back to the per-analyzer walks -- ` + "`surface_memories`" + ` + ` + "`distill_session`" + ` — prior agents may have left invariants / conventions / decisions that shape the tour -- ` + "`get_communities`" + ` + ` + "`get_processes`" + ` for the architectural skeleton -- ` + "`audit_agent_config`" + ` reads the team's own CLAUDE.md / AGENTS.md so the tour respects whatever conventions the team wrote down -- For multi-repo: pick the primary, then ` + "`analyze kind=cross_repo`" + ` for the boundary view -- End with ` + "`export_context format=markdown`" + ` so the user keeps the tour -- Suggest a safe first edit via ` + "`analyze kind=todos`" + ` / ` + "`coverage_gaps`" + ` / ` + "`stale_code`" + ` and hand off to ` + "`/gortex-safe-edit`" + ` or ` + "`/gortex-add-test`" + ` -` - -const commandQualityAudit = `# Quality Audit with Gortex (repo health scan) - -Use this when the user wants a structured pass over a repo / directory looking for code-quality issues at scale — dead code, hotspots, cycles, churn, todos, coverage gaps, clones, anti-pattern smells, configuration drift. The output is a prioritised punch list, not a verdict; each finding is grounded in a graph query the user can re-run. - -## Workflow (do not skip steps) - -` + "```" + ` -1. graph_stats -> Orient -2. gortex enrich blame coverage releases all (CLI; once) -> Stamp the metadata the temporal + coverage analyzers need -3. analyze({kind: "dead_code", path_prefix: "/"}) -> Symbols with zero incoming edges (excludes entry points + tests) -4. find_clones({path_prefix: "/", dead_only: true}) -> Dead duplicates of live code — the segment-unique diagnostic -5. analyze({kind: "hotspots", path_prefix: "/"}) -> Over-coupled symbols by fan-in / fan-out / community crossings -6. analyze({kind: "cycles", path_prefix: "/"}) -> Tarjan SCCs with severity -7. analyze({kind: "todos", path_prefix: "/"}) -> TODO / FIXME / HACK nodes -8. analyze({kind: "stale_code", older_than: 365, path_prefix: "/"}) -> Code untouched 1y+ — refactor / delete candidates -9. analyze({kind: "stale_flags", older_than: 180}) -> Feature flags whose every toggler is older than 6mo -10. analyze({kind: "coverage_summary", path_prefix: "/"}) -> Per-directory coverage rollup -11. analyze({kind: "coverage_gaps", path_prefix: "/", -> Undertested symbols - min_pct: 0, max_pct: 50}) -12. get_untested_symbols({path_prefix: "/"}) -> Symbols with zero covering tests -13. analyze({kind: "error_surface", path_prefix: "/"}) -> Functions and what they throw — risk concentration -14. analyze({kind: "field_writers"}) -> Mutability hotspots — fields ranked by write count -15. analyze({kind: "race_writes"}) -> Cross-language goroutine-reachable writes w/o lock -16. analyze({kind: "unclosed_channels"}) -> Channels with sends but no close() -17. analyze({kind: "orphan_tables"}) -> Tables queried but missing a migration -18. analyze({kind: "unreferenced_tables"}) -> Tables provided by a migration with zero readers -19. analyze({kind: "stale_flags"}) -> Dead-rollout flag candidates (rerun with smaller window if noisy) -20. analyze({kind: "sast", path_prefix: "/", severity: "high"}) -> CWE/OWASP-tagged security scan — 190+ rules across 8 languages -21. analyze({kind: "named", name: ""}) -> Named query bundles — sql-injection, hardcoded-secrets, ssrf, xxe, weak-crypto, … -22. analyze({kind: "unsafe_patterns", path_prefix: "/"}) -> Panic-prone / undefined-behavior primitives across all languages -23. search_ast({detector: ""}) -> Targeted structural anti-pattern sweep (see list_inspections for the menu) -24. analyze({kind: "health_score", path_prefix: "/", -> Composite per-file health grade — ranks the worst files first - roll_up: "file"}) -25. audit_agent_config -> Stale references in CLAUDE.md / AGENTS.md / .cursor/rules — config drift -26. contracts({action: "check"}) -> Orphan providers / consumers; HTTP / gRPC / topics / env drift -27. analyze({kind: "ownership", path_prefix: "/"}) -> Per-author rollup — who to ping per finding -28. export_context({format: "markdown", -> Hand the audit packet to PR / Slack / wiki - sections: ["findings", "ownership", "priorities"]}) -` + "```" + ` - -## Prioritising findings - -Findings are not equal. Rank by: - -| Tier | Filter | Why this matters first | -| ---- | ---------------------------------------------------------------------------------------- | ---------------------- | -| **P0** | ` + "`analyze kind=sast severity=high`" + ` / ` + "`analyze kind=named`" + ` / ` + "`race_writes`" + ` / ` + "`unclosed_channels`" + ` / ` + "`weak-crypto`" + ` / ` + "`hardcoded-secret`" + ` / ` + "`http-client-no-timeout`" + ` | Correctness / security | -| **P1** | ` + "`cycles severity=severe`" + ` / ` + "`orphan_tables`" + ` / ` + "`contracts({action: check})`" + ` orphans / ` + "`audit_agent_config`" + ` stale refs | Real bugs latent in the graph | -| **P2** | ` + "`dead_code`" + ` ∩ ` + "`find_clones dead_only=true`" + ` / ` + "`stale_flags`" + ` / ` + "`stale_code`" + ` | Deletion candidates — easy ROI | -| **P3** | ` + "`hotspots top=20`" + ` / ` + "`coverage_gaps min_pct=0 max_pct=20`" + ` / ` + "`error_surface`" + ` widening | Refactor targets | -| **P4** | ` + "`todos`" + ` / ` + "`field_writers`" + ` heavy churn | Backlog signal | - -Always pair every P0 / P1 finding with ` + "`analyze kind=ownership`" + ` on its path so the audit packet pings the right reviewer. - -## Security & anti-pattern sweep - -` + "`analyze kind=sast`" + ` is the comprehensive pass — 190+ CWE/OWASP-tagged rules across 8 languages, filterable by ` + "`severity`" + ` / ` + "`cwe`" + ` / ` + "`tag`" + `. ` + "`analyze kind=named`" + ` runs focused query bundles (sql-injection, hardcoded-secrets, ssrf, xxe, …). ` + "`search_ast`" + ` then targets specific structural smells — a representative subset of its bundled detectors: - -| Detector | Languages | -| --------------------------------- | ------------------------------------ | -| ` + "`error-not-wrapped`" + ` | Go | -| ` + "`sql-string-concat`" + ` | Go / Python / JS / TS / Ruby | -| ` + "`weak-crypto`" + ` | Go / Python | -| ` + "`panic-in-library`" + ` | Go | -| ` + "`goroutine-without-recover`" + ` | Go | -| ` + "`http-client-no-timeout`" + ` | Go | -| ` + "`hardcoded-secret`" + ` | Go / Python / JS / TS / Ruby | -| ` + "`empty-catch`" + ` | Java / JS / TS / Python | -| ` + "`java-string-equality`" + ` | Java | -| ` + "`python-mutable-default-arg`" + ` | Python | - -Run each ` + "`search_ast detector=`" + ` once; the per-match ` + "`symbol_id`" + ` chains directly into ` + "`find_usages`" + ` / ` + "`apply_code_action`" + ` for follow-up. - -## Audit deliverable - -The output of a quality audit is a prioritised markdown packet, not a one-line "score": - -` + "```" + ` -# audit — - -## P0 — Correctness / security -- · · owner: · evidence: - -## P1 — Real bugs latent in the graph -… - -## P2 — Deletion candidates -… - -## P3 — Refactor targets -… - -## P4 — Backlog signal -… -` + "```" + ` - -` + "`export_context format=markdown`" + ` is the producer. - -## Checklist - -- ` + "`gortex enrich blame coverage releases all`" + ` (CLI) before the audit — temporal + coverage analyzers need ` + "`meta.last_authored`" + ` / ` + "`meta.coverage_pct`" + ` / ` + "`meta.added_in`" + ` -- Walk the analyzers in the order above — earlier ones surface highest-priority findings -- Pair every finding with ` + "`analyze kind=ownership`" + ` so the audit packet has a routing column -- ` + "`analyze kind=sast`" + ` is the security backbone; add ` + "`search_ast`" + ` detectors for the language-specific smells it doesn't cover -- ` + "`audit_agent_config`" + ` catches stale references in the team's CLAUDE.md / AGENTS.md / IDE config — config drift is a real-world finding -- Rank by the P0..P4 tiers; do not hand the user a flat list -- ` + "`export_context format=markdown`" + ` for the packet -- ` + "`store_memory({kind: \"reference\", title: \" audit YYYY-MM\", body: \"...\"})`" + ` so the next audit can diff against this baseline -` - -const commandArchitectureReview = `# Architecture Review with Gortex - -Use this when the user wants a graph-grounded architectural read of a repo / system — "what does the architecture actually look like, where is the design under stress, what should we refactor before it breaks." Distinct from ` + "`/gortex-quality-audit`" + ` (which produces a punch list) — this produces a *narrative* with diagrams. - -## Workflow (do not skip steps) - -` + "```" + ` -1. graph_stats -> Orient -2. get_repo_outline -> Narrative single-call overview -3. get_communities -> Functional clusters via Louvain — the de facto module boundaries -4. get_processes -> Discovered execution flows — the de facto use cases -5. analyze({kind: "components"}) -> UI component tree (when applicable) — render hierarchy fan-in/out -6. analyze({kind: "routes"}) -> The API surface (HTTP / gRPC / GraphQL / WS / topic) -7. analyze({kind: "models"}) -> ORM models → tables — the data surface -8. analyze({kind: "k8s_resources"}) + analyze({kind: "images"}) -> Deployment topology (when applicable) -9. analyze({kind: "hotspots", top: 20}) -> Symbols under coupling stress -10. analyze({kind: "cycles"}) -> Architectural cycles (Tarjan SCC with severity) -11. analyze({kind: "would_create_cycle", from: "", to: ""}) -> Pre-flight before proposing a new dep in the review -12. analyze({kind: "cross_repo", base_kind: "calls"}) -> Inter-repo coupling (when multi-repo) -13. contracts({action: "list"}) -> Provider-side wire surface -14. contracts({action: "check"}) -> Provider ↔ consumer match across repos -15. get_class_hierarchy({id: ""}) -> Inheritance / implementation depth on load-bearing interfaces -16. get_dependencies / get_dependents at the community / file level -> Layering walk — does the dependency direction match the intent? -17. analyze({kind: "pubsub"}) + analyze({kind: "channel_ops"}) -> Async + concurrency topology -18. analyze({kind: "field_writers"}) + analyze({kind: "race_writes"}) -> Shared-state hotspots — the architecture's hidden mutable surface -19. analyze({kind: "stale_code", older_than: 365}) -> Strata that haven't moved in a year — likely the "stable core" -20. analyze({kind: "ownership"}) -> Per-author footprint per community — Conway's law check -21. export_context({format: "markdown", -> The review deliverable - sections: ["overview", "modules", "processes", - "wire_surface", "data_surface", "concurrency", - "cross_repo", "stress_points", "recommendations"]}) -` + "```" + ` - -## What the architecture review answers - -| Question | Tool | -| -------------------------------------------------------------- | ----------------------------------------------------------------------------- | -| **What are the de facto modules?** (not what's claimed) | ` + "`get_communities`" + ` — Louvain finds the actual cluster structure | -| **What are the de facto use cases?** | ` + "`get_processes`" + ` — discovered execution flows | -| **Where is the design under coupling stress?** | ` + "`analyze kind=hotspots top=20`" + ` — fan-in + fan-out + community crossings | -| **Are there cycles?** | ` + "`analyze kind=cycles`" + ` — Tarjan SCC with severity classification | -| **Does the dependency direction match the intent?** | Walk ` + "`get_dependencies`" + ` / ` + "`get_dependents`" + ` at the community / file level | -| **What is the wire surface?** | ` + "`analyze kind=routes`" + ` + ` + "`contracts({action: list})`" + ` | -| **What is the data surface?** | ` + "`analyze kind=models`" + ` + ` + "`analyze kind=orphan_tables`" + ` / ` + "`unreferenced_tables`" + ` | -| **What is the deployment topology?** | ` + "`analyze kind=k8s_resources`" + ` + ` + "`analyze kind=images`" + ` | -| **Where does shared state live?** | ` + "`analyze kind=field_writers`" + ` + ` + "`race_writes`" + ` — the mutable-state surface | -| **Where does the async + pub/sub topology lead?** | ` + "`analyze kind=pubsub`" + ` + ` + "`channel_ops`" + ` + ` + "`goroutine_spawns`" + ` + ` + "`unclosed_channels`" + ` | -| **Does the team's structure match the code's structure? (Conway)** | ` + "`analyze kind=ownership`" + ` projected onto ` + "`get_communities`" + ` — author overlap per community | -| **Where is the stable core?** | ` + "`analyze kind=stale_code older_than=365`" + ` — strata that haven't moved in a year | -| **Where would adding a new edge create a cycle?** | ` + "`analyze kind=would_create_cycle from=A to=B`" + ` — pre-flight for proposed deps | - -## Stress points - -Stress points are graph signals that the design is fighting itself. Surface them explicitly in the review: - -- **Cyclic dependency in the load-bearing layer** — ` + "`analyze kind=cycles severity=severe`" + ` ∩ a community with high ` + "`hotspots`" + ` rank -- **Architecture-spanning hotspot** — a symbol whose ` + "`community_crossings`" + ` is > 50% of its fan-in (the symbol "is" the integration layer, often by accident) -- **Mutable shared field reachable from a goroutine without a lock** — ` + "`analyze kind=race_writes`" + ` on a high-fan-in field -- **Contract orphan** — ` + "`contracts({action: check})`" + ` returns a provider with no consumer or a consumer with no provider, especially cross-repo -- **Inverted dependency** — a "lower" layer importing a "higher" one. Walk ` + "`get_dependencies`" + ` from each community to confirm directionality -- **Deployment ↔ code drift** — ` + "`analyze kind=images role=base ref=latest`" + ` (unpinned), ` + "`k8s_resources k8s_kind=ConfigMap`" + ` orphans - -## Multi-repo architecture - -When ` + "`get_active_project`" + ` shows >1 member: - -- ` + "`analyze kind=cross_repo base_kind=calls`" + ` — typed cross-repo edges; the count is the *contract surface* the architecture must hold stable -- ` + "`contracts({action: check})`" + ` partitioned by repo pair — orphan providers / consumers across the boundary -- For each cross-repo edge, ` + "`get_test_targets`" + ` returns the cross-repo tests that exercise it; the absence of those tests is itself an architecture finding - -## Review deliverable - -The output of an architecture review is a markdown narrative with embedded diagrams (Mermaid / DOT) generated from the graph: - -` + "```" + ` -export_context({ - task: "architecture review of ", - format: "markdown", - sections: [...] -}) -` + "```" + ` - -The packet rides the same surface as ` + "`/gortex-quality-audit`" + ` but is structured as a *narrative* (modules, processes, surfaces, stress, recommendations) rather than a flat punch list. - -## Checklist - -- ` + "`graph_stats`" + ` + ` + "`get_active_project`" + ` before any analyzer -- ` + "`get_repo_outline`" + ` for the narrative skeleton -- ` + "`get_communities`" + ` + ` + "`get_processes`" + ` are the architectural primitives — pin the review on these -- ` + "`analyze kind=hotspots`" + ` + ` + "`cycles`" + ` for stress points -- Wire surface = ` + "`analyze kind=routes`" + ` + ` + "`contracts list`" + `; data surface = ` + "`analyze kind=models`" + ` + ` + "`orphan_tables`" + `; deployment = ` + "`k8s_resources`" + ` + ` + "`images`" + ` -- Concurrency / async = ` + "`pubsub`" + ` + ` + "`channel_ops`" + ` + ` + "`goroutine_spawns`" + ` + ` + "`race_writes`" + ` + ` + "`unclosed_channels`" + ` -- Multi-repo: ` + "`cross_repo`" + ` + ` + "`contracts check`" + ` partitioned by repo pair -- ` + "`ownership`" + ` ∩ ` + "`communities`" + ` for Conway's law alignment -- ` + "`export_context format=markdown`" + ` for the narrative -- ` + "`store_memory({kind: \"decision\"})`" + ` for every architectural decision the review surfaces — the next agent inherits the rationale -` - -const commandPRReview = `# PR Review with Gortex (graph-grounded change review) - -Use this when the user wants a code-review pass on a pending change — local staged diff, a branch about to merge, or a PR they're reading. The review walks the diff through the graph so the comments are grounded in real callers / contracts / coverage / guards, not surface-level style nitpicks. - -## Workflow (do not skip steps) - -` + "```" + ` -1. graph_stats -> Orient -2. detect_changes({scope: "staged"}) -> The change-set's graph projection (use "all" / "since-tag" as needed) -3. diff_context({scope: "staged"}) -> Graph-enriched diff: callers, callees, communities, processes, per-file risk -4. For each changed symbol: - explain_change_impact({ids: ""}) -> Risk-tiered blast radius - verify_change({id: "", new_signature: ""}) -> Catch interface / contract breaks -5. contracts({action: "check"}) -> Provider ↔ consumer match across repos (HTTP / gRPC / topics / env / OpenAPI) -6. check_guards({ids: []}) -> Team conventions from .gortex.yaml -7. analyze({kind: "would_create_cycle", from: "", to: ""}) -> If the diff adds a new import edge, pre-flight it -8. analyze({kind: "coverage_gaps", path_prefix: "/", -> Did the change touch undertested code? - min_pct: 0, max_pct: 50}) -9. get_untested_symbols({path_prefix: "/"}) -> Symbols still uncovered after the change -10. get_test_targets({ids: []}) -> Tests to re-run (cross-repo aware) -11. find_clones({path_prefix: "/", dead_only: true}) -> Did the change leave dead duplicates behind? -12. analyze({kind: "error_surface", path_prefix: "/"}) -> Did the change widen what gets thrown? -13. analyze({kind: "cross_repo", base_kind: "calls"}) -> Cross-repo blast (when multi-repo) -14. For high-risk changed symbols (d=1 in explain_change_impact): - preview_edit({edit: }) -> Speculative apply on the shadow graph; reports broken_callers / broken_implementors -15. surface_memories({task: "review ", symbol_ids: [...]}) -> Cross-session invariants on the touched symbols -16. export_context({format: "markdown", -> Review packet for the PR thread - sections: ["scope", "impact", "contracts", - "guards", "coverage", "tests", - "recommendations"]}) -` + "```" + ` +const commandGuide = `# Gortex Guide -## Review priorities +Use this sequence for codebase work: + +1. ` + "`explore({operation: \"task\", task: \"\"})`" + ` — localize the task and obtain the working set. +2. ` + "`search({operation: \"symbols\", query: \"\"})`" + ` or ` + "`search({operation: \"text\", query: \"\"})`" + ` — resolve a precise anchor. +3. ` + "`read({operation: \"source\", target: {symbol: \"\"}})`" + ` — read only the source needed. +4. ` + "`relations({operation: \"usages\", target: {symbol: \"\"}})`" + ` or ` + "`trace({operation: \"call_chain\", target: {symbol: \"\"}})`" + ` — follow verified graph edges. +5. ` + "`change({operation: \"impact\", source: {symbols: [\"\"]}})`" + ` — assess a planned change. +6. Apply with ` + "`edit`" + ` or ` + "`refactor`" + `, then run ` + "`change`" + ` operations ` + "`detect`" + `, ` + "`guards`" + `, and ` + "`tests`" + `. +` + nativeMCPRules + +const commandExplore = `# Explore a Codebase with Gortex + +1. Call ` + "`explore({operation: \"task\", task: \"\"})`" + `. +2. Read only a returned anchor with ` + "`read({target: {symbol: \"\"}})`" + `. +3. Choose the needed relationship (` + "`callers`" + `, ` + "`usages`" + `, or ` + "`dependencies`" + `), for example ` + "`relations({operation: \"callers\", target: {symbol: \"\"}})`" + `; use ` + "`trace({operation: \"call_chain\", target: {symbol: \"\"}})`" + ` for execution order. +4. Answer with the execution path, file locations, symbol IDs, and any graph uncertainty. +` + nativeMCPRules + +const commandDebug = `# Debug with Gortex + +1. Localize the exact symptom with ` + "`explore({operation: \"task\", task: \"\"})`" + `. +2. For a literal error, call ` + "`search({operation: \"text\", query: \"\"})`" + `. For a name, use operation ` + "`symbols`" + `. +3. Walk toward the cause with ` + "`relations({operation: \"callers\", target: {symbol: \"\"}})`" + ` and ` + "`trace({operation: \"call_chain\", target: {symbol: \"\"}})`" + `. Use ` + "`flow`" + ` or ` + "`taint`" + ` only after resolving both ` + "`target`" + ` and ` + "`to`" + ` endpoints. +4. Confirm repository-wide error propagation with ` + "`analyze({kind: \"error_surface\"})`" + `; use ` + "`options.repo`" + ` to narrow a multi-repository workspace. +5. Before fixing, run ` + "`change({operation: \"impact\", source: {symbols: [\"\"]}})`" + `. After fixing, run ` + "`change`" + ` operations ` + "`detect`" + `, ` + "`tests`" + `, and ` + "`guards`" + `. +` + nativeMCPRules + +const commandImpact = `# Assess Change Impact with Gortex + +1. Resolve the target with ` + "`search({operation: \"symbols\", query: \"\"})`" + `. +2. Query ` + "`relations`" + ` operations ` + "`usages`" + `, ` + "`dependents`" + `, and ` + "`implementations`" + ` for the resolved symbol. +3. Call ` + "`change({operation: \"impact\", source: {symbols: [\"\"]}})`" + `; add operation ` + "`api_impact`" + ` for a public API. +4. For a signature change, require ` + "`change({operation: \"verify\", source: {changes: [{symbol_id: \"\", new_signature: \"\"}]}})`" + `. +5. Report direct callers, transitive risk, interfaces, contracts, and the tests returned by ` + "`change({operation: \"tests\", source: {symbols: [\"\"]}})`" + `. +` + nativeMCPRules + +const commandRefactor = `# Refactor with Gortex + +1. Call ` + "`explore({operation: \"task\", task: \"\"})`" + ` and resolve every target with ` + "`search`" + `. +2. Run ` + "`change({operation: \"impact\", source: {symbols: [\"\"]}})`" + `. Before altering a signature, also run ` + "`change({operation: \"verify\", source: {changes: [{symbol_id: \"\", new_signature: \"\"}]}})`" + `. +3. Choose exactly one ` + "`refactor`" + ` operation: ` + "`rename`" + `, ` + "`move`" + `, ` + "`inline`" + `, ` + "`delete`" + `, or ` + "`apply_code_action`" + `. For example: ` + "`refactor({operation: \"rename\", target: {symbol: \"\"}, new_name: \"\"})`" + `. +4. Require ` + "`change`" + ` operations ` + "`detect`" + `, ` + "`tests`" + `, ` + "`guards`" + `, and ` + "`contract`" + ` after the mutation. Resolve every violation before finishing. +` + nativeMCPRules + +const commandSafeEdit = `# Make a Safe Edit with Gortex + +1. Localize with ` + "`explore`" + ` and inspect ` + "`read({operation: \"editing_context\", target: {file: \"\"}})`" + `. +2. Run ` + "`change({operation: \"impact\", source: {symbols: [\"\"]}})`" + ` before editing. +3. Choose ` + "`edit`" + ` operation ` + "`file`" + `, ` + "`symbol`" + `, or ` + "`batch`" + `. Preview with ` + "`dry_run: true`" + `; after review, repeat the same guarded request with ` + "`dry_run: false`" + `. Use ` + "`change({operation: \"simulate\", source: {steps: \"\"}})`" + ` for a multi-step semantic edit. +4. Run ` + "`change`" + ` operations ` + "`detect`" + `, ` + "`tests`" + `, ` + "`guards`" + `, and ` + "`contract`" + `. Signature verification belongs before mutation with the proposed signature. +` + nativeMCPRules + +const commandFixAll = `# Fix Diagnostics with Gortex + +1. Read diagnostics using ` + "`change({operation: \"diagnostics\", source: {file: \"\"}})`" + `. +2. Fetch applicable fixes with ` + "`change({operation: \"code_actions\", source: {file: \"\"}})`" + `. +3. Apply one reviewed action with ` + "`refactor({operation: \"apply_code_action\", target: {file: \"\"}, options: {...}})`" + `, or use operation ` + "`fix_all`" + ` only when the user asked for all safe fixes. +4. Re-run diagnostics, then run ` + "`change`" + ` operations ` + "`detect`" + ` and ` + "`tests`" + `. +` + nativeMCPRules + +const commandExtractFunction = `# Extract a Function with Gortex + +1. Inspect the file with ` + "`read({operation: \"editing_context\", target: {file: \"\"}})`" + `. +2. Resolve the selected range with ` + "`change({operation: \"ranges\", source: {file: \"\", range: {...}}})`" + `. +3. Request extraction actions with ` + "`change({operation: \"code_actions\", source: {file: \"\", range: {...}}})`" + `. +4. Apply the selected action through ` + "`refactor({operation: \"apply_code_action\", target: {file: \"\"}, options: {...}})`" + `. +5. Run ` + "`change`" + ` operations ` + "`detect`" + `, ` + "`tests`" + `, ` + "`guards`" + `, and ` + "`contract`" + `. +` + nativeMCPRules + +const commandRename = `# Rename a Symbol with Gortex + +1. Resolve exactly one symbol with ` + "`search({operation: \"symbols\", query: \"\"})`" + `. +2. Enumerate references and implementations with ` + "`relations`" + ` operations ` + "`usages`" + ` and ` + "`implementations`" + `. +3. Run ` + "`change({operation: \"impact\", source: {symbols: [\"\"]}})`" + `. For a public signature rename, also run ` + "`change({operation: \"verify\", source: {changes: [{symbol_id: \"\", new_signature: \"\"}]}})`" + `. +4. Preview with ` + "`refactor({operation: \"rename\", target: {symbol: \"\"}, new_name: \"\", dry_run: true})`" + `, then repeat it with ` + "`dry_run: false`" + ` to apply. +5. Require ` + "`change`" + ` operations ` + "`detect`" + `, ` + "`guards`" + `, and ` + "`tests`" + `; confirm no old usages remain. +` + nativeMCPRules -Walk every PR through these gates, in order. A failure at gate N is a blocker — do not move to gate N+1 until N is addressed: +const commandCrossRepoUsage = `# Find Cross-Repository Usage with Gortex -| Gate | Tool | Blocker if … | -| ---- | ----------------------------------------------- | ------------ | -| **1. Scope** | ` + "`detect_changes`" + ` | Touches files / symbols the PR description doesn't mention | -| **2. Signature safety** | ` + "`verify_change`" + ` per signature change | Callers / implementors break | -| **3. Contract safety** | ` + "`contracts({action: check})`" + ` | Orphan providers / consumers; cross-repo wire drift | -| **4. Convention compliance** | ` + "`check_guards`" + ` | Project rules from ` + "`.gortex.yaml`" + ` violated | -| **5. Coupling sanity** | ` + "`analyze kind=would_create_cycle`" + ` | New import edge introduces a cycle | -| **6. Coverage hygiene** | ` + "`analyze kind=coverage_gaps`" + ` + ` + "`get_untested_symbols`" + ` | Changed code is uncovered or under-covered | -| **7. Test discoverability** | ` + "`get_test_targets`" + ` | The PR adds a symbol with no covering test target | -| **8. Dead duplication** | ` + "`find_clones dead_only=true`" + ` | Refactor left a dead duplicate of live code | -| **9. Blast verification** | ` + "`preview_edit`" + ` on high-risk changes | Speculative apply reports ` + "`broken_callers`" + ` or ` + "`broken_implementors`" + ` that the diff doesn't address | -| **10. Memory check** | ` + "`surface_memories`" + ` on touched symbols | Diff contradicts a pinned invariant / decision / gotcha | +1. Confirm tracked repositories with ` + "`workspace({operation: \"repos\"})`" + `. +2. Resolve the provider symbol with ` + "`search({operation: \"symbols\", query: \"\", options: {repo: \"\"}})`" + `. +3. Call ` + "`relations({operation: \"usages\", target: {symbol: \"\"}})`" + ` and group results by repository. +4. Add ` + "`analyze({kind: \"cross_repo\", options: {repo: \"\"}})`" + ` for repository boundaries and ` + "`analyze({kind: \"contracts\", options: {action: \"bridge\", mode: \"impact\", symbol: \"\"}})`" + ` for wire-level consumers. +5. Report indexed coverage and name any untracked repositories as an explicit gap. +` + nativeMCPRules -## Cross-repo PRs +const commandDataflowTrace = `# Trace Data Flow with Gortex -When the PR touches a multi-repo project: +1. Call ` + "`explore({operation: \"task\", task: \"trace from to \"})`" + `. +2. Resolve endpoints with ` + "`search({operation: \"symbols\", query: \"\"})`" + `. +3. Use ` + "`trace({operation: \"flow\", target: {symbol: \"\"}, to: {symbol: \"\"}})`" + `. Use operation ` + "`taint`" + ` when source/sink security semantics matter. +4. Cross-check control flow with operation ` + "`call_chain`" + ` when the data-flow graph has a gap. +5. Report each hop with its symbol ID and confidence; distinguish no path from incomplete indexing. +` + nativeMCPRules -- ` + "`detect_changes`" + ` only sees the local repo's changes; also run ` + "`contracts({action: check})`" + ` to flag wire-side breakage in consumer repos -- ` + "`analyze kind=cross_repo base_kind=calls repo=`" + ` lists every consumer call that crosses out of the changed repo -- ` + "`get_test_targets`" + ` returns the cross-repo tests that exercise the affected wire path — those tests must be on the reviewer's run list +const commandAddTest = `# Add Tests with Gortex -## Review deliverable +1. Localize the behavior with ` + "`explore`" + `. +2. Confirm the specific gap with ` + "`change({operation: \"tests\", source: {symbols: [\"\"]}})`" + `. Use repository-wide ` + "`analyze({kind: \"untested\"})`" + ` or path-scoped ` + "`analyze({kind: \"coverage_gaps\", options: {path_prefix: \"\"}})`" + ` only for broader coverage discovery. +3. Inspect callers with ` + "`relations({operation: \"callers\", target: {symbol: \"\"}})`" + ` and request targets with ` + "`change({operation: \"tests\", source: {symbols: [\"\"]}})`" + `. +4. Add the narrowest test with ` + "`edit({operation: \"file\", target: {file: \"\"}, ...})`" + `; use operation ` + "`write`" + ` for a new test file. +5. Run the test, then require ` + "`change`" + ` operations ` + "`detect`" + ` and ` + "`guards`" + `. +` + nativeMCPRules -Hand the PR author a structured comment block, not a stream-of-consciousness comment: +const commandIncidentInvestigation = `# Investigate an Incident with Gortex -` + "```markdown" + ` -## Review — +1. Paste the exact symptom into ` + "`explore({operation: \"task\", task: \"\"})`" + `. +2. Search exact error text with ` + "`search({operation: \"text\", query: \"\"})`" + `. +3. Walk ` + "`relations`" + ` operation ` + "`callers`" + ` and ` + "`trace`" + ` operations ` + "`call_chain`" + `, ` + "`flow`" + `, or ` + "`taint`" + ` from symptom toward cause. +4. Correlate repository-wide ` + "`analyze({kind: \"recent_changes\"})`" + ` with symbol-scoped ` + "`recall({operation: \"surface\", arguments: {symbol_ids: \"\", task: \"\"}})`" + `. +5. Separate evidence, hypothesis, and unknowns. Gate any fix through ` + "`change`" + ` before and after the mutation. +` + nativeMCPRules -### Scope -- ✅ / ⚠ — ` + "`detect_changes`" + ` matches the PR description -- Touched: N files, M symbols, K cross-repo edges +const commandEpisodeReplay = `# Replay a Change Episode with Gortex -### Impact -- High-risk: · d=1 blast — see ` + "`explain_change_impact`" + ` -- Medium: … +1. Run ` + "`analyze({kind: \"replay\", options: {from: \"\", to: \"\"}})`" + ` for the requested window. +2. Project the relevant diff with ` + "`change({operation: \"detect\", source: {scope: \"\"}})`" + `. +3. Surface decisions with ` + "`recall({operation: \"surface\", arguments: {task: \"\"}})`" + `. +4. Trace important before/after paths using ` + "`trace`" + ` and identify which change altered behavior. +5. Produce a timestamped narrative with evidence links, impact, missed signals, and remaining uncertainty. +` + nativeMCPRules -### Contracts -- ✅ / ❌ — ` + "`contracts({action: check})`" + ` summary +const commandCoChange = `# Analyze Co-Change with Gortex -### Guards -- ✅ / ❌ — ` + "`check_guards`" + ` summary +1. Resolve the anchor with ` + "`search({operation: \"symbols\", query: \"\"})`" + `. +2. Call symbol-scoped ` + "`analyze({kind: \"co_change\", target: {symbol: \"\"}})`" + ` and repository-wide ` + "`analyze({kind: \"churn\"})`" + `. +3. Compare structural coupling through ` + "`relations`" + ` operations ` + "`cluster`" + ` and ` + "`dependents`" + `. +4. Report strong historical pairs, graph-confirmed dependencies, likely hidden coupling, and an actionable boundary or guard. +` + nativeMCPRules -### Coverage -- Uncovered after this PR: (` + "`get_untested_symbols`" + `) -- Coverage gap deltas: … +const commandOnboarding = `# Onboard to a Repository with Gortex -### Tests -- ` + "`get_test_targets`" + ` recommends: … +1. Call ` + "`explore({operation: \"outline\"})`" + `, then ` + "`explore({operation: \"task\", task: \"explain the repository's main responsibilities and entry points\"})`" + `. +2. Run ` + "`analyze`" + ` with kinds ` + "`architecture`" + `, ` + "`communities`" + `, and ` + "`processes`" + `. +3. Trace one representative request or job with ` + "`trace({operation: \"call_chain\", target: {symbol: \"\"}})`" + `. +4. Read only the key returned symbols. Deliver a concise map of entry points, boundaries, data flow, tests, and operational risks. +` + nativeMCPRules -### Speculative apply (for high-risk changes) -- ` + "`preview_edit`" + ` flagged: broken_callers=…, broken_implementors=… +const commandQualityAudit = `# Audit Repository Quality with Gortex -### Recommendations -1. … -2. … -` + "```" + ` +1. Establish scope with ` + "`workspace({operation: \"info\"})`" + ` and ` + "`explore({operation: \"outline\"})`" + `. +2. Run ` + "`analyze`" + ` for ` + "`health`" + `, ` + "`dead_code`" + `, ` + "`hotspots`" + `, ` + "`cycles`" + `, and ` + "`clones`" + `. +3. Validate high-risk findings with ` + "`read`" + ` and ` + "`relations`" + `; do not report an unverified heuristic as a fact. +4. Rank findings by evidence, impact, and remediation cost. Include exact paths, symbol IDs, and a smallest-first action plan. +` + nativeMCPRules -` + "`export_context format=markdown sections=[...]`" + ` produces this skeleton automatically. +const commandArchitectureReview = `# Review Architecture with Gortex -## When the diff is already on disk +1. Build the map with ` + "`explore({operation: \"outline\"})`" + ` and ` + "`analyze`" + ` kinds ` + "`architecture`" + ` and ` + "`communities`" + `. +2. Measure boundaries with ` + "`analyze({kind: \"coupling\"})`" + ` and cycles with ` + "`analyze({kind: \"cycles\"})`" + `. +3. Trace representative paths with ` + "`trace`" + ` and inspect public boundaries with ` + "`analyze({kind: \"contracts\", options: {action: \"list\"}})`" + `. +4. Deliver observed structure, intended structure, violations, consequences, and prioritized changes. Cite graph evidence for every finding. +` + nativeMCPRules -If the diff is in the working tree (or a feature branch checked out), ` + "`detect_changes`" + ` + ` + "`diff_context`" + ` see it directly. If the user pasted in a unified diff or a GitHub URL, parse it into a synthetic ` + "`WorkspaceEdit`" + ` and drive ` + "`preview_edit`" + ` to get the speculative report — same blast / broken-callers / broken-implementors signal without touching disk. +const commandPRReview = `# Review a Change with Gortex -## Checklist +1. Project the working tree with ` + "`change({operation: \"detect\", source: {scope: \"unstaged\"}})`" + `; use ` + "`staged`" + ` or ` + "`compare`" + ` only when that is the requested review scope. +2. Build review context with ` + "`review({operation: \"run\", source: {scope: \"\"}})`" + ` and operation ` + "`diff_context`" + ` when per-file context is needed. +3. Require ` + "`change`" + ` operations ` + "`guards`" + `, ` + "`tests`" + `, and ` + "`contract`" + `. When the proposed signature is known, run operation ` + "`verify`" + ` before mutation. +4. Check wire boundaries with ` + "`analyze({kind: \"contracts\", options: {action: \"check\"}})`" + ` when APIs or events changed. +5. Report only actionable findings with severity, ` + "`file:line`" + `, evidence, consequence, and correction. State an explicit clean verdict when none remain. +` + nativeMCPRules -- ` + "`index_health`" + ` — confirm Gortex sees the working tree (` + "`graph_stats`" + ` for counts) -- ` + "`detect_changes`" + ` + ` + "`diff_context`" + ` produce the graph-grounded scope + per-file risk -- Walk the **10 gates** above in order; do not skip ahead on a blocker -- ` + "`contracts({action: check})`" + ` is **mandatory** for any PR that touches a provider symbol — the orphan check catches wire drift the diff doesn't -- ` + "`check_guards`" + ` runs the team's ` + "`.gortex.yaml`" + ` rules — these encode conventions a reviewer would otherwise have to remember -- For high-risk changes, ` + "`preview_edit`" + ` the diff as a ` + "`WorkspaceEdit`" + ` and read ` + "`broken_callers`" + ` / ` + "`broken_implementors`" + `; the speculative report is the highest-confidence signal in the review -- ` + "`surface_memories`" + ` on the touched symbols before finalising the review — a diff that contradicts a pinned invariant is a blocker, not a nit -- ` + "`export_context format=markdown`" + ` for the structured review block; never hand the user a stream-of-consciousness comment -- Cross-repo PRs: ` + "`contracts({action: check})`" + ` + ` + "`analyze kind=cross_repo`" + ` + ` + "`get_test_targets`" + ` (cross-repo aware) -` +const commandPRReviewAgent = `# Review a Change as a Sub-Agent -const commandPRReviewAgent = `# PR Review as a sub-agent (shell the review verb) +## MCP-capable harness -Use this when you are a coding agent (Codex / Claude Code / any CLI agent) and need a graph-grounded review verdict on a pending change **without** hand-walking the ten review gates yourself. Instead of orchestrating ` + "`detect_changes`" + ` / ` + "`diff_context`" + ` / ` + "`verify_change`" + ` / … one call at a time, shell the ` + "`gortex review`" + ` verb once and act on its terse, machine-first output. +Native Gortex MCP is mandatory. Call ` + "`review({operation: \"run\", source: {scope: \"\"}})`" + `, then use ` + "`change`" + ` operations ` + "`guards`" + `, ` + "`tests`" + `, and ` + "`contract`" + `. When the proposed signature is known, run operation ` + "`verify`" + ` before mutation. If the configured callable tools are missing, report a Gortex MCP integration failure and stop; do not start a daemon or use the Bash path below. -The review engine runs the deterministic correctness rulepack, grounds every finding to an exact ` + "`file:line`" + `, optionally folds in LLM findings, gates by confidence / severity, and prints a verdict — all server-side. You consume the result. +## Bash-only harness -## Run the verb +Use this path only when the harness has no MCP transport by design: ` + "```bash" + ` -# Terse, prose-free summary an agent can parse (one-line verdict + compact findings + cost): -gortex review --audience agent - -# The same review as structured JSON when you want to branch on fields programmatically: gortex review --audience agent --format json ` + "```" + ` -Select the changeset the same way a human would: - -- ` + "`--scope unstaged|staged|all|compare`" + ` — which working-tree changes to review (default ` + "`unstaged`" + `) -- ` + "`--base `" + ` — review everything since a base ref (shorthand for compare) -- ` + "`--diff `" + ` (or ` + "`--diff -`" + ` for stdin) — review a pasted unified diff instead of git -- ` + "`--use-llm`" + ` — additionally fold in LLM-found findings (needs a configured provider) -- ` + "`--repo `" + ` — the repo the daemon tracks (default: current directory) - -A daemon that tracks the repo must be running (` + "`gortex daemon status`" + `). The verb relays to the daemon; you never start the engine yourself. - -## Read the terse output - -` + "`--audience agent`" + ` prints exactly three things, no narrative: - -` + "```" + ` -VERDICT: block (1 critical, 2 error) -findings: - internal/svc/handler.go:7 error go-inverted-err-check — inverted error check - internal/svc/loop.go:12 warning go-loop-query-call — query in loop -cost: in=1234 out=456 cache_r=2000 cache_w=0 usd=0.012000 elapsed=4.2s -` + "```" + ` - -- **Line 1** is the verdict — ` + "`block`" + ` / ` + "`review`" + ` / ` + "`approve`" + ` — with the kept-finding severity histogram in parentheses. -- The **findings** block has one compact line per finding: ` + "`file:line severity rule — message`" + `. Each is anchored to a real new-side line, so you can open the file at that line directly. -- The **cost** line is the per-review token + USD accounting. - -## Act on the verdict - -| Verdict | What to do | -| ------- | ---------- | -| ` + "`block`" + ` | Do **not** merge / proceed. Fix every ` + "`critical`" + ` / ` + "`error`" + ` finding at its ` + "`file:line`" + `, then re-run ` + "`gortex review --audience agent`" + ` until the verdict clears. | -| ` + "`review`" + ` | Address the ` + "`warning`" + ` findings or justify each one in the PR thread before merging. | -| ` + "`approve`" + ` | No blocking findings — proceed. | - -When you want the full reasoning behind a verdict (per-file risk, contracts, guards, coverage), drop the ` + "`--audience agent`" + ` flag for the readable human packet, or use the ` + "`/gortex-pr-review`" + ` playbook to walk the ten gates by hand. - -## Checklist - -- ` + "`gortex daemon status`" + ` first — the verb needs a daemon tracking the repo -- ` + "`gortex review --audience agent`" + ` for the terse, parseable summary; add ` + "`--format json`" + ` to branch on fields -- Open each finding at its printed ` + "`file:line`" + ` — the anchor is exact, not approximate -- On ` + "`block`" + `, fix and **re-run the verb** until it clears; do not merge a blocking verdict -- Escalate to the human packet (drop ` + "`--audience agent`" + `) or ` + "`/gortex-pr-review`" + ` when you need the full gate-by-gate reasoning +In either mode, return ` + "`VERDICT: clean`" + ` or ` + "`VERDICT: findings`" + ` followed by actionable findings with severity and ` + "`file:line`" + `. Do not replace graph-backed review with ad-hoc ` + "`git diff`" + ` inspection. ` -// skillGortexCLI is a user-level skill that drives Gortex entirely through -// the `gortex` CLI verb groups — no MCP transport mounted. It exists for the -// case where the MCP surface is unavailable or impractical (a plain shell, a -// CI job, a low-context budget), or where an agent simply prefers to stay on -// the command line. Unlike the other GlobalSkills it does not reuse a shared -// command* constant: it has no slash-command twin, and its body is shell -// commands rather than MCP tool names. The frontmatter carries an -// `allowed-tools: Bash(gortex:*)` line — the Claude Code SKILL.md analogue of -// the sub-agents' `tools:` allowlist — scoping the skill to the gortex binary. const skillGortexCLI = `--- name: gortex-cli -description: "Drive Gortex from the shell via the gortex CLI, no MCP surface." -allowed-tools: Bash(gortex:*) +description: "Bash-only mirror of Gortex MCP tools for harnesses without MCP support." --- +# Gortex from a Bash-Only Harness -# Gortex from the CLI (no MCP surface) - -Every Gortex capability is reachable from the ` + "`gortex`" + ` command, so you can work the graph from a plain shell — no MCP server mounted, no extra tools in your context. Reach for this when the MCP surface is unavailable or impractical (a CI job, a low-context budget, a shell-only agent), or when you simply prefer the command line. - -This is a **toolkit, not a mandate**: pick the verbs your task needs. The sequence below is a reference safety workflow for changing code — run the steps that apply and skip the rest. Most verbs accept ` + "`--format json`" + ` for machine-readable output and ` + "`--repo `" + ` to target a tracked repo other than the cwd. They all talk to a running daemon, so ` + "`gortex daemon status`" + ` first if anything errors. - -## Orient +Use this skill only in a harness that has no MCP transport by design. If a Gortex MCP server is configured but its callable tools are missing, report an integration failure; do not use this skill as a fallback. -` + "```bash" + ` -gortex query stats -` + "```" + ` - -Confirms the daemon is up and prints node / edge counts for the active repo — the CLI analogue of a session-start orientation. - -## Assemble the working set +Every public MCP tool has the same name through ` + "`gortex call`" + `: ` + "```bash" + ` -gortex explore "make the indexer skip vendored files" +gortex call explore --arg task='locate the authentication flow' +gortex call search --arg operation=symbols --arg query=UserStore +gortex call read --arg target='{"symbol":"internal/store.go::UserStore"}' +gortex call relations --arg operation=usages --arg target='{"symbol":"internal/store.go::UserStore"}' +gortex call change --arg operation=impact --arg target='{"symbol":"internal/store.go::UserStore"}' +gortex call edit --arg target='{"file":"internal/store.go"}' --arg match='old text' --arg replacement='new text' +gortex call change --arg operation=detect --arg source='{"scope":"all"}' ` + "```" + ` -Shells smart_context: one call returns the minimal relevant symbols, sources, and an edit plan for a task description. Start here instead of reading files one at a time. Note the symbol IDs it surfaces — later verbs take them as ` + "`--ids`" + ` / ` + "`--symbols`" + `. - -## Pick up prior knowledge - -` + "```bash" + ` -gortex memory surface --task "make the indexer skip vendored files" --symbols internal/indexer/indexer.go::IndexFile,internal/indexer/skip.go::shouldSkip -` + "```" + ` - -Surfaces cross-session invariants, decisions, and gotchas anchored to your working set, ranked by overlap. If it returns nothing, move on — don't probe further. - -## Before editing a file - -` + "```bash" + ` -gortex edit context internal/indexer/indexer.go -` + "```" + ` - -Shows the file's symbols, signatures, callers, and callees so you edit with the blast radius in view. - -## Before a signature change - -` + "```bash" + ` -gortex edit verify --change 'internal/indexer/indexer.go::IndexFile=func(path string, opts Opts) error' -` + "```" + ` - -Checks every caller and interface implementor against the proposed signature and reports what would break. ` + "`--change`" + ` is repeatable (` + "`id=new_signature`" + `); pass a raw JSON array via ` + "`--changes`" + ` / ` + "`--changes-file`" + ` for bulk edits. - -## Plan a multi-symbol refactor - -` + "```bash" + ` -gortex edit plan --ids internal/indexer/indexer.go::IndexFile,internal/indexer/skip.go::shouldSkip -` + "```" + ` - -Returns a dependency-ordered list of files and symbols to touch, so a multi-file change goes in the right order. - -## Apply edits atomically - -` + "```bash" + ` -gortex edit batch --edits-file edits.json -` + "```" + ` - -Applies multiple edits in one atomic, dependency-ordered pass. Build ` + "`edits.json`" + ` as the edits array (or pass it inline with ` + "`--edits`" + `, or on stdin with ` + "`--edits -`" + `); add ` + "`--dry-run`" + ` to preview the unified diff first. - -## After editing - -` + "```bash" + ` -gortex edit guards --ids internal/indexer/indexer.go::IndexFile -gortex edit tests --ids internal/indexer/indexer.go::IndexFile -` + "```" + ` - -` + "`guards`" + ` evaluates team rules against the changed symbols; ` + "`tests`" + ` enumerates the test files and functions that exercise them so you know what to run. - -## Close the loop - -` + "```bash" + ` -gortex feedback record --task "make the indexer skip vendored files" --useful internal/indexer/skip.go::shouldSkip -gortex memory store --kind decision --body "skip lives in shouldSkip; IndexFile stays signature-stable for the daemon hot path" --symbols internal/indexer/skip.go::shouldSkip -` + "```" + ` - -` + "`feedback record`" + ` scores which ` + "`explore`" + ` suggestions were useful / not needed / missing, improving future context quality. ` + "`memory store`" + ` persists a durable invariant / decision / gotcha so the next agent inherits the lesson — set ` + "`--kind`" + ` honestly and anchor it with ` + "`--symbols`" + `. - -## When you need more - -These verbs cover the dev-cycle workhorses. For the long tail of the catalog, discover and invoke any tool directly: +For an exact operation schema: ` + "```bash" + ` -gortex tools list # the whole surface, by category + read/write class -gortex tools search "dataflow taint" # find a tool by keyword -gortex call analyze --arg kind=dead_code --arg path=internal/indexer +gortex call capabilities --arg domain=read --arg operation=source --arg detail=schema ` + "```" + ` -` + "`gortex call --arg key=value`" + ` is the generic escape hatch — it invokes any registered tool by name, so nothing in the MCP surface is out of reach from the shell. There are also dedicated verb groups worth knowing: ` + "`gortex analyze`" + `, ` + "`gortex flow`" + ` / ` + "`gortex taint`" + `, ` + "`gortex clones`" + `, and the rest of the ` + "`gortex edit`" + ` and ` + "`gortex memory`" + ` families. +The required order is ` + "`explore`" + ` → targeted ` + "`search/read/relations/trace`" + ` → pre-change ` + "`change`" + ` → ` + "`edit/refactor`" + ` → post-change ` + "`change`" + `. Do not use shell file reads or search as a substitute. ` diff --git a/internal/agents/claudecode/content_test.go b/internal/agents/claudecode/content_test.go index edf69bada..689d1b2b0 100644 --- a/internal/agents/claudecode/content_test.go +++ b/internal/agents/claudecode/content_test.go @@ -1,17 +1,23 @@ package claudecode import ( + "regexp" "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. +// retains the dedicated CLI path only for a harness with no MCP transport by +// design. A missing configured handle remains an integration failure. func TestCommandPRReviewAgent_ShellsTheReviewVerb(t *testing.T) { for _, want := range []string{ + "Native Gortex MCP is mandatory", + "review({operation: \"run\"", + "configured callable tools are missing", + "Gortex MCP integration failure", + "do not start a daemon or use the Bash path below", + "## Bash-only harness", + "no MCP transport by design", "gortex review --audience agent", "--format json", "VERDICT:", @@ -21,6 +27,53 @@ func TestCommandPRReviewAgent_ShellsTheReviewVerb(t *testing.T) { t.Errorf("commandPRReviewAgent must reference %q so the agent shells the verb and parses its output", want) } } + if strings.Contains(commandPRReviewAgent, "when native Gortex MCP is available") { + t.Error("commandPRReviewAgent must not reinterpret a missing MCP bridge as transport unavailability") + } +} + +func TestGeneratedClaudeContent_UsesOnlyPublicToolDomains(t *testing.T) { + public := map[string]bool{ + "analyze": true, "ask": true, "capabilities": true, "change": true, + "edit": true, "explore": true, "overlay": true, "pr": true, + "publish_review": true, "read": true, "recall": true, "refactor": true, + "relations": true, "remember": true, "response": true, "review": true, + "search": true, "session": true, "trace": true, "workspace": true, + "workspace_admin": true, + } + legacy := []string{ + "smart_context", "search_symbols", "search_text", "read_file", + "get_symbol_source", "get_editing_context", "find_usages", "get_callers", + "get_call_chain", "detect_changes", "explain_change_impact", "verify_change", + "check_guards", "get_test_targets", "preview_edit", "simulate_chain", + "batch_edit", "edit_symbol", "rename_symbol", "get_code_actions", + "tools_search", "tool_profile", "facade-v1", + } + callPattern := regexp.MustCompile(`\b([a-z][a-z0-9_]*)\(\{`) + + artifacts := make(map[string]string, len(SlashCommands)+len(GlobalSkills)) + for name, body := range SlashCommands { + artifacts["command/"+name] = body + } + for name, body := range GlobalSkills { + artifacts["skill/"+name] = body + } + + for name, body := range artifacts { + for _, hidden := range legacy { + if strings.Contains(body, hidden) { + t.Errorf("%s exposes hidden implementation tool %q", name, hidden) + } + } + for _, match := range callPattern.FindAllStringSubmatch(body, -1) { + if !public[match[1]] { + t.Errorf("%s instructs the agent to call non-public tool %q", name, match[1]) + } + } + if len(body) > 6000 { + t.Errorf("%s is not lean: %d bytes (limit 6000)", name, len(body)) + } + } } // TestCommandPRReviewAgent_Registered asserts the agent-review skill is wired diff --git a/internal/agents/claudecode/hooks.go b/internal/agents/claudecode/hooks.go index 32f25dee0..c5174024b 100644 --- a/internal/agents/claudecode/hooks.go +++ b/internal/agents/claudecode/hooks.go @@ -20,12 +20,13 @@ import ( // 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" +// in the hook itself). The compact mcp__gortex__read tool is included so the +// hook can also enforce read shaping after the client has entered Gortex. +const CurrentPreToolUseMatcher = "Read|Grep|Glob|Task|Bash|Edit|Write|mcp__gortex__read" + +// v060PreToolUseMatcher fingerprints the exact matcher shipped by gortex +// v0.60.0. The concrete retirement gate is documented in docs/versioning.md. +const v060PreToolUseMatcher = "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 @@ -45,6 +46,13 @@ const ( HookModeAdaptiveNudge = "nudge" ) +const ( + preToolUseStatusDeny = "Enforcing Gortex graph access policy..." + preToolUseStatusEnrich = "Enriching with Gortex graph context..." + preToolUseStatusConsultUnlock = "Consulting Gortex graph before tool use..." + preToolUseStatusNudge = "Checking Gortex graph guidance..." +) + // 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 @@ -80,6 +88,19 @@ func hookCommandWithMode(base, mode string) string { } } +func preToolUseStatusMessage(mode string) string { + switch normalizeHookMode(mode) { + case HookModeEnrich: + return preToolUseStatusEnrich + case HookModeConsultUnlock: + return preToolUseStatusConsultUnlock + case HookModeAdaptiveNudge: + return preToolUseStatusNudge + default: + return preToolUseStatusDeny + } +} + // 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 @@ -202,12 +223,13 @@ func upgradeGortexMatcher(hooks map[string]any) bool { if !ok { return false } - legacyMatchers := map[string]bool{ + supersededMatchers := 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, + v060PreToolUseMatcher: true, } upgraded := false for _, h := range pre { @@ -216,7 +238,7 @@ func upgradeGortexMatcher(hooks map[string]any) bool { continue } matcher, _ := hm["matcher"].(string) - if !legacyMatchers[matcher] { + if !supersededMatchers[matcher] { continue } if !entryInvokesGortexHook(hm) { @@ -340,6 +362,60 @@ func rewriteGortexHookMode(hooks map[string]any, newCommand string) int { return rewritten } +// rewriteGortexPreToolUseStatus keeps the installer-authored progress text in +// sync with the configured posture. A message outside the known generated set +// belongs to the user and is deliberately left untouched. +func rewriteGortexPreToolUseStatus(hooks map[string]any, mode string) int { + list, ok := hooks["PreToolUse"].([]any) + if !ok { + return 0 + } + desired := preToolUseStatusMessage(mode) + generated := map[string]struct{}{ + preToolUseStatusDeny: {}, + preToolUseStatusEnrich: {}, + preToolUseStatusConsultUnlock: {}, + preToolUseStatusNudge: {}, + } + rewritten := 0 + for _, h := range list { + hm, ok := h.(map[string]any) + if !ok { + continue + } + inner, ok := hm["hooks"].([]any) + if !ok { + continue + } + for _, raw := range inner { + entry, ok := raw.(map[string]any) + if !ok { + continue + } + command, _ := entry["command"].(string) + if !commandInvokesGortexHook(command) { + continue + } + current, exists := entry["statusMessage"] + if exists { + currentText, isString := current.(string) + if !isString { + continue + } + if _, managed := generated[currentText]; !managed { + continue + } + if currentText == desired { + continue + } + } + entry["statusMessage"] = desired + 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 @@ -442,6 +518,7 @@ func InstallHookWithMode(w io.Writer, settingsPath string, mode string, opts age healedCount := healStaleHookCommands(hooks, hookCommand) matcherUpgraded := upgradeGortexMatcher(hooks) modeRewriteCount := rewriteGortexHookMode(hooks, hookCommand) + statusRewriteCount := rewriteGortexPreToolUseStatus(hooks, mode) dedupedCount := dedupGortexEntries(hooks, "PreToolUse") + dedupGortexEntries(hooks, "PreCompact") + dedupGortexEntries(hooks, "PostToolUse") + @@ -472,7 +549,7 @@ func InstallHookWithMode(w io.Writer, settingsPath string, mode string, opts age "type": "command", "command": hookCommand, "timeout": 3000, - "statusMessage": "Enriching with Gortex graph context...", + "statusMessage": preToolUseStatusMessage(mode), }, }, }) @@ -556,7 +633,7 @@ func InstallHookWithMode(w io.Writer, settingsPath string, mode string, opts age allPresent := preToolUseInstalled && preCompactInstalled && stopInstalled && sessionStartInstalled && userPromptSubmitInstalled && (mode != HookModeEnrich || postToolUseInstalled) noChanges := allPresent && !matcherUpgraded && dedupedCount == 0 && healedCount == 0 && - modeRewriteCount == 0 && postToolUseRemoved == 0 && !postToolUseAdded + modeRewriteCount == 0 && statusRewriteCount == 0 && postToolUseRemoved == 0 && !postToolUseAdded if noChanges { if w != nil { fmt.Fprintf(w, "[gortex init] all hooks already present in %s\n", settingsPath) @@ -616,6 +693,9 @@ func InstallHookWithMode(w io.Writer, settingsPath string, mode string, opts age if modeRewriteCount > 0 { changes = append(changes, fmt.Sprintf("rewrote %d hook command(s) for mode=%s", modeRewriteCount, mode)) } + if statusRewriteCount > 0 { + changes = append(changes, fmt.Sprintf("rewrote %d PreToolUse status message(s) for mode=%s", statusRewriteCount, mode)) + } if w != nil { fmt.Fprintf(w, "[gortex init] %s in %s\n", strings.Join(changes, ", "), settingsPath) } diff --git a/internal/agents/claudecode/hooks_status_test.go b/internal/agents/claudecode/hooks_status_test.go new file mode 100644 index 000000000..f55d8496c --- /dev/null +++ b/internal/agents/claudecode/hooks_status_test.go @@ -0,0 +1,79 @@ +package claudecode + +import ( + "io" + "path/filepath" + "strings" + "testing" +) + +func TestPreToolUseStatusMessageReflectsMode(t *testing.T) { + tests := map[string]string{ + HookModeDeny: preToolUseStatusDeny, + HookModeEnrich: preToolUseStatusEnrich, + HookModeConsultUnlock: preToolUseStatusConsultUnlock, + HookModeAdaptiveNudge: preToolUseStatusNudge, + } + for mode, want := range tests { + if got := preToolUseStatusMessage(mode); got != want { + t.Fatalf("mode %q: got %q, want %q", mode, got, want) + } + } +} + +func TestRewriteGortexPreToolUseStatusPreservesCustomMessage(t *testing.T) { + generated := map[string]any{ + "command": "/opt/gortex hook --mode=enrich", + "statusMessage": preToolUseStatusEnrich, + } + custom := map[string]any{ + "command": "/opt/gortex hook --mode=enrich", + "statusMessage": "custom user-set message", + } + hooks := map[string]any{ + "PreToolUse": []any{map[string]any{"hooks": []any{generated, custom}}}, + } + + if got := rewriteGortexPreToolUseStatus(hooks, HookModeDeny); got != 1 { + t.Fatalf("rewritten count = %d, want 1", got) + } + if got := generated["statusMessage"]; got != preToolUseStatusDeny { + t.Fatalf("generated message = %#v, want %q", got, preToolUseStatusDeny) + } + if got := custom["statusMessage"]; got != "custom user-set message" { + t.Fatalf("custom message changed: %#v", got) + } +} + +func TestInstallHookWithModeUpdatesManagedPreToolUseStatus(t *testing.T) { + settingsPath := filepath.Join(t.TempDir(), "settings.local.json") + t.Setenv("PATH", t.TempDir()) + + if _, err := InstallHookWithMode(io.Discard, settingsPath, HookModeDeny, agentsApplyOptsZero()); err != nil { + t.Fatal(err) + } + if got := installedPreToolUseStatus(t, readSettingsHooks(t, settingsPath)); got != preToolUseStatusDeny { + t.Fatalf("deny status = %q, want %q", got, preToolUseStatusDeny) + } + + if _, err := InstallHookWithMode(io.Discard, settingsPath, HookModeEnrich, agentsApplyOptsZero()); err != nil { + t.Fatal(err) + } + if got := installedPreToolUseStatus(t, readSettingsHooks(t, settingsPath)); got != preToolUseStatusEnrich { + t.Fatalf("enrich status = %q, want %q", got, preToolUseStatusEnrich) + } +} + +func TestPluginPreToolUseStatusMatchesDefaultDenyMode(t *testing.T) { + if !strings.Contains(pluginHooksJSON, `"statusMessage": "`+preToolUseStatusDeny+`"`) { + t.Fatalf("plugin PreToolUse status does not describe its default deny command") + } +} + +func installedPreToolUseStatus(t *testing.T, hooks map[string]any) string { + t.Helper() + entries := hooks["PreToolUse"].([]any) + inner := entries[0].(map[string]any)["hooks"].([]any) + status, _ := inner[0].(map[string]any)["statusMessage"].(string) + return status +} diff --git a/internal/agents/claudecode/hooks_test.go b/internal/agents/claudecode/hooks_test.go index 74b4f7c6b..1a966bce7 100644 --- a/internal/agents/claudecode/hooks_test.go +++ b/internal/agents/claudecode/hooks_test.go @@ -387,8 +387,18 @@ func TestUpgradeGortexMatcher_RewritesPreviousCurrent(t *testing.T) { 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") + assert.Contains(t, got, "mcp__gortex__read", + "upgraded matcher must include the compact gortex read tool") + assert.NotContains(t, got, "mcp__gortex__read_file") +} + +func TestUpgradeGortexMatcher_RewritesV060MCPReadMatcher(t *testing.T) { + hooks := map[string]any{ + "PreToolUse": []any{makeHookEntry(v060PreToolUseMatcher, "/opt/gortex hook")}, + } + assert.True(t, upgradeGortexMatcher(hooks)) + got := hooks["PreToolUse"].([]any)[0].(map[string]any)["matcher"].(string) + assert.Equal(t, CurrentPreToolUseMatcher, got) } func TestUpgradeGortexMatcher_NoOpOnCurrent(t *testing.T) { diff --git a/internal/agents/claudecode/install_diet_test.go b/internal/agents/claudecode/install_diet_test.go index 181eab9e3..8116400b4 100644 --- a/internal/agents/claudecode/install_diet_test.go +++ b/internal/agents/claudecode/install_diet_test.go @@ -12,15 +12,15 @@ import ( ) // 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 +// - 4 KiB global section (pointer block + the @-included default +// profile body) ≈ 0.9k 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 + globalSectionByteCeiling = 4096 // 4 KiB skillsEagerByteCeiling = 2560 // 2.5 KiB ) @@ -145,7 +145,10 @@ func TestGlobalInstall_FatToSlimReplacement(t *testing.T) { 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"} { + for _, token := range []string{ + "gortex://guide", "explore", "search", "read", "change", + "capabilities", "recall", "remember", + } { if !strings.Contains(string(activeBody), token) { t.Errorf("active profile body is missing the policy core token %q", token) } diff --git a/internal/agents/claudecode/plugin.go b/internal/agents/claudecode/plugin.go index f2d83e13b..467f7db56 100644 --- a/internal/agents/claudecode/plugin.go +++ b/internal/agents/claudecode/plugin.go @@ -46,7 +46,7 @@ const ( // 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." + pluginDescription = "Your AI's live map of your codebase — every call, dependency, and contract indexed across repositories and shared through a local daemon. Exposes 21 focused MCP tools for exploration, graph navigation, change safety, editing, refactoring, and review without flooding the agent with one schema per operation. Works with Claude Code and any MCP-aware client. Real AST parsing across 92 languages is deterministic and local-first, with confidence-tagged results and no LLM calls during indexing. Apache 2.0 licensed." pluginAuthorName = "Andrey Kumanyaev" pluginAuthorEmail = "support@gortex.dev" pluginHomepage = "https://gortex.dev" @@ -94,7 +94,7 @@ const pluginHooksJSON = `{ "type": "command", "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks-handlers/gortex-hook.sh\"", "timeout": 3000, - "statusMessage": "Enriching with Gortex graph context..." + "statusMessage": "Enforcing Gortex graph access policy..." } ] } @@ -165,10 +165,10 @@ 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. +Gortex is a graph-based code intelligence engine. This plugin connects Claude +Code to 21 focused MCP tools for exploration, navigation, change safety, +editing, refactoring, and review across 92 languages. A shared graph daemon +keeps agents, harnesses, and editor sessions in sync. ## Install @@ -186,21 +186,21 @@ installs to ` + "`~/.local/bin`" + ` (or ` + "`/usr/local/bin`" + `), and runs ` | 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`" + ` | +| **MCP server** | 21 tools over stdio via ` + "`gortex mcp`" + `: begin with ` + "`explore`" + `, inspect with ` + "`search`" + ` / ` + "`read`" + ` / ` + "`relations`" + ` / ` + "`trace`" + `, assess with ` + "`analyze`" + ` / ` + "`change`" + ` / ` + "`review`" + `, and mutate with ` + "`edit`" + ` / ` + "`refactor`" + `. ` + "`capabilities`" + ` returns an operation's exact schema on demand. | | **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 | +| **Skills** | Twenty model-invoked, task-shaped workflows require callable native MCP, start with ` + "`explore`" + `, gate mutations with ` + "`change`" + `, write through ` + "`edit`" + ` / ` + "`refactor`" + `, and verify the result. The separate CLI skill mirrors those names through ` + "`gortex call`" + ` only for a harness with no MCP transport by design. | +| **Hooks** | PreToolUse graph routing, PreCompact orientation, Stop post-task diagnostics, and SessionStart briefing. Hook guidance uses the same public MCP tool names. | ## 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. +"what breaks if I rename ` + "`UserStore`" + `?"). Begin with ` + "`explore`" + `; the +slash commands provide short, ordered workflows for common tasks. -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. +The MCP server owns graph startup and should expose the tools without a manual +daemon step. If configured tools are missing or unreachable, report an MCP +integration failure; do not start a daemon manually or switch to a CLI fallback. ## Links diff --git a/internal/agents/claudecode/plugin_test.go b/internal/agents/claudecode/plugin_test.go index 753e659ed..703b71f75 100644 --- a/internal/agents/claudecode/plugin_test.go +++ b/internal/agents/claudecode/plugin_test.go @@ -129,6 +129,30 @@ func TestEmitPluginBundle_MCPJSONShape(t *testing.T) { } } +func TestPluginReadme_DescribesPublicAgentSurface(t *testing.T) { + for _, want := range []string{ + "21 tools", + "`explore`", + "`search` / `read` / `relations` / `trace`", + "`edit` / `refactor`", + "`capabilities`", + "`gortex call`", + } { + if !strings.Contains(pluginReadmeBody, want) { + t.Errorf("plugin README must contain %q", want) + } + } + for _, forbidden := range []string{ + "52 tools", "search_symbols", "find_usages", "get_call_chain", + "rename_symbol", "preview_edit", "simulate_chain", "facade-v1", + "gortex daemon start", + } { + if strings.Contains(pluginReadmeBody, forbidden) { + t.Errorf("plugin README must not expose %q", forbidden) + } + } +} + func TestEmitPluginBundle_HooksShape(t *testing.T) { dir := t.TempDir() if _, err := EmitPluginBundle(PluginBundleSpec{ diff --git a/internal/agents/claudecode/subagents.go b/internal/agents/claudecode/subagents.go index bc5221992..0a7770239 100644 --- a/internal/agents/claudecode/subagents.go +++ b/internal/agents/claudecode/subagents.go @@ -2,32 +2,15 @@ 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. +// SubAgents maps the filename under .claude/agents/ to a graph-only +// sub-agent definition. Each allowlist names tools present on the compact MCP +// surface received by every named client. 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). +// SubAgentTools parses the tools allowlist from YAML frontmatter. func SubAgentTools(def string) []string { for _, line := range strings.Split(def, "\n") { t := strings.TrimSpace(line) @@ -46,59 +29,37 @@ func SubAgentTools(def string) []string { 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 +tools: mcp__gortex__capabilities, mcp__gortex__explore, mcp__gortex__search, mcp__gortex__read, mcp__gortex__relations, mcp__gortex__trace, mcp__gortex__analyze, mcp__gortex__recall, mcp__gortex__workspace --- -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. +Answer the delegated code-navigation question using only Gortex. -Your tool surface is graph-only. There is no Bash, Grep, or Glob. Use the graph: +1. Call ` + "`explore`" + ` with ` + "`operation: \"task\"`" + ` and the delegated task. +2. Use ` + "`search`" + ` with ` + "`operation: \"symbols\"`" + ` for names or ` + "`operation: \"text\"`" + ` for exact literals. +3. Use ` + "`read`" + ` with ` + "`operation: \"source\"`" + ` or ` + "`operation: \"summary\"`" + `; do not request unrelated whole files. +4. Use ` + "`relations`" + ` for callers, usages, dependencies, dependents, or implementations. Use ` + "`trace`" + ` for call chains and dataflow. +5. Use ` + "`analyze`" + ` for architecture, communities, processes, or a named graph analysis. +6. Call ` + "`capabilities`" + ` with ` + "`detail: \"schema\"`" + ` if an operation's exact arguments are not visible. -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. +Return the answer first, then symbol IDs and file:line evidence, then caveats. Do not dump raw tool output. ` -// 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 +description: "Assess a change's blast radius, contracts, guards, and tests." +tools: mcp__gortex__capabilities, mcp__gortex__explore, mcp__gortex__search, mcp__gortex__read, mcp__gortex__relations, mcp__gortex__trace, mcp__gortex__analyze, mcp__gortex__change, mcp__gortex__recall, mcp__gortex__workspace --- -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). +Convert the delegated change into a concise, graph-grounded impact report. Do not mutate files. -Pass ` + "`format: \"gcx\"`" + ` on list-shaped responses for ~27% fewer tokens. +1. Resolve the working set with ` + "`explore`" + `; use ` + "`search`" + ` and ` + "`read`" + ` only to disambiguate targets. +2. Call ` + "`change`" + ` with ` + "`operation: \"impact\"`" + `. For signature changes also call ` + "`operation: \"verify\"`" + `. +3. Use ` + "`relations`" + ` for callers, usages, and dependents; use ` + "`analyze`" + ` with ` + "`kind: \"contracts\"`" + ` when a boundary may change. +4. Call ` + "`change`" + ` with ` + "`operation: \"guards\"`" + ` and ` + "`operation: \"tests\"`" + `. +5. Call ` + "`capabilities`" + ` with ` + "`detail: \"schema\"`" + ` if an operation's exact arguments are not visible. -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. +Return: one-line safe/risky/breaking verdict; broken callers, contracts, or guards with file:line; then exact tests to run. ` diff --git a/internal/agents/claudecode/subagents_test.go b/internal/agents/claudecode/subagents_test.go index 94fb2b4a0..a2f98f4f8 100644 --- a/internal/agents/claudecode/subagents_test.go +++ b/internal/agents/claudecode/subagents_test.go @@ -28,13 +28,25 @@ func TestSubAgentToolPropagation(t *testing.T) { 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) + for _, required := range []string{"mcp__gortex__capabilities", "mcp__gortex__explore"} { + require.Truef(t, seen[required], "%s should grant compact entry point %s", name, required) + } + for _, legacy := range []string{"mcp__gortex__smart_context", "mcp__gortex__search_symbols", "mcp__gortex__read_file"} { + require.Falsef(t, seen[legacy], "%s should not grant legacy tool %s", name, legacy) + } }) } } +func TestSubAgentBodiesUseOnlyCompactVocabulary(t *testing.T) { + for name, def := range SubAgents { + for _, legacy := range []string{"smart_context", "search_symbols", "get_symbol_source", "find_usages", "get_callers", "verify_change", "read_file"} { + require.NotContainsf(t, def, legacy, "%s contains legacy MCP tool %q", name, legacy) + } + require.NotContains(t, def, "facade-v1") + } +} + // TestSubAgentFrontmatterNameMatchesFile guards against a rename drifting the // frontmatter `name:` away from the installed filename. func TestSubAgentFrontmatterNameMatchesFile(t *testing.T) { diff --git a/internal/agents/claudecode/v060_artifact_hashes.go b/internal/agents/claudecode/v060_artifact_hashes.go new file mode 100644 index 000000000..d120af5a1 --- /dev/null +++ b/internal/agents/claudecode/v060_artifact_hashes.go @@ -0,0 +1,93 @@ +package claudecode + +import ( + "crypto/sha256" + "errors" + "fmt" + "io" + "os" + + "github.com/zzet/gortex/internal/agents" +) + +// These hashes are byte-for-byte fingerprints of the artifacts shipped by +// gortex v0.60.0 before the compact public surface. They let upgrades replace +// only untouched Gortex files while preserving every customized copy. The +// concrete retirement gate is documented in docs/versioning.md. +var v060GlobalSkillHashes = map[string]string{ + "gortex-add-test": "ac7088f6146395d15e70b011e4150a1708614e03763618202cb65367f807018c", + "gortex-architecture-review": "01594df88d730aab382dd3b2ef5d99c4cb03ce62ed1e04e56ef02049325efdcd", + "gortex-cli": "8c71c2290a1695fc2c2461575ff949f0d33a659fff2d291e728c8109fb81d16b", + "gortex-co-change": "3bdf72fac5521b25785d059b80d3b8e37ed42c0128469cb69d0ce92723ea236c", + "gortex-cross-repo-usage": "4039ab9879a065b6c203796a0a4f0a3c28994e50f1e15524cf678e5123d9641e", + "gortex-dataflow-trace": "4eb7f28c754758b5926d7cd2b4cad630fa60aa98d3fbb81f49369174b9abb100", + "gortex-debug": "3af79a0b0be77d0660ca8f8cb0343500cff6743b6e9fcfddbc4d542afdffd167", + "gortex-episode-replay": "d120d0948085837bf7a1066d16e0250999eff8f0f1acc067b970e51c9fddd598", + "gortex-explore": "0edc3c1fbd82483b993f960f4947c07bbcb6c0f5d3d230fd459425e69e874b97", + "gortex-extract-function": "637c84c094f221b3841685c5165c2eebc34ef7ebd06a5af07c54c7c600467ccd", + "gortex-fix-all": "33fc9b35567033434354188704aba70f952b7950e3df6b512d4df0adf87f9089", + "gortex-guide": "d7cba2d96a2bba7c96df17e2f410e8de140764ec24c466123621b34cbb2ed7c2", + "gortex-impact": "2111222dc7fcd4227db773ccb5ac8dca45f867f3ad2fba88b260569d5237a1ed", + "gortex-incident-investigation": "30572b4534c5aa17a17789b52e1a74ab142e0b81e78b3fb85af207d4835d69f3", + "gortex-onboarding": "8803580cfefdbf45bb3a87e760340134f41dc5378cb5881a33bc7bfc054a448a", + "gortex-pr-review": "f87a000a7776e4db83c20f62372659efb354a4bca5d800cdd520ba05c1316cbe", + "gortex-pr-review-agent": "2be09fca75a79f7574cb57c5990aa87e27d72845cb8888a47b25e3129123ebb0", + "gortex-quality-audit": "51424a58bf9bbe9769997524feff7d5db07878d7012e49e8a5fcb855484b311a", + "gortex-refactor": "5d72e85a53df7cade5776cf0499ea5a2cb5a61f93bb89121abb5e829e1648eb6", + "gortex-rename": "6416d2ee5a1c8c6c4231c4e7bc2de92cd6723bd968b4d72969b446df3920f807", + "gortex-safe-edit": "ccfc21a7ffc81ef8407b97cd54509f18226a09ee46a0d8fa3acc4265422f935e", +} + +var v060SlashCommandHashes = map[string]string{ + "gortex-add-test.md": "5dc170d682bfd0e9023b1d4f906378aa9bd017d11d1891e12166bc39f231a6d8", + "gortex-architecture-review.md": "79e168a3bf9a7cc015a565b33b9e78befc96fc3ddf57f2de716caa5490154f6c", + "gortex-co-change.md": "95a0176b2d231295d0936aa7e2334ee55ffa6aeddbd728f920732145cda3b784", + "gortex-cross-repo-usage.md": "00bdcb9545f95d2bcc30f67a815760577610fca6947b4d90aa80c75386f6bdcd", + "gortex-dataflow-trace.md": "a6d4d3a2d19ac9d628b1b3821f40e85bd17608690d2e8275011c5c3d857b8547", + "gortex-debug.md": "4236422d3c14d3f0faf57648e00aa32e7c98e0c00f68dd29dfaa7c5c3a84a70f", + "gortex-episode-replay.md": "3ff36a94763529100b7acb0ee24d1134eb33ebe6884d98ff4fbd424510d69e40", + "gortex-explore.md": "9154a6bbcfc60c198890f29355faea3d8cf398c6d574b9d8ada905121663f39c", + "gortex-extract-function.md": "aa0d3c453b5e2ed5ec8bc28ef83e2ed6983126b53c79bcecdba205d7ff8c4e08", + "gortex-fix-all.md": "a3dd6bfa6a8a14915e766685e051e319b4193ba8e52d74811af0cb7fa5ebb58b", + "gortex-guide.md": "068a0f2e7911bf0404888235490c97baf3a6632e9ef8eced171b09543851f480", + "gortex-impact.md": "4c02060ae0868363d27c9b4b42a6a54ac432ac39e1687d4c2b77d5e4d740ee9d", + "gortex-incident-investigation.md": "7a5b5d2be17eb76477acefc9beb41752b07cfb6a15aaa50e427a500a4b956638", + "gortex-onboarding.md": "4f09948418b3754d03321a81201185f744b55bb850cd2d18a5ace11708c97fe6", + "gortex-pr-review-agent.md": "776a9af046c26556561f8b9e9f92ac5c4737507c6380d5c274507294e9f72d26", + "gortex-pr-review.md": "0954c788915b270646c9593b1a4d890411a408bf1a6eaf76b3e40d8eb05dcfe0", + "gortex-quality-audit.md": "bd6bdb90fa9466c836c95755724bd359a7aae51d6818d7941da3063c2eee197b", + "gortex-refactor.md": "313fdc2f7daf28891ce19a94164c71a6e00eae225d76143251f3794b4d1d7a34", + "gortex-rename.md": "5700aa7b1bdaba861c8e7f5b59623f17a49935de6bd8203bf7142a58499cbcd3", + "gortex-safe-edit.md": "5e487527a55816d64c23cc0fa080e024af3a771ed68e96c28a16940693f0b91d", +} + +var v060SubAgentHashes = map[string]string{ + "gortex-impact.md": "bcf2bebeee1a932d1896e14a8de8ae8892191aed8683e233ca1d8ffa5276dae5", + "gortex-search.md": "40cb39e36419993b2d75a9a13f363542bf392168e2d299e201165da7459f715b", +} + +func artifactHash(content []byte) string { + return fmt.Sprintf("%x", sha256.Sum256(content)) +} + +func isShippedAgentArtifact(existing []byte, current, migrationHash string) bool { + return string(existing) == current || (migrationHash != "" && artifactHash(existing) == migrationHash) +} + +func writeAgentArtifact(w io.Writer, path, current, migrationHash string, opts agents.ApplyOpts) (agents.FileAction, error) { + existing, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return agents.WriteIfNotExists(w, path, current, opts) + } + if err != nil { + return agents.FileAction{}, fmt.Errorf("read %s: %w", path, err) + } + if string(existing) == current { + return agents.FileAction{Path: path, Action: agents.ActionSkip, Reason: "unchanged"}, nil + } + if migrationHash != "" && artifactHash(existing) == migrationHash { + return agents.WriteOwnedFile(w, path, current, opts) + } + logWarn(w, "keeping customised agent artifact %s", path) + return agents.FileAction{Path: path, Action: agents.ActionSkip, Reason: "customised"}, nil +} diff --git a/internal/agents/claudecode/v060_artifact_hashes_test.go b/internal/agents/claudecode/v060_artifact_hashes_test.go new file mode 100644 index 000000000..6e5021b9c --- /dev/null +++ b/internal/agents/claudecode/v060_artifact_hashes_test.go @@ -0,0 +1,71 @@ +package claudecode + +import ( + "os" + "path/filepath" + "testing" + + "github.com/zzet/gortex/internal/agents" +) + +func TestV060ArtifactHashCatalogCoversShippedArtifacts(t *testing.T) { + for name := range GlobalSkills { + if v060GlobalSkillHashes[name] == "" { + t.Errorf("missing v0.60.0 skill fingerprint for %s", name) + } + } + for name := range SlashCommands { + if v060SlashCommandHashes[name] == "" { + t.Errorf("missing v0.60.0 command fingerprint for %s", name) + } + } + for name := range SubAgents { + if v060SubAgentHashes[name] == "" { + t.Errorf("missing v0.60.0 sub-agent fingerprint for %s", name) + } + } +} + +func TestWriteAgentArtifactMigratesOnlyExactV060Bytes(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "SKILL.md") + v060 := []byte("exact old Gortex artifact\n") + current := "new public-tool artifact\n" + if err := os.WriteFile(path, v060, 0o644); err != nil { + t.Fatal(err) + } + + action, err := writeAgentArtifact(nil, path, current, artifactHash(v060), agents.ApplyOpts{}) + if err != nil { + t.Fatal(err) + } + if action.Action != agents.ActionMerge { + t.Fatalf("v0.60.0 action = %s, want merge", action.Action) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(got) != current { + t.Fatalf("v0.60.0 artifact was not migrated: %q", got) + } + + custom := []byte("user customized policy\n") + if err := os.WriteFile(path, custom, 0o644); err != nil { + t.Fatal(err) + } + action, err = writeAgentArtifact(nil, path, current, artifactHash(v060), agents.ApplyOpts{Force: true}) + if err != nil { + t.Fatal(err) + } + if action.Action != agents.ActionSkip || action.Reason != "customised" { + t.Fatalf("custom action = %+v, want customized skip", action) + } + got, err = os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(got) != string(custom) { + t.Fatalf("custom artifact was overwritten: %q", got) + } +} diff --git a/internal/agents/cline/adapter.go b/internal/agents/cline/adapter.go index c2f55b550..4c7562172 100644 --- a/internal/agents/cline/adapter.go +++ b/internal/agents/cline/adapter.go @@ -42,23 +42,21 @@ func settingsPaths(home string) []string { 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{ +// alwaysAllow is Cline's coarse tool-level auto-approve list. It follows the +// compact MCP surface while leaving external review publishing unapproved. +var alwaysAllow = agents.CompactMCPAutoApproveTools() + +// v060AlwaysAllow is the exact approval list shipped by gortex v0.60.0. It is +// only a migration fingerprint; customized lists are preserved. The concrete +// retirement gate is documented in docs/versioning.md. +var v060AlwaysAllow = []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", + "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) { @@ -114,7 +112,7 @@ func (a *Adapter) Apply(env agents.Env, opts agents.ApplyOpts) (*agents.Result, 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 + return agents.UpsertMCPServerApprovalList(root, "gortex", "alwaysAllow", alwaysAllow, entry, opts, v060AlwaysAllow), nil }, opts) if err != nil { internalutil.Warnf(env.Stderr, "could not configure Cline at %s: %v", path, err) diff --git a/internal/agents/cline/adapter_test.go b/internal/agents/cline/adapter_test.go new file mode 100644 index 000000000..d984cb942 --- /dev/null +++ b/internal/agents/cline/adapter_test.go @@ -0,0 +1,15 @@ +package cline + +import ( + "slices" + "testing" + + "github.com/zzet/gortex/internal/agents" +) + +func TestAlwaysAllowUsesSafeCompactSurface(t *testing.T) { + want := agents.CompactMCPAutoApproveTools() + if !slices.Equal(alwaysAllow, want) { + t.Fatalf("alwaysAllow=%v want safe compact tools %v", alwaysAllow, want) + } +} diff --git a/internal/agents/codex/adapter.go b/internal/agents/codex/adapter.go index 598bba27e..c33417e4c 100644 --- a/internal/agents/codex/adapter.go +++ b/internal/agents/codex/adapter.go @@ -18,23 +18,43 @@ import ( "os" "os/exec" "path/filepath" + "reflect" + "slices" "strings" "github.com/zzet/gortex/internal/agents" "github.com/zzet/gortex/internal/agents/internalutil" + "github.com/zzet/gortex/internal/mcp" + "github.com/zzet/gortex/internal/version" ) -const Name = "codex" -const DocsURL = "https://developers.openai.com/codex/mcp" +const ( + Name = "codex" + DocsURL = "https://developers.openai.com/codex/mcp" +) + +const ( + codexGortexToolNamespace = "mcp__gortex" + codexGortexNonPrefixedToolNamespace = "gortex" + codexMCPStartupTimeoutSeconds = 90 + codexDirectToolNamespacesMinMinor = 142 +) 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 + +// v060CodexSessionStart* fingerprints the static hook shipped by gortex +// v0.60.0 so an upgrade replaces it instead of installing a duplicate. The +// concrete retirement gate is documented in docs/versioning.md. +const ( + v060CodexSessionStartMessage = "IMPORTANT: Prefer Gortex MCP tools (search_symbols, get_callers, get_file_summary, edit_file) over Read/Grep/Glob/Edit." + v060CodexSessionStartCommand = "printf '%s\\n' '" + v060CodexSessionStartMessage + "'" + v060CodexSessionStartWindowsCommand = "powershell -NoProfile -Command \"Write-Output '" + v060CodexSessionStartMessage + "'\"" + codexPreToolUseMatcher = "^Bash$" + codexMCPReadPreToolUseMatcher = "^mcp__gortex__read$" + codexPostToolUseMatcher = "^(Bash|apply_patch)$" + codexHookTimeoutSeconds = 5 + codexHookModeEnvVar = "GORTEX_CODEX_HOOK_MODE" +) type Adapter struct{} @@ -59,7 +79,7 @@ func (a *Adapter) Detect(env agents.Env) (bool, error) { func (a *Adapter) Plan(env agents.Env) (*agents.Plan, error) { p := &agents.Plan{} if env.Home != "" { - keys := []string{"mcp_servers"} + keys := []string{"mcp_servers", "features.code_mode"} if env.InstallHooks { keys = append(keys, "hooks") } @@ -93,21 +113,13 @@ func (a *Adapter) Apply(env agents.Env, opts agents.ApplyOpts) (*agents.Result, 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", - }, + changed := upsertCodexMCPServer(root, opts) + if supported, detectedVersion := codexSupportsDirectToolNamespaces(); supported || codexHasDirectToolNamespaces(root) { + if upsertCodexDirectToolNamespaces(root) { + changed = true } - root["mcp_servers"] = servers - changed = true + } else { + internalutil.Warnf(env.Stderr, "Codex %s does not support direct MCP namespaces; upgrade to Codex 0.%d+ to keep Gortex tools eager", detectedVersion, codexDirectToolNamespacesMinMinor) } if env.InstallHooks { @@ -140,8 +152,282 @@ func (a *Adapter) Apply(env agents.Env, opts agents.ApplyOpts) (*agents.Result, return res, nil } -func upsertSessionStartHook(root map[string]any, opts agents.ApplyOpts) bool { - return upsertCodexHook(root, "SessionStart", codexHookEntryIsGortexSessionStart, codexSessionStartHookEntry(), opts) +func codexHasDirectToolNamespaces(root map[string]any) bool { + features, ok := root["features"].(map[string]any) + if !ok { + return false + } + codeMode, ok := features["code_mode"].(map[string]any) + if !ok { + return false + } + _, exists := codeMode["direct_only_tool_namespaces"] + return exists +} + +// upsertCodexMCPServer makes Gortex a required Codex dependency and gives its +// first-start daemon path enough time to publish the initial snapshot. Codex's +// default MCP startup timeout can expire before that path's 60-second wait. +// Existing Gortex-authored launch, environment, approval, and tool-timeout +// settings are preserved; only these two availability invariants are managed. +func upsertCodexMCPServer(root map[string]any, opts agents.ApplyOpts) bool { + servers, ok := root["mcp_servers"].(map[string]any) + if !ok { + servers = make(map[string]any) + } + desired := map[string]any{ + "command": "gortex", + "args": []string{"mcp"}, + "required": true, + "startup_timeout_sec": codexMCPStartupTimeoutSeconds, + "env": map[string]any{ + "GORTEX_INDEX_WORKERS": "8", + }, + } + existing, exists := servers["gortex"] + if !exists || opts.Force { + servers["gortex"] = desired + root["mcp_servers"] = servers + return true + } + if !agents.IsGortexAuthoredMCPEntry(existing) { + return false + } + entry, ok := existing.(map[string]any) + if !ok { + return false + } + changed := migrateCodexFacadeToolApprovals(entry) + if required, _ := entry["required"].(bool); !required { + entry["required"] = true + changed = true + } + if !codexStartupTimeoutAtLeast(entry["startup_timeout_sec"], codexMCPStartupTimeoutSeconds) { + entry["startup_timeout_sec"] = codexMCPStartupTimeoutSeconds + changed = true + } + if changed { + servers["gortex"] = entry + root["mcp_servers"] = servers + } + return changed +} + +// migrateCodexFacadeToolApprovals upgrades per-tool approval entries from the +// legacy one-tool-per-operation surface to the compact public facade. Entries +// for current facade tools, unknown extension tools, and user-defined fields +// are preserved. Several legacy tools can collapse into one facade tool; when +// their approval modes disagree, prompt is the conservative merged posture. +func migrateCodexFacadeToolApprovals(entry map[string]any) bool { + tools, ok := entry["tools"].(map[string]any) + if !ok || len(tools) == 0 { + return false + } + + legacyNames := make([]string, 0, len(tools)) + for name := range tools { + facade, _, recognized := mcp.PublicOperationForLegacy(name) + if recognized && facade != name { + legacyNames = append(legacyNames, name) + } + } + if len(legacyNames) == 0 { + return false + } + slices.Sort(legacyNames) + + changed := false + for _, legacyName := range legacyNames { + facade, _, _ := mcp.PublicOperationForLegacy(legacyName) + legacyValue := tools[legacyName] + legacyTable, validTable := legacyValue.(map[string]any) + if !validTable { + // Do not delete malformed or future config shapes that we cannot + // migrate without losing user intent. + continue + } + + if currentValue, exists := tools[facade]; !exists { + tools[facade] = cloneCodexToolApproval(legacyTable) + } else if currentTable, ok := currentValue.(map[string]any); ok { + mergeCodexToolApproval(currentTable, legacyTable) + } else { + // A non-table public entry is not a documented Codex shape. Keep it + // and the legacy entry rather than guessing which one the user owns. + continue + } + delete(tools, legacyName) + changed = true + } + return changed +} + +func cloneCodexToolApproval(source map[string]any) map[string]any { + clone := make(map[string]any, len(source)) + for key, value := range source { + clone[key] = value + } + return clone +} + +func mergeCodexToolApproval(current, incoming map[string]any) { + for key, value := range incoming { + if key == "approval_mode" { + continue + } + if _, exists := current[key]; !exists { + current[key] = value + } + } + + incomingMode, incomingHasMode := incoming["approval_mode"] + currentMode, currentHasMode := current["approval_mode"] + switch { + case !currentHasMode && incomingHasMode: + current["approval_mode"] = incomingMode + case currentHasMode && incomingHasMode && !reflect.DeepEqual(currentMode, incomingMode): + current["approval_mode"] = "prompt" + } +} + +func codexStartupTimeoutAtLeast(value any, minimum int) bool { + switch n := value.(type) { + case int: + return n >= minimum + case int8: + return int(n) >= minimum + case int16: + return int(n) >= minimum + case int32: + return int(n) >= minimum + case int64: + return n >= int64(minimum) + case uint: + return n >= uint(minimum) + case uint8: + return uint(n) >= uint(minimum) + case uint16: + return uint(n) >= uint(minimum) + case uint32: + return uint(n) >= uint(minimum) + case uint64: + return n >= uint64(minimum) + case float32: + return n >= float32(minimum) + case float64: + return n >= float64(minimum) + default: + return false + } +} + +// codexVersionOutput is a seam for hermetic adapter tests. +var codexVersionOutput = func() ([]byte, error) { + path, err := exec.LookPath("codex") + if err != nil { + return nil, err + } + return exec.Command(path, "--version").Output() +} + +func codexSupportsDirectToolNamespaces() (supported bool, detectedVersion string) { + out, err := codexVersionOutput() + if err != nil { + // Codex App and IDE installs do not necessarily put their bundled + // CLI on PATH. Treat an unknown install as current so those surfaces + // retain automatic direct exposure; only a positively identified old + // CLI is gated below. + return true, "" + } + for _, token := range strings.Fields(string(out)) { + token = strings.Trim(token, "(),") + parsed, parseErr := version.Parse(token) + if parseErr != nil { + continue + } + detectedVersion = strings.TrimPrefix(token, "v") + return parsed.Major > 0 || (parsed.Major == 0 && parsed.Minor >= codexDirectToolNamespacesMinMinor), detectedVersion + } + return true, "" +} + +// upsertCodexDirectToolNamespaces keeps Gortex's compact MCP facade in the +// model-facing tool manifest. Codex 0.142+ can otherwise defer MCP tools +// behind tool search when the active model supports it. The namespace list is +// additive so user-selected direct namespaces and all other code-mode fields +// survive; the old boolean feature form is upgraded without changing its value. +// Both exact namespace spellings are installed because Codex's opt-in +// non_prefixed_mcp_tool_names feature changes `mcp__gortex` to `gortex`. +func upsertCodexDirectToolNamespaces(root map[string]any) bool { + changed := false + for _, namespace := range []string{codexGortexToolNamespace, codexGortexNonPrefixedToolNamespace} { + if upsertCodexDirectToolNamespace(root, namespace) { + changed = true + } + } + return changed +} + +func upsertCodexDirectToolNamespace(root map[string]any, namespace string) bool { + features, ok := root["features"].(map[string]any) + if !ok { + if _, exists := root["features"]; exists { + return false + } + features = make(map[string]any) + } + + var codeMode map[string]any + switch existing := features["code_mode"].(type) { + case nil: + codeMode = make(map[string]any) + case bool: + codeMode = map[string]any{"enabled": existing} + case map[string]any: + codeMode = existing + default: + return false + } + + const field = "direct_only_tool_namespaces" + namespaces, valid := codexStringList(codeMode[field]) + if !valid { + return false + } + for _, existing := range namespaces { + if existing == namespace { + return false + } + } + codeMode[field] = append(namespaces, namespace) + features["code_mode"] = codeMode + root["features"] = features + return true +} + +func codexStringList(value any) ([]string, bool) { + switch list := value.(type) { + case nil: + return nil, true + case []string: + return append([]string(nil), list...), true + case []any: + out := make([]string, 0, len(list)) + for _, value := range list { + s, ok := value.(string) + if !ok { + return nil, false + } + out = append(out, s) + } + return out, true + default: + return nil, false + } +} + +func upsertSessionStartHook(root map[string]any, env agents.Env, opts agents.ApplyOpts) bool { + return upsertCodexHookSet(root, "SessionStart", codexHookEntryIsGortexSessionStart, []map[string]any{codexSessionStartHookEntry(env)}, opts) } func upsertPreToolUseHook(root map[string]any, env agents.Env, opts agents.ApplyOpts) bool { @@ -153,11 +439,11 @@ func upsertPreToolUseHook(root map[string]any, env agents.Env, opts agents.Apply } func upsertPostToolUseHook(root map[string]any, env agents.Env, opts agents.ApplyOpts) bool { - return upsertCodexHook(root, "PostToolUse", codexHookEntryIsGortexPostToolUse, codexPostToolUseHookEntry(env), opts) + return upsertCodexHookSet(root, "PostToolUse", codexHookEntryIsGortexPostToolUse, []map[string]any{codexPostToolUseHookEntry(env)}, opts) } func upsertUserPromptSubmitHook(root map[string]any, env agents.Env, opts agents.ApplyOpts) bool { - return upsertCodexHook(root, "UserPromptSubmit", codexHookEntryIsGortexUserPromptSubmit, codexUserPromptSubmitHookEntry(env), opts) + return upsertCodexHookSet(root, "UserPromptSubmit", codexHookEntryIsGortexUserPromptSubmit, []map[string]any{codexUserPromptSubmitHookEntry(env)}, opts) } // InstallHooksOnly refreshes the Codex lifecycle hooks in configPath without @@ -176,47 +462,13 @@ func InstallHooksOnly(w io.Writer, configPath string, env agents.Env, opts agent } func upsertCodexHooks(root map[string]any, env agents.Env, opts agents.ApplyOpts) bool { - sessionChanged := upsertSessionStartHook(root, opts) + sessionChanged := upsertSessionStartHook(root, env, 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 { @@ -233,22 +485,30 @@ func upsertCodexHookSet(root map[string]any, event string, isGortex func(any) bo found := make([]bool, len(desired)) kept := make([]any, 0, len(entries)+len(desired)) + changed := false for _, entry := range entries { if isGortex(entry) { if opts.Force { continue } + matched := false for i, want := range desired { if !found[i] && codexHookEntryMatchesDesired(entry, want) { found[i] = true + matched = true break } } + if !matched { + // This is a Gortex-authored hook with a stale matcher, command, + // or posture. Replace it instead of accumulating duplicate hooks. + changed = true + continue + } } kept = append(kept, entry) } - changed := false for i, want := range desired { if opts.Force || !found[i] { kept = append(kept, want) @@ -266,7 +526,30 @@ func upsertCodexHookSet(root map[string]any, event string, isGortex func(any) bo func codexHookEntryMatchesDesired(entry any, desired map[string]any) bool { matcher, _ := desired["matcher"].(string) - return codexHookEntryHasMatcher(entry, matcher) && codexHookEntryInvokesCodexHook(entry) + if !codexHookEntryHasMatcher(entry, matcher) { + return false + } + want := codexHookEntryCommand(desired) + return want != "" && codexHookEntryCommand(entry) == want +} + +func codexHookEntryCommand(entry any) string { + group, ok := entry.(map[string]any) + if !ok { + return "" + } + handlers, ok := codexHookList(group["hooks"]) + if !ok { + return "" + } + for _, handler := range handlers { + if fields, ok := handler.(map[string]any); ok { + if command, _ := fields["command"].(string); strings.TrimSpace(command) != "" { + return command + } + } + } + return "" } func codexHookEntryHasMatcher(entry any, matcher string) bool { @@ -310,10 +593,10 @@ func codexHookEntryIsGortexSessionStart(entry any) bool { if !ok { continue } - if cmd, _ := hm["command"].(string); cmd == codexSessionStartCommand { + if cmd, _ := hm["command"].(string); cmd == v060CodexSessionStartCommand || codexCommandInvokesCodexHook(cmd) { return true } - if cmd, _ := hm["command_windows"].(string); cmd == codexSessionStartWindowsCommand { + if cmd, _ := hm["command_windows"].(string); cmd == v060CodexSessionStartWindowsCommand { return true } } @@ -365,16 +648,15 @@ func codexCommandInvokesCodexHook(cmd string) bool { return strings.Contains(cmd, "--agent=codex") || strings.Contains(cmd, "--agent codex") } -func codexSessionStartHookEntry() map[string]any { +func codexSessionStartHookEntry(env agents.Env) 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...", + "type": "command", + "command": codexHookCommand(env), + "timeout": codexHookTimeoutSeconds, + "statusMessage": "Loading Gortex graph orientation...", }, }, } @@ -416,7 +698,7 @@ func codexPostToolUseHookEntry(env agents.Env) map[string]any { "type": "command", "command": codexHookCommand(env), "timeout": codexHookTimeoutSeconds, - "statusMessage": "Loading Gortex Bash output context...", + "statusMessage": "Loading Gortex post-tool context...", }, }, } @@ -449,5 +731,23 @@ func codexHookCommand(env agents.Env) string { if base == "" { base = "gortex hook" } - return base + " --agent=codex --mode=enrich" + return base + " --agent=codex --mode=" + codexHookMode() +} + +// codexHookMode keeps the shipped posture advisory while allowing a team to +// opt into current Codex capabilities without changing the generic Claude hook +// posture. Supported values: enrich, deny, rewrite, suppress. Suppress uses +// PostToolUse result replacement because Codex does not yet implement the +// suppressOutput field itself. +func codexHookMode() string { + switch strings.ToLower(strings.TrimSpace(os.Getenv(codexHookModeEnvVar))) { + case "deny", "hard-deny": + return "deny" + case "rewrite", "input-rewrite": + return "rewrite" + case "suppress", "replace-output", "output-suppression": + return "suppress" + default: + return "enrich" + } } diff --git a/internal/agents/codex/adapter_approval_test.go b/internal/agents/codex/adapter_approval_test.go new file mode 100644 index 000000000..3649fe633 --- /dev/null +++ b/internal/agents/codex/adapter_approval_test.go @@ -0,0 +1,85 @@ +package codex + +import ( + "reflect" + "testing" +) + +func TestMigrateCodexFacadeToolApprovalsPreservesUserIntent(t *testing.T) { + unknown := map[string]any{"approval_mode": "company-policy", "owner": "user"} + tools := map[string]any{ + "read": map[string]any{ + "approval_mode": "writes", + "owner": "public", + }, + "get_symbol_source": map[string]any{ + "approval_mode": "auto", + "legacy_field": true, + }, + "private_extension": unknown, + } + entry := map[string]any{"tools": tools} + + if !migrateCodexFacadeToolApprovals(entry) { + t.Fatal("expected recognized legacy approval to migrate") + } + if _, exists := tools["get_symbol_source"]; exists { + t.Fatal("recognized legacy approval was not pruned") + } + read := tools["read"].(map[string]any) + if got := read["approval_mode"]; got != "prompt" { + t.Fatalf("conflicting modes must become prompt, got %#v", got) + } + if got := read["owner"]; got != "public" { + t.Fatalf("public facade fields must win, got owner %#v", got) + } + if got := read["legacy_field"]; got != true { + t.Fatalf("non-conflicting custom legacy field was lost: %#v", got) + } + if !reflect.DeepEqual(tools["private_extension"], unknown) { + t.Fatalf("unknown tool config changed: %#v", tools["private_extension"]) + } +} + +func TestMigrateCodexFacadeToolApprovalsMergesLegacyEntries(t *testing.T) { + tools := map[string]any{ + "edit_file": map[string]any{ + "approval_mode": "approve", + "file_policy": "kept", + }, + "edit_symbol": map[string]any{ + "approval_mode": "approve", + "symbol_policy": "kept", + }, + } + entry := map[string]any{"tools": tools} + + if !migrateCodexFacadeToolApprovals(entry) { + t.Fatal("expected legacy approvals to migrate") + } + if len(tools) != 1 { + t.Fatalf("expected one facade approval, got %#v", tools) + } + edit, ok := tools["edit"].(map[string]any) + if !ok { + t.Fatalf("edit facade approval missing: %#v", tools) + } + if edit["approval_mode"] != "approve" || edit["file_policy"] != "kept" || edit["symbol_policy"] != "kept" { + t.Fatalf("merged approval lost intent: %#v", edit) + } +} + +func TestMigrateCodexFacadeToolApprovalsKeepsUnknownShapes(t *testing.T) { + tools := map[string]any{ + "get_symbol_source": "future-codex-shape", + "read": map[string]any{"approval_mode": "auto"}, + } + entry := map[string]any{"tools": tools} + + if migrateCodexFacadeToolApprovals(entry) { + t.Fatal("unsupported config shape must be left untouched") + } + if got := tools["get_symbol_source"]; got != "future-codex-shape" { + t.Fatalf("unsupported legacy shape changed: %#v", got) + } +} diff --git a/internal/agents/codex/adapter_test.go b/internal/agents/codex/adapter_test.go index c9a377c4b..a8b880aab 100644 --- a/internal/agents/codex/adapter_test.go +++ b/internal/agents/codex/adapter_test.go @@ -13,6 +13,7 @@ import ( ) const testCodexHookCommand = "/tmp/test-gortex hook --agent=codex --mode=enrich" +const v060CodexMCPReadPreToolUseMatcher = "^mcp__gortex__(read_file|get_editing_context)$" // TestCodexWritesMcpServersTOMLTable verifies we produce the // documented [mcp_servers.gortex] table — not a legacy @@ -57,6 +58,255 @@ func TestCodexWritesMcpServersTOMLTable(t *testing.T) { agentstest.AssertIdempotent(t, a, env) } +func TestCodexMakesGortexToolsDirectAndRequired(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) + servers := cfg["mcp_servers"].(map[string]any) + server := servers["gortex"].(map[string]any) + if server["required"] != true { + t.Fatalf("mcp_servers.gortex.required=%v want true", server["required"]) + } + if server["startup_timeout_sec"] != int64(codexMCPStartupTimeoutSeconds) { + t.Fatalf("mcp_servers.gortex.startup_timeout_sec=%v want %d", server["startup_timeout_sec"], codexMCPStartupTimeoutSeconds) + } + + features := cfg["features"].(map[string]any) + codeMode := features["code_mode"].(map[string]any) + namespaces, ok := codexStringList(codeMode["direct_only_tool_namespaces"]) + if !ok || len(namespaces) != 2 || namespaces[0] != codexGortexToolNamespace || namespaces[1] != codexGortexNonPrefixedToolNamespace { + t.Fatalf("direct_only_tool_namespaces=%#v want [%q %q]", codeMode["direct_only_tool_namespaces"], codexGortexToolNamespace, codexGortexNonPrefixedToolNamespace) + } + if _, exists := codeMode["enabled"]; exists { + t.Fatalf("Gortex must not enable Codex's experimental code mode: %#v", codeMode) + } +} + +func TestCodexAvailabilityMergePreservesCustomConfig(t *testing.T) { + env := codexGlobalEnv(t) + env.InstallHooks = false + path := codexConfigPath(env) + seed := `model = "gpt-5.4" + +[features.code_mode] +enabled = false +excluded_tool_namespaces = ["mcp__private"] +direct_only_tool_namespaces = ["mcp__history"] + +[mcp_servers.gortex] +command = "gortex" +args = ["mcp", "--custom"] +required = false +startup_timeout_sec = 15 +tool_timeout_sec = 321 + +[mcp_servers.gortex.env] +CUSTOM_GORTEX = "yes" + +[mcp_servers.other] +command = "other" +` + if err := os.WriteFile(path, []byte(seed), 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) + } + cfg := readCodexConfig(t, env) + if cfg["model"] != "gpt-5.4" { + t.Fatalf("unrelated top-level config changed: %#v", cfg) + } + servers := cfg["mcp_servers"].(map[string]any) + server := servers["gortex"].(map[string]any) + if server["command"] != "gortex" { + t.Fatalf("custom command changed: %#v", server) + } + args, ok := codexStringList(server["args"]) + if !ok || len(args) != 2 || args[1] != "--custom" { + t.Fatalf("custom args changed: %#v", server["args"]) + } + if server["tool_timeout_sec"] != int64(321) { + t.Fatalf("custom tool timeout changed: %#v", server) + } + if server["required"] != true { + t.Fatalf("required=%v want true", server["required"]) + } + if server["startup_timeout_sec"] != int64(codexMCPStartupTimeoutSeconds) { + t.Fatalf("startup timeout=%v want %d", server["startup_timeout_sec"], codexMCPStartupTimeoutSeconds) + } + envMap := server["env"].(map[string]any) + if envMap["CUSTOM_GORTEX"] != "yes" { + t.Fatalf("custom environment changed: %#v", envMap) + } + if _, exists := servers["other"]; !exists { + t.Fatalf("unrelated MCP server removed: %#v", servers) + } + + features := cfg["features"].(map[string]any) + codeMode := features["code_mode"].(map[string]any) + if codeMode["enabled"] != false { + t.Fatalf("code_mode.enabled changed: %#v", codeMode) + } + excluded, ok := codexStringList(codeMode["excluded_tool_namespaces"]) + if !ok || len(excluded) != 1 || excluded[0] != "mcp__private" { + t.Fatalf("excluded namespaces changed: %#v", codeMode) + } + direct, ok := codexStringList(codeMode["direct_only_tool_namespaces"]) + if !ok || len(direct) != 3 || direct[0] != "mcp__history" || direct[1] != codexGortexToolNamespace || direct[2] != codexGortexNonPrefixedToolNamespace { + t.Fatalf("direct namespaces=%#v", direct) + } + + 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}) +} + +func TestCodexDirectNamespaceUpgradesBooleanFeatureForm(t *testing.T) { + root := map[string]any{ + "features": map[string]any{"code_mode": true}, + } + if !upsertCodexDirectToolNamespaces(root) { + t.Fatal("expected boolean code_mode form to be upgraded") + } + features := root["features"].(map[string]any) + codeMode := features["code_mode"].(map[string]any) + if codeMode["enabled"] != true { + t.Fatalf("boolean feature value not preserved: %#v", codeMode) + } + namespaces, ok := codexStringList(codeMode["direct_only_tool_namespaces"]) + if !ok || len(namespaces) != 2 || namespaces[0] != codexGortexToolNamespace || namespaces[1] != codexGortexNonPrefixedToolNamespace { + t.Fatalf("direct namespaces=%#v", namespaces) + } +} + +func TestCodexPreservesUserOwnedGortexServer(t *testing.T) { + env := codexGlobalEnv(t) + env.InstallHooks = false + path := codexConfigPath(env) + seed := `[mcp_servers.gortex] +command = "company-gortex-wrapper" +args = ["serve", "--company-policy"] +custom = "keep" +` + if err := os.WriteFile(path, []byte(seed), 0o644); err != nil { + t.Fatalf("seed config: %v", err) + } + + if _, err := New().Apply(env, agents.ApplyOpts{}); err != nil { + t.Fatalf("apply: %v", err) + } + cfg := readCodexConfig(t, env) + server := cfg["mcp_servers"].(map[string]any)["gortex"].(map[string]any) + if server["command"] != "company-gortex-wrapper" || server["custom"] != "keep" { + t.Fatalf("user-owned server changed: %#v", server) + } + if _, exists := server["required"]; exists { + t.Fatalf("user-owned server gained managed required policy: %#v", server) + } + if _, exists := server["startup_timeout_sec"]; exists { + t.Fatalf("user-owned server gained managed startup timeout: %#v", server) + } + features := cfg["features"].(map[string]any) + codeMode := features["code_mode"].(map[string]any) + namespaces, ok := codexStringList(codeMode["direct_only_tool_namespaces"]) + if !ok || len(namespaces) != 2 { + t.Fatalf("Gortex server namespace should still be direct: %#v", codeMode) + } +} + +func TestCodexMCPServerPreservesLongerStartupTimeout(t *testing.T) { + root := map[string]any{ + "mcp_servers": map[string]any{ + "gortex": map[string]any{ + "command": "gortex", + "args": []any{"mcp"}, + "required": true, + "startup_timeout_sec": int64(180), + }, + }, + } + if upsertCodexMCPServer(root, agents.ApplyOpts{}) { + t.Fatal("a longer user-selected startup timeout should already satisfy the invariant") + } + server := root["mcp_servers"].(map[string]any)["gortex"].(map[string]any) + if server["startup_timeout_sec"] != int64(180) { + t.Fatalf("longer startup timeout changed: %#v", server) + } +} + +func TestCodexDirectNamespaceVersionGate(t *testing.T) { + for _, tc := range []struct { + name string + output string + supported bool + version string + }{ + {name: "current", output: "codex-cli 0.144.1\n", supported: true, version: "0.144.1"}, + {name: "first supported minor", output: "codex-cli 0.142.0\n", supported: true, version: "0.142.0"}, + {name: "unsupported", output: "codex-cli 0.141.0\n", supported: false, version: "0.141.0"}, + {name: "future major", output: "codex-cli v1.0.0\n", supported: true, version: "1.0.0"}, + {name: "unparseable app or IDE version", output: "codex-cli dev\n", supported: true, version: ""}, + } { + t.Run(tc.name, func(t *testing.T) { + stubCodexVersion(t, tc.output) + supported, detected := codexSupportsDirectToolNamespaces() + if supported != tc.supported || detected != tc.version { + t.Fatalf("support=(%v, %q) want (%v, %q)", supported, detected, tc.supported, tc.version) + } + }) + } +} + +func TestCodexOldVersionSkipsUnsupportedDirectNamespaceConfig(t *testing.T) { + env := codexGlobalEnv(t) + env.InstallHooks = false + stubCodexVersion(t, "codex-cli 0.141.0\n") + + if _, err := New().Apply(env, agents.ApplyOpts{ForceDetect: true}); err != nil { + t.Fatalf("apply: %v", err) + } + cfg := readCodexConfig(t, env) + if _, exists := cfg["features"]; exists { + t.Fatalf("Codex 0.141 must not receive unsupported features.code_mode fields: %#v", cfg["features"]) + } + server := cfg["mcp_servers"].(map[string]any)["gortex"].(map[string]any) + if server["required"] != true || server["startup_timeout_sec"] != int64(codexMCPStartupTimeoutSeconds) { + t.Fatalf("version gate must not weaken MCP startup safety: %#v", server) + } +} + +func TestCodexOldVersionMergesExistingDirectNamespaceField(t *testing.T) { + env := codexGlobalEnv(t) + env.InstallHooks = false + stubCodexVersion(t, "codex-cli 0.141.0\n") + path := codexConfigPath(env) + seed := `[features.code_mode] +direct_only_tool_namespaces = ["mcp__history"] +` + if err := os.WriteFile(path, []byte(seed), 0o644); err != nil { + t.Fatalf("seed config: %v", err) + } + + if _, err := New().Apply(env, agents.ApplyOpts{}); err != nil { + t.Fatalf("apply: %v", err) + } + cfg := readCodexConfig(t, env) + codeMode := cfg["features"].(map[string]any)["code_mode"].(map[string]any) + direct, ok := codexStringList(codeMode["direct_only_tool_namespaces"]) + if !ok || len(direct) != 3 || direct[0] != "mcp__history" || direct[1] != codexGortexToolNamespace || direct[2] != codexGortexNonPrefixedToolNamespace { + t.Fatalf("existing supported field was not merged: %#v", codeMode) + } +} + func TestCodexInstallsSessionStartHook(t *testing.T) { env := codexGlobalEnv(t) a := New() @@ -84,21 +334,15 @@ func TestCodexInstallsSessionStartHook(t *testing.T) { 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"] != testCodexHookCommand { + t.Errorf("command=%v want %q", handler["command"], testCodexHookCommand) } - if handler["command_windows"] != codexSessionStartWindowsCommand { - t.Errorf("command_windows=%v want %q", handler["command_windows"], codexSessionStartWindowsCommand) + if _, exists := handler["command_windows"]; exists { + t.Errorf("SessionStart should use the same managed hook command on every platform: %#v", handler) } 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"]) + if !codexCommandInvokesCodexHook(command) { + t.Errorf("SessionStart must flow through the managed Codex hook for effectiveness telemetry: %v", handler["command"]) } } @@ -156,6 +400,9 @@ func TestCodexInstallsPostToolUseHook(t *testing.T) { if entry["matcher"] != codexPostToolUseMatcher { t.Fatalf("matcher=%v want %q", entry["matcher"], codexPostToolUseMatcher) } + if !strings.Contains(codexPostToolUseMatcher, "apply_patch") { + t.Fatalf("PostToolUse matcher must cover mutation-aware apply_patch handling: %q", codexPostToolUseMatcher) + } handlers, ok := codexHookList(entry["hooks"]) if !ok || len(handlers) != 1 { t.Fatalf("handlers=%#v", entry["hooks"]) @@ -173,6 +420,40 @@ func TestCodexInstallsPostToolUseHook(t *testing.T) { } } +func TestCodexHookModeIsOptInAndMigratesInPlace(t *testing.T) { + env := codexGlobalEnv(t) + a := New() + if got := codexHookMode(); got != "enrich" { + t.Fatalf("default Codex posture=%q want enrich", got) + } + t.Setenv(codexHookModeEnvVar, "deny") + if _, err := a.Apply(env, agents.ApplyOpts{}); err != nil { + t.Fatal(err) + } + cfg := readCodexConfig(t, env) + if !hasHookCommand(t, cfg, "PreToolUse", "/tmp/test-gortex hook --agent=codex --mode=deny") { + t.Fatalf("deny posture not installed: %#v", preToolUseEntries(t, cfg)) + } + if !hasSessionStartCommand(t, cfg, "/tmp/test-gortex hook --agent=codex --mode=deny") { + t.Fatalf("SessionStart did not migrate to deny command: %#v", sessionStartEntries(t, cfg)) + } + + t.Setenv(codexHookModeEnvVar, "rewrite") + if _, err := a.Apply(env, agents.ApplyOpts{}); err != nil { + t.Fatal(err) + } + cfg = readCodexConfig(t, env) + if !hasHookCommand(t, cfg, "PreToolUse", "/tmp/test-gortex hook --agent=codex --mode=rewrite") { + t.Fatalf("rewrite posture not installed: %#v", preToolUseEntries(t, cfg)) + } + if hasHookCommand(t, cfg, "PreToolUse", "/tmp/test-gortex hook --agent=codex --mode=deny") { + t.Fatalf("stale deny hook survived posture migration: %#v", preToolUseEntries(t, cfg)) + } + if count := gortexPreToolUseHookCount(t, cfg); count != 2 { + t.Fatalf("posture migration duplicated PreToolUse hooks: %d", count) + } +} + func TestCodexInstallsUserPromptSubmitHook(t *testing.T) { env := codexGlobalEnv(t) a := New() @@ -446,6 +727,52 @@ func TestCodexSessionStartHookIdempotent(t *testing.T) { } } +func TestCodexUpgradesV060CompactSurfaceHooks(t *testing.T) { + env := codexGlobalEnv(t) + path := codexConfigPath(env) + seed := `[[hooks.SessionStart]] +matcher = "startup|resume|clear|compact" + +[[hooks.SessionStart.hooks]] +type = "command" +command = "` + strings.ReplaceAll(v060CodexSessionStartCommand, `\`, `\\`) + `" + +[[hooks.PreToolUse]] +matcher = "^Bash$" + +[[hooks.PreToolUse.hooks]] +type = "command" +command = "` + testCodexHookCommand + `" + +[[hooks.PreToolUse]] +matcher = "^mcp__gortex__(read_file|get_editing_context)$" + +[[hooks.PreToolUse.hooks]] +type = "command" +command = "` + testCodexHookCommand + `" +` + 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{}); err != nil { + t.Fatalf("upgrade hooks: %v", err) + } + cfg := readCodexConfig(t, env) + if len(sessionStartEntries(t, cfg)) != 1 { + t.Fatalf("v0.60.0 SessionStart should update in place: %#v", sessionStartEntries(t, cfg)) + } + if !hasSessionStartCommand(t, cfg, testCodexHookCommand) { + t.Fatalf("managed SessionStart hook missing after static-command migration: %#v", sessionStartEntries(t, cfg)) + } + if count := hookMatcherCommandCount(t, cfg, "PreToolUse", codexMCPReadPreToolUseMatcher, testCodexHookCommand); count != 1 { + t.Fatalf("compact read matcher count=%d want 1: %#v", count, preToolUseEntries(t, cfg)) + } + if count := hookMatcherCommandCount(t, cfg, "PreToolUse", v060CodexMCPReadPreToolUseMatcher, testCodexHookCommand); count != 0 { + t.Fatalf("v0.60.0 read matcher survived upgrade: %#v", preToolUseEntries(t, cfg)) + } +} + func TestCodexSessionStartHookPreservesExistingConfig(t *testing.T) { env := codexGlobalEnv(t) path := codexConfigPath(env) @@ -617,6 +944,8 @@ func TestCodexNoHooksSkipsSessionStartHook(t *testing.T) { func codexGlobalEnv(t *testing.T) agents.Env { t.Helper() + t.Setenv(codexHookModeEnvVar, "") + stubCodexVersion(t, "codex-cli 0.144.1\n") env, _ := agentstest.NewEnv(t) env.Mode = agents.ModeGlobal if err := os.MkdirAll(filepath.Join(env.Home, ".codex"), 0o755); err != nil { @@ -625,6 +954,13 @@ func codexGlobalEnv(t *testing.T) agents.Env { return env } +func stubCodexVersion(t *testing.T, output string) { + t.Helper() + previous := codexVersionOutput + codexVersionOutput = func() ([]byte, error) { return []byte(output), nil } + t.Cleanup(func() { codexVersionOutput = previous }) +} + func codexConfigPath(env agents.Env) string { return filepath.Join(env.Home, ".codex", "config.toml") } diff --git a/internal/agents/cursor/adapter.go b/internal/agents/cursor/adapter.go index 4715884ea..810be0cdf 100644 --- a/internal/agents/cursor/adapter.go +++ b/internal/agents/cursor/adapter.go @@ -118,27 +118,17 @@ func workflowRulePath(env agents.Env) string { // project-specific rules. const workflowRuleBody = `## Gortex in Cursor -This repository wires the **gortex** MCP server via .cursor/mcp.json (merge-managed by Gortex). +Use Gortex MCP tools for indexed code. This is mandatory. -**MANDATORY: use graph tools, not blind file reads** +1. Start every coding task with **explore** using ` + "`operation: \"task\"`" + ` and the user's task text. +2. Use **search**, **read**, **relations**, and **trace** instead of text search or whole-file source reads. +3. Before mutation, call **change** with ` + "`operation: \"impact\"`" + `; for a signature change, also call operation ` + "`verify`" + ` with the proposed signature. +4. Mutate only with **edit** or **refactor**. After mutation, call **change** operations ` + "`detect`" + `, ` + "`tests`" + `, ` + "`guards`" + `, and ` + "`contract`" + `. +5. Call **capabilities** with ` + "`domain`" + `, ` + "`operation`" + `, and ` + "`detail: \"schema\"`" + ` when exact arguments are not visible. -You **MUST** prefer Gortex graph queries over text search and whole-file opens on every task. These are not suggestions. +If the configured Gortex tools are missing from the callable MCP tools, report a Gortex MCP integration failure and stop. Do not start a daemon or use a CLI/shell fallback. -- **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:\"\"`" + ` to surface prior warnings and decisions. - -**MANDATORY: development memories (cross-session)** - -- **Immediately after smart_context** on every task, call **surface_memories** with ` + "`task:\"\"`" + ` and ` + "`symbol_ids:\"\"`" + ` — 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:\"\"`" + `, ` + "`symbol_ids:\"\"`" + `, ` + "`importance:5`" + `. Pin load-bearing memories. Use ` + "`supersedes:\"\"`" + ` when newer knowledge replaces older. -- Memories are workspace-wide and outlive sessions, agents, and teammates — every future agent inherits them. +Use **recall** before editing known code and **remember** for durable decisions or invariants. ` func (a *Adapter) Apply(env agents.Env, opts agents.ApplyOpts) (*agents.Result, error) { diff --git a/internal/agents/cursor/adapter_test.go b/internal/agents/cursor/adapter_test.go index 81592cc17..f218a75d7 100644 --- a/internal/agents/cursor/adapter_test.go +++ b/internal/agents/cursor/adapter_test.go @@ -3,12 +3,29 @@ package cursor import ( "os" "path/filepath" + "strings" "testing" "github.com/zzet/gortex/internal/agents" "github.com/zzet/gortex/internal/agents/agentstest" ) +func TestWorkflowRuleUsesCompactMCPTools(t *testing.T) { + for _, required := range []string{"explore", "search", "read", "relations", "trace", "change", "edit", "refactor", "capabilities"} { + if !strings.Contains(workflowRuleBody, required) { + t.Errorf("workflow rule missing compact tool %q", required) + } + } + for _, legacy := range []string{"smart_context", "search_symbols", "get_symbol_source", "find_usages", "get_callers", "verify_change", "read_file"} { + if strings.Contains(workflowRuleBody, legacy) { + t.Errorf("workflow rule contains legacy MCP tool %q", legacy) + } + } + if strings.Contains(workflowRuleBody, "facade-v1") { + t.Error("workflow rule should not expose an implementation version") + } +} + // TestCursorCreatesMergesAndSkips covers the three behavioural // phases every adapter must honour: // - initial create on an empty project (via .cursor/ sentinel) diff --git a/internal/agents/hermes/adapter.go b/internal/agents/hermes/adapter.go index 690a313e8..5d2d70b42 100644 --- a/internal/agents/hermes/adapter.go +++ b/internal/agents/hermes/adapter.go @@ -139,9 +139,9 @@ func (a *Adapter) Apply(env agents.Env, opts agents.ApplyOpts) (*agents.Result, // 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) + // mirroring the Claude Code user-level skill set. User-authored files are + // preserved; exact legacy Gortex vocabulary is migrated in place. + masterAction, err := writeHermesSkillArtifact(env.Stderr, skillPath(env.Home, SkillName), SkillBody(), opts) if err != nil { return res, fmt.Errorf("hermes skill: %w", err) } @@ -149,7 +149,7 @@ func (a *Adapter) Apply(env agents.Env, opts agents.ApplyOpts) (*agents.Result, routing := RoutingSkills() for _, name := range RoutingSkillNames() { - action, rerr := agents.WriteIfNotExists(env.Stderr, skillPath(env.Home, name), routing[name], opts) + action, rerr := writeHermesSkillArtifact(env.Stderr, skillPath(env.Home, name), routing[name], opts) if rerr != nil { internalutil.Warnf(env.Stderr, "hermes skill %s: %v", name, rerr) continue @@ -161,6 +161,28 @@ func (a *Adapter) Apply(env agents.Env, opts agents.ApplyOpts) (*agents.Result, return res, nil } +func writeHermesSkillArtifact(w io.Writer, path, content string, opts agents.ApplyOpts) (agents.FileAction, error) { + if existing, err := os.ReadFile(path); err == nil && isLegacyHermesSkill(string(existing)) { + return agents.WriteOwnedFile(w, path, content, opts) + } + return agents.WriteIfNotExists(w, path, content, opts) +} + +func isLegacyHermesSkill(body string) bool { + if !strings.HasPrefix(body, "---\nname: gortex") { + return false + } + for _, legacy := range []string{ + "smart_context", "search_symbols", "get_symbol_source", "get_editing_context", + "find_usages", "get_callers", "verify_change", "detect_changes", "tools_search", + } { + if strings.Contains(body, legacy) { + return true + } + } + return false +} + // 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 diff --git a/internal/agents/hermes/adapter_test.go b/internal/agents/hermes/adapter_test.go index b4611790e..b46b47058 100644 --- a/internal/agents/hermes/adapter_test.go +++ b/internal/agents/hermes/adapter_test.go @@ -84,7 +84,7 @@ func TestHermesApplyWritesGlobalConfigAndSkill(t *testing.T) { t.Fatalf("skill missing: %v", err) } for _, want := range []string{ - "name: gortex", "metadata:", "hermes:", "set_active_project", + "name: gortex", "metadata:", "hermes:", "workspace_admin", "platforms: [linux, macos, windows]", // standard Hermes frontmatter "related_skills:", // links to the routing playbooks "## Task playbooks", // slash-command discoverability @@ -94,10 +94,39 @@ func TestHermesApplyWritesGlobalConfigAndSkill(t *testing.T) { t.Errorf("skill missing %q", want) } } + for _, legacy := range []string{"smart_context", "search_symbols", "get_symbol_source", "find_usages", "get_callers", "verify_change", "read_file"} { + if strings.Contains(string(skill), legacy) { + t.Errorf("skill contains legacy MCP tool %q", legacy) + } + } agentstest.AssertIdempotent(t, a, env) } +func TestHermesMigratesLegacyOwnedSkill(t *testing.T) { + env, _ := agentstest.NewEnv(t) + seedHermesHome(t, env.Home) + path := skillPath(env.Home, SkillName) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + legacy := "---\nname: gortex\ndescription: old\n---\nCall smart_context and search_symbols.\n" + if err := os.WriteFile(path, []byte(legacy), 0o644); err != nil { + t.Fatal(err) + } + + if _, err := New().Apply(env, agents.ApplyOpts{}); err != nil { + t.Fatalf("apply: %v", err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(got), "smart_context") || !strings.Contains(string(got), "explore") { + t.Fatalf("legacy skill was not migrated:\n%s", got) + } +} + // 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 @@ -155,6 +184,14 @@ func TestHermesInstallsRoutingSkills(t *testing.T) { 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) } + for _, legacy := range []string{"smart_context", "search_symbols", "get_symbol_source", "get_editing_context", "find_usages", "get_callers", "verify_change", "detect_changes", "tools_search"} { + if strings.Contains(string(data), legacy) { + t.Errorf("%s: routing skill contains legacy MCP tool %q", name, legacy) + } + } + if strings.Contains(string(data), "facade-v1") { + t.Errorf("%s: routing skill exposes an implementation version", name) + } } // A couple of representative routing skills must be present. diff --git a/internal/agents/hermes/content.go b/internal/agents/hermes/content.go index 0e33e4844..c725f10d8 100644 --- a/internal/agents/hermes/content.go +++ b/internal/agents/hermes/content.go @@ -76,18 +76,11 @@ const SkillName = "gortex" // 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. +// masterSkillRaw is the lean, operation-oriented master guide emitted for +// every new Hermes integration. 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." +description: "Use Gortex for indexed-code exploration, reads, relationships, impact checks, edits, and refactors." version: 1.0.0 metadata: hermes: @@ -95,92 +88,20 @@ metadata: category: code-intelligence --- -# Gortex 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. +Use Gortex MCP tools for indexed code. This is mandatory. -## When to Use +1. Start every coding task with ` + "`explore`" + ` using ` + "`operation: \"task\"`" + ` and the task text. +2. Use ` + "`search`" + ` for symbols, text, files, or AST shapes. Use ` + "`read`" + ` for source, summaries, files, or editing context. +3. Use ` + "`relations`" + ` for usages, callers, dependencies, dependents, and implementations. Use ` + "`trace`" + ` for call chains and dataflow. +4. Before mutation, call ` + "`change`" + ` with ` + "`operation: \"impact\"`" + `; for a signature change, also call operation ` + "`verify`" + ` with the proposed signature. +5. Mutate only with ` + "`edit`" + ` or ` + "`refactor`" + `. After mutation, call ` + "`change`" + ` operations ` + "`detect`" + `, ` + "`tests`" + `, ` + "`guards`" + `, and ` + "`contract`" + `. +6. Call ` + "`capabilities`" + ` with ` + "`domain`" + `, ` + "`operation`" + `, and ` + "`detail: \"schema\"`" + ` when exact arguments are not visible. -- 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. +Do not replace graph reads or searches with terminal commands. If the configured Gortex tools are missing from the callable MCP tools, report a Gortex MCP integration failure and stop; do not start a daemon or use a CLI/shell fallback. -## 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 `" + `). -- 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 --arg k=v`" + ` (e.g. ` + "`gortex call read_file --arg path=`" + `) — there is no bare ` + "`gortex `" + ` 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 (~30–40% 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. +Use ` + "`workspace`" + ` for local index, repository, and project state. Use ` + "`workspace_admin`" + ` only when the user asks to change that state. Use ` + "`recall`" + ` before editing known code and ` + "`remember`" + ` for durable decisions or invariants. ` // SkillBody renders the master gortex skill: the static guide diff --git a/internal/agents/instructions.go b/internal/agents/instructions.go index 83ebdc72b..0683ac406 100644 --- a/internal/agents/instructions.go +++ b/internal/agents/instructions.go @@ -29,6 +29,10 @@ import ( // like AGENTS.md) we skip to stay idempotent. const InstructionsSentinel = "## MANDATORY: Use Gortex MCP tools" +// BashInstructionsSentinel identifies the separate policy installed only by +// harness adapters that have no native MCP transport. +const BashInstructionsSentinel = "## MANDATORY: Use the Gortex public CLI mirror" + // 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 @@ -66,36 +70,43 @@ func GlobalPointerBody(instructionsDir string) string { "Switch guidance depth with `gortex instructions switch ` (`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. +// InstructionsBody is the shared, agent-neutral rule block every doc-aware +// adapter writes. It names only the compact public tools and treats a missing +// callable handle as an integration failure; transport versions, +// implementation aliases, and cross-transport fallbacks do not belong in an +// MCP-capable agent's working context. 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. +For every coding task: + +1. Call ` + "`" + `explore` + "`" + ` first with the complete task. Work from the returned source and call paths; do not reopen them with file or shell tools. +2. Inspect indexed code only with ` + "`" + `search` + "`" + `, ` + "`" + `read` + "`" + `, ` + "`" + `relations` + "`" + `, and ` + "`" + `trace` + "`" + `. Never use Read/Grep/Glob or shell equivalents for indexed source. +3. Before mutation, call ` + "`" + `change(operation:"impact")` + "`" + `; for a signature change, also call ` + "`" + `change(operation:"verify")` + "`" + ` with the proposed signature. Mutate only with ` + "`" + `edit` + "`" + ` or ` + "`" + `refactor` + "`" + `. After mutation, call ` + "`" + `change(operation:"detect")` + "`" + `, then use its symbol IDs with ` + "`" + `change(operation:"tests")` + "`" + `, ` + "`" + `change(operation:"guards")` + "`" + `, and ` + "`" + `change(operation:"contract")` + "`" + `. +4. Call ` + "`" + `capabilities` + "`" + ` only when you need the exact fields for an operation. -**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:` + "`" + `. +Common calls: -| 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` + "`" + ` | +- ` + "`" + `search({operation:"symbols", query:""})` + "`" + ` +- ` + "`" + `read({target:{symbol:"::"}})` + "`" + ` +- ` + "`" + `relations({operation:"usages", target:{symbol:""}})` + "`" + ` +- ` + "`" + `edit({target:{file:""}, match:"", replacement:""})` + "`" + ` -**CLI fallback (no MCP):** every tool above is reachable from a shell as ` + "`" + `gortex call --arg k=v` + "`" + ` (e.g. ` + "`" + `gortex call read_file --arg path=` + "`" + `) — there is no bare ` + "`" + `gortex ` + "`" + ` verb. +If the Gortex server is configured but these tools are missing from the callable MCP tools, report a Gortex MCP integration failure and stop. Do not start a daemon or switch to a CLI/shell fallback. + +Use ` + "`" + `recall` + "`" + ` before revisiting prior work and ` + "`" + `remember` + "`" + ` immediately for durable decisions, invariants, or gotchas. +` -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. +// BashInstructionsBody is used only by harnesses that genuinely have no MCP +// transport. It mirrors the same compact public surface through the CLI rather +// than teaching MCP-capable agents to silently change transports. +const BashInstructionsBody = BashInstructionsSentinel + ` instead of raw source reads/searches -**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. +This harness has no native MCP transport. Invoke public Gortex tools only as ` + "`" + `gortex call ` + "`" + `; never invent a bare ` + "`" + `gortex ` + "`" + ` command. -**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. +1. Start every coding task with ` + "`" + `gortex call explore --arg task=""` + "`" + `. +2. Inspect with ` + "`" + `gortex call search` + "`" + `, ` + "`" + `gortex call read` + "`" + `, ` + "`" + `gortex call relations` + "`" + `, or ` + "`" + `gortex call trace` + "`" + ` instead of Read/Grep/Glob or shell equivalents. +3. Before mutation call ` + "`" + `gortex call change --arg operation=impact` + "`" + `. Mutate only through ` + "`" + `gortex call edit` + "`" + ` or ` + "`" + `gortex call refactor` + "`" + `; afterward run change operations ` + "`" + `detect` + "`" + `, ` + "`" + `tests` + "`" + `, ` + "`" + `guards` + "`" + `, and ` + "`" + `contract` + "`" + `. +4. Use ` + "`" + `gortex call capabilities` + "`" + ` only when exact operation fields are unknown. ` // AppendInstructions appends body to path, creating the file if diff --git a/internal/agents/instructions_test.go b/internal/agents/instructions_test.go index 0d5f762cd..f42a045fd 100644 --- a/internal/agents/instructions_test.go +++ b/internal/agents/instructions_test.go @@ -138,30 +138,42 @@ const ( 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. +// TestInstructionsBody_PolicyCoreAndSingleHome locks the compact public +// workflow and its native-MCP failure posture. Agent-facing rules must not leak transport +// versions, legacy implementation aliases, or the relocated long reference. 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", + "explore", "search", "read", "relations", "trace", "change", + "edit", "refactor", "capabilities", "recall", "remember", + "Gortex MCP integration failure", `operation:"verify"`, } { if !strings.Contains(InstructionsBody, token) { t.Errorf("InstructionsBody no longer mentions %q — policy core regression", token) } } - for _, banned := range []string{providerMatrixMarker, analyzeCatalogMarker, formatDeepDiveMarker} { + for _, banned := range []string{ + providerMatrixMarker, analyzeCatalogMarker, formatDeepDiveMarker, + "facade-v1", "search_symbols", "get_symbol_source", "tools_search", + "gortex call", "daemon start", "while these tools are available", + } { if strings.Contains(InstructionsBody, banned) { t.Errorf("InstructionsBody re-carries relocated content %q — single-home violation", banned) } } + if len(InstructionsBody) > 2_500 { + t.Fatalf("InstructionsBody grew to %d bytes; keep ambient agent guidance lean", len(InstructionsBody)) + } +} + +func TestBashInstructionsBodyUsesOnlyExplicitCLIMirror(t *testing.T) { + for _, want := range []string{"no native MCP transport", "gortex call ", "gortex call explore", "gortex call change"} { + if !strings.Contains(BashInstructionsBody, want) { + t.Errorf("BashInstructionsBody missing %q", want) + } + } + if strings.Contains(BashInstructionsBody, "Native Gortex MCP is mandatory") { + t.Error("Bash-only instructions must not claim a native MCP transport") + } } // TestGlobalPointerBody_ShapeAndSentinel locks in the thin pointer diff --git a/internal/agents/kilocode/adapter.go b/internal/agents/kilocode/adapter.go index 6b4d70207..df45ab42a 100644 --- a/internal/agents/kilocode/adapter.go +++ b/internal/agents/kilocode/adapter.go @@ -29,21 +29,21 @@ 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{ +// alwaysAllow is Kilo Code's coarse tool-level auto-approve list. Kilo is a +// Cline fork and uses the same field and compact-tool policy. +var alwaysAllow = agents.CompactMCPAutoApproveTools() + +// v060AlwaysAllow fingerprints the exact approval list shipped by gortex +// v0.60.0. User customizations, including deliberately narrower lists, are +// never widened. The concrete retirement gate is in docs/versioning.md. +var v060AlwaysAllow = []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", + "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 @@ -125,7 +125,7 @@ func (a *Adapter) Apply(env agents.Env, opts agents.ApplyOpts) (*agents.Result, entry["alwaysAllow"] = alwaysAllow merge := func(root map[string]any, _ bool) (bool, error) { - return agents.UpsertMCPServer(root, "gortex", entry, opts), nil + return agents.UpsertMCPServerApprovalList(root, "gortex", "alwaysAllow", alwaysAllow, entry, opts, v060AlwaysAllow), nil } // Project-level first, if a .kilocode/ dir already exists. diff --git a/internal/agents/kilocode/adapter_test.go b/internal/agents/kilocode/adapter_test.go new file mode 100644 index 000000000..cf0fcae51 --- /dev/null +++ b/internal/agents/kilocode/adapter_test.go @@ -0,0 +1,15 @@ +package kilocode + +import ( + "slices" + "testing" + + "github.com/zzet/gortex/internal/agents" +) + +func TestAlwaysAllowUsesSafeCompactSurface(t *testing.T) { + want := agents.CompactMCPAutoApproveTools() + if !slices.Equal(alwaysAllow, want) { + t.Fatalf("alwaysAllow=%v want safe compact tools %v", alwaysAllow, want) + } +} diff --git a/internal/agents/kiro/adapter.go b/internal/agents/kiro/adapter.go index 3ef07fea1..d0d7a2a79 100644 --- a/internal/agents/kiro/adapter.go +++ b/internal/agents/kiro/adapter.go @@ -6,6 +6,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "github.com/zzet/gortex/internal/agents" ) @@ -86,7 +87,7 @@ func (a *Adapter) Apply(env agents.Env, opts agents.ApplyOpts) (*agents.Result, entry := agents.DefaultGortexMCPEntry() entry["disabled"] = false entry["autoApprove"] = AutoApproveTools - return agents.UpsertMCPServer(root, "gortex", entry, opts), nil + return agents.UpsertMCPServerApprovalList(root, "gortex", "autoApprove", AutoApproveTools, entry, opts, v060AutoApproveTools), nil }, opts) if err != nil { return res, fmt.Errorf("kiro mcp.json: %w", err) @@ -100,18 +101,19 @@ func (a *Adapter) Apply(env agents.Env, opts agents.ApplyOpts) (*agents.Result, return res, nil } - // 2. Steering docs — static, created if absent. + // 2. Steering docs — preserve user-authored replacements, but migrate the + // legacy Gortex-authored tool vocabulary to the compact surface. for name, content := range SteeringFiles { - action, err := agents.WriteIfNotExists(env.Stderr, filepath.Join(env.Root, ".kiro", "steering", name), content, opts) + action, err := writeKiroArtifact(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. + // 3. Agent hooks — use the same targeted migration policy. for name, content := range HookFiles { - action, err := agents.WriteIfNotExists(env.Stderr, filepath.Join(env.Root, ".kiro", "hooks", name), content, opts) + action, err := writeKiroArtifact(env.Stderr, filepath.Join(env.Root, ".kiro", "hooks", name), content, opts) if err != nil { return res, err } @@ -122,6 +124,26 @@ func (a *Adapter) Apply(env agents.Env, opts agents.ApplyOpts) (*agents.Result, return res, nil } +func writeKiroArtifact(w io.Writer, path, content string, opts agents.ApplyOpts) (agents.FileAction, error) { + if existing, err := os.ReadFile(path); err == nil && isLegacyKiroArtifact(string(existing)) { + return agents.WriteOwnedFile(w, path, content, opts) + } + return agents.WriteIfNotExists(w, path, content, opts) +} + +func isLegacyKiroArtifact(body string) bool { + owned := strings.Contains(body, "# Gortex") || strings.Contains(body, `"name": "Gortex:`) + if !owned { + return false + } + for _, legacy := range []string{"smart_context", "search_symbols", "get_editing_context", "detect_changes"} { + if strings.Contains(body, legacy) { + return true + } + } + return false +} + // 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 diff --git a/internal/agents/kiro/adapter_test.go b/internal/agents/kiro/adapter_test.go index b81702b8f..385aea088 100644 --- a/internal/agents/kiro/adapter_test.go +++ b/internal/agents/kiro/adapter_test.go @@ -3,6 +3,8 @@ package kiro import ( "os" "path/filepath" + "slices" + "strings" "testing" "github.com/zzet/gortex/internal/agents" @@ -44,6 +46,46 @@ func TestKiroCreatesAllArtifactsAndIsIdempotent(t *testing.T) { if gortex["disabled"] != false { t.Fatalf("disabled should be false: %v", gortex) } + approved := make([]string, 0, len(approvals)) + for _, raw := range approvals { + approved = append(approved, raw.(string)) + } + if !slices.Equal(approved, agents.CompactMCPAutoApproveTools()) { + t.Fatalf("autoApprove=%v want safe compact tools %v", approved, agents.CompactMCPAutoApproveTools()) + } + + for name, body := range SteeringFiles { + for _, legacy := range []string{"smart_context", "search_symbols", "get_symbol_source", "find_usages", "get_callers", "verify_change", "read_file"} { + if strings.Contains(body, legacy) { + t.Errorf("steering file %s contains legacy MCP tool %q", name, legacy) + } + } + if strings.Contains(body, "facade-v1") { + t.Errorf("steering file %s exposes an implementation version", name) + } + } agentstest.AssertIdempotent(t, a, env) } + +func TestKiroMigratesLegacyOwnedInstructions(t *testing.T) { + env, _ := agentstest.NewEnv(t) + legacyPath := filepath.Join(env.Root, ".kiro", "steering", "gortex-workflow.md") + if err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(legacyPath, []byte("# Gortex Code Intelligence\nCall smart_context then get_editing_context.\n"), 0o644); err != nil { + t.Fatal(err) + } + + if _, err := New().Apply(env, agents.ApplyOpts{}); err != nil { + t.Fatalf("apply: %v", err) + } + got, err := os.ReadFile(legacyPath) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(got), "smart_context") || !strings.Contains(string(got), "explore") { + t.Fatalf("legacy steering was not migrated:\n%s", got) + } +} diff --git a/internal/agents/kiro/content.go b/internal/agents/kiro/content.go index f9e78b0e7..9496d3093 100644 --- a/internal/agents/kiro/content.go +++ b/internal/agents/kiro/content.go @@ -1,14 +1,9 @@ -// 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 implements the Gortex init integration for Kiro IDE. 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). +import "github.com/zzet/gortex/internal/agents" + +// SteeringFiles maps filename to content under .kiro/steering/. var SteeringFiles = map[string]string{ "gortex-workflow.md": steeringWorkflow, "gortex-explore.md": steeringExplore, @@ -17,10 +12,9 @@ var SteeringFiles = map[string]string{ "gortex-refactor.md": steeringRefactor, } -// HookFiles maps filename → content under .kiro/hooks/. Each file -// is a Kiro agent-hook definition (when+then JSON). +// HookFiles maps filename to Kiro agent-hook JSON under .kiro/hooks/. var HookFiles = map[string]string{ - "gortex-smart-context.json": hookSmartContext, + "gortex-smart-context.json": hookTaskContext, "gortex-post-edit.json": hookPostEdit, "gortex-pre-read.json": hookPreRead, } @@ -29,331 +23,127 @@ 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 --arg k=v`" + ` (e.g. ` + "`gortex call read_file --arg path=`" + `) — there is no bare ` + "`gortex `" + ` 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: \"\"`" + ` (` + "`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: `" + ` — 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:\"\"`" + ` — 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:\"\"`" + ` — 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) +# Gortex workflow -` + "`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. +Use Gortex MCP tools for indexed code. This is mandatory. -| Trigger | Use | -|----------------------------------------------------------|--------------------------------------------------------------------------------| -| Immediately after ` + "`smart_context`" + ` (every new task) | ` + "`surface_memories task:\"\" symbol_ids:\"\"`" + ` — 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:\"\" body:\"\" symbol_ids:\"\" importance:5`" + ` — pin load-bearing memories. | -| A memory is no longer true | ` + "`store_memory body:\"\" supersedes:\"\"`" + ` — preserves audit trail; old memory hidden by default. | +1. Start every coding task with ` + "`explore`" + ` using ` + "`operation: \"task\"`" + ` and the user's task text. +2. Use ` + "`search`" + ` for symbols, text, files, or AST shapes; use ` + "`read`" + ` for file, source, summary, or editing context. +3. Use ` + "`relations`" + ` for usages, callers, dependencies, dependents, and implementations; use ` + "`trace`" + ` for call chains and dataflow. +4. Before mutation, call ` + "`change`" + ` with ` + "`operation: \"impact\"`" + `; for a signature change, also call operation ` + "`verify`" + ` with the proposed signature. +5. Mutate only with ` + "`edit`" + ` or ` + "`refactor`" + `. After mutation, call ` + "`change`" + ` operations ` + "`detect`" + `, ` + "`tests`" + `, ` + "`guards`" + `, and ` + "`contract`" + `. +6. Call ` + "`capabilities`" + ` with ` + "`domain`" + `, ` + "`operation`" + `, and ` + "`detail: \"schema\"`" + ` when exact arguments are not already visible. -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. +Do not replace graph reads or searches with shell commands. If the configured Gortex tools are missing from the callable MCP tools, report a Gortex MCP integration failure and stop; do not start a daemon or use a CLI/shell fallback. -## 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:\"\"`" + ` and ` + "`query_memories symbol_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. +For durable context, use ` + "`recall`" + ` (` + "`surface`" + `/` + "`notes`" + `/` + "`memories`" + `) before editing known code and ` + "`remember`" + ` (` + "`note`" + `/` + "`memory`" + `) for decisions and invariants. ` 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: \"\"})`" + ` — find symbols related to a concept -4. ` + "`get_processes`" + ` — discover execution flows -5. ` + "`get_process({id: \"\"})`" + ` — trace a specific flow step by step -6. ` + "`get_editing_context({path: \"\"})`" + ` — deep dive on a specific file - -## When to use +# Explore with Gortex -- "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) +1. Call ` + "`explore({operation:\"task\", task:\"\"})`" + `. +2. Narrow names with ` + "`search({operation:\"symbols\", query:\"\"})`" + ` or literals with ` + "`operation:\"text\"`" + `. +3. Read only the needed source with ` + "`read`" + ` (` + "`source`" + `/` + "`summary`" + `/` + "`editing_context`" + `). +4. Prove relationships with ` + "`relations`" + ` or execution paths with ` + "`trace`" + `. +5. Return symbol IDs, file:line locations, and the shortest evidence needed. ` const steeringDebug = `--- inclusion: manual --- -# Debugging with Gortex - -## Workflow +# Debug with Gortex -1. ` + "`search_symbols({query: \"\"})`" + ` — find related symbols -2. ` + "`get_callers({id: \"\"})`" + ` — who calls it? -3. ` + "`get_call_chain({id: \"\"})`" + ` — what does it call? -4. ` + "`get_editing_context({path: \"\"})`" + ` — full file context -5. ` + "`get_process({id: \"\"})`" + ` — 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 | +1. Localize the symptom with ` + "`explore`" + `. +2. Use ` + "`search`" + ` with ` + "`operation:\"text\"`" + ` for an exact error and ` + "`operation:\"symbols\"`" + ` for named code. +3. Use ` + "`relations({operation:\"callers\", ...})`" + ` and ` + "`trace({operation:\"call_chain\", ...})`" + ` to follow execution. +4. Use ` + "`trace`" + ` with ` + "`flow`" + ` or ` + "`taint`" + ` when the bug concerns values crossing helpers. +5. Read only the suspect symbols, then state the root cause and evidence. ` const steeringImpact = `--- inclusion: manual --- -# Impact Analysis with Gortex - -## Workflow - -1. ` + "`search_symbols({query: \"X\"})`" + ` — find the symbol ID -2. ` + "`explain_change_impact({ids: \", \"})`" + ` — risk-tiered blast radius -3. ` + "`get_dependents({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 | +# Assess change impact with Gortex -## 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 +1. Resolve the target with ` + "`explore`" + ` or ` + "`search`" + `. +2. Call ` + "`change`" + ` with ` + "`operation:\"impact\"`" + `. +3. For signature changes, call ` + "`change`" + ` with ` + "`operation:\"verify\"`" + `. +4. Check ` + "`relations`" + ` usages/dependents and ` + "`analyze`" + ` contracts when boundaries are involved. +5. Call ` + "`change`" + ` with ` + "`operation:\"tests\"`" + ` and report concrete tests. ` const steeringRefactor = `--- inclusion: manual --- -# Refactoring with Gortex - -## Workflow - -1. ` + "`search_symbols({query: \"X\"})`" + ` — find the symbol ID -2. ` + "`explain_change_impact({ids: \"\"})`" + ` — map blast radius -3. ` + "`get_editing_context({path: \"\"})`" + ` — see all symbols and relationships -4. ` + "`find_usages({id: \"\"})`" + ` — every reference to change -5. ` + "`get_edit_plan({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 +# Refactor with Gortex -## 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 +1. Start with ` + "`explore`" + ` and read the target through ` + "`read`" + `. +2. Call ` + "`change`" + ` operations ` + "`impact`" + ` and ` + "`edit_plan`" + ` before writing; for a signature change, also call ` + "`verify`" + ` with the proposed signature. +3. Use ` + "`refactor`" + ` for rename, move, inline, delete, or code actions. Use ` + "`edit`" + ` for file, symbol, batch, or new-file changes. +4. After writing, call ` + "`change`" + ` operations ` + "`detect`" + `, ` + "`tests`" + `, ` + "`guards`" + `, and ` + "`contract`" + `. +5. Run the project tests selected by the graph. ` -const hookSmartContext = `{ - "name": "Gortex: Smart Context on Prompt", +const hookTaskContext = `{ + "name": "Gortex: Task Context", "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" - }, + "description": "Localize each coding task with the graph before opening files.", + "when": {"type": "userTriggered"}, "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." + "prompt": "For a coding task, call Gortex explore with operation task and the user's task text before reading or searching files. Skip this only for non-coding conversation." } } ` const hookPostEdit = `{ - "name": "Gortex: Post-Edit Impact Check", + "name": "Gortex: Post-Edit 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.", + "description": "Check impact and tests after a source edit.", "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." + "prompt": "Call Gortex change with operation detect for unstaged changes. Use its affected symbol IDs with operations tests, guards, and contract; run the selected tests and report every result." } } ` const hookPreRead = `{ - "name": "Gortex: Enrich File Reads", + "name": "Gortex: Enrich Source Read", "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"] - }, + "description": "Use indexed source context before a raw source-file read.", + "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." + "prompt": "For indexed source code, use Gortex read with operation editing_context or source instead of a raw file read. Skip generated files, metadata, documentation, configuration, and non-source assets." } } ` -// 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{ +// AutoApproveTools follows the compact MCP surface. External review +// publishing remains approval-gated by the shared policy. +var AutoApproveTools = agents.CompactMCPAutoApproveTools() + +// v060AutoApproveTools is the exact approval policy shipped by gortex +// v0.60.0. It is only a safe migration fingerprint. The concrete retirement +// gate is documented in docs/versioning.md. +var v060AutoApproveTools = []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", + "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", } diff --git a/internal/agents/pi/adapter.go b/internal/agents/pi/adapter.go index fd071b761..846ab51ad 100644 --- a/internal/agents/pi/adapter.go +++ b/internal/agents/pi/adapter.go @@ -49,19 +49,12 @@ 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}}" + sentinelBin = "{{GORTEX_BIN}}" + sentinelArgv = "{{GORTEX_HOOK_ARGV}}" + sentinelEnforce = "{{GORTEX_ENFORCE}}" + sentinelInstructions = "{{GORTEX_INSTRUCTIONS}}" ) -// 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{} } @@ -164,7 +157,7 @@ func renderExtension(env agents.Env) string { 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)) + src = substituteSentinel(src, sentinelInstructions, jsonString(agents.BashInstructionsBody)) return src } diff --git a/internal/agents/pi/adapter_test.go b/internal/agents/pi/adapter_test.go index 70824af5d..5b3dfd9f9 100644 --- a/internal/agents/pi/adapter_test.go +++ b/internal/agents/pi/adapter_test.go @@ -4,6 +4,8 @@ import ( "encoding/json" "os" "path/filepath" + "regexp" + "slices" "strings" "testing" @@ -75,7 +77,7 @@ func TestPiApplyWritesExtensionAndRouting(t *testing.T) { src := string(data) // Sentinels must be fully substituted — no template left behind. - for _, sentinel := range []string{sentinelBin, sentinelArgv, sentinelEnforce, sentinelToolsPreset} { + for _, sentinel := range []string{sentinelBin, sentinelArgv, sentinelEnforce, sentinelInstructions} { if strings.Contains(src, sentinel) { t.Errorf("unsubstituted sentinel %q remains in extension", sentinel) } @@ -234,36 +236,56 @@ func parseArgv(t *testing.T, src string) []string { return argv } -func TestPiRendersSearchToolAndPreset(t *testing.T) { +func TestPiRendersCompactPublicTools(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") + // Every Gortex tool is namespaced under gortex_ so it cannot clobber a + // Pi built-in such as read or edit. The CLI still receives the bare public + // name and the model's exact request object. + if !strings.Contains(src, `const TOOL_PREFIX = "gortex_"`) { + t.Error("expected a gortex_ tool-name prefix") } - if !strings.Contains(src, `"tools", "search", query`) { - t.Error("expected the meta-tool to shell `gortex tools search`") + if !strings.Contains(src, `"call", + bare, + "--json", + JSON.stringify(params ?? {}),`) { + t.Error("expected each Pi tool to forward the exact request object through gortex call") } - // 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") + for _, legacy := range []string{"tools_search", "TOOLS_PRESET", `"tools", "list"`, `"tools", "search"`} { + if strings.Contains(src, legacy) { + t.Errorf("Pi compact surface must not contain legacy discovery vocabulary %q", legacy) + } } - if !strings.Contains(src, `piToolName("tools_search")`) { - t.Error("expected the search meta-tool name to be derived via the gortex_ prefix") + if strings.Contains(src, `"--format"`) { + t.Error("Pi must not rewrite the request by forcing an output format") } - // 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") + blockStart := strings.Index(src, "const PUBLIC_TOOLS: ToolDescriptor[] = [") + if blockStart < 0 { + t.Fatal("PUBLIC_TOOLS roster missing") + } + block := src[blockStart:] + if end := strings.Index(block, "];"); end >= 0 { + block = block[:end] + } + matches := regexp.MustCompile(`\{ name: "([^"]+)"`).FindAllStringSubmatch(block, -1) + got := make([]string, 0, len(matches)) + for _, match := range matches { + got = append(got, match[1]) + } + want := []string{ + "explore", "search", "read", "relations", "trace", "analyze", "ask", + "change", "edit", "refactor", "review", "publish_review", "pr", "recall", + "remember", "workspace", "workspace_admin", "overlay", "session", "response", + "capabilities", + } + if !slices.Equal(got, want) { + t.Fatalf("public Pi tools = %v, want %v", got, want) } - if strings.Contains(src, `"--preset", "core", "--format"`) { - t.Error("discovery must no longer hardcode --preset core; it should route through presetListArgs()") + if !strings.Contains(src, agents.BashInstructionsSentinel) { + t.Error("rendered Pi extension must carry the mandatory Bash-only public workflow") } } @@ -287,7 +309,7 @@ func TestPiRegistersToolsPerSession(t *testing.T) { 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()"} { + for _, want := range []string{"gortexToolNames.clear()", "registerGortexTools(pi)", "ensureDaemon()"} { if !strings.Contains(body, want) { t.Errorf("expected %q inside the session_start handler (per-session re-registration)", want) } diff --git a/internal/agents/pi/extension/index.ts b/internal/agents/pi/extension/index.ts index 428a0e300..b5286734a 100644 --- a/internal/agents/pi/extension/index.ts +++ b/internal/agents/pi/extension/index.ts @@ -3,21 +3,15 @@ // 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 +// 1. Exposes Gortex's compact public 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 `, 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. +// relays the exact request object to the daemon. The fixed public roster +// keeps Pi aligned with every MCP host without exposing implementation +// aliases or requiring runtime catalogue discovery. // // 2. Re-creates the Claude-Code "prefer graph tools over raw file // reads" enforcement. Rather than re-implementing the deny logic in @@ -36,9 +30,8 @@ // {{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. +// {{GORTEX_INSTRUCTIONS}} -> a JSON string: the shared mandatory public-tool +// workflow injected once per session. // // Each is emitted as a JSON literal so values containing spaces survive. // @@ -59,15 +52,7 @@ 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(); +const GORTEX_INSTRUCTIONS: string = {{ GORTEX_INSTRUCTIONS }}; // Names (all gortex_-prefixed) of the Gortex tools registered into Pi. const gortexToolNames = new Set(); @@ -188,41 +173,40 @@ function normalizeToolCall( } // --------------------------------------------------------------------------- -// Dynamic tool registration +// Compact public-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]; + description: string; } -// 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 []; -} +// Keep this roster closed and agent-neutral. The bare names are the exact +// public CLI/MCP names; Pi adds only its collision-avoidance prefix at the +// registration boundary. +const PUBLIC_TOOLS: ToolDescriptor[] = [ + { name: "explore", description: "Localize a task and return ranked source plus call paths. Call first." }, + { name: "search", description: "Search indexed symbols, text, files, or AST shapes." }, + { name: "read", description: "Read indexed files, symbols, summaries, or editing context." }, + { name: "relations", description: "Find usages, callers, dependencies, dependents, or implementations." }, + { name: "trace", description: "Trace call chains, value flow, or taint paths." }, + { name: "analyze", description: "Run a graph analysis; use kind help to list analyses." }, + { name: "ask", description: "Answer a codebase question from graph evidence." }, + { name: "change", description: "Plan and check mutations: impact, verify, detect, tests, guards, or contract." }, + { name: "edit", description: "Apply file, symbol, batch, or new-file edits." }, + { name: "refactor", description: "Rename, move, inline, delete, or apply code actions." }, + { name: "review", description: "Review changes with graph context." }, + { name: "publish_review", description: "Publish prepared review feedback." }, + { name: "pr", description: "Inspect pull-request changes, context, or risk." }, + { name: "recall", description: "Retrieve notes or memories relevant to the work." }, + { name: "remember", description: "Store durable decisions, invariants, or gotchas." }, + { name: "workspace", description: "Inspect workspace and repository state." }, + { name: "workspace_admin", description: "Perform explicit workspace administration." }, + { name: "overlay", description: "Compare or manage session overlay state." }, + { name: "session", description: "Manage session state or subscriptions." }, + { name: "response", description: "Control response encoding or pagination." }, + { name: "capabilities", description: "Get exact operations and request schemas only when needed." }, +]; // safeRegister registers a Pi tool, tolerating a redundant registration — // a config reload can re-fire session_start without resetting the session's @@ -235,12 +219,10 @@ function safeRegister(pi: any, def: any): void { } } -// registerOneTool registers a single Gortex tool as a native Pi tool named -// `gortex_`, whose execute() shells `gortex call `. 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. +// registerOneTool registers one public Gortex tool as a native Pi tool named +// `gortex_`, whose execute() shells `gortex call `. The `--json` +// value is the model's exact request object; the adapter does not add output +// fields or translate operation names. function registerOneTool(pi: any, desc: ToolDescriptor): void { const bare = desc.name; if (!bare) return; @@ -250,7 +232,7 @@ function registerOneTool(pi: any, desc: ToolDescriptor): void { safeRegister(pi, { name, label: name, - description: (desc.description || desc.summary || name).trim(), + description: desc.description, // 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. @@ -262,8 +244,6 @@ function registerOneTool(pi: any, desc: ToolDescriptor): void { bare, "--json", JSON.stringify(params ?? {}), - "--format", - "gcx", ]); return { content: [{ type: "text", text: out }], details: {} }; } catch (err: any) { @@ -275,124 +255,7 @@ function registerOneTool(pi: any, desc: ToolDescriptor): void { } 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) { - 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: {} }; - }, - }); + for (const desc of PUBLIC_TOOLS) registerOneTool(pi, desc); } // --------------------------------------------------------------------------- @@ -401,22 +264,22 @@ function registerSearchTool(pi: any): void { 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 + // Mandatory workflow and hook orientation awaiting injection into the next + // LLM call. The `context` hook appends them 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 = ""; + let pendingOrientation = GORTEX_INSTRUCTIONS; // 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 = ""; + pendingOrientation = GORTEX_INSTRUCTIONS; ensureDaemon(); gortexToolNames.clear(); registerGortexTools(pi); - registerSearchTool(pi); }); // Fires before the agent loop's first LLM call. It can't mutate messages @@ -425,9 +288,9 @@ export default function (pi: any) { if (orientationInjected) return; const decision = callHook({ event: "session_start", cwd: pi?.cwd ?? process.cwd() }); if (decision.orientation) { - pendingOrientation = decision.orientation; - orientationInjected = true; + pendingOrientation = [GORTEX_INSTRUCTIONS, decision.orientation].filter(Boolean).join("\n\n"); } + orientationInjected = true; return; }); diff --git a/internal/agents/tool_surface.go b/internal/agents/tool_surface.go new file mode 100644 index 000000000..1951123ed --- /dev/null +++ b/internal/agents/tool_surface.go @@ -0,0 +1,93 @@ +package agents + +// compactMCPAutoApproveTools is the read-only, local subset of the compact MCP +// surface that coding-agent adapters may approve without an extra per-call +// prompt. Coarse host allowlists cannot distinguish operations, so aggregated +// mutation and open-world facades remain approval-gated. +var compactMCPAutoApproveTools = []string{ + "analyze", + "capabilities", + "change", + "explore", + "read", + "recall", + "relations", + "response", + "search", + "trace", + "workspace", +} + +// CompactMCPAutoApproveTools returns a fresh copy so adapter-specific config +// generation cannot mutate the shared policy. +func CompactMCPAutoApproveTools() []string { + return append([]string(nil), compactMCPAutoApproveTools...) +} + +// UpsertMCPServerApprovalList installs a new MCP entry or migrates the +// approval field of an existing Gortex-authored entry in place. Updating only +// the approval field preserves user environment and launcher customizations +// while removing stale tool names after a surface change. Non-Gortex entries +// remain untouched unless force is set. +func UpsertMCPServerApprovalList(root map[string]any, serverName, field string, tools []string, entry map[string]any, opts ApplyOpts, shippedLegacy ...[]string) bool { + servers, ok := root["mcpServers"].(map[string]any) + if !ok { + servers = make(map[string]any) + } + existing, exists := servers[serverName] + if !exists || opts.Force { + servers[serverName] = entry + root["mcpServers"] = servers + return true + } + if !IsGortexAuthoredMCPEntry(existing) { + return false + } + m, ok := existing.(map[string]any) + if !ok || stringListEqual(m[field], tools) { + return false + } + knownShippedList := false + for _, legacy := range shippedLegacy { + if stringListEqual(m[field], legacy) { + knownShippedList = true + break + } + } + if !knownShippedList { + // A narrower or otherwise customized approval list is user policy. Do + // not silently widen it merely because the MCP launcher is Gortex. + return false + } + m[field] = append([]string(nil), tools...) + servers[serverName] = m + root["mcpServers"] = servers + return true +} + +func stringListEqual(got any, want []string) bool { + switch list := got.(type) { + case []string: + if len(list) != len(want) { + return false + } + for i := range list { + if list[i] != want[i] { + return false + } + } + return true + case []any: + if len(list) != len(want) { + return false + } + for i := range list { + if value, ok := list[i].(string); !ok || value != want[i] { + return false + } + } + return true + default: + return false + } +} diff --git a/internal/agents/tool_surface_test.go b/internal/agents/tool_surface_test.go new file mode 100644 index 000000000..51a6502b0 --- /dev/null +++ b/internal/agents/tool_surface_test.go @@ -0,0 +1,79 @@ +package agents + +import ( + "slices" + "testing" +) + +func TestCompactMCPAutoApproveTools(t *testing.T) { + tools := CompactMCPAutoApproveTools() + if len(tools) != 11 { + t.Fatalf("auto-approved compact tools = %d, want 11", len(tools)) + } + for _, required := range []string{"analyze", "capabilities", "change", "explore", "read", "recall", "relations", "response", "search", "trace", "workspace"} { + if !slices.Contains(tools, required) { + t.Errorf("auto-approved compact tools missing %q", required) + } + } + for _, approvalGated := range []string{ + "ask", "edit", "overlay", "pr", "publish_review", "refactor", + "remember", "review", "session", "workspace_admin", + } { + if slices.Contains(tools, approvalGated) { + t.Errorf("effectful or open-world facade %q must require explicit approval", approvalGated) + } + } + + tools[0] = "mutated" + if slices.Contains(CompactMCPAutoApproveTools(), "mutated") { + t.Error("CompactMCPAutoApproveTools must return a defensive copy") + } +} + +func TestUpsertMCPServerApprovalListMigratesOnlyApprovalField(t *testing.T) { + root := map[string]any{"mcpServers": map[string]any{ + "gortex": map[string]any{ + "command": "gortex", + "args": []any{"mcp"}, + "alwaysAllow": []any{"search_symbols", "read_file"}, + "env": map[string]any{"CUSTOM": "keep"}, + }, + }} + entry := DefaultGortexMCPEntry() + entry["alwaysAllow"] = CompactMCPAutoApproveTools() + legacy := []string{"search_symbols", "read_file"} + if !UpsertMCPServerApprovalList(root, "gortex", "alwaysAllow", CompactMCPAutoApproveTools(), entry, ApplyOpts{}, legacy) { + t.Fatal("legacy approval list should migrate") + } + got := root["mcpServers"].(map[string]any)["gortex"].(map[string]any) + if !stringListEqual(got["alwaysAllow"], CompactMCPAutoApproveTools()) { + t.Fatalf("alwaysAllow=%v", got["alwaysAllow"]) + } + if got["env"].(map[string]any)["CUSTOM"] != "keep" { + t.Fatalf("migration clobbered custom entry fields: %v", got) + } + if UpsertMCPServerApprovalList(root, "gortex", "alwaysAllow", CompactMCPAutoApproveTools(), entry, ApplyOpts{}, legacy) { + t.Fatal("current approval list should be idempotent") + } +} + +func TestUpsertMCPServerApprovalListPreservesCustomNarrowPolicy(t *testing.T) { + root := map[string]any{"mcpServers": map[string]any{ + "gortex": map[string]any{ + "command": "gortex", + "args": []any{"mcp"}, + "alwaysAllow": []any{"read"}, + }, + }} + entry := DefaultGortexMCPEntry() + entry["alwaysAllow"] = CompactMCPAutoApproveTools() + legacy := []string{"search_symbols", "read_file"} + if UpsertMCPServerApprovalList(root, "gortex", "alwaysAllow", CompactMCPAutoApproveTools(), entry, ApplyOpts{}, legacy) { + t.Fatal("custom approval policy must not be widened") + } + got := root["mcpServers"].(map[string]any)["gortex"].(map[string]any)["alwaysAllow"] + list, ok := got.([]any) + if !ok || len(list) != 1 || list[0] != "read" { + t.Fatalf("custom approval policy changed: %#v", got) + } +} diff --git a/internal/analysis/adjacency.go b/internal/analysis/adjacency.go index 7aa3dc8f4..0c983df0d 100644 --- a/internal/analysis/adjacency.go +++ b/internal/analysis/adjacency.go @@ -75,7 +75,7 @@ func BuildAdjacencySnapshot(g graph.Store) *AdjacencySnapshot { return snap } - nodes := g.AllNodes() + nodes := graph.AllNodesLight(g) if len(nodes) == 0 { return snap } diff --git a/internal/analysis/adjacency_bounded.go b/internal/analysis/adjacency_bounded.go new file mode 100644 index 000000000..000217d82 --- /dev/null +++ b/internal/analysis/adjacency_bounded.go @@ -0,0 +1,215 @@ +package analysis + +import ( + "sort" + + "github.com/zzet/gortex/internal/graph" +) + +const ( + defaultBoundedAdjacencyDepth = 2 + defaultBoundedAdjacencyNodes = 4096 + defaultBoundedAdjacencyEdges = 16384 + boundedAdjacencyBatchSize = 512 +) + +// BoundedAdjacencyStats reports the exact work and whether a hard boundary made +// the snapshot a lower bound. Callers can reduce confidence instead of silently +// treating a capped neighborhood as the complete graph. +type BoundedAdjacencyStats struct { + NodeCount int + EdgeCount int + Depth int + NodeBatches int + EdgeBatches int + Truncated bool +} + +// BuildBoundedAdjacencySnapshot constructs a deterministic call/reference CSR +// from a candidate-seeded neighborhood. It is the interactive counterpart to +// BuildAdjacencySnapshot: bounded batch reads per depth and explicit node/edge +// caps prevent a normal search from materializing the whole graph. +func BuildBoundedAdjacencySnapshot(g graph.Reader, roots []string, depth, maxNodes, maxEdges int) (*AdjacencySnapshot, BoundedAdjacencyStats) { + snap := &AdjacencySnapshot{index: map[string]int{}} + stats := BoundedAdjacencyStats{} + if g == nil || len(roots) == 0 { + return snap, stats + } + if depth <= 0 { + depth = defaultBoundedAdjacencyDepth + } + if maxNodes <= 0 { + maxNodes = defaultBoundedAdjacencyNodes + } + if maxEdges <= 0 { + maxEdges = defaultBoundedAdjacencyEdges + } + + rootIDs := uniqueSortedIDs(roots) + rootNodes, batches := boundedNodesByIDs(g, rootIDs) + stats.NodeBatches += batches + seen := make(map[string]struct{}, min(len(rootNodes), maxNodes)) + frontier := make([]string, 0, min(len(rootNodes), maxNodes)) + for _, id := range rootIDs { + if rootNodes[id] == nil { + continue + } + if len(seen) == maxNodes { + stats.Truncated = true + break + } + seen[id] = struct{}{} + frontier = append(frontier, id) + } + + edges := make([]*graph.Edge, 0, min(maxEdges, len(frontier)*4)) + for level := 0; level < depth && len(frontier) > 0 && len(edges) < maxEdges; level++ { + stats.Depth = level + 1 + out, edgeBatches := boundedOutEdges(g, frontier) + stats.EdgeBatches += edgeBatches + candidates := make([]*graph.Edge, 0) + targetIDs := make([]string, 0) + for _, from := range frontier { + for _, edge := range out[from] { + if edge == nil || (edge.Kind != graph.EdgeCalls && edge.Kind != graph.EdgeReferences) { + continue + } + candidates = append(candidates, edge) + targetIDs = append(targetIDs, edge.To) + } + } + sort.Slice(candidates, func(i, j int) bool { + if candidates[i].From != candidates[j].From { + return candidates[i].From < candidates[j].From + } + if candidates[i].To != candidates[j].To { + return candidates[i].To < candidates[j].To + } + return candidates[i].Kind < candidates[j].Kind + }) + targetNodes, nodeBatches := boundedNodesByIDs(g, uniqueSortedIDs(targetIDs)) + stats.NodeBatches += nodeBatches + nextSet := make(map[string]struct{}) + for i, edge := range candidates { + if len(edges) == maxEdges { + stats.Truncated = stats.Truncated || i < len(candidates) + break + } + if targetNodes[edge.To] == nil { + continue + } + if _, ok := seen[edge.To]; !ok { + if len(seen) == maxNodes { + stats.Truncated = true + continue + } + seen[edge.To] = struct{}{} + nextSet[edge.To] = struct{}{} + } + edges = append(edges, edge) + } + frontier = make([]string, 0, len(nextSet)) + for id := range nextSet { + frontier = append(frontier, id) + } + sort.Strings(frontier) + } + if len(frontier) > 0 && stats.Depth == depth { + stats.Truncated = true + } + + ids := make([]string, 0, len(seen)) + for id := range seen { + ids = append(ids, id) + } + sort.Strings(ids) + index := make(map[string]int, len(ids)) + for i, id := range ids { + index[id] = i + } + type link struct { + to int + w float64 + } + adj := make([][]link, len(ids)) + for _, edge := range edges { + from, fromOK := index[edge.From] + to, toOK := index[edge.To] + if !fromOK || !toOK { + continue + } + adj[from] = append(adj[from], link{to: to, w: graph.ProvenanceWeight(edge)}) + } + offsets := make([]int32, len(ids)+1) + total := 0 + 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 { + row := adj[i] + sort.Slice(row, func(a, b int) bool { return row[a].to < row[b].to }) + for _, edge := range row { + neighbors = append(neighbors, int32(edge.to)) + weights = append(weights, edge.w) + outWeight[i] += edge.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) + stats.NodeCount = len(ids) + stats.EdgeCount = len(neighbors) + return snap, stats +} + +func uniqueSortedIDs(ids []string) []string { + seen := make(map[string]struct{}, len(ids)) + out := make([]string, 0, len(ids)) + for _, id := range ids { + if id == "" { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + sort.Strings(out) + return out +} + +func boundedNodesByIDs(g graph.Reader, ids []string) (map[string]*graph.Node, int) { + out := make(map[string]*graph.Node, len(ids)) + batches := 0 + for start := 0; start < len(ids); start += boundedAdjacencyBatchSize { + end := min(start+boundedAdjacencyBatchSize, len(ids)) + for id, node := range g.GetNodesByIDs(ids[start:end]) { + out[id] = node + } + batches++ + } + return out, batches +} + +func boundedOutEdges(g graph.Reader, ids []string) (map[string][]*graph.Edge, int) { + out := make(map[string][]*graph.Edge, len(ids)) + batches := 0 + for start := 0; start < len(ids); start += boundedAdjacencyBatchSize { + end := min(start+boundedAdjacencyBatchSize, len(ids)) + for id, edges := range g.GetOutEdgesByNodeIDs(ids[start:end]) { + out[id] = edges + } + batches++ + } + return out, batches +} diff --git a/internal/analysis/adjacency_bounded_test.go b/internal/analysis/adjacency_bounded_test.go new file mode 100644 index 000000000..3a3da1e84 --- /dev/null +++ b/internal/analysis/adjacency_bounded_test.go @@ -0,0 +1,88 @@ +package analysis + +import ( + "math" + "testing" + + "github.com/zzet/gortex/internal/graph" +) + +func TestBuildBoundedAdjacencySnapshotParityOnCoveredFixture(t *testing.T) { + g := graph.New() + ids := []string{"a", "b", "c", "d", "e"} + for _, id := range ids { + g.AddNode(&graph.Node{ID: id, Name: id, Kind: graph.KindFunction}) + } + for _, edge := range [][2]string{{"a", "b"}, {"b", "c"}, {"a", "d"}, {"d", "e"}} { + g.AddEdge(&graph.Edge{From: edge[0], To: edge[1], Kind: graph.EdgeCalls}) + } + + full := BuildAdjacencySnapshot(g) + bounded, stats := BuildBoundedAdjacencySnapshot(g, ids, 2, 100, 100) + if stats.Truncated { + t.Fatalf("covered fixture unexpectedly truncated: %+v", stats) + } + want := full.PersonalizedPageRankTopK([]string{"a"}, 0, 100) + got := bounded.PersonalizedPageRankTopK([]string{"a"}, 0, 100) + if len(got) != len(want) { + t.Fatalf("score count mismatch: got=%d want=%d", len(got), len(want)) + } + for id, wantScore := range want { + if delta := math.Abs(got[id] - wantScore); delta > 1e-12 { + t.Fatalf("score[%s] delta=%g got=%g want=%g", id, delta, got[id], wantScore) + } + } +} + +func TestBuildBoundedAdjacencySnapshotReportsHardCaps(t *testing.T) { + g := graph.New() + g.AddNode(&graph.Node{ID: "root", Name: "root", Kind: graph.KindFunction}) + for _, id := range []string{"a", "b", "c", "d"} { + g.AddNode(&graph.Node{ID: id, Name: id, Kind: graph.KindFunction}) + g.AddEdge(&graph.Edge{From: "root", To: id, Kind: graph.EdgeCalls}) + } + _, stats := BuildBoundedAdjacencySnapshot(g, []string{"root"}, 2, 3, 2) + if !stats.Truncated { + t.Fatalf("hard cap was not reported: %+v", stats) + } + if stats.NodeCount > 3 || stats.EdgeCount > 2 { + t.Fatalf("hard cap exceeded: %+v", stats) + } +} + +type countingAdjacencyReader struct { + graph.Reader + nodeBatches int + edgeBatches int +} + +func (r *countingAdjacencyReader) GetNodesByIDs(ids []string) map[string]*graph.Node { + r.nodeBatches++ + return r.Reader.GetNodesByIDs(ids) +} + +func (r *countingAdjacencyReader) GetOutEdgesByNodeIDs(ids []string) map[string][]*graph.Edge { + r.edgeBatches++ + return r.Reader.GetOutEdgesByNodeIDs(ids) +} + +func TestBuildBoundedAdjacencySnapshotUsesBatchRounds(t *testing.T) { + g := graph.New() + roots := make([]string, 0, 64) + for i := 0; i < 64; i++ { + id := string(rune('a' + i)) + roots = append(roots, id) + g.AddNode(&graph.Node{ID: id, Name: id, Kind: graph.KindFunction}) + } + for i := 0; i+1 < len(roots); i++ { + g.AddEdge(&graph.Edge{From: roots[i], To: roots[i+1], Kind: graph.EdgeReferences}) + } + reader := &countingAdjacencyReader{Reader: g} + _, stats := BuildBoundedAdjacencySnapshot(reader, roots, 2, 4096, 16384) + if reader.edgeBatches != stats.EdgeBatches || reader.nodeBatches != stats.NodeBatches { + t.Fatalf("receipt mismatch: reader nodes=%d edges=%d stats=%+v", reader.nodeBatches, reader.edgeBatches, stats) + } + if reader.edgeBatches != 1 || reader.nodeBatches != 2 { + t.Fatalf("expected constant batch rounds, got nodes=%d edges=%d", reader.nodeBatches, reader.edgeBatches) + } +} diff --git a/internal/analysis/hits.go b/internal/analysis/hits.go index 28d27b1f3..ac58bd8a7 100644 --- a/internal/analysis/hits.go +++ b/internal/analysis/hits.go @@ -69,7 +69,7 @@ func ComputeHITS(g graph.Store) *HITSResult { if g == nil { return &HITSResult{Authorities: map[string]float64{}, Hubs: map[string]float64{}} } - nodes := excludeProxyNodes(g.AllNodes()) + nodes := excludeProxyNodes(graph.AllNodesLight(g)) n := len(nodes) if n == 0 { return &HITSResult{Authorities: map[string]float64{}, Hubs: map[string]float64{}} diff --git a/internal/analysis/impact.go b/internal/analysis/impact.go index 51a3b6946..8f2ffb8b3 100644 --- a/internal/analysis/impact.go +++ b/internal/analysis/impact.go @@ -1,8 +1,10 @@ package analysis import ( + "context" "fmt" "sort" + "time" "github.com/zzet/gortex/internal/graph" "github.com/zzet/gortex/internal/reach" @@ -45,6 +47,10 @@ type ImpactResult struct { // interface site the resolver could not bind: the true affected count is // then a floor (">=TotalAffected, could be more"), not an exact number. LowerBound bool `json:"lower_bound,omitempty"` + // Truncated means traversal or response fan-out hit a safety budget. + // ByDepth and TotalAffected are then a lower bound, never proof that the + // omitted portion is empty. + Truncated bool `json:"truncated,omitempty"` // Boundaries names the unresolved/dispatch sites that make the count a // floor, so an agent can act on them (e.g. find_implementations on the // interface). Omitted when empty. @@ -53,21 +59,36 @@ type ImpactResult struct { // AnalyzeImpact performs depth-tiered blast radius analysis on a set of symbols. // -// Fast path: when every seed has a precomputed reach index -// (`Node.Meta["reach_d1/d2/d3"]` stamped by BuildReachIndex), the +// Fast path: when every seed has a complete reach index record +// (`Node.Meta["reach_d1/d2/d3"]` with matching build and completion +// markers), the // depth-1/2/3 ByDepth tiers are constructed from those sets without // a live BFS — turning the dominant cost from O(reach) edge walks // into O(reach) map lookups. The representative in-edge per tier // entry is recovered with a linear scan of the entry's incoming // edges, matching the live walk's behavior. Fall back to live BFS -// when any seed lacks the index — the slow path is identical to the -// pre-index implementation so consumer semantics never diverge. +// when a seed cannot be indexed (for example, a non-symbol node) — the +// slow path is identical to the pre-index implementation so consumer +// semantics never diverge. Missing or interrupted symbol records are +// recomputed and atomically published by reach.Lookup. func AnalyzeImpact(g graph.Store, symbolIDs []string, communities *CommunityResult, processes *ProcessResult) *ImpactResult { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + return AnalyzeImpactContext(ctx, g, symbolIDs, communities, processes) +} + +// AnalyzeImpactContext is the cancellable form used by interactive callers. +// The compatibility wrapper above supplies a strict deadline so even callers +// that have not yet propagated their request context cannot hang on a hub. +func AnalyzeImpactContext(ctx context.Context, g graph.Store, symbolIDs []string, communities *CommunityResult, processes *ProcessResult) *ImpactResult { + if ctx == nil { + ctx = context.Background() + } result := &ImpactResult{ ByDepth: make(map[int][]ImpactEntry), } - if !fillImpactFromReach(g, result, symbolIDs) { - fillImpactLive(g, result, symbolIDs) + if !fillImpactFromReach(ctx, g, result, symbolIDs) { + fillImpactLive(ctx, g, result, symbolIDs) } // Trim noise from the transitive tiers: a resolution edge with @@ -85,10 +106,10 @@ func AnalyzeImpact(g graph.Store, symbolIDs []string, communities *CommunityResu // Hard fan-out cap per tier so a pathological hub doesn't blow up // the response. Sorted ID order is already deterministic from the // reach index, so the cap is stable. - const maxPerTier = 50 for depth := 1; depth <= 3; depth++ { - if len(result.ByDepth[depth]) > maxPerTier { - result.ByDepth[depth] = result.ByDepth[depth][:maxPerTier] + if len(result.ByDepth[depth]) > maxImpactEntriesPerTier { + result.Truncated = true + result.ByDepth[depth] = result.ByDepth[depth][:maxImpactEntriesPerTier] } } @@ -147,18 +168,34 @@ func AnalyzeImpact(g graph.Store, symbolIDs []string, communities *CommunityResu sort.Strings(result.AffectedCommunities) } + seedNodes, seedNodeErr := getImpactNodesContext(ctx, g, symbolIDs) + if seedNodeErr != nil { + result.Truncated = true + } + // Epistemic lower bound: blast radius is a count of *callers*, so a seed // that implements/overrides an interface may be reached through dynamic // dispatch the resolver could not attribute — the count is then a floor. - result.Boundaries = graph.CallerBoundaries(g, symbolIDs, 0) - result.LowerBound = graph.LowerBoundCaveat(result.Boundaries) + boundaries, boundaryTruncated := graph.CallerBoundariesContext(ctx, g, symbolIDs, 0) + result.Boundaries = boundaries + result.Truncated = result.Truncated || boundaryTruncated + result.LowerBound = result.Truncated || graph.LowerBoundCaveat(result.Boundaries) + // A partial traversal is never a LOW-risk verdict. MEDIUM is the minimum + // conservative posture; observed direct/transitive fan-out can still raise + // it to HIGH or CRITICAL through assessRisk above. + if result.LowerBound && result.Risk == RiskLow { + result.Risk = RiskMedium + } // Summary result.Summary = fmt.Sprintf( "%d direct dependents, %d transitively affected, %d test files, risk: %s", d1, result.TotalAffected, len(result.TestFiles), result.Risk, ) - if result.LowerBound { + if result.Truncated { + result.Summary += " — lower bound: reach traversal or output budget was reached; more callers may exist" + } + if graph.LowerBoundCaveat(result.Boundaries) { result.Summary += fmt.Sprintf( " — lower bound: %d dispatch boundary(ies) may add more callers", len(result.Boundaries), @@ -169,7 +206,7 @@ func AnalyzeImpact(g graph.Store, symbolIDs []string, communities *CommunityResu repoSet := make(map[string]bool) byRepo := make(map[string][]ImpactEntry) for _, id := range symbolIDs { - if n := g.GetNode(id); n != nil && n.RepoPrefix != "" { + if n := seedNodes[id]; n != nil && n.RepoPrefix != "" { repoSet[n.RepoPrefix] = true } } @@ -194,51 +231,134 @@ func AnalyzeImpact(g graph.Store, symbolIDs []string, communities *CommunityResu // per discovered node, attributing the in-edge that introduced it to // EdgeConfidence / ConfidenceLabel. Kept as the always-correct // fallback for fillImpactFromReach. -func fillImpactLive(g graph.Store, result *ImpactResult, symbolIDs []string) { +const maxImpactEntriesPerTier = 50 + +type impactNodesContextGetter interface { + GetNodesByIDsContext(context.Context, []string) (map[string]*graph.Node, error) +} + +func getImpactNodesContext(ctx context.Context, g graph.Store, ids []string) (map[string]*graph.Node, error) { + if getter, ok := g.(impactNodesContextGetter); ok { + return getter.GetNodesByIDsContext(ctx, ids) + } + if err := ctx.Err(); err != nil { + return nil, err + } + return g.GetNodesByIDs(ids), nil +} + +func fillImpactLive(ctx context.Context, g graph.Store, result *ImpactResult, symbolIDs []string) { + const maxLiveImpactEdges = maxImpactEntriesPerTier + 1 + + type boundedIncomingEdgeReader interface { + GetInEdgesByNodeIDsContext(context.Context, []string, int) (map[string][]*graph.Edge, bool, error) + } + readIncoming := func(ids []string, limit int) (map[string][]*graph.Edge, bool, error) { + if reader, ok := g.(boundedIncomingEdgeReader); ok { + return reader.GetInEdgesByNodeIDsContext(ctx, ids, limit) + } + if err := ctx.Err(); err != nil { + return nil, true, err + } + all := g.GetInEdgesByNodeIDs(ids) + out := make(map[string][]*graph.Edge, len(all)) + count := 0 + for _, id := range ids { + for _, edge := range all[id] { + if count >= limit { + return out, true, nil + } + out[id] = append(out[id], edge) + count++ + } + } + return out, false, nil + } + visited := make(map[string]bool) for _, id := range symbolIDs { visited[id] = true } - current := symbolIDs - for depth := 1; depth <= 3; depth++ { - var next []string + current := append([]string(nil), symbolIDs...) + edgesRemaining := maxLiveImpactEdges + for depth := 1; depth <= 3 && len(current) > 0; depth++ { + if ctx.Err() != nil || edgesRemaining <= 0 { + result.Truncated = true + break + } + inEdges, limited, err := readIncoming(current, edgesRemaining) + if err != nil { + result.Truncated = true + break + } + + type candidate struct { + id string + edge *graph.Edge + } + next := make([]string, 0) + candidates := make([]candidate, 0) for _, id := range current { - for _, e := range g.GetInEdges(id) { - if visited[e.From] { + for _, edge := range inEdges[id] { + edgesRemaining-- + if edge == nil || visited[edge.From] { continue } - if e.Kind == graph.EdgeDefines || e.Kind == graph.EdgeMemberOf { + if edge.Kind == graph.EdgeDefines || edge.Kind == graph.EdgeMemberOf { continue } - visited[e.From] = true - next = append(next, e.From) + visited[edge.From] = true + next = append(next, edge.From) + candidates = append(candidates, candidate{id: edge.From, edge: edge}) + } + } - n := g.GetNode(e.From) - if n == nil || n.Kind == graph.KindFile || n.Kind == graph.KindImport { - continue - } - result.ByDepth[depth] = append(result.ByDepth[depth], ImpactEntry{ - ID: n.ID, - Name: n.Name, - Kind: string(n.Kind), - FilePath: n.FilePath, - Line: n.StartLine, - RepoPrefix: n.RepoPrefix, - EdgeConfidence: e.Confidence, - ConfidenceLabel: graph.ConfidenceLabelFor(e.Kind, e.Confidence), - }) - if isTestFile(n.FilePath) { - result.TestFiles = append(result.TestFiles, n.FilePath) - } + nodes, err := getImpactNodesContext(ctx, g, next) + if err != nil { + result.Truncated = true + return + } + emitted := 0 + for _, candidate := range candidates { + if ctx.Err() != nil { + result.Truncated = true + break + } + n := nodes[candidate.id] + if n == nil || n.Kind == graph.KindFile || n.Kind == graph.KindImport { + continue + } + if emitted >= maxImpactEntriesPerTier { + result.Truncated = true + continue + } + result.ByDepth[depth] = append(result.ByDepth[depth], ImpactEntry{ + ID: n.ID, + Name: n.Name, + Kind: string(n.Kind), + FilePath: n.FilePath, + Line: n.StartLine, + RepoPrefix: n.RepoPrefix, + EdgeConfidence: candidate.edge.Confidence, + ConfidenceLabel: graph.ConfidenceLabelFor(candidate.edge.Kind, candidate.edge.Confidence), + }) + emitted++ + if isTestFile(n.FilePath) { + result.TestFiles = append(result.TestFiles, n.FilePath) } } current = next + if limited { + result.Truncated = true + break + } } } -// fillImpactFromReach is the precomputed fast path. Returns false if -// any seed lacks a reach build stamp — the caller must then run -// fillImpactLive. The union of per-seed reach_d1 sets becomes the +// fillImpactFromReach is the indexed fast path. Returns false if any seed +// cannot produce a complete reach record — the caller must then run +// fillImpactLive. A merely stamped but incomplete record is never a hit. +// The union of per-seed reach_d1 sets becomes the // depth-1 tier; depth-2 is the union of per-seed reach_d2 minus // seeds and minus the depth-1 set; depth-3 is built the same way // against (seeds ∪ d1 ∪ d2). For each tier-N entry we look up the @@ -248,7 +368,7 @@ func fillImpactLive(g graph.Store, result *ImpactResult, symbolIDs []string) { // deterministic-by-shard-iteration choice closely enough for tests // that compare ByDepth ID sets, which is the contract consumers rely // on. EdgeConfidence is set from that representative edge. -func fillImpactFromReach(g graph.Store, result *ImpactResult, symbolIDs []string) bool { +func fillImpactFromReach(ctx context.Context, g graph.Store, result *ImpactResult, symbolIDs []string) bool { if len(symbolIDs) == 0 { return true } @@ -261,20 +381,47 @@ func fillImpactFromReach(g graph.Store, result *ImpactResult, symbolIDs []string // generic path). if len(symbolIDs) == 1 { seedID := symbolIDs[0] - d1, d2, d3, hit := reach.Lookup(g, seedID) + d1, d2, d3, hit, truncated := reach.LookupContext(ctx, g, seedID) + result.Truncated = result.Truncated || truncated if !hit { + if truncated { + // Cancellation before the seed could be read is still a bounded + // result; do not fall into the unbounded legacy walk. + return true + } return false } for depth, tier := range [3][]reach.Entry{d1, d2, d3} { if len(tier) == 0 { continue } - out := make([]ImpactEntry, 0, len(tier)) - for _, e := range tier { - if e.ID == seedID { + selected := make([]reach.Entry, 0, min(len(tier), maxImpactEntriesPerTier)) + for _, entry := range tier { + if entry.ID == seedID { continue } - n := g.GetNode(e.ID) + if len(selected) == maxImpactEntriesPerTier { + result.Truncated = true + break + } + selected = append(selected, entry) + } + ids := make([]string, len(selected)) + for i := range selected { + ids[i] = selected[i].ID + } + nodes, err := getImpactNodesContext(ctx, g, ids) + if err != nil { + result.Truncated = true + return true + } + out := make([]ImpactEntry, 0, len(selected)) + for _, entry := range selected { + if ctx.Err() != nil { + result.Truncated = true + break + } + n := nodes[entry.ID] if n == nil || n.Kind == graph.KindFile || n.Kind == graph.KindImport { continue } @@ -285,8 +432,8 @@ func fillImpactFromReach(g graph.Store, result *ImpactResult, symbolIDs []string FilePath: n.FilePath, Line: n.StartLine, RepoPrefix: n.RepoPrefix, - EdgeConfidence: e.Conf, - ConfidenceLabel: e.Label, + EdgeConfidence: entry.Conf, + ConfidenceLabel: entry.Label, }) if isTestFile(n.FilePath) { result.TestFiles = append(result.TestFiles, n.FilePath) @@ -299,8 +446,12 @@ func fillImpactFromReach(g graph.Store, result *ImpactResult, symbolIDs []string perSeed := make([][3][]reach.Entry, len(symbolIDs)) for i, id := range symbolIDs { - d1, d2, d3, hit := reach.Lookup(g, id) + d1, d2, d3, hit, truncated := reach.LookupContext(ctx, g, id) + result.Truncated = result.Truncated || truncated if !hit { + if truncated { + continue + } return false } perSeed[i] = [3][]reach.Entry{d1, d2, d3} @@ -329,8 +480,25 @@ func fillImpactFromReach(g graph.Store, result *ImpactResult, symbolIDs []string // Deterministic emission — matches each per-seed slice's // build-time sort + makes the JSON payload diff-stable. sort.Slice(tier, func(i, j int) bool { return tier[i].ID < tier[j].ID }) - for _, e := range tier { - n := g.GetNode(e.ID) + if len(tier) > maxImpactEntriesPerTier { + result.Truncated = true + tier = tier[:maxImpactEntriesPerTier] + } + ids := make([]string, len(tier)) + for i := range tier { + ids[i] = tier[i].ID + } + nodes, err := getImpactNodesContext(ctx, g, ids) + if err != nil { + result.Truncated = true + return true + } + for _, entry := range tier { + if ctx.Err() != nil { + result.Truncated = true + break + } + n := nodes[entry.ID] if n == nil || n.Kind == graph.KindFile || n.Kind == graph.KindImport { continue } @@ -341,8 +509,8 @@ func fillImpactFromReach(g graph.Store, result *ImpactResult, symbolIDs []string FilePath: n.FilePath, Line: n.StartLine, RepoPrefix: n.RepoPrefix, - EdgeConfidence: e.Conf, - ConfidenceLabel: e.Label, + EdgeConfidence: entry.Conf, + ConfidenceLabel: entry.Label, }) if isTestFile(n.FilePath) { result.TestFiles = append(result.TestFiles, n.FilePath) diff --git a/internal/analysis/impact_adversarial_test.go b/internal/analysis/impact_adversarial_test.go new file mode 100644 index 000000000..2d052cd88 --- /dev/null +++ b/internal/analysis/impact_adversarial_test.go @@ -0,0 +1,219 @@ +package analysis + +import ( + "context" + "encoding/json" + "fmt" + "testing" + "time" + + "github.com/zzet/gortex/internal/graph" +) + +const ( + adversarialImpactFanIn = 4096 + adversarialImpactRuns = 5 + adversarialImpactTierCap = 50 + adversarialImpactCallTime = 750 * time.Millisecond +) + +// countingImpactStore models the bounded, context-aware SQLite read seam without +// involving the live daemon. The backing graph deliberately contains far more +// rows than impact is allowed to materialize. +type countingImpactStore struct { + graph.Store + + inEdgeContextCalls int + inEdgeLegacyCalls int + nodeContextCalls int + nodeLegacyCalls int + singleNodeCalls int + inEdgeRows int + nodeRows int + maxEdgeLimit int +} + +func (s *countingImpactStore) resetCounts() { + s.inEdgeContextCalls = 0 + s.inEdgeLegacyCalls = 0 + s.nodeContextCalls = 0 + s.nodeLegacyCalls = 0 + s.singleNodeCalls = 0 + s.inEdgeRows = 0 + s.nodeRows = 0 + s.maxEdgeLimit = 0 +} + +func (s *countingImpactStore) GetNode(id string) *graph.Node { + s.singleNodeCalls++ + return s.Store.GetNode(id) +} + +func (s *countingImpactStore) GetNodeContext(ctx context.Context, id string) (*graph.Node, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + s.singleNodeCalls++ + return s.Store.GetNode(id), nil +} + +func (s *countingImpactStore) GetNodesByIDs(ids []string) map[string]*graph.Node { + s.nodeLegacyCalls++ + out := s.Store.GetNodesByIDs(ids) + s.nodeRows += len(out) + return out +} + +func (s *countingImpactStore) GetNodesByIDsContext(ctx context.Context, ids []string) (map[string]*graph.Node, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + s.nodeContextCalls++ + out := s.Store.GetNodesByIDs(ids) + s.nodeRows += len(out) + return out, nil +} + +func (s *countingImpactStore) GetInEdgesByNodeIDs(ids []string) map[string][]*graph.Edge { + s.inEdgeLegacyCalls++ + out := s.Store.GetInEdgesByNodeIDs(ids) + for _, edges := range out { + s.inEdgeRows += len(edges) + } + return out +} + +func (s *countingImpactStore) GetInEdgesByNodeIDsContext(ctx context.Context, ids []string, limit int) (map[string][]*graph.Edge, bool, error) { + if err := ctx.Err(); err != nil { + return nil, false, err + } + if limit <= 0 { + return nil, false, fmt.Errorf("impact requested an unbounded incoming-edge batch: %d", limit) + } + + s.inEdgeContextCalls++ + if limit > s.maxEdgeLimit { + s.maxEdgeLimit = limit + } + + all := s.Store.GetInEdgesByNodeIDs(ids) + out := make(map[string][]*graph.Edge, len(ids)) + remaining := limit + truncated := false + for _, id := range ids { + edges := all[id] + if len(edges) == 0 { + continue + } + take := len(edges) + if take > remaining { + take = remaining + truncated = true + } + if take > 0 { + out[id] = append([]*graph.Edge(nil), edges[:take]...) + s.inEdgeRows += take + remaining -= take + } + if remaining == 0 { + if take < len(edges) { + truncated = true + } + continue + } + } + return out, truncated, nil +} + +func TestFillImpactLiveAdversarialFanInStaysBounded(t *testing.T) { + store := newAdversarialImpactStore(t, adversarialImpactFanIn) + + for run := 0; run < adversarialImpactRuns; run++ { + // Obtain the production-initialized result shape without performing work. + initCtx, stopInit := context.WithCancel(context.Background()) + stopInit() + result := AnalyzeImpactContext(initCtx, store, []string{"seed"}, nil, nil) + store.resetCounts() + + ctx, cancel := context.WithTimeout(context.Background(), adversarialImpactCallTime) + started := time.Now() + fillImpactLive(ctx, store, result, []string{"seed"}) + elapsed := time.Since(started) + err := ctx.Err() + cancel() + + if err != nil { + t.Fatalf("run %d exceeded %s: %v", run, adversarialImpactCallTime, err) + } + if elapsed >= adversarialImpactCallTime { + t.Fatalf("run %d took %s; want below %s", run, elapsed, adversarialImpactCallTime) + } + if store.inEdgeContextCalls > 3 { + t.Fatalf("run %d used %d incoming-edge batches; want at most 3", run, store.inEdgeContextCalls) + } + if store.nodeContextCalls > 3 { + t.Fatalf("run %d used %d node batches; want at most 3", run, store.nodeContextCalls) + } + if store.inEdgeLegacyCalls != 0 || store.nodeLegacyCalls != 0 { + t.Fatalf("run %d fell back to unbounded reads: edge=%d node=%d", run, store.inEdgeLegacyCalls, store.nodeLegacyCalls) + } + if store.maxEdgeLimit > adversarialImpactTierCap+1 { + t.Fatalf("run %d requested edge limit %d; want at most %d", run, store.maxEdgeLimit, adversarialImpactTierCap+1) + } + if store.inEdgeRows > 3*(adversarialImpactTierCap+1) { + t.Fatalf("run %d materialized %d edge rows; want at most %d", run, store.inEdgeRows, 3*(adversarialImpactTierCap+1)) + } + if store.nodeRows > 3*adversarialImpactTierCap { + t.Fatalf("run %d materialized %d node rows; want at most %d", run, store.nodeRows, 3*adversarialImpactTierCap) + } + } +} + +func TestAnalyzeImpactContextAdversarialFanInIsConservative(t *testing.T) { + store := newAdversarialImpactStore(t, adversarialImpactFanIn) + ctx, cancel := context.WithTimeout(context.Background(), adversarialImpactCallTime) + defer cancel() + + result := AnalyzeImpactContext(ctx, store, []string{"seed"}, nil, nil) + if err := ctx.Err(); err != nil { + t.Fatalf("impact did not finish within %s: %v", adversarialImpactCallTime, err) + } + + encoded, err := json.Marshal(result) + if err != nil { + t.Fatalf("marshal impact result: %v", err) + } + var fields map[string]any + if err := json.Unmarshal(encoded, &fields); err != nil { + t.Fatalf("decode impact result: %v", err) + } + if truncated, _ := fields["truncated"].(bool); !truncated { + t.Fatalf("large fan-in result must report truncation: %s", encoded) + } + if risk, _ := fields["risk"].(string); risk == "" || risk == "LOW" { + t.Fatalf("truncated large fan-in result must not be low risk: %s", encoded) + } +} + +func newAdversarialImpactStore(t *testing.T, fanIn int) *countingImpactStore { + t.Helper() + g := graph.New() + g.AddNode(&graph.Node{ID: "seed", Name: "seed", Kind: graph.NodeKind("function"), FilePath: "core/seed.go"}) + + for i := 0; i < fanIn; i++ { + community := i % 32 + directID := fmt.Sprintf("direct-%05d", i) + parentID := fmt.Sprintf("parent-%05d", i) + rootID := fmt.Sprintf("root-%05d", i) + basePath := fmt.Sprintf("community-%02d/pkg-%02d", community, i%128) + + g.AddNode(&graph.Node{ID: directID, Name: directID, Kind: graph.NodeKind("function"), FilePath: basePath + "/direct.go"}) + g.AddNode(&graph.Node{ID: parentID, Name: parentID, Kind: graph.NodeKind("function"), FilePath: basePath + "/parent.go"}) + g.AddNode(&graph.Node{ID: rootID, Name: rootID, Kind: graph.NodeKind("function"), FilePath: basePath + "/root.go"}) + g.AddEdge(&graph.Edge{From: directID, To: "seed", Kind: graph.EdgeKind("calls")}) + g.AddEdge(&graph.Edge{From: parentID, To: directID, Kind: graph.EdgeKind("calls")}) + g.AddEdge(&graph.Edge{From: rootID, To: parentID, Kind: graph.EdgeKind("calls")}) + } + + return &countingImpactStore{Store: g} +} diff --git a/internal/analysis/impact_finalization_context_test.go b/internal/analysis/impact_finalization_context_test.go new file mode 100644 index 000000000..3be4511ca --- /dev/null +++ b/internal/analysis/impact_finalization_context_test.go @@ -0,0 +1,39 @@ +package analysis + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/zzet/gortex/internal/graph" +) + +type failingImpactSeedBatchStore struct { + graph.Store +} + +func (s *failingImpactSeedBatchStore) GetNodesByIDsContext(context.Context, []string) (map[string]*graph.Node, error) { + return nil, errors.New("seed batch unavailable") +} + +func TestAnalyzeImpactContextSeedReadErrorRemainsConservative(t *testing.T) { + const seedID = "repo/service.go::Handle" + backing := graph.New() + backing.AddNode(&graph.Node{ID: seedID, Name: "Handle", Kind: graph.KindFunction}) + store := &failingImpactSeedBatchStore{Store: backing} + + result := AnalyzeImpactContext(context.Background(), store, []string{seedID}, nil, nil) + if !result.Truncated { + t.Fatal("seed-node read error must mark impact truncated") + } + if !result.LowerBound { + t.Fatal("seed-node read error must mark impact as a lower bound") + } + if result.Risk == RiskLow { + t.Fatal("truncated impact must never retain LOW risk") + } + if !strings.Contains(result.Summary, "lower bound") { + t.Fatalf("summary %q does not disclose the lower bound", result.Summary) + } +} diff --git a/internal/analysis/impact_node_context_test.go b/internal/analysis/impact_node_context_test.go new file mode 100644 index 000000000..ec77609e6 --- /dev/null +++ b/internal/analysis/impact_node_context_test.go @@ -0,0 +1,55 @@ +package analysis + +import ( + "context" + "errors" + "testing" + + "github.com/zzet/gortex/internal/graph" +) + +type impactContextNodesStoreStub struct { + graph.Store + err error + called bool +} + +func (s *impactContextNodesStoreStub) GetNodesByIDsContext(context.Context, []string) (map[string]*graph.Node, error) { + s.called = true + return nil, s.err +} + +func TestGetImpactNodesContextUsesOptionalStoreMethod(t *testing.T) { + s := &impactContextNodesStoreStub{err: context.Canceled} + _, err := getImpactNodesContext(context.Background(), s, []string{"node"}) + if !errors.Is(err, context.Canceled) { + t.Fatalf("getImpactNodesContext error = %v, want context.Canceled", err) + } + if !s.called { + t.Fatal("optional GetNodesByIDsContext was not called") + } +} + +type impactLegacyNodesStoreStub struct { + graph.Store + called bool +} + +func (s *impactLegacyNodesStoreStub) GetNodesByIDs([]string) map[string]*graph.Node { + s.called = true + return nil +} + +func TestGetImpactNodesContextChecksCancellationBeforeLegacyFallback(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + s := &impactLegacyNodesStoreStub{} + + _, err := getImpactNodesContext(ctx, s, []string{"node"}) + if !errors.Is(err, context.Canceled) { + t.Fatalf("getImpactNodesContext error = %v, want context.Canceled", err) + } + if s.called { + t.Fatal("legacy GetNodesByIDs called after cancellation") + } +} diff --git a/internal/analysis/impact_reach_test.go b/internal/analysis/impact_reach_test.go index 873235c2f..83b4fddf9 100644 --- a/internal/analysis/impact_reach_test.go +++ b/internal/analysis/impact_reach_test.go @@ -123,6 +123,45 @@ func TestAnalyzeImpact_ReachKeysPersistAcrossLookups(t *testing.T) { } } +// TestAnalyzeImpact_IncompleteReachRecordNeverLooksSafe reproduces the +// false-safe regression where a concurrent reach build exposed its generation +// stamp before its tier slices. The first query establishes the real blast +// radius; the test then replaces that cache with the exact partially-published +// shape and repeats the query. Lookup must reject the incomplete record and +// recompute, never turn a known impact into LOW / zero affected. +func TestAnalyzeImpact_IncompleteReachRecordNeverLooksSafe(t *testing.T) { + g := newFanInChain(18) + const seed = "sink" + + first := AnalyzeImpact(g, []string{seed}, nil, nil) + if first.TotalAffected != 18 || first.Risk == RiskLow { + t.Fatalf("fixture must establish a non-trivial impact; got risk=%s affected=%d", first.Risk, first.TotalAffected) + } + + n := g.GetNode(seed) + if n == nil { + t.Fatal("seed disappeared from graph") + } + partial := *n + partial.Meta = make(map[string]any, len(n.Meta)) + for key, value := range n.Meta { + if len(key) >= len("reach_") && key[:len("reach_")] == "reach_" { + continue + } + partial.Meta[key] = value + } + // This was the unsafe publication order: the current generation was + // visible while reach_d1/d2/d3 had not been written yet. + partial.Meta[reach.MetaReachBuild] = reach.BuildCounter() + g.AddNode(&partial) + + second := AnalyzeImpact(g, []string{seed}, nil, nil) + if second.TotalAffected != first.TotalAffected || second.Risk != first.Risk { + t.Fatalf("repeated impact under-reported after partial cache publication: first risk=%s affected=%d, second risk=%s affected=%d", + first.Risk, first.TotalAffected, second.Risk, second.TotalAffected) + } +} + // idSet returns a sorted ID slice for set comparison. func idSet(entries []ImpactEntry) []string { out := make([]string, len(entries)) @@ -147,37 +186,25 @@ func setsEqual(a, b []string) bool { // TestAnalyzeImpact_FastPathSubMillisecond commits to the claim // that a precomputed AnalyzeImpact call on a 1000-caller fan-in -// completes well inside a single-digit-ms p99 budget AND is -// substantially faster than the live BFS walk. -// -// The test gates on two signals so it stays meaningful on noisy -// CI runners (where absolute wall-time can be 10x slower than a -// developer laptop without indicating a real regression): +// completes well inside a single-digit-ms p99 budget and does not become +// pathologically slower than the batched live BFS walk. // -// 1. A relative speedup gate: fast path average must be at least -// 1.5x faster than the live walk measured on the same fixture -// in the same process. This captures the "precomputation paid -// off" intent and is immune to CI clock noise — both paths -// experience the same overhead. -// 2. A loose absolute ceiling (15 ms) as a backstop against a -// pathological regression that doesn't ruin the live walk too. -// -// Failing the speedup gate means a regression slipped a live walk -// (or equivalent overhead) into the fast path. +// The absolute ceiling is deliberately loose enough for noisy CI runners +// (where wall-time can be 10x slower than a developer laptop) but catches a +// regression that makes either path non-interactive. The relative ratio is +// logged as a diagnostic, not gated: stripReachIndex makes only the first +// iteration cold because Lookup immediately rebuilds its lazy cache, so the +// two long loops mostly measure identical indexed work and GC/scheduler noise +// can swing their ratio substantially. func TestAnalyzeImpact_FastPathSubMillisecond(t *testing.T) { if testing.Short() { t.Skip("perf gate skipped under -short") } if raceEnabled { - // Race instrumentation adds per-memory-op overhead to both the - // fast path and the live walk equally, but it compresses the - // ratio toward 1.0 — the live walk's BFS is small enough that - // race overhead dominates its wall time, while the fast path's - // map lookups gain almost no headroom. Under -race the - // observed speedup collapses below the 1.3x gate even though - // the precomputed index still saves real work; this test - // belongs to the non-race build only. - t.Skip("perf gate skipped under -race (race instrumentation distorts the ratio)") + // Race instrumentation makes this wall-clock latency ceiling a + // measurement of instrumentation overhead rather than user-visible + // query latency. Concurrency correctness has dedicated -race tests. + t.Skip("perf gate skipped under -race (instrumentation distorts latency)") } g := newFanInChain(1000) reach.BuildIndex(g) @@ -226,13 +253,6 @@ func TestAnalyzeImpact_FastPathSubMillisecond(t *testing.T) { // backends (SQLite), where each per-node query the batching // eliminates is a disk round-trip, not a map read. // - // We therefore keep the absolute sub-ms guarantee (the user-facing - // contract: a blast-radius query stays interactive) and a loose - // regression guard that the fast path is not materially SLOWER than - // the batched live walk — without re-asserting the obsolete - // in-memory speedup premise. - const minSpeedup = 0.9 - speedup := float64(avgLive) / float64(avgFast) t.Logf("AnalyzeImpact on 1000-caller fan-in: fast=%v live=%v speedup=%.2fx (over %d iters)", avgFast, avgLive, speedup, iters) @@ -243,9 +263,6 @@ func TestAnalyzeImpact_FastPathSubMillisecond(t *testing.T) { if avgLive > absoluteCeiling { t.Errorf("live-walk AnalyzeImpact too slow: avg=%v (absolute ceiling=%v)", avgLive, absoluteCeiling) } - if speedup < minSpeedup { - t.Errorf("fast-path is materially slower than the live walk: %.2fx (want >= %.2fx)", speedup, minSpeedup) - } } // stripReachIndex removes every Meta key BuildIndex stamps so the diff --git a/internal/analysis/impact_sqlite_test.go b/internal/analysis/impact_sqlite_test.go new file mode 100644 index 000000000..e49211838 --- /dev/null +++ b/internal/analysis/impact_sqlite_test.go @@ -0,0 +1,66 @@ +package analysis + +import ( + "path/filepath" + "testing" + + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/graph/store_sqlite" +) + +func TestAnalyzeImpactSQLiteRepeatedIsStable(t *testing.T) { + s, err := store_sqlite.Open(filepath.Join(t.TempDir(), "impact.db")) + if err != nil { + t.Fatal(err) + } + defer s.Close() + nodes := []*graph.Node{ + {ID: "seed", Kind: graph.KindFunction, Name: "seed", FilePath: "seed.go"}, + {ID: "direct", Kind: graph.KindFunction, Name: "direct", FilePath: "direct.go"}, + {ID: "transitive", Kind: graph.KindFunction, Name: "transitive", FilePath: "transitive.go"}, + } + edges := []*graph.Edge{ + {From: "direct", To: "seed", Kind: graph.EdgeCalls, Confidence: 0.9}, + {From: "transitive", To: "direct", Kind: graph.EdgeCalls, Confidence: 0.8}, + } + s.AddBatch(nodes, edges) + + first := AnalyzeImpact(s, []string{"seed"}, nil, nil) + for i := 0; i < 3; i++ { + got := AnalyzeImpact(s, []string{"seed"}, nil, nil) + if got.TotalAffected != first.TotalAffected || got.Risk != first.Risk || got.Summary != first.Summary { + t.Fatalf("impact call %d changed: first=%+v got=%+v", i+2, first, got) + } + if got.TotalAffected != 2 { + t.Fatalf("impact call %d total = %d, want 2", i+2, got.TotalAffected) + } + } + if got := s.EdgeCount(); got != len(edges) { + t.Fatalf("edge count after repeated impact = %d, want %d", got, len(edges)) + } +} + +func TestAnalyzeImpactTruncationIsConservative(t *testing.T) { + g := graph.New() + const callers = 5100 + nodes := make([]*graph.Node, 0, callers+1) + edges := make([]*graph.Edge, 0, callers) + nodes = append(nodes, &graph.Node{ID: "seed", Kind: graph.KindFunction, Name: "seed"}) + for i := 0; i < callers; i++ { + id := "caller-" + string(rune(i+1)) + nodes = append(nodes, &graph.Node{ID: id, Kind: graph.KindFunction, Name: id}) + edges = append(edges, &graph.Edge{From: id, To: "seed", Kind: graph.EdgeCalls}) + } + g.AddBatch(nodes, edges) + + result := AnalyzeImpact(g, []string{"seed"}, nil, nil) + if !result.Truncated || !result.LowerBound { + t.Fatalf("truncated/lower_bound = %v/%v, want true/true", result.Truncated, result.LowerBound) + } + if result.Risk == RiskLow { + t.Fatalf("truncated impact returned false-safe LOW risk: %+v", result) + } + if result.TotalAffected == 0 { + t.Fatal("bounded impact discarded all observed callers") + } +} diff --git a/internal/analysis/incremental_cache_retention_test.go b/internal/analysis/incremental_cache_retention_test.go new file mode 100644 index 000000000..681094295 --- /dev/null +++ b/internal/analysis/incremental_cache_retention_test.go @@ -0,0 +1,73 @@ +package analysis + +import ( + "fmt" + "runtime" + "testing" + + "github.com/zzet/gortex/internal/graph" +) + +// BenchmarkLeidenCacheRetainedHeap measures the topology heap that used to be +// pinned by LeidenPartitionCache in addition to its assignment map. It is a +// retained-heap benchmark, not a throughput benchmark; run with -benchtime=1x. +func BenchmarkLeidenCacheRetainedHeap(b *testing.B) { + part := benchmarkLeidenPartition(10_000) + if part == nil || len(part.comm) == 0 { + b.Fatal("benchmark graph produced no Leiden partition") + } + + runtime.GC() + var withTopology runtime.MemStats + runtime.ReadMemStats(&withTopology) + + assignments := part.comm + adjacencyEntries := 0 + for _, neighbors := range part.neighbors { + adjacencyEntries += len(neighbors) + } + part.neighbors = nil + part.degree = nil + part.symbolNodes = nil + runtime.GC() + var assignmentsOnly runtime.MemStats + runtime.ReadMemStats(&assignmentsOnly) + runtime.KeepAlive(assignments) + + var avoided uint64 + if withTopology.HeapAlloc > assignmentsOnly.HeapAlloc { + avoided = withTopology.HeapAlloc - assignmentsOnly.HeapAlloc + } + b.ReportMetric(float64(avoided), "avoided-retained-B") + b.ReportMetric(float64(adjacencyEntries), "avoided-adjacency-entries") + b.ReportMetric(float64(len(assignments)), "retained-assignments") +} + +func benchmarkLeidenPartition(nodeCount int) *leidenPartition { + g := graph.New() + nodes := make([]*graph.Node, 0, nodeCount) + edges := make([]*graph.Edge, 0, nodeCount*2) + for i := 0; i < nodeCount; i++ { + id := fmt.Sprintf("pkg/mod%d/f%d.go::Fn%d", i/50, i, i) + nodes = append(nodes, &graph.Node{ + ID: id, + Kind: graph.KindFunction, + Name: fmt.Sprintf("Fn%d", i), + FilePath: fmt.Sprintf("pkg/mod%d/f%d.go", i/50, i), + Language: "go", + }) + } + for i := range nodes { + base := (i / 50) * 50 + pos := i - base + next := base + (pos+1)%50 + next2 := base + (pos+2)%50 + edges = append(edges, + &graph.Edge{From: nodes[i].ID, To: nodes[next].ID, Kind: graph.EdgeCalls}, + &graph.Edge{From: nodes[i].ID, To: nodes[next2].ID, Kind: graph.EdgeCalls}, + ) + } + g.AddBatch(nodes, edges) + _, part := detectCommunitiesLeidenRaw(g, defaultLeidenOptions()) + return part +} diff --git a/internal/analysis/incremental_communities.go b/internal/analysis/incremental_communities.go index 891c4df2d..99f44fd47 100644 --- a/internal/analysis/incremental_communities.go +++ b/internal/analysis/incremental_communities.go @@ -15,8 +15,10 @@ import ( // changed since the last run that is wasteful: the partition of the // untouched 95% of the graph is bit-for-bit what it was before. // -// The incremental path keeps the last partition in a cache keyed by -// a per-package content fingerprint. On the next request it diffs +// The incremental path keeps the last node-to-community assignments in a cache +// keyed by a per-package content fingerprint. It deliberately does not retain +// the prior weighted adjacency: the current graph is rebuilt for every run. +// On the next request it diffs // the current fingerprints against the cached ones; packages whose // fingerprint is unchanged keep their cached community assignment, // and only the changed packages — plus their immediate cross-package @@ -77,7 +79,7 @@ type leidenGraph struct { // clustering-relevant edges — the caller then yields an empty // partition. func buildLeidenGraph(g graph.Store) *leidenGraph { - nodes := g.AllNodes() + nodes := graph.AllNodesLight(g) // Meta-less scan (see LightEdgeScanner): the Leiden weighting keys only off // e.Kind (via edgeWeight) and endpoints. No kind argument — edgeWeight scores // ~14 kinds, so the kind set is pushed down here rather than duplicated at the @@ -138,8 +140,10 @@ func buildLeidenGraph(g graph.Store) *leidenGraph { } } -// LeidenPartitionCache holds the last Leiden partition so a later -// run can recompute only the packages that changed. It is opaque to +// LeidenPartitionCache holds the last Leiden assignment map so a later run can +// recompute only the packages that changed. It intentionally omits the prior +// adjacency/degree graph, which would duplicate the store's largest topology +// structures for the lifetime of the server. It is opaque to // callers: hand a *LeidenPartitionCache (or nil on the first call) // to DetectCommunitiesLeidenIncremental and store the cache it // returns for next time. A nil cache is always safe — it simply @@ -157,10 +161,6 @@ type LeidenPartitionCache struct { // community key. Reused wholesale for unchanged packages and as // the seed for the restricted re-optimization of changed ones. nodeComm map[string]string - // part is the adjacency + weights the cached partition was - // computed on; needed to evaluate modularity gain during the - // restricted local-moves pass and to relabel the merged result. - part *leidenPartition // edgeIdentityRevisions snapshots the graph's monotonic // provenance-revision counter at cache time. A mismatch means // in-place edge provenance changed under the cache; per-package @@ -234,8 +234,8 @@ func packageKey(filePath string) string { // fingerprint of every package it touches and leaves all others // bit-identical. func fingerprintPackages(g graph.Store) map[string]uint64 { - nodes := g.AllNodes() - edges := g.AllEdges() + nodes := graph.AllNodesLight(g) + edges := graph.EdgesForKindsLight(g) // Symbol-node filter + each node's package, mirroring // buildLeidenGraph so the fingerprint and the partition agree on @@ -347,15 +347,19 @@ func DetectCommunitiesLeidenIncremental( edgeIdentityRevisions: edgeRev, } if part != nil { + // Retain only the assignment map needed to seed later incremental + // runs. The full partition also owns weighted adjacency, degree, and + // symbol-node maps; keeping it here duplicates the graph for the + // lifetime of the MCP server even though incrementalLeiden rebuilds + // current adjacency before every run. newCache.nodeComm = part.comm - newCache.part = part } return result, newCache, stats } // No cache, or a cache whose partition never materialized // (previous graph had no clustering edges): nothing to reuse. - if cache == nil || cache.part == nil || len(cache.nodeComm) == 0 { + if cache == nil || len(cache.nodeComm) == 0 { return fullRecompute("no cached partition") } @@ -393,7 +397,6 @@ func DetectCommunitiesLeidenIncremental( newCache := &LeidenPartitionCache{ pkgFingerprint: curFP, nodeComm: newPart.partition.comm, - part: newPart.partition, edgeIdentityRevisions: edgeRev, } return result, newCache, stats @@ -422,7 +425,7 @@ func incrementalLeiden( ) (*CommunityResult, incrementalResult) { // Package of every current symbol node. pkgOf := make(map[string]string, len(lg.symbolNodes)) - for _, n := range g.AllNodes() { + for _, n := range graph.AllNodesLight(g) { if lg.symbolNodes[n.ID] { pkgOf[n.ID] = packageKey(n.FilePath) } diff --git a/internal/analysis/incremental_communities_test.go b/internal/analysis/incremental_communities_test.go index 228a51587..4be618f59 100644 --- a/internal/analysis/incremental_communities_test.go +++ b/internal/analysis/incremental_communities_test.go @@ -2,6 +2,7 @@ package analysis import ( "fmt" + "reflect" "sort" "testing" @@ -110,9 +111,15 @@ func TestDetectCommunitiesLeidenIncremental(t *testing.T) { if stats.FullRecomputeReason == "" { t.Error("first run should carry a full-recompute reason") } - if cache == nil || cache.part == nil || len(cache.nodeComm) == 0 { + if cache == nil || len(cache.nodeComm) == 0 { t.Fatal("first run did not populate the cache") } + // The long-lived cache must keep assignments only. Retaining the + // raw partition also pins weighted adjacency, degree, and symbol + // maps — a second copy of the graph for the server lifetime. + if _, retained := reflect.TypeOf(*cache).FieldByName("part"); retained { + t.Fatal("Leiden cache retains the full topology partition") + } if len(cache.pkgFingerprint) == 0 { t.Error("first run cached no package fingerprints") } diff --git a/internal/analysis/leiden.go b/internal/analysis/leiden.go index ded0035df..2c1a89e2b 100644 --- a/internal/analysis/leiden.go +++ b/internal/analysis/leiden.go @@ -448,7 +448,7 @@ func buildCommunityResult( totalWeight float64, degree map[string]float64, ) *CommunityResult { - nodes := g.AllNodes() + nodes := graph.AllNodesLight(g) nodeMap := make(map[string]*graph.Node, len(nodes)) for _, n := range nodes { nodeMap[n.ID] = n diff --git a/internal/analysis/light_node_scan_test.go b/internal/analysis/light_node_scan_test.go new file mode 100644 index 000000000..baecd60bc --- /dev/null +++ b/internal/analysis/light_node_scan_test.go @@ -0,0 +1,85 @@ +package analysis + +import ( + "testing" + + "github.com/zzet/gortex/internal/graph" +) + +// lightNodeOnlyStore fails loudly if a global analysis regresses to the full +// node scan. Its light projection delegates to the in-memory graph because no +// metadata decoding is involved there. +type lightNodeOnlyStore struct { + graph.Store + lightCalls int +} + +func (s *lightNodeOnlyStore) AllNodes() []*graph.Node { + panic("global analysis used the full node scan") +} + +func (s *lightNodeOnlyStore) AllNodesLight() []*graph.Node { + s.lightCalls++ + full := s.Store.AllNodes() + out := make([]*graph.Node, 0, len(full)) + for _, n := range full { + out = append(out, &graph.Node{ + ID: n.ID, Kind: n.Kind, Name: n.Name, QualName: n.QualName, + FilePath: n.FilePath, StartLine: n.StartLine, EndLine: n.EndLine, + StartColumn: n.StartColumn, EndColumn: n.EndColumn, Language: n.Language, + RepoPrefix: n.RepoPrefix, WorkspaceID: n.WorkspaceID, ProjectID: n.ProjectID, + Origin: n.Origin, Stub: n.Stub, FetchedAt: n.FetchedAt, + }) + } + return out +} + +func TestDetectCommunitiesLeidenUsesLightNodesForBuildAndResult(t *testing.T) { + base := graph.New() + base.AddNode(&graph.Node{ + ID: "repo/pkg/a.go::A", + Kind: graph.KindFunction, + Name: "A", + FilePath: "pkg/a.go", + }) + base.AddNode(&graph.Node{ + ID: "repo/pkg/b.go::B", + Kind: graph.KindFunction, + Name: "B", + FilePath: "pkg/b.go", + }) + base.AddEdge(&graph.Edge{ + From: "repo/pkg/a.go::A", + To: "repo/pkg/b.go::B", + Kind: graph.EdgeCalls, + }) + + store := &lightNodeOnlyStore{Store: base} + result := DetectCommunitiesLeiden(store) + if result == nil { + t.Fatal("DetectCommunitiesLeiden returned nil") + } + if store.lightCalls < 2 { + t.Fatalf("light node scan calls = %d, want at least 2 (graph build and result materialization)", store.lightCalls) + } +} + +func TestCentralityUsesLightNodeProjection(t *testing.T) { + base := graph.New() + base.AddNode(&graph.Node{ID: "repo/a.go::A", Kind: graph.KindFunction, Name: "A", FilePath: "a.go"}) + base.AddNode(&graph.Node{ID: "repo/b.go::B", Kind: graph.KindFunction, Name: "B", FilePath: "b.go"}) + base.AddEdge(&graph.Edge{From: "repo/a.go::A", To: "repo/b.go::B", Kind: graph.EdgeCalls}) + + store := &lightNodeOnlyStore{Store: base} + pageRank := ComputePageRank(store) + if len(pageRank.Scores) != 2 { + t.Fatalf("PageRank scores = %d, want 2", len(pageRank.Scores)) + } + hits := ComputeHITS(store) + if len(hits.Authorities) != 2 || len(hits.Hubs) != 2 { + t.Fatalf("HITS authorities/hubs = %d/%d, want 2/2", len(hits.Authorities), len(hits.Hubs)) + } + if store.lightCalls != 2 { + t.Fatalf("centrality light node scan calls = %d, want 2", store.lightCalls) + } +} diff --git a/internal/analysis/pagerank.go b/internal/analysis/pagerank.go index 514b7fcf9..62e9a55ac 100644 --- a/internal/analysis/pagerank.go +++ b/internal/analysis/pagerank.go @@ -53,7 +53,7 @@ func ComputePageRank(g graph.Store) *PageRankResult { if g == nil { return &PageRankResult{Scores: map[string]float64{}} } - nodes := excludeProxyNodes(g.AllNodes()) + nodes := excludeProxyNodes(graph.AllNodesLight(g)) n := len(nodes) if n == 0 { return &PageRankResult{Scores: map[string]float64{}} diff --git a/internal/analysis/persistence_snapshot.go b/internal/analysis/persistence_snapshot.go new file mode 100644 index 000000000..aded243c0 --- /dev/null +++ b/internal/analysis/persistence_snapshot.go @@ -0,0 +1,118 @@ +package analysis + +import "fmt" + +// AdjacencyPersistenceSnapshot is the serializable form of an immutable CSR +// adjacency. The slices returned by PersistenceSnapshot alias the live +// snapshot and must be treated as read-only. +type AdjacencyPersistenceSnapshot struct { + IDs []string + Offsets []int32 + Neighbors []int32 + Weights []float64 + OutWeight []float64 + PackageRoots map[string]uint64 +} + +func (a *AdjacencySnapshot) PersistenceSnapshot() AdjacencyPersistenceSnapshot { + if a == nil { + return AdjacencyPersistenceSnapshot{} + } + return AdjacencyPersistenceSnapshot{ + IDs: a.ids, + Offsets: a.offsets, + Neighbors: a.neighbors, + Weights: a.weights, + OutWeight: a.outWeight, + PackageRoots: a.pkgRoots, + } +} + +// RestoreAdjacencySnapshot validates and takes ownership of snapshot's slices. +// Rebuilding only the ID index avoids re-scanning the graph after restart. +func RestoreAdjacencySnapshot(snapshot AdjacencyPersistenceSnapshot) (*AdjacencySnapshot, error) { + n := len(snapshot.IDs) + if n == 0 { + if (len(snapshot.Offsets) != 0 && len(snapshot.Offsets) != 1) || len(snapshot.Neighbors) != 0 || len(snapshot.Weights) != 0 || len(snapshot.OutWeight) != 0 { + return nil, fmt.Errorf("adjacency cache: invalid empty snapshot") + } + return &AdjacencySnapshot{ + ids: snapshot.IDs, index: map[string]int{}, offsets: snapshot.Offsets, + neighbors: snapshot.Neighbors, weights: snapshot.Weights, + outWeight: snapshot.OutWeight, pkgRoots: snapshot.PackageRoots, + }, nil + } + if len(snapshot.Offsets) != n+1 { + return nil, fmt.Errorf("adjacency cache: offsets=%d, want %d", len(snapshot.Offsets), n+1) + } + if len(snapshot.OutWeight) != n { + return nil, fmt.Errorf("adjacency cache: out weights=%d, want %d", len(snapshot.OutWeight), n) + } + if len(snapshot.Neighbors) != len(snapshot.Weights) { + return nil, fmt.Errorf("adjacency cache: neighbors=%d weights=%d", len(snapshot.Neighbors), len(snapshot.Weights)) + } + if snapshot.Offsets[0] != 0 || int(snapshot.Offsets[n]) != len(snapshot.Neighbors) { + return nil, fmt.Errorf("adjacency cache: invalid offset bounds") + } + index := make(map[string]int, n) + for i, id := range snapshot.IDs { + if id == "" { + return nil, fmt.Errorf("adjacency cache: empty id at %d", i) + } + if _, duplicate := index[id]; duplicate { + return nil, fmt.Errorf("adjacency cache: duplicate id %q", id) + } + index[id] = i + if snapshot.Offsets[i] > snapshot.Offsets[i+1] { + return nil, fmt.Errorf("adjacency cache: offsets not monotonic at %d", i) + } + } + for i, neighbor := range snapshot.Neighbors { + if neighbor < 0 || int(neighbor) >= n { + return nil, fmt.Errorf("adjacency cache: neighbor %d out of range at %d", neighbor, i) + } + } + return &AdjacencySnapshot{ + ids: snapshot.IDs, + index: index, + offsets: snapshot.Offsets, + neighbors: snapshot.Neighbors, + weights: snapshot.Weights, + outWeight: snapshot.OutWeight, + pkgRoots: snapshot.PackageRoots, + }, nil +} + +// LeidenPartitionSnapshot is the serializable state needed to resume +// package-incremental community detection after a process restart. +type LeidenPartitionSnapshot struct { + PackageFingerprints map[string]uint64 + NodeCommunities map[string]string +} + +func (c *LeidenPartitionCache) PersistenceSnapshot() LeidenPartitionSnapshot { + if c == nil { + return LeidenPartitionSnapshot{} + } + return LeidenPartitionSnapshot{ + PackageFingerprints: c.pkgFingerprint, + NodeCommunities: c.nodeComm, + } +} + +// RestoreLeidenPartitionCache takes ownership of snapshot's maps and stamps +// the cache with the current process-local edge-identity revision. Durable +// validity was already established by the graph store's mutation gate. +func RestoreLeidenPartitionCache(snapshot LeidenPartitionSnapshot, edgeIdentityRevision int) *LeidenPartitionCache { + if snapshot.PackageFingerprints == nil { + snapshot.PackageFingerprints = map[string]uint64{} + } + if snapshot.NodeCommunities == nil { + snapshot.NodeCommunities = map[string]string{} + } + return &LeidenPartitionCache{ + pkgFingerprint: snapshot.PackageFingerprints, + nodeComm: snapshot.NodeCommunities, + edgeIdentityRevisions: edgeIdentityRevision, + } +} diff --git a/internal/analysis/processes.go b/internal/analysis/processes.go index 2e63680cb..523cd1ca6 100644 --- a/internal/analysis/processes.go +++ b/internal/analysis/processes.go @@ -19,100 +19,170 @@ type Step struct { Depth int `json:"depth"` } +const ( + defaultMaxProcesses = 50 + defaultMaxProcessDepth = 15 + defaultMaxStepsPerProcess = 2048 + defaultMaxTotalProcessSteps = 16384 +) + +// ProcessLimits bounds both the CPU work and retained result size of process +// discovery. Zero values select the safe defaults. +type ProcessLimits struct { + MaxProcesses int + MaxDepth int + MaxStepsPerProcess int + MaxTotalSteps int +} + +func defaultProcessLimits() ProcessLimits { + return ProcessLimits{ + MaxProcesses: defaultMaxProcesses, + MaxDepth: defaultMaxProcessDepth, + MaxStepsPerProcess: defaultMaxStepsPerProcess, + MaxTotalSteps: defaultMaxTotalProcessSteps, + } +} + +func (l ProcessLimits) normalized() ProcessLimits { + d := defaultProcessLimits() + if l.MaxProcesses > 0 { + d.MaxProcesses = l.MaxProcesses + } + if l.MaxDepth > 0 { + d.MaxDepth = l.MaxDepth + } + if l.MaxStepsPerProcess > 0 { + d.MaxStepsPerProcess = l.MaxStepsPerProcess + } + if l.MaxTotalSteps > 0 { + d.MaxTotalSteps = l.MaxTotalSteps + } + return d +} + // Process represents a discovered execution flow in the codebase. type Process struct { ID string `json:"id"` Name string `json:"name"` // human-readable name EntryPoint string `json:"entry_point"` // node ID of the entry function - Steps []Step `json:"steps"` // DFS preorder with call-tree depth + Steps []Step `json:"steps"` // bounded DFS preorder with call-tree depth StepCount int `json:"step_count"` Files []string `json:"files"` // unique files touched Score float64 `json:"score"` // entry point confidence score + Truncated bool `json:"truncated,omitempty"` } // ProcessResult is the output of process discovery. type ProcessResult struct { - Processes []Process `json:"processes"` - NodeToProcs map[string][]string `json:"node_to_processes"` // nodeID → process IDs + Processes []Process `json:"processes"` + NodeToProcs map[string][]string `json:"node_to_processes"` // nodeID → process IDs + Truncated bool `json:"truncated,omitempty"` + TruncationReason string `json:"truncation_reason,omitempty"` } -// DiscoverProcesses finds execution flows by identifying entry points and tracing forward. +// DiscoverProcesses finds execution flows using fixed, conservative limits so +// a connected multi-repository graph cannot leave O(processes*nodes) retained +// in a long-lived daemon. func DiscoverProcesses(g graph.Store) *ProcessResult { - nodes := g.AllNodes() - // Meta-less call-edge scan (see LightEdgeScanner): process discovery reads only - // endpoints off each call edge. - edges := graph.EdgesForKindsLight(g, graph.EdgeCalls) + return DiscoverProcessesWithLimits(g, defaultProcessLimits()) +} + +// DiscoverProcessesWithLimits is DiscoverProcesses with explicit bounds. It is +// primarily useful to tests and specialized callers that need a smaller result. +func DiscoverProcessesWithLimits(g graph.Store, limits ProcessLimits) *ProcessResult { + return discoverProcesses(g.AllNodes(), graph.EdgesForKindsLight(g, graph.EdgeCalls), limits) +} - // Build call graph adjacency (forward only) - callees := make(map[string][]string) // who does this function call? - callers := make(map[string][]string) // who calls this function? +func discoverProcesses(nodes []*graph.Node, edges []*graph.Edge, limits ProcessLimits) *ProcessResult { + limits = limits.normalized() + callees := make(map[string][]string) + callers := make(map[string][]string) for _, e := range edges { - if e.Kind == graph.EdgeCalls { + if e != nil && e.Kind == graph.EdgeCalls { callees[e.From] = append(callees[e.From], e.To) callers[e.To] = append(callers[e.To], e.From) } } + // SQLite and in-memory stores do not promise edge scan order. Stable child + // order makes a bounded prefix reproducible across runs and backends. + for id := range callees { + sort.Strings(callees[id]) + } - // Score each function/method as a potential entry point type scored struct { node *graph.Node score float64 } var candidates []scored - - nodeMap := make(map[string]*graph.Node) + nodeMap := make(map[string]*graph.Node, len(nodes)) for _, n := range nodes { + if n == nil { + continue + } nodeMap[n.ID] = n if n.Kind != graph.KindFunction && n.Kind != graph.KindMethod { continue } - score := scoreEntryPoint(n, len(callees[n.ID]), len(callers[n.ID])) if score > 0.5 { - candidates = append(candidates, scored{n, score}) + candidates = append(candidates, scored{node: n, score: score}) } } - - // Sort by score descending sort.Slice(candidates, func(i, j int) bool { + if candidates[i].score == candidates[j].score { + return candidates[i].node.ID < candidates[j].node.ID + } return candidates[i].score > candidates[j].score }) - // Trace forward from each entry point to build processes - result := &ProcessResult{ - NodeToProcs: make(map[string][]string), - } - - seen := make(map[string]bool) // avoid duplicate processes + result := &ProcessResult{NodeToProcs: make(map[string][]string)} + seen := make(map[string]bool) + totalSteps := 0 + stepLimited := false + processLimited := false for i, c := range candidates { - if i >= 50 { // cap at 50 processes + if i >= limits.MaxProcesses { + processLimited = true break } if seen[c.node.ID] { continue } - - steps := traceForward(c.node.ID, callees, 15) // max depth 15 + remaining := limits.MaxTotalSteps - totalSteps + if remaining < 2 { + stepLimited = true + break + } + stepCap := limits.MaxStepsPerProcess + if remaining < stepCap { + stepCap = remaining + } + steps, truncated := traceForwardBounded(c.node.ID, callees, limits.MaxDepth, stepCap) if len(steps) < 2 { - continue // not interesting + continue } seen[c.node.ID] = true + totalSteps += len(steps) + if truncated { + stepLimited = true + } fileSet := make(map[string]bool) - for _, s := range steps { - if n, ok := nodeMap[s.ID]; ok { + for _, step := range steps { + if n, ok := nodeMap[step.ID]; ok && n.FilePath != "" { fileSet[n.FilePath] = true } } files := make([]string, 0, len(fileSet)) - for f := range fileSet { - files = append(files, f) + for file := range fileSet { + files = append(files, file) } sort.Strings(files) procID := fmt.Sprintf("process-%d", len(result.Processes)) - proc := Process{ + result.Processes = append(result.Processes, Process{ ID: procID, Name: inferProcessName(c.node), EntryPoint: c.node.ID, @@ -120,14 +190,22 @@ func DiscoverProcesses(g graph.Store) *ProcessResult { StepCount: len(steps), Files: files, Score: c.score, - } - result.Processes = append(result.Processes, proc) - - for _, s := range steps { - result.NodeToProcs[s.ID] = append(result.NodeToProcs[s.ID], procID) + Truncated: truncated, + }) + for _, step := range steps { + result.NodeToProcs[step.ID] = append(result.NodeToProcs[step.ID], procID) } } + result.Truncated = stepLimited || processLimited + switch { + case stepLimited && processLimited: + result.TruncationReason = "step_limit,process_limit" + case stepLimited: + result.TruncationReason = "step_limit" + case processLimited: + result.TruncationReason = "process_limit" + } return result } @@ -136,45 +214,28 @@ func scoreEntryPoint(n *graph.Node, calleeCount, callerCount int) float64 { return 0 // leaf functions are not entry points } - // Base score: ratio of outgoing to incoming calls base := float64(calleeCount) / (float64(callerCount) + 1.0) - - // Name pattern multiplier nameMult := namePatternMultiplier(n.Name, n.Language) - - // Export/visibility multiplier exportMult := 1.0 if isExportedForProcess(n) { exportMult = 1.5 } - - // Low caller count bonus (true entry points have few callers) callerMult := 1.0 if callerCount == 0 { callerMult = 2.0 } else if callerCount <= 2 { callerMult = 1.3 } - - // Framework entry points stamped by the entrypoints detector (Spring - // handlers, JAX-RS resources, annotated servlets, the JVM main, …) - // are invoked by a runtime, not application code — the most reliable - // process roots. Test fixtures are stamped too (so dead-code keeps - // them live) but are noise as top-level processes, so skip the boost - // for them. entryMult := 1.0 if ep, _ := n.Meta["entry_point"].(bool); ep { if kind, _ := n.Meta["entry_point_kind"].(string); !strings.HasPrefix(kind, "junit:") { entryMult = 2.0 } } - return base * nameMult * exportMult * callerMult * entryMult } -// isExportedForProcess mirrors the dead-code visibility logic: for -// keyword-visibility languages (Java) it trusts the recorded modifier -// so a private helper isn't handed the public-API entry-point boost. +// isExportedForProcess mirrors the dead-code visibility logic. func isExportedForProcess(n *graph.Node) bool { if n.Language == "java" { if v, ok := n.Meta["visibility"].(string); ok && v != "" { @@ -186,8 +247,6 @@ func isExportedForProcess(n *graph.Node) bool { func namePatternMultiplier(name, lang string) float64 { lower := strings.ToLower(name) - - // High-value entry point patterns entryPatterns := []string{ "main", "init", "run", "start", "serve", "listen", "handle", "handler", "controller", "middleware", @@ -198,8 +257,6 @@ func namePatternMultiplier(name, lang string) float64 { return 1.5 } } - - // Go-specific if lang == "go" { if strings.HasPrefix(name, "New") || strings.HasPrefix(name, "Serve") { return 1.3 @@ -208,8 +265,6 @@ func namePatternMultiplier(name, lang string) float64 { return 0.3 } } - - // Utility patterns (deprioritize) utilPatterns := []string{ "get", "set", "is", "has", "to", "from", "parse", "format", "validate", "helper", "util", "string", @@ -219,7 +274,6 @@ func namePatternMultiplier(name, lang string) float64 { return 0.5 } } - return 1.0 } @@ -227,31 +281,44 @@ func isExported(name, lang string) bool { if lang == "go" { return len(name) > 0 && name[0] >= 'A' && name[0] <= 'Z' } - // For other languages, assume exported if not starting with underscore return !strings.HasPrefix(name, "_") } -func traceForward(startID string, callees map[string][]string, maxDepth int) []Step { - var result []Step - visited := make(map[string]bool) +func traceForwardBounded(startID string, callees map[string][]string, maxDepth, maxSteps int) ([]Step, bool) { + if maxSteps <= 0 { + return nil, true + } + result := make([]Step, 0, min(maxSteps, 64)) + visited := make(map[string]bool, min(maxSteps, 64)) + truncated := false var dfs func(id string, depth int) dfs = func(id string, depth int) { - if visited[id] || depth > maxDepth { + if truncated || visited[id] || depth > maxDepth { + return + } + if len(result) >= maxSteps { + truncated = true return } visited[id] = true result = append(result, Step{ID: id, Depth: depth}) - for _, callee := range callees[id] { - if !visited[callee] { - dfs(callee, depth+1) + if visited[callee] { + continue + } + if len(result) >= maxSteps { + truncated = true + return + } + dfs(callee, depth+1) + if truncated { + return } } } - dfs(startID, 0) - return result + return result, truncated } func inferProcessName(n *graph.Node) string { diff --git a/internal/analysis/processes_test.go b/internal/analysis/processes_test.go new file mode 100644 index 000000000..d705957c9 --- /dev/null +++ b/internal/analysis/processes_test.go @@ -0,0 +1,140 @@ +package analysis + +import ( + "fmt" + "reflect" + "testing" + + "github.com/zzet/gortex/internal/graph" +) + +func processFixture(entryCount, leafCount int) ([]*graph.Node, []*graph.Edge) { + nodes := make([]*graph.Node, 0, entryCount+leafCount) + edges := make([]*graph.Edge, 0, entryCount*leafCount) + for i := 0; i < leafCount; i++ { + nodes = append(nodes, &graph.Node{ + ID: fmt.Sprintf("leaf-%04d", i), + Name: fmt.Sprintf("parseLeaf%04d", i), + Kind: graph.KindFunction, + Language: "go", + FilePath: fmt.Sprintf("pkg/leaf_%04d.go", i), + }) + } + for i := 0; i < entryCount; i++ { + id := fmt.Sprintf("entry-%04d", i) + nodes = append(nodes, &graph.Node{ + ID: id, + Name: fmt.Sprintf("Serve%04d", i), + Kind: graph.KindFunction, + Language: "go", + FilePath: fmt.Sprintf("cmd/entry_%04d.go", i), + }) + for j := leafCount - 1; j >= 0; j-- { + edges = append(edges, &graph.Edge{ + From: id, + To: fmt.Sprintf("leaf-%04d", j), + Kind: graph.EdgeCalls, + }) + } + } + return nodes, edges +} + +func TestDiscoverProcessesKeepsSmallGraphComplete(t *testing.T) { + nodes := []*graph.Node{ + {ID: "root", Name: "ServeRoot", Kind: graph.KindFunction, Language: "go", FilePath: "cmd/root.go"}, + {ID: "child", Name: "parseChild", Kind: graph.KindFunction, Language: "go", FilePath: "pkg/child.go"}, + {ID: "leaf", Name: "parseLeaf", Kind: graph.KindFunction, Language: "go", FilePath: "pkg/leaf.go"}, + } + edges := []*graph.Edge{ + {From: "root", To: "child", Kind: graph.EdgeCalls}, + {From: "child", To: "leaf", Kind: graph.EdgeCalls}, + } + + got := discoverProcesses(nodes, edges, ProcessLimits{}) + if got.Truncated || got.TruncationReason != "" { + t.Fatalf("small graph unexpectedly truncated: %#v", got) + } + if len(got.Processes) != 1 { + t.Fatalf("processes = %d, want 1", len(got.Processes)) + } + wantSteps := []Step{{ID: "root", Depth: 0}, {ID: "child", Depth: 1}, {ID: "leaf", Depth: 2}} + if !reflect.DeepEqual(got.Processes[0].Steps, wantSteps) { + t.Fatalf("steps = %#v, want %#v", got.Processes[0].Steps, wantSteps) + } + if got.Processes[0].Truncated { + t.Fatal("complete process marked truncated") + } +} + +func TestDiscoverProcessesBoundsRetainedStepsDeterministically(t *testing.T) { + nodes, edges := processFixture(12, 80) + limits := ProcessLimits{ + MaxProcesses: 10, + MaxDepth: 15, + MaxStepsPerProcess: 32, + MaxTotalSteps: 128, + } + + first := discoverProcesses(nodes, edges, limits) + second := discoverProcesses(nodes, edges, limits) + if !reflect.DeepEqual(first, second) { + t.Fatal("bounded process discovery is not deterministic") + } + if !first.Truncated || first.TruncationReason != "step_limit" { + t.Fatalf("truncation = %v %q, want step_limit", first.Truncated, first.TruncationReason) + } + if len(first.Processes) != 4 { + t.Fatalf("processes = %d, want 4 at the total-step ceiling", len(first.Processes)) + } + + totalSteps := 0 + memberships := 0 + for _, process := range first.Processes { + if len(process.Steps) > limits.MaxStepsPerProcess { + t.Fatalf("process %s retained %d steps, limit %d", process.ID, len(process.Steps), limits.MaxStepsPerProcess) + } + if !process.Truncated { + t.Fatalf("process %s should report its bounded prefix", process.ID) + } + totalSteps += len(process.Steps) + } + for _, processIDs := range first.NodeToProcs { + memberships += len(processIDs) + } + if totalSteps > limits.MaxTotalSteps { + t.Fatalf("retained %d steps, limit %d", totalSteps, limits.MaxTotalSteps) + } + if memberships != totalSteps { + t.Fatalf("node-to-process memberships = %d, retained steps = %d", memberships, totalSteps) + } +} + +func TestTraceForwardBoundedHandlesCycles(t *testing.T) { + callees := map[string][]string{ + "a": {"b"}, + "b": {"c"}, + "c": {"a"}, + } + steps, truncated := traceForwardBounded("a", callees, 15, 8) + if truncated { + t.Fatal("finite cycle should complete without truncation") + } + want := []Step{{ID: "a", Depth: 0}, {ID: "b", Depth: 1}, {ID: "c", Depth: 2}} + if !reflect.DeepEqual(steps, want) { + t.Fatalf("steps = %#v, want %#v", steps, want) + } +} + +func BenchmarkDiscoverProcessesBoundedDense(b *testing.B) { + nodes, edges := processFixture(50, 4096) + limits := defaultProcessLimits() + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + result := discoverProcesses(nodes, edges, limits) + if len(result.Processes) == 0 { + b.Fatal("no processes discovered") + } + } +} diff --git a/internal/claudemd/generator.go b/internal/claudemd/generator.go index cfae458b3..308e52dd8 100644 --- a/internal/claudemd/generator.go +++ b/internal/claudemd/generator.go @@ -69,7 +69,7 @@ func Generate(engine *query.Engine, _ int) string { } b.WriteString("\n## MANDATORY: Use Gortex MCP tools instead of Read/Grep/Glob\n\n") - b.WriteString("Gortex is running as an MCP server. You **MUST** prefer graph queries over file reads on every task in this repo — `search_symbols`, `find_usages`, `get_symbol_source`, `get_editing_context`, `smart_context`, `edit_symbol` / `edit_file` / `rename_symbol` / `batch_edit`. PreToolUse hooks deny `Read` / `Grep` / `Glob` against indexed source; the deny message names the right tool. The full per-tool catalog loads via `tools/list` — not restated here.\n\n") + b.WriteString("Gortex is running as an MCP server. You **MUST** prefer graph queries over file reads on every task in this repo — `search_symbols`, `find_usages`, `get_symbol_source`, `get_editing_context`, `smart_context`, `edit_symbol` / `edit_file` / `rename_symbol` / `batch_edit`. Hook posture is configurable; follow every Gortex hook instruction even when `Read` / `Grep` / `Glob` remain callable. The full per-tool catalog loads via `tools/list` — not restated here.\n\n") b.WriteString("### Calibration: the graph narrows scope, source confirms behavior\n\n") b.WriteString("The mandate above stands — but graph queries *narrow scope*, they do not *replace reading the implementation*. The graph tells you **where** the logic lives and **what** connects to it; the source tells you **how** it behaves. For the symbol you are about to change or depend on, read its full body with `get_symbol_source` — do not act on a one-line summary alone.\n\n") diff --git a/internal/config/config.go b/internal/config/config.go index dfb43c5db..6aa4465d3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1649,6 +1649,11 @@ type MCPToolsConfig struct { Mode string `mapstructure:"mode" yaml:"mode,omitempty"` Allow []string `mapstructure:"allow" yaml:"allow,omitempty"` Deny []string `mapstructure:"deny" yaml:"deny,omitempty"` + // Explicit records whether the operator supplied any mcp.tools field. + // It is runtime provenance, not serialized configuration, and lets an + // explicit core/defer rollback outrank named-client defaults even though it + // has the same values as the shipped compatibility fallback. + Explicit bool `mapstructure:"-" yaml:"-"` } // defaultMaxParseBytesInFlight caps concurrent raw source bytes in @@ -1774,6 +1779,9 @@ func Load(configPath string) (*Config, error) { if err := v.Unmarshal(cfg); err != nil { return nil, err } + cfg.MCP.Tools.Explicit = v.IsSet("mcp.tools") || + v.IsSet("mcp.tools.preset") || v.IsSet("mcp.tools.mode") || + v.IsSet("mcp.tools.allow") || v.IsSet("mcp.tools.deny") cfg.Scope = cfg.Scope.MergeEnv() if err := cfg.validateWorkspaceSchema(); err != nil { diff --git a/internal/config/mcp_tools_default_test.go b/internal/config/mcp_tools_default_test.go index 57e822834..2d76567e5 100644 --- a/internal/config/mcp_tools_default_test.go +++ b/internal/config/mcp_tools_default_test.go @@ -28,6 +28,7 @@ func TestLoad_MCPToolsDefaultAndOverride(t *testing.T) { require.NoError(t, err) require.Equal(t, "core", cfg.MCP.Tools.Preset, "omitted mcp.tools keeps the core default") require.Equal(t, "defer", cfg.MCP.Tools.Mode) + require.False(t, cfg.MCP.Tools.Explicit) // An explicit preset overrides the default — the documented opt-out // back to the full eager surface. @@ -35,6 +36,17 @@ func TestLoad_MCPToolsDefaultAndOverride(t *testing.T) { require.NoError(t, os.WriteFile(full, []byte("mcp:\n tools:\n preset: full\n mode: hide\n"), 0o644)) cfg2, err := Load(full) require.NoError(t, err) + require.True(t, cfg2.MCP.Tools.Explicit) require.Equal(t, "full", cfg2.MCP.Tools.Preset) require.Equal(t, "hide", cfg2.MCP.Tools.Mode) } + +func TestLoad_MCPToolsExplicitCoreDeferKeepsProvenance(t *testing.T) { + path := filepath.Join(t.TempDir(), ".gortex.yaml") + require.NoError(t, os.WriteFile(path, []byte("mcp:\n tools:\n preset: core\n mode: defer\n"), 0o644)) + cfg, err := Load(path) + require.NoError(t, err) + require.Equal(t, "core", cfg.MCP.Tools.Preset) + require.Equal(t, "defer", cfg.MCP.Tools.Mode) + require.True(t, cfg.MCP.Tools.Explicit, "explicit rollback must remain distinguishable from defaults") +} diff --git a/internal/contracts/registry.go b/internal/contracts/registry.go index 8895f6248..4c1521880 100644 --- a/internal/contracts/registry.go +++ b/internal/contracts/registry.go @@ -202,6 +202,47 @@ func (r *Registry) ByFile(filePath string) []Contract { return out } +// ReplaceFile atomically replaces the contracts owned by one source file while +// preserving every other file's registry entries. Incremental indexing uses +// this to keep contract extraction bounded to the changed-file frontier. +func (r *Registry) ReplaceFile(filePath string, list []Contract) { + r.mu.Lock() + defer r.mu.Unlock() + + for _, old := range r.byFilePath[filePath] { + r.byID[old.ID] = removeContract(r.byID[old.ID], old) + if len(r.byID[old.ID]) == 0 { + delete(r.byID, old.ID) + } + r.byRepo[old.RepoPrefix] = removeContract(r.byRepo[old.RepoPrefix], old) + if len(r.byRepo[old.RepoPrefix]) == 0 { + delete(r.byRepo, old.RepoPrefix) + } + if old.SymbolID != "" { + r.bySymbol[old.SymbolID] = removeContract(r.bySymbol[old.SymbolID], old) + if len(r.bySymbol[old.SymbolID]) == 0 { + delete(r.bySymbol, old.SymbolID) + } + } + workspace := old.EffectiveWorkspace() + r.byWorkspace[workspace] = removeContract(r.byWorkspace[workspace], old) + if len(r.byWorkspace[workspace]) == 0 { + delete(r.byWorkspace, workspace) + } + } + delete(r.byFilePath, filePath) + + for _, c := range list { + r.byID[c.ID] = append(r.byID[c.ID], c) + r.byRepo[c.RepoPrefix] = append(r.byRepo[c.RepoPrefix], c) + if c.SymbolID != "" { + r.bySymbol[c.SymbolID] = append(r.bySymbol[c.SymbolID], c) + } + r.byFilePath[c.FilePath] = append(r.byFilePath[c.FilePath], c) + r.byWorkspace[c.EffectiveWorkspace()] = append(r.byWorkspace[c.EffectiveWorkspace()], c) + } +} + // ByWorkspace returns every contract whose effective workspace // (WorkspaceID || RepoPrefix default) equals workspaceID. Used by // per-workspace `contracts check` calls so each workspace's matcher diff --git a/internal/contracts/registry_replace_test.go b/internal/contracts/registry_replace_test.go new file mode 100644 index 000000000..8e4d4b2aa --- /dev/null +++ b/internal/contracts/registry_replace_test.go @@ -0,0 +1,35 @@ +package contracts + +import "testing" + +func TestRegistryReplaceFileUpdatesEveryIndex(t *testing.T) { + reg := NewRegistry() + oldProvider := Contract{ID: "http::GET::/old", Role: RoleProvider, RepoPrefix: "repo", WorkspaceID: "ws", FilePath: "a.go", SymbolID: "a.go::Old"} + oldConsumer := Contract{ID: "http::GET::/dep", Role: RoleConsumer, RepoPrefix: "repo", WorkspaceID: "ws", FilePath: "a.go", SymbolID: "a.go::Call"} + unchanged := Contract{ID: "http::GET::/keep", Role: RoleProvider, RepoPrefix: "repo", WorkspaceID: "ws", FilePath: "b.go", SymbolID: "b.go::Keep"} + reg.Add(oldProvider) + reg.Add(oldConsumer) + reg.Add(unchanged) + + fresh := Contract{ID: "http::POST::/new", Role: RoleProvider, RepoPrefix: "repo", WorkspaceID: "ws", FilePath: "a.go", SymbolID: "a.go::New"} + reg.ReplaceFile("a.go", []Contract{fresh}) + + if got := reg.ByFile("a.go"); len(got) != 1 || got[0].ID != fresh.ID { + t.Fatalf("replacement file index = %#v", got) + } + if got := reg.ByID(oldProvider.ID); len(got) != 0 { + t.Fatalf("old ID survived replacement: %#v", got) + } + if got := reg.BySymbol(oldConsumer.SymbolID); len(got) != 0 { + t.Fatalf("old symbol survived replacement: %#v", got) + } + if got := reg.ByFile("b.go"); len(got) != 1 || got[0].ID != unchanged.ID { + t.Fatalf("unchanged file was disturbed: %#v", got) + } + if got := reg.ByRepo("repo"); len(got) != 2 { + t.Fatalf("repo index size = %d, want replacement + unchanged", len(got)) + } + if got := reg.ByWorkspace("ws"); len(got) != 2 { + t.Fatalf("workspace index size = %d, want replacement + unchanged", len(got)) + } +} diff --git a/internal/daemon/client.go b/internal/daemon/client.go index 223b5fb4d..6cc747494 100644 --- a/internal/daemon/client.go +++ b/internal/daemon/client.go @@ -131,6 +131,12 @@ func (c *Client) WriteMCPFrame(frame []byte) error { // ReadMCPFrame reads one MCP JSON-RPC frame from the daemon. Returns // io.EOF when the daemon closes the connection. func (c *Client) ReadMCPFrame() ([]byte, error) { + if c.reader == nil { + if c.Conn == nil { + return nil, net.ErrClosed + } + c.reader = bufio.NewReader(c.Conn) + } line, err := c.reader.ReadBytes('\n') if err != nil { return nil, err diff --git a/internal/daemon/federation.go b/internal/daemon/federation.go index 1643fab2d..5df2575dd 100644 --- a/internal/daemon/federation.go +++ b/internal/daemon/federation.go @@ -15,8 +15,8 @@ import ( ) // federationReadTools is the allowlist of read traversal tools eligible -// for remote fan-out. Anything not here (and everything in MutatingTools) -// is never federated. +// for remote fan-out. Anything not here (and every effectful tool) is never +// federated. var federationReadTools = map[string]bool{ "find_usages": true, "get_callers": true, @@ -35,11 +35,11 @@ const localSchemaMajor = 1 // FederationConfig carries the tunable knobs (from .gortex.yaml's // federation: block). Zero values fall back to sane defaults. type FederationConfig struct { - PerRemoteTimeout time.Duration - Budget time.Duration - BreakerThreshold int - BreakerCooldown time.Duration - HealthTTL time.Duration + PerRemoteTimeout time.Duration + Budget time.Duration + BreakerThreshold int + BreakerCooldown time.Duration + HealthTTL time.Duration NameKeyedFallback bool } @@ -125,7 +125,7 @@ func (f *Federator) Augment(ctx context.Context, tool string, body, localResult // roster) is unaffected; the federation{} block + origins map are // the additive superset that appears only when there is something to // federate. - if !federationReadTools[tool] || MutatingTools[tool] || len(remotes) == 0 { + if !federationReadTools[tool] || IsEffectful(tool) || len(remotes) == 0 { return localResult } diff --git a/internal/daemon/federation_test.go b/internal/daemon/federation_test.go index 8b79381b8..d4383ed9f 100644 --- a/internal/daemon/federation_test.go +++ b/internal/daemon/federation_test.go @@ -434,3 +434,15 @@ func TestFederator_WriteToolNeverFederated(t *testing.T) { } } } + +// TestFederationReadAllowlistIsEffectFree makes the fan-out allowlist and the +// canonical effect registry a two-key gate. A future session/control writer +// must not become remotely fan-out eligible merely because it is intentionally +// excluded from the planning-mode IsMutating set. +func TestFederationReadAllowlistIsEffectFree(t *testing.T) { + for tool := range federationReadTools { + if effect := EffectOf(tool); effect != EffectNone { + t.Errorf("federation read allowlist contains effectful tool %q (effect=%d)", tool, effect) + } + } +} diff --git a/internal/daemon/mcp_request_lifetime.go b/internal/daemon/mcp_request_lifetime.go new file mode 100644 index 000000000..a6ab6cdf6 --- /dev/null +++ b/internal/daemon/mcp_request_lifetime.go @@ -0,0 +1,501 @@ +package daemon + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net" + "os" + "strconv" + "strings" + "sync" + "time" +) + +const ( + // DefaultMCPToolCallTimeout leaves time for a host to receive a terminal + // response before its common 300-second transport timeout. + DefaultMCPToolCallTimeout = 60 * time.Second + + mcpRequestCancelledCode = -32800 + mcpRequestTimeoutCode = -32001 +) + +type mcpRequestEnvelope struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + Params struct { + RequestID json.RawMessage `json:"requestId"` + } `json:"params"` +} + +func canonicalMCPRequestID(id json.RawMessage) (string, bool) { + if len(id) == 0 { + return "", false + } + decoder := json.NewDecoder(bytes.NewReader(id)) + decoder.UseNumber() + var decoded any + if err := decoder.Decode(&decoded); err != nil { + return "", false + } + var trailing any + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + return "", false + } + switch value := decoded.(type) { + case nil: + return "z:null", true + case string: + return "s:" + value, true + case json.Number: + return "n:" + value.String(), true + default: + return "", false + } +} + +type mcpInFlightRequest struct { + key string + cancel context.CancelFunc +} + +type mcpRequestRegistry struct { + mu sync.Mutex + requests map[string]*mcpInFlightRequest +} + +func (r *mcpRequestRegistry) begin(parent context.Context, id json.RawMessage, timeout time.Duration) (context.Context, *mcpInFlightRequest, bool) { + key, valid := canonicalMCPRequestID(id) + if !valid { + return nil, nil, false + } + ctx, cancel := context.WithTimeout(parent, timeout) + entry := &mcpInFlightRequest{key: key, cancel: cancel} + + r.mu.Lock() + defer r.mu.Unlock() + if r.requests == nil { + r.requests = make(map[string]*mcpInFlightRequest) + } + if _, exists := r.requests[key]; exists { + cancel() + return nil, nil, false + } + r.requests[key] = entry + return ctx, entry, true +} + +func (r *mcpRequestRegistry) finish(entry *mcpInFlightRequest) { + if entry == nil { + return + } + r.mu.Lock() + if r.requests[entry.key] == entry { + delete(r.requests, entry.key) + } + r.mu.Unlock() + entry.cancel() +} + +func (r *mcpRequestRegistry) cancel(id json.RawMessage) bool { + key, valid := canonicalMCPRequestID(id) + if !valid { + return false + } + r.mu.Lock() + entry := r.requests[key] + r.mu.Unlock() + if entry == nil { + return false + } + entry.cancel() + return true +} + +func (r *mcpRequestRegistry) cancelAll() { + r.mu.Lock() + entries := make([]*mcpInFlightRequest, 0, len(r.requests)) + for key, entry := range r.requests { + entries = append(entries, entry) + delete(r.requests, key) + } + r.mu.Unlock() + for _, entry := range entries { + entry.cancel() + } +} + +type mcpResponseWriter struct { + mu sync.Mutex + conn net.Conn +} + +func (w *mcpResponseWriter) write(frame []byte) error { + if len(frame) == 0 { + return nil + } + w.mu.Lock() + defer w.mu.Unlock() + frame = append(append([]byte(nil), frame...), '\n') + for len(frame) > 0 { + n, err := w.conn.Write(frame) + if err != nil { + return err + } + frame = frame[n:] + } + return nil +} + +type mcpFrameDispatch func(context.Context, []byte) ([]byte, error) + +type mcpDispatchResult struct { + reply []byte + err error +} + +const ( + defaultMaxConcurrentMCPDispatches = 8 + maxConfiguredMCPDispatches = 64 + mcpDispatchLimitEnv = "GORTEX_MCP_MAX_CONCURRENT_DISPATCHES" +) + +func mcpDispatchLimit(raw string) int { + if strings.TrimSpace(raw) == "" { + return defaultMaxConcurrentMCPDispatches + } + limit, err := strconv.Atoi(strings.TrimSpace(raw)) + if err != nil || limit < 1 { + return defaultMaxConcurrentMCPDispatches + } + if limit > maxConfiguredMCPDispatches { + return maxConfiguredMCPDispatches + } + return limit +} + +func newMCPDispatchSlots(limit int) chan struct{} { + if limit < 1 { + limit = defaultMaxConcurrentMCPDispatches + } + return make(chan struct{}, limit) +} + +var mcpDispatchSlots = newMCPDispatchSlots(mcpDispatchLimit(os.Getenv(mcpDispatchLimitEnv))) + +var errMCPDispatchSaturated = errors.New("MCP dispatcher capacity is saturated") + +type mcpDispatchAdmission struct { + once sync.Once + done chan struct{} +} + +type mcpDispatchAdmissionContextKey struct{} +type mcpDispatchWaitContextKey struct{} +type mcpDispatchSlotsContextKey struct{} + +func newMCPDispatchAdmission() *mcpDispatchAdmission { + return &mcpDispatchAdmission{done: make(chan struct{})} +} + +func withMCPDispatchAdmission(ctx context.Context, admission *mcpDispatchAdmission) context.Context { + return context.WithValue(ctx, mcpDispatchAdmissionContextKey{}, admission) +} + +func withMCPDispatchWait(ctx context.Context) context.Context { + return context.WithValue(ctx, mcpDispatchWaitContextKey{}, true) +} + +func withMCPDispatchSlots(ctx context.Context, slots chan struct{}) context.Context { + return context.WithValue(ctx, mcpDispatchSlotsContextKey{}, slots) +} + +func mcpDispatchSlotsForContext(ctx context.Context) chan struct{} { + if slots, _ := ctx.Value(mcpDispatchSlotsContextKey{}).(chan struct{}); slots != nil { + return slots + } + return mcpDispatchSlots +} + +func signalMCPDispatchAdmission(ctx context.Context) { + admission, _ := ctx.Value(mcpDispatchAdmissionContextKey{}).(*mcpDispatchAdmission) + if admission != nil { + admission.once.Do(func() { close(admission.done) }) + } +} + +// dispatchMCPWithContext bounds detached dispatcher work process-wide. A +// cancelled caller returns without waiting for an uncooperative dispatcher; +// the buffered result lets that dispatcher finish without retaining a waiter. +func dispatchMCPWithContext(ctx context.Context, dispatch func() ([]byte, error)) ([]byte, error) { + slots := mcpDispatchSlotsForContext(ctx) + if wait, _ := ctx.Value(mcpDispatchWaitContextKey{}).(bool); wait { + select { + case slots <- struct{}{}: + case <-ctx.Done(): + signalMCPDispatchAdmission(ctx) + return nil, ctx.Err() + } + } else { + select { + case slots <- struct{}{}: + default: + signalMCPDispatchAdmission(ctx) + return nil, errMCPDispatchSaturated + } + } + + result := make(chan mcpDispatchResult, 1) + started := make(chan struct{}) + go func() { + defer func() { <-slots }() + close(started) + reply, err := dispatch() + result <- mcpDispatchResult{reply: reply, err: err} + }() + <-started + signalMCPDispatchAdmission(ctx) + + select { + case completed := <-result: + return completed.reply, completed.err + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +func (s *Server) mcpToolCallTimeout() time.Duration { + if s != nil && s.MCPToolCallTimeout > 0 { + return s.MCPToolCallTimeout + } + return DefaultMCPToolCallTimeout +} + +func serveMCPConnection(conn net.Conn, reader *bufio.Reader, timeout time.Duration, dispatch mcpFrameDispatch) { + serveMCPConnectionWithHooks(conn, reader, timeout, dispatch, nil) +} + +type mcpConnectionHooks struct { + beforeResponse func(json.RawMessage) + dispatchSlots chan struct{} +} + +func serveMCPConnectionWithHooks(conn net.Conn, reader *bufio.Reader, timeout time.Duration, dispatch mcpFrameDispatch, hooks *mcpConnectionHooks) { + if timeout <= 0 { + timeout = DefaultMCPToolCallTimeout + } + connectionCtx, connectionCancel := context.WithCancel(context.Background()) + if hooks != nil && hooks.dispatchSlots != nil { + connectionCtx = withMCPDispatchSlots(connectionCtx, hooks.dispatchSlots) + } + registry := &mcpRequestRegistry{} + writer := &mcpResponseWriter{conn: conn} + var requests sync.WaitGroup + var initializeDone <-chan struct{} + defer func() { + connectionCancel() + registry.cancelAll() + requests.Wait() + }() + + write := func(reply []byte) bool { + if err := writer.write(reply); err != nil { + connectionCancel() + registry.cancelAll() + _ = conn.Close() + return false + } + return true + } + + launchNotification := func(line []byte, waitFor <-chan struct{}) { + requests.Add(1) + go func() { + defer requests.Done() + if waitFor != nil { + select { + case <-waitFor: + case <-connectionCtx.Done(): + return + } + } + // The supplied dispatcher owns the single process-wide admission. + // Notifications wait for that slot under the connection context so + // state transitions are not silently dropped under load. + notificationCtx := withMCPDispatchWait(connectionCtx) + _, _ = dispatch(notificationCtx, line) + }() + } + + launchRequest := func(line []byte, envelope mcpRequestEnvelope, responseID json.RawMessage, tracked bool) { + var requestCtx context.Context + var entry *mcpInFlightRequest + if tracked { + var admitted bool + requestCtx, entry, admitted = registry.begin(connectionCtx, responseID, timeout) + if !admitted { + write(mcpErrorResponse(responseID, -32600, "duplicate in-flight request id", map[string]any{ + "outcome": "duplicate_id", + "retryable": false, + })) + return + } + } else { + var cancel context.CancelFunc + requestCtx, cancel = context.WithTimeout(connectionCtx, timeout) + entry = &mcpInFlightRequest{cancel: cancel} + } + + var initialized chan struct{} + if envelope.Method == "initialize" { + initialized = make(chan struct{}) + initializeDone = initialized + } + requestLine := append([]byte(nil), line...) + requestID := append(json.RawMessage(nil), responseID...) + admission := newMCPDispatchAdmission() + dispatchCtx := withMCPDispatchAdmission(requestCtx, admission) + requests.Add(1) + go func() { + defer requests.Done() + if initialized != nil { + defer close(initialized) + } + finished := false + finish := func() { + if finished { + return + } + finished = true + if tracked { + registry.finish(entry) + } else { + entry.cancel() + } + } + defer finish() + + reply, dispatchErr := dispatch(dispatchCtx, requestLine) + if requestErr := requestCtx.Err(); requestErr != nil { + code := mcpRequestCancelledCode + message := "request cancelled" + data := map[string]any{"outcome": "cancelled"} + if errors.Is(requestErr, context.DeadlineExceeded) { + code = mcpRequestTimeoutCode + message = "request deadline exceeded" + data = map[string]any{ + "outcome": "deadline", + "deadline_ms": timeout.Milliseconds(), + } + } + if envelope.Method == "tools/call" { + data["delivery_unknown"] = true + data["retryable"] = false + } + reply = mcpErrorResponse(requestID, code, message, data) + } else if errors.Is(dispatchErr, errMCPDispatchSaturated) { + data := map[string]any{ + "outcome": "server_busy", + "retryable": true, + } + if envelope.Method == "tools/call" { + data["delivery_unknown"] = false + } + reply = mcpErrorResponse(requestID, -32002, "MCP dispatcher is busy", data) + } else if dispatchErr != nil { + reply = mcpErrorResponse(requestID, -32603, "request dispatch failed", map[string]any{ + "outcome": "dispatch_error", + "retryable": false, + }) + } else if len(reply) == 0 { + reply = mcpErrorResponse(requestID, -32603, "request completed without a response", map[string]any{ + "outcome": "empty_response", + "retryable": false, + }) + } + + // Release the request ID before publishing any terminal response so + // an immediate same-ID retry can be admitted deterministically. + finish() + if hooks != nil && hooks.beforeResponse != nil { + hooks.beforeResponse(requestID) + } + write(reply) + }() + select { + case <-admission.done: + case <-requestCtx.Done(): + } + } + + for { + line, err := reader.ReadBytes('\n') + if err != nil { + if !errors.Is(err, io.EOF) { + connectionCancel() + } + return + } + line = bytes.TrimSuffix(line, []byte{'\n'}) + line = bytes.TrimSuffix(line, []byte{'\r'}) + if len(line) == 0 { + continue + } + + var envelope mcpRequestEnvelope + if err := json.Unmarshal(line, &envelope); err != nil { + launchRequest(line, envelope, json.RawMessage("null"), false) + continue + } + if envelope.Method == "notifications/cancelled" { + if len(envelope.ID) != 0 { + responseID := envelope.ID + if _, valid := canonicalMCPRequestID(responseID); !valid { + responseID = json.RawMessage("null") + } + write(mcpErrorResponse(responseID, -32600, "notifications/cancelled must not contain an id", nil)) + continue + } + if _, valid := canonicalMCPRequestID(envelope.Params.RequestID); valid { + registry.cancel(envelope.Params.RequestID) + } + continue + } + if len(envelope.ID) == 0 { + var waitFor <-chan struct{} + if envelope.Method == "notifications/initialized" { + waitFor = initializeDone + } + launchNotification(append([]byte(nil), line...), waitFor) + continue + } + if _, valid := canonicalMCPRequestID(envelope.ID); !valid { + write(mcpErrorResponse(json.RawMessage("null"), -32600, "invalid JSON-RPC request id", nil)) + continue + } + launchRequest(line, envelope, envelope.ID, true) + } +} + +func mcpErrorResponse(id json.RawMessage, code int, message string, data map[string]any) []byte { + if len(id) == 0 { + return nil + } + type rpcError struct { + Code int `json:"code"` + Message string `json:"message"` + Data map[string]any `json:"data,omitempty"` + } + errorJSON, _ := json.Marshal(rpcError{Code: code, Message: message, Data: data}) + encoded := make([]byte, 0, len(id)+len(errorJSON)+40) + encoded = append(encoded, `{"jsonrpc":"2.0","id":`...) + encoded = append(encoded, id...) + encoded = append(encoded, `,"error":`...) + encoded = append(encoded, errorJSON...) + encoded = append(encoded, '}') + return encoded +} diff --git a/internal/daemon/mcp_request_lifetime_regression_test.go b/internal/daemon/mcp_request_lifetime_regression_test.go new file mode 100644 index 000000000..be8b09c16 --- /dev/null +++ b/internal/daemon/mcp_request_lifetime_regression_test.go @@ -0,0 +1,458 @@ +package daemon + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "net" + "sync" + "sync/atomic" + "testing" + "time" +) + +func TestServeMCPRegistryReleasedBeforeTerminalResponse(t *testing.T) { + var calls atomic.Int32 + firstResponseReady := make(chan struct{}) + releaseFirstResponse := make(chan struct{}) + var blockFirst atomic.Bool + hooks := &mcpConnectionHooks{beforeResponse: func(id json.RawMessage) { + if string(id) == "41" && blockFirst.CompareAndSwap(false, true) { + close(firstResponseReady) + <-releaseFirstResponse + } + }} + dispatch := func(ctx context.Context, _ []byte) ([]byte, error) { + if calls.Add(1) == 1 { + <-ctx.Done() + return nil, ctx.Err() + } + return []byte(`{"jsonrpc":"2.0","id":41,"result":{"retried":true}}`), nil + } + conn, reader, done := startMCPConnectionWithHooksTest(t, 20*time.Millisecond, dispatch, hooks) + request := `{"jsonrpc":"2.0","id":41,"method":"tools/call","params":{"name":"search"}}` + + writeMCPTestFrame(t, conn, request) + awaitMCPTestSignal(t, firstResponseReady, "first terminal response did not reach publish barrier") + writeMCPTestFrame(t, conn, request) + response := readMCPTestResponse(t, conn, reader) + if response.Error != nil || response.Result["retried"] != true { + t.Fatalf("immediate retry response = %#v", response) + } + close(releaseFirstResponse) + response = readMCPTestResponse(t, conn, reader) + if response.Error == nil || response.Error.Code != mcpRequestTimeoutCode { + t.Fatalf("original terminal response = %#v", response) + } + if got := calls.Load(); got != 2 { + t.Fatalf("dispatch calls = %d, want 2", got) + } + _ = conn.Close() + awaitMCPTestSignal(t, done, "connection did not stop") +} + +func awaitMCPDispatchSlotsReleased(t *testing.T) { + t.Helper() + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + if len(mcpDispatchSlots) == 0 { + return + } + time.Sleep(time.Millisecond) + } + t.Fatalf("MCP dispatch slots still occupied: %d", len(mcpDispatchSlots)) +} + +func TestSessionDispatchMCPOnceContextRetriesCancelledForeignFlight(t *testing.T) { + sess := &Session{LogicalSessionID: "reconnect-cancel"} + request := []byte(`{"jsonrpc":"2.0","id":77,"method":"tools/call","params":{"name":"search"}}`) + oldCtx, cancelOld := context.WithCancel(context.Background()) + oldStarted := make(chan struct{}) + releaseOld := make(chan struct{}) + oldHandlerReturned := make(chan struct{}) + oldDone := make(chan error, 1) + go func() { + _, _, err := sess.dispatchMCPOnceContext(oldCtx, request, func() ([]byte, error) { + close(oldStarted) + <-releaseOld + close(oldHandlerReturned) + return []byte(`{"jsonrpc":"2.0","id":77,"result":{"old":true}}`), nil + }) + oldDone <- err + }() + awaitMCPTestSignal(t, oldStarted, "old connection flight did not start") + + newStarted := make(chan struct{}) + type dispatchResult struct { + reply []byte + replayed bool + err error + } + newDone := make(chan dispatchResult, 1) + go func() { + reply, replayed, err := sess.dispatchMCPOnceContext(context.Background(), request, func() ([]byte, error) { + close(newStarted) + return []byte(`{"jsonrpc":"2.0","id":77,"result":{"new":true}}`), nil + }) + newDone <- dispatchResult{reply: reply, replayed: replayed, err: err} + }() + select { + case <-newStarted: + t.Fatal("new connection bypassed the live old flight") + case <-time.After(10 * time.Millisecond): + } + + cancelOld() + select { + case err := <-oldDone: + if !errors.Is(err, context.Canceled) { + t.Fatalf("old flight error = %v", err) + } + case <-time.After(time.Second): + t.Fatal("old cancelled flight did not detach") + } + awaitMCPTestSignal(t, newStarted, "new connection did not retry cancelled flight") + result := <-newDone + if result.err != nil || result.replayed || !json.Valid(result.reply) { + t.Fatalf("new flight result = %#v", result) + } + close(releaseOld) + awaitMCPTestSignal(t, oldHandlerReturned, "old orphan handler did not return") + awaitMCPDispatchSlotsReleased(t) + + called := false + reply, replayed, err := sess.dispatchMCPOnceContext(context.Background(), request, func() ([]byte, error) { + called = true + return nil, errors.New("cached response was lost") + }) + if err != nil || !replayed || called || string(reply) != string(result.reply) { + t.Fatalf("cached replacement = reply:%s replayed:%v called:%v err:%v", reply, replayed, called, err) + } +} + +func TestServeMCPUncooperativeNonToolDoesNotBlockNextRequestOrTeardown(t *testing.T) { + started := make(chan struct{}) + release := make(chan struct{}) + handlerReturned := make(chan struct{}) + dispatcher := testMCPDispatcherFunc(func(_ context.Context, _ *Session, frame []byte) ([]byte, error) { + var envelope mcpRequestEnvelope + if err := json.Unmarshal(frame, &envelope); err != nil { + return nil, err + } + if string(envelope.ID) == "1" { + close(started) + <-release + close(handlerReturned) + return []byte(`{"jsonrpc":"2.0","id":1,"result":{"late":true}}`), nil + } + return []byte(`{"jsonrpc":"2.0","id":2,"result":{"ok":true}}`), nil + }) + server := &Server{MCPDispatcher: dispatcher, MCPToolCallTimeout: 40 * time.Millisecond} + serverConn, clientConn := net.Pipe() + done := make(chan struct{}) + go func() { + defer close(done) + defer serverConn.Close() + server.serveMCP(serverConn, bufio.NewReader(serverConn), &Session{}) + }() + + writeMCPTestFrame(t, clientConn, `{"jsonrpc":"2.0","id":1,"method":"ping"}`) + awaitMCPTestSignal(t, started, "uncooperative non-tool request did not start") + writeMCPTestFrame(t, clientConn, `{"jsonrpc":"2.0","id":2,"method":"tools/list"}`) + reader := bufio.NewReader(clientConn) + response := readMCPTestResponse(t, clientConn, reader) + if string(response.ID) != "2" || response.Error != nil || response.Result["ok"] != true { + t.Fatalf("next request response = %#v", response) + } + response = readMCPTestResponse(t, clientConn, reader) + if string(response.ID) != "1" || response.Error == nil || response.Error.Code != mcpRequestTimeoutCode { + t.Fatalf("uncooperative timeout response = %#v", response) + } + _ = clientConn.Close() + awaitMCPTestSignal(t, done, "connection teardown waited for uncooperative dispatcher") + close(release) + select { + case <-handlerReturned: + case <-time.After(time.Second): + t.Fatal("uncooperative dispatcher did not return after release") + } + awaitMCPDispatchSlotsReleased(t) +} + +func TestServeMCPCancellationCanonicalizesEquivalentIDs(t *testing.T) { + tests := []struct { + name string + requestID string + cancelID string + }{ + {name: "escaped string", requestID: `"<"`, cancelID: `"\u003c"`}, + {name: "large number", requestID: `9007199254740993`, cancelID: `9007199254740993`}, + {name: "null compatibility", requestID: `null`, cancelID: `null`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + started := make(chan struct{}) + conn, reader, _ := startMCPConnectionTest(t, time.Second, func(ctx context.Context, _ []byte) ([]byte, error) { + close(started) + <-ctx.Done() + return nil, ctx.Err() + }) + writeMCPTestFrame(t, conn, `{"jsonrpc":"2.0","id":`+tt.requestID+`,"method":"tools/call","params":{"name":"search"}}`) + awaitMCPTestSignal(t, started, "request did not start") + writeMCPTestFrame(t, conn, `{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":`+tt.cancelID+`}}`) + response := readMCPTestResponse(t, conn, reader) + if string(response.ID) != tt.requestID || response.Error == nil || response.Error.Code != mcpRequestCancelledCode { + t.Fatalf("canonical cancellation response = %#v", response) + } + }) + } +} + +func TestServeMCPRejectsInvalidRequestAndCancellationIDs(t *testing.T) { + var calls atomic.Int32 + conn, reader, _ := startMCPConnectionTest(t, time.Second, func(context.Context, []byte) ([]byte, error) { + calls.Add(1) + return nil, nil + }) + for _, invalidID := range []string{`{}`, `[]`, `true`} { + writeMCPTestFrame(t, conn, `{"jsonrpc":"2.0","id":`+invalidID+`,"method":"tools/call","params":{"name":"search"}}`) + response := readMCPTestResponse(t, conn, reader) + if string(response.ID) != "null" || response.Error == nil || response.Error.Code != -32600 { + t.Fatalf("invalid id %s response = %#v", invalidID, response) + } + } + writeMCPTestFrame(t, conn, `{"jsonrpc":"2.0","id":5,"method":"notifications/cancelled","params":{"requestId":1}}`) + response := readMCPTestResponse(t, conn, reader) + if string(response.ID) != "5" || response.Error == nil || response.Error.Code != -32600 { + t.Fatalf("request-shaped cancellation response = %#v", response) + } + if got := calls.Load(); got != 0 { + t.Fatalf("invalid requests reached dispatcher %d times", got) + } +} + +func TestServeMCPInvalidCancellationTargetDoesNotCancelRequest(t *testing.T) { + started := make(chan struct{}) + cancelled := make(chan error, 1) + conn, reader, _ := startMCPConnectionTest(t, time.Second, func(ctx context.Context, _ []byte) ([]byte, error) { + close(started) + <-ctx.Done() + cancelled <- ctx.Err() + return nil, ctx.Err() + }) + writeMCPTestFrame(t, conn, `{"jsonrpc":"2.0","id":"keep","method":"tools/call","params":{"name":"search"}}`) + awaitMCPTestSignal(t, started, "request did not start") + writeMCPTestFrame(t, conn, `{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":{}}}`) + select { + case err := <-cancelled: + t.Fatalf("invalid cancellation target cancelled request: %v", err) + case <-time.After(30 * time.Millisecond): + } + writeMCPTestFrame(t, conn, `{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":"keep"}}`) + response := readMCPTestResponse(t, conn, reader) + if response.Error == nil || response.Error.Code != mcpRequestCancelledCode { + t.Fatalf("valid cancellation response = %#v", response) + } +} + +func TestServeMCPRequestsAlwaysReceiveDispatchTerminalResponse(t *testing.T) { + conn, reader, _ := startMCPConnectionTest(t, time.Second, func(_ context.Context, frame []byte) ([]byte, error) { + var envelope mcpRequestEnvelope + if err := json.Unmarshal(frame, &envelope); err != nil { + return nil, err + } + if string(envelope.ID) == "1" { + return nil, errors.New("boom") + } + return nil, nil + }) + writeMCPTestFrame(t, conn, `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`) + writeMCPTestFrame(t, conn, `{"jsonrpc":"2.0","id":2,"method":"tools/list"}`) + seen := map[string]string{} + for range 2 { + response := readMCPTestResponse(t, conn, reader) + if response.Error == nil || response.Error.Code != -32603 { + t.Fatalf("terminal error response = %#v", response) + } + seen[string(response.ID)] = response.Error.Data["outcome"].(string) + } + if seen["1"] != "dispatch_error" || seen["2"] != "empty_response" { + t.Fatalf("terminal outcomes = %#v", seen) + } +} + +func TestServeMCPMutationTimeoutMarksDeliveryUnknown(t *testing.T) { + conn, reader, _ := startMCPConnectionTest(t, 20*time.Millisecond, func(ctx context.Context, _ []byte) ([]byte, error) { + <-ctx.Done() + return nil, ctx.Err() + }) + writeMCPTestFrame(t, conn, `{"jsonrpc":"2.0","id":"mutation","method":"tools/call","params":{"name":"edit"}}`) + response := readMCPTestResponse(t, conn, reader) + if response.Error == nil || response.Error.Code != mcpRequestTimeoutCode { + t.Fatalf("mutation timeout = %#v", response) + } + if response.Error.Data["delivery_unknown"] != true || response.Error.Data["retryable"] != false { + t.Fatalf("mutation timeout safety data = %#v", response.Error.Data) + } +} + +func TestServeMCPInitializedNotificationWaitsForInitializeResponse(t *testing.T) { + testSlots := newMCPDispatchSlots(1) + initializeStarted := make(chan struct{}) + releaseInitialize := make(chan struct{}) + initializedDispatched := make(chan struct{}) + slotHeld := make(chan struct{}) + var heldSlotReleased atomic.Bool + t.Cleanup(func() { + if heldSlotReleased.Load() { + return + } + select { + case <-slotHeld: + select { + case <-testSlots: + default: + } + default: + } + }) + hooks := &mcpConnectionHooks{dispatchSlots: testSlots, beforeResponse: func(id json.RawMessage) { + if string(id) != "1" { + return + } + testSlots <- struct{}{} + close(slotHeld) + }} + conn, reader, _ := startMCPConnectionWithHooksTest(t, time.Second, func(_ context.Context, frame []byte) ([]byte, error) { + var envelope mcpRequestEnvelope + if err := json.Unmarshal(frame, &envelope); err != nil { + return nil, err + } + switch envelope.Method { + case "initialize": + close(initializeStarted) + <-releaseInitialize + return []byte(`{"jsonrpc":"2.0","id":1,"result":{"ready":true}}`), nil + case "notifications/initialized": + close(initializedDispatched) + } + return nil, nil + }, hooks) + writeMCPTestFrame(t, conn, `{"jsonrpc":"2.0","id":1,"method":"initialize"}`) + awaitMCPTestSignal(t, initializeStarted, "initialize did not start") + writeMCPTestFrame(t, conn, `{"jsonrpc":"2.0","method":"notifications/initialized"}`) + select { + case <-initializedDispatched: + t.Fatal("initialized notification overtook initialize response") + case <-time.After(20 * time.Millisecond): + } + close(releaseInitialize) + awaitMCPTestSignal(t, slotHeld, "test did not occupy the only dispatch slot") + response := readMCPTestResponse(t, conn, reader) + if response.Error != nil || response.Result["ready"] != true { + t.Fatalf("initialize response = %#v", response) + } + select { + case <-initializedDispatched: + t.Fatal("initialized notification bypassed occupied dispatch capacity") + case <-time.After(20 * time.Millisecond): + } + <-testSlots + heldSlotReleased.Store(true) + awaitMCPTestSignal(t, initializedDispatched, "initialized notification was dropped after capacity became available") +} + +func TestMCPDispatchLimitConfiguration(t *testing.T) { + for _, test := range []struct { + name string + raw string + want int + }{ + {name: "default", want: defaultMaxConcurrentMCPDispatches}, + {name: "whitespace", raw: " ", want: defaultMaxConcurrentMCPDispatches}, + {name: "small override", raw: "2", want: 2}, + {name: "trimmed override", raw: " 16 ", want: 16}, + {name: "invalid", raw: "many", want: defaultMaxConcurrentMCPDispatches}, + {name: "zero", raw: "0", want: defaultMaxConcurrentMCPDispatches}, + {name: "negative", raw: "-1", want: defaultMaxConcurrentMCPDispatches}, + {name: "bounded override", raw: "65", want: maxConfiguredMCPDispatches}, + } { + t.Run(test.name, func(t *testing.T) { + if got := mcpDispatchLimit(test.raw); got != test.want { + t.Fatalf("mcpDispatchLimit(%q) = %d, want %d", test.raw, got, test.want) + } + }) + } + if got := cap(newMCPDispatchSlots(0)); got != defaultMaxConcurrentMCPDispatches { + t.Fatalf("zero-capacity test override = %d, want %d", got, defaultMaxConcurrentMCPDispatches) + } +} + +func TestServeMCPDispatcherSaturationFailsFastAndKeepsReaderResponsive(t *testing.T) { + filled := 0 + for { + select { + case mcpDispatchSlots <- struct{}{}: + filled++ + default: + goto saturated + } + } + +saturated: + defer func() { + for range filled { + <-mcpDispatchSlots + } + }() + var dispatches atomic.Int32 + conn, reader, done := startMCPConnectionTest(t, time.Second, func(context.Context, []byte) ([]byte, error) { + dispatches.Add(1) + return nil, errors.New("saturated dispatcher unexpectedly ran") + }) + writeMCPTestFrame(t, conn, `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"edit"}}`) + writeMCPTestFrame(t, conn, `{"jsonrpc":"2.0","id":2,"method":"ping"}`) + responses := map[string]testMCPResponse{} + for range 2 { + response := readMCPTestResponse(t, conn, reader) + responses[string(response.ID)] = response + } + for _, id := range []string{"1", "2"} { + response := responses[id] + if response.Error == nil || response.Error.Code != -32002 || response.Error.Data["outcome"] != "server_busy" { + t.Fatalf("busy response %s = %#v", id, response) + } + } + if responses["1"].Error.Data["delivery_unknown"] != false || responses["1"].Error.Data["retryable"] != true { + t.Fatalf("not-started mutation busy data = %#v", responses["1"].Error.Data) + } + if got := dispatches.Load(); got != 0 { + t.Fatalf("saturated dispatcher ran %d times", got) + } + _ = conn.Close() + awaitMCPTestSignal(t, done, "saturated connection did not stop") +} + +func startMCPConnectionWithHooksTest(t *testing.T, timeout time.Duration, dispatch mcpFrameDispatch, hooks *mcpConnectionHooks) (net.Conn, *bufio.Reader, <-chan struct{}) { + t.Helper() + serverConn, clientConn := net.Pipe() + done := make(chan struct{}) + go func() { + defer close(done) + defer serverConn.Close() + serveMCPConnectionWithHooks(serverConn, bufio.NewReader(serverConn), timeout, func(ctx context.Context, frame []byte) ([]byte, error) { + return dispatchMCPWithContext(ctx, func() ([]byte, error) { return dispatch(ctx, frame) }) + }, hooks) + }() + var closeOnce sync.Once + t.Cleanup(func() { + closeOnce.Do(func() { _ = clientConn.Close() }) + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("MCP connection with hooks did not stop") + } + }) + return clientConn, bufio.NewReader(clientConn), done +} diff --git a/internal/daemon/mcp_request_lifetime_test.go b/internal/daemon/mcp_request_lifetime_test.go new file mode 100644 index 000000000..b3b975fcf --- /dev/null +++ b/internal/daemon/mcp_request_lifetime_test.go @@ -0,0 +1,265 @@ +package daemon + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "net" + "sync/atomic" + "testing" + "time" +) + +type testMCPResponse struct { + ID json.RawMessage `json:"id"` + Result map[string]any `json:"result"` + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + Data map[string]any `json:"data"` + } `json:"error"` +} + +type testMCPDispatcherFunc func(context.Context, *Session, []byte) ([]byte, error) + +func (f testMCPDispatcherFunc) Dispatch(ctx context.Context, sess *Session, frame []byte) ([]byte, error) { + return f(ctx, sess, frame) +} + +func TestServeMCPConnectionDeadlineCancelsDispatcher(t *testing.T) { + cancelled := make(chan error, 1) + conn, reader, _ := startMCPConnectionTest(t, 25*time.Millisecond, func(ctx context.Context, _ []byte) ([]byte, error) { + <-ctx.Done() + cancelled <- ctx.Err() + return nil, ctx.Err() + }) + + writeMCPTestFrame(t, conn, `{"jsonrpc":"2.0","id":"slow-id","method":"tools/call","params":{"name":"search"}}`) + response := readMCPTestResponse(t, conn, reader) + if string(response.ID) != `"slow-id"` { + t.Fatalf("response id = %s, want exact raw string id", response.ID) + } + if response.Error == nil || response.Error.Code != mcpRequestTimeoutCode { + t.Fatalf("response error = %#v, want timeout code %d", response.Error, mcpRequestTimeoutCode) + } + if response.Error.Data["outcome"] != "deadline" || response.Error.Data["deadline_ms"] != float64(25) { + t.Fatalf("timeout data = %#v", response.Error.Data) + } + select { + case err := <-cancelled: + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("dispatcher cancellation = %v", err) + } + case <-time.After(time.Second): + t.Fatal("dispatcher did not observe deadline") + } +} + +func TestServeMCPConnectionProcessesNextRequestAndCancellation(t *testing.T) { + started := make(chan struct{}) + conn, reader, _ := startMCPConnectionTest(t, time.Second, func(ctx context.Context, frame []byte) ([]byte, error) { + var envelope mcpRequestEnvelope + if err := json.Unmarshal(frame, &envelope); err != nil { + return nil, err + } + if string(envelope.ID) == "1" { + close(started) + <-ctx.Done() + return nil, ctx.Err() + } + return []byte(`{"jsonrpc":"2.0","id":2,"result":{"ok":true}}`), nil + }) + + writeMCPTestFrame(t, conn, `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search"}}`) + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("blocked request did not start") + } + writeMCPTestFrame(t, conn, `{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"search"}}`) + response := readMCPTestResponse(t, conn, reader) + if string(response.ID) != "2" || response.Error != nil || response.Result["ok"] != true { + t.Fatalf("next response = %#v", response) + } + + writeMCPTestFrame(t, conn, `{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":1,"reason":"test"}}`) + response = readMCPTestResponse(t, conn, reader) + if string(response.ID) != "1" || response.Error == nil || response.Error.Code != mcpRequestCancelledCode { + t.Fatalf("cancel response = %#v", response) + } +} + +func TestServeMCPConnectionCancellationUsesExactRawIDAndTeardownCancelsAll(t *testing.T) { + numericStarted := make(chan struct{}) + stringStarted := make(chan struct{}) + numericDone := make(chan error, 1) + stringDone := make(chan error, 1) + conn, reader, done := startMCPConnectionTest(t, time.Second, func(ctx context.Context, frame []byte) ([]byte, error) { + var envelope mcpRequestEnvelope + if err := json.Unmarshal(frame, &envelope); err != nil { + return nil, err + } + switch string(envelope.ID) { + case "7": + close(numericStarted) + <-ctx.Done() + numericDone <- ctx.Err() + case `"7"`: + close(stringStarted) + <-ctx.Done() + stringDone <- ctx.Err() + } + return nil, ctx.Err() + }) + + writeMCPTestFrame(t, conn, `{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"search"}}`) + writeMCPTestFrame(t, conn, `{"jsonrpc":"2.0","id":"7","method":"tools/call","params":{"name":"search"}}`) + awaitMCPTestSignal(t, numericStarted, "numeric request did not start") + awaitMCPTestSignal(t, stringStarted, "string request did not start") + + writeMCPTestFrame(t, conn, `{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":7}}`) + response := readMCPTestResponse(t, conn, reader) + if string(response.ID) != "7" || response.Error == nil || response.Error.Code != mcpRequestCancelledCode { + t.Fatalf("numeric cancel response = %#v", response) + } + select { + case err := <-numericDone: + if !errors.Is(err, context.Canceled) { + t.Fatalf("numeric cancellation = %v", err) + } + case <-time.After(time.Second): + t.Fatal("numeric request was not cancelled") + } + select { + case err := <-stringDone: + t.Fatalf("string request with distinct raw id was cancelled: %v", err) + case <-time.After(30 * time.Millisecond): + } + + if err := conn.Close(); err != nil { + t.Fatal(err) + } + select { + case err := <-stringDone: + if !errors.Is(err, context.Canceled) { + t.Fatalf("teardown cancellation = %v", err) + } + case <-time.After(time.Second): + t.Fatal("connection teardown did not cancel in-flight request") + } + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("connection did not wait for in-flight request cleanup") + } +} + +func TestServeMCPDeadlineIsNotStoredAsReplayResponse(t *testing.T) { + var calls atomic.Int32 + dispatcher := testMCPDispatcherFunc(func(ctx context.Context, _ *Session, _ []byte) ([]byte, error) { + if calls.Add(1) == 1 { + <-ctx.Done() + return []byte(`{"jsonrpc":"2.0","id":9,"result":{"late":true}}`), nil + } + return []byte(`{"jsonrpc":"2.0","id":9,"result":{"retried":true}}`), nil + }) + server := &Server{ + MCPDispatcher: dispatcher, + MCPToolCallTimeout: 20 * time.Millisecond, + } + sess := &Session{LogicalSessionID: "deadline-retry"} + serverConn, clientConn := net.Pipe() + done := make(chan struct{}) + go func() { + defer close(done) + defer serverConn.Close() + server.serveMCP(serverConn, bufio.NewReader(serverConn), sess) + }() + t.Cleanup(func() { + _ = clientConn.Close() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("serveMCP did not stop") + } + }) + reader := bufio.NewReader(clientConn) + request := `{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"search"}}` + + writeMCPTestFrame(t, clientConn, request) + first := readMCPTestResponse(t, clientConn, reader) + if first.Error == nil || first.Error.Code != mcpRequestTimeoutCode { + t.Fatalf("first response = %#v", first) + } + writeMCPTestFrame(t, clientConn, request) + second := readMCPTestResponse(t, clientConn, reader) + if second.Error != nil || second.Result["retried"] != true { + t.Fatalf("retry response = %#v", second) + } + if got := calls.Load(); got != 2 { + t.Fatalf("dispatch calls = %d, want 2", got) + } +} + +func startMCPConnectionTest(t *testing.T, timeout time.Duration, dispatch mcpFrameDispatch) (net.Conn, *bufio.Reader, <-chan struct{}) { + t.Helper() + serverConn, clientConn := net.Pipe() + done := make(chan struct{}) + go func() { + defer close(done) + defer serverConn.Close() + serveMCPConnection(serverConn, bufio.NewReader(serverConn), timeout, func(ctx context.Context, frame []byte) ([]byte, error) { + return dispatchMCPWithContext(ctx, func() ([]byte, error) { return dispatch(ctx, frame) }) + }) + }() + t.Cleanup(func() { + _ = clientConn.Close() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("MCP connection did not stop") + } + }) + return clientConn, bufio.NewReader(clientConn), done +} + +func writeMCPTestFrame(t *testing.T, conn net.Conn, frame string) { + t.Helper() + if err := conn.SetWriteDeadline(time.Now().Add(time.Second)); err != nil { + t.Fatal(err) + } + payload := append([]byte(frame), '\n') + for len(payload) > 0 { + n, err := conn.Write(payload) + if err != nil { + t.Fatal(err) + } + payload = payload[n:] + } +} + +func readMCPTestResponse(t *testing.T, conn net.Conn, reader *bufio.Reader) testMCPResponse { + t.Helper() + if err := conn.SetReadDeadline(time.Now().Add(time.Second)); err != nil { + t.Fatal(err) + } + frame, err := reader.ReadBytes('\n') + if err != nil { + t.Fatal(err) + } + var response testMCPResponse + if err := json.Unmarshal(frame, &response); err != nil { + t.Fatalf("decode response %q: %v", frame, err) + } + return response +} + +func awaitMCPTestSignal(t *testing.T, signal <-chan struct{}, message string) { + t.Helper() + select { + case <-signal: + case <-time.After(time.Second): + t.Fatal(message) + } +} diff --git a/internal/daemon/mutating.go b/internal/daemon/mutating.go index d122a6071..2e0e1c51a 100644 --- a/internal/daemon/mutating.go +++ b/internal/daemon/mutating.go @@ -2,47 +2,184 @@ package daemon import "sort" -// MutatingTools is the single source of truth for MCP tools that mutate -// state — files on disk, the graph store, or the project config. It is -// the verified superset of the three previously-disagreeing lists: the -// planning-mode editing set, the cloud proxy's write denylist, and the -// editing entries scattered through the eager-publish set. +// ToolEffect describes externally relevant state changes a tool may make. +// It is a bitmask because a single tool can cross more than one boundary +// (for example overlay_merge changes session state and can write files). // -// It is consulted by BOTH the planning-mode gate (which hides and -// hard-blocks these tools for a read-only session) and the federation -// write-gate (which refuses to route any of these to a remote in v1). -// A tool listed here is NEVER federated. +// EffectNone is intentionally the zero value: tools absent from ToolEffects +// are read-only from the permission system's point of view. Incidental cache +// touches and read telemetry do not count as effects here. +type ToolEffect uint8 + +const EffectNone ToolEffect = 0 + +const ( + // EffectFilesystemWrite covers working-tree writes and other durable files + // such as notes, memories, generated exports, and notebook metadata. + EffectFilesystemWrite ToolEffect = 1 << iota + // EffectGraphWrite covers index or enrichment changes to the graph store. + EffectGraphWrite + // EffectConfigWrite covers durable daemon, project, scope, and review policy. + EffectConfigWrite + // EffectSessionWrite covers volatile state scoped to a connection/session. + // It is observable but deliberately does not trigger the read-only gate: a + // planning session must still be able to switch mode, stop a workflow, and + // manage subscriptions or speculative overlays. + EffectSessionWrite + // EffectExternalWrite covers mutations outside the local Gortex process, + // such as posting review comments to a forge. + EffectExternalWrite +) + +const durableToolEffects = EffectFilesystemWrite | EffectGraphWrite | EffectConfigWrite | EffectExternalWrite + +// ToolEffects is the canonical effect registry for state-changing MCP tools. +// Permission gates, federation, tool descriptors, and facade adapters should +// consult this registry instead of maintaining verb/prefix-based denylists. // -// Keep this list a superset: the parity test asserts every member of -// the legacy lists is present, so the consolidation can never silently -// shrink the deny-set. -var MutatingTools = map[string]bool{ - // File / symbol editors. - "edit_file": true, - "edit_symbol": true, - "write_file": true, - "rename_symbol": true, - "batch_edit": true, - "move_symbol": true, - "inline_symbol": true, - "safe_delete_symbol": true, - "scaffold": true, - // Repo / project / scope mutators. - "index_repository": true, - "reindex_repository": true, - "track_repository": true, - "untrack_repository": true, - "set_active_project": true, - "delete_scope": true, - "save_scope": true, +// Classification is conservative at tool-name granularity: if any supported +// operation or argument shape can write, the tool carries that write effect. +// Keep this map immutable after package initialization; MutatingTools is +// derived from it for compatibility with existing callers. +var ToolEffects = map[string]ToolEffect{ + // Source and filesystem mutation. + "apply_code_action": EffectFilesystemWrite, + "batch_edit": EffectFilesystemWrite, + "edit_file": EffectFilesystemWrite, + "edit_symbol": EffectFilesystemWrite, + "fix_all_in_file": EffectFilesystemWrite, + "inline_symbol": EffectFilesystemWrite, + "move_symbol": EffectFilesystemWrite, + "rename_symbol": EffectFilesystemWrite, + "safe_delete_symbol": EffectFilesystemWrite, + "scaffold": EffectFilesystemWrite, + "write_file": EffectFilesystemWrite, + + // Conditional output writers are write-classified for the whole tool. + "export_graph": EffectFilesystemWrite, + "generate_docs": EffectFilesystemWrite, + "generate_skill": EffectFilesystemWrite, + "generate_wiki": EffectFilesystemWrite, + + // Durable agent knowledge and learning stores. + "edit_memory": EffectFilesystemWrite, + "feedback": EffectFilesystemWrite, + "notebook_save": EffectFilesystemWrite, + "notebook_used": EffectFilesystemWrite, + "rename_memory": EffectFilesystemWrite, + "save_note": EffectFilesystemWrite, + "store_memory": EffectFilesystemWrite, + "surface_memories": EffectFilesystemWrite, + "suppress_finding": EffectConfigWrite, + + // Graph/index mutation. + "enrich_churn": EffectGraphWrite, + "enrich_releases": EffectGraphWrite, + "index_repository": EffectGraphWrite, + "reindex_repository": EffectGraphWrite, + + // Repository/project/scope lifecycle mutates both configuration and, for + // tracking, the indexed graph materialized from that configuration. + "delete_scope": EffectConfigWrite, + "save_scope": EffectConfigWrite, + "set_active_project": EffectConfigWrite | EffectSessionWrite, + "track_repository": EffectConfigWrite | EffectGraphWrite, + "untrack_repository": EffectConfigWrite | EffectGraphWrite, + + // Review publication mutates a remote forge. dry_run does not weaken the + // tool-level classification because ordinary calls can still post. + "post_review": EffectExternalWrite, + + // overlay_merge normally changes session state and can also apply the + // branch to disk when to_disk=true. + "overlay_merge": EffectSessionWrite | EffectFilesystemWrite, + + // Intentional legacy exception: the unified analyze tool contains durable + // graph enrichers (blame, coverage, sql_rebuild, and temporal_verify). + // Marking the legacy tool at name granularity would hide every read-only + // analysis in planning mode. The compact public surface rejects those kinds + // on analyze and routes them through workspace_admin. Remove this exception + // when the legacy dispatcher is retired. + // + // change_contract has a similar conditional legacy write (ack=true). The + // compact change.contract adapter fixes ack=false and exposes durable risk + // acknowledgement as remember.risk_ack. + + // Volatile MCP/session controls. These are effectful for introspection but + // remain available in planning/read-only mode (see durableToolEffects). + "agent_registry": EffectSessionWrite, + "nav": EffectSessionWrite, + "overlay_delete": EffectSessionWrite, + "overlay_drop": EffectSessionWrite, + "overlay_drop_branch": EffectSessionWrite, + "overlay_fork": EffectSessionWrite, + "overlay_keepalive": EffectSessionWrite, + "overlay_push": EffectSessionWrite, + "overlay_register": EffectSessionWrite, + "overlay_switch": EffectSessionWrite, + "proxy_disable": EffectSessionWrite, + "proxy_enable": EffectSessionWrite, + "set_planning_mode": EffectSessionWrite, + "simulate_chain": EffectSessionWrite, + "subscribe_daemon_health": EffectSessionWrite, + "subscribe_diagnostics": EffectSessionWrite, + "subscribe_graph_invalidated": EffectSessionWrite, + "subscribe_stale_refs": EffectSessionWrite, + "subscribe_workspace_readiness": EffectSessionWrite, + "unsubscribe_daemon_health": EffectSessionWrite, + "unsubscribe_diagnostics": EffectSessionWrite, + "unsubscribe_graph_invalidated": EffectSessionWrite, + "unsubscribe_stale_refs": EffectSessionWrite, + "unsubscribe_workspace_readiness": EffectSessionWrite, + "tools_search": EffectSessionWrite, + "workflow": EffectSessionWrite, + + // MCP facade-v1 effect boundaries. Facades intentionally remain + // homogeneous so clients can authorize them by tool name. + "edit": EffectFilesystemWrite | EffectSessionWrite, + "refactor": EffectFilesystemWrite, + "remember": EffectFilesystemWrite | EffectConfigWrite, + "workspace_admin": EffectFilesystemWrite | EffectGraphWrite | EffectConfigWrite | EffectSessionWrite, + "publish_review": EffectExternalWrite, + "overlay": EffectSessionWrite, + "session": EffectSessionWrite, } -// IsMutating reports whether a tool name mutates state. -func IsMutating(name string) bool { return MutatingTools[name] } +// MutatingTools is the compatibility set used by the planning-mode and +// federation write gates. It contains durable and external writes, but not +// session-only effects. New code should prefer EffectOf when it needs the +// finer distinction. +var MutatingTools = buildMutatingTools(ToolEffects) -// SortedMutatingTools returns the canonical mutating-tool names in -// stable order — used where a deterministic list is surfaced to a -// client (e.g. set_planning_mode's response). +func buildMutatingTools(effects map[string]ToolEffect) map[string]bool { + mutating := make(map[string]bool) + for name, effect := range effects { + if effect&durableToolEffects != 0 { + mutating[name] = true + } + } + return mutating +} + +// EffectOf reports all known effects for a tool. An unclassified tool is +// read-only from the permission system's point of view. +func EffectOf(name string) ToolEffect { return ToolEffects[name] } + +// HasEffect reports whether a tool carries every bit in effect. +func HasEffect(name string, effect ToolEffect) bool { + return effect != EffectNone && EffectOf(name)&effect == effect +} + +// IsEffectful reports whether a tool changes durable, external, or session +// state. It is intended for introspection, not the planning-mode write gate. +func IsEffectful(name string) bool { return EffectOf(name) != EffectNone } + +// IsMutating reports whether a tool can mutate durable local state or an +// external system. Session-only controls deliberately return false. +func IsMutating(name string) bool { return EffectOf(name)&durableToolEffects != 0 } + +// SortedMutatingTools returns the canonical mutating-tool names in stable +// order — used where a deterministic list is surfaced to a client. func SortedMutatingTools() []string { out := make([]string, 0, len(MutatingTools)) for n := range MutatingTools { @@ -51,3 +188,14 @@ func SortedMutatingTools() []string { sort.Strings(out) return out } + +// SortedEffectfulTools returns every classified effectful tool in stable +// order, including session-only tools omitted from SortedMutatingTools. +func SortedEffectfulTools() []string { + out := make([]string, 0, len(ToolEffects)) + for n := range ToolEffects { + out = append(out, n) + } + sort.Strings(out) + return out +} diff --git a/internal/daemon/mutating_parity_test.go b/internal/daemon/mutating_parity_test.go index 97c8216a0..396cfa0ee 100644 --- a/internal/daemon/mutating_parity_test.go +++ b/internal/daemon/mutating_parity_test.go @@ -2,11 +2,11 @@ package daemon import "testing" -// legacyEditingToolNames mirrors the planning-mode editing set that used -// to live in internal/mcp (tools_mode.go::editingToolNames) before the -// consolidation. Duplicated here as a fixture so the parity test fails -// closed if the canonical set ever drops one of them. -var legacyEditingToolNames = []string{ +// preConsolidationEditingToolNames mirrors the planning-mode editing set that +// lived in internal/mcp (tools_mode.go::editingToolNames) before the effect +// registry became canonical. The fixture makes parity fail closed if the +// canonical set ever drops one of them. +var preConsolidationEditingToolNames = []string{ "edit_file", "edit_symbol", "write_file", "rename_symbol", } @@ -23,7 +23,7 @@ var cloudMutatingDenied = []string{ // superset of both legacy write-tool lists — the single source of truth // the planning-mode gate and the federation write-gate both consult. func TestMutatingTools_Superset(t *testing.T) { - for _, name := range legacyEditingToolNames { + for _, name := range preConsolidationEditingToolNames { if !MutatingTools[name] { t.Errorf("MutatingTools is missing legacy editing tool %q", name) } @@ -46,14 +46,153 @@ func TestMutatingTools_ReadToolsExcluded(t *testing.T) { "find_usages", "get_callers", "get_call_chain", "find_implementations", "get_dependents", "search_symbols", "smart_context", "get_symbol_source", "read_file", "graph_stats", + // Facade-v1 read-only boundaries. + "capabilities", "change", "overlay_query", "read", "recall", "relations", + "response", "search", "trace", "workspace", } for _, name := range reads { + if got := EffectOf(name); got != EffectNone { + t.Errorf("read tool %q has effect mask %d, want EffectNone", name, got) + } if IsMutating(name) { t.Errorf("read tool %q must not be classified as mutating", name) } } } +// TestToolEffects_DurableAudit is the reviewed list of legacy tools with a +// durable or external side effect. Keeping the complete fixture here makes a +// dropped classification fail closed instead of silently reopening planning +// mode or federation to a writer. +func TestToolEffects_DurableAudit(t *testing.T) { + want := map[string]ToolEffect{ + "apply_code_action": EffectFilesystemWrite, + "batch_edit": EffectFilesystemWrite, + "delete_scope": EffectConfigWrite, + "edit_file": EffectFilesystemWrite, + "edit_memory": EffectFilesystemWrite, + "edit_symbol": EffectFilesystemWrite, + "enrich_churn": EffectGraphWrite, + "enrich_releases": EffectGraphWrite, + "export_graph": EffectFilesystemWrite, + "feedback": EffectFilesystemWrite, + "fix_all_in_file": EffectFilesystemWrite, + "generate_docs": EffectFilesystemWrite, + "generate_skill": EffectFilesystemWrite, + "generate_wiki": EffectFilesystemWrite, + "index_repository": EffectGraphWrite, + "inline_symbol": EffectFilesystemWrite, + "move_symbol": EffectFilesystemWrite, + "notebook_save": EffectFilesystemWrite, + "notebook_used": EffectFilesystemWrite, + "overlay_merge": EffectSessionWrite | EffectFilesystemWrite, + "post_review": EffectExternalWrite, + "reindex_repository": EffectGraphWrite, + "rename_memory": EffectFilesystemWrite, + "rename_symbol": EffectFilesystemWrite, + "safe_delete_symbol": EffectFilesystemWrite, + "save_note": EffectFilesystemWrite, + "save_scope": EffectConfigWrite, + "scaffold": EffectFilesystemWrite, + "set_active_project": EffectConfigWrite | EffectSessionWrite, + "store_memory": EffectFilesystemWrite, + "suppress_finding": EffectConfigWrite, + "track_repository": EffectConfigWrite | EffectGraphWrite, + "untrack_repository": EffectConfigWrite | EffectGraphWrite, + "write_file": EffectFilesystemWrite, + } + + for name, effect := range want { + if got := EffectOf(name); got != effect { + t.Errorf("EffectOf(%q) = %d, want %d", name, got, effect) + } + if !IsMutating(name) { + t.Errorf("IsMutating(%q) = false for durable/external effect %d", name, effect) + } + if !MutatingTools[name] { + t.Errorf("MutatingTools is missing audited writer %q", name) + } + } +} + +// TestToolEffects_SessionOnlyAudit documents stateful controls which must be +// visible to effect introspection but must not be hidden by planning mode. +// In particular, hiding set_planning_mode/workflow would make recovery from a +// read-only phase impossible. +func TestToolEffects_SessionOnlyAudit(t *testing.T) { + names := []string{ + "agent_registry", + "nav", + "overlay_delete", "overlay_drop", "overlay_drop_branch", "overlay_fork", + "overlay_keepalive", "overlay_push", "overlay_register", "overlay_switch", + "proxy_disable", "proxy_enable", "set_planning_mode", "simulate_chain", + "subscribe_daemon_health", "subscribe_diagnostics", "subscribe_graph_invalidated", + "subscribe_stale_refs", "subscribe_workspace_readiness", + "unsubscribe_daemon_health", "unsubscribe_diagnostics", "unsubscribe_graph_invalidated", + "unsubscribe_stale_refs", "unsubscribe_workspace_readiness", "tools_search", "workflow", + } + + for _, name := range names { + if got := EffectOf(name); got != EffectSessionWrite { + t.Errorf("EffectOf(%q) = %d, want EffectSessionWrite", name, got) + } + if !IsEffectful(name) { + t.Errorf("IsEffectful(%q) = false", name) + } + if IsMutating(name) || MutatingTools[name] { + t.Errorf("session-only tool %q must not enter the durable mutation gate", name) + } + } +} + +func TestToolEffects_FacadeBoundaries(t *testing.T) { + want := map[string]ToolEffect{ + "edit": EffectFilesystemWrite | EffectSessionWrite, + "refactor": EffectFilesystemWrite, + "remember": EffectFilesystemWrite | EffectConfigWrite, + "workspace_admin": EffectFilesystemWrite | EffectGraphWrite | EffectConfigWrite | EffectSessionWrite, + "publish_review": EffectExternalWrite, + "overlay": EffectSessionWrite, + "session": EffectSessionWrite, + } + + for name, effect := range want { + if got := EffectOf(name); got != effect { + t.Errorf("EffectOf facade %q = %d, want %d", name, got, effect) + } + wantMutating := effect&durableToolEffects != 0 + if got := IsMutating(name); got != wantMutating { + t.Errorf("IsMutating facade %q = %v, want %v", name, got, wantMutating) + } + } +} + +func TestMutatingTools_DerivedFromEffects(t *testing.T) { + for name, effect := range ToolEffects { + want := effect&durableToolEffects != 0 + if got := MutatingTools[name]; got != want { + t.Errorf("MutatingTools[%q] = %v for effect %d, want %v", name, got, effect, want) + } + } + for name := range MutatingTools { + if EffectOf(name)&durableToolEffects == 0 { + t.Errorf("MutatingTools contains %q without a durable/external effect", name) + } + } +} + +func TestHasEffect_AllRequestedBits(t *testing.T) { + if !HasEffect("track_repository", EffectConfigWrite|EffectGraphWrite) { + t.Fatal("track_repository should carry config and graph effects") + } + if HasEffect("track_repository", EffectFilesystemWrite) { + t.Fatal("track_repository must not carry a filesystem effect") + } + if HasEffect("read_file", EffectNone) { + t.Fatal("EffectNone is not an actionable effect") + } +} + // TestSortedMutatingTools_Stable asserts the surfaced list is sorted and // complete. func TestSortedMutatingTools_Stable(t *testing.T) { @@ -67,3 +206,15 @@ func TestSortedMutatingTools_Stable(t *testing.T) { } } } + +func TestSortedEffectfulTools_Stable(t *testing.T) { + sorted := SortedEffectfulTools() + if len(sorted) != len(ToolEffects) { + t.Fatalf("sorted list length %d != effect registry size %d", len(sorted), len(ToolEffects)) + } + for i := 1; i < len(sorted); i++ { + if sorted[i-1] >= sorted[i] { + t.Fatalf("not strictly sorted at %d: %q >= %q", i, sorted[i-1], sorted[i]) + } + } +} diff --git a/internal/daemon/proto.go b/internal/daemon/proto.go index bcbe9b630..05fb2421c 100644 --- a/internal/daemon/proto.go +++ b/internal/daemon/proto.go @@ -39,6 +39,12 @@ type Handshake struct { CWD string `json:"cwd,omitempty"` ClientName string `json:"client,omitempty"` // e.g. "claude-code", "kiro", "cli" PID int `json:"pid,omitempty"` + // LogicalSessionID is a proxy-owned opaque token that survives socket + // reconnects. The daemon rebinds an existing MCP session with this token + // when the originating proxy PID is still alive, preserving per-session + // planning, localization, subscriptions, and client metadata. Empty keeps + // the traditional connection-scoped lifecycle. + LogicalSessionID string `json:"logical_session_id,omitempty"` // Tools / ToolsMode carry the client-side tool-surface preference // (GORTEX_TOOLS / --tools and GORTEX_TOOLS_MODE / --tools-mode of the // `gortex mcp` proxy). The daemon serves a shared graph, so a per-client @@ -66,7 +72,8 @@ type HandshakeAck struct { ActiveProject string `json:"active_project,omitempty"` // For clients that want to compare before trusting the connection. - DaemonVersion string `json:"daemon_version,omitempty"` + DaemonVersion string `json:"daemon_version,omitempty"` + DaemonInstance string `json:"daemon_instance,omitempty"` // changes on every daemon process start // Warming is true when the handshake completed but the daemon has not // finished its warmup pipeline — the graph is still filling, so tool diff --git a/internal/daemon/router.go b/internal/daemon/router.go index 99075ce7d..55461e1e6 100644 --- a/internal/daemon/router.go +++ b/internal/daemon/router.go @@ -196,7 +196,8 @@ func (r *Router) RouteToolCall(ctx context.Context, toolName string, body []byte // any bearer token leaves the process, for BOTH explicit // single-remote routing and (later) federation fan-out: // 1. enabled-set gate — a disabled remote is never queried. - // 2. write-gate — a mutating tool never routes to any remote. + // 2. effect-gate — only effect-free reads route to a remote. Durable + // writes and volatile session controls both stay machine-local. slug := lookup.Server.Slug // Audit every remote-routed call (cross-daemon access record), // emitted before the gates so a refusal is auditable too. @@ -214,7 +215,7 @@ func (r *Router) RouteToolCall(ctx context.Context, toolName string, body []byte if !remoteEnabledIn(enabled, slug) { return remoteDisabledRefusal(slug) } - if IsMutating(toolName) { + if IsEffectful(toolName) { return remoteReadOnlyRefusal(toolName, slug) } @@ -249,16 +250,17 @@ func remoteDisabledRefusal(slug string) ([]byte, int, error) { return b, http.StatusForbidden, nil } -// remoteReadOnlyRefusal is the structured envelope returned when a -// mutating tool resolves to a remote. In v1 no write ever routes to a -// remote, regardless of flags. Fires before any outbound HTTP. +// remoteReadOnlyRefusal is the structured envelope returned when an +// effectful tool resolves to a remote. In v1 neither durable writes nor +// volatile session controls route to a remote, regardless of flags. Fires +// before any outbound HTTP. func remoteReadOnlyRefusal(tool, slug string) ([]byte, int, error) { b, _ := json.Marshal(map[string]any{ "error": "remote_read_only", "error_code": "remote_read_only", "tool": tool, "target_slug": slug, - "message": fmt.Sprintf("%q is a write tool and remote %q is read-only — writes never route to a remote", tool, slug), + "message": fmt.Sprintf("%q changes state and remote %q is read-only — effectful tools never route to a remote", tool, slug), }) return b, http.StatusForbidden, nil } diff --git a/internal/daemon/router_reload_test.go b/internal/daemon/router_reload_test.go index 2645d1161..b5bfd8a67 100644 --- a/internal/daemon/router_reload_test.go +++ b/internal/daemon/router_reload_test.go @@ -58,8 +58,10 @@ func TestRouter_DisabledRemoteRefused(t *testing.T) { } } -// TestRouter_WriteGateRefusesRemote asserts a mutating tool routed to an -// enabled remote is refused before any outbound HTTP. +// TestRouter_WriteGateRefusesRemote asserts every effectful tool routed to an +// enabled remote is refused before any outbound HTTP. Session-only effects are +// intentionally not IsMutating (so planning-mode recovery remains possible), +// but they must still stay machine-local. func TestRouter_WriteGateRefusesRemote(t *testing.T) { srv, hit := recordingRemote(t) cfg := &ServersConfig{Server: []ServerEntry{{Slug: "r2", URL: srv.URL, Default: true}}} @@ -72,7 +74,18 @@ func TestRouter_WriteGateRefusesRemote(t *testing.T) { return []byte(`{}`), 200, nil }, }) - for _, tool := range []string{"edit_file", "batch_edit", "rename_symbol", "track_repository"} { + tools := []string{ + // Durable legacy writers. + "edit_file", "batch_edit", "rename_symbol", "track_repository", + // Facade-v1 session writers. + "session", "overlay", + // Legacy session writer. + "proxy_enable", + } + for _, tool := range tools { + if !IsEffectful(tool) { + t.Fatalf("test fixture %q must be classified as effectful", tool) + } out, status, err := router.RouteToolCall(context.Background(), tool, []byte(`{}`), RouteContext{}) if err != nil { t.Fatalf("%s: %v", tool, err) @@ -116,7 +129,7 @@ func TestRouter_LocalWriteAllowed(t *testing.T) { // and ignore a session override for a slug not in the roster. func TestRouter_EffectiveEnabledRemotes(t *testing.T) { cfg := &ServersConfig{Server: []ServerEntry{ - {Slug: "r2", URL: "https://r2:4747"}, // global on (nil) + {Slug: "r2", URL: "https://r2:4747"}, // global on (nil) {Slug: "r3", URL: "https://r3:4747", Enabled: boolp(false)}, // global off }} router := NewRouter(RouterConfig{Servers: cfg, LocalSlug: LocalServerSentinel}) diff --git a/internal/daemon/server.go b/internal/daemon/server.go index ce608398e..3385dbd89 100644 --- a/internal/daemon/server.go +++ b/internal/daemon/server.go @@ -37,6 +37,9 @@ type Server struct { // tests and for early integration before the MCP passthrough lands. MCPDispatcher MCPDispatcher + // MCPToolCallTimeout bounds individual tools/call dispatches. Zero uses DefaultMCPToolCallTimeout. + MCPToolCallTimeout time.Duration + // Controller handles control-mode RPCs (track/untrack/reload/status/shutdown). Controller Controller @@ -64,6 +67,7 @@ type Server struct { httpListener net.Listener httpServer *http.Server started time.Time + instanceID string // unique to this daemon process; exposed in handshake acks shutdown chan struct{} doneOnce sync.Once @@ -146,6 +150,7 @@ func New(socketPath, version string, logger *zap.Logger) *Server { SocketPath: socketPath, Version: version, Logger: logger, + instanceID: newSessionID(), sessions: NewSessionRegistry(), shutdown: make(chan struct{}), conns: make(map[net.Conn]struct{}), @@ -311,6 +316,9 @@ func (s *Server) runMaintenance() { return case <-t.C: for _, sd := range s.sessions.SweepDead(platform.ProcessAlive) { + if hook, ok := s.MCPDispatcher.(SessionEndedHook); ok && hook != nil { + hook.SessionEnded(sd) + } s.Logger.Info("daemon: swept dead session", zap.String("session_id", sd.ID), zap.Int("client_pid", sd.ClientPID)) if sd.Conn != nil { @@ -438,9 +446,10 @@ func (s *Server) handshake(conn net.Conn, reader *bufio.Reader) (*Session, error sess := s.sessions.Register(conn, h) ack := HandshakeAck{ - OK: true, - SessionID: sess.ID, - DaemonVersion: s.Version, + OK: true, + SessionID: sess.ID, + DaemonVersion: s.Version, + DaemonInstance: s.instanceID, } // Stamp warmup state so the client can tell a still-warming daemon from a // ready one. The session is established either way — Warming is advisory. @@ -477,41 +486,33 @@ func (s *Server) serveMCP(conn net.Conn, reader *bufio.Reader, sess *Session) { }) return } - for { - line, err := reader.ReadBytes('\n') - if err != nil { - if !errors.Is(err, io.EOF) { - s.Logger.Debug("daemon: mcp read closed", + + serveMCPConnection(conn, reader, s.mcpToolCallTimeout(), func(ctx context.Context, line []byte) ([]byte, error) { + reply, _, err := sess.dispatchMCPOnceContext(ctx, line, func() ([]byte, error) { + reply, err := s.MCPDispatcher.Dispatch(ctx, sess, line) + if ctxErr := ctx.Err(); ctxErr != nil { + return nil, ctxErr + } + return reply, err + }) + if err != nil && s.Logger != nil { + if ctxErr := ctx.Err(); ctxErr != nil { + outcome := "cancelled" + if errors.Is(ctxErr, context.DeadlineExceeded) { + outcome = "deadline" + } + s.Logger.Warn("daemon: mcp request lifetime ended", + zap.String("session_id", sess.ID), + zap.String("outcome", outcome), + zap.Duration("deadline", s.mcpToolCallTimeout()), + zap.Error(ctxErr)) + } else { + s.Logger.Warn("daemon: dispatch error", zap.String("session_id", sess.ID), zap.Error(err)) } - return - } - // Scanner-style: trim trailing newline but keep the payload as-is - // so the dispatcher sees valid JSON. - if n := len(line); n > 0 && line[n-1] == '\n' { - line = line[:n-1] } - if len(line) == 0 { - continue - } - - ctx := context.Background() - reply, err := s.MCPDispatcher.Dispatch(ctx, sess, line) - if err != nil { - s.Logger.Warn("daemon: dispatch error", - zap.String("session_id", sess.ID), zap.Error(err)) - continue - } - if len(reply) == 0 { - continue - } - // The dispatcher returns a full JSON-RPC frame; re-append newline. - if _, werr := conn.Write(append(reply, '\n')); werr != nil { - s.Logger.Debug("daemon: mcp write failed", - zap.String("session_id", sess.ID), zap.Error(werr)) - return - } - } + return reply, err + }) } // serveControl drains ControlRequest messages, invokes the Controller, diff --git a/internal/daemon/session.go b/internal/daemon/session.go index 615767421..a4abb747e 100644 --- a/internal/daemon/session.go +++ b/internal/daemon/session.go @@ -1,8 +1,12 @@ package daemon import ( + "bytes" + "context" "crypto/rand" + "crypto/sha256" "encoding/hex" + "encoding/json" "net" "sync" "time" @@ -17,10 +21,14 @@ import ( // socket connection closes. The daemon routes every inbound frame to its // session by looking up the net.Conn in the session registry. type Session struct { - ID string - Mode ConnectionMode - CWD string - ClientName string + ID string + // LogicalSessionID is non-empty for an MCP proxy session that may detach + // from one socket and rebind to another while its proxy PID remains alive. + // ID equals this token so all daemon/MCP state continues using one key. + LogicalSessionID string + Mode ConnectionMode + CWD string + ClientName string // ClientVersion is the version reported by the MCP client in its // `initialize` request (`params.clientInfo.version`). Empty until // the daemon dispatcher sees that frame; the env-var sniff in @@ -59,6 +67,15 @@ type Session struct { SymHistory any TokenStats any + // responseMu serializes logical-session MCP dispatch and protects the + // bounded response cache. Caching before socket write gives reconnect + // replay at-least-once response semantics without repeating side effects. + responseMu sync.Mutex + responseCache map[string]cachedMCPResponse + responseOrder []string + responseInFlight map[mcpResponseFlightKey]*mcpResponseFlight + responseBytes int + // remoteOverrides is the per-session enable/disable layer over the // global roster: slug -> enabled. An absent slug means "no // override" (the global Enabled state wins). It is ephemeral by @@ -74,6 +91,186 @@ type Session struct { mu sync.RWMutex } +const ( + sessionResponseCacheEntries = 64 + sessionResponseCacheBytes = 8 << 20 + MCPResponseCacheMaxIDBytes = 4 << 10 + sessionResponseCacheEntryOverhead = sha256.Size + 64 +) + +type cachedMCPResponse struct { + requestDigest [sha256.Size]byte + response []byte + size int +} + +type mcpResponseFlightKey struct { + id string + digest [sha256.Size]byte +} + +type mcpResponseFlight struct { + done chan struct{} + response []byte + err error +} + +// dispatchMCPOnce returns a cached serialized response when the same logical +// dispatchMCPOnce returns a response already produced for an identical request +// in the same logical session. Identical concurrent requests share one dispatch, +// while unrelated requests remain independent even if one handler blocks. +func (s *Session) dispatchMCPOnce(request []byte, dispatch func() ([]byte, error)) ([]byte, bool, error) { + return s.dispatchMCPOnceContext(context.Background(), request, dispatch) +} + +func (s *Session) dispatchMCPOnceContext(ctx context.Context, request []byte, dispatch func() ([]byte, error)) ([]byte, bool, error) { + if s == nil || s.LogicalSessionID == "" { + reply, err := dispatchMCPWithContext(ctx, dispatch) + return reply, false, err + } + identity, cacheable := parseMCPReplayIdentity(request) + if !cacheable { + reply, err := dispatchMCPWithContext(ctx, dispatch) + return reply, false, err + } + key, digest := identity.key, identity.digest + if len(key) > MCPResponseCacheMaxIDBytes { + reply, err := dispatchMCPWithContext(ctx, dispatch) + return reply, false, err + } + + flightKey := mcpResponseFlightKey{id: key, digest: digest} + +retryFlight: + s.responseMu.Lock() + if cached, ok := s.responseCache[key]; ok && cached.requestDigest == digest { + reply := append([]byte(nil), cached.response...) + s.responseMu.Unlock() + signalMCPDispatchAdmission(ctx) + return reply, true, nil + } + if existing, ok := s.responseInFlight[flightKey]; ok { + s.responseMu.Unlock() + signalMCPDispatchAdmission(ctx) + select { + case <-existing.done: + if existing.err != nil { + if ctxErr := ctx.Err(); ctxErr != nil { + return nil, false, ctxErr + } + goto retryFlight + } + return append([]byte(nil), existing.response...), true, nil + case <-ctx.Done(): + return nil, false, ctx.Err() + } + } + if s.responseInFlight == nil { + s.responseInFlight = make(map[mcpResponseFlightKey]*mcpResponseFlight) + } + flight := &mcpResponseFlight{done: make(chan struct{})} + s.responseInFlight[flightKey] = flight + s.responseMu.Unlock() + + reply, err := dispatchMCPWithContext(ctx, dispatch) + copyReply := append([]byte(nil), reply...) + + s.responseMu.Lock() + current, ownsFlight := s.responseInFlight[flightKey] + if !ownsFlight || current != flight { + s.responseMu.Unlock() + return reply, false, err + } + flight.response = copyReply + flight.err = err + if err == nil && len(copyReply) > 0 { + entrySize := len(key) + len(copyReply) + sessionResponseCacheEntryOverhead + if entrySize <= sessionResponseCacheBytes { + if s.responseCache == nil { + s.responseCache = make(map[string]cachedMCPResponse) + } + if previous, ok := s.responseCache[key]; ok { + s.responseBytes -= previous.size + delete(s.responseCache, key) + for i, ordered := range s.responseOrder { + if ordered == key { + s.responseOrder = append(s.responseOrder[:i], s.responseOrder[i+1:]...) + break + } + } + } + for len(s.responseOrder) > 0 && + (len(s.responseOrder) >= sessionResponseCacheEntries || s.responseBytes+entrySize > sessionResponseCacheBytes) { + oldest := s.responseOrder[0] + s.responseOrder = s.responseOrder[1:] + if evicted, ok := s.responseCache[oldest]; ok { + s.responseBytes -= evicted.size + delete(s.responseCache, oldest) + } + } + s.responseOrder = append(s.responseOrder, key) + s.responseCache[key] = cachedMCPResponse{ + requestDigest: digest, + response: copyReply, + size: entrySize, + } + s.responseBytes += entrySize + } + } + delete(s.responseInFlight, flightKey) + close(flight.done) + s.responseMu.Unlock() + return reply, false, err +} + +type mcpReplayIdentity struct { + key string + digest [sha256.Size]byte +} + +func parseMCPReplayIdentity(request []byte) (mcpReplayIdentity, bool) { + trimmed := bytes.TrimSpace(request) + var envelope map[string]json.RawMessage + if json.Unmarshal(trimmed, &envelope) != nil { + return mcpReplayIdentity{}, false + } + var method string + if raw, ok := envelope["method"]; !ok || json.Unmarshal(raw, &method) != nil || method == "" { + return mcpReplayIdentity{}, false + } + rawID, present := envelope["id"] + if !present { + return mcpReplayIdentity{}, false // notification + } + rawID = bytes.TrimSpace(rawID) + canonicalID, valid := canonicalMCPRequestID(rawID) + if !valid { + return mcpReplayIdentity{}, false + } + + // Normalize only the request ID representation before hashing. The digest + // still distinguishes different methods, params, and extension fields, while + // equivalent JSON string escapes share replay and singleflight state. + normalizedID, err := json.Marshal(canonicalID) + if err != nil { + return mcpReplayIdentity{}, false + } + envelope["id"] = normalizedID + normalizedRequest, err := json.Marshal(envelope) + if err != nil { + return mcpReplayIdentity{}, false + } + return mcpReplayIdentity{ + key: canonicalID, + digest: sha256.Sum256(normalizedRequest), + }, true +} + +func mcpResponseCacheIdentity(request []byte) (string, [sha256.Size]byte, bool) { + identity, ok := parseMCPReplayIdentity(request) + return identity.key, identity.digest, ok +} + // SetClientInfo updates the session's client metadata from the MCP // `initialize` frame. Called by the daemon dispatcher when it sees // the first `initialize` request on this session. Idempotent — a @@ -168,8 +365,46 @@ func NewSessionRegistry() *SessionRegistry { // Register creates and stores a new session for the given connection. // Called after a successful handshake. Generates the session ID. func (r *SessionRegistry) Register(conn net.Conn, h Handshake) *Session { + logicalID := "" + if h.Mode == ModeMCP && h.PID > 0 { + logicalID = h.LogicalSessionID + } + + r.mu.Lock() + if logicalID != "" { + if existing := r.sessions[logicalID]; existing != nil && + existing.LogicalSessionID == logicalID && + existing.Mode == h.Mode && + existing.ClientPID == h.PID && + existing.CWD == h.CWD && + existing.ToolSpec == h.Tools && + existing.ToolMode == h.ToolsMode { + oldConn := existing.Conn + if oldConn != nil { + delete(r.byConn, oldConn) + } + existing.Conn = conn + r.byConn[conn] = existing + r.mu.Unlock() + if oldConn != nil && oldConn != conn { + _ = oldConn.Close() + } + return existing + } + // Never overwrite a non-logical session that happens to share the + // requested token. Fall back to an ordinary generated ID instead. + if r.sessions[logicalID] != nil { + logicalID = "" + } + } + + id := newSessionID() + if logicalID != "" { + id = logicalID + } s := &Session{ - ID: newSessionID(), + ID: id, + LogicalSessionID: logicalID, Mode: h.Mode, CWD: h.CWD, ClientName: h.ClientName, @@ -180,7 +415,6 @@ func (r *SessionRegistry) Register(conn net.Conn, h Handshake) *Session { StartedAt: time.Now(), Conn: conn, } - r.mu.Lock() r.sessions[s.ID] = s r.byConn[conn] = s r.mu.Unlock() @@ -244,6 +478,16 @@ func (r *SessionRegistry) GetByID(id string) *Session { return r.sessions[id] } +// IsAttached reports whether id currently owns a live socket. It is the safe +// observation point for reconnect coordination; callers must not read +// Session.Conn directly while Register/Remove may rebind it. +func (r *SessionRegistry) IsAttached(id string) bool { + r.mu.RLock() + defer r.mu.RUnlock() + s := r.sessions[id] + return s != nil && s.Conn != nil +} + // Remove deletes the session for a connection. Idempotent — safe to call // from both the accept-loop's defer and the shutdown path. func (r *SessionRegistry) Remove(conn net.Conn) *Session { @@ -254,6 +498,18 @@ func (r *SessionRegistry) Remove(conn net.Conn) *Session { return nil } delete(r.byConn, conn) + if s.LogicalSessionID != "" && s.Conn == conn { + // Keep the logical session and its MCP-owned state while the proxy + // process lives. A later handshake rebinds it; SweepDead performs the + // final removal and SessionEnded cleanup after the proxy PID exits. + s.Conn = nil + return nil + } + if s.Conn != conn { + // This was an old socket that lost a rebind race. The new connection + // owns the session and must not be torn down by the old handler. + return nil + } delete(r.sessions, s.ID) return s } diff --git a/internal/daemon/session_reconnect_test.go b/internal/daemon/session_reconnect_test.go new file mode 100644 index 000000000..9f6d459c6 --- /dev/null +++ b/internal/daemon/session_reconnect_test.go @@ -0,0 +1,325 @@ +package daemon + +import ( + "context" + "encoding/json" + "net" + "os" + "path/filepath" + "runtime" + "sync" + "testing" + "time" + + "go.uber.org/zap" +) + +func TestSessionRegistryRebindsLogicalSession(t *testing.T) { + registry := NewSessionRegistry() + h := Handshake{ + Mode: ModeMCP, + PID: os.Getpid(), + LogicalSessionID: "logical-session-rebind", + CWD: "/workspace", + Tools: "core", + } + proxy1, peer1 := net.Pipe() + defer peer1.Close() + first := registry.Register(proxy1, h) + state := &struct{ value string }{value: "preserved"} + first.SessionState = state + + if removed := registry.Remove(proxy1); removed != nil { + t.Fatalf("logical disconnect removed session %q", removed.ID) + } + if first.Conn != nil { + t.Fatal("detached logical session retained stale connection") + } + if got := registry.GetByID(h.LogicalSessionID); got != first { + t.Fatalf("detached session lookup = %p, want %p", got, first) + } + + proxy2, peer2 := net.Pipe() + defer peer2.Close() + second := registry.Register(proxy2, h) + if second != first { + t.Fatalf("rebind created a new Session: got %p, want %p", second, first) + } + if second.SessionState != state { + t.Fatal("rebind lost daemon-owned session state") + } + if second.Conn != proxy2 || registry.Get(proxy2) != first { + t.Fatal("new connection was not bound to the logical session") + } +} + +func TestSessionRegistryRejectsLogicalRebindMetadataDrift(t *testing.T) { + registry := NewSessionRegistry() + h := Handshake{ + Mode: ModeMCP, + PID: os.Getpid(), + LogicalSessionID: "logical-session-metadata", + CWD: "/workspace-a", + Tools: "core", + ToolsMode: "hide", + } + proxy1, peer1 := net.Pipe() + defer peer1.Close() + first := registry.Register(proxy1, h) + if removed := registry.Remove(proxy1); removed != nil { + t.Fatal("logical session was removed") + } + + drifted := h + drifted.CWD = "/workspace-b" + proxy2, peer2 := net.Pipe() + defer proxy2.Close() + defer peer2.Close() + second := registry.Register(proxy2, drifted) + if second == first || second.ID == h.LogicalSessionID { + t.Fatal("metadata drift rebound the existing logical session") + } + if first.CWD != h.CWD || first.ClientPID != h.PID || first.ToolSpec != h.Tools || first.ToolMode != h.ToolsMode { + t.Fatalf("original session metadata mutated: %+v", first) + } + if registry.GetByID(h.LogicalSessionID) != first { + t.Fatal("original logical session was displaced") + } +} + +type reconnectDispatcher struct { + mu sync.Mutex + sessions []*Session + ended int +} + +func (d *reconnectDispatcher) Dispatch(_ context.Context, sess *Session, frame []byte) ([]byte, error) { + d.mu.Lock() + if sess.SessionState == nil { + sess.SessionState = &struct{ generation int }{generation: 1} + } + d.sessions = append(d.sessions, sess) + d.mu.Unlock() + + var request struct { + ID json.RawMessage `json:"id"` + } + if err := json.Unmarshal(frame, &request); err != nil { + return nil, err + } + return json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": request.ID, + "result": map[string]any{"ok": true}, + }) +} + +func (d *reconnectDispatcher) SessionEnded(*Session) { + d.mu.Lock() + d.ended++ + d.mu.Unlock() +} + +func (d *reconnectDispatcher) snapshot() ([]*Session, int) { + d.mu.Lock() + defer d.mu.Unlock() + return append([]*Session(nil), d.sessions...), d.ended +} + +func TestDaemonRebindsLogicalMCPStateAcrossRealSocketReconnect(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Unix socket reconnect test") + } + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + + socketDir, err := os.MkdirTemp("/tmp", "gxr") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(socketDir) }) + socket := filepath.Join(socketDir, "s") + dispatcher := &reconnectDispatcher{} + server := New(socket, "test", zap.NewNop()) + server.MCPDispatcher = dispatcher + if err := server.Listen(); err != nil { + t.Fatal(err) + } + serveDone := make(chan error, 1) + go func() { serveDone <- server.Serve() }() + t.Cleanup(func() { + _ = server.Shutdown() + select { + case <-serveDone: + case <-time.After(2 * time.Second): + t.Error("daemon did not stop") + } + }) + + h := Handshake{ + Version: ProtocolVersion, + Mode: ModeMCP, + PID: os.Getpid(), + LogicalSessionID: "real-socket-logical-session", + CWD: t.TempDir(), + } + first, err := DialTo(socket, h) + if err != nil { + t.Fatal(err) + } + firstInstance := first.Ack.DaemonInstance + if first.Ack.SessionID != h.LogicalSessionID || firstInstance == "" { + t.Fatalf("first ack = %+v", first.Ack) + } + if err := first.WriteMCPFrame([]byte(`{"jsonrpc":"2.0","id":1,"method":"ping"}`)); err != nil { + t.Fatal(err) + } + if _, err := first.ReadMCPFrame(); err != nil { + t.Fatal(err) + } + if err := first.Close(); err != nil { + t.Fatal(err) + } + + deadline := time.Now().Add(2 * time.Second) + for server.Sessions().IsAttached(h.LogicalSessionID) && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + if server.Sessions().IsAttached(h.LogicalSessionID) { + t.Fatal("logical session did not detach from the first socket") + } + + second, err := DialTo(socket, h) + if err != nil { + t.Fatal(err) + } + defer second.Close() + if second.Ack.SessionID != h.LogicalSessionID || second.Ack.DaemonInstance != firstInstance { + t.Fatalf("reconnect ack = %+v; first instance %q", second.Ack, firstInstance) + } + if err := second.WriteMCPFrame([]byte(`{"jsonrpc":"2.0","id":2,"method":"ping"}`)); err != nil { + t.Fatal(err) + } + if _, err := second.ReadMCPFrame(); err != nil { + t.Fatal(err) + } + + sessions, ended := dispatcher.snapshot() + if len(sessions) != 2 { + t.Fatalf("dispatch count = %d, want 2", len(sessions)) + } + if sessions[0] != sessions[1] || sessions[0].SessionState != sessions[1].SessionState { + t.Fatal("real reconnect did not preserve the daemon Session and its state") + } + if ended != 0 { + t.Fatalf("SessionEnded called %d times during reconnect", ended) + } +} + +func TestDaemonReplaysCanonicalIDAfterRealSocketLoss(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Unix socket reconnect test") + } + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + socketDir, err := os.MkdirTemp("/tmp", "gxrc") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(socketDir) }) + + dispatcher := &reconnectDispatcher{} + server := New(filepath.Join(socketDir, "s"), "test", zap.NewNop()) + server.MCPDispatcher = dispatcher + if err := server.Listen(); err != nil { + t.Fatal(err) + } + serveDone := make(chan error, 1) + go func() { serveDone <- server.Serve() }() + t.Cleanup(func() { + _ = server.Shutdown() + select { + case <-serveDone: + case <-time.After(2 * time.Second): + t.Error("daemon did not stop") + } + }) + + h := Handshake{ + Version: ProtocolVersion, + Mode: ModeMCP, + PID: os.Getpid(), + LogicalSessionID: "response-loss-logical-session", + CWD: t.TempDir(), + } + first, err := DialTo(server.SocketPath, h) + if err != nil { + t.Fatal(err) + } + firstRequest := []byte(`{"jsonrpc":"2.0","id":"\u003c","method":"tools/call","params":{"name":"read","arguments":{"operation":"source"}}}`) + if err := first.WriteMCPFrame(firstRequest); err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(2 * time.Second) + for { + sessions, _ := dispatcher.snapshot() + if len(sessions) == 1 { + break + } + if time.Now().After(deadline) { + t.Fatal("first request was not dispatched") + } + time.Sleep(time.Millisecond) + } + // Dispatch returns before serveMCP publishes the cache; allow that short + // critical section to complete, then drop the unread response. + time.Sleep(10 * time.Millisecond) + if err := first.Close(); err != nil { + t.Fatal(err) + } + for server.Sessions().IsAttached(h.LogicalSessionID) && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + + second, err := DialTo(server.SocketPath, h) + if err != nil { + t.Fatal(err) + } + defer second.Close() + secondRequest := []byte(`{"jsonrpc":"2.0","id":"<","method":"tools/call","params":{"name":"read","arguments":{"operation":"source"}}}`) + if err := second.WriteMCPFrame(secondRequest); err != nil { + t.Fatal(err) + } + response, err := second.ReadMCPFrame() + if err != nil { + t.Fatal(err) + } + var envelope struct { + ID string `json:"id"` + } + if json.Unmarshal(response, &envelope) != nil || envelope.ID != "<" { + t.Fatalf("cached response changed reconnect request ID value: %s", response) + } + sessions, ended := dispatcher.snapshot() + if len(sessions) != 1 || ended != 0 { + t.Fatalf("identical replay redispatched: calls=%d ended=%d", len(sessions), ended) + } + + different := []byte(`{"jsonrpc":"2.0","id":"<","method":"tools/call","params":{"name":"read","arguments":{"operation":"source","symbol":"other"}}}`) + if err := second.WriteMCPFrame(different); err != nil { + t.Fatal(err) + } + if _, err := second.ReadMCPFrame(); err != nil { + t.Fatal(err) + } + sessions, _ = dispatcher.snapshot() + if len(sessions) != 2 { + t.Fatalf("same ID with different payload was deduplicated: calls=%d", len(sessions)) + } +} + +func TestDaemonInstanceChangesAcrossServerProcesses(t *testing.T) { + first := New("first.sock", "test", zap.NewNop()) + second := New("second.sock", "test", zap.NewNop()) + if first.instanceID == "" || second.instanceID == "" || first.instanceID == second.instanceID { + t.Fatalf("daemon instance IDs are not unique: %q %q", first.instanceID, second.instanceID) + } +} diff --git a/internal/daemon/session_response_cache_concurrency_test.go b/internal/daemon/session_response_cache_concurrency_test.go new file mode 100644 index 000000000..651191515 --- /dev/null +++ b/internal/daemon/session_response_cache_concurrency_test.go @@ -0,0 +1,161 @@ +package daemon + +import ( + "bytes" + "fmt" + "sync/atomic" + "testing" + "time" +) + +func TestSessionDispatchMCPOnceDoesNotSerializeUnrelatedRequests(t *testing.T) { + t.Parallel() + + sess := &Session{LogicalSessionID: "logical-session"} + blockedStarted := make(chan struct{}) + releaseBlocked := make(chan struct{}) + defer close(releaseBlocked) + blockedDone := make(chan struct{}) + + go func() { + defer close(blockedDone) + _, _, _ = sess.dispatchMCPOnce( + []byte(`{"jsonrpc":"2.0","id":"blocked","method":"tools/call"}`), + func() ([]byte, error) { + close(blockedStarted) + <-releaseBlocked + return []byte(`{"jsonrpc":"2.0","id":"blocked","result":{}}`), nil + }, + ) + }() + <-blockedStarted + + unrelatedDone := make(chan error, 1) + go func() { + response, _, err := sess.dispatchMCPOnce( + []byte(`{"jsonrpc":"2.0","id":"unrelated","method":"tools/call"}`), + func() ([]byte, error) { + return []byte(`{"jsonrpc":"2.0","id":"unrelated","result":{}}`), nil + }, + ) + if err == nil && !bytes.Contains(response, []byte(`"id":"unrelated"`)) { + err = fmt.Errorf("unexpected response: %s", response) + } + unrelatedDone <- err + }() + + select { + case err := <-unrelatedDone: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("unrelated request waited for a blocked dispatch") + } +} + +func TestSessionDispatchMCPOnceSingleflightsIdenticalRequests(t *testing.T) { + t.Parallel() + + sess := &Session{LogicalSessionID: "logical-session"} + frame := []byte(`{"jsonrpc":"2.0","id":99,"method":"tools/call","params":{"name":"read"}}`) + started := make(chan struct{}) + release := make(chan struct{}) + var calls atomic.Int32 + type result struct { + response []byte + cached bool + err error + } + results := make(chan result, 2) + dispatch := func() ([]byte, error) { + if calls.Add(1) == 1 { + close(started) + } + <-release + return []byte(`{"jsonrpc":"2.0","id":99,"result":{"ok":true}}`), nil + } + + go func() { + response, cached, err := sess.dispatchMCPOnce(frame, dispatch) + results <- result{response: response, cached: cached, err: err} + }() + <-started + go func() { + response, cached, err := sess.dispatchMCPOnce(frame, dispatch) + results <- result{response: response, cached: cached, err: err} + }() + close(release) + + first := <-results + second := <-results + if first.err != nil || second.err != nil { + t.Fatalf("dispatch errors: first=%v second=%v", first.err, second.err) + } + if calls.Load() != 1 { + t.Fatalf("dispatch calls = %d, want 1", calls.Load()) + } + if !bytes.Equal(first.response, second.response) { + t.Fatalf("responses differ: first=%s second=%s", first.response, second.response) + } + if first.cached == second.cached { + t.Fatalf("cached flags = %v and %v, want one original and one shared response", first.cached, second.cached) + } +} + +func TestSessionDispatchMCPOnceRejectsOversizedIDFromCache(t *testing.T) { + t.Parallel() + + sess := &Session{LogicalSessionID: "logical-session"} + id := bytes.Repeat([]byte{'x'}, MCPResponseCacheMaxIDBytes+1) + frame := append([]byte(`{"jsonrpc":"2.0","id":"`), id...) + frame = append(frame, []byte(`","method":"tools/call"}`)...) + var calls atomic.Int32 + dispatch := func() ([]byte, error) { + calls.Add(1) + return []byte(`{"jsonrpc":"2.0","id":null,"result":{}}`), nil + } + + for range 2 { + if _, cached, err := sess.dispatchMCPOnce(frame, dispatch); err != nil { + t.Fatal(err) + } else if cached { + t.Fatal("oversized request ID was cached") + } + } + if calls.Load() != 2 { + t.Fatalf("dispatch calls = %d, want 2", calls.Load()) + } + + sess.responseMu.Lock() + defer sess.responseMu.Unlock() + if len(sess.responseCache) != 0 || len(sess.responseInFlight) != 0 || sess.responseBytes != 0 { + t.Fatalf("oversized ID retained state: cache=%d in_flight=%d bytes=%d", len(sess.responseCache), len(sess.responseInFlight), sess.responseBytes) + } +} + +func TestSessionResponseCacheAccountsForKeyAndEntryOverhead(t *testing.T) { + t.Parallel() + + sess := &Session{LogicalSessionID: "logical-session"} + frame := []byte(`{"jsonrpc":"2.0","id":"accounted-key","method":"tools/call"}`) + response := []byte(`{"jsonrpc":"2.0","id":"accounted-key","result":{}}`) + if _, _, err := sess.dispatchMCPOnce(frame, func() ([]byte, error) { return response, nil }); err != nil { + t.Fatal(err) + } + key, _, ok := mcpResponseCacheIdentity(frame) + if !ok { + t.Fatal("cache identity unexpectedly rejected") + } + want := len(key) + len(response) + sessionResponseCacheEntryOverhead + + sess.responseMu.Lock() + defer sess.responseMu.Unlock() + entry, ok := sess.responseCache[key] + if !ok { + t.Fatal("response was not cached") + } + if entry.size != want || sess.responseBytes != want { + t.Fatalf("accounted size entry=%d total=%d, want %d", entry.size, sess.responseBytes, want) + } +} diff --git a/internal/daemon/session_response_cache_test.go b/internal/daemon/session_response_cache_test.go new file mode 100644 index 000000000..cce114416 --- /dev/null +++ b/internal/daemon/session_response_cache_test.go @@ -0,0 +1,134 @@ +package daemon + +import ( + "bytes" + "fmt" + "sync" + "sync/atomic" + "testing" +) + +func TestSessionDispatchMCPOnceDeduplicatesStatefulCalls(t *testing.T) { + session := &Session{LogicalSessionID: "logical-cache", ID: "logical-cache"} + frames := [][]byte{ + []byte(`{"jsonrpc":"2.0","id":9007199254740993,"method":"tools/call","params":{"name":"read","arguments":{"operation":"source"}}}`), + []byte(`{"jsonrpc":"2.0","id":null,"method":"tools/call","params":{"name":"explore","arguments":{"operation":"localize"}}}`), + } + for i, frame := range frames { + calls := 0 + dispatch := func() ([]byte, error) { + calls++ + return []byte(fmt.Sprintf(`{"jsonrpc":"2.0","id":%d,"result":{"side_effect":%d}}`, i, calls)), nil + } + first, cached, err := session.dispatchMCPOnce(frame, dispatch) + if err != nil || cached { + t.Fatalf("first dispatch %d: cached=%v err=%v", i, cached, err) + } + second, cached, err := session.dispatchMCPOnce(frame, dispatch) + if err != nil || !cached { + t.Fatalf("replay %d: cached=%v err=%v", i, cached, err) + } + if calls != 1 || !bytes.Equal(first, second) { + t.Fatalf("frame %d dispatched %d times; first=%s second=%s", i, calls, first, second) + } + } +} + +func TestSessionDispatchMCPOnceSameIDDifferentPayloadIsNotDeduplicated(t *testing.T) { + session := &Session{LogicalSessionID: "logical-cache", ID: "logical-cache"} + firstFrame := []byte(`{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"search","arguments":{"query":"a"}}}`) + secondFrame := []byte(`{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"search","arguments":{"query":"b"}}}`) + calls := 0 + dispatch := func() ([]byte, error) { + calls++ + return []byte(fmt.Sprintf(`{"jsonrpc":"2.0","id":7,"result":%d}`, calls)), nil + } + first, _, err := session.dispatchMCPOnce(firstFrame, dispatch) + if err != nil { + t.Fatal(err) + } + second, cached, err := session.dispatchMCPOnce(secondFrame, dispatch) + if err != nil || cached { + t.Fatalf("different payload was cached: cached=%v err=%v", cached, err) + } + replay, cached, err := session.dispatchMCPOnce(secondFrame, dispatch) + if err != nil || !cached { + t.Fatalf("identical second payload was not cached: cached=%v err=%v", cached, err) + } + if calls != 2 || bytes.Equal(first, second) || !bytes.Equal(second, replay) { + t.Fatalf("calls=%d first=%s second=%s replay=%s", calls, first, second, replay) + } +} + +func TestSessionDispatchMCPOnceDoesNotCacheNotifications(t *testing.T) { + session := &Session{LogicalSessionID: "logical-cache", ID: "logical-cache"} + frame := []byte(`{"jsonrpc":"2.0","method":"notifications/initialized"}`) + calls := 0 + for i := 0; i < 2; i++ { + _, cached, err := session.dispatchMCPOnce(frame, func() ([]byte, error) { + calls++ + return nil, nil + }) + if err != nil || cached { + t.Fatalf("notification dispatch %d: cached=%v err=%v", i, cached, err) + } + } + if calls != 2 { + t.Fatalf("notification calls=%d, want 2", calls) + } +} + +func TestSessionDispatchMCPOnceEvictsOldestResponse(t *testing.T) { + session := &Session{LogicalSessionID: "logical-cache", ID: "logical-cache"} + calls := 0 + for i := 0; i <= sessionResponseCacheEntries; i++ { + frame := []byte(fmt.Sprintf(`{"jsonrpc":"2.0","id":%d,"method":"ping"}`, i)) + _, cached, err := session.dispatchMCPOnce(frame, func() ([]byte, error) { + calls++ + return []byte(fmt.Sprintf(`{"jsonrpc":"2.0","id":%d,"result":{}}`, i)), nil + }) + if err != nil || cached { + t.Fatalf("fill %d: cached=%v err=%v", i, cached, err) + } + } + first := []byte(`{"jsonrpc":"2.0","id":0,"method":"ping"}`) + _, cached, err := session.dispatchMCPOnce(first, func() ([]byte, error) { + calls++ + return []byte(`{"jsonrpc":"2.0","id":0,"result":{}}`), nil + }) + if err != nil || cached { + t.Fatalf("evicted first response still cached: cached=%v err=%v", cached, err) + } + if calls != sessionResponseCacheEntries+2 { + t.Fatalf("dispatch calls=%d, want %d", calls, sessionResponseCacheEntries+2) + } +} + +func TestSessionDispatchMCPOnceSerializesOverlappingReplay(t *testing.T) { + session := &Session{LogicalSessionID: "logical-cache", ID: "logical-cache"} + frame := []byte(`{"jsonrpc":"2.0","id":"same","method":"tools/call","params":{"name":"read"}}`) + var calls atomic.Int32 + start := make(chan struct{}) + dispatch := func() ([]byte, error) { + calls.Add(1) + <-start + return []byte(`{"jsonrpc":"2.0","id":"same","result":{}}`), nil + } + var wg sync.WaitGroup + wg.Add(2) + for i := 0; i < 2; i++ { + go func() { + defer wg.Done() + if _, _, err := session.dispatchMCPOnce(frame, dispatch); err != nil { + t.Error(err) + } + }() + } + for calls.Load() == 0 { + } + close(start) + wg.Wait() + if calls.Load() != 1 { + t.Fatalf("overlapping identical request dispatched %d times", calls.Load()) + } +} diff --git a/internal/graph/analysis_cache.go b/internal/graph/analysis_cache.go new file mode 100644 index 000000000..3ad9d9fc4 --- /dev/null +++ b/internal/graph/analysis_cache.go @@ -0,0 +1,183 @@ +package graph + +import ( + "context" + "errors" +) + +var ( + // ErrAnalysisGenerationCorrupt reports a poisoned active generation. The + // store clears the active pointer before returning it, so callers may log + // the diagnostic and safely recompute. + ErrAnalysisGenerationCorrupt = errors.New("analysis generation is corrupt") + // ErrAnalysisGenerationInactive prevents direct reads of stale or + // superseded generations after the active pointer has moved or cleared. + ErrAnalysisGenerationInactive = errors.New("analysis generation is not active") +) + +// AnalysisComponent is one independently sealed part of a durable analysis +// generation. A generation is invisible until every required component is +// sealed and activation succeeds under the graph mutation gate. +type AnalysisComponent string + +const ( + AnalysisComponentNodes AnalysisComponent = "nodes" + AnalysisComponentCommunities AnalysisComponent = "communities" + AnalysisComponentProcesses AnalysisComponent = "processes" + AnalysisComponentConcepts AnalysisComponent = "concepts" + AnalysisComponentAdjacency AnalysisComponent = "adjacency_csr" + AnalysisComponentLeiden AnalysisComponent = "leiden_state" +) + +// AnalysisBlobComponent is deliberately narrower than AnalysisComponent: +// row-addressable results are normalized, while dense algorithm state remains +// a compact versioned blob. +type AnalysisBlobComponent string + +const ( + AnalysisBlobAdjacency AnalysisBlobComponent = "adjacency_csr" + AnalysisBlobLeiden AnalysisBlobComponent = "leiden_state" +) + +// AnalysisMetric selects a normalized node score and its matching keyset +// index. Values outside this closed set must be rejected rather than +// interpolated into SQL. +type AnalysisMetric string + +const ( + AnalysisMetricPageRank AnalysisMetric = "pagerank" + AnalysisMetricAuthority AnalysisMetric = "authority" + AnalysisMetricHub AnalysisMetric = "hub" +) + +// AnalysisConceptDirection selects outgoing or incoming ranked relations. +type AnalysisConceptDirection string + +const ( + AnalysisConceptForward AnalysisConceptDirection = "forward" + AnalysisConceptReverse AnalysisConceptDirection = "reverse" +) + +// AnalysisGenerationHeader is the small, eagerly loaded manifest for one +// immutable analysis snapshot. GraphRevision returned by +// LoadActiveAnalysisHeader is a process-local load receipt; the persisted +// build revision is diagnostic only and is never compared across restarts. +type AnalysisGenerationHeader struct { + GenerationID int64 + FormatVersion uint32 + GraphRevision uint64 + CreatedAtUnix int64 + NodeCount int + CommunityCount int + ProcessCount int + ConceptCount int + PageRankMax float64 + AuthorityMax float64 + HubMax float64 + Modularity float64 + ProcessesTruncated bool + ProcessesTruncationReason string +} + +type AnalysisNodeMetric struct { + RowID int64 + NodeID string + CommunityID string + PageRank float64 + Authority float64 + Hub float64 +} + +type AnalysisMetricCursor struct { + Score float64 + RowID int64 +} + +type AnalysisCommunitySummary struct { + ID string + Label string + Hub string + ParentID string + Size int + Cohesion float64 + Files []string +} + +type AnalysisProcessSummary struct { + ID string + Name string + EntryPoint string + StepCount int + Score float64 + Truncated bool + Files []string +} + +type AnalysisProcessStep struct { + ProcessID string + NodeID string + Ordinal int + Depth int +} + +type AnalysisProcessMembership struct { + NodeID string + ProcessID string +} + +type AnalysisConcept struct { + Token string + InVocabulary bool +} + +type AnalysisConceptRelation struct { + Token string + RelatedToken string + Rank int +} + +type AnalysisConceptQueryResult struct { + Concepts []AnalysisConcept + Relations []AnalysisConceptRelation +} + +type AnalysisBlob struct { + Component AnalysisBlobComponent + Payload []byte +} + +// AnalysisGenerationStore writes immutable generations in bounded chunks. +// Every mutating method re-checks expectedRevision while holding the same +// mutation gate used by graph writes. Partial generations remain invisible. +type AnalysisGenerationStore interface { + AnalysisMutationRevision() uint64 + CommitAnalysisSnapshot(expectedRevision uint64, install func()) bool + BeginAnalysisGeneration(expectedRevision uint64, header AnalysisGenerationHeader) (generationID int64, accepted bool, err error) + AppendAnalysisNodes(expectedRevision uint64, generationID int64, nodes []AnalysisNodeMetric) (accepted bool, err error) + AppendAnalysisCommunities(expectedRevision uint64, generationID int64, communities []AnalysisCommunitySummary) (accepted bool, err error) + AppendAnalysisProcesses(expectedRevision uint64, generationID int64, processes []AnalysisProcessSummary, steps []AnalysisProcessStep) (accepted bool, err error) + AppendAnalysisConcepts(expectedRevision uint64, generationID int64, concepts []AnalysisConcept, relations []AnalysisConceptRelation) (accepted bool, err error) + PutAnalysisBlob(expectedRevision uint64, generationID int64, blob AnalysisBlob) (accepted bool, err error) + SealAnalysisComponent(expectedRevision uint64, generationID int64, component AnalysisComponent, expectedRows int) (accepted bool, err error) + ActivateAnalysisGeneration(expectedRevision uint64, generationID int64) (accepted bool, err error) + AbortAnalysisGeneration(generationID int64) error + PruneAnalysisGenerations(ctx context.Context, keep, batch int) error +} + +// AnalysisQueryStore exposes bounded point, batch, top-N, and keyset-paged +// reads. Empty ID/token inputs always mean an empty result, never an unbounded +// scan. +type AnalysisQueryStore interface { + LoadActiveAnalysisHeader(formatVersion uint32) (AnalysisGenerationHeader, bool, error) + AnalysisNodeMetrics(generationID int64, nodeIDs []string) ([]AnalysisNodeMetric, error) + ListAnalysisNodeMetrics(generationID int64, limit int, cursorNodeID string) ([]AnalysisNodeMetric, string, error) + TopAnalysisNodeMetrics(generationID int64, metric AnalysisMetric, limit int, cursor *AnalysisMetricCursor) ([]AnalysisNodeMetric, *AnalysisMetricCursor, error) + ListAnalysisCommunitySummaries(generationID int64, limit int, cursorID string) ([]AnalysisCommunitySummary, string, error) + AnalysisCommunityMembers(generationID int64, communityID string, limit int, cursorNodeID string) ([]AnalysisNodeMetric, string, error) + ListAnalysisProcessSummaries(generationID int64, limit int, cursorID string) ([]AnalysisProcessSummary, string, error) + AnalysisProcessSteps(generationID int64, processID string, limit int, cursorOrdinal int) ([]AnalysisProcessStep, int, error) + AnalysisProcessesForNodes(generationID int64, nodeIDs []string) ([]AnalysisProcessMembership, error) + AnalysisConcepts(generationID int64, tokens []string, direction AnalysisConceptDirection) (AnalysisConceptQueryResult, error) + ListAnalysisConcepts(generationID int64, limit int, cursorToken string) (AnalysisConceptQueryResult, string, error) + LoadAnalysisBlob(generationID int64, component AnalysisBlobComponent) ([]byte, bool, error) +} diff --git a/internal/graph/epistemic_boundary.go b/internal/graph/epistemic_boundary.go index 79cb7b995..a44d6f83d 100644 --- a/internal/graph/epistemic_boundary.go +++ b/internal/graph/epistemic_boundary.go @@ -1,6 +1,7 @@ package graph import ( + "context" "sort" "strings" ) @@ -127,16 +128,56 @@ func CalleeBoundaries(g Store, nodeIDs []string, limit int) []EpistemicBoundary // callers not attributed to it directly. It names the interface so an agent // can run find_implementations / get_callers on it to widen the picture. func CallerBoundaries(g Store, nodeIDs []string, limit int) []EpistemicBoundary { + out, _ := CallerBoundariesContext(context.Background(), g, nodeIDs, limit) + return out +} + +type callerBoundaryOutgoingContextStore interface { + GetOutEdgesByNodeIDsContext(context.Context, []string, int) (map[string][]*Edge, bool, error) +} + +type callerBoundaryNodesContextStore interface { + GetNodesByIDsContext(context.Context, []string) (map[string]*Node, error) +} + +// CallerBoundariesContext reports interface-dispatch boundaries without +// allowing SQLite reads to outlive ctx. Stores may opt into cancellable batch +// reads; the legacy path remains available for in-memory implementations. +func CallerBoundariesContext(ctx context.Context, g Store, nodeIDs []string, limit int) ([]EpistemicBoundary, bool) { if g == nil { - return nil + return nil, false + } + if ctx == nil { + ctx = context.Background() } if limit <= 0 { limit = maxBoundaries } + if err := ctx.Err(); err != nil { + return nil, true + } + + edgeLimit := limit + 1 + if edgeLimit < maxBoundaries+1 { + edgeLimit = maxBoundaries + 1 + } + edgesByID, truncated := callerBoundaryOutEdgesContext(ctx, g, nodeIDs, edgeLimit) + nodesByID, nodeErr := callerBoundaryNodesContext(ctx, g, nodeIDs) + if nodeErr != nil { + truncated = true + } + seen := map[string]bool{} var out []EpistemicBoundary for _, id := range nodeIDs { - for _, e := range g.GetOutEdges(id) { + if err := ctx.Err(); err != nil { + return sortBoundaries(out), true + } + seedName := boundaryTargetName(id) + if n := nodesByID[id]; n != nil && n.Name != "" { + seedName = n.Name + } + for _, e := range edgesByID[id] { if e.Kind != EdgeImplements && e.Kind != EdgeOverrides { continue } @@ -147,18 +188,50 @@ func CallerBoundaries(g Store, nodeIDs []string, limit int) []EpistemicBoundary seen[key] = true out = append(out, EpistemicBoundary{ SeedID: id, - SeedName: nameForID(g, id), + SeedName: seedName, Target: boundaryTargetName(e.To), EdgeKind: string(e.Kind), Reason: BoundaryInterfaceDispatch, Direction: "callers", }) if len(out) >= limit { - return sortBoundaries(out) + return sortBoundaries(out), truncated } } } - return sortBoundaries(out) + return sortBoundaries(out), truncated +} + +func callerBoundaryOutEdgesContext(ctx context.Context, g Store, nodeIDs []string, limit int) (map[string][]*Edge, bool) { + if reader, ok := g.(callerBoundaryOutgoingContextStore); ok { + out, truncated, err := reader.GetOutEdgesByNodeIDsContext(ctx, nodeIDs, limit) + return out, truncated || err != nil + } + out := make(map[string][]*Edge, len(nodeIDs)) + total := 0 + for _, id := range nodeIDs { + if err := ctx.Err(); err != nil { + return out, true + } + for _, e := range g.GetOutEdges(id) { + if total >= limit { + return out, true + } + out[id] = append(out[id], e) + total++ + } + } + return out, false +} + +func callerBoundaryNodesContext(ctx context.Context, g Store, nodeIDs []string) (map[string]*Node, error) { + if reader, ok := g.(callerBoundaryNodesContextStore); ok { + return reader.GetNodesByIDsContext(ctx, nodeIDs) + } + if err := ctx.Err(); err != nil { + return nil, err + } + return g.GetNodesByIDs(nodeIDs), nil } // LowerBoundCaveat reports whether the boundary set makes the count a genuine diff --git a/internal/graph/epistemic_boundary_context_test.go b/internal/graph/epistemic_boundary_context_test.go new file mode 100644 index 000000000..e25d76d8c --- /dev/null +++ b/internal/graph/epistemic_boundary_context_test.go @@ -0,0 +1,76 @@ +package graph + +import ( + "context" + "testing" +) + +type boundaryContextStoreStub struct { + Store + edges map[string][]*Edge + nodes map[string]*Node + edgeErr error + nodeErr error + edgeCalled bool + nodeCalled bool + legacyCalled bool +} + +func (s *boundaryContextStoreStub) GetOutEdgesByNodeIDsContext(context.Context, []string, int) (map[string][]*Edge, bool, error) { + s.edgeCalled = true + return s.edges, false, s.edgeErr +} + +func (s *boundaryContextStoreStub) GetNodesByIDsContext(context.Context, []string) (map[string]*Node, error) { + s.nodeCalled = true + return s.nodes, s.nodeErr +} + +func (s *boundaryContextStoreStub) GetOutEdges(string) []*Edge { + s.legacyCalled = true + return nil +} + +func (s *boundaryContextStoreStub) GetNodesByIDs([]string) map[string]*Node { + s.legacyCalled = true + return nil +} + +func TestCallerBoundariesContextStopsBeforeStoreReadsWhenCanceled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + s := &boundaryContextStoreStub{} + + boundaries, truncated := CallerBoundariesContext(ctx, s, []string{"seed"}, 1) + if len(boundaries) != 0 || !truncated { + t.Fatalf("CallerBoundariesContext = (%v, %v), want empty conservative result", boundaries, truncated) + } + if s.edgeCalled || s.nodeCalled || s.legacyCalled { + t.Fatal("store read attempted after cancellation") + } +} + +func TestCallerBoundariesContextUsesCancellableBatchReads(t *testing.T) { + s := &boundaryContextStoreStub{ + edges: map[string][]*Edge{ + "seed": {{From: "seed", To: "pkg::Trait.method", Kind: EdgeImplements}}, + }, + nodes: map[string]*Node{ + "seed": {ID: "seed", Name: "Run"}, + }, + } + + boundaries, truncated := CallerBoundariesContext(context.Background(), s, []string{"seed"}, 1) + if truncated { + t.Fatal("unexpected truncated result") + } + if len(boundaries) != 1 || boundaries[0].SeedName != "Run" { + t.Fatalf("boundaries = %#v, want one boundary named Run", boundaries) + } + if !s.edgeCalled || !s.nodeCalled { + t.Fatal("cancellable batch methods were not both used") + } + if s.legacyCalled { + t.Fatal("legacy store read used despite context-aware methods") + } +} diff --git a/internal/graph/graph.go b/internal/graph/graph.go index 08c3cba0f..2132ada2f 100644 --- a/internal/graph/graph.go +++ b/internal/graph/graph.go @@ -537,6 +537,12 @@ type Graph struct { // (the files sidecar feeding index_health). Guarded by fileMetasMu. fileMetasMu sync.Mutex fileMetas map[string]map[string]FileMetaRow + + // mutationReceipts backs the optional, in-memory-only mutation receipt + // capability used to bound post-enrichment resolution. It is intentionally + // absent from disk stores until they can provide the same completeness + // guarantee. + mutationReceipts mutationReceiptState } // cloneShingleEntry is one in-memory clone_shingles row: the owning @@ -659,8 +665,52 @@ func (g *Graph) ReindexEdges(batch []EdgeReindex) { if r.Edge == nil { continue } - g.ReindexEdge(r.Edge, r.OldTo) + if !r.RefreshIdentity { + g.ReindexEdge(r.Edge, r.OldTo) + continue + } + g.refreshEdgeIdentity(r.Edge, r.OldTo, r.OldFilePath, r.OldLine) + } +} + +// refreshEdgeIdentity replaces an existing edge in place while repairing the +// out/in identity sidecars. Keeping the stored pointer preserves AllEdges cache +// coherence; the watcher uses this for source-span/meta refreshes that must not +// discard a resolver-selected target. +func (g *Graph) refreshEdgeIdentity(e *Edge, oldTo, oldFilePath string, oldLine int) { + receiptActive := g.beginReceiptMutation() + if receiptActive { + defer g.endReceiptMutation() + } + g.markMutationReceiptsIncomplete() + unlock := g.lockThreeWrite(e.From, oldTo, e.To) + defer unlock() + + oldKey := hashEdgeKey(edgeKey{From: e.From, To: oldTo, Kind: e.Kind, FilePath: oldFilePath, Line: oldLine}) + newKey := hashEdgeKey(keyOf(e)) + sFrom := g.shardFor(e.From) + fromIdx := sFrom.outEdgeIdx[e.From] + pos, ok := fromIdx[oldKey] + if !ok || pos >= len(sFrom.outEdges[e.From]) { + return + } + stored := sFrom.outEdges[e.From][pos] + + if oldKey != newKey || oldTo != e.To { + delete(fromIdx, oldKey) + fromIdx[newKey] = pos + if keys := sFrom.outEdgeKeys[e.From]; pos < len(keys) { + keys[pos] = newKey + } + sOld := g.shardFor(oldTo) + removeEdgeFromBucket(sOld.inEdges, sOld.inEdgeKeys, sOld.inEdgeIdx, oldTo, oldKey) + *stored = *e + sNew := g.shardFor(e.To) + addEdgeToBucket(sNew.inEdges, sNew.inEdgeKeys, sNew.inEdgeIdx, e.To, stored) + g.edgeIdentityRevisions.Add(1) + return } + *stored = *e } // BulkSetCloneShingles is the in-memory implementation of @@ -2011,10 +2061,26 @@ func (g *Graph) unlockAllRead() { // are migrated from the old bucket to the new one atomically under the // shard lock. func (g *Graph) AddNode(n *Node) { + receiptActive := g.beginReceiptMutation() + if receiptActive { + defer g.endReceiptMutation() + } s := g.shardFor(n.ID) s.mu.Lock() + old := s.nodes[n.ID] g.addNodeLocked(s, n) s.mu.Unlock() + + if old == nil { + g.recordAddedNodeForReceipts(n, IsReferenceableSymbol(n.Kind), true) + return + } + // Replacing an existing definition with a different identity surface is + // uncommon in deferred passes and cannot be represented as an add-only + // receipt. Fail closed rather than claiming a precise frontier. + if old.Name != n.Name || old.QualName != n.QualName || old.FilePath != n.FilePath || old.Kind != n.Kind || old.RepoPrefix != n.RepoPrefix { + g.markMutationReceiptsIncomplete() + } } // addNodeLocked is AddNode's body, expecting the caller to already hold @@ -2099,6 +2165,10 @@ func (g *Graph) AddBatch(nodes []*Node, edges []*Edge) { if len(nodes) == 0 && len(edges) == 0 { return } + receiptActive := g.beginReceiptMutation() + if receiptActive { + defer g.endReceiptMutation() + } nodesByShard := make([][]*Node, g.shardCount) outEdgesByShard := make([][]*Edge, g.shardCount) inEdgesByShard := make([][]*Edge, g.shardCount) @@ -2117,6 +2187,9 @@ func (g *Graph) AddBatch(nodes []*Node, edges []*Edge) { inEdgesByShard[g.shardIdx(e.To)] = append(inEdgesByShard[g.shardIdx(e.To)], e) } + var addedNodes []*Node + var addedEdges []*Edge + incompleteReceipt := false for i := range g.shards { if len(nodesByShard[i]) == 0 && len(outEdgesByShard[i]) == 0 && len(inEdgesByShard[i]) == 0 { continue @@ -2124,7 +2197,13 @@ func (g *Graph) AddBatch(nodes []*Node, edges []*Edge) { s := g.shards[i] s.mu.Lock() for _, n := range nodesByShard[i] { + old := s.nodes[n.ID] g.addNodeLocked(s, n) + if old == nil { + addedNodes = append(addedNodes, n) + } else if old.Name != n.Name || old.QualName != n.QualName || old.FilePath != n.FilePath || old.Kind != n.Kind || old.RepoPrefix != n.RepoPrefix { + incompleteReceipt = true + } } // Out-side writes own the "was this a new insert?" signal that // drives the per-repo edge counter and the "did Origin change?" @@ -2132,17 +2211,27 @@ func (g *Graph) AddBatch(nodes []*Node, edges []*Edge) { // write is bookkeeping only and charges neither (mirrors // AddEdge's behaviour). for _, e := range outEdgesByShard[i] { + var old *Edge + h := hashEdgeKey(keyOf(e)) + if positions := s.outEdgeIdx[e.From]; positions != nil { + if pos, ok := positions[h]; ok && pos < len(s.outEdges[e.From]) { + old = s.outEdges[e.From][pos] + } + } inserted, originChanged := addEdgeToBucket(s.outEdges, s.outEdgeKeys, s.outEdgeIdx, e.From, e) if originChanged { g.edgeIdentityRevisions.Add(1) } if inserted { g.edgeMutGen.Add(1) + addedEdges = append(addedEdges, e) var srcRepo string if src, ok := s.nodes[e.From]; ok && src != nil { srcRepo = src.RepoPrefix } s.repoEdgeAdd(srcRepo, e) + } else if old != nil && old.Alias != e.Alias { + incompleteReceipt = true } } for _, e := range inEdgesByShard[i] { @@ -2150,6 +2239,22 @@ func (g *Graph) AddBatch(nodes []*Node, edges []*Edge) { } s.mu.Unlock() } + + if incompleteReceipt { + g.markMutationReceiptsIncomplete() + } + for _, n := range addedNodes { + g.recordAddedNodeForReceipts(n, IsReferenceableSymbol(n.Kind), true) + } + for _, e := range addedEdges { + file := e.FilePath + if file == "" { + if src := g.GetNode(e.From); src != nil { + file = src.FilePath + } + } + g.recordAddedEdgeForReceipts(e, file) + } } // AddEdge inserts or updates a directed edge in the graph. Locks both @@ -2160,10 +2265,20 @@ func (g *Graph) AddBatch(nodes []*Node, edges []*Edge) { // adjacency-list length is unchanged. Drops the double-edge problem // that used to surface after daemon restarts (bug B1). func (g *Graph) AddEdge(e *Edge) { + receiptActive := g.beginReceiptMutation() + if receiptActive { + defer g.endReceiptMutation() + } unlock := g.lockTwoWrite(e.From, e.To) - defer unlock() sFrom := g.shardFor(e.From) sTo := g.shardFor(e.To) + var old *Edge + h := hashEdgeKey(keyOf(e)) + if positions := sFrom.outEdgeIdx[e.From]; positions != nil { + if pos, ok := positions[h]; ok && pos < len(sFrom.outEdges[e.From]) { + old = sFrom.outEdges[e.From][pos] + } + } // Only charge the source-repo counter on a brand-new insert. // Idempotent re-adds (same edgeKey) replace the slot in place and // would otherwise double-count. addEdgeToBucket reports inserted == @@ -2186,6 +2301,19 @@ func (g *Graph) AddEdge(e *Edge) { } sFrom.repoEdgeAdd(srcRepo, e) } + unlock() + + if inserted { + file := e.FilePath + if file == "" { + if src := g.GetNode(e.From); src != nil { + file = src.FilePath + } + } + g.recordAddedEdgeForReceipts(e, file) + } else if old != nil && old.Alias != e.Alias { + g.markMutationReceiptsIncomplete() + } } // SetEdgeProvenance changes the Origin of an edge already in the graph @@ -2306,6 +2434,11 @@ func (g *Graph) ReindexEdge(e *Edge, oldTo string) { if oldTo == e.To { return } + receiptActive := g.beginReceiptMutation() + if receiptActive { + defer g.endReceiptMutation() + } + g.markMutationReceiptsIncomplete() // Must lock the From shard too — we mutate sFrom.outEdgeIdx below, // and without its lock a concurrent AddEdge on From panics the // runtime with "concurrent map read and map write". @@ -2587,6 +2720,11 @@ func (g *Graph) GetInEdgesByNodeIDs(ids []string) map[string][]*Edge { // path. Nodes for one file can span many shards (different IDs hash // differently), so we lock all shards for this multi-shard operation. func (g *Graph) EvictFile(filePath string) (nodesRemoved, edgesRemoved int) { + receiptActive := g.beginReceiptMutation() + if receiptActive { + defer g.endReceiptMutation() + } + g.markMutationReceiptsIncomplete() g.lockAllWrite() defer g.unlockAllWrite() @@ -2698,6 +2836,11 @@ func (g *Graph) evictEdgesLocked(evictedIDs map[string]string) int { // (same from/to/kind but different file/line — rare but possible), // removes the first one encountered. func (g *Graph) RemoveEdge(from, to string, kind EdgeKind) bool { + receiptActive := g.beginReceiptMutation() + if receiptActive { + defer g.endReceiptMutation() + } + g.markMutationReceiptsIncomplete() unlock := g.lockTwoWrite(from, to) defer unlock() @@ -2986,6 +3129,11 @@ func (g *Graph) GetRepoEdges(repoPrefix string) []*Edge { // EvictRepo removes all nodes with matching RepoPrefix and all edges // referencing those nodes. Returns counts of removed nodes and edges. func (g *Graph) EvictRepo(repoPrefix string) (nodesRemoved, edgesRemoved int) { + receiptActive := g.beginReceiptMutation() + if receiptActive { + defer g.endReceiptMutation() + } + g.markMutationReceiptsIncomplete() g.lockAllWrite() defer g.unlockAllWrite() diff --git a/internal/graph/mutation_receipt.go b/internal/graph/mutation_receipt.go new file mode 100644 index 000000000..38bbc23d9 --- /dev/null +++ b/internal/graph/mutation_receipt.go @@ -0,0 +1,258 @@ +package graph + +import ( + "slices" + "sync" + "sync/atomic" +) + +// MutationReceiptToken identifies one active graph-mutation receipt. Tokens are +// store-local and opaque to callers. +type MutationReceiptToken uint64 + +// MutationReceipt is the exact resolution-facing delta observed between +// BeginMutationReceipt and EndMutationReceipt. +// +// Complete is false when the store saw a mutation shape it cannot describe +// exactly. Callers must fall back to a whole-graph pass in that case. A complete +// receipt with ResolutionRelevant false proves that no added definition or +// unresolved reference can change name/import resolution. +type MutationReceipt struct { + Complete bool `json:"complete"` + ResolutionRelevant bool `json:"resolution_relevant"` + ChangedFiles []string `json:"changed_files,omitempty"` + DefinitionFiles []string `json:"definition_files,omitempty"` + TargetNames []string `json:"target_names,omitempty"` + TargetIDs []string `json:"target_ids,omitempty"` + ImportCandidates []string `json:"import_candidates,omitempty"` +} + +// ResolutionFiles returns the deduplicated exact file frontier needed by +// Resolver.ResolveFilesAndIncoming. +func (r MutationReceipt) ResolutionFiles() []string { + seen := make(map[string]struct{}, len(r.ChangedFiles)+len(r.DefinitionFiles)) + out := make([]string, 0, len(seen)) + for _, files := range [][]string{r.ChangedFiles, r.DefinitionFiles} { + for _, file := range files { + if file == "" { + continue + } + if _, ok := seen[file]; ok { + continue + } + seen[file] = struct{}{} + out = append(out, file) + } + } + slices.Sort(out) + return out +} + +// MutationReceiptStore is an optional graph-store capability. Backends must not +// advertise it unless every resolution-relevant mutation performed while a +// receipt is active is either represented exactly or marks the receipt +// incomplete. Multiple receipts may overlap; each must observe the mutations +// made during its own lifetime independently. +type MutationReceiptStore interface { + BeginMutationReceipt() MutationReceiptToken + EndMutationReceipt(MutationReceiptToken) MutationReceipt +} + +type mutationReceiptAccumulator struct { + complete bool + resolutionRelevant bool + changedFiles map[string]struct{} + definitionFiles map[string]struct{} + targetNames map[string]struct{} + targetIDs map[string]struct{} + importCandidates map[string]struct{} +} + +func newMutationReceiptAccumulator() *mutationReceiptAccumulator { + return &mutationReceiptAccumulator{ + complete: true, + changedFiles: make(map[string]struct{}), + definitionFiles: make(map[string]struct{}), + targetNames: make(map[string]struct{}), + targetIDs: make(map[string]struct{}), + importCandidates: make(map[string]struct{}), + } +} + +func (a *mutationReceiptAccumulator) receipt() MutationReceipt { + return MutationReceipt{ + Complete: a.complete, + ResolutionRelevant: a.resolutionRelevant, + ChangedFiles: sortedReceiptKeys(a.changedFiles), + DefinitionFiles: sortedReceiptKeys(a.definitionFiles), + TargetNames: sortedReceiptKeys(a.targetNames), + TargetIDs: sortedReceiptKeys(a.targetIDs), + ImportCandidates: sortedReceiptKeys(a.importCandidates), + } +} + +func sortedReceiptKeys(values map[string]struct{}) []string { + if len(values) == 0 { + return nil + } + out := make([]string, 0, len(values)) + for value := range values { + if value != "" { + out = append(out, value) + } + } + slices.Sort(out) + return out +} + +// mutationReceiptState is embedded in the in-memory Graph. Keeping it separate +// makes the optional capability self-contained and avoids widening Store. +type mutationReceiptState struct { + // activeCount keeps the overwhelmingly common no-receipt mutation path + // lock- and allocation-free. Begin/End publish it while holding gate + // exclusively, so a mutation that observes zero can be linearized before + // an overlapping Begin (or after an overlapping End). + activeCount atomic.Uint64 + + // gate makes active receipt boundaries atomic with graph writes without + // serialising writers: mutations hold a shared lock only while at least one + // receipt is active; Begin/End take the exclusive lock while changing the + // active window set. + gate sync.RWMutex + mu sync.Mutex + next MutationReceiptToken + active map[MutationReceiptToken]*mutationReceiptAccumulator +} + +// BeginMutationReceipt starts an independent mutation observation window. +func (g *Graph) BeginMutationReceipt() MutationReceiptToken { + g.mutationReceipts.gate.Lock() + defer g.mutationReceipts.gate.Unlock() + g.mutationReceipts.mu.Lock() + defer g.mutationReceipts.mu.Unlock() + g.mutationReceipts.next++ + if g.mutationReceipts.next == 0 { + g.mutationReceipts.next++ + } + if g.mutationReceipts.active == nil { + g.mutationReceipts.active = make(map[MutationReceiptToken]*mutationReceiptAccumulator) + } + token := g.mutationReceipts.next + g.mutationReceipts.active[token] = newMutationReceiptAccumulator() + g.mutationReceipts.activeCount.Store(uint64(len(g.mutationReceipts.active))) + return token +} + +// EndMutationReceipt closes one observation window. An unknown/already-ended +// token fails closed so consumers never mistake a bookkeeping error for a +// proven empty delta. +func (g *Graph) EndMutationReceipt(token MutationReceiptToken) MutationReceipt { + g.mutationReceipts.gate.Lock() + defer g.mutationReceipts.gate.Unlock() + g.mutationReceipts.mu.Lock() + defer g.mutationReceipts.mu.Unlock() + acc := g.mutationReceipts.active[token] + if acc == nil { + return MutationReceipt{Complete: false} + } + delete(g.mutationReceipts.active, token) + g.mutationReceipts.activeCount.Store(uint64(len(g.mutationReceipts.active))) + return acc.receipt() +} + +// beginReceiptMutation enters the receipt gate only when a window is active. +// A mutation that observes zero overlaps any concurrent Begin and is +// linearizable immediately before it; an active mutation holds the shared gate +// through recording so End cannot retire its accumulator too early. +func (g *Graph) beginReceiptMutation() bool { + if g.mutationReceipts.activeCount.Load() == 0 { + return false + } + g.mutationReceipts.gate.RLock() + return true +} + +func (g *Graph) endReceiptMutation() { + g.mutationReceipts.gate.RUnlock() +} + +func (g *Graph) recordAddedNodeForReceipts(n *Node, definition, exact bool) { + if n == nil || g.mutationReceipts.activeCount.Load() == 0 { + return + } + g.mutationReceipts.mu.Lock() + defer g.mutationReceipts.mu.Unlock() + for _, acc := range g.mutationReceipts.active { + if n.ID != "" { + acc.targetIDs[n.ID] = struct{}{} + } + if n.Name != "" { + acc.targetNames[n.Name] = struct{}{} + } + if n.QualName != "" { + acc.targetNames[n.QualName] = struct{}{} + } + if n.FilePath != "" { + acc.changedFiles[n.FilePath] = struct{}{} + } + if !definition { + continue + } + acc.resolutionRelevant = true + if n.FilePath != "" { + acc.definitionFiles[n.FilePath] = struct{}{} + } + if !exact || n.FilePath == "" { + acc.complete = false + } + } +} + +func (g *Graph) recordAddedEdgeForReceipts(e *Edge, exactFile string) { + if e == nil || g.mutationReceipts.activeCount.Load() == 0 { + return + } + g.mutationReceipts.mu.Lock() + defer g.mutationReceipts.mu.Unlock() + for _, acc := range g.mutationReceipts.active { + if e.To != "" { + acc.targetIDs[e.To] = struct{}{} + } + if name := UnresolvedName(e.To); name != "" { + acc.targetNames[name] = struct{}{} + } + if e.Kind == EdgeImports { + if name := UnresolvedName(e.To); name != "" { + acc.importCandidates[name] = struct{}{} + } else if e.To != "" { + acc.importCandidates[e.To] = struct{}{} + } + if e.Alias != "" { + acc.importCandidates[e.Alias] = struct{}{} + } + } + if exactFile != "" { + acc.changedFiles[exactFile] = struct{}{} + } + if !IsUnresolvedTarget(e.To) { + continue + } + acc.resolutionRelevant = true + if exactFile == "" { + acc.complete = false + } + } +} + +func (g *Graph) markMutationReceiptsIncomplete() { + if g.mutationReceipts.activeCount.Load() == 0 { + return + } + g.mutationReceipts.mu.Lock() + defer g.mutationReceipts.mu.Unlock() + for _, acc := range g.mutationReceipts.active { + acc.complete = false + } +} + +var _ MutationReceiptStore = (*Graph)(nil) diff --git a/internal/graph/mutation_receipt_test.go b/internal/graph/mutation_receipt_test.go new file mode 100644 index 000000000..7fea691f2 --- /dev/null +++ b/internal/graph/mutation_receipt_test.go @@ -0,0 +1,155 @@ +package graph + +import ( + "slices" + "sync" + "testing" + "time" +) + +func TestMutationReceiptCapturesExactResolutionDelta(t *testing.T) { + g := New() + token := g.BeginMutationReceipt() + g.AddBatch([]*Node{ + {ID: "repo/src/a.go::Caller", Kind: KindFunction, Name: "Caller", QualName: "pkg.Caller", FilePath: "src/a.go", RepoPrefix: "repo"}, + {ID: "repo/src/b.go::Load", Kind: KindFunction, Name: "Load", QualName: "pkg.Load", FilePath: "src/b.go", RepoPrefix: "repo"}, + }, []*Edge{{ + From: "repo/src/a.go::Caller", To: "repo::" + UnresolvedMarker + "Load", Kind: EdgeImports, + FilePath: "src/a.go", Alias: "loader", + }}) + receipt := g.EndMutationReceipt(token) + + if !receipt.Complete || !receipt.ResolutionRelevant { + t.Fatalf("receipt = %+v, want complete resolution delta", receipt) + } + if want := []string{"src/a.go", "src/b.go"}; !slices.Equal(receipt.ResolutionFiles(), want) { + t.Fatalf("resolution files = %v, want %v", receipt.ResolutionFiles(), want) + } + assertReceiptContains(t, "target names", receipt.TargetNames, "Caller", "Load", "pkg.Caller", "pkg.Load") + assertReceiptContains(t, "target ids", receipt.TargetIDs, + "repo/src/a.go::Caller", "repo/src/b.go::Load", "repo::"+UnresolvedMarker+"Load") + assertReceiptContains(t, "import candidates", receipt.ImportCandidates, "Load", "loader") +} + +func TestMutationReceiptIdempotentWritesProduceNoResolutionDelta(t *testing.T) { + g := New() + n := &Node{ID: "repo/a.go::A", Kind: KindFunction, Name: "A", FilePath: "a.go", RepoPrefix: "repo"} + e := &Edge{From: n.ID, To: "repo::" + UnresolvedMarker + "B", Kind: EdgeCalls, FilePath: "a.go"} + g.AddNode(n) + g.AddEdge(e) + + token := g.BeginMutationReceipt() + g.AddNode(n) + g.AddEdge(e) + receipt := g.EndMutationReceipt(token) + if !receipt.Complete { + t.Fatalf("idempotent receipt unexpectedly incomplete: %+v", receipt) + } + if receipt.ResolutionRelevant || len(receipt.ResolutionFiles()) != 0 { + t.Fatalf("idempotent writes produced resolution delta: %+v", receipt) + } +} + +func TestMutationReceiptsOverlapWithoutStealingEvents(t *testing.T) { + g := New() + outer := g.BeginMutationReceipt() + g.AddNode(&Node{ID: "repo/a.go::A", Kind: KindFunction, Name: "A", FilePath: "a.go", RepoPrefix: "repo"}) + inner := g.BeginMutationReceipt() + g.AddNode(&Node{ID: "repo/b.go::B", Kind: KindFunction, Name: "B", FilePath: "b.go", RepoPrefix: "repo"}) + outerReceipt := g.EndMutationReceipt(outer) + g.AddNode(&Node{ID: "repo/c.go::C", Kind: KindFunction, Name: "C", FilePath: "c.go", RepoPrefix: "repo"}) + innerReceipt := g.EndMutationReceipt(inner) + + assertReceiptContains(t, "outer files", outerReceipt.ResolutionFiles(), "a.go", "b.go") + if slices.Contains(outerReceipt.ResolutionFiles(), "c.go") { + t.Fatalf("outer receipt observed mutation after it ended: %+v", outerReceipt) + } + assertReceiptContains(t, "inner files", innerReceipt.ResolutionFiles(), "b.go", "c.go") + if slices.Contains(innerReceipt.ResolutionFiles(), "a.go") { + t.Fatalf("inner receipt observed mutation before it began: %+v", innerReceipt) + } +} + +func TestMutationReceiptFailsClosedForUnsupportedMutationAndUnknownToken(t *testing.T) { + g := New() + token := g.BeginMutationReceipt() + g.RemoveEdge("missing", "missing", EdgeCalls) + if receipt := g.EndMutationReceipt(token); receipt.Complete { + t.Fatalf("unsupported mutation returned complete receipt: %+v", receipt) + } + if receipt := g.EndMutationReceipt(token); receipt.Complete { + t.Fatalf("already-ended token returned complete receipt: %+v", receipt) + } +} + +func TestMutationReceiptsConcurrentOverlap(t *testing.T) { + g := New() + const workers = 12 + ready := sync.WaitGroup{} + ready.Add(workers) + start := make(chan struct{}) + var wg sync.WaitGroup + wg.Add(workers) + for i := 0; i < workers; i++ { + i := i + go func() { + defer wg.Done() + token := g.BeginMutationReceipt() + ready.Done() + <-start + id := string(rune('a' + i)) + g.AddNode(&Node{ID: "repo/" + id + ".go::" + id, Kind: KindFunction, Name: id, FilePath: id + ".go", RepoPrefix: "repo"}) + receipt := g.EndMutationReceipt(token) + if !receipt.Complete || !receipt.ResolutionRelevant { + t.Errorf("concurrent receipt %d = %+v", i, receipt) + } + }() + } + ready.Wait() + close(start) + wg.Wait() +} + +func TestMutationReceiptEndWaitsForInFlightMutationRecord(t *testing.T) { + g := New() + token := g.BeginMutationReceipt() + mutationStarted := make(chan struct{}) + releaseMutation := make(chan struct{}) + go func() { + if !g.beginReceiptMutation() { + t.Error("active receipt was not observed") + return + } + defer g.endReceiptMutation() + close(mutationStarted) + <-releaseMutation + g.recordAddedNodeForReceipts(&Node{ID: "repo/a.go::A", Kind: KindFunction, Name: "A", FilePath: "a.go"}, true, true) + }() + <-mutationStarted + + receiptCh := make(chan MutationReceipt, 1) + go func() { receiptCh <- g.EndMutationReceipt(token) }() + select { + case receipt := <-receiptCh: + t.Fatalf("EndMutationReceipt overtook in-flight mutation: %+v", receipt) + case <-time.After(20 * time.Millisecond): + } + close(releaseMutation) + select { + case receipt := <-receiptCh: + if !receipt.Complete || !receipt.ResolutionRelevant || !slices.Contains(receipt.DefinitionFiles, "a.go") { + t.Fatalf("receipt missed in-flight mutation: %+v", receipt) + } + case <-time.After(time.Second): + t.Fatal("EndMutationReceipt did not complete after mutation drained") + } +} + +func assertReceiptContains(t *testing.T, label string, got []string, want ...string) { + t.Helper() + for _, value := range want { + if !slices.Contains(got, value) { + t.Errorf("%s = %v, missing %q", label, got, value) + } + } +} diff --git a/internal/graph/retrieval_metadata.go b/internal/graph/retrieval_metadata.go new file mode 100644 index 000000000..613ee998a --- /dev/null +++ b/internal/graph/retrieval_metadata.go @@ -0,0 +1,236 @@ +package graph + +import "strings" + +const ( + metaRetrievalSignature = "search_signature" + metaRetrievalQualName = "search_qual_name" + metaRetrievalDoc = "search_doc" + metaRetrievalSuppressed = "search_metadata_suppressed" + maxRetrievalSignature = 512 +) + +// RetrievalMetadata is the search/display projection of parser-owned symbol +// metadata. It is deliberately separate from Node.QualName and the parser's +// signature/doc values so retrieval heuristics cannot alter resolver identity. +type RetrievalMetadata struct { + Signature string + QualName string + Doc string +} + +// RetrievalMetadata returns normalized retrieval fields, falling back to the +// parser-owned values for graph snapshots created before normalization. +// QualName is empty unless the parser or normalizer had semantic owner evidence. +func (n *Node) RetrievalMetadata() RetrievalMetadata { + if n == nil { + return RetrievalMetadata{} + } + // Child scopes frequently inherit their owner's source span and parser + // metadata. Never expose that declaration payload as parameter/local search + // metadata, including when reading snapshots created before normalization. + if retrievalMetadataSuppressed(n) { + return RetrievalMetadata{} + } + qualName := firstMetadataString(n.Meta, metaRetrievalQualName, "") + if qualName == "" { + qualName = n.QualName + } + return RetrievalMetadata{ + Signature: compactRetrievalSignature(firstMetadataString(n.Meta, metaRetrievalSignature, "signature"), n.Language), + QualName: qualName, + Doc: firstMetadataString(n.Meta, metaRetrievalDoc, "doc"), + } +} + +// SetRetrievalMetadata replaces the retrieval-only projection. Empty values +// remove prior projections; parser-owned metadata and Node.QualName are never +// modified. +func SetRetrievalMetadata(n *Node, metadata RetrievalMetadata) { + if n == nil { + return + } + if n.Meta == nil { + if metadata.Signature == "" && metadata.QualName == "" && metadata.Doc == "" { + return + } + n.Meta = make(map[string]any) + } + delete(n.Meta, metaRetrievalSuppressed) + setMetadataString(n.Meta, metaRetrievalSignature, compactRetrievalSignature(metadata.Signature, n.Language)) + setMetadataString(n.Meta, metaRetrievalQualName, metadata.QualName) + setMetadataString(n.Meta, metaRetrievalDoc, metadata.Doc) +} + +// SuppressRetrievalMetadata marks a parser node as intentionally absent from +// search metadata without deleting parser-owned signature, doc, or QualName. +func SuppressRetrievalMetadata(n *Node) { + if n == nil { + return + } + if n.Meta == nil { + n.Meta = make(map[string]any) + } + n.Meta[metaRetrievalSuppressed] = true + delete(n.Meta, metaRetrievalSignature) + delete(n.Meta, metaRetrievalQualName) + delete(n.Meta, metaRetrievalDoc) +} + +func retrievalMetadataSuppressed(n *Node) bool { + if n == nil { + return true + } + if suppressed, _ := n.Meta[metaRetrievalSuppressed].(bool); suppressed { + return true + } + switch n.Kind { + case KindParam, KindLocal, KindGenericParam: + return true + case KindVariable: + return legacyVariableMetadataLocal(n.Meta) + default: + return false + } +} + +func legacyVariableMetadataLocal(meta map[string]any) bool { + for _, key := range []string{"local", "is_local"} { + if value, ok := meta[key].(bool); ok && value { + return true + } + } + for _, key := range []string{"scope", "scope_kind", "parent_kind", "enclosing_kind", "storage"} { + value, _ := meta[key].(string) + switch strings.ToLower(strings.TrimSpace(value)) { + case "local", "function", "function-local", "method", "closure", "block": + return true + } + } + return false +} + +func compactRetrievalSignature(value, language string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + language = strings.ToLower(strings.TrimSpace(language)) + parenDepth, bracketDepth, angleDepth := 0, 0, 0 + quote := rune(0) + escaped := false + cut := len(value) + for i, r := range value { + if quote != 0 { + if escaped { + escaped = false + continue + } + if r == '\\' { + escaped = true + continue + } + if r == quote { + quote = 0 + } + continue + } + switch r { + case '\'': + if language == "rust" && retrievalRustLifetimeAt(value, i) { + continue + } + quote = r + case '"', '`': + quote = r + case '(': + parenDepth++ + case ')': + if parenDepth > 0 { + parenDepth-- + } + case '[': + bracketDepth++ + case ']': + if bracketDepth > 0 { + bracketDepth-- + } + case '<': + if (language == "go" || language == "golang") && i+1 < len(value) && value[i+1] == '-' { + continue + } + angleDepth++ + case '>': + if angleDepth > 0 { + angleDepth-- + } + case '{', ';': + if parenDepth == 0 && bracketDepth == 0 && angleDepth == 0 { + cut = i + } + case '\n', '\r': + if !retrievalLanguageAllowsMultilineSignature(language) && parenDepth == 0 && bracketDepth == 0 && angleDepth == 0 { + cut = i + } + } + if cut != len(value) { + break + } + } + return boundedRetrievalString(strings.Join(strings.Fields(value[:cut]), " "), maxRetrievalSignature) +} + +func retrievalLanguageAllowsMultilineSignature(language string) bool { + switch language { + case "go", "golang", "rust", "java", "kotlin", "typescript", "tsx", "javascript", "jsx", "c", "cpp", "c++", "csharp", "c#", "swift": + return true + default: + return false + } +} + +func retrievalRustLifetimeAt(value string, quote int) bool { + if quote+1 >= len(value) || !retrievalIdentifierByte(value[quote+1]) { + return false + } + end := quote + 2 + for end < len(value) && retrievalIdentifierByte(value[end]) { + end++ + } + return end >= len(value) || value[end] != '\'' +} + +func retrievalIdentifierByte(b byte) bool { + return b == '_' || b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z' || b >= '0' && b <= '9' +} + +func boundedRetrievalString(value string, limit int) string { + value = strings.TrimSpace(value) + if limit <= 0 || len(value) <= limit { + return value + } + cut := limit + for cut > 0 && value[cut]&0xc0 == 0x80 { + cut-- + } + return strings.TrimSpace(value[:cut]) +} + +func firstMetadataString(meta map[string]any, primary, fallback string) string { + if value, _ := meta[primary].(string); value != "" { + return value + } + if fallback != "" { + value, _ := meta[fallback].(string) + return value + } + return "" +} + +func setMetadataString(meta map[string]any, key, value string) { + if value == "" { + delete(meta, key) + return + } + meta[key] = value +} diff --git a/internal/graph/retrieval_metadata_test.go b/internal/graph/retrieval_metadata_test.go new file mode 100644 index 000000000..27bf085ce --- /dev/null +++ b/internal/graph/retrieval_metadata_test.go @@ -0,0 +1,104 @@ +package graph + +import ( + "strings" + "testing" +) + +func TestNodeRetrievalMetadataFallbackAndOverride(t *testing.T) { + n := &Node{ + QualName: "pkg.Service.Handle", + Meta: map[string]any{ + "signature": "func (s *Service) Handle()", + "doc": "Handles requests.", + }, + } + fallback := n.RetrievalMetadata() + if fallback.Signature != "func (s *Service) Handle()" || fallback.QualName != n.QualName || fallback.Doc != "Handles requests." { + t.Fatalf("fallback metadata = %#v", fallback) + } + + SetRetrievalMetadata(n, RetrievalMetadata{ + Signature: "func Service.Handle()", + QualName: "Service.Handle", + Doc: "Handle a request.", + }) + got := n.RetrievalMetadata() + if got.Signature != "func Service.Handle()" || got.QualName != "Service.Handle" || got.Doc != "Handle a request." { + t.Fatalf("normalized metadata = %#v", got) + } + if n.QualName != "pkg.Service.Handle" || n.Meta["signature"] != "func (s *Service) Handle()" || n.Meta["doc"] != "Handles requests." { + t.Fatalf("parser metadata mutated: %#v", n) + } + + SetRetrievalMetadata(n, RetrievalMetadata{}) + if got := n.RetrievalMetadata(); got != fallback { + t.Fatalf("fallback after clearing = %#v, want %#v", got, fallback) + } +} + +func TestSetRetrievalMetadataEmptyDoesNotAllocateMeta(t *testing.T) { + n := &Node{} + SetRetrievalMetadata(n, RetrievalMetadata{}) + if n.Meta != nil { + t.Fatalf("empty metadata allocated map: %#v", n.Meta) + } +} + +func TestRetrievalMetadataCompactsLegacyAndNormalizedSignatures(t *testing.T) { + n := &Node{ + Kind: KindFunction, Name: "run", Language: "rust", + Meta: map[string]any{"signature": "fn run(value: Input) -> Output { let INLINE_SECRET = \"token\"; }"}, + } + if got := n.RetrievalMetadata().Signature; got != "fn run(value: Input) -> Output" { + t.Fatalf("legacy signature = %q", got) + } + SetRetrievalMetadata(n, RetrievalMetadata{ + Signature: "fn run(value: T) -> T { expose(INLINE_SECRET) } " + strings.Repeat("x", 700), + }) + got := n.RetrievalMetadata().Signature + if got != "fn run(value: T) -> T" || strings.Contains(got, "INLINE_SECRET") || len(got) > maxRetrievalSignature { + t.Fatalf("normalized signature not compact: len=%d value=%q", len(got), got) + } + long := &Node{Kind: KindFunction, Name: "long", Language: "rust", Meta: map[string]any{ + "signature": "fn long(" + strings.Repeat("VeryLongType, ", 80) + ")", + }} + if got := long.RetrievalMetadata().Signature; len(got) > maxRetrievalSignature { + t.Fatalf("legacy signature exceeded cap: len=%d", len(got)) + } +} + +func TestRetrievalMetadataSuppressesLegacyVariableLocalsOnly(t *testing.T) { + local := &Node{ + Kind: KindVariable, Name: "value", QualName: "pkg.resolve.value", + Meta: map[string]any{"scope": "function", "signature": "let value = INLINE_SECRET", "doc": "local"}, + } + if got := local.RetrievalMetadata(); got != (RetrievalMetadata{}) { + t.Fatalf("legacy local metadata = %#v", got) + } + global := &Node{ + Kind: KindVariable, Name: "value", QualName: "pkg.value", + Meta: map[string]any{"scope": "global", "signature": "var value string", "doc": "global"}, + } + if got := global.RetrievalMetadata(); got.Signature != "var value string" || got.QualName != "pkg.value" || got.Doc != "global" { + t.Fatalf("global metadata suppressed: %#v", got) + } +} + +func TestRetrievalMetadataSuppressesOwnerPayloadForChildScopes(t *testing.T) { + for _, kind := range []NodeKind{KindParam, KindLocal, KindGenericParam} { + n := &Node{ + Kind: kind, Name: "value", QualName: "pkg.resolve.value", + Meta: map[string]any{ + "signature": "fn resolve(value: Input) -> Output", + "doc": "Resolves an input value.", + metaRetrievalSignature: "fn resolve(value: Input) -> Output", + metaRetrievalQualName: "pkg.resolve", + metaRetrievalDoc: "Resolves an input value.", + }, + } + if got := n.RetrievalMetadata(); got != (RetrievalMetadata{}) { + t.Errorf("kind %q leaked owner metadata: %#v", kind, got) + } + } +} diff --git a/internal/graph/store.go b/internal/graph/store.go index 563e93caf..c853debe0 100644 --- a/internal/graph/store.go +++ b/internal/graph/store.go @@ -13,6 +13,12 @@ import ( type EdgeReindex struct { Edge *Edge OldTo string + // RefreshIdentity replaces the complete stored edge payload while moving + // its identity from (OldTo, OldFilePath, OldLine) to Edge's current key. + // Resolver callers leave it false and retain the historical To-only path. + RefreshIdentity bool + OldFilePath string + OldLine int } // EdgeProvenanceUpdate is the per-edge payload for @@ -1070,6 +1076,27 @@ type LightNodeReader interface { GetRepoNodesLight(repoPrefix string) []*Node } +// NodeLightScanner is an optional reader capability for whole-graph algorithms +// that need only the Node struct's identity and location fields. Implementations +// must not materialize Meta, including promoted metadata such as docs and +// signatures. Callers must treat returned nodes as read-only snapshots and must +// not depend on Meta being present. A backend that persists federation proxy +// nodes must preserve Origin, Stub, and FetchedAt so centrality filters remain +// sound (SQLite never persists proxy nodes). +type NodeLightScanner interface { + AllNodesLight() []*Node +} + +// AllNodesLight uses the metadata-free summary projection when the reader +// supports it. In-memory and overlay readers fall back to AllNodes because their +// nodes are already materialized and no disk blob decoding is involved. +func AllNodesLight(s Reader) []*Node { + if scanner, ok := s.(NodeLightScanner); ok { + return scanner.AllNodesLight() + } + return s.AllNodes() +} + // LightEdgeScanner is an optional store capability: a kind-scoped edge scan // that skips decoding each row's opaque meta blob. AllEdgesLight returns the // edges whose Kind is in kinds (an empty kinds list means every kind), Meta diff --git a/internal/graph/store_sqlite/all_nodes_light_allocation_test.go b/internal/graph/store_sqlite/all_nodes_light_allocation_test.go new file mode 100644 index 000000000..9c0a68da5 --- /dev/null +++ b/internal/graph/store_sqlite/all_nodes_light_allocation_test.go @@ -0,0 +1,75 @@ +package store_sqlite + +import ( + "database/sql" + "testing" + + "github.com/zzet/gortex/internal/graph" +) + +type nodeSummaryAllocationScanner struct { + promoted bool +} + +func (s nodeSummaryAllocationScanner) Scan(dest ...any) error { + *dest[0].(*string) = "repo/pkg/a.go::A" + *dest[1].(*graph.NodeKind) = graph.KindFunction + *dest[2].(*string) = "A" + *dest[3].(*string) = "repo.pkg.A" + *dest[4].(*string) = "pkg/a.go" + *dest[5].(*int) = 11 + *dest[6].(*int) = 19 + *dest[7].(*int) = 2 + *dest[8].(*int) = 8 + *dest[9].(*string) = "go" + *dest[10].(*string) = "repo" + *dest[11].(*string) = "workspace" + *dest[12].(*string) = "project" + if s.promoted { + *dest[13].(*sql.NullString) = sql.NullString{String: "func A()", Valid: true} + *dest[15].(*sql.NullString) = sql.NullString{String: "large promoted documentation", Valid: true} + } + return nil +} + +var nodeSummaryAllocationSink *graph.Node + +func TestScanNodeSummaryOmitsMetaAndAllocatesLess(t *testing.T) { + n, err := scanNodeSummary(nodeSummaryAllocationScanner{}) + if err != nil { + t.Fatal(err) + } + if n.ID != "repo/pkg/a.go::A" || n.Kind != graph.KindFunction || n.Name != "A" || + n.QualName != "repo.pkg.A" || n.FilePath != "pkg/a.go" || n.StartLine != 11 || + n.EndLine != 19 || n.StartColumn != 2 || n.EndColumn != 8 || n.Language != "go" || + n.RepoPrefix != "repo" || n.WorkspaceID != "workspace" || n.ProjectID != "project" { + t.Fatalf("summary scan altered identity/location fields: %#v", n) + } + if n.Meta != nil { + t.Fatalf("summary scan materialized Meta: %#v", n.Meta) + } + + summaryAllocs := testing.AllocsPerRun(1000, func() { + nodeSummaryAllocationSink, _ = scanNodeSummary(nodeSummaryAllocationScanner{}) + }) + promotedAllocs := testing.AllocsPerRun(1000, func() { + nodeSummaryAllocationSink, _ = scanNodeLight(nodeSummaryAllocationScanner{promoted: true}) + }) + if summaryAllocs >= promotedAllocs { + t.Fatalf("summary scan allocations = %.1f, promoted scan = %.1f; want summary lower", summaryAllocs, promotedAllocs) + } +} + +func BenchmarkScanNodeSummary(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + nodeSummaryAllocationSink, _ = scanNodeSummary(nodeSummaryAllocationScanner{}) + } +} + +func BenchmarkScanNodeLightPromoted(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + nodeSummaryAllocationSink, _ = scanNodeLight(nodeSummaryAllocationScanner{promoted: true}) + } +} diff --git a/internal/graph/store_sqlite/analysis_generation_gc.go b/internal/graph/store_sqlite/analysis_generation_gc.go new file mode 100644 index 000000000..f180f6acd --- /dev/null +++ b/internal/graph/store_sqlite/analysis_generation_gc.go @@ -0,0 +1,185 @@ +package store_sqlite + +import ( + "context" + "database/sql" + "fmt" +) + +const analysisGenerationGCDefaultBatch = 1000 + +type analysisGenerationGCTable struct { + name string + delete string +} + +// Child-first order is intentional. The final generation DELETE must never +// trigger a large cascade while holding writeMu; by then every child table is +// proven empty. +var analysisGenerationGCTables = [...]analysisGenerationGCTable{ + {name: "process_steps", delete: `DELETE FROM analysis_process_steps WHERE generation_id = ? AND (process_id, ordinal) IN (SELECT process_id, ordinal FROM analysis_process_steps WHERE generation_id = ? LIMIT ?)`}, + {name: "process_files", delete: `DELETE FROM analysis_process_files WHERE generation_id = ? AND (process_id, ordinal) IN (SELECT process_id, ordinal FROM analysis_process_files WHERE generation_id = ? LIMIT ?)`}, + {name: "processes", delete: `DELETE FROM analysis_processes WHERE generation_id = ? AND process_id IN (SELECT process_id FROM analysis_processes WHERE generation_id = ? LIMIT ?)`}, + {name: "concept_relations", delete: `DELETE FROM analysis_concept_relations WHERE generation_id = ? AND (token, rank, related_token) IN (SELECT token, rank, related_token FROM analysis_concept_relations WHERE generation_id = ? LIMIT ?)`}, + {name: "concepts", delete: `DELETE FROM analysis_concepts WHERE generation_id = ? AND token IN (SELECT token FROM analysis_concepts WHERE generation_id = ? LIMIT ?)`}, + {name: "community_files", delete: `DELETE FROM analysis_community_files WHERE generation_id = ? AND (community_id, ordinal) IN (SELECT community_id, ordinal FROM analysis_community_files WHERE generation_id = ? LIMIT ?)`}, + {name: "nodes", delete: `DELETE FROM analysis_nodes WHERE id IN (SELECT id FROM analysis_nodes WHERE generation_id = ? LIMIT ?)`}, + {name: "communities", delete: `DELETE FROM analysis_communities WHERE generation_id = ? AND community_id IN (SELECT community_id FROM analysis_communities WHERE generation_id = ? LIMIT ?)`}, + {name: "blobs", delete: `DELETE FROM analysis_blobs WHERE generation_id = ? AND component IN (SELECT component FROM analysis_blobs WHERE generation_id = ? LIMIT ?)`}, + {name: "component_seals", delete: `DELETE FROM analysis_generation_components WHERE generation_id = ? AND component IN (SELECT component FROM analysis_generation_components WHERE generation_id = ? LIMIT ?)`}, +} + +func (s *Store) PruneAnalysisGenerations(ctx context.Context, keep, batch int) error { + if ctx == nil { + return fmt.Errorf("analysis generation gc: nil context") + } + if keep == 0 { + keep = 1 + } + if keep < 1 { + return fmt.Errorf("analysis generation gc: keep must be at least 1") + } + if batch == 0 { + batch = analysisGenerationGCDefaultBatch + } + if batch < 1 || batch > analysisGenerationChunkLimit { + return fmt.Errorf("analysis generation gc: batch %d outside 1..%d", batch, analysisGenerationChunkLimit) + } + + // Materialize candidates before acquiring writeMu. Building generations + // are never collected, and each chunk rechecks that its candidate is still + // non-active and non-building. + rows, err := s.db.QueryContext(ctx, ` + SELECT generation_id FROM analysis_generations + WHERE state != ? AND generation_id NOT IN ( + SELECT generation_id FROM analysis_active_generation + ) + ORDER BY generation_id DESC LIMIT -1 OFFSET ?`, analysisGenerationBuilding, keep) + if err != nil { + return err + } + var candidates []int64 + for rows.Next() { + var generationID int64 + if err := rows.Scan(&generationID); err != nil { + rows.Close() + return err + } + candidates = append(candidates, generationID) + } + if err := rows.Close(); err != nil { + return err + } + + for _, generationID := range candidates { + for _, table := range analysisGenerationGCTables { + for { + if err := ctx.Err(); err != nil { + return err + } + removed, eligible, err := s.pruneAnalysisGenerationChunk(ctx, generationID, table, batch) + if err != nil { + return fmt.Errorf("analysis generation gc: generation %d %s: %w", generationID, table.name, err) + } + if !eligible { + break + } + if removed == 0 { + break + } + } + } + if err := s.finishPruneAnalysisGeneration(ctx, generationID); err != nil { + return err + } + } + return nil +} + +func (s *Store) pruneAnalysisGenerationChunk(ctx context.Context, generationID int64, table analysisGenerationGCTable, batch int) (int64, bool, error) { + s.writeMu.Lock() + defer s.writeMu.Unlock() + tx, err := s.beginWrite() + if err != nil { + return 0, false, err + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback() + } + }() + eligible, err := analysisGenerationPrunableTx(tx, generationID) + if err != nil || !eligible { + return 0, eligible, err + } + var result sql.Result + if table.name == "nodes" { + result, err = tx.ExecContext(ctx, table.delete, generationID, batch) + } else { + result, err = tx.ExecContext(ctx, table.delete, generationID, generationID, batch) + } + if err != nil { + return 0, false, err + } + removed, err := result.RowsAffected() + if err != nil { + return 0, false, err + } + if err := tx.Commit(); err != nil { + return 0, false, err + } + committed = true + return removed, true, nil +} + +func analysisGenerationPrunableTx(tx *sql.Tx, generationID int64) (bool, error) { + var count int + err := tx.QueryRow(` + SELECT COUNT(*) FROM analysis_generations g + WHERE g.generation_id = ? AND g.state != ? AND NOT EXISTS ( + SELECT 1 FROM analysis_active_generation a WHERE a.generation_id = g.generation_id + )`, generationID, analysisGenerationBuilding).Scan(&count) + return count == 1, err +} + +func (s *Store) finishPruneAnalysisGeneration(ctx context.Context, generationID int64) error { + s.writeMu.Lock() + defer s.writeMu.Unlock() + tx, err := s.beginWrite() + if err != nil { + return err + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback() + } + }() + eligible, err := analysisGenerationPrunableTx(tx, generationID) + if err != nil || !eligible { + return err + } + for _, table := range []string{ + "analysis_process_steps", "analysis_process_files", "analysis_processes", + "analysis_concept_relations", "analysis_concepts", + "analysis_community_files", "analysis_nodes", "analysis_communities", + "analysis_blobs", "analysis_generation_components", + } { + var count int + if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM `+table+` WHERE generation_id = ?`, generationID).Scan(&count); err != nil { + return err + } + if count != 0 { + return fmt.Errorf("analysis generation gc: generation %d still has %d rows in %s", generationID, count, table) + } + } + if _, err := tx.ExecContext(ctx, `DELETE FROM analysis_generations WHERE generation_id = ?`, generationID); err != nil { + return err + } + if err := tx.Commit(); err != nil { + return err + } + committed = true + return nil +} diff --git a/internal/graph/store_sqlite/analysis_generation_read.go b/internal/graph/store_sqlite/analysis_generation_read.go new file mode 100644 index 000000000..519721650 --- /dev/null +++ b/internal/graph/store_sqlite/analysis_generation_read.go @@ -0,0 +1,638 @@ +package store_sqlite + +import ( + "database/sql" + "fmt" + "strings" + + "github.com/zzet/gortex/internal/graph" +) + +const ( + analysisQueryDefaultLimit = 100 + analysisQueryMaxLimit = 1000 +) + +func boundedAnalysisLimit(limit int) (int, error) { + if limit == 0 { + return analysisQueryDefaultLimit, nil + } + if limit < 0 || limit > analysisQueryMaxLimit { + return 0, fmt.Errorf("analysis generation: limit %d outside 1..%d", limit, analysisQueryMaxLimit) + } + return limit, nil +} + +func analysisPlaceholders(count int) string { + return strings.TrimSuffix(strings.Repeat("?,", count), ",") +} + +func (s *Store) LoadActiveAnalysisHeader(formatVersion uint32) (graph.AnalysisGenerationHeader, bool, error) { + s.writeMu.Lock() + defer s.writeMu.Unlock() + tx, err := s.beginWrite() + if err != nil { + return graph.AnalysisGenerationHeader{}, false, err + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback() + } + }() + var generationID int64 + if err := tx.QueryRow(`SELECT generation_id FROM analysis_active_generation WHERE slot = 1`).Scan(&generationID); err != nil { + if err == sql.ErrNoRows { + return graph.AnalysisGenerationHeader{}, false, nil + } + return graph.AnalysisGenerationHeader{}, false, err + } + header, state, err := loadAnalysisGenerationHeaderTx(tx, generationID) + if err == nil && state == analysisGenerationReady && header.FormatVersion == formatVersion { + header, err = validateAnalysisGenerationTx(tx, generationID) + } + if err != nil || state != analysisGenerationReady { + reason := err + if reason == nil { + reason = fmt.Errorf("state is %d, want ready", state) + } + if _, clearErr := tx.Exec(`DELETE FROM analysis_active_generation WHERE slot = 1 AND generation_id = ?`, generationID); clearErr != nil { + return graph.AnalysisGenerationHeader{}, false, clearErr + } + if _, staleErr := tx.Exec(`UPDATE analysis_generations SET state = ? WHERE generation_id = ?`, analysisGenerationStale, generationID); staleErr != nil { + return graph.AnalysisGenerationHeader{}, false, staleErr + } + if commitErr := tx.Commit(); commitErr != nil { + return graph.AnalysisGenerationHeader{}, false, commitErr + } + committed = true + s.analysisGenerationPresent = false + return graph.AnalysisGenerationHeader{}, false, fmt.Errorf("%w: generation %d: %v", graph.ErrAnalysisGenerationCorrupt, generationID, reason) + } + if header.FormatVersion != formatVersion { + return graph.AnalysisGenerationHeader{}, false, nil + } + if err := tx.Commit(); err != nil { + return graph.AnalysisGenerationHeader{}, false, err + } + committed = true + // A persisted build revision belongs to the previous process after reopen. + // Return the current process revision as the publication receipt instead. + header.GraphRevision = s.analysisMutationRevision.Load() + return header, true, nil +} + +func (s *Store) ensureAnalysisGenerationReadableLocked(generationID int64) error { + var one int + err := s.db.QueryRow(` + SELECT 1 FROM analysis_active_generation a + JOIN analysis_generations g ON g.generation_id = a.generation_id + WHERE a.slot = 1 AND a.generation_id = ? AND g.state = ?`, generationID, analysisGenerationReady).Scan(&one) + if err == sql.ErrNoRows { + return graph.ErrAnalysisGenerationInactive + } + return err +} + +func scanAnalysisNode(scanner interface{ Scan(...any) error }) (graph.AnalysisNodeMetric, error) { + var node graph.AnalysisNodeMetric + var community sql.NullString + err := scanner.Scan(&node.RowID, &node.NodeID, &community, &node.PageRank, &node.Authority, &node.Hub) + if community.Valid { + node.CommunityID = community.String + } + return node, err +} + +func (s *Store) AnalysisNodeMetrics(generationID int64, nodeIDs []string) ([]graph.AnalysisNodeMetric, error) { + ids, err := sortedUniqueAnalysisStrings(nodeIDs, "node id") + if err != nil || len(ids) == 0 { + return nil, err + } + s.writeMu.Lock() + defer s.writeMu.Unlock() + if err := s.ensureAnalysisGenerationReadableLocked(generationID); err != nil { + return nil, err + } + args := make([]any, 0, len(ids)+1) + args = append(args, generationID) + for _, id := range ids { + args = append(args, id) + } + rows, err := s.db.Query(` + SELECT id, node_id, community_id, pagerank, authority, hub + FROM analysis_nodes + WHERE generation_id = ? AND node_id IN (`+analysisPlaceholders(len(ids))+`) + ORDER BY node_id`, args...) + if err != nil { + return nil, err + } + defer rows.Close() + result := make([]graph.AnalysisNodeMetric, 0, len(ids)) + for rows.Next() { + node, err := scanAnalysisNode(rows) + if err != nil { + return nil, err + } + result = append(result, node) + } + return result, rows.Err() +} + +func (s *Store) ListAnalysisNodeMetrics(generationID int64, limit int, cursorNodeID string) ([]graph.AnalysisNodeMetric, string, error) { + limit, err := boundedAnalysisLimit(limit) + if err != nil { + return nil, "", err + } + s.writeMu.Lock() + defer s.writeMu.Unlock() + if err := s.ensureAnalysisGenerationReadableLocked(generationID); err != nil { + return nil, "", err + } + rows, err := s.db.Query(` + SELECT id, node_id, community_id, pagerank, authority, hub + FROM analysis_nodes WHERE generation_id = ? AND node_id > ? + ORDER BY node_id LIMIT ?`, generationID, cursorNodeID, limit+1) + if err != nil { + return nil, "", err + } + defer rows.Close() + items := make([]graph.AnalysisNodeMetric, 0, limit+1) + for rows.Next() { + node, err := scanAnalysisNode(rows) + if err != nil { + return nil, "", err + } + items = append(items, node) + } + if err := rows.Err(); err != nil { + return nil, "", err + } + next := "" + if len(items) > limit { + items = items[:limit] + next = items[len(items)-1].NodeID + } + return items, next, nil +} + +func analysisMetricColumn(metric graph.AnalysisMetric) (string, error) { + switch metric { + case graph.AnalysisMetricPageRank: + return "pagerank", nil + case graph.AnalysisMetricAuthority: + return "authority", nil + case graph.AnalysisMetricHub: + return "hub", nil + default: + return "", fmt.Errorf("analysis generation: unsupported metric %q", metric) + } +} + +func analysisMetricValue(node graph.AnalysisNodeMetric, metric graph.AnalysisMetric) float64 { + switch metric { + case graph.AnalysisMetricPageRank: + return node.PageRank + case graph.AnalysisMetricAuthority: + return node.Authority + default: + return node.Hub + } +} + +func (s *Store) TopAnalysisNodeMetrics(generationID int64, metric graph.AnalysisMetric, limit int, cursor *graph.AnalysisMetricCursor) ([]graph.AnalysisNodeMetric, *graph.AnalysisMetricCursor, error) { + column, err := analysisMetricColumn(metric) + if err != nil { + return nil, nil, err + } + limit, err = boundedAnalysisLimit(limit) + if err != nil { + return nil, nil, err + } + if cursor != nil && (!validAnalysisFloat(cursor.Score) || cursor.RowID <= 0) { + return nil, nil, fmt.Errorf("analysis generation: invalid metric cursor") + } + s.writeMu.Lock() + defer s.writeMu.Unlock() + if err := s.ensureAnalysisGenerationReadableLocked(generationID); err != nil { + return nil, nil, err + } + query := `SELECT id, node_id, community_id, pagerank, authority, hub FROM analysis_nodes WHERE generation_id = ?` + args := []any{generationID} + if cursor != nil { + query += ` AND (` + column + ` < ? OR (` + column + ` = ? AND id > ?))` + args = append(args, cursor.Score, cursor.Score, cursor.RowID) + } + query += ` ORDER BY ` + column + ` DESC, id ASC LIMIT ?` + args = append(args, limit+1) + rows, err := s.db.Query(query, args...) + if err != nil { + return nil, nil, err + } + defer rows.Close() + items := make([]graph.AnalysisNodeMetric, 0, limit+1) + for rows.Next() { + node, err := scanAnalysisNode(rows) + if err != nil { + return nil, nil, err + } + items = append(items, node) + } + if err := rows.Err(); err != nil { + return nil, nil, err + } + var next *graph.AnalysisMetricCursor + if len(items) > limit { + items = items[:limit] + last := items[len(items)-1] + next = &graph.AnalysisMetricCursor{Score: analysisMetricValue(last, metric), RowID: last.RowID} + } + return items, next, nil +} + +func (s *Store) ListAnalysisCommunitySummaries(generationID int64, limit int, cursorID string) ([]graph.AnalysisCommunitySummary, string, error) { + limit, err := boundedAnalysisLimit(limit) + if err != nil { + return nil, "", err + } + s.writeMu.Lock() + defer s.writeMu.Unlock() + if err := s.ensureAnalysisGenerationReadableLocked(generationID); err != nil { + return nil, "", err + } + rows, err := s.db.Query(` + SELECT community_id, label, hub, parent_id, size, cohesion + FROM analysis_communities WHERE generation_id = ? AND community_id > ? + ORDER BY community_id LIMIT ?`, generationID, cursorID, limit+1) + if err != nil { + return nil, "", err + } + items := make([]graph.AnalysisCommunitySummary, 0, limit+1) + for rows.Next() { + var item graph.AnalysisCommunitySummary + if err := rows.Scan(&item.ID, &item.Label, &item.Hub, &item.ParentID, &item.Size, &item.Cohesion); err != nil { + rows.Close() + return nil, "", err + } + items = append(items, item) + } + if err := rows.Close(); err != nil { + return nil, "", err + } + next := "" + if len(items) > limit { + items = items[:limit] + next = items[len(items)-1].ID + } + if err := s.loadAnalysisCommunityFilesLocked(generationID, items); err != nil { + return nil, "", err + } + return items, next, nil +} + +func (s *Store) loadAnalysisCommunityFilesLocked(generationID int64, items []graph.AnalysisCommunitySummary) error { + if len(items) == 0 { + return nil + } + args := make([]any, 0, len(items)+1) + args = append(args, generationID) + positions := make(map[string]int, len(items)) + for i := range items { + args = append(args, items[i].ID) + positions[items[i].ID] = i + } + rows, err := s.db.Query(` + SELECT community_id, file_path FROM analysis_community_files + WHERE generation_id = ? AND community_id IN (`+analysisPlaceholders(len(items))+`) + ORDER BY community_id, ordinal`, args...) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var id, file string + if err := rows.Scan(&id, &file); err != nil { + return err + } + items[positions[id]].Files = append(items[positions[id]].Files, file) + } + return rows.Err() +} + +func (s *Store) AnalysisCommunityMembers(generationID int64, communityID string, limit int, cursorNodeID string) ([]graph.AnalysisNodeMetric, string, error) { + if communityID == "" { + return nil, "", fmt.Errorf("analysis generation: empty community id") + } + limit, err := boundedAnalysisLimit(limit) + if err != nil { + return nil, "", err + } + s.writeMu.Lock() + defer s.writeMu.Unlock() + if err := s.ensureAnalysisGenerationReadableLocked(generationID); err != nil { + return nil, "", err + } + rows, err := s.db.Query(` + SELECT id, node_id, community_id, pagerank, authority, hub + FROM analysis_nodes + WHERE generation_id = ? AND community_id = ? AND node_id > ? + ORDER BY node_id LIMIT ?`, generationID, communityID, cursorNodeID, limit+1) + if err != nil { + return nil, "", err + } + defer rows.Close() + items := make([]graph.AnalysisNodeMetric, 0, limit+1) + for rows.Next() { + node, err := scanAnalysisNode(rows) + if err != nil { + return nil, "", err + } + items = append(items, node) + } + if err := rows.Err(); err != nil { + return nil, "", err + } + next := "" + if len(items) > limit { + items = items[:limit] + next = items[len(items)-1].NodeID + } + return items, next, nil +} + +func (s *Store) ListAnalysisProcessSummaries(generationID int64, limit int, cursorID string) ([]graph.AnalysisProcessSummary, string, error) { + limit, err := boundedAnalysisLimit(limit) + if err != nil { + return nil, "", err + } + s.writeMu.Lock() + defer s.writeMu.Unlock() + if err := s.ensureAnalysisGenerationReadableLocked(generationID); err != nil { + return nil, "", err + } + rows, err := s.db.Query(` + SELECT process_id, name, entry_point, step_count, score, truncated + FROM analysis_processes WHERE generation_id = ? AND process_id > ? + ORDER BY process_id LIMIT ?`, generationID, cursorID, limit+1) + if err != nil { + return nil, "", err + } + items := make([]graph.AnalysisProcessSummary, 0, limit+1) + for rows.Next() { + var item graph.AnalysisProcessSummary + var truncated int + if err := rows.Scan(&item.ID, &item.Name, &item.EntryPoint, &item.StepCount, &item.Score, &truncated); err != nil { + rows.Close() + return nil, "", err + } + item.Truncated = truncated != 0 + items = append(items, item) + } + if err := rows.Close(); err != nil { + return nil, "", err + } + next := "" + if len(items) > limit { + items = items[:limit] + next = items[len(items)-1].ID + } + if err := s.loadAnalysisProcessFilesLocked(generationID, items); err != nil { + return nil, "", err + } + return items, next, nil +} + +func (s *Store) loadAnalysisProcessFilesLocked(generationID int64, items []graph.AnalysisProcessSummary) error { + if len(items) == 0 { + return nil + } + args := make([]any, 0, len(items)+1) + args = append(args, generationID) + positions := make(map[string]int, len(items)) + for i := range items { + args = append(args, items[i].ID) + positions[items[i].ID] = i + } + rows, err := s.db.Query(` + SELECT process_id, file_path FROM analysis_process_files + WHERE generation_id = ? AND process_id IN (`+analysisPlaceholders(len(items))+`) + ORDER BY process_id, ordinal`, args...) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var id, file string + if err := rows.Scan(&id, &file); err != nil { + return err + } + items[positions[id]].Files = append(items[positions[id]].Files, file) + } + return rows.Err() +} + +// cursorOrdinal is exclusive. Pass -1 for the first page; the returned cursor +// is the final ordinal in the page and can be fed directly into the next call. +func (s *Store) AnalysisProcessSteps(generationID int64, processID string, limit int, cursorOrdinal int) ([]graph.AnalysisProcessStep, int, error) { + if processID == "" || cursorOrdinal < -1 { + return nil, cursorOrdinal, fmt.Errorf("analysis generation: invalid process step cursor") + } + limit, err := boundedAnalysisLimit(limit) + if err != nil { + return nil, cursorOrdinal, err + } + s.writeMu.Lock() + defer s.writeMu.Unlock() + if err := s.ensureAnalysisGenerationReadableLocked(generationID); err != nil { + return nil, cursorOrdinal, err + } + rows, err := s.db.Query(` + SELECT s.process_id, n.node_id, s.ordinal, s.depth + FROM analysis_process_steps s JOIN analysis_nodes n ON n.id = s.node_rowid + WHERE s.generation_id = ? AND s.process_id = ? AND s.ordinal > ? + ORDER BY s.ordinal LIMIT ?`, generationID, processID, cursorOrdinal, limit+1) + if err != nil { + return nil, cursorOrdinal, err + } + defer rows.Close() + items := make([]graph.AnalysisProcessStep, 0, limit+1) + for rows.Next() { + var item graph.AnalysisProcessStep + if err := rows.Scan(&item.ProcessID, &item.NodeID, &item.Ordinal, &item.Depth); err != nil { + return nil, cursorOrdinal, err + } + items = append(items, item) + } + if err := rows.Err(); err != nil { + return nil, cursorOrdinal, err + } + next := -1 + if len(items) > limit { + items = items[:limit] + next = items[len(items)-1].Ordinal + } + return items, next, nil +} + +func (s *Store) AnalysisProcessesForNodes(generationID int64, nodeIDs []string) ([]graph.AnalysisProcessMembership, error) { + ids, err := sortedUniqueAnalysisStrings(nodeIDs, "node id") + if err != nil || len(ids) == 0 { + return nil, err + } + s.writeMu.Lock() + defer s.writeMu.Unlock() + if err := s.ensureAnalysisGenerationReadableLocked(generationID); err != nil { + return nil, err + } + args := make([]any, 0, len(ids)+1) + args = append(args, generationID) + for _, id := range ids { + args = append(args, id) + } + rows, err := s.db.Query(` + SELECT DISTINCT n.node_id, s.process_id + FROM analysis_process_steps s JOIN analysis_nodes n ON n.id = s.node_rowid + WHERE s.generation_id = ? AND n.node_id IN (`+analysisPlaceholders(len(ids))+`) + ORDER BY n.node_id, s.process_id`, args...) + if err != nil { + return nil, err + } + defer rows.Close() + items := make([]graph.AnalysisProcessMembership, 0) + for rows.Next() { + var item graph.AnalysisProcessMembership + if err := rows.Scan(&item.NodeID, &item.ProcessID); err != nil { + return nil, err + } + items = append(items, item) + } + return items, rows.Err() +} + +func validAnalysisConceptDirection(direction graph.AnalysisConceptDirection) bool { + return direction == graph.AnalysisConceptForward || direction == graph.AnalysisConceptReverse +} + +func (s *Store) AnalysisConcepts(generationID int64, tokens []string, direction graph.AnalysisConceptDirection) (graph.AnalysisConceptQueryResult, error) { + if !validAnalysisConceptDirection(direction) { + return graph.AnalysisConceptQueryResult{}, fmt.Errorf("analysis generation: unsupported concept direction %q", direction) + } + ordered, err := sortedUniqueAnalysisStrings(tokens, "concept token") + if err != nil || len(ordered) == 0 { + return graph.AnalysisConceptQueryResult{}, err + } + s.writeMu.Lock() + defer s.writeMu.Unlock() + if err := s.ensureAnalysisGenerationReadableLocked(generationID); err != nil { + return graph.AnalysisConceptQueryResult{}, err + } + return s.analysisConceptsLocked(generationID, ordered, direction) +} + +func (s *Store) analysisConceptsLocked(generationID int64, tokens []string, direction graph.AnalysisConceptDirection) (graph.AnalysisConceptQueryResult, error) { + result := graph.AnalysisConceptQueryResult{Concepts: make([]graph.AnalysisConcept, len(tokens))} + positions := make(map[string]int, len(tokens)) + args := make([]any, 0, len(tokens)+1) + args = append(args, generationID) + for i, token := range tokens { + result.Concepts[i].Token = token + positions[token] = i + args = append(args, token) + } + rows, err := s.db.Query(`SELECT token, in_vocabulary FROM analysis_concepts WHERE generation_id = ? AND token IN (`+analysisPlaceholders(len(tokens))+`)`, args...) + if err != nil { + return graph.AnalysisConceptQueryResult{}, err + } + for rows.Next() { + var token string + var vocabulary int + if err := rows.Scan(&token, &vocabulary); err != nil { + rows.Close() + return graph.AnalysisConceptQueryResult{}, err + } + result.Concepts[positions[token]].InVocabulary = vocabulary != 0 + } + if err := rows.Close(); err != nil { + return graph.AnalysisConceptQueryResult{}, err + } + column := "token" + if direction == graph.AnalysisConceptReverse { + column = "related_token" + } + relationRows, err := s.db.Query(` + SELECT token, related_token, rank FROM analysis_concept_relations + WHERE generation_id = ? AND `+column+` IN (`+analysisPlaceholders(len(tokens))+`) + ORDER BY token, rank, related_token`, args...) + if err != nil { + return graph.AnalysisConceptQueryResult{}, err + } + defer relationRows.Close() + for relationRows.Next() { + var relation graph.AnalysisConceptRelation + if err := relationRows.Scan(&relation.Token, &relation.RelatedToken, &relation.Rank); err != nil { + return graph.AnalysisConceptQueryResult{}, err + } + result.Relations = append(result.Relations, relation) + } + return result, relationRows.Err() +} + +func (s *Store) ListAnalysisConcepts(generationID int64, limit int, cursorToken string) (graph.AnalysisConceptQueryResult, string, error) { + limit, err := boundedAnalysisLimit(limit) + if err != nil { + return graph.AnalysisConceptQueryResult{}, "", err + } + s.writeMu.Lock() + defer s.writeMu.Unlock() + if err := s.ensureAnalysisGenerationReadableLocked(generationID); err != nil { + return graph.AnalysisConceptQueryResult{}, "", err + } + rows, err := s.db.Query(` + SELECT token FROM analysis_concepts WHERE generation_id = ? AND token > ? + ORDER BY token LIMIT ?`, generationID, cursorToken, limit+1) + if err != nil { + return graph.AnalysisConceptQueryResult{}, "", err + } + tokens := make([]string, 0, limit+1) + for rows.Next() { + var token string + if err := rows.Scan(&token); err != nil { + rows.Close() + return graph.AnalysisConceptQueryResult{}, "", err + } + tokens = append(tokens, token) + } + if err := rows.Close(); err != nil { + return graph.AnalysisConceptQueryResult{}, "", err + } + next := "" + if len(tokens) > limit { + tokens = tokens[:limit] + next = tokens[len(tokens)-1] + } + if len(tokens) == 0 { + return graph.AnalysisConceptQueryResult{}, next, nil + } + result, err := s.analysisConceptsLocked(generationID, tokens, graph.AnalysisConceptForward) + return result, next, err +} + +func (s *Store) LoadAnalysisBlob(generationID int64, component graph.AnalysisBlobComponent) ([]byte, bool, error) { + if !validAnalysisBlobComponent(component) { + return nil, false, fmt.Errorf("analysis generation: unsupported blob component %q", component) + } + s.writeMu.Lock() + defer s.writeMu.Unlock() + if err := s.ensureAnalysisGenerationReadableLocked(generationID); err != nil { + return nil, false, err + } + var payload []byte + err := s.db.QueryRow(`SELECT payload FROM analysis_blobs WHERE generation_id = ? AND component = ?`, generationID, string(component)).Scan(&payload) + if err == sql.ErrNoRows { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + return payload, true, nil +} diff --git a/internal/graph/store_sqlite/analysis_generation_state.go b/internal/graph/store_sqlite/analysis_generation_state.go new file mode 100644 index 000000000..aa23e8d04 --- /dev/null +++ b/internal/graph/store_sqlite/analysis_generation_state.go @@ -0,0 +1,150 @@ +package store_sqlite + +import ( + "database/sql" + "errors" + + "github.com/zzet/gortex/internal/graph" +) + +// AnalysisMutationRevision is a process-local graph mutation clock. Durable +// restart correctness comes from clearing the active generation pointer before +// a committed graph mutation can become visible. +func (s *Store) AnalysisMutationRevision() uint64 { + return s.analysisMutationRevision.Load() +} + +// initAnalysisGenerationState makes interrupted builders collectible and +// initializes the mutation hot-path latch from the active singleton. +func (s *Store) initAnalysisGenerationState() error { + if _, err := s.db.Exec(`UPDATE analysis_generations SET state = ? WHERE state = ?`, analysisGenerationStale, analysisGenerationBuilding); err != nil { + return err + } + var present int + if err := s.db.QueryRow(`SELECT EXISTS(SELECT 1 FROM analysis_active_generation LIMIT 1)`).Scan(&present); err != nil { + return err + } + s.analysisGenerationPresent = present != 0 + return nil +} + +// CommitAnalysisSnapshot closes the revision-check-to-install race by holding +// the graph mutation gate across both operations. install must only publish +// in-memory pointers/tokens and must not re-enter graph mutation methods. +func (s *Store) CommitAnalysisSnapshot(expectedRevision uint64, install func()) bool { + if install == nil { + return false + } + s.writeMu.Lock() + defer s.writeMu.Unlock() + if s.analysisMutationRevision.Load() != expectedRevision { + return false + } + install() + return true +} + +// invalidateAnalysisGenerationLocked commits durable invalidation before its +// caller mutates nodes or edges. Building generations are made collectible; +// the active singleton is cleared and its generation marked stale. A crash can +// therefore only lose an optimization, never resurrect stale analysis. +// writeMu must be held. +func (s *Store) invalidateAnalysisGenerationLocked() error { + if !s.analysisGenerationPresent { + return nil + } + tx, err := s.beginWrite() + if err != nil { + return err + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback() + } + }() + if _, err := tx.Exec(`UPDATE analysis_generations SET state = ? WHERE state = ? OR generation_id IN (SELECT generation_id FROM analysis_active_generation)`, analysisGenerationStale, analysisGenerationBuilding); err != nil { + return err + } + if _, err := tx.Exec(`DELETE FROM analysis_active_generation`); err != nil { + return err + } + if err := tx.Commit(); err != nil { + return err + } + committed = true + s.analysisGenerationPresent = false + return nil +} + +// finishAnalysisMutationLocked advances the in-process race detector only +// after a graph mutation committed. writeMu must be held. +func (s *Store) finishAnalysisMutationLocked(changed bool) { + if changed { + s.analysisMutationRevision.Add(1) + } +} + +// invalidateAnalysisBeforeNodeMutationLocked preserves a generation across a +// metadata-only AddNode (reachability stamps are stored in Meta) while still +// treating every identity/location field read by AllNodesLight as relevant. +// In particular line/column shifts invalidate: consumers surface locations and +// must never restore old coordinates after restart. writeMu must be held. +func (s *Store) invalidateAnalysisBeforeNodeMutationLocked(n *graph.Node) bool { + if !s.analysisGenerationPresent { + return true + } + var ( + kind, name, qualName, filePath, language string + repoPrefix, workspaceID, projectID string + startLine, endLine, startColumn, endColumn int + visibility sql.NullString + metaBlob []byte + ) + err := s.db.QueryRow(`SELECT kind, name, qual_name, file_path, start_line, end_line, start_column, end_column, language, repo_prefix, workspace_id, project_id, visibility, meta FROM nodes WHERE id = ?`, n.ID).Scan( + &kind, &name, &qualName, &filePath, + &startLine, &endLine, &startColumn, &endColumn, + &language, &repoPrefix, &workspaceID, &projectID, + &visibility, &metaBlob, + ) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + panicOnFatal(err) + return false + } + lightChanged := errors.Is(err, sql.ErrNoRows) || + kind != string(n.Kind) || name != n.Name || qualName != n.QualName || + filePath != n.FilePath || startLine != n.StartLine || endLine != n.EndLine || + startColumn != n.StartColumn || endColumn != n.EndColumn || + language != n.Language || repoPrefix != n.RepoPrefix || + workspaceID != n.WorkspaceID || projectID != n.ProjectID + processChanged := false + if !errors.Is(err, sql.ErrNoRows) { + storedMeta, decodeErr := decodeMeta(metaBlob) + if decodeErr != nil { + panicOnFatal(decodeErr) + return false + } + storedEntry, _ := storedMeta["entry_point"].(bool) + storedEntryKind, _ := storedMeta["entry_point_kind"].(string) + newEntry, _ := n.Meta["entry_point"].(bool) + newEntryKind, _ := n.Meta["entry_point_kind"].(string) + newVisibility, _ := n.Meta["visibility"].(string) + processChanged = visibility.String != newVisibility || storedEntry != newEntry || + (storedEntry && storedEntryKind != newEntryKind) + } + if !lightChanged && !processChanged { + return true + } + return s.invalidateAnalysisBeforeMutationLocked() +} + +// invalidateAnalysisBeforeMutationLocked is the common fail-closed gate for +// graph writes. If durable invalidation fails, callers must not apply the +// mutation: doing so could make stale analysis look valid after restart. +func (s *Store) invalidateAnalysisBeforeMutationLocked() bool { + if err := s.invalidateAnalysisGenerationLocked(); err != nil { + panicOnFatal(err) + return false + } + return true +} diff --git a/internal/graph/store_sqlite/analysis_generation_test.go b/internal/graph/store_sqlite/analysis_generation_test.go new file mode 100644 index 000000000..ac5faa4aa --- /dev/null +++ b/internal/graph/store_sqlite/analysis_generation_test.go @@ -0,0 +1,573 @@ +package store_sqlite + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "sync" + "testing" + "time" + + "github.com/zzet/gortex/internal/graph" +) + +func requireAnalysisAccepted(t *testing.T) func(bool, error) { + t.Helper() + return func(accepted bool, err error) { + t.Helper() + if err != nil { + t.Fatal(err) + } + if !accepted { + t.Fatal("analysis generation write unexpectedly rejected") + } + } +} + +func buildMinimalAnalysisGeneration(t *testing.T, store *Store, prefix string, conceptCount int, activate bool) int64 { + t.Helper() + revision := store.AnalysisMutationRevision() + header := graph.AnalysisGenerationHeader{ + FormatVersion: 77, + NodeCount: 1, + CommunityCount: 1, + ConceptCount: conceptCount, + PageRankMax: 1, + AuthorityMax: 1, + HubMax: 1, + Modularity: 0.5, + } + generationID, accepted, err := store.BeginAnalysisGeneration(revision, header) + requireAnalysisAccepted(t)(accepted, err) + requireAnalysisAccepted(t)(store.AppendAnalysisCommunities(revision, generationID, []graph.AnalysisCommunitySummary{{ID: prefix + "-community", Label: prefix, Size: 1}})) + requireAnalysisAccepted(t)(store.AppendAnalysisNodes(revision, generationID, []graph.AnalysisNodeMetric{{NodeID: prefix + "-node", CommunityID: prefix + "-community", PageRank: 1, Authority: 1, Hub: 1}})) + concepts := make([]graph.AnalysisConcept, conceptCount) + for i := range concepts { + concepts[i] = graph.AnalysisConcept{Token: fmt.Sprintf("%s-token-%05d", prefix, i), InVocabulary: i%2 == 0} + } + if conceptCount != 0 { + requireAnalysisAccepted(t)(store.AppendAnalysisConcepts(revision, generationID, concepts, nil)) + } + requireAnalysisAccepted(t)(store.PutAnalysisBlob(revision, generationID, graph.AnalysisBlob{Component: graph.AnalysisBlobAdjacency, Payload: []byte("adjacency-" + prefix)})) + requireAnalysisAccepted(t)(store.PutAnalysisBlob(revision, generationID, graph.AnalysisBlob{Component: graph.AnalysisBlobLeiden, Payload: []byte("leiden-" + prefix)})) + for component, rows := range map[graph.AnalysisComponent]int{ + graph.AnalysisComponentNodes: 1, + graph.AnalysisComponentCommunities: 1, + graph.AnalysisComponentProcesses: 0, + graph.AnalysisComponentConcepts: conceptCount, + graph.AnalysisComponentAdjacency: 1, + graph.AnalysisComponentLeiden: 1, + } { + requireAnalysisAccepted(t)(store.SealAnalysisComponent(revision, generationID, component, rows)) + } + if activate { + requireAnalysisAccepted(t)(store.ActivateAnalysisGeneration(revision, generationID)) + } + return generationID +} + +func TestAnalysisGenerationActivationAndBoundedQueries(t *testing.T) { + store, err := Open(filepathForAnalysisTest(t)) + if err != nil { + t.Fatal(err) + } + defer store.Close() + revision := store.AnalysisMutationRevision() + header := graph.AnalysisGenerationHeader{ + FormatVersion: 91, NodeCount: 3, CommunityCount: 2, ProcessCount: 1, ConceptCount: 3, + PageRankMax: 0.9, AuthorityMax: 0.8, HubMax: 0.7, Modularity: 0.4, + ProcessesTruncated: true, ProcessesTruncationReason: "fixture cap", + } + generationID, accepted, err := store.BeginAnalysisGeneration(revision, header) + requireAnalysisAccepted(t)(accepted, err) + requireAnalysisAccepted(t)(store.AppendAnalysisCommunities(revision, generationID, []graph.AnalysisCommunitySummary{ + {ID: "c2", Label: "second", Hub: "n3", Size: 1, Cohesion: 0.8, Files: []string{"z.go"}}, + {ID: "c1", Label: "first", Hub: "n2", Size: 2, Cohesion: 0.9, Files: []string{"a.go", "b.go"}}, + })) + requireAnalysisAccepted(t)(store.AppendAnalysisNodes(revision, generationID, []graph.AnalysisNodeMetric{ + {NodeID: "n3", CommunityID: "c2", PageRank: 0.5, Authority: 0.8, Hub: 0.2}, + {NodeID: "n1", CommunityID: "c1", PageRank: 0.2, Authority: 0.1, Hub: 0.7}, + {NodeID: "n2", CommunityID: "c1", PageRank: 0.9, Authority: 0.4, Hub: 0.3}, + })) + requireAnalysisAccepted(t)(store.AppendAnalysisProcesses(revision, generationID, []graph.AnalysisProcessSummary{{ + ID: "p1", Name: "request", EntryPoint: "n1", StepCount: 2, Score: 0.75, Truncated: true, Files: []string{"a.go", "z.go"}, + }}, nil)) + requireAnalysisAccepted(t)(store.AppendAnalysisProcesses(revision, generationID, nil, []graph.AnalysisProcessStep{ + {ProcessID: "p1", NodeID: "n1", Ordinal: 0, Depth: 0}, + {ProcessID: "p1", NodeID: "n3", Ordinal: 1, Depth: 1}, + })) + concepts := []graph.AnalysisConcept{{Token: "alpha", InVocabulary: true}, {Token: "beta", InVocabulary: true}, {Token: "gamma"}} + requireAnalysisAccepted(t)(store.AppendAnalysisConcepts(revision, generationID, concepts, nil)) + requireAnalysisAccepted(t)(store.AppendAnalysisConcepts(revision, generationID, nil, []graph.AnalysisConceptRelation{ + {Token: "alpha", RelatedToken: "beta", Rank: 0}, + {Token: "beta", RelatedToken: "gamma", Rank: 0}, + })) + requireAnalysisAccepted(t)(store.PutAnalysisBlob(revision, generationID, graph.AnalysisBlob{Component: graph.AnalysisBlobAdjacency, Payload: []byte("adj")})) + requireAnalysisAccepted(t)(store.PutAnalysisBlob(revision, generationID, graph.AnalysisBlob{Component: graph.AnalysisBlobLeiden, Payload: []byte("lei")})) + if accepted, err := store.ActivateAnalysisGeneration(revision, generationID); err == nil || accepted { + t.Fatalf("partial activation accepted=%v err=%v", accepted, err) + } + for component, rows := range map[graph.AnalysisComponent]int{ + graph.AnalysisComponentNodes: 3, graph.AnalysisComponentCommunities: 2, + graph.AnalysisComponentProcesses: 1, graph.AnalysisComponentConcepts: 3, + graph.AnalysisComponentAdjacency: 1, graph.AnalysisComponentLeiden: 1, + } { + requireAnalysisAccepted(t)(store.SealAnalysisComponent(revision, generationID, component, rows)) + } + if accepted, err := store.AppendAnalysisNodes(revision, generationID, nil); err == nil || accepted { + t.Fatalf("sealed component accepted append: accepted=%v err=%v", accepted, err) + } + requireAnalysisAccepted(t)(store.ActivateAnalysisGeneration(revision, generationID)) + + gotHeader, found, err := store.LoadActiveAnalysisHeader(91) + if err != nil || !found { + t.Fatalf("active header found=%v err=%v", found, err) + } + if gotHeader.GenerationID != generationID || gotHeader.GraphRevision != store.AnalysisMutationRevision() || !gotHeader.ProcessesTruncated { + t.Fatalf("active header = %+v", gotHeader) + } + metrics, err := store.AnalysisNodeMetrics(generationID, []string{"n3", "n1"}) + if err != nil || len(metrics) != 2 || metrics[0].NodeID != "n1" || metrics[1].NodeID != "n3" { + t.Fatalf("point metrics = %+v err=%v", metrics, err) + } + page, nextNode, err := store.ListAnalysisNodeMetrics(generationID, 2, "") + if err != nil || len(page) != 2 || page[0].NodeID != "n1" || page[1].NodeID != "n2" || nextNode != "n2" { + t.Fatalf("node page = %+v next=%q err=%v", page, nextNode, err) + } + page, nextNode, err = store.ListAnalysisNodeMetrics(generationID, 2, nextNode) + if err != nil || len(page) != 1 || page[0].NodeID != "n3" || nextNode != "" { + t.Fatalf("node tail = %+v next=%q err=%v", page, nextNode, err) + } + top, cursor, err := store.TopAnalysisNodeMetrics(generationID, graph.AnalysisMetricPageRank, 2, nil) + if err != nil || len(top) != 2 || top[0].NodeID != "n2" || top[1].NodeID != "n3" || cursor == nil { + t.Fatalf("top = %+v cursor=%+v err=%v", top, cursor, err) + } + top, tailCursor, err := store.TopAnalysisNodeMetrics(generationID, graph.AnalysisMetricPageRank, 2, cursor) + if err != nil || len(top) != 1 || top[0].NodeID != "n1" || tailCursor != nil { + t.Fatalf("top tail = %+v cursor=%+v err=%v", top, tailCursor, err) + } + communities, nextCommunity, err := store.ListAnalysisCommunitySummaries(generationID, 1, "") + if err != nil || len(communities) != 1 || communities[0].ID != "c1" || len(communities[0].Files) != 2 || nextCommunity != "c1" { + t.Fatalf("communities = %+v next=%q err=%v", communities, nextCommunity, err) + } + members, _, err := store.AnalysisCommunityMembers(generationID, "c1", 10, "") + if err != nil || len(members) != 2 || members[0].NodeID != "n1" || members[1].NodeID != "n2" { + t.Fatalf("members = %+v err=%v", members, err) + } + processes, _, err := store.ListAnalysisProcessSummaries(generationID, 10, "") + if err != nil || len(processes) != 1 || len(processes[0].Files) != 2 { + t.Fatalf("processes = %+v err=%v", processes, err) + } + steps, nextOrdinal, err := store.AnalysisProcessSteps(generationID, "p1", 1, -1) + if err != nil || len(steps) != 1 || steps[0].Ordinal != 0 || nextOrdinal != 0 { + t.Fatalf("steps = %+v next=%d err=%v", steps, nextOrdinal, err) + } + steps, nextOrdinal, err = store.AnalysisProcessSteps(generationID, "p1", 2, nextOrdinal) + if err != nil || len(steps) != 1 || steps[0].Ordinal != 1 || nextOrdinal != -1 { + t.Fatalf("step tail = %+v next=%d err=%v", steps, nextOrdinal, err) + } + memberships, err := store.AnalysisProcessesForNodes(generationID, []string{"n3", "n1"}) + if err != nil || len(memberships) != 2 || memberships[0].NodeID != "n1" || memberships[1].NodeID != "n3" { + t.Fatalf("memberships = %+v err=%v", memberships, err) + } + forward, err := store.AnalysisConcepts(generationID, []string{"beta", "alpha"}, graph.AnalysisConceptForward) + if err != nil || len(forward.Concepts) != 2 || len(forward.Relations) != 2 || forward.Relations[0].Token != "alpha" { + t.Fatalf("forward concepts = %+v err=%v", forward, err) + } + reverse, err := store.AnalysisConcepts(generationID, []string{"beta"}, graph.AnalysisConceptReverse) + if err != nil || len(reverse.Relations) != 1 || reverse.Relations[0].Token != "alpha" { + t.Fatalf("reverse concepts = %+v err=%v", reverse, err) + } + conceptPage, nextConcept, err := store.ListAnalysisConcepts(generationID, 2, "") + if err != nil || len(conceptPage.Concepts) != 2 || nextConcept != "beta" { + t.Fatalf("concept page = %+v next=%q err=%v", conceptPage, nextConcept, err) + } + blob, found, err := store.LoadAnalysisBlob(generationID, graph.AnalysisBlobAdjacency) + if err != nil || !found || string(blob) != "adj" { + t.Fatalf("blob=%q found=%v err=%v", blob, found, err) + } + + store.AddNode(&graph.Node{ID: "live", Kind: graph.KindFunction, Name: "Live", FilePath: "live.go"}) + if _, found, err := store.LoadActiveAnalysisHeader(91); err != nil || found { + t.Fatalf("mutated header found=%v err=%v", found, err) + } + if _, err := store.AnalysisNodeMetrics(generationID, []string{"n1"}); !errors.Is(err, graph.ErrAnalysisGenerationInactive) { + t.Fatalf("stale point read err=%v", err) + } + var retained int + if err := store.db.QueryRow(`SELECT COUNT(*) FROM analysis_nodes WHERE generation_id = ?`, generationID).Scan(&retained); err != nil || retained != 3 { + t.Fatalf("live-node mutation cascaded snapshot rows: count=%d err=%v", retained, err) + } +} + +func TestAnalysisGenerationCorruptionClearsActivePointer(t *testing.T) { + store, err := Open(filepathForAnalysisTest(t)) + if err != nil { + t.Fatal(err) + } + defer store.Close() + generationID := buildMinimalAnalysisGeneration(t, store, "corrupt", 1, true) + if _, err := store.db.Exec(`DELETE FROM analysis_generation_components WHERE generation_id = ? AND component = ?`, generationID, string(graph.AnalysisComponentNodes)); err != nil { + t.Fatal(err) + } + if _, found, err := store.LoadActiveAnalysisHeader(77); found || !errors.Is(err, graph.ErrAnalysisGenerationCorrupt) { + t.Fatalf("corrupt load found=%v err=%v", found, err) + } + var active, state int + if err := store.db.QueryRow(`SELECT COUNT(*) FROM analysis_active_generation`).Scan(&active); err != nil { + t.Fatal(err) + } + if err := store.db.QueryRow(`SELECT state FROM analysis_generations WHERE generation_id = ?`, generationID).Scan(&state); err != nil { + t.Fatal(err) + } + if active != 0 || state != analysisGenerationStale { + t.Fatalf("corrupt generation active=%d state=%d", active, state) + } +} + +func TestAnalysisGenerationForeignKeysEnabledOnPoolAndNoLiveNodeFK(t *testing.T) { + store, err := Open(filepathForAnalysisTest(t)) + if err != nil { + t.Fatal(err) + } + defer store.Close() + ctx := context.Background() + connections := make([]*sql.Conn, 0, sqliteMaxOpenConns) + for i := 0; i < sqliteMaxOpenConns; i++ { + conn, err := store.db.Conn(ctx) + if err != nil { + t.Fatal(err) + } + connections = append(connections, conn) + } + for i, conn := range connections { + var enabled int + if err := conn.QueryRowContext(ctx, `PRAGMA foreign_keys`).Scan(&enabled); err != nil { + t.Fatal(err) + } + if enabled != 1 { + t.Fatalf("connection %d foreign_keys=%d", i, enabled) + } + } + for _, conn := range connections { + conn.Close() + } + if _, err := store.db.Exec(`INSERT INTO analysis_nodes(generation_id,node_id,pagerank,authority,hub) VALUES(999,'orphan',0,0,0)`); err == nil { + t.Fatal("orphan analysis node bypassed generation FK") + } + store.AddNode(&graph.Node{ID: "same-id", Kind: graph.KindFunction, Name: "Same", FilePath: "same.go"}) + generationID := buildMinimalAnalysisGeneration(t, store, "same-id", 0, true) + store.EvictFile("same.go") + var retained int + if err := store.db.QueryRow(`SELECT COUNT(*) FROM analysis_nodes WHERE generation_id = ?`, generationID).Scan(&retained); err != nil || retained != 1 { + t.Fatalf("live node eviction cascaded generation-local node: count=%d err=%v", retained, err) + } +} + +func TestAnalysisGenerationV4MigrationPreservesGraphAndDropsProvisionalCache(t *testing.T) { + path := filepathForAnalysisTest(t) + store, err := Open(path) + if err != nil { + t.Fatal(err) + } + store.AddNode(&graph.Node{ID: "kept", Kind: graph.KindFunction, Name: "Kept", FilePath: "kept.go"}) + if _, err := store.db.Exec(`CREATE TABLE analysis_cache(component TEXT PRIMARY KEY, format_version INTEGER NOT NULL, payload BLOB NOT NULL) WITHOUT ROWID; INSERT INTO analysis_cache(component,format_version,payload) VALUES('pagerank',1,'unreleased-v3')`); err != nil { + t.Fatal(err) + } + drop := ` + DROP TABLE analysis_active_generation; + DROP TABLE analysis_generation_components; + DROP TABLE analysis_process_steps; + DROP TABLE analysis_process_files; + DROP TABLE analysis_processes; + DROP TABLE analysis_concept_relations; + DROP TABLE analysis_concepts; + DROP TABLE analysis_community_files; + DROP TABLE analysis_nodes; + DROP TABLE analysis_communities; + DROP TABLE analysis_blobs; + DROP TABLE analysis_generations;` + if _, err := store.db.Exec(drop); err != nil { + t.Fatal(err) + } + if _, err := store.db.Exec(`PRAGMA user_version = 3`); err != nil { + t.Fatal(err) + } + if err := store.Close(); err != nil { + t.Fatal(err) + } + store, err = Open(path) + if err != nil { + t.Fatal(err) + } + defer store.Close() + if store.GetNode("kept") == nil { + t.Fatal("v4 in-place migration lost graph rows") + } + var version, tables, provisional int + if err := store.db.QueryRow(`PRAGMA user_version`).Scan(&version); err != nil { + t.Fatal(err) + } + if err := store.db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='analysis_cache'`).Scan(&provisional); err != nil { + t.Fatal(err) + } + if err := store.db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN ('analysis_generations','analysis_nodes','analysis_blobs')`).Scan(&tables); err != nil { + t.Fatal(err) + } + if version != currentSchemaVersion || provisional != 0 || tables != 3 { + t.Fatalf("migration version=%d provisional=%d tables=%d", version, provisional, tables) + } +} + +func TestAnalysisGenerationReopenMarksBuildingStale(t *testing.T) { + path := filepathForAnalysisTest(t) + store, err := Open(path) + if err != nil { + t.Fatal(err) + } + generationID, accepted, err := store.BeginAnalysisGeneration(store.AnalysisMutationRevision(), graph.AnalysisGenerationHeader{FormatVersion: 1}) + requireAnalysisAccepted(t)(accepted, err) + if err := store.Close(); err != nil { + t.Fatal(err) + } + store, err = Open(path) + if err != nil { + t.Fatal(err) + } + defer store.Close() + var state int + if err := store.db.QueryRow(`SELECT state FROM analysis_generations WHERE generation_id = ?`, generationID).Scan(&state); err != nil { + t.Fatal(err) + } + if state != analysisGenerationStale { + t.Fatalf("reopened building generation state=%d", state) + } +} + +func TestAnalysisGenerationPruneKeepsActiveAndFallback(t *testing.T) { + store, err := Open(filepathForAnalysisTest(t)) + if err != nil { + t.Fatal(err) + } + defer store.Close() + first := buildMinimalAnalysisGeneration(t, store, "first", 3, true) + second := buildMinimalAnalysisGeneration(t, store, "second", 2, true) + third := buildMinimalAnalysisGeneration(t, store, "third", 1, true) + if err := store.PruneAnalysisGenerations(context.Background(), 1, 1); err != nil { + t.Fatal(err) + } + for generationID, want := range map[int64]int{first: 0, second: 1, third: 1} { + var count int + if err := store.db.QueryRow(`SELECT COUNT(*) FROM analysis_generations WHERE generation_id = ?`, generationID).Scan(&count); err != nil { + t.Fatal(err) + } + if count != want { + t.Fatalf("generation %d count=%d want=%d", generationID, count, want) + } + } + header, found, err := store.LoadActiveAnalysisHeader(77) + if err != nil || !found || header.GenerationID != third { + t.Fatalf("active after gc = %+v found=%v err=%v", header, found, err) + } +} + +func TestAnalysisGenerationGCReleasesWriterLockBetweenChunks(t *testing.T) { + store, err := Open(filepathForAnalysisTest(t)) + if err != nil { + t.Fatal(err) + } + defer store.Close() + first := buildMinimalAnalysisGeneration(t, store, "gc-large", analysisGenerationChunkLimit, true) + buildMinimalAnalysisGeneration(t, store, "gc-fallback", 0, true) + buildMinimalAnalysisGeneration(t, store, "gc-active", 0, true) + + gcDone := make(chan error, 1) + go func() { + gcDone <- store.PruneAnalysisGenerations(context.Background(), 1, 1) + }() + deadline := time.Now().Add(5 * time.Second) + observedPartial := false + for time.Now().Before(deadline) { + var remaining int + if err := store.db.QueryRow(`SELECT COUNT(*) FROM analysis_concepts WHERE generation_id = ?`, first).Scan(&remaining); err != nil { + t.Fatal(err) + } + if remaining > 0 && remaining < analysisGenerationChunkLimit { + observedPartial = true + break + } + select { + case err := <-gcDone: + t.Fatalf("gc completed before an interleavable chunk boundary: %v", err) + default: + } + time.Sleep(time.Millisecond) + } + if !observedPartial { + t.Fatal("did not observe bounded child deletion") + } + writerDone := make(chan struct{}) + go func() { + store.AddNode(&graph.Node{ID: "gc-interleaved-writer", Kind: graph.KindFunction, Name: "Writer", FilePath: "writer.go"}) + close(writerDone) + }() + select { + case <-writerDone: + // The graph writer acquired writeMu between GC chunks. + case err := <-gcDone: + select { + case <-writerDone: + default: + t.Fatalf("gc monopolized writer lock until completion: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("graph writer starved behind chunked analysis GC") + } + if err := <-gcDone; err != nil { + t.Fatal(err) + } + if store.GetNode("gc-interleaved-writer") == nil { + t.Fatal("interleaved graph writer was lost") + } +} + +func TestAnalysisGenerationInvalidationLatchRunsOnce(t *testing.T) { + store, err := Open(filepathForAnalysisTest(t)) + if err != nil { + t.Fatal(err) + } + defer store.Close() + buildMinimalAnalysisGeneration(t, store, "latch", 0, true) + if _, err := store.db.Exec(` + CREATE TABLE analysis_invalidation_audit(n INTEGER); + CREATE TRIGGER analysis_active_deleted AFTER DELETE ON analysis_active_generation + BEGIN INSERT INTO analysis_invalidation_audit(n) VALUES(1); END;`); err != nil { + t.Fatal(err) + } + for i := 0; i < 100; i++ { + store.AddNode(&graph.Node{ID: fmt.Sprintf("live-%03d", i), Kind: graph.KindFunction, Name: "Live", FilePath: "live.go"}) + } + store.AddEdge(&graph.Edge{From: "live-000", To: "live-001", Kind: graph.EdgeCalls}) + var deletes int + if err := store.db.QueryRow(`SELECT COUNT(*) FROM analysis_invalidation_audit`).Scan(&deletes); err != nil { + t.Fatal(err) + } + if deletes != 1 || store.analysisGenerationPresent { + t.Fatalf("invalidation deletes=%d cachePresent=%v", deletes, store.analysisGenerationPresent) + } + revision := store.AnalysisMutationRevision() + generationID, accepted, err := store.BeginAnalysisGeneration(revision, graph.AnalysisGenerationHeader{FormatVersion: 2}) + requireAnalysisAccepted(t)(accepted, err) + store.AddNode(&graph.Node{ID: "after-building", Kind: graph.KindFunction, Name: "After", FilePath: "after.go"}) + var state int + if err := store.db.QueryRow(`SELECT state FROM analysis_generations WHERE generation_id = ?`, generationID).Scan(&state); err != nil { + t.Fatal(err) + } + if state != analysisGenerationStale || store.analysisGenerationPresent { + t.Fatalf("building invalidation state=%d cachePresent=%v", state, store.analysisGenerationPresent) + } +} + +func TestAnalysisGenerationActivationMutationRaceNeverPublishesStale(t *testing.T) { + store, err := Open(filepathForAnalysisTest(t)) + if err != nil { + t.Fatal(err) + } + defer store.Close() + for i := 0; i < 25; i++ { + generationID := buildMinimalAnalysisGeneration(t, store, fmt.Sprintf("race-%02d", i), 0, false) + revision := store.AnalysisMutationRevision() + start := make(chan struct{}) + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + <-start + _, _ = store.ActivateAnalysisGeneration(revision, generationID) + }() + go func(iteration int) { + defer wg.Done() + <-start + store.AddNode(&graph.Node{ID: fmt.Sprintf("mutation-%02d", iteration), Kind: graph.KindFunction, Name: "Mutation", FilePath: "mutation.go"}) + }(i) + close(start) + wg.Wait() + if _, found, err := store.LoadActiveAnalysisHeader(77); err != nil || found { + t.Fatalf("iteration %d stale generation remained active: found=%v err=%v", i, found, err) + } + } +} + +func TestAnalysisGenerationChunkLimitRejectsBeforeWriterLock(t *testing.T) { + store, err := Open(filepathForAnalysisTest(t)) + if err != nil { + t.Fatal(err) + } + defer store.Close() + generationID, accepted, err := store.BeginAnalysisGeneration(store.AnalysisMutationRevision(), graph.AnalysisGenerationHeader{FormatVersion: 1}) + requireAnalysisAccepted(t)(accepted, err) + nodes := make([]graph.AnalysisNodeMetric, analysisGenerationChunkLimit+1) + for i := range nodes { + nodes[i].NodeID = fmt.Sprintf("node-%05d", i) + } + store.writeMu.Lock() + done := make(chan error, 1) + go func() { + _, err := store.AppendAnalysisNodes(store.AnalysisMutationRevision(), generationID, nodes) + done <- err + }() + select { + case err := <-done: + if err == nil || !strings.Contains(err.Error(), "limit") { + t.Fatalf("oversized chunk err=%v", err) + } + case <-time.After(time.Second): + t.Fatal("oversized chunk waited on writer lock instead of failing before it") + } + store.writeMu.Unlock() +} + +func TestAnalysisGenerationQueryPlansUseBoundedIndexes(t *testing.T) { + store, err := Open(filepathForAnalysisTest(t)) + if err != nil { + t.Fatal(err) + } + defer store.Close() + plans := map[string]struct { + query string + args []any + }{ + "analysis_nodes_by_pagerank": {`SELECT id FROM analysis_nodes WHERE generation_id = ? ORDER BY pagerank DESC, id ASC LIMIT ?`, []any{1, 10}}, + "analysis_nodes_by_community": {`SELECT id FROM analysis_nodes WHERE generation_id = ? AND community_id = ? AND node_id > ? ORDER BY node_id LIMIT ?`, []any{1, "c", "", 10}}, + "analysis_process_steps_by_node": {`SELECT process_id FROM analysis_process_steps WHERE generation_id = ? AND node_rowid = ? ORDER BY process_id`, []any{1, 1}}, + "analysis_concept_relations_by_related": {`SELECT token FROM analysis_concept_relations WHERE generation_id = ? AND related_token = ? ORDER BY rank, token`, []any{1, "x"}}, + } + for index, fixture := range plans { + rows, err := store.db.Query(`EXPLAIN QUERY PLAN `+fixture.query, fixture.args...) + if err != nil { + t.Fatal(err) + } + var details []string + for rows.Next() { + var id, parent, unused int + var detail string + if err := rows.Scan(&id, &parent, &unused, &detail); err != nil { + rows.Close() + t.Fatal(err) + } + details = append(details, detail) + } + rows.Close() + plan := strings.Join(details, " | ") + if !strings.Contains(plan, index) { + t.Fatalf("plan for %s did not use index: %s", index, plan) + } + } +} + +func filepathForAnalysisTest(t *testing.T) string { + t.Helper() + return t.TempDir() + "/graph.sqlite" +} diff --git a/internal/graph/store_sqlite/analysis_generation_write.go b/internal/graph/store_sqlite/analysis_generation_write.go new file mode 100644 index 000000000..08e1494a4 --- /dev/null +++ b/internal/graph/store_sqlite/analysis_generation_write.go @@ -0,0 +1,808 @@ +package store_sqlite + +import ( + "database/sql" + "fmt" + "math" + "sort" + "time" + + "github.com/zzet/gortex/internal/graph" +) + +const ( + analysisGenerationBuilding = 0 + analysisGenerationReady = 1 + analysisGenerationStale = 2 + + analysisGenerationChunkLimit = 5000 + maxSQLiteRevision = uint64(1<<63 - 1) +) + +var ( + _ graph.AnalysisGenerationStore = (*Store)(nil) + _ graph.AnalysisQueryStore = (*Store)(nil) +) + +var requiredAnalysisComponents = [...]graph.AnalysisComponent{ + graph.AnalysisComponentNodes, + graph.AnalysisComponentCommunities, + graph.AnalysisComponentProcesses, + graph.AnalysisComponentConcepts, + graph.AnalysisComponentAdjacency, + graph.AnalysisComponentLeiden, +} + +func validAnalysisComponent(component graph.AnalysisComponent) bool { + for _, required := range requiredAnalysisComponents { + if component == required { + return true + } + } + return false +} + +func validAnalysisBlobComponent(component graph.AnalysisBlobComponent) bool { + return component == graph.AnalysisBlobAdjacency || component == graph.AnalysisBlobLeiden +} + +func analysisBool(value bool) int { + if value { + return 1 + } + return 0 +} + +func validAnalysisFloat(value float64) bool { + return !math.IsNaN(value) && !math.IsInf(value, 0) +} + +func validateAnalysisHeader(header graph.AnalysisGenerationHeader) error { + if header.GenerationID != 0 { + return fmt.Errorf("analysis generation: begin header must not set generation id") + } + if header.NodeCount < 0 || header.CommunityCount < 0 || header.ProcessCount < 0 || header.ConceptCount < 0 { + return fmt.Errorf("analysis generation: negative manifest count") + } + if !validAnalysisFloat(header.PageRankMax) || !validAnalysisFloat(header.AuthorityMax) || + !validAnalysisFloat(header.HubMax) || !validAnalysisFloat(header.Modularity) { + return fmt.Errorf("analysis generation: non-finite manifest metric") + } + return nil +} + +func validateAnalysisChunkSize(kind string, size int) error { + if size > analysisGenerationChunkLimit { + return fmt.Errorf("analysis generation: %s chunk has %d rows, limit is %d", kind, size, analysisGenerationChunkLimit) + } + return nil +} + +func (s *Store) BeginAnalysisGeneration(expectedRevision uint64, header graph.AnalysisGenerationHeader) (int64, bool, error) { + if err := validateAnalysisHeader(header); err != nil { + return 0, false, err + } + if expectedRevision > maxSQLiteRevision { + return 0, false, fmt.Errorf("analysis generation: revision %d exceeds sqlite integer range", expectedRevision) + } + if header.GraphRevision != 0 && header.GraphRevision != expectedRevision { + return 0, false, fmt.Errorf("analysis generation: header revision %d does not match expected revision %d", header.GraphRevision, expectedRevision) + } + createdAt := header.CreatedAtUnix + if createdAt == 0 { + createdAt = time.Now().Unix() + } + + s.writeMu.Lock() + defer s.writeMu.Unlock() + if s.analysisMutationRevision.Load() != expectedRevision { + return 0, false, nil + } + tx, err := s.beginWrite() + if err != nil { + return 0, false, err + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback() + } + }() + result, err := tx.Exec(` + INSERT INTO analysis_generations( + format_version, build_revision, created_at_unix, state, + node_count, community_count, process_count, concept_count, + pagerank_max, authority_max, hub_max, modularity, + processes_truncated, processes_truncation_reason + ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + header.FormatVersion, int64(expectedRevision), createdAt, analysisGenerationBuilding, + header.NodeCount, header.CommunityCount, header.ProcessCount, header.ConceptCount, + header.PageRankMax, header.AuthorityMax, header.HubMax, header.Modularity, + analysisBool(header.ProcessesTruncated), header.ProcessesTruncationReason) + if err != nil { + return 0, false, err + } + generationID, err := result.LastInsertId() + if err != nil { + return 0, false, err + } + if err := tx.Commit(); err != nil { + return 0, false, err + } + committed = true + // Include invisible building rows in the mutation latch: the next graph + // write will mark them stale once, then return to the O(1) no-cache path. + s.analysisGenerationPresent = true + return generationID, true, nil +} + +// beginAnalysisGenerationWrite starts a short transaction for one bounded +// append/seal operation. The caller holds writeMu. A stale revision or a +// generation no longer in building state is a clean rejected write. +func (s *Store) beginAnalysisGenerationWrite(expectedRevision uint64, generationID int64) (*sql.Tx, bool, error) { + if generationID <= 0 { + return nil, false, fmt.Errorf("analysis generation: invalid generation id %d", generationID) + } + if s.analysisMutationRevision.Load() != expectedRevision { + return nil, false, nil + } + tx, err := s.beginWrite() + if err != nil { + return nil, false, err + } + var state int + if err := tx.QueryRow(`SELECT state FROM analysis_generations WHERE generation_id = ?`, generationID).Scan(&state); err != nil { + _ = tx.Rollback() + if err == sql.ErrNoRows { + return nil, false, fmt.Errorf("analysis generation: generation %d does not exist", generationID) + } + return nil, false, err + } + if state != analysisGenerationBuilding { + _ = tx.Rollback() + return nil, false, nil + } + return tx, true, nil +} + +func (s *Store) beginAnalysisComponentWrite(expectedRevision uint64, generationID int64, component graph.AnalysisComponent) (*sql.Tx, bool, error) { + tx, accepted, err := s.beginAnalysisGenerationWrite(expectedRevision, generationID) + if err != nil || !accepted { + return tx, accepted, err + } + var sealed int + if err := tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM analysis_generation_components WHERE generation_id = ? AND component = ?)`, generationID, string(component)).Scan(&sealed); err != nil { + _ = tx.Rollback() + return nil, false, err + } + if sealed != 0 { + _ = tx.Rollback() + return nil, false, fmt.Errorf("analysis generation: component %s is already sealed", component) + } + return tx, true, nil +} + +func (s *Store) AppendAnalysisCommunities(expectedRevision uint64, generationID int64, communities []graph.AnalysisCommunitySummary) (bool, error) { + if err := validateAnalysisChunkSize("community", len(communities)); err != nil { + return false, err + } + seen := make(map[string]struct{}, len(communities)) + for _, community := range communities { + if community.ID == "" { + return false, fmt.Errorf("analysis generation: empty community id") + } + if community.Size < 0 || !validAnalysisFloat(community.Cohesion) { + return false, fmt.Errorf("analysis generation: invalid community %q metrics", community.ID) + } + if _, duplicate := seen[community.ID]; duplicate { + return false, fmt.Errorf("analysis generation: duplicate community %q in chunk", community.ID) + } + seen[community.ID] = struct{}{} + } + + s.writeMu.Lock() + defer s.writeMu.Unlock() + tx, accepted, err := s.beginAnalysisComponentWrite(expectedRevision, generationID, graph.AnalysisComponentCommunities) + if err != nil || !accepted { + return accepted, err + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback() + } + }() + communityStmt, err := tx.Prepare(` + INSERT INTO analysis_communities(generation_id, community_id, label, hub, parent_id, size, cohesion) + VALUES(?,?,?,?,?,?,?) + ON CONFLICT(generation_id, community_id) DO UPDATE SET + label=excluded.label, hub=excluded.hub, parent_id=excluded.parent_id, + size=excluded.size, cohesion=excluded.cohesion`) + if err != nil { + return false, err + } + defer communityStmt.Close() + fileStmt, err := tx.Prepare(`INSERT INTO analysis_community_files(generation_id, community_id, ordinal, file_path) VALUES(?,?,?,?)`) + if err != nil { + return false, err + } + defer fileStmt.Close() + for _, community := range communities { + if _, err := communityStmt.Exec(generationID, community.ID, community.Label, community.Hub, community.ParentID, community.Size, community.Cohesion); err != nil { + return false, err + } + if _, err := tx.Exec(`DELETE FROM analysis_community_files WHERE generation_id = ? AND community_id = ?`, generationID, community.ID); err != nil { + return false, err + } + filesSeen := make(map[string]struct{}, len(community.Files)) + for ordinal, file := range community.Files { + if file == "" { + return false, fmt.Errorf("analysis generation: community %q has empty file", community.ID) + } + if _, duplicate := filesSeen[file]; duplicate { + return false, fmt.Errorf("analysis generation: community %q repeats file %q", community.ID, file) + } + filesSeen[file] = struct{}{} + if _, err := fileStmt.Exec(generationID, community.ID, ordinal, file); err != nil { + return false, err + } + } + } + if err := tx.Commit(); err != nil { + return false, err + } + committed = true + return true, nil +} + +func (s *Store) AppendAnalysisNodes(expectedRevision uint64, generationID int64, nodes []graph.AnalysisNodeMetric) (bool, error) { + if err := validateAnalysisChunkSize("node", len(nodes)); err != nil { + return false, err + } + seen := make(map[string]struct{}, len(nodes)) + for _, node := range nodes { + if node.NodeID == "" { + return false, fmt.Errorf("analysis generation: empty node id") + } + if node.RowID != 0 { + return false, fmt.Errorf("analysis generation: writer must not set node row id for %q", node.NodeID) + } + if !validAnalysisFloat(node.PageRank) || !validAnalysisFloat(node.Authority) || !validAnalysisFloat(node.Hub) { + return false, fmt.Errorf("analysis generation: non-finite node metric for %q", node.NodeID) + } + if _, duplicate := seen[node.NodeID]; duplicate { + return false, fmt.Errorf("analysis generation: duplicate node %q in chunk", node.NodeID) + } + seen[node.NodeID] = struct{}{} + } + + s.writeMu.Lock() + defer s.writeMu.Unlock() + tx, accepted, err := s.beginAnalysisComponentWrite(expectedRevision, generationID, graph.AnalysisComponentNodes) + if err != nil || !accepted { + return accepted, err + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback() + } + }() + stmt, err := tx.Prepare(` + INSERT INTO analysis_nodes(generation_id, node_id, community_id, pagerank, authority, hub) + VALUES(?,?,?,?,?,?) + ON CONFLICT(generation_id, node_id) DO UPDATE SET + community_id=excluded.community_id, pagerank=excluded.pagerank, + authority=excluded.authority, hub=excluded.hub`) + if err != nil { + return false, err + } + defer stmt.Close() + for _, node := range nodes { + var community any + if node.CommunityID != "" { + community = node.CommunityID + } + if _, err := stmt.Exec(generationID, node.NodeID, community, node.PageRank, node.Authority, node.Hub); err != nil { + return false, err + } + } + if err := tx.Commit(); err != nil { + return false, err + } + committed = true + return true, nil +} + +func (s *Store) AppendAnalysisProcesses(expectedRevision uint64, generationID int64, processes []graph.AnalysisProcessSummary, steps []graph.AnalysisProcessStep) (bool, error) { + if err := validateAnalysisChunkSize("process", len(processes)); err != nil { + return false, err + } + if err := validateAnalysisChunkSize("process step", len(steps)); err != nil { + return false, err + } + processSeen := make(map[string]struct{}, len(processes)) + for _, process := range processes { + if process.ID == "" || process.StepCount < 0 || !validAnalysisFloat(process.Score) { + return false, fmt.Errorf("analysis generation: invalid process %q", process.ID) + } + if _, duplicate := processSeen[process.ID]; duplicate { + return false, fmt.Errorf("analysis generation: duplicate process %q in chunk", process.ID) + } + processSeen[process.ID] = struct{}{} + } + stepSeen := make(map[string]struct{}, len(steps)) + for _, step := range steps { + if step.ProcessID == "" || step.NodeID == "" || step.Ordinal < 0 || step.Depth < 0 { + return false, fmt.Errorf("analysis generation: invalid process step %+v", step) + } + key := fmt.Sprintf("%s\x00%d", step.ProcessID, step.Ordinal) + if _, duplicate := stepSeen[key]; duplicate { + return false, fmt.Errorf("analysis generation: duplicate step %s ordinal %d", step.ProcessID, step.Ordinal) + } + stepSeen[key] = struct{}{} + } + + s.writeMu.Lock() + defer s.writeMu.Unlock() + tx, accepted, err := s.beginAnalysisComponentWrite(expectedRevision, generationID, graph.AnalysisComponentProcesses) + if err != nil || !accepted { + return accepted, err + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback() + } + }() + processStmt, err := tx.Prepare(` + INSERT INTO analysis_processes(generation_id, process_id, name, entry_point, step_count, score, truncated) + VALUES(?,?,?,?,?,?,?) + ON CONFLICT(generation_id, process_id) DO UPDATE SET + name=excluded.name, entry_point=excluded.entry_point, step_count=excluded.step_count, + score=excluded.score, truncated=excluded.truncated`) + if err != nil { + return false, err + } + defer processStmt.Close() + fileStmt, err := tx.Prepare(`INSERT INTO analysis_process_files(generation_id, process_id, ordinal, file_path) VALUES(?,?,?,?)`) + if err != nil { + return false, err + } + defer fileStmt.Close() + for _, process := range processes { + if _, err := processStmt.Exec(generationID, process.ID, process.Name, process.EntryPoint, process.StepCount, process.Score, analysisBool(process.Truncated)); err != nil { + return false, err + } + if _, err := tx.Exec(`DELETE FROM analysis_process_files WHERE generation_id = ? AND process_id = ?`, generationID, process.ID); err != nil { + return false, err + } + filesSeen := make(map[string]struct{}, len(process.Files)) + for ordinal, file := range process.Files { + if file == "" { + return false, fmt.Errorf("analysis generation: process %q has empty file", process.ID) + } + if _, duplicate := filesSeen[file]; duplicate { + return false, fmt.Errorf("analysis generation: process %q repeats file %q", process.ID, file) + } + filesSeen[file] = struct{}{} + if _, err := fileStmt.Exec(generationID, process.ID, ordinal, file); err != nil { + return false, err + } + } + } + stepStmt, err := tx.Prepare(` + INSERT INTO analysis_process_steps(generation_id, process_id, ordinal, node_rowid, depth) + SELECT ?, ?, ?, id, ? FROM analysis_nodes WHERE generation_id = ? AND node_id = ? + ON CONFLICT(generation_id, process_id, ordinal) DO UPDATE SET + node_rowid=excluded.node_rowid, depth=excluded.depth`) + if err != nil { + return false, err + } + defer stepStmt.Close() + for _, step := range steps { + result, err := stepStmt.Exec(generationID, step.ProcessID, step.Ordinal, step.Depth, generationID, step.NodeID) + if err != nil { + return false, err + } + if rows, err := result.RowsAffected(); err != nil || rows != 1 { + if err != nil { + return false, err + } + return false, fmt.Errorf("analysis generation: process step %s[%d] references unknown node %q", step.ProcessID, step.Ordinal, step.NodeID) + } + } + if err := tx.Commit(); err != nil { + return false, err + } + committed = true + return true, nil +} + +func (s *Store) AppendAnalysisConcepts(expectedRevision uint64, generationID int64, concepts []graph.AnalysisConcept, relations []graph.AnalysisConceptRelation) (bool, error) { + if err := validateAnalysisChunkSize("concept", len(concepts)); err != nil { + return false, err + } + if err := validateAnalysisChunkSize("concept relation", len(relations)); err != nil { + return false, err + } + conceptSeen := make(map[string]struct{}, len(concepts)) + for _, concept := range concepts { + if concept.Token == "" { + return false, fmt.Errorf("analysis generation: empty concept token") + } + if _, duplicate := conceptSeen[concept.Token]; duplicate { + return false, fmt.Errorf("analysis generation: duplicate concept %q in chunk", concept.Token) + } + conceptSeen[concept.Token] = struct{}{} + } + relationSeen := make(map[string]struct{}, len(relations)) + for _, relation := range relations { + if relation.Token == "" || relation.RelatedToken == "" || relation.Rank < 0 { + return false, fmt.Errorf("analysis generation: invalid concept relation %+v", relation) + } + key := fmt.Sprintf("%s\x00%d\x00%s", relation.Token, relation.Rank, relation.RelatedToken) + if _, duplicate := relationSeen[key]; duplicate { + return false, fmt.Errorf("analysis generation: duplicate concept relation %q", key) + } + relationSeen[key] = struct{}{} + } + + s.writeMu.Lock() + defer s.writeMu.Unlock() + tx, accepted, err := s.beginAnalysisComponentWrite(expectedRevision, generationID, graph.AnalysisComponentConcepts) + if err != nil || !accepted { + return accepted, err + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback() + } + }() + conceptStmt, err := tx.Prepare(` + INSERT INTO analysis_concepts(generation_id, token, in_vocabulary) VALUES(?,?,?) + ON CONFLICT(generation_id, token) DO UPDATE SET in_vocabulary=excluded.in_vocabulary`) + if err != nil { + return false, err + } + defer conceptStmt.Close() + for _, concept := range concepts { + if _, err := conceptStmt.Exec(generationID, concept.Token, analysisBool(concept.InVocabulary)); err != nil { + return false, err + } + } + relationStmt, err := tx.Prepare(` + INSERT INTO analysis_concept_relations(generation_id, token, related_token, rank) VALUES(?,?,?,?) + ON CONFLICT(generation_id, token, rank, related_token) DO NOTHING`) + if err != nil { + return false, err + } + defer relationStmt.Close() + for _, relation := range relations { + if _, err := relationStmt.Exec(generationID, relation.Token, relation.RelatedToken, relation.Rank); err != nil { + return false, err + } + } + if err := tx.Commit(); err != nil { + return false, err + } + committed = true + return true, nil +} + +func (s *Store) PutAnalysisBlob(expectedRevision uint64, generationID int64, blob graph.AnalysisBlob) (bool, error) { + if !validAnalysisBlobComponent(blob.Component) { + return false, fmt.Errorf("analysis generation: unsupported blob component %q", blob.Component) + } + if len(blob.Payload) == 0 { + return false, fmt.Errorf("analysis generation: empty %s blob", blob.Component) + } + + s.writeMu.Lock() + defer s.writeMu.Unlock() + tx, accepted, err := s.beginAnalysisComponentWrite(expectedRevision, generationID, graph.AnalysisComponent(blob.Component)) + if err != nil || !accepted { + return accepted, err + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback() + } + }() + if _, err := tx.Exec(` + INSERT INTO analysis_blobs(generation_id, component, payload) VALUES(?,?,?) + ON CONFLICT(generation_id, component) DO UPDATE SET payload=excluded.payload`, + generationID, string(blob.Component), blob.Payload); err != nil { + return false, err + } + if err := tx.Commit(); err != nil { + return false, err + } + committed = true + return true, nil +} + +func (s *Store) SealAnalysisComponent(expectedRevision uint64, generationID int64, component graph.AnalysisComponent, expectedRows int) (bool, error) { + if !validAnalysisComponent(component) { + return false, fmt.Errorf("analysis generation: unsupported component %q", component) + } + if expectedRows < 0 { + return false, fmt.Errorf("analysis generation: negative expected row count for %s", component) + } + + s.writeMu.Lock() + defer s.writeMu.Unlock() + tx, accepted, err := s.beginAnalysisGenerationWrite(expectedRevision, generationID) + if err != nil || !accepted { + return accepted, err + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback() + } + }() + actual, err := analysisComponentRowsTx(tx, generationID, component) + if err != nil { + return false, err + } + if actual != expectedRows { + return false, fmt.Errorf("analysis generation: %s has %d rows, expected %d", component, actual, expectedRows) + } + headerExpected, err := analysisHeaderComponentRowsTx(tx, generationID, component) + if err != nil { + return false, err + } + if headerExpected >= 0 && actual != headerExpected { + return false, fmt.Errorf("analysis generation: %s has %d rows, manifest requires %d", component, actual, headerExpected) + } + if component == graph.AnalysisComponentProcesses { + var mismatches int + if err := tx.QueryRow(` + SELECT COUNT(*) FROM analysis_processes p + WHERE p.generation_id = ? AND p.step_count != ( + SELECT COUNT(*) FROM analysis_process_steps s + WHERE s.generation_id = p.generation_id AND s.process_id = p.process_id + )`, generationID).Scan(&mismatches); err != nil { + return false, err + } + if mismatches != 0 { + return false, fmt.Errorf("analysis generation: %d process step counts are incomplete", mismatches) + } + } + if _, err := tx.Exec(` + INSERT INTO analysis_generation_components(generation_id, component, row_count, sealed_at_unix) + VALUES(?,?,?,?) + ON CONFLICT(generation_id, component) DO UPDATE SET + row_count=excluded.row_count, sealed_at_unix=excluded.sealed_at_unix`, + generationID, string(component), actual, time.Now().Unix()); err != nil { + return false, err + } + if err := tx.Commit(); err != nil { + return false, err + } + committed = true + return true, nil +} + +func analysisComponentRowsTx(tx *sql.Tx, generationID int64, component graph.AnalysisComponent) (int, error) { + var query string + var args []any + switch component { + case graph.AnalysisComponentNodes: + query = `SELECT COUNT(*) FROM analysis_nodes WHERE generation_id = ?` + case graph.AnalysisComponentCommunities: + query = `SELECT COUNT(*) FROM analysis_communities WHERE generation_id = ?` + case graph.AnalysisComponentProcesses: + query = `SELECT COUNT(*) FROM analysis_processes WHERE generation_id = ?` + case graph.AnalysisComponentConcepts: + query = `SELECT COUNT(*) FROM analysis_concepts WHERE generation_id = ?` + case graph.AnalysisComponentAdjacency, graph.AnalysisComponentLeiden: + query = `SELECT COUNT(*) FROM analysis_blobs WHERE generation_id = ? AND component = ? AND length(payload) > 0` + args = append(args, string(component)) + default: + return 0, fmt.Errorf("analysis generation: unsupported component %q", component) + } + args = append([]any{generationID}, args...) + var count int + if err := tx.QueryRow(query, args...).Scan(&count); err != nil { + return 0, err + } + return count, nil +} + +func analysisHeaderComponentRowsTx(tx *sql.Tx, generationID int64, component graph.AnalysisComponent) (int, error) { + column := "" + switch component { + case graph.AnalysisComponentNodes: + column = "node_count" + case graph.AnalysisComponentCommunities: + column = "community_count" + case graph.AnalysisComponentProcesses: + column = "process_count" + case graph.AnalysisComponentConcepts: + column = "concept_count" + case graph.AnalysisComponentAdjacency, graph.AnalysisComponentLeiden: + return 1, nil + default: + return -1, fmt.Errorf("analysis generation: unsupported component %q", component) + } + var count int + if err := tx.QueryRow(`SELECT `+column+` FROM analysis_generations WHERE generation_id = ?`, generationID).Scan(&count); err != nil { + return -1, err + } + return count, nil +} + +func loadAnalysisGenerationHeaderTx(tx *sql.Tx, generationID int64) (graph.AnalysisGenerationHeader, int, error) { + var header graph.AnalysisGenerationHeader + var buildRevision int64 + var state int + var truncated int + err := tx.QueryRow(` + SELECT generation_id, format_version, build_revision, created_at_unix, state, + node_count, community_count, process_count, concept_count, + pagerank_max, authority_max, hub_max, modularity, + processes_truncated, processes_truncation_reason + FROM analysis_generations WHERE generation_id = ?`, generationID).Scan( + &header.GenerationID, &header.FormatVersion, &buildRevision, &header.CreatedAtUnix, &state, + &header.NodeCount, &header.CommunityCount, &header.ProcessCount, &header.ConceptCount, + &header.PageRankMax, &header.AuthorityMax, &header.HubMax, &header.Modularity, + &truncated, &header.ProcessesTruncationReason) + if err != nil { + return graph.AnalysisGenerationHeader{}, 0, err + } + header.GraphRevision = uint64(buildRevision) + header.ProcessesTruncated = truncated != 0 + return header, state, nil +} + +// validateAnalysisGenerationTx re-counts every sealed primary component and +// verifies subordinate process/node links. Activation therefore cannot expose +// a partial generation even if a writer crashed between chunks. +func validateAnalysisGenerationTx(tx *sql.Tx, generationID int64) (graph.AnalysisGenerationHeader, error) { + header, _, err := loadAnalysisGenerationHeaderTx(tx, generationID) + if err != nil { + return graph.AnalysisGenerationHeader{}, err + } + sealed := make(map[graph.AnalysisComponent]int, len(requiredAnalysisComponents)) + rows, err := tx.Query(`SELECT component, row_count FROM analysis_generation_components WHERE generation_id = ?`, generationID) + if err != nil { + return graph.AnalysisGenerationHeader{}, err + } + for rows.Next() { + var component graph.AnalysisComponent + var count int + if err := rows.Scan(&component, &count); err != nil { + rows.Close() + return graph.AnalysisGenerationHeader{}, err + } + sealed[component] = count + } + if err := rows.Close(); err != nil { + return graph.AnalysisGenerationHeader{}, err + } + for _, component := range requiredAnalysisComponents { + sealedRows, ok := sealed[component] + if !ok { + return graph.AnalysisGenerationHeader{}, fmt.Errorf("missing sealed component %s", component) + } + actual, err := analysisComponentRowsTx(tx, generationID, component) + if err != nil { + return graph.AnalysisGenerationHeader{}, err + } + headerRows, err := analysisHeaderComponentRowsTx(tx, generationID, component) + if err != nil { + return graph.AnalysisGenerationHeader{}, err + } + if sealedRows != actual || actual != headerRows { + return graph.AnalysisGenerationHeader{}, fmt.Errorf("component %s count sealed=%d actual=%d manifest=%d", component, sealedRows, actual, headerRows) + } + } + var brokenSteps int + if err := tx.QueryRow(` + SELECT COUNT(*) FROM analysis_process_steps s + LEFT JOIN analysis_nodes n ON n.id = s.node_rowid AND n.generation_id = s.generation_id + WHERE s.generation_id = ? AND n.id IS NULL`, generationID).Scan(&brokenSteps); err != nil { + return graph.AnalysisGenerationHeader{}, err + } + if brokenSteps != 0 { + return graph.AnalysisGenerationHeader{}, fmt.Errorf("%d process steps reference a node outside the generation", brokenSteps) + } + var mismatchedProcesses int + if err := tx.QueryRow(` + SELECT COUNT(*) FROM analysis_processes p + WHERE p.generation_id = ? AND p.step_count != ( + SELECT COUNT(*) FROM analysis_process_steps s + WHERE s.generation_id = p.generation_id AND s.process_id = p.process_id + )`, generationID).Scan(&mismatchedProcesses); err != nil { + return graph.AnalysisGenerationHeader{}, err + } + if mismatchedProcesses != 0 { + return graph.AnalysisGenerationHeader{}, fmt.Errorf("%d process step counts differ from their manifests", mismatchedProcesses) + } + return header, nil +} + +func (s *Store) ActivateAnalysisGeneration(expectedRevision uint64, generationID int64) (bool, error) { + s.writeMu.Lock() + defer s.writeMu.Unlock() + tx, accepted, err := s.beginAnalysisGenerationWrite(expectedRevision, generationID) + if err != nil || !accepted { + return accepted, err + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback() + } + }() + if _, err := validateAnalysisGenerationTx(tx, generationID); err != nil { + return false, fmt.Errorf("analysis generation %d is incomplete: %w", generationID, err) + } + if _, err := tx.Exec(`UPDATE analysis_generations SET state = ? WHERE generation_id = ? AND state = ?`, analysisGenerationReady, generationID, analysisGenerationBuilding); err != nil { + return false, err + } + if _, err := tx.Exec(` + INSERT INTO analysis_active_generation(slot, generation_id) VALUES(1, ?) + ON CONFLICT(slot) DO UPDATE SET generation_id=excluded.generation_id`, generationID); err != nil { + return false, err + } + if err := tx.Commit(); err != nil { + return false, err + } + committed = true + s.analysisGenerationPresent = true + return true, nil +} + +func (s *Store) AbortAnalysisGeneration(generationID int64) error { + if generationID <= 0 { + return fmt.Errorf("analysis generation: invalid generation id %d", generationID) + } + s.writeMu.Lock() + defer s.writeMu.Unlock() + result, err := s.db.Exec(`UPDATE analysis_generations SET state = ? WHERE generation_id = ? AND state = ?`, analysisGenerationStale, generationID, analysisGenerationBuilding) + if err != nil { + return err + } + changed, err := result.RowsAffected() + if err != nil { + return err + } + if changed == 0 { + var state int + if err := s.db.QueryRow(`SELECT state FROM analysis_generations WHERE generation_id = ?`, generationID).Scan(&state); err != nil { + if err == sql.ErrNoRows { + return fmt.Errorf("analysis generation: generation %d does not exist", generationID) + } + return err + } + if state == analysisGenerationReady { + return fmt.Errorf("analysis generation: cannot abort ready generation %d", generationID) + } + } + return nil +} + +func sortedUniqueAnalysisStrings(values []string, kind string) ([]string, error) { + if len(values) == 0 { + return nil, nil + } + if len(values) > analysisGenerationChunkLimit { + return nil, fmt.Errorf("analysis generation: %s batch has %d values, limit is %d", kind, len(values), analysisGenerationChunkLimit) + } + out := append([]string(nil), values...) + sort.Strings(out) + for i, value := range out { + if value == "" { + return nil, fmt.Errorf("analysis generation: empty %s", kind) + } + if i > 0 && out[i-1] == value { + return nil, fmt.Errorf("analysis generation: duplicate %s %q", kind, value) + } + } + return out, nil +} diff --git a/internal/graph/store_sqlite/bulk_load.go b/internal/graph/store_sqlite/bulk_load.go index b778491a9..e4f05484b 100644 --- a/internal/graph/store_sqlite/bulk_load.go +++ b/internal/graph/store_sqlite/bulk_load.go @@ -153,6 +153,9 @@ func (s *Store) BeginBulkLoad() { s.bulkConn = conn s.bulkPrevSync = prevSync s.bulkPrevCacheSize = prevCache + // The bulk path changes durability and secondary-index maintenance outside + // the ordinary row mutation protocol. Active receipts therefore fail closed. + s.markMutationReceiptsIncompleteLocked() } // FlushBulk exits the bulk-load fast path: it rebuilds every index @@ -174,6 +177,10 @@ func (s *Store) FlushBulk() error { } // Detach first: the fast path is over regardless of the outcome below. s.bulkConn = nil + // A receipt may have started after BeginBulkLoad. Rebuilding the dropped + // indexes is outside the ordinary row protocol, so that window also fails + // closed even when restoration later returns an error. + s.markMutationReceiptsIncompleteLocked() ctx := context.Background() defer func() { diff --git a/internal/graph/store_sqlite/meta_flat_test.go b/internal/graph/store_sqlite/meta_flat_test.go index 833dfa027..aadc51274 100644 --- a/internal/graph/store_sqlite/meta_flat_test.go +++ b/internal/graph/store_sqlite/meta_flat_test.go @@ -118,8 +118,9 @@ func TestFlatCodecDeterministic(t *testing.T) { // model makes encodeMeta fall back to JSON (leading '{'), and decodeMeta // still reads it. No data is dropped. func TestEncodeMetaFallbackToJSON(t *testing.T) { - // uint64 is deliberately outside the modelled type set. - in := map[string]any{"weird": uint64(42), "name": "keep"} + // []int is deliberately outside the modelled type set. uint64 is now an + // exact fast-codec type because reach generations must survive reloads. + in := map[string]any{"weird": []int{42}, "name": "keep"} if _, ok := encodeMetaFast(in); ok { t.Fatal("encodeMetaFast should bail on an unmodelled value type") @@ -139,8 +140,8 @@ func TestEncodeMetaFallbackToJSON(t *testing.T) { if err != nil { t.Fatalf("decodeMeta(json fallback): %v", err) } - // The JSON fallback widens uint64 -> int (documented, lossy only for the - // exotic tail), but the string survives and no row is lost. + // The exotic tail may widen through JSON, but the string survives and no + // row is lost. if got["name"] != "keep" { t.Errorf("name not preserved through JSON fallback: %#v", got["name"]) } diff --git a/internal/graph/store_sqlite/meta_json.go b/internal/graph/store_sqlite/meta_json.go index bb05afce1..726b74777 100644 --- a/internal/graph/store_sqlite/meta_json.go +++ b/internal/graph/store_sqlite/meta_json.go @@ -416,6 +416,8 @@ const ( metaTagMapSlice = 0x09 // uvarint count, then count map bodies metaTagAnySlice = 0x0A // uvarint count, then count tagged values metaTagShape = 0x0B // uvarint len, len bytes of JSON-encoded *contracts.Shape + metaTagUint64 = 0x0C // unsigned varint + metaTagF64Slice = 0x0D // uvarint count, then count little-endian float64 values ) var errMetaTruncated = errors.New("store_sqlite: truncated meta blob") @@ -484,9 +486,19 @@ func appendMetaValue(buf []byte, v any) ([]byte, bool) { case int64: buf = append(buf, metaTagInt64) return binary.AppendVarint(buf, vv), true + case uint64: + buf = append(buf, metaTagUint64) + return binary.AppendUvarint(buf, vv), true case float64: buf = append(buf, metaTagFloat64) return binary.LittleEndian.AppendUint64(buf, math.Float64bits(vv)), true + case []float64: + buf = append(buf, metaTagF64Slice) + buf = binary.AppendUvarint(buf, uint64(len(vv))) + for _, f := range vv { + buf = binary.LittleEndian.AppendUint64(buf, math.Float64bits(f)) + } + return buf, true case []string: buf = append(buf, metaTagStrSlice) buf = binary.AppendUvarint(buf, uint64(len(vv))) @@ -664,12 +676,28 @@ func (d *metaDecoder) readValue() (any, error) { return nil, err } return v, nil + case metaTagUint64: + return d.uvarint() case metaTagFloat64: b, err := d.readBytes(8) if err != nil { return nil, err } return math.Float64frombits(binary.LittleEndian.Uint64(b)), nil + case metaTagF64Slice: + count, err := d.readCount() + if err != nil { + return nil, err + } + out := make([]float64, count) + for i := range out { + b, err := d.readBytes(8) + if err != nil { + return nil, err + } + out[i] = math.Float64frombits(binary.LittleEndian.Uint64(b)) + } + return out, nil case metaTagStrSlice: count, err := d.readCount() if err != nil { diff --git a/internal/graph/store_sqlite/meta_json_test.go b/internal/graph/store_sqlite/meta_json_test.go index c9b65bf45..f5792741a 100644 --- a/internal/graph/store_sqlite/meta_json_test.go +++ b/internal/graph/store_sqlite/meta_json_test.go @@ -158,6 +158,20 @@ func TestEncodeMetaEmpty(t *testing.T) { } } +func TestMetaRoundTripReachExactTypes(t *testing.T) { + wantConf := []float64{0, 0.625, 1} + got := roundTrip(t, map[string]any{ + "reach_build": uint64(1<<63 + 17), + "reach_d1_conf": wantConf, + }) + if build, ok := got["reach_build"].(uint64); !ok || build != uint64(1<<63+17) { + t.Fatalf("reach_build type/value = %T(%v), want uint64", got["reach_build"], got["reach_build"]) + } + if conf, ok := got["reach_d1_conf"].([]float64); !ok || !reflect.DeepEqual(conf, wantConf) { + t.Fatalf("reach_d1_conf type/value = %T(%v), want []float64(%v)", got["reach_d1_conf"], got["reach_d1_conf"], wantConf) + } +} + func assertType[T comparable](t *testing.T, m map[string]any, key string, want T) { t.Helper() v, ok := m[key] diff --git a/internal/graph/store_sqlite/meta_promoted_test.go b/internal/graph/store_sqlite/meta_promoted_test.go index 6eb80d702..3f0dcd2a8 100644 --- a/internal/graph/store_sqlite/meta_promoted_test.go +++ b/internal/graph/store_sqlite/meta_promoted_test.go @@ -234,7 +234,9 @@ func TestPromotedColumns_Migration(t *testing.T) { } _ = raw.Close() - s, err := Open(path) + // Keep this mechanical-column migration test on the historical v2 plan; + // shipped v3 intentionally rebuilds pre-v3 topology for integrity. + s, err := openWith(path, 2, schemaMigrations[:1], false) if err != nil { t.Fatalf("Open old-schema db: %v", err) } diff --git a/internal/graph/store_sqlite/mutation_preflight.go b/internal/graph/store_sqlite/mutation_preflight.go new file mode 100644 index 000000000..95ae850e1 --- /dev/null +++ b/internal/graph/store_sqlite/mutation_preflight.go @@ -0,0 +1,93 @@ +package store_sqlite + +import ( + "database/sql" + "strings" + + "github.com/zzet/gortex/internal/graph" +) + +type sqliteEdgeIdentity struct { + from string + to string + kind graph.EdgeKind + filePath string + line int +} + +func sqliteIdentityForEdge(e *graph.Edge) sqliteEdgeIdentity { + return sqliteEdgeIdentity{ + from: e.From, to: e.To, kind: e.Kind, filePath: e.FilePath, line: e.Line, + } +} + +func (s *Store) edgeExistsLocked(e *graph.Edge) (bool, error) { + var one int + err := s.stmtEdgeExists.QueryRow(e.From, e.To, string(e.Kind), e.FilePath, e.Line).Scan(&one) + if err == sql.ErrNoRows { + return false, nil + } + return err == nil, err +} + +// batchContainsNewEdgeLocked determines whether AddBatch can change topology. +// It runs only while an active analysis generation exists; ordinary indexing +// pays no preflight cost. Composite keys are checked in bounded queries so an +// idempotent enrichment batch preserves the expensive warm analysis cache. +func (s *Store) batchContainsNewEdgeLocked(edges []*graph.Edge) (bool, error) { + const keysPerQuery = 180 // five bound values per key; remain below SQLite's 999 limit + unique := make(map[sqliteEdgeIdentity]struct{}, len(edges)) + keys := make([]sqliteEdgeIdentity, 0, len(edges)) + for _, edge := range edges { + if edge == nil || graph.IsProxyID(edge.From) || graph.IsProxyID(edge.To) { + continue + } + key := sqliteIdentityForEdge(edge) + if _, exists := unique[key]; exists { + continue + } + unique[key] = struct{}{} + keys = append(keys, key) + } + if len(keys) == 0 { + return false, nil + } + + found := make(map[sqliteEdgeIdentity]struct{}, len(keys)) + for start := 0; start < len(keys); start += keysPerQuery { + end := start + keysPerQuery + if end > len(keys) { + end = len(keys) + } + chunk := keys[start:end] + clauses := make([]string, len(chunk)) + args := make([]any, 0, len(chunk)*5) + for i, key := range chunk { + clauses[i] = `(from_id = ? AND to_id = ? AND kind = ? AND file_path = ? AND line = ?)` + args = append(args, key.from, key.to, string(key.kind), key.filePath, key.line) + } + rows, err := s.db.Query( + `SELECT from_id, to_id, kind, file_path, line FROM edges WHERE `+strings.Join(clauses, " OR "), + args..., + ) + if err != nil { + return false, err + } + for rows.Next() { + var key sqliteEdgeIdentity + var kind string + if err := rows.Scan(&key.from, &key.to, &kind, &key.filePath, &key.line); err != nil { + _ = rows.Close() + return false, err + } + key.kind = graph.EdgeKind(kind) + found[key] = struct{}{} + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return false, err + } + _ = rows.Close() + } + return len(found) != len(keys), nil +} diff --git a/internal/graph/store_sqlite/mutation_receipt.go b/internal/graph/store_sqlite/mutation_receipt.go new file mode 100644 index 000000000..1334476bb --- /dev/null +++ b/internal/graph/store_sqlite/mutation_receipt.go @@ -0,0 +1,319 @@ +package store_sqlite + +import ( + "database/sql" + "errors" + "sort" + "strings" + + "github.com/zzet/gortex/internal/graph" +) + +// sqliteMutationReceiptState is guarded exclusively by Store.writeMu. Sharing +// that lock with every graph mutation makes receipt boundaries atomic without a +// second gate or lock-ordering edge. +type sqliteMutationReceiptState struct { + next graph.MutationReceiptToken + active map[graph.MutationReceiptToken]*sqliteMutationReceiptAccumulator +} + +type sqliteMutationReceiptAccumulator struct { + complete bool + resolutionRelevant bool + changedFiles map[string]struct{} + definitionFiles map[string]struct{} + targetNames map[string]struct{} + targetIDs map[string]struct{} + importCandidates map[string]struct{} +} + +type sqliteMutationNodeIdentity struct { + kind string + name string + qualName string + filePath string + repoPrefix string +} + +func newSQLiteMutationReceiptAccumulator() *sqliteMutationReceiptAccumulator { + return &sqliteMutationReceiptAccumulator{ + complete: true, + changedFiles: make(map[string]struct{}), + definitionFiles: make(map[string]struct{}), + targetNames: make(map[string]struct{}), + targetIDs: make(map[string]struct{}), + importCandidates: make(map[string]struct{}), + } +} + +func (a *sqliteMutationReceiptAccumulator) receipt() graph.MutationReceipt { + return graph.MutationReceipt{ + Complete: a.complete, + ResolutionRelevant: a.resolutionRelevant, + ChangedFiles: sortedSQLiteReceiptKeys(a.changedFiles), + DefinitionFiles: sortedSQLiteReceiptKeys(a.definitionFiles), + TargetNames: sortedSQLiteReceiptKeys(a.targetNames), + TargetIDs: sortedSQLiteReceiptKeys(a.targetIDs), + ImportCandidates: sortedSQLiteReceiptKeys(a.importCandidates), + } +} + +func sortedSQLiteReceiptKeys(values map[string]struct{}) []string { + if len(values) == 0 { + return nil + } + out := make([]string, 0, len(values)) + for value := range values { + if value != "" { + out = append(out, value) + } + } + sort.Strings(out) + return out +} + +// BeginMutationReceipt starts an independent observation window. writeMu is +// the receipt boundary: a concurrent writer is wholly before or wholly inside +// the window, never split across it. +func (s *Store) BeginMutationReceipt() graph.MutationReceiptToken { + s.writeMu.Lock() + defer s.writeMu.Unlock() + + s.mutationReceipts.next++ + if s.mutationReceipts.next == 0 { + s.mutationReceipts.next++ + } + if s.mutationReceipts.active == nil { + s.mutationReceipts.active = make(map[graph.MutationReceiptToken]*sqliteMutationReceiptAccumulator) + } + token := s.mutationReceipts.next + s.mutationReceipts.active[token] = newSQLiteMutationReceiptAccumulator() + return token +} + +// EndMutationReceipt closes one observation window. Unknown or already-ended +// tokens fail closed. +func (s *Store) EndMutationReceipt(token graph.MutationReceiptToken) graph.MutationReceipt { + s.writeMu.Lock() + defer s.writeMu.Unlock() + + acc := s.mutationReceipts.active[token] + if acc == nil { + return graph.MutationReceipt{Complete: false} + } + delete(s.mutationReceipts.active, token) + return acc.receipt() +} + +func (s *Store) hasActiveMutationReceiptsLocked() bool { + return len(s.mutationReceipts.active) != 0 +} + +func (s *Store) markMutationReceiptsIncompleteLocked() { + for _, acc := range s.mutationReceipts.active { + acc.complete = false + } +} + +func (s *Store) mergeMutationReceiptLocked(delta *sqliteMutationReceiptAccumulator) { + if delta == nil { + return + } + for _, acc := range s.mutationReceipts.active { + if !delta.complete { + acc.complete = false + } + acc.resolutionRelevant = acc.resolutionRelevant || delta.resolutionRelevant + mergeSQLiteReceiptSet(acc.changedFiles, delta.changedFiles) + mergeSQLiteReceiptSet(acc.definitionFiles, delta.definitionFiles) + mergeSQLiteReceiptSet(acc.targetNames, delta.targetNames) + mergeSQLiteReceiptSet(acc.targetIDs, delta.targetIDs) + mergeSQLiteReceiptSet(acc.importCandidates, delta.importCandidates) + } +} + +func mergeSQLiteReceiptSet(dst, src map[string]struct{}) { + for value := range src { + if value != "" { + dst[value] = struct{}{} + } + } +} + +func recordSQLiteAddedNode(acc *sqliteMutationReceiptAccumulator, n *graph.Node) { + if acc == nil || n == nil { + return + } + if n.ID != "" { + acc.targetIDs[n.ID] = struct{}{} + } + if n.Name != "" { + acc.targetNames[n.Name] = struct{}{} + } + if n.QualName != "" { + acc.targetNames[n.QualName] = struct{}{} + } + if n.FilePath != "" { + acc.changedFiles[n.FilePath] = struct{}{} + } + if !graph.IsReferenceableSymbol(n.Kind) { + return + } + acc.resolutionRelevant = true + if n.FilePath != "" { + acc.definitionFiles[n.FilePath] = struct{}{} + } else { + acc.complete = false + } +} + +func recordSQLiteAddedEdge(acc *sqliteMutationReceiptAccumulator, e *graph.Edge, exactFile string) { + if acc == nil || e == nil { + return + } + if e.To != "" { + acc.targetIDs[e.To] = struct{}{} + } + if name := graph.UnresolvedName(e.To); name != "" { + acc.targetNames[name] = struct{}{} + } + if e.Kind == graph.EdgeImports { + if name := graph.UnresolvedName(e.To); name != "" { + acc.importCandidates[name] = struct{}{} + } else if e.To != "" { + acc.importCandidates[e.To] = struct{}{} + } + if e.Alias != "" { + acc.importCandidates[e.Alias] = struct{}{} + } + } + if exactFile != "" { + acc.changedFiles[exactFile] = struct{}{} + } + if !graph.IsUnresolvedTarget(e.To) { + return + } + acc.resolutionRelevant = true + if exactFile == "" { + acc.complete = false + } +} + +func sqliteIdentityForNode(n *graph.Node) sqliteMutationNodeIdentity { + return sqliteMutationNodeIdentity{ + kind: string(n.Kind), + name: n.Name, + qualName: n.QualName, + filePath: n.FilePath, + repoPrefix: n.RepoPrefix, + } +} + +func (i sqliteMutationNodeIdentity) equalsNode(n *graph.Node) bool { + return i.kind == string(n.Kind) && i.name == n.Name && i.qualName == n.QualName && + i.filePath == n.FilePath && i.repoPrefix == n.RepoPrefix +} + +func (s *Store) publishSQLiteNodeWriteLocked(n *graph.Node, old sqliteMutationNodeIdentity, found, exact, changed bool) { + if !changed || !s.hasActiveMutationReceiptsLocked() { + return + } + if !exact { + s.markMutationReceiptsIncompleteLocked() + return + } + if found { + if !old.equalsNode(n) { + s.markMutationReceiptsIncompleteLocked() + } + return + } + delta := newSQLiteMutationReceiptAccumulator() + recordSQLiteAddedNode(delta, n) + s.mergeMutationReceiptLocked(delta) +} + +func (s *Store) publishSQLiteEdgeInsertLocked(e *graph.Edge, inserted bool) { + if !inserted || !s.hasActiveMutationReceiptsLocked() { + return + } + file := e.FilePath + delta := newSQLiteMutationReceiptAccumulator() + if file == "" { + source, found, err := s.mutationNodeIdentityLocked(e.From) + if err != nil { + delta.complete = false + } else if found { + file = source.filePath + } + } + recordSQLiteAddedEdge(delta, e, file) + s.mergeMutationReceiptLocked(delta) +} + +func (s *Store) mutationNodeIdentityLocked(id string) (sqliteMutationNodeIdentity, bool, error) { + var identity sqliteMutationNodeIdentity + err := s.db.QueryRow( + `SELECT kind, name, qual_name, file_path, repo_prefix FROM nodes WHERE id = ?`, id, + ).Scan(&identity.kind, &identity.name, &identity.qualName, &identity.filePath, &identity.repoPrefix) + if errors.Is(err, sql.ErrNoRows) { + return sqliteMutationNodeIdentity{}, false, nil + } + return identity, err == nil, err +} + +// mutationNodeIdentitiesTx preloads node identities in bounded batches. It is +// called only while receipts are active, so the steady indexing path pays no +// extra reads. +func mutationNodeIdentitiesTx(tx *sql.Tx, ids []string) (map[string]sqliteMutationNodeIdentity, error) { + const chunkSize = 900 + unique := make(map[string]struct{}, len(ids)) + ordered := make([]string, 0, len(ids)) + for _, id := range ids { + if id == "" { + continue + } + if _, exists := unique[id]; exists { + continue + } + unique[id] = struct{}{} + ordered = append(ordered, id) + } + identities := make(map[string]sqliteMutationNodeIdentity, len(ordered)) + for start := 0; start < len(ordered); start += chunkSize { + end := start + chunkSize + if end > len(ordered) { + end = len(ordered) + } + chunk := ordered[start:end] + placeholders := strings.TrimSuffix(strings.Repeat("?,", len(chunk)), ",") + args := make([]any, len(chunk)) + for i, id := range chunk { + args[i] = id + } + rows, err := tx.Query( + `SELECT id, kind, name, qual_name, file_path, repo_prefix FROM nodes WHERE id IN (`+placeholders+`)`, + args..., + ) + if err != nil { + return nil, err + } + for rows.Next() { + var id string + var identity sqliteMutationNodeIdentity + if err := rows.Scan(&id, &identity.kind, &identity.name, &identity.qualName, &identity.filePath, &identity.repoPrefix); err != nil { + _ = rows.Close() + return nil, err + } + identities[id] = identity + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return nil, err + } + _ = rows.Close() + } + return identities, nil +} + +var _ graph.MutationReceiptStore = (*Store)(nil) diff --git a/internal/graph/store_sqlite/mutation_receipt_test.go b/internal/graph/store_sqlite/mutation_receipt_test.go new file mode 100644 index 000000000..e6a1872ba --- /dev/null +++ b/internal/graph/store_sqlite/mutation_receipt_test.go @@ -0,0 +1,531 @@ +package store_sqlite + +import ( + "path/filepath" + "slices" + "sync" + "testing" + "time" + + "github.com/zzet/gortex/internal/graph" +) + +func openMutationReceiptStore(t *testing.T) *Store { + t.Helper() + store, err := Open(filepath.Join(t.TempDir(), "mutation-receipt.sqlite")) + if err != nil { + t.Fatalf("open SQLite store: %v", err) + } + t.Cleanup(func() { + if err := store.Close(); err != nil { + t.Errorf("close SQLite store: %v", err) + } + }) + return store +} + +func TestSQLiteMutationReceiptCapturesExactResolutionDelta(t *testing.T) { + store := openMutationReceiptStore(t) + token := store.BeginMutationReceipt() + store.AddBatch([]*graph.Node{ + {ID: "repo/src/a.go::Caller", Kind: graph.KindFunction, Name: "Caller", QualName: "pkg.Caller", FilePath: "src/a.go", RepoPrefix: "repo"}, + {ID: "repo/src/b.go::Load", Kind: graph.KindFunction, Name: "Load", QualName: "pkg.Load", FilePath: "src/b.go", RepoPrefix: "repo"}, + }, []*graph.Edge{{ + From: "repo/src/a.go::Caller", To: "repo::" + graph.UnresolvedMarker + "Load", Kind: graph.EdgeImports, + FilePath: "src/a.go", Alias: "loader", + }}) + receipt := store.EndMutationReceipt(token) + + if !receipt.Complete || !receipt.ResolutionRelevant { + t.Fatalf("receipt = %+v, want complete resolution delta", receipt) + } + if want := []string{"src/a.go", "src/b.go"}; !slices.Equal(receipt.ResolutionFiles(), want) { + t.Fatalf("resolution files = %v, want %v", receipt.ResolutionFiles(), want) + } + assertSQLiteReceiptContains(t, "target names", receipt.TargetNames, "Caller", "Load", "pkg.Caller", "pkg.Load") + assertSQLiteReceiptContains(t, "target ids", receipt.TargetIDs, + "repo/src/a.go::Caller", "repo/src/b.go::Load", "repo::"+graph.UnresolvedMarker+"Load") + assertSQLiteReceiptContains(t, "import candidates", receipt.ImportCandidates, "Load", "loader") +} + +func TestSQLiteMutationReceiptIdempotentAndAttributeOnlyWritesAreNeutral(t *testing.T) { + store := openMutationReceiptStore(t) + node := &graph.Node{ID: "repo/a.go::A", Kind: graph.KindFunction, Name: "A", FilePath: "a.go", RepoPrefix: "repo"} + edge := &graph.Edge{From: node.ID, To: "repo::" + graph.UnresolvedMarker + "B", Kind: graph.EdgeCalls, FilePath: "a.go"} + store.AddNode(node) + store.AddEdge(edge) + + token := store.BeginMutationReceipt() + store.AddNode(node) + store.AddEdge(edge) + store.PersistEdgeAttributes(&graph.Edge{ + From: edge.From, To: edge.To, Kind: edge.Kind, FilePath: edge.FilePath, + Confidence: 0.9, ConfidenceLabel: "HIGH", Origin: "semantic", Tier: "semantic", + }) + receipt := store.EndMutationReceipt(token) + if !receipt.Complete { + t.Fatalf("neutral receipt unexpectedly incomplete: %+v", receipt) + } + if receipt.ResolutionRelevant || len(receipt.ResolutionFiles()) != 0 { + t.Fatalf("neutral writes produced resolution delta: %+v", receipt) + } +} + +func TestSQLiteMutationReceiptIdentityChangingUpsertFailsClosed(t *testing.T) { + store := openMutationReceiptStore(t) + store.AddNode(&graph.Node{ID: "repo/a.go::A", Kind: graph.KindFunction, Name: "A", QualName: "pkg.A", FilePath: "a.go", RepoPrefix: "repo"}) + + token := store.BeginMutationReceipt() + store.AddNode(&graph.Node{ID: "repo/a.go::A", Kind: graph.KindFunction, Name: "Renamed", QualName: "pkg.Renamed", FilePath: "a.go", RepoPrefix: "repo"}) + if receipt := store.EndMutationReceipt(token); receipt.Complete { + t.Fatalf("identity-changing UPSERT returned complete receipt: %+v", receipt) + } +} + +func TestSQLiteMutationReceiptBatchRollbackPublishesNothing(t *testing.T) { + store := openMutationReceiptStore(t) + token := store.BeginMutationReceipt() + func() { + defer func() { + if recovered := recover(); recovered == nil { + t.Fatal("invalid batch unexpectedly succeeded") + } + }() + store.AddBatch([]*graph.Node{ + {ID: "repo/a.go::A", Kind: graph.KindFunction, Name: "A", FilePath: "a.go", RepoPrefix: "repo"}, + {ID: "repo/b.go::B", Kind: graph.KindFunction, Name: "B", FilePath: "b.go", RepoPrefix: "repo", Meta: map[string]any{"unsupported": make(chan int)}}, + }, nil) + }() + receipt := store.EndMutationReceipt(token) + if !receipt.Complete || receipt.ResolutionRelevant || len(receipt.ResolutionFiles()) != 0 { + t.Fatalf("rolled-back batch leaked receipt events: %+v", receipt) + } + if node := store.GetNode("repo/a.go::A"); node != nil { + t.Fatalf("rolled-back batch leaked node: %+v", node) + } +} + +func TestSQLiteMutationReceiptsOverlapWithoutStealingEvents(t *testing.T) { + store := openMutationReceiptStore(t) + outer := store.BeginMutationReceipt() + store.AddNode(&graph.Node{ID: "repo/a.go::A", Kind: graph.KindFunction, Name: "A", FilePath: "a.go", RepoPrefix: "repo"}) + inner := store.BeginMutationReceipt() + store.AddNode(&graph.Node{ID: "repo/b.go::B", Kind: graph.KindFunction, Name: "B", FilePath: "b.go", RepoPrefix: "repo"}) + outerReceipt := store.EndMutationReceipt(outer) + store.AddNode(&graph.Node{ID: "repo/c.go::C", Kind: graph.KindFunction, Name: "C", FilePath: "c.go", RepoPrefix: "repo"}) + innerReceipt := store.EndMutationReceipt(inner) + + assertSQLiteReceiptContains(t, "outer files", outerReceipt.ResolutionFiles(), "a.go", "b.go") + if slices.Contains(outerReceipt.ResolutionFiles(), "c.go") { + t.Fatalf("outer receipt observed mutation after it ended: %+v", outerReceipt) + } + assertSQLiteReceiptContains(t, "inner files", innerReceipt.ResolutionFiles(), "b.go", "c.go") + if slices.Contains(innerReceipt.ResolutionFiles(), "a.go") { + t.Fatalf("inner receipt observed mutation before it began: %+v", innerReceipt) + } +} + +func TestSQLiteMutationReceiptsConcurrentOverlap(t *testing.T) { + store := openMutationReceiptStore(t) + const workers = 12 + ready := sync.WaitGroup{} + ready.Add(workers) + start := make(chan struct{}) + var wg sync.WaitGroup + wg.Add(workers) + for i := 0; i < workers; i++ { + i := i + go func() { + defer wg.Done() + token := store.BeginMutationReceipt() + ready.Done() + <-start + id := string(rune('a' + i)) + store.AddNode(&graph.Node{ID: "repo/" + id + ".go::" + id, Kind: graph.KindFunction, Name: id, FilePath: id + ".go", RepoPrefix: "repo"}) + receipt := store.EndMutationReceipt(token) + if !receipt.Complete || !receipt.ResolutionRelevant || !slices.Contains(receipt.DefinitionFiles, id+".go") { + t.Errorf("concurrent receipt %d = %+v", i, receipt) + } + }() + } + ready.Wait() + close(start) + wg.Wait() +} + +func TestSQLiteMutationReceiptBoundaryWaitsForInFlightWrite(t *testing.T) { + store := openMutationReceiptStore(t) + token := store.BeginMutationReceipt() + + store.writeMu.Lock() + started := make(chan struct{}) + receiptCh := make(chan graph.MutationReceipt, 1) + go func() { + close(started) + receiptCh <- store.EndMutationReceipt(token) + }() + <-started + select { + case receipt := <-receiptCh: + store.writeMu.Unlock() + t.Fatalf("EndMutationReceipt overtook in-flight write: %+v", receipt) + case <-time.After(20 * time.Millisecond): + } + + node := &graph.Node{ID: "repo/a.go::A", Kind: graph.KindFunction, Name: "A", FilePath: "a.go", RepoPrefix: "repo"} + changed, err := store.insertNodeLocked(store.stmtInsertNode, node) + if err != nil { + store.writeMu.Unlock() + t.Fatalf("insert node: %v", err) + } + delta := newSQLiteMutationReceiptAccumulator() + recordSQLiteAddedNode(delta, node) + if changed { + store.mergeMutationReceiptLocked(delta) + } + store.writeMu.Unlock() + + select { + case receipt := <-receiptCh: + if !receipt.Complete || !receipt.ResolutionRelevant || !slices.Contains(receipt.DefinitionFiles, "a.go") { + t.Fatalf("receipt missed in-flight write: %+v", receipt) + } + case <-time.After(time.Second): + t.Fatal("EndMutationReceipt did not complete after write drained") + } +} + +func TestSQLiteMutationReceiptEdgeSourceFileFallback(t *testing.T) { + store := openMutationReceiptStore(t) + store.AddNode(&graph.Node{ID: "repo/a.go::A", Kind: graph.KindFunction, Name: "A", FilePath: "a.go", RepoPrefix: "repo"}) + + token := store.BeginMutationReceipt() + store.AddEdge(&graph.Edge{From: "repo/a.go::A", To: "repo::" + graph.UnresolvedMarker + "B", Kind: graph.EdgeCalls}) + receipt := store.EndMutationReceipt(token) + if !receipt.Complete || !receipt.ResolutionRelevant || !slices.Contains(receipt.ChangedFiles, "a.go") { + t.Fatalf("source-file fallback receipt = %+v", receipt) + } + + missing := store.BeginMutationReceipt() + store.AddEdge(&graph.Edge{From: "repo/missing.go::Missing", To: "repo::" + graph.UnresolvedMarker + "C", Kind: graph.EdgeCalls}) + if receipt := store.EndMutationReceipt(missing); receipt.Complete { + t.Fatalf("missing source file returned complete receipt: %+v", receipt) + } +} + +func TestSQLiteMutationReceiptNoOpMutationsStayComplete(t *testing.T) { + store := openMutationReceiptStore(t) + token := store.BeginMutationReceipt() + if store.RemoveEdge("missing", "missing", graph.EdgeCalls) { + t.Fatal("missing edge unexpectedly removed") + } + if err := store.PurgeRepo("missing"); err != nil { + t.Fatalf("purge missing repo: %v", err) + } + if err := store.RekeyRepoPrefix("missing", "new"); err != nil { + t.Fatalf("rekey missing repo: %v", err) + } + receipt := store.EndMutationReceipt(token) + if !receipt.Complete || receipt.ResolutionRelevant || len(receipt.ResolutionFiles()) != 0 { + t.Fatalf("no-op mutations changed receipt: %+v", receipt) + } +} + +func TestSQLiteMutationReceiptPurgeAndRekeyFailClosedAfterChange(t *testing.T) { + t.Run("purge", func(t *testing.T) { + store := openMutationReceiptStore(t) + store.AddNode(&graph.Node{ID: "repo/a.go::A", Kind: graph.KindFunction, Name: "A", FilePath: "a.go", RepoPrefix: "repo"}) + token := store.BeginMutationReceipt() + if err := store.PurgeRepo("repo"); err != nil { + t.Fatalf("purge repo: %v", err) + } + if receipt := store.EndMutationReceipt(token); receipt.Complete { + t.Fatalf("purge returned complete receipt: %+v", receipt) + } + }) + + t.Run("purge sidecar only", func(t *testing.T) { + store := openMutationReceiptStore(t) + if err := store.SetFileMtime("repo", "a.go", 1); err != nil { + t.Fatalf("seed file mtime: %v", err) + } + token := store.BeginMutationReceipt() + if err := store.PurgeRepo("repo"); err != nil { + t.Fatalf("purge repo: %v", err) + } + if receipt := store.EndMutationReceipt(token); receipt.Complete { + t.Fatalf("sidecar-only purge returned complete receipt: %+v", receipt) + } + }) + + t.Run("rekey", func(t *testing.T) { + store := openMutationReceiptStore(t) + if err := store.SetFileMtime("old", "a.go", 1); err != nil { + t.Fatalf("seed file mtime: %v", err) + } + token := store.BeginMutationReceipt() + if err := store.RekeyRepoPrefix("old", "new"); err != nil { + t.Fatalf("rekey repo: %v", err) + } + if receipt := store.EndMutationReceipt(token); receipt.Complete { + t.Fatalf("rekey returned complete receipt: %+v", receipt) + } + }) +} + +func TestSQLiteMutationReceiptBulkBoundariesFailClosed(t *testing.T) { + t.Run("begin", func(t *testing.T) { + store := openMutationReceiptStore(t) + token := store.BeginMutationReceipt() + store.BeginBulkLoad() + if receipt := store.EndMutationReceipt(token); receipt.Complete { + t.Fatalf("BeginBulkLoad returned complete receipt: %+v", receipt) + } + if err := store.FlushBulk(); err != nil { + t.Fatalf("flush bulk: %v", err) + } + }) + + t.Run("flush", func(t *testing.T) { + store := openMutationReceiptStore(t) + store.BeginBulkLoad() + token := store.BeginMutationReceipt() + if err := store.FlushBulk(); err != nil { + t.Fatalf("flush bulk: %v", err) + } + if receipt := store.EndMutationReceipt(token); receipt.Complete { + t.Fatalf("FlushBulk returned complete receipt: %+v", receipt) + } + }) +} + +func TestSQLiteMutationReceiptUnsupportedTopologyMutations(t *testing.T) { + t.Run("reindex edge", func(t *testing.T) { + store := openMutationReceiptStore(t) + edge := &graph.Edge{From: "repo/a.go::A", To: "repo/old.go::Old", Kind: graph.EdgeCalls, FilePath: "a.go", Line: 1} + store.AddEdge(edge) + updated := *edge + updated.To = "repo/new.go::New" + token := store.BeginMutationReceipt() + store.ReindexEdge(&updated, edge.To) + if receipt := store.EndMutationReceipt(token); receipt.Complete { + t.Fatalf("ReindexEdge returned complete receipt: %+v", receipt) + } + + noop := store.BeginMutationReceipt() + store.ReindexEdge(&updated, updated.To) + if receipt := store.EndMutationReceipt(noop); !receipt.Complete { + t.Fatalf("no-op ReindexEdge returned incomplete receipt: %+v", receipt) + } + }) + + t.Run("reindex edges", func(t *testing.T) { + store := openMutationReceiptStore(t) + edge := &graph.Edge{From: "repo/a.go::A", To: "repo/old.go::Old", Kind: graph.EdgeCalls, FilePath: "a.go", Line: 1} + store.AddEdge(edge) + updated := *edge + updated.To = "repo/new.go::New" + token := store.BeginMutationReceipt() + store.ReindexEdges([]graph.EdgeReindex{{Edge: &updated, OldTo: edge.To}}) + if receipt := store.EndMutationReceipt(token); receipt.Complete { + t.Fatalf("ReindexEdges returned complete receipt: %+v", receipt) + } + + noop := store.BeginMutationReceipt() + store.ReindexEdges([]graph.EdgeReindex{{Edge: &updated, OldTo: updated.To}}) + if receipt := store.EndMutationReceipt(noop); !receipt.Complete { + t.Fatalf("no-op ReindexEdges returned incomplete receipt: %+v", receipt) + } + }) + + t.Run("evict file", func(t *testing.T) { + store := openMutationReceiptStore(t) + store.AddNode(&graph.Node{ID: "repo/a.go::A", Kind: graph.KindFunction, Name: "A", FilePath: "a.go", RepoPrefix: "repo"}) + token := store.BeginMutationReceipt() + nodes, _ := store.EvictFile("a.go") + if nodes != 1 { + t.Fatalf("EvictFile nodes = %d, want 1", nodes) + } + if receipt := store.EndMutationReceipt(token); receipt.Complete { + t.Fatalf("EvictFile returned complete receipt: %+v", receipt) + } + + noop := store.BeginMutationReceipt() + store.EvictFile("missing.go") + if receipt := store.EndMutationReceipt(noop); !receipt.Complete { + t.Fatalf("no-op EvictFile returned incomplete receipt: %+v", receipt) + } + }) + + t.Run("evict repo", func(t *testing.T) { + store := openMutationReceiptStore(t) + store.AddNode(&graph.Node{ID: "repo/a.go::A", Kind: graph.KindFunction, Name: "A", FilePath: "a.go", RepoPrefix: "repo"}) + token := store.BeginMutationReceipt() + nodes, _ := store.EvictRepo("repo") + if nodes != 1 { + t.Fatalf("EvictRepo nodes = %d, want 1", nodes) + } + if receipt := store.EndMutationReceipt(token); receipt.Complete { + t.Fatalf("EvictRepo returned complete receipt: %+v", receipt) + } + + noop := store.BeginMutationReceipt() + store.EvictRepo("missing") + if receipt := store.EndMutationReceipt(noop); !receipt.Complete { + t.Fatalf("no-op EvictRepo returned incomplete receipt: %+v", receipt) + } + }) +} + +func TestSQLiteMutationReceiptReindexEdgeRollbackIsAtomic(t *testing.T) { + store := openMutationReceiptStore(t) + edge := &graph.Edge{From: "repo/a.go::A", To: "repo/old.go::Old", Kind: graph.EdgeCalls, FilePath: "a.go", Line: 1} + store.AddEdge(edge) + bad := *edge + bad.To = "repo/new.go::New" + bad.Meta = map[string]any{"unsupported": make(chan int)} + + token := store.BeginMutationReceipt() + func() { + defer func() { + if recovered := recover(); recovered == nil { + t.Fatal("invalid ReindexEdge unexpectedly succeeded") + } + }() + store.ReindexEdge(&bad, edge.To) + }() + receipt := store.EndMutationReceipt(token) + if !receipt.Complete || receipt.ResolutionRelevant || len(receipt.ResolutionFiles()) != 0 { + t.Fatalf("rolled-back ReindexEdge changed receipt: %+v", receipt) + } + edges := store.GetOutEdges(edge.From) + if len(edges) != 1 || edges[0].To != edge.To { + t.Fatalf("rolled-back ReindexEdge changed stored topology: %+v", edges) + } +} + +func TestSQLiteMutationReceiptProvenanceOnlyWriteIsNeutral(t *testing.T) { + store := openMutationReceiptStore(t) + edge := &graph.Edge{ + From: "repo/a.go::A", To: "repo/b.go::B", Kind: graph.EdgeCalls, + FilePath: "a.go", Line: 1, Origin: "heuristic", Tier: "heuristic", + } + store.AddEdge(edge) + token := store.BeginMutationReceipt() + if !store.SetEdgeProvenance(edge, "semantic") { + t.Fatal("SetEdgeProvenance did not update edge") + } + receipt := store.EndMutationReceipt(token) + if !receipt.Complete || receipt.ResolutionRelevant || len(receipt.ResolutionFiles()) != 0 { + t.Fatalf("provenance-only write changed receipt: %+v", receipt) + } +} + +func TestSQLiteDuplicateEdgeWritesPreservePersistedAnalysis(t *testing.T) { + t.Run("AddEdge", func(t *testing.T) { + store := openMutationReceiptStore(t) + edge := &graph.Edge{From: "repo/a.go::A", To: "repo/b.go::B", Kind: graph.EdgeCalls, FilePath: "a.go", Line: 1} + store.AddEdge(edge) + buildMinimalAnalysisGeneration(t, store, "add-edge-noop", 0, true) + before := store.AnalysisMutationRevision() + + store.AddEdge(edge) + if after := store.AnalysisMutationRevision(); after != before { + t.Fatalf("duplicate AddEdge advanced analysis revision: before=%d after=%d", before, after) + } + if _, found, err := store.LoadActiveAnalysisHeader(77); err != nil { + t.Fatalf("load active analysis: %v", err) + } else if !found { + t.Fatal("duplicate AddEdge discarded active analysis") + } + }) + + t.Run("AddBatch", func(t *testing.T) { + store := openMutationReceiptStore(t) + node := &graph.Node{ID: "repo/a.go::A", Kind: graph.KindFunction, Name: "A", FilePath: "a.go", RepoPrefix: "repo"} + edge := &graph.Edge{From: node.ID, To: "repo/b.go::B", Kind: graph.EdgeCalls, FilePath: "a.go", Line: 1} + store.AddBatch([]*graph.Node{node}, []*graph.Edge{edge}) + buildMinimalAnalysisGeneration(t, store, "add-batch-noop", 0, true) + before := store.AnalysisMutationRevision() + + store.AddBatch([]*graph.Node{nil, node}, []*graph.Edge{nil, edge}) + if after := store.AnalysisMutationRevision(); after != before { + t.Fatalf("duplicate AddBatch advanced analysis revision: before=%d after=%d", before, after) + } + if _, found, err := store.LoadActiveAnalysisHeader(77); err != nil { + t.Fatalf("load active analysis: %v", err) + } else if !found { + t.Fatal("duplicate AddBatch discarded active analysis") + } + }) + + t.Run("new AddEdge invalidates", func(t *testing.T) { + store := openMutationReceiptStore(t) + buildMinimalAnalysisGeneration(t, store, "add-edge-change", 0, true) + store.AddEdge(&graph.Edge{From: "repo/a.go::A", To: "repo/b.go::B", Kind: graph.EdgeCalls, FilePath: "a.go", Line: 1}) + if _, found, err := store.LoadActiveAnalysisHeader(77); err != nil { + t.Fatalf("load active analysis: %v", err) + } else if found { + t.Fatal("new AddEdge preserved stale active analysis") + } + }) + + t.Run("new AddBatch edge invalidates", func(t *testing.T) { + store := openMutationReceiptStore(t) + buildMinimalAnalysisGeneration(t, store, "add-batch-change", 0, true) + store.AddBatch(nil, []*graph.Edge{{From: "repo/a.go::A", To: "repo/b.go::B", Kind: graph.EdgeCalls, FilePath: "a.go", Line: 1}}) + if _, found, err := store.LoadActiveAnalysisHeader(77); err != nil { + t.Fatalf("load active analysis: %v", err) + } else if found { + t.Fatal("new AddBatch edge preserved stale active analysis") + } + }) + + t.Run("filtered batch", func(t *testing.T) { + store := openMutationReceiptStore(t) + buildMinimalAnalysisGeneration(t, store, "filtered-batch-noop", 0, true) + store.AddBatch([]*graph.Node{nil}, []*graph.Edge{nil}) + if _, found, err := store.LoadActiveAnalysisHeader(77); err != nil { + t.Fatalf("load active analysis: %v", err) + } else if !found { + t.Fatal("filtered AddBatch discarded active analysis") + } + }) +} + +func TestSQLitePurgeInvalidatesPersistedAnalysis(t *testing.T) { + store := openMutationReceiptStore(t) + store.AddNode(&graph.Node{ID: "repo/a.go::A", Kind: graph.KindFunction, Name: "A", FilePath: "a.go", RepoPrefix: "repo"}) + buildMinimalAnalysisGeneration(t, store, "purge", 0, true) + before := store.AnalysisMutationRevision() + + if err := store.PurgeRepo("repo"); err != nil { + t.Fatalf("purge repo: %v", err) + } + if after := store.AnalysisMutationRevision(); after <= before { + t.Fatalf("analysis revision did not advance: before=%d after=%d", before, after) + } + if _, found, err := store.LoadActiveAnalysisHeader(77); err != nil { + t.Fatalf("load active analysis: %v", err) + } else if found { + t.Fatal("purge left stale analysis generation active") + } +} + +func TestSQLiteMutationReceiptUnknownTokenFailsClosed(t *testing.T) { + store := openMutationReceiptStore(t) + token := store.BeginMutationReceipt() + _ = store.EndMutationReceipt(token) + if receipt := store.EndMutationReceipt(token); receipt.Complete { + t.Fatalf("already-ended token returned complete receipt: %+v", receipt) + } +} + +func assertSQLiteReceiptContains(t *testing.T, label string, got []string, want ...string) { + t.Helper() + for _, value := range want { + if !slices.Contains(got, value) { + t.Errorf("%s = %v, missing %q", label, got, value) + } + } +} diff --git a/internal/graph/store_sqlite/pool_config_test.go b/internal/graph/store_sqlite/pool_config_test.go new file mode 100644 index 000000000..5ddea6c14 --- /dev/null +++ b/internal/graph/store_sqlite/pool_config_test.go @@ -0,0 +1,51 @@ +package store_sqlite + +import ( + "context" + "database/sql" + "path/filepath" + "runtime" + "testing" +) + +func TestConnectionPoolBoundsPerConnectionMemory(t *testing.T) { + store, err := Open(filepath.Join(t.TempDir(), "store.sqlite")) + if err != nil { + t.Fatalf("Open: %v", err) + } + t.Cleanup(func() { _ = store.Close() }) + + expectedMax := runtime.NumCPU() + if expectedMax > sqliteMaxOpenConns { + expectedMax = sqliteMaxOpenConns + } + if got := store.db.Stats().MaxOpenConnections; got != expectedMax { + t.Fatalf("MaxOpenConnections = %d, want %d", got, expectedMax) + } + + // Hold every allowed connection concurrently, then return them together. + // database/sql must close all but one immediately; otherwise each retained + // modernc connection keeps its own mmap and page cache alive while idle. + ctx := context.Background() + conns := make([]*sql.Conn, 0, expectedMax) + for range expectedMax { + conn, err := store.db.Conn(ctx) + if err != nil { + t.Fatalf("Conn: %v", err) + } + conns = append(conns, conn) + } + for _, conn := range conns { + if err := conn.Close(); err != nil { + t.Fatalf("close connection: %v", err) + } + } + + stats := store.db.Stats() + if stats.Idle > sqliteMaxIdleConns { + t.Fatalf("idle connections = %d, want <= %d", stats.Idle, sqliteMaxIdleConns) + } + if stats.OpenConnections > sqliteMaxIdleConns { + t.Fatalf("open connections after burst = %d, want <= %d", stats.OpenConnections, sqliteMaxIdleConns) + } +} diff --git a/internal/graph/store_sqlite/schema.go b/internal/graph/store_sqlite/schema.go index 55f64c931..6c7ff643a 100644 --- a/internal/graph/store_sqlite/schema.go +++ b/internal/graph/store_sqlite/schema.go @@ -196,6 +196,160 @@ func ensureNodeGeneratedColumns(db *sql.DB) error { return nil } +// analysisGenerationSchemaSQL is the normalized, generation-addressed +// whole-graph analysis cache. Node IDs are copied into generation-local rows: +// they deliberately do not reference live nodes because incremental reindex +// deletes and recreates live rows, which would either erase an immutable +// snapshot or turn a tiny edit into a large FK cascade. Dense CSR and Leiden +// state remain blobs; point-queryable results use typed rows and compact +// surrogate node IDs. +const analysisGenerationSchemaSQL = ` +CREATE TABLE IF NOT EXISTS analysis_generations ( + generation_id INTEGER PRIMARY KEY AUTOINCREMENT, + format_version INTEGER NOT NULL, + build_revision INTEGER NOT NULL, + created_at_unix INTEGER NOT NULL, + state INTEGER NOT NULL CHECK (state IN (0, 1, 2)), + node_count INTEGER NOT NULL CHECK (node_count >= 0), + community_count INTEGER NOT NULL CHECK (community_count >= 0), + process_count INTEGER NOT NULL CHECK (process_count >= 0), + concept_count INTEGER NOT NULL CHECK (concept_count >= 0), + pagerank_max REAL NOT NULL DEFAULT 0, + authority_max REAL NOT NULL DEFAULT 0, + hub_max REAL NOT NULL DEFAULT 0, + modularity REAL NOT NULL DEFAULT 0, + processes_truncated INTEGER NOT NULL DEFAULT 0 CHECK (processes_truncated IN (0, 1)), + processes_truncation_reason TEXT NOT NULL DEFAULT '' +); +CREATE INDEX IF NOT EXISTS analysis_generations_by_state + ON analysis_generations(state, generation_id DESC); + +CREATE TABLE IF NOT EXISTS analysis_active_generation ( + slot INTEGER PRIMARY KEY CHECK (slot = 1), + generation_id INTEGER NOT NULL UNIQUE + REFERENCES analysis_generations(generation_id) ON DELETE RESTRICT +); + +CREATE TABLE IF NOT EXISTS analysis_generation_components ( + generation_id INTEGER NOT NULL + REFERENCES analysis_generations(generation_id) ON DELETE CASCADE, + component TEXT NOT NULL, + row_count INTEGER NOT NULL CHECK (row_count >= 0), + sealed_at_unix INTEGER NOT NULL, + PRIMARY KEY (generation_id, component) +) WITHOUT ROWID; + +CREATE TABLE IF NOT EXISTS analysis_communities ( + generation_id INTEGER NOT NULL + REFERENCES analysis_generations(generation_id) ON DELETE CASCADE, + community_id TEXT NOT NULL, + label TEXT NOT NULL DEFAULT '', + hub TEXT NOT NULL DEFAULT '', + parent_id TEXT NOT NULL DEFAULT '', + size INTEGER NOT NULL CHECK (size >= 0), + cohesion REAL NOT NULL DEFAULT 0, + PRIMARY KEY (generation_id, community_id) +) WITHOUT ROWID; + +CREATE TABLE IF NOT EXISTS analysis_community_files ( + generation_id INTEGER NOT NULL, + community_id TEXT NOT NULL, + ordinal INTEGER NOT NULL CHECK (ordinal >= 0), + file_path TEXT NOT NULL, + PRIMARY KEY (generation_id, community_id, ordinal), + FOREIGN KEY (generation_id, community_id) + REFERENCES analysis_communities(generation_id, community_id) ON DELETE CASCADE +) WITHOUT ROWID; + +CREATE TABLE IF NOT EXISTS analysis_nodes ( + id INTEGER PRIMARY KEY, + generation_id INTEGER NOT NULL + REFERENCES analysis_generations(generation_id) ON DELETE CASCADE, + node_id TEXT NOT NULL, + community_id TEXT, + pagerank REAL NOT NULL DEFAULT 0, + authority REAL NOT NULL DEFAULT 0, + hub REAL NOT NULL DEFAULT 0, + UNIQUE (generation_id, node_id), + FOREIGN KEY (generation_id, community_id) + REFERENCES analysis_communities(generation_id, community_id) +); +CREATE INDEX IF NOT EXISTS analysis_nodes_by_pagerank + ON analysis_nodes(generation_id, pagerank DESC, id ASC); +CREATE INDEX IF NOT EXISTS analysis_nodes_by_authority + ON analysis_nodes(generation_id, authority DESC, id ASC); +CREATE INDEX IF NOT EXISTS analysis_nodes_by_hub + ON analysis_nodes(generation_id, hub DESC, id ASC); +CREATE INDEX IF NOT EXISTS analysis_nodes_by_community + ON analysis_nodes(generation_id, community_id, node_id); + +CREATE TABLE IF NOT EXISTS analysis_processes ( + generation_id INTEGER NOT NULL + REFERENCES analysis_generations(generation_id) ON DELETE CASCADE, + process_id TEXT NOT NULL, + name TEXT NOT NULL DEFAULT '', + entry_point TEXT NOT NULL DEFAULT '', + step_count INTEGER NOT NULL CHECK (step_count >= 0), + score REAL NOT NULL DEFAULT 0, + truncated INTEGER NOT NULL DEFAULT 0 CHECK (truncated IN (0, 1)), + PRIMARY KEY (generation_id, process_id) +) WITHOUT ROWID; + +CREATE TABLE IF NOT EXISTS analysis_process_files ( + generation_id INTEGER NOT NULL, + process_id TEXT NOT NULL, + ordinal INTEGER NOT NULL CHECK (ordinal >= 0), + file_path TEXT NOT NULL, + PRIMARY KEY (generation_id, process_id, ordinal), + FOREIGN KEY (generation_id, process_id) + REFERENCES analysis_processes(generation_id, process_id) ON DELETE CASCADE +) WITHOUT ROWID; + +CREATE TABLE IF NOT EXISTS analysis_process_steps ( + generation_id INTEGER NOT NULL, + process_id TEXT NOT NULL, + ordinal INTEGER NOT NULL CHECK (ordinal >= 0), + node_rowid INTEGER NOT NULL + REFERENCES analysis_nodes(id), + depth INTEGER NOT NULL CHECK (depth >= 0), + PRIMARY KEY (generation_id, process_id, ordinal), + FOREIGN KEY (generation_id, process_id) + REFERENCES analysis_processes(generation_id, process_id) ON DELETE CASCADE +) WITHOUT ROWID; +CREATE INDEX IF NOT EXISTS analysis_process_steps_by_node + ON analysis_process_steps(generation_id, node_rowid, process_id); + +CREATE TABLE IF NOT EXISTS analysis_concepts ( + generation_id INTEGER NOT NULL + REFERENCES analysis_generations(generation_id) ON DELETE CASCADE, + token TEXT NOT NULL, + in_vocabulary INTEGER NOT NULL CHECK (in_vocabulary IN (0, 1)), + PRIMARY KEY (generation_id, token) +) WITHOUT ROWID; + +CREATE TABLE IF NOT EXISTS analysis_concept_relations ( + generation_id INTEGER NOT NULL, + token TEXT NOT NULL, + related_token TEXT NOT NULL, + rank INTEGER NOT NULL CHECK (rank >= 0), + PRIMARY KEY (generation_id, token, rank, related_token), + FOREIGN KEY (generation_id, token) + REFERENCES analysis_concepts(generation_id, token) ON DELETE CASCADE, + FOREIGN KEY (generation_id, related_token) + REFERENCES analysis_concepts(generation_id, token) ON DELETE CASCADE +) WITHOUT ROWID; +CREATE INDEX IF NOT EXISTS analysis_concept_relations_by_related + ON analysis_concept_relations(generation_id, related_token, rank, token); + +CREATE TABLE IF NOT EXISTS analysis_blobs ( + generation_id INTEGER NOT NULL + REFERENCES analysis_generations(generation_id) ON DELETE CASCADE, + component TEXT NOT NULL, + payload BLOB NOT NULL, + PRIMARY KEY (generation_id, component) +) WITHOUT ROWID; +` + // schemaSQL is the canonical DDL applied on Open. Statements are // idempotent (IF NOT EXISTS) so they run cleanly against a fresh DB // and against an existing one. @@ -515,4 +669,4 @@ CREATE TABLE IF NOT EXISTS symbol_fts_rowid ( -- repo_prefix / file_path / ordinal ride UNINDEXED so the per-repo and -- per-file staleness wipes hit literal columns without a b-tree. CREATE VIRTUAL TABLE IF NOT EXISTS content_fts USING fts5(node_id UNINDEXED, repo_prefix UNINDEXED, file_path UNINDEXED, ordinal UNINDEXED, body); -` +` + analysisGenerationSchemaSQL diff --git a/internal/graph/store_sqlite/schema_upgrade_test.go b/internal/graph/store_sqlite/schema_upgrade_test.go index b98ce2e08..33ee39232 100644 --- a/internal/graph/store_sqlite/schema_upgrade_test.go +++ b/internal/graph/store_sqlite/schema_upgrade_test.go @@ -32,8 +32,8 @@ func hasNodeColumn(t *testing.T, db *sql.DB, col string) bool { } // TestOpenUpgradesPreDataClassStore is the backward-compatibility proof for the -// promoted data_class column: an existing v1 store written before the column -// existed must Open cleanly (ensureNodeColumns ALTERs the column in before the +// promoted data_class column: under the historical v2 plan, an existing v1 +// store written before the column existed must open cleanly (ensureNodeColumns ALTERs the column in before the // node statements are prepared), keep its rows, and immediately get the working // SQL-level content filter — all WITHOUT a schema_version bump or a reindex. // @@ -71,9 +71,10 @@ func TestOpenUpgradesPreDataClassStore(t *testing.T) { require.Equal(t, 1, v, "the simulated old store must sit at the v1 baseline") }) - // 3. Reopen with the current binary. ensureNodeColumns must re-add the - // column before prepare() references it, so Open succeeds without a wipe. - s2, err := Open(path) + // 3. Reopen under the historical v2 plan. ensureNodeColumns must re-add the + // column before prepare() references it, so open succeeds without a wipe. + // Shipped v3 deliberately rebuilds every older topology cache. + s2, err := openWith(path, 2, schemaMigrations[:1], false) require.NoError(t, err, "Open must upgrade a pre-data_class store in place, not fail on the missing column") t.Cleanup(func() { _ = s2.Close() }) diff --git a/internal/graph/store_sqlite/schema_version.go b/internal/graph/store_sqlite/schema_version.go index 8dfaa0a64..59a73e85b 100644 --- a/internal/graph/store_sqlite/schema_version.go +++ b/internal/graph/store_sqlite/schema_version.go @@ -32,7 +32,7 @@ import ( // index changes in a way an old on-disk DB would not already have, and append a // matching schemaMigrations entry describing how to bring an older store // forward (in place, or by rebuild). -const currentSchemaVersion = 2 +const currentSchemaVersion = 4 // schemaMigration is one forward step. Exactly one strategy applies: // - rebuild=true: the change introduces structure/data that can only come @@ -56,6 +56,28 @@ type schemaMigration struct { // entries for version 2 and up as the schema evolves. var schemaMigrations = []schemaMigration{ {version: 2, name: "dedupe fn-value placeholder edges", inPlace: dedupeFnValuePlaceholderEdges}, + // Versions through v2 wrote node updates with INSERT OR REPLACE. REPLACE + // has delete semantics and can invalidate incident-edge integrity when + // foreign-key enforcement is enabled by a host/connection. Deleted edges + // cannot be reconstructed from the remaining graph rows, so this is an + // explicit source-reindex boundary rather than a misleading in-place fix. + {version: 3, name: "restore topology after node replace writes", rebuild: true}, + {version: 4, name: "add normalized analysis generations", inPlace: createAnalysisGenerationTables}, +} + +// createAnalysisGenerationTables is the explicit v4 in-place migration. +// schemaSQL runs first and is intentionally idempotent, so this is a no-op on +// fresh stores and a defensive create on older stores opened by migration +// tests or future alternate open paths. +func createAnalysisGenerationTables(tx *sql.Tx) error { + if _, err := tx.Exec(analysisGenerationSchemaSQL); err != nil { + return err + } + // Builds used during development briefly created a blob-only cache under + // schema v3. It was never released; remove the artifact instead of carrying + // a conversion or compatibility API into v4. + _, err := tx.Exec(`DROP TABLE IF EXISTS analysis_cache`) + return err } // dedupeFnValuePlaceholderEdges collapses duplicate function-as-value gate diff --git a/internal/graph/store_sqlite/schema_version_test.go b/internal/graph/store_sqlite/schema_version_test.go index f080bbaad..2b1216f1f 100644 --- a/internal/graph/store_sqlite/schema_version_test.go +++ b/internal/graph/store_sqlite/schema_version_test.go @@ -46,10 +46,11 @@ func TestOpenStampsFreshDB(t *testing.T) { } } -// TestOpenBaselineStampsOldDBWithoutWipe: a pre-versioning store (user_version -// 0, reconcilable to current by schemaSQL + ensureNodeColumns) is stamped in -// place — its data must survive, not be wiped. -func TestOpenBaselineStampsOldDBWithoutWipe(t *testing.T) { +// TestOpenPreVersionStoreRequiresRebuild: once a rebuild boundary ships, an +// existing user_version=0 graph cannot be confused with a brand-new empty DB. +// The default open preserves it and returns ErrSchemaRebuildRequired; the +// exclusive daemon path wipes it and reports NeedsRebuild. +func TestOpenPreVersionStoreRequiresRebuild(t *testing.T) { path := filepath.Join(t.TempDir(), "store.sqlite") // Create the store, then simulate a pre-versioning DB: a row + user_version 0. @@ -69,16 +70,25 @@ func TestOpenBaselineStampsOldDBWithoutWipe(t *testing.T) { } }) - s2, err := Open(path) + if _, err := Open(path); !errors.Is(err, ErrSchemaRebuildRequired) { + t.Fatalf("reopen old DB error = %v, want ErrSchemaRebuildRequired", err) + } + withRawDB(t, path, func(db *sql.DB) { + if n := nodeCount(t, db); n != 1 { + t.Fatalf("refused rebuild changed node count to %d, want 1", n) + } + }) + + s2, err := Open(path, WithRebuild()) if err != nil { - t.Fatalf("reopen old DB: %v", err) + t.Fatalf("exclusive rebuild: %v", err) } defer s2.Close() - if v, _ := readUserVersion(s2.db); v != currentSchemaVersion { - t.Fatalf("user_version after baseline = %d, want %d", v, currentSchemaVersion) + if !s2.NeedsRebuild() { + t.Fatal("rebuilt pre-version store did not report NeedsRebuild") } - if n := nodeCount(t, s2.db); n != 1 { - t.Fatalf("node count after baseline = %d, want 1 (data must NOT be wiped)", n) + if n := nodeCount(t, s2.db); n != 0 { + t.Fatalf("node count after integrity rebuild = %d, want 0", n) } } @@ -191,6 +201,52 @@ func TestPlanSchemaMigration(t *testing.T) { } } +func TestOpenV2RequiresTopologyIntegrityRebuild(t *testing.T) { + path := filepath.Join(t.TempDir(), "store.sqlite") + s, err := Open(path) + if err != nil { + t.Fatalf("first open: %v", err) + } + if _, err := s.db.Exec(`INSERT INTO nodes (id, kind, name, file_path) VALUES ('n1','func','Foo','f.go')`); err != nil { + t.Fatalf("seed node: %v", err) + } + if err := s.Close(); err != nil { + t.Fatalf("close: %v", err) + } + withRawDB(t, path, func(db *sql.DB) { + if _, err := db.Exec(`PRAGMA user_version = 2`); err != nil { + t.Fatalf("set v2: %v", err) + } + }) + + if _, err := Open(path); !errors.Is(err, ErrSchemaRebuildRequired) { + t.Fatalf("default v2 open error = %v, want ErrSchemaRebuildRequired", err) + } + withRawDB(t, path, func(db *sql.DB) { + if v, err := readUserVersion(db); err != nil || v != 2 { + t.Fatalf("refused v2 version = %d (err %v), want 2", v, err) + } + if n := nodeCount(t, db); n != 1 { + t.Fatalf("refused v2 rebuild changed node count to %d, want 1", n) + } + }) + + rebuilt, err := Open(path, WithRebuild()) + if err != nil { + t.Fatalf("exclusive v2 rebuild: %v", err) + } + defer rebuilt.Close() + if !rebuilt.NeedsRebuild() { + t.Fatal("v2 integrity rebuild did not report NeedsRebuild") + } + if v, err := readUserVersion(rebuilt.db); err != nil || v != currentSchemaVersion { + t.Fatalf("rebuilt version = %d (err %v), want %d", v, err, currentSchemaVersion) + } + if n := nodeCount(t, rebuilt.db); n != 0 { + t.Fatalf("rebuilt v2 node count = %d, want 0", n) + } +} + // TestApplyInPlaceMigrations: steps run in order and commit; a failing step // rolls the whole transaction back. func TestApplyInPlaceMigrations(t *testing.T) { @@ -506,14 +562,16 @@ func TestOpenDedupesFnValuePlaceholders(t *testing.T) { } }) - s2, err := Open(path) + // Exercise the historical v1->v2 in-place migration in isolation. The + // shipped v3 boundary intentionally rebuilds every v2-or-older graph. + s2, err := openWith(path, 2, schemaMigrations[:1], false) if err != nil { t.Fatalf("reopen for dedup: %v", err) } defer s2.Close() - if v, _ := readUserVersion(s2.db); v != currentSchemaVersion { - t.Fatalf("user_version after dedup = %d, want %d", v, currentSchemaVersion) + if v, _ := readUserVersion(s2.db); v != 2 { + t.Fatalf("user_version after dedup = %d, want 2", v) } present := func(id int) bool { diff --git a/internal/graph/store_sqlite/store.go b/internal/graph/store_sqlite/store.go index 7c2bb2a01..9ccf0b7c2 100644 --- a/internal/graph/store_sqlite/store.go +++ b/internal/graph/store_sqlite/store.go @@ -52,6 +52,10 @@ type Store struct { // concurrency test predictable. writeMu sync.Mutex + // mutationReceipts is guarded only by writeMu, making Begin/End atomic + // with every durable graph write without another lock-ordering edge. + mutationReceipts sqliteMutationReceiptState + // resolveMu is the resolver-coordination mutex returned by // ResolveMutex. Held by cross-repo / temporal / external resolver // passes to keep their edge mutations from interleaving. Separate @@ -61,6 +65,13 @@ type Store struct { edgeIdentityRevs atomic.Int64 + // analysisMutationRevision closes the in-process race between loading or + // computing a persisted whole-graph analysis and a concurrent graph write. + // analysisGenerationPresent is guarded by writeMu and avoids a redundant cache + // DELETE on every row after the first fail-closed invalidation. + analysisMutationRevision atomic.Uint64 + analysisGenerationPresent bool + // wiped records that Open dropped an incompatible on-disk DB and // recreated it empty (a schema-version mismatch that an in-place ALTER // could not satisfy). Surfaced via NeedsRebuild so the daemon forces a @@ -220,6 +231,26 @@ var ErrSchemaRebuildRequired = errors.New("store_sqlite: on-disk schema is incom // registry, and rebuild permission so tests can drive the baseline / in-place // / rebuild arms without mutating package globals. Open passes the package // defaults (currentSchemaVersion, schemaMigrations) and the WithRebuild flag. +const ( + // Each modernc SQLite connection can map up to sqliteMmapSizeBytes and + // grow a separate page cache. Bounding the pool prevents a read burst on + // a high-core machine from multiplying clean file mappings into several + // GiB of resident address space. Four readers retained full-scan + // throughput in the pool benchmark while cutting the 16-reader peak by + // roughly 75%. + sqliteMaxOpenConns = 4 + sqliteMaxIdleConns = 1 +) + +func configureConnectionPool(db *sql.DB) { + maxOpen := runtime.NumCPU() + if maxOpen > sqliteMaxOpenConns { + maxOpen = sqliteMaxOpenConns + } + db.SetMaxOpenConns(maxOpen) + db.SetMaxIdleConns(sqliteMaxIdleConns) +} + func openWith(path string, current int, migrations []schemaMigration, allowRebuild bool) (*Store, error) { // Pragmas: WAL + synchronous=NORMAL is the standard write-heavy // embedded tradeoff. cache_size(-32768) gives each pooled connection a @@ -235,7 +266,7 @@ func openWith(path string, current int, migrations []schemaMigration, allowRebui // it), which is how a 535 MB DB ends up with an 11 GB -wal. This bounds // the file even between the explicit TRUNCATE checkpoints runCheckpointLoop // issues, and even if that loop is not running. - dsn := path + "?_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(OFF)&_pragma=cache_size(-32768)&_pragma=temp_store(MEMORY)&_pragma=mmap_size(268435456)&_pragma=journal_size_limit(67108864)" + dsn := path + "?_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(ON)&_pragma=cache_size(-32768)&_pragma=temp_store(MEMORY)&_pragma=mmap_size(268435456)&_pragma=journal_size_limit(67108864)" db, err := sql.Open("sqlite", dsn) if err != nil { return nil, fmt.Errorf("sqlite open: %w", err) @@ -251,7 +282,7 @@ func openWith(path string, current int, migrations []schemaMigration, allowRebui // the pool opens picks up the journal-mode / synchronous / // busy-timeout pragmas from the DSN above, so we don't need to // pin one connection to "remember" them. - db.SetMaxOpenConns(runtime.NumCPU()) + configureConnectionPool(db) // Reconcile the on-disk schema version before applying schemaSQL. The graph // store is a rebuildable cache, so an incompatible (older needing a rebuild @@ -264,6 +295,22 @@ func openWith(path string, current int, migrations []schemaMigration, allowRebui return nil, fmt.Errorf("sqlite read schema version: %w", err) } plan := planSchemaMigrationWith(stored, current, migrations) + // A rebuild migration applies to an existing pre-versioning database, but + // not to the brand-new empty file sql.Open just created. Distinguish those + // two user_version=0 cases before requiring destructive-rebuild authority. + // An existing nodes/edges schema may already contain derived topology and + // must take the conservative rebuild path even when its current row count is + // zero; absence of both tables is the only safe fresh-store proof. + if stored == 0 && plan.wipe && !isMemoryPath(path) { + existing, probeErr := hasGraphStoreTables(db) + if probeErr != nil { + _ = db.Close() + return nil, fmt.Errorf("sqlite probe existing graph schema: %w", probeErr) + } + if !existing { + plan = schemaPlan{stamp: true} + } + } didWipe := false if plan.wipe && !isMemoryPath(path) { // Refuse the destructive rebuild unless the caller proved it holds @@ -283,7 +330,7 @@ func openWith(path string, current int, migrations []schemaMigration, allowRebui if err != nil { return nil, fmt.Errorf("sqlite reopen for rebuild: %w", err) } - db.SetMaxOpenConns(runtime.NumCPU()) + configureConnectionPool(db) didWipe = true } @@ -359,6 +406,18 @@ func openWith(path string, current int, migrations []schemaMigration, allowRebui return nil, fmt.Errorf("sqlite stamp schema version: %w", err) } } + // A schema transition invalidates any generation produced against the old + // graph shape. The v4 migration also drops the unreleased blob-only table. + if stored != current { + if _, err := db.Exec(`UPDATE analysis_generations SET state = ? WHERE generation_id IN (SELECT generation_id FROM analysis_active_generation)`, analysisGenerationStale); err != nil { + _ = db.Close() + return nil, fmt.Errorf("sqlite stale analysis generation after migration: %w", err) + } + if _, err := db.Exec(`DELETE FROM analysis_active_generation`); err != nil { + _ = db.Close() + return nil, fmt.Errorf("sqlite clear active analysis generation after migration: %w", err) + } + } s := &Store{db: db, dbPath: path, wiped: didWipe} // Initialise the bundle cache at construction so its pointer is @@ -367,6 +426,10 @@ func openWith(path string, current int, migrations []schemaMigration, allowRebui // own mutex-guarded maps, not on the Store field. The cache stays // inert (every lookup a miss) until the daemon supplies fingerprints. s.bundles = newBundleCache() + if err := s.initAnalysisGenerationState(); err != nil { + _ = db.Close() + return nil, fmt.Errorf("sqlite analysis generation state: %w", err) + } if err := s.prepare(); err != nil { _ = db.Close() return nil, fmt.Errorf("sqlite prepare: %w", err) @@ -382,6 +445,12 @@ func openWith(path string, current int, migrations []schemaMigration, allowRebui return s, nil } +func hasGraphStoreTables(db *sql.DB) (bool, error) { + var count int + err := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name IN ('nodes','edges')`).Scan(&count) + return count > 0, err +} + // walCheckpointInterval is how often runCheckpointLoop drains the WAL into // the main DB and truncates the -wal file. Five minutes keeps the file // bounded under steady writes without making the checkpoint itself a hot @@ -482,8 +551,40 @@ func (s *Store) prepare() error { const nodeCols = lookupNodeCols + // Never use INSERT OR REPLACE here. SQLite implements REPLACE as + // DELETE+INSERT; the DELETE fires the nodes->edges ON DELETE CASCADE and + // silently erases every incident edge when a caller only intends to update + // node metadata (reach.Lookup does exactly that when publishing its cache). + // A true UPSERT updates the existing row in place and therefore preserves + // graph topology. prep(&s.stmtInsertNode, - `INSERT OR REPLACE INTO nodes (`+nodeCols+`) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`) + `INSERT INTO nodes (`+nodeCols+`) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(id) DO UPDATE SET + kind=excluded.kind, name=excluded.name, qual_name=excluded.qual_name, + file_path=excluded.file_path, start_line=excluded.start_line, end_line=excluded.end_line, + start_column=excluded.start_column, end_column=excluded.end_column, + language=excluded.language, repo_prefix=excluded.repo_prefix, + workspace_id=excluded.workspace_id, project_id=excluded.project_id, + signature=excluded.signature, visibility=excluded.visibility, doc=excluded.doc, + external=excluded.external, return_type=excluded.return_type, + is_async=excluded.is_async, is_static=excluded.is_static, + is_abstract=excluded.is_abstract, is_exported=excluded.is_exported, + updated_at=excluded.updated_at, data_class=excluded.data_class, + semantic_type=excluded.semantic_type, semantic_source=excluded.semantic_source, + meta=excluded.meta + WHERE nodes.kind IS NOT excluded.kind OR nodes.name IS NOT excluded.name OR + nodes.qual_name IS NOT excluded.qual_name OR nodes.file_path IS NOT excluded.file_path OR + nodes.start_line IS NOT excluded.start_line OR nodes.end_line IS NOT excluded.end_line OR + nodes.start_column IS NOT excluded.start_column OR nodes.end_column IS NOT excluded.end_column OR + nodes.language IS NOT excluded.language OR nodes.repo_prefix IS NOT excluded.repo_prefix OR + nodes.workspace_id IS NOT excluded.workspace_id OR nodes.project_id IS NOT excluded.project_id OR + nodes.signature IS NOT excluded.signature OR nodes.visibility IS NOT excluded.visibility OR + nodes.doc IS NOT excluded.doc OR nodes.external IS NOT excluded.external OR + nodes.return_type IS NOT excluded.return_type OR nodes.is_async IS NOT excluded.is_async OR + nodes.is_static IS NOT excluded.is_static OR nodes.is_abstract IS NOT excluded.is_abstract OR + nodes.is_exported IS NOT excluded.is_exported OR nodes.updated_at IS NOT excluded.updated_at OR + nodes.data_class IS NOT excluded.data_class OR nodes.semantic_type IS NOT excluded.semantic_type OR + nodes.semantic_source IS NOT excluded.semantic_source OR nodes.meta IS NOT excluded.meta`) prep(&s.stmtGetNode, `SELECT `+nodeCols+` FROM nodes WHERE id = ?`) prep(&s.stmtGetNodeByQual, @@ -649,6 +750,26 @@ func scanNodeLight(scanner interface { return &n, nil } +// scanNodeSummary scans the identity/location projection used by whole-graph +// algorithms. It deliberately leaves Meta nil: even promoted docs and +// signatures are unnecessary for adjacency, centrality, communities, and +// concept-name mining, and allocating them once per node dominates large +// SQLite scans. +func scanNodeSummary(scanner interface { + Scan(...any) error +}) (*graph.Node, error) { + var n graph.Node + err := scanner.Scan( + &n.ID, &n.Kind, &n.Name, &n.QualName, &n.FilePath, + &n.StartLine, &n.EndLine, &n.StartColumn, &n.EndColumn, &n.Language, + &n.RepoPrefix, &n.WorkspaceID, &n.ProjectID, + ) + if err != nil { + return nil, err + } + return &n, nil +} + func scanEdge(scanner interface { Scan(...any) error }) (*graph.Edge, error) { @@ -707,9 +828,9 @@ func scanEdgeLight(scanner interface { // -- writes --------------------------------------------------------------- -// AddNode inserts or replaces a node. Idempotent on the id column -- -// re-adding the same id with new content does a last-write-wins -// update, matching the in-memory store's behaviour. +// AddNode inserts or updates a node in place. Idempotent on the id column -- +// re-adding the same id with new content does a last-write-wins update while +// preserving incident edge rows, matching the in-memory store's behaviour. func (s *Store) AddNode(n *graph.Node) { if n == nil || n.ID == "" { return @@ -724,22 +845,40 @@ func (s *Store) AddNode(n *graph.Node) { } s.writeMu.Lock() defer s.writeMu.Unlock() - if err := s.insertNodeLocked(s.stmtInsertNode, n); err != nil { + + var ( + oldIdentity sqliteMutationNodeIdentity + oldFound bool + identityExact = true + ) + if s.hasActiveMutationReceiptsLocked() { + var err error + oldIdentity, oldFound, err = s.mutationNodeIdentityLocked(n.ID) + identityExact = err == nil + } + if !s.invalidateAnalysisBeforeNodeMutationLocked(n) { + return + } + changed, err := s.insertNodeLocked(s.stmtInsertNode, n) + if err != nil { // graph.Store.AddNode has no error channel; the in-memory // store can't fail either. We swallow the error here for API // parity; surface as a panic only on a clearly catastrophic // failure (closed DB), not on a transient busy. panicOnFatal(err) + return } + s.finishAnalysisMutationLocked(changed) + s.publishSQLiteNodeWriteLocked(n, oldIdentity, oldFound, identityExact, changed) } -func (s *Store) insertNodeLocked(stmt *sql.Stmt, n *graph.Node) error { +func (s *Store) insertNodeLocked(stmt *sql.Stmt, n *graph.Node) (bool, error) { p, blobMeta := extractPromotedMeta(n.Meta) metaBlob, err := encodeMeta(blobMeta) if err != nil { - return err + return false, err } - _, err = stmt.Exec( + res, err := stmt.Exec( n.ID, string(n.Kind), n.Name, n.QualName, n.FilePath, n.StartLine, n.EndLine, n.StartColumn, n.EndColumn, n.Language, n.RepoPrefix, n.WorkspaceID, n.ProjectID, @@ -747,7 +886,11 @@ func (s *Store) insertNodeLocked(stmt *sql.Stmt, n *graph.Node) error { p.isAsync, p.isStatic, p.isAbstract, p.isExported, p.updatedAt, p.dataClass, p.semanticType, p.semanticSource, metaBlob, ) - return err + if err != nil { + return false, err + } + changed, err := res.RowsAffected() + return changed > 0, err } // AddEdge inserts an edge. Idempotent on the logical edge key (from, @@ -770,27 +913,48 @@ func (s *Store) AddEdge(e *graph.Edge) { } s.writeMu.Lock() defer s.writeMu.Unlock() - if err := s.insertEdgeLocked(s.stmtInsertEdge, e); err != nil { + if s.analysisGenerationPresent { + exists, err := s.edgeExistsLocked(e) + if err != nil { + panicOnFatal(err) + return + } + if exists { + return + } + } + if !s.invalidateAnalysisBeforeMutationLocked() { + return + } + changed, err := s.insertEdgeLocked(s.stmtInsertEdge, e) + if err != nil { panicOnFatal(err) + return } + s.finishAnalysisMutationLocked(changed) + s.publishSQLiteEdgeInsertLocked(e, changed) } -func (s *Store) insertEdgeLocked(stmt *sql.Stmt, e *graph.Edge) error { +func (s *Store) insertEdgeLocked(stmt *sql.Stmt, e *graph.Edge) (bool, error) { p, blobMeta := extractPromotedEdgeMeta(e.Meta) metaBlob, err := encodeMeta(blobMeta) if err != nil { - return err + return false, err } var crossRepo int64 if e.CrossRepo { crossRepo = 1 } - _, err = stmt.Exec( + res, err := stmt.Exec( e.From, e.To, string(e.Kind), e.FilePath, e.Line, e.Confidence, e.ConfidenceLabel, e.Origin, e.Tier, crossRepo, metaBlob, p.resolveTerminal, p.resolveTerminalReason, ) - return err + if err != nil { + return false, err + } + changed, err := res.RowsAffected() + return changed > 0, err } // AddBatch inserts nodes and edges in a single transaction -- the @@ -802,6 +966,54 @@ func (s *Store) AddBatch(nodes []*graph.Node, edges []*graph.Edge) { s.writeMu.Lock() defer s.writeMu.Unlock() + hasGraphInput := false + for _, n := range nodes { + if n != nil && n.ID != "" && !graph.IsProxyNode(n) { + hasGraphInput = true + break + } + } + if !hasGraphInput { + for _, e := range edges { + if e != nil && !graph.IsProxyID(e.From) && !graph.IsProxyID(e.To) { + hasGraphInput = true + break + } + } + } + if !hasGraphInput { + return + } + + // Preserve an active analysis generation for a genuinely idempotent batch. + // Node preflights reuse the exact analysis-facing field comparison; edge + // identities are checked in bounded queries. writeMu keeps the preflight, + // invalidation, and subsequent transaction one atomic visibility interval. + if s.analysisGenerationPresent { + for _, n := range nodes { + if n == nil || n.ID == "" || graph.IsProxyNode(n) { + continue + } + if !s.invalidateAnalysisBeforeNodeMutationLocked(n) { + return + } + if !s.analysisGenerationPresent { + break + } + } + if s.analysisGenerationPresent { + containsNewEdge, err := s.batchContainsNewEdgeLocked(edges) + if err != nil { + panicOnFatal(err) + return + } + if containsNewEdge && !s.invalidateAnalysisBeforeMutationLocked() { + return + } + } + } + changed := false + tx, err := s.beginWrite() if err != nil { panicOnFatal(err) @@ -814,6 +1026,31 @@ func (s *Store) AddBatch(nodes []*graph.Node, edges []*graph.Edge) { } }() + var ( + receiptDelta *sqliteMutationReceiptAccumulator + identities map[string]sqliteMutationNodeIdentity + identityExact = true + ) + if s.hasActiveMutationReceiptsLocked() { + receiptDelta = newSQLiteMutationReceiptAccumulator() + ids := make([]string, 0, len(nodes)+len(edges)) + for _, n := range nodes { + if n != nil && n.ID != "" && !graph.IsProxyNode(n) { + ids = append(ids, n.ID) + } + } + for _, e := range edges { + if e != nil && e.FilePath == "" && !graph.IsProxyID(e.From) && !graph.IsProxyID(e.To) { + ids = append(ids, e.From) + } + } + identities, err = mutationNodeIdentitiesTx(tx, ids) + if err != nil { + identityExact = false + identities = make(map[string]sqliteMutationNodeIdentity) + } + } + insertNode := tx.Stmt(s.stmtInsertNode) defer insertNode.Close() insertEdge := tx.Stmt(s.stmtInsertEdge) @@ -827,10 +1064,24 @@ func (s *Store) AddBatch(nodes []*graph.Node, edges []*graph.Edge) { if graph.IsProxyNode(n) { continue } - if err := s.insertNodeLocked(insertNode, n); err != nil { + oldIdentity, oldFound := identities[n.ID] + inserted, err := s.insertNodeLocked(insertNode, n) + if err != nil { panicOnFatal(err) return } + changed = changed || inserted + if receiptDelta != nil && inserted { + switch { + case !identityExact: + receiptDelta.complete = false + case !oldFound: + recordSQLiteAddedNode(receiptDelta, n) + case !oldIdentity.equalsNode(n): + receiptDelta.complete = false + } + identities[n.ID] = sqliteIdentityForNode(n) + } } for _, e := range edges { if e == nil { @@ -842,10 +1093,23 @@ func (s *Store) AddBatch(nodes []*graph.Node, edges []*graph.Edge) { if graph.IsProxyID(e.From) || graph.IsProxyID(e.To) { continue } - if err := s.insertEdgeLocked(insertEdge, e); err != nil { + inserted, err := s.insertEdgeLocked(insertEdge, e) + if err != nil { panicOnFatal(err) return } + changed = changed || inserted + if receiptDelta != nil && inserted { + file := e.FilePath + if file == "" { + if source, found := identities[e.From]; found { + file = source.filePath + } else if !identityExact { + receiptDelta.complete = false + } + } + recordSQLiteAddedEdge(receiptDelta, e, file) + } } if err := tx.Commit(); err != nil { @@ -853,6 +1117,10 @@ func (s *Store) AddBatch(nodes []*graph.Node, edges []*graph.Edge) { return } commit = true + s.finishAnalysisMutationLocked(changed) + if changed { + s.mergeMutationReceiptLocked(receiptDelta) + } } // SetEdgeProvenance mutates an existing edge's origin in-place and @@ -881,6 +1149,9 @@ func (s *Store) SetEdgeProvenance(e *graph.Edge, newOrigin string) bool { if storedOrigin == newOrigin { return false } + if !s.invalidateAnalysisBeforeMutationLocked() { + return false + } newTier := e.Tier if newTier != "" { newTier = graph.ResolvedBy(newOrigin) @@ -896,6 +1167,7 @@ func (s *Store) SetEdgeProvenance(e *graph.Edge, newOrigin string) bool { e.Tier = newTier } s.edgeIdentityRevs.Add(1) + s.finishAnalysisMutationLocked(true) return true } @@ -918,13 +1190,24 @@ func (s *Store) PersistEdgeAttributes(e *graph.Edge) { } s.writeMu.Lock() defer s.writeMu.Unlock() - if _, err := s.stmtUpdateEdgeAttrs.Exec( + if !s.invalidateAnalysisBeforeMutationLocked() { + return + } + res, err := s.stmtUpdateEdgeAttrs.Exec( e.Confidence, e.ConfidenceLabel, e.Origin, e.Tier, metaBlob, p.resolveTerminal, p.resolveTerminalReason, e.From, e.To, string(e.Kind), e.FilePath, e.Line, - ); err != nil { + ) + if err != nil { + panicOnFatal(err) + return + } + changed, err := res.RowsAffected() + if err != nil { panicOnFatal(err) + return } + s.finishAnalysisMutationLocked(changed > 0) } // Compile-time assertion: *Store satisfies the batched meta persister. @@ -943,6 +1226,9 @@ func (s *Store) PersistEdgeAttributesBatch(edges []*graph.Edge) { } s.writeMu.Lock() defer s.writeMu.Unlock() + if !s.invalidateAnalysisBeforeMutationLocked() { + return + } for i := 0; i < len(edges); i += reindexChunkSize { end := minInt(i+reindexChunkSize, len(edges)) chunk := edges[i:end] @@ -952,6 +1238,7 @@ func (s *Store) PersistEdgeAttributesBatch(edges []*graph.Edge) { return } updStmt := tx.Stmt(s.stmtUpdateEdgeAttrs) + chunkChanged := false for _, e := range chunk { if e == nil { continue @@ -963,20 +1250,25 @@ func (s *Store) PersistEdgeAttributesBatch(edges []*graph.Edge) { panicOnFatal(err) return } - if _, err := updStmt.Exec( + res, err := updStmt.Exec( e.Confidence, e.ConfidenceLabel, e.Origin, e.Tier, metaBlob, p.resolveTerminal, p.resolveTerminalReason, e.From, e.To, string(e.Kind), e.FilePath, e.Line, - ); err != nil { + ) + if err != nil { _ = tx.Rollback() panicOnFatal(err) return } + if changed, err := res.RowsAffected(); err == nil && changed > 0 { + chunkChanged = true + } } if err := tx.Commit(); err != nil { panicOnFatal(err) return } + s.finishAnalysisMutationLocked(chunkChanged) } } @@ -993,15 +1285,55 @@ func (s *Store) ReindexEdge(e *graph.Edge, oldTo string) { } s.writeMu.Lock() defer s.writeMu.Unlock() + if !s.invalidateAnalysisBeforeMutationLocked() { + return + } + + // Delete and reinsert are one topology change. Keeping them in one + // transaction prevents an encoding/insert failure from committing only the + // delete while both analysis and mutation receipts still describe the old + // graph as current. + tx, err := s.beginWrite() + if err != nil { + panicOnFatal(err) + return + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback() + } + }() + deleteStmt := tx.Stmt(s.stmtDeleteEdgeByKey) + defer deleteStmt.Close() + insertStmt := tx.Stmt(s.stmtInsertEdge) + defer insertStmt.Close() - if _, err := s.stmtDeleteEdgeByKey.Exec(e.From, oldTo, string(e.Kind), e.FilePath, e.Line); err != nil { + res, err := deleteStmt.Exec(e.From, oldTo, string(e.Kind), e.FilePath, e.Line) + if err != nil { + panicOnFatal(err) + return + } + deleted, err := res.RowsAffected() + if err != nil { + panicOnFatal(err) + return + } + inserted, err := s.insertEdgeLocked(insertStmt, e) + if err != nil { panicOnFatal(err) return } - if err := s.insertEdgeLocked(s.stmtInsertEdge, e); err != nil { + if err := tx.Commit(); err != nil { panicOnFatal(err) return } + committed = true + changed := deleted > 0 || inserted + s.finishAnalysisMutationLocked(changed) + if changed { + s.markMutationReceiptsIncompleteLocked() + } } // reindexChunkSize bounds the number of edge re-binds per BEGIN/COMMIT. @@ -1021,6 +1353,9 @@ func (s *Store) ReindexEdges(batch []graph.EdgeReindex) { } s.writeMu.Lock() defer s.writeMu.Unlock() + if !s.invalidateAnalysisBeforeMutationLocked() { + return + } for i := 0; i < len(batch); i += reindexChunkSize { end := minInt(i+reindexChunkSize, len(batch)) chunk := batch[i:end] @@ -1031,25 +1366,42 @@ func (s *Store) ReindexEdges(batch []graph.EdgeReindex) { } delStmt := tx.Stmt(s.stmtDeleteEdgeByKey) insStmt := tx.Stmt(s.stmtInsertEdge) + chunkChanged := false for _, r := range chunk { - if r.Edge == nil || r.OldTo == r.Edge.To { + if r.Edge == nil { + continue + } + oldFilePath, oldLine := r.Edge.FilePath, r.Edge.Line + if r.RefreshIdentity { + oldFilePath, oldLine = r.OldFilePath, r.OldLine + } else if r.OldTo == r.Edge.To { continue } - if _, err := delStmt.Exec(r.Edge.From, r.OldTo, string(r.Edge.Kind), r.Edge.FilePath, r.Edge.Line); err != nil { + res, err := delStmt.Exec(r.Edge.From, r.OldTo, string(r.Edge.Kind), oldFilePath, oldLine) + if err != nil { _ = tx.Rollback() panicOnFatal(err) return } - if err := s.insertEdgeLocked(insStmt, r.Edge); err != nil { + if deleted, err := res.RowsAffected(); err == nil && deleted > 0 { + chunkChanged = true + } + inserted, err := s.insertEdgeLocked(insStmt, r.Edge) + if err != nil { _ = tx.Rollback() panicOnFatal(err) return } + chunkChanged = chunkChanged || inserted } if err := tx.Commit(); err != nil { panicOnFatal(err) return } + s.finishAnalysisMutationLocked(chunkChanged) + if chunkChanged { + s.markMutationReceiptsIncompleteLocked() + } } } @@ -1063,6 +1415,9 @@ func (s *Store) SetEdgeProvenanceBatch(batch []graph.EdgeProvenanceUpdate) int { } s.writeMu.Lock() defer s.writeMu.Unlock() + if !s.invalidateAnalysisBeforeMutationLocked() { + return 0 + } totalChanged := 0 for i := 0; i < len(batch); i += reindexChunkSize { end := minInt(i+reindexChunkSize, len(batch)) @@ -1113,6 +1468,7 @@ func (s *Store) SetEdgeProvenanceBatch(batch []graph.EdgeProvenanceUpdate) int { } if chunkChanged > 0 { s.edgeIdentityRevs.Add(int64(chunkChanged)) + s.finishAnalysisMutationLocked(true) } totalChanged += chunkChanged } @@ -1131,6 +1487,9 @@ func minInt(a, b int) int { func (s *Store) RemoveEdge(from, to string, kind graph.EdgeKind) bool { s.writeMu.Lock() defer s.writeMu.Unlock() + if !s.invalidateAnalysisBeforeMutationLocked() { + return false + } res, err := s.stmtRemoveEdge.Exec(from, to, string(kind)) if err != nil { panicOnFatal(err) @@ -1141,7 +1500,12 @@ func (s *Store) RemoveEdge(from, to string, kind graph.EdgeKind) bool { panicOnFatal(err) return false } - return n > 0 + changed := n > 0 + s.finishAnalysisMutationLocked(changed) + if changed { + s.markMutationReceiptsIncompleteLocked() + } + return changed } // EvictFile removes every node anchored to filePath and every edge @@ -1189,6 +1553,19 @@ func (s *Store) evictByScopeLocked(selectIDs, deleteNodes *sql.Stmt, scope strin if len(ids) == 0 { return 0, 0 } + if !s.invalidateAnalysisBeforeMutationLocked() { + return 0, 0 + } + // At least one selected node is about to be evicted. Advance before the + // chunked deletes so a later partial failure still invalidates an in-flight + // analysis save. + s.finishAnalysisMutationLocked(true) + receiptChanged := false + defer func() { + if receiptChanged { + s.markMutationReceiptsIncompleteLocked() + } + }() // Delete every edge touching one of these nodes. A DELETE-per-node // (… WHERE from_id = ? OR to_id = ?) is one statement round-trip and @@ -1214,6 +1591,7 @@ func (s *Store) evictByScopeLocked(selectIDs, deleteNodes *sql.Stmt, scope strin } if n, err := res.RowsAffected(); err == nil { edgesRemoved += int(n) + receiptChanged = receiptChanged || n > 0 } } } @@ -1228,6 +1606,7 @@ func (s *Store) evictByScopeLocked(selectIDs, deleteNodes *sql.Stmt, scope strin panicOnFatal(err) return 0, edgesRemoved } + receiptChanged = receiptChanged || n > 0 return int(n), edgesRemoved } @@ -1318,11 +1697,34 @@ func (s *Store) GetRepoNonContentNodes(repoPrefix string) []*graph.Node { return s.scanNodeQuery(`SELECT `+lookupNodeCols+` FROM nodes WHERE repo_prefix = ? AND `+filter, repoPrefix) } -// GetRepoNodesLight is the graph.LightNodeReader fast path: repo_prefix -// still uses the nodes_by_repo index, but the meta column is left out of -// the projection entirely, so a repo's already-enriched majority never -// crosses the driver boundary as a blob to decode. See LightNodeReader's -// doc for the read-only-use invariant this projection depends on. +// AllNodesLight implements graph.NodeLightScanner with the identity/location +// projection only. Whole-graph analyses avoid both the opaque metadata blob and +// promoted docs/signatures, so returned nodes always have nil Meta. +func (s *Store) AllNodesLight() []*graph.Node { + rows, err := s.db.Query(`SELECT ` + lookupNodeSummaryCols + ` FROM nodes`) + if err != nil { + panicOnFatal(err) + return nil + } + defer rows.Close() + var out []*graph.Node + for rows.Next() { + n, err := scanNodeSummary(rows) + if err != nil { + panicOnFatal(err) + return out + } + out = append(out, n) + } + if err := rows.Err(); err != nil { + panicOnFatal(err) + } + return out +} + +// GetRepoNodesLight omits the opaque meta column for repo-scoped callers that +// only need promoted structural fields. This keeps an already-enriched repo's +// metadata blobs out of the driver and decoder hot path. func (s *Store) GetRepoNodesLight(repoPrefix string) []*graph.Node { rows, err := s.db.Query(`SELECT `+lookupNodeColsLight+` FROM nodes WHERE repo_prefix = ?`, repoPrefix) if err != nil { diff --git a/internal/graph/store_sqlite/store_lookups.go b/internal/graph/store_sqlite/store_lookups.go index 20f6be9d8..fa8f78941 100644 --- a/internal/graph/store_sqlite/store_lookups.go +++ b/internal/graph/store_sqlite/store_lookups.go @@ -1,6 +1,9 @@ package store_sqlite import ( + "context" + "database/sql" + "fmt" "strings" "github.com/zzet/gortex/internal/graph" @@ -26,6 +29,12 @@ const lookupNodeCols = `id, kind, name, qual_name, file_path, start_line, end_li // can never drift out of sync with lookupNodeCols / scanNode. var lookupNodeColsLight = strings.TrimSuffix(lookupNodeCols, ", meta") +// lookupNodeSummaryCols is the identity/location prefix consumed by +// graph.NodeLightScanner. Unlike lookupNodeColsLight it deliberately excludes +// promoted metadata columns too, so whole-graph algorithms do not allocate a +// Meta map (or retain docs/signatures) for every node. +var lookupNodeSummaryCols = strings.SplitN(lookupNodeCols, ", signature", 2)[0] + const lookupEdgeCols = `from_id, to_id, kind, file_path, line, confidence, confidence_label, origin, tier, cross_repo, meta, resolve_terminal, resolve_terminal_reason` // Compile-time assertion: *Store satisfies graph.NodeNameClassCounter. @@ -136,10 +145,185 @@ func (s *Store) GetOutEdgesByNodeIDs(ids []string) map[string][]*graph.Edge { } // GetInEdgesByNodeIDs is the incoming-edge twin of GetOutEdgesByNodeIDs. +// GetNodeContext returns one node while allowing callers to cancel a blocked +// SQLite read. It intentionally remains an optional extension of graph.Store +// so in-memory and third-party stores keep their existing contract. +func (s *Store) GetNodeContext(ctx context.Context, id string) (*graph.Node, error) { + if ctx == nil { + ctx = context.Background() + } + row := s.stmtGetNode.QueryRowContext(ctx, id) + n, err := scanNode(row) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("get node %q: %w", id, err) + } + return n, nil +} + +// GetNodesByIDsContext batch-loads nodes with cancellable SQLite queries. +// Partial results are returned with an error so safety-sensitive callers can +// retain evidence while marking their result incomplete. +func (s *Store) GetNodesByIDsContext(ctx context.Context, ids []string) (map[string]*graph.Node, error) { + if ctx == nil { + ctx = context.Background() + } + uniq := dedupeNonEmpty(ids) + out := make(map[string]*graph.Node, len(uniq)) + for i := 0; i < len(uniq); i += lookupChunkSize { + if err := ctx.Err(); err != nil { + return out, err + } + end := minInt(i+lookupChunkSize, len(uniq)) + chunk := uniq[i:end] + q := `SELECT ` + lookupNodeCols + ` FROM nodes WHERE id IN (` + inPlaceholders(len(chunk)) + `)` + rows, err := s.db.QueryContext(ctx, q, toAnyArgs(chunk)...) + if err != nil { + return out, fmt.Errorf("get nodes by ids: %w", err) + } + for rows.Next() { + n, scanErr := scanNode(rows) + if scanErr != nil { + _ = rows.Close() + return out, fmt.Errorf("scan node by id: %w", scanErr) + } + if n != nil { + out[n.ID] = n + } + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return out, fmt.Errorf("get nodes by ids: %w", err) + } + if err := rows.Close(); err != nil { + return out, err + } + } + return out, nil +} + func (s *Store) GetInEdgesByNodeIDs(ids []string) map[string][]*graph.Edge { return s.edgesByNodeIDs(ids, "to_id", func(e *graph.Edge) string { return e.To }) } +// GetInEdgesByNodeIDsContext is the bounded, cancellable incoming-edge read +// used by reachability analysis. The ordinary Store interface intentionally +// stays unchanged; reach detects this optional capability and falls back to +// the in-memory batch read on backends that do not implement it. +// +// limit is a total row budget across every IN-list chunk. One extra row is +// requested only to prove truncation, and QueryContext lets an expired impact +// request interrupt SQLite instead of monopolising its single connection. +// GetOutEdgesByNodeIDsContext returns at most limit outgoing edges and reports +// whether additional rows exist. Every SQLite query observes ctx cancellation. +func (s *Store) GetOutEdgesByNodeIDsContext(ctx context.Context, ids []string, limit int) (map[string][]*graph.Edge, bool, error) { + uniq := dedupeNonEmpty(ids) + if len(uniq) == 0 { + return nil, false, nil + } + if limit <= 0 { + return nil, true, nil + } + + out := make(map[string][]*graph.Edge, len(uniq)) + total := 0 + for i := 0; i < len(uniq); i += lookupChunkSize { + if err := ctx.Err(); err != nil { + return out, true, err + } + end := minInt(i+lookupChunkSize, len(uniq)) + chunk := uniq[i:end] + remaining := limit - total + queryLimit := remaining + 1 + q := `SELECT ` + edgeColsLight + ` FROM edges WHERE from_id IN (` + inPlaceholders(len(chunk)) + `) LIMIT ?` + args := toAnyArgs(chunk) + args = append(args, queryLimit) + rows, err := s.db.QueryContext(ctx, q, args...) + if err != nil { + return out, true, err + } + for rows.Next() { + e, scanErr := scanEdgeLight(rows) + if scanErr != nil { + _ = rows.Close() + return out, true, scanErr + } + if total >= limit { + _ = rows.Close() + return out, true, nil + } + if e != nil { + out[e.From] = append(out[e.From], e) + total++ + } + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return out, true, fmt.Errorf("bounded outgoing-edge query: %w", err) + } + if err := rows.Close(); err != nil { + return out, true, err + } + } + return out, false, nil +} + +func (s *Store) GetInEdgesByNodeIDsContext(ctx context.Context, ids []string, limit int) (map[string][]*graph.Edge, bool, error) { + uniq := dedupeNonEmpty(ids) + if len(uniq) == 0 { + return nil, false, nil + } + if limit <= 0 { + return nil, true, nil + } + + out := make(map[string][]*graph.Edge, len(uniq)) + total := 0 + for i := 0; i < len(uniq); i += lookupChunkSize { + if err := ctx.Err(); err != nil { + return out, true, err + } + end := minInt(i+lookupChunkSize, len(uniq)) + chunk := uniq[i:end] + remaining := limit - total + // remaining may be zero: fetch one proof row from later chunks so + // exactly-limit and greater-than-limit are distinguishable. + queryLimit := remaining + 1 + q := `SELECT ` + edgeColsLight + ` FROM edges WHERE to_id IN (` + inPlaceholders(len(chunk)) + `) LIMIT ?` + args := toAnyArgs(chunk) + args = append(args, queryLimit) + rows, err := s.db.QueryContext(ctx, q, args...) + if err != nil { + return out, true, err + } + for rows.Next() { + e, scanErr := scanEdgeLight(rows) + if scanErr != nil { + _ = rows.Close() + return out, true, scanErr + } + if total >= limit { + _ = rows.Close() + return out, true, nil + } + if e != nil { + out[e.To] = append(out[e.To], e) + total++ + } + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return out, true, fmt.Errorf("bounded incoming-edge query: %w", err) + } + if err := rows.Close(); err != nil { + return out, true, err + } + } + return out, false, nil +} + // edgesByNodeIDs runs the chunked IN-list edge fetch keyed on the given // column (from_id or to_id), grouping results by the supplied key extractor. func (s *Store) edgesByNodeIDs(ids []string, col string, key func(*graph.Edge) string) map[string][]*graph.Edge { diff --git a/internal/graph/store_sqlite/store_lookups_context_test.go b/internal/graph/store_sqlite/store_lookups_context_test.go new file mode 100644 index 000000000..3276f19c5 --- /dev/null +++ b/internal/graph/store_sqlite/store_lookups_context_test.go @@ -0,0 +1,53 @@ +package store_sqlite + +import ( + "context" + "errors" + "path/filepath" + "testing" +) + +func TestGetNodeContextHonorsCancellation(t *testing.T) { + s, err := Open(filepath.Join(t.TempDir(), "graph.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = s.Close() }) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + if _, err := s.GetNodeContext(ctx, "missing"); !errors.Is(err, context.Canceled) { + t.Fatalf("GetNodeContext error = %v, want context.Canceled", err) + } +} + +func TestGetNodesByIDsContextHonorsCancellation(t *testing.T) { + s, err := Open(filepath.Join(t.TempDir(), "graph.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = s.Close() }) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + if _, err := s.GetNodesByIDsContext(ctx, []string{"missing"}); !errors.Is(err, context.Canceled) { + t.Fatalf("GetNodesByIDsContext error = %v, want context.Canceled", err) + } +} + +func TestGetOutEdgesByNodeIDsContextHonorsCancellation(t *testing.T) { + s, err := Open(filepath.Join(t.TempDir(), "graph.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = s.Close() }) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + if _, _, err := s.GetOutEdgesByNodeIDsContext(ctx, []string{"missing"}, 1); !errors.Is(err, context.Canceled) { + t.Fatalf("GetOutEdgesByNodeIDsContext error = %v, want context.Canceled", err) + } +} diff --git a/internal/graph/store_sqlite/store_purge.go b/internal/graph/store_sqlite/store_purge.go index eda28bea1..9e39a4e80 100644 --- a/internal/graph/store_sqlite/store_purge.go +++ b/internal/graph/store_sqlite/store_purge.go @@ -63,6 +63,18 @@ func (s *Store) PurgeRepo(prefix string) error { s.writeMu.Lock() defer s.writeMu.Unlock() + // PurgeRepo bypasses the ordinary Add/Evict entry points, so invalidate a + // persisted whole-graph analysis before the transaction can delete live graph + // rows. The preflight is safe under writeMu and avoids touching analysis state + // for sidecar-only purges. + var hasGraphRows int + if err := s.db.QueryRow(`SELECT EXISTS(SELECT 1 FROM nodes WHERE repo_prefix = ? LIMIT 1)`, prefix).Scan(&hasGraphRows); err != nil { + return fmt.Errorf("store_sqlite: PurgeRepo graph preflight: %w", err) + } + if hasGraphRows != 0 && !s.invalidateAnalysisBeforeMutationLocked() { + return fmt.Errorf("store_sqlite: PurgeRepo could not invalidate active analysis") + } + tx, err := s.db.Begin() if err != nil { return err @@ -85,17 +97,33 @@ func (s *Store) PurgeRepo(prefix string) error { return fmt.Errorf("store_sqlite: PurgeRepo vectors: %w", err) } + changed := len(ids) > 0 for _, table := range purgeSidecarTables { - if _, err := tx.Exec(`DELETE FROM `+table+` WHERE repo_prefix = ?`, prefix); err != nil { + res, err := tx.Exec(`DELETE FROM `+table+` WHERE repo_prefix = ?`, prefix) + if err != nil { return fmt.Errorf("store_sqlite: PurgeRepo %s: %w", table, err) } + if n, rowsErr := res.RowsAffected(); rowsErr == nil && n > 0 { + changed = true + } } - if _, err := tx.Exec(`DELETE FROM nodes WHERE repo_prefix = ?`, prefix); err != nil { + res, err := tx.Exec(`DELETE FROM nodes WHERE repo_prefix = ?`, prefix) + if err != nil { return fmt.Errorf("store_sqlite: PurgeRepo nodes: %w", err) } + if n, rowsErr := res.RowsAffected(); rowsErr == nil && n > 0 { + changed = true + } - return tx.Commit() + if err := tx.Commit(); err != nil { + return err + } + s.finishAnalysisMutationLocked(len(ids) > 0) + if changed { + s.markMutationReceiptsIncompleteLocked() + } + return nil } // orphanScanTables are the tables OrphanRepoPrefixes unions DISTINCT @@ -169,7 +197,7 @@ func (s *Store) OrphanRepoPrefixes(known []string) []string { // to new. Every one is keyed by repo_prefix (+ file_path or provider), NOT // by node_id, so its row content survives a node-id change: file_mtimes / // files by (repo_prefix, file_path); repo_index_state / enrichment_state by -// repo_prefix (+ provider). At a solo->multi migration every '' row in these +// repo_prefix (+ provider). At a solo->multi migration every ” row in these // belongs to the one migrating repo — global externals live in the NODES // table and hold NO rows here — so moving them wholesale is safe. UPDATE OR // REPLACE folds any row the re-mint re-index already wrote under new @@ -219,7 +247,7 @@ var rekeyDropTables = []string{ // Refuses new=="" (cannot rekey INTO the protected empty prefix). old=="" IS // allowed — that is the whole point, since solo repos index unprefixed — and // is safe here because this method touches SIDECAR tables ONLY; the synthetic -// global externals that also carry repo_prefix='' live in the NODES table, +// global externals that also carry repo_prefix=” live in the NODES table, // which RekeyRepoPrefix never writes. func (s *Store) RekeyRepoPrefix(oldPrefix, newPrefix string) error { if newPrefix == "" { @@ -238,15 +266,24 @@ func (s *Store) RekeyRepoPrefix(oldPrefix, newPrefix string) error { } defer tx.Rollback() //nolint:errcheck // rollback after Commit is a no-op + changed := false for _, table := range rekeyMoveTables { - if _, err := tx.Exec(`UPDATE OR REPLACE `+table+` SET repo_prefix = ? WHERE repo_prefix = ?`, newPrefix, oldPrefix); err != nil { + res, err := tx.Exec(`UPDATE OR REPLACE `+table+` SET repo_prefix = ? WHERE repo_prefix = ?`, newPrefix, oldPrefix) + if err != nil { return fmt.Errorf("store_sqlite: RekeyRepoPrefix move %s: %w", table, err) } + if n, rowsErr := res.RowsAffected(); rowsErr == nil && n > 0 { + changed = true + } } for _, table := range rekeyDropTables { - if _, err := tx.Exec(`DELETE FROM `+table+` WHERE repo_prefix = ?`, oldPrefix); err != nil { + res, err := tx.Exec(`DELETE FROM `+table+` WHERE repo_prefix = ?`, oldPrefix) + if err != nil { return fmt.Errorf("store_sqlite: RekeyRepoPrefix drop %s: %w", table, err) } + if n, rowsErr := res.RowsAffected(); rowsErr == nil && n > 0 { + changed = true + } } // vectors is intentionally omitted: it has NO repo_prefix column (keyed // by node_id alone), so it cannot be addressed here by prefix. Any '' @@ -254,7 +291,13 @@ func (s *Store) RekeyRepoPrefix(oldPrefix, newPrefix string) error { // dangling, and absent in the common case (embeddings are opt-in). They // are left to a node-membership vector GC rather than guessed at here. - return tx.Commit() + if err := tx.Commit(); err != nil { + return err + } + if changed { + s.markMutationReceiptsIncompleteLocked() + } + return nil } // repoNodeIDsTx returns every node id in repoPrefix, read inside tx. The diff --git a/internal/graph/store_sqlite/store_test.go b/internal/graph/store_sqlite/store_test.go index 3b294c3ff..858295760 100644 --- a/internal/graph/store_sqlite/store_test.go +++ b/internal/graph/store_sqlite/store_test.go @@ -20,3 +20,30 @@ func TestSQLiteStoreConformance(t *testing.T) { return s }) } + +func TestAddNodeUpdatePreservesIncidentEdges(t *testing.T) { + s, err := store_sqlite.Open(filepath.Join(t.TempDir(), "graph.db")) + if err != nil { + t.Fatal(err) + } + defer s.Close() + + a := &graph.Node{ID: "a", Kind: graph.KindFunction, Name: "a"} + b := &graph.Node{ID: "b", Kind: graph.KindFunction, Name: "b"} + s.AddBatch([]*graph.Node{a, b}, []*graph.Edge{{From: "a", To: "b", Kind: graph.EdgeCalls}}) + if got := s.EdgeCount(); got != 1 { + t.Fatalf("edge count before metadata update = %d, want 1", got) + } + + b.Meta = map[string]any{"reach_build": uint64(7)} + s.AddNode(b) + if got := s.EdgeCount(); got != 1 { + t.Fatalf("edge count after node upsert = %d, want 1", got) + } + if got := len(s.GetInEdges("b")); got != 1 { + t.Fatalf("incoming edges after node upsert = %d, want 1", got) + } + if got := len(s.GetOutEdges("a")); got != 1 { + t.Fatalf("outgoing edges after node upsert = %d, want 1", got) + } +} diff --git a/internal/graph/storetest/storetest.go b/internal/graph/storetest/storetest.go index ddae34b52..2d1837d8e 100644 --- a/internal/graph/storetest/storetest.go +++ b/internal/graph/storetest/storetest.go @@ -68,6 +68,7 @@ func RunConformance(t *testing.T, factory Factory) { t.Run("NodesByKind", func(t *testing.T) { testNodesByKind(t, factory) }) t.Run("EdgesWithUnresolvedTarget", func(t *testing.T) { testEdgesWithUnresolvedTarget(t, factory) }) t.Run("FnValuePlaceholderEdges", func(t *testing.T) { testFnValuePlaceholderEdges(t, factory) }) + t.Run("NodeLightScanner", func(t *testing.T) { testNodeLightScanner(t, factory) }) t.Run("LightEdgeScanner", func(t *testing.T) { testLightEdgeScanner(t, factory) }) t.Run("GetNodesByIDs", func(t *testing.T) { testGetNodesByIDs(t, factory) }) t.Run("FindNodesByNames", func(t *testing.T) { testFindNodesByNames(t, factory) }) @@ -656,6 +657,26 @@ func testReindexEdges(t *testing.T, factory Factory) { t.Fatalf("GetOutEdges(a) after batch reindex = %d, want 3", got) } + // Full-identity refresh updates source location/meta while preserving the + // resolver-selected target and adjacency. + oldLine := e1.Line + refreshed := *e1 + refreshed.Line = 41 + refreshed.Meta = map[string]any{"doc": "shifted"} + s.ReindexEdges([]graph.EdgeReindex{{ + Edge: &refreshed, OldTo: e1.To, OldFilePath: e1.FilePath, + OldLine: oldLine, RefreshIdentity: true, + }}) + var found *graph.Edge + for _, edge := range s.GetOutEdges("a") { + if edge.To == "z" && edge.Line == 41 { + found = edge + } + } + if found == nil { + t.Fatal("full-identity refresh did not move edge line while preserving target") + } + // Empty batch is a no-op. s.ReindexEdges(nil) s.ReindexEdges([]graph.EdgeReindex{}) @@ -1001,7 +1022,7 @@ func testFnValuePlaceholderEdges(t *testing.T, factory Factory) { t.Skip("backend does not implement FnValuePlaceholderScanner") } s.AddNode(mkNode("a", "A", "x.go", graph.KindFunction)) - s.AddEdge(mkEdge("a", "b", graph.EdgeReferences)) // real reference — not a placeholder + s.AddEdge(mkEdge("a", "b", graph.EdgeReferences)) // real reference — not a placeholder s.AddEdge(mkEdge("a", "unresolved::Foo", graph.EdgeCalls)) // unresolved, but not fn-value s.AddEdge(mkEdge("a", "resolved", graph.EdgeReferences)) // resolved ph1 := mkEdge("a", "unresolved::fnvalue::handler", graph.EdgeReferences) @@ -1029,6 +1050,65 @@ func testFnValuePlaceholderEdges(t *testing.T, factory Factory) { } } +// testNodeLightScanner proves a whole-graph summary projection preserves the +// identity/location fields its analysis callers consume while omitting all +// Meta, including promoted docs and signatures. +func testNodeLightScanner(t *testing.T, factory Factory) { + t.Helper() + s := factory(t) + sc, ok := s.(graph.NodeLightScanner) + if !ok { + t.Skip("backend does not implement NodeLightScanner") + } + + a := mkNode("repo/pkg/a.go::A", "A", "pkg/a.go", graph.KindFunction) + a.RepoPrefix = "repo" + a.Language = "go" + a.QualName = "repo.pkg.A" + a.StartLine = 11 + a.EndLine = 19 + a.StartColumn = 2 + a.EndColumn = 8 + a.WorkspaceID = "workspace" + a.ProjectID = "project" + a.Meta = map[string]any{ + "signature": "func A()", + "doc": "large promoted documentation", + "blob_only": "large opaque payload", + } + b := mkNode("repo/pkg/b.go::B", "B", "pkg/b.go", graph.KindType) + b.RepoPrefix = "repo" + b.Language = "go" + for _, n := range []*graph.Node{a, b} { + s.AddNode(n) + } + + got := sc.AllNodesLight() + if ids := sortNodeIDs(got); fmt.Sprint(ids) != fmt.Sprint([]string{a.ID, b.ID}) { + t.Fatalf("AllNodesLight IDs = %v, want [%s %s]", ids, a.ID, b.ID) + } + var lightA *graph.Node + for _, n := range got { + if n.ID == a.ID { + lightA = n + break + } + } + if lightA == nil { + t.Fatal("AllNodesLight did not return A") + } + if lightA.Kind != a.Kind || lightA.Name != a.Name || lightA.QualName != a.QualName || + lightA.FilePath != a.FilePath || lightA.StartLine != a.StartLine || lightA.EndLine != a.EndLine || + lightA.StartColumn != a.StartColumn || lightA.EndColumn != a.EndColumn || + lightA.Language != a.Language || lightA.RepoPrefix != a.RepoPrefix || + lightA.WorkspaceID != a.WorkspaceID || lightA.ProjectID != a.ProjectID { + t.Fatalf("AllNodesLight altered identity/location fields: got %#v", lightA) + } + if lightA.Meta != nil { + t.Fatalf("AllNodesLight materialized metadata: %#v", lightA.Meta) + } +} + // testLightEdgeScanner pins graph.LightEdgeScanner: the kind filter must scope // to the requested kinds (empty means all), and every promoted field must // survive the meta-less scan intact and equal the values written. (The diff --git a/internal/hooks/alternation_test.go b/internal/hooks/alternation_test.go index e878ecee6..2d74b97f4 100644 --- a/internal/hooks/alternation_test.go +++ b/internal/hooks/alternation_test.go @@ -71,7 +71,7 @@ func TestEnrichGrep_Alternation_MixedProbesOnlySymbolSegments(t *testing.T) { // TestEnrichGrep_Alternation_PureTextSkips covers a multi-keyword pattern with // no identifier-shaped alternative: it must not probe and must steer the agent -// toward search_text. +// toward the public text-search operation. func TestEnrichGrep_Alternation_PureTextSkips(t *testing.T) { logPath := redirectTelemetry(t) rec := stubProbe(t, nil, nil) @@ -80,8 +80,8 @@ func TestEnrichGrep_Alternation_PureTextSkips(t *testing.T) { if result.deny { t.Fatal("pure-text alternation should not deny") } - if !strings.Contains(result.context, "search_text") { - t.Errorf("guidance should point at search_text: %q", result.context) + if !strings.Contains(result.context, `search(operation:"symbols"`) || !strings.Contains(result.context, "literal text: use operation `text`") { + t.Errorf("guidance should point at search(operation=text): %q", result.context) } if len(rec.calls) != 0 { t.Errorf("pure-text alternation should not probe, got %v", rec.calls) diff --git a/internal/hooks/bash_classify.go b/internal/hooks/bash_classify.go index 3af5245fa..9c61ab2c2 100644 --- a/internal/hooks/bash_classify.go +++ b/internal/hooks/bash_classify.go @@ -1,6 +1,10 @@ package hooks -import "strings" +import ( + "regexp" + "strconv" + "strings" +) // BashAction describes what an enrichBash caller should do with a Bash command. type BashAction int @@ -17,13 +21,20 @@ const ( // BashActionReadSource means the command reads an indexed-looking source // file (cat/head/tail of .go/.ts/…). Path holds the file path. BashActionReadSource + // BashActionFileList is a read-only, line-oriented file listing whose + // stdout can be normalized to the existing Glob post-processing path. + // Ambiguous or execution-capable variants remain passthrough. + BashActionFileList + // BashActionReadRange is a bounded sed/awk source read. It is never denied + // or rewritten; PostToolUse may add graph context for the referenced file. + BashActionReadRange ) // BashClassification is the result of classifyBashCommand. type BashClassification struct { Action BashAction Pattern string // for GrepLike / FindName - Path string // for ReadSource + Path string // for ReadSource / ReadRange Primary string // the primary command token (grep, rg, cat, …) — for messages } @@ -72,6 +83,22 @@ func classifyBashCommand(cmd string) BashClassification { if path, ok := extractReadFile(tokens); ok { return BashClassification{Action: BashActionReadSource, Path: path, Primary: tokens[0]} } + case "fd", "fdfind", "ls", "tree": + if safeFileList(tokens) { + return BashClassification{Action: BashActionFileList, Primary: tokens[0]} + } + case "git": + if safeGitFileList(tokens) { + return BashClassification{Action: BashActionFileList, Primary: "git ls-files"} + } + case "sed": + if path, ok := extractSedReadFile(tokens); ok { + return BashClassification{Action: BashActionReadRange, Path: path, Primary: "sed"} + } + case "awk": + if path, ok := extractAwkReadFile(tokens); ok { + return BashClassification{Action: BashActionReadRange, Path: path, Primary: "awk"} + } } } return BashClassification{Action: BashActionPassthrough} @@ -201,6 +228,11 @@ var grepFlagsTakingArg = map[string]bool{ // or the first non-flag positional. Returns ok=false if no pattern is // present (e.g. `grep -h` help invocation). func extractGrepPattern(tokens []string) (string, bool) { + pattern, _, ok := extractGrepPatternAt(tokens) + return pattern, ok +} + +func extractGrepPatternAt(tokens []string) (string, int, bool) { // tokens[0] is the command itself. i := 1 for i < len(tokens) { @@ -210,9 +242,9 @@ func extractGrepPattern(tokens []string) (string, bool) { // purposes — it'll be gated by classifyGrepPattern anyway). if t == "-e" || t == "--regexp" { if i+1 < len(tokens) { - return tokens[i+1], true + return tokens[i+1], i + 1, true } - return "", false + return "", -1, false } // --flag=value: one token, skip. if strings.HasPrefix(t, "--") && strings.Contains(t, "=") { @@ -227,9 +259,9 @@ func extractGrepPattern(tokens []string) (string, bool) { i++ continue } - return t, true + return t, i, true } - return "", false + return "", -1, false } // extractFindName walks tokens looking for `-name`/`-iname` and returns the @@ -264,3 +296,218 @@ func extractReadFile(tokens []string) (string, bool) { } return "", false } + +func safeFileList(tokens []string) bool { + if len(tokens) == 0 { + return false + } + switch tokens[0] { + case "fd", "fdfind": + for _, token := range tokens[1:] { + if token == "-x" || token == "-X" || token == "-0" || token == "-l" || + strings.HasPrefix(token, "--exec") || strings.HasPrefix(token, "--print0") || + token == "--list-details" || strings.HasPrefix(token, "--format") { + return false + } + } + return true + case "ls": + for _, token := range tokens[1:] { + if !safeLSOption(token) { + return false + } + } + return true + case "tree": + hasFullPath, hasNoIndent := false, false + for _, token := range tokens[1:] { + if !strings.HasPrefix(token, "-") { + continue + } + if token == "--noreport" { + continue + } + if strings.HasPrefix(token, "--") { + return false + } + for _, flag := range strings.TrimPrefix(token, "-") { + switch flag { + case 'f': + hasFullPath = true + case 'i': + hasNoIndent = true + case 'a', 'd', 'n': + // These filter entries or disable color without decorating paths. + default: + return false + } + } + } + return hasFullPath && hasNoIndent + default: + return false + } +} + +func safeGitFileList(tokens []string) bool { + if len(tokens) < 2 || tokens[0] != "git" || tokens[1] != "ls-files" { + return false + } + for _, token := range tokens[2:] { + if token == "--stage" || token == "--debug" || token == "--eol" || + token == "--unmerged" || token == "--resolve-undo" || + strings.HasPrefix(token, "--format") || strings.HasPrefix(token, "--abbrev") { + return false + } + if strings.HasPrefix(token, "-") && !strings.HasPrefix(token, "--") { + for _, flag := range strings.TrimPrefix(token, "-") { + if strings.ContainsRune("zstvfuh", flag) { + return false + } + } + } + } + return true +} + +// safeLSOption permits only flags that keep stdout one-path-per-line. The +// default non-TTY shape and -1 are line-oriented; metadata, quoting, +// classification suffixes, columns, commas, and recursion are deliberately +// ignored because PostToolUse would otherwise parse decorations as paths. +func safeLSOption(token string) bool { + if !strings.HasPrefix(token, "-") || token == "-" { + return true + } + if token == "--" { + return true + } + if strings.HasPrefix(token, "--") { + switch token { + case "--all", "--almost-all", "--directory", "--hide-control-chars", "--literal": + return true + default: + return false + } + } + for _, flag := range strings.TrimPrefix(token, "-") { + if !strings.ContainsRune("aA1d", flag) { + return false + } + } + return true +} + +const maxHookReadRangeLines = 2000 + +var ( + sedSinglePrintRE = regexp.MustCompile(`^\s*(\d+|\$)\s*p\s*$`) + sedRangePrintRE = regexp.MustCompile(`^\s*(\d+)\s*,\s*(\d+)\s*p\s*$`) + awkRangePrintRE = regexp.MustCompile(`(?i)^\s*NR\s*>=?\s*(\d+)\s*&&\s*NR\s*<=\s*(\d+)\s*\{\s*print(?:\s+\$0)?\s*\}\s*$`) +) + +func extractSedReadFile(tokens []string) (string, bool) { + if len(tokens) < 3 { + return "", false + } + quiet := false + for _, token := range tokens[1:] { + if token == "-i" || strings.HasPrefix(token, "-i") || token == "--in-place" || strings.HasPrefix(token, "--in-place=") { + return "", false + } + if token == "-n" || token == "--quiet" || token == "--silent" { + quiet = true + } + } + if !quiet { + return "", false + } + path, ok := lastSourceToken(tokens) + if !ok { + return "", false + } + program := "" + for i := 1; i < len(tokens); i++ { + if tokens[i] == "-n" || tokens[i] == "--quiet" || tokens[i] == "--silent" || tokens[i] == path { + continue + } + if strings.HasPrefix(tokens[i], "-") { + return "", false + } + program = tokens[i] + break + } + if !safeSedPrintProgram(program) { + return "", false + } + return path, true +} + +func safeSedPrintProgram(program string) bool { + if sedSinglePrintRE.MatchString(program) { + return true + } + match := sedRangePrintRE.FindStringSubmatch(program) + return boundedLineRange(match) +} + +func extractAwkReadFile(tokens []string) (string, bool) { + if len(tokens) < 3 { + return "", false + } + path, ok := lastSourceToken(tokens) + if !ok { + return "", false + } + if !boundedLineRange(awkRangePrintRE.FindStringSubmatch(tokens[1])) { + return "", false + } + return path, true +} + +func boundedLineRange(match []string) bool { + if len(match) != 3 { + return false + } + start, startErr := strconv.Atoi(match[1]) + end, endErr := strconv.Atoi(match[2]) + return startErr == nil && endErr == nil && start > 0 && end >= start && end-start+1 <= maxHookReadRangeLines +} + +func lastSourceToken(tokens []string) (string, bool) { + for i := len(tokens) - 1; i >= 1; i-- { + if strings.HasPrefix(tokens[i], "-") { + continue + } + if looksLikeSourceFile(tokens[i]) { + return tokens[i], true + } + return "", false + } + return "", false +} + +// simpleBashCommand accepts exactly one unpiped, unredirected command. It is +// used only by rewrite mode; ambiguity always falls back to advisory context. +func simpleBashCommand(command string) bool { + if strings.Contains(command, "$(") || strings.Contains(command, "`") || strings.Contains(command, "\n") { + return false + } + inSingle, inDouble := false, false + for i := 0; i < len(command); i++ { + switch command[i] { + case '\'': + if !inDouble { + inSingle = !inSingle + } + case '"': + if !inSingle { + inDouble = !inDouble + } + case ';', '|', '&', '>', '<': + if !inSingle && !inDouble { + return false + } + } + } + return !inSingle && !inDouble && len(primarySegments(command)) == 1 +} diff --git a/internal/hooks/bash_classify_test.go b/internal/hooks/bash_classify_test.go index 46a687783..f70de85fc 100644 --- a/internal/hooks/bash_classify_test.go +++ b/internal/hooks/bash_classify_test.go @@ -44,6 +44,32 @@ func TestClassifyBashCommand(t *testing.T) { {"cat .json", `cat package.json`, BashActionPassthrough, ""}, {"cat .go | grep", `cat /repo/x.go | grep foo`, BashActionReadSource, "/repo/x.go"}, + // --- conservative file-list shapes --- + {"fd", `fd '\\.go$' internal`, BashActionFileList, ""}, + {"fd exec stays passthrough", `fd '\\.go$' -x rm`, BashActionPassthrough, ""}, + {"fd custom format stays passthrough", `fd --format '{/}' internal`, BashActionPassthrough, ""}, + {"ls", `ls /repo`, BashActionFileList, ""}, + {"ls explicit single column", `ls -1A /repo`, BashActionFileList, ""}, + {"ls long stays passthrough", `ls -la /repo`, BashActionPassthrough, ""}, + {"ls columns stay passthrough", `ls -C /repo`, BashActionPassthrough, ""}, + {"tree full paths no indent", `tree -fi internal`, BashActionFileList, ""}, + {"tree metadata stays passthrough", `tree -fip internal`, BashActionPassthrough, ""}, + {"tree decorative stays passthrough", `tree internal`, BashActionPassthrough, ""}, + {"git ls-files", `git ls-files '*.go'`, BashActionFileList, ""}, + {"git ls-files nul stays passthrough", `git ls-files -z`, BashActionPassthrough, ""}, + {"git ls-files eol stays passthrough", `git ls-files --eol`, BashActionPassthrough, ""}, + {"git ls-files unmerged stays passthrough", `git ls-files -u`, BashActionPassthrough, ""}, + + // --- bounded source reads --- + {"sed line range", `sed -n '20,80p' internal/x.go`, BashActionReadRange, "internal/x.go"}, + {"sed high bounded range", `sed -n '5000,5050p' internal/x.go`, BashActionReadRange, "internal/x.go"}, + {"sed default printing is not bounded", `sed '20,80p' internal/x.go`, BashActionPassthrough, ""}, + {"sed oversized range stays passthrough", `sed -n '1,5000p' internal/x.go`, BashActionPassthrough, ""}, + {"sed in-place stays passthrough", `sed -i 's/a/b/' internal/x.go`, BashActionPassthrough, ""}, + {"awk line range", `awk 'NR>=20 && NR<=80 {print}' internal/x.go`, BashActionReadRange, "internal/x.go"}, + {"awk oversized range stays passthrough", `awk 'NR>=1 && NR<=5000 {print}' internal/x.go`, BashActionPassthrough, ""}, + {"awk system stays passthrough", `awk '{system($0)}' internal/x.go`, BashActionPassthrough, ""}, + // --- quoting --- {"single-quoted pattern", `grep -rn 'foo bar' .`, BashActionGrepLike, "foo bar"}, {"double-quoted pattern", `grep -rn "foo bar" .`, BashActionGrepLike, "foo bar"}, @@ -52,7 +78,6 @@ func TestClassifyBashCommand(t *testing.T) { // --- passthroughs --- {"empty", ``, BashActionPassthrough, ""}, {"whitespace only", ` `, BashActionPassthrough, ""}, - {"ls", `ls /repo`, BashActionPassthrough, ""}, {"go build", `go build ./...`, BashActionPassthrough, ""}, {"echo", `echo hello`, BashActionPassthrough, ""}, } @@ -68,7 +93,7 @@ func TestClassifyBashCommand(t *testing.T) { if got.Pattern != tt.wantExtra { t.Errorf("pattern = %q, want %q", got.Pattern, tt.wantExtra) } - case BashActionReadSource: + case BashActionReadSource, BashActionReadRange: if got.Path != tt.wantExtra { t.Errorf("path = %q, want %q", got.Path, tt.wantExtra) } @@ -77,6 +102,22 @@ func TestClassifyBashCommand(t *testing.T) { } } +func TestSimpleBashCommand(t *testing.T) { + for _, command := range []string{`cat internal/x.go`, `cat 'internal/a b.go'`} { + if !simpleBashCommand(command) { + t.Errorf("expected simple command: %q", command) + } + } + for _, command := range []string{ + `cat internal/x.go | head`, `cat internal/x.go > /tmp/x`, `cd . && cat internal/x.go`, + `cat $(pwd)/x.go`, "cat internal/x.go\ncat internal/y.go", + } { + if simpleBashCommand(command) { + t.Errorf("compound command must not be rewrite-safe: %q", command) + } + } +} + func TestPrimarySegments(t *testing.T) { tests := []struct { name string diff --git a/internal/hooks/codex.go b/internal/hooks/codex.go index 7fb2dfe50..0b6ce5321 100644 --- a/internal/hooks/codex.go +++ b/internal/hooks/codex.go @@ -2,38 +2,96 @@ package hooks import ( "encoding/json" + "fmt" "io" "os" + "path/filepath" + "strings" + "time" ) -// RunCodex handles the Codex hook wire shape. Codex support is deliberately -// soft-only: PreToolUse is forced through ModeEnrich, PostToolUse only emits -// additionalContext, and UserPromptSubmit re-surfaces prompt-relevant graph -// symbols on every turn. No branch ever denies a tool call. -func RunCodex(port int) { +// CodexMode is deliberately separate from the cross-agent hook Mode: Codex's +// compatibility default must remain advisory even though the generic hook +// command defaults to deny for Claude Code. +type CodexMode int + +const ( + CodexModeEnrich CodexMode = iota + CodexModeDeny + CodexModeRewrite + CodexModeSuppress +) + +func ParseCodexMode(value string) CodexMode { + switch strings.ToLower(strings.TrimSpace(value)) { + case "deny", "hard-deny": + return CodexModeDeny + case "rewrite", "input-rewrite": + return CodexModeRewrite + case "suppress", "replace-output", "output-suppression": + return CodexModeSuppress + default: + return CodexModeEnrich + } +} + +func (m CodexMode) String() string { + switch m { + case CodexModeDeny: + return "deny" + case CodexModeRewrite: + return "rewrite" + case CodexModeSuppress: + return "suppress" + default: + return "enrich" + } +} + +// RunCodex handles the Codex hook wire shape. Advisory enrich remains the +// default. Operators may opt into hard deny, conservative input rewrite, or +// supported PostToolUse result replacement (the current Codex release rejects +// the nominal suppressOutput field, so suppress mode never emits it). +func RunCodex(port int, selected ...CodexMode) { data, err := io.ReadAll(os.Stdin) if err != nil { return } - runCodex(data, port) + runCodex(data, port, selected...) } -func runCodex(data []byte, port int) { +func runCodex(data []byte, port int, selected ...CodexMode) { var peek struct { HookEventName string `json:"hook_event_name"` ToolName string `json:"tool_name"` + CWD string `json:"cwd"` } if err := json.Unmarshal(data, &peek); err != nil { return } + mode := CodexModeEnrich + if len(selected) > 0 { + mode = selected[0] + } + setHookCWD(peek.CWD) + defer setHookCWD("") switch { + case peek.HookEventName == "SessionStart": + runSessionStart(data) case peek.HookEventName == "PreToolUse" && peek.ToolName == "Bash": - runPreToolUse(data, port, ModeEnrich) + switch mode { + case CodexModeDeny: + runCodexBashHardDeny(data, port) + case CodexModeRewrite: + runCodexBashRewrite(data, port) + default: + runPreToolUse(data, port, ModeEnrich) + } case peek.HookEventName == "PreToolUse" && codexMCPReadPreToolUseTool(peek.ToolName): - runCodexMCPReadPreToolUse(data) - case peek.HookEventName == "PostToolUse" && peek.ToolName == "Bash": - runCodexPostToolUse(data) + runCodexMCPReadPreToolUse(data, mode) + case peek.HookEventName == "PostToolUse" && (peek.ToolName == "Bash" || peek.ToolName == "apply_patch"): + runCodexPostToolUse(data, port, mode) case peek.HookEventName == "UserPromptSubmit": // Re-surface graph symbols relevant to the prompt on every turn. // Codex forgets MCP tools as context grows, so a SessionStart @@ -44,16 +102,157 @@ func runCodex(data []byte, port int) { } } +func runCodexBashHardDeny(data []byte, port int) { + started := time.Now() + var input HookInput + if err := json.Unmarshal(data, &input); err != nil || input.HookEventName != "PreToolUse" || input.ToolName != "Bash" { + return + } + emitted := false + defer func() { + logHookEffectiveness("PreToolUse", emitted, daemonReachableFn(), hookAlternationSegmentCount(input), time.Since(started)) + }() + + result := enrich(input, port) + classification := classifyBashCommand(fmt.Sprint(input.ToolInput["command"])) + searchShape := classification.Action == BashActionGrepLike || classification.Action == BashActionFindName + workspaceScoped := !searchShape || bashSearchTargetsWorkspace(fmt.Sprint(input.ToolInput["command"]), input.CWD, classification.Action) + if result.deny && searchShape && !workspaceScoped { + // A graph hit does not prove an explicitly external grep/find target is + // indexed. Keep the reminder but never block that command. + result = enrichResult{context: defaultGrepGuidance()} + } + if !result.deny && daemonReachableFn() && workspaceScoped && + classification.Action == BashActionGrepLike && result.context != "" && + codexTextSearchHitFn(port, classification.Pattern) { + result.deny = true + result.reason = "[Gortex] BLOCKED by opt-in Codex deny posture. Use the public MCP search/relations operations instead of a raw source search.\n" + result.context + result.context = "" + } + if result.context == "" && !result.deny { + return + } + hso := &HookSpecificOutput{HookEventName: "PreToolUse"} + if result.deny { + hso.PermissionDecision = "deny" + hso.PermissionDecisionReason = result.reason + } else { + hso.AdditionalContext = result.context + } + emitted = true + emitPreToolUse(HookOutput{HookSpecificOutput: hso}) +} + +// codexTextSearchHitFn confirms that a raw regex/literal search actually has +// an indexed-code match before the opt-in deny posture blocks it. Keeping the +// check behind a seam makes the hard-deny boundary testable without a daemon. +var codexTextSearchHitFn = codexTextSearchHasHit + +func codexTextSearchHasHit(port int, pattern string) bool { + if strings.TrimSpace(pattern) == "" { + return false + } + raw := callServerTool(port, "search_text", map[string]any{ + "query": pattern, "regexp": true, "limit": 1, + }) + var result struct { + Count int `json:"count"` + } + return json.Unmarshal([]byte(raw), &result) == nil && result.Count > 0 +} + +// bashSearchTargetsWorkspace accepts only a single grep/find command whose +// explicit search roots stay under cwd. Compound commands remain advisory; +// an absolute or ../ scope outside the workspace can never be hard-denied just +// because the same pattern happens to exist in the graph. +func bashSearchTargetsWorkspace(command, cwd string, action BashAction) bool { + if !simpleBashCommand(command) { + return false + } + tokens := tokenize(command) + for len(tokens) > 0 && (tokens[0] == "sudo" || tokens[0] == "time" || + (strings.Contains(tokens[0], "=") && !strings.HasPrefix(tokens[0], "-"))) { + tokens = tokens[1:] + } + if len(tokens) == 0 { + return false + } + var scopes []string + switch action { + case BashActionGrepLike: + _, patternAt, ok := extractGrepPatternAt(tokens) + if !ok { + return false + } + for i := patternAt + 1; i < len(tokens); i++ { + token := tokens[i] + if token == "--" { + scopes = append(scopes, tokens[i+1:]...) + break + } + if strings.HasPrefix(token, "--") && strings.Contains(token, "=") { + continue + } + if grepFlagsTakingArg[token] { + i++ + continue + } + if strings.HasPrefix(token, "-") { + continue + } + scopes = append(scopes, token) + } + case BashActionFindName: + for _, token := range tokens[1:] { + if strings.HasPrefix(token, "-") || token == "!" || token == "(" { + break + } + scopes = append(scopes, token) + } + default: + return false + } + if len(scopes) == 0 { + scopes = []string{"."} + } + for _, scope := range scopes { + if !pathWithinWorkspace(cwd, scope) { + return false + } + } + return true +} + +func pathWithinWorkspace(cwd, scope string) bool { + if strings.TrimSpace(scope) == "" || scope == "-" { + return false + } + root, err := filepath.Abs(cwd) + if err != nil || strings.TrimSpace(cwd) == "" { + root = "" + } + candidate := filepath.Clean(scope) + if root == "" { + return !filepath.IsAbs(candidate) && candidate != ".." && !strings.HasPrefix(candidate, ".."+string(filepath.Separator)) + } + if !filepath.IsAbs(candidate) { + candidate = filepath.Join(root, candidate) + } + rel, err := filepath.Rel(root, candidate) + return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} + func codexMCPReadPreToolUseTool(toolName string) bool { switch toolName { - case gortexReadFileTool, gortexEditingContextTool: + case gortexCompactReadTool, gortexReadFileTool, gortexEditingContextTool: return true default: return false } } -func runCodexMCPReadPreToolUse(data []byte) { +func runCodexMCPReadPreToolUse(data []byte, mode CodexMode) { + started := time.Now() var input HookInput if err := json.Unmarshal(data, &input); err != nil { return @@ -61,25 +260,131 @@ func runCodexMCPReadPreToolUse(data []byte) { if input.HookEventName != "PreToolUse" || !codexMCPReadPreToolUseTool(input.ToolName) { return } + emitted := false + defer func() { + logHookEffectiveness("PreToolUse", emitted, daemonReachableFn(), 0, time.Since(started)) + }() ctx := gortexReadNudge(input.ToolName, input.ToolInput) if ctx == "" { return } - emitPreToolUse(HookOutput{ - HookSpecificOutput: &HookSpecificOutput{ - HookEventName: "PreToolUse", - AdditionalContext: ctx, - }, - }) + hso := &HookSpecificOutput{HookEventName: "PreToolUse", AdditionalContext: ctx} + switch mode { + case CodexModeDeny: + hso.AdditionalContext = "" + hso.PermissionDecision = "deny" + hso.PermissionDecisionReason = ctx + case CodexModeRewrite: + hso.PermissionDecision = "allow" + hso.UpdatedInput = rewrittenGortexReadInput(input.ToolName, input.ToolInput) + } + emitted = true + emitPreToolUse(HookOutput{HookSpecificOutput: hso}) +} + +func rewrittenGortexReadInput(toolName string, input map[string]any) map[string]any { + out := cloneStringAnyMap(input) + switch strings.TrimSpace(toolName) { + case gortexCompactReadTool, "read": + options, _ := input["options"].(map[string]any) + options = cloneStringAnyMap(options) + options["compress_bodies"] = true + out["options"] = options + default: + out["compress_bodies"] = true + } + return out +} + +func cloneStringAnyMap(input map[string]any) map[string]any { + out := make(map[string]any, len(input)+1) + for key, value := range input { + out[key] = value + } + return out } -func runCodexPostToolUse(data []byte) { +func runCodexBashRewrite(data []byte, port int) { + started := time.Now() + var input HookInput + if err := json.Unmarshal(data, &input); err != nil || input.HookEventName != "PreToolUse" || input.ToolName != "Bash" { + return + } + emitted := false + defer func() { + logHookEffectiveness("PreToolUse", emitted, daemonReachableFn(), hookAlternationSegmentCount(input), time.Since(started)) + }() + + if updated, message, ok := rewrittenCodexBashInput(input); ok { + emitted = true + emitPreToolUse(HookOutput{HookSpecificOutput: &HookSpecificOutput{ + HookEventName: "PreToolUse", + AdditionalContext: message, + PermissionDecision: "allow", + UpdatedInput: updated, + }}) + return + } + + result := applyMode(input, false, ModeEnrich, enrich(input, port)) + if result.context == "" { + return + } + emitted = true + emitPreToolUse(HookOutput{HookSpecificOutput: &HookSpecificOutput{ + HookEventName: "PreToolUse", + AdditionalContext: result.context, + }}) +} + +// rewrittenCodexBashInput rewrites only a single, unpiped `cat ` for +// a file the daemon confirms is indexed. Head/tail, compound commands, +// redirects, and search/list shapes retain advisory behavior because changing +// their output contract could alter caller semantics. +func rewrittenCodexBashInput(input HookInput) (map[string]any, string, bool) { + command, _ := input.ToolInput["command"].(string) + classification := classifyBashCommand(command) + if classification.Action != BashActionReadSource || classification.Primary != "cat" || !simpleBashCommand(command) { + return nil, "", false + } + indexed, _ := queryFileIndexed(input.CWD, classification.Path) + if !indexed { + return nil, "", false + } + args, err := json.Marshal(map[string]any{"target": map[string]any{"file": classification.Path}}) + if err != nil { + return nil, "", false + } + updated := cloneStringAnyMap(input.ToolInput) + updated["command"] = "gortex call read --json " + shellSingleQuote(string(args)) + return updated, fmt.Sprintf("[Gortex] Rewrote indexed source read %s to the exact public read mirror.", classification.Path), true +} + +func shellSingleQuote(value string) string { + return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'" +} + +func runCodexPostToolUse(data []byte, port int, mode CodexMode) { + started := time.Now() var input postHookInput if err := json.Unmarshal(data, &input); err != nil { return } - if input.HookEventName != "PostToolUse" || input.ToolName != "Bash" { + if input.HookEventName != "PostToolUse" { + return + } + if input.ToolName == "apply_patch" { + ctx := buildMutationBriefing(port) + emitted := ctx != "" + logHookEffectiveness("PostToolUse", emitted, daemonReachableFn(), 0, time.Since(started)) + if !emitted { + return + } + emitPostToolContext(ctx, mode == CodexModeSuppress) + return + } + if input.ToolName != "Bash" { return } @@ -91,9 +396,9 @@ func runCodexPostToolUse(data []byte) { // the existing PostToolUse enrichment can parse path:line output and do // the graph lookup without changing Claude Code behavior. input.ToolName = "Grep" - case BashActionFindName: + case BashActionFindName, BashActionFileList: input.ToolName = "Glob" - case BashActionReadSource: + case BashActionReadSource, BashActionReadRange: if classification.Path == "" { return } @@ -110,5 +415,14 @@ func runCodexPostToolUse(data []byte) { if err != nil { return } - runPostToolUse(normalized) + if mode != CodexModeSuppress { + runPostToolUse(normalized) + return + } + ctx := postToolContext(input) + emitted := ctx != "" + logHookEffectiveness("PostToolUse", emitted, daemonReachableFn(), 0, time.Since(started)) + if emitted { + emitPostToolContext(ctx, true) + } } diff --git a/internal/hooks/codex_test.go b/internal/hooks/codex_test.go index 730eaf8bf..d378e84e9 100644 --- a/internal/hooks/codex_test.go +++ b/internal/hooks/codex_test.go @@ -5,8 +5,24 @@ import ( "strings" "testing" "time" + + "github.com/zzet/gortex/internal/daemon" ) +func TestParseCodexModeDefaultsAdvisory(t *testing.T) { + tests := map[string]CodexMode{ + "": CodexModeEnrich, "unknown": CodexModeEnrich, "enrich": CodexModeEnrich, + "deny": CodexModeDeny, "hard-deny": CodexModeDeny, + "rewrite": CodexModeRewrite, "input-rewrite": CodexModeRewrite, + "suppress": CodexModeSuppress, "replace-output": CodexModeSuppress, + } + for input, want := range tests { + if got := ParseCodexMode(input); got != want { + t.Errorf("ParseCodexMode(%q)=%v want %v", input, got, want) + } + } +} + func TestRunCodexMalformedJSONNoop(t *testing.T) { out := captureStdout(t, func() { runCodex([]byte(`{`), 0) }) if out != "" { @@ -14,6 +30,24 @@ func TestRunCodexMalformedJSONNoop(t *testing.T) { } } +func TestRunCodexSessionStartUsesManagedOrientationHook(t *testing.T) { + withFakeStatus(t, func() (*daemon.StatusResponse, error) { + return nil, errDaemonUnreachable + }) + data := []byte(`{"hook_event_name":"SessionStart","cwd":"/tmp/gortex","source":"startup"}`) + out := captureStdout(t, func() { runCodex(data, 0) }) + if out == "" { + t.Fatal("expected managed SessionStart orientation") + } + hso := decodeHookOutput(t, out).HookSpecificOutput + if hso == nil || hso.HookEventName != "SessionStart" { + t.Fatalf("invalid SessionStart hook output: %s", out) + } + if !strings.Contains(hso.AdditionalContext, "Call `explore` first") { + t.Fatalf("mandatory compact-tool orientation missing: %q", hso.AdditionalContext) + } +} + func TestRunCodexPostToolUseWithoutParseableOutputSilent(t *testing.T) { data := []byte(`{"hook_event_name":"PostToolUse","tool_name":"Bash","tool_input":{"command":"rg Foo"}}`) out := captureStdout(t, func() { runCodex(data, 0) }) @@ -52,7 +86,8 @@ func TestRunCodexPreToolUseBashSoftAdditionalContext(t *testing.T) { if hso.HookEventName != "PreToolUse" { t.Fatalf("hookEventName=%q want PreToolUse", hso.HookEventName) } - if !strings.Contains(hso.AdditionalContext, "PREFER graph tools over Grep") { + if !strings.Contains(hso.AdditionalContext, "Do not Grep indexed source") || + !strings.Contains(hso.AdditionalContext, `search(operation:"symbols"`) { t.Fatalf("additionalContext missing graph guidance: %q", hso.AdditionalContext) } if hso.PermissionDecision != "" || hso.PermissionDecisionReason != "" { @@ -63,22 +98,30 @@ func TestRunCodexPreToolUseBashSoftAdditionalContext(t *testing.T) { func TestRunCodexPreToolUseGortexMCPReadSoftAdditionalContext(t *testing.T) { withForceCompress(t, false) tests := []struct { - name string - tool string + name string + tool string + input string }{ { - name: "read file", - tool: gortexReadFileTool, + name: "compact read file", + tool: gortexCompactReadTool, + input: `{"operation":"file","target":{"file":"internal/a.go"}}`, }, { - name: "editing context", - tool: gortexEditingContextTool, + name: "read file compatibility", + tool: gortexReadFileTool, + input: `{"path":"internal/a.go"}`, + }, + { + name: "editing context compatibility", + tool: gortexEditingContextTool, + input: `{"path":"internal/a.go"}`, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - data := codexPreToolPayload(tt.tool, `{"path":"internal/a.go"}`) + data := codexPreToolPayload(tt.tool, tt.input) out := captureStdout(t, func() { runCodex(data, 0) }) if out == "" { t.Fatal("expected Codex MCP read PreToolUse guidance, got empty output") @@ -90,11 +133,14 @@ func TestRunCodexPreToolUseGortexMCPReadSoftAdditionalContext(t *testing.T) { if hso.HookEventName != "PreToolUse" { t.Fatalf("hookEventName=%q want PreToolUse", hso.HookEventName) } - for _, want := range []string{"compress_bodies", "search_text", "keep"} { + for _, want := range []string{"compress_bodies", `search(operation:"text", query:`, "keep", "Native Gortex MCP is mandatory"} { if !strings.Contains(hso.AdditionalContext, want) { t.Fatalf("additionalContext missing %q: %q", want, hso.AdditionalContext) } } + if strings.Contains(hso.AdditionalContext, "gortex call ") { + t.Fatalf("MCP read advisory must not advertise CLI fallback: %q", hso.AdditionalContext) + } if hso.PermissionDecision != "" || hso.PermissionDecisionReason != "" { t.Fatalf("Codex MCP read nudge must not deny: %#v", hso) } @@ -102,6 +148,104 @@ func TestRunCodexPreToolUseGortexMCPReadSoftAdditionalContext(t *testing.T) { } } +func TestRunCodexPreToolUseHardDenyAndRewrite(t *testing.T) { + data := codexPreToolPayload(gortexCompactReadTool, `{"target":{"file":"internal/a.go"}}`) + + denied := captureStdout(t, func() { runCodex(data, 0, CodexModeDeny) }) + deny := decodeHookOutput(t, denied).HookSpecificOutput + if deny == nil || deny.PermissionDecision != "deny" || !strings.Contains(deny.PermissionDecisionReason, "compress_bodies") { + t.Fatalf("hard-deny output=%s", denied) + } + if deny.UpdatedInput != nil { + t.Fatalf("deny must not include updatedInput: %#v", deny) + } + + rewritten := captureStdout(t, func() { runCodex(data, 0, CodexModeRewrite) }) + rewrite := decodeHookOutput(t, rewritten).HookSpecificOutput + if rewrite == nil || rewrite.PermissionDecision != "allow" { + t.Fatalf("rewrite output=%s", rewritten) + } + options, ok := rewrite.UpdatedInput["options"].(map[string]any) + if !ok || options["compress_bodies"] != true { + t.Fatalf("rewrite updatedInput=%#v", rewrite.UpdatedInput) + } + target := rewrite.UpdatedInput["target"].(map[string]any) + if target["file"] != "internal/a.go" { + t.Fatalf("rewrite lost target: %#v", rewrite.UpdatedInput) + } +} + +func TestRunCodexHardDenyCoversPureTextAlternation(t *testing.T) { + oldReachable := daemonReachableFn + daemonReachableFn = func() bool { return true } + t.Cleanup(func() { daemonReachableFn = oldReachable }) + oldTextSearch := codexTextSearchHitFn + codexTextSearchHitFn = func(int, string) bool { return true } + t.Cleanup(func() { codexTextSearchHitFn = oldTextSearch }) + data := codexBashPayload(`rg 'Phase 5|world-map' .`) + out := captureStdout(t, func() { runCodex(data, 0, CodexModeDeny) }) + hso := decodeHookOutput(t, out).HookSpecificOutput + if hso == nil || hso.PermissionDecision != "deny" { + t.Fatalf("pure-text alternation was not denied: %s", out) + } + if !strings.Contains(hso.PermissionDecisionReason, "operation `text`") { + t.Fatalf("deny did not route to public text search: %s", hso.PermissionDecisionReason) + } +} + +func TestRunCodexHardDenyRequiresIndexedWorkspaceMatch(t *testing.T) { + oldReachable := daemonReachableFn + daemonReachableFn = func() bool { return true } + t.Cleanup(func() { daemonReachableFn = oldReachable }) + oldTextSearch := codexTextSearchHitFn + codexTextSearchHitFn = func(int, string) bool { return false } + t.Cleanup(func() { codexTextSearchHitFn = oldTextSearch }) + + noHit := captureStdout(t, func() { + runCodex(codexBashPayload(`rg 'Phase 5|world-map' .`), 0, CodexModeDeny) + }) + noHitHSO := decodeHookOutput(t, noHit).HookSpecificOutput + if noHitHSO == nil || noHitHSO.PermissionDecision != "" || noHitHSO.AdditionalContext == "" { + t.Fatalf("unmatched text search must remain advisory: %s", noHit) + } + + oldProbe := grepProbe + grepProbe = func(string, time.Duration) ([]grepSymbolHit, error) { + return []grepSymbolHit{{Name: "Foo", FilePath: "internal/a.go", Line: 1}}, nil + } + t.Cleanup(func() { grepProbe = oldProbe }) + external := []byte(`{"hook_event_name":"PreToolUse","tool_name":"Bash","cwd":"/repo","tool_input":{"command":"rg Foo /tmp/external"}}`) + externalOut := captureStdout(t, func() { runCodex(external, 0, CodexModeDeny) }) + externalHSO := decodeHookOutput(t, externalOut).HookSpecificOutput + if externalHSO == nil || externalHSO.PermissionDecision != "" || externalHSO.AdditionalContext == "" { + t.Fatalf("external search target must never be hard-denied: %s", externalOut) + } +} + +func TestRunCodexBashRewriteOnlyForSimpleIndexedCat(t *testing.T) { + oldIndexed := fileIndexedFn + fileIndexedFn = func(_, path string) (bool, int) { return path == "internal/a.go", 3 } + t.Cleanup(func() { fileIndexedFn = oldIndexed }) + + data := codexBashPayload("cat internal/a.go") + out := captureStdout(t, func() { runCodex(data, 0, CodexModeRewrite) }) + hso := decodeHookOutput(t, out).HookSpecificOutput + if hso == nil || hso.PermissionDecision != "allow" { + t.Fatalf("rewrite output=%s", out) + } + command, _ := hso.UpdatedInput["command"].(string) + if !strings.Contains(command, "gortex call read --json") || strings.Contains(command, "cat internal/a.go") { + t.Fatalf("rewritten command=%q", command) + } + + compound := codexBashPayload("cat internal/a.go | head") + compoundOut := captureStdout(t, func() { runCodex(compound, 0, CodexModeRewrite) }) + compoundHSO := decodeHookOutput(t, compoundOut).HookSpecificOutput + if compoundHSO == nil || compoundHSO.UpdatedInput != nil || compoundHSO.PermissionDecision != "" { + t.Fatalf("compound command must remain advisory: %s", compoundOut) + } +} + func TestRunCodexPreToolUseGortexMCPReadPermissiveModeStaysAdditionalContext(t *testing.T) { withForceCompress(t, true) tests := []struct { @@ -146,6 +290,16 @@ func TestRunCodexPreToolUseGortexMCPReadSilentShapes(t *testing.T) { tool string input string }{ + { + name: "compact source is already narrow", + tool: gortexCompactReadTool, + input: `{"operation":"source","target":{"symbol":"internal/a.go::A"}}`, + }, + { + name: "compact read compressed", + tool: gortexCompactReadTool, + input: `{"operation":"file","target":{"file":"internal/a.go"},"options":{"compress_bodies":true}}`, + }, { name: "read_file compressed", tool: gortexReadFileTool, @@ -220,6 +374,47 @@ func TestRunCodexPostToolUseBashGrepOutputAdditionalContext(t *testing.T) { } } +func TestRunCodexPostToolUseSuppressUsesSupportedReplacement(t *testing.T) { + port := stubBridge(t, nil, + map[string]struct{ ID, Name, Kind string }{ + "internal/a.go:7": {ID: "internal/a.go::MyType", Name: "MyType", Kind: "type"}, + }, nil) + data := codexPostBashPayload("rg -n MyType", "internal/a.go:7:type MyType struct{}\n") + out := captureStdout(t, func() { runCodex(data, port, CodexModeSuppress) }) + decision := decodeHookOutput(t, out) + if decision.Decision != "block" || !strings.Contains(decision.Reason, "type MyType") { + t.Fatalf("suppress replacement output=%s", out) + } + if strings.Contains(out, "suppressOutput") || strings.Contains(out, "updatedMCPToolOutput") { + t.Fatalf("must not emit Codex fields that are parsed but unsupported: %s", out) + } +} + +func TestRunCodexPostToolUseApplyPatchMutationPipeline(t *testing.T) { + changed := `{"changed_files":["internal/a.go"],"changed_symbols":[{"id":"internal/a.go::A"}],"risk":"MEDIUM"}` + srv := newFakeServer(map[string]string{ + "detect_changes": changed, + "get_test_targets": "internal/a_test.go::TestA", + "check_guards": "boundary layering violated", + "contracts": "orphan provider GET /a", + }) + defer srv.Close() + payload := []byte(`{"hook_event_name":"PostToolUse","tool_name":"apply_patch","cwd":"/repo","tool_input":{"command":"*** Begin Patch"},"tool_response":"Done!"}`) + out := captureStdout(t, func() { runCodex(payload, portFromURL(t, srv.URL), CodexModeEnrich) }) + hso := decodeHookOutput(t, out).HookSpecificOutput + if hso == nil { + t.Fatalf("missing apply_patch context: %s", out) + } + for _, want := range []string{"mutation follow-up", "internal/a.go::A", "Tests", "TestA", "Guards", "layering", "Contracts", "orphan provider"} { + if !strings.Contains(hso.AdditionalContext, want) { + t.Fatalf("apply_patch context missing %q: %s", want, hso.AdditionalContext) + } + } + if hso.PermissionDecision != "" { + t.Fatalf("post-mutation advisory must not deny: %#v", hso) + } +} + func TestRunCodexPostToolUseBashReadSourceAdditionalContext(t *testing.T) { tests := []struct { name string @@ -346,6 +541,7 @@ func TestRunCodexPostToolUseBashCommandShapes(t *testing.T) { name string command string response string + want string }{ { name: "grep with no path line output stays quiet", @@ -373,9 +569,10 @@ func TestRunCodexPostToolUseBashCommandShapes(t *testing.T) { response: "internal/a.go:7:type MyType struct{}\n", }, { - name: "sed source read stays quiet", + name: "sed source read is enriched", command: `sed -n '1,20p' internal/a.go`, response: "package hooks\n", + want: "Graph footprint for internal/a.go", }, { name: "unsupported awk source scan stays quiet", @@ -383,19 +580,27 @@ func TestRunCodexPostToolUseBashCommandShapes(t *testing.T) { response: "internal/a.go:7:type MyType struct{}\n", }, { - name: "awk source read stays quiet", + name: "awk unbounded source read stays quiet", command: `awk '{print}' internal/a.go`, response: "package hooks\n", }, { - name: "ls stays quiet", + name: "awk bounded source read is enriched", + command: `awk 'NR>=1 && NR<=20 {print}' internal/a.go`, + response: "package hooks\n", + want: "Graph footprint for internal/a.go", + }, + { + name: "ls file list is enriched", command: "ls /repo", response: "internal/a.go\n", + want: "Glob match(es)", }, { - name: "fd stays quiet", + name: "fd file list is enriched", command: `fd '\.go$' internal`, response: "internal/a.go\n", + want: "Glob match(es)", }, { name: "tree stays quiet", @@ -403,9 +608,16 @@ func TestRunCodexPostToolUseBashCommandShapes(t *testing.T) { response: "internal/a.go\n", }, { - name: "git ls-files stays quiet", + name: "tree full-path list is enriched", + command: "tree -fi internal", + response: "internal/a.go\n", + want: "Glob match(es)", + }, + { + name: "git ls-files is enriched", command: "git ls-files '*.go'", response: "internal/a.go\n", + want: "Glob match(es)", }, } @@ -418,8 +630,14 @@ func TestRunCodexPostToolUseBashCommandShapes(t *testing.T) { data := codexPostBashPayload(tt.command, tt.response) out := captureStdout(t, func() { runCodex(data, port) }) - if out != "" { - t.Fatalf("expected silent no-op, got %q", out) + if tt.want == "" { + if out != "" { + t.Fatalf("expected silent no-op, got %q", out) + } + return + } + if !strings.Contains(out, tt.want) { + t.Fatalf("expected enrichment containing %q, got %q", tt.want, out) } }) } @@ -434,7 +652,7 @@ func TestRunCodexPostToolUseIgnoresNonBash(t *testing.T) { } func TestRunCodexPostToolUseMalformedJSONNoop(t *testing.T) { - out := captureStdout(t, func() { runCodexPostToolUse([]byte(`{`)) }) + out := captureStdout(t, func() { runCodexPostToolUse([]byte(`{`), 0, CodexModeEnrich) }) if out != "" { t.Fatalf("malformed JSON should be silent, got %q", out) } diff --git a/internal/hooks/daemon_tool.go b/internal/hooks/daemon_tool.go index 42060d9a0..584c2120b 100644 --- a/internal/hooks/daemon_tool.go +++ b/internal/hooks/daemon_tool.go @@ -47,6 +47,25 @@ var callServerToolDaemonFn = callServerToolViaDaemon // hook past the host's own hook timeout. const callServerToolTimeout = 5 * time.Second +const ( + hookInternalToolSurface = "core" + hookInternalToolMode = "defer" +) + +// hookMCPHandshake explicitly selects the compatibility surface used by hook +// internals. Hooks call canonical implementation tools such as +// get_file_summary and detect_changes; they are not an agent session and must +// not inherit the compact named-client default. +func hookMCPHandshake(cwd string) daemon.Handshake { + return daemon.Handshake{ + Mode: daemon.ModeMCP, + ClientName: "gortex-hook", + CWD: cwd, + Tools: hookInternalToolSurface, + ToolsMode: hookInternalToolMode, + } +} + // callServerToolViaDaemon runs one MCP tools/call against the local daemon over // its AF_UNIX socket and returns the first text content block, or "" on any // error. cwd scopes the handshake to the caller's workspace so tools that read @@ -57,11 +76,7 @@ func callServerToolViaDaemon(cwd, name string, args map[string]any) string { if args == nil { args = map[string]any{} } - client, err := daemon.Dial(daemon.Handshake{ - Mode: daemon.ModeMCP, - ClientName: "gortex-hook", - CWD: cwd, - }) + client, err := daemon.Dial(hookMCPHandshake(cwd)) if err != nil { return "" } diff --git a/internal/hooks/daemon_tool_test.go b/internal/hooks/daemon_tool_test.go index 78f2a28f5..4230590d9 100644 --- a/internal/hooks/daemon_tool_test.go +++ b/internal/hooks/daemon_tool_test.go @@ -1,6 +1,10 @@ package hooks -import "testing" +import ( + "testing" + + "github.com/zzet/gortex/internal/daemon" +) // The daemon-socket fallback must only fire when a hook CWD has been recorded, // so the pure-HTTP unit tests (which never set one) keep their "no bridge" @@ -35,3 +39,18 @@ func TestCallServerToolDaemonFallbackGatedOnHookCWD(t *testing.T) { t.Fatalf("daemon fallback should fire exactly once, fired %d times", called) } } + +func TestHookMCPHandshakePinsInternalCompatibilitySurface(t *testing.T) { + t.Parallel() + + hs := hookMCPHandshake("/repo") + if hs.Mode != daemon.ModeMCP || hs.ClientName != "gortex-hook" || hs.CWD != "/repo" { + t.Fatalf("unexpected hook handshake identity: %#v", hs) + } + if hookInternalToolSurface != "core" { + t.Fatalf("internal hook surface = %q, want core", hookInternalToolSurface) + } + if hs.Tools != hookInternalToolSurface || hs.ToolsMode != "defer" { + t.Fatalf("hook handshake surface = %q/%q, want core/defer", hs.Tools, hs.ToolsMode) + } +} diff --git a/internal/hooks/dispatch.go b/internal/hooks/dispatch.go index 6deb9c2d2..bd20ecece 100644 --- a/internal/hooks/dispatch.go +++ b/internal/hooks/dispatch.go @@ -103,10 +103,13 @@ func Run(port int, mode Mode) { var peek struct { HookEventName string `json:"hook_event_name"` + CWD string `json:"cwd"` } if err := json.Unmarshal(data, &peek); err != nil { return } + setHookCWD(peek.CWD) + defer setHookCWD("") switch peek.HookEventName { case "PreToolUse": diff --git a/internal/hooks/edit_test.go b/internal/hooks/edit_test.go index 8c48b921e..e95a6423e 100644 --- a/internal/hooks/edit_test.go +++ b/internal/hooks/edit_test.go @@ -71,7 +71,7 @@ func TestEnrichEdit_IndexedSource_Denies(t *testing.T) { if !result.deny { t.Fatal("expected deny for Edit on indexed source") } - for _, want := range []string{"BLOCKED", "edit_symbol", "edit_file", "rename_symbol"} { + for _, want := range []string{"BLOCKED", "choose `edit` operation `symbol`, `file`, or `batch`", `refactor(operation:"rename")`} { if !strings.Contains(result.reason, want) { t.Errorf("reason missing %q:\n%s", want, result.reason) } @@ -86,8 +86,8 @@ func TestEnrichWrite_IndexedSource_Denies(t *testing.T) { if !result.deny { t.Fatal("expected deny for Write on indexed source") } - if !strings.Contains(result.reason, "write_file") { - t.Errorf("reason should mention write_file, got:\n%s", result.reason) + if !strings.Contains(result.reason, `edit(operation:"write")`) { + t.Errorf("reason should mention edit(operation=write), got:\n%s", result.reason) } } diff --git a/internal/hooks/enrich_bash_test.go b/internal/hooks/enrich_bash_test.go index 42d916529..deb084012 100644 --- a/internal/hooks/enrich_bash_test.go +++ b/internal/hooks/enrich_bash_test.go @@ -45,8 +45,8 @@ func TestEnrichBash_GrepMiss_SoftGuidance(t *testing.T) { if r.deny { t.Fatal("miss should not deny") } - if !strings.Contains(r.context, "search_symbols") { - t.Error("miss should return soft guidance mentioning search_symbols") + if !strings.Contains(r.context, `search(operation:"symbols"`) || !strings.Contains(r.context, "operation `text`") { + t.Error("miss should return soft guidance mentioning public search operations") } } @@ -122,8 +122,8 @@ func TestEnrichBash_CatIndexedSource_Denies(t *testing.T) { if !strings.Contains(r.reason, "17 symbols") { t.Error("deny reason should include the symbol count") } - if !strings.Contains(r.reason, "get_file_summary") { - t.Error("deny reason should point to get_file_summary") + if !strings.Contains(r.reason, `read(operation:"summary"`) { + t.Error("deny reason should point to read(operation=summary)") } } @@ -133,8 +133,8 @@ func TestEnrichBash_CatUnindexedSource_SoftGuidance(t *testing.T) { if r.deny { t.Fatal("unindexed source should not deny") } - if !strings.Contains(r.context, "get_symbol_source") { - t.Error("soft guidance should mention get_symbol_source") + if !strings.Contains(r.context, "Use `read` instead") || !strings.Contains(r.context, `read(target:{symbol:`) { + t.Error("soft guidance should show the selector-driven public symbol read") } } diff --git a/internal/hooks/external_agent.go b/internal/hooks/external_agent.go index 2fd15b60e..ad3266ee7 100644 --- a/internal/hooks/external_agent.go +++ b/internal/hooks/external_agent.go @@ -93,7 +93,7 @@ func buildStaleIndexHint(cwd string) string { status, err := sessionStartStatusFn() switch { case errors.Is(err, errDaemonUnreachable): - return "[Gortex] daemon is not running — graph tools are unavailable. Start it: `gortex daemon start --detach`" + return "[Gortex] graph transport is unreachable — treat this as an MCP integration failure, stop indexed code operations, and report it; do not start a daemon manually or switch to a CLI fallback." case err != nil: return "" } diff --git a/internal/hooks/external_agent_test.go b/internal/hooks/external_agent_test.go index 6b6a825d6..563df75c5 100644 --- a/internal/hooks/external_agent_test.go +++ b/internal/hooks/external_agent_test.go @@ -58,8 +58,12 @@ func TestHandleExternalAgent_AfterToolDaemonDown(t *testing.T) { if err := json.Unmarshal([]byte(out), &payload); err != nil { t.Fatalf("invalid JSON: %v\n%s", err, out) } - if !strings.Contains(payload.HookSpecificOutput.AdditionalContext, "daemon is not running") { - t.Errorf("expected a daemon-down hint, got:\n%s", payload.HookSpecificOutput.AdditionalContext) + context := payload.HookSpecificOutput.AdditionalContext + if !strings.Contains(context, "graph transport is unreachable") || !strings.Contains(context, "MCP integration failure") { + t.Errorf("expected an MCP integration-failure hint, got:\n%s", context) + } + if strings.Contains(context, "gortex daemon start") || strings.Contains(context, "gortex call ") { + t.Errorf("native MCP guidance must not advertise a manual fallback, got:\n%s", context) } } diff --git a/internal/hooks/gortex_read.go b/internal/hooks/gortex_read.go index e26609d0e..e3a2711e6 100644 --- a/internal/hooks/gortex_read.go +++ b/internal/hooks/gortex_read.go @@ -7,15 +7,13 @@ import ( "github.com/zzet/gortex/internal/toolref" ) -// The Gortex MCP read tools that return whole-file source. A call to -// either with default arguments hands the agent every function body — -// the exact pattern that burns context in issue #40. They are the one -// gap the native-tool (Read / Grep / Bash) redirects can't cover: once -// the agent is already inside a Gortex tool, nothing else guards how it -// is called. +// The Gortex MCP read tools that can return whole-file source. The compact +// public read dispatcher is the normal path; the two legacy names remain for +// explicitly selected compatibility surfaces. const ( gortexReadFileTool = gortexMCPToolPrefix + "read_file" gortexEditingContextTool = gortexMCPToolPrefix + "get_editing_context" + gortexCompactReadTool = gortexMCPToolPrefix + "read" ) // gortexForceCompressEnvVar upgrades the compress-bodies advisory from a @@ -26,11 +24,8 @@ const ( const gortexForceCompressEnvVar = "GORTEX_HOOK_FORCE_COMPRESS" // enrichGortexRead nudges (or, when GORTEX_HOOK_FORCE_COMPRESS is set, -// denies) a whole-file read through read_file / get_editing_context -// that omits compress_bodies on a source file. Returns an empty result -// — i.e. silent pass-through — for any call that is already economical -// (compressed, size-capped, or a non-code file where compression is a -// no-op). +// denies) a whole-file read that omits compression on a source file. It +// understands both compact and explicitly selected legacy request shapes. func enrichGortexRead(toolName string, toolInput map[string]any) enrichResult { msg := gortexReadNudge(toolName, toolInput) if msg == "" { @@ -48,11 +43,14 @@ func enrichGortexRead(toolName string, toolInput map[string]any) enrichResult { // entirely from the tool input, so the hook stays sub-millisecond) so // callers can surface the message either as soft context or as a deny. func gortexReadNudge(toolName string, toolInput map[string]any) string { + path, options, output, wholeFile := normalizeGortexReadInput(toolName, toolInput) + if !wholeFile { + return "" + } // Already compressing — nothing to suggest. - if asBool(toolInput["compress_bodies"]) { + if asBool(toolInput["compress_bodies"]) || asBool(options["compress_bodies"]) { return "" } - path, _ := toolInput["path"].(string) if path == "" { return "" } @@ -63,25 +61,70 @@ func gortexReadNudge(toolName string, toolInput map[string]any) string { } // The agent already bounded the read (a slice or a token / byte cap) // — it knows what it wants; don't second-guess a constrained call. - if hasReadSizeCap(toolInput) { + if hasReadSizeCap(toolInput) || hasReadSizeCap(options) || hasReadSizeCap(output) { return "" } return gortexReadAdvisory(toolName, path) } -// gortexReadAdvisory builds the reminder shown when a Gortex read tool -// is about to pull full bodies. It names the cheaper paths the reporter -// of issue #40 wished the agent had taken: search_text to locate sites -// without reading bodies, and compress_bodies (+ keep) to read for an -// edit at a fraction of the tokens. +// normalizeGortexReadInput returns the file and nested controls for a read +// operation that can pull whole-file bodies. Compact source/symbol/summary +// operations are already narrow and therefore intentionally return false. +func normalizeGortexReadInput(toolName string, input map[string]any) (path string, options, output map[string]any, wholeFile bool) { + options = map[string]any{} + output = map[string]any{} + switch strings.TrimSpace(toolName) { + case gortexReadFileTool, gortexEditingContextTool, "read_file", "get_editing_context": + path, _ = input["path"].(string) + return path, input, output, true + case gortexCompactReadTool, "read": + operation, _ := input["operation"].(string) + operation = strings.ReplaceAll(strings.ToLower(strings.TrimSpace(operation)), "-", "_") + target, _ := input["target"].(map[string]any) + if operation == "" { + if file, _ := target["file"].(string); strings.TrimSpace(file) != "" { + operation = "file" + } + } + switch operation { + case "file", "editing_context": + default: + return "", options, output, false + } + path, _ = target["file"].(string) + options = mergeReadControls(input["context"], input["options"]) + if shaped, ok := input["output"].(map[string]any); ok { + output = shaped + } + return path, options, output, true + default: + return "", options, output, false + } +} + +func mergeReadControls(values ...any) map[string]any { + out := make(map[string]any) + for _, value := range values { + if fields, ok := value.(map[string]any); ok { + for key, field := range fields { + out[key] = field + } + } + } + return out +} + +// gortexReadAdvisory builds the reminder shown when a Gortex read tool is +// about to pull full bodies. It names only compact public operations and gives +// the mandatory native-MCP transport rule for this configured profile. func gortexReadAdvisory(toolName, path string) string { var b strings.Builder fmt.Fprintf(&b, "[Gortex] %s on %s without compress_bodies — a full-body read can dominate context.\n", shortGortexToolName(toolName), path) - b.WriteString(" - Locating specific call sites? `search_text` returns line-precise hits and reads no bodies.\n") - b.WriteString(" - Reading to edit? Re-call with compress_bodies:true (~30-40% of the tokens; signatures, types, and comments kept).\n") - b.WriteString(" - Need certain bodies in full? Add keep:\"Name1,Name2\" alongside compress_bodies:true.\n") - b.WriteString(toolref.FallbackLine("search_text")) + b.WriteString(" - Locate sites with `search(operation:\"text\", query:\"\")`; it reads no bodies.\n") + b.WriteString(" - Read with `read(target:{file:\"\"}, options:{compress_bodies:true})` when full bodies are unnecessary.\n") + b.WriteString(" - Add `options:{keep:\"Name1,Name2\"}` when selected bodies must stay complete.\n") + b.WriteString(toolref.MCPRequiredLine()) return b.String() } @@ -97,7 +140,7 @@ func shortGortexToolName(toolName string) string { // value is treated as "no cap" so an explicit `max_tokens: 0` opt-out // still draws the nudge. func hasReadSizeCap(toolInput map[string]any) bool { - for _, k := range []string{"max_lines", "max_bytes", "max_tokens"} { + for _, k := range []string{"max_lines", "max_bytes", "max_tokens", "limit"} { if n, ok := toFloat64(toolInput[k]); ok && n > 0 { return true } diff --git a/internal/hooks/gortex_read_test.go b/internal/hooks/gortex_read_test.go index aa9995310..c708f497f 100644 --- a/internal/hooks/gortex_read_test.go +++ b/internal/hooks/gortex_read_test.go @@ -18,13 +18,49 @@ func TestGortexReadNudge_NudgesFullSourceRead(t *testing.T) { if msg == "" { t.Fatal("expected a nudge for a full-body source read") } - for _, want := range []string{"compress_bodies", "search_text", "keep", "read_file"} { + for _, want := range []string{"compress_bodies", `search(operation:"text", query:`, "keep", "read_file"} { if !strings.Contains(msg, want) { t.Errorf("nudge missing %q:\n%s", want, msg) } } } +func TestGortexReadNudge_CompactReadShape(t *testing.T) { + input := map[string]any{ + "operation": "file", + "target": map[string]any{"file": "internal/resolver/resolver.go"}, + } + msg := gortexReadNudge(gortexCompactReadTool, input) + for _, want := range []string{"compress_bodies", `search(operation:"text", query:`, `read(target:{file:`, "Native Gortex MCP is mandatory"} { + if !strings.Contains(msg, want) { + t.Errorf("compact read nudge missing %q:\n%s", want, msg) + } + } + if strings.Contains(msg, "gortex call ") { + t.Errorf("compact read nudge advertised CLI fallback:\n%s", msg) + } + for _, legacy := range []string{"search_text", "read_file", "get_editing_context"} { + if strings.Contains(msg, legacy) { + t.Errorf("compact read nudge leaked legacy tool %q:\n%s", legacy, msg) + } + } +} + +func TestGortexReadNudge_CompactNarrowAndBoundedShapesAreSilent(t *testing.T) { + tests := []map[string]any{ + {"operation": "source", "target": map[string]any{"symbol": "a.go::A"}}, + {"operation": "summary", "target": map[string]any{"file": "a.go"}}, + {"operation": "file", "target": map[string]any{"file": "a.go"}, "options": map[string]any{"compress_bodies": true}}, + {"operation": "editing_context", "target": map[string]any{"file": "a.go"}, "context": map[string]any{"max_tokens": 500}}, + {"operation": "file", "target": map[string]any{"file": "a.go"}, "output": map[string]any{"max_bytes": 4000}}, + } + for _, input := range tests { + if msg := gortexReadNudge(gortexCompactReadTool, input); msg != "" { + t.Errorf("economical compact read emitted nudge:\n%s\ninput=%#v", msg, input) + } + } +} + func TestGortexReadNudge_SilentWhenAlreadyEconomical(t *testing.T) { cases := []struct { name string @@ -88,14 +124,36 @@ func TestEnrichGortexRead_EconomicalCallPassesThroughEvenWhenGated(t *testing.T) func TestEnrich_DispatchesGortexReadTools(t *testing.T) { withForceCompress(t, false) - for _, tool := range []string{gortexReadFileTool, gortexEditingContextTool} { - input := HookInput{ToolName: tool, ToolInput: map[string]any{"path": "a.go"}} + tests := []struct { + tool string + input map[string]any + }{ + {gortexReadFileTool, map[string]any{"path": "a.go"}}, + {gortexEditingContextTool, map[string]any{"path": "a.go"}}, + {gortexCompactReadTool, map[string]any{"operation": "file", "target": map[string]any{"file": "a.go"}}}, + } + for _, tt := range tests { + input := HookInput{ToolName: tt.tool, ToolInput: tt.input} if result := enrich(input, 0); result.context == "" { - t.Errorf("enrich must route %s to enrichGortexRead", tool) + t.Errorf("enrich must route %s to enrichGortexRead", tt.tool) } } } +func TestRunPreToolUse_CompactRead_EmitsAdditionalContext(t *testing.T) { + withForceCompress(t, false) + payload := []byte(`{"hook_event_name":"PreToolUse","tool_name":"mcp__gortex__read","tool_input":{"operation":"file","target":{"file":"internal/x.go"}}}`) + out := captureStdout(t, func() { runPreToolUse(payload, 0, ModeDeny) }) + + var dec HookOutput + if err := json.Unmarshal([]byte(out), &dec); err != nil { + t.Fatalf("invalid JSON: %v\n%s", err, out) + } + if dec.HookSpecificOutput == nil || !strings.Contains(dec.HookSpecificOutput.AdditionalContext, "compress_bodies") { + t.Fatalf("expected compact read guidance, got: %s", out) + } +} + func TestRunPreToolUse_GortexRead_EmitsAdditionalContext(t *testing.T) { withForceCompress(t, false) payload := []byte(`{"hook_event_name":"PreToolUse","tool_name":"mcp__gortex__read_file","tool_input":{"path":"internal/x.go"}}`) diff --git a/internal/hooks/grep_probe_test.go b/internal/hooks/grep_probe_test.go index ca8e0adf6..7d55ca3da 100644 --- a/internal/hooks/grep_probe_test.go +++ b/internal/hooks/grep_probe_test.go @@ -103,7 +103,7 @@ func TestEnrichGrep_SymbolMiss_FallsThrough(t *testing.T) { if result.deny { t.Fatal("miss should not deny") } - if !strings.Contains(result.context, "PREFER graph tools") { + if !strings.Contains(result.context, `search(operation:"symbols"`) { t.Error("miss should return soft guidance") } recs := readDecisions(t, logPath) @@ -154,7 +154,7 @@ func TestEnrichGrep_DaemonUnreachable_NoTelemetry(t *testing.T) { if result.deny { t.Fatal("daemon unreachable should not deny") } - if !strings.Contains(result.context, "PREFER graph tools") { + if !strings.Contains(result.context, `search(operation:"symbols"`) { t.Error("daemon unreachable should still return soft guidance") } if recs := readDecisions(t, logPath); len(recs) != 0 { diff --git a/internal/hooks/hermes_test.go b/internal/hooks/hermes_test.go index 6c6974dc0..92d234187 100644 --- a/internal/hooks/hermes_test.go +++ b/internal/hooks/hermes_test.go @@ -51,7 +51,7 @@ func TestHermesPreToolCall_ReadFileIndexed_Blocks(t *testing.T) { if !strings.Contains(d.Message, "/repo/handler.go") { t.Errorf("message should name the file: %q", d.Message) } - if !strings.Contains(d.Message, "get_symbol_source") { + if !strings.Contains(d.Message, `read(target:{symbol:`) { t.Errorf("message should redirect to graph tools: %q", d.Message) } // Canonical Hermes shape — never Claude's envelope. diff --git a/internal/hooks/hook_tier_test.go b/internal/hooks/hook_tier_test.go index 383baca29..385dc18e2 100644 --- a/internal/hooks/hook_tier_test.go +++ b/internal/hooks/hook_tier_test.go @@ -40,7 +40,7 @@ func TestSessionStart_LeanTier_TrackedCwd(t *testing.T) { if !strings.Contains(briefing, "enforcement active") { t.Errorf("lean briefing lost the enforcement signal:\n%s", briefing) } - if !strings.Contains(briefing, "Rule:") || !strings.Contains(briefing, "search_symbols") { + if !strings.Contains(briefing, "Rule:") || !strings.Contains(briefing, "`explore`") || !strings.Contains(briefing, "`search`") { t.Errorf("lean briefing lost the rule preamble cues:\n%s", briefing) } // The standard-tier status prose must be gone. @@ -88,7 +88,7 @@ func TestPromptInjection_LeanTier(t *testing.T) { if !strings.Contains(lean, "Alpha") { t.Errorf("lean tier dropped the top hit:\n%s", lean) } - if !strings.Contains(lean, "get_symbol_source") || !strings.Contains(lean, "explore") { + if !strings.Contains(lean, `read(operation:"source")`) || !strings.Contains(lean, "explore") { t.Errorf("lean tier lost the tool cues:\n%s", lean) } diff --git a/internal/hooks/kimi.go b/internal/hooks/kimi.go index 2726d73f9..88470ca64 100644 --- a/internal/hooks/kimi.go +++ b/internal/hooks/kimi.go @@ -102,10 +102,8 @@ func runKimiPreToolUseEvent(data []byte, port int, mode Mode) { // runKimiPreToolUse applies the graph-aware PreToolUse posture to one tool call. // -// - Gortex's own whole-file read tools (read_file / get_editing_context) keep -// the historical plain-stdout compress-bodies nudge, never a hard deny — -// matching Claude's soft posture for these and the established Kimi -// contract. +// - Gortex's compact read tool (plus legacy compatibility names) keeps the +// historical plain-stdout compress-bodies nudge, never a hard deny. // - Native tools (Read / Grep / Glob / Bash) and Task subagent spawns run // through the shared enrich + applyMode path: a deny of an indexed // whole-file read becomes a Kimi permission-decision block; soft guidance @@ -188,8 +186,8 @@ func kimiSubagentFallbackBriefing() string { sb.WriteString("[Gortex] Subagent briefing — this repo has a Gortex MCP server.\n") sb.WriteString("Subagents don't inherit project instructions, so the rules below are restated inline:\n\n") sb.WriteString(gortexToolGuidance) - sb.WriteString(toolref.FallbackLine("smart_context")) - sb.WriteString("\n_First call: `smart_context` with your task. Before editing any file: `get_editing_context`. Never Read/Grep an indexed source file._\n") + sb.WriteString(toolref.MCPRequiredLine()) + sb.WriteString("\n_First call: `explore` with the task. Before mutation, call `change(operation:\"impact\")`; inspect with `read`; mutate only with `edit` or `refactor`. After mutation, call `change(operation:\"detect\")`, then use its symbol IDs with `change` operations `tests`, `guards`, and `contract`. Never Read/Grep indexed source._\n") return sb.String() } @@ -246,7 +244,7 @@ func emitKimiDeny(reason string) { func kimiGortexReadPreToolUseTool(toolName string) bool { switch strings.TrimSpace(toolName) { - case gortexReadFileTool, gortexEditingContextTool, "read_file", "get_editing_context": + case gortexCompactReadTool, gortexReadFileTool, gortexEditingContextTool, "read", "read_file", "get_editing_context": return true default: return false diff --git a/internal/hooks/kimi_events_test.go b/internal/hooks/kimi_events_test.go index 26fd5ebdf..fadba461b 100644 --- a/internal/hooks/kimi_events_test.go +++ b/internal/hooks/kimi_events_test.go @@ -32,7 +32,7 @@ func TestRunKimiPreToolUseReadIndexedDenies(t *testing.T) { if !strings.Contains(out, `"permissionDecision":"deny"`) { t.Fatalf("expected a Kimi permission-decision deny, got: %q", out) } - if !strings.Contains(out, "get_symbol_source") { + if !strings.Contains(out, "read(operation") { t.Fatalf("deny reason should name graph tools, got: %q", out) } if strings.Contains(out, "additionalContext") { @@ -52,7 +52,7 @@ func TestRunKimiPreToolUseReadUnindexedSoftStdout(t *testing.T) { if strings.Contains(out, "permissionDecision") || strings.Contains(out, "hookSpecificOutput") { t.Fatalf("an unindexed Read should be soft plain-stdout guidance, not a deny: %q", out) } - if !strings.Contains(out, "get_symbol_source") { + if !strings.Contains(out, "read(operation") { t.Fatalf("expected soft graph guidance, got: %q", out) } } @@ -122,8 +122,8 @@ func TestRunKimiSubagentStartBriefsWithTask(t *testing.T) { if strings.Contains(out, "hookSpecificOutput") { t.Fatalf("subagent briefing must be plain stdout, got JSON: %q", out) } - if !strings.Contains(out, "Use Gortex MCP tools") { - t.Fatalf("expected the tool-swap table, got: %q", out) + if !strings.Contains(out, "MUST use Gortex MCP tools") || !strings.Contains(out, "`explore`") { + t.Fatalf("expected the compact mandatory workflow, got: %q", out) } if !strings.Contains(out, "FooHandler") { t.Fatalf("expected smart_context symbols in the briefing, got: %q", out) @@ -137,8 +137,8 @@ func TestRunKimiSubagentStartFallbackWithoutTask(t *testing.T) { out := captureStdout(t, func() { runKimi([]byte(`{"hook_event_name":"SubagentStart","cwd":`+strconv.Quote(cwd)+`}`), 0, ModeDeny) }) - if !strings.Contains(out, "Use Gortex MCP tools") { - t.Fatalf("expected the static tool-swap fallback briefing, got: %q", out) + if !strings.Contains(out, "MUST use Gortex MCP tools") || !strings.Contains(out, "`change`") { + t.Fatalf("expected the static compact fallback briefing, got: %q", out) } } diff --git a/internal/hooks/kimi_test.go b/internal/hooks/kimi_test.go index dda5c7b48..8c45b0fcc 100644 --- a/internal/hooks/kimi_test.go +++ b/internal/hooks/kimi_test.go @@ -54,36 +54,48 @@ func TestRunKimiPreToolUseGortexReadPlainStdout(t *testing.T) { withForceCompress(t, true) cwd := writeGortexProjectMarker(t, t.TempDir()) tests := []struct { - name string - tool string + name string + tool string + input string }{ { - name: "kimi plain read_file", - tool: "read_file", + name: "compact read", + tool: gortexCompactReadTool, + input: `{"operation":"file","target":{"file":"internal/a.go"}}`, + }, + { + name: "kimi plain read_file", + tool: "read_file", + input: `{"path":"internal/a.go"}`, }, { - name: "kimi plain get_editing_context", - tool: "get_editing_context", + name: "kimi plain get_editing_context", + tool: "get_editing_context", + input: `{"path":"internal/a.go"}`, }, { - name: "prefixed read_file", - tool: gortexReadFileTool, + name: "prefixed read_file", + tool: gortexReadFileTool, + input: `{"path":"internal/a.go"}`, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { out := captureStdout(t, func() { - runKimi(kimiPreToolPayload(cwd, tt.tool, `{"path":"internal/a.go"}`), 0, ModeDeny) + runKimi(kimiPreToolPayload(cwd, tt.tool, tt.input), 0, ModeDeny) }) if out == "" { t.Fatal("expected Kimi MCP read PreToolUse guidance, got empty output") } - for _, want := range []string{"compress_bodies", "search_text", "keep"} { + for _, want := range []string{"compress_bodies", `search(operation:"text", query:`, "keep", "Native Gortex MCP is mandatory"} { if !strings.Contains(out, want) { t.Fatalf("stdout guidance missing %q: %q", want, out) } } + if strings.Contains(out, "gortex call ") { + t.Fatalf("Kimi MCP read guidance advertised CLI fallback: %q", out) + } for _, notWant := range []string{"hookSpecificOutput", "additionalContext", "permissionDecision"} { if strings.Contains(out, notWant) { t.Fatalf("Kimi PreToolUse nudge must stay plain stdout, got %q", out) diff --git a/internal/hooks/main_test.go b/internal/hooks/main_test.go index 697dc89f1..5c4b02d76 100644 --- a/internal/hooks/main_test.go +++ b/internal/hooks/main_test.go @@ -19,6 +19,7 @@ func TestMain(m *testing.M) { dir, err := os.MkdirTemp("", "gortex-hooks-test") if err == nil { _ = os.Setenv("GORTEX_HOOK_LOG", filepath.Join(dir, "hook-decisions.jsonl")) + _ = os.Setenv("GORTEX_HOOK_EFFECTIVENESS_LOG", filepath.Join(dir, "hook-effectiveness.jsonl")) defer func() { _ = os.RemoveAll(dir) }() } // Default the file-indexed / file-summary probes to "not indexed" so no @@ -27,5 +28,6 @@ func TestMain(m *testing.M) { // stubBridge) and restore these defaults on cleanup. fileIndexedFn = func(_, _ string) (bool, int) { return false, 0 } fileSummaryFn = func(_, _ string) (*hookFileSummary, bool) { return nil, false } + callServerToolDaemonFn = func(string, string, map[string]any) string { return "" } os.Exit(m.Run()) } diff --git a/internal/hooks/pi_test.go b/internal/hooks/pi_test.go index ed50e4431..c35921aa2 100644 --- a/internal/hooks/pi_test.go +++ b/internal/hooks/pi_test.go @@ -23,11 +23,8 @@ func TestHandlePi_SessionStartReturnsOrientation(t *testing.T) { } } -func TestHandlePi_ToolCallBlocksGreedyGlob(t *testing.T) { - // Greedy source glob denies only when the daemon is reachable. - prev := daemonReachableFn - daemonReachableFn = func() bool { return true } - t.Cleanup(func() { daemonReachableFn = prev }) +func TestHandlePi_ToolCallBlocksTrackedGreedyGlob(t *testing.T) { + stubTrackedScope(t, true) d := handlePi([]byte(`{"event":"tool_call","tool_name":"Glob","tool_input":{"pattern":"**/*.go"},"cwd":"/tmp/repo"}`), 0, ModeDeny) if !d.Block { @@ -39,9 +36,7 @@ func TestHandlePi_ToolCallBlocksGreedyGlob(t *testing.T) { } func TestHandlePi_EnrichModeDowngradesBlockToContext(t *testing.T) { - prev := daemonReachableFn - daemonReachableFn = func() bool { return true } - t.Cleanup(func() { daemonReachableFn = prev }) + stubTrackedScope(t, true) d := handlePi([]byte(`{"event":"tool_call","tool_name":"Glob","tool_input":{"pattern":"**/*.go"},"cwd":"/tmp/repo"}`), 0, ModeEnrich) if d.Block { @@ -53,9 +48,7 @@ func TestHandlePi_EnrichModeDowngradesBlockToContext(t *testing.T) { } func TestHandlePi_GortexToolNeverBlocks(t *testing.T) { - prev := daemonReachableFn - daemonReachableFn = func() bool { return true } - t.Cleanup(func() { daemonReachableFn = prev }) + stubTrackedScope(t, true) // Even a name that would otherwise classify as a fallback is allowed // when flagged as a Gortex graph tool. diff --git a/internal/hooks/posttask.go b/internal/hooks/posttask.go index 346db02b0..e64b0f2b8 100644 --- a/internal/hooks/posttask.go +++ b/internal/hooks/posttask.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "strings" + "time" ) // PostTaskInput is the JSON structure Claude Code sends to Stop hooks. @@ -26,6 +27,7 @@ type PostTaskInput struct { // Degrades silently when: the bridge is unreachable, there are no // changes, or stop_hook_active is true. func runPostTask(data []byte, port int) { + started := time.Now() var input PostTaskInput if err := json.Unmarshal(data, &input); err != nil { return @@ -33,6 +35,10 @@ func runPostTask(data []byte, port int) { if input.HookEventName != "Stop" { return } + emitted := false + defer func() { + logHookEffectiveness("Stop", emitted, daemonReachableFn(), 0, time.Since(started)) + }() // Prevent recursion — if we're already rerunning a Stop hook, don't fire again. if input.StopHookActive { return @@ -53,6 +59,7 @@ func runPostTask(data []byte, port int) { if err != nil { return } + emitted = true fmt.Print(string(out)) } @@ -149,6 +156,64 @@ func buildPostTaskBriefing(port int) string { return sb.String() } +// buildMutationBriefing is the focused PostToolUse pipeline for mutations that +// happen outside Gortex, notably Codex apply_patch. It detects affected graph +// symbols first, then asks for tests, guards, and contracts using that observed +// change set. The hook is advisory: an unavailable daemon or an edit touching +// no indexed symbol yields no output and never changes the completed mutation. +func buildMutationBriefing(port int) string { + raw := callServerTool(port, "detect_changes", map[string]any{"scope": "unstaged"}) + if raw == "" { + return "" + } + var changes struct { + ChangedFiles []string `json:"changed_files"` + ChangedSymbols []struct { + ID string `json:"id"` + } `json:"changed_symbols"` + Risk string `json:"risk"` + } + if json.Unmarshal([]byte(raw), &changes) != nil || len(changes.ChangedSymbols) == 0 { + return "" + } + ids := make([]string, 0, len(changes.ChangedSymbols)) + for _, symbol := range changes.ChangedSymbols { + if strings.TrimSpace(symbol.ID) != "" { + ids = append(ids, symbol.ID) + } + } + if len(ids) == 0 { + return "" + } + idsCSV := strings.Join(ids, ",") + + var out strings.Builder + out.WriteString("## Gortex mutation follow-up\n\n") + fmt.Fprintf(&out, "Detected %d affected symbol(s) across %d file(s); risk `%s`.\n\n", len(ids), len(changes.ChangedFiles), changes.Risk) + out.WriteString("Affected symbols:\n") + for _, id := range ids { + fmt.Fprintf(&out, "- `%s`\n", id) + } + out.WriteString("\n") + if tests := renderTestTargets(port, idsCSV); tests != "" { + out.WriteString("### Tests\n\n") + out.WriteString(tests) + out.WriteString("\n") + } + if guards := renderGuardViolations(port, idsCSV); guards != "" { + out.WriteString("### Guards\n\n") + out.WriteString(guards) + out.WriteString("\n") + } + if contracts := renderContractMismatches(port); contracts != "" { + out.WriteString("### Contracts\n\n") + out.WriteString(contracts) + out.WriteString("\n") + } + out.WriteString("Run the selected tests and resolve any guard or contract findings before handoff.\n") + return out.String() +} + // renderTestTargets asks the bridge for test files that exercise the changed symbols. func renderTestTargets(port int, idsCSV string) string { raw := callServerTool(port, "get_test_targets", map[string]any{ diff --git a/internal/hooks/posttooluse.go b/internal/hooks/posttooluse.go index 0cb58c6ec..3a594fcf4 100644 --- a/internal/hooks/posttooluse.go +++ b/internal/hooks/posttooluse.go @@ -7,6 +7,7 @@ import ( "sort" "strconv" "strings" + "time" ) // postHookInput is the PostToolUse payload Claude Code sends. It differs @@ -44,6 +45,7 @@ type postHookInput struct { // was removed when the web surface migrated to the daemon, so these // lookups silently returned nothing regardless of configuration (#241). func runPostToolUse(data []byte) { + started := time.Now() var input postHookInput if err := json.Unmarshal(data, &input); err != nil { return @@ -51,26 +53,45 @@ func runPostToolUse(data []byte) { if input.HookEventName != "PostToolUse" { return } + emitted := false + defer func() { + logHookEffectiveness("PostToolUse", emitted, daemonReachableFn(), 0, time.Since(started)) + }() - var ctx string + ctx := postToolContext(input) + if ctx == "" { + return + } + emitted = true + emitPostToolContext(ctx, false) +} + +func postToolContext(input postHookInput) string { switch input.ToolName { case "Grep": - ctx = postGrep(input) + return postGrep(input) case "Glob": - ctx = postGlob(input) + return postGlob(input) case "Read": - ctx = postRead(input) - } - if ctx == "" { - return + return postRead(input) } + return "" +} +func emitPostToolContext(ctx string, replace bool) { output := HookOutput{ HookSpecificOutput: &HookSpecificOutput{ HookEventName: "PostToolUse", AdditionalContext: ctx, }, } + if replace { + // Codex currently implements output replacement through the supported + // PostToolUse block decision. The nominal suppressOutput field is parsed + // but rejected by current releases, so never emit it. + output.Decision = "block" + output.Reason = ctx + } out, err := json.Marshal(output) if err != nil { return @@ -134,7 +155,7 @@ func postGrep(input postHookInput) string { for _, line := range enriched { b.WriteString(line + "\n") } - b.WriteString("Follow-up: pass any symbol ID to `find_usages` / `get_callers` for blast radius without re-Grep.\n") + b.WriteString("Follow-up: call `relations(operation:\"usages\", target:{symbol:\"\"})`; choose operation `callers` when you need only callers. Do not re-Grep.\n") return b.String() } @@ -185,7 +206,7 @@ func postGlob(input postHookInput) string { if len(paths)-unindexed > len(indexed) { fmt.Fprintf(&b, " ... and %d more indexed file(s)\n", (len(paths)-unindexed)-len(indexed)) } - b.WriteString("Follow-up: `get_file_summary` for any single file; `get_repo_outline` for the whole workspace shape.\n") + b.WriteString("Follow-up: use `read(operation:\"summary\", target:{file:\"\"})` for one file or `explore(operation:\"outline\")` for the workspace shape.\n") return b.String() } @@ -209,7 +230,7 @@ func postRead(input postHookInput) string { if summary.Dependents > 0 { fmt.Fprintf(&b, " %d file(s) import this one\n", summary.Dependents) } - b.WriteString("Follow-up: `get_file_summary` / `get_editing_context` returns the same info plus signatures, no re-Read needed.\n") + b.WriteString("Follow-up: call `read(operation:\"summary\", target:{file:\"\"})`; choose operation `editing_context` before editing. Do not re-Read.\n") return b.String() } diff --git a/internal/hooks/precompact.go b/internal/hooks/precompact.go index 3f66c3c50..ca61fd4ab 100644 --- a/internal/hooks/precompact.go +++ b/internal/hooks/precompact.go @@ -31,6 +31,7 @@ type PreCompactInput struct { // Graceful degradation: if the bridge is unreachable (port wrong, server down), // the hook returns silently with no output. It never blocks compaction. func runPreCompact(data []byte, port int) { + started := time.Now() var input PreCompactInput if err := json.Unmarshal(data, &input); err != nil { return @@ -38,6 +39,10 @@ func runPreCompact(data []byte, port int) { if input.HookEventName != "PreCompact" { return } + emitted := false + defer func() { + logHookEffectiveness("PreCompact", emitted, daemonReachableFn(), 0, time.Since(started)) + }() briefing := buildPreCompactBriefing(port) if briefing == "" { @@ -54,6 +59,7 @@ func runPreCompact(data []byte, port int) { if err != nil { return } + emitted = true fmt.Print(string(out)) } @@ -68,7 +74,7 @@ func buildPreCompactBriefing(port int) string { var sb strings.Builder sb.WriteString("## Gortex PreCompact Snapshot\n\n") - sb.WriteString("Use this orientation to avoid re-exploring after compaction. Prefer graph tools (`smart_context`, `get_symbol_source`, `get_editing_context`) over re-reading files.\n\n") + sb.WriteString("Continue with Gortex after compaction: call `explore` for the task, then inspect with `search`, `read`, `relations`, or `trace`; do not re-read indexed files.\n\n") if summary := renderStatsSummary(stats); summary != "" { sb.WriteString("**Index:** ") @@ -177,25 +183,24 @@ func cappedLines(s string, max int) string { return strings.Join(lines, "\n") + "\n" } -// callServerTool resolves a Gortex tool call for a hook handler. It first -// tries the HTTP REST surface (POST /v1/tools/{name} on the given port, served -// only when the daemon runs with --http-addr); when that yields nothing it -// falls back to the daemon's AF_UNIX socket, scoped to the current hook's -// working directory. Returns "" on any error so callers degrade silently. +// callServerTool resolves a Gortex tool call for a hook handler. Live hook +// invocations carry a CWD and use the daemon's default AF_UNIX socket first; +// the optional HTTP REST surface is a compatibility fallback for older +// integrations and focused tests. Returns "" on any error so callers degrade +// silently. // // The socket fallback is gated on a set hookCWD (see setHookCWD): the pure-HTTP // unit tests never set it, so they keep their existing "no bridge" semantics, // while a live hook process — which sets hookCWD from the payload — reaches a // normally-running daemon that only listens on the socket. func callServerTool(port int, name string, args map[string]any) string { - if raw := callServerToolHTTP(port, name, args); raw != "" { - return raw - } cwd := loadHookCWD() - if cwd == "" { - return "" + if cwd != "" { + if raw := callServerToolDaemonFn(cwd, name, args); raw != "" { + return raw + } } - return callServerToolDaemonFn(cwd, name, args) + return callServerToolHTTP(port, name, args) } // callServerToolHTTP issues a POST /v1/tools/{name} against the server and diff --git a/internal/hooks/pretool_enforcement_test.go b/internal/hooks/pretool_enforcement_test.go new file mode 100644 index 000000000..0633e307d --- /dev/null +++ b/internal/hooks/pretool_enforcement_test.go @@ -0,0 +1,169 @@ +package hooks + +import ( + "strings" + "testing" +) + +func stubIndexedFile(t *testing.T, indexed bool, symbols int) { + t.Helper() + old := fileIndexedFn + fileIndexedFn = func(string, string) (bool, int) { return indexed, symbols } + t.Cleanup(func() { fileIndexedFn = old }) +} + +func stubTrackedScope(t *testing.T, tracked bool) { + t.Helper() + old := scopeTrackedFn + scopeTrackedFn = func(string, string) bool { return tracked } + t.Cleanup(func() { scopeTrackedFn = old }) +} + +func TestEnrichReadBlocksIndexedRangedRead(t *testing.T) { + stubIndexedFile(t, true, 7) + + result := enrichRead(map[string]any{ + "file_path": "internal/hooks/pretooluse.go", + "offset": float64(10), + "limit": float64(20), + }, "/repo") + + if !result.deny { + t.Fatalf("indexed ranged Read was not denied: %#v", result) + } + if !strings.Contains(result.reason, "7 symbols indexed") { + t.Fatalf("deny reason does not retain indexed-file evidence: %q", result.reason) + } +} + +func TestEnrichReadUnindexedRangedReadFallsBackSoftly(t *testing.T) { + stubIndexedFile(t, false, 0) + + result := enrichRead(map[string]any{ + "file_path": "internal/hooks/pretooluse.go", + "offset": float64(10), + "limit": float64(20), + }, "/repo") + + if result.deny || result.context == "" { + t.Fatalf("unindexed ranged Read should remain soft: %#v", result) + } +} + +func TestEnrichGrepDeniesAnyPatternInTrackedScope(t *testing.T) { + stubTrackedScope(t, true) + + for _, pattern := range []string{"e.x|ex", "ca740d9"} { + t.Run(pattern, func(t *testing.T) { + result := enrichGrep(map[string]any{"pattern": pattern}, 0, "/repo") + if !result.deny { + t.Fatalf("tracked Grep %q was not denied: %#v", pattern, result) + } + if !strings.Contains(result.reason, "search(operation:\"text\"") { + t.Fatalf("tracked Grep lacks indexed-search redirect: %q", result.reason) + } + }) + } +} + +func TestEnrichGrepUntrackedRegexFallsBackSoftly(t *testing.T) { + stubTrackedScope(t, false) + + result := enrichGrep(map[string]any{"pattern": "e.x|ex"}, 0, "/untracked") + if result.deny || result.context == "" { + t.Fatalf("untracked regex Grep should remain soft: %#v", result) + } +} + +func TestEnrichGlobDeniesSourcePatternInTrackedScope(t *testing.T) { + stubTrackedScope(t, true) + + result := enrichGlob(map[string]any{ + "pattern": "**/handler*.go", + "path": "internal", + }, "/repo") + if !result.deny { + t.Fatalf("tracked source Glob was not denied: %#v", result) + } + if !strings.Contains(result.reason, "search(operation:\"files\"") { + t.Fatalf("tracked Glob lacks indexed-file redirect: %q", result.reason) + } +} + +func TestEnrichGlobUntrackedSourcePatternFallsBackSoftly(t *testing.T) { + stubTrackedScope(t, false) + + result := enrichGlob(map[string]any{ + "pattern": "**/handler*.go", + "path": "internal", + }, "/untracked") + if result.deny || result.context == "" { + t.Fatalf("untracked source Glob should remain soft: %#v", result) + } +} + +func TestScopeTrackedViaDaemonUnavailable(t *testing.T) { + old := daemonReachableFn + daemonReachableFn = func() bool { return false } + t.Cleanup(func() { daemonReachableFn = old }) + + if scopeTrackedViaDaemon("/repo", "internal") { + t.Fatal("unreachable daemon must not prove a tracked scope") + } +} + +func TestEnrichGrepExplicitNonSourceFileStaysSoft(t *testing.T) { + stubTrackedScope(t, true) + stubProbe(t, nil, errDaemonUnreachable) + + result := enrichGrep(map[string]any{ + "pattern": "TODO", + "path": "/repo/README.md", + }, 0, "/repo") + if result.deny || result.context == "" { + t.Fatalf("explicit non-source Grep must remain soft: %#v", result) + } +} + +func TestEnrichGrepIndexedSourceFileDenies(t *testing.T) { + stubIndexedFile(t, true, 4) + stubTrackedScope(t, false) + + result := enrichGrep(map[string]any{ + "pattern": "e.x|ex", + "path": "pkg/matcher.go", + }, 0, "/repo") + if !result.deny { + t.Fatalf("indexed source-file Grep was not denied: %#v", result) + } +} + +func TestEnrichGlobUntrackedDaemonUpGreedyPatternStaysSoft(t *testing.T) { + stubTrackedScope(t, false) + withDaemonReachable(t, true) + + result := enrichGlob(map[string]any{"pattern": "**/*.go"}, "/untracked") + if result.deny || result.context == "" { + t.Fatalf("daemon reachability alone must not deny an untracked Glob: %#v", result) + } +} + +func TestParseFindFilesHasSourceRequiresNonEmptyIndexedResult(t *testing.T) { + withSource := []byte(`{"result":{"content":[{"text":"{\"count\":1,\"files\":[{\"path\":\"pkg/a.go\"}]}"}]}}`) + if !parseFindFilesHasSource(withSource) { + t.Fatal("non-empty find_files result should prove indexed source") + } + withoutSource := []byte(`{"result":{"content":[{"text":"{\"count\":0,\"files\":[]}"}]}}`) + if parseFindFilesHasSource(withoutSource) { + t.Fatal("empty find_files result must not prove indexed source") + } +} + +func TestTrackedSearchDenyStillSoftensInEnrichMode(t *testing.T) { + stubTrackedScope(t, true) + raw := enrichGrep(map[string]any{"pattern": "ca740d9"}, 0, "/repo") + result := applyMode(HookInput{ToolName: "Grep"}, false, ModeEnrich, raw) + if result.deny || result.context == "" { + t.Fatalf("ModeEnrich posture should soften the new tracked-scope deny: %#v", result) + } +} diff --git a/internal/hooks/pretooluse.go b/internal/hooks/pretooluse.go index 605ba7b88..0ab2549e4 100644 --- a/internal/hooks/pretooluse.go +++ b/internal/hooks/pretooluse.go @@ -33,15 +33,21 @@ type HookInput struct { // HookOutput is the JSON structure the hook writes to stdout. type HookOutput struct { + Decision string `json:"decision,omitempty"` + Reason string `json:"reason,omitempty"` + Continue *bool `json:"continue,omitempty"` + StopReason string `json:"stopReason,omitempty"` + SystemMessage string `json:"systemMessage,omitempty"` HookSpecificOutput *HookSpecificOutput `json:"hookSpecificOutput,omitempty"` } // HookSpecificOutput carries the permission decision and/or additional context. type HookSpecificOutput struct { - HookEventName string `json:"hookEventName"` - AdditionalContext string `json:"additionalContext,omitempty"` - PermissionDecision string `json:"permissionDecision,omitempty"` - PermissionDecisionReason string `json:"permissionDecisionReason,omitempty"` + HookEventName string `json:"hookEventName"` + AdditionalContext string `json:"additionalContext,omitempty"` + PermissionDecision string `json:"permissionDecision,omitempty"` + PermissionDecisionReason string `json:"permissionDecisionReason,omitempty"` + UpdatedInput map[string]any `json:"updatedInput,omitempty"` } // enrichResult carries both the context text and whether the call should be blocked. @@ -76,6 +82,7 @@ const gortexMCPToolPrefix = "mcp__gortex__" // alternative but the original call still runs and PostToolUse can layer // graph context on the actual output. func runPreToolUse(data []byte, gortexPort int, mode Mode) { + started := time.Now() var input HookInput if err := json.Unmarshal(data, &input); err != nil { return @@ -84,6 +91,10 @@ func runPreToolUse(data []byte, gortexPort int, mode Mode) { if input.HookEventName != "PreToolUse" { return } + emitted := false + defer func() { + logHookEffectiveness("PreToolUse", emitted, daemonReachableFn(), hookAlternationSegmentCount(input), time.Since(started)) + }() isGortexMCP := strings.HasPrefix(input.ToolName, gortexMCPToolPrefix) @@ -106,6 +117,7 @@ func runPreToolUse(data []byte, gortexPort int, mode Mode) { if adv := gortexReadNudge(input.ToolName, input.ToolInput); adv != "" { hso.AdditionalContext = adv } + emitted = hso.AdditionalContext != "" || hso.PermissionDecisionReason != "" emitPreToolUse(HookOutput{HookSpecificOutput: hso}) return } @@ -138,9 +150,28 @@ func runPreToolUse(data []byte, gortexPort int, mode Mode) { output.HookSpecificOutput.AdditionalContext = result.context } + emitted = true emitPreToolUse(output) } +func hookAlternationSegmentCount(input HookInput) int { + var pattern string + switch input.ToolName { + case "Grep": + pattern, _ = input.ToolInput["pattern"].(string) + case "Bash": + command, _ := input.ToolInput["command"].(string) + classification := classifyBashCommand(command) + if classification.Action == BashActionGrepLike || classification.Action == BashActionFindName { + pattern = classification.Pattern + } + } + if pattern == "" || !strings.Contains(pattern, "|") { + return 0 + } + return len(splitAlternation(pattern)) +} + // applyMode adjusts a raw enrich result according to the active posture. // Shared by the Claude Code PreToolUse handler (runPreToolUse) and the Pi // bridge (RunPi) so the deny / enrich / consult-unlock / nudge semantics @@ -287,8 +318,8 @@ func adaptiveNudge(input HookInput, isGortexMCP bool, result enrichResult) enric func nudgeReason(guidance string) string { var b strings.Builder b.WriteString("[Gortex] You've made several raw file-search calls in a row. ") - b.WriteString("Localizing a task? Call `explore` — one call returns the ranked neighborhood (likely symbols + source + call paths) and ends the search/read loop. Otherwise prefer `search_symbols`, `find_usages`, `get_callers`, `get_symbol_source` — all faster and far more precise than raw search.\n") - b.WriteString(toolref.FallbackLine("search_symbols")) + b.WriteString("Call `explore` for the task, then use `search`, `read`, or `relations`; do not continue the raw search/read loop.\n") + b.WriteString(toolref.MCPRequiredLine()) if guidance != "" { b.WriteString(guidance) if !strings.HasSuffix(guidance, "\n") { @@ -304,9 +335,9 @@ func enrich(input HookInput, port int) enrichResult { case "Read": return enrichRead(input.ToolInput, input.CWD) case "Grep": - return enrichGrep(input.ToolInput, port) + return enrichGrep(input.ToolInput, port, input.CWD) case "Glob": - return enrichGlob(input.ToolInput) + return enrichGlob(input.ToolInput, input.CWD) case "Task": return enrichTask(input.ToolInput, port) case "Bash": @@ -315,15 +346,15 @@ func enrich(input HookInput, port int) enrichResult { return enrichEdit(input.ToolInput, input.CWD) case "Write": return enrichWrite(input.ToolInput, input.CWD) - case gortexReadFileTool, gortexEditingContextTool: + case gortexReadFileTool, gortexEditingContextTool, gortexCompactReadTool: return enrichGortexRead(input.ToolName, input.ToolInput) default: return enrichResult{} } } -// enrichRead blocks whole-file reads of indexed source files and suggests graph tools. -// Narrow reads (with offset+limit for editing) are allowed through with advisory context. +// enrichRead blocks reads of indexed source files and suggests graph tools. +// Ranged reads are included: indexed source must stay on the graph-aware read path. func enrichRead(toolInput map[string]any, cwd string) enrichResult { filePath, ok := toolInput["file_path"].(string) if !ok || filePath == "" { @@ -335,25 +366,17 @@ func enrichRead(toolInput map[string]any, cwd string) enrichResult { return enrichResult{} } - // Detect narrow reads (offset+limit for editing). These are legitimate - // and should pass through — the agent already knows what it needs. - if isNarrowRead(toolInput) { - return enrichResult{} - } - fileIndexed, symbolCount := queryFileIndexed(cwd, filePath) // If the file is indexed, BLOCK the read and provide graph alternatives. if fileIndexed { var reason strings.Builder - fmt.Fprintf(&reason, "[Gortex] BLOCKED: Read of %s (%d symbols indexed). Read it through a graph tool instead:\n", filePath, symbolCount) - reason.WriteString(" - `get_symbol_source` — read one symbol (80% fewer tokens); if `explore` already returned this file, its source is in context — read a listed symbol by its `id:`\n") - reason.WriteString(" - `batch_symbols` — several symbols in one call\n") - reason.WriteString(" - `get_editing_context` — full file context before editing\n") - reason.WriteString(" - `get_file_summary` — all symbols and imports\n") - reason.WriteString(" - `explore` — if you have not localized yet: one call for the ranked symbols + source + call paths\n") + fmt.Fprintf(&reason, "[Gortex] BLOCKED: Read of %s (%d symbols indexed). Call `explore` first, then use `read` instead:\n", filePath, symbolCount) + reason.WriteString(" - `read(target:{symbol:\"\"})` — one symbol\n") + reason.WriteString(" - `read(target:{symbols:[\"\"]})` — several symbols\n") + reason.WriteString(" - `read(operation:\"editing_context\", target:{file:\"\"})` — full editing context\n") reason.WriteString(gcxTip) - reason.WriteString(toolref.FallbackLine("get_symbol_source")) + reason.WriteString(toolref.MCPRequiredLine()) return enrichResult{ deny: true, @@ -363,21 +386,20 @@ func enrichRead(toolInput map[string]any, cwd string) enrichResult { // File not indexed — allow with advisory. var guidance strings.Builder - guidance.WriteString("[Gortex] PREFER graph tools over Read for source files:\n") - guidance.WriteString(" - Localizing a whole task (a bug / \"where is X\"): use `explore` (ranked neighborhood + source + call paths in one call)\n") - guidance.WriteString(" - To read one symbol: use `get_symbol_source` (80% fewer tokens)\n") - guidance.WriteString(" - To understand a file before editing: use `get_editing_context`\n") - guidance.WriteString(" - To get a file overview: use `get_file_summary`\n") + guidance.WriteString("[Gortex] Use `explore` first, then `read` for indexed source:\n") + guidance.WriteString(" - one symbol: `read(target:{symbol:\"\"})`\n") + guidance.WriteString(" - before editing: `read(operation:\"editing_context\", target:{file:\"\"})`\n") + guidance.WriteString(" - file overview: `read(operation:\"summary\", target:{file:\"\"})`\n") guidance.WriteString(gcxTip) - guidance.WriteString(toolref.FallbackLine("get_symbol_source")) + guidance.WriteString(toolref.MCPRequiredLine()) return enrichResult{context: guidance.String()} } // gcxTip is appended to every Read/Grep/Glob redirect so agents see the -// GCX1 wire-format opt-in at the exact moment they are picking a tool -// call. Kept short — the messages are read under token pressure. -const gcxTip = " - Tip: pass format:\"gcx\" to any of these for round-trippable compact output (~27% fewer tokens, spec: docs/wire-format.md).\n" +// compact-output option at the exact moment they are picking a tool call. +// Public tools nest response shaping under output. +const gcxTip = " - For compact output, pass `output:{format:\"gcx\"}`.\n" // isNarrowRead returns true if the Read has offset+limit targeting a small range, // indicating the agent is reading a specific section for editing. @@ -437,15 +459,63 @@ type grepProbeFn func(pattern string, timeout time.Duration) ([]grepSymbolHit, e // tests reassign this var via a t.Cleanup-restored helper. var grepProbe grepProbeFn = probeViaDaemon -// enrichGrep classifies the Grep pattern and, for symbol-shaped patterns, -// probes the local daemon's search_symbols endpoint. On ≥1 hit the call is -// denied with top matches and a bypass hint; on miss/timeout/non-symbol the -// existing soft guidance is returned so Grep proceeds. -func enrichGrep(toolInput map[string]any, _ int) enrichResult { +// enrichGrep denies searches within a proven tracked/indexed scope, regardless +// of pattern shape. When scope ownership cannot be established, the existing +// symbol probe and soft fallback preserve the historical posture. +func enrichGrep(toolInput map[string]any, _ int, cwdArg ...string) enrichResult { + cwd := "" + if len(cwdArg) > 0 { + cwd = cwdArg[0] + } pattern, _ := toolInput["pattern"].(string) + if pattern == "" { + return enrichResult{} + } + if hookSearchScopeIndexed(cwd, toolInput) { + return enrichResult{ + deny: true, + reason: formatTrackedSearchDeny("Grep", pattern), + } + } return probeSymbolPattern("Grep", pattern, defaultGrepGuidance()) } +func hookSearchScopeIndexed(cwd string, toolInput map[string]any) bool { + scope, _ := toolInput["path"].(string) + scope = strings.TrimSpace(scope) + if scope != "" { + abs := scope + if !filepath.IsAbs(abs) && cwd != "" { + abs = filepath.Join(cwd, abs) + } + if info, err := os.Stat(abs); err == nil && !info.IsDir() { + if !looksLikeSourceFile(scope) { + return false + } + indexed, _ := queryFileIndexed(cwd, scope) + return indexed + } + if filepath.Ext(scope) != "" { + if !looksLikeSourceFile(scope) { + return false + } + indexed, _ := queryFileIndexed(cwd, scope) + return indexed + } + } + return scopeTrackedFn(cwd, scope) +} + +func formatTrackedSearchDeny(tool, query string) string { + var b strings.Builder + fmt.Fprintf(&b, "[Gortex] BLOCKED: %s `%s` targets indexed source. Use the indexed search surface instead:\n", tool, query) + b.WriteString(" - `search(operation:\"text\", query:\"\")` — literal or regex-related source lookup\n") + b.WriteString(" - `search(operation:\"symbols\", query:\"\")` — symbol lookup\n") + b.WriteString(gcxTip) + b.WriteString(toolref.MCPRequiredLine()) + return b.String() +} + // maxAlternationProbes caps how many identifier-shaped alternatives of a // multi-keyword grep pattern (grep 'a|b|c') the hook probes, so a long // alternation can't fan out into an unbounded number of daemon round-trips. @@ -618,24 +688,19 @@ func splitAlternation(pattern string) []string { func defaultGrepGuidance() string { var b strings.Builder - b.WriteString("[Gortex] PREFER graph tools over Grep:\n") - b.WriteString(" - Localizing a task / bug (not a single symbol): use `explore` (ranked neighborhood + source + call paths in one call)\n") - b.WriteString(" - To find a symbol by name: use `search_symbols` (BM25 + camelCase-aware)\n") - b.WriteString(" - To find all references: use `find_usages` (zero false positives)\n") - b.WriteString(" - To find callers: use `get_callers`\n") - b.WriteString(" - To find implementations: use `find_implementations`\n") - b.WriteString(" - For literal / multi-keyword text (phrases, `foo|bar|baz`, hyphenated or other non-identifier strings): use `search_text` (trigram literal / regex search)\n") - b.WriteString(" - For TODO / FIXME / HACK / XXX / NOTE patterns: use `analyze kind=todos` (filter by tag/assignee/ticket)\n") - b.WriteString(" - For HTTP route / handler patterns (e.g. `app.get`, `func.*Handler`, `@RequestMapping`): use `contracts` (action=list to enumerate, action=check to match cross-repo)\n") + b.WriteString("[Gortex] Do not Grep indexed source. Call `explore` for a task, then use:\n") + b.WriteString(" - symbol name: `search(operation:\"symbols\", query:\"...\")`; literal text: use operation `text`\n") + b.WriteString(" - references: `relations(operation:\"usages\", target:{symbol:\"\"})`; choose `callers` or `implementations` when that is the required relation\n") + b.WriteString(" - TODOs or contracts: `analyze(kind:\"todos\")` or `analyze(kind:\"contracts\")`\n") b.WriteString(gcxTip) - b.WriteString(toolref.FallbackLine("search_symbols")) + b.WriteString(toolref.MCPRequiredLine()) return b.String() } func formatGrepDeny(pattern string, hits []grepSymbolHit) string { const maxShown = 5 var b strings.Builder - fmt.Fprintf(&b, "[Gortex] BLOCKED: \"%s\" matches %d symbol(s) in the knowledge graph. Use `search_symbols` or `find_usages` instead:\n\n", pattern, len(hits)) + fmt.Fprintf(&b, "[Gortex] BLOCKED: \"%s\" matches %d indexed symbol(s). Use `search(operation:\"symbols\")` or `relations(operation:\"usages\")`:\n\n", pattern, len(hits)) shown := min(len(hits), maxShown) for i := range shown { h := hits[i] @@ -651,7 +716,7 @@ func formatGrepDeny(pattern string, hits []grepSymbolHit) string { b.WriteString("\n") b.WriteString("Localizing a task rather than one symbol? `explore` returns the ranked neighborhood (symbols + source + call paths) in one call.\n") b.WriteString(gcxTip) - b.WriteString(toolref.FallbackLine("search_symbols")) + b.WriteString(toolref.MCPRequiredLine()) b.WriteString("To force text search, add a regex metachar (e.g. \\b) or quote the pattern.") return b.String() } @@ -727,11 +792,7 @@ func daemonFileSummaryRaw(cwd, filePath string) ([]byte, bool) { return nil, false } - client, err := daemon.Dial(daemon.Handshake{ - Mode: daemon.ModeMCP, - ClientName: "gortex-hook", - CWD: root, - }) + client, err := daemon.Dial(hookMCPHandshake(root)) if err != nil { return nil, false } @@ -884,21 +945,19 @@ func enrichBash(toolInput map[string]any, cwd string) enrichResult { fmt.Fprintf(&reason, "[Gortex] BLOCKED: Bash `%s %s` reads indexed source (%d symbols). Use graph tools instead:\n", c.Primary, c.Path, symbolCount) - reason.WriteString(" - `get_symbol_source` — one symbol (80% fewer tokens)\n") - reason.WriteString(" - `get_file_summary` — all symbols and imports\n") - reason.WriteString(" - `get_editing_context` — full file context before editing\n") + reason.WriteString(" - one symbol: `read(target:{symbol:\"\"})`\n") + reason.WriteString(" - file overview: `read(operation:\"summary\", target:{file:\"\"})`\n") + reason.WriteString(" - before editing: `read(operation:\"editing_context\", target:{file:\"\"})`\n") reason.WriteString(gcxTip) - reason.WriteString(toolref.FallbackLine("get_symbol_source")) + reason.WriteString(toolref.MCPRequiredLine()) return enrichResult{deny: true, reason: reason.String()} } // Not indexed — soft guidance so Bash proceeds. var g strings.Builder - g.WriteString("[Gortex] PREFER graph tools over Bash cat/head/tail for source files:\n") - g.WriteString(" - To read one symbol: use `get_symbol_source` (80% fewer tokens)\n") - g.WriteString(" - To get a file overview: use `get_file_summary`\n") - g.WriteString(" - To understand a file before editing: use `get_editing_context`\n") + g.WriteString("[Gortex] Use `read` instead of Bash cat/head/tail for indexed source:\n") + g.WriteString(" - `read(target:{symbol:\"\"})` for one symbol; use operation `summary` for an overview or `editing_context` before editing\n") g.WriteString(gcxTip) - g.WriteString(toolref.FallbackLine("get_symbol_source")) + g.WriteString(toolref.MCPRequiredLine()) return enrichResult{context: g.String()} } @@ -909,14 +968,100 @@ func enrichBash(toolInput map[string]any, cwd string) enrichResult { // without a real socket. Production reads daemon.IsRunning. var daemonReachableFn = daemon.IsRunning -// enrichGlob denies "list all source files of extension X" patterns -// when the daemon is reachable — those are exactly the queries the -// graph already answers (via `get_repo_outline` / `search_symbols`). -// Name-based patterns (e.g. `**/handler*.go`, `*test*.ts`) get soft -// guidance only because grep-style filename search has no clean -// graph equivalent. When the daemon is unreachable, every shape -// degrades to soft guidance — no daemon means no enforcement. -func enrichGlob(toolInput map[string]any) enrichResult { +// scopeTrackedFn proves that a Grep/Glob scope contains at least one indexed +// source file. Tests replace it so fallback cases stay deterministic. +var scopeTrackedFn = scopeTrackedViaDaemon + +func scopeTrackedViaDaemon(cwd, scope string) bool { + if !daemonReachableFn() { + return false + } + scope = strings.TrimSpace(scope) + if scope == "" { + scope = cwd + } + if scope == "" { + return false + } + if !filepath.IsAbs(scope) { + if cwd == "" { + return false + } + scope = filepath.Join(cwd, scope) + } + scope = filepath.Clean(scope) + info, err := os.Stat(scope) + if err != nil || !info.IsDir() { + return false + } + root := repoRootForFile(scope) + if root == "" { + return false + } + rel, err := filepath.Rel(root, scope) + if err != nil { + return false + } + + client, err := daemon.Dial(hookMCPHandshake(root)) + if err != nil { + return false + } + defer client.Close() + _ = client.Conn.SetDeadline(time.Now().Add(fileIndexedTimeout)) + + arguments := map[string]any{"glob": "**/*", "limit": 1, "format": "json"} + if rel != "." { + arguments["path"] = filepath.ToSlash(rel) + } + frame, err := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": map[string]any{ + "name": "find_files", + "arguments": arguments, + }, + }) + if err != nil || client.WriteMCPFrame(frame) != nil { + return false + } + resp, err := client.ReadMCPFrame() + return err == nil && parseFindFilesHasSource(resp) +} + +func parseFindFilesHasSource(resp []byte) bool { + var rpc struct { + Result struct { + Content []struct { + Text string `json:"text"` + } `json:"content"` + IsError bool `json:"isError"` + } `json:"result"` + } + if json.Unmarshal(resp, &rpc) != nil || rpc.Result.IsError || len(rpc.Result.Content) == 0 { + return false + } + var files struct { + Count int `json:"count"` + Files []struct { + Path string `json:"path"` + } `json:"files"` + } + if json.Unmarshal([]byte(rpc.Result.Content[0].Text), &files) != nil { + return false + } + return files.Count > 0 && len(files.Files) > 0 +} + +// enrichGlob denies source enumeration within a proven tracked/indexed scope. +// Pattern shape no longer creates a bypass; unproven or unavailable scopes +// retain soft guidance so the hook never blocks code it cannot identify. +func enrichGlob(toolInput map[string]any, cwdArg ...string) enrichResult { + cwd := "" + if len(cwdArg) > 0 { + cwd = cwdArg[0] + } pattern, ok := toolInput["pattern"].(string) if !ok || pattern == "" { return enrichResult{} @@ -925,25 +1070,19 @@ func enrichGlob(toolInput map[string]any) enrichResult { return enrichResult{} } - guidance := defaultGlobGuidance() - - // Greedy source-ext patterns (`**/*.go`, `*.ts`) are the - // "enumerate every source file" shape. Hard-deny only when the - // daemon is up — we can't redirect to graph tools that aren't - // answering. - if isGreedySourceGlob(pattern) && daemonReachableFn() { + if hookSearchScopeIndexed(cwd, toolInput) { var b strings.Builder - fmt.Fprintf(&b, "[Gortex] BLOCKED: Glob `%s` enumerates source files. The graph already indexes them — use:\n", pattern) - b.WriteString(" - `get_repo_outline` — every file with symbol counts\n") - b.WriteString(" - `search_symbols` — name-based lookup that returns file paths\n") - b.WriteString(" - `get_file_summary` — when you have a specific file in mind\n") + fmt.Fprintf(&b, "[Gortex] BLOCKED: Glob `%s` targets indexed source. Use:\n", pattern) + b.WriteString(" - `explore(operation:\"outline\")` — repository/file outline\n") + b.WriteString(" - `search(operation:\"files\", query:\"\")` — filename lookup\n") + b.WriteString(" - `search(operation:\"symbols\", query:\"\")` — symbols with file paths\n") + b.WriteString(" - `read(operation:\"summary\", target:{file:\"\"})` — a specific file overview\n") b.WriteString(gcxTip) - b.WriteString(toolref.FallbackLine("get_repo_outline")) - b.WriteString("If you genuinely need a file-system listing, run `find` or `ls` via Bash with a specific filename component — Glob deny only triggers on bare extension wildcards.") + b.WriteString(toolref.MCPRequiredLine()) return enrichResult{deny: true, reason: b.String()} } - return enrichResult{context: guidance} + return enrichResult{context: defaultGlobGuidance()} } // defaultGlobGuidance is the soft-guidance message returned when a @@ -951,13 +1090,12 @@ func enrichGlob(toolInput map[string]any) enrichResult { // extension" pattern, or when the daemon is unreachable. func defaultGlobGuidance() string { return "[Gortex] PREFER graph tools over Glob for source files:\n" + - " - To find a symbol by name: use `search_symbols`\n" + - " - To find files containing a symbol: use `search_symbols` (returns file paths)\n" + - " - To understand file structure: use `get_file_summary`\n" + - " - For task-level file discovery: use `smart_context`\n" + + " - symbol/file lookup: `search(operation:\"symbols\")`\n" + + " - file structure: `read(operation:\"summary\", target:{file:\"\"})`\n" + + " - task-level discovery: `explore`\n" + " - For migration / SQL globs (`db/migrations/*.sql`, `**/*.sql`): use `analyze kind=orphan_tables` and `kind=unreferenced_tables` to find queried-but-undeclared and provided-but-unused tables\n" + gcxTip + - toolref.FallbackLine("search_symbols") + toolref.MCPRequiredLine() } // isGreedySourceGlob returns true when the pattern is a bare @@ -1031,11 +1169,9 @@ func enrichEdit(toolInput map[string]any, cwd string) enrichResult { var b strings.Builder fmt.Fprintf(&b, "[Gortex] BLOCKED: Edit of %s (indexed source). Use Gortex MCP edit tools — they don't require a prior Read and update the graph atomically:\n", filePath) - b.WriteString(" - `edit_symbol` — change one symbol's body by ID (cleanest for one-function changes)\n") - b.WriteString(" - `edit_file` — whole-file replace, no Read precondition\n") - b.WriteString(" - `rename_symbol` — coordinated rename across all references\n") - b.WriteString(" - `batch_edit` — multi-file edits in dependency order\n\n") - b.WriteString(toolref.FallbackLine("edit_file")) + b.WriteString(" - choose `edit` operation `symbol`, `file`, or `batch`; for example `edit(operation:\"file\", target:{file:\"\"})`\n") + b.WriteString(" - `refactor(operation:\"rename\")` for a coordinated rename\n\n") + b.WriteString(toolref.MCPRequiredLine()) b.WriteString("To bypass this redirect: unset GORTEX_HOOK_BLOCK_EDIT, or target a file outside the tracked repos.\n") return enrichResult{deny: true, reason: b.String()} } @@ -1061,9 +1197,9 @@ func enrichWrite(toolInput map[string]any, cwd string) enrichResult { var b strings.Builder fmt.Fprintf(&b, "[Gortex] BLOCKED: Write of %s (indexed source — would overwrite existing tracked file). Use:\n", filePath) - b.WriteString(" - `write_file` — whole-file write through Gortex (re-indexes after)\n") - b.WriteString(" - `edit_file` — when you want a delta-style replace\n\n") - b.WriteString(toolref.FallbackLine("edit_file")) + b.WriteString(" - `edit(operation:\"write\")` for a whole-file write\n") + b.WriteString(" - `edit(operation:\"file\")` for a guarded replacement\n\n") + b.WriteString(toolref.MCPRequiredLine()) b.WriteString("To bypass: unset GORTEX_HOOK_BLOCK_EDIT, or target a path outside tracked repos.\n") return enrichResult{deny: true, reason: b.String()} } diff --git a/internal/hooks/pretooluse_mode_test.go b/internal/hooks/pretooluse_mode_test.go index e0b29c01b..8f54c0d28 100644 --- a/internal/hooks/pretooluse_mode_test.go +++ b/internal/hooks/pretooluse_mode_test.go @@ -55,10 +55,10 @@ func TestRunPreToolUse_EnrichModeDowngradesDeny(t *testing.T) { if dec.HookSpecificOutput.AdditionalContext == "" { t.Error("enrich mode should surface the deny rationale as additionalContext") } - // The downgraded message should still reference the graph - // alternative (edit_symbol/edit_file/etc) so the agent learns + // The downgraded message should still reference the public graph + // mutation alternative so the agent learns // without being blocked. - if !strings.Contains(dec.HookSpecificOutput.AdditionalContext, "edit_symbol") { + if !strings.Contains(dec.HookSpecificOutput.AdditionalContext, "choose `edit` operation `symbol`, `file`, or `batch`") { t.Errorf("downgraded context should retain graph alternative hints, got:\n%s", dec.HookSpecificOutput.AdditionalContext) } @@ -284,7 +284,7 @@ func TestRunPreToolUse_AdaptiveNudge_FiresOncePerBurst(t *testing.T) { if !strings.Contains(reason, "fires once") { t.Errorf("nudge reason should note it fires once, got:\n%s", reason) } - if !strings.Contains(reason, "search_symbols") { + if !strings.Contains(reason, "`search`") || !strings.Contains(reason, "`relations`") { t.Errorf("nudge reason should point at Gortex graph tools, got:\n%s", reason) } diff --git a/internal/hooks/pretooluse_test.go b/internal/hooks/pretooluse_test.go index 64a791491..b09958ce3 100644 --- a/internal/hooks/pretooluse_test.go +++ b/internal/hooks/pretooluse_test.go @@ -12,8 +12,9 @@ func withDaemonReachable(t *testing.T, reachable bool) { t.Cleanup(func() { daemonReachableFn = prev }) } -func TestEnrichGlob_GreedySourcePattern_DaemonUp_Denies(t *testing.T) { +func TestEnrichGlob_GreedySourcePattern_TrackedScope_Denies(t *testing.T) { withDaemonReachable(t, true) + stubTrackedScope(t, true) result := enrichGlob(map[string]any{"pattern": "**/*.go"}) if !result.deny { t.Fatal("expected deny for greedy source glob with daemon up") @@ -21,8 +22,8 @@ func TestEnrichGlob_GreedySourcePattern_DaemonUp_Denies(t *testing.T) { if !strings.Contains(result.reason, "BLOCKED") { t.Errorf("expected BLOCKED in reason, got: %s", result.reason) } - if !strings.Contains(result.reason, "get_repo_outline") { - t.Errorf("expected get_repo_outline in reason, got: %s", result.reason) + if !strings.Contains(result.reason, `explore(operation:"outline")`) { + t.Errorf("expected explore(operation=outline) in reason, got: %s", result.reason) } } @@ -35,8 +36,8 @@ func TestEnrichGlob_GreedySourcePattern_DaemonDown_Soft(t *testing.T) { if result.context == "" { t.Fatal("expected soft guidance text, got empty") } - if !strings.Contains(result.context, "search_symbols") { - t.Error("expected guidance to mention search_symbols") + if !strings.Contains(result.context, `search(operation:"symbols")`) { + t.Error("expected guidance to mention search(operation=symbols)") } } @@ -107,11 +108,11 @@ func TestEnrichRead_NonIndexed_Guidance(t *testing.T) { if result.deny { t.Error("should not deny when file is not indexed") } - if !strings.Contains(result.context, "get_symbol_source") { - t.Error("expected guidance to mention get_symbol_source") + if !strings.Contains(result.context, `read(target:{symbol:`) { + t.Error("expected guidance to mention the selector-driven symbol read") } - if !strings.Contains(result.context, "get_editing_context") { - t.Error("expected guidance to mention get_editing_context") + if !strings.Contains(result.context, `read(operation:"editing_context"`) { + t.Error("expected guidance to mention read(operation=editing_context)") } } @@ -122,18 +123,18 @@ func TestEnrichRead_NonSourceFile(t *testing.T) { } } -func TestEnrichRead_NarrowRead_Allowed(t *testing.T) { - // A read with offset+limit is narrow (for editing) — should always pass through. +func TestEnrichRead_NarrowUnindexedRead_GetsSoftGuidance(t *testing.T) { + // Range shape is no longer a policy bypass. An unindexed file remains soft. result := enrichRead(map[string]any{ "file_path": "/tmp/foo.go", "offset": float64(100), "limit": float64(20), }, "") if result.deny { - t.Error("narrow read (offset+limit) should not be denied") + t.Error("unindexed narrow read should not be denied") } - if result.context != "" { - t.Error("narrow read should not produce guidance") + if result.context == "" { + t.Error("unindexed narrow read should receive graph guidance") } } @@ -182,11 +183,11 @@ func TestEnrichGrep_Guidance(t *testing.T) { if result.deny { t.Error("grep should never be denied") } - if !strings.Contains(result.context, "search_symbols") { - t.Error("expected guidance to mention search_symbols") + if !strings.Contains(result.context, `search(operation:"symbols"`) || !strings.Contains(result.context, "operation `text`") { + t.Error("expected guidance to mention public search operations") } - if !strings.Contains(result.context, "find_usages") { - t.Error("expected guidance to mention find_usages") + if !strings.Contains(result.context, `relations(operation:"usages"`) || !strings.Contains(result.context, "choose `callers` or `implementations`") { + t.Error("expected guidance to mention public relations operations") } } diff --git a/internal/hooks/sessionstart.go b/internal/hooks/sessionstart.go index 7488cdcfd..b922a3ecf 100644 --- a/internal/hooks/sessionstart.go +++ b/internal/hooks/sessionstart.go @@ -37,6 +37,7 @@ type SessionStartInput struct { // hook still emits a block — but its content tells the user that // enforcement is disabled and how to fix it. func runSessionStart(data []byte) { + started := time.Now() var input SessionStartInput if err := json.Unmarshal(data, &input); err != nil { return @@ -44,6 +45,10 @@ func runSessionStart(data []byte) { if input.HookEventName != "SessionStart" { return } + emitted := false + defer func() { + logHookEffectiveness("SessionStart", emitted, daemonReachableFn(), 0, time.Since(started)) + }() ctx := buildSessionStartBriefing(input.CWD) if ctx == "" { @@ -60,6 +65,7 @@ func runSessionStart(data []byte) { if err != nil { return } + emitted = true fmt.Print(string(out)) } @@ -111,8 +117,7 @@ func buildSessionStartBriefing(cwd string) string { status, err := sessionStartStatusFn() switch { case errors.Is(err, errDaemonUnreachable): - sb.WriteString("⚠️ **Gortex daemon is not running.** Code-operation enforcement is disabled for this session: Read/Grep/Glob/Bash on indexed source files will not be redirected to graph tools.\n\n") - sb.WriteString("Start it with: `gortex daemon start --detach`\n\n") + sb.WriteString("⚠️ **Gortex graph transport is unreachable.** Required native MCP tools and code-operation enforcement cannot be assumed healthy. Treat this as an MCP integration failure: stop indexed code operations and report it; do not start a daemon manually or switch to a CLI fallback.\n\n") sb.WriteString(rulePreamble()) return sb.String() case err != nil: @@ -284,13 +289,10 @@ func hasPathPrefix(path, prefix string) bool { // — this is just enough that an agent in the very first turn knows // to reach for graph tools first. func rulePreamble() string { - return "**Rule:** Use Gortex MCP tools for code operations in this repo. Prefer:\n" + - "- **`explore` first for any task or bug report** — one call returns the ranked neighborhood (likely symbols + their source + call paths + the files to change); answer or start editing from it\n" + - "- `search_symbols` / `find_usages` / `get_callers` over `grep` / `Grep`\n" + - "- `get_symbol_source` / `batch_symbols` / `get_file_summary` over `Read`\n" + - "- `edit_symbol` / `edit_file` / `rename_symbol` over `Edit` / `Write` for indexed source\n\n" + - "Pre-tool hooks will deny attempts to Read/Grep/Glob indexed source files; the deny message names the right tool.\n" + - "Shell only (no MCP tools)? Reach any tool with `gortex call --arg k=v` (e.g. `" + toolref.CLIFallback("get_symbol_source") + "`) — there is no bare `gortex ` verb.\n" + return "**Rule:** Call `explore` first for every code task. Inspect indexed code with `search`, `read`, `relations`, or `trace`; never Read/Grep/Glob it. " + + "Before mutation call `change(operation:\"impact\")`; for a signature change also call `change(operation:\"verify\")` with the proposed signature. Mutate only with `edit` or `refactor`. After mutation call `change(operation:\"detect\")`; use the returned symbol IDs with `change` operations `tests`, `guards`, and `contract`. " + + "Call `capabilities` only when exact operation fields are unknown.\n" + + toolref.MCPRequiredLine() } // formatDuration renders a number of seconds as "1h7m" or "45s". diff --git a/internal/hooks/sessionstart_test.go b/internal/hooks/sessionstart_test.go index aba9c9112..02cd61b16 100644 --- a/internal/hooks/sessionstart_test.go +++ b/internal/hooks/sessionstart_test.go @@ -40,11 +40,14 @@ func TestRunSessionStart_DaemonDown(t *testing.T) { t.Fatalf("invalid HookOutput JSON: %v\n%s", err, out) } ac := payload.HookSpecificOutput.AdditionalContext - if !strings.Contains(ac, "daemon is not running") { - t.Errorf("expected daemon-down notice, got:\n%s", ac) + if !strings.Contains(ac, "graph transport is unreachable") { + t.Errorf("expected transport-down notice, got:\n%s", ac) } - if !strings.Contains(ac, "gortex daemon start") { - t.Errorf("expected start command, got:\n%s", ac) + if !strings.Contains(ac, "MCP integration failure") { + t.Errorf("expected integration-failure direction, got:\n%s", ac) + } + if strings.Contains(ac, "gortex daemon start") || strings.Contains(ac, "gortex call ") { + t.Errorf("native MCP guidance must not advertise a manual fallback, got:\n%s", ac) } if !strings.Contains(ac, "Rule:") { t.Errorf("rule preamble missing, got:\n%s", ac) diff --git a/internal/hooks/subagent.go b/internal/hooks/subagent.go index 0a48c040a..b63dc79c7 100644 --- a/internal/hooks/subagent.go +++ b/internal/hooks/subagent.go @@ -44,7 +44,7 @@ func enrichTask(toolInput map[string]any, port int) enrichResult { sb.WriteString("Subagents don't inherit CLAUDE.md, so the rules below are restated inline:\n\n") sb.WriteString(gortexToolGuidance) - sb.WriteString(toolref.FallbackLine("smart_context")) + sb.WriteString(toolref.MCPRequiredLine()) sb.WriteString("\n") if summary := renderStatsSummary(stats); summary != "" { @@ -54,7 +54,7 @@ func enrichTask(toolInput map[string]any, port int) enrichResult { } if ctx := renderTaskContext(port, task); ctx != "" { - sb.WriteString("### Relevant Symbols (from `smart_context`)\n\n") + sb.WriteString("### Relevant Symbols (from `explore`)\n\n") sb.WriteString(ctx) sb.WriteString("\n") } @@ -65,8 +65,8 @@ func enrichTask(toolInput map[string]any, port int) enrichResult { sb.WriteString("\n") } - sb.WriteString("_First call: `smart_context` with your task description. Before editing any file: `get_editing_context`. Never Read/Grep an indexed source file._\n") - sb.WriteString("_For list-shaped responses (search_symbols, find_usages, analyze, batch_symbols, get_callers), pass `format:\"gcx\"` to save ~27% tokens — round-trippable, spec at docs/wire-format.md._\n") + sb.WriteString("_First call: `explore` with the task. Inspect with `search`, `read`, `relations`, or `trace`. Before mutation call `change(operation:\"impact\")`; mutate only with `edit` or `refactor`. After mutation call `change(operation:\"detect\")`, then use its symbol IDs with `change` operations `tests`, `guards`, and `contract`._\n") + sb.WriteString("_For compact output, pass `output:{format:\"gcx\"}`._\n") return enrichResult{context: sb.String()} } @@ -74,20 +74,12 @@ func enrichTask(toolInput map[string]any, port int) enrichResult { // gortexToolGuidance is the condensed tool-swap reference injected into every // subagent briefing. Kept short (~14 lines) so the token overhead per Task // spawn stays small; the full table lives in CLAUDE.md for parent-agent use. -const gortexToolGuidance = "### Use Gortex MCP tools instead of Read/Grep/Glob\n" + +const gortexToolGuidance = "### MUST use Gortex MCP tools instead of Read/Grep/Glob\n" + "\n" + - "| Instead of... | Use... |\n" + - "|----------------------------------|---------------------------------------|\n" + - "| `Read` a whole source file | `get_symbol_source` (one symbol) |\n" + - "| `Read` to understand a file | `get_editing_context` / `get_file_summary` |\n" + - "| `Grep` for a symbol | `search_symbols` (BM25, camelCase) |\n" + - "| `Grep` for references | `find_usages` (zero false positives) |\n" + - "| `Grep` to find callers | `get_callers` / `get_call_chain` |\n" + - "| `Glob` over source files | `search_symbols` (returns file paths) |\n" + - "| Many Read calls to explore | `smart_context` (one call) |\n" + - "| Reading to pick tests to run | `get_test_targets` |\n" + - "\n" + - "**Token tip:** 13 tools accept `format:\"gcx\"` for compact round-trippable output (~27% fewer tokens). Pass it on any list-shaped query: `search_symbols`, `find_usages`, `analyze`, `contracts`, `batch_symbols`, `get_callers`/`get_call_chain`/`get_dependencies`/`get_dependents`/`find_implementations`, `get_file_summary`, `get_editing_context`, `smart_context`.\n" + "1. Call `explore` with the delegated task.\n" + + "2. Inspect indexed code only with `search`, `read`, `relations`, and `trace`.\n" + + "3. Before mutation call `change(operation:\"impact\")`; for a signature change also call `change(operation:\"verify\")` with the proposed signature. Mutate only with `edit` or `refactor`. After mutation call `change(operation:\"detect\")`, then use its symbol IDs with `change` operations `tests`, `guards`, and `contract`.\n" + + "4. Call `capabilities` only when an operation's exact fields are unknown.\n" // renderTaskContext calls smart_context with the subagent task text and // returns a compacted body. Falls back to empty on any error. diff --git a/internal/hooks/subagent_test.go b/internal/hooks/subagent_test.go index aa81ca0b7..de73575bd 100644 --- a/internal/hooks/subagent_test.go +++ b/internal/hooks/subagent_test.go @@ -59,13 +59,17 @@ func TestEnrichTask_Briefing(t *testing.T) { t.Errorf("missing recent modifications:\n%s", result.context) } - // Tool-swap guidance must be inlined because subagents don't see CLAUDE.md. + // Compact workflow guidance must be inlined because subagents don't see CLAUDE.md. for _, needle := range []string{ - "get_symbol_source", - "get_editing_context", - "search_symbols", - "find_usages", - "smart_context", + "`explore`", + "`search`", + "`read`", + "`relations`", + "`trace`", + "`change`", + "`edit`", + "`refactor`", + "`capabilities`", } { if !strings.Contains(result.context, needle) { t.Errorf("briefing missing Gortex tool guidance for %q:\n%s", needle, result.context) @@ -73,7 +77,7 @@ func TestEnrichTask_Briefing(t *testing.T) { } } -// TestEnrichTask_AlwaysIncludesToolGuidance ensures the tool-swap table is +// TestEnrichTask_AlwaysIncludesToolGuidance ensures the compact workflow is // present even when smart_context and history return nothing. Subagents must // not be able to reach a state where they receive stats but no guidance to // use graph tools over Read/Grep. @@ -91,7 +95,7 @@ func TestEnrichTask_AlwaysIncludesToolGuidance(t *testing.T) { if result.context == "" { t.Fatal("expected briefing") } - if !strings.Contains(result.context, "Use Gortex MCP tools instead of Read/Grep/Glob") { + if !strings.Contains(result.context, "MUST use Gortex MCP tools instead of Read/Grep/Glob") { t.Errorf("tool guidance header missing:\n%s", result.context) } } diff --git a/internal/hooks/telemetry.go b/internal/hooks/telemetry.go index 8a7e7003b..779a3edea 100644 --- a/internal/hooks/telemetry.go +++ b/internal/hooks/telemetry.go @@ -25,12 +25,36 @@ const ( type hookDecision struct { Timestamp string `json:"ts"` Tool string `json:"tool"` - Pattern string `json:"pattern"` Decision DecisionKind `json:"decision"` Hits int `json:"hits,omitempty"` DurationMS int64 `json:"duration_ms,omitempty"` } +// hookEffectiveness records one hook invocation without source, prompt, path, +// symbol, or command content. Keeping it separate from hookDecision preserves +// the existing probe log while making skipped/no-output invocations visible; +// that denominator is what catches regressions such as "91% skipped". +type hookEffectiveness struct { + Timestamp string `json:"ts"` + Event string `json:"event"` + EmittedContext bool `json:"emitted_context"` + DaemonReachable bool `json:"daemon_reachable"` + AlternationSegments int `json:"alternation_segments"` + DurationMS int64 `json:"duration_ms"` +} + +var hookEffectivenessEvents = map[string]bool{ + "PostToolUse": true, + "PreToolUse": true, + "SessionStart": true, + "UserPromptSubmit": true, + "PreCompact": true, + "PostCompact": true, + "Stop": true, + "SubagentStart": true, + "SubagentStop": true, +} + // hookDecisionsPath returns the telemetry file path. Respects GORTEX_HOOK_LOG // so tests can redirect writes. Defaults to ~/.gortex/cache (or the // $XDG_CACHE_HOME equivalent when that variable is set). @@ -46,25 +70,70 @@ func hookDecisionsPath() string { return filepath.Join(platform.CacheDir(), "hook-decisions.jsonl") } +// hookEffectivenessPath is a privacy-safe, denominator-complete companion to +// hook-decisions.jsonl. Tests can redirect it independently so decision-log +// assertions remain stable. +func hookEffectivenessPath() string { + if p := os.Getenv("GORTEX_HOOK_EFFECTIVENESS_LOG"); p != "" { + return p + } + if v := os.Getenv("XDG_CACHE_HOME"); v == "" || !filepath.IsAbs(v) { + if _, err := os.UserHomeDir(); err != nil { + return "" + } + } + return filepath.Join(platform.CacheDir(), "hook-effectiveness.jsonl") +} + // logHookDecision appends one JSONL record. Best-effort: errors are swallowed // because telemetry must never block a hook. -func logHookDecision(tool, pattern string, decision DecisionKind, hits int, dur time.Duration) { +func logHookDecision(tool, _ string, decision DecisionKind, hits int, dur time.Duration) { path := hookDecisionsPath() if path == "" { return } - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - return - } rec := hookDecision{ Timestamp: time.Now().UTC().Format(time.RFC3339Nano), Tool: tool, - Pattern: pattern, Decision: decision, Hits: hits, DurationMS: dur.Milliseconds(), } - line, err := json.Marshal(rec) + appendHookJSONL(path, rec) +} + +// logHookEffectiveness appends one bounded observation. alternationSegments is +// capped to prevent an adversarial regex from becoming a high-cardinality +// metric; values above the probe ceiling share the same overflow bucket. +func logHookEffectiveness(event string, emitted, reachable bool, alternationSegments int, dur time.Duration) { + if !hookEffectivenessEvents[event] { + return + } + if alternationSegments < 0 { + alternationSegments = 0 + } + if alternationSegments > maxAlternationProbes+1 { + alternationSegments = maxAlternationProbes + 1 + } + path := hookEffectivenessPath() + if path == "" { + return + } + appendHookJSONL(path, hookEffectiveness{ + Timestamp: time.Now().UTC().Format(time.RFC3339Nano), + Event: event, + EmittedContext: emitted, + DaemonReachable: reachable, + AlternationSegments: alternationSegments, + DurationMS: dur.Milliseconds(), + }) +} + +func appendHookJSONL(path string, record any) { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return + } + line, err := json.Marshal(record) if err != nil { return } diff --git a/internal/hooks/telemetry_effectiveness_test.go b/internal/hooks/telemetry_effectiveness_test.go new file mode 100644 index 000000000..411a8b22a --- /dev/null +++ b/internal/hooks/telemetry_effectiveness_test.go @@ -0,0 +1,67 @@ +package hooks + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestHookEffectivenessRecordsSkippedDenominatorAndAlternation(t *testing.T) { + path := filepath.Join(t.TempDir(), "effectiveness.jsonl") + t.Setenv("GORTEX_HOOK_EFFECTIVENESS_LOG", path) + + oldReachable := daemonReachableFn + daemonReachableFn = func() bool { return true } + t.Cleanup(func() { daemonReachableFn = oldReachable }) + stubProbe(t, nil, nil) + + withEffect := []byte(`{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"rg 'Alpha|two words|Beta'"}}`) + captureStdout(t, func() { runPreToolUse(withEffect, 0, ModeEnrich) }) + withoutEffect := []byte(`{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"go test ./..."}}`) + captureStdout(t, func() { runPreToolUse(withoutEffect, 0, ModeEnrich) }) + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read effectiveness telemetry: %v", err) + } + if strings.Contains(string(data), "Alpha") || strings.Contains(string(data), "two words") { + t.Fatalf("effectiveness telemetry leaked command/query content: %s", data) + } + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + if len(lines) != 2 { + t.Fatalf("records=%d want 2: %s", len(lines), data) + } + var first, second hookEffectiveness + if json.Unmarshal([]byte(lines[0]), &first) != nil || json.Unmarshal([]byte(lines[1]), &second) != nil { + t.Fatalf("invalid effectiveness JSONL: %s", data) + } + if first.Event != "PreToolUse" || !first.EmittedContext || !first.DaemonReachable || first.AlternationSegments != 3 { + t.Fatalf("first record=%+v", first) + } + if second.Event != "PreToolUse" || second.EmittedContext || !second.DaemonReachable || second.AlternationSegments != 0 { + t.Fatalf("second record=%+v", second) + } +} + +func TestHookEffectivenessRejectsUnknownEventsAndCapsSegments(t *testing.T) { + path := filepath.Join(t.TempDir(), "effectiveness.jsonl") + t.Setenv("GORTEX_HOOK_EFFECTIVENESS_LOG", path) + logHookEffectiveness("arbitrary-user-value", true, true, 99, 0) + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("unknown event must not be logged, stat err=%v", err) + } + logHookEffectiveness("PreToolUse", true, false, 99, 0) + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var record hookEffectiveness + if json.Unmarshal([]byte(strings.TrimSpace(string(data))), &record) != nil { + t.Fatalf("invalid record: %s", data) + } + if record.AlternationSegments != maxAlternationProbes+1 || record.DaemonReachable { + t.Fatalf("record=%+v", record) + } +} diff --git a/internal/hooks/tool_invocation_shape_test.go b/internal/hooks/tool_invocation_shape_test.go index 476af5446..c0066ffb1 100644 --- a/internal/hooks/tool_invocation_shape_test.go +++ b/internal/hooks/tool_invocation_shape_test.go @@ -17,11 +17,14 @@ func collectHookGuidance(t *testing.T) map[string]string { prevIndexed := fileIndexedFn prevReach := daemonReachableFn + prevScope := scopeTrackedFn t.Cleanup(func() { fileIndexedFn = prevIndexed daemonReachableFn = prevReach + scopeTrackedFn = prevScope }) daemonReachableFn = func() bool { return true } + scopeTrackedFn = func(string, string) bool { return true } out := map[string]string{} @@ -69,9 +72,9 @@ func collectHookGuidance(t *testing.T) map[string]string { // the REAL MCP tool registry (daemon-free) and asserts that no hook / adapter // guidance template ever renders a bare `gortex ` shell shape — the // invalid form (`gortex read_file `) agents invented from guidance that -// named a tool without an invocation shape. The correct shell fallback is -// `gortex call --arg …`, which this regex allows (the `call` token sits -// between `gortex` and the tool name). +// named a tool without an invocation shape. Hook guidance for an MCP-configured +// profile must not emit any shell transport; CLI-only renderers are tested in +// their own packages. func TestGuidanceNeverEmitsBareToolVerb(t *testing.T) { names := mcp.RegisteredToolNames() if len(names) < 50 { @@ -96,29 +99,31 @@ func TestGuidanceNeverEmitsBareToolVerb(t *testing.T) { } } -// TestGuidanceTeachesCLIFallbackShape is the positive counterpart: the -// tool-listing guidance messages must actually teach the shell fallback shape, -// so the fix is not merely the absence of the bad shape but the presence of the -// good one. Scoped to the messages that enumerate graph tools (a bare -// consult-unlock reason legitimately names no tool to invoke). -func TestGuidanceTeachesCLIFallbackShape(t *testing.T) { +// TestGuidanceRequiresNativeMCP is the positive counterpart: every rendered +// redirect must identify a missing callable handle as a host integration +// failure, never reinterpret it as permission to start infrastructure or use +// the Bash mirror. +func TestGuidanceRequiresNativeMCP(t *testing.T) { guidance := collectHookGuidance(t) - const want = "gortex call " - mustTeach := []string{ + const want = "Native Gortex MCP is mandatory" + mustRequire := []string{ "defaultGrepGuidance", "defaultGlobGuidance", "formatGrepDeny", "nudgeReason_empty", "gortexReadAdvisory", "kimiSubagentFallbackBriefing", "rulePreamble", "enrichRead_deny", "enrichBash_readSource_deny", "enrichEdit_deny", "enrichWrite_deny", "enrichGlob_deny", "enrichRead_soft", "enrichBash_readSource_soft", } - for _, label := range mustTeach { + for _, label := range mustRequire { text, ok := guidance[label] if !ok { t.Errorf("guidance %q did not render — the collector or emitter changed", label) continue } if !strings.Contains(text, want) { - t.Errorf("guidance %q never teaches the `%s --arg …` shell shape:\n%s", label, want, text) + t.Errorf("guidance %q does not require native MCP:\n%s", label, text) + } + if strings.Contains(text, "gortex call ") { + t.Errorf("guidance %q advertises a CLI fallback in an MCP-configured profile:\n%s", label, text) } } } diff --git a/internal/hooks/userpromptsubmit.go b/internal/hooks/userpromptsubmit.go index ef9506510..58d9fca2f 100644 --- a/internal/hooks/userpromptsubmit.go +++ b/internal/hooks/userpromptsubmit.go @@ -37,10 +37,18 @@ var userPromptProbe grepProbeFn = probeViaDaemon // polluted, and no warning is emitted (SessionStart already warns once when the // daemon is down; doing so every turn would be noise). func runUserPromptSubmit(data []byte) { + started := time.Now() var input UserPromptSubmitInput if err := json.Unmarshal(data, &input); err != nil { return } + if input.HookEventName != "UserPromptSubmit" { + return + } + emitted := false + defer func() { + logHookEffectiveness("UserPromptSubmit", emitted, daemonReachableFn(), 0, time.Since(started)) + }() block := buildUserPromptSubmitContext(input.HookEventName, input.Prompt) if block == "" { return @@ -54,6 +62,7 @@ func runUserPromptSubmit(data []byte) { if err != nil { return } + emitted = true fmt.Print(string(out)) } @@ -128,13 +137,13 @@ func buildPromptInjection(hits []grepSymbolHit) string { } if lean { sb.WriteString("\nLeads, not the full picture — call `explore` with the request text for the ranked neighborhood " + - "in one call; `get_symbol_source` reads one symbol. Prefer these graph facts over grep/Read.\n") + "in one call; use `read(operation:\"source\")` for one symbol. Prefer these graph facts over grep/Read.\n") } else { sb.WriteString("\nThese are leads, not the full picture — call `explore` with the request text to get the ranked " + "neighborhood (these symbols and their siblings, WITH source + call paths + the files to change) in one call, " + - "then answer or edit directly from it. To read just one of them use `get_symbol_source` (several: `batch_symbols`); " + - "trace with `find_usages` / `get_callers`. These are indexed graph facts — prefer them over grep/Read. " + - "Shell only (no MCP tools)? Reach any with `gortex call --arg k=v` (e.g. `" + toolref.CLIFallback("explore") + "`).\n") + "then answer or edit directly from it. Use `read(operation:\"source\")` for one symbol or operation `symbols` for a batch; " + + "use `relations(operation:\"usages\")` for references or operation `callers` for callers. Prefer these graph facts over grep/Read.\n") } + sb.WriteString(toolref.MCPRequiredLine()) return sb.String() } diff --git a/internal/hooks/userpromptsubmit_test.go b/internal/hooks/userpromptsubmit_test.go index b9b4e7185..154de39c8 100644 --- a/internal/hooks/userpromptsubmit_test.go +++ b/internal/hooks/userpromptsubmit_test.go @@ -49,7 +49,7 @@ func TestBuildPromptInjection(t *testing.T) { // The follow-up routing points at the one-shot localization verb first, // with the granular readers as the single-symbol fallback. require.Contains(t, block, "`explore`") - require.Contains(t, block, "batch_symbols") + require.Contains(t, block, `read(operation:"source")`) } func TestBuildPromptInjectionCapsHits(t *testing.T) { diff --git a/internal/indexer/capability_edges.go b/internal/indexer/capability_edges.go index 6faf25491..dd1f75213 100644 --- a/internal/indexer/capability_edges.go +++ b/internal/indexer/capability_edges.go @@ -222,15 +222,18 @@ func synthesizeCapabilityEdges(g graph.Store) (readsEnv, execProc, fieldAccess i } } + nodes := make([]*graph.Node, 0, len(procNodes)) for _, n := range procNodes { - g.AddNode(n) + nodes = append(nodes, n) } + edges := make([]*graph.Edge, 0, len(pending)) for _, s := range pending { - g.AddEdge(&graph.Edge{ + edges = append(edges, &graph.Edge{ From: s.from, To: s.to, Kind: s.kind, FilePath: s.file, Line: s.line, Origin: s.origin, Meta: s.meta, }) } + g.AddBatch(nodes, edges) return readsEnv, execProc, fieldAccess } @@ -367,14 +370,17 @@ func synthesizeCapabilityEdgesScoped(g graph.Store, changedPrefixes map[string]b } } + nodes := make([]*graph.Node, 0, len(procNodes)) for _, n := range procNodes { - g.AddNode(n) + nodes = append(nodes, n) } + edges := make([]*graph.Edge, 0, len(pending)) for _, s := range pending { - g.AddEdge(&graph.Edge{ + edges = append(edges, &graph.Edge{ From: s.from, To: s.to, Kind: s.kind, FilePath: s.file, Line: s.line, Origin: s.origin, Meta: s.meta, }) } + g.AddBatch(nodes, edges) return readsEnv, execProc, fieldAccess } diff --git a/internal/indexer/clone_incremental.go b/internal/indexer/clone_incremental.go index 6fce556bb..305bf5203 100644 --- a/internal/indexer/clone_incremental.go +++ b/internal/indexer/clone_incremental.go @@ -32,6 +32,10 @@ type incrementalCloneIndex struct { shingles map[string][]uint64 // node id -> raw shingle set (cache) corpus int built bool + // pending is true when an edit touched clone-capable functions while + // the in-memory index was unbuilt. It is observable and stays true + // until an explicit global/clone-consuming rebuild completes. + pending bool } // newIncrementalCloneIndex returns an empty, un-built index. built stays @@ -173,6 +177,45 @@ func (ci *incrementalCloneIndex) Rebuild(g graph.Store, repoPrefix string) { ci.lsh.Add(clones.Item{ID: n.ID, Sig: sig, TokenCount: tokensFromMeta(n)}) } ci.built = true + ci.pending = false +} + +// Ready reports whether one-file clone maintenance can run without a +// graph-wide seed. +func (ci *incrementalCloneIndex) Ready() bool { + if ci == nil { + return false + } + ci.mu.Lock() + defer ci.mu.Unlock() + return ci.built +} + +// MarkPending records that clone edges may be incomplete after a bounded +// edit. It intentionally schedules no background work: an automatic rebuild +// would merely move whole-graph starvation off the watcher goroutine. +func (ci *incrementalCloneIndex) MarkPending() { + if ci == nil { + return + } + ci.mu.Lock() + ci.pending = true + ci.mu.Unlock() +} + +func (ci *incrementalCloneIndex) Pending() bool { + if ci == nil { + return false + } + ci.mu.Lock() + defer ci.mu.Unlock() + return ci.pending +} + +// CloneIndexPending reports whether clone-derived edges are awaiting an +// explicit global/clone-consuming rebuild. +func (idx *Indexer) CloneIndexPending() bool { + return idx != nil && idx.cloneIndex != nil && idx.cloneIndex.Pending() } // EvictFuncs removes a set of function/method nodes from the index: it diff --git a/internal/indexer/contract_import_resolve.go b/internal/indexer/contract_import_resolve.go index c48deb948..c772470ee 100644 --- a/internal/indexer/contract_import_resolve.go +++ b/internal/indexer/contract_import_resolve.go @@ -78,6 +78,18 @@ func (mi *MultiIndexer) resolveBareTypeViaImports( srcCache map[string][]byte, importCache map[string]map[string]string, ) string { + if isRustFile(srcFile) { + src := mi.cachedSource(srcFile, srcCache) + if len(src) == 0 { + return "" + } + rustFacts, ok := rustImportFactsForName(string(src), srcFile, name) + if !ok { + return "" + } + return mi.resolveRustUseFactsTarget(rustFacts, g, srcCache) + } + candidates := g.FindNodesByName(name) if len(candidates) == 0 { return "" @@ -89,9 +101,8 @@ func (mi *MultiIndexer) resolveBareTypeViaImports( } } if len(typed) < 2 { - // 0 candidates → nothing to do; 1 candidate would already have - // been caught by UpgradeBareTypeRefs, so we don't try to redo - // its work here. + // A single TS candidate would already have been caught by + // UpgradeBareTypeRefs, so this pass handles ambiguous candidates only. return "" } @@ -108,21 +119,57 @@ func (mi *MultiIndexer) resolveBareTypeViaImports( if len(imports) == 0 { return "" } - wantFile, ok := imports[name] - if !ok { + wantFile, found := imports[name] + if !found { return "" } - // Follow re-export chains: a symbol imported from a barrel that - // only `export { X } from './x'`s — or a Rust module that only - // `pub use`s X — is defined in the terminal module, not the - // re-exporting one. - reachable := mi.followReExportChain(wantFile, name, srcCache) + + // Follow re-export chains while retaining the source leaf through every + // alias. The follower is depth-bounded and cycle-safe. + reachable, unsafe := mi.followReExportChainChecked(wantFile, name, srcCache) + if unsafe { + return "" + } + var hit string for _, n := range typed { - if reachable[n.FilePath] { - return n.ID + if !reachable[n.FilePath] { + continue + } + if hit != "" && hit != n.ID { + return "" } + hit = n.ID } - return "" + return hit +} + +func (mi *MultiIndexer) resolveRustUseFactsTarget(facts []rustUseFact, g graph.Store, srcCache map[string][]byte) string { + seen := map[string]bool{} + var hit string + for _, fact := range facts { + chain := mi.followReExportChainDetailed(fact.fromFile, fact.sourceName, srcCache) + if chain.unsafe { + return "" + } + for file, names := range chain.names { + for name := range names { + for _, node := range g.FindNodesByName(name) { + if node == nil || node.FilePath != file || seen[node.ID] { + continue + } + if node.Kind != graph.KindType && node.Kind != graph.KindInterface { + continue + } + seen[node.ID] = true + if hit != "" && hit != node.ID { + return "" + } + hit = node.ID + } + } + } + } + return hit } // tsAliasCache caches the per-repo Collection of tsconfig/jsconfig @@ -499,21 +546,29 @@ type reExportEdge struct { star bool names map[string]string fromFile string + // Rust-only provenance retained from the source use-tree. TypeScript + // re-exports leave these empty. + sourceModule string + visibility string + ambiguousName map[string]bool } -// rustReExportRe matches a `pub use` (or `pub(crate) use`) re-export -// statement. A plain `use` carries no visibility modifier and is a -// private import — never a re-export — so the leading `pub` is -// mandatory. Capture groups: -// -// 1: the use path, up to but excluding a trailing `::*` glob, -// `::{...}` list, or `::Symbol` final segment -// 2: `*` when the statement is a glob re-export (`pub use mod::*`) -// 3: the brace body of a list re-export (`pub use mod::{A, B}`) -// 4: the final path segment of a single-symbol re-export -// (`pub use mod::Symbol`) — possibly `Orig as Public` -var rustReExportRe = regexp.MustCompile( - `(?m)\bpub(?:\s*\([^)]*\))?\s+use\s+([\w:]+?)\s*::\s*(?:(\*)|\{([^}]*)\}|(\w+(?:\s+as\s+\w+)?))\s*;`) +// rustUseRe matches both private imports and visible re-exports. Capture +// groups retain visibility, the source module, glob/list shape, and the full +// symbol entry so aliases can be followed without degrading to a basename. +// The regex deliberately accepts a conservative ASCII identifier subset; +// Unicode identifiers remain unresolved instead of being guessed. +var rustUseRe = regexp.MustCompile( + `(?m)\b(pub(?:\s*\([^)]*\))?\s+)?use\s+([\w#:]+?)\s*::\s*(?:(\*)|\{([^}]*)\}|([\w#]+(?:\s+as\s+[\w#]+)?))\s*;`) + +type rustUseFact struct { + fromFile string + sourceModule string + sourceName string + localName string + visibility string + glob bool +} // rustFileCandidates expands a resolved Rust module path into the // concrete files it might be on disk. A module `foo` lives either in @@ -521,7 +576,17 @@ var rustReExportRe = regexp.MustCompile( // turning a logical module reference into matchable file paths. The // input is the `.rs`-suffixed module path resolveRustModulePath // produces; the suffix is stripped to recover the stem. +const rustLogicalCrateRootFile = ".gortex-crate-root.rs" + +func rustLogicalCrateRoot(crateRoot string) string { + return path.Join(crateRoot, rustLogicalCrateRootFile) +} + func rustFileCandidates(modulePath string) []string { + if path.Base(modulePath) == rustLogicalCrateRootFile { + root := path.Dir(modulePath) + return []string{path.Join(root, "lib.rs"), path.Join(root, "main.rs")} + } if modulePath == "" { return nil } @@ -552,109 +617,325 @@ func rustCrateRoot(srcFile string) string { // `.rs`-suffixed module path, anchored against the re-exporting file. // `crate::` is anchored at the crate src root; `self::` at the current // module directory; `super::` at the parent. A leading bare segment is -// treated as a submodule of the current file's directory — the -// best-effort local candidate; genuine external-crate paths simply -// fail to match a graph file, which leaves the bare name in place. The +// conservatively left unresolved because Rust 2018 may interpret it as an +// external crate and Cargo dependency identity is not available here. The // `.rs` suffix lets downstream language dispatch recognise the result // as Rust even though a logical module name carries no extension; // rustFileCandidates strips it back to a stem. func resolveRustModulePath(modPath, srcFile string) string { - segs := strings.Split(modPath, "::") + segs := strings.Split(strings.TrimSpace(modPath), "::") for len(segs) > 0 && segs[0] == "" { segs = segs[1:] } if len(segs) == 0 { return "" } - dir := path.Dir(srcFile) + crateRoot := rustCrateRoot(srcFile) + var dir string switch segs[0] { case "crate": - dir = rustCrateRoot(srcFile) + dir = crateRoot segs = segs[1:] + if len(segs) == 0 { + return rustCrateModuleFile(srcFile) + } case "self": + dir = rustModuleDirectory(srcFile) segs = segs[1:] + if len(segs) == 0 { + return path.Clean(srcFile) + } case "super": + dir = rustModuleDirectory(srcFile) for len(segs) > 0 && segs[0] == "super" { + if dir == crateRoot || !strings.HasPrefix(dir, crateRoot+"/") { + return "" + } dir = path.Dir(dir) segs = segs[1:] } + default: + // In Rust 2018 a leading bare segment may name an external crate. + // Without Cargo dependency facts it is unsafe to reinterpret it as + // a same-named local module; callers must use crate/self/super for + // conservative local resolution. + return "" } if len(segs) == 0 { - // `pub use crate::*` / `pub use self::*` — re-exporting the - // module's own directory; nothing more specific to anchor. + if dir == crateRoot { + return rustCrateModuleFile(srcFile) + } return path.Clean(dir) + ".rs" } return path.Clean(path.Join(dir, path.Join(segs...))) + ".rs" } +func rustCrateModuleFile(srcFile string) string { + clean := path.Clean(srcFile) + root := rustCrateRoot(clean) + if path.Dir(clean) == root { + switch path.Base(clean) { + case "lib.rs", "main.rs": + return clean + } + } + // Nested modules do not encode whether their crate root is a library or + // binary. Keep a logical root so both lib.rs and main.rs remain candidates; + // exact graph identity later selects one or rejects ambiguity. + return rustLogicalCrateRoot(root) +} + +func rustModuleDirectory(srcFile string) string { + dir := path.Dir(srcFile) + base := path.Base(srcFile) + switch base { + case "lib.rs", "main.rs", "mod.rs": + return dir + } + return strings.TrimSuffix(srcFile, path.Ext(srcFile)) +} + +// maskRustNonCode blanks comments and string literals while preserving byte +// positions and newlines. The use parser remains intentionally shallow (it +// does not parse recursive use trees), but apparent `use` text in comments, +// normal strings, byte strings, and raw strings cannot become import facts. +func maskRustNonCode(src string) string { + masked := []byte(src) + mask := func(start, end int) { + for i := start; i < end; i++ { + if masked[i] != '\n' && masked[i] != '\r' { + masked[i] = ' ' + } + } + } + isIdentContinue := func(b byte) bool { + return b == '_' || b >= '0' && b <= '9' || b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z' || b >= 0x80 + } + rawStart := func(start int) (quote, hashes int, ok bool) { + if start > 0 && isIdentContinue(src[start-1]) { + return 0, 0, false + } + i := start + if src[i] == 'b' { + i++ + if i >= len(src) || src[i] != 'r' { + return 0, 0, false + } + } + if src[i] != 'r' { + return 0, 0, false + } + i++ + for i < len(src) && src[i] == '#' { + hashes++ + i++ + } + if i >= len(src) || src[i] != '"' { + return 0, 0, false + } + return i, hashes, true + } + + for i := 0; i < len(src); { + switch { + case i+1 < len(src) && src[i] == '/' && src[i+1] == '/': + start := i + i += 2 + for i < len(src) && src[i] != '\n' { + i++ + } + mask(start, i) + case i+1 < len(src) && src[i] == '/' && src[i+1] == '*': + start, depth := i, 1 + i += 2 + for i < len(src) && depth > 0 { + switch { + case i+1 < len(src) && src[i] == '/' && src[i+1] == '*': + depth++ + i += 2 + case i+1 < len(src) && src[i] == '*' && src[i+1] == '/': + depth-- + i += 2 + default: + i++ + } + } + mask(start, i) + case src[i] == 'r' || src[i] == 'b': + quote, hashes, ok := rawStart(i) + if !ok { + i++ + continue + } + start := i + i = quote + 1 + for i < len(src) { + if src[i] != '"' { + i++ + continue + } + end := i + 1 + for end < len(src) && end-i-1 < hashes && src[end] == '#' { + end++ + } + if end-i-1 == hashes { + i = end + break + } + i++ + } + mask(start, i) + case src[i] == '"': + start := i + i++ + for i < len(src) { + if src[i] == '\\' { + i += 2 + if i > len(src) { + i = len(src) + } + continue + } + i++ + if src[i-1] == '"' { + break + } + } + mask(start, i) + default: + i++ + } + } + return string(masked) +} + // parseRustReExports extracts `pub use` re-export statements from a // Rust source file, resolving each module path to a repo-prefixed // module stem the same way parseTSReExports resolves an `export ... -// from` specifier. -func parseRustReExports(src, srcFile string) []reExportEdge { - matches := rustReExportRe.FindAllStringSubmatch(src, -1) +// from` specifier. Restricted visibility is retained as source text; this +// resolver does not interpret the restriction's access scope. +func parseRustUseFacts(src, srcFile string) []rustUseFact { + matches := rustUseRe.FindAllStringSubmatch(maskRustNonCode(src), -1) if len(matches) == 0 { return nil } + var out []rustUseFact + for _, match := range matches { + visibility := strings.TrimSpace(match[1]) + baseModule := strings.TrimSpace(match[2]) + if match[3] == "*" { + fromFile := resolveRustModulePath(baseModule, srcFile) + if fromFile != "" { + out = append(out, rustUseFact{ + fromFile: fromFile, sourceModule: baseModule, + visibility: visibility, glob: true, + }) + } + continue + } + entries := []string{match[5]} + if match[4] != "" { + entries = strings.Split(match[4], ",") + } + for _, raw := range entries { + if fact, ok := rustUseFactForEntry(baseModule, raw, srcFile, visibility); ok { + out = append(out, fact) + } + } + } + return out +} + +func rustUseFactForEntry(baseModule, raw, srcFile, visibility string) (rustUseFact, bool) { + orig, local := splitRustUseEntry(raw) + orig = strings.Trim(strings.TrimSpace(orig), ":") + if orig == "" || local == "" || orig == "self" { + return rustUseFact{}, false + } + parts := strings.Split(orig, "::") + for _, part := range parts { + if strings.TrimSpace(part) == "" { + return rustUseFact{}, false + } + } + sourceName := parts[len(parts)-1] + sourceModule := strings.Trim(strings.TrimSpace(baseModule), ":") + if len(parts) > 1 { + sourceModule += "::" + strings.Join(parts[:len(parts)-1], "::") + } + fromFile := resolveRustModulePath(sourceModule, srcFile) + if fromFile == "" { + return rustUseFact{}, false + } + return rustUseFact{ + fromFile: fromFile, sourceModule: sourceModule, + sourceName: sourceName, localName: local, visibility: visibility, + }, true +} + +func parseRustReExports(src, srcFile string) []reExportEdge { + type groupKey struct { + fromFile, sourceModule, visibility string + } var out []reExportEdge - for _, m := range matches { - fromMod := resolveRustModulePath(m[1], srcFile) - if fromMod == "" { + groups := map[groupKey]int{} + for _, fact := range parseRustUseFacts(src, srcFile) { + if fact.visibility == "" { continue } - re := reExportEdge{fromFile: fromMod} - if m[2] == "*" { - re.star = true - out = append(out, re) + key := groupKey{fact.fromFile, fact.sourceModule, fact.visibility} + if fact.glob { + out = append(out, reExportEdge{ + star: true, fromFile: fact.fromFile, + sourceModule: fact.sourceModule, visibility: fact.visibility, + }) continue } - re.names = map[string]string{} - switch { - case m[3] != "": - // `pub use mod::{Orig, Other as Public}` — a list. - for _, raw := range strings.Split(m[3], ",") { - orig, exported := splitRustUseEntry(raw) - if orig != "" && exported != "" { - re.names[exported] = orig - } + if index, ok := groups[key]; ok { + edge := &out[index] + if edge.ambiguousName[fact.localName] { + continue } - case m[4] != "": - // `pub use mod::Symbol` / `pub use mod::Orig as Public`. - orig, exported := splitRustUseEntry(m[4]) - if orig != "" && exported != "" { - re.names[exported] = orig + if _, duplicate := edge.names[fact.localName]; duplicate { + if edge.ambiguousName == nil { + edge.ambiguousName = map[string]bool{} + } + edge.ambiguousName[fact.localName] = true + delete(edge.names, fact.localName) + continue } + edge.names[fact.localName] = fact.sourceName + continue } - if len(re.names) > 0 { - out = append(out, re) - } + groups[key] = len(out) + out = append(out, reExportEdge{ + names: map[string]string{fact.localName: fact.sourceName}, + fromFile: fact.fromFile, sourceModule: fact.sourceModule, + visibility: fact.visibility, + }) } return out } // splitRustUseEntry normalises a single `use`-list entry, resolving an -// `Orig as Public` rebind to (sourceName, exportedName). A plain entry -// returns the same name twice. +// `Orig as Public` rebind to (sourceName, exportedName). A qualified plain +// entry retains its full source path while exporting only its target leaf. func splitRustUseEntry(raw string) (orig, exported string) { entry := strings.TrimSpace(raw) if entry == "" { return "", "" } - orig, exported = entry, entry + orig = entry if i := strings.Index(entry, " as "); i >= 0 { orig = strings.TrimSpace(entry[:i]) exported = strings.TrimSpace(entry[i+4:]) + } else { + parts := strings.Split(strings.Trim(orig, ":"), "::") + exported = strings.TrimSpace(parts[len(parts)-1]) } return orig, exported } -// rustImportRe matches any `use` statement (re-export or private -// import) — the consumer side, where a private `use` is a legitimate -// import to resolve. Capture groups mirror rustReExportRe groups 1-4 -// minus the leading visibility modifier. -var rustImportRe = regexp.MustCompile( - `(?m)\buse\s+([\w:]+?)\s*::\s*(?:(\*)|\{([^}]*)\}|(\w+(?:\s+as\s+\w+)?))\s*;`) - // parseRustImports walks the `use` statements of a Rust source file // and returns local-binding-name → the repo-prefixed module stem the // name was imported from. It is the Rust counterpart of parseTSImports: @@ -662,35 +943,64 @@ var rustImportRe = regexp.MustCompile( // reach the symbol's real definition. Glob imports (`use mod::*`) are // skipped — they bind no specific name to follow. func parseRustImports(src, srcFile string) map[string]string { - matches := rustImportRe.FindAllStringSubmatch(src, -1) - if len(matches) == 0 { - return nil - } out := map[string]string{} - for _, m := range matches { - if m[2] == "*" { + ambiguous := map[string]bool{} + for _, fact := range parseRustUseFacts(src, srcFile) { + if fact.glob || fact.localName == "" { continue } - fromMod := resolveRustModulePath(m[1], srcFile) - if fromMod == "" { + if _, exists := out[fact.localName]; exists { + ambiguous[fact.localName] = true continue } - switch { - case m[3] != "": - for _, raw := range strings.Split(m[3], ",") { - if _, exported := splitRustUseEntry(raw); exported != "" { - out[exported] = fromMod - } - } - case m[4] != "": - if _, exported := splitRustUseEntry(m[4]); exported != "" { - out[exported] = fromMod - } + if !ambiguous[fact.localName] { + out[fact.localName] = fact.fromFile } } + for name := range ambiguous { + delete(out, name) + } + if len(out) == 0 { + return nil + } return out } +func rustImportFactsForName(src, srcFile, name string) ([]rustUseFact, bool) { + facts := parseRustUseFacts(src, srcFile) + var explicit []rustUseFact + for _, fact := range facts { + if !fact.glob && fact.localName == name { + explicit = append(explicit, fact) + } + } + if len(explicit) > 0 { + // Rust rejects duplicate local bindings even when they happen to point + // at the same source. Never let source order choose a winner. + if len(explicit) != 1 { + return nil, false + } + return explicit, true + } + + var globs []rustUseFact + seen := map[string]bool{} + for _, fact := range facts { + if !fact.glob { + continue + } + fact.sourceName = name + fact.localName = name + key := fact.fromFile + "\x00" + fact.sourceName + if seen[key] { + continue + } + seen[key] = true + globs = append(globs, fact) + } + return globs, len(globs) > 0 +} + // isRustFile reports whether a repo-prefixed path is a Rust source // file — the re-export follower parses these with the `pub use` // grammar rather than the TS `export ... from` grammar. @@ -733,7 +1043,7 @@ func (mi *MultiIndexer) reExportsFor(src, srcPath string) []reExportEdge { } out := make([]reExportEdge, len(tsRe)) for i, re := range tsRe { - out[i] = reExportEdge(re) + out[i] = reExportEdge{star: re.star, names: re.names, fromFile: re.fromFile} } return out } @@ -745,51 +1055,119 @@ func (mi *MultiIndexer) reExportsFor(src, srcPath string) []reExportEdge { // maxReExportDepth. A symbol's real definition is in one of the // returned files, so a caller matching an import target against graph // nodes resolves through the barrel / re-exporting module. +type reExportChainResult struct { + files map[string]bool + names map[string]map[string]bool + unsafe bool +} + +func addReExportChainTarget(result *reExportChainResult, file, name string) { + for _, candidate := range fileCandidatesFor(file) { + result.files[candidate] = true + if result.names[candidate] == nil { + result.names[candidate] = map[string]bool{} + } + result.names[candidate][name] = true + } +} + func (mi *MultiIndexer) followReExportChain(startFile, name string, srcCache map[string][]byte) map[string]bool { - reachable := map[string]bool{} - for _, c := range fileCandidatesFor(startFile) { - reachable[c] = true + return mi.followReExportChainDetailed(startFile, name, srcCache).files +} + +func (mi *MultiIndexer) followReExportChainChecked(startFile, name string, srcCache map[string][]byte) (map[string]bool, bool) { + result := mi.followReExportChainDetailed(startFile, name, srcCache) + return result.files, result.unsafe +} + +func (mi *MultiIndexer) followReExportChainDetailed(startFile, name string, srcCache map[string][]byte) reExportChainResult { + result := reExportChainResult{ + files: map[string]bool{}, names: map[string]map[string]bool{}, } + addReExportChainTarget(&result, startFile, name) type step struct{ file, name string } - seen := map[step]bool{{startFile, name}: true} - queue := []step{{startFile, name}} - for depth := 0; depth < maxReExportDepth && len(queue) > 0; depth++ { - var next []step - for _, s := range queue { - var src []byte - var srcPath string - for _, c := range fileCandidatesFor(s.file) { - if data := mi.cachedSource(c, srcCache); len(data) > 0 { - src, srcPath = data, c - break - } - } - if len(src) == 0 { - continue + visiting := map[step]bool{} + visited := map[step]bool{} + + var visit func(step, int) + visit = func(current step, depth int) { + if visiting[current] { + result.unsafe = true + return + } + if visited[current] { + return + } + visiting[current] = true + defer func() { + delete(visiting, current) + visited[current] = true + }() + + type sourceFile struct { + data []byte + path string + } + var sources []sourceFile + for _, candidate := range fileCandidatesFor(current.file) { + if data := mi.cachedSource(candidate, srcCache); len(data) > 0 { + sources = append(sources, sourceFile{data: data, path: candidate}) } - for _, re := range mi.reExportsFor(string(src), srcPath) { - var want string - if re.star { - want = s.name - } else if orig, ok := re.names[s.name]; ok { - want = orig - } - if want == "" { + } + if len(sources) == 0 { + return + } + + forwarded := map[step]bool{} + for _, source := range sources { + named := map[step]bool{} + globs := map[step]bool{} + namedMatches := 0 + for _, re := range mi.reExportsFor(string(source.data), source.path) { + if re.ambiguousName[current.name] { + result.unsafe = true continue } - for _, c := range fileCandidatesFor(re.fromFile) { - reachable[c] = true + if re.star { + globs[step{file: re.fromFile, name: current.name}] = true + continue } - st := step{file: re.fromFile, name: want} - if !seen[st] { - seen[st] = true - next = append(next, st) + if original, ok := re.names[current.name]; ok { + namedMatches++ + named[step{file: re.fromFile, name: original}] = true } } + + // Explicit re-exports shadow globs within one concrete module file. + // Alternate lib/main roots remain parallel candidates, and exact graph + // identity decides between them after the full chain is collected. + selected := globs + switch namedMatches { + case 0: + case 1: + selected = named + default: + result.unsafe = true + } + for target := range selected { + forwarded[target] = true + } + } + if result.unsafe { + return + } + if depth >= maxReExportDepth && len(forwarded) > 0 { + result.unsafe = true + return + } + for target := range forwarded { + addReExportChainTarget(&result, target.file, target.name) + visit(target, depth+1) } - queue = next } - return reachable + + visit(step{file: startFile, name: name}, 0) + return result } // isImportResolvableLang reports whether the contract source file diff --git a/internal/indexer/crash_isolation.go b/internal/indexer/crash_isolation.go index 4de5de5a0..07f69d5f1 100644 --- a/internal/indexer/crash_isolation.go +++ b/internal/indexer/crash_isolation.go @@ -247,6 +247,7 @@ func (idx *Indexer) extractFile( return nil, false, eerr } stampParseErrors(r) + normalizeExtractionMetadata(r, src) return r, false, nil } @@ -278,6 +279,7 @@ func (idx *Indexer) extractFile( if res.HasParseErr { stampParseErrorCount(result.Nodes, res.ParseErrors) } + normalizeExtractionMetadata(result, src) return result, false, nil } diff --git a/internal/indexer/deferred_receipt_test.go b/internal/indexer/deferred_receipt_test.go new file mode 100644 index 000000000..f2181d98c --- /dev/null +++ b/internal/indexer/deferred_receipt_test.go @@ -0,0 +1,123 @@ +package indexer + +import ( + "iter" + "sync/atomic" + "testing" + + "github.com/zzet/gortex/internal/graph" + "go.uber.org/zap" +) + +type deferredScanCountingStore struct { + *graph.Graph + unresolvedScans atomic.Int64 +} + +func (s *deferredScanCountingStore) EdgesWithUnresolvedTarget() iter.Seq[*graph.Edge] { + s.unresolvedScans.Add(1) + return s.Graph.EdgesWithUnresolvedTarget() +} + +func TestResolveDeferredMutationsSkipsCompleteEmptyReceiptWithoutScanning(t *testing.T) { + store, edge := deferredReceiptFixture() + mi := &MultiIndexer{graph: store, logger: zap.NewNop()} + + mode := mi.resolveDeferredMutations(&graph.MutationReceipt{Complete: true}, true, nil) + if mode != deferredResolveSkipped { + t.Fatalf("mode = %q, want %q", mode, deferredResolveSkipped) + } + if got := store.unresolvedScans.Load(); got != 0 { + t.Fatalf("unresolved full scans = %d, want 0", got) + } + if edge.To != graph.UnresolvedMarker+"Target" { + t.Fatalf("skip unexpectedly changed target to %q", edge.To) + } +} + +func TestResolveDeferredMutationsUsesExactDefinitionFrontier(t *testing.T) { + store, edge := deferredReceiptFixture() + mi := &MultiIndexer{graph: store, logger: zap.NewNop()} + receipt := &graph.MutationReceipt{ + Complete: true, + ResolutionRelevant: true, + DefinitionFiles: []string{"b.go"}, + TargetNames: []string{"Target"}, + TargetIDs: []string{"b.go::Target"}, + } + + mode := mi.resolveDeferredMutations(receipt, true, nil) + if mode != deferredResolveExact { + t.Fatalf("mode = %q, want %q", mode, deferredResolveExact) + } + if got := store.unresolvedScans.Load(); got != 0 { + t.Fatalf("exact resolution performed %d whole unresolved scans", got) + } + if edge.To != "b.go::Target" { + t.Fatalf("edge target = %q, want b.go::Target", edge.To) + } +} + +func TestResolveDeferredMutationsUsesExactChangedFileFrontier(t *testing.T) { + store, edge := deferredReceiptFixture() + mi := &MultiIndexer{graph: store, logger: zap.NewNop()} + receipt := &graph.MutationReceipt{ + Complete: true, + ResolutionRelevant: true, + ChangedFiles: []string{"a.go"}, + TargetNames: []string{"Target"}, + } + + mode := mi.resolveDeferredMutations(receipt, false, nil) + if mode != deferredResolveExact { + t.Fatalf("mode = %q, want %q", mode, deferredResolveExact) + } + if got := store.unresolvedScans.Load(); got != 0 { + t.Fatalf("exact resolution performed %d whole unresolved scans", got) + } + if edge.To != "b.go::Target" { + t.Fatalf("edge target = %q, want b.go::Target", edge.To) + } +} + +func TestResolveDeferredMutationsIncompleteReceiptFallsBackToFullScan(t *testing.T) { + store, edge := deferredReceiptFixture() + mi := &MultiIndexer{graph: store, logger: zap.NewNop()} + + mode := mi.resolveDeferredMutations(&graph.MutationReceipt{Complete: false}, false, map[string]struct{}{"repo": {}}) + if mode != deferredResolveFallback { + t.Fatalf("mode = %q, want %q", mode, deferredResolveFallback) + } + if got := store.unresolvedScans.Load(); got == 0 { + t.Fatal("incomplete receipt did not use whole-graph unresolved scan") + } + if edge.To != "b.go::Target" { + t.Fatalf("fallback edge target = %q, want b.go::Target", edge.To) + } +} + +func TestResolveDeferredMutationsMissingExactPathFailsClosed(t *testing.T) { + store, _ := deferredReceiptFixture() + mi := &MultiIndexer{graph: store, logger: zap.NewNop()} + receipt := &graph.MutationReceipt{Complete: true, ResolutionRelevant: true, TargetNames: []string{"Target"}} + + mode := mi.resolveDeferredMutations(receipt, false, nil) + if mode != deferredResolveFallback { + t.Fatalf("mode = %q, want %q", mode, deferredResolveFallback) + } + if got := store.unresolvedScans.Load(); got == 0 { + t.Fatal("missing exact path did not fail closed to whole scan") + } +} + +func deferredReceiptFixture() (*deferredScanCountingStore, *graph.Edge) { + g := graph.New() + edge := &graph.Edge{From: "a.go::Caller", To: graph.UnresolvedMarker + "Target", Kind: graph.EdgeCalls, FilePath: "a.go", Line: 3} + g.AddBatch([]*graph.Node{ + {ID: "a.go", Kind: graph.KindFile, Name: "a.go", FilePath: "a.go", Language: "go"}, + {ID: "a.go::Caller", Kind: graph.KindFunction, Name: "Caller", FilePath: "a.go", Language: "go"}, + {ID: "b.go", Kind: graph.KindFile, Name: "b.go", FilePath: "b.go", Language: "go"}, + {ID: "b.go::Target", Kind: graph.KindFunction, Name: "Target", FilePath: "b.go", Language: "go"}, + }, []*graph.Edge{edge}) + return &deferredScanCountingStore{Graph: g}, edge +} diff --git a/internal/indexer/deferred_semantic_state_test.go b/internal/indexer/deferred_semantic_state_test.go new file mode 100644 index 000000000..5d76c8283 --- /dev/null +++ b/internal/indexer/deferred_semantic_state_test.go @@ -0,0 +1,134 @@ +package indexer + +import ( + "context" + "fmt" + "sync" + "testing" + + "go.uber.org/zap" + + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/semantic" +) + +func TestDeferredEnrichmentReleasesRepoStatePerConcurrencyBatch(t *testing.T) { + const repoCount = 9 + g := graph.New() + provider := &retainingRepoProvider{ + retained: make(map[string]struct{}), + leased: make(map[string]struct{}), + } + manager := semantic.NewManager(semantic.Config{Enabled: true}, zap.NewNop()) + manager.RegisterProvider(provider) + + mi := &MultiIndexer{ + graph: g, + indexers: make(map[string]*Indexer, repoCount), + logger: zap.NewNop(), + semanticMgr: manager, + } + for i := 0; i < repoCount; i++ { + prefix := fmt.Sprintf("repo-%02d", i) + root := t.TempDir() + idx := &Indexer{ + graph: g, + rootPath: root, + repoPrefix: prefix, + logger: zap.NewNop(), + semanticMgr: manager, + } + idx.pendingEnrich.Store(true) + mi.indexers[prefix] = idx + g.AddNode(&graph.Node{ + ID: prefix + "/main.go::F", Kind: graph.KindFunction, Name: "F", + FilePath: prefix + "/main.go", StartLine: 1, EndLine: 1, + Language: "go", RepoPrefix: prefix, + }) + } + + scheduled := mi.RunDeferredPassesAll(context.Background()) + if scheduled != repoCount { + t.Fatalf("scheduled enrichment = %d, want %d", scheduled, repoCount) + } + provider.mu.Lock() + peak := provider.peak + retained := len(provider.retained) + leases := len(provider.leased) + retains := provider.retains + releases := provider.releases + unleased := provider.unleased + provider.mu.Unlock() + if want := enrichConcurrency(repoCount); peak > want { + t.Fatalf("peak retained repo states = %d, want <= concurrency %d", peak, want) + } + if retained != 0 { + t.Fatalf("retained repo states after deferred batch = %d, want 0", retained) + } + if leases != 0 || retains != repoCount || unleased { + t.Fatalf("deferred leases: active=%d retains=%d unleased_enrich=%v, want 0/%d/false", leases, retains, unleased, repoCount) + } + if releases != repoCount { + t.Fatalf("released repo states = %d, want %d", releases, repoCount) + } +} + +type retainingRepoProvider struct { + mu sync.Mutex + retained map[string]struct{} + leased map[string]struct{} + peak int + retains int + releases int + unleased bool +} + +func (p *retainingRepoProvider) Name() string { return "retaining-go" } +func (p *retainingRepoProvider) Languages() []string { return []string{"go"} } +func (p *retainingRepoProvider) Available() bool { return true } +func (p *retainingRepoProvider) Close() error { return nil } + +func (p *retainingRepoProvider) Enrich(_ graph.Store, repoRoot string) (*semantic.EnrichResult, error) { + return p.retain(repoRoot), nil +} + +func (p *retainingRepoProvider) EnrichRepo(_ graph.Store, _ string, repoRoot string) (*semantic.EnrichResult, error) { + return p.retain(repoRoot), nil +} + +func (p *retainingRepoProvider) EnrichFile(graph.Store, string, string) (*semantic.EnrichResult, error) { + return nil, nil +} + +func (p *retainingRepoProvider) RetainRepoState(repoRoot string) bool { + p.mu.Lock() + p.leased[repoRoot] = struct{}{} + p.retains++ + p.mu.Unlock() + return true +} + +func (p *retainingRepoProvider) ReleaseRepoState(repoRoot string) bool { + p.mu.Lock() + defer p.mu.Unlock() + delete(p.leased, repoRoot) + if _, ok := p.retained[repoRoot]; !ok { + return false + } + delete(p.retained, repoRoot) + p.releases++ + return true +} + +func (p *retainingRepoProvider) retain(repoRoot string) *semantic.EnrichResult { + p.mu.Lock() + if _, ok := p.leased[repoRoot]; !ok { + p.unleased = true + } + p.retained[repoRoot] = struct{}{} + if len(p.retained) > p.peak { + p.peak = len(p.retained) + } + p.mu.Unlock() + return &semantic.EnrichResult{Provider: p.Name(), Language: "go"} +} diff --git a/internal/indexer/derived_invalidation.go b/internal/indexer/derived_invalidation.go new file mode 100644 index 000000000..72f39b7f2 --- /dev/null +++ b/internal/indexer/derived_invalidation.go @@ -0,0 +1,218 @@ +package indexer + +import ( + "sort" + "strings" + + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/parser" +) + +const ( + sourceDerivedDeclFingerprintMeta = "source_derived_decl_fingerprint" + sourceDerivedImportFingerprintMeta = "source_derived_import_fingerprint" + sourceDerivedRuntimeFingerprintMeta = "source_derived_runtime_fingerprint" + sourceDerivedArtifactFingerprintMeta = "source_derived_artifact_fingerprint" +) + +type DerivedInvalidationFlags uint32 + +const ( + DerivedInvalidatesDeclarations DerivedInvalidationFlags = 1 << iota + DerivedInvalidatesImports + DerivedInvalidatesRuntime + DerivedInvalidatesArtifacts + DerivedInvalidatesTests + DerivedInvalidatesContracts +) + +func (f DerivedInvalidationFlags) Has(flag DerivedInvalidationFlags) bool { return f&flag != 0 } + +// DerivedInvalidationPlan is the bounded work contract carried from extraction +// through reconcile. Files and TypeIDs are exact frontiers, not repo-wide hints. +type DerivedInvalidationPlan struct { + Flags DerivedInvalidationFlags `json:"flags,omitempty"` + Files []string `json:"files,omitempty"` + TypeIDs []string `json:"type_ids,omitempty"` + BodyOnlyFiles int `json:"body_only_files,omitempty"` + MetadataOnlyFiles int `json:"metadata_only_files,omitempty"` + InertFiles int `json:"inert_files,omitempty"` + LegacyFallback bool `json:"legacy_fallback,omitempty"` +} + +func (p DerivedInvalidationPlan) Empty() bool { + return p.Flags == 0 && len(p.Files) == 0 && p.BodyOnlyFiles == 0 && p.MetadataOnlyFiles == 0 && p.InertFiles == 0 +} + +func (p *DerivedInvalidationPlan) Merge(other DerivedInvalidationPlan) { + if p == nil { + return + } + p.Flags |= other.Flags + p.BodyOnlyFiles += other.BodyOnlyFiles + p.MetadataOnlyFiles += other.MetadataOnlyFiles + p.InertFiles += other.InertFiles + p.LegacyFallback = p.LegacyFallback || other.LegacyFallback + p.Files = appendUniqueSorted(p.Files, other.Files...) + p.TypeIDs = appendUniqueSorted(p.TypeIDs, other.TypeIDs...) +} + +func appendUniqueSorted(dst []string, values ...string) []string { + seen := make(map[string]struct{}, len(dst)+len(values)) + for _, value := range dst { + if value != "" { + seen[value] = struct{}{} + } + } + for _, value := range values { + if value != "" { + seen[value] = struct{}{} + } + } + out := make([]string, 0, len(seen)) + for value := range seen { + out = append(out, value) + } + sort.Strings(out) + return out +} + +type derivedFingerprints struct { + declarations string + imports string + runtime string + artifacts string +} + +func (f derivedFingerprints) complete() bool { + return f.declarations != "" && f.imports != "" && f.runtime != "" && f.artifacts != "" +} + +func isDeclarationNodeKind(kind graph.NodeKind) bool { + switch strings.ToLower(string(kind)) { + case "type", "interface", "class", "trait", "struct", "enum", "function", "method", "field": + return true + default: + return false + } +} + +func isImportNodeKind(kind graph.NodeKind) bool { + value := strings.ToLower(string(kind)) + return value == "import" || value == "module" || value == "package" +} + +func edgeKindContains(kind graph.EdgeKind, needles ...string) bool { + value := strings.ToLower(string(kind)) + for _, needle := range needles { + if strings.Contains(value, needle) { + return true + } + } + return false +} + +func isDeclarationEdgeKind(kind graph.EdgeKind) bool { + return edgeKindContains(kind, "extend", "implement", "override", "compose", "inherit", "satisf") +} + +func isImportEdgeKind(kind graph.EdgeKind) bool { + return edgeKindContains(kind, "import", "export", "depend", "module", "reexport") +} + +func isRuntimeDerivedEdgeKind(kind graph.EdgeKind) bool { + return edgeKindContains(kind, + "call", "read", "write", "emit", "publish", "subscrib", "dispatch", "register", + "route", "endpoint", "handler", "invoke", "message", "channel", "event", "config") +} + +func stampDerivedFingerprints(result *parser.ExtractionResult, fingerprints derivedFingerprints) { + if result == nil || !fingerprints.complete() { + return + } + for _, node := range result.Nodes { + if node == nil || node.Kind != graph.KindFile { + continue + } + if node.Meta == nil { + node.Meta = map[string]any{} + } + node.Meta[sourceDerivedDeclFingerprintMeta] = fingerprints.declarations + node.Meta[sourceDerivedImportFingerprintMeta] = fingerprints.imports + node.Meta[sourceDerivedRuntimeFingerprintMeta] = fingerprints.runtime + node.Meta[sourceDerivedArtifactFingerprintMeta] = fingerprints.artifacts + return + } +} + +func storedDerivedFingerprints(nodes []*graph.Node) derivedFingerprints { + for _, node := range nodes { + if node == nil || node.Kind != graph.KindFile || node.Meta == nil { + continue + } + return derivedFingerprints{ + declarations: stringMeta(node.Meta, sourceDerivedDeclFingerprintMeta), + imports: stringMeta(node.Meta, sourceDerivedImportFingerprintMeta), + runtime: stringMeta(node.Meta, sourceDerivedRuntimeFingerprintMeta), + artifacts: stringMeta(node.Meta, sourceDerivedArtifactFingerprintMeta), + } + } + return derivedFingerprints{} +} + +func stringMeta(meta map[string]any, key string) string { + value, _ := meta[key].(string) + return value +} + +func derivedPlanForDelta(prior, fresh derivedFingerprints, semanticChanged bool, graphPath string, priorNodes, freshNodes []*graph.Node) DerivedInvalidationPlan { + plan := DerivedInvalidationPlan{Files: []string{graphPath}} + if !prior.complete() || !fresh.complete() { + plan.Flags = DerivedInvalidatesDeclarations | DerivedInvalidatesImports | DerivedInvalidatesRuntime | DerivedInvalidatesArtifacts + plan.LegacyFallback = true + } else { + if prior.declarations != fresh.declarations { + plan.Flags |= DerivedInvalidatesDeclarations + } + if prior.imports != fresh.imports { + plan.Flags |= DerivedInvalidatesImports + } + if prior.runtime != fresh.runtime { + plan.Flags |= DerivedInvalidatesRuntime + } + if prior.artifacts != fresh.artifacts { + plan.Flags |= DerivedInvalidatesArtifacts + } + } + if semanticChanged && looksLikeTestPath(graphPath) { + plan.Flags |= DerivedInvalidatesTests + } + if semanticChanged && plan.Flags == 0 { + plan.BodyOnlyFiles = 1 + } + for _, nodes := range [][]*graph.Node{priorNodes, freshNodes} { + for _, node := range nodes { + if node != nil && isTypeFrontierNodeKind(node.Kind) { + plan.TypeIDs = append(plan.TypeIDs, node.ID) + } + } + } + plan.TypeIDs = appendUniqueSorted(nil, plan.TypeIDs...) + return plan +} + +func isTypeFrontierNodeKind(kind graph.NodeKind) bool { + switch strings.ToLower(string(kind)) { + case "type", "interface", "class", "trait", "struct", "enum": + return true + default: + return false + } +} + +func looksLikeTestPath(path string) bool { + value := strings.ToLower(path) + return strings.Contains(value, "/test/") || strings.Contains(value, "/tests/") || + strings.Contains(value, "_test.") || strings.Contains(value, ".test.") || + strings.Contains(value, ".spec.") || strings.HasSuffix(value, "test") +} diff --git a/internal/indexer/derived_invalidation_test.go b/internal/indexer/derived_invalidation_test.go new file mode 100644 index 000000000..bf4e276a0 --- /dev/null +++ b/internal/indexer/derived_invalidation_test.go @@ -0,0 +1,26 @@ +package indexer + +import "testing" + +func TestDerivedPlanForBodyOnlyDelta(t *testing.T) { + fingerprints := derivedFingerprints{ + declarations: "decl", + imports: "imports", + runtime: "runtime", + artifacts: "artifacts", + } + + plan := derivedPlanForDelta(fingerprints, fingerprints, true, "gortex/internal/indexer/example.go", nil, nil) + if plan.Flags != 0 { + t.Fatalf("body-only delta flags = %v, want 0", plan.Flags) + } + if plan.BodyOnlyFiles != 1 { + t.Fatalf("body-only files = %d, want 1", plan.BodyOnlyFiles) + } + if len(plan.Files) != 1 || plan.Files[0] != "gortex/internal/indexer/example.go" { + t.Fatalf("files = %v, want exact changed file", plan.Files) + } + if plan.LegacyFallback { + t.Fatal("body-only delta must not request legacy fallback") + } +} diff --git a/internal/indexer/doc_index_test.go b/internal/indexer/doc_index_test.go index 3f1f75966..64ec7635e 100644 --- a/internal/indexer/doc_index_test.go +++ b/internal/indexer/doc_index_test.go @@ -56,8 +56,8 @@ func TestDocSummary_RuneBound(t *testing.T) { // TestSearchIndexFields_IncludesDocSummary is the load-bearing regression // guard: a code symbol's doc comment reaches the BM25 document (the -// task-intent recall fix), while a symbol without a doc indexes exactly as -// before and a prose (KindDoc) node keeps its section-text path untouched. +// task-intent recall fix), while a symbol without a doc keeps an empty +// retrieval-qualifier slot and a prose (KindDoc) node stays untouched. func TestSearchIndexFields_IncludesDocSummary(t *testing.T) { withDoc := &graph.Node{ Kind: graph.KindFunction, Name: "union", FilePath: "crates/regex/src/literal.rs", @@ -81,7 +81,7 @@ func TestSearchIndexFields_IncludesDocSummary(t *testing.T) { Meta: map[string]any{"signature": "fn helper()"}, } got := searchIndexFields(noDoc, "") - want := []string{"helper", "x.rs", "fn helper()", ""} + want := []string{"helper", "x.rs", "", "fn helper()", ""} if len(got) != len(want) { t.Fatalf("no-doc fields len = %d, want %d (%q)", len(got), len(want), got) } diff --git a/internal/indexer/file_delta.go b/internal/indexer/file_delta.go new file mode 100644 index 000000000..24f53b90c --- /dev/null +++ b/internal/indexer/file_delta.go @@ -0,0 +1,718 @@ +package indexer + +import ( + "bytes" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "encoding/json" + "hash" + "math" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/parser" + "github.com/zzet/gortex/internal/parser/crashpool" +) + +const ( + sourceSemanticFingerprintMeta = "source_semantic_fingerprint" + sourceMetadataFingerprintMeta = "source_metadata_fingerprint" + sourceCoreFingerprintMeta = "source_core_fingerprint" +) + +type fileDeltaFingerprints struct { + semantic string + metadata string + core string +} + +// preparedExtraction is the parse result produced by the watcher's delta +// probe. A structural edit consumes it in indexFile so the same bytes are not +// parsed twice. src is the transformed source, matching indexFile's input to +// extractFile. +type preparedExtraction struct { + absPath string + relPath string + lang string + src []byte + result *parser.ExtractionResult +} + +// fileDeltaProbe exposes phase timings and the three delta boundaries used by +// the watcher: metadata-only, artifact-only, and semantic topology. +type fileDeltaProbe struct { + fingerprints fileDeltaFingerprints + derived derivedFingerprints + read time.Duration + extract time.Duration + coverage time.Duration + fingerprintTime time.Duration +} + +// prepareFileDelta parses the current file once and caches that exact +// extraction for either the bounded refresh or the structural reindex. Cold +// indexing deliberately does not pay this fingerprint cost: an old/missing +// fingerprint gets one conservative structural patch, which stamps the file +// for subsequent edits. +func (idx *Indexer) prepareFileDelta(filePath string) (fileDeltaProbe, bool) { + var probe fileDeltaProbe + absPath, err := filepath.Abs(filePath) + if err != nil { + return probe, false + } + relPath := idx.relKey(absPath) + + started := time.Now() + src, err := os.ReadFile(absPath) + probe.read = time.Since(started) + if err != nil { + return probe, false + } + lang, ok := idx.effectiveLanguage(absPath, src) + if !ok { + return probe, false + } + ext, _ := idx.registry.GetByLanguage(lang) + if ext == nil { + return probe, false + } + if maxSize := idx.config.MaxFileSize; maxSize > 0 && int64(len(src)) > maxSize { + return probe, false + } + if _, skip := idx.newContentAdmissionGate().skip(lang, int64(len(src))); skip { + return probe, false + } + src = idx.transforms.run(relPath, src) + + var pool *crashpool.Pool + var quarantine *crashpool.Quarantine + if idx.crashIsolationEnabled() { + pool, quarantine = idx.sharedParsePool() + } + started = time.Now() + result, skipped, err := idx.extractFile(pool, quarantine, absPath, relPath, lang, ext, src) + probe.extract = time.Since(started) + if quarantine != nil && quarantine.Len() > 0 { + _ = quarantine.Save() + } + if result == nil || skipped || err != nil { + return probe, false + } + + started = time.Now() + idx.applyCoverageDomains(relPath, lang, src, result) + probe.coverage = time.Since(started) + + started = time.Now() + fingerprints, derived, ok := extractionFingerprints(result) + probe.fingerprintTime = time.Since(started) + if !ok { + return probe, false + } + probe.fingerprints = fingerprints + probe.derived = derived + stampExtractionGraphFingerprints(result, fingerprints) + stampDerivedFingerprints(result, derived) + + idx.preparedMu.Lock() + if idx.prepared == nil { + idx.prepared = make(map[string]*preparedExtraction) + } + idx.prepared[absPath] = &preparedExtraction{ + absPath: absPath, + relPath: relPath, + lang: lang, + src: append([]byte(nil), src...), + result: result, + } + idx.preparedMu.Unlock() + return probe, true +} + +func (idx *Indexer) takePreparedExtraction(absPath, relPath, lang string, src []byte) (*parser.ExtractionResult, bool) { + idx.preparedMu.Lock() + prepared := idx.prepared[absPath] + delete(idx.prepared, absPath) + idx.preparedMu.Unlock() + if prepared == nil || prepared.relPath != relPath || prepared.lang != lang || !bytes.Equal(prepared.src, src) { + return nil, false + } + return prepared.result, true +} + +func (idx *Indexer) takePreparedRefresh(filePath string) (*preparedExtraction, bool) { + absPath, err := filepath.Abs(filePath) + if err != nil { + return nil, false + } + idx.preparedMu.Lock() + prepared := idx.prepared[absPath] + delete(idx.prepared, absPath) + idx.preparedMu.Unlock() + if prepared == nil { + return nil, false + } + current, err := os.ReadFile(absPath) + if err != nil { + return nil, false + } + current = idx.transforms.run(prepared.relPath, current) + if !bytes.Equal(current, prepared.src) { + return nil, false + } + return prepared, true +} + +func (idx *Indexer) discardPreparedExtraction(filePath string) { + absPath, err := filepath.Abs(filePath) + if err != nil { + return + } + idx.preparedMu.Lock() + delete(idx.prepared, absPath) + idx.preparedMu.Unlock() +} + +type fingerprintMode uint8 + +const ( + fingerprintMetadata fingerprintMode = iota + fingerprintSemantic + fingerprintCore + fingerprintDerived +) + +var presentationMetaKeys = map[string]struct{}{ + "body": {}, "comment": {}, "comments": {}, "doc": {}, + "documentation": {}, "search_doc": {}, "section_text": {}, + "snippet": {}, "source": {}, "source_text": {}, +} + +func isFingerprintMeta(key string) bool { + switch key { + case sourceSemanticFingerprintMeta, sourceMetadataFingerprintMeta, sourceCoreFingerprintMeta, + sourceDerivedDeclFingerprintMeta, sourceDerivedImportFingerprintMeta, + sourceDerivedRuntimeFingerprintMeta, sourceDerivedArtifactFingerprintMeta: + return true + default: + return false + } +} + +func isArtifactNodeKind(kind graph.NodeKind) bool { + switch kind { + case graph.KindArtifact, graph.KindDoc, graph.KindLicense, graph.KindRelease, graph.KindTeam, graph.KindTodo: + return true + default: + return false + } +} + +func fingerprintMetaKeys(meta map[string]any, mode fingerprintMode, keepPresentation bool) []string { + if len(meta) == 0 { + return nil + } + keys := make([]string, 0, len(meta)) + for key := range meta { + if isFingerprintMeta(key) { + continue + } + if mode != fingerprintMetadata && (!keepPresentation || mode == fingerprintDerived) { + if _, presentation := presentationMetaKeys[key]; presentation { + continue + } + } + if mode == fingerprintDerived { + switch strings.ToLower(key) { + case "body", "body_hash", "body_text", "clone_sig", "content", "raw_source", "snippet", "source_text": + continue + } + } + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +// semanticFingerprintCoversDerived reports whether the semantic row digest is +// also a valid derived-work digest. Most extracted rows have no metadata, so +// reusing that digest removes a third full field/hash pass from cold indexing. +func semanticFingerprintCoversDerived(meta map[string]any, keepPresentation bool) bool { + for key := range meta { + if isFingerprintMeta(key) { + continue + } + if _, presentation := presentationMetaKeys[key]; presentation { + if keepPresentation { + return false + } + continue + } + switch strings.ToLower(key) { + case "body", "body_hash", "body_text", "clone_sig", "content", "raw_source", "snippet", "source_text": + return false + } + } + return true +} + +type fingerprintDigest [sha256.Size]byte + +func writeFingerprintUint(h hash.Hash, value uint64) { + var scratch [binary.MaxVarintLen64]byte + n := binary.PutUvarint(scratch[:], value) + _, _ = h.Write(scratch[:n]) +} + +func writeFingerprintInt(h hash.Hash, value int) { + var scratch [binary.MaxVarintLen64]byte + n := binary.PutVarint(scratch[:], int64(value)) + _, _ = h.Write(scratch[:n]) +} + +func writeFingerprintBytes(h hash.Hash, value []byte) { + writeFingerprintUint(h, uint64(len(value))) + _, _ = h.Write(value) +} + +func writeFingerprintString(h hash.Hash, value string) { + writeFingerprintUint(h, uint64(len(value))) + _, _ = h.Write([]byte(value)) +} + +func writeFingerprintBool(h hash.Hash, value bool) { + if value { + _, _ = h.Write([]byte{1}) + return + } + _, _ = h.Write([]byte{0}) +} + +func writeFingerprintMeta(h hash.Hash, meta map[string]any, mode fingerprintMode, keepPresentation bool) bool { + keys := fingerprintMetaKeys(meta, mode, keepPresentation) + writeFingerprintUint(h, uint64(len(keys))) + for _, key := range keys { + encoded, err := json.Marshal(meta[key]) + if err != nil { + return false + } + writeFingerprintString(h, key) + writeFingerprintBytes(h, encoded) + } + return true +} + +func nodeFingerprintDigest(h hash.Hash, node *graph.Node, mode fingerprintMode) (fingerprintDigest, bool) { + h.Reset() + _, _ = h.Write([]byte{'N'}) + writeFingerprintString(h, node.ID) + writeFingerprintString(h, string(node.Kind)) + writeFingerprintString(h, node.Name) + writeFingerprintString(h, node.QualName) + writeFingerprintString(h, node.FilePath) + if mode == fingerprintMetadata { + writeFingerprintInt(h, node.StartLine) + writeFingerprintInt(h, node.EndLine) + writeFingerprintInt(h, node.StartColumn) + writeFingerprintInt(h, node.EndColumn) + } else { + writeFingerprintInt(h, 0) + writeFingerprintInt(h, 0) + writeFingerprintInt(h, 0) + writeFingerprintInt(h, 0) + } + writeFingerprintString(h, node.Language) + if !writeFingerprintMeta(h, node.Meta, mode, isArtifactNodeKind(node.Kind)) { + return fingerprintDigest{}, false + } + writeFingerprintString(h, node.RepoPrefix) + writeFingerprintString(h, node.WorkspaceID) + writeFingerprintString(h, node.ProjectID) + writeFingerprintString(h, node.AbsoluteFilePath) + writeFingerprintString(h, node.Origin) + writeFingerprintBool(h, node.Stub) + fetchedAt, err := node.FetchedAt.MarshalJSON() + if err != nil { + return fingerprintDigest{}, false + } + writeFingerprintBytes(h, fetchedAt) + var digest fingerprintDigest + h.Sum(digest[:0]) + return digest, true +} + +func edgeFingerprintDigest(h hash.Hash, edge *graph.Edge, mode fingerprintMode) (fingerprintDigest, bool) { + if math.IsNaN(edge.Confidence) || math.IsInf(edge.Confidence, 0) { + return fingerprintDigest{}, false + } + h.Reset() + _, _ = h.Write([]byte{'E'}) + writeFingerprintString(h, edge.From) + writeFingerprintString(h, edge.To) + writeFingerprintString(h, string(edge.Kind)) + writeFingerprintString(h, edge.FilePath) + if mode == fingerprintMetadata { + writeFingerprintInt(h, edge.Line) + } else { + writeFingerprintInt(h, 0) + } + writeFingerprintUint(h, math.Float64bits(edge.Confidence)) + writeFingerprintString(h, edge.ConfidenceLabel) + writeFingerprintString(h, edge.Origin) + writeFingerprintString(h, edge.Tier) + writeFingerprintBool(h, edge.CrossRepo) + writeFingerprintString(h, edge.Alias) + if !writeFingerprintMeta(h, edge.Meta, mode, false) { + return fingerprintDigest{}, false + } + var digest fingerprintDigest + h.Sum(digest[:0]) + return digest, true +} + +func stableFingerprintDigests(rows []fingerprintDigest) string { + sort.Slice(rows, func(i, j int) bool { + return bytes.Compare(rows[i][:], rows[j][:]) < 0 + }) + h := sha256.New() + for _, row := range rows { + _, _ = h.Write(row[:]) + } + return hex.EncodeToString(h.Sum(nil)) +} + +func extractionFingerprints(result *parser.ExtractionResult) (fileDeltaFingerprints, derivedFingerprints, bool) { + if result == nil { + return fileDeltaFingerprints{}, derivedFingerprints{}, false + } + var artifactIDs map[string]struct{} + for _, node := range result.Nodes { + if node == nil || !isArtifactNodeKind(node.Kind) { + continue + } + if artifactIDs == nil { + artifactIDs = make(map[string]struct{}) + } + artifactIDs[node.ID] = struct{}{} + } + + capacity := len(result.Nodes) + len(result.Edges) + metadataRows := make([]fingerprintDigest, 0, capacity) + semanticRows := make([]fingerprintDigest, 0, capacity) + coreRows := make([]fingerprintDigest, 0, capacity) + var declarations, imports, runtimeRows, artifacts []fingerprintDigest + h := sha256.New() + for _, node := range result.Nodes { + if node == nil { + continue + } + metadata, ok := nodeFingerprintDigest(h, node, fingerprintMetadata) + if !ok { + return fileDeltaFingerprints{}, derivedFingerprints{}, false + } + semantic, ok := nodeFingerprintDigest(h, node, fingerprintSemantic) + if !ok { + return fileDeltaFingerprints{}, derivedFingerprints{}, false + } + artifact := false + if _, artifact = artifactIDs[node.ID]; !artifact { + coreRows = append(coreRows, semantic) + } + derived := semantic + if !semanticFingerprintCoversDerived(node.Meta, artifact) { + derived, ok = nodeFingerprintDigest(h, node, fingerprintDerived) + if !ok { + return fileDeltaFingerprints{}, derivedFingerprints{}, false + } + } + metadataRows = append(metadataRows, metadata) + semanticRows = append(semanticRows, semantic) + if isDeclarationNodeKind(node.Kind) { + declarations = append(declarations, derived) + } + if isImportNodeKind(node.Kind) { + imports = append(imports, derived) + } + if artifact { + artifacts = append(artifacts, derived) + } + } + for _, edge := range result.Edges { + if edge == nil { + continue + } + metadata, ok := edgeFingerprintDigest(h, edge, fingerprintMetadata) + if !ok { + return fileDeltaFingerprints{}, derivedFingerprints{}, false + } + semantic, ok := edgeFingerprintDigest(h, edge, fingerprintSemantic) + if !ok { + return fileDeltaFingerprints{}, derivedFingerprints{}, false + } + _, fromArtifact := artifactIDs[edge.From] + _, toArtifact := artifactIDs[edge.To] + if !fromArtifact && !toArtifact { + coreRows = append(coreRows, semantic) + } + derived := semantic + if !semanticFingerprintCoversDerived(edge.Meta, false) { + derived, ok = edgeFingerprintDigest(h, edge, fingerprintDerived) + if !ok { + return fileDeltaFingerprints{}, derivedFingerprints{}, false + } + } + metadataRows = append(metadataRows, metadata) + semanticRows = append(semanticRows, semantic) + if isDeclarationEdgeKind(edge.Kind) { + declarations = append(declarations, derived) + } + if isImportEdgeKind(edge.Kind) { + imports = append(imports, derived) + } + if isRuntimeDerivedEdgeKind(edge.Kind) { + runtimeRows = append(runtimeRows, derived) + } + if fromArtifact || toArtifact { + artifacts = append(artifacts, derived) + } + } + return fileDeltaFingerprints{ + metadata: stableFingerprintDigests(metadataRows), + semantic: stableFingerprintDigests(semanticRows), + core: stableFingerprintDigests(coreRows), + }, derivedFingerprints{ + declarations: stableFingerprintDigests(declarations), + imports: stableFingerprintDigests(imports), + runtime: stableFingerprintDigests(runtimeRows), + artifacts: stableFingerprintDigests(artifacts), + }, true +} + +func stampExtractionGraphFingerprints(result *parser.ExtractionResult, fingerprints fileDeltaFingerprints) { + for _, n := range result.Nodes { + if n == nil || n.Kind != graph.KindFile { + continue + } + if n.Meta == nil { + n.Meta = make(map[string]any) + } + n.Meta[sourceSemanticFingerprintMeta] = fingerprints.semantic + n.Meta[sourceMetadataFingerprintMeta] = fingerprints.metadata + n.Meta[sourceCoreFingerprintMeta] = fingerprints.core + return + } +} + +func stampExtractionGraphFingerprint(result *parser.ExtractionResult) string { + fingerprints, derived, ok := extractionFingerprints(result) + if !ok { + return "" + } + stampExtractionGraphFingerprints(result, fingerprints) + stampDerivedFingerprints(result, derived) + return fingerprints.metadata +} + +func storedExtractionGraphFingerprints(nodes []*graph.Node) fileDeltaFingerprints { + for _, n := range nodes { + if n == nil || n.Kind != graph.KindFile || n.Meta == nil { + continue + } + semantic, _ := n.Meta[sourceSemanticFingerprintMeta].(string) + metadata, _ := n.Meta[sourceMetadataFingerprintMeta].(string) + core, _ := n.Meta[sourceCoreFingerprintMeta].(string) + return fileDeltaFingerprints{semantic: semantic, metadata: metadata, core: core} + } + return fileDeltaFingerprints{} +} + +func mergeRefreshMeta(oldMeta, freshMeta map[string]any) map[string]any { + merged := make(map[string]any, len(oldMeta)+len(freshMeta)) + for key, value := range oldMeta { + if isFingerprintMeta(key) { + continue + } + if _, presentation := presentationMetaKeys[key]; presentation { + continue + } + merged[key] = value + } + for key, value := range freshMeta { + merged[key] = value + } + if len(merged) == 0 { + return nil + } + return merged +} + +type edgeRefreshKey struct { + from string + kind graph.EdgeKind + alias string +} + +func metadataEdgeRefreshes(g graph.Store, graphPath string, priorNodes, freshNodes []*graph.Node, freshEdges []*graph.Edge) ([]graph.EdgeReindex, bool) { + if len(freshNodes) == 0 { + return nil, false + } + priorByID := make(map[string]*graph.Node, len(priorNodes)) + ids := make([]string, 0, len(priorNodes)) + for _, n := range priorNodes { + if n == nil { + continue + } + priorByID[n.ID] = n + ids = append(ids, n.ID) + } + if len(priorByID) != len(freshNodes) { + return nil, false + } + newIDs := make(map[string]struct{}, len(freshNodes)) + for _, n := range freshNodes { + if n == nil || priorByID[n.ID] == nil { + return nil, false + } + newIDs[n.ID] = struct{}{} + } + + reuse, _ := captureIncrementalState(g, graphPath) + applyResolvedOutEdges(g, freshEdges, reuse, newIDs) + + freshByKey := make(map[edgeRefreshKey][]*graph.Edge) + for _, edge := range freshEdges { + if edge == nil { + continue + } + if _, local := priorByID[edge.From]; !local { + return nil, false + } + key := edgeRefreshKey{from: edge.From, kind: edge.Kind, alias: edge.Alias} + freshByKey[key] = append(freshByKey[key], edge) + } + oldByKey := make(map[edgeRefreshKey][]*graph.Edge) + for _, edges := range graph.OutEdgesForNodes(g, ids) { + for _, edge := range edges { + if edge == nil { + continue + } + key := edgeRefreshKey{from: edge.From, kind: edge.Kind, alias: edge.Alias} + if _, needed := freshByKey[key]; needed { + oldByKey[key] = append(oldByKey[key], edge) + } + } + } + + updates := make([]graph.EdgeReindex, 0, len(freshEdges)) + for key, fresh := range freshByKey { + old := oldByKey[key] + if len(old) != len(fresh) { + return nil, false + } + sort.Slice(old, func(i, j int) bool { + if old[i].Line != old[j].Line { + return old[i].Line < old[j].Line + } + return old[i].To < old[j].To + }) + sort.Slice(fresh, func(i, j int) bool { + if fresh[i].Line != fresh[j].Line { + return fresh[i].Line < fresh[j].Line + } + return fresh[i].To < fresh[j].To + }) + for i := range fresh { + before := old[i] + after := *before + after.FilePath = fresh[i].FilePath + after.Line = fresh[i].Line + after.Alias = fresh[i].Alias + after.Meta = mergeRefreshMeta(before.Meta, fresh[i].Meta) + updates = append(updates, graph.EdgeReindex{ + Edge: &after, OldTo: before.To, OldFilePath: before.FilePath, + OldLine: before.Line, RefreshIdentity: true, + }) + } + } + return updates, true +} + +// applyPreparedMetadataRefresh updates source-owned node metadata/locations and +// stable edge spans without evicting the file or invoking the resolver. A +// shape mismatch is conservative: the caller falls back to structural reindex. +func (idx *Indexer) applyPreparedMetadataRefresh(filePath string, priorNodes []*graph.Node) ([]*graph.Node, bool) { + prepared, ok := idx.takePreparedRefresh(filePath) + if !ok || prepared.result == nil { + return nil, false + } + result := prepared.result + idx.applyRepoPrefix(result.Nodes, result.Edges) + graphPath := idx.prefixPath(prepared.relPath) + + priorByID := make(map[string]*graph.Node, len(priorNodes)) + for _, n := range priorNodes { + if n != nil { + priorByID[n.ID] = n + } + } + if len(priorByID) != len(result.Nodes) { + return nil, false + } + for i, fresh := range result.Nodes { + if fresh == nil { + return nil, false + } + old := priorByID[fresh.ID] + if old == nil { + return nil, false + } + cp := *fresh + cp.Meta = mergeRefreshMeta(old.Meta, fresh.Meta) + if cp.WorkspaceID == "" { + cp.WorkspaceID = old.WorkspaceID + } + if cp.ProjectID == "" { + cp.ProjectID = old.ProjectID + } + result.Nodes[i] = &cp + } + + edgeUpdates, ok := metadataEdgeRefreshes(idx.graph, graphPath, priorNodes, result.Nodes, result.Edges) + if !ok { + return nil, false + } + if cs := idx.contentSearcher(); cs != nil { + if fp := firstContentFilePath(result.Nodes); fp != "" { + if err := cs.WipeContentFile(fp); err != nil { + return nil, false + } + } + } + idx.streamContentSections(result.Nodes) + idx.graph.AddBatch(result.Nodes, nil) + idx.graph.ReindexEdges(edgeUpdates) + idx.persistFileMeta(prepared.relPath, prepared.src, result) + + searcher, _ := idx.graph.(graph.SymbolSearcher) + for _, n := range result.Nodes { + if !idx.shouldIndexForSearch(n) { + continue + } + if idx.search != nil { + idx.search.Remove(n.ID) + idx.search.Add(n.ID, searchIndexFields(n, idx.projectName)...) + } + if searcher != nil { + if err := searcher.UpsertSymbolFTS(n.ID, ftsTokensFor(n, idx.projectName)); err != nil { + return nil, false + } + } + } + idx.recordFileMtime(prepared.relPath, prepared.absPath) + return idx.graph.GetFileNodes(graphPath), true +} diff --git a/internal/indexer/gc_tune.go b/internal/indexer/gc_tune.go index e8e628adb..b1e55068b 100644 --- a/internal/indexer/gc_tune.go +++ b/internal/indexer/gc_tune.go @@ -9,6 +9,8 @@ import ( "time" "go.uber.org/zap" + + "github.com/zzet/gortex/internal/runtimeactivity" ) // Cold-index GC tuning. @@ -106,6 +108,21 @@ func indexMemoryBudget(hostRAM, cgroupLimit uint64) int64 { return budget } +// boundedIndexMemoryBudget prevents the cold-index window from weakening an +// already-lower process memory limit (the daemon standing limit or GOMEMLIMIT). +// A non-positive calculated budget means "leave the runtime untouched". The +// runtime's default effectively-unbounded MaxInt64 value naturally selects the +// calculated budget, while a configured lower ceiling is preserved exactly. +func boundedIndexMemoryBudget(calculated, current int64) int64 { + if calculated <= 0 { + return 0 + } + if current > 0 && current < calculated { + return current + } + return calculated +} + // cgroupMemoryLimit returns the active cgroup memory ceiling in bytes, or 0 // when the process is not under a cgroup memory limit (or detection fails). // cgroup v2 (`memory.max`) is consulted first, then v1 @@ -315,6 +332,7 @@ func applyIndexGCTuning(logger *zap.Logger) func() { // the restore is exact. gcTunePrevPct = debug.SetGCPercent(gcPct) gcTunePrevLim = debug.SetMemoryLimit(-1) + budget = boundedIndexMemoryBudget(budget, gcTunePrevLim) if budget > 0 { debug.SetMemoryLimit(budget) } @@ -371,9 +389,17 @@ func freeOSMemoryAfterColdIndex(logger *zap.Logger) { return } start := time.Now() - debug.FreeOSMemory() - if logger != nil { - logger.Debug("indexer: released heap to OS after cold index", - zap.Duration("elapsed", time.Since(start))) + ran, _ := runtimeactivity.RunIfQuiet(0, debug.FreeOSMemory) + if logger == nil { + return + } + if !ran { + snapshot := runtimeactivity.Current() + logger.Debug("indexer: deferred cold-index heap release until process idle", + zap.Int64("active_work", snapshot.Active), + zap.Any("active_by_kind", snapshot.ByKind)) + return } + logger.Debug("indexer: released heap to OS after cold index", + zap.Duration("elapsed", time.Since(start))) } diff --git a/internal/indexer/gc_tune_budget_test.go b/internal/indexer/gc_tune_budget_test.go new file mode 100644 index 000000000..6df097652 --- /dev/null +++ b/internal/indexer/gc_tune_budget_test.go @@ -0,0 +1,27 @@ +package indexer + +import ( + "math" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestBoundedIndexMemoryBudget(t *testing.T) { + tests := []struct { + name string + calculated int64 + current int64 + want int64 + }{ + {name: "preserves lower standing limit", calculated: 8 << 30, current: 4 << 30, want: 4 << 30}, + {name: "uses tighter cold budget", calculated: 2 << 30, current: 4 << 30, want: 2 << 30}, + {name: "uses cold budget when runtime is unbounded", calculated: 2 << 30, current: math.MaxInt64, want: 2 << 30}, + {name: "invalid calculated budget leaves runtime untouched", calculated: 0, current: 4 << 30, want: 0}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, boundedIndexMemoryBudget(tt.calculated, tt.current)) + }) + } +} diff --git a/internal/indexer/gc_tune_test.go b/internal/indexer/gc_tune_test.go index b5e49a6b9..4a9da4671 100644 --- a/internal/indexer/gc_tune_test.go +++ b/internal/indexer/gc_tune_test.go @@ -6,6 +6,11 @@ import ( "runtime" "runtime/debug" "testing" + + "go.uber.org/zap" + "go.uber.org/zap/zaptest/observer" + + "github.com/zzet/gortex/internal/runtimeactivity" ) func TestIndexMemoryBudget(t *testing.T) { @@ -552,3 +557,17 @@ func TestFreeOSMemoryAfterColdIndex(t *testing.T) { before.HeapReleased, after.HeapReleased) } } + +func TestFreeOSMemoryAfterColdIndexDefersWhileTrackedWorkActive(t *testing.T) { + t.Setenv("GORTEX_DAEMON_MEMRELEASE", "1") + core, logs := observer.New(zap.DebugLevel) + logger := zap.New(core) + + runtimeactivity.Begin("mcp") + defer runtimeactivity.End("mcp") + freeOSMemoryAfterColdIndex(logger) + + if got := logs.FilterMessage("indexer: deferred cold-index heap release until process idle").Len(); got != 1 { + t.Fatalf("deferred-release logs = %d, want 1", got) + } +} diff --git a/internal/indexer/global_pass_batch_test.go b/internal/indexer/global_pass_batch_test.go new file mode 100644 index 000000000..fb80eadda --- /dev/null +++ b/internal/indexer/global_pass_batch_test.go @@ -0,0 +1,89 @@ +package indexer + +import ( + "testing" + + "github.com/zzet/gortex/internal/graph" +) + +type batchCountingStore struct { + graph.Store + addBatchCalls int + addEdgeCalls int + batchNodes int + batchEdges int +} + +func (s *batchCountingStore) AddBatch(nodes []*graph.Node, edges []*graph.Edge) { + s.addBatchCalls++ + s.batchNodes += len(nodes) + s.batchEdges += len(edges) + s.Store.AddBatch(nodes, edges) +} + +func (s *batchCountingStore) AddEdge(edge *graph.Edge) { + s.addEdgeCalls++ + s.Store.AddEdge(edge) +} + +func TestEmitTestEdgesUsesSingleBatchWrite(t *testing.T) { + base := graph.New() + base.AddBatch( + []*graph.Node{ + {ID: "repo::test", Kind: graph.KindFunction, RepoPrefix: "repo"}, + {ID: "repo::prod", Kind: graph.KindFunction, RepoPrefix: "repo"}, + }, + []*graph.Edge{{From: "repo::test", To: "repo::prod", Kind: graph.EdgeCalls}}, + ) + store := &batchCountingStore{Store: base} + + if got := emitTestEdgesLocked(store, map[string]bool{"repo::test": true}, nil); got != 1 { + t.Fatalf("emitted edges = %d, want 1", got) + } + if store.addBatchCalls != 1 || store.batchEdges != 1 { + t.Fatalf("batch writes = %d calls/%d edges, want 1/1", store.addBatchCalls, store.batchEdges) + } + if store.addEdgeCalls != 0 { + t.Fatalf("per-edge writes = %d, want 0", store.addEdgeCalls) + } +} + +func TestCapabilityEdgesUseSingleBatchWrite(t *testing.T) { + for _, tc := range []struct { + name string + run func(graph.Store) (int, int, int) + }{ + { + name: "global", + run: synthesizeCapabilityEdges, + }, + { + name: "scoped", + run: func(store graph.Store) (int, int, int) { + return synthesizeCapabilityEdgesScoped(store, map[string]bool{"repo": true}) + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + base := graph.New() + base.AddBatch( + []*graph.Node{{ID: "repo::source", Kind: graph.KindFunction, RepoPrefix: "repo"}}, + []*graph.Edge{{ + From: "repo::source", To: "cfg::env::TOKEN", Kind: graph.EdgeReadsConfig, + }}, + ) + store := &batchCountingStore{Store: base} + + readsEnv, execProc, fieldAccess := tc.run(store) + if readsEnv != 1 || execProc != 0 || fieldAccess != 0 { + t.Fatalf("counts = (%d, %d, %d), want (1, 0, 0)", readsEnv, execProc, fieldAccess) + } + if store.addBatchCalls != 1 || store.batchEdges != 1 { + t.Fatalf("batch writes = %d calls/%d edges, want 1/1", store.addBatchCalls, store.batchEdges) + } + if store.addEdgeCalls != 0 { + t.Fatalf("per-edge writes = %d, want 0", store.addEdgeCalls) + } + }) + } +} diff --git a/internal/indexer/incremental_contracts.go b/internal/indexer/incremental_contracts.go new file mode 100644 index 000000000..e94a3a293 --- /dev/null +++ b/internal/indexer/incremental_contracts.go @@ -0,0 +1,183 @@ +package indexer + +import ( + "encoding/json" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/zzet/gortex/internal/contracts" + "github.com/zzet/gortex/internal/graph" +) + +// refreshContractsForFiles re-extracts only the exact changed-file frontier. +// It returns whether the effective contract set changed and whether a +// conservative full-repo fallback was required. +func (idx *Indexer) refreshContractsForFiles(files []string) (bool, bool) { + files = appendUniqueSorted(nil, files...) + if len(files) == 0 { + return false, false + } + if idx.contractRegistry == nil { + idx.extractContracts() + return true, true + } + + reg := idx.contractRegistry + _, byLang := idx.buildPerFileContractExtractors() + changed := false + for _, graphPath := range files { + if contractRefreshAlwaysFull(graphPath) { + idx.extractContracts() + return true, true + } + + prior := reg.ByFile(graphPath) + fresh, mtimeNano, exists, fullFallback := idx.extractContractsForGraphFile(graphPath, byLang) + if fullFallback { + idx.extractContracts() + return true, true + } + if !contractSetsEqual(prior, fresh) { + reg.ReplaceFile(graphPath, fresh) + changed = true + } + + idx.contractCacheMu.Lock() + if exists { + idx.contractCache[graphPath] = &contractCacheEntry{mtimeNano: mtimeNano, contracts: fresh} + } else { + delete(idx.contractCache, graphPath) + } + idx.contractCacheMu.Unlock() + } + if changed { + idx.commitContracts(reg) + } + return changed, false +} + +func (idx *Indexer) extractContractsForGraphFile( + graphPath string, + byLang map[string][]contracts.Extractor, +) ([]contracts.Contract, int64, bool, bool) { + fileNodes := idx.graph.GetFileNodes(graphPath) + var fileNode *graph.Node + for _, node := range fileNodes { + if node != nil && node.Kind == graph.KindFile { + fileNode = node + break + } + } + if fileNode == nil { + // The exact file was deleted and its graph nodes were already evicted. + return nil, 0, false, false + } + + relPath := graphPath + if idx.repoPrefix != "" { + prefix := idx.repoPrefix + "/" + if !strings.HasPrefix(relPath, prefix) { + return nil, 0, true, true + } + relPath = strings.TrimPrefix(relPath, prefix) + } + absPath := filepath.Join(idx.rootPath, filepath.FromSlash(relPath)) + info, err := os.Stat(absPath) + if err != nil { + return nil, 0, true, true + } + src, err := os.ReadFile(absPath) + if err != nil { + return nil, 0, true, true + } + if contractSourceNeedsFullRefresh(graphPath, fileNode.Language, src) { + return nil, 0, true, true + } + + fileEdges := idx.graph.GetOutEdges(fileNode.ID) + tree := contracts.ParseTreeForLang(fileNode.Language, src) + fresh := idx.runContractExtractorsForFile( + graphPath, src, fileNodes, fileEdges, byLang[fileNode.Language], tree, + ) + if tree != nil { + tree.Release() + } + + // DI contracts are normally appended by the repo-wide post-pass. Rebuild + // only those whose source edge belongs to this file so ReplaceFile does not + // discard valid @Inject / provider records on an incremental refresh. + for _, node := range fileNodes { + if node == nil { + continue + } + for _, edge := range idx.graph.GetOutEdges(node.ID) { + contract, ok := diContractFromEdge(edge) + if !ok || contract.FilePath != graphPath { + continue + } + contract.RepoPrefix = idx.repoPrefix + if idx.workspaceID != "" { + contract.WorkspaceID = idx.workspaceID + } + if idx.projectID != "" { + contract.ProjectID = idx.projectID + } + fresh = append(fresh, contract) + } + } + return fresh, info.ModTime().UnixNano(), true, false +} + +func contractRefreshAlwaysFull(graphPath string) bool { + base := strings.ToLower(filepath.Base(graphPath)) + return base == "go.mod" || base == "go.work" +} + +func contractSourceNeedsFullRefresh(graphPath, language string, src []byte) bool { + lowerPath := strings.ToLower(graphPath) + lowerSource := strings.ToLower(string(src)) + // These constructs can rewrite contracts owned by sibling files. They are + // uncommon, so retain the full pass only when the changed bytes actually + // contain a cross-file mount or DI declaration. + if language == "python" && strings.Contains(lowerSource, "include_router") { + return true + } + if (language == "typescript" || language == "javascript") && + (strings.Contains(lowerSource, ".use(") || strings.Contains(lowerSource, "@controller(")) { + return true + } + if language == "java" && + (strings.Contains(lowerSource, "@bean") || strings.Contains(lowerSource, "@inject") || + strings.Contains(lowerSource, "@configuration")) { + return true + } + return strings.HasSuffix(lowerPath, ".properties") || strings.HasSuffix(lowerPath, ".yaml") || strings.HasSuffix(lowerPath, ".yml") +} + +func contractSetsEqual(left, right []contracts.Contract) bool { + rows := func(list []contracts.Contract) ([]string, bool) { + out := make([]string, 0, len(list)) + for _, contract := range list { + encoded, err := json.Marshal(contract) + if err != nil { + return nil, false + } + out = append(out, string(encoded)) + } + sort.Strings(out) + return out, true + } + leftRows, leftOK := rows(left) + rightRows, rightOK := rows(right) + if !leftOK || !rightOK || len(leftRows) != len(rightRows) { + return false + } + for i := range leftRows { + if leftRows[i] != rightRows[i] { + return false + } + } + return true +} diff --git a/internal/indexer/incremental_derived.go b/internal/indexer/incremental_derived.go new file mode 100644 index 000000000..42f7fc279 --- /dev/null +++ b/internal/indexer/incremental_derived.go @@ -0,0 +1,159 @@ +package indexer + +import ( + "context" + "time" + + "go.uber.org/zap" + + "github.com/zzet/gortex/internal/resolver" +) + +// IncrementalDerivedReport exposes the work selected by the changed-file +// invalidation plans. It is intentionally count-only so telemetry remains +// bounded even for a large workspace. +type IncrementalDerivedReport struct { + Repos int + Files int + TypeIDs int + LegacyFallback bool + Implements int + Overrides int + TestSymbols int + TestEdges int + Capability int + Framework int + ExternalCalls int + CrossRepo int + Contracts int + DurationMs int64 +} + +// RunIncrementalDerivedPasses executes only the derived families invalidated +// by the exact per-file plans. A legacy database without persisted fingerprints +// takes the old scoped-global path once; ordinary body/metadata edits never do. +func (mi *MultiIndexer) RunIncrementalDerivedPasses( + ctx context.Context, + plans map[string]DerivedInvalidationPlan, +) IncrementalDerivedReport { + started := time.Now() + report := IncrementalDerivedReport{} + if mi == nil || mi.graph == nil || len(plans) == 0 { + return report + } + + var merged DerivedInvalidationPlan + prefixSet := make(map[string]struct{}, len(plans)) + prefixScope := make(map[string]bool, len(plans)) + for prefix, plan := range plans { + if plan.Empty() { + continue + } + merged.Merge(plan) + prefixSet[prefix] = struct{}{} + prefixScope[prefix] = true + } + if len(prefixSet) == 0 { + return report + } + report.Repos = len(prefixSet) + report.Files = len(merged.Files) + report.TypeIDs = len(merged.TypeIDs) + report.LegacyFallback = merged.LegacyFallback + + // A single unprefixed repo cannot use repo-scoped readers. Falling back to + // nil here preserves the old whole-graph behavior, which is still bounded + // to that one repository. + scopedPrefixes := prefixScope + if prefixScope[""] { + scopedPrefixes = nil + } + + if merged.LegacyFallback { + mi.ArmBatchScope(prefixSet) + mi.RunGlobalGraphPasses(ctx) + if merged.Flags.Has(DerivedInvalidatesContracts) { + report.Contracts = mi.ReconcileContractEdges() + } + report.DurationMs = time.Since(started).Milliseconds() + mi.logIncrementalDerived(report, merged) + return report + } + if ctx != nil { + select { + case <-ctx.Done(): + report.DurationMs = time.Since(started).Milliseconds() + return report + default: + } + } + + // Batched IndexFile calls deliberately do not update the clone index because + // its old function signatures are gone by the time this coordinator runs. + // Mark incompleteness rather than rebuilding a whole repo on an edit; the + // explicit clone/global consumer owns the eventual bounded rebuild. + mi.mu.RLock() + for prefix := range prefixSet { + if idx := mi.indexers[prefix]; idx != nil && idx.cloneIndex != nil { + idx.cloneIndex.MarkPending() + } + } + mi.mu.RUnlock() + + if merged.Flags.Has(DerivedInvalidatesDeclarations) && len(merged.TypeIDs) > 0 { + typeFrontier := make(map[string]bool, len(merged.TypeIDs)) + for _, id := range merged.TypeIDs { + typeFrontier[id] = true + } + r := resolver.New(mi.graph) + report.Implements = r.InferImplementsScoped(typeFrontier, typeFrontier) + report.Overrides = r.InferOverridesScoped(typeFrontier) + } + + if merged.Flags.Has(DerivedInvalidatesRuntime) || merged.Flags.Has(DerivedInvalidatesTests) { + report.TestSymbols, report.TestEdges = markTestSymbolsAndEmitEdgesScoped(mi.graph, scopedPrefixes) + } + if merged.Flags.Has(DerivedInvalidatesRuntime) { + readsEnv, execProc, fields := synthesizeCapabilityEdgesScoped(mi.graph, scopedPrefixes) + report.Capability = readsEnv + execProc + fields + } + if merged.Flags.Has(DerivedInvalidatesDeclarations) || + merged.Flags.Has(DerivedInvalidatesImports) || + merged.Flags.Has(DerivedInvalidatesRuntime) { + framework := resolver.RunFrameworkSynthesizersScoped(mi.graph, scopedPrefixes) + report.Framework = framework.Total + if scopedPrefixes == nil { + report.ExternalCalls = resolver.SynthesizeExternalCalls(mi.graph, mi.externalCallSynthesisEnabled()) + } else { + report.ExternalCalls = resolver.SynthesizeExternalCallsForRepos( + mi.graph, mi.externalCallSynthesisEnabled(), scopedPrefixes, + ) + } + report.CrossRepo = resolver.DetectCrossRepoEdgesForFiles(mi.graph, merged.Files) + } + if merged.Flags.Has(DerivedInvalidatesContracts) { + report.Contracts = mi.ReconcileContractEdges() + } + + report.DurationMs = time.Since(started).Milliseconds() + mi.logIncrementalDerived(report, merged) + return report +} + +func (mi *MultiIndexer) logIncrementalDerived(report IncrementalDerivedReport, plan DerivedInvalidationPlan) { + mi.logger.Info("incremental derived passes complete", + zap.Int("repos", report.Repos), + zap.Int("files", report.Files), + zap.Int("type_ids", report.TypeIDs), + zap.Uint32("flags", uint32(plan.Flags)), + zap.Bool("legacy_fallback", report.LegacyFallback), + zap.Int("implements", report.Implements), + zap.Int("overrides", report.Overrides), + zap.Int("test_edges", report.TestEdges), + zap.Int("capability_edges", report.Capability), + zap.Int("framework_edges", report.Framework), + zap.Int("external_calls", report.ExternalCalls), + zap.Int("cross_repo_edges", report.CrossRepo), + zap.Int("contract_edges", report.Contracts), + zap.Int64("duration_ms", report.DurationMs)) +} diff --git a/internal/indexer/incremental_derived_test.go b/internal/indexer/incremental_derived_test.go new file mode 100644 index 000000000..2e48bbf9d --- /dev/null +++ b/internal/indexer/incremental_derived_test.go @@ -0,0 +1,57 @@ +package indexer + +import ( + "context" + "testing" + + "go.uber.org/zap" + + "github.com/zzet/gortex/internal/contracts" + "github.com/zzet/gortex/internal/graph" +) + +func TestRunIncrementalDerivedPassesBodyOnlySkipsGlobalFamilies(t *testing.T) { + mi := &MultiIndexer{ + graph: graph.New(), + indexers: map[string]*Indexer{}, + logger: zap.NewNop(), + } + report := mi.RunIncrementalDerivedPasses(context.Background(), map[string]DerivedInvalidationPlan{ + "repo": { + Files: []string{"repo/a.go"}, + BodyOnlyFiles: 1, + }, + }) + if report.Repos != 1 || report.Files != 1 || report.LegacyFallback { + t.Fatalf("frontier report = %#v", report) + } + if report.Implements != 0 || report.Overrides != 0 || report.TestEdges != 0 || + report.Capability != 0 || report.Framework != 0 || report.ExternalCalls != 0 || + report.CrossRepo != 0 || report.Contracts != 0 { + t.Fatalf("body-only edit ran a derived family: %#v", report) + } +} + +func TestContractSetsEqualIncludesAccuracyBearingLocations(t *testing.T) { + base := contracts.Contract{ID: "http::GET::/x", Role: contracts.RoleProvider, FilePath: "a.go", SymbolID: "a.go::Serve", Line: 4} + if !contractSetsEqual([]contracts.Contract{base}, []contracts.Contract{base}) { + t.Fatal("identical contract sets compare different") + } + shifted := base + shifted.Line++ + if contractSetsEqual([]contracts.Contract{base}, []contracts.Contract{shifted}) { + t.Fatal("line shift was ignored by contract comparison") + } +} + +func TestContractSourceNeedsFullRefreshOnlyForCrossFileConstructs(t *testing.T) { + if contractSourceNeedsFullRefresh("repo/a.go", "go", []byte("func f() {}")) { + t.Fatal("ordinary Go source requested a full contract pass") + } + if !contractSourceNeedsFullRefresh("repo/router.py", "python", []byte("app.include_router(users)")) { + t.Fatal("cross-file router mount did not request conservative fallback") + } + if !contractRefreshAlwaysFull("repo/go.mod") { + t.Fatal("go.mod must keep dependency-contract fallback") + } +} diff --git a/internal/indexer/incremental_reindex_paths_test.go b/internal/indexer/incremental_reindex_paths_test.go index 3343c5e06..feb56a580 100644 --- a/internal/indexer/incremental_reindex_paths_test.go +++ b/internal/indexer/incremental_reindex_paths_test.go @@ -155,6 +155,26 @@ func TestIncrementalReindexPaths_EvictsDeletedFileInScope(t *testing.T) { "a deletion outside the scoped path must NOT be evicted by a scoped pass") } +func TestIncrementalDiscoverPaths_PreservesDeletedTrackedFile(t *testing.T) { + dir := t.TempDir() + gone := filepath.Join(dir, "gone.go") + writeFile(t, gone, "package main\n\nfunc Original() {}\n") + + g := graph.New() + idx := newTestIndexer(g) + _, err := idx.Index(dir) + require.NoError(t, err) + require.NotEmpty(t, g.FindNodesByName("Original")) + + require.NoError(t, os.Remove(gone)) + res, err := idx.incrementalDiscoverPaths(dir, nil) + require.NoError(t, err) + require.NotNil(t, res) + assert.Zero(t, res.DeletedFileCount) + assert.NotEmpty(t, g.FindNodesByName("Original"), + "directory discovery must leave deletion ownership to the file event") +} + // TestIncrementalReindexPaths_RejectsPathOutsideRoot verifies that a // scoped path escaping the repository root is rejected — scoping is a // narrowing operation, never an escape hatch. diff --git a/internal/indexer/indexer.go b/internal/indexer/indexer.go index a2c6d241c..0c70e8737 100644 --- a/internal/indexer/indexer.go +++ b/internal/indexer/indexer.go @@ -116,6 +116,10 @@ type IndexResult struct { // outcome read it from here. Populated by the multi-repo track and // reconcile paths; empty in single-repo mode. RepoPrefix string `json:"repo_prefix,omitempty"` + // DerivedInvalidation is the exact bounded frontier produced by an + // incremental reconcile. A zero plan proves that no graph-wide derived + // pass is required; callers must not infer work merely from stale count. + DerivedInvalidation DerivedInvalidationPlan `json:"derived_invalidation,omitempty"` } // EdgeSanityViolated reports the post-reindex sanity-check failure: an @@ -390,11 +394,20 @@ type Indexer struct { // LSH live across single-file edits, so a steady-state reindex // updates EdgeSimilarTo edges in O(edited file) instead of the // whole-graph detectClonesAndEmitEdges recompute. Constructed empty - // (built=false) — a batch/global clone pass calls Rebuild to seed it, - // after which indexFile drives EvictFuncs/UpdateFuncs. While un-built - // indexFile falls back to the whole-graph pass. + // (built=false) — a batch/global clone pass calls Rebuild to seed it. + // A watcher edit never seeds it synchronously: until an explicit + // clone-consuming/global pass rebuilds it, clone completeness is marked + // pending and the edit hot path stays bounded to the changed file. cloneIndex *incrementalCloneIndex + // prepared caches the watcher's one-shot parsed graph delta until + // indexFile consumes it. This avoids parsing a structural edit twice + // (once to classify it and once to apply it); byte equality in + // takePreparedExtraction prevents stale reuse when another save lands + // between the probe and graph patch. + preparedMu sync.Mutex + prepared map[string]*preparedExtraction + // affectedByPasses / affectedByFilesResolved / affectedByDropped // count the affected-by re-resolution activity (see affected_by.go): // passes that found a signature delta and ran, referencing files @@ -492,7 +505,10 @@ func searchIndexFields(n *graph.Node, projectName string) []string { body, _ := n.Meta["section_text"].(string) return []string{n.Name, indexedPath, body} } - sig, _ := n.Meta["signature"].(string) + // Retrieval-only fields are normalized at the shared extraction boundary. + // The graph-owned accessor keeps search, embeddings, and MCP presentation + // on the same fallback contract without exposing metadata keys. + retrieval := n.RetrievalMetadata() // A symbol's doc comment is the natural-language statement of what it // does — precisely the vocabulary a task-intent query carries ("union // the two sequences", "performs matching on the ignore files") when it @@ -503,8 +519,7 @@ func searchIndexFields(n *graph.Node, projectName string) []string { // can't dilute the name/signature tokens under BM25 length // normalisation. Empty doc → empty field, dropped by the caller, so a // symbol with no doc indexes exactly as before. - doc, _ := n.Meta["doc"].(string) - return []string{n.Name, indexedPath, sig, docSummary(doc)} + return []string{n.Name, indexedPath, retrieval.QualName, retrieval.Signature, docSummary(retrieval.Doc)} } // docSummaryMaxRunes bounds how much of a doc comment enters the search @@ -621,15 +636,9 @@ func lessEdgeKey(a, b *graph.Edge) bool { // same recall against either backend. Joined with spaces so the // downstream COPY FROM sees a single STRING column value. func ftsTokensFor(n *graph.Node, projectName string) string { + // searchIndexFields includes the resolver qualifier or its retrieval-only + // replacement, so both BM25 backends and embeddings see the same token bag. fields := searchIndexFields(n, projectName) - if n.QualName != "" { - // QualName carries the dotted form (`pkg.Sub.Type.Method`) - // that adds qualifier-hop recall ("auth" matching - // "auth.ValidateToken"). searchIndexFields omits it for - // the legacy BM25 path (which folds qual into the - // name-token bag separately), so we add it explicitly here. - fields = append(fields, n.QualName) - } tokens := make([]string, 0, 16) for _, f := range fields { if f == "" { @@ -1016,7 +1025,7 @@ func (idx *Indexer) RunDeferredPasses(ctx context.Context) { tphase = time.Now() reporter.Report("extracting contracts", 0, 0) - idx.runDeferredContracts() + idx.runDeferredContractsAndReleaseSemanticState() dContract = time.Since(tphase) idx.logger.Info("DEFERRED-TIMING per-repo", zap.String("repo", idx.repoPrefix), @@ -1063,6 +1072,10 @@ func (idx *Indexer) runDeferredEnrich() { idx.logger.Info("deferred enrichment forced despite no pending changes", zap.String("repo", idx.repoPrefix)) } + // Lease compiler-backed provider state before enrichment creates it. The + // matching release runs after this repo's contract pass, including failure + // unwinding, so a slow sibling in the batch cannot trigger TTL eviction. + idx.retainDeferredSemanticState() // Key by the repo prefix so a repo-scoped provider can scope file // selection to this repo (empty in single-repo mode). roots := map[string]string{idx.repoPrefix: idx.rootPath} @@ -1176,6 +1189,52 @@ func (idx *Indexer) runDeferredContracts() { idx.pendingContractReg = nil } +// runDeferredContractsAndReleaseSemanticState keeps the compiler program alive +// through every contract consumer and releases it on every return path. The +// defer also prevents a contract-extraction panic from stranding a multi-GB +// stash while the panic unwinds. +func (idx *Indexer) runDeferredContractsAndReleaseSemanticState() { + defer idx.releaseDeferredSemanticState() + idx.runDeferredContracts() +} + +// repoSemanticStateRetainer leases repository-scoped compiler state across the +// enrichment-to-contract batch barrier so TTL/LRU cleanup cannot evict it while +// another repository in the same batch is still enriching. +type repoSemanticStateRetainer interface { + RetainRepoState(repoRoot string) bool +} + +// repoSemanticStateReleaser is implemented by semantic providers that retain +// repository-scoped state solely for the deferred contract consumer. +type repoSemanticStateReleaser interface { + ReleaseRepoState(repoRoot string) bool +} + +func (idx *Indexer) retainDeferredSemanticState() { + if idx.semanticMgr == nil { + return + } + for _, provider := range idx.semanticMgr.AllProviders() { + if retainer, ok := provider.(repoSemanticStateRetainer); ok { + retainer.RetainRepoState(idx.rootPath) + } + } +} + +// releaseDeferredSemanticState runs only after this repository's contract pass +// has returned. Providers not retaining contract-only state are untouched. +func (idx *Indexer) releaseDeferredSemanticState() { + if idx.semanticMgr == nil { + return + } + for _, provider := range idx.semanticMgr.AllProviders() { + if releaser, ok := provider.(repoSemanticStateReleaser); ok { + releaser.ReleaseRepoState(idx.rootPath) + } + } +} + // RootPath returns the root path used for relative path computation. func (idx *Indexer) RootPath() string { return idx.rootPath } @@ -3481,6 +3540,9 @@ func (idx *Indexer) indexFile(filePath string, resolve bool) error { // In multi-repo mode, the graph stores prefixed file paths. graphPath := idx.prefixPath(relPath) + indexFileStarted := time.Now() + var snapshotDuration, commitDuration, resolveDuration time.Duration + var refFactsDuration, affectedDuration, enrichDuration time.Duration // Parse-then-swap: we must NOT evict the file's existing nodes/edges // and search entries until we hold a usable parse result. Evicting @@ -3563,9 +3625,13 @@ func (idx *Indexer) indexFile(filePath string, resolve bool) error { if idx.crashIsolationEnabled() { pool, quarantine = idx.sharedParsePool() } - result, skipped, err := idx.extractFile(pool, quarantine, absPath, relPath, lang, ext, src) - if quarantine != nil && quarantine.Len() > 0 { - _ = quarantine.Save() + result, prepared := idx.takePreparedExtraction(absPath, relPath, lang, src) + skipped := false + if !prepared { + result, skipped, err = idx.extractFile(pool, quarantine, absPath, relPath, lang, ext, src) + if quarantine != nil && quarantine.Len() > 0 { + _ = quarantine.Save() + } } if result == nil { // No usable parse result (transient parse failure, quarantine, @@ -3603,6 +3669,7 @@ func (idx *Indexer) indexFile(filePath string, resolve bool) error { var reuseIdx map[reuseKey]*reuseVal var priorUnresolved []*graph.Edge if resolve && !idx.deferGlobalPasses && !skipped { + snapshotStarted := time.Now() abSnap = idx.snapshotAffectedBy(graphPath) // Snapshot the file's outgoing edges before eviction: resolved ones so // the re-parse recovers unchanged resolutions (reuseIdx), and the ones @@ -3610,17 +3677,25 @@ func (idx *Indexer) indexFile(filePath string, resolve bool) error { // (priorUnresolved). Together this makes a save re-resolve only the // references it actually changed instead of the whole file. reuseIdx, priorUnresolved = captureIncrementalState(idx.graph, graphPath) + snapshotDuration = time.Since(snapshotStarted) } // We hold a usable result: evict the old state now, then add the // new — the window where the file has no nodes is just this gap. + commitStarted := time.Now() evictExisting() - // Coverage extractors (todos, licenses, ownership). Same call - // site exists in the bulk IndexCtx worker pool — see - // applyCoverageDomains. Skipped for a quarantined / timed-out file. + // Coverage extractors (todos, licenses, ownership). A prepared watcher + // delta already ran this exact pipeline; reusing it avoids both a second + // parse and duplicate coverage artifacts. if !skipped { - idx.applyCoverageDomains(relPath, lang, src, result) + if !prepared { + idx.applyCoverageDomains(relPath, lang, src, result) + } + // Persist the canonical raw-extraction identity on the file node. + // Future watcher probes compare against it and skip only when every + // graph artifact — nodes, edges, locations and metadata — is equal. + stampExtractionGraphFingerprint(result) } idx.applyRepoPrefix(result.Nodes, result.Edges) @@ -3680,6 +3755,7 @@ func (idx *Indexer) indexFile(filePath string, resolve bool) error { } } } + commitDuration = time.Since(commitStarted) if resolve { // Forward pass (this file's outgoing references) plus the @@ -3696,7 +3772,9 @@ func (idx *Indexer) indexFile(filePath string, resolve bool) error { // incoming pass, so a small edit to a reference-heavy file no longer // re-runs the candidate cascade on thousands of stdlib/external calls. idx.resolver.SetIncrementalSkip(priorUnresolved) + resolveStarted := time.Now() idx.resolver.ResolveFileAndIncoming(graphPath) + resolveDuration = time.Since(resolveStarted) idx.resolver.SetIncrementalSkip(nil) // CPG-lite dataflow placeholders for this file: inter- // procedural callees may have just been lifted by @@ -3716,19 +3794,22 @@ func (idx *Indexer) indexFile(filePath string, resolve bool) error { // Skipped under deferGlobalPasses — a batch caller (ReconcileAll, // warmup) runs the global pass once at the end. if !idx.deferGlobalPasses { - if idx.cloneIndex != nil { - // Seed the incremental clone index on first use — e.g. after a - // warm restart that loaded the graph from disk without a full - // re-index, `built` is false. Without this we'd fall to the - // whole-graph recompute on EVERY save; instead pay it once and - // then run the O(edited file) update. - if !idx.cloneIndex.built { - idx.cloneIndex.Rebuild(idx.graph, idx.repoPrefix) + newCloneFuncs := cloneFuncNodes(result.Nodes) + // A file with no old or new function nodes cannot change clone + // topology. In particular, do not make the first JSON/Markdown/data + // watcher event after a warm load seed the graph-wide clone index. + // The first real function edit still performs the normal one-time + // rebuild before its incremental update. + if len(oldFuncIDs) > 0 || len(newCloneFuncs) > 0 { + if idx.cloneIndex != nil && idx.cloneIndex.Ready() { + idx.cloneIndex.EvictFuncs(idx.graph, oldFuncIDs) + idx.cloneIndex.UpdateFuncs(idx.graph, idx.repoPrefix, newCloneFuncs, idx.cloneThreshold()) + } else if idx.cloneIndex != nil { + // Never turn the first edit after a warm restart into a hidden + // whole-repo clone scan. Mark the clone view incomplete; an + // explicit clone-consuming/global pass rebuilds it later. + idx.cloneIndex.MarkPending() } - idx.cloneIndex.EvictFuncs(idx.graph, oldFuncIDs) - idx.cloneIndex.UpdateFuncs(idx.graph, idx.repoPrefix, cloneFuncNodes(result.Nodes), idx.cloneThreshold()) - } else { - detectClonesAndEmitEdges(idx.graph, idx.repoPrefix, idx.cloneThreshold()) } } // in-memory backend. Skipped for a quarantined / timed-out / @@ -3738,12 +3819,16 @@ func (idx *Indexer) indexFile(filePath string, resolve bool) error { // reparse — abSnap is nil here too, so the affected-by pass that // would also fan out is already a no-op. if !skipped { + refFactsStarted := time.Now() idx.persistRefFactsForFiles([]string{graphPath}) + refFactsDuration = time.Since(refFactsStarted) // Affected-by re-resolution: if this save changed a symbol's // signature or kind, or removed a symbol, re-resolve the files // that referenced it — bounded, synchronous, and gated on the // signature delta so a body-only edit fans out to nothing. + affectedStarted := time.Now() idx.reresolveAffectedBy(graphPath, abSnap, result.Nodes) + affectedDuration = time.Since(affectedStarted) // Incremental semantic enrichment for this single file. Mirrors the // full-index EnrichAll call but scoped to the saved file, so a @@ -3754,6 +3839,7 @@ func (idx *Indexer) indexFile(filePath string, resolve bool) error { providersPresent := idx.semanticMgr != nil && idx.semanticMgr.Enabled() && idx.semanticMgr.HasProviders() reEnriched := false if providersPresent { + enrichStarted := time.Now() if _, err := idx.semanticMgr.EnrichFile(idx.graph, idx.rootPath, graphPath); err != nil { idx.logger.Debug("indexer: incremental semantic enrichment failed", zap.String("file", graphPath), @@ -3761,6 +3847,7 @@ func (idx *Indexer) indexFile(filePath string, resolve bool) error { } else { reEnriched = idx.semanticMgr.EnrichesOnWatch() } + enrichDuration = time.Since(enrichStarted) } // Record whether this live re-parse left the file below the // enrichment tier: providers exist (so there IS an lsp/ast tier to @@ -3778,6 +3865,18 @@ func (idx *Indexer) indexFile(filePath string, resolve bool) error { // the fileMtimes map and IsStale / TrackedFileState all key on the // slash form. idx.recordFileMtime(mtimeKey, absPath) + if elapsed := time.Since(indexFileStarted); elapsed >= 2*time.Second { + idx.logger.Warn("indexer: slow incremental stages", + zap.String("file", graphPath), + zap.Duration("total", elapsed), + zap.Duration("snapshot_capture", snapshotDuration), + zap.Duration("commit_fts", commitDuration), + zap.Duration("resolver", resolveDuration), + zap.Duration("ref_facts", refFactsDuration), + zap.Duration("affected_by", affectedDuration), + zap.Duration("enrich_file", enrichDuration), + ) + } return nil } @@ -4900,8 +4999,25 @@ func (idx *Indexer) SetRootPath(root string) { // When paths is empty the call degrades to IncrementalReindex(root) — // callers can therefore pass an optional path list unconditionally. func (idx *Indexer) IncrementalReindexPaths(root string, paths []string) (*IndexResult, error) { + return idx.incrementalReindexPaths(root, paths, true) +} + +// incrementalDiscoverPaths discovers and refreshes files beneath paths without +// treating absent tracked files as deletions. Watcher directory-create scans +// use this mode because their job is to recover creates that happened before a +// nested watch was attached. A concurrent file-delete event must remain the +// sole owner of eviction so it can publish the pre-delete symbol snapshot. +func (idx *Indexer) incrementalDiscoverPaths(root string, paths []string) (*IndexResult, error) { + return idx.incrementalReindexPaths(root, paths, false) +} + +func (idx *Indexer) incrementalReindexPaths(root string, paths []string, detectDeletions bool) (*IndexResult, error) { if len(paths) == 0 { - return idx.IncrementalReindex(root) + // An empty scope means the repository root. detectDeletions decides + // whether absent persisted paths are evicted; keeping that choice here + // lets IncrementalReindex share this bounded implementation without a + // recursive wrapper call. + paths = []string{root} } start := time.Now() @@ -5018,44 +5134,61 @@ func (idx *Indexer) IncrementalReindexPaths(root string, paths []string) (*Index } } - // Deletion detection, bounded to the scoped subtree. A file tracked - // in fileMtimes that sits under one of the requested paths but is - // absent from this scoped discovery walk is a deletion candidate; - // the same stat-before-evict guard as IncrementalReindex applies so - // a newly-excluded or transiently-unreachable file is preserved. - idx.mtimeMu.RLock() - var candidates []string - for relPath := range idx.fileMtimes { - if diskFiles[relPath] { - continue - } - if relPathInScope(relPath, scopeRels) { - candidates = append(candidates, relPath) - } - } - idx.mtimeMu.RUnlock() - var deletedFiles []string - for _, relPath := range candidates { - absPath := filepath.Join(absRoot, filepath.FromSlash(relPath)) - _, statErr := os.Stat(absPath) - if statErr == nil { - continue + if detectDeletions { + // Deletion detection is deliberately opt-in. General scoped + // reconciles need it, while directory-create discovery must never + // consume a concurrent delete before the watcher can notify clients. + idx.mtimeMu.RLock() + var candidates []string + for relPath := range idx.fileMtimes { + if diskFiles[relPath] { + continue + } + if relPathInScope(relPath, scopeRels) { + candidates = append(candidates, relPath) + } } - if errors.Is(statErr, os.ErrNotExist) { - deletedFiles = append(deletedFiles, relPath) - continue + idx.mtimeMu.RUnlock() + + for _, relPath := range candidates { + absPath := filepath.Join(absRoot, filepath.FromSlash(relPath)) + _, statErr := os.Stat(absPath) + if statErr == nil { + continue + } + if errors.Is(statErr, os.ErrNotExist) { + deletedFiles = append(deletedFiles, relPath) + continue + } + idx.logger.Warn("incremental reindex: stat failed during scoped deletion detection, preserving", + zap.String("rel", relPath), zap.Error(statErr)) } - idx.logger.Warn("incremental reindex: stat failed during scoped deletion detection, preserving", - zap.String("rel", relPath), zap.Error(statErr)) } + var invalidation DerivedInvalidationPlan for _, relPath := range deletedFiles { // relPath is a fileMtimes key (relKey: slash + NFC); the graph // keys nodes under OS-native separators, so convert before the // evict or a deleted file's nodes leak on Windows. FromSlash is // a no-op on POSIX. graphPath := idx.prefixPath(filepath.FromSlash(relPath)) + priorNodes := idx.graph.GetFileNodes(graphPath) + for _, node := range priorNodes { + if node != nil && node.Kind != graph.KindFile && node.Kind != graph.KindImport { + idx.search.Remove(node.ID) + } + } + priorDerived := storedDerivedFingerprints(priorNodes) + deletedPlan := derivedPlanForDelta( + priorDerived, derivedFingerprints{}, true, + graphPath, priorNodes, nil, + ) + // A known fingerprinted deletion is conservatively all-family but not + // a legacy uncertainty. Missing persisted fingerprints mean this is an + // upgraded database and must retain the wide safety fallback once. + deletedPlan.LegacyFallback = !priorDerived.complete() + invalidation.Merge(deletedPlan) idx.restubIncomingRefs(graphPath) idx.evictEnrichment(graphPath) idx.graph.EvictFile(graphPath) @@ -5063,88 +5196,103 @@ func (idx *Indexer) IncrementalReindexPaths(root string, paths []string) (*Index delete(idx.fileMtimes, relPath) idx.mtimeMu.Unlock() } - // Prune the persisted mtime rows for deleted files too, so the next - // warm restart does not see them as phantom deletions (the in-memory - // delete above does not reach the store's sidecar table). idx.pruneDeletedFileMtimes(deletedFiles) - // Re-index stale files with the same one-shot retry as the - // whole-root path — a file locked or mid-write when the walk caught - // it gets a second chance before landing on FailedFiles. + reindexOne := func(filePath string) error { + mtimeKey := idx.relKey(filePath) + graphPath := idx.prefixPath(idx.graphRelKey(filePath)) + priorNodes := idx.graph.GetFileNodes(graphPath) + storedGraph := storedExtractionGraphFingerprints(priorNodes) + storedDerived := storedDerivedFingerprints(priorNodes) + probe, probeOK := idx.prepareFileDelta(filePath) + + if probeOK && storedGraph.semantic != "" { + switch { + case probe.fingerprints.semantic == storedGraph.semantic && probe.fingerprints.metadata == storedGraph.metadata: + idx.discardPreparedExtraction(filePath) + idx.recordFileMtime(mtimeKey, filePath) + invalidation.InertFiles++ + return nil + case probe.fingerprints.semantic == storedGraph.semantic: + if _, refreshed := idx.applyPreparedMetadataRefresh(filePath, priorNodes); refreshed { + invalidation.MetadataOnlyFiles++ + invalidation.Files = appendUniqueSorted(invalidation.Files, graphPath) + return nil + } + } + } + + if err := idx.IndexFile(filePath); err != nil { + idx.discardPreparedExtraction(filePath) + return err + } + freshNodes := idx.graph.GetFileNodes(graphPath) + freshDerived := storedDerivedFingerprints(freshNodes) + if probeOK && probe.derived.complete() { + freshDerived = probe.derived + } + semanticChanged := !probeOK || storedGraph.semantic == "" || probe.fingerprints.semantic != storedGraph.semantic + invalidation.Merge(derivedPlanForDelta( + storedDerived, freshDerived, semanticChanged, graphPath, priorNodes, freshNodes, + )) + return nil + } + var failedFiles []string - for _, f := range staleFiles { - if err := idx.IndexFile(f); err != nil { + for _, filePath := range staleFiles { + if err := reindexOne(filePath); err != nil { idx.logger.Debug("incremental reindex: failed to index file", - zap.String("file", f), zap.Error(err)) - failedFiles = append(failedFiles, f) + zap.String("file", filePath), zap.Error(err)) + failedFiles = append(failedFiles, filePath) } } if len(failedFiles) > 0 { retry := failedFiles failedFiles = nil - for _, f := range retry { - if err := idx.IndexFile(f); err != nil { + for _, filePath := range retry { + if err := reindexOne(filePath); err != nil { idx.logger.Warn("incremental reindex: file failed after retry", - zap.String("file", f), zap.Error(err)) - failedFiles = append(failedFiles, f) + zap.String("file", filePath), zap.Error(err)) + failedFiles = append(failedFiles, filePath) } } } - // Re-infer interface implementations and re-run stub-call passes — - // eviction may have dropped edges. Skipped under deferGlobalPasses - // so a batch caller runs one global pass at the end. - if !idx.deferGlobalPasses && (len(staleFiles) > 0 || len(deletedFiles) > 0) { - // Scoped inference passes re-derive only the affected types/interfaces - // (add-parity with the full pass); fall back to whole-graph when - // scoping is disabled. - if !idx.runScopedInferencePasses(staleFiles) { - idx.resolver.InferImplements() - idx.resolver.InferOverrides() - } - // Capability (reads_env / executes_process / accesses_field) and - // framework-dispatch synthesis derive from code structure; skip them - // when the reconcile touched only non-code files (docs/config) and - // removed nothing — they cannot change any edge in that case, and - // eviction already handled any deletion. Same idempotent re-derive - // RunGlobalGraphPasses runs at full index. - if len(deletedFiles) > 0 || idx.staleFilesAffectDerivedEdges(staleFiles) { - synthesizeCapabilityEdges(idx.graph) - resolver.RunFrameworkSynthesizers(idx.graph) - } - // Incremental: synthesize external calls only for the reindexed - // files (O(edited files)), not a full-graph recompute. - resolver.SynthesizeExternalCallsForFiles(idx.graph, idx.externalCallSynthesisEnabled(), idx.graphFilePaths(staleFiles)) - } - - // Skip the search-index rebuild on a zero-change reconcile when the - // backend already persists its search structures (the on-disk - // backend keeps its FTS index and vector embeddings on disk). - // buildSearchIndex re-reads every node (GetRepoNodes) and re-embeds - // them, then BulkUpsertEmbeddings re-writes the embedding rows. On a - // warm restart that work is pure recompute of already-persisted data. - // When nothing changed there is nothing to re-embed, so skip it - // entirely — the persisted index is authoritative. The in-memory - // backends (BM25 / Bleve) must still rebuild from the replayed - // snapshot, so they keep the unconditional path. - if len(staleFiles) > 0 || len(deletedFiles) > 0 || !isSymbolSearcherBackend(idx.search) { + // Structural and metadata refreshes maintain both the in-memory search + // backend and persistent FTS one symbol at a time; deletions remove the + // prior symbols above. Rebuilding the whole corpus after a tiny edit would + // re-embed every unchanged symbol. The only rebuild retained here is the + // zero-delta bootstrap for a non-persistent backend restored beside an + // already-populated graph. + if len(staleFiles) == 0 && len(deletedFiles) == 0 && !isSymbolSearcherBackend(idx.search) { idx.buildSearchIndex() } if len(staleFiles) > 0 || len(deletedFiles) > 0 { - idx.extractContracts() + // Contract extraction is file-bounded even for body-only and metadata + // deltas: endpoint literals, response envelopes and source locations can + // change without changing declaration/call topology. Only an effective + // contract-set change requests the workspace reconciliation pass. + contractsChanged, contractFallback := idx.refreshContractsForFiles(invalidation.Files) + if contractsChanged { + invalidation.Flags |= DerivedInvalidatesContracts + } + if contractFallback { + invalidation.LegacyFallback = true + } idx.indexGen.Add(1) // files changed — invalidate the trigram cache } nodes, edges := idx.repoNodeEdgeCount() result := &IndexResult{ - NodeCount: nodes, - EdgeCount: edges, - FileCount: len(diskFiles), - StaleFileCount: len(staleFiles), - DeletedFileCount: len(deletedFiles), - FailedFiles: failedFiles, - DurationMs: time.Since(start).Milliseconds(), + NodeCount: nodes, + EdgeCount: edges, + FileCount: len(diskFiles), + StaleFileCount: len(staleFiles), + DeletedFileCount: len(deletedFiles), + FailedFiles: failedFiles, + DurationMs: time.Since(start).Milliseconds(), + DerivedInvalidation: invalidation, } idx.warnIfEdgeSanityViolated(result) // An incremental pass that re-indexed or evicted at least one file did diff --git a/internal/indexer/metadata_normalize.go b/internal/indexer/metadata_normalize.go new file mode 100644 index 000000000..419d85fcd --- /dev/null +++ b/internal/indexer/metadata_normalize.go @@ -0,0 +1,692 @@ +package indexer + +import ( + "path" + "strconv" + "strings" + + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/parser" +) + +// normalizeExtractionMetadata adds retrieval-only metadata at the shared +// extraction boundary. Parser-owned signature, documentation, and QualName +// values remain untouched: resolvers may rely on their exact representation. +func normalizeExtractionMetadata(result *parser.ExtractionResult, src []byte) { + if result == nil { + return + } + var sourceLines []string + if len(src) > 0 { + sourceLines = strings.Split(string(src), "\n") + } + + nodesByID := make(map[string]*graph.Node, len(result.Nodes)) + for _, n := range result.Nodes { + if n != nil { + nodesByID[n.ID] = n + } + } + ownerIDs := make(map[string]string) + legacyLocals := make(map[string]bool) + for _, edge := range result.Edges { + if edge == nil || edge.Kind != graph.EdgeMemberOf || edge.From == "" || edge.To == "" { + continue + } + if current := ownerIDs[edge.From]; current == "" || edge.To < current { + ownerIDs[edge.From] = edge.To + } + if owner := nodesByID[edge.To]; owner != nil && localScopeOwner(owner.Kind) { + legacyLocals[edge.From] = true + } + } + ownerNodes := make(map[string]*graph.Node, len(ownerIDs)) + ownerNames := make(map[string]string, len(ownerIDs)) + for childID, ownerID := range ownerIDs { + if owner := nodesByID[ownerID]; owner != nil { + ownerNodes[childID] = owner + } else { + ownerNames[childID] = ownerNameFromID(ownerID) + } + } + qualNames := make(map[*graph.Node]string, len(result.Nodes)) + + for _, n := range result.Nodes { + if n == nil || n.Name == "" { + continue + } + if legacyLocalVariable(n, legacyLocals[n.ID]) { + graph.SuppressRetrievalMetadata(n) + continue + } + if !shouldNormalizeDefinitionMetadata(n.Kind) { + // Params, locals, imports, builtins, and synthetic graph entities + // often share their owner's StartLine. Deriving from source would + // copy the enclosing declaration and doc into every child node. + graph.SetRetrievalMetadata(n, graph.RetrievalMetadata{}) + continue + } + sig := compactSignature(normalizedMetaString(n.Meta, "signature"), n.Language) + if sig == "" || syntheticSignature(sig, n.Name) { + if derived := declarationSignature(sourceLines, n); derived != "" { + sig = derived + } + } + if sig == "" { + sig = fallbackSignature(n) + } + sig = compactSignature(sig, n.Language) + + doc := normalizedDoc(firstNonEmpty( + metaString(n.Meta, "doc"), + metaString(n.Meta, "documentation"), + metaString(n.Meta, "comment"), + )) + if doc == "" { + doc = docAbove(sourceLines, n.StartLine) + } + + qual := retrievalQualName(n, ownerNodes, ownerNames, qualNames, make(map[*graph.Node]bool)) + graph.SetRetrievalMetadata(n, graph.RetrievalMetadata{ + Signature: sig, + QualName: qual, + Doc: doc, + }) + } +} + +func legacyLocalVariable(n *graph.Node, edgeLocal bool) bool { + if n == nil || n.Kind != graph.KindVariable { + return false + } + if edgeLocal { + return true + } + for _, key := range []string{"local", "is_local"} { + if value, ok := n.Meta[key].(bool); ok && value { + return true + } + } + for _, key := range []string{"scope", "scope_kind", "parent_kind", "enclosing_kind", "storage"} { + value, _ := n.Meta[key].(string) + switch strings.ToLower(strings.TrimSpace(value)) { + case "local", "function", "function-local", "method", "closure", "block": + return true + } + } + return false +} + +func localScopeOwner(kind graph.NodeKind) bool { + switch kind { + case graph.KindFunction, graph.KindMethod, graph.KindClosure: + return true + default: + return false + } +} + +func shouldNormalizeDefinitionMetadata(kind graph.NodeKind) bool { + switch kind { + case graph.KindFunction, + graph.KindMethod, + graph.KindType, + graph.KindInterface, + graph.KindVariable, + graph.KindField, + graph.KindClosure, + graph.KindConstant, + graph.KindEnumMember, + graph.KindMacro: + return true + default: + return false + } +} + +func retrievalQualName( + n *graph.Node, + ownerNodes map[string]*graph.Node, + ownerNames map[string]string, + cache map[*graph.Node]string, + visiting map[*graph.Node]bool, +) string { + if n == nil || strings.TrimSpace(n.Name) == "" { + return "" + } + if cached := cache[n]; cached != "" { + return cached + } + if visiting[n] { + return boundedMetadata(strings.TrimSpace(n.Name), 512) + } + visiting[n] = true + defer delete(visiting, n) + + separator := qualifierSeparator(n.Language) + module := moduleQualifier(n) + native := normalizeQualifier(n.QualName, separator) + qual := native + if strings.EqualFold(n.Language, "rust") && native != "" { + qual = qualifyRustNative(module, native) + } + if qual == "" { + owner := "" + if ownerNode := ownerNodes[n.ID]; ownerNode != nil { + owner = retrievalQualName(ownerNode, ownerNodes, ownerNames, cache, visiting) + } + if owner == "" { + owner = normalizeQualifier(normalizedMetaString(n.Meta, "receiver"), separator) + } + if owner == "" { + owner = normalizeQualifier(ownerNames[n.ID], separator) + } + if strings.EqualFold(n.Language, "rust") && owner != "" { + owner = qualifyRustNative(module, owner) + } + if owner != "" { + qual = joinQualifiedWithSeparator(owner, n.Name, separator) + } else if module != "" { + qual = joinQualifiedWithSeparator(module, n.Name, separator) + } else { + qual = strings.TrimSpace(n.Name) + } + } + qual = boundedMetadata(qual, 512) + cache[n] = qual + return qual +} + +func qualifyRustNative(module, native string) string { + module = normalizeQualifier(module, "::") + native = normalizeQualifier(native, "::") + if module == "" || native == "" || native == module || strings.HasPrefix(native, module+"::") { + return native + } + native = strings.TrimPrefix(native, "crate::") + moduleParts := strings.Split(module, "::") + nativeParts := strings.Split(native, "::") + overlap := 0 + for size := 1; size <= len(moduleParts) && size <= len(nativeParts); size++ { + if equalQualifierParts(moduleParts[len(moduleParts)-size:], nativeParts[:size]) { + overlap = size + } + } + return strings.Join(append(moduleParts, nativeParts[overlap:]...), "::") +} + +func equalQualifierParts(left, right []string) bool { + if len(left) != len(right) { + return false + } + for i := range left { + if left[i] != right[i] { + return false + } + } + return true +} + +func moduleQualifier(n *graph.Node) string { + if n == nil { + return "" + } + separator := qualifierSeparator(n.Language) + for _, key := range []string{"module_path", "module", "namespace", "package"} { + if value := normalizeQualifier(metaString(n.Meta, key), separator); value != "" { + return boundedMetadata(value, 384) + } + } + + filePath := strings.Trim(strings.ReplaceAll(strings.TrimSpace(n.FilePath), "\\", "/"), "/") + if filePath == "" { + if strings.EqualFold(n.Language, "rust") { + return "crate" + } + return normalizeQualifier(n.RepoPrefix, separator) + } + parts := strings.Split(filePath, "/") + base := parts[len(parts)-1] + stem := strings.TrimSuffix(base, path.Ext(base)) + dirs := append([]string(nil), parts[:len(parts)-1]...) + language := strings.ToLower(strings.TrimSpace(n.Language)) + components := dirs + switch language { + case "rust": + components = append(components, stem) + if stem == "lib" || stem == "main" || stem == "mod" { + components = components[:len(components)-1] + } + for i := len(components) - 1; i >= 0; i-- { + if components[i] == "src" { + components = append(components[:i], components[i+1:]...) + break + } + } + case "python": + if stem != "__init__" { + components = append(components, stem) + } + case "typescript", "tsx", "javascript", "jsx": + if stem != "index" { + components = append(components, stem) + } + case "go", "golang", "java": + // Go package and Java namespace identity comes from the directory; + // the declaration name already represents the file's primary type. + default: + components = append(components, stem) + } + + module := normalizeQualifier(strings.Join(components, "/"), separator) + if module == "" { + module = normalizeQualifier(n.RepoPrefix, separator) + } + if module == "" && language == "rust" { + module = "crate" + } + return boundedMetadata(module, 384) +} + +func qualifierSeparator(language string) string { + if strings.EqualFold(strings.TrimSpace(language), "rust") { + return "::" + } + return "." +} + +func normalizeQualifier(value, separator string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + parts := strings.FieldsFunc(value, func(r rune) bool { + return r == '/' || r == '\\' || r == '.' || r == ':' + }) + clean := parts[:0] + for _, part := range parts { + part = strings.TrimSpace(part) + if part != "" && part != "." { + clean = append(clean, part) + } + } + return strings.Join(clean, separator) +} + +func joinQualifiedWithSeparator(owner, name, separator string) string { + owner = strings.TrimSpace(owner) + name = strings.Trim(strings.TrimSpace(name), ".:") + if owner == "" || name == "" { + return "" + } + if owner == name || strings.HasSuffix(owner, separator+name) { + return owner + } + return owner + separator + name +} + +func fallbackSignature(n *graph.Node) string { + if n == nil { + return "" + } + return boundedMetadata(strings.Join(strings.Fields(string(n.Kind)+" "+n.Name), " "), 512) +} + +func metaString(meta map[string]any, key string) string { + if meta == nil { + return "" + } + v, _ := meta[key].(string) + return v +} + +func normalizedMetaString(meta map[string]any, key string) string { + return strings.Join(strings.Fields(metaString(meta, key)), " ") +} + +func syntheticSignature(sig, name string) bool { + compact := strings.ReplaceAll(sig, " ", "") + return strings.Contains(compact, name+"(...)") || + compact == "function"+name+"()" || + compact == "fn"+name+"(...)" +} + +// declarationSignature extracts only a declaration header from the node's +// source range. It is deliberately retrieval-only and bounded, so an +// imperfect language heuristic can never change symbol identity or resolution. +func declarationSignature(lines []string, n *graph.Node) string { + if len(lines) == 0 || n == nil || n.StartLine < 1 { + return "" + } + // Columns plus a bounded end range are the closest thing to a parser-owned + // header span shared by every extractor. Prefer them when present, then fall + // back to a small source window beginning at the declaration. + if n.EndColumn > 0 && n.EndLine >= n.StartLine && n.EndLine-n.StartLine < 12 { + if signature := declarationHeader(declarationCandidate(lines, n, true), n.Name, n.Language); signature != "" { + return signature + } + } + return declarationHeader(declarationCandidate(lines, n, false), n.Name, n.Language) +} + +func declarationCandidate(lines []string, n *graph.Node, exactSpan bool) string { + start := n.StartLine - 1 + if start < 0 || start >= len(lines) { + return "" + } + end := start + 12 + if exactSpan { + end = n.EndLine + } else if n.EndLine > n.StartLine && n.EndLine < end { + end = n.EndLine + } + if end <= start { + end = start + 1 + } + if end > len(lines) { + end = len(lines) + } + selected := append([]string(nil), lines[start:end]...) + if len(selected) == 0 { + return "" + } + if exactSpan && n.EndColumn > 0 { + last := len(selected) - 1 + if n.EndColumn < len(selected[last]) { + selected[last] = selected[last][:n.EndColumn] + } + } + if n.StartColumn > 0 { + if n.StartColumn >= len(selected[0]) { + return "" + } + selected[0] = selected[0][n.StartColumn:] + } + candidate := strings.TrimSpace(strings.Join(selected, "\n")) + return boundedMetadata(candidate, 2048) +} + +func declarationHeader(candidate, name, language string) string { + if candidate == "" || name == "" { + return "" + } + candidateLines := strings.Split(candidate, "\n") + for len(candidateLines) > 1 { + line := strings.TrimSpace(candidateLines[0]) + if strings.Contains(line, name) || (!strings.HasPrefix(line, "@") && !strings.HasPrefix(line, "#[")) { + break + } + candidateLines = candidateLines[1:] + } + candidate = compactSignature(strings.Join(candidateLines, "\n"), language) + if candidate == "" || !strings.Contains(candidate, name) { + return "" + } + return candidate +} + +func compactSignature(candidate, language string) string { + candidate = strings.TrimSpace(candidate) + if candidate == "" { + return "" + } + language = strings.ToLower(strings.TrimSpace(language)) + parenDepth, bracketDepth, angleDepth := 0, 0, 0 + quote := rune(0) + escaped := false + cut := len(candidate) + for i, r := range candidate { + if quote != 0 { + if escaped { + escaped = false + continue + } + if r == '\\' { + escaped = true + continue + } + if r == quote { + quote = 0 + } + continue + } + switch r { + case '\'': + if language == "rust" && rustLifetimeAt(candidate, i) { + continue + } + quote = r + case '"', '`': + quote = r + case '(': + parenDepth++ + case ')': + if parenDepth > 0 { + parenDepth-- + } + case '[': + bracketDepth++ + case ']': + if bracketDepth > 0 { + bracketDepth-- + } + case '<': + if (language == "go" || language == "golang") && i+1 < len(candidate) && candidate[i+1] == '-' { + continue + } + angleDepth++ + case '>': + if angleDepth > 0 { + angleDepth-- + } + case '{', ';': + if parenDepth == 0 && bracketDepth == 0 && angleDepth == 0 { + cut = i + } + case '\n', '\r': + if !signatureLanguageAllowsMultiline(language) && parenDepth == 0 && bracketDepth == 0 && angleDepth == 0 { + cut = i + } + } + if cut != len(candidate) { + break + } + } + return boundedMetadata(strings.Join(strings.Fields(candidate[:cut]), " "), 512) +} + +func signatureLanguageAllowsMultiline(language string) bool { + switch language { + case "go", "golang", "rust", "java", "kotlin", "typescript", "tsx", "javascript", "jsx", "c", "cpp", "c++", "csharp", "c#", "swift": + return true + default: + return false + } +} + +func rustLifetimeAt(value string, quote int) bool { + if quote+1 >= len(value) || !signatureIdentifierByte(value[quote+1]) { + return false + } + end := quote + 2 + for end < len(value) && signatureIdentifierByte(value[end]) { + end++ + } + return end >= len(value) || value[end] != '\'' +} + +func signatureIdentifierByte(b byte) bool { + return b == '_' || b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z' || b >= '0' && b <= '9' +} + +func normalizedDoc(doc string) string { + if doc == "" { + return "" + } + if attributeDoc := rustDocAttribute(doc); attributeDoc != "" { + doc = attributeDoc + } + lines := strings.Split(doc, "\n") + for i := range lines { + line := strings.TrimSpace(lines[i]) + line = strings.TrimSpace(strings.TrimPrefix(line, "/**")) + line = strings.TrimSpace(strings.TrimPrefix(line, "/*")) + line = strings.TrimSpace(strings.TrimSuffix(line, "*/")) + line = strings.TrimSpace(strings.TrimPrefix(line, "///")) + line = strings.TrimSpace(strings.TrimPrefix(line, "//!")) + line = strings.TrimSpace(strings.TrimPrefix(line, "//")) + line = strings.TrimSpace(strings.TrimPrefix(line, "#")) + line = strings.TrimSpace(strings.TrimPrefix(line, "*")) + lines[i] = line + } + return boundedMetadata(strings.Join(strings.Fields(strings.Join(lines, " ")), " "), 1024) +} + +func docAbove(lines []string, startLine int) string { + if len(lines) == 0 || startLine <= 1 { + return "" + } + const maxDocScanLines = 96 + i := startLine - 2 + scanned := 0 + var reversed []string + for i >= 0 && scanned < maxDocScanLines { + if start, attribute, ok := rustAttributeAbove(lines, i, maxDocScanLines-scanned); ok { + if doc := rustDocAttribute(attribute); doc != "" { + reversed = append(reversed, doc) + } + scanned += i - start + 1 + i = start - 1 + continue + } + + trimmed := strings.TrimSpace(lines[i]) + if strings.HasPrefix(trimmed, "@") { + i-- + scanned++ + continue + } + if strings.HasSuffix(trimmed, "*/") { + end := i + for i >= 0 && scanned < maxDocScanLines { + scanned++ + if strings.Contains(lines[i], "/*") { + reversed = append(reversed, normalizedDoc(strings.Join(lines[i:end+1], "\n"))) + i-- + break + } + i-- + } + continue + } + if isLineDoc(trimmed) { + end := i + for i >= 0 && scanned < maxDocScanLines && isLineDoc(strings.TrimSpace(lines[i])) { + i-- + scanned++ + } + reversed = append(reversed, normalizedDoc(strings.Join(lines[i+1:end+1], "\n"))) + continue + } + break + } + for left, right := 0, len(reversed)-1; left < right; left, right = left+1, right-1 { + reversed[left], reversed[right] = reversed[right], reversed[left] + } + return normalizedDoc(strings.Join(reversed, " ")) +} + +func isLineDoc(line string) bool { + return strings.HasPrefix(line, "//") || strings.HasPrefix(line, "# ") +} + +func rustAttributeAbove(lines []string, end, limit int) (int, string, bool) { + if end < 0 || end >= len(lines) || limit <= 0 { + return 0, "", false + } + depth := 0 + for i := end; i >= 0 && end-i < limit; i-- { + line := strings.TrimSpace(lines[i]) + if line == "" || isLineDoc(line) || strings.HasSuffix(line, "*/") { + return 0, "", false + } + for _, r := range line { + switch r { + case '[': + depth-- + case ']': + depth++ + } + } + if strings.HasPrefix(line, "#[") && depth <= 0 { + return i, strings.Join(lines[i:end+1], "\n"), true + } + } + return 0, "", false +} + +func rustDocAttribute(attribute string) string { + attribute = strings.TrimSpace(attribute) + if !strings.HasPrefix(attribute, "#[doc") { + return "" + } + rest := strings.TrimSpace(strings.TrimPrefix(attribute, "#[doc")) + if !strings.HasPrefix(rest, "=") { + return "" + } + literal := strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(strings.TrimPrefix(rest, "=")), "]")) + if strings.HasPrefix(literal, "r") { + quote := strings.IndexByte(literal, '"') + if quote > 0 { + hashes := literal[1:quote] + closing := "\"" + hashes + if end := strings.LastIndex(literal, closing); end > quote { + return boundedMetadata(literal[quote+1:end], 1024) + } + } + } + firstQuote := strings.IndexByte(literal, '"') + lastQuote := strings.LastIndexByte(literal, '"') + if firstQuote < 0 || lastQuote <= firstQuote { + return "" + } + value, err := strconv.Unquote(literal[firstQuote : lastQuote+1]) + if err != nil { + return "" + } + return boundedMetadata(value, 1024) +} + +func boundedMetadata(value string, limit int) string { + value = strings.TrimSpace(value) + if limit <= 0 || len(value) <= limit { + return value + } + cut := limit + for cut > 0 && value[cut]&0xc0 == 0x80 { + cut-- + } + return strings.TrimSpace(value[:cut]) +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value = strings.TrimSpace(value); value != "" { + return value + } + } + return "" +} + +func ownerNameFromID(id string) string { + if id == "" || strings.HasPrefix(id, "unresolved::") { + return "" + } + if idx := strings.LastIndex(id, "::"); idx >= 0 { + id = id[idx+2:] + } + if idx := strings.LastIndexAny(id, ".#"); idx >= 0 { + id = id[idx+1:] + } + return strings.TrimSpace(id) +} diff --git a/internal/indexer/metadata_normalize_test.go b/internal/indexer/metadata_normalize_test.go new file mode 100644 index 000000000..1ca26c2a7 --- /dev/null +++ b/internal/indexer/metadata_normalize_test.go @@ -0,0 +1,436 @@ +package indexer + +import ( + "reflect" + "strings" + "testing" + + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/parser" +) + +type metadataFixtureExtractor struct { + result *parser.ExtractionResult +} + +func (e metadataFixtureExtractor) Language() string { return "rust" } +func (e metadataFixtureExtractor) Extensions() []string { return []string{".rs"} } +func (e metadataFixtureExtractor) Extract(string, []byte) (*parser.ExtractionResult, error) { + return e.result, nil +} + +func TestExtractFileNormalizesMetadataAtSharedBoundary(t *testing.T) { + src := []byte("fn resolve(value: Input) -> Output { value.into() }\n") + n := &graph.Node{ + ID: "src/lib.rs::resolve", Kind: graph.KindFunction, Name: "resolve", + FilePath: "src/lib.rs", StartLine: 1, EndLine: 1, Language: "rust", + Meta: map[string]any{"signature": "fn resolve(...)"}, + } + idx := &Indexer{} + result, skipped, err := idx.extractFile(nil, nil, "src/lib.rs", "src/lib.rs", "rust", metadataFixtureExtractor{ + result: &parser.ExtractionResult{Nodes: []*graph.Node{n}}, + }, src) + if err != nil || skipped { + t.Fatalf("extractFile() err = %v, skipped = %v", err, skipped) + } + if got := result.Nodes[0].RetrievalMetadata().Signature; got != "fn resolve(value: Input) -> Output" { + t.Fatalf("search signature = %q", got) + } +} + +func TestNormalizeExtractionMetadataRustMethod(t *testing.T) { + src := []byte("impl Worker {\n /// Runs a queued job.\n pub fn run(\n &self,\n item: T,\n ) -> Result<(), Error> {\n }\n}\n") + owner := &graph.Node{ID: "src/worker.rs::Worker", Name: "Worker", FilePath: "src/worker.rs", StartLine: 1, Language: "rust"} + method := &graph.Node{ + ID: "src/worker.rs::Worker.run", Kind: graph.KindMethod, Name: "run", QualName: "Worker::run", + FilePath: "src/worker.rs", StartLine: 3, EndLine: 7, Language: "rust", + Meta: map[string]any{"signature": "fn run(...)", "receiver": "Worker", "doc": "/// Runs a queued job."}, + } + result := &parser.ExtractionResult{ + Nodes: []*graph.Node{owner, method}, + Edges: []*graph.Edge{{From: method.ID, To: owner.ID, Kind: graph.EdgeMemberOf}}, + } + + normalizeExtractionMetadata(result, src) + + if got := method.Meta["signature"]; got != "fn run(...)" { + t.Fatalf("parser signature mutated: %v", got) + } + if method.QualName != "Worker::run" { + t.Fatalf("resolver QualName mutated: %q", method.QualName) + } + retrieval := method.RetrievalMetadata() + if retrieval.Signature != "pub fn run( &self, item: T, ) -> Result<(), Error>" { + t.Fatalf("search signature = %q", retrieval.Signature) + } + if retrieval.QualName != "worker::Worker::run" { + t.Fatalf("search qualifier = %q", retrieval.QualName) + } + if retrieval.Doc != "Runs a queued job." { + t.Fatalf("search doc = %q", retrieval.Doc) + } +} + +func TestNormalizeExtractionMetadataTypeScriptFallbacks(t *testing.T) { + src := []byte("/**\n * Validates an incoming request.\n */\nexport async function validate(\n input: Request,\n): Promise {\n return check(input)\n}\n") + n := &graph.Node{ + ID: "src/auth/index.ts::validate", Kind: graph.KindFunction, Name: "validate", + FilePath: "src/auth/index.ts", StartLine: 4, EndLine: 8, Language: "typescript", + Meta: map[string]any{"signature": "function validate()"}, + } + + normalizeExtractionMetadata(&parser.ExtractionResult{Nodes: []*graph.Node{n}}, src) + + retrieval := n.RetrievalMetadata() + if retrieval.Signature != "export async function validate( input: Request, ): Promise" { + t.Fatalf("search signature = %q", retrieval.Signature) + } + if retrieval.Doc != "Validates an incoming request." { + t.Fatalf("search doc = %q", retrieval.Doc) + } + if retrieval.QualName != "src.auth.validate" { + t.Fatalf("search qualifier = %q", retrieval.QualName) + } +} + +func TestNormalizeExtractionMetadataPreservesExplicitQualName(t *testing.T) { + n := &graph.Node{ + ID: "service.go::Service.Handle", Kind: graph.KindMethod, Name: "Handle", + QualName: "example.Service.Handle", FilePath: "service.go", StartLine: 1, + Meta: map[string]any{"signature": "func (s *Service) Handle(ctx context.Context)", "doc": " Handles requests. "}, + } + + normalizeExtractionMetadata(&parser.ExtractionResult{Nodes: []*graph.Node{n}}, nil) + + if n.QualName != "example.Service.Handle" { + t.Fatalf("QualName mutated: %q", n.QualName) + } + retrieval := n.RetrievalMetadata() + if retrieval.QualName != n.QualName { + t.Fatalf("search qualifier = %q", retrieval.QualName) + } + if retrieval.Doc != "Handles requests." { + t.Fatalf("search doc = %q", retrieval.Doc) + } +} + +func TestNormalizeExtractionMetadataDoesNotCopyOwnerTextIntoParam(t *testing.T) { + src := []byte("/// Resolves an input value.\nfn resolve(value: Input) -> Output { value.into() }\n") + fn := &graph.Node{ + ID: "src/lib.rs::resolve", Kind: graph.KindFunction, Name: "resolve", + FilePath: "src/lib.rs", StartLine: 2, EndLine: 2, Language: "rust", + Meta: map[string]any{"signature": "fn resolve(...)"}, + } + param := &graph.Node{ + ID: fn.ID + "#param:value", Kind: graph.KindParam, Name: "value", + FilePath: fn.FilePath, StartLine: fn.StartLine, EndLine: fn.EndLine, Language: fn.Language, + } + + normalizeExtractionMetadata(&parser.ExtractionResult{Nodes: []*graph.Node{fn, param}}, src) + + owner := fn.RetrievalMetadata() + if owner.Signature != "fn resolve(value: Input) -> Output" || owner.Doc != "Resolves an input value." { + t.Fatalf("owner metadata = %#v", owner) + } + child := param.RetrievalMetadata() + if child.Signature != "" || child.Doc != "" || child.QualName != "" { + t.Fatalf("parameter inherited owner metadata: %#v", child) + } + fields := searchIndexFields(param, "") + if len(fields) != 5 || fields[2] != "" || fields[3] != "" || fields[4] != "" { + t.Fatalf("parameter search fields contain owner payload: %#v", fields) + } + if joined := strings.Join(fields, " "); strings.Contains(joined, "resolve") || strings.Contains(joined, "Resolves") { + t.Fatalf("parameter duplicated enclosing declaration: %q", joined) + } +} + +func TestShouldNormalizeDefinitionMetadata(t *testing.T) { + allowed := []graph.NodeKind{ + graph.KindFunction, graph.KindMethod, graph.KindType, graph.KindInterface, + graph.KindVariable, graph.KindField, graph.KindClosure, graph.KindConstant, + graph.KindEnumMember, graph.KindMacro, + } + for _, kind := range allowed { + if !shouldNormalizeDefinitionMetadata(kind) { + t.Errorf("definition kind %q rejected", kind) + } + } + denied := []graph.NodeKind{ + graph.KindParam, graph.KindLocal, graph.KindImport, graph.KindBuiltin, + graph.KindFile, graph.KindPackage, graph.KindGenericParam, graph.KindContract, + graph.KindModule, graph.KindDoc, graph.KindEvent, graph.KindString, + } + for _, kind := range denied { + if shouldNormalizeDefinitionMetadata(kind) { + t.Errorf("non-definition kind %q accepted", kind) + } + } +} + +func TestNormalizeExtractionMetadataAcrossLanguages(t *testing.T) { + tests := []struct { + name string + language string + filePath string + kind graph.NodeKind + symbol string + source string + startLine int + endLine int + wantQual string + wantSig string + wantDoc string + }{ + { + name: "go", language: "go", filePath: "internal/api/service.go", kind: graph.KindFunction, symbol: "Serve", + source: "// Serves requests.\nfunc Serve(ctx context.Context) error { return nil }\n", startLine: 2, endLine: 2, + wantQual: "internal.api.Serve", wantSig: "func Serve(ctx context.Context) error", wantDoc: "Serves requests.", + }, + { + name: "java", language: "java", filePath: "src/main/java/com/acme/Builder.java", kind: graph.KindType, symbol: "Builder", + source: "/** Builds values. */\n@Deprecated\npublic final class Builder {\n}\n", startLine: 3, endLine: 4, + wantQual: "src.main.java.com.acme.Builder", wantSig: "public final class Builder", wantDoc: "Builds values.", + }, + { + name: "python", language: "python", filePath: "pkg/io.py", kind: graph.KindFunction, symbol: "load", + source: "# Loads values.\ndef load(value: str) -> Result:\n return Result(value)\n", startLine: 2, endLine: 3, + wantQual: "pkg.io.load", wantSig: "def load(value: str) -> Result:", wantDoc: "Loads values.", + }, + { + name: "typescript", language: "typescript", filePath: "src/api/users.ts", kind: graph.KindFunction, symbol: "loadUsers", + source: "/** Loads users. */\nexport function loadUsers(id: ID): User {\n return users[id]\n}\n", startLine: 2, endLine: 4, + wantQual: "src.api.users.loadUsers", wantSig: "export function loadUsers(id: ID): User", wantDoc: "Loads users.", + }, + { + name: "rust", language: "rust", filePath: "crates/alpha/src/parser.rs", kind: graph.KindFunction, symbol: "parse", + source: "/// Parses values.\npub fn parse(input: &str) -> Value {\n todo!()\n}\n", startLine: 2, endLine: 4, + wantQual: "crates::alpha::parser::parse", wantSig: "pub fn parse(input: &str) -> Value", wantDoc: "Parses values.", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + n := &graph.Node{ + ID: tt.filePath + "::" + tt.symbol, Kind: tt.kind, Name: tt.symbol, + FilePath: tt.filePath, StartLine: tt.startLine, EndLine: tt.endLine, Language: tt.language, + } + normalizeExtractionMetadata(&parser.ExtractionResult{Nodes: []*graph.Node{n}}, []byte(tt.source)) + got := n.RetrievalMetadata() + if got.QualName != tt.wantQual || got.Signature != tt.wantSig || got.Doc != tt.wantDoc { + t.Fatalf("metadata = %#v, want qual=%q signature=%q doc=%q", got, tt.wantQual, tt.wantSig, tt.wantDoc) + } + if n.QualName != "" { + t.Fatalf("parser QualName mutated: %q", n.QualName) + } + }) + } +} + +func TestNormalizeExtractionMetadataRustDocsThroughAttributes(t *testing.T) { + src := []byte("/// Base docs.\n#[cfg(\n feature = \"fast\",\n)]\n#[inline]\n#[doc = \"Attribute docs.\"]\n#[doc = r#\"Raw docs.\"#]\npub fn run(value: Input) -> Output { value.into() }\n") + n := &graph.Node{ + ID: "src/feature.rs::run", Kind: graph.KindFunction, Name: "run", + FilePath: "src/feature.rs", StartLine: 8, EndLine: 8, Language: "rust", + } + normalizeExtractionMetadata(&parser.ExtractionResult{Nodes: []*graph.Node{n}}, src) + + got := n.RetrievalMetadata() + if got.Doc != "Base docs. Attribute docs. Raw docs." { + t.Fatalf("doc = %q", got.Doc) + } + if got.QualName != "feature::run" { + t.Fatalf("qualifier = %q", got.QualName) + } +} + +func TestNormalizeExtractionMetadataDisambiguatesRustModules(t *testing.T) { + alpha := &graph.Node{ + ID: "crates/alpha/src/lib.rs::parse", Kind: graph.KindFunction, Name: "parse", + FilePath: "crates/alpha/src/lib.rs", Language: "rust", Meta: map[string]any{"signature": "fn parse()", "doc": "Parses alpha."}, + } + beta := &graph.Node{ + ID: "crates/beta/src/lib.rs::parse", Kind: graph.KindFunction, Name: "parse", + FilePath: "crates/beta/src/lib.rs", Language: "rust", Meta: map[string]any{"signature": "fn parse()", "doc": "Parses beta."}, + } + normalizeExtractionMetadata(&parser.ExtractionResult{Nodes: []*graph.Node{alpha, beta}}, nil) + + if got := alpha.RetrievalMetadata().QualName; got != "crates::alpha::parse" { + t.Fatalf("alpha qualifier = %q", got) + } + if got := beta.RetrievalMetadata().QualName; got != "crates::beta::parse" { + t.Fatalf("beta qualifier = %q", got) + } + if alpha.QualName != "" || beta.QualName != "" { + t.Fatalf("parser qualifiers mutated: alpha=%q beta=%q", alpha.QualName, beta.QualName) + } +} + +func TestDeclarationSignaturePrefersColumnSpan(t *testing.T) { + line := "ignored prefix fn exact(value: Input) -> Output { body() } trailing" + start := strings.Index(line, "fn exact") + end := strings.Index(line, " { body") + n := &graph.Node{ + Kind: graph.KindFunction, Name: "exact", StartLine: 1, EndLine: 1, + StartColumn: start, EndColumn: end, + } + if got := declarationSignature([]string{line}, n); got != "fn exact(value: Input) -> Output" { + t.Fatalf("signature = %q", got) + } +} + +func TestNormalizeExtractionMetadataSanitizesParserSignature(t *testing.T) { + n := &graph.Node{ + ID: "src/lib.rs::run", Kind: graph.KindFunction, Name: "run", Language: "rust", + Meta: map[string]any{"signature": "fn run(value: Input) -> Output { let INLINE_SECRET = \"token\"; } " + strings.Repeat("x", 700)}, + } + normalizeExtractionMetadata(&parser.ExtractionResult{Nodes: []*graph.Node{n}}, nil) + + got := n.RetrievalMetadata().Signature + if got != "fn run(value: Input) -> Output" || strings.Contains(got, "INLINE_SECRET") || len(got) > 512 { + t.Fatalf("signature not compact: len=%d value=%q", len(got), got) + } + if !strings.Contains(n.Meta["signature"].(string), "INLINE_SECRET") { + t.Fatalf("parser signature mutated: %q", n.Meta["signature"]) + } +} + +func TestDeclarationSignatureLanguageAwareBodyBoundary(t *testing.T) { + t.Run("go receive-only channel", func(t *testing.T) { + line := "func relay(dst chan<- string, src <-chan string) { INLINE_SECRET := token }" + n := &graph.Node{Kind: graph.KindFunction, Name: "relay", Language: "go", StartLine: 1, EndLine: 1} + got := declarationSignature([]string{line}, n) + if got != "func relay(dst chan<- string, src <-chan string)" || strings.Contains(got, "INLINE_SECRET") { + t.Fatalf("signature = %q", got) + } + }) + t.Run("rust lifetimes", func(t *testing.T) { + source := "pub fn borrow<'a, T>(\n value: &'a T,\n) -> impl for<'b> Trait<'b>\nwhere\n T: 'a,\n{\n let INLINE_SECRET = token;\n}\n" + n := &graph.Node{Kind: graph.KindFunction, Name: "borrow", Language: "rust", StartLine: 1, EndLine: 8} + got := declarationSignature(strings.Split(source, "\n"), n) + want := "pub fn borrow<'a, T>( value: &'a T, ) -> impl for<'b> Trait<'b> where T: 'a," + if got != want || strings.Contains(got, "INLINE_SECRET") { + t.Fatalf("signature = %q, want %q", got, want) + } + }) +} + +func TestNormalizeExtractionMetadataRustQualifierOverlap(t *testing.T) { + method := &graph.Node{ + ID: "crates/alpha/src/parser.rs::Type.method", Kind: graph.KindMethod, Name: "method", + QualName: "crate::parser::Type::method", FilePath: "crates/alpha/src/parser.rs", Language: "rust", + Meta: map[string]any{"signature": "fn method(&self)", "doc": "Runs."}, + } + normalizeExtractionMetadata(&parser.ExtractionResult{Nodes: []*graph.Node{method}}, nil) + if got := method.RetrievalMetadata().QualName; got != "crates::alpha::parser::Type::method" { + t.Fatalf("qualifier = %q", got) + } + if method.QualName != "crate::parser::Type::method" { + t.Fatalf("parser qualifier mutated: %q", method.QualName) + } +} + +func TestNormalizeExtractionMetadataAvoidsDefaultFileStemDuplication(t *testing.T) { + n := &graph.Node{ + ID: "pkg/Foo.widget::Foo", Kind: graph.KindType, Name: "Foo", + FilePath: "pkg/Foo.widget", Language: "widget", Meta: map[string]any{"signature": "type Foo", "doc": "Foo type."}, + } + normalizeExtractionMetadata(&parser.ExtractionResult{Nodes: []*graph.Node{n}}, nil) + if got := n.RetrievalMetadata().QualName; got != "pkg.Foo" { + t.Fatalf("duplicated qualifier = %q", got) + } +} + +func TestNormalizeExtractionMetadataMemberOfSelectionIsStable(t *testing.T) { + qualifier := func(reverse bool) string { + alpha := &graph.Node{ID: "types.go::Alpha", Kind: graph.KindType, Name: "Alpha", QualName: "pkg.Alpha", Language: "go"} + beta := &graph.Node{ID: "types.go::Beta", Kind: graph.KindType, Name: "Beta", QualName: "pkg.Beta", Language: "go"} + method := &graph.Node{ID: "types.go::Run", Kind: graph.KindMethod, Name: "Run", Language: "go"} + edges := []*graph.Edge{ + {From: method.ID, To: alpha.ID, Kind: graph.EdgeMemberOf}, + {From: method.ID, To: beta.ID, Kind: graph.EdgeMemberOf}, + } + if reverse { + edges[0], edges[1] = edges[1], edges[0] + } + normalizeExtractionMetadata(&parser.ExtractionResult{Nodes: []*graph.Node{alpha, beta, method}, Edges: edges}, nil) + return method.RetrievalMetadata().QualName + } + forward, reverse := qualifier(false), qualifier(true) + if forward != "pkg.Alpha.Run" || reverse != forward { + t.Fatalf("unstable qualifiers: forward=%q reverse=%q", forward, reverse) + } +} + +func TestNormalizeExtractionMetadataSuppressesLegacyVariableLocalsOnly(t *testing.T) { + fn := &graph.Node{ID: "scope.go::resolve", Kind: graph.KindFunction, Name: "resolve", QualName: "pkg.resolve", Language: "go"} + local := &graph.Node{ + ID: "scope.go::value", Kind: graph.KindVariable, Name: "value", Language: "go", + Meta: map[string]any{"signature": "var value = INLINE_SECRET", "doc": "local value"}, + } + metadataLocal := &graph.Node{ + ID: "scope.go::other", Kind: graph.KindVariable, Name: "other", Language: "go", + Meta: map[string]any{"scope": "block", "signature": "var other = INLINE_SECRET", "doc": "other local"}, + } + global := &graph.Node{ + ID: "globals.go::Global", Kind: graph.KindVariable, Name: "Global", Language: "go", + Meta: map[string]any{"scope": "global", "signature": "var Global string", "doc": "global value"}, + } + owner := &graph.Node{ID: "types.go::Record", Kind: graph.KindType, Name: "Record", QualName: "pkg.Record", Language: "go"} + field := &graph.Node{ + ID: "types.go::Record.Value", Kind: graph.KindField, Name: "Value", Language: "go", + Meta: map[string]any{"signature": "Value string", "doc": "field value"}, + } + result := &parser.ExtractionResult{ + Nodes: []*graph.Node{fn, local, metadataLocal, global, owner, field}, + Edges: []*graph.Edge{ + {From: local.ID, To: fn.ID, Kind: graph.EdgeMemberOf}, + {From: field.ID, To: owner.ID, Kind: graph.EdgeMemberOf}, + }, + } + normalizeExtractionMetadata(result, nil) + + for _, n := range []*graph.Node{local, metadataLocal} { + if got := n.RetrievalMetadata(); got != (graph.RetrievalMetadata{}) { + t.Errorf("local %s leaked metadata: %#v", n.Name, got) + } + if !strings.Contains(n.Meta["signature"].(string), "INLINE_SECRET") { + t.Errorf("parser metadata mutated for %s: %#v", n.Name, n.Meta) + } + } + if got := global.RetrievalMetadata(); got.Signature != "var Global string" || got.Doc != "global value" { + t.Fatalf("global suppressed: %#v", got) + } + if got := field.RetrievalMetadata(); got.QualName != "pkg.Record.Value" || got.Signature != "Value string" { + t.Fatalf("field suppressed: %#v", got) + } +} + +func TestSearchIndexFieldsUseNormalizedRetrievalMetadata(t *testing.T) { + n := &graph.Node{ + Kind: graph.KindFunction, Name: "validate", QualName: "legacy.validate", FilePath: "repo/src/auth.ts", + Meta: map[string]any{ + "signature": "function validate()", + "doc": "legacy docs", + }, + } + graph.SetRetrievalMetadata(n, graph.RetrievalMetadata{ + Signature: "function validate(input: Request): Result", + QualName: "auth.validate", + Doc: "Validates incoming requests.", + }) + + want := []string{"validate", "repo/src/auth.ts", "auth.validate", "function validate(input: Request): Result", "Validates incoming requests."} + if got := searchIndexFields(n, ""); !reflect.DeepEqual(got, want) { + t.Fatalf("fields = %#v, want %#v", got, want) + } + tokens := strings.Fields(ftsTokensFor(n, "")) + count := 0 + for _, token := range tokens { + if token == "auth" { + count++ + } + } + if count != 2 { // path plus retrieval qualifier; no duplicate QualName append + t.Fatalf("auth token count = %d in %q", count, strings.Join(tokens, " ")) + } +} diff --git a/internal/indexer/multi.go b/internal/indexer/multi.go index ce245d797..7d48f9982 100644 --- a/internal/indexer/multi.go +++ b/internal/indexer/multi.go @@ -447,44 +447,125 @@ func (mi *MultiIndexer) RunDeferredPassesAll(ctx context.Context) int { mi.mu.RUnlock() forced := os.Getenv("GORTEX_WARMUP_FORCE_ENRICH") == "1" enrichScheduled := 0 + catchupNeeded := false + catchupScopeKnown := true + catchupScope := make(map[string]struct{}) for _, idx := range indexers { - if idx.semanticMgr == nil || !idx.semanticMgr.Enabled() || !idx.semanticMgr.HasProviders() { + enrich := idx.semanticMgr != nil && idx.semanticMgr.Enabled() && idx.semanticMgr.HasProviders() && + (idx.pendingEnrich.Load() || forced) + if enrich { + enrichScheduled++ + } + // This remains the conservative fallback for stores that do not yet + // advertise exact mutation receipts (notably SQLite). + if !enrich && idx.pendingContractReg == nil { continue } - if idx.pendingEnrich.Load() || forced { - enrichScheduled++ + catchupNeeded = true + if idx.repoPrefix == "" { + catchupScopeKnown = false + continue } + catchupScope[idx.repoPrefix] = struct{}{} } for _, idx := range indexers { idx.SetSkipResolveInDeferred(true) } - // Per-repo deferred work in three phases. gomod (materialises dep - // contract nodes) and contracts (extract + commit, which walk repo edges) - // mutate the shared graph in ways that race across repos, so they stay - // serial. Enrichment is the dominant cost — LSP background-indexing and - // hover I/O — and is safe to overlap across repos: the manager hands each - // repo its own LSP provider instance, go-types stashes per repo, and every - // provider serialises its graph mutations on the backend resolve mutex. - for _, idx := range indexers { - idx.runDeferredGoMod() - } - mi.runDeferredEnrichParallel(indexers) - for _, idx := range indexers { - idx.runDeferredContracts() - } + + // Keep the receipt window exact: only go.mod materialisation, semantic + // enrichment, and contract commits are observed. The capability is optional; + // unsupported stores retain the conservative scheduled-work fallback below. + var mutationReceipt *graph.MutationReceipt + receiptStore, _ := mi.graph.(graph.MutationReceiptStore) + func() { + var token graph.MutationReceiptToken + if receiptStore != nil { + token = receiptStore.BeginMutationReceipt() + defer func() { + receipt := receiptStore.EndMutationReceipt(token) + mutationReceipt = &receipt + }() + } + + // Per-repo deferred work starts with serial go.mod materialisation. + // Semantic enrichment then runs in bounded parallel batches; after each + // batch drains, its contract passes run serially and release compiler + // state. No contract mutation overlaps enrichment. + for _, idx := range indexers { + idx.runDeferredGoMod() + } + mi.runDeferredEnrichBatches(indexers, func(batch []*Indexer) { + for _, idx := range batch { + idx.runDeferredContractsAndReleaseSemanticState() + } + }) + }() + for _, idx := range indexers { idx.SetSkipResolveInDeferred(false) } - // Re-run the master same-repo resolver to lift the placeholder edges the - // enrichment + contract passes just added. The references-completeness - // resolve already ran ahead of enrichment in RunPreEnrichResolve, so this - // is the idempotent catch-up pass for edges minted during enrichment. - // Whole-graph (nil scope): enrichment can mint placeholder edges in any - // repo, and scoping this catch-up is left to a follow-up. - mi.runMasterResolve(nil) + catchupScope = normalizeDeferredCatchupScope(catchupScope, catchupScopeKnown, len(indexers)) + mi.resolveDeferredMutations(mutationReceipt, catchupNeeded, catchupScope) return enrichScheduled } +// normalizeDeferredCatchupScope preserves the resolver's full-pass semantics +// when deferred work covered every registered repo. A non-empty scope disables +// terminal-unresolved stamping inside ResolveAll; treating an all-repo map as +// scoped would therefore leave permanently external edges hot on every future +// warmup even though the pass had complete workspace evidence. Unknown/single- +// repo prefixes are likewise conservative full passes. Only a strict subset is +// safe to retain as a scoped catch-up. +func normalizeDeferredCatchupScope(scope map[string]struct{}, known bool, repoCount int) map[string]struct{} { + if !known || (repoCount > 0 && len(scope) >= repoCount) { + return nil + } + return scope +} + +type deferredResolveMode string + +const ( + deferredResolveSkipped deferredResolveMode = "skipped" + deferredResolveExact deferredResolveMode = "exact_files" + deferredResolveFallback deferredResolveMode = "fallback_all" +) + +// resolveDeferredMutations chooses the narrowest safe catch-up resolution. +// A complete receipt is authoritative even when the old scheduled-work +// heuristic predicted mutations; an incomplete receipt always fails closed to +// a whole-graph pass. nil means the store does not support receipts yet. +func (mi *MultiIndexer) resolveDeferredMutations(receipt *graph.MutationReceipt, fallbackNeeded bool, fallbackScope map[string]struct{}) deferredResolveMode { + if receipt != nil { + if !receipt.Complete { + mi.logger.Info("DEFERRED-TIMING mutation receipt incomplete; resolving all") + mi.runMasterResolve(nil, false) + return deferredResolveFallback + } + if !receipt.ResolutionRelevant { + mi.logger.Info("DEFERRED-TIMING mutation receipt has no resolution delta", + zap.Int("changed_files", len(receipt.ChangedFiles)), + zap.Int("target_ids", len(receipt.TargetIDs))) + return deferredResolveSkipped + } + files := receipt.ResolutionFiles() + if len(files) == 0 { + // Completeness implementations should already reject this shape, but + // keep the consumer fail-closed if a future backend gets it wrong. + mi.logger.Warn("DEFERRED-TIMING resolution delta lacks exact files; resolving all") + mi.runMasterResolve(nil, false) + return deferredResolveFallback + } + mi.runMasterResolveFiles(files, false) + return deferredResolveExact + } + if !fallbackNeeded { + return deferredResolveSkipped + } + mi.runMasterResolve(fallbackScope, false) + return deferredResolveFallback +} + // runMasterResolve runs one same-repo resolver over the whole shared graph, // lifting every placeholder edge to its canonical target. Split out so the // pre-enrichment resolve stage (RunPreEnrichResolve) and the post-enrichment @@ -493,26 +574,35 @@ func (mi *MultiIndexer) RunDeferredPassesAll(ctx context.Context) int { // into one of the named changed repos (see resolver.SetScope). It is honoured // only when scoped global passes are enabled; a nil / empty scope or a // disabled switch runs the whole-graph resolve, exactly the prior behaviour. -func (mi *MultiIndexer) runMasterResolve(scope map[string]struct{}) { +func (mi *MultiIndexer) newMasterResolver(useLSP bool) *resolver.Resolver { if mi.graph == nil { - return + return nil } master := resolver.New(mi.graph) master.SetLogger(mi.logger) // The master resolve is the only pass with whole-graph evidence, so it is // the one allowed to durably flag terminally-unresolved edges (permanently // external / stdlib / definition-less) that a later scoped warm resolve can - // skip. A scoped master pass leaves the flag alone (stamping is gated on an - // empty scope inside ResolveAll); only a full pass stamps and self-heals. + // skip. A scoped/file pass leaves the flag alone; only a full ResolveAll + // stamps and self-heals terminal state. master.SetStampTerminal(true) - // Mirror the resolve-time LSP helper onto the master pass so TS/JS-family - // edges pick up LSP-precision answers just like the per-repo passes do. - if mi.resolverLSPHelper != nil { + // The pre-enrichment queryability pass uses resolver-time LSP precision. + // The post-enrichment catch-up disables it because semantic providers just + // ran and replaying synchronous definition lookups dominates cold startup. + if useLSP && mi.resolverLSPHelper != nil { master.SetLSPHelper(mi.resolverLSPHelper) } master.SetNpmAliasResolver(mi.npmAliasResolver()) master.SetPathAliasResolver(mi.pathAliasResolver()) master.SetWorkspaceMembership(mi.workspaceMembershipResolver()) + return master +} + +func (mi *MultiIndexer) runMasterResolve(scope map[string]struct{}, useLSP bool) { + master := mi.newMasterResolver(useLSP) + if master == nil { + return + } scoped := len(scope) > 0 && mi.scopedGlobalPassesEnabled() if scoped { master.SetScope(scope) @@ -522,11 +612,27 @@ func (mi *MultiIndexer) runMasterResolve(scope map[string]struct{}) { mi.logger.Info("DEFERRED-TIMING master.ResolveAll", zap.Duration("elapsed", time.Since(mt)), zap.Bool("scoped", scoped), + zap.Bool("lsp_enabled", useLSP && mi.resolverLSPHelper != nil), zap.Int("scope_repos", len(scope)), zap.Int("pending_before", stats.PendingBefore), zap.Int("pending_after", stats.PendingAfter)) } +func (mi *MultiIndexer) runMasterResolveFiles(files []string, useLSP bool) { + master := mi.newMasterResolver(useLSP) + if master == nil { + return + } + mt := time.Now() + stats := master.ResolveFilesAndIncoming(files) + mi.logger.Info("DEFERRED-TIMING master.ResolveFilesAndIncoming", + zap.Duration("elapsed", time.Since(mt)), + zap.Bool("lsp_enabled", useLSP && mi.resolverLSPHelper != nil), + zap.Int("files", len(files)), + zap.Int("pending_before", stats.PendingBefore), + zap.Int("pending_after", stats.PendingAfter)) +} + // RunPreEnrichResolve runs the resolution stage that makes references queryable // ahead of the slow semantic-enrichment pass. It materialises go.mod dep // contract nodes (so the resolver's import bridge can re-target Go imports of @@ -557,7 +663,7 @@ func (mi *MultiIndexer) RunPreEnrichResolve(ctx context.Context, scope map[strin for _, idx := range indexers { idx.runDeferredGoMod() } - mi.runMasterResolve(scope) + mi.runMasterResolve(scope, true) // Cross-repo references resolve here too so a multi-repo workspace is fully // queryable at "ready", not just within each repo. Whole-graph so inbound // references from unchanged repos into the changed repos bind before ready. @@ -570,6 +676,12 @@ func (mi *MultiIndexer) RunPreEnrichResolve(ctx context.Context, scope map[strin // repo's LSP provider in-use for the duration of its pass, so the router's // LRU evictor never closes a provider another repo is still enriching against. func (mi *MultiIndexer) runDeferredEnrichParallel(indexers []*Indexer) { + mi.runDeferredEnrichBatches(indexers, nil) +} + +// runDeferredEnrichBatches preserves the bounded parallel enrichment posture +// while exposing a serial boundary after each fully drained worker batch. +func (mi *MultiIndexer) runDeferredEnrichBatches(indexers []*Indexer, afterBatch func([]*Indexer)) { // Per-repo language sets computed once from a single graph-stats scan, // shared by the spec-grouped ordering and the batch pool-raise sizing // so the Manager's per-repo enrichment scan is not duplicated here. @@ -592,21 +704,32 @@ func (mi *MultiIndexer) runDeferredEnrichParallel(indexers []*Indexer) { if conc <= 1 { for _, idx := range indexers { idx.runDeferredEnrich() + if afterBatch != nil { + afterBatch([]*Indexer{idx}) + } } return } - sem := make(chan struct{}, conc) - var wg sync.WaitGroup - for _, idx := range indexers { - wg.Add(1) - sem <- struct{}{} - go func(idx *Indexer) { - defer func() { <-sem; wg.Done() }() - idx.runDeferredEnrich() - }(idx) + for start := 0; start < len(indexers); start += conc { + end := start + conc + if end > len(indexers) { + end = len(indexers) + } + batch := indexers[start:end] + var wg sync.WaitGroup + wg.Add(len(batch)) + for _, idx := range batch { + go func(idx *Indexer) { + defer wg.Done() + idx.runDeferredEnrich() + }(idx) + } + wg.Wait() + if afterBatch != nil { + afterBatch(batch) + } } - wg.Wait() } // maxBatchProviders caps the temporary live-provider pool raise during @@ -1599,7 +1722,9 @@ func (mi *MultiIndexer) IncrementalReindexRepo(repoPrefix string, paths []string } mi.mu.Unlock() - mi.ReconcileContractEdges() + mi.RunIncrementalDerivedPasses(context.Background(), map[string]DerivedInvalidationPlan{ + repoPrefix: result.DerivedInvalidation, + }) return result, nil } @@ -2086,6 +2211,12 @@ func firstNStrings(s []string, n int) []string { // permission bit on one repo should not starve reconciliation on the // others. func (mi *MultiIndexer) ReconcileAll() map[string]*IndexResult { + return mi.ReconcileAllCtx(context.Background()) +} + +// ReconcileAllCtx is ReconcileAll with cooperative cancellation between +// repositories and before the derived pass coordinator. +func (mi *MultiIndexer) ReconcileAllCtx(ctx context.Context) map[string]*IndexResult { mi.mu.RLock() prefixes := make([]string, 0, len(mi.indexers)) for p := range mi.indexers { @@ -2108,9 +2239,15 @@ func (mi *MultiIndexer) ReconcileAll() map[string]*IndexResult { defer mi.ResetBatch() results := make(map[string]*IndexResult, len(prefixes)) - reindexed := 0 - changedPrefixes := make(map[string]struct{}) + plans := make(map[string]DerivedInvalidationPlan) for _, prefix := range prefixes { + if ctx != nil { + select { + case <-ctx.Done(): + return results + default: + } + } mi.mu.RLock() idx, ok := mi.indexers[prefix] meta, metaOK := mi.repos[prefix] @@ -2128,8 +2265,9 @@ func (mi *MultiIndexer) ReconcileAll() map[string]*IndexResult { mi.logger.Info("janitor: reconciled repo", zap.String("prefix", prefix), zap.Int("stale_files_reindexed", result.StaleFileCount)) - reindexed++ - changedPrefixes[prefix] = struct{}{} + plan := plans[prefix] + plan.Merge(result.DerivedInvalidation) + plans[prefix] = plan } results[prefix] = result @@ -2143,19 +2281,11 @@ func (mi *MultiIndexer) ReconcileAll() map[string]*IndexResult { mi.mu.Unlock() } - if reindexed > 0 { - mi.ReconcileContractEdges() - // Only now — when at least one repo actually reindexed — is it - // worth the full-graph derivation pass. Nothing changed → skip it - // (the deferred ResetBatch still clears the batch flags). Scope the - // per-repo clone detection + Rebuild to just the repos that changed - // this cycle: an unchanged repo's clone edges are already on disk, so - // re-detecting all of them holds the resolve mutex for tens of seconds - // and stalls every concurrent interactive edit. Warmup arms the same - // scope; without this the janitor re-ran clone detection over every - // tracked repo on every cycle that touched even one file. - mi.ArmBatchScope(changedPrefixes) - mi.RunGlobalGraphPasses(context.Background()) + if len(plans) > 0 { + // Run only the derived families selected by each repo's exact file + // deltas. Body-only and metadata-only changes therefore avoid global + // derivation, while structural changes retain conservative fallbacks. + mi.RunIncrementalDerivedPasses(ctx, plans) } return results } diff --git a/internal/indexer/multi_deferred_scope_test.go b/internal/indexer/multi_deferred_scope_test.go new file mode 100644 index 000000000..5ad713fdb --- /dev/null +++ b/internal/indexer/multi_deferred_scope_test.go @@ -0,0 +1,26 @@ +package indexer + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNormalizeDeferredCatchupScope(t *testing.T) { + t.Run("all repos restores full pass terminal stamping", func(t *testing.T) { + scope := map[string]struct{}{"alpha": {}, "beta": {}, "gamma": {}} + assert.Nil(t, normalizeDeferredCatchupScope(scope, true, 3)) + }) + + t.Run("strict subset remains scoped", func(t *testing.T) { + scope := map[string]struct{}{"beta": {}} + got := normalizeDeferredCatchupScope(scope, true, 3) + assert.NotNil(t, got) + assert.Equal(t, scope, got) + }) + + t.Run("unknown prefix restores full pass", func(t *testing.T) { + scope := map[string]struct{}{"known": {}} + assert.Nil(t, normalizeDeferredCatchupScope(scope, false, 3)) + }) +} diff --git a/internal/indexer/multi_watcher.go b/internal/indexer/multi_watcher.go index 9a9c2e78b..7a99841eb 100644 --- a/internal/indexer/multi_watcher.go +++ b/internal/indexer/multi_watcher.go @@ -1,6 +1,7 @@ package indexer import ( + "context" "fmt" "os" "sort" @@ -361,6 +362,34 @@ func (mw *MultiWatcher) OnDegraded(cb func(reason string)) { } } +// EnqueueFileMutation routes a committed file mutation to the active watcher +// that owns the path. Routing is path-scoped: an unrelated degraded watcher +// cannot force a synchronous fallback, and a watcher that failed to start is +// never reported as accepting work. +func (mw *MultiWatcher) EnqueueFileMutation(ctx context.Context, filePath string) (*MutationTicket, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + prefix := mw.multi.RepoForFile(filePath) + mw.mu.Lock() + w := mw.watchers[prefix] + started := mw.started[prefix] + // A single-repo MultiIndexer may intentionally use the empty prefix; + // when RepoForFile cannot distinguish it from "not covered", let the + // per-repo watcher perform the authoritative containment check. + if w == nil && prefix == "" && len(mw.watchers) == 1 { + for candidatePrefix, candidate := range mw.watchers { + w = candidate + started = mw.started[candidatePrefix] + } + } + mw.mu.Unlock() + if w == nil || !started { + return nil, nil + } + return w.EnqueueFileMutation(ctx, filePath) +} + // DegradedReason returns the first non-empty per-repo degraded reason, prefixed // with the repo it came from, or "" when every watcher is healthy. Lets the // daemon-mode freshness rider surface a whole-index "frozen" banner the same diff --git a/internal/indexer/multi_watcher_enqueue_test.go b/internal/indexer/multi_watcher_enqueue_test.go new file mode 100644 index 000000000..78dac60be --- /dev/null +++ b/internal/indexer/multi_watcher_enqueue_test.go @@ -0,0 +1,65 @@ +package indexer + +import ( + "context" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestMultiWatcherEnqueueFileMutationIgnoresUnrelatedDegradation(t *testing.T) { + rootA, _, watcherA := inertTestWatcher(t, "a.go", "package a\n\nfunc A() {}\n") + rootB, _, watcherB := inertTestWatcher(t, "b.go", "package b\n\nfunc B() {}\n") + + watcherA.degradedMu.Lock() + watcherA.degradedReason = "overflow" + watcherA.degradedMu.Unlock() + + multi := &MultiIndexer{repos: map[string]*RepoMetadata{ + "a": {RepoPrefix: "a", RootPath: rootA}, + "b": {RepoPrefix: "b", RootPath: rootB}, + }} + watcher := &MultiWatcher{ + watchers: map[string]*Watcher{"a": watcherA, "b": watcherB}, + started: map[string]bool{"a": true, "b": true}, + multi: multi, + } + + require.NotEmpty(t, watcher.DegradedReason()) + ticket, err := watcher.EnqueueFileMutation(context.Background(), filepath.Join(rootB, "b.go")) + require.NoError(t, err) + require.NotNil(t, ticket) + select { + case result := <-ticket.Done: + require.NoError(t, result.Err) + require.True(t, result.Reindexed) + require.Equal(t, ticket.Generation, result.RequestedGeneration) + require.GreaterOrEqual(t, result.AppliedGeneration, ticket.Generation) + case <-time.After(2 * time.Second): + t.Fatalf("mutation ticket generation %d did not complete", ticket.Generation) + } + require.Len(t, watcherB.History(), 1) + require.Empty(t, watcherA.History()) +} + +func TestMultiWatcherEnqueueFileMutationRejectsUnstartedOwner(t *testing.T) { + root, _, repoWatcher := inertTestWatcher(t, "main.go", "package main\n\nfunc main() {}\n") + multi := &MultiIndexer{repos: map[string]*RepoMetadata{ + "repo": {RepoPrefix: "repo", RootPath: root}, + }} + watcher := &MultiWatcher{ + watchers: map[string]*Watcher{"repo": repoWatcher}, + started: map[string]bool{"repo": false}, + multi: multi, + } + + ticket, err := watcher.EnqueueFileMutation(context.Background(), filepath.Join(root, "main.go")) + require.NoError(t, err) + require.Nil(t, ticket) + repoWatcher.mu.Lock() + pending := len(repoWatcher.pending) + repoWatcher.mu.Unlock() + require.Zero(t, pending) +} diff --git a/internal/indexer/rust_reexport_identity_test.go b/internal/indexer/rust_reexport_identity_test.go new file mode 100644 index 000000000..36d58426a --- /dev/null +++ b/internal/indexer/rust_reexport_identity_test.go @@ -0,0 +1,387 @@ +package indexer + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/graph" +) + +func TestParseRustUseFactsRetainsIdentity(t *testing.T) { + src := ` +pub(crate) use crate::{engine::Renderer as Draw, shapes::Circle}; +use self::local::Helper as LocalHelper; +pub use super::shared::*; +use serde::Serialize; +` + facts := parseRustUseFacts(src, "crate/src/api/mod.rs") + require.Len(t, facts, 4, "external serde path must be omitted") + + byLocal := map[string]rustUseFact{} + for _, fact := range facts { + key := fact.localName + if fact.glob { + key = "*" + } + byLocal[key] = fact + } + require.Equal(t, rustUseFact{ + fromFile: "crate/src/engine.rs", sourceModule: "crate::engine", + sourceName: "Renderer", localName: "Draw", visibility: "pub(crate)", + }, byLocal["Draw"]) + require.Equal(t, rustUseFact{ + fromFile: "crate/src/shapes.rs", sourceModule: "crate::shapes", + sourceName: "Circle", localName: "Circle", visibility: "pub(crate)", + }, byLocal["Circle"]) + require.Equal(t, rustUseFact{ + fromFile: "crate/src/api/local.rs", sourceModule: "self::local", + sourceName: "Helper", localName: "LocalHelper", + }, byLocal["LocalHelper"]) + require.Equal(t, rustUseFact{ + fromFile: "crate/src/shared.rs", sourceModule: "super::shared", + visibility: "pub", glob: true, + }, byLocal["*"]) +} + +func TestResolveRustModulePathIsExplicitAndModuleAware(t *testing.T) { + const srcFile = "crate/src/api/v1.rs" + require.Equal(t, "crate/src/api/v1/models.rs", resolveRustModulePath("self::models", srcFile)) + require.Equal(t, "crate/src/api/models.rs", resolveRustModulePath("super::models", srcFile)) + require.Equal(t, "crate/src/models.rs", resolveRustModulePath("super::super::models", srcFile)) + require.Equal(t, "crate/src/models.rs", resolveRustModulePath("crate::models", srcFile)) + require.Empty(t, resolveRustModulePath("serde::Serialize", srcFile)) + require.Empty(t, resolveRustModulePath("super::models", "crate/src/lib.rs")) +} + +func TestResolveBareTypeViaImportsRustAliasChain(t *testing.T) { + g := graph.New() + g.AddBatch([]*graph.Node{ + {ID: "crate/src/model.rs::Original", Kind: graph.KindType, Name: "Original", FilePath: "crate/src/model.rs"}, + {ID: "crate/src/legacy.rs::Original", Kind: graph.KindType, Name: "Original", FilePath: "crate/src/legacy.rs"}, + }, nil) + mi := &MultiIndexer{} + srcCache := map[string][]byte{ + "crate/src/main.rs": []byte(`use crate::api::Public as Local;`), + "crate/src/api.rs": []byte(`pub use crate::model::Original as Public;`), + "crate/src/model.rs": []byte(`pub struct Original;`), + } + got := mi.resolveBareTypeViaImports( + "crate/src/main.rs", "Local", g, srcCache, map[string]map[string]string{}, + ) + require.Equal(t, "crate/src/model.rs::Original", got) +} + +func TestResolveBareTypeViaImportsRustAmbiguityAndExternalStayUnresolved(t *testing.T) { + tests := []struct { + name string + consumer string + files map[string][]byte + }{ + { + name: "duplicate public alias", + consumer: `use crate::api::Public;`, + files: map[string][]byte{ + "crate/src/api.rs": []byte(` +pub use crate::a::Original as Public; +pub use crate::b::Original as Public; +`), + }, + }, + { + name: "external import", + consumer: `use serde::Original;`, + files: map[string][]byte{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := graph.New() + g.AddBatch([]*graph.Node{ + {ID: "crate/src/a.rs::Original", Kind: graph.KindType, Name: "Original", FilePath: "crate/src/a.rs"}, + {ID: "crate/src/b.rs::Original", Kind: graph.KindType, Name: "Original", FilePath: "crate/src/b.rs"}, + }, nil) + srcCache := map[string][]byte{"crate/src/main.rs": []byte(tt.consumer)} + for path, src := range tt.files { + srcCache[path] = src + } + got := (&MultiIndexer{}).resolveBareTypeViaImports( + "crate/src/main.rs", "Public", g, srcCache, map[string]map[string]string{}, + ) + if tt.name == "external import" { + got = (&MultiIndexer{}).resolveBareTypeViaImports( + "crate/src/main.rs", "Original", g, srcCache, map[string]map[string]string{}, + ) + } + require.Empty(t, got) + }) + } +} + +func TestFollowRustReExportChainRejectsCycleAndDepthOverflow(t *testing.T) { + mi := &MultiIndexer{} + cycle := map[string][]byte{ + "crate/src/a.rs": []byte(`pub use crate::b::X;`), + "crate/src/b.rs": []byte(`pub use crate::a::X;`), + } + _, unsafe := mi.followReExportChainChecked("crate/src/a.rs", "X", cycle) + require.True(t, unsafe, "a re-export cycle must be non-resolvable") + + deep := map[string][]byte{} + for i := 0; i <= maxReExportDepth; i++ { + deep[fmt.Sprintf("crate/src/m%d.rs", i)] = []byte(fmt.Sprintf("pub use crate::m%d::X;", i+1)) + } + _, unsafe = mi.followReExportChainChecked("crate/src/m0.rs", "X", deep) + require.True(t, unsafe, "a chain beyond maxReExportDepth must be non-resolvable") +} + +func TestResolveRustModulePathUsesCurrentModuleForEmptyTail(t *testing.T) { + require.Equal(t, "crate/src/lib.rs", resolveRustModulePath("crate", "crate/src/lib.rs")) + require.Equal(t, "crate/src/main.rs", resolveRustModulePath("crate", "crate/src/main.rs")) + logicalRoot := resolveRustModulePath("crate", "crate/src/api/mod.rs") + require.Equal(t, rustLogicalCrateRoot("crate/src"), logicalRoot) + require.ElementsMatch(t, []string{"crate/src/lib.rs", "crate/src/main.rs"}, rustFileCandidates(logicalRoot)) + require.Equal(t, "crate/src/lib.rs", resolveRustModulePath("self", "crate/src/lib.rs")) + require.Equal(t, "crate/src/main.rs", resolveRustModulePath("self", "crate/src/main.rs")) + require.Equal(t, "crate/src/api/mod.rs", resolveRustModulePath("self", "crate/src/api/mod.rs")) + require.Equal(t, "crate/src/api.rs", resolveRustModulePath("self", "crate/src/api.rs")) +} + +func TestResolveBareTypeViaImportsRustRootAndSelfChains(t *testing.T) { + g := graph.New() + g.AddBatch([]*graph.Node{ + {ID: "crate/src/model.rs::Original", Kind: graph.KindType, Name: "Original", FilePath: "crate/src/model.rs"}, + }, nil) + + tests := []struct { + name string + srcFile string + srcCache map[string][]byte + }{ + { + name: "crate root library", + srcFile: "crate/src/consumer.rs", + srcCache: map[string][]byte{ + "crate/src/consumer.rs": []byte(`use crate::Public as Local;`), + "crate/src/lib.rs": []byte(`pub use crate::model::Original as Public;`), + }, + }, + { + name: "crate root binary", + srcFile: "crate/src/main.rs", + srcCache: map[string][]byte{ + "crate/src/main.rs": []byte(` +use crate::Public as Local; +pub use crate::model::Original as Public; +`), + }, + }, + { + name: "nested module in binary-only crate", + srcFile: "crate/src/api/mod.rs", + srcCache: map[string][]byte{ + "crate/src/api/mod.rs": []byte(`use crate::Public as Local;`), + "crate/src/main.rs": []byte(`pub use crate::model::Original as Public;`), + }, + }, + { + name: "current mod file", + srcFile: "crate/src/api/mod.rs", + srcCache: map[string][]byte{ + "crate/src/api/mod.rs": []byte(` +use self::Public as Local; +pub use crate::model::Original as Public; +`), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.srcCache["crate/src/model.rs"] = []byte(`pub struct Original;`) + got := (&MultiIndexer{}).resolveBareTypeViaImports( + tt.srcFile, "Local", g, tt.srcCache, map[string]map[string]string{}, + ) + require.Equal(t, "crate/src/model.rs::Original", got) + }) + } +} + +func TestParseRustUseFactsMasksCommentsAndStrings(t *testing.T) { + src := ` +// use crate::fake::Line; +/* use crate::fake::Block; + /* pub use crate::fake::Nested; */ +*/ +const NORMAL: &str = "use crate::fake::Normal;"; +const BYTE: &[u8] = b"pub use crate::fake::Byte;"; +const RAW: &str = r#"use crate::fake::Raw;"#; +const RAW_BYTE: &[u8] = br##"pub use crate::fake::RawByte;"##; +use crate::real::Actual; +` + facts := parseRustUseFacts(src, "crate/src/lib.rs") + require.Equal(t, []rustUseFact{{ + fromFile: "crate/src/real.rs", sourceModule: "crate::real", + sourceName: "Actual", localName: "Actual", + }}, facts) +} + +func TestResolveBareTypeViaImportsRustDuplicateAliasInOneSourceIsUnsafe(t *testing.T) { + g := graph.New() + g.AddBatch([]*graph.Node{ + {ID: "crate/src/same.rs::A", Kind: graph.KindType, Name: "A", FilePath: "crate/src/same.rs"}, + {ID: "crate/src/same.rs::B", Kind: graph.KindType, Name: "B", FilePath: "crate/src/same.rs"}, + }, nil) + srcCache := map[string][]byte{ + "crate/src/main.rs": []byte(`use crate::api::Public;`), + "crate/src/api.rs": []byte(`pub use crate::same::{A as Public, B as Public};`), + "crate/src/same.rs": []byte(`pub struct A; pub struct B;`), + } + + edges := parseRustReExports(string(srcCache["crate/src/api.rs"]), "crate/src/api.rs") + require.Len(t, edges, 1) + require.True(t, edges[0].ambiguousName["Public"]) + require.NotContains(t, edges[0].names, "Public") + require.Empty(t, (&MultiIndexer{}).resolveBareTypeViaImports( + "crate/src/main.rs", "Public", g, srcCache, map[string]map[string]string{}, + )) +} + +func TestResolveBareTypeViaImportsRustMultipleGlobsUseExactGraphMatch(t *testing.T) { + tests := []struct { + name string + nodes []*graph.Node + want string + }{ + { + name: "unique", + nodes: []*graph.Node{ + {ID: "crate/src/a.rs::Foo", Kind: graph.KindType, Name: "Foo", FilePath: "crate/src/a.rs"}, + {ID: "crate/src/b.rs::Bar", Kind: graph.KindType, Name: "Bar", FilePath: "crate/src/b.rs"}, + }, + want: "crate/src/a.rs::Foo", + }, + { + name: "ambiguous", + nodes: []*graph.Node{ + {ID: "crate/src/a.rs::Foo", Kind: graph.KindType, Name: "Foo", FilePath: "crate/src/a.rs"}, + {ID: "crate/src/b.rs::Foo", Kind: graph.KindType, Name: "Foo", FilePath: "crate/src/b.rs"}, + }, + }, + {name: "zero"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := graph.New() + if len(tt.nodes) > 0 { + g.AddBatch(tt.nodes, nil) + } + srcCache := map[string][]byte{ + "crate/src/main.rs": []byte(`use crate::prelude::Foo;`), + "crate/src/prelude.rs": []byte(`pub use crate::a::*; pub use crate::b::*;`), + } + got := (&MultiIndexer{}).resolveBareTypeViaImports( + "crate/src/main.rs", "Foo", g, srcCache, map[string]map[string]string{}, + ) + require.Equal(t, tt.want, got) + }) + } +} + +func TestResolveBareTypeViaImportsRustDirectMultipleGlobs(t *testing.T) { + g := graph.New() + g.AddBatch([]*graph.Node{ + {ID: "crate/src/b.rs::Foo", Kind: graph.KindType, Name: "Foo", FilePath: "crate/src/b.rs"}, + }, nil) + srcCache := map[string][]byte{ + "crate/src/main.rs": []byte(`use crate::a::*; use crate::b::*;`), + } + require.Equal(t, "crate/src/b.rs::Foo", (&MultiIndexer{}).resolveBareTypeViaImports( + "crate/src/main.rs", "Foo", g, srcCache, map[string]map[string]string{}, + )) +} + +func TestResolveRustModulePathTopLevelSuperUsesLogicalRoot(t *testing.T) { + logicalRoot := resolveRustModulePath("super", "crate/src/api/mod.rs") + require.Equal(t, rustLogicalCrateRoot("crate/src"), logicalRoot) + require.ElementsMatch(t, []string{"crate/src/lib.rs", "crate/src/main.rs"}, rustFileCandidates(logicalRoot)) +} + +func TestResolveBareTypeViaImportsRustTopLevelSuperChains(t *testing.T) { + g := graph.New() + g.AddBatch([]*graph.Node{ + {ID: "crate/src/model.rs::Original", Kind: graph.KindType, Name: "Original", FilePath: "crate/src/model.rs"}, + }, nil) + tests := []struct { + name string + srcFile string + srcCache map[string][]byte + }{ + { + name: "private use from top-level mod", + srcFile: "crate/src/api/mod.rs", + srcCache: map[string][]byte{ + "crate/src/api/mod.rs": []byte(`use super::Public as Local;`), + "crate/src/main.rs": []byte(`pub use crate::model::Original as Public;`), + }, + }, + { + name: "public use from top-level mod", + srcFile: "crate/src/consumer.rs", + srcCache: map[string][]byte{ + "crate/src/consumer.rs": []byte(`use crate::api::Public as Local;`), + "crate/src/api/mod.rs": []byte(`pub use super::Public;`), + "crate/src/main.rs": []byte(`pub use crate::model::Original as Public;`), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.srcCache["crate/src/model.rs"] = []byte(`pub struct Original;`) + got := (&MultiIndexer{}).resolveBareTypeViaImports( + tt.srcFile, "Local", g, tt.srcCache, map[string]map[string]string{}, + ) + require.Equal(t, "crate/src/model.rs::Original", got) + }) + } +} + +func TestResolveBareTypeViaImportsRustLogicalRootRequiresUniqueGraphMatch(t *testing.T) { + tests := []struct { + name string + nodes []*graph.Node + want string + }{ + { + name: "main only", + nodes: []*graph.Node{ + {ID: "crate/src/main.rs::Public", Kind: graph.KindType, Name: "Public", FilePath: "crate/src/main.rs"}, + }, + want: "crate/src/main.rs::Public", + }, + { + name: "lib and main ambiguous", + nodes: []*graph.Node{ + {ID: "crate/src/lib.rs::Public", Kind: graph.KindType, Name: "Public", FilePath: "crate/src/lib.rs"}, + {ID: "crate/src/main.rs::Public", Kind: graph.KindType, Name: "Public", FilePath: "crate/src/main.rs"}, + }, + }, + {name: "zero"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := graph.New() + if len(tt.nodes) > 0 { + g.AddBatch(tt.nodes, nil) + } + srcCache := map[string][]byte{ + "crate/src/api/mod.rs": []byte(`use crate::Public as Local;`), + "crate/src/lib.rs": []byte(`pub struct Public;`), + "crate/src/main.rs": []byte(`pub struct Public;`), + } + got := (&MultiIndexer{}).resolveBareTypeViaImports( + "crate/src/api/mod.rs", "Local", g, srcCache, map[string]map[string]string{}, + ) + require.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/indexer/test_edges.go b/internal/indexer/test_edges.go index e10d5812c..027b05ffe 100644 --- a/internal/indexer/test_edges.go +++ b/internal/indexer/test_edges.go @@ -58,19 +58,43 @@ func markTestSymbolsAndEmitEdgesScoped(g graph.Store, changedPrefixes map[string g.ResolveMutex().Lock() defer g.ResolveMutex().Unlock() - testNodes, markedTests := markTestSymbolsLocked(g) + testNodes, markedTests, changedNodes := markTestSymbolsLocked(g) if len(testNodes) == 0 { + // Test-file metadata still needs persistence even when the file has no + // function or method symbols. There are no test edges to batch with in + // this case, so persist just the changed nodes while ResolveMutex is held. + g.AddBatch(changedNodes, nil) return markedTests, 0 } - edgesEmitted = emitTestEdgesLocked(g, testNodes, changedPrefixes) + edgesEmitted = emitTestEdgesAndPersistLocked(g, testNodes, changedNodes, changedPrefixes) return markedTests, edgesEmitted } // markTestSymbolsLocked runs Pass 1: it stamps test Meta on every test symbol -// and returns the complete test-node membership set plus the marked count. The -// caller must hold g.ResolveMutex(). Always whole-graph — see the scoped -// entry point for why the set must be complete. -func markTestSymbolsLocked(g graph.Store) (testNodes map[string]bool, markedTests int) { +// and returns the complete test-node membership set, marked count, and every +// node whose test metadata changed. The caller persists changedNodes through +// the same AddBatch as the derived test edges. The caller must hold +// g.ResolveMutex(). Always whole-graph — see the scoped entry point for why the +// set must be complete. +func markTestSymbolsLocked(g graph.Store) (testNodes map[string]bool, markedTests int, changedNodes []*graph.Node) { + setMeta := func(n *graph.Node, key string, value any) bool { + if n.Meta == nil { + n.Meta = map[string]any{} + } + switch want := value.(type) { + case bool: + if got, ok := n.Meta[key].(bool); ok && got == want { + return false + } + case string: + if got, ok := n.Meta[key].(string); ok && got == want { + return false + } + } + n.Meta[key] = value + return true + } + // Pass 1: classify file nodes, then function/method nodes. Build // a local testNodes set keyed by node id so Pass 2 can probe it // without re-walking the Meta. (Node.Meta mutations on returned @@ -84,14 +108,14 @@ func markTestSymbolsLocked(g graph.Store) (testNodes map[string]bool, markedTest } if IsTestFile(n.FilePath) { testFiles[n.ID] = true - if n.Meta == nil { - n.Meta = map[string]any{} - } - n.Meta["is_test_file"] = true + changed := setMeta(n, "is_test_file", true) if runner := detectTestRunnerForFile(g, n); runner != "" { - n.Meta["test_runner"] = runner + changed = setMeta(n, "test_runner", runner) || changed fileRunners[n.FilePath] = runner } + if changed { + changedNodes = append(changedNodes, n) + } } } @@ -145,13 +169,13 @@ func markTestSymbolsLocked(g graph.Store) (testNodes map[string]bool, markedTest default: return } - if n.Meta == nil { - n.Meta = map[string]any{} - } - n.Meta["is_test"] = true - n.Meta["test_role"] = role + changed := setMeta(n, "is_test", true) + changed = setMeta(n, "test_role", role) || changed if runner != "" { - n.Meta["test_runner"] = runner + changed = setMeta(n, "test_runner", runner) || changed + } + if changed { + changedNodes = append(changedNodes, n) } testNodes[n.ID] = true markedTests++ @@ -174,7 +198,7 @@ func markTestSymbolsLocked(g graph.Store) (testNodes map[string]bool, markedTest stampTestSymbol(n) } } - return testNodes, markedTests + return testNodes, markedTests, changedNodes } // emitTestEdgesLocked runs Pass 2: for each (test, non-test) call it emits a @@ -189,7 +213,12 @@ func markTestSymbolsLocked(g graph.Store) (testNodes map[string]bool, markedTest // testNodes[e.To] test→test skip stays correct across repos because testNodes is // complete (Pass 1 is whole-graph). func emitTestEdgesLocked(g graph.Store, testNodes map[string]bool, changedPrefixes map[string]bool) int { - edgesEmitted := 0 + return emitTestEdgesAndPersistLocked(g, testNodes, nil, changedPrefixes) +} + +// emitTestEdgesAndPersistLocked additionally persists changedNodes in the same +// AddBatch as the derived edges so disk-backed stores retain the classification. +func emitTestEdgesAndPersistLocked(g graph.Store, testNodes map[string]bool, changedNodes []*graph.Node, changedPrefixes map[string]bool) int { seen := map[string]bool{} type pending struct { from, to, file string @@ -227,8 +256,9 @@ func emitTestEdgesLocked(g graph.Store, testNodes map[string]bool, changedPrefix } } } + edges := make([]*graph.Edge, 0, len(out)) for _, p := range out { - g.AddEdge(&graph.Edge{ + edges = append(edges, &graph.Edge{ From: p.from, To: p.to, Kind: graph.EdgeTests, @@ -236,9 +266,9 @@ func emitTestEdgesLocked(g graph.Store, testNodes map[string]bool, changedPrefix Line: p.line, Origin: graph.OriginASTInferred, }) - edgesEmitted++ } - return edgesEmitted + g.AddBatch(changedNodes, edges) + return len(edges) } // detectTestRunnerForFile resolves the runner identifier for a test file diff --git a/internal/indexer/test_edges_test.go b/internal/indexer/test_edges_test.go index 3718c9639..c1edcb514 100644 --- a/internal/indexer/test_edges_test.go +++ b/internal/indexer/test_edges_test.go @@ -1,9 +1,11 @@ package indexer import ( + "path/filepath" "testing" "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/graph/store_sqlite" ) func TestMarkTestSymbolsAndEmitEdges_GoStyle(t *testing.T) { @@ -273,3 +275,77 @@ func TestMarkTestSymbolsAndEmitEdges_DedupesParallelCalls(t *testing.T) { t.Fatalf("expected 1 deduped EdgeTests, got %d", emitted) } } + +func TestMarkTestSymbolsAndEmitEdges_PersistsMetadataAcrossSQLiteReload(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "store.sqlite") + g, err := store_sqlite.Open(dbPath) + if err != nil { + t.Fatalf("open sqlite store: %v", err) + } + + g.AddBatch([]*graph.Node{ + {ID: "pkg/foo_test.go", Kind: graph.KindFile, Name: "pkg/foo_test.go", FilePath: "pkg/foo_test.go", Language: "go"}, + {ID: "pkg/foo.go", Kind: graph.KindFile, Name: "pkg/foo.go", FilePath: "pkg/foo.go", Language: "go"}, + {ID: "pkg/foo_test.go::TestFoo", Kind: graph.KindFunction, Name: "TestFoo", FilePath: "pkg/foo_test.go", Language: "go"}, + {ID: "pkg/foo_test.go::Suite.TestBar", Kind: graph.KindMethod, Name: "TestBar", FilePath: "pkg/foo_test.go", Language: "go"}, + {ID: "pkg/foo.go::Foo", Kind: graph.KindFunction, Name: "Foo", FilePath: "pkg/foo.go", Language: "go"}, + }, []*graph.Edge{ + {From: "pkg/foo_test.go::TestFoo", To: "pkg/foo.go::Foo", Kind: graph.EdgeCalls, FilePath: "pkg/foo_test.go", Line: 10}, + {From: "pkg/foo_test.go::Suite.TestBar", To: "pkg/foo.go::Foo", Kind: graph.EdgeCalls, FilePath: "pkg/foo_test.go", Line: 20}, + }) + + marked, emitted := markTestSymbolsAndEmitEdges(g) + if marked != 2 || emitted != 2 { + t.Fatalf("mark/emit counts = %d/%d, want 2/2", marked, emitted) + } + markedAgain, emittedAgain := markTestSymbolsAndEmitEdges(g) + if markedAgain != 2 || emittedAgain != 2 { + t.Fatalf("repeat mark/emit counts = %d/%d, want 2/2", markedAgain, emittedAgain) + } + if err := g.Close(); err != nil { + t.Fatalf("close sqlite store: %v", err) + } + + reloaded, err := store_sqlite.Open(dbPath) + if err != nil { + t.Fatalf("reopen sqlite store: %v", err) + } + defer func() { _ = reloaded.Close() }() + + file := reloaded.GetNode("pkg/foo_test.go") + if file == nil { + t.Fatal("test file missing after reload") + } + if got, _ := file.Meta["is_test_file"].(bool); !got { + t.Fatalf("test file is_test_file = %v, want true", file.Meta["is_test_file"]) + } + if got, _ := file.Meta["test_runner"].(string); got != "gotest" { + t.Fatalf("test file test_runner = %q, want gotest", got) + } + + for _, id := range []string{"pkg/foo_test.go::TestFoo", "pkg/foo_test.go::Suite.TestBar"} { + n := reloaded.GetNode(id) + if n == nil { + t.Fatalf("test symbol %s missing after reload", id) + } + if got, _ := n.Meta["is_test"].(bool); !got { + t.Errorf("%s is_test = %v, want true", id, n.Meta["is_test"]) + } + if got, _ := n.Meta["test_role"].(string); got != "test" { + t.Errorf("%s test_role = %q, want test", id, got) + } + if got, _ := n.Meta["test_runner"].(string); got != "gotest" { + t.Errorf("%s test_runner = %q, want gotest", id, got) + } + } + + var persistedEdges int + for edge := range reloaded.EdgesByKind(graph.EdgeTests) { + if edge != nil && edge.To == "pkg/foo.go::Foo" { + persistedEdges++ + } + } + if persistedEdges != 2 { + t.Fatalf("persisted EdgeTests = %d, want 2", persistedEdges) + } +} diff --git a/internal/indexer/watcher.go b/internal/indexer/watcher.go index 1a1037626..8fcfb8722 100644 --- a/internal/indexer/watcher.go +++ b/internal/indexer/watcher.go @@ -7,7 +7,6 @@ import ( "os" "path/filepath" "runtime" - "sort" "strings" "sync" "syscall" @@ -35,14 +34,31 @@ const ( // GraphChangeEvent is emitted after a successful graph patch. type GraphChangeEvent struct { - FilePath string `json:"file_path"` - Kind ChangeKind `json:"kind"` - NodesAdded int `json:"nodes_added"` - NodesRemoved int `json:"nodes_removed"` - EdgesAdded int `json:"edges_added"` - EdgesRemoved int `json:"edges_removed"` - Timestamp time.Time `json:"timestamp"` - DurationMs int64 `json:"duration_ms"` + FilePath string `json:"file_path"` + Kind ChangeKind `json:"kind"` + Classification string `json:"classification,omitempty"` + NodesAdded int `json:"nodes_added"` + NodesRemoved int `json:"nodes_removed"` + EdgesAdded int `json:"edges_added"` + EdgesRemoved int `json:"edges_removed"` + Timestamp time.Time `json:"timestamp"` + DurationMs int64 `json:"duration_ms"` +} + +// MutationTicket identifies one admitted file mutation and resolves when the +// graph has applied this generation or a newer coalesced generation. +type MutationTicket struct { + Path string + Generation uint64 + Done <-chan MutationResult +} + +// MutationResult is the terminal graph-freshness result for a ticket. +type MutationResult struct { + RequestedGeneration uint64 + AppliedGeneration uint64 + Reindexed bool + Err error } // SymbolChangeCallback is called when symbols change during file re-indexing. @@ -64,6 +80,9 @@ type Watcher struct { history []GraphChangeEvent historyMu sync.Mutex pending map[string]*time.Timer + pendingGeneration map[string]uint64 + mutationWaiters map[string]map[uint64]chan MutationResult + nextGeneration uint64 mu sync.Mutex // patchMu serialises per-path patchGraph invocations so the // post-patch reach rebuild (which scans every Node.Meta) cannot @@ -146,6 +165,11 @@ type Watcher struct { const maxHistory = 1000 +var ( + errMutationSuperseded = errors.New("mutation generation superseded") + errMutationPatchAborted = errors.New("mutation patch aborted") +) + // probeMarker is the substring embedded in handshake-probe filenames // (see confirmWatchActive) and used by handleEvent to absorb their // create/remove events without touching the indexer. @@ -184,15 +208,16 @@ func NewWatcher(idx *Indexer, cfg config.WatchConfig, logger *zap.Logger) (*Watc } return &Watcher{ - indexer: idx, - config: cfg, - excludes: excludes.New(patterns), - events: make(chan GraphChangeEvent, 64), - pending: make(map[string]*time.Timer), - stormBatch: make(map[string]ChangeKind), - logger: logger, - done: make(chan struct{}), - stopped: make(chan struct{}), + indexer: idx, + config: cfg, + excludes: excludes.New(patterns), + events: make(chan GraphChangeEvent, 64), + pending: make(map[string]*time.Timer), + pendingGeneration: make(map[string]uint64), + stormBatch: make(map[string]ChangeKind), + logger: logger, + done: make(chan struct{}), + stopped: make(chan struct{}), }, nil } @@ -423,6 +448,14 @@ func (w *Watcher) Stop() error { w.poller.Stop() } close(w.done) + w.mu.Lock() + for _, timer := range w.pending { + timer.Stop() + } + w.pending = make(map[string]*time.Timer) + w.pendingGeneration = make(map[string]uint64) + w.mu.Unlock() + w.failMutationWaiters(errors.New("watcher stopped before mutation completed")) if w.fsCancel != nil { w.fsCancel() } @@ -684,8 +717,8 @@ func (w *Watcher) enqueueDirScan(dir string) { }() } -// runDirScan re-indexes the accumulated new directories. A large burst -// escalates to one full-tree reconcile (dirScanEscalateCap); otherwise +// runDirScan discovers files in the accumulated new directories. A large burst +// escalates to one full-tree additive discovery (dirScanEscalateCap); otherwise // the scoped subtrees are walked in a single IncrementalReindexPaths // call, which IsStale-gates each file so already-current files cost only // a stat. fn is the test seam. @@ -696,11 +729,11 @@ func (w *Watcher) runDirScan(dirs map[string]struct{}, fn func(map[string]struct } if len(dirs) > dirScanEscalateCap { if w.logger != nil { - w.logger.Info("watcher: large new-directory burst — full-tree reconcile", + w.logger.Info("watcher: large new-directory burst — full-tree discovery", zap.Int("dirs", len(dirs)), zap.String("root", w.indexer.rootPath)) } - if _, err := w.indexer.IncrementalReindex(w.indexer.rootPath); err != nil && w.logger != nil { - w.logger.Warn("watcher: new-directory reconcile failed", zap.Error(err)) + if _, err := w.indexer.incrementalDiscoverPaths(w.indexer.rootPath, nil); err != nil && w.logger != nil { + w.logger.Warn("watcher: new-directory discovery failed", zap.Error(err)) } return } @@ -708,7 +741,7 @@ func (w *Watcher) runDirScan(dirs map[string]struct{}, fn func(map[string]struct for d := range dirs { paths = append(paths, d) } - if _, err := w.indexer.IncrementalReindexPaths(w.indexer.rootPath, paths); err != nil && w.logger != nil { + if _, err := w.indexer.incrementalDiscoverPaths(w.indexer.rootPath, paths); err != nil && w.logger != nil { w.logger.Warn("watcher: new-directory scan failed", zap.Strings("dirs", paths), zap.Error(err)) } @@ -724,6 +757,29 @@ func hasEventType(types []fswatcher.EventType, want fswatcher.EventType) bool { return false } +func isGortexAtomicTemp(path string) bool { + // filepath.Base only recognizes the host separator. Watcher tests and + // forwarded events can contain either slash, so split both explicitly. + if idx := strings.LastIndexAny(path, `/\\`); idx >= 0 { + path = path[idx+1:] + } + const marker = ".gortex.tmp-" + idx := strings.LastIndex(path, marker) + if idx < 0 { + return false + } + suffix := path[idx+len(marker):] + if suffix == "" { + return false + } + for _, ch := range suffix { + if ch < '0' || ch > '9' { + return false + } + } + return true +} + func (w *Watcher) handleEvent(event fswatcher.WatchEvent) { // Kernel queue overflow arrives as a pathless EventOverflow on the // Events channel: the Linux inotify and Windows backends emit it when @@ -741,6 +797,12 @@ func (w *Watcher) handleEvent(event fswatcher.WatchEvent) { } path := normalizeEventPath(event.Path, w.indexer.rootPath) + // Guarded edits use atomic temp files in the watched directory. They are + // implementation artifacts, not source changes; indexing them duplicates + // the subsequent target-file patch and can monopolize the serialized queue. + if isGortexAtomicTemp(path) { + return + } // Probe artifacts: sentinel files Start writes to confirm the // OS-level watch is actually active. Their create event signals @@ -808,24 +870,176 @@ func (w *Watcher) handleEvent(event fswatcher.WatchEvent) { return } - // Debounce: reset or start timer for this file. + w.scheduleFileMutation(path, kind) +} + +// EnqueueFileMutation hands a committed filesystem mutation directly to the +// watcher's debounced generation queue. It does not depend on fsnotify delivery, +// so daemon-backed MCP edits remain reliable even when native watch delivery is +// degraded. The request context governs admission only; the returned ticket +// resolves when this generation or a newer coalesced generation reaches a +// terminal graph state. +func (w *Watcher) EnqueueFileMutation(ctx context.Context, filePath string) (*MutationTicket, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + absPath, err := filepath.Abs(filePath) + if err != nil { + return nil, err + } + root := w.indexer.RootPath() + if root == "" { + return nil, nil + } + rel, err := filepath.Rel(root, absPath) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return nil, nil + } + if w.isExcluded(absPath) { + return nil, nil + } + if _, ok := w.indexer.effectiveLanguage(absPath, nil); !ok { + return nil, nil + } + select { + case <-w.done: + return nil, errors.New("watcher is stopped") + default: + } + return w.scheduleFileMutation(absPath, ChangeModified), nil +} + +// scheduleFileMutation is the single admission point for native events and +// direct daemon mutations. A later admission supersedes every queued callback +// for the same path; every earlier ticket stays attached until the newest patch +// produces a terminal graph event. +func (w *Watcher) scheduleFileMutation(path string, kind ChangeKind) *MutationTicket { w.mu.Lock() if timer, exists := w.pending[path]; exists { timer.Stop() } + w.nextGeneration++ + generation := w.nextGeneration + if w.pendingGeneration == nil { + w.pendingGeneration = make(map[string]uint64) + } + if w.mutationWaiters == nil { + w.mutationWaiters = make(map[string]map[uint64]chan MutationResult) + } + if w.mutationWaiters[path] == nil { + w.mutationWaiters[path] = make(map[uint64]chan MutationResult) + } + done := make(chan MutationResult, 1) + w.mutationWaiters[path][generation] = done + w.pendingGeneration[path] = generation debounce := time.Duration(w.config.DebounceMs) * time.Millisecond - w.pending[path] = time.AfterFunc(debounce, func() { - // Clean up the pending entry even if the patch panics, then - // recover so a fatal store error can't crash the daemon. + var timer *time.Timer + timer = time.AfterFunc(debounce, func() { + // Timer.Stop can lose a race with a callback that is already queued. + // Only the newest timer may consume the pending entry. + if !w.claimPendingTimer(path, &timer) { + return + } + before := w.mutationEventCount(path) + patchErr := errMutationPatchAborted + superseded := false + defer w.guardWatcherPanic("patch " + path) defer func() { - w.mu.Lock() - delete(w.pending, path) - w.mu.Unlock() + if !superseded { + w.completeMutationWaiters(path, generation, patchErr) + } }() - defer w.guardWatcherPanic("patch " + path) - w.patchGraph(path, kind) + w.patchGraph(path, kind, generation) + if w.mutationGenerationSuperseded(path, generation) { + patchErr = errMutationSuperseded + superseded = true + return + } + if w.mutationEventCount(path) == before { + patchErr = errors.New("mutation patch completed without a graph event") + return + } + patchErr = nil }) + w.pending[path] = timer w.mu.Unlock() + return &MutationTicket{Path: path, Generation: generation, Done: done} +} + +func (w *Watcher) completeMutationWaiters(path string, appliedGeneration uint64, err error) { + type completion struct { + requested uint64 + done chan MutationResult + } + w.mu.Lock() + var completions []completion + for generation, done := range w.mutationWaiters[path] { + if generation <= appliedGeneration { + completions = append(completions, completion{requested: generation, done: done}) + delete(w.mutationWaiters[path], generation) + } + } + if len(w.mutationWaiters[path]) == 0 { + delete(w.mutationWaiters, path) + } + w.mu.Unlock() + + for _, completion := range completions { + completion.done <- MutationResult{ + RequestedGeneration: completion.requested, + AppliedGeneration: appliedGeneration, + Reindexed: err == nil, + Err: err, + } + close(completion.done) + } +} + +func (w *Watcher) failMutationWaiters(err error) { + w.mu.Lock() + waiters := w.mutationWaiters + w.mutationWaiters = nil + w.mu.Unlock() + for _, byGeneration := range waiters { + for generation, done := range byGeneration { + done <- MutationResult{RequestedGeneration: generation, Err: err} + close(done) + } + } +} + +func (w *Watcher) mutationEventCount(path string) int { + w.historyMu.Lock() + defer w.historyMu.Unlock() + count := 0 + for _, event := range w.history { + if event.FilePath == path { + count++ + } + } + return count +} + +func (w *Watcher) mutationGenerationSuperseded(path string, generation uint64) bool { + w.mu.Lock() + defer w.mu.Unlock() + current, pending := w.pendingGeneration[path] + return pending && current != generation +} + +// claimPendingTimer atomically consumes timer only when it is still the +// newest debounce timer for path. time.Timer.Stop may return false after a +// callback has been queued; without this identity check that stale callback +// can delete a newer timer's pending entry and let later saves fan out into +// redundant concurrent callbacks. +func (w *Watcher) claimPendingTimer(path string, timerRef **time.Timer) bool { + w.mu.Lock() + defer w.mu.Unlock() + if w.pending[path] != *timerRef { + return false + } + delete(w.pending, path) + return true } // shouldEnterStorm records the current event in the rate window and @@ -909,11 +1123,22 @@ func (w *Watcher) drainStorm() { } start := time.Now() w.logger.Info("watcher: storm drain starting", zap.Int("paths", len(batch))) + finishTopologyMutation := reach.BeginTopologyMutation(w.indexer.graph) + mutationFinished := false + defer func() { + if !mutationFinished { + // The batch is non-empty and may have partially applied before a + // panic. Invalidate conservatively before unblocking readers. + finishTopologyMutation(true) + } + }() for path, kind := range batch { w.patchGraphNoResolve(path, kind) } w.indexer.ResolveAll() + finishTopologyMutation(true) + mutationFinished = true w.logger.Info("watcher: storm drain complete", zap.Int("paths", len(batch)), @@ -994,9 +1219,40 @@ func (w *Watcher) forgetDeletedFileMtime(relPath string) { w.indexer.pruneDeletedFileMtimes([]string{relPath}) } -func (w *Watcher) patchGraph(path string, kind ChangeKind) { +func (w *Watcher) generationCurrent(path string, generation uint64) bool { + if generation == 0 { + return true + } + w.mu.Lock() + defer w.mu.Unlock() + return w.pendingGeneration[path] == generation +} + +func (w *Watcher) finishGeneration(path string, generation uint64) { + if generation == 0 { + return + } + w.mu.Lock() + if w.pendingGeneration[path] == generation { + delete(w.pendingGeneration, path) + } + w.mu.Unlock() +} + +func (w *Watcher) patchGraph(path string, kind ChangeKind, generations ...uint64) { + var generation uint64 + if len(generations) > 0 { + generation = generations[0] + } + if !w.generationCurrent(path, generation) { + return + } + defer w.finishGeneration(path, generation) w.patchMu.Lock() defer w.patchMu.Unlock() + if !w.generationCurrent(path, generation) { + return + } // A replace/revert (rename-over or unlink+recreate) reaches us as a // delete/rename even though a file is right back at the same path; // reconcile against disk so it takes the parse-then-swap + incoming- @@ -1004,7 +1260,32 @@ func (w *Watcher) patchGraph(path string, kind ChangeKind) { // the definition's callers. A vanished create/modify becomes a delete. kind = w.reconcileKindWithDisk(path, kind) start := time.Now() + classification := "structural" var nodesAdded, nodesRemoved, edgesAdded, edgesRemoved int + var finishTopologyMutation func(bool) + topologyChanged := false + // Keep a panic/error-safe release so no lookup can remain parked behind the + // reach topology gate if an incremental path exits early. + defer func() { + if finishTopologyMutation != nil { + finishTopologyMutation(topologyChanged) + } + }() + beginTopologyMutation := func() { + finishTopologyMutation = reach.BeginTopologyMutation(w.indexer.graph) + // Conservatively invalidate after every structural reindex attempt. Most + // parse failures leave the old graph untouched, but treating that as a + // cache miss is safer than trusting count-based telemetry to prove no + // resolver edge was retargeted. + topologyChanged = true + } + endTopologyMutation := func() { + if finishTopologyMutation == nil { + return + } + finishTopologyMutation(topologyChanged) + finishTopologyMutation = nil + } // Two keys for this file. relPath (RelKey: slash form + NFC) is the // mtime-forget / change-callback key. graphKey (graphRelKey: @@ -1024,6 +1305,7 @@ func (w *Watcher) patchGraph(path string, kind ChangeKind) { switch kind { case ChangeCreated: + beginTopologyMutation() if err := w.indexer.IndexFile(path); err != nil { w.logger.Warn("index file failed", zap.String("path", path), zap.Error(err)) return @@ -1031,6 +1313,7 @@ func (w *Watcher) patchGraph(path string, kind ChangeKind) { newSymbols := w.indexer.graph.GetFileNodes(graphKey) nodesAdded = len(newSymbols) edgesAdded = w.countFileEdges(newSymbols) + endTopologyMutation() // Notify callback: no old symbols, only new symbols. w.symbolChangeCbMu.RLock() @@ -1041,39 +1324,84 @@ func (w *Watcher) patchGraph(path string, kind ChangeKind) { } case ChangeModified: - // Snapshot old symbols before eviction. - oldSymbols := w.snapshotSymbols(graphKey) - - // Content-aware skip: if the saved file's structural symbols - // are byte-for-byte identical to the ones already in the - // graph, the change touched no Function / Type / Method / - // etc. — a comment-only edit, a whitespace reflow, a doc - // change, or a config / JSON value save. Re-indexing it would - // evict and rebuild every node for no graph-level effect, so - // skip the structural reindex entirely. The probe is - // read-only; only on a proven match do we take the cheap - // path. A probe that can't run (unknown language, over-cap - // file, parser quarantine) returns ok == false and falls - // through to the normal reindex. - if newSymbols, ok := w.indexer.StructuralSymbols(path); ok && - structuralFingerprint(newSymbols) == structuralFingerprint(oldSymbols) { - w.recordInertModify(path, relPath, oldSymbols, start) + // Read the prior file state once. It supplies the callback snapshot, + // gross change telemetry and the durable raw-extraction fingerprint; + // repeating GetFileNodes here is particularly costly when SQLite is + // under analysis load. + snapshotStarted := time.Now() + priorNodes := w.indexer.graph.GetFileNodes(graphKey) + oldSymbols := make([]*graph.Node, 0, len(priorNodes)) + for _, n := range priorNodes { + if n == nil || n.Kind == graph.KindFile || n.Kind == graph.KindImport { + continue + } + cp := &graph.Node{ID: n.ID, Kind: n.Kind, Name: n.Name, QualName: n.QualName, FilePath: n.FilePath} + if sig, ok := n.Meta["signature"]; ok { + cp.Meta = map[string]any{"signature": sig} + } + oldSymbols = append(oldSymbols, cp) + } + snapshotDuration := time.Since(snapshotStarted) + + // Parse once and compare the complete post-coverage extraction, not + // only declarations. A changed call target, doc, TODO, location, + // metadata field or edge changes this fingerprint and must patch the + // graph. Only a byte-for-byte-equivalent graph output is inert. The + // prepared extraction is consumed by IndexFile below, avoiding the old + // double parse on structural edits. + probe, probeOK := w.indexer.prepareFileDelta(path) + if !w.generationCurrent(path, generation) { + w.indexer.discardPreparedExtraction(path) return } + stored := storedExtractionGraphFingerprints(priorNodes) + classification := "structural" + if probeOK && stored.semantic != "" { + switch { + case probe.fingerprints.semantic == stored.semantic && probe.fingerprints.metadata == stored.metadata: + w.indexer.discardPreparedExtraction(path) + w.logger.Info("watcher: inert delta phases", + zap.String("file", path), zap.String("delta_class", "inert"), + zap.Duration("snapshot", snapshotDuration), zap.Duration("read", probe.read), + zap.Duration("extract", probe.extract), zap.Duration("coverage", probe.coverage), + zap.Duration("fingerprint", probe.fingerprintTime)) + w.recordInertModify(path, relPath, oldSymbols, start) + return + case probe.fingerprints.semantic == stored.semantic: + var refreshed []*graph.Node + var ok bool + if generation == 0 { + refreshed, ok = w.indexer.applyPreparedMetadataRefresh(path, priorNodes) + } else { + // Serialise generation validation with the bounded commit. A newer + // event cannot register between the byte check and fingerprint/mtime write. + w.mu.Lock() + if w.pendingGeneration[path] == generation { + refreshed, ok = w.indexer.applyPreparedMetadataRefresh(path, priorNodes) + } + w.mu.Unlock() + } + if ok { + w.logger.Info("watcher: metadata-only delta phases", + zap.String("file", path), zap.String("delta_class", "metadata_only"), + zap.Duration("snapshot", snapshotDuration), zap.Duration("read", probe.read), + zap.Duration("extract", probe.extract), zap.Duration("coverage", probe.coverage), + zap.Duration("fingerprint", probe.fingerprintTime)) + w.recordMetadataModify(path, relPath, oldSymbols, refreshed, start) + return + } + case stored.core != "" && probe.fingerprints.core == stored.core: + classification = "artifact_only" + } + } - // Do NOT pre-evict. IndexFile parse-then-swaps internally: it - // evicts the file's prior nodes and re-adds the new ones only on a - // successful parse, and leaves the prior nodes intact on a parse - // failure. Pre-evicting here was the node-loss bug — a transiently - // unparseable save (mid-edit) dropped the file's symbols from the - // graph until the next clean save. Capture the file's prior node - // count first (still present pre-swap) so removed/added telemetry - // stays gross: a rename removes one node and adds one even though - // the net node delta is zero. - priorNodes := w.indexer.graph.GetFileNodes(graphKey) + // Do NOT pre-evict. IndexFile parse-then-swaps internally and consumes + // the prepared extraction when its transformed bytes still match. + reindexStarted := time.Now() fileEdgesBefore := w.countFileEdges(priorNodes) resolvedBefore := w.countResolvedFileEdges(priorNodes) incomingBeforeByID := w.resolvedIncomingByNode(priorNodes) + beginTopologyMutation() if err := w.indexer.IndexFile(path); err != nil { w.logger.Warn("reindex file failed", zap.String("path", path), zap.Error(err)) return @@ -1097,6 +1425,18 @@ func (w *Watcher) patchGraph(path string, kind ChangeKind) { // self-heals instead of persisting the degraded shape. incomingBefore, incomingAfter := w.incomingRegressionForSurvivors(incomingBeforeByID, newSymbols) w.guardResolvedEdgeRegression(path, len(priorNodes), len(newSymbols), resolvedBefore, w.countResolvedFileEdges(newSymbols), incomingBefore, incomingAfter) + endTopologyMutation() + w.logger.Info("watcher: structural delta phases", + zap.String("file", path), + zap.String("delta_class", classification), + zap.Duration("snapshot", snapshotDuration), + zap.Duration("read", probe.read), + zap.Duration("extract", probe.extract), + zap.Duration("coverage", probe.coverage), + zap.Duration("fingerprint", probe.fingerprintTime), + zap.Duration("reindex", time.Since(reindexStarted)), + zap.Bool("probe_complete", probeOK), + zap.Bool("clone_pending", w.indexer.CloneIndexPending())) // Notify callback with old and new symbols. w.symbolChangeCbMu.RLock() @@ -1110,9 +1450,12 @@ func (w *Watcher) patchGraph(path string, kind ChangeKind) { // Snapshot old symbols before eviction. oldSymbols := w.snapshotSymbols(graphKey) + beginTopologyMutation() nr, er := w.indexer.EvictFile(path) nodesRemoved = nr edgesRemoved = er + topologyChanged = nr > 0 || er > 0 + endTopologyMutation() // The file is genuinely gone from disk here — reconcileKindWithDisk // already downgraded a replace/revert (path still present) to @@ -1120,6 +1463,16 @@ func (w *Watcher) patchGraph(path string, kind ChangeKind) { // not re-discover the vanished path as a phantom deletion. relPath is // the canonical relKey EvictFile evicted under. w.forgetDeletedFileMtime(relPath) + // fsnotify and the adaptive poller are deliberately redundant. They + // may both report the same deletion, but only the producer that + // actually removed graph topology represents a semantic change. Do + // not publish a second empty callback/history/event: consumers would + // otherwise lose the pre-delete symbol snapshot or repeat downstream + // invalidation work. EvictFile's zero delta is authoritative even for + // source files that contain no symbols of their own. + if nr == 0 && er == 0 { + return + } // Notify callback: old symbols removed, no new symbols. w.symbolChangeCbMu.RLock() @@ -1131,29 +1484,22 @@ func (w *Watcher) patchGraph(path string, kind ChangeKind) { } ev := GraphChangeEvent{ - FilePath: path, - Kind: kind, - NodesAdded: nodesAdded, - NodesRemoved: nodesRemoved, - EdgesAdded: edgesAdded, - EdgesRemoved: edgesRemoved, - Timestamp: time.Now(), - DurationMs: time.Since(start).Milliseconds(), - } - - // Rebuild the reachability index so AnalyzeImpact / - // explain_change_impact stay correct against the patched topology. - // Lazy reach: instead of eagerly recomputing every seed's reach - // after a watcher-driven patch (the old reach.BuildIndex call - // here paid the full O(seeds) cost on every file edit), we just - // invalidate the build counter so subsequent AnalyzeImpact calls - // recompute on demand against the fresh graph. No-op patches - // (nodesAdded == 0 && nodesRemoved == 0 && edgesAdded == 0 && - // edgesRemoved == 0) leave the counter alone so existing caches - // stay valid. - if nodesAdded+nodesRemoved+edgesAdded+edgesRemoved > 0 { - reach.InvalidateIndex() - } + FilePath: path, + Kind: kind, + Classification: classification, + NodesAdded: nodesAdded, + NodesRemoved: nodesRemoved, + EdgesAdded: edgesAdded, + EdgesRemoved: edgesRemoved, + Timestamp: time.Now(), + DurationMs: time.Since(start).Milliseconds(), + } + + // Reach invalidation is published by endTopologyMutation before any + // callbacks or events can observe the new graph. The topology gate spans + // IndexFile's parse-then-swap and nested resolver work without taking the + // resolver's non-reentrant mutex twice; impact readers therefore see the + // complete old graph or the complete new graph, never the eviction gap. w.historyMu.Lock() w.history = append(w.history, ev) @@ -1404,40 +1750,46 @@ func (w *Watcher) enqueueReresolve(path string) { // The reachability index is intentionally not rebuilt — the topology // did not change, so the existing reach stamps stay valid. func (w *Watcher) recordInertModify(path, relPath string, symbols []*graph.Node, start time.Time) { - // Advance the recorded mtime past this save so the poller does - // not treat the (untouched) file as perpetually stale. + // Advance the recorded mtime past this save so the poller does not keep + // reporting it. Metadata refresh records its mtime only after all stores. w.indexer.RefreshFileMtime(path) + w.recordNonStructuralModify(path, relPath, symbols, symbols, "inert", start) +} - ev := GraphChangeEvent{ - FilePath: path, - Kind: ChangeModified, - Timestamp: time.Now(), - DurationMs: time.Since(start).Milliseconds(), +func (w *Watcher) recordMetadataModify(path, relPath string, oldSymbols, refreshed []*graph.Node, start time.Time) { + newSymbols := make([]*graph.Node, 0, len(refreshed)) + for _, node := range refreshed { + if node == nil || node.Kind == graph.KindFile || node.Kind == graph.KindImport { + continue + } + newSymbols = append(newSymbols, node) } + w.recordNonStructuralModify(path, relPath, oldSymbols, newSymbols, "metadata_only", start) +} +func (w *Watcher) recordNonStructuralModify(path, relPath string, oldSymbols, newSymbols []*graph.Node, classification string, start time.Time) { + ev := GraphChangeEvent{ + FilePath: path, Kind: ChangeModified, Classification: classification, + Timestamp: time.Now(), DurationMs: time.Since(start).Milliseconds(), + } w.historyMu.Lock() w.history = append(w.history, ev) if len(w.history) > maxHistory { w.history = w.history[len(w.history)-maxHistory:] } w.historyMu.Unlock() - select { case w.events <- ev: default: } - w.symbolChangeCbMu.RLock() cb := w.symbolChangeCb w.symbolChangeCbMu.RUnlock() if cb != nil { - cb(relPath, symbols, symbols) + cb(relPath, oldSymbols, newSymbols) } - - w.logger.Info("graph patch skipped: structurally inert change", - zap.String("file", path), - zap.Int64("ms", ev.DurationMs), - ) + w.logger.Info("graph patch: non-structural change", + zap.String("file", path), zap.String("delta_class", classification), zap.Int64("ms", ev.DurationMs)) } // snapshotSymbols returns a deep copy of the symbols for a file, preserving @@ -1465,34 +1817,6 @@ func (w *Watcher) snapshotSymbols(graphKey string) []*graph.Node { return snapshot } -// structuralFingerprint reduces a set of symbols to an order-independent -// string identity built only from each structural symbol's kind, name, -// qualified name, and signature — never its line range. Two snapshots -// of the same file taken before and after an edit produce an equal -// fingerprint exactly when the edit changed no structural symbol: a -// comment-only change, a whitespace reflow, or a doc / config-value -// edit shifts line numbers but leaves every (kind, name, sig) tuple -// intact, while renaming a function, changing a signature, or -// adding / removing a declaration changes the fingerprint. -// -// Non-structural nodes (file, import, params, closures, coverage-domain -// kinds) are skipped so a change confined to them is still treated as -// inert — they carry no structural graph shape. -func structuralFingerprint(symbols []*graph.Node) string { - lines := make([]string, 0, len(symbols)) - for _, n := range symbols { - if !isStructuralKind(n.Kind) { - continue - } - sig, _ := n.Meta["signature"].(string) - // NUL separates fields so a value containing the field - // delimiter can't forge a collision across two symbols. - lines = append(lines, string(n.Kind)+"\x00"+n.Name+"\x00"+n.QualName+"\x00"+sig) - } - sort.Strings(lines) - return strings.Join(lines, "\n") -} - // normalizeEventPath aligns an event path emitted by the OS-level // backend with the form the indexer stored when it walked the tree. // diff --git a/internal/indexer/watcher_debounce_test.go b/internal/indexer/watcher_debounce_test.go new file mode 100644 index 000000000..7e4a17617 --- /dev/null +++ b/internal/indexer/watcher_debounce_test.go @@ -0,0 +1,30 @@ +package indexer + +import ( + "testing" + "time" +) + +func TestWatcherClaimPendingTimerRejectsSupersededCallback(t *testing.T) { + stale := time.NewTimer(time.Hour) + current := time.NewTimer(time.Hour) + t.Cleanup(func() { + stale.Stop() + current.Stop() + }) + + w := &Watcher{pending: map[string]*time.Timer{"result.json": current}} + + if w.claimPendingTimer("result.json", &stale) { + t.Fatal("superseded timer claimed the pending edit") + } + if got := w.pending["result.json"]; got != current { + t.Fatal("superseded timer removed the current pending edit") + } + if !w.claimPendingTimer("result.json", ¤t) { + t.Fatal("current timer did not claim the pending edit") + } + if _, ok := w.pending["result.json"]; ok { + t.Fatal("claimed timer remained pending") + } +} diff --git a/internal/indexer/watcher_enqueue_test.go b/internal/indexer/watcher_enqueue_test.go new file mode 100644 index 000000000..3e7706da3 --- /dev/null +++ b/internal/indexer/watcher_enqueue_test.go @@ -0,0 +1,81 @@ +package indexer + +import ( + "context" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestWatcherEnqueueFileMutationLatestGenerationWins(t *testing.T) { + dir, _, watcher := inertTestWatcher(t, "main.go", "package main\n\nfunc value() int { return 0 }\n") + path := filepath.Join(dir, "main.go") + + // Hold the patch lane so all three debounce callbacks can become runnable. + // Only the latest generation may cross the second generation check after the + // lane is released. + watcher.patchMu.Lock() + locked := true + defer func() { + if locked { + watcher.patchMu.Unlock() + } + }() + + tickets := make([]*MutationTicket, 0, 3) + for i := 1; i <= 3; i++ { + writeTestFile(t, path, "package main\n\nfunc value() int { return "+string(rune('0'+i))+" }\n") + ticket, err := watcher.EnqueueFileMutation(context.Background(), path) + require.NoError(t, err) + require.NotNil(t, ticket) + tickets = append(tickets, ticket) + time.Sleep(25 * time.Millisecond) + } + + watcher.patchMu.Unlock() + locked = false + + var appliedGeneration uint64 + for _, ticket := range tickets { + select { + case result := <-ticket.Done: + require.NoError(t, result.Err) + require.True(t, result.Reindexed) + require.Equal(t, ticket.Generation, result.RequestedGeneration) + require.GreaterOrEqual(t, result.AppliedGeneration, ticket.Generation) + if appliedGeneration == 0 { + appliedGeneration = result.AppliedGeneration + } else { + require.Equal(t, appliedGeneration, result.AppliedGeneration) + } + case <-time.After(2 * time.Second): + t.Fatalf("mutation ticket generation %d did not complete", ticket.Generation) + } + } + require.Equal(t, tickets[len(tickets)-1].Generation, appliedGeneration) + + // Give stale callbacks time to acquire the lane and reject themselves. + time.Sleep(50 * time.Millisecond) + history := watcher.History() + require.Len(t, history, 1) + require.Equal(t, ChangeModified, history[0].Kind) +} + +func TestWatcherEnqueueFileMutationCancelledAdmissionLeavesNoWork(t *testing.T) { + dir, _, watcher := inertTestWatcher(t, "main.go", "package main\n\nfunc value() int { return 0 }\n") + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + ticket, err := watcher.EnqueueFileMutation(ctx, filepath.Join(dir, "main.go")) + require.ErrorIs(t, err, context.Canceled) + require.Nil(t, ticket) + + watcher.mu.Lock() + pending := len(watcher.pending) + generation := watcher.nextGeneration + watcher.mu.Unlock() + require.Zero(t, pending) + require.Zero(t, generation) +} diff --git a/internal/indexer/watcher_inert_test.go b/internal/indexer/watcher_inert_test.go index c33423243..16387b27c 100644 --- a/internal/indexer/watcher_inert_test.go +++ b/internal/indexer/watcher_inert_test.go @@ -16,9 +16,9 @@ import ( "github.com/zzet/gortex/internal/search" ) -// inertTestWatcher builds an indexed repo with a single Go file and a -// Watcher wired to it, ready to drive patchGraph directly. The search -// backend is set because the modify path evicts from and adds to it. +// inertTestWatcher builds a steady-state indexed repo. The explicit IndexFile +// stamps the tiered fingerprints; separate coverage below proves an old DB's +// first patch remains conservative. func inertTestWatcher(t *testing.T, fileName, content string) (string, *Indexer, *Watcher) { t.Helper() dir := t.TempDir() @@ -31,39 +31,21 @@ func inertTestWatcher(t *testing.T, fileName, content string) (string, *Indexer, idx.SetRootPath(dir) _, err := idx.Index(dir) require.NoError(t, err) + require.NoError(t, idx.IndexFile(path)) w, err := NewWatcher(idx, config.WatchConfig{Enabled: true, DebounceMs: 10}, zap.NewNop()) require.NoError(t, err) return dir, idx, w } -// TestWatcher_CommentOnlyChangeSkipsReindex is the central proof of -// the content-aware skip: a save that adds only comment lines — which -// shifts every following line number but changes no Function / Type / -// Method — must not trigger a structural reindex. The proof is node -// pointer identity: a reindex evicts and rebuilds the file's nodes, -// minting fresh structs, so if the live node pointer is unchanged -// after the save, no eviction happened. func TestWatcher_CommentOnlyChangeSkipsReindex(t *testing.T) { dir, idx, w := inertTestWatcher(t, "main.go", `package main func Stable() {} `) path := filepath.Join(dir, "main.go") + before := nodeByName(t, idx, "main.go", "Stable") - nodes := idx.graph.GetFileNodes("main.go") - var funcID string - for _, n := range nodes { - if n.Kind == graph.KindFunction && n.Name == "Stable" { - funcID = n.ID - } - } - require.NotEmpty(t, funcID, "Stable must be indexed before the edit") - before := idx.graph.GetNode(funcID) - require.NotNil(t, before) - - // Comment-only edit: the function and its signature are untouched, - // but every line below the new comments shifts down. writeTestFile(t, path, `package main // Stable does nothing at all. @@ -72,26 +54,21 @@ func Stable() {} `) w.patchGraph(path, ChangeModified) - after := idx.graph.GetNode(funcID) - require.NotNil(t, after, "the function node must still exist") - assert.Same(t, before, after, - "a comment-only change must not evict and rebuild the node — "+ - "the structural reindex was supposed to be skipped") + after := nodeByName(t, idx, "main.go", "Stable") + assert.Equal(t, 3, before.StartLine) + assert.Equal(t, 5, after.StartLine, "metadata refresh must update shifted source spans") + assert.Equal(t, 5, after.EndLine) + assert.Contains(t, after.Meta["doc"], "brand new") } -// TestWatcher_WhitespaceChangeSkipsReindex proves a pure whitespace -// reflow — blank lines, indentation — is treated as structurally -// inert. Like the comment case, the node pointer must survive. func TestWatcher_WhitespaceChangeSkipsReindex(t *testing.T) { dir, idx, w := inertTestWatcher(t, "main.go", `package main func KeepMe() {} `) path := filepath.Join(dir, "main.go") - before := nodeByName(t, idx, "main.go", "KeepMe") - // Whitespace-only edit: extra blank lines, no structural change. writeTestFile(t, path, `package main @@ -103,20 +80,16 @@ func KeepMe() {} w.patchGraph(path, ChangeModified) after := nodeByName(t, idx, "main.go", "KeepMe") - assert.Same(t, before, after, - "a whitespace-only change must skip the structural reindex") + assert.Equal(t, 3, before.StartLine) + assert.Equal(t, 5, after.StartLine) } -// TestWatcher_InertChangeEmitsZeroDeltaEvent checks the skip path -// still reports the save through the events channel and history, but -// with every node/edge delta zero — nothing structural moved. func TestWatcher_InertChangeEmitsZeroDeltaEvent(t *testing.T) { dir, _, w := inertTestWatcher(t, "main.go", `package main func Same() {} `) path := filepath.Join(dir, "main.go") - writeTestFile(t, path, `package main // freshly added doc line @@ -127,23 +100,19 @@ func Same() {} select { case ev := <-w.Events(): assert.Equal(t, ChangeModified, ev.Kind) - assert.Zero(t, ev.NodesAdded, "an inert skip adds no nodes") - assert.Zero(t, ev.NodesRemoved, "an inert skip removes no nodes") - assert.Zero(t, ev.EdgesAdded, "an inert skip adds no edges") - assert.Zero(t, ev.EdgesRemoved, "an inert skip removes no edges") + assert.Equal(t, "metadata_only", ev.Classification) + assert.Zero(t, ev.NodesAdded) + assert.Zero(t, ev.NodesRemoved) + assert.Zero(t, ev.EdgesAdded) + assert.Zero(t, ev.EdgesRemoved) default: - t.Fatal("an inert modify must still publish a change event") + t.Fatal("metadata-only modify must publish a change event") } - history := w.History() require.Len(t, history, 1) - assert.Equal(t, ChangeModified, history[0].Kind) + assert.Equal(t, "metadata_only", history[0].Classification) } -// TestWatcher_InertChangeFiresCallbackWithUnchangedSymbols verifies -// the symbol-change callback still fires on a skipped save, with the -// before and after symbol sets identical — consumers see a coherent -// no-op rather than no callback at all. func TestWatcher_InertChangeFiresCallbackWithUnchangedSymbols(t *testing.T) { dir, _, w := inertTestWatcher(t, "main.go", `package main @@ -175,17 +144,11 @@ func Untouched() {} mu.Lock() defer mu.Unlock() - require.Equal(t, 1, calls, "the callback must fire once on an inert save") - assert.Equal(t, gotOld, gotNew, - "an inert save must report identical before/after symbol sets") + require.Equal(t, 1, calls) + assert.Equal(t, gotOld, gotNew) assert.Contains(t, gotOld, "Untouched") } -// TestWatcher_StructuralChangeStillReindexes is the other half of the -// contract: a save that DOES change a structural symbol must reindex -// normally. Renaming the function changes the fingerprint, so the -// skip must not engage — the old symbol is evicted and the new one -// indexed. func TestWatcher_StructuralChangeStillReindexes(t *testing.T) { dir, idx, w := inertTestWatcher(t, "main.go", `package main @@ -200,31 +163,23 @@ func NewName() {} `) w.patchGraph(path, ChangeModified) - assert.Empty(t, idx.graph.FindNodesByName("OldName"), - "a renamed function must be evicted — the reindex must run") - assert.NotEmpty(t, idx.graph.FindNodesByName("NewName"), - "the renamed function must be indexed under its new name") - + assert.Empty(t, idx.graph.FindNodesByName("OldName")) + assert.NotEmpty(t, idx.graph.FindNodesByName("NewName")) select { case ev := <-w.Events(): - assert.Equal(t, ChangeModified, ev.Kind) - assert.NotZero(t, ev.NodesRemoved, - "a structural change must report evicted nodes") + assert.Equal(t, "structural", ev.Classification) + assert.NotZero(t, ev.NodesRemoved) default: t.Fatal("a structural modify must publish a change event") } } -// TestWatcher_AddedSymbolStillReindexes covers the additive case: -// declaring a new function alongside the existing one changes the -// fingerprint (a new tuple appears), so the reindex must run. func TestWatcher_AddedSymbolStillReindexes(t *testing.T) { dir, idx, w := inertTestWatcher(t, "main.go", `package main func First() {} `) path := filepath.Join(dir, "main.go") - writeTestFile(t, path, `package main func First() {} @@ -232,16 +187,10 @@ func First() {} func Second() {} `) w.patchGraph(path, ChangeModified) - assert.NotEmpty(t, idx.graph.FindNodesByName("First")) - assert.NotEmpty(t, idx.graph.FindNodesByName("Second"), - "adding a function must trigger a reindex so the new symbol appears") + assert.NotEmpty(t, idx.graph.FindNodesByName("Second")) } -// TestWatcher_SignatureChangeStillReindexes proves the fingerprint is -// signature-sensitive, not just name-sensitive: changing a function's -// parameter list keeps its name but changes its signature, and that -// must still reindex. func TestWatcher_SignatureChangeStillReindexes(t *testing.T) { dir, idx, w := inertTestWatcher(t, "main.go", `package main @@ -249,33 +198,24 @@ func Compute(a int) {} `) path := filepath.Join(dir, "main.go") funcBefore := nodeByName(t, idx, "main.go", "Compute") - - // Same name, different signature — a structural change. writeTestFile(t, path, `package main func Compute(a int, b string) {} `) w.patchGraph(path, ChangeModified) - funcAfter := nodeByName(t, idx, "main.go", "Compute") - assert.NotSame(t, funcBefore, funcAfter, - "a signature change must evict and rebuild the node — reindex must run") + assert.NotSame(t, funcBefore, funcAfter) } -// TestWatcher_InertSkipRefreshesMtime checks the skip path advances -// the indexer's recorded mtime past the save, so the adaptive poller -// does not keep re-flagging the structurally-untouched file as stale. func TestWatcher_InertSkipRefreshesMtime(t *testing.T) { dir, idx, w := inertTestWatcher(t, "main.go", `package main func Steady() {} `) path := filepath.Join(dir, "main.go") - mtimeBefore := idx.FileMtimes()["main.go"] require.NotZero(t, mtimeBefore) - // Save the file with a strictly later mtime, comment-only. future := time.Now().Add(2 * time.Second) writeTestFile(t, path, `package main @@ -284,14 +224,73 @@ func Steady() {} `) require.NoError(t, os.Chtimes(path, future, future)) w.patchGraph(path, ChangeModified) + assert.Greater(t, idx.FileMtimes()["main.go"], mtimeBefore) +} + +func TestWatcher_MetadataOnlyRefreshUpdatesEdgeLineAndTarget(t *testing.T) { + dir, idx, w := inertTestWatcher(t, "main.go", `package main + +func Target() {} +func Caller() { Target() } +`) + path := filepath.Join(dir, "main.go") + caller := nodeByName(t, idx, "main.go", "Caller") + before := callEdge(t, idx, caller.ID) + beforeLine, beforeTarget := before.Line, before.To + + writeTestFile(t, path, `package main + +func Target() {} + +// Caller invokes Target. +func Caller() { Target() } +`) + w.patchGraph(path, ChangeModified) + + after := callEdge(t, idx, caller.ID) + assert.Greater(t, after.Line, beforeLine) + assert.Equal(t, beforeTarget, after.To, "metadata refresh must preserve resolver-selected target") + select { + case ev := <-w.Events(): + assert.Equal(t, "metadata_only", ev.Classification) + assert.Zero(t, ev.NodesAdded) + assert.Zero(t, ev.EdgesAdded) + default: + t.Fatal("metadata-only refresh must emit an event") + } +} + +func TestWatcher_OldDatabaseFirstPatchIsConservative(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "main.go") + writeTestFile(t, path, "package main\n\nfunc Stable() {}\n") + idx := newTestIndexer(graph.New()) + idx.search = search.NewBM25() + idx.SetRootPath(dir) + _, err := idx.Index(dir) + require.NoError(t, err) + // Cold indexing deliberately skips edit-routing fingerprints. The first + // patch must therefore fail closed to a structural file refresh; that patch + // stamps the exact extraction so subsequent edits can take narrower routes. + coldNodes := idx.graph.GetFileNodes("main.go") + assert.Equal(t, fileDeltaFingerprints{}, storedExtractionGraphFingerprints(coldNodes)) + assert.False(t, storedDerivedFingerprints(coldNodes).complete()) + w, err := NewWatcher(idx, config.WatchConfig{Enabled: true}, zap.NewNop()) + require.NoError(t, err) + + writeTestFile(t, path, "package main\n\n// first patch\nfunc Stable() {}\n") + w.patchGraph(path, ChangeModified) + first := <-w.Events() + assert.Equal(t, "structural", first.Classification) + assert.NotZero(t, first.NodesRemoved) - mtimeAfter := idx.FileMtimes()["main.go"] - assert.Greater(t, mtimeAfter, mtimeBefore, - "an inert skip must restamp the recorded mtime past the save") + writeTestFile(t, path, "package main\n\n// second patch\n// now fingerprinted\nfunc Stable() {}\n") + w.patchGraph(path, ChangeModified) + second := <-w.Events() + assert.Equal(t, "metadata_only", second.Classification) + assert.Zero(t, second.NodesRemoved) } -// nodeByName returns the live graph node for a named symbol in a file, -// failing the test if it is absent. func nodeByName(t *testing.T, idx *Indexer, relPath, name string) *graph.Node { t.Helper() for _, n := range idx.graph.GetFileNodes(relPath) { @@ -302,3 +301,14 @@ func nodeByName(t *testing.T, idx *Indexer, relPath, name string) *graph.Node { t.Fatalf("node %q not found in %s", name, relPath) return nil } + +func callEdge(t *testing.T, idx *Indexer, from string) *graph.Edge { + t.Helper() + for _, edge := range idx.graph.GetOutEdges(from) { + if edge.Kind == graph.EdgeCalls { + return edge + } + } + t.Fatalf("call edge not found for %s", from) + return nil +} diff --git a/internal/indexer/watcher_temp_test.go b/internal/indexer/watcher_temp_test.go new file mode 100644 index 000000000..34eb6e36d --- /dev/null +++ b/internal/indexer/watcher_temp_test.go @@ -0,0 +1,114 @@ +package indexer + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/sgtdi/fswatcher" + + "github.com/zzet/gortex/internal/parser" + "github.com/zzet/gortex/internal/parser/languages" +) + +func TestIsGortexAtomicTemp(t *testing.T) { + t.Parallel() + + for _, path := range []string{ + "internal/mcp/tools_explore.go.gortex.tmp-1725706750", + "/repo/internal/mcp/.gortex.tmp-42", + `C:\\repo\\watcher.go.gortex.tmp-7`, + `C:\\repo/mixed\\watcher.go.gortex.tmp-8`, + } { + if !isGortexAtomicTemp(path) { + t.Errorf("isGortexAtomicTemp(%q) = false, want true", path) + } + } + + for _, path := range []string{ + "internal/mcp/tools_explore.go", + "internal/mcp/gortex.tmp.go", + "internal/mcp/file.tmp-42", + "internal/mcp/file.go.gortex.tmp-", + "internal/mcp/file.go.gortex.tmp-backup", + "internal/mcp/file.go.gortex.tmp-42.go", + "/repo/.gortex.tmp-42/real.go", + `C:\\repo\\.gortex.tmp-42\\real.go`, + } { + if isGortexAtomicTemp(path) { + t.Errorf("isGortexAtomicTemp(%q) = true, want false", path) + } + } +} + +func TestWatcherHandleEventIgnoresAtomicTempSequenceAndProcessesTarget(t *testing.T) { + root := t.TempDir() + registry := parser.NewRegistry() + registry.Register(languages.NewGoExtractor()) + watcher := &Watcher{ + indexer: &Indexer{rootPath: root, registry: registry}, + pending: make(map[string]*time.Timer), + stormBatch: make(map[string]ChangeKind), + pendingScanDirs: make(map[string]struct{}), + } + watcher.config.DebounceMs = 60_000 + watcher.config.StormThreshold = 1_000 + watcher.config.StormWindowMs = 60_000 + // If a temp directory ever reaches enqueueDirScan, keep its drainer active + // until cleanup so the state assertion below cannot race with completion. + scanRelease := make(chan struct{}) + watcher.scanFn = func(map[string]struct{}) { <-scanRelease } + defer close(scanRelease) + + target := filepath.Join(root, "sample.go") + temp := target + ".gortex.tmp-42" + tempDir := filepath.Join(root, ".gortex.tmp-43") + if err := os.Mkdir(tempDir, 0o755); err != nil { + t.Fatal(err) + } + events := []fswatcher.WatchEvent{ + {Path: temp, Types: []fswatcher.EventType{fswatcher.EventCreate}}, + {Path: temp, Types: []fswatcher.EventType{fswatcher.EventMod}}, + {Path: temp, Types: []fswatcher.EventType{fswatcher.EventRename}}, + {Path: temp, Types: []fswatcher.EventType{fswatcher.EventRemove}}, + {Path: tempDir, Types: []fswatcher.EventType{fswatcher.EventCreate}}, + } + for _, event := range events { + watcher.handleEvent(event) + watcher.mu.Lock() + pending := len(watcher.pending) + watcher.mu.Unlock() + if pending != 0 { + t.Fatalf("temp %v event entered debounce queue: pending=%d", event.Types, pending) + } + watcher.stormMu.Lock() + stormEvents, stormPaths, stormActive := len(watcher.eventTimes), len(watcher.stormBatch), watcher.stormActive + watcher.stormMu.Unlock() + if stormEvents != 0 || stormPaths != 0 || stormActive { + t.Fatalf("temp %v event entered storm state: events=%d paths=%d active=%v", event.Types, stormEvents, stormPaths, stormActive) + } + watcher.reconcileMu.Lock() + scanDirs, scanActive := len(watcher.pendingScanDirs), watcher.dirScanActive + watcher.reconcileMu.Unlock() + if scanDirs != 0 || scanActive { + t.Fatalf("temp %v event entered directory scan: dirs=%d active=%v", event.Types, scanDirs, scanActive) + } + } + + for _, eventType := range []fswatcher.EventType{fswatcher.EventCreate, fswatcher.EventMod} { + watcher.handleEvent(fswatcher.WatchEvent{Path: target, Types: []fswatcher.EventType{eventType}}) + watcher.mu.Lock() + timer, queued := watcher.pending[target] + if queued { + delete(watcher.pending, target) + } + watcher.mu.Unlock() + if !queued { + t.Fatalf("target %s event did not enter debounce queue", eventType) + } + if !timer.Stop() { + t.Fatalf("target %s debounce timer fired before assertion", eventType) + } + } +} diff --git a/internal/indexer/watcher_test.go b/internal/indexer/watcher_test.go index 6b8c098b1..b22bcba5e 100644 --- a/internal/indexer/watcher_test.go +++ b/internal/indexer/watcher_test.go @@ -174,6 +174,7 @@ func Modified() {} func TestWatcher_SymbolChangeCallback_Delete(t *testing.T) { dir, _, w := setupWatcher(t) + require.NotEmpty(t, w.snapshotSymbols("main.go"), "setup must index Original before watching deletion") type callbackData struct { filePath string @@ -198,6 +199,69 @@ func TestWatcher_SymbolChangeCallback_Delete(t *testing.T) { require.Len(t, calls, 1) // Old symbols should have entries, new should be nil (deleted). - assert.NotEmpty(t, calls[0].oldSymbols) + assert.NotEmpty(t, calls[0].oldSymbols, "callback=%+v", calls[0]) assert.Nil(t, calls[0].newSymbols) } + +func TestWatcher_DirScanDeleteOverlapPublishesPrimaryOnce(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "main.go") + writeTestFile(t, path, `package main + +func Original() {} +`) + + g := graph.New() + reg := parser.NewRegistry() + reg.Register(languages.NewGoExtractor()) + cfg := config.Default() + cfg.Index.Workers = 1 + idx := New(g, reg, cfg.Index, zap.NewNop()) + _, err := idx.Index(dir) + require.NoError(t, err) + + w, err := NewWatcher(idx, config.WatchConfig{DebounceMs: 50}, zap.NewNop()) + require.NoError(t, err) + + type callbackData struct { + oldSymbols []*graph.Node + newSymbols []*graph.Node + } + var calls []callbackData + w.OnSymbolChange(func(_ string, oldSymbols, newSymbols []*graph.Node) { + calls = append(calls, callbackData{oldSymbols: oldSymbols, newSymbols: newSymbols}) + }) + + // Force the bad ordering deterministically: the file disappears, a + // new-directory discovery scan observes that absence first, and the real + // file event is delivered only afterward. Discovery must not consume the + // deletion or its pre-delete symbol snapshot. + require.NoError(t, os.Remove(path)) + w.runDirScan(map[string]struct{}{dir: {}}, nil) + require.NotEmpty(t, g.FindNodesByName("Original")) + + w.patchGraph(path, ChangeDeleted) + w.patchGraph(path, ChangeDeleted) // redundant producer is suppressed + + require.Len(t, calls, 1) + require.NotEmpty(t, calls[0].oldSymbols) + oldNames := make([]string, 0, len(calls[0].oldSymbols)) + for _, symbol := range calls[0].oldSymbols { + oldNames = append(oldNames, symbol.Name) + } + assert.Contains(t, oldNames, "Original") + assert.Nil(t, calls[0].newSymbols) + + select { + case ev := <-w.Events(): + assert.Equal(t, ChangeDeleted, ev.Kind) + assert.Positive(t, ev.NodesRemoved) + default: + t.Fatal("primary delete event was not published") + } + select { + case ev := <-w.Events(): + t.Fatalf("duplicate delete event published: %+v", ev) + default: + } +} diff --git a/internal/mcp/agent_preset_test.go b/internal/mcp/agent_preset_test.go index 66dd2bb01..53e2fd05a 100644 --- a/internal/mcp/agent_preset_test.go +++ b/internal/mcp/agent_preset_test.go @@ -27,32 +27,62 @@ func TestAgentPreset_Membership(t *testing.T) { require.Equal(t, "agent", newToolPolicy(ToolPolicyConfig{Preset: "coding-agent"}, nil).preset) } -// TestAgentPreset_ClientAwareDefault: a known coding-agent client gets the -// lean agent surface with no configuration; an editor / unknown client -// keeps the server's global core default; an explicit forwarded preset -// overrides the client default. -func TestAgentPreset_ClientAwareDefault(t *testing.T) { +// TestClientAwareDefault: every identified client gets the same compact closed +// surface; a pre-initialize session keeps the compatibility default; and an +// explicit forwarded preset still wins. +func TestClientAwareDefault(t *testing.T) { srv := setupPresetServer(t, ToolPolicyConfig{Preset: "core", Mode: "defer"}) - // Known coding-agent client → agent surface (analyze deferred out). + // Known coding-agent client → compact domain surface. srv.NoteSessionClient("sess_cc", "claude-code", "1.0") cc := listToolNamesForSession(t, srv, "sess_cc") - require.True(t, cc["search_symbols"]) - require.True(t, cc["edit_file"]) - require.False(t, cc["analyze"], "analyze is not in the lean agent surface") + require.Equal(t, mapKeysAsSet(facadeToolNames()), cc) + require.True(t, cc["read"]) + require.False(t, cc["read_file"]) - // Editor / unknown client → the server's global core default (analyze in). + // An unknown editor is still an identified MCP client and gets the same + // JSON-safe compact surface. srv.NoteSessionClient("sess_ed", "some-editor", "1.0") ed := listToolNamesForSession(t, srv, "sess_ed") - require.True(t, ed["analyze"], "unknown client keeps the core default") - require.Greater(t, len(ed), len(cc), "core is wider than the lean agent surface") + require.Equal(t, mapKeysAsSet(facadeToolNames()), ed) + require.True(t, ed["read"]) + require.False(t, ed["read_file"]) + + // Before initialize supplies clientInfo, the server global remains in force. + anonymous := listToolNamesForSession(t, srv, "sess_anonymous") + require.True(t, anonymous["read_file"]) + require.False(t, anonymous["read"]) + require.Greater(t, len(anonymous), len(cc), "core is wider than the compact surface") // A forwarded GORTEX_TOOLS spec overrides the client-aware default. srv.NoteSessionClient("sess_ov", "claude-code", "1.0") srv.NoteSessionToolPolicy("sess_ov", "full", "") ov := listToolNamesForSession(t, srv, "sess_ov") - require.True(t, ov["analyze"], "forwarded full overrides the agent default") + require.True(t, ov["analyze"], "forwarded full overrides the coding-client default") require.True(t, ov["get_architecture"]) + require.True(t, ov["read_file"]) + require.False(t, ov["read"]) +} + +func TestExplicitCoreDeferPolicyOverridesNamedClientDefault(t *testing.T) { + srv := setupPresetServer(t, ToolPolicyConfig{ + Preset: "core", Mode: "defer", OperatorPinned: true, + }) + srv.NoteSessionClient("sess_rollback", "any-agent", "1.0") + tools := listToolNamesForSession(t, srv, "sess_rollback") + require.True(t, tools["read_file"]) + require.False(t, tools["read"]) +} + +func TestCompactSurfaceIgnoresDeltasToKeepListAndGateAligned(t *testing.T) { + cfg := parseToolSpec("facade-v1,+read_file,-read") + p := newToolPolicy(cfg, nil) + require.True(t, p.allows("read")) + require.False(t, p.allows("read_file")) + + srv := setupPresetServer(t, cfg) + srv.NoteSessionClient("sess_closed", "any-agent", "1.0") + require.Equal(t, mapKeysAsSet(facadeToolNames()), listToolNamesForSession(t, srv, "sess_closed")) } // paramDescLen returns the length of a parameter's description in one tool's diff --git a/internal/mcp/analysis_fixture_test.go b/internal/mcp/analysis_fixture_test.go new file mode 100644 index 000000000..fbafa0982 --- /dev/null +++ b/internal/mcp/analysis_fixture_test.go @@ -0,0 +1,40 @@ +package mcp + +import ( + "testing" + + "github.com/zzet/gortex/internal/analysis" + "github.com/zzet/gortex/internal/graph" +) + +// installCommunitiesForTest publishes a synthetic community result with the +// same graph-token discipline as production analysis. Tests must use this +// helper instead of assigning Server.communities directly: production readers +// deliberately reject results that are not tied to the current graph revision. +func installCommunitiesForTest(s *Server, communities *analysis.CommunityResult) { + s.analysisMu.Lock() + defer s.analysisMu.Unlock() + + s.communities = communities + s.communitiesToken = s.currentCommunityToken() + s.analysisEpoch++ + s.hotspots = nil + s.hotspotsReady = false +} + +func TestInstallCommunitiesForTestRespectsGraphFreshness(t *testing.T) { + g := graph.New() + g.AddNode(&graph.Node{ID: "pkg/a.go::A", Name: "A", Kind: graph.KindFunction, FilePath: "pkg/a.go"}) + s := &Server{graph: g} + fixture := &analysis.CommunityResult{NodeToComm: map[string]string{"pkg/a.go::A": "c1"}} + installCommunitiesForTest(s, fixture) + + if got := s.getCommunities(); got != fixture { + t.Fatalf("current fixture = %p, want %p", got, fixture) + } + + g.AddNode(&graph.Node{ID: "pkg/b.go::B", Name: "B", Kind: graph.KindFunction, FilePath: "pkg/b.go"}) + if got := s.getCommunities(); got != nil { + t.Fatalf("stale fixture survived graph mutation: %#v", got) + } +} diff --git a/internal/mcp/analysis_generation.go b/internal/mcp/analysis_generation.go new file mode 100644 index 000000000..244fe8a38 --- /dev/null +++ b/internal/mcp/analysis_generation.go @@ -0,0 +1,332 @@ +package mcp + +import ( + "context" + "fmt" + "sort" + "time" + + "github.com/zzet/gortex/internal/analysis" + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/runtimeactivity" + "github.com/zzet/gortex/internal/search" + "go.uber.org/zap" +) + +const analysisGenerationFormatVersion uint32 = 1 + +const ( + analysisGenerationWriteChunk = 1000 + analysisGenerationPruneBatch = 1000 + analysisGenerationPruneKeep = 2 +) + +func (s *Server) analysisGenerationBackends() (graph.AnalysisGenerationStore, graph.AnalysisQueryStore) { + backend := s.backendStore() + writer, _ := backend.(graph.AnalysisGenerationStore) + query, _ := backend.(graph.AnalysisQueryStore) + return writer, query +} + +type analysisGenerationInput struct { + header graph.AnalysisGenerationHeader + adjacency analysis.AdjacencyPersistenceSnapshot + communities []graph.AnalysisCommunitySummary + processes []graph.AnalysisProcessSummary + concepts []graph.AnalysisConcept + conceptRelated map[string][]string +} + +// normalizePersistedAnalysis gives cold and lazy materializations the same +// deterministic collection order. Community membership has no semantic order, +// while process Steps deliberately retain their call-tree preorder. +func normalizePersistedAnalysis(candidate *persistedAnalysis) { + if candidate == nil { + return + } + if candidate.communities != nil { + for i := range candidate.communities.Communities { + sort.Strings(candidate.communities.Communities[i].Members) + sort.Strings(candidate.communities.Communities[i].Files) + } + sort.Slice(candidate.communities.Communities, func(i, j int) bool { + return candidate.communities.Communities[i].ID < candidate.communities.Communities[j].ID + }) + } + if candidate.processes != nil { + for i := range candidate.processes.Processes { + sort.Strings(candidate.processes.Processes[i].Files) + } + sort.Slice(candidate.processes.Processes, func(i, j int) bool { + return candidate.processes.Processes[i].ID < candidate.processes.Processes[j].ID + }) + for nodeID := range candidate.processes.NodeToProcs { + sort.Strings(candidate.processes.NodeToProcs[nodeID]) + } + } +} + +func prepareAnalysisGeneration(candidate persistedAnalysis, revision uint64) (analysisGenerationInput, error) { + normalizePersistedAnalysis(&candidate) + if candidate.communities == nil || candidate.leiden == nil || candidate.processes == nil || + candidate.pageRank == nil || candidate.adjacency == nil || candidate.autoConcepts == nil || candidate.hits == nil { + return analysisGenerationInput{}, fmt.Errorf("analysis generation: incomplete candidate") + } + + adjacency := candidate.adjacency.PersistenceSnapshot() + communities := make([]graph.AnalysisCommunitySummary, 0, len(candidate.communities.Communities)) + for _, community := range candidate.communities.Communities { + files := append([]string(nil), community.Files...) + sort.Strings(files) + communities = append(communities, graph.AnalysisCommunitySummary{ + ID: community.ID, Label: community.Label, Hub: community.Hub, ParentID: community.ParentID, + Size: community.Size, Cohesion: community.Cohesion, Files: files, + }) + } + sort.Slice(communities, func(i, j int) bool { return communities[i].ID < communities[j].ID }) + + processes := make([]graph.AnalysisProcessSummary, 0, len(candidate.processes.Processes)) + for _, process := range candidate.processes.Processes { + files := append([]string(nil), process.Files...) + sort.Strings(files) + processes = append(processes, graph.AnalysisProcessSummary{ + ID: process.ID, Name: process.Name, EntryPoint: process.EntryPoint, + StepCount: process.StepCount, Score: process.Score, Truncated: process.Truncated, Files: files, + }) + } + sort.Slice(processes, func(i, j int) bool { return processes[i].ID < processes[j].ID }) + + conceptSnapshot := candidate.autoConcepts.PersistenceSnapshot() + conceptSet := make(map[string]bool, len(conceptSnapshot.Vocab)+len(conceptSnapshot.Related)) + for _, token := range conceptSnapshot.Vocab { + conceptSet[token] = true + } + for token, related := range conceptSnapshot.Related { + if _, ok := conceptSet[token]; !ok { + conceptSet[token] = false + } + for _, sibling := range related { + if _, ok := conceptSet[sibling]; !ok { + conceptSet[sibling] = false + } + } + } + conceptTokens := make([]string, 0, len(conceptSet)) + for token := range conceptSet { + conceptTokens = append(conceptTokens, token) + } + sort.Strings(conceptTokens) + concepts := make([]graph.AnalysisConcept, 0, len(conceptTokens)) + for _, token := range conceptTokens { + concepts = append(concepts, graph.AnalysisConcept{Token: token, InVocabulary: conceptSet[token]}) + } + + return analysisGenerationInput{ + header: graph.AnalysisGenerationHeader{ + FormatVersion: analysisGenerationFormatVersion, + GraphRevision: revision, + CreatedAtUnix: time.Now().Unix(), + NodeCount: len(adjacency.IDs), CommunityCount: len(communities), + ProcessCount: len(processes), ConceptCount: len(concepts), + PageRankMax: candidate.pageRank.Max, AuthorityMax: candidate.hits.MaxAuth, + HubMax: candidate.hits.MaxHub, Modularity: candidate.communities.Modularity, + ProcessesTruncated: candidate.processes.Truncated, + ProcessesTruncationReason: candidate.processes.TruncationReason, + }, + adjacency: adjacency, communities: communities, processes: processes, + concepts: concepts, conceptRelated: conceptSnapshot.Related, + }, nil +} + +func persistAnalysisGeneration( + writer graph.AnalysisGenerationStore, + expectedRevision uint64, + candidate persistedAnalysis, +) (graph.AnalysisGenerationHeader, bool, error) { + input, err := prepareAnalysisGeneration(candidate, expectedRevision) + if err != nil { + return graph.AnalysisGenerationHeader{}, false, err + } + generationID, accepted, err := writer.BeginAnalysisGeneration(expectedRevision, input.header) + if err != nil || !accepted { + return graph.AnalysisGenerationHeader{}, accepted, err + } + activated := false + defer func() { + if !activated { + _ = writer.AbortAnalysisGeneration(generationID) + } + }() + + // Communities precede nodes because a non-empty node community_id carries + // an immediate foreign key to the immutable generation's community row. + for start := 0; start < len(input.communities); start += analysisGenerationWriteChunk { + end := min(start+analysisGenerationWriteChunk, len(input.communities)) + accepted, err = writer.AppendAnalysisCommunities(expectedRevision, generationID, input.communities[start:end]) + if err != nil || !accepted { + return graph.AnalysisGenerationHeader{}, accepted, err + } + } + if accepted, err = writer.SealAnalysisComponent(expectedRevision, generationID, graph.AnalysisComponentCommunities, input.header.CommunityCount); err != nil || !accepted { + return graph.AnalysisGenerationHeader{}, accepted, err + } + + for start := 0; start < len(input.adjacency.IDs); start += analysisGenerationWriteChunk { + end := min(start+analysisGenerationWriteChunk, len(input.adjacency.IDs)) + rows := make([]graph.AnalysisNodeMetric, 0, end-start) + for _, nodeID := range input.adjacency.IDs[start:end] { + rows = append(rows, graph.AnalysisNodeMetric{ + NodeID: nodeID, CommunityID: candidate.communities.NodeToComm[nodeID], + PageRank: candidate.pageRank.Scores[nodeID], Authority: candidate.hits.Authorities[nodeID], + Hub: candidate.hits.Hubs[nodeID], + }) + } + accepted, err = writer.AppendAnalysisNodes(expectedRevision, generationID, rows) + if err != nil || !accepted { + return graph.AnalysisGenerationHeader{}, accepted, err + } + } + if accepted, err = writer.SealAnalysisComponent(expectedRevision, generationID, graph.AnalysisComponentNodes, input.header.NodeCount); err != nil || !accepted { + return graph.AnalysisGenerationHeader{}, accepted, err + } + + for start := 0; start < len(input.processes); start += analysisGenerationWriteChunk { + end := min(start+analysisGenerationWriteChunk, len(input.processes)) + accepted, err = writer.AppendAnalysisProcesses(expectedRevision, generationID, input.processes[start:end], nil) + if err != nil || !accepted { + return graph.AnalysisGenerationHeader{}, accepted, err + } + } + for _, process := range candidate.processes.Processes { + for start := 0; start < len(process.Steps); start += analysisGenerationWriteChunk { + end := min(start+analysisGenerationWriteChunk, len(process.Steps)) + steps := make([]graph.AnalysisProcessStep, 0, end-start) + for ordinal, step := range process.Steps[start:end] { + steps = append(steps, graph.AnalysisProcessStep{ + ProcessID: process.ID, NodeID: step.ID, Ordinal: start + ordinal, Depth: step.Depth, + }) + } + accepted, err = writer.AppendAnalysisProcesses(expectedRevision, generationID, nil, steps) + if err != nil || !accepted { + return graph.AnalysisGenerationHeader{}, accepted, err + } + } + } + if accepted, err = writer.SealAnalysisComponent(expectedRevision, generationID, graph.AnalysisComponentProcesses, input.header.ProcessCount); err != nil || !accepted { + return graph.AnalysisGenerationHeader{}, accepted, err + } + + for start := 0; start < len(input.concepts); start += analysisGenerationWriteChunk { + end := min(start+analysisGenerationWriteChunk, len(input.concepts)) + accepted, err = writer.AppendAnalysisConcepts(expectedRevision, generationID, input.concepts[start:end], nil) + if err != nil || !accepted { + return graph.AnalysisGenerationHeader{}, accepted, err + } + } + conceptTokens := make([]string, 0, len(input.conceptRelated)) + for token := range input.conceptRelated { + conceptTokens = append(conceptTokens, token) + } + sort.Strings(conceptTokens) + relations := make([]graph.AnalysisConceptRelation, 0, analysisGenerationWriteChunk) + flushRelations := func() (bool, error) { + if len(relations) == 0 { + return true, nil + } + ok, flushErr := writer.AppendAnalysisConcepts(expectedRevision, generationID, nil, relations) + relations = relations[:0] + return ok, flushErr + } + for _, token := range conceptTokens { + for rank, sibling := range input.conceptRelated[token] { + relations = append(relations, graph.AnalysisConceptRelation{Token: token, RelatedToken: sibling, Rank: rank}) + if len(relations) == cap(relations) { + accepted, err = flushRelations() + if err != nil || !accepted { + return graph.AnalysisGenerationHeader{}, accepted, err + } + } + } + } + if accepted, err = flushRelations(); err != nil || !accepted { + return graph.AnalysisGenerationHeader{}, accepted, err + } + if accepted, err = writer.SealAnalysisComponent(expectedRevision, generationID, graph.AnalysisComponentConcepts, input.header.ConceptCount); err != nil || !accepted { + return graph.AnalysisGenerationHeader{}, accepted, err + } + + adjacencyPayload, err := encodeAnalysisBlobValue(input.adjacency) + if err != nil { + return graph.AnalysisGenerationHeader{}, false, fmt.Errorf("encode adjacency: %w", err) + } + if accepted, err = writer.PutAnalysisBlob(expectedRevision, generationID, graph.AnalysisBlob{Component: graph.AnalysisBlobAdjacency, Payload: adjacencyPayload}); err != nil || !accepted { + return graph.AnalysisGenerationHeader{}, accepted, err + } + if accepted, err = writer.SealAnalysisComponent(expectedRevision, generationID, graph.AnalysisComponentAdjacency, 1); err != nil || !accepted { + return graph.AnalysisGenerationHeader{}, accepted, err + } + + leidenPayload, err := encodeAnalysisBlobValue(candidate.leiden.PersistenceSnapshot()) + if err != nil { + return graph.AnalysisGenerationHeader{}, false, fmt.Errorf("encode Leiden state: %w", err) + } + if accepted, err = writer.PutAnalysisBlob(expectedRevision, generationID, graph.AnalysisBlob{Component: graph.AnalysisBlobLeiden, Payload: leidenPayload}); err != nil || !accepted { + return graph.AnalysisGenerationHeader{}, accepted, err + } + if accepted, err = writer.SealAnalysisComponent(expectedRevision, generationID, graph.AnalysisComponentLeiden, 1); err != nil || !accepted { + return graph.AnalysisGenerationHeader{}, accepted, err + } + + if accepted, err = writer.ActivateAnalysisGeneration(expectedRevision, generationID); err != nil || !accepted { + return graph.AnalysisGenerationHeader{}, accepted, err + } + activated = true + input.header.GenerationID = generationID + return input.header, true, nil +} + +func (s *Server) scheduleAnalysisGenerationPrune(writer graph.AnalysisGenerationStore) { + if writer == nil || !s.analysisPruneScheduled.CompareAndSwap(false, true) { + return + } + go func() { + defer s.analysisPruneScheduled.Store(false) + runtimeactivity.Begin("analysis_generation_gc") + defer runtimeactivity.End("analysis_generation_gc") + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := writer.PruneAnalysisGenerations(ctx, analysisGenerationPruneKeep, analysisGenerationPruneBatch); err != nil && s.logger != nil { + s.logger.Warn("mcp: analysis generation prune failed", zap.Error(err)) + } + }() +} + +func restoreAdjacencyBlob(payload []byte) (*analysis.AdjacencySnapshot, error) { + var snapshot analysis.AdjacencyPersistenceSnapshot + if err := decodeAnalysisBlobValue(payload, &snapshot); err != nil { + return nil, err + } + return analysis.RestoreAdjacencySnapshot(snapshot) +} + +func restoreLeidenBlob(payload []byte, edgeIdentityRevision int) (*analysis.LeidenPartitionCache, error) { + var snapshot analysis.LeidenPartitionSnapshot + if err := decodeAnalysisBlobValue(payload, &snapshot); err != nil { + return nil, err + } + return analysis.RestoreLeidenPartitionCache(snapshot, edgeIdentityRevision), nil +} + +func conceptsFromQuery(result graph.AnalysisConceptQueryResult) *search.AutoConcepts { + related := make(map[string][]string, len(result.Concepts)) + vocab := make([]string, 0, len(result.Concepts)) + for _, concept := range result.Concepts { + if concept.InVocabulary { + vocab = append(vocab, concept.Token) + } + } + for _, relation := range result.Relations { + related[relation.Token] = append(related[relation.Token], relation.RelatedToken) + } + return search.RestoreAutoConcepts(search.AutoConceptsPersistenceSnapshot{Related: related, Vocab: vocab}) +} diff --git a/internal/mcp/analysis_lazy.go b/internal/mcp/analysis_lazy.go new file mode 100644 index 000000000..d52d5e15b --- /dev/null +++ b/internal/mcp/analysis_lazy.go @@ -0,0 +1,473 @@ +package mcp + +import ( + "errors" + "fmt" + + "github.com/zzet/gortex/internal/analysis" + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/runtimeactivity" + "go.uber.org/zap" +) + +const ( + analysisGenerationQueryPage = 512 + analysisGenerationQueryMax = 1000 +) + +var errAnalysisGenerationUnavailable = errors.New("active analysis generation is unavailable or stale") + +// activeAnalysisQuery returns an immutable generation receipt and its bounded +// query backend. The generation header is useful only while the published +// graph token remains current; a graph mutation invalidates both the SQLite +// active pointer and this in-memory receipt. +func (s *Server) activeAnalysisQuery() (graph.AnalysisQueryStore, graph.AnalysisGenerationHeader, bool) { + s.analysisMu.RLock() + defer s.analysisMu.RUnlock() + if !s.analysisGenerationReady || s.analysisGeneration.GenerationID <= 0 || + s.analysisGeneration.FormatVersion != analysisGenerationFormatVersion || + s.analysisGeneration.GraphRevision != s.communitiesToken.analysisRevision || + !s.analysisSnapshotCurrentLocked() { + return nil, graph.AnalysisGenerationHeader{}, false + } + _, query := s.analysisGenerationBackends() + if query == nil { + return nil, graph.AnalysisGenerationHeader{}, false + } + return query, s.analysisGeneration, true +} + +func boundedAnalysisLimit(limit int) int { + if limit <= 0 { + return analysisGenerationQueryPage + } + if limit > analysisGenerationQueryMax { + return analysisGenerationQueryMax + } + return limit +} + +func boundedAnalysisIDs(ids []string) ([]string, error) { + if len(ids) == 0 { + return nil, nil + } + seen := make(map[string]struct{}, min(len(ids), analysisGenerationQueryMax)) + out := make([]string, 0, min(len(ids), analysisGenerationQueryMax)) + for _, id := range ids { + if id == "" { + continue + } + if _, duplicate := seen[id]; duplicate { + continue + } + if len(out) == analysisGenerationQueryMax { + return nil, fmt.Errorf("analysis query exceeds %d unique IDs", analysisGenerationQueryMax) + } + seen[id] = struct{}{} + out = append(out, id) + } + return out, nil +} + +func (s *Server) analysisNodeMetrics(nodeIDs []string) ([]graph.AnalysisNodeMetric, error) { + ids, err := boundedAnalysisIDs(nodeIDs) + if err != nil || len(ids) == 0 { + return nil, err + } + query, header, ok := s.activeAnalysisQuery() + if !ok { + return nil, errAnalysisGenerationUnavailable + } + return query.AnalysisNodeMetrics(header.GenerationID, ids) +} + +func (s *Server) topAnalysisNodeMetrics(metric graph.AnalysisMetric, limit int, cursor *graph.AnalysisMetricCursor) ([]graph.AnalysisNodeMetric, *graph.AnalysisMetricCursor, error) { + query, header, ok := s.activeAnalysisQuery() + if !ok { + return nil, nil, errAnalysisGenerationUnavailable + } + return query.TopAnalysisNodeMetrics(header.GenerationID, metric, boundedAnalysisLimit(limit), cursor) +} + +func (s *Server) analysisCommunitySummaries(limit int, cursorID string) ([]graph.AnalysisCommunitySummary, string, error) { + query, header, ok := s.activeAnalysisQuery() + if !ok { + return nil, "", errAnalysisGenerationUnavailable + } + return query.ListAnalysisCommunitySummaries(header.GenerationID, boundedAnalysisLimit(limit), cursorID) +} + +func (s *Server) analysisCommunityMembers(communityID string, limit int, cursorNodeID string) ([]graph.AnalysisNodeMetric, string, error) { + if communityID == "" { + return nil, "", nil + } + query, header, ok := s.activeAnalysisQuery() + if !ok { + return nil, "", errAnalysisGenerationUnavailable + } + return query.AnalysisCommunityMembers(header.GenerationID, communityID, boundedAnalysisLimit(limit), cursorNodeID) +} + +func (s *Server) analysisProcessSummaries(limit int, cursorID string) ([]graph.AnalysisProcessSummary, string, error) { + query, header, ok := s.activeAnalysisQuery() + if !ok { + return nil, "", errAnalysisGenerationUnavailable + } + return query.ListAnalysisProcessSummaries(header.GenerationID, boundedAnalysisLimit(limit), cursorID) +} + +func (s *Server) analysisProcessSteps(processID string, limit, cursorOrdinal int) ([]graph.AnalysisProcessStep, int, error) { + if processID == "" { + return nil, cursorOrdinal, nil + } + query, header, ok := s.activeAnalysisQuery() + if !ok { + return nil, cursorOrdinal, errAnalysisGenerationUnavailable + } + return query.AnalysisProcessSteps(header.GenerationID, processID, boundedAnalysisLimit(limit), cursorOrdinal) +} + +func (s *Server) analysisProcessesForNodes(nodeIDs []string) ([]graph.AnalysisProcessMembership, error) { + ids, err := boundedAnalysisIDs(nodeIDs) + if err != nil || len(ids) == 0 { + return nil, err + } + query, header, ok := s.activeAnalysisQuery() + if !ok { + return nil, errAnalysisGenerationUnavailable + } + return query.AnalysisProcessesForNodes(header.GenerationID, ids) +} + +// releaseTransientAnalysisIfIdle drops request-scoped compatibility views only +// after every tracked foreground/background operation is quiet. The compact +// generation receipt and graph tokens remain published, so the next request can +// query bounded rows without a whole-graph recomputation. +func (s *Server) releaseTransientAnalysisIfIdle() bool { + if runtimeactivity.Current().Active != 0 { + return false + } + s.analysisMu.Lock() + defer s.analysisMu.Unlock() + if !s.analysisGenerationReady { + return false + } + s.communities = nil + s.leidenCache = nil + s.processes = nil + s.pageRank = nil + s.adjacency = nil + s.autoConcepts = nil + s.hits = nil + s.hotspots = nil + s.hotspotsReady = false + return true +} + +func (s *Server) logAnalysisMaterializationError(component string, err error) { + if err != nil && s.logger != nil { + s.logger.Warn("mcp: lazy analysis materialization failed", zap.String("component", component), zap.Error(err)) + } +} + +func (s *Server) installMaterializedGeneration(header graph.AnalysisGenerationHeader, install func()) bool { + s.analysisMu.Lock() + defer s.analysisMu.Unlock() + if !s.analysisGenerationReady || s.analysisGeneration.GenerationID != header.GenerationID || + !s.analysisSnapshotCurrentLocked() { + return false + } + install() + return true +} + +func (s *Server) ensureNodeMetricsMaterialized() bool { + s.analysisMu.RLock() + ready := s.analysisSnapshotCurrentLocked() && s.pageRank != nil && s.hits != nil + s.analysisMu.RUnlock() + if ready { + return true + } + + s.analysisMaterializeMu.Lock() + defer s.analysisMaterializeMu.Unlock() + s.analysisMu.RLock() + ready = s.analysisSnapshotCurrentLocked() && s.pageRank != nil && s.hits != nil + s.analysisMu.RUnlock() + if ready { + return true + } + query, header, ok := s.activeAnalysisQuery() + if !ok { + return false + } + + pageRank := &analysis.PageRankResult{Scores: make(map[string]float64, header.NodeCount), Max: header.PageRankMax} + hits := &analysis.HITSResult{ + Authorities: make(map[string]float64, header.NodeCount), + Hubs: make(map[string]float64, header.NodeCount), + MaxAuth: header.AuthorityMax, MaxHub: header.HubMax, + } + cursor := "" + for { + rows, next, err := query.ListAnalysisNodeMetrics(header.GenerationID, analysisGenerationQueryPage, cursor) + if err != nil { + s.logAnalysisMaterializationError("node_metrics", err) + return false + } + for _, row := range rows { + pageRank.Scores[row.NodeID] = row.PageRank + hits.Authorities[row.NodeID] = row.Authority + hits.Hubs[row.NodeID] = row.Hub + } + if len(rows) == 0 || next == "" || next == cursor { + break + } + cursor = next + } + return s.installMaterializedGeneration(header, func() { + s.pageRank = pageRank + s.hits = hits + }) +} + +func (s *Server) ensureCommunitiesMaterialized() bool { + s.analysisMu.RLock() + ready := s.analysisSnapshotCurrentLocked() && s.communities != nil + s.analysisMu.RUnlock() + if ready { + return true + } + + s.analysisMaterializeMu.Lock() + defer s.analysisMaterializeMu.Unlock() + s.analysisMu.RLock() + ready = s.analysisSnapshotCurrentLocked() && s.communities != nil + s.analysisMu.RUnlock() + if ready { + return true + } + query, header, ok := s.activeAnalysisQuery() + if !ok { + return false + } + + result := &analysis.CommunityResult{ + Communities: make([]analysis.Community, 0, header.CommunityCount), + NodeToComm: make(map[string]string, header.NodeCount), + Modularity: header.Modularity, + } + cursor := "" + for { + summaries, next, err := query.ListAnalysisCommunitySummaries(header.GenerationID, analysisGenerationQueryPage, cursor) + if err != nil { + s.logAnalysisMaterializationError("communities", err) + return false + } + for _, summary := range summaries { + community := analysis.Community{ + ID: summary.ID, Label: summary.Label, Hub: summary.Hub, ParentID: summary.ParentID, + Size: summary.Size, Cohesion: summary.Cohesion, Files: append([]string(nil), summary.Files...), + Members: make([]string, 0, summary.Size), + } + memberCursor := "" + for { + members, memberNext, memberErr := query.AnalysisCommunityMembers(header.GenerationID, summary.ID, analysisGenerationQueryPage, memberCursor) + if memberErr != nil { + s.logAnalysisMaterializationError("community_members", memberErr) + return false + } + for _, member := range members { + community.Members = append(community.Members, member.NodeID) + result.NodeToComm[member.NodeID] = summary.ID + } + if len(members) == 0 || memberNext == "" || memberNext == memberCursor { + break + } + memberCursor = memberNext + } + result.Communities = append(result.Communities, community) + } + if len(summaries) == 0 || next == "" || next == cursor { + break + } + cursor = next + } + return s.installMaterializedGeneration(header, func() { s.communities = result }) +} + +func (s *Server) ensureProcessesMaterialized() bool { + s.analysisMu.RLock() + ready := s.analysisSnapshotCurrentLocked() && s.processes != nil + s.analysisMu.RUnlock() + if ready { + return true + } + + s.analysisMaterializeMu.Lock() + defer s.analysisMaterializeMu.Unlock() + s.analysisMu.RLock() + ready = s.analysisSnapshotCurrentLocked() && s.processes != nil + s.analysisMu.RUnlock() + if ready { + return true + } + query, header, ok := s.activeAnalysisQuery() + if !ok { + return false + } + + result := &analysis.ProcessResult{ + Processes: make([]analysis.Process, 0, header.ProcessCount), + NodeToProcs: make(map[string][]string), + Truncated: header.ProcessesTruncated, + TruncationReason: header.ProcessesTruncationReason, + } + cursor := "" + for { + summaries, next, err := query.ListAnalysisProcessSummaries(header.GenerationID, analysisGenerationQueryPage, cursor) + if err != nil { + s.logAnalysisMaterializationError("processes", err) + return false + } + for _, summary := range summaries { + process := analysis.Process{ + ID: summary.ID, Name: summary.Name, EntryPoint: summary.EntryPoint, + StepCount: summary.StepCount, Score: summary.Score, Truncated: summary.Truncated, + Files: append([]string(nil), summary.Files...), Steps: make([]analysis.Step, 0, summary.StepCount), + } + stepCursor := -1 + for { + steps, stepNext, stepErr := query.AnalysisProcessSteps(header.GenerationID, summary.ID, analysisGenerationQueryPage, stepCursor) + if stepErr != nil { + s.logAnalysisMaterializationError("process_steps", stepErr) + return false + } + for _, step := range steps { + process.Steps = append(process.Steps, analysis.Step{ID: step.NodeID, Depth: step.Depth}) + result.NodeToProcs[step.NodeID] = append(result.NodeToProcs[step.NodeID], summary.ID) + } + if len(steps) == 0 || stepNext <= stepCursor { + break + } + stepCursor = stepNext + } + result.Processes = append(result.Processes, process) + } + if len(summaries) == 0 || next == "" || next == cursor { + break + } + cursor = next + } + return s.installMaterializedGeneration(header, func() { s.processes = result }) +} + +func (s *Server) ensureAutoConceptsMaterialized() bool { + s.analysisMu.RLock() + ready := s.analysisSnapshotCurrentLocked() && s.autoConcepts != nil + s.analysisMu.RUnlock() + if ready { + return true + } + + s.analysisMaterializeMu.Lock() + defer s.analysisMaterializeMu.Unlock() + s.analysisMu.RLock() + ready = s.analysisSnapshotCurrentLocked() && s.autoConcepts != nil + s.analysisMu.RUnlock() + if ready { + return true + } + query, header, ok := s.activeAnalysisQuery() + if !ok { + return false + } + + combined := graph.AnalysisConceptQueryResult{} + cursor := "" + for { + page, next, err := query.ListAnalysisConcepts(header.GenerationID, analysisGenerationQueryPage, cursor) + if err != nil { + s.logAnalysisMaterializationError("concepts", err) + return false + } + combined.Concepts = append(combined.Concepts, page.Concepts...) + combined.Relations = append(combined.Relations, page.Relations...) + if len(page.Concepts) == 0 || next == "" || next == cursor { + break + } + cursor = next + } + concepts := conceptsFromQuery(combined) + return s.installMaterializedGeneration(header, func() { s.autoConcepts = concepts }) +} + +func (s *Server) ensureAdjacencyMaterialized() bool { + s.analysisMu.RLock() + ready := s.analysisSnapshotCurrentLocked() && s.adjacency != nil + s.analysisMu.RUnlock() + if ready { + return true + } + s.analysisMaterializeMu.Lock() + defer s.analysisMaterializeMu.Unlock() + s.analysisMu.RLock() + ready = s.analysisSnapshotCurrentLocked() && s.adjacency != nil + s.analysisMu.RUnlock() + if ready { + return true + } + query, header, ok := s.activeAnalysisQuery() + if !ok { + return false + } + payload, found, err := query.LoadAnalysisBlob(header.GenerationID, graph.AnalysisBlobAdjacency) + if err != nil || !found { + if err == nil { + err = errors.New("adjacency blob is missing") + } + s.logAnalysisMaterializationError("adjacency", err) + return false + } + adjacency, err := restoreAdjacencyBlob(payload) + if err != nil { + s.logAnalysisMaterializationError("adjacency", err) + return false + } + return s.installMaterializedGeneration(header, func() { s.adjacency = adjacency }) +} + +func (s *Server) ensureLeidenMaterialized() bool { + s.analysisMu.RLock() + ready := s.analysisSnapshotCurrentLocked() && s.leidenCache != nil + s.analysisMu.RUnlock() + if ready { + return true + } + s.analysisMaterializeMu.Lock() + defer s.analysisMaterializeMu.Unlock() + s.analysisMu.RLock() + ready = s.analysisSnapshotCurrentLocked() && s.leidenCache != nil + s.analysisMu.RUnlock() + if ready { + return true + } + query, header, ok := s.activeAnalysisQuery() + if !ok { + return false + } + payload, found, err := query.LoadAnalysisBlob(header.GenerationID, graph.AnalysisBlobLeiden) + if err != nil || !found { + if err == nil { + err = errors.New("leiden blob is missing") + } + s.logAnalysisMaterializationError("leiden", err) + return false + } + leiden, err := restoreLeidenBlob(payload, s.currentCommunityToken().edgeIdentity) + if err != nil { + s.logAnalysisMaterializationError("leiden", err) + return false + } + return s.installMaterializedGeneration(header, func() { s.leidenCache = leiden }) +} diff --git a/internal/mcp/analysis_lazy_consumers.go b/internal/mcp/analysis_lazy_consumers.go new file mode 100644 index 000000000..2d8d731a8 --- /dev/null +++ b/internal/mcp/analysis_lazy_consumers.go @@ -0,0 +1,206 @@ +package mcp + +import ( + "context" + "sort" + + "github.com/zzet/gortex/internal/analysis" + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/search/rerank" +) + +// analysisNodeMetricsBatched keeps callers on the normalized analysis store +// while allowing bounded operations whose affected set crosses one SQLite +// parameter page. Rows are returned in backend order; callers that expose an +// order must derive it from their original ID slice or sort explicitly. +func (s *Server) analysisNodeMetricsBatched(nodeIDs []string) ([]graph.AnalysisNodeMetric, error) { + ids := dedupeStrings(nodeIDs) + rows := make([]graph.AnalysisNodeMetric, 0, len(ids)) + for start := 0; start < len(ids); start += analysisGenerationQueryMax { + end := min(start+analysisGenerationQueryMax, len(ids)) + page, err := s.analysisNodeMetrics(ids[start:end]) + if err != nil { + return nil, err + } + rows = append(rows, page...) + } + return rows, nil +} + +func (s *Server) analysisProcessesForNodesBatched(nodeIDs []string) ([]graph.AnalysisProcessMembership, error) { + ids := dedupeStrings(nodeIDs) + rows := make([]graph.AnalysisProcessMembership, 0, len(ids)) + for start := 0; start < len(ids); start += analysisGenerationQueryMax { + end := min(start+analysisGenerationQueryMax, len(ids)) + page, err := s.analysisProcessesForNodes(ids[start:end]) + if err != nil { + return nil, err + } + rows = append(rows, page...) + } + return rows, nil +} + +func analysisMetricValue(row graph.AnalysisNodeMetric, metric graph.AnalysisMetric) float64 { + switch metric { + case graph.AnalysisMetricPageRank: + return row.PageRank + case graph.AnalysisMetricAuthority: + return row.Authority + case graph.AnalysisMetricHub: + return row.Hub + default: + return 0 + } +} + +func (s *Server) topAnalysisMetricValue(metric graph.AnalysisMetric) float64 { + rows, _, err := s.topAnalysisNodeMetrics(metric, 1, nil) + if err != nil || len(rows) == 0 { + return 0 + } + return analysisMetricValue(rows[0], metric) +} + +// analyzeImpactLazy runs the graph traversal without compatibility maps, then +// enriches the bounded result set from normalized rows. It preserves the +// deterministic community/process ordering of analysis.AnalyzeImpactContext +// without retaining whole-graph maps on the Server. +func (s *Server) analyzeImpactLazy(ctx context.Context, symbolIDs []string) *analysis.ImpactResult { + result := analysis.AnalyzeImpactContext(ctx, s.graph, symbolIDs, nil, nil) + ids := append([]string(nil), symbolIDs...) + for depth := 1; depth <= 3; depth++ { + for _, entry := range result.ByDepth[depth] { + ids = append(ids, entry.ID) + } + } + ids = dedupeStrings(ids) + + if metrics, err := s.analysisNodeMetricsBatched(ids); err == nil { + communities := make(map[string]struct{}) + for _, metric := range metrics { + if metric.CommunityID != "" { + communities[metric.CommunityID] = struct{}{} + } + } + result.AffectedCommunities = result.AffectedCommunities[:0] + for id := range communities { + result.AffectedCommunities = append(result.AffectedCommunities, id) + } + sort.Strings(result.AffectedCommunities) + } + + if memberships, err := s.analysisProcessesForNodesBatched(ids); err == nil { + processes := make(map[string]struct{}) + for _, membership := range memberships { + if membership.ProcessID != "" { + processes[membership.ProcessID] = struct{}{} + } + } + result.AffectedProcesses = result.AffectedProcesses[:0] + for id := range processes { + result.AffectedProcesses = append(result.AffectedProcesses, id) + } + sort.Strings(result.AffectedProcesses) + } + return result +} + +func (s *Server) communitySummariesForNodes(nodeIDs []string, scoped bool, limit int) []graph.AnalysisCommunitySummary { + allowed := make(map[string]struct{}) + if scoped { + metrics, err := s.analysisNodeMetricsBatched(nodeIDs) + if err != nil { + return nil + } + for _, metric := range metrics { + if metric.CommunityID != "" { + allowed[metric.CommunityID] = struct{}{} + } + } + } + out := make([]graph.AnalysisCommunitySummary, 0, limit) + cursor := "" + for len(out) < limit { + rows, next, err := s.analysisCommunitySummaries(analysisGenerationQueryPage, cursor) + if err != nil { + return nil + } + for _, row := range rows { + if scoped { + if _, ok := allowed[row.ID]; !ok { + continue + } + } + out = append(out, row) + if len(out) == limit { + break + } + } + if next == "" || next == cursor || len(rows) == 0 { + break + } + cursor = next + } + return out +} + +func (s *Server) processSummariesForEntries(entryIDs map[string]bool, scoped bool, limit int) []graph.AnalysisProcessSummary { + out := make([]graph.AnalysisProcessSummary, 0, limit) + cursor := "" + for len(out) < limit { + rows, next, err := s.analysisProcessSummaries(analysisGenerationQueryPage, cursor) + if err != nil { + return nil + } + for _, row := range rows { + if scoped && !entryIDs[row.EntryPoint] { + continue + } + out = append(out, row) + if len(out) == limit { + break + } + } + if next == "" || next == cursor || len(rows) == 0 { + break + } + cursor = next + } + return out +} + +func (s *Server) rerankBoundedCentrality(seeds, candidateIDs []string) rerank.CentralityResult { + snapshot, stats := analysis.BuildBoundedAdjacencySnapshot(s.graph, candidateIDs, 2, 4096, 16384) + return rerank.CentralityResult{ + Scores: s.personalizedPageRank(snapshot, seeds), + NodeCount: stats.NodeCount, + EdgeCount: stats.EdgeCount, + NodeBatches: stats.NodeBatches, + EdgeBatches: stats.EdgeBatches, + Truncated: stats.Truncated, + } +} + +// rerankAnalysisMetrics returns exactly the requested candidates and global +// normalization maxima. The callback is invoked once from rerank.Context.Prepare. +func (s *Server) rerankAnalysisMetrics(nodeIDs []string) map[string]rerank.AnalysisMetric { + rows, err := s.analysisNodeMetricsBatched(nodeIDs) + if err != nil || len(rows) == 0 { + return nil + } + maxAuthority := s.topAnalysisMetricValue(graph.AnalysisMetricAuthority) + maxHub := s.topAnalysisMetricValue(graph.AnalysisMetricHub) + metrics := make(map[string]rerank.AnalysisMetric, len(rows)) + for _, row := range rows { + metric := rerank.AnalysisMetric{CommunityID: row.CommunityID} + if maxAuthority > 0 { + metric.Authority = row.Authority / maxAuthority + } + if maxHub > 0 { + metric.Hub = row.Hub / maxHub + } + metrics[row.NodeID] = metric + } + return metrics +} diff --git a/internal/mcp/analysis_lazy_consumers_test.go b/internal/mcp/analysis_lazy_consumers_test.go new file mode 100644 index 000000000..536606795 --- /dev/null +++ b/internal/mcp/analysis_lazy_consumers_test.go @@ -0,0 +1,57 @@ +package mcp + +import ( + "context" + "testing" + + "github.com/zzet/gortex/internal/search/rerank" +) + +func TestNormalAnalysisConsumersDoNotMaterializeWholeGraphMaps(t *testing.T) { + store, _ := buildAnalysisCacheTestGraph(t, 80) + defer store.Close() + server, metrics := populateAnalysisForTest(store) + if metrics.cacheSaveErr != nil { + t.Fatalf("analysis generation save: %v", metrics.cacheSaveErr) + } + if !server.releaseTransientAnalysisIfIdle() { + t.Fatal("analysis compatibility views were not released") + } + assertNoMaterializedAnalysis(t, server) + + id := "repo::pkg0::HandleRequest0" + impact := server.analyzeImpactLazy(context.Background(), []string{id}) + if impact == nil { + t.Fatal("lazy impact returned nil") + } + prediction := &prediction{ + changedIDs: []string{id}, + nodes: server.nodesForIDs([]string{id}), + impact: impact, + } + _ = server.scoreChangeRisk(prediction) + _ = server.riskGatedSymbols(prediction) + assertNoMaterializedAnalysis(t, server) + + node := store.GetNode(id) + rctx := server.buildRerankContext(context.Background(), "HandleRequest") + rctx.Prepare([]*rerank.Candidate{{Node: node}}) + if receipt := rctx.CentralityTelemetry(); receipt.NodeCount == 0 { + t.Fatalf("bounded centrality did not run: %+v", receipt) + } + assertNoMaterializedAnalysis(t, server) +} + +func assertNoMaterializedAnalysis(t *testing.T, server *Server) { + t.Helper() + server.analysisMu.RLock() + defer server.analysisMu.RUnlock() + if server.communities != nil || server.processes != nil || server.pageRank != nil || + server.hits != nil || server.adjacency != nil || server.autoConcepts != nil || + server.leidenCache != nil { + t.Fatalf("normal consumer materialized compatibility analysis: communities=%t processes=%t pagerank=%t hits=%t adjacency=%t concepts=%t leiden=%t", + server.communities != nil, server.processes != nil, server.pageRank != nil, + server.hits != nil, server.adjacency != nil, server.autoConcepts != nil, + server.leidenCache != nil) + } +} diff --git a/internal/mcp/analysis_persistence.go b/internal/mcp/analysis_persistence.go new file mode 100644 index 000000000..3e015f59f --- /dev/null +++ b/internal/mcp/analysis_persistence.go @@ -0,0 +1,249 @@ +package mcp + +import ( + "bytes" + "compress/gzip" + "encoding/gob" + "fmt" + "io" + "time" + + "github.com/zzet/gortex/internal/analysis" + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/search" + "go.uber.org/zap" +) + +const analysisBlobDecodeLimit = int64(1 << 30) + +type persistedAnalysis struct { + communities *analysis.CommunityResult + leiden *analysis.LeidenPartitionCache + processes *analysis.ProcessResult + pageRank *analysis.PageRankResult + adjacency *analysis.AdjacencySnapshot + autoConcepts *search.AutoConcepts + hits *analysis.HITSResult +} + +func encodeAnalysisBlobValue(value any) ([]byte, error) { + var buf bytes.Buffer + zw, err := gzip.NewWriterLevel(&buf, gzip.BestSpeed) + if err != nil { + return nil, err + } + if err := gob.NewEncoder(zw).Encode(value); err != nil { + _ = zw.Close() + return nil, err + } + if err := zw.Close(); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func decodeAnalysisBlobValue(payload []byte, destination any) error { + zr, err := gzip.NewReader(bytes.NewReader(payload)) + if err != nil { + return err + } + defer zr.Close() + limited := &limitedAnalysisBlobReader{Reader: io.LimitReader(zr, analysisBlobDecodeLimit+1)} + if err := gob.NewDecoder(limited).Decode(destination); err != nil { + return err + } + if limited.consumed > analysisBlobDecodeLimit { + return fmt.Errorf("analysis blob exceeds %d decoded bytes", analysisBlobDecodeLimit) + } + return nil +} + +type limitedAnalysisBlobReader struct { + io.Reader + consumed int64 +} + +func (r *limitedAnalysisBlobReader) Read(p []byte) (int, error) { + n, err := r.Reader.Read(p) + r.consumed += int64(n) + return n, err +} + +type analysisRunMetrics struct { + cacheHit bool + cacheLoad time.Duration + cacheSave time.Duration + cacheSaveErr error + snapshot time.Duration + leiden time.Duration + processes time.Duration + pageRank time.Duration + adjacency time.Duration + autoConcepts time.Duration + hits time.Duration +} + +// populateAnalysisLocked either publishes a current durable generation header +// without materializing its rows, or computes one complete analysis snapshot +// and activates it through the generation store's mutation-revision gate. The +// caller holds analysisMu. +func (s *Server) populateAnalysisLocked() analysisRunMetrics { + const maxSnapshotAttempts = 3 + + generationWriter, generationQuery := s.analysisGenerationBackends() + generationBacked := generationWriter != nil && generationQuery != nil + var lastMetrics analysisRunMetrics + for attempt := 0; attempt < maxSnapshotAttempts; attempt++ { + var metrics analysisRunMetrics + + // Header-only warm start: normalized rows and dense algorithm blobs stay + // in SQLite until a bounded consumer asks for them. + if generationBacked { + started := time.Now() + header, found, err := generationQuery.LoadActiveAnalysisHeader(analysisGenerationFormatVersion) + metrics.cacheLoad = time.Since(started) + if err != nil { + if s.logger != nil { + s.logger.Warn("mcp: active analysis generation rejected", zap.Error(err)) + } + } else if found { + sourceToken := s.currentCommunityToken() + if sourceToken.analysisRevision != header.GraphRevision { + lastMetrics = metrics + continue + } + installHeader := func() { + s.communities = nil + s.leidenCache = nil + s.processes = nil + s.pageRank = nil + s.adjacency = nil + s.autoConcepts = nil + s.hits = nil + s.analysisGeneration = header + s.analysisGenerationReady = true + s.communitiesToken = sourceToken + s.adjacencyToken = sourceToken + s.hotspots = nil + s.hotspotsReady = false + s.analysisEpoch++ + } + if generationWriter.CommitAnalysisSnapshot(header.GraphRevision, installHeader) { + metrics.cacheHit = true + return metrics + } + lastMetrics = metrics + continue + } + } + + expectedRevision := uint64(0) + if generationWriter != nil { + expectedRevision = generationWriter.AnalysisMutationRevision() + } + sourceToken := s.currentCommunityToken() + if generationWriter != nil && sourceToken.analysisRevision != expectedRevision { + lastMetrics = metrics + continue + } + + analysisGraph := newAnalysisSnapshotStore(s.graph) + started := time.Now() + _ = analysisGraph.AllNodesLight() + _ = analysisGraph.AllEdgesLight() + metrics.snapshot = time.Since(started) + + candidate := persistedAnalysis{} + started = time.Now() + candidate.communities, candidate.leiden, _ = analysis.DetectCommunitiesLeidenIncremental(analysisGraph, s.leidenCache) + metrics.leiden = time.Since(started) + started = time.Now() + candidate.processes = analysis.DiscoverProcesses(analysisGraph) + metrics.processes = time.Since(started) + started = time.Now() + candidate.pageRank = analysis.ComputePageRank(analysisGraph) + metrics.pageRank = time.Since(started) + started = time.Now() + candidate.adjacency = analysis.BuildAdjacencySnapshot(analysisGraph) + metrics.adjacency = time.Since(started) + started = time.Now() + candidate.autoConcepts = search.BuildAutoConcepts(analysisGraph) + metrics.autoConcepts = time.Since(started) + started = time.Now() + candidate.hits = analysis.ComputeHITS(analysisGraph) + metrics.hits = time.Since(started) + + var generationHeader graph.AnalysisGenerationHeader + generationReady := false + if generationBacked { + started = time.Now() + storedHeader, stored, err := persistAnalysisGeneration(generationWriter, expectedRevision, candidate) + metrics.cacheSave = time.Since(started) + if err != nil { + metrics.cacheSaveErr = err + if s.logger != nil { + s.logger.Warn("mcp: normalized analysis generation save failed", zap.Error(err)) + } + } else if !stored { + lastMetrics = metrics + continue + } else { + generationHeader = storedHeader + generationReady = true + } + } + + install := func() { + s.communities = candidate.communities + s.leidenCache = candidate.leiden + s.processes = candidate.processes + s.pageRank = candidate.pageRank + s.adjacency = candidate.adjacency + s.autoConcepts = candidate.autoConcepts + s.hits = candidate.hits + s.analysisGeneration = generationHeader + s.analysisGenerationReady = generationReady + s.communitiesToken = sourceToken + s.adjacencyToken = sourceToken + if sink, ok := s.backendStore().(graph.BundleFingerprintSink); ok && candidate.leiden != nil { + sink.SetBundleFingerprints(candidate.leiden.PackageFingerprints()) + } + s.hotspots = nil + s.hotspotsReady = false + s.analysisEpoch++ + } + + installed := false + if generationWriter != nil { + installed = generationWriter.CommitAnalysisSnapshot(expectedRevision, install) + } else if sourceToken == s.currentCommunityToken() { + install() + installed = true + } + if installed { + return metrics + } + lastMetrics = metrics + } + + // Continuous writes denied every bounded publish attempt. Fail closed: a + // result that cannot be tied to a stable graph revision is never exposed. + s.communities = nil + s.leidenCache = nil + s.processes = nil + s.pageRank = nil + s.adjacency = nil + s.autoConcepts = nil + s.hits = nil + s.analysisGeneration = graph.AnalysisGenerationHeader{} + s.analysisGenerationReady = false + s.communitiesToken = communityCacheToken{} + s.adjacencyToken = communityCacheToken{} + s.hotspots = nil + s.hotspotsReady = false + s.analysisEpoch++ + if s.logger != nil { + s.logger.Warn("mcp: analysis publication abandoned after concurrent graph mutations", zap.Int("attempts", maxSnapshotAttempts)) + } + return lastMetrics +} diff --git a/internal/mcp/analysis_persistence_test.go b/internal/mcp/analysis_persistence_test.go new file mode 100644 index 000000000..5e8e83fe2 --- /dev/null +++ b/internal/mcp/analysis_persistence_test.go @@ -0,0 +1,203 @@ +package mcp + +import ( + "fmt" + "reflect" + "sync" + "sync/atomic" + "testing" + + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/graph/store_sqlite" + "go.uber.org/zap" +) + +type countingAnalysisCacheStore struct { + *store_sqlite.Store + store *store_sqlite.Store + allNodes atomic.Int64 + allNodesLight atomic.Int64 + allEdgesLight atomic.Int64 +} + +func newCountingAnalysisCacheStore(store *store_sqlite.Store) *countingAnalysisCacheStore { + return &countingAnalysisCacheStore{Store: store, store: store} +} + +func (s *countingAnalysisCacheStore) AllNodes() []*graph.Node { + s.allNodes.Add(1) + return s.store.AllNodes() +} + +func (s *countingAnalysisCacheStore) AllNodesLight() []*graph.Node { + s.allNodesLight.Add(1) + return s.store.AllNodesLight() +} + +func (s *countingAnalysisCacheStore) AllEdgesLight(kinds ...graph.EdgeKind) []*graph.Edge { + s.allEdgesLight.Add(1) + return s.store.AllEdgesLight(kinds...) +} + +type mutateBeforeAnalysisCommitStore struct { + *countingAnalysisCacheStore + once sync.Once + node *graph.Node +} + +func (s *mutateBeforeAnalysisCommitStore) CommitAnalysisSnapshot(revision uint64, install func()) bool { + s.once.Do(func() { s.store.AddNode(s.node) }) + return s.store.CommitAnalysisSnapshot(revision, install) +} + +func buildAnalysisCacheTestGraph(tb testing.TB, nodeCount int) (*store_sqlite.Store, string) { + tb.Helper() + path := tb.TempDir() + "/analysis.sqlite" + store, err := store_sqlite.Open(path) + if err != nil { + tb.Fatal(err) + } + nodes := make([]*graph.Node, 0, nodeCount) + edges := make([]*graph.Edge, 0, nodeCount*2) + for i := 0; i < nodeCount; i++ { + id := fmt.Sprintf("repo::pkg%d::HandleRequest%d", i/20, i) + nodes = append(nodes, &graph.Node{ + ID: id, Kind: graph.KindFunction, Name: fmt.Sprintf("HandleRequest%d", i), + QualName: fmt.Sprintf("pkg%d.HandleRequest%d", i/20, i), + FilePath: fmt.Sprintf("pkg%d/file%d.go", i/20, i%20), StartLine: i + 1, EndLine: i + 4, + Language: "go", RepoPrefix: "repo", + }) + if i > 0 { + prev := nodes[i-1].ID + edges = append(edges, &graph.Edge{From: prev, To: id, Kind: graph.EdgeCalls, FilePath: nodes[i-1].FilePath, Line: i + 2}) + } + if i > 7 { + edges = append(edges, &graph.Edge{From: nodes[i-7].ID, To: id, Kind: graph.EdgeReferences, FilePath: nodes[i-7].FilePath, Line: i + 1}) + } + } + store.AddBatch(nodes, edges) + return store, path +} + +func populateAnalysisForTest(store graph.Store) (*Server, analysisRunMetrics) { + server := &Server{graph: store, logger: zap.NewNop()} + server.analysisMu.Lock() + metrics := server.populateAnalysisLocked() + server.analysisMu.Unlock() + return server, metrics +} + +func TestAnalysisPersistenceFullHitSurvivesRestart(t *testing.T) { + store, path := buildAnalysisCacheTestGraph(t, 240) + first, cold := populateAnalysisForTest(store) + if cold.cacheHit { + t.Fatal("cold analysis unexpectedly reported a cache hit") + } + wantCommunities := first.communities + wantProcesses := first.processes + wantPageRank := first.pageRank + wantAdjacency := first.adjacency.PersistenceSnapshot() + wantConcepts := first.autoConcepts.PersistenceSnapshot() + wantHITS := first.hits + wantLeiden := first.leidenCache.PersistenceSnapshot() + + header, found, err := store.LoadActiveAnalysisHeader(analysisGenerationFormatVersion) + if err != nil || !found { + _, writerOK := any(store).(graph.AnalysisGenerationStore) + _, queryOK := any(store).(graph.AnalysisQueryStore) + t.Fatalf("saved generation found=%v err=%v save_err=%v save=%s writer=%v query=%v revision=%d token_revision=%d", found, err, cold.cacheSaveErr, cold.cacheSave, writerOK, queryOK, store.AnalysisMutationRevision(), first.communitiesToken.analysisRevision) + } + if header.NodeCount != 240 || header.GenerationID <= 0 { + t.Fatalf("unexpected generation header: %+v", header) + } + t.Logf("analysis generation: cold=%s save=%s id=%d", cold.snapshot+cold.leiden+cold.processes+cold.pageRank+cold.adjacency+cold.autoConcepts+cold.hits, cold.cacheSave, header.GenerationID) + + if err := store.Close(); err != nil { + t.Fatal(err) + } + reopened, err := store_sqlite.Open(path) + if err != nil { + t.Fatal(err) + } + defer reopened.Close() + counting := newCountingAnalysisCacheStore(reopened) + second, warm := populateAnalysisForTest(counting) + if !warm.cacheHit { + t.Fatalf("unchanged restart did not hit complete cache: %+v", warm) + } + if counting.allNodes.Load() != 0 || counting.allNodesLight.Load() != 0 || counting.allEdgesLight.Load() != 0 { + t.Fatalf("cache hit scanned graph: full=%d light=%d edges=%d", counting.allNodes.Load(), counting.allNodesLight.Load(), counting.allEdgesLight.Load()) + } + if second.communities != nil || second.processes != nil || second.pageRank != nil || second.adjacency != nil || second.autoConcepts != nil || second.hits != nil || second.leidenCache != nil { + t.Fatal("warm startup eagerly materialized normalized analysis") + } + if !second.analysisGenerationReady || second.analysisGeneration.GenerationID != header.GenerationID { + t.Fatalf("warm generation receipt not published: %+v", second.analysisGeneration) + } + gotCommunities := second.getCommunities() + gotProcesses := second.getProcesses() + gotPageRank := second.getPageRank() + gotAdjacency := second.getAdjacency() + gotConcepts := second.getAutoConcepts() + gotHITS := second.getHITS() + if !second.ensureLeidenMaterialized() { + t.Fatal("lazy Leiden state did not materialize") + } + checks := map[string]bool{ + "communities": reflect.DeepEqual(gotCommunities, wantCommunities), + "processes": reflect.DeepEqual(gotProcesses, wantProcesses), + "pagerank": reflect.DeepEqual(gotPageRank, wantPageRank), + "adjacency": gotAdjacency != nil && reflect.DeepEqual(gotAdjacency.PersistenceSnapshot(), wantAdjacency), + "concepts": gotConcepts != nil && reflect.DeepEqual(gotConcepts.PersistenceSnapshot(), wantConcepts), + "hits": reflect.DeepEqual(gotHITS, wantHITS), + "leiden": reflect.DeepEqual(second.leidenCache.PersistenceSnapshot(), wantLeiden), + } + for component, equal := range checks { + if !equal { + t.Errorf("lazy %s differs from cold result", component) + } + } + t.Logf("analysis generation: warm header load=%s", warm.cacheLoad) +} + +func TestAnalysisPersistenceRejectsMutationBeforePublish(t *testing.T) { + store, _ := buildAnalysisCacheTestGraph(t, 120) + defer store.Close() + _, cold := populateAnalysisForTest(store) + if cold.cacheHit { + t.Fatal("cold analysis unexpectedly reported a cache hit") + } + + raceNode := &graph.Node{ + ID: "race::published", Kind: graph.KindFunction, Name: "published", + QualName: "race.published", FilePath: "race.go", StartLine: 1, EndLine: 2, Language: "go", + } + wrapped := &mutateBeforeAnalysisCommitStore{ + countingAnalysisCacheStore: newCountingAnalysisCacheStore(store), + node: raceNode, + } + server, metrics := populateAnalysisForTest(wrapped) + if metrics.cacheHit { + t.Fatal("cache hit survived a graph mutation before publication") + } + if server.pageRank == nil { + t.Fatal("stable retry did not publish PageRank") + } + if _, ok := server.pageRank.Scores[raceNode.ID]; !ok { + t.Fatal("stable retry published analysis from before the mutation") + } + if got, want := server.communitiesToken.analysisRevision, store.AnalysisMutationRevision(); got != want { + t.Fatalf("published revision=%d, graph revision=%d", got, want) + } + if server.getPageRank() == nil { + t.Fatal("fresh PageRank was rejected") + } + + store.AddNode(&graph.Node{ + ID: "race::later", Kind: graph.KindFunction, Name: "later", + QualName: "race.later", FilePath: "race.go", StartLine: 4, EndLine: 5, Language: "go", + }) + if server.getPageRank() != nil || server.getCommunities() != nil || server.getAdjacency() != nil { + t.Fatal("analysis readers served a snapshot after a later graph mutation") + } +} diff --git a/internal/mcp/analysis_snapshot.go b/internal/mcp/analysis_snapshot.go new file mode 100644 index 000000000..6ff819a44 --- /dev/null +++ b/internal/mcp/analysis_snapshot.go @@ -0,0 +1,78 @@ +package mcp + +import ( + "sync" + + "github.com/zzet/gortex/internal/graph" +) + +// analysisSnapshotStore gives one RunAnalysis pass a shared, immutable view of +// metadata-free bulk reads. SQLite decodes a fresh copy of every node and edge +// string on each scan. PageRank, HITS, Leiden, adjacency, and auto-concepts all +// retain IDs from those scans, so running them directly against the backend +// leaves several independent copies of the same strings live in their caches. +// +// The wrapper embeds the real store for point reads, counts, mutations, and +// full-node scans. Only the optional lightweight scanners are memoized. It is +// scoped to a single, analysisMu-serialized pass and discarded afterward. +// Returning the same read-only pointers is safe because every participating +// analyzer treats lightweight nodes and edges as immutable. +type analysisSnapshotStore struct { + graph.Store + + nodesOnce sync.Once + nodes []*graph.Node + + edgesOnce sync.Once + edges []*graph.Edge +} + +var ( + _ graph.Store = (*analysisSnapshotStore)(nil) + _ graph.NodeLightScanner = (*analysisSnapshotStore)(nil) + _ graph.LightEdgeScanner = (*analysisSnapshotStore)(nil) +) + +func newAnalysisSnapshotStore(store graph.Store) *analysisSnapshotStore { + return &analysisSnapshotStore{Store: store} +} + +func (s *analysisSnapshotStore) AllNodesLight() []*graph.Node { + s.nodesOnce.Do(func() { + s.nodes = graph.AllNodesLight(s.Store) + }) + return s.nodes +} + +func (s *analysisSnapshotStore) AllEdgesLight(kinds ...graph.EdgeKind) []*graph.Edge { + s.edgesOnce.Do(func() { + // Leiden needs all supported kinds and runs first in RunAnalysis. + // Snapshot the superset once; later call/reference-only consumers + // receive a cheap filtered view that shares endpoint strings. + s.edges = graph.EdgesForKindsLight(s.Store) + }) + if len(kinds) == 0 { + return s.edges + } + + want := make(map[graph.EdgeKind]struct{}, len(kinds)) + for _, kind := range kinds { + if kind != "" { + want[kind] = struct{}{} + } + } + if len(want) == 0 { + return nil + } + + out := make([]*graph.Edge, 0, len(s.edges)) + for _, edge := range s.edges { + if edge == nil { + continue + } + if _, ok := want[edge.Kind]; ok { + out = append(out, edge) + } + } + return out +} diff --git a/internal/mcp/analysis_snapshot_test.go b/internal/mcp/analysis_snapshot_test.go new file mode 100644 index 000000000..60cd77f7a --- /dev/null +++ b/internal/mcp/analysis_snapshot_test.go @@ -0,0 +1,271 @@ +package mcp + +import ( + "fmt" + "runtime" + "strings" + "sync" + "sync/atomic" + "testing" + + "github.com/zzet/gortex/internal/analysis" + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/search" +) + +// freshCountingLightStore models the SQLite lightweight scanners: each scan +// materializes fresh nodes, edges, and string backing arrays. The counters make +// backend round trips observable without coupling these tests to SQLite. +type freshCountingLightStore struct { + graph.Store + nodeScans atomic.Int64 + edgeScans atomic.Int64 +} + +func (s *freshCountingLightStore) AllNodesLight() []*graph.Node { + s.nodeScans.Add(1) + nodes := graph.AllNodesLight(s.Store) + out := make([]*graph.Node, 0, len(nodes)) + for _, node := range nodes { + if node == nil { + out = append(out, nil) + continue + } + clone := *node + clone.ID = strings.Clone(node.ID) + clone.Name = strings.Clone(node.Name) + clone.QualName = strings.Clone(node.QualName) + clone.FilePath = strings.Clone(node.FilePath) + clone.Language = strings.Clone(node.Language) + clone.RepoPrefix = strings.Clone(node.RepoPrefix) + clone.Origin = strings.Clone(node.Origin) + clone.Meta = nil + out = append(out, &clone) + } + return out +} + +func (s *freshCountingLightStore) AllEdgesLight(kinds ...graph.EdgeKind) []*graph.Edge { + s.edgeScans.Add(1) + edges := graph.EdgesForKindsLight(s.Store, kinds...) + out := make([]*graph.Edge, 0, len(edges)) + for _, edge := range edges { + if edge == nil { + out = append(out, nil) + continue + } + clone := *edge + clone.From = strings.Clone(edge.From) + clone.To = strings.Clone(edge.To) + clone.Kind = graph.EdgeKind(strings.Clone(string(edge.Kind))) + clone.Origin = strings.Clone(edge.Origin) + clone.Meta = nil + out = append(out, &clone) + } + return out +} + +func TestAnalysisSnapshotCoalescesRepresentativeAnalyzers(t *testing.T) { + base := analysisSnapshotTestGraph(96, false) + backing := &freshCountingLightStore{Store: base} + snapshot := newAnalysisSnapshotStore(backing) + + got := runRetainedAnalysisCaches(snapshot) + if got.communities == nil || got.pageRank == nil || got.adjacency == nil || got.concepts == nil || got.hits == nil { + t.Fatal("representative analysis returned a nil cache") + } + if scans := backing.nodeScans.Load(); scans != 1 { + t.Fatalf("light node scans = %d, want 1", scans) + } + if scans := backing.edgeScans.Load(); scans != 1 { + t.Fatalf("light edge scans = %d, want 1", scans) + } +} + +func TestAnalysisSnapshotFiltersEdgesWithoutRescanning(t *testing.T) { + base := analysisSnapshotTestGraph(32, false) + backing := &freshCountingLightStore{Store: base} + snapshot := newAnalysisSnapshotStore(backing) + + all := snapshot.AllEdgesLight() + calls := snapshot.AllEdgesLight(graph.EdgeCalls) + references := snapshot.AllEdgesLight(graph.EdgeReferences) + both := snapshot.AllEdgesLight(graph.EdgeReferences, graph.EdgeCalls) + if len(all) != base.EdgeCount() { + t.Fatalf("all edges = %d, want %d", len(all), base.EdgeCount()) + } + if len(calls) != 32 { + t.Fatalf("call edges = %d, want 32", len(calls)) + } + if len(references) != 32 { + t.Fatalf("reference edges = %d, want 32", len(references)) + } + if len(both) != len(calls)+len(references) { + t.Fatalf("combined edges = %d, want %d", len(both), len(calls)+len(references)) + } + for _, edge := range calls { + if edge.Kind != graph.EdgeCalls { + t.Fatalf("call filter returned %q", edge.Kind) + } + } + if got := snapshot.AllEdgesLight(""); got != nil { + t.Fatalf("empty kind filter returned %d edges, want nil", len(got)) + } + if scans := backing.edgeScans.Load(); scans != 1 { + t.Fatalf("light edge scans = %d, want 1", scans) + } + + first := snapshot.AllNodesLight() + second := snapshot.AllNodesLight() + if len(first) == 0 || first[0] != second[0] { + t.Fatal("node snapshot did not reuse materialized node pointers") + } + if scans := backing.nodeScans.Load(); scans != 1 { + t.Fatalf("light node scans = %d, want 1", scans) + } +} + +func TestAnalysisSnapshotConcurrentReaders(t *testing.T) { + base := analysisSnapshotTestGraph(128, false) + backing := &freshCountingLightStore{Store: base} + snapshot := newAnalysisSnapshotStore(backing) + + const readers = 32 + start := make(chan struct{}) + errCh := make(chan string, readers) + var wg sync.WaitGroup + wg.Add(readers) + for i := 0; i < readers; i++ { + go func(i int) { + defer wg.Done() + <-start + if len(snapshot.AllNodesLight()) != 128 { + errCh <- "unexpected node count" + return + } + kind := graph.EdgeCalls + if i%2 == 1 { + kind = graph.EdgeReferences + } + if len(snapshot.AllEdgesLight(kind)) != 128 { + errCh <- "unexpected filtered edge count" + } + }(i) + } + close(start) + wg.Wait() + close(errCh) + for err := range errCh { + t.Error(err) + } + if scans := backing.nodeScans.Load(); scans != 1 { + t.Fatalf("concurrent light node scans = %d, want 1", scans) + } + if scans := backing.edgeScans.Load(); scans != 1 { + t.Fatalf("concurrent light edge scans = %d, want 1", scans) + } +} + +type retainedAnalysisCaches struct { + communities *analysis.CommunityResult + partition *analysis.LeidenPartitionCache + processes *analysis.ProcessResult + pageRank *analysis.PageRankResult + adjacency *analysis.AdjacencySnapshot + concepts *search.AutoConcepts + hits *analysis.HITSResult +} + +func runRetainedAnalysisCaches(store graph.Store) *retainedAnalysisCaches { + communities, partition, _ := analysis.DetectCommunitiesLeidenIncremental(store, nil) + return &retainedAnalysisCaches{ + communities: communities, + partition: partition, + processes: analysis.DiscoverProcesses(store), + pageRank: analysis.ComputePageRank(store), + adjacency: analysis.BuildAdjacencySnapshot(store), + concepts: search.BuildAutoConcepts(store), + hits: analysis.ComputeHITS(store), + } +} + +func analysisSnapshotTestGraph(nodeCount int, longIDs bool) *graph.Graph { + g := graph.New() + nodes := make([]*graph.Node, 0, nodeCount) + edges := make([]*graph.Edge, 0, nodeCount*2+nodeCount/8) + ids := make([]string, nodeCount) + padding := "" + if longIDs { + padding = strings.Repeat("deep-component-segment-", 6) + } + for i := 0; i < nodeCount; i++ { + id := fmt.Sprintf("repo/%spkg%04d/file.go::HandleRequest%05d", padding, i/16, i) + ids[i] = id + nodes = append(nodes, &graph.Node{ + ID: id, + Name: fmt.Sprintf("HandleRequest%05d", i), + QualName: fmt.Sprintf("pkg%04d.HandleRequest%05d", i/16, i), + Kind: graph.KindFunction, + Language: "go", + FilePath: fmt.Sprintf("repo/%spkg%04d/file.go", padding, i/16), + RepoPrefix: "repo", + }) + } + for i := 0; i < nodeCount; i++ { + edges = append(edges, + &graph.Edge{From: ids[i], To: ids[(i+1)%nodeCount], Kind: graph.EdgeCalls}, + &graph.Edge{From: ids[i], To: ids[(i+17)%nodeCount], Kind: graph.EdgeReferences}, + ) + if i%8 == 0 { + edges = append(edges, &graph.Edge{From: ids[i], To: ids[(i+31)%nodeCount], Kind: graph.EdgeImports}) + } + } + g.AddBatch(nodes, edges) + return g +} + +var analysisSnapshotBenchmarkSink *retainedAnalysisCaches + +// BenchmarkAnalysisSnapshotRetainedHeap models the SQLite backend's fresh +// string materialization and reports the heap still live after the analyzer +// outputs are retained and a full GC completes. Run with -benchtime=1x so the +// retained-B/op metric represents one complete analysis epoch. +func BenchmarkAnalysisSnapshotRetainedHeap(b *testing.B) { + base := analysisSnapshotTestGraph(6000, true) + b.Run("baseline", func(b *testing.B) { + benchmarkAnalysisSnapshotRetainedHeap(b, &freshCountingLightStore{Store: base}, false) + }) + b.Run("snapshot", func(b *testing.B) { + benchmarkAnalysisSnapshotRetainedHeap(b, &freshCountingLightStore{Store: base}, true) + }) +} + +func benchmarkAnalysisSnapshotRetainedHeap(b *testing.B, backing *freshCountingLightStore, snapshot bool) { + if b.N != 1 { + b.Skip("retained heap metric requires -benchtime=1x") + } + analysisSnapshotBenchmarkSink = nil + runtime.GC() + var before runtime.MemStats + runtime.ReadMemStats(&before) + + store := graph.Store(backing) + if snapshot { + store = newAnalysisSnapshotStore(backing) + } + b.ReportAllocs() + b.ResetTimer() + result := runRetainedAnalysisCaches(store) + b.StopTimer() + + analysisSnapshotBenchmarkSink = result + runtime.GC() + var after runtime.MemStats + runtime.ReadMemStats(&after) + retained := uint64(0) + if after.HeapAlloc > before.HeapAlloc { + retained = after.HeapAlloc - before.HeapAlloc + } + b.ReportMetric(float64(retained), "retained-B/op") + runtime.KeepAlive(result) +} diff --git a/internal/mcp/analysis_telemetry_test.go b/internal/mcp/analysis_telemetry_test.go new file mode 100644 index 000000000..7f1f79aef --- /dev/null +++ b/internal/mcp/analysis_telemetry_test.go @@ -0,0 +1,43 @@ +package mcp + +import ( + "testing" + + "go.uber.org/zap" + "go.uber.org/zap/zaptest/observer" + + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/runtimeactivity" +) + +func TestRunAnalysisBalancesActivityAndReportsStageTelemetry(t *testing.T) { + t.Setenv("GORTEX_DAEMON_MEMRELEASE", "false") + core, logs := observer.New(zap.InfoLevel) + s := &Server{graph: graph.New(), logger: zap.New(core)} + baseline := runtimeactivity.Current().Active + + s.RunAnalysis() + + if got := runtimeactivity.Current().Active; got != baseline { + t.Fatalf("active work after analysis = %d, want %d", got, baseline) + } + entries := logs.FilterMessage("mcp: analysis pass complete").All() + if len(entries) != 1 { + t.Fatalf("analysis telemetry logs = %d, want 1", len(entries)) + } + fields := entries[0].ContextMap() + for _, key := range []string{ + "snapshot", "leiden", "processes", "pagerank", "adjacency", + "auto_concepts", "hits", "total", + "heap_alloc_before_bytes", "heap_alloc_after_bytes", + "heap_inuse_before_bytes", "heap_inuse_after_bytes", + "heap_idle_before_bytes", "heap_idle_after_bytes", + "heap_released_before_bytes", "heap_released_after_bytes", + "heap_sys_before_bytes", "heap_sys_after_bytes", + "stack_inuse_before_bytes", "stack_inuse_after_bytes", + } { + if _, ok := fields[key]; !ok { + t.Errorf("analysis telemetry missing %q: %#v", key, fields) + } + } +} diff --git a/internal/mcp/analyze_kinds.go b/internal/mcp/analyze_kinds.go index 263015839..a46d691fd 100644 --- a/internal/mcp/analyze_kinds.go +++ b/internal/mcp/analyze_kinds.go @@ -275,7 +275,7 @@ var analyzeKindDescriptions = map[string]string{ "pubsub": "Pub/sub topics with publishers + subscribers (NATS/Kafka/RabbitMQ/Redis/EventEmitter/Socket.IO)", "race_writes": "Concurrent-write race detection", "ref_facts": "Resolved-reference facts — each reference edge + the provenance tier that resolved it", - "releases": "Stamp meta.added_in on file nodes from git tags", + "releases": "Read the precomputed release timeline and per-tag files from graph metadata", "resolution_outcomes": "Classify unresolved call/ref edges by why the resolver gave up (ambiguous/out-of-scope/cross-language/stub/no-def)", "retrieval_log": "Mine the retrieval query log — zero-result queries + per-tool latency/result-size rollups for recall tuning", "review": "Idiomatic/correctness rulepack (NPE, thread-safety check-then-act, N+1, logic-error; Go+Python) — the engine behind gortex review", diff --git a/internal/mcp/batch_edit_hetero_test.go b/internal/mcp/batch_edit_hetero_test.go index 99e1716a7..6b7287309 100644 --- a/internal/mcp/batch_edit_hetero_test.go +++ b/internal/mcp/batch_edit_hetero_test.go @@ -15,6 +15,9 @@ import ( // helper's handler map does not register batch_edit. func callBatchEdit(t *testing.T, srv *Server, args map[string]any) *mcplib.CallToolResult { t.Helper() + if os.Getenv(batchTransactionDirEnv) == "" { + t.Setenv(batchTransactionDirEnv, filepath.Join(t.TempDir(), "transactions")) + } req := mcplib.CallToolRequest{} req.Params.Name = "batch_edit" req.Params.Arguments = args diff --git a/internal/mcp/batch_transaction.go b/internal/mcp/batch_transaction.go new file mode 100644 index 000000000..3997e42d2 --- /dev/null +++ b/internal/mcp/batch_transaction.go @@ -0,0 +1,795 @@ +package mcp + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "sort" + "strings" + "sync" + "time" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/query" +) + +const batchTransactionVersion = 1 + +type plannedBatchEdit struct { + edit batchEditItem + op string + order int + file string + absPath string + idx int + node *graph.Node + err string +} + +type batchFileBuffer struct { + absPath string + relPath string + mode os.FileMode + original []byte + content []byte +} + +type batchTransactionFile struct { + Path string `json:"path"` + RelativePath string `json:"relative_path,omitempty"` + Mode os.FileMode `json:"mode"` + BeforeSHA256 string `json:"before_sha256"` + AfterSHA256 string `json:"after_sha256"` + Backup string `json:"backup,omitempty"` + ReindexReceipt string `json:"reindex_receipt,omitempty"` + ReindexGeneration uint64 `json:"reindex_generation,omitempty"` +} + +type batchTransactionReceipt struct { + Version int `json:"version"` + TransactionID string `json:"transaction_id"` + Fingerprint string `json:"fingerprint"` + Status string `json:"status"` + DiskStatus string `json:"disk_status"` + GraphStatus string `json:"graph_status"` + Error string `json:"error,omitempty"` + Results []batchEditResult `json:"results,omitempty"` + Summary map[string]int `json:"summary,omitempty"` + Files []batchTransactionFile `json:"files,omitempty"` + StartedAt time.Time `json:"started_at"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + Recovered bool `json:"recovered,omitempty"` +} + +type batchTransactionState struct { + fingerprint string + done chan struct{} + doneOnce sync.Once + graphMu sync.Mutex + mu sync.RWMutex + receipt batchTransactionReceipt +} + +func (state *batchTransactionState) snapshot() batchTransactionReceipt { + state.mu.RLock() + defer state.mu.RUnlock() + copyReceipt := state.receipt + copyReceipt.Results = append([]batchEditResult(nil), state.receipt.Results...) + copyReceipt.Files = append([]batchTransactionFile(nil), state.receipt.Files...) + if state.receipt.Summary != nil { + copyReceipt.Summary = make(map[string]int, len(state.receipt.Summary)) + for key, value := range state.receipt.Summary { + copyReceipt.Summary[key] = value + } + } + return copyReceipt +} + +func (state *batchTransactionState) publish(receipt batchTransactionReceipt, terminal bool) { + state.mu.Lock() + state.receipt = receipt + state.mu.Unlock() + if terminal { + state.doneOnce.Do(func() { close(state.done) }) + } +} + +func batchEditFingerprint(edits []batchEditItem) string { + payload, _ := json.Marshal(edits) + sum := sha256.Sum256(payload) + return hex.EncodeToString(sum[:]) +} + +func normalizeBatchTransactionID(requested, fingerprint string) (string, error) { + id := strings.TrimSpace(requested) + if id == "" { + // Payload-derived IDs collide across repositories, worktrees, and later + // intentional repetitions of the same edit. Idempotency therefore uses + // an explicit caller key; ordinary calls receive a unique receipt ID. + var nonce [12]byte + if _, err := rand.Read(nonce[:]); err != nil { + return "", fmt.Errorf("generate transaction_id: %w", err) + } + return "batch-" + fingerprint[:12] + "-" + hex.EncodeToString(nonce[:]), nil + } + if len(id) > 200 { + return "", fmt.Errorf("transaction_id exceeds 200 characters") + } + for _, r := range id { + if r < 0x20 || r == 0x7f { + return "", fmt.Errorf("transaction_id contains control characters") + } + } + return id, nil +} + +func batchSummary(results []batchEditResult) map[string]int { + summary := map[string]int{"applied": 0, "failed": 0, "skipped": 0, "total": len(results)} + for _, result := range results { + switch result.Status { + case "applied": + summary["applied"]++ + case "failed": + summary["failed"]++ + case "skipped": + summary["skipped"]++ + } + } + return summary +} + +func batchFailureResults(plans []plannedBatchEdit, failedAt int, message string) []batchEditResult { + results := make([]batchEditResult, len(plans)) + for i, plan := range plans { + result := batchEditResult{Op: plan.op, SymbolID: plan.edit.SymbolID, FilePath: plan.file, Status: "skipped"} + if i == failedAt { + result.Status = "failed" + result.Error = message + } + results[i] = result + } + return results +} + +func markBatchCommitFailure(results []batchEditResult, failedPath, message string) []batchEditResult { + marked := append([]batchEditResult(nil), results...) + for i := range marked { + marked[i].Status = "skipped" + marked[i].Error = "" + if marked[i].FilePath == failedPath { + marked[i].Status = "failed" + marked[i].Error = message + } + } + return marked +} + +func (s *Server) planBatchTransaction(ctx context.Context, edits []batchEditItem, resolvePaths bool) []plannedBatchEdit { + plans := make([]plannedBatchEdit, 0, len(edits)) + for i, edit := range edits { + plan := plannedBatchEdit{edit: edit, op: edit.kind(), idx: i, order: 50} + switch plan.op { + case "edit_file": + plan.order = 1000 + plan.file = edit.Path + switch { + case edit.Path == "": + plan.err = "edit_file op requires path" + case edit.OldString == edit.NewString: + plan.err = "old_string and new_string are identical" + default: + absPath, relPath, err := s.resolveFilePath(edit.Path) + if err != nil { + plan.err = err.Error() + } else { + plan.absPath, plan.file = absPath, relPath + } + } + default: + switch { + case edit.SymbolID == "": + plan.err = "edit_symbol op requires id" + case edit.OldSource == edit.NewSource: + plan.err = "old_source and new_source are identical" + default: + plan.node = s.engineFor(ctx).GetSymbol(edit.SymbolID) + if plan.node == nil { + plan.err = "symbol not found: " + edit.SymbolID + break + } + plan.file = plan.node.FilePath + if plan.node.StartLine == 0 || plan.node.EndLine == 0 { + plan.err = "symbol has no line range" + break + } + if resolvePaths { + absPath, err := s.resolveNodePath(plan.node) + if err != nil { + plan.err = err.Error() + break + } + plan.absPath = absPath + } + switch plan.node.Kind { + case graph.KindInterface, graph.KindType: + plan.order = 0 + case graph.KindFunction, graph.KindMethod: + plan.order = 20 + } + } + } + plans = append(plans, plan) + } + + // Preserve the established definitions-before-callers behavior without + // performing graph work while disk locks are held. + for i := range plans { + if plans[i].node == nil || (plans[i].node.Kind != graph.KindFunction && plans[i].node.Kind != graph.KindMethod) { + continue + } + callers := s.engineFor(ctx).GetCallers(plans[i].edit.SymbolID, query.QueryOptions{Depth: 1, Limit: 100, Detail: "brief"}) + for _, caller := range callers.Nodes { + for j := range plans { + if caller.ID == plans[j].edit.SymbolID && plans[j].edit.SymbolID != plans[i].edit.SymbolID { + plans[i].order = 10 + break + } + } + } + } + + sort.SliceStable(plans, func(i, j int) bool { + if plans[i].order != plans[j].order { + return plans[i].order < plans[j].order + } + if plans[i].file != plans[j].file { + return plans[i].file < plans[j].file + } + return plans[i].idx < plans[j].idx + }) + return plans +} + +func readBatchBuffers(plans []plannedBatchEdit) (map[string]*batchFileBuffer, []string, error) { + buffers := make(map[string]*batchFileBuffer) + paths := make([]string, 0) + for _, plan := range plans { + if _, exists := buffers[plan.absPath]; exists { + continue + } + content, err := os.ReadFile(plan.absPath) + if err != nil { + return nil, nil, fmt.Errorf("could not read %s: %w", plan.file, err) + } + mode := os.FileMode(0o644) + if info, statErr := os.Stat(plan.absPath); statErr == nil { + mode = info.Mode().Perm() + } + buffers[plan.absPath] = &batchFileBuffer{ + absPath: plan.absPath, relPath: plan.file, mode: mode, + original: append([]byte(nil), content...), content: append([]byte(nil), content...), + } + paths = append(paths, plan.absPath) + } + sort.Strings(paths) + return buffers, paths, nil +} + +func applyBatchFileToContent(edit batchEditItem, content []byte) ([]byte, bool, error) { + fileStr := string(content) + matches := findEOLMatches(fileStr, edit.OldString) + if matches.count == 0 { + return nil, false, fmt.Errorf("old_string not found in file") + } + if matches.count > 1 && !edit.ReplaceAll { + return nil, false, fmt.Errorf("old_string matches %d locations%s. Provide a larger fragment for uniqueness or set replace_all=true", matches.count, matchSpansHint(fileStr, matches.spans)) + } + var newContent string + normalized := false + switch { + case matches.normalized: + limit := 1 + if edit.ReplaceAll { + limit = -1 + } + newContent = spliceSpansEOL(fileStr, matches.spans, edit.NewString, limit) + normalized = true + case edit.ReplaceAll: + newContent = strings.ReplaceAll(fileStr, edit.OldString, edit.NewString) + default: + newContent = strings.Replace(fileStr, edit.OldString, edit.NewString, 1) + } + if newContent == fileStr { + return nil, normalized, fmt.Errorf("old_string and new_string are identical after line-ending normalization") + } + return []byte(newContent), normalized, nil +} + +func applyBatchSymbolToContent(edit batchEditItem, node *graph.Node, content []byte) ([]byte, bool, error) { + fileStr := string(content) + lines := strings.Split(fileStr, "\n") + regionMatches := findEOLMatches(fileStr, edit.OldSource) + symbolStart := 0 + rangeMatched := false + if node.StartLine <= len(lines) && node.EndLine <= len(lines) { + symbolSource := strings.Join(lines[node.StartLine-1:node.EndLine], "\n") + effectiveStart := node.StartLine + if findEOLMatches(symbolSource, edit.OldSource).count == 0 { + expandedStart := node.StartLine - 1 + for expandedStart > 0 { + trimmed := strings.TrimSpace(lines[expandedStart-1]) + if strings.HasPrefix(trimmed, "//") || strings.HasPrefix(trimmed, "/*") || strings.HasPrefix(trimmed, "*") || trimmed == "" { + expandedStart-- + } else { + break + } + } + if expandedStart < node.StartLine-1 { + expanded := strings.Join(lines[expandedStart:node.EndLine], "\n") + if findEOLMatches(expanded, edit.OldSource).count > 0 { + symbolSource = expanded + effectiveStart = expandedStart + 1 + } + } + } + for i := 0; i < effectiveStart-1 && i < len(lines); i++ { + symbolStart += len(lines[i]) + 1 + } + symbolEnd := min(symbolStart+len(symbolSource), len(fileStr)) + candidate := findEOLMatches(fileStr[symbolStart:symbolEnd], edit.OldSource) + if candidate.count == 1 { + regionMatches = candidate + rangeMatched = true + } + } + if !rangeMatched { + symbolStart = 0 + switch regionMatches.count { + case 0: + return nil, false, fmt.Errorf("old_source not found within symbol or current file") + case 1: + default: + return nil, false, fmt.Errorf("symbol range is stale and old_source is not unique in the current file") + } + } + span := regionMatches.spans[0] + editStart, editEnd := symbolStart+span.start, symbolStart+span.end + effectiveNew := edit.NewSource + if regionMatches.normalized { + effectiveNew = adaptToDominantEOL(edit.NewSource, fileStr[editStart:editEnd]) + } + newContent := fileStr[:editStart] + effectiveNew + fileStr[editEnd:] + if newContent == fileStr { + return nil, regionMatches.normalized, fmt.Errorf("old_source and new_source are identical after line-ending normalization") + } + return []byte(newContent), regionMatches.normalized, nil +} + +func applyBatchPlans(plans []plannedBatchEdit, buffers map[string]*batchFileBuffer) ([]batchEditResult, int, error) { + results := make([]batchEditResult, 0, len(plans)) + for i, plan := range plans { + buffer := buffers[plan.absPath] + result := batchEditResult{Op: plan.op, SymbolID: plan.edit.SymbolID, FilePath: plan.file, Status: "validated"} + var ( + content []byte + normalized bool + err error + ) + if plan.op == "edit_file" { + content, normalized, err = applyBatchFileToContent(plan.edit, buffer.content) + } else { + content, normalized, err = applyBatchSymbolToContent(plan.edit, plan.node, buffer.content) + } + if err != nil { + return batchFailureResults(plans, i, err.Error()), i, err + } + buffer.content = content + result.EOLNormalized = normalized + results = append(results, result) + } + return results, -1, nil +} + +func (s *Server) runBatchTransaction(ctx context.Context, edits []batchEditItem, requestedID string) (batchTransactionReceipt, error) { + fingerprint := batchEditFingerprint(edits) + transactionID, err := normalizeBatchTransactionID(requestedID, fingerprint) + if err != nil { + return batchTransactionReceipt{}, err + } + state, action, err := s.loadOrCreateBatchTransaction(transactionID, fingerprint) + if err != nil { + return batchTransactionReceipt{}, err + } + switch action { + case "existing": + return waitBatchTransaction(ctx, state), nil + case "recover": + s.recoverBatchTransaction(ctx, state) + return state.snapshot(), nil + case "refresh_graph": + s.refreshBatchGraph(state) + return state.snapshot(), nil + } + + receipt := state.snapshot() + plans := s.planBatchTransaction(ctx, edits, true) + for i, plan := range plans { + if plan.err != "" { + receipt.Results = batchFailureResults(plans, i, plan.err) + receipt.Summary = batchSummary(receipt.Results) + return s.finishBatchTransaction(state, receipt, "aborted", "unchanged", "not_started", plan.err), nil + } + } + paths := make([]string, 0, len(plans)) + for _, plan := range plans { + paths = append(paths, plan.absPath) + } + release, lockErr := acquireMutationPaths(ctx, paths) + if lockErr != nil { + receipt.Results = batchFailureResults(plans, 0, "edit cancelled while waiting for exclusive file access: "+lockErr.Error()) + receipt.Summary = batchSummary(receipt.Results) + return s.finishBatchTransaction(state, receipt, "aborted", "unchanged", "not_started", receipt.Results[0].Error), nil + } + defer release() + if err := ctx.Err(); err != nil { + receipt.Results = batchFailureResults(plans, 0, "edit cancelled before commit: "+err.Error()) + receipt.Summary = batchSummary(receipt.Results) + return s.finishBatchTransaction(state, receipt, "aborted", "unchanged", "not_started", receipt.Results[0].Error), nil + } + + buffers, orderedPaths, readErr := readBatchBuffers(plans) + if readErr != nil { + receipt.Results = batchFailureResults(plans, 0, readErr.Error()) + receipt.Summary = batchSummary(receipt.Results) + return s.finishBatchTransaction(state, receipt, "aborted", "unchanged", "not_started", readErr.Error()), nil + } + results, _, applyErr := applyBatchPlans(plans, buffers) + receipt.Results = results + receipt.Summary = batchSummary(results) + if applyErr != nil { + return s.finishBatchTransaction(state, receipt, "aborted", "unchanged", "not_started", applyErr.Error()), nil + } + if err := s.prepareBatchJournal(&receipt, buffers, orderedPaths); err != nil { + return s.finishBatchTransaction(state, receipt, "aborted", "unchanged", "not_started", "could not persist transaction journal: "+err.Error()), nil + } + receipt.Status, receipt.DiskStatus, receipt.GraphStatus = "prepared", "unchanged", "not_started" + state.publish(receipt, false) + if err := ctx.Err(); err != nil { + return s.finishBatchTransaction(state, receipt, "aborted", "unchanged", "not_started", "edit cancelled before commit: "+err.Error()), nil + } + + // Commit is deliberately non-cancellable. Once the first rename succeeds, + // every remaining write or rollback must run to a terminal disk state. + writer := s.batchDurability().writeFile + if s.batchWriteOverride != nil { + // Preserve the target-only fault-injection seam used by commit tests; + // journal and rollback writes always retain the durability discipline. + writer = s.batchWriteOverride + } + finishCommitFailure := func(failedPath, message string) batchTransactionReceipt { + status, rollbackErr := s.rollbackBatchReceipt(receipt) + if rollbackErr != nil { + message += "; " + rollbackErr.Error() + } + diskStatus := "rolled_back" + if status == "recovery_conflict" { + diskStatus = "conflict" + } + receipt.Results = markBatchCommitFailure(receipt.Results, failedPath, message) + receipt.Summary = batchSummary(receipt.Results) + return s.finishBatchTransaction(state, receipt, status, diskStatus, "not_started", message) + } + for _, path := range orderedPaths { + buffer := buffers[path] + if writeErr := writer(path, buffer.content, buffer.mode); writeErr != nil { + message := fmt.Sprintf("could not commit %s: %v", buffer.relPath, writeErr) + return finishCommitFailure(buffer.relPath, message), nil + } + } + if syncErr := s.syncBatchDirectories(batchPathDirectories(orderedPaths)...); syncErr != nil { + failedPath := buffers[orderedPaths[len(orderedPaths)-1]].relPath + message := "could not persist committed files: " + syncErr.Error() + return finishCommitFailure(failedPath, message), nil + } + + for i := range receipt.Results { + receipt.Results[i].Status = "applied" + } + receipt.Summary = batchSummary(receipt.Results) + receipt.Status, receipt.DiskStatus, receipt.GraphStatus = "committed", "committed", "pending" + receipt.Error = "" + if persistErr := s.persistBatchManifest(receipt); persistErr != nil { + receipt.Error = "disk committed; terminal journal update failed: " + persistErr.Error() + } + state.publish(receipt, false) + + for _, plan := range plans { + session := s.sessionFor(ctx) + session.recordModified(plan.file) + if plan.edit.SymbolID != "" { + session.recordSymbol(plan.edit.SymbolID) + } + } + s.refreshBatchGraph(state) + return state.snapshot(), nil +} + +func waitBatchTransaction(ctx context.Context, state *batchTransactionState) batchTransactionReceipt { + select { + case <-state.done: + return state.snapshot() + case <-ctx.Done(): + receipt := state.snapshot() + if receipt.Status == "preparing" || receipt.Status == "prepared" { + receipt.Error = "transaction continues independently: " + ctx.Err().Error() + } + return receipt + } +} + +func (s *Server) batchTransactionStatus(ctx context.Context, transactionID string) (batchTransactionReceipt, error) { + id := strings.TrimSpace(transactionID) + if id == "" { + return batchTransactionReceipt{}, fmt.Errorf("transaction_id is required for status") + } + if value, ok := s.batchTransactions.Load(id); ok { + state, valid := value.(*batchTransactionState) + if !valid { + return batchTransactionReceipt{}, fmt.Errorf("invalid transaction state for %q", id) + } + if existingBatchTransactionAction(state) == "refresh_graph" { + s.refreshBatchGraph(state) + } + return state.snapshot(), nil + } + state, action, err := s.loadOrCreateBatchTransaction(id, "") + if err != nil { + return batchTransactionReceipt{}, err + } + switch action { + case "recover": + s.recoverBatchTransaction(ctx, state) + case "refresh_graph": + s.refreshBatchGraph(state) + } + return state.snapshot(), nil +} + +func (s *Server) beginBatchGraphRefresh(absPath string) mutationReindexOutcome { + ctx := context.Background() + if watcher := s.currentWatcher(); watcher != nil { + if scheduler, ok := watcher.(mutationScheduler); ok { + ticket, err := scheduler.EnqueueFileMutation(ctx, absPath) + if err != nil { + return mutationReindexOutcome{Err: err} + } + if ticket != nil { + receipt := s.trackMutationTicket(ticket) + return receipt.outcome(true) + } + } + } + return mutationReindexOutcome{Reindexed: s.reindexFile(absPath)} +} + +func (s *Server) waitBatchGraphReceipts(files []batchTransactionFile) { + deadline := time.Now().Add(s.mutationWaitDuration()) + for _, file := range files { + if file.ReindexReceipt == "" { + continue + } + outcome, ok := s.mutationReceiptState(file.ReindexReceipt) + if !ok || !outcome.Pending { + continue + } + value, ok := s.mutationReceipts.Load(file.ReindexReceipt) + if !ok { + continue + } + receipt, ok := value.(*mutationReceipt) + if !ok { + continue + } + remaining := time.Until(deadline) + if remaining <= 0 { + return + } + timer := time.NewTimer(remaining) + select { + case <-receipt.done: + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + case <-timer.C: + return + } + } +} + +func (s *Server) refreshBatchGraph(state *batchTransactionState) { + state.graphMu.Lock() + defer state.graphMu.Unlock() + + receipt := state.snapshot() + if receipt.Status != "committed" { + state.publish(receipt, true) + return + } + if receipt.GraphStatus == "fresh" { + state.publish(receipt, true) + return + } + + outcomes := make(map[string]mutationReindexOutcome, len(receipt.Files)) + for i := range receipt.Files { + file := &receipt.Files[i] + if file.ReindexReceipt != "" { + if outcome, ok := s.mutationReceiptState(file.ReindexReceipt); ok { + outcomes[file.Path] = outcome + continue + } + // Receipt state is daemon-local and can expire. A durable committed + // transaction safely re-admits the file after restart or retention. + file.ReindexReceipt = "" + file.ReindexGeneration = 0 + } + outcome := s.beginBatchGraphRefresh(file.Path) + outcomes[file.Path] = outcome + file.ReindexReceipt = outcome.Receipt + file.ReindexGeneration = outcome.Generation + } + + // Admit the entire file set before waiting. The bounded wait is shared by + // the batch, rather than multiplied by the number of files. + s.waitBatchGraphReceipts(receipt.Files) + graphStatus := "fresh" + for i := range receipt.Files { + file := &receipt.Files[i] + outcome := outcomes[file.Path] + if file.ReindexReceipt != "" { + if latest, ok := s.mutationReceiptState(file.ReindexReceipt); ok { + outcome = latest + outcomes[file.Path] = latest + } + } + switch { + case outcome.Err != nil: + graphStatus = "failed" + // A later status/retry call may re-admit a transient failure. + file.ReindexReceipt = "" + case outcome.Pending: + if graphStatus != "failed" { + graphStatus = "pending" + } + case !outcome.Reindexed: + graphStatus = "failed" + file.ReindexReceipt = "" + } + } + for i := range receipt.Results { + for _, file := range receipt.Files { + if receipt.Results[i].FilePath != file.RelativePath { + continue + } + outcome := outcomes[file.Path] + receipt.Results[i].Reindexed = outcome.Reindexed + receipt.Results[i].ReindexPending = outcome.Pending + receipt.Results[i].ReindexReceipt = outcome.Receipt + receipt.Results[i].ReindexGeneration = outcome.Generation + receipt.Results[i].ReindexAppliedGeneration = outcome.AppliedGeneration + receipt.Results[i].ReindexError = "" + if outcome.Err != nil { + receipt.Results[i].ReindexError = outcome.Err.Error() + } + } + } + receipt.GraphStatus = graphStatus + if receipt.CompletedAt == nil { + now := time.Now().UTC() + receipt.CompletedAt = &now + } + persistErr := s.persistBatchManifest(receipt) + if persistErr != nil { + if receipt.Error == "" { + receipt.Error = "terminal journal update failed: " + persistErr.Error() + } else { + receipt.Error += "; terminal journal update failed: " + persistErr.Error() + } + } + state.publish(receipt, true) + if persistErr == nil && batchReceiptCleanupSafe(receipt) { + _ = s.cleanupBatchBackups(receipt) + } +} + +func (s *Server) finishBatchTransaction(state *batchTransactionState, receipt batchTransactionReceipt, status, diskStatus, graphStatus, message string) batchTransactionReceipt { + receipt.Status, receipt.DiskStatus, receipt.GraphStatus, receipt.Error = status, diskStatus, graphStatus, message + now := time.Now().UTC() + receipt.CompletedAt = &now + persistErr := s.persistBatchManifest(receipt) + if persistErr != nil { + if receipt.Error == "" { + receipt.Error = "terminal journal update failed: " + persistErr.Error() + } else { + receipt.Error += "; terminal journal update failed: " + persistErr.Error() + } + } + state.publish(receipt, true) + if persistErr == nil && batchReceiptCleanupSafe(receipt) { + _ = s.cleanupBatchBackups(receipt) + } + return receipt +} + +func (s *Server) handleAtomicBatchEdit(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := req.GetArguments() + transactionID, _ := args["transaction_id"].(string) + statusOnly, _ := args["status_only"].(bool) + rawEdits, hasEdits := args["edits"] + if statusOnly || !hasEdits || rawEdits == nil { + receipt, err := s.batchTransactionStatus(ctx, transactionID) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + if isCompact(req) { + return mcp.NewToolResultText(fmt.Sprintf("%s %s disk=%s graph=%s\n", receipt.TransactionID, receipt.Status, receipt.DiskStatus, receipt.GraphStatus)), nil + } + return s.respondJSONOrTOON(ctx, req, receipt) + } + + edits, err := parseBatchEdits(rawEdits) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + if len(edits) == 0 { + return mcp.NewToolResultError("edits array is empty"), nil + } + if dryRun, _ := args["dry_run"].(bool); dryRun { + plans := s.planBatchTransaction(ctx, edits, false) + plan := make([]map[string]any, 0, len(plans)) + for i, item := range plans { + status := "planned" + if item.err != "" { + status = "failed: " + item.err + } + plan = append(plan, map[string]any{ + "order": i + 1, "op": item.op, "id": item.edit.SymbolID, + "path": item.file, "status": status, + }) + } + if isCompact(req) { + var out strings.Builder + for _, item := range plan { + fmt.Fprintf(&out, "%s %s %s\n", item["op"], item["path"], item["status"]) + } + return mcp.NewToolResultText(out.String()), nil + } + return s.respondJSONOrTOON(ctx, req, map[string]any{"plan": plan, "dry_run": true, "total": len(plan)}) + } + + receipt, err := s.runBatchTransaction(ctx, edits, transactionID) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + if isCompact(req) { + var out strings.Builder + fmt.Fprintf(&out, "%s %s disk=%s graph=%s\n", receipt.TransactionID, receipt.Status, receipt.DiskStatus, receipt.GraphStatus) + for _, result := range receipt.Results { + target := result.SymbolID + if target == "" { + target = result.FilePath + } + fmt.Fprintf(&out, "%s %s %s\n", result.Op, target, result.Status) + } + return mcp.NewToolResultText(out.String()), nil + } + return s.respondJSONOrTOON(ctx, req, receipt) +} diff --git a/internal/mcp/batch_transaction_durability.go b/internal/mcp/batch_transaction_durability.go new file mode 100644 index 000000000..d1c1b91d3 --- /dev/null +++ b/internal/mcp/batch_transaction_durability.go @@ -0,0 +1,139 @@ +package mcp + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + "runtime" +) + +// batchDurabilityOps is a narrow test seam around the filesystem operations +// that define an atomic batch transaction's crash-durability boundaries. +// Production always uses the defaults returned by batchDurability. +type batchDurabilityOps struct { + writeFile func(string, []byte, os.FileMode) error + syncDirectory func(string) error + removeFile func(string) error +} + +func (s *Server) batchDurability() batchDurabilityOps { + ops := batchDurabilityOps{ + writeFile: durableAtomicWriteFile, + syncDirectory: syncBatchDirectory, + removeFile: os.Remove, + } + if s == nil || s.batchDurabilityOverride == nil { + return ops + } + if override := s.batchDurabilityOverride; override.writeFile != nil { + ops.writeFile = override.writeFile + } + if override := s.batchDurabilityOverride; override.syncDirectory != nil { + ops.syncDirectory = override.syncDirectory + } + if override := s.batchDurabilityOverride; override.removeFile != nil { + ops.removeFile = override.removeFile + } + return ops +} + +// durableAtomicWriteFile makes the new inode durable before publishing it with +// rename. The containing directory is deliberately synced by the transaction +// coordinator, which deduplicates directory syncs across every file in a batch. +func durableAtomicWriteFile(path string, content []byte, mode os.FileMode) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("create parent directory: %w", err) + } + + tmp, err := os.CreateTemp(dir, ".gortex-batch-*") + if err != nil { + return fmt.Errorf("create temporary file: %w", err) + } + tmpPath := tmp.Name() + closed := false + defer func() { + if !closed { + _ = tmp.Close() + } + _ = os.Remove(tmpPath) + }() + + if n, writeErr := tmp.Write(content); writeErr != nil { + return fmt.Errorf("write temporary file: %w", writeErr) + } else if n != len(content) { + return fmt.Errorf("write temporary file: %w", io.ErrShortWrite) + } + if err := tmp.Chmod(mode); err != nil { + return fmt.Errorf("set temporary file mode: %w", err) + } + if err := tmp.Sync(); err != nil { + return fmt.Errorf("sync temporary file: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("close temporary file: %w", err) + } + closed = true + if err := os.Rename(tmpPath, path); err != nil { + return fmt.Errorf("replace target file: %w", err) + } + return nil +} + +// syncBatchDirectories preserves first-seen order while syncing each distinct +// directory exactly once. Callers can therefore express ordering constraints +// (for example transaction directory before its parent) without paying one +// directory fsync per edited file. +func (s *Server) syncBatchDirectories(dirs ...string) error { + ops := s.batchDurability() + seen := make(map[string]struct{}, len(dirs)) + for _, dir := range dirs { + if dir == "" { + continue + } + clean := filepath.Clean(dir) + if _, ok := seen[clean]; ok { + continue + } + seen[clean] = struct{}{} + if err := ops.syncDirectory(clean); err != nil { + return fmt.Errorf("sync directory %s: %w", clean, err) + } + } + return nil +} + +func batchFileDirectories(files []batchTransactionFile) []string { + dirs := make([]string, 0, len(files)) + for _, file := range files { + dirs = append(dirs, filepath.Dir(file.Path)) + } + return dirs +} + +func batchPathDirectories(paths []string) []string { + dirs := make([]string, 0, len(paths)) + for _, path := range paths { + dirs = append(dirs, filepath.Dir(path)) + } + return dirs +} + +// Directory handles cannot be flushed portably on Windows. The file itself is +// still flushed before rename there; Unix-family hosts additionally persist the +// directory entry. Unsupported directory fsync errors are returned rather than +// silently weakening durability. +func syncBatchDirectory(path string) error { + if runtime.GOOS == "windows" || runtime.GOOS == "plan9" || runtime.GOOS == "js" || runtime.GOOS == "wasip1" { + return nil + } + dir, err := os.Open(path) + if err != nil { + return err + } + syncErr := dir.Sync() + closeErr := dir.Close() + return errors.Join(syncErr, closeErr) +} diff --git a/internal/mcp/batch_transaction_durability_test.go b/internal/mcp/batch_transaction_durability_test.go new file mode 100644 index 000000000..7e9f87ed2 --- /dev/null +++ b/internal/mcp/batch_transaction_durability_test.go @@ -0,0 +1,257 @@ +package mcp + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" +) + +func durabilityEventIndex(events []string, prefix string, after int) int { + for i := after + 1; i < len(events); i++ { + if strings.HasPrefix(events[i], prefix) { + return i + } + } + return -1 +} + +func TestAtomicBatchDurabilityOrderAndDirectoryDeduplication(t *testing.T) { + var scheduled atomic.Int64 + s := newAtomicBatchTestServer(t, mutationTestWatcher{scheduled: &scheduled}) + targetDir := t.TempDir() + paths := []string{ + writeAtomicBatchFixture(t, targetDir, "a.txt", "a0\n"), + writeAtomicBatchFixture(t, targetDir, "b.txt", "b0\n"), + } + transactionID := "durability-order" + transactionDir := batchTransactionDir(transactionID) + transactionRoot := filepath.Dir(transactionDir) + var events []string + s.batchDurabilityOverride = &batchDurabilityOps{ + writeFile: func(path string, content []byte, mode os.FileMode) error { + base := filepath.Base(path) + switch { + case base == "manifest.json": + var receipt batchTransactionReceipt + if err := json.Unmarshal(content, &receipt); err != nil { + t.Fatalf("decode manifest event: %v", err) + } + events = append(events, fmt.Sprintf("manifest:%s:%s", receipt.Status, receipt.GraphStatus)) + case strings.HasPrefix(base, "before-"): + events = append(events, "backup:"+base) + default: + events = append(events, "target:"+base) + } + return durableAtomicWriteFile(path, content, mode) + }, + syncDirectory: func(path string) error { + switch filepath.Clean(path) { + case filepath.Clean(targetDir): + events = append(events, "sync:target") + case filepath.Clean(transactionDir): + events = append(events, "sync:journal") + case filepath.Clean(transactionRoot): + events = append(events, "sync:journal-root") + default: + events = append(events, "sync:"+filepath.Clean(path)) + } + return syncBatchDirectory(path) + }, + removeFile: func(path string) error { + events = append(events, "remove:"+filepath.Base(path)) + return os.Remove(path) + }, + } + + receipt, err := s.runBatchTransaction(context.Background(), []batchEditItem{ + atomicFileEdit(paths[0], "a0", "a1"), + atomicFileEdit(paths[1], "b0", "b1"), + }, transactionID) + if err != nil { + t.Fatal(err) + } + if receipt.Status != "committed" || receipt.DiskStatus != "committed" || receipt.GraphStatus != "fresh" { + t.Fatalf("receipt = %+v", receipt) + } + + backupA := durabilityEventIndex(events, "backup:before-0000.bin", -1) + backupB := durabilityEventIndex(events, "backup:before-0001.bin", backupA) + prepared := durabilityEventIndex(events, "manifest:prepared:", backupB) + preparedSync := durabilityEventIndex(events, "sync:journal", prepared) + firstTarget := durabilityEventIndex(events, "target:", preparedSync) + secondTarget := durabilityEventIndex(events, "target:", firstTarget) + targetSync := durabilityEventIndex(events, "sync:target", secondTarget) + committed := durabilityEventIndex(events, "manifest:committed:pending", targetSync) + finalManifest := durabilityEventIndex(events, "manifest:committed:fresh", committed) + finalManifestSync := durabilityEventIndex(events, "sync:journal", finalManifest) + firstRemove := durabilityEventIndex(events, "remove:", finalManifestSync) + if backupA < 0 || backupB < 0 || prepared < 0 || preparedSync < 0 || firstTarget < 0 || secondTarget < 0 || targetSync < 0 || committed < 0 || finalManifest < 0 || finalManifestSync < 0 || firstRemove < 0 { + t.Fatalf("durability events are out of order: %v", events) + } + var targetSyncs int + for _, event := range events { + if event == "sync:target" { + targetSyncs++ + } + } + if targetSyncs != 1 { + t.Fatalf("target directory synced %d times for two same-directory edits; events=%v", targetSyncs, events) + } +} + +func TestAtomicBatchPreparedJournalSyncFailureWritesNoTargets(t *testing.T) { + s := newAtomicBatchTestServer(t, mutationTestWatcher{}) + path := writeAtomicBatchFixture(t, t.TempDir(), "target.txt", "old\n") + transactionID := "prepared-sync-failure" + transactionDir := filepath.Clean(batchTransactionDir(transactionID)) + var targetWrites atomic.Int64 + s.batchWriteOverride = func(string, []byte, os.FileMode) error { + targetWrites.Add(1) + return nil + } + s.batchDurabilityOverride = &batchDurabilityOps{ + syncDirectory: func(path string) error { + if filepath.Clean(path) == transactionDir { + return errors.New("injected journal fsync failure") + } + return syncBatchDirectory(path) + }, + } + + receipt, err := s.runBatchTransaction(context.Background(), []batchEditItem{ + atomicFileEdit(path, "old", "new"), + }, transactionID) + if err != nil { + t.Fatal(err) + } + if receipt.Status != "aborted" || receipt.DiskStatus != "unchanged" || targetWrites.Load() != 0 { + t.Fatalf("receipt=%+v target_writes=%d", receipt, targetWrites.Load()) + } + if got := readAtomicBatchFixture(t, path); got != "old\n" { + t.Fatalf("target changed before prepared journal became durable: %q", got) + } + if _, err := os.Stat(filepath.Join(transactionDir, "before-0000.bin")); err != nil { + t.Fatalf("failed prepared journal discarded recovery backup: %v", err) + } +} + +func TestAtomicBatchTargetDirectorySyncFailureRollsBackDurably(t *testing.T) { + s := newAtomicBatchTestServer(t, mutationTestWatcher{}) + targetDir := t.TempDir() + paths := []string{ + writeAtomicBatchFixture(t, targetDir, "a.txt", "a0\n"), + writeAtomicBatchFixture(t, targetDir, "b.txt", "b0\n"), + } + var targetSyncCalls atomic.Int64 + s.batchDurabilityOverride = &batchDurabilityOps{ + syncDirectory: func(path string) error { + if filepath.Clean(path) == filepath.Clean(targetDir) && targetSyncCalls.Add(1) == 1 { + return errors.New("injected target directory fsync failure") + } + return syncBatchDirectory(path) + }, + } + + receipt, err := s.runBatchTransaction(context.Background(), []batchEditItem{ + atomicFileEdit(paths[0], "a0", "a1"), + atomicFileEdit(paths[1], "b0", "b1"), + }, "target-sync-failure") + if err != nil { + t.Fatal(err) + } + if receipt.Status != "aborted" || receipt.DiskStatus != "rolled_back" { + t.Fatalf("receipt = %+v", receipt) + } + if targetSyncCalls.Load() != 2 { + t.Fatalf("target directory sync calls = %d, want failed commit sync plus durable rollback sync", targetSyncCalls.Load()) + } + if readAtomicBatchFixture(t, paths[0]) != "a0\n" || readAtomicBatchFixture(t, paths[1]) != "b0\n" { + t.Fatal("directory fsync failure did not restore original files") + } +} + +func TestAtomicBatchTerminalManifestFailureRetainsRecoveryBackups(t *testing.T) { + s := newAtomicBatchTestServer(t, mutationTestWatcher{}) + path := writeAtomicBatchFixture(t, t.TempDir(), "target.txt", "old\n") + transactionID := "terminal-manifest-failure" + s.batchDurabilityOverride = &batchDurabilityOps{ + writeFile: func(path string, content []byte, mode os.FileMode) error { + if filepath.Base(path) == "manifest.json" { + var receipt batchTransactionReceipt + if err := json.Unmarshal(content, &receipt); err != nil { + return err + } + if receipt.Status == "committed" { + return errors.New("injected terminal manifest failure") + } + } + return durableAtomicWriteFile(path, content, mode) + }, + } + + receipt, err := s.runBatchTransaction(context.Background(), []batchEditItem{ + atomicFileEdit(path, "old", "new"), + }, transactionID) + if err != nil { + t.Fatal(err) + } + if receipt.Status != "committed" || receipt.DiskStatus != "committed" || !strings.Contains(receipt.Error, "terminal journal update failed") { + t.Fatalf("receipt = %+v", receipt) + } + if got := readAtomicBatchFixture(t, path); got != "new\n" { + t.Fatalf("committed content = %q", got) + } + backupPath := filepath.Join(batchTransactionDir(transactionID), "before-0000.bin") + if _, err := os.Stat(backupPath); err != nil { + t.Fatalf("terminal manifest failure discarded recovery backup: %v", err) + } + persisted, found, err := readBatchManifest(transactionID) + if err != nil || !found || persisted.Status != "prepared" { + t.Fatalf("persisted recovery point = %+v, found=%v, err=%v", persisted, found, err) + } +} + +func TestAtomicBatchRecoveryConflictRetainsBackup(t *testing.T) { + s := newAtomicBatchTestServer(t, mutationTestWatcher{}) + path := writeAtomicBatchFixture(t, t.TempDir(), "target.txt", "old\n") + transactionID := "recovery-conflict-backup" + prepareAtomicRecoveryFixture(t, s, transactionID, []string{path}, []string{"old\n"}, []string{"new\n"}) + if err := durableAtomicWriteFile(path, []byte("external\n"), 0o644); err != nil { + t.Fatal(err) + } + + restarted := &Server{watcher: mutationTestWatcher{}, session: newSessionState()} + receipt, err := restarted.batchTransactionStatus(context.Background(), transactionID) + if err != nil { + t.Fatal(err) + } + if receipt.Status != "recovery_conflict" || receipt.DiskStatus != "conflict" { + t.Fatalf("receipt = %+v", receipt) + } + backupPath := filepath.Join(batchTransactionDir(transactionID), "before-0000.bin") + if _, err := os.Stat(backupPath); err != nil { + t.Fatalf("recovery conflict discarded backup needed for operator repair: %v", err) + } + if got := readAtomicBatchFixture(t, path); got != "external\n" { + t.Fatalf("recovery conflict overwrote external content: %q", got) + } +} + +func TestBatchTransactionJournalPathConfinesCallerID(t *testing.T) { + root := filepath.Join(t.TempDir(), "transactions") + t.Setenv(batchTransactionDirEnv, root) + dir := batchTransactionDir("../../outside/transaction") + rel, err := filepath.Rel(root, dir) + if err != nil { + t.Fatal(err) + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || strings.Contains(filepath.Base(dir), string(filepath.Separator)) { + t.Fatalf("transaction ID escaped journal root: root=%q dir=%q rel=%q", root, dir, rel) + } +} diff --git a/internal/mcp/batch_transaction_journal.go b/internal/mcp/batch_transaction_journal.go new file mode 100644 index 000000000..44450aca9 --- /dev/null +++ b/internal/mcp/batch_transaction_journal.go @@ -0,0 +1,341 @@ +package mcp + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/zzet/gortex/internal/daemon" +) + +const batchTransactionDirEnv = "GORTEX_BATCH_TRANSACTION_DIR" + +func batchTransactionRoot() string { + if root := os.Getenv(batchTransactionDirEnv); root != "" { + return filepath.Clean(root) + } + return filepath.Join(filepath.Dir(daemon.SnapshotPath()), "batch-transactions") +} + +func batchTransactionDir(transactionID string) string { + sum := sha256.Sum256([]byte(transactionID)) + return filepath.Join(batchTransactionRoot(), hex.EncodeToString(sum[:16])) +} + +func batchManifestPath(transactionID string) string { + return filepath.Join(batchTransactionDir(transactionID), "manifest.json") +} + +func digestBatchBytes(content []byte) string { + sum := sha256.Sum256(content) + return hex.EncodeToString(sum[:]) +} + +func (s *Server) persistBatchManifest(receipt batchTransactionReceipt) error { + if receipt.TransactionID == "" { + return nil + } + path := batchManifestPath(receipt.TransactionID) + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + payload, err := json.MarshalIndent(receipt, "", " ") + if err != nil { + return err + } + payload = append(payload, '\n') + if err := s.batchDurability().writeFile(path, payload, 0o600); err != nil { + return err + } + // The transaction directory sync persists backup and manifest renames; + // syncing its parent persists creation of a new transaction directory. + return s.syncBatchDirectories(dir, filepath.Dir(dir)) +} + +func readBatchManifest(transactionID string) (batchTransactionReceipt, bool, error) { + payload, err := os.ReadFile(batchManifestPath(transactionID)) + if errors.Is(err, os.ErrNotExist) { + return batchTransactionReceipt{}, false, nil + } + if err != nil { + return batchTransactionReceipt{}, false, err + } + var receipt batchTransactionReceipt + if err := json.Unmarshal(payload, &receipt); err != nil { + return batchTransactionReceipt{}, false, fmt.Errorf("decode transaction manifest: %w", err) + } + if receipt.Version != batchTransactionVersion { + return batchTransactionReceipt{}, false, fmt.Errorf("unsupported transaction manifest version %d", receipt.Version) + } + if receipt.TransactionID != transactionID { + return batchTransactionReceipt{}, false, fmt.Errorf("transaction manifest identity mismatch") + } + return receipt, true, nil +} + +func existingBatchTransactionAction(state *batchTransactionState) string { + receipt := state.snapshot() + if receipt.Status == "committed" && receipt.GraphStatus != "fresh" { + return "refresh_graph" + } + return "existing" +} + +func (s *Server) loadOrCreateBatchTransaction(transactionID, fingerprint string) (*batchTransactionState, string, error) { + if value, ok := s.batchTransactions.Load(transactionID); ok { + state, valid := value.(*batchTransactionState) + if !valid { + return nil, "", fmt.Errorf("invalid transaction state for %q", transactionID) + } + if fingerprint != "" && state.fingerprint != fingerprint { + return nil, "", fmt.Errorf("transaction_id %q is already bound to a different edit payload", transactionID) + } + return state, existingBatchTransactionAction(state), nil + } + + persisted, found, err := readBatchManifest(transactionID) + if err != nil { + return nil, "", err + } + if found { + if fingerprint != "" && persisted.Fingerprint != fingerprint { + return nil, "", fmt.Errorf("transaction_id %q is already bound to a different edit payload", transactionID) + } + terminal := persisted.Status != "prepared" && (persisted.Status != "committed" || persisted.GraphStatus != "pending") + state := &batchTransactionState{ + fingerprint: persisted.Fingerprint, + done: make(chan struct{}), + receipt: persisted, + } + if terminal { + state.doneOnce.Do(func() { close(state.done) }) + } + actual, loaded := s.batchTransactions.LoadOrStore(transactionID, state) + if loaded { + existing := actual.(*batchTransactionState) + if fingerprint != "" && existing.fingerprint != fingerprint { + return nil, "", fmt.Errorf("transaction_id %q is already bound to a different edit payload", transactionID) + } + return existing, existingBatchTransactionAction(existing), nil + } + if persisted.Status == "prepared" { + return state, "recover", nil + } + if persisted.Status == "committed" && persisted.GraphStatus == "pending" { + return state, "refresh_graph", nil + } + return state, existingBatchTransactionAction(state), nil + } + if fingerprint == "" { + return nil, "", fmt.Errorf("transaction %q not found", transactionID) + } + receipt := batchTransactionReceipt{ + Version: batchTransactionVersion, TransactionID: transactionID, Fingerprint: fingerprint, + Status: "preparing", DiskStatus: "unchanged", GraphStatus: "not_started", StartedAt: time.Now().UTC(), + } + state := &batchTransactionState{fingerprint: fingerprint, done: make(chan struct{}), receipt: receipt} + actual, loaded := s.batchTransactions.LoadOrStore(transactionID, state) + if loaded { + existing := actual.(*batchTransactionState) + if existing.fingerprint != fingerprint { + return nil, "", fmt.Errorf("transaction_id %q is already bound to a different edit payload", transactionID) + } + return existing, existingBatchTransactionAction(existing), nil + } + return state, "execute", nil +} + +func (s *Server) prepareBatchJournal(receipt *batchTransactionReceipt, buffers map[string]*batchFileBuffer, orderedPaths []string) error { + dir := batchTransactionDir(receipt.TransactionID) + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + files := make([]batchTransactionFile, 0, len(orderedPaths)) + for i, path := range orderedPaths { + buffer := buffers[path] + backupName := fmt.Sprintf("before-%04d.bin", i) + backupPath := filepath.Join(dir, backupName) + if err := s.batchDurability().writeFile(backupPath, buffer.original, 0o600); err != nil { + return fmt.Errorf("write backup for %s: %w", buffer.relPath, err) + } + files = append(files, batchTransactionFile{ + Path: path, RelativePath: buffer.relPath, Mode: buffer.mode, + BeforeSHA256: digestBatchBytes(buffer.original), AfterSHA256: digestBatchBytes(buffer.content), Backup: backupName, + }) + // Keep the receipt aware of every completed backup so an error on a + // later file still cleans the already-durable partial journal. + receipt.Files = append([]batchTransactionFile(nil), files...) + } + receipt.Files = files + receipt.Status, receipt.DiskStatus, receipt.GraphStatus = "prepared", "unchanged", "not_started" + if err := s.persistBatchManifest(*receipt); err != nil { + return err + } + return nil +} + +func readBatchBackup(receipt batchTransactionReceipt, file batchTransactionFile) ([]byte, error) { + if file.Backup == "" || filepath.Base(file.Backup) != file.Backup { + return nil, fmt.Errorf("invalid backup name for %s", file.RelativePath) + } + payload, err := os.ReadFile(filepath.Join(batchTransactionDir(receipt.TransactionID), file.Backup)) + if err != nil { + return nil, err + } + if digestBatchBytes(payload) != file.BeforeSHA256 { + return nil, fmt.Errorf("backup hash mismatch for %s", file.RelativePath) + } + return payload, nil +} + +func classifyBatchFiles(files []batchTransactionFile) (before, after, unknown []batchTransactionFile, err error) { + for _, file := range files { + content, readErr := os.ReadFile(file.Path) + if readErr != nil { + unknown = append(unknown, file) + if err == nil { + err = fmt.Errorf("read %s: %w", file.RelativePath, readErr) + } + continue + } + digest := digestBatchBytes(content) + switch digest { + case file.BeforeSHA256: + before = append(before, file) + case file.AfterSHA256: + after = append(after, file) + default: + unknown = append(unknown, file) + if err == nil { + err = fmt.Errorf("%s has neither the before nor after transaction hash", file.RelativePath) + } + } + } + return before, after, unknown, err +} + +func (s *Server) rollbackBatchReceipt(receipt batchTransactionReceipt) (string, error) { + _, after, unknown, classifyErr := classifyBatchFiles(receipt.Files) + if len(unknown) > 0 { + return "recovery_conflict", fmt.Errorf("rollback refused unknown disk state: %w", classifyErr) + } + for _, file := range after { + backup, err := readBatchBackup(receipt, file) + if err != nil { + return "recovery_conflict", fmt.Errorf("load rollback backup for %s: %w", file.RelativePath, err) + } + if err := s.batchDurability().writeFile(file.Path, backup, file.Mode); err != nil { + return "recovery_conflict", fmt.Errorf("restore %s: %w", file.RelativePath, err) + } + } + if len(after) > 0 { + dirs := batchFileDirectories(after) + if err := s.syncBatchDirectories(dirs...); err != nil { + return "recovery_conflict", fmt.Errorf("persist rollback: %w", err) + } + } + before, _, unknown, verifyErr := classifyBatchFiles(receipt.Files) + if len(unknown) > 0 || len(before) != len(receipt.Files) { + if verifyErr == nil { + verifyErr = fmt.Errorf("not every file matches its before hash") + } + return "recovery_conflict", fmt.Errorf("rollback verification failed: %w", verifyErr) + } + return "aborted", nil +} + +func (s *Server) recoverBatchTransaction(ctx context.Context, state *batchTransactionState) { + receipt := state.snapshot() + paths := make([]string, 0, len(receipt.Files)) + for _, file := range receipt.Files { + paths = append(paths, file.Path) + } + recoveryCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second) + defer cancel() + release, err := acquireMutationPaths(recoveryCtx, paths) + if err != nil { + s.finishBatchTransaction(state, receipt, "recovery_conflict", "conflict", "not_started", "could not acquire recovery locks: "+err.Error()) + return + } + defer release() + + before, after, unknown, classifyErr := classifyBatchFiles(receipt.Files) + switch { + case len(unknown) > 0: + receipt.Recovered = true + s.finishBatchTransaction(state, receipt, "recovery_conflict", "conflict", "not_started", "recovery refused unknown disk state: "+classifyErr.Error()) + case len(after) == len(receipt.Files): + receipt.Recovered = true + if err := s.syncBatchDirectories(batchFileDirectories(receipt.Files)...); err != nil { + s.finishBatchTransaction(state, receipt, "recovery_conflict", "conflict", "not_started", "could not persist recovered commit: "+err.Error()) + return + } + for i := range receipt.Results { + receipt.Results[i].Status = "applied" + } + receipt.Summary = batchSummary(receipt.Results) + receipt.Status, receipt.DiskStatus, receipt.GraphStatus = "committed", "committed", "pending" + receipt.Error = "" + state.publish(receipt, false) + s.refreshBatchGraph(state) + case len(before) == len(receipt.Files): + receipt.Recovered = true + s.finishBatchTransaction(state, receipt, "aborted", "unchanged", "not_started", "recovered prepared transaction before commit") + default: + status, rollbackErr := s.rollbackBatchReceipt(receipt) + receipt.Recovered = true + message := "recovered interrupted mixed commit by restoring original bytes" + if rollbackErr != nil { + message = rollbackErr.Error() + } + diskStatus := "rolled_back" + if status == "recovery_conflict" { + diskStatus = "conflict" + } + s.finishBatchTransaction(state, receipt, status, diskStatus, "not_started", message) + } +} + +func batchReceiptCleanupSafe(receipt batchTransactionReceipt) bool { + switch receipt.DiskStatus { + case "committed", "unchanged", "rolled_back": + return true + default: + return false + } +} + +func (s *Server) cleanupBatchBackups(receipt batchTransactionReceipt) error { + dir := batchTransactionDir(receipt.TransactionID) + ops := s.batchDurability() + removed := false + var firstErr error + for _, file := range receipt.Files { + if file.Backup == "" || filepath.Base(file.Backup) != file.Backup { + continue + } + err := ops.removeFile(filepath.Join(dir, file.Backup)) + switch { + case err == nil: + removed = true + case errors.Is(err, os.ErrNotExist): + default: + if firstErr == nil { + firstErr = err + } + } + } + if removed { + if err := s.syncBatchDirectories(dir); err != nil && firstErr == nil { + firstErr = err + } + } + return firstErr +} diff --git a/internal/mcp/batch_transaction_test.go b/internal/mcp/batch_transaction_test.go new file mode 100644 index 000000000..376323d01 --- /dev/null +++ b/internal/mcp/batch_transaction_test.go @@ -0,0 +1,469 @@ +package mcp + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "go.uber.org/zap" + + "github.com/zzet/gortex/internal/agents" + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/indexer" + "github.com/zzet/gortex/internal/query" +) + +func newAtomicBatchTestServer(t *testing.T, watcher watcherHistory) *Server { + t.Helper() + t.Setenv(batchTransactionDirEnv, filepath.Join(t.TempDir(), "transactions")) + return &Server{ + watcher: watcher, + session: newSessionState(), + mutationReindexWait: 100 * time.Millisecond, + } +} + +func writeAtomicBatchFixture(t *testing.T, dir, name, content string) string { + t.Helper() + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + return path +} + +func readAtomicBatchFixture(t *testing.T, path string) string { + t.Helper() + content, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return string(content) +} + +func atomicFileEdit(path, oldText, newText string) batchEditItem { + return batchEditItem{Op: "edit_file", Path: path, OldString: oldText, NewString: newText} +} + +func TestBatchEditSchemaAdvertisesAtomicStatusProtocol(t *testing.T) { + g := graph.New() + s := NewServer(query.NewEngine(g), g, nil, nil, zap.NewNop(), nil) + legacy, ok := s.facades.legacy("batch_edit") + if !ok { + t.Fatal("batch_edit is not registered in the facade registry") + } + properties := legacy.tool.InputSchema.Properties + for _, name := range []string{"edits", "transaction_id", "status_only", "dry_run"} { + if _, ok := properties[name]; !ok { + t.Fatalf("batch_edit schema is missing %q", name) + } + } + for _, name := range legacy.tool.InputSchema.Required { + if name == "edits" { + t.Fatal("edits must be optional at schema level so status-only calls validate") + } + } + if !strings.Contains(legacy.tool.Description, "restores all touched files") || !strings.Contains(legacy.tool.Description, "daemon restart") { + t.Fatalf("batch_edit description does not state atomic/durable behavior: %q", legacy.tool.Description) + } +} + +func TestBatchTransactionDefaultIDsAreUnique(t *testing.T) { + fingerprint := strings.Repeat("a", 64) + first, err := normalizeBatchTransactionID("", fingerprint) + if err != nil { + t.Fatal(err) + } + second, err := normalizeBatchTransactionID("", fingerprint) + if err != nil { + t.Fatal(err) + } + if first == second { + t.Fatalf("default transaction IDs collided: %q", first) + } + explicit, err := normalizeBatchTransactionID("caller-key", fingerprint) + if err != nil || explicit != "caller-key" { + t.Fatalf("explicit id = %q, err=%v", explicit, err) + } +} + +func TestAtomicBatchSameFileIsSequentialAndIdempotent(t *testing.T) { + var scheduled atomic.Int64 + s := newAtomicBatchTestServer(t, mutationTestWatcher{scheduled: &scheduled}) + dir := t.TempDir() + path := writeAtomicBatchFixture(t, dir, "same.txt", "alpha beta\n") + edits := []batchEditItem{ + atomicFileEdit(path, "alpha", "ALPHA"), + atomicFileEdit(path, "beta", "BETA"), + } + var writes atomic.Int64 + s.batchWriteOverride = func(path string, content []byte, mode os.FileMode) error { + writes.Add(1) + return agents.AtomicWriteFile(path, content, mode) + } + + receipt, err := s.runBatchTransaction(context.Background(), edits, "same-file") + if err != nil { + t.Fatal(err) + } + if receipt.Status != "committed" || receipt.DiskStatus != "committed" || receipt.GraphStatus != "fresh" { + t.Fatalf("receipt = %+v", receipt) + } + if got := readAtomicBatchFixture(t, path); got != "ALPHA BETA\n" { + t.Fatalf("content = %q", got) + } + if len(receipt.Files) != 1 || receipt.Summary["applied"] != 2 { + t.Fatalf("files/summary = %d %+v", len(receipt.Files), receipt.Summary) + } + if writes.Load() != 1 || scheduled.Load() != 1 { + t.Fatalf("writes=%d scheduled=%d, want one physical commit and one reindex", writes.Load(), scheduled.Load()) + } + + retry, err := s.runBatchTransaction(context.Background(), edits, "same-file") + if err != nil { + t.Fatal(err) + } + if retry.TransactionID != receipt.TransactionID || writes.Load() != 1 || scheduled.Load() != 1 { + t.Fatalf("idempotent retry wrote or reindexed again: receipt=%+v writes=%d scheduled=%d", retry, writes.Load(), scheduled.Load()) + } + _, err = s.runBatchTransaction(context.Background(), []batchEditItem{ + atomicFileEdit(path, "ALPHA", "different"), + }, "same-file") + if err == nil || !strings.Contains(err.Error(), "different edit payload") { + t.Fatalf("payload conflict error = %v", err) + } + + // Idempotency is durable, not just daemon-local. + restarted := &Server{watcher: mutationTestWatcher{}, session: newSessionState()} + restarted.batchWriteOverride = func(string, []byte, os.FileMode) error { + t.Fatal("durable retry attempted a write") + return nil + } + persisted, err := restarted.runBatchTransaction(context.Background(), edits, "same-file") + if err != nil || persisted.Status != "committed" || persisted.GraphStatus != "fresh" { + t.Fatalf("persisted retry = %+v, err=%v", persisted, err) + } +} + +func TestAtomicBatchConcurrentIdempotencyWritesOnce(t *testing.T) { + var scheduled atomic.Int64 + s := newAtomicBatchTestServer(t, mutationTestWatcher{scheduled: &scheduled}) + path := writeAtomicBatchFixture(t, t.TempDir(), "concurrent.txt", "old\n") + edits := []batchEditItem{atomicFileEdit(path, "old", "new")} + var writes atomic.Int64 + s.batchWriteOverride = func(path string, content []byte, mode os.FileMode) error { + writes.Add(1) + return agents.AtomicWriteFile(path, content, mode) + } + + const callers = 12 + start := make(chan struct{}) + errs := make(chan error, callers) + var wg sync.WaitGroup + for range callers { + wg.Add(1) + go func() { + defer wg.Done() + <-start + receipt, err := s.runBatchTransaction(context.Background(), edits, "concurrent-key") + if err == nil && (receipt.Status != "committed" || receipt.GraphStatus != "fresh") { + err = fmt.Errorf("unexpected receipt: %+v", receipt) + } + errs <- err + }() + } + close(start) + wg.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Fatal(err) + } + } + if writes.Load() != 1 || scheduled.Load() != 1 { + t.Fatalf("concurrent retries wrote=%d scheduled=%d, want 1/1", writes.Load(), scheduled.Load()) + } + if got := readAtomicBatchFixture(t, path); got != "new\n" { + t.Fatalf("content = %q", got) + } +} + +func TestAtomicBatchPreflightFailureWritesNothing(t *testing.T) { + var scheduled atomic.Int64 + s := newAtomicBatchTestServer(t, mutationTestWatcher{scheduled: &scheduled}) + dir := t.TempDir() + a := writeAtomicBatchFixture(t, dir, "a.txt", "one\n") + b := writeAtomicBatchFixture(t, dir, "b.txt", "two\n") + var writes atomic.Int64 + s.batchWriteOverride = func(path string, content []byte, mode os.FileMode) error { + writes.Add(1) + return agents.AtomicWriteFile(path, content, mode) + } + + receipt, err := s.runBatchTransaction(context.Background(), []batchEditItem{ + atomicFileEdit(a, "one", "ONE"), + atomicFileEdit(b, "missing", "TWO"), + }, "preflight") + if err != nil { + t.Fatal(err) + } + if receipt.Status != "aborted" || receipt.DiskStatus != "unchanged" || receipt.GraphStatus != "not_started" { + t.Fatalf("receipt = %+v", receipt) + } + if writes.Load() != 0 || scheduled.Load() != 0 { + t.Fatalf("writes=%d scheduled=%d", writes.Load(), scheduled.Load()) + } + if readAtomicBatchFixture(t, a) != "one\n" || readAtomicBatchFixture(t, b) != "two\n" { + t.Fatal("preflight failure changed disk") + } +} + +func TestAtomicBatchCommitFailureRollsBackEveryFile(t *testing.T) { + for _, tc := range []struct { + name string + failAt int64 + afterWrite bool + }{ + {name: "second-before-write", failAt: 2}, + {name: "second-after-write", failAt: 2, afterWrite: true}, + {name: "third-before-write", failAt: 3}, + } { + t.Run(tc.name, func(t *testing.T) { + var scheduled atomic.Int64 + s := newAtomicBatchTestServer(t, mutationTestWatcher{scheduled: &scheduled}) + dir := t.TempDir() + paths := []string{ + writeAtomicBatchFixture(t, dir, "a.txt", "a0\n"), + writeAtomicBatchFixture(t, dir, "b.txt", "b0\n"), + writeAtomicBatchFixture(t, dir, "c.txt", "c0\n"), + } + edits := make([]batchEditItem, 0, len(paths)) + for i, path := range paths { + edits = append(edits, atomicFileEdit(path, fmt.Sprintf("%c0", 'a'+i), fmt.Sprintf("%c1", 'a'+i))) + } + var calls atomic.Int64 + s.batchWriteOverride = func(path string, content []byte, mode os.FileMode) error { + call := calls.Add(1) + if call == tc.failAt && !tc.afterWrite { + return errors.New("injected commit failure") + } + if err := agents.AtomicWriteFile(path, content, mode); err != nil { + return err + } + if call == tc.failAt { + return errors.New("injected post-write failure") + } + return nil + } + + receipt, err := s.runBatchTransaction(context.Background(), edits, "rollback-"+tc.name) + if err != nil { + t.Fatal(err) + } + if receipt.Status != "aborted" || receipt.DiskStatus != "rolled_back" || receipt.GraphStatus != "not_started" { + t.Fatalf("receipt = %+v", receipt) + } + if receipt.Summary["failed"] != 1 || receipt.Summary["applied"] != 0 { + t.Fatalf("summary = %+v", receipt.Summary) + } + for i, path := range paths { + want := fmt.Sprintf("%c0\n", 'a'+i) + if got := readAtomicBatchFixture(t, path); got != want { + t.Fatalf("%s = %q, want rollback %q", path, got, want) + } + } + if scheduled.Load() != 0 { + t.Fatalf("rolled-back batch scheduled %d reindexes", scheduled.Load()) + } + }) + } +} + +func TestAtomicBatchCancellationBoundaries(t *testing.T) { + t.Run("before-commit", func(t *testing.T) { + var scheduled atomic.Int64 + s := newAtomicBatchTestServer(t, mutationTestWatcher{scheduled: &scheduled}) + path := writeAtomicBatchFixture(t, t.TempDir(), "cancel.txt", "before\n") + ctx, cancel := context.WithCancel(context.Background()) + cancel() + receipt, err := s.runBatchTransaction(ctx, []batchEditItem{atomicFileEdit(path, "before", "after")}, "cancel-before") + if err != nil { + t.Fatal(err) + } + if receipt.Status != "aborted" || receipt.DiskStatus != "unchanged" || readAtomicBatchFixture(t, path) != "before\n" { + t.Fatalf("receipt/content = %+v %q", receipt, readAtomicBatchFixture(t, path)) + } + if scheduled.Load() != 0 { + t.Fatalf("scheduled = %d", scheduled.Load()) + } + }) + + t.Run("after-first-commit", func(t *testing.T) { + var scheduled atomic.Int64 + s := newAtomicBatchTestServer(t, mutationTestWatcher{scheduled: &scheduled}) + dir := t.TempDir() + a := writeAtomicBatchFixture(t, dir, "a.txt", "a0\n") + b := writeAtomicBatchFixture(t, dir, "b.txt", "b0\n") + ctx, cancel := context.WithCancel(context.Background()) + var calls atomic.Int64 + s.batchWriteOverride = func(path string, content []byte, mode os.FileMode) error { + if err := agents.AtomicWriteFile(path, content, mode); err != nil { + return err + } + if calls.Add(1) == 1 { + cancel() + } + return nil + } + receipt, err := s.runBatchTransaction(ctx, []batchEditItem{ + atomicFileEdit(a, "a0", "a1"), atomicFileEdit(b, "b0", "b1"), + }, "cancel-after") + if err != nil { + t.Fatal(err) + } + if receipt.Status != "committed" || receipt.GraphStatus != "fresh" { + t.Fatalf("receipt = %+v", receipt) + } + if readAtomicBatchFixture(t, a) != "a1\n" || readAtomicBatchFixture(t, b) != "b1\n" { + t.Fatal("cancellation interrupted an in-progress atomic commit") + } + if scheduled.Load() != 2 { + t.Fatalf("scheduled = %d, want both files", scheduled.Load()) + } + }) +} + +func TestAtomicBatchPendingGraphReceiptCompletesWithoutDuplicateAdmission(t *testing.T) { + done := make(chan indexer.MutationResult, 1) + var scheduled atomic.Int64 + s := newAtomicBatchTestServer(t, mutationTestWatcher{scheduled: &scheduled, done: done, generation: 7}) + s.mutationReindexWait = 5 * time.Millisecond + path := writeAtomicBatchFixture(t, t.TempDir(), "pending.txt", "old\n") + edits := []batchEditItem{atomicFileEdit(path, "old", "new")} + receipt, err := s.runBatchTransaction(context.Background(), edits, "pending-graph") + if err != nil { + t.Fatal(err) + } + if receipt.Status != "committed" || receipt.GraphStatus != "pending" || len(receipt.Files) != 1 || receipt.Files[0].ReindexReceipt == "" { + t.Fatalf("pending receipt = %+v", receipt) + } + + done <- indexer.MutationResult{RequestedGeneration: 7, AppliedGeneration: 8, Reindexed: true} + close(done) + status, err := s.batchTransactionStatus(context.Background(), "pending-graph") + if err != nil { + t.Fatal(err) + } + if status.GraphStatus != "fresh" || !status.Results[0].Reindexed || status.Results[0].ReindexAppliedGeneration != 8 { + t.Fatalf("completed status = %+v", status) + } + if scheduled.Load() != 1 { + t.Fatalf("status retry admitted %d watcher generations, want 1", scheduled.Load()) + } +} + +func prepareAtomicRecoveryFixture(t *testing.T, s *Server, id string, paths []string, before, after []string) batchTransactionReceipt { + t.Helper() + buffers := make(map[string]*batchFileBuffer, len(paths)) + results := make([]batchEditResult, len(paths)) + for i, path := range paths { + buffers[path] = &batchFileBuffer{ + absPath: path, relPath: filepath.Base(path), mode: 0o644, + original: []byte(before[i]), content: []byte(after[i]), + } + results[i] = batchEditResult{Op: "edit_file", FilePath: filepath.Base(path), Status: "validated"} + } + receipt := batchTransactionReceipt{ + Version: batchTransactionVersion, TransactionID: id, Fingerprint: "recovery-fixture", + Status: "preparing", DiskStatus: "unchanged", GraphStatus: "not_started", + Results: results, Summary: batchSummary(results), StartedAt: time.Now().UTC(), + } + if err := s.prepareBatchJournal(&receipt, buffers, paths); err != nil { + t.Fatal(err) + } + return receipt +} + +func TestAtomicBatchDurableRecovery(t *testing.T) { + t.Run("all-after-finishes-commit", func(t *testing.T) { + var scheduled atomic.Int64 + s := newAtomicBatchTestServer(t, mutationTestWatcher{scheduled: &scheduled}) + dir := t.TempDir() + paths := []string{ + writeAtomicBatchFixture(t, dir, "a.txt", "a0\n"), + writeAtomicBatchFixture(t, dir, "b.txt", "b0\n"), + } + prepareAtomicRecoveryFixture(t, s, "recover-after", paths, []string{"a0\n", "b0\n"}, []string{"a1\n", "b1\n"}) + for i, path := range paths { + if err := agents.AtomicWriteFile(path, []byte(fmt.Sprintf("%c1\n", 'a'+i)), 0o644); err != nil { + t.Fatal(err) + } + } + restarted := &Server{watcher: mutationTestWatcher{scheduled: &scheduled}, session: newSessionState(), mutationReindexWait: 100 * time.Millisecond} + receipt, err := restarted.batchTransactionStatus(context.Background(), "recover-after") + if err != nil { + t.Fatal(err) + } + if !receipt.Recovered || receipt.Status != "committed" || receipt.DiskStatus != "committed" || receipt.GraphStatus != "fresh" { + t.Fatalf("recovered receipt = %+v", receipt) + } + if scheduled.Load() != 2 { + t.Fatalf("scheduled = %d", scheduled.Load()) + } + }) + + t.Run("mixed-commit-rolls-back", func(t *testing.T) { + var scheduled atomic.Int64 + s := newAtomicBatchTestServer(t, mutationTestWatcher{scheduled: &scheduled}) + dir := t.TempDir() + paths := []string{ + writeAtomicBatchFixture(t, dir, "a.txt", "a0\n"), + writeAtomicBatchFixture(t, dir, "b.txt", "b0\n"), + } + prepareAtomicRecoveryFixture(t, s, "recover-mixed", paths, []string{"a0\n", "b0\n"}, []string{"a1\n", "b1\n"}) + if err := agents.AtomicWriteFile(paths[0], []byte("a1\n"), 0o644); err != nil { + t.Fatal(err) + } + restarted := &Server{watcher: mutationTestWatcher{scheduled: &scheduled}, session: newSessionState()} + receipt, err := restarted.batchTransactionStatus(context.Background(), "recover-mixed") + if err != nil { + t.Fatal(err) + } + if !receipt.Recovered || receipt.Status != "aborted" || receipt.DiskStatus != "rolled_back" || receipt.GraphStatus != "not_started" { + t.Fatalf("recovered receipt = %+v", receipt) + } + if readAtomicBatchFixture(t, paths[0]) != "a0\n" || readAtomicBatchFixture(t, paths[1]) != "b0\n" { + t.Fatal("mixed recovery did not restore every original file") + } + if scheduled.Load() != 0 { + t.Fatalf("rolled-back recovery scheduled %d reindexes", scheduled.Load()) + } + }) + + t.Run("unknown-disk-state-fails-closed", func(t *testing.T) { + s := newAtomicBatchTestServer(t, mutationTestWatcher{}) + path := writeAtomicBatchFixture(t, t.TempDir(), "unknown.txt", "old\n") + prepareAtomicRecoveryFixture(t, s, "recover-conflict", []string{path}, []string{"old\n"}, []string{"new\n"}) + if err := agents.AtomicWriteFile(path, []byte("external\n"), 0o644); err != nil { + t.Fatal(err) + } + restarted := &Server{watcher: mutationTestWatcher{}, session: newSessionState()} + receipt, err := restarted.batchTransactionStatus(context.Background(), "recover-conflict") + if err != nil { + t.Fatal(err) + } + if receipt.Status != "recovery_conflict" || receipt.DiskStatus != "conflict" || !strings.Contains(receipt.Error, "unknown disk state") { + t.Fatalf("conflict receipt = %+v", receipt) + } + if readAtomicBatchFixture(t, path) != "external\n" { + t.Fatal("conflict recovery overwrote external bytes") + } + }) +} diff --git a/internal/mcp/change_contract.go b/internal/mcp/change_contract.go index 8a832eea9..6fb138bb0 100644 --- a/internal/mcp/change_contract.go +++ b/internal/mcp/change_contract.go @@ -236,7 +236,7 @@ func (s *Server) lowerEditSource(ctx context.Context, req mcp.CallToolRequest) ( changedIDs: ids, nodes: nodes, step: &step, - impact: analysis.AnalyzeImpact(s.graph, ids, s.getCommunities(), s.getProcesses()), + impact: s.analyzeImpactLazy(ctx, ids), touchedFiles: step.touchedFiles, }, nil } @@ -286,7 +286,7 @@ func (s *Server) lowerRangeSource(ctx context.Context, req mcp.CallToolRequest) changed: changed, changedIDs: ids, nodes: s.nodesForIDs(ids), - impact: analysis.AnalyzeImpact(s.graph, ids, s.getCommunities(), s.getProcesses()), + impact: s.analyzeImpactLazy(ctx, ids), touchedFiles: dedupeStrings(files), }, nil } @@ -309,7 +309,7 @@ func (s *Server) lowerSymbolSource(ctx context.Context, req mcp.CallToolRequest) changed: changed, changedIDs: ids, nodes: nodes, - impact: analysis.AnalyzeImpact(s.graph, ids, s.getCommunities(), s.getProcesses()), + impact: s.analyzeImpactLazy(ctx, ids), touchedFiles: dedupeStrings(files), }, nil } @@ -343,7 +343,7 @@ func (s *Server) lowerDiffSource(ctx context.Context, req mcp.CallToolRequest) ( changed: changed, changedIDs: ids, nodes: s.nodesForIDs(ids), - impact: analysis.AnalyzeImpact(s.graph, ids, s.getCommunities(), s.getProcesses()), + impact: s.analyzeImpactLazy(ctx, ids), touchedFiles: diff.ChangedFiles, }, nil } @@ -419,16 +419,16 @@ func (s *Server) scoreChangeRisk(p *prediction) changeRisk { impactRisk = p.impact.Risk } var maxPR float64 - if s.pageRank != nil { - for _, id := range p.changedIDs { - if pr := s.pageRank.ScoreOf(id); pr > maxPR { - maxPR = pr + if metrics, err := s.analysisNodeMetricsBatched(p.changedIDs); err == nil { + for _, metric := range metrics { + if metric.PageRank > maxPR { + maxPR = metric.PageRank } } } prNorm := 0.0 - if s.pageRank != nil && s.pageRank.Max > 0 { - prNorm = maxPR / s.pageRank.Max + if globalMax := s.topAnalysisMetricValue(graph.AnalysisMetricPageRank); globalMax > 0 { + prNorm = maxPR / globalMax } score := int(100 * (0.6*saturate(float64(blast), 30) + 0.4*prNorm)) if score > 100 { diff --git a/internal/mcp/change_contract_riskgate.go b/internal/mcp/change_contract_riskgate.go index 9b7c9623e..9feb5b08f 100644 --- a/internal/mcp/change_contract_riskgate.go +++ b/internal/mcp/change_contract_riskgate.go @@ -62,29 +62,36 @@ func riskGateCallerThreshold() int { return riskGateCallerDefault } -// isRiskGated reports whether a symbol is load-bearing enough to require an ack. -func (s *Server) isRiskGated(n *graph.Node) bool { - if n == nil { - return false +// riskGatedSymbols returns the changed symbols that require an ack. +func (s *Server) riskGatedSymbols(p *prediction) []*graph.Node { + if p == nil || len(p.nodes) == 0 { + return nil } - fanIn, _ := computeFanInOut(s.graph, []*graph.Node{n}) - if fanIn[n.ID] >= riskGateCallerThreshold() { - return true + fanIn, _ := computeFanInOut(s.graph, p.nodes) + ids := make([]string, 0, len(p.nodes)) + for _, node := range p.nodes { + if node != nil { + ids = append(ids, node.ID) + } } - if s.pageRank != nil && s.pageRank.Max > 0 { - if s.pageRank.ScoreOf(n.ID)/s.pageRank.Max >= riskGatePRThreshold { - return true + pageRank := make(map[string]float64, len(ids)) + if metrics, err := s.analysisNodeMetricsBatched(ids); err == nil { + for _, metric := range metrics { + pageRank[metric.NodeID] = metric.PageRank } } - return false -} - -// riskGatedSymbols returns the changed symbols that require an ack. -func (s *Server) riskGatedSymbols(p *prediction) []*graph.Node { - var out []*graph.Node - for _, n := range p.nodes { - if s.isRiskGated(n) { - out = append(out, n) + maxPR := s.topAnalysisMetricValue(graph.AnalysisMetricPageRank) + out := make([]*graph.Node, 0, len(p.nodes)) + for _, node := range p.nodes { + if node == nil { + continue + } + gated := fanIn[node.ID] >= riskGateCallerThreshold() + if !gated && maxPR > 0 { + gated = pageRank[node.ID]/maxPR >= riskGatePRThreshold + } + if gated { + out = append(out, node) } } return out diff --git a/internal/mcp/edit_serialization.go b/internal/mcp/edit_serialization.go new file mode 100644 index 000000000..66db2a0f4 --- /dev/null +++ b/internal/mcp/edit_serialization.go @@ -0,0 +1,419 @@ +package mcp + +import ( + "context" + "fmt" + "path/filepath" + "sort" + "sync" + "sync/atomic" + "time" + + "github.com/zzet/gortex/internal/indexer" +) + +const ( + defaultMutationReindexWait = 3 * time.Second + defaultMutationSafetyWait = 3 * time.Second + mutationReceiptRetention = 10 * time.Minute +) + +var mutationReceiptSequence atomic.Uint64 + +// mutationPathLocks serializes read-modify-write tool calls per on-disk path. +// MCP requests can run concurrently, and atomic rename only makes each write +// indivisible; without this lock two handlers can still read the same snapshot +// and silently overwrite one another. Entries are reference-counted so a large +// daemon does not retain one lock for every file ever edited. +var mutationPathLocks = struct { + sync.Mutex + byPath map[string]*mutationPathLock +}{byPath: make(map[string]*mutationPathLock)} + +type mutationPathLock struct { + token chan struct{} + refs int +} + +// mutationReindexOutcome is the complete freshness state produced after a disk +// mutation. A receipt is present only when the bounded request-path wait ended +// before the watcher ticket did. +type mutationReindexOutcome struct { + Reindexed bool + Pending bool + Receipt string + Generation uint64 + AppliedGeneration uint64 + Err error +} + +type mutationReceipt struct { + id string + repo string + path string + generation uint64 + done chan struct{} + mu sync.RWMutex + result indexer.MutationResult + completed bool +} + +type mutationScheduler interface { + EnqueueFileMutation(context.Context, string) (*indexer.MutationTicket, error) +} + +// acquireMutationPath waits for exclusive mutation access to path. Waiting is +// context-aware: a cancelled MCP request leaves the queue immediately, which +// lets its dispatcher goroutine finish and release admission capacity. +func acquireMutationPath(ctx context.Context, path string) (func(), error) { + path = filepath.Clean(path) + + mutationPathLocks.Lock() + entry := mutationPathLocks.byPath[path] + if entry == nil { + entry = &mutationPathLock{token: make(chan struct{}, 1)} + entry.token <- struct{}{} + mutationPathLocks.byPath[path] = entry + } + entry.refs++ + mutationPathLocks.Unlock() + + select { + case <-entry.token: + if err := ctx.Err(); err != nil { + entry.token <- struct{}{} + releaseMutationPathRef(path, entry) + return nil, err + } + case <-ctx.Done(): + releaseMutationPathRef(path, entry) + return nil, ctx.Err() + } + + var once sync.Once + return func() { + once.Do(func() { + entry.token <- struct{}{} + releaseMutationPathRef(path, entry) + }) + }, nil +} + +// acquireMutationPaths locks a mutation set in lexical path order. A stable +// order prevents deadlock between overlapping batches; cancellation while +// waiting releases every previously acquired path in reverse order. +func acquireMutationPaths(ctx context.Context, paths []string) (func(), error) { + unique := make(map[string]struct{}, len(paths)) + ordered := make([]string, 0, len(paths)) + for _, path := range paths { + clean := filepath.Clean(path) + if _, exists := unique[clean]; exists { + continue + } + unique[clean] = struct{}{} + ordered = append(ordered, clean) + } + sort.Strings(ordered) + + releases := make([]func(), 0, len(ordered)) + for _, path := range ordered { + release, err := acquireMutationPath(ctx, path) + if err != nil { + for i := len(releases) - 1; i >= 0; i-- { + releases[i]() + } + return nil, err + } + releases = append(releases, release) + } + + var once sync.Once + return func() { + once.Do(func() { + for i := len(releases) - 1; i >= 0; i-- { + releases[i]() + } + }) + }, nil +} + +func releaseMutationPathRef(path string, entry *mutationPathLock) { + mutationPathLocks.Lock() + defer mutationPathLocks.Unlock() + entry.refs-- + if entry.refs == 0 && mutationPathLocks.byPath[path] == entry { + delete(mutationPathLocks.byPath, path) + } +} + +func (s *Server) trackMutationTicket(ticket *indexer.MutationTicket) *mutationReceipt { + repo := "" + if s.multiIndexer != nil { + repo = s.multiIndexer.RepoForFile(ticket.Path) + } + receipt := &mutationReceipt{ + id: fmt.Sprintf("mutation-%d", mutationReceiptSequence.Add(1)), + repo: repo, + path: ticket.Path, + generation: ticket.Generation, + done: make(chan struct{}), + } + s.mutationReceipts.Store(receipt.id, receipt) + go func() { + result, ok := <-ticket.Done + if !ok { + result = indexer.MutationResult{ + RequestedGeneration: ticket.Generation, + Err: fmt.Errorf("mutation ticket generation %d closed without a result", ticket.Generation), + } + } + receipt.mu.Lock() + receipt.result = result + receipt.completed = true + receipt.mu.Unlock() + close(receipt.done) + time.AfterFunc(mutationReceiptRetention, func() { + s.mutationReceipts.Delete(receipt.id) + }) + }() + return receipt +} + +func (r *mutationReceipt) outcome(pending bool) mutationReindexOutcome { + r.mu.RLock() + defer r.mu.RUnlock() + outcome := mutationReindexOutcome{ + Pending: pending, + Receipt: r.id, + Generation: r.generation, + } + if r.completed { + outcome.Reindexed = r.result.Reindexed + outcome.AppliedGeneration = r.result.AppliedGeneration + outcome.Err = r.result.Err + outcome.Pending = false + } + return outcome +} + +func (s *Server) mutationWaitDuration() time.Duration { + if s.mutationReindexWait > 0 { + return s.mutationReindexWait + } + return defaultMutationReindexWait +} + +func (s *Server) mutationSafetyWaitDuration() time.Duration { + if s.mutationSafetyWait > 0 { + return s.mutationSafetyWait + } + return defaultMutationSafetyWait +} + +// mutationReindexState returns the graph-freshness state to expose after a +// successful disk mutation. A live watcher already owns debouncing, patch +// serialization, and latest-bytes reconciliation, so duplicating IndexFile in +// the request path both blocks the response and races the watcher. Embedded +// servers have no watcher and retain the synchronous freshness contract. +func (s *Server) mutationReindexState(ctx context.Context, absPath string) mutationReindexOutcome { + if watcher := s.currentWatcher(); watcher != nil { + // Admission is path-scoped and authoritative. Scheduling uses a detached + // context because the disk commit already happened; client cancellation + // must not leave the graph permanently stale. + if scheduler, ok := watcher.(mutationScheduler); ok { + ticket, scheduleErr := scheduler.EnqueueFileMutation(context.WithoutCancel(ctx), absPath) + if scheduleErr != nil { + return mutationReindexOutcome{Err: scheduleErr} + } + if ticket != nil { + receipt := s.trackMutationTicket(ticket) + timer := time.NewTimer(s.mutationWaitDuration()) + defer timer.Stop() + select { + case <-receipt.done: + outcome := receipt.outcome(false) + outcome.Receipt = "" + return outcome + case <-timer.C: + return receipt.outcome(true) + case <-ctx.Done(): + return receipt.outcome(true) + } + } + } + } + if err := ctx.Err(); err != nil { + return mutationReindexOutcome{Err: err} + } + return mutationReindexOutcome{Reindexed: s.reindexFile(absPath)} +} + +// awaitMutationFreshness is the conservative all-repository safety barrier. +// Callers that can resolve a target repository should use the scoped sibling. +func (s *Server) awaitMutationFreshness(ctx context.Context) error { + return s.awaitMutationFreshnessForRepos(ctx) +} + +// awaitMutationFreshnessForRepos waits once, under one shared budget, for every +// ticket in the requested repositories. Receipts with unknown ownership remain +// in scope so incomplete metadata cannot disarm the safety gate. On timeout it +// reports every pending and terminally failed generation, not only the first. +func (s *Server) awaitMutationFreshnessForRepos(ctx context.Context, repos ...string) error { + repoScope := make(map[string]struct{}, len(repos)) + for _, repo := range repos { + if repo != "" { + repoScope[repo] = struct{}{} + } + } + + var receipts []*mutationReceipt + s.mutationReceipts.Range(func(_, value any) bool { + receipt, ok := value.(*mutationReceipt) + if !ok { + return true + } + if len(repoScope) > 0 && receipt.repo != "" { + if _, included := repoScope[receipt.repo]; !included { + return true + } + } + receipts = append(receipts, receipt) + return true + }) + if len(receipts) == 0 { + return nil + } + sort.Slice(receipts, func(i, j int) bool { + if receipts[i].repo != receipts[j].repo { + return receipts[i].repo < receipts[j].repo + } + if receipts[i].path != receipts[j].path { + return receipts[i].path < receipts[j].path + } + if receipts[i].generation != receipts[j].generation { + return receipts[i].generation < receipts[j].generation + } + return receipts[i].id < receipts[j].id + }) + + timer := time.NewTimer(s.mutationSafetyWaitDuration()) + defer timer.Stop() + waitReason := "" +waitLoop: + for _, receipt := range receipts { + select { + case <-receipt.done: + case <-timer.C: + waitReason = "wait budget expired" + break waitLoop + case <-ctx.Done(): + waitReason = "request cancelled: " + ctx.Err().Error() + break waitLoop + } + } + + issues := make([]string, 0, len(receipts)) + for _, receipt := range receipts { + select { + case <-receipt.done: + outcome := receipt.outcome(false) + switch { + case outcome.Err != nil: + issues = append(issues, fmt.Sprintf( + "failed receipt=%s repo=%q path=%q generation=%d error=%q", + receipt.id, receipt.repo, receipt.path, receipt.generation, outcome.Err.Error())) + case !outcome.Reindexed: + issues = append(issues, fmt.Sprintf( + "failed receipt=%s repo=%q path=%q generation=%d error=%q", + receipt.id, receipt.repo, receipt.path, receipt.generation, "reindex not confirmed")) + } + default: + issues = append(issues, fmt.Sprintf( + "pending receipt=%s repo=%q path=%q generation=%d", + receipt.id, receipt.repo, receipt.path, receipt.generation)) + } + } + if len(issues) == 0 { + if err := ctx.Err(); err != nil { + return fmt.Errorf("graph freshness wait cancelled: %w", err) + } + return nil + } + + message := "graph freshness unavailable" + if waitReason != "" { + message += " (" + waitReason + ")" + } + for i, issue := range issues { + if i == 0 { + message += ": " + } else { + message += "; " + } + message += issue + } + return fmt.Errorf("%s", message) +} + +// mutationReposForSymbolIDs resolves a complete repository scope for a symbol +// request. Any unresolved input returns nil, which deliberately widens the +// barrier to all receipts rather than excluding an unknown mutation. +func (s *Server) mutationReposForSymbolIDs(ctx context.Context, ids []string) []string { + repos := make(map[string]struct{}, len(ids)) + for _, id := range ids { + node := s.engineFor(ctx).GetSymbol(id) + if node == nil || node.RepoPrefix == "" { + return nil + } + repos[node.RepoPrefix] = struct{}{} + } + result := make([]string, 0, len(repos)) + for repo := range repos { + result = append(result, repo) + } + sort.Strings(result) + return result +} + +func (s *Server) mutationReceiptState(id string) (mutationReindexOutcome, bool) { + value, ok := s.mutationReceipts.Load(id) + if !ok { + return mutationReindexOutcome{}, false + } + receipt, ok := value.(*mutationReceipt) + if !ok { + return mutationReindexOutcome{}, false + } + select { + case <-receipt.done: + return receipt.outcome(false), true + default: + return receipt.outcome(true), true + } +} + +// attachMutationFreshness records mutually exclusive freshness states. Syntax +// health is only authoritative after completed reindex; reading it while a +// watcher patch is pending would surface stale parse errors and provoke an +// unnecessary source re-read. +func (s *Server) attachMutationFreshness(resp map[string]any, relPath, absPath string, outcome mutationReindexOutcome) { + resp["reindexed"] = outcome.Reindexed + if outcome.Generation > 0 { + resp["reindex_generation"] = outcome.Generation + } + if outcome.AppliedGeneration > 0 { + resp["applied_generation"] = outcome.AppliedGeneration + } + if outcome.Receipt != "" { + resp["reindex_receipt"] = outcome.Receipt + } + if outcome.Pending { + resp["reindex_pending"] = true + return + } + if outcome.Reindexed { + if health := s.fileSyntaxHealth(relPath, absPath); health != nil { + resp["syntax_health"] = health + } + } +} diff --git a/internal/mcp/edit_serialization_test.go b/internal/mcp/edit_serialization_test.go new file mode 100644 index 000000000..199f6bc3d --- /dev/null +++ b/internal/mcp/edit_serialization_test.go @@ -0,0 +1,376 @@ +package mcp + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/indexer" + "github.com/zzet/gortex/internal/query" +) + +type mutationTestWatcher struct { + reject bool + scheduleErr error + scheduled *atomic.Int64 + done <-chan indexer.MutationResult + generation uint64 +} + +func (mutationTestWatcher) History() []indexer.GraphChangeEvent { return nil } +func (mutationTestWatcher) HistorySince(time.Time) []indexer.GraphChangeEvent { + return nil +} +func (mutationTestWatcher) OnSymbolChange(indexer.SymbolChangeCallback) {} +func (w mutationTestWatcher) EnqueueFileMutation(ctx context.Context, path string) (*indexer.MutationTicket, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if w.scheduled != nil { + w.scheduled.Add(1) + } + if w.scheduleErr != nil { + return nil, w.scheduleErr + } + if w.reject { + return nil, nil + } + generation := w.generation + if generation == 0 { + generation = 1 + } + done := w.done + if done == nil { + completed := make(chan indexer.MutationResult, 1) + completed <- indexer.MutationResult{ + RequestedGeneration: generation, + AppliedGeneration: generation, + Reindexed: true, + } + close(completed) + done = completed + } + return &indexer.MutationTicket{Path: path, Generation: generation, Done: done}, nil +} + +type degradedMutationTestWatcher struct { + mutationTestWatcher + reason string +} + +func (w degradedMutationTestWatcher) DegradedReason() string { return w.reason } + +func mutationRefs(path string) int { + path = filepath.Clean(path) + mutationPathLocks.Lock() + defer mutationPathLocks.Unlock() + if entry := mutationPathLocks.byPath[path]; entry != nil { + return entry.refs + } + return 0 +} + +func waitForMutationRefs(t *testing.T, path string, want int) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if mutationRefs(path) == want { + return + } + time.Sleep(time.Millisecond) + } + t.Fatalf("mutation refs for %q = %d, want %d", path, mutationRefs(path), want) +} + +func TestAcquireMutationPathSerializesAndCleansUp(t *testing.T) { + path := filepath.Join(t.TempDir(), "same.go") + firstRelease, err := acquireMutationPath(context.Background(), path) + if err != nil { + t.Fatalf("first acquire: %v", err) + } + + acquired := make(chan func(), 1) + errs := make(chan error, 1) + go func() { + release, acquireErr := acquireMutationPath(context.Background(), path) + if acquireErr != nil { + errs <- acquireErr + return + } + acquired <- release + }() + waitForMutationRefs(t, path, 2) + select { + case <-acquired: + t.Fatal("second acquire entered before first release") + default: + } + + firstRelease() + var secondRelease func() + select { + case err := <-errs: + t.Fatalf("second acquire: %v", err) + case secondRelease = <-acquired: + case <-time.After(2 * time.Second): + t.Fatal("second acquire did not proceed after release") + } + secondRelease() + waitForMutationRefs(t, path, 0) +} + +func TestAcquireMutationPathCancellationReleasesQueueReference(t *testing.T) { + path := filepath.Join(t.TempDir(), "cancel.go") + ownerRelease, err := acquireMutationPath(context.Background(), path) + if err != nil { + t.Fatalf("owner acquire: %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + errs := make(chan error, 1) + go func() { + _, acquireErr := acquireMutationPath(ctx, path) + errs <- acquireErr + }() + waitForMutationRefs(t, path, 2) + cancel() + select { + case err := <-errs: + if !errors.Is(err, context.Canceled) { + t.Fatalf("cancelled acquire error = %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("cancelled acquire did not leave the queue") + } + waitForMutationRefs(t, path, 1) + ownerRelease() + waitForMutationRefs(t, path, 0) +} + +func TestAcquireMutationPathsCancellationReleasesEarlierLocks(t *testing.T) { + first := filepath.Join(t.TempDir(), "a.go") + second := filepath.Join(t.TempDir(), "b.go") + if second < first { + first, second = second, first + } + + releaseSecond, err := acquireMutationPath(context.Background(), second) + if err != nil { + t.Fatal(err) + } + defer releaseSecond() + + ctx, cancel := context.WithCancel(context.Background()) + result := make(chan error, 1) + go func() { + release, lockErr := acquireMutationPaths(ctx, []string{second, first, first}) + if release != nil { + release() + } + result <- lockErr + }() + + waitForMutationRefs(t, first, 1) + waitForMutationRefs(t, second, 2) + cancel() + if lockErr := <-result; !errors.Is(lockErr, context.Canceled) { + t.Fatalf("acquireMutationPaths error = %v, want context.Canceled", lockErr) + } + waitForMutationRefs(t, first, 0) + waitForMutationRefs(t, second, 1) +} + +func TestMutationReindexStateRoutesByPathAdmission(t *testing.T) { + var scheduled atomic.Int64 + active := &Server{watcher: mutationTestWatcher{scheduled: &scheduled}} + outcome := active.mutationReindexState(context.Background(), "/tmp/active.go") + if outcome.Err != nil || !outcome.Reindexed || outcome.Pending || outcome.Generation != 1 || outcome.AppliedGeneration != 1 || outcome.Receipt != "" || scheduled.Load() != 1 { + t.Fatalf("active watcher outcome = %+v scheduled:%d", outcome, scheduled.Load()) + } + + // Aggregate degradation is informational. Direct path admission decides + // whether the owning watcher can accept the mutation. + degraded := &Server{watcher: degradedMutationTestWatcher{ + mutationTestWatcher: mutationTestWatcher{scheduled: &scheduled}, + reason: "other-repo overflow", + }} + outcome = degraded.mutationReindexState(context.Background(), "/tmp/degraded.go") + if outcome.Err != nil || !outcome.Reindexed || outcome.Pending || scheduled.Load() != 2 { + t.Fatalf("degraded watcher outcome = %+v scheduled:%d", outcome, scheduled.Load()) + } + + rejected := &Server{watcher: mutationTestWatcher{reject: true}} + outcome = rejected.mutationReindexState(context.Background(), "/tmp/uncovered.go") + if outcome.Err != nil || outcome.Reindexed || outcome.Pending || outcome.Generation != 0 { + t.Fatalf("uncovered watcher outcome = %+v", outcome) + } + + embedded := &Server{} + outcome = embedded.mutationReindexState(context.Background(), "/tmp/embedded.go") + if outcome.Err != nil || outcome.Reindexed || outcome.Pending || outcome.Generation != 0 { + t.Fatalf("embedded outcome = %+v", outcome) + } + + scheduleFailure := errors.New("schedule failed") + failed := &Server{watcher: mutationTestWatcher{scheduleErr: scheduleFailure}} + outcome = failed.mutationReindexState(context.Background(), "/tmp/failed.go") + if !errors.Is(outcome.Err, scheduleFailure) || outcome.Reindexed || outcome.Pending { + t.Fatalf("schedule failure outcome = %+v", outcome) + } +} + +func TestMutationReindexStateSlowTicketReceiptCompletes(t *testing.T) { + done := make(chan indexer.MutationResult, 1) + s := &Server{ + watcher: mutationTestWatcher{done: done, generation: 7}, + mutationReindexWait: 5 * time.Millisecond, + mutationSafetyWait: 5 * time.Millisecond, + } + outcome := s.mutationReindexState(context.Background(), "/tmp/slow.go") + if outcome.Reindexed || !outcome.Pending || outcome.Receipt == "" || outcome.Generation != 7 { + t.Fatalf("slow ticket outcome = %+v", outcome) + } + if status, ok := s.mutationReceiptState(outcome.Receipt); !ok || !status.Pending || status.Generation != 7 { + t.Fatalf("pending receipt state = %+v, ok=%v", status, ok) + } + if err := s.awaitMutationFreshness(context.Background()); err == nil || !strings.Contains(err.Error(), "pending receipt=") { + t.Fatalf("pending safety barrier error = %v", err) + } + + done <- indexer.MutationResult{ + RequestedGeneration: 7, + AppliedGeneration: 8, + Reindexed: true, + } + close(done) + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + status, ok := s.mutationReceiptState(outcome.Receipt) + if ok && status.Reindexed && !status.Pending && status.AppliedGeneration == 8 { + if err := s.awaitMutationFreshness(context.Background()); err != nil { + t.Fatalf("completed safety barrier: %v", err) + } + return + } + time.Sleep(time.Millisecond) + } + t.Fatal("mutation receipt did not reach a terminal reindexed state") +} + +func TestMutationReindexStateFailureBlocksSafetyReads(t *testing.T) { + patchFailure := errors.New("patch failed") + done := make(chan indexer.MutationResult, 1) + done <- indexer.MutationResult{RequestedGeneration: 3, AppliedGeneration: 3, Err: patchFailure} + close(done) + s := &Server{watcher: mutationTestWatcher{done: done, generation: 3}} + outcome := s.mutationReindexState(context.Background(), "/tmp/broken.go") + if !errors.Is(outcome.Err, patchFailure) || outcome.Reindexed || outcome.Pending { + t.Fatalf("failed ticket outcome = %+v", outcome) + } + if err := s.awaitMutationFreshness(context.Background()); err == nil || !strings.Contains(err.Error(), "patch failed") { + t.Fatalf("failed safety barrier error = %v", err) + } +} + +func TestAttachMutationFreshnessPendingOmitsStaleSyntaxHealth(t *testing.T) { + resp := map[string]any{} + outcome := mutationReindexOutcome{Pending: true, Receipt: "mutation-7", Generation: 7} + (&Server{}).attachMutationFreshness(resp, "pending.go", "/tmp/pending.go", outcome) + if got := resp["reindexed"]; got != false { + t.Fatalf("reindexed = %#v, want false", got) + } + if got := resp["reindex_pending"]; got != true { + t.Fatalf("reindex_pending = %#v, want true", got) + } + if got := resp["reindex_receipt"]; got != "mutation-7" { + t.Fatalf("reindex_receipt = %#v", got) + } + if got := resp["reindex_generation"]; got != uint64(7) { + t.Fatalf("reindex_generation = %#v", got) + } + if _, ok := resp["syntax_health"]; ok { + t.Fatal("pending response exposed stale syntax_health") + } +} + +func TestApplyBatchFileEditSerializesConcurrentWrites(t *testing.T) { + path := filepath.Join(t.TempDir(), "concurrent.txt") + if err := os.WriteFile(path, []byte("alpha beta\n"), 0o644); err != nil { + t.Fatal(err) + } + s := &Server{watcher: mutationTestWatcher{}, session: newSessionState()} + + start := make(chan struct{}) + results := make(chan batchEditResult, 2) + for _, edit := range []batchEditItem{ + {Path: path, OldString: "alpha", NewString: "ALPHA"}, + {Path: path, OldString: "beta", NewString: "BETA"}, + } { + edit := edit + go func() { + <-start + results <- s.applyBatchFileEdit(context.Background(), edit, true) + }() + } + close(start) + for range 2 { + result := <-results + if result.Status != "applied" || !result.Reindexed || result.ReindexPending || result.ReindexError != "" { + t.Fatalf("unexpected result: %+v", result) + } + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(got) != "ALPHA BETA\n" { + t.Fatalf("concurrent edit result = %q", got) + } +} + +func TestApplyBatchSymbolEditFallsBackAfterPendingLineShift(t *testing.T) { + path := filepath.Join(t.TempDir(), "shifted.go") + content := "package sample\n\nfunc target() {\n\tprintln(\"old\")\n}\n\nfunc other() {}\n" + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + g := graph.New() + node := &graph.Node{ + ID: "sample::target", + Name: "target", + Kind: graph.KindFunction, + FilePath: path, + StartLine: 7, + EndLine: 7, + } + g.AddNode(node) + s := &Server{ + engine: query.NewEngine(g), + graph: g, + watcher: mutationTestWatcher{}, + session: newSessionState(), + } + result := s.applyBatchSymbolEdit(context.Background(), batchEditItem{ + SymbolID: node.ID, + OldSource: "println(\"old\")", + NewSource: "println(\"new\")", + }, true) + if result.Status != "applied" || !result.Reindexed || result.ReindexPending || result.ReindexError != "" { + t.Fatalf("unexpected result: %+v", result) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + want := "package sample\n\nfunc target() {\n\tprintln(\"new\")\n}\n\nfunc other() {}\n" + if string(got) != want { + t.Fatalf("stale-range fallback result:\n%s\nwant:\n%s", got, want) + } +} diff --git a/internal/mcp/errors.go b/internal/mcp/errors.go index 766d163d4..35fe8815e 100644 --- a/internal/mcp/errors.go +++ b/internal/mcp/errors.go @@ -93,6 +93,11 @@ const ( // tool in the current phase. The error data carries the current // phase and the allowed-tool list. ErrCodeToolOutOfPhase ErrorCode = "tool_out_of_phase" + + // ErrCodeLocalizationComplete — an explicit localization-only request + // already produced its terminal evidence. The caller must respond, or make + // the one exact read named by data.completion when that state is returned. + ErrCodeLocalizationComplete ErrorCode = "localization_complete" ) // StructuredError is the JSON shape encoded into the TextContent diff --git a/internal/mcp/facade_change_targets_test.go b/internal/mcp/facade_change_targets_test.go new file mode 100644 index 000000000..00a2331ab --- /dev/null +++ b/internal/mcp/facade_change_targets_test.go @@ -0,0 +1,196 @@ +package mcp + +import ( + "context" + "sync/atomic" + "testing" + + mcpgo "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/require" + + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/query" +) + +func TestFacadeChangeTargetsNormalizeScalarArrayAndTargetShapes(t *testing.T) { + server, calls := newFacadeChangeCaptureServer(t) + ids := []string{"repo/a.go::A", "repo/b.go::B"} + shapes := []struct { + name string + args map[string]any + want string + }{ + {name: "scalar ids", args: map[string]any{"options": map[string]any{"ids": ids[0] + "," + ids[1]}}, want: ids[0] + "," + ids[1]}, + {name: "array ids", args: map[string]any{"options": map[string]any{"ids": []any{ids[0], ids[1]}}}, want: ids[0] + "," + ids[1]}, + {name: "target symbol", args: map[string]any{"target": map[string]any{"symbol": ids[0]}}, want: ids[0]}, + {name: "target symbols", args: map[string]any{"target": map[string]any{"symbols": []any{ids[0], ids[1]}}}, want: ids[0] + "," + ids[1]}, + {name: "source symbols compatibility", args: map[string]any{"source": map[string]any{"symbols": []any{ids[0], ids[1]}}}, want: ids[0] + "," + ids[1]}, + } + + for _, operation := range []string{"tests", "guards", "edit_plan"} { + for _, shape := range shapes { + t.Run(operation+"/"+shape.name, func(t *testing.T) { + args := cloneFacadeTestMap(shape.args) + args["operation"] = operation + payload := callFacadeChangeCapture(t, server, args) + require.Equal(t, shape.want, payload["ids"]) + require.NotContains(t, payload, "id") + require.NotContains(t, payload, "symbols") + }) + } + } + require.Equal(t, int64(len(shapes)*3), calls.Load()) +} + +func TestFacadeChangeContractHonorsExplicitSymbolTargets(t *testing.T) { + server, calls := newFacadeChangeCaptureServer(t) + ids := []string{"repo/a.go::A", "repo/b.go::B"} + shapes := []struct { + name string + args map[string]any + want string + }{ + {name: "scalar ids", args: map[string]any{"options": map[string]any{"ids": ids[0] + "," + ids[1]}}, want: ids[0] + "," + ids[1]}, + {name: "array ids", args: map[string]any{"options": map[string]any{"ids": []any{ids[0], ids[1]}}}, want: ids[0] + "," + ids[1]}, + {name: "target symbol", args: map[string]any{"target": map[string]any{"symbol": ids[0]}}, want: ids[0]}, + {name: "target symbols", args: map[string]any{"target": map[string]any{"symbols": []any{ids[0], ids[1]}}}, want: ids[0] + "," + ids[1]}, + {name: "source symbols compatibility", args: map[string]any{"source": map[string]any{"source": "symbols", "symbols": []any{ids[0], ids[1]}}}, want: ids[0] + "," + ids[1]}, + } + + for _, shape := range shapes { + t.Run(shape.name, func(t *testing.T) { + args := cloneFacadeTestMap(shape.args) + args["operation"] = "contract" + payload := callFacadeChangeCapture(t, server, args) + require.Equal(t, "symbols", payload["source"]) + require.Equal(t, shape.want, payload["symbols"]) + require.NotContains(t, payload, "id") + require.NotContains(t, payload, "ids") + }) + } + // Every explicit target reached the symbols path. None silently lowered as + // source=diff, which would analyze the entire dirty worktree. + require.Equal(t, int64(len(shapes)), calls.Load()) +} + +func TestFacadeChangeTargetsRejectEmptyConflictAndImplicitDiff(t *testing.T) { + server, calls := newFacadeChangeCaptureServer(t) + + assertError := func(args map[string]any, contains string) { + t.Helper() + req := mcpgo.CallToolRequest{} + req.Params.Arguments = args + result, err := server.handleFacade(context.Background(), "change", req) + require.NoError(t, err) + require.True(t, result.IsError, "result: %#v", result) + require.Contains(t, toolResultText(result), contains) + } + + assertError(map[string]any{ + "operation": "tests", + "options": map[string]any{"ids": []any{}}, + }, "must not be empty") + assertError(map[string]any{ + "operation": "guards", + "target": map[string]any{"symbol": "repo/a.go::A"}, + "options": map[string]any{"ids": "repo/b.go::B"}, + }, "conflicting symbol selectors") + assertError(map[string]any{ + "operation": "contract", + }, "requires target.symbol/target.symbols or an explicit non-symbol source") + assertError(map[string]any{ + "operation": "contract", + "target": map[string]any{"symbol": "repo/a.go::A"}, + "source": map[string]any{"source": "diff"}, + }, "conflict with source=diff") + require.Zero(t, calls.Load(), "invalid requests must not reach a legacy handler") + + payload := callFacadeChangeCapture(t, server, map[string]any{ + "operation": "contract", + "source": map[string]any{"source": "diff", "scope": "unstaged"}, + }) + require.Equal(t, "diff", payload["source"]) + require.Equal(t, "unstaged", payload["scope"]) + require.NotContains(t, payload, "symbols") + require.Equal(t, int64(1), calls.Load()) +} + +func TestFacadeChangeTargetShapesAreAdvertisedOnce(t *testing.T) { + server, _ := newFacadeChangeCaptureServer(t) + for _, operation := range []string{"tests", "guards", "edit_plan", "contract"} { + t.Run(operation, func(t *testing.T) { + req := mcpgo.CallToolRequest{} + req.Params.Arguments = map[string]any{ + "domain": "change", "operation": operation, "detail": "schema", + } + result, err := server.handleCapabilities(context.Background(), req) + require.NoError(t, err) + payload := unmarshalResult(t, result) + schema := payload["input_schema"].(map[string]any) + properties := schema["properties"].(map[string]any) + target := properties["target"].(map[string]any) + targetProperties := target["properties"].(map[string]any) + require.Contains(t, targetProperties, "symbol") + require.Contains(t, targetProperties, "symbols") + if source, ok := properties["source"].(map[string]any); ok { + sourceProperties, _ := source["properties"].(map[string]any) + require.NotContains(t, sourceProperties, "symbols", "symbol selectors must be advertised only under target") + } + if options, ok := properties["options"].(map[string]any); ok { + optionProperties, _ := options["properties"].(map[string]any) + require.NotContains(t, optionProperties, "ids", "compatibility aliases must not duplicate the canonical target schema") + } + + shape := payload["request_shape"].(map[string]any) + arguments := shape["arguments"].(map[string]any) + require.Contains(t, arguments, "target") + }) + } +} + +func newFacadeChangeCaptureServer(t *testing.T) (*Server, *atomic.Int64) { + t.Helper() + registry := newFacadeRegistry() + calls := &atomic.Int64{} + capture := func(_ context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + calls.Add(1) + return mcpgo.NewToolResultJSON(req.GetArguments()) + } + registry.capture(mcpgo.NewTool("get_test_targets", mcpgo.WithString("ids", mcpgo.Required())), capture) + registry.capture(mcpgo.NewTool("check_guards", mcpgo.WithString("ids", mcpgo.Required())), capture) + registry.capture(mcpgo.NewTool("get_edit_plan", mcpgo.WithString("ids", mcpgo.Required())), capture) + registry.capture(mcpgo.NewTool("change_contract", + mcpgo.WithString("source"), + mcpgo.WithString("symbols"), + mcpgo.WithString("scope"), + mcpgo.WithBoolean("ack"), + ), capture) + + g := graph.New() + return &Server{ + engine: query.NewEngine(g), graph: g, facades: registry, + localization: newLocalizationTerminalState(), + }, calls +} + +func callFacadeChangeCapture(t *testing.T, server *Server, args map[string]any) map[string]any { + t.Helper() + req := mcpgo.CallToolRequest{} + req.Params.Arguments = args + result, err := server.handleFacade(context.Background(), "change", req) + require.NoError(t, err) + require.False(t, result.IsError, "result: %s", toolResultText(result)) + return unmarshalResult(t, result) +} + +func cloneFacadeTestMap(input map[string]any) map[string]any { + out := make(map[string]any, len(input)) + for key, value := range input { + if nested, ok := value.(map[string]any); ok { + out[key] = cloneFacadeTestMap(nested) + continue + } + out[key] = value + } + return out +} diff --git a/internal/mcp/facade_e2e_test.go b/internal/mcp/facade_e2e_test.go new file mode 100644 index 000000000..1f35a44f1 --- /dev/null +++ b/internal/mcp/facade_e2e_test.go @@ -0,0 +1,119 @@ +package mcp + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + mcpgo "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/require" +) + +// TestFacadeProtocolReadAndGuardedEdit exercises the user-facing named-client +// path through real MCP frames and the real file handlers. It guards two core +// compact-surface promises: read.file can return an uncompressed source file +// immediately, and edit.file preserves the legacy dry-run/cardinality guards. +func TestFacadeProtocolReadAndGuardedEdit(t *testing.T) { + srv, root := setupTestServer(t) + ctx := WithSessionID(context.Background(), "facade_real_file_ops") + + initFrame := []byte(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"integration-harness","version":"1.0"}}}`) + require.NotNil(t, srv.MCPServer().HandleMessage(ctx, initFrame)) + + call := func(id int, name string, arguments map[string]any) *mcpgo.CallToolResult { + t.Helper() + frame, err := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": id, + "method": "tools/call", + "params": map[string]any{ + "name": name, + "arguments": arguments, + }, + }) + require.NoError(t, err) + reply := srv.MCPServer().HandleMessage(ctx, frame) + require.NotNil(t, reply) + raw, err := json.Marshal(reply) + require.NoError(t, err) + var envelope struct { + Error any `json:"error"` + Result *mcpgo.CallToolResult `json:"result"` + } + require.NoError(t, json.Unmarshal(raw, &envelope)) + require.Nil(t, envelope.Error) + require.NotNil(t, envelope.Result) + return envelope.Result + } + + readResult := call(2, "read", map[string]any{ + "operation": "file", + "target": map[string]any{"file": "main.go"}, + "options": map[string]any{"compress_bodies": false}, + "output": map[string]any{"format": "json"}, + }) + require.False(t, readResult.IsError, toolResultText(readResult)) + require.Contains(t, toolResultText(readResult), "func helper() {}", + "an explicit uncompressed facade read must preserve function bodies") + require.NotContains(t, toolResultText(readResult), "lines elided") + + var helperID string + for _, node := range srv.engineFor(ctx).FindSymbols("helper") { + if node != nil && node.Name == "helper" { + helperID = node.ID + break + } + } + require.NotEmpty(t, helperID, "fixture must index main.go::helper") + for i, operation := range []string{"source", "symbols"} { + t.Run("batch_"+operation+"_defaults_source", func(t *testing.T) { + result := call(10+i, "read", map[string]any{ + "operation": operation, + "target": map[string]any{"symbols": []string{helperID}}, + "output": map[string]any{"format": "json"}, + }) + require.False(t, result.IsError, toolResultText(result)) + require.Contains(t, toolResultText(result), "func helper() {}") + require.NotNil(t, result.Meta) + facadeMeta, ok := result.Meta.AdditionalFields["gortex_facade"].(map[string]any) + require.True(t, ok) + require.Equal(t, "batch_symbols", facadeMeta["canonical_tool"]) + }) + t.Run("batch_"+operation+"_source_opt_out", func(t *testing.T) { + result := call(20+i, "read", map[string]any{ + "operation": operation, + "target": map[string]any{"symbols": []string{helperID}}, + "options": map[string]any{"include_source": false}, + "output": map[string]any{"format": "json"}, + }) + require.False(t, result.IsError, toolResultText(result)) + require.NotContains(t, toolResultText(result), "func helper() {}") + require.NotNil(t, result.Meta) + facadeMeta, ok := result.Meta.AdditionalFields["gortex_facade"].(map[string]any) + require.True(t, ok) + require.Equal(t, "batch_symbols", facadeMeta["canonical_tool"]) + }) + } + + path := filepath.Join(root, "main.go") + before, err := os.ReadFile(path) + require.NoError(t, err) + editResult := call(3, "edit", map[string]any{ + "operation": "file", + "target": map[string]any{"file": "main.go"}, + "match": "func helper() {}", + "replacement": `func helper() { panic("dry-run") }`, + "guard": map[string]any{"expected_occurrences": 1}, + "dry_run": true, + "output": map[string]any{"format": "json"}, + }) + require.False(t, editResult.IsError, toolResultText(editResult)) + preview := unmarshalResult(t, editResult) + require.Equal(t, true, preview["dry_run"]) + require.Equal(t, float64(1), preview["replacements"]) + after, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, before, after, "a facade dry-run must not modify the file") +} diff --git a/internal/mcp/facade_localization_scope_test.go b/internal/mcp/facade_localization_scope_test.go new file mode 100644 index 000000000..3474f570d --- /dev/null +++ b/internal/mcp/facade_localization_scope_test.go @@ -0,0 +1,149 @@ +package mcp + +import ( + "context" + "path/filepath" + "testing" + + mcpgo "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/require" + + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/query" +) + +func TestFacadeExplorePathLowersToExplicitRepoSelector(t *testing.T) { + registry := newFacadeRegistry() + registry.capture(mcpgo.NewTool("explore", mcpgo.WithString("task", mcpgo.Required())), func(_ context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + return mcpgo.NewToolResultJSON(req.GetArguments()) + }) + server := &Server{facades: registry, localization: newLocalizationTerminalState()} + path := "/tracked/worktrees/search-engine" + req := mcpgo.CallToolRequest{} + req.Params.Arguments = map[string]any{ + "operation": "localize", + "task": "find the multi-root traversal implementation", + "path": path, + "options": map[string]any{"repo": "active-repo"}, + } + result, err := server.handleFacade(context.Background(), "explore", req) + require.NoError(t, err) + arguments := unmarshalResult(t, result) + require.Equal(t, path, arguments["repo"], "explicit path must win over an unrelated repo default") + require.NotContains(t, arguments, "path", "legacy explore ignores path; it must be lowered to repo") +} + +func TestResolveRepoPrefixAcceptsNestedTrackedPath(t *testing.T) { + fixture := newSharedWorkspaceServer(t, true) + nested := filepath.Join(fixture.repoB, "crates", "ignore", "src") + require.Equal(t, "repo-b", fixture.srv.resolveRepoPrefix(nested)) + + ctx := sessionCtx("nested-repo-path", fixture.repoA) + req := makeReq("explore", map[string]any{"repo": nested}) + scope, errResult := fixture.srv.resolveScope(ctx, req, IntentLocate) + require.Nil(t, errResult) + require.Equal(t, map[string]bool{"repo-b": true}, scope.RepoAllow) + + unknown := filepath.Join(t.TempDir(), "not-tracked") + _, errResult = fixture.srv.resolveScope(ctx, makeReq("explore", map[string]any{"repo": unknown}), IntentLocate) + require.NotNil(t, errResult, "an explicit untracked path must fail instead of selecting the active repo") +} + +func TestFacadeResolvedTargetShapesReachLegacyHandlers(t *testing.T) { + g := graph.New() + node := &graph.Node{ + ID: "repo/pkg/worker.go::Run", Name: "Run", QualName: "worker.Run", + Kind: graph.KindFunction, FilePath: "repo/pkg/worker.go", RepoPrefix: "repo", + } + g.AddNode(node) + registry := newFacadeRegistry() + capture := func(_ context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + return mcpgo.NewToolResultJSON(req.GetArguments()) + } + registry.capture(mcpgo.NewTool("get_editing_context", mcpgo.WithString("path", mcpgo.Required())), capture) + registry.capture(mcpgo.NewTool("explain_change_impact", mcpgo.WithString("ids", mcpgo.Required())), capture) + registry.capture(mcpgo.NewTool("batch_symbols", mcpgo.WithString("ids", mcpgo.Required())), capture) + server := &Server{ + engine: query.NewEngine(g), graph: g, facades: registry, + localization: newLocalizationTerminalState(), + } + + call := func(facade string, arguments map[string]any) map[string]any { + t.Helper() + req := mcpgo.CallToolRequest{} + req.Params.Arguments = arguments + result, err := server.handleFacade(context.Background(), facade, req) + require.NoError(t, err) + require.False(t, result.IsError, "result: %#v", result) + return unmarshalResult(t, result) + } + + editing := call("read", map[string]any{ + "operation": "editing_context", + "target": map[string]any{"symbol": node.ID}, + }) + require.Equal(t, node.FilePath, editing["path"]) + require.NotContains(t, editing, "id") + + impact := call("change", map[string]any{ + "operation": "impact", + "target": map[string]any{"file": node.FilePath}, + }) + require.Equal(t, node.ID, impact["ids"]) + require.NotContains(t, impact, "path") + + batch := call("read", map[string]any{ + "operation": "source", + "target": map[string]any{"symbols": []any{node.ID}}, + }) + require.Equal(t, node.ID, batch["ids"]) + require.Equal(t, true, batch["include_source"]) +} + +func TestFacadeResolvedTargetShapesAreAdvertised(t *testing.T) { + registry := newFacadeRegistry() + handler := func(context.Context, mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + return mcpgo.NewToolResultText("ok"), nil + } + registry.capture(mcpgo.NewTool("get_editing_context", mcpgo.WithString("path", mcpgo.Required())), handler) + registry.capture(mcpgo.NewTool("explain_change_impact", mcpgo.WithString("ids", mcpgo.Required())), handler) + server := &Server{facades: registry} + + targetProperties := func(domain, operation string) map[string]any { + t.Helper() + req := mcpgo.CallToolRequest{} + req.Params.Arguments = map[string]any{"domain": domain, "operation": operation, "detail": "schema"} + result, err := server.handleCapabilities(context.Background(), req) + require.NoError(t, err) + payload := unmarshalResult(t, result) + schema := payload["input_schema"].(map[string]any) + properties := schema["properties"].(map[string]any) + target := properties["target"].(map[string]any) + return target["properties"].(map[string]any) + } + + require.Contains(t, targetProperties("read", "editing_context"), "file") + require.Contains(t, targetProperties("read", "editing_context"), "symbol") + require.Contains(t, targetProperties("change", "impact"), "symbol") + require.Contains(t, targetProperties("change", "impact"), "file") +} + +func TestExploreRepeatedGenericConceptIsNeitherTerminalNorExact(t *testing.T) { + targets := []exploreTarget{ + {node: &graph.Node{ID: "repo/a.go::walk", Name: "walk", QualName: "parser.walk", Kind: graph.KindFunction, FilePath: "repo/a.go"}, score: 2}, + {node: &graph.Node{ID: "repo/b.go::walk", Name: "walk", QualName: "tree.walk", Kind: graph.KindFunction, FilePath: "repo/b.go"}, score: 1}, + } + task := "walk walk walk" + require.True(t, exploreQueryIsConceptTask(task)) + require.False(t, exploreAnswerReady(task, targets)) + require.Empty(t, exploreLocalizationExactTarget(task, targets)) + + anchored := &graph.Node{ + ID: "repo/pkg/walk.go::WalkBuilder.build", Name: "build", QualName: "WalkBuilder.build", + Kind: graph.KindMethod, FilePath: "repo/pkg/walk.go", + } + anchorTask := "repo/pkg/walk.go::WalkBuilder.build" + anchorTargets := []exploreTarget{{node: anchored, score: 1}} + require.True(t, exploreAnswerReady(anchorTask, anchorTargets)) + require.Equal(t, anchored.ID, exploreLocalizationExactTarget(anchorTask, anchorTargets)) +} diff --git a/internal/mcp/facade_registry.go b/internal/mcp/facade_registry.go new file mode 100644 index 000000000..27f5906f6 --- /dev/null +++ b/internal/mcp/facade_registry.go @@ -0,0 +1,444 @@ +package mcp + +import ( + "sort" + "strings" + "sync" + + mcpgo "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +// FacadeSurfaceVersion is the negotiated name of the compact, operation- +// dispatched MCP surface. Legacy tool names remain registered; this surface is +// a stable compatibility facade over those handlers. +const FacadeSurfaceVersion = "facade-v1" + +type facadeEffect string + +const ( + facadeEffectRead facadeEffect = "read" + facadeEffectLocalWrite facadeEffect = "local_write" + facadeEffectControlWrite facadeEffect = "control_write" + facadeEffectSessionWrite facadeEffect = "session_write" + facadeEffectExternalWrite facadeEffect = "external_write" +) + +// facadeOperationSpec is the canonical mapping between a stable facade +// operation and its legacy implementation. Fixed arguments are injected after +// user arguments and therefore cannot be overridden (used for effect-safe +// extraction such as analyze(kind=temporal_verify)). +type facadeOperationSpec struct { + Facade string + Operation string + Legacy string + Effect facadeEffect + Fixed map[string]any + Hidden bool +} + +type capturedFacadeTool struct { + tool mcpgo.Tool + handler server.ToolHandlerFunc +} + +// facadeRegistry is per-server because optional handlers (LLM, overlays, +// proxy controls) can be registered after NewServer. The operation table is +// immutable; captured availability is protected for those late registrations. +type facadeRegistry struct { + mu sync.RWMutex + byFacade map[string]map[string]facadeOperationSpec + byLegacy map[string][]facadeOperationSpec + captured map[string]capturedFacadeTool +} + +func newFacadeRegistry() *facadeRegistry { + r := &facadeRegistry{ + byFacade: make(map[string]map[string]facadeOperationSpec), + byLegacy: make(map[string][]facadeOperationSpec), + captured: make(map[string]capturedFacadeTool), + } + for _, spec := range facadeOperationSpecs() { + if !spec.Hidden { + ops := r.byFacade[spec.Facade] + if ops == nil { + ops = make(map[string]facadeOperationSpec) + r.byFacade[spec.Facade] = ops + } + if _, exists := ops[spec.Operation]; exists { + panic("duplicate MCP facade operation: " + spec.Facade + "." + spec.Operation) + } + ops[spec.Operation] = spec + } + r.byLegacy[spec.Legacy] = append(r.byLegacy[spec.Legacy], spec) + } + return r +} + +func (r *facadeRegistry) capture(tool mcpgo.Tool, handler server.ToolHandlerFunc) { + if r == nil || handler == nil || len(r.byLegacy[tool.Name]) == 0 { + return + } + r.mu.Lock() + r.captured[tool.Name] = capturedFacadeTool{tool: tool, handler: handler} + r.mu.Unlock() +} + +func (r *facadeRegistry) operation(facade, operation string) (facadeOperationSpec, bool) { + if r == nil { + return facadeOperationSpec{}, false + } + spec, ok := r.byFacade[facade][operation] + return spec, ok +} + +func (r *facadeRegistry) legacy(name string) (capturedFacadeTool, bool) { + if r == nil { + return capturedFacadeTool{}, false + } + r.mu.RLock() + tool, ok := r.captured[name] + r.mu.RUnlock() + return tool, ok +} + +func (r *facadeRegistry) operations(facade string) []facadeOperationSpec { + if r == nil { + return nil + } + ops := r.byFacade[facade] + out := make([]facadeOperationSpec, 0, len(ops)) + for _, spec := range ops { + out = append(out, spec) + } + sort.Slice(out, func(i, j int) bool { return out[i].Operation < out[j].Operation }) + return out +} + +// availableOperations is the public runtime catalogue. The migration table is +// intentionally broader than any one server configuration, so only operations +// whose implementation handler was actually captured may be advertised. +func (r *facadeRegistry) availableOperations(facade string) []facadeOperationSpec { + if r == nil { + return nil + } + r.mu.RLock() + defer r.mu.RUnlock() + ops := r.byFacade[facade] + out := make([]facadeOperationSpec, 0, len(ops)) + for _, spec := range ops { + if _, available := r.captured[spec.Legacy]; available { + out = append(out, spec) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].Operation < out[j].Operation }) + return out +} + +func (r *facadeRegistry) mapsLegacy(name string) bool { + return r != nil && len(r.byLegacy[name]) > 0 +} + +func facadeToolNames() []string { + return []string{ + "analyze", "ask", "capabilities", "change", "edit", "explore", + "overlay", "pr", "publish_review", "read", "recall", "refactor", + "relations", "remember", "response", "review", "search", "session", + "trace", "workspace", "workspace_admin", + } +} + +// FacadeToolNames returns the complete stable facade-v1 tool roster. The +// returned slice is a fresh copy so CLI/help callers cannot mutate the server's +// canonical surface. +func FacadeToolNames() []string { + return append([]string(nil), facadeToolNames()...) +} + +// IsFacadeToolName reports whether name belongs to the public facade-v1 +// surface. +func IsFacadeToolName(name string) bool { return isFacadeToolName(name) } + +// IsDedicatedFacadeToolName reports whether name exists only on facade-v1. +// Shared names such as analyze/explore/review/ask retain legacy meanings and +// are deliberately excluded. +func IsDedicatedFacadeToolName(name string) bool { return isDedicatedFacadeTool(name) } + +// PublicOperationForLegacy resolves an implementation-era tool name to the +// compact public domain and operation used in user-facing migration guidance. +// When one legacy handler has deliberate effect splits, the first safe public +// mapping in the canonical registry is returned; callers should obtain its +// exact request through capabilities rather than forwarding legacy arguments. +func PublicOperationForLegacy(name string) (domain, operation string, ok bool) { + for _, spec := range facadeOperationSpecs() { + if spec.Legacy == name && !spec.Hidden { + return spec.Facade, spec.Operation, true + } + } + return "", "", false +} + +func isFacadeToolName(name string) bool { + for _, candidate := range facadeToolNames() { + if candidate == name { + return true + } + } + return false +} + +func isDedicatedFacadeTool(name string) bool { + // These four names pre-date facade-v1 and retain their legacy behavior + // outside a facade session. Every other facade name is new and hidden from + // legacy surfaces. + switch name { + case "analyze", "ask", "explore", "review": + return false + default: + return isFacadeToolName(name) + } +} + +func addFacadeGroup(dst *[]facadeOperationSpec, facade string, effect facadeEffect, ops map[string]string) { + keys := make([]string, 0, len(ops)) + for op := range ops { + keys = append(keys, op) + } + sort.Strings(keys) + for _, op := range keys { + *dst = append(*dst, facadeOperationSpec{Facade: facade, Operation: op, Legacy: ops[op], Effect: effect}) + } +} + +// adminAnalyzeKinds are legacy analyze dispatcher operations that change +// durable graph state (and, for temporal_verify, also call an external model +// and write a cache). The public analyze tool is strictly read-only, so these +// operations are available only through workspace_admin with a server-fixed +// kind argument. +var adminAnalyzeKinds = []string{ + "blame", + "coverage", + "sql_rebuild", + "temporal_verify", +} + +func analyzeKindRequiresAdmin(kind string) bool { + i := sort.SearchStrings(adminAnalyzeKinds, kind) + return i < len(adminAnalyzeKinds) && adminAnalyzeKinds[i] == kind +} + +// facadeOperationSpecs is the single v1 migration table. Every legacy MCP +// tool maps to exactly one ordinary facade operation, except deliberate +// effect splits such as analyze(kind=temporal_verify), which has a second, +// mutating entry under workspace_admin. +func facadeOperationSpecs() []facadeOperationSpec { + var specs []facadeOperationSpec + addFacadeGroup(&specs, "explore", facadeEffectRead, map[string]string{ + "context": "smart_context", "closure": "context_closure", "outline": "get_repo_outline", + "plan": "plan_turn", "prefetch": "prefetch_context", "suggest": "suggest_queries", + "task": "explore", "wakeup": "gortex_wakeup", + }) + // Localization-only requests use the same retrieval implementation but add + // an explicit terminality contract. Keeping this opt-in prevents diagnostic + // and implementation requests from being terminated by a ranking heuristic. + specs = append(specs, facadeOperationSpec{ + Facade: "explore", Operation: "localize", Legacy: "explore", + Effect: facadeEffectRead, Fixed: map[string]any{"localize": true}, + }) + addFacadeGroup(&specs, "search", facadeEffectRead, map[string]string{ + "artifacts": "search_artifacts", "ast": "search_ast", "completion": "graph_completion_search", + "files": "find_files", "symbols": "search_symbols", "text": "search_text", "winnow": "winnow_symbols", + }) + // search_symbols can invoke an LLM under its legacy assist=auto default. + // The public search boundary is deterministic and local; callers that + // explicitly want model-backed research use ask (or the legacy surface). + for i := range specs { + if specs[i].Facade == "search" && specs[i].Operation == "symbols" { + specs[i].Fixed = map[string]any{"assist": "off"} + break + } + } + addFacadeGroup(&specs, "read", facadeEffectRead, map[string]string{ + "artifact": "get_artifact", "editing_context": "get_editing_context", "file": "read_file", + "history": "get_symbol_history", "source": "get_symbol_source", "summary": "get_file_summary", + "symbols": "batch_symbols", + }) + // get_symbol returns metadata without the source body, while source already + // includes location and signature. Keep the legacy handler reachable only + // on compatibility surfaces; the public read tool has one symbol default. + specs = append(specs, facadeOperationSpec{ + Facade: "read", Operation: "symbol_metadata_compat", Legacy: "get_symbol", + Effect: facadeEffectRead, Hidden: true, + }) + addFacadeGroup(&specs, "relations", facadeEffectRead, map[string]string{ + "callers": "get_callers", "cluster": "get_cluster", "declaration": "find_declaration", + "dependencies": "get_dependencies", "dependents": "get_dependents", "hierarchy": "get_class_hierarchy", + "implementations": "find_implementations", "import_path": "find_import_path", "overrides": "find_overrides", + "references": "check_references", "usages": "find_usages", + }) + addFacadeGroup(&specs, "trace", facadeEffectRead, map[string]string{ + "call_chain": "get_call_chain", "cfg": "get_cfg", "flow": "flow_between", + "graph": "graph_query", "path": "trace_path", "taint": "taint_paths", "walk": "walk_graph", + }) + addFacadeGroup(&specs, "analyze", facadeEffectRead, map[string]string{ + "agent_config": "audit_agent_config", "architecture": "get_architecture", "citation": "verify_citation", + "clones": "find_clones", "co_change": "find_co_changing_symbols", "communities": "get_communities", + "contracts": "contracts", "coupling": "get_coupling_metrics", "extraction": "get_extraction_candidates", + "health": "audit_health", "inspections": "run_inspections", + "inspection_catalog": "list_inspections", "knowledge_gaps": "get_knowledge_gaps", "lint": "lint_file", + "processes": "get_processes", "recent_changes": "get_recent_changes", "replay": "replay_episode", + "surprising_connections": "get_surprising_connections", "untested": "get_untested_symbols", "why": "why", + "churn": "get_churn_rate", + }) + // Co-change discovery historically starts an asynchronous git mine that + // persists EdgeCoChange records. Compact analysis reads only the daemon's + // prewarmed cache; explicit legacy calls retain lazy refresh behavior. + for i := range specs { + if specs[i].Facade == "analyze" && specs[i].Operation == "co_change" { + specs[i].Fixed = map[string]any{"refresh": false} + break + } + } + // The legacy dispatcher accepts kind=help even though help is not an + // analysis kind. Make it the safe public default and the ordinary migration + // mapping for the shared legacy analyze name. + specs = append(specs, facadeOperationSpec{ + Facade: "analyze", Operation: "help", Legacy: "analyze", + Effect: facadeEffectRead, Fixed: map[string]any{"kind": "help"}, + }) + addFacadeGroup(&specs, "ask", facadeEffectRead, map[string]string{"research": "ask"}) + addFacadeGroup(&specs, "change", facadeEffectRead, map[string]string{ + "api_impact": "api_impact", "code_actions": "get_code_actions", "compare_branches": "compare_branches", + "compare_overlay": "compare_with_overlay", "contract": "change_contract", "detect": "detect_changes", + "diagnostics": "get_diagnostics", "edit_plan": "get_edit_plan", "guards": "check_guards", + "impact": "explain_change_impact", "overlay_branches": "overlay_branches", "overlay_state": "overlay_list", + "pattern": "suggest_pattern", "preview": "preview_edit", "ranges": "symbols_for_ranges", + "tests": "get_test_targets", "verify": "verify_change", + }) + // change_contract can persist a risk acknowledgement when ack=true. Keep + // the advisory change boundary read-only; acknowledgement is an explicit + // durable-memory operation below. + for i := range specs { + if specs[i].Facade == "change" && specs[i].Operation == "contract" { + specs[i].Fixed = map[string]any{"ack": false} + break + } + } + // simulate_chain can persist an overlay when keep=true. The read-only + // change facade fixes keep=false; persistent simulations belong to overlay. + specs = append(specs, facadeOperationSpec{ + Facade: "change", Operation: "simulate", Legacy: "simulate_chain", + Effect: facadeEffectRead, Fixed: map[string]any{"keep": false}, + }) + addFacadeGroup(&specs, "edit", facadeEffectLocalWrite, map[string]string{ + "batch": "batch_edit", "docs": "generate_docs", "export_graph": "export_graph", "file": "edit_file", "scaffold": "scaffold", + "skill": "generate_skill", "symbol": "edit_symbol", "write": "write_file", + }) + // generate_wiki can call an LLM when enhance=true. Keep the ordinary edit + // authorization boundary local-only; enhanced generation remains available + // through the explicit legacy compatibility surface. + specs = append(specs, facadeOperationSpec{ + Facade: "edit", Operation: "wiki", Legacy: "generate_wiki", + Effect: facadeEffectLocalWrite, Fixed: map[string]any{"enhance": false}, + }) + specs = append(specs, facadeOperationSpec{ + Facade: "edit", Operation: "apply_overlay", Legacy: "overlay_merge", + Effect: facadeEffectLocalWrite, Fixed: map[string]any{"to_disk": true}, + }) + addFacadeGroup(&specs, "refactor", facadeEffectLocalWrite, map[string]string{ + "apply_code_action": "apply_code_action", "delete": "safe_delete_symbol", "fix_all": "fix_all_in_file", + "inline": "inline_symbol", "move": "move_symbol", "rename": "rename_symbol", + }) + addFacadeGroup(&specs, "review", facadeEffectRead, map[string]string{ + "critique": "critique_review", "diff_context": "diff_context", "pack": "review_pack", + "pr_context": "pr_review_context", "questions": "suggested_review_questions", "run": "review", + "sibling_context": "sibling_diff_context", + }) + addFacadeGroup(&specs, "publish_review", facadeEffectExternalWrite, map[string]string{"post": "post_review"}) + addFacadeGroup(&specs, "pr", facadeEffectRead, map[string]string{ + "conflicts": "conflicts_prs", "impact": "get_pr_impact", "list": "list_prs", + "reviewers": "suggest_reviewers", "risk": "pr_risk", "triage": "triage_prs", + }) + addFacadeGroup(&specs, "recall", facadeEffectRead, map[string]string{ + "distill": "distill_session", "memories": "query_memories", "notebook_find": "notebook_find", + "notebook_list": "notebook_list", "notebook_show": "notebook_show", "notes": "query_notes", + "onboarding": "check_onboarding_performed", + }) + // surface_memories normally updates access counters, which affect future + // ranking. Compact recall is a true read; explicit legacy calls retain the + // historical mark_accessed default. + specs = append(specs, facadeOperationSpec{ + Facade: "recall", Operation: "surface", Legacy: "surface_memories", + Effect: facadeEffectRead, Fixed: map[string]any{"mark_accessed": false}, + }) + addFacadeGroup(&specs, "remember", facadeEffectLocalWrite, map[string]string{ + "edit_memory": "edit_memory", "memory": "store_memory", "note": "save_note", + "notebook": "notebook_save", "notebook_used": "notebook_used", "rename_memory": "rename_memory", + "suppress_finding": "suppress_finding", + }) + specs = append(specs, facadeOperationSpec{ + Facade: "remember", Operation: "risk_ack", Legacy: "change_contract", + Effect: facadeEffectLocalWrite, Fixed: map[string]any{"ack": true}, + }) + addFacadeGroup(&specs, "workspace", facadeEffectRead, map[string]string{ + "active_project": "get_active_project", "graph": "graph_stats", "index": "index_health", + "info": "workspace_info", "project": "query_project", "proxy": "proxy_status", + "repos": "list_repos", "scopes": "list_scopes", + }) + addFacadeGroup(&specs, "workspace_admin", facadeEffectControlWrite, map[string]string{ + "delete_scope": "delete_scope", "enrich_churn": "enrich_churn", + "enrich_releases": "enrich_releases", "feedback": "feedback", "index": "index_repository", + "reindex": "reindex_repository", "save_scope": "save_scope", "set_active_project": "set_active_project", + "track": "track_repository", "untrack": "untrack_repository", + }) + addFacadeGroup(&specs, "session", facadeEffectSessionWrite, map[string]string{ + "agents": "agent_registry", "cursor": "nav", "planning_mode": "set_planning_mode", "proxy_disable": "proxy_disable", + "proxy_enable": "proxy_enable", "subscribe_daemon_health": "subscribe_daemon_health", + "subscribe_diagnostics": "subscribe_diagnostics", "subscribe_graph_invalidated": "subscribe_graph_invalidated", + "subscribe_stale_refs": "subscribe_stale_refs", "subscribe_workspace_readiness": "subscribe_workspace_readiness", + "unsubscribe_daemon_health": "unsubscribe_daemon_health", "unsubscribe_diagnostics": "unsubscribe_diagnostics", + "unsubscribe_graph_invalidated": "unsubscribe_graph_invalidated", "unsubscribe_stale_refs": "unsubscribe_stale_refs", + "unsubscribe_workspace_readiness": "unsubscribe_workspace_readiness", "workflow": "workflow", + }) + // The unified legacy analyze dispatcher mixes reads with durable graph + // enrichers. Keep every mutating kind behind one explicit control-write + // boundary and make the selected kind impossible for callers to override. + for _, kind := range adminAnalyzeKinds { + specs = append(specs, facadeOperationSpec{ + Facade: "workspace_admin", Operation: kind, Legacy: "analyze", + Effect: facadeEffectControlWrite, Fixed: map[string]any{"kind": kind}, + }) + } + addFacadeGroup(&specs, "overlay", facadeEffectSessionWrite, map[string]string{ + "delete": "overlay_delete", "drop": "overlay_drop", "drop_branch": "overlay_drop_branch", + "fork": "overlay_fork", "keepalive": "overlay_keepalive", "push": "overlay_push", + "register": "overlay_register", "switch": "overlay_switch", + }) + specs = append(specs, facadeOperationSpec{ + Facade: "overlay", Operation: "simulate", Legacy: "simulate_chain", + Effect: facadeEffectSessionWrite, Fixed: map[string]any{"keep": true}, + }) + specs = append(specs, facadeOperationSpec{ + Facade: "overlay", Operation: "merge", Legacy: "overlay_merge", + Effect: facadeEffectSessionWrite, Fixed: map[string]any{"to_disk": false}, + }) + addFacadeGroup(&specs, "response", facadeEffectRead, map[string]string{ + "export_context": "export_context", "grep": "ctx_grep", "peek": "ctx_peek", "slice": "ctx_slice", "stats": "ctx_stats", + }) + // Exact compatibility aliases intentionally map to the same canonical + // operations. They remain callable by legacy name but never become new + // facade operations. + specs = append(specs, + facadeOperationSpec{Facade: "capabilities", Operation: "legacy_profile", Legacy: "tool_profile", Effect: facadeEffectRead, Hidden: true}, + facadeOperationSpec{Facade: "capabilities", Operation: "legacy_search", Legacy: "tools_search", Effect: facadeEffectRead, Hidden: true}, + facadeOperationSpec{Facade: "response", Operation: "grep_compat", Legacy: "grep_results", Effect: facadeEffectRead, Hidden: true}, + facadeOperationSpec{Facade: "response", Operation: "head_compat", Legacy: "head_results", Effect: facadeEffectRead, Hidden: true}, + ) + return specs +} + +func normalizeFacadeOperation(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + value = strings.ReplaceAll(value, "-", "_") + return value +} diff --git a/internal/mcp/facade_schema.go b/internal/mcp/facade_schema.go new file mode 100644 index 000000000..13bdf6983 --- /dev/null +++ b/internal/mcp/facade_schema.go @@ -0,0 +1,561 @@ +package mcp + +import ( + "encoding/json" + "fmt" + "slices" + "sort" + "strings" +) + +type facadePublicPath struct { + container string + field string +} + +func (p facadePublicPath) key() string { + if p.container == "" { + return p.field + } + return p.container + "." + p.field +} + +func facadePublicCapabilitySchema( + spec facadeOperationSpec, + legacyProperties map[string]any, + legacyRequired []string, + requestShape map[string]any, +) map[string]any { + definition := facadeToolDefinition(spec.Facade) + staticProperties := definition.InputSchema.Properties + requestArguments, _ := requestShape["arguments"].(map[string]any) + + properties := make(map[string]any) + requiredTop := make(map[string]struct{}) + requiredNested := make(map[string]map[string]struct{}) + requestPaths := make(map[string]struct{}) + legacyPaths := make(map[string][]facadePublicPath) + + ensureContainer := func(name string) map[string]any { + if existing, ok := properties[name].(map[string]any); ok { + return existing + } + container := map[string]any{ + "type": "object", + "properties": map[string]any{}, + "additionalProperties": false, + } + if raw, ok := staticProperties[name].(map[string]any); ok { + if description, ok := raw["description"]; ok { + container["description"] = description + } + } + properties[name] = container + return container + } + putPath := func(path facadePublicPath, schema any) { + if path.container == "" { + properties[path.field] = schema + return + } + container := ensureContainer(path.container) + container["properties"].(map[string]any)[path.field] = schema + } + addLegacyPath := func(field string, path facadePublicPath) { + for _, existing := range legacyPaths[field] { + if existing == path { + return + } + } + legacyPaths[field] = append(legacyPaths[field], path) + } + + addCandidate := func(path facadePublicPath, sample any, fromRequest bool) []string { + if fromRequest { + requestPaths[path.key()] = struct{}{} + } + lowered := facadeProbePublicPath(spec, path, sample) + mapped := make([]string, 0, len(lowered)) + for field := range lowered { + if _, fixed := spec.Fixed[field]; fixed { + continue + } + if _, captured := legacyProperties[field]; captured { + mapped = append(mapped, field) + } + } + sort.Strings(mapped) + if !fromRequest && len(mapped) == 0 { + return nil + } + var raw any + if path.container == "" { + raw = staticProperties[path.field] + } else if path.container == "target" || path.container == "to" { + raw = facadeTargetProperties()[path.field] + } else if container, ok := staticProperties[path.container].(map[string]any); ok { + if nested, ok := container["properties"].(map[string]any); ok { + raw = nested[path.field] + } + } + if raw == nil && len(mapped) > 0 { + raw = legacyProperties[mapped[0]] + } + putPath(path, facadePublicValueSchema(sample, raw)) + for _, field := range mapped { + addLegacyPath(field, path) + } + return mapped + } + + argumentKeys := sortedFacadeMapKeys(requestArguments) + for _, field := range argumentKeys { + value := requestArguments[field] + if field == "operation" { + schema := facadePublicValueSchema(value, staticProperties[field]) + schema["const"] = spec.Operation + schema["enum"] = []string{spec.Operation} + properties[field] = schema + requiredTop[field] = struct{}{} + continue + } + if facadePublicContainer(field) { + ensureContainer(field) + if nested, ok := value.(map[string]any); ok { + for _, nestedField := range sortedFacadeMapKeys(nested) { + addCandidate(facadePublicPath{container: field, field: nestedField}, nested[nestedField], true) + } + } + continue + } + addCandidate(facadePublicPath{field: field}, value, true) + } + + // Stable documented top-level aliases are available only when the actual + // normalizer lowers them into a field captured by this operation. + for _, field := range sortedFacadeMapKeys(staticProperties) { + if field == "operation" || facadePublicContainer(field) { + continue + } + path := facadePublicPath{field: field} + if _, exists := properties[field]; exists { + continue + } + addCandidate(path, facadeSchemaPlaceholder(field, staticProperties[field]), false) + } + + // A singular/plural symbol pair is one public selector family. Advertise + // the alternate only when both forms lower to the same captured field. + if target, ok := requestArguments["target"].(map[string]any); ok { + for from, to := range map[string]string{"symbol": "symbols", "symbols": "symbol"} { + fromValue, selected := target[from] + if !selected { + continue + } + fromMapped := facadeCapturedProbeFields(spec, legacyProperties, facadePublicPath{container: "target", field: from}, fromValue) + toRaw := facadeTargetProperties()[to] + toValue := facadeSchemaPlaceholder(to, toRaw) + toMapped := facadeCapturedProbeFields(spec, legacyProperties, facadePublicPath{container: "target", field: to}, toValue) + if facadeStringSetsOverlap(fromMapped, toMapped) { + addCandidate(facadePublicPath{container: "target", field: to}, toValue, false) + } + } + } + + // Some public selectors are resolved against the graph before the legacy + // handler runs, so they cannot be inferred from legacy schema fields alone. + // Keep them in the same canonical target envelope as their static sibling. + if target, ok := properties["target"].(map[string]any); ok { + targetProperties, _ := target["properties"].(map[string]any) + var dynamicSelector string + switch spec.Facade + "." + spec.Operation { + case "read.editing_context": + dynamicSelector = "symbol" + case "change.impact": + dynamicSelector = "file" + } + if dynamicSelector != "" { + raw := facadeTargetProperties()[dynamicSelector] + targetProperties[dynamicSelector] = facadePublicValueSchema(facadeSchemaPlaceholder(dynamicSelector, raw), raw) + } + } + + // Every remaining captured field is still public, but only through a stable + // envelope. Cold domains keep their operation payload under arguments; + // common domains use semantic envelopes with options as the safe fallback. + for _, field := range sortedFacadeMapKeys(legacyProperties) { + if _, fixed := spec.Fixed[field]; fixed { + continue + } + if len(legacyPaths[field]) > 0 { + continue + } + path := facadeFallbackPublicPath(spec, field) + putPath(path, facadePublicValueSchema(facadeSchemaPlaceholder(field, legacyProperties[field]), legacyProperties[field])) + addLegacyPath(field, path) + } + + markRequired := func(path facadePublicPath) { + if path.container == "" { + requiredTop[path.field] = struct{}{} + return + } + requiredTop[path.container] = struct{}{} + if path.container == "target" || path.container == "to" { + container := ensureContainer(path.container) + container["minProperties"] = 1 + container["maxProperties"] = 1 + return + } + if requiredNested[path.container] == nil { + requiredNested[path.container] = make(map[string]struct{}) + } + requiredNested[path.container][path.field] = struct{}{} + } + for _, field := range legacyRequired { + if _, fixed := spec.Fixed[field]; fixed { + continue + } + paths := legacyPaths[field] + if len(paths) == 0 { + continue + } + selected := paths[0] + for _, path := range paths { + if _, canonical := requestPaths[path.key()]; canonical { + selected = path + break + } + } + markRequired(selected) + } + for _, field := range definition.InputSchema.Required { + if _, available := properties[field]; available { + requiredTop[field] = struct{}{} + } + } + for containerName, fields := range requiredNested { + container := ensureContainer(containerName) + container["required"] = sortedFacadeSetKeys(fields) + } + for _, name := range []string{"target", "to"} { + if container, ok := properties[name].(map[string]any); ok { + if nested, _ := container["properties"].(map[string]any); len(nested) > 0 { + container["minProperties"] = 1 + container["maxProperties"] = 1 + } + } + } + + schema := map[string]any{ + "type": "object", + "properties": properties, + "additionalProperties": false, + } + if len(requiredTop) > 0 { + schema["required"] = sortedFacadeSetKeys(requiredTop) + } + return schema +} + +func facadePublicContainer(field string) bool { + switch field { + case "arguments", "target", "source", "options", "output", "context", "guard", "to": + return true + default: + return false + } +} + +func facadeProbePublicPath(spec facadeOperationSpec, path facadePublicPath, value any) map[string]any { + input := map[string]any{"operation": spec.Operation} + if path.container == "" { + input[path.field] = value + } else { + input[path.container] = map[string]any{path.field: value} + } + return normalizeFacadeArguments(spec, input) +} + +func facadeCapturedProbeFields(spec facadeOperationSpec, properties map[string]any, path facadePublicPath, value any) map[string]struct{} { + out := make(map[string]struct{}) + for field := range facadeProbePublicPath(spec, path, value) { + if _, fixed := spec.Fixed[field]; fixed { + continue + } + if _, captured := properties[field]; captured { + out[field] = struct{}{} + } + } + return out +} + +func facadeStringSetsOverlap(left, right map[string]struct{}) bool { + for value := range left { + if _, ok := right[value]; ok { + return true + } + } + return false +} + +func facadeFallbackPublicPath(spec facadeOperationSpec, field string) facadePublicPath { + if facadeColdDomain(spec.Facade) { + return facadePublicPath{container: "arguments", field: field} + } + if spec.Facade == "change" && spec.Operation == "impact" { + return facadePublicPath{container: "output", field: field} + } + switch field { + case "format", "max_bytes", "fields", "compact", "summary_only": + return facadePublicPath{container: "output", field: field} + } + if spec.Facade == "read" { + switch field { + case "context_lines", "max_lines", "compress_bodies": + return facadePublicPath{container: "context", field: field} + } + } + if spec.Facade == "edit" && (strings.Contains(field, "etag") || strings.Contains(field, "hash") || strings.Contains(field, "occurrence") || strings.HasPrefix(field, "expected_")) { + return facadePublicPath{container: "guard", field: field} + } + if spec.Facade == "change" || spec.Facade == "review" { + switch field { + case "diff", "base", "head", "branch", "path", "file", "ranges", "symbols", "changes", "scope", "workspace_edit", "steps", "source", "staged": + return facadePublicPath{container: "source", field: field} + } + } + return facadePublicPath{container: "options", field: field} +} + +func facadeColdDomain(facade string) bool { + switch facade { + case "publish_review", "pr", "recall", "remember", "workspace", "workspace_admin", "overlay", "response": + return true + default: + return false + } +} + +func facadePublicValueSchema(value, raw any) map[string]any { + if schema, ok := raw.(map[string]any); ok && facadeSchemaAcceptsPlaceholder(schema, value) { + return cloneFacadeSchemaMap(schema) + } + schema := facadeSchemaForValue(value) + if rawSchema, ok := raw.(map[string]any); ok { + if description, ok := rawSchema["description"]; ok { + schema["description"] = description + } + } + return schema +} + +func facadeSchemaAcceptsPlaceholder(schema map[string]any, value any) bool { + if constant, ok := schema["const"]; ok && fmt.Sprint(constant) != fmt.Sprint(value) { + return false + } + switch choices := schema["enum"].(type) { + case []any: + matched := false + for _, choice := range choices { + if fmt.Sprint(choice) == fmt.Sprint(value) { + matched = true + break + } + } + if !matched { + return false + } + case []string: + if !slices.Contains(choices, fmt.Sprint(value)) { + return false + } + } + typeName, _ := schema["type"].(string) + switch typeName { + case "string": + _, ok := value.(string) + return ok + case "boolean": + _, ok := value.(bool) + return ok + case "integer", "number": + switch value.(type) { + case int, int32, int64, float32, float64: + return true + default: + return false + } + case "array": + switch value.(type) { + case []any, []string, []map[string]any: + return true + default: + return false + } + case "object": + _, ok := value.(map[string]any) + return ok + default: + return true + } +} + +func facadeSchemaForValue(value any) map[string]any { + switch typed := value.(type) { + case bool: + return map[string]any{"type": "boolean"} + case int, int32, int64, float32, float64: + return map[string]any{"type": "number"} + case []string: + return map[string]any{"type": "array", "items": map[string]any{"type": "string"}} + case []any: + items := map[string]any{} + if len(typed) > 0 { + items = facadeSchemaForValue(typed[0]) + } + return map[string]any{"type": "array", "items": items} + case []map[string]any: + items := map[string]any{"type": "object", "additionalProperties": true} + if len(typed) > 0 { + items = facadeSchemaForValue(typed[0]) + } + return map[string]any{"type": "array", "items": items} + case map[string]any: + properties := make(map[string]any, len(typed)) + for _, field := range sortedFacadeMapKeys(typed) { + properties[field] = facadeSchemaForValue(typed[field]) + } + return map[string]any{"type": "object", "properties": properties, "additionalProperties": false} + default: + return map[string]any{"type": "string"} + } +} + +func cloneFacadeSchemaMap(schema map[string]any) map[string]any { + raw, err := json.Marshal(schema) + if err != nil { + return schema + } + var clone map[string]any + if err := json.Unmarshal(raw, &clone); err != nil { + return schema + } + return clone +} + +func sortedFacadeMapKeys(values map[string]any) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +func sortedFacadeSetKeys(values map[string]struct{}) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +func facadeCompleteRequiredSelectors(spec facadeOperationSpec, args map[string]any, required []string) { + staticProperties := facadeToolDefinition(spec.Facade).InputSchema.Properties + containers := []string{"target", "to"} + selectors := []string{"file", "symbol", "symbols", "query", "artifact", "repo"} + for _, field := range required { + if _, fixed := spec.Fixed[field]; fixed { + continue + } + if _, satisfied := normalizeFacadeArguments(spec, args)[field]; satisfied { + continue + } + for _, container := range containers { + if _, supported := staticProperties[container]; !supported { + continue + } + if existing, ok := args[container].(map[string]any); ok && len(existing) > 0 { + continue + } + matched := false + for _, selector := range selectors { + value := facadeSchemaPlaceholder(selector, facadeTargetProperties()[selector]) + path := facadePublicPath{container: container, field: selector} + if _, lowersRequiredField := facadeProbePublicPath(spec, path, value)[field]; !lowersRequiredField { + continue + } + args[container] = map[string]any{selector: value} + matched = true + break + } + if matched { + break + } + } + } +} + +func facadeSchemaPlaceholder(field string, raw any) any { + property, _ := raw.(map[string]any) + if constant, ok := property["const"]; ok { + return constant + } + switch choices := property["enum"].(type) { + case []any: + if len(choices) > 0 { + return choices[0] + } + case []string: + if len(choices) > 0 { + return choices[0] + } + } + if value, ok := property["default"]; ok { + return value + } + switch property["type"] { + case "boolean": + return false + case "integer", "number": + if minimum, ok := property["minimum"]; ok { + return minimum + } + return 1 + case "array": + if item, ok := property["items"]; ok { + return []any{facadeSchemaPlaceholder(field, item)} + } + return []any{"<" + field + ">"} + case "object": + value := map[string]any{} + properties, _ := property["properties"].(map[string]any) + var required []string + switch fields := property["required"].(type) { + case []string: + required = append(required, fields...) + case []any: + for _, candidate := range fields { + if name, ok := candidate.(string); ok { + required = append(required, name) + } + } + } + if len(required) == 0 { + if minimum, ok := property["minProperties"].(float64); ok && minimum > 0 { + keys := sortedFacadeMapKeys(properties) + if len(keys) > 0 { + required = append(required, keys[0]) + } + } + } + for _, name := range required { + value[name] = facadeSchemaPlaceholder(name, properties[name]) + } + return value + default: + return "<" + field + ">" + } +} diff --git a/internal/mcp/facade_schema_test.go b/internal/mcp/facade_schema_test.go new file mode 100644 index 000000000..db00efc40 --- /dev/null +++ b/internal/mcp/facade_schema_test.go @@ -0,0 +1,312 @@ +package mcp + +import ( + "encoding/json" + "fmt" + "slices" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestFacadeCapabilitiesPublicSchemasMatchEveryAvailableOperation(t *testing.T) { + srv, _ := setupTestServer(t) + checked := 0 + for _, domain := range facadeToolNames() { + if domain == "capabilities" || domain == "analyze" || domain == "session" { + continue + } + staticSchema := facadeSchemaMapForTest(t, facadeToolDefinition(domain).InputSchema) + for _, spec := range srv.facades.operations(domain) { + if spec.Hidden { + continue + } + legacy, available := srv.facades.legacy(spec.Legacy) + if !available { + continue + } + checked++ + name := domain + "." + spec.Operation + t.Run(name, func(t *testing.T) { + capability := srv.facadeCapability(spec, true) + publicSchema := facadeSchemaMapForTest(t, capability["input_schema"]) + requestShape := capability["request_shape"].(map[string]any) + arguments := requestShape["arguments"].(map[string]any) + + require.NoError(t, validateFacadeSchema(publicSchema, arguments, "$"), "public schema must accept its own request_shape") + require.NoError(t, validateFacadeSchema(staticSchema, arguments, "$"), "request_shape must remain valid against the static tools/list schema") + assertFacadeSchemaWithinStatic(t, publicSchema, staticSchema, "$") + + for field := range spec.Fixed { + require.False(t, facadeSchemaDeclaresProperty(publicSchema, field), "fixed field %q leaked into public input_schema", field) + } + for _, field := range legacy.tool.InputSchema.Required { + if _, fixed := spec.Fixed[field]; fixed { + continue + } + require.True(t, facadeRequiredFieldCovered(spec, publicSchema, arguments, field), "required legacy field %q lost its public requirement", field) + if facadeSelectorInShapeLowersField(spec, arguments, field) { + require.False(t, facadeSchemaRequiresOutsideSelector(publicSchema, field), "required selector implementation field %q leaked outside target/to", field) + } + } + }) + } + } + require.Greater(t, checked, 100, "the exhaustive contract test must exercise the registered facade catalog") +} + +func facadeSchemaMapForTest(t *testing.T, raw any) map[string]any { + t.Helper() + encoded, err := json.Marshal(raw) + require.NoError(t, err) + var schema map[string]any + require.NoError(t, json.Unmarshal(encoded, &schema)) + return schema +} + +func validateFacadeSchema(schema map[string]any, value any, path string) error { + if constant, ok := schema["const"]; ok && !facadeJSONValuesEqual(constant, value) { + return fmt.Errorf("%s: value %v does not match const %v", path, value, constant) + } + if choices, ok := schema["enum"].([]any); ok { + matched := false + for _, choice := range choices { + if facadeJSONValuesEqual(choice, value) { + matched = true + break + } + } + if !matched { + return fmt.Errorf("%s: value %v is not in enum", path, value) + } + } + if alternatives, ok := schema["anyOf"].([]any); ok { + for _, raw := range alternatives { + if candidate, ok := raw.(map[string]any); ok && validateFacadeSchema(candidate, value, path) == nil { + return nil + } + } + return fmt.Errorf("%s: no anyOf branch accepts value", path) + } + if alternatives, ok := schema["oneOf"].([]any); ok { + matches := 0 + for _, raw := range alternatives { + if candidate, ok := raw.(map[string]any); ok && validateFacadeSchema(candidate, value, path) == nil { + matches++ + } + } + if matches != 1 { + return fmt.Errorf("%s: expected one matching oneOf branch, got %d", path, matches) + } + return nil + } + + typeName, _ := schema["type"].(string) + switch typeName { + case "object": + object, ok := value.(map[string]any) + if !ok { + return fmt.Errorf("%s: expected object, got %T", path, value) + } + properties, _ := schema["properties"].(map[string]any) + for _, field := range facadeSchemaRequiredNames(schema) { + if _, exists := object[field]; !exists { + return fmt.Errorf("%s: missing required property %q", path, field) + } + } + if minimum, ok := schema["minProperties"].(float64); ok && float64(len(object)) < minimum { + return fmt.Errorf("%s: has %d properties, minimum is %.0f", path, len(object), minimum) + } + if maximum, ok := schema["maxProperties"].(float64); ok && float64(len(object)) > maximum { + return fmt.Errorf("%s: has %d properties, maximum is %.0f", path, len(object), maximum) + } + additional, constrained := schema["additionalProperties"].(bool) + for field, child := range object { + raw, declared := properties[field] + if !declared { + if constrained && !additional { + return fmt.Errorf("%s: undeclared property %q", path, field) + } + continue + } + childSchema, ok := raw.(map[string]any) + if ok { + if err := validateFacadeSchema(childSchema, child, path+"."+field); err != nil { + return err + } + } + } + case "array": + array, ok := value.([]any) + if !ok { + encoded, _ := json.Marshal(value) + if err := json.Unmarshal(encoded, &array); err != nil { + return fmt.Errorf("%s: expected array, got %T", path, value) + } + } + if raw, ok := schema["items"].(map[string]any); ok { + for i, child := range array { + if err := validateFacadeSchema(raw, child, fmt.Sprintf("%s[%d]", path, i)); err != nil { + return err + } + } + } + case "string": + if _, ok := value.(string); !ok { + return fmt.Errorf("%s: expected string, got %T", path, value) + } + case "boolean": + if _, ok := value.(bool); !ok { + return fmt.Errorf("%s: expected boolean, got %T", path, value) + } + case "integer", "number": + switch value.(type) { + case int, int32, int64, float32, float64: + default: + return fmt.Errorf("%s: expected number, got %T", path, value) + } + } + return nil +} + +func facadeJSONValuesEqual(left, right any) bool { + leftJSON, _ := json.Marshal(left) + rightJSON, _ := json.Marshal(right) + return string(leftJSON) == string(rightJSON) +} + +func facadeSchemaRequiredNames(schema map[string]any) []string { + var required []string + switch fields := schema["required"].(type) { + case []string: + return fields + case []any: + for _, field := range fields { + if name, ok := field.(string); ok { + required = append(required, name) + } + } + } + return required +} + +func assertFacadeSchemaWithinStatic(t *testing.T, public, static map[string]any, path string) { + t.Helper() + if publicType, ok := public["type"].(string); ok { + if staticType, ok := static["type"].(string); ok { + require.Equal(t, staticType, publicType, "%s changes the static value type", path) + } + } + publicProperties, _ := public["properties"].(map[string]any) + staticProperties, _ := static["properties"].(map[string]any) + staticAdditional, _ := static["additionalProperties"].(bool) + for field, raw := range publicProperties { + staticRaw, declared := staticProperties[field] + if !declared { + require.True(t, staticAdditional, "%s.%s is absent from the static facade schema", path, field) + continue + } + publicChild, publicObject := raw.(map[string]any) + staticChild, staticObject := staticRaw.(map[string]any) + if publicObject && staticObject { + assertFacadeSchemaWithinStatic(t, publicChild, staticChild, path+"."+field) + } + } +} + +func facadeSchemaDeclaresProperty(schema map[string]any, field string) bool { + properties, _ := schema["properties"].(map[string]any) + if _, exists := properties[field]; exists { + return true + } + for _, raw := range properties { + if child, ok := raw.(map[string]any); ok && facadeSchemaDeclaresProperty(child, field) { + return true + } + } + return false +} + +func facadeRequestLeafPaths(arguments map[string]any) []facadePublicPath { + var paths []facadePublicPath + for field, value := range arguments { + if field == "operation" { + continue + } + if facadePublicContainer(field) { + if nested, ok := value.(map[string]any); ok { + for nestedField := range nested { + paths = append(paths, facadePublicPath{container: field, field: nestedField}) + } + } + continue + } + paths = append(paths, facadePublicPath{field: field}) + } + return paths +} + +func facadeValueAtPublicPath(arguments map[string]any, path facadePublicPath) any { + if path.container == "" { + return arguments[path.field] + } + container, _ := arguments[path.container].(map[string]any) + return container[path.field] +} + +func facadeRequiredFieldCovered(spec facadeOperationSpec, schema map[string]any, arguments map[string]any, field string) bool { + for _, path := range facadeRequestLeafPaths(arguments) { + value := facadeValueAtPublicPath(arguments, path) + if _, lowers := facadeProbePublicPath(spec, path, value)[field]; lowers && facadeSchemaRequiresPublicPath(schema, path) { + return true + } + } + return false +} + +func facadeSchemaRequiresPublicPath(schema map[string]any, path facadePublicPath) bool { + if path.container == "" { + return slices.Contains(facadeSchemaRequiredNames(schema), path.field) + } + if !slices.Contains(facadeSchemaRequiredNames(schema), path.container) { + return false + } + properties, _ := schema["properties"].(map[string]any) + container, _ := properties[path.container].(map[string]any) + if path.container == "target" || path.container == "to" { + minimum, _ := container["minProperties"].(float64) + return minimum >= 1 + } + return slices.Contains(facadeSchemaRequiredNames(container), path.field) +} + +func facadeSelectorInShapeLowersField(spec facadeOperationSpec, arguments map[string]any, field string) bool { + for _, container := range []string{"target", "to"} { + selectors, _ := arguments[container].(map[string]any) + for selector, value := range selectors { + if _, lowers := facadeProbePublicPath(spec, facadePublicPath{container: container, field: selector}, value)[field]; lowers { + return true + } + } + } + return false +} + +func facadeSchemaRequiresOutsideSelector(schema map[string]any, field string) bool { + properties, _ := schema["properties"].(map[string]any) + for _, required := range facadeSchemaRequiredNames(schema) { + if required == field { + return true + } + } + for containerName, raw := range properties { + if containerName == "target" || containerName == "to" { + continue + } + container, ok := raw.(map[string]any) + if ok && slices.Contains(facadeSchemaRequiredNames(container), field) { + return true + } + } + return false +} diff --git a/internal/mcp/facade_tools.go b/internal/mcp/facade_tools.go new file mode 100644 index 000000000..8105339af --- /dev/null +++ b/internal/mcp/facade_tools.go @@ -0,0 +1,2054 @@ +package mcp + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "slices" + "sort" + "strings" + "time" + + mcpgo "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/telemetry" +) + +var facadeDescriptions = map[string]string{ + "explore": "Localize a task in indexed code.", + "search": "Search indexed code and artifacts by operation.", + "read": "Read files, symbols, or context by operation.", + "relations": "Query symbol relationships by operation.", + "trace": "Trace graph or data flow by operation.", + "analyze": "Run graph analysis by kind.", + "ask": "Ask the configured research agent.", + "change": "Assess a proposed or existing change.", + "edit": "Apply guarded source or file changes.", + "refactor": "Apply a semantic refactor.", + "review": "Build or critique a code review.", + "publish_review": "Publish a review to a forge.", + "pr": "Inspect pull requests.", + "recall": "Read notes, memories, or notebooks.", + "remember": "Persist notes, memories, or suppressions.", + "workspace": "Inspect workspace and index state.", + "workspace_admin": "Change workspace or daemon state.", + "session": "Change volatile session state.", + "overlay": "Change speculative overlay state.", + "response": "Inspect a buffered response.", + "capabilities": "List operations or return an exact schema.", +} + +func boolPointer(v bool) *bool { return &v } + +func facadeAnnotation(name string) mcpgo.ToolAnnotation { + readOnly := true + destructive := false + openWorld := false + switch name { + case "ask", "pr", "review": + openWorld = true + case "edit", "refactor", "remember", "workspace_admin": + readOnly = false + destructive = true + if name == "workspace_admin" { + openWorld = true + } + case "overlay", "session": + readOnly = false + case "publish_review": + readOnly = false + destructive = true + openWorld = true + } + return mcpgo.ToolAnnotation{ + ReadOnlyHint: boolPointer(readOnly), + DestructiveHint: boolPointer(destructive), + OpenWorldHint: boolPointer(openWorld), + } +} + +func facadeTargetProperties() map[string]any { + return map[string]any{ + "file": map[string]any{"type": "string"}, + "symbol": map[string]any{"type": "string"}, + "symbols": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "query": map[string]any{"type": "string"}, + "artifact": map[string]any{"type": "string"}, + "repo": map[string]any{"type": "string"}, + } +} + +func facadeTargetProperty() mcpgo.PropertyOption { + return mcpgo.Properties(facadeTargetProperties()) +} + +func facadeToolDefinition(name string) mcpgo.Tool { + return facadeToolDefinitionWithOperations(name, facadeCanonicalOperationNames(name)) +} + +func facadeToolDefinitionWithOperations(name string, operations []string) mcpgo.Tool { + desc := facadeDescriptions[name] + annotation := mcpgo.WithToolAnnotation(facadeAnnotation(name)) + freeObject := func(field, _ string) mcpgo.ToolOption { + return mcpgo.WithObject(field, mcpgo.AdditionalProperties(true)) + } + operation := mcpgo.WithString("operation") + options := freeObject("options", "") + output := freeObject("output", "") + target := mcpgo.WithObject("target", facadeTargetProperty(), mcpgo.AdditionalProperties(false)) + + var opts []mcpgo.ToolOption + switch name { + case "explore": + opts = []mcpgo.ToolOption{ + mcpgo.WithString("operation", mcpgo.Description("Use localize when the requested outcome is files or symbols; it returns terminal evidence. Use task only when diagnosis or implementation will continue.")), + mcpgo.WithString("task", mcpgo.Description("Task, bug, or question to localize.")), + mcpgo.WithString("path"), + mcpgo.WithObject("options", + mcpgo.Description("Set new_user_task=true only on the first localize call caused by a new user request. Never set it to retry, paraphrase, or continue the current request."), + mcpgo.AdditionalProperties(true), + ), + output, + } + case "search": + opts = []mcpgo.ToolOption{operation, mcpgo.WithString("query"), options, output} + case "read": + opts = []mcpgo.ToolOption{operation, target, freeObject("context", "Read window or source-context controls."), options, output} + case "relations", "trace": + opts = []mcpgo.ToolOption{operation, freeObject("target", "Primary file or symbol target."), freeObject("to", "Optional destination target."), options, output} + case "analyze": + opts = []mcpgo.ToolOption{ + mcpgo.WithString("kind", mcpgo.Description("Analysis kind or operation; omit to list supported kinds.")), + freeObject("target", "Optional analysis target."), options, output, + } + case "ask": + opts = []mcpgo.ToolOption{mcpgo.WithString("question", mcpgo.Required()), options, output} + case "change": + opts = []mcpgo.ToolOption{ + operation, target, + freeObject("source", "Diff, working tree, ranges, symbols, or other change source."), + options, output, + } + case "review": + opts = []mcpgo.ToolOption{operation, freeObject("source", "Diff, working tree, ranges, symbols, or review source."), options, output} + case "edit": + opts = []mcpgo.ToolOption{ + operation, target, mcpgo.WithString("match"), mcpgo.WithString("replacement"), + mcpgo.WithString("content"), freeObject("guard", "Stale-write and occurrence guards."), + mcpgo.WithArray("changes", mcpgo.Description("Batch file or symbol edits."), mcpgo.Items(map[string]any{"type": "object", "additionalProperties": true})), + mcpgo.WithBoolean("dry_run"), options, output, + } + case "refactor": + opts = []mcpgo.ToolOption{ + operation, target, mcpgo.WithString("new_name"), mcpgo.WithString("destination"), + mcpgo.WithBoolean("dry_run"), options, output, + } + case "publish_review", "pr", "recall", "remember", "workspace", "workspace_admin", "overlay", "response": + // Cold domain facades keep only the stable discriminator plus a + // runtime-validated payload. capabilities returns the exact operation + // schema on demand without changing tools/list. + opts = []mcpgo.ToolOption{operation, freeObject("arguments", "Operation arguments.")} + case "session": + opts = []mcpgo.ToolOption{ + mcpgo.WithString("operation", mcpgo.Description("Session operation; see capabilities. Use subscribe or unsubscribe with channel.")), + mcpgo.WithString("channel", mcpgo.Description("daemon_health, diagnostics, graph_invalidated, stale_refs, or workspace_readiness")), + freeObject("arguments", "Optional session arguments."), + } + case "capabilities": + opts = []mcpgo.ToolOption{ + mcpgo.WithString("domain", mcpgo.Description("Public tool name; omit to list all tool domains.")), + mcpgo.WithString("operation", mcpgo.Description("Operation name; omit to list the domain.")), + mcpgo.WithString("detail", mcpgo.Description("summary or schema")), + } + default: + opts = []mcpgo.ToolOption{operation, target, options, output} + } + // Response shaping is universal so the shell mirror can merge --format into + // the same public request object for every compact tool. Common-domain cases + // already include output above; reapplying the same property is idempotent. + opts = append(opts, output) + opts = append([]mcpgo.ToolOption{mcpgo.WithDescription(desc), annotation}, opts...) + tool := mcpgo.NewTool(name, opts...) + discriminator := "operation" + if name == "analyze" { + discriminator = "kind" + } + if property, ok := tool.InputSchema.Properties[discriminator].(map[string]any); ok && len(operations) > 0 { + property["enum"] = append([]string(nil), operations...) + } + return tool +} + +func facadeCanonicalOperationNames(name string) []string { + seen := make(map[string]bool) + for _, spec := range facadeOperationSpecs() { + if spec.Facade == name && !spec.Hidden { + seen[spec.Operation] = true + } + } + if name == "analyze" { + for _, kind := range AnalyzeKinds() { + if !analyzeKindRequiresAdmin(kind) { + seen[kind] = true + } + } + } + if name == "session" { + for operation := range seen { + if strings.HasPrefix(operation, "subscribe_") || strings.HasPrefix(operation, "unsubscribe_") { + delete(seen, operation) + } + } + seen["subscribe"] = true + seen["unsubscribe"] = true + } + operations := make([]string, 0, len(seen)) + for operation := range seen { + operations = append(operations, operation) + } + sort.Strings(operations) + return operations +} + +func (s *Server) facadeToolDefinition(name string) mcpgo.Tool { + specs := s.capabilityOperations(name) + operations := make([]string, 0, len(specs)) + for _, spec := range specs { + operations = append(operations, spec.Operation) + } + return facadeToolDefinitionWithOperations(name, operations) +} + +// registerFacadeTools installs every facade name directly into the live MCP +// server. Session filtering keeps them out of legacy surfaces, while a +// facade-v1 session receives all names from its first tools/list and never +// depends on deferred promotion or tools/list_changed. +func (s *Server) registerFacadeTools() { + for _, name := range facadeToolNames() { + if _, alreadyLegacy := s.facades.legacy(name); alreadyLegacy { + continue // explore/analyze/review (and ask when configured) + } + facade := name + tool := s.facadeToolDefinition(facade) + var handler server.ToolHandlerFunc + if facade == "capabilities" { + handler = s.handleCapabilities + } else { + handler = func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + return s.handleFacade(ctx, facade, req) + } + } + // Deliberately bypass addTool/lazy routing. The per-session surface + // filter hides these from legacy clients; facade clients need every + // dispatcher callable immediately. + scrubToolText(&tool) + s.mcpServer.AddTool(tool, s.wrapControlToolHandler(handler)) + } +} + +func (s *Server) wrapLegacyFacade(name string, raw server.ToolHandlerFunc) server.ToolHandlerFunc { + if !isFacadeToolName(name) { + return raw + } + return func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + args := req.GetArguments() + _, explicitOperation := args["operation"] + facadeSession := s.effectiveSessionPolicy(ctx).preset == FacadeSurfaceVersion + if !facadeSession && !explicitOperation { + return raw(ctx, req) + } + if name == "analyze" { + // Compact calls, including native dispatcher kinds, all pass through + // the same effect split and capability lookup below. + return s.handleFacade(ctx, name, req) + } + return s.handleFacade(ctx, name, req) + } +} + +func (s *Server) handleFacade(ctx context.Context, facade string, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + started := time.Now() + operation := normalizeFacadeOperation(req.GetString("operation", "")) + if facade == "analyze" { + operation = requestedAnalyzeKind(req.GetArguments()) + if operation == "" { + operation = "help" + } + if analyzeKindRequiresAdmin(operation) { + result := blockedAnalyzeKindResult(operation) + s.recordFacadeTelemetry("analyze", operation, facadeOutcomeBlocked, time.Since(started)) + return result, nil + } + } + if facade == "session" && (operation == "subscribe" || operation == "unsubscribe") { + channel := normalizeFacadeOperation(req.GetString("channel", "")) + if !validFacadeSessionChannel(channel) { + result := NewStructuredErrorResult(StructuredError{ + ErrorCode: ErrCodeInvalidArgument, + Message: fmt.Sprintf("unknown session channel %q", channel), + Data: map[string]any{ + "operation": operation, "valid_channels": facadeSessionChannels, + }, + }) + s.recordFacadeTelemetry("session", operation, facadeOutcomeInvalidOperation, time.Since(started)) + return result, nil + } + operation += "_" + channel + } + if operation == "" { + operation = inferFacadeOperation(facade, req.GetArguments()) + } + if operation == "" { + operation = defaultFacadeOperation(facade) + } + if facade == "read" { + operation = normalizeFacadeReadOperation(operation, req.GetArguments()) + } + var spec facadeOperationSpec + var ok bool + if facade == "analyze" { + spec, ok = s.capabilityOperation(facade, operation) + } else { + spec, ok = s.facades.operation(facade, operation) + } + if !ok { + valid := make([]string, 0) + for _, candidate := range s.capabilityOperations(facade) { + valid = append(valid, candidate.Operation) + } + result := NewStructuredErrorResult(StructuredError{ + ErrorCode: ErrCodeInvalidArgument, + Message: fmt.Sprintf("unknown %s operation %q", facade, operation), + Data: map[string]any{"facade": facade, "operation": operation, "valid_operations": valid}, + }) + // Never put the caller-provided operation in telemetry. All unresolved + // values collapse to the fixed sentinel "unknown". + s.recordFacadeTelemetry(facade, "unknown", facadeOutcomeInvalidOperation, time.Since(started)) + return result, nil + } + input, _ := req.Params.Arguments.(map[string]any) + if invalid := validateFacadeInput(spec, input); invalid != nil { + s.recordFacadeTelemetry(facade, operation, facadeOutcomeInvalidArgument, time.Since(started)) + return invalid, nil + } + terminal := s.localizationFor(ctx) + // An explicit localization request starts a transactional reservation. The + // first localization in an inactive session needs no boundary flag. Once a + // contract exists, only the first localize call caused by a new user request + // may set options.new_user_task=true; task text never implies a boundary. + freshLocalizeFlow := facade == "explore" && operation == "localize" + localizeReservation := uint64(0) + localizeFinished := false + if freshLocalizeFlow { + newUserTask := false + if options, ok := req.GetArguments()["options"].(map[string]any); ok { + newUserTask, _ = options["new_user_task"].(bool) + } + var blocked *mcpgo.CallToolResult + localizeReservation, blocked = terminal.beginLocalize(req.GetString("task", ""), newUserTask) + if blocked != nil { + s.recordFacadeTelemetry(facade, operation, facadeOutcomeBlocked, time.Since(started)) + return blocked, nil + } + } + exactReadReserved := false + exactReadSucceeded := false + defer func() { + if exactReadReserved { + terminal.finishExactRead(exactReadSucceeded) + } + if localizeReservation != 0 && !localizeFinished { + // Errors and panics roll back to the previous completion contract. + terminal.finishLocalize(localizeReservation, false) + } + }() + if !freshLocalizeFlow { + blocked, reserved := terminal.authorize(facade, operation, req.GetArguments()) + if blocked != nil { + s.recordFacadeTelemetry(facade, operation, facadeOutcomeBlocked, time.Since(started)) + return blocked, nil + } + exactReadReserved = reserved + } + result, err := s.invokeFacadeSpec(ctx, req, spec) + succeeded := err == nil && result != nil && !result.IsError + if exactReadReserved { + exactReadSucceeded = succeeded + } + if freshLocalizeFlow { + terminal.finishLocalize(localizeReservation, succeeded) + localizeFinished = true + } + return result, err +} + +func inferFacadeOperation(facade string, input map[string]any) string { + target, _ := input["target"].(map[string]any) + switch facade { + case "read": + switch { + case facadeSelectorPresent(target["file"]): + return "file" + case facadeSelectorPresent(target["symbol"]): + return "source" + case facadeSelectorPresent(target["symbols"]): + return "symbols" + case facadeSelectorPresent(target["artifact"]): + return "artifact" + } + case "edit": + switch { + case facadeSelectorPresent(input["changes"]): + return "batch" + case facadeSelectorPresent(target["symbol"]): + return "symbol" + case facadeSelectorPresent(target["file"]): + if facadeSelectorPresent(input["content"]) && !facadeSelectorPresent(input["match"]) { + return "write" + } + return "file" + } + } + return "" +} + +// normalizeFacadeReadOperation makes the selector cardinality authoritative. +// This accepts harmless migration aliases without forwarding an impossible +// request to a single-symbol or batch legacy handler. +func normalizeFacadeReadOperation(operation string, input map[string]any) string { + target, _ := input["target"].(map[string]any) + hasFile := facadeSelectorPresent(target["file"]) + hasSymbol := facadeSelectorPresent(target["symbol"]) + hasSymbols := facadeSelectorPresent(target["symbols"]) + switch operation { + case "source": + if hasSymbols { + return "symbols" + } + if hasFile && !hasSymbol { + return "file" + } + case "symbols": + if hasSymbol && !hasSymbols { + return "source" + } + if hasFile && !hasSymbols { + return "file" + } + } + return operation +} + +var facadeSessionChannels = []string{ + "daemon_health", "diagnostics", "graph_invalidated", "stale_refs", "workspace_readiness", +} + +func validFacadeSessionChannel(channel string) bool { + return slices.Contains(facadeSessionChannels, channel) +} + +func defaultFacadeOperation(facade string) string { + switch facade { + case "explore": + return "task" + case "search": + return "symbols" + case "read": + return "source" + case "relations": + return "usages" + case "trace": + return "call_chain" + case "analyze": + return "help" + case "ask": + return "research" + case "change": + return "contract" + case "edit": + return "file" + case "refactor": + return "rename" + case "review": + return "run" + case "publish_review": + return "post" + case "pr": + return "list" + case "recall": + return "surface" + case "remember": + return "memory" + case "workspace": + return "info" + case "response": + return "stats" + default: + return "" + } +} + +func (s *Server) invokeFacadeSpec(ctx context.Context, req mcpgo.CallToolRequest, spec facadeOperationSpec) (result *mcpgo.CallToolResult, err error) { + started := time.Now() + outcome := "" + defer func() { + if outcome == "" { + outcome = classifyFacadeOutcome(result, err) + } + s.recordFacadeTelemetry(spec.Facade, spec.Operation, outcome, time.Since(started)) + }() + legacy, ok := s.facades.legacy(spec.Legacy) + if !ok { + outcome = facadeOutcomeUnavailable + return NewStructuredErrorResult(StructuredError{ + ErrorCode: ErrCodeInvalidArgument, + Message: fmt.Sprintf("%s.%s is unavailable in this server configuration", spec.Facade, spec.Operation), + Data: map[string]any{"facade": spec.Facade, "operation": spec.Operation, "legacy_tool": spec.Legacy}, + }), nil + } + if invalid := validateFacadeInput(spec, req.GetArguments()); invalid != nil { + outcome = facadeOutcomeInvalidArgument + return invalid, nil + } + normalized := normalizeFacadeArguments(spec, req.GetArguments()) + if targetErr := normalizeFacadeChangeTargets(spec, req.GetArguments(), normalized); targetErr != nil { + outcome = facadeOutcomeInvalidArgument + return NewStructuredErrorResult(StructuredError{ + ErrorCode: ErrCodeInvalidArgument, + Message: targetErr.Error(), + Data: map[string]any{"facade": spec.Facade, "operation": spec.Operation}, + }), nil + } + if spec.Facade == "read" && (spec.Operation == "source" || spec.Operation == "symbols") { + ids := []string{strings.TrimSpace(fmt.Sprint(normalized["id"]))} + field := "id" + if spec.Operation == "symbols" { + ids = strings.Split(fmt.Sprint(normalized["ids"]), ",") + field = "ids" + } + resolved := make([]string, 0, len(ids)) + for _, id := range ids { + id = strings.TrimSpace(id) + if id == "" { + continue + } + canonical, ambiguous := s.resolveFacadeSymbolShorthand(ctx, id) + if len(ambiguous) > 0 { + outcome = facadeOutcomeInvalidArgument + return NewStructuredErrorResult(StructuredError{ + ErrorCode: ErrCodeInvalidArgument, + Message: fmt.Sprintf("symbol shorthand %q is ambiguous", id), + Data: map[string]any{"symbol": id, "candidates": ambiguous}, + }), nil + } + resolved = append(resolved, canonical) + } + if len(resolved) > 0 { + normalized[field] = strings.Join(resolved, ",") + } + } + if spec.Facade == "read" && spec.Operation == "editing_context" { + if rawID, exists := normalized["id"]; exists { + if id := strings.TrimSpace(fmt.Sprint(rawID)); id != "" { + canonical, ambiguous := s.resolveFacadeSymbolShorthand(ctx, id) + if len(ambiguous) > 0 { + outcome = facadeOutcomeInvalidArgument + return NewStructuredErrorResult(StructuredError{ + ErrorCode: ErrCodeInvalidArgument, + Message: fmt.Sprintf("symbol shorthand %q is ambiguous", id), + Data: map[string]any{"symbol": id, "candidates": ambiguous}, + }), nil + } + var node *graph.Node + if s.graph != nil { + node = s.graph.GetNode(canonical) + } + if node == nil || node.FilePath == "" || !s.nodeInSessionScope(ctx, node) { + outcome = facadeOutcomeInvalidArgument + return NewStructuredErrorResult(StructuredError{ + ErrorCode: ErrCodeSymbolNotFound, + Message: fmt.Sprintf("symbol %q is not indexed in this session scope", id), + Data: map[string]any{"symbol": id}, + }), nil + } + normalized["path"] = node.FilePath + delete(normalized, "id") + } + } + } + if spec.Facade == "change" && spec.Operation == "impact" { + if rawPath, exists := normalized["path"]; exists { + if path := strings.TrimSpace(fmt.Sprint(rawPath)); path != "" { + path = s.graphRelPath(path) + eng := s.engineFor(ctx) + ids := make([]string, 0) + if eng != nil { + if symbols := eng.GetFileSymbols(path); symbols != nil { + for _, node := range symbols.Nodes { + if node == nil || node.Kind == graph.KindFile || !exploreLocalizableKind(node.Kind) || !s.nodeInSessionScope(ctx, node) { + continue + } + ids = append(ids, node.ID) + } + } + } + if len(ids) == 0 { + outcome = facadeOutcomeInvalidArgument + return NewStructuredErrorResult(StructuredError{ + ErrorCode: ErrCodeFileNotIndexed, + Message: fmt.Sprintf("no indexed symbols found for file %q", path), + Data: map[string]any{"file": path}, + }), nil + } + sort.Strings(ids) + normalized["ids"] = strings.Join(ids, ",") + delete(normalized, "path") + } + } + } + if spec.Facade == "analyze" && analyzeKindRequiresAdmin(normalizeFacadeOperation(fmt.Sprint(normalized["kind"]))) { + kind := normalizeFacadeOperation(fmt.Sprint(normalized["kind"])) + outcome = facadeOutcomeBlocked + return blockedAnalyzeKindResult(kind), nil + } + if OverlayViewFromContext(ctx) == nil && !facadeLegacyManagesOwnOverlay(spec.Legacy) { + view, viewErr := s.buildOverlayViewForCtx(ctx) + if viewErr != nil { + outcome = facadeOutcomeToolError + return mcpgo.NewToolResultError(viewErr.Error()), nil + } + if view != nil { + ctx = WithOverlayView(ctx, view) + } + } + forwarded := req + forwarded.Params.Name = spec.Legacy + forwarded.Params.Arguments = normalized + forwarded.Params.RawArguments = nil + result, err = legacy.handler(ctx, forwarded) + if err == nil { + result = s.decorateFacadeFreshness(spec.Legacy, forwarded, result) + } + if result != nil { + if result.Meta == nil { + result.Meta = &mcpgo.Meta{} + } + if result.Meta.AdditionalFields == nil { + result.Meta.AdditionalFields = make(map[string]any) + } + result.Meta.AdditionalFields["gortex_facade"] = map[string]any{ + "surface_version": FacadeSurfaceVersion, + "facade": spec.Facade, + "operation": spec.Operation, + "canonical_tool": spec.Legacy, + } + } + return result, err +} + +func (s *Server) resolveFacadeSymbolShorthand(ctx context.Context, id string) (string, []string) { + resolved := s.resolveSymbolID(ctx, id) + if s.graph == nil || s.graph.GetNode(resolved) != nil || strings.Contains(id, "::") { + return resolved, nil + } + eng := s.engineFor(ctx) + if eng == nil { + return resolved, nil + } + seen := make(map[string]bool) + candidates := make([]string, 0, 2) + for _, node := range eng.FindSymbols(id) { + if node == nil || seen[node.ID] || !s.nodeInSessionScope(ctx, node) { + continue + } + storedName := node.Name + if parts := strings.SplitN(node.ID, "::", 2); len(parts) == 2 && parts[1] == id { + storedName = id + } + if storedName != id { + continue + } + seen[node.ID] = true + candidates = append(candidates, node.ID) + } + sort.Strings(candidates) + if len(candidates) == 1 { + return candidates[0], nil + } + if len(candidates) > 1 { + return id, candidates + } + return resolved, nil +} + +// requestedAnalyzeKind applies the same argument-container precedence as the +// public dispatcher before choosing an operation. This closes nested bypasses +// such as options.kind=coverage while keeping the wire shape compact. +func requestedAnalyzeKind(input map[string]any) string { + normalized := normalizeFacadeArguments(facadeOperationSpec{ + Facade: "analyze", Legacy: "analyze", Effect: facadeEffectRead, + }, input) + raw, ok := normalized["kind"] + if !ok || raw == nil { + return "" + } + return normalizeFacadeOperation(fmt.Sprint(raw)) +} + +func blockedAnalyzeKindResult(kind string) *mcpgo.CallToolResult { + return NewStructuredErrorResult(StructuredError{ + ErrorCode: ErrCodeToolBlockedByMode, + Message: fmt.Sprintf("analyze(kind=%s) changes durable state; use workspace_admin(operation=%s)", kind, kind), + Data: map[string]any{"domain": "workspace_admin", "operation": kind}, + }) +} + +// decorateFacadeFreshness runs the existing legacy freshness policy after a +// facade operation has resolved to its canonical tool and normalized request. +// The outer facade middleware only sees compact names/targets (read, +// relations, target.file, ...), so applying the policy there would miss the +// legacy path/id fields the rider is deliberately keyed to. +func (s *Server) decorateFacadeFreshness(legacy string, req mcpgo.CallToolRequest, result *mcpgo.CallToolResult) *mcpgo.CallToolResult { + if rider := s.freshnessRiderFor(legacy, req); rider != nil { + return decorateResultWithFreshness(result, rider) + } + if isFreshnessListTool(legacy) { + return s.decorateListResultWithFreshness(result) + } + return result +} + +func facadeLegacyManagesOwnOverlay(name string) bool { + if strings.HasPrefix(name, "overlay_") || strings.HasPrefix(name, "subscribe_") || + strings.HasPrefix(name, "unsubscribe_") || strings.HasPrefix(name, "proxy_") { + return true + } + switch name { + case "preview_edit", "simulate_chain", "compare_with_overlay", "compare_branches", "agent_registry", "set_planning_mode", "workflow": + return true + default: + return false + } +} + +func validateFacadeInput(spec facadeOperationSpec, input map[string]any) *mcpgo.CallToolResult { + for _, field := range []string{"arguments", "options", "source", "context", "guard", "output"} { + value, present := input[field] + if !present || value == nil { + continue + } + if _, ok := value.(map[string]any); !ok { + return NewStructuredErrorResult(StructuredError{ + ErrorCode: ErrCodeInvalidArgument, + Message: fmt.Sprintf("%s must be an object", field), + Data: map[string]any{"field": field}, + }) + } + } + for _, field := range []string{"target", "to"} { + if raw, present := input[field]; present && raw != nil { + if invalid := validateFacadeSelector(field, raw); invalid != nil { + return invalid + } + } + } + if spec.Facade == "search" { + switch spec.Operation { + case "symbols", "text", "completion": + query := strings.TrimSpace(fmt.Sprint(input["query"])) + if query == "" || query == "" { + return NewStructuredErrorResult(StructuredError{ + ErrorCode: ErrCodeInvalidArgument, + Message: fmt.Sprintf("search.%s requires query", spec.Operation), + Data: map[string]any{"field": "query", "operation": spec.Operation}, + }) + } + } + } + if spec.Facade == "explore" && spec.Operation == "task" { + normalized := normalizeFacadeArguments(spec, input) + if localize, _ := normalized["localize"].(bool); localize { + return NewStructuredErrorResult(StructuredError{ + ErrorCode: ErrCodeInvalidArgument, Message: "explore.task does not accept localize=true", + Data: map[string]any{"field": "localize", "operation": spec.Operation}, + }) + } + } + task, _ := input["task"].(string) + if spec.Facade == "explore" && (spec.Operation == "task" || spec.Operation == "localize") && strings.TrimSpace(task) == "" { + return NewStructuredErrorResult(StructuredError{ + ErrorCode: ErrCodeInvalidArgument, Message: fmt.Sprintf("explore.%s requires task", spec.Operation), + Data: map[string]any{"field": "task", "operation": spec.Operation}, + }) + } + return nil +} + +func validateFacadeSelector(field string, raw any) *mcpgo.CallToolResult { + target, ok := raw.(map[string]any) + if !ok { + return NewStructuredErrorResult(StructuredError{ + ErrorCode: ErrCodeInvalidArgument, Message: field + " must be an object", + Data: map[string]any{"field": field}, + }) + } + allowed := map[string]bool{"file": true, "symbol": true, "symbols": true, "query": true, "artifact": true, "repo": true} + selectors := make([]string, 0, len(target)) + for key, value := range target { + if !allowed[key] { + return NewStructuredErrorResult(StructuredError{ + ErrorCode: ErrCodeInvalidArgument, Message: fmt.Sprintf("unknown %s selector %q", field, key), + Data: map[string]any{"field": field, "valid_selectors": []string{"file", "symbol", "symbols", "query", "artifact", "repo"}}, + }) + } + if facadeSelectorPresent(value) { + selectors = append(selectors, key) + } + } + if len(selectors) != 1 { + sort.Strings(selectors) + return NewStructuredErrorResult(StructuredError{ + ErrorCode: ErrCodeInvalidArgument, Message: field + " must contain exactly one selector", + Data: map[string]any{"field": field, "selectors": selectors}, + }) + } + return nil +} + +func facadeSelectorPresent(value any) bool { + if value == nil { + return false + } + switch typed := value.(type) { + case string: + return strings.TrimSpace(typed) != "" + case []any: + return len(typed) > 0 + case []string: + return len(typed) > 0 + default: + return fmt.Sprint(value) != "" + } +} + +const ( + facadeOutcomeSuccess = "success" + facadeOutcomeInvalidOperation = "invalid_operation" + facadeOutcomeInvalidArgument = "invalid_argument" + facadeOutcomeBlocked = "blocked" + facadeOutcomeUnavailable = "unavailable" + facadeOutcomeToolError = "tool_error" + facadeOutcomeHandlerError = "handler_error" + facadeOutcomeEmptyResult = "empty_result" +) + +func facadeTelemetryDimension(spec facadeOperationSpec) string { + return boundedFacadeTelemetryDimension(spec.Facade, spec.Operation) +} + +// boundedFacadeTelemetryDimension joins fixed, low-cardinality tokens and +// deterministically folds long combinations under telemetry's 32-byte guard. +// Callers must pass registry values or fixed sentinels, never request values. +func boundedFacadeTelemetryDimension(parts ...string) string { + dim := strings.Join(parts, ".") + if len(dim) <= 32 { + return dim + } + sum := sha256.Sum256([]byte(dim)) + return dim[:23] + "." + hex.EncodeToString(sum[:4]) +} + +func classifyFacadeOutcome(result *mcpgo.CallToolResult, err error) string { + if err != nil { + return facadeOutcomeHandlerError + } + if result == nil { + return facadeOutcomeEmptyResult + } + if !result.IsError { + return facadeOutcomeSuccess + } + body, ok := singleTextContent(result) + if !ok { + return facadeOutcomeToolError + } + var structured struct { + ErrorCode ErrorCode `json:"error_code"` + } + if json.Unmarshal([]byte(body), &structured) != nil { + return facadeOutcomeToolError + } + switch structured.ErrorCode { + case ErrCodeInvalidArgument: + return facadeOutcomeInvalidArgument + case ErrCodeToolBlockedByMode, ErrCodeToolOutOfPhase: + return facadeOutcomeBlocked + case ErrCodeWorkspaceUnknown, ErrCodeProjectUnknown, ErrCodeRepoNotTracked, ErrCodeRouteUnresolved: + return facadeOutcomeUnavailable + default: + return facadeOutcomeToolError + } +} + +func validFacadeOutcome(outcome string) string { + switch outcome { + case facadeOutcomeSuccess, facadeOutcomeInvalidOperation, facadeOutcomeInvalidArgument, + facadeOutcomeBlocked, facadeOutcomeUnavailable, facadeOutcomeToolError, + facadeOutcomeHandlerError, facadeOutcomeEmptyResult: + return outcome + default: + return facadeOutcomeToolError + } +} + +// facadeTelemetryIdentity admits only registry-backed operations and four +// fixed capabilities buckets. This is the privacy boundary that prevents a +// caller-provided operation/domain from becoming even a hashed dimension. +func (s *Server) facadeTelemetryIdentity(facade, operation string) (string, string) { + if !isFacadeToolName(facade) { + return "unknown", "unknown" + } + if facade == "capabilities" { + switch operation { + case "list", "domain", "operation", "unknown": + return facade, operation + default: + return facade, "unknown" + } + } + if operation == "unknown" { + return facade, operation + } + if _, ok := s.facades.operation(facade, operation); ok { + return facade, operation + } + if facade == "analyze" && AnalyzeKindDescription(operation) != "" { + return facade, operation + } + if facade == "session" && (operation == "subscribe" || operation == "unsubscribe") { + return facade, operation + } + // Admin-only analyze kinds are rejected before capability dispatch, but + // remain a fixed low-cardinality vocabulary worth measuring directly. + if facade == "analyze" && analyzeKindRequiresAdmin(operation) { + return facade, operation + } + return facade, "unknown" +} + +func (s *Server) recordFacadeTelemetry(facade, operation, outcome string, elapsed time.Duration) { + facade, operation = s.facadeTelemetryIdentity(facade, operation) + outcome = validFacadeOutcome(outcome) + status := "error" + if outcome == facadeOutcomeSuccess { + status = "ok" + } + s.recorder.Record("mcp_facade_call", boundedFacadeTelemetryDimension(facade, operation)) + s.recorder.Record("mcp_facade_status", boundedFacadeTelemetryDimension(facade, operation, status)) + s.recorder.Record("mcp_facade_outcome", boundedFacadeTelemetryDimension(facade, operation, outcome)) + s.recorder.Record("mcp_facade_latency", boundedFacadeTelemetryDimension(facade, operation, telemetry.BucketDuration(elapsed))) + if outcome == facadeOutcomeInvalidOperation || outcome == facadeOutcomeInvalidArgument { + s.recorder.Record("mcp_facade_invalid", boundedFacadeTelemetryDimension(facade, operation, string(ErrCodeInvalidArgument))) + } +} + +func normalizeFacadeArguments(spec facadeOperationSpec, input map[string]any) map[string]any { + out := make(map[string]any) + mergeFacadeObject(out, input["arguments"]) + mergeFacadeObject(out, input["options"]) + mergeFacadeObject(out, input["source"]) + mergeFacadeObject(out, input["context"]) + mergeFacadeObject(out, input["guard"]) + mergeFacadeObject(out, input["output"]) + for key, value := range input { + switch key { + case "operation", "arguments", "options", "source", "context", "guard", "output", "target", "to": + continue + } + out[key] = value + } + if target, ok := input["target"].(map[string]any); ok { + applyFacadeTarget(spec.Legacy, out, target) + } + if to, ok := input["to"].(map[string]any); ok { + for key, value := range to { + out["to_"+key] = value + } + } + // Friendly edit aliases become the exact legacy vocabulary. + if match, ok := out["match"]; ok { + if spec.Legacy == "edit_symbol" { + out["old_source"] = match + } else { + out["old_string"] = match + } + delete(out, "match") + } + if replacement, ok := out["replacement"]; ok { + if spec.Legacy == "edit_symbol" { + out["new_source"] = replacement + } else { + out["new_string"] = replacement + } + delete(out, "replacement") + } + normalizeFacadeAliases(spec, input, out) + for key, value := range spec.Fixed { + out[key] = value + } + return out +} + +func normalizeFacadeAliases(spec facadeOperationSpec, input, out map[string]any) { + alias := func(from, to string) { + if value, ok := out[from]; ok { + out[to] = value + if from != to { + delete(out, from) + } + } + } + jsonString := func(key string) { + value, ok := out[key] + if !ok { + return + } + if _, already := value.(string); already { + return + } + if raw, err := json.Marshal(value); err == nil { + out[key] = string(raw) + } + } + commaString := func(from, to string) { + value, ok := out[from] + if !ok { + return + } + switch values := value.(type) { + case []any: + parts := make([]string, 0, len(values)) + for _, item := range values { + parts = append(parts, fmt.Sprint(item)) + } + out[to] = strings.Join(parts, ",") + case []string: + out[to] = strings.Join(values, ",") + default: + out[to] = value + } + if from != to { + delete(out, from) + } + } + flattenRange := func() { + raw, ok := out["range"] + if !ok { + return + } + if fields, ok := raw.(map[string]any); ok { + for _, key := range []string{"start_line", "start_char", "end_line", "end_char"} { + if value, exists := fields[key]; exists { + out[key] = value + } + } + } + delete(out, "range") + } + // Explore's public path is a repository-selection anchor, not a legacy + // retrieval field. Lower it to repo so a caller working outside the active + // repository is either scoped to the containing tracked repo or receives an + // explicit scope error. A non-empty path wins over options.repo: silently + // ignoring an explicit filesystem anchor would be less safe than rejecting + // an untracked path. + if spec.Facade == "explore" { + if path, exists := out["path"]; exists { + if strings.TrimSpace(fmt.Sprint(path)) != "" { + out["repo"] = path + } + delete(out, "path") + } + } + switch spec.Facade + "." + spec.Operation { + case "read.file": + normalizeFacadeReadWindow(out) + case "read.symbols": + if _, explicit := out["include_source"]; !explicit { + out["include_source"] = true + } + case "search.ast": + alias("query", "pattern") + case "search.winnow": + alias("query", "text_match") + case "relations.declaration": + alias("query", "use_site") + case "edit.batch": + alias("changes", "edits") + case "refactor.move": + alias("destination", "target_file") + case "change.impact": + // Compatibility source fields are lowered first. An explicit target + // is the canonical selector and therefore wins deterministically when + // a caller supplies both forms during migration. + commaString("symbols", "ids") + if symbol := facadeSelector(input["target"], "symbol"); symbol != nil { + out["ids"] = symbol + } + if symbols := facadeSelector(input["target"], "symbols"); symbols != nil { + out["symbols"] = symbols + commaString("symbols", "ids") + } + delete(out, "id") + case "change.edit_plan", "change.guards", "change.tests": + commaString("symbols", "ids") + case "change.pattern": + // suggest_pattern accepts one anchor. Preserve an explicit id; when the + // public source carries a one-element symbols list, lower its first item. + if _, exists := out["id"]; !exists { + switch values := out["symbols"].(type) { + case []any: + if len(values) > 0 { + out["id"] = fmt.Sprint(values[0]) + } + case []string: + if len(values) > 0 { + out["id"] = values[0] + } + case string: + out["id"] = values + } + } + delete(out, "symbols") + case "change.verify": + jsonString("changes") + case "change.diagnostics", "change.code_actions": + alias("file", "path") + flattenRange() + case "change.ranges": + alias("file", "path") + flattenRange() + jsonString("ranges") + case "change.preview": + jsonString("workspace_edit") + case "change.simulate": + jsonString("steps") + case "change.contract": + commaString("symbols", "symbols") + jsonString("ranges") + jsonString("workspace_edit") + case "trace.flow", "trace.path": + if source := facadeSelector(input["target"], "symbol", "query"); source != nil { + out["source_id"] = source + } + if sink := facadeSelector(input["to"], "symbol", "query"); sink != nil { + out["sink_id"] = sink + } + delete(out, "id") + case "trace.taint": + if source := facadeSelector(input["target"], "query", "symbol"); source != nil { + out["source_pattern"] = source + } + if sink := facadeSelector(input["to"], "query", "symbol"); sink != nil { + out["sink_pattern"] = sink + } + delete(out, "id") + } + // Capability/schema probes use this same lowering path as live dispatch. + // Invalid selector combinations are rejected by invokeFacadeSpec; probes + // deliberately ignore the error so they can still discover captured fields. + _ = normalizeFacadeChangeTargets(spec, input, out) +} + +// normalizeFacadeChangeTargets lowers every supported symbol-selector shape to +// the one legacy field consumed by the selected change operation. The same +// function is used during capability probing and live dispatch so schemas cannot +// advertise a selector that handlers interpret differently. +func normalizeFacadeChangeTargets(spec facadeOperationSpec, input, out map[string]any) error { + if spec.Facade != "change" { + return nil + } + switch spec.Operation { + case "edit_plan", "guards", "tests", "contract": + default: + return nil + } + + type selection struct { + label string + ids []string + } + selections := make([]selection, 0, 4) + collect := func(container string, raw any) error { + fields, ok := raw.(map[string]any) + if !ok || fields == nil { + return nil + } + for _, field := range []string{"symbol", "symbols", "id", "ids"} { + value, present := fields[field] + if !present { + continue + } + ids, err := facadeChangeTargetIDs(value, field == "symbol" || field == "id") + if err != nil { + return fmt.Errorf("change.%s %s.%s: %w", spec.Operation, container, field, err) + } + selections = append(selections, selection{label: container + "." + field, ids: ids}) + } + return nil + } + + // Canonical target selectors lead so equivalent compatibility forms retain + // target order. Different selectors must name the same set or the request is + // ambiguous; silently choosing one is unsafe for change analysis. + if err := collect("target", input["target"]); err != nil { + return err + } + for _, container := range []string{"source", "options", "arguments"} { + if err := collect(container, input[container]); err != nil { + return err + } + } + top := make(map[string]any, 4) + for _, field := range []string{"symbol", "symbols", "id", "ids"} { + if value, present := input[field]; present { + top[field] = value + } + } + if err := collect("request", top); err != nil { + return err + } + + var ids []string + if len(selections) > 0 { + ids = selections[0].ids + for _, candidate := range selections[1:] { + if !sameFacadeChangeTargetSet(ids, candidate.ids) { + return fmt.Errorf("change.%s received conflicting symbol selectors %s and %s", + spec.Operation, selections[0].label, candidate.label) + } + } + } + + if spec.Operation == "contract" { + source := strings.ToLower(strings.TrimSpace(fmt.Sprint(out["source"]))) + if source == "" { + source = "" + } + if len(ids) > 0 { + if source != "" && source != "auto" && source != "symbols" { + return fmt.Errorf("change.contract symbol targets conflict with source=%s", source) + } + out["source"] = "symbols" + out["symbols"] = strings.Join(ids, ",") + delete(out, "id") + delete(out, "ids") + return nil + } + if source == "" || source == "auto" || source == "symbols" { + return fmt.Errorf("change.contract requires target.symbol/target.symbols or an explicit non-symbol source") + } + return nil + } + + if len(ids) == 0 { + return fmt.Errorf("change.%s requires target.symbol, target.symbols, or ids", spec.Operation) + } + out["ids"] = strings.Join(ids, ",") + delete(out, "id") + delete(out, "symbols") + return nil +} + +func facadeChangeTargetIDs(raw any, singular bool) ([]string, error) { + var values []string + switch value := raw.(type) { + case string: + if singular && strings.Contains(value, ",") { + return nil, fmt.Errorf("singular selector contains multiple IDs") + } + values = strings.Split(value, ",") + case []string: + values = append(values, value...) + case []any: + values = make([]string, 0, len(value)) + for _, item := range value { + text, ok := item.(string) + if !ok { + return nil, fmt.Errorf("IDs must be strings") + } + values = append(values, text) + } + default: + return nil, fmt.Errorf("expected a string or string array") + } + if singular && len(values) != 1 { + return nil, fmt.Errorf("singular selector requires exactly one ID") + } + if len(values) == 0 { + return nil, fmt.Errorf("selector must not be empty") + } + + ids := make([]string, 0, len(values)) + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + id := strings.TrimSpace(value) + if id == "" { + return nil, fmt.Errorf("selector contains an empty ID") + } + if _, duplicate := seen[id]; duplicate { + continue + } + seen[id] = struct{}{} + ids = append(ids, id) + } + if len(ids) == 0 { + return nil, fmt.Errorf("selector must not be empty") + } + return ids, nil +} + +func sameFacadeChangeTargetSet(left, right []string) bool { + if len(left) != len(right) { + return false + } + left = append([]string(nil), left...) + right = append([]string(nil), right...) + sort.Strings(left) + sort.Strings(right) + return slices.Equal(left, right) +} + +func normalizeFacadeReadWindow(out map[string]any) { + if window, ok := out["window"].(map[string]any); ok { + for _, key := range []string{"offset", "limit", "line"} { + if _, exists := out[key]; !exists { + if value, present := window[key]; present { + out[key] = value + } + } + } + } + delete(out, "window") + if line, ok := facadePositiveInt(out["line"]); ok { + if _, exists := out["offset"]; !exists { + out["offset"] = line + } + if _, exists := out["limit"]; !exists { + out["limit"] = 1 + } + } + delete(out, "line") +} + +func facadePositiveInt(value any) (int, bool) { + switch value := value.(type) { + case int: + return value, value > 0 + case int32: + return int(value), value > 0 + case int64: + return int(value), value > 0 + case float32: + integer := int(value) + return integer, value > 0 && float32(integer) == value + case float64: + integer := int(value) + return integer, value > 0 && float64(integer) == value + case json.Number: + integer, err := value.Int64() + return int(integer), err == nil && integer > 0 + default: + return 0, false + } +} + +func facadeSelector(raw any, keys ...string) any { + obj, ok := raw.(map[string]any) + if !ok { + return nil + } + for _, key := range keys { + if value, exists := obj[key]; exists && value != nil && fmt.Sprint(value) != "" { + return value + } + } + return nil +} + +func mergeFacadeObject(dst map[string]any, raw any) { + obj, ok := raw.(map[string]any) + if !ok { + return + } + for key, value := range obj { + dst[key] = value + } +} + +func applyFacadeTarget(legacy string, out, target map[string]any) { + set := func(key string, value any) { + if value != nil { + out[key] = value + } + } + if file := target["file"]; file != nil { + key := "path" + switch legacy { + case "find_co_changing_symbols": + key = "file_path" + } + set(key, file) + } + if symbol := target["symbol"]; symbol != nil { + key := "id" + switch legacy { + case "check_references", "find_co_changing_symbols": + key = "symbol_id" + case "find_import_path": + key = "name" + } + set(key, symbol) + } + if symbols := target["symbols"]; symbols != nil { + if values, ok := symbols.([]any); ok { + parts := make([]string, 0, len(values)) + for _, value := range values { + parts = append(parts, fmt.Sprint(value)) + } + set("ids", strings.Join(parts, ",")) + } else if values, ok := symbols.([]string); ok { + set("ids", strings.Join(values, ",")) + } else { + set("ids", symbols) + } + } + if query := target["query"]; query != nil { + set("query", query) + } + if artifact := target["artifact"]; artifact != nil { + set("id", artifact) + } + if repo := target["repo"]; repo != nil { + set("repo", repo) + } +} + +func (s *Server) handleCapabilities(_ context.Context, req mcpgo.CallToolRequest) (result *mcpgo.CallToolResult, err error) { + started := time.Now() + telemetryOperation := "list" + outcome := "" + defer func() { + if outcome == "" { + outcome = classifyFacadeOutcome(result, err) + } + s.recordFacadeTelemetry("capabilities", telemetryOperation, outcome, time.Since(started)) + }() + domain := normalizeFacadeOperation(req.GetString("domain", "")) + operation := normalizeFacadeOperation(req.GetString("operation", "")) + detail := normalizeFacadeOperation(req.GetString("detail", "summary")) + if domain == "" { + domains := make([]map[string]any, 0, len(facadeToolNames())) + for _, name := range facadeToolNames() { + domains = append(domains, map[string]any{ + "name": name, "description": facadeDescriptions[name], "operations": len(s.capabilityOperations(name)), + }) + } + return mcpgo.NewToolResultJSON(map[string]any{ + "surface_version": FacadeSurfaceVersion, "domains": domains, + }) + } + telemetryOperation = "domain" + if !isFacadeToolName(domain) { + telemetryOperation = "unknown" + outcome = facadeOutcomeInvalidOperation + return NewStructuredErrorResult(StructuredError{ + ErrorCode: ErrCodeInvalidArgument, Message: fmt.Sprintf("unknown tool domain %q", domain), + Data: map[string]any{"valid_domains": facadeToolNames()}, + }), nil + } + if operation != "" { + telemetryOperation = "operation" + spec, ok := s.capabilityOperation(domain, operation) + if !ok { + telemetryOperation = "unknown" + outcome = facadeOutcomeInvalidOperation + return NewStructuredErrorResult(StructuredError{ + ErrorCode: ErrCodeInvalidArgument, Message: fmt.Sprintf("unknown %s operation %q", domain, operation), + }), nil + } + return mcpgo.NewToolResultJSON(s.facadeCapability(spec, detail == "schema")) + } + ops := make([]map[string]any, 0) + for _, spec := range s.capabilityOperations(domain) { + ops = append(ops, s.facadeCapability(spec, detail == "schema")) + } + return mcpgo.NewToolResultJSON(map[string]any{ + "surface_version": FacadeSurfaceVersion, "domain": domain, "operations": ops, + }) +} + +// capabilityOperation includes the native analyze(kind=...) catalogue without +// duplicating every kind in the legacy-to-public migration registry. Mutating +// dispatcher kinds are available only through workspace_admin. +func (s *Server) capabilityOperation(domain, operation string) (facadeOperationSpec, bool) { + if domain == "session" { + switch operation { + case "subscribe": + _, available := s.facades.legacy("subscribe_diagnostics") + return facadeOperationSpec{Facade: "session", Operation: operation, Legacy: "subscribe_diagnostics", Effect: facadeEffectSessionWrite}, available + case "unsubscribe": + _, available := s.facades.legacy("unsubscribe_diagnostics") + return facadeOperationSpec{Facade: "session", Operation: operation, Legacy: "unsubscribe_diagnostics", Effect: facadeEffectSessionWrite}, available + } + if strings.HasPrefix(operation, "subscribe_") || strings.HasPrefix(operation, "unsubscribe_") { + return facadeOperationSpec{}, false + } + } + if spec, ok := s.facades.operation(domain, operation); ok { + if _, available := s.facades.legacy(spec.Legacy); available { + return spec, true + } + } + if domain == "analyze" && !analyzeKindRequiresAdmin(operation) && AnalyzeKindDescription(operation) != "" { + if _, available := s.facades.legacy("analyze"); available { + return facadeOperationSpec{ + Facade: "analyze", Operation: operation, Legacy: "analyze", Effect: facadeEffectRead, + Fixed: publicAnalyzeFixedArguments(operation), + }, true + } + } + return facadeOperationSpec{}, false +} + +func (s *Server) capabilityOperations(domain string) []facadeOperationSpec { + ops := s.facades.availableOperations(domain) + if domain == "session" { + public := make([]facadeOperationSpec, 0, len(ops)+2) + for _, spec := range ops { + if strings.HasPrefix(spec.Operation, "subscribe_") || strings.HasPrefix(spec.Operation, "unsubscribe_") { + continue + } + public = append(public, spec) + } + public = append(public, + facadeOperationSpec{Facade: "session", Operation: "subscribe", Legacy: "subscribe_diagnostics", Effect: facadeEffectSessionWrite}, + facadeOperationSpec{Facade: "session", Operation: "unsubscribe", Legacy: "unsubscribe_diagnostics", Effect: facadeEffectSessionWrite}, + ) + sort.Slice(public, func(i, j int) bool { return public[i].Operation < public[j].Operation }) + return public + } + if domain != "analyze" { + return ops + } + seen := make(map[string]bool, len(ops)) + for _, spec := range ops { + seen[spec.Operation] = true + } + for _, kind := range AnalyzeKinds() { + if analyzeKindRequiresAdmin(kind) || seen[kind] { + continue + } + ops = append(ops, facadeOperationSpec{ + Facade: "analyze", Operation: kind, Legacy: "analyze", Effect: facadeEffectRead, + Fixed: publicAnalyzeFixedArguments(kind), + }) + } + sort.Slice(ops, func(i, j int) bool { return ops[i].Operation < ops[j].Operation }) + return ops +} + +// publicAnalyzeFixedArguments keeps the read-only analyze boundary free of +// optional external effects. Explicit legacy calls retain their historical +// behavior. +func publicAnalyzeFixedArguments(kind string) map[string]any { + fixed := map[string]any{"kind": kind} + switch kind { + case "concepts": + fixed["use_llm"] = false + case "impact": + fixed["refresh_cochange"] = false + case "sql_call_sites": + fixed["materialize"] = false + } + return fixed +} + +func (s *Server) facadeCapability(spec facadeOperationSpec, includeSchema bool) map[string]any { + legacy, available := s.facades.legacy(spec.Legacy) + out := map[string]any{ + "surface_version": FacadeSurfaceVersion, "domain": spec.Facade, "operation": spec.Operation, + "effect": spec.Effect, "available": available, + } + if len(spec.Fixed) > 0 { + out["fixed_arguments"] = spec.Fixed + } + if available { + if spec.Facade == "explore" && spec.Operation == "localize" { + out["summary"] = "Locate files and symbols, then stop navigation and answer from the returned evidence. Set options.new_user_task=true only on the first localize call caused by a new user request; never use it to retry or continue the current request." + } else if spec.Facade == "explore" && spec.Operation == "task" { + out["summary"] = "Gather a nonterminal neighborhood for diagnosis or implementation that will continue." + } else if spec.Facade == "analyze" && spec.Operation == "help" { + out["summary"] = "List supported analysis kinds." + } else if summary := AnalyzeKindDescription(spec.Operation); spec.Legacy == "analyze" && summary != "" { + out["summary"] = summary + } else { + out["summary"] = firstSentence(legacy.tool.Description) + } + if includeSchema { + inputSchema := any(legacy.tool.InputSchema) + properties := legacy.tool.InputSchema.Properties + if spec.Facade == "read" && spec.Operation == "symbols" { + properties = cloneFacadeSchemaMap(properties) + if includeSource, ok := properties["include_source"].(map[string]any); ok { + includeSource["default"] = true + includeSource["description"] = "Include source code for each symbol (default: true; pass false for metadata only)." + } + } + required := legacy.tool.InputSchema.Required + if spec.Facade == "analyze" || (spec.Facade == "workspace_admin" && spec.Legacy == "analyze") { + inputSchema, properties, required = analyzeFacadeCapabilitySchema(spec, properties, required) + } else if spec.Facade == "session" && (spec.Operation == "subscribe" || spec.Operation == "unsubscribe") { + inputSchema = map[string]any{ + "type": "object", + "properties": map[string]any{ + "channel": map[string]any{"type": "string", "enum": facadeSessionChannels}, + "arguments": map[string]any{"type": "object", "additionalProperties": true}, + }, + "required": []string{"channel"}, + } + properties = map[string]any{"channel": map[string]any{"type": "string"}} + required = []string{"channel"} + } + requestShape := facadeRequestShape(spec, properties, required) + if spec.Facade != "analyze" && spec.Facade != "session" && (spec.Facade != "workspace_admin" || spec.Legacy != "analyze") { + inputSchema = facadePublicCapabilitySchema(spec, properties, required, requestShape) + } + if spec.Facade == "read" && spec.Operation == "symbols" { + if schema, ok := inputSchema.(map[string]any); ok { + schemaProperties, _ := schema["properties"].(map[string]any) + options, _ := schemaProperties["options"].(map[string]any) + optionProperties, _ := options["properties"].(map[string]any) + if optionProperties == nil { + optionProperties = make(map[string]any) + options["properties"] = optionProperties + } + includeSource, _ := optionProperties["include_source"].(map[string]any) + if includeSource == nil { + includeSource = map[string]any{"type": "boolean"} + optionProperties["include_source"] = includeSource + } + includeSource["default"] = true + includeSource["description"] = "Include source code for each symbol (default: true; pass false for metadata only)." + } + } + out["input_schema"] = inputSchema + out["request_shape"] = requestShape + if raw, err := json.Marshal(inputSchema); err == nil { + sum := sha256.Sum256(raw) + out["schema_hash"] = hex.EncodeToString(sum[:]) + } + } + } + return out +} + +// analyzeFacadeCapabilitySchema turns the legacy unified dispatcher schema +// into the public operation-specific contract. Agents see only fields relevant +// to the selected kind, fixed safety arguments disappear, and conditional +// requirements become ordinary JSON Schema requirements. +func analyzeFacadeCapabilitySchema(spec facadeOperationSpec, legacyProperties map[string]any, legacyRequired []string) (map[string]any, map[string]any, []string) { + options := make(map[string]any) + output := make(map[string]any) + for field, property := range legacyProperties { + if field == "kind" { + continue + } + if _, fixed := spec.Fixed[field]; fixed { + continue + } + if !analyzeFieldApplies(spec.Operation, field, property) { + continue + } + switch field { + case "format", "max_bytes", "cursor", "fields", "compact", "limit": + output[field] = property + default: + options[field] = property + } + } + + requiredFields := append([]string(nil), analyzeRequiredFields(spec.Operation)...) + for _, field := range legacyRequired { + if field == "kind" { + continue + } + if _, fixed := spec.Fixed[field]; fixed { + continue + } + if _, available := options[field]; available && !slices.Contains(requiredFields, field) { + requiredFields = append(requiredFields, field) + } + } + if spec.Facade == "workspace_admin" { + arguments := map[string]any{ + "type": "object", + "properties": options, + "additionalProperties": false, + } + if len(requiredFields) > 0 { + arguments["required"] = requiredFields + } + properties := map[string]any{ + "operation": map[string]any{"type": "string", "const": spec.Operation}, + "arguments": arguments, + } + if len(output) > 0 { + properties["output"] = map[string]any{"type": "object", "properties": output, "additionalProperties": false} + } + return map[string]any{ + "type": "object", "properties": properties, + "required": []string{"operation", "arguments"}, "additionalProperties": false, + }, mergeAnalyzeSchemaProperties(options, output), requiredFields + } + + properties := map[string]any{ + "kind": map[string]any{"type": "string", "const": spec.Operation}, + "options": map[string]any{ + "type": "object", + "properties": options, + "additionalProperties": false, + }, + } + topRequired := []string{"kind"} + if len(requiredFields) > 0 { + properties["options"].(map[string]any)["required"] = requiredFields + topRequired = append(topRequired, "options") + } + if len(output) > 0 { + properties["output"] = map[string]any{"type": "object", "properties": output, "additionalProperties": false} + } + if spec.Operation == "def_use" || spec.Operation == "co_change" { + targetProperties := map[string]any{"symbol": map[string]any{"type": "string"}} + if spec.Operation == "co_change" { + targetProperties["file"] = map[string]any{"type": "string"} + } + properties["target"] = map[string]any{ + "type": "object", "properties": targetProperties, + "minProperties": 1, "maxProperties": 1, "additionalProperties": false, + } + topRequired = append(topRequired, "target") + } + return map[string]any{ + "type": "object", "properties": properties, + "required": topRequired, "additionalProperties": false, + }, mergeAnalyzeSchemaProperties(options, output), requiredFields +} + +func mergeAnalyzeSchemaProperties(options, output map[string]any) map[string]any { + merged := make(map[string]any, len(options)+len(output)) + for key, value := range options { + merged[key] = value + } + for key, value := range output { + merged[key] = value + } + return merged +} + +func analyzeRequiredFields(kind string) []string { + switch kind { + case "coverage": + return []string{"profile"} + case "would_create_cycle": + return []string{"from_id", "to_id"} + default: + return nil + } +} + +// analyzeFieldApplies filters the legacy dispatcher's annotated field list. +// Kind-specific descriptions start with one or more parenthesized kind groups; +// unannotated fields are shared. A few handlers predate complete annotations +// and are covered by the explicit additions below. +func analyzeFieldApplies(kind, field string, raw any) bool { + if kind == "help" { + return false + } + property, _ := raw.(map[string]any) + description, _ := property["description"].(string) + description = strings.TrimSpace(description) + if strings.HasPrefix(description, "(") { + matched := false + remaining := description + for { + start := strings.IndexByte(remaining, '(') + if start < 0 { + break + } + remaining = remaining[start+1:] + end := strings.IndexByte(remaining, ')') + if end < 0 { + break + } + for _, candidate := range strings.Split(remaining[:end], ",") { + if normalizeFacadeOperation(candidate) == kind { + matched = true + } + } + remaining = strings.TrimSpace(remaining[end+1:]) + } + if !matched { + switch kind + "." + field { + case "impact.ids", "impact.path_prefix", "impact.kinds", "impact.min_score", "impact.max_score", "impact.limit", + "def_use.id", "def_use.ids": + return true + default: + return false + } + } + } + return true +} + +// facadeRequestShape makes capabilities actionable without teaching callers +// canonical handler names. input_schema describes the operation-specific +// fields; request_shape shows where those fields belong in the stable public +// envelope and which target selector to use. +func facadeRequestShape(spec facadeOperationSpec, properties map[string]any, required []string) map[string]any { + args := map[string]any{"operation": spec.Operation} + placeholder := func(key string) map[string]any { return map[string]any{key: "<" + key + ">"} } + hasLegacyField := func(key string) bool { + _, ok := properties[key] + return ok + } + + switch spec.Facade { + case "explore": + switch spec.Operation { + case "task", "context": + args["task"] = "" + case "closure": + args["options"] = map[string]any{"files": ""} + default: + args["options"] = map[string]any{} + } + case "search": + args["query"] = "" + args["options"] = map[string]any{} + case "read": + switch spec.Operation { + case "file", "editing_context", "summary": + args["target"] = placeholder("file") + case "symbols": + args["target"] = map[string]any{"symbols": []string{""}} + case "artifact": + args["target"] = placeholder("artifact") + default: + args["target"] = placeholder("symbol") + } + args["options"] = map[string]any{} + case "relations": + if spec.Operation == "declaration" { + args["target"] = placeholder("query") + } else { + args["target"] = placeholder("symbol") + } + args["options"] = map[string]any{} + case "trace": + switch spec.Operation { + case "flow", "path": + args["target"] = placeholder("symbol") + args["to"] = placeholder("symbol") + case "taint": + args["target"] = placeholder("query") + args["to"] = placeholder("query") + case "graph": + args["options"] = map[string]any{"query": ""} + default: + args["target"] = placeholder("symbol") + } + if _, ok := args["options"]; !ok { + args["options"] = map[string]any{} + } + case "analyze": + delete(args, "operation") + args["kind"] = spec.Operation + args["options"] = map[string]any{} + switch spec.Operation { + case "citation": + args["options"] = map[string]any{"span": "", "file_path": ""} + case "co_change": + args["target"] = placeholder("symbol") + case "def_use": + args["target"] = placeholder("symbol") + case "would_create_cycle": + args["options"] = map[string]any{"from_id": "", "to_id": ""} + } + case "ask": + delete(args, "operation") + args["question"] = "" + case "change": + source := map[string]any{} + switch spec.Operation { + case "api_impact": + source["file"] = "" + case "impact": + args["target"] = placeholder("symbol") + case "edit_plan", "guards", "tests": + args["target"] = map[string]any{"symbols": []string{""}} + case "pattern": + source["symbols"] = []string{""} + case "verify": + source["changes"] = []map[string]any{{"symbol_id": "", "new_signature": ""}} + case "diagnostics", "code_actions", "ranges": + source["file"] = "" + if spec.Operation == "ranges" { + source["ranges"] = []map[string]any{{"file": "", "start_line": 1, "end_line": 1}} + } + case "detect": + source["scope"] = "unstaged" + case "preview": + source["workspace_edit"] = "" + case "simulate": + source["steps"] = "" + case "contract": + args["target"] = map[string]any{"symbols": []string{""}} + } + if len(source) > 0 { + args["source"] = source + } + case "review": + args["source"] = map[string]any{} + case "edit": + switch spec.Operation { + case "file": + args["target"] = placeholder("file") + args["match"] = "" + args["replacement"] = "" + case "write": + args["target"] = placeholder("file") + args["content"] = "" + case "symbol": + args["target"] = placeholder("symbol") + args["match"] = "" + args["replacement"] = "" + case "batch": + args["changes"] = []map[string]any{{ + "op": "edit_file", "path": "", + "old_string": "", "new_string": "", + }} + default: + args["options"] = map[string]any{} + } + if spec.Operation == "skill" { + args["options"] = map[string]any{"directory": ""} + } + if hasLegacyField("dry_run") { + args["dry_run"] = true + } + case "refactor": + switch spec.Operation { + case "fix_all", "apply_code_action": + args["target"] = placeholder("file") + case "rename": + args["target"] = placeholder("symbol") + args["new_name"] = "" + case "move": + args["target"] = placeholder("symbol") + args["destination"] = "" + default: + args["target"] = placeholder("symbol") + } + args["options"] = map[string]any{} + if hasLegacyField("dry_run") { + args["dry_run"] = true + } + case "session": + args["arguments"] = map[string]any{} + if spec.Operation == "subscribe" || spec.Operation == "unsubscribe" { + args["channel"] = "" + } + case "capabilities": + delete(args, "operation") + args["domain"] = "" + case "remember": + args["arguments"] = map[string]any{} + if spec.Operation == "risk_ack" { + args["arguments"] = map[string]any{"source": "symbols", "symbols": ""} + } + default: + args["arguments"] = map[string]any{} + } + if spec.Facade == "workspace_admin" && spec.Operation == "coverage" { + args["arguments"] = map[string]any{"profile": ""} + } + if spec.Facade != "analyze" && spec.Facade != "session" { + facadeCompleteRequiredSelectors(spec, args, required) + } + + // Manual aliases above cover common intent-oriented and conditional fields; + // remaining schema-required legacy fields stay operation-specific under + // options/arguments. Handler data preconditions may still apply. + lowered := normalizeFacadeArguments(spec, args) + var extras map[string]any + for _, field := range required { + if _, fixedOrLowered := lowered[field]; fixedOrLowered { + continue + } + if extras == nil { + container := "options" + switch spec.Facade { + case "publish_review", "pr", "recall", "remember", "workspace", "workspace_admin", "overlay", "response", "session": + container = "arguments" + } + if existing, ok := args[container].(map[string]any); ok { + extras = existing + } else { + extras = map[string]any{} + args[container] = extras + } + } + extras[field] = facadeSchemaPlaceholder(field, properties[field]) + } + return map[string]any{"tool": spec.Facade, "arguments": args} +} + +// applyFacadeSurface provides session-level surface negotiation. Legacy +// clients never see the new dedicated facade names. facade-v1 clients see +// exactly the 21 compact definitions, including reused names whose global +// registration still carries a legacy schema. +func (s *Server) applyFacadeSurface(ctx context.Context, tools []mcpgo.Tool) []mcpgo.Tool { + p := s.effectiveSessionPolicy(ctx) + if p == nil || p.preset != FacadeSurfaceVersion { + out := tools[:0] + for _, tool := range tools { + if isDedicatedFacadeTool(tool.Name) { + continue + } + if tool.Name == "ask" { + if _, available := s.facades.legacy("ask"); !available { + continue + } + } + out = append(out, tool) + } + return out + } + byName := make(map[string]mcpgo.Tool, len(facadeToolNames())) + for _, tool := range tools { + if isFacadeToolName(tool.Name) { + byName[tool.Name] = s.facadeToolDefinition(tool.Name) + } + } + out := make([]mcpgo.Tool, 0, len(facadeToolNames())) + for _, name := range facadeToolNames() { + if tool, ok := byName[name]; ok { + out = append(out, tool) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} diff --git a/internal/mcp/facade_tools_test.go b/internal/mcp/facade_tools_test.go new file mode 100644 index 000000000..c984c9165 --- /dev/null +++ b/internal/mcp/facade_tools_test.go @@ -0,0 +1,1599 @@ +package mcp + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "testing" + "time" + + mcpgo "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/require" + + "github.com/zzet/gortex/internal/daemon" + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/query" + "github.com/zzet/gortex/internal/search" + "github.com/zzet/gortex/internal/telemetry" + "go.uber.org/zap" +) + +func TestFacadeRegistryCoversRegisteredLegacyCatalog(t *testing.T) { + srv, _ := setupTestServer(t) + var missing []string + for _, descriptor := range srv.ToolDescriptors() { + if isFacadeToolName(descriptor.Name) { + continue + } + if !srv.facades.mapsLegacy(descriptor.Name) { + missing = append(missing, descriptor.Name) + } + } + require.Empty(t, missing, "every registered legacy tool must map into facade-v1") + require.Len(t, srv.facades.byLegacy, 178, "facade-v1 migration table must cover the full legacy catalog") + require.Len(t, facadeToolNames(), 21) + for _, name := range facadeToolNames() { + if name == "capabilities" { + continue + } + require.NotEmpty(t, srv.facades.operations(name), "%s has no operations", name) + } +} + +func TestFacadeEffectBoundaryParity(t *testing.T) { + registry := newFacadeRegistry() + for _, facade := range facadeToolNames() { + definition := facadeToolDefinition(facade) + readOnly := definition.Annotations.ReadOnlyHint != nil && *definition.Annotations.ReadOnlyHint + effects := registry.operations(facade) + if facade == "capabilities" { + require.True(t, readOnly) + continue + } + require.NotEmpty(t, effects) + class := effects[0].Effect + for _, spec := range effects { + require.Equal(t, class, spec.Effect, "%s mixes effect classes", facade) + if spec.Effect == facadeEffectRead && daemon.IsMutating(spec.Legacy) && spec.Facade+"."+spec.Operation != "recall.surface" { + t.Fatalf("read facade %s.%s routes durable writer %s", facade, spec.Operation, spec.Legacy) + } + if spec.Effect == facadeEffectRead && daemon.IsEffectful(spec.Legacy) { + switch spec.Facade + "." + spec.Operation { + case "change.simulate": + require.Equal(t, false, spec.Fixed["keep"], "change.simulate must disable session persistence") + case "recall.surface": + require.Equal(t, false, spec.Fixed["mark_accessed"], "recall.surface must disable ranking mutation") + default: + t.Fatalf("read facade routes effectful legacy tool without an audited fixed-safe adapter: %s.%s -> %s", spec.Facade, spec.Operation, spec.Legacy) + } + } + if spec.Effect == facadeEffectSessionWrite && daemon.IsMutating(spec.Legacy) { + require.Equal(t, "overlay", spec.Facade, "session facade routes durable legacy writer without an audited fixed-safe adapter") + require.Equal(t, "merge", spec.Operation, "session facade routes durable legacy writer without an audited fixed-safe adapter") + require.Equal(t, false, spec.Fixed["to_disk"], "overlay.merge must disable disk application") + } + if spec.Facade == "edit" && spec.Operation == "wiki" { + require.Equal(t, false, spec.Fixed["enhance"], "local edit.wiki must disable LLM egress") + } + } + switch class { + case facadeEffectRead: + require.True(t, readOnly, facade) + require.False(t, daemon.IsEffectful(facade), facade) + case facadeEffectSessionWrite: + require.False(t, readOnly, facade) + require.True(t, daemon.IsEffectful(facade), facade) + require.False(t, daemon.IsMutating(facade), facade) + case facadeEffectLocalWrite, facadeEffectControlWrite, facadeEffectExternalWrite: + require.False(t, readOnly, facade) + require.True(t, daemon.IsMutating(facade), facade) + default: + t.Fatalf("unknown effect class %q", class) + } + } +} + +func TestCompactToolsListIsStaticAndBudgeted(t *testing.T) { + srv := setupPresetServer(t, ToolPolicyConfig{Preset: "core", Mode: "defer"}) + ctx := WithSessionID(context.Background(), "facade_budget") + initFrame := []byte(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"generic-harness","version":"1.0"}}}`) + require.NotNil(t, srv.MCPServer().HandleMessage(ctx, initFrame)) + + listFrame := []byte(`{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}`) + reply := srv.MCPServer().HandleMessage(ctx, listFrame) + require.NotNil(t, reply) + raw, err := json.Marshal(reply) + require.NoError(t, err) + t.Logf("compact tools/list: %d bytes", len(raw)) + serialized := strings.ToLower(string(raw)) + require.NotContains(t, serialized, "facade-v1") + require.NotContains(t, serialized, "facade") + require.LessOrEqual(t, len(raw), 15_000, + "facade-v1 tools/list is %d bytes; compact the facade schemas before raising the ceiling", len(raw)) + + var parsed struct { + Result struct { + Tools []mcpgo.Tool `json:"tools"` + } `json:"result"` + } + require.NoError(t, json.Unmarshal(raw, &parsed)) + require.Len(t, parsed.Result.Tools, 21) + foundExplore := false + for _, tool := range parsed.Result.Tools { + require.True(t, isFacadeToolName(tool.Name), "legacy tool leaked into facade-v1: %s", tool.Name) + if tool.Name != "explore" { + continue + } + foundExplore = true + operation := tool.InputSchema.Properties["operation"].(map[string]any) + require.Contains(t, operation["enum"], "localize") + require.Contains(t, operation["enum"], "task") + require.Contains(t, operation["description"], "Use localize") + require.Contains(t, operation["description"], "Use task only") + } + require.True(t, foundExplore, "runtime tools/list omitted explore") +} + +func TestFacadeSchemasAcceptUniversalCLIOutput(t *testing.T) { + for _, name := range facadeToolNames() { + tool := facadeToolDefinition(name) + raw, err := json.Marshal(tool.InputSchema) + require.NoError(t, err) + var schema map[string]any + require.NoError(t, json.Unmarshal(raw, &schema)) + properties, ok := schema["properties"].(map[string]any) + require.True(t, ok, name) + require.Contains(t, properties, "output", "%s must accept gortex call's universal --format shaping", name) + } +} + +func TestFacadeChangeSchemaAdvertisesSymbolTargets(t *testing.T) { + tool := facadeToolDefinition("change") + raw, err := json.Marshal(tool.InputSchema) + require.NoError(t, err) + var schema map[string]any + require.NoError(t, json.Unmarshal(raw, &schema)) + properties := schema["properties"].(map[string]any) + target := properties["target"].(map[string]any) + targetProperties := target["properties"].(map[string]any) + require.Contains(t, targetProperties, "symbol") + require.Contains(t, targetProperties, "symbols") +} + +func TestIdentifiedClientsShareDefaultSurface(t *testing.T) { + clients := make([]string, 0, len(knownAgentClients)+3) + for client := range knownAgentClients { + clients = append(clients, client) + } + // Product aliases resolved through host context must follow the same rule. + clients = append(clients, "openai-codex", "Claude Code 1.4", "Visual Studio Code") + sort.Strings(clients) + + srv := setupPresetServer(t, ToolPolicyConfig{Preset: "core", Mode: "defer"}) + wantTools := mapKeysAsSet(facadeToolNames()) + require.Len(t, wantTools, 21) + for i, client := range clients { + t.Run(client, func(t *testing.T) { + sessionID := fmt.Sprintf("identified_client_%d", i) + ctx := WithSessionID(context.Background(), sessionID) + frame := []byte(fmt.Sprintf(`{"jsonrpc":"2.0","id":%d,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":%q,"version":"1.0"}}}`, i+1, client)) + reply := srv.MCPServer().HandleMessage(ctx, frame) + require.NotNil(t, reply) + raw, err := json.Marshal(reply) + require.NoError(t, err) + var parsed struct { + Result struct { + Instructions string `json:"instructions"` + } `json:"result"` + } + require.NoError(t, json.Unmarshal(raw, &parsed)) + require.Equal(t, codingAgentInstructions, parsed.Result.Instructions) + require.Equal(t, wantTools, listToolNamesForSession(t, srv, sessionID)) + policy := srv.effectiveSessionPolicy(ctx) + require.Equal(t, FacadeSurfaceVersion, policy.preset) + require.Equal(t, toolPolicyModeHide, policy.mode) + }) + } + + unknownCtx := WithSessionID(context.Background(), "unknown_editor") + unknownFrame := []byte(`{"jsonrpc":"2.0","id":99,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"some-editor","version":"1.0"}}}`) + reply := srv.MCPServer().HandleMessage(unknownCtx, unknownFrame) + require.NotNil(t, reply) + raw, err := json.Marshal(reply) + require.NoError(t, err) + var parsed struct { + Result struct { + Instructions string `json:"instructions"` + } `json:"result"` + } + require.NoError(t, json.Unmarshal(raw, &parsed)) + require.Equal(t, codingAgentInstructions, parsed.Result.Instructions) + unknownTools := listToolNamesForSession(t, srv, "unknown_editor") + require.Equal(t, wantTools, unknownTools) + require.Equal(t, "", srv.resolveSessionFormat(unknownCtx), + "unknown clients keep the JSON-safe wire format") + + // Until initialize provides a non-empty clientInfo.name, preserve the + // server's global compatibility policy and instructions. + anonymousTools := listToolNamesForSession(t, srv, "anonymous_client") + require.True(t, anonymousTools["read_file"]) + require.False(t, anonymousTools["read"]) + require.Equal(t, serverInstructions, srv.stateAwareInstructionsForClient("", "")) + require.Equal(t, serverInstructions, srv.stateAwareInstructionsForClient("", " \t ")) +} + +func TestFacadeDispatchReachesColdLegacyHandlerWithoutPromotion(t *testing.T) { + srv := setupPresetServer(t, ToolPolicyConfig{Preset: "core", Mode: "defer"}) + ctx := WithSessionID(context.Background(), "facade_cold_dispatch") + initFrame := []byte(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"codex","version":"1.0"}}}`) + require.NotNil(t, srv.MCPServer().HandleMessage(ctx, initFrame)) + require.True(t, srv.lazy.IsDeferred("get_architecture")) + + callFrame := []byte(`{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"analyze","arguments":{"kind":"architecture"}}}`) + reply := srv.MCPServer().HandleMessage(ctx, callFrame) + require.NotNil(t, reply) + raw, err := json.Marshal(reply) + require.NoError(t, err) + var parsed struct { + Error any `json:"error"` + Result *mcpgo.CallToolResult `json:"result"` + } + require.NoError(t, json.Unmarshal(raw, &parsed)) + require.Nil(t, parsed.Error) + require.NotNil(t, parsed.Result) + require.False(t, parsed.Result.IsError) + require.True(t, srv.lazy.IsDeferred("get_architecture"), "facade dispatch must not promote the legacy schema") + require.Equal(t, listToolNamesForSession(t, srv, "facade_cold_dispatch"), + mapKeysAsSet(facadeToolNames()), "facade dispatch must not change the static tools/list") + + helpFrame := []byte(`{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"analyze","arguments":{}}}`) + raw, err = json.Marshal(srv.MCPServer().HandleMessage(ctx, helpFrame)) + require.NoError(t, err) + parsed = struct { + Error any `json:"error"` + Result *mcpgo.CallToolResult `json:"result"` + }{} + require.NoError(t, json.Unmarshal(raw, &parsed)) + require.Nil(t, parsed.Error) + require.NotNil(t, parsed.Result) + require.False(t, parsed.Result.IsError, "omitted public analyze kind must return help") +} + +func TestFacadeLegacyClientKeepsLegacySurfaceAndSchema(t *testing.T) { + srv := setupPresetServer(t, ToolPolicyConfig{Preset: "core", Mode: "defer"}) + ctx := WithSessionID(context.Background(), "legacy_client") + srv.NoteSessionToolPolicy("legacy_client", "core", "defer") + before := listToolNamesForSession(t, srv, "legacy_client") + + initFrame := []byte(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"some-editor","version":"1.0"}}}`) + require.NotNil(t, srv.MCPServer().HandleMessage(ctx, initFrame)) + after := listToolNamesForSession(t, srv, "legacy_client") + require.Equal(t, before, after, "an explicitly selected legacy preset must retain the core surface") + require.True(t, after["read_file"]) + require.True(t, after["analyze"]) + for _, name := range facadeToolNames() { + if isDedicatedFacadeTool(name) { + require.Falsef(t, after[name], "dedicated facade %q leaked into a legacy tools/list", name) + } + } + + listFrame := []byte(`{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}`) + raw, err := json.Marshal(srv.MCPServer().HandleMessage(ctx, listFrame)) + require.NoError(t, err) + var listed struct { + Result struct { + Tools []mcpgo.Tool `json:"tools"` + } `json:"result"` + } + require.NoError(t, json.Unmarshal(raw, &listed)) + var analyzeSchema map[string]any + for _, tool := range listed.Result.Tools { + if tool.Name != "analyze" { + continue + } + schema, marshalErr := json.Marshal(tool.InputSchema) + require.NoError(t, marshalErr) + require.NoError(t, json.Unmarshal(schema, &analyzeSchema)) + } + properties, ok := analyzeSchema["properties"].(map[string]any) + require.True(t, ok) + require.Contains(t, properties, "algorithm", "legacy analyze must retain its full legacy schema") + require.NotContains(t, properties, "target", "legacy analyze must not receive the facade schema") + + callFrame := []byte(`{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"analyze","arguments":{"kind":"help"}}}`) + raw, err = json.Marshal(srv.MCPServer().HandleMessage(ctx, callFrame)) + require.NoError(t, err) + var called struct { + Error any `json:"error"` + Result *mcpgo.CallToolResult `json:"result"` + } + require.NoError(t, json.Unmarshal(raw, &called)) + require.Nil(t, called.Error) + require.NotNil(t, called.Result) + require.False(t, called.Result.IsError, "legacy analyze call shape must still reach the legacy handler: %#v", called.Result) +} + +func TestDedicatedFacadeNamesRequireFacadeNegotiation(t *testing.T) { + srv := setupPresetServer(t, ToolPolicyConfig{Preset: "core", Mode: "defer"}) + ctx := WithSessionID(context.Background(), "legacy_facade_gate") + srv.NoteSessionToolPolicy("legacy_facade_gate", "core", "defer") + initFrame := []byte(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"some-editor","version":"1.0"}}}`) + require.NotNil(t, srv.MCPServer().HandleMessage(ctx, initFrame)) + + listed := listToolNamesForSession(t, srv, "legacy_facade_gate") + for _, name := range facadeToolNames() { + if !isDedicatedFacadeTool(name) { + continue + } + require.Falsef(t, listed[name], "dedicated facade %q leaked into the legacy tools/list", name) + require.Equal(t, "blocked", srv.sessionToolStatus(ctx, name), name) + require.NotNilf(t, srv.checkFacadeSurfaceGate(ctx, name), "dedicated facade %q remained directly callable", name) + } + for _, reused := range []string{"analyze", "explore", "review"} { + require.Nilf(t, srv.checkFacadeSurfaceGate(ctx, reused), "reused legacy name %q must remain compatible", reused) + } + // ask is reusable only when its optional LLM-backed legacy handler exists. + require.False(t, listed["ask"]) + require.Equal(t, "blocked", srv.sessionToolStatus(ctx, "ask")) + require.NotNil(t, srv.checkFacadeSurfaceGate(ctx, "ask")) + + // Exercise the actual globally-registered handler path: although `read` + // exists process-wide for facade sessions, this legacy/defer session must + // receive the same blocked result that tools/list and tool_profile report. + callFrame := []byte(`{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"read","arguments":{"operation":"file","target":{"file":"main.go"}}}}`) + raw, err := json.Marshal(srv.MCPServer().HandleMessage(ctx, callFrame)) + require.NoError(t, err) + var called struct { + Error any `json:"error"` + Result *mcpgo.CallToolResult `json:"result"` + } + require.NoError(t, json.Unmarshal(raw, &called)) + if called.Error != nil { + // mcp-go may enforce the session tools filter before entering the + // registered handler. That protocol-level not-found is an equally hard + // block; checkFacadeSurfaceGate above covers direct handler transports. + require.Nil(t, called.Result) + require.Contains(t, fmt.Sprint(called.Error), "tool not found") + } else { + require.NotNil(t, called.Result) + require.True(t, called.Result.IsError) + require.Contains(t, toolResultText(called.Result), ErrCodeToolBlockedByMode) + require.Contains(t, toolResultText(called.Result), "GORTEX_TOOLS="+FacadeSurfaceVersion) + } + + profileFrame := []byte(`{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"tool_profile","arguments":{"tool":"read"}}}`) + raw, err = json.Marshal(srv.MCPServer().HandleMessage(ctx, profileFrame)) + require.NoError(t, err) + called = struct { + Error any `json:"error"` + Result *mcpgo.CallToolResult `json:"result"` + }{} + require.NoError(t, json.Unmarshal(raw, &called)) + require.Nil(t, called.Error) + require.NotNil(t, called.Result) + profile := unmarshalResult(t, called.Result) + require.Equal(t, false, profile["enabled"]) + require.Equal(t, "blocked", profile["status"]) + + srv.facades.capture(mcpgo.NewTool("ask"), func(context.Context, mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + return mcpgo.NewToolResultText("configured"), nil + }) + require.Nil(t, srv.checkFacadeSurfaceGate(ctx, "ask"), "configured legacy ask must remain callable") +} + +func TestFacadeCapabilitiesProtocolDoesNotChangeSurface(t *testing.T) { + srv := setupPresetServer(t, ToolPolicyConfig{Preset: "core", Mode: "defer"}) + ctx := WithSessionID(context.Background(), "facade_capabilities") + initFrame := []byte(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"codex","version":"1.0"}}}`) + require.NotNil(t, srv.MCPServer().HandleMessage(ctx, initFrame)) + wantSurface := mapKeysAsSet(facadeToolNames()) + require.Equal(t, wantSurface, listToolNamesForSession(t, srv, "facade_capabilities")) + + callFrame := []byte(`{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"capabilities","arguments":{"domain":"read","operation":"file","detail":"schema"}}}`) + raw, err := json.Marshal(srv.MCPServer().HandleMessage(ctx, callFrame)) + require.NoError(t, err) + var called struct { + Error any `json:"error"` + Result *mcpgo.CallToolResult `json:"result"` + } + require.NoError(t, json.Unmarshal(raw, &called)) + require.Nil(t, called.Error) + require.NotNil(t, called.Result) + require.False(t, called.Result.IsError) + capability := unmarshalResult(t, called.Result) + require.Equal(t, FacadeSurfaceVersion, capability["surface_version"]) + require.Equal(t, "file", capability["operation"]) + require.Equal(t, "read", capability["effect"]) + require.Equal(t, true, capability["available"]) + require.NotNil(t, capability["input_schema"]) + require.NotEmpty(t, capability["schema_hash"]) + + exploreFrame := []byte(`{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"capabilities","arguments":{"domain":"explore"}}}`) + exploreRaw, err := json.Marshal(srv.MCPServer().HandleMessage(ctx, exploreFrame)) + require.NoError(t, err) + var explored struct { + Error any `json:"error"` + Result *mcpgo.CallToolResult `json:"result"` + } + require.NoError(t, json.Unmarshal(exploreRaw, &explored)) + require.Nil(t, explored.Error) + exploreDomain := unmarshalResult(t, explored.Result) + operations := exploreDomain["operations"].([]any) + summaries := make(map[string]string, len(operations)) + for _, rawOperation := range operations { + operation := rawOperation.(map[string]any) + summaries[operation["operation"].(string)] = operation["summary"].(string) + } + require.Contains(t, summaries, "localize") + require.Contains(t, summaries["localize"], "stop navigation") + require.Contains(t, summaries, "task") + require.Contains(t, summaries["task"], "nonterminal") + + require.Equal(t, wantSurface, listToolNamesForSession(t, srv, "facade_capabilities"), + "capability discovery must not promote schemas or mutate tools/list") +} + +func TestFacadeHiddenLegacyCallsAreHardBlocked(t *testing.T) { + srv := setupPresetServer(t, ToolPolicyConfig{Preset: "core", Mode: "defer"}) + ctx := WithSessionID(context.Background(), "facade_hidden_legacy") + initFrame := []byte(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"codex","version":"1.0"}}}`) + require.NotNil(t, srv.MCPServer().HandleMessage(ctx, initFrame)) + + for id, test := range []struct { + name string + args string + }{ + {name: "read_file", args: `{"path":"main.go"}`}, + {name: "tool_profile", args: `{"format":"json"}`}, + {name: LazyToolsSearchName, args: `{}`}, + } { + t.Run(test.name, func(t *testing.T) { + frame := []byte(`{"jsonrpc":"2.0","id":` + fmt.Sprint(id+2) + `,"method":"tools/call","params":{"name":"` + test.name + `","arguments":` + test.args + `}}`) + raw, err := json.Marshal(srv.MCPServer().HandleMessage(ctx, frame)) + require.NoError(t, err) + var parsed struct { + Error any `json:"error"` + Result *mcpgo.CallToolResult `json:"result"` + } + require.NoError(t, json.Unmarshal(raw, &parsed)) + if parsed.Error != nil { + require.Nil(t, parsed.Result) + require.Contains(t, fmt.Sprint(parsed.Error), "tool not found", + "the MCP surface filter may reject a hidden tool before its hard gate runs") + return + } + require.NotNil(t, parsed.Result) + require.True(t, parsed.Result.IsError, "hidden legacy tool must be blocked in facade-v1") + require.Contains(t, parsed.Result.Content[0].(mcpgo.TextContent).Text, ErrCodeToolBlockedByMode) + }) + } +} + +func TestFacadeGateRecoveryGuidanceUsesCallableSurface(t *testing.T) { + srv := setupPresetServer(t, ToolPolicyConfig{Preset: "core", Mode: "defer"}) + srv.NoteSessionClient("facade_guidance", "codex", "1") + ctx := WithSessionID(context.Background(), "facade_guidance") + + preset := srv.checkToolPresetGate(ctx, "read_file") + require.NotNil(t, preset) + require.Contains(t, toolResultText(preset), "Call capabilities") + require.NotContains(t, toolResultText(preset), "Call tool_profile") + + sess := srv.sessionFor(ctx) + sess.mu.Lock() + sess.planningMode = true + sess.mu.Unlock() + planning := srv.checkPlanningModeGate(ctx, "edit") + require.NotNil(t, planning) + require.Contains(t, toolResultText(planning), `session with operation \"planning_mode\"`) + require.NotContains(t, toolResultText(planning), "Call set_planning_mode") + + sess.mu.Lock() + sess.planningMode = false + sess.workflow = &workflowState{phases: defaultWorkflowPhases(), mode: workflowModeBlock} + sess.mu.Unlock() + workflow := srv.checkWorkflowGate(ctx, "edit") + require.NotNil(t, workflow) + require.Contains(t, toolResultText(workflow), `session(operation=\"workflow\"`) + + // Legacy sessions retain the established recovery vocabulary. + srv.NoteSessionToolPolicy("legacy_guidance", "core", "defer") + srv.NoteSessionClient("legacy_guidance", "some-editor", "1") + legacyCtx := WithSessionID(context.Background(), "legacy_guidance") + legacySess := srv.sessionFor(legacyCtx) + legacySess.mu.Lock() + legacySess.planningMode = true + legacySess.workflow = &workflowState{phases: defaultWorkflowPhases(), mode: workflowModeBlock} + legacySess.mu.Unlock() + require.Contains(t, toolResultText(srv.checkPlanningModeGate(legacyCtx, "edit_file")), "Call set_planning_mode") + require.Contains(t, toolResultText(srv.checkWorkflowGate(legacyCtx, "edit_file")), `workflow action=\"advance\"`) +} + +func mapKeysAsSet(names []string) map[string]bool { + out := make(map[string]bool, len(names)) + for _, name := range names { + out[name] = true + } + return out +} + +func TestCodingAgentInstructionsStayTerseAndDirective(t *testing.T) { + srv := &Server{} + got := srv.stateAwareInstructionsForClient("", "generic-harness") + require.Equal(t, codingAgentInstructions, got) + require.Less(t, len(got), 500) + for _, implementationTerm := range []string{"codex", "facade", "version", "preset", "tools/list", "tools_search"} { + require.NotContains(t, strings.ToLower(got), implementationTerm) + } + for _, directive := range []string{"MUST use Gortex MCP", "files/symbols/evidence/where", `explore(operation:"localize")`, "completion.required_action", "make no calls after answer_ready", `explore(operation:"task")`, "For diagnosis/change", `change(operation:"impact")`, "Mutate only with edit or refactor", `change(operation:"detect")`, "Call capabilities only for unknown fields"} { + require.Contains(t, got, directive) + } +} + +func TestFacadeInitializeInstructionsFollowEffectivePolicy(t *testing.T) { + initialize := func(t *testing.T, srv *Server, ctx context.Context, client string) string { + t.Helper() + frame := []byte(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"` + client + `","version":"1.0"}}}`) + raw, err := json.Marshal(srv.MCPServer().HandleMessage(ctx, frame)) + require.NoError(t, err) + var parsed struct { + Result struct { + Instructions string `json:"instructions"` + } `json:"result"` + } + require.NoError(t, json.Unmarshal(raw, &parsed)) + return parsed.Result.Instructions + } + + t.Run("codex_explicit_core_gets_legacy_instructions", func(t *testing.T) { + srv := setupPresetServer(t, ToolPolicyConfig{Preset: "core", Mode: "defer"}) + srv.NoteSessionToolPolicy("codex_core", "core", "defer") + ctx := WithSessionID(context.Background(), "codex_core") + instructions := initialize(t, srv, ctx, "codex") + require.Contains(t, instructions, "tools_search") + names := listToolNamesForSession(t, srv, "codex_core") + require.True(t, names["read_file"]) + require.False(t, names["read"]) + }) + + t.Run("legacy_client_explicit_facade_gets_facade_instructions", func(t *testing.T) { + srv := setupPresetServer(t, ToolPolicyConfig{Preset: "core", Mode: "defer"}) + srv.NoteSessionToolPolicy("editor_facade", FacadeSurfaceVersion, "hide") + ctx := WithSessionID(context.Background(), "editor_facade") + instructions := initialize(t, srv, ctx, "some-editor") + require.Equal(t, codingAgentInstructions, instructions) + names := listToolNamesForSession(t, srv, "editor_facade") + require.True(t, names["read"]) + require.False(t, names["read_file"]) + }) +} + +func TestFacadePolicyResolutionPrecedence(t *testing.T) { + previousProfile := activeInstructionPreset + activeInstructionPreset = func() string { return "" } + t.Cleanup(func() { activeInstructionPreset = previousProfile }) + + initialize := func(t *testing.T, srv *Server, sessionID, client string) string { + t.Helper() + ctx := WithSessionID(context.Background(), sessionID) + frame := []byte(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"` + client + `","version":"1.0"}}}`) + raw, err := json.Marshal(srv.MCPServer().HandleMessage(ctx, frame)) + require.NoError(t, err) + var parsed struct { + Result struct { + Instructions string `json:"instructions"` + } `json:"result"` + } + require.NoError(t, json.Unmarshal(raw, &parsed)) + return parsed.Result.Instructions + } + + t.Run("codex_host_alias_uses_facade", func(t *testing.T) { + srv := setupPresetServer(t, ToolPolicyConfig{Preset: "core", Mode: "defer"}) + require.Equal(t, codingAgentInstructions, initialize(t, srv, "codex_alias", "openai-codex")) + require.Equal(t, mapKeysAsSet(facadeToolNames()), listToolNamesForSession(t, srv, "codex_alias")) + }) + + t.Run("operator_pin_beats_codex_default", func(t *testing.T) { + srv := setupPresetServer(t, ToolPolicyConfig{Preset: "nav", Mode: "hide"}) + instructions := initialize(t, srv, "codex_pinned", "codex") + require.Contains(t, instructions, "tools_search") + ctx := WithSessionID(context.Background(), "codex_pinned") + policy := srv.effectiveSessionPolicy(ctx) + require.Equal(t, "nav", policy.preset) + require.Equal(t, "hide", policy.mode) + names := listToolNamesForSession(t, srv, "codex_pinned") + require.True(t, names["read_file"]) + require.False(t, names["read"]) + require.False(t, names["edit_file"]) + }) + + t.Run("bare_forwarded_facade_defaults_hide", func(t *testing.T) { + srv := setupPresetServer(t, ToolPolicyConfig{Preset: "core", Mode: "defer"}) + srv.NoteSessionToolPolicy("forwarded_facade", FacadeSurfaceVersion, "") + require.Equal(t, codingAgentInstructions, initialize(t, srv, "forwarded_facade", "some-editor")) + ctx := WithSessionID(context.Background(), "forwarded_facade") + policy := srv.effectiveSessionPolicy(ctx) + require.Equal(t, FacadeSurfaceVersion, policy.preset) + require.Equal(t, "hide", policy.mode) + require.Equal(t, mapKeysAsSet(facadeToolNames()), listToolNamesForSession(t, srv, "forwarded_facade")) + }) +} + +func TestFacadeToolProfileReflectsPlanningMode(t *testing.T) { + srv := setupPresetServer(t, ToolPolicyConfig{Preset: "core", Mode: "defer"}) + ctx := WithSessionID(context.Background(), "facade_planning") + initFrame := []byte(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"codex","version":"1.0"}}}`) + require.NotNil(t, srv.MCPServer().HandleMessage(ctx, initFrame)) + + callMode := func(t *testing.T, id int, mode string) *mcpgo.CallToolResult { + t.Helper() + frame := []byte(`{"jsonrpc":"2.0","id":` + fmt.Sprint(id) + `,"method":"tools/call","params":{"name":"session","arguments":{"operation":"planning_mode","arguments":{"mode":"` + mode + `"}}}}`) + raw, err := json.Marshal(srv.MCPServer().HandleMessage(ctx, frame)) + require.NoError(t, err) + var parsed struct { + Error any `json:"error"` + Result *mcpgo.CallToolResult `json:"result"` + } + require.NoError(t, json.Unmarshal(raw, &parsed)) + require.Nil(t, parsed.Error) + require.NotNil(t, parsed.Result) + require.False(t, parsed.Result.IsError) + return parsed.Result + } + + callMode(t, 2, "planning") + planning := listToolNamesForSession(t, srv, "facade_planning") + require.False(t, planning["edit"], "durable mutation facades must leave tools/list in planning mode") + require.True(t, planning["session"], "the session-only escape hatch must remain visible") + require.Equal(t, "blocked", srv.sessionToolStatus(ctx, "edit")) +} + +func TestFacadeDispatchNormalizesTargetAndEditAliases(t *testing.T) { + srv := &Server{facades: newFacadeRegistry()} + captureArguments := func(_ context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + return mcpgo.NewToolResultJSON(req.GetArguments()) + } + srv.facades.capture(mcpgo.NewTool("read_file"), captureArguments) + srv.facades.capture(mcpgo.NewTool("edit_symbol"), captureArguments) + + readReq := mcpgo.CallToolRequest{} + readReq.Params.Name = "read" + readReq.Params.Arguments = map[string]any{ + "operation": "file", + "target": map[string]any{"file": "internal/mcp/server.go"}, + "context": map[string]any{"offset": 20, "limit": 10, "path": "must-not-win.go"}, + "output": map[string]any{"format": "json"}, + } + readResult, err := srv.handleFacade(context.Background(), "read", readReq) + require.NoError(t, err) + readArgs := unmarshalResult(t, readResult) + require.Equal(t, "internal/mcp/server.go", readArgs["path"]) + require.Equal(t, float64(20), readArgs["offset"]) + require.Equal(t, float64(10), readArgs["limit"]) + require.Equal(t, "json", readArgs["format"]) + + editReq := mcpgo.CallToolRequest{} + editReq.Params.Name = "edit" + editReq.Params.Arguments = map[string]any{ + "operation": "symbol", + "target": map[string]any{"symbol": "internal/mcp/server.go::Server.addTool"}, + "match": "old", + "replacement": "new", + "guard": map[string]any{"base_sha": "abc"}, + } + editResult, err := srv.handleFacade(context.Background(), "edit", editReq) + require.NoError(t, err) + editArgs := unmarshalResult(t, editResult) + require.Equal(t, "internal/mcp/server.go::Server.addTool", editArgs["id"]) + require.Equal(t, "old", editArgs["old_source"]) + require.Equal(t, "new", editArgs["new_source"]) + require.Equal(t, "abc", editArgs["base_sha"]) + + inferredRead := mcpgo.CallToolRequest{} + inferredRead.Params.Arguments = map[string]any{"target": map[string]any{"file": "internal/mcp/server.go"}} + readResult, err = srv.handleFacade(context.Background(), "read", inferredRead) + require.NoError(t, err) + require.Equal(t, "internal/mcp/server.go", unmarshalResult(t, readResult)["path"]) + + inferredEdit := mcpgo.CallToolRequest{} + inferredEdit.Params.Arguments = map[string]any{ + "target": map[string]any{"symbol": "internal/mcp/server.go::Server.addTool"}, + "match": "old", "replacement": "new", + } + editResult, err = srv.handleFacade(context.Background(), "edit", inferredEdit) + require.NoError(t, err) + require.Equal(t, "internal/mcp/server.go::Server.addTool", unmarshalResult(t, editResult)["id"]) +} + +func TestFacadeOperationAliasesMatchLegacyContracts(t *testing.T) { + srv := &Server{facades: newFacadeRegistry()} + captureArguments := func(_ context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + return mcpgo.NewToolResultJSON(req.GetArguments()) + } + for _, legacy := range []string{ + "search_ast", "winnow_symbols", "find_files", "search_symbols", "find_declaration", + "flow_between", "trace_path", "taint_paths", "move_symbol", "batch_edit", "subscribe_diagnostics", + "explain_change_impact", "get_test_targets", "check_guards", "get_edit_plan", "suggest_pattern", + "verify_change", "get_diagnostics", "get_code_actions", "symbols_for_ranges", "preview_edit", + "simulate_chain", "change_contract", + } { + srv.facades.capture(mcpgo.NewTool(legacy), captureArguments) + } + tests := []struct { + facade string + args map[string]any + want map[string]any + }{ + {"search", map[string]any{"operation": "ast", "query": "(call) @match"}, map[string]any{"pattern": "(call) @match"}}, + {"search", map[string]any{"operation": "winnow", "query": "load bearing"}, map[string]any{"text_match": "load bearing"}}, + {"relations", map[string]any{"operation": "declaration", "target": map[string]any{"query": "Serve("}}, map[string]any{"use_site": "Serve("}}, + {"trace", map[string]any{"operation": "flow", "target": map[string]any{"symbol": "a"}, "to": map[string]any{"symbol": "b"}}, map[string]any{"source_id": "a", "sink_id": "b"}}, + {"trace", map[string]any{"operation": "path", "target": map[string]any{"symbol": "a"}, "to": map[string]any{"symbol": "b"}}, map[string]any{"source_id": "a", "sink_id": "b"}}, + {"trace", map[string]any{"operation": "taint", "target": map[string]any{"query": "user.*"}, "to": map[string]any{"query": "exec.*"}}, map[string]any{"source_pattern": "user.*", "sink_pattern": "exec.*"}}, + {"refactor", map[string]any{"operation": "move", "target": map[string]any{"symbol": "pkg/a.go::A"}, "destination": "pkg/b.go"}, map[string]any{"id": "pkg/a.go::A", "target_file": "pkg/b.go"}}, + {"edit", map[string]any{"operation": "batch", "changes": []any{map[string]any{"op": "edit_file"}}}, map[string]any{"edits": []any{map[string]any{"op": "edit_file"}}}}, + {"change", map[string]any{"operation": "impact", "source": map[string]any{"symbols": []any{"a", "b"}}}, map[string]any{"ids": "a,b"}}, + {"change", map[string]any{"operation": "impact", "target": map[string]any{"symbol": "a"}}, map[string]any{"ids": "a"}}, + {"change", map[string]any{"operation": "impact", "target": map[string]any{"symbols": []any{"a", "b"}}}, map[string]any{"ids": "a,b"}}, + {"change", map[string]any{"operation": "impact", "source": map[string]any{"symbols": []any{"source"}}, "target": map[string]any{"symbol": "target"}}, map[string]any{"ids": "target"}}, + {"change", map[string]any{"operation": "impact", "source": map[string]any{"symbols": []any{"source"}}, "target": map[string]any{"symbols": []any{"target-a", "target-b"}}}, map[string]any{"ids": "target-a,target-b"}}, + {"change", map[string]any{"operation": "tests", "source": map[string]any{"symbols": []any{"a", "b"}}}, map[string]any{"ids": "a,b"}}, + {"change", map[string]any{"operation": "guards", "source": map[string]any{"symbols": []any{"a", "b"}}}, map[string]any{"ids": "a,b"}}, + {"change", map[string]any{"operation": "edit_plan", "source": map[string]any{"symbols": []any{"a", "b"}}}, map[string]any{"ids": "a,b"}}, + {"change", map[string]any{"operation": "pattern", "source": map[string]any{"symbols": []any{"a"}}}, map[string]any{"id": "a"}}, + {"change", map[string]any{"operation": "verify", "source": map[string]any{"changes": []any{map[string]any{"symbol_id": "a", "new_signature": "A()"}}}}, map[string]any{"changes": `[{"new_signature":"A()","symbol_id":"a"}]`}}, + {"change", map[string]any{"operation": "diagnostics", "source": map[string]any{"file": "a.go"}}, map[string]any{"path": "a.go"}}, + {"change", map[string]any{"operation": "code_actions", "source": map[string]any{"file": "a.go", "range": map[string]any{"start_line": 2, "end_line": 4}}}, map[string]any{"path": "a.go", "start_line": float64(2), "end_line": float64(4)}}, + {"change", map[string]any{"operation": "ranges", "source": map[string]any{"file": "a.go", "range": map[string]any{"start_line": 2, "end_line": 4}}}, map[string]any{"path": "a.go", "start_line": float64(2), "end_line": float64(4)}}, + {"change", map[string]any{"operation": "preview", "source": map[string]any{"workspace_edit": map[string]any{"changes": map[string]any{}}}}, map[string]any{"workspace_edit": `{"changes":{}}`}}, + {"change", map[string]any{"operation": "simulate", "source": map[string]any{"steps": []any{map[string]any{"changes": map[string]any{}}}}}, map[string]any{"steps": `[{"changes":{}}]`, "keep": false}}, + {"change", map[string]any{"operation": "contract", "source": map[string]any{"symbols": []any{"a", "b"}, "ranges": []any{map[string]any{"file": "a.go", "start_line": 2}}}}, map[string]any{"symbols": "a,b", "ranges": `[{"file":"a.go","start_line":2}]`}}, + } + for _, test := range tests { + req := mcpgo.CallToolRequest{} + req.Params.Arguments = test.args + result, err := srv.handleFacade(context.Background(), test.facade, req) + require.NoError(t, err) + got := unmarshalResult(t, result) + for key, want := range test.want { + require.Equal(t, want, got[key], "%s.%v alias %s", test.facade, test.args["operation"], key) + } + } + globOnly := mcpgo.CallToolRequest{} + globOnly.Params.Arguments = map[string]any{"operation": "files", "options": map[string]any{"glob": "*_test.go"}} + result, err := srv.handleFacade(context.Background(), "search", globOnly) + require.NoError(t, err) + require.False(t, result.IsError, "search.files must support a glob without query") + + missingQuery := mcpgo.CallToolRequest{} + missingQuery.Params.Arguments = map[string]any{"operation": "symbols"} + result, err = srv.handleFacade(context.Background(), "search", missingQuery) + require.NoError(t, err) + require.True(t, result.IsError, "search.symbols must validate its required query") + + subscribe := mcpgo.CallToolRequest{} + subscribe.Params.Arguments = map[string]any{"operation": "subscribe", "channel": "diagnostics"} + result, err = srv.handleFacade(context.Background(), "session", subscribe) + require.NoError(t, err) + require.False(t, result.IsError, "session subscribe+channel must resolve to the canonical operation") +} + +func TestFacadeFreshnessUsesCanonicalLegacyRequest(t *testing.T) { + srv, root := setupTestServer(t) + future := time.Now().Add(2 * time.Second) + require.NoError(t, os.Chtimes(filepath.Join(root, "main.go"), future, future)) + + srv.facades.capture(mcpgo.NewTool("read_file"), func(_ context.Context, _ mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + return mcpgo.NewToolResultText(`{"content":"package main"}`), nil + }) + readReq := mcpgo.CallToolRequest{} + readReq.Params.Arguments = map[string]any{ + "operation": "file", + "target": map[string]any{"file": "main.go"}, + } + result, err := srv.handleFacade(context.Background(), "read", readReq) + require.NoError(t, err) + readPayload := unmarshalResult(t, result) + readFreshness, ok := readPayload["freshness"].(map[string]any) + require.True(t, ok, "facade file read must carry the legacy freshness rider") + require.Equal(t, true, readFreshness["stale"]) + require.Equal(t, "main.go", readFreshness["file"]) + + srv.facades.capture(mcpgo.NewTool("search_symbols"), func(_ context.Context, _ mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + return mcpgo.NewToolResultText(`{"results":[{"name":"main","file":"main.go"}]}`), nil + }) + searchReq := mcpgo.CallToolRequest{} + searchReq.Params.Arguments = map[string]any{"operation": "symbols", "query": "main"} + result, err = srv.handleFacade(context.Background(), "search", searchReq) + require.NoError(t, err) + searchPayload := unmarshalResult(t, result) + searchFreshness, ok := searchPayload["freshness"].(map[string]any) + require.True(t, ok, "facade list query must run the canonical legacy freshness sweep") + require.Len(t, searchFreshness["stale_files"], 1) +} + +func TestFacadeSessionCanExitPlanningMode(t *testing.T) { + srv := setupPresetServer(t, ToolPolicyConfig{Preset: "core", Mode: "defer"}) + ctx := WithSessionID(context.Background(), "facade_planning_recovery") + initFrame := []byte(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"codex","version":"1.0"}}}`) + require.NotNil(t, srv.MCPServer().HandleMessage(ctx, initFrame)) + + call := func(id int, mode string) *mcpgo.CallToolResult { + frame := []byte(fmt.Sprintf(`{"jsonrpc":"2.0","id":%d,"method":"tools/call","params":{"name":"session","arguments":{"operation":"planning_mode","mode":%q}}}`, id, mode)) + reply := srv.MCPServer().HandleMessage(ctx, frame) + require.NotNil(t, reply) + raw, err := json.Marshal(reply) + require.NoError(t, err) + var parsed struct { + Error any `json:"error"` + Result *mcpgo.CallToolResult `json:"result"` + } + require.NoError(t, json.Unmarshal(raw, &parsed)) + require.Nil(t, parsed.Error) + require.NotNil(t, parsed.Result) + return parsed.Result + } + require.False(t, call(2, "planning").IsError) + planning := listToolNamesForSession(t, srv, "facade_planning_recovery") + require.True(t, planning["session"], "session recovery facade must remain visible in planning mode") + require.False(t, planning["edit"]) + require.False(t, planning["workspace_admin"]) + + require.False(t, call(3, "editing").IsError) + editing := listToolNamesForSession(t, srv, "facade_planning_recovery") + require.True(t, editing["edit"]) + require.True(t, editing["workspace_admin"]) +} + +func TestControlToolCannotBypassFacadeHideGate(t *testing.T) { + srv := setupPresetServer(t, ToolPolicyConfig{Preset: "core", Mode: "defer"}) + called := false + srv.addControlTool(mcpgo.NewTool("overlay_merge"), func(context.Context, mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + called = true + return mcpgo.NewToolResultText("should not run"), nil + }) + ctx := WithSessionID(context.Background(), "facade_direct_legacy_gate") + initFrame := []byte(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"codex","version":"1.0"}}}`) + require.NotNil(t, srv.MCPServer().HandleMessage(ctx, initFrame)) + frame := []byte(`{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"overlay_merge","arguments":{"from":"branch","to_disk":true}}}`) + reply := srv.MCPServer().HandleMessage(ctx, frame) + require.NotNil(t, reply) + require.False(t, called, "direct legacy writer must be blocked before its handler runs") + _, captured := srv.facades.legacy("overlay_merge") + require.True(t, captured, "control-tool registration must capture optional legacy handlers for facade dispatch") +} + +func TestFacadeReadOnlyOperationsCannotEnablePersistence(t *testing.T) { + srv := &Server{facades: newFacadeRegistry()} + analyzeCalled := false + captureArguments := func(_ context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + if req.Params.Name == "analyze" { + analyzeCalled = true + } + return mcpgo.NewToolResultJSON(req.GetArguments()) + } + srv.facades.capture(mcpgo.NewTool("simulate_chain"), captureArguments) + srv.facades.capture(mcpgo.NewTool("analyze"), captureArguments) + srv.facades.capture(mcpgo.NewTool("overlay_merge"), captureArguments) + srv.facades.capture(mcpgo.NewTool("generate_wiki"), captureArguments) + srv.facades.capture(mcpgo.NewTool("surface_memories"), captureArguments) + srv.facades.capture(mcpgo.NewTool("search_symbols"), captureArguments) + srv.facades.capture(mcpgo.NewTool("change_contract"), captureArguments) + srv.facades.capture(mcpgo.NewTool("find_co_changing_symbols"), captureArguments) + + simulate := mcpgo.CallToolRequest{} + simulate.Params.Arguments = map[string]any{ + "operation": "simulate", + "options": map[string]any{"keep": true}, + } + result, err := srv.handleFacade(context.Background(), "change", simulate) + require.NoError(t, err) + args := unmarshalResult(t, result) + require.Equal(t, false, args["keep"], "fixed read-only guard must override user keep=true") + + overlayMerge := mcpgo.CallToolRequest{} + overlayMerge.Params.Arguments = map[string]any{"operation": "merge", "options": map[string]any{"to_disk": true}} + result, err = srv.handleFacade(context.Background(), "overlay", overlayMerge) + require.NoError(t, err) + args = unmarshalResult(t, result) + require.Equal(t, false, args["to_disk"], "session overlay merge must never write to disk") + + applyOverlay := mcpgo.CallToolRequest{} + applyOverlay.Params.Arguments = map[string]any{"operation": "apply_overlay", "options": map[string]any{"to_disk": false}} + result, err = srv.handleFacade(context.Background(), "edit", applyOverlay) + require.NoError(t, err) + args = unmarshalResult(t, result) + require.Equal(t, true, args["to_disk"], "edit.apply_overlay must always cross the disk-write boundary") + + wiki := mcpgo.CallToolRequest{} + wiki.Params.Arguments = map[string]any{"operation": "wiki", "options": map[string]any{"enhance": true}} + result, err = srv.handleFacade(context.Background(), "edit", wiki) + require.NoError(t, err) + args = unmarshalResult(t, result) + require.Equal(t, false, args["enhance"], "compact edit.wiki must not cross the LLM/open-world boundary") + + recall := mcpgo.CallToolRequest{} + recall.Params.Arguments = map[string]any{"operation": "surface", "arguments": map[string]any{"mark_accessed": true}} + result, err = srv.handleFacade(context.Background(), "recall", recall) + require.NoError(t, err) + args = unmarshalResult(t, result) + require.Equal(t, false, args["mark_accessed"], "read-only recall must not mutate future memory ranking") + + search := mcpgo.CallToolRequest{} + search.Params.Arguments = map[string]any{ + "operation": "symbols", "query": "where is authentication handled", + "options": map[string]any{"assist": "deep"}, + } + result, err = srv.handleFacade(context.Background(), "search", search) + require.NoError(t, err) + args = unmarshalResult(t, result) + require.Equal(t, "off", args["assist"], "local search must not invoke an LLM") + + contract := mcpgo.CallToolRequest{} + contract.Params.Arguments = map[string]any{ + "operation": "contract", + "source": map[string]any{"source": "symbols", "symbols": []any{"a.go::A"}}, + "options": map[string]any{"ack": true}, + } + result, err = srv.handleFacade(context.Background(), "change", contract) + require.NoError(t, err) + args = unmarshalResult(t, result) + require.Equal(t, false, args["ack"], "read-only change.contract must not persist a risk acknowledgement") + + riskAck := mcpgo.CallToolRequest{} + riskAck.Params.Arguments = map[string]any{ + "operation": "risk_ack", + "arguments": map[string]any{"source": "symbols", "symbols": "a.go::A", "ack": false}, + } + result, err = srv.handleFacade(context.Background(), "remember", riskAck) + require.NoError(t, err) + args = unmarshalResult(t, result) + require.Equal(t, true, args["ack"], "remember.risk_ack must always take the durable acknowledgement path") + + coChange := mcpgo.CallToolRequest{} + coChange.Params.Arguments = map[string]any{ + "kind": "co_change", "target": map[string]any{"symbol": "a.go::A"}, + "options": map[string]any{"refresh": true}, + } + result, err = srv.handleFacade(context.Background(), "analyze", coChange) + require.NoError(t, err) + args = unmarshalResult(t, result) + require.Equal(t, false, args["refresh"], "compact co-change lookup must not start a durable mine") + + for kind, flag := range map[string]string{"concepts": "use_llm", "impact": "refresh_cochange", "sql_call_sites": "materialize"} { + req := mcpgo.CallToolRequest{} + req.Params.Arguments = map[string]any{"kind": kind, "options": map[string]any{flag: true}} + result, err = srv.handleFacade(context.Background(), "analyze", req) + require.NoError(t, err) + args = unmarshalResult(t, result) + require.Equal(t, kind, args["kind"]) + require.Equal(t, false, args[flag], "analyze.%s must keep its public read-only posture", kind) + } + + normalizedKind := mcpgo.CallToolRequest{} + normalizedKind.Params.Arguments = map[string]any{"kind": "dead-code"} + result, err = srv.handleFacade(context.Background(), "analyze", normalizedKind) + require.NoError(t, err) + args = unmarshalResult(t, result) + require.Equal(t, "dead_code", args["kind"], "the normalized public kind must be fixed before legacy dispatch") + + defaultHelp := mcpgo.CallToolRequest{} + result, err = srv.handleFacade(context.Background(), "analyze", defaultHelp) + require.NoError(t, err) + args = unmarshalResult(t, result) + require.Equal(t, "help", args["kind"], "omitted kind must select the safe help operation") + + for _, kind := range adminAnalyzeKinds { + admin := mcpgo.CallToolRequest{} + admin.Params.Arguments = map[string]any{ + "operation": kind, + "arguments": map[string]any{"kind": "hotspots"}, + } + result, err = srv.handleFacade(context.Background(), "workspace_admin", admin) + require.NoError(t, err) + args = unmarshalResult(t, result) + require.Equal(t, kind, args["kind"], "effect-safe admin routing must fix kind=%s", kind) + } + + for _, kind := range adminAnalyzeKinds { + analyzeCalled = false + nestedBypass := mcpgo.CallToolRequest{} + nestedBypass.Params.Arguments = map[string]any{ + "options": map[string]any{"kind": kind}, + } + result, err = srv.handleFacade(context.Background(), "analyze", nestedBypass) + require.NoError(t, err) + require.True(t, result.IsError) + require.False(t, analyzeCalled, "nested %s must be rejected before the read-only analyze handler runs", kind) + } + + ambiguous := mcpgo.CallToolRequest{} + ambiguous.Params.Arguments = map[string]any{ + "operation": "source", + "target": map[string]any{ + "file": "a.go", "symbol": "a.go::A", + }, + } + result, err = srv.handleFacade(context.Background(), "read", ambiguous) + require.NoError(t, err) + require.True(t, result.IsError) +} + +func TestFacadeCapabilitiesReturnsOperationSchema(t *testing.T) { + srv := &Server{facades: newFacadeRegistry()} + legacy := mcpgo.NewTool("read_file", mcpgo.WithString("path", mcpgo.Required())) + srv.facades.capture(legacy, func(context.Context, mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + return mcpgo.NewToolResultText("ok"), nil + }) + req := mcpgo.CallToolRequest{} + req.Params.Arguments = map[string]any{"domain": "read", "operation": "file", "detail": "schema"} + result, err := srv.handleCapabilities(context.Background(), req) + require.NoError(t, err) + out := unmarshalResult(t, result) + require.Equal(t, FacadeSurfaceVersion, out["surface_version"]) + require.Equal(t, "read", out["domain"]) + require.Equal(t, "file", out["operation"]) + require.Equal(t, true, out["available"]) + require.NotEmpty(t, out["schema_hash"]) + require.NotNil(t, out["input_schema"]) + shape, ok := out["request_shape"].(map[string]any) + require.True(t, ok) + require.Equal(t, "read", shape["tool"]) + arguments, ok := shape["arguments"].(map[string]any) + require.True(t, ok) + require.Equal(t, "file", arguments["operation"]) + require.Equal(t, map[string]any{"file": ""}, arguments["target"]) +} + +func TestFacadeCapabilitiesChangeImpactUsesPublicTargetSchema(t *testing.T) { + srv := &Server{facades: newFacadeRegistry()} + legacy := mcpgo.NewTool("explain_change_impact", + mcpgo.WithString("ids", mcpgo.Required()), + mcpgo.WithBoolean("summary_only"), + mcpgo.WithNumber("offset"), + mcpgo.WithNumber("limit"), + mcpgo.WithString("format"), + mcpgo.WithNumber("max_bytes"), + ) + srv.facades.capture(legacy, func(context.Context, mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + return mcpgo.NewToolResultText("ok"), nil + }) + req := mcpgo.CallToolRequest{} + req.Params.Arguments = map[string]any{"domain": "change", "operation": "impact", "detail": "schema"} + result, err := srv.handleCapabilities(context.Background(), req) + require.NoError(t, err) + out := unmarshalResult(t, result) + + schema := out["input_schema"].(map[string]any) + properties := schema["properties"].(map[string]any) + require.NotContains(t, properties, "ids") + require.Equal(t, "impact", properties["operation"].(map[string]any)["const"]) + require.ElementsMatch(t, []any{"operation", "target"}, schema["required"].([]any)) + + target := properties["target"].(map[string]any) + targetProperties := target["properties"].(map[string]any) + require.Contains(t, targetProperties, "symbol") + require.Contains(t, targetProperties, "symbols") + require.Equal(t, float64(1), target["minProperties"]) + require.Equal(t, float64(1), target["maxProperties"]) + require.Equal(t, false, target["additionalProperties"]) + + outputProperties := properties["output"].(map[string]any)["properties"].(map[string]any) + for _, field := range []string{"summary_only", "offset", "limit", "format", "max_bytes"} { + require.Contains(t, outputProperties, field) + } + shape := out["request_shape"].(map[string]any)["arguments"].(map[string]any) + require.Equal(t, map[string]any{"symbol": ""}, shape["target"]) +} + +func TestFacadeCapabilitiesRequestShapesUsePublicMutationFields(t *testing.T) { + srv := &Server{facades: newFacadeRegistry()} + handler := func(context.Context, mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + return mcpgo.NewToolResultText("ok"), nil + } + srv.facades.capture(mcpgo.NewTool("edit_file", + mcpgo.WithString("path", mcpgo.Required()), + mcpgo.WithString("old_string", mcpgo.Required()), + mcpgo.WithString("new_string", mcpgo.Required()), + mcpgo.WithBoolean("dry_run"), + ), handler) + srv.facades.capture(mcpgo.NewTool("rename_symbol", + mcpgo.WithString("id", mcpgo.Required()), + mcpgo.WithString("new_name", mcpgo.Required()), + mcpgo.WithBoolean("dry_run"), + ), handler) + srv.facades.capture(mcpgo.NewTool("find_files"), handler) + srv.facades.capture(mcpgo.NewTool("api_impact"), handler) + srv.facades.capture(mcpgo.NewTool("explain_change_impact", + mcpgo.WithString("ids", mcpgo.Required()), + ), handler) + srv.facades.capture(mcpgo.NewTool("symbols_for_ranges"), handler) + srv.facades.capture(mcpgo.NewTool("batch_edit", + mcpgo.WithArray("edits", mcpgo.Required()), + ), handler) + srv.facades.capture(mcpgo.NewTool("context_closure"), handler) + srv.facades.capture(mcpgo.NewTool("verify_citation"), handler) + srv.facades.capture(mcpgo.NewTool("find_co_changing_symbols"), handler) + srv.facades.capture(mcpgo.NewTool("generate_skill"), handler) + + requestShape := func(domain, operation string) map[string]any { + t.Helper() + req := mcpgo.CallToolRequest{} + req.Params.Arguments = map[string]any{"domain": domain, "operation": operation, "detail": "schema"} + result, err := srv.handleCapabilities(context.Background(), req) + require.NoError(t, err) + out := unmarshalResult(t, result) + shape, ok := out["request_shape"].(map[string]any) + require.True(t, ok) + arguments, ok := shape["arguments"].(map[string]any) + require.True(t, ok) + return arguments + } + + edit := requestShape("edit", "file") + require.Equal(t, map[string]any{"file": ""}, edit["target"]) + require.Equal(t, "", edit["match"]) + require.Equal(t, "", edit["replacement"]) + require.Equal(t, true, edit["dry_run"]) + require.NotContains(t, edit, "old_string") + require.NotContains(t, edit, "new_string") + + rename := requestShape("refactor", "rename") + require.Equal(t, map[string]any{"symbol": ""}, rename["target"]) + require.Equal(t, "", rename["new_name"]) + require.Equal(t, true, rename["dry_run"]) + + files := requestShape("search", "files") + require.Equal(t, "", files["query"]) + apiImpact := requestShape("change", "api_impact") + require.Equal(t, "", apiImpact["source"].(map[string]any)["file"]) + impact := requestShape("change", "impact") + require.Equal(t, map[string]any{"symbol": ""}, impact["target"]) + require.NotContains(t, impact, "source") + ranges := requestShape("change", "ranges") + require.NotEmpty(t, ranges["source"].(map[string]any)["ranges"]) + batch := requestShape("edit", "batch") + require.NotEmpty(t, batch["changes"]) + closure := requestShape("explore", "closure") + require.NotEmpty(t, closure["options"].(map[string]any)["files"]) + citation := requestShape("analyze", "citation") + require.NotEmpty(t, citation["options"].(map[string]any)["span"]) + require.NotEmpty(t, citation["options"].(map[string]any)["file_path"]) + coChange := requestShape("analyze", "co_change") + require.Equal(t, map[string]any{"symbol": ""}, coChange["target"]) + skill := requestShape("edit", "skill") + require.Equal(t, "", skill["options"].(map[string]any)["directory"]) +} + +func TestFacadeCapabilitiesDiscoversNativeAnalyzeKinds(t *testing.T) { + srv := &Server{facades: newFacadeRegistry()} + srv.facades.capture(mcpgo.NewTool("analyze", + mcpgo.WithString("kind", mcpgo.Required()), + mcpgo.WithString("tag", mcpgo.Description("(todos) TODO tag; also accepted by (releases)")), + mcpgo.WithNumber("limit", mcpgo.Description("Maximum rows")), + mcpgo.WithString("profile", mcpgo.Description("(coverage) Cover profile")), + mcpgo.WithString("from_id", mcpgo.Description("(would_create_cycle) Source symbol")), + mcpgo.WithString("to_id", mcpgo.Description("(would_create_cycle) Target symbol")), + mcpgo.WithString("id", mcpgo.Description("(def_use) Symbol")), + mcpgo.WithString("ids", mcpgo.Description("(def_use, impact) Symbols")), + mcpgo.WithBoolean("use_llm", mcpgo.Description("(concepts) Use a model")), + mcpgo.WithBoolean("refresh_cochange", mcpgo.Description("(impact) Refresh co-change")), + mcpgo.WithBoolean("materialize", mcpgo.Description("(sql_call_sites) Rebuild SQL edges")), + ), func(context.Context, mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + return mcpgo.NewToolResultText("ok"), nil + }) + + req := mcpgo.CallToolRequest{} + req.Params.Arguments = map[string]any{"domain": "analyze", "operation": "todos", "detail": "schema"} + result, err := srv.handleCapabilities(context.Background(), req) + require.NoError(t, err) + out := unmarshalResult(t, result) + require.Equal(t, true, out["available"]) + require.Equal(t, AnalyzeKindDescription("todos"), out["summary"]) + shape := out["request_shape"].(map[string]any)["arguments"].(map[string]any) + require.Equal(t, "todos", shape["kind"]) + schema := out["input_schema"].(map[string]any) + schemaProperties := schema["properties"].(map[string]any) + optionsProperties := schemaProperties["options"].(map[string]any)["properties"].(map[string]any) + require.Contains(t, optionsProperties, "tag") + require.NotContains(t, optionsProperties, "profile") + require.NotContains(t, optionsProperties, "from_id") + require.Contains(t, schemaProperties["output"].(map[string]any)["properties"], "limit") + + capability := func(domain, operation string) map[string]any { + t.Helper() + call := mcpgo.CallToolRequest{} + call.Params.Arguments = map[string]any{"domain": domain, "operation": operation, "detail": "schema"} + got, callErr := srv.handleCapabilities(context.Background(), call) + require.NoError(t, callErr) + return unmarshalResult(t, got) + } + + would := capability("analyze", "would_create_cycle") + wouldSchema := would["input_schema"].(map[string]any) + require.ElementsMatch(t, []any{"kind", "options"}, wouldSchema["required"].([]any)) + wouldOptions := wouldSchema["properties"].(map[string]any)["options"].(map[string]any) + require.ElementsMatch(t, []any{"from_id", "to_id"}, wouldOptions["required"].([]any)) + + defUse := capability("analyze", "def_use") + defSchema := defUse["input_schema"].(map[string]any) + require.Contains(t, defSchema["required"].([]any), "target") + defTarget := defSchema["properties"].(map[string]any)["target"].(map[string]any) + require.Contains(t, defTarget["properties"].(map[string]any), "symbol") + + coverage := capability("workspace_admin", "coverage") + require.Equal(t, AnalyzeKindDescription("coverage"), coverage["summary"]) + coverageSchema := coverage["input_schema"].(map[string]any) + coverageArgs := coverageSchema["properties"].(map[string]any)["arguments"].(map[string]any) + require.Contains(t, coverageArgs["properties"].(map[string]any), "profile") + require.Contains(t, coverageArgs["required"].([]any), "profile") + require.Equal(t, "coverage", coverage["fixed_arguments"].(map[string]any)["kind"]) + + concepts := capability("analyze", "concepts") + require.Equal(t, false, concepts["fixed_arguments"].(map[string]any)["use_llm"]) + require.Equal(t, "concepts", concepts["fixed_arguments"].(map[string]any)["kind"]) + + releases := capability("analyze", "releases") + releasesOptions := releases["input_schema"].(map[string]any)["properties"].(map[string]any)["options"].(map[string]any)["properties"].(map[string]any) + require.Contains(t, releasesOptions, "tag") + + listReq := mcpgo.CallToolRequest{} + listReq.Params.Arguments = map[string]any{"domain": "analyze"} + listResult, err := srv.handleCapabilities(context.Background(), listReq) + require.NoError(t, err) + list := unmarshalResult(t, listResult) + operations := list["operations"].([]any) + foundTodos := false + foundHelp := false + for _, raw := range operations { + operation := raw.(map[string]any)["operation"] + foundTodos = foundTodos || operation == "todos" + foundHelp = foundHelp || operation == "help" + require.NotEqual(t, "graph", operation) + for _, adminKind := range adminAnalyzeKinds { + require.NotEqual(t, adminKind, operation) + } + } + require.True(t, foundTodos) + require.True(t, foundHelp) +} + +func TestFacadeCapabilitiesCollapseSessionSubscriptionChannels(t *testing.T) { + srv := &Server{facades: newFacadeRegistry()} + handler := func(context.Context, mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + return mcpgo.NewToolResultText("ok"), nil + } + srv.facades.capture(mcpgo.NewTool("subscribe_diagnostics"), handler) + srv.facades.capture(mcpgo.NewTool("unsubscribe_diagnostics"), handler) + srv.facades.capture(mcpgo.NewTool("nav", mcpgo.WithString("action", mcpgo.Required()), mcpgo.WithString("id")), handler) + + req := mcpgo.CallToolRequest{} + req.Params.Arguments = map[string]any{"domain": "session"} + result, err := srv.handleCapabilities(context.Background(), req) + require.NoError(t, err) + operations := unmarshalResult(t, result)["operations"].([]any) + seen := map[string]bool{} + for _, raw := range operations { + name := raw.(map[string]any)["operation"].(string) + seen[name] = true + require.NotContains(t, name, "diagnostics") + require.NotContains(t, name, "daemon_health") + } + require.True(t, seen["subscribe"]) + require.True(t, seen["unsubscribe"]) + require.True(t, seen["cursor"]) + + schemaReq := mcpgo.CallToolRequest{} + schemaReq.Params.Arguments = map[string]any{"domain": "session", "operation": "subscribe", "detail": "schema"} + schemaResult, err := srv.handleCapabilities(context.Background(), schemaReq) + require.NoError(t, err) + shape := unmarshalResult(t, schemaResult)["request_shape"].(map[string]any)["arguments"].(map[string]any) + require.Equal(t, "subscribe", shape["operation"]) + require.Equal(t, "", shape["channel"]) + + cursorReq := mcpgo.CallToolRequest{} + cursorReq.Params.Arguments = map[string]any{"domain": "session", "operation": "cursor", "detail": "schema"} + cursorResult, err := srv.handleCapabilities(context.Background(), cursorReq) + require.NoError(t, err) + cursorShape := unmarshalResult(t, cursorResult)["request_shape"].(map[string]any)["arguments"].(map[string]any) + require.Equal(t, "cursor", cursorShape["operation"]) + require.Equal(t, "", cursorShape["arguments"].(map[string]any)["action"]) + + invalid := mcpgo.CallToolRequest{} + invalid.Params.Arguments = map[string]any{"operation": "subscribe", "channel": "private_channel"} + invalidResult, err := srv.handleFacade(context.Background(), "session", invalid) + require.NoError(t, err) + require.True(t, invalidResult.IsError) + invalidText := toolResultText(invalidResult) + require.Contains(t, invalidText, "valid_channels") + require.NotContains(t, invalidText, "subscribe_diagnostics") + require.NotContains(t, invalidText, "unsubscribe_daemon_health") +} + +func TestFacadeCapabilitiesUsesPublicDomainVocabulary(t *testing.T) { + srv := &Server{facades: newFacadeRegistry()} + + listReq := mcpgo.CallToolRequest{} + listResult, err := srv.handleCapabilities(context.Background(), listReq) + require.NoError(t, err) + list := unmarshalResult(t, listResult) + require.NotNil(t, list["domains"]) + require.NotContains(t, list, "facades") + + domainReq := mcpgo.CallToolRequest{} + domainReq.Params.Arguments = map[string]any{"domain": "read"} + domainResult, err := srv.handleCapabilities(context.Background(), domainReq) + require.NoError(t, err) + domain := unmarshalResult(t, domainResult) + require.Equal(t, "read", domain["domain"]) + require.NotContains(t, domain, "facade") + + unknownReq := mcpgo.CallToolRequest{} + unknownReq.Params.Arguments = map[string]any{"domain": "missing"} + unknownResult, err := srv.handleCapabilities(context.Background(), unknownReq) + require.NoError(t, err) + require.True(t, unknownResult.IsError) + unknownText := unknownResult.Content[0].(mcpgo.TextContent).Text + require.Contains(t, unknownText, "unknown tool domain") + require.Contains(t, unknownText, "valid_domains") + require.NotContains(t, unknownText, "valid_facades") +} + +func TestFacadeDispatchRecordsOperationTelemetry(t *testing.T) { + srv := &Server{facades: newFacadeRegistry()} + srv.facades.capture(mcpgo.NewTool("read_file"), func(_ context.Context, _ mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + return mcpgo.NewToolResultText("ok"), nil + }) + store := telemetry.NewStore(t.TempDir()) + srv.SetTelemetryRecorder(telemetry.NewRecorder(telemetry.Consent{Enabled: true}, store)) + req := mcpgo.CallToolRequest{} + req.Params.Arguments = map[string]any{"operation": "file", "target": map[string]any{"file": "x.go"}} + _, err := srv.handleFacade(context.Background(), "read", req) + require.NoError(t, err) + + invalid := mcpgo.CallToolRequest{} + invalid.Params.Arguments = map[string]any{ + "operation": "file", + "target": map[string]any{"file": "x.go", "symbol": "secretSymbol"}, + } + result, err := srv.handleFacade(context.Background(), "read", invalid) + require.NoError(t, err) + require.True(t, result.IsError) + + // Unknown request values are observable only through a fixed sentinel; + // neither the raw operation nor its deterministic hash may be recorded. + const sensitiveOperation = "/Users/alice/private/repository/read-secret" + unknown := mcpgo.CallToolRequest{} + unknown.Params.Arguments = map[string]any{"operation": sensitiveOperation} + result, err = srv.handleFacade(context.Background(), "read", unknown) + require.NoError(t, err) + require.True(t, result.IsError) + + unknownCapability := mcpgo.CallToolRequest{} + unknownCapability.Params.Arguments = map[string]any{"domain": sensitiveOperation} + result, err = srv.handleCapabilities(context.Background(), unknownCapability) + require.NoError(t, err) + require.True(t, result.IsError) + + srv.recordFacadeTelemetry("analyze", "todos", facadeOutcomeSuccess, time.Millisecond) + srv.recordFacadeTelemetry("analyze", "coverage", facadeOutcomeBlocked, time.Millisecond) + srv.recordFacadeTelemetry("analyze", sensitiveOperation, facadeOutcomeSuccess, time.Millisecond) + + srv.FlushTelemetry() + days, err := store.Days() + require.NoError(t, err) + require.Len(t, days, 1) + rollup, err := store.Load(days[0]) + require.NoError(t, err) + require.Equal(t, 2, rollup.Counts["mcp_facade_call:read.file"]) + require.Equal(t, 1, rollup.Counts["mcp_facade_status:read.file.ok"]) + require.Equal(t, 1, rollup.Counts["mcp_facade_status:read.file.error"]) + require.Equal(t, 1, rollup.Counts["mcp_facade_outcome:read.file.success"]) + require.Equal(t, 1, rollup.Counts["mcp_facade_outcome:read.file.invalid_argument"]) + require.Equal(t, 1, rollup.Counts["mcp_facade_invalid:read.file.invalid_argument"]) + require.Equal(t, 1, rollup.Counts["mcp_facade_call:read.unknown"]) + require.Equal(t, 1, rollup.Counts["mcp_facade_status:read.unknown.error"]) + require.Equal(t, 1, rollup.Counts["mcp_facade_outcome:read.unknown.invalid_operation"]) + require.Equal(t, 1, rollup.Counts["mcp_facade_invalid:read.unknown.invalid_argument"]) + require.Equal(t, 1, rollup.Counts["mcp_facade_call:capabilities.unknown"]) + require.Equal(t, 1, rollup.Counts["mcp_facade_outcome:"+ + boundedFacadeTelemetryDimension("capabilities", "unknown", facadeOutcomeInvalidOperation)]) + require.Equal(t, 1, rollup.Counts["mcp_facade_invalid:"+ + boundedFacadeTelemetryDimension("capabilities", "unknown", string(ErrCodeInvalidArgument))]) + require.Equal(t, 1, rollup.Counts["mcp_facade_call:analyze.todos"]) + require.Equal(t, 1, rollup.Counts["mcp_facade_outcome:analyze.todos.success"]) + require.Equal(t, 1, rollup.Counts["mcp_facade_call:analyze.coverage"]) + require.Equal(t, 1, rollup.Counts["mcp_facade_outcome:analyze.coverage.blocked"]) + require.Equal(t, 1, rollup.Counts["mcp_facade_call:analyze.unknown"]) + latencyCounts := map[string]int{ + "read.file": 0, "read.unknown": 0, "capabilities.unknown": 0, + "analyze.todos": 0, "analyze.coverage": 0, "analyze.unknown": 0, + } + for key := range rollup.Counts { + require.LessOrEqual(t, len(strings.TrimPrefix(key, strings.SplitN(key, ":", 2)[0]+":")), 32) + require.NotContains(t, key, "alice") + require.NotContains(t, key, "private") + require.NotContains(t, key, "read-secret") + require.NotContains(t, key, "secretSymbol") + for identity := range latencyCounts { + if strings.HasPrefix(key, "mcp_facade_latency:"+identity+".") { + latencyCounts[identity] += rollup.Counts[key] + } + } + } + require.Equal(t, 2, latencyCounts["read.file"]) + require.Equal(t, 1, latencyCounts["read.unknown"]) + require.Equal(t, 1, latencyCounts["capabilities.unknown"]) + require.Equal(t, 1, latencyCounts["analyze.todos"]) + require.Equal(t, 1, latencyCounts["analyze.coverage"]) + require.Equal(t, 1, latencyCounts["analyze.unknown"]) + + long := facadeTelemetryDimension(facadeOperationSpec{Facade: "session", Operation: "unsubscribe_workspace_readiness"}) + require.LessOrEqual(t, len(long), 32) +} + +func TestFacadeTelemetryRespectsDisabledConsent(t *testing.T) { + srv := &Server{facades: newFacadeRegistry()} + srv.facades.capture(mcpgo.NewTool("read_file"), func(_ context.Context, _ mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + return mcpgo.NewToolResultText("ok"), nil + }) + store := telemetry.NewStore(t.TempDir()) + srv.SetTelemetryRecorder(telemetry.NewRecorder(telemetry.Consent{Enabled: false}, store)) + req := mcpgo.CallToolRequest{} + req.Params.Arguments = map[string]any{"operation": "file", "target": map[string]any{"file": "x.go"}} + _, err := srv.handleFacade(context.Background(), "read", req) + require.NoError(t, err) + srv.FlushTelemetry() + days, err := store.Days() + require.NoError(t, err) + require.Empty(t, days) +} + +func TestFacadeReadSelectorCardinalityDefaultsAndAliases(t *testing.T) { + srv := &Server{facades: newFacadeRegistry()} + capture := func(_ context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + return mcpgo.NewToolResultJSON(req.GetArguments()) + } + srv.facades.capture(mcpgo.NewTool("read_file"), capture) + srv.facades.capture(mcpgo.NewTool("get_symbol_source"), capture) + srv.facades.capture(mcpgo.NewTool("batch_symbols"), capture) + + call := func(arguments map[string]any) map[string]any { + t.Helper() + req := mcpgo.CallToolRequest{} + req.Params.Arguments = arguments + result, err := srv.handleFacade(context.Background(), "read", req) + require.NoError(t, err) + return unmarshalResult(t, result) + } + + batch := call(map[string]any{ + "operation": "source", + "target": map[string]any{"symbols": []any{"a.go::A", "b.go::B"}}, + }) + require.Equal(t, "a.go::A,b.go::B", batch["ids"]) + require.Equal(t, true, batch["include_source"]) + + metadataOnly := call(map[string]any{ + "operation": "symbols", + "target": map[string]any{"symbols": []any{"a.go::A"}}, + "options": map[string]any{"include_source": false}, + }) + require.Equal(t, false, metadataOnly["include_source"]) + + single := call(map[string]any{ + "operation": "symbols", + "target": map[string]any{"symbol": "a.go::A"}, + }) + require.Equal(t, "a.go::A", single["id"]) + require.NotContains(t, single, "ids") + + line := call(map[string]any{ + "operation": "source", + "target": map[string]any{"file": "a.go"}, + "options": map[string]any{"line": 42}, + }) + require.Equal(t, "a.go", line["path"]) + require.Equal(t, float64(42), line["offset"]) + require.Equal(t, float64(1), line["limit"]) + + window := call(map[string]any{ + "operation": "source", + "target": map[string]any{"file": "a.go"}, + "options": map[string]any{"window": map[string]any{"offset": 20, "limit": 7}}, + }) + require.Equal(t, float64(20), window["offset"]) + require.Equal(t, float64(7), window["limit"]) +} + +func TestFacadeSchemasEnumerateOnlyAvailableOperations(t *testing.T) { + srv := &Server{facades: newFacadeRegistry()} + capture := func(_ context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + return mcpgo.NewToolResultJSON(req.GetArguments()) + } + srv.facades.capture(mcpgo.NewTool("read_file", mcpgo.WithString("path", mcpgo.Required())), capture) + + runtime := srv.facadeToolDefinition("read") + operation := runtime.InputSchema.Properties["operation"].(map[string]any) + require.Equal(t, []string{"file"}, operation["enum"]) + + canonical := facadeToolDefinition("read") + canonicalOperation := canonical.InputSchema.Properties["operation"].(map[string]any) + require.Contains(t, canonicalOperation["enum"], "source") + require.Contains(t, canonicalOperation["enum"], "symbols") + analyzeKind := facadeToolDefinition("analyze").InputSchema.Properties["kind"].(map[string]any) + require.Contains(t, analyzeKind["enum"], "todos") + + srv.facades.capture(mcpgo.NewTool("batch_symbols", + mcpgo.WithString("ids", mcpgo.Required()), + mcpgo.WithBoolean("include_source"), + ), capture) + spec, ok := srv.capabilityOperation("read", "symbols") + require.True(t, ok) + capability := srv.facadeCapability(spec, true) + schema := capability["input_schema"].(map[string]any) + properties := schema["properties"].(map[string]any) + exactOperation := properties["operation"].(map[string]any) + require.Equal(t, []string{"symbols"}, exactOperation["enum"]) + options := properties["options"].(map[string]any)["properties"].(map[string]any) + includeSource := options["include_source"].(map[string]any) + require.Equal(t, true, includeSource["default"]) + require.Contains(t, includeSource["description"], "default: true") +} + +func TestBatchEditPreflightsAllItemsBeforeFirstWrite(t *testing.T) { + srv, root := setupTestServer(t) + path := filepath.Join(root, "batch-preflight.txt") + require.NoError(t, os.WriteFile(path, []byte("alpha beta\n"), 0o644)) + + req := mcpgo.CallToolRequest{} + req.Params.Arguments = map[string]any{ + "edits": []any{ + map[string]any{ + "op": "edit_file", "path": path, + "old_string": "alpha", "new_string": "ALPHA", + }, + map[string]any{ + "op": "edit_file", "path": path, + "old_string": "beta", "new_string": "beta", + }, + }, + } + result, err := srv.handleBatchEdit(context.Background(), req) + require.NoError(t, err) + out := unmarshalResult(t, result) + summary := out["summary"].(map[string]any) + require.Equal(t, float64(0), summary["applied"]) + require.Equal(t, float64(1), summary["failed"]) + require.Equal(t, float64(1), summary["skipped"]) + content, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, "alpha beta\n", string(content), "a later no-op must fail before the first edit writes") +} + +func TestFacadeReadResolvesOnlyUniqueSymbolShorthand(t *testing.T) { + g := graph.New() + bm := search.NewBM25() + first := &graph.Node{ID: "pkg/a.go::UniqueReadTarget", Name: "UniqueReadTarget", Kind: graph.KindFunction, FilePath: "pkg/a.go"} + g.AddNode(first) + bm.Add(first.ID, first.Name, first.FilePath, first.Name) + eng := query.NewEngine(g) + eng.SetSearch(bm) + srv := NewServer(eng, g, nil, nil, zap.NewNop(), nil) + + resolved, ambiguous := srv.resolveFacadeSymbolShorthand(context.Background(), "UniqueReadTarget") + require.Equal(t, first.ID, resolved) + require.Empty(t, ambiguous) + + second := &graph.Node{ID: "pkg/b.go::UniqueReadTarget", Name: "UniqueReadTarget", Kind: graph.KindFunction, FilePath: "pkg/b.go"} + g.AddNode(second) + bm.Add(second.ID, second.Name, second.FilePath, second.Name) + resolved, ambiguous = srv.resolveFacadeSymbolShorthand(context.Background(), "UniqueReadTarget") + require.Equal(t, "UniqueReadTarget", resolved) + require.ElementsMatch(t, []string{first.ID, second.ID}, ambiguous) +} diff --git a/internal/mcp/format_negotiation_test.go b/internal/mcp/format_negotiation_test.go index 1c166c244..54238acce 100644 --- a/internal/mcp/format_negotiation_test.go +++ b/internal/mcp/format_negotiation_test.go @@ -31,12 +31,18 @@ func TestDefaultFormatForClient(t *testing.T) { {"opencode", "gcx"}, {"openclaw", "gcx"}, {"codex", "gcx"}, + {"openai-codex", "gcx"}, + {"Claude Code 1.4", "gcx"}, + {"Visual Studio Code", "gcx"}, {"omp-coding-agent", "gcx"}, // Unknown / unset → JSON fallback. {"", ""}, {"some-other-client", ""}, + {"windsurf", ""}, {"unknown", ""}, + {"Claude Desktop", ""}, + {"not-codex", ""}, } for _, tc := range cases { assert.Equal(t, tc.want, defaultFormatForClient(tc.client), diff --git a/internal/mcp/freshness_rider.go b/internal/mcp/freshness_rider.go index 8534e08a6..03add1e2a 100644 --- a/internal/mcp/freshness_rider.go +++ b/internal/mcp/freshness_rider.go @@ -112,10 +112,11 @@ func (s *Server) freshnessRiderFor(toolName string, req mcp.CallToolRequest) map // stays decoupled from the concrete *indexer.Watcher behind the server's // watcher field. func (s *Server) watchDegradedReason() string { - if s.watcher == nil { + watcher := s.currentWatcher() + if watcher == nil { return "" } - if dr, ok := s.watcher.(interface{ DegradedReason() string }); ok { + if dr, ok := watcher.(interface{ DegradedReason() string }); ok { return dr.DegradedReason() } return "" diff --git a/internal/mcp/host_context.go b/internal/mcp/host_context.go index df149816c..449b79c9e 100644 --- a/internal/mcp/host_context.go +++ b/internal/mcp/host_context.go @@ -10,12 +10,12 @@ import ( // hostContext is a runtime, per-host adaptation of the served tool // surface, resolved from the MCP initialize clientInfo.name. It is the // serve-time counterpart of the install-time agent adapters: it can hide -// tools the host duplicates, override individual tool descriptions, and -// carry a host-specific guidance fragment surfaced via tool_profile. +// tools the host duplicates and override individual tool descriptions. +// Workflow guidance is surface-dependent and comes from MCP initialize, not +// from host labels: a legacy tool_profile must never recommend compact names. type hostContext struct { name string // canonical host name ("" = no context) matches []string // lowercase substrings of clientInfo.name that select this context - instruction string // host-specific guidance fragment excluded map[string]bool // tools removed from this host's tools/list descOverride map[string]string // per-tool description replacements } @@ -45,32 +45,22 @@ func (h hostContext) apply(tools []mcp.Tool) []mcp.Tool { return out } -// editorHostInstruction is shared by the IDE-extension hosts. -const editorHostInstruction = "You are driving Gortex from an editor extension. Push unsaved buffers " + - "with overlay_push so graph queries see your in-progress edits before they reach disk, and use " + - "preview_edit / simulate_chain to evaluate a change without writing it." - // hostContexts is the runtime registry of per-host adaptations, matched // against the MCP initialize clientInfo.name. Order matters — the first // matching entry wins, so more specific hosts come first. var hostContexts = []hostContext{ { name: "claude-code", - matches: []string{"claude"}, - instruction: "Gortex runs here with PreToolUse hooks that redirect Read / Grep / Glob to graph " + - "tools. Begin every task with smart_context, prefer get_symbol_source over reading whole " + - "files, and edit through edit_file / edit_symbol.", + matches: []string{"claude-code", "claude code"}, }, - {name: "cursor", matches: []string{"cursor"}, instruction: editorHostInstruction}, - {name: "vscode", matches: []string{"vscode", "visual studio"}, instruction: editorHostInstruction}, - {name: "zed", matches: []string{"zed"}, instruction: editorHostInstruction}, - {name: "windsurf", matches: []string{"windsurf"}, instruction: editorHostInstruction}, - {name: "jetbrains", matches: []string{"jetbrains", "intellij"}, instruction: editorHostInstruction}, + {name: "cursor", matches: []string{"cursor"}}, + {name: "vscode", matches: []string{"vscode", "visual studio"}}, + {name: "zed", matches: []string{"zed"}}, + {name: "windsurf", matches: []string{"windsurf"}}, + {name: "jetbrains", matches: []string{"jetbrains", "intellij"}}, { name: "codex", - matches: []string{"codex"}, - instruction: "Gortex is available as an MCP server. Use search_symbols and smart_context to " + - "locate code before editing, and verify changes with check_guards and get_test_targets.", + matches: []string{"codex", "openai-codex"}, }, } @@ -83,7 +73,7 @@ func resolveHostContext(clientName string) hostContext { } for _, hc := range hostContexts { for _, m := range hc.matches { - if strings.Contains(name, m) { + if matchesClientAlias(name, m) { return hc } } @@ -91,6 +81,21 @@ func resolveHostContext(clientName string) hostContext { return hostContext{} } +// matchesClientAlias accepts an exact client family or an anchored versioned +// spelling. Substring matching is unsafe here: names such as "not-codex" and +// "Claude Desktop" must remain unknown and therefore JSON-only. +func matchesClientAlias(name, alias string) bool { + if name == alias { + return true + } + for _, separator := range []string{" ", "/", "@"} { + if strings.HasPrefix(name, alias+separator) { + return true + } + } + return false +} + // sessionHostContext resolves the per-host context for the request's // session from the MCP client name captured at initialize time. func (s *Server) sessionHostContext(ctx context.Context) hostContext { diff --git a/internal/mcp/host_context_test.go b/internal/mcp/host_context_test.go index 7944f8858..6c6de9ef0 100644 --- a/internal/mcp/host_context_test.go +++ b/internal/mcp/host_context_test.go @@ -15,6 +15,8 @@ func TestResolveHostContext(t *testing.T) { require.Equal(t, "cursor", resolveHostContext("Cursor").name) require.Equal(t, "vscode", resolveHostContext("Visual Studio Code").name) require.Equal(t, "", resolveHostContext("some-unknown-agent").name) + require.Equal(t, "", resolveHostContext("Claude Desktop").name) + require.Equal(t, "", resolveHostContext("not-codex").name) require.True(t, resolveHostContext("").empty(), "an unidentified host applies no adaptation") } @@ -57,5 +59,5 @@ func TestToolProfile_ReportsHostContext(t *testing.T) { var profile map[string]any require.NoError(t, json.Unmarshal([]byte(res.Content[0].(mcplib.TextContent).Text), &profile)) require.Equal(t, "cursor", profile["host"], "tool_profile must report the resolved host") - require.NotEmpty(t, profile["host_instruction"], "an editor host carries a guidance fragment") + require.NotContains(t, profile, "host_instruction", "legacy tool_profile must not recommend compact-only names") } diff --git a/internal/mcp/hotspots_lazy_test.go b/internal/mcp/hotspots_lazy_test.go new file mode 100644 index 000000000..79756a72e --- /dev/null +++ b/internal/mcp/hotspots_lazy_test.go @@ -0,0 +1,158 @@ +package mcp + +import ( + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zzet/gortex/internal/analysis" + "github.com/zzet/gortex/internal/graph" +) + +func markHotspotAnalysisCurrent(s *Server) { + s.analysisEpoch = 1 + token := s.currentCommunityToken() + s.communitiesToken = token + s.adjacencyToken = token +} + +func TestGetHotspotsCoalescesConcurrentLazyBuilds(t *testing.T) { + t.Setenv("GORTEX_DAEMON_MEMRELEASE", "0") + var builds atomic.Int32 + s := &Server{ + graph: graph.New(), + hotspotsFn: func(graph.Store, *analysis.CommunityResult, float64) []analysis.HotspotEntry { + builds.Add(1) + return []analysis.HotspotEntry{{ID: "hot"}} + }, + } + markHotspotAnalysisCurrent(s) + + const callers = 32 + results := make(chan []analysis.HotspotEntry, callers) + var wg sync.WaitGroup + wg.Add(callers) + for range callers { + go func() { + defer wg.Done() + results <- s.getHotspots() + }() + } + wg.Wait() + close(results) + + for got := range results { + require.Len(t, got, 1) + assert.Equal(t, "hot", got[0].ID) + } + assert.EqualValues(t, 1, builds.Load()) +} + +func TestGetHotspotsRebuildsAfterAnalysisEpochInvalidation(t *testing.T) { + t.Setenv("GORTEX_DAEMON_MEMRELEASE", "0") + var builds atomic.Int32 + s := &Server{ + graph: graph.New(), + hotspotsFn: func(graph.Store, *analysis.CommunityResult, float64) []analysis.HotspotEntry { + return []analysis.HotspotEntry{{ID: string(rune('0' + builds.Add(1)))}} + }, + } + markHotspotAnalysisCurrent(s) + + first := s.getHotspots() + require.Len(t, first, 1) + + s.analysisMu.Lock() + s.analysisEpoch++ + s.hotspots = nil + s.hotspotsReady = false + s.analysisMu.Unlock() + + second := s.getHotspots() + require.Len(t, second, 1) + assert.NotEqual(t, first[0].ID, second[0].ID) + assert.EqualValues(t, 2, builds.Load()) +} + +func TestIncrementalCommunitiesInvalidatesInFlightHotspotBuild(t *testing.T) { + t.Setenv("GORTEX_DAEMON_MEMRELEASE", "0") + g := graph.New() + g.AddNode(&graph.Node{ID: "pkg/a.go::A", Kind: graph.KindFunction, Name: "A", FilePath: "pkg/a.go", Language: "go"}) + + oldCommunities := &analysis.CommunityResult{ + NodeToComm: map[string]string{"pkg/a.go::A": "old"}, + } + started := make(chan struct{}) + release := make(chan struct{}) + var builds atomic.Int32 + var firstSawOld atomic.Bool + s := &Server{ + graph: g, + communities: oldCommunities, + hotspotsFn: func(_ graph.Store, communities *analysis.CommunityResult, _ float64) []analysis.HotspotEntry { + if builds.Add(1) == 1 { + firstSawOld.Store(communities == oldCommunities) + close(started) + <-release + return []analysis.HotspotEntry{{ID: "stale"}} + } + if communities == oldCommunities { + return []analysis.HotspotEntry{{ID: "stale"}} + } + return []analysis.HotspotEntry{{ID: "fresh"}} + }, + } + markHotspotAnalysisCurrent(s) + + resultCh := make(chan []analysis.HotspotEntry, 1) + go func() { resultCh <- s.getHotspots() }() + select { + case <-started: + case <-time.After(5 * time.Second): + close(release) + t.Fatal("hotspot build did not start") + } + + g.AddNode(&graph.Node{ID: "pkg/b.go::B", Kind: graph.KindFunction, Name: "B", FilePath: "pkg/b.go", Language: "go"}) + beforeEpoch := s.analysisEpoch + communities, _ := s.incrementalCommunities() + require.NotSame(t, oldCommunities, communities) + + s.analysisMu.RLock() + assert.Equal(t, beforeEpoch+1, s.analysisEpoch) + assert.False(t, s.hotspotsReady) + assert.Nil(t, s.hotspots) + s.analysisMu.RUnlock() + + close(release) + select { + case got := <-resultCh: + require.Len(t, got, 1) + assert.Equal(t, "fresh", got[0].ID) + case <-time.After(5 * time.Second): + t.Fatal("hotspot build did not finish") + } + assert.True(t, firstSawOld.Load()) + assert.EqualValues(t, 2, builds.Load()) +} + +func TestGetHotspotsRejectsCachedResultAfterGraphMutation(t *testing.T) { + t.Setenv("GORTEX_DAEMON_MEMRELEASE", "0") + g := graph.New() + g.AddNode(&graph.Node{ID: "pkg/a.go::A", Kind: graph.KindFunction, Name: "A", FilePath: "pkg/a.go"}) + s := &Server{ + graph: g, + hotspots: []analysis.HotspotEntry{{ID: "stale"}}, + hotspotsReady: true, + communities: &analysis.CommunityResult{NodeToComm: map[string]string{"pkg/a.go::A": "old"}}, + } + markHotspotAnalysisCurrent(s) + + g.AddNode(&graph.Node{ID: "pkg/b.go::B", Kind: graph.KindFunction, Name: "B", FilePath: "pkg/b.go"}) + + assert.Nil(t, s.getHotspots()) +} diff --git a/internal/mcp/instruction_profile_policy_test.go b/internal/mcp/instruction_profile_policy_test.go index f30ad953c..24e0150d4 100644 --- a/internal/mcp/instruction_profile_policy_test.go +++ b/internal/mcp/instruction_profile_policy_test.go @@ -72,6 +72,7 @@ func TestOperatorPinnedToolPolicy(t *testing.T) { }{ {"zero config", ToolPolicyConfig{}, false}, {"shipped default", ToolPolicyConfig{Preset: "core", Mode: "defer"}, false}, + {"explicit shipped values", ToolPolicyConfig{Preset: "core", Mode: "defer", OperatorPinned: true}, true}, {"default alias", ToolPolicyConfig{Preset: "default", Mode: "defer"}, false}, {"core in hide mode", ToolPolicyConfig{Preset: "core", Mode: "hide"}, true}, {"named preset", ToolPolicyConfig{Preset: "agent", Mode: "defer"}, true}, diff --git a/internal/mcp/instructions_test.go b/internal/mcp/instructions_test.go index 8f58da1a1..b4989b7d2 100644 --- a/internal/mcp/instructions_test.go +++ b/internal/mcp/instructions_test.go @@ -47,11 +47,7 @@ func TestInitialize_ReturnsInstructions(t *testing.T) { if strings.TrimSpace(parsed.Result.Instructions) == "" { t.Fatalf("initialize response carried no instructions field; got: %s", out) } - for _, want := range []string{"explore", "search_symbols", "tools_search"} { - if !strings.Contains(parsed.Result.Instructions, want) { - t.Errorf("instructions should mention %q; got: %q", want, parsed.Result.Instructions) - } - } + require.Equal(t, codingAgentInstructions, parsed.Result.Instructions) } func TestServerInstructions_NonEmpty(t *testing.T) { diff --git a/internal/mcp/lazy_tools_test.go b/internal/mcp/lazy_tools_test.go index 019b5cba1..8f24210c5 100644 --- a/internal/mcp/lazy_tools_test.go +++ b/internal/mcp/lazy_tools_test.go @@ -331,7 +331,10 @@ func TestLazyRegistration_PromotedToolsDispatchNormally(t *testing.T) { func TestLazyRegistration_HitsSpecTarget(t *testing.T) { t.Setenv("GORTEX_LAZY_TOOLS", "1") srv, _ := setupTestServer(t) - eager := len(srv.mcpServer.ListTools()) + // Count the effective initial tools/list, not the process-global live map: + // facade-v1 dispatchers are registered live for Codex but session-filtered + // out of the legacy/default surface. + eager := len(srv.sessionLiveToolNames(context.Background())) deferred := srv.lazy.CountDeferred() // Spec target: deferred ≥ 30 (the gap-analysis floor for the row diff --git a/internal/mcp/localization_terminal.go b/internal/mcp/localization_terminal.go new file mode 100644 index 000000000..3b0a60b45 --- /dev/null +++ b/internal/mcp/localization_terminal.go @@ -0,0 +1,282 @@ +package mcp + +import ( + "context" + "fmt" + "strings" + "sync" + + mcpgo "github.com/mark3labs/mcp-go/mcp" +) + +const ( + localizationStateInactive = "" + localizationStateNeedsExactRead = "needs_exact_read" + localizationStateExactReadInFlight = "exact_read_in_flight" + localizationStateAnswerReady = "answer_ready" +) + +// localizationCompletion is the host-neutral terminality contract returned by +// explore(operation:"localize"). Hosts may stop the turn from this payload; +// the server also enforces it for later Gortex navigation calls in the same +// MCP session. +type localizationCompletion struct { + State string `json:"state"` + Scope string `json:"scope"` + RequiredAction string `json:"required_action"` + AllowedToolCalls int `json:"allowed_tool_calls"` + ExactSymbol string `json:"exact_symbol,omitempty"` +} + +func newLocalizationCompletion(answerReady bool, exactSymbol string) localizationCompletion { + if answerReady { + return localizationCompletion{ + State: localizationStateAnswerReady, + Scope: "localization", + RequiredAction: "respond", + AllowedToolCalls: 0, + } + } + return localizationCompletion{ + State: localizationStateNeedsExactRead, + Scope: "localization", + RequiredAction: "read_exact", + AllowedToolCalls: 1, + ExactSymbol: exactSymbol, + } +} + +// localizationTerminalState is intentionally session-local. It never affects +// mutation, analysis, workspace, or memory tools; it only prevents an agent +// from reopening localization after an explicit localization-only request. +type localizationTerminalState struct { + mu sync.Mutex + state string + exactSymbol string + taskFingerprint string + generation uint64 + nextReservation uint64 + reservation *localizationReservation +} + +type localizationReservation struct { + token uint64 + generation uint64 + pendingCompletion localizationCompletion + pendingTaskFingerprint string + staged bool +} + +func newLocalizationTerminalState() *localizationTerminalState { + return &localizationTerminalState{} +} + +func (s *localizationTerminalState) reset() { + if s == nil { + return + } + s.mu.Lock() + s.generation++ + s.state = localizationStateInactive + s.exactSymbol = "" + s.taskFingerprint = "" + // Keep an in-flight reservation until its owner finishes. Its captured + // generation is now stale, so finishLocalize cannot commit it, while a + // second localization cannot race ahead of the still-running handler. + s.mu.Unlock() +} + +func (s *localizationTerminalState) arm(completion localizationCompletion) { + s.armForTask(completion, "") +} + +func (s *localizationTerminalState) armForTask(completion localizationCompletion, task string) { + if s == nil { + return + } + s.mu.Lock() + defer s.mu.Unlock() + fingerprint := localizationTaskFingerprint(task) + if s.reservation != nil { + s.reservation.pendingCompletion = completion + s.reservation.pendingTaskFingerprint = fingerprint + s.reservation.staged = true + return + } + s.commitLocalizationLocked(completion, fingerprint) +} + +func (s *localizationTerminalState) commitLocalizationLocked(completion localizationCompletion, fingerprint string) { + s.generation++ + s.state = completion.State + s.exactSymbol = completion.ExactSymbol + s.taskFingerprint = fingerprint +} + +// beginLocalize reserves the only localization handler slot for this session. +// An inactive session admits its first localization without a boundary flag. +// Once a contract exists, only the first localize call for a genuinely new user +// request may cross it, and the caller must say so explicitly. The old contract +// remains live until finishLocalize commits the successful replacement. +func (s *localizationTerminalState) beginLocalize(task string, newUserTask bool) (uint64, *mcpgo.CallToolResult) { + if s == nil { + return 0, nil + } + fingerprint := localizationTaskFingerprint(task) + if fingerprint == "" { + return 0, nil + } + s.mu.Lock() + defer s.mu.Unlock() + if s.reservation != nil { + return 0, localizationInProgressResult() + } + if s.state != localizationStateInactive && !newUserTask { + completion := newLocalizationCompletion(s.state == localizationStateAnswerReady, s.exactSymbol) + return 0, NewStructuredErrorResult(StructuredError{ + ErrorCode: ErrCodeLocalizationComplete, + Message: "this user request already has a localization completion contract; follow it instead of starting another localize call", + Data: map[string]any{ + "completion": completion, + "facade": "explore", + "operation": "localize", + }, + }) + } + s.nextReservation++ + if s.nextReservation == 0 { + s.nextReservation++ + } + token := s.nextReservation + s.reservation = &localizationReservation{token: token, generation: s.generation} + return token, nil +} + +// finishLocalize commits only the completion staged by the matching reservation +// and only if no reset changed its generation. Errors and panics pass success=false +// and leave the prior contract untouched. A stale finisher can never clear or +// overwrite a newer reservation. +func (s *localizationTerminalState) finishLocalize(token uint64, success bool) bool { + if s == nil || token == 0 { + return false + } + s.mu.Lock() + defer s.mu.Unlock() + reservation := s.reservation + if reservation == nil || reservation.token != token { + return false + } + s.reservation = nil + if !success || !reservation.staged || reservation.generation != s.generation { + return false + } + s.commitLocalizationLocked(reservation.pendingCompletion, reservation.pendingTaskFingerprint) + return true +} + +func localizationInProgressResult() *mcpgo.CallToolResult { + return NewStructuredErrorResult(StructuredError{ + ErrorCode: ErrCodeLocalizationComplete, + Message: "a localization request is already in progress for this session", + Data: map[string]any{ + "completion": map[string]any{ + "state": "localization_in_progress", "scope": "localization", + "required_action": "wait", "allowed_tool_calls": 0, + }, + "facade": "explore", "operation": "localize", + }, + }) +} + +func localizationTaskFingerprint(task string) string { + return strings.Join(strings.Fields(task), " ") +} + +// authorize checks a navigation call and reserves the one exact-read slot when +// applicable. The caller must finish the reservation after invocation so a +// failed read restores the allowance instead of silently consuming it. +func (s *localizationTerminalState) authorize(facade, operation string, arguments map[string]any) (*mcpgo.CallToolResult, bool) { + if s == nil || !localizationNavigationFacade(facade) { + return nil, false + } + + s.mu.Lock() + defer s.mu.Unlock() + if s.reservation != nil { + return localizationInProgressResult(), false + } + if s.state == localizationStateInactive { + return nil, false + } + if s.state == localizationStateNeedsExactRead && facade == "read" && operation == "source" && exactLocalizationSymbol(arguments) == s.exactSymbol { + s.state = localizationStateExactReadInFlight + return nil, true + } + + answerReady := s.state == localizationStateAnswerReady + completion := newLocalizationCompletion(answerReady, s.exactSymbol) + message := "localization is complete; return the existing evidence without another Gortex navigation call" + switch s.state { + case localizationStateNeedsExactRead: + message = fmt.Sprintf("localization needs exactly one read(operation:\"source\") for %q; other navigation calls are blocked", s.exactSymbol) + case localizationStateExactReadInFlight: + message = "the permitted exact localization read is already in progress" + } + return NewStructuredErrorResult(StructuredError{ + ErrorCode: ErrCodeLocalizationComplete, + Message: message, + Data: map[string]any{ + "completion": completion, + "facade": facade, + "operation": operation, + }, + }), false +} + +func (s *localizationTerminalState) finishExactRead(success bool) { + if s == nil { + return + } + s.mu.Lock() + defer s.mu.Unlock() + if s.state != localizationStateExactReadInFlight { + return + } + if success { + s.state = localizationStateAnswerReady + s.exactSymbol = "" + return + } + s.state = localizationStateNeedsExactRead +} + +// block is retained for direct state checks; production dispatch uses +// authorize so it can finish a reserved exact read after handler completion. +func (s *localizationTerminalState) block(facade, operation string, arguments map[string]any) *mcpgo.CallToolResult { + blocked, _ := s.authorize(facade, operation, arguments) + return blocked +} + +func localizationNavigationFacade(facade string) bool { + switch facade { + case "explore", "search", "read", "relations", "trace": + return true + default: + return false + } +} + +func exactLocalizationSymbol(arguments map[string]any) string { + if target, ok := arguments["target"].(map[string]any); ok { + return strings.TrimSpace(fmt.Sprint(target["symbol"])) + } + return strings.TrimSpace(fmt.Sprint(arguments["symbol"])) +} + +func (s *Server) localizationFor(ctx context.Context) *localizationTerminalState { + id := SessionIDFromContext(ctx) + if id == "" || s.sessions == nil { + return s.localization + } + return s.sessions.get(id).localization +} diff --git a/internal/mcp/localization_terminal_test.go b/internal/mcp/localization_terminal_test.go new file mode 100644 index 000000000..169db1ab0 --- /dev/null +++ b/internal/mcp/localization_terminal_test.go @@ -0,0 +1,591 @@ +package mcp + +import ( + "context" + "encoding/json" + "strings" + "testing" + + mcpgo "github.com/mark3labs/mcp-go/mcp" + + "github.com/zzet/gortex/internal/graph" +) + +func TestHandleFacadeRejectsLocalizationBypassesWithoutClearingState(t *testing.T) { + registry := newFacadeRegistry() + calls := 0 + registry.capture(mcpgo.NewTool("explore"), func(context.Context, mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + calls++ + return mcpgo.NewToolResultText("unexpected"), nil + }) + server := &Server{facades: registry, localization: &localizationTerminalState{}} + ctx := WithSessionID(context.Background(), "localize-validation") + terminal := server.localizationFor(ctx) + terminal.armForTask(newLocalizationCompletion(true, ""), "Locate Foo") + + requests := []struct { + name string + args map[string]any + }{ + {name: "task top-level override", args: map[string]any{"operation": "task", "task": "Locate Bar", "localize": true}}, + {name: "task nested override", args: map[string]any{"operation": "task", "task": "Locate Bar", "options": map[string]any{"localize": true}}}, + {name: "empty localize", args: map[string]any{"operation": "localize", "task": ""}}, + {name: "nested localize task", args: map[string]any{"operation": "localize", "options": map[string]any{"task": "Locate Bar"}}}, + {name: "malformed different localize", args: map[string]any{"operation": "localize", "task": "Locate Bar", "options": "bad"}}, + } + for _, request := range requests { + t.Run(request.name, func(t *testing.T) { + req := mcpgo.CallToolRequest{} + req.Params.Name = "explore" + req.Params.Arguments = request.args + result, err := server.handleFacade(ctx, "explore", req) + if err != nil || result == nil || !result.IsError { + t.Fatalf("handleFacade() = (%v, %v), want invalid argument result", result, err) + } + if blocked := terminal.block("search", "symbols", nil); blocked == nil { + t.Fatal("invalid localization request cleared terminal state") + } + }) + } + if calls != 0 { + t.Fatalf("legacy explore calls = %d, want 0", calls) + } +} + +func TestCompleteEmptyLocalizationReplacesPriorContract(t *testing.T) { + terminal := &localizationTerminalState{} + server := &Server{localization: terminal} + terminal.armForTask(newLocalizationCompletion(true, ""), "Locate A") + + result := server.completeEmptyLocalization(context.Background(), "Locate B", exploreDefaultBudgetTokens) + if result == nil || result.IsError || len(result.Content) != 1 { + t.Fatalf("empty localization result = %v, want one successful compact envelope", result) + } + content, ok := result.Content[0].(mcpgo.TextContent) + if !ok { + t.Fatalf("empty localization content type = %T, want TextContent", result.Content[0]) + } + var envelope map[string]any + if err := json.Unmarshal([]byte(content.Text), &envelope); err != nil { + t.Fatalf("decode empty localization envelope: %v", err) + } + for _, field := range []string{"files", "symbols", "evidence"} { + items, ok := envelope[field].([]any) + if !ok || len(items) != 0 { + t.Fatalf("%s = %#v, want empty array", field, envelope[field]) + } + } + if _, blocked := terminal.beginLocalize("Locate B", false); blocked == nil { + t.Fatal("empty localization did not arm the new task contract") + } + if _, blocked := terminal.beginLocalize("Locate A", false); blocked == nil { + t.Fatal("task text bypassed the active localization contract") + } +} + +func TestLocalizationFacadeIsExplicit(t *testing.T) { + registry := newFacadeRegistry() + localize, ok := registry.operation("explore", "localize") + if !ok { + t.Fatal("explore(localize) is not registered") + } + if localize.Legacy != "explore" || localize.Fixed["localize"] != true { + t.Fatalf("unexpected localize mapping: %#v", localize) + } + task, ok := registry.operation("explore", "task") + if !ok { + t.Fatal("explore(task) is not registered") + } + if _, terminal := task.Fixed["localize"]; terminal { + t.Fatalf("ordinary explore(task) must remain non-terminal: %#v", task.Fixed) + } +} + +func TestLocalizationCompletionEnvelope(t *testing.T) { + completion := newLocalizationCompletion(true, "") + result := newLocalizationExploreResult(completion, []exploreTarget{{node: &graph.Node{ + ID: "repo/pkg/file.go::Run", Name: "Run", Kind: graph.KindFunction, + FilePath: "pkg/file.go", StartLine: 12, + QualName: "resolver.Run", + Meta: map[string]any{ + "signature": "func Run()", "search_qual_name": "pkg.Run", + "search_signature": "func pkg.Run()", + }, + }, source: "func Run() {}"}}, 1600) + text, ok := singleTextContent(result) + if !ok { + t.Fatalf("expected one text result: %#v", result) + } + var envelope localizationExploreEnvelope + if err := json.Unmarshal([]byte(text), &envelope); err != nil { + t.Fatalf("decode completion envelope: %v\n%s", err, text) + } + if envelope.Completion.State != localizationStateAnswerReady || envelope.Completion.RequiredAction != "respond" || envelope.Completion.AllowedToolCalls != 0 { + t.Fatalf("unexpected completion: %#v", envelope.Completion) + } + if len(envelope.Files) != 1 || len(envelope.Symbols) != 1 || envelope.Symbols[0] != "repo/pkg/file.go::Run" || len(envelope.Evidence) != 1 { + t.Fatalf("missing localization payload: %#v", envelope) + } + if envelope.Evidence[0].QualName != "pkg.Run" || envelope.Evidence[0].Signature != "func pkg.Run()" { + t.Fatalf("normalized retrieval metadata not used: %#v", envelope.Evidence[0]) + } + if strings.Contains(text, "RANKED LOCALIZATION") || strings.Contains(text, "## Likely targets") || len(text) > 2000 { + t.Fatalf("localize envelope duplicated the legacy rendering or exceeded its compact budget (%d bytes): %s", len(text), text) + } +} + +func TestLocalizationTerminalStateBlocksOnlyNavigation(t *testing.T) { + state := newLocalizationTerminalState() + state.arm(newLocalizationCompletion(true, "")) + for _, facade := range []string{"explore", "search", "read", "relations", "trace"} { + blocked := state.block(facade, "anything", nil) + if blocked == nil || !blocked.IsError { + t.Fatalf("%s should be terminally blocked", facade) + } + text, _ := singleTextContent(blocked) + if !strings.Contains(text, string(ErrCodeLocalizationComplete)) { + t.Fatalf("%s returned the wrong error: %s", facade, text) + } + } + for _, facade := range []string{"change", "edit", "refactor", "analyze", "workspace", "recall", "remember"} { + if blocked := state.block(facade, "anything", nil); blocked != nil { + t.Fatalf("%s must remain available after localization: %#v", facade, blocked) + } + } +} + +func TestLocalizationNeedsExactlyOneRead(t *testing.T) { + state := newLocalizationTerminalState() + state.arm(newLocalizationCompletion(false, "repo/pkg/file.go::Run")) + wrong := map[string]any{"target": map[string]any{"symbol": "repo/pkg/file.go::Other"}} + if state.block("read", "source", wrong) == nil { + t.Fatal("a different symbol must not consume the exact-read allowance") + } + exact := map[string]any{"target": map[string]any{"symbol": "repo/pkg/file.go::Run"}} + if blocked := state.block("read", "source", exact); blocked != nil { + t.Fatalf("the named exact read should be allowed: %#v", blocked) + } + if state.block("read", "source", exact) == nil { + t.Fatal("the exact-read allowance must be consumed once") + } +} + +func TestLocalizationTerminalStateIsPerSession(t *testing.T) { + server := &Server{ + localization: newLocalizationTerminalState(), + sessions: newSessionMap(), + } + ctxA := WithSessionID(context.Background(), "a") + ctxB := WithSessionID(context.Background(), "b") + server.localizationFor(ctxA).arm(newLocalizationCompletion(true, "")) + if server.localizationFor(ctxA).block("search", "symbols", nil) == nil { + t.Fatal("armed session should be blocked") + } + if blocked := server.localizationFor(ctxB).block("search", "symbols", nil); blocked != nil { + t.Fatalf("separate session inherited terminality: %#v", blocked) + } + if blocked := server.localizationFor(context.Background()).block("search", "symbols", nil); blocked != nil { + t.Fatalf("embedded default inherited daemon session state: %#v", blocked) + } +} + +func TestHandleFacadeTaskCannotEscapeTerminalState(t *testing.T) { + registry := newFacadeRegistry() + called := false + registry.capture(mcpgo.NewTool("explore"), func(context.Context, mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + called = true + return mcpgo.NewToolResultText("unexpected"), nil + }) + server := &Server{ + facades: registry, + localization: newLocalizationTerminalState(), + sessions: newSessionMap(), + } + ctx := WithSessionID(context.Background(), "diagnosis") + terminal := server.localizationFor(ctx) + terminal.armForTask(newLocalizationCompletion(true, ""), "locate the failing writer") + for _, task := range []string{ + "diagnose the failing writer", + "find where that writer failure originates", + } { + req := mcpgo.CallToolRequest{} + req.Params.Name = "explore" + req.Params.Arguments = map[string]any{"operation": "task", "task": task} + result, err := server.handleFacade(ctx, "explore", req) + if err != nil || result == nil || !result.IsError { + t.Fatalf("ordinary task escaped terminal state: result=%#v err=%v", result, err) + } + text, _ := singleTextContent(result) + if !strings.Contains(text, string(ErrCodeLocalizationComplete)) { + t.Fatalf("ordinary task returned wrong terminal error: %s", text) + } + } + if called { + t.Fatal("ordinary explore(task) dispatched after localization completed") + } + if blocked := terminal.block("search", "symbols", nil); blocked == nil { + t.Fatal("ordinary explore(task) cleared terminal state") + } +} + +func TestHandleFacadeTaskRunsWhenTerminalInactive(t *testing.T) { + registry := newFacadeRegistry() + called := false + registry.capture(mcpgo.NewTool("explore"), func(_ context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + called = true + if req.GetBool("localize", false) { + t.Fatal("explore(task) must not inherit the localize fixed argument") + } + return mcpgo.NewToolResultText("ordinary diagnostic neighborhood"), nil + }) + server := &Server{ + facades: registry, + localization: newLocalizationTerminalState(), + sessions: newSessionMap(), + } + ctx := WithSessionID(context.Background(), "inactive-diagnosis") + req := mcpgo.CallToolRequest{} + req.Params.Name = "explore" + req.Params.Arguments = map[string]any{"operation": "task", "task": "diagnose the failure"} + result, err := server.handleFacade(ctx, "explore", req) + if err != nil || result == nil || result.IsError || !called { + t.Fatalf("inactive ordinary task did not dispatch: result=%#v err=%v called=%v", result, err, called) + } + if blocked := server.localizationFor(ctx).block("search", "symbols", nil); blocked != nil { + t.Fatalf("ordinary task unexpectedly armed terminal state: %#v", blocked) + } +} + +func TestHandleFacadeExactReadCommitsOnlyOnSuccess(t *testing.T) { + registry := newFacadeRegistry() + calls := 0 + registry.capture(mcpgo.NewTool("get_symbol_source", mcpgo.WithString("id", mcpgo.Required())), func(context.Context, mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + calls++ + if calls == 1 { + return mcpgo.NewToolResultError("transient read failure"), nil + } + return mcpgo.NewToolResultText("func Run() {}"), nil + }) + server := &Server{facades: registry, localization: newLocalizationTerminalState(), sessions: newSessionMap()} + ctx := WithSessionID(context.Background(), "exact-read") + server.localizationFor(ctx).armForTask(newLocalizationCompletion(false, "repo/pkg/file.go::Run"), "locate Run") + req := mcpgo.CallToolRequest{} + req.Params.Name = "read" + req.Params.Arguments = map[string]any{ + "operation": "source", + "target": map[string]any{"symbol": "repo/pkg/file.go::Run"}, + } + first, err := server.handleFacade(ctx, "read", req) + if err != nil || first == nil || !first.IsError { + t.Fatalf("expected transient read failure: result=%#v err=%v", first, err) + } + second, err := server.handleFacade(ctx, "read", req) + if err != nil || second == nil || second.IsError { + t.Fatalf("retry should retain and consume the allowance on success: result=%#v err=%v", second, err) + } + third, err := server.handleFacade(ctx, "read", req) + if err != nil || third == nil || !third.IsError || calls != 2 { + t.Fatalf("successful exact read must make later navigation terminal: result=%#v err=%v calls=%d", third, err, calls) + } +} + +func TestHandleFacadeFailedDifferentLocalizePreservesTerminalState(t *testing.T) { + registry := newFacadeRegistry() + calls := 0 + registry.capture(mcpgo.NewTool("explore"), func(context.Context, mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + calls++ + return mcpgo.NewToolResultError("localization failed"), nil + }) + server := &Server{facades: registry, localization: &localizationTerminalState{}} + ctx := WithSessionID(context.Background(), "failed-different-localize") + terminal := server.localizationFor(ctx) + terminal.armForTask(newLocalizationCompletion(true, ""), "Locate Foo") + + req := mcpgo.CallToolRequest{} + req.Params.Name = "explore" + req.Params.Arguments = map[string]any{ + "operation": "localize", "task": "Locate Bar", + "options": map[string]any{"new_user_task": true}, + } + result, err := server.handleFacade(ctx, "explore", req) + if err != nil || result == nil || !result.IsError || calls != 1 { + t.Fatalf("failed boundary localize = (%v, %v), calls=%d, want one tool error", result, err, calls) + } + if blocked := terminal.block("search", "symbols", nil); blocked == nil { + t.Fatal("failed boundary localize cleared terminal state") + } + terminal.mu.Lock() + fingerprint := terminal.taskFingerprint + terminal.mu.Unlock() + if fingerprint != "Locate Foo" { + t.Fatalf("failed boundary replaced task fingerprint with %q", fingerprint) + } +} + +func TestHandleFacadeExplicitNewUserTaskCommitsOnSuccess(t *testing.T) { + registry := newFacadeRegistry() + var server *Server + calls := 0 + registry.capture(mcpgo.NewTool("explore", mcpgo.WithString("task", mcpgo.Required())), func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + calls++ + server.localizationFor(ctx).armForTask(newLocalizationCompletion(true, ""), req.GetString("task", "")) + return mcpgo.NewToolResultText("localized"), nil + }) + server = &Server{facades: registry, localization: newLocalizationTerminalState(), sessions: newSessionMap()} + ctx := WithSessionID(context.Background(), "new-user-boundary") + terminal := server.localizationFor(ctx) + terminal.armForTask(newLocalizationCompletion(true, ""), "Locate Foo") + + req := mcpgo.CallToolRequest{} + req.Params.Name = "explore" + req.Params.Arguments = map[string]any{ + "operation": "localize", "task": "Locate Bar", + "options": map[string]any{"new_user_task": true}, + } + result, err := server.handleFacade(ctx, "explore", req) + if err != nil || result == nil || result.IsError || calls != 1 { + t.Fatalf("explicit boundary localize = (%v, %v), calls=%d", result, err, calls) + } + terminal.mu.Lock() + fingerprint := terminal.taskFingerprint + terminal.mu.Unlock() + if fingerprint != "Locate Bar" { + t.Fatalf("successful boundary committed fingerprint %q", fingerprint) + } + + req.Params.Arguments = map[string]any{"operation": "localize", "task": "Locate Baz"} + blocked, err := server.handleFacade(ctx, "explore", req) + if err != nil || blocked == nil || !blocked.IsError || calls != 1 { + t.Fatalf("later localize without boundary escaped: result=%#v err=%v calls=%d", blocked, err, calls) + } +} + +func TestHandleFacadeNewUserTaskPanicRollsBack(t *testing.T) { + registry := newFacadeRegistry() + registry.capture(mcpgo.NewTool("explore"), func(context.Context, mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + panic("localization panic") + }) + server := &Server{facades: registry, localization: newLocalizationTerminalState(), sessions: newSessionMap()} + ctx := WithSessionID(context.Background(), "new-user-panic") + terminal := server.localizationFor(ctx) + terminal.armForTask(newLocalizationCompletion(true, ""), "Locate Foo") + req := mcpgo.CallToolRequest{} + req.Params.Name = "explore" + req.Params.Arguments = map[string]any{ + "operation": "localize", "task": "Locate Bar", + "options": map[string]any{"new_user_task": true}, + } + var recovered any + func() { + defer func() { recovered = recover() }() + _, _ = server.handleFacade(ctx, "explore", req) + }() + if recovered == nil { + t.Fatal("handleFacade did not propagate localization panic") + } + terminal.mu.Lock() + fingerprint := terminal.taskFingerprint + reservation := terminal.reservation + terminal.mu.Unlock() + if fingerprint != "Locate Foo" || reservation != nil { + t.Fatalf("panic changed prior contract: fingerprint=%q reservation=%#v", fingerprint, reservation) + } +} + +func TestHandleFacadeConcurrentLocalizeAdmitsOnlyOne(t *testing.T) { + registry := newFacadeRegistry() + started := make(chan struct{}) + release := make(chan struct{}) + var server *Server + registry.capture(mcpgo.NewTool("explore", mcpgo.WithString("task", mcpgo.Required())), func(ctx context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + close(started) + <-release + server.localizationFor(ctx).armForTask(newLocalizationCompletion(true, ""), req.GetString("task", "")) + return mcpgo.NewToolResultText("localized"), nil + }) + server = &Server{facades: registry, localization: newLocalizationTerminalState(), sessions: newSessionMap()} + ctx := WithSessionID(context.Background(), "concurrent-localize") + firstReq := mcpgo.CallToolRequest{} + firstReq.Params.Name = "explore" + firstReq.Params.Arguments = map[string]any{"operation": "localize", "task": "Locate First"} + type response struct { + result *mcpgo.CallToolResult + err error + } + firstDone := make(chan response, 1) + go func() { + result, err := server.handleFacade(ctx, "explore", firstReq) + firstDone <- response{result: result, err: err} + }() + <-started + + secondReq := mcpgo.CallToolRequest{} + secondReq.Params.Name = "explore" + secondReq.Params.Arguments = map[string]any{ + "operation": "localize", "task": "Locate Second", + "options": map[string]any{"new_user_task": true}, + } + second, err := server.handleFacade(ctx, "explore", secondReq) + if err != nil || second == nil || !second.IsError { + t.Fatalf("concurrent localize was not blocked: result=%#v err=%v", second, err) + } + close(release) + first := <-firstDone + if first.err != nil || first.result == nil || first.result.IsError { + t.Fatalf("admitted localize failed: result=%#v err=%v", first.result, first.err) + } + terminal := server.localizationFor(ctx) + terminal.mu.Lock() + fingerprint := terminal.taskFingerprint + terminal.mu.Unlock() + if fingerprint != "Locate First" { + t.Fatalf("blocked concurrent localize won state: fingerprint=%q", fingerprint) + } +} + +func TestLocalizationStaleReservationCannotOverwriteNewerState(t *testing.T) { + terminal := newLocalizationTerminalState() + token, blocked := terminal.beginLocalize("Locate Stale", false) + if blocked != nil || token == 0 { + t.Fatalf("first reservation = (%d, %#v)", token, blocked) + } + terminal.armForTask(newLocalizationCompletion(true, ""), "Locate Stale") + terminal.reset() + if terminal.finishLocalize(token, true) { + t.Fatal("generation-stale reservation committed") + } + + newToken, blocked := terminal.beginLocalize("Locate Current", false) + if blocked != nil || newToken == 0 { + t.Fatalf("new reservation = (%d, %#v)", newToken, blocked) + } + terminal.armForTask(newLocalizationCompletion(true, ""), "Locate Current") + if !terminal.finishLocalize(newToken, true) { + t.Fatal("current reservation did not commit") + } + if terminal.finishLocalize(token, true) { + t.Fatal("stale finisher matched a newer reservation") + } + terminal.mu.Lock() + fingerprint := terminal.taskFingerprint + terminal.mu.Unlock() + if fingerprint != "Locate Current" { + t.Fatalf("stale finisher overwrote %q", fingerprint) + } +} + +func TestHandleFacadeExactReadPanicRestoresReservation(t *testing.T) { + registry := newFacadeRegistry() + calls := 0 + registry.capture(mcpgo.NewTool("get_symbol_source"), func(context.Context, mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + calls++ + if calls == 1 { + panic("legacy source panic") + } + return mcpgo.NewToolResultText("source"), nil + }) + server := &Server{facades: registry, localization: &localizationTerminalState{}} + ctx := WithSessionID(context.Background(), "exact-read-panic") + terminal := server.localizationFor(ctx) + const symbol = "repo/internal/file.go::Target" + terminal.armForTask(newLocalizationCompletion(false, symbol), "Locate Target") + + args := map[string]any{ + "operation": "source", + "target": map[string]any{"symbol": symbol}, + } + req := mcpgo.CallToolRequest{} + req.Params.Name = "read" + req.Params.Arguments = args + var recovered any + func() { + defer func() { recovered = recover() }() + _, _ = server.handleFacade(ctx, "read", req) + }() + if recovered == nil { + t.Fatal("handleFacade() did not propagate legacy panic") + } + result, err := server.handleFacade(ctx, "read", req) + if err != nil || result == nil || result.IsError { + t.Fatalf("exact read retry = (%v, %v), want success", result, err) + } + third, err := server.handleFacade(ctx, "read", req) + if err != nil || third == nil || !third.IsError { + t.Fatalf("third exact read = (%v, %v), want terminal block", third, err) + } + if calls != 2 { + t.Fatalf("legacy source calls = %d, want 2", calls) + } +} + +func TestHandleFacadeLocalizeBlocksParaphrasesWithoutBoundary(t *testing.T) { + registry := newFacadeRegistry() + calls := 0 + registry.capture(mcpgo.NewTool("explore", mcpgo.WithString("task", mcpgo.Required())), func(context.Context, mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + calls++ + return mcpgo.NewToolResultText("unexpected"), nil + }) + server := &Server{facades: registry, localization: newLocalizationTerminalState(), sessions: newSessionMap()} + ctx := WithSessionID(context.Background(), "repeat-localize") + terminal := server.localizationFor(ctx) + terminal.armForTask(newLocalizationCompletion(true, ""), "Locate Run Handler") + + for _, task := range []string{ + " Locate Run Handler ", + "Locate run Handler", + "find where the run handler fails", + } { + req := mcpgo.CallToolRequest{} + req.Params.Name = "explore" + req.Params.Arguments = map[string]any{"operation": "localize", "task": task} + result, err := server.handleFacade(ctx, "explore", req) + if err != nil || result == nil || !result.IsError { + t.Fatalf("localize(%q) bypassed active contract: result=%#v err=%v", task, result, err) + } + } + if calls != 0 { + t.Fatalf("blocked localize calls dispatched %d legacy request(s)", calls) + } + terminal.mu.Lock() + fingerprint := terminal.taskFingerprint + terminal.mu.Unlock() + if fingerprint != "Locate Run Handler" { + t.Fatalf("blocked localize changed task fingerprint to %q", fingerprint) + } +} + +func TestExploreAnswerReadyUsesNormalizedRetrievalMetadata(t *testing.T) { + node := &graph.Node{ + ID: "pkg/worker.go::run", Name: "run", Kind: graph.KindMethod, + FilePath: "pkg/worker.go", QualName: "resolver.run", + Meta: map[string]any{"search_qual_name": "BillingService.Reconcile", "search_signature": "func BillingService.Reconcile(invoice Invoice) error"}, + } + task := "locate BillingService.Reconcile" + if !exploreAnswerReady(task, []exploreTarget{{node: node, score: 1}}) { + t.Fatal("normalized retrieval metadata should make the explicit localization answer-ready") + } + delete(node.Meta, "search_qual_name") + delete(node.Meta, "search_signature") + if exploreAnswerReady(task, []exploreTarget{{node: node, score: 1}}) { + t.Fatal("resolver-only metadata must not accidentally satisfy the retrieval-specific query") + } +} + +func TestLocalizationEnvelopeOmitsOversizedSource(t *testing.T) { + node := &graph.Node{ID: "pkg/huge.go::Huge", Name: "Huge", Kind: graph.KindFunction, FilePath: "pkg/huge.go"} + result := newLocalizationExploreResult(newLocalizationCompletion(true, ""), []exploreTarget{{node: node, source: strings.Repeat("x", 32_000)}}, 1000) + text, ok := singleTextContent(result) + if !ok { + t.Fatalf("expected compact text result: %#v", result) + } + var envelope localizationExploreEnvelope + if err := json.Unmarshal([]byte(text), &envelope); err != nil { + t.Fatalf("decode compact envelope: %v", err) + } + if len(envelope.Evidence) != 1 || envelope.Evidence[0].Source != "" { + t.Fatalf("oversized source leaked into compact envelope: %#v", envelope.Evidence) + } + if len(text) > 1500 { + t.Fatalf("compact envelope exceeded size guard: %d bytes", len(text)) + } +} diff --git a/internal/mcp/memory_reclaim.go b/internal/mcp/memory_reclaim.go new file mode 100644 index 000000000..a5f2f84fc --- /dev/null +++ b/internal/mcp/memory_reclaim.go @@ -0,0 +1,201 @@ +package mcp + +import ( + "os" + "runtime" + "runtime/debug" + "strconv" + "strings" + "sync/atomic" + "time" + + "go.uber.org/zap" + + "github.com/zzet/gortex/internal/runtimeactivity" +) + +const ( + defaultIdleReleaseMinBytes = 128 << 20 + defaultIdleReleaseDelay = 100 * time.Millisecond + defaultIdleReleaseQuiet = 3 * time.Second + minimumIdleReleaseQuiet = 2 * time.Second + maximumIdleReleaseQuiet = 5 * time.Second + defaultIdleReleaseCooldown = 15 * time.Second +) + +var lastMemoryReleaseAtNanos atomic.Int64 + +// MCP calls participate in the process-wide activity tracker. The same tracker +// also covers warmup, reconciliation, snapshots, analysis, and other background +// work, because debug.FreeOSMemory is process-wide and must not overlap any of +// them. +func beginMCPToolCall() { + runtimeactivity.Begin("mcp") +} + +func endMCPToolCall(logger *zap.Logger, tool string) { + runtimeactivity.End("mcp") + scheduleOSMemoryReleaseAfterBurst(logger, "mcp_tool:"+tool) +} + +func memoryReleaseEnabled() bool { + v := strings.TrimSpace(os.Getenv("GORTEX_DAEMON_MEMRELEASE")) + return v != "0" && !strings.EqualFold(v, "false") +} + +func idleReleaseMinBytes() uint64 { + return envMegabytes("GORTEX_DAEMON_MEMRELEASE_MIN_MB", defaultIdleReleaseMinBytes) +} + +func idleReleaseCooldown() time.Duration { + v := strings.TrimSpace(os.Getenv("GORTEX_DAEMON_MEMRELEASE_COOLDOWN")) + if v == "" { + return defaultIdleReleaseCooldown + } + d, err := time.ParseDuration(v) + if err != nil || d < 0 { + return defaultIdleReleaseCooldown + } + return d +} + +// idleReleaseQuiet is deliberately bounded. Shorter windows race normal tool +// alternation and turn reclamation into user-visible latency; much longer ones +// strand a burst's high-water in otherwise quiet daemon sessions. +func idleReleaseQuiet() time.Duration { + v := strings.TrimSpace(os.Getenv("GORTEX_DAEMON_MEMRELEASE_QUIET")) + if v == "" { + return defaultIdleReleaseQuiet + } + d, err := time.ParseDuration(v) + if err != nil { + return defaultIdleReleaseQuiet + } + if d < minimumIdleReleaseQuiet { + return minimumIdleReleaseQuiet + } + if d > maximumIdleReleaseQuiet { + return maximumIdleReleaseQuiet + } + return d +} + +func envMegabytes(name string, fallback uint64) uint64 { + v := strings.TrimSpace(os.Getenv(name)) + if v == "" { + return fallback + } + mb, err := strconv.ParseUint(v, 10, 64) + if err != nil { + return fallback + } + const mib = uint64(1 << 20) + if mb > ^uint64(0)/mib { + return fallback + } + return mb * mib +} + +func heapIdleUnreleased(m *runtime.MemStats) uint64 { + if m == nil || m.HeapIdle <= m.HeapReleased { + return 0 + } + return m.HeapIdle - m.HeapReleased +} + +// releaseIdleMCPHeap is the zero-quiet test/benchmark entry point. Production +// scheduling uses releaseIdleHeapAfterQuiet with the bounded quiet window. +func releaseIdleMCPHeap(logger *zap.Logger, reason string) (done bool, retryAfter time.Duration) { + return releaseIdleHeapAfterQuiet(logger, reason, 0) +} + +// releaseIdleHeapAfterQuiet performs one adaptive release attempt. The +// process-wide tracker closes the check-to-release race: once admitted, no MCP +// request or tracked background job can begin until the scavenge completes. +func releaseIdleHeapAfterQuiet(logger *zap.Logger, reason string, quiet time.Duration) (done bool, retryAfter time.Duration) { + var ( + attemptDone bool + attemptRetry time.Duration + ) + ran, trackerRetry := runtimeactivity.RunIfQuiet(quiet, func() { + var before runtime.MemStats + runtime.ReadMemStats(&before) + candidate := heapIdleUnreleased(&before) + if candidate < idleReleaseMinBytes() { + attemptDone = true + return + } + + if cooldown := idleReleaseCooldown(); cooldown > 0 { + last := time.Unix(0, lastMemoryReleaseAtNanos.Load()) + if remaining := cooldown - time.Since(last); remaining > 0 { + attemptRetry = remaining + return + } + } + + start := time.Now() + debug.FreeOSMemory() + lastMemoryReleaseAtNanos.Store(time.Now().UnixNano()) + + var after runtime.MemStats + runtime.ReadMemStats(&after) + if logger != nil { + logger.Debug("daemon: released idle heap to OS", + zap.String("reason", reason), + zap.Uint64("heap_alloc_bytes", after.HeapAlloc), + zap.Uint64("heap_inuse_bytes", after.HeapInuse), + zap.Uint64("heap_idle_bytes", after.HeapIdle), + zap.Uint64("heap_idle_unreleased_before_bytes", candidate), + zap.Uint64("heap_idle_unreleased_after_bytes", heapIdleUnreleased(&after)), + zap.Uint64("heap_released_before_bytes", before.HeapReleased), + zap.Uint64("heap_released_after_bytes", after.HeapReleased), + zap.Uint64("heap_sys_bytes", after.HeapSys), + zap.Uint64("stack_inuse_bytes", after.StackInuse), + zap.Duration("elapsed", time.Since(start))) + } + attemptDone = true + }) + if !ran { + if trackerRetry <= 0 { + trackerRetry = defaultIdleReleaseDelay + } + return false, trackerRetry + } + if attemptRetry > 0 { + return false, attemptRetry + } + return attemptDone, 0 +} + +// runScheduledMCPMemoryRelease debounces all tracked process activity into a +// real quiet window, then releases only material idle, unreleased heap. Epoch +// checks make the scheduler lost-wakeup safe when work starts or ends while it +// is deciding whether to disarm. +func runScheduledMCPMemoryRelease(logger *zap.Logger, reason string) { + seen := runtimeactivity.Current().Epoch + for { + time.Sleep(defaultIdleReleaseDelay) + snapshot := runtimeactivity.Current() + if snapshot.Active != 0 || snapshot.Epoch != seen { + seen = snapshot.Epoch + continue + } + + done, retryAfter := releaseIdleHeapAfterQuiet(logger, reason, idleReleaseQuiet()) + if !done { + if retryAfter > defaultIdleReleaseDelay { + time.Sleep(retryAfter - defaultIdleReleaseDelay) + } + seen = runtimeactivity.Current().Epoch + continue + } + + osMemoryReleaseScheduled.Store(false) + current := runtimeactivity.Current() + if current.Active == 0 && current.Epoch != snapshot.Epoch { + scheduleOSMemoryReleaseAfterBurst(logger, reason) + } + return + } +} diff --git a/internal/mcp/memory_reclaim_test.go b/internal/mcp/memory_reclaim_test.go new file mode 100644 index 000000000..1e23b67d0 --- /dev/null +++ b/internal/mcp/memory_reclaim_test.go @@ -0,0 +1,169 @@ +package mcp + +import ( + "context" + "runtime" + "testing" + "time" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/zzet/gortex/internal/runtimeactivity" +) + +func TestHeapIdleUnreleased(t *testing.T) { + for _, tc := range []struct { + name string + idle uint64 + rel uint64 + want uint64 + }{ + {name: "difference", idle: 9, rel: 4, want: 5}, + {name: "fully released", idle: 9, rel: 9, want: 0}, + {name: "defensive underflow", idle: 4, rel: 9, want: 0}, + } { + t.Run(tc.name, func(t *testing.T) { + got := heapIdleUnreleased(&runtime.MemStats{HeapIdle: tc.idle, HeapReleased: tc.rel}) + if got != tc.want { + t.Fatalf("heapIdleUnreleased() = %d, want %d", got, tc.want) + } + }) + } + if got := heapIdleUnreleased(nil); got != 0 { + t.Fatalf("heapIdleUnreleased(nil) = %d, want 0", got) + } +} + +func TestReleaseIdleMCPHeapSkipsBelowThreshold(t *testing.T) { + waitForMemoryReleaseSchedulerIdle(t) + t.Setenv("GORTEX_DAEMON_MEMRELEASE_MIN_MB", "1048576") // 1 TiB + t.Setenv("GORTEX_DAEMON_MEMRELEASE_COOLDOWN", "0") + + done, retryAfter := releaseIdleMCPHeap(nil, "below-threshold") + if !done || retryAfter != 0 { + t.Fatalf("releaseIdleMCPHeap() = (%v, %v), want (true, 0)", done, retryAfter) + } +} + +func TestReleaseIdleMCPHeapDefersWhileToolActive(t *testing.T) { + waitForMemoryReleaseSchedulerIdle(t) + t.Setenv("GORTEX_DAEMON_MEMRELEASE", "false") + baseline := runtimeactivity.Current().Active + + beginMCPToolCall() + done, retryAfter := releaseIdleMCPHeap(nil, "active") + if done || retryAfter <= 0 { + t.Fatalf("releaseIdleMCPHeap() = (%v, %v), want deferred retry", done, retryAfter) + } + if got := runtimeactivity.Current().Active; got != baseline+1 { + t.Fatalf("active work = %d, want %d", got, baseline+1) + } + endMCPToolCall(nil, "active") + if got := runtimeactivity.Current().Active; got != baseline { + t.Fatalf("active work after end = %d, want %d", got, baseline) + } +} + +func TestReleaseIdleHeapDefersForBackgroundActivity(t *testing.T) { + waitForMemoryReleaseSchedulerIdle(t) + t.Setenv("GORTEX_DAEMON_MEMRELEASE", "false") + + runtimeactivity.Begin("warmup") + defer runtimeactivity.End("warmup") + done, retryAfter := releaseIdleMCPHeap(nil, "background-active") + if done || retryAfter <= 0 { + t.Fatalf("release during background work = (%v, %v), want deferred retry", done, retryAfter) + } +} + +func TestWrappedToolHandlerBalancesActiveCalls(t *testing.T) { + waitForMemoryReleaseSchedulerIdle(t) + t.Setenv("GORTEX_DAEMON_MEMRELEASE", "false") + baseline := runtimeactivity.Current().Active + + started := make(chan struct{}) + finish := make(chan struct{}) + s := &Server{} + wrapped := s.wrapControlToolHandler(func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { + close(started) + <-finish + return mcp.NewToolResultText("ok"), nil + }) + var req mcp.CallToolRequest + req.Params.Name = "memory_reclaim_test" + + done := make(chan struct{}) + go func() { + defer close(done) + _, _ = wrapped(context.Background(), req) + }() + select { + case <-started: + case <-time.After(5 * time.Second): + t.Fatal("wrapped handler did not start") + } + if got := runtimeactivity.Current().Active; got != baseline+1 { + t.Fatalf("active work while handler runs = %d, want %d", got, baseline+1) + } + close(finish) + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("wrapped handler did not finish") + } + if got := runtimeactivity.Current().Active; got != baseline { + t.Fatalf("active work after handler = %d, want %d", got, baseline) + } +} + +func TestIdleReleaseQuietIsBounded(t *testing.T) { + for _, tc := range []struct { + name string + value string + want time.Duration + }{ + {name: "default", value: "", want: defaultIdleReleaseQuiet}, + {name: "minimum", value: "1ms", want: minimumIdleReleaseQuiet}, + {name: "maximum", value: "1h", want: maximumIdleReleaseQuiet}, + {name: "explicit", value: "4s", want: 4 * time.Second}, + {name: "invalid", value: "later", want: defaultIdleReleaseQuiet}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("GORTEX_DAEMON_MEMRELEASE_QUIET", tc.value) + if got := idleReleaseQuiet(); got != tc.want { + t.Fatalf("idleReleaseQuiet() = %v, want %v", got, tc.want) + } + }) + } +} + +func BenchmarkAdaptiveIdleRelease(b *testing.B) { + b.Setenv("GORTEX_DAEMON_MEMRELEASE_MIN_MB", "0") + b.Setenv("GORTEX_DAEMON_MEMRELEASE_COOLDOWN", "0") + + const burstBytes = 256 << 20 + for range b.N { + burst := make([]byte, burstBytes) + for i := 0; i < len(burst); i += 4096 { + burst[i] = 1 + } + runtime.KeepAlive(burst) + runtime.GC() + + var before runtime.MemStats + runtime.ReadMemStats(&before) + done, retryAfter := releaseIdleMCPHeap(nil, "benchmark") + if !done || retryAfter != 0 { + b.Fatalf("releaseIdleMCPHeap() = (%v, %v)", done, retryAfter) + } + var after runtime.MemStats + runtime.ReadMemStats(&after) + + b.ReportMetric(float64(before.HeapAlloc)/(1<<20), "heap-live-before-MiB") + b.ReportMetric(float64(after.HeapAlloc)/(1<<20), "heap-live-after-MiB") + b.ReportMetric(float64(heapIdleUnreleased(&before))/(1<<20), "idle-unreleased-before-MiB") + b.ReportMetric(float64(heapIdleUnreleased(&after))/(1<<20), "idle-unreleased-after-MiB") + if after.HeapReleased >= before.HeapReleased { + b.ReportMetric(float64(after.HeapReleased-before.HeapReleased)/(1<<20), "released-delta-MiB") + } + } +} diff --git a/internal/mcp/memory_release_test.go b/internal/mcp/memory_release_test.go new file mode 100644 index 000000000..fbf0caf93 --- /dev/null +++ b/internal/mcp/memory_release_test.go @@ -0,0 +1,85 @@ +package mcp + +import ( + "sync" + "testing" + "time" + + "go.uber.org/zap" + "go.uber.org/zap/zaptest/observer" +) + +func waitForMemoryReleaseSchedulerIdle(t *testing.T) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if !osMemoryReleaseScheduled.Load() { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatal("memory release scheduler did not become idle") +} + +func waitForScheduledMemoryRelease(t *testing.T, logs *observer.ObservedLogs, wantLogs int) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if !osMemoryReleaseScheduled.Load() && logs.Len() >= wantLogs { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("memory release did not finish: scheduled=%v logs=%d, want logs >= %d", + osMemoryReleaseScheduled.Load(), logs.Len(), wantLogs) +} + +func TestScheduleOSMemoryReleaseAfterBurstDisabled(t *testing.T) { + waitForMemoryReleaseSchedulerIdle(t) + t.Setenv("GORTEX_DAEMON_MEMRELEASE", "false") + + scheduleOSMemoryReleaseAfterBurst(zap.NewNop(), "disabled") + if osMemoryReleaseScheduled.Load() { + t.Fatal("disabled memory release reserved the scheduler") + } +} + +func TestScheduleOSMemoryReleaseAfterBurstCoalescesAndRearms(t *testing.T) { + waitForMemoryReleaseSchedulerIdle(t) + t.Setenv("GORTEX_DAEMON_MEMRELEASE", "1") + t.Setenv("GORTEX_DAEMON_MEMRELEASE_MIN_MB", "0") + t.Setenv("GORTEX_DAEMON_MEMRELEASE_COOLDOWN", "0") + + core, observed := observer.New(zap.DebugLevel) + logger := zap.New(core) + + const callers = 32 + var wg sync.WaitGroup + wg.Add(callers) + for range callers { + go func() { + defer wg.Done() + scheduleOSMemoryReleaseAfterBurst(logger, "first-wave") + }() + } + wg.Wait() + if !osMemoryReleaseScheduled.Load() { + t.Fatal("enabled memory release was not scheduled") + } + waitForScheduledMemoryRelease(t, observed, 1) + if got := observed.Len(); got != 1 { + t.Fatalf("coalesced release logs = %d, want 1", got) + } + if got := observed.All()[0].ContextMap()["reason"]; got != "first-wave" { + t.Fatalf("first release reason = %v, want first-wave", got) + } + + scheduleOSMemoryReleaseAfterBurst(logger, "second-wave") + waitForScheduledMemoryRelease(t, observed, 2) + if got := observed.Len(); got != 2 { + t.Fatalf("rearmed release logs = %d, want 2", got) + } + if got := observed.All()[1].ContextMap()["reason"]; got != "second-wave" { + t.Fatalf("second release reason = %v, want second-wave", got) + } +} diff --git a/internal/mcp/momentum.go b/internal/mcp/momentum.go index 994075dbd..1d66f3127 100644 --- a/internal/mcp/momentum.go +++ b/internal/mcp/momentum.go @@ -28,7 +28,11 @@ const momentumEscalateStreak = 10 // momentum note. Edit and verify tools never count — the note is about // evidence-gathering loops, not about acting. var momentumReadTools = map[string]bool{ - "explore": true, "search_symbols": true, "search_text": true, + // Stable facade-v1 read/navigation dispatchers. These are counted by the + // outer middleware exactly once per facade call. + "explore": true, "search": true, "read": true, "relations": true, "trace": true, + // Legacy read/navigation tools. + "search_symbols": true, "search_text": true, "get_symbol_source": true, "batch_symbols": true, "get_file_summary": true, "get_editing_context": true, "read_file": true, "find_usages": true, "get_callers": true, "get_call_chain": true, "find_implementations": true, diff --git a/internal/mcp/momentum_test.go b/internal/mcp/momentum_test.go index aacb32b78..725a3b40b 100644 --- a/internal/mcp/momentum_test.go +++ b/internal/mcp/momentum_test.go @@ -39,6 +39,23 @@ func TestMomentumNoteFiresOnceAtThreshold(t *testing.T) { } } +func TestMomentumFacadeReadToolsCountOnce(t *testing.T) { + for _, tool := range []string{"search", "read", "relations", "trace"} { + s := &Server{session: &sessionState{}} + ctx := WithSessionID(context.Background(), "sess_facade_"+tool) + for i := 1; i < momentumReadThreshold; i++ { + res := s.maybeAttachMomentumNote(ctx, tool, mcp.NewToolResultText("ok")) + if strings.Contains(momentumTextOf(res), "Session note") { + t.Fatalf("%s facade note fired early at read %d", tool, i) + } + } + res := s.maybeAttachMomentumNote(ctx, tool, mcp.NewToolResultText("ok")) + if !strings.Contains(momentumTextOf(res), "Session note") { + t.Fatalf("%s facade note did not fire at threshold", tool) + } + } +} + func TestMomentumNoteIgnoresNonReadAndErrors(t *testing.T) { s := &Server{} ctx := WithSessionID(context.Background(), "sess_momentum2") diff --git a/internal/mcp/mutation_freshness_test.go b/internal/mcp/mutation_freshness_test.go new file mode 100644 index 000000000..e632fe555 --- /dev/null +++ b/internal/mcp/mutation_freshness_test.go @@ -0,0 +1,219 @@ +package mcp + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + mcpgo "github.com/mark3labs/mcp-go/mcp" + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/indexer" + "github.com/zzet/gortex/internal/query" +) + +func pendingFreshnessReceipt(s *Server, id, repo, path string, generation uint64) *mutationReceipt { + receipt := &mutationReceipt{ + id: id, + repo: repo, + path: path, + generation: generation, + done: make(chan struct{}), + } + s.mutationReceipts.Store(id, receipt) + return receipt +} + +func completeFreshnessReceipt(receipt *mutationReceipt, result indexer.MutationResult) { + receipt.mu.Lock() + receipt.result = result + receipt.completed = true + receipt.mu.Unlock() + close(receipt.done) +} + +func freshnessToolResultText(result *mcpgo.CallToolResult) string { + if result == nil { + return "" + } + var text strings.Builder + for _, content := range result.Content { + if item, ok := content.(mcpgo.TextContent); ok { + text.WriteString(item.Text) + } + } + return text.String() +} + +func TestMutationFreshnessRepoScopeAggregatesPending(t *testing.T) { + s := &Server{mutationSafetyWait: time.Millisecond} + pendingFreshnessReceipt(s, "receipt-a1", "repo-a", "/repo-a/a.go", 3) + pendingFreshnessReceipt(s, "receipt-a2", "repo-a", "/repo-a/b.go", 8) + pendingFreshnessReceipt(s, "receipt-b", "repo-b", "/repo-b/c.go", 5) + + err := s.awaitMutationFreshnessForRepos(context.Background(), "repo-a") + if err == nil { + t.Fatal("repo-scoped freshness unexpectedly succeeded") + } + message := err.Error() + for _, want := range []string{"receipt-a1", "generation=3", "receipt-a2", "generation=8"} { + if !strings.Contains(message, want) { + t.Fatalf("freshness error %q does not contain %q", message, want) + } + } + if strings.Contains(message, "receipt-b") { + t.Fatalf("repo-scoped freshness included unrelated receipt: %s", message) + } +} + +func TestMutationFreshnessScopeIncludesUnknownAndIgnoresUnrelated(t *testing.T) { + s := &Server{mutationSafetyWait: time.Millisecond} + pendingFreshnessReceipt(s, "receipt-b", "repo-b", "/repo-b/b.go", 2) + if err := s.awaitMutationFreshnessForRepos(context.Background(), "repo-a"); err != nil { + t.Fatalf("unrelated repository blocked repo-a: %v", err) + } + + pendingFreshnessReceipt(s, "receipt-unknown", "", "/unknown/u.go", 4) + err := s.awaitMutationFreshnessForRepos(context.Background(), "repo-a") + if err == nil || !strings.Contains(err.Error(), "receipt-unknown") { + t.Fatalf("unknown-owner receipt did not fail wide: %v", err) + } + if strings.Contains(err.Error(), "receipt-b") { + t.Fatalf("unknown-owner check also included unrelated known repo: %v", err) + } +} + +func TestMutationFreshnessTerminalFailureFailsClosed(t *testing.T) { + s := &Server{mutationSafetyWait: time.Millisecond} + failed := pendingFreshnessReceipt(s, "receipt-failed", "repo-a", "/repo-a/broken.go", 9) + completeFreshnessReceipt(failed, indexer.MutationResult{ + RequestedGeneration: 9, + AppliedGeneration: 9, + Err: errors.New("syntax patch failed"), + }) + + err := s.awaitMutationFreshnessForRepos(context.Background(), "repo-a") + if err == nil { + t.Fatal("terminal patch failure did not fail closed") + } + for _, want := range []string{"failed", "receipt-failed", "generation=9", "syntax patch failed"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("terminal failure %q does not contain %q", err, want) + } + } +} + +func TestMutationReposForSymbolIDsUnresolvedWidensBarrier(t *testing.T) { + g := graph.New() + g.AddNode(&graph.Node{ + ID: "repo-a::known", + Kind: graph.KindFunction, + Name: "known", + FilePath: "known.go", + StartLine: 1, + EndLine: 1, + Language: "go", + RepoPrefix: "repo-a", + }) + s := &Server{ + graph: g, + engine: query.NewEngine(g), + session: newSessionState(), + mutationSafetyWait: time.Millisecond, + } + pendingFreshnessReceipt(s, "receipt-other", "repo-b", "/repo-b/other.go", 6) + + if repos := s.mutationReposForSymbolIDs(context.Background(), []string{"missing"}); repos != nil { + t.Fatalf("unresolved symbol scope = %v, want nil fail-wide scope", repos) + } + err := s.awaitMutationFreshnessForRepos(context.Background(), s.mutationReposForSymbolIDs(context.Background(), []string{"missing"})...) + if err == nil || !strings.Contains(err.Error(), "receipt-other") { + t.Fatalf("unresolved symbol did not widen the barrier: %v", err) + } + + repos := s.mutationReposForSymbolIDs(context.Background(), []string{"repo-a::known"}) + if len(repos) != 1 || repos[0] != "repo-a" { + t.Fatalf("resolved symbol scope = %v", repos) + } + if err := s.awaitMutationFreshnessForRepos(context.Background(), repos...); err != nil { + t.Fatalf("resolved repo-a scope was blocked by repo-b: %v", err) + } +} + +func TestChangeImpactFreshnessGuardAndRetry(t *testing.T) { + g := graph.New() + g.AddNode(&graph.Node{ + ID: "repo-a::target", + Kind: graph.KindFunction, + Name: "target", + FilePath: "target.go", + StartLine: 1, + EndLine: 1, + Language: "go", + RepoPrefix: "repo-a", + }) + s := &Server{ + graph: g, + engine: query.NewEngine(g), + session: newSessionState(), + mutationSafetyWait: time.Millisecond, + } + receipt := pendingFreshnessReceipt(s, "receipt-impact", "repo-a", "/repo-a/target.go", 11) + req := mcpgo.CallToolRequest{Params: mcpgo.CallToolParams{ + Name: "explain_change_impact", + Arguments: map[string]any{"ids": "repo-a::target", "format": "json"}, + }} + + result, err := s.handleEnhancedChangeImpact(context.Background(), req) + if err != nil { + t.Fatalf("pending impact handler error: %v", err) + } + if result == nil || !result.IsError { + t.Fatalf("pending impact result = %#v, want MCP error", result) + } + for _, want := range []string{"change impact refused a stale graph", "receipt-impact", "generation=11"} { + if !strings.Contains(freshnessToolResultText(result), want) { + t.Fatalf("pending impact response %q does not contain %q", freshnessToolResultText(result), want) + } + } + + completeFreshnessReceipt(receipt, indexer.MutationResult{ + RequestedGeneration: 11, + AppliedGeneration: 11, + Reindexed: true, + }) + result, err = s.handleEnhancedChangeImpact(context.Background(), req) + if err != nil { + t.Fatalf("completed impact handler error: %v", err) + } + if result == nil || result.IsError { + t.Fatalf("completed impact remained blocked: %q", freshnessToolResultText(result)) + } +} + +func TestDetectChangesFreshnessGuard(t *testing.T) { + s := &Server{ + graph: graph.New(), + session: newSessionState(), + mutationSafetyWait: time.Millisecond, + } + pendingFreshnessReceipt(s, "receipt-detect", "repo-a", "/repo-a/changed.go", 12) + req := mcpgo.CallToolRequest{Params: mcpgo.CallToolParams{ + Name: "detect_changes", + Arguments: map[string]any{"format": "json"}, + }} + + result, err := s.handleDetectChanges(context.Background(), req) + if err != nil { + t.Fatalf("detect handler error: %v", err) + } + if result == nil || !result.IsError { + t.Fatalf("pending detect result = %#v, want MCP error", result) + } + for _, want := range []string{"change detection refused a stale graph", "receipt-detect", "generation=12"} { + if !strings.Contains(freshnessToolResultText(result), want) { + t.Fatalf("pending detect response %q does not contain %q", freshnessToolResultText(result), want) + } + } +} diff --git a/internal/mcp/overlay.go b/internal/mcp/overlay.go index a7745b5bd..e41cd2a2b 100644 --- a/internal/mcp/overlay.go +++ b/internal/mcp/overlay.go @@ -72,10 +72,28 @@ func (s *Server) OverlayManager() *daemon.OverlayManager { return s.overlays } // wired, this is a transparent pass-through (one map lookup, zero // parsing) — non-overlay traffic pays no cost. func (s *Server) wrapToolHandler(h mcpserver.ToolHandlerFunc) mcpserver.ToolHandlerFunc { + return s.wrapToolHandlerMode(h, true) +} + +// wrapControlToolHandler applies the normal safety, telemetry, logging, and +// response middleware without pre-building the caller's overlay view. Overlay +// lifecycle/simulation handlers compose their own views and must run through +// this path rather than bypassing gates with a bare MCP AddTool. +func (s *Server) wrapControlToolHandler(h mcpserver.ToolHandlerFunc) mcpserver.ToolHandlerFunc { + return s.wrapToolHandlerMode(h, false) +} + +func (s *Server) wrapToolHandlerMode(h mcpserver.ToolHandlerFunc, injectOverlay bool) mcpserver.ToolHandlerFunc { // Prompt-injection screening sits closest to the handler so it // sees the real arguments and the real result (see sanitize.go). h = s.sanitizeToolHandler(h) return func(ctx context.Context, req mcp.CallToolRequest) (res *mcp.CallToolResult, retErr error) { + beginMCPToolCall() + defer func() { + endMCPToolCall(s.logger, req.Params.Name) + s.releaseTransientAnalysisIfIdle() + }() + // Last-resort panic firewall around EVERY tool handler. A Go // panic in any handler (e.g. when the store surfaces a fatal // engine error) would otherwise unwind past the mcp-go server @@ -109,16 +127,18 @@ func (s *Server) wrapToolHandler(h mcpserver.ToolHandlerFunc) mcpserver.ToolHand // tool call (GORTEX_AUTOINDEX=1). Cheap getenv + sync.Once on the // request path; all real work runs on a background goroutine. s.maybeAutoIndexCWD() - view, err := s.buildOverlayViewForCtx(ctx) - if err != nil { - // Drift surfaces as a structured tool error result so the - // client knows to re-read and resubmit. Return (result, - // nil) so the JSON-RPC framing carries the message rather - // than a transport error. - return mcp.NewToolResultError(err.Error()), nil - } - if view != nil { - ctx = WithOverlayView(ctx, view) + if injectOverlay { + view, err := s.buildOverlayViewForCtx(ctx) + if err != nil { + // Drift surfaces as a structured tool error result so the + // client knows to re-read and resubmit. Return (result, + // nil) so the JSON-RPC framing carries the message rather + // than a transport error. + return mcp.NewToolResultError(err.Error()), nil + } + if view != nil { + ctx = WithOverlayView(ctx, view) + } } // Warmup fast path: when the daemon is still warming up and // this is a graph-querying tool, the handler still runs (so diff --git a/internal/mcp/prompts.go b/internal/mcp/prompts.go index 0ebe9ca1f..ae1d60ce3 100644 --- a/internal/mcp/prompts.go +++ b/internal/mcp/prompts.go @@ -99,7 +99,7 @@ func (s *Server) handlePromptPreCommit(ctx context.Context, req mcp.GetPromptReq for i, cs := range diff.ChangedSymbols { symbolIDs[i] = cs.ID } - impact := analysis.AnalyzeImpact(s.graph, symbolIDs, s.getCommunities(), s.getProcesses()) + impact := s.analyzeImpactLazy(ctx, symbolIDs) fmt.Fprintf(&b, "**Risk: %s**\n\n", impact.Risk) fmt.Fprintf(&b, "Changed: %d symbols in %d files\n\n", len(diff.ChangedSymbols), len(diff.ChangedFiles)) @@ -203,56 +203,30 @@ func (s *Server) handlePromptOrientation(ctx context.Context, _ mcp.GetPromptReq b.WriteString("\n") } - comms := s.getCommunities() - if comms != nil && len(comms.Communities) > 0 { - var shown []analysis.Community - for _, c := range comms.Communities { - if inScope == nil { - shown = append(shown, c) - continue - } - for _, m := range c.Members { - if inScope[m] { - shown = append(shown, c) - break - } - } + scopedIDs := make([]string, 0, len(scoped)) + for _, node := range scoped { + if node != nil { + scopedIDs = append(scopedIDs, node.ID) } - if len(shown) > 0 { - b.WriteString("### Functional Areas (Communities)\n") - for _, c := range shown { - fmt.Fprintf(&b, "- **%s** — %d symbols, %d files (cohesion: %.2f)\n", - c.Label, c.Size, len(c.Files), c.Cohesion) - } - b.WriteString("\n") + } + communities := s.communitySummariesForNodes(scopedIDs, bound, 10) + if len(communities) > 0 { + b.WriteString("### Functional Areas (Communities)\n") + for _, community := range communities { + fmt.Fprintf(&b, "- **%s** — %d symbols, %d files (cohesion: %.2f)\n", + community.Label, community.Size, len(community.Files), community.Cohesion) } + b.WriteString("\n") } - procs := s.getProcesses() - if procs != nil && len(procs.Processes) > 0 { - // A process is in scope when its entry point is — entry points - // are real symbol IDs and must not leak across the boundary. - var shown []analysis.Process - for _, p := range procs.Processes { - if inScope == nil || inScope[p.EntryPoint] { - shown = append(shown, p) - } - } - if len(shown) > 0 { - b.WriteString("### Execution Flows (Processes)\n") - limit := len(shown) - if limit > 10 { - limit = 10 - } - for _, p := range shown[:limit] { - fmt.Fprintf(&b, "- **%s** — %d steps, entry: `%s`\n", - p.Name, p.StepCount, p.EntryPoint) - } - if len(shown) > 10 { - fmt.Fprintf(&b, "- ... and %d more\n", len(shown)-10) - } - b.WriteString("\n") + processes := s.processSummariesForEntries(inScope, bound, 10) + if len(processes) > 0 { + b.WriteString("### Execution Flows (Processes)\n") + for _, process := range processes { + fmt.Fprintf(&b, "- **%s** — %d steps, entry: `%s`\n", + process.Name, process.StepCount, process.EntryPoint) } + b.WriteString("\n") } topRefs := s.findTopReferenced(ctx, 10) @@ -303,7 +277,7 @@ func (s *Server) handlePromptSafeToChange(ctx context.Context, req mcp.GetPrompt return promptError("No valid symbols found"), nil } - impact := analysis.AnalyzeImpact(s.graph, validIDs, s.getCommunities(), s.getProcesses()) + impact := s.analyzeImpactLazy(ctx, validIDs) fmt.Fprintf(&b, "**Risk: %s**\n\n", impact.Risk) fmt.Fprintf(&b, "Symbols to change: %s\n\n", strings.Join(validIDs, ", ")) diff --git a/internal/mcp/query_log.go b/internal/mcp/query_log.go index b36455f1c..17a1b94db 100644 --- a/internal/mcp/query_log.go +++ b/internal/mcp/query_log.go @@ -37,6 +37,10 @@ import ( type queryLogRecord struct { TS string `json:"ts"` Tool string `json:"tool"` + Surface string `json:"surface,omitempty"` + Facade string `json:"facade,omitempty"` + Operation string `json:"operation,omitempty"` + CanonicalTool string `json:"canonical_tool,omitempty"` Repo string `json:"repo,omitempty"` Project string `json:"project,omitempty"` Question string `json:"question"` @@ -83,6 +87,13 @@ var retrievalToolSpecs = map[string]queryToolSpec{ "graph_completion_search": {questionKeys: []string{"query", "prefix"}, corpus: "completion"}, } +var facadeRetrievalTools = map[string]bool{ + "explore": true, "search": true, "read": true, "relations": true, + "trace": true, "analyze": true, "ask": true, "change": true, + "review": true, "pr": true, "recall": true, "workspace": true, + "response": true, +} + // countResultKeys are the JSON array keys a retrieval response is most // likely to carry its result list under, tried before a generic scan. var countResultKeys = []string{ @@ -144,7 +155,7 @@ func (q *queryLogger) shouldLog(tool string) bool { return false } _, ok := retrievalToolSpecs[tool] - return ok + return ok || facadeRetrievalTools[tool] } // Path returns the resolved log file path ("" when disabled). @@ -164,11 +175,17 @@ func (q *queryLogger) record(s *Server, ctx context.Context, req mcp.CallToolReq } tool := req.Params.Name spec, ok := retrievalToolSpecs[tool] - if !ok { + if !ok && !facadeRetrievalTools[tool] { return } + if !ok { + spec = queryToolSpec{questionKeys: []string{"query", "task", "question", "symbol", "id", "name"}, corpus: "facade"} + } args := req.GetArguments() question := firstStringArg(args, spec.questionKeys) + if question == "" { + question = nestedFacadeQuestion(args) + } corpus := spec.corpus if spec.corpusKey != "" { if c := stringArg(args, spec.corpusKey); c != "" { @@ -200,6 +217,20 @@ func (q *queryLogger) record(s *Server, ctx context.Context, req mcp.CallToolReq if s != nil { rec.Repo, rec.Project = s.sessionLocality(ctx) rec.Session = SessionIDFromContext(ctx) + if p := s.effectiveSessionPolicy(ctx); p != nil && p.preset == FacadeSurfaceVersion && isFacadeToolName(tool) { + rec.Surface = FacadeSurfaceVersion + rec.Facade = tool + rec.Operation = normalizeFacadeOperation(stringArg(args, "operation")) + if tool == "analyze" { + rec.Operation = normalizeFacadeOperation(stringArg(args, "kind")) + } + if rec.Operation == "" { + rec.Operation = defaultFacadeOperation(tool) + } + if op, found := s.facades.operation(tool, rec.Operation); found { + rec.CanonicalTool = op.Legacy + } + } } if !okCall { if hErr != nil { @@ -219,6 +250,19 @@ func (q *queryLogger) record(s *Server, ctx context.Context, req mcp.CallToolReq q.append(line) } +func nestedFacadeQuestion(args map[string]any) string { + for _, field := range []string{"target", "source", "arguments", "options"} { + obj, ok := args[field].(map[string]any) + if !ok { + continue + } + if value := firstStringArg(obj, []string{"query", "task", "question", "symbol", "file", "id", "name"}); value != "" { + return value + } + } + return "" +} + // append writes one JSONL line, opening (and rotating) the file lazily. // All errors are swallowed — logging must never break a tool call. func (q *queryLogger) append(line []byte) { diff --git a/internal/mcp/query_log_test.go b/internal/mcp/query_log_test.go index a8bc28a77..688f35fc7 100644 --- a/internal/mcp/query_log_test.go +++ b/internal/mcp/query_log_test.go @@ -1,10 +1,15 @@ package mcp import ( + "context" + "encoding/json" "os" "path/filepath" "strings" "testing" + "time" + + "github.com/mark3labs/mcp-go/mcp" ) func TestCountFromResultText(t *testing.T) { @@ -138,7 +143,7 @@ func TestShouldLogToolSet(t *testing.T) { t.Setenv("GORTEX_QUERY_LOG", filepath.Join(t.TempDir(), "q.jsonl")) t.Setenv("GORTEX_QUERY_LOG_DISABLE", "") q := newQueryLogger() - for _, tool := range []string{"search_symbols", "smart_context", "find_usages", "search_text"} { + for _, tool := range []string{"search_symbols", "smart_context", "find_usages", "search_text", "search", "read", "relations"} { if !q.shouldLog(tool) { t.Fatalf("%s should be logged", tool) } @@ -149,3 +154,36 @@ func TestShouldLogToolSet(t *testing.T) { } } } + +func TestQueryLoggerRecordsFacadeMetadata(t *testing.T) { + path := filepath.Join(t.TempDir(), "facade.jsonl") + t.Setenv("GORTEX_QUERY_LOG", path) + t.Setenv("GORTEX_QUERY_LOG_DISABLE", "") + q := newQueryLogger() + srv := setupPresetServer(t, ToolPolicyConfig{Preset: "core", Mode: "defer"}) + srv.NoteSessionClient("facade_query", "codex", "1") + ctx := WithSessionID(context.Background(), "facade_query") + req := mcp.CallToolRequest{} + req.Params.Name = "search" + req.Params.Arguments = map[string]any{ + "operation": "symbols", + "query": "FacadeSurfaceVersion", + } + q.record(srv, ctx, req, mcp.NewToolResultText(`{"results":[]}`), nil, time.Now().Add(-time.Millisecond)) + q.Close() + + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var record queryLogRecord + if err := json.Unmarshal([]byte(strings.TrimSpace(string(raw))), &record); err != nil { + t.Fatal(err) + } + if record.Surface != FacadeSurfaceVersion || record.Facade != "search" || record.Operation != "symbols" { + t.Fatalf("facade metadata = surface:%q facade:%q operation:%q", record.Surface, record.Facade, record.Operation) + } + if record.CanonicalTool != "search_symbols" { + t.Fatalf("canonical_tool = %q, want search_symbols", record.CanonicalTool) + } +} diff --git a/internal/mcp/rerank_context.go b/internal/mcp/rerank_context.go index b622e5a8e..c2675c6e2 100644 --- a/internal/mcp/rerank_context.go +++ b/internal/mcp/rerank_context.go @@ -20,14 +20,11 @@ import ( func (s *Server) buildRerankContext(ctx context.Context, query string) *rerank.Context { repo, project := s.sessionLocality(ctx) rctx := &rerank.Context{ - Graph: s.graph, - RepoPrefix: repo, - ProjectID: project, - } - - if cr := s.getCommunities(); cr != nil && cr.NodeToComm != nil { - nodeToComm := cr.NodeToComm - rctx.CommunityOf = func(id string) string { return nodeToComm[id] } + Graph: s.graph, + RepoPrefix: repo, + ProjectID: project, + AnalysisMetricsOf: s.rerankAnalysisMetrics, + BatchedCentrality: s.rerankBoundedCentrality, } if s.combo != nil { @@ -77,38 +74,9 @@ func (s *Server) buildRerankContext(ctx context.Context, query string) *rerank.C } } - // HITS authority / hub scores feed the authority rerank signal. - // Both closures normalise against the graph maxima so the signal - // receives values already in [0, 1]. - if h := s.getHITS(); h != nil { - hits := h - maxAuth, maxHub := hits.MaxAuth, hits.MaxHub - rctx.AuthorityOf = func(id string) float64 { - if maxAuth <= 0 { - return 0 - } - return hits.AuthorityOf(id) / maxAuth - } - rctx.HubOf = func(id string) float64 { - if maxHub <= 0 { - return 0 - } - return hits.HubOf(id) / maxHub - } - } - - // Centrality: a per-query Random-Walk-with-Restart (Personalized - // PageRank) over the call/reference graph, seeded from the query's - // strongest candidate matches, feeds ProximitySignal — the graph- - // centrality spine of retrieval. The walk runs against the - // immutable adjacency snapshot built by RunAnalysis; nil until then - // (the signal then sits at 0). The actual walk fires once per - // Rerank inside Context.prepare, after the seeds are chosen. - if snap := s.getAdjacency(); snap != nil { - rctx.Centrality = func(seeds []string) map[string]float64 { - return s.personalizedPageRank(snap, seeds) - } - } + // Centrality is built from a capped candidate-seeded neighborhood when + // Prepare knows the complete result batch. This avoids restoring the + // whole-graph CSR for every interactive search. // Co-change feeds the rerank pipeline once the git-history mine // has run (lazily, on the first find_co_changing_symbols call, or diff --git a/internal/mcp/resources.go b/internal/mcp/resources.go index 2b51f5e2a..7cf2cf08f 100644 --- a/internal/mcp/resources.go +++ b/internal/mcp/resources.go @@ -293,8 +293,8 @@ func (s *Server) handleResourceGuideSection(_ context.Context, req mcp.ReadResou } func (s *Server) handleResourceCommunities(_ context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { - comms := s.getCommunities() - if comms == nil || len(comms.Communities) == 0 { + rows, next, err := s.analysisCommunitySummaries(100, "") + if err != nil || len(rows) == 0 { return []mcp.ResourceContents{ mcp.TextResourceContents{ URI: req.Params.URI, @@ -311,18 +311,26 @@ func (s *Server) handleResourceCommunities(_ context.Context, req mcp.ReadResour Files []string `json:"files"` Cohesion float64 `json:"cohesion"` } - var summaries []summary - for _, c := range comms.Communities { + summaries := make([]summary, 0, len(rows)) + for _, row := range rows { summaries = append(summaries, summary{ - ID: c.ID, Label: c.Label, Size: c.Size, - Files: c.Files, Cohesion: c.Cohesion, + ID: row.ID, Label: row.Label, Size: row.Size, + Files: row.Files, Cohesion: row.Cohesion, }) } - + total := len(summaries) + modularity := 0.0 + if _, header, ok := s.activeAnalysisQuery(); ok { + total = header.CommunityCount + modularity = header.Modularity + } data, err := json.Marshal(map[string]any{ "communities": summaries, - "total": len(summaries), - "modularity": comms.Modularity, + "returned": len(summaries), + "total": total, + "modularity": modularity, + "truncated": next != "", + "next_cursor": next, }) if err != nil { return nil, err @@ -337,38 +345,62 @@ func (s *Server) handleResourceCommunities(_ context.Context, req mcp.ReadResour } func (s *Server) handleResourceCommunity(_ context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { - comms := s.getCommunities() - if comms == nil { - return nil, fmt.Errorf("no communities detected yet") - } - - // Extract ID from URI: gortex://community/{id} id := extractURIParam(req.Params.URI, "gortex://community/") if id == "" { return nil, fmt.Errorf("missing community id in URI") } - - for _, c := range comms.Communities { - if c.ID == id { - data, err := json.Marshal(c) - if err != nil { - return nil, err + var found bool + var label, hub, parentID string + var size int + var cohesion float64 + var files []string + cursor := "" + for !found { + rows, next, err := s.analysisCommunitySummaries(analysisGenerationQueryPage, cursor) + if err != nil { + return nil, fmt.Errorf("no communities detected yet") + } + for _, row := range rows { + if row.ID == id { + found, label, hub, parentID = true, row.Label, row.Hub, row.ParentID + size, cohesion, files = row.Size, row.Cohesion, row.Files + break } - return []mcp.ResourceContents{ - mcp.TextResourceContents{ - URI: req.Params.URI, - MIMEType: "application/json", - Text: string(data), - }, - }, nil } + if found || next == "" || next == cursor || len(rows) == 0 { + break + } + cursor = next + } + if !found { + return nil, fmt.Errorf("community not found: %s", id) + } + members, next, err := s.analysisCommunityMembers(id, analysisGenerationQueryMax, "") + if err != nil { + return nil, err } - return nil, fmt.Errorf("community not found: %s", id) + memberIDs := make([]string, 0, len(members)) + for _, member := range members { + memberIDs = append(memberIDs, member.NodeID) + } + data, err := json.Marshal(map[string]any{ + "id": id, "label": label, "hub": hub, "parent_id": parentID, + "size": size, "cohesion": cohesion, "files": files, "members": memberIDs, + "members_truncated": next != "", "next_member_cursor": next, + }) + if err != nil { + return nil, err + } + return []mcp.ResourceContents{ + mcp.TextResourceContents{ + URI: req.Params.URI, MIMEType: "application/json", Text: string(data), + }, + }, nil } func (s *Server) handleResourceProcesses(_ context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { - procs := s.getProcesses() - if procs == nil || len(procs.Processes) == 0 { + rows, next, err := s.analysisProcessSummaries(100, "") + if err != nil || len(rows) == 0 { return []mcp.ResourceContents{ mcp.TextResourceContents{ URI: req.Params.URI, @@ -386,17 +418,23 @@ func (s *Server) handleResourceProcesses(_ context.Context, req mcp.ReadResource FileCount int `json:"file_count"` Score float64 `json:"score"` } - var summaries []summary - for _, p := range procs.Processes { + summaries := make([]summary, 0, len(rows)) + for _, row := range rows { summaries = append(summaries, summary{ - ID: p.ID, Name: p.Name, EntryPoint: p.EntryPoint, - StepCount: p.StepCount, FileCount: len(p.Files), Score: p.Score, + ID: row.ID, Name: row.Name, EntryPoint: row.EntryPoint, + StepCount: row.StepCount, FileCount: len(row.Files), Score: row.Score, }) } - + total := len(summaries) + if _, header, ok := s.activeAnalysisQuery(); ok { + total = header.ProcessCount + } data, err := json.Marshal(map[string]any{ - "processes": summaries, - "total": len(summaries), + "processes": summaries, + "returned": len(summaries), + "total": total, + "truncated": next != "", + "next_cursor": next, }) if err != nil { return nil, err @@ -411,32 +449,63 @@ func (s *Server) handleResourceProcesses(_ context.Context, req mcp.ReadResource } func (s *Server) handleResourceProcess(_ context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { - procs := s.getProcesses() - if procs == nil { - return nil, fmt.Errorf("no processes discovered yet") - } - id := extractURIParam(req.Params.URI, "gortex://process/") if id == "" { return nil, fmt.Errorf("missing process id in URI") } - - for _, p := range procs.Processes { - if p.ID == id { - data, err := json.Marshal(p) - if err != nil { - return nil, err + var found bool + var name, entryPoint string + var stepCount int + var score float64 + var truncated bool + var files []string + cursor := "" + for !found { + rows, next, err := s.analysisProcessSummaries(analysisGenerationQueryPage, cursor) + if err != nil { + return nil, fmt.Errorf("no processes discovered yet") + } + for _, row := range rows { + if row.ID == id { + found, name, entryPoint = true, row.Name, row.EntryPoint + stepCount, score, truncated, files = row.StepCount, row.Score, row.Truncated, row.Files + break } - return []mcp.ResourceContents{ - mcp.TextResourceContents{ - URI: req.Params.URI, - MIMEType: "application/json", - Text: string(data), - }, - }, nil } + if found || next == "" || next == cursor || len(rows) == 0 { + break + } + cursor = next + } + if !found { + return nil, fmt.Errorf("process not found: %s", id) + } + steps, next, err := s.analysisProcessSteps(id, analysisGenerationQueryMax, -1) + if err != nil { + return nil, err + } + type step struct { + ID string `json:"id"` + Depth int `json:"depth"` } - return nil, fmt.Errorf("process not found: %s", id) + outSteps := make([]step, 0, len(steps)) + for _, row := range steps { + outSteps = append(outSteps, step{ID: row.NodeID, Depth: row.Depth}) + } + data, err := json.Marshal(map[string]any{ + "id": id, "name": name, "entry_point": entryPoint, + "steps": outSteps, "step_count": stepCount, "files": files, + "score": score, "truncated": truncated || next >= 0, + "next_step_cursor": next, + }) + if err != nil { + return nil, err + } + return []mcp.ResourceContents{ + mcp.TextResourceContents{ + URI: req.Params.URI, MIMEType: "application/json", Text: string(data), + }, + }, nil } // extractURIParam extracts the parameter value after a URI prefix. diff --git a/internal/mcp/response_buffer.go b/internal/mcp/response_buffer.go index 49187d6ce..87a8ca6d9 100644 --- a/internal/mcp/response_buffer.go +++ b/internal/mcp/response_buffer.go @@ -14,7 +14,10 @@ import ( // defaultResponseBufferCap is how many recent tool responses a session // keeps available for post-filter re-cutting. -const defaultResponseBufferCap = 8 +const ( + defaultResponseBufferCap = 8 + defaultResponseBufferBytes = 8 << 20 +) // minCapturedResponseBytes is the capture floor: responses smaller // than this are cheap to re-fetch, so they never displace a ring slot. @@ -43,15 +46,20 @@ type bufferedResponse struct { // It backs the post-filter tools (ctx_grep, ctx_slice, …) so an agent // can re-cut a prior result without re-issuing the original query. type responseBuffer struct { - mu sync.Mutex - entries []bufferedResponse - seq int + mu sync.Mutex + entries []bufferedResponse + totalBytes int + seq int } -// capture stores a response and returns its handle ID. The oldest -// entry is evicted, and its backing memory released, once the ring is -// full. +// capture stores a response and returns its handle ID. The oldest entries are +// evicted, and their strings released, once either the count or byte budget is +// full. A single response larger than the whole budget is not retained. func (b *responseBuffer) capture(tool, text string) string { + if len(text) > defaultResponseBufferBytes { + return "" + } + b.mu.Lock() defer b.mu.Unlock() b.seq++ @@ -62,10 +70,11 @@ func (b *responseBuffer) capture(tool, text string) string { Text: text, CapturedAt: time.Now(), }) - if len(b.entries) > defaultResponseBufferCap { - trimmed := make([]bufferedResponse, defaultResponseBufferCap) - copy(trimmed, b.entries[len(b.entries)-defaultResponseBufferCap:]) - b.entries = trimmed + b.totalBytes += len(text) + for len(b.entries) > defaultResponseBufferCap || b.totalBytes > defaultResponseBufferBytes { + b.totalBytes -= len(b.entries[0].Text) + b.entries[0] = bufferedResponse{} + b.entries = b.entries[1:] } return id } @@ -120,8 +129,12 @@ func (s *Server) captureResponse(ctx context.Context, tool string, res *mcp.Call if res == nil || res.IsError || postFilterTools[tool] { return } - text := normalizeForBuffer(toolResultText(res)) - if len(text) < minCapturedResponseBytes { + raw := toolResultText(res) + if len(raw) < minCapturedResponseBytes || len(raw) > defaultResponseBufferBytes { + return + } + text := normalizeForBuffer(raw) + if len(text) > defaultResponseBufferBytes { return } s.responseBufferFor(ctx).capture(tool, text) diff --git a/internal/mcp/response_buffer_memory_test.go b/internal/mcp/response_buffer_memory_test.go new file mode 100644 index 000000000..f4a77028a --- /dev/null +++ b/internal/mcp/response_buffer_memory_test.go @@ -0,0 +1,96 @@ +package mcp + +import ( + "fmt" + "strings" + "sync" + "testing" +) + +func TestResponseBufferEnforcesByteBudget(t *testing.T) { + var b responseBuffer + chunk := strings.Repeat("x", defaultResponseBufferBytes/3+1) + first := b.capture("first", chunk) + _ = b.capture("second", chunk) + latest := b.capture("third", chunk) + + if first == "" || latest == "" { + t.Fatal("responses within the per-entry budget were not captured") + } + if b.totalBytes > defaultResponseBufferBytes { + t.Fatalf("retained bytes = %d, cap = %d", b.totalBytes, defaultResponseBufferBytes) + } + if got := len(b.entries); got != 2 { + t.Fatalf("retained entries = %d, want 2 after byte eviction", got) + } + if _, ok := b.get(first); ok { + t.Fatal("oldest response survived byte-budget eviction") + } + if _, ok := b.get(latest); !ok { + t.Fatal("latest response was evicted") + } +} + +func TestResponseBufferRejectsOversizedEntry(t *testing.T) { + var b responseBuffer + oversized := strings.Repeat("x", defaultResponseBufferBytes+1) + if id := b.capture("oversized", oversized); id != "" { + t.Fatalf("oversized capture id = %q, want empty", id) + } + if len(b.entries) != 0 || b.totalBytes != 0 { + t.Fatalf("oversized response retained: entries=%d bytes=%d", len(b.entries), b.totalBytes) + } +} + +func TestResponseBufferCountEvictionUpdatesBytes(t *testing.T) { + var b responseBuffer + for i := 0; i < defaultResponseBufferCap+3; i++ { + b.capture(fmt.Sprintf("tool-%d", i), strings.Repeat("x", 1024+i)) + } + if got := len(b.entries); got != defaultResponseBufferCap { + t.Fatalf("retained entries = %d, want %d", got, defaultResponseBufferCap) + } + wantBytes := 0 + for _, entry := range b.entries { + wantBytes += len(entry.Text) + } + if b.totalBytes != wantBytes { + t.Fatalf("tracked bytes = %d, actual = %d", b.totalBytes, wantBytes) + } +} + +func TestResponseBufferConcurrentCapturesStayBounded(t *testing.T) { + var b responseBuffer + const callers = 64 + payload := strings.Repeat("x", 256<<10) + var wg sync.WaitGroup + wg.Add(callers) + for i := 0; i < callers; i++ { + go func(i int) { + defer wg.Done() + b.capture(fmt.Sprintf("tool-%d", i), payload) + }(i) + } + wg.Wait() + + b.mu.Lock() + defer b.mu.Unlock() + if len(b.entries) > defaultResponseBufferCap { + t.Fatalf("retained entries = %d, cap = %d", len(b.entries), defaultResponseBufferCap) + } + if b.totalBytes > defaultResponseBufferBytes { + t.Fatalf("retained bytes = %d, cap = %d", b.totalBytes, defaultResponseBufferBytes) + } +} + +func BenchmarkResponseBufferByteCap(b *testing.B) { + for range b.N { + var buf responseBuffer + for i := 0; i < 32; i++ { + payload := strings.Repeat(string(rune('a'+i%26)), 1<<20) + buf.capture(fmt.Sprintf("tool-%d", i), payload) + } + b.ReportMetric(float64(buf.totalBytes)/(1<<20), "retained-MiB") + b.ReportMetric(float64(len(buf.entries)), "retained-entries") + } +} diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 0a624ac2f..765118857 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -6,10 +6,11 @@ import ( "fmt" "math" "os" - "runtime/debug" + "runtime" "sort" "strings" "sync" + "sync/atomic" "time" "go.uber.org/zap" @@ -30,6 +31,7 @@ import ( "github.com/zzet/gortex/internal/platform" "github.com/zzet/gortex/internal/query" "github.com/zzet/gortex/internal/review" + "github.com/zzet/gortex/internal/runtimeactivity" "github.com/zzet/gortex/internal/savings" "github.com/zzet/gortex/internal/search" "github.com/zzet/gortex/internal/semantic" @@ -47,20 +49,57 @@ type SymbolModification struct { SignatureChanged bool `json:"signature_changed"` } -// symbolHistory tracks symbol modifications during the current session. +const ( + maxSymbolHistorySymbols = 256 + maxSymbolHistoryPerSymbol = 32 +) + +// symbolHistory tracks a bounded, daemon-wide working set of recent symbol +// modifications. A per-symbol cap prevents one hot file from growing forever; +// the LRU symbol cap bounds the aggregate to 8,192 modification records. type symbolHistory struct { mu sync.Mutex - entries map[string][]SymbolModification // symbolID → modifications + entries map[string][]SymbolModification // symbolID → oldest-to-newest modifications + order []string // least-to-most recently modified symbol IDs } // Record adds a modification entry for the given symbol. func (sh *symbolHistory) Record(symbolID string, signatureChanged bool) { sh.mu.Lock() defer sh.mu.Unlock() - sh.entries[symbolID] = append(sh.entries[symbolID], SymbolModification{ + if sh.entries == nil { + sh.entries = make(map[string][]SymbolModification) + } + + mods := append(sh.entries[symbolID], SymbolModification{ Timestamp: time.Now(), SignatureChanged: signatureChanged, }) + if len(mods) > maxSymbolHistoryPerSymbol { + copy(mods, mods[len(mods)-maxSymbolHistoryPerSymbol:]) + mods = mods[:maxSymbolHistoryPerSymbol] + } + sh.entries[symbolID] = mods + sh.touchSymbolLocked(symbolID) + for len(sh.entries) > maxSymbolHistorySymbols && len(sh.order) > 0 { + oldest := sh.order[0] + copy(sh.order, sh.order[1:]) + sh.order[len(sh.order)-1] = "" + sh.order = sh.order[:len(sh.order)-1] + delete(sh.entries, oldest) + } +} + +func (sh *symbolHistory) touchSymbolLocked(symbolID string) { + for i, id := range sh.order { + if id != symbolID { + continue + } + copy(sh.order[i:], sh.order[i+1:]) + sh.order[len(sh.order)-1] = symbolID + return + } + sh.order = append(sh.order, symbolID) } // Get returns the modification history for a specific symbol. @@ -92,6 +131,7 @@ type Server struct { engine *query.Engine graph graph.Store indexer *indexer.Indexer + watcherMu sync.RWMutex watcher watcherHistory multiIndexer *indexer.MultiIndexer configManager *config.ConfigManager @@ -143,13 +183,20 @@ type Server struct { // fingerprint packages. On a disk backend the fingerprint scan alone is // ~140s; the cache check is three scalar reads. communitiesToken communityCacheToken - // hotspots is the default-threshold (mean + 2*stddev) hotspot - // ranking. FindHotspots' inner ComputeBetweenness pass dominates - // the wall clock of get_repo_outline / get_architecture / - // gortex_wakeup / the analyze(hotspots) resource — caching it - // once per RunAnalysis turn turns repeat calls into a map lookup. - // Rebuilt each RunAnalysis pass; guarded by analysisMu. - hotspots []analysis.HotspotEntry + // hotspots is the lazily-built default-threshold (mean + 2*stddev) + // ranking. FindHotspots' inner ComputeBetweenness pass is too expensive + // to put on every daemon cold-start path; RunAnalysis invalidates this + // cache and the first hotspot-consuming request rebuilds it once for that + // analysis epoch. Reads/cache state are guarded by analysisMu; the separate + // build mutex coalesces concurrent first requests without blocking unrelated + // analysis snapshots while betweenness runs. + hotspots []analysis.HotspotEntry + hotspotsReady bool + hotspotsBuildMu sync.Mutex + analysisEpoch uint64 + // hotspotsFn is a test seam for deterministic concurrency/invalidation + // tests. Production leaves it nil and uses analysis.FindHotspots. + hotspotsFn func(graph.Store, *analysis.CommunityResult, float64) []analysis.HotspotEntry // adjacency is the compact CSR snapshot of the call / reference // graph, built once per RunAnalysis pass so seeded random-walk // queries (context_closure proximity ranking) never re-scan @@ -162,7 +209,14 @@ type Server struct { // consumer can tell whether the snapshot still matches the live // graph before trusting it. adjacencyToken communityCacheToken - analysisMu sync.RWMutex + // analysisGeneration is the small active durable-generation manifest. + // Warm startup publishes only this header; normalized metrics and bounded + // memberships stay in SQLite until a request asks for them. + analysisGeneration graph.AnalysisGenerationHeader + analysisGenerationReady bool + analysisPruneScheduled atomic.Bool + analysisMaterializeMu sync.Mutex + analysisMu sync.RWMutex // cochange caches the git-history co-change graph. cochangeByFile // maps a file path to its co-changing file paths and association @@ -208,9 +262,10 @@ type Server struct { // state for the embedded stdio path (one implicit client per process). // Tool handlers reach per-session activity via sessionFor(ctx); that // helper returns this default when ctx carries no session ID. - session *sessionState - symHistory *symbolHistory - tokenStats *tokenStats + session *sessionState + symHistory *symbolHistory + tokenStats *tokenStats + localization *localizationTerminalState // sessions multiplexes per-client sessionLocal for the daemon // transport. When ctx carries a session ID (WithSessionID), handlers @@ -268,6 +323,24 @@ type Server struct { // until InitSuppressions fires; the review flow tolerates a nil store. suppressions *suppressionManager + // mutationReceipts tracks watcher tickets that outlive an edit response. + // Safety-critical graph consumers wait on these receipts before reading the + // graph, so a slow patch is explicit rather than a silent stale read. Test + // servers may shorten the two bounded waits; zero selects production + // defaults. sync.Map keeps directly-constructed servers usable. + mutationReceipts sync.Map + mutationReindexWait time.Duration + mutationSafetyWait time.Duration + + // batchTransactions holds daemon-lifetime delivery receipts for atomic + // batch edits. sync.Map's zero value keeps directly-constructed test and + // embedded servers usable without constructor wiring. batchWriteOverride is + // a narrow target-write fault-injection seam; batchDurabilityOverride covers + // journal, fsync, rollback, and cleanup ordering. Production leaves both nil. + batchTransactions sync.Map + batchWriteOverride func(string, []byte, os.FileMode) error + batchDurabilityOverride *batchDurabilityOps + // packCache retains recent smart_context pack views keyed by pack // root so a later call with delta_from= returns only the // added/removed/changed symbols vs that prior pack. Always non-nil @@ -421,6 +494,12 @@ type Server struct { // is explicitly disabled — see lazyEnabledFromEnv. lazy *lazyToolRegistry + // facades maps the stable facade-v1 operation surface to the existing + // handlers. It captures registrations before lazy routing, so a facade can + // invoke a cold legacy implementation without promoting or re-advertising + // that legacy tool. + facades *facadeRegistry + // toolPolicy restricts the published tool surface to a named preset // / allow-deny set (see tool_presets.go). Resolved at construction // from MultiRepoOptions.ToolPolicy (the mcp.tools config block) plus @@ -884,10 +963,8 @@ func (s *Server) NoteSessionToolPolicy(sessionID, spec, mode string) { // named MCP client is known to decode. Resolution order is gcx > // toon > json: // -// - GCX-capable: claude-code, cursor, vscode (via the @gortex/wire -// extension that ships with the IDE plugin), zed (gortex-zed -// plugin links gcx-go), aider, kilocode, opencode, openclaw, -// codex (Anthropic CLI bundles the gcx decoder). +// - GCX-capable: the canonical coding clients and product aliases in +// knownAgentClients / knownAgentHosts. // - TOON-capable but no GCX: kept for forward compat; today there // is no client we know to be in this bucket. Listed for the // mapping shape and as a placeholder — clients can be promoted @@ -903,11 +980,9 @@ func defaultFormatForClient(name string) string { return "" } -// knownAgentClients is the set of MCP clients recognised as coding agents: -// they ship a GCX decoder (so they default to the gcx wire format) AND they -// get the lean `agent` tool preset by default. One list drives both -// defaults so the two stay in lock-step. Matched case-insensitively on the -// exact clientInfo.name. +// knownAgentClients is the set of canonical MCP client identifiers known to +// ship a GCX decoder. It controls wire-format negotiation only; every non-empty +// clientInfo.name receives the compact coding surface independently. var knownAgentClients = map[string]bool{ "claude-code": true, "cursor": true, @@ -921,10 +996,26 @@ var knownAgentClients = map[string]bool{ "omp-coding-agent": true, } +// knownAgentHosts are host-context families whose aliases name the same +// GCX-capable coding client (for example "Claude Code 1.4", "Visual Studio +// Code", or "openai-codex"). Editor-only hosts without the decoder remain on +// JSON, while still receiving the compact coding surface. +var knownAgentHosts = map[string]bool{ + "claude-code": true, + "cursor": true, + "vscode": true, + "zed": true, + "codex": true, +} + // isKnownAgentClient reports whether the named MCP client is a recognised // coding agent (see knownAgentClients). func isKnownAgentClient(name string) bool { - return knownAgentClients[strings.ToLower(strings.TrimSpace(name))] + name = strings.ToLower(strings.TrimSpace(name)) + if knownAgentClients[name] { + return true + } + return knownAgentHosts[resolveHostContext(name).name] } // resolveSessionFormat returns the format the current session prefers @@ -1118,7 +1209,7 @@ type MultiRepoOptions struct { // the graph tools over raw file reads, and where to start." const serverInstructions = `Gortex is a code-intelligence graph server — it indexes repositories into a queryable knowledge graph. Prefer its graph tools over raw file reads and text search: -- START WITH explore FOR EVERY TASK-SHAPED REQUEST (a bug report, a feature, "where is / how does X work"): one call returns the ranked neighborhood — the likely symbols with their source, call paths, and the files to change. Answer or start editing directly from its output instead of chaining search/read/callers calls; its locations are graph-verified, so no re-checking with file reads is needed. +- For a request whose deliverable is only files, symbols, evidence, or a location, call explore(operation:"localize"). Follow completion.required_action: answer immediately when state is answer_ready; when state is needs_exact_read, make only the named exact read. For diagnosis or modification, call explore(operation:"task") and continue from its evidence. - For one known symbol: search_symbols (BM25, camelCase-aware) to find it, get_symbol_source to read it, batch_symbols for several bodies in one call; find_usages / get_callers for references and callers. - Before editing, call get_editing_context on the file; for refactors use edit_symbol / rename_symbol / batch_edit. - The cold tools/list shows a core set — call tools_search to discover the rest of the catalogue on demand. @@ -1126,6 +1217,11 @@ const serverInstructions = `Gortex is a code-intelligence graph server — it in ` + sharedParamLegend +// codingAgentInstructions is intentionally terse because some MCP hosts repeat +// initialize instructions beside every rendered tool. It describes what to do, +// not how the server versions or implements its tool surface. +const codingAgentInstructions = `MUST use Gortex MCP. For files/symbols/evidence/where, call explore(operation:"localize"); obey completion.required_action and make no calls after answer_ready. For diagnosis/change, call explore(operation:"task"), then at most one focused follow-up. Before edit: change(operation:"impact"); signature: change(operation:"verify"). Mutate only with edit or refactor. After: change(operation:"detect"), then tests/guards/contract with its IDs. Call capabilities only for unknown fields.` + // ServerInstructionsUntracked is the inactive-state `instructions` variant // returned when a session's cwd is not covered by any tracked repo. Rather than // poisoning the connection with an errored initialize, the handshake succeeds @@ -1155,16 +1251,23 @@ func ServerInstructionsUntracked(cwd string, roots ...string) string { return msg } -// afterInitializeInstructions is the server's OnAfterInitialize hook: it -// rewrites the initialize result's Instructions to the variant that fits THIS -// connection's cwd. Because it runs inside the handshake, every MCP client — -// not just ones that execute a SessionStart hook — learns the live workspace -// shape and warmup state directly from initialize. See stateAwareInstructions. -func (s *Server) afterInitializeInstructions(ctx context.Context, _ any, _ *mcp.InitializeRequest, result *mcp.InitializeResult) { - if s == nil || result == nil { +// afterInitializeInstructions is the server's OnAfterInitialize hook. It +// records clientInfo on THIS session before tools/list is served, then rewrites +// the initialize result's Instructions to the cwd-aware variant. Recording the +// client here keeps embedded/raw HandleMessage users on the same client-aware +// tool policy as the daemon path, whose dispatcher also snoops initialize. +func (s *Server) afterInitializeInstructions(ctx context.Context, _ any, req *mcp.InitializeRequest, result *mcp.InitializeResult) { + if s == nil { return } - result.Instructions = s.stateAwareInstructions(SessionCWDFromContext(ctx)) + if req != nil { + s.sessionFor(ctx).recordClientName(req.Params.ClientInfo.Name) + } + if result == nil { + return + } + result.Instructions = s.stateAwareInstructionsForPolicy( + SessionCWDFromContext(ctx), s.effectiveSessionPolicy(ctx)) } // stateAwareInstructions chooses the initialize `instructions` text for a @@ -1189,15 +1292,31 @@ func (s *Server) trackedRepoRoots() []string { } func (s *Server) stateAwareInstructions(cwd string) string { + return s.stateAwareInstructionsWithBase(cwd, serverInstructions) +} + +func (s *Server) stateAwareInstructionsForClient(cwd, client string) string { + return s.stateAwareInstructionsForPolicy(cwd, s.clientDefaultPolicy(client)) +} + +func (s *Server) stateAwareInstructionsForPolicy(cwd string, policy *toolPolicy) string { + base := serverInstructions + if policy != nil && policy.preset == FacadeSurfaceVersion { + base = codingAgentInstructions + } + return s.stateAwareInstructionsWithBase(cwd, base) +} + +func (s *Server) stateAwareInstructionsWithBase(cwd, base string) string { if s.multiIndexer != nil && strings.TrimSpace(cwd) != "" { if _, _, _, ok := s.multiIndexer.ScopeForCWD(cwd); !ok { return ServerInstructionsUntracked(cwd, s.trackedRepoRoots()...) } } if facts := s.liveInstructionFacts(cwd); facts != "" { - return serverInstructions + "\n\n" + facts + return base + "\n\n" + facts } - return serverInstructions + return base } // liveInstructionFacts renders the per-connection state block appended to the @@ -1289,6 +1408,7 @@ func NewServer(engine *query.Engine, g graph.Store, idx *indexer.Indexer, watche indexer: idx, logger: logger, session: newSessionState(), + localization: newLocalizationTerminalState(), scopeIntentDefaults: true, tokenStats: &tokenStats{}, symHistory: &symbolHistory{ @@ -1302,6 +1422,7 @@ func NewServer(engine *query.Engine, g graph.Store, idx *indexer.Indexer, watche pprCache: newPPRWalkCache(), packCache: newPackDeltaCache(), prCache: newPRCache(prCacheTTL), + facades: newFacadeRegistry(), } // Wire the process-wide tokenStats as the parent of every // per-session counter so record() fanout aggregates daemon-wide. @@ -1471,6 +1592,11 @@ func NewServer(engine *query.Engine, g graph.Store, idx *indexer.Indexer, watche // to a one-member view. s.registerWorkspaceTools() + // Register the stable facade surface only after the legacy sweep, so every + // operation has captured its existing implementation. Facade dispatchers + // are installed live and session-filtered; they never rely on promotion. + s.registerFacadeTools() + // LLM-backed tools (`ask`) are NOT registered here — they're // gated on SetLLMService being called with an enabled service, // which happens post-construction from the daemon entrypoint. @@ -2165,21 +2291,27 @@ func (s *Server) ResolveToolScope(toolName string, repo any) (*ScopedRepos, *mcp // communityCacheToken is the per-graph identity tuple // handleAnalyzeClusters checks before re-running the incremental -// detector. EdgeIdentity moves on any structural mutation; NodeCount -// and EdgeCount cover pure additions / removals that leave the -// identity counter alone. A zero token is "never populated". +// detector. EdgeIdentity moves on provenance churn; NodeCount and EdgeCount +// cover additions/removals. analysisRevision closes the remaining same-count +// mutation gap on durable stores (for example a rebind or source-location +// shift). A zero token is "never populated". type communityCacheToken struct { - edgeIdentity int - nodeCount int - edgeCount int + edgeIdentity int + nodeCount int + edgeCount int + analysisRevision uint64 } func (s *Server) currentCommunityToken() communityCacheToken { - return communityCacheToken{ + token := communityCacheToken{ edgeIdentity: s.graph.EdgeIdentityRevisions(), nodeCount: s.graph.NodeCount(), edgeCount: s.graph.EdgeCount(), } + if writer, _ := s.analysisGenerationBackends(); writer != nil { + token.analysisRevision = writer.AnalysisMutationRevision() + } + return token } // RunAnalysis performs community detection and process discovery on @@ -2187,47 +2319,20 @@ func (s *Server) currentCommunityToken() communityCacheToken { // for every bootstrap resource so subscribed clients can refresh // without polling. func (s *Server) RunAnalysis() { + runtimeactivity.Begin("analysis") + analysisStarted := time.Now() + var memoryBefore runtime.MemStats + runtime.ReadMemStats(&memoryBefore) + defer func() { + runtimeactivity.End("analysis") + s.releaseTransientAnalysisIfIdle() + writer, _ := s.analysisGenerationBackends() + s.scheduleAnalysisGenerationPrune(writer) + scheduleOSMemoryReleaseAfterBurst(s.logger, "mcp_analysis") + }() + s.analysisMu.Lock() - // Detect communities through the incremental path, threading the - // partition cache. When a re-warm only touched a few packages - // this recomputes just those; the cache is also left warm so the - // next `analyze kind=clusters` call inherits it. The result is - // shape-identical to a full DetectCommunities run. - communities, cache, _ := analysis.DetectCommunitiesLeidenIncremental(s.graph, s.leidenCache) - s.communities = communities - s.leidenCache = cache - s.communitiesToken = s.currentCommunityToken() - // Feed the freshly computed per-package fingerprints to the - // backend's bundle cache so it retires bundles for packages whose - // content changed since the last pass and keeps the rest. The - // fingerprints are edge-aware (DetectCommunitiesLeidenIncremental - // folds each package's nodes and the edges touching them), so this - // is the correct staleness signal for cached node + in/out edges. - // A backend without a bundle cache simply doesn't satisfy the - // interface and this no-ops. - if sink, ok := s.backendStore().(graph.BundleFingerprintSink); ok && cache != nil { - sink.SetBundleFingerprints(cache.PackageFingerprints()) - } - s.processes = analysis.DiscoverProcesses(s.graph) - s.pageRank = analysis.ComputePageRank(s.graph) - // Compact CSR adjacency over the same call / reference edge set - // PageRank uses — the substrate for seeded random-walk proximity - // queries. Built once here so per-query walks never re-scan the - // graph; stamped with the current graph identity for the same - // invalidation discipline as the community cache. - s.adjacency = analysis.BuildAdjacencySnapshot(s.graph) - s.adjacencyToken = s.currentCommunityToken() - // Auto-concept vocabulary: mine domain phrases from symbol names - // so equivalence-class expansion can bridge repo-specific terms - // even with no LLM provider configured. - s.autoConcepts = search.BuildAutoConcepts(s.graph) - // HITS authority/hub scores -- fed into the search rerank as an - // authority signal that complements raw fan-in. - s.hits = analysis.ComputeHITS(s.graph) - // Default-threshold hotspot ranking — cached because FindHotspots - // triggers ComputeBetweenness which is the shared wall-clock - // floor for outline / architecture / wakeup / the resource view. - s.hotspots = analysis.FindHotspots(s.graph, communities, 0) + analysisMetrics := s.populateAnalysisLocked() s.analysisMu.Unlock() // The graph was just rebuilt, so the lazy-enrichment ledger — symbol @@ -2251,29 +2356,58 @@ func (s *Server) RunAnalysis() { s.graphInvalidatedBroadcaster.broadcast(s.graph.NodeCount(), s.graph.EdgeCount(), "reanalysis") } - // A full analysis pass (PageRank / Leiden / HITS / hotspots over the - // whole graph) is one of the daemon's largest on-demand allocation - // bursts. Scavenge its high-water back to the OS so a client-triggered - // reanalysis doesn't ratchet the idle footprint up and leave it there. - freeOSMemoryAfterBurst(s.logger, "mcp_analysis") -} - -// freeOSMemoryAfterBurst returns a completed whole-graph burst's heap -// high-water to the OS. debug.FreeOSMemory forces a GC + scavenge; -// GORTEX_DAEMON_MEMRELEASE=0 (or "false") disables it. The env check is -// duplicated here (rather than shared) because the canonical release helper -// lives in the cmd layer, which this package must not import. -func freeOSMemoryAfterBurst(logger *zap.Logger, reason string) { - if v := os.Getenv("GORTEX_DAEMON_MEMRELEASE"); v == "0" || strings.EqualFold(v, "false") { + var memoryAfter runtime.MemStats + runtime.ReadMemStats(&memoryAfter) + if s.logger != nil { + s.logger.Info("mcp: analysis pass complete", + zap.Bool("cache_hit", analysisMetrics.cacheHit), + zap.Duration("cache_load", analysisMetrics.cacheLoad), + zap.Duration("cache_save", analysisMetrics.cacheSave), + zap.Duration("snapshot", analysisMetrics.snapshot), + zap.Duration("leiden", analysisMetrics.leiden), + zap.Duration("processes", analysisMetrics.processes), + zap.Duration("pagerank", analysisMetrics.pageRank), + zap.Duration("adjacency", analysisMetrics.adjacency), + zap.Duration("auto_concepts", analysisMetrics.autoConcepts), + zap.Duration("hits", analysisMetrics.hits), + zap.Duration("total", time.Since(analysisStarted)), + zap.Uint64("heap_alloc_before_bytes", memoryBefore.HeapAlloc), + zap.Uint64("heap_alloc_after_bytes", memoryAfter.HeapAlloc), + zap.Uint64("heap_inuse_before_bytes", memoryBefore.HeapInuse), + zap.Uint64("heap_inuse_after_bytes", memoryAfter.HeapInuse), + zap.Uint64("heap_idle_before_bytes", memoryBefore.HeapIdle), + zap.Uint64("heap_idle_after_bytes", memoryAfter.HeapIdle), + zap.Uint64("heap_released_before_bytes", memoryBefore.HeapReleased), + zap.Uint64("heap_released_after_bytes", memoryAfter.HeapReleased), + zap.Uint64("heap_sys_before_bytes", memoryBefore.HeapSys), + zap.Uint64("heap_sys_after_bytes", memoryAfter.HeapSys), + zap.Uint64("stack_inuse_before_bytes", memoryBefore.StackInuse), + zap.Uint64("stack_inuse_after_bytes", memoryAfter.StackInuse)) + } +} + +var osMemoryReleaseScheduled atomic.Bool + +// scheduleOSMemoryReleaseAfterBurst coalesces full-graph query bursts and +// releases their heap high-water after the response has had time to leave the +// handler. The short delay keeps debug.FreeOSMemory's stop-the-world work out +// of the user-visible tool latency while bounding idle RSS after analysis. +func scheduleOSMemoryReleaseAfterBurst(logger *zap.Logger, reason string) { + ScheduleMemoryReleaseAfterBurst(logger, reason) +} + +// ScheduleMemoryReleaseAfterBurst coalesces process-wide allocation bursts and +// reclaims their idle heap only after all tracked foreground and background work +// has remained quiet. It is exported for the daemon lifecycle, which shares the +// same process and therefore must share the same gate and scheduler. +func ScheduleMemoryReleaseAfterBurst(logger *zap.Logger, reason string) { + if !memoryReleaseEnabled() { return } - start := time.Now() - debug.FreeOSMemory() - if logger != nil { - logger.Debug("mcp: released heap to OS", - zap.String("reason", reason), - zap.Duration("elapsed", time.Since(start))) + if !osMemoryReleaseScheduled.CompareAndSwap(false, true) { + return } + go runScheduledMCPMemoryRelease(logger, reason) } // resetConfirmedRefs clears the lazy-enrichment ledger (see the @@ -2288,9 +2422,17 @@ func (s *Server) resetConfirmedRefs() { }) } +func (s *Server) analysisSnapshotCurrentLocked() bool { + return s.analysisEpoch > 0 && s.communitiesToken == s.currentCommunityToken() +} + func (s *Server) getCommunities() *analysis.CommunityResult { + _ = s.ensureCommunitiesMaterialized() s.analysisMu.RLock() defer s.analysisMu.RUnlock() + if !s.analysisSnapshotCurrentLocked() { + return nil + } return s.communities } @@ -2311,6 +2453,8 @@ func (s *Server) getCommunities() *analysis.CommunityResult { // incremental run (no changed packages, no repartitioned nodes) so // callers see the cache hit on the wire. func (s *Server) incrementalCommunities() (*analysis.CommunityResult, analysis.IncrementalCommunityStats) { + _ = s.ensureCommunitiesMaterialized() + _ = s.ensureLeidenMaterialized() s.analysisMu.Lock() defer s.analysisMu.Unlock() cur := s.currentCommunityToken() @@ -2353,18 +2497,33 @@ func (s *Server) incrementalCommunities() (*analysis.CommunityResult, analysis.I // the state the result was actually computed against, and the next // call's token comparison stays meaningful. s.communitiesToken = s.currentCommunityToken() + // A cache miss ran a real community recompute. Invalidate the dependent + // hotspot ranking and advance its epoch while holding analysisMu: an + // in-flight getHotspots build that captured the old communities will see + // the epoch change, discard its result, and rebuild before publishing. + s.hotspots = nil + s.hotspotsReady = false + s.analysisEpoch++ return result, stats } func (s *Server) getProcesses() *analysis.ProcessResult { + _ = s.ensureProcessesMaterialized() s.analysisMu.RLock() defer s.analysisMu.RUnlock() + if !s.analysisSnapshotCurrentLocked() { + return nil + } return s.processes } func (s *Server) getPageRank() *analysis.PageRankResult { + _ = s.ensureNodeMetricsMaterialized() s.analysisMu.RLock() defer s.analysisMu.RUnlock() + if !s.analysisSnapshotCurrentLocked() { + return nil + } return s.pageRank } @@ -2373,8 +2532,12 @@ func (s *Server) getPageRank() *analysis.PageRankResult { // immutable after construction, so the caller may run seeded walks over // it after releasing the read lock. func (s *Server) getAdjacency() *analysis.AdjacencySnapshot { + _ = s.ensureAdjacencyMaterialized() s.analysisMu.RLock() defer s.analysisMu.RUnlock() + if !s.analysisSnapshotCurrentLocked() { + return nil + } return s.adjacency } @@ -2382,8 +2545,12 @@ func (s *Server) getAdjacency() *analysis.AdjacencySnapshot { // vocabulary. Nil until the first RunAnalysis pass; callers // nil-check (AutoConcepts.Expand is itself nil-safe). func (s *Server) getAutoConcepts() *search.AutoConcepts { + _ = s.ensureAutoConceptsMaterialized() s.analysisMu.RLock() defer s.analysisMu.RUnlock() + if !s.analysisSnapshotCurrentLocked() { + return nil + } return s.autoConcepts } @@ -2391,20 +2558,77 @@ func (s *Server) getAutoConcepts() *search.AutoConcepts { // first RunAnalysis pass; callers nil-check (HITSResult accessors // are themselves nil-safe). func (s *Server) getHITS() *analysis.HITSResult { + _ = s.ensureNodeMetricsMaterialized() s.analysisMu.RLock() defer s.analysisMu.RUnlock() + if !s.analysisSnapshotCurrentLocked() { + return nil + } return s.hits } -// getHotspots returns the default-threshold hotspot ranking computed -// by the most recent RunAnalysis pass. Nil/empty until the first -// pass; callers use the live FindHotspots(threshold) path when they -// need a non-default threshold. Returned slice is shared and must -// not be mutated by the caller. +// getHotspots returns the default-threshold hotspot ranking for the current +// analysis epoch, computing it on first demand. Concurrent first callers are +// coalesced. If RunAnalysis invalidates the epoch while a build is in flight, +// the stale result is discarded and rebuilt against the new communities. +// The returned slice is shared and must not be mutated by the caller. func (s *Server) getHotspots() []analysis.HotspotEntry { + if !s.ensureCommunitiesMaterialized() && s.hotspotsFn == nil { + return nil + } s.analysisMu.RLock() - defer s.analysisMu.RUnlock() - return s.hotspots + if !s.analysisSnapshotCurrentLocked() { + s.analysisMu.RUnlock() + return nil + } + if s.hotspotsReady { + hotspots := s.hotspots + s.analysisMu.RUnlock() + return hotspots + } + s.analysisMu.RUnlock() + + s.hotspotsBuildMu.Lock() + defer s.hotspotsBuildMu.Unlock() + for { + s.analysisMu.RLock() + if !s.analysisSnapshotCurrentLocked() { + s.analysisMu.RUnlock() + return nil + } + if s.hotspotsReady { + hotspots := s.hotspots + s.analysisMu.RUnlock() + return hotspots + } + epoch := s.analysisEpoch + token := s.communitiesToken + communities := s.communities + s.analysisMu.RUnlock() + + build := analysis.FindHotspots + if s.hotspotsFn != nil { + build = s.hotspotsFn + } + hotspots := build(s.graph, communities, 0) + + s.analysisMu.Lock() + if s.analysisEpoch == epoch && s.communitiesToken == token && s.analysisSnapshotCurrentLocked() { + s.hotspots = hotspots + s.hotspotsReady = true + s.analysisMu.Unlock() + scheduleOSMemoryReleaseAfterBurst(s.logger, "lazy_hotspots") + return hotspots + } + epochChanged := s.analysisEpoch != epoch + s.analysisMu.Unlock() + if !epochChanged { + // The graph changed before a new analysis snapshot was published. + // Fail closed instead of spinning or serving hotspots built from the + // stale community assignment. + return nil + } + } } // SetArchitecture installs the declarative architecture-rules DSL so @@ -2483,24 +2707,46 @@ func (s *Server) MCPServer() *server.MCPServer { // session context burn for token-economical clients while keeping // the full surface reachable through a one-call discovery hop. func (s *Server) addTool(tool mcp.Tool, handler server.ToolHandlerFunc) { + handler = s.prepareTool(&tool, handler) + if s.lazy != nil && s.lazy.IsDeferred(tool.Name) { + s.lazy.Register(tool, handler) + return + } + s.mcpServer.AddTool(tool, s.wrapToolHandler(handler)) +} + +// addControlTool registers a live tool that must manage overlay state/views +// itself. Unlike a bare mcpServer.AddTool it still captures facade adapters and +// applies gates, sanitization, telemetry, logging, panic recovery, and response +// middleware. These tools stay live because they are session/control recovery +// paths or optional subsystems registered after the initial lazy sweep. +func (s *Server) addControlTool(tool mcp.Tool, handler server.ToolHandlerFunc) { + handler = s.prepareTool(&tool, handler) + s.mcpServer.AddTool(tool, s.wrapControlToolHandler(handler)) +} + +func (s *Server) prepareTool(tool *mcp.Tool, handler server.ToolHandlerFunc) server.ToolHandlerFunc { // Scrub control characters / ANSI escapes out of the tool's text // before it reaches any client's tools/list rendering. tool is a // value copy, so this mutates only the registered instance. - scrubToolText(&tool) + scrubToolText(tool) // Embed a project-size-scaled exploration-call budget in navigation // tools' descriptions so the model self-throttles. Runs before the // deferred-vs-live split so a tool keeps the hint after promotion. - s.annotateToolBudget(&tool) + s.annotateToolBudget(tool) // Replace the recurring-parameter prose (format / max_bytes / cursor / // fields / scope / repo / project / workspace / ref) with a terse gloss; // the full semantics live once in the server instructions legend. Runs // before the split so deferred tools carry the compact schema too. - compactSharedToolParams(&tool) - if s.lazy != nil && s.lazy.IsDeferred(tool.Name) { - s.lazy.Register(tool, handler) - return + compactSharedToolParams(tool) + // Capture the finished schema plus the unwrapped legacy implementation + // before lazy routing. Reused facade names receive a compatibility wrapper + // that keeps their old call shape outside facade-v1 sessions. + if s.facades != nil { + s.facades.capture(*tool, handler) + handler = s.wrapLegacyFacade(tool.Name, handler) } - s.mcpServer.AddTool(tool, s.wrapToolHandler(handler)) + return handler } // attachLazyRegistry wires the deferred catalog to the live MCP @@ -2539,6 +2785,21 @@ func (s *Server) EnsureToolPromoted(name string) bool { return len(s.lazy.Promote(name)) > 0 } +// EnsureToolPromotedForSession is the per-connection promote-on-demand entry +// point used by the daemon dispatcher. Live/absent names take the cheap no-op +// path; a genuinely deferred name is promoted only when ctx's effective +// surface permits calling it. This prevents a hide-mode request from mutating +// the shared lazy registry before the MCP call gate rejects the tool. +func (s *Server) EnsureToolPromotedForSession(ctx context.Context, name string) bool { + if s == nil || s.lazy == nil || name == "" || !s.lazy.IsDeferred(name) { + return false + } + if !s.IsToolEnabledForSession(ctx, name) { + return false + } + return s.EnsureToolPromoted(name) +} + // SetContractRegistry sets an explicit contract registry override for the MCP // server. Used by single-indexer callers and tests. In multi-repo mode the // server prefers a freshly-merged registry from MultiIndexer (see @@ -2604,8 +2865,16 @@ type watcherHistory interface { // a symbol change callback to record modifications in symbolHistory. // Accepts either a single-repo *indexer.Watcher or a multi-repo // *indexer.MultiWatcher — both satisfy watcherHistory. +func (s *Server) currentWatcher() watcherHistory { + s.watcherMu.RLock() + defer s.watcherMu.RUnlock() + return s.watcher +} + func (s *Server) SetWatcher(w watcherHistory) { + s.watcherMu.Lock() s.watcher = w + s.watcherMu.Unlock() // Register callback to track symbol modifications for // get_symbol_history AND fan stale_refs notifications to any diff --git a/internal/mcp/session_ctx.go b/internal/mcp/session_ctx.go index 256323ba1..6d8c30634 100644 --- a/internal/mcp/session_ctx.go +++ b/internal/mcp/session_ctx.go @@ -117,8 +117,9 @@ func repoAllowFromContext(ctx context.Context) map[string]bool { // stay on *Server directly or are referenced via pointers that all // sessions share. type sessionLocal struct { - session *sessionState - tokenStats *tokenStats + session *sessionState + tokenStats *tokenStats + localization *localizationTerminalState } // newSessionLocal constructs a fresh per-session state container. The @@ -130,7 +131,8 @@ type sessionLocal struct { // shared default reflects daemon-wide live activity. func newSessionLocal(id string, persistent *savings.Store, repoPath string, parent *tokenStats) *sessionLocal { return &sessionLocal{ - session: newSessionState(), + session: newSessionState(), + localization: newLocalizationTerminalState(), tokenStats: &tokenStats{ persistent: persistent, repoPath: repoPath, diff --git a/internal/mcp/symbol_history_bounds_test.go b/internal/mcp/symbol_history_bounds_test.go new file mode 100644 index 000000000..2d48d80c6 --- /dev/null +++ b/internal/mcp/symbol_history_bounds_test.go @@ -0,0 +1,84 @@ +package mcp + +import ( + "fmt" + "testing" +) + +func TestSymbolHistoryBoundsPerSymbol(t *testing.T) { + history := &symbolHistory{} + for i := 0; i < maxSymbolHistoryPerSymbol+17; i++ { + history.Record("hot-symbol", i%2 == 0) + } + + mods := history.Get("hot-symbol") + if len(mods) != maxSymbolHistoryPerSymbol { + t.Fatalf("modifications = %d, want %d", len(mods), maxSymbolHistoryPerSymbol) + } + all := history.All() + if len(all) != 1 || len(all["hot-symbol"]) != maxSymbolHistoryPerSymbol { + t.Fatalf("unexpected bounded snapshot: %#v", all) + } +} + +func TestSymbolHistoryBoundsAggregateAndEvictsLRU(t *testing.T) { + history := &symbolHistory{} + for i := 0; i < maxSymbolHistorySymbols; i++ { + history.Record(fmt.Sprintf("symbol-%03d", i), false) + } + + // Refresh the first symbol so adding one more evicts symbol-001 instead. + history.Record("symbol-000", true) + history.Record("symbol-new", false) + + all := history.All() + if len(all) != maxSymbolHistorySymbols { + t.Fatalf("symbols = %d, want %d", len(all), maxSymbolHistorySymbols) + } + if _, ok := all["symbol-000"]; !ok { + t.Fatal("recently touched symbol was evicted") + } + if _, ok := all["symbol-001"]; ok { + t.Fatal("least-recently-used symbol was retained") + } + if _, ok := all["symbol-new"]; !ok { + t.Fatal("new symbol missing") + } + + total := 0 + for _, mods := range all { + total += len(mods) + if len(mods) > maxSymbolHistoryPerSymbol { + t.Fatalf("per-symbol history exceeded cap: %d", len(mods)) + } + } + if total > maxSymbolHistorySymbols*maxSymbolHistoryPerSymbol { + t.Fatalf("aggregate history = %d, cap %d", total, maxSymbolHistorySymbols*maxSymbolHistoryPerSymbol) + } +} + +func TestSymbolHistoryConcurrentRecordStaysBounded(t *testing.T) { + history := &symbolHistory{} + done := make(chan struct{}) + for worker := 0; worker < 16; worker++ { + go func(worker int) { + defer func() { done <- struct{}{} }() + for i := 0; i < 1000; i++ { + history.Record(fmt.Sprintf("symbol-%03d", (worker*1000+i)%400), i%2 == 0) + } + }(worker) + } + for worker := 0; worker < 16; worker++ { + <-done + } + + all := history.All() + if len(all) > maxSymbolHistorySymbols { + t.Fatalf("symbols = %d, cap %d", len(all), maxSymbolHistorySymbols) + } + for id, mods := range all { + if len(mods) > maxSymbolHistoryPerSymbol { + t.Fatalf("%s modifications = %d, cap %d", id, len(mods), maxSymbolHistoryPerSymbol) + } + } +} diff --git a/internal/mcp/tool_catalog.go b/internal/mcp/tool_catalog.go index e33f1bab5..d416ee9a1 100644 --- a/internal/mcp/tool_catalog.go +++ b/internal/mcp/tool_catalog.go @@ -97,6 +97,9 @@ func presetsContaining(name string) []string { if toolSetContains(localizationPresetTools, name) { presets = append(presets, "localization") } + if toolSetContains(facadePresetTools, name) { + presets = append(presets, FacadeSurfaceVersion) + } if !daemon.IsMutating(name) { presets = append(presets, "readonly") } diff --git a/internal/mcp/tool_categories.go b/internal/mcp/tool_categories.go index cd51de6e4..0709270f8 100644 --- a/internal/mcp/tool_categories.go +++ b/internal/mcp/tool_categories.go @@ -28,6 +28,14 @@ const ( // does not reveal (or actively misleads about) their family — e.g. // edit_memory starts with "edit_" but belongs to the memory family. var toolCategoryOverrides = map[string]string{ + // facade-v1 dispatchers + "explore": toolCatNav, "search": toolCatNav, "relations": toolCatNav, "trace": toolCatNav, + "read": toolCatRead, "edit": toolCatEdit, "refactor": toolCatEdit, + "change": toolCatOverlay, "overlay": toolCatOverlay, + "publish_review": toolCatReview, "pr": toolCatPR, + "recall": toolCatMemory, "remember": toolCatMemory, + "workspace": toolCatWorkspace, "workspace_admin": toolCatWorkspace, + "session": toolCatAdmin, "capabilities": toolCatAdmin, "response": toolCatOther, // nav (no find_/search_ prefix) "smart_context": toolCatNav, "nav": toolCatNav, "walk_graph": toolCatNav, "graph_query": toolCatNav, "trace_path": toolCatNav, "flow_between": toolCatNav, diff --git a/internal/mcp/tool_effects_test.go b/internal/mcp/tool_effects_test.go new file mode 100644 index 000000000..fd51a2456 --- /dev/null +++ b/internal/mcp/tool_effects_test.go @@ -0,0 +1,44 @@ +package mcp + +import ( + "testing" + + "github.com/zzet/gortex/internal/daemon" +) + +// TestLegacyEffectRegistryNamesAreRegistered closes the other half of the +// effect audit: daemon tests assert every known writer has the right effect, +// while this protocol-side test asserts those effect entries still name real +// legacy MCP tools. Facade names are validated by the facade surface tests. +func TestLegacyEffectRegistryNamesAreRegistered(t *testing.T) { + srv := newFullTestServer(t) + registered := make(map[string]bool) + for _, descriptor := range srv.ToolDescriptors() { + registered[descriptor.Name] = true + } + + facades := map[string]bool{ + "edit": true, "refactor": true, "remember": true, + "workspace_admin": true, "publish_review": true, + "overlay": true, "session": true, + } + // These legacy tools are registered only when their optional subsystem is + // enabled (multi-repo, overlays, proxy routing, or lazy discovery), which + // the minimal full test server intentionally does not initialize. + conditionalLegacy := map[string]bool{ + "overlay_delete": true, "overlay_drop": true, "overlay_drop_branch": true, + "overlay_fork": true, "overlay_keepalive": true, "overlay_merge": true, + "overlay_push": true, "overlay_register": true, "overlay_switch": true, + "proxy_disable": true, "proxy_enable": true, + "set_active_project": true, "tools_search": true, + "track_repository": true, "untrack_repository": true, + } + for name := range daemon.ToolEffects { + if facades[name] || conditionalLegacy[name] { + continue + } + if !registered[name] { + t.Errorf("daemon.ToolEffects classifies unknown legacy MCP tool %q", name) + } + } +} diff --git a/internal/mcp/tool_presets.go b/internal/mcp/tool_presets.go index eeb225b34..d855d0863 100644 --- a/internal/mcp/tool_presets.go +++ b/internal/mcp/tool_presets.go @@ -18,10 +18,11 @@ import ( // toolPolicy. Zero value (empty preset, no deltas) means "no // restriction" — the full surface. type ToolPolicyConfig struct { - Preset string - Mode string // "hide" | "defer" — default hide - Allow []string - Deny []string + Preset string + Mode string // "hide" | "defer" — default hide + Allow []string + Deny []string + OperatorPinned bool // explicit config provenance, including core/defer } const ( @@ -82,13 +83,12 @@ var corePresetTools = []string{ "surface_memories", "save_note", "store_memory", } -// agentFloorTools is the measured coding-agent working set — the tools a -// headless coding agent actually reaches for across a navigate → read → -// edit → verify cycle, from adoption probes and dogfooding. This is the -// FLOOR: it never shrinks, and it is the default eager surface for known -// coding-agent clients. Everything else defers behind tools_search (still -// callable by name via promote-on-demand). tool_profile / tools_search are -// always kept on top (isAlwaysKeptTool). +// agentFloorTools is the measured working set retained by the legacy `agent` +// compatibility preset. It covers a navigate → read → edit → verify +// cycle; everything else defers behind tools_search. Named MCP clients now use +// the compact surface unless a higher-precedence policy explicitly selects +// this preset. tool_profile / tools_search are always kept on top +// (isAlwaysKeptTool). var agentFloorTools = []string{ // orient — explore is the one-shot localization verb: the obvious // opening move for any task-shaped request. It returns the ranked @@ -124,6 +124,11 @@ var agentTailTools = []string{ // regression test (TestAgentPresetByteCeiling). var agentPresetTools = append([]string{}, agentFloorTools...) +// facadePresetTools is the complete, static facade-v1 surface. All names are +// registered live and carry compact schemas; capabilities discovers operation +// details without promoting more tools into tools/list. +var facadePresetTools = facadeToolNames() + // editPresetTools is the minimal headless code-editing surface: orient, // navigate, mutate, verify. Sized so an agent can edit code safely on a // remote box without the full 170-tool catalogue. tool_profile and @@ -174,6 +179,8 @@ func builtinToolPresetSet(name string) (set map[string]bool, denyMutating, known return toToolSet(corePresetTools), false, true case "agent", "coding-agent": return toToolSet(agentPresetTools), false, true + case FacadeSurfaceVersion, "compact", "facade", "agent-v2": + return toToolSet(facadePresetTools), false, true case "readonly", "read-only", "read_only": return nil, true, true case "edit", "editor", "edit-harness": @@ -188,7 +195,7 @@ func builtinToolPresetSet(name string) (set map[string]bool, denyMutating, known } // builtinPresetNames lists the recognised preset names for diagnostics. -var builtinPresetNames = []string{"agent", "core", "full", "readonly", "edit", "nav", "localization"} +var builtinPresetNames = []string{"agent", FacadeSurfaceVersion, "core", "full", "readonly", "edit", "nav", "localization"} // toolPolicy is the resolved, in-memory restriction applied to the tool // surface by the lazy registry (defer mode) and toolSurfaceFilter / @@ -260,6 +267,8 @@ func newToolPolicy(cfg ToolPolicyConfig, logger *zap.Logger) *toolPolicy { label = "core" case "coding-agent": label = "agent" + case "compact", "facade", "agent-v2": + label = FacadeSurfaceVersion case "locate", "find": label = "localization" } @@ -273,6 +282,17 @@ func newToolPolicy(cfg ToolPolicyConfig, logger *zap.Logger) *toolPolicy { } label = "full" } + if label == FacadeSurfaceVersion && (len(allow) > 0 || len(deny) > 0) { + // This is a closed protocol contract: tools/list and the hard call gate + // must always agree on the same 21 names. Use a legacy/custom preset when + // a per-tool surface is required. + if logger != nil { + logger.Warn("compact MCP surface ignores allow/deny deltas", + zap.Strings("allow", cfg.Allow), zap.Strings("deny", cfg.Deny)) + } + allow = nil + deny = nil + } active := explicit != nil || denyMutating || len(allow) > 0 || len(deny) > 0 return &toolPolicy{ preset: label, @@ -282,7 +302,7 @@ func newToolPolicy(cfg ToolPolicyConfig, logger *zap.Logger) *toolPolicy { allow: allow, deny: deny, active: active, - lean: label == "agent" || label == "localization", + lean: label == "agent" || label == "localization" || label == FacadeSurfaceVersion, } } @@ -304,6 +324,12 @@ func (p *toolPolicy) allows(name string) bool { return false } if isAlwaysKeptTool(name) { + // capabilities replaces legacy discovery/introspection on the closed + // facade-v1 surface. Keeping these two names would make tools/list and + // the hard call gate disagree. + if p.preset == FacadeSurfaceVersion { + return false + } return true } if p.allow[name] { @@ -460,13 +486,12 @@ func (s *ToolSurface) Preset() string { return s.p.preset } -// effectiveSessionPolicy resolves the tool-surface policy in force for -// the current request's session. Precedence: a client-forwarded preset / -// spec (GORTEX_TOOLS / --tools of the `gortex mcp` proxy, relayed through -// the daemon handshake) wins; else the client-aware preset default (a -// known coding-agent client gets the lean `agent` surface); else the -// server's global preset (the `core` default). The result is cached on the -// session so it is derived once, not on every tools/list. Never nil. +// effectiveSessionPolicy resolves the tool-surface policy in force for the +// current request's session. A forwarded, operator-pinned, or active-profile +// selection wins; otherwise every identified client gets the compact closed +// surface and an unidentified/pre-initialize session keeps the server default. +// The result is cached on the session so it is derived once, not on every +// tools/list. Never nil. // // This is the single authoritative resolution point the diet relies on: // wherever tools/list is answered on the daemon, the surface for THIS @@ -516,11 +541,22 @@ func (s *Server) resolveSessionPolicy(spec, mode, client string) *toolPolicy { switch { case strings.TrimSpace(mode) != "": cfg.Mode = mode + case isFacadePreset(cfg.Preset): + // facade-v1 is a closed, versioned contract. A bare forwarded + // GORTEX_TOOLS=facade-v1 must not inherit the daemon's usual + // core/defer mode and silently weaken its direct-call gate. + cfg.Mode = toolPolicyModeHide case s.toolPolicy != nil: cfg.Mode = s.toolPolicy.mode } return newToolPolicy(cfg, s.logger) } + // A deliberate server/operator policy outranks machine instruction + // profiles and client-aware defaults. Returning nil makes + // effectiveSessionPolicy fall back to the already-resolved global policy. + if s.toolPolicyOperatorPinned { + return nil + } if p := s.instructionProfilePolicy(); p != nil { return p } @@ -530,6 +566,15 @@ func (s *Server) resolveSessionPolicy(spec, mode, client string) *toolPolicy { return nil } +func isFacadePreset(name string) bool { + switch strings.ToLower(strings.TrimSpace(name)) { + case FacadeSurfaceVersion, "compact", "facade", "agent-v2": + return true + default: + return false + } +} + // activeInstructionPreset reads the machine's active instruction // profile and returns its tool preset ("" when the profile keeps the // defaults). Package var so tests can stub the machine state. @@ -566,6 +611,9 @@ func operatorPinnedToolPolicy(base ToolPolicyConfig) bool { if _, envSet := toolPolicyConfigFromEnv(); envSet { return true } + if base.OperatorPinned { + return true + } if len(base.Allow) > 0 || len(base.Deny) > 0 { return true } @@ -578,22 +626,17 @@ func operatorPinnedToolPolicy(base ToolPolicyConfig) bool { return true } -// clientDefaultPolicy returns the preset a known client should get when it -// forwarded no explicit tool spec, or nil to keep the server's global -// default. The default surface is client-aware: a known coding-agent client -// (the same set that defaults the wire format to GCX) gets the lean `agent` -// working set without any configuration; editors and unknown clients keep -// the server's global preset. GORTEX_TOOLS always overrides, because a -// forwarded spec is resolved before this in resolveSessionPolicy. +// clientDefaultPolicy returns the compact, closed coding surface for every +// identified MCP client. Surface selection is intentionally independent of +// wire-format decoding: an unknown editor can use the JSON-safe compact tools, +// while only decoder-allowlisted clients default to GCX. Empty/pre-initialize +// sessions retain the server default. Explicit forwarded, operator-pinned, and +// instruction-profile policies are resolved before this fallback. func (s *Server) clientDefaultPolicy(client string) *toolPolicy { - if !isKnownAgentClient(client) { + if strings.TrimSpace(client) == "" { return nil } - mode := toolPolicyModeDefer - if s.toolPolicy != nil && s.toolPolicy.mode != "" { - mode = s.toolPolicy.mode - } - return newToolPolicy(ToolPolicyConfig{Preset: "agent", Mode: mode}, s.logger) + return newToolPolicy(ToolPolicyConfig{Preset: FacadeSurfaceVersion, Mode: toolPolicyModeHide}, s.logger) } // toolPolicyBaseFromOptions extracts the config-supplied tool policy diff --git a/internal/mcp/tool_profile.go b/internal/mcp/tool_profile.go index 3554e6d4b..31e1f9ef8 100644 --- a/internal/mcp/tool_profile.go +++ b/internal/mcp/tool_profile.go @@ -6,6 +6,8 @@ import ( "sort" "github.com/mark3labs/mcp-go/mcp" + + "github.com/zzet/gortex/internal/daemon" ) // IsToolEnabled reports whether a tool is reachable in this server's @@ -64,11 +66,103 @@ func (s *Server) liveToolNames() []string { return out } +// sessionLiveToolNames returns the exact eagerly-visible surface for ctx. +// It deliberately drives the same per-session filter used by tools/list so +// client-aware presets, planning/workflow mode, learned promotions, and host +// exclusions cannot drift from what tool_profile reports. +func (s *Server) sessionLiveToolNames(ctx context.Context) []string { + registered := s.mcpServer.ListTools() + tools := make([]mcp.Tool, 0, len(registered)) + for _, entry := range registered { + if entry == nil { + continue + } + tools = append(tools, entry.Tool) + } + + tools = s.toolSurfaceFilter(ctx, tools) + out := make([]string, 0, len(tools)) + for _, tool := range tools { + out = append(out, tool.Name) + } + sort.Strings(out) + return out +} + +// registeredToolNames returns the complete catalog behind this server: both +// the currently registered MCP tools and the lazy registry's cold tools. +func (s *Server) registeredToolNames() []string { + names := make(map[string]bool) + for name := range s.mcpServer.ListTools() { + names[name] = true + } + if s.lazy != nil { + for _, name := range s.lazy.DeferredNames() { + names[name] = true + } + } + + out := make([]string, 0, len(names)) + for name := range names { + out = append(out, name) + } + sort.Strings(out) + return out +} + +// sessionToolBlocked reports whether a registered tool is intentionally +// unavailable in this session rather than merely withheld from tools/list. +func (s *Server) sessionToolBlocked(ctx context.Context, p *toolPolicy, name string) bool { + if p != nil && p.preset == FacadeSurfaceVersion && !isFacadeToolName(name) { + return true + } + if isDedicatedFacadeTool(name) { + return true + } + if name == "ask" { + if _, available := s.facades.legacy("ask"); !available { + return true + } + } + if p != nil && p.hideMode() && !s.sessionAllows(p, name) { + return true + } + if s.editingToolsHidden(ctx) && daemon.IsMutating(name) { + return true + } + return s.sessionHostContext(ctx).excluded[name] +} + +// sessionToolStatus classifies name against the effective surface for ctx, +// rather than the process-global registration split. +func (s *Server) sessionToolStatus(ctx context.Context, name string) string { + if !slices.Contains(s.registeredToolNames(), name) { + return "absent" + } + if slices.Contains(s.sessionLiveToolNames(ctx), name) { + return "live" + } + if s.sessionToolBlocked(ctx, s.effectiveSessionPolicy(ctx), name) { + return "blocked" + } + return "deferred" +} + +// IsToolEnabledForSession reports whether name is callable in ctx's effective +// surface. Unlike the legacy process-global IsToolEnabled helper, this honors +// client/preset negotiation plus planning, workflow, and host restrictions. +// The daemon dispatcher consults it before promote-on-demand so a rejected +// hidden call cannot mutate the shared lazy registry first. +func (s *Server) IsToolEnabledForSession(ctx context.Context, name string) bool { + status := s.sessionToolStatus(ctx, name) + return status == "live" || status == "deferred" +} + // registerToolProfileTool wires the `tool_profile` introspection tool. func (s *Server) registerToolProfileTool() { s.addTool( mcp.NewTool("tool_profile", - mcp.WithDescription("Report the active MCP tool profile so the agent knows what is actually available instead of guessing. With no arguments: returns `{lazy_enabled, total, live_count, deferred_count, live[], deferred[], scopes{}, categories{}}` plus `preset` / `preset_mode` when a tool preset narrows the surface — `live` tools are in the current tools/list, `deferred` tools are reachable via `tools_search`, and `categories` groups every tool into a functional family (nav / read / edit / analysis / review / pr / memory / overlay / subscription / enrich / workspace / admin). With `tool:\"\"`: returns `{tool, enabled, status, scope, category}` for that one tool (status ∈ live | deferred | blocked | absent)."), + mcp.WithDescription("Report the effective MCP tool profile for this session so the agent knows what is actually available instead of guessing. With no arguments: returns `{lazy_enabled, total, live_count, deferred_count, blocked_count, live[], deferred[], blocked[], scopes{}, categories{}}` plus `preset` / `preset_mode` when a tool preset narrows the surface — `live` exactly matches this session's current tools/list, `deferred` tools remain callable on demand, and `blocked` tools are prohibited by this session's hide/planning/workflow/host policy. With `tool:\"\"`: returns `{tool, enabled, status, scope, category}` for that one tool (status ∈ live | deferred | blocked | absent)."), mcp.WithString("tool", mcp.Description("Optional — report only this tool's enabled status and scope instead of the whole profile.")), ), s.handleToolProfile, @@ -81,30 +175,47 @@ func (s *Server) handleToolProfile(ctx context.Context, req mcp.CallToolRequest) // Per-tool advisory mode. if name, _ := args["tool"].(string); name != "" { + status := s.sessionToolStatus(ctx, name) return s.respondJSONOrTOON(ctx, req, map[string]any{ "tool": name, - "enabled": s.IsToolEnabled(name), - "status": s.toolStatus(name), + "enabled": status == "live" || status == "deferred", + "status": status, "scope": scopes[name], "category": toolCategory(name), }) } // Full-profile mode. - live := s.liveToolNames() - var deferred []string + p := s.effectiveSessionPolicy(ctx) + live := s.sessionLiveToolNames(ctx) + liveSet := make(map[string]bool, len(live)) + for _, name := range live { + liveSet[name] = true + } + var deferred, blocked []string + for _, name := range s.registeredToolNames() { + if liveSet[name] { + continue + } + if s.sessionToolBlocked(ctx, p, name) { + blocked = append(blocked, name) + continue + } + deferred = append(deferred, name) + } lazyEnabled := false if s.lazy != nil { lazyEnabled = s.lazy.Enabled() - deferred = s.lazy.DeferredNames() } profile := map[string]any{ "lazy_enabled": lazyEnabled, "total": len(live) + len(deferred), "live_count": len(live), "deferred_count": len(deferred), + "blocked_count": len(blocked), "live": live, "deferred": deferred, + "blocked": blocked, "scopes": scopes, "categories": toolCategories(append(append([]string{}, live...), deferred...)), // Per-tool metadata catalog (category / mutating / presets / @@ -112,20 +223,19 @@ func (s *Server) handleToolProfile(ctx context.Context, req mcp.CallToolRequest) // the socket instead of re-deriving each tool's classification. "descriptors": s.ToolDescriptors(), } - // Active tool preset (mcp.tools / GORTEX_TOOLS): report the preset - // name and mode so an agent knows its surface was deliberately - // narrowed rather than the daemon mis-registering tools. - if s.toolPolicy.isActive() { - profile["preset"] = s.toolPolicy.preset - profile["preset_mode"] = s.toolPolicy.mode + // Effective tool preset for THIS session (forwarded mcp.tools / + // GORTEX_TOOLS, client-aware default, then daemon global): report the + // name and mode so an agent knows its surface was deliberately narrowed + // rather than the daemon mis-registering tools. + if p != nil && p.isActive() { + profile["preset"] = p.preset + profile["preset_mode"] = p.mode } - // Per-host runtime context — the resolved host name and its guidance - // fragment, when the MCP client identified itself (host_context.go). + // Per-host runtime context. Guidance is emitted at initialize time from the + // effective tool policy; tool_profile may describe a legacy surface and + // therefore must not inject compact-only names. if hc := s.sessionHostContext(ctx); hc.name != "" { profile["host"] = hc.name - if hc.instruction != "" { - profile["host_instruction"] = hc.instruction - } } return s.respondJSONOrTOON(ctx, req, profile) } diff --git a/internal/mcp/tool_profile_test.go b/internal/mcp/tool_profile_test.go index 15b43c335..c64b0c1dc 100644 --- a/internal/mcp/tool_profile_test.go +++ b/internal/mcp/tool_profile_test.go @@ -1,7 +1,13 @@ package mcp import ( + "context" + "encoding/json" + "sort" "testing" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/require" ) func TestIsToolEnabled(t *testing.T) { @@ -79,3 +85,101 @@ func TestToolProfile_PerTool(t *testing.T) { t.Errorf("unknown tool status = %v, want absent", out["status"]) } } + +// TestToolProfile_CodexSessionMatchesToolsList is the protocol regression for +// the Codex discovery failure: initialize.clientInfo must select facade-v1, +// tools/list must carry the static read facade without promotion, and profile +// introspection must describe that same session rather than global core. +func TestToolProfile_CodexSessionMatchesToolsList(t *testing.T) { + srv := setupPresetServer(t, ToolPolicyConfig{Preset: "core", Mode: "defer"}) + ctx := WithSessionID(context.Background(), "sess_codex") + + initFrame := []byte(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"codex","version":"1.0"}}}`) + require.NotNil(t, srv.MCPServer().HandleMessage(ctx, initFrame)) + + listed := listToolNamesForSession(t, srv, "sess_codex") + require.True(t, listed["read"], "Codex must receive an eager source-read facade") + require.False(t, listed["read_file"], "Codex facade-v1 must not leak legacy reader names") + require.True(t, listed["analyze"], "Codex must receive the complete static facade surface") + + wantLive := append([]string{}, facadePresetTools...) + sort.Strings(wantLive) + gotLive := make([]string, 0, len(listed)) + for name := range listed { + gotLive = append(gotLive, name) + } + sort.Strings(gotLive) + require.Equal(t, wantLive, gotLive, "initialize(codex) must publish the exact eager agent roster") + + req := mcp.CallToolRequest{} + req.Params.Name = "tool_profile" + req.Params.Arguments = map[string]any{"format": "json"} + res, err := srv.handleToolProfile(ctx, req) + require.NoError(t, err) + require.False(t, res.IsError) + + var profile map[string]any + require.NoError(t, json.Unmarshal([]byte(res.Content[0].(mcp.TextContent).Text), &profile)) + require.Equal(t, FacadeSurfaceVersion, profile["preset"]) + require.Equal(t, "hide", profile["preset_mode"]) + require.Equal(t, float64(len(wantLive)), profile["live_count"]) + require.Equal(t, stringsToAny(wantLive), profile["live"], + "tool_profile.live must exactly match this session's tools/list") + + readReq := req + readReq.Params.Arguments = map[string]any{"format": "json", "tool": "read"} + readRes, err := srv.handleToolProfile(ctx, readReq) + require.NoError(t, err) + readProfile := unmarshalResult(t, readRes) + require.Equal(t, "live", readProfile["status"]) + require.Equal(t, true, readProfile["enabled"]) + + legacyReadReq := req + legacyReadReq.Params.Arguments = map[string]any{"format": "json", "tool": "read_file"} + analyzeRes, err := srv.handleToolProfile(ctx, legacyReadReq) + require.NoError(t, err) + analyzeProfile := unmarshalResult(t, analyzeRes) + require.Equal(t, "blocked", analyzeProfile["status"]) + require.Equal(t, false, analyzeProfile["enabled"]) +} + +func TestToolProfile_SessionHidePolicyReportsBlocked(t *testing.T) { + srv := setupPresetServer(t, ToolPolicyConfig{Preset: "core", Mode: "defer"}) + srv.NoteSessionToolPolicy("sess_nav", "nav", "hide") + navCtx := WithSessionID(context.Background(), "sess_nav") + + req := mcp.CallToolRequest{} + req.Params.Name = "tool_profile" + req.Params.Arguments = map[string]any{"format": "json", "tool": "edit_file"} + res, err := srv.handleToolProfile(navCtx, req) + require.NoError(t, err) + tool := unmarshalResult(t, res) + require.Equal(t, "blocked", tool["status"]) + require.Equal(t, false, tool["enabled"]) + + req.Params.Arguments = map[string]any{"format": "json"} + res, err = srv.handleToolProfile(navCtx, req) + require.NoError(t, err) + profile := unmarshalResult(t, res) + require.Equal(t, "nav", profile["preset"]) + require.Equal(t, "hide", profile["preset_mode"]) + require.Contains(t, profile["blocked"], "edit_file") + require.Equal(t, float64(len(listToolNamesForSession(t, srv, "sess_nav"))), profile["live_count"]) + + // A restrictive policy on one connection must not leak into another. + defaultCtx := WithSessionID(context.Background(), "sess_default") + req.Params.Arguments = map[string]any{"format": "json", "tool": "edit_file"} + res, err = srv.handleToolProfile(defaultCtx, req) + require.NoError(t, err) + defaultTool := unmarshalResult(t, res) + require.Equal(t, "live", defaultTool["status"]) + require.Equal(t, true, defaultTool["enabled"]) +} + +func stringsToAny(in []string) []any { + out := make([]any, len(in)) + for i, value := range in { + out[i] = value + } + return out +} diff --git a/internal/mcp/tools_analysis.go b/internal/mcp/tools_analysis.go index 64d573477..19354a479 100644 --- a/internal/mcp/tools_analysis.go +++ b/internal/mcp/tools_analysis.go @@ -4,6 +4,7 @@ import ( "context" "math" "strings" + "time" "github.com/mark3labs/mcp-go/mcp" "github.com/zzet/gortex/internal/analysis" @@ -248,6 +249,9 @@ func (s *Server) handleDetectChanges(ctx context.Context, req mcp.CallToolReques if repoRoot == "" { repoRoot = "." } + if freshnessErr := s.awaitMutationFreshnessForRepos(ctx, repoPrefix); freshnessErr != nil { + return mcp.NewToolResultError("change detection refused a stale graph: " + freshnessErr.Error()), nil + } diff, err := analysis.MapGitDiff(s.graph, repoRoot, repoPrefix, scope, baseRef) if err != nil { @@ -289,6 +293,18 @@ func (s *Server) handleDetectChanges(ctx context.Context, req mcp.CallToolReques return s.respondJSONOrTOON(ctx, req, detectResult) } +// tryImpactAnalysisSnapshots reads optional cached enrichments without waiting +// behind a background community/process rebuild. Impact is a mandatory safety +// gate; cached labels must never determine whether the core blast radius can +// return within its deadline. +func (s *Server) tryImpactAnalysisSnapshots() (*analysis.CommunityResult, *analysis.ProcessResult) { + if !s.analysisMu.TryRLock() { + return nil, nil + } + defer s.analysisMu.RUnlock() + return s.communities, s.processes +} + // handleEnhancedChangeImpact replaces the original explain_change_impact with risk tiering // and cross-community warnings. func (s *Server) handleEnhancedChangeImpact(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { @@ -302,11 +318,24 @@ func (s *Server) handleEnhancedChangeImpact(ctx context.Context, req mcp.CallToo ids[i] = strings.TrimSpace(ids[i]) } - impact := analysis.AnalyzeImpact(s.graph, ids, s.getCommunities(), s.getProcesses()) + if freshnessErr := s.awaitMutationFreshnessForRepos(ctx, s.mutationReposForSymbolIDs(ctx, ids)...); freshnessErr != nil { + return mcp.NewToolResultError("change impact refused a stale graph: " + freshnessErr.Error()), nil + } + + // Keep the mandatory pre-edit safety gate well below host transport + // timeouts. Every lower layer receives this deadline and must return a + // conservative truncated result rather than leaving the daemon busy after + // the client has already abandoned the call. + impactCtx, cancelImpact := context.WithTimeout(ctx, 3*time.Second) + defer cancelImpact() + communities, processes := s.tryImpactAnalysisSnapshots() + impact := analysis.AnalyzeImpactContext(impactCtx, s.graph, ids, communities, processes) result := map[string]any{ "risk": impact.Risk, "summary": impact.Summary, + "complete": impactComplete(impact), + "truncated": impact.Truncated, "by_depth": impact.ByDepth, "affected_processes": impact.AffectedProcesses, "affected_communities": impact.AffectedCommunities, @@ -343,34 +372,42 @@ func (s *Server) handleEnhancedChangeImpact(ctx context.Context, req mcp.CallToo // safe-to-change symbols apart from symbols the extractor never // wired up. Classify each input so a safety gate is not disarmed // by a false "0 affected". - if impact.TotalAffected == 0 { - var caveats []graph.ZeroImpactCaveat - for _, id := range ids { - if id == "" { - continue + if impact.TotalAffected == 0 && !impact.Truncated { + if _, inMemory := s.graph.(*graph.Graph); inMemory { + var caveats []graph.ZeroImpactCaveat + for _, id := range ids { + if id == "" { + continue + } + if c := graph.CaveatForZeroEdge(s.graph, id); c != nil { + caveats = append(caveats, graph.ZeroImpactCaveat{ + ID: id, + Class: c.Class, + Message: c.Message, + }) + } } - if c := graph.CaveatForZeroEdge(s.graph, id); c != nil { - caveats = append(caveats, graph.ZeroImpactCaveat{ - ID: id, - Class: c.Class, - Message: c.Message, - }) + if len(caveats) > 0 { + result["zero_impact_caveat"] = caveats } - } - if len(caveats) > 0 { - result["zero_impact_caveat"] = caveats + } else { + // Detailed extraction-gap classification performs additional graph + // reads. Keep the disk-backed safety gate deadline strict and state + // the uncertainty directly instead of risking another SQLite wait. + result["zero_impact_warning"] = "zero observed dependents is not proof of zero impact; extraction or resolution gaps may exist" } } // Cross-community warning if len(impact.AffectedCommunities) >= 2 { - communities := s.getCommunities() warning := s.computeCrossCommunityWarning(impact.AffectedCommunities, communities) result["cross_community_warning"] = warning } else { result["cross_community_warning"] = nil - if len(impact.AffectedCommunities) == 1 { + if len(impact.AffectedCommunities) == 1 && !impact.LowerBound { result["community_note"] = "change is community-local" + } else if len(impact.AffectedCommunities) == 1 { + result["community_scope"] = "incomplete — the bounded impact result cannot prove community locality" } } @@ -380,11 +417,13 @@ func (s *Server) handleEnhancedChangeImpact(ctx context.Context, req mcp.CallToo // before the edit lands. Live validate pass runs on the affected // contracts so existing breaking drift is reported alongside the // pending-change blast radius. - if ci := s.computeContractImpact(ids); ci != nil { - result["contract_impact"] = ci - if impact.Risk == analysis.RiskLow && ci.Breaking > 0 { - result["risk"] = analysis.RiskHigh - result["contract_risk_upgrade"] = "risk raised to HIGH — type is a contract boundary with breaking drift" + if impactCtx.Err() == nil { + if ci := s.computeContractImpactContext(impactCtx, ids); ci != nil { + result["contract_impact"] = ci + if impact.Risk == analysis.RiskLow && ci.Breaking > 0 { + result["risk"] = analysis.RiskHigh + result["contract_risk_upgrade"] = "risk raised to HIGH — type is a contract boundary with breaking drift" + } } } @@ -402,6 +441,10 @@ func (s *Server) handleEnhancedChangeImpact(ctx context.Context, req mcp.CallToo return s.respondJSONOrTOON(ctx, req, result) } +func impactComplete(impact *analysis.ImpactResult) bool { + return impact != nil && !impact.LowerBound +} + // ----------------------------------------------------------------------------- // Contract impact helper // ----------------------------------------------------------------------------- @@ -430,11 +473,30 @@ type contractImpactEntry struct { // registry and returns the ones whose request_type or response_type // matches any of the changed symbol IDs. Returns nil when nothing // matches so the JSON payload stays compact. +type contractImpactNodeContextGetter interface { + GetNodeContext(context.Context, string) (*graph.Node, error) +} + +// computeContractImpact keeps non-interactive callers bounded while the MCP +// impact handler supplies its stricter request-scoped deadline below. func (s *Server) computeContractImpact(changedIDs []string) *contractImpact { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + return s.computeContractImpactContext(ctx, changedIDs) +} + +func (s *Server) computeContractImpactContext(ctx context.Context, changedIDs []string) *contractImpact { + if ctx == nil { + ctx = context.Background() + } + if ctx.Err() != nil { + return nil + } reg := s.effectiveContractRegistry() - if reg == nil { + if reg == nil || ctx.Err() != nil { return nil } + allContracts := reg.All() changed := make(map[string]struct{}, len(changedIDs)) for _, id := range changedIDs { changed[id] = struct{}{} @@ -442,7 +504,10 @@ func (s *Server) computeContractImpact(changedIDs []string) *contractImpact { var entries []contractImpactEntry affectedIDs := make(map[string]struct{}) - for _, c := range reg.All() { + for _, c := range allContracts { + if ctx.Err() != nil { + return nil + } reqType := impactMetaString(c.Meta, "request_type") respType := impactMetaString(c.Meta, "response_type") if _, hit := changed[reqType]; hit && reqType != "" { @@ -467,13 +532,42 @@ func (s *Server) computeContractImpact(changedIDs []string) *contractImpact { // Validate the affected subset only — Validate on the full // registry would drown the payload in unrelated drift. sub := contracts.NewRegistry() - for _, c := range reg.All() { + for _, c := range allContracts { + if ctx.Err() != nil { + return nil + } if _, ok := affectedIDs[c.ID]; ok { sub.Add(c) } } + aborted := false lookup := contracts.ShapeLookup(func(id string) *contracts.Shape { - n := s.graph.GetNode(id) + if ctx.Err() != nil || s.graph == nil { + aborted = true + return nil + } + var n *graph.Node + if getter, ok := s.graph.(contractImpactNodeContextGetter); ok { + var err error + n, err = getter.GetNodeContext(ctx, id) + if err != nil { + aborted = true + return nil + } + } else { + // Third-party and in-memory stores retain the existing Store + // contract. Check cancellation immediately around the fallback; + // production SQLite implements GetNodeContext above. + if ctx.Err() != nil { + aborted = true + return nil + } + n = s.graph.GetNode(id) + if ctx.Err() != nil { + aborted = true + return nil + } + } if n == nil || n.Meta == nil { return nil } @@ -486,6 +580,9 @@ func (s *Server) computeContractImpact(changedIDs []string) *contractImpact { return nil }) issues := contracts.Validate(sub, lookup) + if aborted || ctx.Err() != nil { + return nil + } out := &contractImpact{Affected: entries} for _, is := range issues { @@ -530,72 +627,76 @@ type CrossCommunityWarning struct { } func (s *Server) computeCrossCommunityWarning(affectedCommunities []string, communities *analysis.CommunityResult) *CrossCommunityWarning { - warning := &CrossCommunityWarning{ - AffectedCommunities: affectedCommunities, + warning := &CrossCommunityWarning{AffectedCommunities: affectedCommunities} + if communities == nil || len(affectedCommunities) < 2 { + return warning } - if communities == nil { + // Impact is an interactive safety gate. Never materialise SQLite's entire + // edge table here: the previous implementation did that once per community + // pair, turning a small impact query into O(E*C²) database work and starving + // every other daemon request. Preserve the detailed score only for bounded + // in-memory graphs; disk-backed callers still receive the actionable list of + // affected communities and can request the dedicated coupling analysis. + const ( + maxImpactCouplingCommunities = 8 + maxImpactCouplingEdges = 50_000 + ) + memoryGraph, ok := s.graph.(*graph.Graph) + if !ok || len(affectedCommunities) > maxImpactCouplingCommunities || memoryGraph.EdgeCount() > maxImpactCouplingEdges { return warning } - // Build community label lookup - commLabels := make(map[string]string) - commMembers := make(map[string]map[string]bool) + commLabels := make(map[string]string, len(communities.Communities)) + commMembers := make(map[string]map[string]bool, len(affectedCommunities)) + affected := make(map[string]bool, len(affectedCommunities)) + for _, id := range affectedCommunities { + affected[id] = true + } for _, c := range communities.Communities { + if !affected[c.ID] { + continue + } commLabels[c.ID] = c.Label memberSet := make(map[string]bool, len(c.Members)) - for _, m := range c.Members { - memberSet[m] = true + for _, member := range c.Members { + memberSet[member] = true } commMembers[c.ID] = memberSet } - // For each pair of affected communities, compute coupling score + // Materialise the already-bounded in-memory edge set once, not once per + // pair. This keeps the compatibility detail inexpensive in tests and small + // embedded graphs while leaving the production SQLite path read-light. + edges := memoryGraph.AllEdges() for i := 0; i < len(affectedCommunities); i++ { for j := i + 1; j < len(affectedCommunities); j++ { - cA := affectedCommunities[i] - cB := affectedCommunities[j] - - membersA := commMembers[cA] - membersB := commMembers[cB] - + cA, cB := affectedCommunities[i], affectedCommunities[j] + membersA, membersB := commMembers[cA], commMembers[cB] if len(membersA) == 0 || len(membersB) == 0 { continue } - - // Count edges crossing the boundary and total edges in both communities - crossBoundary := 0 - totalEdges := 0 - - edges := s.graph.AllEdges() - for _, e := range edges { - inA := membersA[e.From] || membersA[e.To] - inB := membersB[e.From] || membersB[e.To] - + crossBoundary, totalEdges := 0, 0 + for _, edge := range edges { + inA := membersA[edge.From] || membersA[edge.To] + inB := membersB[edge.From] || membersB[edge.To] if inA || inB { totalEdges++ } - // Cross-boundary: one end in A, other in B - if (membersA[e.From] && membersB[e.To]) || (membersB[e.From] && membersA[e.To]) { + if (membersA[edge.From] && membersB[edge.To]) || (membersB[edge.From] && membersA[edge.To]) { crossBoundary++ } } - - var couplingScore float64 + var score float64 if totalEdges > 0 { - couplingScore = math.Round(float64(crossBoundary)/float64(totalEdges)*10000) / 100 + score = math.Round(float64(crossBoundary)/float64(totalEdges)*10_000) / 100 } - warning.Couplings = append(warning.Couplings, CommunityCoupling{ - CommunityA: cA, - CommunityB: cB, - LabelA: commLabels[cA], - LabelB: commLabels[cB], - CouplingScore: couplingScore, - TightlyCoupled: couplingScore > 15, + CommunityA: cA, CommunityB: cB, + LabelA: commLabels[cA], LabelB: commLabels[cB], + CouplingScore: score, TightlyCoupled: score > 15, }) } } - return warning } diff --git a/internal/mcp/tools_analysis_contract_context_test.go b/internal/mcp/tools_analysis_contract_context_test.go new file mode 100644 index 000000000..994e3b199 --- /dev/null +++ b/internal/mcp/tools_analysis_contract_context_test.go @@ -0,0 +1,53 @@ +package mcp + +import ( + "context" + "testing" + "time" + + "github.com/zzet/gortex/internal/contracts" + "github.com/zzet/gortex/internal/graph" +) + +type blockingContractNodeStore struct { + graph.Store +} + +func (s *blockingContractNodeStore) GetNode(string) *graph.Node { + panic("contract impact must use the context-aware node lookup") +} + +func (s *blockingContractNodeStore) GetNodeContext(ctx context.Context, _ string) (*graph.Node, error) { + <-ctx.Done() + return nil, ctx.Err() +} + +func TestComputeContractImpactContextStopsOnCancellation(t *testing.T) { + const typeID = "repo/model.go::Payload" + registry := contracts.NewRegistry() + registry.Add(contracts.Contract{ + ID: "GET /payload", + Role: contracts.RoleProvider, + Meta: map[string]any{"response_type": typeID}, + }) + registry.Add(contracts.Contract{ + ID: "GET /payload", + Role: contracts.RoleConsumer, + Meta: map[string]any{"response_type": typeID}, + }) + + server := &Server{ + graph: &blockingContractNodeStore{Store: graph.New()}, + contractRegistry: registry, + } + ctx, cancel := context.WithTimeout(context.Background(), 25*time.Millisecond) + defer cancel() + + started := time.Now() + if got := server.computeContractImpactContext(ctx, []string{typeID}); got != nil { + t.Fatalf("cancelled contract enrichment = %#v, want nil", got) + } + if elapsed := time.Since(started); elapsed > 250*time.Millisecond { + t.Fatalf("cancelled contract enrichment took %s; want <= 250ms", elapsed) + } +} diff --git a/internal/mcp/tools_analysis_warning_test.go b/internal/mcp/tools_analysis_warning_test.go new file mode 100644 index 000000000..33255006d --- /dev/null +++ b/internal/mcp/tools_analysis_warning_test.go @@ -0,0 +1,43 @@ +package mcp + +import ( + "context" + "reflect" + "testing" + + "github.com/zzet/gortex/internal/analysis" + "github.com/zzet/gortex/internal/graph" +) + +func TestComputeCrossCommunityWarningDoesNotScanGraph(t *testing.T) { + affected := []string{"community-a", "community-b"} + warning := (&Server{}).computeCrossCommunityWarning(affected, nil) + if warning == nil { + t.Fatal("expected a cross-community warning") + } + if !reflect.DeepEqual(warning.AffectedCommunities, affected) { + t.Fatalf("affected communities = %v, want %v", warning.AffectedCommunities, affected) + } + if len(warning.Couplings) != 0 { + t.Fatalf("mandatory impact path computed %d coupling(s); want no graph-wide coupling scan", len(warning.Couplings)) + } +} + +func TestImpactCompleteRejectsDispatchLowerBound(t *testing.T) { + const ( + seedID = "repo/impl.go::Service.Run" + targetID = "repo/api.go::Runner.Run" + ) + g := graph.New() + g.AddNode(&graph.Node{ID: seedID, Name: "Run", Kind: graph.KindMethod}) + g.AddNode(&graph.Node{ID: targetID, Name: "Run", Kind: graph.KindMethod}) + g.AddEdge(&graph.Edge{From: seedID, To: targetID, Kind: graph.EdgeImplements}) + + impact := analysis.AnalyzeImpactContext(context.Background(), g, []string{seedID}, nil, nil) + if !impact.LowerBound { + t.Fatal("implements boundary must make impact a lower bound") + } + if impactComplete(impact) { + t.Fatal("lower-bound impact must serialize complete=false") + } +} diff --git a/internal/mcp/tools_analyze_clusters.go b/internal/mcp/tools_analyze_clusters.go index 88367d2c0..c7d04e50c 100644 --- a/internal/mcp/tools_analyze_clusters.go +++ b/internal/mcp/tools_analyze_clusters.go @@ -44,6 +44,7 @@ func (s *Server) handleAnalyzeClusters(ctx context.Context, req mcp.CallToolRequ } var cr *analysis.CommunityResult + fullGraphBurst := false // incrStats is populated only on the Leiden path; it records // whether the partition was recomputed incrementally (only the // packages that changed since the last call) or in full. @@ -58,17 +59,24 @@ func (s *Server) handleAnalyzeClusters(ctx context.Context, req mcp.CallToolRequ // output. if resolution == 1.0 { cr, incrStats = s.incrementalCommunities() + fullGraphBurst = !incrStats.Incremental } else { cr = analysis.DetectCommunitiesLeidenWith(s.graph, analysis.LeidenOptions{Resolution: resolution}) + fullGraphBurst = true } case "louvain": cr = analysis.DetectCommunitiesLouvain(s.graph) + fullGraphBurst = true case "spectral": cr = analysis.SpectralClusters(s.graph) + fullGraphBurst = true default: return mcp.NewToolResultError("analyze clusters: unknown algorithm " + algorithm + " (expected: leiden, louvain, spectral)"), nil } + if fullGraphBurst { + defer scheduleOSMemoryReleaseAfterBurst(s.logger, "analyze_clusters") + } // Clamp the global partition to the session workspace so a // workspace-bound caller never sees clusters whose members live in a diff --git a/internal/mcp/tools_analyze_clusters_test.go b/internal/mcp/tools_analyze_clusters_test.go index e596849c0..0c30b8dcb 100644 --- a/internal/mcp/tools_analyze_clusters_test.go +++ b/internal/mcp/tools_analyze_clusters_test.go @@ -48,6 +48,10 @@ func newClustersTestServer(t *testing.T) *Server { // recomputes from the graph through the incremental path. s.analysisMu.Lock() s.communities = analysis.DetectCommunities(g) + token := s.currentCommunityToken() + s.communitiesToken = token + s.adjacencyToken = token + s.analysisEpoch = 1 s.analysisMu.Unlock() return s } diff --git a/internal/mcp/tools_analyze_impact.go b/internal/mcp/tools_analyze_impact.go index 6618bc4c9..29af9245f 100644 --- a/internal/mcp/tools_analyze_impact.go +++ b/internal/mcp/tools_analyze_impact.go @@ -81,12 +81,13 @@ type impactRow struct { Community float64 `json:"community"` // Raw inputs behind the axes, for explainability. - PageRank float64 `json:"pagerank"` - ReachCount int `json:"reach_count"` - Cyclomatic int `json:"cyclomatic"` - CoChangeFiles int `json:"co_change_files"` - CommunitySpan int `json:"community_span"` - FanIn int `json:"fan_in"` + PageRank float64 `json:"pagerank"` + ReachCount int `json:"reach_count"` + ReachLowerBound bool `json:"reach_lower_bound,omitempty"` + Cyclomatic int `json:"cyclomatic"` + CoChangeFiles int `json:"co_change_files"` + CommunitySpan int `json:"community_span"` + FanIn int `json:"fan_in"` } // handleAnalyzeImpactComposite ranks scoped symbols by composite @@ -123,8 +124,12 @@ func (s *Server) handleAnalyzeImpactComposite(ctx context.Context, req mcp.CallT allowedKinds = parseAnalyzeKindsFilter(k) } - // Co-change feeds one axis — make sure the git mine has run. - s.ensureCoChange() + // Legacy analyze historically starts the co-change mine lazily. The compact + // read-only adapter fixes refresh_cochange=false, so it consumes only the + // daemon-prewarmed cache and cannot cause durable EdgeCoChange writes. + if requestBoolDefault(req, "refresh_cochange", true) { + s.ensureCoChange() + } pr := s.getPageRank() maxPR := 0.0 @@ -223,11 +228,21 @@ func (s *Server) handleAnalyzeImpactComposite(ctx context.Context, req mcp.CallT centrality = 100 * prVal / maxPR } - // Reach: precomputed transitive set when the index is built, - // otherwise direct fan-in as the depth-1 proxy. + // Reach: consume an already-published transitive set when available, + // otherwise direct fan-in is the depth-1 proxy. This must be cache-only: + // invoking lazy BFS once per candidate turns a ranking call into an + // accidental eager all-symbol reach build. reachCount := fanIn[n.ID] - if d1, d2, d3, hit := reach.Lookup(s.graph, n.ID); hit { - reachCount = len(d1) + len(d2) + len(d3) + reachLowerBound := false + if d1, d2, d3, hit, bounded := reach.LookupCached(s.graph, n.ID); hit { + observed := len(d1) + len(d2) + len(d3) + if observed > reachCount { + reachCount = observed + } + reachLowerBound = bounded + } else if bounded { + // Lock/cancellation failure is not evidence of a small reach set. + reachLowerBound = true } reachScore := saturate(float64(reachCount), impactReachK) @@ -268,11 +283,15 @@ func (s *Server) handleAnalyzeImpactComposite(ctx context.Context, req mcp.CallT Community: roundTo(communityScore, 2), PageRank: prVal, ReachCount: reachCount, + ReachLowerBound: reachLowerBound, Cyclomatic: cyc, CoChangeFiles: ccFiles, CommunitySpan: span, FanIn: fanIn[n.ID], } + if reachLowerBound && row.Risk == "LOW" { + row.Risk = "MEDIUM" + } if minScore >= 0 && row.Score < minScore { continue } diff --git a/internal/mcp/tools_analyze_impact_test.go b/internal/mcp/tools_analyze_impact_test.go index b2d0cdf7a..d12a634d9 100644 --- a/internal/mcp/tools_analyze_impact_test.go +++ b/internal/mcp/tools_analyze_impact_test.go @@ -39,8 +39,8 @@ func newImpactTestServer(t *testing.T) *Server { } s.analysisMu.Lock() s.pageRank = analysis.ComputePageRank(g) - s.communities = analysis.DetectCommunities(g) s.analysisMu.Unlock() + installCommunitiesForTest(s, analysis.DetectCommunities(g)) // Consume the co-change once-guard so ensureCoChange does not // shell out to git during the test. s.cochangeOnce.Do(func() {}) diff --git a/internal/mcp/tools_analyze_pagerank.go b/internal/mcp/tools_analyze_pagerank.go index 5a24b9c01..198457231 100644 --- a/internal/mcp/tools_analyze_pagerank.go +++ b/internal/mcp/tools_analyze_pagerank.go @@ -75,13 +75,16 @@ func (s *Server) handleAnalyzePageRank(ctx context.Context, req mcp.CallToolRequ prLimit = 0 } - hits := s.runPageRank(graph.PageRankOpts{ + hits, fullGraphBurst := s.runPageRank(graph.PageRankOpts{ NodeKinds: nodeKinds, DampingFactor: damping, MaxIterations: maxIter, Tolerance: tolerance, Limit: prLimit, }) + if fullGraphBurst { + defer scheduleOSMemoryReleaseAfterBurst(s.logger, "analyze_pagerank") + } // Narrow to the session workspace + optional repo allow-set, then // re-apply the original limit (runPageRank ran unbounded above under @@ -143,12 +146,12 @@ func (s *Server) handleAnalyzePageRank(ctx context.Context, req mcp.CallToolRequ // runPageRank picks the engine-native PageRanker when the // backing store implements it, otherwise falls back to the // in-process power iteration. -func (s *Server) runPageRank(opts graph.PageRankOpts) []graph.PageRankHit { +func (s *Server) runPageRank(opts graph.PageRankOpts) ([]graph.PageRankHit, bool) { if store := s.backendStore(); store != nil { if pr, ok := store.(graph.PageRanker); ok { hits, err := pr.PageRank(opts) if err == nil { - return hits + return hits, false } // Fall through to the in-process path on backend // error rather than surface a half-completed @@ -164,7 +167,7 @@ func (s *Server) runPageRank(opts graph.PageRankOpts) []graph.PageRankHit { // NodeKinds filter is honoured by post-filtering the result. res := analysis.ComputePageRank(s.graph) if res == nil || len(res.Scores) == 0 { - return nil + return nil, true } allow := makeKindAllow(opts.NodeKinds) hits := make([]graph.PageRankHit, 0, len(res.Scores)) @@ -178,7 +181,7 @@ func (s *Server) runPageRank(opts graph.PageRankOpts) []graph.PageRankHit { if opts.Limit > 0 && opts.Limit < len(hits) { hits = hits[:opts.Limit] } - return hits + return hits, true } // backendStore returns the underlying graph.Store the indexer diff --git a/internal/mcp/tools_analyze_role_test.go b/internal/mcp/tools_analyze_role_test.go index 8c9de6b0d..f3c823823 100644 --- a/internal/mcp/tools_analyze_role_test.go +++ b/internal/mcp/tools_analyze_role_test.go @@ -99,6 +99,10 @@ func TestAnalyzeRole_AdapterDetectedViaCommunities(t *testing.T) { "p/util.go::Leaf": "c-core", }, } + token := s.currentCommunityToken() + s.communitiesToken = token + s.adjacencyToken = token + s.analysisEpoch = 1 s.analysisMu.Unlock() out := callAnalyzeRole(t, s, map[string]any{}) diff --git a/internal/mcp/tools_analyze_string_downstream.go b/internal/mcp/tools_analyze_string_downstream.go index 5f704226e..680341863 100644 --- a/internal/mcp/tools_analyze_string_downstream.go +++ b/internal/mcp/tools_analyze_string_downstream.go @@ -204,27 +204,28 @@ func (s *Server) handleAnalyzeSQLRebuild(ctx context.Context, req mcp.CallToolRe )), nil } return s.respondJSONOrTOON(ctx, req, map[string]any{ - "strings_visited": stats.StringsVisited, - "tables_created": stats.TablesCreated, - "columns_created": stats.ColumnsCreated, - "query_edges_created": stats.QueryEdges, - "reads_col_edges": stats.ReadColEdges, - "writes_col_edges": stats.WriteColEdges, - "emitters_linked": stats.EmittersLinked, - "skipped": stats.Skipped, + "strings_visited": stats.StringsVisited, + "tables_created": stats.TablesCreated, + "columns_created": stats.ColumnsCreated, + "query_edges_created": stats.QueryEdges, + "reads_col_edges": stats.ReadColEdges, + "writes_col_edges": stats.WriteColEdges, + "emitters_linked": stats.EmittersLinked, + "skipped": stats.Skipped, }) } // handleAnalyzeSQLCallSites lists the call sites that execute SQL, // grouped by the calling symbol, with the tables each one touches and -// a read / write split. It re-derives the table / EdgeQueries layer -// from the string registry first (idempotent), so the view works even -// when sql_rebuild was not run explicitly. +// a read / write split. Legacy calls materialise the table / EdgeQueries layer +// first; the compact read-only adapter fixes materialize=false and requires an +// explicit effectful sql_rebuild operation when that layer is absent. // // Filters: name (call-site symbol name, case-insensitive), limit. func (s *Server) handleAnalyzeSQLCallSites(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - gortexsql.RebuildTablesFromStringRegistry(s.graph) - + if requestBoolDefault(req, "materialize", true) { + gortexsql.RebuildTablesFromStringRegistry(s.graph) + } args := req.GetArguments() nameFilter := strings.ToLower(strings.TrimSpace(stringArg(args, "name"))) limit := 20 @@ -274,8 +275,7 @@ func (s *Server) handleAnalyzeSQLCallSites(ctx context.Context, req mcp.CallTool // Scope filter: keep only call sites whose calling symbol is visible // to the current request. Tables are names (not node IDs) so they need // no pruning. total/truncated recompute below. No-op for an unbound - // request. (The RebuildTablesFromStringRegistry call above is left - // untouched — it is an idempotent graph mutation, not a row source.) + // request. if s.scopeFiltersActive(ctx) { kept := make([]*sqlCallSite, 0, len(rows)) for _, r := range rows { diff --git a/internal/mcp/tools_architecture_hierarchy_test.go b/internal/mcp/tools_architecture_hierarchy_test.go index fa3952b77..0ac8e9389 100644 --- a/internal/mcp/tools_architecture_hierarchy_test.go +++ b/internal/mcp/tools_architecture_hierarchy_test.go @@ -55,6 +55,10 @@ func newArchHierarchyTestServer(t *testing.T) *Server { "Save": "community-1", "Load": "community-1", }, } + token := s.currentCommunityToken() + s.communitiesToken = token + s.adjacencyToken = token + s.analysisEpoch = 1 s.analysisMu.Unlock() return s } diff --git a/internal/mcp/tools_architecture_test.go b/internal/mcp/tools_architecture_test.go index d64663910..389159455 100644 --- a/internal/mcp/tools_architecture_test.go +++ b/internal/mcp/tools_architecture_test.go @@ -105,6 +105,10 @@ func TestArchitecture_CommunitiesIncludedWhenAnalysisCached(t *testing.T) { }, Modularity: 0.5, } + token := s.currentCommunityToken() + s.communitiesToken = token + s.adjacencyToken = token + s.analysisEpoch = 1 s.analysisMu.Unlock() out := callArchitectureHandler(t, s, map[string]any{}) @@ -209,6 +213,10 @@ func TestArchitecture_TopCommunitiesCap(t *testing.T) { } s.analysisMu.Lock() s.communities = &analysis.CommunityResult{Communities: comms} + token := s.currentCommunityToken() + s.communitiesToken = token + s.adjacencyToken = token + s.analysisEpoch = 1 s.analysisMu.Unlock() out := callArchitectureHandler(t, s, map[string]any{"top_communities": 3}) diff --git a/internal/mcp/tools_cochange.go b/internal/mcp/tools_cochange.go index df24faf92..6015f4214 100644 --- a/internal/mcp/tools_cochange.go +++ b/internal/mcp/tools_cochange.go @@ -9,6 +9,7 @@ import ( "github.com/mark3labs/mcp-go/mcp" "github.com/zzet/gortex/internal/cochange" "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/runtimeactivity" ) // registerCoChangeTool wires find_co_changing_symbols — the MCP @@ -21,6 +22,7 @@ func (s *Server) registerCoChangeTool() { mcp.WithString("file_path", mcp.Description("File path to analyse directly. One of symbol_id / file_path is required.")), mcp.WithNumber("limit", mcp.Description("Cap the number of co-changing files returned (default: 20).")), mcp.WithNumber("min_score", mcp.Description("Drop co-change relationships scoring below this threshold, 0..1 (default: 0).")), + mcp.WithBoolean("refresh", mcp.Description("Start the legacy lazy git-history mine when the cache is cold (default true). Compact public analysis fixes this false; daemon prewarming remains independent.")), mcp.WithString("format", mcp.Description("Output format: json (default), gcx, or toon")), ), s.handleFindCoChangingSymbols, @@ -60,7 +62,9 @@ func (s *Server) handleFindCoChangingSymbols(ctx context.Context, req mcp.CallTo return mcp.NewToolResultError("target symbol has no file path"), nil } - s.ensureCoChange() + if requestBoolDefault(req, "refresh", true) { + s.ensureCoChange() + } scores := s.coChangeScores(targetFile) counts := s.coChangeCounts(targetFile) @@ -140,9 +144,9 @@ func (s *Server) handleFindCoChangingSymbols(ctx context.Context, req mcp.CallTo // turned every queued tool call into a blocked-for-60s caller. The // async shape keeps the request path off the slow path. // -// PrewarmCoChange (called from RunAnalysis at daemon-ready) fires -// the mine ahead of any user-visible call so the cache is already -// populated by the time the first find_co_changing_symbols arrives. +// PrewarmCoChange (called from RunAnalysis at daemon-ready) is the only normal +// entrypoint. Read-only query handlers never call ensureCoChange: doing so +// would make a query causally responsible for durable EdgeCoChange writes. // // Returning immediately means the first user call may see an empty // cache when the prewarm goroutine has not yet completed. That is @@ -203,6 +207,12 @@ func (s *Server) coChangeReady() bool { // `gortex enrich cochange` (or a cold reindex) — the lazy path does not // auto-re-mine once edges exist. func (s *Server) mineCoChange() { + runtimeactivity.Begin("cochange") + defer func() { + runtimeactivity.End("cochange") + scheduleOSMemoryReleaseAfterBurst(s.logger, "cochange") + }() + scores := map[string]map[string]float64{} counts := map[string]map[string]int{} diff --git a/internal/mcp/tools_coding.go b/internal/mcp/tools_coding.go index 913182f7a..c56eeaae0 100644 --- a/internal/mcp/tools_coding.go +++ b/internal/mcp/tools_coding.go @@ -12,6 +12,7 @@ import ( "time" "github.com/mark3labs/mcp-go/mcp" + "github.com/zzet/gortex/internal/agents" "github.com/zzet/gortex/internal/elide" "github.com/zzet/gortex/internal/graph" "github.com/zzet/gortex/internal/query" @@ -172,7 +173,7 @@ func (s *Server) registerCodingTools() { s.addTool( mcp.NewTool("rename_symbol", - mcp.WithDescription("Plans a coordinated multi-file rename for a symbol. Plan-only — this call never writes to disk. Returns {file, line, old_text, new_text, confidence, reason} for the definition, every graph usage (calls / references / instantiates), receiver-line edits when renaming a type, and test-function names that embed the old identifier. Apply the returned edits with batch_edit (preferred — dependency-ordered, atomic), edit_file, or edit_symbol."), + mcp.WithDescription("Plans a coordinated multi-file rename for a symbol. Plan-only — this call never writes to disk. Returns {file, line, old_text, new_text, confidence, reason} for the definition, every graph usage (calls / references / instantiates), receiver-line edits when renaming a type, and test-function names that embed the old identifier. Apply the returned edits with batch_edit (preferred — preflighted and dependency-ordered), edit_file, or edit_symbol."), mcp.WithString("id", mcp.Required(), mcp.Description("Symbol ID to rename (e.g. auth/token.go::validateToken)")), mcp.WithString("new_name", mcp.Required(), mcp.Description("New name for the symbol")), ), @@ -733,7 +734,8 @@ func (s *Server) handleFindImportPath(ctx context.Context, req mcp.CallToolReque } func (s *Server) handleGetRecentChanges(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - if s.watcher == nil { + watcher := s.currentWatcher() + if watcher == nil { return mcp.NewToolResultError("watch mode is not active"), nil } @@ -745,7 +747,7 @@ func (s *Server) handleGetRecentChanges(ctx context.Context, req mcp.CallToolReq if err != nil { return mcp.NewToolResultError("invalid timestamp: " + sinceStr), nil } - for _, ev := range s.watcher.HistorySince(t) { + for _, ev := range watcher.HistorySince(t) { changes = append(changes, map[string]any{ "file": ev.FilePath, "kind": ev.Kind, @@ -755,7 +757,7 @@ func (s *Server) handleGetRecentChanges(ctx context.Context, req mcp.CallToolReq }) } } else { - for _, ev := range s.watcher.History() { + for _, ev := range watcher.History() { changes = append(changes, map[string]any{ "file": ev.FilePath, "kind": ev.Kind, @@ -2639,6 +2641,11 @@ func (s *Server) handleEditSymbol(ctx context.Context, req mcp.CallToolRequest) if err != nil { return mcp.NewToolResultError(err.Error()), nil } + releaseMutation, lockErr := acquireMutationPath(ctx, absPath) + if lockErr != nil { + return mcp.NewToolResultError("edit cancelled while waiting for exclusive file access: " + lockErr.Error()), nil + } + defer releaseMutation() // Read the entire file ONCE — both the drift check and the // patch operate on the same byte snapshot so a concurrent @@ -2768,15 +2775,19 @@ func (s *Server) handleEditSymbol(ctx context.Context, req mcp.CallToolRequest) return s.respondJSONOrTOON(ctx, req, preview) } - // Write the file. - if err := os.WriteFile(absPath, newContentBytes, 0o644); err != nil { + // Write the file atomically while holding the path mutation lock. + perm := os.FileMode(0o644) + if info, statErr := os.Stat(absPath); statErr == nil { + perm = info.Mode().Perm() + } + if err := agents.AtomicWriteFile(absPath, newContentBytes, perm); err != nil { return mcp.NewToolResultError(fmt.Sprintf("could not write file: %v", err)), nil } sess := s.sessionFor(ctx) sess.recordModified(node.FilePath) sess.recordSymbol(id) - reindexed := s.reindexFile(absPath) + reindexOutcome := s.mutationReindexState(ctx, absPath) // Count lines changed. oldLines := strings.Count(oldSource, "\n") + 1 @@ -2789,15 +2800,15 @@ func (s *Server) handleEditSymbol(ctx context.Context, req mcp.CallToolRequest) "lines_after": newLines, "start_line": node.StartLine, "status": "applied", - "reindexed": reindexed, "new_sha": gitBlobSHA(newContentBytes), } if regionMatches.normalized { resp["eol_normalized"] = true } - if health := s.fileSyntaxHealth(node.FilePath, absPath); health != nil { - resp["syntax_health"] = health + if reindexOutcome.Err != nil { + resp["reindex_error"] = reindexOutcome.Err.Error() } + s.attachMutationFreshness(resp, node.FilePath, absPath, reindexOutcome) return s.respondJSONOrTOON(ctx, req, resp) } diff --git a/internal/mcp/tools_conflicts_test.go b/internal/mcp/tools_conflicts_test.go index f7eab2f57..524c806e3 100644 --- a/internal/mcp/tools_conflicts_test.go +++ b/internal/mcp/tools_conflicts_test.go @@ -134,12 +134,12 @@ func conflictsTestServer(t *testing.T) (*Server, string, string) { g.AddNode(&graph.Node{ID: idB, Kind: graph.KindFunction, Name: "DoB", FilePath: fileB, StartLine: 1, EndLine: 5}) srv := NewServer(query.NewEngine(g), g, nil, nil, zap.NewNop(), nil) - srv.communities = &analysis.CommunityResult{ + installCommunitiesForTest(srv, &analysis.CommunityResult{ Communities: []analysis.Community{ {ID: "comm-shared", Size: 7, Members: []string{idA, idB}}, }, NodeToComm: map[string]string{idA: "comm-shared", idB: "comm-shared"}, - } + }) return srv, fileA, fileB } @@ -271,7 +271,7 @@ func conflictsBudgetServer(t *testing.T) (*Server, map[string][]string, []forge. for c := 0; c < 3; c++ { comms[c] = analysis.Community{ID: "comm" + string(rune('A'+c)), Size: 5 + c, Members: members3[c]} } - srv.communities = &analysis.CommunityResult{Communities: comms, NodeToComm: nodeToComm} + installCommunitiesForTest(srv, &analysis.CommunityResult{Communities: comms, NodeToComm: nodeToComm}) return srv, files, prs } diff --git a/internal/mcp/tools_coupling_test.go b/internal/mcp/tools_coupling_test.go index 238bd37e0..4e8870504 100644 --- a/internal/mcp/tools_coupling_test.go +++ b/internal/mcp/tools_coupling_test.go @@ -104,8 +104,7 @@ func TestCoupling_InternalEdgesNotInCaCe(t *testing.T) { func TestCoupling_CommunityRollupHonored(t *testing.T) { s := newCouplingTestServer(t) - s.analysisMu.Lock() - s.communities = &analysis.CommunityResult{ + installCommunitiesForTest(s, &analysis.CommunityResult{ NodeToComm: map[string]string{ "api/h.go::Handle": "c-edge", "service/s.go::Process": "c-core", @@ -115,8 +114,7 @@ func TestCoupling_CommunityRollupHonored(t *testing.T) { {ID: "c-edge", Members: []string{"api/h.go::Handle"}}, {ID: "c-core", Members: []string{"service/s.go::Process", "repo/r.go::Fetch"}}, }, - } - s.analysisMu.Unlock() + }) out := callCouplingHandler(t, s, map[string]any{"unit": "community"}) diff --git a/internal/mcp/tools_enhancements.go b/internal/mcp/tools_enhancements.go index 896474f44..652340090 100644 --- a/internal/mcp/tools_enhancements.go +++ b/internal/mcp/tools_enhancements.go @@ -14,6 +14,7 @@ import ( "time" "github.com/mark3labs/mcp-go/mcp" + "github.com/zzet/gortex/internal/agents" "github.com/zzet/gortex/internal/analysis" "github.com/zzet/gortex/internal/audit" "github.com/zzet/gortex/internal/blame" @@ -66,11 +67,10 @@ func (s *Server) ensureFresh(filePaths []string) []string { if !idx.IsTrackedStale(rel) { continue } - if err := idx.IndexFile(absPath); err != nil { + if !s.reindexFile(absPath) { s.logger.Warn("auto re-index failed", zap.String("file", fp), - zap.String("resolved", absPath), - zap.Error(err)) + zap.String("resolved", absPath)) continue } // Advance the recorded mtime so a follow-up read in the same window @@ -98,7 +98,7 @@ func (s *Server) freshnessIndexer(fp string) (*indexer.Indexer, string) { idx, _ := s.multiIndexer.IndexerForFile(abs) return idx, abs } - if s.indexer == nil || s.watcher != nil { + if s.indexer == nil || s.currentWatcher() != nil { return nil, "" } abs := fp @@ -202,6 +202,8 @@ func (s *Server) registerEnhancementTools() { mcp.WithNumber("min_axes", mcp.Description("(health_score) Require at least this many populated axes per row (default 1; raise to demand multi-signal confidence)")), mcp.WithString("roll_up", mcp.Description("(health_score) Aggregate per-symbol scores up to a coarser scope — 'file' (per-file average + per-grade counts) or 'repo' (per-repo). Omit for per-symbol rows.")), mcp.WithString("ids", mcp.Description("(impact) Comma-separated symbol IDs — score only these, the blast radius of changing specific symbols.")), + mcp.WithBoolean("refresh_cochange", mcp.Description("(impact) Start legacy lazy co-change mining when cold (default true). The compact public operation fixes this false.")), + mcp.WithBoolean("materialize", mcp.Description("(sql_call_sites) Rebuild SQL table/query edges before reading (legacy default true). The compact public operation fixes this false.")), mcp.WithString("name", mcp.Description("(named) The query bundle to run. Omit to list every available bundle.")), mcp.WithString("group_by", mcp.Description("(tests_as_edges) symbol (default — tested symbol → its tests) or test (test → symbols it exercises).")), mcp.WithString("algorithm", mcp.Description("(clusters) Community-detection algorithm — leiden (default), louvain, or spectral (recursive Fiedler-vector bisection).")), @@ -288,13 +290,15 @@ func (s *Server) registerEnhancementTools() { // batch_edit s.addTool( mcp.NewTool("batch_edit", - mcp.WithDescription("Applies multiple edits in one atomic, dependency-ordered batch. Symbol edits are ordered definitions→callers; the graph re-indexes after each edit; the batch stops on the first failure and skips the rest. Each edit is one of two operations selected by `op`:\n • edit_symbol (default): {id, old_source, new_source} — replace a fragment inside a symbol's body.\n • edit_file: {op:\"edit_file\", path, old_string, new_string, replace_all?} — replace a string in any file (imports, config, comments).\nPass `edits` as a JSON array of objects (a JSON-encoded string of the same array is also accepted for backward compatibility)."), - mcp.WithArray("edits", mcp.Required(), - mcp.Description("Edit operations. Each item is an edit_symbol or edit_file object selected by the `op` discriminator."), + mcp.WithDescription("Atomically applies a dependency-ordered edit set. Every guard and replacement is evaluated against one locked snapshot before any file is written; a commit failure restores all touched files. The durable transaction receipt survives response loss and daemon restart. Retry with the same transaction_id and identical edits to receive the original result without writing again, or omit edits and pass transaction_id to query status. Each edit is one of two operations selected by `op`:\n • edit_symbol (default): {id, old_source, new_source} — replace a fragment inside a symbol's body.\n • edit_file: {op:\"edit_file\", path, old_string, new_string, replace_all?} — replace a string in any file (imports, config, comments).\nPass `edits` as a JSON array of objects (a JSON-encoded string is accepted for compatibility)."), + mcp.WithArray("edits", + mcp.Description("Edit operations. Required for execution; omit only when querying an existing transaction. Each item is an edit_symbol or edit_file object selected by `op`."), mcp.Items(batchEditItemsSchema()), ), + mcp.WithString("transaction_id", mcp.Description("Stable caller-chosen idempotency key. Reusing it with identical edits returns the same receipt; a different payload is rejected. When omitted, the server creates a unique transaction ID.")), + mcp.WithBoolean("status_only", mcp.Description("Query transaction_id without executing edits. Edits may also simply be omitted.")), mcp.WithBoolean("dry_run", mcp.Description("Return the dependency-ordered plan without applying changes")), - mcp.WithBoolean("compact", mcp.Description("One-line-per-edit summary")), + mcp.WithBoolean("compact", mcp.Description("One-line transaction and per-edit summary")), ), s.handleBatchEdit, ) @@ -2431,6 +2435,9 @@ func (s *Server) handleFindHotspots(ctx context.Context, req mcp.CallToolRequest if v, ok := req.GetArguments()["threshold"].(float64); ok { threshold = v } + if threshold != 0 { + defer scheduleOSMemoryReleaseAfterBurst(s.logger, "analyze_hotspots") + } var entries []analysis.HotspotEntry if threshold == 0 { @@ -3319,11 +3326,17 @@ func (it batchEditItem) kind() string { // batchEditResult represents the outcome of a single edit in the batch. type batchEditResult struct { - Op string `json:"op,omitempty"` - SymbolID string `json:"id,omitempty"` - FilePath string `json:"path"` - Status string `json:"status"` // "applied", "failed", "skipped" - Error string `json:"error,omitempty"` + Op string `json:"op,omitempty"` + SymbolID string `json:"id,omitempty"` + FilePath string `json:"path"` + Status string `json:"status"` // "applied", "failed", "skipped" + Error string `json:"error,omitempty"` + Reindexed bool `json:"reindexed"` + ReindexPending bool `json:"reindex_pending,omitempty"` + ReindexReceipt string `json:"reindex_receipt,omitempty"` + ReindexGeneration uint64 `json:"reindex_generation,omitempty"` + ReindexAppliedGeneration uint64 `json:"reindex_applied_generation,omitempty"` + ReindexError string `json:"reindex_error,omitempty"` // EOLNormalized is true when the fragment only matched through the // CRLF<->LF-tolerant fallback and the replacement was written with the // file's own line terminators. @@ -3390,177 +3403,13 @@ func parseBatchEdits(raw any) ([]batchEditItem, error) { } func (s *Server) handleBatchEdit(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - edits, perr := parseBatchEdits(req.GetArguments()["edits"]) - if perr != nil { - return mcp.NewToolResultError(perr.Error()), nil - } - if len(edits) == 0 { - return mcp.NewToolResultError("edits array is empty"), nil - } - - dryRun := false - if v, ok := req.GetArguments()["dry_run"].(bool); ok { - dryRun = v - } - - // Sort edits in dependency order using get_edit_plan logic: - // Definitions first, then implementations, then callers. - type editWithOrder struct { - edit batchEditItem - op string - order int - file string - idx int - } - - var ordered []editWithOrder - for i, edit := range edits { - op := edit.kind() - if op == "edit_file" { - // File edits carry no graph dependency; apply them after all - // symbol edits (order 1000), preserving input order via idx. - ordered = append(ordered, editWithOrder{edit: edit, op: op, order: 1000, file: edit.Path, idx: i}) - continue - } - node := s.engineFor(ctx).GetSymbol(edit.SymbolID) - order := 50 // default middle priority - filePath := "" - if node != nil { - filePath = node.FilePath - // Definitions/interfaces get lowest order (edited first) - if node.Kind == graph.KindInterface || node.Kind == graph.KindType { - order = 0 - } else if node.Kind == graph.KindFunction || node.Kind == graph.KindMethod { - // Check if this symbol is depended on by other edits - for _, other := range edits { - if other.SymbolID == edit.SymbolID { - continue - } - // Check if other calls this symbol - callers := s.engineFor(ctx).GetCallers(edit.SymbolID, query.QueryOptions{Depth: 1, Limit: 100, Detail: "brief"}) - for _, cn := range callers.Nodes { - if cn.ID == other.SymbolID { - order = 10 // this is a dependency — edit first - break - } - } - } - if order == 50 { - order = 20 // regular function - } - } - } - ordered = append(ordered, editWithOrder{edit: edit, op: op, order: order, file: filePath, idx: i}) - } - - // Sort by order ascending (lowest = edit first), tie-broken by file then - // by original index so same-bucket edits stay in a deterministic order. - sort.SliceStable(ordered, func(i, j int) bool { - if ordered[i].order != ordered[j].order { - return ordered[i].order < ordered[j].order - } - if ordered[i].file != ordered[j].file { - return ordered[i].file < ordered[j].file - } - return ordered[i].idx < ordered[j].idx - }) - - if dryRun { - var plan []map[string]any - for i, o := range ordered { - entry := map[string]any{ - "order": i + 1, - "op": o.op, - "id": o.edit.SymbolID, - "path": o.file, - "status": "planned", - } - plan = append(plan, entry) - } - - if isCompact(req) { - var b strings.Builder - for _, p := range plan { - fmt.Fprintf(&b, "%s %s planned\n", p["id"], p["path"]) - } - return mcp.NewToolResultText(b.String()), nil - } - - return s.respondJSONOrTOON(ctx, req, map[string]any{ - "plan": plan, - "dry_run": true, - "total": len(plan), - }) - } - - // Apply edits sequentially, dispatching per operation kind. Stop on the - // first failure and mark the remainder skipped. - var results []batchEditResult - failed := false - - for _, o := range ordered { - if failed { - results = append(results, batchEditResult{ - Op: o.op, - SymbolID: o.edit.SymbolID, - FilePath: o.file, - Status: "skipped", - }) - continue - } - var r batchEditResult - switch o.op { - case "edit_file": - r = s.applyBatchFileEdit(o.edit) - default: - r = s.applyBatchSymbolEdit(ctx, o.edit) - } - results = append(results, r) - if r.Status == "failed" { - failed = true - } - } - - if isCompact(req) { - var b strings.Builder - for _, r := range results { - target := r.SymbolID - if target == "" { - target = r.FilePath - } - fmt.Fprintf(&b, "%s %s %s\n", r.Op, target, r.Status) - } - return mcp.NewToolResultText(b.String()), nil - } - - // Count statuses - applied, failedCount, skipped := 0, 0, 0 - for _, r := range results { - switch r.Status { - case "applied": - applied++ - case "failed": - failedCount++ - case "skipped": - skipped++ - } - } - - return s.respondJSONOrTOON(ctx, req, map[string]any{ - "results": results, - "summary": map[string]int{ - "applied": applied, - "failed": failedCount, - "skipped": skipped, - "total": len(results), - }, - }) + return s.handleAtomicBatchEdit(ctx, req) } // applyBatchSymbolEdit applies one edit_symbol operation: it locates the // symbol's source range, replaces old_source with new_source inside it, writes // the file, and re-indexes. Semantics match the legacy single-op batch_edit. -func (s *Server) applyBatchSymbolEdit(ctx context.Context, edit batchEditItem) batchEditResult { +func (s *Server) applyBatchSymbolEdit(ctx context.Context, edit batchEditItem, write bool) batchEditResult { res := batchEditResult{Op: "edit_symbol", SymbolID: edit.SymbolID} if edit.OldSource == edit.NewSource { res.Status, res.Error = "failed", "old_source and new_source are identical" @@ -3581,6 +3430,14 @@ func (s *Server) applyBatchSymbolEdit(ctx context.Context, edit batchEditItem) b res.Status, res.Error = "failed", resolveErr.Error() return res } + if write { + releaseMutation, lockErr := acquireMutationPath(ctx, absPath) + if lockErr != nil { + res.Status, res.Error = "failed", "edit cancelled while waiting for exclusive file access: "+lockErr.Error() + return res + } + defer releaseMutation() + } content, readErr := os.ReadFile(absPath) if readErr != nil { res.Status, res.Error = "failed", fmt.Sprintf("could not read file: %v", readErr) @@ -3588,72 +3445,93 @@ func (s *Server) applyBatchSymbolEdit(ctx context.Context, edit batchEditItem) b } fileStr := string(content) lines := strings.Split(fileStr, "\n") - if node.StartLine > len(lines) || node.EndLine > len(lines) { - res.Status, res.Error = "failed", "symbol line range exceeds file length" - return res - } - symbolSource := strings.Join(lines[node.StartLine-1:node.EndLine], "\n") - effectiveStart := node.StartLine - if findEOLMatches(symbolSource, edit.OldSource).count == 0 { - // Expand the window upward over preceding doc comments and blank - // lines — mirrors handleEditSymbol (agents often include the doc - // comment because get_symbol_source returns context above the - // symbol). - expandedStart := node.StartLine - 1 - for expandedStart > 0 { - trimmed := strings.TrimSpace(lines[expandedStart-1]) - if strings.HasPrefix(trimmed, "//") || strings.HasPrefix(trimmed, "/*") || - strings.HasPrefix(trimmed, "*") || trimmed == "" { - expandedStart-- - } else { - break + + // Prefer the indexed symbol range, including preceding documentation. If a + // prior edit in this batch shifted line numbers while watcher reindex is + // pending, fall back only when old_source is unique in the current file. + regionMatches := findEOLMatches(fileStr, edit.OldSource) + symbolStart := 0 + rangeMatched := false + if node.StartLine <= len(lines) && node.EndLine <= len(lines) { + symbolSource := strings.Join(lines[node.StartLine-1:node.EndLine], "\n") + effectiveStart := node.StartLine + if findEOLMatches(symbolSource, edit.OldSource).count == 0 { + expandedStart := node.StartLine - 1 + for expandedStart > 0 { + trimmed := strings.TrimSpace(lines[expandedStart-1]) + if strings.HasPrefix(trimmed, "//") || strings.HasPrefix(trimmed, "/*") || + strings.HasPrefix(trimmed, "*") || trimmed == "" { + expandedStart-- + } else { + break + } } - } - if expandedStart < node.StartLine-1 { - expanded := strings.Join(lines[expandedStart:node.EndLine], "\n") - if findEOLMatches(expanded, edit.OldSource).count > 0 { - symbolSource = expanded - effectiveStart = expandedStart + 1 + if expandedStart < node.StartLine-1 { + expanded := strings.Join(lines[expandedStart:node.EndLine], "\n") + if findEOLMatches(expanded, edit.OldSource).count > 0 { + symbolSource = expanded + effectiveStart = expandedStart + 1 + } } } + for i := 0; i < effectiveStart-1 && i < len(lines); i++ { + symbolStart += len(lines[i]) + 1 + } + symbolEnd := min(symbolStart+len(symbolSource), len(fileStr)) + candidate := findEOLMatches(fileStr[symbolStart:symbolEnd], edit.OldSource) + if candidate.count == 1 { + regionMatches = candidate + rangeMatched = true + } } - if findEOLMatches(symbolSource, edit.OldSource).count == 0 { - res.Status, res.Error = "failed", "old_source not found within symbol" - return res - } - symbolStart := 0 - for i := 0; i < effectiveStart-1 && i < len(lines); i++ { - symbolStart += len(lines[i]) + 1 - } - symbolEnd := min(symbolStart+len(symbolSource), len(fileStr)) - // EOL-tolerant region match: spans are byte offsets into the raw - // region, so the splice below always lands on the real on-disk bytes. - regionMatches := findEOLMatches(fileStr[symbolStart:symbolEnd], edit.OldSource) - if len(regionMatches.spans) == 0 { - res.Status, res.Error = "failed", "old_source not found in symbol region" - return res + if !rangeMatched { + symbolStart = 0 + switch regionMatches.count { + case 0: + res.Status, res.Error = "failed", "old_source not found within symbol or current file" + return res + case 1: + // Safe stale-range fallback. + default: + res.Status, res.Error = "failed", "symbol range is stale and old_source is not unique in the current file" + return res + } } span := regionMatches.spans[0] editStart := symbolStart + span.start editEnd := symbolStart + span.end effectiveNew := edit.NewSource if regionMatches.normalized { - // Rewrite new_source's terminators to the matched region's own - // style so the splice never introduces mixed line endings. effectiveNew = adaptToDominantEOL(edit.NewSource, fileStr[editStart:editEnd]) res.EOLNormalized = true } newContent := fileStr[:editStart] + effectiveNew + fileStr[editEnd:] - if regionMatches.normalized && newContent == fileStr { + if newContent == fileStr { res.Status, res.Error = "failed", "old_source and new_source are identical after line-ending normalization" return res } - if writeErr := os.WriteFile(absPath, []byte(newContent), 0o644); writeErr != nil { + if !write { + res.Status = "validated" + return res + } + perm := os.FileMode(0o644) + if info, statErr := os.Stat(absPath); statErr == nil { + perm = info.Mode().Perm() + } + if writeErr := agents.AtomicWriteFile(absPath, []byte(newContent), perm); writeErr != nil { res.Status, res.Error = "failed", fmt.Sprintf("could not write file: %v", writeErr) return res } - if s.indexer != nil { - _ = s.indexer.IndexFile(absPath) + sess := s.sessionFor(ctx) + sess.recordModified(node.FilePath) + sess.recordSymbol(edit.SymbolID) + reindexOutcome := s.mutationReindexState(ctx, absPath) + res.Reindexed, res.ReindexPending = reindexOutcome.Reindexed, reindexOutcome.Pending + res.ReindexReceipt = reindexOutcome.Receipt + res.ReindexGeneration = reindexOutcome.Generation + res.ReindexAppliedGeneration = reindexOutcome.AppliedGeneration + if reindexOutcome.Err != nil { + res.ReindexError = reindexOutcome.Err.Error() } res.Status = "applied" return res @@ -3662,7 +3540,7 @@ func (s *Server) applyBatchSymbolEdit(ctx context.Context, edit batchEditItem) b // applyBatchFileEdit applies one edit_file operation: it replaces old_string // with new_string in the file at path, mirroring edit_file's uniqueness and // replace_all semantics, then re-indexes. -func (s *Server) applyBatchFileEdit(edit batchEditItem) batchEditResult { +func (s *Server) applyBatchFileEdit(ctx context.Context, edit batchEditItem, write bool) batchEditResult { res := batchEditResult{Op: "edit_file", FilePath: edit.Path} if edit.Path == "" { res.Status, res.Error = "failed", "edit_file op requires path" @@ -3678,6 +3556,14 @@ func (s *Server) applyBatchFileEdit(edit batchEditItem) batchEditResult { return res } res.FilePath = relPath + if write { + releaseMutation, lockErr := acquireMutationPath(ctx, absPath) + if lockErr != nil { + res.Status, res.Error = "failed", "edit cancelled while waiting for exclusive file access: "+lockErr.Error() + return res + } + defer releaseMutation() + } content, readErr := os.ReadFile(absPath) if readErr != nil { res.Status, res.Error = "failed", fmt.Sprintf("could not read file: %v", readErr) @@ -3717,15 +3603,27 @@ func (s *Server) applyBatchFileEdit(edit batchEditItem) batchEditResult { default: newContent = strings.Replace(fileStr, edit.OldString, edit.NewString, 1) } + if !write { + res.Status = "validated" + return res + } perm := os.FileMode(0o644) - if info, e := os.Stat(absPath); e == nil { + if info, statErr := os.Stat(absPath); statErr == nil { perm = info.Mode().Perm() } - if writeErr := os.WriteFile(absPath, []byte(newContent), perm); writeErr != nil { + if writeErr := agents.AtomicWriteFile(absPath, []byte(newContent), perm); writeErr != nil { res.Status, res.Error = "failed", fmt.Sprintf("could not write file: %v", writeErr) return res } - s.reindexFile(absPath) + s.sessionFor(ctx).recordModified(relPath) + reindexOutcome := s.mutationReindexState(ctx, absPath) + res.Reindexed, res.ReindexPending = reindexOutcome.Reindexed, reindexOutcome.Pending + res.ReindexReceipt = reindexOutcome.Receipt + res.ReindexGeneration = reindexOutcome.Generation + res.ReindexAppliedGeneration = reindexOutcome.AppliedGeneration + if reindexOutcome.Err != nil { + res.ReindexError = reindexOutcome.Err.Error() + } res.Status = "applied" return res } diff --git a/internal/mcp/tools_explore.go b/internal/mcp/tools_explore.go index 619ebe882..70765f6f3 100644 --- a/internal/mcp/tools_explore.go +++ b/internal/mcp/tools_explore.go @@ -2,6 +2,7 @@ package mcp import ( "context" + "encoding/json" "fmt" "regexp" "sort" @@ -12,6 +13,7 @@ import ( "github.com/zzet/gortex/internal/elide" "github.com/zzet/gortex/internal/graph" "github.com/zzet/gortex/internal/query" + "github.com/zzet/gortex/internal/search" "github.com/zzet/gortex/internal/search/rerank" ) @@ -33,13 +35,18 @@ const exploreToolDescription = "Start here for any task, bug report, or " + // corpus, query vocabulary, or benchmark. The verb takes arbitrary free // text; nothing here is derived from a fixed task set. const ( - exploreDefaultBudgetTokens = 9000 - exploreMinBudgetTokens = 2000 + exploreDefaultBudgetTokens = 1600 + exploreMinBudgetTokens = 1000 exploreMaxBudgetTokens = 24000 exploreDefaultMaxSymbols = 10 exploreMaxMaxSymbols = 30 exploreRingCap = 5 // callers / callees shown per target exploreCharsPerToken = 4 // coarse token estimate for budgeting + // Full bodies are the largest repeated-context cost. Candidate headers, + // locations, signatures, and graph rings preserve recall for the whole + // neighborhood; only the top targets need full source to make the first + // response answer-ready. + exploreFullBodyLimit = 2 // exploreBodyBudgetShare caps any single full body at this fraction of // the total budget, so one huge top-ranked symbol cannot starve the // rest of the neighborhood of their bodies. @@ -56,7 +63,7 @@ func (s *Server) registerExploreTool() { mcp.WithDescription(exploreToolDescription), mcp.WithString("task", mcp.Required(), mcp.Description("Natural-language description of the task, bug report, or question to localize (e.g. paste an issue body, or 'the retry backoff never triggers on a 429').")), mcp.WithNumber("max_symbols", mcp.Description("Max ranked candidate symbols (default 10).")), - mcp.WithNumber("token_budget", mcp.Description("Response token ceiling (default 9000). Bodies pack until it fills, then demote to signatures; every candidate location is always listed.")), + mcp.WithNumber("token_budget", mcp.Description("Approximate source-packing budget in tokens (default 1600). Candidate locations and signatures are always listed and may exceed it; full source is reserved for the highest-ranked targets.")), mcp.WithString("repo", mcp.Description("Filter results to a specific repository prefix")), mcp.WithString("path", mcp.Description("Restrict the neighborhood to one or more sub-paths (comma-separated), anchored at the repo root — a monorepo-service slice.")), ), @@ -74,6 +81,969 @@ type exploreTarget struct { source string // full body (may be empty for non-source kinds) } +const ( + exploreDraftPrimaryLimit = 3 + exploreDraftTotalLimit = 5 +) + +type exploreDraftEntry struct { + node *graph.Node + evidence string + exact bool + overlap int + bodyOverlap int + rareOverlap int + bodyDensity int + generic bool + direct bool + structural bool + structuralShared int + structuralLocal bool + structuralCallee bool + structuralCrossFile bool + parentExact bool + parentOverlap int + parentRank int +} + +// exploreAnswerDraft puts a small, ready-to-use evidence set before the full +// neighborhood. The ranked head always leads; the remaining slots promote +// query-aligned deeper targets or graph neighbors without hiding any detailed +// candidate below. +func exploreQueryIsConceptTask(query string) bool { + query = strings.TrimSpace(stripLeadingExploreDirective(query)) + if query == "" { + return false + } + class := rerank.ClassifyQuery(query) + lower := strings.ToLower(query) + // Catch declaration-shaped signatures whose return type is project-specific + // and therefore may not be recognized by the generic query classifier. A + // call at the end of prose is not a declaration: its pre-call prefix is a + // sentence, not a compact return-type/name pair. + open, close := strings.Index(lower, "("), strings.LastIndex(lower, ")") + if open > 0 && close > open { + suffix := strings.TrimSpace(lower[close+1:]) + terminalDeclaration := suffix == "" || suffix == ";" || suffix == "{" || suffix == "const" || suffix == "noexcept" || strings.HasPrefix(suffix, "->") || strings.HasPrefix(suffix, ": ") || strings.HasPrefix(suffix, "throws ") + prefixFields := strings.Fields(strings.TrimSpace(lower[:open])) + if terminalDeclaration && len(prefixFields) >= 2 && len(prefixFields) <= 6 { + previous := strings.Trim(prefixFields[len(prefixFields)-2], "*&[]<>,:;") + switch previous { + case "a", "an", "and", "at", "before", "by", "calls", "does", "for", "from", "how", "in", "into", "of", "on", "or", "reaches", "the", "through", "to", "via", "when", "where", "why", "with": + // Natural-language call site; continue with concept detection. + default: + return false + } + } + } + if class == rerank.QueryClassConcept { + return true + } + + // A symbol, path, or signature embedded in a natural-language task must not + // turn the whole task into an exact lookup. Pure lookup shapes are compact; + // prose has several ordinary words around a bounded amount of code syntax. + fields := strings.Fields(query) + if len(fields) < 8 || len(query) < 48 { + return false + } + for _, prefix := range []string{ + "func ", "fn ", "pub ", "async ", "unsafe ", "extern ", + "def ", "class ", "interface ", "trait ", "type ", + "public ", "private ", "protected ", "internal ", "static ", + "export ", "declare ", "function ", "const ", "constexpr ", + "void ", "bool ", "char ", "int ", "long ", "short ", + "float ", "double ", "auto ", "unsigned ", "signed ", "std::", "@", + } { + if strings.HasPrefix(lower, prefix) { + return false + } + } + plain, codeShaped := 0, 0 + for _, field := range fields { + token := strings.Trim(field, ".,;:!?\"'") + if token == "" { + continue + } + if strings.ContainsAny(token, `/\\(){}[]<>=&|*_:;`) { + codeShaped++ + continue + } + plain++ + } + return plain >= 6 && plain*2 >= len(fields) && codeShaped*2 < len(fields) +} + +func exploreQueryHasCallAnchor(query, name string) bool { + name = strings.TrimSpace(name) + if name == "" { + return false + } + isIdentifierByte := func(b byte) bool { + return b == '_' || b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z' || b >= '0' && b <= '9' + } + for offset := 0; offset < len(query); { + index := strings.Index(query[offset:], name) + if index < 0 { + return false + } + index += offset + beforeOK := index == 0 || !isIdentifierByte(query[index-1]) + after := index + len(name) + for after < len(query) && (query[after] == ' ' || query[after] == '\t') { + after++ + } + if beforeOK && after < len(query) && query[after] == '(' { + return true + } + offset = index + len(name) + } + return false +} + +func exploreQueryHasPathSymbolAnchor(query string, n *graph.Node) bool { + if n == nil { + return false + } + normalizedPath := exploreNormalizedPath(nodeDisplayPath(n)) + name := exploreNormalizedAnchor(n.Name) + qualName := exploreNormalizedAnchor(n.RetrievalMetadata().QualName) + if qualName == "" || qualName == name { + if separator := strings.LastIndex(n.ID, "::"); separator >= 0 { + qualName = exploreNormalizedAnchor(n.ID[separator+2:]) + } + } + for _, field := range strings.Fields(query) { + field = strings.Trim(field, "`'\"()[]{}<>,;") + separator := strings.LastIndex(field, "::") + if separator <= 0 || !strings.ContainsAny(field[:separator], "/\\") { + continue + } + if exploreNormalizedPath(field[:separator]) != normalizedPath { + continue + } + symbol := exploreNormalizedAnchor(field[separator+2:]) + if symbol == name || symbol == qualName || strings.HasSuffix(qualName, " "+symbol) { + return true + } + } + return false +} + +func exploreAnswerDraft(task string, targets []exploreTarget) []exploreDraftEntry { + query := shapeExploreQuery(task) + conceptTask := exploreQueryIsConceptTask(query) + if conceptTask { + query = stripLeadingExploreDirective(query) + } + queryTerms := exploreTerminalTerms(query) + + // Cache body term sets once per direct candidate. The bounded frequency + // table supplies a small inverse-frequency/density tie-break without + // repeatedly tokenizing source or allowing body prose to dominate names. + bodyTermCache := make(map[string]map[string]struct{}, len(targets)) + candidateTermCache := make(map[string]map[string]struct{}, len(targets)) + termFrequency := make(map[string]int, len(queryTerms)) + if conceptTask { + for _, target := range targets { + if target.node == nil { + continue + } + key := exploreDraftNodeKey(target.node) + if _, cached := candidateTermCache[key]; cached { + continue + } + bodyTerms := exploreTerminalTerms(target.source) + bodyTermCache[key] = bodyTerms + candidateTerms := exploreDraftNodeTerms(target.node) + for term := range bodyTerms { + candidateTerms[term] = struct{}{} + } + candidateTermCache[key] = candidateTerms + for term := range queryTerms { + if _, matched := candidateTerms[term]; matched { + termFrequency[term]++ + } + } + } + } + makeEntry := func(n *graph.Node, source, evidence string, direct bool, parentRank int) (exploreDraftEntry, int) { + overlap, longest := exploreDraftTermOverlap(queryTerms, n) + explicitAnchor := exploreLocalizationExplicitAnchor(query, n) + exact := exploreDraftExactAnchor(query, n) || explicitAnchor + // Concept prompts often mention a nearby test helper while asking how the + // production path works. Do not let that lexical anchor outrank the + // implementation. A genuinely explicit symbol/path request remains exact. + conceptTestHelper := conceptTask && exploreDraftIsTestNode(n) && !explicitAnchor + if conceptTestHelper { + exact = false + } + bodyOverlap, rareOverlap, bodyDensity := 0, 0, 0 + if conceptTask && n != nil { + key := exploreDraftNodeKey(n) + bodyTerms, cached := bodyTermCache[key] + if !cached && source != "" { + bodyTerms = exploreTerminalTerms(source) + bodyTermCache[key] = bodyTerms + } + bodyOverlap = exploreDraftTermSetOverlap(queryTerms, bodyTerms) + if candidateTerms, cached := candidateTermCache[key]; cached { + for term := range queryTerms { + if termFrequency[term] == 1 { + if _, matched := candidateTerms[term]; matched && rareOverlap < 2 { + rareOverlap++ + } + } + } + } + denominator := len(bodyTerms) + if denominator > 12 { + denominator = 12 + } + if denominator > 0 && bodyOverlap >= 2 { + switch { + case bodyOverlap*2 >= denominator: + bodyDensity = 2 + case bodyOverlap*4 >= denominator: + bodyDensity = 1 + } + } + } + return exploreDraftEntry{ + node: n, + evidence: evidence, + exact: exact, + overlap: overlap, + bodyOverlap: bodyOverlap, + rareOverlap: rareOverlap, + bodyDensity: bodyDensity, + generic: conceptTask && !exact && (conceptTestHelper || exploreDraftGenericCandidate(n, source)), + direct: direct, + parentRank: parentRank, + }, longest + } + + entries := make([]exploreDraftEntry, 0, exploreDraftTotalLimit) + seen := make(map[string]struct{}, exploreDraftTotalLimit) + appendEntry := func(entry exploreDraftEntry) bool { + if entry.node == nil { + return false + } + key := exploreDraftNodeKey(entry.node) + if _, ok := seen[key]; ok { + return false + } + seen[key] = struct{}{} + entries = append(entries, entry) + return true + } + + // Preserve the retrieval head, then reserve at most one structural caller + // and one structural callee. The global quotas keep graph expansion useful + // without allowing a generic call neighborhood to consume the whole draft. + for i := 0; i < len(targets) && i < exploreDraftPrimaryLimit; i++ { + entry, _ := makeEntry(targets[i].node, targets[i].source, fmt.Sprintf("ranked #%d", i+1), true, i) + appendEntry(entry) + } + + var aligned, structuralNeighbors []exploreDraftEntry + consider := func(n *graph.Node, source, evidence string, direct bool, parentRank int) { + if n == nil || (!direct && exploreDraftIsTestNode(n)) { + return + } + entry, longest := makeEntry(n, source, evidence, direct, parentRank) + if !entry.exact && entry.bodyOverlap < 2 && entry.overlap < 2 && (entry.overlap != 1 || longest < 5) { + return + } + aligned = append(aligned, entry) + } + for i, target := range targets { + if i >= exploreDraftPrimaryLimit { + consider(target.node, target.source, fmt.Sprintf("ranked #%d", i+1), true, i) + } + for _, n := range target.callers { + consider(n, "", fmt.Sprintf("caller of ranked #%d", i+1), false, i) + } + for _, n := range target.callees { + consider(n, "", fmt.Sprintf("callee of ranked #%d", i+1), false, i) + } + + if target.node == nil || !conceptTask || exploreDraftIsTestNode(target.node) { + continue + } + parent, longest := makeEntry(target.node, target.source, "", true, i) + strongParent := parent.exact || parent.overlap >= 2 || parent.bodyOverlap >= 2 || (parent.overlap == 1 && longest >= 5) + if !strongParent || (!parent.exact && exploreIdentifierSegmentCount(target.node.Name) < 2) { + continue + } + collectStructural := func(n *graph.Node, evidence string, callee bool) { + if n == nil || exploreDraftIsTestNode(n) || (n.Kind != graph.KindFunction && n.Kind != graph.KindMethod) || exploreIdentifierSegmentCount(n.Name) < 3 { + return + } + entry, _ := makeEntry(n, "", evidence, false, i) + shared, local := exploreStructuralSignals(target.node, n) + crossFile := nodeDisplayPath(target.node) != nodeDisplayPath(n) + // A concrete cross-file callee is causal graph evidence even when its + // name shares no useful query token with the parent. This is the path + // from orchestration methods to the implementation agents need to read. + causalCrossFile := callee && crossFile + if shared == 0 && !entry.exact && entry.overlap == 0 && entry.bodyOverlap == 0 && !local && !causalCrossFile { + return + } + entry.structural = true + entry.structuralShared = shared + entry.structuralLocal = local + entry.structuralCallee = callee + entry.structuralCrossFile = crossFile + entry.parentExact = parent.exact + entry.parentOverlap = parent.overlap + structuralNeighbors = append(structuralNeighbors, entry) + } + for _, n := range target.callers { + collectStructural(n, fmt.Sprintf("caller of ranked #%d", i+1), false) + } + for _, n := range target.callees { + collectStructural(n, fmt.Sprintf("callee of ranked #%d", i+1), true) + } + } + + sort.SliceStable(aligned, func(i, j int) bool { + return exploreDraftEntryLess(aligned[i], aligned[j]) + }) + sort.SliceStable(structuralNeighbors, func(i, j int) bool { + return exploreStructuralEntryLess(structuralNeighbors[i], structuralNeighbors[j]) + }) + // Reserve one structural slot across both directions. A single causal + // implementation edge adds useful breadth without displacing two ranked + // candidates from the compact draft. + for _, entry := range structuralNeighbors { + if appendEntry(entry) { + break + } + } + for _, entry := range aligned { + if len(entries) >= exploreDraftTotalLimit { + break + } + appendEntry(entry) + } + for i := exploreDraftPrimaryLimit; i < len(targets) && len(entries) < exploreDraftTotalLimit; i++ { + entry, _ := makeEntry(targets[i].node, targets[i].source, fmt.Sprintf("ranked #%d", i+1), true, i) + appendEntry(entry) + } + sort.SliceStable(entries, func(i, j int) bool { + return exploreDraftEntryLess(entries[i], entries[j]) + }) + return entries +} + +func exploreDraftEntryLess(a, b exploreDraftEntry) bool { + alignment := func(entry exploreDraftEntry) int { + body := entry.bodyOverlap + if body > 2 { + body = 2 + } + return entry.overlap + body + } + priority := func(entry exploreDraftEntry) int { + switch { + case entry.exact && entry.direct: + return 0 + case entry.exact: + return 1 + case alignment(entry) > 0 && entry.direct: + return 2 + case alignment(entry) > 0: + return 3 + case entry.structural: + return 4 + case entry.direct: + return 5 + default: + return 6 + } + } + if ap, bp := priority(a), priority(b); ap != bp { + return ap < bp + } + if aa, ba := alignment(a), alignment(b); aa != ba { + return aa > ba + } + if a.rareOverlap != b.rareOverlap { + return a.rareOverlap > b.rareOverlap + } + if a.bodyDensity != b.bodyDensity { + return a.bodyDensity > b.bodyDensity + } + // Exact anchors and stronger term alignment already won above. Generic + // declarations are only a bounded tie-break within the same evidence + // family; they can never demote a better-aligned candidate. + if !a.exact && !b.exact && a.direct == b.direct && a.structural == b.structural && a.generic != b.generic { + return !a.generic + } + if a.overlap != b.overlap { + return a.overlap > b.overlap + } + if a.parentOverlap != b.parentOverlap { + return a.parentOverlap > b.parentOverlap + } + if a.direct != b.direct { + return a.direct + } + if a.parentRank != b.parentRank { + return a.parentRank < b.parentRank + } + return exploreDraftNodeKey(a.node) < exploreDraftNodeKey(b.node) +} + +func exploreStructuralEntryLess(a, b exploreDraftEntry) bool { + causalAligned := func(entry exploreDraftEntry) bool { + return entry.structuralCallee && entry.structuralCrossFile && (entry.exact || entry.overlap > 0 || entry.bodyOverlap >= 2 || entry.rareOverlap > 0) + } + if ac, bc := causalAligned(a), causalAligned(b); ac != bc { + return ac + } + if a.exact != b.exact { + return a.exact + } + if a.overlap != b.overlap { + return a.overlap > b.overlap + } + if a.structuralShared != b.structuralShared { + return a.structuralShared > b.structuralShared + } + if a.generic != b.generic { + return !a.generic + } + if a.structuralCallee != b.structuralCallee { + return a.structuralCallee + } + if a.structuralCrossFile != b.structuralCrossFile { + return a.structuralCrossFile + } + if a.structuralLocal != b.structuralLocal { + return a.structuralLocal + } + if a.parentExact != b.parentExact { + return a.parentExact + } + if a.parentOverlap != b.parentOverlap { + return a.parentOverlap > b.parentOverlap + } + if a.parentRank != b.parentRank { + return a.parentRank < b.parentRank + } + return exploreDraftNodeKey(a.node) < exploreDraftNodeKey(b.node) +} + +func exploreIdentifierSegmentCount(name string) int { + return len(rerank.Tokenize(name)) +} + +func exploreIdentifierTerms(name string) map[string]struct{} { + generic := map[string]struct{}{ + "and": {}, "for": {}, "from": {}, "get": {}, "has": {}, "into": {}, + "is": {}, "new": {}, "of": {}, "or": {}, "set": {}, "the": {}, + "to": {}, "with": {}, "without": {}, + } + terms := make(map[string]struct{}) + for _, term := range rerank.Tokenize(name) { + term = strings.ToLower(term) + if len(term) < 3 { + continue + } + if _, skip := generic[term]; skip { + continue + } + terms[term] = struct{}{} + } + return terms +} + +func exploreStructuralSignals(parent, child *graph.Node) (shared int, local bool) { + parentTerms := exploreIdentifierTerms(parent.Name) + for term := range exploreIdentifierTerms(child.Name) { + if _, ok := parentTerms[term]; ok { + shared++ + } + } + parentPath := nodeDisplayPath(parent) + childPath := nodeDisplayPath(child) + parentSlash := strings.LastIndex(parentPath, "/") + childSlash := strings.LastIndex(childPath, "/") + if parentSlash >= 0 && childSlash >= 0 && parentPath != childPath { + local = parentPath[:parentSlash] == childPath[:childSlash] + } + return shared, local +} + +func exploreDraftIsTestNode(n *graph.Node) bool { + if n == nil { + return false + } + if auditIsTestNode(n) { + return true + } + path := strings.ToLower(strings.ReplaceAll(nodeDisplayPath(n), "\\", "/")) + segmented := "/" + strings.Trim(path, "/") + "/" + for _, dir := range []string{"/__tests__/", "/spec/", "/specs/", "/test/", "/tests/"} { + if strings.Contains(segmented, dir) { + return true + } + } + base := path + if slash := strings.LastIndex(base, "/"); slash >= 0 { + base = base[slash+1:] + } + return strings.HasPrefix(base, "test_") || strings.Contains(base, "_test.") || + strings.Contains(base, ".spec.") || strings.Contains(base, ".test.") +} + +func exploreDraftNodeTerms(n *graph.Node) map[string]struct{} { + if n == nil { + return map[string]struct{}{} + } + retrieval := n.RetrievalMetadata() + return exploreTerminalTerms(n.Name + " " + retrieval.QualName + " " + nodeDisplayPath(n) + " " + retrieval.Signature) +} + +func exploreDraftTermSetOverlap(queryTerms, candidateTerms map[string]struct{}) int { + count := 0 + for term := range queryTerms { + if _, ok := candidateTerms[term]; ok { + count++ + } + } + return count +} + +func exploreDraftTermOverlap(queryTerms map[string]struct{}, n *graph.Node) (count, longest int) { + if n == nil || len(queryTerms) == 0 { + return 0, 0 + } + for term := range exploreDraftNodeTerms(n) { + if _, ok := queryTerms[term]; !ok { + continue + } + count++ + if len(term) > longest { + longest = len(term) + } + } + return count, longest +} + +// exploreDraftGenericCandidate identifies structurally generic API context: +// interface/trait declarations, declaration-only methods, tiny accessors, and +// one-step receiver forwarders. It is language-neutral and only participates +// as a Concept-query tie-break; exact anchors bypass it at the call site. +func exploreDraftGenericCandidate(n *graph.Node, source string) bool { + if n == nil { + return false + } + nameTerms := rerank.Tokenize(n.Name) + if len(nameTerms) > 0 && len(nameTerms) <= 3 { + switch strings.ToLower(nameTerms[0]) { + case "get", "set", "is", "has": + return true + } + } + + retrieval := n.RetrievalMetadata() + declaration := strings.ToLower(strings.TrimSpace(retrieval.Signature + "\n" + source)) + for _, keyword := range []string{"interface ", "trait ", "protocol "} { + if strings.Contains(declaration, keyword) { + return true + } + } + trimmed := strings.TrimSpace(declaration) + if strings.HasSuffix(trimmed, ";") && (strings.Contains(trimmed, "(") || strings.Contains(trimmed, " function ")) { + return true + } + if strings.TrimSpace(source) == "" { + return false + } + + lines := make([]string, 0, 8) + for _, line := range strings.Split(strings.ToLower(source), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "//") || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "/*") || strings.HasPrefix(line, "*") { + continue + } + lines = append(lines, line) + } + if len(lines) > 10 { + return false + } + code := " " + strings.Join(lines, " ") + " " + if len(code) > 700 { + return false + } + for _, control := range []string{" if ", " for ", " while ", " loop ", " match ", " switch ", " let ", " try ", " catch "} { + if strings.Contains(code, control) { + return false + } + } + // Receiver syntax varies, but trivial implementations consistently contain + // a member access plus either one return/call or one field assignment. + hasMemberAccess := strings.Contains(code, ".") + hasOneStepReturn := strings.Contains(code, " return ") && strings.Contains(code, "(") + hasAccessorAssignment := strings.Contains(code, "=") + return hasMemberAccess && (hasOneStepReturn || hasAccessorAssignment) +} + +func exploreDraftExactAnchor(query string, n *graph.Node) bool { + if n == nil { + return false + } + if pathAnchors, hasDirectory := exploreQueryPathAnchors(query); hasDirectory { + normalizedPath := exploreNormalizedPath(nodeDisplayPath(n)) + for _, anchor := range pathAnchors { + if normalizedPath == anchor { + return true + } + } + // Directory-qualified requests are strict: neither a coincident symbol + // name nor the same basename in another directory is an exact draft hit. + return false + } + normalize := func(text string) string { + return strings.ToLower(strings.Join(rerank.Tokenize(text), " ")) + } + genericName := map[string]struct{}{ + "clear": {}, "convert": {}, "get": {}, "set": {}, "write": {}, + } + query = " " + normalize(query) + " " + for _, anchor := range []string{n.Name, n.QualName} { + anchor = normalize(anchor) + if _, generic := genericName[anchor]; generic { + continue + } + if len(anchor) >= 3 && strings.Contains(query, " "+anchor+" ") { + return true + } + } + path := nodeDisplayPath(n) + if slash := strings.LastIndex(path, "/"); slash >= 0 { + path = path[slash+1:] + } + path = normalize(path) + return len(path) >= 3 && strings.Contains(query, " "+path+" ") +} + +func exploreDraftNodeKey(n *graph.Node) string { + if n == nil { + return "" + } + if n.ID != "" { + return n.ID + } + return fmt.Sprintf("%s|%s|%s|%d", nodeDisplayPath(n), n.Kind, n.Name, n.StartLine) +} + +func exploreDraftSymbol(n *graph.Node) string { + if n.QualName != "" { + return n.QualName + } + return n.Name +} + +// exploreAnswerReady decides whether the ranked head is strong enough to end +// localization. A result is terminal only when its symbols visibly align with +// the shaped query; rank alone is not enough because broad review/audit prompts +// can otherwise elevate common identifiers such as current, change, or client. +func exploreAnswerReady(task string, targets []exploreTarget) bool { + if len(targets) == 0 || targets[0].node == nil { + return false + } + query := shapeExploreQuery(task) + class := rerank.ClassifyQuery(query) + if class == rerank.QueryClassConcept { + query = stripLeadingExploreDirective(query) + } + queryTerms := exploreTerminalTerms(query) + if len(queryTerms) == 0 { + return false + } + + matched := func(target exploreTarget) int { + if target.node == nil { + return 0 + } + n := target.node + text := exploreTerminalNodeText(n) + candidateTerms := exploreTerminalTerms(text) + count := 0 + for term := range queryTerms { + if _, ok := candidateTerms[term]; ok { + count++ + } + } + return count + } + + headMatches := matched(targets[0]) + union := make(map[string]struct{}) + for i := 0; i < len(targets) && i < 3; i++ { + if targets[i].node == nil { + continue + } + n := targets[i].node + text := exploreTerminalNodeText(n) + for term := range exploreTerminalTerms(text) { + if _, relevant := queryTerms[term]; relevant { + union[term] = struct{}{} + } + } + } + + // Paths, signatures, and identifier-shaped queries carry explicit anchors. + // A shared token is not enough: the ranked head must cover the complete + // path, qualified symbol, or signature anchor from the request. + if class == rerank.QueryClassPath || class == rerank.QueryClassSymbol || class == rerank.QueryClassSignature { + return exploreLocalizationExplicitAnchor(query, targets[0].node) + } + // Broad prompts need several independent anchors in the top result. This + // keeps a multi-area audit from becoming terminal merely because one generic + // word matched somewhere in the neighborhood. + if len(queryTerms) > 10 { + return headMatches >= 3 && len(union) >= 3 + } + if len(queryTerms) == 1 { + // A concept phrase can repeat one generic word many times, but the term + // set intentionally de-duplicates it. One such token is not independent + // evidence and must never make localization terminal. Explicit path, + // symbol, and signature anchors already returned above. + return false + } + if headMatches >= 1 && len(union) >= 2 { + return true + } + // A sharply separated ranked head is useful supporting evidence, but only + // when it also has a visible lexical anchor to the request. + if headMatches > 0 && targets[0].score > 0 { + if len(targets) == 1 || targets[1].score <= 0 || targets[0].score/targets[1].score >= 1.5 { + return true + } + } + return false +} + +func exploreNormalizedAnchor(text string) string { + return strings.ToLower(strings.Join(rerank.Tokenize(text), " ")) +} + +func exploreNormalizedPath(text string) string { + text = strings.TrimSpace(text) + text = strings.Trim(text, "`'\"()[]{}<>,;") + text = strings.ReplaceAll(text, "\\", "/") + text = strings.TrimPrefix(text, "./") + parts := strings.Split(text, "/") + normalized := make([]string, 0, len(parts)) + for _, part := range parts { + if value := exploreNormalizedAnchor(part); value != "" { + normalized = append(normalized, value) + } + } + return strings.Join(normalized, "/") +} + +func exploreQueryPathAnchors(query string) ([]string, bool) { + anchors := make([]string, 0, 1) + hasDirectory := false + for _, field := range strings.Fields(query) { + field = strings.Trim(field, "`'\"()[]{}<>,;") + if !strings.ContainsAny(field, "/\\") { + continue + } + hasDirectory = true + if anchor := exploreNormalizedPath(field); anchor != "" { + anchors = append(anchors, anchor) + } + } + return anchors, hasDirectory +} + +func exploreLocalizationExplicitAnchor(query string, n *graph.Node) bool { + if n == nil { + return false + } + normalizedQueryText := exploreNormalizedAnchor(query) + normalizedQuery := " " + normalizedQueryText + " " + retrieval := n.RetrievalMetadata() + name := exploreNormalizedAnchor(n.Name) + qualName := exploreNormalizedAnchor(retrieval.QualName) + if qualName == "" || qualName == name { + if separator := strings.LastIndex(n.ID, "::"); separator >= 0 { + qualName = exploreNormalizedAnchor(n.ID[separator+2:]) + } + } + if exploreQueryHasPathSymbolAnchor(query, n) || exploreQueryHasCallAnchor(query, n.Name) { + return true + } + if qualName != "" && qualName != name && strings.Contains(normalizedQuery, " "+qualName+" ") { + return true + } + + path := nodeDisplayPath(n) + normalizedPath := exploreNormalizedPath(path) + for _, field := range strings.Fields(query) { + field = strings.Trim(field, "`'\"()[]{}<>,;") + separator := strings.LastIndex(field, "::") + if separator <= 0 || !strings.ContainsAny(field[:separator], "/\\") { + continue + } + if exploreNormalizedPath(field[:separator]) != normalizedPath { + continue + } + symbol := exploreNormalizedAnchor(field[separator+2:]) + if symbol == name || symbol == qualName || strings.HasSuffix(qualName, " "+symbol) { + return true + } + } + pathAnchors, hasDirectory := exploreQueryPathAnchors(query) + if hasDirectory { + for _, anchor := range pathAnchors { + if normalizedPath == anchor { + return true + } + } + // A directory-qualified request must never degrade to a basename match; + // that would make same-name files in different packages terminal. + return false + } + if slash := strings.LastIndex(path, "/"); slash >= 0 { + path = path[slash+1:] + } + basename := exploreNormalizedAnchor(path) + if len(basename) >= 3 && strings.Contains(normalizedQuery, " "+basename+" ") { + return true + } + + class := rerank.ClassifyQuery(query) + if class == rerank.QueryClassSignature { + signature := exploreNormalizedAnchor(retrieval.Signature) + return signature != "" && strings.Contains(normalizedQuery, " "+signature+" ") + } + // An unqualified symbol cannot disambiguate same-name methods. Permit it + // only for nodes whose canonical name is itself unqualified. + return class == rerank.QueryClassSymbol && qualName == name && normalizedQueryText == name +} + +// exploreLocalizationExactTarget selects the one permitted refinement read for +// an explicit localization request. Explicit qualified/path anchors win. For +// natural-language tasks, candidates with more independent task terms win and +// inverse candidate frequency breaks ties so repeated generic setters do not +// outrank a rarer conjunctive implementation target. Retrieval order is the +// stable final tie-breaker. +func exploreLocalizationExactTarget(task string, targets []exploreTarget) string { + query := shapeExploreQuery(task) + class := rerank.ClassifyQuery(query) + if class == rerank.QueryClassConcept { + query = stripLeadingExploreDirective(query) + } + for _, target := range targets { + if target.node != nil && exploreLocalizationExplicitAnchor(query, target.node) { + return target.node.ID + } + } + queryTerms := exploreTerminalTerms(query) + if len(queryTerms) == 0 { + return "" + } + + matches := make([]map[string]struct{}, len(targets)) + frequency := make(map[string]int) + for i, target := range targets { + matches[i] = make(map[string]struct{}) + if target.node == nil { + continue + } + for term := range exploreTerminalTerms(exploreTerminalNodeText(target.node)) { + if _, relevant := queryTerms[term]; !relevant { + continue + } + matches[i][term] = struct{}{} + frequency[term]++ + } + } + + best := -1 + bestOverlap := -1 + bestRarity := -1 + for i, target := range targets { + if target.node == nil { + continue + } + overlap := len(matches[i]) + rarity := 0 + for term := range matches[i] { + if frequency[term] > 0 { + rarity += 1000 / frequency[term] + } + } + if best < 0 || overlap > bestOverlap || (overlap == bestOverlap && rarity > bestRarity) { + best = i + bestOverlap = overlap + bestRarity = rarity + } + } + if best < 0 || bestOverlap == 0 { + return "" + } + // Concept localization needs two independent lexical anchors before it may + // prescribe an exact source read. They may jointly support an equal-evidence + // neighborhood, in which case retrieval order remains the stable tie-breaker. + // Repeating one generic token (for example walk/walker/walking) still leaves + // frequency with one key and cannot turn retrieval order into exactness. + if class == rerank.QueryClassConcept && bestOverlap < 2 && len(frequency) < 2 { + return "" + } + return targets[best].node.ID +} + +func exploreTerminalNodeText(n *graph.Node) string { + if n == nil { + return "" + } + retrieval := n.RetrievalMetadata() + return strings.TrimSpace(strings.Join([]string{n.Name, retrieval.QualName, n.FilePath, retrieval.Signature}, " ")) +} + +var exploreTerminalGenericTerms = map[string]struct{}{ + "audit": {}, "behavior": {}, "blocker": {}, "branch": {}, "change": {}, + "check": {}, "code": {}, "correctness": {}, "current": {}, "file": {}, + "find": {}, "fix": {}, "identify": {}, "implement": {}, "investigate": {}, + "issue": {}, "problem": {}, "quality": {}, "release": {}, "review": {}, + "source": {}, "symbol": {}, "task": {}, "validate": {}, +} + +func exploreTerminalTerms(text string) map[string]struct{} { + terms := make(map[string]struct{}) + for _, raw := range rerank.Tokenize(text) { + term := strings.ToLower(strings.TrimSpace(raw)) + if len(term) < 3 { + continue + } + if _, stop := assistStopWords[term]; stop { + continue + } + term = exploreTerminalTermRoot(term) + if _, generic := exploreTerminalGenericTerms[term]; generic { + continue + } + terms[term] = struct{}{} + } + return terms +} + +func exploreTerminalTermRoot(term string) string { + if len(term) > 4 && strings.HasSuffix(term, "s") && !strings.HasSuffix(term, "ss") { + return strings.TrimSuffix(term, "s") + } + return term +} + // handleExplore is the one-shot localization verb: free text in, a ranked // neighborhood (symbols + source + call paths + file map + completeness // cue) out, bounded by a token budget, in a single response. @@ -109,6 +1079,10 @@ func (s *Server) handleExplore(ctx context.Context, req mcp.CallToolRequest) (*m // untouched. The original task is still shown in the header so the agent // sees what it asked. searchQuery := shapeExploreQuery(task) + queryClass := rerank.ClassifyQuery(searchQuery) + if queryClass == rerank.QueryClassConcept { + searchQuery = stripLeadingExploreDirective(searchQuery) + } rctx := s.buildRerankContext(ctx, searchQuery) // Over-fetch, then keep the top maxSymbols that are real localization // targets — params / locals / closures / imports are never a place a @@ -116,8 +1090,36 @@ func (s *Server) handleExplore(ctx context.Context, req mcp.CallToolRequest) (*m // slots and clutter the file map. Test-source symbols are demoted, not // dropped: production code is where a report is resolved, but a task // genuinely about tests still gets them when production hits run out. - fetch := clampInt(maxSymbols*4, maxSymbols, 80) - ranked := eng.SearchSymbolsRanked(searchQuery, fetch, opts, rctx) + fetch := exploreCandidateFetchLimit(maxSymbols, queryClass) + var ranked []*rerank.Candidate + if queryClass == rerank.QueryClassConcept { + // Gather the hybrid primary channel without truncating away vector-only + // candidates, add one text-only concept bag, then run the session-aware + // reranker exactly once over the union. Channel ranks survive the merge, + // so semantic intent remains useful outside signature-rich languages. + primaryOpts := opts + primaryOpts.SkipInnerRerank = true + ranked = eng.GatherSymbolCandidates(searchQuery, fetch, primaryOpts, rctx) + terms := exploreConceptRecallTerms(searchQuery) + if hasExploreExpansionTerms(searchQuery, terms) { + expansionOpts := opts + expansionOpts.SkipInnerRerank = true + expansionOpts.SkipVectorChannel = true + expansionOpts.SkipExactNameSplice = true + expanded := eng.GatherSymbolCandidates(strings.Join(terms, " "), fetch, expansionOpts, rctx) + ranked = mergeExploreCandidates(ranked, expanded, fetch) + } + // Gathering deliberately over-fetches each retrieval channel so scope + // filtering cannot erase relevant results. Bound the union before the + // graph-aware reranker: its centrality and edge hydration costs scale + // with every candidate, not just the final response size. + ranked = limitExploreCandidates(ranked, fetch*2) + if pipeline := eng.Rerank(); pipeline != nil { + ranked = pipeline.Rerank(searchQuery, ranked, rctx) + } + } else { + ranked = eng.SearchSymbolsRanked(searchQuery, fetch, opts, rctx) + } // Resilience ladder: a warm-restarted daemon can transiently return an // empty scoped ranked result (workspace stamps not yet backfilled, or // search bundles served before their node payloads re-materialise) @@ -162,6 +1164,13 @@ func (s *Server) handleExplore(ctx context.Context, req mcp.CallToolRequest) (*m prod = append(prod, c) } } + // Natural-language localization can retrieve large exact-name collision + // sets (`client` fields, `Validate` methods, generated accessors). BM25 is + // right that each item matches, but a useful neighborhood needs distinct + // concepts. Keep one data declaration and at most two callable/type members + // per repeated name, moving the remaining collisions behind distinct + // definitions. Identifier/path lookups retain literal ordering. + prod = diversifyRepeatedExploreNames(prod, queryClass) // Bounded per-file diversification (the same demote-only mechanism the // ranked search head uses): a localization neighborhood that spans // files beats one file's cluster of sibling shims crowding out every @@ -171,7 +1180,9 @@ func (s *Server) handleExplore(ctx context.Context, req mcp.CallToolRequest) (*m for i, c := range prod { prodNodes[i] = c.Node } - _, prod = diversifyByFile(prodNodes, prod, defaultMaxPerFile) + if exploreShouldDiversifyByFile(queryClass) { + _, prod = diversifyByFile(prodNodes, prod, defaultMaxPerFile) + } cands := prod if len(cands) > maxSymbols { cands = cands[:maxSymbols] @@ -183,12 +1194,18 @@ func (s *Server) handleExplore(ctx context.Context, req mcp.CallToolRequest) (*m cands = append(cands, test[:room]...) } if len(cands) == 0 { + if req.GetBool("localize", false) { + return s.completeEmptyLocalization(ctx, task, budget), nil + } return mcp.NewToolResultText(fmt.Sprintf( - "EXPLORE — %s\n\nNo ranked symbols matched this request. The graph found nothing on the ranked path — widen the wording, or drop to search_text / find_files for a literal or filename lead.", + "EXPLORE — %s\n\nNo ranked symbols matched this request. Widen the wording, use search(operation:\"text\", query:\"\") for a literal lead, or search(operation:\"files\", query:\"\") for a path lead.", truncateOneLine(task, 200))), nil } - ringOpts := query.QueryOptions{Depth: 1, Limit: exploreRingCap * 3, Detail: "brief", WorkspaceID: resolved.WorkspaceID} + ringOpts := query.QueryOptions{ + Depth: 1, Limit: exploreRingCap * 3, Detail: "brief", + WorkspaceID: resolved.WorkspaceID, ProjectID: resolved.ProjectID, RepoAllow: resolved.RepoAllow, + } targets := make([]exploreTarget, 0, len(cands)) for _, c := range cands { if c == nil || c.Node == nil { @@ -205,8 +1222,450 @@ func (s *Server) handleExplore(ctx context.Context, req mcp.CallToolRequest) (*m t.source = s.manifestSymbolSource(ctx, n) targets = append(targets, t) } + // Direct retrieval owns the ranked head. Once graph promotion has selected + // a cross-file boundary, materialize exactly that one node so both text and + // structured responses can reserve a source body without a broad read. + targets = s.materializeExploreStructuralSource(ctx, task, targets, opts) + + if !req.GetBool("localize", false) { + return mcp.NewToolResultText(s.renderExplore(task, targets, budget)), nil + } + answerReady := exploreAnswerReady(task, targets) + exactSymbol := exploreLocalizationExactTarget(task, targets) + if !answerReady && exactSymbol == "" { + anchors := make([]string, 0, 3) + for _, target := range targets { + if target.node == nil { + continue + } + anchors = append(anchors, fmt.Sprintf("%s (%s)", target.node.ID, nodeDisplayPath(target.node))) + if len(anchors) == cap(anchors) { + break + } + } + return mcp.NewToolResultError(fmt.Sprintf( + "localization confidence is insufficient: no candidate has an explicit file/symbol anchor or two distinct task-term matches; top candidates: %s. Add a concrete file or symbol anchor, or use explore(operation:\"task\") when investigation must continue.", + strings.Join(anchors, ", "))), nil + } + completion := newLocalizationCompletion(answerReady, exactSymbol) + s.localizationFor(ctx).armForTask(completion, task) + return newLocalizationExploreResultForTask(completion, task, targets, budget), nil +} + +// localizationExploreEnvelope is the compact, machine-readable result for an +// explicit localization-only request. Ordinary explore(task) retains the +// human-oriented legacy rendering; localize does not duplicate it. +type localizationExploreEnvelope struct { + Completion localizationCompletion `json:"completion"` + Files []string `json:"files"` + Symbols []string `json:"symbols"` + Evidence []localizationEvidence `json:"evidence"` +} + +type localizationEvidence struct { + Rank int `json:"rank"` + ID string `json:"id"` + Name string `json:"name"` + QualName string `json:"qual_name,omitempty"` + Kind string `json:"kind"` + File string `json:"file"` + Line int `json:"line"` + EndLine int `json:"end_line,omitempty"` + Signature string `json:"signature,omitempty"` + Callers []string `json:"callers,omitempty"` + Callees []string `json:"callees,omitempty"` + Source string `json:"source,omitempty"` +} + +func (s *Server) completeEmptyLocalization(ctx context.Context, task string, budget int) *mcp.CallToolResult { + completion := newLocalizationCompletion(true, "") + s.localizationFor(ctx).armForTask(completion, task) + return newLocalizationExploreResult(completion, []exploreTarget{}, budget) +} + +// exploreAllowsStructuralBody keeps graph-expanded source reads exclusive to +// broad concept localization. Explicit directory-qualified paths are guarded +// independently because prose around a path can still classify as Concept; +// literal symbol and signature queries retain their direct-only behavior. +func exploreAllowsStructuralBody(task string) bool { + shaped := shapeExploreQuery(task) + class := rerank.ClassifyQuery(shaped) + _, directoryQualified := exploreQueryPathAnchors(shaped) + return !directoryQualified && class != rerank.QueryClassSymbol && class != rerank.QueryClassSignature +} + +// explorePreferredFullBodyIDs reserves the first source slot for the strongest +// direct answer and, when available, the second for the promoted cross-file +// structural boundary. Remaining direct targets fill unused slots. +func explorePreferredFullBodyIDs(task string, targets []exploreTarget, draft []exploreDraftEntry, limit int) []string { + if limit <= 0 { + return nil + } + sources := make(map[string]struct{}, len(targets)) + for _, target := range targets { + if target.node != nil && target.source != "" { + sources[exploreDraftNodeKey(target.node)] = struct{}{} + } + } + result := make([]string, 0, limit) + seen := make(map[string]struct{}, limit) + appendEntry := func(entry exploreDraftEntry) bool { + if entry.node == nil || len(result) >= limit { + return false + } + key := exploreDraftNodeKey(entry.node) + if _, hasSource := sources[key]; !hasSource { + return false + } + if _, exists := seen[key]; exists { + return false + } + seen[key] = struct{}{} + result = append(result, key) + return true + } + if draft == nil { + draft = exploreAnswerDraft(task, targets) + } + if !exploreAllowsStructuralBody(task) { + for _, entry := range draft { + if entry.direct && entry.exact { + appendEntry(entry) + } + } + return result + } + for _, entry := range draft { + if entry.direct && appendEntry(entry) { + break + } + } + for _, entry := range draft { + if entry.structural && entry.structuralCrossFile && appendEntry(entry) { + break + } + } + for _, entry := range draft { + if entry.direct { + appendEntry(entry) + } + } + return result +} + +// materializeExploreStructuralSource loads source for at most one promoted +// cross-file structural boundary. Graph expansion discovers the node without a +// broad source read; this performs the same bounded symbol-range read used for +// direct targets only after the draft has selected that boundary. +func (s *Server) materializeExploreStructuralSource(ctx context.Context, task string, targets []exploreTarget, scope query.QueryOptions) []exploreTarget { + return materializeExploreStructuralSourceWithReader(ctx, task, targets, scope, s.manifestSymbolSource) +} + +func materializeExploreStructuralSourceWithReader( + ctx context.Context, + task string, + targets []exploreTarget, + scope query.QueryOptions, + readSource func(context.Context, *graph.Node) string, +) []exploreTarget { + if len(targets) == 0 || !exploreAllowsStructuralBody(task) || ctx.Err() != nil || readSource == nil { + return targets + } + draft := exploreAnswerDraft(task, targets) + present := make(map[string]struct{}, len(targets)) + for _, target := range targets { + if target.node != nil { + present[exploreDraftNodeKey(target.node)] = struct{}{} + } + } + for _, entry := range draft { + if entry.node == nil || !entry.structural || !entry.structuralCrossFile { + continue + } + key := exploreDraftNodeKey(entry.node) + if _, exists := present[key]; exists { + return targets + } + if !exploreNodeWithinQueryScope(entry.node, scope) { + return targets + } + // Cancellation may arrive while graph promotion is ranking candidates; + // check at the actual source-read boundary so an abandoned request never + // opens a file merely to populate an output that cannot be delivered. + if ctx.Err() != nil { + return targets + } + source := readSource(ctx, entry.node) + if strings.TrimSpace(source) == "" { + return targets + } + materialized := make([]exploreTarget, 0, len(targets)+1) + materialized = append(materialized, targets...) + return append(materialized, exploreTarget{node: entry.node, source: source}) + } + return targets +} + +func exploreNodeWithinQueryScope(n *graph.Node, scope query.QueryOptions) bool { + if n == nil { + return false + } + if scope.WorkspaceID != "" && n.WorkspaceID != scope.WorkspaceID { + return false + } + if scope.ProjectID != "" && n.ProjectID != scope.ProjectID { + return false + } + if len(scope.RepoAllow) > 0 && !scope.RepoAllow[n.RepoPrefix] { + return false + } + return true +} + +// localizationEvidenceTargets projects the same bounded answer-draft evidence +// used by ordinary rendering into the structured terminal envelope. Promoted +// callers/callees may replace low-ranked direct candidates, but never increase +// the response cardinality. Direct targets retain their source and neighbors. +func localizationEvidenceTargets(task, exactID string, targets []exploreTarget) []exploreTarget { + return localizationEvidenceTargetsFromDraft(task, exactID, targets, nil) +} + +func localizationEvidenceTargetsFromDraft(task, exactID string, targets []exploreTarget, draft []exploreDraftEntry) []exploreTarget { + if len(targets) == 0 { + return targets + } + limit := len(targets) + direct := make(map[string]exploreTarget, len(targets)) + for _, target := range targets { + if target.node != nil { + direct[exploreDraftNodeKey(target.node)] = target + } + } + selected := make([]exploreTarget, 0, limit) + seen := make(map[string]struct{}, limit) + appendTarget := func(target exploreTarget) { + if len(selected) >= limit || target.node == nil { + return + } + key := exploreDraftNodeKey(target.node) + if _, exists := seen[key]; exists { + return + } + seen[key] = struct{}{} + selected = append(selected, target) + } + if draft == nil && strings.TrimSpace(task) != "" { + draft = exploreAnswerDraft(task, targets) + } + appendTarget(targets[0]) + // The completion contract names the exact refinement target. Reserve it + // before promoted structural neighbors so a tight evidence limit cannot + // advertise an exact_symbol that is absent from files/symbols/evidence. + if exactID != "" { + for _, target := range targets { + if target.node != nil && target.node.ID == exactID { + appendTarget(target) + break + } + } + } + appendEntry := func(entry exploreDraftEntry) { + if entry.node == nil { + return + } + if target, ok := direct[exploreDraftNodeKey(entry.node)]; ok { + appendTarget(target) + } else { + appendTarget(exploreTarget{node: entry.node}) + } + } + // Reserve bounded space for promoted implementations before lower-ranked + // direct retrieval hits consume the envelope. + for _, entry := range draft { + if entry.structural || !entry.direct { + appendEntry(entry) + } + } + for _, entry := range draft { + appendEntry(entry) + } + for _, target := range targets { + appendTarget(target) + } + return selected +} + +func newLocalizationExploreResult(completion localizationCompletion, targets []exploreTarget, budget int) *mcp.CallToolResult { + return newLocalizationExploreResultForTask(completion, "", targets, budget) +} + +const ( + localizationEnvelopeBytesPerToken = 4 + localizationMaxNameRunes = 120 + localizationMaxQualNameRunes = 180 + localizationMaxSignatureRunes = 240 + localizationMaxNeighborIDs = 3 + localizationMaxNeighborIDRunes = 96 +) + +func compactLocalizationField(text string, limit int) string { + text = strings.TrimSpace(text) + runes := []rune(text) + if limit <= 0 || len(runes) <= limit { + return text + } + return string(runes[:limit-1]) + "…" +} + +func localizationEnvelopeFits(envelope localizationExploreEnvelope, maxBytes int) bool { + body, err := json.Marshal(envelope) + return err == nil && len(body) <= maxBytes +} + +func newLocalizationExploreResultForTask(completion localizationCompletion, task string, targets []exploreTarget, budget int) *mcp.CallToolResult { + var draft []exploreDraftEntry + if strings.TrimSpace(task) != "" { + draft = exploreAnswerDraft(task, targets) + } + preferredBodyIDs := explorePreferredFullBodyIDs(task, targets, draft, exploreFullBodyLimit) + targets = localizationEvidenceTargetsFromDraft(task, completion.ExactSymbol, targets, draft) + envelope := localizationExploreEnvelope{ + Completion: completion, + Files: make([]string, 0), + Symbols: make([]string, 0), + Evidence: make([]localizationEvidence, 0), + } + maxBytes := budget * localizationEnvelopeBytesPerToken + if maxBytes < 1 { + maxBytes = 1 + } + + mandatoryCount := 0 + if len(targets) > 0 { + mandatoryCount = 1 + } + if completion.ExactSymbol != "" { + for index, target := range targets { + if target.node != nil && target.node.ID == completion.ExactSymbol && index+1 > mandatoryCount { + mandatoryCount = index + 1 + break + } + } + } + + seenFiles := make(map[string]struct{}) + acceptedTargets := make([]exploreTarget, 0, len(targets)) + for _, target := range targets { + if target.node == nil { + continue + } + n := target.node + path := nodeDisplayPath(n) + retrieval := n.RetrievalMetadata() + evidence := localizationEvidence{ + Rank: len(envelope.Evidence) + 1, ID: n.ID, + Name: compactLocalizationField(n.Name, localizationMaxNameRunes), + Kind: string(n.Kind), File: path, + Line: n.StartLine, EndLine: n.EndLine, + QualName: compactLocalizationField(retrieval.QualName, localizationMaxQualNameRunes), + Signature: compactLocalizationField(retrieval.Signature, localizationMaxSignatureRunes), + Callers: boundedLocalizationNeighborIDs(target.callers, localizationMaxNeighborIDs), + Callees: boundedLocalizationNeighborIDs(target.callees, localizationMaxNeighborIDs), + } + + candidate := envelope + candidate.Files = append([]string(nil), envelope.Files...) + candidate.Symbols = append([]string(nil), envelope.Symbols...) + candidate.Evidence = append([]localizationEvidence(nil), envelope.Evidence...) + if _, seen := seenFiles[path]; !seen { + candidate.Files = append(candidate.Files, path) + } + candidate.Symbols = append(candidate.Symbols, n.ID) + candidate.Evidence = append(candidate.Evidence, evidence) + mandatory := len(envelope.Evidence) < mandatoryCount + if !localizationEnvelopeFits(candidate, maxBytes) { + if !mandatory { + continue + } + // Primary and exact identifiers are contractual. If their optional + // metadata would exceed the envelope, retain the identifiers and + // locations while shedding expansion details first. + evidence.QualName = "" + evidence.Signature = "" + evidence.Callers = nil + evidence.Callees = nil + candidate.Evidence[len(candidate.Evidence)-1] = evidence + } + envelope = candidate + seenFiles[path] = struct{}{} + acceptedTargets = append(acceptedTargets, target) + } + + // Add full bodies only when the complete serialized response still fits. + // JSON marshaling here accounts for metadata arrays and escaping that the + // previous source-only token estimate omitted. Preferred IDs reserve one + // slot for a promoted cross-file causal boundary when it has source. + bodyOrder := make([]int, 0, exploreFullBodyLimit) + seenBody := make(map[int]struct{}, exploreFullBodyLimit) + appendBodyIndex := func(index int) { + if index < 0 || index >= len(acceptedTargets) || len(bodyOrder) >= exploreFullBodyLimit { + return + } + if _, exists := seenBody[index]; exists { + return + } + seenBody[index] = struct{}{} + bodyOrder = append(bodyOrder, index) + } + for _, preferredID := range preferredBodyIDs { + for index, target := range acceptedTargets { + if target.node != nil && exploreDraftNodeKey(target.node) == preferredID { + appendBodyIndex(index) + break + } + } + } + for index := range acceptedTargets { + appendBodyIndex(index) + } + for _, index := range bodyOrder { + if acceptedTargets[index].source == "" { + continue + } + candidate := envelope + candidate.Evidence = append([]localizationEvidence(nil), envelope.Evidence...) + candidate.Evidence[index].Source = acceptedTargets[index].source + if localizationEnvelopeFits(candidate, maxBytes) { + envelope = candidate + } + } + + body, err := json.Marshal(envelope) + if err != nil { + return mcp.NewToolResultError("encode localization result: " + err.Error()) + } + return mcp.NewToolResultText(string(body)) +} + +func boundedLocalizationNeighborIDs(nodes []*graph.Node, limit int) []string { + ids := localizationNeighborIDs(nodes) + if limit >= 0 && len(ids) > limit { + ids = ids[:limit] + } + for index := range ids { + ids[index] = compactLocalizationField(ids[index], localizationMaxNeighborIDRunes) + } + return ids +} - return mcp.NewToolResultText(s.renderExplore(task, targets, budget)), nil +func localizationNeighborIDs(nodes []*graph.Node) []string { + ids := make([]string, 0, len(nodes)) + for _, node := range nodes { + if node != nil && node.ID != "" { + ids = append(ids, node.ID) + } + } + return ids } // renderExplore lays out the ranked neighborhood as a compact, agent-facing @@ -226,8 +1685,24 @@ func (s *Server) renderExplore(task string, targets []exploreTarget, budget int) } fmt.Fprintf(&b, "EXPLORE — %s\n\n", truncateOneLine(task, 200)) - b.WriteString("Ranked localization neighborhood (graph-verified). Likely targets first; each carries its call paths and source.\n\n") - b.WriteString("## Likely targets (most-relevant first)\n") + b.WriteString("RANKED LOCALIZATION: use the evidence below with stated uncertainty. Do not fan out or rerun broad exploration. Localization-only callers receive a separate completion contract; diagnosis and change callers continue from this evidence.\n\n") + draft := exploreAnswerDraft(task, targets) + b.WriteString("## Answer draft\n") + for _, entry := range draft { + if !entry.direct && entry.node.ID != "" { + fmt.Fprintf(&b, "- FILE: %s · SYMBOL: %s · ID: %s · EVIDENCE: %s\n", + nodeLoc(entry.node), exploreDraftSymbol(entry.node), entry.node.ID, entry.evidence) + continue + } + fmt.Fprintf(&b, "- FILE: %s · SYMBOL: %s · EVIDENCE: %s\n", + nodeLoc(entry.node), exploreDraftSymbol(entry.node), entry.evidence) + } + fullBodyIDs := make(map[string]struct{}, exploreFullBodyLimit) + for _, id := range explorePreferredFullBodyIDs(task, targets, draft, exploreFullBodyLimit) { + fullBodyIDs[id] = struct{}{} + } + b.WriteString("\n## Likely targets (most-relevant first)\n") + b.WriteString("Graph-verified details follow; all candidate locations and signatures are retained, with full source for the strongest direct draft targets.\n") used := estimateTokens(b.String()) truncated := false @@ -247,15 +1722,16 @@ func (s *Server) renderExplore(task string, targets []exploreTarget, budget int) b.WriteString(head.String()) used += estimateTokens(head.String()) - // Source body: full while the budget holds (rank decides order, the - // budget decides where full source stops; no single body may take - // more than 1/exploreBodyBudgetShare of the whole budget), signature - // stub otherwise. The header/locations above are always emitted so + // Source body: full for the strongest direct draft targets while the + // budget holds (no single body may take more than 1/exploreBodyBudgetShare + // of the whole budget), signature stub otherwise. The header/locations + // above are always emitted so // file-hit / symbol-hit never depend on budget. body := "" if t.source != "" { cost := estimateTokens(t.source) - if used+cost <= budget && cost <= budget/exploreBodyBudgetShare { + _, preferred := fullBodyIDs[exploreDraftNodeKey(n)] + if preferred && used+cost <= budget && cost <= budget/exploreBodyBudgetShare { body = t.source } else { if sig, err := elide.CompressString(t.source, n.Language); err == nil && sig != "" { @@ -275,20 +1751,16 @@ func (s *Server) renderExplore(task string, targets []exploreTarget, budget int) } } - b.WriteString("\n## Files to change\n") + b.WriteString("\n## Candidate files\n") for _, f := range fileOrder { fmt.Fprintf(&b, "- %s · %s\n", f, strings.Join(dedupStrings(files[f]), ", ")) } - fmt.Fprintf(&b, "\n— Completeness: %d candidate symbol(s) across %d file(s); callers/callees resolved server-side from the graph. This is the ranked neighborhood for the request — a location not listed here is not on the ranked path. Answer (FILES / SYMBOLS / EVIDENCE) or start editing directly from this; the paths and line numbers above are real and citeable.\n", + fmt.Fprintf(&b, "\n— Coverage: %d ranked candidate symbol(s) across %d file(s); callers/callees resolved server-side. Locations and IDs above are citeable.\n", len(targets), len(fileOrder)) - // Terminality affordance: the source for each listed symbol is already in - // this response. Re-opening these files with Read / Glob is the measured - // wasted-turn trap (the indexed-source deny-hook rejects it); the follow-up - // reader is get_symbol_source / batch_symbols on the `id:` shown above. - b.WriteString(" The source for each symbol is included above — do not re-open these files with Read/Glob; read more of any listed symbol with get_symbol_source / batch_symbols using its exact `id:`.\n") + b.WriteString("END OF LOCALIZATION — localization-only callers answer from this evidence; diagnosis and change callers proceed from it.\n") if truncated { - fmt.Fprintf(&b, " (Some bodies are elided under the %d-token budget; every candidate's location is still listed above — fetch an elided body with get_symbol_source / batch_symbols using the exact `id:` shown on its line.)\n", budget) + fmt.Fprintf(&b, "Some bodies are signature-only under the %d-token budget; every candidate location remains listed.\n", budget) } return b.String() } @@ -309,6 +1781,72 @@ func exploreCodeDefinitionKind(k graph.NodeKind) bool { } } +// exploreDataDefinitionKind identifies leaf declarations whose repeated +// generic names carry little extra localization information. These remain real +// edit targets: the first/highest-ranked occurrence stays in place, and every +// occurrence stays available below the diversified head. +func exploreDataDefinitionKind(k graph.NodeKind) bool { + switch k { + case graph.KindField, graph.KindConstant, graph.KindVariable, graph.KindEnumMember: + return true + default: + return false + } +} + +func exploreShouldDiversifyByFile(class rerank.QueryClass) bool { + return class == rerank.QueryClassConcept +} + +// exploreCandidateFetchLimit leaves enough headroom for concept localization +// to survive generic exact-name collisions and non-localizable graph nodes +// before filtering and diversification. Literal identifier/path queries keep +// the tighter historical window because their exact ordering is the intent. +func exploreCandidateFetchLimit(maxSymbols int, class rerank.QueryClass) int { + factor := 4 + if class == rerank.QueryClassConcept { + factor = 8 + } + return clampInt(maxSymbols*factor, maxSymbols, 80) +} + +// diversifyRepeatedExploreNames performs stable, bounded name diversification +// for concept queries. One data declaration and two callable/type definitions +// per normalized name remain in the head; every overflow candidate is retained +// behind distinct concepts. Keeping two callables preserves useful overload or +// receiver families without letting a common method such as Validate consume +// the whole neighborhood. +func diversifyRepeatedExploreNames(cands []*rerank.Candidate, class rerank.QueryClass) []*rerank.Candidate { + if class != rerank.QueryClassConcept || len(cands) < 2 { + return cands + } + seen := make(map[string]int, len(cands)) + duplicates := make([]*rerank.Candidate, 0) + head := cands[:0] + for _, cand := range cands { + if cand == nil || cand.Node == nil { + head = append(head, cand) + continue + } + name := strings.ToLower(strings.TrimSpace(cand.Node.Name)) + if name == "" { + head = append(head, cand) + continue + } + limit := 2 + if exploreDataDefinitionKind(cand.Node.Kind) { + limit = 1 + } + if seen[name] >= limit { + duplicates = append(duplicates, cand) + continue + } + seen[name]++ + head = append(head, cand) + } + return append(head, duplicates...) +} + // exploreLocalizableKind reports whether a node kind is a place a // developer would actually edit to resolve a report — the localization // targets. Params, locals, closures, generic params, imports and file @@ -378,8 +1916,57 @@ func fenceLang(lang string) string { return lang } -// Query-shaping tuning. Generic structural thresholds — no vocabulary, no -// dependence on any corpus or task set. +// stripLeadingExploreDirective removes generic agent-task verbs from the start +// of a multi-word concept query. These verbs describe what the agent should do, +// not the subsystem being localized; letting an exact symbol named Audit or +// Validate own that token can otherwise dominate every domain term. Identifier +// and short queries are left untouched by the caller/query-length guard. +func stripLeadingExploreDirective(query string) string { + fields := strings.Fields(query) + if len(fields) < 4 { + return query + } + word := func(s string) string { + return strings.ToLower(strings.Trim(s, "\"'`.,;:()[]{}<>!?—-")) + } + fillers := map[string]struct{}{ + "please": {}, "can": {}, "could": {}, "would": {}, "you": {}, "help": {}, "me": {}, "to": {}, + } + directives := map[string]struct{}{ + "audit": {}, "check": {}, "diagnose": {}, "explain": {}, "find": {}, "fix": {}, + "implement": {}, "improve": {}, "investigate": {}, "review": {}, "trace": {}, + "understand": {}, "update": {}, "validate": {}, + } + i := 0 + for i < len(fields) && i < 4 { + if _, ok := fillers[word(fields[i])]; !ok { + break + } + i++ + } + if i >= len(fields) { + return query + } + if _, ok := directives[word(fields[i])]; !ok { + return query + } + i++ + if i < len(fields) && (word(fields[i]) == "and" || word(fields[i]) == "then") { + i++ + if i < len(fields) { + if _, ok := directives[word(fields[i])]; ok { + i++ + } + } + } + if len(fields)-i < 2 { + return query + } + return strings.Join(fields[i:], " ") +} + +// Query-shaping tuning. Generic structural thresholds — no corpus-specific +// vocabulary or benchmark-derived terms. const ( // shapeMinReportChars is the size above which a multi-line task is // treated as a pasted report worth distilling. Below it (or single-line) @@ -672,6 +2259,125 @@ func inlineLeadClause(task string) string { return lead } +// mergeExploreCandidates unions retrieval passes by symbol ID without mutating +// either input. Expansion text ranks are offset so a reduced concept bag can +// add recall without claiming the same authority as the original query. +func mergeExploreCandidates(primary, expanded []*rerank.Candidate, expansionRankOffset int) []*rerank.Candidate { + if expansionRankOffset < 0 { + expansionRankOffset = 0 + } + byID := make(map[string]*rerank.Candidate, len(primary)+len(expanded)) + out := make([]*rerank.Candidate, 0, len(primary)+len(expanded)) + add := func(candidate *rerank.Candidate, expansion bool) { + if candidate == nil || candidate.Node == nil { + return + } + clone := *candidate + if expansion && clone.TextRank >= 0 { + clone.TextRank += expansionRankOffset + } + if current, ok := byID[clone.Node.ID]; ok { + // A primary lexical rank always wins. Expansion may add a weaker + // lexical signal to a semantic-only primary candidate. + if clone.TextRank >= 0 && current.TextRank < 0 { + current.TextRank = clone.TextRank + } else if !expansion && clone.TextRank >= 0 && clone.TextRank < current.TextRank { + current.TextRank = clone.TextRank + } + if clone.VectorRank >= 0 && (current.VectorRank < 0 || clone.VectorRank < current.VectorRank) { + current.VectorRank = clone.VectorRank + } + return + } + byID[clone.Node.ID] = &clone + out = append(out, &clone) + } + for _, candidate := range primary { + add(candidate, false) + } + for _, candidate := range expanded { + add(candidate, true) + } + return out +} + +// hasExploreExpansionTerms reports whether concept normalization introduced a +// genuinely new discriminative term. A subset/equivalent bag would repeat the +// same FTS and materialization work without adding a retrieval channel. +func hasExploreExpansionTerms(query string, terms []string) bool { + if len(terms) == 0 { + return false + } + raw := rerank.Tokenize(query) + base := make(map[string]struct{}, len(raw)*2) + for _, token := range raw { + base[token] = struct{}{} + base[exploreTerminalTermRoot(token)] = struct{}{} + } + for _, token := range search.NormalizeFTSTokens(raw) { + base[token] = struct{}{} + } + for _, term := range search.NormalizeFTSTokens(terms) { + if _, ok := base[term]; !ok { + return true + } + } + return false +} + +// limitExploreCandidates cheaply fuses text and vector ranks before the full +// graph-aware reranker. It keeps vector-only evidence while bounding centrality +// and edge-hydration work to a predictable multiple of the response size. +func limitExploreCandidates(candidates []*rerank.Candidate, limit int) []*rerank.Candidate { + if limit <= 0 || len(candidates) <= limit { + return candidates + } + bounded := append([]*rerank.Candidate(nil), candidates...) + rrf := func(candidate *rerank.Candidate) float64 { + if candidate == nil || candidate.Node == nil { + return -1 + } + score := 0.0 + if candidate.TextRank >= 0 { + score += 1 / (60 + float64(candidate.TextRank+1)) + } + if candidate.VectorRank >= 0 { + score += 1 / (60 + float64(candidate.VectorRank+1)) + } + return score + } + sort.SliceStable(bounded, func(i, j int) bool { + return rrf(bounded[i]) > rrf(bounded[j]) + }) + return bounded[:limit] +} + +// exploreConceptRecallTerms preserves the query's discriminative concepts in +// first-seen order for the ordinary concept recall channel. The same generic +// and stopword filter used by answer-readiness keeps agent verbs and task +// boilerplate from consuming the bounded expansion bag. +func exploreConceptRecallTerms(text string) []string { + const maxTerms = 12 + allowed := exploreTerminalTerms(text) + seen := make(map[string]struct{}, len(allowed)) + out := make([]string, 0, min(len(allowed), maxTerms)) + for _, raw := range rerank.Tokenize(text) { + term := exploreTerminalTermRoot(strings.ToLower(strings.TrimSpace(raw))) + if _, ok := allowed[term]; !ok { + continue + } + if _, ok := seen[term]; ok { + continue + } + seen[term] = struct{}{} + out = append(out, term) + if len(out) >= maxTerms { + break + } + } + return out +} + // exploreLexicalTerms splits free task text into the distinct word/identifier // terms (length >= 3, capped) that feed the per-term BM25 OR-merge fallback. // Purely lexical — no vocabulary, no language model. diff --git a/internal/mcp/tools_explore_ripgrep_test.go b/internal/mcp/tools_explore_ripgrep_test.go new file mode 100644 index 000000000..98f2855aa --- /dev/null +++ b/internal/mcp/tools_explore_ripgrep_test.go @@ -0,0 +1,179 @@ +package mcp + +import ( + "testing" + + "github.com/zzet/gortex/internal/graph" +) + +func TestExploreAnswerDraftPrefersProductionOverMentionedTestHelperAndPromotesCrossFileCallee(t *testing.T) { + builder := &graph.Node{ + ID: "ripgrep/crates/ignore/src/walk.rs::WalkBuilder", Name: "WalkBuilder", QualName: "walk::WalkBuilder", + Kind: graph.KindFunction, FilePath: "crates/ignore/src/walk.rs", Language: "rust", + StartLine: 1, EndLine: 1, WorkspaceID: "bench", ProjectID: "ripgrep", RepoPrefix: "ripgrep", + } + helper := &graph.Node{ + ID: "ripgrep/crates/ignore/src/walk.rs::walk_collect_parallel", Name: "walk_collect_parallel", QualName: "crates::ignore::walk::walk_collect_parallel", + Kind: graph.KindFunction, FilePath: "crates/ignore/src/walk.rs", Language: "rust", Meta: map[string]any{"is_test": true}, + StartLine: 10, EndLine: 18, WorkspaceID: "bench", ProjectID: "ripgrep", RepoPrefix: "ripgrep", + } + buildParallel := &graph.Node{ + ID: "ripgrep/crates/ignore/src/walk.rs::WalkBuilder.build_parallel", Name: "build_parallel", QualName: "walk::WalkBuilder::build_parallel", + Kind: graph.KindMethod, FilePath: "crates/ignore/src/walk.rs", Language: "rust", + StartLine: 30, EndLine: 42, WorkspaceID: "bench", ProjectID: "ripgrep", RepoPrefix: "ripgrep", + } + buildWithCWD := &graph.Node{ + ID: "ripgrep/crates/ignore/src/dir.rs::IgnoreBuilder.build_with_cwd", Name: "build_with_cwd", QualName: "dir::IgnoreBuilder::build_with_cwd", + Kind: graph.KindMethod, FilePath: "crates/ignore/src/dir.rs", Language: "rust", + StartLine: 50, EndLine: 62, WorkspaceID: "bench", ProjectID: "ripgrep", RepoPrefix: "ripgrep", + } + + targets := []exploreTarget{ + {node: builder, source: "pub struct WalkBuilder { /* ignore traversal configuration */ }"}, + {node: helper, source: "#[test] fn walk_collect_parallel() { WalkBuilder::new().build_parallel(); }"}, + {node: buildParallel, source: "pub fn build_parallel(&self) { IgnoreBuilder::build_with_cwd(self.cwd); }", callees: []*graph.Node{buildWithCWD}}, + } + task := "Nondeterminism in ignore::WalkBuilder parallel multi-root walk: a scoped .gitignore rule from one root (e.g. tests/**/build/) incorrectly applies to unrelated root/added path (src/...) in build_parallel() walk, causing files to be nondeterministically excluded depending on thread scheduling/order of directory traversal." + if !exploreQueryIsConceptTask(task) { + t.Fatalf("natural-language issue with embedded symbols must remain concept-like") + } + exactID := exploreLocalizationExactTarget(task, targets) + if exactID != buildParallel.ID { + t.Fatalf("ripgrep-3419 must refine to production method %s, got %s", buildParallel.ID, exactID) + } + entries := exploreAnswerDraft(task, targets) + + productionIndex, helperIndex, structuralCount := -1, -1, 0 + var structural *exploreDraftEntry + for i := range entries { + entry := &entries[i] + switch entry.node.ID { + case buildParallel.ID: + productionIndex = i + case helper.ID: + helperIndex = i + } + if entry.structural { + structuralCount++ + structural = entry + } + } + if productionIndex < 0 || helperIndex < 0 { + t.Fatalf("draft missing production/helper: production=%d helper=%d", productionIndex, helperIndex) + } + if productionIndex >= helperIndex { + t.Fatalf("production target must outrank mentioned test helper: production=%d helper=%d", productionIndex, helperIndex) + } + if structuralCount != 1 || structural == nil || structural.node.ID != buildWithCWD.ID || !structural.structuralCrossFile || !structural.structuralCallee { + t.Fatalf("want one cross-file causal callee %s, got count=%d entry=%+v", buildWithCWD.ID, structuralCount, structural) + } + + evidence := localizationEvidenceTargetsFromDraft(task, buildParallel.ID, targets, entries) + foundNeighbor := false + for _, target := range evidence { + if target.node != nil && target.node.ID == buildWithCWD.ID { + foundNeighbor = true + if target.source != "" { + t.Fatalf("graph-only neighbor must be materialized by the bounded reader, got eager source %q", target.source) + } + } + } + if !foundNeighbor { + t.Fatalf("promoted cross-file callee absent from evidence") + } +} + +func TestExploreAnswerDraftKeepsExplicitTestHelperExact(t *testing.T) { + production := &graph.Node{ID: "ripgrep/crates/ignore/src/walk.rs::WalkBuilder.build_parallel", Name: "build_parallel", QualName: "walk::WalkBuilder::build_parallel", Kind: graph.KindMethod, FilePath: "crates/ignore/src/walk.rs", Language: "rust"} + helper := &graph.Node{ID: "ripgrep/crates/ignore/src/walk.rs::walk_collect_parallel", Name: "walk_collect_parallel", QualName: "crates::ignore::walk::walk_collect_parallel", Kind: graph.KindFunction, FilePath: "crates/ignore/src/walk.rs", Language: "rust", Meta: map[string]any{"is_test": true}} + targets := []exploreTarget{{node: production}, {node: helper}} + for _, query := range []string{ + "walk_collect_parallel", + "crates/ignore/src/walk.rs::walk_collect_parallel", + "fn walk_collect_parallel(prefix: &Path, builder: &WalkBuilder) -> Vec", + } { + t.Run(query, func(t *testing.T) { + if exploreQueryIsConceptTask(query) { + t.Fatalf("pure identifier/path/signature request must remain explicit") + } + entries := exploreAnswerDraft(query, targets) + if len(entries) == 0 || entries[0].node.ID != helper.ID || !entries[0].exact { + t.Fatalf("explicit helper request must remain exact, got %+v", entries) + } + }) + } +} + +func TestExploreQueryIsConceptTaskDistinguishesTrailingCallFromDeclarations(t *testing.T) { + tests := []struct { + name string + query string + want bool + }{ + { + name: "natural language ending in call", + query: "Explain why parallel multi-root traversal leaks ignore state into another root before it reaches build_parallel()", + want: true, + }, + { + name: "custom C return type", + query: "Result resolve_target(Context *ctx, Node node, Options opts, Resolver resolver, Diagnostics diagnostics);", + want: false, + }, + { + name: "Java declaration", + query: "public static CompletionStage resolveTarget(Context ctx, Node node, Options opts, Diagnostics diagnostics)", + want: false, + }, + { + name: "TypeScript declaration", + query: "export async function resolveTarget(ctx: Context, node: Node, options: Options): Promise", + want: false, + }, + { + name: "Rust declaration", + query: "pub async fn resolve_target(ctx: &Context, node: Node, options: Options) -> Result", + want: false, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := exploreQueryIsConceptTask(test.query); got != test.want { + t.Fatalf("exploreQueryIsConceptTask(%q) = %v, want %v", test.query, got, test.want) + } + }) + } +} + +func TestExploreAnswerDraftKeepsProsePathSymbolAnchorsExact(t *testing.T) { + production := &graph.Node{ID: "ripgrep/crates/ignore/src/walk.rs::WalkBuilder.build_parallel", Name: "build_parallel", QualName: "walk::WalkBuilder::build_parallel", Kind: graph.KindMethod, FilePath: "crates/ignore/src/walk.rs", Language: "rust"} + helper := &graph.Node{ID: "ripgrep/crates/ignore/src/walk.rs::walk_collect_parallel", Name: "walk_collect_parallel", QualName: "crates::ignore::walk::walk_collect_parallel", Kind: graph.KindFunction, FilePath: "crates/ignore/src/walk.rs", Language: "rust", Meta: map[string]any{"is_test": true}} + targets := []exploreTarget{{node: production}, {node: helper}} + tests := []struct { + name string + query string + want string + }{ + { + name: "test helper", + query: "Inspect crates/ignore/src/walk.rs::walk_collect_parallel directly to understand this exact test helper behavior", + want: helper.ID, + }, + { + name: "production method", + query: "Inspect crates/ignore/src/walk.rs::WalkBuilder.build_parallel directly to understand this exact production method behavior", + want: production.ID, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if !exploreQueryIsConceptTask(test.query) { + t.Fatalf("natural-language path anchor should remain a concept task") + } + entries := exploreAnswerDraft(test.query, targets) + if len(entries) == 0 || entries[0].node.ID != test.want || !entries[0].exact { + t.Fatalf("explicit path symbol must remain exact for %s, got %+v", test.want, entries) + } + }) + } +} diff --git a/internal/mcp/tools_explore_test.go b/internal/mcp/tools_explore_test.go index b511507c7..585f111e1 100644 --- a/internal/mcp/tools_explore_test.go +++ b/internal/mcp/tools_explore_test.go @@ -1,10 +1,20 @@ package mcp import ( + "context" + "encoding/json" + "fmt" + "os" "strings" "testing" + mcpgo "github.com/mark3labs/mcp-go/mcp" + "go.uber.org/zap" + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/query" + "github.com/zzet/gortex/internal/search" + "github.com/zzet/gortex/internal/search/rerank" ) func TestExploreLocalizableKind(t *testing.T) { @@ -31,11 +41,11 @@ func TestExploreLocalizableKind(t *testing.T) { } func exploreTestTargets() []exploreTarget { - fn := &graph.Node{Name: "DoWithRetry", Kind: graph.KindFunction, + fn := &graph.Node{ID: "retry.go::DoWithRetry", Name: "DoWithRetry", Kind: graph.KindFunction, FilePath: "retry.go", StartLine: 11, EndLine: 20, Language: "go"} - helper := &graph.Node{Name: "Backoff", Kind: graph.KindFunction, + helper := &graph.Node{ID: "retry.go::Backoff", Name: "Backoff", Kind: graph.KindFunction, FilePath: "retry.go", StartLine: 6, EndLine: 8, Language: "go"} - caller := &graph.Node{Name: "Fetch", Kind: graph.KindFunction, + caller := &graph.Node{ID: "client.go::Fetch", Name: "Fetch", Kind: graph.KindFunction, FilePath: "client.go", StartLine: 4, EndLine: 6, Language: "go"} return []exploreTarget{ {node: fn, score: 0.9, callers: []*graph.Node{caller}, callees: []*graph.Node{helper}, @@ -45,27 +55,597 @@ func exploreTestTargets() []exploreTarget { } } -func TestRenderExploreShape(t *testing.T) { +func TestRenderExploreShapeIsNonTerminal(t *testing.T) { out := (&Server{}).renderExplore("the retry backoff never fires on 429", exploreTestTargets(), 9000) - // Ranked targets, with citeable path:line locations. + // Ranked targets, with citeable path:line locations and an answer draft + // before the detailed payload. for _, want := range []string{ "EXPLORE — the retry backoff never fires on 429", + "RANKED LOCALIZATION:", + "Localization-only callers receive a separate completion contract", + "diagnosis and change callers continue from this evidence", + "## Answer draft", + "FILE: retry.go:11-20 · SYMBOL: DoWithRetry · EVIDENCE: ranked #1", "## Likely targets", "1. DoWithRetry function · retry.go:11-20", "2. Backoff function · retry.go:6-8", "^ callers: Fetch (client.go:4-6)", "v calls: Backoff (retry.go:6-8)", "func DoWithRetry(max int) error", // full body for hot target - "## Files to change", + "## Candidate files", "- retry.go · Backoff, DoWithRetry", - "— Completeness: 2 candidate symbol(s) across 1 file(s)", - "FILES / SYMBOLS / EVIDENCE", + "— Coverage: 2 ranked candidate symbol(s) across 1 file(s)", + "END OF LOCALIZATION — localization-only callers answer from this evidence; diagnosis and change callers proceed from it", } { if !strings.Contains(out, want) { t.Errorf("render missing %q\n--- got ---\n%s", want, out) } } + if strings.Index(out, "## Answer draft") >= strings.Index(out, "## Likely targets") { + t.Fatalf("answer draft must precede detailed targets:\n%s", out) + } + for _, forbidden := range []string{"LOCALIZATION COMPLETE:", "answer now", "Do not make another localization", "REFINEMENT NEEDED", "read(operation:", "Do not call another tool"} { + if strings.Contains(out, forbidden) { + t.Fatalf("ordinary explore(task) must not claim terminality via %q:\n%s", forbidden, out) + } + } +} + +func TestRenderExploreBroadWeakNeighborhoodReturnsBestSupportedDraft(t *testing.T) { + targets := []exploreTarget{ + {node: &graph.Node{ID: "internal/embedding/onnx.go::current", Name: "current", Kind: graph.KindVariable, FilePath: "internal/embedding/onnx.go"}, score: 0.9}, + {node: &graph.Node{ID: "internal/analyzer/state.go::dirty", Name: "dirty", Kind: graph.KindVariable, FilePath: "internal/analyzer/state.go"}, score: 0.8}, + } + task := "review release blockers across impact reach sqlite races mcp facade routing codex claude guidance explore ranking token cost" + out := (&Server{}).renderExplore(task, targets, 1600) + for _, want := range []string{"RANKED LOCALIZATION:", "with stated uncertainty", "## Answer draft", "Do not fan out or rerun broad exploration", "diagnosis and change callers continue from this evidence"} { + if !strings.Contains(out, want) { + t.Fatalf("weak neighborhood missing %q:\n%s", want, out) + } + } + for _, forbidden := range []string{"LOCALIZATION COMPLETE:", "REFINEMENT NEEDED", "make one focused refinement", "rerun explore for", "search(operation:"} { + if strings.Contains(out, forbidden) { + t.Fatalf("weak neighborhood must not invite broad refinement via %q:\n%s", forbidden, out) + } + } +} + +func TestRenderExploreAnswerDraftIncludesQueryAlignedGraphNeighbor(t *testing.T) { + head := &graph.Node{ID: "handler.go::HandleRequest", Name: "HandleRequest", Kind: graph.KindFunction, FilePath: "handler.go", StartLine: 10, EndLine: 18} + retry := &graph.Node{ID: "retry/coordinator.go::RetryCoordinator", Name: "RetryCoordinator", Kind: graph.KindFunction, FilePath: "retry/coordinator.go", StartLine: 20, EndLine: 30} + logger := &graph.Node{ID: "log.go::Logger", Name: "Logger", Kind: graph.KindFunction, FilePath: "log.go", StartLine: 5, EndLine: 8} + out := (&Server{}).renderExplore( + "locate retry coordinator implementation for transient failures", + []exploreTarget{{node: head, score: 1, callers: []*graph.Node{logger, retry}}}, + 1600, + ) + draftStart := strings.Index(out, "## Answer draft") + detailsStart := strings.Index(out, "## Likely targets") + if draftStart < 0 || detailsStart < 0 || draftStart >= detailsStart { + t.Fatalf("invalid draft/detail ordering:\n%s", out) + } + draft := out[draftStart:detailsStart] + for _, want := range []string{"SYMBOL: RetryCoordinator", "ID: retry/coordinator.go::RetryCoordinator", "retry/coordinator.go:20-30", "caller of ranked #1"} { + if !strings.Contains(draft, want) { + t.Fatalf("query-aligned neighbor missing %q from answer draft:\n%s", want, draft) + } + } + if strings.Contains(draft, "log.go::Logger") { + t.Fatalf("unrelated graph neighbor must not be promoted into answer draft:\n%s", draft) + } +} + +func TestRenderExploreAnswerDraftPromotesBoundedStructuralNeighbors(t *testing.T) { + node := func(id, name, file string) *graph.Node { + return &graph.Node{ID: id, Name: name, Kind: graph.KindMethod, FilePath: file, StartLine: 20} + } + genericHidden := node("crates/ignore/src/walk.rs::WalkBuilder.hidden", "hidden", "crates/ignore/src/walk.rs") + standardFilters := node("crates/ignore/src/walk.rs::WalkBuilder.standard_filters", "standard_filters", "crates/ignore/src/walk.rs") + isHidden := node("crates/ignore/src/walk.rs::Ignore.is_hidden", "is_hidden", "crates/ignore/src/walk.rs") + matched := node("crates/ignore/src/dir.rs::Ignore.matched_dir_entry", "matched_dir_entry", "crates/ignore/src/dir.rs") + testCaller := &graph.Node{ID: "crates/ignore/tests/gitignore_tests.rs::hidden_test", Name: "hidden_test", Kind: graph.KindFunction, FilePath: "crates/ignore/tests/gitignore_tests.rs"} + callers := []*graph.Node{testCaller, node("crates/core/src/log.rs::trace_event", "trace_event", "crates/core/src/log.rs"), matched} + targets := []exploreTarget{ + {node: genericHidden, callees: []*graph.Node{standardFilters}}, + {node: node("rank2", "ignore_rules", "crates/ignore/src/dir.rs")}, + {node: node("rank3", "walk_entries", "crates/ignore/src/walk.rs")}, + {node: node("rank4", "ignore", "crates/ignore/src/dir.rs")}, + {node: node("rank5", "hidden", "crates/ignore/src/dir.rs")}, + {node: node("rank6", "filter_entry", "crates/ignore/src/walk.rs")}, + {node: isHidden, callers: callers}, + {node: node("rank8", "walk_state", "crates/ignore/src/walk.rs")}, + {node: node("rank9", "path_filter", "crates/ignore/src/dir.rs")}, + {node: node("rank10", "ignore_match", "crates/ignore/src/dir.rs")}, + } + out := (&Server{}).renderExplore("locate is_hidden scoped ignore behavior", targets, 1600) + draft := out[strings.Index(out, "## Answer draft"):strings.Index(out, "## Likely targets")] + for _, want := range []string{"SYMBOL: matched_dir_entry", "ID: crates/ignore/src/dir.rs::Ignore.matched_dir_entry", "caller of ranked #7"} { + if !strings.Contains(draft, want) { + t.Fatalf("rank-7 structural caller missing %q from answer draft:\n%s", want, draft) + } + } + for _, forbidden := range []string{"hidden_test", "trace_event", "standard_filters"} { + if strings.Contains(draft, forbidden) { + t.Fatalf("generic or ineligible caller %q was promoted:\n%s", forbidden, draft) + } + } + + buildParallel := node("crates/ignore/src/walk.rs::WalkBuilder.build_parallel", "build_parallel", "crates/ignore/src/walk.rs") + buildWithCWD := node("crates/ignore/src/dir.rs::IgnoreBuilder.build_with_cwd", "build_with_cwd", "crates/ignore/src/dir.rs") + getCurrentDir := node("crates/ignore/src/walk.rs::get_or_set_current_dir", "get_or_set_current_dir", "crates/ignore/src/walk.rs") + build := node("crates/ignore/src/walk.rs::WalkBuilder.build", "build", "crates/ignore/src/walk.rs") + targets = []exploreTarget{ + {node: node("parallel-rank1", "parallel_walk", "crates/ignore/src/walk.rs")}, + {node: buildParallel, callees: []*graph.Node{build, getCurrentDir, buildWithCWD}}, + {node: node("parallel-rank3", "multi_root", "crates/ignore/src/walk.rs")}, + {node: node("parallel-rank4", "current_dir", "crates/ignore/src/walk.rs")}, + {node: node("parallel-rank5", "standard_filters", "crates/ignore/src/walk.rs")}, + {node: node("parallel-rank6", "add_root", "crates/ignore/src/walk.rs")}, + {node: node("parallel-rank7", "ignore_rule", "crates/ignore/src/dir.rs")}, + {node: node("parallel-rank8", "walk_state", "crates/ignore/src/walk.rs")}, + {node: node("parallel-rank9", "root_path", "crates/ignore/src/dir.rs")}, + {node: node("parallel-rank10", "ignore_match", "crates/ignore/src/dir.rs")}, + } + out = (&Server{}).renderExplore("Nondeterminism in ignore::WalkBuilder parallel multi-root walk", targets, 1600) + draft = out[strings.Index(out, "## Answer draft"):strings.Index(out, "## Likely targets")] + for _, want := range []string{"SYMBOL: build_with_cwd", "ID: crates/ignore/src/dir.rs::IgnoreBuilder.build_with_cwd", "callee of ranked #2"} { + if !strings.Contains(draft, want) { + t.Fatalf("cross-file structural callee missing %q from answer draft:\n%s", want, draft) + } + } + if strings.Contains(draft, "get_or_set_current_dir") { + t.Fatalf("query-overlapping but parent-unrelated callee displaced structural implementation:\n%s", draft) + } +} + +func TestExploreDraftGenericCandidateIsLanguageNeutral(t *testing.T) { + cases := []struct { + name string + path string + symbol string + source string + }{ + {name: "rust", path: "matcher.rs", symbol: "set_replacement", source: "fn set_replacement(&mut self, value: bool) { self.replacement = value; }"}, + {name: "go", path: "matcher.go", symbol: "SetReplacement", source: "func (m *Matcher) SetReplacement(value bool) { m.replacement = value }"}, + {name: "java", path: "Matcher.java", symbol: "setReplacement", source: "void setReplacement(boolean value) { this.replacement = value; }"}, + {name: "typescript", path: "matcher.ts", symbol: "setReplacement", source: "setReplacement(value: boolean) { this.replacement = value; }"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + n := &graph.Node{ID: tc.path + "::" + tc.symbol, Name: tc.symbol, Kind: graph.KindMethod, FilePath: tc.path} + if !exploreDraftGenericCandidate(n, tc.source) { + t.Fatalf("%s accessor was not classified structurally", tc.name) + } + }) + } + concrete := &graph.Node{ID: "matcher.go::ApplyReplacement", Name: "ApplyReplacement", Kind: graph.KindMethod, FilePath: "matcher.go"} + concreteSource := "func (m *Matcher) ApplyReplacement(parts []Part) { for _, part := range parts { m.output = append(m.output, part.Bytes()...) } }" + if exploreDraftGenericCandidate(concrete, concreteSource) { + t.Fatal("multi-step concrete implementation classified as generic") + } +} + +func TestExploreAnswerDraftExactTraitMethodBypassesGenericTieBreak(t *testing.T) { + traitMethod := &graph.Node{ID: "matcher.rs::Matcher.replace", Name: "replace", QualName: "Matcher.replace", Kind: graph.KindMethod, FilePath: "matcher.rs"} + implementation := &graph.Node{ID: "replacer.rs::Replacer.interpolate", Name: "interpolate", QualName: "Replacer.interpolate", Kind: graph.KindMethod, FilePath: "replacer.rs"} + task := "How does Matcher.replace choose replacement bytes?" + if rerank.ClassifyQuery(shapeExploreQuery(task)) != rerank.QueryClassConcept { + t.Fatalf("fixture must exercise the Concept path: %q", shapeExploreQuery(task)) + } + entries := exploreAnswerDraft(task, []exploreTarget{ + {node: implementation, source: "fn interpolate(&self, replacement: &[u8]) { for byte in replacement { self.output.push(*byte); } }"}, + {node: traitMethod, source: "trait Matcher { fn replace(&self, replacement: &[u8]) { self.replace_at(replacement); } }"}, + }) + if len(entries) == 0 || entries[0].node.ID != traitMethod.ID || !entries[0].exact || entries[0].generic { + t.Fatalf("exact trait method lost its anchor exemption: %#v", entries) + } +} + +func TestExploreAnswerDraftPrefersConcreteRustImplementationOverTraitDefaults(t *testing.T) { + node := func(id, name, qual string, kind graph.NodeKind) *graph.Node { + return &graph.Node{ID: id, Name: name, QualName: qual, Kind: kind, FilePath: "crates/grep-matcher/src/lib.rs", StartLine: 20} + } + matcher := node("matcher::Matcher", "Matcher", "Matcher", graph.KindType) + defaultReplace := node("matcher::Matcher.replace", "replace", "Matcher.replace", graph.KindMethod) + setter := node("matcher::Matcher.set_replacement", "set_replacement", "Matcher.set_replacement", graph.KindMethod) + replacer := node("matcher::Replacer", "Replacer", "Replacer", graph.KindType) + targets := []exploreTarget{ + {node: matcher, source: "pub trait Matcher { fn replace(&self, bytes: &[u8]); }"}, + {node: defaultReplace, source: "fn replace(&self, bytes: &[u8]) { self.replace_with_captures(bytes) }"}, + {node: setter, source: "fn set_replacement(&mut self, yes: bool) -> &mut Self { self.replacement = yes; self }"}, + {node: replacer, source: "pub struct Replacer; impl Replacer { fn interpolate(&self, captures: Captures, replacement: &[u8]) { for capture in captures.iter() { output.extend_from_slice(capture.bytes()); } } }"}, + } + + conceptTask := "How does replacement byte capture interpolation produce incorrect output?" + if rerank.ClassifyQuery(shapeExploreQuery(conceptTask)) != rerank.QueryClassConcept { + t.Fatalf("fixture must exercise the Concept path: %q", shapeExploreQuery(conceptTask)) + } + entries := exploreAnswerDraft(conceptTask, targets) + if len(entries) == 0 || entries[0].node.ID != replacer.ID { + t.Fatalf("concrete replacement implementation must precede trait defaults: %#v", entries) + } + for _, entry := range entries { + if entry.node.ID == replacer.ID && entry.generic { + t.Fatalf("concrete implementation classified as generic: %#v", entry) + } + } + + exact := exploreAnswerDraft("Matcher", targets) + if len(exact) == 0 || exact[0].node.ID != matcher.ID { + t.Fatalf("exact trait lookup must retain anchor order: %#v", exact) + } +} + +func TestExploreAnswerDraftReservesOneCrossFileCausalNeighborWithinBudget(t *testing.T) { + scope := query.QueryOptions{ + WorkspaceID: "bench", ProjectID: "ripgrep", RepoAllow: map[string]bool{"ripgrep": true}, + } + node := func(id, name, qual, file string) *graph.Node { + return &graph.Node{ + ID: id, Name: name, QualName: qual, Kind: graph.KindMethod, + FilePath: file, Language: "rust", StartLine: 1, EndLine: 1, + WorkspaceID: "bench", ProjectID: "ripgrep", RepoPrefix: "ripgrep", + } + } + const causalBody = "fn build_with_cwd(&self) {\n let ignore = self.parents_for_each_root();\n record(\"CROSS_FILE_CAUSAL_BODY_MARKER\", ignore);\n}\n" + causalPath := t.TempDir() + "/dir.rs" + if err := os.WriteFile(causalPath, []byte(causalBody), 0o600); err != nil { + t.Fatal(err) + } + buildParallel := node("walk::build_parallel", "build_parallel", "WalkBuilder.build_parallel", "crates/ignore/src/walk.rs") + buildWithCWD := node("dir::build_with_cwd", "build_with_cwd", "IgnoreBuilder.build_with_cwd", causalPath) + buildWithCWD.EndLine = 4 + sameFileBuild := node("walk::build", "build", "WalkBuilder.build", "crates/ignore/src/walk.rs") + genericGetter := node("walk::get_current_dir", "get_current_dir", "WalkBuilder.get_current_dir", "crates/ignore/src/walk.rs") + genericCaller := node("walk::set_parallel", "set_parallel", "WalkBuilder.set_parallel", "crates/ignore/src/walk.rs") + targets := []exploreTarget{ + {node: buildParallel, callers: []*graph.Node{genericCaller}, callees: []*graph.Node{sameFileBuild, genericGetter, buildWithCWD}, source: "fn build_parallel(&self) { self.ignore.build_with_cwd(&self.cwd); }"}, + {node: node("walk::parallel", "parallel_walk", "WalkBuilder.parallel_walk", "crates/ignore/src/walk.rs")}, + {node: node("walk::roots", "multiple_roots", "WalkBuilder.multiple_roots", "crates/ignore/src/walk.rs")}, + {node: node("walk::filters", "standard_filters", "WalkBuilder.standard_filters", "crates/ignore/src/walk.rs")}, + } + task := "Nondeterminism in ignore::WalkBuilder parallel multi-root walk\n" + + "ignore::WalkBuilder appears to produce nondeterministic results for a parallel multi-root walk when one root has a scoped ignore rule that should not apply to another root.\n" + + "The racy result suggests the per-directory ignore stack is inherited across roots." + if strings.Contains(task, "build_with_cwd") { + t.Fatal("fixture must not name the expected callee") + } + for _, target := range targets { + if target.node != nil && target.node.ID == buildWithCWD.ID { + t.Fatal("graph-only promoted neighbor must be absent from direct targets") + } + } + if rerank.ClassifyQuery(shapeExploreQuery(task)) != rerank.QueryClassConcept { + t.Fatalf("fixture must exercise the Concept path: %q", shapeExploreQuery(task)) + } + entries := exploreAnswerDraft(task, targets) + structural := 0 + for _, entry := range entries { + if entry.structural { + structural++ + if entry.node.ID != buildWithCWD.ID { + t.Fatalf("reserved structural slot = %q, want cross-file causal callee %q: %#v", entry.node.ID, buildWithCWD.ID, entries) + } + } + } + if structural != 1 { + t.Fatalf("structural quota = %d, want exactly 1: %#v", structural, entries) + } + if len(entries) > exploreDraftTotalLimit { + t.Fatalf("draft exceeded bounded cardinality: %d", len(entries)) + } + shuffledTargets := append([]exploreTarget(nil), targets...) + shuffledTargets[0].callers = []*graph.Node{genericCaller} + shuffledTargets[0].callees = []*graph.Node{genericGetter, buildWithCWD, sameFileBuild} + shuffled := exploreAnswerDraft(task, shuffledTargets) + if len(shuffled) != len(entries) { + t.Fatalf("shuffled draft size = %d, want %d", len(shuffled), len(entries)) + } + for i := range entries { + if shuffled[i].node.ID != entries[i].node.ID { + t.Fatalf("neighbor input order changed draft at %d: got %q want %q", i, shuffled[i].node.ID, entries[i].node.ID) + } + } + + server := &Server{} + reads := 0 + materialized := materializeExploreStructuralSourceWithReader( + context.Background(), task, targets, scope, + func(ctx context.Context, n *graph.Node) string { + reads++ + return server.manifestSymbolSource(ctx, n) + }, + ) + if reads != 1 { + t.Fatalf("promoted source reads = %d, want exactly one", reads) + } + if len(materialized) != len(targets)+1 || materialized[len(materialized)-1].node.ID != buildWithCWD.ID { + t.Fatalf("materialized targets = %#v, want one appended graph-only boundary", materialized) + } + if !strings.Contains(materialized[len(materialized)-1].source, "CROSS_FILE_CAUSAL_BODY_MARKER") { + t.Fatalf("promoted source was not read from its graph node range: %q", materialized[len(materialized)-1].source) + } + rendered := server.renderExplore(task, materialized, 1600) + if !strings.Contains(rendered, "CROSS_FILE_CAUSAL_BODY_MARKER") { + t.Fatalf("promoted cross-file boundary missing reserved rendered body:\n%s", rendered) + } + + const budget = 1000 + result := newLocalizationExploreResultForTask(newLocalizationCompletion(true, ""), task, materialized, budget) + text, ok := singleTextContent(result) + if !ok { + t.Fatalf("expected one text result: %#v", result) + } + if len(text) > budget*localizationEnvelopeBytesPerToken { + t.Fatalf("serialized envelope = %d bytes, budget = %d", len(text), budget*localizationEnvelopeBytesPerToken) + } + var envelope localizationExploreEnvelope + if err := json.Unmarshal([]byte(text), &envelope); err != nil { + t.Fatal(err) + } + if !strings.Contains(strings.Join(envelope.Symbols, "\n"), buildWithCWD.ID) { + t.Fatalf("cross-file causal boundary missing from bounded envelope: %#v", envelope.Symbols) + } + foundSource := false + for _, evidence := range envelope.Evidence { + if evidence.ID == buildWithCWD.ID && strings.Contains(evidence.Source, "CROSS_FILE_CAUSAL_BODY_MARKER") { + foundSource = true + break + } + } + if !foundSource { + t.Fatalf("promoted cross-file boundary did not receive its reserved source slot: %#v", envelope.Evidence) + } +} + +func TestExploreStructuralSourceMaterializationHonorsReadBoundary(t *testing.T) { + const baseTask = "Nondeterminism in ignore::WalkBuilder parallel multi-root walk\n" + + "ignore::WalkBuilder appears to produce nondeterministic results for a parallel multi-root walk when one root has a scoped ignore rule that should not apply to another root.\n" + + "The racy result suggests the per-directory ignore stack is inherited across roots." + scope := query.QueryOptions{ + WorkspaceID: "bench", ProjectID: "ripgrep", RepoAllow: map[string]bool{"ripgrep": true}, + } + makeTargets := func(neighborProject, neighborRepo string) ([]exploreTarget, *graph.Node) { + node := func(id, name, qual, file string) *graph.Node { + return &graph.Node{ + ID: id, Name: name, QualName: qual, Kind: graph.KindMethod, + FilePath: file, Language: "rust", StartLine: 1, EndLine: 4, + WorkspaceID: "bench", ProjectID: "ripgrep", RepoPrefix: "ripgrep", + } + } + neighbor := node("dir::build_with_cwd", "build_with_cwd", "IgnoreBuilder.build_with_cwd", "crates/ignore/src/dir.rs") + neighbor.ProjectID = neighborProject + neighbor.RepoPrefix = neighborRepo + primary := node("walk::build_parallel", "build_parallel", "WalkBuilder.build_parallel", "crates/ignore/src/walk.rs") + return []exploreTarget{ + {node: primary, callees: []*graph.Node{neighbor}, source: "fn build_parallel(&self) { self.ignore.build_with_cwd(&self.cwd); }"}, + {node: node("walk::parallel", "parallel_walk", "WalkBuilder.parallel_walk", "crates/ignore/src/walk.rs")}, + {node: node("walk::roots", "multiple_roots", "WalkBuilder.multiple_roots", "crates/ignore/src/walk.rs")}, + {node: node("walk::filters", "standard_filters", "WalkBuilder.standard_filters", "crates/ignore/src/walk.rs")}, + }, neighbor + } + assertPromoted := func(task string, targets []exploreTarget, wantID string) { + t.Helper() + for _, entry := range exploreAnswerDraft(task, targets) { + if entry.structural && entry.node != nil && entry.node.ID == wantID { + return + } + } + t.Fatalf("fixture did not promote %q before source guard", wantID) + } + assertNoRead := func(name string, ctx context.Context, task string, targets []exploreTarget, scope query.QueryOptions) { + t.Helper() + reads := 0 + got := materializeExploreStructuralSourceWithReader( + ctx, task, targets, scope, + func(context.Context, *graph.Node) string { + reads++ + return "SHOULD_NOT_BE_READ" + }, + ) + if reads != 0 { + t.Fatalf("%s: source reads = %d, want zero", name, reads) + } + if len(got) != len(targets) { + t.Fatalf("%s: materialized %d targets, want unchanged %d", name, len(got), len(targets)) + } + } + + for _, explicit := range []string{ + "build_parallel", + "WalkBuilder.build_parallel", + "fn build_parallel(&self) -> WalkParallel", + } { + if exploreAllowsStructuralBody(explicit) { + t.Errorf("explicit query %q must remain direct-only", explicit) + } + } + + pathTargets, pathNeighbor := makeTargets("ripgrep", "ripgrep") + pathTask := baseTask + "\nInvestigate crates/ignore/src/walk.rs without letting one root inherit another root's scoped rules." + if rerank.ClassifyQuery(shapeExploreQuery(pathTask)) != rerank.QueryClassConcept { + t.Fatalf("path fixture must remain prose Concept: %q", shapeExploreQuery(pathTask)) + } + assertPromoted(pathTask, pathTargets, pathNeighbor.ID) + assertNoRead("directory-qualified Concept prose", context.Background(), pathTask, pathTargets, scope) + + cancelTargets, cancelNeighbor := makeTargets("ripgrep", "ripgrep") + assertPromoted(baseTask, cancelTargets, cancelNeighbor.ID) + canceled, cancel := context.WithCancel(context.Background()) + cancel() + assertNoRead("canceled context", canceled, baseTask, cancelTargets, scope) + if got := (&Server{}).materializeExploreStructuralSource(canceled, baseTask, cancelTargets, scope); len(got) != len(cancelTargets) { + t.Fatalf("production wrapper materialized source after cancellation: got %d targets, want %d", len(got), len(cancelTargets)) + } + + projectTargets, projectNeighbor := makeTargets("other-project", "ripgrep") + assertPromoted(baseTask, projectTargets, projectNeighbor.ID) + assertNoRead("cross-project neighbor", context.Background(), baseTask, projectTargets, scope) + + repoTargets, repoNeighbor := makeTargets("ripgrep", "other-repo") + assertPromoted(baseTask, repoTargets, repoNeighbor.ID) + assertNoRead("cross-repository neighbor", context.Background(), baseTask, repoTargets, scope) +} + +func TestExploreAnswerDraftCapsAndDeduplicates(t *testing.T) { + node := func(id, name string, line int) *graph.Node { + return &graph.Node{ID: id, Name: name, Kind: graph.KindFunction, FilePath: id + ".go", StartLine: line} + } + deep := node("deep", "DeepTarget", 40) + targets := []exploreTarget{ + {node: node("alpha", "Alpha", 10), callers: []*graph.Node{deep}}, + {node: node("beta", "Beta", 20)}, + {node: node("gamma", "Gamma", 30)}, + {node: deep}, + {node: node("epsilon", "Epsilon", 50)}, + {node: node("zeta", "Zeta", 60)}, + } + entries := exploreAnswerDraft("locate DeepTarget implementation", targets) + if len(entries) != exploreDraftTotalLimit { + t.Fatalf("draft size = %d, want %d: %#v", len(entries), exploreDraftTotalLimit, entries) + } + if entries[0].node.ID != "deep" { + t.Fatalf("exact query-aligned target must lead the draft, got %q", entries[0].node.ID) + } + seen := map[string]int{} + for _, entry := range entries { + seen[exploreDraftNodeKey(entry.node)]++ + } + if seen["deep"] != 1 { + t.Fatalf("aligned node must be promoted exactly once, got %d: %#v", seen["deep"], entries) + } + for id, count := range seen { + if count != 1 { + t.Fatalf("draft node %q appears %d times", id, count) + } + } +} + +func TestExploreDirectoryQualifiedDraftAndBodiesRejectSameBasename(t *testing.T) { + node := func(id, name, path, source string) exploreTarget { + return exploreTarget{node: &graph.Node{ID: id, Name: name, QualName: name, Kind: graph.KindFunction, FilePath: path, Language: "go", StartLine: 10}, source: source} + } + wrong := node("pkg/wrong/config.go::Load", "Load", "pkg/wrong/config.go", "func Load() string {\n\treturn \"WRONG_FULL_BODY_MARKER\"\n}") + rightLoad := node("pkg/right/config.go::Load", "Load", "pkg/right/config.go", "func Load() string {\n\treturn \"RIGHT_LOAD_BODY_MARKER\"\n}") + rightSave := node("pkg/right/config.go::Save", "Save", "pkg/right/config.go", "func Save() string {\n\treturn \"RIGHT_SAVE_BODY_MARKER\"\n}") + targets := []exploreTarget{wrong, rightLoad, rightSave} + entries := exploreAnswerDraft("pkg/right/config.go", targets) + if len(entries) < 3 || entries[0].node.ID != rightLoad.node.ID || entries[1].node.ID != rightSave.node.ID { + t.Fatalf("strict path anchors did not preserve same-file retrieval order: %#v", entries) + } + if entries[0].exact != true || entries[1].exact != true || entries[2].exact { + t.Fatalf("same-basename path leaked into exact draft anchors: %#v", entries) + } + preferred := explorePreferredFullBodyIDs("pkg/right/config.go", targets, entries, exploreFullBodyLimit) + if len(preferred) != 2 || preferred[0] != exploreDraftNodeKey(rightLoad.node) || preferred[1] != exploreDraftNodeKey(rightSave.node) { + t.Fatalf("explicit path full-body order = %#v", preferred) + } + out := (&Server{}).renderExplore("pkg/right/config.go", targets, 1600) + for _, marker := range []string{"RIGHT_LOAD_BODY_MARKER", "RIGHT_SAVE_BODY_MARKER"} { + if !strings.Contains(out, marker) { + t.Fatalf("exact same-file body %q missing:\n%s", marker, out) + } + } + if strings.Contains(out, "WRONG_FULL_BODY_MARKER") { + t.Fatalf("same-basename body was promoted:\n%s", out) + } +} + +func TestExploreDraftExactAnchorUsesTokenBoundaries(t *testing.T) { + n := &graph.Node{Name: "Get", QualName: "Client.Get", FilePath: "runtime/get.go"} + if exploreDraftExactAnchor("target offset runtime user backend", n) { + t.Fatal("short identifier must not match inside unrelated words") + } + if !exploreDraftExactAnchor("trace Client.Get behavior", n) { + t.Fatal("qualified identifier token sequence should be an exact anchor") + } + if !exploreDraftExactAnchor("inspect get.go", n) { + t.Fatal("path basename should be an exact anchor") + } + generic := &graph.Node{Name: "write", QualName: "Writer.write", FilePath: "runtime/output.go"} + if exploreDraftExactAnchor("fix write behavior", generic) { + t.Fatal("unqualified generic method must not become an exact anchor") + } + if !exploreDraftExactAnchor("fix Writer.write behavior", generic) { + t.Fatal("qualified generic method should remain an exact anchor") + } + plainGeneric := &graph.Node{Name: "write", QualName: "write", FilePath: "runtime/output.go"} + if exploreDraftExactAnchor("fix write behavior", plainGeneric) { + t.Fatal("duplicated unqualified qualname must not bypass the generic-name guard") + } + convert := &graph.Node{Name: "Convert", QualName: "Convert", FilePath: "runtime/value.cs"} + if exploreDraftExactAnchor("review Convert behavior", convert) { + t.Fatal("unqualified cross-language generic method must not become an exact anchor") + } + convert.QualName = "Thing.Convert" + if !exploreDraftExactAnchor("review Thing.Convert behavior", convert) { + t.Fatal("qualified cross-language generic method should remain an exact anchor") + } +} + +func TestExploreBroadWellAlignedNeighborhoodGetsExplicitAnswerReadyContract(t *testing.T) { + targets := []exploreTarget{{ + node: &graph.Node{ID: "internal/mcp/facade_tools.go::registerFacadeTools", Name: "registerFacadeTools", Kind: graph.KindMethod, FilePath: "internal/mcp/facade_tools.go"}, + score: 1.2, + }} + task := "investigate mcp facade tool routing schema registration operation dispatch surface architecture integration behavior" + if !exploreAnswerReady(task, targets) { + t.Fatal("well-aligned ranked head should produce answer_ready for explore(localize)") + } + out := (&Server{}).renderExplore(task, targets, 1600) + if !strings.Contains(out, "RANKED LOCALIZATION:") || strings.Contains(out, "LOCALIZATION COMPLETE:") { + t.Fatalf("ordinary explore(task) rendering must stay non-terminal:\n%s", out) + } + for _, forbidden := range []string{"exact-ID source read", "REFINEMENT", "rerun explore", "read(operation:"} { + if strings.Contains(out, forbidden) { + t.Fatalf("well-aligned neighborhood must not invite another call via %q:\n%s", forbidden, out) + } + } +} + +func TestRenderExplorePacksStrongestDraftBody(t *testing.T) { + targets := []exploreTarget{ + {node: &graph.Node{ID: "generic.go::Current", Name: "Current", Kind: graph.KindFunction, FilePath: "generic.go", StartLine: 1}, source: "func Current() {\n\tgenericOne()\n}"}, + {node: &graph.Node{ID: "generic.go::State", Name: "State", Kind: graph.KindFunction, FilePath: "generic.go", StartLine: 10}, source: "func State() {\n\tgenericTwo()\n}"}, + {node: &graph.Node{ID: "retry.go::DeepTarget", Name: "DeepTarget", Kind: graph.KindFunction, FilePath: "retry.go", StartLine: 20}, source: "func DeepTarget() {\n\tdecisiveBody()\n}"}, + } + out := (&Server{}).renderExplore("locate DeepTarget implementation", targets, 9000) + draftStart := strings.Index(out, "## Answer draft") + if draftStart < 0 { + t.Fatalf("missing answer draft:\n%s", out) + } + if first := strings.Index(out[draftStart:], "SYMBOL:"); first < 0 || !strings.HasPrefix(out[draftStart+first:], "SYMBOL: DeepTarget") { + t.Fatalf("query-aligned target must lead the answer draft:\n%s", out) + } + if !strings.Contains(out, "decisiveBody()") { + t.Fatalf("query-aligned direct target must receive a full source body:\n%s", out) + } +} + +func TestRenderExploreDefaultBudgetRetainsTopBody(t *testing.T) { + targets := exploreTestTargets() + for i := 0; i < 4; i++ { + targets = append(targets, exploreTarget{node: &graph.Node{ + ID: fmt.Sprintf("internal/retry/candidate_%d.go::Candidate%d", i, i), + Name: fmt.Sprintf("Candidate%d", i), + Kind: graph.KindFunction, + FilePath: fmt.Sprintf("internal/retry/candidate_%d.go", i), + StartLine: 10 + i, + }}) + } + out := (&Server{}).renderExplore("the retry backoff never fires on 429", targets, exploreDefaultBudgetTokens) + if !strings.Contains(out, "func DoWithRetry(max int) error") { + t.Fatalf("compact answer draft must not displace the top source body at the default budget:\n%s", out) + } + draftStart := strings.Index(out, "## Answer draft") + detailsStart := strings.Index(out, "## Likely targets") + if draftStart < 0 || detailsStart < 0 { + t.Fatalf("missing draft/detail sections:\n%s", out) + } + if rows := strings.Count(out[draftStart:detailsStart], "- FILE:"); rows > exploreDraftTotalLimit { + t.Fatalf("answer draft has %d rows, limit %d:\n%s", rows, exploreDraftTotalLimit, out[draftStart:detailsStart]) + } } func TestRenderExploreBudgetTruncation(t *testing.T) { @@ -76,7 +656,7 @@ func TestRenderExploreBudgetTruncation(t *testing.T) { for _, want := range []string{ "1. DoWithRetry function · retry.go:11-20", "2. Backoff function · retry.go:6-8", - "— Completeness:", + "— Coverage:", } { if !strings.Contains(out, want) { t.Errorf("truncated render missing %q", want) @@ -84,6 +664,24 @@ func TestRenderExploreBudgetTruncation(t *testing.T) { } } +func TestRenderExploreLimitsFullBodiesToTopTargets(t *testing.T) { + targets := exploreTestTargets() + targets = append(targets, exploreTarget{ + node: &graph.Node{Name: "Third", Kind: graph.KindFunction, FilePath: "third.go", StartLine: 1, EndLine: 4, Language: "go"}, + source: "func Third() int {\n\treturn 3\n}", + }) + out := (&Server{}).renderExplore("task", targets, exploreDefaultBudgetTokens) + if exploreDefaultBudgetTokens != 1600 { + t.Fatalf("default explore budget=%d want 1600", exploreDefaultBudgetTokens) + } + if !strings.Contains(out, "3. Third function") { + t.Fatal("third candidate header was dropped") + } + if strings.Contains(out, "return 3") { + t.Fatal("third candidate unexpectedly retained a full body") + } +} + func TestExploreHelpers(t *testing.T) { if got := clampInt(5, 1, 3); got != 3 { t.Errorf("clampInt hi: got %d", got) @@ -103,4 +701,445 @@ func TestExploreHelpers(t *testing.T) { if got := dedupStrings([]string{"b", "a", "b"}); strings.Join(got, ",") != "a,b" { t.Errorf("dedupStrings: %v", got) } + got := exploreConceptRecallTerms("Find the code responsible for case insensitive alternation literal prefixes") + joined := strings.Join(got, ",") + for _, want := range []string{"case", "insensitive", "alternation", "literal", "prefix"} { + if !strings.Contains(joined, want) { + t.Errorf("concept recall terms missing %q: %v", want, got) + } + } + for _, noise := range []string{"find", "code"} { + if strings.Contains(joined, noise) { + t.Errorf("concept recall terms retained generic %q: %v", noise, got) + } + } + if hasExploreExpansionTerms("case insensitive alternation", []string{"case", "alternation"}) { + t.Fatal("subset concept bag should not trigger duplicate retrieval") + } + if hasExploreExpansionTerms("case insensitive alternations", []string{"case", "alternation"}) { + t.Fatal("FTS-equivalent concept root should not trigger duplicate retrieval") + } + if !hasExploreExpansionTerms("case insensitive alternation", []string{"case", "prefix"}) { + t.Fatal("genuinely new concept term should trigger expansion retrieval") + } + + semantic := &rerank.Candidate{Node: &graph.Node{ID: "semantic"}, TextRank: -1, VectorRank: 0} + textDuplicate := &rerank.Candidate{Node: &graph.Node{ID: "semantic"}, TextRank: 2, VectorRank: -1} + textOnly := &rerank.Candidate{Node: &graph.Node{ID: "text"}, TextRank: 0, VectorRank: -1} + merged := mergeExploreCandidates([]*rerank.Candidate{semantic}, []*rerank.Candidate{textDuplicate, textOnly}, 80) + if len(merged) != 2 || merged[0].Node.ID != "semantic" || merged[0].VectorRank != 0 || merged[0].TextRank != 82 { + t.Fatalf("candidate merge lost hybrid ranks or expansion provenance: %#v", merged) + } + if semantic.TextRank != -1 { + t.Fatalf("candidate merge mutated its primary input: %#v", semantic) + } + + primaryText := &rerank.Candidate{Node: &graph.Node{ID: "primary"}, TextRank: 0, VectorRank: -1} + primaryMerged := mergeExploreCandidates([]*rerank.Candidate{primaryText}, []*rerank.Candidate{{Node: primaryText.Node, TextRank: 0, VectorRank: -1}}, 80) + if primaryMerged[0].TextRank != 0 { + t.Fatalf("expansion rank displaced primary query rank: %#v", primaryMerged[0]) + } + + window := make([]*rerank.Candidate, 0, 11) + for i := 0; i < 10; i++ { + window = append(window, &rerank.Candidate{Node: &graph.Node{ID: fmt.Sprintf("text-%d", i)}, TextRank: i, VectorRank: -1}) + } + window = append(window, &rerank.Candidate{Node: &graph.Node{ID: "vector-only"}, TextRank: -1, VectorRank: 0}) + bounded := limitExploreCandidates(window, 5) + foundVector := false + for _, candidate := range bounded { + foundVector = foundVector || candidate.Node.ID == "vector-only" + } + if !foundVector { + t.Fatalf("bounded candidate union dropped top vector-only evidence: %#v", bounded) + } +} + +// TestFacadeExploreDemotesRepeatedDataLeafNames reproduces the reported +// localization failure through the public facade: many unrelated declarations +// named `client` used to consume the whole result head, excluding the callable +// definition that explains the area. One best data-leaf match may remain, but +// repeated same-name leaves must not crowd out a differently-named code target. +func TestFacadeExploreDemotesRepeatedDataLeafNames(t *testing.T) { + g := graph.New() + bm := search.NewBM25() + for i := 0; i < 30; i++ { + id := fmt.Sprintf("pkg/service%d.go::client", i) + n := &graph.Node{ + ID: id, Name: "client", Kind: graph.KindVariable, + FilePath: fmt.Sprintf("pkg/service%d.go", i), Language: "go", + Meta: map[string]any{"signature": "var client *Transport"}, + } + g.AddNode(n) + bm.Add(id, n.Name, n.FilePath, "trace client coordinated transport") + } + relevant := &graph.Node{ + ID: "pkg/coordinator.go::TransportCoordinator", Name: "TransportCoordinator", + Kind: graph.KindFunction, FilePath: "pkg/coordinator.go", Language: "go", + Meta: map[string]any{"signature": "func TransportCoordinator()"}, + } + g.AddNode(relevant) + // It matches the whole task, but its longer prose and non-literal symbol + // name rank below the short exact-name `client` declarations. The concept + // over-fetch window must retain it so name diversification can promote it. + relevantText := strings.Repeat("architecture routing plumbing lifecycle ", 40) + "trace client coordinated transport coordinator" + bm.Add(relevant.ID, relevant.Name, relevant.FilePath, relevantText) + + eng := query.NewEngine(g) + eng.SetSearch(bm) + srv := NewServer(eng, g, nil, nil, zap.NewNop(), nil) + task := "trace how the client is coordinated" + searchQuery := shapeExploreQuery(task) + const maxSymbols = 6 + queryClass := rerank.ClassifyQuery(searchQuery) + raw := eng.SearchSymbolsRanked(searchQuery, exploreCandidateFetchLimit(maxSymbols, queryClass), query.QueryOptions{}, srv.buildRerankContext(context.Background(), searchQuery)) + rawHead := make([]string, 0, 6) + relevantRetrieved := false + for _, candidate := range raw { + if candidate != nil && candidate.Node != nil && candidate.Node.ID == relevant.ID { + relevantRetrieved = true + } + if len(rawHead) == 6 { + continue + } + if candidate == nil || candidate.Node == nil || !exploreLocalizableKind(candidate.Node.Kind) || !exploreCodeDefinitionKind(candidate.Node.Kind) { + continue + } + rawHead = append(rawHead, candidate.Node.ID) + } + if len(rawHead) != 6 { + t.Fatalf("fixture produced only %d raw localization candidates: %v", len(rawHead), rawHead) + } + if !relevantRetrieved { + rawIDs := make([]string, 0, len(raw)) + for _, candidate := range raw { + if candidate != nil && candidate.Node != nil { + rawIDs = append(rawIDs, candidate.Node.ID) + } + } + t.Fatalf("fixture's relevant callable fell outside the concept-query over-fetch window: query=%q raw=%v", searchQuery, rawIDs) + } + for _, id := range rawHead { + if id == relevant.ID { + t.Fatalf("fixture no longer reproduces crowd-out before explore diversification: %v", rawHead) + } + } + req := mcpgo.CallToolRequest{} + // Omit operation deliberately: the facade default must route to task. + req.Params.Arguments = map[string]any{ + "task": task, "options": map[string]any{"max_symbols": maxSymbols}, + } + result, err := srv.handleFacade(context.Background(), "explore", req) + if err != nil { + t.Fatal(err) + } + if result == nil || result.IsError || len(result.Content) == 0 { + t.Fatalf("explore failed: %#v", result) + } + text, ok := result.Content[0].(mcpgo.TextContent) + if !ok { + t.Fatalf("unexpected explore result content: %#v", result.Content[0]) + } + out := text.Text + if strings.Contains(out, "get_symbol_source") || strings.Contains(out, "batch_symbols") || strings.Contains(out, "search_text") || strings.Contains(out, "find_files") { + t.Fatalf("explore emitted unavailable legacy follow-up guidance:\n%s", out) + } + relevantAt := strings.Index(out, "TransportCoordinator") + if relevantAt < 0 { + t.Fatalf("callable target was crowded out by repeated data leaves:\n%s", out) + } + firstClient := strings.Index(out, ". client variable") + if firstClient < 0 { + t.Fatalf("fixture did not surface its best literal data-leaf match:\n%s", out) + } + secondClient := strings.Index(out[firstClient+1:], ". client variable") + if secondClient >= 0 && firstClient+1+secondClient < relevantAt { + t.Fatalf("repeated generic data leaves still rank ahead of the callable target:\n%s", out) + } +} + +func TestExploreFileDiversificationIsStrictlyConceptOnly(t *testing.T) { + if !exploreShouldDiversifyByFile(rerank.QueryClassConcept) { + t.Fatal("Concept query must retain bounded per-file diversification") + } + for _, class := range []rerank.QueryClass{rerank.QueryClassSymbol, rerank.QueryClassSignature} { + if exploreShouldDiversifyByFile(class) { + t.Fatalf("explicit query class %v must preserve retrieval order", class) + } + } +} + +func TestDiversifyRepeatedExploreNamesIsConceptOnlyAndStable(t *testing.T) { + leaf1 := &rerank.Candidate{Node: &graph.Node{ID: "a", Name: "client", Kind: graph.KindVariable}} + leaf2 := &rerank.Candidate{Node: &graph.Node{ID: "b", Name: "client", Kind: graph.KindField}} + validate1 := &rerank.Candidate{Node: &graph.Node{ID: "v1", Name: "Validate", Kind: graph.KindMethod}} + validate2 := &rerank.Candidate{Node: &graph.Node{ID: "v2", Name: "Validate", Kind: graph.KindMethod}} + validate3 := &rerank.Candidate{Node: &graph.Node{ID: "v3", Name: "Validate", Kind: graph.KindFunction}} + callable := &rerank.Candidate{Node: &graph.Node{ID: "c", Name: "FacadeRegistry", Kind: graph.KindFunction}} + input := []*rerank.Candidate{leaf1, leaf2, validate1, validate2, validate3, callable} + + concept := diversifyRepeatedExploreNames(append([]*rerank.Candidate(nil), input...), rerank.QueryClassConcept) + want := []*rerank.Candidate{leaf1, validate1, validate2, callable, leaf2, validate3} + for i := range want { + if concept[i] != want[i] { + t.Fatalf("concept diversification[%d]=%s want %s", i, concept[i].Node.ID, want[i].Node.ID) + } + } + symbol := diversifyRepeatedExploreNames(append([]*rerank.Candidate(nil), input...), rerank.QueryClassSymbol) + for i := range input { + if symbol[i] != input[i] { + t.Fatalf("identifier lookup order changed at %d", i) + } + } + if got := exploreCandidateFetchLimit(6, rerank.QueryClassConcept); got != 48 { + t.Fatalf("concept fetch limit=%d want 48", got) + } + if got := exploreCandidateFetchLimit(6, rerank.QueryClassSymbol); got != 24 { + t.Fatalf("symbol fetch limit=%d want 24", got) + } +} + +func TestStripLeadingExploreDirective(t *testing.T) { + for input, want := range map[string]string{ + "Audit and fix MCP facade impact handling": "MCP facade impact handling", + "Please validate operation schema discoverability": "operation schema discoverability", + "How does Validate work": "How does Validate work", + "fix impact": "fix impact", + } { + if got := stripLeadingExploreDirective(input); got != want { + t.Errorf("stripLeadingExploreDirective(%q)=%q want %q", input, got, want) + } + } +} + +func TestExploreLocalizationExactTargetPrefersRareTaskCoverage(t *testing.T) { + method := func(id, name, qualName, file string) exploreTarget { + return exploreTarget{node: &graph.Node{ + ID: id, Name: name, QualName: qualName, Kind: graph.KindMethod, + FilePath: file, Meta: map[string]any{"search_qual_name": qualName}, + }} + } + targets := []exploreTarget{ + method("regex/matcher.rs::RegexMatcherBuilder.case_insensitive", "case_insensitive", "RegexMatcherBuilder.case_insensitive", "regex/matcher.rs"), + method("pcre/matcher.rs::RegexMatcherBuilder.case_insensitive", "case_insensitive", "RegexMatcherBuilder.case_insensitive", "pcre/matcher.rs"), + method("ignore/walk.rs::WalkBuilder.case_insensitive", "case_insensitive", "WalkBuilder.case_insensitive", "ignore/walk.rs"), + method("regex/literal.rs::Extractor.extract_alternation", "extract_alternation", "Extractor.extract_alternation", "regex/literal.rs"), + } + task := "case-insensitive literal alternation optimization causes missed matches" + if got, want := exploreLocalizationExactTarget(task, targets), targets[3].node.ID; got != want { + t.Fatalf("exact target=%q want rare conjunctive implementation %q", got, want) + } +} + +func TestExploreLocalizationExactTargetIsStableAndHonorsExplicitAnchor(t *testing.T) { + head := exploreTarget{node: &graph.Node{ID: "a.go::Alpha", Name: "Alpha", Kind: graph.KindFunction, FilePath: "a.go"}} + second := exploreTarget{node: &graph.Node{ID: "b.go::Beta", Name: "Beta", Kind: graph.KindFunction, FilePath: "b.go"}} + if got := exploreLocalizationExactTarget("alpha beta behavior", []exploreTarget{head, second}); got != head.node.ID { + t.Fatalf("equal evidence must retain retrieval order, got %q", got) + } + setter := exploreTarget{node: &graph.Node{ + ID: "regex/matcher.rs::RegexMatcherBuilder.case_insensitive", Name: "case_insensitive", + QualName: "RegexMatcherBuilder.case_insensitive", Kind: graph.KindMethod, FilePath: "regex/matcher.rs", + Meta: map[string]any{"search_qual_name": "RegexMatcherBuilder.case_insensitive"}, + }} + if got := exploreLocalizationExactTarget("inspect RegexMatcherBuilder.case_insensitive", []exploreTarget{head, setter}); got != setter.node.ID { + t.Fatalf("explicit qualified anchor must win, got %q", got) + } +} + +func TestExploreAnswerReadyRequiresQualifiedSymbolAnchor(t *testing.T) { + wrong := &graph.Node{ + ID: "repo/pkg/other.go::Other.Validate", Name: "Validate", Kind: graph.KindMethod, + FilePath: "pkg/other.go", StartLine: 10, EndLine: 20, + } + if exploreAnswerReady("Client.Validate", []exploreTarget{{node: wrong}}) { + t.Fatal("same-name method in a different qualifier must not be terminal") + } + + right := &graph.Node{ + ID: "repo/pkg/client.go::Client.Validate", Name: "Validate", Kind: graph.KindMethod, + FilePath: "pkg/client.go", StartLine: 10, EndLine: 20, + } + if !exploreAnswerReady("Client.Validate", []exploreTarget{{node: right}}) { + t.Fatal("fully-qualified symbol anchor should be terminal") + } +} + +func TestExploreExplicitPathAnchorDoesNotCrossSameBasename(t *testing.T) { + n := &graph.Node{ + ID: "repo/pkg/b/config.go::Load", Name: "Load", Kind: graph.KindFunction, + FilePath: "pkg/b/config.go", StartLine: 1, EndLine: 5, + } + if exploreAnswerReady("pkg/a/config.go", []exploreTarget{{node: n}}) { + t.Fatal("directory-qualified path must not fall back to the matching basename") + } + if !exploreAnswerReady("pkg/b/config.go", []exploreTarget{{node: n}}) { + t.Fatal("full normalized repository-relative path should be terminal") + } + if !exploreLocalizationExplicitAnchor("config.go", n) { + t.Fatal("basename fallback should remain available without a directory") + } +} + +func TestLocalizationEvidenceReservesExactBeforePromotedNeighbor(t *testing.T) { + primary := &graph.Node{ + ID: "repo/walk.go::WalkBuilder.build_parallel", Name: "build_parallel", Kind: graph.KindMethod, + FilePath: "walk.go", StartLine: 10, EndLine: 20, + } + exact := &graph.Node{ + ID: "repo/walk.go::WalkBuilder.build_serial", Name: "build_serial", Kind: graph.KindMethod, + FilePath: "walk.go", StartLine: 30, EndLine: 40, + } + promoted := &graph.Node{ + ID: "repo/ignore.go::IgnoreBuilder.build_with_cwd", Name: "build_with_cwd", Kind: graph.KindMethod, + FilePath: "ignore.go", StartLine: 50, EndLine: 60, + } + targets := []exploreTarget{ + {node: primary, callees: []*graph.Node{promoted}}, + {node: exact}, + } + selected := localizationEvidenceTargets("parallel multi-root walk build_with_cwd", exact.ID, targets) + if len(selected) != 2 { + t.Fatalf("selected evidence = %d, want 2", len(selected)) + } + if selected[0].node.ID != primary.ID || selected[1].node.ID != exact.ID { + t.Fatalf("selected evidence = [%s, %s], want primary then exact", selected[0].node.ID, selected[1].node.ID) + } + + result := newLocalizationExploreResultForTask( + newLocalizationCompletion(false, exact.ID), + "parallel multi-root walk build_with_cwd", + targets, + 512, + ) + encoded, err := json.Marshal(result) + if err != nil { + t.Fatal(err) + } + var wire struct { + Content []struct { + Text string `json:"text"` + } `json:"content"` + } + if err := json.Unmarshal(encoded, &wire); err != nil || len(wire.Content) == 0 { + t.Fatalf("decode tool result: %v", err) + } + var envelope localizationExploreEnvelope + if err := json.Unmarshal([]byte(wire.Content[0].Text), &envelope); err != nil { + t.Fatal(err) + } + if len(envelope.Symbols) != 2 || envelope.Symbols[0] != primary.ID || envelope.Symbols[1] != exact.ID { + t.Fatalf("symbols = %v, want primary and exact", envelope.Symbols) + } + if len(envelope.Evidence) != 2 || envelope.Evidence[1].ID != exact.ID { + t.Fatalf("exact evidence missing: %+v", envelope.Evidence) + } + if len(envelope.Files) != 1 || envelope.Files[0] != "walk.go" { + t.Fatalf("files = %v, want exact target file retained with primary", envelope.Files) + } +} + +func TestLocalizationEnvelopeEnforcesSerializedBudgetWithLongMetadata(t *testing.T) { + primary := &graph.Node{ + ID: "repo/primary.go::Primary", Name: "Primary", Kind: graph.KindFunction, + FilePath: "primary.go", StartLine: 1, EndLine: 10, + } + exact := &graph.Node{ + ID: "repo/exact.go::Exact", Name: "Exact", Kind: graph.KindFunction, + FilePath: "exact.go", StartLine: 1, EndLine: 10, + } + longMetadata := strings.Repeat("quoted-\"-slash-\\-metadata-", 24) + neighbors := make([]*graph.Node, 0, 12) + for i := 0; i < 12; i++ { + neighbors = append(neighbors, &graph.Node{ID: fmt.Sprintf("repo/neighbor-%02d-%s", i, longMetadata)}) + } + targets := []exploreTarget{ + {node: primary, callers: neighbors, callees: neighbors, source: longMetadata}, + {node: exact, callers: neighbors, callees: neighbors, source: longMetadata}, + } + for i := 0; i < 10; i++ { + targets = append(targets, exploreTarget{node: &graph.Node{ + ID: fmt.Sprintf("repo/optional-%02d.go::%s", i, longMetadata), + Name: longMetadata, Kind: graph.KindFunction, + FilePath: fmt.Sprintf("optional/%02d/%s.go", i, longMetadata), StartLine: 1, EndLine: 2, + }, source: longMetadata}) + } + + const budget = 512 + result := newLocalizationExploreResultForTask(newLocalizationCompletion(false, exact.ID), "", targets, budget) + encoded, err := json.Marshal(result) + if err != nil { + t.Fatal(err) + } + var wire struct { + Content []struct { + Text string `json:"text"` + } `json:"content"` + } + if err := json.Unmarshal(encoded, &wire); err != nil || len(wire.Content) == 0 { + t.Fatalf("decode tool result: %v", err) + } + text := wire.Content[0].Text + if len(text) > budget*localizationEnvelopeBytesPerToken { + t.Fatalf("serialized envelope = %d bytes, budget = %d", len(text), budget*localizationEnvelopeBytesPerToken) + } + var envelope localizationExploreEnvelope + if err := json.Unmarshal([]byte(text), &envelope); err != nil { + t.Fatal(err) + } + if len(envelope.Symbols) < 2 || envelope.Symbols[0] != primary.ID || envelope.Symbols[1] != exact.ID { + t.Fatalf("mandatory symbols not retained: %v", envelope.Symbols) + } + if len(envelope.Evidence) < 2 || envelope.Evidence[0].ID != primary.ID || envelope.Evidence[1].ID != exact.ID { + t.Fatalf("mandatory evidence not retained: %+v", envelope.Evidence) + } + for _, evidence := range envelope.Evidence { + if len(evidence.Callers) > localizationMaxNeighborIDs || len(evidence.Callees) > localizationMaxNeighborIDs { + t.Fatalf("neighbor metadata not capped: callers=%d callees=%d", len(evidence.Callers), len(evidence.Callees)) + } + if len([]rune(evidence.Signature)) > localizationMaxSignatureRunes { + t.Fatalf("signature metadata not capped: %d", len([]rune(evidence.Signature))) + } + } + if got := compactLocalizationField(longMetadata, localizationMaxSignatureRunes); len([]rune(got)) > localizationMaxSignatureRunes { + t.Fatalf("signature compaction = %d runes", len([]rune(got))) + } +} + +func TestLocalizationEnvelopeIncludesBoundedStructuralNeighbor(t *testing.T) { + buildParallel := &graph.Node{ID: "crates/ignore/src/walk.rs::WalkBuilder.build_parallel", Name: "build_parallel", QualName: "WalkBuilder.build_parallel", Kind: graph.KindMethod, FilePath: "crates/ignore/src/walk.rs"} + buildWithCWD := &graph.Node{ID: "crates/ignore/src/dir.rs::IgnoreBuilder.build_with_cwd", Name: "build_with_cwd", QualName: "IgnoreBuilder.build_with_cwd", Kind: graph.KindMethod, FilePath: "crates/ignore/src/dir.rs"} + unrelated := &graph.Node{ID: "crates/core/src/log.rs::trace_event", Name: "trace_event", Kind: graph.KindFunction, FilePath: "crates/core/src/log.rs"} + targets := []exploreTarget{ + {node: buildParallel, callees: []*graph.Node{unrelated, buildWithCWD}}, + {node: &graph.Node{ID: "crates/ignore/src/walk.rs::WalkParallel", Name: "WalkParallel", Kind: graph.KindType, FilePath: "crates/ignore/src/walk.rs"}}, + } + result := newLocalizationExploreResultForTask( + newLocalizationCompletion(true, ""), + "nondeterministic WalkBuilder parallel multi-root walk build_parallel", + targets, + 1600, + ) + text, ok := singleTextContent(result) + if !ok { + t.Fatalf("expected one text result: %#v", result) + } + var envelope localizationExploreEnvelope + if err := json.Unmarshal([]byte(text), &envelope); err != nil { + t.Fatalf("decode localization envelope: %v", err) + } + if len(envelope.Evidence) > len(targets) { + t.Fatalf("promoted evidence exceeded direct target bound: %#v", envelope.Evidence) + } + joinedSymbols := strings.Join(envelope.Symbols, "\n") + if !strings.Contains(joinedSymbols, buildWithCWD.ID) { + t.Fatalf("structural implementation missing from terminal symbols: %#v", envelope.Symbols) + } + if strings.Contains(joinedSymbols, unrelated.ID) { + t.Fatalf("unrelated graph neighbor leaked into terminal symbols: %#v", envelope.Symbols) + } + joinedFiles := strings.Join(envelope.Files, "\n") + if !strings.Contains(joinedFiles, buildWithCWD.FilePath) { + t.Fatalf("structural implementation file missing: %#v", envelope.Files) + } } diff --git a/internal/mcp/tools_fileops.go b/internal/mcp/tools_fileops.go index 20332a655..397a1b451 100644 --- a/internal/mcp/tools_fileops.go +++ b/internal/mcp/tools_fileops.go @@ -15,6 +15,7 @@ import ( "github.com/zzet/gortex/internal/elide" "github.com/zzet/gortex/internal/graph" "github.com/zzet/gortex/internal/indexer" + "github.com/zzet/gortex/internal/reach" "github.com/zzet/gortex/internal/tokens" ) @@ -611,21 +612,25 @@ func (s *Server) repoRelative(absPath string) string { // reindexFile refreshes the graph for a single file after a write. Best-effort: // non-source files or files outside any indexed repo are silently skipped. func (s *Server) reindexFile(absPath string) bool { + reindex := func(idx *indexer.Indexer) bool { + finishTopologyMutation := reach.BeginTopologyMutation(s.graph) + // Conservatively invalidate reachability after every reindex attempt: + // an error may still follow a partial topology mutation. + defer finishTopologyMutation(true) + return idx.IndexFile(absPath) == nil + } + if s.multiIndexer != nil { if prefix := s.multiIndexer.RepoForFile(absPath); prefix != "" { - if idx := s.multiIndexer.GetIndexer(prefix); idx != nil { - if err := idx.IndexFile(absPath); err == nil { - return true - } + if idx := s.multiIndexer.GetIndexer(prefix); idx != nil && reindex(idx) { + return true } } } if s.indexer != nil { if root := s.indexer.RootPath(); root != "" { - if rel, err := filepath.Rel(root, absPath); err == nil && !strings.HasPrefix(rel, "..") { - if err := s.indexer.IndexFile(absPath); err == nil { - return true - } + if rel, err := filepath.Rel(root, absPath); err == nil && !strings.HasPrefix(rel, "..") && reindex(s.indexer) { + return true } } } @@ -695,6 +700,11 @@ func (s *Server) handleEditFile(ctx context.Context, req mcp.CallToolRequest) (* if resolveErr != nil { return mcp.NewToolResultError(resolveErr.Error()), nil } + releaseMutation, lockErr := acquireMutationPath(ctx, absPath) + if lockErr != nil { + return mcp.NewToolResultError("edit cancelled while waiting for exclusive file access: " + lockErr.Error()), nil + } + defer releaseMutation() content, err := os.ReadFile(absPath) if err != nil { @@ -797,14 +807,13 @@ func (s *Server) handleEditFile(ctx context.Context, req mcp.CallToolRequest) (* sess := s.sessionFor(ctx) sess.recordModified(relPath) - reindexed := s.reindexFile(absPath) + reindexOutcome := s.mutationReindexState(ctx, absPath) resp := map[string]any{ "path": relPath, "status": "applied", "replacements": replacements, "bytes_written": len(newContentBytes), - "reindexed": reindexed, "new_sha": newSHA, } if matches.normalized { @@ -813,9 +822,10 @@ func (s *Server) handleEditFile(ctx context.Context, req mcp.CallToolRequest) (* if info := parseGateInfo(gate, allowParseErrors); info != nil { resp["parse_gate"] = info } - if health := s.fileSyntaxHealth(relPath, absPath); health != nil { - resp["syntax_health"] = health + if reindexOutcome.Err != nil { + resp["reindex_error"] = reindexOutcome.Err.Error() } + s.attachMutationFreshness(resp, relPath, absPath, reindexOutcome) return s.respondJSONOrTOON(ctx, req, resp) } @@ -835,6 +845,11 @@ func (s *Server) handleWriteFile(ctx context.Context, req mcp.CallToolRequest) ( if resolveErr != nil { return mcp.NewToolResultError(resolveErr.Error()), nil } + releaseMutation, lockErr := acquireMutationPath(ctx, absPath) + if lockErr != nil { + return mcp.NewToolResultError("write cancelled while waiting for exclusive file access: " + lockErr.Error()), nil + } + defer releaseMutation() status := "created" perm := os.FileMode(0o644) @@ -914,21 +929,21 @@ func (s *Server) handleWriteFile(ctx context.Context, req mcp.CallToolRequest) ( sess := s.sessionFor(ctx) sess.recordModified(relPath) - reindexed := s.reindexFile(absPath) + reindexOutcome := s.mutationReindexState(ctx, absPath) resp := map[string]any{ "path": relPath, "status": status, "bytes_written": len(contentBytes), - "reindexed": reindexed, "new_sha": newSHA, } if info := parseGateInfo(gate, allowParseErrors); info != nil { resp["parse_gate"] = info } - if health := s.fileSyntaxHealth(relPath, absPath); health != nil { - resp["syntax_health"] = health + if reindexOutcome.Err != nil { + resp["reindex_error"] = reindexOutcome.Err.Error() } + s.attachMutationFreshness(resp, relPath, absPath, reindexOutcome) return s.respondJSONOrTOON(ctx, req, resp) } diff --git a/internal/mcp/tools_fileops_sync_test.go b/internal/mcp/tools_fileops_sync_test.go new file mode 100644 index 000000000..deb6320aa --- /dev/null +++ b/internal/mcp/tools_fileops_sync_test.go @@ -0,0 +1,56 @@ +package mcp + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFileMutationsReindexSynchronouslyAcrossConsecutiveEdits(t *testing.T) { + srv, _ := setupTestServer(t) + + write := callTool(t, srv, "write_file", map[string]any{ + "path": "fresh.go", + "content": "package fresh\n\nfunc First() {}\n", + }) + require.False(t, write.IsError) + writeResp := decodeFileOpsResult(t, write) + assert.Equal(t, true, writeResp["reindexed"]) + assert.NotContains(t, writeResp, "reindex_pending") + + path, ok := writeResp["path"].(string) + require.True(t, ok) + assertSymbols := func(present string, absent ...string) { + t.Helper() + names := make(map[string]bool) + for _, node := range srv.graph.GetFileNodes(path) { + names[node.Name] = true + } + assert.True(t, names[present], "expected %q in the graph immediately after mutation", present) + for _, name := range absent { + assert.False(t, names[name], "did not expect stale symbol %q after mutation", name) + } + } + assertSymbols("First") + + for _, edit := range []struct { + oldName string + newName string + }{ + {oldName: "First", newName: "Second"}, + {oldName: "Second", newName: "Third"}, + } { + result := callTool(t, srv, "edit_file", map[string]any{ + "path": path, + "old_string": edit.oldName, + "new_string": edit.newName, + "expected_occurrences": 1, + }) + require.False(t, result.IsError) + resp := decodeFileOpsResult(t, result) + assert.Equal(t, true, resp["reindexed"]) + assert.NotContains(t, resp, "reindex_pending") + assertSymbols(edit.newName, edit.oldName) + } +} diff --git a/internal/mcp/tools_knowledge_gaps_test.go b/internal/mcp/tools_knowledge_gaps_test.go index 6269e819a..8f8812e59 100644 --- a/internal/mcp/tools_knowledge_gaps_test.go +++ b/internal/mcp/tools_knowledge_gaps_test.go @@ -55,11 +55,7 @@ func newGapsTestServer(t *testing.T) *Server { } func setCommunitiesForTest(s *Server, communities []analysis.Community) { - s.analysisMu.Lock() - defer s.analysisMu.Unlock() - s.communities = &analysis.CommunityResult{ - Communities: communities, - } + installCommunitiesForTest(s, &analysis.CommunityResult{Communities: communities}) } func callGapsHandler(t *testing.T, s *Server, args map[string]any) map[string]any { diff --git a/internal/mcp/tools_mode.go b/internal/mcp/tools_mode.go index 121abaafd..5cabe7f27 100644 --- a/internal/mcp/tools_mode.go +++ b/internal/mcp/tools_mode.go @@ -40,25 +40,39 @@ func (s *Server) editingToolsHidden(ctx context.Context) bool { func (s *Server) toolSurfaceFilter(ctx context.Context, tools []mcp.Tool) []mcp.Tool { if s.editingToolsHidden(ctx) { - kept := make([]mcp.Tool, 0, len(tools)) - for _, t := range tools { - if daemon.MutatingTools[t.Name] { - continue - } - kept = append(kept, t) - } - tools = kept + tools = withoutMutatingTools(tools) } // Per-session tool-surface preset: narrow (and, for a client asking for // a wider surface than the daemon's eager set, widen) the list to the // policy resolved for THIS connection (forwarded GORTEX_TOOLS / --tools, // else the client-aware default, else the server's global preset). tools = s.applySessionPreset(ctx, tools) + // Negotiate the versioned facade surface. Legacy sessions do not see the + // new dispatcher names; facade-v1 sessions receive exactly the compact, + // static definitions and never depend on lazy promotion. + tools = s.applyFacadeSurface(ctx, tools) + // Session preset shaping can widen from the lazy catalogue. Re-apply the + // planning/workflow boundary after widening so tools/list never advertises + // an edit that the hard call gate will refuse. + if s.editingToolsHidden(ctx) { + tools = withoutMutatingTools(tools) + } // Per-host adaptation: drop tools the host duplicates and apply any // host-specific description overrides (see host_context.go). return s.sessionHostContext(ctx).apply(tools) } +func withoutMutatingTools(tools []mcp.Tool) []mcp.Tool { + kept := make([]mcp.Tool, 0, len(tools)) + for _, tool := range tools { + if daemon.MutatingTools[tool.Name] { + continue + } + kept = append(kept, tool) + } + return kept +} + // applySessionPreset shapes the tool list to the surface in force for this // session. Two regimes: // @@ -141,7 +155,14 @@ func (s *Server) sessionAllows(p *toolPolicy, name string) bool { if p.allows(name) { return true } - return p.lean && s.isLearnedPromoted(name) + // facade-v1 is a static versioned contract: learned legacy promotions + // must not leak back into its compact surface. + return p.lean && p.preset != FacadeSurfaceVersion && s.isLearnedPromoted(name) +} + +func (s *Server) usesFacadeSurface(ctx context.Context) bool { + p := s.effectiveSessionPolicy(ctx) + return p != nil && p.preset == FacadeSurfaceVersion } // narrowToPolicy keeps only the tools the policy allows, preserving order. @@ -160,6 +181,9 @@ func narrowToPolicy(tools []mcp.Tool, p *toolPolicy) []mcp.Tool { // proceed. This is the hard guarantee behind planning mode: even a // client that never re-read tools/list cannot slip an edit through. func (s *Server) checkToolGate(ctx context.Context, toolName string) *mcp.CallToolResult { + if blocked := s.checkFacadeSurfaceGate(ctx, toolName); blocked != nil { + return blocked + } if blocked := s.checkPlanningModeGate(ctx, toolName); blocked != nil { return blocked } @@ -172,6 +196,42 @@ func (s *Server) checkToolGate(ctx context.Context, toolName string) *mcp.CallTo return nil } +// checkFacadeSurfaceGate keeps the additive facade protocol behind explicit +// session negotiation. Dedicated facade names are registered process-wide so +// facade-v1 sessions can receive a complete static first tools/list, but a +// legacy defer-mode session must not be able to hard-call one that its own +// tools/list and tool_profile both classify as unavailable. Reused names such +// as analyze/explore/review/ask retain their legacy behavior outside facade-v1. +func (s *Server) checkFacadeSurfaceGate(ctx context.Context, toolName string) *mcp.CallToolResult { + facadeOnly := isDedicatedFacadeTool(toolName) + if toolName == "ask" { + // ask is a reused legacy name only when an LLM handler was actually + // configured. Otherwise the process-global registration is the facade's + // unavailable-operation stub and must stay behind facade negotiation. + _, legacyAvailable := s.facades.legacy("ask") + facadeOnly = !legacyAvailable + } + if !facadeOnly || s.usesFacadeSurface(ctx) { + return nil + } + currentPreset := "full" + if p := s.effectiveSessionPolicy(ctx); p != nil && p.preset != "" { + currentPreset = p.preset + } + return NewStructuredErrorResult(StructuredError{ + ErrorCode: ErrCodeToolBlockedByMode, + Message: fmt.Sprintf("%q belongs to the %q MCP surface, but this session is using a legacy tool surface. "+ + "Use the advertised legacy tools or reconnect with GORTEX_TOOLS=%s.", + toolName, FacadeSurfaceVersion, FacadeSurfaceVersion), + Data: map[string]any{ + "tool": toolName, + "required_preset": FacadeSurfaceVersion, + "current_preset": currentPreset, + "recovery_setting": "GORTEX_TOOLS=" + FacadeSurfaceVersion, + }, + }) +} + // checkToolPresetGate hard-blocks calls to tools outside the active // hide-mode preset, so a client that hard-codes a hidden tool name can't // bypass the restricted surface. The preset is resolved per session @@ -185,11 +245,22 @@ func (s *Server) checkToolPresetGate(ctx context.Context, toolName string) *mcp. if !p.hideMode() || p.allows(toolName) { return nil } + guidance := "Call tool_profile to see the available tools." + recovery := "tool_profile" + if p.preset == FacadeSurfaceVersion { + guidance = "Call capabilities to see the available public operations and their schemas." + recovery = "capabilities" + return NewStructuredErrorResult(StructuredError{ + ErrorCode: ErrCodeToolBlockedByMode, + Message: fmt.Sprintf("%q is not part of the active public Gortex tool surface. %s", toolName, guidance), + Data: map[string]any{"tool": toolName, "preset": p.preset, "recovery_tool": recovery}, + }) + } return NewStructuredErrorResult(StructuredError{ ErrorCode: ErrCodeToolBlockedByMode, Message: fmt.Sprintf("%q is not part of the active tool preset %q — it has been removed from this "+ - "server's tool surface. Call tool_profile to see the available tools.", toolName, p.preset), - Data: map[string]any{"tool": toolName, "preset": p.preset}, + "server's tool surface. %s", toolName, p.preset, guidance), + Data: map[string]any{"tool": toolName, "preset": p.preset, "recovery_tool": recovery}, }) } @@ -202,12 +273,24 @@ func (s *Server) checkPlanningModeGate(ctx context.Context, toolName string) *mc if !s.sessionPlanningMode(ctx) { return nil } + guidance := "Call set_planning_mode with mode \"editing\" to enable edits." + recovery := map[string]any{"tool": "set_planning_mode", "arguments": map[string]any{"mode": "editing"}} + if s.usesFacadeSurface(ctx) { + guidance = "Call session with operation \"planning_mode\" and arguments {\"mode\":\"editing\"} to enable edits." + recovery = map[string]any{ + "tool": "session", + "arguments": map[string]any{ + "operation": "planning_mode", + "arguments": map[string]any{"mode": "editing"}, + }, + } + } return NewStructuredErrorResult(StructuredError{ ErrorCode: ErrCodeToolBlockedByMode, Message: fmt.Sprintf("%q is an editing tool and this session is in planning mode — no writes are "+ - "permitted. Call set_planning_mode with mode \"editing\" to enable edits.", toolName), + "permitted. %s", toolName, guidance), Retriable: true, - Data: map[string]any{"tool": toolName, "mode": "planning"}, + Data: map[string]any{"tool": toolName, "mode": "planning", "recovery": recovery}, }) } diff --git a/internal/mcp/tools_mode_test.go b/internal/mcp/tools_mode_test.go index 7c3560299..62406cf5f 100644 --- a/internal/mcp/tools_mode_test.go +++ b/internal/mcp/tools_mode_test.go @@ -6,6 +6,8 @@ import ( mcplib "github.com/mark3labs/mcp-go/mcp" "github.com/stretchr/testify/require" + + "github.com/zzet/gortex/internal/daemon" ) func TestPlanningMode_TogglesAndBlocksEdits(t *testing.T) { @@ -70,3 +72,21 @@ func TestPlanningMode_RejectsUnknownMode(t *testing.T) { require.NoError(t, err) require.True(t, res.IsError, "an unknown mode must be rejected") } + +func TestPlanningModeRefiltersToolsWidenedBySessionPreset(t *testing.T) { + srv := setupPresetServer(t, ToolPolicyConfig{Preset: "core", Mode: "defer"}) + ctx := WithSessionID(context.Background(), "planning_full_override") + srv.NoteSessionToolPolicy("planning_full_override", "full", "defer") + + req := mcplib.CallToolRequest{} + req.Params.Arguments = map[string]any{"mode": "planning"} + result, err := srv.handleSetPlanningMode(ctx, req) + require.NoError(t, err) + require.False(t, result.IsError) + + filtered := srv.toolSurfaceFilter(ctx, []mcplib.Tool{{Name: "search_symbols"}}) + require.Greater(t, len(filtered), 1, "full override should widen read tools from the deferred catalogue") + for _, tool := range filtered { + require.Falsef(t, daemon.IsMutating(tool.Name), "planning tools/list leaked widened mutation %q", tool.Name) + } +} diff --git a/internal/mcp/tools_multi.go b/internal/mcp/tools_multi.go index ef8fc4347..34173822c 100644 --- a/internal/mcp/tools_multi.go +++ b/internal/mcp/tools_multi.go @@ -287,21 +287,38 @@ func (s *Server) resolveRepoPrefix(pathOrPrefix string) string { return "" } - // Check if it's a known prefix directly. + // Check if it is a known prefix directly. if meta := s.multiIndexer.GetMetadata(pathOrPrefix); meta != nil { return pathOrPrefix } - // Try to match as a path — check all tracked repos. Also try the - // absolute form since users may pass either. - absInput, _ := filepath.Abs(pathOrPrefix) + // Files and working directories inside a tracked repository are valid + // selectors too. Prefer the longest containing root so nested repositories + // resolve deterministically to their own graph instead of the parent repo. + absInput, err := filepath.Abs(pathOrPrefix) + if err != nil { + return "" + } + bestPrefix := "" + bestRootLen := -1 for prefix, meta := range s.multiIndexer.AllMetadata() { - if meta.RootPath == pathOrPrefix || (absInput != "" && meta.RootPath == absInput) { - return prefix + if meta == nil || meta.RootPath == "" { + continue + } + root, err := filepath.Abs(meta.RootPath) + if err != nil { + continue + } + rel, err := filepath.Rel(root, absInput) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + continue + } + if len(root) > bestRootLen { + bestPrefix = prefix + bestRootLen = len(root) } } - - return "" + return bestPrefix } // diffJoinPrefix resolves the graph repo prefix used to join repo-relative diff --git a/internal/mcp/tools_overlay.go b/internal/mcp/tools_overlay.go index 6a6317caa..04c5976f5 100644 --- a/internal/mcp/tools_overlay.go +++ b/internal/mcp/tools_overlay.go @@ -20,20 +20,21 @@ import ( // subsequent tools/call from the same MCP session sees the overlaid // view (see overlay.go::wrapToolHandler). // -// The functions are intentionally not routed through s.addTool: the +// The functions are intentionally routed through addControlTool rather than +// addTool: the // overlay middleware MUST NOT apply overlays before an overlay_push // runs (or the new buffer would be re-evicted by the revert pass // before it ever reached the user-facing query). So they register -// directly via s.mcpServer.AddTool. +// without bypassing safety gates, capture, or telemetry. func (s *Server) registerOverlayTools() { - s.mcpServer.AddTool( + s.addControlTool( mcp.NewTool("overlay_register", mcp.WithDescription("Register an editor-overlay session bound to the current MCP session. Subsequent overlay_push calls attach in-flight editor buffers; every tools/call from this session then sees the overlay merged on top of the saved-buffer graph view. Idempotent — calling twice with the same workspace is a no-op."), mcp.WithString("workspace_id", mcp.Description("Workspace slug the overlay belongs to. Optional; defaults to the session's bound workspace.")), ), s.handleOverlayRegister, ) - s.mcpServer.AddTool( + s.addControlTool( mcp.NewTool("overlay_push", mcp.WithDescription("Push (or update) a single file overlay onto the current MCP session's overlay. The file at `path` is treated as if it contained `content` for the duration of every subsequent tools/call. Set `deleted: true` to mark a tombstone (queries see the file as missing). Drift detection: when `base_sha` is set, the daemon compares it to the on-disk git blob SHA at apply time and returns an overlay-drift error if they disagree."), mcp.WithString("path", mcp.Required(), mcp.Description("Repo-relative or absolute file path.")), @@ -43,26 +44,26 @@ func (s *Server) registerOverlayTools() { ), s.handleOverlayPush, ) - s.mcpServer.AddTool( + s.addControlTool( mcp.NewTool("overlay_list", mcp.WithDescription("List every overlay file currently attached to the calling MCP session. Returns workspace_id, the count of files, and each overlay's path / content length / deleted flag / base_sha. The path and metadata are returned; content bytes are not, to keep the response small."), ), s.handleOverlayList, ) - s.mcpServer.AddTool( + s.addControlTool( mcp.NewTool("overlay_delete", mcp.WithDescription("Remove a single overlay file from the calling MCP session's overlay. The next tools/call will see the saved-buffer view for that path."), mcp.WithString("path", mcp.Required(), mcp.Description("Repo-relative or absolute path of the overlay to remove.")), ), s.handleOverlayDelete, ) - s.mcpServer.AddTool( + s.addControlTool( mcp.NewTool("overlay_drop", mcp.WithDescription("Tear down the calling MCP session's overlay entirely. Equivalent to calling overlay_delete on every attached path. The session remains live; a subsequent overlay_register / overlay_push starts a fresh overlay."), ), s.handleOverlayDrop, ) - s.mcpServer.AddTool( + s.addControlTool( mcp.NewTool("overlay_keepalive", mcp.WithDescription("Refresh the calling MCP session's overlay idle timer without changing any overlay content. Cheaper than re-pushing buffer content when the editor needs to extend the lease (e.g. the user is debugging or paused on a breakpoint and won't push for a while). Returns the resulting expires_at / idle_seconds so the editor can schedule the next keepalive. The MCP session disconnect path already drops the overlay synchronously, so keepalive is only needed for genuine idle gaps below the disconnect-detection threshold."), ), diff --git a/internal/mcp/tools_overlay_branch.go b/internal/mcp/tools_overlay_branch.go index a5bfb8b43..c17487c0e 100644 --- a/internal/mcp/tools_overlay_branch.go +++ b/internal/mcp/tools_overlay_branch.go @@ -24,7 +24,7 @@ import ( // before the next tools/call's view is built, not be re-evaluated // against the branch about to be switched away). func (s *Server) registerOverlayBranchTools() { - s.mcpServer.AddTool( + s.addControlTool( mcp.NewTool("overlay_fork", mcp.WithDescription("Clone the current session's overlay state into a named branch. Branches let an agent hold N simultaneous speculative states off one baseline so it can apply different edit strategies to each, compare outcomes via compare_branches, and promote the winner with overlay_merge. The implicit base branch is `main`; every new fork inherits its parent's file map by deep copy. Pass activate:true to make the new branch the session's active branch immediately."), mcp.WithString("name", mcp.Required(), mcp.Description("Branch name (alphanumeric + dash/underscore, max 64 chars).")), @@ -33,20 +33,20 @@ func (s *Server) registerOverlayBranchTools() { ), s.handleOverlayFork, ) - s.mcpServer.AddTool( + s.addControlTool( mcp.NewTool("overlay_branches", mcp.WithDescription("List every overlay branch the calling MCP session holds, including the implicit `main` branch. Each entry carries the branch name, whether it is currently active, the number of files attached, the number of files that carry a base_sha drift anchor, the parent branch name (empty for `main`), and the creation timestamp."), ), s.handleOverlayBranches, ) - s.mcpServer.AddTool( + s.addControlTool( mcp.NewTool("overlay_switch", mcp.WithDescription("Make the named branch the active overlay for the calling MCP session. All existing overlay tools (overlay_push, overlay_list, overlay_delete, overlay_drop, compare_with_overlay, preview_edit, simulate_chain) operate against the active branch after the switch."), mcp.WithString("name", mcp.Required(), mcp.Description("Branch name to activate.")), ), s.handleOverlaySwitch, ) - s.mcpServer.AddTool( + s.addControlTool( mcp.NewTool("overlay_merge", mcp.WithDescription("Merge a branch's edits into another branch or write them to disk. With to_disk:false (default) the from branch's files are folded into the into branch (default: main); with to_disk:true the from branch's overlay files are written to the filesystem using the same atomic-write + base_sha drift-guard machinery as edit_file, then the from branch is dropped from the session. Conflict policy: a path that exists in both branches with different content is a conflict; without force:true the merge aborts and the response carries a conflicts list. force:true resolves conflicts last-writer-wins (from wins) and notes the resolution in the response."), mcp.WithString("from", mcp.Required(), mcp.Description("Source branch to merge.")), @@ -56,14 +56,14 @@ func (s *Server) registerOverlayBranchTools() { ), s.handleOverlayMerge, ) - s.mcpServer.AddTool( + s.addControlTool( mcp.NewTool("overlay_drop_branch", mcp.WithDescription("Delete a named overlay branch from the calling session. Refuses to drop the implicit `main` branch (drop the whole session instead with overlay_drop) and refuses to drop the currently active branch (call overlay_switch first)."), mcp.WithString("name", mcp.Required(), mcp.Description("Branch name to delete.")), ), s.handleOverlayDropBranch, ) - s.mcpServer.AddTool( + s.addControlTool( mcp.NewTool("compare_branches", mcp.WithDescription("Run a graph query against two overlay branches in the calling session and report the delta. Each branch is materialised as its own shadow-graph view (no shared state). Useful when an agent has applied different edit strategies to two branches and wants to know which strategy actually changes the dependency graph in the desired direction. Supported `kind` values: find_usages, get_callers, get_call_chain, get_dependencies, get_dependents."), mcp.WithString("a", mcp.Required(), mcp.Description("First branch name.")), diff --git a/internal/mcp/tools_overlay_diff.go b/internal/mcp/tools_overlay_diff.go index 651d3e084..082d18590 100644 --- a/internal/mcp/tools_overlay_diff.go +++ b/internal/mcp/tools_overlay_diff.go @@ -19,7 +19,7 @@ import ( // answers to "what would change if I committed this buffer?" without // touching the saved-view graph. func (s *Server) registerOverlayDiffTool() { - s.mcpServer.AddTool( + s.addControlTool( mcp.NewTool("compare_with_overlay", mcp.WithDescription("Run a graph query against both the base (saved-buffer) graph and the calling session's overlay (editor-buffer) view, then report the delta. The base side answers \"what's true now?\"; the overlay side answers \"what would be true if I committed this buffer?\". Useful for previewing the impact of an unsaved edit on callers, dependents, or call chains. Supported `kind` values: find_usages, get_callers, get_call_chain, get_dependencies, get_dependents."), mcp.WithString("kind", mcp.Required(), mcp.Description("Query kind to run. One of: find_usages, get_callers, get_call_chain, get_dependencies, get_dependents.")), @@ -70,13 +70,13 @@ func (s *Server) handleCompareWithOverlay(ctx context.Context, req mcp.CallToolR added, removed, common := diffIDSets(baseIDs, overlayIDs) result := map[string]any{ - "kind": kind, - "id": id, - "depth": depth, - "limit": limit, + "kind": kind, + "id": id, + "depth": depth, + "limit": limit, "overlay_paths": view.Layer().FilePaths(), - "base": baseIDs, - "overlay": overlayIDs, + "base": baseIDs, + "overlay": overlayIDs, "delta": map[string]any{ "added": added, "removed": removed, diff --git a/internal/mcp/tools_proxy.go b/internal/mcp/tools_proxy.go index ae02a76dd..2f1d49255 100644 --- a/internal/mcp/tools_proxy.go +++ b/internal/mcp/tools_proxy.go @@ -45,21 +45,21 @@ func (s *Server) SetRemoteOverrideSink(sink RemoteOverrideSink) { } func (s *Server) registerProxyTools() { - s.mcpServer.AddTool( + s.addControlTool( mcp.NewTool("proxy_enable", mcp.WithDescription("Enable federation/proxy to a remote Gortex daemon FOR THIS MCP SESSION ONLY. Overrides the global enabled state for the session's lifetime and auto-reverts on disconnect. For a persistent global change use `gortex proxy on `."), mcp.WithString("slug", mcp.Required(), mcp.Description("Roster slug of the remote to enable for this session.")), ), s.handleProxyEnable, ) - s.mcpServer.AddTool( + s.addControlTool( mcp.NewTool("proxy_disable", mcp.WithDescription("Disable federation/proxy to a remote Gortex daemon FOR THIS MCP SESSION ONLY. Beats the global enabled state for the session's lifetime; queries from this session skip the remote. Auto-reverts on disconnect. For a persistent global change use `gortex proxy off `."), mcp.WithString("slug", mcp.Required(), mcp.Description("Roster slug of the remote to disable for this session.")), ), s.handleProxyDisable, ) - s.mcpServer.AddTool( + s.addControlTool( mcp.NewTool("proxy_status", mcp.WithDescription("List every roster remote with its global enabled state, this session's override (if any), and the resulting effective enabled state."), ), diff --git a/internal/mcp/tools_review_questions_test.go b/internal/mcp/tools_review_questions_test.go index 22a790115..90062ca80 100644 --- a/internal/mcp/tools_review_questions_test.go +++ b/internal/mcp/tools_review_questions_test.go @@ -80,13 +80,13 @@ func reviewQuestionsTestServer(t *testing.T) *Server { for _, m := range bigMembers { nodeToComm[m] = "c-big" } - s.communities = &analysis.CommunityResult{ + installCommunitiesForTest(s, &analysis.CommunityResult{ Communities: []analysis.Community{ {ID: "c-big", Label: "big", Size: len(bigMembers)}, {ID: "c-lonely", Label: "lonely", Size: 1}, }, NodeToComm: nodeToComm, - } + }) return s } diff --git a/internal/mcp/tools_search.go b/internal/mcp/tools_search.go index d379c6ff7..c7dc452c4 100644 --- a/internal/mcp/tools_search.go +++ b/internal/mcp/tools_search.go @@ -56,7 +56,7 @@ func (s *Server) registerToolsSearch() { )), ) - s.mcpServer.AddTool(tool, s.wrapToolHandler(s.handleToolsSearch)) + s.addControlTool(tool, s.handleToolsSearch) } // toolsSearchPayload is the structured form of the discovery tool's diff --git a/internal/mcp/tools_simulate.go b/internal/mcp/tools_simulate.go index c281d3636..98ab9970e 100644 --- a/internal/mcp/tools_simulate.go +++ b/internal/mcp/tools_simulate.go @@ -54,13 +54,11 @@ import ( // are registered without the overlay-injecting middleware (they // manage their own simulation views) but they DO honour an existing // session overlay when `inherit_overlay: true` is set on the chain -// form. We register through s.mcpServer.AddTool directly — the -// addTool wrapper would build the caller's overlay view and stash it -// on ctx, but the simulation tools layer their own view on top of -// base (or on top of the caller's), so we want to keep that -// composition explicit rather than fight the middleware. +// form. We register through addControlTool: it keeps safety, gating, and +// telemetry middleware but skips automatic overlay injection because these +// tools compose their own simulation views explicitly. func (s *Server) registerSimulationTools() { - s.mcpServer.AddTool( + s.addControlTool( mcp.NewTool("preview_edit", mcp.WithDescription("Speculatively apply one WorkspaceEdit (LSP-shaped: `changes` or `documentChanges`) to a fresh shadow view on top of the base graph, then return the impact — touched files, before/after symbol surfaces, broken callers, broken interface implementors, suggested test targets, and (when the LSP server for the language is running) the diagnostics that the change would produce. Disk is never written; the base graph is never mutated. The shadow view is built per call and discarded with the response. Useful when you want to know if a refactor is safe before staging it."), mcp.WithString("workspace_edit", mcp.Required(), mcp.Description("LSP WorkspaceEdit as a JSON string. Either `{\"changes\": {uri: [TextEdit, ...], ...}}` or `{\"documentChanges\": [{\"textDocument\": {\"uri\": \"...\"}, \"edits\": [TextEdit, ...]}, ...]}`. TextEdit is `{\"range\": {\"start\": {\"line\": N, \"character\": M}, \"end\": {...}}, \"newText\": \"...\"}` with 0-indexed line/char. URIs can be `file://`, absolute paths, or repo-relative paths.")), @@ -70,7 +68,7 @@ func (s *Server) registerSimulationTools() { ), s.handlePreviewEdit, ) - s.mcpServer.AddTool( + s.addControlTool( mcp.NewTool("simulate_chain", mcp.WithDescription("Apply an ordered sequence of WorkspaceEdits to a fresh shadow view, accumulating overlay state between steps. Returns per-step impact (touched files, broken callers, broken implementors, diagnostics delta vs the previous step) and a cumulative impact rollup at the end. Disk is never written. Useful for previewing multi-step refactors where each step depends on the previous step's result (rename → fix callers → adjust signature, etc.). Pass `keep: true` to persist the final state as an editor overlay session — the response then includes `overlay_session_id` and the caller can `overlay_push` / `overlay_drop` / `compare_with_overlay` from there, or eventually write the changes to disk."), mcp.WithString("steps", mcp.Required(), mcp.Description("JSON array of LSP WorkspaceEdit objects, applied in order. Each step's edits are layered on top of the prior step's simulated state. An empty array is rejected; pass a single-element array for trivial chains.")), @@ -115,19 +113,19 @@ func (s *Server) handlePreviewEdit(ctx context.Context, req mcp.CallToolRequest) step := sim.steps[0] result := map[string]any{ - "touched_files": step.touchedFiles, - "new_files": step.newFiles, - "deleted_files": step.deletedFiles, - "missing_files": step.missingFiles, - "symbols_added": step.symbolsAdded, - "symbols_removed": step.symbolsRemoved, - "symbols_renamed": step.symbolsRenamed, - "broken_callers": step.brokenCallers, + "touched_files": step.touchedFiles, + "new_files": step.newFiles, + "deleted_files": step.deletedFiles, + "missing_files": step.missingFiles, + "symbols_added": step.symbolsAdded, + "symbols_removed": step.symbolsRemoved, + "symbols_renamed": step.symbolsRenamed, + "broken_callers": step.brokenCallers, "broken_implementors": step.brokenImplementors, - "impact": step.impact, - "test_targets": step.testTargets, - "overlay_paths": sim.coveredPaths, - "summary": step.summary, + "impact": step.impact, + "test_targets": step.testTargets, + "overlay_paths": sim.coveredPaths, + "summary": step.summary, } if wantDiag { @@ -228,14 +226,14 @@ func (s *Server) handleSimulateChain(ctx context.Context, req mcp.CallToolReques } result := map[string]any{ - "steps": stepsOut, - "total_steps": len(sim.steps), - "applied_steps": len(stepsOut), - "stopped_at": stoppedAt, - "overlay_paths": sim.coveredPaths, - "cumulative": sim.cumulative, - "inherit_overlay": inherit, - "kept": false, + "steps": stepsOut, + "total_steps": len(sim.steps), + "applied_steps": len(stepsOut), + "stopped_at": stoppedAt, + "overlay_paths": sim.coveredPaths, + "cumulative": sim.cumulative, + "inherit_overlay": inherit, + "kept": false, } if keep { diff --git a/internal/mcp/tools_suggest_boundaries_test.go b/internal/mcp/tools_suggest_boundaries_test.go index 9669e0d63..740b14466 100644 --- a/internal/mcp/tools_suggest_boundaries_test.go +++ b/internal/mcp/tools_suggest_boundaries_test.go @@ -52,7 +52,7 @@ func TestSuggestBoundariesHandler(t *testing.T) { g.AddNode(&graph.Node{ID: "internal/graph/g.go::G", Name: "G", Kind: graph.KindFunction, FilePath: "internal/graph/g.go"}) g.AddEdge(&graph.Edge{From: "internal/parser/a.go::A", To: "internal/graph/g.go::G", Kind: graph.EdgeCalls}) - srv.communities = &analysis.CommunityResult{ + installCommunitiesForTest(srv, &analysis.CommunityResult{ Communities: []analysis.Community{ {ID: "c1", Label: "parser", Members: []string{"internal/parser/a.go::A"}, Files: []string{"internal/parser/a.go"}, Size: 4}, {ID: "c2", Label: "graph", Members: []string{"internal/graph/g.go::G"}, Files: []string{"internal/graph/g.go"}, Size: 5}, @@ -62,7 +62,7 @@ func TestSuggestBoundariesHandler(t *testing.T) { "internal/graph/g.go::G": "c2", }, Modularity: 0.5, - } + }) req := mcplib.CallToolRequest{} req.Params.Arguments = map[string]any{"kind": "suggest_boundaries"} diff --git a/internal/mcp/tools_surprising_test.go b/internal/mcp/tools_surprising_test.go index 949bd9658..23c7533d8 100644 --- a/internal/mcp/tools_surprising_test.go +++ b/internal/mcp/tools_surprising_test.go @@ -64,11 +64,9 @@ func TestSurprising_CrossCommunityFires(t *testing.T) { s.graph.AddEdge(&graph.Edge{From: "a", To: "b", Kind: graph.EdgeCalls}) // Two different communities so cross_community fires. - s.analysisMu.Lock() - s.communities = &analysis.CommunityResult{ + installCommunitiesForTest(s, &analysis.CommunityResult{ NodeToComm: map[string]string{"a": "c1", "b": "c2"}, - } - s.analysisMu.Unlock() + }) out := callSurprisingHandler(t, s, map[string]any{"min_score": 0.1}) row := findEdgeRow(out, "a", "b") @@ -151,9 +149,9 @@ func TestSurprising_MinScoreFilters(t *testing.T) { s.graph.AddNode(&graph.Node{ID: "a", Name: "a", Kind: graph.KindFunction, FilePath: "p/a.go", Language: "go"}) s.graph.AddNode(&graph.Node{ID: "b", Name: "b", Kind: graph.KindFunction, FilePath: "p/b.go", Language: "go"}) s.graph.AddEdge(&graph.Edge{From: "a", To: "b", Kind: graph.EdgeCalls}) - s.analysisMu.Lock() - s.communities = &analysis.CommunityResult{NodeToComm: map[string]string{"a": "c1", "b": "c1"}} - s.analysisMu.Unlock() + installCommunitiesForTest(s, &analysis.CommunityResult{ + NodeToComm: map[string]string{"a": "c1", "b": "c1"}, + }) out := callSurprisingHandler(t, s, map[string]any{"min_score": 0.3}) conns, _ := out["connections"].([]any) @@ -183,9 +181,9 @@ func TestSurprising_CompositeScoreStacks(t *testing.T) { s.graph.AddNode(&graph.Node{ID: "a", Name: "a", Kind: graph.KindFunction, FilePath: "p/a.go", Language: "go"}) s.graph.AddNode(&graph.Node{ID: "b", Name: "b", Kind: graph.KindFunction, FilePath: "p/b.test.ts", Language: "typescript"}) s.graph.AddEdge(&graph.Edge{From: "a", To: "b", Kind: graph.EdgeCalls}) - s.analysisMu.Lock() - s.communities = &analysis.CommunityResult{NodeToComm: map[string]string{"a": "c1", "b": "c2"}} - s.analysisMu.Unlock() + installCommunitiesForTest(s, &analysis.CommunityResult{ + NodeToComm: map[string]string{"a": "c1", "b": "c2"}, + }) out := callSurprisingHandler(t, s, map[string]any{"min_score": 0.1}) row := findEdgeRow(out, "a", "b") diff --git a/internal/mcp/tools_wakeup_test.go b/internal/mcp/tools_wakeup_test.go index 02a5e39a1..81b7fdd75 100644 --- a/internal/mcp/tools_wakeup_test.go +++ b/internal/mcp/tools_wakeup_test.go @@ -31,13 +31,11 @@ func newWakeupTestServer(t *testing.T) *Server { sessions: newSessionMap(), toolScopes: newScopeRegistry(), } - s.analysisMu.Lock() - s.communities = &analysis.CommunityResult{ + installCommunitiesForTest(s, &analysis.CommunityResult{ Communities: []analysis.Community{ {ID: "c1", Label: "core", Size: 3, Hub: "Run", Members: []string{"p/main.go::Run", "p/util.go::Helper"}}, }, - } - s.analysisMu.Unlock() + }) return s } @@ -130,8 +128,7 @@ func TestWakeup_TokenBudgetCaps(t *testing.T) { func TestWakeup_TopCommunitiesCap(t *testing.T) { s := newWakeupTestServer(t) - s.analysisMu.Lock() - s.communities = &analysis.CommunityResult{ + installCommunitiesForTest(s, &analysis.CommunityResult{ Communities: []analysis.Community{ {ID: "c1", Label: "a", Size: 5}, {ID: "c2", Label: "b", Size: 4}, @@ -139,8 +136,7 @@ func TestWakeup_TopCommunitiesCap(t *testing.T) { {ID: "c4", Label: "d", Size: 2}, {ID: "c5", Label: "e", Size: 1}, }, - } - s.analysisMu.Unlock() + }) text, _ := callWakeupHandler(t, s, map[string]any{"top_communities": 2}) assert.Contains(t, text, "a (5") diff --git a/internal/mcp/tools_walk_community_test.go b/internal/mcp/tools_walk_community_test.go index 2afdce6c6..b1003f760 100644 --- a/internal/mcp/tools_walk_community_test.go +++ b/internal/mcp/tools_walk_community_test.go @@ -25,8 +25,7 @@ func seedWalkCommunityGraph(t *testing.T, srv *Server) { g.AddEdge(&graph.Edge{From: "x.go::X", To: "y.go::Y", Kind: graph.EdgeCalls, FilePath: "x.go", Line: 1}) g.AddEdge(&graph.Edge{From: "y.go::Y", To: "z.go::Z", Kind: graph.EdgeCalls, FilePath: "y.go", Line: 1}) - srv.analysisMu.Lock() - srv.communities = &analysis.CommunityResult{ + installCommunitiesForTest(srv, &analysis.CommunityResult{ Communities: []analysis.Community{ {ID: "c-alpha", Label: "alpha"}, {ID: "c-beta", Label: "beta"}, @@ -36,8 +35,7 @@ func seedWalkCommunityGraph(t *testing.T, srv *Server) { "y.go::Y": "c-alpha", "z.go::Z": "c-beta", }, - } - srv.analysisMu.Unlock() + }) } func walkResultIDs(t *testing.T, out map[string]any) map[string]bool { diff --git a/internal/mcp/tools_wiki_helpers.go b/internal/mcp/tools_wiki_helpers.go index 0f22a1b62..3575733c8 100644 --- a/internal/mcp/tools_wiki_helpers.go +++ b/internal/mcp/tools_wiki_helpers.go @@ -86,10 +86,11 @@ func writeWikiFile(path string, body []byte) error { // docs-package shape. Returns nil when no watcher is attached, in // which case the recent-changes section will be empty. func (s *Server) docsHistoryProvider() docs.HistoryProvider { - if s.watcher == nil { + watcher := s.currentWatcher() + if watcher == nil { return nil } - return &watcherHistoryAdapter{w: s.watcher} + return &watcherHistoryAdapter{w: watcher} } type watcherHistoryAdapter struct { diff --git a/internal/mcp/tools_winnow_test.go b/internal/mcp/tools_winnow_test.go index 0db6f5a76..19fcb5d0d 100644 --- a/internal/mcp/tools_winnow_test.go +++ b/internal/mcp/tools_winnow_test.go @@ -52,7 +52,7 @@ func buildWinnowGraph(t *testing.T) *Server { eng := query.NewEngine(g) srv := NewServer(eng, g, nil, nil, zap.NewNop(), nil) // Seed community membership so community-filter tests have data. - srv.communities = &analysis.CommunityResult{ + installCommunitiesForTest(srv, &analysis.CommunityResult{ NodeToComm: map[string]string{ "svc/auth.go::Login": "community-0", "svc/auth.go::Logout": "community-0", @@ -63,7 +63,7 @@ func buildWinnowGraph(t *testing.T) *Server { {ID: "community-0", Label: "authz"}, {ID: "community-1", Label: "handlers"}, }, - } + }) return srv } diff --git a/internal/mcp/tools_workflow.go b/internal/mcp/tools_workflow.go index a151157dd..74fb4c663 100644 --- a/internal/mcp/tools_workflow.go +++ b/internal/mcp/tools_workflow.go @@ -97,6 +97,7 @@ func (s *Server) checkWorkflowGate(ctx context.Context, toolName string) *mcp.Ca if !daemon.IsMutating(toolName) { return nil } + facadeSurface := s.usesFacadeSurface(ctx) sess := s.sessionFor(ctx) if sess == nil { return nil @@ -111,17 +112,22 @@ func (s *Server) checkWorkflowGate(ctx context.Context, toolName string) *mcp.Ca wf.current = wf.firstEditPhase() // auto-advance return nil } + guidance := "Advance the workflow (workflow action=\"advance\") until it reaches an editing phase." + recovery := "call the workflow tool with action=advance" + if facadeSurface { + guidance = "Advance it with session(operation=\"workflow\", arguments={\"action\":\"advance\"}) until it reaches an editing phase." + recovery = "call session with operation=workflow and arguments.action=advance" + } return NewStructuredErrorResult(StructuredError{ ErrorCode: ErrCodeToolOutOfPhase, Message: fmt.Sprintf("%q is an editing tool but the active workflow is in the %q phase, which "+ - "does not permit edits. Advance the workflow (workflow action=\"advance\") until it reaches "+ - "an editing phase.", toolName, wf.phase().name), + "does not permit edits. %s", toolName, wf.phase().name, guidance), Retriable: true, Data: map[string]any{ "tool": toolName, "current_phase": wf.phase().name, "phases": wf.phaseNames(), - "recovery": "call the workflow tool with action=advance", + "recovery": recovery, }, }) } diff --git a/internal/parser/languages/rust.go b/internal/parser/languages/rust.go index 5d2265746..ef5d9cbb1 100644 --- a/internal/parser/languages/rust.go +++ b/internal/parser/languages/rust.go @@ -423,7 +423,7 @@ func (e *RustExtractor) emitFunction(m parser.QueryResult, filePath, fileID stri doc := ExtractDocAbove(src, def.StartLine, DocLangSlashSlash) visibility := rustVisibility(def.Node, src) - typeParams := rustTypeParams(def.Node, src) + typeParams := rustEffectiveTypeParams(def.Node, src) complexity, cognitive, loopDepth := 0, 0, 0 if def.Node != nil { if body := def.Node.ChildByFieldName("body"); body != nil { @@ -596,6 +596,214 @@ func rustTypeParams(item *sitter.Node, src []byte) []map[string]string { return out } +// rustEffectiveTypeParams returns the generic parameters visible inside a +// function. Methods inherit parameters declared on their enclosing impl block, +// and both impl/function where clauses refine those parameters. Keeping the +// effective bounds on the emitted function node lets the scope resolver bind a +// call such as `matcher.is_match()` when `matcher: M` and `M: Matcher`. +func rustEffectiveTypeParams(fn *sitter.Node, src []byte) []map[string]string { + var out []map[string]string + if impl := rustEnclosingImplItem(fn); impl != nil { + out = rustMergeTypeParams(out, rustTypeParams(impl, src)) + out = rustApplyWhereBounds(out, rustWhereBounds(impl, src)) + } + out = rustMergeTypeParams(out, rustTypeParams(fn, src)) + return rustApplyWhereBounds(out, rustWhereBounds(fn, src)) +} + +func rustEnclosingImplItem(fn *sitter.Node) *sitter.Node { + if fn == nil { + return nil + } + parent := fn.Parent() + if parent == nil || parent.Type() != "declaration_list" { + return nil + } + impl := parent.Parent() + if impl == nil || impl.Type() != "impl_item" { + return nil + } + return impl +} + +func rustMergeTypeParams(dst, src []map[string]string) []map[string]string { + positions := make(map[string]int, len(dst)+len(src)) + out := make([]map[string]string, 0, len(dst)+len(src)) + for _, entry := range dst { + if name := entry["name"]; name != "" { + positions[name] = len(out) + out = append(out, rustCloneTypeParam(entry)) + } + } + for _, entry := range src { + name := entry["name"] + if name == "" { + continue + } + copy := rustCloneTypeParam(entry) + if pos, ok := positions[name]; ok { + out[pos] = copy + continue + } + positions[name] = len(out) + out = append(out, copy) + } + return out +} + +func rustCloneTypeParam(entry map[string]string) map[string]string { + copy := make(map[string]string, len(entry)) + for key, value := range entry { + copy[key] = value + } + return copy +} + +func rustApplyWhereBounds(params []map[string]string, bounds map[string]string) []map[string]string { + if len(params) == 0 || len(bounds) == 0 { + return params + } + for _, entry := range params { + bound := bounds[entry["name"]] + if bound == "" { + continue + } + if existing := strings.TrimSpace(entry["bound"]); existing != "" { + entry["bound"] = existing + " + " + bound + } else { + entry["bound"] = bound + } + } + return params +} + +// rustWhereBounds extracts the common `where M: Matcher + Send` shape. It is +// intentionally conservative: only predicates whose left side is a simple +// generic identifier are retained; lifetime and associated-type predicates are +// ignored rather than guessed. +func rustWhereBounds(item *sitter.Node, src []byte) map[string]string { + if item == nil { + return nil + } + var clause *sitter.Node + for i, n := 0, int(item.ChildCount()); i < n; i++ { + child := item.Child(i) + if child != nil && child.Type() == "where_clause" { + clause = child + break + } + } + if clause == nil { + return nil + } + text := strings.TrimSpace(clause.Content(src)) + text = strings.TrimSpace(strings.TrimPrefix(text, "where")) + out := map[string]string{} + for _, predicate := range rustSplitTopLevel(text, ',') { + left, right, ok := rustSplitWherePredicate(predicate) + if !ok || !rustSimpleIdentifier(left) { + continue + } + if prior := out[left]; prior != "" { + out[left] = prior + " + " + right + } else { + out[left] = right + } + } + if len(out) == 0 { + return nil + } + return out +} + +func rustSplitWherePredicate(predicate string) (string, string, bool) { + predicate = strings.TrimSpace(predicate) + angle, paren, bracket := 0, 0, 0 + for i := 0; i < len(predicate); i++ { + switch predicate[i] { + case '<': + angle++ + case '>': + if angle > 0 { + angle-- + } + case '(': + paren++ + case ')': + if paren > 0 { + paren-- + } + case '[': + bracket++ + case ']': + if bracket > 0 { + bracket-- + } + case ':': + if angle != 0 || paren != 0 || bracket != 0 || + (i > 0 && predicate[i-1] == ':') || + (i+1 < len(predicate) && predicate[i+1] == ':') { + continue + } + left := strings.TrimSpace(predicate[:i]) + right := strings.TrimSpace(predicate[i+1:]) + return left, right, left != "" && right != "" + } + } + return "", "", false +} + +func rustSplitTopLevel(text string, separator byte) []string { + var out []string + start := 0 + angle, paren, bracket := 0, 0, 0 + for i := 0; i < len(text); i++ { + switch text[i] { + case '<': + angle++ + case '>': + if angle > 0 { + angle-- + } + case '(': + paren++ + case ')': + if paren > 0 { + paren-- + } + case '[': + bracket++ + case ']': + if bracket > 0 { + bracket-- + } + default: + if text[i] == separator && angle == 0 && paren == 0 && bracket == 0 { + out = append(out, strings.TrimSpace(text[start:i])) + start = i + 1 + } + } + } + if tail := strings.TrimSpace(text[start:]); tail != "" { + out = append(out, tail) + } + return out +} + +func rustSimpleIdentifier(s string) bool { + if s == "" { + return false + } + for i := 0; i < len(s); i++ { + c := s[i] + if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_' || (i > 0 && c >= '0' && c <= '9') { + continue + } + return false + } + return true +} + // emitRustThrowsEdges inspects a function's return type for a Result // wrapper and emits an EdgeThrows to the error type when found. Idiom: // @@ -944,6 +1152,24 @@ func (e *RustExtractor) emitTrait(m parser.QueryResult, filePath, fileID string, result.Edges = append(result.Edges, &graph.Edge{ From: fileID, To: id, Kind: graph.EdgeDefines, FilePath: filePath, Line: def.StartLine + 1, }) + bounds := def.Node.ChildByFieldName("bounds") + if bounds == nil { + for child := range def.Node.NamedChildren() { + if child.Type() == "trait_bounds" { + bounds = child + break + } + } + } + if bounds != nil { + for _, parentPath := range rustPositiveTraitBounds(bounds.Content(src)) { + result.Edges = append(result.Edges, &graph.Edge{ + From: id, To: "unresolved::extends::" + parentPath, + Kind: graph.EdgeExtends, FilePath: filePath, Line: def.StartLine + 1, + Meta: map[string]any{"rust_trait_path": parentPath}, + }) + } + } emitRustAnnotationEdges(rustCollectAttributes(def.Node), id, filePath, src, result, annotationSeen) emitRustGenericParamNodes(id, def.Node, src, filePath, def.StartLine+1, result) } diff --git a/internal/parser/languages/rust_generic_bounds_test.go b/internal/parser/languages/rust_generic_bounds_test.go new file mode 100644 index 000000000..dbd1573a3 --- /dev/null +++ b/internal/parser/languages/rust_generic_bounds_test.go @@ -0,0 +1,92 @@ +package languages + +import ( + "testing" + + "github.com/zzet/gortex/internal/graph" +) + +func TestRustExtractorCapturesEffectiveGenericBounds(t *testing.T) { + tests := []struct { + name string + src string + kind graph.NodeKind + }{ + { + name: "inline impl bound", + kind: graph.KindMethod, + src: ` +trait Matcher { fn is_match(&self) -> bool; } +struct Runner { matcher: M } +impl Runner { + fn run(&self, matcher: M) -> bool { matcher.is_match() } +} +`, + }, + { + name: "impl where bound", + kind: graph.KindMethod, + src: ` +trait Matcher { fn is_match(&self) -> bool; } +struct Runner { matcher: M } +impl Runner where M: Matcher { + fn run(&self, matcher: M) -> bool { matcher.is_match() } +} +`, + }, + { + name: "function where bound", + kind: graph.KindFunction, + src: ` +trait Matcher { fn is_match(&self) -> bool; } +fn run(matcher: M) -> bool where M: Matcher { matcher.is_match() } +`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := NewRustExtractor().Extract("src/lib.rs", []byte(tt.src)) + if err != nil { + t.Fatalf("extract: %v", err) + } + var run *graph.Node + for _, node := range result.Nodes { + if node.Name == "run" && node.Kind == tt.kind { + run = node + break + } + } + if run == nil { + t.Fatalf("run node (%s) not emitted", tt.kind) + } + if got := rustTestTypeParamBound(run.Meta["type_params"], "M"); got != "Matcher" { + t.Fatalf("M bound = %q, want Matcher; meta=%v", got, run.Meta) + } + + var call *graph.Edge + for _, edge := range result.Edges { + if edge.From == run.ID && edge.Kind == graph.EdgeCalls && edge.To == "unresolved::*.is_match" { + call = edge + break + } + } + if call == nil { + t.Fatalf("generic receiver call not emitted for %s", run.ID) + } + if got, _ := call.Meta["receiver_type"].(string); got != "M" { + t.Fatalf("receiver_type = %q, want M; meta=%v", got, call.Meta) + } + }) + } +} + +func rustTestTypeParamBound(value any, name string) string { + entries, _ := value.([]map[string]string) + for _, entry := range entries { + if entry["name"] == name { + return entry["bound"] + } + } + return "" +} diff --git a/internal/parser/languages/rust_trait_bounds.go b/internal/parser/languages/rust_trait_bounds.go new file mode 100644 index 000000000..34cf14e20 --- /dev/null +++ b/internal/parser/languages/rust_trait_bounds.go @@ -0,0 +1,173 @@ +package languages + +import ( + "strings" + "unicode" +) + +// rustPositiveTraitBounds returns the positive, top-level trait paths from a +// Rust trait_bounds node. Lifetimes and optional `?Trait` bounds do not define +// supertrait relationships and are intentionally omitted. +func rustPositiveTraitBounds(raw string) []string { + raw = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(raw), ":")) + parts, ok := splitRustPositiveBounds(raw) + if !ok { + return nil + } + seen := make(map[string]struct{}) + var paths []string + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" || strings.HasPrefix(part, "'") || strings.HasPrefix(part, "?") { + continue + } + part = stripRustPositiveBoundPrefix(part) + if part == "" || strings.HasPrefix(part, "'") || strings.HasPrefix(part, "?") { + continue + } + path := rustPositiveTraitPath(part) + if path == "" { + continue + } + if _, exists := seen[path]; exists { + continue + } + seen[path] = struct{}{} + paths = append(paths, path) + } + return paths +} + +func splitRustPositiveBounds(raw string) ([]string, bool) { + var parts []string + start := 0 + angle, paren, bracket, brace := 0, 0, 0, 0 + for i, r := range raw { + switch r { + case '<': + angle++ + case '>': + if i > 0 && raw[i-1] == '-' { + continue + } + if angle == 0 { + return nil, false + } + angle-- + case '(': + paren++ + case ')': + if paren == 0 { + return nil, false + } + paren-- + case '[': + bracket++ + case ']': + if bracket == 0 { + return nil, false + } + bracket-- + case '{': + brace++ + case '}': + if brace == 0 { + return nil, false + } + brace-- + case '+': + if angle == 0 && paren == 0 && bracket == 0 && brace == 0 { + parts = append(parts, raw[start:i]) + start = i + 1 + } + } + } + if angle != 0 || paren != 0 || bracket != 0 || brace != 0 { + return nil, false + } + parts = append(parts, raw[start:]) + return parts, true +} + +func stripRustPositiveBoundPrefix(part string) string { + part = strings.TrimSpace(part) + if strings.HasPrefix(part, "for") { + rest := strings.TrimSpace(strings.TrimPrefix(part, "for")) + if strings.HasPrefix(rest, "<") { + depth := 0 + end := -1 + for i, r := range rest { + switch r { + case '<': + depth++ + case '>': + if depth == 0 { + return "" + } + depth-- + if depth == 0 { + end = i + 1 + } + } + if end >= 0 { + break + } + } + if end < 0 { + return "" + } + part = strings.TrimSpace(rest[end:]) + } + } + for { + before := part + for _, prefix := range []string{"~const ", "const ", "dyn "} { + if strings.HasPrefix(part, prefix) { + part = strings.TrimSpace(strings.TrimPrefix(part, prefix)) + break + } + } + if part == before { + return part + } + } +} + +func rustPositiveTraitPath(part string) string { + end := len(part) + for i, r := range part { + switch r { + case '<', '(', '[', '{', '=', ' ', '\t', '\r', '\n': + end = i + } + if end != len(part) { + break + } + } + path := strings.Trim(strings.TrimSpace(part[:end]), ":") + if path == "" || strings.ContainsAny(path, "&*!,;") { + return "" + } + segments := strings.Split(path, "::") + for i, segment := range segments { + segment = strings.TrimPrefix(segment, "r#") + if !rustTraitIdentifier(segment) { + return "" + } + segments[i] = segment + } + return strings.Join(segments, "::") +} + +func rustTraitIdentifier(segment string) bool { + if segment == "" { + return false + } + for i, r := range segment { + if r == '_' || unicode.IsLetter(r) || (i > 0 && unicode.IsDigit(r)) { + continue + } + return false + } + return true +} diff --git a/internal/parser/languages/rust_trait_bounds_test.go b/internal/parser/languages/rust_trait_bounds_test.go new file mode 100644 index 000000000..c5b9b2b48 --- /dev/null +++ b/internal/parser/languages/rust_trait_bounds_test.go @@ -0,0 +1,50 @@ +package languages + +import ( + "reflect" + "testing" +) + +func TestRustPositiveTraitBounds(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + raw string + want []string + }{ + { + name: "qualified raw unicode and omitted non-positive bounds", + raw: "crate::r#match::Διαβάζει + ?Sized + 'a", + want: []string{"crate::match::Διαβάζει"}, + }, + { + name: "higher ranked trait bound with whitespace", + raw: "for <'a> Fn(&'a str) + Send", + want: []string{"Fn", "Send"}, + }, + { + name: "optional qualified trait is omitted", + raw: "?crate::Maybe + Sync", + want: []string{"Sync"}, + }, + { + name: "unmatched close rejects entire list", + raw: "Matcher> + Send", + }, + { + name: "unmatched open rejects entire list", + raw: "Matcher")§ · §read(target:{symbol:""})§ · §relations(operation:"usages", target:{symbol:""})§. + +If the Gortex server is configured but these tools are missing from the callable MCP tools, report a Gortex MCP integration failure and stop. Do not start a daemon or switch to a CLI/shell fallback. + +`) + +var sectionCompactMemory = bt(`Use §recall§ before revisiting prior work. Call §remember§ immediately for a durable decision, invariant, constraint, or gotcha. `) var sectionFullRuleTable = bt(`| Instead of... | Use... | |-------------------------------------|------------------------------------------| -| Localizing a task / bug / "where is X" | §explore§ (one call: ranked neighborhood + source + call paths) | +| Files / symbols / evidence / "where is X" | §explore(operation:"localize")§; obey §completion§ | +| Diagnosing or changing code | §explore(operation:"task")§, then impact/edit/test | | §Grep§ / §grep§ / §rg§ 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§ | @@ -59,9 +80,9 @@ var sectionFullRuleTable = bt(`| Instead of... | Use... `) -// sectionCLIFallback is the shell fallback line. Load-bearing for -// harnesses that mount no MCP tools — present in every profile. -var sectionCLIFallback = bt(`**CLI fallback (no MCP):** every tool above is reachable from a shell as §gortex call --arg k=v§ (e.g. §gortex call read_file --arg path=§) — there is no bare §gortex § verb. +// sectionMCPRequired keeps the legacy/full MCP profile from silently changing +// transports when a host bridge fails to expose a configured tool. +var sectionMCPRequired = bt(`**Native MCP required:** if a configured Gortex tool is missing from the callable MCP tools, report a Gortex MCP integration failure and stop. Do not start a daemon or use a CLI/shell fallback. `) @@ -85,12 +106,6 @@ The graph remembers code; these tools remember **why you made a call**. They are `) -// sectionMemoryLean is the one-paragraph memory trio for the lean -// profile — the triggers survive, the elaboration moves to the guide. -var sectionMemoryLean = bt(`**Memory (mandatory):** §distill_session§ at session start; §surface_memories task:""§ right after §smart_context§; §save_note tags:"decision" body:""§ at every decision; §store_memory§ for durable invariants / gotchas the team should inherit; §query_notes§ / §query_memories§ before re-touching a symbol. - -`) - // switchBullet renders the instruction-profile discovery line — the // line every profile carries so a switched-down machine can always // find its way back. The lean rendering keeps the verb and the @@ -110,11 +125,11 @@ func sectionDiscovery(active, surfaceLine string) string { return bt(`## Reference and discovery - **§gortex://guide§ resource** (or the §gortex guide [topic]§ CLI) is the full reference: LLM-provider matrix, capabilities catalog, analyze / search_ast catalogs, token-economy detail, MCP resources, session-start checklist. Read it on demand — it is not pre-paid here. -- `) + surfaceLine + bt(`- The SessionStart hook injects daemon status. "daemon is not running" → run §gortex daemon start --detach§; "cwd is not covered by any tracked repo" → graph tools are unavailable there. +- `) + surfaceLine + bt(`- MCP startup manages daemon availability. If the configured server or its tools are unavailable, report a Gortex MCP integration failure; do not start a daemon manually. "cwd is not covered by any tracked repo" means graph tools are unavailable there. `) + switchBullet(active, false) } -var coreSurfaceLine = bt(`**§tools_search§** — the server publishes a lean tool preset eagerly and defers the rest; call §tools_search§ to discover and load any tool by keyword (every tool stays callable by name). +var compactSurfaceLine = bt(`**§capabilities§** — request an exact operation schema only when the compact tool description is insufficient; ordinary coding requires no tool discovery or promotion. `) var fullSurfaceLine = bt(`**§tools_search§** — under this profile the server publishes the full documented dev-cycle preset (~34 workhorse tools) eagerly; the long tail still loads by keyword via §tools_search§ (every tool stays callable by name). @@ -124,12 +139,9 @@ var fullSurfaceLine = bt(`**§tools_search§** — under this profile the server // profile-switch discovery line. func coreBody() string { return sectionHeader(false) + - sectionExploreOpener + - sectionFullRuleTable + - sectionCLIFallback + - sectionReadDiscipline + - sectionMemoryFull + - sectionDiscovery("core", coreSurfaceLine) + sectionCompactWorkflow + + sectionCompactMemory + + sectionDiscovery("core", compactSurfaceLine) } // fullBody differs from core only in the eager-surface description — @@ -139,7 +151,7 @@ func fullBody() string { return sectionHeader(false) + sectionExploreOpener + sectionFullRuleTable + - sectionCLIFallback + + sectionMCPRequired + sectionReadDiscipline + sectionMemoryFull + sectionDiscovery("full", fullSurfaceLine) @@ -174,31 +186,14 @@ var localizationNonTableTools = map[string]bool{ "index_health": true, // the liveness line in discovery } -func localizationTable() string { - var sb strings.Builder - sb.WriteString("| Instead of... | Use... |\n") - sb.WriteString("|--------------------------------|---------------------------------|\n") - for _, r := range localizationRows { - fmt.Fprintf(&sb, "| %s | %s |\n", bt(r.instead), bt(r.use)) - } - sb.WriteString("\n") - return sb.String() -} - // localizationBody is the lean profile: every positioning cue (MUST // rule, deny warning, one-shot opener, memory triggers, discovery / // switch-back path) survives; reference elaboration moves to // gortex://guide. func localizationBody() string { return sectionHeader(true) + - bt(`**Open every localization task with §explore§** (paste the whole issue — distilled server-side): the ranked neighborhood in one call. Answer from its output; its source is in context — don't re-open those files with §Read§/§Glob§, read one with §get_symbol_source§/§batch_symbols§. - -`) + - localizationTable() + - bt(`Read the real body (§get_symbol_source§, without §compress_bodies§) before you change or depend on a symbol; §smart_context§ assembles the broader working set when explore's neighborhood is not enough. **No MCP mounted?** Any tool works from a shell: §gortex call --arg k=v§. - -`) + - sectionMemoryLean + - bt(`**Discovery:** lean §localization§ profile — the tools above plus §index_health§ (liveness) ship eagerly; anything else loads by name via §tools_search§. Full reference: §gortex://guide§. + sectionCompactWorkflow + + sectionCompactMemory + + bt(`**Reference:** call §capabilities§ for an exact operation schema; use §gortex://guide§ only for deeper background. `) + switchBullet("localization", true) } diff --git a/internal/profiles/profiles.go b/internal/profiles/profiles.go index 9360d038a..bc6e5b8d5 100644 --- a/internal/profiles/profiles.go +++ b/internal/profiles/profiles.go @@ -57,8 +57,8 @@ type Profile struct { Summary string // ToolPreset is the MCP tool-surface preset sessions default to // while this profile is active. Empty keeps the server's existing - // client-aware defaults (known coding agents get the `agent` - // floor, everyone else the server's global preset). + // client-aware default (named MCP clients get the compact public + // surface; uninitialized sessions keep the server's global preset). ToolPreset string // EagerTools, when non-nil, is the eager allow-set of the preset // named by ToolPreset. internal/mcp builds the preset from this diff --git a/internal/profiles/profiles_test.go b/internal/profiles/profiles_test.go index 2e19312c9..502f393b5 100644 --- a/internal/profiles/profiles_test.go +++ b/internal/profiles/profiles_test.go @@ -16,9 +16,9 @@ import ( // gets slack for its longer surface description only; localization is // the diet profile and must stay under 2 KiB (~0.45k tokens). var bodyByteCeilings = map[string]int{ - "core": 6656, + "core": 3072, "full": 6912, - "localization": 2304, // explore opener + row joined at the explore-branch consolidation + "localization": 2304, } func TestProfileBodyByteCeilings(t *testing.T) { @@ -40,23 +40,18 @@ func TestProfileBodyByteCeilings(t *testing.T) { } // positioningCues are the load-bearing fragments EVERY profile must -// keep, lean ones included: the mandatory-rule sentinel, the deny-hook +// keep, lean ones included: the mandatory-rule sentinel, the hook-posture // warning, the one-shot opener, the memory triggers, the discovery -// path, the CLI fallback, and the switch-back line. Trimming any of +// path, the configurable hook posture, and the switch-back line. Trimming any of // these is what costs tool adoption — the diet only ever removes // elaboration around them. var positioningCues = []string{ "## MANDATORY: Use Gortex MCP tools", // idempotency sentinel + the rule itself "MUST prefer graph queries", - "deny", - "smart_context", - "distill_session", - "surface_memories", - "save_note", - "store_memory", - "tools_search", - "gortex://guide", - "gortex call", + "Hook posture is configurable", + "explore", + "Gortex MCP integration failure", + "Do not start a daemon", "gortex instructions switch", "NEW sessions only", } @@ -72,16 +67,14 @@ func TestEveryProfileKeepsPositioningCues(t *testing.T) { } } -// TestLocalizationEagerToolsAllCued is the no-drift gate between the -// localization tool preset and the localization instructions body: -// every eager tool must be documented (table row or prose cue), and -// every table row must correspond to an eager tool. -func TestLocalizationEagerToolsAllCued(t *testing.T) { +// TestLocalizationPresetRowsStayInSync keeps the optional legacy +// localization preset table internally consistent. The active localization +// instruction profile now describes the compact public surface instead. +func TestLocalizationPresetRowsStayInSync(t *testing.T) { p, ok := ByName("localization") if !ok { t.Fatal("localization profile missing from the table") } - body := p.Body() rowTools := map[string]bool{} for _, r := range localizationRows { rowTools[r.tool] = true @@ -90,9 +83,6 @@ func TestLocalizationEagerToolsAllCued(t *testing.T) { if !rowTools[tool] && !localizationNonTableTools[tool] { t.Errorf("eager tool %q has neither a table row nor a prose cue — add one", tool) } - if !strings.Contains(body, tool) { - t.Errorf("eager tool %q does not appear in the localization body", tool) - } } eager := map[string]bool{} for _, tool := range p.EagerTools { @@ -239,25 +229,39 @@ var relocatedContentMarkers = []string{ // carry — the machine-level single home for the full memory-workflow // triggers. The localization profile intentionally keeps only the // positioning cues (TestEveryProfileKeepsPositioningCues). -var fullPolicyTokens = []string{ +var preCompactFullPolicyTokens = []string{ "search_symbols", "find_usages", "get_callers", "get_call_chain", "get_symbol_source", "get_editing_context", "read_file", "smart_context", "edit_file", "rename_symbol", "compress_bodies", "distill_session", "surface_memories", "save_note", "store_memory", "query_notes", "query_memories", - "tools_search", "gortex://guide", "gortex daemon start", + "tools_search", "gortex://guide", "MCP startup manages daemon availability", } func TestBodies_PolicyCoreAndSingleHome(t *testing.T) { - for _, name := range []string{"core", "full"} { - p, ok := ByName(name) - if !ok { + full, ok := ByName("full") + if !ok { + t.Fatal("full profile missing") + } + for _, token := range preCompactFullPolicyTokens { + if !strings.Contains(full.Body(), token) { + t.Errorf("full body no longer mentions %q — pre-compact opt-in policy regression", token) + } + } + + for _, name := range []string{"core", "localization"} { + p, found := ByName(name) + if !found { t.Fatalf("profile %q missing", name) } - body := p.Body() - for _, token := range fullPolicyTokens { - if !strings.Contains(body, token) { - t.Errorf("%s body no longer mentions %q — policy core regression", name, token) + for _, token := range []string{"explore", "search", "read", "relations", "trace", "change", "edit", "refactor", "capabilities", "recall", "remember"} { + if !strings.Contains(p.Body(), token) { + t.Errorf("%s body no longer mentions compact tool %q", name, token) + } + } + for _, banned := range []string{"facade-v1", "search_symbols", "get_symbol_source", "tools_search", "while these tools are available"} { + if strings.Contains(p.Body(), banned) { + t.Errorf("%s body exposes implementation detail %q", name, banned) } } } diff --git a/internal/query/engine.go b/internal/query/engine.go index 188732205..ee8907396 100644 --- a/internal/query/engine.go +++ b/internal/query/engine.go @@ -472,13 +472,11 @@ func (e *Engine) SearchSymbols(query string, limit int) []*graph.Node { return e.SearchSymbolsScoped(query, limit, QueryOptions{}) } -// SearchSymbolsRanked is SearchSymbolsScoped that returns the full -// rerank.Candidate slice instead of just the nodes — callers can read -// the per-signal contributions and the final score off each candidate. -// rctx is optional session context (frecency / combo / feedback / -// repo + project locality); pass nil to score with structural signals -// only. -func (e *Engine) SearchSymbolsRanked(query string, limit int, opts QueryOptions, rctx *rerank.Context) []*rerank.Candidate { +// GatherSymbolCandidates retrieves the hybrid BM25/vector candidate union with +// channel ranks and scope preserved, but does not rerank or truncate the union. +// Fan-out callers merge multiple retrieval channels through this method and run +// one final session-aware rerank over the combined slice. +func (e *Engine) GatherSymbolCandidates(query string, limit int, opts QueryOptions, rctx *rerank.Context) []*rerank.Candidate { if limit <= 0 { limit = 20 } @@ -490,11 +488,6 @@ func (e *Engine) SearchSymbolsRanked(query string, limit int, opts QueryOptions, } } - // Engine-side rctx wins over the opts-piggybacked one (the explicit - // arg is the load-bearing path for callers that build the context - // inline). Callers (the MCP search_symbols handler) that build the - // rctx upstream and want both BM25 calls to share the same edge- - // cache seeding pass it through opts.RerankContext instead. gatherCtx := rctx if gatherCtx == nil { gatherCtx = opts.RerankContext @@ -526,12 +519,21 @@ func (e *Engine) SearchSymbolsRanked(query string, limit int, opts QueryOptions, cands = kept } - // Cross-repo RRF: when the candidate set spans repositories, the - // per-channel ranks are reassigned repo by repo so each repo's - // strongest hits compete on even footing. The rerank's RRF-kernel - // bm25 and semantic signals then fuse across repos rather than - // ranking within one merged corpus. No-op for a single-repo set. crossRepoRerank(cands) + return cands +} + +// SearchSymbolsRanked is SearchSymbolsScoped that returns the full +// rerank.Candidate slice instead of just the nodes — callers can read +// the per-signal contributions and the final score off each candidate. +// rctx is optional session context (frecency / combo / feedback / +// repo + project locality); pass nil to score with structural signals +// only. +func (e *Engine) SearchSymbolsRanked(query string, limit int, opts QueryOptions, rctx *rerank.Context) []*rerank.Candidate { + if limit <= 0 { + limit = 20 + } + cands := e.GatherSymbolCandidates(query, limit, opts, rctx) if e.rerank != nil && !opts.SkipInnerRerank { ctx := rctx diff --git a/internal/query/engine_candidates_test.go b/internal/query/engine_candidates_test.go new file mode 100644 index 000000000..a01bacfd2 --- /dev/null +++ b/internal/query/engine_candidates_test.go @@ -0,0 +1,67 @@ +package query + +import ( + "testing" + + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/search" + "github.com/zzet/gortex/internal/search/rerank" +) + +type candidateChannelBackend struct { + text []search.SearchResult + vector []string +} + +func (b *candidateChannelBackend) Add(string, ...string) {} +func (b *candidateChannelBackend) Remove(string) {} +func (b *candidateChannelBackend) Search(string, int) []search.SearchResult { + return append([]search.SearchResult(nil), b.text...) +} +func (b *candidateChannelBackend) SearchChannels(string, int) ([]search.SearchResult, []string) { + return append([]search.SearchResult(nil), b.text...), append([]string(nil), b.vector...) +} +func (b *candidateChannelBackend) Count() int { return len(b.text) + len(b.vector) } +func (b *candidateChannelBackend) Close() {} + +func TestGatherSymbolCandidatesPreservesHybridRanksAndScope(t *testing.T) { + g := graph.New() + for _, node := range []*graph.Node{ + {ID: "text", Name: "text", Kind: graph.KindFunction, WorkspaceID: "wanted"}, + {ID: "vector", Name: "vector", Kind: graph.KindFunction, WorkspaceID: "wanted"}, + {ID: "outside", Name: "outside", Kind: graph.KindFunction, WorkspaceID: "other"}, + } { + g.AddNode(node) + } + + backend := &candidateChannelBackend{ + text: []search.SearchResult{{ID: "text", Score: 1}}, + vector: []string{"vector", "outside"}, + } + engine := NewEngine(g) + engine.SetSearch(backend) + opts := QueryOptions{WorkspaceID: "wanted", SkipInnerRerank: true} + + gathered := engine.GatherSymbolCandidates("concept", 1, opts, nil) + if len(gathered) != 2 { + t.Fatalf("gathered %d candidates, want text and vector-only hits: %#v", len(gathered), gathered) + } + byID := make(map[string]*rerank.Candidate, len(gathered)) + for _, candidate := range gathered { + byID[candidate.Node.ID] = candidate + } + if got := byID["text"]; got == nil || got.TextRank != 0 || got.VectorRank != -1 { + t.Fatalf("text rank not preserved: %#v", got) + } + if got := byID["vector"]; got == nil || got.TextRank != -1 || got.VectorRank != 0 { + t.Fatalf("vector-only rank not preserved: %#v", got) + } + if byID["outside"] != nil { + t.Fatalf("workspace scope leaked unrelated candidate: %#v", byID["outside"]) + } + + ranked := engine.SearchSymbolsRanked("concept", 1, opts, nil) + if len(ranked) != 1 || ranked[0].Node.ID != "text" { + t.Fatalf("ranked compatibility no longer truncates to the requested limit: %#v", ranked) + } +} diff --git a/internal/reach/reach.go b/internal/reach/reach.go index 9968339a2..155271a1d 100644 --- a/internal/reach/reach.go +++ b/internal/reach/reach.go @@ -12,8 +12,14 @@ package reach import ( "context" + "crypto/rand" + "encoding/hex" + "math" "sort" + "strconv" + "sync" "sync/atomic" + "time" "github.com/zzet/gortex/internal/graph" "github.com/zzet/gortex/internal/progress" @@ -30,8 +36,9 @@ import ( // GetInEdges calls at query time — so a precomputed AnalyzeImpact // stays sub-ms even on graphs with high fan-in. // -// Stored on Node.Meta — gob-serialized into the daemon snapshot so -// warm starts keep O(1) impact lookups without paying the build cost. +// Eager records are stored on Node.Meta. A process epoch deliberately rejects +// records persisted by a prior daemon process: graph topology may have changed +// while the restart-local numeric build counter reused the same value. const ( MetaReachD1 = "reach_d1" MetaReachD2 = "reach_d2" @@ -42,15 +49,50 @@ const ( MetaReachD1Label = "reach_d1_label" MetaReachD2Label = "reach_d2_label" MetaReachD3Label = "reach_d3_label" + // MetaReachComplete is the publication marker for a reach record. + // A matching generation is not sufficient on its own: older code + // stamped MetaReachBuild before it finished writing the tier keys, so + // a concurrent lookup could observe a matching stamp with empty tiers + // and return a false-safe zero-impact result. New records are assembled + // on a copy of the node and published only after this marker is set. + MetaReachComplete = "reach_complete" + // MetaReachEpoch identifies the daemon process that published a record. + // The numeric build counter restarts with each process; without a separate + // epoch, a warm database can make a new build generation collide with an + // old persisted record and temporarily expose stale or empty tiers. + MetaReachEpoch = "reach_epoch" + // MetaReachTruncated marks a complete-but-bounded record. Its tiers are + // valid lower-bound evidence, but callers must not interpret their end as + // proof that no more dependents exist. + MetaReachTruncated = "reach_truncated" // MetaReachBuild is a monotonic build-generation counter stamped // on every node the indexer touched in the most recent reach pass. - // Consumers compare it against the graph-level counter on - // AnalyzeImpact entry to decide whether to trust the precomputed - // sets or fall back to a live walk. The Meta value is a uint64. + // Consumers require both a counter match and MetaReachComplete before + // trusting the precomputed sets. The Meta value is a uint64. MetaReachBuild = "reach_build" + + // reachPublishBatchSize bounds the temporary copy-on-write node set + // retained by eager builds and clears. A few thousand rows keeps SQLite + // transaction amortisation while avoiding a full-graph duplicate of every + // Node and Meta map at peak. + reachPublishBatchSize = 256 + + // Lazy impact lookup is an interactive safety gate. A pathological hub + // must return a conservative lower bound promptly, not hold the resolver + // mutex for minutes while expanding an unbounded breadth-first frontier. + maxLookupEdges = 5000 + lookupTimeout = 3 * time.Second + lockPoll = 2 * time.Millisecond ) +var reachMetaKeys = [...]string{ + MetaReachD1, MetaReachD2, MetaReachD3, + MetaReachD1Conf, MetaReachD2Conf, MetaReachD3Conf, + MetaReachD1Label, MetaReachD2Label, MetaReachD3Label, + MetaReachBuild, MetaReachComplete, MetaReachEpoch, MetaReachTruncated, +} + // ReachableEdge returns true when an edge participates in the impact // graph. Mirrors AnalyzeImpact's filter exactly so the precomputed // sets and the live walk agree on membership. Exported so the @@ -85,9 +127,142 @@ type Stats struct { } // buildCounter is a process-wide monotonic generation counter used to -// invalidate cached reach sets across snapshot reloads and -// incremental rebuilds. Bumped on every BuildIndex / ClearIndex call. -var buildCounter uint64 +// invalidate cached reach sets after graph mutations and incremental rebuilds. +// reachProcessEpoch prevents a counter value reused by a restarted process from +// matching a record persisted by the previous process. +var ( + buildCounter uint64 + reachProcessEpoch = newReachProcessEpoch() +) + +func newReachProcessEpoch() string { + var token [16]byte + if _, err := rand.Read(token[:]); err == nil { + return hex.EncodeToString(token[:]) + } + // crypto/rand failure is exceptional. A nanosecond process-start token still + // keeps the safety marker independent from the restart-local build counter. + return strconv.FormatInt(time.Now().UnixNano(), 36) +} + +// topologyGate serialises impact readers/eager reach maintenance with a +// watcher topology transaction. It is separate from Store.ResolveMutex: +// watcher indexing calls resolver methods that acquire ResolveMutex +// internally, so holding that non-reentrant mutex around the whole patch would +// deadlock. Readers always acquire this gate before ResolveMutex; a watcher +// holds the writer side while its nested resolver calls take ResolveMutex. +type topologyGate struct { + mu sync.Mutex + cond *sync.Cond + readers int + writer bool + writersWaiting int +} + +// buildCounter is already process-global, so topology publication uses one +// process-global gate as well. This avoids retaining one registry entry per +// short-lived Store forever (notably across tests and workspace reloads). +// Production multi-repo indexers share one Store; independent-store watcher +// transactions are rare enough that the conservative cross-store serialization +// is preferable to an unbounded coordination registry. +var globalTopologyGate = func() *topologyGate { + gate := &topologyGate{} + gate.cond = sync.NewCond(&gate.mu) + return gate +}() + +func beginTopologyRead(_ graph.Store) func() { + gate := globalTopologyGate + gate.mu.Lock() + for gate.writer || gate.writersWaiting > 0 { + gate.cond.Wait() + } + gate.readers++ + gate.mu.Unlock() + + return func() { + gate.mu.Lock() + gate.readers-- + if gate.readers == 0 { + gate.cond.Broadcast() + } + gate.mu.Unlock() + } +} + +// beginTopologyReadContext is the cancellable counterpart used by lazy +// impact lookups. It never waits on sync.Cond (which has no cancellation +// channel); a short poll keeps the writer-preference semantics while allowing +// an expired tool request to stop waiting. +func beginTopologyReadContext(ctx context.Context) (func(), bool) { + gate := globalTopologyGate + for { + gate.mu.Lock() + if !gate.writer && gate.writersWaiting == 0 { + gate.readers++ + gate.mu.Unlock() + return func() { + gate.mu.Lock() + gate.readers-- + if gate.readers == 0 { + gate.cond.Broadcast() + } + gate.mu.Unlock() + }, true + } + gate.mu.Unlock() + select { + case <-ctx.Done(): + return nil, false + case <-time.After(lockPoll): + } + } +} + +func lockContext(ctx context.Context, mu *sync.Mutex) bool { + for !mu.TryLock() { + select { + case <-ctx.Done(): + return false + case <-time.After(lockPoll): + } + } + return true +} + +// BeginTopologyMutation prevents reach lookups/builds from observing a +// watcher's parse-then-swap and resolver work half-applied. The returned +// function must be called exactly once; changed=true invalidates every cached +// generation before waiting readers are released. A separate gate is required +// because the mutation body itself invokes resolver methods that acquire the +// store's non-reentrant ResolveMutex. +func BeginTopologyMutation(g graph.Store) func(changed bool) { + if g == nil { + return func(bool) {} + } + gate := globalTopologyGate + gate.mu.Lock() + gate.writersWaiting++ + for gate.writer || gate.readers > 0 { + gate.cond.Wait() + } + gate.writersWaiting-- + gate.writer = true + gate.mu.Unlock() + + var once sync.Once + return func(changed bool) { + once.Do(func() { + if changed { + InvalidateIndex() + } + gate.mu.Lock() + gate.writer = false + gate.cond.Broadcast() + gate.mu.Unlock() + }) + } +} // BuildIndex precomputes per-depth incoming reachability sets for // every impact-seed node in g and stores them under Node.Meta as @@ -121,13 +296,26 @@ func BuildIndexCtx(ctx context.Context, g graph.Store) *Stats { return &Stats{} } reporter := progress.FromContext(ctx) + releaseTopology, ok := beginTopologyReadContext(ctx) + if !ok { + return &Stats{} + } mu := g.ResolveMutex() - mu.Lock() - defer mu.Unlock() + if !lockContext(ctx, mu) { + releaseTopology() + return &Stats{} + } build := atomic.AddUint64(&buildCounter, 1) + mu.Unlock() + releaseTopology() stats := &Stats{Build: build} - nodes := g.AllNodes() + // Reachability only needs identity and kind during the graph-wide seed + // scan. On SQLite, AllNodes decodes every node's opaque metadata — including + // prior reach payloads — and retains those blobs for the whole build. Use the + // metadata-free projection and fetch a full node only for each bounded + // publication batch below. + nodes := graph.AllNodesLight(g) // Sort by ID so the deterministic iteration order produces stable // reach slices — important for snapshot determinism and for tests // that compare reach payloads across runs. @@ -146,37 +334,66 @@ func BuildIndexCtx(ctx context.Context, g graph.Store) *Stats { const reachProgressEvery = 1000 seedsDone := 0 - // Collect the seed nodes we stamp so we can persist the Meta back - // through the store in one batch at the end. On the in-memory - // backend the in-place stamp already persists (n is canonical); on - // disk backends n is a GetNode reconstruction, so without - // the write-back the whole reach index would be computed and then - // thrown away. Mirrors the per-seed AddNode in Lookup's slow path. - stamped := make([]*graph.Node, 0, seedTotal) + // Persist complete copy-on-write records in bounded batches. Traversal is + // deliberately outside topology/resolve locks: a full eager build can take + // minutes, and holding either lock for that duration starves synchronous + // edits. Each short publication reacquires both locks and checks the build + // generation; a topology mutation makes the pending batch stale and aborts + // the build rather than publishing a mixed snapshot. + type pendingRecord struct { + id string + tiers [3]tier + truncated bool + } + batchCap := min(seedTotal, reachPublishBatchSize) + pending := make([]pendingRecord, 0, batchCap) + publish := func() bool { + if len(pending) == 0 { + return true + } + release, acquired := beginTopologyReadContext(ctx) + if !acquired { + return false + } + if !lockContext(ctx, mu) { + release() + return false + } + if build != atomic.LoadUint64(&buildCounter) { + mu.Unlock() + release() + return false + } + stamped := make([]*graph.Node, 0, len(pending)) + for _, record := range pending { + n := g.GetNode(record.id) + if n == nil || !ImpactSeedKind(n.Kind) { + continue + } + published := cloneNodeWithMeta(n) + writeRecord(published.Meta, build, record.tiers, record.truncated) + stamped = append(stamped, published) + } + if len(stamped) > 0 { + g.AddBatch(stamped, nil) + } + mu.Unlock() + release() + pending = pending[:0] + return true + } for _, n := range nodes { if n == nil || !ImpactSeedKind(n.Kind) { continue } - tiers := compute(g, n.ID) - if n.Meta == nil { - n.Meta = make(map[string]any, 10) - } - // Always stamp the build counter so a node with no reach - // (sink with zero callers) still proves "we tried" — without - // this, lookups for sink nodes would fall back to a live walk - // on every call. - n.Meta[MetaReachBuild] = build - setOrDeleteStrings(n.Meta, MetaReachD1, tiers[0].IDs) - setOrDeleteStrings(n.Meta, MetaReachD2, tiers[1].IDs) - setOrDeleteStrings(n.Meta, MetaReachD3, tiers[2].IDs) - setOrDeleteFloats(n.Meta, MetaReachD1Conf, tiers[0].Conf) - setOrDeleteFloats(n.Meta, MetaReachD2Conf, tiers[1].Conf) - setOrDeleteFloats(n.Meta, MetaReachD3Conf, tiers[2].Conf) - setOrDeleteStrings(n.Meta, MetaReachD1Label, tiers[0].Labels) - setOrDeleteStrings(n.Meta, MetaReachD2Label, tiers[1].Labels) - setOrDeleteStrings(n.Meta, MetaReachD3Label, tiers[2].Labels) - - stamped = append(stamped, n) + if ctx.Err() != nil || build != atomic.LoadUint64(&buildCounter) { + break + } + tiers, truncated := compute(ctx, g, n.ID) + pending = append(pending, pendingRecord{id: n.ID, tiers: tiers, truncated: truncated}) + if len(pending) == reachPublishBatchSize && !publish() { + break + } stats.NodesIndexed++ stats.EntriesD1 += len(tiers[0].IDs) stats.EntriesD2 += len(tiers[1].IDs) @@ -187,12 +404,9 @@ func BuildIndexCtx(ctx context.Context, g graph.Store) *Stats { reporter.Report("reachability index", seedsDone, seedTotal) } } - // Persist every stamped node's Meta back through the store in one - // batch (no-op-ish on the in-memory backend, the durable write on - // disk backends). AddBatch with no edges only upserts the nodes. - if len(stamped) > 0 { - g.AddBatch(stamped, nil) - } + // Flush the final partial batch. AddBatch with no edges only upserts the + // nodes; a stale generation or cancelled context deliberately drops it. + _ = publish() reporter.Report("reachability index", seedsDone, seedTotal) return stats } @@ -228,6 +442,42 @@ func setOrDeleteFloats(m map[string]any, key string, value []float64) { m[key] = value } +// cloneNodeWithMeta makes a copy-on-write carrier for a reach record. +// Node.Meta is otherwise a regular Go map: editing the canonical map in +// place while Lookup reads it is both a data race and a partial-publication +// hazard. Values are shallow-copied because reach only replaces its own +// immutable slices and never mutates metadata owned by another subsystem. +func cloneNodeWithMeta(n *graph.Node) *graph.Node { + clone := *n + clone.Meta = make(map[string]any, len(n.Meta)+11) + for key, value := range n.Meta { + clone.Meta[key] = value + } + return &clone +} + +// writeRecord writes every tier onto a private metadata map and sets the +// completeness marker last. The containing node must not be published to the +// Store until this function returns. +func writeRecord(meta map[string]any, build uint64, tiers [3]tier, truncated bool) { + delete(meta, MetaReachComplete) + setOrDeleteStrings(meta, MetaReachD1, tiers[0].IDs) + setOrDeleteStrings(meta, MetaReachD2, tiers[1].IDs) + setOrDeleteStrings(meta, MetaReachD3, tiers[2].IDs) + setOrDeleteFloats(meta, MetaReachD1Conf, tiers[0].Conf) + setOrDeleteFloats(meta, MetaReachD2Conf, tiers[1].Conf) + setOrDeleteFloats(meta, MetaReachD3Conf, tiers[2].Conf) + setOrDeleteStrings(meta, MetaReachD1Label, tiers[0].Labels) + setOrDeleteStrings(meta, MetaReachD2Label, tiers[1].Labels) + setOrDeleteStrings(meta, MetaReachD3Label, tiers[2].Labels) + // A node with no callers deliberately has no tier keys. These two + // fields distinguish that valid empty record from an interrupted write. + meta[MetaReachBuild] = build + meta[MetaReachEpoch] = reachProcessEpoch + meta[MetaReachTruncated] = truncated + meta[MetaReachComplete] = true +} + // compute walks incoming edges from seed up to depth 3 and returns // per-depth tiers carrying every ID encountered plus the // representative in-edge's confidence + label. Each ID appears in at @@ -235,18 +485,29 @@ func setOrDeleteFloats(m map[string]any, key string, value []float64) { // filtered with ReachableEdge so the result matches AnalyzeImpact; // file / import nodes are walked through for fan-out but excluded // from the tier slices. -func compute(g graph.Store, seedID string) [3]tier { +func compute(ctx context.Context, g graph.Store, seedID string) ([3]tier, bool) { var result [3]tier + truncated := false + edgesRemaining := maxLookupEdges visited := map[string]struct{}{seedID: {}} current := []string{seedID} for depth := 1; depth <= 3 && len(current) > 0; depth++ { + if ctx.Err() != nil || edgesRemaining <= 0 { + truncated = true + break + } // Batch the whole BFS level's incoming-edge fetch into one // backend round-trip. The per-node g.GetInEdges(id) form issued // one query per node on disk backends — an // O(reachable-nodes) query storm that turned a single // AnalyzeImpact live walk into a multi-minute (timeout) call on // a disk backend. GetInEdgesByNodeIDs collapses it to one query per depth. - inEdges := g.GetInEdgesByNodeIDs(current) + inEdges, limited, err := getInEdgesBounded(ctx, g, current, edgesRemaining) + if err != nil { + truncated = true + break + } + edgesRemaining -= edgeCount(inEdges) // First pass: discover this level's new From-nodes in // deterministic (current-order, edge-order) order, recording the @@ -260,6 +521,10 @@ func compute(g graph.Store, seedID string) [3]tier { var cands []cand for _, id := range current { for _, e := range inEdges[id] { + if ctx.Err() != nil { + truncated = true + break + } if !ReachableEdge(e.Kind) { continue } @@ -281,7 +546,11 @@ func compute(g graph.Store, seedID string) [3]tier { for i := range cands { ids[i] = cands[i].from } - nodes := g.GetNodesByIDs(ids) + nodes, nodeErr := getNodesContext(ctx, g, ids) + if nodeErr != nil { + truncated = true + break + } slot := depth - 1 for _, c := range cands { n := nodes[c.from] @@ -294,11 +563,49 @@ func compute(g graph.Store, seedID string) [3]tier { graph.ConfidenceLabelFor(c.kind, c.conf)) } current = next + if limited { + truncated = true + break + } } for i := range result { sortTierByID(&result[i]) } - return result + return result, truncated +} + +type boundedIncomingEdgeReader interface { + GetInEdgesByNodeIDsContext(context.Context, []string, int) (map[string][]*graph.Edge, bool, error) +} + +func getInEdgesBounded(ctx context.Context, g graph.Store, ids []string, limit int) (map[string][]*graph.Edge, bool, error) { + if reader, ok := g.(boundedIncomingEdgeReader); ok { + return reader.GetInEdgesByNodeIDsContext(ctx, ids, limit) + } + if err := ctx.Err(); err != nil { + return nil, true, err + } + all := g.GetInEdgesByNodeIDs(ids) + out := make(map[string][]*graph.Edge, len(all)) + count := 0 + for _, id := range ids { + for _, edge := range all[id] { + if count >= limit { + return out, true, nil + } + out[id] = append(out[id], edge) + count++ + } + } + return out, false, nil +} + +func edgeCount(byNode map[string][]*graph.Edge) int { + total := 0 + for _, edges := range byNode { + total += len(edges) + } + return total } // sortTierByID sorts a tier's parallel arrays in lock-step by ID so @@ -335,23 +642,39 @@ func ClearIndex(g graph.Store) { if g == nil { return } + releaseTopology := beginTopologyRead(g) + defer releaseTopology() mu := g.ResolveMutex() mu.Lock() defer mu.Unlock() atomic.AddUint64(&buildCounter, 1) + cleared := make([]*graph.Node, 0, reachPublishBatchSize) for _, n := range g.AllNodes() { - if n == nil || n.Meta == nil { + if n == nil || !hasReachMeta(n.Meta) { continue } - for _, k := range []string{ - MetaReachD1, MetaReachD2, MetaReachD3, - MetaReachD1Conf, MetaReachD2Conf, MetaReachD3Conf, - MetaReachD1Label, MetaReachD2Label, MetaReachD3Label, - MetaReachBuild, - } { - delete(n.Meta, k) + clone := cloneNodeWithMeta(n) + for _, k := range reachMetaKeys { + delete(clone.Meta, k) + } + cleared = append(cleared, clone) + if len(cleared) == reachPublishBatchSize { + g.AddBatch(cleared, nil) + cleared = cleared[:0] + } + } + if len(cleared) > 0 { + g.AddBatch(cleared, nil) + } +} + +func hasReachMeta(meta map[string]any) bool { + for _, key := range reachMetaKeys { + if _, ok := meta[key]; ok { + return true } } + return false } // Entry is one precomputed reach record: a node ID and the @@ -364,121 +687,266 @@ type Entry struct { Label string } -// Lookup returns the per-depth reach for seedID. On a fresh cache hit -// (build counter matches current generation) it returns the cached -// tiers in sub-millisecond. On a miss — first call for this seed, or -// the global build counter has advanced past the stamped value -// because the graph mutated — it runs the BFS on demand under -// g.ResolveMutex(), caches the result onto n.Meta, and returns the -// fresh tiers. Returns hit=false only when seedID names no node or -// names a node whose kind is not an impact seed (KindFunction, -// KindMethod, KindType, KindInterface). +// Lookup returns the per-depth reach for seedID. A current, complete eager +// record is read in sub-millisecond. On a miss it performs one bounded, +// cancellable BFS and returns that lower-bound-aware result without writing to +// the graph. Read-only lazy traversal is intentional: publishing from an +// interactive safety check can wait behind SQLite writers after the request +// deadline and starve edits. Returns hit=false only when seedID is absent or is +// not an impact-seed kind. // -// This is the "lazy reach index" — the eager BuildIndex pass that -// used to walk every impact seed during cold-index has been removed -// from the IndexCtx hot path because the breakeven was untenable on -// monorepo graphs: ~2000 s of cold-index work on k8s to save ~10 ms -// per query, requiring ~200 k queries to break even. The lazy form -// pays the 10 ms only on the first AnalyzeImpact call that names a -// given seed, then caches forever. BuildIndex remains available for -// `gortex enrich reach` (explicit prebuild) and for callers that -// want to pay the cost up front under controlled conditions. +// The eager BuildIndex pass is no longer part of cold indexing because its +// monorepo breakeven is poor. It remains available for explicit prebuilding; +// normal impact calls pay only for their requested seed and never recurse over +// every symbol in the repository. func Lookup(g graph.Store, seedID string) (d1, d2, d3 []Entry, hit bool) { + d1, d2, d3, hit, truncated := LookupContext(context.Background(), g, seedID) + // The legacy API has no channel for lower-bound status. Never call a + // bounded record an exact cache hit; status-aware impact consumers use + // LookupContext below. + return d1, d2, d3, hit && !truncated +} + +// LookupCached reads an already-published record without triggering BFS. +// Whole-repository ranking calls this form: lazily expanding reach for every +// candidate is an accidental eager index build and can issue hundreds of +// thousands of SQLite queries. Missing records fail closed to the caller's +// direct-fan-in fallback; bounded records retain their truncation signal. +func LookupCached(g graph.Store, seedID string) (d1, d2, d3 []Entry, hit, truncated bool) { if g == nil { - return nil, nil, nil, false + return nil, nil, nil, false, false + } + ctx, cancel := context.WithTimeout(context.Background(), lookupTimeout) + defer cancel() + releaseTopology, ok := beginTopologyReadContext(ctx) + if !ok { + return nil, nil, nil, false, true } + mu := g.ResolveMutex() + if !lockContext(ctx, mu) { + releaseTopology() + return nil, nil, nil, false, true + } + build := atomic.LoadUint64(&buildCounter) n := g.GetNode(seedID) - if n == nil { - return nil, nil, nil, false + if n != nil && ImpactSeedKind(n.Kind) { + d1, d2, d3, truncated, hit = readCached(n, build) } - if !ImpactSeedKind(n.Kind) { - return nil, nil, nil, false + stable := build == atomic.LoadUint64(&buildCounter) + mu.Unlock() + releaseTopology() + if !stable { + return nil, nil, nil, false, true } + return d1, d2, d3, hit, truncated +} - currentBuild := atomic.LoadUint64(&buildCounter) - // Fast path: existing stamp matches the current build generation. - if d1, d2, d3, ok := readCached(n, currentBuild); ok { - return d1, d2, d3, true +// LookupContext returns a complete reach record or a bounded lower-bound +// record. Expensive BFS work deliberately happens outside topology and +// resolver locks. Publication re-enters both locks, verifies the generation, +// and retries if a mutation crossed the optimistic compute window. This keeps +// synchronous edits from waiting behind breadth-first graph traversal. +type contextNodeGetter interface { + GetNodeContext(context.Context, string) (*graph.Node, error) +} + +func getNodeContext(ctx context.Context, g graph.Store, id string) (*graph.Node, error) { + if getter, ok := g.(contextNodeGetter); ok { + return getter.GetNodeContext(ctx, id) + } + if err := ctx.Err(); err != nil { + return nil, err } + return g.GetNode(id), nil +} - // Slow path: compute the tiers and cache them. Acquire the resolve - // mutex so the Meta writes don't race other graph-wide passes that - // already serialise on it (markTestSymbolsAndEmitEdges, clone - // detection, ResolveTemporalCalls). - mu := g.ResolveMutex() - mu.Lock() - defer mu.Unlock() +type contextNodesGetter interface { + GetNodesByIDsContext(context.Context, []string) (map[string]*graph.Node, error) +} + +func getNodesContext(ctx context.Context, g graph.Store, ids []string) (map[string]*graph.Node, error) { + if getter, ok := g.(contextNodesGetter); ok { + return getter.GetNodesByIDsContext(ctx, ids) + } + if err := ctx.Err(); err != nil { + return nil, err + } + return g.GetNodesByIDs(ids), nil +} - // Re-check after acquiring the lock: another goroutine may have - // computed and cached this seed while we were waiting. - if d1, d2, d3, ok := readCached(n, currentBuild); ok { - return d1, d2, d3, true +func LookupContext(parent context.Context, g graph.Store, seedID string) (d1, d2, d3 []Entry, hit, truncated bool) { + if g == nil { + return nil, nil, nil, false, false } + if parent == nil { + parent = context.Background() + } + ctx, cancel := context.WithTimeout(parent, lookupTimeout) + defer cancel() - tiers := compute(g, seedID) - if n.Meta == nil { - n.Meta = make(map[string]any, 10) - } - n.Meta[MetaReachBuild] = currentBuild - setOrDeleteStrings(n.Meta, MetaReachD1, tiers[0].IDs) - setOrDeleteStrings(n.Meta, MetaReachD2, tiers[1].IDs) - setOrDeleteStrings(n.Meta, MetaReachD3, tiers[2].IDs) - setOrDeleteFloats(n.Meta, MetaReachD1Conf, tiers[0].Conf) - setOrDeleteFloats(n.Meta, MetaReachD2Conf, tiers[1].Conf) - setOrDeleteFloats(n.Meta, MetaReachD3Conf, tiers[2].Conf) - setOrDeleteStrings(n.Meta, MetaReachD1Label, tiers[0].Labels) - setOrDeleteStrings(n.Meta, MetaReachD2Label, tiers[1].Labels) - setOrDeleteStrings(n.Meta, MetaReachD3Label, tiers[2].Labels) - - // Persist the freshly-stamped Meta through the store. On the - // in-memory backend n is the canonical node, so the mutations above - // already stuck — AddNode re-inserts the same pointer idempotently. - // On disk backends n is a per-call reconstruction returned - // by GetNode, so the in-place stamp would otherwise be discarded the - // moment this function returns: the lazy reach cache would never - // survive a single query, forcing a full recompute on every - // AnalyzeImpact / explain_change_impact / get_callers call. AddNode - // upserts the Meta column so the cache actually sticks. - g.AddNode(n) - - d1 = readTier(n.Meta, MetaReachD1, MetaReachD1Conf, MetaReachD1Label) - d2 = readTier(n.Meta, MetaReachD2, MetaReachD2Conf, MetaReachD2Label) - d3 = readTier(n.Meta, MetaReachD3, MetaReachD3Conf, MetaReachD3Label) - return d1, d2, d3, true + for { + releaseTopology, ok := beginTopologyReadContext(ctx) + if !ok { + return nil, nil, nil, false, true + } + mu := g.ResolveMutex() + if !lockContext(ctx, mu) { + releaseTopology() + return nil, nil, nil, false, true + } + currentBuild := atomic.LoadUint64(&buildCounter) + n, nodeErr := getNodeContext(ctx, g, seedID) + if nodeErr != nil { + mu.Unlock() + releaseTopology() + return nil, nil, nil, true, true + } + if n == nil || !ImpactSeedKind(n.Kind) { + mu.Unlock() + releaseTopology() + return nil, nil, nil, false, false + } + if d1, d2, d3, cachedTruncated, cached := readCached(n, currentBuild); cached { + stable := currentBuild == atomic.LoadUint64(&buildCounter) + mu.Unlock() + releaseTopology() + if stable { + return d1, d2, d3, true, cachedTruncated + } + continue + } + mu.Unlock() + releaseTopology() + + // The expensive portion is optimistic and lock-free with respect to + // indexing. Lazy lookups are deliberately read-only: publishing the + // result through graph.Store.AddNode turns an interactive safety check + // into an uncancellable SQLite write. Under concurrent indexing that + // write can wait on the store write mutex long after ctx expires while + // also retaining topology/resolve coordination, starving the daemon. + // + // BuildIndexCtx is the sole persistent reach-cache writer. A lazy miss + // returns its bounded lower-bound evidence directly; a concurrent graph + // generation change marks that evidence truncated rather than retrying + // or publishing a mixed snapshot. + tiers, traversalTruncated := compute(ctx, g, seedID) + if ctx.Err() != nil { + return entriesForTier(tiers[0]), entriesForTier(tiers[1]), entriesForTier(tiers[2]), true, true + } + if currentBuild != atomic.LoadUint64(&buildCounter) { + continue + } + return entriesForTier(tiers[0]), entriesForTier(tiers[1]), entriesForTier(tiers[2]), true, traversalTruncated + } +} + +func entriesForTier(t tier) []Entry { + if len(t.IDs) == 0 { + return nil + } + out := make([]Entry, len(t.IDs)) + for i, id := range t.IDs { + out[i].ID = id + if i < len(t.Conf) { + out[i].Conf = t.Conf[i] + } + if i < len(t.Labels) { + out[i].Label = t.Labels[i] + } + } + return out } // readCached reads the stamped reach tiers off n.Meta when the stamp -// matches currentBuild. Returns ok=false when the stamp is missing -// (never built), stale (graph has changed since), or has the wrong -// Go type (snapshot from an older format). -func readCached(n *graph.Node, currentBuild uint64) (d1, d2, d3 []Entry, ok bool) { +// matches currentBuild and the record carries a completion marker. +// Returns ok=false when either marker is missing (never built, legacy, +// or interrupted), stale (graph has changed since), or has the wrong +// Go type. +func readCached(n *graph.Node, currentBuild uint64) (d1, d2, d3 []Entry, truncated, ok bool) { if n.Meta == nil { - return nil, nil, nil, false + return nil, nil, nil, false, false } raw, present := n.Meta[MetaReachBuild] if !present { - return nil, nil, nil, false + return nil, nil, nil, false, false + } + stamped, valid := safeUint64(raw) + if !valid { + return nil, nil, nil, false, false + } + if stamped != currentBuild { + return nil, nil, nil, false, false + } + epoch, valid := n.Meta[MetaReachEpoch].(string) + if !valid || epoch == "" || epoch != reachProcessEpoch { + // A restart-local build number may equal a persisted record's number. + // Never accept that record unless this process published it. + return nil, nil, nil, false, false + } + complete, _ := n.Meta[MetaReachComplete].(bool) + if !complete { + // A generation stamp without an explicit completion marker is an + // interrupted/legacy publication. Treat it as a miss so Lookup + // recomputes instead of silently interpreting missing tiers as an + // intentionally empty blast radius. + return nil, nil, nil, false, false + } + if rawTruncated, present := n.Meta[MetaReachTruncated]; present { + var valid bool + truncated, valid = rawTruncated.(bool) + if !valid { + return nil, nil, nil, false, false + } + } + var tierValid bool + if d1, tierValid = readTier(n.Meta, MetaReachD1, MetaReachD1Conf, MetaReachD1Label); !tierValid { + return nil, nil, nil, false, false + } + if d2, tierValid = readTier(n.Meta, MetaReachD2, MetaReachD2Conf, MetaReachD2Label); !tierValid { + return nil, nil, nil, false, false + } + if d3, tierValid = readTier(n.Meta, MetaReachD3, MetaReachD3Conf, MetaReachD3Label); !tierValid { + return nil, nil, nil, false, false } - var stamped uint64 - switch v := raw.(type) { + seen := make(map[string]struct{}, len(d1)+len(d2)+len(d3)) + for _, entries := range [][]Entry{d1, d2, d3} { + for _, entry := range entries { + if entry.ID == "" { + return nil, nil, nil, false, false + } + if _, duplicate := seen[entry.ID]; duplicate { + return nil, nil, nil, false, false + } + seen[entry.ID] = struct{}{} + } + } + return d1, d2, d3, truncated, true +} + +func safeUint64(v any) (uint64, bool) { + switch n := v.(type) { case uint64: - stamped = v + return n, true + case uint: + return uint64(n), true case uint32: - stamped = uint64(v) + return uint64(n), true case int: - stamped = uint64(v) + if n >= 0 { + return uint64(n), true + } case int64: - stamped = uint64(v) - default: - return nil, nil, nil, false - } - if stamped != currentBuild { - return nil, nil, nil, false + if n >= 0 { + return uint64(n), true + } + case float64: + // float64(math.MaxUint64) rounds up to 2^64, so the upper bound + // must be strict or an out-of-range legacy JSON number can wrap. + if n >= 0 && n < math.Exp2(64) && math.Trunc(n) == n { + return uint64(n), true + } } - d1 = readTier(n.Meta, MetaReachD1, MetaReachD1Conf, MetaReachD1Label) - d2 = readTier(n.Meta, MetaReachD2, MetaReachD2Conf, MetaReachD2Label) - d3 = readTier(n.Meta, MetaReachD3, MetaReachD3Conf, MetaReachD3Label) - return d1, d2, d3, true + return 0, false } // InvalidateIndex advances the global build counter so every future @@ -500,13 +968,22 @@ func InvalidateIndex() { // confidence / label keys (or shorter slices) zero-fill so older // snapshots that lack the parallel data degrade gracefully — the // caller still sees the ID set, just with zero confidence. -func readTier(meta map[string]any, idsKey, confKey, labelKey string) []Entry { - ids, _ := meta[idsKey].([]string) - if len(ids) == 0 { - return nil +func readTier(meta map[string]any, idsKey, confKey, labelKey string) ([]Entry, bool) { + ids, idsPresent, valid := safeStringSlice(meta[idsKey]) + _, confPresent := meta[confKey] + _, labelsPresent := meta[labelKey] + if !idsPresent { + return nil, !confPresent && !labelsPresent + } + if !valid { + return nil, false + } + conf, _, confValid := safeFloatSlice(meta[confKey]) + labels, _, labelsValid := safeStringSlice(meta[labelKey]) + if (confPresent && (!confValid || len(conf) != len(ids))) || + (labelsPresent && (!labelsValid || len(labels) != len(ids))) { + return nil, false } - conf, _ := meta[confKey].([]float64) - labels, _ := meta[labelKey].([]string) out := make([]Entry, len(ids)) for i, id := range ids { out[i].ID = id @@ -517,7 +994,58 @@ func readTier(meta map[string]any, idsKey, confKey, labelKey string) []Entry { out[i].Label = labels[i] } } - return out + return out, true +} + +func safeStringSlice(v any) ([]string, bool, bool) { + if v == nil { + return nil, false, true + } + switch values := v.(type) { + case []string: + return values, true, true + case []any: + out := make([]string, len(values)) + for i, value := range values { + text, ok := value.(string) + if !ok { + return nil, true, false + } + out[i] = text + } + return out, true, true + default: + return nil, true, false + } +} + +func safeFloatSlice(v any) ([]float64, bool, bool) { + if v == nil { + return nil, false, true + } + switch values := v.(type) { + case []float64: + return values, true, true + case []any: + out := make([]float64, len(values)) + for i, value := range values { + switch number := value.(type) { + case float64: + out[i] = number + case int: + out[i] = float64(number) + case int64: + out[i] = float64(number) + case uint64: + out[i] = float64(number) + default: + return nil, true, false + } + } + return out, true, true + default: + return nil, true, false + } } // BuildCounter returns the current generation tag. Tests use it to diff --git a/internal/reach/reach_epoch_test.go b/internal/reach/reach_epoch_test.go new file mode 100644 index 000000000..876dda74c --- /dev/null +++ b/internal/reach/reach_epoch_test.go @@ -0,0 +1,45 @@ +package reach + +import "testing" + +func TestLookupRejectsPersistedRecordFromPreviousProcessEpoch(t *testing.T) { + g, ids := newCallChain(t, 3) + seed := g.GetNode(ids[2]) + if seed == nil { + t.Fatal("seed node missing") + } + stale := *seed + stale.Meta = map[string]any{ + MetaReachBuild: BuildCounter(), + MetaReachEpoch: "previous-daemon-process", + MetaReachComplete: true, + MetaReachTruncated: false, + } + g.AddNode(&stale) + + d1, d2, d3, hit, truncated := LookupContext(t.Context(), g, ids[2]) + if !hit || truncated { + t.Fatalf("lookup status hit=%v truncated=%v, want exact recomputation", hit, truncated) + } + if got, want := joinIDs(d1), ids[1]; got != want { + t.Fatalf("d1 = %q, want %q; stale empty record was accepted", got, want) + } + if got, want := joinIDs(d2), ids[0]; got != want { + t.Fatalf("d2 = %q, want %q", got, want) + } + if len(d3) != 0 { + t.Fatalf("d3 = %v, want empty", d3) + } +} + +func TestBuildIndexStampsCurrentProcessEpoch(t *testing.T) { + g, ids := newCallChain(t, 2) + BuildIndex(g) + seed := g.GetNode(ids[1]) + if seed == nil { + t.Fatal("seed node missing") + } + if got, _ := seed.Meta[MetaReachEpoch].(string); got != reachProcessEpoch { + t.Fatalf("reach epoch = %q, want current process epoch %q", got, reachProcessEpoch) + } +} diff --git a/internal/reach/reach_node_context_test.go b/internal/reach/reach_node_context_test.go new file mode 100644 index 000000000..fb491cb76 --- /dev/null +++ b/internal/reach/reach_node_context_test.go @@ -0,0 +1,104 @@ +package reach + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/zzet/gortex/internal/graph" +) + +type contextNodeStoreStub struct { + graph.Store + err error + called bool +} + +func (s *contextNodeStoreStub) GetNodeContext(context.Context, string) (*graph.Node, error) { + s.called = true + return nil, s.err +} + +func TestGetNodeContextUsesOptionalStoreMethod(t *testing.T) { + s := &contextNodeStoreStub{err: context.Canceled} + _, err := getNodeContext(context.Background(), s, "seed") + if !errors.Is(err, context.Canceled) { + t.Fatalf("getNodeContext error = %v, want context.Canceled", err) + } + if !s.called { + t.Fatal("optional GetNodeContext was not called") + } +} + +type legacyNodeStoreStub struct { + graph.Store + called bool +} + +func (s *legacyNodeStoreStub) GetNode(string) *graph.Node { + s.called = true + return nil +} + +func TestGetNodeContextChecksCancellationBeforeLegacyFallback(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + s := &legacyNodeStoreStub{} + + _, err := getNodeContext(ctx, s, "seed") + if !errors.Is(err, context.Canceled) { + t.Fatalf("getNodeContext error = %v, want context.Canceled", err) + } + if s.called { + t.Fatal("legacy GetNode called after cancellation") + } +} + +type contextNodesStoreStub struct { + graph.Store + err error + called bool +} + +func (s *contextNodesStoreStub) GetNodesByIDsContext(context.Context, []string) (map[string]*graph.Node, error) { + s.called = true + return nil, s.err +} + +func TestGetNodesContextUsesOptionalStoreMethod(t *testing.T) { + s := &contextNodesStoreStub{err: context.Canceled} + _, err := getNodesContext(context.Background(), s, []string{"seed"}) + if !errors.Is(err, context.Canceled) { + t.Fatalf("getNodesContext error = %v, want context.Canceled", err) + } + if !s.called { + t.Fatal("optional GetNodesByIDsContext was not called") + } +} + +type blockingContextNodesStore struct { + graph.Store +} + +func (s *blockingContextNodesStore) GetNodesByIDsContext(ctx context.Context, _ []string) (map[string]*graph.Node, error) { + <-ctx.Done() + return nil, ctx.Err() +} + +func TestComputeCancellationBoundsNodeHydration(t *testing.T) { + g, ids := newCallChain(t, 2) + store := &blockingContextNodesStore{Store: g} + ctx, cancel := context.WithTimeout(context.Background(), 25*time.Millisecond) + defer cancel() + + started := time.Now() + _, truncated := compute(ctx, store, ids[1]) + elapsed := time.Since(started) + if !truncated { + t.Fatal("compute must mark a cancelled node hydration as truncated") + } + if elapsed > 250*time.Millisecond { + t.Fatalf("compute returned after %s, want cancellation-bounded traversal", elapsed) + } +} diff --git a/internal/reach/reach_readonly_test.go b/internal/reach/reach_readonly_test.go new file mode 100644 index 000000000..50c6f8aa9 --- /dev/null +++ b/internal/reach/reach_readonly_test.go @@ -0,0 +1,80 @@ +package reach + +import ( + "context" + "reflect" + "testing" + "time" + + "github.com/zzet/gortex/internal/graph" +) + +type panicOnAddNodeStore struct { + graph.Store +} + +func (panicOnAddNodeStore) AddNode(*graph.Node) { + panic("LookupContext must not write reach metadata through AddNode") +} + +func TestLookupContextLazyMissIsReadOnly(t *testing.T) { + backing := graph.New() + backing.AddNode(&graph.Node{ID: "seed", Name: "seed", Kind: graph.KindFunction}) + store := panicOnAddNodeStore{Store: backing} + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + started := time.Now() + _, _, _, hit, truncated := LookupContext(ctx, store, "seed") + if elapsed := time.Since(started); elapsed > 500*time.Millisecond { + t.Fatalf("lazy read-only lookup took %s; want <= 500ms", elapsed) + } + if !hit { + t.Fatal("lazy lookup did not return a bounded result") + } + if truncated { + t.Fatal("uncontended lazy lookup unexpectedly truncated") + } +} + +func TestLookupContextExpiredContextIsBoundedAndConservative(t *testing.T) { + backing := graph.New() + backing.AddNode(&graph.Node{ID: "seed", Name: "seed", Kind: graph.KindFunction}) + store := panicOnAddNodeStore{Store: backing} + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + started := time.Now() + _, _, _, hit, truncated := LookupContext(ctx, store, "seed") + if elapsed := time.Since(started); elapsed > 100*time.Millisecond { + t.Fatalf("expired lookup took %s; want <= 100ms", elapsed) + } + if !hit { + t.Fatal("expired lookup must return a bounded conservative result") + } + if !truncated { + t.Fatal("expired lookup must report truncation") + } +} + +func TestLookupContextRepeatedLazyMissIsStableAndReadOnly(t *testing.T) { + backing, ids := newCallChain(t, 4) + store := panicOnAddNodeStore{Store: backing} + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + d1a, d2a, d3a, hit, truncated := LookupContext(ctx, store, ids[3]) + if !hit || truncated { + t.Fatalf("first lookup: hit=%v truncated=%v", hit, truncated) + } + d1b, d2b, d3b, hit, truncated := LookupContext(ctx, store, ids[3]) + if !hit || truncated { + t.Fatalf("second lookup: hit=%v truncated=%v", hit, truncated) + } + if !reflect.DeepEqual(d1a, d1b) || !reflect.DeepEqual(d2a, d2b) || !reflect.DeepEqual(d3a, d3b) { + t.Fatalf("repeated lazy lookup changed topology: first=(%v,%v,%v) second=(%v,%v,%v)", d1a, d2a, d3a, d1b, d2b, d3b) + } +} diff --git a/internal/reach/reach_sqlite_test.go b/internal/reach/reach_sqlite_test.go new file mode 100644 index 000000000..a788800e6 --- /dev/null +++ b/internal/reach/reach_sqlite_test.go @@ -0,0 +1,143 @@ +package reach + +import ( + "context" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/graph/store_sqlite" +) + +func TestLookupSQLiteRepeatedAndReloadedPreservesTopology(t *testing.T) { + path := filepath.Join(t.TempDir(), "reach.db") + s, err := store_sqlite.Open(path) + if err != nil { + t.Fatal(err) + } + nodes := []*graph.Node{ + {ID: "seed", Kind: graph.KindFunction, Name: "seed"}, + {ID: "direct", Kind: graph.KindFunction, Name: "direct"}, + {ID: "transitive", Kind: graph.KindFunction, Name: "transitive"}, + } + edges := []*graph.Edge{ + {From: "direct", To: "seed", Kind: graph.EdgeCalls, Confidence: 0.9}, + {From: "transitive", To: "direct", Kind: graph.EdgeCalls, Confidence: 0.8}, + } + s.AddBatch(nodes, edges) + + assertLookup := func(store graph.Store) { + t.Helper() + d1, d2, d3, hit, truncated := LookupContext(context.Background(), store, "seed") + if !hit || truncated { + t.Fatalf("lookup status hit=%v truncated=%v, want exact hit", hit, truncated) + } + if got := joinIDs(d1); got != "direct" { + t.Fatalf("d1 = %q, want direct", got) + } + if got := joinIDs(d2); got != "transitive" { + t.Fatalf("d2 = %q, want transitive", got) + } + if len(d3) != 0 { + t.Fatalf("d3 = %v, want empty", d3) + } + } + + assertLookup(s) // computes an exact, read-only result + assertLookup(s) // repeated lazy lookup remains exact without writing + if got := s.EdgeCount(); got != len(edges) { + t.Fatalf("edge count after repeated lookup = %d, want %d", got, len(edges)) + } + seed := s.GetNode("seed") + if seed.Meta != nil { + if _, ok := seed.Meta[MetaReachBuild]; ok { + t.Fatal("lazy lookup must not persist a reach build") + } + if _, ok := seed.Meta[MetaReachD1Conf]; ok { + t.Fatal("lazy lookup must not persist reach confidence") + } + } + + if err := s.Close(); err != nil { + t.Fatal(err) + } + s, err = store_sqlite.Open(path) + if err != nil { + t.Fatal(err) + } + defer s.Close() + assertLookup(s) // proves exact codec types survive a warm reload + if got := s.EdgeCount(); got != len(edges) { + t.Fatalf("edge count after reload = %d, want %d", got, len(edges)) + } +} + +func TestReadCachedValidatesLegacySlicesAtomically(t *testing.T) { + build := BuildCounter() + n := &graph.Node{Meta: map[string]any{ + MetaReachBuild: int64(build), + MetaReachEpoch: reachProcessEpoch, + MetaReachComplete: true, + MetaReachD1: []any{"caller"}, + MetaReachD1Conf: []any{0.75}, + MetaReachD1Label: []any{"INFERRED"}, + }} + d1, d2, d3, truncated, ok := readCached(n, build) + if !ok || truncated || len(d1) != 1 || d1[0].ID != "caller" || d1[0].Conf != 0.75 { + t.Fatalf("legacy cache decode = (%v,%v,%v truncated=%v ok=%v)", d1, d2, d3, truncated, ok) + } + + // A present-but-short parallel array is an interrupted record, not an + // intentionally empty tier. Reject the entire record atomically. + n.Meta[MetaReachD1Conf] = []any{} + if _, _, _, _, ok := readCached(n, build); ok { + t.Fatal("cache with mismatched parallel tier arrays was accepted") + } + n.Meta[MetaReachBuild] = int64(-1) + if _, _, _, _, ok := readCached(n, build); ok { + t.Fatal("negative legacy generation was accepted as uint64") + } +} + +type blockingBoundedStore struct { + graph.Store + started chan struct{} + once sync.Once +} + +func (s *blockingBoundedStore) GetInEdgesByNodeIDsContext(ctx context.Context, _ []string, _ int) (map[string][]*graph.Edge, bool, error) { + s.once.Do(func() { close(s.started) }) + <-ctx.Done() + return nil, true, ctx.Err() +} + +func TestLookupTraversalDoesNotStarveTopologyOrResolverLocks(t *testing.T) { + base := graph.New() + base.AddNode(&graph.Node{ID: "seed", Kind: graph.KindFunction, Name: "seed"}) + store := &blockingBoundedStore{Store: base, started: make(chan struct{})} + ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) + defer cancel() + done := make(chan struct{}) + go func() { + _, _, _, _, _ = LookupContext(ctx, store, "seed") + close(done) + }() + <-store.started + + // Synchronous IndexFile/edit work needs both of these gates. The lookup's + // deliberately blocked traversal must own neither one. + if !store.ResolveMutex().TryLock() { + t.Fatal("reach traversal held ResolveMutex and would starve an edit") + } + store.ResolveMutex().Unlock() + start := time.Now() + finishMutation := BeginTopologyMutation(store) + finishMutation(false) + if elapsed := time.Since(start); elapsed > 100*time.Millisecond { + t.Fatalf("topology mutation waited %s behind lock-free traversal", elapsed) + } + + <-done +} diff --git a/internal/reach/reach_test.go b/internal/reach/reach_test.go index 0c229e9aa..2ea4aa8c2 100644 --- a/internal/reach/reach_test.go +++ b/internal/reach/reach_test.go @@ -2,7 +2,9 @@ package reach import ( "strings" + "sync" "testing" + "time" "github.com/zzet/gortex/internal/graph" ) @@ -188,13 +190,192 @@ func TestLookup_LazyComputesOnFirstMiss(t *testing.T) { if len(d3) != 0 { t.Errorf("expected d3=[], got %#v", d3) } - // The lazy compute should have stamped the result for next time. + // Lazy lookup is intentionally read-only. Persisting this result would + // turn a safety check into an uncancellable store write; eager BuildIndex + // remains the only reach-cache publication path. n := g.GetNode(seed) - if n == nil || n.Meta == nil { - t.Fatalf("lazy Lookup should have stamped result on %s", seed) + if n == nil { + t.Fatalf("seed %s disappeared after lazy lookup", seed) } - if _, ok := n.Meta[MetaReachBuild]; !ok { - t.Errorf("lazy Lookup should have stamped MetaReachBuild on %s", seed) + if _, ok := n.Meta[MetaReachBuild]; ok { + t.Errorf("lazy Lookup must not stamp MetaReachBuild on %s", seed) + } +} + +// TestLookup_ConcurrentBuildNeverReturnsPartial exercises the real publication +// path while eager rebuilds and hot cache reads overlap. Every generation has +// identical graph topology, so a reader may observe either adjacent complete +// generation, but it must never observe an empty or truncated tier. +func TestLookup_ConcurrentBuildNeverReturnsPartial(t *testing.T) { + g, ids := newCallChain(t, 5) + seed := ids[4] + BuildIndex(g) + + const ( + builds = 40 + readers = 8 + reads = 400 + ) + start := make(chan struct{}) + errs := make(chan string, readers*reads) + var wg sync.WaitGroup + wg.Add(1 + readers) + go func() { + defer wg.Done() + <-start + for range builds { + BuildIndex(g) + } + }() + for range readers { + go func() { + defer wg.Done() + <-start + for range reads { + d1, d2, d3, hit := Lookup(g, seed) + if !hit || joinIDs(d1) != ids[3] || joinIDs(d2) != ids[2] || joinIDs(d3) != ids[1] { + errs <- "lookup observed an incomplete reach generation" + return + } + } + }() + } + close(start) + wg.Wait() + close(errs) + if err := <-errs; err != "" { + t.Fatal(err) + } +} + +// TestLookup_ConcurrentUnrelatedMetaMutation proves the cache fast path shares +// ResolveMutex with graph-wide passes that still update non-reach Node.Meta +// fields in place. The race detector used to report readCached racing the +// writer below even though both reach writers themselves were copy-on-write. +func TestLookup_ConcurrentUnrelatedMetaMutation(t *testing.T) { + g, ids := newCallChain(t, 5) + seed := ids[4] + BuildIndex(g) + + const iterations = 1000 + start := make(chan struct{}) + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + <-start + for i := range iterations { + mu := g.ResolveMutex() + mu.Lock() + n := g.GetNode(seed) + n.Meta["unrelated_test_generation"] = i + mu.Unlock() + } + }() + go func() { + defer wg.Done() + <-start + for range iterations { + d1, d2, d3, hit := Lookup(g, seed) + if !hit || joinIDs(d1) != ids[3] || joinIDs(d2) != ids[2] || joinIDs(d3) != ids[1] { + t.Errorf("lookup changed while unrelated metadata was updated") + return + } + } + }() + close(start) + wg.Wait() +} + +// TestTopologyMutationPublishesBeforeLookup ensures a cached zero-impact +// answer cannot escape while the watcher is replacing topology. Readers wait +// for the mutation's invalidation, then compute from the complete new graph. +func TestTopologyMutationPublishesBeforeLookup(t *testing.T) { + g := graph.New() + g.AddNode(&graph.Node{ID: "sink", Kind: graph.KindFunction, Name: "sink", FilePath: "sink.go"}) + if d1, _, _, hit := Lookup(g, "sink"); !hit || len(d1) != 0 { + t.Fatalf("fixture must begin with a cached empty reach record; hit=%v d1=%v", hit, d1) + } + + finishMutation := BeginTopologyMutation(g) + type lookupResult struct { + d1 []Entry + hit bool + } + resultCh := make(chan lookupResult, 1) + started := make(chan struct{}) + go func() { + close(started) + d1, _, _, hit := Lookup(g, "sink") + resultCh <- lookupResult{d1: d1, hit: hit} + }() + <-started + + select { + case result := <-resultCh: + t.Fatalf("lookup escaped an active topology mutation: %+v", result) + case <-time.After(10 * time.Millisecond): + } + + g.AddNode(&graph.Node{ID: "caller", Kind: graph.KindFunction, Name: "caller", FilePath: "caller.go"}) + g.AddEdge(&graph.Edge{From: "caller", To: "sink", Kind: graph.EdgeCalls, Confidence: 1}) + finishMutation(true) + + select { + case result := <-resultCh: + if !result.hit || len(result.d1) != 1 || result.d1[0].ID != "caller" { + t.Fatalf("lookup did not observe the fully published topology: hit=%v d1=%v", result.hit, result.d1) + } + case <-time.After(time.Second): + t.Fatal("lookup remained blocked after topology publication") + } +} + +type countingBatchStore struct { + graph.Store + mu sync.Mutex + batches int + maxRows int +} + +func (s *countingBatchStore) AddBatch(nodes []*graph.Node, edges []*graph.Edge) { + s.mu.Lock() + s.batches++ + if len(nodes) > s.maxRows { + s.maxRows = len(nodes) + } + s.mu.Unlock() + s.Store.AddBatch(nodes, edges) +} + +func (s *countingBatchStore) reset() { + s.mu.Lock() + s.batches = 0 + s.maxRows = 0 + s.mu.Unlock() +} + +func (s *countingBatchStore) counts() (batches, maxRows int) { + s.mu.Lock() + defer s.mu.Unlock() + return s.batches, s.maxRows +} + +// TestReachMaintenancePublishesBoundedBatches prevents eager maintenance from +// retaining a full-graph duplicate of Node + Meta records before publication. +func TestReachMaintenancePublishesBoundedBatches(t *testing.T) { + g, _ := newCallChain(t, reachPublishBatchSize+17) + store := &countingBatchStore{Store: g} + + BuildIndex(store) + if batches, maxRows := store.counts(); batches < 2 || maxRows > reachPublishBatchSize { + t.Fatalf("BuildIndex batches=%d max_rows=%d; want multiple batches capped at %d", batches, maxRows, reachPublishBatchSize) + } + + store.reset() + ClearIndex(store) + if batches, maxRows := store.counts(); batches < 2 || maxRows > reachPublishBatchSize { + t.Fatalf("ClearIndex batches=%d max_rows=%d; want multiple batches capped at %d", batches, maxRows, reachPublishBatchSize) } } @@ -228,7 +409,7 @@ func TestClearIndex_RemovesStampsAndBumpsCounter(t *testing.T) { if n.Meta == nil { continue } - for _, k := range []string{MetaReachD1, MetaReachD2, MetaReachD3, MetaReachBuild} { + for _, k := range []string{MetaReachD1, MetaReachD2, MetaReachD3, MetaReachBuild, MetaReachComplete} { if _, ok := n.Meta[k]; ok { t.Errorf("node %s still has key %q after ClearIndex", n.ID, k) } diff --git a/internal/resolver/cross_repo_incremental.go b/internal/resolver/cross_repo_incremental.go new file mode 100644 index 000000000..29fdc99eb --- /dev/null +++ b/internal/resolver/cross_repo_incremental.go @@ -0,0 +1,94 @@ +package resolver + +import ( + "strconv" + + "github.com/zzet/gortex/internal/graph" +) + +// DetectCrossRepoEdgesForFiles materializes the cross-repo layer only for base +// edges incident to nodes in the exact changed-file frontier. Inspecting both +// incoming and outgoing edges covers unchanged callers rebound to a changed +// target as well as new calls emitted by the changed source file. +func DetectCrossRepoEdgesForFiles(g graph.Store, filePaths []string) int { + if g == nil || len(filePaths) == 0 { + return 0 + } + baseKinds := make(map[graph.EdgeKind]bool) + for _, kind := range graph.BaseKindsForCrossRepo() { + baseKinds[kind] = true + } + if len(baseKinds) == 0 { + return 0 + } + + nodeCache := make(map[string]*graph.Node) + getNode := func(id string) *graph.Node { + if node, ok := nodeCache[id]; ok { + return node + } + node := g.GetNode(id) + nodeCache[id] = node + return node + } + seenEdges := make(map[string]bool) + emitted := 0 + visit := func(edge *graph.Edge) { + if edge == nil || !baseKinds[edge.Kind] { + return + } + key := string(edge.Kind) + "\x00" + edge.From + "\x00" + edge.To + "\x00" + edge.FilePath + "\x00" + strconv.Itoa(edge.Line) + if seenEdges[key] { + return + } + seenEdges[key] = true + from, to := getNode(edge.From), getNode(edge.To) + if from == nil || to == nil || from.RepoPrefix == "" || to.RepoPrefix == "" || from.RepoPrefix == to.RepoPrefix { + return + } + crossKind, ok := graph.CrossRepoKindFor(edge.Kind) + if !ok { + return + } + edge.CrossRepo = true + g.AddEdge(&graph.Edge{ + From: edge.From, + To: edge.To, + Kind: crossKind, + FilePath: edge.FilePath, + Line: edge.Line, + Confidence: edge.Confidence, + ConfidenceLabel: edge.ConfidenceLabel, + Origin: edge.Origin, + CrossRepo: true, + Meta: map[string]any{ + "base_kind": string(edge.Kind), + "source_repo": from.RepoPrefix, + "target_repo": to.RepoPrefix, + }, + }) + emitted++ + } + + seenNodes := make(map[string]bool) + seenFiles := make(map[string]bool) + for _, filePath := range filePaths { + if filePath == "" || seenFiles[filePath] { + continue + } + seenFiles[filePath] = true + for _, node := range g.GetFileNodes(filePath) { + if node == nil || seenNodes[node.ID] { + continue + } + seenNodes[node.ID] = true + for _, edge := range g.GetOutEdges(node.ID) { + visit(edge) + } + for _, edge := range g.GetInEdges(node.ID) { + visit(edge) + } + } + } + return emitted +} diff --git a/internal/resolver/cross_repo_incremental_test.go b/internal/resolver/cross_repo_incremental_test.go new file mode 100644 index 000000000..8f6f79e00 --- /dev/null +++ b/internal/resolver/cross_repo_incremental_test.go @@ -0,0 +1,47 @@ +package resolver + +import ( + "testing" + + "github.com/zzet/gortex/internal/graph" +) + +func TestDetectCrossRepoEdgesForFilesUsesExactIncidentFrontier(t *testing.T) { + g := graph.New() + g.AddBatch([]*graph.Node{ + {ID: "a/a.go", Kind: graph.KindFile, Name: "a.go", FilePath: "a/a.go", RepoPrefix: "a"}, + {ID: "a/a.go::Call", Kind: graph.KindFunction, Name: "Call", FilePath: "a/a.go", RepoPrefix: "a"}, + {ID: "b/b.go", Kind: graph.KindFile, Name: "b.go", FilePath: "b/b.go", RepoPrefix: "b"}, + {ID: "b/b.go::Serve", Kind: graph.KindFunction, Name: "Serve", FilePath: "b/b.go", RepoPrefix: "b"}, + {ID: "c/c.go", Kind: graph.KindFile, Name: "c.go", FilePath: "c/c.go", RepoPrefix: "c"}, + {ID: "c/c.go::Call", Kind: graph.KindFunction, Name: "Call", FilePath: "c/c.go", RepoPrefix: "c"}, + {ID: "d/d.go", Kind: graph.KindFile, Name: "d.go", FilePath: "d/d.go", RepoPrefix: "d"}, + {ID: "d/d.go::Serve", Kind: graph.KindFunction, Name: "Serve", FilePath: "d/d.go", RepoPrefix: "d"}, + }, []*graph.Edge{ + {From: "a/a.go::Call", To: "b/b.go::Serve", Kind: graph.EdgeCalls, FilePath: "a/a.go", Line: 3}, + {From: "c/c.go::Call", To: "d/d.go::Serve", Kind: graph.EdgeCalls, FilePath: "c/c.go", Line: 4}, + }) + + if got := DetectCrossRepoEdgesForFiles(g, []string{"b/b.go"}); got != 1 { + t.Fatalf("emitted = %d, want incident edge only", got) + } + crossKind, ok := graph.CrossRepoKindFor(graph.EdgeCalls) + if !ok { + t.Fatal("calls has no cross-repo kind") + } + if !hasEdgeKindTo(g.GetOutEdges("a/a.go::Call"), crossKind, "b/b.go::Serve") { + t.Fatal("incoming edge to changed target was not materialized") + } + if hasEdgeKindTo(g.GetOutEdges("c/c.go::Call"), crossKind, "d/d.go::Serve") { + t.Fatal("unrelated cross-repo edge leaked outside exact frontier") + } +} + +func hasEdgeKindTo(edges []*graph.Edge, kind graph.EdgeKind, target string) bool { + for _, edge := range edges { + if edge != nil && edge.Kind == kind && edge.To == target { + return true + } + } + return false +} diff --git a/internal/resolver/framework_synth.go b/internal/resolver/framework_synth.go index 02f674ac0..5ad33aa07 100644 --- a/internal/resolver/framework_synth.go +++ b/internal/resolver/framework_synth.go @@ -1,6 +1,7 @@ package resolver import ( + "strings" "time" "github.com/zzet/gortex/internal/graph" @@ -200,6 +201,283 @@ func (s synthFunc) synthesizeScoped(g graph.Store, scope map[string]bool) int { return s.fn(g) } +// frameworkSynthLanguageFamilies is deliberately conservative. An absent +// entry means that the pass is generic, spans too many runtimes to bound +// safely, or has not yet been audited, and therefore always runs. A mapped +// pass may be skipped only when its candidate domain contains none of the +// listed language families. +var frameworkSynthLanguageFamilies = map[string][]string{ + SynthSwiftObjC: {"apple"}, + SynthReactNative: {"web", "apple", "jvm"}, + SynthReactNativePair: {"apple", "jvm"}, + SynthClosureCollection: {"apple"}, + SynthKMPExpectActual: {"jvm"}, + SynthExpoModules: {"web", "apple", "jvm"}, + SynthFabric: {"web", "apple", "jvm"}, + SynthMyBatis: {"jvm"}, + SynthSQLCallsite: {"sql"}, + SynthStoreFactory: {"web"}, + SynthReduxThunk: {"web"}, + SynthNgRxEffect: {"web"}, + SynthObjectRegistry: {"web"}, + SynthRTKQuery: {"web"}, + SynthVuexDispatch: {"web"}, + SynthCelery: {"python"}, + SynthSpringEvent: {"jvm"}, + SynthMediatR: {"dotnet"}, + SynthCSharpIfaceDispatch: {"dotnet"}, + SynthSidekiq: {"ruby"}, + SynthLaravelEvent: {"php"}, + SynthFnPointerDispatch: {"c"}, + SynthMacroExpansion: {"c"}, + SynthGinMiddleware: {"go"}, + SynthExpressResolve: {"web"}, + SynthReactResolve: {"web"}, + SynthFastAPIResolve: {"python"}, + SynthRailsResolve: {"ruby"}, + SynthSwiftUIResolve: {"apple"}, + SynthUIKitResolve: {"apple"}, + SynthVaporResolve: {"apple"}, + SynthGoFrameRoute: {"go"}, + SynthSvelteKitLoad: {"web"}, + SynthRustScope: {"rust"}, + SynthPascalFormName: {"pascal"}, +} + +type frameworkCandidateSummary struct { + all map[string]int + scoped map[string]int + allMarkers map[string]int + scopedMarkers map[string]int +} + +// summarizeFrameworkCandidates makes one metadata-free node pass for the +// entire synthesizer run. Both the whole-graph and changed-repo views are +// populated in that same pass so a synthesizer that falls back to its global +// implementation is never gated by the narrower repo scope. +func summarizeFrameworkCandidates(g graph.Store, scope map[string]bool) frameworkCandidateSummary { + summary := frameworkCandidateSummary{ + all: map[string]int{}, + scoped: map[string]int{}, + allMarkers: map[string]int{}, + scopedMarkers: map[string]int{}, + } + var observerRoles map[string]uint8 + for _, n := range graph.AllNodesLight(g) { + if n == nil { + continue + } + family := frameworkLanguageFamily(n.Language) + if role := recordFrameworkNodeCandidates(summary.allMarkers, n, family); role != 0 && n.ID != "" { + if observerRoles == nil { + observerRoles = map[string]uint8{} + } + observerRoles[n.ID] = role + } + if scope != nil && scope[n.RepoPrefix] { + recordFrameworkNodeCandidates(summary.scopedMarkers, n, family) + } + if family == "" { + continue + } + summary.all[family]++ + if scope != nil && scope[n.RepoPrefix] { + summary.scoped[family]++ + } + } + // Observer synthesis needs registrar and dispatcher methods accessing the + // same field. When both name vocabularies exist, one metadata-free scan of + // accesses_field edges proves whether such a channel can exist. This is + // stricter than two unrelated name hits but retains every candidate the + // synthesizer itself can consume. + if summary.allMarkers[frameworkMarkerObserverRegistrar] > 0 && + summary.allMarkers[frameworkMarkerObserverDispatcher] > 0 { + fieldRoles := map[string]uint8{} + for _, e := range graph.EdgesForKindsLight(g, graph.EdgeAccessesField) { + if e == nil || e.From == "" || e.To == "" { + continue + } + role := observerRoles[e.From] + if role == 0 { + continue + } + fieldRoles[e.To] |= role + if fieldRoles[e.To] == (frameworkObserverRegistrarRole | frameworkObserverDispatcherRole) { + summary.allMarkers[SynthObserverChannel]++ + break + } + } + } + return summary +} + +const ( + frameworkMarkerObserverRegistrar = "observer-channel:registrar" + frameworkMarkerObserverDispatcher = "observer-channel:dispatcher" + frameworkMarkerSwift = "swift-objc-bridge:swift" + frameworkMarkerObjC = "swift-objc-bridge:objc" + + frameworkObserverRegistrarRole uint8 = 1 + frameworkObserverDispatcherRole uint8 = 2 +) + +// frameworkSynthNodePreflights names passes with necessary candidate shapes +// visible in the light node projection. Every listed marker is required: a +// missing marker proves the pass cannot produce an edge and avoids its full +// graph scans. These are deliberately one-way gates; a marker hit only keeps +// the pass enabled and does not claim that an edge will be produced. +var frameworkSynthNodePreflights = map[string][]string{ + SynthEventChannel: {SynthEventChannel}, + SynthSwiftObjC: {frameworkMarkerSwift, frameworkMarkerObjC}, + SynthObserverChannel: {SynthObserverChannel}, + SynthReactSetState: {SynthReactSetState}, + SynthFlutterSetState: {SynthFlutterSetState}, + SynthMyBatis: {SynthMyBatis}, + SynthSidekiq: {SynthSidekiq}, + SynthLaravelEvent: {SynthLaravelEvent}, + SynthSwiftUIResolve: {SynthSwiftUIResolve}, + SynthUIKitResolve: {SynthUIKitResolve}, + SynthVaporResolve: {SynthVaporResolve}, +} + +func recordFrameworkNodeCandidates(markers map[string]int, n *graph.Node, family string) uint8 { + if isPubsubEventNode(n.ID) || isEmitterEventNode(n.ID) { + markers[SynthEventChannel]++ + } + + language := strings.ToLower(strings.TrimSpace(n.Language)) + switch language { + case "swift": + markers[frameworkMarkerSwift]++ + case "objc", "objective-c", "objectivec": + markers[frameworkMarkerObjC]++ + case "mybatis": + if n.Kind == graph.KindMethod { + markers[SynthMyBatis]++ + } + case "ruby": + if (n.Kind == graph.KindMethod || n.Kind == graph.KindFunction) && n.Name == "perform" { + markers[SynthSidekiq]++ + } + case "php": + if (n.Kind == graph.KindMethod || n.Kind == graph.KindFunction) && n.Name == "handle" { + markers[SynthLaravelEvent]++ + } + } + + observerRole := uint8(0) + if (n.Kind == graph.KindMethod || n.Kind == graph.KindFunction) && n.Name != "" { + if observerRegistrarRe.MatchString(n.Name) { + markers[frameworkMarkerObserverRegistrar]++ + observerRole |= frameworkObserverRegistrarRole + } + if observerDispatcherRe.MatchString(n.Name) { + markers[frameworkMarkerObserverDispatcher]++ + observerRole |= frameworkObserverDispatcherRole + } + } + if n.Kind == graph.KindMethod { + switch n.Name { + case "render": + markers[SynthReactSetState]++ + case "build": + markers[SynthFlutterSetState]++ + } + } + if family != "apple" { + return observerRole + } + name := n.Name + path := strings.ToLower(strings.ReplaceAll(n.FilePath, "\\", "/")) + if strings.HasSuffix(name, "ViewModel") || strings.HasSuffix(name, "View") || + strings.HasSuffix(name, "Store") || strings.HasSuffix(name, "Manager") || + strings.Contains(path, "/models/") || strings.Contains(path, "/model/") { + markers[SynthSwiftUIResolve]++ + } + if strings.HasSuffix(name, "ViewController") || strings.HasSuffix(name, "Cell") || + strings.HasSuffix(name, "Delegate") || strings.HasSuffix(name, "DataSource") { + markers[SynthUIKitResolve]++ + } + if (strings.HasSuffix(name, "Controller") && !strings.HasSuffix(name, "ViewController")) || + strings.HasSuffix(name, "Middleware") || strings.Contains(path, "/models/") || + strings.Contains(path, "/model/") { + markers[SynthVaporResolve]++ + } + return observerRole +} + +func frameworkLanguageFamily(language string) string { + language = strings.ToLower(strings.TrimSpace(language)) + if family := languageFamily(language); family != "" { + return family + } + switch language { + case "go", "golang": + return "go" + case "rust": + return "rust" + case "python", "py": + return "python" + case "ruby": + return "ruby" + case "php": + return "php" + case "dart": + return "dart" + case "sql": + return "sql" + case "pascal", "object-pascal", "object_pascal": + return "pascal" + case "vue", "svelte", "astro": + return "web" + case "objective-c++", "objc++", "objective-cpp": + return "apple" + default: + return "" + } +} + +func frameworkSynthUsesScopedCandidates(s FrameworkSynthesizer, scope map[string]bool) bool { + if scope == nil { + return false + } + // synthFunc implements scopedSynthesizer even when scopedFn is nil so it + // can transparently fall back to fn. Inspect the adapter directly to avoid + // mistaking that global fallback for a scoped pass. + if sf, ok := s.(synthFunc); ok { + return sf.scopedFn != nil + } + _, ok := s.(scopedSynthesizer) + return ok +} + +func shouldRunFrameworkSynthesizer(s FrameworkSynthesizer, scope map[string]bool, summary frameworkCandidateSummary) bool { + present := summary.all + markers := summary.allMarkers + if frameworkSynthUsesScopedCandidates(s, scope) { + present = summary.scoped + markers = summary.scopedMarkers + } + if families := frameworkSynthLanguageFamilies[s.Name()]; len(families) > 0 { + found := false + for _, family := range families { + if present[family] > 0 { + found = true + break + } + } + if !found { + return false + } + } + for _, marker := range frameworkSynthNodePreflights[s.Name()] { + if markers[marker] == 0 { + return false + } + } + return true +} + // defaultFrameworkSynthesizers returns the registered framework // synthesizers in run order. Order is load-bearing: every synthesizer // here runs after InferImplements/InferOverrides (some depend on the @@ -384,22 +662,26 @@ func RunFrameworkSynthesizers(g graph.Store) FrameworkSynthReport { // RunFrameworkSynthesizersScoped is RunFrameworkSynthesizers with an armed // changed-repo scope: each synthesizer that implements scopedSynthesizer // narrows its candidate scan to those repos, the rest run whole-graph. A nil -// scope runs every pass whole-graph, so the fresh-index / single-repo path is -// byte-identical to the pre-scoping behaviour. The claiming-resolver, family- -// gate and receiver-gate tail passes always run whole-graph — they reconcile +// scope uses the whole-graph candidate census; language-specific passes proven +// irrelevant are skipped, while generic or unaudited passes always run. The +// claiming-resolver, family-gate and receiver-gate tail passes always run +// whole-graph — they reconcile // the settled cross-repo call graph, not a per-repo candidate set. func RunFrameworkSynthesizersScoped(g graph.Store, scope map[string]bool) FrameworkSynthReport { rep := FrameworkSynthReport{} if g == nil { return rep } + candidates := summarizeFrameworkCandidates(g, scope) for _, s := range defaultFrameworkSynthesizers() { start := time.Now() var n int - if ss, ok := s.(scopedSynthesizer); ok { - n = ss.synthesizeScoped(g, scope) - } else { - n = s.Synthesize(g) + if shouldRunFrameworkSynthesizer(s, scope, candidates) { + if ss, ok := s.(scopedSynthesizer); ok { + n = ss.synthesizeScoped(g, scope) + } else { + n = s.Synthesize(g) + } } rep.Per = append(rep.Per, SynthCount{Name: s.Name(), Edges: n, Millis: time.Since(start).Milliseconds()}) rep.Total += n diff --git a/internal/resolver/framework_synth_preflight_test.go b/internal/resolver/framework_synth_preflight_test.go new file mode 100644 index 000000000..7accd6d61 --- /dev/null +++ b/internal/resolver/framework_synth_preflight_test.go @@ -0,0 +1,394 @@ +package resolver + +import ( + "fmt" + "testing" + + "github.com/zzet/gortex/internal/graph" +) + +type countingFrameworkLightStore struct { + graph.Store + nodes []*graph.Node + edges []*graph.Edge + nodeCalls int + edgeCalls int +} + +func (s *countingFrameworkLightStore) AllNodesLight() []*graph.Node { + s.nodeCalls++ + return s.nodes +} + +func (s *countingFrameworkLightStore) AllEdgesLight(...graph.EdgeKind) []*graph.Edge { + s.edgeCalls++ + return s.edges +} + +func TestFrameworkLanguageFamily(t *testing.T) { + t.Parallel() + tests := []struct { + language string + want string + }{ + {language: "Go", want: "go"}, + {language: "golang", want: "go"}, + {language: "TypeScript", want: "web"}, + {language: "Vue", want: "web"}, + {language: "Kotlin", want: "jvm"}, + {language: "Objective-C++", want: "apple"}, + {language: "C++", want: "c"}, + {language: "C#", want: "dotnet"}, + {language: "object-pascal", want: "pascal"}, + {language: "SQL", want: "sql"}, + {language: "unknown", want: ""}, + {language: "", want: ""}, + } + for _, tt := range tests { + t.Run(tt.language, func(t *testing.T) { + t.Parallel() + if got := frameworkLanguageFamily(tt.language); got != tt.want { + t.Fatalf("frameworkLanguageFamily(%q) = %q, want %q", tt.language, got, tt.want) + } + }) + } +} + +func TestFrameworkSynthLanguageFamiliesAreConservativeAndRegistered(t *testing.T) { + t.Parallel() + registered := map[string]bool{} + for _, synth := range defaultFrameworkSynthesizers() { + if registered[synth.Name()] { + t.Fatalf("duplicate registered synthesizer %q", synth.Name()) + } + registered[synth.Name()] = true + } + for name, families := range frameworkSynthLanguageFamilies { + if !registered[name] { + t.Errorf("language gate names unregistered synthesizer %q", name) + } + if len(families) == 0 { + t.Errorf("language gate for %q has no families", name) + } + seen := map[string]bool{} + for _, family := range families { + if family == "" || seen[family] { + t.Errorf("language gate for %q has invalid or duplicate family %q", name, family) + } + seen[family] = true + } + } + for name := range frameworkSynthNodePreflights { + if !registered[name] { + t.Errorf("node preflight names unregistered synthesizer %q", name) + } + } + + // These passes intentionally remain language-ungated. Their candidates are + // generic, span many runtime languages, or come from edge metadata rather + // than a bounded language frontend. Some have independent node preflights. + ambiguous := []string{ + SynthGRPCStub, + SynthTemporalStub, + SynthEventChannel, + SynthObserverChannel, + SynthReactSetState, + SynthFlutterSetState, + SynthFactoryChain, + SynthFnValue, + SynthValueRefName, + } + for _, name := range ambiguous { + if _, gated := frameworkSynthLanguageFamilies[name]; gated { + t.Errorf("generic/ambiguous synthesizer %q must not be language-gated", name) + } + } +} + +func TestFrameworkSynthPreflightPreservesScopedFallbackSemantics(t *testing.T) { + t.Parallel() + nodes := []*graph.Node{ + {ID: "go-a", Kind: graph.KindFunction, Language: "go", RepoPrefix: "a"}, + {ID: "web-b", Kind: graph.KindMethod, Name: "render", Language: "typescript", RepoPrefix: "b"}, + } + store := &countingFrameworkLightStore{Store: graph.New(), nodes: nodes} + scope := map[string]bool{"a": true} + summary := summarizeFrameworkCandidates(store, scope) + if store.nodeCalls != 1 { + t.Fatalf("light-node scans = %d, want 1", store.nodeCalls) + } + + tests := []struct { + name string + synth FrameworkSynthesizer + want bool + }{ + { + name: "mapped whole-graph fallback sees language outside scope", + synth: synthFunc{name: SynthStoreFactory, fn: func(graph.Store) int { return 0 }}, + want: true, + }, + { + name: "mapped scoped pass ignores language outside scope", + synth: synthFunc{ + name: SynthStoreFactory, + fn: func(graph.Store) int { return 0 }, + scopedFn: func(graph.Store, map[string]bool) int { return 0 }, + }, + want: false, + }, + { + name: "mapped family present in scope", + synth: synthFunc{name: SynthGinMiddleware, fn: func(graph.Store) int { return 0 }, scopedFn: func(graph.Store, map[string]bool) int { return 0 }}, + want: true, + }, + { + name: "unmapped generic pass always runs", + synth: synthFunc{name: SynthTemporalStub, fn: func(graph.Store) int { return 0 }, scopedFn: func(graph.Store, map[string]bool) int { return 0 }}, + want: true, + }, + { + name: "node candidate keeps whole-graph fallback enabled", + synth: synthFunc{name: SynthReactSetState, fn: func(graph.Store) int { return 0 }}, + want: true, + }, + { + name: "node candidate outside scope does not enable scoped pass", + synth: synthFunc{ + name: SynthReactSetState, + fn: func(graph.Store) int { return 0 }, + scopedFn: func(graph.Store, map[string]bool) int { return 0 }, + }, + want: false, + }, + { + name: "missing required node candidate skips generic pass", + synth: synthFunc{name: SynthFlutterSetState, fn: func(graph.Store) int { return 0 }}, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := shouldRunFrameworkSynthesizer(tt.synth, scope, summary); got != tt.want { + t.Fatalf("shouldRunFrameworkSynthesizer(%q) = %v, want %v", tt.synth.Name(), got, tt.want) + } + }) + } +} + +func TestRecordFrameworkNodeCandidates(t *testing.T) { + t.Parallel() + tests := []struct { + name string + node *graph.Node + family string + want string + }{ + {name: "generic render method", node: &graph.Node{Kind: graph.KindMethod, Name: "render"}, want: SynthReactSetState}, + {name: "generic build method", node: &graph.Node{Kind: graph.KindMethod, Name: "build"}, want: SynthFlutterSetState}, + {name: "swiftui suffix", node: &graph.Node{Kind: graph.KindType, Name: "ProfileView"}, family: "apple", want: SynthSwiftUIResolve}, + {name: "uikit suffix", node: &graph.Node{Kind: graph.KindType, Name: "ProfileViewController"}, family: "apple", want: SynthUIKitResolve}, + {name: "vapor suffix", node: &graph.Node{Kind: graph.KindType, Name: "UserController"}, family: "apple", want: SynthVaporResolve}, + {name: "swift model directory", node: &graph.Node{Kind: graph.KindType, Name: "User", FilePath: "Sources/App/Models/User.swift"}, family: "apple", want: SynthSwiftUIResolve}, + {name: "event topic", node: &graph.Node{ID: "event::pubsub::in-process::saved", Kind: graph.KindEvent}, want: SynthEventChannel}, + {name: "observer registrar", node: &graph.Node{Kind: graph.KindMethod, Name: "addListener"}, want: frameworkMarkerObserverRegistrar}, + {name: "observer dispatcher", node: &graph.Node{Kind: graph.KindFunction, Name: "notifyObservers"}, want: frameworkMarkerObserverDispatcher}, + {name: "mybatis statement", node: &graph.Node{Kind: graph.KindMethod, Name: "findUser", Language: "mybatis"}, want: SynthMyBatis}, + {name: "sidekiq worker", node: &graph.Node{Kind: graph.KindMethod, Name: "perform", Language: "ruby"}, want: SynthSidekiq}, + {name: "laravel listener", node: &graph.Node{Kind: graph.KindMethod, Name: "handle", Language: "php"}, want: SynthLaravelEvent}, + {name: "swift bridge side", node: &graph.Node{Kind: graph.KindMethod, Name: "save", Language: "swift"}, want: frameworkMarkerSwift}, + {name: "objc bridge side", node: &graph.Node{Kind: graph.KindMethod, Name: "save:", Language: "objc"}, want: frameworkMarkerObjC}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + markers := map[string]int{} + recordFrameworkNodeCandidates(markers, tt.node, tt.family) + if markers[tt.want] == 0 { + t.Fatalf("candidate marker %q missing: %#v", tt.want, markers) + } + }) + } +} + +func TestFrameworkSynthPreflightRequiresEveryNecessaryMarker(t *testing.T) { + t.Parallel() + tests := []struct { + name string + synth string + nodes []*graph.Node + edges []*graph.Edge + want bool + }{ + { + name: "observer registrar alone is insufficient", + synth: SynthObserverChannel, + nodes: []*graph.Node{{Kind: graph.KindMethod, Name: "addListener"}}, + want: false, + }, + { + name: "observer registrar and dispatcher", + synth: SynthObserverChannel, + nodes: []*graph.Node{ + {ID: "register", Kind: graph.KindMethod, Name: "addListener"}, + {ID: "dispatch", Kind: graph.KindMethod, Name: "notifyObservers"}, + }, + edges: []*graph.Edge{ + {From: "register", To: "listeners", Kind: graph.EdgeAccessesField}, + {From: "dispatch", To: "listeners", Kind: graph.EdgeAccessesField}, + }, + want: true, + }, + { + name: "observer methods on different fields are insufficient", + synth: SynthObserverChannel, + nodes: []*graph.Node{ + {ID: "register", Kind: graph.KindMethod, Name: "addListener"}, + {ID: "dispatch", Kind: graph.KindMethod, Name: "notifyObservers"}, + }, + edges: []*graph.Edge{ + {From: "register", To: "listeners-a", Kind: graph.EdgeAccessesField}, + {From: "dispatch", To: "listeners-b", Kind: graph.EdgeAccessesField}, + }, + want: false, + }, + { + name: "event topic enables event channel", + synth: SynthEventChannel, + nodes: []*graph.Node{{ID: "event::emitter::bus::saved", Kind: graph.KindEvent}}, + want: true, + }, + { + name: "swift alone is insufficient for objc bridge", + synth: SynthSwiftObjC, + nodes: []*graph.Node{{Kind: graph.KindMethod, Language: "swift"}}, + want: false, + }, + { + name: "swift and objc enable bridge", + synth: SynthSwiftObjC, + nodes: []*graph.Node{ + {Kind: graph.KindMethod, Language: "swift"}, + {Kind: graph.KindMethod, Language: "objc"}, + }, + want: true, + }, + { + name: "mybatis file without statement is insufficient", + synth: SynthMyBatis, + nodes: []*graph.Node{{Kind: graph.KindFile, Language: "mybatis"}, {Kind: graph.KindMethod, Language: "java"}}, + want: false, + }, + { + name: "mybatis statement and java domain enable mapper join", + synth: SynthMyBatis, + nodes: []*graph.Node{{Kind: graph.KindMethod, Language: "mybatis"}, {Kind: graph.KindMethod, Language: "java"}}, + want: true, + }, + { + name: "sidekiq perform enables dispatch", + synth: SynthSidekiq, + nodes: []*graph.Node{{Kind: graph.KindMethod, Name: "perform", Language: "ruby"}}, + want: true, + }, + { + name: "laravel handle enables event dispatch", + synth: SynthLaravelEvent, + nodes: []*graph.Node{{Kind: graph.KindMethod, Name: "handle", Language: "php"}}, + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + store := &countingFrameworkLightStore{Store: graph.New(), nodes: tt.nodes, edges: tt.edges} + summary := summarizeFrameworkCandidates(store, nil) + synth := synthFunc{name: tt.synth, fn: func(graph.Store) int { return 0 }} + if got := shouldRunFrameworkSynthesizer(synth, nil, summary); got != tt.want { + t.Fatalf("shouldRunFrameworkSynthesizer(%q) = %v, want %v; markers=%v", tt.synth, got, tt.want, summary.allMarkers) + } + }) + } +} + +func TestRunFrameworkSynthesizersUsesOneLightNodeScan(t *testing.T) { + base := graph.New() + base.AddNode(&graph.Node{ID: "go-node", Kind: graph.KindFunction, Name: "f", Language: "go", RepoPrefix: "repo"}) + store := &countingFrameworkLightStore{ + Store: base, + nodes: []*graph.Node{ + {ID: "go-node", Kind: graph.KindFunction, Name: "f", Language: "go", RepoPrefix: "repo"}, + {ID: "register", Kind: graph.KindMethod, Name: "addListener", RepoPrefix: "repo"}, + {ID: "dispatch", Kind: graph.KindMethod, Name: "notifyObservers", RepoPrefix: "repo"}, + }, + edges: []*graph.Edge{ + {From: "register", To: "listeners", Kind: graph.EdgeAccessesField}, + {From: "dispatch", To: "listeners", Kind: graph.EdgeAccessesField}, + }, + } + report := RunFrameworkSynthesizersScoped(store, map[string]bool{"repo": true}) + if store.nodeCalls != 1 { + t.Fatalf("light-node scans = %d, want 1", store.nodeCalls) + } + if store.edgeCalls != 1 { + t.Fatalf("light-edge scans = %d, want 1", store.edgeCalls) + } + wantRows := len(defaultFrameworkSynthesizers()) + len(defaultClaimingResolvers()) + if len(report.Per) != wantRows { + t.Fatalf("report rows = %d, want %d", len(report.Per), wantRows) + } +} + +func BenchmarkSummarizeFrameworkCandidates100K(b *testing.B) { + nodes := make([]*graph.Node, 100_000) + languages := []string{"go", "typescript", "rust", "python", "java"} + for i := range nodes { + nodes[i] = &graph.Node{ + ID: fmt.Sprintf("n-%d", i), + Kind: graph.KindFunction, + Language: languages[i%len(languages)], + RepoPrefix: fmt.Sprintf("repo-%d", i%20), + } + } + store := &countingFrameworkLightStore{Store: graph.New(), nodes: nodes} + scope := map[string]bool{"repo-1": true, "repo-3": true} + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + summary := summarizeFrameworkCandidates(store, scope) + if len(summary.all) == 0 { + b.Fatal("empty summary") + } + } +} + +func BenchmarkSummarizeFrameworkObserverCandidates100K(b *testing.B) { + nodes := make([]*graph.Node, 100_000) + edges := make([]*graph.Edge, 100_000) + for i := range nodes { + nodes[i] = &graph.Node{ + ID: fmt.Sprintf("n-%d", i), + Kind: graph.KindFunction, + Name: "work", + Language: "go", + RepoPrefix: "repo", + } + edges[i] = &graph.Edge{ + From: fmt.Sprintf("n-%d", i), + To: fmt.Sprintf("field-%d", i), + Kind: graph.EdgeAccessesField, + } + } + nodes[0].Name = "addListener" + nodes[1].Name = "notifyObservers" + store := &countingFrameworkLightStore{Store: graph.New(), nodes: nodes, edges: edges} + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + summary := summarizeFrameworkCandidates(store, nil) + if summary.allMarkers[SynthObserverChannel] != 0 { + b.Fatal("unrelated observer fields must not enable synthesis") + } + } +} diff --git a/internal/resolver/lsp_budget_test.go b/internal/resolver/lsp_budget_test.go new file mode 100644 index 000000000..f95d6f310 --- /dev/null +++ b/internal/resolver/lsp_budget_test.go @@ -0,0 +1,275 @@ +package resolver + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/graph" +) + +type slowLSPBudgetHelper struct { + delay time.Duration + defPath string + defLine int + ok bool + answers map[string]lspBudgetAnswer + + mu sync.Mutex + calls []string +} + +type lspBudgetAnswer struct { + delay time.Duration + defPath string + defLine int + ok bool +} + +func (h *slowLSPBudgetHelper) SupportsPath(path string) bool { return true } + +func (h *slowLSPBudgetHelper) Definition(_ string, _ int, name string) (string, int, bool) { + h.mu.Lock() + h.calls = append(h.calls, name) + h.mu.Unlock() + if answer, exists := h.answers[name]; exists { + if answer.delay > 0 { + time.Sleep(answer.delay) + } + return answer.defPath, answer.defLine, answer.ok + } + if h.delay > 0 { + time.Sleep(h.delay) + } + return h.defPath, h.defLine, h.ok +} + +func (h *slowLSPBudgetHelper) callNames() []string { + h.mu.Lock() + defer h.mu.Unlock() + return append([]string(nil), h.calls...) +} + +func lspBudgetGraph(names ...string) (*graph.Graph, []*graph.Edge) { + g := graph.New() + g.AddNode(&graph.Node{ + ID: "src/caller.ts", Kind: graph.KindFile, Name: "caller.ts", + FilePath: "src/caller.ts", Language: "typescript", + }) + edges := make([]*graph.Edge, 0, len(names)) + for i, name := range names { + callerID := "src/caller.ts::Caller" + name + g.AddNode(&graph.Node{ + ID: callerID, Kind: graph.KindFunction, Name: "Caller" + name, + FilePath: "src/caller.ts", StartLine: i + 1, Language: "typescript", + }) + edge := &graph.Edge{ + From: callerID, To: "unresolved::" + name, Kind: graph.EdgeCalls, + FilePath: "src/caller.ts", Line: i + 1, + } + g.AddEdge(edge) + edges = append(edges, edge) + } + return g, edges +} + +func TestLSPResolvePassBudgetFromEnv(t *testing.T) { + t.Run("default", func(t *testing.T) { + t.Setenv(LSPResolvePassBudgetEnv, "") + assert.Equal(t, 15*time.Second, lspResolvePassBudgetFromEnv()) + }) + t.Run("duration override", func(t *testing.T) { + t.Setenv(LSPResolvePassBudgetEnv, "75ms") + assert.Equal(t, 75*time.Millisecond, lspResolvePassBudgetFromEnv()) + }) + for _, value := range []string{"0", "off", "none"} { + t.Run("unlimited "+value, func(t *testing.T) { + t.Setenv(LSPResolvePassBudgetEnv, value) + assert.Zero(t, lspResolvePassBudgetFromEnv()) + }) + } + t.Run("invalid falls back", func(t *testing.T) { + t.Setenv(LSPResolvePassBudgetEnv, "not-a-duration") + assert.Equal(t, defaultLSPResolvePassBudget, lspResolvePassBudgetFromEnv()) + }) +} + +func TestResolveAllDeferredLSPPassBudgetStopsNewCalls(t *testing.T) { + g, edges := lspBudgetGraph("Alpha", "Beta", "Gamma", "Delta") + g.AddNode(&graph.Node{ + ID: "src/target.ts::Target", Kind: graph.KindFunction, Name: "Target", + FilePath: "src/target.ts", StartLine: 50, Language: "typescript", + }) + helper := &slowLSPBudgetHelper{ + delay: 20 * time.Millisecond, defPath: "src/target.ts", defLine: 50, ok: true, + } + r := New(g) + r.SetLSPHelper(helper) + r.SetLSPResolvePassBudget(5 * time.Millisecond) + + started := time.Now() + stats := r.ResolveAll() + elapsed := time.Since(started) + + require.True(t, stats.LSPBudgetExhausted) + assert.Equal(t, len(edges), stats.LSPDeferred) + assert.Equal(t, 1, stats.LSPAttempted, "only the already-in-flight call may outlive the pass budget") + assert.Equal(t, 1, stats.LSPResolved) + assert.Equal(t, len(edges)-1, stats.LSPBudgetSkipped) + assert.Len(t, helper.callNames(), 1, "skipped edges must not invoke the helper") + assert.GreaterOrEqual(t, elapsed, helper.delay) + assert.Less(t, elapsed, 100*time.Millisecond, + "the cumulative breaker must avoid paying the delay once per deferred edge") + + lspResolved := 0 + for _, edge := range edges { + if edge.Origin == graph.OriginLSPResolved { + lspResolved++ + assert.Equal(t, "src/target.ts::Target", edge.To, + "a helper answer completed before the breaker opened must be retained") + } + } + assert.Equal(t, 1, lspResolved) +} + +func TestResolveAllDeferredLSPZeroBudgetIsUnlimited(t *testing.T) { + g, edges := lspBudgetGraph("Alpha", "Beta", "Gamma") + g.AddNode(&graph.Node{ + ID: "src/target.ts::Target", Kind: graph.KindFunction, Name: "Target", + FilePath: "src/target.ts", StartLine: 50, Language: "typescript", + }) + helper := &slowLSPBudgetHelper{ + defPath: "src/target.ts", defLine: 50, ok: true, + } + r := New(g) + r.SetLSPHelper(helper) + r.SetLSPResolvePassBudget(0) + + stats := r.ResolveAll() + + assert.False(t, stats.LSPBudgetExhausted) + assert.Equal(t, len(edges), stats.LSPAttempted) + assert.Equal(t, len(edges), stats.LSPResolved) + assert.Zero(t, stats.LSPBudgetSkipped) + assert.Len(t, helper.callNames(), len(edges)) +} + +func TestResolveDeferredLSPChecksCancellationBeforeAttempt(t *testing.T) { + g, edges := lspBudgetGraph("Alpha", "Beta") + helper := &slowLSPBudgetHelper{ok: false} + r := New(g) + r.SetLSPHelper(helper) + deferred := make([]deferredLSPEdge, 0, len(edges)) + for i, edge := range edges { + deferred = append(deferred, deferredLSPEdge{edge: edge, target: []string{"Alpha", "Beta"}[i]}) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + result := r.resolveDeferredLSP(ctx, deferred) + + assert.Zero(t, result.attempted) + assert.Equal(t, len(edges), result.skipped) + assert.False(t, result.budgetExhausted, "caller cancellation is distinct from budget expiry") + assert.Empty(t, helper.callNames()) +} + +func TestResolveAllBudgetSkippedLSPIsNotStampedTerminal(t *testing.T) { + g, edges := lspBudgetGraph("NoDefinitionA", "NoDefinitionB") + for _, edge := range edges { + setEdgeTerminal(edge, terminalReasonNoDefinition) + } + helper := &slowLSPBudgetHelper{delay: 20 * time.Millisecond, ok: false} + r := New(g) + r.SetLSPHelper(helper) + r.SetLSPResolvePassBudget(5 * time.Millisecond) + r.SetStampTerminal(true) + + stats := r.ResolveAll() + + require.True(t, stats.LSPBudgetExhausted) + require.Equal(t, 1, stats.LSPBudgetSkipped) + attempted := helper.callNames() + require.Len(t, attempted, 1) + for _, edge := range edges { + if edge.To == "unresolved::"+attempted[0] { + continue + } + assert.False(t, edgeTerminalFlag(edge), + "budget-skipped LSP work must remain retryable on a later scoped pass") + } +} + +func TestResolveAllDeferredLSPBudgetContinuesAfterSlowSortedHead(t *testing.T) { + g, _ := lspBudgetGraph("Alpha", "Beta", "Gamma") + helper := &slowLSPBudgetHelper{delay: 20 * time.Millisecond, ok: false} + r := New(g) + r.SetLSPHelper(helper) + r.SetLSPResolvePassBudget(5 * time.Millisecond) + + first := r.ResolveAll() + require.True(t, first.LSPBudgetExhausted) + require.Equal(t, 1, first.LSPAttempted) + require.Equal(t, []string{"Alpha"}, helper.callNames(), + "the deterministic source order starts at the sorted head") + assert.Empty(t, r.lspDeferredRetry, + "unresolved work stays in the graph pending set instead of retaining edge payloads") + + second := r.ResolveAll() + require.True(t, second.LSPBudgetExhausted) + require.Equal(t, 1, second.LSPAttempted) + assert.Equal(t, []string{"Alpha", "Beta"}, helper.callNames(), + "the next bounded pass must resume at the first budget-skipped edge") +} + +func TestResolveAllRetriesBudgetSkippedHeuristicCorrection(t *testing.T) { + g, edges := lspBudgetGraph("Alpha", "Beta") + // The normal cascade confidently binds both calls in the caller file. Beta + // is deliberately wrong; its LSP answer points at the type-aware target in + // another file. Because Alpha consumes the first pass budget, preserving + // Beta requires retry state independent of the unresolved-edge scan. + g.AddNode(&graph.Node{ + ID: "src/caller.ts::WrongAlpha", Kind: graph.KindFunction, Name: "Alpha", + FilePath: "src/caller.ts", StartLine: 20, Language: "typescript", + }) + g.AddNode(&graph.Node{ + ID: "src/caller.ts::WrongBeta", Kind: graph.KindFunction, Name: "Beta", + FilePath: "src/caller.ts", StartLine: 21, Language: "typescript", + }) + g.AddNode(&graph.Node{ + ID: "src/correct.ts", Kind: graph.KindFile, Name: "correct.ts", + FilePath: "src/correct.ts", Language: "typescript", + }) + g.AddNode(&graph.Node{ + ID: "src/correct.ts::CorrectBeta", Kind: graph.KindFunction, Name: "Beta", + FilePath: "src/correct.ts", StartLine: 50, Language: "typescript", + }) + helper := &slowLSPBudgetHelper{answers: map[string]lspBudgetAnswer{ + "Alpha": {delay: 20 * time.Millisecond, ok: false}, + "Beta": {defPath: "src/correct.ts", defLine: 50, ok: true}, + }} + r := New(g) + r.SetLSPHelper(helper) + r.SetLSPResolvePassBudget(5 * time.Millisecond) + + first := r.ResolveAll() + require.True(t, first.LSPBudgetExhausted) + require.Equal(t, []string{"Alpha"}, helper.callNames()) + require.Equal(t, "src/caller.ts::WrongBeta", edges[1].To, + "the skipped edge should retain its best heuristic answer meanwhile") + require.NotEmpty(t, r.lspDeferredRetry, + "a resolved edge cannot rely on the next unresolved-edge scan") + + second := r.ResolveAll() + require.False(t, second.LSPBudgetExhausted) + require.Equal(t, 1, second.LSPDeferred) + require.Equal(t, 1, second.LSPAttempted) + require.Equal(t, 1, second.LSPResolved) + assert.Equal(t, []string{"Alpha", "Beta"}, helper.callNames()) + assert.Equal(t, "src/correct.ts::CorrectBeta", edges[1].To) + assert.Equal(t, graph.OriginLSPResolved, edges[1].Origin) + assert.Empty(t, r.lspDeferredRetry) +} diff --git a/internal/resolver/lsp_helper.go b/internal/resolver/lsp_helper.go index 4f8f18d21..07b200681 100644 --- a/internal/resolver/lsp_helper.go +++ b/internal/resolver/lsp_helper.go @@ -1,5 +1,20 @@ package resolver +import ( + "os" + "strings" + "time" +) + +const ( + // LSPResolvePassBudgetEnv overrides the total wall-clock budget for the + // deferred resolve-time LSP batch in ResolveAll. A zero/off/none value + // preserves the pre-budget unlimited behaviour. + LSPResolvePassBudgetEnv = "GORTEX_LSP_RESOLVE_PASS_BUDGET" + + defaultLSPResolvePassBudget = 15 * time.Second +) + // LSPHelper drives resolve-time LSP queries from the cross-file // resolver. The resolver consults it for TS/JS/JSX/TSX edges before // falling back to AST/name heuristics — letting the type-aware @@ -38,7 +53,33 @@ func (r *Resolver) SetLSPHelper(h LSPHelper) { r.lspHelper = h } +// SetLSPResolvePassBudget overrides the cumulative deferred-LSP budget used +// by ResolveAll. Zero disables the cumulative bound (the helper's per-call +// timeout still applies); negative values are normalised to zero. Like +// SetLSPHelper, it must be configured before a resolve pass starts. +func (r *Resolver) SetLSPResolvePassBudget(budget time.Duration) { + if budget < 0 { + budget = 0 + } + r.lspResolvePassBudget = budget +} + // LSPHelper returns the currently installed helper, or nil. func (r *Resolver) LSPHelper() LSPHelper { return r.lspHelper } + +func lspResolvePassBudgetFromEnv() time.Duration { + switch raw := strings.TrimSpace(os.Getenv(LSPResolvePassBudgetEnv)); raw { + case "": + return defaultLSPResolvePassBudget + case "0", "off", "none": + return 0 + default: + budget, err := time.ParseDuration(raw) + if err != nil || budget < 0 { + return defaultLSPResolvePassBudget + } + return budget + } +} diff --git a/internal/resolver/lsp_resolve.go b/internal/resolver/lsp_resolve.go index 4a585fcc9..e2b1f2e98 100644 --- a/internal/resolver/lsp_resolve.go +++ b/internal/resolver/lsp_resolve.go @@ -1,6 +1,8 @@ package resolver import ( + "context" + "errors" "path/filepath" "sort" "strings" @@ -112,8 +114,165 @@ func (r *Resolver) tryResolveViaLSP(e *graph.Edge, target string, stats *Resolve // may already carry a heuristic-resolved node ID from which the original // identifier can no longer be recovered. type deferredLSPEdge struct { - edge *graph.Edge - target string + edge *graph.Edge + target string + carried bool +} + +// deferredLSPWorkKey identifies one deferred lookup independently of the +// edge's current target. The heuristic pass may rewrite edge.To before the LSP +// batch gets to it, so including To would make a budget-skipped correction +// impossible to carry into the next ResolveAll pass. target is the original +// source identifier captured while To was still unresolved. +type deferredLSPWorkKey struct { + filePath string + line int + from string + kind graph.EdgeKind + target string +} + +func deferredLSPWorkKeyFor(de deferredLSPEdge) deferredLSPWorkKey { + if de.edge == nil { + return deferredLSPWorkKey{} + } + return deferredLSPWorkKey{ + filePath: de.edge.FilePath, + line: de.edge.Line, + from: de.edge.From, + kind: de.edge.Kind, + target: de.target, + } +} + +func deferredLSPWorkKeyLess(a, b deferredLSPWorkKey) bool { + if a.filePath != b.filePath { + return a.filePath < b.filePath + } + if a.line != b.line { + return a.line < b.line + } + if a.from != b.from { + return a.from < b.from + } + if a.kind != b.kind { + return a.kind < b.kind + } + return a.target < b.target +} + +type deferredLSPEdgeKey struct { + from string + to string + kind graph.EdgeKind + filePath string + line int +} + +func deferredLSPKey(e *graph.Edge) deferredLSPEdgeKey { + if e == nil { + return deferredLSPEdgeKey{} + } + return deferredLSPEdgeKey{ + from: e.From, to: e.To, kind: e.Kind, filePath: e.FilePath, line: e.Line, + } +} + +type deferredLSPBatchResult struct { + newlyResolved int + resolved int + attempted int + skipped int + budgetExhausted bool + terminalityExcluded map[deferredLSPEdgeKey]struct{} + retry map[deferredLSPWorkKey]deferredLSPEdge +} + +// prepareDeferredLSPBatch merges newly collected work with budget-skipped +// work from the prior pass, de-duplicates it by stable source identity, and +// returns a deterministic order. Retry work outside an active scope remains +// parked for a later compatible/full pass rather than leaking across the +// scope boundary or being lost. +func (r *Resolver) prepareDeferredLSPBatch(current []deferredLSPEdge) ( + []deferredLSPEdge, + map[deferredLSPWorkKey]deferredLSPEdge, +) { + merged := make(map[deferredLSPWorkKey]deferredLSPEdge, len(current)+len(r.lspDeferredRetry)) + retained := make(map[deferredLSPWorkKey]deferredLSPEdge) + + for key, de := range r.lspDeferredRetry { + if r.lspHelper == nil || (len(r.scope) > 0 && de.edge != nil && !edgeInResolveScope(de.edge, r.scope)) { + retained[key] = de + continue + } + merged[key] = de + } + for _, de := range current { + if de.edge == nil { + continue + } + merged[deferredLSPWorkKeyFor(de)] = de + } + + ordered := make([]deferredLSPEdge, 0, len(merged)) + for _, de := range merged { + ordered = append(ordered, de) + } + sort.Slice(ordered, func(i, j int) bool { + return deferredLSPWorkKeyLess( + deferredLSPWorkKeyFor(ordered[i]), + deferredLSPWorkKeyFor(ordered[j]), + ) + }) + if len(retained) == 0 { + retained = nil + } + return ordered, retained +} + +func (r *Resolver) hasDeferredLSPRetryForScope() bool { + if r.lspHelper == nil { + return false + } + for _, de := range r.lspDeferredRetry { + if de.edge != nil && (len(r.scope) == 0 || edgeInResolveScope(de.edge, r.scope)) { + return true + } + } + return false +} + +func (r *Resolver) replaceDeferredLSPRetries( + retained, skipped map[deferredLSPWorkKey]deferredLSPEdge, +) { + if len(retained) == 0 && len(skipped) == 0 { + r.lspDeferredRetry = nil + return + } + next := make(map[deferredLSPWorkKey]deferredLSPEdge, len(retained)+len(skipped)) + for key, de := range retained { + next[key] = de + } + for key, de := range skipped { + next[key] = de + } + r.lspDeferredRetry = next +} + +// compactDeferredLSPRetries drops skipped entries that are still unresolved +// after the resolver's tail passes. They are already durable in the graph's +// normal pending set, and the continuation cursor is sufficient to keep that +// set fair. Only edges that a heuristic/tail pass resolved need an explicit +// retry pointer, because those disappear from EdgesWithUnresolvedTarget. +func (r *Resolver) compactDeferredLSPRetries() { + for key, de := range r.lspDeferredRetry { + if de.edge == nil || graph.IsUnresolvedTarget(de.edge.To) { + delete(r.lspDeferredRetry, key) + } + } + if len(r.lspDeferredRetry) == 0 { + r.lspDeferredRetry = nil + } } // lspDeferTarget reports whether a bulk-mode ResolveAll should collect e for @@ -174,53 +333,101 @@ func (r *Resolver) lspDeferTarget(e *graph.Edge) (string, bool) { // of edges that were heuristic-UNRESOLVED before the helper bound them — only // those move the pass tally from Unresolved to Resolved. Overriding an // already-resolved heuristic bind changes the target but not the count. -func (r *Resolver) resolveDeferredLSP(edges []deferredLSPEdge) int { +func (r *Resolver) resolveDeferredLSP(ctx context.Context, edges []deferredLSPEdge) deferredLSPBatchResult { + result := deferredLSPBatchResult{} if len(edges) == 0 || r.lspHelper == nil { - return 0 + return result } - byFile := make(map[string][]deferredLSPEdge, len(edges)) - files := make([]string, 0, len(edges)) - for _, de := range edges { - if de.edge == nil { - continue - } - fp := de.edge.FilePath - if _, seen := byFile[fp]; !seen { - files = append(files, fp) + if ctx == nil { + ctx = context.Background() + } + + // Start at the first key at-or-after the prior pass's first skipped item. + // A key cursor remains fair even if completed/stale entries disappear or + // new entries are inserted between calls; an integer offset would not. + start := 0 + if r.lspDeferredCursorSet { + start = sort.Search(len(edges), func(i int) bool { + return !deferredLSPWorkKeyLess( + deferredLSPWorkKeyFor(edges[i]), + r.lspDeferredCursor, + ) + }) + if start == len(edges) { + start = 0 } - byFile[fp] = append(byFile[fp], de) } - sort.Strings(files) var stats ResolveStats - newlyResolved := 0 reindexBatch := make([]graph.EdgeReindex, 0, len(edges)) - for _, f := range files { - for _, de := range byFile[f] { - e := de.edge - // A concurrent single-file edit during an inter-chunk yield may - // have evicted this edge since it was collected; skip anything no - // longer in the graph so we don't half-resurrect an evicted edge. - // A resolved-but-live edge is NOT skipped: the heuristic may have - // confidently bound it to the wrong node, and the LSP override - // below is exactly what corrects that. - if r.validateLiveness && !edgeStillLive(r.graph, e) { - continue + stopped := false + cursorRecorded := false + for offset := 0; offset < len(edges); offset++ { + de := edges[(start+offset)%len(edges)] + e := de.edge + if e == nil { + continue + } + // A concurrent single-file edit during an inter-chunk yield may + // have evicted this edge since it was collected; skip anything no + // longer in the graph so we don't half-resurrect an evicted edge. + // A resolved-but-live edge is NOT skipped: the heuristic may have + // confidently bound it to the wrong node, and the LSP override + // below is exactly what corrects that. + if (de.carried || r.validateLiveness) && !edgeStillLive(r.graph, e) { + continue + } + // Cancellation/deadline is checked before accounting an attempt: a + // helper invocation already in flight is allowed to finish, but no new + // work starts afterward. This preserves every completed answer and + // bounds the batch to the configured budget plus at most one helper's + // own per-call timeout. + if !stopped { + if err := ctx.Err(); err != nil { + stopped = true + result.budgetExhausted = errors.Is(err, context.DeadlineExceeded) + } + } + if stopped { + result.skipped++ + workKey := deferredLSPWorkKeyFor(de) + de.carried = true + if result.retry == nil { + result.retry = make(map[deferredLSPWorkKey]deferredLSPEdge) + } + result.retry[workKey] = de + if !cursorRecorded { + r.lspDeferredCursor = workKey + r.lspDeferredCursorSet = true + cursorRecorded = true } - oldTo := e.To - wasUnresolved := graph.IsUnresolvedTarget(oldTo) - if r.tryResolveViaLSP(e, de.target, &stats) { - reindexBatch = append(reindexBatch, graph.EdgeReindex{Edge: e, OldTo: oldTo}) - if wasUnresolved { - newlyResolved++ + if graph.IsUnresolvedTarget(e.To) { + if result.terminalityExcluded == nil { + result.terminalityExcluded = make(map[deferredLSPEdgeKey]struct{}) } + result.terminalityExcluded[deferredLSPKey(e)] = struct{}{} + } + continue + } + oldTo := e.To + wasUnresolved := graph.IsUnresolvedTarget(oldTo) + result.attempted++ + if r.tryResolveViaLSP(e, de.target, &stats) { + result.resolved++ + reindexBatch = append(reindexBatch, graph.EdgeReindex{Edge: e, OldTo: oldTo}) + if wasUnresolved { + result.newlyResolved++ } } } + if result.skipped == 0 { + r.lspDeferredCursor = deferredLSPWorkKey{} + r.lspDeferredCursorSet = false + } if len(reindexBatch) > 0 { r.graph.ReindexEdges(reindexBatch) } - return newlyResolved + return result } // identifierFromTarget extracts the bare identifier from a resolver diff --git a/internal/resolver/pending_frontier_test.go b/internal/resolver/pending_frontier_test.go new file mode 100644 index 000000000..698bc5757 --- /dev/null +++ b/internal/resolver/pending_frontier_test.go @@ -0,0 +1,146 @@ +package resolver + +import ( + "testing" + + "github.com/zzet/gortex/internal/graph" +) + +func TestBuildPassIndexesForPendingBoundsReachability(t *testing.T) { + g := graph.New() + g.AddBatch([]*graph.Node{ + {ID: "a.go", Kind: graph.KindFile, Name: "a.go", FilePath: "a.go"}, + {ID: "a.go::Caller", Kind: graph.KindFunction, Name: "Caller", FilePath: "a.go"}, + {ID: "dep/dep.go", Kind: graph.KindFile, Name: "dep.go", FilePath: "dep/dep.go"}, + {ID: "other/other.go", Kind: graph.KindFile, Name: "other.go", FilePath: "other/other.go"}, + }, []*graph.Edge{ + {From: "a.go", To: "dep/dep.go", Kind: graph.EdgeImports, FilePath: "a.go", Line: 1}, + }) + + r := New(g) + pending := []*graph.Edge{{ + From: "a.go::Caller", To: graph.UnresolvedMarker + "Work", + Kind: graph.EdgeCalls, FilePath: "a.go", Line: 3, + }} + clear := r.buildPassIndexesForPending(pending) + defer clear() + + if got := len(r.reachableDirsByFile); got != 1 { + t.Fatalf("frontier reachability files = %d, want 1", got) + } + reachable := r.reachableDirsByFile["a.go"] + if _, ok := reachable["."]; !ok { + t.Fatal("caller own directory missing from frontier reachability") + } + if _, ok := reachable["dep"]; !ok { + t.Fatal("resolved import directory missing from frontier reachability") + } + if _, ok := r.reachableDirsByFile["other/other.go"]; ok { + t.Fatal("unrelated file leaked into frontier reachability") + } + if r.dirIndex != nil || r.depModuleIndex != nil || r.providesForIdx != nil { + t.Fatal("call-only frontier eagerly built a global resolver index") + } +} + +func TestBuildPassIndexesForPendingRecoversMissingEdgeFilePath(t *testing.T) { + g := graph.New() + g.AddBatch([]*graph.Node{ + {ID: "a.go", Kind: graph.KindFile, Name: "a.go", FilePath: "a.go"}, + {ID: "a.go::Caller", Kind: graph.KindFunction, Name: "Caller", FilePath: "a.go"}, + {ID: "other/other.go", Kind: graph.KindFile, Name: "other.go", FilePath: "other/other.go"}, + }, nil) + + r := New(g) + clear := r.buildPassIndexesForPending([]*graph.Edge{{ + From: "a.go::Caller", To: graph.UnresolvedMarker + "Work", Kind: graph.EdgeCalls, + }}) + defer clear() + + if got := len(r.reachableDirsByFile); got != 1 { + t.Fatalf("frontier reachability files = %d, want recovered caller only", got) + } + if _, ok := r.reachableDirsByFile["a.go"]; !ok { + t.Fatal("caller path was not recovered from the From node") + } + if _, ok := r.reachableDirsByFile["other/other.go"]; ok { + t.Fatal("missing edge FilePath triggered unrelated reachability work") + } +} + +func TestBuildPassIndexesForPendingUnknownCallerDoesNotBuildGlobalReachability(t *testing.T) { + g := graph.New() + g.AddNode(&graph.Node{ID: "other/other.go", Kind: graph.KindFile, Name: "other.go", FilePath: "other/other.go"}) + r := New(g) + + clear := r.buildPassIndexesForPending([]*graph.Edge{{ + From: "missing::Caller", To: graph.UnresolvedMarker + "Work", Kind: graph.EdgeCalls, + }}) + defer clear() + + if r.reachableDirsByFile == nil { + t.Fatal("bounded empty reachability index was not installed") + } + if len(r.reachableDirsByFile) != 0 { + t.Fatalf("unknown caller reachability = %#v, want empty fail-open frontier", r.reachableDirsByFile) + } +} + +func TestBoundImplsForBuildsProvidesIndexLazily(t *testing.T) { + g := graph.New() + g.AddBatch([]*graph.Node{ + {ID: "module", Kind: graph.KindType, Name: "Module", FilePath: "module.ts"}, + {ID: "repo::EmailNotifier", Kind: graph.KindType, Name: "EmailNotifier", FilePath: "email.ts"}, + }, []*graph.Edge{{ + From: "module", To: "repo::EmailNotifier", Kind: graph.EdgeProvides, + Meta: map[string]any{"provides_for": "Notifier", "binding": "useClass"}, + }}) + + r := New(g) + if r.providesForIdx != nil { + t.Fatal("provides index must start lazy") + } + bound := r.boundImplsFor("Notifier") + if _, ok := bound["EmailNotifier"]; !ok { + t.Fatalf("lazy provides lookup = %#v, want EmailNotifier", bound) + } + if r.providesForIdx == nil { + t.Fatal("provides index was not cached after first lookup") + } +} + +func TestResolveFilesAndIncomingNoPendingBuildsNoIndexes(t *testing.T) { + g := graph.New() + g.AddNode(&graph.Node{ID: "a.go", Kind: graph.KindFile, Name: "a.go", FilePath: "a.go"}) + r := New(g) + stats := r.ResolveFilesAndIncoming([]string{"a.go", "a.go"}) + if stats == nil || stats.Resolved != 0 || stats.Unresolved != 0 { + t.Fatalf("no-pending stats = %#v, want zero", stats) + } + if r.dirIndex != nil || r.depModuleIndex != nil || r.providesForIdx != nil || r.reachableDirsByFile != nil { + t.Fatal("no-pending batch left pass indexes allocated") + } +} + +func TestResolveFilesAndIncomingKeepsAttributionOnExactFiles(t *testing.T) { + g := graph.New() + g.AddBatch([]*graph.Node{ + {ID: "a.go", Kind: graph.KindFile, Name: "a.go", FilePath: "a.go", Language: "go"}, + {ID: "a.go::Caller", Kind: graph.KindFunction, Name: "Caller", FilePath: "a.go", Language: "go"}, + {ID: "b.go", Kind: graph.KindFile, Name: "b.go", FilePath: "b.go", Language: "go"}, + {ID: "b.go::Caller", Kind: graph.KindFunction, Name: "Caller", FilePath: "b.go", Language: "go"}, + }, []*graph.Edge{ + {From: "a.go::Caller", To: graph.UnresolvedMarker + "Missing", Kind: graph.EdgeCalls, FilePath: "a.go", Line: 3}, + {From: "b.go::Caller", To: graph.UnresolvedMarker + "len", Kind: graph.EdgeCalls, FilePath: "b.go", Line: 4}, + }) + + New(g).ResolveFilesAndIncoming([]string{"a.go"}) + + edges := g.GetOutEdges("b.go::Caller") + if len(edges) != 1 || edges[0].To != graph.UnresolvedMarker+"len" { + t.Fatalf("unrelated file attribution changed edge = %#v", edges) + } + if g.GetNode(graph.StubID("", graph.StubKindBuiltin, "go", "len")) != nil { + t.Fatal("unrelated file caused whole-graph builtin attribution") + } +} diff --git a/internal/resolver/resolver.go b/internal/resolver/resolver.go index 2658baf01..4f1dc1a83 100644 --- a/internal/resolver/resolver.go +++ b/internal/resolver/resolver.go @@ -1,6 +1,7 @@ package resolver import ( + "context" "fmt" "iter" "os" @@ -29,6 +30,14 @@ type ResolveStats struct { Resolved int `json:"resolved"` Unresolved int `json:"unresolved"` External int `json:"external"` + // LSP* exposes the deferred whole-pass circuit-breaker outcome. These are + // diagnostic counters: skipped edges keep the heuristic result produced by + // the normal resolve cascade and remain eligible for a later full pass. + LSPDeferred int `json:"lsp_deferred,omitempty"` + LSPAttempted int `json:"lsp_attempted,omitempty"` + LSPResolved int `json:"lsp_resolved,omitempty"` + LSPBudgetSkipped int `json:"lsp_budget_skipped,omitempty"` + LSPBudgetExhausted bool `json:"lsp_budget_exhausted,omitempty"` // PendingBefore / PendingAfter record the pending-edge count before and // after the scope filter (see SetScope). Diagnostic only — the // warm-restart master-resolve log surfaces them so a scoped pass's @@ -179,6 +188,22 @@ type Resolver struct { // tsserver). See lsp_helper.go for the contract. Set via // SetLSPHelper before ResolveAll runs. lspHelper LSPHelper + // lspResolvePassBudget bounds the cumulative deferred LSP batch in a + // whole-graph ResolveAll. Zero intentionally means unlimited for + // compatibility. Individual helper calls retain their own timeout, so the + // wall bound can overrun by at most the one call already in flight. + lspResolvePassBudget time.Duration + // lspDeferredRetry preserves only budget-skipped LSP work across + // ResolveAll calls. This is required for heuristic-resolved edges: after + // the heuristic rewrites To they no longer appear in + // EdgesWithUnresolvedTarget, but still need the type-aware correction the + // exhausted pass did not attempt. The cursor is the stable key of the first + // skipped item, making the next bounded pass resume fairly even if the + // candidate set changes between calls. All three fields are protected by + // mu; retries run synchronously in the next compatible ResolveAll. + lspDeferredRetry map[deferredLSPWorkKey]deferredLSPEdge + lspDeferredCursor deferredLSPWorkKey + lspDeferredCursorSet bool // lspIndex caches a (filePath, oneBasedLine) → *graph.Node // lookup table populated lazily on first LSP hit per pass so @@ -246,7 +271,12 @@ type depModuleEntry struct { // the same Store, so their ResolveAll / ResolveFile calls serialise // end-to-end across cross-repo / temporal / external passes. func New(g graph.Store) *Resolver { - return &Resolver{graph: g, mu: g.ResolveMutex(), logger: zap.NewNop()} + return &Resolver{ + graph: g, + mu: g.ResolveMutex(), + logger: zap.NewNop(), + lspResolvePassBudget: lspResolvePassBudgetFromEnv(), + } } // SetLogger attaches a logger so ResolveAll emits pass-progress @@ -300,6 +330,11 @@ func (r *Resolver) SetGraph(g graph.Store) { } r.graph = g r.mu = g.ResolveMutex() + // Deferred entries hold edge pointers from the previous store. Carrying + // them across a graph swap could reindex an unrelated/stale row. + r.lspDeferredRetry = nil + r.lspDeferredCursor = deferredLSPWorkKey{} + r.lspDeferredCursorSet = false if oldMu != nil { oldMu.Unlock() } @@ -388,7 +423,7 @@ func (r *Resolver) ResolveAll() *ResolveStats { if len(r.scope) > 0 && !warmupFullResolve() { pending, terminalSkipped = filterTerminalSkip(pending, r.scope) } - if len(pending) == 0 { + if len(pending) == 0 && !r.hasDeferredLSPRetryForScope() { return &ResolveStats{PendingBefore: pendingBefore} } @@ -608,13 +643,34 @@ func (r *Resolver) ResolveAll() *ResolveStats { // Runs before the tail attribution passes so external-call // materialisation sees the LSP-resolved targets, exactly as the inline // (non-bulk) path would. + deferredLSP, retainedLSPRetries := r.prepareDeferredLSPBatch(deferredLSP) lspDeferred := len(deferredLSP) - lspBatchResolved := 0 + lspResult := deferredLSPBatchResult{} lspStart := time.Now() if lspDeferred > 0 { - lspBatchResolved = r.resolveDeferredLSP(deferredLSP) - } + lspCtx := context.Background() + cancel := func() {} + if r.lspResolvePassBudget > 0 { + lspCtx, cancel = context.WithTimeout(lspCtx, r.lspResolvePassBudget) + } + lspResult = r.resolveDeferredLSP(lspCtx, deferredLSP) + cancel() + } + // Commit retry state as soon as the bounded batch completes. Later + // attribution passes may rewrite an entry's live edge, but the retry holds + // that same pointer and validates liveness on the next pass. Persisting here + // also keeps work retryable if an unrelated tail pass fails afterward. + r.replaceDeferredLSPRetries(retainedLSPRetries, lspResult.retry) lspElapsed := time.Since(lspStart) + if lspResult.budgetExhausted { + r.logger.Warn("resolver: deferred LSP pass budget exhausted", + zap.Duration("budget", r.lspResolvePassBudget), + zap.Duration("elapsed", lspElapsed), + zap.Int("attempted", lspResult.attempted), + zap.Int("resolved", lspResult.resolved), + zap.Int("skipped", lspResult.skipped), + zap.String("bound", "budget plus at most one in-flight helper call")) + } // Bulk mode covers only the parallel compute + the deferred LSP batch; the // guard and tail attribution passes below run identically to the single- // file path. (The deferred defer() is the panic-safety net.) @@ -626,7 +682,11 @@ func (r *Resolver) ResolveAll() *ResolveStats { zap.Int("reindex_batch", reindexTotal), zap.Int("super_chunk", superChunk), zap.Int("lsp_deferred", lspDeferred), - zap.Int("lsp_batch_resolved", lspBatchResolved), + zap.Int("lsp_attempted", lspResult.attempted), + zap.Int("lsp_batch_resolved", lspResult.resolved), + zap.Int("lsp_budget_skipped", lspResult.skipped), + zap.Bool("lsp_budget_exhausted", lspResult.budgetExhausted), + zap.Duration("lsp_budget", r.lspResolvePassBudget), zap.Duration("warm_lookup", warmElapsed), zap.Duration("compute_loop", loopElapsed), zap.Duration("deferred_lsp", lspElapsed), @@ -740,13 +800,17 @@ func (r *Resolver) ResolveAll() *ResolveStats { // candidate. Gated to the master resolve via SetStampTerminal so a // partially-indexed per-repo pass never stamps a false "no definition". if r.stampTerminal && len(r.scope) == 0 { - stamped, unstamped := r.reconcileTerminalStamps() + stamped, unstamped := r.reconcileTerminalStampsExcluding(lspResult.terminalityExcluded) if stamped > 0 || unstamped > 0 { r.logger.Info("resolver: terminal stamps", zap.Int("stamped", stamped), zap.Int("unstamped", unstamped)) } } + // Unresolved skipped work is already represented by the graph's pending + // set; retain explicit pointers only for skipped edges a heuristic or tail + // pass resolved, since those would otherwise vanish from the next pass. + r.compactDeferredLSPRetries() // Diagnostic sub-phase breakdown of the whole ResolveAll pass. The // compute loop is parallel; the tail passes (guard, Go/lang attribution, @@ -782,14 +846,19 @@ func (r *Resolver) ResolveAll() *ResolveStats { // Unresolved by the heuristic cascade that left it pending; binding it in // the batch moves it back to Resolved, matching the non-bulk path where // the inline LSP win would have counted a Resolved and no Unresolved. - if lspBatchResolved > 0 { - total.Resolved += lspBatchResolved - if total.Unresolved >= lspBatchResolved { - total.Unresolved -= lspBatchResolved + if lspResult.newlyResolved > 0 { + total.Resolved += lspResult.newlyResolved + if total.Unresolved >= lspResult.newlyResolved { + total.Unresolved -= lspResult.newlyResolved } else { total.Unresolved = 0 } } + total.LSPDeferred = lspDeferred + total.LSPAttempted = lspResult.attempted + total.LSPResolved = lspResult.resolved + total.LSPBudgetSkipped = lspResult.skipped + total.LSPBudgetExhausted = lspResult.budgetExhausted total.PendingBefore = pendingBefore total.PendingAfter = len(pending) return total @@ -1357,13 +1426,32 @@ func (r *Resolver) buildPassIndexes() (clear func()) { r.buildDepModuleIndex() r.buildProvidesForIndex() r.buildReachabilityIndex() - return func() { - r.clearDirIndexes() - r.clearDepModuleIndex() - r.clearProvidesForIndex() - r.clearReachabilityIndex() - r.clearLSPIndex() + return r.clearPassIndexes +} + +func (r *Resolver) clearPassIndexes() { + r.clearDirIndexes() + r.clearDepModuleIndex() + r.clearProvidesForIndex() + r.clearReachabilityIndex() + r.clearLSPIndex() +} + +// buildPassIndexesForPending bounds the interactive path to the caller files +// represented by the pending frontier. Directory/dependency scans are paid only +// when an unresolved import actually needs them; the DI provides index is lazy. +func (r *Resolver) buildPassIndexesForPending(pending []*graph.Edge) (clear func()) { + for _, edge := range pending { + if edge != nil && edge.Kind == graph.EdgeImports && graph.IsUnresolvedTarget(edge.To) { + r.buildDirIndexes() + r.buildDepModuleIndex() + break + } + } + if !r.buildReachabilityIndexForPending(pending) { + r.buildReachabilityIndex() } + return r.clearPassIndexes } // ResolveFile resolves unresolved edges originating from a specific file. @@ -1386,10 +1474,27 @@ func (r *Resolver) ResolveFile(filePath string) *ResolveStats { // ResolveIncomingForFile back-to-back, which built and tore down the // same four indexes twice per save. func (r *Resolver) ResolveFileAndIncoming(filePath string) *ResolveStats { + started := time.Now() r.mu.Lock() defer r.mu.Unlock() - clear := r.buildPassIndexes() + // Establish whether this edit left any work before building the four + // graph-wide pass indexes. Generated assets and source saves that carry + // only already-resolved/structural edges are common watcher events; the + // old ordering rebuilt every index even though both scoped passes were + // guaranteed to visit zero pending edges. On a disk-backed multi-repo + // graph that no-op cost can be minutes and holds the shared resolver lock + // for the whole duration. + pendingStarted := time.Now() + pending := r.pendingEdgesForFileAndIncoming(filePath) + pendingDuration := time.Since(pendingStarted) + if len(pending) == 0 { + return &ResolveStats{} + } + + indexStarted := time.Now() + clear := r.buildPassIndexesForPending(pending) + indexDuration := time.Since(indexStarted) defer clear() // Warm the per-edge lookup cache for this file's pending forward and @@ -1399,12 +1504,33 @@ func (r *Resolver) ResolveFileAndIncoming(filePath string) *ResolveStats { // every edge that shares a name. Seeding the cache once (one batched // FindNodesByNames, like ResolveAll) materialises each candidate once // and the passes read it from memory. - r.warmLookupCache(r.pendingEdgesForFileAndIncoming(filePath)) + warmStarted := time.Now() + r.warmLookupCache(pending) + warmDuration := time.Since(warmStarted) defer r.clearLookupCache() stats := &ResolveStats{} - r.resolveFileLocked(filePath, stats) + forwardStarted := time.Now() + r.resolveFileEdgesLocked(filePath, stats) + forwardDuration := time.Since(forwardStarted) + attributionStarted := time.Now() + r.runFileAttributionPassesForFileLocked(filePath) + attributionDuration := time.Since(attributionStarted) + incomingStarted := time.Now() r.resolveIncomingLocked(filePath, stats) + incomingDuration := time.Since(incomingStarted) + if elapsed := time.Since(started); elapsed >= time.Second { + r.logger.Info("resolver: incremental file phases", + zap.String("file", filePath), + zap.Int("pending", len(pending)), + zap.Duration("pending_collect", pendingDuration), + zap.Duration("build_indexes", indexDuration), + zap.Duration("warm_lookup", warmDuration), + zap.Duration("forward", forwardDuration), + zap.Duration("attribution", attributionDuration), + zap.Duration("incoming", incomingDuration), + zap.Duration("total", elapsed)) + } return stats } @@ -1467,17 +1593,55 @@ func (r *Resolver) ResolveFilesAndIncoming(filePaths []string) *ResolveStats { if len(filePaths) == 0 { return stats } + started := time.Now() r.mu.Lock() defer r.mu.Unlock() - clear := r.buildPassIndexes() + pendingStarted := time.Now() + var pending []*graph.Edge + seenPaths := make(map[string]struct{}, len(filePaths)) + for _, p := range filePaths { + if _, duplicate := seenPaths[p]; duplicate { + continue + } + seenPaths[p] = struct{}{} + pending = append(pending, r.pendingEdgesForFileAndIncoming(p)...) + } + pendingDuration := time.Since(pendingStarted) + if len(pending) == 0 { + return stats + } + indexStarted := time.Now() + clear := r.buildPassIndexesForPending(pending) + indexDuration := time.Since(indexStarted) defer clear() + warmStarted := time.Now() + r.warmLookupCache(pending) + warmDuration := time.Since(warmStarted) + defer r.clearLookupCache() - for _, p := range filePaths { + resolveStarted := time.Now() + for p := range seenPaths { r.resolveFileEdgesLocked(p, stats) r.resolveIncomingLocked(p, stats) } - r.runFileAttributionPassesLocked() + resolveDuration := time.Since(resolveStarted) + attributionStarted := time.Now() + for p := range seenPaths { + r.runFileAttributionPassesForFileLocked(p) + } + attributionDuration := time.Since(attributionStarted) + if elapsed := time.Since(started); elapsed >= time.Second { + r.logger.Info("resolver: incremental files phases", + zap.Int("files", len(seenPaths)), + zap.Int("pending", len(pending)), + zap.Duration("pending_collect", pendingDuration), + zap.Duration("build_indexes", indexDuration), + zap.Duration("warm_lookup", warmDuration), + zap.Duration("resolve", resolveDuration), + zap.Duration("attribution", attributionDuration), + zap.Duration("total", elapsed)) + } return stats } @@ -3178,6 +3342,96 @@ func (r *Resolver) buildReachabilityIndex() { r.dirByFilePath = dirByPath } +// buildReachabilityIndexForPending materialises reachability only for caller +// files in the interactive frontier. Missing edge FilePath metadata must never +// promote an interactive edit to a whole-graph scan: recover the path from the +// concrete From node when possible, otherwise leave that edge unfiltered. The +// reachability filter is explicitly fail-open for an unknown caller path. +func (r *Resolver) buildReachabilityIndexForPending(pending []*graph.Edge) bool { + callerPaths := make(map[string]struct{}) + for _, edge := range pending { + if edge == nil { + continue + } + callerPath := edge.FilePath + if callerPath == "" { + if from := r.graph.GetNode(edge.From); from != nil { + callerPath = from.FilePath + } + } + if callerPath != "" { + callerPaths[callerPath] = struct{}{} + } + } + + reachable := make(map[string]map[string]struct{}, len(callerPaths)) + dirs := make(map[string]string, len(callerPaths)) + for filePath := range callerPaths { + dir := filepath.Dir(filePath) + dirs[filePath] = dir + reachable[filePath] = map[string]struct{}{dir: {}} + + nodes := r.graph.GetFileNodes(filePath) + if len(nodes) == 0 { + // Preserve the caller directory as the bounded evidence we do + // have. filterByReachability fails open if no candidate lands + // there, so an incomplete persisted file bucket cannot lose a + // resolution and does not justify a global index build. + continue + } + ids := make([]string, 0, len(nodes)) + for _, node := range nodes { + if node != nil { + ids = append(ids, node.ID) + } + } + byNode := r.graph.GetOutEdgesByNodeIDs(ids) + var imports []*graph.Edge + var targetIDs []string + for _, id := range ids { + for _, edge := range byNode[id] { + if edge == nil || edge.Kind != graph.EdgeImports { + continue + } + imports = append(imports, edge) + if !graph.IsUnresolvedTarget(edge.To) && !strings.HasPrefix(edge.To, "external::") { + targetIDs = append(targetIDs, edge.To) + } + } + } + targets := r.graph.GetNodesByIDs(targetIDs) + for _, edge := range imports { + var importedDir string + switch { + case graph.IsUnresolvedTarget(edge.To) && strings.HasPrefix(graph.UnresolvedName(edge.To), "import::"): + if r.dirIndex == nil { + r.buildDirIndexes() + } + path := strings.TrimPrefix(graph.UnresolvedName(edge.To), "import::") + if files := r.dirIndex[path]; len(files) > 0 { + importedDir = filepath.Dir(files[0].FilePath) + } else if last := lastPathComponent(path); last != "" { + if files := r.lastDirIndex[last]; len(files) > 0 { + importedDir = filepath.Dir(files[0].FilePath) + } + } + case strings.HasPrefix(edge.To, "external::"): + default: + if target := targets[edge.To]; target != nil && target.FilePath != "" { + importedDir = filepath.Dir(target.FilePath) + dirs[target.FilePath] = importedDir + } + } + if importedDir != "" { + reachable[filePath][importedDir] = struct{}{} + } + } + } + r.reachableDirsByFile = reachable + r.dirByFilePath = dirs + return true +} + func (r *Resolver) clearReachabilityIndex() { r.reachableDirsByFile = nil r.dirByFilePath = nil @@ -3229,9 +3483,12 @@ func (r *Resolver) filterByReachability(callerFileID string, candidates []*graph // so the caller can match against nodeReceiverType of method candidates. // Empty when no binding exists. func (r *Resolver) boundImplsFor(abstractName string) map[string]struct{} { - if abstractName == "" || len(r.providesForIdx) == 0 { + if abstractName == "" { return nil } + if r.providesForIdx == nil { + r.buildProvidesForIndex() + } return r.providesForIdx[abstractName] } diff --git a/internal/resolver/resolver_incremental_bench_test.go b/internal/resolver/resolver_incremental_bench_test.go new file mode 100644 index 000000000..b8fef0f57 --- /dev/null +++ b/internal/resolver/resolver_incremental_bench_test.go @@ -0,0 +1,31 @@ +package resolver + +import ( + "fmt" + "testing" + + "github.com/zzet/gortex/internal/graph" +) + +// BenchmarkResolveFileAndIncomingNoPending100K guards the watcher hot path: +// a generated asset with no unresolved forward/incoming edge must stay scoped +// to that file instead of rebuilding pass indexes over the whole graph. +func BenchmarkResolveFileAndIncomingNoPending100K(b *testing.B) { + g := graph.New() + for i := 0; i < 100_000; i++ { + path := fmt.Sprintf("pkg/%06d.go", i) + g.AddNode(&graph.Node{ID: path, Kind: graph.KindFile, Name: path, FilePath: path, Language: "go"}) + } + g.AddNode(&graph.Node{ID: "results.json", Kind: graph.KindFile, Name: "results.json", FilePath: "results.json", Language: "json"}) + g.AddNode(&graph.Node{ID: "results.json::records", Kind: graph.KindVariable, Name: "records", FilePath: "results.json", Language: "json"}) + g.AddEdge(&graph.Edge{From: "results.json", To: "results.json::records", Kind: graph.EdgeDefines, FilePath: "results.json"}) + + r := New(g) + b.ResetTimer() + for i := 0; i < b.N; i++ { + stats := r.ResolveFileAndIncoming("results.json") + if stats.Resolved != 0 || stats.Unresolved != 0 { + b.Fatalf("unexpected resolver work: %+v", stats) + } + } +} diff --git a/internal/resolver/resolver_test.go b/internal/resolver/resolver_test.go index 992ec81b1..8eceea917 100644 --- a/internal/resolver/resolver_test.go +++ b/internal/resolver/resolver_test.go @@ -1,6 +1,7 @@ package resolver import ( + "iter" "testing" "github.com/stretchr/testify/assert" @@ -581,6 +582,48 @@ func TestResolveFile(t *testing.T) { assert.Equal(t, "b.go::Bar", callEdge.To) } +type countingPassIndexStore struct { + graph.Store + nodesByKindCalls int +} + +func (s *countingPassIndexStore) NodesByKind(kind graph.NodeKind) iter.Seq[*graph.Node] { + s.nodesByKindCalls++ + return s.Store.NodesByKind(kind) +} + +func TestResolveFileAndIncomingSkipsPassIndexesWithoutPendingEdges(t *testing.T) { + g := graph.New() + g.AddNode(&graph.Node{ID: "results.json", Kind: graph.KindFile, Name: "results.json", FilePath: "results.json", Language: "json"}) + g.AddNode(&graph.Node{ID: "results.json::records", Kind: graph.KindVariable, Name: "records", FilePath: "results.json", Language: "json"}) + g.AddEdge(&graph.Edge{From: "results.json", To: "results.json::records", Kind: graph.EdgeDefines, FilePath: "results.json"}) + + store := &countingPassIndexStore{Store: g} + stats := New(store).ResolveFileAndIncoming("results.json") + + assert.Zero(t, stats.Resolved) + assert.Zero(t, stats.Unresolved) + assert.Zero(t, store.nodesByKindCalls, + "a file with no pending forward or incoming edges must not build graph-wide pass indexes") +} + +func TestResolveFileAndIncomingBuildsPassIndexesForPendingEdge(t *testing.T) { + g := graph.New() + g.AddNode(&graph.Node{ID: "a.go", Kind: graph.KindFile, Name: "a.go", FilePath: "a.go", Language: "go"}) + g.AddNode(&graph.Node{ID: "a.go::Foo", Kind: graph.KindFunction, Name: "Foo", FilePath: "a.go", Language: "go"}) + g.AddNode(&graph.Node{ID: "b.go::Bar", Kind: graph.KindFunction, Name: "Bar", FilePath: "b.go", Language: "go"}) + callEdge := &graph.Edge{From: "a.go::Foo", To: "unresolved::Bar", Kind: graph.EdgeCalls, FilePath: "a.go", Line: 5} + g.AddEdge(callEdge) + + store := &countingPassIndexStore{Store: g} + stats := New(store).ResolveFileAndIncoming("a.go") + + assert.Equal(t, 1, stats.Resolved) + assert.Equal(t, "b.go::Bar", callEdge.To) + assert.NotZero(t, store.nodesByKindCalls, + "pending work must retain the normal pass-indexed resolver path") +} + // TestResolveMethodCall_ImportReachabilityFilter exercises Pass 0: // when two methods named "Register" exist in different packages and // only one of those packages is imported by the caller's file, the diff --git a/internal/resolver/rust_aliases.go b/internal/resolver/rust_aliases.go new file mode 100644 index 000000000..f6abf84d1 --- /dev/null +++ b/internal/resolver/rust_aliases.go @@ -0,0 +1,322 @@ +package resolver + +import ( + "path" + "strings" + + "github.com/zzet/gortex/internal/graph" +) + +const rustAliasDepthLimit = 32 + +type rustAliasKey struct { + repo string + file string + name string +} + +type rustExportAliasKey struct { + repo string + crate string + name string +} + +type rustAliasEntry struct { + source string + file string + ambiguous bool +} + +type rustTypeAliasIndex struct { + local map[rustAliasKey]rustAliasEntry + exported map[rustExportAliasKey]rustAliasEntry +} + +type rustUseBinding struct { + source string + local string +} + +func newRustTypeAliasIndex(g graph.Store) *rustTypeAliasIndex { + if g == nil { + return nil + } + idx := &rustTypeAliasIndex{ + local: make(map[rustAliasKey]rustAliasEntry), + exported: make(map[rustExportAliasKey]rustAliasEntry), + } + for edge := range g.EdgesByKind(graph.EdgeImports) { + if edge == nil || !strings.HasSuffix(edge.From, ".rs") { + continue + } + raw := rustUsePathFromEdge(edge) + if raw == "" { + continue + } + from := g.GetNode(edge.From) + repo := "" + if from != nil { + repo = from.RepoPrefix + } + bindings := parseRustUseBindings(raw) + for _, binding := range bindings { + if binding.local == "" || binding.source == "" || binding.local == "_" { + continue + } + entry := rustAliasEntry{source: binding.source, file: edge.From} + addRustAlias(idx.local, rustAliasKey{repo: repo, file: edge.From, name: binding.local}, entry) + if edge.Meta != nil { + if reexport, _ := edge.Meta["reexport"].(bool); reexport { + addRustAlias(idx.exported, rustExportAliasKey{ + repo: repo, + crate: rustAliasCrate(edge.From), + name: binding.local, + }, entry) + } + } + } + } + if len(idx.local) == 0 && len(idx.exported) == 0 { + return nil + } + return idx +} + +func addRustAlias[K comparable](aliases map[K]rustAliasEntry, key K, entry rustAliasEntry) { + current, exists := aliases[key] + if !exists { + aliases[key] = entry + return + } + if current.ambiguous || current.source != entry.source { + current.ambiguous = true + aliases[key] = current + } +} + +// resolve follows aliases visible in node's source file, then public re-export +// aliases at the crate boundary. It returns the canonical Rust path, whether an +// alias was traversed, and false when the alias set is ambiguous or cyclic. +func (idx *rustTypeAliasIndex) resolve(node *graph.Node, name string) (string, bool, bool) { + name = normalizeRustUsePath(name) + if idx == nil || node == nil || name == "" { + return name, false, name != "" + } + + repo := node.RepoPrefix + crate := rustAliasCrate(node.FilePath) + file := node.FilePath + current := name + changed := false + seen := make(map[string]struct{}, rustAliasDepthLimit) + + for depth := 0; depth < rustAliasDepthLimit; depth++ { + lookup := rustUseBindingName(current) + if lookup == "" { + return current, changed, true + } + state := file + "\x00" + current + if _, duplicate := seen[state]; duplicate { + return "", changed, false + } + seen[state] = struct{}{} + + entry, exists := idx.local[rustAliasKey{repo: repo, file: file, name: lookup}] + if exists && entry.ambiguous { + return "", changed, false + } + if exists && normalizeRustUsePath(entry.source) == current { + // The current value is already this file-local import's canonical + // path. Continue at the crate re-export layer instead of applying + // the same alias forever. + exists = false + } + if !exists { + entry, exists = idx.exported[rustExportAliasKey{repo: repo, crate: crate, name: lookup}] + if exists && entry.ambiguous { + return "", changed, false + } + if exists && normalizeRustUsePath(entry.source) == current { + return current, changed, true + } + } + if !exists { + return current, changed, true + } + if entry.source == "" { + return "", changed, false + } + current = normalizeRustUsePath(entry.source) + file = entry.file + changed = true + } + return "", changed, false +} + +func rustUsePathFromEdge(edge *graph.Edge) string { + if edge == nil { + return "" + } + if edge.Meta != nil { + if raw, _ := edge.Meta["rust_use_path"].(string); strings.TrimSpace(raw) != "" { + return normalizeRustUsePath(raw) + } + } + const prefix = "unresolved::import::" + if !strings.HasPrefix(edge.To, prefix) { + return "" + } + return normalizeRustUsePath(strings.TrimPrefix(edge.To, prefix)) +} + +func normalizeRustUsePath(raw string) string { + raw = strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(raw), ";")) + raw = strings.ReplaceAll(raw, "/", "::") + for strings.Contains(raw, "::::") { + raw = strings.ReplaceAll(raw, "::::", "::") + } + return strings.Trim(raw, ":") +} + +func parseRustUseBindings(raw string) []rustUseBinding { + bindings := expandRustUseTree("", normalizeRustUsePath(raw)) + seen := make(map[rustUseBinding]struct{}, len(bindings)) + out := bindings[:0] + for _, binding := range bindings { + binding.source = normalizeRustUsePath(binding.source) + binding.local = strings.TrimSpace(binding.local) + if binding.source == "" || binding.local == "" || binding.local == "*" { + continue + } + if _, duplicate := seen[binding]; duplicate { + continue + } + seen[binding] = struct{}{} + out = append(out, binding) + } + return out +} + +func expandRustUseTree(prefix, tree string) []rustUseBinding { + tree = strings.TrimSpace(tree) + if tree == "" { + return nil + } + if open := strings.IndexByte(tree, '{'); open >= 0 { + close := matchingRustUseBrace(tree, open) + if close < 0 || strings.TrimSpace(tree[close+1:]) != "" { + return nil + } + base := strings.TrimSuffix(strings.TrimSpace(tree[:open]), "::") + base = joinRustUsePath(prefix, base) + var out []rustUseBinding + for _, item := range splitRustUseGroup(tree[open+1 : close]) { + item = strings.TrimSpace(item) + if item == "" { + continue + } + leaf, alias := splitRustUseAlias(item) + if leaf == "self" && base != "" { + if alias == "" { + alias = rustUseBindingName(base) + } + out = append(out, rustUseBinding{source: base, local: alias}) + continue + } + out = append(out, expandRustUseTree(base, item)...) + } + return out + } + + leaf, alias := splitRustUseAlias(tree) + if leaf == "" || leaf == "*" { + return nil + } + source := joinRustUsePath(prefix, leaf) + source = strings.TrimSuffix(source, "::self") + if alias == "" { + alias = rustUseBindingName(source) + } + return []rustUseBinding{{source: source, local: alias}} +} + +func splitRustUseAlias(tree string) (string, string) { + tree = strings.TrimSpace(tree) + if i := strings.LastIndex(tree, " as "); i >= 0 { + return strings.TrimSpace(tree[:i]), strings.TrimSpace(tree[i+4:]) + } + return tree, "" +} + +func matchingRustUseBrace(tree string, open int) int { + depth := 0 + for i := open; i < len(tree); i++ { + switch tree[i] { + case '{': + depth++ + case '}': + depth-- + if depth == 0 { + return i + } + } + } + return -1 +} + +func splitRustUseGroup(group string) []string { + start, depth := 0, 0 + var out []string + for i := 0; i < len(group); i++ { + switch group[i] { + case '{': + depth++ + case '}': + if depth > 0 { + depth-- + } + case ',': + if depth == 0 { + out = append(out, strings.TrimSpace(group[start:i])) + start = i + 1 + } + } + } + out = append(out, strings.TrimSpace(group[start:])) + return out +} + +func joinRustUsePath(prefix, suffix string) string { + prefix = strings.Trim(strings.TrimSpace(prefix), ":") + suffix = strings.Trim(strings.TrimSpace(suffix), ":") + switch { + case prefix == "": + return suffix + case suffix == "": + return prefix + default: + return prefix + "::" + suffix + } +} + +func rustUseBindingName(source string) string { + source = normalizeRustUsePath(source) + if source == "" { + return "" + } + if i := strings.LastIndex(source, "::"); i >= 0 { + return strings.TrimSpace(source[i+2:]) + } + return strings.TrimSpace(source) +} + +func rustAliasCrate(file string) string { + if crate := rustCrateOf(file); crate != "" { + return crate + } + root := rustCrateRootDir(file) + if root == "." || root == "/" { + return path.Dir(file) + } + return root +} diff --git a/internal/resolver/rust_bounds.go b/internal/resolver/rust_bounds.go new file mode 100644 index 000000000..69fce69c6 --- /dev/null +++ b/internal/resolver/rust_bounds.go @@ -0,0 +1,658 @@ +package resolver + +import ( + "sort" + "strings" + "unicode" + + "github.com/zzet/gortex/internal/graph" +) + +const rustTraitSuperDepthLimit = 16 + +func rustTypeParamTraitNames(value any, param string) []string { + param = strings.TrimSpace(param) + if param == "" { + return nil + } + var bounds []string + appendEntry := func(name, bound string) { + if strings.TrimSpace(name) == param && strings.TrimSpace(bound) != "" { + bounds = append(bounds, bound) + } + } + switch entries := value.(type) { + case []map[string]string: + for _, entry := range entries { + appendEntry(entry["name"], entry["bound"]) + } + case []any: + for _, raw := range entries { + switch entry := raw.(type) { + case map[string]any: + name, _ := entry["name"].(string) + bound, _ := entry["bound"].(string) + appendEntry(name, bound) + case map[string]string: + appendEntry(entry["name"], entry["bound"]) + } + } + case map[string]string: + appendEntry(param, entries[param]) + case map[string]any: + if raw, ok := entries[param]; ok { + if bound, ok := raw.(string); ok { + appendEntry(param, bound) + } + } + } + + seen := make(map[string]struct{}) + var names []string + for _, bound := range bounds { + for _, name := range parseRustTraitBoundNames(bound) { + if _, ok := seen[name]; ok { + continue + } + seen[name] = struct{}{} + names = append(names, name) + } + } + return names +} + +func parseRustTraitBoundNames(bound string) []string { + parts, ok := splitStructuredRustTraitBounds(bound) + if !ok { + return nil + } + seen := make(map[string]struct{}) + var names []string + appendName := func(name string) { + name = strings.Trim(strings.TrimSpace(name), ":") + if name == "" { + return + } + if _, ok := seen[name]; ok { + return + } + seen[name] = struct{}{} + names = append(names, name) + } + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" || strings.HasPrefix(part, "'") || strings.HasPrefix(part, "?") { + continue + } + part = stripRustBoundPrefix(part) + if part == "" || strings.HasPrefix(part, "'") || strings.HasPrefix(part, "?") || strings.HasPrefix(part, "<") { + continue + } + name := rustTraitPathHead(part) + if name == "" { + continue + } + appendName(name) + } + return names +} + +func splitStructuredRustTraitBounds(bound string) ([]string, bool) { + var parts []string + start := 0 + angle, paren, bracket, brace := 0, 0, 0, 0 + for i, r := range bound { + switch r { + case '<': + angle++ + case '>': + if i > 0 && bound[i-1] == '-' { + continue + } + if angle == 0 { + return nil, false + } + angle-- + case '(': + paren++ + case ')': + if paren == 0 { + return nil, false + } + paren-- + case '[': + bracket++ + case ']': + if bracket == 0 { + return nil, false + } + bracket-- + case '{': + brace++ + case '}': + if brace == 0 { + return nil, false + } + brace-- + case '+': + if angle == 0 && paren == 0 && bracket == 0 && brace == 0 { + parts = append(parts, bound[start:i]) + start = i + 1 + } + } + } + if angle != 0 || paren != 0 || bracket != 0 || brace != 0 { + return nil, false + } + parts = append(parts, bound[start:]) + return parts, true +} + +func stripRustBoundPrefix(part string) string { + part = strings.TrimSpace(part) + for strings.HasPrefix(part, "for") { + rest := strings.TrimSpace(strings.TrimPrefix(part, "for")) + if !strings.HasPrefix(rest, "<") { + break + } + depth := 0 + end := -1 + for i, r := range rest { + switch r { + case '<': + depth++ + case '>': + if depth == 0 { + return "" + } + depth-- + if depth == 0 { + end = i + 1 + } + } + if end >= 0 { + break + } + } + if end < 0 { + return "" + } + part = strings.TrimSpace(rest[end:]) + } + for { + before := part + for _, prefix := range []string{"~const ", "const ", "dyn ", "impl "} { + if strings.HasPrefix(part, prefix) { + part = strings.TrimSpace(strings.TrimPrefix(part, prefix)) + break + } + } + if part == before { + return part + } + } +} + +func rustTraitPathHead(part string) string { + part = strings.TrimSpace(part) + end := len(part) + for i, r := range part { + switch r { + case '<', '(', '[', '{', '=', ' ', '\t', '\r', '\n': + end = i + } + if end != len(part) { + break + } + } + name := strings.TrimSpace(part[:end]) + if name == "" || strings.ContainsAny(name, "&*!,;") { + return "" + } + segments := strings.Split(strings.Trim(name, ":"), "::") + for i, segment := range segments { + segment = strings.TrimPrefix(segment, "r#") + if segment == "" { + return "" + } + for pos, r := range segment { + if r == '_' || unicode.IsLetter(r) || (pos > 0 && unicode.IsDigit(r)) { + continue + } + return "" + } + segments[i] = segment + } + return strings.Join(segments, "::") +} + +type rustTraitTargetKey struct { + repo string + crateRoot string + path string +} + +type rustTraitTargetEntry struct { + id string + ambiguous bool +} + +type rustTraitTargetIndex struct { + exact map[rustTraitTargetKey]rustTraitTargetEntry + basename map[rustTraitTargetKey]rustTraitTargetEntry + nodes map[string]*graph.Node +} + +func resolveRustTraitExtendsWithIndex(g graph.Store, idx *rustTraitTargetIndex, aliases *rustTypeAliasIndex) int { + if g == nil || idx == nil { + return 0 + } + var batch []graph.EdgeReindex + for edge := range g.EdgesByKind(graph.EdgeExtends) { + if edge == nil || !strings.HasPrefix(edge.To, "unresolved::extends::") { + continue + } + child := idx.nodes[edge.From] + if child == nil { + continue + } + raw := strings.TrimPrefix(edge.To, "unresolved::extends::") + if edge.Meta != nil { + if path, _ := edge.Meta["rust_trait_path"].(string); path != "" { + raw = path + } + } + if aliases != nil { + resolvedPath, _, ok := aliases.resolve(child, raw) + if !ok { + continue + } + raw = resolvedPath + } + target := idx.resolve(child, raw) + if target == "" || target == edge.From { + continue + } + oldTo := edge.To + edge.To = target + edge.Origin = graph.OriginASTResolved + edge.Confidence = 0.95 + edge.ConfidenceLabel = graph.ConfidenceLabelFor(graph.EdgeExtends, edge.Confidence) + if edge.Meta == nil { + edge.Meta = map[string]any{} + } + edge.Meta["resolved_via"] = "rust_supertrait" + batch = append(batch, graph.EdgeReindex{Edge: edge, OldTo: oldTo}) + } + if len(batch) > 0 { + g.ReindexEdges(batch) + } + return len(batch) +} + +func newRustTraitTargetIndex(g graph.Store) *rustTraitTargetIndex { + idx := &rustTraitTargetIndex{ + exact: make(map[rustTraitTargetKey]rustTraitTargetEntry), + basename: make(map[rustTraitTargetKey]rustTraitTargetEntry), + nodes: make(map[string]*graph.Node), + } + for node := range g.NodesByKind(graph.KindInterface) { + if node == nil || node.Language != "rust" || node.ID == "" || node.Name == "" { + continue + } + crateRoot, module := rustTraitCrateAndModule(node.FilePath) + name := normalizeRustTraitIdentifier(node.Name) + if name == "" { + continue + } + idx.nodes[node.ID] = node + baseKey := rustTraitTargetKey{repo: node.RepoPrefix, crateRoot: crateRoot, path: name} + addRustTraitTarget(idx.basename, baseKey, node.ID) + full := "crate::" + name + if module != "" { + full = "crate::" + module + "::" + name + } + addRustTraitTarget(idx.exact, rustTraitTargetKey{ + repo: node.RepoPrefix, crateRoot: crateRoot, path: full, + }, node.ID) + addRustTraitTarget(idx.exact, rustTraitTargetKey{ + repo: node.RepoPrefix, crateRoot: crateRoot, path: strings.TrimPrefix(full, "crate::"), + }, node.ID) + if qual := normalizeRustTraitPath(node.QualName); qual != "" { + addRustTraitTarget(idx.exact, rustTraitTargetKey{ + repo: node.RepoPrefix, crateRoot: crateRoot, path: qual, + }, node.ID) + } + } + if len(idx.nodes) == 0 { + return nil + } + return idx +} + +func addRustTraitTarget(index map[rustTraitTargetKey]rustTraitTargetEntry, key rustTraitTargetKey, id string) { + if key.path == "" || id == "" { + return + } + current, exists := index[key] + if !exists { + index[key] = rustTraitTargetEntry{id: id} + return + } + if current.id == id && !current.ambiguous { + return + } + index[key] = rustTraitTargetEntry{ambiguous: true} +} + +func (idx *rustTraitTargetIndex) resolve(child *graph.Node, raw string) string { + if idx == nil || child == nil { + return "" + } + path := normalizeRustTraitPath(raw) + if path == "" || strings.HasPrefix(strings.TrimSpace(raw), "?") { + return "" + } + crateRoot, module := rustTraitCrateAndModule(child.FilePath) + lookupExact := func(candidate string) string { + entry, ok := idx.exact[rustTraitTargetKey{ + repo: child.RepoPrefix, crateRoot: crateRoot, path: candidate, + }] + if !ok || entry.ambiguous { + return "" + } + return entry.id + } + if strings.HasPrefix(path, "crate::") { + return lookupExact(path) + } + if strings.HasPrefix(path, "self::") { + candidate := "crate::" + strings.TrimPrefix(path, "self::") + if module != "" { + candidate = "crate::" + module + "::" + strings.TrimPrefix(path, "self::") + } + return lookupExact(candidate) + } + if strings.HasPrefix(path, "super::") { + parts := splitRustModulePath(module) + rest := path + for strings.HasPrefix(rest, "super::") { + if len(parts) == 0 { + return "" + } + parts = parts[:len(parts)-1] + rest = strings.TrimPrefix(rest, "super::") + } + candidateParts := append([]string{"crate"}, parts...) + candidateParts = append(candidateParts, rest) + return lookupExact(strings.Join(candidateParts, "::")) + } + if strings.Contains(path, "::") { + // A qualified non-crate path may name an internal crate module or an + // external crate. Bind only when the full internal path is proven. + return lookupExact("crate::" + path) + } + if module != "" { + if id := lookupExact("crate::" + module + "::" + path); id != "" { + return id + } + } else if id := lookupExact("crate::" + path); id != "" { + return id + } + entry, ok := idx.basename[rustTraitTargetKey{ + repo: child.RepoPrefix, crateRoot: crateRoot, path: path, + }] + if !ok || entry.ambiguous { + return "" + } + return entry.id +} + +func normalizeRustTraitPath(raw string) string { + raw = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(raw), "unresolved::extends::")) + if raw == "" || strings.HasPrefix(raw, "?") || strings.HasPrefix(raw, "'") { + return "" + } + raw = stripRustBoundPrefix(raw) + if raw == "" || strings.HasPrefix(raw, "?") || strings.HasPrefix(raw, "'") { + return "" + } + return rustTraitPathHead(raw) +} + +func normalizeRustTraitIdentifier(name string) string { + name = strings.TrimPrefix(strings.TrimSpace(name), "r#") + if rustTraitPathHead(name) != name { + return "" + } + return name +} + +func rustTraitCrateAndModule(filePath string) (string, string) { + path := strings.Trim(strings.ReplaceAll(filePath, "\\", "/"), "/") + marker := "/src/" + crateRoot := "" + rel := path + if i := strings.LastIndex(path, marker); i >= 0 { + crateRoot = path[:i] + rel = path[i+len(marker):] + } else if strings.HasPrefix(path, "src/") { + rel = strings.TrimPrefix(path, "src/") + } + rel = strings.TrimSuffix(rel, ".rs") + parts := splitRustModulePath(rel) + if len(parts) > 0 { + switch parts[len(parts)-1] { + case "lib", "main", "mod": + parts = parts[:len(parts)-1] + } + } + return crateRoot, strings.Join(parts, "::") +} + +func splitRustModulePath(path string) []string { + path = strings.Trim(path, "/:") + if path == "" { + return nil + } + var parts []string + for _, part := range strings.FieldsFunc(path, func(r rune) bool { return r == '/' || r == ':' }) { + part = normalizeRustTraitIdentifier(part) + if part == "" { + return nil + } + parts = append(parts, part) + } + return parts +} + +func (idx *rustTraitTargetIndex) ownerAliases(id string) []string { + if idx == nil { + return nil + } + node := idx.nodes[id] + if node == nil { + return nil + } + crateRoot, module := rustTraitCrateAndModule(node.FilePath) + name := normalizeRustTraitIdentifier(node.Name) + if name == "" { + return nil + } + full := "crate::" + name + if module != "" { + full = "crate::" + module + "::" + name + } + aliases := []string{full, strings.TrimPrefix(full, "crate::")} + base := idx.basename[rustTraitTargetKey{repo: node.RepoPrefix, crateRoot: crateRoot, path: name}] + baseUnique := !base.ambiguous && base.id == id + if qual := normalizeRustTraitPath(node.QualName); qual != "" && (strings.Contains(qual, "::") || baseUnique) { + aliases = append(aliases, qual) + } + if baseUnique { + aliases = append(aliases, name) + } + sort.Strings(aliases) + return dedupeRustStrings(aliases) +} + +func inheritRustTraitMethods(g graph.Store, scope *rustScopeIndex, targets *rustTraitTargetIndex) { + if g == nil || scope == nil || targets == nil { + return + } + + methodByID := make(map[string]*graph.Node) + for method := range g.NodesByKind(graph.KindMethod) { + if method == nil || method.Language != "rust" || method.Meta == nil { + continue + } + if traitDecl, _ := method.Meta["trait_decl"].(string); traitDecl != "true" { + continue + } + methodByID[method.ID] = method + } + + directByTrait := make(map[string][]*graph.Node) + assigned := make(map[string]bool) + for edge := range g.EdgesByKind(graph.EdgeMemberOf) { + if edge == nil || targets.nodes[edge.To] == nil { + continue + } + method := methodByID[edge.From] + if method == nil { + continue + } + directByTrait[edge.To] = append(directByTrait[edge.To], method) + assigned[method.ID] = true + } + for id, method := range methodByID { + if assigned[id] { + continue + } + owner := nodeReceiverType(method) + traitID := targets.resolve(method, owner) + if traitID == "" { + continue + } + directByTrait[traitID] = append(directByTrait[traitID], method) + } + for id, methods := range directByTrait { + directByTrait[id] = mergeRustTraitMethods(nil, methods) + } + + supers := make(map[string][]string) + for edge := range g.EdgesByKind(graph.EdgeExtends) { + if edge == nil || edge.From == edge.To || targets.nodes[edge.From] == nil || targets.nodes[edge.To] == nil { + continue + } + supers[edge.From] = append(supers[edge.From], edge.To) + } + for child, parents := range supers { + sort.Strings(parents) + supers[child] = dedupeRustStrings(parents) + } + + traitIDs := make([]string, 0, len(targets.nodes)) + for id := range targets.nodes { + traitIDs = append(traitIDs, id) + } + sort.Strings(traitIDs) + for _, childID := range traitIDs { + methods := mergeRustTraitMethods(nil, directByTrait[childID]) + for _, parentID := range rustInheritedTraitIDs(childID, supers) { + methods = mergeRustTraitMethods(methods, directByTrait[parentID]) + } + scope.traitMethodsByID[childID] = methods + + child := targets.nodes[childID] + for _, owner := range targets.ownerAliases(childID) { + key := rustOwnerKey{repo: child.RepoPrefix, owner: owner} + scope.methodsByOwner[key] = mergeRustTraitMethods(scope.methodsByOwner[key], methods) + } + } +} + +func mergeRustTraitMethods(dst, src []*graph.Node) []*graph.Node { + seen := make(map[string]struct{}, len(dst)+len(src)) + out := make([]*graph.Node, 0, len(dst)+len(src)) + for _, methods := range [][]*graph.Node{dst, src} { + for _, method := range methods { + if method == nil || method.ID == "" { + continue + } + if _, ok := seen[method.ID]; ok { + continue + } + seen[method.ID] = struct{}{} + out = append(out, method) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + return out +} + +func rustInheritedTraitIDs(child string, supers map[string][]string) []string { + seen := map[string]bool{child: true} + var inherited []string + var visit func(string, int) + visit = func(current string, depth int) { + if depth >= rustTraitSuperDepthLimit { + return + } + for _, parent := range supers[current] { + if seen[parent] { + continue + } + seen[parent] = true + inherited = append(inherited, parent) + visit(parent, depth+1) + } + } + visit(child, 0) + sort.Strings(inherited) + return inherited +} + +func dedupeRustStrings(values []string) []string { + if len(values) < 2 { + return values + } + out := values[:1] + for _, value := range values[1:] { + if value != out[len(out)-1] { + out = append(out, value) + } + } + return out +} + +func rustInheritedTraitOwners(child rustOwnerKey, supers map[rustOwnerKey][]rustOwnerKey) []rustOwnerKey { + seen := map[rustOwnerKey]bool{child: true} + var inherited []rustOwnerKey + var visit func(rustOwnerKey, int) + visit = func(current rustOwnerKey, depth int) { + if depth >= rustTraitSuperDepthLimit { + return + } + for _, parent := range supers[current] { + if seen[parent] { + continue + } + seen[parent] = true + inherited = append(inherited, parent) + visit(parent, depth+1) + } + } + visit(child, 0) + sortRustOwnerKeys(inherited) + return inherited +} + +func sortRustOwnerKeys(keys []rustOwnerKey) { + sort.Slice(keys, func(i, j int) bool { + if keys[i].repo != keys[j].repo { + return keys[i].repo < keys[j].repo + } + return keys[i].owner < keys[j].owner + }) +} diff --git a/internal/resolver/rust_bounds_test.go b/internal/resolver/rust_bounds_test.go new file mode 100644 index 000000000..74c4675ce --- /dev/null +++ b/internal/resolver/rust_bounds_test.go @@ -0,0 +1,244 @@ +package resolver + +import ( + "fmt" + "reflect" + "testing" + + "github.com/zzet/gortex/internal/graph" +) + +func TestRustTypeParamTraitNamesStructuredLegacyView(t *testing.T) { + tests := []struct { + name string + value any + param string + want []string + }{ + { + name: "qualified associated constraint and lifetime", + value: []map[string]string{{ + "name": "M", + "bound": "crate::matcher::Matcher> + Send + 'static", + }}, + param: "M", + want: []string{"crate::matcher::Matcher", "Send"}, + }, + { + name: "simple higher ranked trait bound", + value: []any{map[string]any{ + "name": "F", + "bound": "for <'a> Fn(&'a [u8]) -> bool + Sync", + }}, + param: "F", + want: []string{"Fn", "Sync"}, + }, + { + name: "map compatibility shape", + value: map[string]string{"T": "?Sized + r#AsyncRead"}, + param: "T", + want: []string{"AsyncRead"}, + }, + { + name: "duplicate lookup keys remain stable", + value: []map[string]string{ + {"name": "T", "bound": "pkg::Read + Read"}, + {"name": "T", "bound": "Read + Send"}, + }, + param: "T", + want: []string{"pkg::Read", "Read", "Send"}, + }, + { + name: "unicode and raw identifiers normalize", + value: []map[string]string{{"name": "T", "bound": "crate::r#match::Διαβάζει"}}, + param: "T", + want: []string{"crate::match::Διαβάζει"}, + }, + { + name: "malformed generic is conservative", + value: []map[string]string{{"name": "T", "bound": "Matcher"}}, + param: "T", + want: nil, + }, + { + name: "unmatched close is conservative", + value: []map[string]string{{"name": "T", "bound": "Matcher> + Send"}}, + param: "T", + want: nil, + }, + { + name: "missing parameter", + value: []map[string]string{{"name": "T", "bound": "Read"}}, + param: "U", + want: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := rustTypeParamTraitNames(tt.value, tt.param) + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("rustTypeParamTraitNames() = %#v, want %#v", got, tt.want) + } + }) + } +} + +func TestUniqueGenericBoundTraitMethodStructuredBounds(t *testing.T) { + matcherMethod := &graph.Node{ + ID: "repo/matcher.rs::Matcher.find", + Kind: graph.KindMethod, + Name: "find", + Language: "rust", + RepoPrefix: "repo", + Meta: map[string]any{ + "receiver": "Matcher", + "trait_decl": "true", + }, + } + idx := &rustScopeIndex{methodsByOwner: map[rustOwnerKey][]*graph.Node{ + {repo: "repo", owner: "crate::matcher::Matcher"}: {matcherMethod}, + {repo: "repo", owner: "Matcher"}: {matcherMethod}, + }} + caller := &graph.Node{Meta: map[string]any{ + "type_params": []map[string]string{{ + "name": "M", + "bound": "crate::matcher::Matcher> + Send", + }}, + }} + if got := idx.uniqueGenericBoundTraitMethod("repo", caller, "M", "find"); got != matcherMethod.ID { + t.Fatalf("uniqueGenericBoundTraitMethod() = %q, want %q", got, matcherMethod.ID) + } + + other := &graph.Node{ + ID: "repo/other.rs::Send.find", + Kind: graph.KindMethod, + Name: "find", + Language: "rust", + RepoPrefix: "repo", + Meta: map[string]any{"receiver": "Send", "trait_decl": "true"}, + } + idx.methodsByOwner[rustOwnerKey{repo: "repo", owner: "Send"}] = []*graph.Node{other} + if got := idx.uniqueGenericBoundTraitMethod("repo", caller, "M", "find"); got != "" { + t.Fatalf("ambiguous trait method resolved to %q", got) + } + + caller.Meta["type_params"] = []map[string]string{{"name": "M", "bound": "Matcher"}} + if got := idx.uniqueGenericBoundTraitMethod("repo", caller, "M", "find"); got != "" { + t.Fatalf("malformed bound resolved to %q", got) + } + + caller.Meta["type_params"] = []map[string]string{{"name": "M", "bound": "external::Matcher"}} + if got := idx.uniqueGenericBoundTraitMethod("repo", caller, "M", "find"); got != "" { + t.Fatalf("external qualified bound degraded to local Matcher: %q", got) + } +} + +func TestRustTraitTargetIndexConservativeResolution(t *testing.T) { + const ( + repo = "repo" + root = "crates/app" + ) + child := &graph.Node{ + ID: "crates/app/src/nested/child.rs::Child", Name: "Child", + FilePath: "crates/app/src/nested/child.rs", RepoPrefix: repo, Language: "rust", + } + idx := &rustTraitTargetIndex{ + exact: make(map[rustTraitTargetKey]rustTraitTargetEntry), + basename: make(map[rustTraitTargetKey]rustTraitTargetEntry), + nodes: map[string]*graph.Node{child.ID: child}, + } + addRustTraitTarget(idx.exact, rustTraitTargetKey{repo: repo, crateRoot: root, path: "crate::nested::child::Local"}, "local-id") + addRustTraitTarget(idx.exact, rustTraitTargetKey{repo: repo, crateRoot: root, path: "crate::nested::Parent"}, "parent-id") + addRustTraitTarget(idx.exact, rustTraitTargetKey{repo: repo, crateRoot: root, path: "crate::Root"}, "root-id") + addRustTraitTarget(idx.basename, rustTraitTargetKey{repo: repo, crateRoot: root, path: "Unique"}, "unique-id") + addRustTraitTarget(idx.basename, rustTraitTargetKey{repo: repo, crateRoot: root, path: "Ambiguous"}, "a-id") + addRustTraitTarget(idx.basename, rustTraitTargetKey{repo: repo, crateRoot: root, path: "Ambiguous"}, "b-id") + + tests := []struct { + raw string + want string + }{ + {raw: "self::Local", want: "local-id"}, + {raw: "super::Parent", want: "parent-id"}, + {raw: "crate::Root", want: "root-id"}, + {raw: "nested::Parent", want: "parent-id"}, + {raw: "Unique", want: "unique-id"}, + {raw: "Ambiguous", want: ""}, + {raw: "serde::Unique", want: ""}, + {raw: "?Unique", want: ""}, + } + for _, tt := range tests { + if got := idx.resolve(child, tt.raw); got != tt.want { + t.Errorf("resolve(%q) = %q, want %q", tt.raw, got, tt.want) + } + } +} + +func TestRustInheritedMethodsKeepQualifiedOwnerAliases(t *testing.T) { + const repo = "repo" + trait := func(id, name, file string) *graph.Node { + return &graph.Node{ID: id, Kind: graph.KindInterface, Name: name, FilePath: file, Language: "rust", RepoPrefix: repo} + } + method := func(id, file string) *graph.Node { + return &graph.Node{ + ID: id, Kind: graph.KindMethod, Name: "p", FilePath: file, Language: "rust", RepoPrefix: repo, + Meta: map[string]any{"receiver": "Parent", "trait_decl": "true"}, + } + } + aParent := trait("src/a.rs::Parent", "Parent", "src/a.rs") + aChild := trait("src/a.rs::Child", "Child", "src/a.rs") + bParent := trait("src/b.rs::Parent", "Parent", "src/b.rs") + bChild := trait("src/b.rs::Child", "Child", "src/b.rs") + aMethod := method("src/a.rs::Parent.p", "src/a.rs") + bMethod := method("src/b.rs::Parent.p", "src/b.rs") + + g := graph.New() + g.AddBatch( + []*graph.Node{aParent, aChild, bParent, bChild, aMethod, bMethod}, + []*graph.Edge{ + {From: aMethod.ID, To: aParent.ID, Kind: graph.EdgeMemberOf}, + {From: bMethod.ID, To: bParent.ID, Kind: graph.EdgeMemberOf}, + {From: aChild.ID, To: aParent.ID, Kind: graph.EdgeExtends}, + {From: bChild.ID, To: bParent.ID, Kind: graph.EdgeExtends}, + }, + ) + idx, changed := buildRustScopeIndex(g) + if changed != 0 || idx == nil { + t.Fatalf("buildRustScopeIndex() = (%v, %d), want non-nil, 0", idx, changed) + } + if got := idx.methodsByOwner[rustOwnerKey{repo: repo, owner: "Child"}]; len(got) != 0 { + t.Fatalf("ambiguous basename Child was indexed: %#v", got) + } + assertMethodIDs := func(owner string, want *graph.Node) { + t.Helper() + got := idx.methodsByOwner[rustOwnerKey{repo: repo, owner: owner}] + if len(got) != 1 || got[0].ID != want.ID { + t.Fatalf("methodsByOwner[%q] = %#v, want only %s", owner, got, want.ID) + } + } + assertMethodIDs("a::Child", aMethod) + assertMethodIDs("b::Child", bMethod) +} + +func TestRustInheritedTraitOwnersCycleAndDepthCap(t *testing.T) { + key := func(name string) rustOwnerKey { return rustOwnerKey{repo: "repo", owner: name} } + a, b, c := key("A"), key("B"), key("C") + got := rustInheritedTraitOwners(a, map[rustOwnerKey][]rustOwnerKey{ + a: {b}, + b: {c}, + c: {a}, + }) + want := []rustOwnerKey{b, c} + if !reflect.DeepEqual(got, want) { + t.Fatalf("cycle closure = %#v, want %#v", got, want) + } + + supers := make(map[rustOwnerKey][]rustOwnerKey) + for i := 0; i < rustTraitSuperDepthLimit+4; i++ { + supers[key(fmt.Sprintf("T%02d", i))] = []rustOwnerKey{key(fmt.Sprintf("T%02d", i+1))} + } + got = rustInheritedTraitOwners(key("T00"), supers) + if len(got) != rustTraitSuperDepthLimit { + t.Fatalf("depth-capped closure has %d owners, want %d", len(got), rustTraitSuperDepthLimit) + } +} diff --git a/internal/resolver/rust_cross_file_test.go b/internal/resolver/rust_cross_file_test.go new file mode 100644 index 000000000..2c9bb92cd --- /dev/null +++ b/internal/resolver/rust_cross_file_test.go @@ -0,0 +1,244 @@ +package resolver + +import ( + "sort" + "testing" + + "github.com/stretchr/testify/require" + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/parser" + "github.com/zzet/gortex/internal/parser/languages" +) + +var rustBuilderWalkerFixture = map[string]string{ + "src/lib.rs": ` +mod ignore; +mod walker; + +pub use ignore::GitIgnore; +pub use walker::{run_walk, Walker}; +`, + "src/ignore.rs": ` +pub trait IgnoreState { + fn is_ignored(&self, path: &str) -> bool; +} + +pub struct GitIgnore {} + +impl IgnoreState for GitIgnore { + fn is_ignored(&self, path: &str) -> bool { path.starts_with('.') } +} +`, + "src/walker.rs": ` +use crate::ignore::{GitIgnore, IgnoreState}; + +pub trait WalkState: IgnoreState {} +impl WalkState for GitIgnore {} + +pub struct Walker {} + +impl Walker { + pub fn walk(&self, state: &S, path: &str) -> bool { + state.is_ignored(path) + } +} + +pub fn run_walk() -> bool { + let walker = Walker {}; + let state = GitIgnore {}; + walker.walk(&state, ".git"); + state.is_ignored("target") +} +`, +} + +var rustReexportAliasFixture = map[string]string{ + "src/lib.rs": ` +mod literal; +mod matcher; + +pub use literal::Literal as RegexLiteral; +pub use matcher::accepts; +`, + "src/literal.rs": ` +pub struct Literal { value: String } + +impl Literal { + pub fn new(value: &str) -> Self { Self { value: value.to_string() } } + pub fn is_match(&self, text: &str) -> bool { text.contains(&self.value) } +} +`, + "src/matcher.rs": ` +use crate::RegexLiteral as Pattern; + +pub fn accepts(text: &str) -> bool { + let pattern = Pattern::new("todo"); + pattern.is_match(text) +} +`, +} + +func TestRustBuilderWalkerCrossFileCallChain(t *testing.T) { + g, nodes, edges := extractRustFixture(t, rustBuilderWalkerFixture) + t.Logf("fixture extracted %d nodes and %d edges", nodes, edges) + + resolved := ResolveRustScopeCalls(g) + t.Logf("rust scope resolved %d edges", resolved) + + walk := requireRustNode(t, g, "walk", "src/walker.rs", graph.KindMethod) + traitMethod := requireRustTraitMethod(t, g, "is_ignored", "src/ignore.rs") + implMethod := requireRustConcreteMethod(t, g, "is_ignored", "src/ignore.rs") + run := requireRustNode(t, g, "run_walk", "src/walker.rs", graph.KindFunction) + + requireEdge(t, g, walk.ID, traitMethod.ID, graph.EdgeCalls, + "generic S: WalkState dispatch must inherit IgnoreState::is_ignored across files") + requireEdge(t, g, implMethod.ID, traitMethod.ID, graph.EdgeOverrides, + "concrete GitIgnore implementation must connect to its trait declaration") + requireEdge(t, g, run.ID, walk.ID, graph.EdgeCalls, + "the public entrypoint must retain a causal call edge into Walker::walk") +} + +func TestRustTypeAliasIndexStopsAtCanonicalImportPath(t *testing.T) { + g, _, _ := extractRustFixture(t, rustBuilderWalkerFixture) + walkState := requireRustNode(t, g, "WalkState", "src/walker.rs", graph.KindInterface) + + canonical, changed, ok := newRustTypeAliasIndex(g).resolve(walkState, "IgnoreState") + require.True(t, ok) + require.True(t, changed) + require.Equal(t, "crate::ignore::IgnoreState", canonical) +} + +func TestRustAssociatedCallsFollowReexportAliasChain(t *testing.T) { + g, nodes, edges := extractRustFixture(t, rustReexportAliasFixture) + t.Logf("fixture extracted %d nodes and %d edges", nodes, edges) + + ResolveRustScopeCalls(g) + + accepts := requireRustNode(t, g, "accepts", "src/matcher.rs", graph.KindFunction) + newMethod := requireRustNode(t, g, "new", "src/literal.rs", graph.KindMethod) + matchMethod := requireRustNode(t, g, "is_match", "src/literal.rs", graph.KindMethod) + + requireEdge(t, g, accepts.ID, newMethod.ID, graph.EdgeCalls, + "Pattern::new must follow Pattern -> RegexLiteral -> Literal") + requireEdge(t, g, accepts.ID, matchMethod.ID, graph.EdgeCalls, + "the constructor-derived Pattern receiver must resolve through the same alias chain") +} + +func TestParseRustUseBindingsNestedGroups(t *testing.T) { + got := parseRustUseBindings("crate::ignore::{self, GitIgnore, nested::{IgnoreState as State, Other}}") + require.ElementsMatch(t, []rustUseBinding{ + {source: "crate::ignore", local: "ignore"}, + {source: "crate::ignore::GitIgnore", local: "GitIgnore"}, + {source: "crate::ignore::nested::IgnoreState", local: "State"}, + {source: "crate::ignore::nested::Other", local: "Other"}, + }, got) +} + +func BenchmarkRustBuilderWalkerExtraction(b *testing.B) { + extractor := languages.NewRustExtractor() + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + for path, source := range rustBuilderWalkerFixture { + if _, err := extractor.Extract(path, []byte(source)); err != nil { + b.Fatal(err) + } + } + } +} + +func BenchmarkRustBuilderWalkerGraphAndResolve(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + g, _, _ := extractRustFixture(b, rustBuilderWalkerFixture) + ResolveRustScopeCalls(g) + } +} + +func extractRustFixture(tb testing.TB, files map[string]string) (graph.Store, int, int) { + tb.Helper() + g := graph.New() + extractor := languages.NewRustExtractor() + paths := make([]string, 0, len(files)) + for path := range files { + paths = append(paths, path) + } + sort.Strings(paths) + nodeCount, edgeCount := 0, 0 + for _, path := range paths { + result, err := extractor.Extract(path, []byte(files[path])) + require.NoError(tb, err, "extract %s", path) + addRustExtraction(g, result) + nodeCount += len(result.Nodes) + edgeCount += len(result.Edges) + } + return g, nodeCount, edgeCount +} + +func addRustExtraction(g graph.Store, result *parser.ExtractionResult) { + for _, node := range result.Nodes { + g.AddNode(node) + } + for _, edge := range result.Edges { + g.AddEdge(edge) + } +} + +func requireRustNode(tb testing.TB, g graph.Store, name, file string, kind graph.NodeKind) *graph.Node { + tb.Helper() + var matches []*graph.Node + for _, node := range g.FindNodesByName(name) { + if node != nil && node.FilePath == file && node.Kind == kind { + matches = append(matches, node) + } + } + require.Len(tb, matches, 1, "%s %s in %s", kind, name, file) + return matches[0] +} + +func requireRustTraitMethod(tb testing.TB, g graph.Store, name, file string) *graph.Node { + tb.Helper() + var matches []*graph.Node + for _, node := range g.FindNodesByName(name) { + if node == nil || node.FilePath != file || node.Kind != graph.KindMethod || node.Meta == nil { + continue + } + if node.Meta["trait_decl"] == "true" { + matches = append(matches, node) + } + } + require.Len(tb, matches, 1, "trait method %s in %s", name, file) + return matches[0] +} + +func requireRustConcreteMethod(tb testing.TB, g graph.Store, name, file string) *graph.Node { + tb.Helper() + var matches []*graph.Node + for _, node := range g.FindNodesByName(name) { + if node == nil || node.FilePath != file || node.Kind != graph.KindMethod { + continue + } + if node.Meta != nil && node.Meta["trait_decl"] == "true" { + continue + } + matches = append(matches, node) + } + require.Len(tb, matches, 1, "concrete method %s in %s", name, file) + return matches[0] +} + +func requireEdge(tb testing.TB, g graph.Store, from, to string, kind graph.EdgeKind, message string) { + tb.Helper() + var actual []string + for _, edge := range g.GetOutEdges(from) { + if edge.Kind != kind { + continue + } + actual = append(actual, edge.To) + if edge.To == to { + return + } + } + sort.Strings(actual) + require.Failf(tb, message, "from=%s kind=%s want=%s actual=%v", from, kind, to, actual) +} diff --git a/internal/resolver/rust_extractor_integration_test.go b/internal/resolver/rust_extractor_integration_test.go new file mode 100644 index 000000000..09ac590f6 --- /dev/null +++ b/internal/resolver/rust_extractor_integration_test.go @@ -0,0 +1,278 @@ +package resolver_test + +import ( + "strings" + "testing" + + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/parser/languages" + "github.com/zzet/gortex/internal/resolver" +) + +func TestRustTraitExtendsGenericDispatchExtractionIntegration(t *testing.T) { + t.Parallel() + + src := []byte(` +trait Parent { + fn p(&self); +} + +trait Child: Parent + ?Sized + 'static {} + +fn call(value: T) { + value.p(); +} +`) + result, err := languages.NewRustExtractor().Extract("src/lib.rs", src) + if err != nil { + t.Fatalf("extract Rust source: %v", err) + } + if result.Tree != nil { + defer result.Tree.Close() + } + + var parent, child, parentMethod, caller *graph.Node + for _, node := range result.Nodes { + switch { + case node.Kind == graph.KindInterface && node.Name == "Parent": + parent = node + case node.Kind == graph.KindInterface && node.Name == "Child": + child = node + case node.Kind == graph.KindMethod && node.Name == "p": + parentMethod = node + case node.Kind == graph.KindFunction && node.Name == "call": + caller = node + } + } + if parent == nil || child == nil || parentMethod == nil || caller == nil { + t.Fatalf("missing extracted symbols: parent=%v child=%v method=%v caller=%v", parent, child, parentMethod, caller) + } + + var extractedExtends *graph.Edge + for _, edge := range result.Edges { + if edge.Kind == graph.EdgeExtends && edge.From == child.ID { + extractedExtends = edge + break + } + } + if extractedExtends == nil { + t.Fatal("extractor did not emit Child extends edge") + } + if extractedExtends.To != "unresolved::extends::Parent" { + t.Fatalf("extracted extends target = %q, want unresolved::extends::Parent", extractedExtends.To) + } + + var extractedCall *graph.Edge + for _, edge := range result.Edges { + if edge.Kind == graph.EdgeCalls && edge.From == caller.ID && strings.HasSuffix(edge.To, ".p") { + extractedCall = edge + break + } + } + if extractedCall == nil { + var calls []string + for _, edge := range result.Edges { + if edge.Kind == graph.EdgeCalls { + calls = append(calls, edge.From+" -> "+edge.To) + } + } + t.Fatalf("extractor did not emit generic value.p() call edge; calls=%v", calls) + } + + g := graph.New() + g.AddBatch(result.Nodes, result.Edges) + if got := resolver.ResolveRustScopeCalls(g); got != 2 { + t.Fatalf("first resolution count = %d, want 2 (extends + call)", got) + } + + assertRustEdgeTarget(t, g.GetOutEdges(child.ID), graph.EdgeExtends, parent.ID) + assertRustEdgeSource(t, g.GetInEdges(parent.ID), graph.EdgeExtends, child.ID) + assertRustEdgeTarget(t, g.GetOutEdges(caller.ID), graph.EdgeCalls, parentMethod.ID) + + before := g.EdgeCount() + if got := resolver.ResolveRustScopeCalls(g); got != 0 { + t.Fatalf("second resolution count = %d, want 0", got) + } + if after := g.EdgeCount(); after != before { + t.Fatalf("second resolution changed edge count: before=%d after=%d", before, after) + } + assertRustEdgeTarget(t, g.GetOutEdges(child.ID), graph.EdgeExtends, parent.ID) + assertRustEdgeTarget(t, g.GetOutEdges(caller.ID), graph.EdgeCalls, parentMethod.ID) +} + +func TestRustDuplicateModuleTraitNamesStayIsolated(t *testing.T) { + t.Parallel() + + type fixture struct { + path string + src string + } + fixtures := []fixture{ + { + path: "src/a.rs", + src: `trait Parent { fn p(&self); } +trait Child: Parent {} +fn call_a(value: T) { value.p(); } +`, + }, + { + path: "src/b.rs", + src: `trait Parent { fn p(&self); } +trait Child: Parent {} +fn call_b(value: T) { value.p(); } +`, + }, + } + + g := graph.New() + for _, fixture := range fixtures { + result, err := languages.NewRustExtractor().Extract(fixture.path, []byte(fixture.src)) + if err != nil { + t.Fatalf("extract %s: %v", fixture.path, err) + } + if result.Tree != nil { + defer result.Tree.Close() + } + g.AddBatch(result.Nodes, result.Edges) + } + + var aParent, bParent, aChild, bChild, aMethod, bMethod, aCaller, bCaller *graph.Node + for _, node := range g.AllNodes() { + switch { + case node.FilePath == "src/a.rs" && node.Kind == graph.KindInterface && node.Name == "Parent": + aParent = node + case node.FilePath == "src/b.rs" && node.Kind == graph.KindInterface && node.Name == "Parent": + bParent = node + case node.FilePath == "src/a.rs" && node.Kind == graph.KindInterface && node.Name == "Child": + aChild = node + case node.FilePath == "src/b.rs" && node.Kind == graph.KindInterface && node.Name == "Child": + bChild = node + case node.FilePath == "src/a.rs" && node.Kind == graph.KindMethod && node.Name == "p": + aMethod = node + case node.FilePath == "src/b.rs" && node.Kind == graph.KindMethod && node.Name == "p": + bMethod = node + case node.FilePath == "src/a.rs" && node.Kind == graph.KindFunction && node.Name == "call_a": + aCaller = node + case node.FilePath == "src/b.rs" && node.Kind == graph.KindFunction && node.Name == "call_b": + bCaller = node + } + } + if aParent == nil || bParent == nil || aChild == nil || bChild == nil || aMethod == nil || bMethod == nil || aCaller == nil || bCaller == nil { + t.Fatalf("missing duplicate-module fixture symbols: aParent=%v bParent=%v aChild=%v bChild=%v aMethod=%v bMethod=%v aCaller=%v bCaller=%v", aParent, bParent, aChild, bChild, aMethod, bMethod, aCaller, bCaller) + } + + if got := resolver.ResolveRustScopeCalls(g); got != 4 { + t.Fatalf("duplicate-module resolution count = %d, want 4", got) + } + assertRustEdgeTarget(t, g.GetOutEdges(aChild.ID), graph.EdgeExtends, aParent.ID) + assertRustEdgeTarget(t, g.GetOutEdges(bChild.ID), graph.EdgeExtends, bParent.ID) + assertRustEdgeTarget(t, g.GetOutEdges(aCaller.ID), graph.EdgeCalls, aMethod.ID) + assertRustEdgeTarget(t, g.GetOutEdges(bCaller.ID), graph.EdgeCalls, bMethod.ID) + assertRustNoEdgeTarget(t, g.GetOutEdges(aCaller.ID), graph.EdgeCalls, bMethod.ID) + assertRustNoEdgeTarget(t, g.GetOutEdges(bCaller.ID), graph.EdgeCalls, aMethod.ID) + if got := resolver.ResolveRustScopeCalls(g); got != 0 { + t.Fatalf("duplicate-module second resolution count = %d, want 0", got) + } +} + +func TestRustExternalQualifiedBoundDoesNotBindLocalBasename(t *testing.T) { + t.Parallel() + + src := []byte(`trait Matcher { fn find(&self); } +fn call(value: T) { value.find(); } +`) + result, err := languages.NewRustExtractor().Extract("src/lib.rs", src) + if err != nil { + t.Fatalf("extract external-bound fixture: %v", err) + } + if result.Tree != nil { + defer result.Tree.Close() + } + var caller *graph.Node + for _, node := range result.Nodes { + if node.Kind == graph.KindFunction && node.Name == "call" { + caller = node + break + } + } + if caller == nil { + t.Fatal("missing external-bound caller") + } + var call *graph.Edge + for _, edge := range result.Edges { + if edge.Kind == graph.EdgeCalls && edge.From == caller.ID { + call = edge + break + } + } + if call == nil { + t.Fatal("missing external-bound call edge") + } + originalTarget := call.To + + g := graph.New() + g.AddBatch(result.Nodes, result.Edges) + if got := resolver.ResolveRustScopeCalls(g); got != 0 { + t.Fatalf("external-bound resolution count = %d, want 0", got) + } + if call.To != originalTarget || !graph.IsUnresolvedTarget(call.To) { + t.Fatalf("external::Matcher call resolved to local symbol: before=%q after=%q", originalTarget, call.To) + } +} + +func TestRustMarkerTraitExtendsResolvesAndCounts(t *testing.T) { + t.Parallel() + + parent := &graph.Node{ + ID: "src/lib.rs::Parent", Kind: graph.KindInterface, Name: "Parent", + FilePath: "src/lib.rs", Language: "rust", RepoPrefix: "repo", + } + child := &graph.Node{ + ID: "src/lib.rs::Child", Kind: graph.KindInterface, Name: "Child", + FilePath: "src/lib.rs", Language: "rust", RepoPrefix: "repo", + } + extends := &graph.Edge{ + From: child.ID, To: "unresolved::extends::Parent", Kind: graph.EdgeExtends, + FilePath: child.FilePath, Meta: map[string]any{"rust_trait_path": "Parent"}, + } + g := graph.New() + g.AddBatch([]*graph.Node{parent, child}, []*graph.Edge{extends}) + + if got := resolver.ResolveRustScopeCalls(g); got != 1 { + t.Fatalf("marker-trait resolution count = %d, want 1", got) + } + assertRustEdgeTarget(t, g.GetOutEdges(child.ID), graph.EdgeExtends, parent.ID) + assertRustEdgeSource(t, g.GetInEdges(parent.ID), graph.EdgeExtends, child.ID) + if got := resolver.ResolveRustScopeCalls(g); got != 0 { + t.Fatalf("marker-trait second resolution count = %d, want 0", got) + } +} + +func assertRustEdgeTarget(t *testing.T, edges []*graph.Edge, kind graph.EdgeKind, target string) { + t.Helper() + for _, edge := range edges { + if edge.Kind == kind && edge.To == target { + return + } + } + t.Fatalf("missing %s edge to %q in %#v", kind, target, edges) +} + +func assertRustNoEdgeTarget(t *testing.T, edges []*graph.Edge, kind graph.EdgeKind, target string) { + t.Helper() + for _, edge := range edges { + if edge.Kind == kind && edge.To == target { + t.Fatalf("unexpected %s edge to %q in %#v", kind, target, edges) + } + } +} + +func assertRustEdgeSource(t *testing.T, edges []*graph.Edge, kind graph.EdgeKind, source string) { + t.Helper() + for _, edge := range edges { + if edge.Kind == kind && edge.From == source { + return + } + } + t.Fatalf("missing %s edge from %q in %#v", kind, source, edges) +} diff --git a/internal/resolver/rust_generic_bounds_test.go b/internal/resolver/rust_generic_bounds_test.go new file mode 100644 index 000000000..3bbd2d524 --- /dev/null +++ b/internal/resolver/rust_generic_bounds_test.go @@ -0,0 +1,118 @@ +package resolver + +import ( + "testing" + + "github.com/zzet/gortex/internal/graph" +) + +func TestResolveRustScopeCallsGenericTraitBound(t *testing.T) { + tests := []struct { + name string + typeParams any + traits []string + want string + }{ + { + name: "inline bound", + typeParams: []map[string]string{{"name": "M", "bound": "Matcher"}}, + traits: []string{"Matcher"}, + want: "repo::matcher.rs::Matcher.is_match", + }, + { + name: "decoded metadata", + typeParams: []any{ + map[string]any{"name": "M", "bound": "Send + crate::Matcher"}, + }, + traits: []string{"Matcher"}, + want: "repo::matcher.rs::Matcher.is_match", + }, + { + name: "ambiguous trait bounds", + typeParams: []map[string]string{{"name": "M", "bound": "Matcher + OtherMatcher"}}, + traits: []string{"Matcher", "OtherMatcher"}, + want: "", + }, + { + name: "unconstrained generic", + typeParams: []map[string]string{{"name": "M"}}, + traits: []string{"Matcher"}, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g, call := rustGenericBoundFixture(tt.typeParams, tt.traits) + resolved := ResolveRustScopeCalls(g) + if tt.want == "" { + if resolved != 0 { + t.Fatalf("resolved = %d, want 0", resolved) + } + if !graph.IsUnresolvedTarget(call.To) { + t.Fatalf("ambiguous/unbounded call resolved to %q", call.To) + } + return + } + if resolved != 1 { + t.Fatalf("resolved = %d, want 1", resolved) + } + if call.To != tt.want { + t.Fatalf("call target = %q, want %q", call.To, tt.want) + } + if got, _ := call.Meta["rust_resolution"].(string); got != "generic_trait_bound" { + t.Fatalf("resolution reason = %q, want generic_trait_bound", got) + } + }) + } +} + +func TestResolveRustScopeCallsGenericTraitBoundDuplicateDeclaration(t *testing.T) { + g, call := rustGenericBoundFixture( + []map[string]string{{"name": "M", "bound": "Matcher"}}, + []string{"Matcher"}, + ) + g.AddNode(&graph.Node{ + ID: "repo::other.rs::Matcher.is_match", Kind: graph.KindMethod, Name: "is_match", + RepoPrefix: "repo", FilePath: "src/other.rs", Language: "rust", + Meta: map[string]any{"receiver": "Matcher", "trait_decl": "true"}, + }) + if resolved := ResolveRustScopeCalls(g); resolved != 0 { + t.Fatalf("resolved duplicate declaration count = %d, want 0", resolved) + } + if !graph.IsUnresolvedTarget(call.To) { + t.Fatalf("duplicate trait declarations resolved to %q", call.To) + } +} + +func rustGenericBoundFixture(typeParams any, traits []string) (*graph.Graph, *graph.Edge) { + g := graph.New() + for _, trait := range traits { + g.AddNode(&graph.Node{ + ID: "repo::" + traitFileName(trait) + "::" + trait + ".is_match", + Kind: graph.KindMethod, Name: "is_match", RepoPrefix: "repo", + FilePath: "src/" + traitFileName(trait), Language: "rust", + Meta: map[string]any{"receiver": trait, "trait_decl": "true"}, + }) + } + caller := &graph.Node{ + ID: "repo::src/run.rs::run", Kind: graph.KindFunction, Name: "run", + RepoPrefix: "repo", FilePath: "src/run.rs", Language: "rust", + Meta: map[string]any{"type_params": typeParams}, + } + g.AddNode(caller) + call := &graph.Edge{ + From: caller.ID, To: "unresolved::*.is_match", Kind: graph.EdgeCalls, + FilePath: caller.FilePath, Line: 3, + Meta: map[string]any{"receiver_type": "M"}, + } + g.AddEdge(call) + return g, call +} + +func traitFileName(trait string) string { + if trait == "Matcher" { + return "matcher.rs" + } + return "other_matcher.rs" +} diff --git a/internal/resolver/rust_module_imports.go b/internal/resolver/rust_module_imports.go index c77bb6679..6d2a53e20 100644 --- a/internal/resolver/rust_module_imports.go +++ b/internal/resolver/rust_module_imports.go @@ -56,6 +56,12 @@ func resolveRustModuleImports(g graph.Store) int { var reindexBatch []graph.EdgeReindex for _, e := range cands { raw := strings.TrimPrefix(e.To, "unresolved::import::") + if e.Meta == nil { + e.Meta = map[string]any{} + } + if _, exists := e.Meta["rust_use_path"]; !exists { + e.Meta["rust_use_path"] = normalizeRustUsePath(raw) + } target := resolveRustUseFile(e.From, strings.Split(raw, "/"), fileIDs) if target == "" || target == e.From { continue diff --git a/internal/resolver/rust_scope.go b/internal/resolver/rust_scope.go index 67a956464..c152f659a 100644 --- a/internal/resolver/rust_scope.go +++ b/internal/resolver/rust_scope.go @@ -53,13 +53,12 @@ import ( // matches, the pass skips the edge (zero false positives over breadth). // // Out of scope (left for the generic resolver, the cross-repo resolver, -// or future work): cross-repo Rust calls, trait-bound / generic-typed -// receivers, fully-qualified `::method` UFCS, and resolving -// a module path against a real module-tree node (only the trailing -// segment + locality is used today). +// or future work): cross-repo Rust calls, fully-qualified +// `::method` UFCS, and resolving a module path against a real +// module-tree node (only the trailing segment + locality is used today). // -// Returns the number of Rust call edges this pass landed on a concrete -// node. +// Returns the number of Rust module-import, supertrait, override, and call +// edges this pass landed on concrete nodes. func ResolveRustScopeCalls(g graph.Store) int { if g == nil { return 0 @@ -70,7 +69,8 @@ func ResolveRustScopeCalls(g graph.Store) int { // graph has no unresolved Rust call edges. bound := resolveRustModuleImports(g) - idx := buildRustScopeIndex(g) + idx, extendsResolved := buildRustScopeIndex(g) + bound += extendsResolved if idx == nil { return bound } @@ -244,6 +244,15 @@ func rustScopeEdgeCandidate(e *graph.Edge) bool { type rustScopeIndex struct { // methodsByOwner: (repo, ownerType) → method nodes of that type. methodsByOwner map[rustOwnerKey][]*graph.Node + // traitTargets resolves a bound path relative to its caller to a concrete + // trait node. traitMethodsByID keeps direct and inherited methods attached + // to that concrete identity so same-named traits in different modules do + // not cross-pollinate through a basename alias. + traitTargets *rustTraitTargetIndex + traitMethodsByID map[string][]*graph.Node + // typeAliases follows file-scoped use aliases and crate-visible re-export + // chains before owner and trait lookup. Ambiguous aliases fail closed. + typeAliases *rustTypeAliasIndex // freeFuncsByName: (repo, name) → free function nodes. freeFuncsByName map[rustNameKey][]*graph.Node // paramsByOwner: caller function/method ID → set of param names, @@ -275,12 +284,18 @@ type rustFieldKey struct { } // buildRustScopeIndex walks the graph once and indexes Rust method -// owners, free functions, and caller params. Returns nil when the graph -// has no Rust methods or functions (the pass is a no-op for non-Rust -// graphs). -func buildRustScopeIndex(g graph.Store) *rustScopeIndex { +// owners, free functions, and caller params. Supertrait edges are resolved +// before the method/function early-out; the returned count includes those +// rewrites even when the graph contains marker traits only. +func buildRustScopeIndex(g graph.Store) (*rustScopeIndex, int) { + typeAliases := newRustTypeAliasIndex(g) + traitTargets := newRustTraitTargetIndex(g) + extendsResolved := resolveRustTraitExtendsWithIndex(g, traitTargets, typeAliases) idx := &rustScopeIndex{ methodsByOwner: map[rustOwnerKey][]*graph.Node{}, + traitTargets: traitTargets, + traitMethodsByID: map[string][]*graph.Node{}, + typeAliases: typeAliases, freeFuncsByName: map[rustNameKey][]*graph.Node{}, paramsByOwner: map[string]map[string]struct{}{}, fieldTypesByOwner: map[rustFieldKey]string{}, @@ -290,6 +305,22 @@ func buildRustScopeIndex(g graph.Store) *rustScopeIndex { if n == nil || n.Language != "rust" { continue } + if n.Meta != nil { + if traitDecl, _ := n.Meta["trait_decl"].(string); traitDecl == "true" { + // Concrete trait identity is indexed after extends resolution; + // indexing a declaration under its basename here would merge + // same-named traits from different modules. Synthetic legacy + // graphs without interface nodes retain exact-owner lookup only. + if traitTargets == nil { + if owner := rustBaseTypeName(nodeReceiverType(n)); owner != "" { + key := rustOwnerKey{repo: n.RepoPrefix, owner: owner} + idx.methodsByOwner[key] = append(idx.methodsByOwner[key], n) + } + } + any = true + continue + } + } owner := nodeReceiverType(n) if owner == "" { continue @@ -314,7 +345,7 @@ func buildRustScopeIndex(g graph.Store) *rustScopeIndex { any = true } if !any { - return nil + return nil, extendsResolved } // Params are read lazily-but-once: index every Rust param by its // enclosing function/method ID for the shadow check. @@ -350,7 +381,8 @@ func buildRustScopeIndex(g graph.Store) *rustScopeIndex { field: n.Name, }] = rustBaseTypeName(ft) } - return idx + inheritRustTraitMethods(g, idx, traitTargets) + return idx, extendsResolved } // resolve returns the target node ID an unresolved Rust call edge should @@ -402,8 +434,26 @@ func (idx *rustScopeIndex) resolve(e *graph.Edge, caller *graph.Node) string { // method. if rt, _ := e.Meta["receiver_type"].(string); rt != "" { if name := selectorCallName(e.To); name != "" { - if id := idx.uniqueMethod(repo, rustBaseTypeName(rt), name); id != "" { - idx.set(graph.OriginASTResolved, 0.88, "receiver_type") + if id, aliased, valid := idx.ownerMethod(caller, rt, name); id != "" { + reason := "receiver_type" + if aliased { + reason = "receiver_type_alias" + } + idx.set(graph.OriginASTResolved, 0.88, reason) + return id + } else if !valid || aliased { + // An ambiguous alias, or a qualified alias whose destination did + // not match a graph owner, must not degrade to basename guessing. + return "" + } + // A parameter whose declared type is a generic (`matcher: M`) + // has no concrete method owner. If the enclosing function's + // effective type parameters constrain M to a trait that declares + // this method, bind to that trait declaration. The helper scans + // every bound and returns a target only when the declaration is + // unique across the full bound set. + if id := idx.uniqueGenericBoundTraitMethod(repo, caller, rustBaseTypeName(rt), name); id != "" { + idx.set(graph.OriginASTResolved, 0.86, "generic_trait_bound") return id } } @@ -435,11 +485,17 @@ func (idx *rustScopeIndex) resolve(e *graph.Edge, caller *graph.Node) string { case isRustTypeName(qualifier): // Type::method() — bind to a method whose owner type is the - // qualifier. The receiver type is named explicitly in source, so - // this is structurally resolved within that type. - if id := idx.uniqueMethod(repo, qualifier, last); id != "" { - idx.set(graph.OriginASTResolved, 0.9, "impl_owner") + // qualifier. File-local aliases and public re-export chains are + // canonicalised before owner lookup. + if id, aliased, valid := idx.ownerMethod(caller, qualifier, last); id != "" { + reason := "impl_owner" + if aliased { + reason = "impl_owner_alias" + } + idx.set(graph.OriginASTResolved, 0.9, reason) return id + } else if !valid || aliased { + return "" } // Ambiguous by type name alone — the same type name is defined in // more than one crate/module (e.g. grep::regex::RegexMatcherBuilder @@ -482,6 +538,38 @@ func (idx *rustScopeIndex) set(origin string, conf float64, reason string) { idx.lastReason = reason } +// ownerMethod resolves a possibly aliased Rust owner and returns its unique +// method. Qualified alias destinations are matched against file-path segments +// before any basename lookup, preventing an external or ambiguous alias from +// silently binding to an unrelated local type. +func (idx *rustScopeIndex) ownerMethod(caller *graph.Node, owner, name string) (id string, aliased, valid bool) { + if caller == nil || owner == "" || name == "" { + return "", false, false + } + ownerPath := rustBaseTypeName(owner) + if idx.typeAliases != nil { + var ok bool + ownerPath, aliased, ok = idx.typeAliases.resolve(caller, ownerPath) + if !ok || ownerPath == "" { + return "", aliased, false + } + } + canonicalOwner := rustUseBindingName(ownerPath) + if canonicalOwner == "" { + return "", aliased, false + } + if aliased && strings.Contains(ownerPath, "::") { + segments := strings.Split(ownerPath, "::") + if id := idx.methodByPathSegments(caller.RepoPrefix, canonicalOwner, name, segments); id != "" { + return id, true, true + } + // A qualified alias carries stronger evidence than a basename. If + // its path cannot identify a matching owner, fail closed. + return "", true, true + } + return idx.uniqueMethod(caller.RepoPrefix, canonicalOwner, name), aliased, true +} + // uniqueMethod returns the ID of the single method named `name` owned by // (repo, owner), or "" when there is no match or the choice is // ambiguous (more than one). @@ -516,7 +604,7 @@ func (idx *rustScopeIndex) methodByPathSegments(repo, owner, name string, segmen if seg == "" || seg == owner || seg == name { continue } - if strings.Contains(m.FilePath, "/"+seg+"/") { + if rustFileMatchesModuleSegment(m.FilePath, seg) { if hit != "" && hit != m.ID { return "" // more than one crate/module matched } @@ -528,6 +616,18 @@ func (idx *rustScopeIndex) methodByPathSegments(repo, owner, name string, segmen return hit } +func rustFileMatchesModuleSegment(filePath, segment string) bool { + filePath = strings.TrimSpace(strings.ReplaceAll(filePath, "\\", "/")) + segment = strings.Trim(strings.TrimSpace(segment), "/:") + if filePath == "" || segment == "" { + return false + } + return strings.Contains(filePath, "/"+segment+"/") || + strings.HasSuffix(filePath, "/"+segment+".rs") || + strings.HasSuffix(filePath, "/"+segment+"/mod.rs") || + filePath == segment+".rs" +} + // methodBySameCrate disambiguates a `Type::method` call that names no // disambiguating path segment by preferring the candidate defined in the // caller's own crate. Returns the ID only when exactly one candidate lives @@ -614,6 +714,64 @@ func (idx *rustScopeIndex) uniqueTraitMethod(repo, owner, name string) string { return hit } +// uniqueGenericBoundTraitMethod returns the single trait declaration method +// named by all bounds on generic parameter param. It deliberately scans the raw +// candidate lists rather than composing uniqueTraitMethod calls: a duplicated +// declaration for one bound must keep the call unresolved even if another bound +// happens to have one unique declaration. +func (idx *rustScopeIndex) uniqueGenericBoundTraitMethod(repo string, caller *graph.Node, param, name string) string { + if caller == nil || caller.Meta == nil || param == "" || name == "" { + return "" + } + owners := rustTypeParamTraitNames(caller.Meta["type_params"], param) + if len(owners) == 0 { + return "" + } + var hit string + for _, owner := range owners { + var methods []*graph.Node + if idx.typeAliases != nil { + resolvedOwner, _, ok := idx.typeAliases.resolve(caller, owner) + if !ok { + return "" + } + owner = resolvedOwner + } + if idx.traitTargets != nil { + traitID := idx.traitTargets.resolve(caller, owner) + if traitID == "" { + // A qualified path may name an external trait. Never degrade it + // to a same-named local basename merely because one exists. + continue + } + methods = idx.traitMethodsByID[traitID] + } else { + // Compatibility for small synthetic indexes that predate concrete + // trait nodes: use only the exact parsed bound key. An explicit + // crate:: root is proven local, so its crate-relative exact key is + // also safe; arbitrary qualified paths never degrade to basename. + methods = idx.methodsByOwner[rustOwnerKey{repo: repo, owner: owner}] + if len(methods) == 0 && strings.HasPrefix(owner, "crate::") { + crateRelative := strings.TrimPrefix(owner, "crate::") + methods = idx.methodsByOwner[rustOwnerKey{repo: repo, owner: crateRelative}] + } + } + for _, method := range methods { + if method == nil || method.Name != name || method.Meta == nil { + continue + } + if traitDecl, _ := method.Meta["trait_decl"].(string); traitDecl != "true" { + continue + } + if hit != "" && hit != method.ID { + return "" + } + hit = method.ID + } + } + return hit +} + // uniqueFreeFunc returns the ID of a free function named `name` in repo, // preferring a same-file candidate, then a same-directory candidate, // then a unique candidate overall. Returns "" when nothing matches or diff --git a/internal/resolver/terminal.go b/internal/resolver/terminal.go index 0fb3403d7..515f85f4a 100644 --- a/internal/resolver/terminal.go +++ b/internal/resolver/terminal.go @@ -227,16 +227,37 @@ func terminalEdgeAnchoredToScope(e *graph.Edge, scope map[string]struct{}) bool // reconcileTerminalStampsBulk's doc for why the bulk path can skip fromLang / // language-family entirely without changing the outcome. func (r *Resolver) reconcileTerminalStamps() (stamped, unstamped int) { + return r.reconcileTerminalStampsExcluding(nil) +} + +// reconcileTerminalStampsExcluding applies the normal full-pass terminal +// classification except for deferred LSP edges the pass budget did not let us +// query. Those skips carry no terminality evidence and must remain retryable. +func (r *Resolver) reconcileTerminalStampsExcluding(excluded map[deferredLSPEdgeKey]struct{}) (stamped, unstamped int) { var stillPending []*graph.Edge + var changed []*graph.Edge for e := range r.graph.EdgesWithUnresolvedTarget() { - if e != nil { - stillPending = append(stillPending, e) + if e == nil { + continue + } + if _, skip := excluded[deferredLSPKey(e)]; skip { + // A pass-budget skip is not evidence that the edge is terminal. + // Clear an older stamp so a later scoped pass may retry LSP. + if edgeTerminalFlag(e) { + clearEdgeTerminal(e) + changed = append(changed, e) + unstamped++ + } + continue } + stillPending = append(stillPending, e) } - var changed []*graph.Edge if counter, ok := r.graph.(graph.NodeNameClassCounter); ok { - changed, stamped, unstamped = r.reconcileTerminalStampsBulk(stillPending, counter) + bulkChanged, bulkStamped, bulkUnstamped := r.reconcileTerminalStampsBulk(stillPending, counter) + changed = append(changed, bulkChanged...) + stamped += bulkStamped + unstamped += bulkUnstamped } else { for _, e := range stillPending { reason, terminal := r.classifyTerminal(e) diff --git a/internal/runtimeactivity/activity.go b/internal/runtimeactivity/activity.go new file mode 100644 index 000000000..22393ded5 --- /dev/null +++ b/internal/runtimeactivity/activity.go @@ -0,0 +1,216 @@ +// Package runtimeactivity coordinates process-wide work with expensive runtime +// maintenance such as debug.FreeOSMemory. The daemon, MCP handlers, and +// background indexers share one tracker because a process-wide GC cannot be +// made safe by guarding only one subsystem. +package runtimeactivity + +import ( + "sync" + "sync/atomic" + "time" +) + +// Snapshot is a point-in-time view of process work. ByKind is a defensive copy +// and can be inspected without holding the tracker lock. +type Snapshot struct { + Active int64 + Epoch uint64 + LastTransition time.Time + ByKind map[string]int64 +} + +// QuietFor reports how long no tracked work transition has occurred. It is +// zero while work is active or when the snapshot predates tracker startup. +func (s Snapshot) QuietFor(now time.Time) time.Duration { + if s.Active != 0 || s.LastTransition.IsZero() || now.Before(s.LastTransition) { + return 0 + } + return now.Sub(s.LastTransition) +} + +// Tracker closes the check-to-maintenance race with gate: Begin and End take a +// read lock while a maintenance callback takes the write lock. Once RunIfQuiet +// admits a callback, no new tracked work can start until it returns. +type Tracker struct { + gate sync.RWMutex + + active atomic.Int64 + epoch atomic.Uint64 + lastNano atomic.Int64 + + kindsMu sync.Mutex + byKind map[string]int64 + + hooksMu sync.RWMutex + hooks []func(string) +} + +// NewTracker returns an independent tracker. Most production code uses the +// process-wide package functions below; the constructor exists for isolated +// policy and race tests. +func NewTracker() *Tracker { + t := &Tracker{byKind: make(map[string]int64)} + t.lastNano.Store(time.Now().UnixNano()) + return t +} + +// Begin marks one unit of work active. kind should be a bounded subsystem name, +// not a path or user-provided value. +func (t *Tracker) Begin(kind string) { + if t == nil { + return + } + kind = normalizeKind(kind) + t.gate.RLock() + t.active.Add(1) + t.epoch.Add(1) + t.lastNano.Store(time.Now().UnixNano()) + t.kindsMu.Lock() + if t.byKind == nil { + t.byKind = make(map[string]int64) + } + t.byKind[kind]++ + t.kindsMu.Unlock() + t.gate.RUnlock() +} + +// End balances Begin. A defensive underflow repair prevents a caller bug from +// permanently disabling reclamation; per-kind counts are repaired likewise. +func (t *Tracker) End(kind string) { + if t == nil { + return + } + kind = normalizeKind(kind) + t.gate.RLock() + remaining := t.active.Add(-1) + if remaining < 0 { + t.active.Store(0) + remaining = 0 + } + t.epoch.Add(1) + t.lastNano.Store(time.Now().UnixNano()) + t.kindsMu.Lock() + if n := t.byKind[kind]; n > 1 { + t.byKind[kind] = n - 1 + } else { + delete(t.byKind, kind) + } + t.kindsMu.Unlock() + t.gate.RUnlock() + + if remaining == 0 { + t.notifyIdle(kind) + } +} + +// Snapshot returns process activity without blocking a running maintenance +// callback for longer than the small map copy. +func (t *Tracker) Snapshot() Snapshot { + if t == nil { + return Snapshot{} + } + s := Snapshot{ + Active: t.active.Load(), + Epoch: t.epoch.Load(), + } + if n := t.lastNano.Load(); n > 0 { + s.LastTransition = time.Unix(0, n) + } + t.kindsMu.Lock() + if len(t.byKind) > 0 { + s.ByKind = make(map[string]int64, len(t.byKind)) + for kind, count := range t.byKind { + s.ByKind[kind] = count + } + } + t.kindsMu.Unlock() + return s +} + +// RunIfQuiet runs fn only when there is no active work and the last activity +// transition is at least quiet ago. retryAfter is the earliest useful retry. +// The callback runs behind the exclusive gate, so Begin cannot race between +// the idle check and a stop-the-world runtime operation. +func (t *Tracker) RunIfQuiet(quiet time.Duration, fn func()) (ran bool, retryAfter time.Duration) { + if t == nil { + return false, quiet + } + if quiet < 0 { + quiet = 0 + } + t.gate.Lock() + defer t.gate.Unlock() + + if t.active.Load() != 0 { + return false, quiet + } + last := time.Unix(0, t.lastNano.Load()) + if remaining := quiet - time.Since(last); remaining > 0 { + return false, remaining + } + if fn != nil { + fn() + } + return true, 0 +} + +// RegisterIdleHook registers a process-lifetime callback invoked after tracked +// activity transitions to zero. Hooks must return quickly; schedulers should +// launch their expensive work asynchronously. The returned function unregisters +// the hook and is primarily useful to tests. +func (t *Tracker) RegisterIdleHook(hook func(string)) func() { + if t == nil || hook == nil { + return func() {} + } + t.hooksMu.Lock() + t.hooks = append(t.hooks, hook) + idx := len(t.hooks) - 1 + t.hooksMu.Unlock() + var once sync.Once + return func() { + once.Do(func() { + t.hooksMu.Lock() + if idx < len(t.hooks) { + t.hooks[idx] = nil + } + t.hooksMu.Unlock() + }) + } +} + +func (t *Tracker) notifyIdle(kind string) { + t.hooksMu.RLock() + hooks := append([]func(string){}, t.hooks...) + t.hooksMu.RUnlock() + for _, hook := range hooks { + if hook != nil { + hook(kind) + } + } +} + +func normalizeKind(kind string) string { + if kind == "" { + return "unspecified" + } + return kind +} + +var process = NewTracker() + +// Begin marks process-wide work active. +func Begin(kind string) { process.Begin(kind) } + +// End balances a process-wide Begin. +func End(kind string) { process.End(kind) } + +// Current returns a process-wide activity snapshot. +func Current() Snapshot { return process.Snapshot() } + +// RunIfQuiet executes fn behind the process-wide exclusive gate. +func RunIfQuiet(quiet time.Duration, fn func()) (bool, time.Duration) { + return process.RunIfQuiet(quiet, fn) +} + +// RegisterIdleHook registers a process-wide idle-transition callback. +func RegisterIdleHook(hook func(string)) func() { return process.RegisterIdleHook(hook) } diff --git a/internal/runtimeactivity/activity_test.go b/internal/runtimeactivity/activity_test.go new file mode 100644 index 000000000..683cb0881 --- /dev/null +++ b/internal/runtimeactivity/activity_test.go @@ -0,0 +1,131 @@ +package runtimeactivity + +import ( + "sync" + "sync/atomic" + "testing" + "time" +) + +func TestTrackerRunIfQuietRejectsActiveAndRecentWork(t *testing.T) { + tracker := NewTracker() + tracker.Begin("watcher") + if ran, retry := tracker.RunIfQuiet(0, func() { t.Fatal("ran while active") }); ran || retry != 0 { + t.Fatalf("active RunIfQuiet = (%v, %v), want (false, 0)", ran, retry) + } + tracker.End("watcher") + + const quiet = 20 * time.Millisecond + if ran, retry := tracker.RunIfQuiet(quiet, nil); ran || retry <= 0 || retry > quiet { + t.Fatalf("recent RunIfQuiet = (%v, %v), want bounded retry", ran, retry) + } + time.Sleep(quiet + 5*time.Millisecond) + called := false + if ran, retry := tracker.RunIfQuiet(quiet, func() { called = true }); !ran || retry != 0 || !called { + t.Fatalf("quiet RunIfQuiet = (%v, %v, called=%v), want (true, 0, true)", ran, retry, called) + } +} + +func TestTrackerExclusiveMaintenanceBlocksNewWork(t *testing.T) { + tracker := NewTracker() + entered := make(chan struct{}) + release := make(chan struct{}) + maintenanceDone := make(chan struct{}) + go func() { + defer close(maintenanceDone) + ran, _ := tracker.RunIfQuiet(0, func() { + close(entered) + <-release + }) + if !ran { + t.Error("maintenance did not run") + } + }() + select { + case <-entered: + case <-time.After(time.Second): + t.Fatal("maintenance did not enter") + } + + workStarted := make(chan struct{}) + go func() { + tracker.Begin("mcp") + close(workStarted) + tracker.End("mcp") + }() + select { + case <-workStarted: + t.Fatal("work started during exclusive maintenance") + case <-time.After(25 * time.Millisecond): + } + close(release) + select { + case <-maintenanceDone: + case <-time.After(time.Second): + t.Fatal("maintenance did not finish") + } + select { + case <-workStarted: + case <-time.After(time.Second): + t.Fatal("work remained blocked after maintenance") + } +} + +func TestTrackerIdleHookCoversLostWakeupTransition(t *testing.T) { + tracker := NewTracker() + var calls atomic.Int64 + called := make(chan struct{}, 2) + unregister := tracker.RegisterIdleHook(func(kind string) { + if kind != "analysis" { + t.Errorf("idle kind = %q, want analysis", kind) + } + calls.Add(1) + called <- struct{}{} + }) + defer unregister() + + tracker.Begin("analysis") + tracker.End("analysis") + select { + case <-called: + case <-time.After(time.Second): + t.Fatal("idle hook was lost") + } + tracker.Begin("analysis") + tracker.End("analysis") + select { + case <-called: + case <-time.After(time.Second): + t.Fatal("second idle hook was lost") + } + if got := calls.Load(); got != 2 { + t.Fatalf("idle hook calls = %d, want 2", got) + } +} + +func TestTrackerConcurrentKindsBalance(t *testing.T) { + tracker := NewTracker() + const workers = 32 + var wg sync.WaitGroup + wg.Add(workers) + for i := range workers { + go func() { + defer wg.Done() + kind := "watcher" + if i%2 == 0 { + kind = "mcp" + } + tracker.Begin(kind) + time.Sleep(time.Millisecond) + tracker.End(kind) + }() + } + wg.Wait() + snapshot := tracker.Snapshot() + if snapshot.Active != 0 || len(snapshot.ByKind) != 0 { + t.Fatalf("unbalanced snapshot: %+v", snapshot) + } + if snapshot.Epoch != workers*2 { + t.Fatalf("epoch = %d, want %d", snapshot.Epoch, workers*2) + } +} diff --git a/internal/search/auto_concepts.go b/internal/search/auto_concepts.go index 31c69f196..3fe60835e 100644 --- a/internal/search/auto_concepts.go +++ b/internal/search/auto_concepts.go @@ -81,7 +81,7 @@ func BuildAutoConcepts(g graph.Reader) *AutoConcepts { // the per-symbol token sets for the pair-counting pass. docFreq := map[string]int{} var docs [][]string - for _, n := range g.AllNodes() { + for _, n := range graph.AllNodesLight(g) { if !autoConceptEligible(n.Kind) { continue } diff --git a/internal/search/auto_concepts_light_test.go b/internal/search/auto_concepts_light_test.go new file mode 100644 index 000000000..2f71a7aaa --- /dev/null +++ b/internal/search/auto_concepts_light_test.go @@ -0,0 +1,36 @@ +package search + +import ( + "testing" + + "github.com/zzet/gortex/internal/graph" +) + +type lightOnlyReader struct { + graph.Reader + lightCalls int +} + +func (r *lightOnlyReader) AllNodes() []*graph.Node { + panic("auto concepts used the full node scan") +} + +func (r *lightOnlyReader) AllNodesLight() []*graph.Node { + r.lightCalls++ + return r.Reader.AllNodes() +} + +func TestBuildAutoConceptsUsesLightNodeProjection(t *testing.T) { + base := graph.New() + base.AddNode(&graph.Node{ID: "repo/a.go::ParseRequest", Kind: graph.KindFunction, Name: "ParseRequest"}) + base.AddNode(&graph.Node{ID: "repo/b.go::ParseResponse", Kind: graph.KindFunction, Name: "ParseResponse"}) + + reader := &lightOnlyReader{Reader: base} + concepts := BuildAutoConcepts(reader) + if !concepts.InVocabulary("parse") { + t.Fatal("light projection lost symbol names used by auto concepts") + } + if reader.lightCalls != 1 { + t.Fatalf("light node scan calls = %d, want 1", reader.lightCalls) + } +} diff --git a/internal/search/auto_concepts_persistence.go b/internal/search/auto_concepts_persistence.go new file mode 100644 index 000000000..b47aa14cb --- /dev/null +++ b/internal/search/auto_concepts_persistence.go @@ -0,0 +1,38 @@ +package search + +import "sort" + +// AutoConceptsPersistenceSnapshot is the serializable form of the mined +// vocabulary. The maps returned by PersistenceSnapshot alias the immutable +// live structure and must be treated as read-only. +type AutoConceptsPersistenceSnapshot struct { + Related map[string][]string + Vocab []string +} + +func (a *AutoConcepts) PersistenceSnapshot() AutoConceptsPersistenceSnapshot { + if a == nil { + return AutoConceptsPersistenceSnapshot{} + } + vocab := make([]string, 0, len(a.vocab)) + for token := range a.vocab { + vocab = append(vocab, token) + } + sort.Strings(vocab) + return AutoConceptsPersistenceSnapshot{Related: a.related, Vocab: vocab} +} + +// RestoreAutoConcepts takes ownership of snapshot.Related and rebuilds the +// set-shaped vocabulary without touching the graph. +func RestoreAutoConcepts(snapshot AutoConceptsPersistenceSnapshot) *AutoConcepts { + if snapshot.Related == nil { + snapshot.Related = map[string][]string{} + } + vocab := make(map[string]struct{}, len(snapshot.Vocab)) + for _, token := range snapshot.Vocab { + if token != "" { + vocab[token] = struct{}{} + } + } + return &AutoConcepts{related: snapshot.Related, vocab: vocab} +} diff --git a/internal/search/rerank/context.go b/internal/search/rerank/context.go index f8d975918..466ee4dfa 100644 --- a/internal/search/rerank/context.go +++ b/internal/search/rerank/context.go @@ -8,6 +8,26 @@ import ( "github.com/zzet/gortex/internal/graph" ) +// AnalysisMetric is the bounded per-candidate analysis state consumed by rerank. +// Values are already normalized where appropriate so callers can load one batch +// without exposing or retaining whole-graph analysis maps. +type AnalysisMetric struct { + CommunityID string + Authority float64 + Hub float64 +} + +// CentralityResult carries scores plus the bounded-walk receipt. Truncated +// results are lower bounds and are confidence-damped before scoring. +type CentralityResult struct { + Scores map[string]float64 + NodeCount int + EdgeCount int + NodeBatches int + EdgeBatches int + Truncated bool +} + // Context bundles the read-only data signals need at scoring time. // All fields are optional; signals must gracefully degrade when a // data source is absent. The zero value is a valid Context. @@ -55,6 +75,12 @@ type Context struct { // nil, the community signal contributes 0. CommunityOf func(nodeID string) string + // AnalysisMetricsOf loads community and normalized HITS values for the + // current candidate batch. prepare calls it exactly once per distinct + // candidate slice; it replaces whole-graph materialization on bounded + // search paths. Missing rows fall back to CommunityOf/AuthorityOf/HubOf. + AnalysisMetricsOf func(nodeIDs []string) map[string]AnalysisMetric + // RepoPrefix and ProjectID name the session's home repo and // project. Used by the community signal to score candidates by // locality. Both empty disables the locality side of the signal. @@ -115,6 +141,11 @@ type Context struct { // them to [0,1] against the batch maximum. Centrality func(seeds []string) map[string]float64 + // BatchedCentrality is the bounded interactive variant. It receives the + // complete candidate IDs so callers can build a capped neighborhood in a + // fixed number of batch reads. When set it takes precedence over Centrality. + BatchedCentrality func(seeds, candidateIDs []string) CentralityResult + // CentralitySeedCount caps how many top candidates seed the RWR // walk. 0 means use defaultCentralitySeeds. CentralitySeedCount int @@ -139,7 +170,12 @@ type Context struct { // proximity to the query seeds, normalised to [0,1] against the // batch maximum. Populated by prepare() when Centrality is wired; // read by ProximitySignal. Nil when no centrality provider is set. - centralityScores map[string]float64 + centralityScores map[string]float64 + centralityReceipt CentralityResult + + // analysisMetrics is request-scoped state loaded in one batch by + // AnalysisMetricsOf. It is reset for each Prepare call. + analysisMetrics map[string]AnalysisMetric // communityCount maps community ID → number of candidates in that // community. Used by the community signal to detect topic clusters. @@ -280,6 +316,9 @@ func (c *Context) SeedEdgeCaches(inEdges, outEdges map[string][]*graph.Edge, pre // in its debug log without grepping internal state. func (c *Context) CachePreSeeded() bool { return c.cachePreSeeded } +// CentralityTelemetry returns the receipt from the most recent Prepare call. +func (c *Context) CentralityTelemetry() CentralityResult { return c.centralityReceipt } + // InheritEdgeCacheFrom shares the source context's edge caches + // cachePreSeeded flag onto c. Used by the engine to give per-call // inner reranks access to the handler-built bundle cache without @@ -369,8 +408,8 @@ func (c *Context) prepare(cands []*Candidate) { c.inEdgeCache = nil } - // First pass: collect candidate IDs (the input to the batched edge - // fetch) and populate the non-edge scratch fields. + // Collect candidate IDs before scoring so analysis-backed signals can load + // their rows in one bounded query instead of one lookup per candidate. ids := make([]string, 0, len(cands)) for _, cand := range cands { if cand == nil || cand.Node == nil { @@ -378,18 +417,25 @@ func (c *Context) prepare(cands []*Candidate) { } c.candidateIDs[cand.Node.ID] = struct{}{} ids = append(ids, cand.Node.ID) + } + c.analysisMetrics = nil + if c.AnalysisMetricsOf != nil && len(ids) > 0 { + c.analysisMetrics = c.AnalysisMetricsOf(ids) + } + // Populate the non-edge scratch fields from the candidate batch. + for _, cand := range cands { + if cand == nil || cand.Node == nil { + continue + } if nm := strings.ToLower(cand.Node.Name); nm != "" { c.nameGroupCount[nm]++ } - if c.CommunityOf != nil { - com := c.CommunityOf(cand.Node.ID) - if com != "" { - c.communityCount[com]++ - if c.communityCount[com] > c.maxCommunityCount { - c.maxCommunityCount = c.communityCount[com] - } + if com := c.communityFor(cand.Node.ID); com != "" { + c.communityCount[com]++ + if c.communityCount[com] > c.maxCommunityCount { + c.maxCommunityCount = c.communityCount[com] } } @@ -470,19 +516,63 @@ func (c *Context) prepare(cands []*Candidate) { c.computeCentrality(cands) } +func (c *Context) communityFor(nodeID string) string { + if metric, ok := c.analysisMetrics[nodeID]; ok && metric.CommunityID != "" { + return metric.CommunityID + } + if c.CommunityOf != nil { + return c.CommunityOf(nodeID) + } + return "" +} + +func (c *Context) authorityFor(nodeID string) (float64, bool) { + if metric, ok := c.analysisMetrics[nodeID]; ok { + return metric.Authority, true + } + if c.AuthorityOf != nil { + return c.AuthorityOf(nodeID), true + } + return 0, false +} + +func (c *Context) hubFor(nodeID string) (float64, bool) { + if metric, ok := c.analysisMetrics[nodeID]; ok { + return metric.Hub, true + } + if c.HubOf != nil { + return c.HubOf(nodeID), true + } + return 0, false +} + // computeCentrality runs the RWR walk from the batch's strongest seeds // and stores per-candidate proximity normalised to [0,1]. No-op when // Centrality is nil or the walk returns nothing. func (c *Context) computeCentrality(cands []*Candidate) { c.centralityScores = nil - if c.Centrality == nil || len(cands) == 0 { + c.centralityReceipt = CentralityResult{} + if (c.BatchedCentrality == nil && c.Centrality == nil) || len(cands) == 0 { return } seeds := selectCentralitySeeds(cands, c.CentralitySeedCount) if len(seeds) == 0 { return } - raw := c.Centrality(seeds) + var raw map[string]float64 + if c.BatchedCentrality != nil { + candidateIDs := make([]string, 0, len(cands)) + for _, cand := range cands { + if cand != nil && cand.Node != nil { + candidateIDs = append(candidateIDs, cand.Node.ID) + } + } + c.centralityReceipt = c.BatchedCentrality(seeds, candidateIDs) + raw = c.centralityReceipt.Scores + c.centralityReceipt.Scores = nil + } else { + raw = c.Centrality(seeds) + } if len(raw) == 0 { return } diff --git a/internal/search/rerank/context_analysis_test.go b/internal/search/rerank/context_analysis_test.go new file mode 100644 index 000000000..03996e5de --- /dev/null +++ b/internal/search/rerank/context_analysis_test.go @@ -0,0 +1,64 @@ +package rerank + +import ( + "math" + "reflect" + "testing" + + "github.com/zzet/gortex/internal/graph" +) + +func TestContextLoadsAnalysisMetricsOncePerCandidateBatch(t *testing.T) { + candidates := []*Candidate{ + {Node: &graph.Node{ID: "b", Name: "B"}}, + {Node: &graph.Node{ID: "a", Name: "A"}}, + } + calls := 0 + ctx := &Context{ + AnalysisMetricsOf: func(ids []string) map[string]AnalysisMetric { + calls++ + if !reflect.DeepEqual(ids, []string{"b", "a"}) { + t.Fatalf("candidate order changed: %v", ids) + } + return map[string]AnalysisMetric{ + "a": {CommunityID: "community-1", Authority: 0.8, Hub: 0.2}, + "b": {CommunityID: "community-1", Authority: 0.4, Hub: 0.1}, + } + }, + CommunityOf: func(string) string { t.Fatal("whole-map community fallback called"); return "" }, + AuthorityOf: func(string) float64 { t.Fatal("whole-map HITS fallback called"); return 0 }, + HubOf: func(string) float64 { t.Fatal("whole-map HITS fallback called"); return 0 }, + } + ctx.Prepare(candidates) + if calls != 1 { + t.Fatalf("analysis provider calls=%d want=1", calls) + } + if got := ctx.communityCount["community-1"]; got != 2 { + t.Fatalf("community count=%d want=2", got) + } + got := (HITSSignal{}).Contribute("", candidates[1], ctx) + want := 0.8 / 1.2 + if math.Abs(got-want) > 1e-12 { + t.Fatalf("HITS contribution=%g want=%g", got, want) + } +} + +func TestContextExposesTruncatedCentralityWithoutSilentDamping(t *testing.T) { + candidate := &Candidate{Node: &graph.Node{ID: "a", Name: "A"}} + ctx := &Context{ + BatchedCentrality: func(seeds, candidates []string) CentralityResult { + return CentralityResult{ + Scores: map[string]float64{"a": 0.75}, NodeCount: 7, EdgeCount: 9, + NodeBatches: 2, EdgeBatches: 1, Truncated: true, + } + }, + } + ctx.Prepare([]*Candidate{candidate}) + receipt := ctx.CentralityTelemetry() + if !receipt.Truncated || receipt.NodeCount != 7 || receipt.EdgeCount != 9 || receipt.Scores != nil { + t.Fatalf("unexpected centrality receipt: %+v", receipt) + } + if got := (ProximitySignal{}).Contribute("", candidate, ctx); got != 1 { + t.Fatalf("bounded centrality was silently damped: got=%g want=1", got) + } +} diff --git a/internal/search/rerank/signals_hits.go b/internal/search/rerank/signals_hits.go index 1a105da57..eac3b8287 100644 --- a/internal/search/rerank/signals_hits.go +++ b/internal/search/rerank/signals_hits.go @@ -23,17 +23,14 @@ type HITSSignal struct{} func (HITSSignal) Name() string { return SignalHITS } func (HITSSignal) Contribute(_ string, c *Candidate, ctx *Context) float64 { - if ctx == nil || ctx.AuthorityOf == nil || c == nil || c.Node == nil { + if ctx == nil || c == nil || c.Node == nil { return 0 } - authority := ctx.AuthorityOf(c.Node.ID) - if authority <= 0 { + authority, available := ctx.authorityFor(c.Node.ID) + if !available || authority <= 0 { return 0 } - var hub float64 - if ctx.HubOf != nil { - hub = ctx.HubOf(c.Node.ID) - } + hub, _ := ctx.hubFor(c.Node.ID) // authority is already normalised into [0, 1] by buildRerankContext; // hub likewise. The hub penalty divides by (1 + hub), so a pure // authority (hub == 0) keeps its full score while a strong hub diff --git a/internal/search/rerank/signals_path_penalty.go b/internal/search/rerank/signals_path_penalty.go index af5bc9105..dee2a5b08 100644 --- a/internal/search/rerank/signals_path_penalty.go +++ b/internal/search/rerank/signals_path_penalty.go @@ -9,7 +9,7 @@ import ( ) // PathPenaltySignal applies a multiplicative penalty to candidates -// whose file path falls into one of six "supporting cast" buckets — +// whose file path falls into one of seven "supporting cast" buckets — // test files, compatibility shims, examples, type declarations, // re-export barrels, and generated files that shadow a real // implementation. The intuition: when an agent asks for the @@ -34,8 +34,10 @@ import ( // - Examples → 0.5 (demo code; useful but never the truth) // - Type declarations → 0.7 (`.d.ts`, `.pyi`, `.h` headers — the // interface, not the implementation) -// - Re-export barrels → 0.7 (`index.ts`, `__init__.py`, `mod.rs`, -// `lib.rs` — a forwarding hop, not the source) +// - Re-export barrels → 0.7 (`index.js`, `__init__.py` — normally a +// forwarding hop, not the source) +// - Module entries → 0.9 (`index.ts[x]`, `mod.rs`, `lib.rs` — often +// both a public API surface and real implementation) // - Generated files → 0.4 (`foo.pb.go`, `mock_x.go`, `x_pb2.py` — // ONLY when a real same-named hand-written peer exists in the // graph; a generated file that is the sole definition is left at @@ -113,9 +115,10 @@ const ( PathPenaltyGenerated = 0.4 PathPenaltyCompat = 0.5 PathPenaltyExamples = 0.5 - PathPenaltyTypeDecl = 0.7 - PathPenaltyReexport = 0.7 - PathPenaltyUncatched = 1.0 + PathPenaltyTypeDecl = 0.7 + PathPenaltyReexport = 0.7 + PathPenaltyModuleEntry = 0.9 + PathPenaltyUncatched = 1.0 ) // Pre-compiled patterns. Built at package init so the rubric stays @@ -182,18 +185,24 @@ var ( // in include/ or directly named like a type-only declaration. pathRETypeDecl = regexp.MustCompile(`(?i)\.(d\.ts|d\.cts|d\.mts|pyi|hpp|hxx|hh)$|(^|/)include/.*\.h$`) - // Re-export filenames — barrels that just forward symbols from - // other modules. The canonical names across ecosystems. - reexportNames = map[string]struct{}{ - "index.js": {}, - "index.jsx": {}, + // Module-entry filenames are commonly both implementation surfaces and + // re-export hubs. Keep a mild tie-breaker so canonical leaf definitions + // still win, but do not bury Rust crate/module roots or TypeScript barrels. + moduleEntryNames = map[string]struct{}{ "index.ts": {}, "index.tsx": {}, - "index.mjs": {}, - "index.cjs": {}, + "mod.rs": {}, + "lib.rs": {}, + } + + // Generic re-export filenames — barrels that normally only forward + // symbols from other modules. These retain the stronger demotion. + reexportNames = map[string]struct{}{ + "index.js": {}, + "index.jsx": {}, + "index.mjs": {}, + "index.cjs": {}, "__init__.py": {}, - "mod.rs": {}, - "lib.rs": {}, } ) @@ -264,8 +273,11 @@ func classifyPathPenalty(fp string) float64 { if pathRETypeDecl.MatchString(norm) { return PathPenaltyTypeDecl } - base := path.Base(norm) - if _, ok := reexportNames[strings.ToLower(base)]; ok { + base := strings.ToLower(path.Base(norm)) + if _, ok := moduleEntryNames[base]; ok { + return PathPenaltyModuleEntry + } + if _, ok := reexportNames[base]; ok { return PathPenaltyReexport } return PathPenaltyUncatched diff --git a/internal/search/rerank/signals_path_penalty_test.go b/internal/search/rerank/signals_path_penalty_test.go index 2580caa98..3f6ff3e5d 100644 --- a/internal/search/rerank/signals_path_penalty_test.go +++ b/internal/search/rerank/signals_path_penalty_test.go @@ -63,13 +63,15 @@ func TestClassifyPathPenalty_Tiers(t *testing.T) { {"include header", "src/include/foo.h", PathPenaltyTypeDecl}, {"cpp header .hpp", "core.hpp", PathPenaltyTypeDecl}, - // --- Re-export barrel tier (×0.7) --- + // --- Generic re-export barrel tier (×0.7) --- {"js index", "src/index.js", PathPenaltyReexport}, - {"ts index", "src/index.ts", PathPenaltyReexport}, - {"tsx index", "src/Layout/index.tsx", PathPenaltyReexport}, {"py __init__", "pkg/__init__.py", PathPenaltyReexport}, - {"rust mod", "src/auth/mod.rs", PathPenaltyReexport}, - {"rust lib", "src/lib.rs", PathPenaltyReexport}, + + // --- Rust / TypeScript module-entry tier (×0.9) --- + {"ts index", "src/index.ts", PathPenaltyModuleEntry}, + {"tsx index", "src/Layout/index.tsx", PathPenaltyModuleEntry}, + {"rust mod", "src/auth/mod.rs", PathPenaltyModuleEntry}, + {"rust lib", "src/lib.rs", PathPenaltyModuleEntry}, // --- Uncatched / neutral (×1.0) --- {"plain go", "auth/token.go", PathPenaltyUncatched}, @@ -97,11 +99,13 @@ func TestClassifyPathPenalty_TestBeatsExample(t *testing.T) { } func TestClassifyPathPenalty_NormalizesBackslashes(t *testing.T) { - // Windows-style separators should still trip the regex. - got := classifyPathPenalty(`src\__tests__\comp.js`) - if got != PathPenaltyTest { + // Windows-style separators should still trip path-specific tiers. + if got := classifyPathPenalty(`src\__tests__\comp.js`); got != PathPenaltyTest { t.Errorf("windows-path test got %v, want %v", got, PathPenaltyTest) } + if got := classifyPathPenalty(`crates\ignore\src\mod.rs`); got != PathPenaltyModuleEntry { + t.Errorf("windows-path module entry got %v, want %v", got, PathPenaltyModuleEntry) + } } func TestPathPenaltySignal_AppliedToCandidate(t *testing.T) { diff --git a/internal/semantic/goanalysis/provider.go b/internal/semantic/goanalysis/provider.go index 5e8fc163f..890d03219 100644 --- a/internal/semantic/goanalysis/provider.go +++ b/internal/semantic/goanalysis/provider.go @@ -58,6 +58,7 @@ type Provider struct { // back to its tree-sitter type tier, the intended graceful degradation. stateMu sync.RWMutex stashes map[string]*goStash // absRoot → loaded package state + retained map[string]int // absRoot → active deferred-contract leases maxStashes int // count ceiling (env GORTEX_GOTYPES_MAX_STASHES) stashTTL time.Duration // idle release window (env GORTEX_GOTYPES_STASH_TTL) sweepOnce sync.Once @@ -78,7 +79,10 @@ type goStash struct { // Stash-retention bounds. Both overridable via env for operators who want // a different memory/recompute trade-off. const ( - defaultMaxStashes = 8 + // Multi-repo enrichment runs at most four repositories concurrently. + // Explicit batch-boundary release keeps the live set at that ceiling; + // this count limit remains the fallback for interrupted callers. + defaultMaxStashes = 4 defaultStashTTL = 3 * time.Minute ) @@ -88,6 +92,7 @@ func NewProvider(mode LoadMode, includeTest bool, logger *zap.Logger) *Provider mode: mode, includeTest: includeTest, logger: logger, + retained: make(map[string]int), maxStashes: envPositiveInt("GORTEX_GOTYPES_MAX_STASHES", defaultMaxStashes), stashTTL: envPositiveDuration("GORTEX_GOTYPES_STASH_TTL", defaultStashTTL), stopSweep: make(chan struct{}), @@ -109,10 +114,54 @@ func (p *Provider) Close() error { } p.stateMu.Lock() p.stashes = nil + p.retained = nil p.stateMu.Unlock() return nil } +// RetainRepoState leases repoRoot's compiler program for the deferred contract +// consumer. The lease may be acquired before EnrichRepo creates the stash; TTL +// and count eviction must ignore it until the matching release. +func (p *Provider) RetainRepoState(repoRoot string) bool { + absRoot, err := filepath.Abs(repoRoot) + if err != nil { + return false + } + p.stateMu.Lock() + if p.retained == nil { + p.retained = make(map[string]int) + } + p.retained[absRoot]++ + p.stateMu.Unlock() + return true +} + +// ReleaseRepoState drops the type-checked program retained for repoRoot after +// its deferred contract pass has finished. The idle TTL and count ceiling are +// fallback protection for interrupted/non-batch callers; the normal indexing +// lifecycle should release this multi-gigabyte state deterministically. +// +// Deleting the map entry is safe alongside LookupTypeAtLine: a lookup snapshots +// the *goStash under stateMu, so an already-running lookup retains its own +// reference until it returns while future lookups stop discovering the repo. +func (p *Provider) ReleaseRepoState(repoRoot string) bool { + absRoot, err := filepath.Abs(repoRoot) + if err != nil { + return false + } + p.stateMu.Lock() + if leases := p.retained[absRoot]; leases > 1 { + p.retained[absRoot] = leases - 1 + p.stateMu.Unlock() + return false + } + delete(p.retained, absRoot) + _, removed := p.stashes[absRoot] + delete(p.stashes, absRoot) + p.stateMu.Unlock() + return removed +} + // goToolchainOnce caches the one-time probe for the `go` command. The // provider shells out through go/packages (`go list`), so without the // toolchain on PATH it can only fail — and a shipped gortex binary often @@ -213,10 +262,11 @@ func (p *Provider) EnrichRepo(g graph.Store, repoPrefix, repoRoot string) (*sema if relPath == "" { continue } + graphPath := scopedGraphPath(repoPrefix, relPath) - node := semantic.MatchNodeByFileLine(g, relPath, pos.Line) + node := semantic.MatchNodeByFileLine(g, graphPath, pos.Line) if node == nil { - node = semantic.MatchNodeByNameInFile(g, ident.Name, relPath) + node = semantic.MatchNodeByNameInFile(g, ident.Name, graphPath) } if node != nil { objID := objectID(obj) @@ -269,9 +319,10 @@ func (p *Provider) EnrichRepo(g graph.Store, repoPrefix, repoRoot string) (*sema if relPath == "" { continue } + graphPath := scopedGraphPath(repoPrefix, relPath) // Find the containing Gortex node (the caller). - callerNode := findContainingFunc(g, pkgs, fset, absRoot, pos) + callerNode := findContainingFunc(g, pkgs, fset, absRoot, pos, repoPrefix) if callerNode == nil { continue } @@ -310,7 +361,7 @@ func (p *Provider) EnrichRepo(g graph.Store, repoPrefix, repoRoot string) (*sema kind := inferEdgeKindFromObj(obj) if kind != "" { semantic.AddSemanticEdge(g, callerNode.ID, targetNodeID, kind, - relPath, pos.Line, p.Name()) + graphPath, pos.Line, p.Name()) result.EdgesAdded++ } continue @@ -328,7 +379,7 @@ func (p *Provider) EnrichRepo(g graph.Store, repoPrefix, repoRoot string) (*sema kind := inferEdgeKindFromObj(obj) if kind != "" { semantic.AddSemanticEdge(g, callerNode.ID, targetNodeID, kind, - relPath, pos.Line, p.Name()) + graphPath, pos.Line, p.Name()) result.EdgesAdded++ } } @@ -389,7 +440,7 @@ func (p *Provider) EnrichRepo(g graph.Store, repoPrefix, repoRoot string) (*sema } var stampedNodes []*graph.Node for rel := range relSet { - for _, node := range g.GetFileNodes(rel) { + for _, node := range g.GetFileNodes(scopedGraphPath(repoPrefix, rel)) { if node.Kind == graph.KindFile || node.Kind == graph.KindImport || node.Name == "" { continue } @@ -522,6 +573,9 @@ func (p *Provider) evictLocked() { } now := time.Now() for root, st := range p.stashes { + if p.retained[root] > 0 { + continue + } if now.Sub(st.lastUsed) > ttl { delete(p.stashes, root) } @@ -534,6 +588,9 @@ func (p *Provider) evictLocked() { var lruRoot string var lruTime time.Time for root, st := range p.stashes { + if p.retained[root] > 0 { + continue + } if lruRoot == "" || st.lastUsed.Before(lruTime) { lruRoot, lruTime = root, st.lastUsed } @@ -905,13 +962,13 @@ func (p *Provider) addMissingImplements(g graph.Store, pkgs []*packages.Package, } // findContainingFunc finds the Gortex function/method node that contains the given position. -func findContainingFunc(g graph.Store, pkgs []*packages.Package, fset *token.FileSet, absRoot string, pos token.Position) *graph.Node { +func findContainingFunc(g graph.Store, pkgs []*packages.Package, fset *token.FileSet, absRoot string, pos token.Position, repoPrefix string) *graph.Node { relPath := relativePath(pos.Filename, absRoot) if relPath == "" { return nil } - nodes := g.GetFileNodes(relPath) + nodes := g.GetFileNodes(scopedGraphPath(repoPrefix, relPath)) var best *graph.Node bestSize := int(^uint(0) >> 1) for _, n := range nodes { @@ -966,5 +1023,15 @@ func relativePath(absPath, repoRoot string) string { return filepath.ToSlash(rel) } +// scopedGraphPath converts a repository-relative source path into the path +// stored by a multi-repo graph. Single-repo graphs intentionally retain the +// unprefixed form used by Provider.Enrich. +func scopedGraphPath(repoPrefix, relPath string) string { + if repoPrefix == "" || relPath == "" { + return relPath + } + return repoPrefix + "/" + relPath +} + // Ensure ast is used. var _ = (*ast.File)(nil) diff --git a/internal/semantic/goanalysis/provider_test.go b/internal/semantic/goanalysis/provider_test.go index e90798e3f..45d62c0b8 100644 --- a/internal/semantic/goanalysis/provider_test.go +++ b/internal/semantic/goanalysis/provider_test.go @@ -6,12 +6,14 @@ import ( "os" "path/filepath" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/zap" "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/graph/store_sqlite" ) // resolvedTempDir wraps t.TempDir() with EvalSymlinks because on macOS @@ -104,19 +106,19 @@ func TestGoAnalysis_FindContainingFunc_PicksSmallest(t *testing.T) { }) pos := token.Position{Filename: "/repo/main.go", Line: 17} - got := findContainingFunc(g, nil, nil, "/repo", pos) + got := findContainingFunc(g, nil, nil, "/repo", pos, "") require.NotNil(t, got) assert.Equal(t, "main.go::Inner", got.ID) // Line 25 is inside Outer only. pos.Line = 25 - got = findContainingFunc(g, nil, nil, "/repo", pos) + got = findContainingFunc(g, nil, nil, "/repo", pos, "") require.NotNil(t, got) assert.Equal(t, "main.go::Outer", got.ID) // Line 5 is in neither. pos.Line = 5 - got = findContainingFunc(g, nil, nil, "/repo", pos) + got = findContainingFunc(g, nil, nil, "/repo", pos, "") assert.Nil(t, got) } @@ -407,3 +409,118 @@ func F() (int, error) { assert.Equal(t, "go-types", node.Meta["semantic_source"]) } + +func TestProviderEnrichRepoScopesGraphPathsForMultiRepoStore(t *testing.T) { + root := resolvedTempDir(t) + writeGoMod(t, root, "example.com/sample") + writeFile(t, root, "sample.go", `package sample + +func BuildWidget() int { return 1 } + +func UseWidget() int { return BuildWidget() } +`) + + store, err := store_sqlite.Open(filepath.Join(t.TempDir(), "graph.sqlite")) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, store.Close()) + }) + + const ( + repoPrefix = "sample-repo" + graphPath = repoPrefix + "/sample.go" + buildID = graphPath + "::BuildWidget" + useID = graphPath + "::UseWidget" + ) + store.AddBatch([]*graph.Node{ + {ID: buildID, Kind: graph.KindFunction, Name: "BuildWidget", FilePath: graphPath, StartLine: 3, EndLine: 3, Language: "go", RepoPrefix: repoPrefix}, + {ID: useID, Kind: graph.KindFunction, Name: "UseWidget", FilePath: graphPath, StartLine: 5, EndLine: 5, Language: "go", RepoPrefix: repoPrefix}, + // Decoys reproduce the ambiguity that hid the bug: go/types yields + // repo-relative paths while a multi-repo graph stores prefixed paths. + {ID: "sample.go::BuildWidget", Kind: graph.KindFunction, Name: "BuildWidget", FilePath: "sample.go", StartLine: 3, EndLine: 3, Language: "go"}, + {ID: "sample.go::UseWidget", Kind: graph.KindFunction, Name: "UseWidget", FilePath: "sample.go", StartLine: 5, EndLine: 5, Language: "go"}, + }, nil) + + provider := newTestProvider(t) + t.Cleanup(func() { + require.NoError(t, provider.Close()) + }) + + result, err := provider.EnrichRepo(store, repoPrefix, root) + require.NoError(t, err) + require.Equal(t, 2, result.SymbolsTotal) + require.Equal(t, result.SymbolsTotal, result.SymbolsCovered, + "all repo-scoped symbols should match their prefixed graph nodes") + + build := store.GetNode(buildID) + require.NotNil(t, build) + require.NotEmpty(t, build.Meta["semantic_type"]) + decoy := store.GetNode("sample.go::BuildWidget") + require.NotNil(t, decoy) + assert.Empty(t, decoy.Meta["semantic_type"], + "an unprefixed node outside this repo must not be enriched") + + var call *graph.Edge + for _, edge := range store.GetOutEdges(useID) { + if edge.To == buildID && edge.Kind == graph.EdgeCalls { + call = edge + break + } + } + require.NotNil(t, call, "semantic call edge should use prefixed node IDs") + assert.Equal(t, graphPath, call.FilePath) +} + +func TestReleaseRepoStateDropsOnlyCompletedRepository(t *testing.T) { + provider := newTestProvider(t) + t.Cleanup(func() { + require.NoError(t, provider.Close()) + }) + + rootA := resolvedTempDir(t) + rootB := resolvedTempDir(t) + provider.stashes = map[string]*goStash{ + rootA: {absRoot: rootA, lastUsed: time.Now()}, + rootB: {absRoot: rootB, lastUsed: time.Now()}, + } + + require.True(t, provider.ReleaseRepoState(rootA)) + provider.stateMu.RLock() + _, retainedA := provider.stashes[rootA] + _, retainedB := provider.stashes[rootB] + retainedCount := len(provider.stashes) + provider.stateMu.RUnlock() + assert.False(t, retainedA) + assert.True(t, retainedB) + assert.Equal(t, 1, retainedCount) + assert.False(t, provider.ReleaseRepoState(rootA), "release should be idempotent") +} + +func TestRetainedRepoStateSurvivesEvictionUntilRelease(t *testing.T) { + provider := newTestProvider(t) + t.Cleanup(func() { + require.NoError(t, provider.Close()) + }) + + root := resolvedTempDir(t) + provider.stashTTL = time.Nanosecond + provider.maxStashes = 1 + provider.stashes = map[string]*goStash{ + root: {absRoot: root, lastUsed: time.Now().Add(-time.Hour)}, + } + + require.True(t, provider.RetainRepoState(root)) + provider.stateMu.Lock() + provider.evictLocked() + _, survived := provider.stashes[root] + provider.stateMu.Unlock() + require.True(t, survived, "an active deferred-contract lease must defeat TTL eviction") + + require.True(t, provider.ReleaseRepoState(root)) + provider.stateMu.RLock() + _, survived = provider.stashes[root] + _, leased := provider.retained[root] + provider.stateMu.RUnlock() + assert.False(t, survived) + assert.False(t, leased) +} diff --git a/internal/semantic/tstypes/engine.go b/internal/semantic/tstypes/engine.go index 0980c1d9f..8f8d209f4 100644 --- a/internal/semantic/tstypes/engine.go +++ b/internal/semantic/tstypes/engine.go @@ -84,6 +84,14 @@ func languageFiles(g graph.Store, spec *LangSpec, repoPrefix, repoRoot string) [ if !langs[n.Language] || n.RepoPrefix != repoPrefix { continue } + // Match the manager/LSP enrichment policy: vendored and generated + // sources are intentionally outside semantic coverage. Without this + // gate, one real source file can make the provider reparse an entire + // dependency or generated tree that did not count as language-presence + // evidence in the first place. + if semantic.IsLowValueForEnrichment(n.FilePath, nil) { + continue + } ref, ok := fileRefFor(n, repoRoot) if !ok { continue @@ -153,6 +161,18 @@ type applier struct { // adaptations, built in the alias phase and consulted when a call's // method name is not a direct or inherited member. aliases map[string][]resolvedAlias + // languages is the immutable set served by spec. Keeping it once per + // pass avoids rebuilding the same map for every receiver-type fact. + languages map[string]bool + // typeCandidatesCache memoizes the store's name lookup. The apply phase + // never creates nodes, so the candidate set is immutable for its lifetime; + // only edges and metadata change between its ordered phases. + typeCandidatesCache map[typeCandidateKey][]*graph.Node + // methodCache memoizes the inheritance/member walk after the supertype + // phase has completed. The depth belongs in the key because the walk is + // intentionally bounded and a lookup reached near the bound can differ + // from the same lookup started at the root. + methodCache map[methodLookupKey]*graph.Node // extensions indexes the language's extension functions by // (receiver-type-name, method-name). An extension `fun Foo.ext()` is // callable as `recv.ext()` on any Foo receiver but is declared at file @@ -173,13 +193,34 @@ type extKey struct { method string } +type typeCandidateKey struct { + repoPrefix string + name string +} + +type methodLookupKey struct { + typeID string + method string + argCount int + depth int +} + func newApplier(g graph.Store, spec *LangSpec, provider string) *applier { + languages := make(map[string]bool) + if spec != nil { + for _, language := range spec.Languages { + languages[language] = true + } + } return &applier{ - g: g, - spec: spec, - provider: provider, - stampedNodes: make(map[string]*graph.Node), - aliases: make(map[string][]resolvedAlias), + g: g, + spec: spec, + provider: provider, + stampedNodes: make(map[string]*graph.Node), + aliases: make(map[string][]resolvedAlias), + languages: languages, + typeCandidatesCache: make(map[typeCandidateKey][]*graph.Node), + methodCache: make(map[methodLookupKey]*graph.Node), } } @@ -344,11 +385,15 @@ func (a *applier) resolveNodeOfKinds(idx *fileIndex, name string, sameFile map[s } func (a *applier) typeCandidates(idx *fileIndex, name string, kinds map[graph.NodeKind]bool) []*graph.Node { - var raw []*graph.Node - if idx.facts.repoPrefix != "" { - raw = a.g.FindNodesByNameInRepo(name, idx.facts.repoPrefix) - } else { - raw = a.g.FindNodesByName(name) + key := typeCandidateKey{repoPrefix: idx.facts.repoPrefix, name: name} + raw, ok := a.typeCandidatesCache[key] + if !ok { + if idx.facts.repoPrefix != "" { + raw = a.g.FindNodesByNameInRepo(name, idx.facts.repoPrefix) + } else { + raw = a.g.FindNodesByName(name) + } + a.typeCandidatesCache[key] = raw } lang := a.languageSet() var out []*graph.Node @@ -365,11 +410,7 @@ func (a *applier) typeCandidates(idx *fileIndex, name string, kinds map[graph.No } func (a *applier) languageSet() map[string]bool { - set := make(map[string]bool, len(a.spec.Languages)) - for _, l := range a.spec.Languages { - set[l] = true - } - return set + return a.languages } // importMatches reports whether a candidate definition file plausibly @@ -415,10 +456,16 @@ func pathSegSuffix(cand, want string) bool { // argument count (argCount) may still pick a unique target by arity; // argCount < 0 means the arity is unknown, in which case an overload // set stays untouched rather than half-guessed. -func (a *applier) methodOn(typeNode *graph.Node, method string, argCount, depth int) *graph.Node { +func (a *applier) methodOn(typeNode *graph.Node, method string, argCount, depth int) (result *graph.Node) { if typeNode == nil || depth > extendsWalkDepth { return nil } + key := methodLookupKey{typeID: typeNode.ID, method: method, argCount: argCount, depth: depth} + if cached, ok := a.methodCache[key]; ok { + return cached + } + defer func() { a.methodCache[key] = result }() + var fromIDs []string for _, e := range a.g.GetInEdges(typeNode.ID) { if e.Kind == graph.EdgeMemberOf { @@ -1148,14 +1195,17 @@ func (a *applier) confirmAST(e *graph.Edge) bool { // a non-empty Origin here would wrongly let those edges through and // clobber both their tier and their semantic_source — so the only // gate is the effective-rank comparison. - if graph.OriginRank(effectiveOrigin(e)) >= graph.OriginRank(graph.OriginASTResolved) { + origin := effectiveOrigin(e) + if graph.OriginRank(origin) >= graph.OriginRank(graph.OriginASTResolved) { // Origin is already AST-or-better — never downgrade it. But an edge the - // extractor emitted carries OriginASTResolved with NO semantic_source - // (e.g. an AST-level extends/implements reference form); the engine - // grounded this relation, so still credit the provider and raise - // confidence to the AST ceiling when those are missing, without - // touching the origin/tier. + // extractor emitted may carry that provenance only implicitly (for + // example, a structural extends edge with no Origin field). Materialize + // the effective tier while crediting the provider and raising confidence. changed := false + if e.Origin == "" && origin == graph.OriginASTResolved { + e.Origin = origin + changed = true + } if e.Meta == nil { e.Meta = make(map[string]any) } diff --git a/internal/semantic/tstypes/engine_cache_test.go b/internal/semantic/tstypes/engine_cache_test.go new file mode 100644 index 000000000..fe138ab20 --- /dev/null +++ b/internal/semantic/tstypes/engine_cache_test.go @@ -0,0 +1,149 @@ +package tstypes + +import ( + "os" + "path/filepath" + "testing" + + "github.com/zzet/gortex/internal/graph" +) + +type lookupCountingStore struct { + graph.Store + findInRepoCalls int + findCalls int + inEdgeCalls int + outEdgeCalls int + nodesByIDCalls int +} + +func (s *lookupCountingStore) FindNodesByNameInRepo(name, repoPrefix string) []*graph.Node { + s.findInRepoCalls++ + return s.Store.FindNodesByNameInRepo(name, repoPrefix) +} + +func (s *lookupCountingStore) FindNodesByName(name string) []*graph.Node { + s.findCalls++ + return s.Store.FindNodesByName(name) +} + +func (s *lookupCountingStore) GetInEdges(id string) []*graph.Edge { + s.inEdgeCalls++ + return s.Store.GetInEdges(id) +} + +func (s *lookupCountingStore) GetOutEdges(id string) []*graph.Edge { + s.outEdgeCalls++ + return s.Store.GetOutEdges(id) +} + +func (s *lookupCountingStore) GetNodesByIDs(ids []string) map[string]*graph.Node { + s.nodesByIDCalls++ + return s.Store.GetNodesByIDs(ids) +} + +func TestApplierMemoizesStoreLookups(t *testing.T) { + base := graph.New() + typeNode := &graph.Node{ + ID: "repo/types.ts::Remote", + Kind: graph.KindType, + Name: "Remote", + FilePath: "repo/types.ts", + Language: "typescript", + RepoPrefix: "repo", + } + methodNode := &graph.Node{ + ID: "repo/types.ts::Remote.run", + Kind: graph.KindMethod, + Name: "run", + FilePath: "repo/types.ts", + Language: "typescript", + RepoPrefix: "repo", + } + base.AddBatch([]*graph.Node{typeNode, methodNode}, []*graph.Edge{{ + From: methodNode.ID, + To: typeNode.ID, + Kind: graph.EdgeMemberOf, + }}) + + store := &lookupCountingStore{Store: base} + ap := newApplier(store, TypeScriptSpec(), "test-types") + idx := &fileIndex{ + facts: &fileFacts{file: "repo/use.ts", repoPrefix: "repo"}, + imports: map[string]string{}, + types: map[string]*graph.Node{}, + } + + for i := 0; i < 100; i++ { + if got := ap.resolveTypeNode(idx, "Remote"); got == nil || got.ID != typeNode.ID { + t.Fatalf("resolveTypeNode(Remote) = %#v, want %s", got, typeNode.ID) + } + } + if got := store.findInRepoCalls; got != 1 { + t.Fatalf("FindNodesByNameInRepo calls = %d, want 1", got) + } + + for i := 0; i < 100; i++ { + if got := ap.resolveTypeNode(idx, "Missing"); got != nil { + t.Fatalf("resolveTypeNode(Missing) = %#v, want nil", got) + } + } + if got := store.findInRepoCalls; got != 2 { + t.Fatalf("FindNodesByNameInRepo calls including cached miss = %d, want 2", got) + } + + for i := 0; i < 100; i++ { + if got := ap.methodOn(typeNode, "run", 0, 0); got == nil || got.ID != methodNode.ID { + t.Fatalf("methodOn(Remote, run) = %#v, want %s", got, methodNode.ID) + } + } + if got := store.inEdgeCalls; got != 1 { + t.Fatalf("GetInEdges calls = %d, want 1", got) + } + if got := store.nodesByIDCalls; got != 1 { + t.Fatalf("GetNodesByIDs calls = %d, want 1", got) + } +} + +func TestMethodMemoizationKeepsDepthBound(t *testing.T) { + base := graph.New() + child := &graph.Node{ID: "repo/types.ts::Child", Kind: graph.KindType, Name: "Child", Language: "typescript", RepoPrefix: "repo"} + parent := &graph.Node{ID: "repo/types.ts::Parent", Kind: graph.KindType, Name: "Parent", Language: "typescript", RepoPrefix: "repo"} + method := &graph.Node{ID: "repo/types.ts::Parent.run", Kind: graph.KindMethod, Name: "run", Language: "typescript", RepoPrefix: "repo"} + base.AddBatch([]*graph.Node{child, parent, method}, []*graph.Edge{ + {From: child.ID, To: parent.ID, Kind: graph.EdgeExtends}, + {From: method.ID, To: parent.ID, Kind: graph.EdgeMemberOf}, + }) + + ap := newApplier(base, TypeScriptSpec(), "test-types") + if got := ap.methodOn(child, "run", 0, extendsWalkDepth); got != nil { + t.Fatalf("depth-limited method lookup = %#v, want nil", got) + } + if got := ap.methodOn(child, "run", 0, 0); got == nil || got.ID != method.ID { + t.Fatalf("root method lookup after depth-limited miss = %#v, want %s", got, method.ID) + } +} + +func TestLanguageFilesSkipVendoredSources(t *testing.T) { + root := t.TempDir() + for _, rel := range []string{"src/app.ts", "node_modules/pkg/index.ts"} { + abs := filepath.Join(root, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(abs, []byte("export const value = 1\n"), 0o644); err != nil { + t.Fatal(err) + } + } + + g := graph.New() + g.AddBatch([]*graph.Node{ + {ID: "repo/src/app.ts", Kind: graph.KindFile, Name: "app.ts", FilePath: "repo/src/app.ts", Language: "typescript", RepoPrefix: "repo"}, + {ID: "repo/node_modules/pkg/index.ts", Kind: graph.KindFile, Name: "index.ts", FilePath: "repo/node_modules/pkg/index.ts", Language: "typescript", RepoPrefix: "repo"}, + }, nil) + + files := languageFiles(g, TypeScriptSpec(), "repo", root) + if len(files) != 1 || files[0].node.FilePath != "repo/src/app.ts" { + t.Fatalf("languageFiles() = %#v, want only repo/src/app.ts", files) + } +} diff --git a/internal/serverstack/shared_server.go b/internal/serverstack/shared_server.go index cf955936c..94eaa87f6 100644 --- a/internal/serverstack/shared_server.go +++ b/internal/serverstack/shared_server.go @@ -557,10 +557,11 @@ func NewSharedServer(cfg SharedServerConfig) (*SharedServer, error) { s.MultiIndexer = mi toolPolicyCfg := gortexmcp.ToolPolicyConfig{ - Preset: conf.MCP.Tools.Preset, - Mode: conf.MCP.Tools.Mode, - Allow: conf.MCP.Tools.Allow, - Deny: conf.MCP.Tools.Deny, + Preset: conf.MCP.Tools.Preset, + Mode: conf.MCP.Tools.Mode, + Allow: conf.MCP.Tools.Allow, + Deny: conf.MCP.Tools.Deny, + OperatorPinned: conf.MCP.Tools.Explicit, } scopeIntentDefaults := conf.Scope.IntentDefaults multiOpts := []gortexmcp.MultiRepoOptions{{ diff --git a/internal/telemetry/rollup.go b/internal/telemetry/rollup.go index 6fa8ecef9..972526667 100644 --- a/internal/telemetry/rollup.go +++ b/internal/telemetry/rollup.go @@ -10,14 +10,19 @@ import ( // on this list, so a path, a symbol name, or any arbitrary string can never // become a metric. Adding a counter is a deliberate edit here and nowhere else. var allowedMetrics = map[string]bool{ - "mcp_tool_call": true, // an MCP tool was invoked; dim = tool name - "cli_command": true, // a CLI subcommand ran; dim = command name - "index": true, // an index pass completed; dim = file-count bucket - "index_lang": true, // a language was present in an index pass; dim = language - "daemon_session": true, // a daemon session started; dim = backend kind - "install": true, // an install applied; dim = agent target / scope - "uninstall": true, // an uninstall applied; dim = agent target / scope - "client": true, // an MCP client connected; dim = client app name + "mcp_tool_call": true, // an MCP tool was invoked; dim = tool name + "mcp_facade_call": true, // a facade-v1 attempt; dim = facade.operation + "mcp_facade_status": true, // facade result class; dim = facade.operation.ok|error + "mcp_facade_outcome": true, // bounded facade outcome; dim = facade.operation.outcome + "mcp_facade_invalid": true, // validation failure; dim = facade.operation.error_code + "mcp_facade_latency": true, // end-to-end latency; dim = facade.operation.duration_bucket + "cli_command": true, // a CLI subcommand ran; dim = command name + "index": true, // an index pass completed; dim = file-count bucket + "index_lang": true, // a language was present in an index pass; dim = language + "daemon_session": true, // a daemon session started; dim = backend kind + "install": true, // an install applied; dim = agent target / scope + "uninstall": true, // an uninstall applied; dim = agent target / scope + "client": true, // an MCP client connected; dim = client app name } // IsAllowedMetric reports whether key may be recorded. @@ -55,16 +60,19 @@ func BucketFileCount(n int) string { // BucketDuration collapses an elapsed time into a coarse bucket. func BucketDuration(d time.Duration) string { - ms := d.Milliseconds() switch { - case ms < 10_000: - return "<10s" - case ms < 60_000: - return "10-60s" - case ms < 300_000: - return "1-5m" + case d < time.Millisecond: + return "<1ms" + case d < 10*time.Millisecond: + return "1-10ms" + case d < 100*time.Millisecond: + return "10-100ms" + case d < time.Second: + return "100ms-1s" + case d < 10*time.Second: + return "1-10s" default: - return "5m+" + return "10s+" } } diff --git a/internal/telemetry/rollup_test.go b/internal/telemetry/rollup_test.go index fc3045420..9bf15a34a 100644 --- a/internal/telemetry/rollup_test.go +++ b/internal/telemetry/rollup_test.go @@ -108,13 +108,15 @@ func TestBucketDuration(t *testing.T) { d time.Duration want string }{ - {5 * time.Second, "<10s"}, - {10 * time.Second, "10-60s"}, - {59 * time.Second, "10-60s"}, - {time.Minute, "1-5m"}, - {4 * time.Minute, "1-5m"}, - {5 * time.Minute, "5m+"}, - {time.Hour, "5m+"}, + {500 * time.Microsecond, "<1ms"}, + {time.Millisecond, "1-10ms"}, + {9 * time.Millisecond, "1-10ms"}, + {10 * time.Millisecond, "10-100ms"}, + {99 * time.Millisecond, "10-100ms"}, + {100 * time.Millisecond, "100ms-1s"}, + {999 * time.Millisecond, "100ms-1s"}, + {time.Second, "1-10s"}, + {10 * time.Second, "10s+"}, } for _, c := range cases { if got := BucketDuration(c.d); got != c.want { @@ -191,7 +193,10 @@ func TestRollupMerge(t *testing.T) { } func TestIsAllowedMetric(t *testing.T) { - for _, k := range []string{"mcp_tool_call", "cli_command", "index", "daemon_session"} { + for _, k := range []string{ + "mcp_tool_call", "mcp_facade_call", "mcp_facade_status", "mcp_facade_outcome", + "mcp_facade_invalid", "mcp_facade_latency", "cli_command", "index", "daemon_session", + } { if !IsAllowedMetric(k) { t.Errorf("%q should be allow-listed", k) } @@ -202,3 +207,32 @@ func TestIsAllowedMetric(t *testing.T) { } } } + +func TestFacadeMetricRollupKeepsOnlyBoundedDimensions(t *testing.T) { + r := NewRollup(time.Date(2026, 6, 18, 9, 0, 0, 0, time.UTC)) + for key, dim := range map[string]string{ + "mcp_facade_call": "read.file", + "mcp_facade_status": "read.file.ok", + "mcp_facade_outcome": "read.file.success", + "mcp_facade_invalid": "read.unknown.invalid_argument", + "mcp_facade_latency": "read.file.<1ms", + } { + if !r.Add(key, dim) { + t.Fatalf("%s was unexpectedly rejected", key) + } + if got := r.Counts[key+":"+dim]; got != 1 { + t.Errorf("%s:%s = %d, want 1", key, dim, got) + } + } + + const sensitive = "/Users/alice/private/repository/operation" + r.Add("mcp_facade_outcome", sensitive) + if got := r.Counts["mcp_facade_outcome"]; got != 1 { + t.Fatalf("unsafe dimension should fold to the bare key, got %v", r.Counts) + } + for key := range r.Counts { + if strings.Contains(key, "alice") || strings.Contains(key, "private") { + t.Fatalf("sensitive facade dimension leaked: %q", key) + } + } +} diff --git a/internal/toolref/toolref.go b/internal/toolref/toolref.go index c1b777a7a..334acebfa 100644 --- a/internal/toolref/toolref.go +++ b/internal/toolref/toolref.go @@ -1,41 +1,36 @@ -// Package toolref renders references to Gortex MCP tools for guidance text so -// every hook, adapter, and CLI message names a tool the SAME way — and never -// mints the invalid bare `gortex ` shell shape that led agents astray -// (an agent that sees a bare tool name in a shell context invents -// `gortex read_file `, which is not a real verb). MCP-directed guidance -// says "call the `` MCP tool"; a shell fallback renders the real -// `gortex call --arg k=v` invocation, which is the ONE correct way to -// reach a tool that has no dedicated CLI verb. +// Package toolref renders references to Gortex tools for agent guidance. +// MCP-capable profiles get an explicit native-MCP requirement and must surface +// a missing callable handle as an integration failure. Bash-only profiles and +// CLI diagnostics can separately render the real `gortex call --arg +// k=v` shape, never the invalid bare `gortex ` form. package toolref -import "strings" - -// exampleArg maps a Gortex MCP tool to a realistic `--arg` for its shell -// fallback, so the rendered hint reads `gortex call read_file --arg path=` -// rather than a shapeless `key=value`. A tool absent here falls back to the -// generic `key=value`, which is still a valid invocation shape. +// cliExample maps internal/compatibility tool names used by hooks to the +// compact public call an agent should execute from Bash. Hooks may still use +// the internal handler name for their daemon probe; emitted guidance must not +// make an agent learn that implementation vocabulary. // // Symbol-ID placeholders render both forms — `::` — so // an agent targeting a method knows the ID carries the receiver // (`pkg/s.go::Server.Handle`), not the bare method name; the bare form only // resolves functions and types. -var exampleArg = map[string]string{ - "read_file": "path=", - "get_symbol_source": "symbol=::", - "get_editing_context": "path=", - "get_file_summary": "path=", - "get_symbol": "id=::", - "search_symbols": "query=", - "search_text": "query=", - "find_usages": "symbol=::", - "get_callers": "symbol=::", - "smart_context": "task=", - "explore": "task=", - "get_repo_outline": "path_prefix=/", - "edit_file": "path=", - "edit_symbol": "id=::", - "index_repository": "path=", - "reindex_repository": "path=", +var cliExample = map[string]string{ + "read_file": `gortex call read --arg target='{"file":""}'`, + "get_symbol_source": `gortex call read --arg target='{"symbol":"::"}'`, + "get_editing_context": `gortex call read --arg operation=editing_context --arg target='{"file":""}'`, + "get_file_summary": `gortex call read --arg operation=summary --arg target='{"file":""}'`, + "get_symbol": `gortex call read --arg target='{"symbol":"::"}'`, + "search_symbols": `gortex call search --arg operation=symbols --arg query=''`, + "search_text": `gortex call search --arg operation=text --arg query=''`, + "find_usages": `gortex call relations --arg operation=usages --arg target='{"symbol":"::"}'`, + "get_callers": `gortex call relations --arg operation=callers --arg target='{"symbol":"::"}'`, + "smart_context": `gortex call explore --arg operation=context --arg task=''`, + "explore": `gortex call explore --arg task=''`, + "get_repo_outline": `gortex call explore --arg operation=outline --arg options='{"path_prefix":"/"}'`, + "edit_file": `gortex call edit --arg target='{"file":""}' --arg match='' --arg replacement=''`, + "edit_symbol": `gortex call edit --arg target='{"symbol":""}' --arg match='' --arg replacement=''`, + "index_repository": `gortex call workspace_admin --arg operation=index --arg arguments='{"path":""}'`, + "reindex_repository": `gortex call workspace_admin --arg operation=reindex --arg arguments='{"path":""}'`, } // MCPRef renders an MCP-directed reference to a tool: "call the `read_file` MCP @@ -45,31 +40,29 @@ func MCPRef(tool string) string { return "call the `" + tool + "` MCP tool" } -// CLIFallback renders the shell-fallback invocation for one tool: -// `gortex call read_file --arg path=`. This is the single place a tool -// name becomes a shell command — nothing else should hand-assemble a -// `gortex …` shape, so the bare-verb mistake can never be re-minted piecemeal. +// CLIFallback renders the compact shell invocation for one operation, for +// example `gortex call read --arg target='{"file":"..."}'`. This is the single +// place an internal tool reference becomes agent-facing Bash — nothing else +// should hand-assemble a `gortex …` shape, so the bare-verb mistake can never +// be re-minted piecemeal. func CLIFallback(tool string) string { - arg := exampleArg[tool] - if arg == "" { - arg = "key=value" + if example := cliExample[tool]; example != "" { + return example } - return "gortex call " + tool + " --arg " + arg + return "gortex call " + tool + " --arg key=value" } -// FallbackLine is the standard one-line advisory appended to graph-tool -// guidance. It teaches the `gortex call --arg …` shape (with a realistic -// example for the primary tool) so an agent in a shell context — MCP unmounted -// or degraded, or one that chose Bash — never invents the invalid -// `gortex ` verb. primary names the most relevant tool for the worked -// example; pass "" to emit only the generic form. The returned string ends in a -// newline so it drops straight into a bulleted guidance block. -func FallbackLine(primary string) string { - var b strings.Builder - b.WriteString(" - Shell only (no MCP tools)? Reach any tool above with `gortex call --arg k=v`") - if primary != "" { - b.WriteString(" — e.g. `" + CLIFallback(primary) + "`") - } - b.WriteString(". There is no bare `gortex ` verb.\n") - return b.String() +// ConcreteCLIFallback reports whether tool has a reviewed, shell-safe compact +// invocation rather than the generic compatibility fallback. +func ConcreteCLIFallback(tool string) (string, bool) { + example, ok := cliExample[tool] + return example, ok +} + +// MCPRequiredLine is the standard transport rule appended to guidance emitted +// by hooks installed for an MCP-capable profile. A configured server whose +// tools are absent from the model's callable manifest is a host integration +// failure, not permission to launch infrastructure or switch transports. +func MCPRequiredLine() string { + return " - Native Gortex MCP is mandatory for this profile. If a referenced Gortex tool is missing from the callable MCP tools despite the server being configured, surface a Gortex MCP integration failure to the user and stop. Do not start a daemon or use any CLI/shell fallback.\n" } diff --git a/internal/toolref/toolref_test.go b/internal/toolref/toolref_test.go new file mode 100644 index 000000000..93f5cd634 --- /dev/null +++ b/internal/toolref/toolref_test.go @@ -0,0 +1,60 @@ +package toolref + +import ( + "strings" + "testing" +) + +func TestCLIFallbackUsesCompactPublicSurface(t *testing.T) { + t.Parallel() + + tests := map[string]string{ + "read_file": "gortex call read --arg target=", + "get_symbol_source": "gortex call read --arg target=", + "search_symbols": "gortex call search --arg operation=symbols", + "find_usages": "gortex call relations --arg operation=usages", + "smart_context": "gortex call explore --arg operation=context", + "edit_file": "gortex call edit --arg target=", + "index_repository": "gortex call workspace_admin --arg operation=index", + } + for internal, want := range tests { + internal, want := internal, want + t.Run(internal, func(t *testing.T) { + t.Parallel() + got := CLIFallback(internal) + if !strings.Contains(got, want) { + t.Fatalf("CLIFallback(%q) = %q, want compact call containing %q", internal, got, want) + } + if strings.Contains(got, "gortex call "+internal+" ") { + t.Fatalf("CLIFallback(%q) leaked the internal tool name: %q", internal, got) + } + }) + } +} + +func TestMCPRequiredLineRejectsTransportFallback(t *testing.T) { + t.Parallel() + + got := MCPRequiredLine() + for _, want := range []string{ + "Native Gortex MCP is mandatory", + "surface a Gortex MCP integration failure", + "Do not start a daemon", + } { + if !strings.Contains(got, want) { + t.Fatalf("MCPRequiredLine() = %q, want %q", got, want) + } + } + if strings.Contains(got, "gortex call ") { + t.Fatalf("MCP-required guidance advertised a CLI fallback: %q", got) + } +} + +func TestCLIFallbackPlaceholdersAreShellQuoted(t *testing.T) { + t.Parallel() + for name, example := range cliExample { + if strings.Contains(example, "=<") { + t.Errorf("cliExample[%q] contains an unquoted shell-redirection placeholder: %s", name, example) + } + } +}