feat(cli): recover omitted and length-aware command suggestions (#37624)

This commit is contained in:
L1nSn0w 2026-06-22 11:56:52 +08:00 committed by GitHub
parent d5cdb2e6f1
commit 3e606ff0dc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 204 additions and 2 deletions

View File

@ -139,3 +139,114 @@ describe('findSuggestions', () => {
expect(suggestions).toHaveLength(0)
})
})
describe('findSuggestions — cross-namespace fallback', () => {
// Mirrors the real command tree shape (auth/login, auth/devices/list, …) so the
// omitted-namespace cases are deterministic without importing the generated tree.
const realish: CommandTree = {
auth: {
subcommands: {
login: { command: FooBarCmd, subcommands: {} },
logout: { command: FooBarCmd, subcommands: {} },
devices: {
subcommands: {
list: { command: FooBarCmd, subcommands: {} },
},
},
},
},
describe: { subcommands: { app: { command: FooBarCmd, subcommands: {} } } },
get: { subcommands: { app: { command: FooBarCmd, subcommands: {} } } },
create: { subcommands: { member: { command: FooBarCmd, subcommands: {} } } },
set: { subcommands: { member: { command: FooBarCmd, subcommands: {} } } },
verbose: { subcommands: { snapshot: { command: FooBarCmd, subcommands: {} } } },
io: { subcommands: { dir: { command: FooBarCmd, subcommands: {} } } },
}
it('recovers an omitted namespace for a bare leaf', () => {
expect(findSuggestions(realish, ['login'])).toEqual(['auth login'])
})
it('does not suggest a leaf outside the length-aware threshold', () => {
// editDistance('login','logout') = 3 > threshold(2) — logout must not appear.
expect(findSuggestions(realish, ['login'])).not.toContain('auth logout')
})
it('recovers a two-level omitted namespace with one typo', () => {
// 'list' anchors the leaf; 'device'→'devices' costs 1; 'auth' omitted → score 2.5.
expect(findSuggestions(realish, ['device', 'list'])).toEqual(['auth devices list'])
})
it('recovers a transposed leaf typo the same-level walk cannot fix', () => {
// editDistance('descrbie','describe') = 2 > traverse's fixed 1, but within length-aware threshold(2).
expect(findSuggestions(realish, ['descrbie', 'app'])).toEqual(['describe app'])
})
it('ranks a same-level fix ahead of any omitted-namespace match', () => {
// 'descibe' (edit distance 1 to the 'describe' namespace) is fixed in-place by the walk,
// so the ambiguous omitted-namespace 'app' fan-out never runs.
const suggestions = findSuggestions(realish, ['descibe', 'app'])
expect(suggestions[0]).toBe('describe app')
expect(suggestions).not.toContain('get app')
})
it('tolerates a two-edit typo on a long leaf', () => {
// editDistance('snpashot','snapshot') = 2, leaf length 8 → threshold 2.
expect(findSuggestions(realish, ['verbose', 'snpashot'])).toContain('verbose snapshot')
})
it('keeps short tokens strict and rejects a two-edit neighbor', () => {
// editDistance('dxx','dir') = 2 > threshold(1) for a 3-char token.
expect(findSuggestions(realish, ['dxx'])).not.toContain('io dir')
// editDistance('dxr','dir') = 1 ≤ threshold(1) — the one-edit neighbor is recovered.
expect(findSuggestions(realish, ['dxr'])).toEqual(['io dir'])
})
it('suppresses ambiguous fan-out when a bare leaf lives under many namespaces', () => {
// 'member' (create/set) and 'app' (describe/get) each tie with zero spelling cost — unroutable.
expect(findSuggestions(realish, ['member'])).toEqual([])
expect(findSuggestions(realish, ['app'])).toEqual([])
})
it('stays silent when nothing clears the threshold', () => {
expect(findSuggestions(realish, ['zzzzz'])).toEqual([])
})
it('drops a low-confidence two-level omission past the score cutoff', () => {
// 'list' only reaches the depth-3 'auth devices list' (two namespaces omitted,
// score 3.0) — beyond the cutoff, so nothing is suggested.
expect(findSuggestions(realish, ['list'])).toEqual([])
})
it('rejects a candidate when more tokens are typed than its path can hold', () => {
// Three positional tokens cannot align to the two-segment 'describe app'.
expect(findSuggestions(realish, ['extra', 'descrbie', 'app'])).toEqual([])
})
it('produces a deterministic, stable result across runs', () => {
expect(findSuggestions(realish, ['login'])).toEqual(findSuggestions(realish, ['login']))
expect(findSuggestions(realish, ['device', 'list'])).toEqual(findSuggestions(realish, ['device', 'list']))
})
})
describe('findSuggestions — hidden commands', () => {
class Visible extends Command {
async run(_argv: string[]) {}
}
class Hidden extends Command {
static hidden = true
async run(_argv: string[]) {}
}
const hiddenTree: CommandTree = {
status: { command: Visible, subcommands: {} },
secret: { command: Hidden, subcommands: {} },
}
it('never surfaces a hidden command, even for a near typo', () => {
expect(findSuggestions(hiddenTree, ['secrt'])).toEqual([])
})
it('still suggests visible siblings', () => {
expect(findSuggestions(hiddenTree, ['statuss'])).toEqual(['status'])
})
})

View File

@ -76,11 +76,97 @@ export function collectCommands(
return results
}
// Below MAX_SCORE a score decomposes uniquely into (integer spelling cost,
// 1.5 × omitted namespaces), so scoreFallback's ambiguity guard can read
// `spelling === 0` as "exact leaf, only the namespace was omitted".
const OMIT_PENALTY = 1.5
const MAX_SCORE = 3
function relThreshold(token: string): number {
return token.length <= 3 ? 1 : 2
}
function positionalTokens(argv: string[]): string[] {
const tokens: string[] = []
for (const token of argv) {
if (token.startsWith('-'))
break
tokens.push(token)
}
return tokens
}
// Minimum total edit distance to align `tokens` as an ordered subsequence of
// `segments`, every matched pair within its length-aware threshold. Returns
// null when no alignment exists (e.g. more tokens than segments).
function minSubsequenceCost(tokens: string[], segments: string[]): number | null {
const [head, ...rest] = tokens
if (head === undefined)
return 0
const threshold = relThreshold(head)
let best: number | null = null
for (const [index, segment] of segments.entries()) {
const cost = editDistance(head, segment)
if (cost > threshold)
continue
const tail = minSubsequenceCost(rest, segments.slice(index + 1))
if (tail !== null && (best === null || cost + tail < best))
best = cost + tail
}
return best
}
// Recovers a wrong/omitted namespace by scoring the typed tokens against the
// flat command list. The last token must align to a candidate's leaf; earlier
// tokens align, in order, to the candidate's preceding segments (the rest are
// namespace the user omitted). Lower score = higher confidence.
function scoreFallback(tree: CommandTree, tokens: string[]): string[] {
const last = tokens.length - 1
const lastToken = tokens[last]
if (lastToken === undefined)
return []
const prefix = tokens.slice(0, last)
const scored: Array<{ path: string, score: number, spelling: number, depth: number }> = []
for (const { path } of collectCommands(tree)) {
const leaf = path[path.length - 1] ?? ''
const leafCost = editDistance(lastToken, leaf)
if (leafCost > relThreshold(lastToken))
continue
const prefixCost = minSubsequenceCost(prefix, path.slice(0, -1))
if (prefixCost === null)
continue
const spelling = leafCost + prefixCost
const score = spelling + OMIT_PENALTY * (path.length - tokens.length)
if (score >= MAX_SCORE)
continue
scored.push({ path: buildPath(path), score, spelling, depth: path.length })
}
if (scored.length === 0)
return []
scored.sort((a, b) => a.score - b.score || a.depth - b.depth || a.path.localeCompare(b.path))
// An exact leaf living under several namespaces is unroutable — staying silent
// beats guessing an arbitrary one.
const best = scored[0]?.score
const tied = scored.filter(item => item.score === best)
if (tied.length >= 2 && tied.every(item => item.spelling === 0))
return []
return scored.map(item => item.path)
}
export function findSuggestions(tree: CommandTree, argv: string[]): string[] {
const results: string[] = []
function collectAll(node: CommandNode, path: string[]): void {
if (node.command)
if (node.command && node.command.hidden !== true)
results.push(buildPath(path))
for (const [key, child] of Object.entries(node.subcommands))
collectAll(child, [...path, key])
@ -106,6 +192,11 @@ export function findSuggestions(tree: CommandTree, argv: string[]): string[] {
}
}
// Same-level typos and namespace listing first; only fall back to
// cross-namespace scoring when the level-by-level walk finds nothing.
traverse(tree, argv, [])
return results
if (results.length > 0)
return results
return scoreFallback(tree, positionalTokens(argv))
}