From 3e606ff0dca5ebad2ce5a210b41c166949f873a5 Mon Sep 17 00:00:00 2001 From: L1nSn0w Date: Mon, 22 Jun 2026 11:56:52 +0800 Subject: [PATCH] feat(cli): recover omitted and length-aware command suggestions (#37624) --- cli/src/framework/registry.test.ts | 111 +++++++++++++++++++++++++++++ cli/src/framework/registry.ts | 95 +++++++++++++++++++++++- 2 files changed, 204 insertions(+), 2 deletions(-) diff --git a/cli/src/framework/registry.test.ts b/cli/src/framework/registry.test.ts index 0d208d485d9..8c414908e45 100644 --- a/cli/src/framework/registry.test.ts +++ b/cli/src/framework/registry.test.ts @@ -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']) + }) +}) diff --git a/cli/src/framework/registry.ts b/cli/src/framework/registry.ts index a2249788e90..58ef363b2a3 100644 --- a/cli/src/framework/registry.ts +++ b/cli/src/framework/registry.ts @@ -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)) }