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

A wave is a file: src/story/wave.ts reads and writes waves/<version>.md as plain data and refuses a malformed one, naming the file #1510

Merged opened this

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

Merged

scout/forge:scout/1506-a-wave-is-a-file-src-story-wave-ts-reads → main 1 commit fork 1 thread · 1 unresolved

  • Merged. scout/1506-a-wave-is-a-file-src-story-wave-ts-reads was merged into main as cfb58fc.

Review

Sign in to review this change.
3 files changed +240 −0
scripts/gate.ts +1
@@ -52,6 +52,7 @@ export const GATE: readonly string[] = [
5252 "test/review-from-check.test.ts",
5353 "test/review-from-green.test.ts",
5454 "test/themes.test.ts",
55 "test/waves.test.ts",
5556 "test/crew-wrapup.test.ts",
5657 "test/crew-limits.test.ts",
5758 "test/crew-parks.test.ts",
src/story/wave.ts added +167
@@ -0,0 +1,167 @@
1 /**
2 * A wave is a file: `waves/<version>.md`. Front matter (version, date,
3 * headline), three to five highlights each with its why, then every merged
4 * pull as a link. Pure: text in, plain data out, and back. No I/O here.
5 *
6 * ---
7 * version: 0.7.81
8 * date: 2026-10-15
9 * headline: One line
10 * ---
11 *
12 * ## Highlights
13 *
14 * - **Title** — why it matters
15 *
16 * ## Pulls
17 *
18 * - [#12 Title](https://forge.example/trevato/forge/pulls/12)
19 */
20
21 export interface Highlight {
22 title: string;
23 why: string;
24 }
25 export interface WavePull {
26 title: string;
27 url: string;
28 }
29 export interface Wave {
30 version: string;
31 date: string;
32 headline: string;
33 highlights: Highlight[];
34 pulls: WavePull[];
35 }
36 export type WaveResult<T> =
37 | { ok: true; value: T }
38 | { ok: false; error: Error };
39
40 export const MIN_HIGHLIGHTS = 3;
41 export const MAX_HIGHLIGHTS = 5;
42
43 const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
44 const DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
45 const SEP = " — ";
46
47 const fail = (path: string, why: string): { ok: false; error: Error } => ({
48 ok: false,
49 error: new Error(`${path}: ${why}`),
50 });
51
52 const realDate = (s: string): boolean => {
53 const m = DATE.exec(s);
54 if (!m) return false;
55 const d = new Date(`${s}T00:00:00Z`);
56 return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === s;
57 };
58
59 const oneLine = (s: unknown): s is string =>
60 typeof s === "string" && s.trim() !== "" && !/[\r\n]/.test(s) && s === s.trim();
61
62 const safeUrl = (s: string): boolean => {
63 try {
64 const u = new URL(s);
65 return (u.protocol === "https:" || u.protocol === "http:") && !/[()\s]/.test(s);
66 } catch {
67 return false;
68 }
69 };
70
71 /** Check a wave's shape; the reason names `path`. */
72 export function checkWave(path: string, w: Wave): WaveResult<Wave> {
73 if (!oneLine(w.version)) return fail(path, "missing version");
74 if (!VERSION.test(w.version)) return fail(path, `bad version "${w.version}"`);
75 if (!oneLine(w.date)) return fail(path, "missing date");
76 if (!realDate(w.date)) return fail(path, `bad date "${w.date}", want YYYY-MM-DD`);
77 if (!oneLine(w.headline)) return fail(path, "missing headline");
78 const hs = w.highlights;
79 if (!Array.isArray(hs) || hs.length === 0) return fail(path, "no highlights");
80 if (hs.length < MIN_HIGHLIGHTS)
81 return fail(path, `too few highlights: ${hs.length}, want ${MIN_HIGHLIGHTS} to ${MAX_HIGHLIGHTS}`);
82 if (hs.length > MAX_HIGHLIGHTS)
83 return fail(path, `too many highlights: ${hs.length}, want ${MIN_HIGHLIGHTS} to ${MAX_HIGHLIGHTS}`);
84 for (const [i, h] of hs.entries()) {
85 if (!oneLine(h?.title) || h.title.includes(SEP))
86 return fail(path, `highlight ${i + 1} has no usable title`);
87 if (!oneLine(h.why)) return fail(path, `highlight ${i + 1} has no why`);
88 }
89 if (!Array.isArray(w.pulls)) return fail(path, "no pulls list");
90 for (const [i, p] of w.pulls.entries()) {
91 if (!oneLine(p?.title) || /[\[\]]/.test(p.title))
92 return fail(path, `pull ${i + 1} has no usable title`);
93 if (typeof p.url !== "string" || !safeUrl(p.url))
94 return fail(path, `pull ${i + 1} has a bad link`);
95 }
96 return { ok: true, value: w };
97 }
98
99 /** Parse the text of `path` into a wave, or say what is wrong with the file. */
100 export function parseWave(path: string, text: string): WaveResult<Wave> {
101 const lines = text.replace(/\r\n/g, "\n").split("\n");
102 if (lines[0] !== "---") return fail(path, "missing front matter");
103 const end = lines.indexOf("---", 1);
104 if (end < 0) return fail(path, "front matter is not closed");
105 const front: Record<string, string> = {};
106 for (const line of lines.slice(1, end)) {
107 if (line.trim() === "") continue;
108 const m = /^(version|date|headline):[ \t]*(.*)$/.exec(line);
109 if (!m) return fail(path, `unknown front matter line "${line}"`);
110 front[m[1]!] = m[2]!.trim();
111 }
112 const highlights: Highlight[] = [];

A repeated key (two version: lines) overwrites silently. Refuse it, naming the file, and add a test.

113 const pulls: WavePull[] = [];
114 let section = "";
115 for (const raw of lines.slice(end + 1)) {
116 const line = raw.trimEnd();
117 if (line === "") continue;
118 if (line.startsWith("## ")) {
119 section = line.slice(3).trim();
120 if (section !== "Highlights" && section !== "Pulls")
121 return fail(path, `unknown section "${section}"`);
122 continue;
123 }
124 if (section === "Highlights") {
125 const m = /^- \*\*(.+?)\*\* —(?: (.*))?$/.exec(line);
126 if (!m) return fail(path, `bad highlight line "${line}"`);
127 highlights.push({ title: m[1]!, why: (m[2] ?? "").trim() });
128 } else if (section === "Pulls") {
129 const m = /^- \[([^\]]+)\]\(([^()\s]+)\)$/.exec(line);
130 if (!m) return fail(path, `bad pull line "${line}"`);
131 pulls.push({ title: m[1]!, url: m[2]! });
132 } else return fail(path, `text outside a section "${line}"`);
133 }
134 return checkWave(path, {
135 version: front.version ?? "",
136 date: front.date ?? "",
137 headline: front.headline ?? "",
138 highlights,
139 pulls,
140 });
141 }
142
143 /** The file name a wave lives at. */
144 export const wavePath = (version: string): string => `waves/${version}.md`;
145
146 /** Render a wave as the text of its file, or refuse a malformed one. */
147 export function writeWave(w: Wave): WaveResult<string> {
148 const checked = checkWave(wavePath(String(w?.version ?? "")), w);
149 if (!checked.ok) return checked;
150 const out = [
151 "---",
152 `version: ${w.version}`,
153 `date: ${w.date}`,
154 `headline: ${w.headline}`,
155 "---",
156 "",
157 "## Highlights",
158 "",
159 ...w.highlights.map((h) => `- **${h.title}**${SEP}${h.why}`),
160 "",
161 "## Pulls",
162 "",
163 ...w.pulls.map((p) => `- [${p.title}](${p.url})`),
164 "",
165 ];
166 return { ok: true, value: out.join("\n") };
167 }
test/waves.test.ts added +72
@@ -0,0 +1,72 @@
1 import { describe, expect, test } from "bun:test";
2 import { parseWave, writeWave, type Wave } from "../src/story/wave.ts";
3
4 const wave = (): Wave => ({
5 version: "0.7.81",
6 date: "2026-10-15",
7 headline: "Waves are files",
8 highlights: [
9 { title: "One", why: "because one" },
10 { title: "Two", why: "because two" },
11 { title: "Three", why: "because three" },
12 ],
13 pulls: [{ title: "#1 First", url: "https://forge.example/t/f/pulls/1" }],
14 });
15 const P = "waves/0.7.81.md";
16 const text = () => {
17 const r = writeWave(wave());
18 if (!r.ok) throw r.error;
19 return r.value;
20 };
21 const bad = (t: string, re: RegExp) => {
22 const r = parseWave(P, t);
23 expect(r.ok).toBe(false);
24 if (!r.ok) {
25 expect(r.error.message).toContain(P);
26 expect(r.error.message).toMatch(re);
27 }
28 };
29
30 describe("wave file", () => {
31 test("round-trips through write and parse", () => {
32 const r = parseWave(P, text());
33 expect(r).toEqual({ ok: true, value: wave() });
34 const again = writeWave((r as { value: Wave }).value);
35 expect(again).toEqual({ ok: true, value: text() });
36 });
37 test("an empty pull list round-trips", () => {
38 const w = { ...wave(), pulls: [] };
39 const t = writeWave(w);
40 expect(t.ok && parseWave(P, t.value)).toEqual({ ok: true, value: w });
41 });
42 test("missing front matter, version, date, headline", () => {
43 bad("# hi", /front matter/);
44 bad(text().replace("version: 0.7.81\n", ""), /missing version/);
45 bad(text().replace("version: 0.7.81", "version: soon"), /bad version/);
46 bad(text().replace("date: 2026-10-15\n", ""), /missing date/);
47 bad(text().replace("2026-10-15", "2026-02-30"), /bad date/);
48 bad(text().replace("2026-10-15", "yesterday"), /bad date/);
49 bad(text().replace("headline: Waves are files\n", ""), /missing headline/);
50 });
51 test("no, too few, too many highlights", () => {
52 const t = text();
53 const hs = t.split("\n").filter((l) => l.startsWith("- **"));
54 bad(hs.reduce((a, l) => a.replace(l + "\n", ""), t), /no highlights/);
55 bad(t.replace(hs[2]! + "\n", ""), /too few highlights/);
56 const extra = ["- **Four** — w4", "- **Five** — w5", "- **Six** — w6"];
57 bad(t.replace(hs[2]!, [hs[2]!, ...extra].join("\n")), /too many highlights/);
58 });
59 test("a highlight without a why, a bad pull line", () => {
60 bad(text().replace("— because one", "—"), /no why/);
61 bad(text().replace("- **One** — because one", "- One"), /bad highlight/);
62 bad(text().replace("(https://forge.example/t/f/pulls/1)", "(javascript:x)"), /bad link/);
63 bad(text() + "- stray\n", /bad pull line/);
64 });
65 test("writeWave refuses a malformed wave, naming the file", () => {
66 const r = writeWave({ ...wave(), highlights: [] });
67 expect(r.ok).toBe(false);
68 if (!r.ok) expect(r.error.message).toContain("waves/0.7.81.md");
69 const s = writeWave({ ...wave(), date: "nope" });
70 expect(s.ok).toBe(false);
71 });
72 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs