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

Merged opened this

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

Merged

scout/forge:scout/7595-explore-says-once-when-a-kind-or-sort-wo → main 3 commits fork 3 threads · 3 unresolved

  • Merged. scout/7595-explore-says-once-when-a-kind-or-sort-wo was merged into main as 457f7b6.

Review

Sign in to review this change.
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
284284 freshness, and a "Read as:" line says how the line was understood. Enter on an
285285 app opens it; a person or forge row has no terminal page and says where the
286286 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.
287290
288291 ## On your machine
289292
src/actions/explore.ts +48 −15
@@ -85,10 +85,21 @@ const ROWS = 60;
8585
8686 /**
8787 * 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`
outdated

This comment line is now overlong. Re-wrap it.

89 * names it), not an error — the line is typed by a person.
9090 */
9191 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[] = [];
92103 let kind: ExploreKind | "all" = "all";
93104 let sort: ExploreSort = "score";
94105 let archived = false;
@@ -101,13 +112,15 @@ export function parseExploreQuery(line: string): ExploreQuery {
101112 .filter((w) => w !== "")) {
102113 const [key, value = ""] = word.split(/:(.*)/s);
103114 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 } followed by a dangling else if on the next line is mis-formatted. Put } else if on one line, or use a plain if/else chain.

111124 archived = value.toLowerCase() === "yes";
112125 else if (k === "updated" && value.toLowerCase() in WINDOWS)
113126 updated = value.toLowerCase() as ExploreWindow;
@@ -116,15 +129,35 @@ export function parseExploreQuery(line: string): ExploreQuery {
116129 else words.push(word);
117130 }
118131 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),
125141 };
126142 }
127143
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
128161 /** The line a query reads as, for links and the query box. */
129162 export function exploreLine(query: ExploreQuery): string {
130163 return [
src/http/api.ts +4 −3
@@ -307,7 +307,8 @@ import {
307307 explorePage,
308308 type ExploreRow,
309309 exploreLine,
310 parseExploreQuery,
310 exploreNote,
311 readExploreLine,
311312 } from "../actions/explore.ts";
312313 import { frontInput, frontJson, setFront } from "../actions/front.ts";
313314 import { COPIES_PER_DAY, makeItYours } from "../actions/make_it_yours.ts";
@@ -3116,7 +3117,7 @@ export function apiRoutes(router: Router<Handler>): void {
31163117 // Explore (#607): the rows /explore shows, for the same one line.
31173118 router.add("GET", "/api/v1/explore", async (ctx) => {
31183119 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") ?? "");
31203121 const mine = runRepos(ctx.app, ctx.auth);
31213122 // A run token is shown only the rows of its own repositories; the cut
31223123 // and the cursor are made from those rows alone.
@@ -3132,7 +3133,7 @@ export function apiRoutes(router: Router<Handler>): void {
31323133 if (page === null)
31333134 return apiError("invalid_cursor", 400, "cursor is the next_cursor of a page before");
31343135 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 }));
31363137 });
31373138
31383139 // 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";
22 import { bad, good, type Tool, str } from "../shared.ts";
33
44 export const tool: Tool = {
@@ -21,12 +21,13 @@ export const tool: Tool = {
2121 },
2222 writes: false,
2323 async run(ctx, user, args) {
24 const query = parseExploreQuery(str(args["query"]));
24 const { query, unknown } = readExploreLine(str(args["query"]));
2525 const cursor = str(args["cursor"]);
2626 const page = await explorePage(ctx.app, user, query, cursor === "" ? null : cursor);
2727 if (page === null) return bad("invalid_cursor", "cursor is not the next_cursor of a page before");
2828 return good({
2929 query: exploreLine(query),
30 ...(unknown.length === 0 ? {} : { note: exploreNote(unknown) }),

Add a test that the MCP explore tool returns note. Only the REST API is covered, and this path would not fail any test if it were removed.

3031 items: page.items,
3132 next_cursor: page.nextCursor,
3233 });
src/tui/screens/dashboard.ts +7 −4
@@ -14,7 +14,7 @@
1414 * whole of this screen's design.
1515 */
1616
17 import { exploreLine, explorePage, parseExploreQuery, type ExploreRow } from "../../actions/explore.ts";
17 import { exploreLine, exploreNote, 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 { forgeFlakes, forgeLane, forgeNeeds } from "../../actions/health.ts";
@@ -1055,9 +1055,12 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
10551055
10561056 // A site admin is told once, under the filter, when the forge runs
10571057 // 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}`}`;
10611064 const reduced = readAs ?? (f.user?.isAdmin === true ? protectionNotice() : undefined);
10621065 const [box, warn, list] = split(
10631066 main,
src/web/studio/explore.ts +5 −2
@@ -9,7 +9,8 @@
99 import {
1010 explore,
1111 exploreLine,
12 parseExploreQuery,
12 exploreNote,
13 readExploreLine,
1314 type ExploreQuery,
1415 type ExploreRow,
1516 } from "../../actions/explore.ts";
@@ -122,7 +123,8 @@ function emptyState(signedIn: boolean, query: ExploreQuery): Html {
122123 // GET /explore
123124 export async function explorePage(ctx: Ctx): Promise<Response> {
124125 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);
126128 const ranking = performance.now();
127129 const rows = await explore(ctx.app, ctx.auth.user, query);
128130 notePhase(ctx.request, "rank", performance.now() - ranking);
@@ -165,6 +167,7 @@ export async function explorePage(ctx: Ctx): Promise<Response> {
165167 />
166168 ${btn("Go", { size: "sm" })}
167169 </form>
170 ${note === "" ? html`` : html`<p class="muted mb" role="status">${note}</p>`}
168171 <div class="bar mb">
169172 ${seg(
170173 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:&lt;b&gt; 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";
2525 quietLogs();
2626
2727 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", () => {
2929 expect(parseExploreQuery("kind:app sort:forks garden")).toEqual({
3030 kind: "app",
3131 sort: "forks",
@@ -43,7 +43,7 @@ describe("the one line", () => {
4343 expect(parseExploreQuery("kind:nonsense sort:nonsense")).toEqual({
4444 kind: "all",
4545 sort: "score",
46 text: "sort:nonsense",
46 text: "",
4747 archived: false,
4848 updated: "all",
4949 });
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
9999 expect(text).toContain("Read as: kind:app sort:forks");
100100 });
101101
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 () => {
103103 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");
106106 });
107107
108108 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 ()
157157 app.stop();
158158 expect(text).not.toContain("alice/zed");
159159 });
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs