diff --git a/apps/cli-docs/src/content/docs/features.md b/apps/cli-docs/src/content/docs/features.md index 61adf9b7d..8ed71a987 100644 --- a/apps/cli-docs/src/content/docs/features.md +++ b/apps/cli-docs/src/content/docs/features.md @@ -9,6 +9,8 @@ The Sentry CLI includes several features designed to streamline your workflow, e When the CLI asks you to choose an organization or project, type to filter the loaded options by name or slug. The filter appears as `/text` below the list. Matching ignores case and tolerates small typos. Use arrow keys to navigate, Enter to select, and Backspace to edit. Esc clears the filter before cancelling; Ctrl+C cancels immediately. The same controls work in the organization, team, and monorepo app selectors in `sentry init`. +Selectors tolerate typos in unfinished prefixes: `frone` can find `frontend-web`. Queries with one or two characters match literal text only, so `ap` finds `api` but `zz` does not find `ui`. + ## DSN Auto-Detection The CLI automatically detects your Sentry project from your codebase, eliminating the need to specify the target for every command. DSN detection is one part of the [resolution priority chain](./configuration/#resolution-priority) — it runs after checking for explicit arguments, environment variables, and `.sentryclirc` config files. diff --git a/apps/cli-docs/src/fragments/commands/init.md b/apps/cli-docs/src/fragments/commands/init.md index 138e845de..0cc4aa942 100644 --- a/apps/cli-docs/src/fragments/commands/init.md +++ b/apps/cli-docs/src/fragments/commands/init.md @@ -4,7 +4,7 @@ **Authentication:** Interactive runs start the OAuth login flow automatically when credentials are missing or the stored session has expired. Non-interactive and CI runs must authenticate before running `sentry init`. -In organization, team, and monorepo app selectors, type to filter the loaded choices by name or slug. The filter appears as `/text` below the list. Matching ignores case and tolerates small typos. Use arrow keys to navigate, Enter to select, and Backspace to edit. Esc clears an active filter; with no filter, Esc cancels. Ctrl+C always cancels. +In organization, team, and monorepo app selectors, type to filter the loaded choices by name or slug. The filter appears as `/text` below the list. Matching ignores case and tolerates small typos, including in unfinished prefixes such as `frone` for `frontend-web`. Queries with one or two characters match literal text only. Use arrow keys to navigate, Enter to select, and Backspace to edit. Esc clears an active filter; with no filter, Esc cancels. Ctrl+C always cancels. ## Examples diff --git a/packages/cli/src/lib/complete.ts b/packages/cli/src/lib/complete.ts index 3fec1796a..062ed302c 100644 --- a/packages/cli/src/lib/complete.ts +++ b/packages/cli/src/lib/complete.ts @@ -233,10 +233,12 @@ export function completeProjectCreateSpec(partial: string): Completion[] { const platformPartial = partial.slice(colonIdx + 1); const candidates = platformPartial === "" ? COMMON_PLATFORMS : VALID_PLATFORMS; - return fuzzyMatch(platformPartial, candidates).map((platform) => ({ - value: `${specPrefix}${platform}`, - description: "Platform", - })); + return fuzzyMatch(platformPartial, candidates, { mode: "search" }).map( + (platform) => ({ + value: `${specPrefix}${platform}`, + description: "Platform", + }), + ); } if (partial.includes("/")) { @@ -263,7 +265,9 @@ export function completeOrgSlugs(partial: string, suffix = ""): Completion[] { } const nameMap = new Map(orgs.map((o) => [o.slug, o.name])); - const matched = fuzzyMatch(partial, Array.from(nameMap.keys())); + const matched = fuzzyMatch(partial, Array.from(nameMap.keys()), { + mode: "search", + }); return matched.map((slug) => ({ value: `${slug}${suffix}`, @@ -343,7 +347,9 @@ export function completeProjectSlugs( } const nameMap = new Map(projects.map((p) => [p.projectSlug, p.projectName])); - const matched = fuzzyMatch(projectPartial, Array.from(nameMap.keys())); + const matched = fuzzyMatch(projectPartial, Array.from(nameMap.keys()), { + mode: "search", + }); return matched.map((slug) => ({ value: `${orgSlug}/${slug}`, @@ -385,7 +391,7 @@ export function completeAliases(partial: string): Completion[] { } const keys = Object.keys(aliases); - const matched = fuzzyMatch(partial, keys); + const matched = fuzzyMatch(partial, keys, { mode: "search" }); return matched.map((alias) => { const entry = aliases[alias]; diff --git a/packages/cli/src/lib/fuzzy.ts b/packages/cli/src/lib/fuzzy.ts index 63bd6e905..6041fc0b6 100644 --- a/packages/cli/src/lib/fuzzy.ts +++ b/packages/cli/src/lib/fuzzy.ts @@ -16,6 +16,16 @@ * @returns The minimum number of single-character edits (insert, delete, replace) */ export function levenshtein(a: string, b: string): number { + return editDistance(a, b); +} + +/** + * Return the full-string edit distance, allowing a smaller distance to a + * non-empty prefix of `b` when it is within `prefixThreshold`. + * + * Omit `prefixThreshold` to compare only the complete strings. + */ +function editDistance(a: string, b: string, prefixThreshold?: number): number { const m = a.length; const n = b.length; const dp = new Map(); @@ -40,7 +50,18 @@ export function levenshtein(a: string, b: string): number { dp.set(key(i, j), cost); } } - return dp.get(key(m, n)) ?? 0; + let distance = dp.get(key(m, n)) ?? 0; + if (prefixThreshold !== undefined) { + const firstLength = Math.max(1, m - prefixThreshold); + const lastLength = Math.min(n, m + prefixThreshold); + for (let length = firstLength; length <= lastLength; length++) { + const prefixDistance = dp.get(key(m, length)) ?? Number.POSITIVE_INFINITY; + if (prefixDistance <= prefixThreshold) { + distance = Math.min(distance, prefixDistance); + } + } + } + return distance; } /** Score tiers for fuzzy matching, ordered by relevance. */ @@ -54,6 +75,8 @@ const FUZZY_BASE_SCORE = 3; export type FuzzyMatchOptions = { /** Maximum number of results to return (default: unlimited). */ maxResults?: number; + /** Search unfinished input; omit to preserve submitted-name matching. */ + mode?: "search"; }; /** @@ -65,10 +88,16 @@ export type FuzzyMatchOptions = { * 3. **Contains match** → score 2 (partial appears as substring) * 4. **Fuzzy match** → score 3 + Levenshtein distance (capped by threshold) * - * Threshold: `max(2, floor(partial.length / 3))`. This means `"tnry"` - * (length 4, threshold 2) matches `"sentry"` (distance 2). Candidates + * Threshold: `max(2, floor(partial.length / 3))`. This means `"isseu"` + * (length 5, threshold 2) matches `"issue"` (distance 2). Candidates * beyond the threshold are excluded entirely. * + * Search mode requires literal matches for one- and two-character queries. + * Longer queries also match candidate prefixes within `floor(length / 3)` + * edits, without counting the unfinished suffix. Full-name typo tolerance + * stays unchanged. Use this mode for selectors and incomplete completions, + * not automatic resolution of submitted names. + * * When `partial` is empty, all candidates are returned (sorted alphabetically). * * @param partial - The partial input to match against @@ -88,6 +117,7 @@ export function fuzzyMatch( const lowerPartial = partial.toLowerCase(); const threshold = Math.max(2, Math.floor(partial.length / 3)); + const search = opts?.mode === "search"; const scored: { candidate: string; score: number }[] = []; for (const candidate of candidates) { @@ -99,8 +129,12 @@ export function fuzzyMatch( scored.push({ candidate, score: PREFIX_SCORE }); } else if (lowerCandidate.includes(lowerPartial)) { scored.push({ candidate, score: CONTAINS_SCORE }); - } else { - const dist = levenshtein(lowerPartial, lowerCandidate); + } else if (!search || partial.length >= 3) { + const dist = editDistance( + lowerPartial, + lowerCandidate, + search ? Math.floor(partial.length / 3) : undefined, + ); if (dist <= threshold) { scored.push({ candidate, score: FUZZY_BASE_SCORE + dist }); } diff --git a/packages/cli/src/lib/init/ui/select-filter.ts b/packages/cli/src/lib/init/ui/select-filter.ts index 1d9ccb7ab..9ce737166 100644 --- a/packages/cli/src/lib/init/ui/select-filter.ts +++ b/packages/cli/src/lib/init/ui/select-filter.ts @@ -23,7 +23,7 @@ export function filterSelectOptions>( if (option.hint) { candidates.push(option.hint); } - return fuzzyMatch(query, candidates).length > 0; + return fuzzyMatch(query, candidates, { mode: "search" }).length > 0; }); } diff --git a/packages/cli/test/lib/complete.test.ts b/packages/cli/test/lib/complete.test.ts index b10bfc6ea..29632c043 100644 --- a/packages/cli/test/lib/complete.test.ts +++ b/packages/cli/test/lib/complete.test.ts @@ -162,6 +162,13 @@ describe("completeProjectCreateSpec", () => { ]); }); + test("tolerates a typo in an unfinished platform name", () => { + expect(completeProjectCreateSpec("example:javascipt-r")).toContainEqual({ + value: "example:javascript-react", + description: "Platform", + }); + }); + test("does not complete existing project names before the colon", () => { expect(completeProjectCreateSpec("acme/new-project")).toEqual([]); }); @@ -214,6 +221,14 @@ describe("completeOrgSlugs", () => { const result = completeOrgSlugs("senry"); expect(result.some((c) => c.value === "sentry")).toBe(true); }); + + test("tolerates a typo in an unfinished organization name", async () => { + await seedOrgs([{ slug: "frontend-web", name: "Frontend web" }]); + + expect(completeOrgSlugs("frone")).toEqual([ + { value: "frontend-web", description: "Frontend web" }, + ]); + }); }); describe("completeOrgSlashProject", () => { @@ -273,6 +288,21 @@ describe("completeOrgSlashProject", () => { expect(result).toEqual([]); }); + test("does not treat the organization before a slash as unfinished", async () => { + await seedOrgs([{ slug: "frontend-web", name: "Frontend web" }]); + await seedProjects([ + { + orgId: "1", + projectId: "10", + orgSlug: "frontend-web", + projectSlug: "backend", + projectName: "Backend", + }, + ]); + + expect(completeOrgSlashProject("frone/")).toEqual([]); + }); + test("with slash and partial project filters", async () => { await seedOrgs([{ slug: "my-org", name: "My Org" }]); await seedProjects([ @@ -327,6 +357,22 @@ describe("completeProjectSlugs", () => { expect(result[0].value).toBe("my-org/web"); expect(result[0].description).toBe("Web App"); }); + + test("tolerates a typo in an unfinished project name", async () => { + await seedProjects([ + { + orgId: "1", + projectId: "10", + orgSlug: "my-org", + projectSlug: "frontend-web", + projectName: "Frontend web", + }, + ]); + + expect(completeProjectSlugs("frone", "my-org")).toEqual([ + { value: "my-org/frontend-web", description: "Frontend web" }, + ]); + }); }); describe("completeAliases", () => { @@ -352,7 +398,7 @@ describe("completeAliases", () => { expect(aCompletion!.description).toBe("my-org/frontend"); }); - test("filters aliases by prefix (exact match ranks first)", async () => { + test("filters short aliases literally with the exact match first", async () => { setProjectAliases( { a: { orgSlug: "org", projectSlug: "proj-a" }, @@ -363,8 +409,6 @@ describe("completeAliases", () => { ); const result = completeAliases("a"); - // "a" is exact match, "abc" is prefix match, "b" is fuzzy match - expect(result[0].value).toBe("a"); // exact match first - expect(result.some((c) => c.value === "abc")).toBe(true); // prefix match + expect(result.map((c) => c.value)).toEqual(["a", "abc"]); }); }); diff --git a/packages/cli/test/lib/fuzzy.test.ts b/packages/cli/test/lib/fuzzy.test.ts index 2f0a43adf..3513d603f 100644 --- a/packages/cli/test/lib/fuzzy.test.ts +++ b/packages/cli/test/lib/fuzzy.test.ts @@ -152,6 +152,44 @@ describe("fuzzyMatch: unit tests", () => { }); }); +describe("fuzzyMatch: search mode", () => { + test.each(["frone", "froont", "frnt"])( + "matches an unfinished prefix with an edit: %s", + (query) => { + expect(fuzzyMatch(query, ["frontend-web"], { mode: "search" })).toEqual([ + "frontend-web", + ]); + }, + ); + + test("preserves full-name typos while allowing incomplete typo prefixes", () => { + expect(fuzzyMatch("isseu", ["issue"], { mode: "search" })).toEqual([ + "issue", + ]); + expect(fuzzyMatch("frone", ["frontend-web"])).toEqual([]); + expect(fuzzyMatch("frone", ["frontend-web"], { mode: "search" })).toEqual([ + "frontend-web", + ]); + }); + + test("ranks literal matches before typo prefixes and respects the result limit", () => { + const candidates = ["frnntend-web", "team-frontend", "frontend", "front"]; + const expected = ["front", "frontend", "team-frontend", "frnntend-web"]; + expect(fuzzyMatch("FRONT", candidates, { mode: "search" })).toEqual( + expected, + ); + expect( + fuzzyMatch("FRONT", candidates, { mode: "search", maxResults: 2 }), + ).toEqual(expected.slice(0, 2)); + }); + + test("keeps typo prefixes anchored to the beginning of a candidate", () => { + expect( + fuzzyMatch("frone", ["team-frontend-web"], { mode: "search" }), + ).toEqual([]); + }); +}); + describe("property: fuzzyMatch", () => { test("exact candidate is always in the result", () => { const candidatesArb = array(shortStringArb, { diff --git a/packages/cli/test/lib/init/ui/ink-app.snapshot.test.tsx b/packages/cli/test/lib/init/ui/ink-app.snapshot.test.tsx index 39ffe8f90..d926a9135 100644 --- a/packages/cli/test/lib/init/ui/ink-app.snapshot.test.tsx +++ b/packages/cli/test/lib/init/ui/ink-app.snapshot.test.tsx @@ -1170,7 +1170,7 @@ describe("Ink App snapshot", () => { exitOnCtrlC: false, }); try { - stdin.push("frontz"); + stdin.push("frozz"); // Observe the empty state before later input can coalesce its render. await vi.waitFor(() => { expect(stripAnsi(output.latestFrame())).toContain("No matches"); @@ -1183,7 +1183,7 @@ describe("Ink App snapshot", () => { stdin.push("\u007f\r"); await vi.waitFor(() => { expect(resolve).toHaveBeenCalledExactlyOnceWith("frontend"); - expect(stripAnsi(output.latestFrame())).toContain("/front 1 of 1"); + expect(stripAnsi(output.latestFrame())).toContain("/froz 1 of 1"); }); } finally { instance.unmount(); @@ -1272,9 +1272,9 @@ describe("Ink App snapshot", () => { }); const output = await renderApp(store, 80, { - input: ["\u001b[200~frontz\u001b[201~\u007f\r"], + input: ["\u001b[200~frozz\u001b[201~\u007f\r"], }); - expect(stripAnsi(output.latestFrame())).toContain("/front 1 of 2"); + expect(stripAnsi(output.latestFrame())).toContain("/froz 1 of 2"); expect(resolve).toHaveBeenCalledExactlyOnceWith("frontend"); }); diff --git a/packages/cli/test/lib/init/ui/select-filter.test.ts b/packages/cli/test/lib/init/ui/select-filter.test.ts index a5dbf6e43..71ef48c26 100644 --- a/packages/cli/test/lib/init/ui/select-filter.test.ts +++ b/packages/cli/test/lib/init/ui/select-filter.test.ts @@ -50,6 +50,26 @@ describe("filterSelectOptions", () => { ).toEqual([]); }); + test("short queries only match literal parts of the options", () => { + const candidates = ["UI", "Go", "DB", "pizza"].map((value) => ({ + value, + label: value, + })); + + expect(filterSelectOptions(candidates, "zz")).toEqual([candidates[3]]); + }); + + test("keeps a matching option visible while typing a prefix with a typo", () => { + const candidates = [ + { value: "frontend-web", label: "Frontend web" }, + { value: "backend", label: "Backend" }, + ]; + + for (const query of ["fron", "frone", "fronen", "fronend", "fronend-w"]) { + expect(filterSelectOptions(candidates, query)).toEqual([candidates[0]]); + } + }); + test("an empty filter restores every original option", () => { fcAssert( property( diff --git a/packages/cli/test/lib/resolve-target.test.ts b/packages/cli/test/lib/resolve-target.test.ts index ec8abc2f5..20903f9a3 100644 --- a/packages/cli/test/lib/resolve-target.test.ts +++ b/packages/cli/test/lib/resolve-target.test.ts @@ -905,6 +905,30 @@ describe("tryFuzzyProjectRecovery", () => { } }); + test("keeps short submitted project names ambiguous", async () => { + setOrgRegion("test-org", DEFAULT_SENTRY_URL); + globalThis.fetch = mockListProjects({ + "test-org": ["go-api", "db"], + }); + + const result = await tryFuzzyProjectRecovery("go", [{ slug: "test-org" }]); + + expect(result.kind).toBe("suggestions"); + }); + + test("does not recover a submitted project name from a typo in its prefix", async () => { + setOrgRegion("test-org", DEFAULT_SENTRY_URL); + globalThis.fetch = mockListProjects({ + "test-org": ["frontend-web"], + }); + + const result = await tryFuzzyProjectRecovery("frone", [ + { slug: "test-org" }, + ]); + + expect(result.kind).toBe("none"); + }); + test("returns 'none' when no similar projects exist", async () => { setOrgRegion("test-org", DEFAULT_SENTRY_URL); globalThis.fetch = mockListProjects({