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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/9076-a-mistyped-explore-word-offers-the-neare → main 3 commits fork

  • Merged. mason/9076-a-mistyped-explore-word-offers-the-neare was merged into main as 7f9b5cb.

Review

Sign in to review this change.
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
2323 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.
2424
2525 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 {
175175 .join("; ");
176176 }
177177
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
178237 /** The line a query reads as, for links and the query box. */
179238 export function exploreLine(query: ExploreQuery): string {
180239 return [
src/http/api.ts +5 −2
@@ -325,6 +325,7 @@ import {
325325 type ExploreRow,
326326 exploreLine,
327327 exploreNote,
328 exploreSuggestion,
328329 readExploreLine,
329330 } from "../actions/explore.ts";
330331 import { frontInput, frontJson, setFront } from "../actions/front.ts";
@@ -3239,7 +3240,9 @@ export function apiRoutes(router: Router<Handler>): void {
32393240 // Explore (#607): the rows /explore shows, for the same one line.
32403241 router.add("GET", "/api/v1/explore", async (ctx) => {
32413242 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);
32433246 const mine = runRepos(ctx.app, ctx.auth);
32443247 // A run token is shown only the rows of its own repositories; the cut
32453248 // and the cursor are made from those rows alone.
@@ -3276,7 +3279,7 @@ export function apiRoutes(router: Router<Handler>): void {
32763279 if (page === null)
32773280 return apiError("invalid_cursor", 400, "cursor is the next_cursor of a page before");
32783281 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));
32803283 });
32813284
32823285 // 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";
22 import { bad, good, type Tool, str } from "../shared.ts";
33
44 export const tool: Tool = {
@@ -29,12 +29,14 @@ export const tool: Tool = {
2929 const q = str(args["q"]);
3030 const line = q !== "" ? q : str(args["query"]);
3131 const { query, unknown } = readExploreLine(line);
32 const suggest = exploreSuggestion(line);
3233 const cursor = str(args["cursor"]);
3334 const page = await explorePage(ctx.app, user, query, cursor === "" ? null : cursor);
3435 if (page === null) return bad("invalid_cursor", "cursor is not the next_cursor of a page before");
3536 return good({
3637 query: exploreLine(query),
3738 ...(unknown.length === 0 ? {} : { note: exploreNote(unknown) }),
39 ...(suggest === undefined ? {} : { suggest: suggest.line }),
3840 items: page.items,
3941 next_cursor: page.nextCursor,
4042 });
src/tui/screens/dashboard.ts +3 −2
@@ -14,7 +14,7 @@
1414 * whole of this screen's design.
1515 */
1616
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";
1818 import { protectionNotice } from "../../kernel/protection.ts";
1919 import { holdWords, plannerHold } from "../../crew/autonomy.ts";
2020 import { governorSeen } from "../../crew/governed.ts";
@@ -1130,10 +1130,11 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
11301130 // with less than its default protection — the web footer's line.
11311131 const read = exploring() ? readExploreLine(filter.query) : undefined;
11321132 const note = read === undefined ? "" : exploreNote(read.unknown);
1133 const mend = exploring() ? exploreSuggestion(filter.query) : undefined;
11331134 const readAs =
11341135 read === undefined
11351136 ? 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}`}`;
11371138 const reduced = readAs ?? (f.user?.isAdmin === true ? protectionNotice() : undefined);
11381139 const [box, warn, list] = split(
11391140 main,
src/web/studio/explore.ts +9 −2
@@ -10,6 +10,7 @@ import {
1010 explore,
1111 exploreLine,
1212 exploreNote,
13 exploreSuggestion,
1314 readExploreLine,
1415 type ExploreQuery,
1516 type ExploreRow,
@@ -126,8 +127,10 @@ function emptyState(ctx: Ctx, query: ExploreQuery): Html {
126127 // GET /explore
127128 export async function explorePage(ctx: Ctx): Promise<Response> {
128129 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);
130132 const note = exploreNote(unknown);
133 const suggestion = exploreSuggestion(line);
131134 const ranking = performance.now();
132135 const rows = await explore(ctx.app, ctx.auth.user, query);
133136 notePhase(ctx.request, "rank", performance.now() - ranking);
@@ -171,7 +174,11 @@ export async function explorePage(ctx: Ctx): Promise<Response> {
171174 />
172175 ${btn("Go", { size: "sm" })}
173176 </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>`}
175182 <div class="bar mb">
176183 ${seg(
177184 kinds.map((k) => ({
test/explore-unknown-words.test.ts +56 −1
@@ -8,7 +8,7 @@ import { mkdtemp, rm } from "node:fs/promises";
88 import { tmpdir } from "node:os";
99 import { join } from "node:path";
1010 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";
1212 import { createToken } from "../src/core/tokens.ts";
1313 import { createUser } from "../src/core/users.ts";
1414 import { serve, type Servers } from "../src/http/server.ts";
@@ -161,3 +161,58 @@ describe("the page and the API", () => {
161161 expect((await call("owner:a/b")).note).toBe("owner:a/b is not a name");
162162 });
163163 });
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:&lt;script&gt; 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 ()
163163 expect(text).toContain("sort:nonsense is not a sort — use score, forks");
164164 });
165165
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
166171 test("owner:<name> alone lists that owner's apps as the explorer", async () => {
167172 const text = await typed("owner:alice");
168173 expect(text).toContain("Read as: owner:alice");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs