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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1506-a-wave-is-a-file-src-story-wave-ts-reads → main 1 commit fork 1 thread · 1 unresolved
3 files
changed +240
−0
scripts/gate.ts +1
| @@ -52,6 +52,7 @@ export const GATE: readonly string[] = [ | ||
| 52 | 52 | "test/review-from-check.test.ts", |
| 53 | 53 | "test/review-from-green.test.ts", |
| 54 | 54 | "test/themes.test.ts", |
| 55 | "test/waves.test.ts", | |
| 55 | 56 | "test/crew-wrapup.test.ts", |
| 56 | 57 | "test/crew-limits.test.ts", |
| 57 | 58 | "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[] = []; | |
| 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 | }); |
new above
offline
A repeated key (two
version:lines) overwrites silently. Refuse it, naming the file, and add a test.