fix(suggest): surface both halves of a welded compound flag name (#2604)

`suggest.Closest` ranks flag/command suggestions by shared prefix then edit
distance. A hallucinated name welded from two real names -- e.g. `--sql-file`
from the real `--sql` and `--file` of `apps +db-execute` -- defeats both
signals: the leading half wins on prefix, and the trailing half (`file`, 4
edits from `sql-file`, budget 2) is dropped. The hint then names the flag the
caller did not want and omits the one that does exactly what they asked for.

The cost is not the rejected call. Steered to `--sql`, callers inline SQL
through the shell, where quoting mangles `DEFAULT ''` and
`current_setting(...)` into syntax errors that read as SQL-authoring bugs.
`--file` passes file contents verbatim and avoids that class entirely.

Treat a candidate that exactly equals one hyphen-delimited segment of the typed
name as plausible, however far the whole string drifted. Ranking is unchanged --
segment hits are admitted, not promoted -- so the leading segment still ranks
first, and an unrelated candidate list still yields no suggestions.
This commit is contained in:
木杉
2026-09-03 11:33:52 +08:00
committed by GitHub
parent 59f6ad4900
commit 6606594068
2 changed files with 68 additions and 4 deletions
+34 -4
View File
@@ -7,7 +7,10 @@
// carrying their own copy.
package suggest
import "sort"
import (
"sort"
"strings"
)
// Levenshtein computes the classic edit distance between two strings. It is
// rune-aware, so it is correct for multi-byte input.
@@ -49,6 +52,13 @@ func Levenshtein(a, b string) int {
// semantically close but lexically far (e.g. "+cells-find" vs "+cells-search",
// "--with-styles" vs nothing close), where the common prefix is the strongest
// signal of intent that raw edit distance misses.
//
// A hallucinated name is also often a compound welded from real names ("sql-file"
// from "sql" + "file"). Prefix and edit distance both miss the trailing half:
// "file" shares no prefix with "sql-file" and sits 4 edits away, past the budget,
// so the one candidate naming what the caller actually wanted got dropped while
// the leading half survived on prefix alone. Segment-exact candidates are
// therefore always plausible, however far the whole string drifted.
func Closest(typed string, candidates []string, maxN int) []string {
type scored struct {
name string
@@ -56,13 +66,15 @@ func Closest(typed string, candidates []string, maxN int) []string {
dist int
}
limit := editLimit(typed)
segments := hyphenSegments(typed)
ranked := make([]scored, 0, len(candidates))
for _, c := range candidates {
p := sharedPrefixLen(typed, c)
d := Levenshtein(typed, c)
// Keep only plausible matches: a meaningful shared prefix, or an edit
// distance within budget. Drop everything else so the hint stays short.
if p >= 3 || d <= limit {
// Keep only plausible matches: a meaningful shared prefix, an edit
// distance within budget, or an exact hit on one segment of a compound.
// Drop everything else so the hint stays short.
if p >= 3 || d <= limit || segments[c] {
ranked = append(ranked, scored{name: c, prefix: p, dist: d})
}
}
@@ -102,3 +114,21 @@ func sharedPrefixLen(a, b string) int {
}
return n
}
// hyphenSegments splits a hyphenated name into its parts and returns them as a
// set, so a candidate that exactly equals one part can be recognized in O(1).
// Single-segment names yield an empty set: without a hyphen there is no compound
// to decompose, and treating the whole string as a "segment" would just restate
// the equality case Closest already handles at distance 0.
func hyphenSegments(typed string) map[string]bool {
if !strings.Contains(typed, "-") {
return nil
}
out := make(map[string]bool, 2)
for _, seg := range strings.Split(typed, "-") {
if seg != "" {
out[seg] = true
}
}
return out
}
+34
View File
@@ -43,6 +43,40 @@ func TestClosest_NoPlausibleMatch(t *testing.T) {
}
}
func TestClosest_CompoundSurfacesTrailingSegment(t *testing.T) {
// `--sql-file` is welded from two real flags of `apps +db-execute`. Prefix
// weighting alone surfaced only the leading half (--sql), and --file sits 4
// edits away — past the budget — so the flag that does what the caller asked
// for was dropped, pushing callers to inline SQL through the shell.
flags := []string{"app-id", "as", "dry-run", "environment", "file", "format", "help", "jq", "json", "sql", "yes"}
got := Closest("sql-file", flags, 3)
for _, want := range []string{"sql", "file"} {
if !slices.Contains(got, want) {
t.Errorf("expected %q among suggestions for sql-file, got %v", want, got)
}
}
}
func TestClosest_CompoundKeepsPrefixRankingFirst(t *testing.T) {
// Segment-exact candidates become plausible, they do not jump the queue:
// ranking stays prefix-first, so the leading segment still leads.
flags := []string{"file", "sql"}
if got := Closest("sql-file", flags, 3); len(got) == 0 || got[0] != "sql" {
t.Errorf("expected sql ranked first for sql-file, got %v", got)
}
if got := Closest("file-sql", flags, 3); len(got) == 0 || got[0] != "file" {
t.Errorf("expected file ranked first for file-sql, got %v", got)
}
}
func TestClosest_SegmentRescueDoesNotAdmitUnrelated(t *testing.T) {
// Only exact segment hits are rescued; a hyphen in the typed name must not
// turn every candidate into a plausible match.
if got := Closest("sql-file", []string{"table", "environment"}, 6); len(got) != 0 {
t.Errorf("expected no suggestions for unrelated candidates, got %v", got)
}
}
func TestLevenshtein(t *testing.T) {
cases := []struct {
a, b string