Skip to content

Commit c29fe0c

Browse files
committed
feat(help): fuzzy "Did you mean?" suggestions for command typos
Add fuzzy matching to resolveCommandPath() so that top-level command typos (e.g. `sentry isseu`) and subcommand typos via help (e.g. `sentry help issue lis`) now show "Did you mean: issue?" suggestions instead of a bare "Command not found" error. This closes the gap where `defaultCommand: "help"` in app.ts routes unrecognized words to the help command, bypassing Stricli's built-in Damerau-Levenshtein fuzzy matching. - Add UnresolvedPath type to introspect.ts with fuzzy suggestions - Use fuzzyMatch() at both top-level and subcommand resolution levels - Update HelpJsonResult to include optional `suggestions` array - Surface suggestions in human output ("Did you mean: X?") and JSON - Add formatSuggestionList() with Oxford-comma grammar
1 parent 4b4ed91 commit c29fe0c

File tree

4 files changed

+197
-21
lines changed

4 files changed

+197
-21
lines changed

src/lib/help.ts

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ export type HelpJsonResult =
152152
| ({ routes: RouteInfo[] } & { _banner?: string })
153153
| CommandInfo
154154
| RouteInfo
155-
| { error: string };
155+
| { error: string; suggestions?: string[] };
156156

157157
/**
158158
* Introspect the full command tree.
@@ -166,19 +166,48 @@ export function introspectAllCommands(): { routes: RouteInfo[] } {
166166
/**
167167
* Introspect a specific command or group.
168168
* Returns the resolved command/group info, or an error object
169-
* if the path doesn't resolve.
169+
* with optional fuzzy suggestions if the path doesn't resolve.
170170
*/
171171
export function introspectCommand(
172172
commandPath: string[]
173-
): CommandInfo | RouteInfo | { error: string } {
173+
): CommandInfo | RouteInfo | { error: string; suggestions?: string[] } {
174174
const routeMap = routes as unknown as RouteMap;
175175
const resolved = resolveCommandPath(routeMap, commandPath);
176176
if (!resolved) {
177177
return { error: `Command not found: ${commandPath.join(" ")}` };
178178
}
179+
if (resolved.kind === "unresolved") {
180+
const { suggestions } = resolved;
181+
return {
182+
error: `Command not found: ${commandPath.join(" ")}`,
183+
suggestions: suggestions.length > 0 ? suggestions : undefined,
184+
};
185+
}
179186
return resolved.info;
180187
}
181188

189+
// ---------------------------------------------------------------------------
190+
// Suggestion Formatting
191+
// ---------------------------------------------------------------------------
192+
193+
/**
194+
* Join suggestion strings with Oxford-comma grammar.
195+
*
196+
* Matches Stricli's "did you mean" style:
197+
* - 1 item: `"issue"`
198+
* - 2 items: `"issue or trace"`
199+
* - 3 items: `"issue, trace, or auth"`
200+
*/
201+
function formatSuggestionList(items: string[]): string {
202+
if (items.length <= 1) {
203+
return items.join("");
204+
}
205+
if (items.length === 2) {
206+
return `${items[0]} or ${items[1]}`;
207+
}
208+
return `${items.slice(0, -1).join(", ")}, or ${items.at(-1)}`;
209+
}
210+
182211
// ---------------------------------------------------------------------------
183212
// Human Rendering of Introspection Data
184213
// ---------------------------------------------------------------------------
@@ -288,8 +317,12 @@ export function formatHelpHuman(data: HelpJsonResult): string {
288317
return formatCommandHuman(data as CommandInfo);
289318
}
290319

291-
// Error
320+
// Error (with optional fuzzy suggestions)
292321
if ("error" in data) {
322+
const { suggestions } = data;
323+
if (suggestions && suggestions.length > 0) {
324+
return `Error: ${data.error}\n\nDid you mean: ${formatSuggestionList(suggestions)}?`;
325+
}
293326
return `Error: ${data.error}`;
294327
}
295328

src/lib/introspect.ts

Lines changed: 53 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
* for introspection and documentation generation.
1111
*/
1212

13+
import { fuzzyMatch } from "./fuzzy.js";
14+
1315
// ---------------------------------------------------------------------------
1416
// Stricli Runtime Types (simplified for introspection)
1517
// ---------------------------------------------------------------------------
@@ -105,6 +107,19 @@ export type ResolvedPath =
105107
| { kind: "command"; info: CommandInfo }
106108
| { kind: "group"; info: RouteInfo };
107109

110+
/**
111+
* Returned when a path segment fails to match any route.
112+
* Includes fuzzy-matched suggestions (up to 3) from the available
113+
* routes at the level where matching failed.
114+
*/
115+
export type UnresolvedPath = {
116+
kind: "unresolved";
117+
/** The input segment that didn't match any route */
118+
input: string;
119+
/** Fuzzy-matched suggestions from available route names */
120+
suggestions: string[];
121+
};
122+
108123
// ---------------------------------------------------------------------------
109124
// Type Guards
110125
// ---------------------------------------------------------------------------
@@ -279,6 +294,9 @@ export function extractAllRoutes(routeMap: RouteMap): RouteInfo[] {
279294
return result;
280295
}
281296

297+
/** Maximum number of fuzzy suggestions to include in an UnresolvedPath. */
298+
const MAX_SUGGESTIONS = 3;
299+
282300
/**
283301
* Resolve a command path through the route tree.
284302
*
@@ -287,27 +305,40 @@ export function extractAllRoutes(routeMap: RouteMap): RouteInfo[] {
287305
* or the command if it's a standalone command
288306
* - Two segments (e.g. ["issue", "list"]) → returns the specific subcommand
289307
*
308+
* When a segment doesn't match, returns an {@link UnresolvedPath} with
309+
* fuzzy-matched suggestions from the available routes at that level.
310+
*
290311
* @param routeMap - Top-level Stricli route map
291312
* @param path - Command path segments (e.g. ["issue", "list"])
292-
* @returns Resolved command or group info, or null if not found
313+
* @returns Resolved command/group, unresolved with suggestions, or null for empty paths
293314
*/
294315
export function resolveCommandPath(
295316
routeMap: RouteMap,
296317
path: string[]
297-
): ResolvedPath | null {
318+
): ResolvedPath | UnresolvedPath | null {
298319
if (path.length === 0) {
299320
return null;
300321
}
301322

302-
const [first, ...rest] = path;
323+
const first = path[0];
324+
const rest = path.slice(1);
325+
326+
// length === 0 is handled above; this guard helps TS narrow the type
327+
if (first === undefined) {
328+
return null;
329+
}
303330

304-
// Find the top-level entry matching the first segment
305-
const entry = routeMap
306-
.getAllEntries()
307-
.find((e) => e.name.original === first && !e.hidden);
331+
// Collect visible entries once — used for both exact match and fuzzy fallback
332+
const visibleEntries = routeMap.getAllEntries().filter((e) => !e.hidden);
333+
const entry = visibleEntries.find((e) => e.name.original === first);
308334

309335
if (!entry) {
310-
return null;
336+
const names = visibleEntries.map((e) => e.name.original);
337+
return {
338+
kind: "unresolved",
339+
input: first,
340+
suggestions: fuzzyMatch(first, names, { maxResults: MAX_SUGGESTIONS }),
341+
};
311342
}
312343

313344
const target = entry.target;
@@ -344,14 +375,23 @@ export function resolveCommandPath(
344375
return null;
345376
}
346377

347-
// Find the subcommand
378+
// Find the subcommand, with fuzzy fallback
348379
const subName = rest[0];
349-
const subEntry = target
350-
.getAllEntries()
351-
.find((e) => e.name.original === subName && !e.hidden);
380+
if (subName === undefined) {
381+
return null;
382+
}
383+
const visibleSubEntries = target.getAllEntries().filter((e) => !e.hidden);
384+
const subEntry = visibleSubEntries.find((e) => e.name.original === subName);
352385

353386
if (!subEntry) {
354-
return null;
387+
const subNames = visibleSubEntries.map((e) => e.name.original);
388+
return {
389+
kind: "unresolved",
390+
input: subName,
391+
suggestions: fuzzyMatch(subName, subNames, {
392+
maxResults: MAX_SUGGESTIONS,
393+
}),
394+
};
355395
}
356396

357397
if (isCommand(subEntry.target)) {

test/commands/help.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,3 +174,68 @@ describe("introspectCommand error cases", () => {
174174
expect(result).toHaveProperty("error");
175175
});
176176
});
177+
178+
describe("introspectCommand fuzzy suggestions", () => {
179+
test("top-level typo includes suggestions", async () => {
180+
const { introspectCommand } = await import("../../src/lib/help.js");
181+
const result = introspectCommand(["isseu"]);
182+
expect(result).toHaveProperty("error");
183+
if ("error" in result) {
184+
expect(result.error).toContain("isseu");
185+
expect(result.suggestions).toContain("issue");
186+
}
187+
});
188+
189+
test("subcommand typo includes suggestions", async () => {
190+
const { introspectCommand } = await import("../../src/lib/help.js");
191+
const result = introspectCommand(["issue", "lis"]);
192+
expect(result).toHaveProperty("error");
193+
if ("error" in result) {
194+
expect(result.suggestions).toContain("list");
195+
}
196+
});
197+
198+
test("completely unrelated input has no suggestions", async () => {
199+
const { introspectCommand } = await import("../../src/lib/help.js");
200+
const result = introspectCommand(["xyzfoo123456"]);
201+
expect(result).toHaveProperty("error");
202+
if ("error" in result) {
203+
expect(result.suggestions).toBeUndefined();
204+
}
205+
});
206+
});
207+
208+
describe("formatHelpHuman with suggestions", () => {
209+
test("renders 'Did you mean' with single suggestion", async () => {
210+
const { formatHelpHuman } = await import("../../src/lib/help.js");
211+
const output = formatHelpHuman({
212+
error: "Command not found: isseu",
213+
suggestions: ["issue"],
214+
});
215+
expect(output).toContain("Did you mean: issue?");
216+
});
217+
218+
test("renders 'Did you mean' with multiple suggestions", async () => {
219+
const { formatHelpHuman } = await import("../../src/lib/help.js");
220+
const output = formatHelpHuman({
221+
error: "Command not found: trc",
222+
suggestions: ["trace", "trial"],
223+
});
224+
expect(output).toContain("Did you mean: trace or trial?");
225+
});
226+
227+
test("renders three suggestions with Oxford comma", async () => {
228+
const { formatHelpHuman } = await import("../../src/lib/help.js");
229+
const output = formatHelpHuman({
230+
error: "Command not found: x",
231+
suggestions: ["alpha", "beta", "gamma"],
232+
});
233+
expect(output).toContain("Did you mean: alpha, beta, or gamma?");
234+
});
235+
236+
test("no 'Did you mean' when suggestions are absent", async () => {
237+
const { formatHelpHuman } = await import("../../src/lib/help.js");
238+
const output = formatHelpHuman({ error: "Command not found: xyz123" });
239+
expect(output).not.toContain("Did you mean");
240+
});
241+
});

test/lib/introspect.test.ts

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -344,8 +344,13 @@ describe("resolveCommandPath", () => {
344344
expect(resolveCommandPath(topLevel, [])).toBeNull();
345345
});
346346

347-
test("returns null for unknown top-level entry", () => {
348-
expect(resolveCommandPath(topLevel, ["nonexistent"])).toBeNull();
347+
test("returns unresolved for unknown top-level entry", () => {
348+
const result = resolveCommandPath(topLevel, ["nonexistent"]);
349+
expect(result).not.toBeNull();
350+
expect(result?.kind).toBe("unresolved");
351+
if (result?.kind === "unresolved") {
352+
expect(result.input).toBe("nonexistent");
353+
}
349354
});
350355

351356
test("resolves route group", () => {
@@ -377,8 +382,13 @@ describe("resolveCommandPath", () => {
377382
}
378383
});
379384

380-
test("returns null for unknown subcommand", () => {
381-
expect(resolveCommandPath(topLevel, ["issue", "unknown"])).toBeNull();
385+
test("returns unresolved for unknown subcommand", () => {
386+
const result = resolveCommandPath(topLevel, ["issue", "unknown"]);
387+
expect(result).not.toBeNull();
388+
expect(result?.kind).toBe("unresolved");
389+
if (result?.kind === "unresolved") {
390+
expect(result.input).toBe("unknown");
391+
}
382392
});
383393

384394
test("returns null when navigating deeper into standalone command", () => {
@@ -391,6 +401,34 @@ describe("resolveCommandPath", () => {
391401
resolveCommandPath(topLevel, ["issue", "list", "extra", "more"])
392402
).toBeNull();
393403
});
404+
405+
// -------------------------------------------------------------------------
406+
// Fuzzy suggestions
407+
// -------------------------------------------------------------------------
408+
409+
test("suggests close top-level match for typo", () => {
410+
const result = resolveCommandPath(topLevel, ["issu"]);
411+
expect(result?.kind).toBe("unresolved");
412+
if (result?.kind === "unresolved") {
413+
expect(result.suggestions).toContain("issue");
414+
}
415+
});
416+
417+
test("suggests close subcommand match for typo", () => {
418+
const result = resolveCommandPath(topLevel, ["issue", "lis"]);
419+
expect(result?.kind).toBe("unresolved");
420+
if (result?.kind === "unresolved") {
421+
expect(result.suggestions).toContain("list");
422+
}
423+
});
424+
425+
test("returns empty suggestions for completely unrelated input", () => {
426+
const result = resolveCommandPath(topLevel, ["xyzfoo123"]);
427+
expect(result?.kind).toBe("unresolved");
428+
if (result?.kind === "unresolved") {
429+
expect(result.suggestions).toEqual([]);
430+
}
431+
});
394432
});
395433

396434
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)