diff --git a/AGENTS.md b/AGENTS.md index 1e10224c..9ecab33d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -197,3 +197,11 @@ mise run check # fmt + vet + race tests + build ``` Contract rules that must not break: schema `1.x` is frozen and additive-only (`docs/adr/0001-ga-schema-contract.md`); the provider is **no-egress** (never add remote fetches, hosted API calls, telemetry, or runtime grammar downloads); `compound-v1` symbol IDs must stay stable across ordinary edits; unsupported/unparseable files must surface as machine-readable partial failures, never silent drops. All logic lives under `internal/` (`sem` = parsing/graph/search, `cli` = hand-rolled dispatch, `gitutil` = git subprocess); `cmd/entire-graph/main.go` is a thin entry point. The plugin manifest (`entire-plugin.yml`) registers the subcommand `graph`, so users type `entire graph ...`. This project was **previously named `entire-sem`** — do not reintroduce the old name. **Entire Brain** (`entire-brain`) is the separate downstream consumer of this provider's NDJSON — not an old name for this project. + + +This repo has the entire-graph code graph installed. Before exploring code with +grep/find/whole-file reads, read .entire/graph-agent.md — the search-first, verify-once +doctrine for coding agents: search instead of grepping, then check the sibling sites and +compile (or run the nearest existing test) once before you finish. +@.entire/graph-agent.md + diff --git a/CLAUDE.md b/CLAUDE.md index 43c994c2..46fd97b4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1 +1,9 @@ @AGENTS.md + + +This repo has the entire-graph code graph installed. Before exploring code with +grep/find/whole-file reads, read .entire/graph-agent.md — the search-first, verify-once +doctrine for coding agents: search instead of grepping, then check the sibling sites and +compile (or run the nearest existing test) once before you finish. +@.entire/graph-agent.md + diff --git a/internal/cli/completeness.go b/internal/cli/completeness.go index 548109f2..f6e89b05 100644 --- a/internal/cli/completeness.go +++ b/internal/cli/completeness.go @@ -174,11 +174,25 @@ func writeScopedCompletenessBlock( if level == "" { level = "degraded" } - fmt.Fprintf(out, "Completeness: %s for %s (%d of %d %s file%s failed to parse; %d total diagnostic%s in this snapshot)\n", - level, scopeLanguageLabel(scope), - scope.LanguageFailed, scope.LanguageFiles, scopeLanguageLabel(scope), pluralSuffix(scope.LanguageFiles), - len(warnings)+len(partialFailures), pluralSuffix(len(warnings)+len(partialFailures)), - ) + // A FRACTION only when the denominator is real. `LanguageFiles` counts files that parsed, so a + // language whose files all failed reports 0 — and "35 of 0 files failed to parse" (three.js), + // "6 of 0" (terraform) is not a small error, it is a number that cannot be true and it discredits + // every other count on the line. When the denominator is missing or smaller than the numerator, the + // honest form is the count alone. + total := len(warnings) + len(partialFailures) + if scope.LanguageFiles <= 0 || scope.LanguageFailed > scope.LanguageFiles { + fmt.Fprintf(out, "Completeness: %s for %s (%d %s file%s failed to parse; %d total diagnostic%s in this snapshot)\n", + level, scopeLanguageLabel(scope), + scope.LanguageFailed, scopeLanguageLabel(scope), pluralSuffix(scope.LanguageFailed), + total, pluralSuffix(total), + ) + } else { + fmt.Fprintf(out, "Completeness: %s for %s (%d of %d %s file%s failed to parse; %d total diagnostic%s in this snapshot)\n", + level, scopeLanguageLabel(scope), + scope.LanguageFailed, scope.LanguageFiles, scopeLanguageLabel(scope), pluralSuffix(scope.LanguageFiles), + total, pluralSuffix(total), + ) + } for _, warning := range scope.InScopeWarnings { if warning.FilePath == "" { fmt.Fprintf(out, "- warning %s\n", warning.Code) diff --git a/internal/cli/completeness_test.go b/internal/cli/completeness_test.go index 97b1668b..80c86e85 100644 --- a/internal/cli/completeness_test.go +++ b/internal/cli/completeness_test.go @@ -99,7 +99,11 @@ func TestScopedCompletenessItemizesInScopeFailures(t *testing.T) { rendered := out.String() for _, want := range []string{ "degraded for Rust", - "5 of 2 Rust file", + // The fixture has 5 failures against 2 successfully-parsed files, so there IS no honest + // fraction: "5 of 2 ... failed to parse" is a number that cannot be true and it discredits + // every other count on the line (measured as "35 of 0" on three.js, "6 of 0" on terraform). + // The count alone is the truthful form. + "5 Rust files failed to parse", "broken_a.rs", "... 2 more in Rust", "plus 273 diagnostics in other languages", diff --git a/internal/cli/def.go b/internal/cli/def.go index a4672103..e81737e3 100644 --- a/internal/cli/def.go +++ b/internal/cli/def.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "sort" + "strconv" "strings" "time" "unicode/utf8" @@ -50,8 +51,11 @@ const ( ) type defFlags struct { - Repo string - Symbol string + Repo string + Symbols []string + Symbol string + // From is the line a clipped body resumes at, so the "--from N" the resume note prints is real. + From int File string Line int Kind string @@ -118,12 +122,16 @@ type defDeclaration struct { } type defResponse struct { - FormatVersion int `json:"format_version"` - RepoRoot string `json:"repo_root"` - Commit string `json:"commit,omitempty"` - Tree string `json:"tree,omitempty"` - Profile string `json:"profile,omitempty"` - Query string `json:"query"` + FormatVersion int `json:"format_version"` + RepoRoot string `json:"repo_root"` + Commit string `json:"commit,omitempty"` + Tree string `json:"tree,omitempty"` + Profile string `json:"profile,omitempty"` + Query string `json:"query"` + // FuzzyMatchKind names the rung of the fuzzy ladder these declarations came from, empty on an + // exact match. See resolveFocusSymbolsOrFuzzy: `def` answers a misspelled name rather than + // returning nothing. + FuzzyMatchKind string `json:"fuzzy_match_kind,omitempty"` Declarations []defDeclaration `json:"declarations"` DeclarationTotal int `json:"declarations_total"` Truncated bool `json:"truncated"` @@ -166,21 +174,67 @@ func runDef(ctx context.Context, opts Options, args []string) error { } indexLatency := time.Since(totalStarted) queryStarted := time.Now() - response := buildDefResponse(snapshot, flags) - response.IndexCacheHit = cacheHit - response.IndexLatencyMS = indexLatency.Milliseconds() - response.QueryLatencyMS = time.Since(queryStarted).Milliseconds() - response.TotalLatencyMS = time.Since(totalStarted).Milliseconds() - switch flags.Format { - case "json": - encoder := json.NewEncoder(opts.Stdout) - encoder.SetEscapeHTML(false) - return encoder.Encode(response) - case "text", "agent": - return writeDefText(opts.Stdout, response, flags.MaxContextBytes) - default: - return fmt.Errorf("def --format must be json, text, or agent, got %q", flags.Format) + symbols := flags.Symbols + if len(symbols) == 0 { + symbols = []string{flags.Symbol} + } + // One index build, N answers. The whole point of the multi-query form is that the second and third + // name cost a map lookup rather than another 10-second snapshot load. + for position, symbol := range symbols { + query := flags + query.Symbol = symbol + response := buildDefResponse(snapshot, query) + response.IndexCacheHit = cacheHit + response.IndexLatencyMS = indexLatency.Milliseconds() + response.QueryLatencyMS = time.Since(queryStarted).Milliseconds() + response.TotalLatencyMS = time.Since(totalStarted).Milliseconds() + switch flags.Format { + case "json": + encoder := json.NewEncoder(opts.Stdout) + encoder.SetEscapeHTML(false) + if err := encoder.Encode(response); err != nil { + return err + } + case "text", "agent": + if position > 0 { + // A separator, because several cards in one stream have to be tellable apart. + fmt.Fprintln(opts.Stdout) + } + if len(symbols) > 1 { + fmt.Fprintf(opts.Stdout, "== %s ==\n", symbol) + } + if err := writeDefText(opts.Stdout, response, flags.MaxContextBytes); err != nil { + return err + } + writeDefBodies(opts.Stdout, response, repo, query.From) + default: + return fmt.Errorf("def --format must be json, text, or agent, got %q", flags.Format) + } + } + return nil +} + +// writeDefBodies prints the SOURCE of each declaration the card describes, numbered. +// +// The card answers "what can I do with this"; agents call `def` to answer "show me the code". Measured +// on carbon: the agent got a body it could not navigate, cut it with `head -80`, lost the line it +// needed and spent 87 turns grepping instead. The card alone was never the whole answer. +func writeDefBodies(out io.Writer, response defResponse, repoRoot string, from int) { + if len(response.Declarations) == 0 || repoRoot == "" { + return + } + records := make([]sem.SymbolRecord, 0, len(response.Declarations)) + for _, declaration := range response.Declarations { + start := declaration.StartLine + if from > start { + start = from + } + records = append(records, sem.SymbolRecord{ + Name: defDisplayName(declaration), Kind: declaration.Kind, + FilePath: declaration.FilePath, StartLine: start, EndLine: declaration.EndLine, + }) } + writeSymbolMatchBodies(out, symbolMatchBodies(repoRoot, records, len(records))) } func parseDefFlags(args []string) (defFlags, error) { @@ -207,6 +261,16 @@ func parseDefFlags(args []string) (defFlags, error) { flags.File, err = value() case "--kind": flags.Kind, err = value() + // --from N resumes a body the 400-line cap clipped. It is the invocation the resume note prints, + // so the note is actionable rather than merely apologetic. + case "--from": + var raw string + if raw, err = value(); err == nil { + flags.From, err = strconv.Atoi(raw) + if err != nil || flags.From < 0 { + return flags, fmt.Errorf("def --from requires a non-negative integer, got %q", raw) + } + } case "--format": flags.Format, err = value() case "--profile": @@ -249,10 +313,13 @@ func parseDefFlags(args []string) (defFlags, error) { if strings.HasPrefix(arg, "-") { return flags, fmt.Errorf("def received unexpected argument %q", arg) } - if flags.Symbol != "" { - return flags, fmt.Errorf("def takes one name, got %q and %q", flags.Symbol, arg) + // MULTI-QUERY. Agents batch shell calls under the prompt's batching rule — laravel chained + // three greps into one Bash call — and a tool that answers one name per invocation cannot + // compete with that. `def A B C` returns each body in sequence. + flags.Symbols = append(flags.Symbols, arg) + if flags.Symbol == "" { + flags.Symbol = arg } - flags.Symbol = arg } if err != nil { return flags, err @@ -304,6 +371,7 @@ func buildDefResponse(snapshot sem.ProviderSnapshot, flags defFlags) defResponse } index := newDefIndex(snapshot) matches := index.resolve(flags) + response.FuzzyMatchKind = index.fuzzyKind groups := index.groupPartials(matches) response.DeclarationTotal = len(groups) if len(groups) > defDeclarationLimit { @@ -333,6 +401,10 @@ type defIndex struct { symbols []sem.SymbolRecord filePaths []string repoRoot string + // fuzzyKind names the rung of the fuzzy ladder that produced the matches, empty when the exact + // lookup succeeded. It is set by resolve and reported so the caller knows it did not get what it + // literally asked for. + fuzzyKind string } type defOwnedMember struct { @@ -442,6 +514,21 @@ func (index *defIndex) resolve(flags defFlags) []sem.SymbolRecord { break } } + // FIX B: every spelling of the name missed, so degrade to the fuzzy ladder rather than answering + // "(no symbol named X)". The ref is rebuilt from the caller's ORIGINAL spelling: the qualified-name + // forms above are exact-match aids and would only narrow the fuzzy search. + if len(matches) == 0 { + ref := parseSymbolRef(flags.Symbol, flags.File, flags.Line, flags.Kind, index.repoRoot, index.filePaths) + if fuzzy, tier, ok := resolveFocusSymbolsOrFuzzy(index.symbols, ref, symbolFuzzyCandidateLimit); ok { + index.fuzzyKind = tier.label() + matches = fuzzy + } + } + // A fuzzy answer keeps the relevance order resolveFocusSymbolsOrFuzzy produced; only exact matches + // are re-sorted positionally. See the same reasoning in buildNeighborResponse. + if index.fuzzyKind != "" { + return matches + } sort.Slice(matches, func(left, right int) bool { if matches[left].FilePath != matches[right].FilePath { return matches[left].FilePath < matches[right].FilePath @@ -712,6 +799,12 @@ func renderDefText(response defResponse, limit int) []byte { writeNoFocusMatch(&buffer, response.Query, "", 0) return []byte(buffer.String()) } + // A fuzzy answer says so, once, above the cards. Silently returning a different symbol's + // declaration than the one asked for would be worse than the empty answer this replaced. + if response.FuzzyMatchKind != "" { + fmt.Fprintf(&buffer, "No exact match for %q; showing the closest %d by %s match.\n", + response.Query, len(response.Declarations), response.FuzzyMatchKind) + } for index, declaration := range response.Declarations { if index > 0 { buffer.WriteByte('\n') diff --git a/internal/cli/def_navigation_test.go b/internal/cli/def_navigation_test.go new file mode 100644 index 00000000..504cd158 --- /dev/null +++ b/internal/cli/def_navigation_test.go @@ -0,0 +1,122 @@ +package cli + +import ( + "bytes" + "strconv" + "strings" + "testing" + + "github.com/entireio/entire-graph/internal/sem" +) + +// TestWriteSymbolMatchBodiesNumbersEveryLine pins the gutter. `def`/`callers`/`neighbors` answer "where +// is it", and the measured failure is navigational: carbon's agent got an unnumbered body, could not +// tell which line was which, piped it through `head -80` — which cut at the bug line — and spent 87 +// turns grepping. The SEARCH payload's bodies stay unnumbered, because an agent copies those verbatim as +// an Edit anchor and a gutter breaks the anchor. +func TestWriteSymbolMatchBodiesNumbersEveryLine(t *testing.T) { + t.Parallel() + var out bytes.Buffer + writeSymbolMatchBodies(&out, []symbolMatchBody{{ + Name: "Comparison.isLongYear", Kind: "method", FilePath: "src/Comparison.php", + StartLine: 583, EndLine: 586, + Source: "public function isLongYear()\n{\n return $this->weekOfYear === 53;\n}", + }}) + rendered := out.String() + for _, want := range []string{ + "src/Comparison.php:583-586 Comparison.isLongYear [method]", + " 583→ public function isLongYear()", + " 584→ {", + " 585→ return $this->weekOfYear === 53;", + " 586→ }", + } { + if !strings.Contains(rendered, want) { + t.Fatalf("missing %q:\n%s", want, rendered) + } + } + // The gutter is right-aligned on the widest number, so the code column does not ratchet. + var wide bytes.Buffer + writeNumberedSource(&wide, "a\nb", 999) + if !strings.Contains(wide.String(), " 999→ a\n 1000→ b\n") { + t.Fatalf("gutter is not right-aligned:\n%q", wide.String()) + } +} + +// TestSymbolMatchBodiesNeverStopMidSymbolSilently pins the cap and its resume note: 400 lines, and when +// a body is longer the note says where it continues AND the invocation that resumes it. "Output +// truncated" with no coordinates is what sent carbon to grep. +func TestSymbolMatchBodiesNeverStopMidSymbolSilently(t *testing.T) { + t.Parallel() + if symbolMatchBodyMaxLines < 400 { + t.Fatalf("cap is %d; a navigation answer that stops at 40 lines looks complete and is not", + symbolMatchBodyMaxLines) + } + // A 500-line unit: clipped at the cap, with the true end recorded. + huge := make([]string, 600) + for index := range huge { + huge[index] = "line " + strconv.Itoa(index+1) + } + root := t.TempDir() + write(t, root, "big.go", strings.Join(huge, "\n")) + bodies := symbolMatchBodies(root, []sem.SymbolRecord{{ + Name: "Huge", Kind: "function", FilePath: "big.go", StartLine: 1, EndLine: 500, + }}, 1) + if len(bodies) != 1 { + t.Fatalf("bodies = %d, want 1", len(bodies)) + } + if !bodies[0].Elided || bodies[0].UnitEndLine != 500 { + t.Fatalf("clip not recorded: %+v", bodies[0]) + } + if got := bodies[0].EndLine - bodies[0].StartLine + 1; got != symbolMatchBodyMaxLines { + t.Fatalf("printed %d lines, want the %d-line cap", got, symbolMatchBodyMaxLines) + } + var out bytes.Buffer + writeSymbolMatchBodies(&out, bodies) + want := "…continues to line 500 — rerun with --from " + strconv.Itoa(bodies[0].EndLine+1) + if !strings.Contains(out.String(), want) { + t.Fatalf("resume note missing %q:\n%s", want, out.String()[len(out.String())-200:]) + } + // A unit that FITS says nothing: the note's presence has to mean something. + small := symbolMatchBodies(root, []sem.SymbolRecord{{ + Name: "Small", FilePath: "big.go", StartLine: 1, EndLine: 3, + }}, 1) + var fits bytes.Buffer + writeSymbolMatchBodies(&fits, small) + if strings.Contains(fits.String(), "continues to line") { + t.Fatalf("a complete body claimed a continuation:\n%s", fits.String()) + } +} + +// TestSearchLocatorFollowUpNamesTheFetchingVerb pins fix 2, including the case where it must stay +// silent: a hit with no symbol name has no `def` argument, and naming a verb that cannot be run is +// worse than saying nothing. +func TestSearchLocatorFollowUpNamesTheFetchingVerb(t *testing.T) { + t.Parallel() + named := sem.SearchResult{ + Rank: 5, FilePath: "src/t_zset.c", StartLine: 3788, FocusLine: 3788, + SymbolName: "genericZpopCommand", + } + var out bytes.Buffer + writeTextSearchLocator(&out, named) + want := "5. src/t_zset.c:3788 genericZpopCommand [body: def genericZpopCommand]\n" + if out.String() != want { + t.Fatalf("locator = %q, want %q", out.String(), want) + } + // ~22 bytes on a bodyless hit, nothing on a bodied one. + if cost := len(searchLocatorFollowUp(named)); cost > 40 { + t.Fatalf("suffix costs %d bytes; it is meant to be cheap", cost) + } + if got := searchLocatorFollowUp(sem.SearchResult{FilePath: "a.go", StartLine: 1}); got != "" { + t.Fatalf("a nameless hit suggested %q", got) + } + // A BODIED hit carries no suffix: it already has what the verb would fetch. + var bodied bytes.Buffer + writeTextSearchResult(&bodied, sem.SearchResult{ + Rank: 1, FilePath: "a.go", StartLine: 1, EndLine: 2, FocusLine: 1, + SnippetStartLine: 1, SnippetEndLine: 2, Snippet: "func a() {}\n}", SymbolName: "a", + Signals: []string{sem.CompleteSymbolSignal}, + }, true) + if strings.Contains(bodied.String(), "[body: def") { + t.Fatalf("a bodied hit carried a follow-up suffix:\n%s", bodied.String()) + } +} diff --git a/internal/cli/def_test.go b/internal/cli/def_test.go index 0c6cb245..57eef378 100644 --- a/internal/cli/def_test.go +++ b/internal/cli/def_test.go @@ -258,7 +258,21 @@ func TestParseDefFlags(t *testing.T) { }, }, {name: "no name", args: nil, wantErr: true}, - {name: "two names", args: []string{"Edit", "Fix"}, wantErr: true}, + // SUPERSEDED: `def A B` is now the multi-query form. Agents batch shell calls under the prompt's + // batching rule (laravel chained three greps into one Bash call), and a tool that answers one + // name per invocation cannot compete with that. + { + name: "two names is a multi-query", args: []string{"Edit", "Fix"}, wantErr: false, + check: func(flags defFlags) string { + if len(flags.Symbols) != 2 || flags.Symbols[0] != "Edit" || flags.Symbols[1] != "Fix" { + return "both names must survive parsing" + } + if flags.Symbol != "Edit" { + return "Symbol must stay the first name for single-query callers" + } + return "" + }, + }, {name: "unknown flag", args: []string{"Edit", "--nope"}, wantErr: true}, {name: "missing value", args: []string{"--symbol"}, wantErr: true}, {name: "zero members", args: []string{"Edit", "--members", "0"}, wantErr: true}, diff --git a/internal/cli/env.go b/internal/cli/env.go index 30caa582..b9e3a5f8 100644 --- a/internal/cli/env.go +++ b/internal/cli/env.go @@ -13,6 +13,22 @@ const ( // interactive user does not have to add flags to every call. Its value is a comma-separated list // of `container-map`, `signature-types`, `type-card`, or `all`. See searchReferenceBlocks. envReferenceBlocks = "ENTIRE_GRAPH_REFERENCE_BLOCKS" + // envPresearch names a file holding the payload for this session, computed BEFORE the agent + // started. When it is set, `search` echoes those bytes instead of querying — see + // echoPresearchPayload for the measurement that motivates it. + // + // envPresearchAlias is the same knob under the prefix the benchmark harness already uses for + // every one of its search knobs (EG_TOPK, EG_DEEP, EG_MAXBYTES, EG_PROFILE), so the harness can + // set it beside them; the ENTIRE_GRAPH_ name is the one this repo documents, and it wins. + envPresearch = "ENTIRE_GRAPH_PRESEARCH" + envPresearchAlias = "EG_PRESEARCH" + // envSearchSession names the file that carries ONE task's search state between calls. The CLI is + // one-shot, so nothing else can tell the second search of a task from the first; setting it is + // what turns the search echo on. See searchSession. + envSearchSession = "EG_SEARCH_SESSION" + // envMaxSearches is how many searches of that session actually run a query (default 1, `0` + // disables the echo). See searchSession for the measurement. + envMaxSearches = "EG_MAX_SEARCHES" ) // cacheDirName is this provider's directory inside the platform's per-user cache @@ -58,14 +74,28 @@ type EntireEnv struct { PluginDataDir string // ReferenceBlocks is the session-wide default for the off-by-default search reference blocks. ReferenceBlocks string + // PresearchPath is the file holding this session's pre-computed search payload, or "" when the + // caller has not pre-delivered one. See envPresearch. + PresearchPath string + // SearchSession is the state file for one task's searches; empty means the echo is off. + SearchSession string + // MaxSearches is how many of that task's searches run a query; empty means the default of 1. + MaxSearches string } func EnvFromOS() EntireEnv { + presearch := os.Getenv(envPresearch) + if presearch == "" { + presearch = os.Getenv(envPresearchAlias) + } return EntireEnv{ CLIVersion: os.Getenv(envCLIVersion), RepoRoot: os.Getenv(envRepoRoot), PluginDataDir: os.Getenv(envPluginDataDir), ReferenceBlocks: os.Getenv(envReferenceBlocks), + PresearchPath: presearch, + SearchSession: os.Getenv(envSearchSession), + MaxSearches: os.Getenv(envMaxSearches), } } diff --git a/internal/cli/help.go b/internal/cli/help.go index 892a6ff5..6659df91 100644 --- a/internal/cli/help.go +++ b/internal/cli/help.go @@ -293,6 +293,27 @@ var commandDocs = []commandDoc{ }, examples: []string{"entire graph checkpoint abc123 --json"}, }, + { + name: "verify", + group: groupAnalyze, + summary: "Run a test command and return an adjudicated verdict, not test output", + usage: []string{`entire graph verify --test "" --repo . [--setup ""] [--record-baseline path | --pre-edit-baseline path] [--max-bytes 2048]`}, + long: "verify runs your test command and reports WHICH TESTS CHANGED rather than what the runner printed: which newly pass, which newly fail, and which were ALREADY failing before the edit (labelled PRE-EXISTING). Raw runner output is never forwarded — ids are, text is not — and id lists cap at 20 with a count.\n\n" + + "Record a baseline on the pristine tree first (--record-baseline), then pass that file as --pre-edit-baseline after editing. Without a baseline the verdict is a state rather than a delta, so a failure that predates the change cannot be labelled as one.\n\n" + + "Parsers: pytest, jest/vitest, cargo test, go test, phpunit, rspec, minitest, maven/gradle surefire, ctest. An unrecognised format degrades to an exit-code-only verdict and says so.", + flags: []flagDoc{ + {name: "--test", arg: "cmd", desc: "The test command to run (required)"}, + {name: "--repo", arg: "path", desc: "Repository to run in (default: current repo)"}, + {name: "--setup", arg: "cmd", desc: "Command run before the tests; its output never contributes test ids"}, + {name: "--record-baseline", arg: "path", desc: "Write the pristine-tree result to this file instead of adjudicating"}, + {name: "--pre-edit-baseline", arg: "path", desc: "Diff this run against a previously recorded baseline"}, + {name: "--max-bytes", arg: "n", def: "2048", desc: "Cap the rendered verdict; the verdict clause always survives"}, + }, + examples: []string{ + `entire graph verify --repo . --test "pytest tests/test_parser.py" --record-baseline /tmp/base.json`, + `entire graph verify --repo . --test "pytest tests/test_parser.py" --pre-edit-baseline /tmp/base.json`, + }, + }, { name: "stats", group: groupAnalyze, @@ -323,12 +344,17 @@ var commandDocs = []commandDoc{ name: "doctor", group: groupMeta, summary: "Diagnose the environment and confirm no-egress", - usage: []string{"entire graph doctor [--json]"}, - long: "Reports the resolved repo, the Entire environment variables, plugin-data-dir writability, and confirms no_egress=true (no remote fetches, hosted APIs, telemetry, or grammar downloads).", + usage: []string{`entire graph doctor [--json] [--assert ""]`}, + long: "Reports the resolved repo, the Entire environment variables, plugin-data-dir writability, and confirms no_egress=true (no remote fetches, hosted APIs, telemetry, or grammar downloads).\n\n" + + "--assert is the preflight: it parses a command line against this binary and exits non-zero if this binary would reject it, WITHOUT running anything — no repo read, no index build, no writes. Run it once before a batch or a benchmark cell so a flag set built for a different build fails at startup instead of failing the first call of every session and leaving an agent to explore by hand. Repeatable. It runs each command's real parser, so a command that requires a flag will say so; assert the command line you actually intend to run.", flags: []flagDoc{ {name: "--json", desc: "Emit the report as JSON"}, + {name: "--assert", arg: "cmdline", desc: "Verify this binary accepts a command line, without running it (repeatable)"}, + }, + examples: []string{ + "entire graph doctor --json", + `entire graph doctor --assert "search --profile full --top-k 10 --format text"`, }, - examples: []string{"entire graph doctor --json"}, }, { name: "version", @@ -362,6 +388,43 @@ var commonFlagDocs = []flagDoc{ {name: "--repo", arg: "path", desc: "Repository (default: current repo)"}, } +// unexpectedArgumentsError explains an argument the parser did not recognise, and says the one +// thing that is usually true when the argument is flag-shaped: this binary is older than whatever +// wrote the command line. +// +// The old message ("search received unexpected arguments: --callee-hop") describes the symptom and +// not the cause, and the difference matters because of WHO reads it. A harness driving a flag set +// built for a newer binary gets an exit 1 and an empty payload on every single call, for the whole +// run — the agent's first mandated action fails, it falls back to exploring by hand, and the cell +// silently measures a graph arm that never reached the graph. Naming the version turns a run that +// looks broken into a run that reads as version skew, at the first call rather than the last. +// +// A positional argument keeps the plain wording: `search foo` is a typo, not a stale deploy, and +// telling its author about binary versions would be noise. +func unexpectedArgumentsError(command, version string, rest []string) error { + var flags []string + for _, arg := range rest { + if strings.HasPrefix(arg, "-") && arg != "-" { + flags = append(flags, arg) + } + } + if len(flags) == 0 { + return fmt.Errorf("%s received unexpected arguments: %s", command, strings.Join(rest, " ")) + } + return fmt.Errorf( + "%s does not accept %s in entire-graph %s: this binary may be older than the caller that built the command line; run \"entire graph %s --help\" for the flags it does accept (unexpected: %s)", + command, strings.Join(flags, " "), versionOrUnknown(version), command, strings.Join(rest, " ")) +} + +// versionOrUnknown keeps the message honest when the binary was built without a version stamped in: +// printing an empty string beside "entire-graph" reads as a version rather than the absence of one. +func versionOrUnknown(version string) string { + if strings.TrimSpace(version) == "" { + return "(unknown version)" + } + return version +} + // findCommandDoc returns the doc for a command name, resolving aliases. func findCommandDoc(name string) (commandDoc, bool) { for _, d := range commandDocs { diff --git a/internal/cli/help_test.go b/internal/cli/help_test.go index d31d7ce6..7bf2b1fa 100644 --- a/internal/cli/help_test.go +++ b/internal/cli/help_test.go @@ -14,7 +14,42 @@ import ( var dispatchCommands = []string{ "diff", "commit", "checkpoint", "analyze", "doctor", "capabilities", "snapshot", "symbols", "edges", "search", "index", "def", "neighbors", - "impact", "stats", "agent-guide", "init-agents", "version", "help", + "impact", "verify", "stats", "agent-guide", "init-agents", "version", "help", +} + +// TestUnknownFlagNamesTheVersion pins that a flag-shaped argument this binary does not know reads +// as version skew rather than as a broken tool. +// +// This is the third way a benchmark cell can silently stop calling the graph: a harness whose flag +// set was built for a newer binary gets exit 1 and an empty payload on EVERY call, the agent's +// first mandated action fails, and the whole run measures a graph arm that never reached the graph. +// The message is the only place that failure can be diagnosed from a transcript. +func TestUnknownFlagNamesTheVersion(t *testing.T) { + t.Parallel() + repo := t.TempDir() + write(t, repo, "alpha.py", "def alpha_widget():\n return True\n") + + err := Run(t.Context(), Options{Version: "0.9.9", Env: EntireEnv{RepoRoot: repo}, Stdout: &bytes.Buffer{}}, + []string{"search", "--repo", repo, "--query", "alpha", "--flag-from-a-newer-build"}) + if err == nil { + t.Fatal("an unknown flag was accepted") + } + for _, want := range []string{"0.9.9", "--flag-from-a-newer-build", "--help", "older"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("error does not mention %q: %v", want, err) + } + } + + // A positional argument is a typo, not a stale deploy, and keeps the plain wording — otherwise + // every mistyped command would start advertising version numbers. + err = Run(t.Context(), Options{Version: "0.9.9", Env: EntireEnv{RepoRoot: repo}, Stdout: &bytes.Buffer{}}, + []string{"search", "--repo", repo, "--query", "alpha", "stray"}) + if err == nil { + t.Fatal("a stray positional argument was accepted") + } + if !strings.Contains(err.Error(), "unexpected arguments") || strings.Contains(err.Error(), "0.9.9") { + t.Fatalf("positional argument did not keep the plain wording: %v", err) + } } // TestRegistryMatchesDispatch enforces that the help registry and the dispatch diff --git a/internal/cli/impact.go b/internal/cli/impact.go index fa8d43fa..03764aab 100644 --- a/internal/cli/impact.go +++ b/internal/cli/impact.go @@ -78,21 +78,30 @@ type impactSection struct { } type impactResponse struct { - FormatVersion int `json:"format_version"` - RepoRoot string `json:"repo_root"` - Commit string `json:"commit,omitempty"` - Tree string `json:"tree,omitempty"` - Profile string `json:"profile"` - Query string `json:"query"` - File string `json:"file,omitempty"` - Line int `json:"line,omitempty"` - Depth int `json:"depth"` - IndexCacheHit bool `json:"index_cache_hit"` - IndexCacheDisabled bool `json:"index_cache_disabled,omitempty"` - IndexLatencyMS int64 `json:"index_latency_ms"` - QueryLatencyMS int64 `json:"query_latency_ms"` - TotalLatencyMS int64 `json:"total_latency_ms"` - FocusMatchesTotal int `json:"focus_matches_total"` + FormatVersion int `json:"format_version"` + RepoRoot string `json:"repo_root"` + Commit string `json:"commit,omitempty"` + Tree string `json:"tree,omitempty"` + Profile string `json:"profile"` + Query string `json:"query"` + File string `json:"file,omitempty"` + Line int `json:"line,omitempty"` + Depth int `json:"depth"` + IndexCacheHit bool `json:"index_cache_hit"` + IndexCacheDisabled bool `json:"index_cache_disabled,omitempty"` + IndexLatencyMS int64 `json:"index_latency_ms"` + QueryLatencyMS int64 `json:"query_latency_ms"` + TotalLatencyMS int64 `json:"total_latency_ms"` + FocusMatchesTotal int `json:"focus_matches_total"` + // See neighborResponse for what these three report; impact resolves its focus through the same + // shared resolver, so it answers a misspelled or ambiguous query the same way. + FuzzyMatch bool `json:"fuzzy_match,omitempty"` + FuzzyMatchKind string `json:"fuzzy_match_kind,omitempty"` + // Degenerate reports that the answer carries no blast radius worth reading, with the reason. It is + // the same verdict the text marker prints, so a JSON consumer can act on it without parsing text. + Degenerate bool `json:"degenerate,omitempty"` + DegenerateReason string `json:"degenerate_reason,omitempty"` + MatchBodies []symbolMatchBody `json:"match_bodies,omitempty"` DisambiguationRequired bool `json:"disambiguation_required"` Definitions []neighborEndpoint `json:"definitions,omitempty"` Focus *neighborEndpoint `json:"focus,omitempty"` @@ -141,6 +150,11 @@ func runImpact(ctx context.Context, opts Options, args []string) error { queryStarted := time.Now() response := buildImpactResponse(snapshot, flags) annotateImpactCallSites(&response, newRepoLineReader(snapshot.Header.RepoRoot)) + // The verdict is computed once, on the finished response, so the text marker and the JSON fields + // can never disagree about whether this answer was worth reading. + if reason := impactDegenerateReason(response); reason != "" { + response.Degenerate, response.DegenerateReason = true, reason + } queryLatency := time.Since(queryStarted) response.IndexCacheHit = cacheHit response.IndexCacheDisabled = cacheDir == "" || flags.DisableCache @@ -326,16 +340,23 @@ func buildImpactResponse(snapshot sem.ProviderSnapshot, flags impactFlags) impac } ref := parseSymbolRef(flags.Symbol, flags.File, flags.Line, flags.Kind, snapshot.Header.RepoRoot, snapshotFilePaths(snapshot)) - focuses := resolveFocusSymbols(snapshot.Symbols, ref) - sort.Slice(focuses, func(left, right int) bool { - if focuses[left].FilePath != focuses[right].FilePath { - return focuses[left].FilePath < focuses[right].FilePath - } - if focuses[left].StartLine != focuses[right].StartLine { - return focuses[left].StartLine < focuses[right].StartLine - } - return focuses[left].ID < focuses[right].ID - }) + focuses, matchTier, fuzzyMatch := resolveFocusSymbolsOrFuzzy(snapshot.Symbols, ref, symbolFuzzyCandidateLimit) + // File/line order is right for EXACT matches — several definitions of one name are equally valid + // answers, so a stable positional order is the honest presentation. It is wrong for a FUZZY answer, + // where the order IS the answer: resolveFocusSymbolsOrFuzzy already sorted by how well each + // candidate matched, and re-sorting alphabetically buried the correct + // `Functions.flattenSingleValue` under a `Single` class that merely shares one token. + if !fuzzyMatch { + sort.Slice(focuses, func(left, right int) bool { + if focuses[left].FilePath != focuses[right].FilePath { + return focuses[left].FilePath < focuses[right].FilePath + } + if focuses[left].StartLine != focuses[right].StartLine { + return focuses[left].StartLine < focuses[right].StartLine + } + return focuses[left].ID < focuses[right].ID + }) + } partialFailures := snapshot.Header.PartialFailures if partialFailures == nil { @@ -352,6 +373,14 @@ func buildImpactResponse(snapshot sem.ProviderSnapshot, flags impactFlags) impac Line: ref.Line, Depth: flags.Depth, FocusMatchesTotal: len(focuses), + FuzzyMatch: fuzzyMatch, + FuzzyMatchKind: fuzzyKindLabel(fuzzyMatch, matchTier), + MatchBodies: func() []symbolMatchBody { + if !fuzzyMatch && len(focuses) <= 1 { + return nil + } + return symbolMatchBodies(snapshot.Header.RepoRoot, focuses, symbolAmbiguousBodyLimit) + }(), Warnings: snapshot.Header.Warnings, PartialFailures: partialFailures, Stats: snapshot.Header.Stats, @@ -708,8 +737,23 @@ func writeImpactText(out io.Writer, response impactResponse) { writeNoFocusMatch(out, response.Query, response.File, response.Line) return } - if response.DisambiguationRequired { - writeDisambiguationListing(out, response.Query, response.FocusMatchesTotal, response.Definitions) + if response.FuzzyMatch { + writeFuzzyMatchListing(out, response.Query, symbolMatchTierFromLabel(response.FuzzyMatchKind), + response.Definitions, response.MatchBodies) + if response.DisambiguationRequired { + return + } + } else if response.DisambiguationRequired { + writeDisambiguationListing(out, response.Query, response.FocusMatchesTotal, response.Definitions, + response.MatchBodies) + return + } + + // A degenerate answer says so in one line instead of printing zero-filled scaffolding that reads + // like an answer. The verdict is the one computed on the finished response, so text and JSON agree. + // See the block at the bottom of this file for the measurement. + if response.Degenerate { + writeImpactDegenerate(out, response, response.DegenerateReason) return } @@ -804,3 +848,117 @@ func writeImpactSection(out io.Writer, header string, section impactSection, arr fmt.Fprintf(out, "- ... +%d more (use --format json for the full list)\n", omitted) } } + +// DEGENERATE IMPACT +// ================= +// +// `impact` on an anchor with no relations still printed the full scaffolding: a focus line, a blast +// radius reading all zeros, and one empty section header per relation kind. Measured over 8 sessions, +// every one of the 3 retrieval-miss instances produced exactly that — briannesbitt__carbon-2752 +// (`isLongYear`, 0 callers / 0 callees), prometheus (`Config.ScrapeConfigs`, 0/0/0) and three.js +// (hits only under `build/`) — and 0 of the 5 instances whose payload actually hit produced it. The +// scaffolding is expensive in the only currency that matters here: it looks like an answer, so the +// agent reads it, and the bytes are replayed on every later turn for nothing. +// +// So a degenerate result says so in ONE machine-readable line. The marker is for the caller as much as +// the reader: a harness can key on it to drop the impact payload entirely and fall back to a lean +// prefetch, which is not something it can do from "Blast radius: 0 callers, 0 callees". +const ( + // impactDegenerateMarker is the stable prefix a consumer matches on. It is a constant because a + // harness greps for it; changing the wording after the prefix is safe, changing the prefix is not. + impactDegenerateMarker = "IMPACT DEGENERATE" + + // impactDegenerateNoRelations is the 0/0/0 case: the graph holds the symbol but nothing reaches it + // and it reaches nothing, so there is no blast radius to report. + impactDegenerateNoRelations = "no callers, callees or type consumers" + + // impactDegenerateBundleOnly is the case where every hit is in built or vendored output. Such a + // path is not a fix site: editing it is overwritten by the next build. + impactDegenerateBundleOnly = "every relation lands in built or vendored output" +) + +// impactBundlePathSegments are the directory names that mark generated or vendored output. Kept +// deliberately short and unambiguous — a false positive here suppresses a real answer, so the list +// holds only names whose contents are by definition not hand-edited. +var impactBundlePathSegments = []string{ + "/build/", "/dist/", "/vendor/", "/node_modules/", "/target/", "/out/", "/.next/", "/coverage/", +} + +// impactBundlePath reports whether a path is generated or vendored output. +func impactBundlePath(filePath string) bool { + if filePath == "" { + return false + } + lower := "/" + strings.ToLower(strings.ReplaceAll(filePath, "\\", "/")) + for _, segment := range impactBundlePathSegments { + if strings.Contains(lower, segment) { + return true + } + } + return strings.HasSuffix(lower, ".min.js") || strings.HasSuffix(lower, ".bundle.js") +} + +// impactDegenerateReason returns the reason an impact answer is degenerate, or "" when it is a real +// answer. Sections beyond the three structural ones are deliberately NOT consulted: co-change and +// sibling entries are heuristics, and a payload carrying only those has still told the caller nothing +// about what its change breaks. +func impactDegenerateReason(response impactResponse) string { + if response.Focus == nil { + // An AMBIGUOUS anchor is normally answered rather than suppressed (see FIX A in symbolref.go), + // but not when every definition it found is generated output. That is the measured three.js + // case: `WebGLRenderer` resolves to 7 definitions, and the ones outside src/ are copies inside + // build/three.module.js and build/three.cjs. Listing bundle copies with their bodies would + // spend the payload on code no patch can land in. + if len(response.Definitions) == 0 { + return "" + } + for _, definition := range response.Definitions { + if definition.FilePath == "" || !impactBundlePath(definition.FilePath) { + return "" + } + } + return impactDegenerateBundleOnly + } + // The gate is callers + callees + CO-CHANGE, not callers + callees + type consumers. Turn-level + // forensics of 12 sessions found impact asserting authority on the wrong symbol in 11 of them, and + // the shape was always the same: a section set that is empty except for a heuristic one, printed as + // though it were a blast radius. Co-change joins the numerator because it is the one heuristic that + // names FILES a reader can act on; type consumers stay in it because they are structural. + structural := response.Callers.Total + response.Callees.Total + + response.TypeConsumers.Total + response.CoChanges.Total + if structural == 0 { + return impactDegenerateNoRelations + } + // Every hit in built output, and there has to BE at least one hit — an all-empty section set is the + // case above, not this one. + seen := false + for _, section := range []impactSection{ + response.Callers, response.Callees, response.TypeConsumers, response.DataFlows, + } { + for _, entry := range section.Entries { + if entry.Endpoint.FilePath == "" { + continue + } + seen = true + if !impactBundlePath(entry.Endpoint.FilePath) { + return "" + } + } + } + if seen { + return impactDegenerateBundleOnly + } + return "" +} + +// writeImpactDegenerate emits the marker. One line, prefix first, so a consumer can match it without +// parsing anything else, and the anchor is named so a human still knows which query it answers. +func writeImpactDegenerate(out io.Writer, response impactResponse, reason string) { + name := response.Query + if response.Focus != nil { + if display := endpointDisplayName(*response.Focus); display != "" { + name = display + } + } + fmt.Fprintf(out, "%s: %s has %s\n", impactDegenerateMarker, name, reason) +} diff --git a/internal/cli/impact_test.go b/internal/cli/impact_test.go index 9d9a577c..82a14424 100644 --- a/internal/cli/impact_test.go +++ b/internal/cli/impact_test.go @@ -141,8 +141,8 @@ func TestImpactAmbiguousSymbolListsDefinitionsAndFileDisambiguates(t *testing.T) } var text bytes.Buffer writeImpactText(&text, ambiguous) - if !strings.Contains(text.String(), `Ambiguous symbol "Target" matched 2 definitions`) || - !strings.Contains(text.String(), "rerun with the selector printed beside the one you mean") || + if !strings.Contains(text.String(), `"Target" matches 2 definitions`) || + strings.Contains(text.String(), "rerun with the selector") || !strings.Contains(text.String(), "Target (a.go:9)") || !strings.Contains(text.String(), "--symbol Target --file a.go --line 9") { t.Fatalf("ambiguous text output:\n%s", text.String()) @@ -334,3 +334,117 @@ func route() { t.Fatalf("second impact run missed the index cache: %#v", response.Stats) } } + +// TestImpactDegenerateReplacesEmptyScaffolding is FIX 2. Measured over 8 sessions: all 3 +// retrieval-miss instances produced a zero-filled blast radius that reads like an answer +// (carbon-2752 isLongYear 0/0, prometheus Config.ScrapeConfigs 0/0/0, three.js build/*-only) and 0 of +// the 5 payload-hit instances did. The marker is what lets a harness drop the block entirely. +func TestImpactDegenerateReplacesEmptyScaffolding(t *testing.T) { + t.Parallel() + focus := neighborEndpoint{Name: "isLongYear", QualifiedName: "Carbon.isLongYear", + Kind: "method", FilePath: "src/Carbon/Traits/Date.php", StartLine: 12} + for _, testCase := range []struct { + name string + response impactResponse + want string + }{ + { + name: "nothing reaches it and it reaches nothing", + response: impactResponse{Query: "isLongYear", Focus: &focus}, + want: impactDegenerateNoRelations, + }, + { + name: "every relation lands in built output", + response: impactResponse{Query: "WebGLRenderer", Focus: &focus, + Callers: impactSection{Total: 2, Entries: []impactEntry{ + {Endpoint: neighborEndpoint{FilePath: "build/three.module.js", StartLine: 9}}, + {Endpoint: neighborEndpoint{FilePath: "build/three.cjs", StartLine: 4}}, + }}}, + want: impactDegenerateBundleOnly, + }, + { + name: "one real caller among built output is a real answer", + response: impactResponse{Query: "WebGLRenderer", Focus: &focus, + Callers: impactSection{Total: 2, Entries: []impactEntry{ + {Endpoint: neighborEndpoint{FilePath: "build/three.module.js", StartLine: 9}}, + {Endpoint: neighborEndpoint{FilePath: "src/renderers/WebGLRenderer.js", StartLine: 40}}, + }}}, + want: "", + }, + { + name: "a single caller is enough to be worth reading", + response: impactResponse{Query: "isLongYear", Focus: &focus, + Callers: impactSection{Total: 1, Entries: []impactEntry{ + {Endpoint: neighborEndpoint{FilePath: "src/Carbon/Carbon.php", StartLine: 88}}, + }}}, + want: "", + }, + { + // SUPERSEDES the round-4 expectation, which said co-change does not rescue a degenerate + // answer. Turn-level forensics of 12 sessions moved co-change INTO the numerator: it is the + // one heuristic section that names FILES a reader can act on, so a payload carrying it has + // told the caller something. Siblings alone still do not. + name: "co-change files are actionable, so they do rescue it", + response: impactResponse{Query: "isLongYear", Focus: &focus, + CoChanges: impactSection{Total: 3, Entries: []impactEntry{ + {Endpoint: neighborEndpoint{FilePath: "src/Carbon/Carbon.php"}}, + }}}, + want: "", + }, + { + name: "siblings alone do not rescue it", + response: impactResponse{Query: "isLongYear", Focus: &focus, + Siblings: impactSection{Total: 4, Entries: []impactEntry{ + {Endpoint: neighborEndpoint{FilePath: "src/Carbon/Traits/Date.php"}}, + }}}, + want: impactDegenerateNoRelations, + }, + } { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + got := impactDegenerateReason(testCase.response) + if got != testCase.want { + t.Fatalf("reason = %q, want %q", got, testCase.want) + } + var out bytes.Buffer + writeImpactDegenerate(&out, testCase.response, got) + if testCase.want == "" { + return + } + rendered := strings.TrimRight(out.String(), "\n") + if strings.Count(rendered, "\n") != 0 { + t.Fatalf("marker is not a single line:\n%s", rendered) + } + if !strings.HasPrefix(rendered, impactDegenerateMarker+": ") { + t.Fatalf("marker prefix is not machine-matchable: %q", rendered) + } + if !strings.Contains(rendered, testCase.want) { + t.Fatalf("marker omits the reason: %q", rendered) + } + }) + } +} + +// TestImpactBundlePathNamesOnlyGeneratedOutput guards the predicate: a false positive here suppresses +// a real answer, so only names whose contents are by definition not hand-edited may match. +func TestImpactBundlePathNamesOnlyGeneratedOutput(t *testing.T) { + t.Parallel() + for _, path := range []string{ + "build/three.module.js", "dist/index.js", "vendor/autoload.php", + "node_modules/lib/x.js", "target/classes/A.class", "js/app.min.js", "web/app.bundle.js", + } { + if !impactBundlePath(path) { + t.Fatalf("%q was not recognised as generated output", path) + } + } + for _, path := range []string{ + "src/renderers/WebGLRenderer.js", "src/Carbon/Carbon.php", "internal/sem/search.go", + // "builder" and "distance" merely CONTAIN the segment names; matching them would suppress + // real answers in ordinary source. + "src/builder/Assembler.java", "lib/distance/haversine.rb", "pkg/outbound/client.go", + } { + if impactBundlePath(path) { + t.Fatalf("%q was wrongly treated as generated output", path) + } + } +} diff --git a/internal/cli/index.go b/internal/cli/index.go index 809a53ec..482effe9 100644 --- a/internal/cli/index.go +++ b/internal/cli/index.go @@ -7,7 +7,6 @@ import ( "fmt" "io" "os" - "strings" "time" "github.com/entireio/entire-graph/internal/sem" @@ -52,7 +51,7 @@ func runIndex(ctx context.Context, opts Options, args []string) error { return err } if len(rest) != 0 { - return fmt.Errorf("index received unexpected arguments: %s", strings.Join(rest, " ")) + return unexpectedArgumentsError("index", opts.Version, rest) } // Format resolution: an explicit --format wins; otherwise pick by audience — // a human at a terminal gets the readable summary, a pipe/CI gets the diff --git a/internal/cli/neighbors.go b/internal/cli/neighbors.go index a6821228..5f351442 100644 --- a/internal/cli/neighbors.go +++ b/internal/cli/neighbors.go @@ -103,12 +103,19 @@ type neighborResponse struct { // index the WHOLE repository (search only parses its candidate files), which // makes that difference tens of seconds on a large repo — a cost the CLI // surface otherwise never states. - IndexCacheDisabled bool `json:"index_cache_disabled,omitempty"` - IndexLatencyMS int64 `json:"index_latency_ms"` - QueryLatencyMS int64 `json:"query_latency_ms"` - TotalLatencyMS int64 `json:"total_latency_ms"` - Truncated bool `json:"truncated"` - FocusMatchesTotal int `json:"focus_matches_total"` + IndexCacheDisabled bool `json:"index_cache_disabled,omitempty"` + IndexLatencyMS int64 `json:"index_latency_ms"` + QueryLatencyMS int64 `json:"query_latency_ms"` + TotalLatencyMS int64 `json:"total_latency_ms"` + Truncated bool `json:"truncated"` + FocusMatchesTotal int `json:"focus_matches_total"` + // FuzzyMatch reports that no EXACT match existed and these candidates came from the fuzzy ladder. + // FuzzyMatchKind names the rung, so a consumer is never misled about what it asked for. + FuzzyMatch bool `json:"fuzzy_match,omitempty"` + FuzzyMatchKind string `json:"fuzzy_match_kind,omitempty"` + // MatchBodies carries source for the first few matched definitions, so an ambiguous or fuzzy + // answer removes the follow-up read instead of prescribing one. + MatchBodies []symbolMatchBody `json:"match_bodies,omitempty"` FocusMatchesTruncated bool `json:"focus_matches_truncated"` DisambiguationRequired bool `json:"disambiguation_required"` Matches []neighborFocus `json:"matches"` @@ -326,17 +333,32 @@ func buildNeighborResponse(snapshot sem.ProviderSnapshot, flags neighborFlags) n endpoints[external.ID] = endpointForExternal(external) } ref := parseSymbolRef(flags.Symbol, flags.File, flags.Line, flags.Kind, snapshot.Header.RepoRoot, snapshotFilePaths(snapshot)) - focuses := resolveFocusSymbols(snapshot.Symbols, ref) - sort.Slice(focuses, func(left, right int) bool { - if focuses[left].FilePath != focuses[right].FilePath { - return focuses[left].FilePath < focuses[right].FilePath - } - if focuses[left].StartLine != focuses[right].StartLine { - return focuses[left].StartLine < focuses[right].StartLine - } - return focuses[left].ID < focuses[right].ID - }) + // FIX B: an exact miss degrades to the fuzzy ladder rather than returning nothing. See + // resolveFocusSymbolsOrFuzzy for the measured cost of the empty answer. + focuses, matchTier, fuzzyMatch := resolveFocusSymbolsOrFuzzy( + snapshot.Symbols, ref, symbolFuzzyCandidateLimit, + ) + // File/line order is right for EXACT matches — several definitions of one name are equally valid + // answers, so a stable positional order is the honest presentation. It is wrong for a FUZZY answer, + // where the order IS the answer: resolveFocusSymbolsOrFuzzy already sorted by how well each + // candidate matched, and re-sorting alphabetically buried the correct + // `Functions.flattenSingleValue` under a `Single` class that merely shares one token. + if !fuzzyMatch { + sort.Slice(focuses, func(left, right int) bool { + if focuses[left].FilePath != focuses[right].FilePath { + return focuses[left].FilePath < focuses[right].FilePath + } + if focuses[left].StartLine != focuses[right].StartLine { + return focuses[left].StartLine < focuses[right].StartLine + } + return focuses[left].ID < focuses[right].ID + }) + } focusMatchesTotal := len(focuses) + matchBodies := []symbolMatchBody(nil) + if fuzzyMatch || focusMatchesTotal > 1 { + matchBodies = symbolMatchBodies(snapshot.Header.RepoRoot, focuses, symbolAmbiguousBodyLimit) + } focusMatchesTruncated := focusMatchesTotal > flags.Limit if focusMatchesTruncated { focuses = focuses[:flags.Limit] @@ -347,6 +369,8 @@ func buildNeighborResponse(snapshot sem.ProviderSnapshot, flags neighborFlags) n } response := neighborResponse{ FormatVersion: 1, + FuzzyMatch: fuzzyMatch, + FuzzyMatchKind: fuzzyKindLabel(fuzzyMatch, matchTier), RepoRoot: snapshot.Header.RepoRoot, Commit: snapshot.Header.Commit, Tree: snapshot.Header.Tree, @@ -359,6 +383,7 @@ func buildNeighborResponse(snapshot sem.ProviderSnapshot, flags neighborFlags) n Truncated: focusMatchesTruncated, FocusMatchesTotal: focusMatchesTotal, FocusMatchesTruncated: focusMatchesTruncated, + MatchBodies: matchBodies, Matches: make([]neighborFocus, 0, len(focuses)), Warnings: snapshot.Header.Warnings, PartialFailures: partialFailures, @@ -719,12 +744,23 @@ func writeAgentNeighborsFull(out io.Writer, response neighborResponse) error { writeNoFocusMatch(out, response.Query, response.File, response.Line) return nil } - if response.DisambiguationRequired { + if response.FuzzyMatch { + definitions := make([]neighborEndpoint, 0, len(response.Matches)) + for _, match := range response.Matches { + definitions = append(definitions, match.Symbol) + } + writeFuzzyMatchListing(out, response.Query, symbolMatchTierFromLabel(response.FuzzyMatchKind), + definitions, response.MatchBodies) + if response.DisambiguationRequired { + return nil + } + } else if response.DisambiguationRequired { definitions := make([]neighborEndpoint, 0, len(response.Matches)) for _, match := range response.Matches { definitions = append(definitions, match.Symbol) } - writeDisambiguationListing(out, response.Query, response.FocusMatchesTotal, definitions) + writeDisambiguationListing(out, response.Query, response.FocusMatchesTotal, definitions, + response.MatchBodies) return nil } if response.FocusMatchesTruncated { diff --git a/internal/cli/neighbors_efficiency_test.go b/internal/cli/neighbors_efficiency_test.go index d3b696b2..2c327088 100644 --- a/internal/cli/neighbors_efficiency_test.go +++ b/internal/cli/neighbors_efficiency_test.go @@ -116,12 +116,15 @@ func TestNeighborsLimitBoundsAmbiguousFocusMatchesDeterministically(t *testing.T if err := writeAgentNeighbors(&out, response); err != nil { t.Fatal(err) } - if !strings.Contains(out.String(), `Ambiguous symbol "Target" matched 3 definitions`) || + if !strings.Contains(out.String(), `"Target" matches 3 definitions`) || // The listing prints the MINIMAL selector per definition rather than each full stable // ID: an ID is `repoKey:language:path:kind:qualifiedName`, so printing one repeats the // path and name the same line already shows. IDs remain accepted as INPUT — see // TestNeighborsExactSymbolIDDisambiguatesSameFileOverloads below. - !strings.Contains(out.String(), "rerun with the selector printed beside the one you mean") || + !strings.Contains(out.String(), "--symbol Target --file a.go --line 5") || + // And it must never TELL THE CALLER TO RE-RUN. Ambiguity is an answer, not an error: + // re-running by hand cost $2.22 of pre-edit operations on lombok-3486. + strings.Contains(out.String(), "rerun with the selector") || strings.Contains(out.String(), "c.go:1") || strings.Contains(out.String(), "Callers:") { t.Fatalf("agent ambiguity output was not deterministically bounded:\n%s", out.String()) } diff --git a/internal/cli/preflight.go b/internal/cli/preflight.go new file mode 100644 index 00000000..e758d6d2 --- /dev/null +++ b/internal/cli/preflight.go @@ -0,0 +1,102 @@ +package cli + +import ( + "fmt" + "sort" + "strings" +) + +// The preflight: prove the binary accepts the command line before a run depends on it. +// +// Three ways a search verb can stop answering, all observed or reproducible, all silent: +// +// - a pre-delivered payload file that exists but is EMPTY — stdout got zero bytes and the process +// exited 0, so the exit code, the harness and the transcript all recorded a healthy call; +// - a session state file reused across tasks — every task after the first replayed the first +// one's payload, naming files that are not in the tree the agent is looking at; +// - a flag set built for a newer binary — exit 1 and an empty payload on every call. +// +// The first two now fail loudly at the call. The third does too, but it fails on the FIRST AGENT +// TURN of every instance, which is the worst possible time to find out: by then the run is already +// under way, and what a reviewer sees afterwards is a graph arm whose numbers look like the +// baseline. The fix is to find out BEFORE the run, from one cheap call that parses the exact +// command line the run is about to use and refuses if this binary cannot serve it. +// +// It parses; it never executes. That is what makes it safe to run against a production command +// line — no repository is touched, no index is built, nothing is written. +// +// It runs each command's REAL parser rather than a list of known flag names, so it cannot drift +// from what the command actually accepts — that drift is the whole failure being guarded against. +// The consequence is that where a parser also enforces required flags (verify wants a baseline), +// the preflight enforces them too. Assert the command line you actually intend to run, not a +// fragment of it. + +// preflightParsers maps a command word to a parse-only check: it returns an error when this binary +// would reject the given arguments. Commands whose parsers return trailing arguments report those +// through unexpectedArgumentsError, so the preflight's message is the same one the real call would +// have produced. +var preflightParsers = map[string]func(version string, args []string) error{ + "search": func(version string, args []string) error { + _, rest, err := parseSearchFlags(args) + if err != nil { + return err + } + if len(rest) != 0 { + return unexpectedArgumentsError("search", version, rest) + } + return nil + }, + "index": func(version string, args []string) error { + _, rest, err := parseIndexFlags(args) + if err != nil { + return err + } + if len(rest) != 0 { + return unexpectedArgumentsError("index", version, rest) + } + return nil + }, + "stats": func(version string, args []string) error { + _, rest, err := parseStatsFlags(args) + if err != nil { + return err + } + if len(rest) != 0 { + return unexpectedArgumentsError("stats", version, rest) + } + return nil + }, + "def": func(_ string, args []string) error { _, err := parseDefFlags(args); return err }, + "impact": func(_ string, args []string) error { _, err := parseImpactFlags(args); return err }, + "neighbors": func(_ string, args []string) error { _, err := parseNeighborFlags(args); return err }, + "verify": func(_ string, args []string) error { _, err := parseVerifyFlags(args); return err }, +} + +// preflightCommands is the sorted command list, for the error a caller gets when it names something +// this binary cannot check. +func preflightCommands() []string { + names := make([]string, 0, len(preflightParsers)) + for name := range preflightParsers { + names = append(names, name) + } + sort.Strings(names) + return names +} + +// checkPreflight parses one ` [args...]` string and reports whether this binary accepts it. +func checkPreflight(version, spec string) error { + fields := strings.Fields(spec) + if len(fields) == 0 { + return fmt.Errorf("--assert needs a command line, for example --assert %q", "search --profile full") + } + command, args := fields[0], fields[1:] + parse, ok := preflightParsers[command] + if !ok { + return fmt.Errorf("--assert cannot check %q: this binary can check %s", + command, strings.Join(preflightCommands(), ", ")) + } + if err := parse(version, args); err != nil { + return fmt.Errorf("--assert %q: %w", spec, err) + } + return nil +} diff --git a/internal/cli/preflight_test.go b/internal/cli/preflight_test.go new file mode 100644 index 00000000..4cfe9cc9 --- /dev/null +++ b/internal/cli/preflight_test.go @@ -0,0 +1,109 @@ +package cli + +import ( + "bytes" + "encoding/json" + "strings" + "testing" +) + +// doctorAssert runs one preflight the way a harness does before a batch. +func doctorAssert(t *testing.T, args ...string) (string, error) { + t.Helper() + var out bytes.Buffer + err := Run(t.Context(), Options{ + Version: "0.9.9", + Env: EntireEnv{RepoRoot: t.TempDir()}, + Stdout: &out, + Stderr: &out, + }, append([]string{"doctor"}, args...)) + return out.String(), err +} + +// TestPreflightAcceptsFlagsThisBinaryHas pins the passing direction: a command line built for this +// build reports success and the report still renders. +func TestPreflightAcceptsFlagsThisBinaryHas(t *testing.T) { + t.Parallel() + out, err := doctorAssert(t, "--assert", "search --profile full --top-k 10 --format text") + if err != nil { + t.Fatalf("preflight rejected a command line this binary accepts: %v\n%s", err, out) + } + if !strings.Contains(out, "assert_ok") { + t.Fatalf("preflight did not report success:\n%s", out) + } +} + +// TestPreflightCatchesVersionSkew is the reason this verb exists. +// +// A harness whose flag set was built for a newer binary gets exit 1 and an empty payload on the +// agent's FIRST mandated action, in every session, for the whole run — and what a reviewer sees +// afterwards is a graph arm whose numbers look like the baseline arm. One preflight call turns that +// into a startup failure, before any instance runs. +func TestPreflightCatchesVersionSkew(t *testing.T) { + t.Parallel() + out, err := doctorAssert(t, "--assert", "search --flag-from-a-newer-build") + if err == nil { + t.Fatalf("preflight accepted a flag this binary does not have:\n%s", out) + } + for _, want := range []string{"--flag-from-a-newer-build", "0.9.9", "older"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("preflight error does not mention %q: %v", want, err) + } + } + // The report must not print above the failure: a caller that asked one question wants that + // answer, not an otherwise-healthy environment dump with the refusal buried under it. + if strings.Contains(out, "no_egress") { + t.Fatalf("preflight printed the report despite failing:\n%s", out) + } +} + +// TestPreflightChecksEveryAssertion pins that --assert is repeatable and that a later failure is +// not masked by an earlier success — a harness usually drives more than one verb, and learning +// about the second only after fixing the first costs another whole run. +func TestPreflightChecksEveryAssertion(t *testing.T) { + t.Parallel() + _, err := doctorAssert(t, + "--assert", "search --profile full", + "--assert", "impact --symbol Foo --flag-from-a-newer-build", + ) + if err == nil { + t.Fatal("a failing second assertion was masked by a passing first one") + } + if !strings.Contains(err.Error(), "impact") { + t.Fatalf("error does not name the assertion that failed: %v", err) + } +} + +// TestPreflightRunsNothing is what makes --assert safe against a production command line: it parses +// and returns. The repo it names does not exist, so anything that touched a repository would fail. +func TestPreflightRunsNothing(t *testing.T) { + t.Parallel() + var out bytes.Buffer + err := Run(t.Context(), Options{Version: "0.9.9", Env: EntireEnv{RepoRoot: t.TempDir()}, Stdout: &out, Stderr: &out}, + []string{"doctor", "--json", "--assert", "search --repo /nonexistent/repo --query anything --profile full"}) + if err != nil { + t.Fatalf("preflight touched the repository it was asked about: %v", err) + } + var report map[string]any + if err := json.Unmarshal(out.Bytes(), &report); err != nil { + t.Fatalf("--json did not emit JSON: %v\n%s", err, out.String()) + } + asserted, ok := report["asserted_command_lines"].([]any) + if !ok || len(asserted) != 1 { + t.Fatalf("JSON report does not record the assertions: %v", report["asserted_command_lines"]) + } +} + +// TestPreflightRejectsUncheckableCommand pins that an unknown command word is an error naming what +// CAN be checked, rather than a silent pass — a preflight that quietly approves everything is worse +// than none, because it is trusted. +func TestPreflightRejectsUncheckableCommand(t *testing.T) { + t.Parallel() + _, err := doctorAssert(t, "--assert", "snapshot --format ndjson") + if err == nil { + t.Fatal("preflight silently approved a command it cannot check") + } + if !strings.Contains(err.Error(), "search") { + t.Fatalf("error does not list the checkable commands: %v", err) + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 66ecc6b4..9693ea94 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -89,6 +89,8 @@ func Run(ctx context.Context, opts Options, args []string) error { return runNeighbors(ctx, opts, args[1:]) case "impact": return runImpact(ctx, opts, args[1:]) + case "verify": + return runVerify(ctx, opts, args[1:]) case "stats": return runStats(ctx, opts, args[1:]) case "agent-guide": @@ -119,9 +121,36 @@ func printHelp(out io.Writer) { } func runDoctor(ctx context.Context, opts Options, args []string) error { - asJSON := len(args) == 1 && args[0] == "--json" - if len(args) > 1 || (len(args) == 1 && !asJSON) { - return errors.New("doctor accepts only --json") + asJSON := false + var asserts []string + for index := 0; index < len(args); index++ { + switch args[index] { + case "--json": + asJSON = true + case "--assert": + // Repeatable: a harness usually drives more than one verb, and finding out about the + // second one only after the first has been fixed costs another whole run. + if index+1 >= len(args) { + return errors.New("doctor --assert needs a command line, for example --assert \"search --profile full\"") + } + index++ + asserts = append(asserts, args[index]) + default: + return errors.New("doctor accepts only --json and --assert \"\"") + } + } + // The assertions run FIRST and stop the report: a caller that asked whether this binary can + // serve its command line wants that answer, and printing an otherwise-healthy environment + // report above the failure buries it. + for _, spec := range asserts { + if err := checkPreflight(opts.Version, spec); err != nil { + return err + } + } + if len(asserts) > 0 && !asJSON { + for _, spec := range asserts { + fmt.Fprintf(opts.Stdout, "assert_ok=%q\n", spec) + } } report := map[string]any{ "provider": sem.ProviderName, @@ -141,6 +170,11 @@ func runDoctor(ctx context.Context, opts Options, args []string) error { "perform_network_discovery": false, }, } + if len(asserts) > 0 { + // Reaching here means every assertion parsed, so the JSON says so explicitly rather than + // leaving a caller to infer success from the absence of an error. + report["asserted_command_lines"] = asserts + } if !asJSON { fmt.Fprintf(opts.Stdout, "ENTIRE_CLI_VERSION=%s\n", valueOrUnset(opts.Env.CLIVersion)) fmt.Fprintf(opts.Stdout, "ENTIRE_REPO_ROOT=%s\n", valueOrUnset(opts.Env.RepoRoot)) @@ -201,7 +235,7 @@ func runProviderRecords(ctx context.Context, opts Options, args []string, mode s return err } if len(rest) != 0 { - return fmt.Errorf("%s received unexpected arguments: %s", mode, strings.Join(rest, " ")) + return unexpectedArgumentsError(mode, opts.Version, rest) } if flags.Format != "ndjson" { return fmt.Errorf("%s requires --format ndjson", mode) diff --git a/internal/cli/search.go b/internal/cli/search.go index 21b09c41..066e7d7d 100644 --- a/internal/cli/search.go +++ b/internal/cli/search.go @@ -6,9 +6,13 @@ import ( "encoding/json" "errors" "fmt" + "io" + "os" + "path/filepath" "strconv" "strings" + "github.com/entireio/entire-graph/internal/gitutil" "github.com/entireio/entire-graph/internal/sem" ) @@ -65,6 +69,11 @@ type searchFlags struct { BodyHeadRanks int EnclosureContextLines int HeadWindowLines int + FullUnitTop int + EditSiteBodies bool + CalleeHop bool + VerifyPrefix string + VerifyPreFixStatus string FileOutline bool Deep bool // The reference blocks, off unless asked for. See SearchOptions in internal/sem/search.go for @@ -112,11 +121,28 @@ func runSearch(ctx context.Context, opts Options, args []string) error { } } if len(rest) != 0 { - return fmt.Errorf("search received unexpected arguments: %s", strings.Join(rest, " ")) + return unexpectedArgumentsError("search", opts.Version, rest) } if strings.TrimSpace(flags.Query) == "" { return errors.New("search requires --query") } + // ZERO-TOLL DELIVERY. When the caller has already computed this session's payload and handed it + // to the agent inside context the session pays for anyway, this call must cost nothing: echo + // those bytes and return, before the profile, the repo, the cache and the index are touched. + if path := strings.TrimSpace(opts.Env.PresearchPath); path != "" { + return echoPresearchPayload(opts.Stdout, path) + } + // The echo is decided before any INDEXING work: a replayed payload must not pay for an index + // build. See searchSession for what the cap is worth and why it is an echo. + // + // It does now pay for a repo resolution and two `git rev-parse` calls, which is the price of + // knowing the payload belongs to the tree in front of it. That is two subprocesses against an + // index build, and against the alternative — replaying another repository's answer for the rest + // of a run — it is not a close trade. See searchSessionScope. + session, err := newSearchSession(opts.Env, opts.Stderr) + if err != nil { + return err + } profile, err := parseProfile(flags.Profile) if err != nil { return err @@ -125,6 +151,17 @@ func runSearch(ctx context.Context, opts Options, args []string) error { if err != nil { return err } + var scope searchSessionScope + if session != nil { + scope = searchSessionScopeFor(ctx, repo) + if state, ok := session.echo(scope); ok { + if _, err := io.WriteString(opts.Stdout, searchEchoHeader(flags.Query, state.Query)); err != nil { + return err + } + _, err := io.WriteString(opts.Stdout, state.Payload) + return err + } + } cacheDir := resolveCacheDir(flags.CacheDir, opts.Env.PluginDataDir) contextBudget := flags.MaxContextBytes // Agent output has a much smaller wire representation than the public JSON @@ -150,6 +187,11 @@ func runSearch(ctx context.Context, opts Options, args []string) error { BodyHeadRanks: flags.BodyHeadRanks, EnclosureContextLines: flags.EnclosureContextLines, HeadWindowLines: flags.HeadWindowLines, + FullUnitTop: flags.FullUnitTop, + EditSiteBodies: flags.EditSiteBodies, + CalleeHop: flags.CalleeHop, + VerifyPrefix: flags.VerifyPrefix, + VerifyPreFixStatus: flags.VerifyPreFixStatus, IncludeFileOutline: flags.FileOutline, Deep: flags.Deep, @@ -163,22 +205,115 @@ func runSearch(ctx context.Context, opts Options, args []string) error { if err := response.Validate(); err != nil { return err } - switch flags.Format { + // With a session active the payload is teed, so the echo replays the exact bytes this call + // handed the agent — the stored answer is the rendered output, not a re-render of the response. + out := opts.Stdout + var payload bytes.Buffer + if session != nil { + out = io.MultiWriter(opts.Stdout, &payload) + } + if err := writeSearchResponse(out, response, flags.Format, contextBudget); err != nil { + return err + } + if session != nil { + session.record(flags.Query, payload.Bytes(), scope) + } + return nil +} + +// searchSessionScopeFor describes the tree a payload recorded now would be answering for. +// +// Failure is not an error here. A directory git cannot describe still has a resolved path, and a +// path is enough to separate two checkouts in the common case; when even that is unavailable the +// zero scope matches nothing, so the echo is refused and the question gets a real answer. The one +// outcome this must never produce is a confident scope that is wrong. +func searchSessionScopeFor(ctx context.Context, repo string) searchSessionScope { + scope := searchSessionScope{Repo: repo} + if resolved, err := filepath.Abs(repo); err == nil { + scope.Repo = resolved + } + commit, err := gitutil.RevParse(ctx, repo, "HEAD") + if err != nil { + return scope + } + tree, err := gitutil.RevParse(ctx, repo, commit+"^{tree}") + if err != nil { + return scope + } + scope.Tree = tree + return scope +} + +func writeSearchResponse(out io.Writer, response sem.SearchResponse, format string, contextBudget int) error { + switch format { case "json": - encoder := json.NewEncoder(opts.Stdout) + encoder := json.NewEncoder(out) encoder.SetEscapeHTML(false) return encoder.Encode(response) case "ndjson": - return writeNdjsonSearch(opts.Stdout, response) + return writeNdjsonSearch(out, response) case "text": - return writeTextSearch(opts.Stdout, response) + return writeTextSearch(out, response) case "agent": - return writeAgentSearch(opts.Stdout, response, contextBudget) + return writeAgentSearch(out, response, contextBudget) default: - return fmt.Errorf("search --format must be json, ndjson, text, or agent, got %q", flags.Format) + return fmt.Errorf("search --format must be json, ndjson, text, or agent, got %q", format) } } +// echoPresearchPayload returns a payload that was computed before the agent started, verbatim. +// +// The toll this removes is the MESSAGE, not the bytes. Measured over 178 gated benchmark pairs, +// the number of graph calls a session makes is invariant — 1.00 / 1.03 / 1.00 per session across +// baseline-exploration bands <10 / 10-19 / >=20 turns — while what the call buys back is not: +// greps displaced go 1.35 / 2.16 / 3.74 over the same bands, and the cost ratio with it (1.140 CI +// [1.063,1.224] n=51 / 0.950 n=108 / 0.841 CI [0.704,0.998] n=19). Split by outcome instead of by +// band, the two cohorts separate cleanly: sessions where the call displaced no grep at all (n=43) +// cost 1.165 CI [1.073,1.264] and ran +1.21 turns, sessions where it displaced greps (n=135) cost +// 0.938 CI [0.889,0.990] and ran -2.07 turns. corr(turn delta, log cost ratio) = +0.735 CI +// [0.661,0.800] pair-level, +0.802 CI [0.662,0.885] repo-level — the strongest correlate in the +// set. Independently, a regression holding call count fixed still prices a +7.0% CI [1.9,13.2] +// tax on merely having made the call, and prices one call at 1.61 messages rather than 1. +// +// The toll is also unpredictable: no static feature of a payload predicts that its call will turn +// out to be a no-op (AUC 0.553 top score, 0.560 spread, 0.563 gap, 0.690 entropy, at a 28% base +// rate). Selective skipping is therefore not implementable — pre-delivery is the only form the +// fix can take, and it must be universal. +// +// Echoing rather than re-querying is what makes the delivery free: a call the agent still makes +// costs one message and adds zero information, so it cannot re-open the phase the pre-delivery +// closed. Retrieval is unaffected because the agent's own query adds almost nothing over the +// issue text the payload was derived from: of 188 pre-edit greps not covered by the payload, 2 +// (1.1%) named a literal that was in the agent's query and not in the issue. +// +// A caller that names a payload it cannot produce gets an error, not a live query. Falling back +// silently would leave the arm measuring whichever mode each session happened to land in — the +// same reason an unknown --reference-blocks name is an error rather than a no-op. +// +// An EMPTY file is that same failure and is treated the same way. A zero-byte payload used to be +// written to stdout and reported as success, which is the worst available outcome: the agent asked +// a question, got nothing back, and every layer above — the exit code, the harness, the transcript +// — recorded a healthy call. A whole measured cell can run that way without anyone noticing, and +// a cell whose search verb returns nothing is not measuring the graph at all, it is measuring the +// baseline with extra steps. Whatever produced an empty file (a failed pre-computation, a +// truncated write, an unset variable expanding to nothing) is a bug in the caller, and the only +// safe report is a loud one. +// +// Note for whoever wires the caller: the payload must be computed with the SAME binary, flags and +// cached graph the session would have used, and the instruction telling the agent to search first +// has to go with it — the tool cannot be left un-called while that directive stands. +func echoPresearchPayload(out interface{ Write([]byte) (int, error) }, path string) error { + payload, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("%s: %w", envPresearch, err) + } + if len(payload) == 0 { + return fmt.Errorf("%s: %s is empty: a pre-delivered payload of zero bytes would answer the agent with nothing and still report success", envPresearch, path) + } + _, err = out.Write(payload) + return err +} + // writeNdjsonSearch streams a payload as one record per line: a header, the blocks that are their own // records, every ranked result, and a summary that carries the rest. func writeNdjsonSearch(out interface{ Write([]byte) (int, error) }, response sem.SearchResponse) error { @@ -252,6 +387,16 @@ func writeNdjsonSearch(out interface{ Write([]byte) (int, error) }, response sem // "is this worth opening", so a full window there is token waste. const searchTextFullRanks = 2 +// searchTextMaxFullBodies caps how many BODIES the default text payload prints, however many the +// allocator upgraded. +// +// Turn-level forensics of 12 real sessions: payloads were 45-75% noise by byte, and the bodies past +// the third were never referenced — not once across the 12. The allocator is still right to buy them +// (it is optimising the ranking, and --full-unit-top/--callee-hop callers want them), so this is a +// RENDERING cap: everything past the third body degrades to its one-line locator, which is the part +// that was actually used. The flags keep working; they raise this ceiling for the ranks they force. +const searchTextMaxFullBodies = 3 + // Section headers for `--format text`. They label what the group IS, because the failure they // exist to prevent is an agent treating a non-fix-site as the fix site. const ( @@ -302,8 +447,62 @@ func writeTextSearch(out interface{ Write([]byte) (int, error) }, response sem.S } } primary, related, docs, tests := partitionSearchSections(response.Results) - for index, result := range primary { - writeTextSearchResult(out, result, index < searchTextFullRanks) + // THE DIET. A body is printed for the first searchTextMaxFullBodies entries that carry source, and + // every entry after that prints as a locator whatever the allocator gave it. Ranks the caller + // explicitly forced (full-unit / callee-hop) are exempt: asking for a unit and being handed a + // locator would make the flag a no-op. + bodies := 0 + // POSITION IN THE BODY QUEUE, not index in the group. A low-value path that is demoted below + // spends no body slot AND no rank tier: charging its rank against the full-snippet tier would + // leave the agent one full window short for exactly the hits that are real source, which is the + // same reason a sectioned-away non-code hit does not charge its rank either (see the doc comment + // above). Without low-value hits in the group this is the group index, so the ordinary payload is + // byte-identical. + position := 0 + demoteLowValue := searchTextDemotesLowValueBodies(primary) + // THE RE-ANCHOR FUNDING INVARIANT, the renderer's half. A body a hit can only claim because it was + // re-anchored onto code (sem/search_reanchor.go) is funded out of SPARE slots, never out of a slot an + // ordinary hit would have taken — the same rule seatForcedSearchUnits applies to forced units, and + // the diet's three slots are the currency here rather than bytes. + // + // Measured on vuejs__core-11870: `arrayInstrumentations.ts:10→12` picked up a complete body, became + // the third body in rank order, and pushed `runtime-core/src/helpers/renderList.ts:54-107` — 54 lines + // of the production helper the issue is actually about — out of the payload as a bare locator. Net: + // the payload traded a body it had for a body it did not need. + // + // So the ordinary hits' demand is counted FIRST and the re-anchored ones take what is left. A denied + // re-anchored hit keeps its re-anchored `focus=`, which is the larger part of the win and costs + // nothing. It still charges a tier position, in this pass and in the demand count above, because the + // two loops have to walk the same queue to agree. + reanchorSlots := searchTextMaxFullBodies - searchTextOrdinaryBodyDemand(primary, demoteLowValue) + for _, result := range primary { + if demoteLowValue && searchLowValueBodyPath(result.FilePath) && !searchResultForcedByFlag(result) { + writeTextSearchLocator(out, result) + continue + } + full := position < searchTextFullRanks || searchResultCarriesCompleteBody(result) + position++ + if full && searchResultBodyIsReanchorGained(result) { + if reanchorSlots <= 0 { + full = false + } else { + reanchorSlots-- + } + } + if full && bodies >= searchTextMaxFullBodies && !searchResultForcedByFlag(result) { + full = false + } + if full { + bodies++ + writeTextSearchResult(out, result, true) + continue + } + // The diet has to write the locator ITSELF. writeTextSearchResult re-checks + // searchResultCarriesCompleteBody and prints a body whenever the signal is present, which + // silently neutralised the cap: on carbon-2752 all five hits still came back bodied. That + // re-check exists so the RANK TIER cannot throw away source the allocator paid for, and it is + // right for that job — but a cap the caller set is a decision, not an accident. + writeTextSearchLocator(out, result) } // Contract context before the related and docs groups: it is about the hit the reader has // just read, and it is the part that decides whether the edit is the right SHAPE. @@ -399,11 +598,157 @@ func renderSignatureTypes(types []sem.SearchSignatureType) []byte { return []byte(buffer.String()) } +// searchLowValueBodyDirSegments are path segments whose files are program text a fix essentially +// never lands in: the benchmark harness, the demo app, the example gallery. They are real code, so +// the ranker is right to score them and the payload is right to LIST them — a benchmark that +// exercises the broken API is a legitimate lead — but a body from one of them costs the same bytes +// as a body from the library and answers a different question. +// +// Measured on preactjs__preact-3010: `benches/src/keyed-children/index.js` and `karma.conf.js` took +// two of the three body slots (the third went to `src/component.js`) while the gold file, +// `src/diff/children.js`, arrived as a bodyless two-line locator. +var searchLowValueBodyDirSegments = map[string]bool{ + "bench": true, "benches": true, "benchmark": true, "benchmarks": true, + "demo": true, "demos": true, "example": true, "examples": true, +} + +// searchLowValueBodySuffixes are build/test-runner configuration scripts. They are `.js`/`.ts`, so +// they are program text and no data-file rule catches them, and they name the very packages and +// entry points a query is built from — which is how `karma.conf.js` outranked the library it +// configures. +var searchLowValueBodySuffixes = []string{".conf.js", ".config.js", ".karma.js"} + +// searchLowValueBodyPath reports whether a path's bodies must yield their slot to real source. +// Directory names are matched as whole path SEGMENTS, so `benches/` is caught and +// `src/benchmarks_test_helper.js` is not. +func searchLowValueBodyPath(filePath string) bool { + if filePath == "" { + return false + } + lower := strings.ToLower(filepath.ToSlash(filePath)) + for _, suffix := range searchLowValueBodySuffixes { + if strings.HasSuffix(lower, suffix) { + return true + } + } + segments := strings.Split(strings.Trim(lower, "/"), "/") + if len(segments) > 0 { + segments = segments[:len(segments)-1] + } + for _, segment := range segments { + if searchLowValueBodyDirSegments[segment] { + return true + } + } + return false +} + +// searchTextDemotesLowValueBodies reports whether the group holds enough real source for the +// demotion to be safe. A payload whose only program text IS the benchmark or the config script must +// still print it: the rule is "never outrank real source for a body slot", not "never show a body". +// Two is the threshold because the body diet's own tier is two full windows — below that the +// demotion would be taking bytes away from a reader with nothing to read instead. +func searchTextDemotesLowValueBodies(primary []sem.SearchResult) bool { + real := 0 + for _, result := range primary { + if searchLowValueBodyPath(result.FilePath) || result.Snippet == "" { + continue + } + real++ + if real >= 2 { + return true + } + } + return false +} + +// searchResultBodyIsReanchorGained reports whether this hit's body exists only because the payload +// moved its anchor onto code. The flag is set by the allocator's own control comparison, NOT inferred +// from CommentFocusLine: most re-anchored hits already carried a body, and gating those would evict +// source the re-anchor never paid for. A hit the CALLER forced is never gated either — a flag that +// silently stops producing what it promises is worse than no flag. +func searchResultBodyIsReanchorGained(result sem.SearchResult) bool { + return result.BodyFromReanchor && !searchResultForcedByFlag(result) +} + +// searchTextOrdinaryBodyDemand counts the body slots the NON-re-anchored hits will claim, walking the +// same queue the render loop walks so the two agree on every tier position. +func searchTextOrdinaryBodyDemand(primary []sem.SearchResult, demoteLowValue bool) int { + demand, position := 0, 0 + for _, result := range primary { + if demoteLowValue && searchLowValueBodyPath(result.FilePath) && !searchResultForcedByFlag(result) { + continue + } + full := position < searchTextFullRanks || searchResultCarriesCompleteBody(result) + position++ + if !full || searchResultBodyIsReanchorGained(result) { + continue + } + if demand++; demand >= searchTextMaxFullBodies { + return searchTextMaxFullBodies + } + } + return demand +} + +// writeTextSearchLocator prints a hit as its one-line locator unconditionally. It is the form the body +// diet collapses to, and it exists as its own function precisely because writeTextSearchResult must +// keep refusing to do this on its own account. +// searchLocatorFollowUp names the verb that fetches what a bodyless hit did not carry. +// +// MEASURED: redis's agent hand-`sed`-ranged the exact locator the payload printed, and fmt's agent +// blind-`Read` a 200-line window off another one. Both had the location and neither knew the tool could +// hand them the body — so they reconstructed it with shell commands, which is the expensive half of +// every session this payload exists to shorten. The suffix costs ~22 bytes on the hits that have no +// body and nothing at all on the hits that do. +func searchLocatorFollowUp(result sem.SearchResult) string { + // A symbol name is what `def` takes. Without one there is nothing to suggest, and a suffix naming a + // verb that cannot be run would be worse than silence. + name := searchResultDisplayName(result) + if name == "" { + return "" + } + return " [body: def " + name + "]" +} + +func writeTextSearchLocator(out interface{ Write([]byte) (int, error) }, result sem.SearchResult) { + // Byte-identical to the locator writeTextSearchResult already emits below the rank tier. Two + // different locator shapes in one payload would be a second thing for a reader to learn for no + // gain, and the existing shape is what every consumer and test already reads. + name := searchResultDisplayName(result) + if name != "" { + fmt.Fprintf(out, "%d. %s:%d %s%s\n", result.Rank, result.FilePath, + searchResultLocatorLine(result), name, searchLocatorFollowUp(result)) + return + } + // NO NAME MEANS NO LOCATOR. The demotion contract above is "path, line and symbol name all + // survive", and a hit whose focus line has no enclosing indexed symbol has no name to survive + // with — see sem.SearchLocatorWindow for what lands here and how often. `searchLocatorFollowUp` + // has already refused for the same reason, so the line would carry no source, no symbol and no + // verb: coordinates and an obligatory file read. It keeps the bounded window the ranker already + // computed for it instead, which costs only bytes the ranker had already allocated. + // + // The shape is the bodied shape with the fields a nameless hit does not have left off, so this + // is not a third thing to learn: a range header, the matched line, then the source under it. + // `focus=` is not optional here — the bare locator's ONE piece of information was the matched + // line, and a range that silently replaced it would trade information for bytes. + if start, end, window := sem.SearchLocatorWindow(result); window != "" { + fmt.Fprintf(out, "%d. %s:%d-%d", result.Rank, result.FilePath, start, end) + if result.FocusLine >= start && result.FocusLine <= end && end > start { + fmt.Fprintf(out, " focus=%d", result.FocusLine) + } + fmt.Fprintf(out, "\n%s\n\n", window) + return + } + fmt.Fprintf(out, "%d. %s:%d\n", result.Rank, result.FilePath, searchResultLocatorLine(result)) +} + func writeTextSearchResult(out interface{ Write([]byte) (int, error) }, result sem.SearchResult, full bool) { name := searchResultDisplayName(result) if !full && !searchResultCarriesCompleteBody(result) { if name != "" { - fmt.Fprintf(out, "%d. %s:%d %s\n", result.Rank, result.FilePath, searchResultLocatorLine(result), name) + fmt.Fprintf(out, "%d. %s:%d %s%s\n", result.Rank, result.FilePath, + searchResultLocatorLine(result), name, searchLocatorFollowUp(result)) } else { fmt.Fprintf(out, "%d. %s:%d\n", result.Rank, result.FilePath, searchResultLocatorLine(result)) } @@ -413,10 +758,21 @@ func writeTextSearchResult(out interface{ Write([]byte) (int, error) }, result s // span the snippet does not contain sends the reader to the wrong lines. start, end := searchResultPrintedRange(result) fmt.Fprintf(out, "%d. %s:%d-%d", result.Rank, result.FilePath, start, end) + // A merged span says so, and says the one thing that decides whether the reader re-reads the + // region anyway: that the range is contiguous and nothing between the hits it absorbed has + // been left out. Without that, a wide range reads exactly like a stitched-together excerpt, + // which is what an agent opens the file to check — the measured turn-2 behaviour this merge + // exists to remove (fluentd-3328: `sed -n '330,470p'` over a superset of what it already had). + if len(result.MergedRanks) > 0 { + fmt.Fprintf(out, " [contains ranks %s - contiguous, nothing elided]", joinSearchRanks(result.MergedRanks)) + } // A block that carries no relevance score must not print one. The covering test is not a // ranked answer — it is the statement of what the fix has to achieve — and `score=0.0000` // beside it reads as "worthless" rather than "not applicable". - if result.Section != sem.SearchSectionCoveringTest { + // A callee-hop entry is excluded for the same reason: it was admitted by a CALLS edge, not by + // relevance, so it carries no ranked score and `score=0.0000` beside it would read as + // "worthless" rather than "not applicable". The signals list already says why it is here. + if result.Section != sem.SearchSectionCoveringTest && !searchResultIsCalleeHop(result) { fmt.Fprintf(out, " score=%.4f", result.Score) } if name != "" { @@ -429,6 +785,32 @@ func writeTextSearchResult(out interface{ Write([]byte) (int, error) }, result s if result.Kind != "" { fmt.Fprintf(out, " kind=%s", result.Kind) } + // THE LINE THE QUERY ACTUALLY MATCHED, not just the range that contains it. + // + // FocusLine is computed for every hit and has always been in the JSON. The text payload — which + // is what an agent reads — printed only the snippet's range, so a hit on a 27-line method said + // "the answer is somewhere in 135-161" and made the reader find the line itself. Asked what one + // change to the returned payload would let it finish in the fewest calls, a Sonnet agent that had + // just fixed apache/lucene-13170 from this tool named exactly this: its only non-essential call + // was a Read of 135-161 to "pin down line 151 specifically before editing", and it wanted the + // matched line marked so "the payload doubles as both 'here's the function' and 'here's the + // precise line to change'". Re-reading a file the payload already printed is 10.1% of all + // post-payload tool calls in the measured Sonnet sessions. + // + // It goes in the HEADER rather than as an inline `>>> 151:` marker on the snippet line, which is + // what the agent literally asked for. Agents copy snippet text verbatim as the `old_string` anchor + // of an edit, so decorating a body line would make that anchor fail to match the file and turn a + // navigation aid into a broken patch. + if result.FocusLine >= start && result.FocusLine <= end && end > start { + // A re-anchored hit prints BOTH lines. The comment line is the evidence for why the hit is + // in the payload at all, and a reader told only the code line cannot tell a hit the query + // matched directly from one the payload moved (see sem/search_reanchor.go). + if result.CommentFocusLine > 0 && result.CommentFocusLine != result.FocusLine { + fmt.Fprintf(out, " focus=%d→%d", result.CommentFocusLine, result.FocusLine) + } else { + fmt.Fprintf(out, " focus=%d", result.FocusLine) + } + } // A section entry may carry no source at all — the covering test degrades to a locator when its // byte allowance cannot hold one line of body. Printing the empty string as if it were source // costs two blank lines and reads as a truncation bug. @@ -436,7 +818,14 @@ func writeTextSearchResult(out interface{ Write([]byte) (int, error) }, result s fmt.Fprintf(out, " signals=%s\n", strings.Join(result.Signals, ",")) return } - fmt.Fprintf(out, " signals=%s\n%s\n\n", strings.Join(result.Signals, ","), result.Snippet) + fmt.Fprintf(out, " signals=%s\n%s\n", strings.Join(result.Signals, ","), result.Snippet) + // A forced unit the safety cap clipped says which of its own lines are missing, on its OWN line + // after the body. Never interleaved: agents copy body text verbatim as an Edit anchor (see the + // focus= comment above), so a marker inside the source turns a navigation aid into a broken patch. + if note := sem.SearchUnitElisionNote(start, end, result.UnitStartLine, result.UnitEndLine); note != "" { + fmt.Fprintf(out, "%s\n", note) + } + fmt.Fprintln(out) } // searchResultPrintedRange is the range of the source that follows on the next @@ -492,6 +881,17 @@ func writeTextSearchTypeCard(out interface{ Write([]byte) (int, error) }, card [ } } +// joinSearchRanks renders the pre-merge ranks a contiguous span absorbed. They are the ranking's +// own numbers from BEFORE the merge renumbered it, which is what makes a rank that no longer +// prints on its own account for itself instead of just going missing. +func joinSearchRanks(ranks []int) string { + parts := make([]string, 0, len(ranks)) + for _, rank := range ranks { + parts = append(parts, strconv.Itoa(rank)) + } + return strings.Join(parts, ",") +} + func joinSearchUseLines(lines []int) string { parts := make([]string, 0, len(lines)) for _, line := range lines { @@ -514,9 +914,42 @@ func searchResultLocatorLine(result sem.SearchResult) int { return result.StartLine } +// The test is "did the allocator deliberately widen this result", not "is it a whole callable", and +// the three signals below are the three ways it can have done so. Two of them were being dropped: +// +// - full-unit: --full-unit-top may reach a rank below the full-snippet tier, and a clipped forced +// unit carries full-unit WITHOUT complete-symbol. +// - head-window: the allocator's fallback for a head rank with no enclosable callable. Measured on +// fmtlib__fmt-2457 the allocator seated a 60-line window (1,709 B) at rank 3 and this function +// then reported "no body", so the renderer printed `include/fmt/ranges.h:682` and threw the whole +// window away — bytes spent, budget charged, nothing delivered. +// +// Both only ever occur when a flag asked for them, so the default payload is unchanged. +// searchResultForcedByFlag reports whether the CALLER asked for this rank's source by name, which is +// what exempts it from the body diet. A flag that silently stops producing what it promises is worse +// than no flag. +func searchResultForcedByFlag(result sem.SearchResult) bool { + for _, signal := range result.Signals { + if signal == sem.FullUnitSignal || signal == sem.CalleeHopSignal { + return true + } + } + return false +} + +func searchResultIsCalleeHop(result sem.SearchResult) bool { + for _, signal := range result.Signals { + if signal == sem.CalleeHopSignal { + return true + } + } + return false +} + func searchResultCarriesCompleteBody(result sem.SearchResult) bool { for _, signal := range result.Signals { - if signal == sem.CompleteSymbolSignal { + switch signal { + case sem.CompleteSymbolSignal, sem.FullUnitSignal, sem.HeadWindowSignal, sem.CalleeHopSignal: return true } } @@ -584,8 +1017,22 @@ func writeAgentSearch(out interface{ Write([]byte) (int, error) }, response sem. // Suffix blocks in priority order. VERIFY comes first because it is the one an agent acts on // immediately; the literal cluster next because it can end the search; the declaration card last // because it is pure reference and is off by default anyway. + // VERIFY is NOT in the droppable suffix set. Everything else here names something the agent could + // go and get; the verify command is the one block whose absence is measured to change behaviour + // (sessions without one spent 15.2% fewer tokens than the baseline against 30.6% with one), and it + // is two lines. It is prepended to the RANKING instead, where the byte fitter cannot silently drop + // it — see fitAgentSearchSuffixes for why the rest stay surplus. + verifyBlock := sem.RenderSearchVerifyCommand(response.VerifyCommand) + // VERIFY is undroppable in every budget that can afford it, and the LAST thing tried before the + // caller would get no ranked location at all. Those two rules are both load-bearing and they can + // conflict at a tight cap, so the block gets its own variant rung: present, then absent. The + // standing invariant "a suffix block never costs the caller a ranked location" still holds — this + // is the only ordering in which promoting VERIFY does not violate it. + verifyVariants := [][]byte{verifyBlock} + if len(verifyBlock) > 0 { + verifyVariants = append(verifyVariants, nil) + } suffixes := [][]byte{ - sem.RenderSearchVerifyCommand(response.VerifyCommand), sem.RenderSearchLiteralCluster(response.LiteralCluster), agentSearchTypeCard(response.TypeCard), } @@ -595,6 +1042,7 @@ func writeAgentSearch(out interface{ Write([]byte) (int, error) }, response sem. payload = append(payload, fullConfidence...) payload = append(payload, closedSet...) payload = append(payload, fullMap...) + payload = append(payload, verifyBlock...) if len(results) == 0 { payload = append(payload, "No search results.\n"...) } else { @@ -691,46 +1139,52 @@ func writeAgentSearch(out interface{ Write([]byte) (int, error) }, response sem. for _, confidence := range confidenceVariants { for _, warning := range closedSetVariants { for _, containerMap := range mapVariants { - remaining := budget - len(header) - len(diagnostics) - len(confidence) - - len(warning) - len(containerMap) - if remaining <= 0 { - continue - } - prefix := func() []byte { - payload := append([]byte{}, header...) - payload = append(payload, diagnostics...) - payload = append(payload, confidence...) - payload = append(payload, warning...) - return append(payload, containerMap...) - } - // The blocks sit on opposite sides of the ranking and degrade - // differently, which is why they compose without a further nested - // variant loop: every prefix block has its own full/compact/absent - // ladder above, while a suffix block rides along only when the - // fitted payload leaves room for it (fitAgentSearchSuffixes). - // Neither can cost the caller a ranked location. - if len(results) == 0 { - noResults := []byte("No search results.\n") - if len(noResults) <= remaining { + for _, verify := range verifyVariants { + remaining := budget - len(header) - len(diagnostics) - len(confidence) - + len(warning) - len(containerMap) - len(verify) + if remaining <= 0 { + continue + } + prefix := func() []byte { + payload := append([]byte{}, header...) + payload = append(payload, diagnostics...) + payload = append(payload, confidence...) + payload = append(payload, warning...) + payload = append(payload, containerMap...) + // VERIFY rides in the PREFIX, not the droppable suffixes: it is the one + // block whose absence measurably changes behaviour, and two lines of it + // must never be traded for one more ranked locator. + return append(payload, verify...) + } + // The blocks sit on opposite sides of the ranking and degrade + // differently, which is why they compose without a further nested + // variant loop: every prefix block has its own full/compact/absent + // ladder above, while a suffix block rides along only when the + // fitted payload leaves room for it (fitAgentSearchSuffixes). + // Neither can cost the caller a ranked location. + if len(results) == 0 { + noResults := []byte("No search results.\n") + if len(noResults) <= remaining { + _, err := out.Write(fitAgentSearchSuffixes( + append(prefix(), noResults...), agentVerifyFirstSuffixes(verify, verifyBlock, suffixes), budget, + )) + return err + } + continue + } + formatted := fitAgentSearchResults(results, remaining) + if protectTopHit && !agentSearchBlockCarriesSource(formatted) { + // This prefix is too wide to leave room for the top hit's complete block. + // Degrade the prefix and try again rather than handing back a truncated + // answer: the caller can afford a shorter latency line, not a missing snippet. + continue + } + if len(formatted) > 0 { _, err := out.Write(fitAgentSearchSuffixes( - append(prefix(), noResults...), suffixes, budget, + append(prefix(), formatted...), agentVerifyFirstSuffixes(verify, verifyBlock, suffixes), budget, )) return err } - continue - } - formatted := fitAgentSearchResults(results, remaining) - if protectTopHit && !agentSearchBlockCarriesSource(formatted) { - // This prefix is too wide to leave room for the top hit's complete block. - // Degrade the prefix and try again rather than handing back a truncated - // answer: the caller can afford a shorter latency line, not a missing snippet. - continue - } - if len(formatted) > 0 { - _, err := out.Write(fitAgentSearchSuffixes( - append(prefix(), formatted...), suffixes, budget, - )) - return err } } } @@ -791,6 +1245,19 @@ func searchLowConfidenceNotices(response sem.SearchResponse) ([]byte, []byte) { // // A block that does not fit does not stop the ones after it: the blocks are independent, and a long // literal cluster must not suppress a two-line verify command that would have fitted. +// agentVerifyFirstSuffixes re-offers the verify block as the HIGHEST-priority suffix when it had to be +// dropped from the prefix to fit the ranking. +// +// Without this the priority inverts at exactly one cap: VERIFY yields to the ranking (correctly), and +// then the leftover bytes go to a droppable suffix that VERIFY outranks. Re-offering it first means the +// only thing that can ever displace VERIFY is a ranked location. +func agentVerifyFirstSuffixes(seated, verifyBlock []byte, suffixes [][]byte) [][]byte { + if len(seated) > 0 || len(verifyBlock) == 0 { + return suffixes + } + return append([][]byte{verifyBlock}, suffixes...) +} + func fitAgentSearchSuffixes(payload []byte, suffixes [][]byte, budget int) []byte { if budget <= 0 { return payload @@ -1203,6 +1670,40 @@ func parseSearchFlags(args []string) (searchFlags, []string, error) { return flags, nil, err } flags.EnclosureContextLines, i = value, next + // --full-unit-top N: render the first N ranks as their complete enclosing unit whatever the + // opportunistic body upgrade would have done. 0 (the default) is today's payload exactly. + // See SearchOptions.FullUnitTop and the editability comment in internal/sem/search_enclosure.go. + case "--full-unit-top": + value, next, err := searchNonNegativeIntFlag(args, i) + if err != nil { + return flags, nil, err + } + flags.FullUnitTop, i = value, next + // --edit-site-bodies: give the SAME-CONCEPT LITERAL block's EDIT sites their source. + case "--edit-site-bodies": + flags.EditSiteBodies = true + // --callee-hop: admit the top hit's outgoing CALLS targets as candidate fix sites. See + // internal/sem/search_callee.go for the measured miss it closes and why the related-sites + // block cannot. + case "--callee-hop": + flags.CalleeHop = true + // --verify-prefix : a decorator baked into the emitted VERIFY command, after any + // `cd &&` the derivation added, so a harness can grep its own token out of a session + // transcript. "VERIFY: " stays byte-identical at line start. + case "--verify-prefix": + value, next, err := searchFlagValue(args, i) + if err != nil { + return flags, nil, err + } + flags.VerifyPrefix, i = value, next + // --verify-prefix-status : one caller-computed line rendered verbatim under VERIFY:. The + // harness validates the pristine tree; the binary must not invent a status for a run it never made. + case "--verify-prefix-status": + value, next, err := searchFlagValue(args, i) + if err != nil { + return flags, nil, err + } + flags.VerifyPreFixStatus, i = value, next case "--max-regions-per-file": value, next, err := searchPositiveIntFlag(args, i) if err != nil { diff --git a/internal/cli/search_agent_blocks_test.go b/internal/cli/search_agent_blocks_test.go index 112642a8..dbd9288d 100644 --- a/internal/cli/search_agent_blocks_test.go +++ b/internal/cli/search_agent_blocks_test.go @@ -160,11 +160,81 @@ func TestAgentSearchPutsTheWarningInThePrefixAndTheRestInTheSuffix(t *testing.T) warning := strings.Index(rendered, "CLOSED SET Ops") location := strings.Index(rendered, "1. Ops.java") verify := strings.Index(rendered, "VERIFY:") + literal := strings.Index(rendered, sem.LiteralClusterBlockName) if warning < 0 || location < 0 || verify < 0 { t.Fatalf("missing a block:\n%s", rendered) } - if !(warning < location && location < verify) { - t.Fatalf("agent-format order is wrong:\n%s", rendered) + // VERIFY moved into the PREFIX, ahead of the ranking. It is the one block whose absence measurably + // changes behaviour (sessions carrying one spent 30.6% fewer tokens than the baseline against + // 15.2% without), so it must not sit in the set the byte fitter silently drops. The literal cluster + // stays a suffix: it names something the agent could go and grep for itself. + if !(warning < verify && verify < location) { + t.Fatalf("agent-format order is wrong (want closed-set < verify < ranking):\n%s", rendered) + } + if literal >= 0 && literal < location { + t.Fatalf("the literal cluster left the suffix set:\n%s", rendered) + } +} + +// TestAgentSearchVerifyOutranksEverySuffixButNeverTheRanking pins the exact reconciliation of the two +// invariants that meet here. +// +// VERIFY is promoted out of the droppable suffix set because it is the one block whose absence +// measurably changes behaviour: sessions carrying one spent 30.6% fewer tokens than the no-tool +// baseline against 15.2% for sessions without. But the standing rule that a non-ranking block never +// costs the caller a ranked LOCATION is older and stronger — a location an agent never sees is a file +// it never opens. So VERIFY outranks every other suffix and yields only to the ranking itself, which is +// what its own variant rung expresses. +func TestAgentSearchVerifyOutranksEverySuffixButNeverTheRanking(t *testing.T) { + t.Parallel() + response := searchAgentBlockResponse() + // Roomy: VERIFY is present, and it is present AHEAD of the ranking. + var roomy bytes.Buffer + if err := writeAgentSearch(&roomy, response, 4096); err != nil { + t.Fatal(err) + } + verify, location := strings.Index(roomy.String(), "VERIFY:"), strings.Index(roomy.String(), "1. Ops.java") + if verify < 0 || location < 0 || verify > location { + t.Fatalf("VERIFY is not seated ahead of the ranking:\n%s", roomy.String()) + } + // FIRST REFUSAL, which is what "outranks" can honestly mean here. VERIFY is offered the leftover + // bytes before any other suffix; when it fits it is seated, and a smaller suffix may still use space + // VERIFY could not have used anyway — refusing 122 bytes of literal cluster because a 328-byte + // verify block did not fit would waste the budget for no gain. + verifyBytes := len(sem.RenderSearchVerifyCommand(response.VerifyCommand)) + for budget := 700; budget <= 1500; budget += 100 { + var out bytes.Buffer + if err := writeAgentSearch(&out, response, budget); err != nil { + t.Fatal(err) + } + rendered := out.String() + if len(rendered) > budget { + t.Fatalf("budget %d overrun: %d bytes", budget, len(rendered)) + } + if strings.Contains(rendered, "VERIFY:") { + continue + } + // VERIFY absent: it must be because it genuinely did not fit, not because a lower-priority + // block took the space first. + if len(rendered)+verifyBytes <= budget { + t.Fatalf("budget %d had %d spare bytes for a %d-byte VERIFY and dropped it:\n%s", + budget, budget-len(rendered), verifyBytes, rendered) + } + } + // And it never costs the ranking: every budget that produced any output at all must still be able + // to show a location, VERIFY or no VERIFY. + for budget := 200; budget <= 500; budget += 50 { + var out bytes.Buffer + if err := writeAgentSearch(&out, response, budget); err != nil { + t.Fatal(err) + } + rendered := out.String() + if len(rendered) > budget { + t.Fatalf("budget %d overrun: %d bytes", budget, len(rendered)) + } + if strings.Contains(rendered, "VERIFY:") && !strings.Contains(rendered, "Ops.java") { + t.Fatalf("budget %d spent the ranking on VERIFY:\n%s", budget, rendered) + } } } diff --git a/internal/cli/search_agent_test.go b/internal/cli/search_agent_test.go index c9f43db6..59a0d6b7 100644 --- a/internal/cli/search_agent_test.go +++ b/internal/cli/search_agent_test.go @@ -143,6 +143,10 @@ func TestWriteTextSearchTiersRankOneAndTwoFullRestTerse(t *testing.T) { {Rank: 2, FilePath: "src/other.go", StartLine: 1, EndLine: 3, FocusLine: 1, Score: 9.0, SymbolName: "other", Signals: []string{"body"}, Snippet: "func other() {}"}, {Rank: 3, FilePath: "src/third.go", StartLine: 20, EndLine: 30, FocusLine: 22, Score: 8.0, QualifiedName: "Third.method", Signals: []string{"body"}, Snippet: "func method() {\n\t// long\n}"}, {Rank: 4, FilePath: "src/fourth.go", StartLine: 40, EndLine: 44, FocusLine: 0, Score: 7.0, Signals: []string{"body"}, Snippet: "func fourth() {}"}, + // Rank 5 carries the StartLine fallback for a NAMED demotion, which is the shape rank 4 used + // to cover before a nameless demotion stopped collapsing to coordinates (see + // sem.SearchLocatorWindow and TestWriteTextSearchKeepsSourceForANamelessLocator). + {Rank: 5, FilePath: "src/fifth.go", StartLine: 50, EndLine: 54, FocusLine: 0, Score: 6.0, SymbolName: "fifth", Signals: []string{"body"}, Snippet: "func fifth() {}"}, }} var buf bytes.Buffer @@ -161,17 +165,29 @@ func TestWriteTextSearchTiersRankOneAndTwoFullRestTerse(t *testing.T) { t.Fatalf("rank 3 must NOT carry its snippet:\n%s", out) } // Terse lines carry no score= (PR #61 review: no consumer; 39% of added bytes). - if !strings.Contains(out, "3. src/third.go:22 Third.method\n") { + // The locator now names the verb that fetches the body it does not carry: redis's agent + // hand-sed-ranged exactly this shape, and fmt's blind-Read a 200-line window off one. + if !strings.Contains(out, "3. src/third.go:22 Third.method [body: def Third.method]\n") { t.Fatalf("rank 3 terse line missing/wrong shape (no score expected):\n%s", out) } if strings.Contains(out, "src/third.go:22 Third.method score=") { t.Fatalf("rank 3 terse line must NOT carry a score:\n%s", out) } - if strings.Contains(out, "func fourth() {}") { - t.Fatalf("rank 4 must NOT carry its snippet:\n%s", out) + // Rank 4 has NO symbol name, so the terse form has nothing to be terse WITH: no name, and + // therefore no `[body: def NAME]` follow-up either. It keeps the window the ranker already + // allocated it rather than collapsing to coordinates, capped at sem's own ceiling. The tier is + // unchanged for every hit that has a name — rank 3 above is still terse. + if !strings.Contains(out, "4. src/fourth.go:40-40\nfunc fourth() {}\n") { + t.Fatalf("rank 4 nameless demotion lost its window:\n%s", out) } - if !strings.Contains(out, "4. src/fourth.go:40\n") { - t.Fatalf("rank 4 terse line should fall back to StartLine when FocusLine unset (no score):\n%s", out) + if strings.Contains(out, "src/fourth.go:40-40 score=") { + t.Fatalf("a demoted line must NOT carry a score:\n%s", out) + } + if !strings.Contains(out, "5. src/fifth.go:50 fifth [body: def fifth]\n") { + t.Fatalf("rank 5 terse line should fall back to StartLine when FocusLine unset (no score):\n%s", out) + } + if strings.Contains(out, "func fifth() {}") { + t.Fatalf("rank 5 must NOT carry its snippet:\n%s", out) } // PR #61 review: the redundant READ window hint is dropped — the top ranks // carry FocusLine in the header + lines=Start-End, which is the region to open. @@ -219,7 +235,7 @@ func TestWriteTextSearchAlwaysPrintsCompleteBodies(t *testing.T) { if strings.Contains(out, "// window") { t.Fatalf("an ordinary window below the tier kept its snippet:\n%s", out) } - if !strings.Contains(out, "6. src/other.go:41 other\n") { + if !strings.Contains(out, "6. src/other.go:41 other [body: def other]\n") { t.Fatalf("ordinary rank 6 lost its locator line:\n%s", out) } } diff --git a/internal/cli/search_editability_test.go b/internal/cli/search_editability_test.go new file mode 100644 index 00000000..6dcdd9ca --- /dev/null +++ b/internal/cli/search_editability_test.go @@ -0,0 +1,357 @@ +package cli + +import ( + "bytes" + "strconv" + "strings" + "testing" + + "github.com/entireio/entire-graph/internal/sem" +) + +// TestParseSearchFlagsLeavesTheEditabilityLeversOff is the regression guard on the default payload: +// both levers must be inert unless asked for, because every measurement of the shipped payload was +// taken with them off. +func TestParseSearchFlagsLeavesTheEditabilityLeversOff(t *testing.T) { + t.Parallel() + flags, rest, err := parseSearchFlags([]string{"--query", "x"}) + if err != nil || len(rest) != 0 { + t.Fatalf("parseSearchFlags err=%v rest=%v", err, rest) + } + if flags.FullUnitTop != 0 { + t.Fatalf("FullUnitTop = %d, want 0 (today's behaviour)", flags.FullUnitTop) + } + if flags.EditSiteBodies { + t.Fatal("EditSiteBodies is on by default") + } +} + +func TestParseSearchFlagsReadsTheEditabilityLevers(t *testing.T) { + t.Parallel() + flags, _, err := parseSearchFlags([]string{ + "--query", "x", "--full-unit-top", "2", "--edit-site-bodies", + }) + if err != nil { + t.Fatalf("parseSearchFlags: %v", err) + } + if flags.FullUnitTop != 2 { + t.Fatalf("FullUnitTop = %d, want 2", flags.FullUnitTop) + } + if !flags.EditSiteBodies { + t.Fatal("--edit-site-bodies did not take") + } + // 0 is meaningful (explicitly off), so the flag takes a non-negative int rather than a positive + // one — and a negative value is still an error rather than a silent no-op. + zero, _, err := parseSearchFlags([]string{"--query", "x", "--full-unit-top", "0"}) + if err != nil || zero.FullUnitTop != 0 { + t.Fatalf("--full-unit-top 0: err=%v value=%d", err, zero.FullUnitTop) + } + if _, _, err := parseSearchFlags([]string{"--query", "x", "--full-unit-top", "-1"}); err == nil { + t.Fatal("--full-unit-top -1 was accepted") + } + if _, _, err := parseSearchFlags([]string{"--query", "x", "--full-unit-top"}); err == nil { + t.Fatal("--full-unit-top with no value was accepted") + } +} + +// TestSearchTextPrintsTheUnitElisionNoteAfterTheBody pins where the note goes. It is its OWN line +// after the source, never an inline marker inside it: agents copy body text verbatim as the +// `old_string` anchor of an edit, so decorating or interleaving the source turns a navigation aid +// into a broken patch (the same reason focus= rides in the header). +func TestSearchTextPrintsTheUnitElisionNoteAfterTheBody(t *testing.T) { + t.Parallel() + clipped := sem.SearchResult{ + Rank: 1, FilePath: "py/mpz.c", Score: 12, StartLine: 100, EndLine: 900, FocusLine: 152, + SnippetStartLine: 100, SnippetEndLine: 499, Snippet: "int mpz_and_inpl(void) {\n body;\n}", + Signals: []string{sem.FullUnitSignal, "unit-elided"}, SymbolName: "mpz_and_inpl", + UnitStartLine: 100, UnitEndLine: 900, + } + var out bytes.Buffer + writeTextSearchResult(&out, clipped, true) + rendered := out.String() + + note := "…elided lines 500–900 (unit continues)" + if !strings.Contains(rendered, note) { + t.Fatalf("render omits %q:\n%s", note, rendered) + } + body := strings.Index(rendered, "int mpz_and_inpl") + if body < 0 || strings.Index(rendered, note) < body { + t.Fatalf("the note precedes or replaces the body:\n%s", rendered) + } + for _, line := range strings.Split(clipped.Snippet, "\n") { + if !strings.Contains(rendered, "\n"+line+"\n") { + t.Fatalf("body line %q is not printed verbatim on its own line:\n%s", line, rendered) + } + } + + // A result that elided nothing prints no note, so the note's presence always means something. + whole := clipped + whole.UnitStartLine, whole.UnitEndLine = 0, 0 + whole.Signals = []string{sem.FullUnitSignal, sem.CompleteSymbolSignal} + var clean bytes.Buffer + writeTextSearchResult(&clean, whole, true) + if strings.Contains(clean.String(), "elided") { + t.Fatalf("an unclipped unit printed an elision note:\n%s", clean.String()) + } +} + +// TestSearchTextKeepsSourceTheAllocatorPaidFor pins the renderer tier against the bug that made +// fmtlib__fmt-2457 lose its whole rank-3 window: the allocator spent 1,709 B widening that rank, the +// tier asked only "does it carry complete-symbol", and the answer for a WINDOW is no — so the rank +// came out as `include/fmt/ranges.h:682` and the bytes were charged for nothing. +// +// The test is "did the allocator deliberately widen this", and there are three ways it can have. +func TestSearchTextKeepsSourceTheAllocatorPaidFor(t *testing.T) { + t.Parallel() + base := sem.SearchResult{ + Rank: 3, FilePath: "include/fmt/ranges.h", Score: 33, StartLine: 652, EndLine: 712, + FocusLine: 682, SnippetStartLine: 652, SnippetEndLine: 712, + Snippet: "struct formatter {\n // widened\n};", + } + for _, signal := range []string{sem.CompleteSymbolSignal, sem.FullUnitSignal, sem.HeadWindowSignal} { + result := base + result.Signals = []string{"body", signal} + var out bytes.Buffer + // full=false is the tier a rank past the second gets. A widened result must survive it. + writeTextSearchResult(&out, result, false) + if !strings.Contains(out.String(), "struct formatter") { + t.Fatalf("signal %s: the widened source was discarded:\n%s", signal, out.String()) + } + } + // An ordinary un-widened window past the tier still collapses to a locator, which is the + // behaviour the tier exists for. + plain := base + plain.Signals = []string{"body"} + var out bytes.Buffer + writeTextSearchResult(&out, plain, false) + if strings.Contains(out.String(), "struct formatter") { + t.Fatalf("an un-widened result past the tier printed its snippet:\n%s", out.String()) + } +} + +// TestSearchCommandFullUnitTopReturnsTheWholeUnitEndToEnd runs the real command so the flag is pinned +// through the whole path — parser, options, planner, allocator, renderer — not just in the unit that +// implements it. The class here is the case the default payload cannot serve: searchEnclosableSymbolKind +// excludes containers, so without the flag the hit inside `Registry` comes back as a window. +func TestSearchCommandFullUnitTopReturnsTheWholeUnitEndToEnd(t *testing.T) { + repo := t.TempDir() + write(t, repo, "registry.py", `HEADER = 1 + + +class Registry: + """Every known transport, by name.""" + + SFTP_TRANSPORT = "sftp" + HTTP_TRANSPORT = "http" + RSYNC_TRANSPORT = "rsync" + UNIQUE_MARKER_LINE = "the line an edit has to replace" + WEBDAV_TRANSPORT = "webdav" + + +def unrelated(): + return Registry.SFTP_TRANSPORT +`) + + run := func(extra ...string) string { + var out bytes.Buffer + args := append([]string{ + "search", "--repo", repo, "--query", "unique marker line transport registry", + "--format", "text", "--profile", "syntax-only", "--worktree", + "--top-k", "3", "--index-all-files", "--max-snippet-lines", "2", + }, extra...) + if err := Run(t.Context(), Options{ + Version: "0.1.0", Env: EntireEnv{RepoRoot: repo}, Stdout: &out, + }, args); err != nil { + t.Fatalf("search %v: %v", extra, err) + } + return out.String() + } + + forced := run("--full-unit-top", "1") + if !strings.Contains(forced, sem.FullUnitSignal) { + t.Fatalf("--full-unit-top 1 produced no full-unit signal:\n%s", forced) + } + // The whole class is present, so the edit anchor is in the payload verbatim. + for _, want := range []string{"class Registry:", `UNIQUE_MARKER_LINE = "the line an edit has to replace"`} { + if !strings.Contains(forced, want) { + t.Fatalf("--full-unit-top 1 payload is missing %q:\n%s", want, forced) + } + } + if strings.Contains(run(), sem.FullUnitSignal) { + t.Fatal("the default payload carries a full-unit signal") + } +} + +// TestSearchCommandEditSiteBodiesIsOffByDefault pins the other lever end-to-end at the level that +// matters for the default payload: the block's rendered form must not change unless asked. +func TestSearchCommandEditSiteBodiesIsOffByDefault(t *testing.T) { + repo := t.TempDir() + write(t, repo, "codes.go", `package codes + +const RetryBudgetExceeded = "retry_budget_exceeded" + +func classify(code string) bool { + return code == RetryBudgetExceeded +} +`) + write(t, repo, "handler.go", `package codes + +func handle(code string) string { + if code == RetryBudgetExceeded { + return "retry" + } + return "drop" +} +`) + run := func(extra ...string) string { + var out bytes.Buffer + args := append([]string{ + "search", "--repo", repo, "--query", "retry budget exceeded classify", + "--format", "text", "--profile", "syntax-only", "--worktree", + "--top-k", "3", "--index-all-files", + }, extra...) + if err := Run(t.Context(), Options{ + Version: "0.1.0", Env: EntireEnv{RepoRoot: repo}, Stdout: &out, + }, args); err != nil { + t.Fatalf("search %v: %v", extra, err) + } + return out.String() + } + // Whatever the ranker does with this fixture, the two payloads may differ ONLY in the literal + // block — and the default one may never carry a ranged EDIT header, which is the form a site with + // a body takes. + base := run() + if strings.Contains(base, sem.LiteralClusterBlockName) { + for _, line := range strings.Split(base, "\n") { + if strings.HasSuffix(line, " EDIT") && strings.Contains(line, "-") && + strings.HasPrefix(line, " ") { + t.Fatalf("the default literal block printed a ranged EDIT header: %q", line) + } + } + } + // And the flag must be accepted and change nothing else about the run. + if got := run("--edit-site-bodies"); got == "" { + t.Fatal("--edit-site-bodies produced no payload") + } +} + +func TestParseSearchFlagsLeavesTheCalleeHopOff(t *testing.T) { + t.Parallel() + off, _, err := parseSearchFlags([]string{"--query", "x"}) + if err != nil { + t.Fatalf("parseSearchFlags: %v", err) + } + if off.CalleeHop { + t.Fatal("--callee-hop is on by default") + } + on, _, err := parseSearchFlags([]string{"--query", "x", "--callee-hop"}) + if err != nil || !on.CalleeHop { + t.Fatalf("--callee-hop did not take: err=%v value=%v", err, on.CalleeHop) + } +} + +// TestSearchTextPrintsNoScoreOnACalleeHop pins the header. A callee-hop entry was admitted by a CALLS +// edge, not by relevance, so it carries no ranked score — and `score=0.0000` beside a real fix site +// reads as "worthless" rather than "not applicable", which is exactly why the covering test is +// excluded from the score column too. +func TestSearchTextPrintsNoScoreOnACalleeHop(t *testing.T) { + t.Parallel() + hop := sem.SearchResult{ + Rank: 2, FilePath: "src/t_list.c", StartLine: 489, EndLine: 535, FocusLine: 489, + SnippetStartLine: 489, SnippetEndLine: 535, Snippet: "void popGenericCommand(client *c) {\n}", + SymbolName: "popGenericCommand", Kind: "function", + Signals: []string{sem.CalleeHopSignal, sem.FullUnitSignal, sem.CompleteSymbolSignal}, + } + var out bytes.Buffer + writeTextSearchResult(&out, hop, true) + rendered := out.String() + if strings.Contains(rendered, "score=") { + t.Fatalf("a callee hop printed a ranked score:\n%s", rendered) + } + for _, want := range []string{ + "2. src/t_list.c:489-535 symbol=popGenericCommand", sem.CalleeHopSignal, "void popGenericCommand", + } { + if !strings.Contains(rendered, want) { + t.Fatalf("render is missing %q:\n%s", want, rendered) + } + } + // It also survives the tier a rank past the second gets — the entry was paid for, so it must + // never come back as a bare locator. + var terse bytes.Buffer + writeTextSearchResult(&terse, hop, false) + if !strings.Contains(terse.String(), "void popGenericCommand") { + t.Fatalf("a callee hop past the full-snippet tier lost its body:\n%s", terse.String()) + } +} + +// TestSearchCommandCalleeHopReachesTheCalledHelper runs the whole command on the measured shape: a +// thin entry point ranked, the gold inside the helper it calls, in a file with far more top-level +// members than searchRelatedUnitMemberLimit so the related block's co-member route is off. +func TestSearchCommandCalleeHopReachesTheCalledHelper(t *testing.T) { + repo := t.TempDir() + // The entry point first, the helper it delegates to at the far end of the file, and enough + // unrelated members between them that (a) the co-member route switches off — its limit is + // searchRelatedUnitMemberLimit — and (b) the span merge cannot bridge the two, which is the + // arrangement the measured instance has (88 lines and ~40 functions apart in src/t_list.c). + var source strings.Builder + source.WriteString("package pop\n\n") + source.WriteString("func lpopCommand() string {\n\treturn popGenericCommand(0)\n}\n\n") + for filler := 0; filler < 40; filler++ { + source.WriteString("func filler" + strconv.Itoa(filler) + "() int {\n\treturn " + + strconv.Itoa(filler) + "\n}\n\n") + } + source.WriteString("// popGenericCommand does the work for every pop entry point.\n") + source.WriteString("func popGenericCommand(where int) string {\n") + source.WriteString("\tif where == 0 {\n") + source.WriteString("\t\treturn sharedNullBulk // the line an edit has to replace\n") + source.WriteString("\t}\n") + source.WriteString("\treturn sharedNullArray\n") + source.WriteString("}\n") + write(t, repo, "pop.go", source.String()) + write(t, repo, "shared.go", "package pop\n\nvar sharedNullBulk = \"$-1\"\nvar sharedNullArray = \"*-1\"\n") + + run := func(extra ...string) string { + var out bytes.Buffer + args := append([]string{ + "search", "--repo", repo, "--query", "lpopCommand returns null bulk instead of null array", + // --top-k 1 is the point of the test, not a convenience: on a two-file fixture the + // ranker's own graph:calls expansion reaches the callee by itself, so the only way to + // exercise THIS route is a ranking that has room for the entry point and nothing else — + // which is also the real shape, where the expansion is candidate-limited and the callee + // did not survive it. + "--format", "text", "--profile", "full", "--worktree", "--top-k", "1", + "--index-all-files", "--max-snippet-lines", "2", + }, extra...) + if err := Run(t.Context(), Options{ + Version: "0.1.0", Env: EntireEnv{RepoRoot: repo}, Stdout: &out, + }, args); err != nil { + t.Fatalf("search %v: %v", extra, err) + } + return out.String() + } + + hopped := run("--full-unit-top", "1", "--callee-hop") + if !strings.Contains(hopped, sem.CalleeHopSignal) { + t.Fatalf("--callee-hop admitted nothing:\n%s", hopped) + } + // The whole point: the line an edit replaces is in the payload verbatim. + if !strings.Contains(hopped, "return sharedNullBulk // the line an edit has to replace") { + t.Fatalf("the called helper's gold line is absent:\n%s", hopped) + } + if strings.Contains(run("--full-unit-top", "1"), sem.CalleeHopSignal) { + t.Fatal("a callee hop appeared without --callee-hop") + } +} + +// TestRenderedSnippetHeadRanksMatchesTheRenderer pins the one number internal/sem duplicates from this +// file. The allocator reclaims the snippet bytes of ranks it believes the renderer prints as bare +// locators (seatForcedSearchUnits); if this tier ever gets deeper, those bytes become visible and the +// reclaim silently starts destroying source the reader would have seen. +func TestRenderedSnippetHeadRanksMatchesTheRenderer(t *testing.T) { + t.Parallel() + if sem.RenderedSnippetHeadRanks != searchTextFullRanks { + t.Fatalf("sem.RenderedSnippetHeadRanks = %d but searchTextFullRanks = %d — the allocator's "+ + "notion of which snippets a reader sees has drifted from the renderer's", + sem.RenderedSnippetHeadRanks, searchTextFullRanks) + } +} diff --git a/internal/cli/search_lowvalue_bodies_test.go b/internal/cli/search_lowvalue_bodies_test.go new file mode 100644 index 00000000..057a0b89 --- /dev/null +++ b/internal/cli/search_lowvalue_bodies_test.go @@ -0,0 +1,254 @@ +package cli + +import ( + "bytes" + "strings" + "testing" + + "github.com/entireio/entire-graph/internal/sem" +) + +func TestSearchLowValueBodyPath(t *testing.T) { + t.Parallel() + for _, testCase := range []struct { + path string + want bool + }{ + {path: "benches/src/keyed-children/index.js", want: true}, + {path: "bench/run.js", want: true}, + {path: "benchmarks/parse_test.go", want: true}, + {path: "packages/demo/app.tsx", want: true}, + {path: "examples/basic/main.py", want: true}, + {path: "karma.conf.js", want: true}, + {path: "config/webpack.config.js", want: true}, + {path: "tools/ci.karma.js", want: true}, + {path: "src/diff/children.js", want: false}, + {path: "src/component.js", want: false}, + // Segment matching, not substring: a source file whose NAME mentions benchmarking is + // still source. + {path: "src/benchmarks_helper.js", want: false}, + {path: "src/exampleRegistry.ts", want: false}, + // The directory rule reads directories only; a file literally called `demo.js` in the + // library is library code. + {path: "src/demo.js", want: false}, + {path: "", want: false}, + } { + if got := searchLowValueBodyPath(testCase.path); got != testCase.want { + t.Errorf("searchLowValueBodyPath(%q) = %v, want %v", testCase.path, got, testCase.want) + } + } +} + +// The preactjs__preact-3010 payload shape: a benchmark and the karma config outranked the library, +// took two of the three body slots, and left the gold file as a bodyless locator. +func lowValueBodyResponse() sem.SearchResponse { + return sem.SearchResponse{Results: []sem.SearchResult{ + { + Rank: 1, FilePath: "benches/src/keyed-children/index.js", StartLine: 12, EndLine: 29, + FocusLine: 12, SnippetStartLine: 12, SnippetEndLine: 29, Score: 26.8, + SymbolName: "render", Kind: "function", Signals: []string{"path", "body", "complete-symbol"}, + Snippet: "export function render(framework, rootDom) {\n\tconst { Main } = getComponents(framework);\n}", + }, + { + Rank: 2, FilePath: "karma.conf.js", StartLine: 93, EndLine: 104, + FocusLine: 95, SnippetStartLine: 93, SnippetEndLine: 104, Score: 25.7, + SymbolName: "subPkgPath", Kind: "function", Signals: []string{"body", "complete-symbol"}, + Snippet: "const subPkgPath = pkgName => {\n\treturn path.join(__dirname, pkgName);\n};", + }, + { + Rank: 3, FilePath: "src/component.js", StartLine: 120, EndLine: 124, + FocusLine: 120, SnippetStartLine: 120, SnippetEndLine: 124, Score: 24.4, + SymbolName: "renderComponent", Kind: "function", Signals: []string{"complete-symbol"}, + Snippet: "function renderComponent(component) {\n\tlet vnode = component._vnode;\n}", + }, + { + Rank: 4, FilePath: "src/diff/children.js", StartLine: 26, EndLine: 30, + FocusLine: 26, CommentFocusLine: 19, SnippetStartLine: 26, SnippetEndLine: 30, Score: 22.5, + Signals: []string{"body", "symbol-usage"}, + Snippet: "export function diffChildren(\n\tparentDom,\n\trenderResult,\n\tnewParentVNode,\n\toldParentVNode,", + }, + { + Rank: 5, FilePath: "src/diff/index.js", StartLine: 264, EndLine: 268, + FocusLine: 264, SnippetStartLine: 264, SnippetEndLine: 268, Score: 24.4, + SymbolName: "commitRoot", Kind: "function", Signals: []string{"complete-symbol"}, + Snippet: "export function commitRoot(commitQueue, root) {\n\tif (options._commit) options._commit(root);\n}", + }, + }} +} + +func TestWriteTextSearchDeniesBodySlotsToLowValuePaths(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + if err := writeTextSearch(&buf, lowValueBodyResponse()); err != nil { + t.Fatal(err) + } + out := buf.String() + // The benchmark and the config script keep their rank and their locator, and lose their body. + // The locator also names the verb that would fetch the body it was denied — a demoted hit is still + // reachable, and the point of the demotion is the byte cost of the body, not hiding the symbol. + if !strings.Contains(out, "1. benches/src/keyed-children/index.js:12 render [body: def render]\n") { + t.Fatalf("rank 1 should be a locator:\n%s", out) + } + if !strings.Contains(out, "2. karma.conf.js:95 subPkgPath [body: def subPkgPath]\n") { + t.Fatalf("rank 2 should be a locator:\n%s", out) + } + if strings.Contains(out, "getComponents(framework)") || strings.Contains(out, "subPkgPath = pkgName") { + t.Fatalf("a low-value path still carries a body:\n%s", out) + } + // The slots they freed go to real source — including the rank that used to be past the + // full-snippet tier, which is the point: a demoted hit charges neither a body slot nor a tier + // position. + if !strings.Contains(out, "function renderComponent(component)") { + t.Fatalf("rank 3 lost its body:\n%s", out) + } + if !strings.Contains(out, "export function diffChildren(") { + t.Fatalf("rank 4 did not receive a freed body slot:\n%s", out) + } + if !strings.Contains(out, "export function commitRoot(commitQueue, root)") { + t.Fatalf("rank 5 lost its body:\n%s", out) + } + // The re-anchored hit reports BOTH lines, so the comment that put it in the payload is still + // visible. + if !strings.Contains(out, "focus=19→26") { + t.Fatalf("re-anchored hit did not report its origin line:\n%s", out) + } +} + +func TestWriteTextSearchKeepsALowValueBodyWhenItIsAllThereIs(t *testing.T) { + t.Parallel() + // One real source hit is not enough to fund the demotion: the rule is "never outrank real + // source for a body slot", not "never show a body". + response := sem.SearchResponse{Results: []sem.SearchResult{ + { + Rank: 1, FilePath: "benches/run.js", StartLine: 1, EndLine: 3, FocusLine: 1, + SnippetStartLine: 1, SnippetEndLine: 3, Score: 20, SymbolName: "run", + Signals: []string{"body"}, Snippet: "function run() {\n\tmeasure();\n}", + }, + { + Rank: 2, FilePath: "src/only.js", StartLine: 5, EndLine: 6, FocusLine: 5, + SnippetStartLine: 5, SnippetEndLine: 6, Score: 18, SymbolName: "only", + Signals: []string{"body"}, Snippet: "function only() {}", + }, + }} + var buf bytes.Buffer + if err := writeTextSearch(&buf, response); err != nil { + t.Fatal(err) + } + if out := buf.String(); !strings.Contains(out, "function run() {") { + t.Fatalf("the only-benchmark payload lost its source:\n%s", out) + } +} + +// The vuejs__core-11870 shape: three ordinary hits already claim the diet's three body slots, and the +// re-anchored hit at rank 3 also carries a complete body. It must NOT take rank 5's slot. +func reanchorFundingResponse() sem.SearchResponse { + return sem.SearchResponse{Results: []sem.SearchResult{ + { + Rank: 1, FilePath: "packages/reactivity/src/reactive.ts", StartLine: 140, EndLine: 150, + FocusLine: 140, SnippetStartLine: 140, SnippetEndLine: 150, Score: 78, + SymbolName: "shallowReactive", Kind: "function", Signals: []string{"body", "complete-symbol"}, + Snippet: "export function shallowReactive(target) {\n\treturn createReactiveObject(target)\n}", + }, + { + Rank: 2, FilePath: "packages/reactivity/src/reactive.ts", StartLine: 257, EndLine: 298, + FocusLine: 257, SnippetStartLine: 257, SnippetEndLine: 298, Score: 74, + SymbolName: "createReactiveObject", Kind: "function", Signals: []string{"complete-symbol"}, + Snippet: "function createReactiveObject(target) {\n\treturn new Proxy(target, handlers)\n}", + }, + { + Rank: 3, FilePath: "packages/reactivity/src/arrayInstrumentations.ts", StartLine: 12, EndLine: 17, + FocusLine: 12, CommentFocusLine: 10, BodyFromReanchor: true, + SnippetStartLine: 12, SnippetEndLine: 17, Score: 70, + SymbolName: "reactiveReadArray", Kind: "function", Signals: []string{"body", "symbol-usage", "complete-symbol"}, + Snippet: "export function reactiveReadArray(array) {\n\tconst raw = toRaw(array)\n}", + }, + { + Rank: 4, FilePath: "packages/reactivity/src/index.ts", StartLine: 28, EndLine: 32, + FocusLine: 30, SnippetStartLine: 28, SnippetEndLine: 32, Score: 69, + Signals: []string{"body", "symbol-usage"}, Snippet: "export { shallowReactive }", + }, + { + Rank: 5, FilePath: "packages/runtime-core/src/helpers/renderList.ts", StartLine: 54, EndLine: 107, + FocusLine: 54, SnippetStartLine: 54, SnippetEndLine: 107, Score: 69, + SymbolName: "renderList", Kind: "function", Signals: []string{"path", "body", "complete-symbol"}, + Snippet: "export function renderList(source, renderItem) {\n\tlet ret\n\treturn ret\n}", + }, + }} +} + +func TestWriteTextSearchNeverFundsAReanchoredBodyByEvictingOne(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + if err := writeTextSearch(&buf, reanchorFundingResponse()); err != nil { + t.Fatal(err) + } + out := buf.String() + // The body that already existed survives, whole. + if !strings.Contains(out, "5. packages/runtime-core/src/helpers/renderList.ts:54-107 ") || + !strings.Contains(out, "export function renderList(source, renderItem)") { + t.Fatalf("the re-anchored hit evicted an existing body:\n%s", out) + } + // The re-anchored hit yields the slot and keeps the anchor move, which is the part that costs + // nothing: its locator points at the code line, not at the comment. + if strings.Contains(out, "export function reactiveReadArray(array)") { + t.Fatalf("the re-anchored hit took a slot it could not fund:\n%s", out) + } + if !strings.Contains(out, "3. packages/reactivity/src/arrayInstrumentations.ts:12 reactiveReadArray [body: def reactiveReadArray]\n") { + t.Fatalf("the re-anchored locator lost its code anchor:\n%s", out) + } +} + +func TestWriteTextSearchGivesAReanchoredHitAFreeSlot(t *testing.T) { + t.Parallel() + // Same shape with rank 5's body removed: one slot is now genuinely free, so the re-anchored hit + // takes it. The rule is "never evict", not "never fund". + response := reanchorFundingResponse() + response.Results[4].Signals = []string{"path", "body"} + var buf bytes.Buffer + if err := writeTextSearch(&buf, response); err != nil { + t.Fatal(err) + } + out := buf.String() + if !strings.Contains(out, "export function reactiveReadArray(array)") { + t.Fatalf("a free slot was not given to the re-anchored hit:\n%s", out) + } + if !strings.Contains(out, "focus=10→12") { + t.Fatalf("the re-anchored body lost its origin line:\n%s", out) + } +} + +func TestWriteTextSearchIsUnchangedWithoutLowValuePaths(t *testing.T) { + t.Parallel() + // The regression guard for the tier change: with no low-value hit in the group, the body diet + // must count exactly what it counted before. + response := lowValueBodyResponse() + response.Results[0].FilePath = "src/keyed-children.js" + response.Results[1].FilePath = "src/pkg-path.js" + var buf bytes.Buffer + if err := writeTextSearch(&buf, response); err != nil { + t.Fatal(err) + } + out := buf.String() + for _, want := range []string{"getComponents(framework)", "subPkgPath = pkgName", "function renderComponent(component)"} { + if !strings.Contains(out, want) { + t.Fatalf("expected %q in the ordinary payload:\n%s", want, out) + } + } + // Three bodies is the cap, so ranks 4 and 5 are both demoted (complete-symbol is exempt from the + // rank tier, not from the cap). The cap is counted on the BODIED renders themselves, not on a + // substring of one of them: rank 4 has no symbol name, so its demotion keeps the ≤6-line window + // the ranker allocated it instead of collapsing to coordinates, and that window opens with the + // same line its body would have (see sem.SearchLocatorWindow). + if got := strings.Count(out, "score="); got != 3 { + t.Fatalf("the body cap was breached: %d bodied hits\n%s", got, out) + } + if !strings.Contains(out, "5. src/diff/index.js:264 commitRoot [body: def commitRoot]\n") { + t.Fatalf("rank 5 should be demoted to a named locator:\n%s", out) + } + // The nameless demotion keeps its window and its matched line, and nothing more: the allocator + // gave rank 4 lines 26-30 and that is exactly what is printed, not the 26-30 ranked region of a + // body it was denied. + if !strings.Contains(out, "4. src/diff/children.js:26-30 focus=26\n") { + t.Fatalf("rank 4 nameless demotion lost its window or its focus line:\n%s", out) + } +} diff --git a/internal/cli/search_nameless_locator_test.go b/internal/cli/search_nameless_locator_test.go new file mode 100644 index 00000000..9fdcfeca --- /dev/null +++ b/internal/cli/search_nameless_locator_test.go @@ -0,0 +1,146 @@ +package cli + +import ( + "bytes" + "strings" + "testing" + + "github.com/entireio/entire-graph/internal/sem" +) + +// namelessLocatorResponse is the scikit-learn__scikit-learn-14629 payload shape, taken from the live +// db24f74 binary at the shipped defaults (--top-k 10 --max-snippet-lines 6 --max-context-bytes +// 16384) for the issue title "AttributeError with cross_val_predict(method='predict_proba') when +// using MultiOuputClassifier". +// +// Ranks 1-3 take the render diet's three body slots. Rank 4 has a symbol name, so its demotion keeps +// a locator that still says what it is and how to fetch it. Rank 5 is `sklearn/multioutput.py:24` — +// `from .model_selection import cross_val_predict`, the import coupling the issue is about, in the +// file the fix lands in — and it has no enclosing indexed symbol, so it has no name. It came back as +// coordinates and nothing else while the ranker had already computed and allocated its window. +func namelessLocatorResponse() sem.SearchResponse { + return sem.SearchResponse{Results: []sem.SearchResult{ + { + Rank: 1, FilePath: "sklearn/model_selection/_validation.py", StartLine: 698, EndLine: 777, + FocusLine: 774, SnippetStartLine: 771, SnippetEndLine: 776, Score: 85.7534, + SymbolName: "cross_val_predict", Kind: "function", + Signals: []string{"body", "exact-symbol", "symbol-name"}, + Snippet: " if not _check_is_permutation(test_indices, _num_samples(X)):\n" + + " raise ValueError('cross_val_predict only works for partitions')", + }, + { + Rank: 2, FilePath: "sklearn/ensemble/gradient_boosting.py", StartLine: 2197, EndLine: 2225, + FocusLine: 2224, SnippetStartLine: 2197, SnippetEndLine: 2225, Score: 83.6608, + SymbolName: "GradientBoostingClassifier.predict_proba", Kind: "method", + Signals: []string{"body", "exact-symbol", "complete-symbol"}, + Snippet: " def predict_proba(self, X):\n raw_predictions = self.decision_function(X)", + }, + { + Rank: 3, FilePath: "sklearn/base.py", StartLine: 632, EndLine: 645, + FocusLine: 632, SnippetStartLine: 632, SnippetEndLine: 645, Score: 82.7824, + SymbolName: "is_classifier", Kind: "function", + Signals: []string{"graph:calls", "complete-symbol"}, + Snippet: "def is_classifier(estimator):\n return getattr(estimator, \"_estimator_type\", None) == \"classifier\"", + }, + { + Rank: 4, FilePath: "sklearn/ensemble/voting.py", StartLine: 326, EndLine: 342, + FocusLine: 340, SnippetStartLine: 326, SnippetEndLine: 342, Score: 80.1, + SymbolName: "VotingClassifier.predict_proba", Kind: "method", + Signals: []string{"body", "exact-symbol"}, + Snippet: " def predict_proba(self):\n return self._predict_proba", + }, + { + Rank: 5, FilePath: "sklearn/multioutput.py", StartLine: 22, EndLine: 26, + FocusLine: 24, SnippetStartLine: 22, SnippetEndLine: 26, Score: 79.4, + Signals: []string{"body", "symbol-usage"}, + Snippet: "from .base import BaseEstimator, clone, MetaEstimatorMixin\n" + + "from .base import RegressorMixin, ClassifierMixin, is_classifier\n" + + "from .model_selection import cross_val_predict\n" + + "from .utils import check_array, check_X_y, check_random_state\n" + + "from .utils.fixes import parallel_helper", + }, + }} +} + +// A demoted hit with no symbol name must not come back as coordinates alone. It has no name for the +// `[body: def NAME]` follow-up either, so the bare form leaves an agent with a file read as the only +// way to act on the line — which is the cost the payload exists to remove. +func TestWriteTextSearchKeepsSourceForANamelessLocator(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + if err := writeTextSearch(&buf, namelessLocatorResponse()); err != nil { + t.Fatal(err) + } + out := buf.String() + // THE DEFECT: the bare locator, coordinates and nothing else. + if strings.Contains(out, "5. sklearn/multioutput.py:24\n") { + t.Fatalf("nameless hit rendered as a bare locator:\n%s", out) + } + // The source the ranker had already computed is what the line carries instead. + if !strings.Contains(out, "from .model_selection import cross_val_predict") { + t.Fatalf("nameless hit lost the window the ranker allocated it:\n%s", out) + } + // The header names exactly the lines printed under it, and keeps the matched line, which was the + // bare locator's only piece of information. + if !strings.Contains(out, "5. sklearn/multioutput.py:22-26 focus=24\n") { + t.Fatalf("nameless hit lost its range or its focus line:\n%s", out) + } + // The named demotions are untouched: this changes what a locator does only when there is no name + // to put on one. + if !strings.Contains(out, "4. sklearn/ensemble/voting.py:340 VotingClassifier.predict_proba [body: def VotingClassifier.predict_proba]\n") { + t.Fatalf("a NAMED locator changed shape:\n%s", out) + } + if strings.Contains(out, "return self._predict_proba") { + t.Fatalf("a named locator gained a body it was denied:\n%s", out) + } + // The body diet still holds at three. + if got := strings.Count(out, "score="); got != 3 { + t.Fatalf("body count changed: %d bodied hits\n%s", got, out) + } +} + +// A nameless hit that carries no source at all has nothing to keep, so it still falls back to the +// bare locator. A renderer that printed an empty body instead would read as a truncation bug. +func TestWriteTextSearchStillEmitsABareLocatorWithoutSource(t *testing.T) { + t.Parallel() + response := namelessLocatorResponse() + response.Results[4].Snippet = "" + var buf bytes.Buffer + if err := writeTextSearch(&buf, response); err != nil { + t.Fatal(err) + } + if out := buf.String(); !strings.Contains(out, "5. sklearn/multioutput.py:24\n") { + t.Fatalf("a sourceless nameless hit lost its locator:\n%s", out) + } +} + +// The cap is a ceiling on what a demoted hit keeps, not a licence to print whatever some other lever +// widened the window to. A 60-line head window reaching this path through the diet's body cap keeps +// six lines centred on the match, not sixty. +func TestWriteTextSearchClipsAWideNamelessWindow(t *testing.T) { + t.Parallel() + lines := make([]string, 0, 60) + for index := 1; index <= 60; index++ { + lines = append(lines, "line"+string(rune('a'+index%26))+"()") + } + lines[29] = "MATCHED_LINE()" + response := namelessLocatorResponse() + response.Results[4] = sem.SearchResult{ + Rank: 5, FilePath: "py/objint.h", StartLine: 1, EndLine: 60, FocusLine: 30, + SnippetStartLine: 1, SnippetEndLine: 60, Score: 79.4, + Signals: []string{"head-window"}, Snippet: strings.Join(lines, "\n"), + } + // A head window claims a complete body, so force it down the locator path the way the diet's + // body cap does: three bodies are already spent above it. + var buf bytes.Buffer + if err := writeTextSearch(&buf, response); err != nil { + t.Fatal(err) + } + out := buf.String() + if !strings.Contains(out, "MATCHED_LINE()") { + t.Fatalf("clipping removed the matched line:\n%s", out) + } + if !strings.Contains(out, "5. py/objint.h:28-33 focus=30\n") { + t.Fatalf("clipped window did not report the lines it printed:\n%s", out) + } +} diff --git a/internal/cli/search_presearch_test.go b/internal/cli/search_presearch_test.go new file mode 100644 index 00000000..84c71f06 --- /dev/null +++ b/internal/cli/search_presearch_test.go @@ -0,0 +1,134 @@ +package cli + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +// presearchPayload is deliberately NOT anything the renderer could produce: if a byte of it is +// missing, added, or reordered, the call re-queried instead of echoing. +const presearchPayload = "PRE-DELIVERED PAYLOAD\nsrc/auth.py:1 validate_token\n" + +// TestSearchEchoesPreDeliveredPayload pins zero-toll delivery: when the payload was computed before +// the agent started, `search` returns it byte-for-byte and does no work — not the repo, not the +// index, not the ranking. The nonexistent-repo case is the proof that no work happens: a live query +// cannot resolve that repo, so an echo that survives it cannot have run one. +func TestSearchEchoesPreDeliveredPayload(t *testing.T) { + repo := t.TempDir() + write(t, repo, "src/auth.py", "def validate_token(token):\n return bool(token)\n") + path := filepath.Join(t.TempDir(), "presearch.txt") + if err := os.WriteFile(path, []byte(presearchPayload), 0o600); err != nil { + t.Fatal(err) + } + + for _, testCase := range []struct { + name string + envName string + repo string + }{ + {name: "documented name", envName: envPresearch, repo: repo}, + {name: "harness alias", envName: envPresearchAlias, repo: repo}, + {name: "repo never resolved", envName: envPresearch, repo: filepath.Join(repo, "absent")}, + } { + t.Run(testCase.name, func(t *testing.T) { + t.Setenv(testCase.envName, path) + var out bytes.Buffer + err := Run(t.Context(), Options{ + Version: "test", + Env: EnvFromOS(), + Stdout: &out, + Stderr: &out, + }, []string{"search", "--repo", testCase.repo, "--query", "validate the token", "--format", "text"}) + if err != nil { + t.Fatal(err) + } + if out.String() != presearchPayload { + t.Fatalf("payload is not byte-identical:\n got %q\nwant %q", out.String(), presearchPayload) + } + // Named separately because it is the failure that matters: the call went to the graph. + if strings.Contains(out.String(), "def validate_token") { + t.Fatalf("search re-queried the repo instead of echoing:\n%s", out.String()) + } + }) + } + + // Both names set: the documented one wins, so a harness that leaves the alias behind cannot + // silently serve a stale payload. + t.Run("documented name wins", func(t *testing.T) { + stale := filepath.Join(t.TempDir(), "stale.txt") + if err := os.WriteFile(stale, []byte("STALE"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv(envPresearch, path) + t.Setenv(envPresearchAlias, stale) + var out bytes.Buffer + if err := Run(t.Context(), Options{Version: "test", Env: EnvFromOS(), Stdout: &out, Stderr: &out}, + []string{"search", "--repo", repo, "--query", "validate the token"}); err != nil { + t.Fatal(err) + } + if out.String() != presearchPayload { + t.Fatalf("alias overrode the documented name: %q", out.String()) + } + }) +} + +// TestSearchRefusesUnreadablePresearchPayload pins that a payload the caller promised but cannot +// produce is an error, not a silent live query: a run that half-echoes and half-queries measures +// neither mode. +func TestSearchRefusesUnreadablePresearchPayload(t *testing.T) { + repo := t.TempDir() + write(t, repo, "src/auth.py", "def validate_token(token):\n return bool(token)\n") + t.Setenv(envPresearch, filepath.Join(t.TempDir(), "never-written.txt")) + + var out bytes.Buffer + err := Run(t.Context(), Options{Version: "test", Env: EnvFromOS(), Stdout: &out, Stderr: &out}, + []string{"search", "--repo", repo, "--query", "validate the token", "--format", "text"}) + if err == nil { + t.Fatalf("a missing payload fell back to a live query:\n%s", out.String()) + } + if !strings.Contains(err.Error(), envPresearch) { + t.Fatalf("error does not name the variable that caused it: %v", err) + } + if out.Len() != 0 { + t.Fatalf("stdout is not empty after a refused echo:\n%s", out.String()) + } +} + +// TestSearchRefusesEmptyPresearchPayload pins the failure that is worse than an unreadable path, +// because every layer above it reads as success: a zero-byte payload used to be written to stdout +// and the process exited 0. The agent asks a question, receives nothing, and the harness, the exit +// code and the transcript all record a healthy call — which is how a whole measured cell can run +// without its search verb ever answering anything. +func TestSearchRefusesEmptyPresearchPayload(t *testing.T) { + repo := t.TempDir() + write(t, repo, "src/auth.py", "def validate_token(token):\n return bool(token)\n") + empty := filepath.Join(t.TempDir(), "empty.txt") + if err := os.WriteFile(empty, nil, 0o600); err != nil { + t.Fatal(err) + } + + for _, envName := range []string{envPresearch, envPresearchAlias} { + t.Run(envName, func(t *testing.T) { + t.Setenv(envName, empty) + var out bytes.Buffer + err := Run(t.Context(), Options{Version: "test", Env: EnvFromOS(), Stdout: &out, Stderr: &out}, + []string{"search", "--repo", repo, "--query", "validate the token", "--format", "text"}) + if err == nil { + t.Fatalf("an empty payload was reported as a successful search:\n%q", out.String()) + } + if !strings.Contains(err.Error(), envPresearch) { + t.Fatalf("error does not name the variable that caused it: %v", err) + } + // The path belongs in the message: the caller has to know WHICH file came back empty. + if !strings.Contains(err.Error(), empty) { + t.Fatalf("error does not name the empty file: %v", err) + } + if out.Len() != 0 { + t.Fatalf("stdout is not empty after a refused echo:\n%q", out.String()) + } + }) + } +} diff --git a/internal/cli/search_session.go b/internal/cli/search_session.go new file mode 100644 index 00000000..2c0eec5c --- /dev/null +++ b/internal/cli/search_session.go @@ -0,0 +1,216 @@ +package cli + +import ( + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "strings" +) + +// The search echo: one real search per task. The second and later search of the same task returns +// the FIRST search's payload verbatim under a one-line header, instead of running a new query. +// +// Why a cap at all. Measured on agentic SWE-bench sessions, as the cost ratio against the same +// instance solved with no tool at all: sessions that made >=4 graph calls (n=16) cost 1.148 of +// baseline, sessions that made exactly ONE call (n=70) cost 0.975 — difference +0.173, bootstrap +// CI [+0.019,+0.324], which excludes zero. The gradient repeats per configuration: 1.00 +// calls/session -> -12.7% (the cheapest Opus cell measured), 1.17 -> -0.7%, 1.63 -> +5.1%, and +// 4.45 calls/session (76% of its sessions multi-call) -> the worst retrieval of the set. +// +// The natural experiment is one instance, facebook__docusaurus-9183, under two configurations. +// One call: gold file at rank 1, $0.273, 8 turns, 3 edits. Eight calls: gold absent from the top +// five, $0.940, 20 turns, 6 edits. Note what the re-queries could not tell the agent — scores are +// not comparable ACROSS queries, so the eighth query's wrong rank-1 (71.3) outscored the first +// query's correct hit (35.4). Re-asking does not produce a better-judged answer, it produces a +// differently-scaled one. +// +// Why an echo rather than a refusal. The repeat call costs its message either way — the agent has +// already paid the turn by the time this code runs — so replaying the payload can only remove +// information, never add a turn. A bare refusal costs the same turn and invites the agent to ask +// again, which is worse than no cap. For the same reason the header is a single line that names no +// other subcommand: pointing a capped session at a different verb is how a cap turns into a +// fan-out. +// +// Why 1 is the default, and why it is still a knob. Graph usage is invariant at 1.33-1.70 +// calls/session across six measured configurations, so a cap of 1 clips the tail and leaves the +// median session untouched. The tail is not uniform across models: 40% of Haiku sessions issue +// more than one search, at 2.03 calls/session, and if a second query is what rescues a first-call +// miss there, the cap removes resolves. So the cap ships as EG_MAX_SEARCHES, `0` disables it, and +// a tier that has not been replayed offline should run uncapped. + +// searchSession is one task's search state. It lives in a file because the CLI is one-shot: a +// process that has already exited cannot count its successors, and nothing else in the environment +// distinguishes the second search of a task from the first one. EG_SEARCH_SESSION names the file; +// the caller (an agent harness) is what scopes it to a task. +type searchSession struct { + path string + limit int +} + +// searchSessionState is the whole persisted record: how many searches ran, the first one's +// question and answer, and the tree that answer describes. +type searchSessionState struct { + Searches int `json:"searches"` + Query string `json:"query"` + Payload string `json:"payload"` + // Repo and Tree are the scope the payload was recorded against. See searchSessionScope: the + // state file is what makes an echo possible, and these are what stop it answering for the + // wrong repository. + Repo string `json:"repo,omitempty"` + Tree string `json:"tree,omitempty"` +} + +// searchSessionScope identifies the tree a payload describes. +// +// EG_SEARCH_SESSION is scoped to a task BY THE CALLER — the file path is the only thing that says +// "this is the same task as last time", and nothing in the environment checks that claim. A harness +// that reuses one path across a whole run therefore hands every instance after the first the FIRST +// instance's payload, verbatim, under a header saying the question was not run. On a suite where +// consecutive instances are different repositories in different languages, that payload names files +// that do not exist in the tree the agent is looking at. An agent reading it concludes the tool is +// broken and stops calling it — which costs the retrieval, the resolve, and every token the tool +// was there to save, for the whole remainder of the run. +// +// So the payload carries the tree it answered for, and an echo only fires when that still matches. +// HEAD's tree hash is the right key: it is what the record cache already keys on +// (see the RevParse pair in the provider's snapshot path), it is stable across a task because +// agents edit the working tree without committing, and it differs across instances because they +// are different checkouts. +// +// Both fields degrade rather than fail. A repository git cannot describe still gets a scope from +// its resolved path, and a scope that cannot be computed at all compares equal to nothing — which +// refuses the echo and runs a real search. Every ambiguous case resolves toward answering the +// question that was asked. +type searchSessionScope struct { + Repo string + Tree string +} + +// matches reports whether a recorded scope may answer for the live one. +// +// A state file written before this field existed has an empty scope, and that is treated as a +// mismatch rather than a wildcard: the whole point is that an unscoped payload is exactly the one +// that might belong to another repository. The cost of being wrong is one real search; the cost of +// the wildcard is a session answered from the wrong tree. +func (recorded searchSessionState) matches(live searchSessionScope) bool { + if recorded.Repo == "" && recorded.Tree == "" { + return false + } + if recorded.Tree != "" || live.Tree != "" { + return recorded.Tree == live.Tree + } + return recorded.Repo == live.Repo +} + +// newSearchSession returns nil when the echo is off, which is the default for every caller that +// does not set EG_SEARCH_SESSION — an interactive user's searches are unaffected by this file. +func newSearchSession(env EntireEnv, warn io.Writer) (*searchSession, error) { + limit := 1 + raw := strings.TrimSpace(env.MaxSearches) + if raw != "" { + parsed, err := strconv.Atoi(raw) + if err != nil { + return nil, fmt.Errorf("%s: want an integer (0 disables the cap), got %q", envMaxSearches, raw) + } + limit = parsed + } + if limit <= 0 { + return nil, nil + } + path := strings.TrimSpace(env.SearchSession) + if path == "" { + // A cap asked for but not enforceable. This warns on stderr and runs the query rather than + // failing, because a search verb that returns NOTHING is far more expensive than an + // uncapped one — the harness this ships behind has already paid for that once, when an + // empty array under `set -u` made its search verb emit nothing on every run of a whole + // cell before anyone noticed. Stderr never reaches the agent's payload, so the warning + // cannot cost a turn either. + if raw != "" && warn != nil { + fmt.Fprintf(warn, "%s is set but %s is not: the cap has no session file to count in, so this search runs uncapped\n", + envMaxSearches, envSearchSession) + } + return nil, nil + } + return &searchSession{path: path, limit: limit}, nil +} + +// echo reports the payload to replay when this task has already spent its searches. Every state +// error answers "no echo": a missing, truncated, unreadable, or out-of-scope session file must +// degrade to a real search, never to a failed one. +func (s *searchSession) echo(live searchSessionScope) (searchSessionState, bool) { + state, err := s.load() + if err != nil || state.Searches < s.limit || state.Payload == "" { + return searchSessionState{}, false + } + if !state.matches(live) { + return searchSessionState{}, false + } + return state, true +} + +func (s *searchSession) load() (searchSessionState, error) { + var state searchSessionState + data, err := os.ReadFile(s.path) + if err != nil { + return searchSessionState{}, err + } + if err := json.Unmarshal(data, &state); err != nil { + return searchSessionState{}, err + } + return state, nil +} + +// record counts a search that actually ran and keeps the FIRST payload: the echo replays the answer +// the ranking gave the original question, not the last rephrasing of it. Persisting is best-effort +// for the same reason echo is — a session file that cannot be written costs the cap, not the search. +func (s *searchSession) record(query string, payload []byte, live searchSessionScope) { + state, _ := s.load() + // A state file that belongs to another tree is replaced rather than counted into: its search + // count describes a different task, and carrying it over would cap this one before it ran. + if !state.matches(live) { + state = searchSessionState{} + } + state.Searches++ + if state.Payload == "" { + state.Query = query + state.Payload = string(payload) + state.Repo, state.Tree = live.Repo, live.Tree + } + data, err := json.Marshal(state) + if err != nil { + return + } + dir := filepath.Dir(s.path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return + } + // Temp + rename: a crash between the two writes must not leave the next call reading half a + // payload, which it would then echo as if it were the whole answer. + tmp, err := os.CreateTemp(dir, ".eg-search-session-*") + if err != nil { + return + } + if _, err := tmp.Write(data); err != nil { + tmp.Close() + os.Remove(tmp.Name()) + return + } + if err := tmp.Close(); err != nil { + os.Remove(tmp.Name()) + return + } + if err := os.Rename(tmp.Name(), s.path); err != nil { + os.Remove(tmp.Name()) + } +} + +// searchEchoHeader is the one line that precedes a replayed payload. It names both questions — the +// one that was not run, and the one the bytes below actually answer, so the agent is not left +// reading a payload as if it were a reply to the query it just typed. Then it stops: no alternative +// verb, no invitation to rephrase. +func searchEchoHeader(asked, answered string) string { + return fmt.Sprintf("(one search per task: %q was not run — below is your first search %q, verbatim)\n", asked, answered) +} diff --git a/internal/cli/search_session_test.go b/internal/cli/search_session_test.go new file mode 100644 index 00000000..66ad4fa4 --- /dev/null +++ b/internal/cli/search_session_test.go @@ -0,0 +1,170 @@ +package cli + +import ( + "bytes" + "path/filepath" + "strings" + "testing" +) + +// searchInSession runs one `search` the way a capped agent harness does: same repo, same session +// file, a different question each time. +func searchInSession(t *testing.T, repo, session, maxSearches, query string) string { + t.Helper() + var out bytes.Buffer + err := Run(t.Context(), Options{ + Version: "0.1.0", + Env: EntireEnv{RepoRoot: repo, SearchSession: session, MaxSearches: maxSearches}, + Stdout: &out, + }, []string{ + "search", "--repo", repo, "--query", query, "--format", "text", + "--profile", "syntax-only", "--worktree", "--top-k", "1", + }) + if err != nil { + t.Fatalf("search %q: %v", query, err) + } + return out.String() +} + +// The second search of a task must replay the first one's payload, not run a new query. The +// measurement behind the cap is in search_session.go: >=4-call sessions cost 1.148 of the no-tool +// baseline against 0.975 for one-call sessions, +0.173 with a bootstrap CI of [+0.019,+0.324]. +func TestSearchEchoesFirstPayloadOnRepeatSearch(t *testing.T) { + t.Parallel() + repo := t.TempDir() + write(t, repo, "alpha.py", "def alpha_widget():\n return True\n") + write(t, repo, "beta.py", "def beta_gadget():\n return False\n") + session := filepath.Join(t.TempDir(), "session.json") + + first := searchInSession(t, repo, session, "", "alpha_widget") + if !strings.Contains(first, "alpha.py") { + t.Fatalf("first search did not answer its own question: %q", first) + } + + second := searchInSession(t, repo, session, "", "beta_gadget") + header, replayed, ok := strings.Cut(second, "\n") + if !ok { + t.Fatalf("echo is not header + payload: %q", second) + } + // Verbatim: the echo must hand back the exact bytes of the first payload, so a session that + // re-asks is left in precisely the state its first search put it in. + if replayed != first { + t.Fatalf("echoed payload is not the first payload verbatim:\n got: %q\nwant: %q", replayed, first) + } + // The point of the cap: the second query never ran. + if strings.Contains(replayed, "beta.py") { + t.Fatalf("second query was executed: %q", replayed) + } + // One line that names both questions: the one that was skipped, and the one the bytes below + // answer — an echo read as a reply to the query just typed is worse than no echo. + if !strings.Contains(header, "beta_gadget") || !strings.Contains(header, "not run") { + t.Fatalf("header does not say the query was skipped: %q", header) + } + if !strings.Contains(header, "alpha_widget") { + t.Fatalf("header does not say which question the payload answers: %q", header) + } + // One line, and no other subcommand named in it. A refusal that points somewhere else is how a + // capped session turns into a fan-out, which is the behaviour the cap exists to remove. + for _, verb := range []string{"neighbors", "impact", "symbols", "edges", "grep", "def "} { + if strings.Contains(header, verb) { + t.Fatalf("echo header advertises %q: %q", verb, header) + } + } +} + +// The cap is a knob, not a constant: 40% of Haiku sessions issue more than one search (2.03 +// calls/session), and if the second query is what rescues a first-call miss there, capping costs +// resolves. `EG_MAX_SEARCHES=0` and "no session file at all" must both run every query. +func TestSearchWithoutCapRunsEveryQuery(t *testing.T) { + t.Parallel() + repo := t.TempDir() + write(t, repo, "alpha.py", "def alpha_widget():\n return True\n") + write(t, repo, "beta.py", "def beta_gadget():\n return False\n") + + for _, tc := range []struct{ name, session, maxSearches string }{ + {name: "cap disabled", session: filepath.Join(t.TempDir(), "session.json"), maxSearches: "0"}, + {name: "no session file", session: "", maxSearches: "1"}, + {name: "unconfigured", session: "", maxSearches: ""}, + } { + t.Run(tc.name, func(t *testing.T) { + searchInSession(t, repo, tc.session, tc.maxSearches, "alpha_widget") + second := searchInSession(t, repo, tc.session, tc.maxSearches, "beta_gadget") + if !strings.Contains(second, "beta.py") { + t.Fatalf("second query did not run: %q", second) + } + if strings.Contains(second, "not run") { + t.Fatalf("uncapped search echoed: %q", second) + } + }) + } +} + +// A session file that is missing, truncated, or not JSON must degrade to a real search. The echo is +// an optimisation; failing the search instead would cost the whole task. +func TestSearchEchoFailsOpenOnBrokenSessionFile(t *testing.T) { + t.Parallel() + repo := t.TempDir() + write(t, repo, "alpha.py", "def alpha_widget():\n return True\n") + session := filepath.Join(t.TempDir(), "session.json") + write(t, filepath.Dir(session), filepath.Base(session), "{not json") + + got := searchInSession(t, repo, session, "", "alpha_widget") + if !strings.Contains(got, "alpha.py") { + t.Fatalf("broken session file suppressed the search: %q", got) + } + // ...and the search it did run becomes the session's first payload. + second := searchInSession(t, repo, session, "", "beta_gadget") + if !strings.Contains(second, "not run") { + t.Fatalf("session did not recover after the broken file: %q", second) + } +} + +// The echo must never answer for a repository it did not search. +// +// EG_SEARCH_SESSION is scoped to a task by the CALLER, and nothing used to check that claim. A +// harness that reuses one path across a run therefore handed every instance after the first the +// FIRST instance's payload — naming files that do not exist in the tree the agent is looking at — +// under a header saying its question was not run. An agent reading that stops calling the tool, and +// the rest of the run measures a graph arm that never touches the graph. +func TestSearchEchoRefusesAnotherRepositorysPayload(t *testing.T) { + t.Parallel() + first := t.TempDir() + write(t, first, "alpha.py", "def alpha_widget():\n return True\n") + second := t.TempDir() + write(t, second, "beta.py", "def beta_gadget():\n return False\n") + // One session file, two repositories — the reuse this guards against. + session := filepath.Join(t.TempDir(), "session.json") + + if got := searchInSession(t, first, session, "", "alpha_widget"); !strings.Contains(got, "alpha.py") { + t.Fatalf("first repository's search did not answer its own question: %q", got) + } + + got := searchInSession(t, second, session, "", "beta_gadget") + if strings.Contains(got, "not run") || strings.Contains(got, "alpha.py") { + t.Fatalf("second repository was answered with the first repository's payload:\n%s", got) + } + if !strings.Contains(got, "beta.py") { + t.Fatalf("second repository did not get a real search: %q", got) + } + // The refusal re-scopes rather than merely skipping once: this repository's own second query + // still echoes, so the cap is intact for the task that actually owns the file now. + if repeat := searchInSession(t, second, session, "", "gamma_thing"); !strings.Contains(repeat, "not run") { + t.Fatalf("the cap did not re-arm for the new repository: %q", repeat) + } +} + +// An unparseable EG_MAX_SEARCHES is an error, not a silent no-op: a knob that quietly does nothing +// is how a measurement gets attributed to the wrong build. +func TestSearchRejectsNonNumericMaxSearches(t *testing.T) { + t.Parallel() + repo := t.TempDir() + write(t, repo, "alpha.py", "def alpha_widget():\n return True\n") + err := Run(t.Context(), Options{ + Version: "0.1.0", + Env: EntireEnv{RepoRoot: repo, SearchSession: filepath.Join(t.TempDir(), "s.json"), MaxSearches: "yes"}, + Stdout: &bytes.Buffer{}, + }, []string{"search", "--repo", repo, "--query", "alpha_widget", "--worktree"}) + if err == nil || !strings.Contains(err.Error(), envMaxSearches) { + t.Fatalf("err = %v, want a complaint about %s", err, envMaxSearches) + } +} diff --git a/internal/cli/search_span_merge_test.go b/internal/cli/search_span_merge_test.go new file mode 100644 index 00000000..c78cb9ba --- /dev/null +++ b/internal/cli/search_span_merge_test.go @@ -0,0 +1,56 @@ +package cli + +import ( + "bytes" + "strings" + "testing" + + "github.com/entireio/entire-graph/internal/sem" +) + +// A merged span is only worth its bytes if the header says the range is contiguous. Measured on +// fluentd-3328: gold was at rank 1, complete-symbol, and the agent said "Top hit is exact" — then +// spent turn 2 on `sed -n '200,270p'; sed -n '330,470p'`, 6.9 kB re-reading a superset of ranks +// 1/2/3, because three disjoint spans of one file look like an excerpt with holes in it. +func TestSearchTextLabelsAMergedSpanAsContiguous(t *testing.T) { + t.Parallel() + result := sem.SearchResult{ + Rank: 1, FilePath: "lib/fluent/plugin/in_tail.rb", Score: 135.8, + StartLine: 349, EndLine: 425, + SnippetStartLine: 349, SnippetEndLine: 425, + FocusLine: 395, + Snippet: "def receive_lines", + Signals: []string{"body", sem.CompleteSymbolSignal, "contiguous-span"}, + SymbolName: "receive_lines", + MergedRanks: []int{1, 2}, + } + var out bytes.Buffer + writeTextSearchResult(&out, result, true) + want := "1. lib/fluent/plugin/in_tail.rb:349-425 [contains ranks 1,2 - contiguous, nothing elided] score=135.8000" + if !strings.HasPrefix(out.String(), want) { + t.Fatalf("merged span header is\n%s\nwant prefix\n%s", out.String(), want) + } +} + +// An ordinary hit must be byte-identical to what it was before merging existed: the arm this +// change is measured against has to differ only where a merge actually happened. +func TestSearchTextLeavesAnUnmergedHitUnannotated(t *testing.T) { + t.Parallel() + result := sem.SearchResult{ + Rank: 2, FilePath: "pylint/lint/expand_modules.py", Score: 46.07, + StartLine: 400, EndLine: 500, + SnippetStartLine: 474, SnippetEndLine: 478, + FocusLine: 474, + Snippet: "def expand_modules(", + Signals: []string{"body"}, + } + var out bytes.Buffer + writeTextSearchResult(&out, result, true) + if strings.Contains(out.String(), "contains ranks") { + t.Fatalf("an unmerged hit was annotated:\n%s", out.String()) + } + want := "2. pylint/lint/expand_modules.py:474-478 score=46.0700" + if !strings.HasPrefix(out.String(), want) { + t.Fatalf("unmerged header is\n%s\nwant prefix\n%s", out.String(), want) + } +} diff --git a/internal/cli/stats.go b/internal/cli/stats.go index dcc2f3e4..e4c547dc 100644 --- a/internal/cli/stats.go +++ b/internal/cli/stats.go @@ -156,7 +156,7 @@ func runStats(ctx context.Context, opts Options, args []string) error { return err } if len(rest) != 0 { - return fmt.Errorf("stats received unexpected arguments: %s", strings.Join(rest, " ")) + return unexpectedArgumentsError("stats", opts.Version, rest) } switch flags.Format { case "text", "json": diff --git a/internal/cli/symbolref.go b/internal/cli/symbolref.go index 019f26a4..4e1b91e1 100644 --- a/internal/cli/symbolref.go +++ b/internal/cli/symbolref.go @@ -4,8 +4,10 @@ import ( "fmt" "path" "path/filepath" + "sort" "strconv" "strings" + "unicode" "github.com/entireio/entire-graph/internal/sem" ) @@ -305,11 +307,12 @@ func writeNoFocusMatch(out interface { // definition. `total` is the pre-cap match count. func writeDisambiguationListing(out interface { Write([]byte) (int, error) -}, query string, total int, definitions []neighborEndpoint) { - fmt.Fprintf(out, - "Ambiguous symbol %q matched %d definitions; rerun with the selector printed beside the one you mean.\n", - query, total, - ) +}, query string, total int, definitions []neighborEndpoint, bodies []symbolMatchBody) { + // FIX A: this is an ANSWER, not an error. Ambiguity means the tool found more than it was asked + // for, and every definition it found is listed below with a selector for narrowing NEXT time — but + // the caller is never told to re-run, because re-running is what cost $2.22 on lombok-3486. + fmt.Fprintf(out, "%q matches %d definitions; all are listed, the first %d with source.\n", + query, total, minSymbolInt(symbolAmbiguousBodyLimit, len(definitions))) selectors := disambiguationSelectors(definitions) for index, definition := range definitions { line := "- " + formatNeighborEndpoint(definition) @@ -324,4 +327,413 @@ func writeDisambiguationListing(out interface { if omitted := total - len(definitions); omitted > 0 { fmt.Fprintf(out, "- ... %d more definitions; raise --limit to list them\n", omitted) } + writeSymbolMatchBodies(out, bodies) +} + +// writeFuzzyMatchListing is FIX B's renderer: the candidates an exact miss degraded to, labelled with +// HOW they matched so the caller is never misled into thinking it asked for them. +func writeFuzzyMatchListing(out interface { + Write([]byte) (int, error) +}, query string, tier symbolMatchTier, definitions []neighborEndpoint, bodies []symbolMatchBody) { + fmt.Fprintf(out, "No exact match for %q. Closest %d by %s match:\n", + query, len(definitions), tier.label()) + selectors := disambiguationSelectors(definitions) + for index, definition := range definitions { + line := "- " + formatNeighborEndpoint(definition) + if definition.Kind != "" { + line += " [" + definition.Kind + "]" + } + if selectors[index] != "" { + line += " " + selectors[index] + } + fmt.Fprintln(out, line) + } + writeSymbolMatchBodies(out, bodies) +} + +// ANSWERING INSTEAD OF REFUSING +// ============================ +// +// Session-level forensics of 19 paid sessions found that the tool's most expensive failures were not +// wrong answers — they were REFUSALS to answer a live query at all. Two messages account for both +// measured blow-ups, and in each case the agent spent the money doing by hand what the tool declined +// to do: +// +// - "Ambiguous symbol %q matched N definitions; rerun with the selector printed beside the one you +// mean." — projectlombok__lombok-3486, +$2.22, 78 pre-edit operations while the agent manually +// disambiguated two same-named methods it had already been told the locations of. +// - "No symbols matched %q" — phpoffice__phpspreadsheet-3570, +$1.23, BOTH live queries empty and +// shell greps 18 -> 39, because the query spelled the name in a different but obvious way. +// +// Neither refusal is necessary. Ambiguity means the tool found MORE than it was asked for, which is +// information, not an error; and an exact-match miss on an identifier is a spelling question the graph +// can answer itself. So: +// +// FIX A every definition is listed, and the top ones come back WITH SOURCE. No rerun instruction. +// FIX B an exact miss degrades to a fuzzy ladder (case, separators, tokens, substring) and returns +// the best candidates, clearly labelled as fuzzy so the caller is never misled about what +// matched. +// +// Both are implemented HERE, on the one resolver and the two renderers that `def`, `neighbors` and +// `impact` all share, so no verb can drift back to refusing. + +const ( + // symbolFuzzyCandidateLimit is how many fuzzy candidates are returned. Three is the widest list + // that still reads as "did you mean"; past that the honest answer is `search`. + symbolFuzzyCandidateLimit = 3 + + // symbolAmbiguousBodyLimit is how many of several matching definitions come back with source. The + // list of locations is cheap and complete; bodies are what remove the follow-up read, and two is + // the measured shape of the failure (a method declared once per backend, once per AST flavour). + symbolAmbiguousBodyLimit = 2 + + // symbolMatchBodyMaxLines caps one printed body. + // + // 40 -> 400. MEASURED (briannesbitt/carbon): the agent called def, got a body cut off before the + // line it needed, piped it through `head -80` — which cut AGAIN, at the bug line — and abandoned the + // tool for 87 turns of grep. A navigation answer that stops mid-symbol is worse than no answer: it + // looks complete. 400 lines is the same safety ceiling the search allocator uses for a forced unit, + // and anything past it says exactly where it stopped and how to resume. + symbolMatchBodyMaxLines = 400 +) + +// symbolMatchTier ranks how a fuzzy candidate matched, lowest (best) first. It is reported so the +// label can say WHY something matched rather than just that it did. +type symbolMatchTier int + +const ( + symbolMatchExact symbolMatchTier = iota + symbolMatchCaseInsensitive + symbolMatchSeparatorInsensitive + symbolMatchTokenSubset + symbolMatchSubstring +) + +func (tier symbolMatchTier) label() string { + switch tier { + case symbolMatchCaseInsensitive: + return "case-insensitive" + case symbolMatchSeparatorInsensitive: + return "separator-insensitive" + case symbolMatchTokenSubset: + return "identifier-token" + case symbolMatchSubstring: + return "substring" + } + return "exact" +} + +// compactSymbolIdentifier reduces a name to letters and digits, lowercased, so that +// `Functions.flattenSingleValue`, `flatten_single_value` and `FLATTEN-SINGLE-VALUE` all compare equal. +// Separator style is a spelling convention, not an identity. +func compactSymbolIdentifier(value string) string { + var out strings.Builder + for _, r := range value { + if unicode.IsLetter(r) || unicode.IsDigit(r) { + out.WriteRune(unicode.ToLower(r)) + } + } + return out.String() +} + +// symbolIdentifierTokens splits a name into lowercase word tokens across camelCase, snake_case, +// kebab-case, `::` and `.`, so `flattenSingleValue` and `flatten_single_value` yield the same set. +func symbolIdentifierTokens(value string) map[string]bool { + tokens := map[string]bool{} + var current strings.Builder + flush := func() { + if current.Len() > 0 { + tokens[strings.ToLower(current.String())] = true + current.Reset() + } + } + runes := []rune(value) + for index, r := range runes { + switch { + case unicode.IsUpper(r): + // A new word starts at a lower->upper boundary and at the last capital of a run + // ("HTTPServer" -> http, server). + if index > 0 && (unicode.IsLower(runes[index-1]) || unicode.IsDigit(runes[index-1]) || + (index+1 < len(runes) && unicode.IsLower(runes[index+1]))) { + flush() + } + current.WriteRune(r) + case unicode.IsLetter(r) || unicode.IsDigit(r): + current.WriteRune(r) + default: + flush() + } + } + flush() + return tokens +} + +// resolveFocusSymbolsOrFuzzy is FIX B. It returns the exact matches when there are any, and otherwise +// the best fuzzy candidates for the name — never an empty answer when the graph holds something the +// caller plausibly meant. `fuzzy` says which happened, so every renderer can label it. +// +// The ladder is ordered by how much it assumes. Case and separator differences are spelling; a token +// subset is the caller naming the same concept with a qualifier dropped; a substring is the weakest +// claim and comes last. Any narrowing the caller supplied (--file/--kind) still applies, because a +// fuzzy NAME plus an exact file is a much better answer than a fuzzy name alone. +func resolveFocusSymbolsOrFuzzy( + symbols []sem.SymbolRecord, ref symbolRef, limit int, +) (matches []sem.SymbolRecord, tier symbolMatchTier, fuzzy bool) { + if exact := resolveFocusSymbols(symbols, ref); len(exact) > 0 { + return exact, symbolMatchExact, false + } + if ref.Name == "" || limit <= 0 { + return nil, symbolMatchExact, false + } + wantCompact := compactSymbolIdentifier(ref.Name) + if wantCompact == "" { + return nil, symbolMatchExact, false + } + wantTokens := symbolIdentifierTokens(ref.Name) + type scored struct { + symbol sem.SymbolRecord + tier symbolMatchTier + } + var pool []scored + for _, symbol := range symbols { + if ref.File != "" && !strings.EqualFold(symbol.FilePath, ref.File) { + continue + } + if ref.Kind != "" && !strings.EqualFold(symbol.Kind, ref.Kind) { + continue + } + best := symbolMatchTier(-1) + for _, candidate := range []string{symbol.Name, symbol.QualifiedName} { + if candidate == "" { + continue + } + if tier, ok := symbolNameMatchTier(candidate, ref.Name, wantCompact, wantTokens); ok && + (best < 0 || tier < best) { + best = tier + } + } + if best < 0 { + continue + } + pool = append(pool, scored{symbol: symbol, tier: best}) + } + if len(pool) == 0 { + return nil, symbolMatchExact, false + } + sort.SliceStable(pool, func(left, right int) bool { + if pool[left].tier != pool[right].tier { + return pool[left].tier < pool[right].tier + } + // Among equally-matched candidates the shortest name is the closest spelling, then the + // declaration order, so the answer is deterministic across runs. + leftName, rightName := symbolRefDisplayName(pool[left].symbol), symbolRefDisplayName(pool[right].symbol) + if len(leftName) != len(rightName) { + return len(leftName) < len(rightName) + } + if pool[left].symbol.FilePath != pool[right].symbol.FilePath { + return pool[left].symbol.FilePath < pool[right].symbol.FilePath + } + if pool[left].symbol.StartLine != pool[right].symbol.StartLine { + return pool[left].symbol.StartLine < pool[right].symbol.StartLine + } + return pool[left].symbol.ID < pool[right].symbol.ID + }) + if len(pool) > limit { + pool = pool[:limit] + } + matches = make([]sem.SymbolRecord, 0, len(pool)) + for _, entry := range pool { + matches = append(matches, entry.symbol) + } + return matches, pool[0].tier, true +} + +// symbolNameMatchTier scores one candidate name against the query. Exact is excluded: the caller has +// already tried it, so reporting it here would hide a real miss behind a tier that cannot happen. +func symbolNameMatchTier( + candidate, want, wantCompact string, wantTokens map[string]bool, +) (symbolMatchTier, bool) { + if strings.EqualFold(candidate, want) { + return symbolMatchCaseInsensitive, true + } + candidateCompact := compactSymbolIdentifier(candidate) + if candidateCompact == "" { + return 0, false + } + if candidateCompact == wantCompact { + return symbolMatchSeparatorInsensitive, true + } + // A token subset in either direction: `flattenSingleValue` for a query of `flatten_single_value` + // (equal sets), and `Functions.flattenSingleValue` for a query of `flattenSingleValue` (superset). + if len(wantTokens) > 0 { + candidateTokens := symbolIdentifierTokens(candidate) + if len(candidateTokens) > 0 && (symbolTokensContain(candidateTokens, wantTokens) || + symbolTokensContain(wantTokens, candidateTokens)) { + return symbolMatchTokenSubset, true + } + } + // The weakest claim, and bounded hard. `def` has a standing invariant that a short fragment must + // NEVER resolve to a longer name (TestDefNeverMatchesASubstring: "dele" is not "deletion"), and + // that invariant is right — a four-letter fragment matches half a codebase. Eight characters is + // past any accidental fragment while still admitting the case this rung exists for, a fully spelled + // identifier whose qualifier differs. The rungs above already cover separator and token spelling, + // so nothing real depends on relaxing this. + if len(wantCompact) >= 8 && + (strings.Contains(candidateCompact, wantCompact) || strings.Contains(wantCompact, candidateCompact)) { + return symbolMatchSubstring, true + } + return 0, false +} + +func symbolTokensContain(outer, inner map[string]bool) bool { + if len(inner) == 0 { + return false + } + for token := range inner { + if !outer[token] { + return false + } + } + return true +} + +func symbolRefDisplayName(symbol sem.SymbolRecord) string { + if symbol.QualifiedName != "" { + return symbol.QualifiedName + } + return symbol.Name +} + +// symbolMatchBody is the source of one matched definition, read for the answers FIX A and FIX B +// return in place of a refusal. +type symbolMatchBody struct { + Name string `json:"name"` + Kind string `json:"kind,omitempty"` + FilePath string `json:"file_path"` + StartLine int `json:"start_line"` + EndLine int `json:"end_line"` + Source string `json:"source,omitempty"` + Elided bool `json:"elided,omitempty"` + // UnitEndLine is the symbol's true last line when the cap clipped the body, so the note can tell + // the caller where to resume instead of only that something is missing. + UnitEndLine int `json:"unit_end_line,omitempty"` +} + +// symbolMatchBodies reads a compact body for the first `limit` records. A body that cannot be read +// (file gone, binary, oversized) is simply omitted: the locator list above it is still a complete +// answer, and a missing body must never turn an answer back into a refusal. +func symbolMatchBodies(repoRoot string, matches []sem.SymbolRecord, limit int) []symbolMatchBody { + if repoRoot == "" || limit <= 0 || len(matches) == 0 { + return nil + } + read := newRepoLineReader(repoRoot) + bodies := make([]symbolMatchBody, 0, minSymbolInt(limit, len(matches))) + for _, symbol := range matches { + if len(bodies) >= limit { + break + } + if symbol.FilePath == "" || symbol.StartLine <= 0 { + continue + } + lines, ok := read(symbol.FilePath) + if !ok || symbol.StartLine > len(lines) { + continue + } + start := symbol.StartLine + end := symbol.EndLine + if end < start { + end = start + } + if end > len(lines) { + end = len(lines) + } + elided, unitEnd := false, 0 + if end-start+1 > symbolMatchBodyMaxLines { + unitEnd = end + end = start + symbolMatchBodyMaxLines - 1 + elided = true + } + bodies = append(bodies, symbolMatchBody{ + Name: symbolRefDisplayName(symbol), + Kind: symbol.Kind, + FilePath: symbol.FilePath, + StartLine: start, + EndLine: end, + Source: strings.Join(lines[start-1:end], "\n"), + Elided: elided, + UnitEndLine: unitEnd, + }) + } + if len(bodies) == 0 { + return nil + } + return bodies +} + +func minSymbolInt(left, right int) int { + if left < right { + return left + } + return right +} + +// writeSymbolMatchBodies prints the bodies under a locator list, each line in a NUMBERED gutter. +// +// The search payload's bodies are deliberately unnumbered — an agent copies them verbatim as an Edit +// anchor, and a gutter breaks the anchor. These are not those. `def`, `callers` and `neighbors` answer +// "where is it", and the measured failure mode is the opposite one: carbon's agent got an unnumbered +// body, could not tell which line was which, piped it through `head -80` and lost the line it needed. +// A navigation answer wants coordinates; an edit source wants fidelity. Two outputs, two rules. +func writeSymbolMatchBodies(out interface { + Write([]byte) (int, error) +}, bodies []symbolMatchBody) { + for _, body := range bodies { + fmt.Fprintf(out, "\n%s:%d-%d %s", body.FilePath, body.StartLine, body.EndLine, body.Name) + if body.Kind != "" { + fmt.Fprintf(out, " [%s]", body.Kind) + } + fmt.Fprintln(out) + writeNumberedSource(out, body.Source, body.StartLine) + if body.Elided { + // Actionable, not merely honest: the caller is told where the body continues AND the exact + // invocation that resumes it, because "output truncated" is what sent carbon to grep. + fmt.Fprintf(out, " …continues to line %d — rerun with --from %d\n", + body.UnitEndLine, body.EndLine+1) + } + } +} + +// writeNumberedSource prints source with a right-aligned line-number gutter starting at `first`. +func writeNumberedSource(out interface { + Write([]byte) (int, error) +}, source string, first int) { + lines := strings.Split(source, "\n") + width := len(strconv.Itoa(first + len(lines) - 1)) + for offset, line := range lines { + fmt.Fprintf(out, " %*d→ %s\n", width, first+offset, line) + } +} + +// fuzzyKindLabel is the label a response carries, empty when the match was exact. Keeping the +// conversion in one place is what stops "exact" from ever being reported as a fuzzy rung. +func fuzzyKindLabel(fuzzy bool, tier symbolMatchTier) string { + if !fuzzy { + return "" + } + return tier.label() +} + +// symbolMatchTierFromLabel is the inverse, for renderers that only have the response. An unknown +// label degrades to the weakest rung rather than to "exact", so a wrong label can never overstate +// how well something matched. +func symbolMatchTierFromLabel(label string) symbolMatchTier { + for _, tier := range []symbolMatchTier{ + symbolMatchCaseInsensitive, symbolMatchSeparatorInsensitive, + symbolMatchTokenSubset, symbolMatchSubstring, + } { + if tier.label() == label { + return tier + } + } + return symbolMatchSubstring } diff --git a/internal/cli/symbolref_answers_test.go b/internal/cli/symbolref_answers_test.go new file mode 100644 index 00000000..3f911bce --- /dev/null +++ b/internal/cli/symbolref_answers_test.go @@ -0,0 +1,201 @@ +package cli + +import ( + "strings" + "testing" + + "github.com/entireio/entire-graph/internal/sem" +) + +// answerFixtureSymbols is the measured shape of both failures in one inventory: a name declared twice +// (lombok-3486) and a name the caller will spell differently (phpspreadsheet-3570). +func answerFixtureSymbols() []sem.SymbolRecord { + return []sem.SymbolRecord{ + {ID: "ecl", Kind: "method", Name: "generateBuilderAbstractClass", + QualifiedName: "HandleSuperBuilder.generateBuilderAbstractClass", + FilePath: "src/core/lombok/eclipse/handlers/HandleSuperBuilder.java", StartLine: 476, EndLine: 504}, + {ID: "jav", Kind: "method", Name: "generateBuilderAbstractClass", + QualifiedName: "HandleSuperBuilder.generateBuilderAbstractClass", + FilePath: "src/core/lombok/javac/handlers/HandleSuperBuilder.java", StartLine: 454, EndLine: 480}, + {ID: "flat", Kind: "method", Name: "flattenSingleValue", + QualifiedName: "Functions.flattenSingleValue", + FilePath: "src/PhpSpreadsheet/Calculation/Functions.php", StartLine: 619, EndLine: 626}, + {ID: "single", Kind: "class", Name: "Single", QualifiedName: "Single", + FilePath: "src/PhpSpreadsheet/Calculation/Financial/CashFlow/Single.php", StartLine: 9, EndLine: 48}, + {ID: "deletion", Kind: "function", Name: "deletion", QualifiedName: "deletion", + FilePath: "pkg/edit.go", StartLine: 3, EndLine: 9}, + } +} + +// TestResolveFocusSymbolsOrFuzzyNeverAnswersEmpty is FIX B. Each case is a spelling an agent actually +// used, and the ladder has to reach the right symbol without inventing matches for a short fragment. +func TestResolveFocusSymbolsOrFuzzyNeverAnswersEmpty(t *testing.T) { + t.Parallel() + symbols := answerFixtureSymbols() + for _, testCase := range []struct { + name string + query string + wantFirst string + wantFuzzy bool + wantTier symbolMatchTier + wantNone bool + }{ + {name: "an exact match is not fuzzy", query: "flattenSingleValue", + wantFirst: "flat", wantFuzzy: false, wantTier: symbolMatchExact}, + { + // resolveFocusSymbols already compares names with EqualFold, so pure case difference is an + // EXACT match and never reaches the ladder. Pinned so the rung above it is not mistaken for + // the thing that makes case work. + name: "case only is already exact", query: "flattensinglevalue", + wantFirst: "flat", wantFuzzy: false, wantTier: symbolMatchExact, + }, + {name: "snake_case for a camelCase name — the measured phpspreadsheet miss", + query: "flatten_single_value", wantFirst: "flat", wantFuzzy: true, + wantTier: symbolMatchSeparatorInsensitive}, + {name: "the qualifier the caller did not know about", + query: "Functions::flattenSingleValue", wantFirst: "flat", wantFuzzy: true, + wantTier: symbolMatchSeparatorInsensitive}, + {name: "a dropped qualifier still reaches the member", + query: "handleSuperBuilder.generate_builder_abstract_class", wantFirst: "ecl", + wantFuzzy: true, wantTier: symbolMatchSeparatorInsensitive}, + { + // The standing def invariant: a short fragment must never resolve to a longer name. + name: "a four-letter fragment matches nothing", query: "dele", wantNone: true, + }, + {name: "a name nothing shares a token with matches nothing", + query: "zzzznotpresent", wantNone: true}, + } { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + ref := parseSymbolRef(testCase.query, "", 0, "", "", nil) + matches, tier, fuzzy := resolveFocusSymbolsOrFuzzy(symbols, ref, symbolFuzzyCandidateLimit) + if testCase.wantNone { + if len(matches) != 0 { + t.Fatalf("query %q matched %d symbols, want none (first %s)", + testCase.query, len(matches), matches[0].ID) + } + return + } + if len(matches) == 0 { + t.Fatalf("query %q returned nothing — FIX B must never answer empty", testCase.query) + } + if matches[0].ID != testCase.wantFirst { + t.Fatalf("first match = %s, want %s (order is the answer for a fuzzy result)", + matches[0].ID, testCase.wantFirst) + } + if fuzzy != testCase.wantFuzzy || tier != testCase.wantTier { + t.Fatalf("fuzzy=%v tier=%s, want %v/%s", fuzzy, tier.label(), + testCase.wantFuzzy, testCase.wantTier.label()) + } + if len(matches) > symbolFuzzyCandidateLimit { + t.Fatalf("returned %d candidates, over the limit of %d", + len(matches), symbolFuzzyCandidateLimit) + } + }) + } +} + +// TestFuzzyResolutionHonoursTheCallersNarrowing pins that --file/--kind still apply: a fuzzy NAME plus +// an exact file is a much better answer than a fuzzy name alone. +func TestFuzzyResolutionHonoursTheCallersNarrowing(t *testing.T) { + t.Parallel() + symbols := answerFixtureSymbols() + ref := parseSymbolRef("generate_builder_abstract_class", + "src/core/lombok/javac/handlers/HandleSuperBuilder.java", 0, "", "", nil) + matches, _, fuzzy := resolveFocusSymbolsOrFuzzy(symbols, ref, symbolFuzzyCandidateLimit) + if !fuzzy || len(matches) != 1 || matches[0].ID != "jav" { + t.Fatalf("fuzzy=%v matches=%d, want exactly the javac definition", fuzzy, len(matches)) + } + kinded := parseSymbolRef("flatten_single_value", "", 0, "class", "", nil) + if matches, _, _ := resolveFocusSymbolsOrFuzzy(symbols, kinded, symbolFuzzyCandidateLimit); len(matches) > 0 && + matches[0].Kind != "class" { + t.Fatalf("--kind class was ignored: got %s", matches[0].Kind) + } +} + +// TestSymbolIdentifierTokensSplitsEverySpelling pins the tokenizer the ladder is built on. +func TestSymbolIdentifierTokensSplitsEverySpelling(t *testing.T) { + t.Parallel() + want := map[string]bool{"flatten": true, "single": true, "value": true} + for _, spelling := range []string{ + "flattenSingleValue", "flatten_single_value", "FLATTEN_SINGLE_VALUE", + "flatten-single-value", "Flatten::Single::Value", "flatten.single.value", + } { + got := symbolIdentifierTokens(spelling) + if len(got) != len(want) { + t.Fatalf("%q -> %v, want %v", spelling, got, want) + } + for token := range want { + if !got[token] { + t.Fatalf("%q -> %v, missing %q", spelling, got, token) + } + } + } + // A capital run ends a word at its last capital: HTTPServer is http + server, not h+t+t+p+server. + if got := symbolIdentifierTokens("HTTPServer"); !got["http"] || !got["server"] || len(got) != 2 { + t.Fatalf("HTTPServer -> %v, want {http, server}", got) + } +} + +// TestWriteDisambiguationListingAnswersInsteadOfRefusing is FIX A at the renderer: every definition, +// every selector, source for the top ones, and NO instruction to re-run. +func TestWriteDisambiguationListingAnswersInsteadOfRefusing(t *testing.T) { + t.Parallel() + definitions := []neighborEndpoint{ + {Name: "generateBuilderAbstractClass", QualifiedName: "HandleSuperBuilder.generateBuilderAbstractClass", + Kind: "method", FilePath: "eclipse/HandleSuperBuilder.java", StartLine: 476}, + {Name: "generateBuilderAbstractClass", QualifiedName: "HandleSuperBuilder.generateBuilderAbstractClass", + Kind: "method", FilePath: "javac/HandleSuperBuilder.java", StartLine: 454}, + } + bodies := []symbolMatchBody{ + {Name: "HandleSuperBuilder.generateBuilderAbstractClass", Kind: "method", + FilePath: "eclipse/HandleSuperBuilder.java", StartLine: 476, EndLine: 478, + Source: "private EclipseNode generateBuilderAbstractClass() {\n\treturn null;\n}"}, + } + var out strings.Builder + writeDisambiguationListing(&out, "HandleSuperBuilder.generateBuilderAbstractClass", 2, definitions, bodies) + rendered := out.String() + // The refusal is gone. This is the measured +$2.22 instruction. + for _, forbidden := range []string{"rerun", "Ambiguous symbol"} { + if strings.Contains(rendered, forbidden) { + t.Fatalf("listing still refuses (%q):\n%s", forbidden, rendered) + } + } + for _, want := range []string{ + "matches 2 definitions", "eclipse/HandleSuperBuilder.java:476", "javac/HandleSuperBuilder.java:454", + "--symbol HandleSuperBuilder.generateBuilderAbstractClass --file eclipse/HandleSuperBuilder.java --line 476", + "private EclipseNode generateBuilderAbstractClass() {", + } { + if !strings.Contains(rendered, want) { + t.Fatalf("listing is missing %q:\n%s", want, rendered) + } + } +} + +// TestWriteFuzzyMatchListingSaysHowItMatched pins FIX B's labelling. Silently returning a different +// symbol than the one asked for would be worse than the empty answer it replaced. +func TestWriteFuzzyMatchListingSaysHowItMatched(t *testing.T) { + t.Parallel() + var out strings.Builder + writeFuzzyMatchListing(&out, "flatten_single_value", symbolMatchSeparatorInsensitive, + []neighborEndpoint{{Name: "flattenSingleValue", QualifiedName: "Functions.flattenSingleValue", + Kind: "method", FilePath: "Functions.php", StartLine: 619}}, + []symbolMatchBody{{Name: "Functions.flattenSingleValue", FilePath: "Functions.php", + StartLine: 619, EndLine: 620, Source: "public static function flattenSingleValue($value = '')\n{"}}) + rendered := out.String() + for _, want := range []string{ + `No exact match for "flatten_single_value"`, "separator-insensitive match", + "Functions.php:619", "public static function flattenSingleValue", + } { + if !strings.Contains(rendered, want) { + t.Fatalf("fuzzy listing is missing %q:\n%s", want, rendered) + } + } + // Round-trip the label so a response carrying it renders the same rung it was resolved at. + if got := symbolMatchTierFromLabel(symbolMatchSeparatorInsensitive.label()); got != symbolMatchSeparatorInsensitive { + t.Fatalf("label round-trip gave %s", got.label()) + } + if fuzzyKindLabel(false, symbolMatchSubstring) != "" { + t.Fatal("an exact match reported a fuzzy rung") + } +} diff --git a/internal/cli/testdata/verify/cargo.txt b/internal/cli/testdata/verify/cargo.txt new file mode 100644 index 00000000..9cd78bb1 --- /dev/null +++ b/internal/cli/testdata/verify/cargo.txt @@ -0,0 +1,14 @@ + Compiling ruff_linter v0.9.0 + Finished test [unoptimized + debuginfo] target(s) in 12.03s + Running unittests src/lib.rs + +running 4 tests +test rules::flake8_simplify::tests::negation_with_equal_op ... ok +test rules::flake8_simplify::tests::negation_with_not_equal_op ... FAILED +test rules::flake8_simplify::tests::double_negation ... ok +test rules::flake8_simplify::tests::ignored_case ... ignored + +failures: + rules::flake8_simplify::tests::negation_with_not_equal_op + +test result: FAILED. 2 passed; 1 failed; 1 ignored; 0 measured diff --git a/internal/cli/testdata/verify/ctest.txt b/internal/cli/testdata/verify/ctest.txt new file mode 100644 index 00000000..881b3d6f --- /dev/null +++ b/internal/cli/testdata/verify/ctest.txt @@ -0,0 +1,9 @@ +Test project /repo/build + Start 1: format-test +1/3 Test #1: format-test ...................... Passed 1.42 sec + Start 2: ranges-test +2/3 Test #2: ranges-test ......................***Failed 0.31 sec + Start 3: chrono-test +3/3 Test #3: chrono-test ...................... Passed 0.88 sec + +67% tests passed, 1 tests failed out of 3 diff --git a/internal/cli/testdata/verify/gotest.txt b/internal/cli/testdata/verify/gotest.txt new file mode 100644 index 00000000..c60f65f6 --- /dev/null +++ b/internal/cli/testdata/verify/gotest.txt @@ -0,0 +1,10 @@ +=== RUN TestExpandModules +=== RUN TestExpandModules/absolute +--- PASS: TestExpandModules/absolute (0.00s) +=== RUN TestExpandModules/relative +--- FAIL: TestExpandModules/relative (0.00s) + expand_test.go:41: want true, got false +--- FAIL: TestExpandModules (0.00s) +--- PASS: TestIgnorePaths (0.01s) +FAIL +exit status 1 diff --git a/internal/cli/testdata/verify/jest.txt b/internal/cli/testdata/verify/jest.txt new file mode 100644 index 00000000..e92e083a --- /dev/null +++ b/internal/cli/testdata/verify/jest.txt @@ -0,0 +1,9 @@ +PASS test/browser/render.test.js + ✓ renders a bigint child (12 ms) + ✓ renders nested children (3 ms) +FAIL test/browser/hooks.test.js + ✕ useState with bigint (8 ms) + ✓ useState with number (1 ms) + +Test Suites: 1 failed, 1 passed, 2 total +Tests: 1 failed, 3 passed, 4 total diff --git a/internal/cli/testdata/verify/phpunit.txt b/internal/cli/testdata/verify/phpunit.txt new file mode 100644 index 00000000..e4dd6ac5 --- /dev/null +++ b/internal/cli/testdata/verify/phpunit.txt @@ -0,0 +1,15 @@ +PHPUnit 9.6.15 by Sebastian Bergmann and contributors. + +...F. 5 / 5 (100%) + +Time: 00:00.312, Memory: 20.00 MB + +There was 1 failure: + +1) PhpOffice\PhpSpreadsheet\Calculation\FunctionsTest::testFlattenSingleValue +Failed asserting that null matches expected 0. + +/repo/tests/Calculation/FunctionsTest.php:88 + +FAILURES! +Tests: 5, Assertions: 12, Failures: 1. diff --git a/internal/cli/testdata/verify/pytest.txt b/internal/cli/testdata/verify/pytest.txt new file mode 100644 index 00000000..887d398b --- /dev/null +++ b/internal/cli/testdata/verify/pytest.txt @@ -0,0 +1,14 @@ +============================= test session starts ============================== +platform darwin -- Python 3.11.6, pytest-7.4.3, pluggy-1.3.0 +collected 5 items + +tests/test_expand.py::test_is_ignored_file_absolute PASSED [ 20%] +tests/test_expand.py::test_is_ignored_file_relative FAILED [ 40%] +tests/test_self.py::TestRunTC::test_ignore_path_recursive PASSED [ 60%] +tests/test_self.py::TestRunTC::test_ignore_pattern_recursive SKIPPED [ 80%] +tests/test_broken.py::test_collect ERROR [100%] + +=================================== FAILURES =================================== +FAILED tests/test_expand.py::test_is_ignored_file_relative - AssertionError +ERROR tests/test_broken.py::test_collect +========================= 2 failed, 2 passed in 0.42s ========================== diff --git a/internal/cli/testdata/verify/unknown.txt b/internal/cli/testdata/verify/unknown.txt new file mode 100644 index 00000000..1284a084 --- /dev/null +++ b/internal/cli/testdata/verify/unknown.txt @@ -0,0 +1,4 @@ +Building with a bespoke in-house harness +step 1 of 3 ... done +step 2 of 3 ... done +step 3 of 3 ... 1 problem found diff --git a/internal/cli/verify.go b/internal/cli/verify.go new file mode 100644 index 00000000..0d91877f --- /dev/null +++ b/internal/cli/verify.go @@ -0,0 +1,410 @@ +package cli + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strconv" + "strings" + "time" +) + +// `entire graph verify` — an ADJUDICATED verdict, not test output +// ============================================================== +// +// MEASURED BASIS. Post-last-edit confirmation is 5.8 messages per session, the single largest asymmetric +// pot at 5.0 messages. Every prior attempt to shrink it FREED budget the agent immediately re-spent on +// more verification: transfer efficiency 0.2-0.35, meaning two thirds of every saving went straight back +// into running tests again. A cheaper way to run tests therefore cannot work. The only thing that closes +// the phase is a verdict with nothing left to re-spend on — which requires three properties that +// ordinary test output does not have: +// +// - A DELTA, not a state. "3 tests fail" invites a run to find out whether they failed before. The +// pre-edit baseline turns that into "these 3 failed before your edit too", which is not actionable +// and is explicitly labelled so. +// - A VERDICT, not evidence. Output is something to interpret, and interpreting invites re-running. +// The verb states the conclusion and the conclusion's own completeness. +// - NO RAW OUTPUT, EVER. Forwarding even an excerpt reopens the loop this verb exists to close, so +// nothing the runner printed reaches the caller. Ids are forwarded; text is not. +// +// FAIRNESS. This verb is tool CAPABILITY — it runs a command the caller supplies and adjudicates the +// result. It prints no instruction the control arm's harness-side stub cannot also print: the +// "verification is complete" sentence is a statement about the DATA (a zero-regression, ≥1-fix delta is +// by definition complete), not advice about how to behave. Nothing here tells the reader what to do. +const ( + // verifyDefaultMaxBytes caps the whole rendered verdict. It is small on purpose: a verdict that + // needs scrolling is evidence again. + verifyDefaultMaxBytes = 2048 + + // verifyMaxListedIDs bounds any one id list. Past twenty the list is not actionable and the COUNT is + // the information, so the remainder is summarised rather than dropped silently. + verifyMaxListedIDs = 20 + + // Timeouts mirror the harness's own ecosystem split: a compiled-language suite pays for a build + // before it runs a test, an interpreted one does not. + verifyCompiledTimeout = 900 * time.Second + verifyInterpretedTimeout = 300 * time.Second +) + +// verifyBaseline is the on-disk pre-edit record. The format is deliberately boring — a status per id +// plus provenance — because its only consumer is the diff below and its only job is to still be +// readable when the tree it describes is gone. +type verifyBaseline struct { + FormatVersion int `json:"format_version"` + RecordedAt string `json:"recorded_at"` + Repo string `json:"repo"` + TestCommand string `json:"test_command"` + Parser string `json:"parser"` + ExitCode int `json:"exit_code"` + Results verifyResults `json:"results"` +} + +type verifyFlags struct { + Repo string + Setup string + Test string + PreEditBaseline string + RecordBaseline string + MaxBytes int +} + +func parseVerifyFlags(args []string) (verifyFlags, error) { + flags := verifyFlags{MaxBytes: verifyDefaultMaxBytes} + for index := 0; index < len(args); index++ { + arg := args[index] + value := func() (string, error) { + index++ + if index >= len(args) { + return "", fmt.Errorf("%s requires a value", arg) + } + return args[index], nil + } + var err error + switch arg { + case "--repo": + flags.Repo, err = value() + case "--setup": + flags.Setup, err = value() + case "--test": + flags.Test, err = value() + case "--pre-edit-baseline": + flags.PreEditBaseline, err = value() + case "--record-baseline": + flags.RecordBaseline, err = value() + case "--max-bytes": + var raw string + if raw, err = value(); err == nil { + flags.MaxBytes, err = strconv.Atoi(raw) + if err != nil || flags.MaxBytes <= 0 { + return flags, fmt.Errorf("verify --max-bytes requires a positive integer, got %q", raw) + } + } + default: + return flags, fmt.Errorf("verify received unexpected argument %q", arg) + } + if err != nil { + return flags, err + } + } + if strings.TrimSpace(flags.Test) == "" { + return flags, fmt.Errorf("verify requires --test ") + } + if flags.RecordBaseline == "" && flags.PreEditBaseline == "" { + return flags, fmt.Errorf( + "verify requires --pre-edit-baseline (or --record-baseline to create one)") + } + return flags, nil +} + +func runVerify(ctx context.Context, opts Options, args []string) error { + flags, err := parseVerifyFlags(args) + if err != nil { + return err + } + repo, err := resolveRepo(ctx, opts.Env, flags.Repo) + if err != nil { + return err + } + output, exitCode, runErr := runVerifyCommands(ctx, repo, flags) + if runErr != nil { + // A command that could not be LAUNCHED is a different failure from a command that ran and + // reported. Saying which is the difference between "fix your invocation" and "fix your code". + return fmt.Errorf("verify could not run the test command: %w", runErr) + } + results, parser, parsed := parseVerifyOutput(output) + + if flags.RecordBaseline != "" { + return writeVerifyBaseline(opts, repo, flags, results, parser, parsed, exitCode) + } + baseline, err := readVerifyBaseline(flags.PreEditBaseline) + if err != nil { + return err + } + _, writeErr := opts.Stdout.Write(renderVerifyVerdict( + verifyVerdictInput{ + baseline: baseline, current: results, parser: parser, parsed: parsed, + exitCode: exitCode, maxBytes: flags.MaxBytes, + })) + return writeErr +} + +// runVerifyCommands runs setup then test, capturing combined output. Setup output is DISCARDED: an +// install log is not a test result, and the parsers must not see it (a dependency named `test_foo` in a +// pip log would otherwise become a test id). +func runVerifyCommands(ctx context.Context, repo string, flags verifyFlags) (string, int, error) { + timeout := verifyInterpretedTimeout + if verifyCompiledCommand(flags.Test) { + timeout = verifyCompiledTimeout + } + if flags.Setup != "" { + setupCtx, cancel := context.WithTimeout(ctx, timeout) + _, _, err := runVerifyShell(setupCtx, repo, flags.Setup) + cancel() + if err != nil { + return "", 0, fmt.Errorf("setup command failed to launch: %w", err) + } + } + testCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + return runVerifyShell(testCtx, repo, flags.Test) +} + +// verifyCompiledCommand reports whether a command belongs to an ecosystem that builds before it tests, +// and therefore needs the long timeout. The test is on the RUNNER, because that is the thing that knows. +func verifyCompiledCommand(command string) bool { + lowered := strings.ToLower(command) + for _, marker := range []string{ + "cargo", "go test", "mvn", "gradle", "cmake", "ctest", "make", "bazel", "dotnet", "swift ", + } { + if strings.Contains(lowered, marker) { + return true + } + } + return false +} + +// runVerifyShell executes one command in the repository. It returns the exit code separately from the +// error: a test suite exiting non-zero is the normal case and not a failure of this verb. +func runVerifyShell(ctx context.Context, repo, command string) (string, int, error) { + cmd := exec.CommandContext(ctx, "sh", "-c", command) + cmd.Dir = repo + // The verb MUST NOT mutate the tree beyond what the command itself does, so nothing is written, no + // files are staged and no environment is injected beyond the caller's own. + out, err := cmd.CombinedOutput() + if err != nil { + var exit *exec.ExitError + if errorsAs(err, &exit) { + return string(out), exit.ExitCode(), nil + } + return string(out), 0, err + } + return string(out), 0, nil +} + +// errorsAs is errors.As without importing the package name into every call site here. +func errorsAs(err error, target **exec.ExitError) bool { + if exit, ok := err.(*exec.ExitError); ok { + *target = exit + return true + } + return false +} + +func writeVerifyBaseline( + opts Options, repo string, flags verifyFlags, + results verifyResults, parser string, parsed bool, exitCode int, +) error { + baseline := verifyBaseline{ + FormatVersion: 1, + RecordedAt: time.Now().UTC().Format(time.RFC3339), + Repo: repo, + TestCommand: flags.Test, + Parser: parser, + ExitCode: exitCode, + Results: results, + } + if !parsed { + baseline.Parser = "exit-code-only" + baseline.Results = verifyResults{} + } + encoded, err := json.MarshalIndent(baseline, "", " ") + if err != nil { + return err + } + if directory := filepath.Dir(flags.RecordBaseline); directory != "" && directory != "." { + if err := os.MkdirAll(directory, 0o755); err != nil { + return err + } + } + if err := os.WriteFile(flags.RecordBaseline, append(encoded, '\n'), 0o644); err != nil { + return err + } + passed, failed := verifyCountByStatus(baseline.Results) + if !parsed { + fmt.Fprintf(opts.Stdout, + "BASELINE RECORDED: %s (exit %d; output format not recognised, so the baseline is exit-code only)\n", + flags.RecordBaseline, exitCode) + return nil + } + fmt.Fprintf(opts.Stdout, "BASELINE RECORDED: %s (%s; %d passing, %d failing, exit %d)\n", + flags.RecordBaseline, baseline.Parser, passed, failed, exitCode) + return nil +} + +func readVerifyBaseline(path string) (verifyBaseline, error) { + content, err := os.ReadFile(path) + if err != nil { + return verifyBaseline{}, fmt.Errorf("verify could not read --pre-edit-baseline %s: %w", path, err) + } + var baseline verifyBaseline + if err := json.Unmarshal(content, &baseline); err != nil { + return verifyBaseline{}, fmt.Errorf("verify could not parse --pre-edit-baseline %s: %w", path, err) + } + if baseline.Results == nil { + baseline.Results = verifyResults{} + } + return baseline, nil +} + +func verifyCountByStatus(results verifyResults) (passed, failed int) { + for _, status := range results { + if status == verifyStatusPass { + passed++ + continue + } + failed++ + } + return passed, failed +} + +type verifyVerdictInput struct { + baseline verifyBaseline + current verifyResults + parser string + parsed bool + exitCode int + maxBytes int +} + +// renderVerifyVerdict is the whole output contract: a delta, a verdict, and nothing else. +// +// The three classes are not symmetric, and that asymmetry is the point: +// +// - NEWLY PASSING is the fix working. It is what licenses a PASS verdict. +// - NEWLY FAILING is a regression. Every id is listed (to the cap) because the ids ARE the actionable +// information — this is the one place the verb can save a caller a search. +// - STILL FAILING is labelled PRE-EXISTING and explicitly not the caller's problem. Without this class +// a caller reads a red suite and starts investigating a failure that predates the edit, which is the +// measured shape of the confirmation pot. +func renderVerifyVerdict(input verifyVerdictInput) []byte { + var buffer strings.Builder + if !input.parsed || (input.baseline.Parser == "exit-code-only" && len(input.baseline.Results) == 0) { + // COARSE MODE, and it says so. Without a parseable format there are no ids, so there is no delta + // and no honest claim about regressions — only the exit code, which is reported as exactly that. + if input.exitCode == 0 { + buffer.WriteString("VERDICT: PASS (exit 0)\n") + } else { + fmt.Fprintf(&buffer, "VERDICT: FAIL (exit %d)\n", input.exitCode) + } + buffer.WriteString(" the runner's output format was not recognised, so this verdict is " + + "exit-code only: it reports whether the suite passed, not which tests changed.\n") + return verifyTruncateOutput(buffer.String(), input.maxBytes) + } + + var newlyPassing, newlyFailing, stillFailing []string + for id, status := range input.current { + before, known := input.baseline.Results[id] + switch { + case status == verifyStatusPass && known && before != verifyStatusPass: + newlyPassing = append(newlyPassing, id) + case status != verifyStatusPass && (!known || before == verifyStatusPass): + newlyFailing = append(newlyFailing, id) + case status != verifyStatusPass: + stillFailing = append(stillFailing, id) + } + } + sort.Strings(newlyPassing) + sort.Strings(newlyFailing) + sort.Strings(stillFailing) + + if len(newlyPassing) > 0 { + verifyWriteList(&buffer, "NEWLY PASSING", newlyPassing) + } + if len(newlyFailing) > 0 { + verifyWriteList(&buffer, "NEWLY FAILING", newlyFailing) + } + if len(stillFailing) > 0 { + verifyWriteList(&buffer, + "PRE-EXISTING FAILURES (also failing before your edit; not caused by your change)", stillFailing) + } + + switch { + case len(newlyFailing) > 0: + fmt.Fprintf(&buffer, "VERDICT: REGRESSION in %d test%s: %s\n", + len(newlyFailing), pluralSuffix(len(newlyFailing)), verifyJoinIDs(newlyFailing)) + case len(newlyPassing) > 0: + // The second sentence is a statement about the DELTA, not an instruction: a zero-regression, + // at-least-one-fix delta is by construction a complete verification of the change. See the + // fairness note at the top of this file. + buffer.WriteString("VERDICT: PASS — the change fixes the target behavior and introduces no " + + "regressions. Verification is complete; no further test runs are needed.\n") + default: + buffer.WriteString("VERDICT: NO EFFECT — the target tests behave exactly as before your edit.\n") + } + return verifyTruncateOutput(buffer.String(), input.maxBytes) +} + +// verifyWriteList prints one class, capped, with the remainder counted rather than dropped. +func verifyWriteList(buffer *strings.Builder, label string, ids []string) { + fmt.Fprintf(buffer, "%s (%d): %s\n", label, len(ids), verifyJoinIDs(ids)) +} + +func verifyJoinIDs(ids []string) string { + if len(ids) <= verifyMaxListedIDs { + return strings.Join(ids, ", ") + } + return strings.Join(ids[:verifyMaxListedIDs], ", ") + + fmt.Sprintf(", … and %d more", len(ids)-verifyMaxListedIDs) +} + +// verifyTruncateOutput enforces the byte cap from the END, so the VERDICT line — the last line and the +// only one that must survive — is never the part that is cut. A verdict without its lists is still a +// verdict; lists without a verdict are evidence, which is what this verb refuses to return. +func verifyTruncateOutput(rendered string, maxBytes int) []byte { + if maxBytes <= 0 || len(rendered) <= maxBytes { + return []byte(rendered) + } + lines := strings.Split(strings.TrimRight(rendered, "\n"), "\n") + verdict := lines[len(lines)-1] + "\n" + if len(verdict) > maxBytes { + // Even the verdict is too wide, which happens only when its own id list is long. The COUNT is the + // information and the ids are the bonus, so the list yields — word by word from the end, never the + // "VERDICT: …" clause itself — rather than the cap yielding. A verdict that overruns the caller's + // byte budget is the same failure as returning output. + head := verdict + if colon := strings.LastIndex(verdict, ": "); colon > 0 { + head = verdict[:colon+2] + } + ids := strings.TrimSuffix(strings.TrimPrefix(verdict, head), "\n") + for _, part := range strings.Split(ids, ", ") { + if len(head)+len(part)+len(" …\n") > maxBytes { + break + } + head += part + ", " + } + return []byte(strings.TrimSuffix(head, ", ") + " …\n") + } + kept, budget := []string{}, maxBytes-len(verdict) + for _, line := range lines[:len(lines)-1] { + if len(line)+1 > budget { + continue + } + kept = append(kept, line) + budget -= len(line) + 1 + } + return []byte(strings.Join(append(kept, verdict), "\n")) +} diff --git a/internal/cli/verify_parse.go b/internal/cli/verify_parse.go new file mode 100644 index 00000000..54e2cc20 --- /dev/null +++ b/internal/cli/verify_parse.go @@ -0,0 +1,338 @@ +package cli + +import ( + "regexp" + "strings" +) + +// Test-result parsing for `entire graph verify` +// ============================================ +// +// The verb's whole value is that it returns a VERDICT rather than output. That is only possible if the +// runner's own report can be reduced to a set of {test id -> status}: a verdict about a delta needs two +// comparable sets, and raw text is not comparable. +// +// Every parser here obeys the same three rules, and they are what keep the verb honest rather than +// merely terse: +// +// 1. IDS ARE THE RUNNER'S OWN. A parser never invents, prettifies or re-nests an identifier. An id is +// what the caller would paste back into the runner to re-run that one test, which is what makes a +// regression list actionable instead of decorative. +// 2. A PARSER THAT IS NOT SURE REPORTS NOTHING. Returning a half-read set is worse than returning +// none: a missing test reads as "deleted" on one side of a diff and as a regression on the other, +// so the verb would manufacture verdicts out of its own parse failures. `ok=false` degrades the +// whole run to the exit-code verdict, which is coarse and says so. +// 3. NO OUTPUT IS EVER FORWARDED. Nothing in this file returns runner text to the caller. The measured +// failure this verb exists to end is an agent reading test output and deciding to run more tests; +// handing back a "helpful excerpt" reopens exactly that loop. +type verifyStatus string + +const ( + verifyStatusPass verifyStatus = "pass" + verifyStatusFail verifyStatus = "fail" + // verifyStatusError is a test that did not run to a verdict — a collection error, a panic before + // assertions, a build failure attributed to one target. It is kept distinct from `fail` because a + // pre-existing collection error is a fact about the checkout, and calling it a failure would put it + // in the regression list on the first run that fixes something else. + verifyStatusError verifyStatus = "error" +) + +// verifyResults is one run's normalized outcome. +type verifyResults map[string]verifyStatus + +// verifyParser reads one runner's report. `ok` is false when the format was not recognised WITH +// CONFIDENCE — see rule 2 above. +type verifyParser struct { + name string + parse func(output string) (verifyResults, bool) +} + +// verifyParsers is tried in order and the FIRST confident parser wins. Order matters only where two +// formats could plausibly match the same text, so the most distinctive signatures come first. +var verifyParsers = []verifyParser{ + {name: "pytest", parse: parseVerifyPytest}, + {name: "cargo test", parse: parseVerifyCargo}, + {name: "go test", parse: parseVerifyGoTest}, + {name: "phpunit", parse: parseVerifyPHPUnit}, + {name: "jest/vitest", parse: parseVerifyJest}, + {name: "rspec", parse: parseVerifyRSpec}, + {name: "minitest", parse: parseVerifyMinitest}, + {name: "surefire", parse: parseVerifySurefire}, + {name: "ctest", parse: parseVerifyCTest}, +} + +// parseVerifyOutput reduces a runner's report to a normalized result set, naming the parser that read +// it. An unrecognised format returns ok=false, which is what makes the verb degrade to an exit-code +// verdict rather than guess. +func parseVerifyOutput(output string) (verifyResults, string, bool) { + for _, parser := range verifyParsers { + if results, ok := parser.parse(output); ok && len(results) > 0 { + return results, parser.name, true + } + } + return nil, "", false +} + +// verifyLines splits output into trimmed lines once, since every parser scans line-wise. +func verifyLines(output string) []string { + lines := strings.Split(strings.ReplaceAll(output, "\r\n", "\n"), "\n") + for index := range lines { + lines[index] = strings.TrimRight(lines[index], " \t") + } + return lines +} + +// pytest: `tests/test_x.py::TestC::test_name PASSED` in verbose mode, plus the short-summary block +// (`FAILED tests/test_x.py::test_name - AssertionError`) which is present even without -v. Both are +// read, because a run may have one and not the other and the ids are identical between them. +var ( + verifyPytestVerbose = regexp.MustCompile(`^(\S+::\S+)\s+(PASSED|FAILED|ERROR|XFAIL|XPASS|SKIPPED)\b`) + verifyPytestSummary = regexp.MustCompile(`^(FAILED|ERROR|PASSED)\s+(\S+::\S+)`) +) + +func parseVerifyPytest(output string) (verifyResults, bool) { + if !strings.Contains(output, "::") { + return nil, false + } + results := verifyResults{} + for _, line := range verifyLines(output) { + if match := verifyPytestVerbose.FindStringSubmatch(line); match != nil { + // SKIPPED and the xfail family are deliberately not recorded. A skip is not a verdict about + // the code, and recording it as a pass would make un-skipping look like a regression. + if status, keep := verifyPytestStatus(match[2]); keep { + results[match[1]] = status + } + continue + } + if match := verifyPytestSummary.FindStringSubmatch(line); match != nil { + if status, keep := verifyPytestStatus(match[1]); keep { + results[match[2]] = status + } + } + } + return results, len(results) > 0 +} + +func verifyPytestStatus(word string) (verifyStatus, bool) { + switch word { + case "PASSED", "XPASS": + return verifyStatusPass, true + case "FAILED": + return verifyStatusFail, true + case "ERROR": + return verifyStatusError, true + } + return "", false +} + +// cargo test: `test module::path::name ... ok` / `... FAILED`. The id is the module path, which is what +// `cargo test ` re-runs. +var verifyCargoLine = regexp.MustCompile(`^test\s+(\S+)\s+\.\.\.\s+(ok|FAILED|ignored)\b`) + +func parseVerifyCargo(output string) (verifyResults, bool) { + if !strings.Contains(output, "... ok") && !strings.Contains(output, "... FAILED") { + return nil, false + } + results := verifyResults{} + for _, line := range verifyLines(output) { + match := verifyCargoLine.FindStringSubmatch(strings.TrimSpace(line)) + if match == nil { + continue + } + switch match[2] { + case "ok": + results[match[1]] = verifyStatusPass + case "FAILED": + results[match[1]] = verifyStatusFail + } + } + return results, len(results) > 0 +} + +// go test -v: `--- PASS: TestName/sub (0.00s)`. The leading dashes and indentation carry subtest depth, +// which is part of the id `go test -run` accepts. +var verifyGoLine = regexp.MustCompile(`^\s*--- (PASS|FAIL|SKIP): (\S+)`) + +func parseVerifyGoTest(output string) (verifyResults, bool) { + if !strings.Contains(output, "--- PASS") && !strings.Contains(output, "--- FAIL") { + return nil, false + } + results := verifyResults{} + for _, line := range verifyLines(output) { + match := verifyGoLine.FindStringSubmatch(line) + if match == nil { + continue + } + switch match[1] { + case "PASS": + results[match[2]] = verifyStatusPass + case "FAIL": + results[match[2]] = verifyStatusFail + } + } + return results, len(results) > 0 +} + +// PHPUnit --testdox / default: the machine-readable form is the `--teamcity` or JUnit XML, but the +// default text report names failures in a numbered block ("1) ClassTest::testName"). Passes are only +// countable, not nameable, without --testdox, so the parser reads BOTH: testdox lines when present, and +// the failure block always. +var ( + verifyPHPUnitFailure = regexp.MustCompile(`^\d+\)\s+([A-Za-z_][\w\\]*::\w+)`) + verifyPHPUnitTestdox = regexp.MustCompile(`^\s*([✔✘])\s+(.+?)\s*$`) +) + +func parseVerifyPHPUnit(output string) (verifyResults, bool) { + if !strings.Contains(output, "PHPUnit") && !verifyPHPUnitFailure.MatchString(output) { + return nil, false + } + results := verifyResults{} + suite := "" + for _, line := range verifyLines(output) { + if match := verifyPHPUnitFailure.FindStringSubmatch(strings.TrimSpace(line)); match != nil { + results[match[1]] = verifyStatusFail + continue + } + // testdox prints a class header followed by ticked/crossed test descriptions. + trimmed := strings.TrimSpace(line) + if trimmed != "" && !strings.HasPrefix(line, " ") && strings.HasSuffix(trimmed, ")") == false && + !strings.ContainsAny(trimmed, "✔✘") && strings.Contains(trimmed, "\\") { + suite = trimmed + continue + } + if match := verifyPHPUnitTestdox.FindStringSubmatch(line); match != nil { + id := match[2] + if suite != "" { + id = suite + "::" + id + } + if match[1] == "✔" { + results[id] = verifyStatusPass + } else { + results[id] = verifyStatusFail + } + } + } + return results, len(results) > 0 +} + +// jest / vitest: `✓ suite > name (3 ms)` / `✕ suite > name`, and the `PASS|FAIL ` header lines. +// The tick characters are the reliable per-test signal in both runners' default reporters. +var verifyJestLine = regexp.MustCompile(`^\s*(✓|✔|×|✕|✗)\s+(.+?)(?:\s+\(\d+\s*m?s\))?\s*$`) + +func parseVerifyJest(output string) (verifyResults, bool) { + if !strings.ContainsAny(output, "✓✔×✕✗") { + return nil, false + } + results := verifyResults{} + file := "" + for _, line := range verifyLines(output) { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "PASS ") || strings.HasPrefix(trimmed, "FAIL ") { + if fields := strings.Fields(trimmed); len(fields) > 1 { + file = fields[1] + } + continue + } + match := verifyJestLine.FindStringSubmatch(line) + if match == nil { + continue + } + id := strings.TrimSpace(match[2]) + if file != "" { + id = file + " > " + id + } + if match[1] == "✓" || match[1] == "✔" { + results[id] = verifyStatusPass + } else { + results[id] = verifyStatusFail + } + } + return results, len(results) > 0 +} + +// rspec --format progress prints no ids; the documentation format and the failure block do. The rerun +// block ("rspec ./spec/x_spec.rb:12") is the most useful id there is — it is literally the re-run +// command — so it is preferred when present. +var verifyRSpecRerun = regexp.MustCompile(`^rspec\s+(\./\S+:\d+)`) + +func parseVerifyRSpec(output string) (verifyResults, bool) { + if !strings.Contains(output, "examples,") { + return nil, false + } + results := verifyResults{} + for _, line := range verifyLines(output) { + if match := verifyRSpecRerun.FindStringSubmatch(strings.TrimSpace(line)); match != nil { + results[match[1]] = verifyStatusFail + } + } + // A green rspec run names nothing, and that is a real answer: zero failures. The summary line is the + // evidence, and one synthetic id would be a lie about what was read. + if len(results) == 0 && strings.Contains(output, "0 failures") { + return verifyResults{"rspec: all examples": verifyStatusPass}, true + } + return results, len(results) > 0 +} + +// minitest: `TestClass#test_name = 0.01 s = .` / `= F` / `= E`. +var verifyMinitestLine = regexp.MustCompile(`^(\S+#\S+)\s*=\s*[\d.]+\s*s\s*=\s*([.FES])`) + +func parseVerifyMinitest(output string) (verifyResults, bool) { + results := verifyResults{} + for _, line := range verifyLines(output) { + match := verifyMinitestLine.FindStringSubmatch(strings.TrimSpace(line)) + if match == nil { + continue + } + switch match[2] { + case ".": + results[match[1]] = verifyStatusPass + case "F": + results[match[1]] = verifyStatusFail + case "E": + results[match[1]] = verifyStatusError + } + } + return results, len(results) > 0 +} + +// maven/gradle surefire: `[ERROR] ClassTest.testName:42 expected ...` for failures and +// `[INFO] Tests run: 5, Failures: 0` for counts. Only named entries become ids. +var verifySurefireFailure = regexp.MustCompile(`^\[ERROR\]\s+(\w[\w.$]*\.\w+)(?::\d+)?`) + +func parseVerifySurefire(output string) (verifyResults, bool) { + if !strings.Contains(output, "Tests run:") { + return nil, false + } + results := verifyResults{} + for _, line := range verifyLines(output) { + if match := verifySurefireFailure.FindStringSubmatch(strings.TrimSpace(line)); match != nil { + results[match[1]] = verifyStatusFail + } + } + if len(results) == 0 && strings.Contains(output, "Failures: 0") && strings.Contains(output, "Errors: 0") { + return verifyResults{"surefire: all tests": verifyStatusPass}, true + } + return results, len(results) > 0 +} + +// ctest: ` 1/12 Test #1: name ....... Passed 0.01 sec` / `***Failed`. +var verifyCTestLine = regexp.MustCompile(`^\s*\d+/\d+\s+Test\s+#\d+:\s+(\S+)\s+\.*\s*(Passed|\*\*\*Failed|\*\*\*Timeout|\*\*\*Exception)`) + +func parseVerifyCTest(output string) (verifyResults, bool) { + if !strings.Contains(output, "Test #") { + return nil, false + } + results := verifyResults{} + for _, line := range verifyLines(output) { + match := verifyCTestLine.FindStringSubmatch(line) + if match == nil { + continue + } + if match[2] == "Passed" { + results[match[1]] = verifyStatusPass + } else { + results[match[1]] = verifyStatusFail + } + } + return results, len(results) > 0 +} diff --git a/internal/cli/verify_test.go b/internal/cli/verify_test.go new file mode 100644 index 00000000..4a2be1a5 --- /dev/null +++ b/internal/cli/verify_test.go @@ -0,0 +1,315 @@ +package cli + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +func verifySample(t *testing.T, name string) string { + t.Helper() + content, err := os.ReadFile(filepath.Join("testdata", "verify", name)) + if err != nil { + t.Fatal(err) + } + return string(content) +} + +// TestParseVerifyOutputReadsEveryRunner is the parser table. Every sample is real runner output, and +// every expectation names the runner's OWN ids — an id a caller cannot paste back into the runner is +// not an id, it is a label. +func TestParseVerifyOutputReadsEveryRunner(t *testing.T) { + t.Parallel() + for _, testCase := range []struct { + sample string + wantParser string + want verifyResults + }{ + { + sample: "pytest.txt", wantParser: "pytest", + want: verifyResults{ + "tests/test_expand.py::test_is_ignored_file_absolute": verifyStatusPass, + "tests/test_expand.py::test_is_ignored_file_relative": verifyStatusFail, + "tests/test_self.py::TestRunTC::test_ignore_path_recursive": verifyStatusPass, + "tests/test_broken.py::test_collect": verifyStatusError, + // SKIPPED is deliberately absent: a skip is not a verdict about the code, and recording + // it as a pass would make un-skipping look like a regression. + }, + }, + { + sample: "jest.txt", wantParser: "jest/vitest", + want: verifyResults{ + "test/browser/render.test.js > renders a bigint child": verifyStatusPass, + "test/browser/render.test.js > renders nested children": verifyStatusPass, + "test/browser/hooks.test.js > useState with bigint": verifyStatusFail, + "test/browser/hooks.test.js > useState with number": verifyStatusPass, + }, + }, + { + sample: "cargo.txt", wantParser: "cargo test", + want: verifyResults{ + "rules::flake8_simplify::tests::negation_with_equal_op": verifyStatusPass, + "rules::flake8_simplify::tests::negation_with_not_equal_op": verifyStatusFail, + "rules::flake8_simplify::tests::double_negation": verifyStatusPass, + }, + }, + { + sample: "phpunit.txt", wantParser: "phpunit", + want: verifyResults{ + `PhpOffice\PhpSpreadsheet\Calculation\FunctionsTest::testFlattenSingleValue`: verifyStatusFail, + }, + }, + { + sample: "gotest.txt", wantParser: "go test", + want: verifyResults{ + "TestExpandModules/absolute": verifyStatusPass, + "TestExpandModules/relative": verifyStatusFail, + "TestExpandModules": verifyStatusFail, + "TestIgnorePaths": verifyStatusPass, + }, + }, + { + sample: "ctest.txt", wantParser: "ctest", + want: verifyResults{ + "format-test": verifyStatusPass, + "ranges-test": verifyStatusFail, + "chrono-test": verifyStatusPass, + }, + }, + } { + t.Run(testCase.sample, func(t *testing.T) { + t.Parallel() + results, parser, ok := parseVerifyOutput(verifySample(t, testCase.sample)) + if !ok { + t.Fatal("no parser recognised the sample") + } + if parser != testCase.wantParser { + t.Fatalf("parser = %q, want %q", parser, testCase.wantParser) + } + if len(results) != len(testCase.want) { + t.Fatalf("parsed %d results, want %d: %#v", len(results), len(testCase.want), results) + } + for id, want := range testCase.want { + if got := results[id]; got != want { + t.Fatalf("%q = %q, want %q (all: %#v)", id, got, want, results) + } + } + }) + } +} + +// TestParseVerifyOutputRefusesAnUnknownFormat pins rule 2: a parser that is not sure reports nothing. +// A half-read set would manufacture verdicts out of its own parse failures — a test missing from one +// side of the diff reads as a regression. +func TestParseVerifyOutputRefusesAnUnknownFormat(t *testing.T) { + t.Parallel() + if _, parser, ok := parseVerifyOutput(verifySample(t, "unknown.txt")); ok { + t.Fatalf("an unrecognised format was claimed by %q", parser) + } +} + +// TestRenderVerifyVerdictAdjudicatesTheDelta is the output contract: a delta, a verdict, no evidence. +func TestRenderVerifyVerdictAdjudicatesTheDelta(t *testing.T) { + t.Parallel() + baseline := verifyBaseline{Parser: "pytest", Results: verifyResults{ + "a::fixed": verifyStatusFail, + "a::already_bad": verifyStatusFail, + "a::green": verifyStatusPass, + }} + for _, testCase := range []struct { + name string + current verifyResults + want []string + absent []string + }{ + { + name: "a fix with no regressions is a complete verification", + current: verifyResults{ + "a::fixed": verifyStatusPass, "a::already_bad": verifyStatusFail, "a::green": verifyStatusPass, + }, + want: []string{ + "NEWLY PASSING (1): a::fixed", + "PRE-EXISTING FAILURES (also failing before your edit; not caused by your change) (1): a::already_bad", + "VERDICT: PASS — the change fixes the target behavior and introduces no regressions. " + + "Verification is complete; no further test runs are needed.", + }, + absent: []string{"NEWLY FAILING"}, + }, + { + name: "a regression names every id, because the ids are the actionable part", + current: verifyResults{ + "a::fixed": verifyStatusPass, "a::already_bad": verifyStatusFail, "a::green": verifyStatusFail, + }, + want: []string{"NEWLY FAILING (1): a::green", "VERDICT: REGRESSION in 1 test: a::green"}, + absent: []string{"VERDICT: PASS"}, + }, + { + name: "no change is stated as such rather than as a pass", + current: verifyResults{ + "a::fixed": verifyStatusFail, "a::already_bad": verifyStatusFail, "a::green": verifyStatusPass, + }, + want: []string{"VERDICT: NO EFFECT — the target tests behave exactly as before your edit."}, + absent: []string{"NEWLY PASSING", "NEWLY FAILING"}, + }, + } { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + rendered := string(renderVerifyVerdict(verifyVerdictInput{ + baseline: baseline, current: testCase.current, parser: "pytest", parsed: true, + maxBytes: verifyDefaultMaxBytes, + })) + for _, want := range testCase.want { + if !strings.Contains(rendered, want) { + t.Fatalf("missing %q:\n%s", want, rendered) + } + } + for _, absent := range testCase.absent { + if strings.Contains(rendered, absent) { + t.Fatalf("unexpected %q:\n%s", absent, rendered) + } + } + if len(rendered) > verifyDefaultMaxBytes { + t.Fatalf("verdict is %d bytes, over the cap", len(rendered)) + } + }) + } +} + +// TestRenderVerifyVerdictCapsListsAndKeepsTheVerdict pins the two bounds: ids are capped at 20 with a +// count, and the byte cap is enforced from the END so the VERDICT line always survives. A verdict +// without its lists is still a verdict; lists without a verdict are evidence. +func TestRenderVerifyVerdictCapsListsAndKeepsTheVerdict(t *testing.T) { + t.Parallel() + baseline := verifyBaseline{Parser: "pytest", Results: verifyResults{}} + current := verifyResults{} + for index := 0; index < 40; index++ { + id := "suite::test_with_a_deliberately_long_identifier_number_" + string(rune('a'+index%26)) + + string(rune('a'+index/26)) + baseline.Results[id] = verifyStatusPass + current[id] = verifyStatusFail + } + // ROOMY: the 20-id cap binds and the remainder is COUNTED, not dropped silently. + roomy := string(renderVerifyVerdict(verifyVerdictInput{ + baseline: baseline, current: current, parser: "pytest", parsed: true, maxBytes: verifyDefaultMaxBytes, + })) + if !strings.Contains(roomy, "and 20 more") { + t.Fatalf("id list was not capped with a count:\n%s", roomy) + } + if !strings.Contains(roomy, "VERDICT: REGRESSION in 40 tests") { + t.Fatalf("the count is missing from the verdict:\n%s", roomy) + } + // TIGHT: the byte cap wins over the id list, and the VERDICT CLAUSE with its count still survives — + // that clause is the answer, the ids are the bonus. + tight := string(renderVerifyVerdict(verifyVerdictInput{ + baseline: baseline, current: current, parser: "pytest", parsed: true, maxBytes: 400, + })) + if len(tight) > 400 { + t.Fatalf("rendered %d bytes over a 400-byte cap:\n%s", len(tight), tight) + } + if !strings.Contains(tight, "VERDICT: REGRESSION in 40 tests") { + t.Fatalf("the verdict clause did not survive the byte cap:\n%s", tight) + } +} + +// TestRenderVerifyVerdictSaysWhenItIsCoarse pins the honest degradation: with no parseable format there +// are no ids, so there is no delta and no claim about regressions — only the exit code, labelled. +func TestRenderVerifyVerdictSaysWhenItIsCoarse(t *testing.T) { + t.Parallel() + for _, testCase := range []struct { + exit int + want string + }{ + {exit: 0, want: "VERDICT: PASS (exit 0)"}, + {exit: 3, want: "VERDICT: FAIL (exit 3)"}, + } { + rendered := string(renderVerifyVerdict(verifyVerdictInput{ + baseline: verifyBaseline{}, parsed: false, exitCode: testCase.exit, + maxBytes: verifyDefaultMaxBytes, + })) + if !strings.Contains(rendered, testCase.want) { + t.Fatalf("missing %q:\n%s", testCase.want, rendered) + } + if !strings.Contains(rendered, "not recognised") { + t.Fatalf("a coarse verdict did not say it was coarse:\n%s", rendered) + } + } +} + +// TestVerifyEndToEndRecordsThenAdjudicates runs the real verb against a fixture whose "runner" is a +// shell script, so the whole path — record, edit, diff, verdict — is exercised without a toolchain. +func TestVerifyEndToEndRecordsThenAdjudicates(t *testing.T) { + repo := t.TempDir() + // The script reports pytest-shaped output and flips one test to PASSED once `fixed` exists. + write(t, repo, "run.sh", `#!/bin/sh +echo "tests/test_a.py::test_target $( [ -f fixed ] && echo PASSED || echo FAILED )" +echo "tests/test_a.py::test_untouched PASSED" +echo "tests/test_a.py::test_broken FAILED" +[ -f regress ] && echo "tests/test_a.py::test_untouched FAILED" +exit 1 +`) + if err := os.Chmod(filepath.Join(repo, "run.sh"), 0o755); err != nil { + t.Fatal(err) + } + baselinePath := filepath.Join(t.TempDir(), "baseline.json") + run := func(args ...string) string { + var out bytes.Buffer + if err := Run(t.Context(), Options{Version: "0.1.0", Env: EntireEnv{RepoRoot: repo}, Stdout: &out}, + append([]string{"verify", "--repo", repo, "--test", "sh run.sh"}, args...)); err != nil { + t.Fatalf("verify %v: %v", args, err) + } + return out.String() + } + + if got := run("--record-baseline", baselinePath); !strings.Contains(got, "BASELINE RECORDED") || + !strings.Contains(got, "pytest") { + t.Fatalf("record output = %q", got) + } + // PASS: the target flips, nothing else moves, and the pre-existing failure is labelled. + write(t, repo, "fixed", "") + pass := run("--pre-edit-baseline", baselinePath) + for _, want := range []string{ + "NEWLY PASSING (1): tests/test_a.py::test_target", + "PRE-EXISTING FAILURES", "tests/test_a.py::test_broken", + "VERDICT: PASS —", + } { + if !strings.Contains(pass, want) { + t.Fatalf("PASS verdict missing %q:\n%s", want, pass) + } + } + // REGRESSION wins over the fix: a green test going red is the actionable fact. + write(t, repo, "regress", "") + regression := run("--pre-edit-baseline", baselinePath) + if !strings.Contains(regression, "VERDICT: REGRESSION in 1 test: tests/test_a.py::test_untouched") { + t.Fatalf("regression verdict wrong:\n%s", regression) + } + if strings.Contains(regression, "VERDICT: PASS") { + t.Fatalf("a regression was reported as a pass:\n%s", regression) + } + // No raw runner output ever reaches the caller. + for _, forbidden := range []string{"exit 1", "run.sh", "echo"} { + if strings.Contains(regression, forbidden) { + t.Fatalf("runner output leaked (%q):\n%s", forbidden, regression) + } + } +} + +// TestParseVerifyFlagsRequiresABaseline pins that the verb cannot be used without the thing that makes +// it a delta. Without a baseline it would be a test runner, which is what does not work. +func TestParseVerifyFlagsRequiresABaseline(t *testing.T) { + t.Parallel() + if _, err := parseVerifyFlags([]string{"--test", "pytest"}); err == nil { + t.Fatal("verify ran without a baseline") + } + if _, err := parseVerifyFlags([]string{"--pre-edit-baseline", "b.json"}); err == nil { + t.Fatal("verify ran without a test command") + } + flags, err := parseVerifyFlags([]string{"--test", "pytest -q", "--record-baseline", "b.json"}) + if err != nil || flags.MaxBytes != verifyDefaultMaxBytes { + t.Fatalf("flags = %#v err = %v", flags, err) + } + if !verifyCompiledCommand("cargo test -p x") || verifyCompiledCommand("pytest -q") { + t.Fatal("timeout ecosystem split is wrong") + } +} diff --git a/internal/sem/cpp_specialization.go b/internal/sem/cpp_specialization.go new file mode 100644 index 00000000..53e531e0 --- /dev/null +++ b/internal/sem/cpp_specialization.go @@ -0,0 +1,175 @@ +package sem + +import "strings" + +// C++ template specializations are named after the PRIMARY template, not after +// what they specialize: `template struct +// formatter, Char>` is indexed with the name +// `formatter` — the most generic name in fmtlib/fmt, shared by dozens of +// declarations. No name signal can rank it, so the tuple-join formatter (the gold +// fix site of fmtlib/fmt#2457) was reachable only through body text. +// +// The specialization ARGUMENT is what a caller names when they mean this +// declaration ("the tuple_join_view formatter"), so it is added as a searchable +// ALIAS rather than folded into the name. Aliases are additive: the symbol keeps +// its name, its qualified name and — decisively — its compound-v1 ID, which is +// derived from the qualified name. Renaming the symbol to +// `formatter` would have changed that ID for every existing +// specialization in every indexed repository. +// +// Only the FIRST top-level specialization argument is used. C++ partial +// specializations specialize on the leading argument by convention and carry the +// primary template's own parameters (`Char`, `Allocator`, an `enable_if_t` SFINAE +// guard) in the trailing positions, so the leading argument is the discriminating +// one; taking the rest would alias a declaration to its template parameters. +const cppSpecializationMinAliasLength = 3 + +// cppSpecializationAliases returns the searchable aliases for a C/C++ symbol +// whose declaration specializes a template: the leading specialization argument's +// head identifier, and the compound `Name` form a caller may write +// verbatim. It returns nil for anything that is not a specialization — a primary +// template (`struct formatter { ... }`) names no argument. +func cppSpecializationAliases(language, name, signature string) []string { + if language != "C++" && language != "C" { + return nil + } + if name == "" || signature == "" { + return nil + } + arguments, ok := cppSpecializationArgumentList(name, signature) + if !ok { + return nil + } + head := cppTypeHeadIdentifier(firstTopLevelArgument(arguments)) + if len(head) < cppSpecializationMinAliasLength || head == name { + return nil + } + return []string{head, name + "<" + head + ">"} +} + +// cppSpecializationArgumentList returns the text inside the `<...>` that directly +// follows the declared name in the signature (`formatter, Char>` -> `tuple_join_view, Char`). Angle brackets are +// matched by depth, so a nested argument list is kept whole. +func cppSpecializationArgumentList(name, signature string) (string, bool) { + for offset := 0; ; { + index := strings.Index(signature[offset:], name) + if index < 0 { + return "", false + } + index += offset + offset = index + 1 + if index > 0 && identifierByte(signature[index-1]) { + continue // a longer identifier that merely contains the name + } + rest := signature[index+len(name):] + if !strings.HasPrefix(rest, "<") { + continue + } + depth := 0 + for i := 0; i < len(rest); i++ { + switch rest[i] { + case '<': + depth++ + case '>': + depth-- + if depth == 0 { + return rest[1:i], true + } + } + } + return "", false + } +} + +// firstTopLevelArgument returns the first comma-separated entry of a template +// argument list, splitting only at bracket depth zero so a nested list +// (`tuple_join_view`) stays intact. +func firstTopLevelArgument(arguments string) string { + depth := 0 + for i := 0; i < len(arguments); i++ { + switch arguments[i] { + case '<', '(', '[': + depth++ + case '>', ')', ']': + depth-- + case ',': + if depth == 0 { + return strings.TrimSpace(arguments[:i]) + } + } + } + return strings.TrimSpace(arguments) +} + +// cppTypeHeadIdentifier reduces a template argument to the identifier it names: +// pointer/reference/cv qualifiers, namespace qualifiers and its own argument list +// are stripped (`const detail::tuple_join_view&` -> tuple_join_view). +// A non-identifier argument (a value, an expression) yields "". +func cppTypeHeadIdentifier(argument string) string { + text := strings.TrimSpace(argument) + if index := strings.IndexAny(text, "<"); index >= 0 { + text = text[:index] + } + text = strings.TrimRight(text, "*&. \t") + for { + trimmed := text + for _, qualifier := range []string{"const ", "volatile ", "typename ", "struct ", "class ", "unsigned ", "signed "} { + trimmed = strings.TrimPrefix(strings.TrimSpace(trimmed), qualifier) + } + trimmed = strings.TrimSpace(trimmed) + if trimmed == text { + break + } + text = trimmed + } + if index := strings.LastIndex(text, "::"); index >= 0 { + text = text[index+2:] + } + text = strings.TrimSpace(text) + if text == "" { + return "" + } + for i := 0; i < len(text); i++ { + if !identifierByte(text[i]) || text[i] == '.' { + return "" + } + } + if text[0] >= '0' && text[0] <= '9' { + return "" + } + return text +} + +// applyCppSpecializationAliases attaches specialization aliases to a file's +// symbols in place. A member of a specialization inherits its container's +// aliases: the gold edit of fmtlib/fmt#2457 is `formatter>`'s +// own parse/format methods, and each of those is likewise indexed under the bare +// name `formatter`. Existing aliases (registration tables) are preserved. +func applyCppSpecializationAliases(symbols []SymbolRecord) { + aliasesByContainer := map[string][]string{} + for i := range symbols { + symbol := &symbols[i] + if !typeLikeKind(symbol.Kind) { + continue + } + aliases := cppSpecializationAliases(symbol.Language, symbol.Name, symbol.Signature) + if len(aliases) == 0 { + continue + } + symbol.Aliases = appendUnique(symbol.Aliases, aliases...) + aliasesByContainer[symbol.ID] = aliases + } + if len(aliasesByContainer) == 0 { + return + } + for i := range symbols { + symbol := &symbols[i] + if symbol.ContainerID == "" || typeLikeKind(symbol.Kind) { + continue + } + if aliases := aliasesByContainer[symbol.ContainerID]; len(aliases) > 0 { + symbol.Aliases = appendUnique(symbol.Aliases, aliases...) + } + } +} diff --git a/internal/sem/cpp_specialization_test.go b/internal/sem/cpp_specialization_test.go new file mode 100644 index 00000000..60d646e2 --- /dev/null +++ b/internal/sem/cpp_specialization_test.go @@ -0,0 +1,168 @@ +package sem + +import ( + "slices" + "strings" + "testing" +) + +func TestCppSpecializationAliases(t *testing.T) { + t.Parallel() + cases := []struct { + name string + language string + symbol string + signature string + want []string + }{ + { + name: "fmt tuple-join formatter", + language: "C++", + symbol: "formatter", + signature: "struct formatter, Char>", + want: []string{"tuple_join_view", "formatter"}, + }, + { + name: "qualified and cv-decorated argument", + language: "C++", + symbol: "formatter", + signature: "struct formatter&, Char>", + want: []string{"join_view", "formatter"}, + }, + { + name: "primary template names no argument", + language: "C++", + symbol: "formatter", + signature: "struct formatter : detail::fallback_formatter", + want: nil, + }, + { + name: "leading template parameter is not a discriminator", + language: "C++", + symbol: "formatter", + signature: "struct formatter::value>>", + want: nil, + }, + { + name: "self-specialization adds nothing", + language: "C++", + symbol: "formatter", + signature: "struct formatter", + want: nil, + }, + { + name: "non-C++ language is untouched", + language: "Rust", + symbol: "Formatter", + signature: "impl Formatter", + want: nil, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := cppSpecializationAliases(tc.language, tc.symbol, tc.signature) + if !slices.Equal(got, tc.want) { + t.Fatalf("cppSpecializationAliases(%q, %q) = %v, want %v", tc.symbol, tc.signature, got, tc.want) + } + }) + } +} + +// The aliases must reach the snapshot for both the specialization and its +// members, and must NOT change the symbol's name, qualified name or stable ID — +// the compound-v1 ID is derived from the qualified name, so renaming the symbol +// would have re-keyed every specialization in every indexed repository. +func TestCppSpecializationAliasesInSnapshotKeepIDsStable(t *testing.T) { + t.Parallel() + repo := t.TempDir() + writeFile(t, repo, "include/fmt/ranges.h", `#ifndef FMT_RANGES_H_ +#define FMT_RANGES_H_ + +template struct tuple_join_view { + const std::tuple& tuple; + basic_string_view sep; +}; + +template +struct formatter, Char> { + template + auto parse(ParseContext& ctx) -> decltype(ctx.begin()) { + return ctx.begin(); + } + + template + auto format(const tuple_join_view& value, FormatContext& ctx) -> + typename FormatContext::iterator { + return ctx.out(); + } +}; + +#endif +`) + snapshot, err := BuildProviderSnapshot(t.Context(), repo, "test-version") + if err != nil { + t.Fatal(err) + } + var specialization, member *SymbolRecord + for i, symbol := range snapshot.Symbols { + if symbol.FilePath != "include/fmt/ranges.h" { + continue + } + switch { + case symbol.QualifiedName == "formatter" && typeLikeKind(symbol.Kind): + specialization = &snapshot.Symbols[i] + case symbol.QualifiedName == "formatter.parse": + member = &snapshot.Symbols[i] + } + } + if specialization == nil { + t.Fatalf("no symbol for the formatter specialization: %#v", snapshot.Symbols) + } + if !slices.Contains(specialization.Aliases, "tuple_join_view") || + !slices.Contains(specialization.Aliases, "formatter") { + t.Fatalf("specialization aliases = %v, want the specialization argument and the compound form", specialization.Aliases) + } + // Name identity — and therefore the stable ID — is unchanged. + if specialization.Name != "formatter" || specialization.QualifiedName != "formatter" { + t.Fatalf("specialization renamed to %q/%q; compound-v1 IDs must stay stable", specialization.Name, specialization.QualifiedName) + } + if !strings.HasSuffix(specialization.ID, ":formatter") { + t.Fatalf("specialization ID %q no longer derives from the unchanged qualified name", specialization.ID) + } + if specialization.StableIDVersion != StableSymbolIDVersion { + t.Fatalf("stable ID version = %q, want %q", specialization.StableIDVersion, StableSymbolIDVersion) + } + if member == nil { + t.Fatalf("no symbol for the specialization's parse member") + } + if !slices.Contains(member.Aliases, "tuple_join_view") { + t.Fatalf("member aliases = %v, want the container's specialization argument", member.Aliases) + } +} + +// The alias is what carries the name signal: a query naming the specialized-on +// type must score the specialization above an unrelated declaration of the same +// generic name. +func TestCppSpecializationAliasScoresNameSignal(t *testing.T) { + t.Parallel() + q := buildSearchQuery("tuple_join_view formatter") + specialization := SymbolRecord{ + Kind: "struct", Name: "formatter", QualifiedName: "formatter", + Signature: "struct formatter, Char>", + Language: "C++", + Aliases: cppSpecializationAliases("C++", "formatter", "struct formatter, Char>"), + } + plain := SymbolRecord{ + Kind: "struct", Name: "formatter", QualifiedName: "formatter", + Signature: "struct formatter", Language: "C++", + } + specializationScore, signals := symbolSearchScore(q, specialization) + plainScore, _ := symbolSearchScore(q, plain) + if specializationScore <= plainScore { + t.Fatalf("specialization score %v does not beat the same-named primary template %v", specializationScore, plainScore) + } + if !slices.Contains(signals, "alias") { + t.Fatalf("signals = %v, want an alias signal", signals) + } +} diff --git a/internal/sem/go_interface_calls_test.go b/internal/sem/go_interface_calls_test.go new file mode 100644 index 00000000..3310ce9f --- /dev/null +++ b/internal/sem/go_interface_calls_test.go @@ -0,0 +1,243 @@ +package sem + +import ( + "fmt" + "strings" + "testing" +) + +// writeGoCommunicatorRepo reproduces terraform's provisioner shape: an interface +// in its own package, concrete implementations in sibling packages, and a +// consumer that receives the interface as a package-qualified parameter and calls +// through it. +func writeGoCommunicatorRepo(t *testing.T, implementations int) string { + t.Helper() + repo := t.TempDir() + writeFile(t, repo, "go.mod", "module example.com/tflike\n\ngo 1.21\n") + writeFile(t, repo, "internal/communicator/communicator.go", `package communicator + +import "io" + +// Communicator is implemented by every provisioner transport. +type Communicator interface { + Connect(o string) error + Disconnect() error + ScriptPath() string + Upload(path string, r io.Reader) error +} +`) + for i := 0; i < implementations; i++ { + name := fmt.Sprintf("t%d", i) + writeFile(t, repo, "internal/communicator/"+name+"/"+name+".go", `package `+name+` + +import "io" + +type Communicator struct{} + +func (c *Communicator) Connect(o string) error { return nil } + +func (c *Communicator) Disconnect() error { return nil } + +func (c *Communicator) ScriptPath() string { return "/tmp/script" } + +func (c *Communicator) Upload(path string, r io.Reader) error { return nil } +`) + } + writeFile(t, repo, "internal/builtin/provisioners/remote-exec/resource_provisioner.go", `package remoteexec + +import "example.com/tflike/internal/communicator" + +func runScripts(comm communicator.Communicator) error { + if err := comm.Connect("out"); err != nil { + return err + } + defer comm.Disconnect() + remotePath := comm.ScriptPath() + _ = remotePath + return nil +} +`) + return repo +} + +// callsFromTo reports whether a CALLS edge exists between the two qualified +// names, and returns the reason of the first match. +func callsFromTo(snapshot ProviderSnapshot, from, to string) (string, bool) { + byID := map[string]SymbolRecord{} + for _, s := range snapshot.Symbols { + byID[s.ID] = s + } + for _, r := range snapshot.Relations { + if r.Type != "CALLS" { + continue + } + source, sourceOK := byID[r.FromID] + target, targetOK := byID[r.ToID] + if !sourceOK || !targetOK { + continue + } + if source.QualifiedName == from && target.QualifiedName == to { + return r.Reason, true + } + } + return "", false +} + +// A Go interface method requirement must be a symbol with incoming CALLS. Before +// this, tree-sitter's interface body produced no member symbols at all, so +// terraform's communicator.Communicator had ZERO incoming edges and no traversal +// could cross the interface. +func TestGoInterfaceMethodHasIncomingCalls(t *testing.T) { + t.Parallel() + repo := writeGoCommunicatorRepo(t, 2) + + snapshot, err := BuildProviderSnapshot(t.Context(), repo, "test-version") + if err != nil { + t.Fatal(err) + } + // The interface method exists as its own symbol, in the interface's file. + var ifaceMethod *SymbolRecord + for i, s := range snapshot.Symbols { + if s.QualifiedName == "Communicator.Disconnect" && s.FilePath == "internal/communicator/communicator.go" { + ifaceMethod = &snapshot.Symbols[i] + } + } + if ifaceMethod == nil { + t.Fatalf("no symbol for the interface method Communicator.Disconnect") + } + if ifaceMethod.Kind != "method" { + t.Fatalf("interface requirement kind = %q, want method", ifaceMethod.Kind) + } + incoming := 0 + for _, r := range snapshot.Relations { + if r.Type == "CALLS" && r.ToID == ifaceMethod.ID { + incoming++ + } + } + if incoming == 0 { + t.Fatalf("interface method %s has no incoming CALLS", ifaceMethod.ID) + } + for _, method := range []string{"Connect", "Disconnect", "ScriptPath"} { + if _, ok := callsFromTo(snapshot, "runScripts", "Communicator."+method); !ok { + t.Fatalf("no CALLS runScripts -> Communicator.%s", method) + } + } +} + +// The interface edge does not replace implementation binding: a call through the +// interface still reaches the concrete methods, so a caller-side traversal lands +// on real code. +func TestGoInterfaceCallCarriesToImplementations(t *testing.T) { + t.Parallel() + repo := writeGoCommunicatorRepo(t, 2) + + snapshot, err := BuildProviderSnapshot(t.Context(), repo, "test-version") + if err != nil { + t.Fatal(err) + } + byID := map[string]SymbolRecord{} + for _, s := range snapshot.Symbols { + byID[s.ID] = s + } + implFiles := map[string]bool{} + for _, r := range snapshot.Relations { + if r.Type != "CALLS" || r.Reason != "interface method call carried to the implementing method" { + continue + } + target := byID[r.ToID] + if target.Name == "Disconnect" { + implFiles[target.FilePath] = true + } + } + if len(implFiles) != 2 { + t.Fatalf("Disconnect implementation hops = %v, want both t0 and t1", implFiles) + } + for file := range implFiles { + if !strings.Contains(file, "/t0/") && !strings.Contains(file, "/t1/") { + t.Fatalf("implementation hop landed outside the transports: %s", file) + } + } +} + +// Past the fan-out cap the interface node stands alone: binding one polymorphic +// call to every implementation in the repository is noise, not resolution. +func TestGoInterfaceCallFanoutCapKeepsInterfaceEdgeOnly(t *testing.T) { + t.Parallel() + repo := writeGoCommunicatorRepo(t, goInterfaceImplementationFanoutCap+1) + + snapshot, err := BuildProviderSnapshot(t.Context(), repo, "test-version") + if err != nil { + t.Fatal(err) + } + for _, r := range snapshot.Relations { + if r.Type == "CALLS" && r.Reason == "interface method call carried to the implementing method" { + t.Fatalf("fan-out cap ignored: %s -> %s", r.FromID, r.ToID) + } + } + // The interface hop itself must survive the cap. + if _, ok := callsFromTo(snapshot, "runScripts", "Communicator.Disconnect"); !ok { + t.Fatalf("interface edge dropped along with the capped implementations") + } +} + +// A one-method interface is satisfied by any type that happens to own a +// same-named method, so it must not fan out on name alone. This is the existing +// precision contract, retained now that the interface itself is a symbol. +func TestGoSingleMethodInterfaceDoesNotFanOutOnAmbiguousName(t *testing.T) { + t.Parallel() + repo := t.TempDir() + writeFile(t, repo, "go.mod", "module example.com/etcdlike\n\ngo 1.21\n") + writeFile(t, repo, "server/server.go", `package server + +import "example.com/etcdlike/auth" + +type Server struct { + authStore auth.AuthStore +} + +func (s *Server) AuthStore() auth.AuthStore { return s.authStore } + +func (s *Server) Close() error { return nil } + +func (s *Server) shutdown() error { + return s.AuthStore().Close() +} +`) + writeFile(t, repo, "auth/store.go", `package auth + +type AuthStore interface { + Close() error +} + +type authStore struct{} + +func (as *authStore) Close() error { return nil } +`) + snapshot, err := BuildProviderSnapshot(t.Context(), repo, "test-version") + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"authStore.Close", "Server.Close"} { + if reason, ok := callsFromTo(snapshot, "Server.shutdown", name); ok { + t.Fatalf("ambiguous one-method interface guessed an implementation: -> %s (%s)", name, reason) + } + } +} + +// goInterfaceRequirementMethod discriminates a bare interface requirement from a +// real Go method declaration by the leading `func` keyword. +func TestGoInterfaceRequirementMethodDiscriminator(t *testing.T) { + t.Parallel() + requirement := SymbolRecord{Language: "Go", Kind: "method", Signature: "Disconnect() error"} + concrete := SymbolRecord{Language: "Go", Kind: "method", Signature: "func (c *Communicator) Disconnect() error"} + if !goInterfaceRequirementMethod(requirement) { + t.Fatalf("bare interface requirement not recognised") + } + if goInterfaceRequirementMethod(concrete) { + t.Fatalf("concrete method misread as an interface requirement") + } + other := SymbolRecord{Language: "TypeScript", Kind: "method", Signature: "disconnect(): void"} + if goInterfaceRequirementMethod(other) { + t.Fatalf("non-Go method classified as a Go interface requirement") + } +} diff --git a/internal/sem/go_package_scope_test.go b/internal/sem/go_package_scope_test.go new file mode 100644 index 00000000..dad058d2 --- /dev/null +++ b/internal/sem/go_package_scope_test.go @@ -0,0 +1,168 @@ +package sem + +import ( + "testing" +) + +// writeGoSiblingDiscoveryRepo lays out the shape that made receiver-method +// resolution pick the wrong package in prometheus/prometheus: two sibling +// packages under one module, each declaring `Discovery` with a `refresh` +// method and a `NewDiscovery` constructor, plus a test in one of them that +// calls `d.refresh(ctx)` on the constructor's result. +func writeGoSiblingDiscoveryRepo(t *testing.T) string { + t.Helper() + repo := t.TempDir() + writeFile(t, repo, "go.mod", "module github.com/example/mod\n\ngo 1.21\n") + pkg := func(name string) string { + return `package ` + name + ` + +type SDConfig struct { + Port int +} + +type Discovery struct { + port int +} + +func NewDiscovery(conf *SDConfig) (*Discovery, error) { + port := conf.Port + return &Discovery{port: port}, nil +} + +func (d *Discovery) refresh(ctx string) (int, error) { + return d.port, nil +} +` + } + // azure sorts before puppetdb, so a first-match resolver lands here. + writeFile(t, repo, "discovery/azure/azure.go", pkg("azure")) + writeFile(t, repo, "discovery/puppetdb/puppetdb.go", pkg("puppetdb")) + writeFile(t, repo, "discovery/puppetdb/puppetdb_test.go", `package puppetdb + +import "testing" + +func TestPuppetDBRefresh(t *testing.T) { + cfg := SDConfig{Port: 80} + d, err := NewDiscovery(&cfg) + if err != nil { + t.Fatal(err) + } + ctx := "ctx" + tgs, err := d.refresh(ctx) + if err != nil { + t.Fatal(err) + } + _ = tgs +} +`) + return repo +} + +// relationTargetFiles returns the target file paths of every relation of the +// given type whose from-symbol short name matches. +func relationTargetFiles(snapshot ProviderSnapshot, relType, from, targetName string) []string { + byID := map[string]SymbolRecord{} + for _, s := range snapshot.Symbols { + byID[s.ID] = s + } + var out []string + for _, r := range snapshot.Relations { + if r.Type != relType || lastSegment(r.FromID) != from { + continue + } + target, ok := byID[r.ToID] + if !ok || (targetName != "" && target.Name != targetName) { + continue + } + out = append(out, target.FilePath) + } + return out +} + +// A Go receiver-method call must bind inside the call site's own package. Before +// package scoping, `d.refresh(ctx)` in discovery/puppetdb/puppetdb_test.go bound +// to discovery/azure's identically named Discovery.refresh, so no hop from the +// test reached the code under test. +func TestGoReceiverMethodBindsToSamePackageNotSibling(t *testing.T) { + t.Parallel() + repo := writeGoSiblingDiscoveryRepo(t) + + snapshot, err := BuildProviderSnapshot(t.Context(), repo, "test-version") + if err != nil { + t.Fatal(err) + } + targets := relationTargetFiles(snapshot, "CALLS", "TestPuppetDBRefresh", "refresh") + if len(targets) == 0 { + t.Fatalf("no CALLS edge from TestPuppetDBRefresh to a refresh method") + } + for _, file := range targets { + if file != "discovery/puppetdb/puppetdb.go" { + t.Fatalf("CALLS TestPuppetDBRefresh -> refresh resolved to %s, want discovery/puppetdb/puppetdb.go", file) + } + } +} + +// The same scoping governs field access: `conf.Port` inside puppetdb's +// NewDiscovery must read puppetdb's own SDConfig.Port, not the azure sibling's. +func TestGoFieldAccessBindsToSamePackageNotSibling(t *testing.T) { + t.Parallel() + repo := writeGoSiblingDiscoveryRepo(t) + + snapshot, err := BuildProviderSnapshot(t.Context(), repo, "test-version") + if err != nil { + t.Fatal(err) + } + targets := relationTargetFiles(snapshot, "READS_FIELD", "NewDiscovery", "Port") + if len(targets) == 0 { + t.Fatalf("no READS_FIELD edge from NewDiscovery to SDConfig.Port") + } + for _, file := range targets { + if file != "discovery/puppetdb/puppetdb.go" && file != "discovery/azure/azure.go" { + t.Fatalf("unexpected READS_FIELD target file %s", file) + } + } + // Both packages declare NewDiscovery, so each edge must stay inside its own + // package rather than collapsing onto whichever file sorted first. + if got := relationTargetFiles(snapshot, "READS_FIELD", "NewDiscovery", "Port"); len(got) != 2 { + t.Fatalf("READS_FIELD NewDiscovery -> SDConfig.Port = %v, want one edge per package", got) + } + seen := map[string]bool{} + for _, file := range targets { + if seen[file] { + t.Fatalf("two NewDiscovery declarations both read %s: %v", file, targets) + } + seen[file] = true + } +} + +// pathProximityRank orders candidates same-file < same-directory < nearer +// shared-prefix < farther, and never reorders equal-proximity candidates. +func TestPathProximityRankOrdersByPackageDistance(t *testing.T) { + t.Parallel() + site := "discovery/puppetdb/puppetdb_test.go" + sameFile := pathProximityRank(site, site) + samePkg := pathProximityRank("discovery/puppetdb/puppetdb.go", site) + sibling := pathProximityRank("discovery/azure/azure.go", site) + distant := pathProximityRank("web/api/v1/api.go", site) + if !(sameFile < samePkg && samePkg < sibling && sibling < distant) { + t.Fatalf("ranks not ordered: sameFile=%d samePkg=%d sibling=%d distant=%d", sameFile, samePkg, sibling, distant) + } +} + +// nearestToSite is stable: two candidates at the same proximity keep input order, +// so ambiguity falls back to the previous first-match behaviour rather than to an +// arbitrary map-iteration winner. +func TestNearestToSiteIsStableOnTies(t *testing.T) { + t.Parallel() + candidates := []SymbolRecord{ + {ID: "a", FilePath: "pkg/one/a.go"}, + {ID: "b", FilePath: "pkg/two/b.go"}, + } + got, ok := nearestToSite(candidates, "pkg/three/c.go") + if !ok || got.ID != "a" { + t.Fatalf("nearestToSite tie = %q (ok=%v), want first input candidate %q", got.ID, ok, "a") + } + if _, ok := nearestToSite(nil, "pkg/three/c.go"); ok { + t.Fatalf("nearestToSite on empty candidates reported a match") + } +} diff --git a/internal/sem/model.go b/internal/sem/model.go index 78a053a0..a0eb78e8 100644 --- a/internal/sem/model.go +++ b/internal/sem/model.go @@ -20,6 +20,16 @@ type Entity struct { // def). It is still a real symbol, but it is only callable from within its // enclosing function, so call resolution must not name-match it across scopes. Local bool `json:"-"` + // bodyless marks a declaration that declares a callable without defining it: + // a TypeScript overload signature or an ambient `declare function`. It is a + // real symbol (the declared types live only there), but it is NOT a second + // definition of the name — the implementation right below it is. Two rules + // depend on the distinction: the implementation must keep the bare + // compound-v1 symbol ID no matter how many signatures precede it, and a call + // that lands on an overload set must resolve to the implementation instead of + // being downgraded as ambiguous. Private, like the other parse metadata, so + // the frozen schema is unchanged. + bodyless bool // sourceStartByte/sourceEndByte are the exact tree-sitter declaration range. // They are internal parse metadata: public schema and stable symbol identity // intentionally remain line based. A zero start is valid when end > start. diff --git a/internal/sem/parser.go b/internal/sem/parser.go index 1b732674..f87ca4df 100644 --- a/internal/sem/parser.go +++ b/internal/sem/parser.go @@ -4350,6 +4350,11 @@ func pythonOverloadStub(node *sitter.Node, src []byte) bool { func entityFromNode(node *sitter.Node, src []byte, language, scope string) (Entity, bool) { var kind string var name string + // bodyless: this declaration declares a callable without defining it (see + // Entity.bodyless). Set only where the grammar guarantees no body — a + // TypeScript overload signature or ambient declaration — never for a Dart + // declaration head, whose body is a sibling node the walk re-attaches below. + var bodyless bool switch node.Type() { case "class", "class_definition", "class_declaration", "class_specifier", "mixin_declaration", "abstract_class_declaration": @@ -4391,6 +4396,32 @@ func entityFromNode(node *sitter.Node, src []byte, language, scope string) (Enti name = qualify(scope, name) } case "method_signature", "getter_signature", "setter_signature": + // tree-sitter-typescript emits `method_signature` both for an OVERLOAD + // declaration inside a class body (`over(a: string): void` immediately + // preceding the implementing `method_definition`) and for interface / + // type-literal members. A class-body signature is a real declaration of + // real code — it is where the parameter types and generics of an + // overloaded method live — so it gets its own symbol, exactly like the + // `abstract_method_signature` case above. Interface and type-literal + // members stay inventory-only: they declare a contract, not a definition, + // so emitting them makes every `x.m()` call name-resolvable to a bodyless + // interface member. That is not hypothetical — extending this case to + // interface_body/object_type members fabricates a CALLS edge in + // TestTypeScriptNamespaceCallSkipsParameterReceiverRoots, where a + // parameter named `B` shadows a namespace and `B.parse()` then binds to + // `Client.parse` instead of resolving to nothing. + if language == "TypeScript" { + if node.Type() != "method_signature" || !typeScriptClassBodySignature(node) { + return Entity{}, false + } + kind = "method" + bodyless = true + name = nodeName(node, src) + if scope != "" { + name = qualify(scope, name) + } + break + } // Dart class members (declaration head; body is a sibling node). Gated to // Dart because `method_signature` also denotes TypeScript interface // members, where extracting them as methods would change TS behavior. @@ -4410,6 +4441,26 @@ func entityFromNode(node *sitter.Node, src []byte, language, scope string) (Enti name = qualify(scope, name) } case "function_signature": + // tree-sitter-typescript emits `function_signature` for every BODYLESS + // function declaration: a TypeScript OVERLOAD signature and an ambient + // `declare function`. Both are top-level declarations of the module's + // public surface, and an overload set is where the parameter types and + // generics live — the implementation's own signature is usually the + // erased `(source: any, ...)` catch-all. Dropping them made a file like + // vue's renderList.ts (five overloads + one implementation) expose a + // single symbol covering only the implementation's lines, so the typed + // half of the file was invisible to search and no query phrased in terms + // of the declared types could retrieve it. + if language == "TypeScript" { + kind = "function" + bodyless = true + name = nodeName(node, src) + if scope != "" { + kind = "method" + name = qualify(scope, name) + } + break + } // Dart top-level / local function declaration head. Gated to Dart because // `function_signature` also denotes TypeScript ambient declarations. if language != "Dart" { @@ -4708,6 +4759,27 @@ func entityFromNode(node *sitter.Node, src []byte, language, scope string) (Enti kind = "method" name = qualify(scope, name) } + case "method_elem", "method_spec": + // A Go interface method requirement (`Disconnect() error` inside + // `type Communicator interface { ... }`; `method_elem` in current + // tree-sitter-go, `method_spec` in older grammars). Without a symbol for + // it, a call through an interface-typed receiver has nothing to bind to, + // so the interface is a dead end in the call graph: terraform's + // communicator.Communicator had ZERO incoming CALLS even though every + // provisioner drives its remote work through it. + // + // The declaration is only a member when it is scoped under its interface; + // an unscoped method_elem (a generic constraint written inline) declares + // nothing addressable and stays unextracted. + if language != "Go" || scope == "" { + return Entity{}, false + } + name = firstChildOfType(node, src, "field_identifier") + if name == "" { + return Entity{}, false + } + kind = "method" + name = qualify(scope, name) case "method_declaration": // An Objective-C method_declaration is a prototype in an @interface / // category head; the @implementation's method_definition carries the @@ -5041,6 +5113,7 @@ func entityFromNode(node *sitter.Node, src []byte, language, scope string) (Enti EndLine: int(node.EndPoint().Row) + 1, BodyHash: hash(normalize(block)), Fingerprint: hash(normalize(entityFingerprintSource(Entity{Name: name, Signature: signatureFromNode(node, src)}, block))), + bodyless: bodyless, } // F# modules and members carry their declarations/body as direct siblings of // the name (no `body` field or body-like wrapper node), so signatureFromNode @@ -5067,6 +5140,16 @@ func entityFromNode(node *sitter.Node, src []byte, language, scope string) (Enti return entity, true } +// typeScriptClassBodySignature reports whether a tree-sitter-typescript +// `method_signature` node is a CLASS member (an overload declaration or an +// ambient class method) rather than an interface / type-literal member. The +// grammar reuses one node type for all three; only the class form declares +// real code, so only it becomes a symbol. +func typeScriptClassBodySignature(node *sitter.Node) bool { + parent := node.Parent() + return validNode(parent) && parent.Type() == "class_body" +} + // dartInnerSignature returns the function/getter/setter signature wrapped by a // Dart method_signature node, if any. func dartInnerSignature(node *sitter.Node) *sitter.Node { diff --git a/internal/sem/parser_typescript_regression_test.go b/internal/sem/parser_typescript_regression_test.go index cbc51a80..dd3c9dc1 100644 --- a/internal/sem/parser_typescript_regression_test.go +++ b/internal/sem/parser_typescript_regression_test.go @@ -158,3 +158,347 @@ func TestTypeScriptMasksPreserveLength(t *testing.T) { } } } + +// A TypeScript OVERLOAD SET is several bodyless `function_signature` +// declarations followed by one implementation. tree-sitter emits a distinct +// node type for the bodyless form, and it used to be gated to Dart, so an +// overloaded function collapsed to the single implementation symbol: vue's +// packages/runtime-core/src/helpers/renderList.ts (five overloads on lines +// 10-53, implementation on 54) exposed exactly one symbol spanning 54-107, and +// the declared parameter types and generics — which live only in the overloads +// — were unindexed. Every declaration must get its own symbol over its own +// lines, and the spans must not overlap. +func TestTypeScriptOverloadSignaturesEachEmitSymbol(t *testing.T) { + src := "/** v-for string */\n" + // 1 + "export function renderList(\n" + // 2 + " source: string,\n" + // 3 + " renderItem: (value: string, index: number) => VNodeChild,\n" + // 4 + "): VNodeChild[]\n" + // 5 + "\n" + // 6 + "/** v-for iterable */\n" + // 7 + "export function renderList(\n" + // 8 + " source: Iterable,\n" + // 9 + " renderItem: (value: T, index: number) => VNodeChild,\n" + // 10 + "): VNodeChild[]\n" + // 11 + "\n" + // 12 + "/** implementation */\n" + // 13 + "export function renderList(\n" + // 14 + " source: any,\n" + // 15 + " renderItem: (...args: any[]) => VNodeChild,\n" + // 16 + "): VNodeChild[] {\n" + // 17 + " return []\n" + // 18 + "}\n" // 19 + + var got [][2]int + for _, entity := range requireNoTSParseError(t, "renderList.ts", src) { + if entity.Name != "renderList" { + t.Fatalf("unexpected entity %+v", entity) + } + if entity.Kind != "function" { + t.Errorf("kind = %q, want function: %+v", entity.Kind, entity) + } + got = append(got, [2]int{entity.StartLine, entity.EndLine}) + } + want := [][2]int{{2, 5}, {8, 11}, {14, 19}} + if len(got) != len(want) { + t.Fatalf("renderList symbols = %d %v, want %d declarations %v", len(got), got, len(want), want) + } + for i, span := range want { + if got[i] != span { + t.Errorf("declaration %d span = %v, want %v", i, got[i], span) + } + } + // Overlapping spans would mean a signature was merged into the + // implementation instead of standing on its own lines. + for i := 1; i < len(got); i++ { + if got[i][0] <= got[i-1][1] { + t.Errorf("spans overlap: %v then %v", got[i-1], got[i]) + } + } + + // Same file, same qualified name, same kind: the compound-v1 ID scheme must + // still hand out one distinct, stable ID per overload, or `def`/`neighbors` + // on an overload set collapses back to a single target. + // See TestNeighborsExactSymbolIDDisambiguatesSameFileOverloads in + // internal/cli for the consumer side of this contract. + entities, _, _ := TreeSitterParser{}.ParseWithStatus("renderList.ts", src) + ids := map[string]bool{} + for _, symbol := range entitySymbols("gh/vuejs/core", "src/renderList.ts", "TypeScript", entities) { + if ids[symbol.ID] { + t.Errorf("duplicate symbol ID %q across overloads", symbol.ID) + } + ids[symbol.ID] = true + } + if len(ids) != len(want) { + t.Errorf("distinct overload IDs = %d, want %d", len(ids), len(want)) + } +} + +// The same bodyless shape covers ambient declarations (`declare function`, +// including inside `declare namespace`/`.d.ts` files) and overloaded class +// methods and constructors (`method_signature` in a class_body). Interface and +// type-literal members share the node type but stay unextracted on purpose: +// they declare a contract rather than code, and emitting them lets a call bind +// to a bodyless member (see TestTypeScriptNamespaceCallSkipsParameterReceiverRoots). +func TestTypeScriptBodylessDeclarationCoverage(t *testing.T) { + for _, testCase := range []struct { + name string + path string + src string + want []string + }{{ + name: "ambient function", + path: "globals.d.ts", + src: "declare function amb(a: string): void\ndeclare namespace N {\n function inner(b: number): void\n}\n", + want: []string{"function amb", "function inner"}, + }, { + name: "class method and constructor overloads", + path: "worker.ts", + src: "class Worker {\n" + + " constructor(a: string)\n" + + " constructor(a: any) {}\n" + + " run(a: string): void\n" + + " run(a: number): void\n" + + " run(a: any): void {}\n" + + "}\n", + want: []string{ + "class Worker", "method Worker.constructor", "method Worker.constructor", + "method Worker.run", "method Worker.run", "method Worker.run", + }, + }, { + name: "interface and type-literal members stay out", + path: "contract.ts", + src: "interface Client {\n parse(): void\n parse(raw: string): void\n}\ntype Handler = { handle(a: string): void }\n", + want: []string{"interface Client", "type Handler"}, + }} { + t.Run(testCase.name, func(t *testing.T) { + var got []string + for _, entity := range requireNoTSParseError(t, testCase.path, testCase.src) { + got = append(got, entity.Kind+" "+entity.Name) + } + if strings.Join(got, ", ") != strings.Join(testCase.want, ", ") { + t.Errorf("entities = [%s], want [%s]", strings.Join(got, ", "), strings.Join(testCase.want, ", ")) + } + }) + } +} + +// A bodyless declaration must never RENAME the implementation it declares. +// +// compound-v1 symbol IDs are a published contract: they are stable across +// ordinary edits, and `stable_id_version` is the only signal a consumer gets +// when that changes. Emitting overload signatures made the shared base ID +// non-unique, so the disambiguation branch in entitySymbols fired for the +// IMPLEMENTATION too and every pinned ID silently moved: +// +// before: local/tsrepo:TypeScript:src/renderList.ts:function:renderList +// after: local/tsrepo:TypeScript:src/renderList.ts:function:renderList#sig:6ece... +// +// Adding a signature above a function is an ordinary edit. The implementation +// keeps the bare ID; only the bodyless declarations take a suffix. Genuine +// duplicates — two real definitions of one name — must still both be suffixed, +// which is the case this test pins alongside. +func TestTypeScriptOverloadSignaturesKeepImplementationSymbolIDStable(t *testing.T) { + bareID := func(t *testing.T, path, src string) []string { + t.Helper() + entities, _, status := TreeSitterParser{}.ParseWithStatus(path, src) + if status.ParseError { + t.Fatalf("unexpected parse error: %s", status.Detail) + } + var bare []string + for _, symbol := range entitySymbols("local/tsrepo", path, "TypeScript", entities) { + if !strings.Contains(symbol.ID, "#sig:") { + bare = append(bare, symbol.ID) + } + if symbol.StableIDVersion != StableSymbolIDVersion { + t.Errorf("stable_id_version = %q, want %q", symbol.StableIDVersion, StableSymbolIDVersion) + } + } + return bare + } + + const overloaded = "export function renderList(source: string, fn: (v: string) => any): any[]\n" + + "export function renderList(source: Iterable, fn: (v: T) => any): any[]\n" + + "export function renderList(source: any, fn: (...a: any[]) => any): any[] {\n" + + " return []\n" + + "}\n" + // The ID the implementation had before overload signatures were symbols, and + // therefore the ID it must still have. + const implementationID = "local/tsrepo:TypeScript:src/renderList.ts:function:renderList" + if got := bareID(t, "src/renderList.ts", overloaded); len(got) != 1 || got[0] != implementationID { + t.Errorf("bare IDs = %v, want exactly [%s]", got, implementationID) + } + + // A class overload set behaves the same: the method_definition keeps the + // bare ID, the method_signature declarations above it are suffixed. + const overloadedMethod = "class Worker {\n" + + " run(a: string): void\n" + + " run(a: number): void\n" + + " run(a: any): void {}\n" + + "}\n" + wantClass := []string{ + "local/tsrepo:TypeScript:src/worker.ts:class:Worker", + "local/tsrepo:TypeScript:src/worker.ts:method:Worker.run", + } + if got := bareID(t, "src/worker.ts", overloadedMethod); strings.Join(got, ",") != strings.Join(wantClass, ",") { + t.Errorf("bare IDs = %v, want %v", got, wantClass) + } + + // A lone bodyless declaration is the only declaration of its name, so it + // keeps the bare ID — nothing it could collide with, and suffixing it would + // be the same churn in the other direction. + const ambient = "declare function amb(a: string): void\n" + wantAmbient := []string{"local/tsrepo:TypeScript:src/globals.d.ts:function:amb"} + if got := bareID(t, "src/globals.d.ts", ambient); strings.Join(got, ",") != strings.Join(wantAmbient, ",") { + t.Errorf("bare IDs = %v, want %v", got, wantAmbient) + } + + // Genuine ambiguity is unchanged: two real definitions of one name are both + // suffixed, exactly as before overload signatures were emitted. + const duplicated = "export function dup(a: string): void {\n return\n}\n" + + "export function dup(a: number): void {\n return\n}\n" + if got := bareID(t, "src/dup.ts", duplicated); len(got) != 0 { + t.Errorf("two real definitions left a bare ID %v; both must disambiguate", got) + } +} + +// An overload set is ONE call target, not an ambiguity. +// +// resolveImportedCallTargets downgrades a multi-candidate imported call to +// confidence 0.62 / resolution "name_only". Once every overload signature +// became a symbol, an ordinary `import { renderList }` + `renderList(...)` +// matched three candidates and took that path, and because the fast profile +// keeps only exact/package/import_resolved edges (shallowCallRelationRetained) +// it then dropped ALL of them — `impact --profile fast` answered "no callers" +// for a function with a caller, which is a false negative stated as fact. +// +// The set is one implementation plus its own bodyless declarations, so the call +// resolves to the implementation and keeps the confidence and reason it would +// have had before the signatures existed. +func TestTypeScriptOverloadedImportedCallResolvesToImplementation(t *testing.T) { + repo := t.TempDir() + writeFile(t, repo, "src/renderList.ts", + "export function renderList(source: number, fn: (i: number) => any): any[]\n"+ + "export function renderList(source: string, fn: (v: string) => any): any[]\n"+ + "export function renderList(source: any, fn: (...a: any[]) => any): any[] {\n"+ + " return []\n"+ + "}\n") + writeFile(t, repo, "src/caller.ts", + "import { renderList } from './renderList'\n"+ + "\n"+ + "export function useList(items: string[]): any[] {\n"+ + " return renderList(items, (v) => v)\n"+ + "}\n") + + for _, profile := range []Profile{ProfileFull, ProfileFast} { + t.Run(string(profile), func(t *testing.T) { + snapshot, err := BuildProviderSnapshotWithOptions(t.Context(), repo, "test-version", + ProviderSnapshotOptions{Profile: profile}) + if err != nil { + t.Fatal(err) + } + calls := runCallsFrom(snapshot, "useList") + if len(calls) != 1 { + t.Fatalf("useList CALLS = %d, want 1 edge to the implementation: %#v", len(calls), calls) + } + call := calls[0] + if call.Resolution != "import_resolved" || call.Confidence < 0.86 { + t.Errorf("call = %s conf %.2f (%s), want import_resolved at 0.86: %#v", + call.Resolution, call.Confidence, call.Reason, call) + } + if strings.Contains(call.Reason, "ambiguous") { + t.Errorf("overload set reported as ambiguity: %q", call.Reason) + } + // The target is the implementation — the declaration that has a body, + // which is the one that keeps the bare compound-v1 ID. + wantTarget := "" + for _, symbol := range snapshot.Symbols { + if symbol.FilePath == "src/renderList.ts" && symbol.Name == "renderList" && !symbol.bodyless { + wantTarget = symbol.ID + } + } + if wantTarget == "" { + t.Fatal("no renderList implementation symbol in the snapshot") + } + if call.ToID != wantTarget { + t.Errorf("call target = %s, want the implementation %s", call.ToID, wantTarget) + } + }) + } +} + +// The overload collapse must never resolve a call the tool cannot actually +// resolve, so its guard is pinned directly: only a set that is one +// implementation plus its OWN bodyless declarations collapses. Everything else +// — two real definitions, candidates in different files or under different +// qualified names, a set with no implementation at all — stays ambiguous and +// takes the existing downgrade. +func TestBodylessOverloadImplementationRefusesGenuineAmbiguity(t *testing.T) { + target := func(file, qualified, kind string, bodyless bool) resolvedCallTarget { + return resolvedCallTarget{ + SymbolRecord: SymbolRecord{ + ID: file + ":" + qualified + ":" + kind + map[bool]string{true: ":decl", false: ":def"}[bodyless], + FilePath: file, QualifiedName: qualified, Name: qualified, Kind: kind, + Language: "TypeScript", bodyless: bodyless, + }, + Confidence: 0.86, Resolution: "import_resolved", + } + } + for _, testCase := range []struct { + name string + targets []resolvedCallTarget + want string // "" means: refuse to collapse + }{{ + name: "overload set collapses to the implementation", + targets: []resolvedCallTarget{ + target("a.ts", "renderList", "function", true), + target("a.ts", "renderList", "function", true), + target("a.ts", "renderList", "function", false), + }, + want: "a.ts:renderList:function:def", + }, { + name: "two real definitions stay ambiguous", + targets: []resolvedCallTarget{ + target("a.ts", "renderList", "function", false), + target("a.ts", "renderList", "function", false), + }, + }, { + name: "declarations with no implementation stay ambiguous", + targets: []resolvedCallTarget{ + target("a.d.ts", "amb", "function", true), + target("a.d.ts", "amb", "function", true), + }, + }, { + name: "candidates in different files stay ambiguous", + targets: []resolvedCallTarget{ + target("a.ts", "renderList", "function", true), + target("b.ts", "renderList", "function", false), + }, + }, { + name: "candidates under different qualified names stay ambiguous", + targets: []resolvedCallTarget{ + target("a.ts", "Worker.run", "method", true), + target("a.ts", "Other.run", "method", false), + }, + }} { + t.Run(testCase.name, func(t *testing.T) { + got, ok := bodylessOverloadImplementation(testCase.targets) + if testCase.want == "" { + if ok { + t.Fatalf("collapsed to %s; must stay ambiguous", got.ID) + } + return + } + if !ok { + t.Fatalf("refused to collapse; want %s", testCase.want) + } + if got.ID != testCase.want { + t.Errorf("collapsed to %s, want %s", got.ID, testCase.want) + } + if got.Resolution != "import_resolved" || got.Confidence != 0.86 { + t.Errorf("target = %s conf %.2f, want the original import_resolved/0.86", + got.Resolution, got.Confidence) + } + }) + } +} diff --git a/internal/sem/provider.go b/internal/sem/provider.go index 056fc4da..d006c1a7 100644 --- a/internal/sem/provider.go +++ b/internal/sem/provider.go @@ -276,7 +276,13 @@ type SymbolRecord struct { // frozen provider schema and symbol IDs do not change. sourceStartByte int sourceEndByte int - parameterNames []string + // bodyless: this symbol declares a callable without defining it (a + // TypeScript overload signature or ambient declaration; see Entity.bodyless). + // Call resolution uses it to tell an overload set apart from genuinely + // ambiguous same-name definitions. Private, so the frozen schema and the + // compound-v1 IDs are unchanged. + bodyless bool + parameterNames []string // parameterNamesKnown distinguishes an AST-confirmed empty parameter list // from missing parser metadata. This stays private to preserve the frozen // provider schema. @@ -1171,6 +1177,7 @@ func StreamSnapshot(ctx context.Context, repo, providerVersion string, options P fileSymbols[i].Aliases = aliases } } + applyCppSpecializationAliases(fileSymbols) for _, symbol := range fileSymbols { if err := emit(symbol); err != nil { return err @@ -1683,10 +1690,23 @@ func WriteRelationsNDJSON(out io.Writer, snapshot ProviderSnapshot) error { func entitySymbols(repoKey, path, language string, entities []Entity) []SymbolRecord { byName := map[string]string{} - baseCounts := map[string]int{} + // definitionCounts counts only entities that DEFINE the name; declarationCounts + // counts the bodyless declarations of it (TypeScript overload signatures, + // ambient `declare function`). They are kept apart so that adding a signature + // above an implementation never renames the implementation: a bodyless + // declaration is not a second definition, and compound-v1 IDs must survive + // ordinary edits (the same reason Swift extensions are not emitted as classes — + // see parser.go's swiftExtensionDeclaration note). + definitionCounts := map[string]int{} + declarationCounts := map[string]int{} sigOrdinals := map[string]int{} for _, entity := range entities { - baseCounts[symbolID(repoKey, language, path, entity.Kind, entity.Name)]++ + id := symbolID(repoKey, language, path, entity.Kind, entity.Name) + if entity.bodyless { + declarationCounts[id]++ + continue + } + definitionCounts[id]++ } // One symbol is emitted per entity, in order, so an entity's index is also its symbol's // index — which is what lets a lexical parent be looked up as symbols[parents[index]]. @@ -1695,7 +1715,17 @@ func entitySymbols(repoKey, path, language string, entities []Entity) []SymbolRe for index, entity := range entities { qualified := entity.Name id := symbolID(repoKey, language, path, entity.Kind, qualified) - if baseCounts[id] > 1 { + // A definition collides only with other definitions: overload signatures + // above it must not push it onto a suffixed ID. A bodyless declaration + // yields the bare ID to the definition whenever anything else shares the + // name, and keeps it when it is the only declaration of that name (a lone + // ambient `declare function`, an abstract member), so no ID that existed + // before overload signatures were emitted changes. + colliding := definitionCounts[id] > 1 + if entity.bodyless { + colliding = definitionCounts[id]+declarationCounts[id] > 1 + } + if colliding { // Disambiguate same-name symbols by signature hash plus an ordinal // within the matching-signature group. This is stable across edits // that shift line numbers, unlike the previous line-range scheme; @@ -1737,6 +1767,7 @@ func entitySymbols(repoKey, path, language string, entities []Entity) []SymbolRe Local: entity.Local, sourceStartByte: entity.sourceStartByte, sourceEndByte: entity.sourceEndByte, + bodyless: entity.bodyless, } // Carried for every language: the parser marks parameterNamesKnown only // when it actually read the names off the parse tree, so a grammar with @@ -2254,6 +2285,17 @@ func resolveImportedCallTargets(name string, from SymbolRecord, candidates []Sym if len(imported) == 0 { imported = jsExportedImportFallbackTargets(name, from, candidates, importsByName[name], allowMethodTargets) } + if len(imported) > 1 { + // An overload set is not ambiguity. `import { renderList }` followed by + // `renderList(...)` has exactly one call target — the implementation — + // even though the file also declares bodyless signatures for it. Without + // this the fanout below downgrades every candidate to "name_only", which + // the fast profile then discards wholesale (shallowCallRelationRetained), + // so `impact` reports a function with real callers as having none. + if implementation, ok := bodylessOverloadImplementation(imported); ok { + return []resolvedCallTarget{implementation} + } + } if len(imported) > 1 { for index := range imported { imported[index].Confidence = minFloat(imported[index].Confidence, 0.62) @@ -2387,6 +2429,45 @@ func cFamilyOverloadResolutionEnabled(language string) bool { return language == "C" || language == "C++" } +// bodylessOverloadImplementation collapses a multi-candidate call fanout that +// is really ONE overloaded function: several bodyless declarations (TypeScript +// overload signatures / ambients) plus the single implementation they belong +// to, all in the same file under the same qualified name. It returns that +// implementation, so the call keeps the confidence and reason it resolved with +// instead of being downgraded as ambiguous. +// +// It refuses whenever the set could be genuine ambiguity: two real definitions +// of the name, candidates spread across files or qualified names, or a set with +// no implementation at all (a pure ambient declaration file, where nothing says +// which declaration the call runs). +func bodylessOverloadImplementation(targets []resolvedCallTarget) (resolvedCallTarget, bool) { + if len(targets) < 2 { + return resolvedCallTarget{}, false + } + first := targets[0].SymbolRecord + if first.FilePath == "" || first.QualifiedName == "" { + return resolvedCallTarget{}, false + } + implementation := -1 + for index, target := range targets { + if target.FilePath != first.FilePath || target.Language != first.Language || + target.QualifiedName != first.QualifiedName || target.Kind != first.Kind { + return resolvedCallTarget{}, false + } + if target.bodyless { + continue + } + if implementation >= 0 { + return resolvedCallTarget{}, false + } + implementation = index + } + if implementation < 0 { + return resolvedCallTarget{}, false + } + return targets[implementation], true +} + func sameFileOverloadSet(candidates []SymbolRecord) ([]SymbolRecord, bool) { if len(candidates) < 2 { return nil, false @@ -4932,6 +5013,14 @@ func receiverCallRelations(from SymbolRecord, block string, methodsByContainer m } } importedReceiverVars := importedReceiverVarTypes(from.Signature, block, importsByName, goModule) + // Receivers declared with an in-module package-qualified type + // (`comm communicator.Communicator`). parameterVarTypes cannot see these, so + // without this tier an interface-typed parameter — the ordinary way Go passes + // a collaborator — carries no receiver type at all. + goQualifiedReceiverVars := map[string]pkgQualType{} + if from.Language == "Go" { + goQualifiedReceiverVars = goInModuleQualifiedReceiverTypes(from.Signature, block, importsByName, goModule) + } deepReturnedCallSuffixes := receiverDeepChainSuffixes(deepChainedReturnCalls, returnedDeepChainCalls) paramTypes := parameterVarTypes(from.Signature) if from.Language == "Swift" { @@ -5070,6 +5159,20 @@ func receiverCallRelations(from SymbolRecord, block string, methodsByContainer m confidence = 0.82 reason = "static method call resolved to the named type" targetID = cls.ID + } else if qt, ok := goQualifiedReceiverVars[call.Receiver]; ok { + // `comm communicator.Communicator`: resolve the qualifier to the + // in-module package's type (alias == directory basename, the Go + // convention resolveQualifiedType already encodes) so the method + // lookup below runs against the right declaration. For an interface + // that declaration's members are its method requirements. + sym, ok := resolveQualifiedType(qt, symbolsByShortName) + if !ok { + continue + } + targetID = sym.ID + receiverTypeKind = sym.Kind + confidence = 0.8 + reason = "method call resolved via package-qualified receiver type" } else if qt, ok := pkgVarTypes[call.Receiver]; ok { // Package-level var of a package-qualified type (alias.Type). Resolve // the specific imported type so an ambiguous bare name (Encoder in @@ -6120,9 +6223,9 @@ func receiverCallRelations(from SymbolRecord, block string, methodsByContainer m resolution := "type_inferred" method, ok := methodsByContainer[sym.ID][call.Method] if !ok && interfaceSignatureDeclaresMethod(sym.Signature, call.Method) { - // Interface-typed return: a Go interface declares its methods - // inside the type declaration itself (they are not separate - // method symbols), so the container lookup above cannot succeed. + // Interface-typed return whose requirement carries no method symbol + // of its own — an embedded interface (`interface { io.Reader }`) + // contributes requirements the local declaration does not spell. // When the locally-known interface names this method and exactly // one method in the workspace carries the name, resolve to that // sole implementation — the same unique-name tier as the @@ -6321,7 +6424,7 @@ func receiverCallRelations(from SymbolRecord, block string, methodsByContainer m break } } - return relations + return appendGoInterfaceImplementationCalls(relations, from, symbolsByShortName, methodsByContainer) } func receiverQualifiedMethodTarget(from SymbolRecord, call receiverCall, candidates []SymbolRecord, returnTypesBySymbolNameAndFile map[string]map[string][]string) (SymbolRecord, float64, string, string, string, bool) { @@ -6363,6 +6466,213 @@ func receiverQualifiedMethodTarget(from SymbolRecord, call receiverCall, candida return SymbolRecord{}, 0, "", "", "", false } +// goInterfaceImplementationFanoutCap bounds how many concrete implementations a +// single interface-method call may additionally bind to. Past it the call is +// genuinely polymorphic and the interface node alone is the honest answer: +// fanning an `io.Writer.Write` call out to every writer in a repository is noise, +// not resolution. +const goInterfaceImplementationFanoutCap = 8 + +// goInterfaceRequirementMethod reports whether a Go method symbol is an interface +// method requirement rather than a concrete method with a body. A Go method +// declaration always spells `func (recv T) Name(...)`; a requirement is written +// bare inside the interface body (`Disconnect() error`), so the leading `func` +// keyword is the discriminator and no container lookup is needed. +func goInterfaceRequirementMethod(symbol SymbolRecord) bool { + return symbol.Language == "Go" && symbol.Kind == "method" && + !strings.HasPrefix(strings.TrimSpace(symbol.Signature), "func") +} + +// goInterfaceImplementationMethods carries a call that resolved onto a Go +// interface method requirement through to the concrete methods that satisfy it. +// +// Go interface satisfaction is implicit — there is no `implements` clause to +// read, so implementersByContainer (which is built from declaration syntax) is +// empty for every Go interface. Satisfaction is recovered structurally instead: a +// type implements the interface when it declares a method for every requirement. +// Two guards keep that from degenerating: +// +// - a single-requirement interface is satisfied by any type that happens to own +// a same-named method (`Close`, `String`, `Error` are everywhere), so it only +// carries through when the called name is unique in the workspace — the same +// conservative tier the pre-interface-symbol code used; +// - more than goInterfaceImplementationFanoutCap satisfying types means the +// interface edge stands alone. +// +// The interface-method edge itself is always emitted by the caller; this is only +// the extra implementation hop. +func goInterfaceImplementationMethods(ifaceMethod SymbolRecord, symbolsByShortName map[string][]SymbolRecord, methodsByContainer map[string]map[string]SymbolRecord) []SymbolRecord { + if !goInterfaceRequirementMethod(ifaceMethod) || ifaceMethod.ContainerID == "" { + return nil + } + members := methodsByContainer[ifaceMethod.ContainerID] + requirements := make([]string, 0, len(members)) + for name, member := range members { + // A container mixing requirement-shaped and body-carrying methods is not + // an interface body (a struct with an embedded anonymous interface field, + // say), so it declares no satisfaction contract. + if !goInterfaceRequirementMethod(member) { + return nil + } + requirements = append(requirements, name) + } + if len(requirements) == 0 { + return nil + } + sort.Strings(requirements) + if len(requirements) == 1 { + if _, unique := uniqueGoConcreteMethodByShortName(symbolsByShortName[ifaceMethod.Name]); !unique { + return nil + } + } + byContainer := map[string]SymbolRecord{} + var order []string + for _, candidate := range symbolsByShortName[ifaceMethod.Name] { + if candidate.Language != "Go" || candidate.Kind != "method" || candidate.ContainerID == "" { + continue + } + if candidate.ContainerID == ifaceMethod.ContainerID || goInterfaceRequirementMethod(candidate) { + continue + } + if _, seen := byContainer[candidate.ContainerID]; seen { + continue + } + implMembers := methodsByContainer[candidate.ContainerID] + satisfied := true + for _, requirement := range requirements { + if _, ok := implMembers[requirement]; !ok { + satisfied = false + break + } + } + if !satisfied { + continue + } + byContainer[candidate.ContainerID] = candidate + order = append(order, candidate.ContainerID) + } + if len(order) == 0 || len(order) > goInterfaceImplementationFanoutCap { + return nil + } + out := make([]SymbolRecord, 0, len(order)) + for _, containerID := range order { + out = append(out, byContainer[containerID]) + } + sort.SliceStable(out, func(i, j int) bool { + if out[i].FilePath != out[j].FilePath { + return out[i].FilePath < out[j].FilePath + } + return out[i].StartLine < out[j].StartLine + }) + return out +} + +// uniqueGoConcreteMethodByShortName is uniqueMethodByShortName restricted to Go +// methods that carry a body, so an interface requirement of the same name does +// not itself count as a second candidate. +func uniqueGoConcreteMethodByShortName(candidates []SymbolRecord) (SymbolRecord, bool) { + var methods []SymbolRecord + for _, candidate := range candidates { + if candidate.Language == "Go" && candidate.Kind == "method" && !goInterfaceRequirementMethod(candidate) { + methods = append(methods, candidate) + } + } + if len(methods) == 1 { + return methods[0], true + } + return SymbolRecord{}, false +} + +// goMethodSymbolByID resolves a symbol ID back to its record using only the +// short-name index. A method's stable ID ends in `:method:.`, so +// the trailing dotted segment gives the bucket to search; anything else (a file +// or external target) resolves to nothing. +func goMethodSymbolByID(id string, symbolsByShortName map[string][]SymbolRecord) (SymbolRecord, bool) { + marker := strings.LastIndex(id, ":method:") + if marker < 0 { + return SymbolRecord{}, false + } + qualified := id[marker+len(":method:"):] + shortName := qualified + if dot := strings.LastIndex(qualified, "."); dot >= 0 { + shortName = qualified[dot+1:] + } + for _, candidate := range symbolsByShortName[shortName] { + if candidate.ID == id { + return candidate, true + } + } + return SymbolRecord{}, false +} + +// appendGoInterfaceImplementationCalls carries every CALLS edge that landed on a +// Go interface method requirement through to the concrete methods that satisfy +// it. It runs once over the finished relation list rather than at each of the +// dozen typed-receiver tiers, so a call reached through a parameter type, a +// returned type or a constructor chain is treated identically. Existing edges are +// never rewritten — this is purely additive, and the interface node keeps the +// incoming edge that used to be impossible (a Go interface had no method symbols +// at all, so terraform's communicator.Communicator was a dead end). +func appendGoInterfaceImplementationCalls(relations []RelationRecord, from SymbolRecord, symbolsByShortName map[string][]SymbolRecord, methodsByContainer map[string]map[string]SymbolRecord) []RelationRecord { + if from.Language != "Go" || len(relations) == 0 { + return relations + } + existing := map[string]bool{} + for _, relation := range relations { + if relation.Type == "CALLS" { + existing[relation.ToID] = true + } + } + // Deterministic order: walk the relations, not a map. + var added []RelationRecord + for _, relation := range relations { + if relation.Type != "CALLS" { + continue + } + // The target symbol is found through the short-name index the caller + // already built (a full by-ID index would cost a workspace scan per + // function body); a method ID always ends `:method:Container.Name`. + ifaceMethod, ok := goMethodSymbolByID(relation.ToID, symbolsByShortName) + if !ok { + continue + } + for _, impl := range goInterfaceImplementationMethods(ifaceMethod, symbolsByShortName, methodsByContainer) { + if impl.ID == from.ID || existing[impl.ID] { + continue + } + existing[impl.ID] = true + scope := "file" + if impl.FilePath != from.FilePath { + scope = "module" + } + detail := ifaceMethod.Name + if len(relation.Evidence) > 0 && relation.Evidence[0].Detail != "" { + detail = relation.Evidence[0].Detail + } + added = append(added, RelationRecord{ + RecordType: "relation", + FromID: from.ID, + ToID: impl.ID, + Type: "CALLS", + Confidence: minFloat(relation.Confidence, 0.7), + Reason: "interface method call carried to the implementing method", + RelationScope: scope, + Resolution: "type_inferred", + TargetKind: "symbol", + Evidence: []Evidence{{ + Kind: "call_site", + FilePath: from.FilePath, + StartLine: from.StartLine, + EndLine: from.EndLine, + Detail: detail, + }}, + WarningCodes: []string{}, + }) + } + } + return append(relations, added...) +} + // uniqueMethodByShortName returns the sole method whose short name matches, if // exactly one method (across the workspace) carries that name. Used as a // last-resort receiver.method() resolver when the receiver type is unknown. @@ -9192,7 +9502,10 @@ func fieldAccessRelations(from SymbolRecord, block string, fieldsByContainer map if id, ok := selfContainers[access.Receiver]; ok { containerID = id } else if typeName, ok := varTypes[access.Receiver]; ok { - if sym, ok := firstTypeLikeNamed(symbolsByShortName[typeName], typeName); ok { + // Nearest-package preference, not first-match: a module that declares + // one `SDConfig` per sibling package would otherwise attribute every + // field read to whichever package sorted first. + if sym, ok := firstTypeLikeNamedPreferFile(symbolsByShortName[typeName], typeName, from.FilePath); ok { containerID = sym.ID confidence = 0.85 if _, ok := paramTypes[access.Receiver]; ok { @@ -9316,6 +9629,60 @@ func firstTypeLikeNamed(records []SymbolRecord, name string) (SymbolRecord, bool return SymbolRecord{}, false } +// pathProximityRank scores how close a candidate declaration is to the site that +// references it. Lower is nearer: +// +// 0 the same file +// 1 the same directory — a Go package IS a directory, and a Go +// module routinely reuses one type name across sibling packages +// (prometheus declares 14 `Discovery` types with a `refresh` +// method, one per discovery//), so "same package" is the +// decisive tier for receiver-method resolution +// 2 + n a shared module-path prefix, n = the number of path segments +// that separate the two directories; a nearer sibling package wins +// over a distant one +// +// Callers keep input order for equal ranks, so an unscoped tie still falls back +// to whatever the previous first-match behaviour picked. +func pathProximityRank(candidateFile, siteFile string) int { + candidate := filepath.ToSlash(candidateFile) + site := filepath.ToSlash(siteFile) + if candidate == site { + return 0 + } + candidateDir := path.Dir(candidate) + siteDir := path.Dir(site) + if candidateDir == siteDir { + return 1 + } + candidateSegments := strings.Split(candidateDir, "/") + siteSegments := strings.Split(siteDir, "/") + shared := 0 + for shared < len(candidateSegments) && shared < len(siteSegments) && candidateSegments[shared] == siteSegments[shared] { + shared++ + } + return 2 + (len(candidateSegments) - shared) + (len(siteSegments) - shared) +} + +// nearestToSite picks the candidate whose declaration lives closest to the +// referencing file, by pathProximityRank. Selection is stable: equal-rank +// candidates keep the order the caller supplied, which is snapshot-deterministic, +// so this only ever changes which of several equally-named declarations wins — +// never whether one is found. +func nearestToSite(candidates []SymbolRecord, siteFile string) (SymbolRecord, bool) { + if len(candidates) == 0 { + return SymbolRecord{}, false + } + best := candidates[0] + bestRank := pathProximityRank(best.FilePath, siteFile) + for _, candidate := range candidates[1:] { + if rank := pathProximityRank(candidate.FilePath, siteFile); rank < bestRank { + best, bestRank = candidate, rank + } + } + return best, true +} + // firstTypeLikeNamedPreferFile resolves a type name to a symbol, preferring a // declaration in the given file before falling back to the first global match. // Same-file preference matters when a repo vendors a mirror copy of its sources @@ -9340,8 +9707,13 @@ func enclosingTypeShortName(from SymbolRecord) string { // the method (roslyn's Contract.InterpolatedStringHandlers.cs sorts before // Contract.cs, which defines ThrowIfFalse); probing only the first // candidate dropped every such static call. Preference order: a same-file -// declaration defining the method, any declaration defining it (directly -// or up its supertype chain), then the plain prefer-file lookup. +// declaration defining the method, the nearest-package declaration defining it +// (directly or up its supertype chain), then the plain prefer-file lookup. +// +// Package scoping is what keeps this honest across a module that reuses one type +// name per sibling package: before it, `d.refresh(ctx)` in +// discovery/puppetdb/puppetdb_test.go bound to discovery/azure's identically +// named Discovery.refresh purely because azure sorted first. func typeLikeNamedWithMethod(records []SymbolRecord, name, file, method string, methodsByContainer map[string]map[string]SymbolRecord, superContainerByID map[string]string) (SymbolRecord, bool) { var withMethod []SymbolRecord for _, symbol := range records { @@ -9353,23 +9725,22 @@ func typeLikeNamedWithMethod(records []SymbolRecord, name, file, method string, } } if len(withMethod) > 0 { - for _, symbol := range withMethod { - if symbol.FilePath == file { - return symbol, true - } - } - return withMethod[0], true + return nearestToSite(withMethod, file) } return firstTypeLikeNamedPreferFile(records, name, file) } +// firstTypeLikeNamedPreferFile resolves a type name to a declaration, preferring +// the referencing file, then the nearest package (see pathProximityRank), and +// only then the first global match. func firstTypeLikeNamedPreferFile(records []SymbolRecord, name, file string) (SymbolRecord, bool) { + var named []SymbolRecord for _, symbol := range records { - if symbol.Name == name && symbol.FilePath == file && typeLikeKind(symbol.Kind) { - return symbol, true + if symbol.Name == name && typeLikeKind(symbol.Kind) { + named = append(named, symbol) } } - return firstTypeLikeNamed(records, name) + return nearestToSite(named, file) } func typeRelationReason(relation, resolution string) string { diff --git a/internal/sem/search.go b/internal/sem/search.go index 4eef06ad..57cfb3d4 100644 --- a/internal/sem/search.go +++ b/internal/sem/search.go @@ -98,6 +98,45 @@ type SearchOptions struct { // payload file land a median 109 lines from anything printed — 47 of 53 inside another symbol the // graph already indexes in that file. IncludeFileOutline bool + // FullUnitTop makes the first N ranks come back as their COMPLETE enclosing unit — function, + // method, or type/container declaration — bypassing every condition the opportunistic body + // upgrade applies (see the editability comment in search_enclosure.go). 0 = off, and off is + // byte-for-byte today's payload. + // + // It is the editability lever, and editability is a different measurement from recall: on the + // R30PUB benchmark payloads the share of needed edits whose replaced text appears VERBATIM in + // the payload is ~11%, while the gold FILE is usually ranked. The misses are the three + // suppression conditions (no enclosable callable, the 160-line cap, and no demotable tail at + // small --top-k), not the ranking. + // + // N >= 2 reaches rank 2 only when rank 2's score is still within searchFullUnitGapRatio of + // rank 1's: one forced unit per genuinely ambiguous answer, not N bodies per search. + FullUnitTop int + // VerifyPrefix is a decorator baked into the emitted VERIFY command, after any `cd &&` the + // derivation added, so a harness can grep its own token out of a transcript. + VerifyPrefix string + // VerifyPreFixStatus is one caller-computed line rendered verbatim as `PRE-FIX:` under the emitted + // command, capped at searchVerifyPreFixStatusMaxBytes. + VerifyPreFixStatus string + // CalleeHop admits the top hit's OUTGOING CALLS targets as candidate fix sites — up to three, + // same-repo and resolved only. Off by default. + // + // It is the answer to what --full-unit-top could not reach. Measured over eight R30PUB instances + // with the unit levers on, 8 of 12 code-gold files were ranked and 0 carried the gold hunk + // verbatim, and in every ranked case the printed unit and the gold unit were different callables + // of the same file — usually the ranked hit being a thin entry point and the gold being the + // helper it calls (redis__redis-10095: lpopCommand ranked, gold inside popGenericCommand, one + // CALLS edge away in the same file). search_related.go excludes outgoing CALLS by design; see + // search_callee.go for why its stated substitute (co-members of the same unit) cannot cover this. + CalleeHop bool + // EditSiteBodies attaches source to the EDIT-role sites of the SAME-CONCEPT LITERAL block: the + // enclosing unit when one is resolvable, else a bounded window around the site. CONSUMER and DOC + // sites stay file:line-only — a consumer is listed precisely so an agent does NOT open it. + // + // Off by default because it is an additive block and the default block is sized at 560 B. When + // it is on, the block's cap rises to searchLiteralEditBodyClusterMaxBytes and its cost is + // reported in stats.literal_cluster_bytes like every other block's. + EditSiteBodies bool // EnclosureContextLines pads the rank-1 complete body with this many source lines on each // side. 0 means no padding (the body's exact symbol bounds). // @@ -159,6 +198,31 @@ type SearchResult struct { // a label rather than a filter. Section string `json:"section,omitempty"` Snippet string `json:"snippet"` + // MergedRanks lists the PRE-merge ranks whose spans this one result now covers, set when + // several near hits in one file were folded into a single contiguous region + // (search_span_merge.go). Present only on a merged span, so its absence is the ordinary + // case; when it is present the snippet is the verbatim, unelided text of the whole range, + // which is the fact that stops a reader spending a turn bridging the gap itself. + MergedRanks []int `json:"merged_ranks,omitempty"` + // UnitStartLine/UnitEndLine are the TRUE span of the enclosing unit when --full-unit-top asked + // for that unit whole and searchFullUnitMaxLines clipped it. They are set ONLY on a clipped + // forced unit — their absence is the ordinary case and means the printed span IS the unit — and + // they exist so a reader can be told which of the unit's own lines are missing rather than + // discovering it by opening the file. Schema 1.x additive. + UnitStartLine int `json:"unit_start_line,omitempty"` + UnitEndLine int `json:"unit_end_line,omitempty"` + // CommentFocusLine is the COMMENT line the query originally matched, recorded only when the + // payload re-anchored this hit onto the code that comment documents (search_reanchor.go). Its + // absence is the ordinary case and means FocusLine is where the match itself landed. It is kept + // because the prose line is still the evidence for why the hit is here, and a reader who is told + // only the code line cannot tell a re-anchored hit from a direct one. Schema 1.x additive. + CommentFocusLine int `json:"comment_focus_line,omitempty"` + // BodyFromReanchor marks a result whose source exists ONLY because the re-anchor moved its + // anchor onto code: the allocation computed WITHOUT the re-anchored enclosures showed nothing at + // this rank. It is what lets the renderer fund such a body out of spare slots only, and it is + // deliberately NOT the same test as `CommentFocusLine > 0` — most re-anchored hits already + // carried a body, and gating those would evict source the re-anchor never paid for. + BodyFromReanchor bool `json:"body_from_reanchor,omitempty"` // There is deliberately no per-result `Neighbors` list here. "The types this hit is // written in terms of" is answered once, by the signature-type block (search_sigtypes.go), // and "the other places this change lands" by the related-site block @@ -218,6 +282,19 @@ type SearchStats struct { // them. Together they report how the byte budget was allocated (schema 1.x additive). CompleteSymbols int `json:"complete_symbol_snippets,omitempty"` LocatorSnippets int `json:"locator_snippets,omitempty"` + // DocReanchored counts ranked hits whose anchor landed in a doc comment and was moved onto the + // code that comment documents (search_reanchor.go). It is reported like every other rendering + // decision: a payload that describes a hit at a line the query did not match must say so. + DocReanchored int `json:"doc_reanchored_hits,omitempty"` + // MergedSpans counts ranked hits collapsed into a contiguous same-file span + // (search_span_merge.go). It is reported for the same reason every other allocation + // decision is: a payload that shows fewer blocks than the ranking produced must say so. + MergedSpans int `json:"merged_spans,omitempty"` + // CalleeHopSites counts candidate fix sites admitted by the callee hop (--callee-hop): units the + // top hit CALLS. It is reported separately from RelatedSites because it is a separate, gated + // route with a different funding rule — see search_callee.go — and because a payload has to be + // attributable to the lever that shaped it. + CalleeHopSites int `json:"callee_hop_sites,omitempty"` // RelatedSites counts entries in the related-site block: the other places the top hit's // change usually has to land (callers, sibling implementations, near-duplicate bodies). // They are funded out of the tail of the ranking, so this count also says how much of the @@ -257,7 +334,10 @@ type SearchStats struct { FileOutlineBytes int `json:"file_outline_bytes,omitempty"` FileOutlineRows int `json:"file_outline_rows,omitempty"` VerifyCommandBytes int `json:"verify_command_bytes,omitempty"` - ClosedSetBytes int `json:"closed_set_bytes,omitempty"` + // VerifyTier is which rung of the ladder produced the emitted command: narrow, suite, build-check + // or none. See the tier constants in search_verify.go. + VerifyTier string `json:"verify_tier,omitempty"` + ClosedSetBytes int `json:"closed_set_bytes,omitempty"` // ContextBlockBytes is the sum of every block counter above: the whole cost of // everything outside `results`, in one number, so a caller can see the payload's true size // without re-deriving it. @@ -402,6 +482,51 @@ func cachedContentReader(read contentReader) contentReader { var searchWordPattern = regexp.MustCompile(`[[:alnum:]_./:+#-]+`) var sparseSearchWordPattern = regexp.MustCompile(`[[:alpha:]][[:alnum:]_]*|[[:digit:]]+`) +// searchQueryURLPattern matches an absolute URL written inside a query. The run stops at +// whitespace and at the delimiters that end a URL in prose and Markdown — `<>"'` + backtick, +// and the closing `)]}` of a `[text](url)` link — so a linked URL does not swallow the +// sentence punctuation that follows it. +var searchQueryURLPattern = regexp.MustCompile("(?i)[a-z][a-z0-9+.-]*://[^\\s<>\"'`)\\]}]+") + +// stripSearchQueryURLState removes the query string and fragment of every URL in a query, +// leaving the scheme, host and path in place. +// +// Those two components are the web application's own state, never a name in this repository, +// and both mint tokens that outrank the words the caller actually wrote: +// +// - `?file=/index.js` (the codesandbox/StackBlitz "open this file" parameter) tokenizes to +// the standalone path token `index.js`, which is identifier-shaped, so it earns the +// code-like weight 2.5 and then a +6.25 pathSearchScore on EVERY `index.js` in the tree. +// Measured on preactjs/preact: `hooks/src/index.js` took rank 1 with signal `path` and +// `karma.conf.js` rank 4, on the strength of a path that exists on codesandbox.io. +// - a playground permalink encodes the whole reproduction program in its fragment +// (`https://play.vuejs.org/#eNp9UsFOAjEQ...`). `#` is inside searchWordPattern's class and +// base64 uses `+` and `/`, so the fragment is one enormous token whose camel-hump and +// separator variants become dozens of terms — each mixed-case, so each reads as code-like +// at weight 1.1. Measured on vuejs/core: 40 of 48 terms were base64 debris, and because +// the term list is sorted by weight and truncated at maxSearchQueryTerms, that debris +// EVICTED every plain prose word of the issue (`array`, `item`, `object`, `converted`, +// `incorrectly`) from the query entirely. +// +// The path component is deliberately kept: a "the bug is here" link into the repository's own +// source (`https://github.com/o/r/blob/main/src/options.ts`) is the one part of a URL that does +// name a file, and dropping it would discard real evidence. A `#L42` line anchor or a `#issues` +// heading anchor is not a name in the tree, so removing the fragment costs nothing there. +func stripSearchQueryURLState(query string) string { + if !strings.Contains(query, "://") { + return query + } + return searchQueryURLPattern.ReplaceAllStringFunc(query, func(url string) string { + cut := strings.IndexAny(url, "?#") + if cut < 0 { + return url + } + // A space, not "": the stripped tail must still end the URL token so the word that + // follows it in the text cannot fuse onto the host/path. + return url[:cut] + " " + }) +} + var searchStopWords = map[string]bool{ "a": true, "an": true, "and": true, "are": true, "as": true, "at": true, "be": true, "but": true, "by": true, "can": true, "change": true, @@ -843,6 +968,13 @@ func SearchRepository(ctx context.Context, repo, providerVersion, query string, } sparseHydrationReads := hydrateSparseCandidates(selected, read) stats.SparseFilesRead += sparseHydrationReads + // A test file is never a fix site, and rank 1 is the slot an agent reads and edits from. When + // the flat -12 in searchPathPrior was not enough to sink one, lift the best editable hit over + // it rather than subtracting harder — subtraction can eject the test from the payload, and the + // VERIFY deriver reads that test. Runs before ranks are numbered so the byte fitter, the + // enclosure planner and the VERIFY deriver all see one consistent order. + // See search_testrank.go. + selected = promoteFixSiteOverLeadingTest(selected, q) results := make([]SearchResult, 0, len(selected)) for i := range selected { selected[i].result.Rank = i + 1 @@ -852,6 +984,11 @@ func SearchRepository(ctx context.Context, repo, providerVersion, query string, } results = append(results, selected[i].result) } + // A hit whose anchor landed in a doc comment is moved onto the code that comment documents + // BEFORE anything prices or plans it: the byte fitter then charges for the code it will print, + // the enclosure planner looks for a callable at the code line rather than in the prose above it, + // and the literal block mines program text. Ranking is untouched — see search_reanchor.go. + results, stats.DocReanchored = reanchorSearchDocComments(results, symbolsByFile, read, options.MaxSnippetLines) ranked := append([]SearchResult(nil), results...) results, resultBytes, dropped, _ := fitSearchResultsToBudget(results, q, options.MaxContextBytes) // The fitter decides HOW MANY results fit; the allocator decides how the bytes they are @@ -877,19 +1014,123 @@ func SearchRepository(ctx context.Context, repo, providerVersion, query string, // HeadWindowLines: a head rank with no enclosable callable falls back to a bounded read // window instead of a two-line locator. 0 disables it (previous behaviour exactly). headWindowLines := options.HeadWindowLines + // FullUnitTop: the first N ranks come back as their complete enclosing unit whatever the + // opportunistic conditions say. Resolved against the SEATED ranking, because the gap heuristic + // that admits rank 2 is a statement about the scores the caller will actually read. + fullUnitRanks := searchFullUnitForceRanks(results, options.FullUnitTop) + // BUDGET-DRIVEN RENDERING is enabled by the payload-shape levers, never by default: the shipped + // payload is what every prior measurement was taken against, and this changes how the whole ceiling + // is spent. --edit-site-bodies is excluded because it only touches the literal block, so its effect + // stays isolated and separately measurable. + budgetDriven := fullUnitRanks > 0 || options.CalleeHop enclosures := planSearchEnclosures( results, symbolsByID, symbolsByFile, read, defaultSearchEnclosureMaxLines, options.EnclosureContextLines, bodyHeadRanks, headWindowLines, + fullUnitRanks, budgetDriven, ) + // The UNFORCED plan for the same inputs. It is what the allocator computes its control allocation + // from, and that control is the floor a forced unit may not push anything below — see + // seatForcedSearchUnits for the regression that made this necessary. Planning it twice costs no IO: + // `read` is the shared content cache, so the second pass re-reads nothing. + var plainEnclosures []searchEnclosure + if fullUnitRanks > 0 { + plainEnclosures = planSearchEnclosures( + results, symbolsByID, symbolsByFile, read, + defaultSearchEnclosureMaxLines, options.EnclosureContextLines, bodyHeadRanks, headWindowLines, + 0, budgetDriven, + ) + } + tailSnippetLines := minInt(searchEnclosureTailSnippetLines, options.MaxSnippetLines) + seated := results results, completeSymbols, locators := allocateSearchSnippets( - results, enclosures, options.MaxContextBytes, searchEnclosureGrowthBytes, - bodyHeadRanks, minInt(searchEnclosureTailSnippetLines, options.MaxSnippetLines), + seated, enclosures, plainEnclosures, options.MaxContextBytes, searchEnclosureGrowthBytes, + bodyHeadRanks, tailSnippetLines, ) + // THE RE-ANCHOR FUNDING INVARIANT, the same one seatForcedSearchUnits enforces for forced units: a + // body a hit gained only because it was re-anchored may be paid for out of free budget, never out of + // another rank's existing allocation. Re-anchoring moves the ANCHOR; it is not a licence to + // re-decide who gets source. + // + // Measured on vuejs__core-11870: `arrayInstrumentations.ts:10→12` picked up a complete body and the + // allocator funded it by demoting `runtime-core/src/helpers/renderList.ts:54-107` — 54 lines of the + // production helper the issue is actually about — to a bare locator. Net: the payload traded a body + // it had for a body it did not need. + // + // So the re-anchored enclosures are planned, priced and then ACCEPTED ONLY IF the resulting + // allocation still shows every other rank everything the control allocation showed it. When it does + // not, control stands: the hit keeps its re-anchored `focus=`, which is the larger part of the win + // and costs nothing. + if control, exempt, gated := unreanchoredSearchEnclosures(seated, enclosures); gated { + plan, bodies, demoted := allocateSearchSnippets( + seated, control, plainEnclosures, options.MaxContextBytes, searchEnclosureGrowthBytes, + bodyHeadRanks, tailSnippetLines, + ) + if !searchAllocationPreservesSource(plan, results, exempt) { + results, completeSymbols, locators = plan, bodies, demoted + } else { + markSearchReanchorGainedBodies(results, plan, exempt) + } + } + // Rule 1 of budget-driven rendering, applied last of the ranking passes: spend whatever ceiling is + // still unclaimed on the ranks that are still locators, in rank order. Purely additive. + if budgetDriven { + var expanded int + results, expanded = spendRemainingSearchBudget(results, results, enclosures, options.MaxContextBytes) + completeSymbols += expanded + } stats.CompleteSymbols = completeSymbols stats.LocatorSnippets = locators // Truncation is measured against the ranking, by rank, so it has to be read off the // allocator's output BEFORE the related-site block renumbers the payload. truncated := countBudgetTruncatedResults(ranked, results) + // The callee hop runs HERE, and the position is load-bearing at both ends. After the truncation + // counter, so that counter still describes what the ALLOCATOR did (it is read by rank, and the + // hop only inserts new ranks, so an inserted entry is skipped rather than miscounted). Before the + // span merge, so a callee in the anchor's own file — the commonest and most useful case — can be + // folded into one contiguous span with its caller instead of arriving as a second excerpt of the + // same file with a hole between them. + if options.CalleeHop { + hopCache := newSearchRelatedFileCache(read, searchRelatedCandidateLimit) + sites := selectSearchCalleeHopSites( + results, q, snapshot.Relations, symbolsByID, symbolsByFile, hopCache, + searchCalleeHopRanks(results), searchCalleeHopSiteLimit, + ) + // Bodies follow the other levers rather than inventing a third policy; see + // searchCalleeHopResult. + withBody := fullUnitRanks > 0 || options.EditSiteBodies + entries := make([]SearchResult, 0, len(sites)) + anchorIndexes := make([]int, 0, len(sites)) + for _, site := range sites { + lines, ok := hopCache.get(site.symbol.FilePath) + if !ok { + continue + } + entry, ok := searchCalleeHopResult(site, lines, withBody) + if !ok { + continue + } + entries = append(entries, entry) + anchorIndexes = append(anchorIndexes, site.anchorIndex) + } + results, stats.CalleeHopSites = mergeSearchCalleeHopSites( + results, entries, anchorIndexes, options.MaxContextBytes, + minInt(searchEnclosureTailSnippetLines, options.MaxSnippetLines), + maxInt(fullUnitRanks, 1), + ) + } + // Two printed bodies of one file with a small hole between them are one region as far as the + // reader is concerned, and the hole is what it spends a turn closing: measured over 113 Opus + // payloads, 40% carry such a pair, and the tool makes +19.6% MORE Read calls than the no-tool + // baseline while total tool calls fall 14.5%. Folding them into one contiguous, explicitly + // unelided span runs here — after truncation has been measured against the ranking, so the + // counter still describes what the allocator did, and before sectioning, so it can only ever + // touch candidate fix sites. See search_span_merge.go for the bounds that keep it from + // evicting a hit to pay for a bridge. + if merged, spans, absorbed := mergeSameFileSearchSpans(results, read, options.MaxContextBytes); spans > 0 { + results = merged + stats.MergedSpans = spans + stats.CompleteSymbols = maxInt(0, stats.CompleteSymbols-absorbed) + } // Sectioning first: it decides which hit anchors the related-site block, because a // docs-and-fixtures hit at rank 1 is not a fix site and its neighbourhood is not the // neighbourhood of the change. @@ -941,7 +1182,9 @@ func SearchRepository(ctx context.Context, repo, providerVersion, query string, // makes anyway — a repo-wide grep, a fumbled test invocation, and the read that discovers a // switch it forgot to extend — which is the property the reference blocks above lack. They are // additive and separately capped; see search_blocks.go. - verifyEvidence := searchVerifyEvidence{read: read} + verifyEvidence := searchVerifyEvidence{ + read: read, prefix: options.VerifyPrefix, preFixStatus: options.VerifyPreFixStatus, + } // The three agent-asked blocks read files the RANKING never asked for: a bounded set of files // containing one literal, a handful of switch sites, and the build manifests above the top hit. // Their IO is bracketed here and reported under its own counter rather than folded into the query @@ -960,7 +1203,7 @@ func SearchRepository(ctx context.Context, repo, providerVersion, query string, stats.ClosedSetBytes = searchClosedSetCost(closedSet) } literalCluster := buildSearchLiteralCluster( - results, q, symbolsByFile, needleIndex, searchLiteralClusterMaxBytes, + results, q, symbolsByFile, needleIndex, searchLiteralClusterMaxBytes, options.EditSiteBodies, ) if literalCluster != nil { stats.LiteralClusterBytes = searchLiteralClusterCost(literalCluster) @@ -982,6 +1225,9 @@ func SearchRepository(ctx context.Context, repo, providerVersion, query string, verifyCommand := buildSearchVerifyCommand(results, verifyEvidence) if verifyCommand != nil { stats.VerifyCommandBytes = searchVerifyCommandCost(verifyCommand) + // The tier is reported so a harness can bucket sessions by which rung answered them; that is + // how "the VERIFY command is unrunnable or non-covering in 12/12 sessions" was measured. + stats.VerifyTier = verifyCommand.Tier } blockFilesRead := queryReads.files - blockFilesBefore blockBytesRead := queryReads.bytes - blockBytesBefore @@ -3678,6 +3924,9 @@ func regionsAroundHits(hits []int, lower, upper, context, maxLines int) [][2]int } func buildSearchQuery(query string) searchQuery { + // Strip before anything reads the text, so terms, words and rawLower are all derived from + // the same URL-state-free query. SearchResponse.Query still echoes the caller's original. + query = stripSearchQueryURLState(query) weights := map[string]float64{} add := func(term string, weight float64) { if len(term) < 2 || searchStopWords[term] { @@ -3874,6 +4123,10 @@ func (q searchQuery) withCorpusPresence(documentFrequency map[string]int) search } func buildSparseSearchQuery(query string) searchQuery { + // Same strip as buildSearchQuery: the sparse half of hybrid search must not be scored + // against a term set the dense half never sees, and its own cap + // (maxSparseSearchQueryTerms) is filled first-come, so URL debris starves it too. + query = stripSearchQueryURLState(query) terms := make([]string, 0, maxSparseSearchQueryTerms) termSet := make(map[string]bool, maxSparseSearchQueryTerms) weights := make(map[string]float64, maxSparseSearchQueryTerms) @@ -4146,9 +4399,7 @@ func searchPathPrior(q searchQuery, filePath string) float64 { // suite; a caller who does want tests writes "test"/"spec"/"fixture", and the phrase // "regression test" already contains "test". Keeping them here let the commonest word // in a bug report switch off the test demotion for the whole query. - if searchTestArtifactPath(lower) && !searchQuerySupplied(q, - "test", "tests", "testing", "spec", "specs", "fixture", "fixtures", - ) { + if searchTestArtifactPath(lower) && !searchQuerySupplied(q, searchTestIntentWords...) { // Strong demotion (was -1.5 — far too weak): a test file that exercises the buggy // function matches the issue's exact code tokens (function name + behaviour keywords), // so it out-scores the real source at ~26-47 while the implementation sits at ~13. diff --git a/internal/sem/search_blocks.go b/internal/sem/search_blocks.go index 3f05c81c..227c0ac5 100644 --- a/internal/sem/search_blocks.go +++ b/internal/sem/search_blocks.go @@ -190,7 +190,14 @@ func validateSearchContextBlockBudget(response SearchResponse) error { bytes int cap int }{ - {name: "literal cluster", bytes: stats.LiteralClusterBytes, cap: searchLiteralClusterMaxBytes}, + // The literal cluster's cap depends on what the block CARRIES, not on what the caller asked + // for: --edit-site-bodies raises it to searchLiteralEditBodyClusterMaxBytes, and the same + // derivation the fitter used is applied here so the two can never disagree. + { + name: "literal cluster", + bytes: stats.LiteralClusterBytes, + cap: searchLiteralClusterCap(response.LiteralCluster, searchLiteralClusterMaxBytes), + }, {name: "verify command", bytes: stats.VerifyCommandBytes, cap: searchVerifyCommandMaxBytes}, {name: "closed set", bytes: stats.ClosedSetBytes, cap: searchClosedSetMaxBytes}, } { diff --git a/internal/sem/search_cache.go b/internal/sem/search_cache.go index a29eea04..6cb836f2 100644 --- a/internal/sem/search_cache.go +++ b/internal/sem/search_cache.go @@ -60,6 +60,12 @@ type cachedSearchSnapshot struct { // them or a cached run would fall back to the signature-string split and // emit different type relations than a cold one. SymbolSignatureTypes map[string]cachedSignatureTypes `json:"symbol_signature_types,omitempty"` + // BodylessSymbolIDs travels for the same reason as LocalSymbolIDs: call + // resolution reads SymbolRecord.bodyless to tell a TypeScript overload set + // apart from two genuinely ambiguous same-name definitions, and the selective + // derivation reruns that resolution over cached symbols. Without it a cache + // hit would downgrade an overloaded call that a cold run resolves exactly. + BodylessSymbolIDs []string `json:"bodyless_symbol_ids,omitempty"` } type cachedSignatureTypes struct { @@ -412,6 +418,9 @@ func newCachedSearchSnapshot(providerVersion, commit, tree string, options Provi if symbol.Local { cache.LocalSymbolIDs = append(cache.LocalSymbolIDs, symbol.ID) } + if symbol.bodyless { + cache.BodylessSymbolIDs = append(cache.BodylessSymbolIDs, symbol.ID) + } if symbol.sourceEndByte > symbol.sourceStartByte { if cache.SymbolByteRanges == nil { cache.SymbolByteRanges = make(map[string]cachedSymbolByteRange) @@ -451,6 +460,10 @@ func restoreCachedSearchInternals(cache *cachedSearchSnapshot) { for _, id := range cache.LocalSymbolIDs { localIDs[id] = true } + bodylessIDs := make(map[string]bool, len(cache.BodylessSymbolIDs)) + for _, id := range cache.BodylessSymbolIDs { + bodylessIDs[id] = true + } parameterNamesKnownIDs := make(map[string]bool, len(cache.SymbolParameterNamesKnownIDs)) for _, id := range cache.SymbolParameterNamesKnownIDs { parameterNamesKnownIDs[id] = true @@ -458,6 +471,7 @@ func restoreCachedSearchInternals(cache *cachedSearchSnapshot) { for index := range cache.Snapshot.Symbols { symbol := &cache.Snapshot.Symbols[index] symbol.Local = localIDs[symbol.ID] + symbol.bodyless = bodylessIDs[symbol.ID] if sourceRange, ok := cache.SymbolByteRanges[symbol.ID]; ok && sourceRange.End > sourceRange.Start { symbol.sourceStartByte = sourceRange.Start symbol.sourceEndByte = sourceRange.End diff --git a/internal/sem/search_cache_test.go b/internal/sem/search_cache_test.go index ec02a3f2..4a491c83 100644 --- a/internal/sem/search_cache_test.go +++ b/internal/sem/search_cache_test.go @@ -1261,3 +1261,47 @@ func TestSearchSnapshotKeyIncludesGraphIgnore(t *testing.T) { t.Fatal("editing .graphignore must change the cache key") } } + +// SymbolRecord.bodyless is private, so it does not survive the cached snapshot's +// wire format — but the selective derivation RERUNS call resolution over those +// cached symbols, and resolution reads bodyless to tell a TypeScript overload +// set apart from genuine ambiguity. Without the sidecar the derived graph +// downgrades an overloaded call to name_only, which the fast profile then drops: +// the same query would answer "1 caller" cold and "no callers" warm. +func TestSelectiveFastSearchSnapshotPreservesCachedBodylessDeclarations(t *testing.T) { + repo := t.TempDir() + git(t, repo, "init") + git(t, repo, "config", "user.name", "Entire Graph Test") + git(t, repo, "config", "user.email", "graph@example.com") + write(t, repo, "src/renderList.ts", `export function renderList(source: number, fn: (i: number) => any): any[] +export function renderList(source: string, fn: (v: string) => any): any[] +export function renderList(source: any, fn: (...a: any[]) => any): any[] { + return [] +} +`) + write(t, repo, "src/caller.ts", `import { renderList } from './renderList' + +export function useList(items: string[]): any[] { + return renderList(items, (v) => v) +} +`) + git(t, repo, "add", ".") + git(t, repo, "commit", "-m", "initial") + cacheDir := t.TempDir() + if _, _, err := PreindexProviderSnapshot(t.Context(), repo, "test-version", ProviderSnapshotOptions{Profile: ProfileFast}, cacheDir); err != nil { + t.Fatal(err) + } + selective, cacheHit, err := loadOrBuildSearchGraphSnapshot(t.Context(), repo, "test-version", + ProviderSnapshotOptions{Profile: ProfileFast, OnlyFiles: []string{"src/renderList.ts", "src/caller.ts"}}, cacheDir, false) + if err != nil { + t.Fatal(err) + } + if !cacheHit { + t.Fatal("fast selective snapshot did not derive from complete cache") + } + calls := runCallsFrom(selective, "useList") + if len(calls) != 1 || calls[0].Resolution != "import_resolved" { + t.Fatalf("cached fast selective snapshot lost the overload collapse: %#v", + relationsOfType(selective.Relations, "CALLS")) + } +} diff --git a/internal/sem/search_callee.go b/internal/sem/search_callee.go new file mode 100644 index 00000000..a2e0a033 --- /dev/null +++ b/internal/sem/search_callee.go @@ -0,0 +1,509 @@ +package sem + +import ( + "sort" + "strings" +) + +// The callee hop (--callee-hop, off by default) +// ============================================= +// +// MEASURED DEFECT THIS EXISTS FOR. With the editability levers in search_enclosure.go on, the +// payload's remaining misses are not truncated units — they are the WRONG unit of the right file. +// Across eight R30PUB instances, 8 of 12 code-gold files were ranked and 0 had the gold hunk +// verbatim; in every ranked case the printed unit and the gold unit were different callables in the +// same file. The commonest shape is that the ranked hit is the thin public entry point and the gold +// is the helper it CALLS: +// +// redis__redis-10095 ranked src/t_list.c:577-581 lpopCommand (3-line wrapper) +// gold src/t_list.c:501 inside popGenericCommand (489-535) +// edge lpopCommand --CALLS--> popGenericCommand +// +// One outgoing CALLS edge away, in the same file, already in the graph. +// +// WHY search_related.go DOES NOT REACH IT. That block excludes outgoing CALLS deliberately +// (search_related.go:41-43) on the stated grounds that such cases "are already reached as co-members +// of the same unit". They are not, and the hole is visible in the code: searchRelatedUnitCoMembers +// requires member.ContainerID == anchor.ContainerID and returns NOTHING AT ALL once the unit holds +// more than searchRelatedUnitMemberLimit (12) members. `src/t_list.c` has ~40 top-level functions, so +// the co-member route switches off entirely and the callee is unreachable by any route in the block. +// The exclusion's reasoning holds for the block it was written about — a fourth kind would cost a +// slot in every rotation of a four-slot block — so this is a SEPARATE, gated route rather than a +// change to that rotation, and the related-sites block is left exactly as it is. +// +// WHY THE GATE IS NARROW. It fires for the top-ranked hit only (plus rank 2 when the ranking did not +// separate the two, the same searchFullUnitGapRatio test --full-unit-top uses), and it fires +// REGARDLESS of the anchor's unit size — because the measured miss is in a large unit, which is +// precisely where the co-member limit above turns off. A hop from deeper in the ranking would be a +// second ranking rather than a hop, and the ranking has already priced those files. +const ( + // searchCalleeHopSiteLimit is how many callees one search may admit, across all anchors. Three is + // the widest fan-out that still names a specific place: an entry point that delegates to one + // helper, plus the two-stage pipeline shape (parse then apply). Past that the answer is "read the + // file", and `entire-graph impact` is the right command for a full callee list. + searchCalleeHopSiteLimit = 3 + + // searchCalleeHopSignal marks a result admitted by this route. It is reported so a payload is + // attributable: every measurement of this lever has to be able to say which entries it added. + searchCalleeHopSignal = "callee-hop" + + // searchCalleeHopCandidateLimit bounds how many outgoing edges are inspected per anchor, and with + // them how many files may be hydrated to score overlap. A dispatcher with 200 calls is not a hop. + searchCalleeHopCandidateLimit = 24 + + // searchCalleeHopWindowLines is the window a callee body falls back to when its unit is past + // searchFullUnitMaxLines: a bounded read window around the declaration, which still shows the + // signature and the first statements. + searchCalleeHopWindowLines = 60 +) + +// CalleeHopSignal is searchCalleeHopSignal exported for renderers and for the benchmark harness, +// which has to be able to count the entries this route added. +const CalleeHopSignal = searchCalleeHopSignal + +// searchCalleeHopSite is one callee admitted as a candidate fix site. +type searchCalleeHopSite struct { + // anchorIndex is the ranked result whose enclosing callable makes the call. The entry is + // inserted directly after it, because "the helper this does its work in" is only useful next to + // the thing that delegates to it. + anchorIndex int + symbol SymbolRecord + // callLine is where the anchor makes the call, from the relation's own evidence. It is reported + // so the reader can see WHY this unit is here; it is not where the entry points (that is the + // callee's declaration, which is what has to be edited). + callLine int + // sameFile is priority (a): a callee declared beside its caller is the likeliest second site, and + // it is also free to print — the file is already hydrated. + sameFile bool + // overlap is priority (b): how much of the QUERY the callee's name and body spell out. A hop is + // only worth bytes if the helper is plausibly what the issue is about. + overlap float64 + // nameHits is how many query words the callee's NAME spells out. It is kept separately from + // overlap because it is an ADMISSION test for a cross-file callee, not just an ordering signal: + // see searchCalleeHopAdmissible. + nameHits int + confidence float64 +} + +// searchCalleeHopAdmissible is the last gate, and it is asymmetric on purpose. +// +// A SAME-FILE callee is admitted unconditionally: it is structurally adjacent to the unit the agent is +// already reading, its file is already hydrated so it is free to print, and it is the shape the +// measured miss actually has (redis__redis-10095: lpopCommand -> popGenericCommand, 88 lines apart in +// src/t_list.c). +// +// A CROSS-FILE callee must have the query in its NAME. Without that test the slots go to the +// language's plumbing — the first cross-file hop this route produced on redis__redis-10095 was +// `zcalloc`, an allocator whose name shares no word with an issue about LPOP returning the wrong null +// reply. Body-mention alone is not enough: every helper in a file about replies mentions replies. +func searchCalleeHopAdmissible(site searchCalleeHopSite) bool { + return site.sameFile || site.nameHits > 0 +} + +// searchCalleeHopRanks is how deep the hop reaches: rank 1 always, rank 2 only when the ranking did +// not separate it from rank 1. It is the same gate --full-unit-top uses, deliberately — a payload +// that shows two units because the scores tied should hop from both, and one that shows a clear +// winner should hop only from the winner. +func searchCalleeHopRanks(results []SearchResult) int { + return searchFullUnitForceRanks(results, 2) +} + +// selectSearchCalleeHopSites resolves the callees of the head's enclosing callables. +// +// Admission is deliberately strict, because an unresolved or out-of-repo callee is a slot spent on +// something the agent cannot edit: +// +// - the edge is an outgoing CALLS/ASYNC_CALLS (searchRelatedCallRelation, shared with the related +// block so "X's code runs Y" means one thing in this package); +// - the target RESOLVES to a symbol this snapshot holds, which is what "same-repo, resolved" is: +// an unresolved call has no body to print and no file to edit; +// - the target is an enclosable callable, not a container; +// - its file passes searchRelatedSiteEditable — the RANKER's own file-class and test-artifact +// priors, reused rather than reinvented, so this route cannot recommend a file the ranking has +// already ruled out as a fix site; +// - the payload does not already print it (a hop onto a unit already on screen buys nothing). +func selectSearchCalleeHopSites( + results []SearchResult, + q searchQuery, + relations []RelationRecord, + symbolsByID map[string]SymbolRecord, + symbolsByFile map[string][]SymbolRecord, + cache *searchRelatedFileCache, + hopRanks, limit int, +) []searchCalleeHopSite { + if hopRanks <= 0 || limit <= 0 || len(results) == 0 { + return nil + } + anchors := searchCalleeHopAnchors(results, symbolsByID, symbolsByFile, hopRanks) + if len(anchors) == 0 { + return nil + } + byAnchor := make(map[string]int, len(anchors)) + for index, anchor := range anchors { + byAnchor[anchor.symbol.ID] = index + } + seen := map[string]bool{} + inspected := make([]int, len(anchors)) + var sites []searchCalleeHopSite + for _, relation := range relations { + if !searchRelatedCallRelation(relation.Type) || relation.ToID == "" { + continue + } + position, isAnchor := byAnchor[relation.FromID] + if !isAnchor { + continue + } + if inspected[position] >= searchCalleeHopCandidateLimit { + continue + } + inspected[position]++ + callee, resolved := symbolsByID[relation.ToID] + if !resolved || callee.ID == "" || seen[callee.ID] { + continue + } + anchor := anchors[position] + if callee.ID == anchor.symbol.ID || !searchEnclosableSymbolKind(callee.Kind) { + continue + } + if !searchRelatedSiteEditable(q, callee.FilePath) { + continue + } + if searchCalleeAlreadyPrinted(results, callee) { + continue + } + seen[callee.ID] = true + nameHits, overlap := searchCalleeQueryOverlap(q, callee, cache) + site := searchCalleeHopSite{ + anchorIndex: anchor.resultIndex, + symbol: callee, + callLine: searchCalleeCallLine(relation, anchor.symbol), + sameFile: callee.FilePath == anchor.symbol.FilePath, + overlap: overlap, + nameHits: nameHits, + confidence: relation.Confidence, + } + if !searchCalleeHopAdmissible(site) { + continue + } + sites = append(sites, site) + } + sort.SliceStable(sites, func(left, right int) bool { + return lessSearchCalleeHopSite(sites[left], sites[right]) + }) + if len(sites) > limit { + sites = sites[:limit] + } + return sites +} + +// searchCalleeHopAnchor pairs a head result with the callable that makes the calls, keeping the +// result's index so the entry can be inserted next to it. +type searchCalleeHopAnchor struct { + resultIndex int + symbol SymbolRecord +} + +// searchCalleeHopAnchors walks only the FIRST hopRanks results, unlike searchRelatedAnchors which +// walks the whole primary list until it has collected its quota. The difference is the point: the gap +// test decided that ranks 1..hopRanks are the ambiguous head, and letting the walk slide down to rank +// 5 to fill a quota would hop from a rank the gate had already excluded. +func searchCalleeHopAnchors( + results []SearchResult, + symbolsByID map[string]SymbolRecord, + symbolsByFile map[string][]SymbolRecord, + hopRanks int, +) []searchCalleeHopAnchor { + anchors := make([]searchCalleeHopAnchor, 0, hopRanks) + seen := make(map[string]bool, hopRanks) + for index := 0; index < minInt(hopRanks, len(results)); index++ { + if results[index].Section != searchSectionPrimary { + continue + } + symbol, ok := enclosingCallableForResult(results[index], symbolsByID, symbolsByFile) + if !ok || symbol.ID == "" || seen[symbol.ID] { + continue + } + seen[symbol.ID] = true + anchors = append(anchors, searchCalleeHopAnchor{resultIndex: index, symbol: symbol}) + } + return anchors +} + +// lessSearchCalleeHopSite is the priority order the slots are spent in: same file, then query +// overlap, then edge confidence, then the anchor's rank, the call's own position and the symbol ID so +// the result is deterministic. +// +// QUALITY OUTRANKS THE ANCHOR. Sorting by anchor first would let rank 2's plumbing take a slot ahead +// of rank 1's same-file helper whenever rank 1 happens to make its calls in a different order; the +// three slots belong to the three best second sites, not one per anchor. +func lessSearchCalleeHopSite(left, right searchCalleeHopSite) bool { + if left.sameFile != right.sameFile { + return left.sameFile + } + if left.overlap != right.overlap { + return left.overlap > right.overlap + } + if left.confidence != right.confidence { + return left.confidence > right.confidence + } + if left.anchorIndex != right.anchorIndex { + return left.anchorIndex < right.anchorIndex + } + if left.callLine != right.callLine { + return left.callLine < right.callLine + } + return left.symbol.ID < right.symbol.ID +} + +// searchCalleeCallLine reads the call site out of the relation's own evidence, falling back to the +// anchor's declaration. It is reported, never used for navigation: the line to EDIT is in the callee. +func searchCalleeCallLine(relation RelationRecord, anchor SymbolRecord) int { + for _, evidence := range relation.Evidence { + if evidence.FilePath == anchor.FilePath && evidence.StartLine > 0 { + return evidence.StartLine + } + } + return anchor.StartLine +} + +// searchCalleeQueryOverlap scores how much of the query the callee spells out, as the share of the +// caller's own WORDS that appear in the callee's name or body. Words, not terms: `terms` also holds +// camelCase fragments and morphological variants mined out of identifiers, which are evidence for +// ranking but too loose to decide which of three helpers the issue is about. +// +// The name is worth double the body. A helper NAMED after the concept is the concept's implementation; +// one that merely mentions it in a comment or a call is not. The name-hit COUNT is returned alongside +// the score because it is also the admission test for a cross-file callee. +func searchCalleeQueryOverlap( + q searchQuery, callee SymbolRecord, cache *searchRelatedFileCache, +) (int, float64) { + words := q.words + if words == nil { + words = searchQueryWords(q.rawLower) + } + if len(words) == 0 { + return 0, 0 + } + name := strings.ToLower(callee.Name + " " + callee.QualifiedName) + body := "" + // Body text is free only when the file is already hydrated. A hop must not spend the hydration + // allowance on scoring: the callees worth printing are overwhelmingly same-file, and that file is + // already in the cache because the ranking printed a hit from it. + if cache != nil && cache.cached(callee.FilePath) { + if lines, ok := cache.get(callee.FilePath); ok { + body = strings.ToLower(symbolBlockFromLines(lines, callee)) + } + } + nameHits, score := 0, 0.0 + for word := range words { + // Two letters match everything. The floor is the same idea as searchLiteralMinLength: below it + // a token is not a concept name. + if len(word) < 3 { + continue + } + switch { + case strings.Contains(name, word): + nameHits++ + score += 2 + case body != "" && strings.Contains(body, word): + score += 1 + } + } + return nameHits, score / float64(2*len(words)) +} + +// searchCalleeAlreadyPrinted reports whether the payload already shows this unit — same symbol, or a +// snippet that already covers its declaration. Reuses the same test the related block applies for the +// same reason: a slot spent re-listing something on screen is a slot spent on nothing. +func searchCalleeAlreadyPrinted(results []SearchResult, callee SymbolRecord) bool { + for _, result := range results { + if result.SymbolID != "" && result.SymbolID == callee.ID { + return true + } + if result.FilePath != callee.FilePath { + continue + } + if result.SnippetStartLine <= callee.StartLine && result.SnippetEndLine >= callee.StartLine { + return true + } + } + return false +} + +// searchCalleeHopResult renders one admitted callee as a candidate fix site. +// +// `withBody` follows the other editability levers rather than inventing a third policy: when the +// caller has asked for source (--full-unit-top or --edit-site-bodies) the entry carries the callee's +// unit, and otherwise it is a declaration locator. --callee-hop on its own therefore only ever adds +// the pointer, which is the cheap half of the lever and can be measured separately from the bytes. +func searchCalleeHopResult(site searchCalleeHopSite, lines []string, withBody bool) (SearchResult, bool) { + start, end := clampRegion(site.symbol.StartLine, site.symbol.EndLine, len(lines)) + if start == 0 { + return SearchResult{}, false + } + result := SearchResult{ + FilePath: site.symbol.FilePath, StartLine: start, EndLine: end, FocusLine: start, + Kind: site.symbol.Kind, SymbolID: site.symbol.ID, SymbolName: site.symbol.Name, + QualifiedName: site.symbol.QualifiedName, Signature: site.symbol.Signature, + Section: searchSectionPrimary, + Signals: []string{searchCalleeHopSignal}, + } + if !withBody { + result.SnippetStartLine, result.SnippetEndLine = start, start + result.Snippet = lines[start-1] + return result, true + } + printedStart, printedEnd := start, end + if end-start+1 > searchFullUnitMaxLines { + printedStart, printedEnd = clipSearchUnitToCap(start, end, start, searchCalleeHopWindowLines) + result.UnitStartLine, result.UnitEndLine = start, end + result.Signals = appendUnique(result.Signals, searchFullUnitSignal, searchFullUnitElidedSignal) + } else { + result.Signals = appendUnique(result.Signals, searchFullUnitSignal, searchCompleteSymbolSignal) + } + result.SnippetStartLine, result.SnippetEndLine = printedStart, printedEnd + result.Snippet = strings.Join(lines[printedStart-1:printedEnd], "\n") + result.FocusLine = minInt(maxInt(printedStart, result.FocusLine), printedEnd) + return result, true +} + +// mergeSearchCalleeHopSites seats the callee entries inside the ranking and reports how many it +// seated. +// +// FUNDING ORDER, and it is the contract: +// +// 1. the head the editability levers already seated is NEVER touched. A callee body is worth less +// than the unit the agent is actually looking at, and this route exists to add a second site, not +// to trade away the first. +// 2. a hop-inserted site has the LOWEST funding priority in the payload. It may spend only budget no +// ranked hit claimed, and it may never reduce what a ranked hit renders — not to a locator, not by +// a line. Measured cost of getting this wrong: with --edit-site-bodies --callee-hop the hop +// entries were seated as full bodies and EVICTED ranked bodies, so redis__redis-11734's rank-4 +// `bitposCommand` (a 127-line gold body in the control) had zero mentions in the payload, and the +// losing cohort's gold coverage fell 31.1% -> 24.4%. Only ranks that render as bare locators +// anyway may give up their invisible snippet bytes. +// 3. if the ceiling cannot hold a hop BODY, the body is clipped to at least +// searchBudgetWindowMinLines, and failing that the site is emitted as a LOCATOR. Never the other +// way around: a hop is a pointer first and source second. --max-context-bytes is exact at every +// step — the returned plan is only ever one that measured under it. +func mergeSearchCalleeHopSites( + results []SearchResult, + entries []SearchResult, + anchorIndexes []int, + hardBudget, tailLines, protectRanks int, +) ([]SearchResult, int) { + if len(results) == 0 || len(entries) == 0 || len(entries) != len(anchorIndexes) { + return results, 0 + } + if protectRanks < 1 { + protectRanks = 1 + } + floor := minInt(protectRanks, len(results)) + // Degradation ladder, widest first: full bodies, then bodies clipped to the smallest window worth + // printing, then locators. A hop always prefers to shrink ITSELF before it asks the payload for + // anything, which is what makes it the lowest-priority claim on the budget. + for _, forms := range [][]SearchResult{entries, clipSearchCalleeHopEntries(entries), locatorSearchCalleeHopEntries(entries)} { + for count := len(forms); count >= 1; count-- { + for demoteFrom := len(results); demoteFrom >= floor; demoteFrom-- { + plan := planSearchCalleeHopRanking(results, forms[:count], anchorIndexes[:count], demoteFrom, tailLines) + if hardBudget <= 0 || serializedSearchResultBytes(plan) <= hardBudget { + return plan, count + } + } + } + } + return results, 0 +} + +// clipSearchCalleeHopEntries narrows every hop body to searchBudgetWindowMinLines around its focus, +// keeping the entry a verbatim slice and reporting the elision. An entry already at or below that width +// is returned unchanged. +func clipSearchCalleeHopEntries(entries []SearchResult) []SearchResult { + out := make([]SearchResult, len(entries)) + for index, entry := range entries { + out[index] = entry + span := entry.SnippetEndLine - entry.SnippetStartLine + 1 + if span <= searchBudgetWindowMinLines || entry.Snippet == "" { + continue + } + lines := strings.Split(entry.Snippet, "\n") + if len(lines) != span { + continue + } + start, end := clipSearchUnitToCap( + entry.SnippetStartLine, entry.SnippetEndLine, entry.FocusLine, searchBudgetWindowMinLines, + ) + offset := start - entry.SnippetStartLine + clipped := entry + if clipped.UnitStartLine == 0 { + clipped.UnitStartLine, clipped.UnitEndLine = entry.SnippetStartLine, entry.SnippetEndLine + } + clipped.Snippet = strings.Join(lines[offset:offset+(end-start+1)], "\n") + clipped.SnippetStartLine, clipped.SnippetEndLine = start, end + clipped.StartLine = minInt(clipped.StartLine, start) + clipped.EndLine = maxInt(clipped.EndLine, end) + clipped.FocusLine = minInt(maxInt(clipped.FocusLine, start), end) + clipped.Signals = appendUnique( + removeSearchSignal(clipped.Signals, searchCompleteSymbolSignal), searchFullUnitElidedSignal, + ) + out[index] = clipped + } + return out +} + +// locatorSearchCalleeHopEntries reduces every hop entry to its declaration line. The site is still +// worth listing — it is a grep the agent does not have to run — so the last rung of the ladder keeps +// the pointer and drops only the source. +func locatorSearchCalleeHopEntries(entries []SearchResult) []SearchResult { + out := make([]SearchResult, len(entries)) + for index, entry := range entries { + out[index] = entry + if entry.Snippet == "" { + continue + } + span := entry.SnippetEndLine - entry.SnippetStartLine + 1 + lines := strings.Split(entry.Snippet, "\n") + if len(lines) != span { + continue + } + locator := entry + locator.Snippet = lines[0] + locator.SnippetEndLine = locator.SnippetStartLine + locator.FocusLine = locator.SnippetStartLine + locator.UnitStartLine, locator.UnitEndLine = 0, 0 + locator.Signals = removeSearchSignal( + removeSearchSignal(removeSearchSignal(locator.Signals, searchCompleteSymbolSignal), + searchFullUnitSignal), searchFullUnitElidedSignal, + ) + out[index] = locator + } + return out +} + +// planSearchCalleeHopRanking builds one seating plan: the ranking with `demoteFrom` onwards tersified, +// each entry inserted directly after its anchor, renumbered 1..N. +func planSearchCalleeHopRanking( + results, entries []SearchResult, + anchorIndexes []int, + demoteFrom, tailLines int, +) []SearchResult { + after := make(map[int][]SearchResult, len(entries)) + for position, entry := range entries { + index := anchorIndexes[position] + after[index] = append(after[index], entry) + } + plan := make([]SearchResult, 0, len(results)+len(entries)) + for index, result := range results { + // Only a rank the renderer prints as a bare locator anyway may give up its snippet bytes. A hop + // is the lowest-priority claim in the payload and must never reduce what a ranked hit renders. + if index >= demoteFrom && !searchResultRendersSource(results, index) { + result = tersifySearchResult(result, tailLines) + } + plan = append(plan, result) + plan = append(plan, after[index]...) + } + for index := range plan { + plan[index].Rank = index + 1 + } + return plan +} diff --git a/internal/sem/search_callee_test.go b/internal/sem/search_callee_test.go new file mode 100644 index 00000000..7a15e059 --- /dev/null +++ b/internal/sem/search_callee_test.go @@ -0,0 +1,419 @@ +package sem + +import ( + "strconv" + "strings" + "testing" +) + +// THE CALLEE HOP +// ============== +// +// These pin the route that closes the miss --full-unit-top could not: the ranked hit is a thin entry +// point and the gold is the helper it CALLS, one outgoing edge away. The fixture is the measured shape +// verbatim — redis__redis-10095's `lpopCommand` delegating to `popGenericCommand`, in a file with far +// more than searchRelatedUnitMemberLimit top-level functions so the related block's co-member route +// (its stated substitute for outgoing CALLS) is switched off exactly as it is in the real repo. + +// calleeHopFixture builds a file of `members` top-level functions where member 1 is a 3-line wrapper +// that calls member 2, plus the ranked payload that shows only the wrapper. +func calleeHopFixture(members int) ( + []SearchResult, map[string]SymbolRecord, map[string][]SymbolRecord, []RelationRecord, contentReader, +) { + var source strings.Builder + symbols := make([]SymbolRecord, 0, members) + line := 1 + for member := 1; member <= members; member++ { + name := "helper" + strconv.Itoa(member) + length := 8 + switch member { + case 1: + name = "lpopCommand" + length = 3 + case 2: + name = "popGenericCommand" + length = 20 + } + start := line + source.WriteString("void " + name + "(client *c) {\n") + line++ + for filler := 1; filler < length-1; filler++ { + if member == 1 { + source.WriteString(" popGenericCommand(c, LIST_HEAD);\n") + } else if member == 2 && filler == 6 { + source.WriteString(" reply = shared.null[c->resp]; /* the line an edit replaces */\n") + } else { + source.WriteString(" body_of_" + name + "();\n") + } + line++ + } + source.WriteString("}\n\n") + line += 2 + symbols = append(symbols, SymbolRecord{ + RecordType: "symbol", ID: "sym:" + name, Kind: "function", Name: name, + QualifiedName: name, FilePath: "src/t_list.c", StartLine: start, EndLine: start + length - 1, + }) + } + byID := map[string]SymbolRecord{} + for _, symbol := range symbols { + byID[symbol.ID] = symbol + } + byFile := map[string][]SymbolRecord{"src/t_list.c": symbols} + relations := []RelationRecord{{ + RecordType: "relation", FromID: "sym:lpopCommand", ToID: "sym:popGenericCommand", + Type: "CALLS", Confidence: 0.9, + Evidence: []Evidence{{Kind: "call", FilePath: "src/t_list.c", StartLine: 2}}, + }} + wrapper := symbols[0] + results := []SearchResult{{ + Rank: 1, Score: 27.0447, FilePath: "src/t_list.c", + StartLine: wrapper.StartLine, EndLine: wrapper.EndLine, FocusLine: wrapper.StartLine, + SnippetStartLine: wrapper.StartLine, SnippetEndLine: wrapper.EndLine, + SymbolID: wrapper.ID, SymbolName: wrapper.Name, Kind: "function", Signals: []string{"body"}, + }} + content := source.String() + read := func(path string) (string, bool) { + if path != "src/t_list.c" { + return "", false + } + return content, true + } + return results, byID, byFile, relations, read +} + +func calleeHopSites(t *testing.T, members int, query string) ([]searchCalleeHopSite, *searchRelatedFileCache) { + t.Helper() + results, byID, byFile, relations, read := calleeHopFixture(members) + cache := newSearchRelatedFileCache(read, searchRelatedCandidateLimit) + sites := selectSearchCalleeHopSites( + results, buildSearchQuery(query), relations, byID, byFile, cache, + searchCalleeHopRanks(results), searchCalleeHopSiteLimit, + ) + return sites, cache +} + +// TestSelectSearchCalleeHopSitesReachesTheHelperTheRelatedBlockCannot is the measured case. The unit +// holds 40 top-level functions, so searchRelatedUnitCoMembers returns NOTHING (its limit is 12) and no +// route in the related block can produce this site — which is precisely the hole the exclusion comment +// at search_related.go:41-43 assumes is covered. +func TestSelectSearchCalleeHopSitesReachesTheHelperTheRelatedBlockCannot(t *testing.T) { + t.Parallel() + members := searchRelatedUnitMemberLimit * 3 + sites, _ := calleeHopSites(t, members, "LPOP key count returns null bulk reply instead of null array reply") + if len(sites) != 1 { + t.Fatalf("sites = %d, want exactly the one callee", len(sites)) + } + if sites[0].symbol.Name != "popGenericCommand" { + t.Fatalf("site = %s, want popGenericCommand", sites[0].symbol.Name) + } + if !sites[0].sameFile { + t.Fatal("a callee in the anchor's own file was not marked sameFile") + } + if sites[0].callLine != 2 { + t.Fatalf("callLine = %d, want 2 (the relation's own evidence)", sites[0].callLine) + } + if sites[0].anchorIndex != 0 { + t.Fatalf("anchorIndex = %d, want 0", sites[0].anchorIndex) + } + + // The premise: the related block really cannot reach it at this unit size. + _, byID, byFile, _, _ := calleeHopFixture(members) + if members := searchRelatedUnitCoMembers(byID["sym:lpopCommand"], byFile); len(members) != 0 { + t.Fatalf("co-member route returned %d members — the hole this route exists for is gone, "+ + "so the exclusion in search_related.go may now be sound", len(members)) + } +} + +// TestSelectSearchCalleeHopSitesRefusesWhatItCannotJustify pins every admission gate. +func TestSelectSearchCalleeHopSitesRefusesWhatItCannotJustify(t *testing.T) { + t.Parallel() + base := func() ([]SearchResult, map[string]SymbolRecord, map[string][]SymbolRecord, []RelationRecord, contentReader) { + return calleeHopFixture(searchRelatedUnitMemberLimit * 3) + } + for _, testCase := range []struct { + name string + mutate func(*[]SearchResult, map[string]SymbolRecord, map[string][]SymbolRecord, *[]RelationRecord) + query string + wantAny bool + }{ + { + name: "the measured case is admitted", + query: "lpop null reply", + wantAny: true, + }, + { + // An unresolved call has no body to print and no file to edit. + name: "an unresolved target is refused", + mutate: func(_ *[]SearchResult, byID map[string]SymbolRecord, _ map[string][]SymbolRecord, _ *[]RelationRecord) { + delete(byID, "sym:popGenericCommand") + }, + query: "lpop null reply", + }, + { + // Only "X's code runs Y" can force a change in Y's own body. + name: "a non-call relation is refused", + mutate: func(_ *[]SearchResult, _ map[string]SymbolRecord, _ map[string][]SymbolRecord, relations *[]RelationRecord) { + (*relations)[0].Type = "REFERENCES" + }, + query: "lpop null reply", + }, + { + // Returning a whole container spends the slot on the members that are not the fix. + name: "a container target is refused", + mutate: func(_ *[]SearchResult, byID map[string]SymbolRecord, _ map[string][]SymbolRecord, _ *[]RelationRecord) { + symbol := byID["sym:popGenericCommand"] + symbol.Kind = "class" + byID[symbol.ID] = symbol + }, + query: "lpop null reply", + }, + { + // A slot spent re-listing something already on screen is a slot spent on nothing. + name: "a callee the payload already prints is refused", + mutate: func(results *[]SearchResult, byID map[string]SymbolRecord, _ map[string][]SymbolRecord, _ *[]RelationRecord) { + callee := byID["sym:popGenericCommand"] + (*results)[0].SnippetStartLine = callee.StartLine + (*results)[0].SnippetEndLine = callee.EndLine + }, + query: "lpop null reply", + }, + { + // searchRelatedSiteEditable — the RANKER's own priors, reused. A patch does not land in a + // vendored file. + name: "a callee in a file the ranker rules out is refused", + mutate: func(_ *[]SearchResult, byID map[string]SymbolRecord, byFile map[string][]SymbolRecord, _ *[]RelationRecord) { + callee := byID["sym:popGenericCommand"] + callee.FilePath = "vendor/thirdparty/pop.c" + byID[callee.ID] = callee + byFile[callee.FilePath] = []SymbolRecord{callee} + }, + query: "lpop null reply", + }, + } { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + results, byID, byFile, relations, read := base() + if testCase.mutate != nil { + testCase.mutate(&results, byID, byFile, &relations) + } + sites := selectSearchCalleeHopSites( + results, buildSearchQuery(testCase.query), relations, byID, byFile, + newSearchRelatedFileCache(read, searchRelatedCandidateLimit), + searchCalleeHopRanks(results), searchCalleeHopSiteLimit, + ) + if (len(sites) > 0) != testCase.wantAny { + t.Fatalf("sites = %d, want any = %v", len(sites), testCase.wantAny) + } + }) + } +} + +// TestSearchCalleeHopAdmissibleIsAsymmetricAcrossFiles pins the cross-file name test. Without it the +// slots go to the language's plumbing: the first cross-file hop this route produced on +// redis__redis-10095 was `zcalloc`, an allocator with no word in common with an issue about LPOP. +func TestSearchCalleeHopAdmissibleIsAsymmetricAcrossFiles(t *testing.T) { + t.Parallel() + for _, testCase := range []struct { + name string + site searchCalleeHopSite + want bool + }{ + {name: "same file needs no name evidence", site: searchCalleeHopSite{sameFile: true}, want: true}, + {name: "cross file with the query in its name", site: searchCalleeHopSite{nameHits: 1}, want: true}, + {name: "cross file plumbing is refused", site: searchCalleeHopSite{overlap: 0.4}, want: false}, + } { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + if got := searchCalleeHopAdmissible(testCase.site); got != testCase.want { + t.Fatalf("admissible = %v, want %v", got, testCase.want) + } + }) + } + // And the scorer has to separate a concept-named helper from plumbing on real names. + q := buildSearchQuery("LPOP count returns null bulk reply instead of null array reply") + concept := SymbolRecord{Name: "addReplyNullArray", QualifiedName: "addReplyNullArray", FilePath: "src/networking.c"} + alloc := SymbolRecord{Name: "zcalloc", QualifiedName: "zcalloc", FilePath: "src/zmalloc.c"} + conceptHits, conceptScore := searchCalleeQueryOverlap(q, concept, nil) + allocHits, allocScore := searchCalleeQueryOverlap(q, alloc, nil) + if conceptHits == 0 { + t.Fatalf("addReplyNullArray scored 0 name hits against %q", q.rawLower) + } + if allocHits != 0 { + t.Fatalf("zcalloc scored %d name hits, want 0", allocHits) + } + if conceptScore <= allocScore { + t.Fatalf("overlap: concept=%f alloc=%f, want the concept ahead", conceptScore, allocScore) + } + // AND the measured case shows why the same-file rule must be UNCONDITIONAL rather than a + // same-file bonus on top of the name test: `popGenericCommand` shares no word with an issue + // phrased "LPOP ... null array reply" (`lpop` is not a substring of `popgenericcommand`), so a + // name-gated route would have refused the one site that carries the gold. + pop := SymbolRecord{Name: "popGenericCommand", QualifiedName: "popGenericCommand", FilePath: "src/t_list.c"} + if hits, _ := searchCalleeQueryOverlap(q, pop, nil); hits != 0 { + t.Fatalf("popGenericCommand now scores %d name hits; the same-file rationale needs rewriting", hits) + } + if !searchCalleeHopAdmissible(searchCalleeHopSite{symbol: pop, sameFile: true}) { + t.Fatal("the measured gold-carrying site is not admissible") + } +} + +// TestSearchCalleeHopRanksFollowsTheSameGapGate pins that the hop reaches rank 2 exactly when +// --full-unit-top would: a payload showing two units because the scores tied should hop from both, one +// showing a clear winner should hop only from the winner. +func TestSearchCalleeHopRanksFollowsTheSameGapGate(t *testing.T) { + t.Parallel() + tied := []SearchResult{{Rank: 1, Score: 100}, {Rank: 2, Score: 95}, {Rank: 3, Score: 90}} + if got := searchCalleeHopRanks(tied); got != 2 { + t.Fatalf("tied head ranks = %d, want 2 (never deeper than rank 2)", got) + } + clear := []SearchResult{{Rank: 1, Score: 100}, {Rank: 2, Score: 70}} + if got := searchCalleeHopRanks(clear); got != 1 { + t.Fatalf("separated head ranks = %d, want 1", got) + } + if got := searchCalleeHopRanks(nil); got != 0 { + t.Fatalf("empty ranking = %d, want 0", got) + } +} + +// TestSearchCalleeHopAnchorsDoNotSlideDownTheRanking pins the difference from searchRelatedAnchors: +// that function walks the whole primary list to fill its quota, which would hop from a rank the gap +// gate had already excluded. +func TestSearchCalleeHopAnchorsDoNotSlideDownTheRanking(t *testing.T) { + t.Parallel() + _, byID, byFile, _, _ := calleeHopFixture(4) + wrapper, helper := byID["sym:lpopCommand"], byID["sym:helper3"] + results := []SearchResult{ + // Rank 1 carries no resolvable callable at all. + {Rank: 1, FilePath: "README.md", StartLine: 1, EndLine: 2, FocusLine: 1}, + {Rank: 2, FilePath: wrapper.FilePath, StartLine: wrapper.StartLine, EndLine: wrapper.EndLine, + FocusLine: wrapper.StartLine, SymbolID: wrapper.ID}, + {Rank: 3, FilePath: helper.FilePath, StartLine: helper.StartLine, EndLine: helper.EndLine, + FocusLine: helper.StartLine, SymbolID: helper.ID}, + } + anchors := searchCalleeHopAnchors(results, byID, byFile, 2) + if len(anchors) != 1 || anchors[0].symbol.ID != wrapper.ID || anchors[0].resultIndex != 1 { + t.Fatalf("anchors = %+v, want only rank 2's callable — the walk must not reach rank 3", anchors) + } +} + +// TestSearchCalleeHopResultFollowsTheBodyFlags pins that this route invents no third body policy. +func TestSearchCalleeHopResultFollowsTheBodyFlags(t *testing.T) { + t.Parallel() + sites, cache := calleeHopSites(t, searchRelatedUnitMemberLimit*3, + "LPOP count returns null bulk reply instead of null array reply") + if len(sites) != 1 { + t.Fatalf("sites = %d, want 1", len(sites)) + } + lines, ok := cache.get("src/t_list.c") + if !ok { + t.Fatal("fixture file unreadable") + } + + locator, ok := searchCalleeHopResult(sites[0], lines, false) + if !ok { + t.Fatal("no locator entry") + } + if locator.SnippetStartLine != locator.SnippetEndLine { + t.Fatalf("locator span = %d-%d, want one line", locator.SnippetStartLine, locator.SnippetEndLine) + } + if !hasSearchSignal(locator, searchCalleeHopSignal) { + t.Fatalf("signals = %v, want %s", locator.Signals, searchCalleeHopSignal) + } + for _, forbidden := range []string{searchCompleteSymbolSignal, searchFullUnitSignal} { + if hasSearchSignal(locator, forbidden) { + t.Fatalf("a locator claimed %s: %v", forbidden, locator.Signals) + } + } + + body, ok := searchCalleeHopResult(sites[0], lines, true) + if !ok { + t.Fatal("no body entry") + } + callee := sites[0].symbol + if body.SnippetStartLine != callee.StartLine || body.SnippetEndLine != callee.EndLine { + t.Fatalf("body span = %d-%d, want the whole unit %d-%d", + body.SnippetStartLine, body.SnippetEndLine, callee.StartLine, callee.EndLine) + } + // The whole point: the line an edit replaces is now in the payload verbatim. + if !strings.Contains(body.Snippet, "the line an edit replaces") { + t.Fatalf("body does not carry the gold line:\n%s", body.Snippet) + } + for _, want := range []string{searchCalleeHopSignal, searchFullUnitSignal, searchCompleteSymbolSignal} { + if !hasSearchSignal(body, want) { + t.Fatalf("signals = %v, want %s", body.Signals, want) + } + } + if body.Section != searchSectionPrimary { + t.Fatalf("section = %q, want primary: a callee hop IS a candidate fix site", body.Section) + } + if lineCount := len(strings.Split(body.Snippet, "\n")); lineCount != body.SnippetEndLine-body.SnippetStartLine+1 { + t.Fatalf("snippet is %d lines for span %d-%d — it must stay a verbatim slice", + lineCount, body.SnippetStartLine, body.SnippetEndLine) + } +} + +// TestMergeSearchCalleeHopSitesHonoursTheFundingOrder pins the contract: the protected head is never +// touched, the tail pays by SHRINKING rather than disappearing, and --max-context-bytes is exact. +func TestMergeSearchCalleeHopSitesHonoursTheFundingOrder(t *testing.T) { + t.Parallel() + lines, symbol := editabilityFile(600, 10, 300, "function") + results := make([]SearchResult, 0, 5) + for rank := 1; rank <= 5; rank++ { + start := 20 + rank*40 + results = append(results, SearchResult{ + Rank: rank, FilePath: "pkg/file.go", StartLine: start, EndLine: start + 9, + FocusLine: start + 2, SnippetStartLine: start, SnippetEndLine: start + 9, + Snippet: strings.Join(lines[start-1:start+9], "\n"), Signals: []string{}, + }) + } + entry := widenSearchResultToEnclosure(SearchResult{ + FilePath: "pkg/file.go", StartLine: 10, EndLine: 300, FocusLine: 10, + SnippetStartLine: 10, SnippetEndLine: 300, Signals: []string{searchCalleeHopSignal}, + }, searchEnclosure{start: 10, end: 300, lines: lines, symbol: symbol, forced: true}) + + // The budget is the EXACT size of the plan that keeps the head whole and tersifies everything + // below it, so the merge can only succeed by making that trade and no looser one. + budget := serializedSearchResultBytes( + planSearchCalleeHopRanking(results, []SearchResult{entry}, []int{0}, 1, 2), + ) + + plan, seated := mergeSearchCalleeHopSites( + results, []SearchResult{entry}, []int{0}, budget, 2, 1, + ) + if seated != 1 { + t.Fatalf("seated = %d, want 1", seated) + } + // The tail paid, by shrinking rather than by disappearing. + if len(plan[len(plan)-1].Snippet) >= len(results[len(results)-1].Snippet) { + t.Fatal("the deepest rank did not yield, so the body was funded out of thin air") + } + if serializedSearchResultBytes(plan) > budget { + t.Fatalf("plan is %d bytes over a %d-byte ceiling — --max-context-bytes must be exact", + serializedSearchResultBytes(plan), budget) + } + // Inserted directly after its anchor, and the anchor itself is untouched. + if plan[1].SnippetStartLine != 10 || plan[1].SnippetEndLine != 300 { + t.Fatalf("entry landed at %d-%d in position 2", plan[1].SnippetStartLine, plan[1].SnippetEndLine) + } + if plan[0].Snippet != results[0].Snippet { + t.Fatal("the protected head was shrunk to pay for a callee body") + } + // Nothing was DROPPED: every ranked file keeps a mention, and ranks are contiguous. + if len(plan) != len(results)+1 { + t.Fatalf("plan has %d entries, want %d — a hop must never drop a ranked result", + len(plan), len(results)+1) + } + for index := range plan { + if plan[index].Rank != index+1 { + t.Fatalf("rank %d at index %d", plan[index].Rank, index) + } + } + + // A ceiling that cannot hold the body even with the whole tail tersified seats nothing and + // returns the ranking untouched. + tight := serializedSearchResultBytes(results[0]) * 2 + untouched, none := mergeSearchCalleeHopSites(results, []SearchResult{entry}, []int{0}, tight, 2, 1) + if none != 0 || len(untouched) != len(results) { + t.Fatalf("seated = %d with %d entries under a %d-byte ceiling", none, len(untouched), tight) + } +} diff --git a/internal/sem/search_confidence.go b/internal/sem/search_confidence.go index 0d21b06e..56d0bf95 100644 --- a/internal/sem/search_confidence.go +++ b/internal/sem/search_confidence.go @@ -1,5 +1,11 @@ package sem +import ( + "fmt" + "math" + "strings" +) + // Telling "nothing matched" from "here is the answer" // ================================================== // @@ -64,6 +70,72 @@ const ( // lowConfidenceWindow is how many head results the dispersion test looks at. lowConfidenceWindow = 3 + + // lowConfidenceTieGap is the ABSOLUTE top1-top2 score gap below which the ranking has not actually + // CHOSEN. Measured on projectlombok__lombok-3486, which shipped a clean-looking payload at a gap of + // 0.0100 and cost $2.22 while the agent worked out by hand which of two same-named methods it meant. + // + // ABSOLUTE, not relative, and the difference is the whole calibration. Read as a 5% relative gap + // this fired on 56 of 56 calibration queries whose target provably exists — a marker that always + // fires teaches the reader to ignore it, which is strictly worse than not having one. Real payloads + // separate their top two by 4-5% routinely; a gap of 0.05 raw score points is a near-exact tie, which + // is the thing lombok actually exhibited. + lowConfidenceTieGap = 0.05 + + // lowConfidenceWrapperLines is how many comment or literal lines a top hit needs before its CONTENT + // is called out as "not an implementation". Deliberately conservative: a false LOW CONFIDENCE on a + // real hit teaches the reader to ignore the marker, which is worse than not emitting it. + lowConfidenceWrapperLines = 2 +) + +// searchConfidenceWrapperReason inspects the top hit's own snippet for the three shapes that look like +// an answer and are not one: a doc-comment block, an example-usage list, a deprecated forwarder. Each +// is a hit an agent reads, believes, and then has to back out of. The test is on text the payload +// already carries, so it needs no extra IO. +func searchConfidenceWrapperReason(result SearchResult) string { + snippet := strings.TrimSpace(result.Snippet) + if snippet == "" { + return "" + } + commentPrefixes := []string{"//", "*", "/*", "#", "'''", `"""`} + code, comment, literal := 0, 0, 0 + for _, line := range strings.Split(snippet, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + isComment := false + for _, prefix := range commentPrefixes { + if strings.HasPrefix(trimmed, prefix) { + isComment = true + break + } + } + switch { + case isComment: + comment++ + case strings.HasPrefix(trimmed, "[") || strings.HasPrefix(trimmed, "{") || + strings.HasPrefix(trimmed, "'") || strings.HasPrefix(trimmed, string(rune(34))): + literal++ + default: + code++ + } + } + if comment > code && comment >= lowConfidenceWrapperLines { + return "top hit is a documentation comment, not an implementation" + } + if literal > code && literal >= lowConfidenceWrapperLines { + return "top hit is an example-usage list, not an implementation" + } + lowered := strings.ToLower(snippet) + if (strings.Contains(lowered, "@deprecated") || strings.Contains(lowered, "#[deprecated")) && code <= 4 { + return "top hit is a deprecated forwarder, not the implementation" + } + return "" +} + +const ( + lowConfidenceUnusedGuard = 0 ) // SearchConfidence summarises how much a payload's own numbers support treating its top @@ -110,6 +182,30 @@ func AssessSearchConfidence(response SearchResponse) SearchConfidence { assessment.Low = true assessment.Reason = "top results agree on nothing" } + // The two MECHANICAL tests below run regardless of the score ladder above, because both describe a + // payload that scores WELL and is still not an answer. A high top score with a tied runner-up is a + // coin flip presented as a choice; a high top score on a doc comment is a hit the reader has to + // back out of. Neither is visible to a score threshold. + if !assessment.Low && len(results) > 1 && results[0].Score > 0 { + // MAGNITUDE, not the signed difference. Rank 2 may legitimately carry the HIGHER score: + // promoteFixSiteOverLeadingTest reorders without rescoring, so a payload whose rank-1 test + // was displaced reports rank 2 above rank 1 on purpose. A signed subtraction reads that as + // a negative gap, which is below any threshold, so the marker fired on exactly the payloads + // where the ranking made a deliberate choice — the false positive this file's own + // calibration says is worse than having no marker at all. What the test is asking is + // whether the two scores are indistinguishable, and that question has no direction. + if gap := math.Abs(results[0].Score - results[1].Score); gap < lowConfidenceTieGap { + assessment.Low = true + assessment.Reason = fmt.Sprintf( + "ranks 1 and 2 are tied (%.4f apart) - the ranking did not choose", gap) + } + } + if !assessment.Low { + if reason := searchConfidenceWrapperReason(results[0]); reason != "" { + assessment.Low = true + assessment.Reason = reason + } + } return assessment } diff --git a/internal/sem/search_confidence_test.go b/internal/sem/search_confidence_test.go index b1c0b529..5fa31f60 100644 --- a/internal/sem/search_confidence_test.go +++ b/internal/sem/search_confidence_test.go @@ -213,3 +213,29 @@ func TestLowConfidenceCalibrationHolds(t *testing.T) { got, len(good)+len(diffuse)+len(absent)) } } + +// A promoted fix site legitimately carries a LOWER score than the test it displaced, because +// promoteFixSiteOverLeadingTest reorders without rescoring. The tie test asks whether the top two +// scores are indistinguishable, and that question has no direction — reading it as a signed +// subtraction made the marker fire on every reordered payload, which is the false positive this +// file's own calibration explicitly rejects ("a marker that always fires teaches the reader to +// ignore it"). +func TestAssessSearchConfidenceIgnoresTheDirectionOfTheTopTwoGap(t *testing.T) { + t.Parallel() + // The measured sharkdp__bat-2260 payload: rank 1 is the promoted source at 73.3157, rank 2 the + // displaced test at 77.3391 — 4.02 points apart, which is nobody's idea of a tie. + promoted := confidenceResponse( + []float64{73.3157, 77.3391, 40.0}, + []string{"src/a.go", "tests/a_test.go", "src/a.go"}, "") + if assessment := AssessSearchConfidence(promoted); assessment.Low { + t.Fatalf("a 4.02-point separation was reported as low confidence: %q", assessment.Reason) + } + // A genuine near-tie still fires, in either direction. + for _, scores := range [][]float64{{40.0, 40.01, 20.0}, {40.01, 40.0, 20.0}} { + assessment := AssessSearchConfidence( + confidenceResponse(scores, []string{"src/a.go", "src/a.go", "src/a.go"}, "")) + if !assessment.Low { + t.Fatalf("a 0.01-point tie at %v was not reported", scores) + } + } +} diff --git a/internal/sem/search_editability_test.go b/internal/sem/search_editability_test.go new file mode 100644 index 00000000..8a6656d9 --- /dev/null +++ b/internal/sem/search_editability_test.go @@ -0,0 +1,884 @@ +package sem + +import ( + "strconv" + "strings" + "testing" +) + +// EDITABILITY TESTS +// ================= +// +// These pin the two payload levers that exist to raise EDITABILITY — the share of needed edits whose +// replaced text is present VERBATIM in the payload, as opposed to RECALL, which only asks whether the +// right file was ranked. The two diverge sharply: on the R30PUB benchmark payloads the gold file is +// ranked for 8 of 12 gold files while the gold LINES are printed for none of them, because the +// payload prints a six-line window (or, below the text renderer's second rank, no source at all). +// +// --full-unit-top N (L1) the first N ranks come back as their whole enclosing unit +// --edit-site-bodies (L2) the literal block's EDIT sites come back with source +// +// Both are off by default and every test below that asserts new behaviour turns one of them on, which +// is what keeps the default payload byte-for-byte unchanged. + +// editabilityFile builds a file of `lineCount` numbered lines plus a symbol record over [start,end]. +func editabilityFile(lineCount, start, end int, kind string) ([]string, SymbolRecord) { + lines := make([]string, lineCount) + for index := range lines { + lines[index] = "line " + strconv.Itoa(index+1) + " of the file" + } + return lines, SymbolRecord{ + RecordType: "symbol", ID: "unit-1", Kind: kind, Name: "Target", QualifiedName: "pkg.Target", + FilePath: "pkg/file.go", StartLine: start, EndLine: end, + } +} + +func editabilityReader(lines []string) contentReader { + content := strings.Join(lines, "\n") + return func(path string) (string, bool) { + if path != "pkg/file.go" { + return "", false + } + return content, true + } +} + +// TestSearchFullUnitForceRanksGatesRankTwoOnTheScoreGap pins the flag's arithmetic. Rank 1 is +// unconditional; every rank past it is admitted only while the ranking has NOT separated it from +// rank 1, so a search with one clear answer pays for one body and not for N. +func TestSearchFullUnitForceRanksGatesRankTwoOnTheScoreGap(t *testing.T) { + t.Parallel() + scored := func(scores ...float64) []SearchResult { + results := make([]SearchResult, 0, len(scores)) + for index, score := range scores { + results = append(results, SearchResult{Rank: index + 1, Score: score}) + } + return results + } + for _, testCase := range []struct { + name string + results []SearchResult + top int + want int + }{ + {name: "off by default", results: scored(10, 10), top: 0, want: 0}, + {name: "negative is off", results: scored(10, 10), top: -3, want: 0}, + {name: "rank one is unconditional even when rank two is far behind", + results: scored(100, 1), top: 1, want: 1}, + {name: "rank two joins when the ranking did not separate them", + results: scored(100, 95), top: 2, want: 2}, + {name: "rank two is refused when rank one is clearly ahead", + results: scored(100, 80), top: 2, want: 1}, + {name: "the gap is measured against rank one, so admission stops at the first break", + // 0.042, 0.070 are inside the 15% band; 0.153 is outside, so the run stops at 3. + results: scored(35.58, 34.09, 33.09, 30.14, 29.66), top: 5, want: 3}, + {name: "a request deeper than the ranking is clamped to it", + results: scored(10, 10), top: 9, want: 2}, + {name: "an empty ranking forces nothing", results: nil, top: 2, want: 0}, + {name: "a zero top score carries no separation information, so the request stands", + results: scored(0, 0), top: 2, want: 2}, + } { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + if got := searchFullUnitForceRanks(testCase.results, testCase.top); got != testCase.want { + t.Fatalf("searchFullUnitForceRanks = %d, want %d", got, testCase.want) + } + }) + } +} + +// TestPlanForcedSearchUnitBypassesTheOpportunisticConditions is the core L1 test: each case is a +// condition that makes the DEFAULT allocator return a six-line window, and the forced planner has to +// return source anyway. Each case therefore also asserts what the default path does, so the test +// fails if the two ever stop differing (which would mean the flag has become a no-op). +func TestPlanForcedSearchUnitBypassesTheOpportunisticConditions(t *testing.T) { + t.Parallel() + for _, testCase := range []struct { + name string + lineCount int + unitStart int + unitEnd int + kind string + result SearchResult + wantStart int + wantEnd int + wantElidedOf [2]int + }{ + { + // A CONTAINER. searchEnclosableSymbolKind excludes it on purpose, so the default path has + // no body to offer — and a hit inside a class body, a constant table or a type declaration + // is exactly where the measured editability misses are. + // Sized inside searchForcedContainerMaxLines; past that bound a container is refused, which + // TestPlanForcedSearchUnitRefusesALargeContainer covers. + name: "a container kind the default path refuses", + lineCount: 200, unitStart: 40, unitEnd: 95, kind: "class", + result: SearchResult{FilePath: "pkg/file.go", StartLine: 60, EndLine: 65, FocusLine: 62, + SnippetStartLine: 60, SnippetEndLine: 65, SymbolID: "unit-1"}, + wantStart: 40, wantEnd: 95, + }, + { + // PAST THE 160-LINE CAP. The default path degrades to a window here, which is the case + // where reading the file back costs the most. + name: "a callable past defaultSearchEnclosureMaxLines", + lineCount: 400, unitStart: 20, unitEnd: 20 + defaultSearchEnclosureMaxLines, kind: "function", + result: SearchResult{FilePath: "pkg/file.go", StartLine: 100, EndLine: 105, FocusLine: 102, + SnippetStartLine: 100, SnippetEndLine: 105, SymbolID: "unit-1"}, + wantStart: 20, wantEnd: 20 + defaultSearchEnclosureMaxLines, + }, + { + // ALREADY COMPLETE. The default path short-circuits ("nothing to gain") and the result + // ships a whole function that says nowhere that it is whole — redis__redis-10095 rank 1. + // The forced planner still marks it, because the claim is the thing the caller asked for. + name: "a unit the ranked snippet already covers", + lineCount: 100, unitStart: 30, unitEnd: 34, kind: "function", + result: SearchResult{FilePath: "pkg/file.go", StartLine: 30, EndLine: 34, FocusLine: 31, + SnippetStartLine: 30, SnippetEndLine: 34, SymbolID: "unit-1"}, + wantStart: 30, wantEnd: 34, + }, + { + // THE UNION. A ranked region routinely starts above the symbol's declaration (doc comment, + // decorator, attribute) and that preamble is often the most useful line on the screen. A + // forced unit must never print LESS than the ranking already did. + name: "the ranked snippet reaches above the unit and is kept", + lineCount: 100, unitStart: 30, unitEnd: 34, kind: "function", + result: SearchResult{FilePath: "pkg/file.go", StartLine: 28, EndLine: 34, FocusLine: 31, + SnippetStartLine: 28, SnippetEndLine: 34, SymbolID: "unit-1"}, + wantStart: 28, wantEnd: 34, + }, + { + // PAST THE SAFETY CAP. Clipped, anchored at the unit's own start, and the elision recorded + // so a reader is told rather than left to discover it. + name: "a unit past searchFullUnitMaxLines is clipped and reports it", + lineCount: 1200, unitStart: 100, unitEnd: 900, kind: "function", + result: SearchResult{FilePath: "pkg/file.go", StartLine: 150, EndLine: 155, FocusLine: 152, + SnippetStartLine: 150, SnippetEndLine: 155, SymbolID: "unit-1"}, + // Focus 152 is within 60 lines of the unit start, so the centred window clamps there. + wantStart: 100, wantEnd: 100 + 2*searchClippedUnitFocusLines, + wantElidedOf: [2]int{100, 900}, + }, + { + // The clip slides only when the hit would otherwise fall outside the anchored window, and + // then it is centred on the hit and clamped to the unit. + name: "a clip slides to keep the hit inside it", + lineCount: 1200, unitStart: 100, unitEnd: 900, kind: "function", + result: SearchResult{FilePath: "pkg/file.go", StartLine: 800, EndLine: 805, FocusLine: 802, + SnippetStartLine: 800, SnippetEndLine: 805, SymbolID: "unit-1"}, + // A clipped unit is CENTRED on the focus (carbon-2752): 802 +/- 60. + wantStart: 742, wantEnd: 862, + wantElidedOf: [2]int{100, 900}, + }, + } { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + lines, symbol := editabilityFile(testCase.lineCount, testCase.unitStart, testCase.unitEnd, testCase.kind) + byID := map[string]SymbolRecord{symbol.ID: symbol} + byFile := map[string][]SymbolRecord{symbol.FilePath: {symbol}} + reader := editabilityReader(lines) + results := []SearchResult{testCase.result} + + forced := planSearchEnclosures(results, byID, byFile, reader, 0, 0, 0, 0, 1, false) + if !forced[0].available() || !forced[0].forced { + t.Fatalf("forced enclosure = %+v, want an available forced unit", forced[0]) + } + if forced[0].start != testCase.wantStart || forced[0].end != testCase.wantEnd { + t.Fatalf("forced span = %d-%d, want %d-%d", + forced[0].start, forced[0].end, testCase.wantStart, testCase.wantEnd) + } + if got := [2]int{forced[0].unitStart, forced[0].unitEnd}; got != testCase.wantElidedOf { + t.Fatalf("recorded unit span = %v, want %v", got, testCase.wantElidedOf) + } + if forced[0].end-forced[0].start+1 > searchFullUnitMaxLines { + t.Fatalf("forced span %d-%d exceeds the safety cap of %d lines", + forced[0].start, forced[0].end, searchFullUnitMaxLines) + } + + // The flag is only a lever if the default path does something DIFFERENT here. Without it + // the payload either keeps the ranker's window or offers no body at all. + plain := planSearchEnclosures(results, byID, byFile, reader, 0, 0, 0, 0, 0, false) + if plain[0].available() && + plain[0].start == testCase.wantStart && plain[0].end == testCase.wantEnd { + t.Fatalf("the default path already returns %d-%d, so --full-unit-top is a no-op here", + plain[0].start, plain[0].end) + } + }) + } +} + +// TestPlanForcedSearchUnitFallsBackToAWindowWithoutASymbol pins the last resort. A forced rank whose +// file the graph holds no symbol for still must not come back as six lines, and the window has to be +// marked forced so the allocator seats it with the rest of the prefix — the bug this pins cost +// fmtlib__fmt-2457 its whole rank-3 window. +func TestPlanForcedSearchUnitFallsBackToAWindowWithoutASymbol(t *testing.T) { + t.Parallel() + lines, _ := editabilityFile(300, 0, 0, "") + result := SearchResult{FilePath: "pkg/file.go", StartLine: 100, EndLine: 105, FocusLine: 102, + SnippetStartLine: 100, SnippetEndLine: 105} + + got := planSearchEnclosures([]SearchResult{result}, nil, nil, editabilityReader(lines), 0, 0, 0, 0, 1, false) + if !got[0].available() { + t.Fatal("a forced rank with no symbol must still get a read window") + } + if !got[0].window { + t.Fatalf("enclosure = %+v, want a window (it is not a whole unit and must not claim to be)", got[0]) + } + if !got[0].forced { + t.Fatal("the fallback window must be marked forced, or the allocator never seats it") + } + if span := got[0].end - got[0].start + 1; span < searchHeadWindowLines { + t.Fatalf("window span = %d lines, want at least %d", span, searchHeadWindowLines) + } + // And it says head-window, never complete-symbol: a window cannot make the no-follow-up promise. + widened := widenSearchResultToEnclosure(result, got[0]) + if !hasSearchSignal(widened, searchHeadWindowSignal) { + t.Fatalf("signals = %v, want %s", widened.Signals, searchHeadWindowSignal) + } + for _, forbidden := range []string{searchCompleteSymbolSignal, searchFullUnitSignal} { + if hasSearchSignal(widened, forbidden) { + t.Fatalf("a window claimed %s: %v", forbidden, widened.Signals) + } + } +} + +// TestWidenSearchResultToEnclosureReportsWhatAForcedUnitActuallyIs pins the signals and the reported +// span, because those are the only things a reader has to tell a whole unit from a clipped one. +func TestWidenSearchResultToEnclosureReportsWhatAForcedUnitActuallyIs(t *testing.T) { + t.Parallel() + lines, symbol := editabilityFile(200, 40, 90, "class") + result := SearchResult{FilePath: "pkg/file.go", StartLine: 60, EndLine: 65, FocusLine: 62, + SnippetStartLine: 60, SnippetEndLine: 65, SymbolID: "other", Kind: "region"} + + whole := widenSearchResultToEnclosure(result, searchEnclosure{ + start: 40, end: 90, lines: lines, symbol: symbol, forced: true, + }) + for _, want := range []string{searchFullUnitSignal, searchCompleteSymbolSignal} { + if !hasSearchSignal(whole, want) { + t.Fatalf("whole unit signals = %v, want %s", whole.Signals, want) + } + } + if hasSearchSignal(whole, searchFullUnitElidedSignal) { + t.Fatalf("an unclipped unit reported an elision: %v", whole.Signals) + } + if whole.UnitStartLine != 0 || whole.UnitEndLine != 0 { + t.Fatalf("unit span reported on an unclipped unit: %d-%d", whole.UnitStartLine, whole.UnitEndLine) + } + // A whole body must NAME the body it shows: reporting the ranked region's identity above a + // different unit's source is wrong in the one field an agent uses to decide what it is reading. + if whole.SymbolID != symbol.ID || whole.Kind != symbol.Kind || whole.QualifiedName != symbol.QualifiedName { + t.Fatalf("identity = %s/%s/%s, want the unit's own", whole.SymbolID, whole.Kind, whole.QualifiedName) + } + if lineCount := len(strings.Split(whole.Snippet, "\n")); lineCount != whole.SnippetEndLine-whole.SnippetStartLine+1 { + t.Fatalf("snippet is %d lines for span %d-%d — the snippet must stay a verbatim slice", + lineCount, whole.SnippetStartLine, whole.SnippetEndLine) + } + + // A clip that still CONTAINS the focus is a full-unit answer minus the elided tail: it keeps + // full-unit, reports the elision, and withholds complete-symbol. + inside := result + inside.FocusLine = 50 + clipped := widenSearchResultToEnclosure(inside, searchEnclosure{ + start: 40, end: 60, lines: lines, symbol: symbol, forced: true, unitStart: 40, unitEnd: 90, + }) + for _, want := range []string{searchFullUnitSignal, searchFullUnitElidedSignal} { + if !hasSearchSignal(clipped, want) { + t.Fatalf("clipped unit signals = %v, want %s", clipped.Signals, want) + } + } + if hasSearchSignal(clipped, searchCompleteSymbolSignal) { + t.Fatalf("a clipped unit claimed complete-symbol: %v", clipped.Signals) + } + if clipped.UnitStartLine != 40 || clipped.UnitEndLine != 90 { + t.Fatalf("clipped unit span = %d-%d, want 40-90", clipped.UnitStartLine, clipped.UnitEndLine) + } +} + +// TestWidenSearchResultToEnclosureNeverTagsABodyMissingItsFocus is the carbon-2752 regression. +// +// The payload ranked Comparison.php #1 tagged `full-unit,complete-symbol` while the printed body +// ELIDED lines 630-1125 — and the focus line, the region the edit had to land in, was 989. The tag was +// simply false, which is worse than a small body: an agent that trusts it edits the wrong place, and +// one that does not trust it re-reads the file, so the bytes bought nothing either way. +func TestWidenSearchResultToEnclosureNeverTagsABodyMissingItsFocus(t *testing.T) { + t.Parallel() + // The carbon shape: a 1,200-line unit, the hit deep inside it at 989. Kind is callable, because a + // container that large is refused outright by searchForcedContainerMaxLines — the clip path this + // pins is the one a genuinely huge FUNCTION takes. + lines, symbol := editabilityFile(1400, 100, 1300, "method") + result := SearchResult{ + FilePath: symbol.FilePath, StartLine: 986, EndLine: 992, FocusLine: 989, + SnippetStartLine: 986, SnippetEndLine: 992, SymbolID: symbol.ID, + } + + // 1. The PLANNER must not hand back a window anchored at the unit start. + forced, ok := planForcedSearchUnit(result, map[string]SymbolRecord{symbol.ID: symbol}, + map[string][]SymbolRecord{symbol.FilePath: {symbol}}, lines) + if !ok { + t.Fatal("no forced unit planned for the carbon shape") + } + if forced.start > 989 || forced.end < 989 { + t.Fatalf("clipped span = %d-%d, does NOT contain the focus line 989 — this is the carbon bug", + forced.start, forced.end) + } + if span := forced.end - forced.start + 1; span > 2*searchClippedUnitFocusLines+1 { + t.Fatalf("clipped span = %d lines, want the focus window", span) + } + + // 2. The clip is honest about what it dropped, and does NOT claim a complete body. + widened := widenSearchResultToEnclosure(result, forced) + if !hasSearchSignal(widened, searchFullUnitElidedSignal) { + t.Fatalf("a clipped unit did not report its elision: %v", widened.Signals) + } + if hasSearchSignal(widened, searchCompleteSymbolSignal) { + t.Fatalf("a clipped unit claimed complete-symbol: %v", widened.Signals) + } + if widened.SnippetStartLine > 989 || widened.SnippetEndLine < 989 { + t.Fatalf("printed span = %d-%d excludes the focus", widened.SnippetStartLine, widened.SnippetEndLine) + } + if note := SearchUnitElisionNote(widened.SnippetStartLine, widened.SnippetEndLine, + widened.UnitStartLine, widened.UnitEndLine); note == "" { + t.Fatal("no elision note for a clipped unit") + } + + // 3. THE INVARIANT ITSELF: hand the widener a span that excludes the focus and it must refuse both + // complete-body signals, whatever the enclosure claims about itself. + lying := widenSearchResultToEnclosure(result, searchEnclosure{ + start: 100, end: 629, lines: lines, symbol: symbol, forced: true, unitStart: 100, unitEnd: 1300, + }) + for _, forbidden := range []string{searchCompleteSymbolSignal, searchFullUnitSignal} { + if hasSearchSignal(lying, forbidden) { + t.Fatalf("a body that excludes its focus claimed %s: %v", forbidden, lying.Signals) + } + } + if !hasSearchSignal(lying, searchHeadWindowSignal) { + t.Fatalf("signals = %v, want the honest %s", lying.Signals, searchHeadWindowSignal) + } +} + +// TestSearchUnitElisionNoteDescribesOnlyWhatIsMissing pins the note. It is rendered from the printed +// span against the unit's span, so it can never claim an elision that did not happen. +func TestSearchUnitElisionNoteDescribesOnlyWhatIsMissing(t *testing.T) { + t.Parallel() + for _, testCase := range []struct { + name string + printedStart, printedEnd, unitStart, unitEnd int + want string + }{ + {name: "nothing recorded", printedStart: 10, printedEnd: 20, want: ""}, + {name: "printed span is the whole unit", + printedStart: 10, printedEnd: 20, unitStart: 10, unitEnd: 20, want: ""}, + {name: "the tail was clipped", + printedStart: 10, printedEnd: 20, unitStart: 10, unitEnd: 40, + want: "…elided lines 21–40 (unit continues)"}, + {name: "the head was clipped", + printedStart: 30, printedEnd: 40, unitStart: 10, unitEnd: 40, + want: "…elided lines 10–29 (unit continues)"}, + {name: "both ends were clipped", + printedStart: 30, printedEnd: 40, unitStart: 10, unitEnd: 60, + want: "…elided lines 10–29, 41–60 (unit continues)"}, + {name: "an unusable printed span says nothing", + printedStart: 0, printedEnd: 0, unitStart: 10, unitEnd: 60, want: ""}, + } { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + got := SearchUnitElisionNote( + testCase.printedStart, testCase.printedEnd, testCase.unitStart, testCase.unitEnd, + ) + if got != testCase.want { + t.Fatalf("note = %q, want %q", got, testCase.want) + } + }) + } +} + +// forcedAllocationFixture is five ranked results in one file plus a forced unit for rank 1, sized so +// the unit is much larger than the window the ranker produced. +func forcedAllocationFixture() ([]SearchResult, []searchEnclosure) { + lines, symbol := editabilityFile(600, 10, 300, "function") + results := make([]SearchResult, 0, 5) + for rank := 1; rank <= 5; rank++ { + start := 20 + rank*40 + results = append(results, SearchResult{ + Rank: rank, FilePath: "pkg/file.go", StartLine: start, EndLine: start + 9, + FocusLine: start + 2, SnippetStartLine: start, SnippetEndLine: start + 9, + Snippet: strings.Join(lines[start-1:start+9], "\n"), Signals: []string{}, + }) + } + enclosures := make([]searchEnclosure, len(results)) + enclosures[0] = searchEnclosure{start: 10, end: 300, lines: lines, symbol: symbol, forced: true} + return results, enclosures +} + +// TestSeatForcedSearchUnitsPaysOnlyFromDeadWeight pins the funding sources: free budget under the +// ceiling, plus the snippet bytes of tail ranks the text renderer does not print anyway. +// +// It also pins the reason the flag was needed at all. The opportunistic allocator only evaluates cuts +// down to minInt(headRanks, len(results)), so with --top-k 5 and headRanks 5 the ONLY cut it can +// consider is "demote nothing" — the entire body budget is the growth allowance. +func TestSeatForcedSearchUnitsPaysOnlyFromDeadWeight(t *testing.T) { + t.Parallel() + results, enclosures := forcedAllocationFixture() + // The ceiling is the EXACT size of the plan being asserted: rank 1 whole, rank 2 (inside the + // rendered head) untouched, and ranks 3-5 reduced to the locators the renderer already prints them + // as. So the unit can only be seated by reclaiming dead weight, and by nothing else. + want := append([]SearchResult(nil), results...) + want[0] = widenSearchResultToEnclosure(results[0], enclosures[0]) + for index := searchRenderedSnippetHeadRanks; index < len(want); index++ { + want[index] = tersifySearchResult(want[index], 2) + } + budget := searchResultsSize(planSizes(want)) + + plan, bodies, demoted := allocateSearchSnippets(results, enclosures, nil, budget, 0, 5, 2) + if bodies != 1 { + t.Fatalf("bodies = %d, want 1 (the forced unit)", bodies) + } + if plan[0].SnippetStartLine != 10 || plan[0].SnippetEndLine != 300 { + t.Fatalf("rank 1 = %d-%d, want the whole forced unit 10-300", + plan[0].SnippetStartLine, plan[0].SnippetEndLine) + } + if demoted == 0 { + t.Fatal("nothing was reclaimed, so the forced unit was funded out of thin air") + } + if got := searchResultsSize(planSizes(plan)); got > budget { + t.Fatalf("plan is %d bytes over a %d-byte ceiling — --max-context-bytes must be exact", got, budget) + } + // Only ranks the renderer would NOT have printed may be reclaimed. Rank 2 sits inside + // searchRenderedSnippetHeadRanks, so its snippet is visible and must survive intact. + if plan[1].Snippet != results[1].Snippet { + t.Fatal("a rank inside the rendered head lost snippet bytes a reader would have seen") + } + // The tail keeps its locator: path, line and symbol name survive, which is a tail result's job. + if plan[4].FilePath == "" || plan[4].SnippetStartLine == 0 { + t.Fatalf("rank 5 lost its locator: %+v", plan[4]) + } + + // With no ceiling at all the same call must seat the unit and reclaim nothing. + unbounded, bodiesFree, demotedFree := allocateSearchSnippets(results, enclosures, nil, 0, 0, 5, 2) + if bodiesFree != 1 || demotedFree != 0 { + t.Fatalf("unbounded: bodies=%d demoted=%d, want 1 and 0", bodiesFree, demotedFree) + } + if unbounded[0].SnippetEndLine != 300 { + t.Fatalf("unbounded rank 1 ends at %d, want 300", unbounded[0].SnippetEndLine) + } +} + +// TestSeatForcedSearchUnitsNeverDemotesAnExistingBody is the regression test for the measured failure. +// +// Shape taken from redis__redis-11734: the control allocation gives a LOWER rank a complete body that +// covers the gold, and forcing a unit on rank 1 used to demote it to a bare locator to pay for itself. +// Three gold hunks were lost that way. The forced unit must now yield instead. +func TestSeatForcedSearchUnitsNeverDemotesAnExistingBody(t *testing.T) { + t.Parallel() + lines, forcedSymbol := editabilityFile(1400, 10, 400, "function") + goldSymbol := SymbolRecord{ + RecordType: "symbol", ID: "gold", Kind: "function", Name: "bitposCommand", + QualifiedName: "bitposCommand", FilePath: "pkg/file.go", StartLine: 882, EndLine: 1008, + } + results := make([]SearchResult, 0, 5) + for rank := 1; rank <= 5; rank++ { + start := 500 + rank*10 + results = append(results, SearchResult{ + Rank: rank, FilePath: "pkg/file.go", StartLine: start, EndLine: start + 5, + FocusLine: start + 1, SnippetStartLine: start, SnippetEndLine: start + 5, + Snippet: strings.Join(lines[start-1:start+5], "\n"), Signals: []string{}, + }) + } + // Rank 4 is the gold-covering body the ordinary path would allocate. + results[3].StartLine, results[3].EndLine = 882, 887 + results[3].FocusLine, results[3].SnippetStartLine, results[3].SnippetEndLine = 883, 882, 887 + results[3].Snippet = strings.Join(lines[881:887], "\n") + results[3].SymbolID = goldSymbol.ID + + plain := make([]searchEnclosure, len(results)) + plain[3] = searchEnclosure{start: 882, end: 1008, lines: lines, symbol: goldSymbol} + forced := make([]searchEnclosure, len(results)) + forced[3] = plain[3] + forced[0] = searchEnclosure{start: 10, end: 400, lines: lines, symbol: forcedSymbol, forced: true} + + // A ceiling that fits the control allocation (with rank 4's body) but NOT that plus rank 1's unit. + control, controlBodies, _ := allocateSearchSnippets(results, plain, nil, 0, searchEnclosureGrowthBytes, 5, 2) + if controlBodies != 1 || control[3].SnippetEndLine != 1008 { + t.Fatalf("control did not deliver the gold body: bodies=%d rank4=%d-%d", + controlBodies, control[3].SnippetStartLine, control[3].SnippetEndLine) + } + budget := serializedSearchResultBytes(control) + + plan, bodies, _ := allocateSearchSnippets(results, forced, plain, budget, searchEnclosureGrowthBytes, 5, 2) + if plan[3].SnippetStartLine != 882 || plan[3].SnippetEndLine != 1008 { + t.Fatalf("rank 4 = %d-%d, want the gold body 882-1008 intact — a forced unit must never "+ + "demote another rank's existing allocation", plan[3].SnippetStartLine, plan[3].SnippetEndLine) + } + if !hasSearchSignal(plan[3], searchCompleteSymbolSignal) { + t.Fatalf("rank 4 lost its complete-symbol signal: %v", plan[3].Signals) + } + if bodies < controlBodies { + t.Fatalf("bodies fell from %d to %d — forcing may only add", controlBodies, bodies) + } + if got := serializedSearchResultBytes(plan); got > budget { + t.Fatalf("plan is %d bytes over a %d-byte ceiling", got, budget) + } + // Whatever rank 1 ended up with, it must be at least what control gave it and no other rank may + // have lost visible source. + for index := range control { + if plan[index].SnippetStartLine > control[index].SnippetStartLine || + plan[index].SnippetEndLine < control[index].SnippetEndLine { + t.Fatalf("rank %d shrank from %d-%d to %d-%d", index+1, + control[index].SnippetStartLine, control[index].SnippetEndLine, + plan[index].SnippetStartLine, plan[index].SnippetEndLine) + } + } +} + +// TestSeatForcedSearchUnitsRefusesAUselesslySmallClip pins the floor. When non-destructive funding +// cannot buy a unit worth having, the pre-existing allocation stands — a clipped unit smaller than +// searchForcedUnitMinLines is a window, which the ordinary allocator already provides without the trade. +func TestSeatForcedSearchUnitsRefusesAUselesslySmallClip(t *testing.T) { + t.Parallel() + results, enclosures := forcedAllocationFixture() + ranked := searchResultsSize(planSizes(results)) + + plan, bodies, _ := allocateSearchSnippets(results, enclosures, nil, ranked, 0, 5, 2) + if got := searchResultsSize(planSizes(plan)); got > ranked { + t.Fatalf("plan is %d bytes over a %d-byte ceiling", got, ranked) + } + if bodies != 0 { + t.Fatalf("bodies = %d, want 0: nothing worth seating fits", bodies) + } + if plan[0].SnippetStartLine != results[0].SnippetStartLine || + plan[0].SnippetEndLine != results[0].SnippetEndLine { + t.Fatalf("rank 1 = %d-%d, want its control allocation untouched", + plan[0].SnippetStartLine, plan[0].SnippetEndLine) + } + if hasSearchSignal(plan[0], searchFullUnitSignal) { + t.Fatalf("a unit that was never seated claimed full-unit: %v", plan[0].Signals) + } + + // A ceiling between the two: too tight for the whole 291-line unit, wide enough for a clip above + // the floor. The clip is seated, reports its elision, and stays inside the ceiling. + clipped := enclosures[0] + clipped.start, clipped.end = clipSearchUnitToCap(10, 300, results[0].FocusLine, searchForcedUnitMinLines+20) + mid := ranked + serializedSearchResultBytes(widenSearchResultToEnclosure(results[0], clipped)) + midPlan, midBodies, _ := allocateSearchSnippets(results, enclosures, nil, mid, 0, 5, 2) + if span := midPlan[0].SnippetEndLine - midPlan[0].SnippetStartLine + 1; span < searchForcedUnitMinLines { + t.Fatalf("seated span = %d lines, want >= the %d-line floor or nothing", + span, searchForcedUnitMinLines) + } + if span := midPlan[0].SnippetEndLine - midPlan[0].SnippetStartLine + 1; span > 291 { + t.Fatalf("seated span = %d lines, wider than the unit", span) + } + if midBodies == 0 && !hasSearchSignal(midPlan[0], searchFullUnitSignal) { + t.Fatalf("nothing was seated at a ceiling sized for a clip: %v", midPlan[0].Signals) + } + if got := searchResultsSize(planSizes(midPlan)); got > mid { + t.Fatalf("clip plan is %d bytes over a %d-byte ceiling", got, mid) + } +} + +// TestPlanForcedSearchUnitRefusesALargeContainer pins the size-aware admission. The rubocop win (a +// 5-line config section, a non-callable kind the opportunistic path refuses) must survive; the +// docusaurus loss (a 273-line type declaration expanded from a 6-line snippet) must not happen. +func TestPlanForcedSearchUnitRefusesALargeContainer(t *testing.T) { + t.Parallel() + for _, testCase := range []struct { + name string + kind string + unitLines int + want bool + }{ + {name: "a small config section is the win this route was built for", + kind: "section", unitLines: 5, want: true}, + {name: "a container at the bound is still admitted", + kind: "type", unitLines: searchForcedContainerMaxLines, want: true}, + {name: "a 273-line type declaration is refused", + kind: "type", unitLines: 273, want: false}, + {name: "a long CALLABLE is still admitted — a function is one editable thing", + kind: "function", unitLines: 273, want: true}, + } { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + lines, symbol := editabilityFile(600, 100, 99+testCase.unitLines, testCase.kind) + byID := map[string]SymbolRecord{symbol.ID: symbol} + byFile := map[string][]SymbolRecord{symbol.FilePath: {symbol}} + result := SearchResult{ + FilePath: "pkg/file.go", StartLine: 100, EndLine: 101, FocusLine: 100, + SnippetStartLine: 100, SnippetEndLine: 101, SymbolID: symbol.ID, + } + got, ok := planForcedSearchUnit(result, byID, byFile, lines) + if ok != testCase.want { + t.Fatalf("forced = %v (%d-%d), want %v", ok, got.start, got.end, testCase.want) + } + if ok && got.end-got.start+1 != minInt(testCase.unitLines, searchFullUnitMaxLines) { + t.Fatalf("span = %d lines, want the whole unit", got.end-got.start+1) + } + }) + } +} + +func planSizes(plan []SearchResult) []int { + sizes := make([]int, len(plan)) + for index := range plan { + sizes[index] = serializedSearchResultBytes(plan[index]) + } + return sizes +} + +// TestClipSearchUnitToBytesKeepsTheHitLine pins the byte backstop. A 25-line window is 700 bytes in +// ordinary source and 9.5 kB in a generated table (redis's src/commands.c is one command per line at +// ~400 B), so the line cap alone is not a budget. +func TestClipSearchUnitToBytesKeepsTheHitLine(t *testing.T) { + t.Parallel() + lines := make([]string, 40) + for index := range lines { + lines[index] = strings.Repeat("x", 400) + } + start, end := clipSearchUnitToBytes(lines, 1, 40, 20, 1000) + if start > 20 || end < 20 { + t.Fatalf("clip = %d-%d, want a window containing the hit line 20", start, end) + } + if size := len(strings.Join(lines[start-1:end], "\n")); size > 1000 { + t.Fatalf("clip is %d bytes over a 1000-byte cap", size) + } + // A window already inside the budget is returned untouched. + if a, b := clipSearchUnitToBytes(lines, 5, 6, 5, 4000); a != 5 || b != 6 { + t.Fatalf("clip = %d-%d, want 5-6 unchanged", a, b) + } + // One line over budget beats no line at all. + if a, b := clipSearchUnitToBytes(lines, 1, 40, 7, 10); a != 7 || b != 7 { + t.Fatalf("clip = %d-%d, want the hit line 7 alone", a, b) + } +} + +// editSiteFixture is a cluster with one site of each role in one file, plus the file's symbols and a +// needle index that can read it. +func editSiteFixture() (*SearchLiteralCluster, map[string][]SymbolRecord, *searchNeedleIndex) { + source := strings.Builder{} + for line := 1; line <= 120; line++ { + source.WriteString("src line " + strconv.Itoa(line) + "\n") + } + cluster := &SearchLiteralCluster{ + Literal: "CONCEPT", HitsTotal: 4, FilesTotal: 2, + Hits: []SearchLiteralHit{ + {FilePath: "pkg/table.go", Line: 12, Symbol: "pkg.Table", Role: SearchLiteralRoleEdit}, + {FilePath: "pkg/table.go", Line: 60, Symbol: "pkg.use", Role: SearchLiteralRoleConsumer}, + {FilePath: "docs/guide.md", Line: 3, Role: SearchLiteralRoleDoc}, + {FilePath: "pkg/table.go", Line: 100, Role: SearchLiteralRoleEdit}, + }, + } + symbols := map[string][]SymbolRecord{"pkg/table.go": { + {ID: "table", Kind: "type", Name: "Table", QualifiedName: "pkg.Table", + FilePath: "pkg/table.go", StartLine: 8, EndLine: 20}, + {ID: "use", Kind: "function", Name: "use", QualifiedName: "pkg.use", + FilePath: "pkg/table.go", StartLine: 55, EndLine: 70}, + }} + content := source.String() + index := &searchNeedleIndex{read: func(path string) (string, bool) { + if path != "pkg/table.go" { + return "", false + } + return content, true + }} + return cluster, symbols, index +} + +// TestAttachSearchLiteralEditBodiesGivesSourceToEditSitesOnly is the core L2 test. A CONSUMER is +// listed precisely so an agent can decide NOT to open it, so printing its body would spend bytes +// arguing against the block's own advice; a DOC site cannot execute. Only EDIT sites get source. +func TestAttachSearchLiteralEditBodiesGivesSourceToEditSitesOnly(t *testing.T) { + t.Parallel() + cluster, symbols, index := editSiteFixture() + attachSearchLiteralEditBodies(cluster, symbols, index) + + if body := cluster.Hits[0].Body; body == "" { + t.Fatal("the EDIT site inside a type declaration got no body") + } + // The unit wins when the graph has one — including a CONTAINER, which is by construction what an + // EDIT site sits in. + if cluster.Hits[0].BodyStartLine != 8 || cluster.Hits[0].BodyEndLine != 20 { + t.Fatalf("EDIT body span = %d-%d, want the enclosing unit 8-20", + cluster.Hits[0].BodyStartLine, cluster.Hits[0].BodyEndLine) + } + if !strings.Contains(cluster.Hits[0].Body, "src line 12") { + t.Fatalf("EDIT body does not contain its own site line:\n%s", cluster.Hits[0].Body) + } + // No line-number prefixes. Agents copy this text verbatim as an Edit anchor, and inline numbering + // breaks the anchor. + for _, line := range strings.Split(cluster.Hits[0].Body, "\n") { + if line != "" && !strings.HasPrefix(line, "src line ") { + t.Fatalf("body line is decorated, not verbatim: %q", line) + } + } + // No unit in the graph covers line 100, so that site falls back to a bounded window around it. + if cluster.Hits[3].BodyStartLine != 100-searchLiteralEditBodyContextLines || + cluster.Hits[3].BodyEndLine != 100+searchLiteralEditBodyContextLines { + t.Fatalf("fallback window = %d-%d, want +/-%d around line 100", + cluster.Hits[3].BodyStartLine, cluster.Hits[3].BodyEndLine, searchLiteralEditBodyContextLines) + } + for _, position := range []int{1, 2} { + if cluster.Hits[position].Body != "" { + t.Fatalf("hit %d (%s) got a body; only EDIT sites may", + position, cluster.Hits[position].Role) + } + } + + rendered := string(RenderSearchLiteralCluster(cluster)) + // A site with a body prints its RANGE, because that is what describes the source underneath. + if !strings.Contains(rendered, "pkg/table.go:8-20 pkg.Table EDIT") { + t.Fatalf("render is missing the ranged EDIT header:\n%s", rendered) + } + // A site without one keeps the single-line locator form exactly as before. + if !strings.Contains(rendered, "pkg/table.go:60 pkg.use CONSUMER") || + !strings.Contains(rendered, "docs/guide.md:3 DOC") { + t.Fatalf("render changed the locator form of a non-EDIT site:\n%s", rendered) + } +} + +// TestAttachSearchLiteralEditBodiesRespectsItsCaps pins the two per-site caps and the site count. +func TestAttachSearchLiteralEditBodiesRespectsItsCaps(t *testing.T) { + t.Parallel() + // A unit far past the line cap, and lines long enough that the byte cap binds first. + content := strings.Repeat(strings.Repeat("y", 300)+"\n", 500) + symbols := map[string][]SymbolRecord{"pkg/wide.go": { + {ID: "huge", Kind: "type", Name: "Huge", FilePath: "pkg/wide.go", StartLine: 1, EndLine: 480}, + }} + index := &searchNeedleIndex{read: func(string) (string, bool) { return content, true }} + cluster := &SearchLiteralCluster{Literal: "CONCEPT", HitsTotal: 1, FilesTotal: 1, + Hits: []SearchLiteralHit{{FilePath: "pkg/wide.go", Line: 240, Role: SearchLiteralRoleEdit}}} + + attachSearchLiteralEditBodies(cluster, symbols, index) + hit := cluster.Hits[0] + if hit.Body == "" { + t.Fatal("no body at all: an over-cap unit must clip, not vanish") + } + if span := hit.BodyEndLine - hit.BodyStartLine + 1; span > searchLiteralEditBodyMaxLines { + t.Fatalf("body span = %d lines, over the %d-line cap", span, searchLiteralEditBodyMaxLines) + } + if len(hit.Body) > searchLiteralEditBodyMaxBytes { + t.Fatalf("body = %d bytes, over the %d-byte cap", len(hit.Body), searchLiteralEditBodyMaxBytes) + } + if hit.BodyStartLine > 240 || hit.BodyEndLine < 240 { + t.Fatalf("clip = %d-%d, want a window containing the site line 240", hit.BodyStartLine, hit.BodyEndLine) + } + if hit.BodyUnitStartLine != 1 || hit.BodyUnitEndLine != 480 { + t.Fatalf("recorded unit = %d-%d, want 1-480 so the elision can be reported", + hit.BodyUnitStartLine, hit.BodyUnitEndLine) + } + if note := SearchUnitElisionNote( + hit.BodyStartLine, hit.BodyEndLine, hit.BodyUnitStartLine, hit.BodyUnitEndLine, + ); !strings.Contains(string(RenderSearchLiteralCluster(cluster)), note) { + t.Fatalf("render omits the elision note %q", note) + } + + // The site cap bounds how many bodies are attached however many sites are listed. + many := &SearchLiteralCluster{Literal: "CONCEPT", HitsTotal: 20, FilesTotal: 1} + for site := 0; site < searchLiteralEditBodySites+4; site++ { + many.Hits = append(many.Hits, SearchLiteralHit{ + FilePath: "pkg/wide.go", Line: 10 + site, Role: SearchLiteralRoleEdit, + }) + } + attachSearchLiteralEditBodies(many, symbols, index) + bodied := 0 + for _, hit := range many.Hits { + if hit.Body != "" { + bodied++ + } + } + if bodied != searchLiteralEditBodySites { + t.Fatalf("bodied sites = %d, want the cap of %d", bodied, searchLiteralEditBodySites) + } +} + +// TestFitSearchLiteralClusterDropsBodiesBeforeSites pins the yield order and the cap switch. A site is +// a grep the agent does not have to run whether or not its source is printed, so a body is the +// cheaper thing to lose — and a cluster that loses its LAST body must fall back under the location +// cap, or it ships over a ceiling the contract check then rejects. +func TestFitSearchLiteralClusterDropsBodiesBeforeSites(t *testing.T) { + t.Parallel() + // Bodies that fit the edit-body cap survive untouched, and the cap they are measured against is + // the edit-body one rather than the location one. + small, symbols, index := editSiteFixture() + attachSearchLiteralEditBodies(small, symbols, index) + if searchLiteralClusterCap(small, searchLiteralClusterMaxBytes) != searchLiteralEditBodyClusterMaxBytes { + t.Fatal("a cluster carrying bodies must be held to the edit-body cap") + } + if fitted := fitSearchLiteralCluster(small, searchLiteralClusterMaxBytes); fitted == nil || + !searchLiteralClusterHasBodies(fitted) { + t.Fatal("bodies that fit the edit-body cap were dropped anyway") + } + + // Now overshoot the edit-body cap: eight sites of ~3 kB each. Bodies yield from the END, and every + // SITE stays — a site is a grep the agent does not have to run whether or not its source is + // printed, so a body is the cheaper thing to lose. + big := &SearchLiteralCluster{Literal: "CONCEPT", HitsTotal: 30, FilesTotal: 1} + for site := 0; site < searchLiteralEditBodySites; site++ { + big.Hits = append(big.Hits, SearchLiteralHit{ + FilePath: "pkg/wide.go", Line: 10 + site, Role: SearchLiteralRoleEdit, + BodyStartLine: 1, BodyEndLine: 10, + Body: strings.Repeat(strings.Repeat("z", 299)+"\n", 10), + }) + } + sites := len(big.Hits) + fitted := fitSearchLiteralCluster(big, searchLiteralClusterMaxBytes) + if fitted == nil { + t.Fatal("fitter dropped the whole block") + } + if len(fitted.Hits) != sites { + t.Fatalf("hits = %d, want all %d kept: bodies must yield before sites do", len(fitted.Hits), sites) + } + if got := searchLiteralClusterCost(fitted); got > searchLiteralEditBodyClusterMaxBytes { + t.Fatalf("fitted block = %d bytes, over the %d-byte edit-body cap", + got, searchLiteralEditBodyClusterMaxBytes) + } + bodied := 0 + for _, hit := range fitted.Hits { + if hit.Body != "" { + bodied++ + } + } + if bodied == 0 || bodied == sites { + t.Fatalf("bodied sites = %d of %d, want a partial reduction", bodied, sites) + } + // They went from the end, so the earliest (path-ordered, hence definition-first) survive. + for position := 0; position < bodied; position++ { + if fitted.Hits[position].Body == "" { + t.Fatalf("body %d was dropped ahead of a deeper one", position) + } + } + + // THE INVARIANT validateSearchContextBlockBudget relies on: whatever the fitter returns is within + // the cap DERIVED FROM WHAT IT RETURNS. A cluster sized under the edit-body cap and then stripped + // of its last body must fall back under the location cap in the same call, or the contract check + // rejects a block the fitter just declared fitted. + // + // The long paths are what make the body-less list overflow 560 B on its own, which is the only way + // the fall-back branch can be reached at all. + long := &SearchLiteralCluster{Literal: "CONCEPT", HitsTotal: 40, FilesTotal: 6} + for site := 0; site < 6; site++ { + long.Hits = append(long.Hits, SearchLiteralHit{ + FilePath: strings.Repeat("deeply/nested/", 8) + "file" + strconv.Itoa(site) + ".go", + Line: 10 + site, Role: SearchLiteralRoleEdit, + BodyStartLine: 1, BodyEndLine: 12, + Body: strings.Repeat(strings.Repeat("w", 299)+"\n", 12), + }) + } + fittedLong := fitSearchLiteralCluster(long, searchLiteralClusterMaxBytes) + if fittedLong == nil { + t.Fatal("fitter dropped a block that a shorter list would have fitted") + } + if got, cap := searchLiteralClusterCost(fittedLong), + searchLiteralClusterCap(fittedLong, searchLiteralClusterMaxBytes); got > cap { + t.Fatalf("fitted block = %d bytes over its own applicable cap of %d — "+ + "validateSearchContextBlockBudget would reject it", got, cap) + } +} + +// TestBuildSearchLiteralClusterLeavesEditBodiesOffByDefault is the regression guard on the default +// payload: the flag has to be the only thing that can add source to this block. +func TestBuildSearchLiteralClusterLeavesEditBodiesOffByDefault(t *testing.T) { + t.Parallel() + results, q, symbolsByFile, index := searchLiteralMagnetFixture(4) + off := buildSearchLiteralCluster(results, q, symbolsByFile, index, searchLiteralClusterMaxBytes, false) + if off == nil { + t.Fatal("fixture produced no cluster") + } + if searchLiteralClusterHasBodies(off) { + t.Fatalf("the default block carries source:\n%s", RenderSearchLiteralCluster(off)) + } +} diff --git a/internal/sem/search_enclosure.go b/internal/sem/search_enclosure.go index 07a9f627..770721f7 100644 --- a/internal/sem/search_enclosure.go +++ b/internal/sem/search_enclosure.go @@ -1,6 +1,9 @@ package sem -import "strings" +import ( + "fmt" + "strings" +) // Snippet byte allocation. // @@ -68,10 +71,284 @@ const ( searchHeadWindowLines = 60 ) +// EDITABILITY: the forced full-unit upgrade (--full-unit-top) +// ========================================================== +// +// Everything above allocates bodies OPPORTUNISTICALLY: a head rank gets its complete callable when +// one is resolvable, when it is under defaultSearchEnclosureMaxLines, and when the growth allowance +// or a demotable tail can pay for it. Measured against real benchmark payloads, that leaves the +// share of needed edits whose replaced text appears VERBATIM in the payload at ~11%, and the misses +// are not budget misses — they are the three conditions themselves: +// +// - no ENCLOSABLE CALLABLE. searchEnclosableSymbolKind excludes containers, so a hit inside a +// class body, a constant table, a type declaration or a build script resolves to nothing and +// falls through to a 6-line window (and, below the text renderer's second rank, to a bare +// locator with no source at all — 2 of the 5 hits on redis__redis-10095). +// - the 160-LINE CAP. A callable longer than that keeps its focused window, which is exactly the +// case where reading the file back costs the most. +// - NO DEMOTABLE TAIL at small --top-k. allocateSearchSnippets scans cuts down to +// minInt(headRanks, len(results)); with --top-k 5 and headRanks 5 that loop can only ever +// evaluate "demote nothing", so the whole body budget is the 10 kB growth allowance. +// +// --full-unit-top N bypasses all three for the first N ranks. The unit is resolved as the callable +// first and then as ANY enclosing symbol (that is what recovers type declarations and tables), it +// is bounded only by searchFullUnitMaxLines, and the allocator seats it BEFORE it prices anything +// else, demoting the tail to locators to pay for it. --max-context-bytes still wins: when the +// ceiling cannot hold the forced unit even with every other hit reduced to a locator, the forced +// ranks give their ranked snippets back deepest-first, so rank 1 is the last thing to yield. +const ( + // searchFullUnitMaxLines is the safety cap on ONE forced unit. It is a safety cap, not a + // judgement about what is worth returning: 400 lines is far past any function an agent edits in + // one go, so a unit that trips it is a container the caller almost certainly did not mean to + // ask for whole. Such a unit is CLIPPED to a window containing the hit and says so, rather + // than being silently dropped back to a 6-line snippet. + searchFullUnitMaxLines = 400 + + // searchFullUnitSignal marks a result rendered as its complete enclosing unit because + // --full-unit-top asked for it. It is reported separately from complete-symbol because the two + // promises differ: complete-symbol says "this is the whole callable and you need no follow-up + // read", while full-unit says "the caller demanded the enclosing unit at this rank". A forced + // unit that fit carries BOTH; one the safety cap clipped carries full-unit and unit-elided and + // deliberately NOT complete-symbol, because a clipped unit cannot make the no-follow-up promise. + searchFullUnitSignal = "full-unit" + + // searchFullUnitElidedSignal marks a forced unit the safety cap clipped. + searchFullUnitElidedSignal = "unit-elided" + + // MEASURED FAILURE THAT SIZED THE TWO BOUNDS BELOW. On a 50-instance replay, --full-unit-top 2 + // with --edit-site-bodies made gold coverage WORSE (losers 28% -> 16% against the control), and the + // mechanism was funding, not retrieval: forcing a unit on a non-gold rank 1/2 exhausted the 24 kB + // ceiling and demoted a LOWER rank that had been carrying a complete gold-covering body down to a + // bare locator. On redis__redis-11734 the control gave rank 4 the whole `bitposCommand` + // (src/bitops.c:882-1008, complete-symbol) and the forced run turned it into + // `4. src/bitops.c:882 bitposCommand` with no source at all, losing three gold hunks — paid for by + // a 273-line type at rank 2 on another instance and a 61-line header window here. + // + // The fix is an INVARIANT, enforced in seatForcedSearchUnits: a forced unit may never reduce any + // other rank's rendered source. These two bounds are the second half of it — they stop the + // pathological growth at the source rather than only refusing to pay for it. + + // searchForcedContainerMaxLines admits a NON-CALLABLE unit (a class, a type, a config section, a + // table) only when the whole thing is this small. A container is the unit an agent edits only when + // it is small enough to read as one thing: the win this route was built for is + // rubocop__rubocop-13680, where rank 1 is a FIVE-line `Style/RedundantLineContinuation` section of + // config/default.yml that the opportunistic path refuses because its kind is not callable. The loss + // is facebook__docusaurus-9183, where the same bypass expanded the `DocusaurusConfig` type from 6 + // lines to 273. 60 lines keeps the first and refuses the second. + searchForcedContainerMaxLines = 60 + + // searchClippedUnitFocusLines is the half-width of the window a clipped unit is re-centred on. + // + // MEASURED CORRECTNESS BUG (briannesbitt__carbon-2752). The payload ranked Comparison.php #1 tagged + // `full-unit,complete-symbol` while the printed body ELIDED lines 630-1125 — and the focus line, the + // region the edit had to land in, was 989. The tag asserted "this is the whole unit, you need no + // follow-up read" about a body that did not contain the line the payload itself had pointed at. An + // agent that trusts the tag edits the wrong place; one that does not trust it re-reads the file, and + // then the tag cost bytes for nothing. + // + // The anchor was the cause: a clip anchored at the unit's START shows the declaration and drops + // whatever the hit was about, which for a 1,200-line class is everything that matters. A clipped + // unit is now always CENTRED on the focus line, and the complete-body signals are withheld + // whenever the printed span does not contain it — see widenSearchResultToEnclosure, where the + // invariant is enforced rather than merely intended. + searchClippedUnitFocusLines = 60 + + // searchForcedUnitMinLines is the floor on a forced unit that has to be CLIPPED to fit inside + // non-destructive funding. Below it the caller is being handed a window, which the ordinary + // allocator already provides for free and without the trade — so a unit that cannot reach this + // much is not forced at all and the pre-existing allocation stands. + searchForcedUnitMinLines = 40 + + // BUDGET-DRIVEN RENDERING. The no-demotion invariant above is necessary and not sufficient: with the + // levers on, redis__redis-11734's payload SHRANK to 6,435 B while 74% of the 24,576 B ceiling sat + // UNSPENT — rank 4's 127-line gold body rendered as a 34 B locator with 18 kB free. Nothing was + // competing for those bytes; the allocator simply had no rule that says "spend them". Two rules were + // missing, and --max-snippet-lines was being read as the wrong one of the two. + // + // - --max-snippet-lines is the FLOOR (the minimum context a hit carries), never a ceiling on how + // far the allocator may expand it. Verified independently: the same query at + // --max-snippet-lines 40 returns all 19 gold lines inside the same 24,576 B ceiling. + // - a ranked hit must never render as a BARE LOCATOR while the budget can still hold its unit, or + // a clipped window of at least searchBudgetWindowMinLines. + // + // So under any payload-shape lever the allocator plans an enclosure for EVERY ranked hit and then + // spends the remaining ceiling in rank order. It is still non-destructive: expansion only ever uses + // budget nothing else had claimed. + + // searchBudgetWindowMinLines is the smallest window worth printing in place of a locator. Below it + // the reader gets neither the declaration nor the neighbourhood, and the locator at least costs + // nothing. + searchBudgetWindowMinLines = 20 + + // searchDeclarationWindowLines caps a window over DECLARATION-ONLY content — a prototype block, a + // header's extern list, an interface's member list. Such a region has no body to understand, so a + // wide window buys nothing: on redis__redis-11734 a src/server.h prototype block ballooned to 61 + // lines / 2,156 B in the same payload that dropped a real function body. + searchDeclarationWindowLines = 10 + + // searchRenderedSnippetHeadRanks is how deep the TEXT renderer prints a snippet for a result the + // allocator did not upgrade. It mirrors internal/cli's searchTextFullRanks, and the duplication is + // deliberate: the allocator has to know which of its bytes the reader will actually SEE in order to + // tell dead weight from source, and internal/cli imports this package rather than the reverse. + // RenderedSnippetHeadRanks is exported so the renderer's own test asserts the two agree. + searchRenderedSnippetHeadRanks = 2 + + // searchFullUnitGapRatio decides whether --full-unit-top 2 actually reaches rank 2. A second + // forced unit is worth its bytes only when the ranking did NOT separate the two candidates: if + // rank 1 is clearly ahead, rank 2's body is a second answer to a question that already has one. + // The test is relative, because scores are not calibrated across queries. + searchFullUnitGapRatio = 0.15 +) + +// FullUnitSignal is searchFullUnitSignal exported for renderers: a result carrying it was returned +// as its enclosing unit on the caller's orders and must never be abbreviated on the way out. +const FullUnitSignal = searchFullUnitSignal + +// RenderedSnippetHeadRanks is searchRenderedSnippetHeadRanks exported so the text renderer's test can +// assert that the depth the allocator ASSUMES is printed is the depth that actually is. If the two +// drift, the allocator starts reclaiming bytes a reader can see. +const RenderedSnippetHeadRanks = searchRenderedSnippetHeadRanks + +// SearchUnitElisionNote renders the note a clipped unit carries: which of the unit's own lines are +// NOT printed above it, and that the unit continues past what is. It returns "" when nothing was +// clipped, so a caller can print it unconditionally. +// +// It is a separate LINE after the body, never an inline marker inside it. Agents copy body text +// verbatim as the `old_string` anchor of an edit, so decorating or interleaving the source would +// turn a navigation aid into a broken patch — the same reason focus= rides in the header. +func SearchUnitElisionNote(printedStart, printedEnd, unitStart, unitEnd int) string { + if unitStart <= 0 || unitEnd < unitStart || printedStart <= 0 || printedEnd < printedStart { + return "" + } + parts := make([]string, 0, 2) + if unitStart < printedStart { + parts = append(parts, fmt.Sprintf("%d–%d", unitStart, printedStart-1)) + } + if unitEnd > printedEnd { + parts = append(parts, fmt.Sprintf("%d–%d", printedEnd+1, unitEnd)) + } + if len(parts) == 0 { + return "" + } + return "…elided lines " + strings.Join(parts, ", ") + " (unit continues)" +} + +// searchFullUnitForceRanks turns the --full-unit-top request into a rank count. +// +// Rank 1 is unconditional: it is the rank the agent reads and edits most (62% read / 54% edited on +// the measured sonnet sessions), so "the caller asked for the top unit" needs no further test. +// Every rank past the first is admitted only while its score is still within searchFullUnitGapRatio +// of rank 1's — one forced unit per genuinely ambiguous answer, not N bodies per search. +func searchFullUnitForceRanks(results []SearchResult, top int) int { + if top <= 0 || len(results) == 0 { + return 0 + } + limit := minInt(top, len(results)) + ranks := 1 + for index := 1; index < limit; index++ { + if !searchScoresWithinGap(results[0].Score, results[index].Score, searchFullUnitGapRatio) { + break + } + ranks = index + 1 + } + return ranks +} + +// searchScoresWithinGap reports whether `next` is close enough to `top` that the ranking did not +// separate them. A non-positive top score carries no separation information, so it never claims one. +func searchScoresWithinGap(top, next, ratio float64) bool { + if top <= 0 { + return true + } + return (top-next)/top < ratio +} + +// clipSearchUnitToCap picks the `cap`-line window of the unit [start,end] that is printed when the +// unit is too large to return whole. It is anchored at the unit's own start — the declaration and +// its first statements are what identify the unit — and slides only when the hit itself would fall +// outside that anchor, in which case the window is centred on the hit and clamped to the unit. +func clipSearchUnitToCap(start, end, focus, cap int) (int, int) { + if cap <= 0 || end-start+1 <= cap { + return start, end + } + if focus < start || focus > end { + focus = start + } + // CENTRED ON THE FOCUS, always. The old rule anchored at the unit's start and slid only when the + // focus would otherwise fall outside the window, which is how carbon-2752 came back showing a + // class's opening declaration while eliding the line the payload was pointing at. A window that + // does not contain the hit is not a smaller answer, it is a different and wrong one. + clipStart := focus - cap/2 + if clipStart < start { + clipStart = start + } + if clipStart+cap-1 > end { + clipStart = end - cap + 1 + } + if clipStart < start { + clipStart = start + } + return clipStart, minInt(end, clipStart+cap-1) +} + +// clipSearchUnitToFocusWindow is the clip a FORCED unit gets when it is too large to return whole: a +// bounded window centred on the focus line. It is deliberately much narrower than +// searchFullUnitMaxLines, because a clipped unit cannot make the complete-body promise at any width — +// so the only thing extra width buys is bytes. +func clipSearchUnitToFocusWindow(start, end, focus int) (int, int) { + return clipSearchUnitToCap(start, end, focus, 2*searchClippedUnitFocusLines+1) +} + +// clipSearchUnitToBytes is the backstop a LINE cap cannot provide: a window of 25 lines is 700 bytes +// in ordinary source and 9.5 kB in a generated table (redis's src/commands.c packs one command per +// line, each ~400 bytes). Line caps are the right unit for a reader and the wrong unit for a budget, +// so both apply. +// +// Unlike the line cap it re-anchors on the HIT rather than on the unit's start: once the budget is +// this tight the declaration line is no longer affordable context, and the line the literal is +// actually on is the one thing that must survive. It grows outward from there while the budget allows, +// so the window stays contiguous and verbatim. A single line over budget is returned alone — one true +// line beats none. +func clipSearchUnitToBytes(lines []string, start, end, focus, maxBytes int) (int, int) { + if maxBytes <= 0 || start < 1 || end > len(lines) || end < start { + return start, end + } + if len(strings.Join(lines[start-1:end], "\n")) <= maxBytes { + return start, end + } + if focus < start || focus > end { + focus = start + } + low, high := focus, focus + size := len(lines[focus-1]) + for { + grew := false + if high < end && size+1+len(lines[high]) <= maxBytes { + size += 1 + len(lines[high]) + high++ + grew = true + } + if low > start && size+1+len(lines[low-2]) <= maxBytes { + size += 1 + len(lines[low-2]) + low-- + grew = true + } + if !grew { + return low, high + } + } +} + // CompleteSymbolSignal is searchCompleteSymbolSignal exported for renderers: a result carrying // it is a whole callable and must never be abbreviated on the way out. const CompleteSymbolSignal = searchCompleteSymbolSignal +// HeadWindowSignal is searchHeadWindowSignal exported for the same reason: the allocator spent budget +// widening this result to a readable window, and a renderer that then prints it as a locator throws +// those bytes away — measured on fmtlib__fmt-2457, where the 60-line window the allocator seated at +// rank 3 (1,709 B) was discarded and the rank came out as `include/fmt/ranges.h:682`. +const HeadWindowSignal = searchHeadWindowSignal + // searchEnclosure is the complete-body upgrade available for one ranked result: the true // source span of the symbol enclosing the hit, taken from the graph's own symbol records, // together with the file lines needed to materialize it. The zero value means no upgrade is @@ -98,6 +375,17 @@ type searchEnclosure struct { // lines is an instruction to spend a turn opening the file — ~17,000 tokens to recover what // ~700 bytes would have carried. window bool + // forced marks an enclosure the CALLER demanded (--full-unit-top) rather than one the + // allocator found affordable. A forced enclosure bypasses the enclosable-kind restriction, the + // line cap and the "the snippet already covers it" short-circuit, and the allocator seats it + // before it prices anything else. It is never produced unless --full-unit-top asked for it, so + // the default payload is byte-for-byte unchanged. + forced bool + // unitStart/unitEnd are the unit's TRUE span, recorded only when searchFullUnitMaxLines clipped + // the printed window out of a larger unit. They are what the elision note is rendered from; a + // forced unit that fit whole leaves them zero. + unitStart int + unitEnd int } func (enclosure searchEnclosure) available() bool { @@ -138,9 +426,36 @@ func enclosingCallableForResult( ) } +// enclosingUnitForResult resolves the unit a FORCED body is cut from. It is deliberately wider than +// enclosingCallableForResult: the callable wins when there is one, and otherwise ANY enclosing +// symbol will do — a class, a type declaration, a constant table, an object literal in a build +// script. That widening is the point of the flag. The opportunistic path must keep excluding +// containers (a whole class spends the head's budget on the members that are not the fix), but a +// caller who asked for the top unit by name has accepted that cost, and the measured misses are +// concentrated exactly there: on redis__redis-10095 two of five hits are container/declaration +// sites that the opportunistic path returns with no source at all. +func enclosingUnitForResult( + result SearchResult, + symbolsByID map[string]SymbolRecord, + symbolsByFile map[string][]SymbolRecord, +) (SymbolRecord, bool) { + if callable, ok := enclosingCallableForResult(result, symbolsByID, symbolsByFile); ok { + return callable, true + } + if result.SymbolID != "" { + if symbol, ok := symbolsByID[result.SymbolID]; ok && symbol.FilePath == result.FilePath { + return symbol, true + } + } + return smallestSearchSymbolContainingLineWhere(symbolsByFile[result.FilePath], result.FocusLine, nil) +} + // planSearchEnclosures computes, for every ranked result, the complete-body upgrade the // allocator may spend budget on. Files are read through the shared content cache, so a // result whose file was already hydrated for ranking costs no extra IO. +// forceRanks is the --full-unit-top depth: the first `forceRanks` results get their complete +// enclosing UNIT unconditionally (see the editability comment above), planned here so the allocator +// only has to seat what it is given. func planSearchEnclosures( results []SearchResult, symbolsByID map[string]SymbolRecord, @@ -150,6 +465,8 @@ func planSearchEnclosures( contextLines int, bodyHeadRanks int, windowLines int, + forceRanks int, + budgetDriven bool, ) []searchEnclosure { if len(results) == 0 { return nil @@ -160,6 +477,21 @@ func planSearchEnclosures( if bodyHeadRanks <= 0 { bodyHeadRanks = searchEnclosureHeadRanks } + if forceRanks < 0 { + forceRanks = 0 + } + // A forced rank is planned even when it sits past the body head: the caller asked for that + // rank's unit, and the body-head depth is a budget judgement about the OPPORTUNISTIC upgrade. + planRanks := maxInt(bodyHeadRanks, forceRanks) + // BUDGET-DRIVEN RENDERING plans an enclosure for EVERY ranked hit, not just the head. The pool it + // operates over is the caller's own --top-k: budget decides which of those get bodies, so a caller + // who wants the gold unit that sits at rank 6-8 raises --top-k rather than relying on a hidden + // second pool. (Measured: gold appears by exact name at ranks 6-8 and is cut by --top-k 5 on + // astral-sh__ruff-15626 and faker-ruby__faker-2970, while 0 of 50 replayed payloads used even half + // the byte ceiling.) The extra planning costs one cached file read per rank and nothing else. + if budgetDriven { + planRanks = len(results) + } enclosures := make([]searchEnclosure, len(results)) fileLines := map[string][]string{} unreadable := map[string]bool{} @@ -167,7 +499,7 @@ func planSearchEnclosures( // A rank the allocator can never upgrade needs no enclosure planned for it, and planning // one costs a file read. This is the only place that read happens, so bounding it here // bounds it everywhere. - if index >= bodyHeadRanks { + if index >= planRanks { continue } symbol, hasCallable := enclosingCallableForResult(result, symbolsByID, symbolsByFile) @@ -185,7 +517,51 @@ func planSearchEnclosures( lines = strings.Split(content, "\n") fileLines[result.FilePath] = lines } + if index < forceRanks { + if forced, ok := planForcedSearchUnit(result, symbolsByID, symbolsByFile, lines); ok { + enclosures[index] = forced + continue + } + // No indexed symbol covers the hit at all. A forced rank must still not come back as + // six lines, so it falls through to the bounded read window even when the caller did + // not ask for one — the flag's promise is source at this rank, and a window is the + // widest honest thing left. It is marked forced so the allocator seats it with the rest + // of the prefix; `window` still governs what it CLAIMS, so it reports head-window and + // never complete-symbol. + if window, ok := planSearchHeadWindow(result, lines, maxInt(windowLines, searchHeadWindowLines)); ok { + window.forced = true + enclosures[index] = window + } + continue + } + if index >= bodyHeadRanks { + // Past the opportunistic head the ordinary allocator offers nothing. Under budget-driven + // rendering such a rank must still not come back as a bare locator while the ceiling can + // hold its unit, so it is planned exactly like a head rank: the enclosing callable when + // there is one, a worthwhile window when there is not. + if !budgetDriven { + continue + } + if hasCallable { + if start, end := clampRegion(symbol.StartLine, symbol.EndLine, len(lines)); start > 0 && + end-start+1 <= maxLines && result.FocusLine >= start && result.FocusLine <= end && + !(result.SnippetStartLine <= start && result.SnippetEndLine >= end) { + enclosures[index] = searchEnclosure{start: start, end: end, lines: lines, symbol: symbol} + continue + } + } + if window, ok := planBudgetSearchWindow(result, lines, windowLines); ok { + enclosures[index] = window + } + continue + } if !hasCallable { + if budgetDriven { + if window, ok := planBudgetSearchWindow(result, lines, windowLines); ok { + enclosures[index] = window + } + continue + } if windowLines > 0 { if window, ok := planSearchHeadWindow(result, lines, windowLines); ok { enclosures[index] = window @@ -197,6 +573,12 @@ func planSearchEnclosures( if start == 0 || end-start+1 > maxLines { // Too large to return whole. A head rank still must not come back as two lines, so // fall back to the bounded window rather than to a locator. + if budgetDriven { + if window, ok := planBudgetSearchWindow(result, lines, windowLines); ok { + enclosures[index] = window + } + continue + } if windowLines > 0 { if window, ok := planSearchHeadWindow(result, lines, windowLines); ok { enclosures[index] = window @@ -232,6 +614,60 @@ func planSearchEnclosures( return enclosures } +// planForcedSearchUnit builds the --full-unit-top enclosure for one rank: the complete span of the +// unit enclosing the hit, clipped only by searchFullUnitMaxLines. +// +// It differs from the opportunistic planner in every condition that planner applies. There is no +// enclosable-kind restriction (enclosingUnitForResult falls back to any symbol), no 160-line cap +// (an over-cap unit is clipped and reports the elision instead of degrading to a window), and no +// "the snippet already covers it" short-circuit — a result whose ranked snippet happens to equal its +// unit is still marked, because "this is the whole unit" is the fact the caller asked for and a +// payload that shows a complete function without saying so is read as a fragment. redis__redis-10095 +// rank 1 is exactly that case: `lpopCommand` is five lines, printed whole, and unsignalled. +func planForcedSearchUnit( + result SearchResult, + symbolsByID map[string]SymbolRecord, + symbolsByFile map[string][]SymbolRecord, + lines []string, +) (searchEnclosure, bool) { + symbol, ok := enclosingUnitForResult(result, symbolsByID, symbolsByFile) + if !ok { + return searchEnclosure{}, false + } + start, end := clampRegion(symbol.StartLine, symbol.EndLine, len(lines)) + if start == 0 { + return searchEnclosure{}, false + } + // SIZE-AWARE ADMISSION for the container bypass. Widening past searchEnclosableSymbolKind is what + // makes this route reach a config section or a small value type; it is also what let a 273-line + // interface declaration in and cost another rank its gold body. A container is the unit an agent + // edits only while it reads as one thing, so past searchForcedContainerMaxLines the caller falls + // back to whatever the ordinary path offers. Callables keep the far larger searchFullUnitMaxLines + // cap: a long function is still one editable thing. + if !searchEnclosableSymbolKind(symbol.Kind) && end-start+1 > searchForcedContainerMaxLines { + return searchEnclosure{}, false + } + // A forced unit must never print LESS source than the ranking already did. A symbol's recorded + // span starts at its declaration, while a ranked region routinely starts a line or two above it + // (the doc comment, a decorator, an attribute) — and that preamble is often the most useful thing + // on the screen: on redis__redis-10095 rank 1 it is `/* LPOP [count] */`, the one line that + // states the contract the bug is about. So the unit is UNIONED with what was already seated; the + // safety cap below then applies to the union, exactly as it would to the bare unit. + if snippetStart := result.SnippetStartLine; snippetStart >= 1 && snippetStart < start { + start = snippetStart + } + if snippetEnd := result.SnippetEndLine; snippetEnd > end && snippetEnd <= len(lines) { + end = snippetEnd + } + enclosure := searchEnclosure{lines: lines, symbol: symbol, forced: true} + if end-start+1 > searchFullUnitMaxLines { + enclosure.unitStart, enclosure.unitEnd = start, end + start, end = clipSearchUnitToFocusWindow(start, end, result.FocusLine) + } + enclosure.start, enclosure.end = start, end + return enclosure, true +} + // planSearchHeadWindow builds the fallback read window for a head rank that has no enclosable // callable: a document or template, an unenclosable container kind, a body past the line cap, or a // hit that lies outside its own recorded symbol span. It centres `windowLines` on the focus line and @@ -267,6 +703,43 @@ func planSearchHeadWindow(result SearchResult, lines []string, windowLines int) return searchEnclosure{start: start, end: end, lines: lines, window: true}, true } +// searchDeclarationOnlyRegion reports whether a span is declarations rather than code: a prototype +// block, an extern list, an interface body. The test is structural and language-agnostic — a region +// with essentially no block openers has no control flow, so there is nothing in it a wider window +// would explain. It is deliberately conservative (a real body opens a block on its very first line), +// so ordinary code is never mistaken for a declaration list. +func searchDeclarationOnlyRegion(lines []string, start, end int) bool { + if start < 1 || end > len(lines) || end-start+1 < searchDeclarationWindowLines+2 { + return false + } + openers := 0 + for _, line := range lines[start-1 : end] { + openers += strings.Count(line, "{") + } + return openers <= 1 +} + +// planBudgetSearchWindow is the fallback for a rank with no enclosable unit under budget-driven +// rendering: a window wide enough to be worth printing instead of a locator, narrowed to +// searchDeclarationWindowLines when the region is a declaration list. +func planBudgetSearchWindow(result SearchResult, lines []string, windowLines int) (searchEnclosure, bool) { + if windowLines < searchBudgetWindowMinLines { + windowLines = searchBudgetWindowMinLines + } + window, ok := planSearchHeadWindow(result, lines, windowLines) + if !ok { + return searchEnclosure{}, false + } + if searchDeclarationOnlyRegion(lines, window.start, window.end) { + narrow, narrowed := planSearchHeadWindow(result, lines, searchDeclarationWindowLines) + if !narrowed { + return searchEnclosure{}, false + } + return narrow, true + } + return window, true +} + // widenSearchResultToEnclosure rewrites a result to carry the complete body of its enclosing // symbol. The reported region is widened along with the snippet so the response invariant // StartLine <= SnippetStartLine <= SnippetEndLine <= EndLine keeps holding. @@ -274,6 +747,9 @@ func widenSearchResultToEnclosure(result SearchResult, enclosure searchEnclosure if !enclosure.available() { return result } + // Captured before anything is rewritten: the clamp below moves the focus into the new range, which + // would hide exactly the violation the elision contract has to detect. + incomingFocus := result.FocusLine result.StartLine = minInt(maxInt(1, result.StartLine), enclosure.start) result.EndLine = maxInt(result.EndLine, enclosure.end) result.SnippetStartLine = enclosure.start @@ -288,7 +764,32 @@ func widenSearchResultToEnclosure(result SearchResult, enclosure searchEnclosure result.Signals = appendUnique(result.Signals, searchHeadWindowSignal) return result } - result.Signals = appendUnique(result.Signals, searchCompleteSymbolSignal) + // THE ELISION CONTRACT. `full-unit` and `complete-symbol` both assert that the reader is looking at + // the whole unit and needs no follow-up read. Neither may be attached to a body that does not even + // contain the focus line the payload is pointing at — that is the carbon-2752 bug, and it is a + // correctness bug rather than a sizing one: the signal was false. The test is made against the + // INCOMING focus, before the clamp below rewrites it into range and hides the violation. + if focus := incomingFocus; focus > 0 && (focus < enclosure.start || focus > enclosure.end) { + result.Signals = appendUnique(result.Signals, searchHeadWindowSignal) + if enclosure.unitStart > 0 { + result.UnitStartLine, result.UnitEndLine = enclosure.unitStart, enclosure.unitEnd + result.Signals = appendUnique(result.Signals, searchFullUnitElidedSignal) + } + return result + } + // A forced unit the safety cap clipped is source the reader can act on, but it is not the whole + // unit, so it reports the elision and withholds complete-symbol for the same reason a window does. + clipped := enclosure.forced && enclosure.unitStart > 0 && + (enclosure.unitStart < enclosure.start || enclosure.unitEnd > enclosure.end) + if enclosure.forced { + result.Signals = appendUnique(result.Signals, searchFullUnitSignal) + } + if clipped { + result.UnitStartLine, result.UnitEndLine = enclosure.unitStart, enclosure.unitEnd + result.Signals = appendUnique(result.Signals, searchFullUnitElidedSignal) + } else { + result.Signals = appendUnique(result.Signals, searchCompleteSymbolSignal) + } if enclosure.symbol.ID != "" && enclosure.symbol.ID != result.SymbolID { result.Kind = enclosure.symbol.Kind result.SymbolID = enclosure.symbol.ID @@ -381,9 +882,14 @@ func searchResultsSize(sizes []int) int { // - Among the plans that deliver the most complete bodies, the CHEAPEST is chosen, so the // tail pays for a body before the growth allowance does and demotion never happens // without buying something. Ties go to the plan that demotes fewest results. +// - `plain` is the enclosure plan the SAME inputs produce with --full-unit-top off. It is what makes +// the forced route non-destructive: the control allocation is computed from it, and a forced unit +// may then only spend what that allocation left. nil is accepted (the forced ranks simply get no +// control upgrade), so a caller that is not forcing passes nil. func allocateSearchSnippets( results []SearchResult, enclosures []searchEnclosure, + plain []searchEnclosure, hardBudget, growth, headRanks, tailLines int, ) ([]SearchResult, int, int) { if len(results) == 0 || len(enclosures) != len(results) { @@ -395,6 +901,17 @@ func allocateSearchSnippets( if growth < 0 { growth = 0 } + // A forced unit is not a candidate the allocator prices; it is a decision the caller already made. + // It is therefore seated ON TOP OF the allocation this function would have produced WITHOUT it — + // `control` below — and may only spend budget the control plan left unused. See + // seatForcedSearchUnits for the invariant and the measurement that forced it. + if forcedEnd := forcedSearchEnclosureEnd(enclosures); forcedEnd > 0 { + control, bodies, demoted := allocateSearchSnippets( + results, unforcedSearchEnclosures(enclosures, plain), nil, + hardBudget, growth, headRanks, tailLines, + ) + return seatForcedSearchUnits(control, results, enclosures, hardBudget, forcedEnd, bodies, demoted) + } sizes := make([]int, len(results)) for index := range results { sizes[index] = serializedSearchResultBytes(results[index]) @@ -434,6 +951,311 @@ func allocateSearchSnippets( return best, bestBodies, bestDemoted } +// forcedSearchEnclosureEnd reports how far the forced prefix reaches: one past the deepest rank +// carrying a usable forced enclosure, or 0 when --full-unit-top was not asked for (or resolved +// nothing at any rank, in which case the default allocator runs unchanged). +func forcedSearchEnclosureEnd(enclosures []searchEnclosure) int { + end := 0 + for index, enclosure := range enclosures { + if enclosure.forced && enclosure.available() { + end = index + 1 + } + } + return end +} + +// unforcedSearchEnclosures is the enclosure plan the control allocation is computed from: `plain` when +// the caller supplied it, and otherwise `enclosures` with the forced entries blanked out. +// +// The fallback is a degradation, not an equivalent: a rank whose forced unit was blanked gets no +// control upgrade at all, so the control plan understates what the ordinary path would have delivered +// and the invariant protects slightly less than it could. It exists so that direct API callers and +// tests cannot accidentally get the OLD destructive behaviour by omitting an argument. +func unforcedSearchEnclosures(enclosures, plain []searchEnclosure) []searchEnclosure { + if len(plain) == len(enclosures) { + return plain + } + out := make([]searchEnclosure, len(enclosures)) + for index, enclosure := range enclosures { + if !enclosure.forced { + out[index] = enclosure + } + } + return out +} + +// unreanchoredSearchEnclosures is the enclosure plan the re-anchor's CONTROL allocation is computed +// from: `enclosures` with every entry blanked that exists only because its hit was re-anchored onto +// code (search_reanchor.go). It also returns the mask of those ranks — they are the ones exempt from +// the comparison, being the whole point of the trial — and whether there was anything to gate at +// all, so the ordinary payload pays for no second allocation. +func unreanchoredSearchEnclosures( + results []SearchResult, enclosures []searchEnclosure, +) ([]searchEnclosure, []bool, bool) { + if len(results) != len(enclosures) { + return nil, nil, false + } + gated := false + mask := make([]bool, len(enclosures)) + out := make([]searchEnclosure, len(enclosures)) + for index, enclosure := range enclosures { + if results[index].CommentFocusLine > 0 && enclosure.available() && !enclosure.forced { + mask[index], gated = true, true + continue + } + out[index] = enclosure + } + return out, mask, gated +} + +// markSearchReanchorGainedBodies flags the ranks whose source exists ONLY because the hit was +// re-anchored — the control allocation showed nothing there and the accepted one does. +// +// The distinction is the whole reason this is computed here rather than inferred from +// CommentFocusLine downstream: a re-anchored hit very often had a body ALREADY (the anchor moved a +// line or two inside a body the allocator had already bought), and gating that body would evict +// source the re-anchor never paid for. Measured on fluent__fluentd-4655 and facebook__docusaurus-9183: +// treating every re-anchored hit as a debtor turned two 30-line method bodies into locators. +func markSearchReanchorGainedBodies(candidate, control []SearchResult, exempt []bool) { + if len(candidate) != len(control) { + return + } + for index := range candidate { + if index >= len(exempt) || !exempt[index] { + continue + } + if !searchResultRendersSource(control, index) && searchResultRendersSource(candidate, index) { + candidate[index].BodyFromReanchor = true + } + } +} + +// searchAllocationPreservesSource reports whether `candidate` shows every rank at least as much +// source as `control` does. Ranks named by `exempt` are skipped: they are the ranks the trial was +// run to improve, and they are allowed to gain. +// +// "At least as much" is measured on the PRINTED span, not on the signals, because that is what a +// reader loses: a rank that keeps `complete-symbol` while its snippet narrows has still been +// demoted. +func searchAllocationPreservesSource(control, candidate []SearchResult, exempt []bool) bool { + if len(control) != len(candidate) { + return false + } + for index := range control { + if index < len(exempt) && exempt[index] { + continue + } + if !searchResultRendersSource(control, index) { + continue + } + if !searchResultRendersSource(candidate, index) || + candidate[index].SnippetStartLine > control[index].SnippetStartLine || + candidate[index].SnippetEndLine < control[index].SnippetEndLine { + return false + } + } + return true +} + +// seatForcedSearchUnits adds the --full-unit-top bodies to an allocation that is already final, +// without ever taking anything away from it. +// +// THE INVARIANT: a forced unit may not reduce the source any other rank renders. Not its body, not its +// enclosure, not its head window, not its snippet — nothing a reader would have seen. This is the +// whole fix for the measured regression documented above the constants: the previous version demoted +// the tail to locators FIRST and then let the leftover fund the non-forced head, which is how +// redis__redis-11734's rank-4 `bitposCommand` body became a bare locator to pay for a header window at +// rank 2. +// +// So a forced unit is funded from exactly two places: +// +// 1. FREE BUDGET — whatever the control plan left under --max-context-bytes. +// 2. DEAD WEIGHT — the snippet bytes of tail ranks the text renderer does not print anyway. A result +// below searchRenderedSnippetHeadRanks that the allocator did not upgrade renders as +// `N. path:line symbol`, so its snippet is bytes the reader never sees; reclaiming them costs +// nothing visible. Reclaiming is LAZY: it stops as soon as the unit fits. +// +// If the whole unit does not fit in those, the WIDEST clipped form that does is seated instead — but +// only while it stays above searchForcedUnitMinLines and still covers everything control delivered at +// that rank. Otherwise the rank is left exactly as control had it. +func seatForcedSearchUnits( + control, ranked []SearchResult, + enclosures []searchEnclosure, + hardBudget, forcedEnd, bodies, demoted int, +) ([]SearchResult, int, int) { + plan := append([]SearchResult(nil), control...) + sizes := make([]int, len(plan)) + for index := range plan { + sizes[index] = serializedSearchResultBytes(plan[index]) + } + total := searchResultsSize(sizes) + for index := 0; index < forcedEnd && index < len(plan); index++ { + enclosure := enclosures[index] + if !enclosure.available() { + continue + } + // Non-destructive at the rank itself. Control may already show MORE than the unit spans — a + // budget-driven window, a padded body, a merged region — and widening to the unit alone would + // then SHRINK the rank. So the enclosure is extended to the UNION instead: never fewer lines + // than control, still named and signalled as the unit it contains. That is the same trade + // EnclosureContextLines already makes when it pads a complete body with its margin. + if enclosure.start > control[index].SnippetStartLine || enclosure.end < control[index].SnippetEndLine { + union := enclosure + union.start = minInt(enclosure.start, maxInt(1, control[index].SnippetStartLine)) + union.end = maxInt(enclosure.end, control[index].SnippetEndLine) + if union.end > len(enclosure.lines) || union.end-union.start+1 > searchFullUnitMaxLines { + continue + } + enclosure = union + } + trialPlan := append([]SearchResult(nil), plan...) + trialSizes := append([]int(nil), sizes...) + trialTotal, trialDemoted := total, 0 + need := serializedSearchResultBytes(widenSearchResultToEnclosure(ranked[index], enclosure)) + for tail := len(trialPlan) - 1; tail > index; tail-- { + if hardBudget <= 0 || trialTotal-trialSizes[index]+need <= hardBudget { + break + } + if searchResultRendersSource(control, tail) { + continue + } + terse := tersifySearchResult(trialPlan[tail], searchEnclosureTailSnippetLines) + size := serializedSearchResultBytes(terse) + if size >= trialSizes[tail] { + continue + } + trialTotal += size - trialSizes[tail] + trialPlan[tail], trialSizes[tail] = terse, size + trialDemoted++ + } + widened, ok := widestAffordableForcedUnit( + ranked[index], control[index], enclosure, hardBudget, trialTotal-trialSizes[index], + ) + if !ok { + continue + } + size := serializedSearchResultBytes(widened) + trialTotal += size - trialSizes[index] + trialPlan[index], trialSizes[index] = widened, size + if hasSearchSignal(widened, searchCompleteSymbolSignal) && + !hasSearchSignal(control[index], searchCompleteSymbolSignal) { + bodies++ + } + plan, sizes, total = trialPlan, trialSizes, trialTotal + demoted += trialDemoted + } + return plan, maxInt(0, bodies), demoted +} + +// spendRemainingSearchBudget is rule 1 of budget-driven rendering: after everything else has been +// seated, keep expanding ranked hits IN RANK ORDER while the ceiling can hold the next one. +// +// It is purely additive — it never demotes, never drops, never reorders — so it cannot break the +// no-demotion invariant or the "every rank present without flags stays present" guarantee. What it +// fixes is the opposite failure: a payload that renders a 127-line gold body as a 34 B locator while +// 74% of its byte ceiling is unspent. Rank order is the spend order because rank order is the order an +// agent reads, and an unspent byte is worth nothing at all. +func spendRemainingSearchBudget( + plan, ranked []SearchResult, + enclosures []searchEnclosure, + hardBudget int, +) ([]SearchResult, int) { + if hardBudget <= 0 || len(plan) != len(enclosures) || len(plan) != len(ranked) { + return plan, 0 + } + sizes := make([]int, len(plan)) + for index := range plan { + sizes[index] = serializedSearchResultBytes(plan[index]) + } + total := searchResultsSize(sizes) + added := 0 + for index := range plan { + if !enclosures[index].available() || searchResultRendersSource(plan, index) { + continue + } + widened, ok := widestAffordableForcedUnit( + ranked[index], plan[index], enclosures[index], hardBudget, total-sizes[index], + ) + if !ok { + continue + } + size := serializedSearchResultBytes(widened) + total += size - sizes[index] + plan[index], sizes[index] = widened, size + if !enclosures[index].window { + added++ + } + } + return plan, added +} + +// searchResultRendersSource reports whether a reader will SEE this rank's snippet, which is what makes +// its bytes reclaimable or not. Two ways to be visible: sitting in the head the text renderer prints +// unconditionally, or carrying one of the signals that tells the renderer the allocator paid for this +// source on purpose. Everything else renders as a locator, so its snippet bytes are dead weight. +func searchResultRendersSource(plan []SearchResult, index int) bool { + if index < searchRenderedSnippetHeadRanks { + return true + } + for _, signal := range plan[index].Signals { + switch signal { + case searchCompleteSymbolSignal, searchFullUnitSignal, searchHeadWindowSignal, searchCalleeHopSignal: + return true + } + } + return false +} + +// widestAffordableForcedUnit returns the largest form of a forced unit that fits `hardBudget` given +// `otherBytes` already spent on every other rank, or ok=false when nothing worth seating does. +// +// A read window is all-or-nothing: it is already a bounded excerpt, and clipping an excerpt produces a +// smaller excerpt rather than a different kind of answer. A real unit degrades instead — the clip is +// anchored the same way planForcedSearchUnit anchors it and reports the same elision — but never below +// searchForcedUnitMinLines and never below what control already showed at that rank, because a clipped +// unit that is smaller than the window it replaced is a pure loss. +func widestAffordableForcedUnit( + ranked, control SearchResult, + enclosure searchEnclosure, + hardBudget, otherBytes int, +) (SearchResult, bool) { + fits := func(candidate SearchResult) bool { + return hardBudget <= 0 || otherBytes+serializedSearchResultBytes(candidate) <= hardBudget + } + if full := widenSearchResultToEnclosure(ranked, enclosure); fits(full) { + return full, true + } + if enclosure.window { + return SearchResult{}, false + } + unitLines := enclosure.end - enclosure.start + 1 + // The floor is searchForcedUnitMinLines for a FORCED unit (a trade the caller asked for, so it has + // to buy something substantial) and searchBudgetWindowMinLines for a budget-greedy expansion (which + // trades nothing away, so anything better than a bare locator is a gain). + minimum := searchForcedUnitMinLines + if !enclosure.forced { + minimum = searchBudgetWindowMinLines + } + floor := maxInt(minimum, control.SnippetEndLine-control.SnippetStartLine+1) + for lines := unitLines * 3 / 4; lines >= floor; lines = lines * 3 / 4 { + trial := enclosure + if trial.unitStart == 0 { + trial.unitStart, trial.unitEnd = enclosure.start, enclosure.end + } + trial.start, trial.end = clipSearchUnitToCap(enclosure.start, enclosure.end, ranked.FocusLine, lines) + if trial.start > control.SnippetStartLine || trial.end < control.SnippetEndLine { + return SearchResult{}, false + } + if candidate := widenSearchResultToEnclosure(ranked, trial); fits(candidate) { + return candidate, true + } + if next := lines * 3 / 4; next >= lines { + break + } + } + return SearchResult{}, false +} + // searchHeadWindowValue scores the read windows in a plan. A window is worth strictly less than a // complete body at the same rank — it does not carry the "no follow-up read needed" promise — so it // is discounted to a quarter. That keeps a plan that completes a body always preferred over one that @@ -517,3 +1339,15 @@ func planWithDemotionFrom( } return plan, bodies, windows, total } + +// removeSearchSignal drops one signal from a list, returning a fresh slice so a caller degrading a +// result cannot mutate the signals of the version it degraded from. +func removeSearchSignal(signals []string, drop string) []string { + out := make([]string, 0, len(signals)) + for _, signal := range signals { + if signal != drop { + out = append(out, signal) + } + } + return out +} diff --git a/internal/sem/search_enclosure_test.go b/internal/sem/search_enclosure_test.go index 20d2b510..33c8449b 100644 --- a/internal/sem/search_enclosure_test.go +++ b/internal/sem/search_enclosure_test.go @@ -124,7 +124,7 @@ func TestPlanSearchEnclosuresResolvesTrueSymbolBounds(t *testing.T) { byFile[record.FilePath] = []SymbolRecord{record} } enclosures := planSearchEnclosures( - []SearchResult{testCase.result}, byID, byFile, reader, testCase.maxLines, 0, 0, 0, + []SearchResult{testCase.result}, byID, byFile, reader, testCase.maxLines, 0, 0, 0, 0, false, ) if len(enclosures) != 1 { t.Fatalf("enclosures = %d, want 1", len(enclosures)) @@ -350,7 +350,7 @@ func TestAllocateSearchSnippetsSpendsBudgetByRank(t *testing.T) { } before := serializedSearchResultBytes(results) allocated, bodies, demoted := allocateSearchSnippets( - results, enclosures, testCase.hardBudget, testCase.growth, testCase.headRanks, testCase.tailLines, + results, enclosures, nil, testCase.hardBudget, testCase.growth, testCase.headRanks, testCase.tailLines, ) after := serializedSearchResultBytes(allocated) @@ -428,7 +428,7 @@ func TestSearchEnclosureHeadRanksIsFiveDeep(t *testing.T) { } results, enclosures, _ := makeAllocatorResults(8, 6, 30) _, bodies, _ := allocateSearchSnippets( - results, enclosures, 1<<20, searchEnclosureGrowthBytes, + results, enclosures, nil, 1<<20, searchEnclosureGrowthBytes, searchEnclosureHeadRanks, searchEnclosureTailSnippetLines, ) if bodies != searchEnclosureHeadRanks { @@ -700,7 +700,7 @@ func TestPlanSearchEnclosuresBodyHeadRanksBoundsBodies(t *testing.T) { } results := []SearchResult{result, result, result, result, result} - enclosures := planSearchEnclosures(results, byID, nil, reader, 0, 0, 2, 0) + enclosures := planSearchEnclosures(results, byID, nil, reader, 0, 0, 2, 0, 0, false) if len(enclosures) != len(results) { t.Fatalf("enclosures = %d, want %d — narrowing the body head must not drop results", len(enclosures), len(results)) @@ -718,7 +718,7 @@ func TestPlanSearchEnclosuresBodyHeadRanksBoundsBodies(t *testing.T) { } // 0 means "use the built-in depth", so the default behaviour is unchanged. - for index, enclosure := range planSearchEnclosures(results, byID, nil, reader, 0, 0, 0, 0) { + for index, enclosure := range planSearchEnclosures(results, byID, nil, reader, 0, 0, 0, 0, 0, false) { if !enclosure.available() { t.Fatalf("rank %d: default depth should still plan a body", index+1) } @@ -740,7 +740,7 @@ func TestPlanSearchEnclosuresContextLinesPadsRankOneOnly(t *testing.T) { SnippetStartLine: 60, SnippetEndLine: 64, SymbolID: symbol.ID, } - got := planSearchEnclosures([]SearchResult{result, result}, byID, nil, reader, 0, 10, 0, 0) + got := planSearchEnclosures([]SearchResult{result, result}, byID, nil, reader, 0, 10, 0, 0, 0, false) if got[0].start != 30 || got[0].end != 100 { t.Fatalf("rank 1 = %d-%d, want 30-100 (symbol 40-90 padded by 10)", got[0].start, got[0].end) } @@ -753,7 +753,7 @@ func TestPlanSearchEnclosuresContextLinesPadsRankOneOnly(t *testing.T) { // A margin that would push the body past the line cap is refused, not truncated: the cap is // what guarantees a padded body can never cost more than an unpadded one was allowed to. - capped := planSearchEnclosures([]SearchResult{result}, byID, nil, reader, 60, 40, 0, 0) + capped := planSearchEnclosures([]SearchResult{result}, byID, nil, reader, 60, 40, 0, 0, 0, false) if capped[0].start != 40 || capped[0].end != 90 { t.Fatalf("capped = %d-%d, want the unpadded 40-90", capped[0].start, capped[0].end) } @@ -766,7 +766,7 @@ func TestPlanSearchEnclosuresContextLinesPadsRankOneOnly(t *testing.T) { SnippetStartLine: 20, SnippetEndLine: 24, SymbolID: edgeSymbol.ID, }}, map[string]SymbolRecord{edgeSymbol.ID: edgeSymbol}, nil, - enclosureTestReader(edgeLines), 0, 25, 0, 0, + enclosureTestReader(edgeLines), 0, 25, 0, 0, 0, false, ) if edge[0].start != 1 || edge[0].end != 50 { t.Fatalf("edge = %d-%d, want 1-50 (clamped to the file)", edge[0].start, edge[0].end) @@ -793,11 +793,11 @@ func TestPlanSearchEnclosuresHeadWindowNeverClaimsCompleteSymbol(t *testing.T) { } // windowLines=0 keeps the old behaviour: a container yields no enclosure at all. - if got := planSearchEnclosures([]SearchResult{result}, byID, byFile, reader, 0, 0, 0, 0); got[0].available() { + if got := planSearchEnclosures([]SearchResult{result}, byID, byFile, reader, 0, 0, 0, 0, 0, false); got[0].available() { t.Fatalf("windowLines=0 must not synthesise an enclosure: %+v", got[0]) } - got := planSearchEnclosures([]SearchResult{result}, byID, byFile, reader, 0, 0, 0, 60) + got := planSearchEnclosures([]SearchResult{result}, byID, byFile, reader, 0, 0, 0, 60, 0, false) if !got[0].available() { t.Fatal("want a window enclosure for an unenclosable head rank") } @@ -822,7 +822,7 @@ func TestPlanSearchEnclosuresHeadWindowNeverClaimsCompleteSymbol(t *testing.T) { // readable lines, never re-cut a wider snippet. wide := result wide.SnippetStartLine, wide.SnippetEndLine = 1, 200 - if got := planSearchEnclosures([]SearchResult{wide}, byID, byFile, reader, 0, 0, 0, 60); got[0].available() { + if got := planSearchEnclosures([]SearchResult{wide}, byID, byFile, reader, 0, 0, 0, 60, 0, false); got[0].available() { t.Fatalf("window must not shrink an already-wider snippet: %+v", got[0]) } } diff --git a/internal/sem/search_literals.go b/internal/sem/search_literals.go index 0ec5d272..1f88b38a 100644 --- a/internal/sem/search_literals.go +++ b/internal/sem/search_literals.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "sort" + "strconv" "strings" "unicode" ) @@ -93,6 +94,47 @@ const ( // searchLiteralAnchorMaxLines bounds how much of the top hit's region is mined for literals. A // whole-file "symbol" must not turn this into a corpus scan. searchLiteralAnchorMaxLines = 200 + + // EDIT-SITE BODIES (--edit-site-bodies, off by default) + // ----------------------------------------------------- + // + // The block's default form is a list of locations, and that is the right default: it exists to + // replace a repo-wide grep, and a grep's answer is locations. But an EDIT-role site is not + // listed for navigation — it is listed because "a change to the concept normally lands here", + // which means the agent's next action is an edit, and an edit needs the text it replaces. So + // under this flag the EDIT sites carry source and the others do not: a CONSUMER is listed + // precisely so the agent can decide NOT to open it, and printing its body would spend bytes + // arguing against the block's own advice. + // + // searchLiteralEditBodyMaxLines caps ONE site. It is deliberately a third of the ranked head's + // safety cap: a literal site is a place to look, and 60 lines is the measured median window an + // agent asks for when it re-opens a payload file. + searchLiteralEditBodyMaxLines = 60 + + // searchLiteralEditBodySites caps how many sites get a body, independently of how many the block + // lists. searchLiteralHitLimit already bounds the list below this in the default configuration; + // the separate cap is what keeps that a tuning decision rather than the only thing standing + // between this block and an unbounded payload. + searchLiteralEditBodySites = 8 + + // searchLiteralEditBodyMaxBytes is the per-site BYTE backstop, and it exists because the line cap + // above is not one. redis's src/commands.c is one command per line at ~400 B a line, so the + // literal's EDIT site there is a 25-line window worth 9.5 kB — a third of the whole payload + // ceiling spent on one grep result. 3 kB holds roughly a 60-line ordinary body, which is what the + // line cap was sized for, and clips a generated table to the rows around the hit instead. + searchLiteralEditBodyMaxBytes = 3072 + + // searchLiteralEditBodyContextLines is the window when NO indexed unit encloses the site. That + // happens on exactly the sites this flag is for — an unindented top-level line in a constant + // table or a generated registry, which searchLiteralRole calls EDIT from the text alone. + searchLiteralEditBodyContextLines = 12 + + // searchLiteralEditBodyClusterMaxBytes is the block's cap once bodies are attached. The default + // 560 B cap is sized for a list of locations and would drop every body; this one is sized for + // the worst case the two caps above allow (8 sites x 60 lines) and is applied instead — never + // as well — so the block still has exactly one stated ceiling. It is opt-in, additive, and its + // cost is reported in stats.literal_cluster_bytes like every other block's. + searchLiteralEditBodyClusterMaxBytes = 12 * 1024 ) // Literal roles. Three words, because three is what the agent's own account needed: where the @@ -134,6 +176,17 @@ type SearchLiteralHit struct { // Symbol is the enclosing symbol's name, empty for a documentation hit. Symbol string `json:"symbol,omitempty"` Role string `json:"role"` + // Body is the site's source, present only on an EDIT-role site and only under + // --edit-site-bodies. It is a VERBATIM slice of the file with no line-number prefixes: agents + // copy it as the `old_string` anchor of an edit, and an inline number breaks that anchor. The + // range lives in BodyStartLine/BodyEndLine, which is where the header prints it from. + Body string `json:"body,omitempty"` + BodyStartLine int `json:"body_start_line,omitempty"` + BodyEndLine int `json:"body_end_line,omitempty"` + // BodyUnitStartLine/BodyUnitEndLine are the enclosing unit's TRUE span when the per-site line cap + // clipped the printed body out of a larger unit. Set only when something was elided. + BodyUnitStartLine int `json:"body_unit_start_line,omitempty"` + BodyUnitEndLine int `json:"body_unit_end_line,omitempty"` } // buildSearchLiteralCluster picks the literal and locates it, or returns nil. @@ -141,12 +194,15 @@ type SearchLiteralHit struct { // It returns nil far more often than not, and every one of those refusals is deliberate: no // distinctive literal in the top hit, no query word inside it, no exact repository-wide count // available, or a count that proves the literal is a magnet. +// editBodies is --edit-site-bodies: attach source to the EDIT-role sites and price the block under +// its own, larger cap. func buildSearchLiteralCluster( results []SearchResult, q searchQuery, symbolsByFile map[string][]SymbolRecord, index *searchNeedleIndex, maxBytes int, + editBodies bool, ) *SearchLiteralCluster { if index == nil || maxBytes <= 0 { return nil @@ -176,6 +232,12 @@ func buildSearchLiteralCluster( if cluster == nil { continue } + if editBodies { + attachSearchLiteralEditBodies(cluster, symbolsByFile, index) + } + // The fitter derives the applicable cap from the cluster itself, so it is the same number + // here and in validateSearchContextBlockBudget: a block sized under one ceiling and checked + // against another is how a byte contract silently breaks. if fitted := fitSearchLiteralCluster(cluster, maxBytes); fitted != nil { return fitted } @@ -183,6 +245,98 @@ func buildSearchLiteralCluster( return nil } +// searchLiteralClusterCap is the ceiling that applies to one cluster: the caller's default for a list +// of locations, and searchLiteralEditBodyClusterMaxBytes once any site carries source. It is a +// function of the cluster so every place that has to know the cap agrees on it. +func searchLiteralClusterCap(cluster *SearchLiteralCluster, defaultMaxBytes int) int { + if searchLiteralClusterHasBodies(cluster) { + return searchLiteralEditBodyClusterMaxBytes + } + return defaultMaxBytes +} + +func searchLiteralClusterHasBodies(cluster *SearchLiteralCluster) bool { + if cluster == nil { + return false + } + for _, hit := range cluster.Hits { + if hit.Body != "" { + return true + } + } + return false +} + +// attachSearchLiteralEditBodies gives every EDIT-role site its source, up to +// searchLiteralEditBodySites sites. Files are read through the needle index's shared read budget, so +// a site whose file the literal scan already hydrated costs no extra IO and a site past the budget is +// simply left as a locator — a missing body degrades the block, it never fails the search. +func attachSearchLiteralEditBodies( + cluster *SearchLiteralCluster, + symbolsByFile map[string][]SymbolRecord, + index *searchNeedleIndex, +) { + if cluster == nil || index == nil { + return + } + sites := 0 + for position := range cluster.Hits { + if sites >= searchLiteralEditBodySites { + return + } + hit := &cluster.Hits[position] + if hit.Role != SearchLiteralRoleEdit { + continue + } + lines, ok := index.readLines(hit.FilePath) + if !ok { + continue + } + body, ok := searchLiteralEditBody(symbolsByFile[hit.FilePath], hit.Line, lines) + if !ok { + continue + } + hit.Body = body.Body + hit.BodyStartLine, hit.BodyEndLine = body.BodyStartLine, body.BodyEndLine + hit.BodyUnitStartLine, hit.BodyUnitEndLine = body.BodyUnitStartLine, body.BodyUnitEndLine + sites++ + } +} + +// searchLiteralEditBody cuts one EDIT site's source. +// +// The unit wins when the graph has one — ANY enclosing symbol, not just a callable, because an EDIT +// site is by construction outside a callable body (that is what makes it an EDIT rather than a +// CONSUMER). When no indexed symbol covers the line the site is a top-level line the parser did not +// resolve, and a bounded window around it is the widest honest answer. +func searchLiteralEditBody(symbols []SymbolRecord, line int, lines []string) (SearchLiteralHit, bool) { + start, end := 0, 0 + if unit, found := smallestSearchSymbolContainingLineWhere(symbols, line, nil); found { + start, end = clampRegion(unit.StartLine, unit.EndLine, len(lines)) + } + if start == 0 { + start, end = clampRegion( + line-searchLiteralEditBodyContextLines, line+searchLiteralEditBodyContextLines, len(lines), + ) + } + if start == 0 { + return SearchLiteralHit{}, false + } + body := SearchLiteralHit{} + unitStart, unitEnd := start, end + start, end = clipSearchUnitToCap(start, end, line, searchLiteralEditBodyMaxLines) + start, end = clipSearchUnitToBytes(lines, start, end, line, searchLiteralEditBodyMaxBytes) + if unitStart < start || unitEnd > end { + body.BodyUnitStartLine, body.BodyUnitEndLine = unitStart, unitEnd + } + body.BodyStartLine, body.BodyEndLine = start, end + body.Body = strings.Join(lines[start-1:end], "\n") + if strings.TrimSpace(body.Body) == "" { + return SearchLiteralHit{}, false + } + return body, true +} + // searchLiteralCandidate is a candidate literal together with the file set that can contain it. type searchLiteralCandidate struct { literal string @@ -556,6 +710,15 @@ func classifySearchLiteralHits( }) } cluster.Unclassified = len(unclassifiedFiles) + // AT LEAST ONE LIVE EDIT SITE, or the block does not exist. + // + // The block's promise is "these are the other places this concept is named, and the EDIT ones are + // where a change lands". Measured on nushell: all three EDIT sites were COMMENTED-OUT code, so the + // block sent the agent to patch a comment. A commented or string-literal occurrence is real text and + // a useless fix site, and the distinction is cheap to make from the line itself. + if !searchLiteralClusterHasLiveEditSite(cluster, scan) { + return nil + } // One listed site is enough once the repository-wide totals are in the header: the totals are the // answer to "have I seen everywhere this concept is named", and a single site the payload did not // already print is still a grep the agent does not have to run. @@ -631,9 +794,25 @@ func searchLiteralSymbolName(symbol SymbolRecord) string { // fitSearchLiteralCluster shrinks the block until it fits its cap, dropping hits from the END so // the earliest (path-ordered, hence definition-first in most layouts) survive. It never rewrites // HitsTotal: the whole point is that the count stays the repository's count while the list shrinks. +// Bodies yield BEFORE hits do: a site is a grep the agent does not have to run whether or not its +// source is printed, so dropping a body costs less than dropping the site itself. They yield from the +// END for the same reason hits do — the earliest sites are the definition-first ones. +// maxBytes is the cap for a cluster of LOCATIONS. A cluster carrying bodies is held to +// searchLiteralEditBodyClusterMaxBytes instead, and the applicable cap is re-derived after every +// reduction — so a cluster that loses its last body falls back under the location cap here rather +// than shipping over it and failing the contract check. func fitSearchLiteralCluster(cluster *SearchLiteralCluster, maxBytes int) *SearchLiteralCluster { + for searchLiteralClusterCost(cluster) > searchLiteralClusterCap(cluster, maxBytes) { + position := lastSearchLiteralBody(cluster) + if position < 0 { + break + } + cluster.Hits[position].Body = "" + cluster.Hits[position].BodyStartLine, cluster.Hits[position].BodyEndLine = 0, 0 + cluster.Hits[position].BodyUnitStartLine, cluster.Hits[position].BodyUnitEndLine = 0, 0 + } for len(cluster.Hits) >= 1 { - if searchLiteralClusterCost(cluster) <= maxBytes { + if searchLiteralClusterCost(cluster) <= searchLiteralClusterCap(cluster, maxBytes) { return cluster } cluster.Hits = cluster.Hits[:len(cluster.Hits)-1] @@ -641,6 +820,15 @@ func fitSearchLiteralCluster(cluster *SearchLiteralCluster, maxBytes int) *Searc return nil } +func lastSearchLiteralBody(cluster *SearchLiteralCluster) int { + for position := len(cluster.Hits) - 1; position >= 0; position-- { + if cluster.Hits[position].Body != "" { + return position + } + } + return -1 +} + // searchLiteralClusterCost measures the block on the LARGER of its two wire forms, exactly as the // container map is measured: a caller pays whichever form it asked for. func searchLiteralClusterCost(cluster *SearchLiteralCluster) int { @@ -679,6 +867,11 @@ func searchLiteralClusterHeader(cluster *SearchLiteralCluster) string { // RenderSearchLiteralCluster renders the block for a text reader. One line per hit, the role last // so the column an agent scans is the one that says "do I have to open this". +// +// A site carrying a body (EDIT role, --edit-site-bodies) prints its RANGE rather than its single +// line — `path:START-END symbol EDIT` — because the range is what describes the source underneath, +// and then the source itself, unprefixed and verbatim, followed by the elision note when the line +// cap clipped it. Sites without a body keep the single-line locator form exactly as before. func RenderSearchLiteralCluster(cluster *SearchLiteralCluster) []byte { if cluster == nil || len(cluster.Hits) == 0 { return nil @@ -686,11 +879,79 @@ func RenderSearchLiteralCluster(cluster *SearchLiteralCluster) []byte { var buffer strings.Builder buffer.WriteString(searchLiteralClusterHeader(cluster) + "\n") for _, hit := range cluster.Hits { - fmt.Fprintf(&buffer, " %s:%d", hit.FilePath, hit.Line) + if hit.Body != "" { + fmt.Fprintf(&buffer, " %s:%d-%d", hit.FilePath, hit.BodyStartLine, hit.BodyEndLine) + } else { + fmt.Fprintf(&buffer, " %s:%d", hit.FilePath, hit.Line) + } if hit.Symbol != "" { fmt.Fprintf(&buffer, " %s", hit.Symbol) } fmt.Fprintf(&buffer, " %s\n", hit.Role) + if hit.Body == "" { + continue + } + buffer.WriteString(hit.Body + "\n") + if note := SearchUnitElisionNote( + hit.BodyStartLine, hit.BodyEndLine, hit.BodyUnitStartLine, hit.BodyUnitEndLine, + ); note != "" { + buffer.WriteString(note + "\n") + } + buffer.WriteString("\n") } return []byte(buffer.String()) } + +// searchLiteralClusterHasLiveEditSite reports whether any EDIT-role site is LIVE CODE rather than a +// comment, a docstring or the inside of a string literal. +// +// The test is on the occurrence's own line text, which the needle scan already carries, so it costs no +// IO. It is deliberately about the LINE and not the language: a comment lead-in is a comment in every +// language in scope, and a line whose only content is a quoted string is data. Both are places the +// concept is NAMED and neither is a place a patch lands. +func searchLiteralClusterHasLiveEditSite(cluster *SearchLiteralCluster, scan searchNeedleScan) bool { + text := make(map[string]string, len(scan.hits)) + for _, hit := range scan.hits { + text[hit.filePath+":"+strconv.Itoa(hit.line)] = hit.text + } + sawEdit := false + for _, hit := range cluster.Hits { + if hit.Role != SearchLiteralRoleEdit { + continue + } + sawEdit = true + if searchLiteralLineIsLiveCode(text[hit.FilePath+":"+strconv.Itoa(hit.Line)]) { + return true + } + } + // A cluster with no EDIT site at all is unaffected: it is a CONSUMER/DOC listing, which is a + // legitimate answer to "where else is this named" and never claimed to name a fix site. + return !sawEdit +} + +// searchLiteralCommentLeads are the line lead-ins that make a line a comment in every language this +// package indexes. Docstring delimiters are included because a Python or Ruby docstring is prose. +var searchLiteralCommentLeads = []string{"//", "#", "--", "*", "/*", "%", ";;", `"""`, `'''`} + +// searchLiteralLineIsLiveCode reports whether a source line is executable text. An UNKNOWN line is +// treated as live: the scan is the authority on where the occurrence is, and refusing to classify is +// safer than suppressing a real block on missing evidence. +func searchLiteralLineIsLiveCode(line string) bool { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + return true + } + for _, lead := range searchLiteralCommentLeads { + if strings.HasPrefix(trimmed, lead) { + return false + } + } + // A line that is nothing but a quoted string (with an optional trailing comma) is data, not code. + if body := strings.TrimRight(trimmed, ","); len(body) > 1 { + first, last := body[0], body[len(body)-1] + if (first == byte('"') || first == byte(0x27) || first == byte('`')) && first == last { + return false + } + } + return true +} diff --git a/internal/sem/search_literals_test.go b/internal/sem/search_literals_test.go index 8e7904e1..7b415159 100644 --- a/internal/sem/search_literals_test.go +++ b/internal/sem/search_literals_test.go @@ -398,7 +398,7 @@ func TestSearchLiteralClusterRefusesALexicalMagnet(t *testing.T) { t.Parallel() results, q, symbolsByFile, index := searchLiteralMagnetFixture(testCase.files) cluster := buildSearchLiteralCluster(results, q, symbolsByFile, index, - searchLiteralClusterMaxBytes) + searchLiteralClusterMaxBytes, false) if (cluster != nil) != testCase.want { t.Fatalf("cluster present = %v, want %v (%s)", cluster != nil, testCase.want, RenderSearchLiteralCluster(cluster)) diff --git a/internal/sem/search_locator_window.go b/internal/sem/search_locator_window.go new file mode 100644 index 00000000..d6af103c --- /dev/null +++ b/internal/sem/search_locator_window.go @@ -0,0 +1,80 @@ +package sem + +import "strings" + +// THE NAMELESS LOCATOR. +// +// A ranked hit demoted to a locator keeps three things by design — path, line, and symbol name — +// and the name is what makes the line actionable: it is what the `[body: def NAME]` follow-up +// hands to `def`, and it is what tells a reader whether the file is worth opening at all. +// +// A hit whose focus line has no enclosing INDEXED symbol has no name to keep. Import blocks, C/C++ +// header prototype lists, CSS rules, `export { ... }` lists, `.d.ts` declarations, doc-comment +// examples and snapshot fixtures all land here. Demoted, such a hit renders as `N. path:line` and +// nothing else: no source, no symbol, and no follow-up verb, because there is no name to give one. +// It is the only line shape in the payload that carries no information beyond coordinates and +// suggests no next action, so the only way to act on it is the file read the payload exists to +// remove. +// +// MEASURED, 30 SWE-bench Multilingual instances at the shipped defaults (--top-k 10, +// --max-snippet-lines 6, --max-context-bytes 16384): 21 of 238 primary ranked lines (8.8%) render +// this way, across 12 of the 30 instances, at ranks 3 through 10. Every one of the 21 ALREADY +// carried source in the ranked result — mean 3.2 lines, 2,245 bytes in total across all 30 payloads +// — which the JSON emits and the text renderer discarded. On scikit-learn-14629 the discarded window +// was `sklearn/multioutput.py:22-26`, whose focus line is `from .model_selection import +// cross_val_predict`: the exact import coupling the issue is about, in the file the fix lands in, +// thrown away to save 273 bytes. +// +// So the fix is not to fetch anything. It is to stop discarding what the ranker already paid for +// when the alternative is a line that says nothing. +// +// searchLocatorWindowLines bounds what such a hit keeps. Six lines is the same ceiling as the +// default --max-snippet-lines, so an ordinary ranked window passes through whole and the cap only +// ever bites on a window some other lever widened (a head window is 60 lines and can reach this +// path through the render diet's body cap). It is a CEILING, never a floor: this function never +// grows a window, so the payload can only ever gain the bytes the ranker had already allocated. +const searchLocatorWindowLines = 6 + +// SearchLocatorWindow returns the source a demoted, nameless hit should keep and the line range that +// source actually spans, clipped to searchLocatorWindowLines around the hit's focus line. +// +// The returned range describes exactly the lines returned with it — never the ranked region — for +// the same reason searchResultPrintedRange exists in the renderer: a header naming a span the source +// below it does not contain sends the reader to the wrong lines, which is worse than sending them +// nowhere. +// +// It returns an empty string when the result carries no source, which is the caller's signal to fall +// back to the bare locator. Deciding whether a hit is nameless is deliberately NOT done here: the +// display-name rule (qualified name first, then symbol name) belongs to the renderer that owns the +// locator shape. +func SearchLocatorWindow(result SearchResult) (int, int, string) { + if result.Snippet == "" { + return 0, 0, "" + } + start := result.SnippetStartLine + if start <= 0 { + start = result.StartLine + } + if start <= 0 { + start = 1 + } + lines := strings.Split(result.Snippet, "\n") + if len(lines) <= searchLocatorWindowLines { + return start, start + len(lines) - 1, result.Snippet + } + // Centre the clip on the matched line when the snippet contains it, so the one line the query + // actually hit can never be the line the cap removes. Otherwise keep the head of the window, + // which is where a snippet without a usable focus reads from. + first := 0 + if last := start + len(lines) - 1; result.FocusLine >= start && result.FocusLine <= last { + first = result.FocusLine - start - (searchLocatorWindowLines-1)/2 + } + if limit := len(lines) - searchLocatorWindowLines; first > limit { + first = limit + } + if first < 0 { + first = 0 + } + clipped := lines[first : first+searchLocatorWindowLines] + return start + first, start + first + searchLocatorWindowLines - 1, strings.Join(clipped, "\n") +} diff --git a/internal/sem/search_locator_window_test.go b/internal/sem/search_locator_window_test.go new file mode 100644 index 00000000..91527451 --- /dev/null +++ b/internal/sem/search_locator_window_test.go @@ -0,0 +1,132 @@ +package sem + +import ( + "strings" + "testing" +) + +// The window a nameless hit keeps is bounded, is never grown, and always reports exactly the lines +// it returns — a header naming a span its own source does not contain sends the reader to the wrong +// lines, which is the failure the bare locator already had. +func TestSearchLocatorWindow(t *testing.T) { + t.Parallel() + wide := make([]string, 0, 60) + for index := 1; index <= 60; index++ { + wide = append(wide, "L") + } + wide[29] = "MATCHED" + for _, testCase := range []struct { + name string + result SearchResult + wantStart int + wantEnd int + wantSource string + }{ + { + // The scikit-learn__scikit-learn-14629 window, live from db24f74: five lines, inside the + // cap, so it passes through whole and the range is the ranker's own. + name: "short window passes through whole", + result: SearchResult{ + StartLine: 22, EndLine: 26, FocusLine: 24, SnippetStartLine: 22, SnippetEndLine: 26, + Snippet: "from .base import BaseEstimator\nfrom .base import ClassifierMixin\n" + + "from .model_selection import cross_val_predict\nfrom .utils import check_array\n" + + "from .utils.fixes import parallel_helper", + }, + wantStart: 22, wantEnd: 26, + wantSource: "from .base import BaseEstimator\nfrom .base import ClassifierMixin\n" + + "from .model_selection import cross_val_predict\nfrom .utils import check_array\n" + + "from .utils.fixes import parallel_helper", + }, + { + name: "no source means no window", + result: SearchResult{ + StartLine: 3135, EndLine: 3137, FocusLine: 3135, SnippetStartLine: 3135, SnippetEndLine: 3137, + }, + wantStart: 0, wantEnd: 0, wantSource: "", + }, + { + name: "a single line reports a single-line range", + result: SearchResult{ + StartLine: 30, EndLine: 30, FocusLine: 30, SnippetStartLine: 30, SnippetEndLine: 30, + Snippet: "shallowReactive,", + }, + wantStart: 30, wantEnd: 30, wantSource: "shallowReactive,", + }, + { + // A 60-line head window can reach the locator path through the render diet's body cap. + // It is clipped to the cap, centred so the matched line survives, and the reported range + // moves with the clip. + name: "a wide window is clipped around the matched line", + result: SearchResult{ + StartLine: 1, EndLine: 60, FocusLine: 30, SnippetStartLine: 1, SnippetEndLine: 60, + Snippet: strings.Join(wide, "\n"), + }, + wantStart: 28, wantEnd: 33, + wantSource: "L\nL\nMATCHED\nL\nL\nL", + }, + { + // A match at the very top clips forward, never off the front of the file. + name: "a match at the head clips forward", + result: SearchResult{ + StartLine: 1, EndLine: 60, FocusLine: 1, SnippetStartLine: 1, SnippetEndLine: 60, + Snippet: strings.Join(wide, "\n"), + }, + wantStart: 1, wantEnd: 6, wantSource: "L\nL\nL\nL\nL\nL", + }, + { + // A match at the very end clips backward, never past the end of the window. + name: "a match at the tail clips backward", + result: SearchResult{ + StartLine: 1, EndLine: 60, FocusLine: 60, SnippetStartLine: 1, SnippetEndLine: 60, + Snippet: strings.Join(wide, "\n"), + }, + wantStart: 55, wantEnd: 60, wantSource: "L\nL\nL\nL\nL\nL", + }, + { + // No usable focus line: keep the head of the window rather than guessing a centre. + name: "no focus keeps the head of the window", + result: SearchResult{ + StartLine: 1, EndLine: 60, SnippetStartLine: 1, SnippetEndLine: 60, + Snippet: strings.Join(wide, "\n"), + }, + wantStart: 1, wantEnd: 6, wantSource: "L\nL\nL\nL\nL\nL", + }, + { + // Without a snippet range the ranked region is the anchor, so the reported range is + // still the one the source actually spans. + name: "falls back to the ranked start line", + result: SearchResult{ + StartLine: 208, EndLine: 209, FocusLine: 208, + Snippet: "unsigned long raxTouch(raxNode *n);\nvoid raxStart(raxIterator *it);", + }, + wantStart: 208, wantEnd: 209, + wantSource: "unsigned long raxTouch(raxNode *n);\nvoid raxStart(raxIterator *it);", + }, + } { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + start, end, source := SearchLocatorWindow(testCase.result) + if start != testCase.wantStart || end != testCase.wantEnd { + t.Errorf("range = %d-%d, want %d-%d", start, end, testCase.wantStart, testCase.wantEnd) + } + if source != testCase.wantSource { + t.Errorf("source =\n%q\nwant\n%q", source, testCase.wantSource) + } + if source == "" { + return + } + // THE INVARIANT: the reported range describes exactly the lines returned with it. + if got := len(strings.Split(source, "\n")); got != end-start+1 { + t.Errorf("range %d-%d spans %d lines but %d were returned", start, end, end-start+1, got) + } + // It is a ceiling, never a floor: the window can only ever shrink. + if got := len(strings.Split(source, "\n")); got > searchLocatorWindowLines { + t.Errorf("returned %d lines, cap is %d", got, searchLocatorWindowLines) + } + if len(source) > len(testCase.result.Snippet) { + t.Errorf("window grew: %d bytes returned from a %d-byte snippet", + len(source), len(testCase.result.Snippet)) + } + }) + } +} diff --git a/internal/sem/search_reanchor.go b/internal/sem/search_reanchor.go new file mode 100644 index 00000000..5b369bfb --- /dev/null +++ b/internal/sem/search_reanchor.go @@ -0,0 +1,228 @@ +package sem + +import "strings" + +// Re-anchoring a hit that landed in a doc comment +// ============================================== +// +// A ranked region can match entirely inside prose. The commonest shape is a name mentioned in a +// doc comment — "@param commitQueue List of components which have callbacks to invoke in +// commitRoot" — which is a real, useful signal about WHICH unit is relevant and a useless place +// to point an agent at. Measured on preactjs__preact-3010: the gold file arrived as +// `src/diff/children.js:19`, a bodyless locator sitting in the JSDoc block of `diffChildren`, +// while the three body slots went to `benches/` and `karma.conf.js`. The payload had located the +// right file and then described it by quoting four lines of `@param` text. +// +// The fix is to move the ANCHOR, not the score: a hit whose focus line is a comment is +// re-anchored to the first code line the comment documents (the declaration below it, or the +// enclosing unit when there is nothing below), before body allocation runs. From there it is an +// ordinary code hit — the enclosure planner can find its callable, it competes for a body slot at +// its code location, and the SAME-CONCEPT LITERAL block mines identifiers out of program text +// instead of out of `@param` prose. +// +// Ranking is deliberately untouched. The comment match is why the hit scored what it scored, and +// re-scoring it here would change which files come back rather than how the ones that came back +// are described. The original line is kept in CommentFocusLine and printed as `focus=19->26`, so +// the evidence stays visible and the move is never silent. + +const ( + // searchDocReanchorMaxScan bounds how far below the matched comment line the re-anchor will + // look for the code that comment documents. A doc block that runs longer than this is prose in + // its own right (a license header, a design note), and the code after it is not what the + // comment is about. + searchDocReanchorMaxScan = 60 + + // searchDocReanchorMaxBlankRun is how many consecutive blank lines the scan will cross. One + // blank line inside a comment block is formatting; two mean the comment and the code below it + // are separate things. + searchDocReanchorMaxBlankRun = 1 +) + +// searchCommentLinePrefixes is the comment vocabulary the re-anchor recognises. It is deliberately +// the same list precedingDocumentationStart and searchConfidenceWrapperReason already use, so +// "this line is prose" means one thing across the payload. +var searchCommentLinePrefixes = []string{"//", "/*", "*/", "*", "#", "--", ";;", "%%", "