Public: anyone can read this; the crew can change it.
/explore says once when a `kind:` or `sort:` word is not one it knows, and names the ones it takes #7612
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7595-explore-says-once-when-a-kind-or-sort-wo → main 3 commits fork 3 threads · 3 unresolved
9 files
changed +184
−31
README.md +3
| @@ -284,6 +284,9 @@ does, by the same action, with each row's forks, merges, hours in use and | ||
| 284 | 284 | freshness, and a "Read as:" line says how the line was understood. Enter on an |
| 285 | 285 | app opens it; a person or forge row has no terminal page and says where the |
| 286 | 286 | web has it. A plain line still matches your repositories by name. |
| 287 | An unknown `kind:` or `sort:` word is dropped and named after "Read as:", with | |
| 288 | the words it takes, on the page, in the terminal and in a `note` field of | |
| 289 | `/api/v1/explore` and the MCP `explore` tool; results still show. | |
| 287 | 290 | |
| 288 | 291 | ## On your machine |
| 289 | 292 |
src/actions/explore.ts +48 −15
| @@ -85,10 +85,21 @@ const ROWS = 60; | ||
| 85 | 85 | |
| 86 | 86 | /** |
| 87 | 87 | * The one line: `kind:` and `sort:` take a word each; anything else is |
| 88 | * text to match. An unknown kind or sort is ignored, not an error — the | |
| 89 | * line is typed by a person. | |
| 88 | * text to match. An unknown kind or sort is ignored (`readExploreLine` | |
| 89 | * names it), not an error — the line is typed by a person. | |
| 90 | 90 | */ |
| 91 | 91 | export function parseExploreQuery(line: string): ExploreQuery { |
| 92 | return readExploreLine(line).query; | |
| 93 | } | |
| 94 | ||
| 95 | /** The query and the `kind:`/`sort:` words it did not know, as typed. */ | |
| 96 | export interface ExploreRead { | |
| 97 | readonly query: ExploreQuery; | |
| 98 | readonly unknown: readonly string[]; | |
| 99 | } | |
| 100 | ||
| 101 | export function readExploreLine(line: string): ExploreRead { | |
| 102 | const unknown: string[] = []; | |
| 92 | 103 | let kind: ExploreKind | "all" = "all"; |
| 93 | 104 | let sort: ExploreSort = "score"; |
| 94 | 105 | let archived = false; |
| @@ -101,13 +112,15 @@ export function parseExploreQuery(line: string): ExploreQuery { | ||
| 101 | 112 | .filter((w) => w !== "")) { |
| 102 | 113 | const [key, value = ""] = word.split(/:(.*)/s); |
| 103 | 114 | const k = (key ?? "").toLowerCase(); |
| 104 | if (k === "kind" && value !== "") kind = KINDS[value.toLowerCase()] ?? kind; | |
| 105 | else if ( | |
| 106 | k === "sort" && | |
| 107 | (SORTS as readonly string[]).includes(value.toLowerCase()) | |
| 108 | ) | |
| 109 | sort = value.toLowerCase() as ExploreSort; | |
| 110 | else if (k === "archived" && ["yes", "no"].includes(value.toLowerCase())) | |
| 115 | if (k === "kind" && value !== "") { | |
| 116 | const known = KINDS[value.toLowerCase()]; | |
| 117 | if (known === undefined) unknown.push(word); | |
| 118 | else kind = known; | |
| 119 | } else if (k === "sort" && value !== "") { | |
| 120 | if ((SORTS as readonly string[]).includes(value.toLowerCase())) | |
| 121 | sort = value.toLowerCase() as ExploreSort; | |
| 122 | else unknown.push(word); | |
| 123 | } else if (k === "archived" && ["yes", "no"].includes(value.toLowerCase())) | |
outdated
The |
||
| 111 | 124 | archived = value.toLowerCase() === "yes"; |
| 112 | 125 | else if (k === "updated" && value.toLowerCase() in WINDOWS) |
| 113 | 126 | updated = value.toLowerCase() as ExploreWindow; |
| @@ -116,15 +129,35 @@ export function parseExploreQuery(line: string): ExploreQuery { | ||
| 116 | 129 | else words.push(word); |
| 117 | 130 | } |
| 118 | 131 | return { |
| 119 | kind, | |
| 120 | sort, | |
| 121 | text: words.join(" ").slice(0, 100), | |
| 122 | archived, | |
| 123 | updated, | |
| 124 | ...(owner === undefined ? {} : { owner }), | |
| 132 | query: { | |
| 133 | kind, | |
| 134 | sort, | |
| 135 | text: words.join(" ").slice(0, 100), | |
| 136 | archived, | |
| 137 | updated, | |
| 138 | ...(owner === undefined ? {} : { owner }), | |
| 139 | }, | |
| 140 | unknown: unknown.slice(0, 5), | |
| 125 | 141 | }; |
| 126 | 142 | } |
| 127 | 143 | |
| 144 | /** The kinds a `kind:` word may be, once each. */ | |
| 145 | const KIND_WORDS: readonly ExploreKind[] = [...new Set(Object.values(KINDS))]; | |
| 146 | ||
| 147 | /** | |
| 148 | * One quiet sentence for the unknown words, naming what is taken; "" when | |
| 149 | * there are none. Plain text: the caller escapes it where it draws it. | |
| 150 | */ | |
| 151 | export function exploreNote(unknown: readonly string[]): string { | |
| 152 | return unknown | |
| 153 | .map((word) => { | |
| 154 | const sort = word.toLowerCase().startsWith("sort:"); | |
| 155 | const shown = word.length > 40 ? `${word.slice(0, 39)}…` : word; | |
| 156 | return `${shown} is not a ${sort ? "sort" : "kind"} — use ${(sort ? SORTS : KIND_WORDS).join(", ")}`; | |
| 157 | }) | |
| 158 | .join("; "); | |
| 159 | } | |
| 160 | ||
| 128 | 161 | /** The line a query reads as, for links and the query box. */ |
| 129 | 162 | export function exploreLine(query: ExploreQuery): string { |
| 130 | 163 | return [ |
src/http/api.ts +4 −3
| @@ -307,7 +307,8 @@ import { | ||
| 307 | 307 | explorePage, |
| 308 | 308 | type ExploreRow, |
| 309 | 309 | exploreLine, |
| 310 | parseExploreQuery, | |
| 310 | exploreNote, | |
| 311 | readExploreLine, | |
| 311 | 312 | } from "../actions/explore.ts"; |
| 312 | 313 | import { frontInput, frontJson, setFront } from "../actions/front.ts"; |
| 313 | 314 | import { COPIES_PER_DAY, makeItYours } from "../actions/make_it_yours.ts"; |
| @@ -3116,7 +3117,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 3116 | 3117 | // Explore (#607): the rows /explore shows, for the same one line. |
| 3117 | 3118 | router.add("GET", "/api/v1/explore", async (ctx) => { |
| 3118 | 3119 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 3119 | const query = parseExploreQuery(ctx.url.searchParams.get("q") ?? ""); | |
| 3120 | const { query, unknown } = readExploreLine(ctx.url.searchParams.get("q") ?? ""); | |
| 3120 | 3121 | const mine = runRepos(ctx.app, ctx.auth); |
| 3121 | 3122 | // A run token is shown only the rows of its own repositories; the cut |
| 3122 | 3123 | // and the cursor are made from those rows alone. |
| @@ -3132,7 +3133,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 3132 | 3133 | if (page === null) |
| 3133 | 3134 | return apiError("invalid_cursor", 400, "cursor is the next_cursor of a page before"); |
| 3134 | 3135 | const items = page.items; |
| 3135 | return timePhase(ctx.request, "render", () => json({ query: exploreLine(query), items, next_cursor: page.nextCursor })); | |
| 3136 | return timePhase(ctx.request, "render", () => json({ query: exploreLine(query), ...(unknown.length === 0 ? {} : { note: exploreNote(unknown) }), items, next_cursor: page.nextCursor })); | |
| 3136 | 3137 | }); |
| 3137 | 3138 | |
| 3138 | 3139 | // Make it yours (#606): a fork with its app, planned unless dryRun is |
src/http/mcp/tools/explore.ts +3 −2
| @@ -1,4 +1,4 @@ | ||
| 1 | import { explorePage, exploreLine, parseExploreQuery } from "../../../actions/explore.ts"; | |
| 1 | import { explorePage, exploreLine, exploreNote, readExploreLine } from "../../../actions/explore.ts"; | |
| 2 | 2 | import { bad, good, type Tool, str } from "../shared.ts"; |
| 3 | 3 | |
| 4 | 4 | export const tool: Tool = { |
| @@ -21,12 +21,13 @@ export const tool: Tool = { | ||
| 21 | 21 | }, |
| 22 | 22 | writes: false, |
| 23 | 23 | async run(ctx, user, args) { |
| 24 | const query = parseExploreQuery(str(args["query"])); | |
| 24 | const { query, unknown } = readExploreLine(str(args["query"])); | |
| 25 | 25 | const cursor = str(args["cursor"]); |
| 26 | 26 | const page = await explorePage(ctx.app, user, query, cursor === "" ? null : cursor); |
| 27 | 27 | if (page === null) return bad("invalid_cursor", "cursor is not the next_cursor of a page before"); |
| 28 | 28 | return good({ |
| 29 | 29 | query: exploreLine(query), |
| 30 | ...(unknown.length === 0 ? {} : { note: exploreNote(unknown) }), | |
Add a test that the MCP explore tool returns |
||
| 30 | 31 | items: page.items, |
| 31 | 32 | next_cursor: page.nextCursor, |
| 32 | 33 | }); |
src/tui/screens/dashboard.ts +7 −4
| @@ -14,7 +14,7 @@ | ||
| 14 | 14 | * whole of this screen's design. |
| 15 | 15 | */ |
| 16 | 16 | |
| 17 | import { exploreLine, explorePage, parseExploreQuery, type ExploreRow } from "../../actions/explore.ts"; | |
| 17 | import { exploreLine, exploreNote, 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 { forgeFlakes, forgeLane, forgeNeeds } from "../../actions/health.ts"; |
| @@ -1055,9 +1055,12 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 1055 | 1055 | |
| 1056 | 1056 | // A site admin is told once, under the filter, when the forge runs |
| 1057 | 1057 | // with less than its default protection — the web footer's line. |
| 1058 | const readAs = exploring() | |
| 1059 | ? `Read as: ${exploreLine(parseExploreQuery(filter.query)) || "everything"}` | |
| 1060 | : undefined; | |
| 1058 | const read = exploring() ? readExploreLine(filter.query) : undefined; | |
| 1059 | const note = read === undefined ? "" : exploreNote(read.unknown); | |
| 1060 | const readAs = | |
| 1061 | read === undefined | |
| 1062 | ? undefined | |
| 1063 | : `Read as: ${exploreLine(read.query) || "everything"}${note === "" ? "" : ` · ${note}`}`; | |
| 1061 | 1064 | const reduced = readAs ?? (f.user?.isAdmin === true ? protectionNotice() : undefined); |
| 1062 | 1065 | const [box, warn, list] = split( |
| 1063 | 1066 | main, |
src/web/studio/explore.ts +5 −2
| @@ -9,7 +9,8 @@ | ||
| 9 | 9 | import { |
| 10 | 10 | explore, |
| 11 | 11 | exploreLine, |
| 12 | parseExploreQuery, | |
| 12 | exploreNote, | |
| 13 | readExploreLine, | |
| 13 | 14 | type ExploreQuery, |
| 14 | 15 | type ExploreRow, |
| 15 | 16 | } from "../../actions/explore.ts"; |
| @@ -122,7 +123,8 @@ function emptyState(signedIn: boolean, query: ExploreQuery): Html { | ||
| 122 | 123 | // GET /explore |
| 123 | 124 | export async function explorePage(ctx: Ctx): Promise<Response> { |
| 124 | 125 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 125 | const query = parseExploreQuery(ctx.url.searchParams.get("q") ?? ""); | |
| 126 | const { query, unknown } = readExploreLine(ctx.url.searchParams.get("q") ?? ""); | |
| 127 | const note = exploreNote(unknown); | |
| 126 | 128 | const ranking = performance.now(); |
| 127 | 129 | const rows = await explore(ctx.app, ctx.auth.user, query); |
| 128 | 130 | notePhase(ctx.request, "rank", performance.now() - ranking); |
| @@ -165,6 +167,7 @@ export async function explorePage(ctx: Ctx): Promise<Response> { | ||
| 165 | 167 | /> |
| 166 | 168 | ${btn("Go", { size: "sm" })} |
| 167 | 169 | </form> |
| 170 | ${note === "" ? html`` : html`<p class="muted mb" role="status">${note}</p>`} | |
| 168 | 171 | <div class="bar mb"> |
| 169 | 172 | ${seg( |
| 170 | 173 | kinds.map((k) => ({ |
test/explore-unknown-words.test.ts added +104
| @@ -0,0 +1,104 @@ | ||
| 1 | /** | |
| 2 | * An unknown `kind:` or `sort:` word is a note, not an error (#7595): the | |
| 3 | * parser hands it back as data, the page and the API say it once and name | |
| 4 | * what is taken, the filter links drop it, and results still show. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createApp, type App } from "../src/app.ts"; | |
| 11 | import { exploreLine, exploreNote, readExploreLine } from "../src/actions/explore.ts"; | |
| 12 | import { createToken } from "../src/core/tokens.ts"; | |
| 13 | import { createUser } from "../src/core/users.ts"; | |
| 14 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 15 | import { quietLogs } from "./helpers/globals.ts"; | |
| 16 | ||
| 17 | quietLogs(); | |
| 18 | ||
| 19 | describe("readExploreLine", () => { | |
| 20 | test("an unknown sort and an unknown kind are returned, and left out of the query", () => { | |
| 21 | const read = readExploreLine("sort:bogus kind:nope garden"); | |
| 22 | expect(read.unknown).toEqual(["sort:bogus", "kind:nope"]); | |
| 23 | expect(read.query.sort).toBe("score"); | |
| 24 | expect(read.query.kind).toBe("all"); | |
| 25 | expect(exploreLine(read.query)).toBe("garden"); | |
| 26 | }); | |
| 27 | ||
| 28 | test("known words yield none", () => { | |
| 29 | expect(readExploreLine("sort:forks kind:apps garden").unknown).toEqual([]); | |
| 30 | expect(exploreNote([])).toBe(""); | |
| 31 | }); | |
| 32 | ||
| 33 | test("the note names what is taken", () => { | |
| 34 | expect(exploreNote(["sort:bogus"])).toBe("sort:bogus is not a sort — use score, forks, ships, use, fresh, name"); | |
| 35 | expect(exploreNote(["kind:x"])).toBe("kind:x is not a kind — use app, person, forge"); | |
| 36 | }); | |
| 37 | }); | |
| 38 | ||
| 39 | describe("the page and the API", () => { | |
| 40 | let dataDir: string; | |
| 41 | let app: App; | |
| 42 | let servers: Servers; | |
| 43 | beforeAll(async () => { | |
| 44 | dataDir = await mkdtemp(join(tmpdir(), "forge-explore-unknown-")); | |
| 45 | app = createApp({ dataDir, port: 0 }); | |
| 46 | servers = await serve(app); | |
| 47 | }); | |
| 48 | afterAll(async () => { | |
| 49 | await servers.stop(); | |
| 50 | app.db.close(); | |
| 51 | await rm(dataDir, { recursive: true, force: true }); | |
| 52 | }); | |
| 53 | ||
| 54 | test("the page says it once and the filter links drop the word", async () => { | |
| 55 | const q = encodeURIComponent("sort:bogus garden"); | |
| 56 | const out = await (await fetch(`${servers.url}/explore?q=${q}`)).text(); | |
| 57 | expect(out.split("is not a sort").length).toBe(2); | |
| 58 | expect(out).toContain("sort:bogus is not a sort — use score, forks, ships, use, fresh, name"); | |
| 59 | expect(out).not.toContain(encodeURIComponent("sort:bogus")); | |
| 60 | expect(out).toContain(`href="/explore?q=${encodeURIComponent("kind:app garden")}"`); | |
| 61 | }); | |
| 62 | ||
| 63 | test("a known word draws no note", async () => { | |
| 64 | const out = await (await fetch(`${servers.url}/explore?q=${encodeURIComponent("sort:forks")}`)).text(); | |
| 65 | expect(out).not.toContain("is not a"); | |
| 66 | }); | |
| 67 | ||
| 68 | test("the note is escaped", async () => { | |
| 69 | const out = await (await fetch(`${servers.url}/explore?q=${encodeURIComponent("sort:<b>")}`)).text(); | |
| 70 | expect(out).toContain("sort:<b> is not a sort"); | |
| 71 | expect(out).not.toContain("<b>"); | |
| 72 | }); | |
| 73 | ||
| 74 | test("the API answers a note in a field, not an error", async () => { | |
| 75 | const res = await fetch(`${servers.url}/api/v1/explore?q=${encodeURIComponent("kind:nope")}`); | |
| 76 | expect(res.status).toBe(200); | |
| 77 | const body = (await res.json()) as { note?: string; query: string; items: unknown[] }; | |
| 78 | expect(body.note).toContain("kind:nope is not a kind"); | |
| 79 | expect(body.query).toBe(""); | |
| 80 | expect(Array.isArray(body.items)).toBe(true); | |
| 81 | const fine = (await (await fetch(`${servers.url}/api/v1/explore?q=kind:app`)).json()) as { note?: string }; | |
| 82 | expect(fine.note).toBeUndefined(); | |
| 83 | }); | |
| 84 | ||
| 85 | test("the MCP tool answers the same note", async () => { | |
| 86 | const made = await createUser(app.db, { origin: "seed", trusted: true, username: "mcpuser", email: "[email protected]", password: "a-long-passphrase" }); | |
| 87 | if (!made.ok) throw new Error(made.error); | |
| 88 | const token = createToken(app.db, made.value.id, "ro", "ro").token; | |
| 89 | const call = async (query: string) => { | |
| 90 | const res = await fetch(`${servers.url}/mcp`, { | |
| 91 | method: "POST", | |
| 92 | headers: { | |
| 93 | "content-type": "application/json", | |
| 94 | accept: "application/json, text/event-stream", | |
| 95 | authorization: `Bearer ${token}`, | |
| 96 | }, | |
| 97 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "explore", arguments: { query } } }), | |
| 98 | }); | |
| 99 | return JSON.parse(((await res.json()) as any).result.content[0].text) as { note?: string }; | |
| 100 | }; | |
| 101 | expect((await call("sort:bogus")).note).toContain("sort:bogus is not a sort"); | |
| 102 | expect((await call("sort:forks")).note).toBeUndefined(); | |
| 103 | }); | |
| 104 | }); |
test/explore.test.ts +2 −2
| @@ -25,7 +25,7 @@ import { promote } from "./helpers/members.ts"; | ||
| 25 | 25 | quietLogs(); |
| 26 | 26 | |
| 27 | 27 | describe("the one line", () => { |
| 28 | test("kind and sort take a word each; the rest is text; unknown words are text, not errors", () => { | |
| 28 | test("kind and sort take a word each; the rest is text; unknown kind and sort words are dropped, not errors", () => { | |
| 29 | 29 | expect(parseExploreQuery("kind:app sort:forks garden")).toEqual({ |
| 30 | 30 | kind: "app", |
| 31 | 31 | sort: "forks", |
| @@ -43,7 +43,7 @@ describe("the one line", () => { | ||
| 43 | 43 | expect(parseExploreQuery("kind:nonsense sort:nonsense")).toEqual({ |
| 44 | 44 | kind: "all", |
| 45 | 45 | sort: "score", |
| 46 | text: "sort:nonsense", | |
| 46 | text: "", | |
| 47 | 47 | archived: false, |
| 48 | 48 | updated: "all", |
| 49 | 49 | }); |
test/tui-dashboard-explore.test.ts +8 −3
| @@ -99,10 +99,10 @@ test("kind:app sort:forks lists the apps in the order the page's action gives, w | ||
| 99 | 99 | expect(text).toContain("Read as: kind:app sort:forks"); |
| 100 | 100 | }); |
| 101 | 101 | |
| 102 | test("an unknown word is said back as the text the query understood", async () => { | |
| 102 | test("an unknown word is dropped from what the query understood", async () => { | |
| 103 | 103 | const text = await typed("kind:app sort:nonsense"); |
| 104 | expect(text).toContain("Read as: kind:app sort:nonsense"); | |
| 105 | expect(text).toContain("Nothing matches kind:app sort:nonsense"); | |
| 104 | expect(text).toContain("Read as: kind:app"); | |
| 105 | expect(text).toContain("alice/zed"); | |
| 106 | 106 | }); |
| 107 | 107 | |
| 108 | 108 | test("a plain search still matches repositories by name, with no explorer line", async () => { |
| @@ -157,3 +157,8 @@ test("while a new line is ranked, the old rows are not shown under it", async () | ||
| 157 | 157 | app.stop(); |
| 158 | 158 | expect(text).not.toContain("alice/zed"); |
| 159 | 159 | }); |
| 160 | ||
| 161 | test("an unknown sort is named under Read as, with the words it takes", async () => { | |
| 162 | const text = await typed("kind:app sort:nonsense"); | |
| 163 | expect(text).toContain("sort:nonsense is not a sort — use score, forks"); | |
| 164 | }); |
new above
offline
This comment line is now overlong. Re-wrap it.