diff --git a/src/cli/command-suggestion-budget.test.ts b/src/cli/command-suggestion-budget.test.ts new file mode 100644 index 00000000000..7902ad7fdba --- /dev/null +++ b/src/cli/command-suggestion-budget.test.ts @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import * as distance from '../shared/edit-distance' +import { suggestCommands, unknownFlagData } from './command-suggestion' +import type { CommandSpec } from './command-spec' + +const specs: CommandSpec[] = [ + { path: ['list'], summary: '', usage: '', allowedFlags: [] }, + { path: ['remove'], summary: '', usage: '', allowedFlags: [], destructive: true } +] + +afterEach(() => vi.restoreAllMocks()) + +describe('suggestion distance work', () => { + it('does no distance calculations for a long command, including destructive intent', () => { + const spy = vi.spyOn(distance, 'levenshtein') + expect(suggestCommands(specs, ['x'.repeat(32_768)])).toEqual([]) + expect(spy).not.toHaveBeenCalled() + }) + + it('does no distance calculations for a long flag but still lists valid flags', () => { + const spy = vi.spyOn(distance, 'levenshtein') + expect(unknownFlagData('x'.repeat(32_768), ['worktree', 'json'])).toEqual({ + validFlags: ['json', 'worktree'], + suggestions: [], + nextSteps: ['Valid flags: --json, --worktree'] + }) + expect(spy).not.toHaveBeenCalled() + }) + + it('keeps the inclusive three-edit suggestion boundary', () => { + expect(suggestCommands(specs, ['listxxx'])).toEqual(['list']) + expect(unknownFlagData('jsonxxx', ['json']).suggestions).toEqual(['json']) + }) + + it('keeps the inclusive one-edit destructive intent boundary', () => { + expect(suggestCommands(specs, ['remov'])).toEqual(['remove']) + expect(suggestCommands(specs, ['remo'])).toEqual([]) + }) + + it('retains UTF-16 distance semantics at the length boundary', () => { + expect(unknownFlagData('json😀x', ['json']).suggestions).toEqual(['json']) + expect(unknownFlagData('json😀😀', ['json']).suggestions).toEqual([]) + }) +}) diff --git a/src/cli/command-suggestion.ts b/src/cli/command-suggestion.ts index 7b80138e2f3..9c935f694fa 100644 --- a/src/cli/command-suggestion.ts +++ b/src/cli/command-suggestion.ts @@ -37,7 +37,10 @@ function destructiveVerbs(specs: CommandSpec[]): Set { // input token is itself a near-miss of a destructive verb. #6303 function intendsDestruction(inputToken: string, verbs: Set): boolean { for (const verb of verbs) { - if (levenshtein(inputToken, verb) <= DESTRUCTIVE_INTENT_THRESHOLD) { + if ( + Math.abs(inputToken.length - verb.length) <= DESTRUCTIVE_INTENT_THRESHOLD && + levenshtein(inputToken, verb) <= DESTRUCTIVE_INTENT_THRESHOLD + ) { return true } } @@ -85,7 +88,9 @@ export function suggestCommands(specs: CommandSpec[], commandPath: string[]): st continue } seen.add(joined) - scored.push({ label: joined, distance: levenshtein(input, joined) }) + if (Math.abs(input.length - joined.length) <= SUGGESTION_THRESHOLD) { + scored.push({ label: joined, distance: levenshtein(input, joined) }) + } } } return rankByDistance(scored) @@ -106,9 +111,13 @@ export type FlagErrorData = { } function suggestFlags(flag: string, validFlags: string[]): string[] { - return rankByDistance( - validFlags.map((candidate) => ({ label: candidate, distance: levenshtein(flag, candidate) })) - ) + const scored: { label: string; distance: number }[] = [] + for (const candidate of validFlags) { + if (Math.abs(flag.length - candidate.length) <= SUGGESTION_THRESHOLD) { + scored.push({ label: candidate, distance: levenshtein(flag, candidate) }) + } + } + return rankByDistance(scored) } // Why: include the accepted set so agents can recover without another help call.