Public: anyone can read this; the crew can change it.
A mistyped explore word offers the nearest one as a single link: sort:froks → sort:forks #9174
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/9076-a-mistyped-explore-word-offers-the-neare → main 3 commits fork
8 files
changed +142
−8
docs/features/explore.md +2
| @@ -23,3 +23,5 @@ A like is a `feed_like` row (one per person and post, `src/feed/likes.ts`). An a | ||
| 23 | 23 | In `score`, likes add `0.5 × ln(1 + likes)`, small next to forks (3) and ships (2). The weight is a constant in `explore.ts`, not one of the front page's admin weights. |
| 24 | 24 | |
| 25 | 25 | An unknown `sort:` word is ignored with a note naming the sorts above. |
| 26 | ||
| 27 | When an unknown `kind:`, `sort:`, `updated:` or `archived:` value is within edit distance 2 of a known one (and nearer than the value is long, so one or two characters of junk suggest nothing), the note ends with `Did you mean <a>sort:forks</a>?`, a link to the whole line with that word mended. Only the first such word is mended per line; a word repeated is mended everywhere. `GET /api/v1/explore` and the MCP `explore` tool carry the same as `suggest` (`"garden sort:forks"`, the whole corrected line), absent when there is none. The terminal shows it as plain text under "Read as:". It is a pure function of the line: nothing is looked up. |
src/actions/explore.ts +59
| @@ -175,6 +175,65 @@ export function exploreNote(unknown: readonly string[]): string { | ||
| 175 | 175 | .join("; "); |
| 176 | 176 | } |
| 177 | 177 | |
| 178 | /** Edit distance between two short words. */ | |
| 179 | function distance(a: string, b: string): number { | |
| 180 | let row = Array.from({ length: b.length + 1 }, (_, i) => i); | |
| 181 | for (let i = 1; i <= a.length; i++) { | |
| 182 | const next = [i]; | |
| 183 | for (let j = 1; j <= b.length; j++) | |
| 184 | next[j] = Math.min(row[j]! + 1, next[j - 1]! + 1, row[j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1)); | |
| 185 | row = next; | |
| 186 | } | |
| 187 | return row[b.length]!; | |
| 188 | } | |
| 189 | ||
| 190 | /** The words each key takes. */ | |
| 191 | function takenBy(key: string): readonly string[] { | |
| 192 | if (key === "kind") return Object.keys(KINDS); | |
| 193 | if (key === "sort") return SORTS; | |
| 194 | if (key === "updated") return Object.keys(WINDOWS); | |
| 195 | if (key === "archived") return ["yes", "no"]; | |
| 196 | return []; | |
| 197 | } | |
| 198 | ||
| 199 | export interface ExploreSuggestion { | |
| 200 | /** The unknown word as typed. */ | |
| 201 | readonly word: string; | |
| 202 | /** The known word it was nearest, `sort:forks`. */ | |
| 203 | readonly fix: string; | |
| 204 | /** The whole line with that one word mended. */ | |
| 205 | readonly line: string; | |
| 206 | } | |
| 207 | ||
| 208 | /** | |
| 209 | * One mended line for the first unknown word within edit distance 2 of a | |
| 210 | * known value of its key; undefined when none is that close. A pure | |
| 211 | * function of the line: nothing is looked up. | |
| 212 | */ | |
| 213 | export function exploreSuggestion(line: string): ExploreSuggestion | undefined { | |
| 214 | const { unknown } = readExploreLine(line); | |
| 215 | for (const word of unknown) { | |
| 216 | const at = word.indexOf(":"); | |
| 217 | const key = word.slice(0, at).toLowerCase(); | |
| 218 | const value = word.slice(at + 1).toLowerCase(); | |
| 219 | let best: string | undefined; | |
| 220 | // Within 2, and nearer than the word is long: 1–2 characters of junk match anything. | |
| 221 | let near = Math.min(3, value.length); | |
| 222 | for (const known of takenBy(key)) { | |
| 223 | const d = distance(value, known); | |
| 224 | if (d < near) { | |
| 225 | near = d; | |
| 226 | best = known; | |
| 227 | } | |
| 228 | } | |
| 229 | if (best === undefined) continue; | |
| 230 | const fix = `${key}:${best}`; | |
| 231 | const words = line.trim().split(/\s+/); | |
| 232 | return { word, fix, line: words.map((w) => (w === word ? fix : w)).join(" ") }; | |
| 233 | } | |
| 234 | return undefined; | |
| 235 | } | |
| 236 | ||
| 178 | 237 | /** The line a query reads as, for links and the query box. */ |
| 179 | 238 | export function exploreLine(query: ExploreQuery): string { |
| 180 | 239 | return [ |
src/http/api.ts +5 −2
| @@ -325,6 +325,7 @@ import { | ||
| 325 | 325 | type ExploreRow, |
| 326 | 326 | exploreLine, |
| 327 | 327 | exploreNote, |
| 328 | exploreSuggestion, | |
| 328 | 329 | readExploreLine, |
| 329 | 330 | } from "../actions/explore.ts"; |
| 330 | 331 | import { frontInput, frontJson, setFront } from "../actions/front.ts"; |
| @@ -3239,7 +3240,9 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 3239 | 3240 | // Explore (#607): the rows /explore shows, for the same one line. |
| 3240 | 3241 | router.add("GET", "/api/v1/explore", async (ctx) => { |
| 3241 | 3242 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 3242 | const { query, unknown } = readExploreLine(ctx.url.searchParams.get("q") ?? ""); | |
| 3243 | const line = ctx.url.searchParams.get("q") ?? ""; | |
| 3244 | const { query, unknown } = readExploreLine(line); | |
| 3245 | const suggest = exploreSuggestion(line); | |
| 3243 | 3246 | const mine = runRepos(ctx.app, ctx.auth); |
| 3244 | 3247 | // A run token is shown only the rows of its own repositories; the cut |
| 3245 | 3248 | // and the cursor are made from those rows alone. |
| @@ -3276,7 +3279,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 3276 | 3279 | if (page === null) |
| 3277 | 3280 | return apiError("invalid_cursor", 400, "cursor is the next_cursor of a page before"); |
| 3278 | 3281 | const items = page.items; |
| 3279 | return timePhase(ctx.request, "render", () => json({ query: exploreLine(query), ...(unknown.length === 0 ? {} : { note: exploreNote(unknown) }), items, next_cursor: page.nextCursor }, 200, headers)); | |
| 3282 | return timePhase(ctx.request, "render", () => json({ query: exploreLine(query), ...(unknown.length === 0 ? {} : { note: exploreNote(unknown) }), ...(suggest === undefined ? {} : { suggest: suggest.line }), items, next_cursor: page.nextCursor }, 200, headers)); | |
| 3280 | 3283 | }); |
| 3281 | 3284 | |
| 3282 | 3285 | // Make it yours (#606): a fork with its app, planned unless dryRun is |
src/http/mcp/tools/explore.ts +3 −1
| @@ -1,4 +1,4 @@ | ||
| 1 | import { explorePage, exploreLine, exploreNote, readExploreLine } from "../../../actions/explore.ts"; | |
| 1 | import { explorePage, exploreLine, exploreNote, exploreSuggestion, readExploreLine } from "../../../actions/explore.ts"; | |
| 2 | 2 | import { bad, good, type Tool, str } from "../shared.ts"; |
| 3 | 3 | |
| 4 | 4 | export const tool: Tool = { |
| @@ -29,12 +29,14 @@ export const tool: Tool = { | ||
| 29 | 29 | const q = str(args["q"]); |
| 30 | 30 | const line = q !== "" ? q : str(args["query"]); |
| 31 | 31 | const { query, unknown } = readExploreLine(line); |
| 32 | const suggest = exploreSuggestion(line); | |
| 32 | 33 | const cursor = str(args["cursor"]); |
| 33 | 34 | const page = await explorePage(ctx.app, user, query, cursor === "" ? null : cursor); |
| 34 | 35 | if (page === null) return bad("invalid_cursor", "cursor is not the next_cursor of a page before"); |
| 35 | 36 | return good({ |
| 36 | 37 | query: exploreLine(query), |
| 37 | 38 | ...(unknown.length === 0 ? {} : { note: exploreNote(unknown) }), |
| 39 | ...(suggest === undefined ? {} : { suggest: suggest.line }), | |
| 38 | 40 | items: page.items, |
| 39 | 41 | next_cursor: page.nextCursor, |
| 40 | 42 | }); |
src/tui/screens/dashboard.ts +3 −2
| @@ -14,7 +14,7 @@ | ||
| 14 | 14 | * whole of this screen's design. |
| 15 | 15 | */ |
| 16 | 16 | |
| 17 | import { exploreLine, isExploreLine, exploreNote, explorePage, parseExploreQuery, readExploreLine, type ExploreRow } from "../../actions/explore.ts"; | |
| 17 | import { exploreLine, isExploreLine, exploreNote, exploreSuggestion, explorePage, parseExploreQuery, readExploreLine, type ExploreRow } from "../../actions/explore.ts"; | |
| 18 | 18 | import { protectionNotice } from "../../kernel/protection.ts"; |
| 19 | 19 | import { holdWords, plannerHold } from "../../crew/autonomy.ts"; |
| 20 | 20 | import { governorSeen } from "../../crew/governed.ts"; |
| @@ -1130,10 +1130,11 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 1130 | 1130 | // with less than its default protection — the web footer's line. |
| 1131 | 1131 | const read = exploring() ? readExploreLine(filter.query) : undefined; |
| 1132 | 1132 | const note = read === undefined ? "" : exploreNote(read.unknown); |
| 1133 | const mend = exploring() ? exploreSuggestion(filter.query) : undefined; | |
| 1133 | 1134 | const readAs = |
| 1134 | 1135 | read === undefined |
| 1135 | 1136 | ? undefined |
| 1136 | : `Read as: ${exploreLine(read.query) || "everything"}${note === "" ? "" : ` · ${note}`}`; | |
| 1137 | : `Read as: ${exploreLine(read.query) || "everything"}${mend === undefined ? "" : ` · Did you mean ${mend.line}?`}${note === "" ? "" : ` · ${note}`}`; | |
| 1137 | 1138 | const reduced = readAs ?? (f.user?.isAdmin === true ? protectionNotice() : undefined); |
| 1138 | 1139 | const [box, warn, list] = split( |
| 1139 | 1140 | main, |
src/web/studio/explore.ts +9 −2
| @@ -10,6 +10,7 @@ import { | ||
| 10 | 10 | explore, |
| 11 | 11 | exploreLine, |
| 12 | 12 | exploreNote, |
| 13 | exploreSuggestion, | |
| 13 | 14 | readExploreLine, |
| 14 | 15 | type ExploreQuery, |
| 15 | 16 | type ExploreRow, |
| @@ -126,8 +127,10 @@ function emptyState(ctx: Ctx, query: ExploreQuery): Html { | ||
| 126 | 127 | // GET /explore |
| 127 | 128 | export async function explorePage(ctx: Ctx): Promise<Response> { |
| 128 | 129 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 129 | const { query, unknown } = readExploreLine(ctx.url.searchParams.get("q") ?? ""); | |
| 130 | const line = ctx.url.searchParams.get("q") ?? ""; | |
| 131 | const { query, unknown } = readExploreLine(line); | |
| 130 | 132 | const note = exploreNote(unknown); |
| 133 | const suggestion = exploreSuggestion(line); | |
| 131 | 134 | const ranking = performance.now(); |
| 132 | 135 | const rows = await explore(ctx.app, ctx.auth.user, query); |
| 133 | 136 | notePhase(ctx.request, "rank", performance.now() - ranking); |
| @@ -171,7 +174,11 @@ export async function explorePage(ctx: Ctx): Promise<Response> { | ||
| 171 | 174 | /> |
| 172 | 175 | ${btn("Go", { size: "sm" })} |
| 173 | 176 | </form> |
| 174 | ${note === "" ? html`` : html`<p class="muted mb" role="status">${note}</p>`} | |
| 177 | ${note === "" ? html`` : html`<p class="muted mb" role="status">${note}${ | |
| 178 | suggestion === undefined | |
| 179 | ? html`` | |
| 180 | : html` Did you mean <a href="/explore?q=${encodeURIComponent(suggestion.line)}">${suggestion.fix}</a>?` | |
| 181 | }</p>`} | |
| 175 | 182 | <div class="bar mb"> |
| 176 | 183 | ${seg( |
| 177 | 184 | kinds.map((k) => ({ |
test/explore-unknown-words.test.ts +56 −1
| @@ -8,7 +8,7 @@ import { mkdtemp, rm } from "node:fs/promises"; | ||
| 8 | 8 | import { tmpdir } from "node:os"; |
| 9 | 9 | import { join } from "node:path"; |
| 10 | 10 | import { createApp, type App } from "../src/app.ts"; |
| 11 | import { exploreLine, exploreNote, readExploreLine } from "../src/actions/explore.ts"; | |
| 11 | import { exploreLine, exploreNote, exploreSuggestion, readExploreLine } from "../src/actions/explore.ts"; | |
| 12 | 12 | import { createToken } from "../src/core/tokens.ts"; |
| 13 | 13 | import { createUser } from "../src/core/users.ts"; |
| 14 | 14 | import { serve, type Servers } from "../src/http/server.ts"; |
| @@ -161,3 +161,58 @@ describe("the page and the API", () => { | ||
| 161 | 161 | expect((await call("owner:a/b")).note).toBe("owner:a/b is not a name"); |
| 162 | 162 | }); |
| 163 | 163 | }); |
| 164 | ||
| 165 | describe("did you mean", () => { | |
| 166 | test("the nearest known word mends the whole line; far words and owner: give none", () => { | |
| 167 | expect(exploreSuggestion("garden sort:froks")?.line).toBe("garden sort:forks"); | |
| 168 | expect(exploreSuggestion("garden sort:froks")?.fix).toBe("sort:forks"); | |
| 169 | expect(exploreSuggestion("kind:aps")?.line).toBe("kind:app"); | |
| 170 | expect(exploreSuggestion("archived:yse")?.line).toBe("archived:yes"); | |
| 171 | expect(exploreSuggestion("sort:zzzzzzzz")).toBeUndefined(); | |
| 172 | expect(exploreSuggestion("owner:a/b")).toBeUndefined(); | |
| 173 | expect(exploreSuggestion("sort:forks")).toBeUndefined(); | |
| 174 | }); | |
| 175 | ||
| 176 | test("two unknown words mend the first; a repeated typo is mended everywhere", () => { | |
| 177 | expect(exploreSuggestion("sort:froks kind:aps")?.line).toBe("sort:forks kind:aps"); | |
| 178 | expect(exploreSuggestion("sort:froks sort:froks")?.line).toBe("sort:forks sort:forks"); | |
| 179 | expect(exploreSuggestion("sort:zzzzzzzz kind:aps")?.line).toBe("sort:zzzzzzzz kind:app"); | |
| 180 | }); | |
| 181 | ||
| 182 | test("one or two characters of junk suggest nothing", () => { | |
| 183 | expect(exploreSuggestion("archived:x")).toBeUndefined(); | |
| 184 | expect(exploreSuggestion("updated:2y")).toBeUndefined(); | |
| 185 | expect(exploreSuggestion("archived:ye")?.line).toBe("archived:yes"); | |
| 186 | }); | |
| 187 | ||
| 188 | test("page, API and MCP carry it, escaped", async () => { | |
| 189 | const dir = await mkdtemp(join(tmpdir(), "forge-explore-suggest-")); | |
| 190 | const a = createApp({ dataDir: dir, port: 0 }); | |
| 191 | const s = await serve(a); | |
| 192 | try { | |
| 193 | const q = encodeURIComponent("garden sort:froks"); | |
| 194 | const html = await (await fetch(`${s.url}/explore?q=${q}`)).text(); | |
| 195 | expect(html).toContain(`Did you mean <a href="/explore?q=${encodeURIComponent("garden sort:forks")}">sort:forks</a>?`); | |
| 196 | const api = (await (await fetch(`${s.url}/api/v1/explore?q=${q}`)).json()) as { suggest?: string }; | |
| 197 | expect(api.suggest).toBe("garden sort:forks"); | |
| 198 | const none = (await (await fetch(`${s.url}/api/v1/explore?q=sort:zzzzzzzz`)).json()) as { suggest?: string }; | |
| 199 | expect(none.suggest).toBeUndefined(); | |
| 200 | const evil = await (await fetch(`${s.url}/explore?q=${encodeURIComponent("sort:<script>")}`)).text(); | |
| 201 | expect(evil).not.toContain("<script>"); | |
| 202 | expect(evil).toContain("sort:<script> is not a sort"); | |
| 203 | const { tool } = await import("../src/http/mcp/tools/explore.ts"); | |
| 204 | const out = await tool.run({ app: a } as never, undefined as never, { q: "garden sort:froks" }); | |
| 205 | expect(JSON.stringify(out)).toContain("garden sort:forks"); | |
| 206 | expect(JSON.stringify(out)).toContain("suggest"); | |
| 207 | const quiet = await tool.run({ app: a } as never, undefined as never, { q: "garden sort:forks" }); | |
| 208 | expect(JSON.stringify(quiet)).not.toContain("suggest"); | |
| 209 | const far = await tool.run({ app: a } as never, undefined as never, { q: "sort:zzzzzzzz" }); | |
| 210 | expect(JSON.stringify(far)).not.toContain("suggest"); | |
| 211 | expect(JSON.stringify(far)).toContain("is not a sort"); | |
| 212 | } finally { | |
| 213 | await s.stop(); | |
| 214 | a.db.close(); | |
| 215 | await rm(dir, { recursive: true, force: true }); | |
| 216 | } | |
| 217 | }); | |
| 218 | }); |
test/tui-dashboard-explore.test.ts +5
| @@ -163,6 +163,11 @@ test("an unknown sort is named under Read as, with the words it takes", async () | ||
| 163 | 163 | expect(text).toContain("sort:nonsense is not a sort — use score, forks"); |
| 164 | 164 | }); |
| 165 | 165 | |
| 166 | test("a mistyped sort is followed by Did you mean the mended line", async () => { | |
| 167 | const text = await typed("kind:app sort:froks"); | |
| 168 | expect(text).toContain("Did you mean kind:app sort:forks?"); | |
| 169 | }); | |
| 170 | ||
| 166 | 171 | test("owner:<name> alone lists that owner's apps as the explorer", async () => { |
| 167 | 172 | const text = await typed("owner:alice"); |
| 168 | 173 | expect(text).toContain("Read as: owner:alice"); |
new above
offline