From 6b17116c682ee3a07274883543f2a05983170b8d Mon Sep 17 00:00:00 2001 From: Takashi Matsuyama Date: Mon, 19 Oct 2026 14:14:36 +0900 Subject: [PATCH 1/2] basou: Name the keys a kind takes when a board refuses one, point the guide at the reference, draw a string measure without its unit, and give each record its own time --- CHANGELOG.md | 26 ++++++ docs/spec/board.md | 12 ++- packages/cli/src/commands/board.test.ts | 18 +++- packages/cli/src/commands/board.ts | 7 +- packages/cli/src/commands/view.test.ts | 5 +- packages/cli/src/lib/board-ui.ts | 3 +- packages/core/src/board/declaration.test.ts | 96 +++++++++++++++++---- packages/core/src/board/declaration.ts | 38 +++++++- packages/core/src/board/guide.test.ts | 15 +++- packages/core/src/board/guide.ts | 20 ++++- packages/core/src/board/page.test.ts | 32 ++++++- packages/core/src/board/page.ts | 42 ++++++++- 12 files changed, 275 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc065d8..59e91e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,32 @@ 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 + +### Fixed + +- **A key a measure or an observation of a board does not take is refused + with the keys its kind takes**, in the version the board is read by: + `observe[0]: unknown keys 'count', 'capture' (kind 'http' takes: key, kind, url, words, count_pattern, capture_pattern, notes)`. + Until now only the key was named, and the names of a kind's keys were + found by guessing from one refusal after another. +- **`basou board guide` says where the keys of each kind are described**: + the reference for its own version (`docs/spec/board.md` at that version's + tag), when the axis is cut before a board is declared and when it is + reviewed. Nothing basou printed named the keys of an `http`, a + `github_check`, a `file_name` or a `total`. +- **A measure whose value is a string (`file_name`, `regex_capture`) is + printed and drawn without its unit** (`latest README.md`, not + `latest README.md name`): a name is no amount. A number keeps its unit, + and so does the count of a `file_paths`. A measure still declares `unit`. +- **The board page's list of records and the last record in + `basou board guide` give each record's own `recorded_at`**, as the + heading of the page does. A record written before 0.70.1 has a name of + another time, a second or so apart, which they gave. +- **`basou board record` takes `recorded_at` after it measures the board + again**, so a record is no longer recorded a few milliseconds before the + measurement it holds. + ## 0.71.1 — 2026-10-19 ### Added diff --git a/docs/spec/board.md b/docs/spec/board.md index f2571d6..52fa2c8 100644 --- a/docs/spec/board.md +++ b/docs/spec/board.md @@ -52,7 +52,9 @@ Conventions on this page: visibility. - A record's file name is a ULID whose time is the record's `recorded_at`, with `.json`. Any other file in `records/` is passed over. The previous - record is the last by name. + record is the last by name. A record written before basou 0.70.1 has a + name of another time, a second or so apart; the board page's list of + records and `basou board guide` give each record's own `recorded_at`. Both shapes carry a version, and every version ever written is read: @@ -89,7 +91,9 @@ last axis review) is not known; it is never taken as no record. (`the key 01 at line 4, column 3 is not a string; write it in quotes`), so the stage ids are written in quotes: `"01"`. - **An unknown key is refused at every level**, so a typo is reported, not - ignored. + ignored. One in a measure or an observation is refused with the keys its + kind takes in the version the file is read by + (`observe[0]: unknown keys 'count', 'capture' (kind 'http' takes: key, kind, url, words, count_pattern, capture_pattern, notes)`). - **A problem in the YAML itself stops the reading**, and so does an unknown `board_version` (see [Files and versions](#1-files-and-versions)): a syntax error, a duplicate key, a `%YAML` directive or a key that is not a string @@ -207,7 +211,7 @@ Every measure has: |---|---|---| | `id` | yes | an id, or from version 3 a dotted id | | `kind` | yes | one of the kinds below | -| `unit` | yes | a non-empty string, shown with the value | +| `unit` | yes | a non-empty string, shown with a value that is a number, and with the count of a `file_paths`; a string (`file_name`, `regex_capture`) is shown without it | | `lane` | no | the lanes whose section of the board page draws the measure: one lane id, or from version 3 a non-empty list of them, each a lane of the board and none twice | A `lane` is read as a list whatever the version, so a record holds a list @@ -828,7 +832,7 @@ later one is added. | Key | Holds | |---|---| | `record_version` | `3` | -| `recorded_at` | when it was recorded: UTC, ISO 8601 with milliseconds | +| `recorded_at` | when it was recorded, after the measurement it holds: UTC, ISO 8601 with milliseconds | | `recorded_with` | `{ basou, build }`: the version of the basou that recorded it, and the commit it was built from (null for a build from source) | | `declaration` | the declaration the board was measured and judged by ([4.2](#42-declaration)) | | `measure` | the measurement, as `basou board measure --json` prints it, without `diff` ([5](#5-the-measurement)) | diff --git a/packages/cli/src/commands/board.test.ts b/packages/cli/src/commands/board.test.ts index 99cb7b6..6b54759 100644 --- a/packages/cli/src/commands/board.test.ts +++ b/packages/cli/src/commands/board.test.ts @@ -293,7 +293,8 @@ describe("basou board measure", () => { await runBoardMeasure({}, ctx(repo)); const text = out.join("\n"); expect(text).toContain("\n paths 2 files (paths in --json) [core]\n"); - expect(text).toContain("\n latest README.md name\n"); + // A name is no amount: it is printed without the unit, a count with it. + expect(text).toContain("\n latest README.md\n"); out.length = 0; await runBoardMeasure({ json: true }, ctx(repo)); expect(JSON.parse(out.join("\n")).measures.paths).toEqual({ @@ -1081,6 +1082,21 @@ describe("basou board record", () => { expect(process.exitCode ?? 0).toBe(0); }); + it("is recorded after the measurement it holds: recorded_at is not before measured_at", async () => { + const { repo, input } = await judged(); + capture(); + // Each reading of the clock a second later than the one before. + let tick = 0; + const result = await doRunBoardRecord( + {}, + { ...fed(repo, input), nowProvider: () => new Date(NOW.getTime() + 1000 * tick++) }, + ); + const written = JSON.parse(await readFile(join(repo, result.record ?? ""), "utf8")); + expect(Date.parse(written.recorded_at)).toBeGreaterThan( + Date.parse(written.measure.measured_at), + ); + }); + it("records a version 2 board with exactly the observations it declares", async () => { const board = JSON.parse(boardYaml([MD])); board.board_version = 2; diff --git a/packages/cli/src/commands/board.ts b/packages/cli/src/commands/board.ts index 4f9a8e7..b686f06 100644 --- a/packages/cli/src/commands/board.ts +++ b/packages/cli/src/commands/board.ts @@ -561,9 +561,10 @@ async function checkAndRecord( .join("\n")}`, ); } - const recordedAt = ctx.nowProvider?.() ?? new Date(); const previous = await previousOf(loaded); const measurement = await measureLoaded(loaded, ctx, parsed.input.judged_by.model, previous); + // After the measurement it holds: a record is not recorded before it is measured. + const recordedAt = ctx.nowProvider?.() ?? new Date(); if (measurement.digest !== parsed.input.measure_digest) { throw new RecordRefusal( `measure_digest is ${parsed.input.measure_digest}, but the board measures ${measurement.digest} now: something it measures or board.yaml changed since the judgement, or basou was rebuilt or upgraded since that measurement. Measure again and judge that; nothing was written.`, @@ -1282,7 +1283,9 @@ function printMeasurementText(m: BoardMeasurement, hasRecords: boolean): void { const width = Math.max(...measures.map(([id]) => id.length)); lines.push("", "Measures:"); for (const [id, measure] of measures) { - const unit = measure.value === null ? "" : ` ${displayPath(measure.unit)}`; + // A string (a file's name, a capture) is no amount: it takes no unit. + const counted = typeof measure.value === "number" || Array.isArray(measure.value); + const unit = counted ? ` ${displayPath(measure.unit)}` : ""; const listed = Array.isArray(measure.value) ? " (paths in --json)" : ""; const lane = measure.lane === undefined ? "" : ` [${measure.lane.join(", ")}]`; lines.push(` ${id.padEnd(width)} ${shownValue(measure.value)}${unit}${listed}${lane}`); diff --git a/packages/cli/src/commands/view.test.ts b/packages/cli/src/commands/view.test.ts index 7603e66..f26ac40 100644 --- a/packages/cli/src/commands/view.test.ts +++ b/packages/cli/src/commands/view.test.ts @@ -1579,17 +1579,20 @@ describe("basou view: the board page", () => { { id: "latest", value: "20260401", unit: "name" }, { id: "migrations", value: ["db/a.sql", "db/.sql"], unit: "files" }, { id: "gone", value: null, unit: "files" }, + { id: "pages", value: 1200, unit: "pages" }, ], flags: { live: false, blocked: false, unverified: false }, }, ], }); const items = drawn.find((n) => n.tag === "li" && n.find((c) => c.tag === "li").length === 1); + // A string is no amount: it is drawn without the unit, a number with it. expect(items.map((n) => n.text())).toEqual([ - "latest: 20260401 name", + "latest: 20260401", "db/a.sql", "db/.sql", "gone: not measured", + "pages: 1,200 pages", ]); const [details] = drawn.find((n) => n.tag === "details"); expect(details?.attrs.open).toBeUndefined(); diff --git a/packages/cli/src/lib/board-ui.ts b/packages/cli/src/lib/board-ui.ts index 2d505da..8de5b41 100644 --- a/packages/cli/src/lib/board-ui.ts +++ b/packages/cli/src/lib/board-ui.ts @@ -657,7 +657,8 @@ export const BOARD_HTML = ` el('ul', null, m.value.map(function (p) { return el('li', { text: p }); })) ])]); } - return el('li', { text: m.id + ': ' + num(m.value) + (m.value === null ? '' : ' ' + m.unit) }); + // A string (a file's name, a capture) is no amount: it takes no unit. + return el('li', { text: m.id + ': ' + num(m.value) + (typeof m.value === 'number' ? ' ' + m.unit : '') }); }))); } return el('div', { class: 'lane', 'data-lane': lane.id }, body); diff --git a/packages/core/src/board/declaration.test.ts b/packages/core/src/board/declaration.test.ts index ba09129..b119fb1 100644 --- a/packages/core/src/board/declaration.test.ts +++ b/packages/core/src/board/declaration.test.ts @@ -306,12 +306,34 @@ describe("parseBoardDeclaration: the shape of each key", () => { ).toEqual(["measures[0].depth: Invalid input: expected number, received undefined"]); }); + it("names what a kind takes in the version the file is read by", () => { + // Version 2: an observation takes no notes yet, and file_name is no kind. + const v2 = boardV2(); + v2.observe = [{ key: "a", kind: "manual", how: "ask", colour: "red" }]; + expect(errorsOf(parse(v2))).toEqual([ + "observe[0]: unknown key 'colour' (kind 'manual' takes: key, kind, how)", + ]); + // Version 3: the same kind takes notes. + const v3 = boardV2(); + v3.board_version = 3; + v3.observe = [{ key: "a", kind: "manual", how: "ask", colour: "red" }]; + expect(errorsOf(parse(v3))).toEqual([ + "observe[0]: unknown key 'colour' (kind 'manual' takes: key, kind, how, notes)", + ]); + // Not for a key outside a measure or an observation. + const lane = boardV2(); + entry(lane.lanes, 0).colour = "red"; + expect(errorsOf(parse(lane))).toEqual(["lanes[0]: unknown key 'colour'"]); + }); + it("refuses keys that belong to another kind", () => { expect( errorsOf( parse(withMeasure({ id: "a", kind: "trail_count", of: "tasks", repo: ".", unit: "tasks" })), ), - ).toEqual(["measures[0]: unknown key 'repo'"]); + ).toEqual([ + "measures[0]: unknown key 'repo' (kind 'trail_count' takes: kind, id, unit, lane, of, status)", + ]); expect( errorsOf( parse( @@ -325,7 +347,9 @@ describe("parseBoardDeclaration: the shape of each key", () => { }), ), ), - ).toEqual(["measures[0]: unknown key 'pattern'"]); + ).toEqual([ + "measures[0]: unknown key 'pattern' (kind 'file_count' takes: kind, id, unit, lane, repo, at, include, exclude)", + ]); }); it.each(["src/*", "src/[ab]", "s?c", "a\\b"])( @@ -776,10 +800,26 @@ describe("parseBoardDeclaration: strictness and required keys at every level", ( const extra = (target: Record) => { target.extra = 1; }; - it.each<[string, (doc: Record) => void, string]>([ - ["a file_count", (d) => extra(entry(d.measures, 0)), "measures[0]"], - ["a line_count", (d) => extra(entry(d.measures, 2)), "measures[2]"], - ["a match_count", (d) => extra(entry(d.measures, 1)), "measures[1]"], + const fileKeys = "kind, id, unit, lane, repo, at, include, exclude"; + it.each<[string, (doc: Record) => void, string, string?]>([ + [ + "a file_count", + (d) => extra(entry(d.measures, 0)), + "measures[0]", + ` (kind 'file_count' takes: ${fileKeys})`, + ], + [ + "a line_count", + (d) => extra(entry(d.measures, 2)), + "measures[2]", + ` (kind 'line_count' takes: ${fileKeys})`, + ], + [ + "a match_count", + (d) => extra(entry(d.measures, 1)), + "measures[1]", + ` (kind 'match_count' takes: ${fileKeys}, pattern, section)`, + ], [ "a match_count section", (d) => extra(entry(entry(d.measures, 1), "section")), @@ -790,10 +830,10 @@ describe("parseBoardDeclaration: strictness and required keys at every level", ( ["seed_review", (d) => extra(entry(d.axis, "seed_review")), "axis.seed_review"], ["effort", (d) => extra(entry(d, "effort")), "effort"], ["a milestone", (d) => extra(entry(entry(d.effort, "milestones"), 0)), "effort.milestones[0]"], - ])("refuses an unknown key in %s", (_what, change, at) => { + ])("refuses an unknown key in %s", (_what, change, at, takes = "") => { const doc = board(); change(doc); - expect(errorsOf(parse(doc))).toEqual([`${at}: unknown key 'extra'`]); + expect(errorsOf(parse(doc))).toEqual([`${at}: unknown key 'extra'${takes}`]); }); it.each<[string, Record]>([ @@ -825,7 +865,15 @@ describe("parseBoardDeclaration: strictness and required keys at every level", ( : measure; target.extra = 1; const at = what.endsWith("section") ? "measures[0].section" : "measures[0]"; - expect(errorsOf(parse(withMeasure(measure)))).toEqual([`${at}: unknown key 'extra'`]); + // A measure's problem names what its kind takes; a section's does not. + const kindKeys: Record = { + dir_count: "kind, id, unit, lane, repo, at, path, depth, include, exclude", + regex_capture: "kind, id, unit, lane, repo, at, file, pattern, group, section", + json_length: "kind, id, unit, lane, repo, at, file, pointer", + trail_count: "kind, id, unit, lane, of, status", + }; + const takes = what.endsWith("section") ? "" : ` (kind '${what}' takes: ${kindKeys[what]})`; + expect(errorsOf(parse(withMeasure(measure)))).toEqual([`${at}: unknown key 'extra'${takes}`]); }); it.each<[string, string, (doc: Record) => Record, string]>([ @@ -1326,7 +1374,7 @@ describe("parseBoardDeclaration: board_version 2", () => { 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[2]: unknown key 'repo' (kind 'npm_version' takes: key, kind, package)", "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 '-'", @@ -1503,7 +1551,7 @@ describe("parseBoardDeclaration: board_version 3 and http", () => { it("refuses a method and any key it does not take, and holds the url to the shape of a page's", () => { expect(errorsOf(parse(withHttp({ url, method: "HEAD", status: 200 })))).toEqual([ - "observe[0]: unknown keys 'method', 'status'", + "observe[0]: unknown keys 'method', 'status' (kind 'http' takes: key, kind, url, words, count_pattern, capture_pattern, notes)", ]); expect(errorsOf(parse(withHttp({ url: "http://example.com/" })))).toEqual([ "observe[0].url: must be an https:// URL", @@ -1545,7 +1593,9 @@ describe("parseBoardDeclaration: board_version 3 and http", () => { it("looks for a word given twice only in an http observation", () => { const doc = withHttp({ url }); doc.observe = [{ key: "m", kind: "manual", how: "ask", words: ["a", "a"] }]; - expect(errorsOf(parse(doc))).toEqual(["observe[0]: unknown key 'words'"]); + expect(errorsOf(parse(doc))).toEqual([ + "observe[0]: unknown key 'words' (kind 'manual' takes: key, kind, how, notes)", + ]); }); it("refuses a word given twice, saying where it was first", () => { @@ -1737,11 +1787,13 @@ describe("parseBoardDeclaration: board_version 3, github_check and an observatio } expect(parse(withCheck({ check: "x", branch: "a.b/c.d" })).ok).toBe(true); expect(errorsOf(parse(withCheck({ check: "x", workflow: "q.yml" })))).toEqual([ - "observe[0]: unknown key 'workflow'", + "observe[0]: unknown key 'workflow' (kind 'github_check' takes: key, kind, repo, check, branch, notes)", ]); const ci = withCheck({}); ci.observe = [{ key: "ci", kind: "github_ci", repo: "o/n", workflow: "q.yml", check: "x" }]; - expect(errorsOf(parse(ci))).toEqual(["observe[0]: unknown key 'check'"]); + expect(errorsOf(parse(ci))).toEqual([ + "observe[0]: unknown key 'check' (kind 'github_ci' takes: key, kind, repo, workflow, branch, notes)", + ]); }); it("takes notes on every kind of observation, kept as written", () => { @@ -1798,7 +1850,7 @@ describe("parseBoardDeclaration: board_version 3, github_check and an observatio "observe[1]: 'notes' is read from board_version 3 on; this file is board_version 2", "observe[2]: 'notes' is read from board_version 3 on; this file is board_version 2", "observe[3].kind: 'github_check' is read from board_version 3 on; this file is board_version 2", - "observe[4]: unknown key 'colour'", + "observe[4]: unknown key 'colour' (kind 'manual' takes: key, kind, how)", "observe[4]: 'notes' is read from board_version 3 on; this file is board_version 2", ]); const v1 = board(); @@ -1893,7 +1945,9 @@ describe("parseBoardDeclaration: board_version 3, file_name and file_paths", () expect(at({ include: undefined })).toEqual([ "measures[0].include: Invalid input: expected array, received undefined", ]); - expect(at({ file: "a.sql" })).toEqual(["measures[0]: unknown key 'file'"]); + expect(at({ file: "a.sql" })).toEqual([ + "measures[0]: unknown key 'file' (kind 'file_name' takes: kind, id, unit, lane, repo, at, include, exclude, pick, pattern, group)", + ]); expect(at({ repo: "../elsewhere" })).toEqual([ "measures[0].repo: '../elsewhere' is not a repo path in the manifest (the manifest declares '.', '../basou')", ]); @@ -1902,8 +1956,12 @@ describe("parseBoardDeclaration: board_version 3, file_name and file_paths", () it("refuses what a file_paths does not take, and a repo the manifest does not declare", () => { const at = (over: Record) => errorsOf(parse(withMeasureV3({ ...MIGRATIONS, ...over }))); - expect(at({ pick: "last" })).toEqual(["measures[0]: unknown key 'pick'"]); - expect(at({ pattern: "x" })).toEqual(["measures[0]: unknown key 'pattern'"]); + expect(at({ pick: "last" })).toEqual([ + "measures[0]: unknown key 'pick' (kind 'file_paths' takes: kind, id, unit, lane, repo, at, include, exclude)", + ]); + expect(at({ pattern: "x" })).toEqual([ + "measures[0]: unknown key 'pattern' (kind 'file_paths' takes: kind, id, unit, lane, repo, at, include, exclude)", + ]); expect(at({ include: [] })).toEqual(["measures[0].include: must list at least one path"]); expect(at({ repo: "../elsewhere" })).toEqual([ "measures[0].repo: '../elsewhere' is not a repo path in the manifest (the manifest declares '.', '../basou')", @@ -2014,7 +2072,7 @@ describe("parseBoardDeclaration: board_version 3, total", () => { `measures[1].plus[2]: ${integer}`, ]); expect(at({ repo: "../basou", at: "main" })).toEqual([ - "measures[1]: unknown keys 'repo', 'at'", + "measures[1]: unknown keys 'repo', 'at' (kind 'total' takes: kind, id, unit, lane, plus, minus)", ]); }); diff --git a/packages/core/src/board/declaration.ts b/packages/core/src/board/declaration.ts index 87f6669..a7b435e 100644 --- a/packages/core/src/board/declaration.ts +++ b/packages/core/src/board/declaration.ts @@ -861,7 +861,7 @@ export function parseBoardDeclaration( if (!parsed.ok) { errors.push( ...(version === BOARD_VERSION || typeof version !== "number" - ? parsed.issues.map(formatIssue) + ? parsed.issues.map((issue) => formatIssueOf(issue, version, raw)) : formatOlderIssues(raw, version, parsed.issues)), ); } @@ -970,7 +970,7 @@ function formatOlderIssues(raw: unknown, version: number, issues: readonly Issue if (from === undefined) others.push(key); else byVersion.set(from, [...(byVersion.get(from) ?? []), key]); } - if (others.length > 0) out.push(formatIssue({ ...issue, keys: others })); + if (others.length > 0) out.push(formatIssueOf({ ...issue, keys: others }, version, raw)); for (const [from, keys] of [...byVersion].sort(([a], [b]) => a - b)) { const named = keys.map((k) => `'${k}'`).join(", "); out.push(readFromLine(issue.path, `${named} ${keys.length === 1 ? "is" : "are"}`, from)); @@ -1076,6 +1076,40 @@ export function formatIssue(issue: Issue): string { return `${formatPath(issue.path)}: ${issue.message}`; } +// A problem of a declaration. A key a measure or an observation does not +// take is told with the keys its kind takes in that version, so the names +// need not be guessed from the problems one at a time. +function formatIssueOf(issue: Issue, version: unknown, raw: unknown): string { + const text = formatIssue(issue); + if (issue.code !== "unrecognized_keys") return text; + const taken = keysOfKind(version, raw, issue.path); + return taken === undefined + ? text + : `${text} (kind '${taken.kind}' takes: ${taken.keys.join(", ")})`; +} + +// The keys of the kind of the measure or the observation at `path`, in the +// shape `version` is read by (the newest, for no version or one that is not +// a number); undefined for any other path or a kind no shape of it has. +function keysOfKind( + version: unknown, + raw: unknown, + path: readonly PropertyKey[], +): { kind: string; keys: string[] } | undefined { + if (path.length !== 2 || typeof path[1] !== "number") return undefined; + const newest = typeof version !== "number" || version >= 3; + const kinds = + path[0] === "measures" + ? (newest ? measureSchemaV3 : measureSchemaV1).options + : path[0] === "observe" && version !== 1 + ? (newest ? observeSchemaV3 : observeSchemaV2).options + : undefined; + const kind = valueAt(raw, [...path, "kind"]); + if (kinds === undefined || typeof kind !== "string") return undefined; + const shape = kinds.find((o) => (o.shape.kind.values as ReadonlySet).has(kind))?.shape; + return shape === undefined ? undefined : { kind, keys: Object.keys(shape) }; +} + // The entries of a list in the raw document with their indexes, or undefined // when the key does not hold a list (the shape check reports that). function entries( diff --git a/packages/core/src/board/guide.test.ts b/packages/core/src/board/guide.test.ts index 3ff24fb..e29b614 100644 --- a/packages/core/src/board/guide.test.ts +++ b/packages/core/src/board/guide.test.ts @@ -186,7 +186,9 @@ describe("boardGuide: a board declared", () => { '- Declaration: board/board.yaml, "Test board", board_version 2, 2 lanes, 7 observations outside basou', ); expect(guide).toContain( - "- Records: board/records/, 2 records; the last is 01M4E6PAN7J2BJ5YK1NFPVAQAR, recorded 2026-10-08T16:49:07.751Z, 1 day ago", + // The record's own time, not its name's: a record written before + // 0.70.1 has a name of another time. + "- Records: board/records/, 2 records; the last is 01M4E6PAN7J2BJ5YK1NFPVAQAR, recorded 2026-10-07T00:00:00.000Z, 2 days ago", ); expect(guide).toContain( "- Axis: version 3, due a review every 60 days; last reviewed 2026-09-30 by Old Model (record 01M4A00000000000000000000A)", @@ -654,6 +656,17 @@ describe("boardGuide: no board declared", () => { "A directory no built-in marker finds (content, scripts) is registered with a `marker` of its own: a glob, from it, of the files that mark it.", ); }); + + it("says where the keys of each kind are described, for this basou's version, with a board or without", () => { + const reference = + "The keys each kind of measure and observation takes at each `board_version`, their defaults and the shapes of their values are described in the reference for basou 0.0.0-test: https://github.com/basou-dev/basou/blob/v0.0.0-test/docs/spec/board.md. A key a kind does not take is refused with the keys it takes."; + // When the axis is cut, before a board is declared. + const undeclared = boardGuide(guideInput({ board: { status: "undeclared" } })); + expect(undeclared).toContain(`\n ${reference}\n4. Check it until it reads`); + // When the axis is reviewed, before the proposal is applied. + const declared = boardGuide(guideInput()); + expect(declared).toContain(`\n\n${reference}\n\nApply nothing until the operator approves.`); + }); }); describe("boardGuide: the commands run", () => { diff --git a/packages/core/src/board/guide.ts b/packages/core/src/board/guide.ts index a013687..d73056b 100644 --- a/packages/core/src/board/guide.ts +++ b/packages/core/src/board/guide.ts @@ -1,5 +1,4 @@ import { createHash } from "node:crypto"; -import { decodeTime } from "ulid"; import { displayPath } from "../lib/display-path.js"; import { BOARD_HTTP_CAPTURE_FLAGS, @@ -231,6 +230,8 @@ export function boardGuide(input: BoardGuideInput): string { "4. Do the decisions recorded hold anything that should move the axis (a new surface, a new base, something decided not to build)?", "5. If the last review was by another model: does anything in how the lanes are cut or worded need fixing now? Change nothing only to make it look new; change only what is worth losing the comparison with past records.", "", + referenceLine(input), + "", 'Apply nothing until the operator approves. Then edit board.yaml, measure again from step 2 (the digest moves with the declaration), and give the record an `axis_review`: `{ "triggers": [every letter that led to it], "summary": "what was reviewed and decided" }`. Raise `axis.version` when a lane is added or removed or a stage\'s condition changes, and name the commit `axis review v`. Give `axis_review` even when nothing changed: the next (b) counts from it. Registering a component only to silence (a) is not a review.', "", ); @@ -383,6 +384,19 @@ export function boardGuide(input: BoardGuideInput): string { return `${out.join("\n").replace(/\n{3,}/g, "\n\n")}\n`; } +/** + * Where the keys each kind of measure and observation takes, and the shapes + * of their values, are described for a version of basou: the reference in + * its source, which the package does not carry. + */ +export function boardReferenceUrl(version: string): string { + return `https://github.com/basou-dev/basou/blob/v${version}/docs/spec/board.md`; +} + +function referenceLine(input: BoardGuideInput): string { + return `The keys each kind of measure and observation takes at each \`board_version\`, their defaults and the shapes of their values are described in the reference for basou ${input.basouVersion}: ${boardReferenceUrl(input.basouVersion)}. A key a kind does not take is refused with the keys it takes.`; +} + function basouLine(input: BoardGuideInput): string { const self = input.basouBuild === null @@ -416,7 +430,8 @@ function recordsLine(input: BoardGuideInput, recordCount: number): string { if (recordCount === 0 && last.status === "none") return "none yet; this will be the first"; const today = todayIn(input.board.declaration.effort.time_zone, input.now); if (last.status === "found") { - const at = new Date(decodeTime(last.id)); + // Its own time: a record written before 0.70.1 has a name of another. + const at = new Date(last.record.recorded_at); const day = todayIn(input.board.declaration.effort.time_zone, at); const when = day !== undefined && today !== undefined ? `, ${ago(day, today)}` : ""; return `${count(recordCount, "record")}; the last is ${last.id}, recorded ${at.toISOString()}${when}`; @@ -812,6 +827,7 @@ function undeclared( block(`${go} && ${BOARD_INIT_SAVE}; echo "exit=$?"`); line( "3. Cut the axis, with the operator. The board asks what can be used now and what is stuck, not what was built. Replace the sample lane with lanes cut by who uses what, not by repo. Fix what each of the six stages means for this product, keeping 04 (merged) apart from 05 (in users' hands): merged but closed to users is used by no one. Declare under `observe` what is reached outside the repos (a registry, a release, a site). Declare under `effort.milestones` the days to mark on the period and effort chart: only what was reached, each with a `ref` saying where it can be checked; a board moved from another keeps its milestones there. Measures, ratios and components may stay empty at first. A measure with `lane:` (a lane id, or a list of them) is drawn in each of those lanes' sections of the board page; one without is kept only in the records and their differences (and in a ratio or a total that uses it).", + ` ${referenceLine(input)}`, "4. Check it until it reads (every problem with it is listed at once):", ); block(`${go} && basou board measure --model "$MODEL" > "$W/measure.txt"; echo "exit=$?"`); diff --git a/packages/core/src/board/page.test.ts b/packages/core/src/board/page.test.ts index 76ddf38..63d84e6 100644 --- a/packages/core/src/board/page.test.ts +++ b/packages/core/src/board/page.test.ts @@ -168,11 +168,12 @@ describe("boardPage", () => { await writeFile(join(records, ".record-1.tmp"), "{"); expect((await boardPage(records)).status).toBe("unavailable"); await place(A, record()); - await place(B, record()); + await place(B, record({ recorded_at: "2026-10-08T09:30:00.000Z" })); const page = await boardPage(records); + // Each by its own recorded_at, not by the time its name holds. expect(page.records).toEqual([ - { id: A, at: "1970-01-01T00:00:00.001Z" }, - { id: B, at: "1970-01-01T00:00:00.002Z" }, + { id: A, at: "2026-10-07T00:00:00.000Z" }, + { id: B, at: "2026-10-08T09:30:00.000Z" }, ]); expect(page).toMatchObject({ status: "ok", id: B, older: A, newer: null }); expect(await boardPage(records, A)).toMatchObject({ @@ -775,6 +776,28 @@ describe("boardPage", () => { } }); + it("lists a record by the recorded_at its head holds, and by its name's time when the head does not hold one", async () => { + await mkdir(records); + // As basou writes it, with an indent of 2. + await place(A, JSON.stringify(record({ recorded_at: "2026-10-07T12:00:00.000Z" }), null, 2)); + // recorded_at not where basou writes it. + const { recorded_at: _at, ...rest } = record(); + await place(B, JSON.stringify({ ...rest, recorded_at: "2026-10-09T00:00:00.000Z" })); + // Not a time. + await place(C, JSON.stringify(record({ recorded_at: "yesterday" }))); + expect((await boardPage(records)).records).toEqual([ + { id: A, at: "2026-10-07T12:00:00.000Z" }, + { id: B, at: "1970-01-01T00:00:00.002Z" }, + { id: C, at: "1970-01-01T00:00:00.003Z" }, + ]); + // Only the record's own, not one a part of it holds. + await place(C, '{"record_version":3,"note":{"recorded_at":"2026-01-01T00:00:00.000Z"}}'); + expect((await boardPage(records)).records.at(-1)).toEqual({ + id: C, + at: "1970-01-01T00:00:00.003Z", + }); + }); + it("does not draw a record it cannot read, of a version it does not know, or not in the shape of one", async () => { await mkdir(records); await place(A, "{ not json"); @@ -783,9 +806,10 @@ describe("boardPage", () => { expect(await boardPage(records, A)).toEqual({ status: "unavailable", why: "not_json", + // One that cannot be read is listed by the time its name holds. records: [ { id: A, at: "1970-01-01T00:00:00.001Z" }, - { id: B, at: "1970-01-01T00:00:00.002Z" }, + { id: B, at: "2026-10-07T00:00:00.000Z" }, ], id: A, older: null, diff --git a/packages/core/src/board/page.ts b/packages/core/src/board/page.ts index 1eadf9d..50eb485 100644 --- a/packages/core/src/board/page.ts +++ b/packages/core/src/board/page.ts @@ -1,4 +1,4 @@ -import { lstat, readdir, readFile } from "node:fs/promises"; +import { lstat, open, readdir, readFile } from "node:fs/promises"; import { join } from "node:path"; import { decodeTime } from "ulid"; import { z } from "zod"; @@ -348,7 +348,7 @@ export async function boardPage(recordsDir: string, id?: string): Promise ({ id: name, at: new Date(decodeTime(name)).toISOString() })); + const records = await recordTimes(recordsDir, ids); if (ids.length === 0) return boardPageUnavailable("no_records", { records }); const shown = id ?? (ids[ids.length - 1] as string); const index = ids.indexOf(shown); @@ -384,6 +384,44 @@ export async function boardPage(recordsDir: string, id?: string): Promise { + const out: { id: string; at: string }[] = []; + for (const id of ids) { + const at = await recordedAtOf(join(recordsDir, `${id}.json`)); + out.push({ id, at: at ?? new Date(decodeTime(id)).toISOString() }); + } + return out; +} + +// How a record basou wrote begins (JSON.stringify with an indent of 2). +const RECORD_HEAD = /^\{\s*"record_version":\s*\d+,\s*"recorded_at":\s*"([^"\\]+)"/; +const RECORD_HEAD_BYTES = 256; + +async function recordedAtOf(path: string): Promise { + let handle: Awaited> | undefined; + try { + handle = await open(path, "r"); + const head = Buffer.alloc(RECORD_HEAD_BYTES); + const { bytesRead } = await handle.read(head, 0, RECORD_HEAD_BYTES, 0); + const at = RECORD_HEAD.exec(head.subarray(0, bytesRead).toString("utf8"))?.[1]; + return at === undefined || Number.isNaN(Date.parse(at)) + ? undefined + : new Date(at).toISOString(); + } catch { + return undefined; + } finally { + await handle?.close(); + } +} + async function readPageRecord( recordsDir: string, id: string, From ae95c0ce21f8b3a879787a5642cac0116ee57907 Mon Sep 17 00:00:00 2001 From: Takashi Matsuyama Date: Mon, 19 Oct 2026 15:33:35 +0900 Subject: [PATCH 2/2] basou: Narrow what the guide and the reference say a refused key is told, list records by a UTC recorded_at off regular files only, and test the anchor and the newest shape --- CHANGELOG.md | 8 ++++--- docs/spec/board.md | 8 ++++--- packages/core/src/board/declaration.test.ts | 22 ++++++++++++++++++ packages/core/src/board/guide.test.ts | 2 +- packages/core/src/board/guide.ts | 2 +- packages/core/src/board/page.test.ts | 25 ++++++++++++++++----- packages/core/src/board/page.ts | 21 ++++++++++++----- 7 files changed, 68 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 59e91e0..f417b09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,9 @@ All notable changes to **basou** are recorded here. The project follows ### Fixed -- **A key a measure or an observation of a board does not take is refused - with the keys its kind takes**, in the version the board is read by: +- **A key no version of its kind takes, in a measure or an observation of + a board, is refused with the keys the kind takes**, in the version the + board is read by: `observe[0]: unknown keys 'count', 'capture' (kind 'http' takes: key, kind, url, words, count_pattern, capture_pattern, notes)`. Until now only the key was named, and the names of a kind's keys were found by guessing from one refusal after another. @@ -27,7 +28,8 @@ All notable changes to **basou** are recorded here. The project follows another time, a second or so apart, which they gave. - **`basou board record` takes `recorded_at` after it measures the board again**, so a record is no longer recorded a few milliseconds before the - measurement it holds. + measurement it holds. Records written by 0.71.1 or earlier keep the time + taken before. ## 0.71.1 — 2026-10-19 diff --git a/docs/spec/board.md b/docs/spec/board.md index 52fa2c8..88d2a9c 100644 --- a/docs/spec/board.md +++ b/docs/spec/board.md @@ -91,9 +91,11 @@ last axis review) is not known; it is never taken as no record. (`the key 01 at line 4, column 3 is not a string; write it in quotes`), so the stage ids are written in quotes: `"01"`. - **An unknown key is refused at every level**, so a typo is reported, not - ignored. One in a measure or an observation is refused with the keys its + ignored. One in a measure or an observation itself (not in its + `section`) that no version of its kind takes is refused with the keys the kind takes in the version the file is read by - (`observe[0]: unknown keys 'count', 'capture' (kind 'http' takes: key, kind, url, words, count_pattern, capture_pattern, notes)`). + (`observe[0]: unknown keys 'count', 'capture' (kind 'http' takes: key, kind, url, words, count_pattern, capture_pattern, notes)`); + one a later version takes is told which version reads it. - **A problem in the YAML itself stops the reading**, and so does an unknown `board_version` (see [Files and versions](#1-files-and-versions)): a syntax error, a duplicate key, a `%YAML` directive or a key that is not a string @@ -832,7 +834,7 @@ later one is added. | Key | Holds | |---|---| | `record_version` | `3` | -| `recorded_at` | when it was recorded, after the measurement it holds: UTC, ISO 8601 with milliseconds | +| `recorded_at` | when it was recorded: UTC, ISO 8601 with milliseconds. After the measurement it holds; a record written by basou 0.71.1 or earlier took it just before measuring again | | `recorded_with` | `{ basou, build }`: the version of the basou that recorded it, and the commit it was built from (null for a build from source) | | `declaration` | the declaration the board was measured and judged by ([4.2](#42-declaration)) | | `measure` | the measurement, as `basou board measure --json` prints it, without `diff` ([5](#5-the-measurement)) | diff --git a/packages/core/src/board/declaration.test.ts b/packages/core/src/board/declaration.test.ts index b119fb1..b1148a1 100644 --- a/packages/core/src/board/declaration.test.ts +++ b/packages/core/src/board/declaration.test.ts @@ -326,6 +326,28 @@ describe("parseBoardDeclaration: the shape of each key", () => { expect(errorsOf(parse(lane))).toEqual(["lanes[0]: unknown key 'colour'"]); }); + it("names the keys of the newest shape for a file with no version, or one that is not a number", () => { + for (const version of [undefined, "2"]) { + const doc = boardV2(); + if (version === undefined) delete doc.board_version; + else doc.board_version = version; + doc.observe = [{ key: "a", kind: "manual", how: "ask", colour: "red" }]; + expect(errorsOf(parse(doc)), String(version)).toContain( + "observe[0]: unknown key 'colour' (kind 'manual' takes: key, kind, how, notes)", + ); + } + }); + + it("names no keys for a key called kind outside a measure or an observation", () => { + const doc = boardV2(); + entry(entry(doc.measures, 1), "section").kind = "file_count"; + entry(doc.lanes, 0).kind = "file_count"; + expect(errorsOf(parse(doc))).toEqual([ + "lanes[0]: unknown key 'kind'", + "measures[1].section: unknown key 'kind'", + ]); + }); + it("refuses keys that belong to another kind", () => { expect( errorsOf( diff --git a/packages/core/src/board/guide.test.ts b/packages/core/src/board/guide.test.ts index e29b614..a76dbea 100644 --- a/packages/core/src/board/guide.test.ts +++ b/packages/core/src/board/guide.test.ts @@ -659,7 +659,7 @@ describe("boardGuide: no board declared", () => { it("says where the keys of each kind are described, for this basou's version, with a board or without", () => { const reference = - "The keys each kind of measure and observation takes at each `board_version`, their defaults and the shapes of their values are described in the reference for basou 0.0.0-test: https://github.com/basou-dev/basou/blob/v0.0.0-test/docs/spec/board.md. A key a kind does not take is refused with the keys it takes."; + "The keys each kind of measure and observation takes at each `board_version`, their defaults and the shapes of their values are described in the reference for basou 0.0.0-test: https://github.com/basou-dev/basou/blob/v0.0.0-test/docs/spec/board.md. A key that no `board_version` of its kind takes is refused with the keys the kind takes; one a later version takes is told which version reads it."; // When the axis is cut, before a board is declared. const undeclared = boardGuide(guideInput({ board: { status: "undeclared" } })); expect(undeclared).toContain(`\n ${reference}\n4. Check it until it reads`); diff --git a/packages/core/src/board/guide.ts b/packages/core/src/board/guide.ts index d73056b..aeb2505 100644 --- a/packages/core/src/board/guide.ts +++ b/packages/core/src/board/guide.ts @@ -394,7 +394,7 @@ export function boardReferenceUrl(version: string): string { } function referenceLine(input: BoardGuideInput): string { - return `The keys each kind of measure and observation takes at each \`board_version\`, their defaults and the shapes of their values are described in the reference for basou ${input.basouVersion}: ${boardReferenceUrl(input.basouVersion)}. A key a kind does not take is refused with the keys it takes.`; + return `The keys each kind of measure and observation takes at each \`board_version\`, their defaults and the shapes of their values are described in the reference for basou ${input.basouVersion}: ${boardReferenceUrl(input.basouVersion)}. A key that no \`board_version\` of its kind takes is refused with the keys the kind takes; one a later version takes is told which version reads it.`; } function basouLine(input: BoardGuideInput): string { diff --git a/packages/core/src/board/page.test.ts b/packages/core/src/board/page.test.ts index 63d84e6..e34c123 100644 --- a/packages/core/src/board/page.test.ts +++ b/packages/core/src/board/page.test.ts @@ -790,12 +790,25 @@ describe("boardPage", () => { { id: B, at: "1970-01-01T00:00:00.002Z" }, { id: C, at: "1970-01-01T00:00:00.003Z" }, ]); - // Only the record's own, not one a part of it holds. - await place(C, '{"record_version":3,"note":{"recorded_at":"2026-01-01T00:00:00.000Z"}}'); - expect((await boardPage(records)).records.at(-1)).toEqual({ - id: C, - at: "1970-01-01T00:00:00.003Z", - }); + const last = async () => (await boardPage(records)).records.at(-1); + const byName = { id: C, at: "1970-01-01T00:00:00.003Z" }; + // Only the record's own, not one a part of it holds before it. + await place( + C, + '{"x":{"record_version":1,"recorded_at":"2026-01-01T00:00:00.000Z"},"record_version":3,"recorded_at":"2026-02-02T00:00:00.000Z"}', + ); + expect(await last()).toEqual(byName); + // Only a time as basou writes it, in UTC to the millisecond: another form + // would be read in the host's time zone. + for (const recorded_at of ["1", "Oct 7", "2026-10-07T12:00:00", "2026-10-07T12:00:00+09:00"]) { + await place(C, JSON.stringify(record({ recorded_at }))); + expect(await last(), recorded_at).toEqual(byName); + } + // Only a file is opened: a name that is a link (or a FIFO, which would + // never answer) is listed by its name's time. + await rm(join(records, `${C}.json`)); + await symlink(join(records, `${A}.json`), join(records, `${C}.json`)); + expect(await last()).toEqual(byName); }); it("does not draw a record it cannot read, of a version it does not know, or not in the shape of one", async () => { diff --git a/packages/core/src/board/page.ts b/packages/core/src/board/page.ts index 50eb485..3c5dd87 100644 --- a/packages/core/src/board/page.ts +++ b/packages/core/src/board/page.ts @@ -339,16 +339,20 @@ export function boardPageUnavailable( */ export async function boardPage(recordsDir: string, id?: string): Promise { let names: string[]; + // The names that are files: the list opens no other (a FIFO would never answer). + let files: Set; try { const entry = await lstat(recordsDir); if (!entry.isDirectory()) return boardPageUnavailable("records_not_directory"); - names = await readdir(recordsDir); + const entries = await readdir(recordsDir, { withFileTypes: true }); + names = entries.map((e) => e.name); + files = new Set(entries.filter((e) => e.isFile()).map((e) => e.name)); } catch (error: unknown) { if (findErrorCode(error, "ENOENT")) return boardPageUnavailable("no_records"); return boardPageUnavailable("records_unreadable"); } const ids = recordIds(names); - const records = await recordTimes(recordsDir, ids); + const records = await recordTimes(recordsDir, ids, files); if (ids.length === 0) return boardPageUnavailable("no_records", { records }); const shown = id ?? (ids[ids.length - 1] as string); const index = ids.indexOf(shown); @@ -388,21 +392,26 @@ export async function boardPage(recordsDir: string, id?: string): Promise, ): Promise<{ id: string; at: string }[]> { const out: { id: string; at: string }[] = []; for (const id of ids) { - const at = await recordedAtOf(join(recordsDir, `${id}.json`)); + const name = `${id}.json`; + const at = files.has(name) ? await recordedAtOf(join(recordsDir, name)) : undefined; out.push({ id, at: at ?? new Date(decodeTime(id)).toISOString() }); } return out; } -// How a record basou wrote begins (JSON.stringify with an indent of 2). -const RECORD_HEAD = /^\{\s*"record_version":\s*\d+,\s*"recorded_at":\s*"([^"\\]+)"/; +// How a record basou wrote begins (JSON.stringify with an indent of 2), with +// `recorded_at` as basou writes it: a time in UTC to the millisecond. Any +// other form would be read in the host's time zone, or not at all. +const RECORD_HEAD = + /^\{\s*"record_version":\s*\d+,\s*"recorded_at":\s*"(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z)"/; const RECORD_HEAD_BYTES = 256; async function recordedAtOf(path: string): Promise {