Public: anyone can read this; the crew can change it.

Bug hunters on a schedule, as a self-improving loop: every escape becomes a detector; KPIs (escapes → 0, valid ≥ 80%, fix < 24 h) #5873

Merged opened this in answer to

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

Merged

scout/forge:scout/5846-bug-hunters-on-a-schedule-as-a-self-impr → main 3 commits fork 4 threads · 4 unresolved

  • Merged. scout/5846-bug-hunters-on-a-schedule-as-a-self-impr was merged into main as 8db1fe6.

Review

Sign in to review this change.
10 files changed +418 −0
README.md +4
@@ -704,6 +704,10 @@ event, without a fork chain; one picked from a connected origin then has none.
704704
705705 `/search` answers repositories and issues in full, and greps code repository by repository. A first search starts no new `git grep` after 30 ms (`codeBudgetMs` in the forge config; a soft stop, since the grep in flight still finishes, one batch of four) and shows what it has with one link, **More code results** (`?code=all`), that greps the rest. The API and the terminal palette always grep everything.
706706
707 ## Bug hunters
708
709 `bun scripts/hunt.ts <origin>` runs the hunters' detectors against one running app (GET only, same-origin, one pass) and prints findings as JSON; it exits 1 when there are any. Lanes and what is not built yet: `docs/HUNT.md`.
710
707711 ## Health
708712
709713 `GET /api/v1/health` (and the MCP `health` tool) answer a site admin with
docs/HUNT.md added +13
@@ -0,0 +1,13 @@
1 # Hunters (#5846)
2
3 `bun scripts/hunt.ts <origin>` runs the detector sets in `src/hunt/` against one app and prints findings as JSON (exit 1 when there are any). It is GET-only by construction (`probe.ts` has no other method), same-origin only, and ends after one pass.
4
5 | Lane | Module | Detectors |
6 |---|---|---|
7 | Front door | `door.ts` | personal pages send anonymous visitors to sign-in with `next=`; public pages answer 200; unknown path is 404 |
8 | UI (links) | `links.ts` | every link on the top pages: no 404/5xx, and a link landing on `/` must be labelled Home (#5844) |
9 | Contract | `contract.ts` | no OpenAPI GET route answers 5xx; the status is documented or a refusal |
10
11 A finding's `key` is stable (`detector:subject`): one issue per key, a comment only when the evidence changes.
12
13 Not built yet: headless-browser lane (buttons, console errors, fold, overflow), flows, speed, the schedule (post-release and 08:00Z), filing issues, the KPI card on home, and the weekly retro.
scripts/hunt.ts added +16
@@ -0,0 +1,16 @@
1 /**
2 * `bun scripts/hunt.ts <https-origin>`: run the hunters' detectors (GET only)
3 * against one app and print the findings as JSON. Exit 1 when there are any.
4 * Never run on a person's machine in a loop: it is one pass, then it ends.
5 */
6
7 import { huntTarget } from "../src/hunt/hunt.ts";
8
9 const target = process.argv[2];
10 if (!target || !/^https?:\/\//.test(target)) {
11 console.error("usage: bun scripts/hunt.ts <origin>");
12 process.exit(2);
13 }
14 const report = await huntTarget(target.replace(/\/$/, ""));
15 console.log(JSON.stringify(report, null, 2));
16 process.exit(report.findings.length === 0 ? 0 : 1);
src/hunt/contract.ts added +36
@@ -0,0 +1,36 @@
1 /**
2 * Contract: no documented GET route answers 500 to an anonymous caller, and
3 * what it answers is a status its OpenAPI entry documents (or a refusal).
4 */
5
6 import { found, seenLine, type Finding } from "./finding.ts";
7 import type { Probe } from "./probe.ts";
8
9 const REFUSALS = new Set(["401", "403", "404", "429"]);
10
11 interface Doc {
12 paths?: Record<string, Record<string, { responses?: Record<string, unknown> }>>;
13 }
14
15 export async function contract(p: Probe): Promise<Finding[]> {
16 const out: Finding[] = [];
17 const doc = await p.get("/contract/openapi.json");
18 let parsed: Doc;
19 try {
20 parsed = JSON.parse(doc.body) as Doc;
21 } catch {
22 return [found("contract.openapi", "/contract/openapi.json", "the OpenAPI document is not JSON", seenLine("/contract/openapi.json", doc.status))];
23 }
24 for (const [path, ops] of Object.entries(parsed.paths ?? {})) {
25 const get = ops.get;
26 if (get === undefined || /[{}]/.test(path)) continue;
27 const r = await p.get(path);
28 if (r.status === 0 || r.status >= 500) out.push(found("contract.no-500", path, `${path} answers ${r.status}`, seenLine(path, r.status)));
29 else if (get.responses && Object.keys(get.responses).length > 0) {
30 const code = String(r.status);
31 if (!(code in get.responses) && !REFUSALS.has(code) && !("default" in get.responses))
32 out.push(found("contract.documented", path, `${path} answers ${r.status}, which its entry does not document`, `${seenLine(path, r.status)}; documented: ${Object.keys(get.responses).join(", ")}`));
33 }
34 }
35 return out;
36 }
src/hunt/door.ts added +31
@@ -0,0 +1,31 @@
1 /**
2 * Front door: an anonymous visitor to a page that needs a person is sent to
3 * sign-in with a way back, an unknown path is a plain 404, and the public top
4 * pages answer.
5 */
6
7 import { found, seenLine, type Finding } from "./finding.ts";
8 import type { Probe } from "./probe.ts";
9
10 export const PERSONAL_PAGES = ["/settings", "/new", "/migrate", "/review"];
11 export const PUBLIC_PAGES = ["/", "/signin", "/explore", "/apps", "/healthz"];
12
13 export async function frontDoor(p: Probe): Promise<Finding[]> {
14 const out: Finding[] = [];
15 for (const path of PERSONAL_PAGES) {
16 const r = await p.get(path);
17 const sent = r.status >= 300 && r.status < 400 && /\/signin(\?|$)/.test(r.location ?? "");
18 if (!sent)
19 out.push(found("door.signin-redirect", path, `${path} does not send an anonymous visitor to sign-in`, seenLine(path, r.status, r.location)));
20 else if (!(r.location ?? "").includes("next="))
21 out.push(found("door.signin-next", path, `${path} sends to sign-in without a way back`, seenLine(path, r.status, r.location)));
22 }
23 for (const path of PUBLIC_PAGES) {
24 const r = await p.get(path);
25 if (r.status !== 200) out.push(found("door.public-page", path, `${path} does not answer 200 to an anonymous visitor`, seenLine(path, r.status, r.location)));

No test fails if this check or the 404 check below is removed. Add one each.

26 }
27 const missing = "/no-such-page-hunter";
28 const r = await p.get(missing);
29 if (r.status !== 404) out.push(found("door.unknown-404", missing, "an unknown path does not answer 404", seenLine(missing, r.status, r.location)));
30 return out;
31 }
src/hunt/finding.ts added +20
@@ -0,0 +1,20 @@
1 /** What a detector says: plain data, one issue per `key` (the sentinel's rule). */
2
3 export interface Finding {
4 detector: string;
5 /** Stable across runs: the same defect has the same key, so it files once. */
6 key: string;
7 title: string;
8 /** The minimal repro: the request and what came back. */
9 evidence: string;
10 }
11
12 export const found = (detector: string, subject: string, title: string, evidence: string): Finding => ({
13 detector,
14 key: `${detector}:${subject}`,
15 title,
16 evidence,
17 });
18
19 export const seenLine = (path: string, status: number, location?: string | null): string =>
20 `GET ${path} → ${status}${location ? ` → ${location}` : ""}`;
src/hunt/hunt.ts added +33
@@ -0,0 +1,33 @@
1 /** One hunt: every detector set against one target, timed (Hunt time KPI). */
2
3 import { contract } from "./contract.ts";
4 import { frontDoor } from "./door.ts";
5 import type { Finding } from "./finding.ts";
6 import { linkLanding } from "./links.ts";
7 import { probe, type Fetcher, type Probe } from "./probe.ts";
8
9 export interface HuntReport {
10 target: string;
11 ms: number;
12 findings: Finding[];
13 }
14
15 /** The lanes this module covers; UI (headless), flows and speed need a browser and are not here. */
16 export const LANES: Record<string, (p: Probe, base: string) => Promise<Finding[]>> = {
outdated

LANES is unused. huntTarget should loop over it, or this line should go.

17 door: (p) => frontDoor(p),
18 links: linkLanding,
19 contract: (p) => contract(p),
20 };
21
22 export async function huntTarget(base: string, fetcher?: Fetcher): Promise<HuntReport> {
23 const started = Date.now();
24 const p = probe(base, fetcher);
25 const findings: Finding[] = [];
26 for (const lane of Object.values(LANES)) findings.push(...(await lane(p, base)));
27 const seen = new Set<string>();
28 return {
29 target: base,
30 ms: Date.now() - started,
31 findings: findings.filter((f) => !seen.has(f.key) && seen.add(f.key)),
32 };
33 }
src/hunt/probe.ts added +73
@@ -0,0 +1,73 @@
1 /**
2 * The hunter's only way to touch an app: GET, never following a redirect on
3 * its own. A validator against production must be unable to mutate, so no
4 * other method exists here (#5846).
5 */
6
7 export type Fetcher = (url: string, init: RequestInit) => Promise<Response>;
8
9 export interface Seen {
10 path: string;
11 status: number;
12 location: string | null;
13 type: string;
14 body: string;
15 }
16
17 export interface Probe {
18 get(path: string): Promise<Seen>;
19 }
20
21 /** Same-origin only: a path or an absolute URL on the target's origin. */
22 export function inside(base: string, href: string): string | null {
23 let url: URL;
24 try {
25 url = new URL(href, base);
26 } catch {
27 return null;
28 }
29 if (url.origin !== new URL(base).origin) return null;
30 return url.pathname + url.search;
31 }
32
33 const BODY_MAX = 400_000;
34
35 /** At most BODY_MAX bytes of the body; the rest is never read. */
36 async function readBounded(res: Response): Promise<string> {
37 const reader = res.body?.getReader();
38 if (!reader) return "";
outdated

A rejected fetch or the timeout throws out of the whole hunt. Catch and turn it into a finding; test it. Bound the body read too (res.text() reads it all before slice).

39 const chunks: Uint8Array[] = [];
40 let size = 0;
41 while (size < BODY_MAX) {
42 const { done, value } = await reader.read();
43 if (done) break;
44 chunks.push(value);
45 size += value.length;
46 }
47 await reader.cancel().catch(() => {});
48 return new TextDecoder().decode(Buffer.concat(chunks).subarray(0, BODY_MAX));
49 }
50
51 export function probe(base: string, fetcher: Fetcher = fetch as Fetcher): Probe {
52 return {
53 async get(path) {
54 const target = inside(base, path);
55 if (target === null) throw new Error(`refusing a path off the target: ${path}`);
56 try {
57 const res = await fetcher(new URL(target, base).href, {
58 method: "GET",
59 redirect: "manual",
60 headers: { "user-agent": "forge-hunter" },
61 signal: AbortSignal.timeout(10_000),
62 });
63 const type = res.headers.get("content-type") ?? "";
64 const body = /text|json|xml/.test(type) ? await readBounded(res) : "";
65 await res.body?.cancel().catch(() => {});
66 return { path: target, status: res.status, location: res.headers.get("location"), type, body };
67 } catch {
68 // A dead or hung route is a result (status 0), not a crash that loses the pass.
69 return { path: target, status: 0, location: null, type: "", body: "" };
70 }
71 },
72 };
73 }
test/hunt.test.ts added +133
@@ -0,0 +1,133 @@
1 import { describe, expect, test } from "bun:test";
2 import { huntTarget } from "../src/hunt/hunt.ts";
3 import { linksIn } from "../src/hunt/links.ts";
4 import { inside, probe, type Fetcher } from "../src/hunt/probe.ts";
5
6 const BASE = "https://app.test";
7 type Page = { status?: number; body?: string; type?: string; location?: string };
8
9 const site = (pages: Record<string, Page>): { fetcher: Fetcher; methods: string[] } => {
10 const methods: string[] = [];
11 const fetcher: Fetcher = async (url, init) => {
12 methods.push(String(init.method));
13 const u = new URL(url);
14 const page = pages[u.pathname + u.search] ?? pages[u.pathname] ?? { status: 404, body: "" };
15 const headers: Record<string, string> = { "content-type": page.type ?? "text/html" };
16 if (page.location) headers.location = page.location;
17 return new Response(page.body ?? "", { status: page.status ?? 200, headers });
18 };
19 return { fetcher, methods };
20 };
21
22 const healthy: Record<string, Page> = {
23 "/": { body: '<a href="/explore">Explore</a><a href="/">Home</a>' },
24 "/signin": {},
25 "/explore": {},
26 "/apps": {},
27 "/healthz": {},
28 "/settings": { status: 303, location: "/signin?next=%2Fsettings" },
29 "/new": { status: 303, location: "/signin?next=%2Fnew" },
30 "/migrate": { status: 303, location: "/signin?next=%2Fmigrate" },
31 "/review": { status: 303, location: "/signin?next=%2Freview" },
32 "/contract/openapi.json": {
33 type: "application/json",
34 body: JSON.stringify({ paths: { "/api/v1/apps": { get: { responses: { "200": {} } } }, "/api/v1/x/{id}": { get: {} } } }),
35 },
36 "/api/v1/apps": { type: "application/json", body: "{}" },
37 };
38
39 describe("the hunter", () => {
40 test("a healthy site has no findings, and only GET was sent", async () => {
41 const { fetcher, methods } = site(healthy);
42 const r = await huntTarget(BASE, fetcher);
43 expect(r.findings).toEqual([]);
44 expect(new Set(methods)).toEqual(new Set(["GET"]));
45 });
46
47 test("a menu link that lands on / without being Home is found (#5844)", async () => {
48 const { fetcher } = site({
49 ...healthy,
50 "/": { body: '<a href="/mine">Your repositories</a>' },
51 "/mine": { status: 302, location: "/" },
52 });
53 const r = await huntTarget(BASE, fetcher);
54 expect(r.findings.map((f) => f.key)).toContain("links.lands-home:/mine");
55 });
56
57 test("a 404 link, a 500 route and an open personal page are each found once", async () => {
58 const { fetcher } = site({
59 ...healthy,
60 "/": { body: '<a href="/gone">Gone</a><a href="/gone">Gone again</a>' },
61 "/settings": { status: 200 },
62 "/api/v1/apps": { status: 500, type: "application/json", body: "{}" },
63 });
64 const keys = (await huntTarget(BASE, fetcher)).findings.map((f) => f.key);
65 expect(keys).toContain("links.status:/gone");
66 expect(keys).toContain("contract.no-500:/api/v1/apps");
67 expect(keys).toContain("door.signin-redirect:/settings");
68 expect(keys.filter((k) => k === "links.status:/gone")).toHaveLength(1);
69 });
70
71 test("it refuses a path off the target", async () => {
72 const { fetcher } = site({});
73 await expect(probe(BASE, fetcher).get("https://evil.test/x")).rejects.toThrow();
74 expect(inside(BASE, "//evil.test/x")).toBeNull();
75 expect(inside(BASE, "/a?b=1")).toBe("/a?b=1");
76 });
77
78 test("links are read with their labels", () => {
79 expect(linksIn('<a class="x" href="/a?b=1&amp;c=2"><b>Hi</b> there</a><a href="#top">t</a>')).toEqual([
80 { href: "/a?b=1&c=2", label: "Hi there" },
81 ]);
82 });
83 });
84
85 describe("more detectors", () => {
86 const keys = async (over: Record<string, Page>) => (await huntTarget(BASE, site({ ...healthy, ...over }).fetcher)).findings.map((f) => f.key);
87
88 test("a sign-in redirect with no way back is found", async () => {
89 expect(await keys({ "/new": { status: 303, location: "/signin" } })).toContain("door.signin-next:/new");
90 });
91
92 test("a status the route does not document is found", async () => {
93 expect(await keys({ "/api/v1/apps": { status: 201, type: "application/json", body: "{}" } })).toContain("contract.documented:/api/v1/apps");
94 });
95
96 test("an OpenAPI document that is not JSON is found", async () => {
97 expect(await keys({ "/contract/openapi.json": { body: "<html>" } })).toContain("contract.openapi:/contract/openapi.json");
98 });
99
100 test("a public page that fails is found", async () => {
101 expect(await keys({ "/explore": { status: 500 } })).toContain("door.public-page:/explore");
102 });
103
104 test("an unknown path that answers 200 is found", async () => {
105 expect(await keys({ "/no-such-page-hunter": { status: 200 } })).toContain("door.unknown-404:/no-such-page-hunter");
106 });
107
108 test("a dead route is a finding and does not end the pass", async () => {
109 const { fetcher } = site(healthy);
110 const dying: Fetcher = (url, init) =>
111 new URL(url).pathname === "/explore" ? Promise.reject(new Error("reset")) : fetcher(url, init);
112 const r = await huntTarget(BASE, dying);
113 expect(r.findings.map((f) => f.key)).toContain("door.public-page:/explore");
114 expect((await probe(BASE, dying).get("/explore")).status).toBe(0);
115 });
116
117 test("a body is read only up to its bound", async () => {
118 let pulled = 0;
119 const endless: Fetcher = async () =>
120 new Response(
121 new ReadableStream({
122 pull(c) {
123 pulled += 100_000;
124 c.enqueue(new Uint8Array(100_000).fill(97));
125 },
126 }),
127 { headers: { "content-type": "text/plain" } },
128 );
129 const seen = await probe(BASE, endless).get("/");
130 expect(seen.body.length).toBe(400_000);
131 expect(pulled).toBeLessThan(1_000_000);
132 });
133 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs