Files
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

742 lines
28 KiB
Go

package mcp
import (
"context"
"fmt"
"regexp"
"sort"
"strings"
"github.com/mark3labs/mcp-go/mcp"
"github.com/zzet/gortex/internal/elide"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/query"
"github.com/zzet/gortex/internal/search/rerank"
)
// exploreToolDescription is the one-shot localization verb's advertised
// contract — engineered to be the obvious opening move for any
// task-shaped request. It promises the whole first exploration phase in
// a single call so the agent never decomposes localization into a string
// of granular search/read/callers turns (the measured turn-economy loss).
const exploreToolDescription = "Start here for any task, bug report, or " +
"\"where is / how does X work\" question. Describe the request in plain " +
"words (paste the issue, name the area) and get the localized neighborhood " +
"in ONE call: the ranked likely-involved symbols with their source and call " +
"paths (callers + callees), plus the files to change — the whole exploration " +
"phase (5-10 search/read/callers calls) folded into one. Answer or edit " +
"straight from it; it states when the neighborhood is complete."
// explore tuning. These are generic retrieval parameters — fan-out
// widths and a token ceiling — with no dependence on any particular
// corpus, query vocabulary, or benchmark. The verb takes arbitrary free
// text; nothing here is derived from a fixed task set.
const (
exploreDefaultBudgetTokens = 9000
exploreMinBudgetTokens = 2000
exploreMaxBudgetTokens = 24000
exploreDefaultMaxSymbols = 10
exploreMaxMaxSymbols = 30
exploreRingCap = 5 // callers / callees shown per target
exploreCharsPerToken = 4 // coarse token estimate for budgeting
// exploreBodyBudgetShare caps any single full body at this fraction of
// the total budget, so one huge top-ranked symbol cannot starve the
// rest of the neighborhood of their bodies.
exploreBodyBudgetShare = 3
)
// registerExploreTool wires the one-shot localization verb into the tool
// surface. It ships eagerly in the coding-agent + core presets (see the
// preset roster in tool_presets.go) so it is the first thing a task-shaped
// session reaches for.
func (s *Server) registerExploreTool() {
s.addTool(
mcp.NewTool("explore",
mcp.WithDescription(exploreToolDescription),
mcp.WithString("task", mcp.Required(), mcp.Description("Natural-language description of the task, bug report, or question to localize (e.g. paste an issue body, or 'the retry backoff never triggers on a 429').")),
mcp.WithNumber("max_symbols", mcp.Description("Max ranked candidate symbols (default 10).")),
mcp.WithNumber("token_budget", mcp.Description("Response token ceiling (default 9000). Bodies pack until it fills, then demote to signatures; every candidate location is always listed.")),
mcp.WithString("repo", mcp.Description("Filter results to a specific repository prefix")),
mcp.WithString("path", mcp.Description("Restrict the neighborhood to one or more sub-paths (comma-separated), anchored at the repo root — a monorepo-service slice.")),
),
s.handleExplore,
)
}
// exploreTarget is one ranked candidate plus its 1-hop neighborhood,
// gathered before rendering so the renderer can honour the token budget.
type exploreTarget struct {
node *graph.Node
score float64
callers []*graph.Node
callees []*graph.Node
source string // full body (may be empty for non-source kinds)
}
// handleExplore is the one-shot localization verb: free text in, a ranked
// neighborhood (symbols + source + call paths + file map + completeness
// cue) out, bounded by a token budget, in a single response.
func (s *Server) handleExplore(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
task := strings.TrimSpace(req.GetString("task", ""))
if task == "" {
return mcp.NewToolResultError("task is required"), nil
}
maxSymbols := clampInt(req.GetInt("max_symbols", exploreDefaultMaxSymbols), 1, exploreMaxMaxSymbols)
budget := clampInt(req.GetInt("token_budget", exploreDefaultBudgetTokens), exploreMinBudgetTokens, exploreMaxBudgetTokens)
resolved, errResult := s.resolveScope(ctx, req, IntentLocate)
if errResult != nil {
return errResult, nil
}
eng := s.engineFor(ctx)
if eng == nil {
return mcp.NewToolResultError("no indexed repository is available; run index_repository first"), nil
}
opts := query.QueryOptions{
WorkspaceID: resolved.WorkspaceID,
ProjectID: resolved.ProjectID,
RepoAllow: resolved.RepoAllow,
}
// The task text is pasted verbatim by the agent and is frequently a
// whole issue report — a title, then a body of repro commands, stack
// traces, environment tables and issue-template prompts. Fed raw to
// retrieval, that body's command-line flags and log lines out-weigh the
// one-line defect description and pull ranking toward the flag-definition
// and entry-point files instead of the fix site. shapeExploreQuery
// distils the report to its retrieval signal (lead weighted, boilerplate
// dropped) before search; a short focused query is passed through
// untouched. The original task is still shown in the header so the agent
// sees what it asked.
searchQuery := shapeExploreQuery(task)
rctx := s.buildRerankContext(ctx, searchQuery)
// Over-fetch, then keep the top maxSymbols that are real localization
// targets — params / locals / closures / imports are never a place a
// developer edits to fix a report, and they otherwise consume ranking
// slots and clutter the file map. Test-source symbols are demoted, not
// dropped: production code is where a report is resolved, but a task
// genuinely about tests still gets them when production hits run out.
fetch := clampInt(maxSymbols*4, maxSymbols, 80)
ranked := eng.SearchSymbolsRanked(searchQuery, fetch, opts, rctx)
// Resilience ladder: a warm-restarted daemon can transiently return an
// empty scoped ranked result (workspace stamps not yet backfilled, or
// search bundles served before their node payloads re-materialise)
// while the index itself is fine. A one-shot verb must not answer
// "nothing matched" for an index that is merely re-warming, so relax in
// two steps — unscoped ranked, then unscoped BM25 — re-applying the
// repo boundary as a post-filter to preserve multi-repo hygiene.
repoAllowed := func(n *graph.Node) bool {
return len(resolved.RepoAllow) == 0 || resolved.RepoAllow[n.RepoPrefix]
}
if len(ranked) == 0 {
for _, c := range eng.SearchSymbolsRanked(searchQuery, fetch, query.QueryOptions{}, rctx) {
if c != nil && c.Node != nil && repoAllowed(c.Node) {
ranked = append(ranked, c)
}
}
}
if len(ranked) == 0 {
// Last rung: the per-term OR-merge the ranked search handler itself
// falls back on — whole-sentence MATCH semantics differ between the
// in-memory and disk-resident search backends, and per-term fetch +
// merge works on both.
nodes, _ := fetchAndMergeBM25Timed(eng, searchQuery, exploreLexicalTerms(searchQuery), fetch, opts, nil)
if len(nodes) == 0 && (opts.WorkspaceID != "" || opts.ProjectID != "" || len(opts.RepoAllow) > 0) {
nodes, _ = fetchAndMergeBM25Timed(eng, searchQuery, exploreLexicalTerms(searchQuery), fetch, query.QueryOptions{}, nil)
}
for i, n := range nodes {
if n != nil && repoAllowed(n) {
ranked = append(ranked, &rerank.Candidate{Node: n, TextRank: i, VectorRank: -1})
}
}
}
var prod, test []*rerank.Candidate
for _, c := range ranked {
if c == nil || c.Node == nil || !exploreLocalizableKind(c.Node.Kind) {
continue
}
isTest, _ := c.Node.Meta["is_test"].(bool)
if isTest || !exploreCodeDefinitionKind(c.Node.Kind) {
test = append(test, c)
} else {
prod = append(prod, c)
}
}
// Bounded per-file diversification (the same demote-only mechanism the
// ranked search head uses): a localization neighborhood that spans
// files beats one file's cluster of sibling shims crowding out every
// other candidate. Nothing is dropped — capped files' extra hits move
// below not-yet-capped files.
prodNodes := make([]*graph.Node, len(prod))
for i, c := range prod {
prodNodes[i] = c.Node
}
_, prod = diversifyByFile(prodNodes, prod, defaultMaxPerFile)
cands := prod
if len(cands) > maxSymbols {
cands = cands[:maxSymbols]
} else if len(cands) < maxSymbols {
room := maxSymbols - len(cands)
if room > len(test) {
room = len(test)
}
cands = append(cands, test[:room]...)
}
if len(cands) == 0 {
return mcp.NewToolResultText(fmt.Sprintf(
"EXPLORE — %s\n\nNo ranked symbols matched this request. The graph found nothing on the ranked path — widen the wording, or drop to search_text / find_files for a literal or filename lead.",
truncateOneLine(task, 200))), nil
}
ringOpts := query.QueryOptions{Depth: 1, Limit: exploreRingCap * 3, Detail: "brief", WorkspaceID: resolved.WorkspaceID}
targets := make([]exploreTarget, 0, len(cands))
for _, c := range cands {
if c == nil || c.Node == nil {
continue
}
n := c.Node
t := exploreTarget{node: n, score: c.Score}
if callers := eng.GetCallers(n.ID, ringOpts); callers != nil {
t.callers = ringNeighbors(callers.Nodes, n.ID, exploreRingCap)
}
if callees := eng.GetCallChain(n.ID, ringOpts); callees != nil {
t.callees = ringNeighbors(callees.Nodes, n.ID, exploreRingCap)
}
t.source = s.manifestSymbolSource(ctx, n)
targets = append(targets, t)
}
return mcp.NewToolResultText(s.renderExplore(task, targets, budget)), nil
}
// renderExplore lays out the ranked neighborhood as a compact, agent-facing
// text block: likely targets (with call paths + source), a file map, and a
// trailing completeness cue — the measured antidote to the cross-check turn.
// Source bodies are packed newest-first until the token budget fills, then
// demoted to signature stubs; every candidate location is always listed.
func (s *Server) renderExplore(task string, targets []exploreTarget, budget int) string {
var b strings.Builder
files := map[string][]string{}
fileOrder := []string{}
addFile := func(path, sym string) {
if _, ok := files[path]; !ok {
fileOrder = append(fileOrder, path)
}
files[path] = append(files[path], sym)
}
fmt.Fprintf(&b, "EXPLORE — %s\n\n", truncateOneLine(task, 200))
b.WriteString("Ranked localization neighborhood (graph-verified). Likely targets first; each carries its call paths and source.\n\n")
b.WriteString("## Likely targets (most-relevant first)\n")
used := estimateTokens(b.String())
truncated := false
for i, t := range targets {
n := t.node
path := nodeDisplayPath(n)
addFile(path, n.Name)
var head strings.Builder
fmt.Fprintf(&head, "\n%d. %s %s · %s · id: %s\n", i+1, n.Name, n.Kind, nodeLoc(n), n.ID)
if len(t.callers) > 0 {
fmt.Fprintf(&head, " ^ callers: %s\n", joinNeighbors(t.callers))
}
if len(t.callees) > 0 {
fmt.Fprintf(&head, " v calls: %s\n", joinNeighbors(t.callees))
}
b.WriteString(head.String())
used += estimateTokens(head.String())
// Source body: full while the budget holds (rank decides order, the
// budget decides where full source stops; no single body may take
// more than 1/exploreBodyBudgetShare of the whole budget), signature
// stub otherwise. The header/locations above are always emitted so
// file-hit / symbol-hit never depend on budget.
body := ""
if t.source != "" {
cost := estimateTokens(t.source)
if used+cost <= budget && cost <= budget/exploreBodyBudgetShare {
body = t.source
} else {
if sig, err := elide.CompressString(t.source, n.Language); err == nil && sig != "" {
body = sig
} else {
body = firstLines(t.source, 3)
}
if used+estimateTokens(body) > budget {
body = ""
}
truncated = true
}
}
if body != "" {
fmt.Fprintf(&b, "```%s\n%s\n```\n", fenceLang(n.Language), strings.TrimRight(body, "\n"))
used += estimateTokens(body)
}
}
b.WriteString("\n## Files to change\n")
for _, f := range fileOrder {
fmt.Fprintf(&b, "- %s · %s\n", f, strings.Join(dedupStrings(files[f]), ", "))
}
fmt.Fprintf(&b, "\n— Completeness: %d candidate symbol(s) across %d file(s); callers/callees resolved server-side from the graph. This is the ranked neighborhood for the request — a location not listed here is not on the ranked path. Answer (FILES / SYMBOLS / EVIDENCE) or start editing directly from this; the paths and line numbers above are real and citeable.\n",
len(targets), len(fileOrder))
// Terminality affordance: the source for each listed symbol is already in
// this response. Re-opening these files with Read / Glob is the measured
// wasted-turn trap (the indexed-source deny-hook rejects it); the follow-up
// reader is get_symbol_source / batch_symbols on the `id:` shown above.
b.WriteString(" The source for each symbol is included above — do not re-open these files with Read/Glob; read more of any listed symbol with get_symbol_source / batch_symbols using its exact `id:`.\n")
if truncated {
fmt.Fprintf(&b, " (Some bodies are elided under the %d-token budget; every candidate's location is still listed above — fetch an elided body with get_symbol_source / batch_symbols using the exact `id:` shown on its line.)\n", budget)
}
return b.String()
}
// exploreCodeDefinitionKind reports whether a node kind is a code
// definition a developer edits to resolve a report. Non-code graph
// nodes (doc sections, packages, resources, contracts, ...) can rank —
// they are demoted to the fallback pool alongside test symbols rather
// than dropped, so a genuinely docs-shaped task still reaches them.
func exploreCodeDefinitionKind(k graph.NodeKind) bool {
switch k {
case graph.KindFunction, graph.KindMethod, graph.KindType,
graph.KindInterface, graph.KindField, graph.KindConstant,
graph.KindVariable, graph.KindEnumMember, graph.KindMacro:
return true
default:
return false
}
}
// exploreLocalizableKind reports whether a node kind is a place a
// developer would actually edit to resolve a report — the localization
// targets. Params, locals, closures, generic params, imports and file
// nodes are structurally never edit targets, so they are dropped from
// both the ranked candidate set and the call-path rings.
func exploreLocalizableKind(k graph.NodeKind) bool {
switch k {
case graph.KindParam, graph.KindLocal, graph.KindClosure,
graph.KindGenericParam, graph.KindImport, graph.KindFile:
return false
default:
return true
}
}
// ringNeighbors filters a traversal result's nodes to real neighbors (not
// the focus node itself, not param/local/import noise), capped.
func ringNeighbors(nodes []*graph.Node, selfID string, cap int) []*graph.Node {
out := make([]*graph.Node, 0, cap)
for _, n := range nodes {
if n == nil || n.ID == selfID || !exploreLocalizableKind(n.Kind) {
continue
}
out = append(out, n)
if len(out) >= cap {
break
}
}
return out
}
// joinNeighbors renders a neighbor ring as "name (path:line), name (path:line)".
func joinNeighbors(nodes []*graph.Node) string {
parts := make([]string, 0, len(nodes))
for _, n := range nodes {
parts = append(parts, fmt.Sprintf("%s (%s)", n.Name, nodeLoc(n)))
}
return strings.Join(parts, ", ")
}
// nodeLoc is the citeable "path:startLine-endLine" (or "path:line") location.
func nodeLoc(n *graph.Node) string {
path := nodeDisplayPath(n)
if n.EndLine > n.StartLine {
return fmt.Sprintf("%s:%d-%d", path, n.StartLine, n.EndLine)
}
if n.StartLine > 0 {
return fmt.Sprintf("%s:%d", path, n.StartLine)
}
return path
}
// nodeDisplayPath is the repo-relative file path (the scorer's suffix-match
// target and the agent's citeable path).
func nodeDisplayPath(n *graph.Node) string {
if n.FilePath != "" {
return n.FilePath
}
return n.AbsoluteFilePath
}
// fenceLang maps a node language to a Markdown fence label (best-effort).
func fenceLang(lang string) string {
if lang == "" {
return ""
}
return lang
}
// Query-shaping tuning. Generic structural thresholds — no vocabulary, no
// dependence on any corpus or task set.
const (
// shapeMinReportChars is the size above which a multi-line task is
// treated as a pasted report worth distilling. Below it (or single-line)
// the task is a focused query and passes through untouched.
shapeMinReportChars = 300
// shapeBodyMaxRunes bounds the distilled body so its bulk cannot re-drown
// the weighted lead under BM25 length normalisation.
shapeBodyMaxRunes = 400
// shapeMinLineWords drops lines shorter than this many words — the
// environment answers ("14.1.0", "Cargo", "macOS 26.5") and one-word
// section headers a report body is padded with.
shapeMinLineWords = 4
)
var (
// A fenced code block: repro commands, log dumps, stack traces, sample
// code. High-noise for LOCALIZATION (it names the invocation, not the
// fix site), so it is removed before the body is distilled.
reFenceBlock = regexp.MustCompile("(?s)```.*?```")
reInlineCode = regexp.MustCompile("`[^`]*`")
reURL = regexp.MustCompile(`https?://\S+`)
// Collapse runs of whitespace (incl. newlines) to single spaces.
reWhitespace = regexp.MustCompile(`\s+`)
)
// shapeExploreQuery distils a pasted issue/report into the query that best
// localizes it, using only markdown/text structure — no vocabulary, no
// language model, nothing derived from any task set.
//
// The problem it solves: an agent commonly pastes a whole issue as the task —
// a one-line title followed by a body of repro commands, stack traces,
// environment tables and issue-template prompts. Fed raw to BM25 the body's
// command-line flags and log lines out-weigh the single defect sentence and
// pull ranking toward the flag-definition / entry-point files rather than the
// fix site. The fix is structural de-noising:
//
// - the first non-empty line is the lead (a report's headline) and is
// repeated once so its high-signal tokens are not drowned by the body;
// - fenced code blocks, inline code and URLs are dropped from the body;
// - body lines that are markdown headers/quotes, issue-template prompts
// (they end in "?") or too short to be prose (environment answers, section
// labels) are dropped;
// - the surviving prose is bounded so it cannot re-drown the lead.
//
// A single-line (or short multi-line) task takes the inline path instead:
// structural noise tokens — commit-SHA-shaped hex, bare version strings —
// are dropped (they can never name a code symbol) and, when the query has
// clause structure, its lead clause is repeated so the headline's tokens
// out-weigh the trailing detail. A clean prose/identifier query with no
// noise tokens is returned byte-for-byte unchanged.
func shapeExploreQuery(task string) string {
trimmed := strings.TrimSpace(task)
// Focused / inline query: single line, or too short to carry a report
// body worth distilling. Token-level shaping only, gated on the
// presence of structural noise; a clean query passes through
// untouched.
if !strings.ContainsAny(trimmed, "\n\r") || len(trimmed) < shapeMinReportChars {
return shapeInlineQuery(task)
}
// Lead = the first non-empty line (the report's headline).
lead := ""
rest := trimmed
for _, ln := range strings.Split(trimmed, "\n") {
if s := strings.TrimSpace(ln); s != "" {
lead = s
if idx := strings.Index(trimmed, ln); idx >= 0 {
rest = trimmed[idx+len(ln):]
}
break
}
}
body := shapeReportBody(rest)
// Weight the lead by repeating it once, then append the distilled body.
shaped := lead + ". " + lead
if body != "" {
shaped += ". " + body
}
return strings.TrimSpace(reWhitespace.ReplaceAllString(shaped, " "))
}
// shapeReportBody strips code / URLs and boilerplate lines from a report body
// and returns the surviving prose, whitespace-collapsed and rune-bounded.
func shapeReportBody(body string) string {
body = reFenceBlock.ReplaceAllString(body, " ")
body = reInlineCode.ReplaceAllString(body, " ")
body = reURL.ReplaceAllString(body, " ")
var keep []string
for _, ln := range strings.Split(body, "\n") {
s := strings.TrimSpace(ln)
if s == "" {
continue
}
if strings.HasPrefix(s, "#") || strings.HasPrefix(s, ">") {
continue // markdown header / block quote
}
if strings.HasSuffix(s, "?") {
continue // issue-template prompt ("What version are you using?")
}
if len(strings.Fields(s)) < shapeMinLineWords {
continue // environment answer / one-word section label
}
keep = append(keep, s)
}
prose := reWhitespace.ReplaceAllString(strings.Join(keep, " "), " ")
prose = strings.TrimSpace(prose)
if r := []rune(prose); len(r) > shapeBodyMaxRunes {
prose = strings.TrimSpace(string(r[:shapeBodyMaxRunes]))
}
return prose
}
// Inline (single-line) query shaping. An agent that paraphrases a report
// into one line carries the report's noise with it — command-line flag
// tokens, quoted pattern/regex literals, commit-SHA hex, bare version
// strings. Those token classes are structurally detectable with no
// vocabulary, and their presence marks the query as report-derived
// rather than hand-focused, so it is worth shaping. Measured behavior on
// report-derived queries drove the transform's shape:
//
// - commit-SHA-shaped hex and bare version strings are DROPPED — they
// can never name a code symbol, so they are pure ranking noise;
// - the lead clause (text before the first ";", " - " or ": "
// separator) is REPEATED once — the same headline-weighting the
// report path applies to a title, since a paraphrase puts the defect
// statement first and trailing detail after a separator;
// - flag tokens and quoted literals are left VERBATIM: the FTS
// tokenizer already reads "--word" as the bare word, and measurement
// showed that removing or down-weighting them costs rank (a flag
// name carries the feature vocabulary; a quoted literal can carry
// the only discriminating token) — they serve as the trigger, not
// as targets.
//
// A query with none of these token classes is returned byte-for-byte
// unchanged.
const (
// shapeInlineMinLeadChars is the minimum length for a lead clause to
// be worth repeating — below it the "clause" is a sentence fragment
// whose repetition would over-weight one or two words.
shapeInlineMinLeadChars = 20
// shaMinHexLen / shaMaxHexLen bound a commit-SHA-shaped token: git
// abbreviates to >=7 hex chars; a full SHA-1 is 40.
shaMinHexLen = 7
shaMaxHexLen = 40
)
var (
// A command-line flag token: "--long-flag" or a lone "-x", token-
// initial (start or whitespace) so hyphenated prose ("case-insensitive",
// "re-searches") never matches.
reInlineFlag = regexp.MustCompile(`(?:^|\s)(?:--[A-Za-z][A-Za-z0-9_-]*|-[A-Za-z])(?:[\s,.;:]|$)`)
// A hex run in the SHA length band. Candidates are verified in code
// (must contain both a digit and a hex letter) because RE2 has no
// lookahead — that check keeps decimal numbers (issue ids) and
// letter-only words out.
reInlineHexRun = regexp.MustCompile(`\b[0-9a-f]{7,40}\b`)
// A bare version string: 1.9.0, v2.14.1, 14.1 — dotted digits with an
// optional leading v. Never a symbol name.
reInlineVersion = regexp.MustCompile(`\bv?\d+\.\d+(?:\.\d+)*\b`)
// A double-quoted span on one line ("e.x|ex", "slice index ...").
reInlineQuoted = regexp.MustCompile(`"[^"\n]*"`)
)
// shapeInlineQuery is the single-line/short-query arm of
// shapeExploreQuery: drop provably-inert tokens and weight the lead
// clause, gated on structural noise so a clean query is untouched.
func shapeInlineQuery(task string) string {
if !hasInlineNoise(task) {
return task
}
cleaned := dropInertTokens(task)
if lead := inlineLeadClause(cleaned); lead != "" {
cleaned += " " + lead
}
return cleaned
}
// hasInlineNoise reports whether the query carries any structurally-
// detectable report noise: a flag token, a commit-SHA-shaped hex token,
// a bare version string, or a quoted pattern/regex literal.
func hasInlineNoise(task string) bool {
if reInlineFlag.MatchString(task) || reInlineVersion.MatchString(task) {
return true
}
for _, m := range reInlineHexRun.FindAllString(task, -1) {
if isSHAToken(m) {
return true
}
}
for _, m := range reInlineQuoted.FindAllString(task, -1) {
if quotedLiteralIsNoise(strings.Trim(m, `"`)) {
return true
}
}
return false
}
// isSHAToken verifies a hex-run candidate looks like a commit hash: the
// SHA length band plus at least one digit AND one hex letter, so a
// decimal number (an issue id) or a letter-only word never qualifies.
func isSHAToken(s string) bool {
if len(s) < shaMinHexLen || len(s) > shaMaxHexLen {
return false
}
hasDigit, hasLetter := false, false
for _, r := range s {
switch {
case r >= '0' && r <= '9':
hasDigit = true
case r >= 'a' && r <= 'f':
hasLetter = true
}
}
return hasDigit && hasLetter
}
// quotedLiteralIsNoise classifies a quoted span's content: regex/pattern
// literals (they carry regex metacharacters, or are short non-alphabetic
// fragments like "e-x") are noise; a quoted plain word or prose phrase
// ("setState", an error message) is signal and never triggers shaping.
func quotedLiteralIsNoise(content string) bool {
if strings.ContainsAny(content, `|\^$*+?[]{}()<>/=~`) {
return true
}
if len(content) <= 5 {
for _, r := range content {
if !unicodeIsLetter(r) {
return true
}
}
}
return false
}
// unicodeIsLetter is a tiny ASCII-fast letter check (the quoted-literal
// rubric only needs letter-vs-not).
func unicodeIsLetter(r rune) bool {
return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || r > 127
}
// dropInertTokens removes commit-SHA-shaped hex and bare version tokens
// and collapses the leftover whitespace. Nothing else is touched.
func dropInertTokens(task string) string {
out := reInlineHexRun.ReplaceAllStringFunc(task, func(m string) string {
if isSHAToken(m) {
return ""
}
return m
})
out = reInlineVersion.ReplaceAllString(out, "")
return strings.TrimSpace(reWhitespace.ReplaceAllString(out, " "))
}
// inlineLeadClause returns the query's lead clause — the text before the
// first ";", " - " or ": " separator (":" only when single, so a
// namespaced identifier's "::" never splits) — when that lead is a
// proper, non-trivial prefix of the query. Empty when the query has no
// clause structure worth weighting.
func inlineLeadClause(task string) string {
t := strings.TrimSpace(task)
end := -1
for i := 0; i < len(t); i++ {
switch t[i] {
case ';':
end = i
case '-':
// " - " — a spaced dash, not a hyphen or a flag.
if i > 0 && t[i-1] == ' ' && i+1 < len(t) && t[i+1] == ' ' {
end = i
}
case ':':
// ": " with a non-colon before it — "walk: a scoped" splits,
// "ignore::WalkBuilder" does not.
if i > 0 && t[i-1] != ':' && t[i-1] != ' ' && i+1 < len(t) && t[i+1] == ' ' {
end = i
}
}
if end >= 0 {
break
}
}
if end < 0 {
return ""
}
lead := strings.TrimSpace(t[:end])
if len(lead) < shapeInlineMinLeadChars || len(lead) >= len(t) {
return ""
}
return lead
}
// exploreLexicalTerms splits free task text into the distinct word/identifier
// terms (length >= 3, capped) that feed the per-term BM25 OR-merge fallback.
// Purely lexical — no vocabulary, no language model.
func exploreLexicalTerms(task string) []string {
const maxTerms = 12
seen := map[string]struct{}{}
var out []string
for _, f := range strings.Fields(task) {
f = strings.Trim(f, "\"'`.,;:()[]{}<>!?—-")
if len(f) < 3 {
continue
}
key := strings.ToLower(f)
if _, dup := seen[key]; dup {
continue
}
seen[key] = struct{}{}
out = append(out, f)
if len(out) >= maxTerms {
break
}
}
return out
}
func estimateTokens(s string) int { return len(s) / exploreCharsPerToken }
func clampInt(v, lo, hi int) int {
if v < lo {
return lo
}
if v > hi {
return hi
}
return v
}
func truncateOneLine(s string, max int) string {
s = strings.ReplaceAll(strings.ReplaceAll(s, "\n", " "), "\r", " ")
s = strings.Join(strings.Fields(s), " ")
if len(s) > max {
return s[:max] + "…"
}
return s
}
func firstLines(s string, n int) string {
lines := strings.SplitN(s, "\n", n+1)
if len(lines) > n {
lines = lines[:n]
}
return strings.Join(lines, "\n")
}
func dedupStrings(in []string) []string {
seen := map[string]struct{}{}
out := make([]string, 0, len(in))
for _, s := range in {
if _, ok := seen[s]; ok {
continue
}
seen[s] = struct{}{}
out = append(out, s)
}
sort.Strings(out)
return out
}