From 77e6becd8df08fea3f90d42925c993dbb9bdd191 Mon Sep 17 00:00:00 2001 From: Takashi Matsuyama Date: Fri, 9 Oct 2026 01:51:19 +0900 Subject: [PATCH 1/3] basou: Let a board declare what to observe and what each stage is judged by A board.yaml of board_version 2 may list, under observe, what a judge observes outside basou, each under the key its record holds it by, of seven kinds (npm_version, github_release, github_open_issues, github_open_prs, github_ci, page_version, manual), and may give each stage look and notes. basou makes none of these observations and sends nothing. Each value is held to a shape a command can be built from: a lowercase npm package name, owner/name, a workflow file name, a branch name that cannot start an option, an https:// URL with no credentials, quotes or whitespace. A version 1 board is read as before; one that has these keys is told that version 2 reads them. A board that declares observations is recorded with exactly those, one not made as null with its error, so a closed exit is never dropped unsaid and a mistyped key never loses the previous record's value. Records move to record_version 2, which holds the observations and the stages' look and notes in the declaration's snapshot. Version 1 records are still read, compared with and drawn. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 25 +++ packages/cli/src/commands/board.test.ts | 34 ++- packages/core/src/board/declaration.test.ts | 217 +++++++++++++++++++- packages/core/src/board/declaration.ts | 198 ++++++++++++++++-- packages/core/src/board/index.ts | 6 + packages/core/src/board/page.test.ts | 21 +- packages/core/src/board/page.ts | 3 +- packages/core/src/board/previous.test.ts | 17 +- packages/core/src/board/previous.ts | 3 +- packages/core/src/board/record.test.ts | 99 +++++++++ packages/core/src/board/record.ts | 32 ++- packages/core/src/index.ts | 6 + 12 files changed, 623 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e6b48f3d..2240a51a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,31 @@ All notable changes to **basou** are recorded here. The project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html) starting with v0.1.0. +## Unreleased + +### Added + +- **A board can declare what to observe outside basou, and what to look at + when judging each stage (`board_version: 2`, experimental).** `observe` + lists observations, each under the `key` its record holds it by, of seven + kinds: an npm package's published version (`npm_version`, `package`), a + GitHub repository's latest release, open issues or open pull requests + (`github_release`, `github_open_issues`, `github_open_prs`, `repo` as + `owner/name`), the conclusion of a workflow's latest run on a branch + (`github_ci`, `repo`, `workflow` as its file name, `branch`, by default + `main`), the version a web page shows (`page_version`, an `https://` URL + with no user name or password), and one only a person can make (`manual`, + `how`, in words). basou does not make them and sends nothing; each value is + held to its shape when the board is read, since the steps a judge follows + turn it into a command. A stage may add `look` (what to look at) and + `notes` (what to mind when judging it). A version 1 board is read as + before, and is told that version 2 reads these keys when it has them. A + record of a board that declares observations must give exactly those, one + not made as `null` with its error: one left out, or one the board does not + declare, is refused, nothing written. Records are now `record_version: 2`, + holding the observations and the stages' `look` and `notes` the board + declared; a record of version 1 is still read, compared with and drawn. + ## 0.67.0 — 2026-10-08 ### Added diff --git a/packages/cli/src/commands/board.test.ts b/packages/cli/src/commands/board.test.ts index 34d9cd6b..8dd71649 100644 --- a/packages/cli/src/commands/board.test.ts +++ b/packages/cli/src/commands/board.test.ts @@ -925,7 +925,7 @@ describe("basou board record", () => { expect(await readdir(join(repo, "board", "records"))).toEqual([name]); const written = JSON.parse(await readFile(join(repo, result.record ?? ""), "utf8")); expect(written).toMatchObject({ - record_version: 1, + record_version: 2, measure: { digest: input.measure_digest }, judged_by: { model: "Claude Opus 5.5", self_reported: true }, order_anomalies: [], @@ -938,6 +938,36 @@ describe("basou board record", () => { expect(process.exitCode ?? 0).toBe(0); }); + it("records a version 2 board with exactly the observations it declares", async () => { + const board = JSON.parse(boardYaml([MD])); + board.board_version = 2; + board.stages["05"].look = ["the published version"]; + board.observe = [{ key: "npm_cli", kind: "npm_version", package: "@scope/cli" }]; + const { repo, input } = await judged(undefined, JSON.stringify(board)); + const { err } = capture(); + await runBoardRecord({}, fed(repo, input)); + expect(err.join("\n")).toContain( + "observed.npm_cli: the board declares it, so it must be given (null, with an error, when it was not observed)", + ); + expect(err.join("\n")).toContain("nothing was written"); + expect(process.exitCode).toBe(1); + process.exitCode = undefined; + const observed = { + npm_cli: { value: null, observed_at: "2026-10-08", source: "npm", error: "offline" }, + }; + const result = await doRunBoardRecord({}, fed(repo, { ...input, observed })); + const written = JSON.parse(await readFile(join(repo, result.record ?? ""), "utf8")); + expect(written).toMatchObject({ + record_version: 2, + measure: { board_version: 2 }, + observed, + declaration: { + observe: [{ key: "npm_cli", kind: "npm_version", package: "@scope/cli" }], + stages: { "05": { meaning: "stage 05", look: ["the published version"] } }, + }, + }); + }); + it("checks and measures but writes nothing with --dry-run, and reports as JSON", async () => { const { repo, input } = await judged(); const { out } = capture(); @@ -983,7 +1013,7 @@ describe("basou board record", () => { ); expect(out.join("\n")).toBe(`Recorded ${result.record}\nNot compared with a previous record.`); await expect(readFile(join(deeper, result.record ?? ""), "utf8")).resolves.toContain( - '"record_version": 1', + '"record_version": 2', ); }); diff --git a/packages/core/src/board/declaration.test.ts b/packages/core/src/board/declaration.test.ts index 90a0c660..7765bca5 100644 --- a/packages/core/src/board/declaration.test.ts +++ b/packages/core/src/board/declaration.test.ts @@ -190,17 +190,16 @@ describe("parseBoardDeclaration: accepted declarations", () => { describe("parseBoardDeclaration: board_version", () => { it("stops at an unknown board_version, naming it, whatever else is wrong", () => { - expect(errorsOf(parse({ ...board(), board_version: 2, title: "", extra: 1 }))).toEqual([ - "board_version: this basou reads board_version 1, not 2", + expect(errorsOf(parse({ ...board(), board_version: 3, title: "", extra: 1 }))).toEqual([ + "board_version: this basou reads board_version 1 or 2, not 3", ]); }); it("refuses a board_version that is missing or not a number", () => { const { board_version: _v, ...missing } = board(); - expect(errorsOf(parse(missing))).toEqual(["board_version: Invalid input: expected 1"]); - expect(errorsOf(parse({ ...board(), board_version: "1" }))).toEqual([ - "board_version: Invalid input: expected 1", - ]); + const reads = "board_version: must be a board version this basou reads (1 or 2)"; + expect(errorsOf(parse(missing))).toEqual([reads]); + expect(errorsOf(parse({ ...board(), board_version: "1" }))).toEqual([reads]); }); }); @@ -1134,11 +1133,11 @@ describe("parseBoardDeclaration: what the review found", () => { }); it("names an unknown board_version before a YAML warning", () => { - const plain = stringify({ ...board(), board_version: 2 }); + const plain = stringify({ ...board(), board_version: 3 }); expect(plain).toContain("title: A board\n"); - const text = plain.replace("title: A board\n", "title: !v2tag A board\n"); + const text = plain.replace("title: A board\n", "title: !v3tag A board\n"); expect(errorsOf(parseBoardDeclaration(text, { manifestRepoPaths: REPOS }))).toEqual([ - "board_version: this basou reads board_version 1, not 2", + "board_version: this basou reads board_version 1 or 2, not 3", ]); }); @@ -1211,3 +1210,203 @@ describe("parseBoardDeclaration: what the review found", () => { } }); }); + +// A board of version 2, observing one thing of each kind. +function boardV2(): Record { + const doc = board(); + entry(doc.stages, "05").look = ["the published version on npm"]; + entry(doc.stages, "05").notes = ["on main but not published is part"]; + return { + ...doc, + board_version: 2, + observe: [ + { key: "npm_cli", kind: "npm_version", package: "@scope/cli" }, + { key: "github_release", kind: "github_release", repo: "owner/name" }, + { key: "github_open_issues", kind: "github_open_issues", repo: "owner/name" }, + { key: "github_open_prs", kind: "github_open_prs", repo: "owner/name.js" }, + { key: "github_main_ci", kind: "github_ci", repo: "owner/name", workflow: "quality.yml" }, + { key: "site_en", kind: "page_version", url: "https://example.com/" }, + { key: "db_users", kind: "manual", how: "count the users in production, read only" }, + ], + }; +} + +// A version 2 board whose only observation is `observe`. +function withObserve(observe: Record): Record { + return { ...boardV2(), observe: [observe] }; +} + +describe("parseBoardDeclaration: board_version 2", () => { + it("accepts what to observe and what to look at for each stage, filling the default branch", () => { + const result = parse(boardV2()); + if (!result.ok) throw new Error(result.errors.join("\n")); + const { declaration } = result; + expect(declaration.board_version).toBe(2); + expect(declaration.observe.map((o) => [o.key, o.kind])).toEqual([ + ["npm_cli", "npm_version"], + ["github_release", "github_release"], + ["github_open_issues", "github_open_issues"], + ["github_open_prs", "github_open_prs"], + ["github_main_ci", "github_ci"], + ["site_en", "page_version"], + ["db_users", "manual"], + ]); + expect(declaration.observe[4]).toEqual({ + key: "github_main_ci", + kind: "github_ci", + repo: "owner/name", + workflow: "quality.yml", + branch: "main", + }); + expect(declaration.stages["05"]).toEqual({ + meaning: "stage 05", + look: ["the published version on npm"], + notes: ["on main but not published is part"], + }); + expect(declaration.stages["01"]).toEqual({ meaning: "stage 01" }); + }); + + it("reads a version 2 board without observe as observing nothing", () => { + const { observe: _o, ...doc } = boardV2(); + const result = parse(doc); + if (!result.ok) throw new Error(result.errors.join("\n")); + expect(result.declaration.observe).toEqual([]); + }); + + it("still reads version 1, as observing nothing", () => { + const result = parse(board()); + if (!result.ok) throw new Error(result.errors.join("\n")); + expect(result.declaration.board_version).toBe(1); + expect(result.declaration.observe).toEqual([]); + }); + + it("says that version 2 reads observe and a stage's look and notes when version 1 has them", () => { + const doc = boardV2(); + doc.board_version = 1; + entry(doc.stages, "03").colour = "red"; + expect(errorsOf(parse(doc))).toEqual([ + "stages.03: unknown key 'colour'", + "stages.05: unknown keys 'look', 'notes' (board_version 2 reads it)", + "(top level): unknown key 'observe' (board_version 2 reads it)", + ]); + }); + + it("refuses an unknown kind, a kind's missing keys, keys of another kind and a duplicate key", () => { + const doc = boardV2(); + doc.observe = [ + { key: "a", kind: "rss_feed", url: "https://example.com/" }, + { key: "b", kind: "github_ci", repo: "owner/name" }, + { key: "c", kind: "npm_version", package: "x", repo: "owner/name" }, + { key: "d", kind: "manual" }, + { key: "d", kind: "manual", how: "ask" }, + { kind: "manual", how: "ask" }, + { key: "Bad", kind: "manual", how: "ask" }, + ]; + expect(errorsOf(parse(doc))).toEqual([ + "observe[0].kind: Invalid discriminator value. Expected 'npm_version' | 'github_release' | 'github_open_issues' | 'github_open_prs' | 'github_ci' | 'page_version' | 'manual'", + "observe[1].workflow: Invalid input: expected string, received undefined", + "observe[2]: unknown key 'repo'", + "observe[3].how: Invalid input: expected string, received undefined", + "observe[5].key: Invalid input: expected string, received undefined", + "observe[6].key: must start with a lowercase letter and use only a-z, 0-9, '_' and '-'", + "observe[4].key: duplicate observation key 'd' (first at observe[3])", + ]); + }); + + it.each<[string, string]>([ + ["Scope/Cli", "uppercase"], + ["-x", "a leading '-'"], + ["@scope/-x", "a leading '-' after the scope"], + ["a b", "a space"], + ["a;rm", "a ';'"], + ["@scope", "a scope alone"], + ["", "nothing"], + ["a".repeat(215), "too long"], + ])("refuses the npm package %j (%s)", (pkg) => { + expect(errorsOf(parse(withObserve({ key: "n", kind: "npm_version", package: pkg })))).toEqual([ + "observe[0].package: must be an npm package name such as name or @scope/name, in lowercase", + ]); + }); + + it.each<[string]>([ + ["name"], + ["-owner/name"], + ["owner/name/more"], + ["owner/.."], + ["owner/."], + ["own er/name"], + ["owner/na'me"], + ])("refuses the GitHub repository %j", (repo) => { + for (const kind of ["github_release", "github_open_issues", "github_open_prs"]) { + expect(errorsOf(parse(withObserve({ key: "g", kind, repo })))).toEqual([ + "observe[0].repo: must be a GitHub repository written as owner/name", + ]); + } + }); + + it("refuses a workflow that is not a file name and a branch a command could misread", () => { + const doc = boardV2(); + doc.observe = [ + { key: "a", kind: "github_ci", repo: "o/n", workflow: ".github/workflows/q.yml" }, + { key: "b", kind: "github_ci", repo: "o/n", workflow: "quality" }, + { key: "c", kind: "github_ci", repo: "o/n", workflow: "-q.yml" }, + { key: "d", kind: "github_ci", repo: "o/n", workflow: "q.yml", branch: "-x" }, + { key: "e", kind: "github_ci", repo: "o/n", workflow: "q.yml", branch: "a..b" }, + { key: "f", kind: "github_ci", repo: "o/n", workflow: "q.yml", branch: "a b" }, + { key: "g", kind: "github_ci", repo: "o/n", workflow: "q.yml", branch: "release/" }, + { key: "h", kind: "github_ci", repo: "o/n", workflow: "q.yaml", branch: "release/1.x" }, + ]; + const workflow = "must be the file name of a workflow, such as quality.yml"; + const branch = + "must be a branch name such as main, using only A-Z, a-z, 0-9, '.', '_', '-' and '/'"; + expect(errorsOf(parse(doc))).toEqual([ + `observe[0].workflow: ${workflow}`, + `observe[1].workflow: ${workflow}`, + `observe[2].workflow: ${workflow}`, + `observe[3].branch: ${branch}`, + `observe[4].branch: ${branch}`, + `observe[5].branch: ${branch}`, + `observe[6].branch: ${branch}`, + ]); + }); + + it.each<[string, string]>([ + ["http://example.com/", "must be an https:// URL"], + ["HTTPS://example.com/", "must be an https:// URL"], + ["ftp://example.com/", "must be an https:// URL"], + ["example.com", "must be an https:// URL"], + ["https://", "must be an https:// URL"], + ["https://example.com/a b", "must be printable ASCII with no spaces"], + ["https://example.com/é", "must be printable ASCII with no spaces"], + ["https://example.com/'; rm -rf ~", "must be printable ASCII with no spaces"], + ["https://example.com/a'b", "must not contain quotes, backslashes, '<' or '>'"], + ["https://example.com/a\\b", "must not contain quotes, backslashes, '<' or '>'"], + ["https://user:secret@example.com/", "must not carry a user name or a password"], + ["https://user@example.com/", "must not carry a user name or a password"], + ])("refuses the page %j", (url, problem) => { + expect(errorsOf(parse(withObserve({ key: "p", kind: "page_version", url })))).toEqual([ + `observe[0].url: ${problem}`, + ]); + }); + + it("takes a page's query and fragment, and a manual observation's how as written", () => { + const doc = boardV2(); + doc.observe = [ + { key: "p", kind: "page_version", url: "https://example.com:8443/ja/?a=1&b=2#v" }, + { key: "m", kind: "manual", how: "count with `psql`; it's read only, $(not run)" }, + ]; + const result = parse(doc); + if (!result.ok) throw new Error(result.errors.join("\n")); + expect(result.declaration.observe).toEqual(doc.observe); + }); + + it("refuses an empty look or note of a stage", () => { + const doc = boardV2(); + entry(doc.stages, "02").look = [""]; + entry(doc.stages, "02").notes = "not a list"; + expect(errorsOf(parse(doc))).toEqual([ + "stages.02.look[0]: must be a non-empty string", + "stages.02.notes: Invalid input: expected array, received string", + ]); + }); +}); diff --git a/packages/core/src/board/declaration.ts b/packages/core/src/board/declaration.ts index 2b870343..6f67c1ed 100644 --- a/packages/core/src/board/declaration.ts +++ b/packages/core/src/board/declaration.ts @@ -13,8 +13,15 @@ import { TaskStatusSchema } from "../schemas/task.schema.js"; * The file is user-written and is read strictly: an unknown key anywhere is * an error, so a typo is refused instead of silently ignored, and every * problem the reader can see is reported at once. + * + * Every version ever written is read: 1, and 2, which adds what to observe + * outside basou (`observe`) and what to look at when judging a stage + * (`stages..look` and `.notes`). */ -export const BOARD_VERSION = 1; +export const BOARD_VERSION = 2; + +/** The board versions this basou reads. A version is added here, never removed. */ +export const BOARD_VERSIONS = [1, 2] as const; /** The stage ids of every board. Their order is fixed; a board sets only their meanings. */ export const BOARD_STAGE_IDS = ["01", "02", "03", "04", "05", "06"] as const; @@ -245,19 +252,118 @@ const measureSchema = z.discriminatedUnion("kind", [ trailCountSchema, ]); -const stageSchema = z.strictObject({ meaning: nonEmptyText }); +/** + * What a board can declare to observe outside basou. basou never makes these + * observations (it sends nothing): `basou board guide` says how to make each, + * and the judge records what it found under the same key. + */ +export const BOARD_OBSERVE_KINDS = [ + "npm_version", + "github_release", + "github_open_issues", + "github_open_prs", + "github_ci", + "page_version", + "manual", +] as const; -const boardSchema = z.strictObject({ - board_version: z.literal(BOARD_VERSION), - title: nonEmptyText, - stages: z.strictObject({ - "01": stageSchema, - "02": stageSchema, - "03": stageSchema, - "04": stageSchema, - "05": stageSchema, - "06": stageSchema, +/** The branch a `github_ci` observation looks at when it names none. */ +export const BOARD_DEFAULT_CI_BRANCH = "main"; + +// The values of an observation become words of a command the guide prints, +// so each is held to a shape that cannot start an option, end a quotation or +// carry a second command. + +// An npm package name, scoped or not, in lowercase. +const NPM_PACKAGE = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/; +const npmPackage = z.string().refine((s) => s.length <= 214 && NPM_PACKAGE.test(s), { + error: "must be an npm package name such as name or @scope/name, in lowercase", +}); + +// A GitHub repository as owner/name. +const GITHUB_REPO = /^[A-Za-z0-9][A-Za-z0-9-]{0,38}\/[A-Za-z0-9._-]{1,100}$/; +const githubRepo = z.string().refine((s) => GITHUB_REPO.test(s) && !/\/\.{1,2}$/.test(s), { + error: "must be a GitHub repository written as owner/name", +}); + +// The file name of a workflow, as gh run list --workflow takes it. +const workflowFile = z + .string() + .refine((s) => s.length <= 255 && /^[A-Za-z0-9_][A-Za-z0-9._-]*\.ya?ml$/.test(s), { + error: "must be the file name of a workflow, such as quality.yml", + }); + +// A branch name without what a shell or git would read as something else. +const branchName = z + .string() + .refine( + (s) => + /^[A-Za-z0-9_][A-Za-z0-9._/-]*$/.test(s) && + !s.includes("..") && + !s.includes("//") && + !/[/.]$/.test(s), + { + error: "must be a branch name such as main, using only A-Z, a-z, 0-9, '.', '_', '-' and '/'", + }, + ); + +// Why a text is not an https:// URL a command can be given as it is, or undefined. +function httpsUrlProblem(s: string): string | undefined { + if (!/^[\x21-\x7e]+$/.test(s)) return "must be printable ASCII with no spaces"; + if (/['"`\\<>]/.test(s)) return "must not contain quotes, backslashes, '<' or '>'"; + if (!s.startsWith("https://")) return "must be an https:// URL"; + let url: URL; + try { + url = new URL(s); + } catch { + return "must be an https:// URL"; + } + if (url.hostname === "") return "must name a host"; + if (url.username !== "" || url.password !== "") { + return "must not carry a user name or a password"; + } + return undefined; +} + +const httpsUrl = z.string().superRefine((s, ctx) => { + const problem = httpsUrlProblem(s); + if (problem !== undefined) ctx.addIssue({ code: "custom", message: problem }); +}); + +const observeSchema = z.discriminatedUnion("kind", [ + z.strictObject({ key: idText, kind: z.literal("npm_version"), package: npmPackage }), + z.strictObject({ key: idText, kind: z.literal("github_release"), repo: githubRepo }), + z.strictObject({ key: idText, kind: z.literal("github_open_issues"), repo: githubRepo }), + z.strictObject({ key: idText, kind: z.literal("github_open_prs"), repo: githubRepo }), + z.strictObject({ + key: idText, + kind: z.literal("github_ci"), + repo: githubRepo, + workflow: workflowFile, + branch: branchName.default(BOARD_DEFAULT_CI_BRANCH), }), + z.strictObject({ key: idText, kind: z.literal("page_version"), url: httpsUrl }), + z.strictObject({ key: idText, kind: z.literal("manual"), how: nonEmptyText }), +]); + +const stageSchemaV1 = z.strictObject({ meaning: nonEmptyText }); + +const stageSchemaV2 = z.strictObject({ + meaning: nonEmptyText, + look: z.array(nonEmptyText).optional(), + notes: z.array(nonEmptyText).optional(), +}); + +const stagesOf = (stage: S) => + z.strictObject({ "01": stage, "02": stage, "03": stage, "04": stage, "05": stage, "06": stage }); + +const boardVersionText = z.literal(BOARD_VERSIONS, { + error: `must be a board version this basou reads (${BOARD_VERSIONS.join(" or ")})`, +}); + +// The keys every version has after its stages, in the order a problem with +// them is reported. +const boardFields = { lanes: z .array( z.strictObject({ @@ -309,11 +415,33 @@ const boardSchema = z.strictObject({ .array(z.strictObject({ date: dateText, label: nonEmptyText, ref: nonEmptyText })) .optional(), }), +}; + +const boardSchemaV1 = z.strictObject({ + board_version: boardVersionText, + title: nonEmptyText, + stages: stagesOf(stageSchemaV1), + ...boardFields, }); -export type BoardDeclaration = z.output; +const boardSchemaV2 = z.strictObject({ + board_version: boardVersionText, + title: nonEmptyText, + stages: stagesOf(stageSchemaV2), + ...boardFields, + observe: z.array(observeSchema).default([]), +}); + +/** + * A declaration as read, whatever its version: one of version 1 has no + * observations and no stage has `look` or `notes`. + */ +export type BoardDeclaration = z.output; export type BoardMeasure = BoardDeclaration["measures"][number]; export type BoardMeasureKind = BoardMeasure["kind"]; +/** One entry of `observe`: what to observe outside basou, and the key it is recorded under. */ +export type BoardObserve = BoardDeclaration["observe"][number]; +export type BoardObserveKind = BoardObserve["kind"]; /** What the reader needs from outside the file. */ export type BoardDeclarationContext = { @@ -384,10 +512,12 @@ export function parseBoardDeclaration( // Before the warnings and the keys: a file in a newer shape may use what // this basou does not know, and the version is what it should be told. const version = raw.board_version; - if (typeof version === "number" && version !== BOARD_VERSION) { + if (typeof version === "number" && !(BOARD_VERSIONS as readonly number[]).includes(version)) { return { ok: false, - errors: [`board_version: this basou reads board_version ${BOARD_VERSION}, not ${version}`], + errors: [ + `board_version: this basou reads board_version ${BOARD_VERSIONS.join(" or ")}, not ${version}`, + ], }; } const yamlErrors = [ @@ -397,7 +527,18 @@ export function parseBoardDeclaration( if (yamlErrors.length > 0) return { ok: false, errors: yamlErrors }; const errors: string[] = []; - const parsed = boardSchema.safeParse(raw); + // A file with no version, or one that is not a number, is held to the + // newest shape. + if (version === 1) { + const parsed = boardSchemaV1.safeParse(raw); + if (!parsed.success) { + for (const issue of parsed.error.issues) errors.push(formatVersion1Issue(issue)); + } + errors.push(...crossCheck(raw, context)); + if (errors.length > 0 || !parsed.success) return { ok: false, errors }; + return { ok: true, declaration: { ...parsed.data, observe: [] } }; + } + const parsed = boardSchemaV2.safeParse(raw); if (!parsed.success) { for (const issue of parsed.error.issues) errors.push(formatIssue(issue)); } @@ -406,6 +547,20 @@ export function parseBoardDeclaration( return { ok: true, declaration: parsed.data }; } +// The keys version 2 added, which a file of version 1 has to move up to use. +const VERSION_2_KEYS = new Set(["observe", "look", "notes"]); + +function formatVersion1Issue(issue: Issue): string { + const formatted = formatIssue(issue); + if (issue.code !== "unrecognized_keys") return formatted; + const atTop = issue.path.length === 0; + const atStage = issue.path.length === 2 && issue.path[0] === "stages"; + const added = issue.keys.some( + (k) => (atTop && k === "observe") || (atStage && VERSION_2_KEYS.has(k) && k !== "observe"), + ); + return added ? `${formatted} (board_version 2 reads it)` : formatted; +} + // The first line of a parser message, without the ':' that introduces the // excerpt prettyErrors appends. function firstLine(message: string): string { @@ -524,22 +679,27 @@ function crossCheck(raw: Record, context: BoardDeclarationConte const errors: string[] = []; // The ids of a list, reporting duplicates; undefined when it is not a list. - const idsOf = (key: string, what: string): Set | undefined => { + const idsOf = (key: string, what: string, field = "id"): Set | undefined => { const list = entries(raw, key); if (list === undefined) return undefined; const seen = new Map(); for (const [i, entry] of list) { - const id = stringAt(entry, "id"); + const id = stringAt(entry, field); if (id === undefined) continue; const first = seen.get(id); if (first === undefined) seen.set(id, i); - else errors.push(`${key}[${i}].id: duplicate ${what} id '${id}' (first at ${key}[${first}])`); + else + errors.push( + `${key}[${i}].${field}: duplicate ${what} ${field} '${id}' (first at ${key}[${first}])`, + ); } return new Set(seen.keys()); }; const laneIds = idsOf("lanes", "lane"); const measureIds = idsOf("measures", "measure"); idsOf("ratios", "ratio"); + // A version 1 file has no observations: its `observe` is refused as a key. + if (raw.board_version !== 1) idsOf("observe", "observation", "key"); const repoPaths = new Set(context.manifestRepoPaths); const declared = diff --git a/packages/core/src/board/index.ts b/packages/core/src/board/index.ts index f3c11e81..cf4407a2 100644 --- a/packages/core/src/board/index.ts +++ b/packages/core/src/board/index.ts @@ -18,14 +18,19 @@ export type { BoardDeclarationResult, BoardMeasure, BoardMeasureKind, + BoardObserve, + BoardObserveKind, } from "./declaration.js"; export { BOARD_DEFAULT_AT, BOARD_DEFAULT_CAPTURE_GROUP, + BOARD_DEFAULT_CI_BRANCH, + BOARD_OBSERVE_KINDS, BOARD_REGEX_FLAGS, BOARD_STAGE_IDS, BOARD_TRAIL_COUNTS, BOARD_VERSION, + BOARD_VERSIONS, parseBoardDeclaration, } from "./declaration.js"; export type { @@ -83,6 +88,7 @@ export { BOARD_AXIS_REVIEW_TRIGGERS, BOARD_CELL_STATES, BOARD_RECORD_VERSION, + BOARD_RECORD_VERSIONS, buildRecord, orderAnomalies, parseRecordInput, diff --git a/packages/core/src/board/page.test.ts b/packages/core/src/board/page.test.ts index a1f623f8..ad990d37 100644 --- a/packages/core/src/board/page.test.ts +++ b/packages/core/src/board/page.test.ts @@ -190,6 +190,23 @@ describe("boardPage", () => { }); }); + it("draws a record of version 2, which holds the observations declared, after one of version 1", async () => { + await mkdir(records); + await place(A, record()); + const v2 = record({ record_version: 2 }); + v2.declaration = { + ...v2.declaration, + observe: [{ key: "npm_cli", kind: "npm_version", package: "@scope/cli" }], + } as typeof v2.declaration; + await place(B, v2); + const page = await boardPage(records); + if (page.status !== "ok") throw new Error(page.why); + expect(page.id).toBe(B); + expect(page.board.heading.title).toBe("Test board"); + const older = await boardPage(records, A); + expect(older.status).toBe("ok"); + }); + it("draws the heading, the tiles and the observations from the record", async () => { await mkdir(records); await place(A, record()); @@ -501,11 +518,11 @@ describe("boardPage", () => { older: null, newer: B, }); - await place(A, record({ record_version: 2 })); + await place(A, record({ record_version: 3 })); expect(await boardPage(records, A)).toMatchObject({ why: "unknown_version", id: A, - version: 2, + version: 3, }); await place(A, record({ prose: { summary: 1 } })); expect(await boardPage(records, A)).toMatchObject({ why: "not_a_record", id: A }); diff --git a/packages/core/src/board/page.ts b/packages/core/src/board/page.ts index f7e325dd..ac419770 100644 --- a/packages/core/src/board/page.ts +++ b/packages/core/src/board/page.ts @@ -6,9 +6,10 @@ import { findErrorCode } from "../lib/error-codes.js"; import { isCalendarDate } from "./declaration.js"; import { diffCells } from "./diff.js"; import { readBoardRecordFile, recordIds } from "./previous.js"; +import { BOARD_RECORD_VERSIONS } from "./record.js"; // The record versions the page draws. A version is added here, never removed. -const DRAWN_VERSIONS = new Set([1]); +const DRAWN_VERSIONS = new Set(BOARD_RECORD_VERSIONS); const text = z.string(); const count = z.number().nullable(); diff --git a/packages/core/src/board/previous.test.ts b/packages/core/src/board/previous.test.ts index 803bf8f5..94314c9b 100644 --- a/packages/core/src/board/previous.test.ts +++ b/packages/core/src/board/previous.test.ts @@ -58,6 +58,19 @@ describe("readPreviousRecords", () => { }); }); + it("reads a record of version 2 after one of version 1", async () => { + await mkdir(records); + await place(`${A}.json`, record({ axis_review: REVIEW })); + await place(`${B}.json`, record({ record_version: 2 })); + const read = await readPreviousRecords(records); + expect(read.last).toMatchObject({ status: "found", id: B, record: { record_version: 2 } }); + expect(read.lastReview).toMatchObject({ + status: "found", + id: A, + record: { record_version: 1 }, + }); + }); + it("takes the last by name as the previous record, and the last with an axis review", async () => { await mkdir(records); await place(`${A}.json`, record({ axis_review: REVIEW, judged_by: { model: "Old Model" } })); @@ -99,10 +112,10 @@ describe("readPreviousRecords", () => { status: "unreadable", reason: `the record ${B} could not be read as JSON`, }); - await place(`${C}.json`, record({ record_version: 2 })); + await place(`${C}.json`, record({ record_version: 3 })); const unread = { status: "unreadable", - reason: `the record ${C} is of record_version 2, which this basou does not read`, + reason: `the record ${C} is of record_version 3, which this basou does not read`, }; expect(await readPreviousRecords(records)).toEqual({ last: unread, lastReview: unread }); await place(`${C}.json`, record({ cells: "all done" })); diff --git a/packages/core/src/board/previous.ts b/packages/core/src/board/previous.ts index fbb68ece..b1cb747e 100644 --- a/packages/core/src/board/previous.ts +++ b/packages/core/src/board/previous.ts @@ -3,10 +3,11 @@ import { join } from "node:path"; import { z } from "zod"; import { isUlidBody } from "../ids/ulid.js"; import { findErrorCode } from "../lib/error-codes.js"; +import { BOARD_RECORD_VERSIONS } from "./record.js"; // The record versions this reader reads. A version is added here, never // removed, when the shape of a record changes. -const READABLE_VERSIONS = new Set([1]); +const READABLE_VERSIONS = new Set(BOARD_RECORD_VERSIONS); function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); diff --git a/packages/core/src/board/record.test.ts b/packages/core/src/board/record.test.ts index 4f0df92e..683355db 100644 --- a/packages/core/src/board/record.test.ts +++ b/packages/core/src/board/record.test.ts @@ -43,6 +43,32 @@ function declaration(): BoardDeclaration { return result.declaration; } +// A board of version 2 that declares two observations. +function observingDeclaration(): BoardDeclaration { + const result = parseBoardDeclaration( + stringify({ + board_version: 2, + title: "Test board", + stages: Object.fromEntries( + STAGES.map((id) => [id, { meaning: `stage ${id}`, look: [`what ${id} needs`] }]), + ), + lanes: [ + { id: "core", name: "Core" }, + { id: "docs", name: "Docs" }, + ], + observe: [ + { key: "npm_cli", kind: "npm_version", package: "@scope/cli" }, + { key: "users", kind: "manual", how: "ask the operator" }, + ], + axis: { version: 1, review_due_days: 60 }, + effort: { start: "2026-04-28", time_zone: "UTC" }, + }), + { manifestRepoPaths: [] }, + ); + if (!result.ok) throw new Error(result.errors.join("\n")); + return result.declaration; +} + const DIGEST = `sha256:${"a".repeat(64)}`; function cells(states: Record = {}) { @@ -273,6 +299,77 @@ describe("parseRecordInput", () => { }); }); +describe("parseRecordInput: a board that declares what to observe", () => { + const at = "2026-10-08T10:00:00+09:00"; + const made = { value: "1.0.0", observed_at: at, source: "npm view @scope/cli version" }; + const notMade = { value: null, observed_at: at, source: "the operator", error: "not asked" }; + + it("takes exactly the observations the board declares, one not made as null with its error", () => { + const result = parseRecordInput( + input({ observed: { npm_cli: made, users: notMade } }), + observingDeclaration(), + ); + expect(result.ok).toBe(true); + }); + + it("refuses an observation the board declares but the input leaves out, and one it does not declare", () => { + const result = parseRecordInput( + input({ observed: { npm_cli: made, npm_cil: made } }), + observingDeclaration(), + ); + expect(result.ok ? [] : result.errors).toEqual([ + "observed.users: the board declares it, so it must be given (null, with an error, when it was not observed)", + "observed.npm_cil: is not an observation the board declares", + ]); + }); + + it("refuses every declared observation when observed is left out", () => { + const result = parseRecordInput(input(), observingDeclaration()); + expect(result.ok ? [] : result.errors).toEqual([ + "observed.npm_cli: the board declares it, so it must be given (null, with an error, when it was not observed)", + "observed.users: the board declares it, so it must be given (null, with an error, when it was not observed)", + ]); + }); + + it("does not name an observation twice when its name is empty or __proto__", () => { + const value = JSON.parse( + `{"measure_digest":"${DIGEST}","cells":${JSON.stringify(cells())},"prose":{"summary":"x"},"judged_by":{"model":"m","self_reported":true},"observed":{"npm_cli":${JSON.stringify(made)},"users":${JSON.stringify(notMade)},"__proto__":${JSON.stringify(made)},"":${JSON.stringify(made)}}}`, + ); + const result = parseRecordInput(value, observingDeclaration()); + expect(result.ok ? [] : result.errors).toEqual([ + "observed.__proto__: is not a name it can have", + 'observed[""]: is not a name it can have', + ]); + }); + + it("leaves the names free on a board that declares nothing to observe", () => { + const result = parseRecordInput(input({ observed: { anything: made } }), declaration()); + expect(result.ok).toBe(true); + }); + + it("keeps the observations and the stages' look in the record of a version 2 board", () => { + const parsed = parseRecordInput( + input({ observed: { npm_cli: made, users: notMade } }), + observingDeclaration(), + ); + if (!parsed.ok) throw new Error(parsed.errors.join("\n")); + const r = buildRecord({ + declaration: observingDeclaration(), + measurement: { digest: DIGEST } as BoardMeasurement, + recordInput: parsed.input, + recordedAt: new Date("2026-10-08T00:00:00.000Z"), + recordedWith: { basou: "0.0.0-test", build: null }, + }); + expect(r.record_version).toBe(2); + expect(r.declaration.observe).toEqual([ + { key: "npm_cli", kind: "npm_version", package: "@scope/cli" }, + { key: "users", kind: "manual", how: "ask the operator" }, + ]); + expect(r.declaration.stages["04"]).toEqual({ meaning: "stage 04", look: ["what 04 needs"] }); + expect(r.observed).toEqual({ npm_cli: made, users: notMade }); + }); +}); + describe("orderAnomalies", () => { it("finds a stage not started, blocked or shelved before one done or begun, not counting unverified", () => { const parsed = parseRecordInput( @@ -344,7 +441,9 @@ describe("buildRecord and writeRecord", () => { components: { "ws/app": { lane: ["core"], note: "the app" } }, axis: d.axis, effort: { start: "2026-04-28", time_zone: "UTC" }, + observe: [], }); + expect(r.record_version).toBe(2); expect(r.recorded_at).toBe("2026-10-07T00:00:00.000Z"); const records = join(dir, "records"); const name = await writeRecord(records, r); diff --git a/packages/core/src/board/record.ts b/packages/core/src/board/record.ts index 5f0f93f2..e34ac228 100644 --- a/packages/core/src/board/record.ts +++ b/packages/core/src/board/record.ts @@ -19,9 +19,14 @@ function isRecord(value: unknown): value is Record { /** * The version of a record's shape, the shape of the measurement it holds - * included. Raised whenever that shape changes. + * included. Raised whenever that shape changes: 2 added the observations the + * declaration names (`declaration.observe`) and what each stage is judged by + * (`look` and `notes` of `declaration.stages`). Every version is read. */ -export const BOARD_RECORD_VERSION = 1; +export const BOARD_RECORD_VERSION = 2; + +/** The record versions this basou reads. A version is added here, never removed. */ +export const BOARD_RECORD_VERSIONS = [1, 2] as const; /** The states a cell of the board can be in. */ export const BOARD_CELL_STATES = [ @@ -210,6 +215,27 @@ export function parseRecordInput( } } } + // A board that declares what to observe is recorded with exactly those + // observations: one that was not made is given as null with its error, so + // a closed exit is never dropped from the board unsaid, and a name the + // declaration does not hold (a typo) would never meet the value the + // previous record has under the right one. + const observed = given.observed === undefined ? {} : given.observed; + if (declaration.observe.length > 0 && isRecord(observed)) { + const declared = new Set(declaration.observe.map((o) => o.key)); + for (const key of declared) { + if (!Object.hasOwn(observed, key)) { + errors.push( + `${formatPath(["observed", key])}: the board declares it, so it must be given (null, with an error, when it was not observed)`, + ); + } + } + for (const key of Object.keys(observed)) { + if (!declared.has(key) && key.trim() !== "" && key !== "__proto__") { + errors.push(`${formatPath(["observed", key])}: is not an observation the board declares`); + } + } + } const cells = cellsSchema.safeParse(given.cells); if (cells.success) { const seen = new Set(); @@ -311,6 +337,7 @@ export type BoardRecord = { components: BoardDeclaration["components"]; axis: BoardDeclaration["axis"]; effort: BoardDeclaration["effort"]; + observe: BoardDeclaration["observe"]; }; /** * What basou measured as it recorded, the digest the judge saw included, @@ -349,6 +376,7 @@ export function buildRecord(input: { components: d.components, axis: d.axis, effort: d.effort, + observe: d.observe, }, measure, observed: r.observed, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5487ca70..8e72c1d0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -112,7 +112,9 @@ export type { BoardMethodChange, BoardNotFound, BoardObservation, + BoardObserve, BoardObservedChange, + BoardObserveKind, BoardOrderAnomaly, BoardPage, BoardPageBody, @@ -142,11 +144,14 @@ export { BOARD_COMPONENTS_METHOD, BOARD_DEFAULT_AT, BOARD_DEFAULT_CAPTURE_GROUP, + BOARD_DEFAULT_CI_BRANCH, BOARD_EFFORT_METHOD, BOARD_FRESHNESS_METHOD, BOARD_INTEGRITY_METHOD, + BOARD_OBSERVE_KINDS, BOARD_PORTFOLIO_METHOD, BOARD_RECORD_VERSION, + BOARD_RECORD_VERSIONS, BOARD_REGEX_FLAGS, BOARD_REPOS_METHOD, BOARD_REVIEW_GAPS_METHOD, @@ -154,6 +159,7 @@ export { BOARD_TRAIL_COUNTS, BOARD_TRAIL_METHOD, BOARD_VERSION, + BOARD_VERSIONS, boardDigest, boardLivePage, boardPage, From 3c0a544981d824833f75fcef1b29f1d047d0c3a5 Mon Sep 17 00:00:00 2001 From: Takashi Matsuyama Date: Fri, 9 Oct 2026 01:56:05 +0900 Subject: [PATCH 2/3] basou: Refuse an uppercase npm package name in the board's tests Co-Authored-By: Claude Opus 5.5 --- packages/core/src/board/declaration.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/core/src/board/declaration.test.ts b/packages/core/src/board/declaration.test.ts index 7765bca5..1626ba77 100644 --- a/packages/core/src/board/declaration.test.ts +++ b/packages/core/src/board/declaration.test.ts @@ -1314,7 +1314,9 @@ describe("parseBoardDeclaration: board_version 2", () => { }); it.each<[string, string]>([ - ["Scope/Cli", "uppercase"], + ["Cli", "uppercase"], + ["@Scope/cli", "an uppercase scope"], + ["scope/cli", "a slash without a scope"], ["-x", "a leading '-'"], ["@scope/-x", "a leading '-' after the scope"], ["a b", "a space"], From 695279d78619dc9b959e5ad7be8ce8b00f1e51b0 Mon Sep 17 00:00:00 2001 From: Takashi Matsuyama Date: Fri, 9 Oct 2026 07:26:00 +0900 Subject: [PATCH 3/3] basou: Settle what the review found in the board's version 2 shapes - Say what an observation's value is safe as: one single-quoted shell word; a manual observation's how is never a word of a command. - Read a page's host as written: refuse https:///x, and any '@' before the host (https://@x, https://:pw@x), which the URL parser drops. - Take '_' in a GitHub owner, as a managed user's handle has. - Tell a version 1 file the keys version 2 reads on a line of their own, apart from keys no version reads. - Check the observations of a record input only when it is an object. - Test every shape rule the change states (branch '//' and trailing '.', the URL's quote characters, length limits, the workflow's case), and bring the comments and record's help up to date. Co-Authored-By: Claude Opus 5.5 --- packages/cli/src/commands/board.ts | 5 +- packages/core/src/board/declaration.test.ts | 52 ++++++++++++++++-- packages/core/src/board/declaration.ts | 58 ++++++++++++--------- packages/core/src/board/page.ts | 3 +- packages/core/src/board/previous.ts | 4 +- packages/core/src/board/record.test.ts | 7 +++ packages/core/src/board/record.ts | 10 ++-- 7 files changed, 103 insertions(+), 36 deletions(-) diff --git a/packages/cli/src/commands/board.ts b/packages/cli/src/commands/board.ts index 28720e3f..05aa0ecf 100644 --- a/packages/cli/src/commands/board.ts +++ b/packages/cli/src/commands/board.ts @@ -147,8 +147,9 @@ read (nothing is printed on stdout).`, "after", ` The input is a JSON object: measure_digest (the digest of the measurement the -judgement saw), observed, cells (every lane at every stage), prose, judged_by -and axis_review. The board is measured again, and nothing is written when the +judgement saw), observed (exactly the observations the board declares, when it +declares any), cells (every lane at every stage), prose, judged_by and +axis_review. The board is measured again, and nothing is written when the digest differs. Records are never written under a .basou/ directory. Exit codes: 0 when the record was written, or with --dry-run when everything checked out (nothing is written then); 1 when it was refused (nothing is diff --git a/packages/core/src/board/declaration.test.ts b/packages/core/src/board/declaration.test.ts index 1626ba77..8ebd3fe5 100644 --- a/packages/core/src/board/declaration.test.ts +++ b/packages/core/src/board/declaration.test.ts @@ -1286,8 +1286,25 @@ describe("parseBoardDeclaration: board_version 2", () => { entry(doc.stages, "03").colour = "red"; expect(errorsOf(parse(doc))).toEqual([ "stages.03: unknown key 'colour'", - "stages.05: unknown keys 'look', 'notes' (board_version 2 reads it)", - "(top level): unknown key 'observe' (board_version 2 reads it)", + "stages.05: 'look', 'notes' are read from board_version 2 on; this file is board_version 1", + "(top level): 'observe' is read from board_version 2 on; this file is board_version 1", + ]); + }); + + it("tells a key version 2 reads apart from one no version reads, where it stands", () => { + const doc = board(); + entry(doc.stages, "02").colour = "red"; + entry(doc.stages, "02").look = ["x"]; + entry(doc.stages, "03").observe = []; + doc.extra = 1; + doc.look = ["x"]; + doc.observe = []; + expect(errorsOf(parse(doc))).toEqual([ + "stages.02: unknown key 'colour'", + "stages.02: 'look' is read from board_version 2 on; this file is board_version 1", + "stages.03: unknown key 'observe'", + "(top level): unknown keys 'extra', 'look'", + "(top level): 'observe' is read from board_version 2 on; this file is board_version 1", ]); }); @@ -1333,11 +1350,14 @@ describe("parseBoardDeclaration: board_version 2", () => { it.each<[string]>([ ["name"], ["-owner/name"], + ["_owner/name"], ["owner/name/more"], ["owner/.."], ["owner/."], ["own er/name"], ["owner/na'me"], + [`${"o".repeat(65)}/name`], + [`owner/${"n".repeat(101)}`], ])("refuses the GitHub repository %j", (repo) => { for (const kind of ["github_release", "github_open_issues", "github_open_prs"]) { expect(errorsOf(parse(withObserve({ key: "g", kind, repo })))).toEqual([ @@ -1346,6 +1366,16 @@ describe("parseBoardDeclaration: board_version 2", () => { } }); + it("takes a managed user's owner, with '_', and names at their longest", () => { + const doc = boardV2(); + doc.observe = [ + { key: "a", kind: "github_release", repo: "mona_corp/name" }, + { key: "b", kind: "github_release", repo: `${"o".repeat(64)}/${"n".repeat(100)}` }, + { key: "c", kind: "github_ci", repo: "o/n", workflow: `${"w".repeat(251)}.yml` }, + ]; + expect(parse(doc).ok).toBe(true); + }); + it("refuses a workflow that is not a file name and a branch a command could misread", () => { const doc = boardV2(); doc.observe = [ @@ -1357,6 +1387,10 @@ describe("parseBoardDeclaration: board_version 2", () => { { key: "f", kind: "github_ci", repo: "o/n", workflow: "q.yml", branch: "a b" }, { key: "g", kind: "github_ci", repo: "o/n", workflow: "q.yml", branch: "release/" }, { key: "h", kind: "github_ci", repo: "o/n", workflow: "q.yaml", branch: "release/1.x" }, + { key: "i", kind: "github_ci", repo: "o/n", workflow: "q.yml", branch: "a//b" }, + { key: "j", kind: "github_ci", repo: "o/n", workflow: "q.yml", branch: "v1." }, + { key: "k", kind: "github_ci", repo: "o/n", workflow: "Q.YML" }, + { key: "l", kind: "github_ci", repo: "o/n", workflow: `${"w".repeat(252)}.yml` }, ]; const workflow = "must be the file name of a workflow, such as quality.yml"; const branch = @@ -1369,6 +1403,10 @@ describe("parseBoardDeclaration: board_version 2", () => { `observe[4].branch: ${branch}`, `observe[5].branch: ${branch}`, `observe[6].branch: ${branch}`, + `observe[8].branch: ${branch}`, + `observe[9].branch: ${branch}`, + `observe[10].workflow: ${workflow}`, + `observe[11].workflow: ${workflow}`, ]); }); @@ -1377,7 +1415,9 @@ describe("parseBoardDeclaration: board_version 2", () => { ["HTTPS://example.com/", "must be an https:// URL"], ["ftp://example.com/", "must be an https:// URL"], ["example.com", "must be an https:// URL"], - ["https://", "must be an https:// URL"], + ["https://", "must name a host"], + ["https:///example.com", "must name a host"], + ["https://?a=1", "must name a host"], ["https://example.com/a b", "must be printable ASCII with no spaces"], ["https://example.com/é", "must be printable ASCII with no spaces"], ["https://example.com/'; rm -rf ~", "must be printable ASCII with no spaces"], @@ -1385,6 +1425,12 @@ describe("parseBoardDeclaration: board_version 2", () => { ["https://example.com/a\\b", "must not contain quotes, backslashes, '<' or '>'"], ["https://user:secret@example.com/", "must not carry a user name or a password"], ["https://user@example.com/", "must not carry a user name or a password"], + ["https://:secret@example.com/", "must not carry a user name or a password"], + ["https://@example.com/", "must not carry a user name or a password"], + ['https://example.com/a"b', "must not contain quotes, backslashes, '<' or '>'"], + ["https://example.com/a`b`", "must not contain quotes, backslashes, '<' or '>'"], + ["https://example.com/", "must not contain quotes, backslashes, '<' or '>'"], + ["https://exa%zzmple.com/", "must be an https:// URL"], ])("refuses the page %j", (url, problem) => { expect(errorsOf(parse(withObserve({ key: "p", kind: "page_version", url })))).toEqual([ `observe[0].url: ${problem}`, diff --git a/packages/core/src/board/declaration.ts b/packages/core/src/board/declaration.ts index 6f67c1ed..069c54cf 100644 --- a/packages/core/src/board/declaration.ts +++ b/packages/core/src/board/declaration.ts @@ -254,8 +254,8 @@ const measureSchema = z.discriminatedUnion("kind", [ /** * What a board can declare to observe outside basou. basou never makes these - * observations (it sends nothing): `basou board guide` says how to make each, - * and the judge records what it found under the same key. + * observations (it sends nothing): the judge makes each and records what it + * found under the same key. */ export const BOARD_OBSERVE_KINDS = [ "npm_version", @@ -270,9 +270,11 @@ export const BOARD_OBSERVE_KINDS = [ /** The branch a `github_ci` observation looks at when it names none. */ export const BOARD_DEFAULT_CI_BRANCH = "main"; -// The values of an observation become words of a command the guide prints, -// so each is held to a shape that cannot start an option, end a quotation or -// carry a second command. +// The values of an observation are put into the commands a judge runs, each +// as one single-quoted shell word. Each is held to a shape that, quoted so, +// cannot end its quotation (no "'") or, where a command takes it as an +// argument, start an option (no leading '-'). A manual observation's `how` is +// words for a person, never a word of a command. // An npm package name, scoped or not, in lowercase. const NPM_PACKAGE = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/; @@ -281,7 +283,8 @@ const npmPackage = z.string().refine((s) => s.length <= 214 && NPM_PACKAGE.test( }); // A GitHub repository as owner/name. -const GITHUB_REPO = /^[A-Za-z0-9][A-Za-z0-9-]{0,38}\/[A-Za-z0-9._-]{1,100}$/; +// An owner may hold '_' (a managed user's handle does). +const GITHUB_REPO = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}\/[A-Za-z0-9._-]{1,100}$/; const githubRepo = z.string().refine((s) => GITHUB_REPO.test(s) && !/\/\.{1,2}$/.test(s), { error: "must be a GitHub repository written as owner/name", }); @@ -312,16 +315,16 @@ function httpsUrlProblem(s: string): string | undefined { if (!/^[\x21-\x7e]+$/.test(s)) return "must be printable ASCII with no spaces"; if (/['"`\\<>]/.test(s)) return "must not contain quotes, backslashes, '<' or '>'"; if (!s.startsWith("https://")) return "must be an https:// URL"; - let url: URL; + // As written, not as the URL parser reads it: the parser drops a slash too + // many and an empty user name, but the text is what a command is given. + const authority = s.slice("https://".length).split(/[/?#]/)[0] ?? ""; + if (authority === "") return "must name a host"; + if (authority.includes("@")) return "must not carry a user name or a password"; try { - url = new URL(s); + new URL(s); } catch { return "must be an https:// URL"; } - if (url.hostname === "") return "must name a host"; - if (url.username !== "" || url.password !== "") { - return "must not carry a user name or a password"; - } return undefined; } @@ -532,7 +535,7 @@ export function parseBoardDeclaration( if (version === 1) { const parsed = boardSchemaV1.safeParse(raw); if (!parsed.success) { - for (const issue of parsed.error.issues) errors.push(formatVersion1Issue(issue)); + for (const issue of parsed.error.issues) errors.push(...formatVersion1Issue(issue)); } errors.push(...crossCheck(raw, context)); if (errors.length > 0 || !parsed.success) return { ok: false, errors }; @@ -547,18 +550,25 @@ export function parseBoardDeclaration( return { ok: true, declaration: parsed.data }; } -// The keys version 2 added, which a file of version 1 has to move up to use. -const VERSION_2_KEYS = new Set(["observe", "look", "notes"]); - -function formatVersion1Issue(issue: Issue): string { - const formatted = formatIssue(issue); - if (issue.code !== "unrecognized_keys") return formatted; - const atTop = issue.path.length === 0; - const atStage = issue.path.length === 2 && issue.path[0] === "stages"; - const added = issue.keys.some( - (k) => (atTop && k === "observe") || (atStage && VERSION_2_KEYS.has(k) && k !== "observe"), +// The keys version 2 added, where it added them: a file of version 1 that +// has one is told so apart from the keys no version reads. +function formatVersion1Issue(issue: Issue): string[] { + if (issue.code !== "unrecognized_keys") return [formatIssue(issue)]; + const added = + issue.path.length === 0 + ? ["observe"] + : issue.path.length === 2 && issue.path[0] === "stages" + ? ["look", "notes"] + : []; + const later = issue.keys.filter((k) => added.includes(k)); + if (later.length === 0) return [formatIssue(issue)]; + const others = issue.keys.filter((k) => !added.includes(k)); + const out = others.length === 0 ? [] : [formatIssue({ ...issue, keys: others })]; + const named = later.map((k) => `'${k}'`).join(", "); + out.push( + `${formatPath(issue.path)}: ${named} ${later.length === 1 ? "is" : "are"} read from board_version 2 on; this file is board_version 1`, ); - return added ? `${formatted} (board_version 2 reads it)` : formatted; + return out; } // The first line of a parser message, without the ':' that introduces the diff --git a/packages/core/src/board/page.ts b/packages/core/src/board/page.ts index ac419770..2bbb6fa9 100644 --- a/packages/core/src/board/page.ts +++ b/packages/core/src/board/page.ts @@ -8,7 +8,8 @@ import { diffCells } from "./diff.js"; import { readBoardRecordFile, recordIds } from "./previous.js"; import { BOARD_RECORD_VERSIONS } from "./record.js"; -// The record versions the page draws. A version is added here, never removed. +// The record versions the page draws: every version a record has had (a +// version is added to BOARD_RECORD_VERSIONS, never removed). const DRAWN_VERSIONS = new Set(BOARD_RECORD_VERSIONS); const text = z.string(); diff --git a/packages/core/src/board/previous.ts b/packages/core/src/board/previous.ts index b1cb747e..19bce7a1 100644 --- a/packages/core/src/board/previous.ts +++ b/packages/core/src/board/previous.ts @@ -5,8 +5,8 @@ import { isUlidBody } from "../ids/ulid.js"; import { findErrorCode } from "../lib/error-codes.js"; import { BOARD_RECORD_VERSIONS } from "./record.js"; -// The record versions this reader reads. A version is added here, never -// removed, when the shape of a record changes. +// The record versions this reader reads: every version a record has had +// (a version is added to BOARD_RECORD_VERSIONS, never removed). const READABLE_VERSIONS = new Set(BOARD_RECORD_VERSIONS); function isRecord(value: unknown): value is Record { diff --git a/packages/core/src/board/record.test.ts b/packages/core/src/board/record.test.ts index 683355db..0009f6c7 100644 --- a/packages/core/src/board/record.test.ts +++ b/packages/core/src/board/record.test.ts @@ -342,6 +342,13 @@ describe("parseRecordInput: a board that declares what to observe", () => { ]); }); + it("reports only that the input is not an object when it is not one", () => { + for (const value of ["x", null, []]) { + const result = parseRecordInput(value, observingDeclaration()); + expect(result.ok ? [] : result.errors).toHaveLength(1); + } + }); + it("leaves the names free on a board that declares nothing to observe", () => { const result = parseRecordInput(input({ observed: { anything: made } }), declaration()); expect(result.ok).toBe(true); diff --git a/packages/core/src/board/record.ts b/packages/core/src/board/record.ts index e34ac228..1ba7132c 100644 --- a/packages/core/src/board/record.ts +++ b/packages/core/src/board/record.ts @@ -181,9 +181,11 @@ export type BoardRecordInputResult = * report every problem at once, each starting with where it is: each part * whose shape is right is held against the declaration even when another * part's shape is wrong. An unknown key is refused at every level but the - * keys of `observed` and of `prose.lanes`, which the latter limits to the - * declaration's lane ids. Every lane has a cell at every stage, once. The - * value is what JSON.parse read, so a key given twice has kept its last. + * keys of `observed` and of `prose.lanes`: the latter are limited to the + * declaration's lane ids, and the former, when the declaration names what to + * observe, are exactly those names. Every lane has a cell at every stage, + * once. The value is what JSON.parse read, so a key given twice has kept + * its last. */ export function parseRecordInput( value: unknown, @@ -221,7 +223,7 @@ export function parseRecordInput( // declaration does not hold (a typo) would never meet the value the // previous record has under the right one. const observed = given.observed === undefined ? {} : given.observed; - if (declaration.observe.length > 0 && isRecord(observed)) { + if (declaration.observe.length > 0 && isRecord(value) && isRecord(observed)) { const declared = new Set(declaration.observe.map((o) => o.key)); for (const key of declared) { if (!Object.hasOwn(observed, key)) {