From 8d84e015cfe5b49979bf6172e582bf3548318a92 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 27 Jul 2026 11:57:09 +0200 Subject: [PATCH 01/17] feat: improve localization evidence quality and session safety --- .../hooks/localization_advised_call_test.go | 34 +- internal/hooks/localization_terminal.go | 38 +- .../localization_terminal_receipt_test.go | 121 +-- internal/hooks/localization_terminal_test.go | 76 +- internal/hooks/posttooluse.go | 2 +- internal/hooks/pretooluse.go | 65 +- internal/hooks/sessionstart.go | 3 +- internal/hooks/sessionstart_test.go | 41 +- internal/localizationauth/receipt.go | 5 +- internal/mcp/explore_artifact_intent.go | 361 +++++++-- internal/mcp/explore_artifact_intent_test.go | 107 ++- internal/mcp/explore_causal_change.go | 652 ++++++++++++++++ internal/mcp/explore_causal_change_test.go | 377 ++++++++++ internal/mcp/explore_causal_consumer_test.go | 116 +++ .../mcp/explore_component_conjunction_test.go | 228 ++++++ .../explore_divergent_default_owner_test.go | 3 + internal/mcp/explore_owner_folding.go | 3 +- internal/mcp/explore_refinement_route_test.go | 40 +- internal/mcp/explore_source_literal_test.go | 34 +- internal/mcp/explore_syntactic_anchor.go | 169 ++++- .../mcp/facade_localization_safety_test.go | 83 +++ internal/mcp/facade_registry.go | 12 + internal/mcp/facade_tools.go | 83 ++- .../mcp/instruction_profile_policy_test.go | 153 +++- .../mcp/localization_current_capture_test.go | 28 +- internal/mcp/localization_digest.go | 653 ++++++++++++---- internal/mcp/localization_digest_test.go | 696 +++++++++++++++--- internal/mcp/localization_evidence_policy.go | 62 +- .../mcp/localization_evidence_policy_test.go | 45 +- .../mcp/localization_explicit_anchor_test.go | 206 ++++++ .../mcp/localization_prescribed_body_test.go | 50 +- .../mcp/localization_recovery_capture_test.go | 65 ++ internal/mcp/localization_recovery_test.go | 330 ++++++--- internal/mcp/localization_terminal.go | 118 ++- .../localization_terminal_evidence_v8_test.go | 48 +- internal/mcp/localization_terminal_test.go | 146 ++-- internal/mcp/localization_tool_preset_test.go | 30 + internal/mcp/overlay.go | 5 +- internal/mcp/query_log.go | 2 +- internal/mcp/server.go | 9 + internal/mcp/tool_presets.go | 72 +- internal/mcp/tools_explore.go | 660 +++++++++++++++-- internal/mcp/tools_explore_test.go | 275 ++++++- internal/mcp/tools_list_budget_test.go | 11 +- internal/mcp/tools_mode.go | 6 +- internal/profiles/bodies.go | 45 +- internal/profiles/profiles.go | 35 +- internal/profiles/profiles_test.go | 23 +- 48 files changed, 5450 insertions(+), 976 deletions(-) create mode 100644 internal/mcp/explore_causal_change.go create mode 100644 internal/mcp/explore_causal_change_test.go create mode 100644 internal/mcp/explore_causal_consumer_test.go create mode 100644 internal/mcp/explore_component_conjunction_test.go create mode 100644 internal/mcp/facade_localization_safety_test.go create mode 100644 internal/mcp/localization_explicit_anchor_test.go create mode 100644 internal/mcp/localization_recovery_capture_test.go create mode 100644 internal/mcp/localization_tool_preset_test.go diff --git a/internal/hooks/localization_advised_call_test.go b/internal/hooks/localization_advised_call_test.go index b8dbe20d1..b2ce30a4e 100644 --- a/internal/hooks/localization_advised_call_test.go +++ b/internal/hooks/localization_advised_call_test.go @@ -2,16 +2,13 @@ package hooks import ( "encoding/json" - "strings" "testing" ) -// A refusal must never be the answer to advice this same hook just gave. When -// a localization marker is live, the access policy's "call a Gortex graph tool -// instead" would be met by the marker refusing exactly that call — two turns -// spent to learn nothing. These fixtures pin that the marker answers the host -// tool directly, and that it hands back the retained answer when it does. -func TestAdvisoryMarkerAnswersTheHostToolsItWouldOtherwiseRedirect(t *testing.T) { +// A non-enforceable localization conclusion is advice, not a termination +// boundary. Host coding/navigation tools may still receive the ordinary access +// policy guidance, but the advisory marker itself must never deny them. +func TestAdvisoryMarkerNeverBlocksHostTools(t *testing.T) { const answer = "LOCALIZATION (UNCONFIRMED):\n- PRIMARY — storage/disk.go:42 — repo/storage/disk.go::DiskStorage.Load" for _, tool := range []string{"Read", "Grep", "Glob"} { t.Run(tool, func(t *testing.T) { @@ -24,27 +21,14 @@ func TestAdvisoryMarkerAnswersTheHostToolsItWouldOtherwiseRedirect(t *testing.T) pre := preToolPayload(t, tool, "advised-tool", identity, input) encoded := captureHookStdout(t, func() { runPreToolUse(pre, 0, ModeEnrich) }) if encoded == "" { - t.Fatalf("%s produced no decision under a live marker", tool) + return } var output HookOutput if err := json.Unmarshal([]byte(encoded), &output); err != nil { t.Fatalf("decode PreToolUse output %q: %v", encoded, err) } - hso := output.HookSpecificOutput - if hso == nil || hso.PermissionDecision != "deny" { - t.Fatalf("%s was left to the access policy under a live marker: %#v", tool, hso) - } - if !strings.HasPrefix(hso.PermissionDecisionReason, localizationAdvisoryDenyReason) { - t.Fatalf("%s deny reason = %q, want the advisory reason", tool, hso.PermissionDecisionReason) - } - // The whole point of answering here is that the caller gets the - // answer, not another instruction. - if !strings.Contains(hso.PermissionDecisionReason, answer) { - t.Fatalf("%s deny did not carry the retained answer: %q", tool, hso.PermissionDecisionReason) - } - if strings.Contains(hso.PermissionDecisionReason, "instead") && - strings.Contains(hso.PermissionDecisionReason, "explore") { - t.Fatalf("%s deny still prescribes a call the gate would refuse: %q", tool, hso.PermissionDecisionReason) + if hso := output.HookSpecificOutput; hso != nil && hso.PermissionDecision == "deny" { + t.Fatalf("advisory marker blocked %s: %#v", tool, hso) } }) } @@ -70,9 +54,7 @@ func TestAdvisoryMarkerStillPassesThroughUnrelatedTools(t *testing.T) { if err := json.Unmarshal([]byte(encoded), &output); err != nil { t.Fatalf("decode PreToolUse output %q: %v", encoded, err) } - if output.HookSpecificOutput != nil && - output.HookSpecificOutput.PermissionDecision == "deny" && - strings.HasPrefix(output.HookSpecificOutput.PermissionDecisionReason, localizationAdvisoryDenyReason) { + if output.HookSpecificOutput != nil && output.HookSpecificOutput.PermissionDecision == "deny" { t.Fatalf("advisory marker denied an unrelated tool %s: %#v", tool, output.HookSpecificOutput) } }) diff --git a/internal/hooks/localization_terminal.go b/internal/hooks/localization_terminal.go index f66452b3a..2a22b4456 100644 --- a/internal/hooks/localization_terminal.go +++ b/internal/hooks/localization_terminal.go @@ -25,11 +25,9 @@ const ( localizationTerminalAgentHardCap = 64 localizationTerminalJanitorDeletes = 32 - localizationTerminalContext = "[Gortex] Localization for this task is complete. Answer now from completion.final_response, naming the files and symbols you rely on; if its evidence does not fit the request, say so and name what does. Either way, do not call another tool." - localizationTerminalDenyReason = "[Gortex] Localization for this task is complete, so this tool call is blocked. Answer now from the retained evidence below, naming what you rely on; if it does not fit the request, say so in your answer." - localizationAdvisoryDenyReason = "[Gortex] Localization for this task is complete, so this additional Gortex navigation call was not run. Answer now from the retained evidence below, naming what you rely on; if it does not fit the request, say so in your answer." - gortexPluginMCPToolPrefix = "mcp__plugin_gortex_gortex__" - localizationHostMetaKey = "gortex/localization" + localizationTerminalContext = "[Gortex] The bounded localization search completed and the retained result is included below. For a localization-only request, answer from the PRIMARY file/symbol tuples. For diagnosis, implementation, or contradictory evidence, continue with the appropriate tools; this conclusion does not block the session." + gortexPluginMCPToolPrefix = "mcp__plugin_gortex_gortex__" + localizationHostMetaKey = "gortex/localization" ) var localizationNavigationOperations = map[string]struct{}{ @@ -39,6 +37,27 @@ var localizationNavigationOperations = map[string]struct{}{ "relations": {}, "trace": {}, "analyze": {}, + + // Legacy navigation names are normally hidden behind the compact facade, + // but the hook keeps the same boundary as defense in depth for promoted or + // older host tool catalogs. + "smart_context": {}, "context_closure": {}, "get_repo_outline": {}, + "plan_turn": {}, "prefetch_context": {}, "suggest_queries": {}, "gortex_wakeup": {}, + "search_artifacts": {}, "search_ast": {}, "graph_completion_search": {}, "find_files": {}, + "search_symbols": {}, "search_text": {}, "winnow_symbols": {}, + "get_artifact": {}, "get_editing_context": {}, "read_file": {}, "get_symbol_history": {}, + "get_symbol_source": {}, "get_file_summary": {}, "batch_symbols": {}, "get_symbol": {}, + "get_callers": {}, "get_cluster": {}, "find_declaration": {}, "get_dependencies": {}, + "get_dependents": {}, "get_class_hierarchy": {}, "find_implementations": {}, + "find_import_path": {}, "find_overrides": {}, "check_references": {}, "find_usages": {}, + "get_call_chain": {}, "get_cfg": {}, "flow_between": {}, "graph_query": {}, + "trace_path": {}, "taint_paths": {}, "walk_graph": {}, + "audit_agent_config": {}, "get_architecture": {}, "verify_citation": {}, "find_clones": {}, + "find_co_changing_symbols": {}, "get_communities": {}, "contracts": {}, + "get_coupling_metrics": {}, "get_extraction_candidates": {}, "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": {}, } // localizationRedirectedHostTools are the host tools whose access-policy deny @@ -638,13 +657,16 @@ func localizationTerminalIdentityCurrent(identity localizationTerminalIdentity) } func markLocalizationTerminal(identity localizationTerminalIdentity, contractVersion int) bool { - return markLocalizationTerminalWithStrength(identity, contractVersion, false, "") + return markLocalizationTerminalWithStrength(identity, contractVersion, true, "") } func markLocalizationTerminalReceipt( - identity localizationTerminalIdentity, contractVersion int, enforceable bool, finalResponse string, + identity localizationTerminalIdentity, contractVersion int, _ bool, finalResponse string, ) bool { - return markLocalizationTerminalWithStrength(identity, contractVersion, !enforceable, finalResponse) + // Retained terminal context is always advisory. A response contract may + // classify its evidence as enforceable for replay quality, but that field is + // never permission authority for host tools. + return markLocalizationTerminalWithStrength(identity, contractVersion, true, finalResponse) } func markLocalizationTerminalWithStrength( diff --git a/internal/hooks/localization_terminal_receipt_test.go b/internal/hooks/localization_terminal_receipt_test.go index 7ac8bf8d2..16a5c71af 100644 --- a/internal/hooks/localization_terminal_receipt_test.go +++ b/internal/hooks/localization_terminal_receipt_test.go @@ -50,6 +50,15 @@ func TestLocalizationReceiptSurvivesStrippedClaudeWire(t *testing.T) { } post := localizationPostToolPayload(t, tt.tool, "tool-use", identity, response) output := captureHookStdout(t, func() { runPostToolUse(post) }) + if !tt.enforceable { + if output != "" { + t.Fatalf("advisory receipt emitted localization-only context: %s", output) + } + if hasLocalizationTerminal(identity) { + t.Fatal("advisory receipt armed hard terminal state") + } + return + } var decoded HookOutput if err := json.Unmarshal([]byte(output), &decoded); err != nil { @@ -64,16 +73,21 @@ func TestLocalizationReceiptSurvivesStrippedClaudeWire(t *testing.T) { t.Fatalf("additionalContext changed final_response bytes\n got: %q\nwant: %q", gotContext, wantContext) } for _, required := range []string{ - "Localization for this task is complete", - "completion.final_response", - "do not call another tool", + "bounded localization search completed", + "retained result", + "PRIMARY file/symbol tuples", + "continue with the appropriate tools", + "does not block the session", } { if !strings.Contains(gotContext, required) { t.Fatalf("additionalContext %q does not contain %q", gotContext, required) } } - if got := hasLocalizationTerminal(identity); got != tt.enforceable { - t.Fatalf("hard marker = %v, want %v", got, tt.enforceable) + if strings.Contains(gotContext, "call no tool") { + t.Fatalf("advisory context retained hard-stop wording: %q", gotContext) + } + if hasLocalizationTerminal(identity) { + t.Fatal("terminal context armed a hard marker") } }) } @@ -100,19 +114,23 @@ func TestLocalizationReceiptMarkerStrengthControlsPreToolUse(t *testing.T) { t.Fatal("server receipt publish failed") } post := localizationPostToolPayload(t, tool, "terminal-tool", identity, strippedToolResponse()) - if output := captureHookStdout(t, func() { runPostToolUse(post) }); output == "" { - t.Fatal("authenticated answer_ready did not emit terminal context") + postOutput := captureHookStdout(t, func() { runPostToolUse(post) }) + if tt.enforceable && postOutput == "" { + t.Fatal("enforceable answer_ready did not emit terminal context") + } + if !tt.enforceable && postOutput != "" { + t.Fatalf("advisory answer_ready emitted localization-only context: %s", postOutput) } marker, marked := localizationTerminalMarkerFor(identity) if !marked { t.Fatal("authenticated answer_ready did not persist a marker") } - if marker.Advisory != !tt.enforceable { - t.Fatalf("marker advisory = %v, want %v", marker.Advisory, !tt.enforceable) + if !marker.Advisory { + t.Fatal("localization receipt created a hard marker") } - if got := hasLocalizationTerminal(identity); got != tt.enforceable { - t.Fatalf("hard marker = %v, want %v", got, tt.enforceable) + if hasLocalizationTerminal(identity) { + t.Fatal("localization receipt armed hard terminal state") } checks := []struct { @@ -127,6 +145,8 @@ func TestLocalizationReceiptMarkerStrengthControlsPreToolUse(t *testing.T) { }{ {name: "direct navigation", tool: gortexMCPToolPrefix + "read", input: map[string]any{"operation": "source"}, navigation: true}, {name: "plugin navigation", tool: gortexPluginMCPToolPrefix + "search", input: map[string]any{"operation": "symbols"}, navigation: true}, + {name: "direct legacy search", tool: gortexMCPToolPrefix + "search_symbols", input: map[string]any{"query": "Load"}, navigation: true}, + {name: "plugin legacy read", tool: gortexPluginMCPToolPrefix + "read_file", input: map[string]any{"path": "storage.go"}, navigation: true}, {name: "host read", tool: "Read", input: map[string]any{"file_path": "README.md"}, redirected: true}, {name: "host grep", tool: "Grep", input: map[string]any{"pattern": "load"}, redirected: true}, {name: "host glob", tool: "Glob", input: map[string]any{"pattern": "**/*.go"}, redirected: true}, @@ -144,29 +164,8 @@ func TestLocalizationReceiptMarkerStrengthControlsPreToolUse(t *testing.T) { t.Fatalf("decode PreToolUse output %q: %v", encoded, err) } } - wantDenied := tt.enforceable || check.navigation || check.redirected - if !wantDenied { - if output.HookSpecificOutput != nil && output.HookSpecificOutput.PermissionDecision == "deny" { - t.Fatalf("advisory marker denied pass-through tool: %#v", output.HookSpecificOutput) - } - return - } - if output.HookSpecificOutput == nil || output.HookSpecificOutput.PermissionDecision != "deny" { - t.Fatalf("terminal marker did not deny tool: %#v", output) - } - wantReason := localizationAdvisoryDenyReason - if tt.enforceable { - wantReason = localizationTerminalDenyReason - } - got := output.HookSpecificOutput.PermissionDecisionReason - if !strings.HasPrefix(got, wantReason) { - t.Fatalf("terminal deny reason = %q, want prefix %q", got, wantReason) - } - // The refusal hands back the answer, so the caller has - // something to act on instead of another tool to try. - if answer := strings.TrimSpace(marker.FinalResponse); answer != "" && - !strings.Contains(got, answer) { - t.Fatalf("terminal deny reason %q does not carry the retained answer %q", got, answer) + if output.HookSpecificOutput != nil && output.HookSpecificOutput.PermissionDecision == "deny" { + t.Fatalf("advisory marker denied %s: %#v", check.tool, output.HookSpecificOutput) } }) } @@ -194,27 +193,33 @@ func TestLocalizationTerminalMarkerStrengthIsMonotonic(t *testing.T) { } marker, marked := localizationTerminalMarkerFor(identity) - if !marked || marker.Advisory || !hasLocalizationTerminal(identity) { - t.Fatalf("advisory receipt downgraded hard terminal marker: marker=%#v marked=%v", marker, marked) + if !marked || !marker.Advisory || hasLocalizationTerminal(identity) { + t.Fatalf("receipt strength escaped advisory-only policy: marker=%#v marked=%v", marker, marked) } - raw, err := os.ReadFile(localizationTerminalMarkerPath(identity)) + raw, err := os.ReadFile(localizationTerminalAdvisoryMarkerPath(identity)) if err != nil { t.Fatal(err) } - if strings.Contains(string(raw), `"advisory"`) { - t.Fatalf("legacy-compatible hard marker unexpectedly encoded advisory field: %s", raw) + if !strings.Contains(string(raw), `"advisory":true`) { + t.Fatalf("terminal marker did not persist advisory strength: %s", raw) } - for _, tool := range []string{"Read", "WebSearch"} { + for _, tool := range []string{"Read", "WebSearch", gortexMCPToolPrefix + "change"} { + input := map[string]any{"operation": "impact"} + if tool == "Read" { + input = map[string]any{"file_path": "README.md"} + } encoded := captureHookStdout(t, func() { - runPreToolUse(preToolPayload(t, tool, "", identity, map[string]any{"file_path": "README.md"}), 0, ModeEnrich) + runPreToolUse(preToolPayload(t, tool, "", identity, input), 0, ModeEnrich) }) + if encoded == "" { + continue + } var output HookOutput - if err := json.Unmarshal([]byte(encoded), &output); err != nil || output.HookSpecificOutput == nil { - t.Fatalf("invalid hard terminal deny for %s: %v\n%s", tool, err, encoded) + if err := json.Unmarshal([]byte(encoded), &output); err != nil { + t.Fatalf("invalid PreToolUse output for %s: %v\n%s", tool, err, encoded) } - if output.HookSpecificOutput.PermissionDecision != "deny" || - !strings.HasPrefix(output.HookSpecificOutput.PermissionDecisionReason, localizationTerminalDenyReason) { - t.Fatalf("hard marker did not remain enforceable for %s: %#v", tool, output.HookSpecificOutput) + if output.HookSpecificOutput != nil && output.HookSpecificOutput.PermissionDecision == "deny" { + t.Fatalf("advisory marker denied %s: %#v", tool, output.HookSpecificOutput) } } } @@ -577,8 +582,11 @@ func TestLocalizationProblemRewriteDirectAndPluginIsIdempotent(t *testing.T) { if err := json.Unmarshal([]byte(terminalOutput), &terminalDecoded); err != nil || terminalDecoded.HookSpecificOutput == nil { t.Fatalf("invalid terminal PreToolUse output: %v\n%s", err, terminalOutput) } - if terminalDecoded.HookSpecificOutput.PermissionDecision != "deny" || terminalDecoded.HookSpecificOutput.UpdatedInput != nil { - t.Fatalf("terminal deny must precede task rewrite: %#v", terminalDecoded.HookSpecificOutput) + if terminalDecoded.HookSpecificOutput.PermissionDecision == "deny" { + t.Fatalf("advisory terminal marker blocked task rewrite: %#v", terminalDecoded.HookSpecificOutput) + } + if got := terminalDecoded.HookSpecificOutput.UpdatedInput["task"]; got != "model paraphrase" { + t.Fatalf("advisory terminal marker changed the continuing task: %#v", terminalDecoded.HookSpecificOutput) } }) } @@ -814,15 +822,24 @@ func TestSessionStartNamesMountedExploreAndPreservesCompleteTask(t *testing.T) { "preserves the recovery allowance", "the rejected request does not count as the accepted recovery", "Do not call host Read, Grep, Glob, or Bash", - "intentionally not executed and replays the same retained terminal payload", - "not stale or canned output or an integration failure", + "bounded localization is complete and retained", + "do not call another tool merely to repeat the same search", + "An identical `explore.localize` call replays the retained terminal payload", + "Ordinary Gortex tools remain live", + "for contradictory evidence or a diagnosis/change workflow", } { if !strings.Contains(briefing, required) { t.Fatalf("SessionStart rule is missing %q:\n%s", required, briefing) } } - if strings.Contains(briefing, "call `explore(operation") { - t.Fatalf("SessionStart still suggests a bare explore call:\n%s", briefing) + for _, forbidden := range []string{ + "call `explore(operation", + "Any later Gortex navigation call for that same task is intentionally not executed", + "not stale or canned output or an integration failure", + } { + if strings.Contains(briefing, forbidden) { + t.Fatalf("SessionStart still suggests blocked ordinary navigation %q:\n%s", forbidden, briefing) + } } } diff --git a/internal/hooks/localization_terminal_test.go b/internal/hooks/localization_terminal_test.go index e980c162e..f9b257e0c 100644 --- a/internal/hooks/localization_terminal_test.go +++ b/internal/hooks/localization_terminal_test.go @@ -224,14 +224,19 @@ func TestPostToolUseObservesMatchingFinalResponseContract(t *testing.T) { t.Fatalf("PostToolUse output %q does not contain terminal context", output) } for _, required := range []string{ - "Localization for this task is complete", - "completion.final_response", - "do not call another tool", + "bounded localization search completed", + "retained result", + "PRIMARY file/symbol tuples", + "continue with the appropriate tools", + "does not block the session", } { if !strings.Contains(output, required) { t.Fatalf("PostToolUse output %q does not contain %q", output, required) } } + if strings.Contains(output, "call no tool") { + t.Fatalf("PostToolUse retained hard-stop wording: %q", output) + } } func TestPostToolUseAnswerReadyEventAuthenticationAndJSONShape(t *testing.T) { @@ -249,13 +254,11 @@ func TestPostToolUseAnswerReadyEventAuthenticationAndJSONShape(t *testing.T) { completionMap(contract)["enforceable"] = false return terminalToolResponse(t, contract, true, false) }, - wantOutput: true, }, { name: "enforceable", response: func(t *testing.T) map[string]any { return terminalToolResponse(t, terminalContractMap(), true, false) }, wantOutput: true, - wantMarker: true, }, { name: "forged without authoritative meta", @@ -318,7 +321,7 @@ func TestPostToolUseAnswerReadyEventAuthenticationAndJSONShape(t *testing.T) { } } -func TestLocalizationTerminalHookFlowDeniesThenPromptRotatesTurn(t *testing.T) { +func TestLocalizationTerminalHookFlowAdvisesWithoutBlockingAndRotatesPrompt(t *testing.T) { configureLocalizationTerminalTestHome(t) sessionID := "terminal-flow" cwd := t.TempDir() @@ -336,31 +339,48 @@ func TestLocalizationTerminalHookFlowDeniesThenPromptRotatesTurn(t *testing.T) { tool string input map[string]any }{ - {name: "web search", tool: "WebSearch"}, + {name: "gortex read", tool: gortexMCPToolPrefix + "read", input: map[string]any{"target": map[string]any{"file": "repo/source.go"}}}, {name: "host read", tool: "Read", input: map[string]any{"file_path": "repo/source.go"}}, {name: "host grep", tool: "Grep", input: map[string]any{"pattern": "Target", "path": "repo"}}, } { t.Run(tt.name, func(t *testing.T) { pre := preToolPayload(t, tt.tool, "", identity, tt.input) preOutput := captureHookStdout(t, func() { runPreToolUse(pre, 0, ModeDeny) }) + if preOutput == "" { + return + } var output HookOutput if err := json.Unmarshal([]byte(preOutput), &output); err != nil { t.Fatalf("decode PreToolUse output %q: %v", preOutput, err) } - if output.HookSpecificOutput == nil || output.HookSpecificOutput.PermissionDecision != "deny" { - t.Fatalf("expected all-tool terminal deny, got %#v", output) + if output.HookSpecificOutput != nil && output.HookSpecificOutput.PermissionDecision == "deny" { + t.Fatalf("advisory localization marker denied %s: %#v", tt.tool, output.HookSpecificOutput) + } + }) + } + + for _, tt := range []struct { + name string + tool string + input map[string]any + }{ + {name: "web search", tool: "WebSearch"}, + {name: "gortex impact", tool: gortexMCPToolPrefix + "change", input: map[string]any{"operation": "impact", "target": map[string]any{"symbol": "repo/source.go::Target"}}}, + {name: "gortex edit", tool: gortexMCPToolPrefix + "edit", input: map[string]any{"operation": "file", "target": map[string]any{"file": "repo/source.go"}}}, + {name: "build and test", tool: "Bash", input: map[string]any{"command": "go test ./..."}}, + } { + t.Run(tt.name, func(t *testing.T) { + pre := preToolPayload(t, tt.tool, "", identity, tt.input) + preOutput := captureHookStdout(t, func() { runPreToolUse(pre, 0, ModeDeny) }) + if preOutput == "" { + return } - if got := output.HookSpecificOutput.PermissionDecisionReason; !strings.HasPrefix(got, localizationTerminalDenyReason) { - t.Fatalf("terminal deny reason = %q, want prefix %q", got, localizationTerminalDenyReason) + var output HookOutput + if err := json.Unmarshal([]byte(preOutput), &output); err != nil { + t.Fatalf("decode PreToolUse output %q: %v", preOutput, err) } - for _, required := range []string{ - "Localization for this task is complete", - "retained evidence", - "naming what you rely on", - } { - if !strings.Contains(output.HookSpecificOutput.PermissionDecisionReason, required) { - t.Fatalf("terminal deny reason %q does not contain %q", output.HookSpecificOutput.PermissionDecisionReason, required) - } + if output.HookSpecificOutput != nil && output.HookSpecificOutput.PermissionDecision == "deny" { + t.Fatalf("advisory localization marker denied task-execution tool %q: %#v", tt.tool, output.HookSpecificOutput) } }) } @@ -420,9 +440,9 @@ func TestLocalizationTerminalSeparatesParentAndSubagent(t *testing.T) { t.Fatalf("subagent was denied by parent marker: %q", got) } if got := captureHookStdout(t, func() { - runPreToolUse(preToolPayload(t, "WebSearch", "", parent, nil), 0, ModeDeny) - }); !strings.Contains(got, `"permissionDecision":"deny"`) { - t.Fatalf("parent marker did not deny parent tool: %q", got) + runPreToolUse(preToolPayload(t, gortexMCPToolPrefix+"read", "", parent, map[string]any{"operation": "source"}), 0, ModeDeny) + }); strings.Contains(got, `"permissionDecision":"deny"`) { + t.Fatalf("advisory parent marker denied parent localization navigation: %q", got) } } @@ -485,8 +505,8 @@ func TestLocalizationTerminalSubagentPromptIDFlowsThroughStartPrePostStop(t *tes if got := captureHookStdout(t, func() { runPostToolUse(post) }); !strings.Contains(got, localizationTerminalContext) { t.Fatalf("PostToolUse did not observe prompt-scoped terminal result: %q", got) } - if !hasLocalizationTerminal(identity) { - t.Fatal("prompt-scoped terminal marker was not persisted") + if marker, marked := localizationTerminalMarkerFor(identity); !marked || !marker.Advisory { + t.Fatalf("prompt-scoped advisory marker was not persisted: marker=%#v marked=%v", marker, marked) } stop := subagentLifecyclePayloadWithPrompt(t, "SubagentStop", sessionID, agentID, promptID, cwd) @@ -727,7 +747,7 @@ func TestPreToolUseUnrelatedToolWithoutTurnIsStrictLocalNoOp(t *testing.T) { func TestLocalizationTerminalMarkerUsesFullHashAndAtomicConcurrentWrites(t *testing.T) { configureLocalizationTerminalTestHome(t) identity := beginTestLocalizationSubagentTurn(t, "terminal-race", "agent", t.TempDir()) - base := strings.TrimSuffix(filepath.Base(localizationTerminalMarkerPath(identity)), ".json") + base := strings.TrimSuffix(filepath.Base(localizationTerminalAdvisoryMarkerPath(identity)), ".advisory.json") if len(base) != sha256HexLength || strings.Trim(base, "0123456789abcdef") != "" { t.Fatalf("marker basename is not a full SHA-256 hex digest: %q", base) } @@ -741,12 +761,12 @@ func TestLocalizationTerminalMarkerUsesFullHashAndAtomicConcurrentWrites(t *test if !markLocalizationTerminal(identity, localizationTerminalContractV2) { t.Errorf("markLocalizationTerminal failed") } - _ = hasLocalizationTerminal(identity) + _, _ = localizationTerminalMarkerFor(identity) }() } wg.Wait() - if !hasLocalizationTerminal(identity) { - t.Fatal("expected a complete marker after concurrent writers") + if marker, marked := localizationTerminalMarkerFor(identity); !marked || !marker.Advisory { + t.Fatalf("expected a complete advisory marker after concurrent writers: marker=%#v marked=%v", marker, marked) } } diff --git a/internal/hooks/posttooluse.go b/internal/hooks/posttooluse.go index 612338fa4..b28600224 100644 --- a/internal/hooks/posttooluse.go +++ b/internal/hooks/posttooluse.go @@ -82,7 +82,7 @@ func runPostToolUse(data []byte) { } func emitLocalizationTerminalContext(terminal localizationTerminalHookInput) bool { - if !localizationTerminalIdentityCurrent(terminal.TerminalIdentity) { + if !terminal.TerminalReceipt.Enforceable || !localizationTerminalIdentityCurrent(terminal.TerminalIdentity) { return false } emitPostToolContext(localizationTerminalAdditionalContext(terminal.TerminalReceipt), false) diff --git a/internal/hooks/pretooluse.go b/internal/hooks/pretooluse.go index 0e765b1ac..796b8708c 100644 --- a/internal/hooks/pretooluse.go +++ b/internal/hooks/pretooluse.go @@ -100,51 +100,15 @@ func runPreToolUse(data []byte, gortexPort int, mode Mode) { return } - // Terminal enforcement is deliberately the first policy branch. It is a - // local marker lookup, so it neither waits for the daemon nor gets bypassed - // by permissive permission modes. A new user prompt clears the marker. + // Localization context may carry the current problem statement into an MCP + // request, but it never grants permission to deny a host tool. Conclusions + // remain advisory so a real coding turn can continue without a user restart. terminalTurn, terminalTurnReady := currentLocalizationTurnState(input.SessionID, input.PromptID, input.AgentID, input.CWD) - terminalIdentity := terminalTurn.Identity - if terminalTurnReady { - if marker, marked := localizationTerminalMarkerFor(terminalIdentity); marked { - reason := "" - switch { - case !marker.Advisory: - reason = localizationTerminalDenyReason - case localizationNavigationTool(input.ToolName): - reason = localizationAdvisoryDenyReason - case localizationRedirectedHostTool(input.ToolName): - // Left to the access policy this deny becomes "call a Gortex - // graph tool instead", and the branch above then refuses that - // call. Prescribing a step we will not honour spends the - // caller's turn and teaches it nothing, so answer here instead. - reason = localizationAdvisoryDenyReason - } - if reason != "" { - // Hand the answer back with the refusal. A bare "you are done" - // leaves the caller with nothing to act on, so it reaches for the - // next tool and the turn budget drains one denial at a time. - if answer := strings.TrimSpace(marker.FinalResponse); answer != "" { - reason += "\n\n" + answer - } - emitPreToolUse(HookOutput{HookSpecificOutput: &HookSpecificOutput{ - HookEventName: "PreToolUse", - PermissionDecision: "deny", - PermissionDecisionReason: reason, - }}) - localizationTerminalTelemetry("denied", true, started) - return - } - } - } localizationAuthToken := "" if terminalTurnReady { - // Correlate the current turn with this exact tool invocation. The nonce is - // injected into the MCP request, then the server publishes a one-shot - // answer_ready receipt under it immediately before returning. PostToolUse - // consumes both the snapshot and receipt, so stripped response metadata, - // delayed events, and visible JSON cannot arm terminal state. - if authToken, snapshotReady := snapshotLocalizationToolUseWithAuth(input, terminalIdentity); snapshotReady { + // Preserve authenticated response correlation for advisory context. The + // resulting receipt can never deny a tool; marker strength is forced soft. + if authToken, snapshotReady := snapshotLocalizationToolUseWithAuth(input, terminalTurn.Identity); snapshotReady { localizationAuthToken = authToken } } @@ -152,18 +116,15 @@ func runPreToolUse(data []byte, gortexPort int, mode Mode) { // Record what this call is about to rewrite, so a Stop-hook briefing can // tell this session's edits apart from a sibling session's on a shared - // checkout. Placed here deliberately: after the terminal deny above (a - // refused call is never credited) but ahead of both gates below, because - // MultiEdit / NotebookEdit are outside preToolUsePolicyTools and every - // Gortex MCP write short-circuits at the auto-approve branch under a - // permissive permission mode. For a tool that writes nothing this returns - // before touching disk, so the no-op contract below still holds. + // checkout. This runs before the policy-tool gate because MultiEdit / + // NotebookEdit are outside preToolUsePolicyTools and Gortex MCP writes can + // short-circuit through auto-approval. For a read-only tool it returns + // without touching disk. recordSessionWriteTargets(input) - // The installed matcher is deliberately broad so terminal state can stop - // any tool. With no marker, tools outside the historical access-policy - // matcher must be an immediate no-op: no daemon probe, classification, - // enrichment, or telemetry I/O beyond the write-target record above. + // Tools outside the historical access-policy matcher are an immediate no-op: + // no daemon probe, classification, enrichment, or telemetry I/O beyond the + // write-target attribution above. if !preToolUsePolicyTool(input.ToolName) { return } diff --git a/internal/hooks/sessionstart.go b/internal/hooks/sessionstart.go index bc8dd3bec..100068f8b 100644 --- a/internal/hooks/sessionstart.go +++ b/internal/hooks/sessionstart.go @@ -298,8 +298,7 @@ func rulePreamble() string { "Its localize task may be concise, but must faithfully preserve the issue title and every user-supplied technical identifier, path, literal, error, symptom, and stated hypothesis; never invent a causal hypothesis. " + "A clearly framed problem section may be restored exactly at execution only for a clearly lossy model task, so evidence can reflect details beyond a concise tool argument. " + "Use `mcp__gortex__explore` with `operation:\"task\"` only when work will actually continue beyond localization into diagnosis, relationship analysis, or implementation. " + - "After starting localization, obey `completion.required_action`. For `needs_recovery`, make one accepted, bounded Gortex MCP `search` or `read` call. If Gortex explicitly rejects an overbroad request and preserves the recovery allowance, correct it using only Gortex MCP `search` or `read`; the rejected request does not count as the accepted recovery. Do not call host Read, Grep, Glob, or Bash. At `answer_ready`, respond from `completion.final_response` and make no further tool calls. In that final response, each PRIMARY or SUPPORTING row is one aligned file/symbol tuple; preserve the PRIMARY file and symbol identities in the answer, while SUPPORTING rows are optional context. " + - "Any later Gortex navigation call for that same task is intentionally not executed and replays the same retained terminal payload; identical output is expected, not stale or canned output or an integration failure. " + + "After starting localization, obey `completion.required_action`. For `needs_recovery`, make one accepted, bounded Gortex MCP `search` or `read` call. If Gortex explicitly rejects an overbroad request and preserves the recovery allowance, correct it using only Gortex MCP `search` or `read`; the rejected request does not count as the accepted recovery. Do not call host Read, Grep, Glob, or Bash. At `answer_ready`, the bounded localization is complete and retained. For a localization-only request, respond directly from `completion.final_response` and do not call another tool merely to repeat the same search. An identical `explore.localize` call replays the retained terminal payload. Ordinary Gortex tools remain live: for contradictory evidence or a diagnosis/change workflow, continue reading, analysis, impact, editing, and verification normally. In `completion.final_response`, each EVIDENCE #N row is one aligned rank/role/file/symbol tuple; preserve the PRIMARY file and symbol identities in the answer, while SUPPORTING rows are optional context. " + "Inspect indexed code with `search`, `read`, `relations`, or `trace`. " + "Outside an active localization contract, use native read, search, or edit tools only when Gortex performance or integration is actually bad. " + "Before mutation call `change(operation:\"impact\")`; " + diff --git a/internal/hooks/sessionstart_test.go b/internal/hooks/sessionstart_test.go index 01b4c60b6..c3fcc7f2c 100644 --- a/internal/hooks/sessionstart_test.go +++ b/internal/hooks/sessionstart_test.go @@ -42,11 +42,18 @@ func TestRulePreambleRoutesByOutcomeAndPreservesExactIdentifiers(t *testing.T) { "preserves the recovery allowance", "the rejected request does not count as the accepted recovery", "Do not call host Read, Grep, Glob, or Bash", - "one aligned file/symbol tuple", + "bounded localization is complete and retained", + "localization-only request", + "respond directly from `completion.final_response`", + "do not call another tool merely to repeat the same search", + "An identical `explore.localize` call replays the retained terminal payload", + "Ordinary Gortex tools remain live", + "for contradictory evidence or a diagnosis/change workflow", + "continue reading, analysis, impact, editing, and verification normally", + "each EVIDENCE #N row", + "one aligned rank/role/file/symbol tuple", "preserve the PRIMARY file and symbol identities", "SUPPORTING rows are optional context", - "intentionally not executed and replays the same retained terminal payload", - "not stale or canned output or an integration failure", "Outside an active localization contract", } { if !strings.Contains(briefing, required) { @@ -56,6 +63,9 @@ func TestRulePreambleRoutesByOutcomeAndPreservesExactIdentifiers(t *testing.T) { for _, forced := range []string{ "including a request framed as diagnosis or a why question", "Call `explore` first for code discovery, diagnosis", + "make no further tool calls", + "Any later Gortex navigation call for that same task is intentionally not executed", + "not stale or canned output or an integration failure", } { if strings.Contains(briefing, forced) { t.Fatalf("rule preamble contains forced-localize wording %q: %s", forced, briefing) @@ -96,19 +106,32 @@ func TestRunSessionStartEmitsNeutralRoutingAndIdentifierGuidance(t *testing.T) { "the rejected request does not count as the accepted recovery", "Do not call host Read, Grep, Glob, or Bash", "At `answer_ready`", - "respond from `completion.final_response`", - "one aligned file/symbol tuple", + "bounded localization is complete and retained", + "localization-only request", + "respond directly from `completion.final_response`", + "do not call another tool merely to repeat the same search", + "An identical `explore.localize` call replays the retained terminal payload", + "Ordinary Gortex tools remain live", + "for contradictory evidence or a diagnosis/change workflow", + "continue reading, analysis, impact, editing, and verification normally", + "each EVIDENCE #N row", + "one aligned rank/role/file/symbol tuple", "preserve the PRIMARY file and symbol identities", "SUPPORTING rows are optional context", - "intentionally not executed and replays the same retained terminal payload", - "not stale or canned output or an integration failure", } { if !strings.Contains(context, required) { t.Fatalf("SessionStart event missing %q: %s", required, context) } } - if strings.Contains(context, "including a request framed as diagnosis or a why question") { - t.Fatalf("SessionStart event contains forced-localize diagnosis wording: %s", context) + for _, forbidden := range []string{ + "including a request framed as diagnosis or a why question", + "make no further tool calls", + "Any later Gortex navigation call for that same task is intentionally not executed", + "not stale or canned output or an integration failure", + } { + if strings.Contains(context, forbidden) { + t.Fatalf("SessionStart event contains obsolete wording %q: %s", forbidden, context) + } } } diff --git a/internal/localizationauth/receipt.go b/internal/localizationauth/receipt.go index 5e6c980c3..9d5cdbbed 100644 --- a/internal/localizationauth/receipt.go +++ b/internal/localizationauth/receipt.go @@ -32,8 +32,9 @@ const ( ) // Receipt is written only by the MCP response path after it has constructed an -// answer_ready contract. PostToolUse treats this server-owned record, rather -// than its visible tool_response, as the terminal authority. +// answer_ready contract. PostToolUse uses this authenticated record, rather than +// visible tool_response text, to attach advisory localization context. A receipt +// never grants permission to deny or replace a later tool call. type Receipt struct { FinalResponse string `json:"final_response"` ContractVersion int `json:"contract_version"` diff --git a/internal/mcp/explore_artifact_intent.go b/internal/mcp/explore_artifact_intent.go index 0270bf984..8753a8a1b 100644 --- a/internal/mcp/explore_artifact_intent.go +++ b/internal/mcp/explore_artifact_intent.go @@ -29,6 +29,12 @@ type exploreArtifactIntent struct { probes []string } +type exploreArtifactProbeCandidate struct { + value string + priority int + order int +} + type exploreArtifactHit struct { file *graph.Node path string @@ -48,9 +54,11 @@ type exploreArtifactLane struct { } var ( - exploreArtifactPathRE = regexp.MustCompile(`[A-Za-z0-9_@+.-]*(?:[\\/][A-Za-z0-9_@+.-]+)+|[A-Za-z0-9_@+-]+(?:\.[A-Za-z0-9_@+-]+)+`) - exploreArtifactProbeRE = regexp.MustCompile("`[^`\\n]{2,96}`|\\\"[^\\\"\\n]{2,96}\\\"|'[^'\\n]{2,96}'|(?:^|\\s)--?[A-Za-z][A-Za-z0-9_.-]{1,63}|\\b[A-Z][A-Z0-9_]{2,63}\\b") - exploreArtifactCallRE = regexp.MustCompile(`\b[A-Za-z_][A-Za-z0-9_]*(?:(?:::|\.)[A-Za-z_][A-Za-z0-9_]*)?\s*\(`) + exploreArtifactPathRE = regexp.MustCompile(`[A-Za-z0-9_@+.-]*(?:[\\/][A-Za-z0-9_@+.-]+)+|[A-Za-z0-9_@+-]+(?:\.[A-Za-z0-9_@+-]+)+`) + exploreArtifactProbeRE = regexp.MustCompile("`[^`\\n]{2,96}`|\\\"[^\\\"\\n]{2,96}\\\"|'[^'\\n]{2,96}'|(?:^|\\s)--?[A-Za-z][A-Za-z0-9_.-]{1,63}|\\b[A-Z][A-Z0-9_]{2,63}\\b") + exploreArtifactPropertyRE = regexp.MustCompile(`(?i)(?:^|[\s,;])/(?:p|property):([A-Za-z_][A-Za-z0-9_.-]{1,63})`) + exploreArtifactAssignmentRE = regexp.MustCompile(`\b([A-Za-z_][A-Za-z0-9_.-]{1,63})\s*=`) + exploreArtifactCallRE = regexp.MustCompile(`\b[A-Za-z_][A-Za-z0-9_]*(?:(?:::|\.)[A-Za-z_][A-Za-z0-9_]*)?\s*\(`) ) func classifyExploreArtifactIntent(task string) exploreArtifactIntent { @@ -68,6 +76,17 @@ func classifyExploreArtifactIntent(task string) exploreArtifactIntent { seen[key] = struct{}{} out.paths = append(out.paths, raw) } + addSemanticPath := func(word string) { + word = strings.ToLower(strings.TrimSpace(word)) + if word == "" || len(out.paths) == exploreArtifactPathLimit { + return + } + if _, ok := seen[word]; ok { + return + } + seen[word] = struct{}{} + out.paths = append(out.paths, word) + } for _, raw := range exploreArtifactPathRE.FindAllString(task, -1) { addPath(raw) } @@ -80,14 +99,15 @@ func classifyExploreArtifactIntent(task string) exploreArtifactIntent { out.explicitCount = len(out.paths) artifactScore, sourceScore := 0, 0 - for _, word := range exploreArtifactWords(task) { + semanticPaths := make([]string, 0, 8) + seenSemantic := make(map[string]struct{}) + for _, rawWord := range exploreArtifactWords(task) { + word := canonicalExploreArtifactWord(rawWord) if exploreArtifactWord(word) { artifactScore++ - if exploreArtifactPathWord(word) && len(out.paths) < exploreArtifactPathLimit { - if _, ok := seen[word]; !ok { - seen[word] = struct{}{} - out.paths = append(out.paths, word) - } + if _, ok := seenSemantic[word]; !ok { + seenSemantic[word] = struct{}{} + semanticPaths = append(semanticPaths, word) } } if exploreSourceWord(word) { @@ -103,26 +123,159 @@ func classifyExploreArtifactIntent(task string) exploreArtifactIntent { sourceScore += 2 } out.semantic = artifactScore >= 2 - for _, raw := range exploreArtifactProbeRE.FindAllString(task, -1) { - probe := strings.TrimSpace(strings.Trim(raw, "`'\"")) - probe = strings.TrimSpace(probe) - if len(probe) < 2 || exploreArtifactFile(probe) || strings.EqualFold(probe, "CI") { - continue + for _, word := range semanticPaths { + if exploreArtifactPathWord(word) { + addSemanticPath(word) } - key := strings.ToLower(probe) - if _, ok := seen[key]; ok { + } + out.probes = rankedExploreArtifactProbes(task, seen) + // "build configuration" alone is too broad to scan every artifact file. + // Build becomes a secondary filename family only after an explicit artifact, + // another semantic path family, or a distinctive content probe activates a + // genuinely searchable lane. Environment/property probes such as TF_BUILD + // also carry that family even though their separators are intentionally kept + // intact by exploreArtifactWords. + _, buildMentioned := seenSemantic["build"] + for _, probe := range out.probes { + for _, word := range strings.FieldsFunc(probe, func(r rune) bool { + return !unicode.IsLetter(r) && !unicode.IsDigit(r) + }) { + if strings.EqualFold(word, "build") { + buildMentioned = true + break + } + } + } + if buildMentioned && (out.explicitCount > 0 || len(out.paths) > 0 || len(out.probes) > 0) { + addSemanticPath("build") + } + // A model may faithfully ask for both "source/configuration files" and + // "precise symbols" even when the supplied anchors are overwhelmingly + // artifact-shaped. Do not let those incidental source nouns veto a lane + // corroborated by at least three artifact terms and two distinctive probes. + // The thresholds keep ordinary config-parser and settings-class tasks on the + // source path. + strongSemanticEvidence := out.semantic && artifactScore >= 3 && len(out.probes) >= 2 + artifactEligible := out.explicitCount > 0 || (out.semantic && (sourceScore == 0 || strongSemanticEvidence)) + out.active = artifactEligible && (len(out.paths) > 0 || len(out.probes) > 0) + return out +} + +func canonicalExploreArtifactWord(word string) string { + switch strings.ToLower(word) { + case "artifacts": + return "artifact" + case "deployments": + return "deployment" + case "pipelines": + return "pipeline" + case "releases": + return "release" + case "settings": + return "setting" + case "workflows": + return "workflow" + default: + return strings.ToLower(word) + } +} + +func rankedExploreArtifactProbes(task string, seen map[string]struct{}) []string { + candidates := make(map[string]exploreArtifactProbeCandidate) + add := func(value string, priority, order int) { + value = strings.TrimSpace(strings.Trim(value, "`'\"")) + if len(value) < 2 || exploreArtifactFile(value) || strings.EqualFold(value, "CI") { + return + } + key := strings.ToLower(value) + if _, exists := seen[key]; exists { + return + } + candidate := exploreArtifactProbeCandidate{value: value, priority: priority, order: order} + if current, exists := candidates[key]; exists && + (current.priority > candidate.priority || (current.priority == candidate.priority && current.order <= candidate.order)) { + return + } + candidates[key] = candidate + } + for _, match := range exploreArtifactPropertyRE.FindAllStringSubmatchIndex(task, -1) { + if len(match) >= 4 && match[2] >= 0 { + add(task[match[2]:match[3]], 500, match[2]) + } + } + for _, match := range exploreArtifactAssignmentRE.FindAllStringSubmatchIndex(task, -1) { + if len(match) < 4 || match[2] < 0 { continue } + value := task[match[2]:match[3]] + priority := 220 + if exploreArtifactEnvironmentProbe(value) { + priority = 450 + } else if exploreArtifactCamelProbe(value) { + priority = 400 + } + add(value, priority, match[2]) + } + for _, match := range exploreArtifactProbeRE.FindAllStringIndex(task, -1) { + raw := task[match[0]:match[1]] + trimmed := strings.TrimSpace(raw) + priority := 100 + switch { + case strings.HasPrefix(trimmed, "`") || strings.HasPrefix(trimmed, "\"") || strings.HasPrefix(trimmed, "'"): + priority = 350 + case strings.HasPrefix(trimmed, "-"): + priority = 300 + case exploreArtifactEnvironmentProbe(trimmed): + priority = 425 + } + add(trimmed, priority, match[0]) + } + ordered := make([]exploreArtifactProbeCandidate, 0, len(candidates)) + for _, candidate := range candidates { + ordered = append(ordered, candidate) + } + sort.Slice(ordered, func(i, j int) bool { + if ordered[i].priority != ordered[j].priority { + return ordered[i].priority > ordered[j].priority + } + if ordered[i].order != ordered[j].order { + return ordered[i].order < ordered[j].order + } + return ordered[i].value < ordered[j].value + }) + out := make([]string, 0, exploreArtifactProbeLimit) + for _, candidate := range ordered { + key := strings.ToLower(candidate.value) seen[key] = struct{}{} - out.probes = append(out.probes, probe) - if len(out.probes) == exploreArtifactProbeLimit { + out = append(out, candidate.value) + if len(out) == exploreArtifactProbeLimit { break } } - out.active = (out.explicitCount > 0 || (sourceScore == 0 && out.semantic)) && (len(out.paths) > 0 || len(out.probes) > 0) return out } +func exploreArtifactEnvironmentProbe(value string) bool { + if !strings.Contains(value, "_") { + return false + } + for _, r := range value { + if unicode.IsLower(r) || (!unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '_') { + return false + } + } + return true +} + +func exploreArtifactCamelProbe(value string) bool { + hasLower, hasUpper := false, false + for _, r := range value { + hasLower = hasLower || unicode.IsLower(r) + hasUpper = hasUpper || unicode.IsUpper(r) + } + return hasLower && hasUpper +} + const ( exploreArtifactNames = "|dockerfile|makefile|justfile|gemfile|brewfile|cargo.toml|cargo.lock|go.mod|go.sum|package.json|package-lock.json|pnpm-lock.yaml|yarn.lock|pom.xml|directory.build.props|directory.build.targets|tsconfig.json|" exploreArtifactExtensions = "|.cfg|.conf|.config|.csproj|.editorconfig|.env|.fsproj|.gradle|.hcl|.ini|.json|.lock|.manifest|.props|.properties|.sln|.targets|.tf|.toml|.vbproj|.xml|.yaml|.yml|" @@ -150,6 +303,30 @@ func exploreArtifactPathWord(word string) bool { return exploreInSet(exploreArti func exploreSourceWord(word string) bool { return exploreInSet(exploreSourceWordsSet, word) } func exploreSourceExtension(ext string) bool { return exploreInSet(exploreSourceExtsSet, ext) } +const exploreArtifactToolMetadataRoots = "|.claude|.codegraph|.codex|.flow|.gitnexus|.graphify|.serena|graphify-out|" + +func exploreArtifactPathEligible(intent exploreArtifactIntent, path string) bool { + normalized := strings.TrimPrefix(strings.ReplaceAll(strings.TrimSpace(path), "\\", "/"), "./") + root, _, _ := strings.Cut(normalized, "/") + if !exploreInSet(exploreArtifactToolMetadataRoots, root) { + return true + } + // Tool/session metadata is not implementation evidence merely because it + // repeats issue vocabulary. It remains searchable when the task names that + // path or basename explicitly, which preserves real configuration work. + for index, explicit := range intent.paths { + if index >= intent.explicitCount { + break + } + explicit = strings.TrimPrefix(strings.ReplaceAll(strings.TrimSpace(explicit), "\\", "/"), "./") + if strings.EqualFold(explicit, normalized) || + (!strings.Contains(explicit, "/") && strings.EqualFold(filepath.Base(explicit), filepath.Base(normalized))) { + return true + } + } + return false +} + // gatherExploreArtifactLane reuses search(files)' graph file nodes and // search(text)'s trigram backend. The inactive path returns before either I/O. func (s *Server) gatherExploreArtifactLane(ctx context.Context, intent exploreArtifactIntent, scope query.QueryOptions) exploreArtifactLane { @@ -163,6 +340,9 @@ func (s *Server) gatherExploreArtifactLane(ctx context.Context, intent exploreAr continue } rel := repoRelativePath(node) + if !exploreArtifactPathEligible(intent, rel) { + continue + } hit := &exploreArtifactHit{file: node, path: rel} files = append(files, hit) key := strings.ToLower(strings.ReplaceAll(rel, "\\", "/")) @@ -171,38 +351,7 @@ func (s *Server) gatherExploreArtifactLane(ctx context.Context, intent exploreAr byPath[strings.ToLower(node.RepoPrefix)+"/"+key] = hit } } - exactBasenames := make(map[string]int) - for _, hit := range files { - for i, term := range intent.paths { - score, ok := scoreFilenameMatch(term, filepath.Base(hit.path), hit.path, false) - if !ok { - continue - } - hit.pathHit = true - hit.score += score - if i >= intent.explicitCount { - continue - } - normalizedTerm := strings.TrimPrefix(strings.ReplaceAll(term, "\\", "/"), "./") - normalizedHit := strings.TrimPrefix(strings.ReplaceAll(hit.path, "\\", "/"), "./") - switch { - case strings.Contains(normalizedTerm, "/") && strings.EqualFold(normalizedTerm, normalizedHit): - hit.fullPath = true - hit.score += 20 - case strings.EqualFold(filepath.Base(term), filepath.Base(hit.path)): - hit.exactBase = strings.ToLower(filepath.Base(term)) - exactBasenames[hit.exactBase]++ - hit.score += 20 - } - } - } - for _, hit := range files { - hit.uniqueBase = hit.exactBase != "" && exactBasenames[hit.exactBase] == 1 - } - sort.Slice(files, func(i, j int) bool { return files[i].score > files[j].score }) - if len(files) > exploreArtifactPathLimit { - files = files[:exploreArtifactPathLimit] - } + files = rankExploreArtifactPathHits(files, intent) kept := make(map[*graph.Node]*exploreArtifactHit, len(files)) for _, hit := range files { if hit.pathHit { @@ -227,8 +376,10 @@ func (s *Server) gatherExploreArtifactLane(ctx context.Context, intent exploreAr if hit == nil || !exploreArtifactFile(hit.path) { continue } + if !hit.contentHit { + hit.score += 5 + } hit.contentHit = true - hit.score += 5 hit.declaration = match.SymbolID if hit.snippet == "" { hit.snippet = truncateExploreArtifactSnippet(match.Text) @@ -240,15 +391,7 @@ func (s *Server) gatherExploreArtifactLane(ctx context.Context, intent exploreAr for _, hit := range kept { results = append(results, hit) } - sort.SliceStable(results, func(i, j int) bool { - if results[i].score != results[j].score { - return results[i].score > results[j].score - } - return results[i].path < results[j].path - }) - if len(results) > exploreArtifactResultLimit { - results = results[:exploreArtifactResultLimit] - } + results = selectExploreArtifactResults(results, exploreArtifactResultLimit) ids := make([]string, 0, len(results)) for _, hit := range results { if hit.declaration != "" { @@ -274,6 +417,102 @@ func (s *Server) gatherExploreArtifactLane(ctx context.Context, intent exploreAr return lane } +func rankExploreArtifactPathHits(files []*exploreArtifactHit, intent exploreArtifactIntent) []*exploreArtifactHit { + exactBasenames := make(map[string]int) + for _, hit := range files { + if hit == nil { + continue + } + explicitScore, semanticScore, explicitBonus := 0, 0, 0 + for i, term := range intent.paths { + score, ok := scoreFilenameMatch(term, filepath.Base(hit.path), hit.path, false) + if !ok { + continue + } + hit.pathHit = true + if i >= intent.explicitCount { + if score > semanticScore { + semanticScore = score + } + continue + } + if score > explicitScore { + explicitScore = score + } + normalizedTerm := strings.TrimPrefix(strings.ReplaceAll(term, "\\", "/"), "./") + normalizedHit := strings.TrimPrefix(strings.ReplaceAll(hit.path, "\\", "/"), "./") + switch { + case strings.Contains(normalizedTerm, "/") && strings.EqualFold(normalizedTerm, normalizedHit): + hit.fullPath = true + explicitBonus = 20 + case strings.EqualFold(filepath.Base(term), filepath.Base(hit.path)): + hit.exactBase = strings.ToLower(filepath.Base(term)) + exactBasenames[hit.exactBase]++ + explicitBonus = 20 + } + } + hit.score += explicitScore + semanticScore + explicitBonus + } + for _, hit := range files { + if hit != nil { + hit.uniqueBase = hit.exactBase != "" && exactBasenames[hit.exactBase] == 1 + } + } + sort.SliceStable(files, func(i, j int) bool { return exploreArtifactHitLess(files[i], files[j]) }) + if len(files) > exploreArtifactPathLimit { + files = files[:exploreArtifactPathLimit] + } + return files +} + +func selectExploreArtifactResults(results []*exploreArtifactHit, limit int) []*exploreArtifactHit { + sort.SliceStable(results, func(i, j int) bool { return exploreArtifactHitLess(results[i], results[j]) }) + if limit <= 0 || len(results) <= limit { + return results + } + selected := make([]*exploreArtifactHit, 0, limit) + deferred := make([]*exploreArtifactHit, 0) + exactBaseCounts := make(map[string]int) + for _, hit := range results { + if hit == nil { + continue + } + if hit.exactBase != "" && !hit.fullPath && exactBaseCounts[hit.exactBase] >= 2 { + deferred = append(deferred, hit) + continue + } + selected = append(selected, hit) + if hit.exactBase != "" { + exactBaseCounts[hit.exactBase]++ + } + if len(selected) == limit { + return selected + } + } + for _, hit := range deferred { + selected = append(selected, hit) + if len(selected) == limit { + break + } + } + return selected +} + +func exploreArtifactHitLess(left, right *exploreArtifactHit) bool { + if left == nil || right == nil { + return right == nil && left != nil + } + if left.score != right.score { + return left.score > right.score + } + leftDepth := strings.Count(strings.Trim(strings.ReplaceAll(left.path, "\\", "/"), "/"), "/") + rightDepth := strings.Count(strings.Trim(strings.ReplaceAll(right.path, "\\", "/"), "/"), "/") + if leftDepth != rightDepth { + return leftDepth < rightDepth + } + return left.path < right.path +} + func truncateExploreArtifactSnippet(snippet string) string { if len(snippet) <= exploreArtifactSnippetLimit { return snippet diff --git a/internal/mcp/explore_artifact_intent_test.go b/internal/mcp/explore_artifact_intent_test.go index e87294b75..139a74948 100644 --- a/internal/mcp/explore_artifact_intent_test.go +++ b/internal/mcp/explore_artifact_intent_test.go @@ -3,6 +3,7 @@ package mcp import ( "context" "fmt" + "path/filepath" "strings" "testing" "unicode/utf8" @@ -41,7 +42,7 @@ func TestClassifyExploreArtifactIntentUsesIndependentSignals(t *testing.T) { if !got.active || got.explicitCount != 0 || !got.semantic { t.Fatalf("intent=%#v", got) } - if len(got.probes) != exploreArtifactProbeLimit || got.probes[0] != "CollectCoverage" || got.probes[1] != "COVERAGE_FORMAT" { + if len(got.probes) != exploreArtifactProbeLimit || !containsFold(got.probes, "CollectCoverage") || !containsFold(got.probes, "COVERAGE_FORMAT") { t.Fatalf("probes=%q", got.probes) } if len(got.paths) == 0 { // semantic filename channel (ci/coverage) @@ -49,6 +50,69 @@ func TestClassifyExploreArtifactIntentUsesIndependentSignals(t *testing.T) { } } +func TestClassifyExploreArtifactIntentRanksDistinctiveBuildSignals(t *testing.T) { + t.Parallel() + task := "LOCALIZE ONLY: testconfig.json settings differ across Pipelines and CI coverage. MTP is enabled, TF_BUILD is true, and /p:EmitCompilerGeneratedFiles=true." + got := classifyExploreArtifactIntent(task) + if !got.active || !containsFold(got.paths, "pipeline") || !containsFold(got.paths, "build") { + t.Fatalf("intent=%#v", got) + } + if len(got.probes) != exploreArtifactProbeLimit || got.probes[0] != "EmitCompilerGeneratedFiles" || got.probes[1] != "TF_BUILD" { + t.Fatalf("distinctive probes lost to directive words: %q", got.probes) + } +} + +func TestRankExploreArtifactHitsPreservesIndependentRootLeads(t *testing.T) { + t.Parallel() + intent := classifyExploreArtifactIntent("testconfig.json differs across Pipelines and CI coverage; TF_BUILD is true and /p:EmitCompilerGeneratedFiles=true") + paths := []string{ + "tests/a/testconfig.json", "tests/b/testconfig.json", "tests/c/testconfig.json", + "tests/d/testconfig.json", "tests/e/testconfig.json", "tests/f/testconfig.json", + "Directory.Build.props", "azure-pipelines.yml", + ".flow/checkpoints/fix-build-ci-code-coverage.json", + ".flow/fix-ci-code-coverage.json", + } + hits := make([]*exploreArtifactHit, 0, len(paths)) + for _, path := range paths { + hits = append(hits, &exploreArtifactHit{path: path}) + } + ranked := rankExploreArtifactPathHits(hits, intent) + for _, hit := range ranked { + if hit.path == "Directory.Build.props" { + hit.contentHit = true + hit.score += 5 + } + if strings.HasPrefix(hit.path, ".flow/") && hit.score > 50 { + t.Fatalf("semantic term stuffing accumulated score for %s: %d", hit.path, hit.score) + } + } + selected := selectExploreArtifactResults(ranked, exploreArtifactResultLimit) + if !artifactHitsContainPath(selected, "Directory.Build.props") || !artifactHitsContainPath(selected, "azure-pipelines.yml") { + t.Fatalf("independent root leads were starved: %#v", selected) + } + duplicates := 0 + for _, hit := range selected { + if strings.EqualFold(filepath.Base(hit.path), "testconfig.json") { + duplicates++ + } + } + if duplicates > 2 { + t.Fatalf("repeated basename consumed %d result slots", duplicates) + } +} + +func TestClassifyExploreArtifactIntentAcceptsStrongArtifactTaskWithIncidentalSourceNouns(t *testing.T) { + t.Parallel() + task := "Localize bug report “Simplify CI code coverage configuration”. Identify the source/configuration files and precise symbols that would need changing to reduce/centralize the current Microsoft.Testing.Platform (MTP) code coverage settings (DeterministicReport=true, ExcludeAssembliesWithoutSources=None, ModulePaths.Include limiting coverage to Humanizer/Humanizer.Analyzers/Humanizer.SourceGenerators) and Azure Pipelines invocation settings (/p:EmitCompilerGeneratedFiles=true; ReportGenerator sourcedirs=$(Build.SourcesDirectory) and settings:rawMode=false while merging per-test-project Cobertura files). Preserve all user identifiers. Do not fix." + got := classifyExploreArtifactIntent(task) + if !got.active || !got.semantic || len(got.probes) < 2 { + t.Fatalf("strong mixed artifact intent was vetoed: %#v", got) + } + if !containsFold(got.probes, "DeterministicReport") || !containsFold(got.probes, "ExcludeAssembliesWithoutSources") { + t.Fatalf("distinctive artifact probes were not retained: %q", got.probes) + } +} + func TestClassifyExploreArtifactIntentRejectsSourceTasks(t *testing.T) { t.Parallel() for _, task := range []string{ @@ -165,6 +229,47 @@ func TestExploreArtifactFileRecognition(t *testing.T) { } } +func TestExploreArtifactPathEligibleFiltersImplicitToolMetadata(t *testing.T) { + t.Parallel() + implicit := classifyExploreArtifactIntent("Simplify CI code coverage configuration using testconfig.json and ReportGenerator") + for _, path := range []string{ + ".codex/agents/build-scout.toml", + ".flow/.checkpoint-fix-ci-code-coverage.json", + ".gitnexus/cache/coverage.json", + } { + if exploreArtifactPathEligible(implicit, path) { + t.Errorf("implicit tool metadata %q is eligible", path) + } + } + for _, path := range []string{"Directory.Build.props", "azure-pipelines.yml", ".github/workflows/ci.yml"} { + if !exploreArtifactPathEligible(implicit, path) { + t.Errorf("repository artifact %q was filtered", path) + } + } + explicit := classifyExploreArtifactIntent("Change .codex/agents/build-scout.toml build configuration") + if !exploreArtifactPathEligible(explicit, ".codex/agents/build-scout.toml") { + t.Fatal("an explicitly named tool configuration must remain eligible") + } +} + +func containsFold(values []string, want string) bool { + for _, value := range values { + if strings.EqualFold(value, want) { + return true + } + } + return false +} + +func artifactHitsContainPath(hits []*exploreArtifactHit, want string) bool { + for _, hit := range hits { + if hit != nil && strings.EqualFold(hit.path, want) { + return true + } + } + return false +} + func queryOptionsForArtifactTest() query.QueryOptions { return query.QueryOptions{} } func BenchmarkClassifyExploreArtifactIntentOrdinaryCodeTask(b *testing.B) { diff --git a/internal/mcp/explore_causal_change.go b/internal/mcp/explore_causal_change.go new file mode 100644 index 000000000..91ed2dbe7 --- /dev/null +++ b/internal/mcp/explore_causal_change.go @@ -0,0 +1,652 @@ +package mcp + +import ( + "sort" + "strings" + + "github.com/zzet/gortex/internal/graph" +) + +const ( + exploreCausalChangeFileNodeCap = 96 + exploreCausalConsumerSeedLimit = 5 + exploreCausalConsumerDepth = 3 + exploreCausalConsumerFrontierCap = 24 + exploreCausalConsumerNodeFanoutCap = 8 +) + +type exploreCausalChangeCandidate struct { + node *graph.Node + continuation *graph.Node + parentID string + parentRank int + direction string + hop int + delegated bool + consumer bool + crossFile bool + overlap int + longest int +} + +type exploreCausalChangeHydrator func(*graph.Node) ([]*graph.Node, bool) + +// promoteExploreCausalChangeTargets reserves one graph-proven change site +// before localization packing. It starts only from hydrated, task-aligned +// production callables and follows their already-bounded direct relations or +// depth-two callee projection. This makes a wrapper's implementation and a +// task-aligned cross-file caller/callee compete on causality instead of raw +// keyword rank. +// +// When the promoted callable crosses a file boundary, one same-file owning +// type is admitted as well. A uniquely returned type wins over the callable's +// enclosing type because it owns the state constructed by builder/factory +// methods; otherwise the enclosing type is the natural change owner. The lane +// never scans a repository, reads at most one source body, and replaces only +// unprotected retrieval-tail rows. +func promoteExploreCausalChangeTargets( + task string, + targets []exploreTarget, + store graph.Store, + maxSymbols int, + readSource func(*graph.Node) string, + hydrateBridges ...exploreCausalChangeHydrator, +) []exploreTarget { + if len(targets) == 0 || !exploreQueryIsConceptTask(task) { + return targets + } + var hydrateBridge exploreCausalChangeHydrator + if len(hydrateBridges) > 0 { + hydrateBridge = hydrateBridges[0] + } + candidate, ok := selectExploreCausalChangeTargetWithConsumers(task, targets, store) + if !ok { + return targets + } + + bridge, bridgePresent := exploreTargetByID(targets, candidate.node.ID) + bridge.node = candidate.node + bridge.causalChangeBridge = true + bridge.causalChangeLeaf = false + if candidate.hop == 1 && (candidate.direction == "caller" || candidate.direction == "callee") { + bridge.localizationRelation = "direct_" + candidate.direction + } + if strings.TrimSpace(bridge.source) == "" && readSource != nil { + bridge.source = readSource(candidate.node) + } + if !bridge.directCalleesComplete && hydrateBridge != nil { + bridge.callees, bridge.directCalleesComplete = hydrateBridge(candidate.node) + } + + leaf := bridge + leafPresent := bridgePresent + bridgeNeeded := false + if continuation := selectExploreCausalContinuation(task, bridge, candidate.continuation); continuation != nil { + leaf, leafPresent = exploreTargetByID(targets, continuation.ID) + leaf.node = continuation + leaf.causalChangeBridge = false + leaf.causalChangeLeaf = true + leaf.localizationRelation = "direct_callee" + bridgeNeeded = true + } else { + leaf.causalChangeBridge = false + leaf.causalChangeLeaf = true + } + + var owner exploreTarget + ownerPresent := false + if candidate.crossFile { + if node := exploreCausalChangeOwner(task, candidate.node, store); node != nil { + owner, ownerPresent = exploreTargetByID(targets, node.ID) + owner.node = node + owner.causalChangeOwner = true + } + } + + missing := 0 + if bridgeNeeded && !bridgePresent { + missing++ + } + if !leafPresent { + missing++ + } + if owner.node != nil && !ownerPresent { + missing++ + } + if maxSymbols <= 0 { + maxSymbols = len(targets) + } + overflow := len(targets) + missing - maxSymbols + if overflow < 0 { + overflow = 0 + } + + evict := make(map[int]struct{}, overflow) + for index := len(targets) - 1; index >= 0 && len(evict) < overflow; index-- { + if exploreCausalChangeAdmissionProtected(task, targets, index, candidate.parentID) { + continue + } + evict[index] = struct{}{} + } + if len(evict) != overflow { + return targets + } + + promoted := make([]exploreTarget, 0, len(targets)-len(evict)+missing) + for index, target := range targets { + if _, drop := evict[index]; drop { + continue + } + switch { + case target.node != nil && target.node.ID == leaf.node.ID: + promoted = append(promoted, leaf) + case bridgeNeeded && target.node != nil && target.node.ID == bridge.node.ID: + promoted = append(promoted, bridge) + case owner.node != nil && target.node != nil && target.node.ID == owner.node.ID: + promoted = append(promoted, owner) + default: + promoted = append(promoted, target) + } + } + if bridgeNeeded && !bridgePresent { + promoted = append(promoted, bridge) + } + if !leafPresent { + promoted = append(promoted, leaf) + } + if owner.node != nil && !ownerPresent { + promoted = append(promoted, owner) + } + return promoted +} + +type exploreCausalContinuationMetric struct { + node *graph.Node + hinted bool + delegated bool + sameOwner bool + compound bool + overlap int + mentions int + segments int + shared int + longest int +} + +func selectExploreCausalContinuation(task string, bridge exploreTarget, hinted *graph.Node) *graph.Node { + if bridge.node == nil || !bridge.directCalleesComplete || len(bridge.callees) == 0 { + return nil + } + query := shapeExploreQuery(task) + if exploreQueryIsConceptTask(query) { + query = stripLeadingExploreDirective(query) + } + terms := exploreTerminalTerms(query) + seen := make(map[string]struct{}, len(bridge.callees)) + var best exploreCausalContinuationMetric + tied := false + compare := func(left, right exploreCausalContinuationMetric) int { + boolCompare := func(a, b bool) int { + switch { + case a && !b: + return 1 + case !a && b: + return -1 + default: + return 0 + } + } + for _, pair := range [][2]bool{ + {left.hinted, right.hinted}, + {left.delegated, right.delegated}, + {left.sameOwner, right.sameOwner}, + {left.compound, right.compound}, + } { + if order := boolCompare(pair[0], pair[1]); order != 0 { + return order + } + } + for _, pair := range [][2]int{ + {left.overlap, right.overlap}, + {left.mentions, right.mentions}, + {left.segments, right.segments}, + {left.shared, right.shared}, + {left.longest, right.longest}, + } { + if pair[0] > pair[1] { + return 1 + } + if pair[0] < pair[1] { + return -1 + } + } + return 0 + } + + bridgePath := exploreNormalizedPath(nodeDisplayPath(bridge.node)) + for _, node := range bridge.callees { + if node == nil || node.ID == "" || node.ID == bridge.node.ID || + (node.Kind != graph.KindFunction && node.Kind != graph.KindMethod) || + exploreDraftIsTestNode(node) || !exploreNodesShareExactScope(bridge.node, node) { + continue + } + if _, duplicate := seen[node.ID]; duplicate { + continue + } + seen[node.ID] = struct{}{} + sameOwner := exploreSameCallableOwner(bridge.node, node) + sameFile := bridgePath != "" && bridgePath == exploreNormalizedPath(nodeDisplayPath(node)) + if !sameOwner && !sameFile { + continue + } + delegated := exploreDelegatedImplementationCallee(bridge.node, node) + mentions := exploreBodyIdentifierMentions(bridge.source, node.Name) + if !delegated && mentions == 0 { + continue + } + overlap, longest := exploreDraftTermOverlap(terms, node) + shared, _ := exploreStructuralSignals(bridge.node, node) + segments := exploreIdentifierSegmentCount(node.Name) + metric := exploreCausalContinuationMetric{ + node: node, hinted: hinted != nil && hinted.ID == node.ID, + delegated: delegated, sameOwner: sameOwner, compound: segments >= 2, + overlap: overlap, mentions: mentions, segments: segments, + shared: shared, longest: longest, + } + if best.node == nil { + best, tied = metric, false + continue + } + switch compare(metric, best) { + case 1: + best, tied = metric, false + case 0: + tied = true + } + } + if best.node == nil || tied { + return nil + } + return best.node +} + +func selectExploreCausalChangeTargetWithConsumers( + task string, + targets []exploreTarget, + store graph.Store, +) (exploreCausalChangeCandidate, bool) { + direct, directOK := selectExploreCausalChangeTarget(task, targets) + consumer, consumerOK := selectExploreCausalConsumerTarget(task, targets, store) + switch { + case !consumerOK: + return direct, directOK + case !directOK || exploreCausalChangeLess(consumer, direct): + return consumer, true + default: + return direct, true + } +} + +// selectExploreCausalConsumerTarget fills one missing implementation slot by +// following reverse call edges from the bounded ranked head. It collapses only +// same-file wrappers and admits only a task-aligned production callable in a +// directory already represented by the evidence page. That directory join is +// the proof that a cross-file caller connects two retrieved feature families; +// it prevents a generic high-fanout caller walk from becoming another broad +// search channel. +func selectExploreCausalConsumerTarget( + task string, + targets []exploreTarget, + store graph.Store, +) (exploreCausalChangeCandidate, bool) { + if store == nil || len(targets) == 0 { + return exploreCausalChangeCandidate{}, false + } + terms := exploreTerminalTerms(shapeExploreQuery(task)) + if len(terms) == 0 { + return exploreCausalChangeCandidate{}, false + } + targetIDs := make(map[string]struct{}, len(targets)) + targetDirectories := make(map[string]struct{}, len(targets)) + for _, target := range targets { + if target.node == nil || target.node.ID == "" { + continue + } + targetIDs[target.node.ID] = struct{}{} + if directory := exploreCausalChangeDirectory(target.node); directory != "" { + targetDirectories[directory] = struct{}{} + } + } + if len(targetDirectories) == 0 { + return exploreCausalChangeCandidate{}, false + } + + byID := make(map[string]exploreCausalChangeCandidate) + seedLimit := min(len(targets), exploreCausalConsumerSeedLimit) + for parentRank := 0; parentRank < seedLimit; parentRank++ { + seed := targets[parentRank] + if seed.node == nil || seed.node.ID == "" || + (seed.node.Kind != graph.KindFunction && seed.node.Kind != graph.KindMethod) || + exploreDraftIsTestNode(seed.node) { + continue + } + seedPath := exploreNormalizedPath(nodeDisplayPath(seed.node)) + if seedPath == "" { + continue + } + expanded := make(map[string]struct{}, exploreCausalConsumerFrontierCap) + frontier := make([]*graph.Node, 0, 1+len(seed.callees)) + appendFrontier := func(node *graph.Node) { + if node == nil || node.ID == "" || !exploreNodesShareExactScope(seed.node, node) { + return + } + if _, exists := expanded[node.ID]; exists { + return + } + expanded[node.ID] = struct{}{} + frontier = append(frontier, node) + } + appendFrontier(seed.node) + for _, callee := range seed.callees { + appendFrontier(callee) + } + + for depth := 1; depth <= exploreCausalConsumerDepth && len(frontier) > 0; depth++ { + sort.SliceStable(frontier, func(i, j int) bool { return frontier[i].ID < frontier[j].ID }) + if len(frontier) > exploreCausalConsumerFrontierCap { + frontier = frontier[:exploreCausalConsumerFrontierCap] + } + ids := make([]string, len(frontier)) + for index, node := range frontier { + ids[index] = node.ID + } + incoming := store.GetInEdgesByNodeIDs(ids) + callerSet := make(map[string]struct{}, exploreCausalConsumerFrontierCap) + for _, id := range ids { + local := make(map[string]struct{}, exploreCausalConsumerNodeFanoutCap+1) + for _, edge := range incoming[id] { + if edge == nil || edge.Kind != graph.EdgeCalls || edge.From == "" { + continue + } + local[edge.From] = struct{}{} + } + if len(local) > exploreCausalConsumerNodeFanoutCap { + continue + } + for callerID := range local { + callerSet[callerID] = struct{}{} + } + } + callerIDs := make([]string, 0, len(callerSet)) + for id := range callerSet { + callerIDs = append(callerIDs, id) + } + sort.Strings(callerIDs) + if len(callerIDs) > exploreCausalConsumerFrontierCap { + callerIDs = callerIDs[:exploreCausalConsumerFrontierCap] + } + nodes := store.GetNodesByIDs(callerIDs) + next := make([]*graph.Node, 0, len(callerIDs)) + for _, callerID := range callerIDs { + if _, exists := expanded[callerID]; exists { + continue + } + node := nodes[callerID] + if node == nil || !exploreNodesShareExactScope(seed.node, node) || + (node.Kind != graph.KindFunction && node.Kind != graph.KindMethod) || + exploreDraftIsTestNode(node) { + continue + } + expanded[callerID] = struct{}{} + nodePath := exploreNormalizedPath(nodeDisplayPath(node)) + if nodePath == seedPath { + next = append(next, node) + } + if nodePath == "" || nodePath == seedPath { + continue + } + if _, alreadyVisible := targetIDs[node.ID]; alreadyVisible { + continue + } + if _, represented := targetDirectories[exploreCausalChangeDirectory(node)]; !represented { + continue + } + overlap, longest := exploreDraftTermOverlap(terms, node) + if overlap < 2 && (overlap != 1 || longest < 5) { + continue + } + candidate := exploreCausalChangeCandidate{ + node: node, parentID: seed.node.ID, parentRank: parentRank, + direction: "caller", hop: depth, consumer: true, + crossFile: true, overlap: overlap, longest: longest, + } + if current, exists := byID[node.ID]; !exists || exploreCausalChangeLess(candidate, current) { + byID[node.ID] = candidate + } + } + frontier = next + } + } + if len(byID) == 0 { + return exploreCausalChangeCandidate{}, false + } + candidates := make([]exploreCausalChangeCandidate, 0, len(byID)) + for _, candidate := range byID { + candidates = append(candidates, candidate) + } + sort.SliceStable(candidates, func(i, j int) bool { + return exploreCausalChangeLess(candidates[i], candidates[j]) + }) + best := candidates[0] + bestPath := exploreNormalizedPath(nodeDisplayPath(best.node)) + for _, candidate := range candidates[1:] { + if candidate.overlap != best.overlap || candidate.longest != best.longest || candidate.hop != best.hop { + break + } + if exploreNormalizedPath(nodeDisplayPath(candidate.node)) != bestPath { + return exploreCausalChangeCandidate{}, false + } + } + return best, true +} + +func exploreCausalChangeDirectory(node *graph.Node) string { + path := exploreNormalizedPath(nodeDisplayPath(node)) + if index := strings.LastIndex(path, "/"); index > 0 { + return path[:index] + } + return "" +} + +func selectExploreCausalChangeTarget(task string, targets []exploreTarget) (exploreCausalChangeCandidate, bool) { + query := shapeExploreQuery(task) + if exploreQueryIsConceptTask(query) { + query = stripLeadingExploreDirective(query) + } + terms := exploreTerminalTerms(query) + byID := make(map[string]exploreCausalChangeCandidate) + for parentRank, target := range targets { + if !exploreHydratedProductionCallable(target) || !exploreStrongCausalSeed(query, target) { + continue + } + continuationHint := func(bridge *graph.Node) *graph.Node { + var hinted *graph.Node + for _, neighbor := range target.causalCallees { + if neighbor.hop != 2 || neighbor.node == nil || + !exploreNodesShareExactScope(bridge, neighbor.node) || + !exploreDelegatedImplementationCallee(bridge, neighbor.node) { + continue + } + if hinted != nil && hinted.ID != neighbor.node.ID { + return nil + } + hinted = neighbor.node + } + return hinted + } + consider := func(node *graph.Node, continuation *graph.Node, direction string, hop int) { + if node == nil || node.ID == "" || node.ID == target.node.ID || + (node.Kind != graph.KindFunction && node.Kind != graph.KindMethod) || + exploreDraftIsTestNode(node) || !exploreNodesShareExactScope(target.node, node) { + return + } + delegated := continuation != nil || (direction == "callee" && hop == 1 && + exploreDelegatedImplementationCallee(target.node, node)) + crossFile := exploreNormalizedPath(nodeDisplayPath(target.node)) != + exploreNormalizedPath(nodeDisplayPath(node)) + overlap, longest := exploreDraftTermOverlap(terms, node) + if !delegated && (!crossFile || overlap == 0) { + return + } + candidate := exploreCausalChangeCandidate{ + node: node, continuation: continuation, + parentID: target.node.ID, parentRank: parentRank, + direction: direction, hop: hop, delegated: delegated, + crossFile: crossFile, overlap: overlap, longest: longest, + } + if current, exists := byID[node.ID]; !exists || exploreCausalChangeLess(candidate, current) { + byID[node.ID] = candidate + } + } + for _, node := range target.callers { + consider(node, nil, "caller", 1) + } + for _, node := range target.callees { + consider(node, continuationHint(node), "callee", 1) + } + for _, neighbor := range target.causalCallees { + consider(neighbor.node, nil, "callee", neighbor.hop) + } + } + if len(byID) == 0 { + return exploreCausalChangeCandidate{}, false + } + candidates := make([]exploreCausalChangeCandidate, 0, len(byID)) + for _, candidate := range byID { + candidates = append(candidates, candidate) + } + sort.SliceStable(candidates, func(i, j int) bool { + return exploreCausalChangeLess(candidates[i], candidates[j]) + }) + return candidates[0], true +} + +func exploreCausalChangeLess(left, right exploreCausalChangeCandidate) bool { + if left.delegated != right.delegated { + return left.delegated + } + if left.consumer != right.consumer { + return left.consumer + } + if left.overlap != right.overlap { + return left.overlap > right.overlap + } + if (left.direction == "caller") != (right.direction == "caller") { + return left.direction == "caller" + } + if left.crossFile != right.crossFile { + return left.crossFile + } + if left.longest != right.longest { + return left.longest > right.longest + } + if left.hop != right.hop { + return left.hop > right.hop + } + if left.parentRank != right.parentRank { + return left.parentRank < right.parentRank + } + return exploreDraftNodeKey(left.node) < exploreDraftNodeKey(right.node) +} + +func exploreCausalChangeOwner(task string, leaf *graph.Node, store graph.Store) *graph.Node { + if leaf == nil || leaf.FilePath == "" || store == nil { + return nil + } + fileNodes := store.GetFileNodesByPaths([]string{leaf.FilePath})[leaf.FilePath] + if len(fileNodes) == 0 || len(fileNodes) > exploreCausalChangeFileNodeCap { + return nil + } + types := make([]*graph.Node, 0, 8) + for _, node := range fileNodes { + if node == nil || node.Kind != graph.KindType || exploreDraftIsTestNode(node) || + !exploreNodesShareExactScope(leaf, node) { + continue + } + types = append(types, node) + } + if len(types) == 0 { + return nil + } + sort.SliceStable(types, func(i, j int) bool { + return exploreDraftNodeKey(types[i]) < exploreDraftNodeKey(types[j]) + }) + + returnText := exploreCallableReturnText(leaf.RetrievalMetadata().Signature) + returned := make([]*graph.Node, 0, 2) + for _, node := range types { + if exploreContainsIdentifier(returnText, node.Name) { + returned = append(returned, node) + } + } + terms := exploreTerminalTerms(shapeExploreQuery(task)) + if len(returned) == 1 { + if overlap, _ := exploreDraftTermOverlap(terms, returned[0]); overlap > 0 { + return returned[0] + } + } + if len(returned) > 1 { + var aligned *graph.Node + bestOverlap := 0 + tied := false + for _, node := range returned { + overlap, _ := exploreDraftTermOverlap(terms, node) + switch { + case overlap > bestOverlap: + aligned, bestOverlap, tied = node, overlap, false + case overlap == bestOverlap && overlap > 0: + tied = true + } + } + if aligned != nil && !tied { + return aligned + } + } + + ownerID, _ := graph.EnclosingFromID(leaf.ID, leaf.Kind) + for _, node := range types { + if node.ID == ownerID { + return node + } + } + return nil +} + +func exploreCallableReturnText(signature string) string { + signature = strings.TrimSpace(signature) + open := strings.IndexByte(signature, '(') + if open < 0 { + return "" + } + close, ok := exploreMatchingParen(signature, open) + if !ok || close+1 >= len(signature) { + return "" + } + return strings.TrimSpace(signature[close+1:]) +} + +func exploreCausalChangeAdmissionProtected(task string, targets []exploreTarget, index int, parentID string) bool { + if index < 0 || index >= len(targets) { + return true + } + target := targets[index] + if target.node == nil || index < exploreDraftPrimaryLimit || target.node.ID == parentID { + return true + } + return target.causalChangeBridge || target.causalChangeLeaf || target.causalChangeOwner || + target.divergentDefaultOwner || target.divergentDefaultType || + target.conceptImplementation || target.conceptComplement || + target.exactContent || target.exactContentAmbiguous || + target.sourceLiteral || target.typedAnchorProjection || + exploreLocalizationExplicitAnchor(task, target.node) +} diff --git a/internal/mcp/explore_causal_change_test.go b/internal/mcp/explore_causal_change_test.go new file mode 100644 index 000000000..b615d4ac1 --- /dev/null +++ b/internal/mcp/explore_causal_change_test.go @@ -0,0 +1,377 @@ +package mcp + +import ( + "slices" + "testing" + + "github.com/zzet/gortex/internal/graph" +) + +func causalChangeTestNode(id, name, file string, kind graph.NodeKind, signature string) *graph.Node { + meta := map[string]any{} + if signature != "" { + meta["signature"] = signature + } + return &graph.Node{ + ID: id, Name: name, QualName: name, Kind: kind, FilePath: file, + Language: "rust", RepoPrefix: "repo", WorkspaceID: "workspace", ProjectID: "project", + StartLine: 10, EndLine: 20, Meta: meta, + } +} + +func causalChangeTargetIDs(targets []exploreTarget) []string { + ids := make([]string, 0, len(targets)) + for _, target := range targets { + if target.node != nil { + ids = append(ids, target.node.ID) + } + } + return ids +} + +func TestPromoteExploreCausalChangeTargetsReservesDelegatedLeaf(t *testing.T) { + wrapper := causalChangeTestNode( + "repo/router/tree.go::node.resolveCaseInsensitivePath", + "resolveCaseInsensitivePath", "router/tree.go", graph.KindMethod, + "func (n *node) resolveCaseInsensitivePath(path string) []byte", + ) + leaf := causalChangeTestNode( + "repo/router/tree.go::node.resolveCaseInsensitivePathRec", + "resolveCaseInsensitivePathRec", "router/tree.go", graph.KindMethod, + "func (n *node) resolveCaseInsensitivePathRec(path string) []byte", + ) + targets := []exploreTarget{ + {node: wrapper, source: "func resolveCaseInsensitivePath(path string) []byte { return n.resolveCaseInsensitivePathRec(path) }", callees: []*graph.Node{leaf}}, + {node: causalChangeTestNode("repo/router/engine.go::Engine.handle", "handle", "router/engine.go", graph.KindMethod, "func handle()")}, + {node: causalChangeTestNode("repo/router/config.go::Config.redirect", "redirect", "router/config.go", graph.KindMethod, "func redirect()")}, + {node: causalChangeTestNode("repo/router/log.go::logInvalidPath", "logInvalidPath", "router/log.go", graph.KindFunction, "func logInvalidPath()")}, + {node: causalChangeTestNode("repo/router/errors.go::invalidNodeDiagnostic", "invalidNodeDiagnostic", "router/errors.go", graph.KindFunction, "func invalidNodeDiagnostic()")}, + } + task := "Request execution panics during case insensitive path fallback instead of returning not found" + got := promoteExploreCausalChangeTargets(task, targets, nil, len(targets), func(node *graph.Node) string { + if node.ID == leaf.ID { + return "func resolveCaseInsensitivePathRec(path string) []byte { return nil }" + } + return "" + }) + + if len(got) != len(targets) { + t.Fatalf("promoted targets = %d, want fixed width %d", len(got), len(targets)) + } + for index := 0; index < exploreDraftPrimaryLimit; index++ { + if got[index].node.ID != targets[index].node.ID { + t.Fatalf("direct head row %d changed from %q to %q", index, targets[index].node.ID, got[index].node.ID) + } + } + leafIndex := exploreTargetIndex(got, leaf.ID) + if leafIndex < 0 || !got[leafIndex].causalChangeLeaf || got[leafIndex].source == "" { + t.Fatalf("delegated leaf was not reserved and hydrated: %#v", got) + } + planned := localizationEvidenceTargetsFromDraft(task, "", got, nil) + if len(planned) < 2 || planned[0].node.ID != wrapper.ID || planned[1].node.ID != leaf.ID { + t.Fatalf("causal leaf did not follow retrieval head before packing: %v", causalChangeTargetIDs(planned)) + } +} + +func TestPromoteExploreCausalChangeTargetsPrefersGraphCallerOverKeywordOnlyTail(t *testing.T) { + matcher := causalChangeTestNode( + "repo/matcher/api.rs::Matcher.replace_with_captures", + "replace_with_captures", "matcher/api.rs", graph.KindMethod, + "fn replace_with_captures(&self) -> Result<()> ", + ) + leaf := causalChangeTestNode( + "repo/output/rewrite.rs::OutputRewriter.apply_replacements", + "apply_replacements", "output/rewrite.rs", graph.KindMethod, + "fn apply_replacements(&mut self) -> io::Result<()> ", + ) + owner := causalChangeTestNode( + "repo/output/rewrite.rs::OutputRewriter", "OutputRewriter", + "output/rewrite.rs", graph.KindType, "struct OutputRewriter", + ) + g := graph.New() + g.AddNode(leaf) + g.AddNode(owner) + + directHead := causalChangeTestNode("repo/search/run.rs::Search.run", "runSearch", "search/run.rs", graph.KindMethod, "fn run_search()") + keywordOnly := causalChangeTestNode( + "repo/search/diagnostics.rs::multiline_replace_output_diagnostic", + "multiline_replace_output_diagnostic", "search/diagnostics.rs", graph.KindFunction, + "fn multiline_replace_output_diagnostic()", + ) + targets := []exploreTarget{ + {node: matcher, source: "fn replace_with_captures() { /* multiline replace */ }", callers: []*graph.Node{leaf}}, + {node: directHead}, + {node: causalChangeTestNode("repo/search/options.rs::Options.multiline", "multiline", "search/options.rs", graph.KindMethod, "fn multiline()")}, + {node: causalChangeTestNode("repo/search/output.rs::duplicate_output", "duplicate_output", "search/output.rs", graph.KindFunction, "fn duplicate_output()")}, + {node: keywordOnly}, + } + task := "Multiline replace duplicates output when replacement captures span several lines" + got := promoteExploreCausalChangeTargets(task, targets, g, len(targets), func(node *graph.Node) string { + if node.ID == leaf.ID { + return "fn apply_replacements(&mut self) -> io::Result<()> { Ok(()) }" + } + return "" + }) + + if slices.Contains(causalChangeTargetIDs(got), keywordOnly.ID) { + t.Fatalf("non-causal keyword-only tail survived ahead of graph change site: %v", causalChangeTargetIDs(got)) + } + leafIndex, ownerIndex := exploreTargetIndex(got, leaf.ID), exploreTargetIndex(got, owner.ID) + if leafIndex < 0 || ownerIndex < 0 || !got[leafIndex].causalChangeLeaf || !got[ownerIndex].causalChangeOwner { + t.Fatalf("caller/owner pair not admitted: %#v", got) + } + planned := localizationEvidenceTargetsFromDraft(task, "", got, nil) + want := []string{matcher.ID, owner.ID, leaf.ID} + if ids := causalChangeTargetIDs(planned); len(ids) < len(want) || !slices.Equal(ids[:len(want)], want) { + t.Fatalf("planned causal prefix = %v, want %v", ids, want) + } +} + +func TestExploreCausalChangeOwnerPrefersUniquelyReturnedStateType(t *testing.T) { + leaf := causalChangeTestNode( + "repo/state/build.rs::RuleBuilder.build_with_context", + "build_with_context", "state/build.rs", graph.KindMethod, + "fn build_with_context(&self, root: &Path) -> RuleState", + ) + builder := causalChangeTestNode( + "repo/state/build.rs::RuleBuilder", "RuleBuilder", "state/build.rs", graph.KindType, "struct RuleBuilder", + ) + state := causalChangeTestNode( + "repo/state/build.rs::RuleState", "RuleState", "state/build.rs", graph.KindType, "struct RuleState", + ) + g := graph.New() + g.AddNode(leaf) + g.AddNode(builder) + g.AddNode(state) + + got := exploreCausalChangeOwner("parallel roots leak rule state between traversals", leaf, g) + if got == nil || got.ID != state.ID { + t.Fatalf("change owner = %#v, want uniquely returned state type %q", got, state.ID) + } +} + +func causalChangeRouteNode(id, name, qualName, file string, kind graph.NodeKind) *graph.Node { + node := causalChangeTestNode(id, name, file, kind, "") + node.QualName = qualName + node.Language = "go" + return node +} + +func requireCausalChangeTarget(t *testing.T, targets []exploreTarget, id string) exploreTarget { + t.Helper() + index := exploreTargetIndex(targets, id) + if index < 0 { + t.Fatalf("target %q missing from %v", id, causalChangeTargetIDs(targets)) + return exploreTarget{} + } + return targets[index] +} + +func TestPromoteExploreCausalChangeTargetsCarriesCrossFileBridgeToContinuationAndOwner(t *testing.T) { + seed := causalChangeRouteNode( + "repo/crates/matcher/src/matcher.rs::Matcher.replace_with_captures_at", + "replace_with_captures_at", "Matcher.replace_with_captures_at", + "crates/matcher/src/matcher.rs", graph.KindMethod, + ) + bridge := causalChangeRouteNode( + "repo/crates/printer/src/util.rs::Replacer.replace_all", + "replace_all", "Replacer.replace_all", "crates/printer/src/util.rs", graph.KindMethod, + ) + continuation := causalChangeRouteNode( + "repo/crates/printer/src/util.rs::replace_with_captures_in_context", + "replace_with_captures_in_context", "replace_with_captures_in_context", + "crates/printer/src/util.rs", graph.KindFunction, + ) + owner := causalChangeRouteNode( + "repo/crates/printer/src/util.rs::Replacer", + "Replacer", "Replacer", "crates/printer/src/util.rs", graph.KindType, + ) + store := graph.New() + store.AddNode(bridge) + store.AddNode(continuation) + store.AddNode(owner) + + readCalls, hydrateCalls := 0, 0 + task := "The replace with captures at path should replace all matches while preserving captures in their surrounding context" + got := promoteExploreCausalChangeTargets(task, []exploreTarget{{ + node: seed, source: "replace_with_captures_at replaces captures", callers: []*graph.Node{bridge}, + }}, store, 4, func(node *graph.Node) string { + readCalls++ + if node.ID != bridge.ID { + t.Fatalf("source read for %q, want bridge %q", node.ID, bridge.ID) + } + return "func (r *Replacer) replace_all() { replace_with_captures_in_context() }" + }, func(node *graph.Node) ([]*graph.Node, bool) { + hydrateCalls++ + if node.ID != bridge.ID { + t.Fatalf("hydrated %q, want bridge %q", node.ID, bridge.ID) + } + return []*graph.Node{continuation}, true + }) + + if readCalls != 1 || hydrateCalls != 1 { + t.Fatalf("bounded reads = source:%d adjacency:%d, want 1 each", readCalls, hydrateCalls) + } + if len(got) != 4 { + t.Fatalf("promoted targets = %d, want seed + owner + bridge + continuation", len(got)) + } + bridgeTarget := requireCausalChangeTarget(t, got, bridge.ID) + leafTarget := requireCausalChangeTarget(t, got, continuation.ID) + ownerTarget := requireCausalChangeTarget(t, got, owner.ID) + if !bridgeTarget.causalChangeBridge || bridgeTarget.causalChangeLeaf { + t.Fatalf("bridge flags = bridge:%t leaf:%t", bridgeTarget.causalChangeBridge, bridgeTarget.causalChangeLeaf) + } + if !exploreFoldedTargetMandatory(bridgeTarget, nil) { + t.Fatal("causal bridge was not protected from owner folding") + } + if !leafTarget.causalChangeLeaf || leafTarget.causalChangeBridge || !ownerTarget.causalChangeOwner { + t.Fatalf("continuation/owner flags were not preserved: %#v %#v", leafTarget, ownerTarget) + } + planned := localizationEvidenceTargetsFromDraft(task, "", got, nil) + want := []string{seed.ID, owner.ID, bridge.ID, continuation.ID} + if ids := causalChangeTargetIDs(planned); len(ids) < len(want) || !slices.Equal(ids[:len(want)], want) { + t.Fatalf("planned causal route = %v, want prefix %v", ids, want) + } +} + +func TestPromoteExploreCausalChangeTargetsUsesNestedDelegationHint(t *testing.T) { + seed := causalChangeRouteNode( + "repo/gin.go::Engine.redirectFixedPath", "redirectFixedPath", "Engine.redirectFixedPath", + "gin.go", graph.KindMethod, + ) + cleanPath := causalChangeRouteNode( + "repo/path.go::cleanPath", "cleanPath", "cleanPath", "gin.go", graph.KindFunction, + ) + bridge := causalChangeRouteNode( + "repo/tree.go::node.findCaseInsensitivePath", "findCaseInsensitivePath", "node.findCaseInsensitivePath", + "tree.go", graph.KindMethod, + ) + continuation := causalChangeRouteNode( + "repo/tree.go::node.findCaseInsensitivePathRec", "findCaseInsensitivePathRec", "node.findCaseInsensitivePathRec", + "tree.go", graph.KindMethod, + ) + targets := []exploreTarget{{ + node: seed, + source: "redirectFixedPath follows the case insensitive path fallback", + callees: []*graph.Node{cleanPath, bridge}, + causalCallees: []exploreCausalNeighbor{{node: bridge, hop: 1}, {node: continuation, hop: 2}}, + }} + task := "The redirect fixed path fallback should follow case insensitive path lookup recursively without cleaning away valid request segments" + got := promoteExploreCausalChangeTargets(task, targets, nil, 3, func(node *graph.Node) string { + if node.ID != bridge.ID { + t.Fatalf("source read for %q, want hinted bridge %q", node.ID, bridge.ID) + } + return "func (n *node) findCaseInsensitivePath() { n.findCaseInsensitivePathRec() }" + }, func(node *graph.Node) ([]*graph.Node, bool) { + if node.ID != bridge.ID { + t.Fatalf("hydrated %q, want hinted bridge %q", node.ID, bridge.ID) + } + return []*graph.Node{continuation}, true + }) + + if len(got) != 3 { + t.Fatalf("promoted targets = %d, want seed + bridge + continuation", len(got)) + } + if slices.Contains(causalChangeTargetIDs(got), cleanPath.ID) { + t.Fatalf("unrelated sibling callee was promoted: %v", causalChangeTargetIDs(got)) + } + if !requireCausalChangeTarget(t, got, bridge.ID).causalChangeBridge || + !requireCausalChangeTarget(t, got, continuation.ID).causalChangeLeaf { + t.Fatalf("nested causal route flags missing: %#v", got) + } +} + +func TestPromoteExploreCausalChangeTargetsContinuesInheritedCaller(t *testing.T) { + seed := causalChangeRouteNode( + "repo/src/Handler/AbstractHandler.php::AbstractHandler.isHandling", + "isHandling", "AbstractHandler.isHandling", "src/Handler/AbstractHandler.php", graph.KindMethod, + ) + genericCaller := causalChangeRouteNode( + "repo/src/Handler/AbstractProcessingHandler.php::AbstractProcessingHandler.handle", + "handle", "AbstractProcessingHandler.handle", "src/Handler/AbstractProcessingHandler.php", graph.KindMethod, + ) + bridge := causalChangeRouteNode( + "repo/src/Handler/HipChatHandler.php::HipChatHandler.handleBatch", + "handleBatch", "HipChatHandler.handleBatch", "src/Handler/HipChatHandler.php", graph.KindMethod, + ) + write := causalChangeRouteNode( + "repo/src/Handler/HipChatHandler.php::HipChatHandler.write", + "write", "HipChatHandler.write", "src/Handler/HipChatHandler.php", graph.KindMethod, + ) + continuation := causalChangeRouteNode( + "repo/src/Handler/HipChatHandler.php::HipChatHandler.combineRecords", + "combineRecords", "HipChatHandler.combineRecords", "src/Handler/HipChatHandler.php", graph.KindMethod, + ) + task := "The Hip Chat handler should handle each batch by combining records before checking handling and writing messages to the API" + got := promoteExploreCausalChangeTargets(task, []exploreTarget{{ + node: seed, source: "isHandling checks every record", callers: []*graph.Node{genericCaller, bridge}, + }}, nil, 3, func(node *graph.Node) string { + if node.ID != bridge.ID { + t.Fatalf("source read for %q, want inherited caller %q", node.ID, bridge.ID) + } + return "function handleBatch() { $records = $this->combineRecords(); $this->write($records); }" + }, func(node *graph.Node) ([]*graph.Node, bool) { + return []*graph.Node{write, continuation}, true + }) + + if slices.Contains(causalChangeTargetIDs(got), genericCaller.ID) || slices.Contains(causalChangeTargetIDs(got), write.ID) { + t.Fatalf("generic inherited route won over concrete continuation: %v", causalChangeTargetIDs(got)) + } + if !requireCausalChangeTarget(t, got, bridge.ID).causalChangeBridge || + !requireCausalChangeTarget(t, got, continuation.ID).causalChangeLeaf { + t.Fatalf("inherited caller route flags missing: %#v", got) + } +} + +func TestPromoteExploreCausalChangeTargetsContinuationFailsClosed(t *testing.T) { + seed := causalChangeRouteNode( + "repo/router/resolve.go::Resolver.resolveRequestPath", "resolveRequestPath", "Resolver.resolveRequestPath", + "router/resolve.go", graph.KindMethod, + ) + bridge := causalChangeRouteNode( + "repo/router/dispatch.go::RouteHandler.dispatchRequest", "dispatchRequest", "RouteHandler.dispatchRequest", + "router/dispatch.go", graph.KindMethod, + ) + primary := causalChangeRouteNode( + "repo/router/dispatch.go::RouteHandler.dispatchPrimaryWork", "dispatchPrimaryWork", "RouteHandler.dispatchPrimaryWork", + "router/dispatch.go", graph.KindMethod, + ) + backup := causalChangeRouteNode( + "repo/router/dispatch.go::RouteHandler.dispatchBackupWork", "dispatchBackupWork", "RouteHandler.dispatchBackupWork", + "router/dispatch.go", graph.KindMethod, + ) + task := "The resolve request path flow asks the route handler to dispatch request work before selecting any terminal implementation" + + tests := []struct { + name string + children []*graph.Node + complete bool + }{ + {name: "ambiguous", children: []*graph.Node{primary, backup}, complete: true}, + {name: "incomplete", children: []*graph.Node{primary}, complete: false}, + {name: "out_of_scope", children: []*graph.Node{func() *graph.Node { + outside := *primary + outside.WorkspaceID = "other-workspace" + return &outside + }()}, complete: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got := promoteExploreCausalChangeTargets(task, []exploreTarget{{ + node: seed, source: "resolveRequestPath dispatches request work", callers: []*graph.Node{bridge}, + }}, nil, 3, func(*graph.Node) string { + return "func dispatchRequest() { dispatchPrimaryWork(); dispatchBackupWork() }" + }, func(*graph.Node) ([]*graph.Node, bool) { + return test.children, test.complete + }) + + bridgeTarget := requireCausalChangeTarget(t, got, bridge.ID) + if bridgeTarget.causalChangeBridge || !bridgeTarget.causalChangeLeaf { + t.Fatalf("uncertain bridge must remain terminal leaf, got bridge:%t leaf:%t", bridgeTarget.causalChangeBridge, bridgeTarget.causalChangeLeaf) + } + if slices.Contains(causalChangeTargetIDs(got), primary.ID) || slices.Contains(causalChangeTargetIDs(got), backup.ID) { + t.Fatalf("uncertain continuation escaped fail-closed selection: %v", causalChangeTargetIDs(got)) + } + }) + } +} diff --git a/internal/mcp/explore_causal_consumer_test.go b/internal/mcp/explore_causal_consumer_test.go new file mode 100644 index 000000000..e814ddb52 --- /dev/null +++ b/internal/mcp/explore_causal_consumer_test.go @@ -0,0 +1,116 @@ +package mcp + +import ( + "testing" + + "github.com/zzet/gortex/internal/graph" +) + +func TestSelectExploreCausalConsumerTargetBridgesRetainedComponent(t *testing.T) { + store := graph.New() + seed := &graph.Node{ + ID: "crates/matcher/src/lib.rs::Matcher.replace_with_captures_at", Name: "replace_with_captures_at", + Kind: graph.KindMethod, FilePath: "crates/matcher/src/lib.rs", + } + callee := &graph.Node{ + ID: "crates/matcher/src/lib.rs::Matcher.captures_iter_at", Name: "captures_iter_at", + Kind: graph.KindMethod, FilePath: "crates/matcher/src/lib.rs", + } + printerPeer := &graph.Node{ + ID: "crates/printer/src/standard.rs::StandardBuilder", Name: "StandardBuilder", + Kind: graph.KindType, FilePath: "crates/printer/src/standard.rs", + } + consumer := &graph.Node{ + ID: "crates/printer/src/util.rs::replace_with_captures_in_context", Name: "replace_with_captures_in_context", + Kind: graph.KindFunction, FilePath: "crates/printer/src/util.rs", + } + for _, node := range []*graph.Node{seed, callee, printerPeer, consumer} { + store.AddNode(node) + } + store.AddEdge(&graph.Edge{From: consumer.ID, To: callee.ID, Kind: graph.EdgeCalls}) + + candidate, ok := selectExploreCausalConsumerTarget( + "panic while using --replace with multiline captures in context", + []exploreTarget{{node: seed, callees: []*graph.Node{callee}}, {node: printerPeer}}, + store, + ) + if !ok { + t.Fatal("expected a unique cross-file consumer") + } + if candidate.node == nil || candidate.node.ID != consumer.ID || !candidate.consumer || + candidate.parentID != seed.ID || candidate.hop != 1 { + t.Fatalf("unexpected consumer candidate: %#v", candidate) + } +} + +func TestSelectExploreCausalConsumerTargetCollapsesSameFileWrappers(t *testing.T) { + store := graph.New() + seed := &graph.Node{ + ID: "crates/ignore/src/gitignore.rs::Glob.is_whitelist", Name: "is_whitelist", + Kind: graph.KindMethod, FilePath: "crates/ignore/src/gitignore.rs", + } + wrapper1 := &graph.Node{ + ID: "crates/ignore/src/gitignore.rs::Gitignore.matched_stripped", Name: "matched_stripped", + Kind: graph.KindMethod, FilePath: "crates/ignore/src/gitignore.rs", + } + wrapper2 := &graph.Node{ + ID: "crates/ignore/src/gitignore.rs::Gitignore.matched", Name: "matched", + Kind: graph.KindMethod, FilePath: "crates/ignore/src/gitignore.rs", + } + consumer := &graph.Node{ + ID: "crates/ignore/src/dir.rs::Ignore.matched_ignore", Name: "matched_ignore", + Kind: graph.KindMethod, FilePath: "crates/ignore/src/dir.rs", + } + for _, node := range []*graph.Node{seed, wrapper1, wrapper2, consumer} { + store.AddNode(node) + } + store.AddEdge(&graph.Edge{From: wrapper1.ID, To: seed.ID, Kind: graph.EdgeCalls}) + store.AddEdge(&graph.Edge{From: wrapper2.ID, To: wrapper1.ID, Kind: graph.EdgeCalls}) + store.AddEdge(&graph.Edge{From: consumer.ID, To: wrapper2.ID, Kind: graph.EdgeCalls}) + + candidate, ok := selectExploreCausalConsumerTarget( + "hidden files whitelisted by an ancestor .ignore fail for explicit current directory", + []exploreTarget{{node: seed}}, + store, + ) + if !ok || candidate.node == nil || candidate.node.ID != consumer.ID || candidate.hop != 3 { + t.Fatalf("same-file wrapper chain did not recover consumer: ok=%v candidate=%#v", ok, candidate) + } +} + +func TestSelectExploreCausalConsumerTargetRejectsAmbiguousFiles(t *testing.T) { + store := graph.New() + seed := &graph.Node{ + ID: "crates/matcher/src/lib.rs::Matcher.captures_iter_at", Name: "captures_iter_at", + Kind: graph.KindMethod, FilePath: "crates/matcher/src/lib.rs", + } + printerPeer := &graph.Node{ + ID: "crates/printer/src/standard.rs::StandardBuilder", Name: "StandardBuilder", + Kind: graph.KindType, FilePath: "crates/printer/src/standard.rs", + } + otherPeer := &graph.Node{ + ID: "crates/other/src/standard.rs::OtherBuilder", Name: "OtherBuilder", + Kind: graph.KindType, FilePath: "crates/other/src/standard.rs", + } + first := &graph.Node{ + ID: "crates/printer/src/util.rs::replace_all", Name: "replace_all", + Kind: graph.KindFunction, FilePath: "crates/printer/src/util.rs", + } + second := &graph.Node{ + ID: "crates/other/src/util.rs::replace_each", Name: "replace_each", + Kind: graph.KindFunction, FilePath: "crates/other/src/util.rs", + } + for _, node := range []*graph.Node{seed, printerPeer, otherPeer, first, second} { + store.AddNode(node) + } + store.AddEdge(&graph.Edge{From: first.ID, To: seed.ID, Kind: graph.EdgeCalls}) + store.AddEdge(&graph.Edge{From: second.ID, To: seed.ID, Kind: graph.EdgeCalls}) + + if candidate, ok := selectExploreCausalConsumerTarget( + "replace output is duplicated", + []exploreTarget{{node: seed}, {node: printerPeer}, {node: otherPeer}}, + store, + ); ok { + t.Fatalf("ambiguous cross-file consumers should be rejected: %#v", candidate) + } +} diff --git a/internal/mcp/explore_component_conjunction_test.go b/internal/mcp/explore_component_conjunction_test.go new file mode 100644 index 000000000..e7377e45e --- /dev/null +++ b/internal/mcp/explore_component_conjunction_test.go @@ -0,0 +1,228 @@ +package mcp + +import ( + "testing" + + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/search/rerank" +) + +func componentConjunctionTestCandidate(id, name, qualName, file string, kind graph.NodeKind) *rerank.Candidate { + return &rerank.Candidate{Node: &graph.Node{ + ID: id, + Name: name, + QualName: qualName, + Kind: kind, + FilePath: file, + WorkspaceID: "workspace", + ProjectID: "project", + RepoPrefix: "repo", + }} +} + +func componentConjunctionTestIDs(candidates []*rerank.Candidate) []string { + ids := make([]string, len(candidates)) + for index, candidate := range candidates { + if candidate != nil && candidate.Node != nil { + ids[index] = candidate.Node.ID + } + } + return ids +} + +func componentConjunctionAssertIDs(t *testing.T, got []*rerank.Candidate, want []string) { + t.Helper() + gotIDs := componentConjunctionTestIDs(got) + if len(gotIDs) != len(want) { + t.Fatalf("candidate count = %d, want %d: got=%v", len(gotIDs), len(want), gotIDs) + } + for index := range want { + if gotIDs[index] != want[index] { + t.Fatalf("candidate %d = %q, want %q: got=%v", index, gotIDs[index], want[index], gotIDs) + } + } +} + +func TestReserveExploreComponentConjunctionPromotesRipgrepPrinterUtility(t *testing.T) { + utility := componentConjunctionTestCandidate( + "crates/printer/src/util.rs::Replacer.replace_all", + "replace_all", + "Replacer.replace_all", + "crates/printer/src/util.rs", + graph.KindMethod, + ) + utility.Signals = map[string]float64{"prior": 0.25} + candidates := []*rerank.Candidate{ + componentConjunctionTestCandidate( + "crates/matcher/src/captures.rs::replace_with_captures", + "replace_with_captures", + "Captures.replace_with_captures", + "crates/matcher/src/captures.rs", + graph.KindFunction, + ), + componentConjunctionTestCandidate( + "crates/printer/src/standard.rs::StandardBuilder.only_matching", + "only_matching", + "StandardBuilder.only_matching", + "crates/printer/src/standard.rs", + graph.KindMethod, + ), + componentConjunctionTestCandidate( + "crates/printer/src/standard.rs::invert_context_only_matching_multi_line", + "invert_context_only_matching_multi_line", + "invert_context_only_matching_multi_line", + "crates/printer/src/standard.rs", + graph.KindFunction, + ), + componentConjunctionTestCandidate( + "crates/core/src/search.rs::search_reader", + "search_reader", + "search_reader", + "crates/core/src/search.rs", + graph.KindFunction, + ), + componentConjunctionTestCandidate( + "crates/core/src/flags.rs::build_flags", + "build_flags", + "build_flags", + "crates/core/src/flags.rs", + graph.KindFunction, + ), + utility, + } + query := "only matching multi line captures must replace printer text" + got := reserveExploreComponentConjunction(query, rerank.QueryClassConcept, candidates, 5) + + if len(got) != len(candidates) { + t.Fatalf("candidate count = %d, want %d", len(got), len(candidates)) + } + if got[0] != candidates[0] { + t.Fatal("semantic head changed") + } + if got[4].Node.ID != utility.Node.ID { + t.Fatalf("final visible candidate = %q, want %q", got[4].Node.ID, utility.Node.ID) + } + if got[4].Signals[exploreConceptComplementSignal] != 1 { + t.Fatalf("utility signals = %#v, want component complement", got[4].Signals) + } + if got[4].Signals["prior"] != 0.25 { + t.Fatalf("utility signals = %#v, want prior signal preserved", got[4].Signals) + } + if utility.Signals[exploreConceptComplementSignal] != 0 { + t.Fatal("original utility candidate was mutated") + } + + wantIDs := componentConjunctionTestIDs(got) + again := reserveExploreComponentConjunction(query, rerank.QueryClassConcept, got, 5) + componentConjunctionAssertIDs(t, again, wantIDs) + if again[4].Signals[exploreConceptComplementSignal] != 1 { + t.Fatalf("second pass signals = %#v, want stable component complement", again[4].Signals) + } +} + +func TestReserveExploreComponentConjunctionRejectsFalsePositives(t *testing.T) { + tests := []struct { + name string + query string + maxSymbols int + candidates []*rerank.Candidate + }{ + { + name: "repeated term has no local marginal", + query: "only matching must replace output", + maxSymbols: 2, + candidates: []*rerank.Candidate{ + componentConjunctionTestCandidate("src/printer/standard.rs::OnlyMatchingPrinter", "OnlyMatchingPrinter", "OnlyMatchingPrinter", "src/printer/standard.rs", graph.KindType), + componentConjunctionTestCandidate("src/printer/util.rs::matching_only", "matching_only", "matching_only", "src/printer/util.rs", graph.KindFunction), + }, + }, + { + name: "different parent directory", + query: "only matching must replace text", + maxSymbols: 2, + candidates: []*rerank.Candidate{ + componentConjunctionTestCandidate("src/printer/standard.rs::OnlyMatchingPrinter", "OnlyMatchingPrinter", "OnlyMatchingPrinter", "src/printer/standard.rs", graph.KindType), + componentConjunctionTestCandidate("src/replacer/util.rs::replace_all", "replace_all", "Replacer.replace_all", "src/replacer/util.rs", graph.KindMethod), + }, + }, + { + name: "generic declaration", + query: "only matching handle must replace text", + maxSymbols: 2, + candidates: func() []*rerank.Candidate { + generic := componentConjunctionTestCandidate("src/printer/util.rs::Handler.Handle", "Handle", "Handler.Handle", "src/printer/util.rs", graph.KindMethod) + generic.Node.Meta = map[string]any{"signature": "interface Handler", "doc": "only matching replacement"} + return []*rerank.Candidate{ + componentConjunctionTestCandidate("src/printer/standard.rs::OnlyMatchingPrinter", "OnlyMatchingPrinter", "OnlyMatchingPrinter", "src/printer/standard.rs", graph.KindType), + generic, + } + }(), + }, + { + name: "low value terms only", + query: "line output diagnostic", + maxSymbols: 2, + candidates: []*rerank.Candidate{ + componentConjunctionTestCandidate("src/printer/standard.rs::LineOutputPrinter", "LineOutputPrinter", "LineOutputPrinter", "src/printer/standard.rs", graph.KindType), + componentConjunctionTestCandidate("src/printer/util.rs::trim_line_output", "trim_line_output", "trim_line_output", "src/printer/util.rs", graph.KindFunction), + }, + }, + { + name: "root component", + query: "only matching must replace text", + maxSymbols: 2, + candidates: []*rerank.Candidate{ + componentConjunctionTestCandidate("standard.rs::OnlyMatchingPrinter", "OnlyMatchingPrinter", "OnlyMatchingPrinter", "standard.rs", graph.KindType), + componentConjunctionTestCandidate("util.rs::replace_all", "replace_all", "Replacer.replace_all", "util.rs", graph.KindMethod), + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + want := componentConjunctionTestIDs(test.candidates) + got := reserveExploreComponentConjunction(test.query, rerank.QueryClassConcept, test.candidates, test.maxSymbols) + componentConjunctionAssertIDs(t, got, want) + for _, candidate := range got { + if candidate != nil && candidate.Signals[exploreConceptComplementSignal] > 0 { + t.Fatalf("unexpected component complement on %q", candidate.Node.ID) + } + } + }) + } +} + +func TestReserveExploreComponentConjunctionRejectsAmbiguousComponents(t *testing.T) { + candidates := []*rerank.Candidate{ + componentConjunctionTestCandidate("src/a/standard.rs::OnlyMatchingA", "OnlyMatchingA", "OnlyMatchingA", "src/a/standard.rs", graph.KindType), + componentConjunctionTestCandidate("src/b/standard.rs::OnlyMatchingB", "OnlyMatchingB", "OnlyMatchingB", "src/b/standard.rs", graph.KindType), + componentConjunctionTestCandidate("src/other/one.rs::parse_config", "parse_config", "parse_config", "src/other/one.rs", graph.KindFunction), + componentConjunctionTestCandidate("src/other/two.rs::build_config", "build_config", "build_config", "src/other/two.rs", graph.KindFunction), + componentConjunctionTestCandidate("src/a/util.rs::replace_all", "replace_all", "Replacer.replace_all", "src/a/util.rs", graph.KindMethod), + componentConjunctionTestCandidate("src/b/util.rs::replace_all", "replace_all", "Replacer.replace_all", "src/b/util.rs", graph.KindMethod), + } + want := componentConjunctionTestIDs(candidates) + got := reserveExploreComponentConjunction("only matching must replace text", rerank.QueryClassConcept, candidates, 4) + componentConjunctionAssertIDs(t, got, want) + for _, candidate := range got { + if candidate.Signals[exploreConceptComplementSignal] > 0 { + t.Fatalf("ambiguous candidate %q was marked", candidate.Node.ID) + } + } +} + +func TestReserveExploreComponentConjunctionMarksVisibleCandidateInPlace(t *testing.T) { + candidates := []*rerank.Candidate{ + componentConjunctionTestCandidate("src/printer/standard.rs::OnlyMatchingPrinter", "OnlyMatchingPrinter", "OnlyMatchingPrinter", "src/printer/standard.rs", graph.KindType), + componentConjunctionTestCandidate("src/printer/util.rs::replace_all", "replace_all", "Replacer.replace_all", "src/printer/util.rs", graph.KindMethod), + } + want := componentConjunctionTestIDs(candidates) + got := reserveExploreComponentConjunction("only matching must replace text", rerank.QueryClassConcept, candidates, 2) + componentConjunctionAssertIDs(t, got, want) + if got[1].Signals[exploreConceptComplementSignal] != 1 { + t.Fatalf("visible utility signals = %#v, want component complement", got[1].Signals) + } + if got[0] != candidates[0] { + t.Fatal("visible marking changed the semantic head") + } +} diff --git a/internal/mcp/explore_divergent_default_owner_test.go b/internal/mcp/explore_divergent_default_owner_test.go index dc6c9da77..341efb533 100644 --- a/internal/mcp/explore_divergent_default_owner_test.go +++ b/internal/mcp/explore_divergent_default_owner_test.go @@ -147,6 +147,9 @@ func TestDivergentDefaultOwnerPromotesFromStoreProjectionWithoutEviction(t *test var envelope localizationExploreEnvelope require.NoError(t, json.Unmarshal([]byte(body), &envelope)) require.GreaterOrEqual(t, len(envelope.Files), 3) + // The prescribed causal owner leads a refinement response, followed by its + // owning type and the task-named consumer. Files, Symbols, and Evidence keep + // this exact positional order. require.Equal(t, fixture.childCtor.FilePath, envelope.Files[0]) require.Equal(t, fixture.childType.FilePath, envelope.Files[1]) require.Equal(t, fixture.write.FilePath, envelope.Files[2]) diff --git a/internal/mcp/explore_owner_folding.go b/internal/mcp/explore_owner_folding.go index 00de1e47c..193be184b 100644 --- a/internal/mcp/explore_owner_folding.go +++ b/internal/mcp/explore_owner_folding.go @@ -271,7 +271,8 @@ func exploreFoldedTargetMandatory(target exploreTarget, reserved map[string]stru return true } return target.sourceLiteral || target.sourceLiteralCallee || target.exactContent || - target.conceptImplementation || target.typedAnchorProjection || + target.causalChangeBridge || target.causalChangeLeaf || target.causalChangeOwner || + target.conceptImplementation || target.conceptComplement || target.syntacticAnchor || target.typedAnchorProjection || target.divergentDefaultOwner || target.divergentDefaultType } diff --git a/internal/mcp/explore_refinement_route_test.go b/internal/mcp/explore_refinement_route_test.go index eb17463be..c20969b6b 100644 --- a/internal/mcp/explore_refinement_route_test.go +++ b/internal/mcp/explore_refinement_route_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "reflect" + "strings" "testing" "github.com/zzet/gortex/internal/graph" @@ -185,14 +186,18 @@ func TestBuildLocalizationRefinementResultKeepsWireAndStateAuthorizationEqual(t result, completion, bounded, digest := buildLocalizationRefinementResultForTask( preferred.ID, "find the replace implementation", targets, exploreDefaultBudgetTokens, routes, ) - if completion.State != localizationStateNeedsRefinement { - t.Fatalf("completion state = %q, want %q", completion.State, localizationStateNeedsRefinement) + if completion.State != localizationStateAnswerReady { + t.Fatalf("completion state = %q, want %q", completion.State, localizationStateAnswerReady) + } + if completion.Enforceable || completion.AllowedToolCalls != 0 { + t.Fatalf("packed refinement completion = %#v, want non-enforceable terminal conclusion", completion) + } + if _, ok := bounded[preferred.ID]; !ok { + t.Fatalf("bounded route = %#v, want retained preferred refinement choice", bounded) + } + if digest == nil { + t.Fatal("packed refinement has no digest") } - state := &localizationTerminalState{} - state.armRefinementRoutesForTask( - "find the replace implementation", completion.refinementSymbol, - completion.AllowedSymbols, bounded, digest, - ) text, ok := singleTextContent(result) if !ok { t.Fatal("localization refinement result has no single text payload") @@ -201,13 +206,28 @@ func TestBuildLocalizationRefinementResultKeepsWireAndStateAuthorizationEqual(t if err := json.Unmarshal([]byte(text), &envelope); err != nil { t.Fatalf("decode localization envelope: %v", err) } - stateCompletion := state.completionLocked() - if !reflect.DeepEqual(envelope.Completion.AllowedSymbols, stateCompletion.AllowedSymbols) { - t.Fatalf("wire allowed symbols = %v, state allowed symbols = %v", envelope.Completion.AllowedSymbols, stateCompletion.AllowedSymbols) + if envelope.Completion.State != completion.State || envelope.Completion.AllowedToolCalls != completion.AllowedToolCalls { + t.Fatalf("wire completion = %#v, returned completion = %#v", envelope.Completion, completion) + } + if !envelope.Terminal || !strings.HasPrefix(envelope.Completion.FinalResponse, localizationBoundedHeading+"\n") { + t.Fatalf("packed refinement envelope = %#v, want terminal bounded-evidence conclusion", envelope) + } + if len(envelope.Completion.AllowedSymbols) != 0 { + t.Fatalf("wire allowed symbols = %v, want none after packed body retires read", envelope.Completion.AllowedSymbols) } if envelope.Completion.refinementSymbol != "" || len(envelope.Completion.refinementRoutes) != 0 { t.Fatalf("wire decoded private routing state: %#v", envelope.Completion) } + foundPackedPreferred := false + for _, evidence := range envelope.Evidence { + if evidence.ID == preferred.ID && strings.TrimSpace(evidence.Source) != "" { + foundPackedPreferred = true + break + } + } + if !foundPackedPreferred { + t.Fatalf("preferred evidence body was not packed: %#v", envelope.Evidence) + } } func TestBuildLocalizationRefinementResultOffersRecoveryWithoutValidPreferredRoute(t *testing.T) { diff --git a/internal/mcp/explore_source_literal_test.go b/internal/mcp/explore_source_literal_test.go index 3ec733bd0..c85143950 100644 --- a/internal/mcp/explore_source_literal_test.go +++ b/internal/mcp/explore_source_literal_test.go @@ -241,12 +241,30 @@ func TestSourceLiteralCalleeRemainsAuthorizedForRefinement(t *testing.T) { preferred := explorePreferredRefinementSymbol(task, targets) require.Equal(t, callee.ID, preferred) - _, completion, _, _ := buildLocalizationRefinementResultForTask( + result, completion, routes, digest := buildLocalizationRefinementResultForTask( preferred, task, targets, exploreDefaultBudgetTokens, exploreLocalizationRefinementRoutes(targets), ) - require.Equal(t, localizationStateNeedsRefinement, completion.State) - require.Contains(t, completion.AllowedSymbols, callee.ID) - require.Contains(t, completion.RequiredAction, callee.ID) + require.Equal(t, localizationStateAnswerReady, completion.State) + require.False(t, completion.Enforceable) + require.Zero(t, completion.AllowedToolCalls) + require.Empty(t, completion.AllowedSymbols) + require.Contains(t, completion.FinalResponse, localizationBoundedHeading) + require.Contains(t, routes, callee.ID, "the packed conclusion must retain the chosen implementation route") + require.NotNil(t, digest) + + text, ok := singleTextContent(result) + require.True(t, ok) + var envelope localizationExploreEnvelope + require.NoError(t, json.Unmarshal([]byte(text), &envelope)) + require.True(t, envelope.Terminal) + foundPackedCallee := false + for _, evidence := range envelope.Evidence { + if evidence.ID == callee.ID && evidence.Source != "" { + foundPackedCallee = true + break + } + } + require.True(t, foundPackedCallee, "the source-literal callee body must replace the retired refinement read") } func TestMapExploreSourceLiteralMatchesFindsCSharpConstructor(t *testing.T) { @@ -956,10 +974,12 @@ func TestExploreCompactLiteralIgnoresTestMetadataAndPrefersSpecificProductionCal } } require.NotEqual(t, -1, specificRank, "construction-aligned source evidence must survive final packing") - require.Equal(t, localizationStateNeedsRefinement, envelope.Completion.State) - require.False(t, envelope.Terminal) + require.Equal(t, localizationStateAnswerReady, envelope.Completion.State) + require.True(t, envelope.Terminal) require.False(t, envelope.Completion.Enforceable, "ambiguous production literal sites remain advisory") - require.Contains(t, envelope.Completion.AllowedSymbols, specificID) + require.Zero(t, envelope.Completion.AllowedToolCalls) + require.Empty(t, envelope.Completion.AllowedSymbols) + require.Contains(t, envelope.Completion.FinalResponse, localizationBoundedHeading) } func TestGatherExploreSourceLiteralRecallBoundsMappingByRequestDeadline(t *testing.T) { diff --git a/internal/mcp/explore_syntactic_anchor.go b/internal/mcp/explore_syntactic_anchor.go index 59b5bad30..1bb68ad1e 100644 --- a/internal/mcp/explore_syntactic_anchor.go +++ b/internal/mcp/explore_syntactic_anchor.go @@ -12,8 +12,9 @@ import ( ) const ( - exploreSyntacticAnchorMaxTerms = 3 - exploreSyntacticAnchorFetch = 10 + exploreSyntacticAnchorMaxTerms = 3 + exploreSyntacticAnchorFetch = 10 + exploreSyntacticAnchorCompetitionFetch = 32 ) // exploreSyntacticAnchor is a bounded, implementation-shaped clue copied @@ -52,7 +53,11 @@ func exploreSyntacticAnchors(task string) []exploreSyntacticAnchor { if !strings.HasPrefix(token, "--") || len(token) <= 2 { continue } - add(strings.TrimLeft(token, "-")) + flag := strings.TrimLeft(token, "-") + if assignment := strings.IndexByte(flag, '='); assignment >= 0 { + flag = flag[:assignment] + } + add(flag) if len(out) == exploreSyntacticAnchorMaxTerms { return out } @@ -62,7 +67,7 @@ func exploreSyntacticAnchors(task string) []exploreSyntacticAnchor { if strings.HasPrefix(token, "--") { continue } - if !exploreCodeShapedToken(token) { + if exploreSyntacticAnchorRuntimeDataPath(token) || !exploreCodeShapedToken(token) { continue } add(token) @@ -75,6 +80,27 @@ func exploreSyntacticAnchors(task string) []exploreSyntacticAnchor { func newExploreSyntacticAnchor(raw string) (exploreSyntacticAnchor, bool) { raw = strings.Trim(raw, " \t\r\n()[]{}<>,.;:'\"") + // Stack traces commonly suffix an otherwise exact source anchor with a + // decimal line/column (`tree.go:637` or `tree.go:637:12`). Those coordinates + // are not identifier terms; retaining them makes both lexical lookup and + // node matching miss the named file. + for { + colon := strings.LastIndexByte(raw, ':') + if colon < 0 || colon == len(raw)-1 { + break + } + digits := true + for _, r := range raw[colon+1:] { + if r < '0' || r > '9' { + digits = false + break + } + } + if !digits { + break + } + raw = raw[:colon] + } if raw == "" { return exploreSyntacticAnchor{}, false } @@ -165,6 +191,16 @@ func exploreUnquotedCodeTokens(task string) []string { }) } +func exploreSyntacticAnchorRuntimeDataPath(token string) bool { + lower := strings.ToLower(strings.Trim(token, "-_.:()[]{}<>,;'\"")) + for _, suffix := range []string{".txt", ".log", ".csv", ".tsv"} { + if strings.HasSuffix(lower, suffix) { + return true + } + } + return false +} + func exploreCodeShapedToken(token string) bool { token = strings.Trim(token, "-_.:") first, _ := utf8.DecodeRuneInString(token) @@ -177,6 +213,18 @@ func exploreCodeShapedToken(token string) bool { return false } } + // Route-registration examples such as r.GET("/users/:id") describe + // runtime input, not implementation anchors. They otherwise consume the + // three-slot syntactic budget ahead of a later stack-trace file/symbol. + if dot := strings.LastIndex(token, "."); dot >= 0 && dot < len(token)-1 { + member := token[dot+1:] + if member == strings.ToUpper(member) { + switch member { + case "GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "CONNECT", "TRACE": + return false + } + } + } if strings.Contains(token, "_") || strings.Contains(token, "::") || strings.Contains(token, ".") { return true } @@ -195,7 +243,17 @@ func exploreSyntacticAnchorMatchesNode(anchor exploreSyntacticAnchor, node *grap return false } return exploreSyntacticAnchorMatchesIdentifier(anchor, node.Name) || - exploreSyntacticAnchorMatchesIdentifier(anchor, node.QualName) + exploreSyntacticAnchorMatchesIdentifier(anchor, node.QualName) || + exploreSyntacticAnchorMatchesPath(anchor, node) +} + +func exploreSyntacticAnchorMatchesPath(anchor exploreSyntacticAnchor, node *graph.Node) bool { + if node == nil || !strings.ContainsAny(anchor.source, ".\\/") { + return false + } + want := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(anchor.source), "\\", "/")) + got := strings.ToLower(strings.ReplaceAll(nodeDisplayPath(node), "\\", "/")) + return want != "" && got != "" && (got == want || strings.HasSuffix(got, "/"+want)) } func exploreSyntacticAnchorMatchesIdentifier(anchor exploreSyntacticAnchor, identifier string) bool { @@ -246,6 +304,9 @@ func exploreSyntacticAnchorNodeStrength(anchor exploreSyntacticAnchor, node *gra } identifierTerms := rerank.Tokenize(node.Name) strength := 0 + if exploreSyntacticAnchorMatchesPath(anchor, node) { + strength = 4 + } for _, anchorTerm := range anchor.terms { best := 0 for _, rawIdentifierTerm := range identifierTerms { @@ -285,10 +346,35 @@ func exploreSyntacticAnchorCandidate( candidates []*rerank.Candidate, scope query.QueryOptions, usedIDs, usedFiles map[string]struct{}, +) *rerank.Candidate { + return exploreSyntacticAnchorCandidateWithTie( + anchor, candidates, scope, usedIDs, usedFiles, false, + ) +} + +func exploreSyntacticAnchorCompetingCandidate( + anchor exploreSyntacticAnchor, + candidates []*rerank.Candidate, + scope query.QueryOptions, + usedIDs, usedFiles map[string]struct{}, +) *rerank.Candidate { + return exploreSyntacticAnchorCandidateWithTie( + anchor, candidates, scope, usedIDs, usedFiles, true, + ) +} + +func exploreSyntacticAnchorCandidateWithTie( + anchor exploreSyntacticAnchor, + candidates []*rerank.Candidate, + scope query.QueryOptions, + usedIDs, usedFiles map[string]struct{}, + preferImplementation bool, ) *rerank.Candidate { var best *rerank.Candidate bestStrength := 0 bestDiverse := false + bestSpan := 0 + bestNameWidth := 0 for _, candidate := range candidates { if candidate == nil || !exploreSyntacticAnchorEligibleNode(candidate.Node) || !scope.ScopeAllows(candidate.Node) || !exploreSyntacticAnchorMatchesNode(anchor, candidate.Node) { @@ -300,15 +386,58 @@ func exploreSyntacticAnchorCandidate( strength := exploreSyntacticAnchorNodeStrength(anchor, candidate.Node) _, repeatedFile := usedFiles[candidate.Node.FilePath] diverse := !repeatedFile - if best == nil || strength > bestStrength || (strength == bestStrength && diverse && !bestDiverse) { + span := max(0, candidate.Node.EndLine-candidate.Node.StartLine) + nameWidth := len(strings.TrimSpace(candidate.Node.Name)) + if best == nil || strength > bestStrength || + (strength == bestStrength && diverse && !bestDiverse) || + (preferImplementation && strength == bestStrength && diverse == bestDiverse && nameWidth < bestNameWidth) || + (preferImplementation && strength == bestStrength && diverse == bestDiverse && nameWidth == bestNameWidth && span > bestSpan) { best = candidate bestStrength = strength bestDiverse = diverse + bestSpan = span + bestNameWidth = nameWidth } } return best } +// A bare one-term flag can match several equally strong compound callables. +// Fetch the existing bounded lexical lane only for that ambiguity class; the +// ordinary selector remains unchanged for every other anchor. +func exploreSyntacticAnchorNeedsLexicalCompetition(anchor exploreSyntacticAnchor, candidate *rerank.Candidate) bool { + if candidate == nil || candidate.Node == nil || len(anchor.terms) != 1 || + exploreSyntacticAnchorMatchesPath(anchor, candidate.Node) { + return false + } + switch candidate.Node.Kind { + case graph.KindFunction, graph.KindMethod: + default: + return false + } + identifierTerms := rerank.Tokenize(candidate.Node.Name) + if len(identifierTerms) < 2 { + return false + } + for _, term := range identifierTerms { + if strings.EqualFold(term, anchor.terms[0]) { + return true + } + } + return false +} + +// Ambiguous one-term flags such as --replace often have many prefix-sharing +// helpers. Widen only that bounded lexical lane so the concrete implementation +// (for example replace_all) can compete with generic helpers such as +// replace_separator; ordinary identifiers retain the smaller fetch. +func exploreSyntacticAnchorFetchLimit(anchor exploreSyntacticAnchor, candidate *rerank.Candidate) int { + if exploreSyntacticAnchorNeedsLexicalCompetition(anchor, candidate) { + return exploreSyntacticAnchorCompetitionFetch + } + return exploreSyntacticAnchorFetch +} + func exploreSyntacticAnchorReusesProtected( anchor exploreSyntacticAnchor, candidates []*rerank.Candidate, @@ -329,6 +458,25 @@ func exploreSyntacticAnchorReusesProtected( // each anchor not represented by the ordinary over-fetch pool. Only after that // identifier lane misses do we invoke the existing request-local source scan. // One diverse node per anchor is retained; no source body is persisted. +func markExploreSyntacticAnchorCandidate(candidate *rerank.Candidate) { + if candidate == nil { + return + } + // Candidate clones elsewhere in the reranking pipeline may share the same + // signal map. Copy before tagging so provenance cannot leak to an unselected + // sibling through that shallow clone. + signals := make(map[string]float64, len(candidate.Signals)+2) + for name, value := range candidate.Signals { + signals[name] = value + } + // The concept marker preserves the row in existing bounded selectors; the + // dedicated marker survives into rendered provenance so PRIMARY confidence + // can distinguish an explicit task-spelled anchor from a semantic neighbour. + signals[exploreConceptComplementSignal] = 1 + signals[exploreSyntacticAnchorSignal] = 1 + candidate.Signals = signals +} + func (s *Server) gatherExploreSyntacticAnchorCandidates( ctx context.Context, task string, @@ -351,6 +499,7 @@ func (s *Server) gatherExploreSyntacticAnchorCandidates( if candidate.Node.FilePath != "" { usedFiles[candidate.Node.FilePath] = struct{}{} } + markExploreSyntacticAnchorCandidate(candidate) } additions := make([]*rerank.Candidate, 0, len(anchors)) @@ -371,16 +520,20 @@ func (s *Server) gatherExploreSyntacticAnchorCandidates( continue } ordinaryCandidate := exploreSyntacticAnchorCandidate(anchor, ordinary, scope, usedIDs, usedFiles) - if ordinaryCandidate != nil && exploreSyntacticAnchorNodeStrength(anchor, ordinaryCandidate.Node) >= 4 { + compete := exploreSyntacticAnchorNeedsLexicalCompetition(anchor, ordinaryCandidate) + if ordinaryCandidate != nil && exploreSyntacticAnchorNodeStrength(anchor, ordinaryCandidate.Node) >= 4 && !compete { addProtected(index, ordinaryCandidate) continue } - rows := eng.GatherSymbolCandidates(anchor.query, exploreSyntacticAnchorFetch, anchorOpts, rctx) + rows := eng.GatherSymbolCandidates(anchor.query, exploreSyntacticAnchorFetchLimit(anchor, ordinaryCandidate), anchorOpts, rctx) combined := rows if ordinaryCandidate != nil { combined = append([]*rerank.Candidate{ordinaryCandidate}, rows...) } candidate := exploreSyntacticAnchorCandidate(anchor, combined, scope, usedIDs, usedFiles) + if compete { + candidate = exploreSyntacticAnchorCompetingCandidate(anchor, combined, scope, usedIDs, usedFiles) + } if candidate == nil { missed = append(missed, index) continue diff --git a/internal/mcp/facade_localization_safety_test.go b/internal/mcp/facade_localization_safety_test.go new file mode 100644 index 000000000..efcdb3d87 --- /dev/null +++ b/internal/mcp/facade_localization_safety_test.go @@ -0,0 +1,83 @@ +package mcp + +import ( + "context" + "testing" + + mcpgo "github.com/mark3labs/mcp-go/mcp" + + "github.com/zzet/gortex/internal/localizationauth" +) + +func TestLegacyLocalizeWrapperKeepsCodingSessionsOpen(t *testing.T) { + for _, tc := range []struct { + name string + policy *toolPolicy + }{ + {name: "core", policy: &toolPolicy{preset: "core"}}, + {name: "full", policy: &toolPolicy{preset: "full"}}, + {name: "unresolved"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("GORTEX_HOOK_SESSION_DIR", t.TempDir()) + registry := newFacadeRegistry() + srv := &Server{ + facades: registry, + localization: newLocalizationTerminalState(), + toolPolicy: tc.policy, + toolPolicyOperatorPinned: tc.policy != nil, + } + completion := newLocalizationCompletion(true, "") + completion.Enforceable = true + completion.FinalResponse = "FILES:\n- storage.go\n\nSYMBOLS:\n- storage.go::Load\n\nEVIDENCE:\n- storage.go:7 — Load" + task := "localize Storage.Load" + localizeCalls := 0 + localizeHandler := func(ctx context.Context, _ mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + localizeCalls++ + srv.localizationFor(ctx).armForTask(completion, task) + return localizationAnswerReadyResult(completion), nil + } + registry.capture(mcpgo.Tool{Name: "explore"}, localizeHandler) + + navigationCalls := 0 + navigationHandler := func(_ context.Context, _ mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + navigationCalls++ + return mcpgo.NewToolResultText("navigation ran"), nil + } + registry.capture(mcpgo.Tool{Name: "search_symbols"}, navigationHandler) + + token, ok := localizationauth.NewToken() + if !ok { + t.Fatal("create localization auth token") + } + request := mcpgo.CallToolRequest{} + request.Params.Name = "explore" + request.Params.Arguments = map[string]any{ + "task": task, "localize": true, localizationauth.ArgumentKey: token, + } + result, err := srv.wrapLegacyFacade("explore", localizeHandler)(context.Background(), request) + if err != nil || result == nil || result.IsError { + t.Fatalf("legacy localize result = (%#v, %v)", result, err) + } + if localizeCalls != 1 { + t.Fatalf("legacy localize calls = %d, want 1", localizeCalls) + } + host, ok := result.Meta.AdditionalFields[localizationHostMetaKey].(localizationHostEnvelope) + if !ok || !host.Contract.Terminal { + t.Fatalf("localize result omitted authenticated advisory context: %#v", result.Meta) + } + receipt, published := localizationauth.Consume(token) + if !published || receipt.FinalResponse == "" { + t.Fatalf("localize result omitted advisory receipt: %#v", receipt) + } + + navigation := mcpgo.CallToolRequest{} + navigation.Params.Name = "search" + navigation.Params.Arguments = map[string]any{"operation": "symbols", "query": "Load"} + navigationResult, navErr := srv.wrapLegacyFacade("search", navigationHandler)(context.Background(), navigation) + if navErr != nil || navigationResult == nil || navigationResult.IsError || navigationCalls != 1 { + t.Fatalf("following navigation = (%#v, %v), calls=%d", navigationResult, navErr, navigationCalls) + } + }) + } +} diff --git a/internal/mcp/facade_registry.go b/internal/mcp/facade_registry.go index 857d17a85..720386305 100644 --- a/internal/mcp/facade_registry.go +++ b/internal/mcp/facade_registry.go @@ -102,6 +102,18 @@ func (r *facadeRegistry) legacy(name string) (capturedFacadeTool, bool) { return tool, ok } +func (r *facadeRegistry) legacyNavigation(name string) bool { + if r == nil { + return false + } + for _, spec := range r.byLegacy[name] { + if localizationNavigationFacade(spec.Facade) { + return true + } + } + return false +} + func (r *facadeRegistry) operations(facade string) []facadeOperationSpec { if r == nil { return nil diff --git a/internal/mcp/facade_tools.go b/internal/mcp/facade_tools.go index 6707de60b..10f68b0fa 100644 --- a/internal/mcp/facade_tools.go +++ b/internal/mcp/facade_tools.go @@ -269,8 +269,10 @@ func (s *Server) wrapLegacyFacade(name string, raw server.ToolHandlerFunc) serve 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 { + policy := s.effectiveSessionPolicy(ctx) + facadeSession := policy != nil && isFacadePreset(policy.preset) + legacyLocalize := name == "explore" && req.GetBool("localize", false) + if !facadeSession && !explicitOperation && !legacyLocalize { return raw(ctx, req) } if name == "analyze" { @@ -420,6 +422,8 @@ func parseLocalizationNewUserBoundary(facade, operation string, arguments map[st func (s *Server) handleFacade(ctx context.Context, facade string, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { started := time.Now() input, _ := req.Params.Arguments.(map[string]any) + // Correlate the response with the host hook so it can attach advisory + // final-response context. Receipt strength never grants deny authority. localizationAuthToken := localizationauth.TakeArgument(input) operation := resolveFacadeOperationAlias(facade, normalizeFacadeOperation(req.GetString("operation", ""))) if facade == "analyze" { @@ -438,6 +442,9 @@ func (s *Server) handleFacade(ctx context.Context, facade string, req mcpgo.Call operation = normalizeFacadeReadOperation(operation, req.GetArguments()) } terminal := s.localizationFor(ctx) + // An explicit localize call owns a bounded evidence transaction in every + // profile. The transaction may prescribe one recovery read/search, but an + // answer_ready result never becomes a permission gate over later coding. freshLocalizeFlow := facade == "explore" && operation == "localize" newUserTask, invalidBoundary := parseLocalizationNewUserBoundary(facade, operation, req.GetArguments()) if invalidBoundary != nil { @@ -445,12 +452,13 @@ func (s *Server) handleFacade(ctx context.Context, facade string, req mcpgo.Call return invalidBoundary, nil } newUserExploreFlow := facade == "explore" && newUserTask && (operation == "task" || operation == "localize") + answerReadyContinuation := terminal.answerReady() && !freshLocalizeFlow // Parse enough of an explicit new-task boundary to validate it, then apply // the cheap terminal gate before operation lookup, shorthand resolution, // overlay construction, and legacy dispatch. Non-navigation facades never // enter this gate. recoveryGeneration := uint64(0) - if !newUserExploreFlow { + if !newUserExploreFlow && !answerReadyContinuation { var blocked *mcpgo.CallToolResult blocked, recoveryGeneration = terminal.interceptAnswerReady(facade, operation, req.GetArguments()) if blocked != nil { @@ -513,7 +521,7 @@ func (s *Server) handleFacade(ctx context.Context, facade string, req mcpgo.Call // transactional reservation. Task text never implies a boundary. A task // boundary stages inactive navigation so later diagnosis calls are admitted // only after the first explore call succeeds. - transactionalExploreFlow := freshLocalizeFlow || newUserExploreFlow + transactionalExploreFlow := freshLocalizeFlow localizeReservation := uint64(0) localizeFinished := false if transactionalExploreFlow { @@ -524,9 +532,6 @@ func (s *Server) handleFacade(ctx context.Context, facade string, req mcpgo.Call publishLocalizationAuthReceipt(localizationAuthToken, blocked) return blocked, nil } - if !freshLocalizeFlow { - terminal.keepOpenForTask(req.GetString("task", "")) - } } localizationReadReservation := uint64(0) localizationReadSucceeded := false @@ -539,7 +544,7 @@ func (s *Server) handleFacade(ctx context.Context, facade string, req mcpgo.Call terminal.finishLocalize(localizeReservation, false) } }() - if !transactionalExploreFlow { + if !transactionalExploreFlow && !answerReadyContinuation { blocked, reservation := terminal.authorizeWithToken(facade, operation, req.GetArguments()) if blocked != nil { s.recordFacadeTelemetry(facade, operation, facadeOutcomeBlocked, time.Since(started)) @@ -578,6 +583,11 @@ func (s *Server) handleFacade(ctx context.Context, facade string, req mcpgo.Call terminal.finishLocalize(localizeReservation, succeeded) localizeFinished = true } + if newUserExploreFlow && !freshLocalizeFlow && succeeded { + // A diagnosis-oriented task is an explicit transition out of any retained + // localization transaction. Its later navigation must start from live code. + terminal.keepOpenForTask(req.GetString("task", "")) + } publishLocalizationAuthReceipt(localizationAuthToken, result) return result, err } @@ -671,15 +681,43 @@ func (s *Server) localizationTextMatchNode(ctx context.Context, match enrichedTe } return nil, "" } - path := strings.TrimSpace(match.Path) + path := strings.ReplaceAll(strings.TrimSpace(match.Path), "\\", "/") if path == "" { return nil, "" } + fileNodes := s.graph.GetFileNodes(path) + expectedRepo := "" + // Multi-repository text search renders paths as /, while + // graph file indexes retain the repository-relative path plus RepoPrefix. + // Resolve that wire form only when the stripped candidates prove the same + // repository identity; never let a coincidental suffix cross repositories. + if len(fileNodes) == 0 { + repoPrefix := "" + for _, candidate := range s.graph.RepoPrefixes() { + candidate = strings.TrimSpace(strings.ReplaceAll(candidate, "\\", "/")) + if strings.HasPrefix(path, candidate+"/") && len(candidate) > len(repoPrefix) { + repoPrefix = candidate + } + } + if repoPrefix != "" { + relativePath := strings.TrimPrefix(path, repoPrefix+"/") + for _, node := range s.graph.GetFileNodes(relativePath) { + if node != nil && strings.EqualFold(strings.TrimSpace(node.RepoPrefix), repoPrefix) { + fileNodes = append(fileNodes, node) + } + } + if len(fileNodes) > 0 { + path = relativePath + expectedRepo = repoPrefix + } + } + } var owner *graph.Node var fileNode *graph.Node ownerSpan := int(^uint(0) >> 1) - for _, node := range s.graph.GetFileNodes(path) { - if node == nil || !s.nodeInSessionScope(ctx, node) { + for _, node := range fileNodes { + if node == nil || !s.nodeInSessionScope(ctx, node) || + (expectedRepo != "" && !strings.EqualFold(strings.TrimSpace(node.RepoPrefix), expectedRepo)) { continue } if node.Kind == graph.KindFile { @@ -708,7 +746,9 @@ func (s *Server) localizationTextMatchNode(ctx context.Context, match enrichedTe return owner, "permitted_search_text_owner" } if fileNode == nil { - if node := s.graph.GetNode(path); node != nil && node.Kind == graph.KindFile && s.nodeInSessionScope(ctx, node) { + if node := s.graph.GetNode(path); node != nil && node.Kind == graph.KindFile && + s.nodeInSessionScope(ctx, node) && + (expectedRepo == "" || strings.EqualFold(strings.TrimSpace(node.RepoPrefix), expectedRepo)) { fileNode = node } } @@ -798,6 +838,10 @@ func decorateExhaustedLocalizationReadFailure( func inferFacadeOperation(facade string, input map[string]any) string { target, _ := input["target"].(map[string]any) switch facade { + case "explore": + if localize, _ := input["localize"].(bool); localize { + return "localize" + } case "read": switch { case facadeSelectorPresent(target["file"]): @@ -2506,7 +2550,7 @@ func facadeRequestShape(spec facadeOperationSpec, properties map[string]any, req // 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 { + if p == nil || !isFacadePreset(p.preset) { out := tools[:0] for _, tool := range tools { if isDedicatedFacadeTool(tool.Name) { @@ -2521,18 +2565,27 @@ func (s *Server) applyFacadeSurface(ctx context.Context, tools []mcpgo.Tool) []m } return out } - byName := make(map[string]mcpgo.Tool, len(facadeToolNames())) + byName := make(map[string]mcpgo.Tool, len(facadeToolNames())+2) for _, tool := range tools { if isFacadeToolName(tool.Name) { byName[tool.Name] = s.facadeToolDefinition(tool.Name) + } else if p.preset == "localization" && isAlwaysKeptTool(tool.Name) { + byName[tool.Name] = tool } } - out := make([]mcpgo.Tool, 0, len(facadeToolNames())) + out := make([]mcpgo.Tool, 0, len(facadeToolNames())+2) for _, name := range facadeToolNames() { if tool, ok := byName[name]; ok { out = append(out, tool) } } + if p.preset == "localization" { + for _, name := range []string{"tool_profile", LazyToolsSearchName} { + 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/instruction_profile_policy_test.go b/internal/mcp/instruction_profile_policy_test.go index 24e0150d4..17f90407d 100644 --- a/internal/mcp/instruction_profile_policy_test.go +++ b/internal/mcp/instruction_profile_policy_test.go @@ -1,9 +1,12 @@ package mcp import ( + "context" "os" + "strings" "testing" + mcpgo "github.com/mark3labs/mcp-go/mcp" "github.com/stretchr/testify/require" "github.com/zzet/gortex/internal/profiles" @@ -35,15 +38,24 @@ func TestInstructionProfilePolicy_AppliesOnDefaultConfig(t *testing.T) { require.NotNil(t, p, "shipped-default config must let the active profile refine the surface") require.Equal(t, "localization", p.preset) require.True(t, p.deferMode(), "profile policy inherits the server's defer mode") - require.True(t, p.allows("search_symbols")) - require.True(t, p.allows("smart_context")) - require.False(t, p.allows("edit_file"), "the localization surface is read-only") + for _, name := range []string{"explore", "search", "read", "capabilities", "tool_profile", LazyToolsSearchName} { + require.Truef(t, p.allows(name), "localization profile must allow %s", name) + } + for _, name := range []string{"search_symbols", "smart_context", "edit_file"} { + require.Falsef(t, p.allows(name), "localization profile must not expose legacy tool %s", name) + } // Session resolution: the profile beats the client-aware default… sp := srv.resolveSessionPolicy("", "", "claude-code") require.NotNil(t, sp) require.Equal(t, "localization", sp.preset) + // A similarly named forwarded tool preset resolves to the same visible + // surface; neither path carries hidden runtime authority. + forwarded := srv.resolveSessionPolicy("localization", "", "claude-code") + require.NotNil(t, forwarded) + require.Equal(t, "localization", forwarded.preset) + // …but a forwarded spec beats the profile. sp = srv.resolveSessionPolicy("edit", "", "claude-code") require.NotNil(t, sp) @@ -57,6 +69,141 @@ func TestInstructionProfilePolicy_DefaultProfileIsNoOp(t *testing.T) { "the core profile carries no preset and must resolve exactly as before") } +func TestEffectiveSessionPolicyPinsInstructionProfileForSessionLifetime(t *testing.T) { + activePreset := "" + previous := activeInstructionPreset + activeInstructionPreset = func() string { return activePreset } + t.Cleanup(func() { activeInstructionPreset = previous }) + + srv := setupPresetServer(t, ToolPolicyConfig{Preset: "core", Mode: "defer"}) + const sessionID = "profile-pinned-coding-session" + ctx := WithSessionID(context.Background(), sessionID) + first := srv.effectiveSessionPolicy(ctx) + require.NotNil(t, first) + + activePreset = "localization" + // A late initialize/client update legitimately re-resolves client defaults, + // but it must use the profile captured when this connection first resolved. + srv.NoteSessionClient(sessionID, "claude-code", "test") + second := srv.effectiveSessionPolicy(ctx) + require.NotNil(t, second) + require.NotEqual(t, "localization", second.preset) +} + +func TestLocalizationProfileSurfaceDoesNotCreateHiddenRuntimeGate(t *testing.T) { + stubActiveProfilePreset(t, "localization") + srv := setupPresetServer(t, ToolPolicyConfig{Preset: "core", Mode: "defer"}) + ctx := WithSessionID(context.Background(), "localization-surface-parity") + policy := srv.effectiveSessionPolicy(ctx) + require.NotNil(t, policy) + + legacyCalls := 0 + wrapped := srv.wrapToolHandler(func(context.Context, mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + legacyCalls++ + return mcpgo.NewToolResultText("legacy navigation executed"), nil + }) + for _, name := range []string{"search_symbols", "search_text", "read_file", "get_symbol_source"} { + // tools/list is only presentation: a profile may expose or omit a legacy + // alias. Calling an already-known alias must never activate hidden, + // benchmark-only permission logic. + result, err := wrapped(ctx, mcpgo.CallToolRequest{Params: mcpgo.CallToolParams{Name: name}}) + require.NoError(t, err) + require.NotNil(t, result) + require.Falsef(t, result.IsError, "legacy navigation %s hit a hidden runtime gate", name) + } + require.Equal(t, 4, legacyCalls) + for _, name := range []string{"explore", "search", "read"} { + require.Truef(t, srv.IsToolEnabledForSession(ctx, name), "localization facade %s was not callable", name) + } +} + +func TestAnswerReadyNeverStopsUnrelatedMCPTools(t *testing.T) { + stubActiveProfilePreset(t, "localization") + srv := setupPresetServer(t, ToolPolicyConfig{Preset: "core", Mode: "defer"}) + ctx := WithSessionID(context.Background(), "answer-ready-tool-parity") + completion := newLocalizationCompletion(true, "") + completion.Enforceable = true + completion.digest = testEvidenceDigest() + srv.localizationFor(ctx).armForTask(completion, "find storage implementation") + + handlerCalls := 0 + wrapped := srv.wrapToolHandler(func(context.Context, mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + handlerCalls++ + return mcpgo.NewToolResultText("handler executed"), nil + }) + for _, name := range []string{"capabilities", "tool_profile", "workspace", "change", "search_symbols"} { + result, err := wrapped(ctx, mcpgo.CallToolRequest{Params: mcpgo.CallToolParams{Name: name}}) + require.NoError(t, err) + require.NotNil(t, result) + text, _ := singleTextContent(result) + require.Equalf(t, "handler executed", text, "answer_ready replaced unrelated tool %s", name) + } + require.Equal(t, 5, handlerCalls, "answer_ready blocked a real coding handler") +} + +func TestHardLocalizationGateStaysOffForRealCodingSessionPolicies(t *testing.T) { + cases := []struct { + name string + profile string + forwarded string + cfg ToolPolicyConfig + unresolved bool + }{ + {name: "core", cfg: ToolPolicyConfig{Preset: "core", Mode: "defer"}}, + {name: "full", cfg: ToolPolicyConfig{Preset: "full", Mode: "defer", OperatorPinned: true}}, + { + name: "operator-pinned localization name", profile: "localization", + cfg: ToolPolicyConfig{Preset: "localization", Mode: "defer", OperatorPinned: true}, + }, + { + name: "client-forwarded localization name", profile: "localization", forwarded: "localization", + cfg: ToolPolicyConfig{Preset: "core", Mode: "defer"}, + }, + { + name: "unresolved policy", profile: "localization", unresolved: true, + cfg: ToolPolicyConfig{Preset: "core", Mode: "defer"}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + stubActiveProfilePreset(t, tc.profile) + srv := setupPresetServer(t, tc.cfg) + sessionID := "real-coding-" + strings.ReplaceAll(tc.name, " ", "-") + ctx := WithSessionID(context.Background(), sessionID) + if tc.forwarded != "" { + srv.NoteSessionToolPolicy(sessionID, tc.forwarded, "defer") + } + if tc.unresolved { + srv.toolPolicy = nil + srv.toolPolicyOperatorPinned = true + } + completion := newLocalizationCompletion(true, "") + completion.Enforceable = true + completion.enforceableOnAnswerReady = true + completion.digest = testEvidenceDigest() + srv.localizationFor(ctx).armForTask(completion, "localize one part of a coding task") + + handlerCalls := 0 + wrapped := srv.wrapToolHandler(func(context.Context, mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + handlerCalls++ + return mcpgo.NewToolResultText("coding handler executed"), nil + }) + result, err := wrapped(ctx, mcpgo.CallToolRequest{Params: mcpgo.CallToolParams{ + Name: "explore", + Arguments: map[string]any{ + "operation": "task", "task": "continue the long-running coding task", + }, + }}) + require.NoError(t, err) + require.NotNil(t, result) + require.False(t, result.IsError) + text, _ := singleTextContent(result) + require.Equal(t, "coding handler executed", text) + require.Equal(t, 1, handlerCalls, "ordinary coding policy invoked benchmark hard termination") + }) + } +} + func TestInstructionProfilePolicy_OperatorPinWins(t *testing.T) { srv := setupPresetServer(t, ToolPolicyConfig{Preset: "agent", Mode: "defer"}) stubActiveProfilePreset(t, "localization") diff --git a/internal/mcp/localization_current_capture_test.go b/internal/mcp/localization_current_capture_test.go index e8f09bb57..c7f39619f 100644 --- a/internal/mcp/localization_current_capture_test.go +++ b/internal/mcp/localization_current_capture_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "strings" "sync" "testing" @@ -194,7 +195,7 @@ func TestFinishReservedReadWithDigestMergesOnlyOnAnswerReady(t *testing.T) { } } -func TestFinishReservedReadWithDigestRetriesRecordedZeroThenPreservesCurrentTaskDigest(t *testing.T) { +func TestFinishReservedReadWithDigestRecordedZeroTerminalizesWithRetainedEvidence(t *testing.T) { retained := mergeLocalizationEvidenceDigest([]localizationDigestRow{ captureTestRow("repo/storage/base.go::Storage.Load", "repo/storage/base.go"), }, nil) @@ -207,25 +208,20 @@ func TestFinishReservedReadWithDigestRetriesRecordedZeroThenPreservesCurrentTask digest: retained, } completion := state.finishReservedReadTokenWithDigest(21, true, nil, true) - if completion.State != localizationStateNeedsRecovery || completion.AllowedToolCalls != 1 { - t.Fatalf("first zero-result completion = %#v", completion) + if completion.State != localizationStateAnswerReady || completion.AllowedToolCalls != 0 || completion.Enforceable { + t.Fatalf("zero-result bounded conclusion = %#v", completion) } - if state.digest == nil { - t.Fatal("bounded retry discarded digest before exhaustion") + if state.digest == nil || completion.digest == nil || len(completion.digest.Evidence) == 0 { + t.Fatalf("zero-result conclusion discarded retained evidence: state=%#v completion=%#v", state.digest, completion.digest) } - - state.state = localizationStateRecoveryInFlight - state.readReservationToken = 22 - state.readReservationGen = state.generation - completion = state.finishReservedReadTokenWithDigest(22, true, nil, true) - if completion.State != localizationStateAnswerReady { - t.Fatalf("exhausted zero-result state = %q", completion.State) + if got := completion.digest.Evidence[0].ID; got != "repo/storage/base.go::Storage.Load" { + t.Fatalf("zero-result conclusion changed retained identity: %q", got) } - if state.digest != retained || completion.digest != retained { - t.Fatalf("same-task exhaustion discarded retained evidence: state=%#v completion=%#v", state.digest, completion.digest) + if !strings.HasPrefix(completion.FinalResponse, localizationBoundedHeading+"\n") { + t.Fatalf("zero-result conclusion lacked bounded-evidence heading: %q", completion.FinalResponse) } - if got := completion.digest.Evidence[0].ID; got != "repo/storage/base.go::Storage.Load" { - t.Fatalf("same-task exhaustion changed retained identity: %q", got) + if blocked, retryToken := state.authorizeWithToken("search", "text", map[string]any{"query": "storage"}); blocked == nil || retryToken != 0 { + t.Fatalf("zero-result accepted recovery preserved a retry: (%#v, %d)", blocked, retryToken) } } diff --git a/internal/mcp/localization_digest.go b/internal/mcp/localization_digest.go index b35afa84c..f686eec5d 100644 --- a/internal/mcp/localization_digest.go +++ b/internal/mcp/localization_digest.go @@ -6,6 +6,8 @@ import ( "strings" mcpgo "github.com/mark3labs/mcp-go/mcp" + + "github.com/zzet/gortex/internal/search/rerank" ) // Terminal evidence retention. @@ -67,9 +69,11 @@ type localizationDigestRow struct { // rows. Files and Symbols are rebuilt from those rows, so an item that was shed // by the replay limit or byte budget cannot survive as an unsupported answer // candidate. Exact and refinement-authorized rows form a stable protected -// prefix in retained state only; the visible envelope keeps its original order. +// prefix in retained state first. The serialized envelope is then aligned to +// that canonical digest order, so every response mode assigns the same rank to +// the same file/symbol tuple on the first page and every later replay. // The request is rendered into the ready-to-emit answer, so a page completing -// on its first call presents the same task-scored rows a later merge would. +// on its first call presents the same canonical retained rows a later replay would. func newLocalizationEvidenceDigestForTask(task string, envelope localizationExploreEnvelope) *localizationEvidenceDigest { digest := &localizationEvidenceDigest{} priorityIDs := localizationDigestPriorityIDs(envelope.Completion, envelope.Evidence) @@ -109,29 +113,7 @@ func newLocalizationEvidenceDigestForTask(task string, envelope localizationExpl appendRows(true) appendRows(false) - for { - rebuildLocalizationDigestSkeleton(digest) - refreshLocalizationDigestResponses(digest, task, nil) - encoded, err := json.Marshal(digest) - if err == nil && len(encoded) <= localizationDigestMaxBytes && len(digest.finalResponse) <= localizationFinalResponseMaxBytes { - return digest - } - if len(digest.Evidence) == 0 { - return digest - } - last := len(digest.Evidence) - 1 - if shedLocalizationDigestRowOptionalFields(&digest.Evidence[last]) { - continue - } - // The identity and file are the irreducible row. If even one pathological - // row cannot fit, prefer a bounded empty answer over retaining oversized - // session state or emitting a truncated, misleading identity. - if last == 0 { - digest.Evidence = nil - continue - } - digest.Evidence = digest.Evidence[:last] - } + return fitLocalizationEvidenceDigest(digest, task, nil, priorityIDs) } // localizationDigestPriorityIDs protects every identity required to justify a @@ -229,26 +211,7 @@ func mergeLocalizationEvidenceDigest(current []localizationDigestRow, retained * } // Any capacity the retained rows did not need goes back to the fresh page. appendRows(current, localizationReplayEvidenceLimit) - for { - rebuildLocalizationDigestSkeleton(digest) - refreshLocalizationDigestResponses(digest, "", nil) - encoded, err := json.Marshal(digest) - if err == nil && len(encoded) <= localizationDigestMaxBytes && len(digest.finalResponse) <= localizationFinalResponseMaxBytes { - return digest - } - if len(digest.Evidence) == 0 { - return digest - } - last := len(digest.Evidence) - 1 - if shedLocalizationDigestRowOptionalFields(&digest.Evidence[last]) { - continue - } - if last == 0 { - digest.Evidence = nil - continue - } - digest.Evidence = digest.Evidence[:last] - } + return fitLocalizationEvidenceDigest(digest, "", nil, nil) } const localizationSameOwnerEvidenceReserve = 3 @@ -378,6 +341,31 @@ func localizationDigestRowOwnerKey(row localizationDigestRow) string { return file + "\x00" + owner } +func shedLocalizationEnvelopeRowOptionalFields(row *localizationEvidence) bool { + if row == nil { + return false + } + if len(row.Callers) > 0 || len(row.Callees) > 0 { + row.Callers = nil + row.Callees = nil + return true + } + if row.Signature != "" { + row.Signature = "" + return true + } + if row.QualName != "" { + row.QualName = "" + return true + } + if row.Name != "" || row.Kind != "" { + row.Name = "" + row.Kind = "" + return true + } + return false +} + func shedLocalizationDigestRowOptionalFields(row *localizationDigestRow) bool { if row == nil { return false @@ -403,6 +391,103 @@ func shedLocalizationDigestRowOptionalFields(row *localizationDigestRow) bool { return false } +const ( + localizationProvenanceTaskComplement = "task_complement" + localizationProvenanceTaskRelationComplement = "task_relation_complement" +) + +// markLocalizationDigestTaskComplement records the one complementary PRIMARY +// identity before byte packing removes relation lists. A separate marker keeps +// a direct graph complement distinguishable from a weak lexical hint after the +// relation arrays are compacted. Both markers are display-only: neither grants +// navigation authorization or changes evidence order. +func markLocalizationDigestTaskComplement(task string, current []localizationDigestRow, digest *localizationEvidenceDigest) { + if digest == nil || strings.TrimSpace(task) == "" { + return + } + relationRows := current + if len(relationRows) == 0 { + relationRows = digest.Evidence + } + relationSeeds := relationRows[:min(localizationFinalResponsePrimaryLimit+2, len(relationRows))] + for _, item := range localizationFinalResponseRows(task, current, digest.Evidence) { + if !item.primary || item.row.Rank <= 2 { + continue + } + provenance := localizationProvenanceTaskComplement + for _, candidate := range relationRows { + if candidate.ID == item.row.ID && localizationFinalResponseDirectRelation(candidate, relationSeeds) { + provenance = localizationProvenanceTaskRelationComplement + break + } + } + for index := range digest.Evidence { + if digest.Evidence[index].ID != item.row.ID { + continue + } + if strings.TrimSpace(digest.Evidence[index].Provenance) == "" { + digest.Evidence[index].Provenance = provenance + } + return + } + } +} + +// fitLocalizationEvidenceDigest removes optional detail across the whole page +// before it drops any file/symbol identity. The old tail-only policy could spend +// the entire byte budget on callers and signatures for early rows, then discard +// a late implementation candidate that would fit once those optional fields +// were compacted. Protected authorization identities are dropped only when no +// unprotected identity can satisfy the hard cap. +func fitLocalizationEvidenceDigest( + digest *localizationEvidenceDigest, + task string, + current []localizationDigestRow, + protectedIDs map[string]struct{}, +) *localizationEvidenceDigest { + markLocalizationDigestTaskComplement(task, current, digest) + for { + rebuildLocalizationDigestSkeleton(digest) + refreshLocalizationDigestResponses(digest, task, current) + encoded, err := json.Marshal(digest) + if err == nil && len(encoded) <= localizationDigestMaxBytes && len(digest.finalResponse) <= localizationFinalResponseMaxBytes { + return digest + } + if len(digest.Evidence) == 0 { + return digest + } + + shed := false + for index := len(digest.Evidence) - 1; index >= 0; index-- { + if shedLocalizationDigestRowOptionalFields(&digest.Evidence[index]) { + shed = true + } + } + if shed { + continue + } + + drop := -1 + for index := len(digest.Evidence) - 1; index >= 0; index-- { + if _, protected := protectedIDs[digest.Evidence[index].ID]; !protected { + drop = index + break + } + } + if drop < 0 { + drop = len(digest.Evidence) - 1 + } + if len(digest.Evidence) == 1 { + // The identity and file are irreducible. A pathological single row + // cannot escape the byte cap as a truncated, misleading identity. + digest.Evidence = nil + continue + } + copy(digest.Evidence[drop:], digest.Evidence[drop+1:]) + digest.Evidence = digest.Evidence[:len(digest.Evidence)-1] + } +} + func rebuildLocalizationDigestSkeleton(digest *localizationEvidenceDigest) { digest.Files = digest.Files[:0] digest.Symbols = digest.Symbols[:0] @@ -416,15 +501,106 @@ func rebuildLocalizationDigestSkeleton(digest *localizationEvidenceDigest) { } } +// alignLocalizationEnvelopeWithDigest makes the retained digest the one +// canonical positional projection. Source bodies and end lines stay attached +// to their identities, while every visible mode receives the digest's ranks, +// file/symbol order, and provenance labels. +func alignLocalizationEnvelopeWithDigest(envelope *localizationExploreEnvelope, digest *localizationEvidenceDigest) { + if envelope == nil || digest == nil { + return + } + + original := append([]localizationEvidence(nil), envelope.Evidence...) + rowsByID := make(map[string]localizationEvidence, len(original)) + for _, row := range original { + if row.ID == "" { + continue + } + if _, exists := rowsByID[row.ID]; !exists { + rowsByID[row.ID] = row + } + } + + files := make([]string, 0, len(original)) + symbols := make([]string, 0, len(original)) + evidence := make([]localizationEvidence, 0, len(original)) + seen := make(map[string]struct{}, len(original)) + appendRow := func(row localizationEvidence) { + if row.ID == "" { + return + } + if _, exists := seen[row.ID]; exists { + return + } + seen[row.ID] = struct{}{} + row.Rank = len(evidence) + 1 + files = append(files, row.File) + symbols = append(symbols, row.ID) + evidence = append(evidence, row) + } + for _, retained := range digest.Evidence { + row, exists := rowsByID[retained.ID] + if !exists { + row = localizationEvidence{ + ID: retained.ID, Name: retained.Name, QualName: retained.QualName, + Kind: retained.Kind, File: retained.File, Line: retained.Line, + Signature: retained.Signature, + Callers: append([]string(nil), retained.Callers...), + Callees: append([]string(nil), retained.Callees...), + } + } + row.ID = retained.ID + row.File = retained.File + row.Provenance = retained.Provenance + appendRow(row) + } + // Rows that did not fit the retained replay digest remain useful on the + // first page. Keep them after the canonical prefix so accuracy does not pay + // for replay's tighter byte cap and every shared rank still stays aligned. + for _, row := range original { + appendRow(row) + } + envelope.Files = files + envelope.Symbols = symbols + envelope.Evidence = evidence +} + +// localizationDigestPackingDropIndex chooses a response-compaction row without +// invalidating the live completion. Authorization dependencies, strong proof +// provenance, and a body that retired its prescribed read are never selected. +func localizationDigestPackingDropIndex( + digest *localizationEvidenceDigest, + completion localizationCompletion, + satisfiedSymbol string, +) int { + if digest == nil || len(digest.Evidence) == 0 { + return -1 + } + evidence := make([]localizationEvidence, 0, len(digest.Evidence)) + for _, row := range digest.Evidence { + evidence = append(evidence, localizationEvidence{ID: row.ID, Provenance: row.Provenance}) + } + protected := localizationDigestPriorityIDs(completion, evidence) + if satisfiedSymbol = strings.TrimSpace(satisfiedSymbol); satisfiedSymbol != "" { + protected[satisfiedSymbol] = struct{}{} + } + for index := len(digest.Evidence) - 1; index >= 0; index-- { + if _, keep := protected[digest.Evidence[index].ID]; !keep { + return index + } + } + return -1 +} + func localizationFinalResponseField(value string) string { return strings.Join(strings.Fields(value), " ") } const ( localizationFinalResponsePrimaryLimit = 3 - // The answer asks the caller to reproduce these lines verbatim, so the - // presentation must cover every row the digest retained: a retained row - // that never reaches the answer is evidence the page found and then hid. + // PRIMARY is a role label on the canonical retained order, never a second + // ranking. Every retained row keeps its EVIDENCE number and later rows remain + // visible even when one complementary row receives the third PRIMARY slot. localizationFinalResponseSupportingLimit = localizationReplayEvidenceLimit - localizationFinalResponsePrimaryLimit ) @@ -444,6 +620,7 @@ func localizationFinalResponsePrimaryProvenance(provenance string) bool { case localizationProvenanceSourceLiteralCallee, localizationProvenanceDivergentDefault, localizationProvenanceImplementationTarget, + localizationProvenanceSyntacticAnchor, localizationProvenanceTypedAnchorProjection: return true default: @@ -455,6 +632,8 @@ func localizationFinalResponseSupportingProvenance(provenance string) bool { switch provenance { case localizationProvenanceDivergentDefaultType, localizationProvenanceImplementationRoute, + localizationProvenanceTaskComplement, + localizationProvenanceTaskRelationComplement, "direct_caller", "direct_callee": return true default: @@ -488,6 +667,21 @@ func scoreLocalizationFinalResponseTask(taskTerms map[string]struct{}, row local return score } +func localizationFinalResponseTaskSupportsPrimary(row localizationDigestRow, score localizationFinalResponseTaskScore) bool { + if score.matched == 0 { + return false + } + switch strings.ToLower(strings.TrimSpace(row.Kind)) { + case "function", "method", "type", "class": + return true + default: + // Constants and enum variants are often incidental flag metadata. Keep + // them PRIMARY only when the task supplies multiple distinctive terms; + // an explicit identifier such as BinaryMode.Auto still clears this bar. + return score.matched >= 2 && score.longest >= 6 + } +} + func localizationFinalResponseBetterTaskScore(left, right localizationFinalResponseTaskScore) bool { if left.matched != right.matched { return left.matched > right.matched @@ -508,9 +702,6 @@ func localizationFinalResponseNeighborContains(ids []string, id string) bool { } func localizationFinalResponseDirectRelation(row localizationDigestRow, primaries []localizationDigestRow) bool { - if localizationFinalResponseSupportingProvenance(row.Provenance) { - return true - } id := strings.TrimSpace(row.ID) for _, primary := range primaries { primaryID := strings.TrimSpace(primary.ID) @@ -524,117 +715,221 @@ func localizationFinalResponseDirectRelation(row localizationDigestRow, primarie return false } -// localizationFinalResponseRows selects a bounded model-facing projection -// without changing the retained evidence rows or their positional JSON arrays. -func localizationFinalResponseRows(task string, current, rows []localizationDigestRow) []localizationFinalResponseRow { - if len(rows) == 0 { - return nil +func localizationFinalResponseDirectory(file string) string { + file = strings.ReplaceAll(strings.TrimSpace(file), "\\", "/") + index := strings.LastIndex(file, "/") + if index <= 0 { + return "" } - primaries := make([]localizationDigestRow, 0, localizationFinalResponsePrimaryLimit) - supporting := make([]localizationDigestRow, 0, localizationFinalResponseSupportingLimit) - selected := make(map[string]struct{}, localizationFinalResponsePrimaryLimit+localizationFinalResponseSupportingLimit) - appendRow := func(dst *[]localizationDigestRow, limit int, row localizationDigestRow) bool { - id := strings.TrimSpace(row.ID) - if id == "" || strings.TrimSpace(row.File) == "" || len(*dst) >= limit { - return false + return strings.ToLower(file[:index]) +} + +func localizationFinalResponseSameDirectory(row localizationDigestRow, primaries []localizationDigestRow) bool { + directory := localizationFinalResponseDirectory(row.File) + if directory == "" { + return false + } + for _, primary := range primaries { + if directory == localizationFinalResponseDirectory(primary.File) { + return true } - if _, exists := selected[id]; exists { - return false + } + return false +} + +func localizationFinalResponseOwnerFamily(row localizationDigestRow) string { + identity := strings.TrimSpace(row.QualName) + if identity == "" { + identity = strings.TrimSpace(row.ID) + if cut := strings.LastIndex(identity, "::"); cut >= 0 { + identity = identity[cut+2:] } - selected[id] = struct{}{} - *dst = append(*dst, row) - return true } + if cut := strings.LastIndexByte(identity, '.'); cut > 0 { + identity = identity[:cut] + } + tokens := rerank.Tokenize(identity) + if len(tokens) == 0 { + return "" + } + family := exploreTerminalTermRoot(strings.ToLower(strings.TrimSpace(tokens[len(tokens)-1]))) + if len(family) < 6 { + return "" + } + switch family { + case "handler", "service", "controller", "manager", "client", "implementation": + return "" + default: + return family + } +} - rowsByID := make(map[string]localizationDigestRow, len(rows)) - for _, row := range rows { - rowsByID[strings.TrimSpace(row.ID)] = row +func localizationFinalResponseSharesOwnerFamily(row localizationDigestRow, primaries []localizationDigestRow) bool { + family := localizationFinalResponseOwnerFamily(row) + if family == "" { + return false } - // A successful authorized read is the freshest bounded evidence and leads - // the presentation even when its retained predecessor carried more metadata. - for _, row := range current { - if retained, exists := rowsByID[strings.TrimSpace(row.ID)]; exists { - appendRow(&primaries, localizationFinalResponsePrimaryLimit, retained) + for _, primary := range primaries { + if family == localizationFinalResponseOwnerFamily(primary) { + return true } } + return false +} + +// localizationFinalResponseRows renders the retained evidence order directly. +// Filtering only rejects invalid, duplicate, or over-limit rows; it never +// promotes a later row ahead of an earlier Files/Symbols/Evidence position. +// For a task-aware page, two leading rows remain PRIMARY and the third slot may +// label one later complementary implementation row. Label selection never +// changes the row's position or EVIDENCE number. +func localizationFinalResponseRows(task string, _ []localizationDigestRow, rows []localizationDigestRow) []localizationFinalResponseRow { + presented := make([]localizationFinalResponseRow, 0, min(len(rows), localizationReplayEvidenceLimit)) + seen := make(map[string]struct{}, localizationReplayEvidenceLimit) for _, row := range rows { - if localizationFinalResponsePrimaryProvenance(row.Provenance) { - appendRow(&primaries, localizationFinalResponsePrimaryLimit, row) + row.ID = strings.TrimSpace(row.ID) + row.File = strings.TrimSpace(row.File) + if row.ID == "" || row.File == "" { + continue + } + if _, exists := seen[row.ID]; exists { + continue + } + seen[row.ID] = struct{}{} + row.Rank = len(presented) + 1 + presented = append(presented, localizationFinalResponseRow{row: row}) + if len(presented) == localizationReplayEvidenceLimit { + break } } + for index := 0; index < min(len(presented), localizationFinalResponsePrimaryLimit); index++ { + presented[index].primary = true + } + if strings.TrimSpace(task) == "" || len(presented) <= localizationFinalResponsePrimaryLimit { + return presented + } + seedLimit := min(2, len(presented)) + relationSeedLimit := min(localizationFinalResponsePrimaryLimit+2, len(presented)) + relationSeeds := make([]localizationDigestRow, 0, relationSeedLimit) + primaryFiles := make(map[string]struct{}, seedLimit) + for index := 0; index < relationSeedLimit; index++ { + relationSeeds = append(relationSeeds, presented[index].row) + if index < seedLimit { + primaryFiles[strings.ToLower(strings.TrimSpace(presented[index].row.File))] = struct{}{} + } + } taskTerms := exploreTerminalTerms(task) - ownerKeys := make(map[string]struct{}, len(current)) - for _, row := range current { - key := localizationDigestRowOwnerKey(row) - if key == "" { - if retained, exists := rowsByID[strings.TrimSpace(row.ID)]; exists { - key = localizationDigestRowOwnerKey(retained) + // The complementary PRIMARY slot should add task coverage, not repeat the + // identifiers already guaranteed by the two leading PRIMARY rows. Score + // only the remaining task terms; graph/provenance signals still decide ties + // and cases with no marginal lexical coverage. + for _, seed := range relationSeeds[:seedLimit] { + identifierText := localizationFinalResponseIdentifierText(seed) + for term := range taskTerms { + if exploreConceptTermPresent(identifierText, term) { + delete(taskTerms, term) } } - if key != "" { - ownerKeys[key] = struct{}{} - } } - bestSameOwner := -1 - var bestSameOwnerScore localizationFinalResponseTaskScore - if len(primaries) < localizationFinalResponsePrimaryLimit && len(ownerKeys) > 0 { - for index, row := range rows { - if _, exists := selected[strings.TrimSpace(row.ID)]; exists { - continue + taskTermFrequency := make(map[string]int, len(taskTerms)) + for index := seedLimit; index < len(presented); index++ { + identifierText := localizationFinalResponseIdentifierText(presented[index].row) + for term := range taskTerms { + if exploreConceptTermPresent(identifierText, term) { + taskTermFrequency[term]++ } - key := localizationDigestRowOwnerKey(row) - if _, sameOwner := ownerKeys[key]; key == "" || !sameOwner { - continue - } - score := scoreLocalizationFinalResponseTask(taskTerms, row) - if bestSameOwner < 0 || localizationFinalResponseBetterTaskScore(score, bestSameOwnerScore) { - bestSameOwner, bestSameOwnerScore = index, score - } - } - if bestSameOwner >= 0 { - appendRow(&primaries, localizationFinalResponsePrimaryLimit, rows[bestSameOwner]) } } - - bestTaskMatch := -1 - var bestTaskScore localizationFinalResponseTaskScore - if len(primaries) < localizationFinalResponsePrimaryLimit && len(taskTerms) > 0 { - for index, row := range rows { - if _, exists := selected[strings.TrimSpace(row.ID)]; exists { - continue - } - score := scoreLocalizationFinalResponseTask(taskTerms, row) - if score.matched == 0 { - continue - } - if bestTaskMatch < 0 || localizationFinalResponseBetterTaskScore(score, bestTaskScore) { - bestTaskMatch, bestTaskScore = index, score + bestIndex := -1 + bestScore := localizationFinalResponseTaskScore{} + bestRareTaskCoverage := 0 + bestDirect, bestSameDirectory, bestStrongTaskAlignment := false, false, false + bestTaskComplement := false + bestSyntacticAnchor := false + bestPrimaryProvenance, bestSupportingProvenance := false, false + for index := seedLimit; index < len(presented); index++ { + row := presented[index].row + if _, duplicateFile := primaryFiles[strings.ToLower(strings.TrimSpace(row.File))]; duplicateFile { + continue + } + score := scoreLocalizationFinalResponseTask(taskTerms, row) + strongTaskAlignment := score.matched >= 2 + rareTaskCoverage := 0 + identifierText := localizationFinalResponseIdentifierText(row) + for term, frequency := range taskTermFrequency { + if frequency == 1 && exploreConceptTermPresent(identifierText, term) { + rareTaskCoverage++ } } - if bestTaskMatch >= 0 { - appendRow(&primaries, localizationFinalResponsePrimaryLimit, rows[bestTaskMatch]) + direct := localizationFinalResponseDirectRelation(row, relationSeeds) + typeLike := strings.EqualFold(strings.TrimSpace(row.Kind), "type") || strings.EqualFold(strings.TrimSpace(row.Kind), "class") + sameDirectory := localizationFinalResponseSameDirectory(row, relationSeeds[:seedLimit]) && + (typeLike || localizationFinalResponseSharesOwnerFamily(row, relationSeeds[:seedLimit])) + primaryProvenance := localizationFinalResponsePrimaryProvenance(row.Provenance) + supportingProvenance := localizationFinalResponseSupportingProvenance(row.Provenance) + syntacticAnchor := row.Provenance == localizationProvenanceSyntacticAnchor + // Only a graph-backed marker outranks ordinary lexical scoring. A plain + // task_complement remains a weak display hint and cannot displace a stronger + // same-directory or task-aligned artifact. + taskComplement := row.Provenance == localizationProvenanceTaskRelationComplement + eligible := direct || supportingProvenance || score.matched >= 2 || + (primaryProvenance && score.matched > 0) || + (sameDirectory && score.matched > 0) + if !eligible || (bestSyntacticAnchor && !syntacticAnchor) { + continue } - } - for _, row := range rows { - appendRow(&primaries, localizationFinalResponsePrimaryLimit, row) - } - - for _, row := range rows { - if localizationFinalResponseDirectRelation(row, primaries) { - appendRow(&supporting, localizationFinalResponseSupportingLimit, row) + better := bestIndex < 0 || + (syntacticAnchor && !bestSyntacticAnchor) || + (direct && !bestDirect) || + (direct == bestDirect && taskComplement && !bestTaskComplement) || + (direct == bestDirect && taskComplement == bestTaskComplement && rareTaskCoverage > bestRareTaskCoverage) || + (direct == bestDirect && taskComplement == bestTaskComplement && rareTaskCoverage == bestRareTaskCoverage && strongTaskAlignment && !bestStrongTaskAlignment) || + (direct == bestDirect && taskComplement == bestTaskComplement && rareTaskCoverage == bestRareTaskCoverage && strongTaskAlignment == bestStrongTaskAlignment && sameDirectory && !bestSameDirectory) || + (direct == bestDirect && taskComplement == bestTaskComplement && rareTaskCoverage == bestRareTaskCoverage && strongTaskAlignment == bestStrongTaskAlignment && sameDirectory == bestSameDirectory && supportingProvenance && !bestSupportingProvenance) || + (direct == bestDirect && taskComplement == bestTaskComplement && rareTaskCoverage == bestRareTaskCoverage && strongTaskAlignment == bestStrongTaskAlignment && sameDirectory == bestSameDirectory && supportingProvenance == bestSupportingProvenance && localizationFinalResponseBetterTaskScore(score, bestScore)) || + (direct == bestDirect && taskComplement == bestTaskComplement && rareTaskCoverage == bestRareTaskCoverage && strongTaskAlignment == bestStrongTaskAlignment && sameDirectory == bestSameDirectory && supportingProvenance == bestSupportingProvenance && score == bestScore && primaryProvenance && !bestPrimaryProvenance) + if !better { + continue } - } - for _, row := range rows { - appendRow(&supporting, localizationFinalResponseSupportingLimit, row) + bestIndex = index + bestScore = score + bestRareTaskCoverage = rareTaskCoverage + bestDirect = direct + bestTaskComplement = taskComplement + bestSyntacticAnchor = syntacticAnchor + bestSameDirectory = sameDirectory + bestStrongTaskAlignment = strongTaskAlignment + bestPrimaryProvenance = primaryProvenance + bestSupportingProvenance = supportingProvenance + } + if bestIndex >= seedLimit { + presented[localizationFinalResponsePrimaryLimit-1].primary = false + presented[bestIndex].primary = true } - presented := make([]localizationFinalResponseRow, 0, len(primaries)+len(supporting)) - for _, row := range primaries { - presented = append(presented, localizationFinalResponseRow{row: row, primary: true}) - } - for _, row := range supporting { - presented = append(presented, localizationFinalResponseRow{row: row}) + // PRIMARY is a confidence claim, not simply a position. Preserve EVIDENCE + // order, but demote leading rows that have no task-term, graph, or provenance + // support. This keeps the role labels aligned with the evidence + // a model is actually being asked to trust. + allEvidenceRows := make([]localizationDigestRow, 0, len(presented)) + for _, candidate := range presented { + allEvidenceRows = append(allEvidenceRows, candidate.row) + } + primaryTaskTerms := exploreTerminalTerms(task) + for index := 1; index < len(presented); index++ { + if !presented[index].primary { + continue + } + row := presented[index].row + score := scoreLocalizationFinalResponseTask(primaryTaskTerms, row) + if localizationFinalResponseTaskSupportsPrimary(row, score) || + localizationFinalResponseDirectRelation(row, allEvidenceRows) || + localizationFinalResponsePrimaryProvenance(row.Provenance) || + localizationFinalResponseSupportingProvenance(row.Provenance) { + continue + } + presented[index].primary = false } return presented } @@ -672,6 +967,23 @@ func renderLocalizationProvisionalResponseForTask(task string, current, rows []l } } +func localizationFinalResponseProvenanceCue(provenance string) string { + switch strings.TrimSpace(provenance) { + case localizationProvenanceDivergentDefault: + return "CAUSAL OWNER — upstream default/state owner" + case localizationProvenanceDivergentDefaultType: + return "OWNING TYPE" + case localizationProvenanceImplementationTarget: + return "IMPLEMENTATION TARGET" + case localizationProvenanceSourceLiteralCallee: + return "LITERAL-RESOLVED CALLEE" + case localizationProvenanceTaskComplement, localizationProvenanceTaskRelationComplement: + return "TASK COMPLEMENT" + default: + return "" + } +} + func renderLocalizationAnswerPage( presented []localizationFinalResponseRow, heading, empty, directive string, ) string { @@ -688,11 +1000,15 @@ func renderLocalizationAnswerPage( } file := localizationFinalResponseField(item.row.File) id := localizationFinalResponseField(item.row.ID) + cue := localizationFinalResponseProvenanceCue(item.row.Provenance) if item.row.Line > 0 { - fmt.Fprintf(&response, "- %s — %s:%d — %s\n", role, file, item.row.Line, id) - continue + fmt.Fprintf(&response, "- EVIDENCE #%d — %s — %s:%d — %s\n", item.row.Rank, role, file, item.row.Line, id) + } else { + fmt.Fprintf(&response, "- EVIDENCE #%d — %s — %s — %s\n", item.row.Rank, role, file, id) + } + if cue != "" { + fmt.Fprintf(&response, " PROVENANCE #%d — %s\n", item.row.Rank, cue) } - fmt.Fprintf(&response, "- %s — %s — %s\n", role, file, id) } response.WriteString("\n") response.WriteString(directive) @@ -725,11 +1041,12 @@ func refreshLocalizationDigestResponses(digest *localizationEvidenceDigest, task // caller's own statements against 2% on pages without it. Ask for the answer, // name what the answer should carry, and leave the caller free to disagree — // its disagreement is right more often than not. -const localizationAnswerReadyDirective = "Localization for this task is complete. Answer now from this evidence, naming the files and symbols you rely on. If it does not fit the request, say so and name what does — your judgement about the code is welcome, another navigation call is not." +const localizationAnswerReadyDirective = "Localization for this task is complete: the bounded search examined the supplied anchors, and this is the full retained result. For a localization-only request, answer now using compact FILES, SYMBOLS, and EVIDENCE sections, preserving each PRIMARY tuple once in EVIDENCE order. Do not call another tool just to gain confidence, repeat, or cross-check this localization: PRIMARY rows are the best-supported answer, and SUPPORTING rows are context, not a signal that the search is unfinished. An identical localize call will replay this result. On a broader coding task, this closes only localization; if the user's request includes diagnosis, implementation, or verification, continue normally, and all tools remain available." const ( localizationAnswerHeading = "LOCALIZATION:" localizationProvisionalHeading = "LOCALIZATION (UNCONFIRMED):" + localizationBoundedHeading = "LOCALIZATION COMPLETE (BOUNDED EVIDENCE):" ) // The provisional page exists because a session can stop at any turn: the @@ -743,10 +1060,10 @@ const ( // with them beats stopping with nothing — and stop. const localizationProvisionalDirective = "Unconfirmed. Prefer the step this response prescribes; if you stop here instead, answer with these candidates and say they are unconfirmed." -// localizationProvisionalRowLimit keeps the unconfirmed page to the rows most -// likely to be the answer. Every identity it lists is already in the -// envelope's evidence, so a longer page buys repetition, not information. -const localizationProvisionalRowLimit = localizationFinalResponsePrimaryLimit +// localizationProvisionalRowLimit starts from every retained evidence row. +// The renderer sheds only the canonical tail when the final-response byte cap +// requires it, so the PRIMARY role limit never hides otherwise retained rows. +const localizationProvisionalRowLimit = localizationReplayEvidenceLimit func localizationDigestRowsByID(digest *localizationEvidenceDigest) map[string]localizationDigestRow { retained := make(map[string]localizationDigestRow) @@ -942,6 +1259,45 @@ func localizationCompletionWithDigest(completion localizationCompletion, digest } return completion } + if completion.Instruction == localizationBoundedConclusionInstruction { + response := completion.FinalResponse + if response == "" && digest != nil && digest.provisionalResponse != "" { + response = digest.provisionalResponse + } else if response == "" && digest != nil && len(digest.Evidence) > 0 { + response = renderLocalizationProvisionalResponseForTask("", nil, digest.Evidence) + } + response = strings.TrimSpace(response) + if response != "" { + switch { + case strings.HasPrefix(response, localizationAnswerHeading): + response = localizationBoundedHeading + strings.TrimPrefix(response, localizationAnswerHeading) + case strings.HasPrefix(response, localizationProvisionalHeading): + response = localizationBoundedHeading + strings.TrimPrefix(response, localizationProvisionalHeading) + case !strings.HasPrefix(response, localizationBoundedHeading): + response = localizationBoundedHeading + "\n" + response + } + for { + stripped := false + for _, directive := range []string{ + localizationAnswerReadyDirective, + localizationBoundedConclusionDirective, + localizationProvisionalDirective, + } { + if strings.HasSuffix(response, directive) { + response = strings.TrimSpace(strings.TrimSuffix(response, directive)) + stripped = true + break + } + } + if !stripped { + break + } + } + response += "\n\n" + localizationBoundedConclusionDirective + completion.FinalResponse = response + } + return completion + } if digest != nil && digest.finalResponse != "" { completion.FinalResponse = digest.finalResponse } else if completion.FinalResponse == "" { @@ -1038,7 +1394,8 @@ func localizationAnswerReadyResult(completion localizationCompletion) *mcpgo.Cal // Older retained completions may predate the in-response convergence cue. // Preserve their successful replay shape without duplicating the directive // for newly rendered terminal evidence. - if !strings.HasSuffix(visible, localizationAnswerReadyDirective) { + if !strings.HasPrefix(visible, localizationBoundedHeading+"\n") && + !strings.HasSuffix(visible, localizationAnswerReadyDirective) { visible += "\n\n" + localizationAnswerReadyDirective } result := mcpgo.NewToolResultText(visible) @@ -1046,8 +1403,8 @@ func localizationAnswerReadyResult(completion localizationCompletion) *mcpgo.Cal } // newLocalizationEvidenceDigest retains rows without a request in hand. Callers -// that know the request use newLocalizationEvidenceDigestForTask so the -// presented rows are scored against it. +// that know the request use newLocalizationEvidenceDigestForTask so retained +// evidence and its response page are built together. func newLocalizationEvidenceDigest(envelope localizationExploreEnvelope) *localizationEvidenceDigest { return newLocalizationEvidenceDigestForTask("", envelope) } diff --git a/internal/mcp/localization_digest_test.go b/internal/mcp/localization_digest_test.go index bee8c5b12..b6a44266d 100644 --- a/internal/mcp/localization_digest_test.go +++ b/internal/mcp/localization_digest_test.go @@ -9,6 +9,8 @@ import ( "testing" mcpgo "github.com/mark3labs/mcp-go/mcp" + + "github.com/zzet/gortex/internal/localizationauth" ) func testEvidenceDigest() *localizationEvidenceDigest { @@ -72,15 +74,37 @@ func requireLocalizationTerminalReplay(t *testing.T, result *mcpgo.CallToolResul t.Fatalf("terminal replay = %#v, want successful MCP result", result) } text, ok := singleTextContent(result) - if !ok || !strings.Contains(text, localizationAnswerReadyDirective) { + if !ok { t.Fatalf("terminal replay content = %#v", result.Content) } - for _, required := range []string{ + requiredPhrases := []string{ "Localization for this task is complete", - "Answer now", - "naming the files and symbols you rely on", - "another navigation call is not", - } { + "bounded search examined the supplied anchors", + "full retained result", + "For a localization-only request, answer now", + "Do not call another tool just to gain confidence, repeat, or cross-check", + "PRIMARY rows are the best-supported answer", + "SUPPORTING rows are context", + "compact FILES, SYMBOLS, and EVIDENCE sections", + "On a broader coding task, this closes only localization", + "all tools remain available", + } + if strings.HasPrefix(text, localizationBoundedHeading+"\n") { + requiredPhrases = []string{ + "The bounded localization search is exhausted", + "full retained result", + "For a localization-only request, answer now", + "Do not call another tool just to gain confidence, repeat, or cross-check", + "PRIMARY rows are the best-supported answer", + "SUPPORTING rows are context", + "compact FILES, SYMBOLS, and EVIDENCE sections", + "On a broader coding task, this closes only localization", + "all tools remain available", + } + } else if !strings.Contains(text, localizationAnswerReadyDirective) { + t.Fatalf("terminal replay omitted its answer-ready directive: %q", text) + } + for _, required := range requiredPhrases { if !strings.Contains(text, required) { t.Fatalf("terminal replay text %q does not contain %q", text, required) } @@ -112,7 +136,11 @@ func requireLocalizationTerminalReplay(t *testing.T, result *mcpgo.CallToolResul if text != contract.Completion.FinalResponse { t.Fatalf("terminal replay text diverged from final_response: %q", text) } - if !strings.HasSuffix(contract.Completion.FinalResponse, localizationAnswerReadyDirective) { + if strings.HasPrefix(contract.Completion.FinalResponse, localizationBoundedHeading+"\n") { + if !strings.HasSuffix(contract.Completion.FinalResponse, localizationBoundedConclusionDirective) { + t.Fatalf("bounded terminal directive is outside final_response: %q", contract.Completion.FinalResponse) + } + } else if !strings.HasSuffix(contract.Completion.FinalResponse, localizationAnswerReadyDirective) { t.Fatalf("terminal convergence directive is outside final_response: %q", contract.Completion.FinalResponse) } return contract @@ -197,8 +225,8 @@ func TestRefinementPromotionRetainsDigestForReplay(t *testing.T) { t.Fatal("post-promotion navigation reserved a handler") } contract := requireLocalizationTerminalReplay(t, terminal, "search", "symbols") - if !strings.Contains(contract.Completion.FinalResponse, "- PRIMARY — repo/storage/disk.go:42 — repo/storage/disk.go::DiskStorage.Load") || - !strings.Contains(contract.Completion.FinalResponse, "- PRIMARY — repo/storage/cloud.go:17 — repo/storage/cloud.go::CloudStorage.Load") { + if !strings.Contains(contract.Completion.FinalResponse, "- EVIDENCE #1 — PRIMARY — repo/storage/disk.go:42 — repo/storage/disk.go::DiskStorage.Load") || + !strings.Contains(contract.Completion.FinalResponse, "- EVIDENCE #2 — PRIMARY — repo/storage/cloud.go:17 — repo/storage/cloud.go::CloudStorage.Load") { t.Fatalf("promotion lost retained aligned evidence: %q", contract.Completion.FinalResponse) } } @@ -273,15 +301,15 @@ func TestDigestByteCapShedsEvidenceTail(t *testing.T) { if len(encoded) > localizationDigestMaxBytes { t.Fatalf("digest = %d bytes, want <= %d", len(encoded), localizationDigestMaxBytes) } - if len(digest.Evidence) == 0 || len(digest.Evidence) >= localizationReplayEvidenceLimit { - t.Fatalf("byte cap retained %d rows, want a non-empty shed prefix", len(digest.Evidence)) + if len(digest.Evidence) != localizationReplayEvidenceLimit { + t.Fatalf("byte cap retained %d rows, want all %d bounded identities after optional-field compaction", len(digest.Evidence), localizationReplayEvidenceLimit) } if len(digest.Files) != len(digest.Evidence) || len(digest.Symbols) != len(digest.Evidence) { t.Fatalf("files=%d symbols=%d evidence=%d, want positional rows", len(digest.Files), len(digest.Symbols), len(digest.Evidence)) } for index, file := range digest.Files { - if file != "repo/big/file.go" || digest.Evidence[index].Rank != index+1 { - t.Fatalf("unaligned compacted row %d: file=%q evidence=%#v", index, file, digest.Evidence[index]) + if file != "repo/big/file.go" || digest.Evidence[index].Rank != index+1 || digest.Evidence[index].Signature != "" { + t.Fatalf("unaligned or uncompressed row %d: file=%q evidence=%#v", index, file, digest.Evidence[index]) } } if len(digest.finalResponse) > localizationFinalResponseMaxBytes { @@ -696,8 +724,15 @@ func TestTaskAwareDigestMergeRetainsLongTailSameOwnerCohort(t *testing.T) { return false } baseline := mergeLocalizationEvidenceDigest([]localizationDigestRow{current}, retained) - if contains(baseline, targetID) { - t.Fatal("fixture did not force the unrelated tail to shed the coherent target") + baselineTarget := -1 + for index, evidence := range baseline.Evidence { + if evidence.ID == targetID { + baselineTarget = index + break + } + } + if baselineTarget < 4 { + t.Fatalf("fixture did not leave the coherent target in the unrelated tail: position=%d evidence=%#v", baselineTarget, baseline.Evidence) } digest := mergeLocalizationEvidenceDigestForTask( @@ -972,12 +1007,12 @@ func TestLocalizationFinalResponseKeepsDuplicateFilesPositionallyAligned(t *test t.Fatalf("positional digest = files %#v symbols %#v", digest.Files, digest.Symbols) } for index, row := range digest.Evidence { - marker := fmt.Sprintf("- PRIMARY — %s:%d — %s", row.File, row.Line, row.ID) - if row.Rank != index+1 || !strings.Contains(digest.finalResponse, marker) { - t.Fatalf("row %d not aligned in final_response:\n%s", index, digest.finalResponse) + marker := fmt.Sprintf("- EVIDENCE #%d — PRIMARY — %s:%d — %s", index+1, row.File, row.Line, row.ID) + if row.Rank != index+1 || strings.Count(digest.finalResponse, marker) != 1 { + t.Fatalf("row %d not aligned exactly once in final_response:\n%s", index, digest.finalResponse) } } - for _, parallelSection := range []string{"FILES:", "SYMBOLS:", "EVIDENCE:", "#1"} { + for _, parallelSection := range []string{"FILES:", "SYMBOLS:"} { if strings.Contains(digest.finalResponse, parallelSection) { t.Fatalf("final_response retained ambiguous parallel section %q:\n%s", parallelSection, digest.finalResponse) } @@ -988,7 +1023,235 @@ func TestLocalizationFinalResponseKeepsDuplicateFilesPositionallyAligned(t *test } } -func TestLocalizationFinalResponseCapsRolesAndPrioritizesProofRelations(t *testing.T) { +func TestDigestCompactsOptionalFieldsBeforeLateImplementationIdentity(t *testing.T) { + task := "Nondeterminism in ignore::WalkBuilder parallel multi-root walk with current_dir, standard_filters, threads, and .gitignore" + rows := []localizationEvidence{ + {ID: "crates/ignore/src/walk.rs::WalkBuilder", Name: "WalkBuilder", Kind: "type", File: "crates/ignore/src/walk.rs", Line: 483}, + {ID: "crates/ignore/src/walk.rs::WalkBuilder.current_dir", Name: "current_dir", Kind: "method", File: "crates/ignore/src/walk.rs", Line: 989}, + {ID: "crates/ignore/src/walk.rs::WalkBuilder.add", Name: "add", Kind: "method", File: "crates/ignore/src/walk.rs", Line: 649}, + {ID: "crates/ignore/src/walk.rs::WalkBuilder.standard_filters", Name: "standard_filters", Kind: "method", File: "crates/ignore/src/walk.rs", Line: 789}, + {ID: "crates/core/flags/hiargs.rs::HiArgs.walk_builder", Name: "walk_builder", Kind: "method", File: "crates/core/flags/hiargs.rs", Line: 874}, + {ID: "crates/ignore/src/walk.rs::walk_collect_entries_parallel", Name: "walk_collect_entries_parallel", Kind: "function", File: "crates/ignore/src/walk.rs", Line: 2115}, + {ID: "crates/ignore/src/walk.rs::walk_collect_parallel", Name: "walk_collect_parallel", Kind: "function", File: "crates/ignore/src/walk.rs", Line: 2099}, + {ID: "crates/ignore/src/walk.rs::WalkBuilder.ignore", Name: "ignore", Kind: "method", File: "crates/ignore/src/walk.rs", Line: 823}, + {ID: "crates/ignore/src/dir.rs::IgnoreBuilder", Name: "IgnoreBuilder", Kind: "type", File: "crates/ignore/src/dir.rs", Line: 609}, + } + for index := range rows { + rows[index].Rank = index + 1 + rows[index].Signature = strings.Repeat("optional signature detail ", 80) + } + digest := newLocalizationEvidenceDigestForTask(task, localizationExploreEnvelope{Evidence: rows}) + if len(digest.Evidence) != len(rows) { + t.Fatalf("retained %d identities, want all %d after optional-field compaction: %#v", len(digest.Evidence), len(rows), digest.Evidence) + } + for index, row := range digest.Evidence { + if row.ID != rows[index].ID || row.Rank != index+1 { + t.Fatalf("identity %d reordered: got %#v want %q", index+1, row, rows[index].ID) + } + } + if !strings.Contains(digest.finalResponse, "- EVIDENCE #9 — PRIMARY — crates/ignore/src/dir.rs:609 — crates/ignore/src/dir.rs::IgnoreBuilder") || + !strings.Contains(digest.finalResponse, "- EVIDENCE #3 — SUPPORTING — crates/ignore/src/walk.rs:649 — crates/ignore/src/walk.rs::WalkBuilder.add") { + t.Fatalf("late complementary implementation did not receive the third PRIMARY role:\n%s", digest.finalResponse) + } + encoded, err := json.Marshal(digest) + if err != nil || len(encoded) > localizationDigestMaxBytes || len(digest.finalResponse) > localizationFinalResponseMaxBytes { + t.Fatalf("bounded digest bytes=%d final=%d err=%v", len(encoded), len(digest.finalResponse), err) + } +} + +func TestLocalizationEnvelopeFollowsCanonicalDigestOrder(t *testing.T) { + envelope := localizationExploreEnvelope{ + Files: []string{"pkg/first.go", "pkg/second.go", "pkg/third.go"}, + Symbols: []string{"first", "second", "third"}, + Evidence: []localizationEvidence{ + {Rank: 1, ID: "first", File: "pkg/first.go", Source: "first body"}, + {Rank: 2, ID: "second", File: "pkg/second.go", Source: "second body"}, + {Rank: 3, ID: "third", File: "pkg/third.go", Source: "third body"}, + }, + } + digest := &localizationEvidenceDigest{Evidence: []localizationDigestRow{ + {ID: "third", File: "pkg/third.go", Provenance: localizationProvenanceTaskComplement}, + {ID: "first", File: "pkg/first.go"}, + }} + rebuildLocalizationDigestSkeleton(digest) + + alignLocalizationEnvelopeWithDigest(&envelope, digest) + + if want := []string{"pkg/third.go", "pkg/first.go", "pkg/second.go"}; !reflect.DeepEqual(envelope.Files, want) { + t.Fatalf("files = %v, want canonical digest prefix %v", envelope.Files, want) + } + if want := []string{"third", "first", "second"}; !reflect.DeepEqual(envelope.Symbols, want) { + t.Fatalf("symbols = %v, want canonical digest prefix %v", envelope.Symbols, want) + } + if len(envelope.Evidence) != 3 || envelope.Evidence[0].ID != "third" || envelope.Evidence[0].Rank != 1 || + envelope.Evidence[1].ID != "first" || envelope.Evidence[1].Rank != 2 || + envelope.Evidence[2].ID != "second" || envelope.Evidence[2].Rank != 3 { + t.Fatalf("evidence is not positionally aligned: %#v", envelope.Evidence) + } + if envelope.Evidence[0].Source != "third body" { + t.Fatalf("canonical reorder detached source body: %#v", envelope.Evidence[0]) + } + if envelope.Evidence[0].Provenance != localizationProvenanceTaskComplement { + t.Fatalf("visible provenance = %q, want digest provenance", envelope.Evidence[0].Provenance) + } +} + +func TestLocalizationDigestPackingDropIndexProtectsLiveDependencies(t *testing.T) { + completion := newLocalizationRefinementCompletionForSymbols("protected", []string{"protected"}) + completion.refinementRoutes = map[string]localizationRefinementRoute{ + "protected": {implementationSymbol: "proof", proofSymbol: "route-proof"}, + } + digest := &localizationEvidenceDigest{Evidence: []localizationDigestRow{ + {ID: "free-head", File: "pkg/free-head.go"}, + {ID: "protected", File: "pkg/protected.go"}, + {ID: "proof", File: "pkg/proof.go"}, + {ID: "route-proof", File: "pkg/route-proof.go"}, + {ID: "free-tail", File: "pkg/free-tail.go"}, + }} + if got := localizationDigestPackingDropIndex(digest, completion, ""); got != 4 { + t.Fatalf("drop index = %d, want unprotected tail 4", got) + } + if got := localizationDigestPackingDropIndex(digest, completion, "free-tail"); got != 0 { + t.Fatalf("drop index with satisfied tail = %d, want remaining unprotected row 0", got) + } +} + +func TestLocalizationFinalResponsePromotesSameDirectoryOwnerFamily(t *testing.T) { + task := "parallel multi-root walk must preserve current directory ignore handling" + rows := []localizationDigestRow{ + {ID: "crates/ignore/src/walk.rs::WalkBuilder", Name: "WalkBuilder", QualName: "WalkBuilder", Kind: "type", File: "crates/ignore/src/walk.rs", Line: 100}, + {ID: "crates/ignore/src/walk.rs::WalkBuilder.standard_filters", Name: "standard_filters", QualName: "WalkBuilder.standard_filters", Kind: "method", File: "crates/ignore/src/walk.rs", Line: 789}, + {ID: "crates/ignore/src/walk.rs::WalkBuilder.add", Name: "add", QualName: "WalkBuilder.add", Kind: "method", File: "crates/ignore/src/walk.rs", Line: 500}, + {ID: "crates/core/flags/hiargs.rs::HiArgs.walk_builder", Name: "walk_builder", QualName: "HiArgs.walk_builder", Kind: "method", File: "crates/core/flags/hiargs.rs", Line: 874, Provenance: localizationProvenanceTaskComplement}, + {ID: "crates/ignore/src/walk.rs::walk_collect_entries_parallel", Name: "walk_collect_entries_parallel", Kind: "function", File: "crates/ignore/src/walk.rs", Line: 2115}, + {ID: "crates/ignore/src/walk.rs::WalkBuilder.build_parallel", Name: "build_parallel", QualName: "WalkBuilder.build_parallel", Kind: "method", File: "crates/ignore/src/walk.rs", Line: 1200}, + {ID: "crates/core/flags/hiargs.rs::HiArgs.path_printer_builder", Name: "path_printer_builder", QualName: "HiArgs.path_printer_builder", Kind: "method", File: "crates/core/flags/hiargs.rs", Line: 900}, + {ID: "crates/ignore/src/walk.rs::WalkBuilder.current_dir", Name: "current_dir", QualName: "WalkBuilder.current_dir", Kind: "method", File: "crates/ignore/src/walk.rs", Line: 610}, + {ID: "crates/ignore/src/walk.rs::WalkBuilder.ignore", Name: "ignore", QualName: "WalkBuilder.ignore", Kind: "method", File: "crates/ignore/src/walk.rs", Line: 823}, + {ID: "crates/ignore/src/dir.rs::IgnoreBuilder.current_dir", Name: "current_dir", QualName: "IgnoreBuilder.current_dir", Kind: "method", File: "crates/ignore/src/dir.rs", Line: 701}, + } + response := renderLocalizationFinalResponseForTask(task, nil, rows) + if !strings.Contains(response, "- EVIDENCE #10 — PRIMARY — crates/ignore/src/dir.rs:701 — crates/ignore/src/dir.rs::IgnoreBuilder.current_dir") || + !strings.Contains(response, "- EVIDENCE #4 — SUPPORTING — crates/core/flags/hiargs.rs:874 — crates/core/flags/hiargs.rs::HiArgs.walk_builder") { + t.Fatalf("same-directory owner-family implementation did not replace external wrapper role:\n%s", response) + } +} + +func TestLocalizationFinalResponseExplainsCausalPrimaryProvenance(t *testing.T) { + response := renderLocalizationFinalResponse([]localizationDigestRow{ + {Rank: 1, ID: "src/Handler/RotatingFileHandler.php::RotatingFileHandler.__construct", File: "src/Handler/RotatingFileHandler.php", Line: 41, Provenance: localizationProvenanceDivergentDefault}, + {Rank: 2, ID: "src/Handler/RotatingFileHandler.php::RotatingFileHandler", File: "src/Handler/RotatingFileHandler.php", Line: 25, Provenance: localizationProvenanceDivergentDefaultType}, + }) + if !strings.Contains(response, "PROVENANCE #1 — CAUSAL OWNER — upstream default/state owner") || + !strings.Contains(response, "PROVENANCE #2 — OWNING TYPE") { + t.Fatalf("causal PRIMARY rows lack model-facing provenance cues:\n%s", response) + } +} + +func TestLocalizationFinalResponseLabelsDirectComplementWithoutReorderingEvidence(t *testing.T) { + task := "CultureNotFoundException ku on Xamarin.Android while calling ToWords with CultureInfo pl after Kurdish support" + rows := []localizationDigestRow{ + {ID: "src/Humanizer/NumberToWordsExtension.cs::NumberToWordsExtension.ToWords_L55", Name: "ToWords", Kind: "method", File: "src/Humanizer/NumberToWordsExtension.cs", Line: 55}, + {ID: "src/Humanizer/Configuration/LocaliserRegistry.cs::LocaliserRegistry.Register", Name: "Register", Kind: "method", File: "src/Humanizer/Configuration/LocaliserRegistry.cs", Line: 55}, + {ID: "src/Humanizer/Configuration/Configurator.cs::Configurator", Name: "Configurator", Kind: "type", File: "src/Humanizer/Configuration/Configurator.cs", Line: 16}, + {ID: "src/Humanizer/Configuration/Configurator.cs::Configurator.GetNumberToWordsConverter", Name: "GetNumberToWordsConverter", Kind: "method", File: "src/Humanizer/Configuration/Configurator.cs", Line: 85, Provenance: "direct_callee"}, + {ID: "src/Humanizer/NoMatchFoundException.cs::NoMatchFoundException.init", Name: "NoMatchFoundException.init", Kind: "method", File: "src/Humanizer/NoMatchFoundException.cs", Line: 20}, + {ID: "src/Humanizer/NumberToWordsExtension.cs::NumberToWordsExtension.ToWords_L92", Name: "ToWords", Kind: "method", File: "src/Humanizer/NumberToWordsExtension.cs", Line: 92}, + {ID: "src/Humanizer/GrammaticalGender.cs::GrammaticalGender", Name: "GrammaticalGender", Kind: "type", File: "src/Humanizer/GrammaticalGender.cs", Line: 6}, + {ID: "src/Humanizer/Configuration/FormatterRegistry.cs::FormatterRegistry.RegisterCzechSlovakPolishFormatter", Name: "RegisterCzechSlovakPolishFormatter", Kind: "method", File: "src/Humanizer/Configuration/FormatterRegistry.cs", Line: 62, Callees: []string{"src/Humanizer/Configuration/LocaliserRegistry.cs::LocaliserRegistry.Register"}}, + } + response := renderLocalizationFinalResponseForTask(task, nil, rows) + if strings.Count(response, " — PRIMARY — ") != localizationFinalResponsePrimaryLimit { + t.Fatalf("unexpected PRIMARY count:\n%s", response) + } + if !strings.Contains(response, "- EVIDENCE #8 — PRIMARY — src/Humanizer/Configuration/FormatterRegistry.cs:62 — src/Humanizer/Configuration/FormatterRegistry.cs::FormatterRegistry.RegisterCzechSlovakPolishFormatter") || + !strings.Contains(response, "- EVIDENCE #3 — SUPPORTING — src/Humanizer/Configuration/Configurator.cs:16 — src/Humanizer/Configuration/Configurator.cs::Configurator") { + t.Fatalf("direct implementation complement was not labeled in canonical order:\n%s", response) + } + last := -1 + for index := range rows { + marker := fmt.Sprintf("- EVIDENCE #%d —", index+1) + position := strings.Index(response, marker) + if position <= last { + t.Fatalf("evidence order diverged at %s:\n%s", marker, response) + } + last = position + } +} + +func TestLocalizationFinalResponseDemotesUnsupportedPrimaryWithoutReorderingEvidence(t *testing.T) { + task := "Ignore matching must strip a leading ./ before checking the parent directory and path in Ignore.matched_ignore during directory walking" + rows := []localizationDigestRow{ + {ID: "crates/ignore/src/dir.rs::Ignore.matched_ignore", Name: "matched_ignore", QualName: "Ignore.matched_ignore", Kind: "method", File: "crates/ignore/src/dir.rs", Line: 370}, + {ID: "crates/core/flags/hiargs.rs::BinaryDetection", Name: "BinaryDetection", QualName: "BinaryDetection", Kind: "type", File: "crates/core/flags/hiargs.rs", Line: 2538}, + {ID: "crates/ignore/src/walk.rs::Walk.next", Name: "next", QualName: "Walk.next", Kind: "method", File: "crates/ignore/src/walk.rs", Line: 178}, + {ID: "crates/core/main.rs::eprint_nothing_searched", Name: "eprint_nothing_searched", QualName: "eprint_nothing_searched", Kind: "function", File: "crates/core/main.rs", Line: 246}, + {ID: "crates/ignore/src/gitignore.rs::Glob.is_whitelist", Name: "is_whitelist", QualName: "Glob.is_whitelist", Kind: "method", File: "crates/ignore/src/gitignore.rs", Line: 597}, + } + presented := localizationFinalResponseRows(task, nil, rows) + if len(presented) != len(rows) { + t.Fatalf("presented = %#v, want all rows", presented) + } + if !presented[0].primary { + t.Fatal("source-backed matched_ignore lost PRIMARY role") + } + if presented[1].primary || presented[3].primary { + t.Fatalf("unsupported rows retained PRIMARY roles: binary=%v eprint=%v", presented[1].primary, presented[3].primary) + } + for index := range presented { + if presented[index].row.ID != rows[index].ID || presented[index].row.Rank != index+1 { + t.Fatalf("evidence order changed at %d: got %#v want %s rank %d", index, presented[index].row, rows[index].ID, index+1) + } + } +} + +func TestLocalizationFinalResponsePromotesSyntacticImplementationOverFlagMetadata(t *testing.T) { + task := "rg panic caused by --replace, --multiline, a particular pattern, and search text containing repeats and newlines; slice index starts at x but ends at y" + rows := []localizationDigestRow{ + {ID: "crates/core/flags/hiargs.rs::suggest_multiline", Name: "suggest_multiline", QualName: "suggest_multiline", Kind: "function", File: "crates/core/flags/hiargs.rs", Line: 1450}, + {ID: "crates/core/flags/lowargs.rs::BinaryMode.Auto", Name: "Auto", QualName: "BinaryMode.Auto", Kind: "variable", File: "crates/core/flags/lowargs.rs", Line: 239}, + {ID: "crates/printer/src/json.rs::JSONSink.replace", Name: "replace", QualName: "JSONSink.replace", Kind: "method", File: "crates/printer/src/json.rs", Line: 686, Callees: []string{"crates/printer/src/util.rs::Replacer.replace_all"}}, + {ID: "crates/searcher/src/testutil.rs::TesterConfig.search_slice", Name: "search_slice", QualName: "TesterConfig.search_slice", Kind: "method", File: "crates/searcher/src/testutil.rs", Line: 710}, + {ID: "crates/printer/src/util.rs::Replacer.replace_all", Name: "replace_all", QualName: "Replacer.replace_all", Kind: "method", File: "crates/printer/src/util.rs", Line: 51, Provenance: localizationProvenanceSyntacticAnchor, Callers: []string{"crates/printer/src/json.rs::JSONSink.replace"}}, + {ID: "crates/searcher/src/lines.rs::lines", Name: "lines", QualName: "lines", Kind: "function", File: "crates/searcher/src/lines.rs", Line: 216}, + {ID: "crates/printer/src/util.rs::replace_separator", Name: "replace_separator", QualName: "replace_separator", Kind: "function", File: "crates/printer/src/util.rs", Line: 320}, + {ID: "crates/searcher/src/searcher/mod.rs::Searcher.multi_line_with_matcher", Name: "multi_line_with_matcher", QualName: "Searcher.multi_line_with_matcher", Kind: "method", File: "crates/searcher/src/searcher/mod.rs", Line: 896, Provenance: localizationProvenanceTaskRelationComplement}, + } + presented := localizationFinalResponseRows(task, nil, rows) + if presented[1].primary { + t.Fatal("incidental BinaryMode.Auto flag metadata remained PRIMARY") + } + if !presented[4].primary { + t.Fatal("task-spelled replace anchor was not promoted to PRIMARY") + } + for index := range presented { + if presented[index].row.ID != rows[index].ID || presented[index].row.Rank != index+1 { + t.Fatalf("evidence order changed at %d: got %#v want %s rank %d", index, presented[index].row, rows[index].ID, index+1) + } + } +} + +func TestLocalizationFinalResponsePrefersStrongTaskArtifactOverWeakComplementProvenance(t *testing.T) { + task := "Simplify CI coverage configuration: repeated testconfig.json settings, Azure Pipelines EmitCompilerGeneratedFiles, and ReportGenerator sourcedirs" + rows := []localizationDigestRow{ + {ID: "scripts/flowctl.py::build_review_prompt", Name: "build_review_prompt", Kind: "function", File: "scripts/flowctl.py", Line: 20}, + {ID: "tests/Humanizer.Analyzers.Tests/testconfig.json", Name: "testconfig.json", Kind: "file", File: "tests/Humanizer.Analyzers.Tests/testconfig.json", Line: 1}, + {ID: "src/Humanizer.SourceGenerators/TokenMapInput.cs::TokenMapInput", Name: "TokenMapInput", Kind: "type", File: "src/Humanizer.SourceGenerators/TokenMapInput.cs", Line: 15}, + {ID: "src/Humanizer.SourceGenerators/TokenMapInput.cs::TokenMapInput.ReadBoolean", Name: "ReadBoolean", Kind: "method", File: "src/Humanizer.SourceGenerators/TokenMapInput.cs", Line: 60}, + {ID: "src/Humanizer.SourceGenerators/TokenMapInput.cs::TokenMapInput.CreateDiagnostic", Name: "CreateDiagnostic", Kind: "method", File: "src/Humanizer.SourceGenerators/TokenMapInput.cs", Line: 80}, + {ID: "src/Humanizer.SourceGenerators/LocaleRegistryInput.cs::LocaleRegistryInput.Emit", Name: "Emit", Kind: "method", File: "src/Humanizer.SourceGenerators/LocaleRegistryInput.cs", Line: 150}, + {ID: "src/Humanizer.SourceGenerators/GenerationHelpers.cs::HumanizerSourceGenerator.BooleanValue", Name: "BooleanValue", QualName: "HumanizerSourceGenerator.BooleanValue", Kind: "method", File: "src/Humanizer.SourceGenerators/GenerationHelpers.cs", Line: 516, Provenance: localizationProvenanceTaskComplement}, + {ID: "tests/Humanizer.SourceGenerators.Tests/testconfig.json", Name: "testconfig.json", Kind: "file", File: "tests/Humanizer.SourceGenerators.Tests/testconfig.json", Line: 1}, + {ID: "azure-pipelines.yml", Name: "azure-pipelines.yml", Kind: "file", File: "azure-pipelines.yml", Line: 1}, + } + response := renderLocalizationFinalResponseForTask(task, nil, rows) + if !strings.Contains(response, "- EVIDENCE #9 — PRIMARY — azure-pipelines.yml:1 — azure-pipelines.yml") || + !strings.Contains(response, "- EVIDENCE #7 — SUPPORTING — src/Humanizer.SourceGenerators/GenerationHelpers.cs:516 — src/Humanizer.SourceGenerators/GenerationHelpers.cs::HumanizerSourceGenerator.BooleanValue") { + t.Fatalf("strong task-aligned artifact did not beat weak complement provenance:\n%s", response) + } +} + +func TestLocalizationFinalResponseCapsRolesAndPreservesCanonicalOrder(t *testing.T) { rows := make([]localizationDigestRow, 10) for index := range rows { rows[index] = localizationDigestRow{ @@ -997,33 +1260,113 @@ func TestLocalizationFinalResponseCapsRolesAndPrioritizesProofRelations(t *testi Kind: "method", File: fmt.Sprintf("pkg/file%02d.go", index), Line: index + 10, } } + // Later provenance hints used to reorder these rows ahead of Evidence #1. + // They remain metadata on the canonical row instead of a second ranking. rows[7].Provenance = localizationProvenanceImplementationTarget rows[8].Provenance = localizationProvenanceImplementationRoute rows[9].Provenance = "direct_callee" response := renderLocalizationFinalResponse(rows) - if got := strings.Count(response, "- PRIMARY —"); got != localizationFinalResponsePrimaryLimit { + if got := strings.Count(response, " — PRIMARY — "); got != localizationFinalResponsePrimaryLimit { t.Fatalf("primary rows = %d, want %d:\n%s", got, localizationFinalResponsePrimaryLimit, response) } - if got := strings.Count(response, "- SUPPORTING —"); got != localizationFinalResponseSupportingLimit { + if got := strings.Count(response, " — SUPPORTING — "); got != localizationFinalResponseSupportingLimit { t.Fatalf("supporting rows = %d, want %d:\n%s", got, localizationFinalResponseSupportingLimit, response) } - for _, want := range []string{ - "- PRIMARY — pkg/file07.go:17 — repo/pkg/file07.go::Worker07.Run", - "- SUPPORTING — pkg/file08.go:18 — repo/pkg/file08.go::Worker08.Run", - "- SUPPORTING — pkg/file09.go:19 — repo/pkg/file09.go::Worker09.Run", - } { - if !strings.Contains(response, want) { - t.Fatalf("bounded response omitted prioritized tuple %q:\n%s", want, response) + last := -1 + for index, row := range rows { + role := "SUPPORTING" + if index < localizationFinalResponsePrimaryLimit { + role = "PRIMARY" + } + want := fmt.Sprintf("- EVIDENCE #%d — %s — %s:%d — %s", index+1, role, row.File, row.Line, row.ID) + position := strings.Index(response, want) + if position <= last || strings.Count(response, want) != 1 { + t.Fatalf("canonical row %d was reordered or duplicated: want %q after byte %d:\n%s", index+1, want, last, response) } + last = position } for _, line := range strings.Split(response, "\n") { - if strings.HasPrefix(line, "- ") && strings.Count(line, " — ") != 2 { - t.Fatalf("response row is not one aligned role/file/symbol tuple: %q", line) + if strings.HasPrefix(line, "- ") && strings.Count(line, " — ") != 3 { + t.Fatalf("response row is not one aligned rank/role/file/symbol tuple: %q", line) + } + } + if !strings.HasSuffix(response, localizationAnswerReadyDirective) { + t.Fatalf("response lost the stable directive:\n%s", response) + } +} + +func TestLocalizationFinalResponseKeepsRankOneAndMarginalRowsInEveryPage(t *testing.T) { + rows := []localizationDigestRow{ + {ID: "monolog/handlers.py::RotatingFileHandler", Name: "RotatingFileHandler", Kind: "class", File: "monolog/handlers.py", Line: 10}, + {ID: "monolog/handlers.py::StreamHandler", Name: "StreamHandler", Kind: "class", File: "monolog/handlers.py", Line: 20, Provenance: localizationProvenanceImplementationTarget}, + {ID: "monolog/handlers.py::WatchedFileHandler", Name: "WatchedFileHandler", Kind: "class", File: "monolog/handlers.py", Line: 30, Provenance: localizationProvenanceImplementationTarget}, + {ID: "monolog/handlers.py::FileHandler", Name: "FileHandler", Kind: "class", File: "monolog/handlers.py", Line: 40, Provenance: localizationProvenanceImplementationTarget}, + } + current := []localizationDigestRow{rows[1]} + pages := []string{ + renderLocalizationFinalResponseForTask("find RotatingFileHandler", current, rows), + renderLocalizationProvisionalResponseForTask("find RotatingFileHandler", current, rows), + } + for _, page := range pages { + last := -1 + for index, row := range rows { + marker := fmt.Sprintf("EVIDENCE #%d", index+1) + position := strings.Index(page, marker) + if position <= last || !strings.Contains(page[position:], row.ID) { + t.Fatalf("page lost canonical row %d (%s):\n%s", index+1, row.ID, page) + } + last = position + } + if !strings.Contains(page, "- EVIDENCE #1 — PRIMARY — monolog/handlers.py:10 — monolog/handlers.py::RotatingFileHandler") || + !strings.Contains(page, "- EVIDENCE #4 — SUPPORTING — monolog/handlers.py:40 — monolog/handlers.py::FileHandler") { + t.Fatalf("page lost rank-one or marginal evidence:\n%s", page) + } + } +} + +func TestLocalizationCompletionWithDigestPreservesBoundedConclusionPage(t *testing.T) { + digest := newLocalizationEvidenceDigest(localizationExploreEnvelope{ + Evidence: []localizationEvidence{ + {Rank: 1, ID: "monolog/handlers.py::RotatingFileHandler", Name: "RotatingFileHandler", Kind: "class", File: "monolog/handlers.py", Line: 10}, + {Rank: 2, ID: "monolog/handlers.py::StreamHandler", Name: "StreamHandler", Kind: "class", File: "monolog/handlers.py", Line: 20}, + {Rank: 3, ID: "monolog/handlers.py::WatchedFileHandler", Name: "WatchedFileHandler", Kind: "class", File: "monolog/handlers.py", Line: 30}, + {Rank: 4, ID: "monolog/handlers.py::FileHandler", Name: "FileHandler", Kind: "class", File: "monolog/handlers.py", Line: 40}, + }, + }) + originalFinal := digest.finalResponse + originalProvisional := digest.provisionalResponse + completion := newLocalizationBoundedConclusionCompletion(digest) + + // Host metadata reattaches the request digest after the bounded conclusion + // has been constructed. That must not turn the exhausted-search page back + // into either the digest's ordinary answer or a provisional call-more page. + rebound := localizationCompletionWithDigest(completion, digest) + if !strings.HasPrefix(rebound.FinalResponse, localizationBoundedHeading+"\n") { + t.Fatalf("bounded conclusion lost its exhausted-search heading:\n%s", rebound.FinalResponse) + } + if rebound.FinalResponse == digest.finalResponse { + t.Fatalf("bounded conclusion was overwritten by the confirmed digest response:\n%s", rebound.FinalResponse) + } + last := -1 + for index, evidence := range digest.Evidence { + marker := fmt.Sprintf("EVIDENCE #%d", index+1) + position := strings.Index(rebound.FinalResponse, marker) + if position <= last || !strings.Contains(rebound.FinalResponse[position:], evidence.ID) { + t.Fatalf("bounded conclusion lost canonical row %d (%s):\n%s", index+1, evidence.ID, rebound.FinalResponse) } + last = position } - if !strings.HasSuffix(response, localizationAnswerReadyDirective) || strings.Contains(response, "#1") { - t.Fatalf("response lost the stable directive or restored ordinal cross-references:\n%s", response) + if strings.Count(rebound.FinalResponse, localizationBoundedConclusionDirective) != 1 || + strings.Contains(rebound.FinalResponse, localizationAnswerReadyDirective) || + strings.Contains(rebound.FinalResponse, localizationProvisionalDirective) { + t.Fatalf("bounded conclusion directives are not canonical:\n%s", rebound.FinalResponse) + } + if !strings.HasSuffix(rebound.FinalResponse, localizationBoundedConclusionDirective) { + t.Fatalf("bounded conclusion directive is not final:\n%s", rebound.FinalResponse) + } + if digest.finalResponse != originalFinal || digest.provisionalResponse != originalProvisional { + t.Fatal("reattaching a bounded conclusion mutated the source digest") } } @@ -1043,8 +1386,8 @@ func TestLocalizationFinalResponsePromotesTaskAlignedSameOwnerMethod(t *testing. retained, ) for _, want := range []string{ - "- PRIMARY — repo/gate.go:80 — repo/gate.go::BatchGate.drainPending", - "- PRIMARY — repo/gate.go:64 — repo/gate.go::BatchGate.discardPending", + "- EVIDENCE #1 — PRIMARY — repo/gate.go:80 — repo/gate.go::BatchGate.drainPending", + "- EVIDENCE #3 — PRIMARY — repo/gate.go:64 — repo/gate.go::BatchGate.discardPending", } { if !strings.Contains(digest.finalResponse, want) { t.Fatalf("same-owner response omitted %q:\n%s", want, digest.finalResponse) @@ -1067,7 +1410,7 @@ func TestLocalizationFinalResponsePromotesIdentifierOnlyTaskStem(t *testing.T) { []localizationDigestRow{current}, retained, ) - want := "- PRIMARY — repo/formatter.go:52 — repo/formatter.go::Formatter.applyAll" + want := "- EVIDENCE #3 — PRIMARY — repo/formatter.go:52 — repo/formatter.go::Formatter.applyAll" if !strings.Contains(digest.finalResponse, want) { t.Fatalf("identifier task-stem response omitted %q:\n%s", want, digest.finalResponse) } @@ -1080,8 +1423,8 @@ func TestLocalizationAnswerReadyResultReplaysAlignedStableContract(t *testing.T) contract := requireLocalizationTerminalReplay(t, result, "", "") wantRows := []string{ "LOCALIZATION:\n", - "- PRIMARY — repo/storage/disk.go:42 — repo/storage/disk.go::DiskStorage.Load", - "- PRIMARY — repo/storage/cloud.go:17 — repo/storage/cloud.go::CloudStorage.Load", + "- EVIDENCE #1 — PRIMARY — repo/storage/disk.go:42 — repo/storage/disk.go::DiskStorage.Load", + "- EVIDENCE #2 — PRIMARY — repo/storage/cloud.go:17 — repo/storage/cloud.go::CloudStorage.Load", } for _, want := range wantRows { if !strings.Contains(contract.Completion.FinalResponse, want) { @@ -1242,7 +1585,7 @@ func TestDecorateLocalizationReadResultMirrorsContentOnlyPayloadForHosts(t *test } func TestPermittedRefinementReadInvokesHandlerAndPreservesPayload(t *testing.T) { - srv := setupPresetServer(t, ToolPolicyConfig{Preset: "core", Mode: "defer"}) + srv := setupHardLocalizationRecoveryServer(t) ctx := WithSessionID(context.Background(), "refinement_read_payload") initFrame := []byte(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"codex","version":"1.0"}}}`) if reply := srv.MCPServer().HandleMessage(ctx, initFrame); reply == nil { @@ -1320,8 +1663,7 @@ func TestPermittedRefinementReadInvokesHandlerAndPreservesPayload(t *testing.T) if err := json.Unmarshal(initialEncoded, &initialContract); err != nil { t.Fatalf("decode initial terminal contract: %v", err) } - initialHost, ok := result.Meta.AdditionalFields[localizationHostMetaKey].(localizationHostEnvelope) - if !ok || !initialContract.Terminal || initialContract.Completion.FinalResponse == "" { + if _, ok := result.Meta.AdditionalFields[localizationHostMetaKey].(localizationHostEnvelope); !ok || !initialContract.Terminal || initialContract.Completion.FinalResponse == "" { t.Fatalf("answer_ready PostToolUse envelope = contract %#v host %#v", initialContract, result.Meta) } wire, err := json.Marshal(result) @@ -1344,34 +1686,27 @@ func TestPermittedRefinementReadInvokesHandlerAndPreservesPayload(t *testing.T) Name: "search", Arguments: map[string]any{"operation": " SyMbOlS ", "query": "Load"}, }} - terminal, err := srv.handleFacade(ctx, "search", searchRequest) - if err != nil { - t.Fatalf("post-refinement navigation error = %v", err) - } - if searchCalls != 0 { - t.Fatalf("search handler calls after answer_ready = %d, want 0", searchCalls) + navigation, err := srv.handleFacade(ctx, "search", searchRequest) + if err != nil || navigation == nil || navigation.IsError { + t.Fatalf("post-refinement navigation = (%#v, %v)", navigation, err) } - replayContract := requireLocalizationTerminalReplay(t, terminal, "search", "symbols") - replayHost, ok := terminal.Meta.AdditionalFields[localizationHostMetaKey].(localizationHostEnvelope) - if !ok { - t.Fatalf("post-terminal replay host envelope missing: %#v", terminal.Meta) + if searchCalls != 1 { + t.Fatalf("search handler calls after answer_ready = %d, want 1", searchCalls) } - if replayContract.Completion.FinalResponse != initialContract.Completion.FinalResponse || - replayHost.Contract.Completion.FinalResponse != initialHost.Contract.Completion.FinalResponse || - !reflect.DeepEqual(replayHost.Evidence, initialHost.Evidence) { - t.Fatalf("host-ignored terminal replay diverged: initial=%#v/%#v replay=%#v/%#v", initialContract, initialHost, replayContract, replayHost) + if text, _ := singleTextContent(navigation); text != "unexpected search result" { + t.Fatalf("post-refinement navigation payload = %q", text) } } -func TestAnswerReadyNavigationDispatchNeverInvokesLegacyHandler(t *testing.T) { - srv := setupPresetServer(t, ToolPolicyConfig{Preset: "core", Mode: "defer"}) - ctx := WithSessionID(context.Background(), "terminal_handler_intercept") +func TestAnswerReadyNavigationDispatchKeepsCodingToolsOpen(t *testing.T) { + srv := setupHardLocalizationRecoveryServer(t) + ctx := WithSessionID(context.Background(), "answer_ready_keeps_coding_open") initFrame := []byte(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"codex","version":"1.0"}}}`) if reply := srv.MCPServer().HandleMessage(ctx, initFrame); reply == nil { t.Fatal("initialize returned nil") } - spec, ok := srv.facades.operation("search", "symbols") + searchSpec, ok := srv.facades.operation("search", "symbols") if !ok { t.Fatal("search.symbols facade operation is missing") } @@ -1383,41 +1718,46 @@ func TestAnswerReadyNavigationDispatchNeverInvokesLegacyHandler(t *testing.T) { if !ok { t.Fatal("change.detect facade operation is missing") } - handlerCalls := 0 + navigationCalls := 0 changeCalls := 0 - srv.facades.capture(mcpgo.NewTool(spec.Legacy), func(context.Context, mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { - handlerCalls++ - return mcpgo.NewToolResultText(strings.Repeat("expensive", 10_000)), nil + srv.facades.capture(mcpgo.NewTool(searchSpec.Legacy), func(context.Context, mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + navigationCalls++ + return mcpgo.NewToolResultText("live search"), nil }) srv.facades.capture(mcpgo.NewTool(readSpec.Legacy), func(context.Context, mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { - handlerCalls++ - return mcpgo.NewToolResultText(strings.Repeat("source", 10_000)), nil + navigationCalls++ + return mcpgo.NewToolResultText("live source"), nil }) srv.facades.capture(mcpgo.NewTool(changeSpec.Legacy), func(context.Context, mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { changeCalls++ return mcpgo.NewToolResultText(`{"changed":[]}`), nil }) completion := newLocalizationCompletion(true, "") + completion.Enforceable = true + completion.enforceableOnAnswerReady = true completion.digest = testEvidenceDigest() srv.localizationFor(ctx).armForTask(completion, "find the storage load implementations") - request := mcpgo.CallToolRequest{Params: mcpgo.CallToolParams{ + searchRequest := mcpgo.CallToolRequest{Params: mcpgo.CallToolParams{ Name: "search", Arguments: map[string]any{ "operation": " SyMbOlS ", "query": "Load", }, }} - for repeat := 0; repeat < 10; repeat++ { - result, err := srv.handleFacade(ctx, "search", request) - if err != nil { - t.Fatalf("repeat %d result = (%+v, %v)", repeat, result, err) + for repeat := 0; repeat < 3; repeat++ { + result, err := srv.handleFacade(ctx, "search", searchRequest) + if err != nil || result == nil || result.IsError { + t.Fatalf("repeat %d live search = (%+v, %v)", repeat, result, err) + } + if text, _ := singleTextContent(result); text != "live search" { + t.Fatalf("repeat %d live search payload = %q", repeat, text) } - requireLocalizationTerminalReplay(t, result, "search", "symbols") } - if handlerCalls != 0 { - t.Fatalf("legacy handler invoked %d times after answer_ready", handlerCalls) + if navigationCalls != 3 { + t.Fatalf("search handler calls after answer_ready = %d, want 3", navigationCalls) } + readRequest := mcpgo.CallToolRequest{Params: mcpgo.CallToolParams{ Name: "read", Arguments: map[string]any{ @@ -1426,22 +1766,24 @@ func TestAnswerReadyNavigationDispatchNeverInvokesLegacyHandler(t *testing.T) { }, }} readResult, err := srv.handleFacade(ctx, "read", readRequest) - if err != nil { - t.Fatalf("post-terminal read = (%+v, %v)", readResult, err) + if err != nil || readResult == nil || readResult.IsError { + t.Fatalf("post-answer-ready read = (%+v, %v)", readResult, err) } - requireLocalizationTerminalReplay(t, readResult, "read", "source") - if handlerCalls != 0 { - t.Fatalf("read handler invoked %d times after answer_ready", handlerCalls) + if text, _ := singleTextContent(readResult); text != "live source" { + t.Fatalf("post-answer-ready read payload = %q", text) + } + if navigationCalls != 4 { + t.Fatalf("navigation handler calls after read = %d, want 4", navigationCalls) } - // The pre-validation gate also catches malformed recovery attempts instead - // of spending a turn on schema errors. - request.Params.Arguments = map[string]any{"operation": "not_an_operation"} - result, err := srv.handleFacade(ctx, "search", request) - if err != nil { - t.Fatalf("malformed post-terminal request = (%+v, %v)", result, err) + searchRequest.Params.Arguments = map[string]any{"operation": "not_an_operation"} + invalid, err := srv.handleFacade(ctx, "search", searchRequest) + if err != nil || invalid == nil || !invalid.IsError { + t.Fatalf("malformed post-answer-ready search = (%+v, %v), want validation error", invalid, err) + } + if navigationCalls != 4 { + t.Fatalf("malformed search dispatched a handler: calls=%d", navigationCalls) } - requireLocalizationTerminalReplay(t, result, "search", "not_an_operation") changeRequest := mcpgo.CallToolRequest{Params: mcpgo.CallToolParams{ Name: "change", @@ -1449,13 +1791,12 @@ func TestAnswerReadyNavigationDispatchNeverInvokesLegacyHandler(t *testing.T) { }} changeResult, err := srv.handleFacade(ctx, "change", changeRequest) if err != nil || changeResult == nil || changeResult.IsError || changeCalls != 1 { - t.Fatalf("post-terminal change.detect = (%+v, %v), calls=%d", changeResult, err, changeCalls) + t.Fatalf("post-answer-ready change.detect = (%+v, %v), calls=%d", changeResult, err, changeCalls) } if text, _ := singleTextContent(changeResult); strings.Contains(text, localizationAnswerReadyDirective) { - t.Fatal("post-terminal change.detect was incorrectly intercepted") + t.Fatal("post-answer-ready change.detect was incorrectly intercepted") } - // Capabilities has a dedicated handler and remains usable after localization. capabilitiesFrame := []byte(`{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"capabilities","arguments":{}}}`) raw, err := json.Marshal(srv.MCPServer().HandleMessage(ctx, capabilitiesFrame)) if err != nil { @@ -1469,9 +1810,184 @@ func TestAnswerReadyNavigationDispatchNeverInvokesLegacyHandler(t *testing.T) { t.Fatalf("decode capabilities response: %v", err) } if called.Error != nil || called.Result == nil || called.Result.IsError { - t.Fatalf("terminal capabilities response = error %#v result %#v", called.Error, called.Result) + t.Fatalf("post-answer-ready capabilities = error %#v result %#v", called.Error, called.Result) } if text, _ := singleTextContent(called.Result); strings.Contains(text, localizationAnswerReadyDirective) { - t.Fatalf("capabilities was incorrectly intercepted: %q", text) + t.Fatalf("capabilities was replaced by localization replay: %q", text) + } +} + +func TestCoreLocalizeDoesNotArmTerminalHostAuthority(t *testing.T) { + t.Setenv("GORTEX_HOOK_SESSION_DIR", t.TempDir()) + srv := setupPresetServer(t, ToolPolicyConfig{Preset: "core", Mode: "defer"}) + ctx := WithSessionID(context.Background(), "core_localize_without_host_lock") + spec, ok := srv.facades.operation("explore", "localize") + if !ok { + t.Fatal("explore.localize facade operation is missing") + } + calls := 0 + completion := newLocalizationCompletion(true, "") + completion.digest = testEvidenceDigest() + srv.facades.capture(mcpgo.NewTool(spec.Legacy), func(context.Context, mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + calls++ + return localizationAnswerReadyResult(completion), nil + }) + token, ok := localizationauth.NewToken() + if !ok { + t.Fatal("failed to mint localization auth token") + } + result, err := srv.handleFacade(ctx, "explore", mcpgo.CallToolRequest{Params: mcpgo.CallToolParams{ + Name: "explore", + Arguments: map[string]any{ + "operation": "localize", + "task": "find the storage load implementations", + localizationauth.ArgumentKey: token, + }, + }}) + if err != nil || result == nil || result.IsError || calls != 1 { + t.Fatalf("core localize = (%+v, %v), calls=%d", result, err, calls) + } + host, ok := result.Meta.AdditionalFields[localizationHostMetaKey].(localizationHostEnvelope) + if !ok || !host.Contract.Terminal { + t.Fatalf("core localize omitted authenticated advisory context: %#v", result.Meta) + } + receipt, published := localizationauth.Consume(token) + if !published || receipt.FinalResponse == "" { + t.Fatalf("core localize omitted advisory receipt: %#v", receipt) + } +} + +func TestUnresolvedPolicyLocalizeDoesNotArmTerminalHostAuthority(t *testing.T) { + t.Setenv("GORTEX_HOOK_SESSION_DIR", t.TempDir()) + srv := setupPresetServer(t, ToolPolicyConfig{Preset: "core", Mode: "defer"}) + srv.toolPolicy = nil + srv.toolPolicyOperatorPinned = true + ctx := WithSessionID(context.Background(), "unresolved_policy_localize_without_host_lock") + spec, ok := srv.facades.operation("explore", "localize") + if !ok { + t.Fatal("explore.localize facade operation is missing") + } + calls := 0 + completion := newLocalizationCompletion(true, "") + completion.digest = testEvidenceDigest() + srv.facades.capture(mcpgo.NewTool(spec.Legacy), func(context.Context, mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + calls++ + return localizationAnswerReadyResult(completion), nil + }) + token, ok := localizationauth.NewToken() + if !ok { + t.Fatal("failed to mint localization auth token") + } + result, err := srv.handleFacade(ctx, "explore", mcpgo.CallToolRequest{Params: mcpgo.CallToolParams{ + Name: "explore", + Arguments: map[string]any{ + "operation": "localize", + "task": "find the storage load implementations", + localizationauth.ArgumentKey: token, + }, + }}) + if err != nil || result == nil || result.IsError || calls != 1 { + t.Fatalf("unresolved-policy localize = (%+v, %v), calls=%d", result, err, calls) + } + host, ok := result.Meta.AdditionalFields[localizationHostMetaKey].(localizationHostEnvelope) + if !ok || !host.Contract.Terminal { + t.Fatalf("unresolved-policy localize omitted authenticated advisory context: %#v", result.Meta) + } + receipt, published := localizationauth.Consume(token) + if !published || receipt.FinalResponse == "" { + t.Fatalf("unresolved-policy localize omitted advisory receipt: %#v", receipt) + } +} + +func TestAnswerReadyNavigationRemainsOpenForCodingSessionPresets(t *testing.T) { + for _, preset := range []string{"core", "full"} { + t.Run(preset, func(t *testing.T) { + srv := setupPresetServer(t, ToolPolicyConfig{Preset: preset, Mode: "defer"}) + ctx := WithSessionID(context.Background(), preset+"_coding_after_localize") + initFrame := []byte(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"codex","version":"1.0"}}}`) + if reply := srv.MCPServer().HandleMessage(ctx, initFrame); reply == nil { + t.Fatal("initialize returned nil") + } + + spec, ok := srv.facades.operation("search", "symbols") + if !ok { + t.Fatal("search.symbols facade operation is missing") + } + handlerCalls := 0 + srv.facades.capture(mcpgo.NewTool(spec.Legacy), func(context.Context, mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + handlerCalls++ + return mcpgo.NewToolResultText(`{"results":[]}`), nil + }) + completion := newLocalizationCompletion(true, "") + completion.Enforceable = true + completion.enforceableOnAnswerReady = true + completion.digest = testEvidenceDigest() + srv.localizationFor(ctx).armForTask(completion, "find the storage load implementations") + + // Exercise the production wrapper, not handleFacade directly. Even an + // enforceable answer_ready state must be advisory outside the explicit + // benchmark profile, so a long-running coding session keeps navigating. + toolName := "search" + arguments := map[string]any{"operation": "symbols", "query": "Load"} + if preset == "full" { + toolName = spec.Legacy + arguments = map[string]any{"query": "Load"} + } + searchFrame, err := json.Marshal(map[string]any{ + "jsonrpc": "2.0", "id": 2, "method": "tools/call", + "params": map[string]any{"name": toolName, "arguments": arguments}, + }) + if err != nil { + t.Fatalf("marshal search request: %v", err) + } + raw, err := json.Marshal(srv.MCPServer().HandleMessage(ctx, searchFrame)) + if err != nil { + t.Fatalf("marshal post-localization search: %v", err) + } + var called struct { + Error any `json:"error"` + Result *mcpgo.CallToolResult `json:"result"` + } + if err := json.Unmarshal(raw, &called); err != nil { + t.Fatalf("decode post-localization search: %v", err) + } + if called.Error != nil || called.Result == nil || called.Result.IsError { + t.Fatalf("%s post-localization search = error %#v result %#v", preset, called.Error, called.Result) + } + if preset == "core" && handlerCalls != 1 { + t.Fatalf("%s facade search handler calls = %d, want 1", preset, handlerCalls) + } + if text, _ := singleTextContent(called.Result); strings.Contains(text, localizationAnswerReadyDirective) { + t.Fatalf("%s coding navigation was incorrectly intercepted: %q", preset, text) + } + }) + } +} + +func TestAnswerReadyDirectiveStopsLocalizationOnlyCrossCheckWithoutBlockingCoding(t *testing.T) { + for _, required := range []string{ + "bounded search examined the supplied anchors", + "full retained result", + "For a localization-only request, answer now", + "Do not call another tool just to gain confidence, repeat, or cross-check this localization", + "PRIMARY rows are the best-supported answer", + "SUPPORTING rows are context, not a signal that the search is unfinished", + "An identical localize call will replay this result", + "On a broader coding task, this closes only localization", + "diagnosis, implementation, or verification", + "all tools remain available", + } { + if !strings.Contains(localizationAnswerReadyDirective, required) { + t.Fatalf("answer-ready directive missing %q: %s", required, localizationAnswerReadyDirective) + } + } + for _, forbidden := range []string{ + "additional Gortex navigation call was not run", + "make no further tool calls", + "if the evidence is contradictory", + } { + if strings.Contains(localizationAnswerReadyDirective, forbidden) { + t.Fatalf("answer-ready directive contains blocking or ambiguous wording %q: %s", forbidden, localizationAnswerReadyDirective) + } } } diff --git a/internal/mcp/localization_evidence_policy.go b/internal/mcp/localization_evidence_policy.go index 39c463f72..24866c3e5 100644 --- a/internal/mcp/localization_evidence_policy.go +++ b/internal/mcp/localization_evidence_policy.go @@ -6,15 +6,17 @@ import ( "github.com/zzet/gortex/internal/graph" ) -// Hard terminal enforcement is deliberately narrower than answer readiness. -// The latter is a ranking decision; the former requires one of these bounded, -// production-proven evidence shapes and must survive final response packing. +// Strong completion evidence is deliberately narrower than answer readiness. +// Answer readiness is a ranking decision; these bounded, production-proven +// shapes justify a stronger advisory confidence signal after response packing. +// They never create a tool-permission boundary for an ordinary coding session. const ( localizationProvenanceSourceLiteralCallee = "source_literal_callee" localizationProvenanceDivergentDefault = "divergent_default_owner" localizationProvenanceDivergentDefaultType = "divergent_default_type" localizationProvenanceImplementationRoute = "implementation_route" localizationProvenanceImplementationTarget = "implementation_target" + localizationProvenanceSyntacticAnchor = "syntactic_anchor" localizationProvenanceTypedAnchorProjection = "typed_anchor_projection" ) @@ -57,6 +59,29 @@ func localizationStrongEvidenceForCompletion(completion localizationCompletion, return localizationEvidenceProof{} } + // A prescribed refinement can retire in the first response because its + // graph-proven wrapper/implementation pair is already packed. Preserve the + // same paired proof that an authorized follow-up read would enforce. + for symbol, route := range completion.refinementRoutes { + if !route.enforceable { + continue + } + switch { + case route.proofSymbol != "": + return localizationEvidenceProof{ + provenance: localizationProvenanceImplementationRoute, + primary: route.proofSymbol, + support: []string{symbol}, + } + case route.implementationSymbol != "": + return localizationEvidenceProof{ + provenance: localizationProvenanceImplementationRoute, + primary: symbol, + support: []string{route.implementationSymbol}, + } + } + } + ownerID, ownerIndex := "", -1 typeID := "" for index, target := range targets { @@ -100,23 +125,21 @@ func localizationStrongEvidenceForCompletion(completion localizationCompletion, } // localizationRetiredReadByteAllowance caps the extra payload a response may -// spend on the one body that removes a round trip. Measured, a full page fills -// its budget to within a few hundred bytes with ranked rows alone, so an -// ordinary body overflows by a little and is refused — and that is the -// expensive outcome. The caller spends those bytes either way: inline they are -// paid once, as a round trip they are paid again as a fresh request, its cache -// write and its output. -const localizationRetiredReadByteAllowance = 4096 +// spend on the one body that removes a round trip. A complete second model turn +// replays the system prompt, tool schemas, and ranked page before it can return +// the same source. Paying up to one response budget for that source in the first +// page is therefore the smaller bounded cost. +const localizationRetiredReadByteAllowance = 8192 -// localizationRetiredReadAllowance bounds that spend against what the caller -// asked for: nothing below the real minimum budget, and never more than half -// again. +// localizationRetiredReadAllowance bounds the inlined source against what the +// caller already budgeted for the ranked response. It never grows a page by +// more than one additional response budget or the hard byte cap. func localizationRetiredReadAllowance(maxBytes int) int { if maxBytes < exploreMinBudgetTokens*localizationEnvelopeBytesPerToken { return 0 } - if allowance := maxBytes / 2; allowance < localizationRetiredReadByteAllowance { - return allowance + if maxBytes < localizationRetiredReadByteAllowance { + return maxBytes } return localizationRetiredReadByteAllowance } @@ -224,6 +247,12 @@ func localizationCompletionRetiringPrescribedRead(completion localizationComplet completed.taskLead = completion.taskLead completed.enforceableOnAnswerReady = completion.enforceableOnAnswerReady completed.digest = completion.digest + if len(completion.refinementRoutes) > 0 { + completed.refinementRoutes = make(map[string]localizationRefinementRoute, len(completion.refinementRoutes)) + for symbol, route := range completion.refinementRoutes { + completed.refinementRoutes[symbol] = route + } + } return completed } @@ -391,6 +420,9 @@ func localizationTargetProvenance(completion localizationCompletion, target expl if localizationStrongSourceLiteralCallee(target) { return localizationProvenanceSourceLiteralCallee } + if target.syntacticAnchor { + return localizationProvenanceSyntacticAnchor + } if target.typedAnchorProjection { return localizationProvenanceTypedAnchorProjection } diff --git a/internal/mcp/localization_evidence_policy_test.go b/internal/mcp/localization_evidence_policy_test.go index 31ce98204..c3c25e62b 100644 --- a/internal/mcp/localization_evidence_policy_test.go +++ b/internal/mcp/localization_evidence_policy_test.go @@ -154,31 +154,23 @@ func TestLocalizationEvidencePolicyKeepsCompleteImplementationRouteAfterPacking( implementation.ID, "find the replacement implementation", targets, exploreDefaultBudgetTokens, routes, ) - require.Equal(t, localizationStateNeedsRefinement, completion.State) - require.True(t, bounded[wrapper.ID].enforceable) + if completion.State != localizationStateAnswerReady { + t.Fatalf("packed implementation route did not conclude in one call: completion=%#v bounded=%#v", completion, bounded) + } + require.False(t, completion.Enforceable, + "a task-aligned body may conclude localization without upgrading bounded evidence to hard proof") + require.Zero(t, completion.AllowedToolCalls) require.True(t, bounded[implementation.ID].enforceable) require.Equal(t, wrapper.ID, bounded[implementation.ID].proofSymbol) - state := &localizationTerminalState{} - state.armRefinementRoutesForTask( - "find the replacement implementation", completion.refinementSymbol, - completion.AllowedSymbols, bounded, nil, - ) - blocked, allowed := state.authorize("read", "source", map[string]any{ - "target": map[string]any{"symbol": implementation.ID}, - }) - require.Nil(t, blocked) - require.True(t, allowed) - finished := state.finishReservedRead(true) - require.Equal(t, localizationStateAnswerReady, finished.State) - require.True(t, finished.Enforceable) - body, ok := singleTextContent(result) require.True(t, ok) var envelope localizationExploreEnvelope require.NoError(t, json.Unmarshal([]byte(body), &envelope)) - require.False(t, envelope.Terminal) + require.True(t, envelope.Terminal) + require.Equal(t, localizationStateAnswerReady, envelope.Completion.State) require.False(t, envelope.Completion.Enforceable) + require.Contains(t, envelope.Completion.FinalResponse, localizationBoundedHeading) provenance := make(map[string]string, len(envelope.Evidence)) for _, row := range envelope.Evidence { provenance[row.ID] = row.Provenance @@ -223,30 +215,21 @@ func TestLocalizationEvidencePolicyPrefersImplementationRouteForMixedStrongProof exploreDefaultBudgetTokens, routes, ) require.True(t, bounded[implementation.ID].enforceable) + require.Equal(t, localizationStateAnswerReady, completion.State) + require.Zero(t, completion.AllowedToolCalls) + require.False(t, completion.Enforceable) body, ok := singleTextContent(result) require.True(t, ok) var envelope localizationExploreEnvelope require.NoError(t, json.Unmarshal([]byte(body), &envelope)) + require.True(t, envelope.Terminal) + require.Contains(t, envelope.Completion.FinalResponse, localizationBoundedHeading) provenance := make(map[string]string, len(envelope.Evidence)) for _, row := range envelope.Evidence { provenance[row.ID] = row.Provenance } require.Equal(t, localizationProvenanceImplementationRoute, provenance[wrapper.ID]) require.Equal(t, localizationProvenanceImplementationTarget, provenance[implementation.ID]) - - state := &localizationTerminalState{} - state.armRefinementRoutesForTask( - "find the replacement implementation", completion.refinementSymbol, - completion.AllowedSymbols, bounded, nil, - ) - blocked, allowed := state.authorize("read", "source", map[string]any{ - "target": map[string]any{"symbol": implementation.ID}, - }) - require.Nil(t, blocked) - require.True(t, allowed) - finished := state.finishReservedRead(true) - require.Equal(t, localizationStateAnswerReady, finished.State) - require.True(t, finished.Enforceable) } func TestTypedAnchorProvenanceDoesNotOverrideStrongerEvidenceRoles(t *testing.T) { diff --git a/internal/mcp/localization_explicit_anchor_test.go b/internal/mcp/localization_explicit_anchor_test.go new file mode 100644 index 000000000..c9f728221 --- /dev/null +++ b/internal/mcp/localization_explicit_anchor_test.go @@ -0,0 +1,206 @@ +package mcp + +import ( + "testing" + + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/query" + "github.com/zzet/gortex/internal/search/rerank" +) + +func TestExploreSyntacticAnchorsDoNotSpendBudgetOnHTTPRouteMethods(t *testing.T) { + task := `HandleMethodNotAllowed with r.GET("/base/metrics"), r.DELETE("/base/v1/organizations/:id"), then panic in tree.go getValue` + anchors := exploreSyntacticAnchors(task) + if len(anchors) != 3 { + t.Fatalf("anchors = %#v, want three implementation anchors", anchors) + } + got := []string{anchors[0].compact, anchors[1].compact, anchors[2].compact} + want := []string{"handlemethodnotallowed", "tree", "getvalue"} + for index := range want { + if got[index] != want[index] { + t.Fatalf("anchor %d = %q, want %q (all=%v)", index, got[index], want[index], got) + } + } +} + +func TestExploreSyntacticAnchorsCollapseAssignedAndBareFlags(t *testing.T) { + anchors := exploreSyntacticAnchors(`rg panic caused by --replace, --multiline, a particular pattern, and search text containing repeats and newlines. Panic message: "slice index starts at x but ends at y" where x and y are integers and y < x. Reproduction: rg "(^|[^a-z])((([a-z]+)?)\s)?b(\s([a-z]+)?)($|[^a-z])" --replace=x --multiline lines2.txt where lines2.txt contains " b b b b b b b b\nc". Also reproduces with lines5.txt containing " b\nb\nb\nb\nc". Bug is very sensitive to exact pattern and text.`) + if len(anchors) != 2 { + t.Fatalf("anchors = %#v, want one replace lane and one multiline lane", anchors) + } + if anchors[0].compact != "replace" || anchors[1].compact != "multiline" { + t.Fatalf("anchor compacts = [%s, %s], want replace then multiline", anchors[0].compact, anchors[1].compact) + } +} + +func TestExploreSyntacticAnchorCompetitionIsScopedToBareCompoundCallable(t *testing.T) { + anchor, ok := newExploreSyntacticAnchor("--replace") + if !ok { + t.Fatal("replace flag did not produce a syntactic anchor") + } + separator := &rerank.Candidate{Node: &graph.Node{ + ID: "crates/printer/src/util.rs::replace_separator", Name: "replace_separator", + QualName: "replace_separator", Kind: graph.KindFunction, FilePath: "crates/printer/src/util.rs", + StartLine: 10, EndLine: 210, + }} + replacement := &rerank.Candidate{Node: &graph.Node{ + ID: "crates/printer/src/util.rs::Replacer.replacement", Name: "replacement", + QualName: "Replacer.replacement", Kind: graph.KindMethod, FilePath: "crates/printer/src/util.rs", + StartLine: 113, EndLine: 124, + }} + replaceAll := &rerank.Candidate{Node: &graph.Node{ + ID: "crates/printer/src/util.rs::Replacer.replace_all", Name: "replace_all", + QualName: "Replacer.replace_all", Kind: graph.KindMethod, FilePath: "crates/printer/src/util.rs", + StartLine: 55, EndLine: 111, + }} + candidates := []*rerank.Candidate{separator, replacement, replaceAll} + usedIDs, usedFiles := map[string]struct{}{}, map[string]struct{}{} + if !exploreSyntacticAnchorNeedsLexicalCompetition(anchor, separator) { + t.Fatal("bare replace anchor prematurely settled on compound replace_separator") + } + ordinary := exploreSyntacticAnchorCandidate( + anchor, candidates, query.QueryOptions{}, usedIDs, usedFiles, + ) + if ordinary == nil || ordinary.Node.ID != separator.Node.ID { + t.Fatalf("ordinary selection = %#v, want stable first candidate", ordinary) + } + competing := exploreSyntacticAnchorCompetingCandidate( + anchor, candidates, query.QueryOptions{}, usedIDs, usedFiles, + ) + if competing == nil || competing.Node.ID != replaceAll.Node.ID { + t.Fatalf("competing selection = %#v, want specific replace_all implementation despite larger generic body", competing) + } + + explicit, ok := newExploreSyntacticAnchor("replace_separator") + if !ok { + t.Fatal("explicit replace_separator did not produce an anchor") + } + if exploreSyntacticAnchorNeedsLexicalCompetition(explicit, separator) { + t.Fatal("multi-term explicit replace_separator was treated as ambiguous") + } + exact := &rerank.Candidate{Node: &graph.Node{ + ID: "crates/printer/src/util.rs::replace", Name: "replace", + Kind: graph.KindFunction, FilePath: "crates/printer/src/util.rs", + }} + if exploreSyntacticAnchorNeedsLexicalCompetition(anchor, exact) { + t.Fatal("atomic exact replace match was treated as ambiguous") + } + if got := exploreSyntacticAnchorFetchLimit(anchor, separator); got != exploreSyntacticAnchorCompetitionFetch { + t.Fatalf("ambiguous replace fetch = %d, want %d", got, exploreSyntacticAnchorCompetitionFetch) + } + if got := exploreSyntacticAnchorFetchLimit(explicit, separator); got != exploreSyntacticAnchorFetch { + t.Fatalf("exact replace_separator fetch = %d, want %d", got, exploreSyntacticAnchorFetch) + } +} + +func TestMarkExploreSyntacticAnchorCandidateCarriesDedicatedProvenanceSignal(t *testing.T) { + shared := map[string]float64{"existing": 1} + candidate := &rerank.Candidate{Node: &graph.Node{ID: "pkg/util.rs::Replacer.replace_all"}, Signals: shared} + sibling := &rerank.Candidate{Node: &graph.Node{ID: "pkg/lines.rs::lines"}, Signals: shared} + markExploreSyntacticAnchorCandidate(candidate) + if candidate.Signals[exploreConceptComplementSignal] != 1 || candidate.Signals[exploreSyntacticAnchorSignal] != 1 { + t.Fatalf("signals = %#v, want concept retention plus syntactic provenance", candidate.Signals) + } + if sibling.Signals[exploreConceptComplementSignal] != 0 || sibling.Signals[exploreSyntacticAnchorSignal] != 0 { + t.Fatalf("syntactic provenance leaked to unselected sibling: %#v", sibling.Signals) + } + if candidate.Signals["existing"] != 1 { + t.Fatalf("existing signal was lost: %#v", candidate.Signals) + } +} + +func TestExploreQueryPathAnchorsIgnoreHTTPRouteExamples(t *testing.T) { + query := `HandleMethodNotAllowed r.GET("/base/metrics") r.GET("/base/v1/:id/devices") panic in github.com/gin-gonic/gin.(*node).getValue at tree.go:637` + syntactic := exploreSyntacticAnchors(query) + if len(syntactic) != 3 || syntactic[2].compact != "tree" { + t.Fatalf("stack source location anchor = %#v, want trailing tree", syntactic) + } + anchors, hasDirectory := exploreQueryPathAnchors(query) + if hasDirectory || len(anchors) != 0 { + t.Fatalf("runtime routes became source paths: anchors=%v hasDirectory=%v", anchors, hasDirectory) + } + node := &graph.Node{ + ID: "tree.go::node.getValue", + Name: "getValue", + QualName: "node.getValue", + Kind: graph.KindMethod, + FilePath: "tree.go", + } + if !exploreLocalizationExplicitAnchor(query, node) { + t.Fatal("explicit tree.go/getValue anchor was hidden by HTTP route examples") + } +} + +func TestLocalizationEvidenceTargetsPreserveProtectedSyntacticAnchorBeforeDraftRelations(t *testing.T) { + head := exploreTarget{node: &graph.Node{ + ID: "crates/core/flags/hiargs.rs::suggest_multiline", Name: "suggest_multiline", + QualName: "suggest_multiline", Kind: graph.KindFunction, FilePath: "crates/core/flags/hiargs.rs", + }} + unrelated := exploreTarget{node: &graph.Node{ + ID: "crates/core/flags/hiargs.rs::BinaryMode.Auto", Name: "Auto", + QualName: "BinaryMode.Auto", Kind: graph.KindMethod, FilePath: "crates/core/flags/hiargs.rs", + }} + replaceAll := exploreTarget{ + node: &graph.Node{ + ID: "crates/printer/src/util.rs::Replacer.replace_all", Name: "replace_all", + QualName: "Replacer.replace_all", Kind: graph.KindMethod, FilePath: "crates/printer/src/util.rs", + }, + conceptComplement: true, + } + targets := []exploreTarget{head, unrelated, replaceAll} + selected := localizationEvidenceTargetsFromDraft( + "--replace with --multiline panics while replacing repeated matches", + "", + targets, + []exploreDraftEntry{{node: head.node}, {node: unrelated.node}, {node: replaceAll.node}}, + ) + if len(selected) != len(targets) { + t.Fatalf("selected = %#v, want all targets", selected) + } + if selected[0].node.ID != head.node.ID || selected[1].node.ID != replaceAll.node.ID { + t.Fatalf("selected order = [%s, %s], want retrieval head then protected replace anchor", selected[0].node.ID, selected[1].node.ID) + } +} + +func TestLocalizationEvidenceTargetsPrioritizeExplicitConsumerBeforeCausalOwner(t *testing.T) { + explicit := exploreTarget{node: &graph.Node{ + ID: "src/Monolog/Handler/StreamHandler.php::StreamHandler.write", + Name: "write", + QualName: "StreamHandler.write", + Kind: graph.KindMethod, + FilePath: "src/Monolog/Handler/StreamHandler.php", + }} + owner := exploreTarget{ + node: &graph.Node{ + ID: "src/Monolog/Handler/RotatingFileHandler.php::RotatingFileHandler.__construct", + Name: "__construct", + QualName: "RotatingFileHandler.__construct", + Kind: graph.KindMethod, + FilePath: "src/Monolog/Handler/RotatingFileHandler.php", + }, + divergentDefaultOwner: true, + } + ownerType := exploreTarget{ + node: &graph.Node{ + ID: "src/Monolog/Handler/RotatingFileHandler.php::RotatingFileHandler", + Name: "RotatingFileHandler", + QualName: "RotatingFileHandler", + Kind: graph.KindType, + FilePath: "src/Monolog/Handler/RotatingFileHandler.php", + }, + divergentDefaultType: true, + } + targets := []exploreTarget{owner, ownerType, explicit} + selected := localizationEvidenceTargetsFromDraft( + "chmod failure in src/Monolog/Handler/StreamHandler.php::StreamHandler.write", + owner.node.ID, + targets, + []exploreDraftEntry{{node: owner.node}, {node: ownerType.node}, {node: explicit.node}}, + ) + if len(selected) < 2 { + t.Fatalf("selected = %#v, want explicit target plus causal owner", selected) + } + if selected[0].node.ID != explicit.node.ID || selected[1].node.ID != owner.node.ID { + t.Fatalf("selected order = [%s, %s], want explicit consumer then causal owner", selected[0].node.ID, selected[1].node.ID) + } +} diff --git a/internal/mcp/localization_prescribed_body_test.go b/internal/mcp/localization_prescribed_body_test.go index 2c327a862..e15ef6520 100644 --- a/internal/mcp/localization_prescribed_body_test.go +++ b/internal/mcp/localization_prescribed_body_test.go @@ -51,8 +51,11 @@ func TestASingleTargetPrescriptionShipsItsBodyAndReleasesTheRead(t *testing.T) { newLocalizationRefinementCompletion(symbol), task, targets, 1600) envelope := decodeLocalizationEnvelope(t, result) - if finalized.State != localizationStateLocalized { - t.Fatalf("a satisfied single-target prescription still commands its read: %#v", finalized) + if finalized.State != localizationStateAnswerReady || finalized.Enforceable { + t.Fatalf("a satisfied single-target prescription did not become a bounded conclusion: %#v", finalized) + } + if !envelope.Terminal || !strings.HasPrefix(finalized.FinalResponse, localizationBoundedHeading+"\n") { + t.Fatalf("a released prescription did not publish its bounded-evidence page: %#v", finalized) } if finalized.AllowedToolCalls != 0 || strings.Contains(finalized.Instruction, "read(") { t.Fatalf("a released prescription still asks for a call: %#v", finalized) @@ -200,3 +203,46 @@ func TestAnUnpackedPrescriptionStillAsksForTheRead(t *testing.T) { t.Fatalf("a prescription was retired without its body: %#v", finalized) } } + +// The server has already ranked and hydrated every refinement candidate. The +// model should not pay another full turn merely to choose and read the same +// preferred source. Alternate candidates remain visible as ranked evidence. +func TestBuildRefinementPacksOnlyThePreferredCandidate(t *testing.T) { + const task = "DiskStorage.Load returns an empty path when loading a stored record" + symbol, targets := prescribedBodyTargets( + `func (d *DiskStorage) Load(path string) ([]byte, error) { return os.ReadFile(path) }`) + alternate := targets[1].node.ID + routes := map[string]localizationRefinementRoute{ + symbol: {}, + alternate: {}, + } + + result, finalized, bounded, _ := buildLocalizationRefinementResultForTask( + symbol, task, targets, 1600, routes) + envelope := decodeLocalizationEnvelope(t, result) + + if finalized.State == localizationStateNeedsRefinement { + t.Fatalf("the hydrated preferred refinement still requires a second call: completion=%#v evidence=%#v", finalized, envelope.Evidence) + } + if finalized.AllowedToolCalls != 0 { + t.Fatalf("the one-shot refinement still permits another call: %#v", finalized) + } + if len(bounded) > 1 { + t.Fatalf("more than the preferred route remained authorized: %#v", bounded) + } + if len(envelope.Evidence) < 2 || envelope.Evidence[1].ID != alternate { + t.Fatalf("the alternate candidate disappeared from ranked evidence: %#v", envelope.Evidence) + } + var preferredBody, alternateBody bool + for _, evidence := range envelope.Evidence { + switch evidence.ID { + case symbol: + preferredBody = strings.TrimSpace(evidence.Source) != "" + case alternate: + alternateBody = strings.TrimSpace(evidence.Source) != "" + } + } + if !preferredBody || alternateBody { + t.Fatalf("body packing preferred=%t alternate=%t; want only preferred", preferredBody, alternateBody) + } +} diff --git a/internal/mcp/localization_recovery_capture_test.go b/internal/mcp/localization_recovery_capture_test.go new file mode 100644 index 000000000..d7ce53eda --- /dev/null +++ b/internal/mcp/localization_recovery_capture_test.go @@ -0,0 +1,65 @@ +package mcp + +import ( + "context" + "testing" + + "github.com/zzet/gortex/internal/graph" +) + +func TestLocalizationSearchTextCaptureResolvesRepositoryPrefixedPath(t *testing.T) { + g := graph.New() + filePath := "src/Humanizer/Configuration/FormatterRegistry.cs" + repoPrefix := "org/humanizer-1059" + file := &graph.Node{ + ID: filePath, + Kind: graph.KindFile, + Name: filePath, + FilePath: filePath, + RepoPrefix: repoPrefix, + } + owner := &graph.Node{ + ID: filePath + "::FormatterRegistry.", + Kind: graph.KindMethod, + Name: "FormatterRegistry", + QualName: "Humanizer.Configuration.FormatterRegistry", + FilePath: filePath, + StartLine: 18, + EndLine: 27, + RepoPrefix: repoPrefix, + } + decoy := &graph.Node{ + ID: "other-repo::FormatterRegistry.", + Kind: graph.KindMethod, + Name: "FormatterRegistry", + FilePath: filePath, + StartLine: 18, + EndLine: 27, + RepoPrefix: "other-repo", + } + g.AddNode(file) + g.AddNode(owner) + g.AddNode(decoy) + + server := &Server{graph: g} + ctx := withLocalizationPermittedEvidenceCapture(context.Background(), 41) + server.captureLocalizationSearchText(ctx, []enrichedTextMatch{{ + Path: repoPrefix + "/" + filePath, + Line: 24, + Text: `RegisterDefaultFormatter("ku");`, + }}) + + rows, recorded := localizationCapturedEvidence(ctx, 41) + if !recorded { + t.Fatal("repository-prefixed text match was not recorded") + } + if len(rows) != 1 { + t.Fatalf("captured rows = %#v, want one graph-backed owner", rows) + } + if rows[0].ID != owner.ID || rows[0].File != filePath || rows[0].Line != 24 { + t.Fatalf("captured row = %#v, want owner %q at %s:24", rows[0], owner.ID, filePath) + } + if rows[0].Provenance != "permitted_search_text_owner" { + t.Fatalf("captured provenance = %q", rows[0].Provenance) + } +} diff --git a/internal/mcp/localization_recovery_test.go b/internal/mcp/localization_recovery_test.go index 6ffd20b47..4921cacfd 100644 --- a/internal/mcp/localization_recovery_test.go +++ b/internal/mcp/localization_recovery_test.go @@ -4,13 +4,28 @@ import ( "context" "encoding/json" "errors" + "strings" "testing" mcpgo "github.com/mark3labs/mcp-go/mcp" + + "github.com/zzet/gortex/internal/graph" ) -func TestWeakReadAllowsOneBoundedSearchRecoveryThenTerminates(t *testing.T) { - server := setupPresetServer(t, ToolPolicyConfig{Preset: "core", Mode: "defer"}) +func setupHardLocalizationRecoveryServer(t *testing.T) *Server { + t.Helper() + cfg := ToolPolicyConfig{Preset: "localization", Mode: "defer"} + server := setupPresetServer(t, cfg) + if server.toolPolicy == nil { + t.Fatal("localization test server omitted tool policy") + } + // Recovery tests exercise the explicit localize transaction itself. Tool + // profile provenance must not alter its state machine or grant a hard gate. + return server +} + +func TestWeakReadAllowsOneBoundedSearchRecoveryThenContinuesOrdinarySearch(t *testing.T) { + server := setupHardLocalizationRecoveryServer(t) ctx := WithSessionID(context.Background(), "weak_read_search_recovery") terminal := server.localizationFor(ctx) preferred := "repo/search.go::findCandidate" @@ -51,20 +66,19 @@ func TestWeakReadAllowsOneBoundedSearchRecoveryThenTerminates(t *testing.T) { } requireLocalizationResultStateEqual(t, terminal, searchResult, localizationStateAnswerReady, true, 0) - extra, err := server.handleFacade(ctx, "search", localizationRecoveryRequest("search", "text", map[string]any{ + ordinary, err := server.handleFacade(ctx, "search", localizationRecoveryRequest("search", "text", map[string]any{ "query": "another anchor", })) - if err != nil { - t.Fatalf("post-recovery call returned transport error: %v", err) + if err != nil || ordinary == nil || ordinary.IsError { + t.Fatalf("ordinary post-recovery search = (%#v, %v)", ordinary, err) } - requireLocalizationTerminalReplay(t, extra, "search", "text") - if searchCalls != 1 { - t.Fatalf("post-recovery search reached handler: calls=%d", searchCalls) + if searchCalls != 2 { + t.Fatalf("ordinary post-recovery search did not reach handler: calls=%d", searchCalls) } } func TestRecoveryRejectsUnrelatedAnchorWithoutConsumingAllowance(t *testing.T) { - server := setupPresetServer(t, ToolPolicyConfig{Preset: "core", Mode: "defer"}) + server := setupHardLocalizationRecoveryServer(t) ctx := WithSessionID(context.Background(), "unrelated_recovery_anchor") terminal := server.localizationFor(ctx) terminal.armForTask(newLocalizationRecoveryCompletion(), "--multiline with --replace duplicates printer output") @@ -149,6 +163,144 @@ func TestRecoveryAcceptsTaskAlignedIdentifierAndCompactLiteralAnchors(t *testing } } +func TestAcceptedRecoveryMergesHumanizerLiteralEvidenceAndTerminalizes(t *testing.T) { + state := newLocalizationTerminalState() + task := `CultureNotFoundException ("ku") on Xamarin for Android` + state.armForTask(newLocalizationRecoveryCompletion(), task) + + blocked, token := state.authorizeWithToken("search", "text", map[string]any{"query": "ku"}) + if blocked != nil || token == 0 { + t.Fatalf("humanizer literal recovery authorization = (%#v, %d)", blocked, token) + } + row := localizationDigestRow{ + ID: "src/Humanizer/Configuration/FormatterRegistry.cs::FormatterRegistry", + Name: "FormatterRegistry", + QualName: "FormatterRegistry", + Kind: "class", + File: "src/Humanizer/Configuration/FormatterRegistry.cs", + Line: 10, + } + completion := state.finishReservedReadTokenWithDigest(token, true, []localizationDigestRow{row}, true) + if completion.State != localizationStateAnswerReady || completion.AllowedToolCalls != 0 || completion.Enforceable { + t.Fatalf("humanizer recovery completion = %#v", completion) + } + if completion.digest == nil || len(completion.digest.Evidence) == 0 || completion.digest.Evidence[0].ID != row.ID { + t.Fatalf("humanizer recovery did not merge captured FormatterRegistry evidence: %#v", completion.digest) + } + if strings.HasPrefix(completion.FinalResponse, localizationProvisionalHeading) || + strings.HasPrefix(completion.FinalResponse, localizationBoundedHeading) || !strings.Contains(completion.FinalResponse, row.ID) { + t.Fatalf("aligned humanizer recovery did not publish confirmed evidence: %q", completion.FinalResponse) + } + if replay, retryToken := state.authorizeWithToken("search", "text", map[string]any{"query": "ku"}); replay == nil || retryToken != 0 { + t.Fatalf("humanizer recovery repeated needs_recovery: (%#v, %d)", replay, retryToken) + } + if blocked, codingToken := state.authorizeWithToken("edit", "file", map[string]any{}); blocked != nil || codingToken != 0 { + t.Fatalf("terminal localization blocked coding workflow: (%#v, %d)", blocked, codingToken) + } +} + +func TestAcceptedRecoveryHumanizerLiteralFacadeEventPublishesEvidenceAndContinuesSearch(t *testing.T) { + server := setupHardLocalizationRecoveryServer(t) + ownerGraph := graph.New() + repoPrefix := "org/humanizer-1059" + file := &graph.Node{ + ID: "src/Humanizer/Configuration/FormatterRegistry.cs", Name: "FormatterRegistry.cs", + Kind: graph.KindFile, FilePath: "src/Humanizer/Configuration/FormatterRegistry.cs", StartLine: 1, EndLine: 67, + RepoPrefix: repoPrefix, + } + owner := &graph.Node{ + ID: "src/Humanizer/Configuration/FormatterRegistry.cs::FormatterRegistry", Name: "FormatterRegistry", + QualName: "FormatterRegistry", Kind: graph.KindType, FilePath: file.FilePath, StartLine: 8, EndLine: 66, + RepoPrefix: repoPrefix, + } + ownerGraph.AddNode(file) + ownerGraph.AddNode(owner) + server.graph = ownerGraph + + ctx := WithSessionID(context.Background(), "humanizer_literal_recovery_event") + terminal := server.localizationFor(ctx) + task := `CultureNotFoundException ("ku") on Xamarin for Android` + retainedID := "src/Humanizer/NumberToWordsExtension.cs::NumberToWordsExtension.ToWords" + recovery := newLocalizationRecoveryCompletion() + recovery.digest = newLocalizationEvidenceDigestForTask(task, localizationExploreEnvelope{ + Evidence: []localizationEvidence{{ + Rank: 1, ID: retainedID, Name: "ToWords", Kind: "method", + File: "src/Humanizer/NumberToWordsExtension.cs", Line: 55, + }}, + }) + terminal.armForTask(recovery, task) + searchSpec, ok := server.facades.operation("search", "text") + if !ok { + t.Fatal("search.text facade operation is missing") + } + calls := 0 + server.facades.capture(mcpgo.NewTool(searchSpec.Legacy), func(callCtx context.Context, _ mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + calls++ + server.captureLocalizationSearchText(callCtx, []enrichedTextMatch{{ + Path: repoPrefix + "/" + file.FilePath, Line: 24, Text: `Register("ku", formatter)`, + }}) + return mcpgo.NewToolResultText(`{"matches":[{"path":"org/humanizer-1059/src/Humanizer/Configuration/FormatterRegistry.cs","line":24}]}`), nil + }) + + result, err := server.handleFacade(ctx, "search", localizationRecoveryRequest("search", "text", map[string]any{"query": "ku"})) + if err != nil || result == nil || result.IsError || calls != 1 { + t.Fatalf("humanizer facade recovery = (%#v, %v), calls=%d", result, err, calls) + } + requireLocalizationResultStateEqual(t, terminal, result, localizationStateAnswerReady, true, 0) + host, ok := result.Meta.AdditionalFields[localizationHostMetaKey].(localizationHostEnvelope) + if !ok || host.Evidence == nil || len(host.Evidence.Evidence) == 0 || host.Evidence.Evidence[0].ID != owner.ID { + t.Fatalf("humanizer facade event did not publish FormatterRegistry evidence: %#v", result.Meta) + } + retained := false + for _, row := range host.Evidence.Evidence { + retained = retained || row.ID == retainedID + } + if !retained { + t.Fatalf("recovery merge discarded retained evidence %q: %#v", retainedID, host.Evidence.Evidence) + } + primary := "- EVIDENCE #1 — PRIMARY — " + file.FilePath + ":24 — " + owner.ID + if !strings.Contains(host.Contract.Completion.FinalResponse, primary) { + t.Fatalf("recovered owner is not the first PRIMARY tuple: %q", host.Contract.Completion.FinalResponse) + } + + ordinary, err := server.handleFacade(ctx, "search", localizationRecoveryRequest("search", "text", map[string]any{"query": "formatter"})) + if err != nil || ordinary == nil || ordinary.IsError { + t.Fatalf("ordinary post-recovery search = (%#v, %v)", ordinary, err) + } + if calls != 2 { + t.Fatalf("ordinary post-recovery search did not reach handler: calls=%d", calls) + } +} + +func TestBoundedConclusionRetainsCanonicalEvidenceAndCodingTools(t *testing.T) { + row := captureTestRow("repo/storage/report.go::Reporter.ReportFailure", "repo/storage/report.go") + digest := mergeLocalizationEvidenceDigest([]localizationDigestRow{row}, nil) + completion := newLocalizationBoundedConclusionCompletion(digest) + if completion.State != localizationStateAnswerReady || completion.AllowedToolCalls != 0 || completion.Enforceable { + t.Fatalf("bounded conclusion completion = %#v", completion) + } + if !strings.HasPrefix(completion.FinalResponse, localizationBoundedHeading+"\n") || + !strings.Contains(completion.FinalResponse, row.ID) { + t.Fatalf("bounded conclusion lost canonical evidence or bounded heading: %q", completion.FinalResponse) + } + for _, term := range []string{"bounded localization search is exhausted", "editing", "building", "testing"} { + if !strings.Contains(completion.Instruction, term) { + t.Fatalf("bounded conclusion instruction %q does not mention %q", completion.Instruction, term) + } + } + + state := newLocalizationTerminalState() + state.armForTask(completion, "storage reports fail") + if replay, token := state.authorizeWithToken("search", "symbols", map[string]any{"query": "storage"}); replay == nil || token != 0 { + t.Fatalf("bounded conclusion did not terminate localization navigation: (%#v, %d)", replay, token) + } + for _, facade := range []string{"change", "edit", "refactor"} { + if blocked, token := state.authorizeWithToken(facade, "anything", nil); blocked != nil || token != 0 { + t.Fatalf("bounded conclusion blocked %s workflow: (%#v, %d)", facade, blocked, token) + } + } +} + func TestRecoveryRejectsDigestOnlyTermFromRG2095Incident(t *testing.T) { terminal := newLocalizationTerminalState() task := "--multiline with --replace causes duplicate output when a match spans multiple lines" @@ -171,8 +323,8 @@ func TestRecoveryRejectsDigestOnlyTermFromRG2095Incident(t *testing.T) { } } -func TestRecoveryFailureRestoresOnceAndTerminalizesSameResponse(t *testing.T) { - server := setupPresetServer(t, ToolPolicyConfig{Preset: "core", Mode: "defer"}) +func TestAcceptedRecoveryFailureTerminalizesSameResponseThenContinuesSearch(t *testing.T) { + server := setupHardLocalizationRecoveryServer(t) ctx := WithSessionID(context.Background(), "weak_recovery_failure") terminal := server.localizationFor(ctx) terminal.armForTask(newLocalizationRecoveryCompletion(), "find candidate resolution") @@ -184,34 +336,30 @@ func TestRecoveryFailureRestoresOnceAndTerminalizesSameResponse(t *testing.T) { calls := 0 server.facades.capture(mcpgo.NewTool(searchSpec.Legacy), func(context.Context, mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { calls++ - return nil, errors.New("recovery backend unavailable") + if calls == 1 { + return nil, errors.New("recovery backend unavailable") + } + return mcpgo.NewToolResultText("ordinary search"), nil }) request := localizationRecoveryRequest("search", "symbols", map[string]any{"query": "resolveCandidate"}) first, err := server.handleFacade(ctx, "search", request) if err != nil || first == nil || !first.IsError || calls != 1 { - t.Fatalf("first failed recovery = (%#v, %v), calls=%d", first, err, calls) + t.Fatalf("failed accepted recovery = (%#v, %v), calls=%d", first, err, calls) } - requireLocalizationResultStateEqual(t, terminal, first, localizationStateNeedsRecovery, false, 1) + requireLocalizationResultStateEqual(t, terminal, first, localizationStateAnswerReady, true, 0) - second, err := server.handleFacade(ctx, "search", request) - if err != nil || second == nil || !second.IsError || calls != 2 { - t.Fatalf("second failed recovery = (%#v, %v), calls=%d", second, err, calls) + ordinary, err := server.handleFacade(ctx, "search", request) + if err != nil || ordinary == nil || ordinary.IsError { + t.Fatalf("ordinary post-failure search = (%#v, %v)", ordinary, err) } - requireLocalizationResultStateEqual(t, terminal, second, localizationStateAnswerReady, true, 0) - - third, err := server.handleFacade(ctx, "search", request) - if err != nil { - t.Fatalf("post-exhaustion search returned transport error: %v", err) - } - requireLocalizationTerminalReplay(t, third, "search", "symbols") if calls != 2 { - t.Fatalf("recovery allowance restored more than once: calls=%d", calls) + t.Fatalf("ordinary post-failure search did not reach handler: calls=%d", calls) } } -func TestEnforceableAnswerReadyLocksBeforeHandler(t *testing.T) { - server := setupPresetServer(t, ToolPolicyConfig{Preset: "core", Mode: "defer"}) +func TestEnforceableAnswerReadyContinuesOrdinarySearch(t *testing.T) { + server := setupHardLocalizationRecoveryServer(t) ctx := WithSessionID(context.Background(), "strong_answer_ready_lock") completion := newLocalizationCompletion(true, "") completion.Enforceable = true @@ -230,39 +378,51 @@ func TestEnforceableAnswerReadyLocksBeforeHandler(t *testing.T) { result, err := server.handleFacade(ctx, "search", localizationRecoveryRequest("search", "text", map[string]any{ "query": "resolveCandidate", })) - if err != nil { - t.Fatalf("strong terminal search returned transport error: %v", err) + if err != nil || result == nil || result.IsError { + t.Fatalf("ordinary answer_ready search = (%#v, %v)", result, err) } - requireLocalizationTerminalReplay(t, result, "search", "text") - if calls != 0 { - t.Fatalf("enforceable answer_ready reached handler: calls=%d", calls) + if calls != 1 { + t.Fatalf("ordinary answer_ready search did not reach handler: calls=%d", calls) } } -func TestUnsupportedRecoveryAttemptTerminatesBeforeSchemaDispatch(t *testing.T) { - server := setupPresetServer(t, ToolPolicyConfig{Preset: "core", Mode: "defer"}) +func TestUnsupportedRecoveryAttemptTerminalizesSameResponseThenContinuesSearch(t *testing.T) { + server := setupHardLocalizationRecoveryServer(t) ctx := WithSessionID(context.Background(), "unsupported_recovery") - server.localizationFor(ctx).armForTask(newLocalizationRecoveryCompletion(), "find candidate resolution") + terminal := server.localizationFor(ctx) + terminal.armForTask(newLocalizationRecoveryCompletion(), "find candidate resolution") + + searchSpec, ok := server.facades.operation("search", "text") + if !ok { + t.Fatal("search.text facade operation is missing") + } + calls := 0 + server.facades.capture(mcpgo.NewTool(searchSpec.Legacy), func(context.Context, mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + calls++ + return mcpgo.NewToolResultText("ordinary search"), nil + }) - result, err := server.handleFacade(ctx, "search", localizationRecoveryRequest("search", "not_an_operation", map[string]any{ + invalid, err := server.handleFacade(ctx, "search", localizationRecoveryRequest("search", "not_an_operation", map[string]any{ "query": "resolveCandidate", })) - if err != nil || result == nil || !result.IsError { - t.Fatalf("unsupported recovery = (%#v, %v), want terminal tool error", result, err) + if err != nil || invalid == nil || !invalid.IsError { + t.Fatalf("unsupported recovery = (%#v, %v), want terminal tool error", invalid, err) + } + requireLocalizationTerminalError(t, invalid, "search", "not_an_operation") + if calls != 0 { + t.Fatalf("unsupported recovery reached handler: calls=%d", calls) } - requireLocalizationTerminalError(t, result, "search", "not_an_operation") - valid, err := server.handleFacade(ctx, "search", localizationRecoveryRequest("search", "text", map[string]any{ + ordinary, err := server.handleFacade(ctx, "search", localizationRecoveryRequest("search", "text", map[string]any{ "query": "resolveCandidate", })) - if err != nil { - t.Fatalf("post-rejection recovery returned transport error: %v", err) + if err != nil || ordinary == nil || ordinary.IsError || calls != 1 { + t.Fatalf("ordinary post-recovery search = (%#v, %v), calls=%d", ordinary, err, calls) } - requireLocalizationTerminalReplay(t, valid, "search", "text") } -func TestSchemaInvalidAllowedRecoveryTerminatesBeforeHandler(t *testing.T) { - server := setupPresetServer(t, ToolPolicyConfig{Preset: "core", Mode: "defer"}) +func TestSchemaInvalidAllowedRecoveryTerminalizesSameResponseThenContinuesSearch(t *testing.T) { + server := setupHardLocalizationRecoveryServer(t) ctx := WithSessionID(context.Background(), "schema_invalid_recovery") terminal := server.localizationFor(ctx) terminal.armForTask(newLocalizationRecoveryCompletion(), "find candidate resolution") @@ -288,15 +448,14 @@ func TestSchemaInvalidAllowedRecoveryTerminatesBeforeHandler(t *testing.T) { t.Fatalf("schema-invalid recovery reached handler: calls=%d", calls) } - valid, err := server.handleFacade(ctx, "search", localizationRecoveryRequest("search", "text", map[string]any{ + ordinary, err := server.handleFacade(ctx, "search", localizationRecoveryRequest("search", "text", map[string]any{ "query": "resolveCandidate", })) - if err != nil { - t.Fatalf("post-invalid recovery returned transport error: %v", err) + if err != nil || ordinary == nil || ordinary.IsError { + t.Fatalf("ordinary post-invalid-recovery search = (%#v, %v)", ordinary, err) } - requireLocalizationTerminalReplay(t, valid, "search", "text") - if calls != 0 { - t.Fatalf("recovery allowance survived invalid schema: calls=%d", calls) + if calls != 1 { + t.Fatalf("ordinary post-invalid-recovery search did not reach handler: calls=%d", calls) } } @@ -411,7 +570,7 @@ func TestRecoveryEvidenceRejectsGenericCallableWithSignatureOnlyOverlap(t *testi } } -func TestRecoveryWeakResultPreservesAllowanceForEveryOperation(t *testing.T) { +func TestAcceptedWeakRecoveryTerminalizesEveryOperation(t *testing.T) { longSink := localizationDigestRow{ ID: "repo/storage/report.go::Reporter.ReportStorageFailureAsPending", Name: "ReportStorageFailureAsPending", @@ -420,32 +579,23 @@ func TestRecoveryWeakResultPreservesAllowanceForEveryOperation(t *testing.T) { File: "repo/storage/report.go", Signature: "storage writes stall during commit", } - implementation := localizationDigestRow{ - ID: "repo/storage/flush.go::Storage.Flush", - Name: "StorageFlush", - QualName: "Storage.Flush", - Kind: "method", - File: "repo/storage/flush.go", - } tests := []struct { - name string - facade string - operation string - arguments map[string]any - retryArguments map[string]any + name string + facade string + operation string + arguments map[string]any }{ { name: "text search", facade: "search", operation: "text", - arguments: map[string]any{"query": "storage"}, retryArguments: map[string]any{"query": "storage"}, + arguments: map[string]any{"query": "storage"}, }, { name: "symbol search", facade: "search", operation: "symbols", - arguments: map[string]any{"query": "storage"}, retryArguments: map[string]any{"query": implementation.Name}, + arguments: map[string]any{"query": "storage"}, }, { name: "source read", facade: "read", operation: "source", - arguments: map[string]any{"target": map[string]any{"symbol": longSink.ID}}, - retryArguments: map[string]any{"target": map[string]any{"symbol": implementation.ID}}, + arguments: map[string]any{"target": map[string]any{"symbol": longSink.ID}}, }, } for _, tt := range tests { @@ -455,20 +605,20 @@ func TestRecoveryWeakResultPreservesAllowanceForEveryOperation(t *testing.T) { blocked, token := state.authorizeWithToken(tt.facade, tt.operation, tt.arguments) if blocked != nil || token == 0 { - t.Fatalf("first recovery authorization = (%#v, %d)", blocked, token) + t.Fatalf("recovery authorization = (%#v, %d)", blocked, token) } completion := state.finishReservedReadTokenWithDigest(token, true, []localizationDigestRow{longSink}, true) - if completion.State != localizationStateNeedsRecovery || completion.AllowedToolCalls != 1 { - t.Fatalf("weak recovery completion = %#v, want preserved allowance", completion) + if completion.State != localizationStateAnswerReady || completion.AllowedToolCalls != 0 || completion.Enforceable { + t.Fatalf("weak accepted recovery completion = %#v", completion) } - - blocked, retryToken := state.authorizeWithToken(tt.facade, tt.operation, tt.retryArguments) - if blocked != nil || retryToken == 0 { - t.Fatalf("retry recovery authorization = (%#v, %d)", blocked, retryToken) + if !strings.HasPrefix(completion.FinalResponse, localizationBoundedHeading+"\n") || + completion.digest == nil || len(completion.digest.Evidence) == 0 || completion.digest.Evidence[0].ID != longSink.ID { + t.Fatalf("weak accepted recovery did not retain bounded best evidence: %#v", completion) } - completion = state.finishReservedReadTokenWithDigest(retryToken, true, []localizationDigestRow{implementation}, true) - if completion.State != localizationStateAnswerReady || completion.AllowedToolCalls != 0 { - t.Fatalf("strong recovery completion = %#v, want answer_ready", completion) + + blocked, retryToken := state.authorizeWithToken(tt.facade, tt.operation, tt.arguments) + if blocked == nil || retryToken != 0 { + t.Fatalf("accepted recovery repeated after terminal conclusion: (%#v, %d)", blocked, retryToken) } }) } @@ -589,7 +739,7 @@ func TestPlannedRecoveryReplaceFamilyRegression(t *testing.T) { } } -func TestPlannedRecoveryIsExactRetriableAndFallsBackToGeneric(t *testing.T) { +func TestPlannedRecoveryIsExactRetriableAndConcludesAfterAcceptedWeakResult(t *testing.T) { task := "Printed records are duplicated unexpectedly\ncombining --multiline with --replace while --only-matching spans lines" preferred := "crates/searcher/src/sink.rs::sink_slow_multi_line_only_matching" current := []localizationDigestRow{{ @@ -666,14 +816,16 @@ func TestPlannedRecoveryIsExactRetriableAndFallsBackToGeneric(t *testing.T) { Name: "replace_with_captures", Kind: "method", File: "crates/searcher/src/glue.rs", }} completion = state.finishReservedReadTokenWithDigest(token, true, weak, true) - generic := newLocalizationRecoveryCompletion() - if completion.RequiredAction != generic.RequiredAction || completion.Instruction != generic.Instruction || - len(completion.AllowedOperations) != len(generic.AllowedOperations) { - t.Fatalf("weak planned result did not fall back to generic recovery: %#v", completion) + if completion.State != localizationStateAnswerReady || completion.AllowedToolCalls != 0 || completion.Enforceable || + !strings.HasPrefix(completion.FinalResponse, localizationBoundedHeading+"\n") { + t.Fatalf("weak planned result did not reach a bounded conclusion: %#v", completion) + } + if completion.digest == nil || len(completion.digest.Evidence) == 0 || completion.digest.Evidence[0].ID != weak[0].ID { + t.Fatalf("weak planned result lost accepted evidence: %#v", completion.digest) } } -func TestPlannedRecoveryEmptyResultFallsBackToGeneric(t *testing.T) { +func TestPlannedRecoveryEmptyResultTerminalizesUnconfirmed(t *testing.T) { state := newLocalizationTerminalState() state.armForTask(newLocalizationPlannedRecoveryCompletion("search.symbols", "transform_with"), "--multi-line with --transform duplicates output") blocked, token := state.authorizeWithToken("search", "symbols", map[string]any{"query": "transform_with"}) @@ -681,10 +833,12 @@ func TestPlannedRecoveryEmptyResultFallsBackToGeneric(t *testing.T) { t.Fatalf("planned recovery authorization = (%#v, %d)", blocked, token) } completion := state.finishReservedReadTokenWithDigest(token, true, nil, true) - generic := newLocalizationRecoveryCompletion() - if completion.State != localizationStateNeedsRecovery || completion.RequiredAction != generic.RequiredAction || - completion.Instruction != generic.Instruction || len(completion.AllowedOperations) != len(generic.AllowedOperations) { - t.Fatalf("empty planned result did not fall back to generic recovery: %#v", completion) + if completion.State != localizationStateAnswerReady || completion.AllowedToolCalls != 0 || completion.Enforceable || + !strings.HasPrefix(completion.FinalResponse, localizationBoundedHeading+"\n") { + t.Fatalf("empty planned recovery did not reach an bounded terminal conclusion: %#v", completion) + } + if blocked, retryToken := state.authorizeWithToken("search", "symbols", map[string]any{"query": "transform_with"}); blocked == nil || retryToken != 0 { + t.Fatalf("empty accepted recovery preserved a retry: (%#v, %d)", blocked, retryToken) } } diff --git a/internal/mcp/localization_terminal.go b/internal/mcp/localization_terminal.go index e6fb78af3..43bbb1b82 100644 --- a/internal/mcp/localization_terminal.go +++ b/internal/mcp/localization_terminal.go @@ -94,8 +94,9 @@ type localizationRefinementRoute struct { } // localizationTerminalContract is the single wire shape used in visible MCP -// payloads and authoritative host-only metadata. Hosts must treat _meta as the -// authority; the visible copy remains useful to agents and legacy harnesses. +// payloads and authenticated host metadata. The metadata lets a host correlate +// advisory context with the server response; it is never a permission boundary. +// The visible copy remains useful to agents and legacy harnesses. type localizationTerminalContract struct { Completion localizationCompletion `json:"completion"` Terminal bool `json:"terminal"` @@ -151,7 +152,7 @@ func newLocalizationPlannedRecoveryCompletion(operation, anchor string) localiza // completion.instruction carries the same obligation as one that reads the // rendered page. required_action stays "respond" — the terminal gate matches // that exact value. -const localizationAnswerReadyInstruction = "Answer now from completion.final_response, naming the files and symbols you rely on. If its evidence does not fit the request, say so and name what does. Either way, do not call another tool." +const localizationAnswerReadyInstruction = "Localization is complete: the bounded search examined the supplied anchors, and completion.final_response is the full retained result. For a localization-only request, answer now from completion.final_response using compact FILES, SYMBOLS, and EVIDENCE sections, preserving each PRIMARY tuple once in EVIDENCE order. Do not call another tool just to gain confidence, repeat, or cross-check this localization: PRIMARY rows are the best-supported answer, and SUPPORTING rows are context, not a signal that the search is unfinished. On a broader coding task, this closes only localization; if the user's request includes diagnosis, implementation, or verification, continue normally, and all tools remain available, including editing, building, testing, and verification." func newLocalizationCompletion(answerReady bool, exactSymbol string) localizationCompletion { if answerReady { @@ -167,6 +168,48 @@ func newLocalizationCompletion(answerReady bool, exactSymbol string) localizatio return newLocalizationExactReadCompletion(exactSymbol, false) } +const localizationBoundedConclusionInstruction = "The bounded localization search is exhausted; completion.final_response contains the full retained task-supported result. For a localization-only request, answer now from completion.final_response using compact FILES, SYMBOLS, and EVIDENCE sections, preserving each PRIMARY tuple once in EVIDENCE order. Do not call another tool just to gain confidence, repeat, or cross-check this localization: PRIMARY rows are the best-supported answer, and SUPPORTING rows are context, not a signal that the search is unfinished. On a broader coding task, this closes only localization; if the user's request includes diagnosis, implementation, or verification, continue normally, and all tools remain available, including editing, building, testing, and verification." + +const localizationBoundedConclusionDirective = "The bounded localization search is exhausted; this is the full retained result supported by the indexed task evidence. For a localization-only request, answer now using compact FILES, SYMBOLS, and EVIDENCE sections, preserving each PRIMARY tuple once in EVIDENCE order. Do not call another tool just to gain confidence, repeat, or cross-check this localization: PRIMARY rows are the best-supported answer, and SUPPORTING rows are context, not a signal that the search is unfinished. On a broader coding task, this closes only localization; if the user's request includes diagnosis, implementation, or verification, continue normally, and all tools remain available, including editing, building, testing, and verification." + +// newLocalizationBoundedConclusionCompletion terminalizes localization +// navigation without promoting bounded best-effort evidence to a confirmed +// answer. The digest clone preserves canonical row order and leaves the caller +// free to continue diagnosis, implementation, and verification. +func newLocalizationBoundedConclusionCompletion(digest *localizationEvidenceDigest) localizationCompletion { + boundedDigest := &localizationEvidenceDigest{} + if digest != nil { + *boundedDigest = *digest + } + response := boundedDigest.finalResponse + if response == "" { + response = renderLocalizationFinalResponse(boundedDigest.Evidence) + } + response = strings.TrimSpace(response) + switch { + case strings.HasPrefix(response, localizationAnswerHeading): + response = localizationBoundedHeading + strings.TrimPrefix(response, localizationAnswerHeading) + case strings.HasPrefix(response, localizationProvisionalHeading): + response = localizationBoundedHeading + strings.TrimPrefix(response, localizationProvisionalHeading) + case !strings.HasPrefix(response, localizationBoundedHeading): + response = localizationBoundedHeading + "\n" + response + } + for _, directive := range []string{localizationAnswerReadyDirective, localizationProvisionalDirective, localizationBoundedConclusionDirective} { + if strings.HasSuffix(response, directive) { + response = strings.TrimSpace(strings.TrimSuffix(response, directive)) + } + } + response += "\n\n" + localizationBoundedConclusionDirective + boundedDigest.finalResponse = response + boundedDigest.provisionalResponse = response + + completion := newLocalizationCompletion(true, "") + completion.Instruction = localizationBoundedConclusionInstruction + completion.FinalResponse = response + completion.digest = boundedDigest + return localizationCompletionWithDigest(completion, boundedDigest) +} + // newLocalizationSingleResultCompletion reports that localization has enough // unique implementation evidence to continue the task without arming terminal // state. The model gets a strong completeness cue, while every coding and @@ -534,6 +577,18 @@ func (s *localizationTerminalState) completionLocked() localizationCompletion { return localizationCompletionWithDigest(completion, s.digest) } +// answerReady reports whether the bounded localization transaction has already +// produced its retained result. Facade dispatch uses this to let later coding +// navigation execute, while an identical explore.localize request still replays. +func (s *localizationTerminalState) answerReady() bool { + if s == nil { + return false + } + s.mu.Lock() + defer s.mu.Unlock() + return s.state == localizationStateAnswerReady +} + // interceptAnswerReady is the cheap pre-validation gate used by facade // dispatch. It makes localization terminality independent of operation // validity, and consumes an unsupported advisory recovery attempt before a @@ -1354,8 +1409,8 @@ func localizationTaskFingerprint(task string) string { // authorize checks a navigation call and reserves the single permitted // localization read when applicable. The caller must finish the reservation -// after invocation so a failed read restores the allowance instead of silently -// consuming it. +// after invocation so the state can restore a prescribed refinement failure or +// terminalize one accepted recovery without silently consuming either event. func (s *localizationTerminalState) authorize(facade, operation string, arguments map[string]any) (*mcpgo.CallToolResult, bool) { blocked, token := s.authorizeWithToken(facade, operation, arguments) return blocked, token != 0 @@ -1374,9 +1429,9 @@ func (s *localizationTerminalState) authorizeWithToken(facade, operation string, if s.state == localizationStateInactive { return nil, 0 } - // answer_ready terminates only localization navigation. Catch those facades - // before their handlers can run and return a compact typed instruction; - // unrelated work remains dispatchable through the early return above. + // The state-level authorizer retains deterministic replay for callers that + // explicitly choose bounded localization semantics. Facade dispatch bypasses + // this branch for answer_ready coding continuations. if s.state == localizationStateAnswerReady { return localizationTerminalResult(s.completionLocked(), facade, operation), 0 } @@ -1507,18 +1562,20 @@ func (s *localizationTerminalState) finishReservedReadTokenInternal( if capturedResult { mergedDigest = mergeLocalizationEvidenceDigestForTask(s.taskFingerprint, currentEvidence, s.digest) } + terminalDigest := mergedDigest if captureRequired && zeroResult { // An explicitly recorded empty typed page is a bounded zero-result, not - // evidence that can safely terminalize the session. A synthetic handler - // with no capture record keeps the legacy transition contract. + // evidence that can confirm localization. Mark it unsuccessful so an + // accepted recovery publishes the terminal unconfirmed conclusion. A + // synthetic handler with no capture record keeps the legacy contract. success = false } defer func() { - if !captureRequired || s.state != localizationStateAnswerReady { + if s.state != localizationStateAnswerReady { return } - if capturedResult { - s.digest = mergedDigest + if terminalDigest != nil { + s.digest = terminalDigest } // A recorded empty page or handler failure carries no new evidence, but // the retained digest still belongs to this token's generation and task. @@ -1530,38 +1587,25 @@ func (s *localizationTerminalState) finishReservedReadTokenInternal( case localizationStateRecoveryInFlight: requested := s.inFlightRecoveryAnchor operation := s.inFlightRecoveryOperation - plannedRecovery := s.localizationRecoveryPlannedLocked() s.inFlightRecoveryAnchor = "" s.inFlightRecoveryOperation = "" legacyRecovery := !captureRequired || (wireSuccess && !evidenceRecorded) || (operation == "" && strings.TrimSpace(s.taskFingerprint) == "") - confidentRecovery := legacyRecovery || (capturedResult && mergedDigest != nil && len(mergedDigest.Evidence) > 0 && - localizationRecoveryEvidenceAlignedWithLead(s.taskFingerprint, s.taskLead, requested, operation, currentEvidence)) - if success && confidentRecovery { - s.recoveryOperation = "" - s.recoveryAnchor = "" - s.state = localizationStateAnswerReady - s.recoveryRetriesRemaining = 0 - return s.completionLocked() - } - if success || (plannedRecovery && zeroResult) { - // A successful but structurally weak page is not an accepted recovery. - // A planned weak or empty page clears only the plan and returns to the - // byte-identical generic recovery contract with its allowance intact. - if plannedRecovery { - s.recoveryOperation = "" - s.recoveryAnchor = "" + confirmedRecovery := success && (legacyRecovery || + (capturedResult && mergedDigest != nil && len(mergedDigest.Evidence) > 0 && + localizationRecoveryEvidenceAlignedWithLead(s.taskFingerprint, s.taskLead, requested, operation, currentEvidence))) + if !confirmedRecovery { + // Authorization accepted this one bounded recovery. Weak, empty, and + // failed pages therefore conclude localization from the best retained + // evidence instead of reproducing the same needs_recovery contract. + if terminalDigest == nil { + terminalDigest = s.digest } - s.state = localizationStateNeedsRecovery - return s.completionLocked() - } - if s.recoveryRetriesRemaining > 0 { - s.recoveryRetriesRemaining-- - s.state = localizationStateNeedsRecovery - return s.completionLocked() + terminalDigest = newLocalizationBoundedConclusionCompletion(terminalDigest).digest } s.recoveryOperation = "" s.recoveryAnchor = "" + s.recoveryRetriesRemaining = 0 s.state = localizationStateAnswerReady return s.completionLocked() case localizationStateExactReadInFlight: diff --git a/internal/mcp/localization_terminal_evidence_v8_test.go b/internal/mcp/localization_terminal_evidence_v8_test.go index e209c0ce2..a599d29de 100644 --- a/internal/mcp/localization_terminal_evidence_v8_test.go +++ b/internal/mcp/localization_terminal_evidence_v8_test.go @@ -192,9 +192,9 @@ func TestLocalizationConceptComplementSurvivesTightAlignedPacking(t *testing.T) targets[1].node.Name = "match_ignore_rules" targets[1].node.QualName = "PathWalker.match_ignore_rules" targets[1].conceptImplementation = true - targets[2].node.Name = "check_hidden" - targets[2].node.QualName = "HiddenMatcher.check_hidden" - targets[2].conceptComplement = true + targets[8].node.Name = "check_hidden" + targets[8].node.QualName = "HiddenMatcher.check_hidden" + targets[8].conceptComplement = true targets[0].callees = []*graph.Node{ localizationV8Node("repo/relation.go::match_ignore_relation", "match_ignore_relation", "repo/relation.go"), } @@ -220,7 +220,7 @@ func TestLocalizationConceptComplementSurvivesTightAlignedPacking(t *testing.T) if err := json.Unmarshal([]byte(text), &envelope); err != nil { t.Fatalf("decode complement envelope: %v", err) } - wantHead := []string{targets[0].node.ID, targets[1].node.ID, targets[2].node.ID} + wantHead := []string{targets[0].node.ID, targets[1].node.ID, targets[8].node.ID} if len(envelope.Evidence) < len(wantHead) { t.Fatalf("packed rows = %d, want at least %d", len(envelope.Evidence), len(wantHead)) } @@ -250,19 +250,27 @@ func TestRecoveryAllowsInferredConcreteIdentifierOnlyForScopedSymbolEvidence(t * t.Fatalf("inferred exact identifier authorization = (%#v, %d)", blocked, firstToken) } zero := state.finishReservedReadTokenWithDigest(firstToken, true, nil, true) - if zero.State != localizationStateNeedsRecovery || zero.AllowedToolCalls != 1 || zero.digest != retained { - t.Fatalf("empty typed page consumed recovery: %#v", zero) + if zero.State != localizationStateAnswerReady || zero.AllowedToolCalls != 0 || zero.digest == nil || + len(zero.digest.Evidence) == 0 || zero.digest.Evidence[0].ID != retained.Evidence[0].ID { + t.Fatalf("empty typed page did not conclude with retained evidence: %#v", zero) + } + if blocked, retryToken := state.authorizeWithToken("search", "symbols", args); blocked == nil || retryToken != 0 { + t.Fatalf("empty accepted symbol recovery preserved a retry: (%#v, %d)", blocked, retryToken) } - blocked, secondToken := state.authorizeWithToken("search", "symbols", args) - if blocked != nil || secondToken == 0 || secondToken == firstToken { - t.Fatalf("restored exact identifier authorization = (%#v, %d)", blocked, secondToken) + capturedState := newLocalizationTerminalState() + capturedCompletion := newLocalizationRecoveryCompletion() + capturedCompletion.digest = retained + capturedState.armForTask(capturedCompletion, "investigate registry configuration behavior") + blocked, capturedToken := capturedState.authorizeWithToken("search", "symbols", args) + if blocked != nil || capturedToken == 0 { + t.Fatalf("captured identifier authorization = (%#v, %d)", blocked, capturedToken) } - captureCtx := withLocalizationPermittedEvidenceCapture(context.Background(), secondToken) + captureCtx := withLocalizationPermittedEvidenceCapture(context.Background(), capturedToken) node := localizationV8Node("repo/policy.go::DerivedPolicy.ApplyRule", "ApplyRule", "repo/policy.go") captureLocalizationSearchSymbols(captureCtx, []*graph.Node{node}) - rows, recorded := localizationEvidenceForPermittedCall(captureCtx, "search", "symbols", secondToken) - ready := state.finishReservedReadTokenWithDigest(secondToken, true, rows, recorded) + rows, recorded := localizationEvidenceForPermittedCall(captureCtx, "search", "symbols", capturedToken) + ready := capturedState.finishReservedReadTokenWithDigest(capturedToken, true, rows, recorded) if ready.State != localizationStateAnswerReady || ready.digest == nil || ready.digest.Evidence[0].ID != node.ID { t.Fatalf("nonempty typed symbol page did not terminalize current-first: %#v", ready) } @@ -447,7 +455,7 @@ func TestFacadeLocalizedCompletionStripsAuthWithoutPublishingReceipt(t *testing. server.localizationFor(ctx).armForTask(completion, req.GetString("task", "")) return result, nil }) - server = &Server{facades: registry, localization: newLocalizationTerminalState(), sessions: newSessionMap()} + server = newStrictLocalizationFacadeTestServer(registry) token, ok := localizationauth.NewToken() if !ok { @@ -508,7 +516,7 @@ func TestFacadeAuthArgumentIsStrippedAndPublishesTypedTerminalReceipts(t *testin server.localizationFor(ctx).armForTask(completion, req.GetString("task", "")) return localizationAnswerReadyResult(completion), nil }) - server = &Server{facades: registry, localization: newLocalizationTerminalState(), sessions: newSessionMap()} + server = newStrictLocalizationFacadeTestServer(registry) newToken := func(t *testing.T) string { t.Helper() @@ -557,14 +565,18 @@ func TestFacadeAuthArgumentIsStrippedAndPublishesTypedTerminalReceipts(t *testin directReceipt := requireReceipt(t, directToken, direct) replayToken := newToken(t) - replayArgs := map[string]any{"operation": "symbols", "query": "AnyIdentifier", localizationauth.ArgumentKey: replayToken} - replay, err := server.handleFacade(ctx, "search", mcpgo.CallToolRequest{Params: mcpgo.CallToolParams{Name: "search", Arguments: replayArgs}}) + replayArgs := map[string]any{ + "operation": "localize", + "task": "locate registry configuration", + localizationauth.ArgumentKey: replayToken, + } + replay, err := server.handleFacade(ctx, "explore", mcpgo.CallToolRequest{Params: mcpgo.CallToolParams{Name: "explore", Arguments: replayArgs}}) if err != nil || replay == nil || replay.IsError { - t.Fatalf("authenticated replay = (%#v, %v)", replay, err) + t.Fatalf("authenticated explicit-localize replay = (%#v, %v)", replay, err) } replayReceipt := requireReceipt(t, replayToken, replay) if replayReceipt.FinalResponse != directReceipt.FinalResponse { - t.Fatal("authenticated replay receipt diverged from direct answer_ready") + t.Fatal("authenticated explicit-localize replay receipt diverged from direct answer_ready") } errorCtx := WithSessionID(context.Background(), "auth_terminal_error") diff --git a/internal/mcp/localization_terminal_test.go b/internal/mcp/localization_terminal_test.go index 9358994e3..55da20b3f 100644 --- a/internal/mcp/localization_terminal_test.go +++ b/internal/mcp/localization_terminal_test.go @@ -12,6 +12,16 @@ import ( "github.com/zzet/gortex/internal/graph" ) +func newStrictLocalizationFacadeTestServer(registry *facadeRegistry) *Server { + return &Server{ + facades: registry, + localization: newLocalizationTerminalState(), + sessions: newSessionMap(), + toolPolicy: &toolPolicy{preset: "localization"}, + toolPolicyOperatorPinned: true, + } +} + func TestHandleFacadeRejectsLocalizationBypassesWithoutClearingState(t *testing.T) { registry := newFacadeRegistry() calls := 0 @@ -19,19 +29,20 @@ func TestHandleFacadeRejectsLocalizationBypassesWithoutClearingState(t *testing. calls++ return mcpgo.NewToolResultText("unexpected"), nil }) - server := &Server{facades: registry, localization: &localizationTerminalState{}} + server := newStrictLocalizationFacadeTestServer(registry) 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 string + args map[string]any + wantReplay bool }{ {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: "empty localize", args: map[string]any{"operation": "localize", "task": ""}, wantReplay: true}, + {name: "nested localize task", args: map[string]any{"operation": "localize", "options": map[string]any{"task": "Locate Bar"}}, wantReplay: true}, } for _, request := range requests { t.Run(request.name, func(t *testing.T) { @@ -43,9 +54,13 @@ func TestHandleFacadeRejectsLocalizationBypassesWithoutClearingState(t *testing. t.Fatalf("handleFacade() transport error = %v", err) } operation, _ := request.args["operation"].(string) - requireLocalizationTerminalReplay(t, result, "explore", normalizeFacadeOperation(operation)) - if blocked := terminal.block("search", "symbols", nil); blocked == nil { - t.Fatal("invalid localization request cleared terminal state") + if request.wantReplay { + requireLocalizationTerminalReplay(t, result, "explore", normalizeFacadeOperation(operation)) + } else if result == nil || !result.IsError { + t.Fatalf("invalid localization bypass = %#v, want tool error", result) + } + if !terminal.answerReady() { + t.Fatal("localization request cleared retained answer-ready state") } }) } @@ -61,7 +76,7 @@ func TestHandleFacadeValidatesMalformedExplicitNewUserBoundary(t *testing.T) { calls++ return mcpgo.NewToolResultText("unexpected"), nil }) - server := &Server{facades: registry, localization: &localizationTerminalState{}} + server := newStrictLocalizationFacadeTestServer(registry) ctx := WithSessionID(context.Background(), "malformed-new-task-boundary") terminal := server.localizationFor(ctx) terminal.armForTask(newLocalizationCompletion(true, ""), "Locate Foo") @@ -427,7 +442,7 @@ func TestHandleFacadeRefinementReadReturnsAnswerReadyCompletion(t *testing.T) { analyzeCalls++ return mcpgo.NewToolResultText(`{"result":"unexpected"}`), nil }) - server := &Server{facades: registry, localization: newLocalizationTerminalState(), sessions: newSessionMap()} + server := newStrictLocalizationFacadeTestServer(registry) ctx := WithSessionID(context.Background(), "refinement-read-completion") server.localizationFor(ctx).armRefinementRoutesForTask( "locate resolver behavior", @@ -479,13 +494,16 @@ func TestHandleFacadeRefinementReadReturnsAnswerReadyCompletion(t *testing.T) { "kind": "why", "target": map[string]any{"symbol": candidate}, } - blockedAnalyze, err := server.handleFacade(ctx, "analyze", analyzeReq) - if err != nil { - t.Fatalf("terminal analyze returned transport error: %v", err) + analyzeResult, err := server.handleFacade(ctx, "analyze", analyzeReq) + if err != nil || analyzeResult == nil || analyzeResult.IsError { + t.Fatalf("post-localization analyze failed: result=%#v err=%v", analyzeResult, err) + } + analyzeBody, ok := singleTextContent(analyzeResult) + if !ok || analyzeBody != `{"result":"unexpected"}` { + t.Fatalf("post-localization analyze payload = %q, want dispatched legacy result", analyzeBody) } - requireLocalizationTerminalReplay(t, blockedAnalyze, "analyze", "why") - if analyzeCalls != 0 { - t.Fatalf("terminal analyze reached its legacy handler %d time(s)", analyzeCalls) + if analyzeCalls != 1 { + t.Fatalf("post-localization analyze legacy calls = %d, want 1", analyzeCalls) } } @@ -517,18 +535,14 @@ func TestLocalizationTerminalStateIsPerSession(t *testing.T) { } } -func TestHandleFacadeTaskCannotEscapeTerminalState(t *testing.T) { +func TestHandleFacadeTaskDispatchesAfterLocalizationAnswerReady(t *testing.T) { registry := newFacadeRegistry() - called := false + calls := 0 registry.capture(mcpgo.NewTool("explore"), func(context.Context, mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { - called = true - return mcpgo.NewToolResultText("unexpected"), nil + calls++ + return mcpgo.NewToolResultText("diagnostic neighborhood"), nil }) - server := &Server{ - facades: registry, - localization: newLocalizationTerminalState(), - sessions: newSessionMap(), - } + server := newStrictLocalizationFacadeTestServer(registry) ctx := WithSessionID(context.Background(), "diagnosis") terminal := server.localizationFor(ctx) terminal.armForTask(newLocalizationCompletion(true, ""), "locate the failing writer") @@ -540,16 +554,28 @@ func TestHandleFacadeTaskCannotEscapeTerminalState(t *testing.T) { req.Params.Name = "explore" req.Params.Arguments = map[string]any{"operation": "task", "task": task} result, err := server.handleFacade(ctx, "explore", req) - if err != nil { - t.Fatalf("ordinary task escaped terminal state: result=%#v err=%v", result, err) + if err != nil || result == nil || result.IsError { + t.Fatalf("ordinary task failed after localization: result=%#v err=%v", result, err) + } + body, ok := singleTextContent(result) + if !ok || body != "diagnostic neighborhood" { + t.Fatalf("ordinary task payload = %q, want dispatched legacy result", body) } - requireLocalizationTerminalReplay(t, result, "explore", "task") } - if called { - t.Fatal("ordinary explore(task) dispatched after localization completed") + if calls != 2 { + t.Fatalf("ordinary explore(task) legacy calls = %d, want 2", calls) } - if blocked := terminal.block("search", "symbols", nil); blocked == nil { - t.Fatal("ordinary explore(task) cleared terminal state") + + repeat := mcpgo.CallToolRequest{} + repeat.Params.Name = "explore" + repeat.Params.Arguments = map[string]any{"operation": "localize", "task": "locate the failing writer"} + replay, err := server.handleFacade(ctx, "explore", repeat) + if err != nil { + t.Fatalf("repeated localization returned transport error: %v", err) + } + requireLocalizationTerminalReplay(t, replay, "explore", "localize") + if calls != 2 { + t.Fatalf("repeated localization reached legacy handler: calls=%d, want 2", calls) } } @@ -563,11 +589,7 @@ func TestHandleFacadeTaskRunsWhenTerminalInactive(t *testing.T) { } return mcpgo.NewToolResultText("ordinary diagnostic neighborhood"), nil }) - server := &Server{ - facades: registry, - localization: newLocalizationTerminalState(), - sessions: newSessionMap(), - } + server := newStrictLocalizationFacadeTestServer(registry) ctx := WithSessionID(context.Background(), "inactive-diagnosis") req := mcpgo.CallToolRequest{} req.Params.Name = "explore" @@ -591,7 +613,7 @@ func TestHandleFacadeExplicitNewUserTaskCrossesTerminalBoundary(t *testing.T) { } return mcpgo.NewToolResultText("diagnostic neighborhood"), nil }) - server := &Server{facades: registry, localization: newLocalizationTerminalState(), sessions: newSessionMap()} + server := newStrictLocalizationFacadeTestServer(registry) ctx := WithSessionID(context.Background(), "explicit-task-boundary") terminal := server.localizationFor(ctx) prior := newLocalizationCompletion(true, "") @@ -639,7 +661,7 @@ func TestHandleFacadeExactReadCommitsOnlyOnSuccess(t *testing.T) { } return mcpgo.NewToolResultText("func Run() {}"), nil }) - server := &Server{facades: registry, localization: newLocalizationTerminalState(), sessions: newSessionMap()} + server := newStrictLocalizationFacadeTestServer(registry) ctx := WithSessionID(context.Background(), "exact-read") exactCompletion := newLocalizationCompletion(false, "repo/pkg/file.go::Run") exactCompletion.enforceableOnAnswerReady = true @@ -668,10 +690,13 @@ func TestHandleFacadeExactReadCommitsOnlyOnSuccess(t *testing.T) { t.Fatalf("bounded exact-read recovery = result=%#v err=%v calls=%d", third, err, calls) } fourth, err := server.handleFacade(ctx, "read", req) - if err != nil || calls != 3 { - t.Fatalf("post-recovery exact read = result=%#v err=%v calls=%d", fourth, err, calls) + if err != nil || fourth == nil || fourth.IsError || calls != 4 { + t.Fatalf("post-recovery exact read = result=%#v err=%v calls=%d, want dispatched success", fourth, err, calls) + } + fourthBody, ok := singleTextContent(fourth) + if !ok || fourthBody != "func Run() {}" { + t.Fatalf("post-recovery exact read payload = %q, want dispatched legacy result", fourthBody) } - requireLocalizationTerminalReplay(t, fourth, "read", "source") } func TestHandleFacadeExhaustedCorrectionFailureCarriesTerminalCompletion(t *testing.T) { @@ -689,7 +714,7 @@ func TestHandleFacadeExhaustedCorrectionFailureCarriesTerminalCompletion(t *test } return nil, errors.New("persistent correction failure") }) - server := &Server{facades: registry, localization: newLocalizationTerminalState(), sessions: newSessionMap()} + server := newStrictLocalizationFacadeTestServer(registry) ctx := WithSessionID(context.Background(), "exhausted-correction") server.localizationFor(ctx).armRefinementRoutesForTask( "locate resolver behavior", @@ -738,10 +763,12 @@ func TestHandleFacadeExhaustedCorrectionFailureCarriesTerminalCompletion(t *test t.Fatalf("exhausted failure omitted terminal completion: %q", thirdBody) } requireFacadeIdentity(t, third, facadeOperationSpec{Facade: "read", Operation: "source", Legacy: "get_symbol_source"}) - if blocked, err := read(alternate); err != nil { - t.Fatalf("post-terminal read returned transport error: %v", err) - } else { - requireLocalizationTerminalReplay(t, blocked, "read", "source") + postTerminal, err := read(alternate) + if err == nil || !strings.Contains(err.Error(), "persistent correction failure") { + t.Fatalf("post-terminal read = (%#v, %v), want dispatched legacy error", postTerminal, err) + } + if postTerminal != nil || alternateCalls != 3 { + t.Fatalf("post-terminal read result=%#v calls=%d, want nil result and 3 legacy calls", postTerminal, alternateCalls) } } @@ -801,7 +828,7 @@ func TestHandleFacadeFailedDifferentLocalizePreservesTerminalState(t *testing.T) calls++ return mcpgo.NewToolResultError("localization failed"), nil }) - server := &Server{facades: registry, localization: &localizationTerminalState{}} + server := newStrictLocalizationFacadeTestServer(registry) ctx := WithSessionID(context.Background(), "failed-different-localize") terminal := server.localizationFor(ctx) terminal.armForTask(newLocalizationCompletion(true, ""), "Locate Foo") @@ -836,7 +863,7 @@ func TestHandleFacadeExplicitNewUserTaskCommitsOnSuccess(t *testing.T) { server.localizationFor(ctx).armForTask(newLocalizationCompletion(true, ""), req.GetString("task", "")) return mcpgo.NewToolResultText("localized"), nil }) - server = &Server{facades: registry, localization: newLocalizationTerminalState(), sessions: newSessionMap()} + server = newStrictLocalizationFacadeTestServer(registry) ctx := WithSessionID(context.Background(), "new-user-boundary") terminal := server.localizationFor(ctx) terminal.armForTask(newLocalizationCompletion(true, ""), "Locate Foo") @@ -871,7 +898,7 @@ func TestHandleFacadeNewUserTaskPanicRollsBack(t *testing.T) { registry.capture(mcpgo.NewTool("explore"), func(context.Context, mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { panic("localization panic") }) - server := &Server{facades: registry, localization: newLocalizationTerminalState(), sessions: newSessionMap()} + server := newStrictLocalizationFacadeTestServer(registry) ctx := WithSessionID(context.Background(), "new-user-panic") terminal := server.localizationFor(ctx) terminal.armForTask(newLocalizationCompletion(true, ""), "Locate Foo") @@ -909,7 +936,7 @@ func TestHandleFacadeConcurrentLocalizeAdmitsOnlyOne(t *testing.T) { server.localizationFor(ctx).armForTask(newLocalizationCompletion(true, ""), req.GetString("task", "")) return mcpgo.NewToolResultText("localized"), nil }) - server = &Server{facades: registry, localization: newLocalizationTerminalState(), sessions: newSessionMap()} + server = newStrictLocalizationFacadeTestServer(registry) ctx := WithSessionID(context.Background(), "concurrent-localize") firstReq := mcpgo.CallToolRequest{} firstReq.Params.Name = "explore" @@ -990,7 +1017,7 @@ func TestHandleFacadeExactReadPanicRestoresReservation(t *testing.T) { } return mcpgo.NewToolResultText("source"), nil }) - server := &Server{facades: registry, localization: &localizationTerminalState{}} + server := newStrictLocalizationFacadeTestServer(registry) ctx := WithSessionID(context.Background(), "exact-read-panic") terminal := server.localizationFor(ctx) const symbol = "repo/internal/file.go::Target" @@ -1022,12 +1049,15 @@ func TestHandleFacadeExactReadPanicRestoresReservation(t *testing.T) { t.Fatalf("third exact read recovery = (%v, %v), want success", third, err) } fourth, err := server.handleFacade(ctx, "read", req) - if err != nil { - t.Fatalf("fourth exact read = (%v, %v), want terminal block", fourth, err) + if err != nil || fourth == nil || fourth.IsError { + t.Fatalf("fourth exact read = (%v, %v), want dispatched success", fourth, err) + } + fourthBody, ok := singleTextContent(fourth) + if !ok || fourthBody != "source" { + t.Fatalf("fourth exact read payload = %q, want dispatched legacy result", fourthBody) } - requireLocalizationTerminalReplay(t, fourth, "read", "source") - if calls != 3 { - t.Fatalf("legacy source calls = %d, want 3", calls) + if calls != 4 { + t.Fatalf("legacy source calls = %d, want 4", calls) } } @@ -1038,7 +1068,7 @@ func TestHandleFacadeLocalizeBlocksParaphrasesWithoutBoundary(t *testing.T) { calls++ return mcpgo.NewToolResultText("unexpected"), nil }) - server := &Server{facades: registry, localization: newLocalizationTerminalState(), sessions: newSessionMap()} + server := newStrictLocalizationFacadeTestServer(registry) ctx := WithSessionID(context.Background(), "repeat-localize") terminal := server.localizationFor(ctx) terminal.armForTask(newLocalizationCompletion(true, ""), "Locate Run Handler") diff --git a/internal/mcp/localization_tool_preset_test.go b/internal/mcp/localization_tool_preset_test.go new file mode 100644 index 000000000..0afc47217 --- /dev/null +++ b/internal/mcp/localization_tool_preset_test.go @@ -0,0 +1,30 @@ +package mcp + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/zzet/gortex/internal/profiles" +) + +func TestLocalizationProfilePublishesSixToolHandshake(t *testing.T) { + profile, ok := profiles.ByName("localization") + require.True(t, ok) + require.Equal(t, "localization", profile.ToolPreset) + + wantEager := []string{"explore", "search", "read", "capabilities"} + require.Equal(t, wantEager, profile.EagerTools) + require.Equal(t, wantEager, profiles.LocalizationEagerTools()) + + set, denyMutating, known := builtinToolPresetSet(profile.ToolPreset) + require.True(t, known) + require.False(t, denyMutating) + require.Equal(t, map[string]bool{ + "explore": true, "search": true, "read": true, "capabilities": true, + }, set) + require.True(t, isFacadePreset(profile.ToolPreset), + "localization must use the facade registry or its eager names cannot reach tools/list") + require.True(t, isAlwaysKeptTool("tool_profile")) + require.True(t, isAlwaysKeptTool(LazyToolsSearchName)) +} diff --git a/internal/mcp/overlay.go b/internal/mcp/overlay.go index e41cd2a2b..e4067b29b 100644 --- a/internal/mcp/overlay.go +++ b/internal/mcp/overlay.go @@ -118,8 +118,9 @@ func (s *Server) wrapToolHandlerMode(h mcpserver.ToolHandlerFunc, injectOverlay // Tolerate hallucinated / mistyped parameter names before the // handler reads arguments (e.g. "symbol" accepted as "id"). s.reconcileToolParams(&req) - // Enforce the session's runtime mode / workflow phase — a hard - // gate even if the client never re-read tools/list. + // Enforce the session's ordinary runtime and workflow gates. Localization + // conclusions are evidence, not permission boundaries: unrelated tools + // remain dispatchable in every profile. if blocked := s.checkToolGate(ctx, req.Params.Name); blocked != nil { return blocked, nil } diff --git a/internal/mcp/query_log.go b/internal/mcp/query_log.go index 17a1b94db..63ab88e1e 100644 --- a/internal/mcp/query_log.go +++ b/internal/mcp/query_log.go @@ -217,7 +217,7 @@ 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) { + if p := s.effectiveSessionPolicy(ctx); p != nil && isFacadePreset(p.preset) && isFacadeToolName(tool) { rec.Surface = FacadeSurfaceVersion rec.Facade = tool rec.Operation = normalizeFacadeOperation(stringArg(args, "operation")) diff --git a/internal/mcp/server.go b/internal/mcp/server.go index f9cbfbe04..1f00879f5 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -631,6 +631,13 @@ type sessionState struct { // toolSpec / clientName is (re)recorded. resolvedToolPolicy *toolPolicy toolPolicyResolved bool + // instructionProfilePreset is captured once for this connection. Profile + // switches are documented to affect new sessions only; later client/policy + // handshake updates may re-resolve the surface, but never against a new + // machine profile. + instructionProfilePreset string + instructionProfileResolved bool + toolPolicyGeneration uint64 // lastSearch captures the most recent search_symbols call so that a // subsequent get_symbol_source / get_editing_context on one of its // results can be attributed back to the query — this is the raw input @@ -906,6 +913,7 @@ func (ss *sessionState) recordClientName(name string) { // session's effective tool policy. ss.resolvedToolPolicy = nil ss.toolPolicyResolved = false + ss.toolPolicyGeneration++ } // recordToolPolicy captures the client-forwarded tool-surface preference @@ -921,6 +929,7 @@ func (ss *sessionState) recordToolPolicy(spec, mode string) { ss.toolMode = mode ss.resolvedToolPolicy = nil ss.toolPolicyResolved = false + ss.toolPolicyGeneration++ } // snapshotClientName returns the captured client name under the diff --git a/internal/mcp/tool_presets.go b/internal/mcp/tool_presets.go index d855d0863..1a44ed765 100644 --- a/internal/mcp/tool_presets.go +++ b/internal/mcp/tool_presets.go @@ -505,29 +505,49 @@ func (s *Server) effectiveSessionPolicy(ctx context.Context) *toolPolicy { if sess == nil { return s.toolPolicy } - sess.mu.Lock() - if sess.toolPolicyResolved { - p := sess.resolvedToolPolicy + for { + sess.mu.Lock() + if sess.toolPolicyResolved { + p := sess.resolvedToolPolicy + sess.mu.Unlock() + if p != nil { + return p + } + return s.toolPolicy + } + if !sess.instructionProfileResolved { + sess.instructionProfilePreset = activeInstructionPreset() + sess.instructionProfileResolved = true + } + spec, mode, client := sess.toolSpec, sess.toolMode, sess.clientName + profilePreset := sess.instructionProfilePreset + generation := sess.toolPolicyGeneration sess.mu.Unlock() + + p := s.resolveSessionPolicyForProfile(spec, mode, client, profilePreset) + + sess.mu.Lock() + if generation != sess.toolPolicyGeneration { + sess.mu.Unlock() + continue + } + if sess.toolPolicyResolved { + cached := sess.resolvedToolPolicy + sess.mu.Unlock() + if cached != nil { + return cached + } + return s.toolPolicy + } + sess.resolvedToolPolicy = p + sess.toolPolicyResolved = true + sess.mu.Unlock() + if p != nil { return p } return s.toolPolicy } - spec, mode, client := sess.toolSpec, sess.toolMode, sess.clientName - sess.mu.Unlock() - - p := s.resolveSessionPolicy(spec, mode, client) - - sess.mu.Lock() - sess.resolvedToolPolicy = p - sess.toolPolicyResolved = true - sess.mu.Unlock() - - if p != nil { - return p - } - return s.toolPolicy } // resolveSessionPolicy builds the effective policy from a client-forwarded @@ -536,6 +556,10 @@ func (s *Server) effectiveSessionPolicy(ctx context.Context) *toolPolicy { // the client did not pin one, so a bare `GORTEX_TOOLS=nav` keeps the // daemon's defer semantics instead of silently switching to hide. func (s *Server) resolveSessionPolicy(spec, mode, client string) *toolPolicy { + return s.resolveSessionPolicyForProfile(spec, mode, client, activeInstructionPreset()) +} + +func (s *Server) resolveSessionPolicyForProfile(spec, mode, client, profilePreset string) *toolPolicy { if strings.TrimSpace(spec) != "" { cfg := parseToolSpec(spec) switch { @@ -557,7 +581,7 @@ func (s *Server) resolveSessionPolicy(spec, mode, client string) *toolPolicy { if s.toolPolicyOperatorPinned { return nil } - if p := s.instructionProfilePolicy(); p != nil { + if p := s.instructionProfilePolicyForPreset(profilePreset); p != nil { return p } if p := s.clientDefaultPolicy(client); p != nil { @@ -568,7 +592,7 @@ func (s *Server) resolveSessionPolicy(spec, mode, client string) *toolPolicy { func isFacadePreset(name string) bool { switch strings.ToLower(strings.TrimSpace(name)) { - case FacadeSurfaceVersion, "compact", "facade", "agent-v2": + case FacadeSurfaceVersion, "compact", "facade", "agent-v2", "localization": return true default: return false @@ -588,11 +612,11 @@ var activeInstructionPreset = profiles.ActiveToolPreset // profile carries no preset, so machines that never ran // `gortex instructions switch` resolve exactly as before. func (s *Server) instructionProfilePolicy() *toolPolicy { - if s == nil || s.toolPolicyOperatorPinned { - return nil - } - preset := activeInstructionPreset() - if strings.TrimSpace(preset) == "" { + return s.instructionProfilePolicyForPreset(activeInstructionPreset()) +} + +func (s *Server) instructionProfilePolicyForPreset(preset string) *toolPolicy { + if s == nil || s.toolPolicyOperatorPinned || strings.TrimSpace(preset) == "" { return nil } mode := toolPolicyModeDefer diff --git a/internal/mcp/tools_explore.go b/internal/mcp/tools_explore.go index a9ac21b86..b29da44e1 100644 --- a/internal/mcp/tools_explore.go +++ b/internal/mcp/tools_explore.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "path" "regexp" "sort" "strings" @@ -84,11 +85,15 @@ type exploreTarget struct { callees []*graph.Node directCalleesComplete bool // false when the direct projection was truncated, bounded, or otherwise lower-bound causalCallees []exploreCausalNeighbor + causalChangeBridge bool // one graph-proven caller/callee retained as provenance for a promoted continuation + causalChangeLeaf bool // graph-proven wrapper implementation or task-aligned cross-file change callable + causalChangeOwner bool // same-file type that encloses or is uniquely returned by the causal change callable source string // full body (may be empty for non-source kinds) divergentDefaultOwner bool // unique child constructor whose concrete default causes the queried behavior divergentDefaultType bool // owning type paired with divergentDefaultOwner for coherent file/symbol output conceptImplementation bool // primary identifier-backed callable; may establish answer readiness conceptComplement bool // marginal concept callable protected as evidence, never as terminal proof + syntacticAnchor bool // task-spelled flag/identifier owner protected by bounded lexical competition exactContent bool // verified full quoted-literal hit from content_fts exactContentAmbiguous bool // exact evidence has visible or possibly truncated peers sourceLiteral bool // exact source-body hit that must survive final envelope packing @@ -1408,11 +1413,50 @@ func exploreNormalizedPath(text string) string { } func exploreQueryPathAnchors(query string) ([]string, bool) { - anchors := make([]string, 0, 1) + fields := make([]string, 0, 2) + routeLike := 0 + for _, raw := range strings.Fields(query) { + field := strings.Trim(raw, "`'\"()[]{}<>,;") + // A route is commonly embedded in a call token such as + // r.GET("/base/:id"). Extract the quoted value before classifying it; + // treating the whole call as a source path poisons every later basename + // match in the request. + if quote := strings.IndexAny(field, "`'\""); quote >= 0 { + field = field[quote+1:] + if end := strings.IndexAny(field, "`'\""); end >= 0 { + field = field[:end] + } + field = strings.Trim(field, "`'\"()[]{}<>,;") + } + if !strings.ContainsAny(field, "/\\") || strings.Contains(field, "://") { + continue + } + // A fully qualified Go stack symbol (`github.com/acme/pkg.(*T).M`) is + // a package-qualified callable, not a user-supplied source directory. + // Let a later basename/file:line anchor such as `tree.go:637` match + // instead of making this package token veto every basename. + if strings.Contains(field, "(*") && strings.Contains(field, ").") { + continue + } + fields = append(fields, field) + normalized := strings.ReplaceAll(field, "\\", "/") + if strings.HasPrefix(normalized, "/") { + base := normalized[strings.LastIndex(normalized, "/")+1:] + dot := strings.LastIndex(base, ".") + if dot <= 0 || dot == len(base)-1 { + routeLike++ + } + } + } + + anchors := make([]string, 0, len(fields)) hasDirectory := false - for _, field := range strings.Fields(query) { - field = strings.Trim(field, "`'\"()[]{}<>,;") - if !strings.ContainsAny(field, "/\\") { + for _, field := range fields { + normalized := strings.ReplaceAll(field, "\\", "/") + base := normalized[strings.LastIndex(normalized, "/")+1:] + dot := strings.LastIndex(base, ".") + rootedWithoutExtension := strings.HasPrefix(normalized, "/") && (dot <= 0 || dot == len(base)-1) + if rootedWithoutExtension && (routeLike >= 2 || strings.Contains(normalized, "/:") || strings.ContainsAny(normalized, "{}")) { continue } hasDirectory = true @@ -1796,9 +1840,322 @@ type exploreConceptImplementationMetric struct { matchedMask uint64 } -const exploreConceptComplementSignal = "explore_concept_complement" +const ( + exploreConceptComplementSignal = "explore_concept_complement" + exploreSyntacticAnchorSignal = "explore_syntactic_anchor" +) // reserveExploreConceptImplementation keeps the strongest identifier-backed +type exploreComponentConjunctionKey struct { + workspaceID string + projectID string + repoPrefix string + directory string +} + +type exploreComponentConjunctionSeed struct { + index int + node *graph.Node + file string + matched []bool +} + +type exploreComponentConjunctionScore struct { + groups int + marginal int + rarity int + longest int +} + +type exploreComponentConjunctionWinner struct { + index int + seedRank int + score exploreComponentConjunctionScore +} + +func exploreComponentConjunctionFile(node *graph.Node) string { + if node == nil { + return "" + } + file := path.Clean(strings.ReplaceAll(strings.TrimSpace(node.FilePath), "\\", "/")) + if file == "" || file == "." || file == "/" || file == ".." || strings.HasPrefix(file, "../") { + return "" + } + return strings.ToLower(file) +} + +func exploreComponentConjunctionComponent(node *graph.Node) (exploreComponentConjunctionKey, bool) { + file := exploreComponentConjunctionFile(node) + if file == "" { + return exploreComponentConjunctionKey{}, false + } + directory := path.Dir(file) + if directory == "" || directory == "." || directory == "/" || directory == ".." || strings.HasPrefix(directory, "../") { + return exploreComponentConjunctionKey{}, false + } + return exploreComponentConjunctionKey{ + workspaceID: strings.ToLower(strings.TrimSpace(node.WorkspaceID)), + projectID: strings.ToLower(strings.TrimSpace(node.ProjectID)), + repoPrefix: strings.ToLower(strings.TrimSpace(node.RepoPrefix)), + directory: directory, + }, true +} + +func exploreComponentConjunctionMatches(node *graph.Node, terms []string) []bool { + matched := make([]bool, len(terms)) + for index, term := range terms { + matched[index] = exploreConceptImplementationHasTerm(node, term) + } + return matched +} + +func exploreComponentConjunctionScoreCompare(left, right exploreComponentConjunctionScore) int { + for _, pair := range [...][2]int{ + {left.groups, right.groups}, + {left.marginal, right.marginal}, + {left.rarity, right.rarity}, + {left.longest, right.longest}, + } { + if pair[0] > pair[1] { + return 1 + } + if pair[0] < pair[1] { + return -1 + } + } + return 0 +} + +// reserveExploreComponentConjunction recovers one callable whose identifier +// completes a task concept already visible in another file of the same exact +// component. It only reorders the bounded retrieval pool: no graph or source +// work is added, the semantic head is fixed, and ambiguous component ties are +// deliberately left untouched. +func reserveExploreComponentConjunction( + query string, + queryClass rerank.QueryClass, + candidates []*rerank.Candidate, + maxSymbols int, +) []*rerank.Candidate { + if queryClass != rerank.QueryClassConcept || maxSymbols < 2 || len(candidates) < 2 { + return candidates + } + width := min(maxSymbols, len(candidates)) + if width < 2 { + return candidates + } + + lowValue := exploreConceptComplementLowValueTermSet(query) + queryTermSet := exploreTerminalTerms(query) + terms := make([]string, 0, len(queryTermSet)) + groups := make(map[string]struct{}, len(queryTermSet)) + for term := range queryTermSet { + if _, low := lowValue[term]; low { + continue + } + terms = append(terms, term) + groups[exploreConceptBehavioralTermGroup(term)] = struct{}{} + } + if len(terms) < 2 || len(groups) < 2 { + return candidates + } + sort.Strings(terms) + + leadTerms := exploreTerminalTerms(exploreConceptIssueLead(query)) + lead := make([]bool, len(terms)) + leadCount := 0 + for index, term := range terms { + if _, present := leadTerms[term]; present { + lead[index] = true + leadCount++ + } + } + if leadCount == 0 { + return candidates + } + + matches := make([][]bool, len(candidates)) + frequency := make([]int, len(terms)) + for candidateIndex, candidate := range candidates { + if candidate == nil || candidate.Node == nil { + continue + } + matches[candidateIndex] = exploreComponentConjunctionMatches(candidate.Node, terms) + for termIndex, matched := range matches[candidateIndex] { + if matched { + frequency[termIndex]++ + } + } + } + + seeds := make(map[exploreComponentConjunctionKey][]exploreComponentConjunctionSeed) + for index := 0; index < width; index++ { + candidate := candidates[index] + if candidate == nil || candidate.Node == nil || exploreDraftIsTestNode(candidate.Node) || + !exploreCodeDefinitionKind(candidate.Node.Kind) { + continue + } + component, ok := exploreComponentConjunctionComponent(candidate.Node) + if !ok { + continue + } + taskAligned := false + for _, matched := range matches[index] { + if matched { + taskAligned = true + break + } + } + if !taskAligned { + continue + } + seeds[component] = append(seeds[component], exploreComponentConjunctionSeed{ + index: index, node: candidate.Node, + file: exploreComponentConjunctionFile(candidate.Node), matched: matches[index], + }) + } + if len(seeds) == 0 { + return candidates + } + + sameNode := func(left, right *graph.Node) bool { + if left == nil || right == nil { + return false + } + if left.ID != "" && right.ID != "" { + return left.ID == right.ID + } + return left == right + } + bestByComponent := make(map[exploreComponentConjunctionKey]exploreComponentConjunctionWinner) + for index, candidate := range candidates { + if candidate == nil || candidate.Node == nil || exploreDraftIsTestNode(candidate.Node) || + (candidate.Node.Kind != graph.KindFunction && candidate.Node.Kind != graph.KindMethod) || + exploreDraftGenericCandidate(candidate.Node, "") { + continue + } + component, ok := exploreComponentConjunctionComponent(candidate.Node) + if !ok { + continue + } + componentSeeds := seeds[component] + if len(componentSeeds) == 0 { + continue + } + + base := make([]bool, len(terms)) + candidateFile := exploreComponentConjunctionFile(candidate.Node) + seedRank := len(candidates) + hasOtherSeed := false + differentFile := false + for _, seed := range componentSeeds { + if sameNode(seed.node, candidate.Node) { + continue + } + hasOtherSeed = true + if seed.index < seedRank { + seedRank = seed.index + } + if seed.file != candidateFile { + differentFile = true + } + for termIndex, matched := range seed.matched { + base[termIndex] = base[termIndex] || matched + } + } + if !hasOtherSeed || !differentFile { + continue + } + + candidateMatched := 0 + marginal := 0 + rarity := 0 + longest := 0 + unionGroups := make(map[string]struct{}, len(groups)) + leadAligned := false + for termIndex, term := range terms { + candidateHasTerm := matches[index][termIndex] + if candidateHasTerm { + candidateMatched++ + if len(term) > longest { + longest = len(term) + } + if !base[termIndex] { + marginal++ + denominator := frequency[termIndex] + if denominator < 1 { + denominator = 1 + } + rarity += 1000 / denominator + } + } + if base[termIndex] || candidateHasTerm { + unionGroups[exploreConceptBehavioralTermGroup(term)] = struct{}{} + leadAligned = leadAligned || lead[termIndex] + } + } + if candidateMatched == 0 || marginal == 0 || len(unionGroups) < 2 || !leadAligned || + (longest < 5 && candidateMatched < 2) { + continue + } + + winner := exploreComponentConjunctionWinner{ + index: index, + seedRank: seedRank, + score: exploreComponentConjunctionScore{ + groups: len(unionGroups), marginal: marginal, rarity: rarity, longest: longest, + }, + } + previous, exists := bestByComponent[component] + comparison := exploreComponentConjunctionScoreCompare(winner.score, previous.score) + if !exists || comparison > 0 || + (comparison == 0 && (winner.seedRank < previous.seedRank || + (winner.seedRank == previous.seedRank && winner.index < previous.index))) { + bestByComponent[component] = winner + } + } + if len(bestByComponent) == 0 { + return candidates + } + + best := exploreComponentConjunctionWinner{index: -1} + ambiguous := false + for _, winner := range bestByComponent { + if best.index < 0 { + best = winner + continue + } + comparison := exploreComponentConjunctionScoreCompare(winner.score, best.score) + if comparison > 0 { + best = winner + ambiguous = false + } else if comparison == 0 { + ambiguous = true + } + } + if best.index < 0 || ambiguous { + return candidates + } + + result := append([]*rerank.Candidate(nil), candidates...) + clone := *result[best.index] + clone.Signals = make(map[string]float64, len(result[best.index].Signals)+1) + for key, value := range result[best.index].Signals { + clone.Signals[key] = value + } + clone.Signals[exploreConceptComplementSignal] = 1 + promoted := &clone + if best.index < width { + result[best.index] = promoted + return result + } + + target := width - 1 + copy(result[target+1:best.index+1], result[target:best.index]) + result[target] = promoted + return result +} + // callable plus up to two callables that cover distinct task concepts in the // final window. Retrieval width and response size remain unchanged. The // semantic head stays first; reserved implementations occupy the next slots. @@ -2441,6 +2798,7 @@ func (s *Server) handleExplore(ctx context.Context, req mcp.CallToolRequest) (*m _, prod = diversifyByFile(prodNodes, prod, defaultMaxPerFile) } prod, protectedImplementationID := reserveExploreConceptImplementation(searchQuery, queryClass, prod, maxSymbols) + prod = reserveExploreComponentConjunction(searchQuery, queryClass, prod, maxSymbols) cands := selectFinalExploreCandidates(prod, test, maxSymbols) if len(protectedSyntacticAnchors) > 0 { // Source-literal selection owns its own final-slot guarantees. Re-union @@ -2509,6 +2867,7 @@ func (s *Server) handleExplore(ctx context.Context, req mcp.CallToolRequest) (*m } if c.Signals != nil { t.conceptComplement = c.Signals[exploreConceptComplementSignal] > 0 + t.syntacticAnchor = c.Signals[exploreSyntacticAnchorSignal] > 0 t.exactContent = c.Signals[exploreContentRecallExactSignal] > 0 t.exactContentAmbiguous = c.Signals[exploreContentRecallAmbiguousSignal] > 0 t.sourceLiteral = c.Signals[exploreSourceLiteralSignal] > 0 @@ -2568,6 +2927,20 @@ func (s *Server) handleExplore(ctx context.Context, req mcp.CallToolRequest) (*m target.directCalleesComplete = target.directCalleesComplete && projectionComplete } } + if len(symbolTargets) > 0 { + symbolTargets = promoteExploreCausalChangeTargets(task, symbolTargets, s.graph, maxSymbols, func(node *graph.Node) string { + return s.manifestSymbolSource(ctx, node) + }, func(node *graph.Node) ([]*graph.Node, bool) { + callees := eng.GetCallChain(node.ID, ringOpts) + if callees == nil { + return nil, false + } + direct, projectionComplete := ringNeighborsProjection(callees.Nodes, node.ID, exploreRingCap) + complete := !callees.Truncated && !callees.BudgetHit && !callees.LowerBound && projectionComplete + return direct, complete + }) + targets = append(targets[:len(artifactTargets):len(artifactTargets)], symbolTargets...) + } if exploreQueryIsConceptTask(task) && len(targets) > len(artifactTargets) { symbolTargets := promoteExploreDivergentDefaultOwner(task, targets[len(artifactTargets):], s.graph, maxSymbols, func(node *graph.Node) string { return s.manifestSymbolSource(ctx, node) @@ -2639,6 +3012,13 @@ func (s *Server) handleExplore(ctx context.Context, req mcp.CallToolRequest) (*m result, refinement, boundedRoutes, digest := buildLocalizationRefinementResultForTask( preferredSymbol, task, targets, budget, routes, ) + if result.IsError { + // A failed response never owns the session. In particular, an + // irreducible packing error must not make a long-running coding turn + // terminal merely because localization was requested within it. + s.localizationFor(ctx).keepOpenForTask(task) + return result, nil + } if refinement.State != localizationStateNeedsRefinement { refinement.digest = digest s.localizationFor(ctx).armForTask(refinement, task) @@ -2660,6 +3040,10 @@ func (s *Server) handleExplore(ctx context.Context, req mcp.CallToolRequest) (*m // too, whose success promotes to answer_ready with the evidence already // stashed. result, _, digest, completion := buildLocalizationExploreResultForTaskFinalized(completion, task, targets, budget) + if result.IsError { + s.localizationFor(ctx).keepOpenForTask(task) + return result, nil + } // Literal-driven terminality must show its evidence: when the verdict // rests on a quoted-literal match but the budgeted envelope shed the // literal, downgrade to the bounded refinement read instead of telling @@ -2674,6 +3058,10 @@ func (s *Server) handleExplore(ctx context.Context, req mcp.CallToolRequest) (*m refined, refinement, boundedRoutes, refinedDigest := buildLocalizationRefinementResultForTask( preferredSymbol, task, targets, budget, routes, ) + if refined.IsError { + s.localizationFor(ctx).keepOpenForTask(task) + return refined, nil + } if refinement.State != localizationStateNeedsRefinement { refinement.digest = refinedDigest s.localizationFor(ctx).armForTask(refinement, task) @@ -3090,6 +3478,17 @@ func localizationEvidenceTargetsFromDraft(task, exactID string, targets []explor if draft == nil && strings.TrimSpace(task) != "" { draft = exploreAnswerDraft(task, targets) } + // Preserve the issue author's explicit file/symbol anchor before causal + // owner promotion. The owner still remains adjacent, but a proven upstream + // cause must not hide the named implementation target from PRIMARY evidence. + if explicitID := exploreLocalizationExplicitTarget(task, targets); explicitID != "" { + for _, target := range targets { + if target.node != nil && target.node.ID == explicitID { + appendTarget(target) + break + } + } + } // A graph-proven causal constructor and its owning type outrank the // downstream retrieval seed. Their explicit admission metadata survives // draft ranking, owner folding, and byte-budget packing, so this ordering is @@ -3146,6 +3545,27 @@ func localizationEvidenceTargetsFromDraft(task, exactID string, targets []explor appendTarget(target) } } + // A graph-proven change owner, bridge, and continuation outrank concept-only + // retrieval complements. The retrieval head remains first; this reservation + // only replaces non-causal tail rows when the final evidence window is full. + for _, target := range targets { + if target.causalChangeOwner { + appendTarget(target) + break + } + } + for _, target := range targets { + if target.causalChangeBridge { + appendTarget(target) + break + } + } + for _, target := range targets { + if target.causalChangeLeaf { + appendTarget(target) + break + } + } // Concept reservations are weaker than exact, source-literal, typed, and // divergent causal evidence, but stronger than draft relations. Keep the // primary and up to two diverse complements adjacent whenever those stronger @@ -3268,11 +3688,13 @@ func interleaveLocalizationDirectRelationsWithRoutes( } direct[target.node.ID] = target if index < localizationDirectEvidenceReserve || target.node.ID == requiredID || + target.causalChangeBridge || target.causalChangeLeaf || target.causalChangeOwner || target.divergentDefaultOwner || target.divergentDefaultType || target.conceptImplementation || target.conceptComplement || target.exactContent || target.sourceLiteral || target.typedAnchorProjection { protected[target.node.ID] = struct{}{} } - if target.node.ID == requiredID || target.divergentDefaultOwner || target.divergentDefaultType || + if target.node.ID == requiredID || target.causalChangeBridge || target.causalChangeLeaf || target.causalChangeOwner || + target.divergentDefaultOwner || target.divergentDefaultType || target.conceptImplementation || target.conceptComplement || target.exactContent || target.sourceLiteral || target.typedAnchorProjection { orderedPrefix = index + 1 @@ -3390,6 +3812,7 @@ func interleaveLocalizationDirectRelationsWithRoutes( if !exists { relation = exploreTarget{node: best.node, localizationRelation: best.direction} } else if relation.node.ID != requiredID && + !relation.causalChangeBridge && !relation.causalChangeLeaf && !relation.causalChangeOwner && !relation.divergentDefaultOwner && !relation.divergentDefaultType && !relation.conceptImplementation && !relation.conceptComplement && !relation.exactContent && !relation.sourceLiteral && !relation.typedAnchorProjection { @@ -3431,6 +3854,41 @@ func interleaveLocalizationDirectRelationsWithRoutes( return selected } +// prioritizeLocalizationConceptComplement keeps the implementation pair close +// enough for agents to interpret it as one unit. The semantic head and its +// primary implementation retain ranks one and two; a later task-complementary +// callable is stably rotated into rank three before the aligned wire projection +// is packed. No row is added or removed. +func prioritizeLocalizationConceptComplement(targets []exploreTarget) []exploreTarget { + if len(targets) < 3 { + return targets + } + hasLeadingImplementation := false + for index := 0; index < 2; index++ { + if targets[index].conceptImplementation { + hasLeadingImplementation = true + break + } + } + if !hasLeadingImplementation { + return targets + } + for index := 2; index < len(targets); index++ { + if !targets[index].conceptComplement { + continue + } + if index == 2 { + return targets + } + ordered := append([]exploreTarget(nil), targets...) + complement := ordered[index] + copy(ordered[3:index+1], ordered[2:index]) + ordered[2] = complement + return ordered + } + return targets +} + func newLocalizationExploreResult(completion localizationCompletion, targets []exploreTarget, budget int) *mcp.CallToolResult { return newLocalizationExploreResultForTask(completion, "", targets, budget) } @@ -3488,19 +3946,19 @@ func buildLocalizationRefinementResultForTask( routes map[string]localizationRefinementRoute, ) (*mcp.CallToolResult, localizationCompletion, map[string]localizationRefinementRoute, *localizationEvidenceDigest) { choosePreferred := func(symbols []string, requested string) (string, []string, map[string]localizationRefinementRoute) { - authorized, bounded := boundedLocalizationRefinementRoutes(symbols, routes, requested) + _, bounded := boundedLocalizationRefinementRoutes(symbols, routes, requested) if requested != "" { - if _, ok := bounded[requested]; ok { - return requested, authorized, bounded + if route, ok := bounded[requested]; ok { + return requested, []string{requested}, map[string]localizationRefinementRoute{requested: route} } } _, bounded = boundedLocalizationRefinementRoutes(symbols, routes, "") for _, symbol := range symbols { - if _, ok := bounded[symbol]; !ok { + route, ok := bounded[symbol] + if !ok { continue } - authorized, bounded = boundedLocalizationRefinementRoutes(symbols, routes, symbol) - return symbol, authorized, bounded + return symbol, []string{symbol}, map[string]localizationRefinementRoute{symbol: route} } return "", nil, nil } @@ -3513,9 +3971,11 @@ func buildLocalizationRefinementResultForTask( return result, packedCompletion, nil, digest } - // Budget against the largest completion this envelope can expose. The final - // allowed set is an equal or smaller intersection with serialized symbols, - // so replacing this provisional contract cannot invalidate the byte cap. + // A localization-only request needs the ranked location, not a second model + // turn that asks the server for source it has already hydrated. Authorize the + // server-ranked preferred refinement only, so the packing pass can inline + // that body and retire the deterministic read. Other ranked candidates stay + // visible in the canonical evidence and final response. budgetCompletion := newLocalizationRefinementCompletionForSymbols(preferredSymbol, preauthorized) budgetCompletion.refinementRoutes = prebounded var finalRoutes map[string]localizationRefinementRoute @@ -3562,10 +4022,14 @@ func buildLocalizationExploreResultForTaskFinalized( targets = localizationEvidenceTargetsFromDraft(task, requiredSymbol, targets, draft) if refinementFirst { targets = prioritizeLocalizationEvidenceTarget(requiredSymbol, targets) + // A divergent-default refinement is one causal unit: keep the prescribed + // constructor adjacent to its owning type before the named consumer. + targets = preserveExploreDivergentDefaultOrder(targets) } targets = interleaveLocalizationDirectRelationsWithRoutes( task, requiredSymbol, targets, completion.refinementRoutes, ) + targets = prioritizeLocalizationConceptComplement(targets) contract := localizationContractFor(completion) envelope := localizationExploreEnvelope{ Completion: contract.Completion, @@ -3702,12 +4166,13 @@ func buildLocalizationExploreResultForTaskFinalized( seenBody[index] = struct{}{} bodyOrder = append(bodyOrder, index) } - // The authorized exact symbol's body is precisely what the prescribed read - // would return, so it packs first: shipping it here can retire the round - // trip, while a preferred body that loses the slot stays one call away. - if envelope.Completion.State == localizationStateNeedsExactRead && requiredSymbol != "" { + // The prescribed symbol's body is precisely what the bounded read would + // return, so it packs first for both exact reads and refinements. Shipping it + // here can retire the round trip; letting unrelated preferred bodies consume + // the fixed body slots makes the later retirement pass unreachable. + if prescribed := localizationPrescribedSymbol(envelope.Completion); prescribed != "" { for index, target := range acceptedTargets { - if target.node != nil && target.node.ID == requiredSymbol { + if target.node != nil && target.node.ID == prescribed { appendBodyIndex(index) break } @@ -3756,6 +4221,7 @@ func buildLocalizationExploreResultForTaskFinalized( // still told them to. So pack the body and retire the read together, or // do neither — the half-measure costs payload and saves no call. claimedAnswer := false + boundedConclusion := false if prescribed := localizationPrescribedSymbol(envelope.Completion); prescribed != "" && localizationPrescriptionHasNothingLeftToChoose(envelope.Completion) { // The allowance is what makes this reachable: a full page fills its @@ -3771,6 +4237,11 @@ func buildLocalizationExploreResultForTaskFinalized( claimedAnswer = true envelope.Completion = localizationCompletionRetiringPrescribedRead(envelope.Completion) } else { + // The body still makes the prescribed read redundant even when its + // metadata alone does not clear the hard lead-alignment bar. Conclude + // localization explicitly as bounded/unconfirmed after the digest is + // built instead of leaving a contradictory continue_task state. + boundedConclusion = true envelope.Completion = localizationCompletionReleasingPrescribedRead(envelope.Completion) } } @@ -3780,72 +4251,131 @@ func buildLocalizationExploreResultForTaskFinalized( // share this one normalized completion value. envelope.Completion = localizationFinalizeCompletionEvidence(task, envelope.Completion, acceptedTargets, envelope) if claimedAnswer && envelope.Completion.State != localizationStateAnswerReady { - // The policy would not stand behind the retirement — an unproven answer - // is demoted to a recovery page, which is a worse offer than the bounded - // read we started from. Put back the prescription, and with it the bytes - // that only made sense if the read went away. - satisfiedSymbol = "" - envelope = prescribedEnvelope - envelope.Completion = localizationFinalizeCompletionEvidence( - task, envelope.Completion, acceptedTargets, envelope) + // Hard terminal policy may decline to call a ranked answer proven even + // though the prescribed body is already present and task-aligned. Keep + // that body and release the redundant read as a bounded conclusion. A + // localization-only caller answers from the explicitly unconfirmed page; + // task/coding tools remain available because only navigation is terminal. + boundedConclusion = true + envelope.Completion = localizationCompletionReleasingPrescribedRead(prescribedEnvelope.Completion) } contract = localizationContractFor(envelope.Completion) envelope.Completion = contract.Completion envelope.Terminal = contract.Terminal digest := newLocalizationEvidenceDigestForTask(task, envelope) - envelope.Completion = localizationCompletionBoundedByDigest(envelope.Completion, digest) + alignLocalizationEnvelopeWithDigest(&envelope, digest) // The serialized completion, returned state, structuredContent, and host - // metadata must carry the same final_response. Build the digest first, then - // enrich the one completion value before any wire representation is made. - envelope.Completion = localizationCompletionWithDigest(envelope.Completion, digest) - contract = localizationContractFor(envelope.Completion) - envelope.Completion = contract.Completion - envelope.Terminal = contract.Terminal + // metadata must carry the same final_response. Reconcile through one closure + // initially and after every later digest mutation so a shed proof row can + // never remain authorized by stale completion fields. + reconcilePackedCompletion := func() { + envelope.Completion = localizationCompletionBoundedByDigest(envelope.Completion, digest) + if boundedConclusion { + envelope.Completion = newLocalizationBoundedConclusionCompletion(digest) + } else { + envelope.Completion = localizationCompletionWithDigest(envelope.Completion, digest) + } + contract = localizationContractFor(envelope.Completion) + envelope.Completion = contract.Completion + envelope.Terminal = contract.Terminal + } + reconcilePackedCompletion() // The ready-to-emit answer is derived from the retained rows, so it lands // after the fit checks above and can push the envelope past its budget. - // Give back the weakest presented row first — the caller is asked to - // reproduce these lines, so a row that cannot be shown is a row that cannot - // be retained either — then packed source bodies, which are the largest and - // most recoverable payload. A body that just retired a prescribed read is - // kept: it is the evidence the caller would otherwise have paid a call for. + // Give back non-prescribed source bodies and optional row detail before any + // file/symbol identity. The terminal page is the compact interpretation of + // those identities; discarding it to preserve duplicate signatures or caller + // lists both costs more and hides a late implementation candidate. A body + // that just retired a prescribed read remains protected. shedBudget := maxBytes if satisfiedSymbol != "" { shedBudget = maxBytes + localizationRetiredReadAllowance(maxBytes) } for !localizationEnvelopeFits(envelope, shedBudget) { - if digest != nil && len(digest.Evidence) > localizationFinalResponsePrimaryLimit { - digest.Evidence = digest.Evidence[:len(digest.Evidence)-1] - rebuildLocalizationDigestSkeleton(digest) - refreshLocalizationDigestResponses(digest, task, nil) - envelope.Completion = localizationCompletionWithDigest(envelope.Completion, digest) - continue - } - shed := -1 - for index := range envelope.Evidence { + shedSource := false + for index := len(envelope.Evidence) - 1; index >= 0; index-- { if strings.TrimSpace(envelope.Evidence[index].Source) == "" || envelope.Evidence[index].ID == satisfiedSymbol { continue } - shed = index - } - if shed < 0 { - // Last resort. The unproven page is the answer a caller that stops - // here would give, so it outranks every packed body and survives - // until nothing else can be given back — and even then it goes only - // if going makes the envelope fit. Dropping it from a response that - // is over budget either way costs the caller its answer and buys - // nothing. - page := envelope.Completion.FinalResponse - if envelope.Completion.State != localizationStateAnswerReady && page != "" { - envelope.Completion.FinalResponse = "" - if localizationEnvelopeFits(envelope, shedBudget) { + envelope.Evidence[index].Source = "" + shedSource = true + break + } + if shedSource { + continue + } + + shedDetails := false + for index := len(envelope.Evidence) - 1; index >= 0; index-- { + if shedLocalizationEnvelopeRowOptionalFields(&envelope.Evidence[index]) { + shedDetails = true + break + } + } + if shedDetails { + continue + } + + if digest != nil && len(digest.Evidence) > localizationFinalResponsePrimaryLimit { + drop := localizationDigestPackingDropIndex(digest, envelope.Completion, satisfiedSymbol) + if drop >= 0 { + copy(digest.Evidence[drop:], digest.Evidence[drop+1:]) + digest.Evidence = digest.Evidence[:len(digest.Evidence)-1] + rebuildLocalizationDigestSkeleton(digest) + refreshLocalizationDigestResponses(digest, task, nil) + alignLocalizationEnvelopeWithDigest(&envelope, digest) + reconcilePackedCompletion() + continue + } + } + + // The prescribed body was packed before final_response existed. If the + // completed page cannot fit even after compaction, restore the exact read + // instead of emitting an oversized or internally contradictory answer. + if satisfiedSymbol != "" { + for index := range envelope.Evidence { + if envelope.Evidence[index].ID == satisfiedSymbol { + envelope.Evidence[index].Source = "" break } - envelope.Completion.FinalResponse = page } - break + satisfiedSymbol = "" + boundedConclusion = false + // The earlier digest was built for the retired prescription. Once the + // read is restored, rebuild from the source-cleared envelope so the + // prescribed identity is retained and evidence-derived enforcement is + // recomputed from exactly what the caller will receive. + restoredCompletion := localizationFinalizeCompletionEvidence( + task, prescribedEnvelope.Completion, acceptedTargets, envelope, + ) + restoredContract := localizationContractFor(restoredCompletion) + envelope.Completion = restoredContract.Completion + envelope.Terminal = restoredContract.Terminal + digest = newLocalizationEvidenceDigestForTask(task, envelope) + alignLocalizationEnvelopeWithDigest(&envelope, digest) + shedBudget = maxBytes + reconcilePackedCompletion() + continue } - envelope.Evidence[shed].Source = "" + + // Last resort. The unproven page is the answer a caller that stops + // here would give, so it goes only if removing it actually fits. + page := envelope.Completion.FinalResponse + if envelope.Completion.State != localizationStateAnswerReady && page != "" { + envelope.Completion.FinalResponse = "" + if localizationEnvelopeFits(envelope, shedBudget) { + break + } + envelope.Completion.FinalResponse = page + } + break + } + if !localizationEnvelopeFits(envelope, shedBudget) { + // Packing failure is not a localization conclusion. Return an explicitly + // inactive completion so no caller can accidentally arm a terminal or + // refinement contract from an error payload. + return mcp.NewToolResultError("localization result exceeds its serialized response budget"), nil, nil, newLocalizationOpenCompletion() } body, err := json.Marshal(envelope) if err != nil { diff --git a/internal/mcp/tools_explore_test.go b/internal/mcp/tools_explore_test.go index 6c406535c..3257e76a8 100644 --- a/internal/mcp/tools_explore_test.go +++ b/internal/mcp/tools_explore_test.go @@ -895,6 +895,60 @@ func TestFacadeExploreDemotesRepeatedDataLeafNames(t *testing.T) { } } +func TestHandleExplorePackingErrorKeepsSessionOpen(t *testing.T) { + g := graph.New() + bm := search.NewBM25() + // IDs are contractual and intentionally are not truncated. This synthetic + // identity makes even the minimal mandatory envelope exceed the handler's + // minimum 1,000-token budget, exercising the irreducible error path. + hugeID := "pkg/huge.go::" + strings.Repeat("HugeTarget", 700) + node := &graph.Node{ + ID: hugeID, Name: "HugeTarget", Kind: graph.KindFunction, + FilePath: "pkg/huge.go", Language: "go", StartLine: 1, EndLine: 3, + } + g.AddNode(node) + bm.Add(hugeID, node.Name, node.FilePath, "HugeTarget implementation") + eng := query.NewEngine(g) + eng.SetSearch(bm) + srv := NewServer(eng, g, nil, nil, zap.NewNop(), nil) + ctx := WithSessionID(context.Background(), "explore_packing_error_stays_open") + + prior := newLocalizationCompletion(true, "") + prior.Enforceable = true + prior.enforceableOnAnswerReady = true + prior.digest = testEvidenceDigest() + srv.localizationFor(ctx).armForTask(prior, "previous localization") + + req := mcpgo.CallToolRequest{} + req.Params.Arguments = map[string]any{ + "task": "locate HugeTarget implementation", + "localize": true, + "token_budget": exploreMinBudgetTokens, + "max_symbols": 1, + } + result, err := srv.handleExplore(ctx, req) + if err != nil { + t.Fatalf("handleExplore error: %v", err) + } + if result == nil || !result.IsError { + t.Fatalf("oversized localization result = %#v, want MCP error", result) + } + if text, _ := singleTextContent(result); !strings.Contains(text, "serialized response budget") { + t.Fatalf("unexpected packing error: %q", text) + } + + state := srv.localizationFor(ctx) + state.mu.Lock() + completion := state.completionLocked() + state.mu.Unlock() + if completion.State != localizationStateInactive || completion.Enforceable || completion.digest != nil { + t.Fatalf("packing error armed localization state: %#v", completion) + } + if blocked, _ := state.authorizeWithToken("search", "text", map[string]any{"query": "HugeTarget"}); blocked != nil { + t.Fatalf("packing error blocked the continuing coding session: %#v", blocked) + } +} + func TestExploreFileDiversificationIsStrictlyConceptOnly(t *testing.T) { if !exploreShouldDiversifyByFile(rerank.QueryClassConcept) { t.Fatal("Concept query must retain bounded per-file diversification") @@ -1065,11 +1119,11 @@ func TestLocalizationEvidenceReservesExactBeforePromotedNeighbor(t *testing.T) { 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.Symbols) != 2 || envelope.Symbols[0] != exact.ID || envelope.Symbols[1] != primary.ID { + t.Fatalf("symbols = %v, want canonical exact then primary order", envelope.Symbols) } - if len(envelope.Evidence) != 2 || envelope.Evidence[1].ID != exact.ID { - t.Fatalf("exact evidence missing: %+v", envelope.Evidence) + if len(envelope.Evidence) != 2 || envelope.Evidence[0].ID != exact.ID || envelope.Evidence[1].ID != primary.ID { + t.Fatalf("canonical evidence order diverged: %+v", envelope.Evidence) } if len(envelope.Files) != 2 || envelope.Files[0] != "walk.go" || envelope.Files[1] != "walk.go" { t.Fatalf("files = %v, want one positional file per primary/exact row", envelope.Files) @@ -1124,11 +1178,11 @@ func TestLocalizationEnvelopeEnforcesSerializedBudgetWithLongMetadata(t *testing 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.Symbols) < 2 || envelope.Symbols[0] != exact.ID || envelope.Symbols[1] != primary.ID { + t.Fatalf("mandatory symbols not retained in canonical order: %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) + if len(envelope.Evidence) < 2 || envelope.Evidence[0].ID != exact.ID || envelope.Evidence[1].ID != primary.ID { + t.Fatalf("mandatory evidence not retained in canonical order: %+v", envelope.Evidence) } for _, evidence := range envelope.Evidence { if len(evidence.Callers) > localizationMaxNeighborIDs || len(evidence.Callees) > localizationMaxNeighborIDs { @@ -1143,6 +1197,78 @@ func TestLocalizationEnvelopeEnforcesSerializedBudgetWithLongMetadata(t *testing } } +func TestLocalizationFinalResponseRestoresReadInsteadOfOversizedRetiredBody(t *testing.T) { + const budget = 512 + exactID := "repo/target.go::Target" + leadID := "repo/entry.go::TargetEntry" + targets := []exploreTarget{ + {node: &graph.Node{ + ID: leadID, Name: "TargetEntry", QualName: "TargetEntry", Kind: graph.KindFunction, + FilePath: "repo/entry.go", StartLine: 1, EndLine: 8, + }}, + { + node: &graph.Node{ + ID: exactID, Name: "Target", QualName: "Target", Kind: graph.KindFunction, + FilePath: "repo/target.go", StartLine: 1, EndLine: 80, + }, + source: "func Target() {\n" + strings.Repeat("\t// target implementation body\n", 90) + "}\n", + sourceLiteral: true, + sourceLiteralCallee: true, + exactContent: true, + directCalleesComplete: true, + sourceLiteralAligned: true, + exactContentAmbiguous: false, + }, + } + for index := 0; index < 4; index++ { + targets = append(targets, exploreTarget{node: &graph.Node{ + ID: fmt.Sprintf("repo/support-%d.go::Support%dWithLongImplementationName", index, index), + Name: fmt.Sprintf("Support%dWithLongImplementationName", index), + QualName: fmt.Sprintf("Support%dWithLongImplementationName", index), + Kind: graph.KindFunction, FilePath: fmt.Sprintf("repo/support-%d.go", index), + StartLine: index + 2, EndLine: index + 8, + }}) + } + + result, _, digest, packed := buildLocalizationExploreResultForTaskFinalized( + newLocalizationExactReadCompletion(exactID, false), + "find the Target implementation body", + targets, + budget, + ) + if result == nil || result.IsError { + t.Fatalf("packing returned an error instead of restoring the read: %#v", result) + } + text, ok := singleTextContent(result) + if !ok { + t.Fatalf("packed result content = %#v", result.Content) + } + if len(text) > budget*localizationEnvelopeBytesPerToken { + t.Fatalf("restored-read 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 packed.State != localizationStateNeedsExactRead || envelope.Completion.State != localizationStateNeedsExactRead { + t.Fatalf("oversized retired body did not restore exact read: packed=%#v visible=%#v", packed, envelope.Completion) + } + if !packed.enforceableOnAnswerReady { + t.Fatalf("restored exact-read prescription lost its visible strong-proof enforcement: %#v", packed) + } + if digest == nil || len(digest.Evidence) == 0 || digest.Evidence[0].ID != exactID { + t.Fatalf("restored non-head prescription was not retained as the canonical digest priority: %#v", digest) + } + if len(envelope.Evidence) == 0 || envelope.Evidence[0].ID != exactID || envelope.Symbols[0] != exactID { + t.Fatalf("visible response order diverged from restored digest priority: %#v", envelope) + } + for _, row := range envelope.Evidence { + if row.ID == exactID && row.Source != "" { + t.Fatalf("restored prescription still shipped its oversized body: %d bytes", len(row.Source)) + } + } +} + 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"} @@ -1181,6 +1307,58 @@ func TestLocalizationEnvelopeIncludesBoundedStructuralNeighbor(t *testing.T) { } } +func TestExploreEnvelopeCompactsOptionalDetailBeforeDigestIdentities(t *testing.T) { + task := "CultureNotFoundException ku on Xamarin.Android while calling ToWords with CultureInfo pl after Kurdish support" + localiser := &graph.Node{ + ID: "src/Humanizer/Configuration/LocaliserRegistry.cs::LocaliserRegistry.Register", Name: "Register", + QualName: "LocaliserRegistry.Register", Kind: graph.KindMethod, + FilePath: "src/Humanizer/Configuration/LocaliserRegistry.cs", StartLine: 55, + } + nodes := []*graph.Node{ + {ID: "src/Humanizer/NumberToWordsExtension.cs::NumberToWordsExtension.ToWords_L92", Name: "ToWords", QualName: "NumberToWordsExtension.ToWords", Kind: graph.KindMethod, FilePath: "src/Humanizer/NumberToWordsExtension.cs", StartLine: 92}, + localiser, + {ID: "src/Humanizer/Configuration/Configurator.cs::Configurator", Name: "Configurator", QualName: "Configurator", Kind: graph.KindType, FilePath: "src/Humanizer/Configuration/Configurator.cs", StartLine: 16}, + {ID: "src/Humanizer/Configuration/Configurator.cs::Configurator.GetNumberToWordsConverter", Name: "GetNumberToWordsConverter", QualName: "Configurator.GetNumberToWordsConverter", Kind: graph.KindMethod, FilePath: "src/Humanizer/Configuration/Configurator.cs", StartLine: 85}, + {ID: "src/Humanizer/NoMatchFoundException.cs::NoMatchFoundException.init", Name: "NoMatchFoundException.init", QualName: "NoMatchFoundException.init", Kind: graph.KindMethod, FilePath: "src/Humanizer/NoMatchFoundException.cs", StartLine: 20}, + {ID: "src/Humanizer/Configuration/LocaliserRegistry.cs::LocaliserRegistry.ResolveForCulture", Name: "ResolveForCulture", QualName: "LocaliserRegistry.ResolveForCulture", Kind: graph.KindMethod, FilePath: "src/Humanizer/Configuration/LocaliserRegistry.cs", StartLine: 47}, + {ID: "src/Humanizer/NumberToWordsExtension.cs::NumberToWordsExtension.ToWords_L55", Name: "ToWords", QualName: "NumberToWordsExtension.ToWords", Kind: graph.KindMethod, FilePath: "src/Humanizer/NumberToWordsExtension.cs", StartLine: 55}, + {ID: "src/Humanizer/GrammaticalGender.cs::GrammaticalGender", Name: "GrammaticalGender", QualName: "GrammaticalGender", Kind: graph.KindType, FilePath: "src/Humanizer/GrammaticalGender.cs", StartLine: 6}, + {ID: "src/Humanizer/Configuration/FormatterRegistry.cs::FormatterRegistry.RegisterCzechSlovakPolishFormatter", Name: "RegisterCzechSlovakPolishFormatter", QualName: "FormatterRegistry.RegisterCzechSlovakPolishFormatter", Kind: graph.KindMethod, FilePath: "src/Humanizer/Configuration/FormatterRegistry.cs", StartLine: 62}, + } + targets := make([]exploreTarget, 0, len(nodes)) + for index, node := range nodes { + node.Meta = map[string]any{"signature": strings.Repeat("optional argument ", 8)} + target := exploreTarget{node: node} + if index == len(nodes)-1 { + target.callees = []*graph.Node{localiser} + } + targets = append(targets, target) + } + result, _, digest := buildLocalizationExploreResultForTask( + newLocalizationRecoveryCompletion(), task, targets, exploreMaxBudgetTokens, + ) + if result == nil || result.IsError || digest == nil { + t.Fatalf("packed result=%#v digest=%#v", result, digest) + } + text, ok := singleTextContent(result) + if !ok { + t.Fatalf("expected one text result: %#v", result.Content) + } + var envelope localizationExploreEnvelope + if err := json.Unmarshal([]byte(text), &envelope); err != nil { + t.Fatalf("decode localization envelope: %v", err) + } + if len(envelope.Evidence) != len(nodes) || len(digest.Evidence) != len(nodes) { + t.Fatalf("optional detail displaced identities: visible=%d retained=%d want=%d", len(envelope.Evidence), len(digest.Evidence), len(nodes)) + } + if !strings.Contains(envelope.Completion.FinalResponse, " — PRIMARY — src/Humanizer/Configuration/FormatterRegistry.cs:62 — src/Humanizer/Configuration/FormatterRegistry.cs::FormatterRegistry.RegisterCzechSlovakPolishFormatter") { + t.Fatalf("direct complementary implementation was not retained as PRIMARY:\n%s", envelope.Completion.FinalResponse) + } + if !localizationEnvelopeFits(envelope, exploreMaxBudgetTokens*localizationEnvelopeBytesPerToken) { + t.Fatal("compacted visible envelope exceeded its byte budget") + } +} + func TestRefinementEnvelopeAuthorizesSerializedEvidenceAndOmitsSource(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"} @@ -1203,29 +1381,43 @@ func TestRefinementEnvelopeAuthorizesSerializedEvidenceAndOmitsSource(t *testing if err := json.Unmarshal([]byte(text), &envelope); err != nil { t.Fatalf("decode localization envelope: %v", err) } - if !strings.Contains(envelope.Completion.RequiredAction, buildParallel.ID) { - t.Fatalf("refinement action did not name preferred symbol: %#v", envelope.Completion) + if envelope.Completion.State != localizationStateAnswerReady || !envelope.Terminal { + t.Fatalf("packed refinement did not terminate localization: %#v", envelope.Completion) + } + if envelope.Completion.Enforceable || envelope.Completion.AllowedToolCalls != 0 { + t.Fatalf("uncertain refinement became enforceable or retained a read: %#v", envelope.Completion) } - if envelope.Completion.ExactSymbol != "" { - t.Fatalf("uncertain refinement advertised exact symbol: %#v", envelope.Completion) + if envelope.Completion.ExactSymbol != "" || len(envelope.Completion.AllowedSymbols) != 0 { + t.Fatalf("bounded conclusion advertised a remaining symbol read: %#v", envelope.Completion) + } + if !strings.Contains(envelope.Completion.FinalResponse, localizationBoundedHeading) { + t.Fatalf("bounded refinement omitted its bounded-evidence heading: %#v", envelope.Completion) } if strings.Join(authorized, "\n") != strings.Join(envelope.Symbols, "\n") { - t.Fatalf("authorization differs from serialized symbols: authorized=%#v symbols=%#v", authorized, envelope.Symbols) + t.Fatalf("returned evidence differs from serialized symbols: returned=%#v symbols=%#v", authorized, envelope.Symbols) } if len(authorized) > 12 { - t.Fatalf("refinement authorization exceeded cap: %d", len(authorized)) + t.Fatalf("serialized evidence exceeded cap: %d", len(authorized)) } if !strings.Contains(strings.Join(authorized, "\n"), buildWithCWD.ID) { - t.Fatalf("serialized structural target was not authorized: %#v", authorized) + t.Fatalf("serialized structural target was not retained: %#v", authorized) } if strings.Contains(strings.Join(authorized, "\n"), unrelated.ID) { - t.Fatalf("neighbor hint became a refinement target: %#v", authorized) + t.Fatalf("neighbor hint became terminal evidence: %#v", authorized) } + packedPreferred := false for _, evidence := range envelope.Evidence { + if evidence.ID == buildParallel.ID && strings.TrimSpace(evidence.Source) != "" { + packedPreferred = true + continue + } if evidence.Source != "" { - t.Fatalf("needs-refinement duplicated source for %s", evidence.ID) + t.Fatalf("bounded refinement packed non-prescribed source for %s", evidence.ID) } } + if !packedPreferred { + t.Fatal("bounded refinement retired the read without the prescribed source") + } } func TestRefinementEnvelopeAndHostDigestShareBoundedAuthorization(t *testing.T) { @@ -1268,9 +1460,12 @@ func TestRefinementEnvelopeAndHostDigestShareBoundedAuthorization(t *testing.T) result, packed, _, digest := buildLocalizationRefinementResultForTask( allowed[0], "", targets, exploreMaxBudgetTokens, routes, ) - if result == nil || result.IsError || digest == nil || packed.State != localizationStateNeedsRefinement { + if result == nil || result.IsError || digest == nil || packed.State != localizationStateAnswerReady { t.Fatalf("packed refinement = result=%#v completion=%#v digest=%#v", result, packed, digest) } + if packed.Enforceable || packed.AllowedToolCalls != 0 || len(packed.AllowedSymbols) != 0 { + t.Fatalf("packed refinement retained an enforceable navigation contract: %#v", packed) + } text, ok := singleTextContent(result) if !ok { t.Fatalf("refinement result content = %#v", result.Content) @@ -1282,10 +1477,16 @@ func TestRefinementEnvelopeAndHostDigestShareBoundedAuthorization(t *testing.T) if len(envelope.Evidence) != len(visibleIDs) || len(envelope.Files) != len(visibleIDs) || len(envelope.Symbols) != len(visibleIDs) { t.Fatalf("visible cardinality changed: files=%d symbols=%d evidence=%d want=%d", len(envelope.Files), len(envelope.Symbols), len(envelope.Evidence), len(visibleIDs)) } - for index, want := range visibleIDs { - row := envelope.Evidence[index] - if envelope.Symbols[index] != want || row.ID != want || envelope.Files[index] != row.File || row.Rank != index+1 { - t.Fatalf("visible row %d changed or diverged: want=%q row=%#v", index+1, want, row) + seenVisible := make(map[string]struct{}, len(visibleIDs)) + for index, row := range envelope.Evidence { + if envelope.Symbols[index] != row.ID || envelope.Files[index] != row.File || row.Rank != index+1 { + t.Fatalf("visible row %d is not positionally aligned: %#v", index+1, row) + } + seenVisible[row.ID] = struct{}{} + } + for _, want := range visibleIDs { + if _, exists := seenVisible[want]; !exists { + t.Fatalf("visible evidence lost %q after canonical reorder", want) } } if len(digest.Evidence) >= len(envelope.Evidence) { @@ -1298,19 +1499,27 @@ func TestRefinementEnvelopeAndHostDigestShareBoundedAuthorization(t *testing.T) if !ok || host.Evidence == nil { t.Fatalf("host localization envelope = %#v", result.Meta.AdditionalFields[localizationHostMetaKey]) } - wantAllowed := strings.Join(allowed, "\n") - if strings.Join(packed.AllowedSymbols, "\n") != wantAllowed || - strings.Join(envelope.Completion.AllowedSymbols, "\n") != wantAllowed || - strings.Join(host.Contract.Completion.AllowedSymbols, "\n") != wantAllowed { - t.Fatalf("completion authorization diverged: packed=%#v visible=%#v host=%#v", packed.AllowedSymbols, envelope.Completion.AllowedSymbols, host.Contract.Completion.AllowedSymbols) + if !envelope.Terminal || !host.Contract.Terminal { + t.Fatalf("bounded refinement was not terminal: visible=%t host=%t", envelope.Terminal, host.Contract.Terminal) + } + if envelope.Completion.State != localizationStateAnswerReady || host.Contract.Completion.State != localizationStateAnswerReady || + envelope.Completion.Enforceable || host.Contract.Completion.Enforceable || + envelope.Completion.AllowedToolCalls != 0 || host.Contract.Completion.AllowedToolCalls != 0 || + len(envelope.Completion.AllowedSymbols) != 0 || len(host.Contract.Completion.AllowedSymbols) != 0 { + t.Fatalf("bounded completion contract diverged: packed=%#v visible=%#v host=%#v", packed, envelope.Completion, host.Contract.Completion) + } + if packed.FinalResponse != envelope.Completion.FinalResponse || packed.FinalResponse != host.Contract.Completion.FinalResponse || + !strings.Contains(packed.FinalResponse, localizationBoundedHeading) { + t.Fatalf("bounded response diverged: packed=%q visible=%q host=%q", packed.FinalResponse, envelope.Completion.FinalResponse, host.Contract.Completion.FinalResponse) } - if len(host.Evidence.Evidence) < len(allowed) { - t.Fatalf("host retained %d rows, want %d authorized rows", len(host.Evidence.Evidence), len(allowed)) + if len(host.Evidence.Evidence) == 0 || len(host.Evidence.Evidence) > len(envelope.Evidence) { + t.Fatalf("host retained invalid evidence cardinality: retained=%d visible=%d", len(host.Evidence.Evidence), len(envelope.Evidence)) } - for index, want := range allowed { - row := host.Evidence.Evidence[index] - if row.ID != want || host.Evidence.Symbols[index] != want || host.Evidence.Files[index] != row.File || row.Rank != index+1 { - t.Fatalf("host authorized row %d diverged: want=%q row=%#v", index+1, want, row) + for index, row := range host.Evidence.Evidence { + want := envelope.Evidence[index] + if row.ID != want.ID || row.File != want.File || row.Rank != want.Rank || + host.Evidence.Symbols[index] != want.ID || host.Evidence.Files[index] != want.File { + t.Fatalf("host row %d diverged from visible evidence: want=%#v row=%#v", index+1, want, row) } } encoded, err := json.Marshal(host.Evidence) diff --git a/internal/mcp/tools_list_budget_test.go b/internal/mcp/tools_list_budget_test.go index 3c529397a..8c4b7718d 100644 --- a/internal/mcp/tools_list_budget_test.go +++ b/internal/mcp/tools_list_budget_test.go @@ -91,13 +91,10 @@ func TestToolsListByteCeilings(t *testing.T) { require.Lessf(t, locBytes, agentBytes, "localization (%d bytes) must stay leaner than the agent floor (%d bytes)", locBytes, agentBytes) - locSet := map[string]bool{} - for _, n := range locNames { - locSet[n] = true - } - require.True(t, locSet["smart_context"], "the one-shot opener must ship eagerly in the localization surface") - require.True(t, locSet[LazyToolsSearchName], "tools_search must survive every preset") - require.False(t, locSet["edit_file"], "the localization surface is read-only") + require.Len(t, locNames, 6, "localization must publish exactly four facade tools plus profile/search discovery") + require.ElementsMatch(t, []string{ + "explore", "search", "read", "capabilities", "tool_profile", LazyToolsSearchName, + }, locNames, "localization tools/list must stay on the six-tool handshake") require.Lessf(t, coreBytes, corePresetBaselineBytes, "core preset must shrink below its pre-diet baseline (%d), got %d", corePresetBaselineBytes, coreBytes) require.Lessf(t, fullBytes, fullPresetBaselineBytes, diff --git a/internal/mcp/tools_mode.go b/internal/mcp/tools_mode.go index 5cabe7f27..9efdb856e 100644 --- a/internal/mcp/tools_mode.go +++ b/internal/mcp/tools_mode.go @@ -157,12 +157,12 @@ func (s *Server) sessionAllows(p *toolPolicy, name string) bool { } // 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) + return p.lean && !isFacadePreset(p.preset) && s.isLearnedPromoted(name) } func (s *Server) usesFacadeSurface(ctx context.Context) bool { p := s.effectiveSessionPolicy(ctx) - return p != nil && p.preset == FacadeSurfaceVersion + return p != nil && isFacadePreset(p.preset) } // narrowToPolicy keeps only the tools the policy allows, preserving order. @@ -247,7 +247,7 @@ func (s *Server) checkToolPresetGate(ctx context.Context, toolName string) *mcp. } guidance := "Call tool_profile to see the available tools." recovery := "tool_profile" - if p.preset == FacadeSurfaceVersion { + if isFacadePreset(p.preset) { guidance = "Call capabilities to see the available public operations and their schemas." recovery = "capabilities" return NewStructuredErrorResult(StructuredError{ diff --git a/internal/profiles/bodies.go b/internal/profiles/bodies.go index 9c0a171e5..dfba9135a 100644 --- a/internal/profiles/bodies.go +++ b/internal/profiles/bodies.go @@ -39,7 +39,7 @@ A Gortex daemon is configured machine-wide via the §gortex§ MCP server. Whenev // sectionExploreOpener positions the one-shot localization verb as // the opening move (standard rendering; the lean profile carries its // own condensed line). -var sectionExploreOpener = bt(`For an explicitly named file to read/review/summarize, call §read(operation:"file", target:{file:""})§ directly; do not start localization. When the file, symbol, or evidence must be discovered, call §explore(operation:"localize")§ and obey §completion.required_action§; after §answer_ready§ answer from §completion.final_response§ and make no further tool calls. For diagnosis or requested changes, call §explore(operation:"task")§, make at most one focused follow-up, then proceed to impact, edit, and test. +var sectionExploreOpener = bt(`For an explicitly named file to read/review/summarize, call §read(operation:"file", target:{file:""})§ directly; do not start localization. When the file, symbol, or evidence must be discovered, call §explore(operation:"localize")§ and obey §completion.required_action§; after §answer_ready§ do not repeat localization or navigation, and answer directly from §completion.final_response§ for localization-only work. For diagnosis or requested changes, call §explore(operation:"task")§, make at most one focused follow-up, then continue normally through impact, edit, and test. `) @@ -48,7 +48,7 @@ var sectionExploreOpener = bt(`For an explicitly named file to read/review/summa // can actually call; exact operation schemas remain on demand. var sectionCompactWorkflow = bt(`For every coding task: -1. For an explicitly named file to read/review/summarize, call §read(operation:"file", target:{file:""})§ directly; do not start localization. When the file, symbol, or evidence must be discovered, call §explore(operation:"localize")§ and obey §completion.required_action§; after §answer_ready§, answer from §completion.final_response§ and stop. For diagnosis or modification, call §explore(operation:"task")§. +1. Named file: call §read(operation:"file", target:{file:""})§. Unknown evidence: call §explore(operation:"localize")§ and obey §completion.required_action§. At §answer_ready§, do not repeat localization or navigation. For localization-only work, next action MUST be the answer from §completion.final_response§: copy PRIMARY tuples in EVIDENCE order; call no tool. Diagnosis/change: continue via §explore(operation:"task")§. 2. In a diagnosis/change flow, make at most one follow-up call on one unresolved symbol with §search§, §read§, §relations§, or §trace§, then continue to step 3. Never reopen indexed source with Read/Grep/Glob or shell equivalents. 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. @@ -168,33 +168,28 @@ type localizationRow struct { } var localizationRows = []localizationRow{ - {"explore", "Localizing a task / bug / \"where is X\"", "§explore§ (one call: ranked neighborhood + source + call paths)"}, - {"search_symbols", "§Grep§ / §rg§ for a symbol", "§search_symbols§ (BM25 + camelCase-aware)"}, - {"search_text", "§Grep§ for a literal / regex", "§search_text§ (trigram-indexed)"}, - {"find_usages", "§Grep§ for references", "§find_usages§ (zero false positives)"}, - {"get_callers", "Reading to find callers", "§get_callers§"}, - {"find_implementations", "Hunting interface implementors", "§find_implementations§"}, - {"get_symbol_source", "§Read§ a file for one symbol", "§get_symbol_source§"}, - {"batch_symbols", "Reading several symbols one by one", "§batch_symbols§ (one call, many bodies)"}, - {"get_file_summary", "§Read§ to understand a file", "§get_file_summary§"}, - {"read_file", "§Read§ a non-indexed / raw file", "§read_file§"}, + {"explore", "Localizing a task / bug / \"where is X\"", "§explore(operation:\"localize\")§ (one ranked terminal result)"}, + {"search", "The one prescribed bounded recovery", "§search§"}, + {"read", "The one prescribed exact or bounded read", "§read§"}, + {"capabilities", "An operation schema is genuinely unknown", "§capabilities§"}, } -// localizationNonTableTools are eager tools cued in prose rather than -// table rows. -var localizationNonTableTools = map[string]bool{ - "smart_context": true, // the open-with-one-call cue - "index_health": true, // the liveness line in discovery -} +// Every eager localization tool has an explicit row above. +var localizationNonTableTools = map[string]bool{} -// 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. +// localizationBody is the purpose-built one-shot profile. It omits mutation, +// memory, review, and analysis guidance because those schemas are intentionally +// absent from this session surface; switch back to core for broader work. func localizationBody() string { return sectionHeader(true) + - sectionCompactWorkflow + - sectionCompactMemory + - bt(`**Reference:** call §capabilities§ for an exact operation schema; use §gortex://guide§ only for deeper background. + bt(`This profile is for localization-only work and publishes a fixed, minimal surface. + +1. For an explicitly named file to read/review/summarize, call §read(operation:"file", target:{file:""})§ directly. +2. When the file, symbol, or evidence must be discovered, call §explore(operation:"localize")§ once and obey §completion.required_action§. For §needs_recovery§, make only the prescribed bounded §search§ or §read§ call. +3. At §answer_ready§, the bounded search is complete and §completion.final_response§ contains the full retained result. For a localization-only request, answer directly with compact FILES, SYMBOLS, and EVIDENCE sections; copy each PRIMARY file/symbol tuple once in EVIDENCE order and use SUPPORTING rows only when needed. An identical §explore(operation:"localize")§ call replays that result. +4. Do not use host Read/Grep/Glob or shell navigation merely to repeat the same localization. If the evidence is contradictory or the user requested diagnosis, modification, review, or other coding work, continue with the appropriate tools; localization never blocks the session. Call §capabilities§ only when an operation schema is genuinely unknown. + +For broader coding work, the core profile exposes the full workflow guidance and tool surface. If the Gortex server is configured but the tools above are missing, report a Gortex MCP integration failure and stop. Do not start a daemon or use a CLI/shell fallback. + `) + switchBullet("localization", true) } diff --git a/internal/profiles/profiles.go b/internal/profiles/profiles.go index bc6e5b8d5..5edf685bb 100644 --- a/internal/profiles/profiles.go +++ b/internal/profiles/profiles.go @@ -73,24 +73,13 @@ type Profile struct { body func() string } -// localizationEagerTools is the lean "where is the code that does X" -// surface: orient, search, trace, read. It is the single source for -// both the `localization` tool preset (internal/mcp) and the rule -// table in the localization instructions body. +// localizationEagerTools is the fixed one-shot localization surface. The +// terminal contract permits only explore plus one bounded search/read recovery; +// capabilities remains available for schema discovery. tool_profile and +// tools_search are kept by every non-facade preset, yielding six published tools +// in total without leaking the full coding surface into each model turn. var localizationEagerTools = []string{ - // the one-shot localization opener - "explore", - // orient - "smart_context", "index_health", - // search - "search_symbols", "search_text", - // trace - "find_usages", "get_callers", "find_implementations", - // read — batch_symbols is load-bearing for turn economy: without a - // multi-symbol read, follow-ups on a localization neighborhood cost - // one turn per symbol (measured +1 median turn on the localization - // benchmark when it was left out of this surface). - "get_symbol_source", "batch_symbols", "get_file_summary", "read_file", + "explore", "search", "read", "capabilities", } // Table returns the profile table. Callers must not mutate the @@ -104,15 +93,9 @@ func Table() []Profile { body: coreBody, }, { - Name: "localization", - Summary: "lean code-finding guidance — diet instructions body, proven tool surface", - // ToolPreset is intentionally empty: the profile diets the - // @-included body (the per-turn ambient) and keeps the - // client-aware default tool surface. The deeper 14-tool - // `localization` preset (built from EagerTools below) stays - // available via GORTEX_TOOLS=localization — benchmarked, it - // cut tools/list ~30% but cost file-hits on cap-adjacent - // sessions, so it is opt-in rather than the profile default. + Name: "localization", + Summary: "one-shot code finding — terminal evidence with a minimal fixed tool surface", + ToolPreset: "localization", EagerTools: localizationEagerTools, Skills: []string{"gortex-explore", "gortex-guide", "gortex-debug"}, HookTier: HookTierStandard, diff --git a/internal/profiles/profiles_test.go b/internal/profiles/profiles_test.go index 502f393b5..47a755e2a 100644 --- a/internal/profiles/profiles_test.go +++ b/internal/profiles/profiles_test.go @@ -67,6 +67,23 @@ func TestEveryProfileKeepsPositioningCues(t *testing.T) { } } +func TestEveryProfileKeepsAnswerReadyWorkflowAdvisory(t *testing.T) { + for _, p := range Table() { + body := p.Body() + for _, required := range []string{ + "localization-only work", + } { + if !strings.Contains(body, required) { + t.Errorf("profile %q body lost answer-ready workflow guidance %q", p.Name, required) + } + } + if strings.Contains(body, "make no further tool calls") || + strings.Contains(body, "answer from `completion.final_response` and stop") { + t.Errorf("profile %q body retained all-tool stop wording", p.Name) + } + } +} + // TestLocalizationPresetRowsStayInSync keeps the optional legacy // localization preset table internally consistent. The active localization // instruction profile now describes the compact public surface instead. @@ -254,7 +271,11 @@ func TestBodies_PolicyCoreAndSingleHome(t *testing.T) { if !found { t.Fatalf("profile %q missing", name) } - for _, token := range []string{"explore", "search", "read", "relations", "trace", "change", "edit", "refactor", "capabilities", "recall", "remember"} { + compactTools := []string{"explore", "search", "read", "relations", "trace", "change", "edit", "refactor", "capabilities", "recall", "remember"} + if name == "localization" { + compactTools = p.EagerTools + } + for _, token := range compactTools { if !strings.Contains(p.Body(), token) { t.Errorf("%s body no longer mentions compact tool %q", name, token) } From 4a70daefd991183f1833393a5d9004f84672123e Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 27 Jul 2026 12:25:55 +0200 Subject: [PATCH 02/17] chore: remove obsolete localization gate helpers --- internal/hooks/localization_terminal.go | 16 ---------------- internal/mcp/facade_registry.go | 12 ------------ 2 files changed, 28 deletions(-) diff --git a/internal/hooks/localization_terminal.go b/internal/hooks/localization_terminal.go index 2a22b4456..9ebf72c5f 100644 --- a/internal/hooks/localization_terminal.go +++ b/internal/hooks/localization_terminal.go @@ -60,22 +60,6 @@ var localizationNavigationOperations = map[string]struct{}{ "get_surprising_connections": {}, "get_untested_symbols": {}, "why": {}, "get_churn_rate": {}, } -// localizationRedirectedHostTools are the host tools whose access-policy deny -// answers with "call a Gortex graph tool instead". While a localization marker -// is live that advice walks the caller straight into a refusal — measured as -// advise-then-refuse pairs, one wasted turn each — so the marker answers these -// tools itself rather than prescribing a call it will not honour. -var localizationRedirectedHostTools = map[string]struct{}{ - "Read": {}, - "Grep": {}, - "Glob": {}, -} - -func localizationRedirectedHostTool(tool string) bool { - _, redirected := localizationRedirectedHostTools[tool] - return redirected -} - var preToolUsePolicyTools = map[string]struct{}{ "Read": {}, "Grep": {}, diff --git a/internal/mcp/facade_registry.go b/internal/mcp/facade_registry.go index 720386305..857d17a85 100644 --- a/internal/mcp/facade_registry.go +++ b/internal/mcp/facade_registry.go @@ -102,18 +102,6 @@ func (r *facadeRegistry) legacy(name string) (capturedFacadeTool, bool) { return tool, ok } -func (r *facadeRegistry) legacyNavigation(name string) bool { - if r == nil { - return false - } - for _, spec := range r.byLegacy[name] { - if localizationNavigationFacade(spec.Facade) { - return true - } - } - return false -} - func (r *facadeRegistry) operations(facade string) []facadeOperationSpec { if r == nil { return nil From a42f7b9d8f819b56a64935bf7c2b1fe03de3b3d9 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 27 Jul 2026 13:30:39 +0200 Subject: [PATCH 03/17] fix: preserve qualified localization anchors --- internal/mcp/explore_syntactic_anchor.go | 15 +++++++++++++++ internal/mcp/localization_explicit_anchor_test.go | 10 ++++++++++ 2 files changed, 25 insertions(+) diff --git a/internal/mcp/explore_syntactic_anchor.go b/internal/mcp/explore_syntactic_anchor.go index 1bb68ad1e..d67172c13 100644 --- a/internal/mcp/explore_syntactic_anchor.go +++ b/internal/mcp/explore_syntactic_anchor.go @@ -149,6 +149,21 @@ func exploreSyntacticAnchorEquivalent(left, right exploreSyntacticAnchor) bool { if left.compact == right.compact { return true } + leftQualified := strings.Contains(left.source, "::") + rightQualified := strings.Contains(right.source, "::") + if leftQualified != rightQualified { + qualified, plain := left, right + if !leftQualified { + qualified, plain = right, left + } + // An explicitly qualified member is complementary to its owner, not a + // spelling variant of it. Keep both retrieval lanes so a task naming + // HipChatHandler and HipChatHandler::buildContent can surface the class + // context and the requested method independently. + if strings.HasPrefix(qualified.compact, plain.compact) { + return false + } + } shorter, longer := left.compact, right.compact if len(shorter) > len(longer) { shorter, longer = longer, shorter diff --git a/internal/mcp/localization_explicit_anchor_test.go b/internal/mcp/localization_explicit_anchor_test.go index c9f728221..129264bfe 100644 --- a/internal/mcp/localization_explicit_anchor_test.go +++ b/internal/mcp/localization_explicit_anchor_test.go @@ -23,6 +23,16 @@ func TestExploreSyntacticAnchorsDoNotSpendBudgetOnHTTPRouteMethods(t *testing.T) } } +func TestExploreSyntacticAnchorsPreserveQualifiedMemberAlongsideOwner(t *testing.T) { + anchors := exploreSyntacticAnchors(`Find HipChatHandler and HipChatHandler::buildContent()`) + if len(anchors) != 2 { + t.Fatalf("anchors = %#v, want owner and qualified member", anchors) + } + if anchors[0].compact != "hipchathandler" || anchors[1].compact != "hipchathandlerbuildcontent" { + t.Fatalf("anchor compacts = [%s, %s], want owner then qualified member", anchors[0].compact, anchors[1].compact) + } +} + func TestExploreSyntacticAnchorsCollapseAssignedAndBareFlags(t *testing.T) { anchors := exploreSyntacticAnchors(`rg panic caused by --replace, --multiline, a particular pattern, and search text containing repeats and newlines. Panic message: "slice index starts at x but ends at y" where x and y are integers and y < x. Reproduction: rg "(^|[^a-z])((([a-z]+)?)\s)?b(\s([a-z]+)?)($|[^a-z])" --replace=x --multiline lines2.txt where lines2.txt contains " b b b b b b b b\nc". Also reproduces with lines5.txt containing " b\nb\nb\nb\nc". Bug is very sensitive to exact pattern and text.`) if len(anchors) != 2 { From ad240f67ba170ebbf556bf1e58130d1a777ccbc6 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 27 Jul 2026 13:57:07 +0200 Subject: [PATCH 04/17] feat: promote cited source range symbols --- internal/mcp/explore_syntactic_anchor.go | 221 ++++++++++++++++++ .../mcp/localization_explicit_anchor_test.go | 45 ++++ internal/mcp/tools_explore.go | 4 + 3 files changed, 270 insertions(+) diff --git a/internal/mcp/explore_syntactic_anchor.go b/internal/mcp/explore_syntactic_anchor.go index d67172c13..c6b0c070c 100644 --- a/internal/mcp/explore_syntactic_anchor.go +++ b/internal/mcp/explore_syntactic_anchor.go @@ -2,6 +2,9 @@ package mcp import ( "context" + "path/filepath" + "regexp" + "strconv" "strings" "unicode" "unicode/utf8" @@ -206,6 +209,224 @@ func exploreUnquotedCodeTokens(task string) []string { }) } +const ( + exploreSourceRangeMaxAnchors = 4 + exploreSourceRangeContextSize = 320 + exploreSourceRangeMaxSpan = 512 +) + +var ( + exploreSourceRangeLineRE = regexp.MustCompile(`(?i)\blines?\s+([0-9]{1,8})(?:\s+(?:to|-)\s+([0-9]{1,8}))?`) + exploreSourceRangeInlineRE = regexp.MustCompile(`^\s*:([0-9]{1,8})(?:-([0-9]{1,8}))?`) +) + +// exploreSourceRangeSpecs pairs an explicit source path with the line citation +// immediately following it. GitHub issue bodies render these as a path and a +// later "Lines X to Y" fragment; stack traces commonly use path:line instead. +// Both shapes are bounded and ignored unless the path has a source extension. +func exploreSourceRangeSpecs(task string) []rangeSpec { + matches := exploreArtifactPathRE.FindAllStringIndex(task, -1) + if len(matches) == 0 { + return nil + } + seen := make(map[string]struct{}, exploreSourceRangeMaxAnchors) + out := make([]rangeSpec, 0, exploreSourceRangeMaxAnchors) + for index, match := range matches { + path := strings.Trim(task[match[0]:match[1]], "`'\"()[]{}<>,;") + normalizedPath := strings.ReplaceAll(path, "\\", "/") + if !exploreSourceExtension(filepath.Ext(normalizedPath)) { + continue + } + tailEnd := len(task) + if index+1 < len(matches) { + tailEnd = matches[index+1][0] + } + if limit := match[1] + exploreSourceRangeContextSize; tailEnd > limit { + tailEnd = limit + } + start, end, ok := exploreSourceRangeLines(task[match[1]:tailEnd]) + if !ok { + continue + } + key := strings.ToLower(normalizedPath) + ":" + strconv.Itoa(start) + "-" + strconv.Itoa(end) + if _, duplicate := seen[key]; duplicate { + continue + } + seen[key] = struct{}{} + out = append(out, rangeSpec{File: path, StartLine: start, EndLine: end}) + if len(out) == exploreSourceRangeMaxAnchors { + break + } + } + return out +} + +func exploreSourceRangeLines(tail string) (int, int, bool) { + match := exploreSourceRangeInlineRE.FindStringSubmatch(tail) + if match == nil { + match = exploreSourceRangeLineRE.FindStringSubmatch(tail) + } + if len(match) < 2 { + return 0, 0, false + } + start, err := strconv.Atoi(match[1]) + if err != nil || start <= 0 { + return 0, 0, false + } + end := start + if len(match) > 2 && match[2] != "" { + if parsed, parseErr := strconv.Atoi(match[2]); parseErr == nil && parsed >= start { + end = parsed + } + } + if end-start >= exploreSourceRangeMaxSpan { + end = start + exploreSourceRangeMaxSpan - 1 + } + return start, end, true +} + +// exploreSourceRangeDefinitions chooses the narrowest editable declaration at +// each cited line. Anonymous closures are implementation detail: when a cited +// line falls inside one, the enclosing named method/function remains the useful +// localization symbol (for example FingersCrossedHandler::flushBuffer). +func exploreSourceRangeDefinitions(index *fileSymbolIndex, start, end int) []*graph.Node { + if index == nil { + return nil + } + if end < start { + end = start + } + if end-start >= exploreSourceRangeMaxSpan { + end = start + exploreSourceRangeMaxSpan - 1 + } + seen := make(map[string]struct{}) + out := make([]*graph.Node, 0, 2) + for line := start; line <= end; line++ { + var best *graph.Node + bestSpan := int(^uint(0) >> 1) + for _, node := range index.syms { + if node == nil { + continue + } + if node.StartLine > line { + break + } + if node.Kind == graph.KindClosure || node.EndLine < line { + continue + } + span := node.EndLine - node.StartLine + if best == nil || span < bestSpan { + best = node + bestSpan = span + } + } + if best == nil { + best = index.smallestEnclosing(line) + } + if best == nil || best.ID == "" { + continue + } + if _, duplicate := seen[best.ID]; duplicate { + continue + } + seen[best.ID] = struct{}{} + out = append(out, best) + } + return out +} + +// promoteExploreSourceRangeCandidates puts task-cited declarations ahead of +// semantic neighbours without reranking the completed search. Existing +// candidates retain their scores; missing exact declarations are admitted with +// neutral ranks. The syntactic marker protects and labels this task-spelled +// evidence through the existing bounded selection and rendering policy. +func (s *Server) promoteExploreSourceRangeCandidates( + ctx context.Context, + task string, + ordinary []*rerank.Candidate, + scope query.QueryOptions, +) []*rerank.Candidate { + specs := exploreSourceRangeSpecs(task) + if s == nil || s.graph == nil || len(specs) == 0 || ctx.Err() != nil { + return ordinary + } + type resolvedRange struct { + spec rangeSpec + graphPath string + } + resolved := make([]resolvedRange, 0, len(specs)) + orderedPaths := make([]string, 0, len(specs)) + seenPaths := make(map[string]struct{}, len(specs)) + for _, spec := range specs { + absPath, relPath, err := s.resolveFilePath(spec.File) + if err != nil { + continue + } + graphPath := s.resolveOverlayGraphPath(relPath, absPath) + resolved = append(resolved, resolvedRange{spec: spec, graphPath: graphPath}) + if _, duplicate := seenPaths[graphPath]; !duplicate { + seenPaths[graphPath] = struct{}{} + orderedPaths = append(orderedPaths, graphPath) + } + } + if len(resolved) == 0 { + return ordinary + } + indexes := s.buildFileSymbolIndexForOrderedPathsContext(ctx, orderedPaths) + exactNodes := make([]*graph.Node, 0, len(resolved)) + seenNodes := make(map[string]struct{}, len(resolved)) + for _, item := range resolved { + for _, node := range exploreSourceRangeDefinitions(indexes[item.graphPath], item.spec.StartLine, item.spec.EndLine) { + if node == nil || !exploreLocalizableKind(node.Kind) || !scope.ScopeAllows(node) || + !s.nodeInSessionScope(ctx, node) { + continue + } + if _, duplicate := seenNodes[node.ID]; duplicate { + continue + } + seenNodes[node.ID] = struct{}{} + exactNodes = append(exactNodes, node) + if len(exactNodes) == exploreSourceRangeMaxAnchors { + break + } + } + if len(exactNodes) == exploreSourceRangeMaxAnchors { + break + } + } + if len(exactNodes) == 0 { + return ordinary + } + ordinaryByID := make(map[string]*rerank.Candidate, len(ordinary)) + for _, candidate := range ordinary { + if candidate != nil && candidate.Node != nil { + ordinaryByID[candidate.Node.ID] = candidate + } + } + out := make([]*rerank.Candidate, 0, len(ordinary)+len(exactNodes)) + promoted := make(map[string]struct{}, len(exactNodes)) + for _, node := range exactNodes { + candidate := &rerank.Candidate{Node: node, TextRank: -1, VectorRank: -1} + if existing := ordinaryByID[node.ID]; existing != nil { + clone := *existing + candidate = &clone + } + markExploreSyntacticAnchorCandidate(candidate) + out = append(out, candidate) + promoted[node.ID] = struct{}{} + } + for _, candidate := range ordinary { + if candidate == nil || candidate.Node == nil { + continue + } + if _, exact := promoted[candidate.Node.ID]; exact { + continue + } + out = append(out, candidate) + } + return out +} + func exploreSyntacticAnchorRuntimeDataPath(token string) bool { lower := strings.ToLower(strings.Trim(token, "-_.:()[]{}<>,;'\"")) for _, suffix := range []string{".txt", ".log", ".csv", ".tsv"} { diff --git a/internal/mcp/localization_explicit_anchor_test.go b/internal/mcp/localization_explicit_anchor_test.go index 129264bfe..61122785d 100644 --- a/internal/mcp/localization_explicit_anchor_test.go +++ b/internal/mcp/localization_explicit_anchor_test.go @@ -1,6 +1,8 @@ package mcp import ( + "context" + "fmt" "testing" "github.com/zzet/gortex/internal/graph" @@ -33,6 +35,49 @@ func TestExploreSyntacticAnchorsPreserveQualifiedMemberAlongsideOwner(t *testing } } +func TestExploreSourceRangeSpecsPairPathsWithFollowingLines(t *testing.T) { + task := `monolog/src/Monolog/Handler/FingersCrossedHandler.php + Lines 185 to 187 + monolog/tests/Monolog/Handler/FingersCrossedHandlerTest.php + Lines 253 to 265` + specs := exploreSourceRangeSpecs(task) + if len(specs) != 2 { + t.Fatalf("source range specs = %#v, want production and test ranges", specs) + } + if specs[0].File != "monolog/src/Monolog/Handler/FingersCrossedHandler.php" || specs[0].StartLine != 185 || specs[0].EndLine != 187 { + t.Fatalf("production range = %#v", specs[0]) + } + if specs[1].File != "monolog/tests/Monolog/Handler/FingersCrossedHandlerTest.php" || specs[1].StartLine != 253 || specs[1].EndLine != 265 { + t.Fatalf("test range = %#v", specs[1]) + } +} + +func TestExploreSourceRangeDefinitionsPreferNamedOwnerOverClosure(t *testing.T) { + idx := &fileSymbolIndex{} + idx.add(&graph.Node{ID: "handler.php::flushBuffer", Name: "flushBuffer", Kind: graph.KindMethod, StartLine: 170, EndLine: 200}) + idx.add(&graph.Node{ID: "handler.php::flushBuffer#closure", Name: "closure", Kind: graph.KindClosure, StartLine: 185, EndLine: 187}) + idx.finalise() + got := exploreSourceRangeDefinitions(idx, 185, 187) + if len(got) != 1 || got[0].ID != "handler.php::flushBuffer" { + t.Fatalf("source range definitions = %#v, want named enclosing method", got) + } +} + +func TestPromoteExploreSourceRangeCandidatesMapsToEnclosingMethod(t *testing.T) { + server, store := setupNavServer(t) + start := store.GetNode(navFindMethod(t, store, "Start")) + stop := store.GetNode(navFindMethod(t, store, "Stop")) + task := fmt.Sprintf("svc.go\nLines %d to %d", start.StartLine, start.EndLine) + ordinary := []*rerank.Candidate{{Node: stop, TextRank: 0, VectorRank: -1}} + got := server.promoteExploreSourceRangeCandidates(context.Background(), task, ordinary, query.QueryOptions{}) + if len(got) != 2 || got[0].Node.ID != start.ID || got[1].Node.ID != stop.ID { + t.Fatalf("promoted candidates = %#v, want cited Start then ordinary Stop", got) + } + if got[0].Signals[exploreSyntacticAnchorSignal] != 1 || got[0].Signals[exploreConceptComplementSignal] != 1 { + t.Fatalf("cited candidate signals = %#v, want protected syntactic evidence", got[0].Signals) + } +} + func TestExploreSyntacticAnchorsCollapseAssignedAndBareFlags(t *testing.T) { anchors := exploreSyntacticAnchors(`rg panic caused by --replace, --multiline, a particular pattern, and search text containing repeats and newlines. Panic message: "slice index starts at x but ends at y" where x and y are integers and y < x. Reproduction: rg "(^|[^a-z])((([a-z]+)?)\s)?b(\s([a-z]+)?)($|[^a-z])" --replace=x --multiline lines2.txt where lines2.txt contains " b b b b b b b b\nc". Also reproduces with lines5.txt containing " b\nb\nb\nb\nc". Bug is very sensitive to exact pattern and text.`) if len(anchors) != 2 { diff --git a/internal/mcp/tools_explore.go b/internal/mcp/tools_explore.go index b29da44e1..b3125700a 100644 --- a/internal/mcp/tools_explore.go +++ b/internal/mcp/tools_explore.go @@ -2735,6 +2735,10 @@ func (s *Server) handleExplore(ctx context.Context, req mcp.CallToolRequest) (*m ranked = mergeExploreCandidates(ranked, anchorCandidates, fetch) } } + // Exact source citations in issue bodies are stronger than semantic ranking: + // map each bounded file/line range to its smallest enclosing declaration and + // place those task-spelled candidates at the head before final selection. + ranked = s.promoteExploreSourceRangeCandidates(ctx, task, ranked, opts) // 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) From 3e8e83fb9bc57bd93e8290c7805d17244716fd6e Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 27 Jul 2026 14:06:14 +0200 Subject: [PATCH 05/17] fix: preserve cited test range candidates --- internal/mcp/explore_syntactic_anchor.go | 2 ++ internal/mcp/localization_explicit_anchor_test.go | 15 +++++++++++++++ internal/mcp/tools_explore.go | 12 +++++++++++- 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/internal/mcp/explore_syntactic_anchor.go b/internal/mcp/explore_syntactic_anchor.go index c6b0c070c..9cdada4e8 100644 --- a/internal/mcp/explore_syntactic_anchor.go +++ b/internal/mcp/explore_syntactic_anchor.go @@ -210,6 +210,7 @@ func exploreUnquotedCodeTokens(task string) []string { } const ( + exploreSourceRangeSignal = "explore_source_range" exploreSourceRangeMaxAnchors = 4 exploreSourceRangeContextSize = 320 exploreSourceRangeMaxSpan = 512 @@ -412,6 +413,7 @@ func (s *Server) promoteExploreSourceRangeCandidates( candidate = &clone } markExploreSyntacticAnchorCandidate(candidate) + candidate.Signals[exploreSourceRangeSignal] = 1 out = append(out, candidate) promoted[node.ID] = struct{}{} } diff --git a/internal/mcp/localization_explicit_anchor_test.go b/internal/mcp/localization_explicit_anchor_test.go index 61122785d..4352dcace 100644 --- a/internal/mcp/localization_explicit_anchor_test.go +++ b/internal/mcp/localization_explicit_anchor_test.go @@ -78,6 +78,21 @@ func TestPromoteExploreSourceRangeCandidatesMapsToEnclosingMethod(t *testing.T) } } +func TestExploreLocalizationTestLaneCandidateKeepsCitedTestRange(t *testing.T) { + node := &graph.Node{ + ID: "tests/HandlerTest.php::testPassthruOnClose", Name: "testPassthruOnClose", + Kind: graph.KindMethod, FilePath: "tests/HandlerTest.php", Meta: map[string]any{"is_test": true}, + } + candidate := &rerank.Candidate{Node: node, Signals: map[string]float64{exploreSourceRangeSignal: 1}} + if exploreLocalizationTestLaneCandidate("passthru level failure", candidate) { + t.Fatal("explicitly cited test range was demoted") + } + delete(candidate.Signals, exploreSourceRangeSignal) + if !exploreLocalizationTestLaneCandidate("passthru level failure", candidate) { + t.Fatal("ordinary test candidate was not demoted") + } +} + func TestExploreSyntacticAnchorsCollapseAssignedAndBareFlags(t *testing.T) { anchors := exploreSyntacticAnchors(`rg panic caused by --replace, --multiline, a particular pattern, and search text containing repeats and newlines. Panic message: "slice index starts at x but ends at y" where x and y are integers and y < x. Reproduction: rg "(^|[^a-z])((([a-z]+)?)\s)?b(\s([a-z]+)?)($|[^a-z])" --replace=x --multiline lines2.txt where lines2.txt contains " b b b b b b b b\nc". Also reproduces with lines5.txt containing " b\nb\nb\nb\nc". Bug is very sensitive to exact pattern and text.`) if len(anchors) != 2 { diff --git a/internal/mcp/tools_explore.go b/internal/mcp/tools_explore.go index b3125700a..ed9eabbb6 100644 --- a/internal/mcp/tools_explore.go +++ b/internal/mcp/tools_explore.go @@ -2776,7 +2776,7 @@ func (s *Server) handleExplore(ctx context.Context, req mcp.CallToolRequest) (*m if c == nil || c.Node == nil || !exploreLocalizableKind(c.Node.Kind) { continue } - if exploreLocalizationTestLaneNode(searchQuery, c.Node) || !exploreCodeDefinitionKind(c.Node.Kind) { + if exploreLocalizationTestLaneCandidate(searchQuery, c) || !exploreCodeDefinitionKind(c.Node.Kind) { test = append(test, c) } else { prod = append(prod, c) @@ -5262,6 +5262,16 @@ func exploreQuotedRecallHasExactSourceNode( // a `spec/` directory), so the draft detector votes too — unless the request is // about test code or names this candidate outright, in which case the test node // is the answer and keeps its production slot. +func exploreLocalizationTestLaneCandidate(query string, candidate *rerank.Candidate) bool { + if candidate == nil || candidate.Node == nil { + return false + } + if candidate.Signals[exploreSourceRangeSignal] > 0 { + return false + } + return exploreLocalizationTestLaneNode(query, candidate.Node) +} + func exploreLocalizationTestLaneNode(query string, node *graph.Node) bool { if node == nil { return false From f2930b51bef9527c80dc142cb3ee48e4ae431556 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 27 Jul 2026 14:51:12 +0200 Subject: [PATCH 06/17] feat: add advisory localization self-check --- internal/mcp/localization_digest.go | 2 +- internal/mcp/localization_digest_test.go | 23 +++++++++++++++++++++++ internal/mcp/localization_terminal.go | 8 +++++--- 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/internal/mcp/localization_digest.go b/internal/mcp/localization_digest.go index f686eec5d..32c42dfc4 100644 --- a/internal/mcp/localization_digest.go +++ b/internal/mcp/localization_digest.go @@ -1041,7 +1041,7 @@ func refreshLocalizationDigestResponses(digest *localizationEvidenceDigest, task // caller's own statements against 2% on pages without it. Ask for the answer, // name what the answer should carry, and leave the caller free to disagree — // its disagreement is right more often than not. -const localizationAnswerReadyDirective = "Localization for this task is complete: the bounded search examined the supplied anchors, and this is the full retained result. For a localization-only request, answer now using compact FILES, SYMBOLS, and EVIDENCE sections, preserving each PRIMARY tuple once in EVIDENCE order. Do not call another tool just to gain confidence, repeat, or cross-check this localization: PRIMARY rows are the best-supported answer, and SUPPORTING rows are context, not a signal that the search is unfinished. An identical localize call will replay this result. On a broader coding task, this closes only localization; if the user's request includes diagnosis, implementation, or verification, continue normally, and all tools remain available." +const localizationAnswerReadyDirective = "Localization for this task is complete: the bounded search examined the supplied anchors, and this is the full retained result. For a localization-only request, answer now using compact FILES, SYMBOLS, and EVIDENCE sections, preserving each PRIMARY tuple once in EVIDENCE order. Do not call another tool just to gain confidence, repeat, or cross-check this localization: PRIMARY rows are the best-supported answer, and SUPPORTING rows are context, not a signal that the search is unfinished. " + localizationAdvisorySelfCheck + " An identical localize call will replay this result. On a broader coding task, this closes only localization; if the user's request includes diagnosis, implementation, or verification, continue normally, and all tools remain available." const ( localizationAnswerHeading = "LOCALIZATION:" diff --git a/internal/mcp/localization_digest_test.go b/internal/mcp/localization_digest_test.go index b6a44266d..b5019002d 100644 --- a/internal/mcp/localization_digest_test.go +++ b/internal/mcp/localization_digest_test.go @@ -68,6 +68,29 @@ func requireLocalizationTerminalError(t *testing.T, result *mcpgo.CallToolResult return contract } +func TestLocalizationTerminalWordingRequiresConcreteMissingArtifact(t *testing.T) { + texts := map[string]string{ + "answer instruction": localizationAnswerReadyInstruction, + "answer directive": localizationAnswerReadyDirective, + "bounded instruction": localizationBoundedConclusionInstruction, + "bounded directive": localizationBoundedConclusionDirective, + } + for name, text := range texts { + t.Run(name, func(t *testing.T) { + if !strings.Contains(text, localizationAdvisorySelfCheck) { + t.Fatalf("terminal wording omitted advisory self-check: %q", text) + } + if !strings.Contains(text, "name a concrete requested file or symbol missing from EVIDENCE") || + !strings.Contains(text, "wanting more context is not a missing artifact") { + t.Fatalf("terminal wording omitted the concrete missing-artifact test: %q", text) + } + if !strings.Contains(text, "On a broader coding task") || !strings.Contains(text, "all tools remain available") { + t.Fatalf("terminal wording no longer keeps broader coding work open: %q", text) + } + }) + } +} + func requireLocalizationTerminalReplay(t *testing.T, result *mcpgo.CallToolResult, _, _ string) localizationTerminalContract { t.Helper() if result == nil || result.IsError { diff --git a/internal/mcp/localization_terminal.go b/internal/mcp/localization_terminal.go index 43bbb1b82..5888d0682 100644 --- a/internal/mcp/localization_terminal.go +++ b/internal/mcp/localization_terminal.go @@ -152,7 +152,9 @@ func newLocalizationPlannedRecoveryCompletion(operation, anchor string) localiza // completion.instruction carries the same obligation as one that reads the // rendered page. required_action stays "respond" — the terminal gate matches // that exact value. -const localizationAnswerReadyInstruction = "Localization is complete: the bounded search examined the supplied anchors, and completion.final_response is the full retained result. For a localization-only request, answer now from completion.final_response using compact FILES, SYMBOLS, and EVIDENCE sections, preserving each PRIMARY tuple once in EVIDENCE order. Do not call another tool just to gain confidence, repeat, or cross-check this localization: PRIMARY rows are the best-supported answer, and SUPPORTING rows are context, not a signal that the search is unfinished. On a broader coding task, this closes only localization; if the user's request includes diagnosis, implementation, or verification, continue normally, and all tools remain available, including editing, building, testing, and verification." +const localizationAdvisorySelfCheck = "For that localization-only request, before another tool call, name a concrete requested file or symbol missing from EVIDENCE. If none is missing, respond now; wanting more context is not a missing artifact." + +const localizationAnswerReadyInstruction = "Localization is complete: the bounded search examined the supplied anchors, and completion.final_response is the full retained result. For a localization-only request, answer now from completion.final_response using compact FILES, SYMBOLS, and EVIDENCE sections, preserving each PRIMARY tuple once in EVIDENCE order. Do not call another tool just to gain confidence, repeat, or cross-check this localization: PRIMARY rows are the best-supported answer, and SUPPORTING rows are context, not a signal that the search is unfinished. " + localizationAdvisorySelfCheck + " On a broader coding task, this closes only localization; if the user's request includes diagnosis, implementation, or verification, continue normally, and all tools remain available, including editing, building, testing, and verification." func newLocalizationCompletion(answerReady bool, exactSymbol string) localizationCompletion { if answerReady { @@ -168,9 +170,9 @@ func newLocalizationCompletion(answerReady bool, exactSymbol string) localizatio return newLocalizationExactReadCompletion(exactSymbol, false) } -const localizationBoundedConclusionInstruction = "The bounded localization search is exhausted; completion.final_response contains the full retained task-supported result. For a localization-only request, answer now from completion.final_response using compact FILES, SYMBOLS, and EVIDENCE sections, preserving each PRIMARY tuple once in EVIDENCE order. Do not call another tool just to gain confidence, repeat, or cross-check this localization: PRIMARY rows are the best-supported answer, and SUPPORTING rows are context, not a signal that the search is unfinished. On a broader coding task, this closes only localization; if the user's request includes diagnosis, implementation, or verification, continue normally, and all tools remain available, including editing, building, testing, and verification." +const localizationBoundedConclusionInstruction = "The bounded localization search is exhausted; completion.final_response contains the full retained task-supported result. For a localization-only request, answer now from completion.final_response using compact FILES, SYMBOLS, and EVIDENCE sections, preserving each PRIMARY tuple once in EVIDENCE order. Do not call another tool just to gain confidence, repeat, or cross-check this localization: PRIMARY rows are the best-supported answer, and SUPPORTING rows are context, not a signal that the search is unfinished. " + localizationAdvisorySelfCheck + " On a broader coding task, this closes only localization; if the user's request includes diagnosis, implementation, or verification, continue normally, and all tools remain available, including editing, building, testing, and verification." -const localizationBoundedConclusionDirective = "The bounded localization search is exhausted; this is the full retained result supported by the indexed task evidence. For a localization-only request, answer now using compact FILES, SYMBOLS, and EVIDENCE sections, preserving each PRIMARY tuple once in EVIDENCE order. Do not call another tool just to gain confidence, repeat, or cross-check this localization: PRIMARY rows are the best-supported answer, and SUPPORTING rows are context, not a signal that the search is unfinished. On a broader coding task, this closes only localization; if the user's request includes diagnosis, implementation, or verification, continue normally, and all tools remain available, including editing, building, testing, and verification." +const localizationBoundedConclusionDirective = "The bounded localization search is exhausted; this is the full retained result supported by the indexed task evidence. For a localization-only request, answer now using compact FILES, SYMBOLS, and EVIDENCE sections, preserving each PRIMARY tuple once in EVIDENCE order. Do not call another tool just to gain confidence, repeat, or cross-check this localization: PRIMARY rows are the best-supported answer, and SUPPORTING rows are context, not a signal that the search is unfinished. " + localizationAdvisorySelfCheck + " On a broader coding task, this closes only localization; if the user's request includes diagnosis, implementation, or verification, continue normally, and all tools remain available, including editing, building, testing, and verification." // newLocalizationBoundedConclusionCompletion terminalizes localization // navigation without promoting bounded best-effort evidence to a confirmed From 6857f94e628db73226a52b13e695cd7864bdce04 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 27 Jul 2026 15:08:35 +0200 Subject: [PATCH 07/17] fix: resolve repo-prefixed source ranges --- internal/mcp/explore_syntactic_anchor.go | 74 ++++++++++++++++--- .../mcp/localization_explicit_anchor_test.go | 28 +++++++ 2 files changed, 92 insertions(+), 10 deletions(-) diff --git a/internal/mcp/explore_syntactic_anchor.go b/internal/mcp/explore_syntactic_anchor.go index 9cdada4e8..3862321b1 100644 --- a/internal/mcp/explore_syntactic_anchor.go +++ b/internal/mcp/explore_syntactic_anchor.go @@ -214,6 +214,7 @@ const ( exploreSourceRangeMaxAnchors = 4 exploreSourceRangeContextSize = 320 exploreSourceRangeMaxSpan = 512 + exploreSourceRangeMaxAliases = 4 ) var ( @@ -336,6 +337,53 @@ func exploreSourceRangeDefinitions(index *fileSymbolIndex, start, end int) []*gr return out } +// exploreSourceRangeGraphPathAliases returns a bounded exact-to-suffix lookup +// order. Benchmark and issue text often prefixes paths with an isolated repo +// label (for example monolog/src/... while a lone repo indexes src/...). The +// exact path always wins; suffixes are only fallback lookup keys. +func exploreSourceRangeGraphPathAliases(graphPath string) []string { + clean := filepath.ToSlash(filepath.Clean(graphPath)) + if clean == "." || clean == "/" || clean == "" { + return nil + } + clean = strings.TrimPrefix(clean, "./") + out := make([]string, 0, exploreSourceRangeMaxAliases) + for len(out) < exploreSourceRangeMaxAliases && clean != "" { + out = append(out, clean) + slash := strings.IndexByte(clean, '/') + if slash < 0 || slash+1 >= len(clean) { + break + } + clean = clean[slash+1:] + } + return out +} + +// exploreSourceRangeIndex chooses the exact indexed path when present. If it is +// absent, exactly one suffix alias may resolve; multiple suffix hits are +// deliberately rejected so an explicit citation never promotes an ambiguous +// same-named file. +func exploreSourceRangeIndex(indexes map[string]*fileSymbolIndex, aliases []string) *fileSymbolIndex { + if len(aliases) == 0 { + return nil + } + if exact := indexes[aliases[0]]; exact != nil { + return exact + } + var resolved *fileSymbolIndex + for _, alias := range aliases[1:] { + index := indexes[alias] + if index == nil { + continue + } + if resolved != nil && resolved != index { + return nil + } + resolved = index + } + return resolved +} + // promoteExploreSourceRangeCandidates puts task-cited declarations ahead of // semantic neighbours without reranking the completed search. Existing // candidates retain their scores; missing exact declarations are admitted with @@ -352,22 +400,27 @@ func (s *Server) promoteExploreSourceRangeCandidates( return ordinary } type resolvedRange struct { - spec rangeSpec - graphPath string + spec rangeSpec + graphPaths []string } resolved := make([]resolvedRange, 0, len(specs)) - orderedPaths := make([]string, 0, len(specs)) - seenPaths := make(map[string]struct{}, len(specs)) + orderedPaths := make([]string, 0, len(specs)*exploreSourceRangeMaxAliases) + seenPaths := make(map[string]struct{}, len(specs)*exploreSourceRangeMaxAliases) for _, spec := range specs { absPath, relPath, err := s.resolveFilePath(spec.File) if err != nil { continue } - graphPath := s.resolveOverlayGraphPath(relPath, absPath) - resolved = append(resolved, resolvedRange{spec: spec, graphPath: graphPath}) - if _, duplicate := seenPaths[graphPath]; !duplicate { - seenPaths[graphPath] = struct{}{} - orderedPaths = append(orderedPaths, graphPath) + graphPaths := exploreSourceRangeGraphPathAliases(s.resolveOverlayGraphPath(relPath, absPath)) + if len(graphPaths) == 0 { + continue + } + resolved = append(resolved, resolvedRange{spec: spec, graphPaths: graphPaths}) + for _, graphPath := range graphPaths { + if _, duplicate := seenPaths[graphPath]; !duplicate { + seenPaths[graphPath] = struct{}{} + orderedPaths = append(orderedPaths, graphPath) + } } } if len(resolved) == 0 { @@ -377,7 +430,8 @@ func (s *Server) promoteExploreSourceRangeCandidates( exactNodes := make([]*graph.Node, 0, len(resolved)) seenNodes := make(map[string]struct{}, len(resolved)) for _, item := range resolved { - for _, node := range exploreSourceRangeDefinitions(indexes[item.graphPath], item.spec.StartLine, item.spec.EndLine) { + index := exploreSourceRangeIndex(indexes, item.graphPaths) + for _, node := range exploreSourceRangeDefinitions(index, item.spec.StartLine, item.spec.EndLine) { if node == nil || !exploreLocalizableKind(node.Kind) || !scope.ScopeAllows(node) || !s.nodeInSessionScope(ctx, node) { continue diff --git a/internal/mcp/localization_explicit_anchor_test.go b/internal/mcp/localization_explicit_anchor_test.go index 4352dcace..59405f889 100644 --- a/internal/mcp/localization_explicit_anchor_test.go +++ b/internal/mcp/localization_explicit_anchor_test.go @@ -78,6 +78,34 @@ func TestPromoteExploreSourceRangeCandidatesMapsToEnclosingMethod(t *testing.T) } } +func TestPromoteExploreSourceRangeCandidatesStripsIsolatedRepoLabel(t *testing.T) { + server, store := setupNavServer(t) + start := store.GetNode(navFindMethod(t, store, "Start")) + stop := store.GetNode(navFindMethod(t, store, "Stop")) + task := fmt.Sprintf("monolog/svc.go\nLines %d to %d", start.StartLine, start.EndLine) + ordinary := []*rerank.Candidate{{Node: stop, TextRank: 0, VectorRank: -1}} + got := server.promoteExploreSourceRangeCandidates(context.Background(), task, ordinary, query.QueryOptions{}) + if len(got) != 2 || got[0].Node.ID != start.ID || got[1].Node.ID != stop.ID { + t.Fatalf("promoted candidates = %#v, want repo-prefixed citation to resolve indexed svc.go", got) + } +} + +func TestExploreSourceRangeIndexRejectsAmbiguousSuffixes(t *testing.T) { + exact := &fileSymbolIndex{} + first := &fileSymbolIndex{} + second := &fileSymbolIndex{} + aliases := []string{"repo/pkg/svc.go", "pkg/svc.go", "svc.go"} + if got := exploreSourceRangeIndex(map[string]*fileSymbolIndex{aliases[0]: exact, aliases[1]: first, aliases[2]: second}, aliases); got != exact { + t.Fatalf("exact index = %p, want %p", got, exact) + } + if got := exploreSourceRangeIndex(map[string]*fileSymbolIndex{aliases[1]: first, aliases[2]: second}, aliases); got != nil { + t.Fatalf("ambiguous suffix index = %p, want nil", got) + } + if got := exploreSourceRangeIndex(map[string]*fileSymbolIndex{aliases[1]: first}, aliases); got != first { + t.Fatalf("unique suffix index = %p, want %p", got, first) + } +} + func TestExploreLocalizationTestLaneCandidateKeepsCitedTestRange(t *testing.T) { node := &graph.Node{ ID: "tests/HandlerTest.php::testPassthruOnClose", Name: "testPassthruOnClose", From 09bf7b14f8b73315b47c3c9d80b97f6c6bc361b6 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 27 Jul 2026 15:18:39 +0200 Subject: [PATCH 08/17] fix: retrieve explicit qualified members --- internal/mcp/explore_syntactic_anchor.go | 4 ++++ .../mcp/localization_explicit_anchor_test.go | 23 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/internal/mcp/explore_syntactic_anchor.go b/internal/mcp/explore_syntactic_anchor.go index 3862321b1..bc18fad05 100644 --- a/internal/mcp/explore_syntactic_anchor.go +++ b/internal/mcp/explore_syntactic_anchor.go @@ -735,10 +735,14 @@ func exploreSyntacticAnchorReusesProtected( candidates []*rerank.Candidate, usedIDs map[string]struct{}, ) string { + qualifiedMember := strings.Contains(anchor.source, "::") for _, candidate := range candidates { if candidate == nil || candidate.Node == nil { continue } + if qualifiedMember && (candidate.Node.Kind == graph.KindType || candidate.Node.Kind == graph.KindInterface) { + continue + } if _, used := usedIDs[candidate.Node.ID]; used && exploreSyntacticAnchorMatchesNode(anchor, candidate.Node) { return candidate.Node.ID } diff --git a/internal/mcp/localization_explicit_anchor_test.go b/internal/mcp/localization_explicit_anchor_test.go index 59405f889..251b7bb21 100644 --- a/internal/mcp/localization_explicit_anchor_test.go +++ b/internal/mcp/localization_explicit_anchor_test.go @@ -35,6 +35,29 @@ func TestExploreSyntacticAnchorsPreserveQualifiedMemberAlongsideOwner(t *testing } } +func TestExploreSyntacticAnchorQualifiedMemberDoesNotReuseOwner(t *testing.T) { + anchors := exploreSyntacticAnchors(`Find HipChatHandler and HipChatHandler::buildContent()`) + if len(anchors) != 2 { + t.Fatalf("anchors = %#v, want owner and qualified member", anchors) + } + owner := &rerank.Candidate{Node: &graph.Node{ + ID: "HipChatHandler.php::HipChatHandler", Name: "HipChatHandler", + QualName: "Monolog.Handler.HipChatHandler", Kind: graph.KindType, + }} + member := &rerank.Candidate{Node: &graph.Node{ + ID: "HipChatHandler.php::HipChatHandler.buildContent", Name: "buildContent", + QualName: "Monolog.Handler.HipChatHandler.buildContent", Kind: graph.KindMethod, + }} + candidates := []*rerank.Candidate{owner, member} + if got := exploreSyntacticAnchorReusesProtected(anchors[0], candidates, map[string]struct{}{owner.Node.ID: {}}); got != owner.Node.ID { + t.Fatalf("owner reuse = %q, want %q", got, owner.Node.ID) + } + used := map[string]struct{}{owner.Node.ID: {}, member.Node.ID: {}} + if got := exploreSyntacticAnchorReusesProtected(anchors[1], candidates, used); got != member.Node.ID { + t.Fatalf("qualified member reuse = %q, want %q instead of owner", got, member.Node.ID) + } +} + func TestExploreSourceRangeSpecsPairPathsWithFollowingLines(t *testing.T) { task := `monolog/src/Monolog/Handler/FingersCrossedHandler.php Lines 185 to 187 From ed63b3a5d7ac59be85f8a8a9b1b45972da53bed8 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 27 Jul 2026 15:25:35 +0200 Subject: [PATCH 09/17] fix: query qualified members by terminal name --- internal/mcp/explore_syntactic_anchor.go | 19 ++++++++++++++++--- .../mcp/localization_explicit_anchor_test.go | 3 +++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/internal/mcp/explore_syntactic_anchor.go b/internal/mcp/explore_syntactic_anchor.go index bc18fad05..186fdbf43 100644 --- a/internal/mcp/explore_syntactic_anchor.go +++ b/internal/mcp/explore_syntactic_anchor.go @@ -127,9 +127,22 @@ func newExploreSyntacticAnchor(raw string) (exploreSyntacticAnchor, bool) { if len(compact) < 4 || exploreSyntacticAnchorNoise(compact) { return exploreSyntacticAnchor{}, false } - queryTerms := append([]string(nil), terms...) - if len(terms) > 1 { - queryTerms = append(queryTerms, strings.Join(terms, "_"), compact) + lookupTerms := terms + if separator := strings.LastIndex(raw, "::"); separator >= 0 && separator+2 < len(raw) { + memberTerms := make([]string, 0, 3) + for _, token := range rerank.Tokenize(raw[separator+2:]) { + term := strings.ToLower(strings.TrimSpace(token)) + if len(term) >= 3 { + memberTerms = append(memberTerms, term) + } + } + if len(memberTerms) > 0 { + lookupTerms = memberTerms + } + } + queryTerms := append([]string(nil), lookupTerms...) + if len(lookupTerms) > 1 { + queryTerms = append(queryTerms, strings.Join(lookupTerms, "_"), strings.Join(lookupTerms, "")) } return exploreSyntacticAnchor{ query: strings.Join(queryTerms, " "), diff --git a/internal/mcp/localization_explicit_anchor_test.go b/internal/mcp/localization_explicit_anchor_test.go index 251b7bb21..cabc50389 100644 --- a/internal/mcp/localization_explicit_anchor_test.go +++ b/internal/mcp/localization_explicit_anchor_test.go @@ -33,6 +33,9 @@ func TestExploreSyntacticAnchorsPreserveQualifiedMemberAlongsideOwner(t *testing if anchors[0].compact != "hipchathandler" || anchors[1].compact != "hipchathandlerbuildcontent" { t.Fatalf("anchor compacts = [%s, %s], want owner then qualified member", anchors[0].compact, anchors[1].compact) } + if anchors[1].query != "build content build_content buildcontent" { + t.Fatalf("qualified member lookup = %q, want terminal member terms", anchors[1].query) + } } func TestExploreSyntacticAnchorQualifiedMemberDoesNotReuseOwner(t *testing.T) { From 94e07b0fc7e2bdf0f6e5f5cba925c159bd68407d Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 27 Jul 2026 15:32:45 +0200 Subject: [PATCH 10/17] fix: match qualified anchors against parser ids --- internal/mcp/explore_syntactic_anchor.go | 1 + internal/mcp/localization_explicit_anchor_test.go | 12 +++++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/internal/mcp/explore_syntactic_anchor.go b/internal/mcp/explore_syntactic_anchor.go index 186fdbf43..58efd435a 100644 --- a/internal/mcp/explore_syntactic_anchor.go +++ b/internal/mcp/explore_syntactic_anchor.go @@ -549,6 +549,7 @@ func exploreSyntacticAnchorMatchesNode(anchor exploreSyntacticAnchor, node *grap } return exploreSyntacticAnchorMatchesIdentifier(anchor, node.Name) || exploreSyntacticAnchorMatchesIdentifier(anchor, node.QualName) || + (strings.Contains(anchor.source, "::") && exploreSyntacticAnchorMatchesIdentifier(anchor, node.ID)) || exploreSyntacticAnchorMatchesPath(anchor, node) } diff --git a/internal/mcp/localization_explicit_anchor_test.go b/internal/mcp/localization_explicit_anchor_test.go index cabc50389..5ca67808a 100644 --- a/internal/mcp/localization_explicit_anchor_test.go +++ b/internal/mcp/localization_explicit_anchor_test.go @@ -49,8 +49,18 @@ func TestExploreSyntacticAnchorQualifiedMemberDoesNotReuseOwner(t *testing.T) { }} member := &rerank.Candidate{Node: &graph.Node{ ID: "HipChatHandler.php::HipChatHandler.buildContent", Name: "buildContent", - QualName: "Monolog.Handler.HipChatHandler.buildContent", Kind: graph.KindMethod, + Kind: graph.KindMethod, }} + wrongOwner := &graph.Node{ + ID: "OtherHandler.php::OtherHandler.buildContent", Name: "buildContent", + Kind: graph.KindMethod, + } + if !exploreSyntacticAnchorMatchesNode(anchors[1], member.Node) { + t.Fatal("qualified member did not match its parser ID when QualName was empty") + } + if exploreSyntacticAnchorMatchesNode(anchors[1], wrongOwner) { + t.Fatal("qualified member matched the same method name under a different owner") + } candidates := []*rerank.Candidate{owner, member} if got := exploreSyntacticAnchorReusesProtected(anchors[0], candidates, map[string]struct{}{owner.Node.ID: {}}); got != owner.Node.ID { t.Fatalf("owner reuse = %q, want %q", got, owner.Node.ID) From 229c530141490a371c4cfe005b862742bc0a1b95 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 27 Jul 2026 15:38:00 +0200 Subject: [PATCH 11/17] fix: use literal qualified member lookup --- internal/mcp/explore_syntactic_anchor.go | 23 +++++++------------ .../mcp/localization_explicit_anchor_test.go | 4 ++-- 2 files changed, 10 insertions(+), 17 deletions(-) diff --git a/internal/mcp/explore_syntactic_anchor.go b/internal/mcp/explore_syntactic_anchor.go index 58efd435a..c3155fb6c 100644 --- a/internal/mcp/explore_syntactic_anchor.go +++ b/internal/mcp/explore_syntactic_anchor.go @@ -127,25 +127,18 @@ func newExploreSyntacticAnchor(raw string) (exploreSyntacticAnchor, bool) { if len(compact) < 4 || exploreSyntacticAnchorNoise(compact) { return exploreSyntacticAnchor{}, false } - lookupTerms := terms + queryTerms := append([]string(nil), terms...) + if len(terms) > 1 { + queryTerms = append(queryTerms, strings.Join(terms, "_"), compact) + } + query := strings.Join(queryTerms, " ") if separator := strings.LastIndex(raw, "::"); separator >= 0 && separator+2 < len(raw) { - memberTerms := make([]string, 0, 3) - for _, token := range rerank.Tokenize(raw[separator+2:]) { - term := strings.ToLower(strings.TrimSpace(token)) - if len(term) >= 3 { - memberTerms = append(memberTerms, term) - } + if member := strings.TrimSpace(raw[separator+2:]); member != "" { + query = member } - if len(memberTerms) > 0 { - lookupTerms = memberTerms - } - } - queryTerms := append([]string(nil), lookupTerms...) - if len(lookupTerms) > 1 { - queryTerms = append(queryTerms, strings.Join(lookupTerms, "_"), strings.Join(lookupTerms, "")) } return exploreSyntacticAnchor{ - query: strings.Join(queryTerms, " "), + query: query, source: strings.ToLower(raw), terms: terms, compact: compact, diff --git a/internal/mcp/localization_explicit_anchor_test.go b/internal/mcp/localization_explicit_anchor_test.go index 5ca67808a..2173bd3b0 100644 --- a/internal/mcp/localization_explicit_anchor_test.go +++ b/internal/mcp/localization_explicit_anchor_test.go @@ -33,8 +33,8 @@ func TestExploreSyntacticAnchorsPreserveQualifiedMemberAlongsideOwner(t *testing if anchors[0].compact != "hipchathandler" || anchors[1].compact != "hipchathandlerbuildcontent" { t.Fatalf("anchor compacts = [%s, %s], want owner then qualified member", anchors[0].compact, anchors[1].compact) } - if anchors[1].query != "build content build_content buildcontent" { - t.Fatalf("qualified member lookup = %q, want terminal member terms", anchors[1].query) + if anchors[1].query != "buildContent" { + t.Fatalf("qualified member lookup = %q, want literal terminal member", anchors[1].query) } } From e0de36f8e17dce8c85e6108bd0f6328f5a612d0b Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 27 Jul 2026 15:42:01 +0200 Subject: [PATCH 12/17] fix: search qualified members by owner and name --- internal/mcp/explore_syntactic_anchor.go | 6 ++---- internal/mcp/localization_explicit_anchor_test.go | 4 ++-- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/internal/mcp/explore_syntactic_anchor.go b/internal/mcp/explore_syntactic_anchor.go index c3155fb6c..b8b0dccbf 100644 --- a/internal/mcp/explore_syntactic_anchor.go +++ b/internal/mcp/explore_syntactic_anchor.go @@ -132,10 +132,8 @@ func newExploreSyntacticAnchor(raw string) (exploreSyntacticAnchor, bool) { queryTerms = append(queryTerms, strings.Join(terms, "_"), compact) } query := strings.Join(queryTerms, " ") - if separator := strings.LastIndex(raw, "::"); separator >= 0 && separator+2 < len(raw) { - if member := strings.TrimSpace(raw[separator+2:]); member != "" { - query = member - } + if strings.Contains(raw, "::") { + query = strings.Join(strings.Fields(strings.ReplaceAll(raw, "::", " ")), " ") } return exploreSyntacticAnchor{ query: query, diff --git a/internal/mcp/localization_explicit_anchor_test.go b/internal/mcp/localization_explicit_anchor_test.go index 2173bd3b0..0212b956c 100644 --- a/internal/mcp/localization_explicit_anchor_test.go +++ b/internal/mcp/localization_explicit_anchor_test.go @@ -33,8 +33,8 @@ func TestExploreSyntacticAnchorsPreserveQualifiedMemberAlongsideOwner(t *testing if anchors[0].compact != "hipchathandler" || anchors[1].compact != "hipchathandlerbuildcontent" { t.Fatalf("anchor compacts = [%s, %s], want owner then qualified member", anchors[0].compact, anchors[1].compact) } - if anchors[1].query != "buildContent" { - t.Fatalf("qualified member lookup = %q, want literal terminal member", anchors[1].query) + if anchors[1].query != "HipChatHandler buildContent" { + t.Fatalf("qualified member lookup = %q, want literal owner and member", anchors[1].query) } } From 395c5cfbd267f382dded5bf39983fc316373746b Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 27 Jul 2026 15:58:22 +0200 Subject: [PATCH 13/17] fix: resolve exact qualified member names --- internal/mcp/explore_syntactic_anchor.go | 62 ++++++++++++++++--- .../mcp/localization_explicit_anchor_test.go | 35 ++++++++++- 2 files changed, 88 insertions(+), 9 deletions(-) diff --git a/internal/mcp/explore_syntactic_anchor.go b/internal/mcp/explore_syntactic_anchor.go index b8b0dccbf..44dc9982e 100644 --- a/internal/mcp/explore_syntactic_anchor.go +++ b/internal/mcp/explore_syntactic_anchor.go @@ -25,10 +25,11 @@ const ( // prose query shaping poorly, but often name the missing implementation more // precisely than the surrounding natural language. type exploreSyntacticAnchor struct { - query string - source string - terms []string - compact string + query string + source string + terms []string + compact string + qualifiedName string } // exploreSyntacticAnchors extracts only syntactically strong, unquoted clues. @@ -132,14 +133,17 @@ func newExploreSyntacticAnchor(raw string) (exploreSyntacticAnchor, bool) { queryTerms = append(queryTerms, strings.Join(terms, "_"), compact) } query := strings.Join(queryTerms, " ") + qualifiedName := "" if strings.Contains(raw, "::") { query = strings.Join(strings.Fields(strings.ReplaceAll(raw, "::", " ")), " ") + qualifiedName = strings.ReplaceAll(strings.Join(strings.Fields(raw), ""), "::", ".") } return exploreSyntacticAnchor{ - query: query, - source: strings.ToLower(raw), - terms: terms, - compact: compact, + query: query, + source: strings.ToLower(raw), + terms: terms, + compact: compact, + qualifiedName: qualifiedName, }, true } @@ -755,6 +759,34 @@ func exploreSyntacticAnchorReusesProtected( return "" } +// exploreExactQualifiedAnchorCandidate resolves the parser's exact Owner.member +// graph name before the bounded lexical lane. Some backends exhaust that lane +// before its exact-name fallback runs; this lookup is restricted to explicit +// Owner::member task syntax and still applies session, query, kind, and diversity +// filters through the ordinary anchor selector. +func (s *Server) exploreExactQualifiedAnchorCandidate( + ctx context.Context, + anchor exploreSyntacticAnchor, + scope query.QueryOptions, + usedIDs, usedFiles map[string]struct{}, +) *rerank.Candidate { + if s == nil || s.graph == nil || anchor.qualifiedName == "" || ctx.Err() != nil { + return nil + } + nodes := s.graph.FindNodesByName(anchor.qualifiedName) + candidates := make([]*rerank.Candidate, 0, min(len(nodes), exploreSyntacticAnchorFetch)) + for _, node := range nodes { + if node == nil || !s.nodeInSessionScope(ctx, node) { + continue + } + candidates = append(candidates, &rerank.Candidate{Node: node, VectorRank: -1}) + if len(candidates) == exploreSyntacticAnchorFetch { + break + } + } + return exploreSyntacticAnchorCandidate(anchor, candidates, scope, usedIDs, usedFiles) +} + // gatherExploreSyntacticAnchorCandidates performs a tiny lexical retrieval for // each anchor not represented by the ordinary over-fetch pool. Only after that // identifier lane misses do we invoke the existing request-local source scan. @@ -820,6 +852,20 @@ func (s *Server) gatherExploreSyntacticAnchorCandidates( protected[index] = reused continue } + if exactCandidate := s.exploreExactQualifiedAnchorCandidate(ctx, anchor, scope, usedIDs, usedFiles); exactCandidate != nil { + protectedCandidate := exactCandidate + for _, existing := range ordinary { + if existing != nil && existing.Node != nil && existing.Node.ID == exactCandidate.Node.ID { + protectedCandidate = existing + break + } + } + if protectedCandidate == exactCandidate { + additions = append(additions, exactCandidate) + } + addProtected(index, protectedCandidate) + continue + } ordinaryCandidate := exploreSyntacticAnchorCandidate(anchor, ordinary, scope, usedIDs, usedFiles) compete := exploreSyntacticAnchorNeedsLexicalCompetition(anchor, ordinaryCandidate) if ordinaryCandidate != nil && exploreSyntacticAnchorNodeStrength(anchor, ordinaryCandidate.Node) >= 4 && !compete { diff --git a/internal/mcp/localization_explicit_anchor_test.go b/internal/mcp/localization_explicit_anchor_test.go index 0212b956c..4dcd4ef6e 100644 --- a/internal/mcp/localization_explicit_anchor_test.go +++ b/internal/mcp/localization_explicit_anchor_test.go @@ -36,6 +36,9 @@ func TestExploreSyntacticAnchorsPreserveQualifiedMemberAlongsideOwner(t *testing if anchors[1].query != "HipChatHandler buildContent" { t.Fatalf("qualified member lookup = %q, want literal owner and member", anchors[1].query) } + if anchors[1].qualifiedName != "HipChatHandler.buildContent" { + t.Fatalf("qualified graph name = %q, want parser owner.member name", anchors[1].qualifiedName) + } } func TestExploreSyntacticAnchorQualifiedMemberDoesNotReuseOwner(t *testing.T) { @@ -48,7 +51,7 @@ func TestExploreSyntacticAnchorQualifiedMemberDoesNotReuseOwner(t *testing.T) { QualName: "Monolog.Handler.HipChatHandler", Kind: graph.KindType, }} member := &rerank.Candidate{Node: &graph.Node{ - ID: "HipChatHandler.php::HipChatHandler.buildContent", Name: "buildContent", + ID: "HipChatHandler.php::HipChatHandler.buildContent", Name: "HipChatHandler.buildContent", Kind: graph.KindMethod, }} wrongOwner := &graph.Node{ @@ -71,6 +74,36 @@ func TestExploreSyntacticAnchorQualifiedMemberDoesNotReuseOwner(t *testing.T) { } } +func TestExploreExactQualifiedAnchorCandidateFindsParserName(t *testing.T) { + anchors := exploreSyntacticAnchors(`Find HipChatHandler and HipChatHandler::buildContent()`) + if len(anchors) != 2 { + t.Fatalf("anchors = %#v, want owner and qualified member", anchors) + } + g := graph.New() + member := &graph.Node{ + ID: "src/Monolog/Handler/HipChatHandler.php::HipChatHandler.buildContent", + Name: "HipChatHandler.buildContent", Kind: graph.KindMethod, + FilePath: "src/Monolog/Handler/HipChatHandler.php", StartLine: 89, + } + g.AddNode(member) + g.AddNode(&graph.Node{ + ID: "src/Monolog/Handler/OtherHandler.php::OtherHandler.buildContent", + Name: "OtherHandler.buildContent", Kind: graph.KindMethod, + FilePath: "src/Monolog/Handler/OtherHandler.php", StartLine: 42, + }) + server := &Server{graph: g} + got := server.exploreExactQualifiedAnchorCandidate( + context.Background(), anchors[1], query.QueryOptions{}, + map[string]struct{}{}, map[string]struct{}{}, + ) + if got == nil || got.Node == nil { + t.Fatal("exact qualified lookup returned no candidate") + } + if got.Node.ID != member.ID { + t.Fatalf("exact qualified lookup = %q, want %q", got.Node.ID, member.ID) + } +} + func TestExploreSourceRangeSpecsPairPathsWithFollowingLines(t *testing.T) { task := `monolog/src/Monolog/Handler/FingersCrossedHandler.php Lines 185 to 187 From 4614a867cf971e027262de336f3858e3ce42a880 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 27 Jul 2026 16:10:35 +0200 Subject: [PATCH 14/17] fix: resolve short parser member names --- internal/mcp/explore_syntactic_anchor.go | 36 +++++++++++++------ .../mcp/localization_explicit_anchor_test.go | 15 +++++++- 2 files changed, 39 insertions(+), 12 deletions(-) diff --git a/internal/mcp/explore_syntactic_anchor.go b/internal/mcp/explore_syntactic_anchor.go index 44dc9982e..66815f48a 100644 --- a/internal/mcp/explore_syntactic_anchor.go +++ b/internal/mcp/explore_syntactic_anchor.go @@ -759,11 +759,12 @@ func exploreSyntacticAnchorReusesProtected( return "" } -// exploreExactQualifiedAnchorCandidate resolves the parser's exact Owner.member -// graph name before the bounded lexical lane. Some backends exhaust that lane -// before its exact-name fallback runs; this lookup is restricted to explicit -// Owner::member task syntax and still applies session, query, kind, and diversity -// filters through the ordinary anchor selector. +// exploreExactQualifiedAnchorCandidate resolves the parser's exact member name +// before the bounded lexical lane. Graph nodes commonly store only the terminal +// method Name while the ID tail carries Owner.member, so query both exact forms. +// This lookup is restricted to explicit Owner::member task syntax and still +// applies session, query, kind, and diversity filters through the ordinary +// anchor selector. func (s *Server) exploreExactQualifiedAnchorCandidate( ctx context.Context, anchor exploreSyntacticAnchor, @@ -773,13 +774,26 @@ func (s *Server) exploreExactQualifiedAnchorCandidate( if s == nil || s.graph == nil || anchor.qualifiedName == "" || ctx.Err() != nil { return nil } - nodes := s.graph.FindNodesByName(anchor.qualifiedName) - candidates := make([]*rerank.Candidate, 0, min(len(nodes), exploreSyntacticAnchorFetch)) - for _, node := range nodes { - if node == nil || !s.nodeInSessionScope(ctx, node) { - continue + names := []string{anchor.qualifiedName} + if dot := strings.LastIndexByte(anchor.qualifiedName, '.'); dot >= 0 && dot < len(anchor.qualifiedName)-1 { + names = []string{anchor.qualifiedName[dot+1:], anchor.qualifiedName} + } + candidates := make([]*rerank.Candidate, 0, exploreSyntacticAnchorFetch) + seen := make(map[string]struct{}, exploreSyntacticAnchorFetch) + for _, name := range names { + for _, node := range s.graph.FindNodesByName(name) { + if node == nil || !s.nodeInSessionScope(ctx, node) { + continue + } + if _, duplicate := seen[node.ID]; duplicate { + continue + } + seen[node.ID] = struct{}{} + candidates = append(candidates, &rerank.Candidate{Node: node, VectorRank: -1}) + if len(candidates) == exploreSyntacticAnchorFetch { + break + } } - candidates = append(candidates, &rerank.Candidate{Node: node, VectorRank: -1}) if len(candidates) == exploreSyntacticAnchorFetch { break } diff --git a/internal/mcp/localization_explicit_anchor_test.go b/internal/mcp/localization_explicit_anchor_test.go index 4dcd4ef6e..03d0e2be9 100644 --- a/internal/mcp/localization_explicit_anchor_test.go +++ b/internal/mcp/localization_explicit_anchor_test.go @@ -41,6 +41,19 @@ func TestExploreSyntacticAnchorsPreserveQualifiedMemberAlongsideOwner(t *testing } } +func TestExploreSyntacticAnchorsKeepQualifiedMemberInLongIssue(t *testing.T) { + anchors := exploreSyntacticAnchors(`Title: HipChatHandler: Parameter validation/handling +The HipChat API at https://www.hipchat.com/docs/api/method/rooms/message limits the from parameter. +When a HipChatHandler is instantiated with a longer name it should fail. +Similarly, strip longer messages within HipChatHandler::buildContent().`) + for _, anchor := range anchors { + if anchor.qualifiedName == "HipChatHandler.buildContent" { + return + } + } + t.Fatalf("anchors = %#v, qualified member was displaced from bounded issue anchors", anchors) +} + func TestExploreSyntacticAnchorQualifiedMemberDoesNotReuseOwner(t *testing.T) { anchors := exploreSyntacticAnchors(`Find HipChatHandler and HipChatHandler::buildContent()`) if len(anchors) != 2 { @@ -82,7 +95,7 @@ func TestExploreExactQualifiedAnchorCandidateFindsParserName(t *testing.T) { g := graph.New() member := &graph.Node{ ID: "src/Monolog/Handler/HipChatHandler.php::HipChatHandler.buildContent", - Name: "HipChatHandler.buildContent", Kind: graph.KindMethod, + Name: "buildContent", Kind: graph.KindMethod, FilePath: "src/Monolog/Handler/HipChatHandler.php", StartLine: 89, } g.AddNode(member) From dfd536c16c2c14540b438f039771e8205baa31a9 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 27 Jul 2026 16:27:23 +0200 Subject: [PATCH 15/17] fix: preserve late qualified issue anchors --- internal/mcp/explore_syntactic_anchor.go | 19 +++++++ .../mcp/localization_explicit_anchor_test.go | 49 +++++++++++++++++++ internal/mcp/tools_explore.go | 20 +++++++- 3 files changed, 87 insertions(+), 1 deletion(-) diff --git a/internal/mcp/explore_syntactic_anchor.go b/internal/mcp/explore_syntactic_anchor.go index 66815f48a..5605bca1a 100644 --- a/internal/mcp/explore_syntactic_anchor.go +++ b/internal/mcp/explore_syntactic_anchor.go @@ -548,6 +548,25 @@ func exploreSyntacticAnchorMatchesNode(anchor exploreSyntacticAnchor, node *grap exploreSyntacticAnchorMatchesPath(anchor, node) } +// exploreTaskQualifiedSyntacticAnchorMatchesNode checks the untouched task so a +// late Owner::member anchor survives long-report query shaping and truncation. +// Only explicit qualified members use this route; ordinary prose and bare +// identifiers continue to rely on the shaped query. +func exploreTaskQualifiedSyntacticAnchorMatchesNode(task string, node *graph.Node) bool { + if node == nil || node.Kind == graph.KindType || node.Kind == graph.KindInterface || + utf8.RuneCountInString(task) < shapeMinReportChars || !strings.ContainsAny(task, "\r\n") { + return false + } + for _, anchor := range exploreSyntacticAnchors(task) { + if anchor.qualifiedName != "" && + (exploreSyntacticAnchorMatchesIdentifier(anchor, node.ID) || + exploreSyntacticAnchorMatchesIdentifier(anchor, node.QualName)) { + return true + } + } + return false +} + func exploreSyntacticAnchorMatchesPath(anchor exploreSyntacticAnchor, node *graph.Node) bool { if node == nil || !strings.ContainsAny(anchor.source, ".\\/") { return false diff --git a/internal/mcp/localization_explicit_anchor_test.go b/internal/mcp/localization_explicit_anchor_test.go index 03d0e2be9..e7e3f3e3d 100644 --- a/internal/mcp/localization_explicit_anchor_test.go +++ b/internal/mcp/localization_explicit_anchor_test.go @@ -3,6 +3,7 @@ package mcp import ( "context" "fmt" + "strings" "testing" "github.com/zzet/gortex/internal/graph" @@ -54,6 +55,54 @@ Similarly, strip longer messages within HipChatHandler::buildContent().`) t.Fatalf("anchors = %#v, qualified member was displaced from bounded issue anchors", anchors) } +func TestExploreQualifiedMemberSurvivesLongIssueShaping(t *testing.T) { + task := "Title: HipChatHandler: Parameter validation/handling\n" + + strings.Repeat("the report explains how an oversized value reaches the remote service without validation and why that behavior is confusing. ", 8) + + "Similarly, strip longer messages within HipChatHandler::buildContent()." + head := &graph.Node{ + ID: "src/Monolog/Logger.php::Logger.API", Name: "API", + Kind: graph.KindConstant, FilePath: "src/Monolog/Logger.php", StartLine: 87, EndLine: 87, + } + owner := &graph.Node{ + ID: "src/Monolog/Handler/HipChatHandler.php::HipChatHandler", Name: "HipChatHandler", + Kind: graph.KindType, FilePath: "src/Monolog/Handler/HipChatHandler.php", StartLine: 28, EndLine: 219, + } + member := &graph.Node{ + ID: "src/Monolog/Handler/HipChatHandler.php::HipChatHandler.buildContent", Name: "buildContent", + Kind: graph.KindMethod, FilePath: "src/Monolog/Handler/HipChatHandler.php", StartLine: 89, EndLine: 101, + } + targets := []exploreTarget{ + {node: head, source: "const API = 1;"}, + {node: owner, source: "class HipChatHandler extends SocketHandler {}", syntacticAnchor: true}, + {node: member, source: "private function buildContent($record) { return $record['formatted']; }", syntacticAnchor: true}, + } + shaped := shapeExploreQuery(task) + if exploreLocalizationExplicitAnchor(shaped, member) { + t.Fatal("test setup failed: shaped query unexpectedly retained the late qualified member") + } + if !exploreTaskQualifiedSyntacticAnchorMatchesNode(task, member) { + t.Fatal("untouched task did not preserve the late qualified member") + } + if got := exploreLocalizationExplicitTarget(task, targets); got != member.ID { + t.Fatalf("explicit target = %q, want %q", got, member.ID) + } + if !exploreAnswerReady(task, targets) { + t.Fatal("hydrated exact qualified member did not make localization answer-ready") + } + draft := exploreAnswerDraft(task, targets) + if len(draft) == 0 || draft[0].node == nil || draft[0].node.ID != member.ID || !draft[0].exact { + t.Fatalf("draft head = %#v, want exact qualified member", draft) + } + bodyIDs := explorePreferredFullBodyIDs(task, targets, draft, 2) + if len(bodyIDs) == 0 || bodyIDs[0] != exploreDraftNodeKey(member) { + t.Fatalf("preferred source bodies = %v, want qualified member first", bodyIDs) + } + evidence := localizationEvidenceTargetsFromDraft(task, "", targets, draft) + if len(evidence) == 0 || evidence[0].node == nil || evidence[0].node.ID != member.ID { + t.Fatalf("evidence head = %#v, want qualified member", evidence) + } +} + func TestExploreSyntacticAnchorQualifiedMemberDoesNotReuseOwner(t *testing.T) { anchors := exploreSyntacticAnchors(`Find HipChatHandler and HipChatHandler::buildContent()`) if len(anchors) != 2 { diff --git a/internal/mcp/tools_explore.go b/internal/mcp/tools_explore.go index ed9eabbb6..495554492 100644 --- a/internal/mcp/tools_explore.go +++ b/internal/mcp/tools_explore.go @@ -478,7 +478,8 @@ func exploreAnswerDraft(task string, targets []exploreTarget) []exploreDraftEntr } makeEntry := func(n *graph.Node, source, evidence string, direct bool, parentRank int) (exploreDraftEntry, int) { overlap, longest := exploreDraftTermOverlap(queryTerms, n) - explicitAnchor := exploreLocalizationExplicitAnchor(query, n) + explicitAnchor := exploreLocalizationExplicitAnchor(query, n) || + exploreTaskQualifiedSyntacticAnchorMatchesNode(task, 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 @@ -1267,6 +1268,16 @@ func exploreAnswerReady(task string, targets []exploreTarget) bool { !exploreSyntacticAnchorEvidenceReady(task, targets) && !strongSourceLiteral { return false } + if class == rerank.QueryClassConcept { + for _, target := range targets { + callable := target.node != nil && + (target.node.Kind == graph.KindFunction || target.node.Kind == graph.KindMethod) + if callable && strings.TrimSpace(target.source) != "" && + exploreTaskQualifiedSyntacticAnchorMatchesNode(task, target.node) { + return true + } + } + } // Paths, signatures, and identifier-shaped queries carry explicit anchors. // A shared token is not enough: the ranked head must cover the complete @@ -1538,6 +1549,13 @@ func exploreLocalizationExplicitAnchor(query string, n *graph.Node) bool { // accepted here: it can rank a neighborhood but must not prescribe the one // allowed post-localization source read. func exploreLocalizationExplicitTarget(task string, targets []exploreTarget) string { + // Preserve exact qualified members from the untouched issue before shaping + // can truncate a late Owner::member mention out of a long report body. + for _, target := range targets { + if target.node != nil && exploreTaskQualifiedSyntacticAnchorMatchesNode(task, target.node) { + return target.node.ID + } + } query := shapeExploreQuery(task) if exploreQueryClass(query) == rerank.QueryClassConcept { query = stripLeadingExploreDirective(query) From c4635ebb217cd474c069e1a364e6982307d6b413 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 27 Jul 2026 16:36:14 +0200 Subject: [PATCH 16/17] fix: require visible source for late anchors --- .../mcp/localization_explicit_anchor_test.go | 27 ++++++++++++++++--- internal/mcp/tools_explore.go | 11 -------- 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/internal/mcp/localization_explicit_anchor_test.go b/internal/mcp/localization_explicit_anchor_test.go index e7e3f3e3d..e25a22cc3 100644 --- a/internal/mcp/localization_explicit_anchor_test.go +++ b/internal/mcp/localization_explicit_anchor_test.go @@ -2,6 +2,7 @@ package mcp import ( "context" + "encoding/json" "fmt" "strings" "testing" @@ -74,7 +75,7 @@ func TestExploreQualifiedMemberSurvivesLongIssueShaping(t *testing.T) { targets := []exploreTarget{ {node: head, source: "const API = 1;"}, {node: owner, source: "class HipChatHandler extends SocketHandler {}", syntacticAnchor: true}, - {node: member, source: "private function buildContent($record) { return $record['formatted']; }", syntacticAnchor: true}, + {node: member, source: "private function buildContent($record) { return http_build_query(array('from' => $this->name, 'message' => $record['formatted'])); }", syntacticAnchor: true}, } shaped := shapeExploreQuery(task) if exploreLocalizationExplicitAnchor(shaped, member) { @@ -86,8 +87,8 @@ func TestExploreQualifiedMemberSurvivesLongIssueShaping(t *testing.T) { if got := exploreLocalizationExplicitTarget(task, targets); got != member.ID { t.Fatalf("explicit target = %q, want %q", got, member.ID) } - if !exploreAnswerReady(task, targets) { - t.Fatal("hydrated exact qualified member did not make localization answer-ready") + if exploreAnswerReady(task, targets) { + t.Fatal("raw late anchor bypassed the serialized source-proof requirement") } draft := exploreAnswerDraft(task, targets) if len(draft) == 0 || draft[0].node == nil || draft[0].node.ID != member.ID || !draft[0].exact { @@ -101,6 +102,26 @@ func TestExploreQualifiedMemberSurvivesLongIssueShaping(t *testing.T) { if len(evidence) == 0 || evidence[0].node == nil || evidence[0].node.ID != member.ID { t.Fatalf("evidence head = %#v, want qualified member", evidence) } + + completion := newLocalizationCompletion(false, member.ID) + result, _, _, finalized := buildLocalizationExploreResultForTaskFinalized(completion, task, targets, exploreDefaultBudgetTokens) + if result.IsError { + t.Fatalf("packed exact-member result failed: %#v", result) + } + if finalized.State != localizationStateAnswerReady { + t.Fatalf("packed completion = %#v, want answer_ready after visible source proof", finalized) + } + body, ok := singleTextContent(result) + if !ok { + t.Fatal("packed result has no text content") + } + var envelope localizationExploreEnvelope + if err := json.Unmarshal([]byte(body), &envelope); err != nil { + t.Fatalf("decode packed result: %v", err) + } + if len(envelope.Evidence) == 0 || envelope.Evidence[0].ID != member.ID || strings.TrimSpace(envelope.Evidence[0].Source) == "" { + t.Fatalf("packed evidence = %#v, want qualified member with source first", envelope.Evidence) + } } func TestExploreSyntacticAnchorQualifiedMemberDoesNotReuseOwner(t *testing.T) { diff --git a/internal/mcp/tools_explore.go b/internal/mcp/tools_explore.go index 495554492..72671d3f8 100644 --- a/internal/mcp/tools_explore.go +++ b/internal/mcp/tools_explore.go @@ -1268,17 +1268,6 @@ func exploreAnswerReady(task string, targets []exploreTarget) bool { !exploreSyntacticAnchorEvidenceReady(task, targets) && !strongSourceLiteral { return false } - if class == rerank.QueryClassConcept { - for _, target := range targets { - callable := target.node != nil && - (target.node.Kind == graph.KindFunction || target.node.Kind == graph.KindMethod) - if callable && strings.TrimSpace(target.source) != "" && - exploreTaskQualifiedSyntacticAnchorMatchesNode(task, target.node) { - return true - } - } - } - // 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. From 3dcc50b12599b423d3c131d6e38d844f87d9ef1f Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Mon, 27 Jul 2026 16:57:04 +0200 Subject: [PATCH 17/17] fix: retire visible exact member reads --- .../mcp/localization_explicit_anchor_test.go | 109 ++++++++++++++++++ internal/mcp/tools_explore.go | 8 ++ 2 files changed, 117 insertions(+) diff --git a/internal/mcp/localization_explicit_anchor_test.go b/internal/mcp/localization_explicit_anchor_test.go index e25a22cc3..261651d2f 100644 --- a/internal/mcp/localization_explicit_anchor_test.go +++ b/internal/mcp/localization_explicit_anchor_test.go @@ -4,10 +4,19 @@ import ( "context" "encoding/json" "fmt" + "os" + "path/filepath" "strings" "testing" + mcpgo "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/zzet/gortex/internal/config" "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/graph/store_sqlite" + "github.com/zzet/gortex/internal/indexer" "github.com/zzet/gortex/internal/query" "github.com/zzet/gortex/internal/search/rerank" ) @@ -124,6 +133,106 @@ func TestExploreQualifiedMemberSurvivesLongIssueShaping(t *testing.T) { } } +func TestExploreInlineQualifiedMemberPrescribesPackedSource(t *testing.T) { + task := `HipChatHandler: Parameter validation/handling. The HipChat API has a limit of 15 characters for the "from" parameter (API docs say "less than 15"). When HipChatHandler is instantiated with a longer name, it should throw an exception or truncate to first 15 characters. Similarly, the message is limited to 10,000 characters, and HipChatHandler::buildContent() should automatically strip longer messages. Need to locate where "from" name is set/validated and where buildContent() builds the message content.` + member := &graph.Node{ + ID: "src/Monolog/Handler/HipChatHandler.php::HipChatHandler.buildContent", Name: "buildContent", + Kind: graph.KindMethod, FilePath: "src/Monolog/Handler/HipChatHandler.php", StartLine: 89, EndLine: 101, + } + targets := []exploreTarget{ + {node: &graph.Node{ID: "src/Monolog/Logger.php::Logger.API", Name: "API", Kind: graph.KindConstant, FilePath: "src/Monolog/Logger.php", StartLine: 87, EndLine: 87}, source: "const API = 1;"}, + {node: &graph.Node{ID: "src/Monolog/Handler/HipChatHandler.php::HipChatHandler", Name: "HipChatHandler", Kind: graph.KindType, FilePath: "src/Monolog/Handler/HipChatHandler.php", StartLine: 28, EndLine: 219}, source: "class HipChatHandler extends SocketHandler {}", syntacticAnchor: true}, + {node: member, source: "private function buildContent($record) { return http_build_query(array('from' => $this->name, 'message' => $record['formatted'])); }", syntacticAnchor: true}, + } + if got := exploreLocalizationExplicitTarget(task, targets); got != member.ID { + t.Fatalf("inline explicit target = %q, want %q", got, member.ID) + } + completion := newLocalizationCompletion(false, member.ID) + result, _, _, finalized := buildLocalizationExploreResultForTaskFinalized(completion, task, targets, exploreDefaultBudgetTokens) + if result.IsError || finalized.State != localizationStateAnswerReady { + t.Fatalf("inline packed completion = %#v, error=%v", finalized, result.IsError) + } + body, ok := singleTextContent(result) + if !ok { + t.Fatal("inline packed result has no text content") + } + var envelope localizationExploreEnvelope + if err := json.Unmarshal([]byte(body), &envelope); err != nil { + t.Fatalf("decode inline packed result: %v", err) + } + if len(envelope.Evidence) == 0 || envelope.Evidence[0].ID != member.ID || strings.TrimSpace(envelope.Evidence[0].Source) == "" { + t.Fatalf("inline packed evidence = %#v, want qualified member with source first", envelope.Evidence) + } +} + +func TestHandleExplorePacksInlineQualifiedMemberSource(t *testing.T) { + server, store := newPHPQualifiedMemberServer(t) + member := requirePHPFixtureNode(t, store, "HipChatHandler.php", "buildContent", graph.KindMethod) + task := `HipChatHandler: Parameter validation/handling. The HipChat API has a limit of 15 characters for the "from" parameter (API docs say "less than 15"). When HipChatHandler is instantiated with a longer name, it should throw an exception or truncate to first 15 characters. Similarly, the message is limited to 10,000 characters, and HipChatHandler::buildContent() should automatically strip longer messages. Need to locate where "from" name is set/validated and where buildContent() builds the message content.` + req := mcpgo.CallToolRequest{} + req.Params.Name = "explore" + req.Params.Arguments = map[string]any{ + "task": task, "localize": true, "max_symbols": 30, "token_budget": exploreDefaultBudgetTokens, + } + result, err := server.handleExplore(WithSessionID(context.Background(), "php_qualified_member_contract"), req) + require.NoError(t, err) + require.False(t, result.IsError) + body, ok := singleTextContent(result) + require.True(t, ok) + var envelope localizationExploreEnvelope + require.NoError(t, json.Unmarshal([]byte(body), &envelope), body) + require.Equal(t, localizationStateAnswerReady, envelope.Completion.State, body) + require.True(t, envelope.Terminal, body) + require.NotEmpty(t, envelope.Evidence, body) + require.Equal(t, member.ID, envelope.Evidence[0].ID, body) + require.Contains(t, envelope.Evidence[0].Source, "http_build_query", body) + require.NotEmpty(t, envelope.Files, body) + require.NotEmpty(t, envelope.Symbols, body) + require.Equal(t, member.FilePath, envelope.Files[0], body) + require.Equal(t, member.ID, envelope.Symbols[0], body) + requireLocalizationHostContractMatchesVisible(t, result, envelope) +} + +func newPHPQualifiedMemberServer(t *testing.T) (*Server, graph.Store) { + t.Helper() + root := t.TempDir() + handlerDir := filepath.Join(root, "src", "Monolog", "Handler") + require.NoError(t, os.MkdirAll(handlerDir, 0o755)) + handler := `name = $name; + } + protected function generateDataStream(array $record): string { + return $this->buildContent($record); + } + private function buildContent(array $record): string { + $content = array('from' => $this->name, 'message' => $record['formatted']); + return http_build_query($content); + } +} +` + logger := `