From 59e3287c3fd2b90dcb6f155cf10b0dbfcdfad52f Mon Sep 17 00:00:00 2001 From: Takashi Matsuyama Date: Tue, 20 Oct 2026 10:45:13 +0900 Subject: [PATCH 1/7] basou: Say where a board's declaration changed since the previous record, in the text of measure and record - board measure and board record (without --json) list, under "Since the previous record", each place where board.yaml differs from the declaration the previous record holds: "declaration : added", "removed" or "changed", and "declaration lanes: the order changed". The places are named as a diff names a value. Both declarations are compared as a record holds them, a measure's lane of one id as a list and a record of version 1 with no observations, so moving a board to version 3 lists nothing for that alone. - What the measurement's diff shows already is not listed again: the title, a measure's unit and lanes, a ratio's numerator and denominator, the effort's start, a time zone declared in both, and a measure or a ratio added or removed. - The JSON of both commands is unchanged. - The guide says the top of measure.txt lists these, to write an axis review's summary from; the reference says the text lists them and the JSON does not. --- docs/spec/board.md | 37 ++++- packages/cli/src/commands/board.test.ts | 115 +++++++++++++ packages/cli/src/commands/board.ts | 83 ++++++++-- packages/core/src/board/diff.test.ts | 207 +++++++++++++++++++++++- packages/core/src/board/diff.ts | 125 ++++++++++++++ packages/core/src/board/guide.test.ts | 6 + packages/core/src/board/guide.ts | 2 +- packages/core/src/board/index.ts | 3 +- packages/core/src/index.ts | 2 + 9 files changed, 560 insertions(+), 20 deletions(-) diff --git a/docs/spec/board.md b/docs/spec/board.md index 5ad7afc..f3e8c63 100644 --- a/docs/spec/board.md +++ b/docs/spec/board.md @@ -857,6 +857,11 @@ no such cell); and `observed` lists each observation whose value or error moved, as `{ name, before, after }`, each `{ value, error }` (no `error` when there is none), or null where there was no such observation. +Without `--json`, the text says where the record is, then what moved since the +previous record: the cells, the observations, the measurement's `diff`, and, +from basou 0.72.0, where the declaration changed ([5.12](#512-diff)), which +the JSON does not hold. + ## 4. A record `records/.json`, written whole (to a file of another name, then linked @@ -1190,7 +1195,9 @@ no previous record, and null with an entry at `diff` when it cannot be read. - **Compared** are the values the digest covers, except `measured_with`, `methods` (compared on their own), `effort.daily` (its rows only grow) and `components.kind_changed` (already a change since the previous record). - The declaration, which the digest also covers, is not compared. + The declaration, which the digest also covers, is not compared in `diff`: + the JSON holds no change of it. The text `basou board measure` and `basou + board record` print without `--json` lists it (see below). - Values are equal when their JSON is, keys sorted. A list whose entries name themselves is compared entry by entry: `repos` by `path`, `trail.tracks_open` by `id`, `not_found` by `at`. `components.unacknowledged` @@ -1200,6 +1207,34 @@ no previous record, and null with an entry at `diff` when it cannot be read. - A measure's lane held as one id, as a record of version 1 or 2 holds it, is equal to the list of that one. +**Where the declaration changed, in the text only.** From basou 0.72.0, the +text `basou board measure` and `basou board record` print without `--json` +lists, under `Since the previous record :` and after what moved in the +measurement, each place where the declaration the board is measured by now +differs from the one the previous record holds, one line each: +`declaration : added`, `removed` or `changed`, and +`declaration lanes: the order changed`. No key of the JSON holds these. + +- `` names the place as `at` does: `stages.03.look`, + `lanes["web"].notes`, `observe["site.top"]` (an observation added or + removed), `measures["readme.latest"].include`, `components["app/db"].lane`. +- Both declarations are compared as a record holds them + ([4.2](#42-declaration)): with their defaults filled in, a record of + version 1 with `observe` as `[]`, and a measure's lane held as one id the + list of that one, so the first record after a board moves to version 3 + lists nothing for that alone. +- The lanes, the measures and the ratios are compared by id, and the + observations by key, each key of an entry on its own; the lanes' order is + one line of its own. Any other list (`notes`, `look`, `words`, `include`, + `effort.milestones`) is compared whole. +- Not listed again is what `values`, `added` and `removed` show already: the + title, a measure's `unit` and `lane`, a ratio's `numerator` and + `denominator`, `effort.start`, `effort.time_zone` when both declare one, + and a measure or a ratio added or removed (its values are added or + removed). +- When the previous record holds no declaration that is an object, the line + says it is not compared. + ### 5.13 The digest `digest` is `sha256:` and the hex SHA-256 of a JSON text: the measurement diff --git a/packages/cli/src/commands/board.test.ts b/packages/cli/src/commands/board.test.ts index 6b54759..29e603a 100644 --- a/packages/cli/src/commands/board.test.ts +++ b/packages/cli/src/commands/board.test.ts @@ -1760,12 +1760,117 @@ describe("basou board measure and record against the previous record", () => { ); }); + it("says in its text, not in its JSON, where the declaration changed since the previous record", async () => { + const { repo, first } = await recorded(); + // The board of version 1 the record holds, read again as version 3 with + // what the measurement holds changed (the title, a unit, a lane) and + // what it does not (a stage's look, a lane's notes, the axis, an observation). + await placeBoard( + repo, + JSON.stringify({ + board_version: 3, + title: "Board v3", + stages: { ...STAGES, "03": { meaning: "stage 03", look: ["the tests"] } }, + lanes: [{ id: "core", name: "Core", notes: ["mind it"] }], + measures: [{ ...MD, unit: "docs", lane: ["core"] }], + axis: { version: 2, review_due_days: 60 }, + effort: { start: "2026-04-28" }, + observe: [{ key: "npm", kind: "npm_version", package: "basou" }], + }), + ); + const { out } = capture(); + const m = await doRunBoardMeasure({ json: true }, ctx(repo)); + expect(Object.keys(m.diff ?? {})).toEqual(["against", "values", "methods", "added", "removed"]); + expect(JSON.stringify(m.diff)).not.toContain("look"); + // What the measurement's diff shows of the declaration, which the text does not repeat. + expect(m.diff?.values.map((v) => v.at)).toEqual( + expect.arrayContaining(["board_version", "title", "measures.md.unit"]), + ); + expect(m.diff?.added.map((v) => v.at)).toContain("measures.md.lane"); + out.length = 0; + await doRunBoardMeasure({}, ctx(repo)); + const declared = [ + " declaration stages.03.look: added", + ' declaration lanes["core"].notes: added', + " declaration axis.version: changed", + ' declaration observe["npm"]: added', + ]; + const text = out.join("\n"); + expect(text).toContain(`Since the previous record ${first}:\n`); + expect(text).toContain(` title: "Board" -> "Board v3"\n`); + expect(text).toContain(`${declared.join("\n")}\n\nRepos:`); + expect(text.match(/^ {2}declaration /gm)).toHaveLength(declared.length); + // The record says the same in its text, and nothing of it in its JSON. + out.length = 0; + const input = JSON.stringify( + judgement(m.digest, ["done", "part"], { + npm: { value: "1.0.0", observed_at: "2026-10-05", source: "registry" }, + }), + ); + const dry = await doRunBoardRecord( + { dryRun: true }, + { ...ctx(repo), readInput: async () => input }, + ); + expect(Object.keys(dry.diff ?? {})).toEqual(["against", "measure", "cells", "observed"]); + expect(JSON.stringify(dry)).not.toContain("look"); + // After what moved in the measurement, as the last of what moved. + expect(out.join("\n")).toContain(` + measures.md.lane: ["core"]\n${declared.join("\n")}`); + out.length = 0; + await doRunBoardRecord( + { dryRun: true, json: true }, + { ...ctx(repo), readInput: async () => input }, + ); + expect(out.join("\n")).not.toContain("declaration "); + // With nothing else moved, the declaration alone is said, not "nothing + // moved". The records are ignored, so a record written is no file more. + await writeFile(join(repo, ".gitignore"), "board/records/\n.gitignore\n"); + const before = await doRunBoardMeasure({ json: true }, ctx(repo)); + await doRunBoardRecord( + {}, + { + ...ctx(repo), + readInput: async () => + JSON.stringify( + judgement(before.digest, ["done", "part"], { + npm: { value: "1.0.0", observed_at: "2026-10-05", source: "registry" }, + }), + ), + }, + ); + await placeBoard( + repo, + JSON.stringify({ + board_version: 3, + title: "Board v3", + stages: { ...STAGES, "03": { meaning: "stage 03", look: ["the tests", "the docs"] } }, + lanes: [{ id: "core", name: "Core", notes: ["mind it"] }], + measures: [{ ...MD, unit: "docs", lane: ["core"] }], + axis: { version: 2, review_due_days: 60 }, + effort: { start: "2026-04-28" }, + observe: [{ key: "npm", kind: "npm_version", package: "basou" }], + }), + ); + const after = await doRunBoardMeasure({ json: true }, ctx(repo)); + expect(after.diff).toMatchObject({ values: [], added: [], removed: [], methods: [] }); + out.length = 0; + await doRunBoardMeasure({}, ctx(repo)); + const only = out.join("\n"); + expect(only).toMatch( + /Since the previous record [0-9A-Z]{26}:\n {2}declaration stages\.03\.look: changed\n\nRepos:/, + ); + expect(only).not.toContain("nothing moved"); + }); + it("shows what a previous record holds with its control characters escaped", async () => { const { repo, first } = await recorded(); const file = join(repo, "board", "records", `${first}.json`); const written = JSON.parse(await readFile(file, "utf8")); written.cells[1].state = `part${ESC}[31m`; written.measure.methods[`evil${ESC}[2J`] = 1; + written.declaration.components[`app/evil${String.fromCodePoint(0x2028)}${ESC}[2J`] = { + lane: "-", + note: "n", + }; await writeFile(file, JSON.stringify(written)); const { out } = capture(); const m = await doRunBoardMeasure({ json: true }, ctx(repo)); @@ -1782,6 +1887,16 @@ describe("basou board measure and record against the previous record", () => { expect(text).not.toContain(ESC); expect(text).toContain(" core 02: part\\x1b[31m -> done\n"); expect(text).toContain("methods changed: evil\\x1b[2J 1 -> none"); + expect(text).toContain(' declaration components["app/evil\\u2028\\u001b[2J"]: removed\n'); + expect(text).not.toContain(String.fromCodePoint(0x2028)); + // A declaration that is not an object is not compared, and says so. + written.declaration = "none"; + await writeFile(file, JSON.stringify(written)); + out.length = 0; + await doRunBoardMeasure({}, ctx(repo)); + expect(out.join("\n")).toContain( + "\n declaration: not compared (the previous record holds no declaration that can be read)\n", + ); }); it("takes a record it cannot read as a gap, not as no record", async () => { diff --git a/packages/cli/src/commands/board.ts b/packages/cli/src/commands/board.ts index b686f06..485b506 100644 --- a/packages/cli/src/commands/board.ts +++ b/packages/cli/src/commands/board.ts @@ -6,6 +6,7 @@ import { BOARD_INIT_SAVE, type BoardCellChange, type BoardDeclaration, + type BoardDeclarationChange, type BoardDiff, type BoardLiveMeasurement, type BoardMeasurement, @@ -16,11 +17,13 @@ import { type BoardPreviousRecords, type BoardRepo, basouPaths, + boardDeclarationSnapshot, boardGuide, boardInitText, buildRecord, byCodePoint, diffCells, + diffDeclarations, diffObserved, displayPath, effortStartOf, @@ -456,9 +459,10 @@ export async function doRunBoardMeasure( throw new Error("--model must name a model."); } const loaded = await loadBoard(options, ctx, "board measure"); - const measurement = await measureLoaded(loaded, ctx, options.model, await previousOf(loaded)); + const previous = await previousOf(loaded); + const measurement = await measureLoaded(loaded, ctx, options.model, previous); if (options.json === true) console.log(JSON.stringify(measurement, null, 2)); - else printMeasurementText(measurement, hasRecords(loaded)); + else printMeasurementText(measurement, hasRecords(loaded), declarationChanges(loaded, previous)); if (!measurement.complete) process.exitCode = 1; return measurement; } @@ -515,22 +519,23 @@ export async function doRunBoardRecord( options: BoardRecordOptions, ctx: BoardContext, ): Promise { - let result: BoardRecordResult; + let checked: Awaited>; try { - result = await checkAndRecord(options, ctx); + checked = await checkAndRecord(options, ctx); } catch (error: unknown) { if (error instanceof RecordRefusal || !(error instanceof Error)) throw error; throw new RecordRefusal(`${error.message}\nNothing was written.`, { cause: error.cause }); } + const { result, declared } = checked; if (options.json === true) console.log(JSON.stringify(result, null, 2)); - else printRecordText(result); + else printRecordText(result, declared); return result; } async function checkAndRecord( options: BoardRecordOptions, ctx: BoardContext, -): Promise { +): Promise<{ result: BoardRecordResult; declared: BoardDeclarationChange[] | null }> { const cwd = ctx.cwd ?? process.cwd(); const loaded = await loadBoard(options, ctx, "board record"); const beside = (...parts: string[]) => @@ -603,15 +608,30 @@ async function checkAndRecord( }; } return { - record: written, - dry_run: options.dryRun === true, - complete: measurement.complete, - not_found: measurement.not_found, - order_anomalies: record.order_anomalies, - diff, + result: { + record: written, + dry_run: options.dryRun === true, + complete: measurement.complete, + not_found: measurement.not_found, + order_anomalies: record.order_anomalies, + diff, + }, + declared: declarationChanges(loaded, previous), }; } +// Where the declaration changed since the previous record, for the text a +// command prints (its JSON holds none of it): null when there is no previous +// record to compare with, or it holds no declaration as an object. +function declarationChanges( + loaded: LoadedBoard, + previous: BoardPreviousRecords, +): BoardDeclarationChange[] | null { + const { last } = previous; + if (last.status !== "found") return null; + return diffDeclarations(last.record.declaration, boardDeclarationSnapshot(loaded.declaration)); +} + // The keys of a record's input, which a location may name: the words of its // shape, and those of an http observation's value, which the guide fixes. // Any other key is a name someone chose (an observation's, a lane's, or one @@ -810,7 +830,10 @@ async function checkRecordsPlace( } } -function printRecordText(result: BoardRecordResult): void { +function printRecordText( + result: BoardRecordResult, + declared: BoardDeclarationChange[] | null, +): void { const lines = [ result.record === null ? "Dry run: the record checked out (nothing was written)." @@ -836,7 +859,11 @@ function printRecordText(result: BoardRecordResult): void { ` observed ${displayPath(o.name)}: ${observation(o.before)} -> ${observation(o.after)}`, ); } - lines.push(...diffLines(result.diff.measure, cells.length + observed.length > 0)); + const declaration = declarationLines(declared); + lines.push( + ...diffLines(result.diff.measure, cells.length + observed.length + declaration.length > 0), + ...declaration, + ); } if (!result.complete) { lines.push(`The measurement recorded is not complete (${result.not_found.length}):`); @@ -1090,6 +1117,20 @@ function diffLines(diff: Omit, more: boolean): string[] { return lines; } +// The lines of where the declaration changed since the previous record. The +// JSON holds none of them. +function declarationLines(changes: BoardDeclarationChange[] | null): string[] { + if (changes === null) { + return [ + " declaration: not compared (the previous record holds no declaration that can be read)", + ]; + } + return changes.map( + (c) => + ` declaration ${displayPath(c.at)}: ${c.change === "reordered" ? "the order changed" : c.change}`, + ); +} + // A value as a line shows it: the paths of a file_paths measure by how many // there are (--json lists them). function shownValue(value: number | string | string[] | null): string { @@ -1260,13 +1301,23 @@ function axisLines(m: BoardMeasurement): string[] { return lines; } -function printMeasurementText(m: BoardMeasurement, hasRecords: boolean): void { +function printMeasurementText( + m: BoardMeasurement, + hasRecords: boolean, + declared: BoardDeclarationChange[] | null, +): void { const lines: string[] = [displayPath(m.title)]; const build = m.measured_with.build === null ? "" : ` (build ${m.measured_with.build})`; lines.push(`Measured ${m.measured_at} with basou ${m.measured_with.basou}${build}`); // What moved comes first: it is what a reader of the board looks for. if (m.diff !== null) { - lines.push("", `Since the previous record ${m.diff.against}:`, ...diffLines(m.diff, false)); + const declaration = declarationLines(declared); + lines.push( + "", + `Since the previous record ${m.diff.against}:`, + ...diffLines(m.diff, declaration.length > 0), + ...declaration, + ); } else if (m.not_found.some((n) => n.at === "diff")) { lines.push("", "The previous record could not be read (see Not measured)."); } else { diff --git a/packages/core/src/board/diff.test.ts b/packages/core/src/board/diff.test.ts index 0b42e45..53194c8 100644 --- a/packages/core/src/board/diff.test.ts +++ b/packages/core/src/board/diff.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { diffCells, diffMeasurements, diffObserved } from "./diff.js"; +import { diffCells, diffDeclarations, diffMeasurements, diffObserved } from "./diff.js"; // A measurement cut down to the parts a diff looks at, and some it does not. function measurement(over: Record = {}) { @@ -346,3 +346,208 @@ describe("diffObserved", () => { expect(diffObserved(keyed, { v: { value: { x: 1, y: [{ a: 1, b: 3 }] } } })).toHaveLength(1); }); }); + +describe("diffDeclarations", () => { + const STAGES = Object.fromEntries( + ["01", "02", "03", "04", "05", "06"].map((id) => [id, { meaning: `stage ${id}` }]), + ); + // A declaration as a record of version 3 holds it, its defaults filled in. + function declaration(over: Record = {}) { + return { + title: "Board", + stages: STAGES, + lanes: [ + { id: "web", name: "Web" }, + { id: "cli", name: "CLI", notes: ["mind the flags"] }, + ], + measures: [ + { + id: "readme.latest", + kind: "file_count", + unit: "files", + lane: ["web"], + repo: ".", + at: "worktree", + include: ["*.md"], + }, + { + id: "src", + kind: "line_count", + unit: "lines", + repo: ".", + at: "worktree", + include: ["src/**"], + }, + ], + ratios: [ + { id: "r", label: "Docs to source", numerator: "readme.latest", denominator: "src" }, + ], + components: { "app/db": { lane: ["cli"] }, app: { lane: "-", note: "the root" } }, + axis: { version: 1, review_due_days: 60 }, + effort: { start: "2026-04-28", time_zone: "Asia/Tokyo" }, + observe: [{ key: "site.top", kind: "http", url: "https://example.com/" }], + ...over, + }; + } + + it("finds nothing changed in the same declaration, one of version 1 or 2 included", () => { + expect(diffDeclarations(declaration(), declaration())).toEqual([]); + // A record of version 1 holds a measure's lane as one id and no observe: + // the same board read as version 3 has not changed. + const v1 = declaration({ observe: undefined }) as Record; + delete v1.observe; + v1.measures = (v1.measures as Record[]).map((m) => + m.lane === undefined ? m : { ...m, lane: "web" }, + ); + expect(diffDeclarations(v1, declaration({ observe: [] }))).toEqual([]); + // A key whose value is undefined is not there, as in a record. + expect(diffDeclarations(declaration(), { ...declaration(), extra: undefined })).toEqual([]); + }); + + it("names each place that changed as a diff names a value, in the order the declaration holds them", () => { + const after = declaration({ + stages: { ...STAGES, "03": { meaning: "stage 03", look: ["the tests"] } }, + lanes: [ + { id: "cli", name: "CLI", notes: ["mind the flags", "and the exit codes"] }, + { id: "web", name: "Web", about: "the site" }, + { id: "api", name: "API" }, + ], + measures: [ + { + id: "readme.latest", + kind: "file_count", + unit: "files", + lane: ["web"], + repo: ".", + at: "HEAD", + include: ["*.md", "docs/*.md"], + }, + { + id: "src", + kind: "line_count", + unit: "lines", + repo: ".", + at: "worktree", + include: ["src/**"], + }, + ], + ratios: [ + { id: "r", label: "Docs per source", numerator: "readme.latest", denominator: "src" }, + ], + components: { "app/db": { lane: ["web"] }, "app/web": { lane: ["web"] } }, + axis: { version: 2, review_due_days: 60, seed_review: { date: "2026-10-01", model: "M" } }, + effort: { + start: "2026-04-28", + time_zone: "Asia/Tokyo", + milestones: [{ date: "2026-05-01", label: "Open", ref: "v0.1.0" }], + }, + observe: [ + { key: "site.top", kind: "http", url: "https://example.com/", words: ["in stock"] }, + { key: "npm", kind: "npm_version", package: "basou", notes: ["the latest tag"] }, + ], + }); + expect(diffDeclarations(declaration(), after)).toEqual([ + { at: "stages.03.look", change: "added" }, + { at: 'lanes["cli"].notes', change: "changed" }, + { at: 'lanes["web"].about', change: "added" }, + { at: 'lanes["api"]', change: "added" }, + { at: "lanes", change: "reordered" }, + { at: 'measures["readme.latest"].at', change: "changed" }, + { at: 'measures["readme.latest"].include', change: "changed" }, + { at: 'ratios["r"].label', change: "changed" }, + { at: 'components["app/db"].lane', change: "changed" }, + { at: 'components["app/web"]', change: "added" }, + { at: "components.app", change: "removed" }, + { at: "axis.version", change: "changed" }, + { at: "axis.seed_review", change: "added" }, + { at: "effort.milestones", change: "added" }, + { at: 'observe["site.top"].words', change: "added" }, + { at: 'observe["npm"]', change: "added" }, + ]); + expect( + diffDeclarations( + declaration({ observe: [{ key: "site.top", kind: "http", url: "https://a.example/" }] }), + declaration({ + observe: [{ key: "site.top", kind: "page_version", url: "https://a.example/" }], + }), + ), + ).toEqual([{ at: 'observe["site.top"].kind', change: "changed" }]); + expect(diffDeclarations(declaration(), declaration({ observe: [] }))).toEqual([ + { at: 'observe["site.top"]', change: "removed" }, + ]); + }); + + it("leaves out what the measurement's diff shows already", () => { + const after = declaration({ + title: "Another board", + measures: [ + // The unit and the lanes are values of the measurement. + { + id: "readme.latest", + kind: "file_count", + unit: "docs", + repo: ".", + at: "worktree", + include: ["*.md"], + }, + { + id: "src", + kind: "line_count", + unit: "lines", + lane: ["cli"], + repo: ".", + at: "worktree", + include: ["src/**"], + }, + // A measure added is a value added. + { + id: "more", + kind: "file_count", + unit: "files", + repo: ".", + at: "worktree", + include: ["*"], + }, + ], + ratios: [ + { id: "r", label: "Docs to source", numerator: "src", denominator: "readme.latest" }, + { id: "s", label: "New", numerator: "src", denominator: "more" }, + ], + effort: { start: "2026-05-01", time_zone: "UTC" }, + }); + expect(diffDeclarations(declaration(), after)).toEqual([]); + // A measure and a ratio removed are values removed. + expect(diffDeclarations(after, declaration())).toEqual([]); + // A time zone declared only once may be this host's either way: it is listed. + const without = declaration({ effort: { start: "2026-04-28" } }); + expect(diffDeclarations(declaration(), without)).toEqual([ + { at: "effort.time_zone", change: "removed" }, + ]); + expect(diffDeclarations(without, declaration())).toEqual([ + { at: "effort.time_zone", change: "added" }, + ]); + }); + + it("compares a list it cannot tell the entries of whole, and has nothing to compare with no declaration", () => { + const twice = declaration({ + lanes: [ + { id: "web", name: "Web" }, + { id: "web", name: "Web again" }, + ], + }); + expect(diffDeclarations(twice, declaration())).toEqual([{ at: "lanes", change: "changed" }]); + expect(diffDeclarations(declaration({ observe: "none" }), declaration())).toEqual([ + { at: "observe", change: "changed" }, + ]); + // A key that is not a plain word is quoted, as a diff quotes it. + expect( + diffDeclarations( + declaration({ stages: { ...STAGES, "a b": { meaning: "x" } } }), + declaration(), + ), + ).toEqual([{ at: 'stages["a b"]', change: "removed" }]); + for (const none of [null, "x", [], undefined]) { + expect(diffDeclarations(none, declaration())).toBeNull(); + } + }); +}); diff --git a/packages/core/src/board/diff.ts b/packages/core/src/board/diff.ts index faa9abf..7961569 100644 --- a/packages/core/src/board/diff.ts +++ b/packages/core/src/board/diff.ts @@ -213,6 +213,131 @@ export function diffMeasurements(before: unknown, after: unknown, against: strin return { against, values, methods, added, removed }; } +/** + * A place where the declaration a board was measured by is not the previous + * record's: an entry or a key there only now (`added`), only then + * (`removed`), or there in both with another value (`changed`); or, for the + * lanes, the same lanes in another order (`reordered`). + */ +export type BoardDeclarationChange = { + /** Where, as a diff names a value: `stages.03.look`, `lanes["web"].notes`. */ + at: string; + change: "added" | "removed" | "changed" | "reordered"; +}; + +// The lists of a declaration whose entries name themselves, by the field +// that names them. +const DECLARED_KEYED: Readonly> = { + lanes: "id", + measures: "id", + ratios: "id", + observe: "key", +}; +// What the diff of the measurement shows already, as a value of the +// measurement that moved: the title, a measure's unit and lanes, a ratio's +// numerator and denominator, and the effort's start. +const DECLARED_IN_VALUES = new Set([ + "title", + "measures[].unit", + "measures[].lane", + "ratios[].numerator", + "ratios[].denominator", + "effort.start", +]); +// The lists whose entries, added or removed, the diff of the measurement +// shows as values added or removed. +const ENTRIES_IN_VALUES = new Set(["measures", "ratios"]); + +// The entries of a list by the field that names them, or undefined when an +// entry is not an object named by a string, or a name is given twice: such a +// list is compared whole. +function byName(list: unknown, field: string): Map | undefined { + if (!Array.isArray(list)) return undefined; + const out = new Map(); + for (const entry of list) { + if (!isRecord(entry)) return undefined; + const name = entry[field]; + if (typeof name !== "string" || out.has(name)) return undefined; + out.set(name, entry); + } + return out; +} + +/** + * Where the declaration a board is measured by now differs from the one the + * previous record holds, in the order the declaration holds its keys, then + * those only the previous one had. Both are compared as a record holds them: + * their defaults filled in, a record of version 1 with `observe` as `[]`, and + * a measure's lane held as one id the list of that one. A list whose entries + * name themselves is compared entry by entry (lanes, measures and ratios by + * id, observations by key, and the lanes' order on its own), and any other + * list whole. Left out is what the measurement's diff shows already: the + * title, a measure's unit and lanes, a ratio's numerator and denominator, the + * effort's start, a time zone declared in both, and a measure or a ratio + * added or removed. Null when the previous record holds no declaration as an + * object. + */ +export function diffDeclarations(before: unknown, after: unknown): BoardDeclarationChange[] | null { + if (!isRecord(before) || !isRecord(after)) return null; + const was = Object.hasOwn(before, "observe") ? before : { ...before, observe: [] }; + // As a record would hold it: a key whose value is undefined is not there. + const is: unknown = JSON.parse(JSON.stringify(after)); + const out: BoardDeclarationChange[] = []; + const walk = (then: unknown, now: unknown, rule: string, shown: string): void => { + if (DECLARED_IN_VALUES.has(rule)) return; + if (then === undefined && now === undefined) return; + if (then === undefined || now === undefined) { + out.push({ at: shown, change: then === undefined ? "added" : "removed" }); + return; + } + // The measurement's time zone is the one declared, when one is. + if (rule === "effort.time_zone" && typeof then === "string" && typeof now === "string") return; + const field = DECLARED_KEYED[rule]; + const [old, current] = + field === undefined ? [undefined, undefined] : [byName(then, field), byName(now, field)]; + if (old !== undefined && current !== undefined) { + const entry = (list: Map, name: string) => + rule === "measures" ? withLaneList(list.get(name)) : list.get(name); + const names = [...current.keys(), ...[...old.keys()].filter((n) => !current.has(n))]; + for (const name of names) { + if (ENTRIES_IN_VALUES.has(rule) && (!old.has(name) || !current.has(name))) continue; + walk( + entry(old, name), + entry(current, name), + `${rule}[]`, + `${shown}[${JSON.stringify(name)}]`, + ); + } + if (rule === "lanes") { + const kept = (from: Map, other: Map) => + [...from.keys()].filter((n) => other.has(n)); + if (!same(kept(old, current), kept(current, old))) { + out.push({ at: shown, change: "reordered" }); + } + } + return; + } + if (field === undefined && isRecord(then) && isRecord(now)) { + const keys = [ + ...Object.keys(now), + ...Object.keys(then).filter((k) => !Object.hasOwn(now, k)), + ]; + for (const key of keys) { + walk( + Object.hasOwn(then, key) ? then[key] : undefined, + Object.hasOwn(now, key) ? now[key] : undefined, + rule === "" ? key : `${rule}.${key}`, + shownKey(shown, key), + ); + } + return; + } + if (!same(then, now)) out.push({ at: shown, change: "changed" }); + }; + walk(was, is, "", ""); + return out; +} + /** A cell whose state is not the previous record's (null where there was no such cell). */ export type BoardCellChange = { lane: string; diff --git a/packages/core/src/board/guide.test.ts b/packages/core/src/board/guide.test.ts index 07c0644..3d31397 100644 --- a/packages/core/src/board/guide.test.ts +++ b/packages/core/src/board/guide.test.ts @@ -317,6 +317,12 @@ describe("boardGuide: a board declared", () => { ); }); + it("says the text of measure lists where board.yaml changed, which the JSON does not hold", () => { + expect(boardGuide(guideInput())).toContain( + "The top of measure.txt also lists each place where board.yaml changed since that record (`declaration : added`, `removed` or `changed`), which the JSON does not hold: write an `axis_review`'s summary from these lines, not from memory.", + ); + }); + it("says what a total's value is, that it may be below 0, and when it is null", () => { expect(boardGuide(guideInput())).toContain( "- A `total` measure's value is what its `plus` adds up to less what its `minus` adds up to, and may be below 0. It is null when a measure it takes was not measured, and when it is out of the range a number holds exactly (-9007199254740991 to 9007199254740991).", diff --git a/packages/core/src/board/guide.ts b/packages/core/src/board/guide.ts index 6b3a745..9941da5 100644 --- a/packages/core/src/board/guide.ts +++ b/packages/core/src/board/guide.ts @@ -187,7 +187,7 @@ export function boardGuide(input: BoardGuideInput): string { "- Exit 0: everything was measured. Exit 1 with JSON on stdout: measured with gaps (`complete: false`, a null for each, its reason under `not_found`). Exit 1 with nothing on stdout: read stderr and stop. The declaration or the manifest cannot be read (fix board.yaml through step 2b), `--model` was empty, or W was not used.", "- An empty file means it did not run, not that it passed: check that measure.json reads as JSON.", '- Never read a null as 0. Write "not confirmed" for it and do not judge by it.', - "- What moved is the point: the top of measure.txt (`Since the previous record :`) and `diff` in the JSON. A value marked `method_changed` may have moved because basou measures it another way now.", + "- What moved is the point: the top of measure.txt (`Since the previous record :`) and `diff` in the JSON. A value marked `method_changed` may have moved because basou measures it another way now. The top of measure.txt also lists each place where board.yaml changed since that record (`declaration : added`, `removed` or `changed`), which the JSON does not hold: write an `axis_review`'s summary from these lines, not from memory.", "- A `total` measure's value is what its `plus` adds up to less what its `minus` adds up to, and may be below 0. It is null when a measure it takes was not measured, and when it is out of the range a number holds exactly (-9007199254740991 to 9007199254740991).", "- A `file_paths` measure's value is the list of paths it found. `Measures:` in measure.txt gives it as a count: read the paths in the JSON. `diff` lists each path that came in (`added`) or went out (`removed`) when both records hold a list, and the list whole when only one does.", "- `repos[].base` is the branch of origin a repo is compared with (`origin/`: the one origin/HEAD names, or origin/main when origin/HEAD names none), `behind` counts the commits on it HEAD does not have, and `ahead` those HEAD has that it does not, as of the last fetch; measure does not fetch. `fetched_at` is when FETCH_HEAD was last written, given that it holds that branch fetched from origin at the commit it is at now (a later `git fetch --append` moves it too), and null when it does not (no fetch since the clone, a failed fetch, a fetch of something else since, or a push since). When it is null or old, `behind` and `ahead` may be old too.", diff --git a/packages/core/src/board/index.ts b/packages/core/src/board/index.ts index 0cb119e..6321d86 100644 --- a/packages/core/src/board/index.ts +++ b/packages/core/src/board/index.ts @@ -38,6 +38,7 @@ export { } from "./declaration.js"; export type { BoardCellChange, + BoardDeclarationChange, BoardDiff, BoardMethodChange, BoardObservation, @@ -45,7 +46,7 @@ export type { BoardValueChange, BoardValueOnly, } from "./diff.js"; -export { diffCells, diffMeasurements, diffObserved } from "./diff.js"; +export { diffCells, diffDeclarations, diffMeasurements, diffObserved } from "./diff.js"; export type { BoardActiveMs, BoardEffort, BoardEffortDay } from "./effort.js"; export { BOARD_EFFORT_METHOD, effortStartOf } from "./effort.js"; export type { BoardFreshness, BoardImportProbe } from "./freshness.js"; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index bad345c..5c8e05e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -97,6 +97,7 @@ export type { BoardComponentChange, BoardComponents, BoardDeclaration, + BoardDeclarationChange, BoardDeclarationContext, BoardDeclarationResult, BoardDeclarationSnapshot, @@ -183,6 +184,7 @@ export { buildRecord, byCodePoint, diffCells, + diffDeclarations, diffMeasurements, diffObserved, effortStartOf, From 211d7ad79da766f156656d13fc0a09f1ed1ae5b5 Mon Sep 17 00:00:00 2001 From: Takashi Matsuyama Date: Tue, 20 Oct 2026 10:54:56 +0900 Subject: [PATCH 2/7] basou: Draw a record's observations as their kind reads, every measure, and the repos and components it measured on the board page - The table of observations outside reads an http value in the shape its declaration gives it as its parts (status, redirects, where it landed, each word found or not, the count, the capture, or that the body was not read when the status is not 2xx); a value of another shape is drawn as before. Under each name, its kind and what it looks at; under each row, its notes, from the declaration the record holds. - An observation that moved since the previous record, as record's diff compares them (its value or its error), is marked, with what that record held, drawn by that record's declaration. A value whose type alone moved ("12" and 12) is drawn as JSON. Nothing is marked when there is no previous record, or it cannot be read, which a line under the table says. - A section lists every measure: its value with its unit (a string without, the paths of a file_paths by how many), the lanes it is drawn in, and, for a number, how much it moved since the previous record. - A record's page draws the repos it measured, by its version (a record of version 1 or 2 measured no base, ahead or fetch time, and held behind as behind_main), and the components it found, by the lanes its registry counts them in, then those in no lane with their notes, those not registered, and those gone, a change of kinds marked. - init's comment on measures, the guide and the reference say every measure is drawn in the page's table of measures. --- docs/spec/board.md | 7 +- packages/cli/src/commands/view.test.ts | 360 ++++++++++++++++++ packages/cli/src/lib/board-ui.ts | 200 ++++++++-- packages/core/src/board/guide.test.ts | 2 +- packages/core/src/board/guide.ts | 4 +- packages/core/src/board/init.test.ts | 4 +- packages/core/src/board/page.test.ts | 488 +++++++++++++++++++++++++ packages/core/src/board/page.ts | 437 +++++++++++++++++++++- packages/core/src/lib/view-strings.ts | 110 +++++- 9 files changed, 1565 insertions(+), 47 deletions(-) diff --git a/docs/spec/board.md b/docs/spec/board.md index f3e8c63..976b5d4 100644 --- a/docs/spec/board.md +++ b/docs/spec/board.md @@ -219,8 +219,9 @@ Every measure has: A `lane` is read as a list whatever the version, so a record holds a list (`lane: cli` is `["cli"]`). A measure with no lane is drawn in no lane's -section, and is still kept in the records, compared with the previous record -and usable by a ratio or a total. +section. Every measure, with a lane or without, is drawn in the board page's +table of measures (from basou 0.72.0), kept in the records, compared with the +previous record and usable by a ratio or a total. | Kind | Since | Reads | Value | |---|---|---|---| @@ -499,7 +500,7 @@ Every observation has: |---|---|---|---| | `key` | yes | 2 | an id, or from version 3 a dotted id | | `kind` | yes | 2 | one of the kinds below | -| `notes` | no | 3 | a list of non-empty strings: what to mind when making it; the guide prints them under its commands | +| `notes` | no | 3 | a list of non-empty strings: what to mind when making it; the guide prints them under its commands, and the board page under the observation | The values of an observation are put into commands, each as one single-quoted word, so each is held to a shape that cannot end its quotation diff --git a/packages/cli/src/commands/view.test.ts b/packages/cli/src/commands/view.test.ts index f26ac40..7303e1f 100644 --- a/packages/cli/src/commands/view.test.ts +++ b/packages/cli/src/commands/view.test.ts @@ -1366,6 +1366,11 @@ describe("basou view: the board page", () => { "when", "shown", "withWords", + "httpText", + "observedText", + "movedText", + "measureText", + "signed", "stateName", "section", "svg", @@ -1387,6 +1392,9 @@ describe("basou view: the board page", () => { const source = [ liftVar("SVGNS"), liftVar("CHART"), + liftVar("DOT"), + liftVar("FOUND"), + liftVar("NOT_FOUND"), ...(names.includes("drawLive") ? [liftVar("liveDrawn")] : []), ...[...helpers, ...names.filter((n) => !helpers.includes(n))].map(lift), ].join("\n"); @@ -1415,8 +1423,12 @@ describe("basou view: the board page", () => { "effort", "matrix", "lanes", + "measures", "composition", "turns", + "recordRepos", + "recordComponents", + "reposTable", "footnotes", "liveHeading", "liveRepos", @@ -1450,6 +1462,24 @@ describe("basou view: the board page", () => { const words = boardPageStrings("en").effort; call("draw", board); expect(document.title).toBe("Board"); + // Every section of a record, the repos and the components it measured among them. + expect(byId.board?.find((n) => n.tag === "h2").map((n) => n.textContent)).toEqual([ + "Summary", + "Period and effort", + "Reach matrix", + "Where each lane is", + "Measures", + "Composition", + "The operator's turns", + "Repositories", + "Components", + "Footnotes", + ]); + // The record was written by this basou: its repo is drawn with its base. + const repoCells = (byId.board as Node) + .find((n) => n.tag === "section" && n.children[0]?.textContent === "Repositories")[0] + ?.find((n) => n.tag === "td"); + expect(repoCells?.[0]?.textContent).toBe(basename(repo)); // The record's board declares no milestones, and says so. expect(byId.board?.text()).toContain(words.noMilestones); expect(byId.board?.text()).not.toContain(words.milestonesNotRead); @@ -1565,6 +1595,335 @@ describe("basou view: the board page", () => { expect(values).toContain('not observed (previous: {"words":[true]})'); }); + it("draws an http value in its shape as its parts, what each observation is and looks at, and its notes", () => { + const { call } = drawing(["summary"]); + const DOT = ` ${String.fromCodePoint(0xb7)} `; + const [YES, NO] = [String.fromCodePoint(0x2713), String.fromCodePoint(0x2717)]; + const made = { observed_at: "2026-10-07T00:00:00Z", source: "https://example.com/x" }; + const drawn = call("summary", { + summary: { + text: "", + tiles: [], + previous_record: "none", + observed: [ + { + name: "shop", + value: { status: 200 }, + http: { + status: 200, + redirects: 1, + final_url: "https://example.com/x/", + body: { + words: [ + { word: "in stock", found: true }, + { word: "sold out", found: false }, + ], + count: 42, + capture: "2.3", + }, + }, + kind: "http", + looks_at: ["https://example.com/x"], + notes: ["mind the `cache`", "not markup"], + ...made, + }, + { + name: "closed", + value: {}, + http: { status: 503, redirects: 0, final_url: "http://example.com/", body: null }, + ...made, + }, + { + name: "plain", + value: {}, + http: { status: 301, redirects: 10, final_url: "https://example.com/y" }, + ...made, + }, + { + name: "nomatch", + value: {}, + http: { + status: 200, + redirects: 0, + final_url: "https://a.example/", + body: { capture: null }, + }, + ...made, + }, + { name: "ci", value: "success", kind: "github_ci", looks_at: ["o/r", "q.yml"], ...made }, + { name: "issues", value: 12, ...made }, + ], + }, + }); + const rows = drawn.find((n) => n.tag === "tr").slice(1); + const cells = rows.map((r) => r.children.map((c) => c.text())); + expect(cells.map((c) => c[1])).toEqual([ + `200${DOT}redirects 1${DOT}https://example.com/x/${DOT}in stock ${YES} sold out ${NO}${DOT}count 42${DOT}capture "2.3"`, + undefined, + `503${DOT}redirects 0${DOT}http://example.com/${DOT}body not read`, + `301${DOT}redirects 10${DOT}https://example.com/y`, + `200${DOT}redirects 0${DOT}https://a.example/${DOT}no capture`, + "success", + "12", + ]); + // Its kind and what it looks at, small under its name. + const [name] = rows[0]?.children ?? []; + expect(name?.children.map((c) => [c.className, c.text()])).toEqual([ + ["", "shop"], + ["sub", `http${DOT}https://example.com/x`], + ]); + expect(rows[5]?.children[0]?.text()).toBe(`ci${"github_ci"}${DOT}o/r${DOT}q.yml`); + expect(rows[6]?.children[0]?.children).toHaveLength(1); + // Its notes in a row under it, across the table, as prose. + const notes = rows[1] as Node; + expect(notes.className).toBe("notes"); + expect(notes.children[0]?.attrs.colspan).toBe("4"); + expect(notes.find((n) => n.tag === "li").map((n) => n.text())).toEqual([ + "mind the cache", + "not markup", + ]); + expect(notes.find((n) => n.tag === "code").map((n) => n.text())).toEqual(["cache"]); + expect(notes.find((n) => n.tag === "b")).toEqual([]); + }); + + it("marks an observation that moved since the previous record, with what that record held", () => { + const { call } = drawing(["summary"]); + const DOT = ` ${String.fromCodePoint(0xb7)} `; + const [YES, NO] = [String.fromCodePoint(0x2713), String.fromCodePoint(0x2717)]; + const made = { observed_at: "2026-10-07T00:00:00Z", source: "s" }; + const ok = (word: string, found: boolean) => ({ + status: 200, + redirects: 0, + final_url: "https://example.com/", + body: { words: [{ word, found }] }, + }); + const summary = (previous_record: string, observed: unknown[]) => + call("summary", { summary: { text: "", tiles: [], previous_record, observed } }); + const drawn = summary("found", [ + { name: "npm", value: "1.1.0", moved: { status: "value", value: "1.0.0" }, ...made }, + // Its type alone moved: both as JSON would tell them apart. + { name: "count", value: 12, moved: { status: "value", value: "12" }, ...made }, + { name: "rev", value: "12", moved: { status: "value", value: 12 }, ...made }, + { + name: "back", + value: "ok", + moved: { status: "value", value: null, error: "timeout" }, + ...made, + }, + { name: "flaky", value: 3, error: "partly", moved: { status: "value", value: 3 }, ...made }, + { name: "fresh", value: true, moved: { status: "none" }, ...made }, + { + name: "shop", + value: {}, + http: ok("is", false), + moved: { status: "value", value: {}, http: ok("was", true) }, + ...made, + }, + { name: "same", value: 3, ...made }, + // Not made now: its previous value as before, unmarked. + { + name: "down", + value: null, + error: "timeout", + previous: { status: "value", value: "1" }, + ...made, + }, + ]); + const cells = drawn + .find((n) => n.tag === "tr") + .slice(1) + .map((r) => r.children[1] as Node); + expect(cells.map((c) => c.children.map((x) => x.text()))).toEqual([ + ["changed", "1.1.0", "previous: 1.0.0"], + ["changed", "12", 'previous: "12"'], + ["changed", '"12"', "previous: 12"], + ["changed", "ok", "previous: not observed (timeout)"], + ["changed", "3 (partly)", "previous: 3"], + ["changed", "true", "not in the previous record"], + [ + "changed", + `200${DOT}redirects 0${DOT}https://example.com/${DOT}is ${NO}`, + `previous: 200${DOT}redirects 0${DOT}https://example.com/${DOT}was ${YES}`, + ], + ["3"], + ["not observed (previous: 1) (timeout)"], + ]); + expect(drawn.find((n) => n.className === "chip changed")).toHaveLength(7); + const unreadable = boardPageStrings("en").observed.previousUnreadable; + expect(drawn.text()).not.toContain(unreadable); + expect(summary("unreadable", [{ name: "npm", value: "1", ...made }]).text()).toContain( + unreadable, + ); + expect(summary("unreadable", []).text()).not.toContain(unreadable); + }); + + it("draws every measure in a table, with its lanes and how much a number moved", () => { + const { call } = drawing(["measures"]); + const drawn = call("measures", { + measures: [ + { id: "src", value: 90, unit: "lines", lanes: ["Core", "Docs"], delta: -10 }, + { id: "test", value: null, unit: "lines", lanes: [], delta: null }, + { id: "latest", value: "20260401", unit: "name", lanes: ["Docs"], delta: null }, + { id: "mig", value: ["db/a.sql", "db/b.sql"], unit: "files", lanes: [], delta: null }, + { id: "pages", value: 1234.5, unit: "pages", lanes: [], delta: 0.5 }, + { id: "same", value: 3, unit: "files", lanes: [], delta: 0 }, + ], + }); + const rows = drawn.find((n) => n.tag === "tr").map((r) => r.children.map((c) => c.text())); + expect(rows).toEqual([ + ["Measure", "Value", "Drawn in", "Since the previous record"], + ["src", "90 lines", "Core, Docs", "-10"], + ["test", "not measured", "-", ""], + // A string is no amount: no unit. The paths of a file_paths by how many. + ["latest", "20260401", "Docs", ""], + ["mig", "2 files", "-", ""], + ["pages", "1,234.5 pages", "-", "+0.5"], + ["same", "3 files", "-", "0"], + ]); + expect(call("measures", { measures: [] }).text()).toContain("No measures are declared."); + }); + + it("draws the repos a record measured, saying what a record of version 1 or 2 did not measure", () => { + const { call } = drawing(["missingIn", "reposTable", "recordRepos"]); + const repo = { + path: ".", + name: "app", + head: "0123456789abcdef0123456789abcdef01234567", + branch: "main", + last_commit: null, + commits: 2, + uncommitted: 0, + base: "origin/main", + behind: 3, + ahead: 1, + fetched_at: null, + }; + const S = boardPageStrings("en"); + const footnotes = (at: string[]) => ({ not_found: at.map((a) => ({ at: a, reason: "x" })) }); + const cells = (node: Node) => node.find((n) => n.tag === "td").map((c) => c.textContent); + const v3 = call("recordRepos", { + repos: { record_version: 3, base_measured: true, list: [repo] }, + footnotes: footnotes([]), + }); + expect(cells(v3)).toEqual([ + "app", + "main", + "0123456", + "-", + "2", + "0", + "origin/main", + "3", + "1", + "-", + ]); + expect(v3.text()).not.toContain("did not measure"); + const older = { + ...repo, + base: null, + behind: null, + ahead: null, + }; + const v2 = call("recordRepos", { + repos: { record_version: 2, base_measured: false, list: [older] }, + // What it could not measure is said, by the name it had then. + footnotes: footnotes(["repos[.].behind_main", "repos[.].base", "repos[.].ahead"]), + }); + expect(cells(v2)).toEqual([ + "app", + "main", + "0123456", + "-", + "2", + "0", + "-", + "not measured", + "-", + "-", + ]); + expect(v2.text()).toContain( + "A record of version 2 did not measure the base branch, the commits ahead or the last fetch: they are drawn as -, and Behind is its behind_main.", + ); + expect(v2.text()).not.toContain("undefined"); + const none = call("recordRepos", { repos: null, footnotes: footnotes([]) }); + expect(none.text()).toContain(S.record.reposUnread); + }); + + it("draws the components a record found by lane, then those in no lane, not registered and gone", () => { + const { call } = drawing(["recordComponents"]); + const S = boardPageStrings("en"); + const drawn = call("recordComponents", { + components: { + measured: true, + reasons: [], + lanes: [ + { + id: "core", + name: "Core", + components: [ + { key: "app", kinds: ["manifest"] }, + { key: "app/db", kinds: null }, + { + key: "app/web", + kinds: ["edge", "manifest"], + kind_changed: { before: ["manifest"], after: ["edge", "manifest"] }, + }, + ], + }, + { id: "docs", name: "Docs", components: [] }, + ], + uncounted: [{ key: "app/scripts", kinds: ["marker"], note: "tooling" }], + unacknowledged: [{ key: "app/new", kinds: ["ci"] }], + gone: ["app/db"], + }, + }); + const flow = drawn.children + .slice(1) + .map((n) => (n.tag === "ul" ? n.children.map((li) => li.text()) : n.text())); + expect(flow).toEqual([ + "Core", + [ + "app manifest", + "app/db not found", + "app/web edge, manifestwas manifest in the previous record", + ], + "Docs", + S.record.noComponentInLane, + S.record.uncounted, + ["app/scripts markertooling"], + S.record.unacknowledged, + ["app/new ci"], + S.record.gone, + ["app/db"], + ]); + const notMeasured = call("recordComponents", { + components: { + measured: false, + reasons: ["two repos are in directories of the same name"], + lanes: [], + uncounted: [], + unacknowledged: [], + gone: null, + }, + }); + expect(notMeasured.text()).toContain(S.record.componentsNotMeasured); + expect(notMeasured.text()).toContain("two repos are in directories of the same name"); + const unknown = call("recordComponents", { + components: { + measured: true, + reasons: [], + lanes: [], + uncounted: [], + unacknowledged: [], + gone: null, + }, + }); + expect(unknown.text()).toContain(S.record.goneUnknown); + expect(call("recordComponents", { components: null }).text()).toContain( + S.record.componentsUnread, + ); + }); + it("draws the paths of a file_paths measure as a count, with the paths folded under it", () => { const { call } = drawing(["lanes"]); const drawn = call("lanes", { @@ -1988,6 +2347,7 @@ describe("basou view: the board page", () => { "missingIn", "liveTile", "liveHeading", + "reposTable", "liveRepos", "liveTrail", "liveComponents", diff --git a/packages/cli/src/lib/board-ui.ts b/packages/cli/src/lib/board-ui.ts index 8de5b41..2e215b6 100644 --- a/packages/cli/src/lib/board-ui.ts +++ b/packages/cli/src/lib/board-ui.ts @@ -1,8 +1,9 @@ /** * The board page of `basou view`, served verbatim at `GET /board` in single * mode. It draws one record of the board (`GET /api/board`, or - * `/api/board/` with `?record=`) in eight sections, from the record - * alone, with the fixed strings the server sends in the workspace's language. + * `/api/board/` with `?record=`) in sections, from the record and + * the one before it alone, with the fixed strings the server sends in the + * workspace's language. * With no record to draw, or with `?live=1`, it draws what basou measures of * the workspace now (`GET /api/board/live`): the period and effort, the * repositories, the trail and the components, with nothing judged. @@ -88,6 +89,11 @@ export const BOARD_HTML = ` .chip.blocked { background: var(--chip-blocked); } .chip.unverified { background: var(--chip-unverified); } .muted { color: var(--fg-2); } + .sub { font-size: 12px; color: var(--fg-2); overflow-wrap: anywhere; } + ul.sub { margin: 0; padding-left: 18px; } + td.wrap { overflow-wrap: anywhere; min-width: 10em; } + tr.notes td { padding-top: 0; } + .chip.changed { margin-left: 0; margin-right: 6px; background: var(--chip-unverified); } .bar { height: 10px; background: var(--fill); border-radius: 5px; min-width: 120px; position: relative; overflow: hidden; } .bar span { position: absolute; left: 0; top: 0; bottom: 0; background: var(--s1); } figure { margin: 16px 0; } @@ -217,6 +223,60 @@ export const BOARD_HTML = ` return JSON.stringify(k) + ':' + v; }).join(',') + '}'; } + // The parts of a value drawn on one line, and what an http value says of each word. + var DOT = ' ' + String.fromCharCode(183) + ' '; + var FOUND = String.fromCharCode(10003); + var NOT_FOUND = String.fromCharCode(10007); + // An http value read as its parts: the status, the redirects, where it + // landed, and what it read off the body (when the status is 2xx) or that it + // did not. Numbers and strings are drawn as they are. + function httpText(h) { + var parts = [String(h.status), fill(S.observed.redirects, { n: h.redirects }), h.final_url]; + if (h.body === null) parts.push(S.observed.bodyNotRead); + else if (h.body) { + if (h.body.words) { + parts.push(h.body.words.map(function (w) { return w.word + ' ' + (w.found ? FOUND : NOT_FOUND); }).join(' ')); + } + if (h.body.count !== undefined) parts.push(fill(S.observed.count, { n: h.body.count })); + if (h.body.capture !== undefined) { + parts.push(h.body.capture === null ? S.observed.captureNone : fill(S.observed.capture, { value: JSON.stringify(h.body.capture) })); + } + } + return parts.join(DOT); + } + // A value of an observation as the page draws it: an http value in its + // shape read as its parts, one with words beside what it says of each, and + // any other as it is (a string) or as JSON. A test lifts this function. + function observedText(value, words, http) { + if (http) return httpText(http); + if (words) return withWords(value, words); + return shown(value); + } + // The value of an observation that moved, and what the previous record + // held: a value whose type alone moved ("12" and 12) is drawn as JSON, so + // that the two do not read the same. A test lifts this function. + function movedText(o) { + var m = o.moved; + var now = observedText(o.value, o.words, o.http); + if (m.status === 'none') return { now: now, then: S.observed.beforeNone }; + if (m.value === null) { + return { now: now, then: S.observed.beforeNotObserved + (m.error ? ' (' + m.error + ')' : '') }; + } + var then = observedText(m.value, m.words, m.http); + if ((typeof m.value === 'string') !== (typeof o.value === 'string')) { + then = JSON.stringify(m.value); + if (then === now) now = JSON.stringify(o.value); + } + return { now: now, then: fill(S.observed.before, { value: then }) + (m.error ? ' (' + m.error + ')' : '') }; + } + // A measure's value: the paths of a file_paths by how many, a number with + // its unit, and a string (a file's name, a capture), which is no amount, + // without it. + function measureText(m) { + if (Array.isArray(m.value)) return num(m.value.length) + ' ' + m.unit; + return num(m.value) + (typeof m.value === 'number' ? ' ' + m.unit : ''); + } + function signed(n) { return (n > 0 ? '+' : '') + num(n); } function stateName(state) { return S.states[state] || state; } function setStatus(message, isErr) { var s = $('status'); @@ -301,17 +361,36 @@ export const BOARD_HTML = ` if (s.observed.length === 0) { observed = el('p', { class: 'muted', text: S.observed.none }); } else { - var rows = s.observed.map(function (o) { + var rows = []; + s.observed.forEach(function (o) { + var cell = el('td', { class: 'wrap' }); var value; - if (o.value !== null) value = o.words ? withWords(o.value, o.words) : shown(o.value); - else if (o.previous && o.previous.status === 'value') value = fill(S.observed.missing, { value: shown(o.previous.value) }); - else if (o.previous && o.previous.status === 'unreadable') value = S.observed.missingPreviousUnreadable; - else value = S.observed.missingNoPrevious; - if (o.error) value += ' (' + o.error + ')'; - return el('tr', null, [ - el('td', { text: o.name }), el('td', { text: value }), - el('td', { text: o.observed_at }), el('td', { text: o.source }) - ]); + if (o.value !== null && o.moved) { + var pair = movedText(o); + cell.appendChild(el('span', { class: 'chip changed', text: S.observed.changed })); + value = pair.now; + cell.appendChild(el('span', { text: value + (o.error ? ' (' + o.error + ')' : '') })); + cell.appendChild(el('div', { class: 'sub', text: pair.then })); + } else { + if (o.value !== null) value = observedText(o.value, o.words, o.http); + else if (o.previous && o.previous.status === 'value') value = fill(S.observed.missing, { value: shown(o.previous.value) }); + else if (o.previous && o.previous.status === 'unreadable') value = S.observed.missingPreviousUnreadable; + else value = S.observed.missingNoPrevious; + if (o.error) value += ' (' + o.error + ')'; + cell.appendChild(el('span', { text: value })); + } + // What the record's declaration says it is and looks at, under its name. + var what = o.kind ? [o.kind].concat(o.looks_at || []).join(DOT) : null; + rows.push(el('tr', null, [ + el('td', null, [el('div', { text: o.name }), what === null ? null : el('div', { class: 'sub', text: what })]), + cell, + el('td', { text: o.observed_at }), el('td', { class: 'wrap', text: o.source }) + ])); + if (o.notes && o.notes.length > 0) { + rows.push(el('tr', { class: 'notes' }, [el('td', { colspan: '4' }, [ + el('ul', { class: 'sub' }, o.notes.map(function (n) { return el('li', null, [prose(n, 'span')]); })) + ])])); + } }); observed = el('div', { class: 'scroll' }, [el('table', { class: 'grid' }, [ el('tr', null, [ @@ -321,12 +400,16 @@ export const BOARD_HTML = ` ].concat(rows))]); } var observedHeading = el('h3', { text: S.observed.heading }, [reported()]); - return section(S.sections.summary, false, [ + var children = [ el('div', null, [prose(s.text), reported()]), tiles, observedHeading, observed - ]); + ]; + if (s.previous_record === 'unreadable' && s.observed.length > 0) { + children.push(el('p', { class: 'muted', text: S.observed.previousUnreadable })); + } + return section(S.sections.summary, false, children); } var SVGNS = 'http://www.w3.org/2000/svg'; @@ -665,6 +748,27 @@ export const BOARD_HTML = ` })); } + // Every measure of the record, with the lanes it is drawn in and how much a + // number moved since the previous record. + function measures(b) { + if (b.measures.length === 0) { + return section(S.sections.measures, false, [el('p', { class: 'muted', text: S.measures.none })]); + } + var head = el('tr', null, [ + el('th', { text: S.measures.id }), el('th', { text: S.measures.value }), + el('th', { text: S.measures.lanes }), el('th', { class: 'n', text: S.measures.delta }) + ]); + var rows = b.measures.map(function (m) { + return el('tr', { 'data-measure': m.id }, [ + el('td', null, [el('code', { text: m.id })]), + el('td', { class: 'wrap', text: measureText(m) }), + el('td', { text: m.lanes.length === 0 ? '-' : m.lanes.join(', ') }), + el('td', { class: 'n', text: m.delta === null || m.delta === undefined ? '' : signed(m.delta) }) + ]); + }); + return section(S.sections.measures, false, [el('div', { class: 'scroll' }, [el('table', { class: 'grid' }, [head].concat(rows))])]); + } + function composition(b) { if (b.composition.length === 0) { return section(S.sections.composition, false, [el('p', { class: 'muted', text: S.composition.none })]); @@ -752,7 +856,8 @@ export const BOARD_HTML = ` var b = page.board; // The board's own title, so that boards open in other tabs can be told apart. document.title = b.heading.title; - [heading(b), summary(b), effort(b, false), matrix(b), lanes(b), composition(b), turns(b), footnotes(b)] + [heading(b), summary(b), effort(b, false), matrix(b), lanes(b), measures(b), composition(b), turns(b), + recordRepos(b), recordComponents(b), footnotes(b)] .forEach(function (node) { root.appendChild(node); }); } @@ -778,12 +883,14 @@ export const BOARD_HTML = ` function missingIn(l) { return function (at) { return l.footnotes.not_found.some(function (n) { return n.at === at; }); }; } - function liveRepos(l) { - var missing = missingIn(l); + // The repositories as a table. older: a record of version 1 or 2, which + // measured no base, ahead or fetch time (drawn as '-') and held the + // commits behind as behind_main. + function reposTable(list, missing, older) { var labels = [S.live.repo, S.live.branch, S.live.head, S.live.lastCommit, S.live.commits, S.live.uncommitted, S.live.base, S.live.behind, S.live.ahead, S.live.fetchedAt]; var numeric = [4, 5, 7, 8]; var head = el('tr', null, labels.map(function (label, i) { return el('th', { class: numeric.indexOf(i) !== -1 ? 'n' : '', text: label }); })); - var rows = l.repos.map(function (r) { + var rows = list.map(function (r) { var at = 'repos[' + r.path + ']'; // A null the measurement says it could not measure, or one that means // so (a detached HEAD, no commit yet, no base, no fetch of it on @@ -792,6 +899,7 @@ export const BOARD_HTML = ` if (v !== null) return show(v); return missing(at) || missing(at + '.' + field) ? S.tiles.notMeasured : '-'; }; + var unmeasured = function (field, v, show) { return older ? '-' : value(field, v, show); }; return el('tr', null, [ el('td', { text: r.name === null ? r.path : r.name }), el('td', { text: value('branch', r.branch, String) }), @@ -799,13 +907,59 @@ export const BOARD_HTML = ` el('td', { text: value('last_commit', r.last_commit, when) }), el('td', { class: 'n', text: num(r.commits) }), el('td', { class: 'n', text: num(r.uncommitted) }), - el('td', { text: value('base', r.base, String) }), - el('td', { class: 'n', text: value('behind', r.behind, num) }), - el('td', { class: 'n', text: value('ahead', r.ahead, num) }), - el('td', { text: value('fetched_at', r.fetched_at, when) }) + el('td', { text: unmeasured('base', r.base, String) }), + el('td', { class: 'n', text: value(older ? 'behind_main' : 'behind', r.behind, num) }), + el('td', { class: 'n', text: unmeasured('ahead', r.ahead, num) }), + el('td', { text: unmeasured('fetched_at', r.fetched_at, when) }) + ]); + }); + return el('div', { class: 'scroll' }, [el('table', { class: 'grid' }, [head].concat(rows))]); + } + function liveRepos(l) { + return section(S.live.repos, false, [reposTable(l.repos, missingIn(l), false)]); + } + // The repositories as the record measured them, by its version. + function recordRepos(b) { + var r = b.repos; + if (!r) return section(S.live.repos, false, [el('p', { class: 'muted', text: S.record.reposUnread })]); + var children = [reposTable(r.list, missingIn(b), !r.base_measured)]; + if (!r.base_measured) children.push(el('p', { class: 'muted', text: fill(S.record.notMeasuredByVersion, { version: r.record_version }) })); + return section(S.live.repos, false, children); + } + // The components the record found, by the lanes its registry counts them + // in, then those in no lane, those not registered, and those gone. + function recordComponents(b) { + var c = b.components; + if (!c) return section(S.live.components, false, [el('p', { class: 'muted', text: S.record.componentsUnread })]); + if (!c.measured) { + return section(S.live.components, false, [ + el('p', { class: 'muted', text: S.record.componentsNotMeasured }), + el('ul', null, c.reasons.map(function (reason) { return el('li', { text: reason }); })) ]); + } + var item = function (x) { + var kinds = x.kinds === null ? S.record.notFound : x.kinds.join(', '); + var parts = [el('code', { text: x.key }), el('span', { class: 'muted', text: ' ' + kinds })]; + if (x.kind_changed) { + parts.push(el('span', { class: 'chip changed', text: fill(S.record.kindChanged, { before: x.kind_changed.before.join(', ') }) })); + } + if (x.note) parts.push(el('div', { class: 'sub', text: x.note })); + return el('li', { 'data-component': x.key }, parts); + }; + var group = function (title, list, empty) { + return [el('h3', { text: title }), list.length === 0 ? el('p', { class: 'muted', text: empty }) : el('ul', null, list.map(item))]; + }; + var children = []; + c.lanes.forEach(function (lane) { + children = children.concat(group(lane.name, lane.components, S.record.noComponentInLane)); }); - return section(S.live.repos, false, [el('div', { class: 'scroll' }, [el('table', { class: 'grid' }, [head].concat(rows))])]); + children = children.concat(group(S.record.uncounted, c.uncounted, S.record.none)); + children = children.concat(group(S.record.unacknowledged, c.unacknowledged, S.record.none)); + children.push(el('h3', { text: S.record.gone })); + children.push(c.gone === null ? el('p', { class: 'muted', text: S.record.goneUnknown }) + : c.gone.length === 0 ? el('p', { class: 'muted', text: S.record.none }) + : el('ul', null, c.gone.map(function (key) { return el('li', null, [el('code', { text: key })]); }))); + return section(S.live.components, false, children); } function liveTrail(l) { var t = l.trail; diff --git a/packages/core/src/board/guide.test.ts b/packages/core/src/board/guide.test.ts index 3d31397..4bc5ed6 100644 --- a/packages/core/src/board/guide.test.ts +++ b/packages/core/src/board/guide.test.ts @@ -545,7 +545,7 @@ describe("boardGuide: the gate on readers, the Stop hook and where a measure is it("says, before a board is declared, which measures the page draws and who may read the repo", () => { const guide = boardGuide(guideInput({ board: { status: "undeclared" } })); expect(guide).toContain( - "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).", + "Every measure is drawn in the board page's table of measures, and one with `lane:` (a lane id, or a list of them) in each of those lanes' sections too.", ); expect(guide).toContain( "no collaborator but the owner and you (the account gh is signed in as), and no invitation", diff --git a/packages/core/src/board/guide.ts b/packages/core/src/board/guide.ts index 9941da5..d756de2 100644 --- a/packages/core/src/board/guide.ts +++ b/packages/core/src/board/guide.ts @@ -248,7 +248,7 @@ export function boardGuide(input: BoardGuideInput): string { "- `measure_digest`: the `digest` of `$W/measure.json`.", '- `observed`: when the board declares observations, one entry per declared observation, under its key, each as step 3 says; nothing else and nothing left out, or record refuses it. An observation made has no `error`; one not made has `"value": null`, an `error` and the `observed_at` of when it was tried. The template leaves each `observed_at` empty: fill every one. With none declared, `observed` may stay empty.', "- `cells`: every lane at every stage. Set each `state` and delete each `previous`: record refuses a cell with an empty state or a `previous`. `blocked`, `shelved` and `unverified` need a `reason`.", - `- \`prose\`: \`summary\`, \`lanes\`, \`operator_turns\` as \`{ "text", "source" }\` and \`footnotes\`, all plain strings (HTML is shown as text; only \`\` \`code\` \`\` is drawn as code). Do not repeat what basou draws (the heading, the tiles, the period and effort, the matrix, the ratios, the axis version and the model). Do not start a lane's prose with its \`about\`: the page draws the about right before it. Give \`lanes\` an entry only for a lane you have something to say about (an empty string is drawn as an empty paragraph); the lane ids are ${d.lanes.map((l) => `\`${l.id}\``).join(", ")}. Write a decision or task id in full, all 26 characters after \`decision_\` or \`task_\`: basou prints some of them short, and a short one can match more than one, a voided decision among them.`, + `- \`prose\`: \`summary\`, \`lanes\`, \`operator_turns\` as \`{ "text", "source" }\` and \`footnotes\`, all plain strings (HTML is shown as text; only \`\` \`code\` \`\` is drawn as code). Do not repeat what basou draws (the heading, the tiles, the period and effort, the matrix, the measures, the ratios, the repos, the components, the axis version and the model). Do not start a lane's prose with its \`about\`: the page draws the about right before it. Give \`lanes\` an entry only for a lane you have something to say about (an empty string is drawn as an empty paragraph); the lane ids are ${d.lanes.map((l) => `\`${l.id}\``).join(", ")}. Write a decision or task id in full, all 26 characters after \`decision_\` or \`task_\`: basou prints some of them short, and a short one can match more than one, a voided decision among them.`, '- `axis_review`: null, or `{ "triggers": [...], "summary": "..." }` as step 2b says.', "", ); @@ -811,7 +811,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).", + "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. Every measure is drawn in the board page's table of measures, and one with `lane:` (a lane id, or a list of them) in each of those lanes' sections too.", ` ${referenceLine(input)}`, "4. Check it until it reads (every problem with it is listed at once):", ); diff --git a/packages/core/src/board/init.test.ts b/packages/core/src/board/init.test.ts index c7aad08..2ada9ab 100644 --- a/packages/core/src/board/init.test.ts +++ b/packages/core/src/board/init.test.ts @@ -48,7 +48,7 @@ describe("boardInitText", () => { expect(text).toContain(`# ${words.comments.measures}\nmeasures: []\n`); } expect(en.comments.measures).toContain( - "One with a lane, or a list of lanes, is drawn in each one's section", + "Each is drawn in the board page's table of measures, and one with a lane, or a list of lanes, in each one's section of the page too.", ); // The comment on observe names every kind a board can declare, http among them. for (const kind of BOARD_OBSERVE_KINDS) { @@ -72,7 +72,7 @@ describe("boardInitText", () => { expect(ja.comments.measures).toContain(kind); } expect(ja.comments.measures).toContain( - "lane\uff081 \u3064\u306e\u30ec\u30fc\u30f3\u304b\u3001\u30ec\u30fc\u30f3\u306e\u4e26\u3073\uff09\u3092\u4ed8\u3051\u305f\u3082\u306e\u306f\u76e4\u306e\u30da\u30fc\u30b8\u306e\u305d\u308c\u305e\u308c\u306e\u30ec\u30fc\u30f3\u306e\u7bc0\u306b\u63cf\u304b\u308c", + "\u3069\u308c\u3082\u76e4\u306e\u30da\u30fc\u30b8\u306e measure \u306e\u4e00\u89a7\u306b\u63cf\u304b\u308c\u3001lane\uff081 \u3064\u306e\u30ec\u30fc\u30f3\u304b\u3001\u30ec\u30fc\u30f3\u306e\u4e26\u3073\uff09\u3092\u4ed8\u3051\u305f\u3082\u306e\u306f\u305d\u308c\u305e\u308c\u306e\u30ec\u30fc\u30f3\u306e\u7bc0\u306b\u3082\u63cf\u304b\u308c\u308b", ); }); diff --git a/packages/core/src/board/page.test.ts b/packages/core/src/board/page.test.ts index e34c123..e1e9ec3 100644 --- a/packages/core/src/board/page.test.ts +++ b/packages/core/src/board/page.test.ts @@ -235,6 +235,12 @@ describe("boardPage", () => { value: { status: 404, redirects: 0, final_url: "https://example.com/x", words: null }, observed_at: "2026-10-07T00:00:00Z", source: "https://example.com/x", + // Read as its parts: the body of a 404 is not read. + http: { status: 404, redirects: 0, final_url: "https://example.com/x", body: null }, + kind: "http", + looks_at: ["https://example.com/x"], + // The record before it observed no such thing. + moved: { status: "none" }, }, ]); expect((await boardPage(records, A)).status).toBe("ok"); @@ -326,6 +332,488 @@ describe("boardPage", () => { expect(Object.hasOwn(shop as object, "words")).toBe(false); }); + it("reads an http value in its shape as its parts, and draws one of another shape as it is", async () => { + await mkdir(records); + const made = (v: unknown) => ({ value: v, observed_at: "2026-10-07T00:00:00Z", source: "s" }); + const r = record({ + record_version: 3, + observed: { + shop: made({ + status: 200, + redirects: 1, + final_url: "https://example.com/x/", + words: [true, false], + count: 42, + capture: "2.3", + }), + closed: made({ + status: 503, + redirects: 0, + final_url: "http://example.com/", + words: null, + count: null, + capture: null, + }), + plain: made({ status: 301, redirects: 10, final_url: "https://example.com/y" }), + nomatch: made({ + status: 200, + redirects: 0, + final_url: "https://a.example/", + capture: null, + }), + // As a record written before 0.72.0 may hold it. + older: made({ status: 200, words: [true] }), + asked: made({ status: 200, redirects: 0, final_url: "https://a.example/" }), + }, + }); + const http = (key: string, more: Record = {}) => ({ + key, + kind: "http", + url: `https://example.com/${key}`, + ...more, + }); + r.declaration = { + ...r.declaration, + observe: [ + http("shop", { + words: ["in stock", "sold out"], + count_pattern: "item", + capture_pattern: "v(\\d)", + }), + http("closed", { words: ["a"], count_pattern: "b", capture_pattern: "(c)" }), + http("plain"), + http("nomatch", { capture_pattern: "(c)" }), + http("older", { words: ["a"] }), + { key: "asked", kind: "manual", how: "ask" }, + ], + } as typeof r.declaration; + await place(A, r); + const page = await boardPage(records); + if (page.status !== "ok") throw new Error(page.why); + const byName = new Map(page.board.summary.observed.map((o) => [o.name, o])); + expect(byName.get("shop")?.http).toEqual({ + status: 200, + redirects: 1, + final_url: "https://example.com/x/", + body: { + words: [ + { word: "in stock", found: true }, + { word: "sold out", found: false }, + ], + count: 42, + capture: "2.3", + }, + }); + // Not 2xx: the body was not read. + expect(byName.get("closed")?.http).toEqual({ + status: 503, + redirects: 0, + final_url: "http://example.com/", + body: null, + }); + // Nothing read off the body: no body at all. + expect(byName.get("plain")?.http).toEqual({ + status: 301, + redirects: 10, + final_url: "https://example.com/y", + }); + expect(byName.get("nomatch")?.http?.body).toEqual({ capture: null }); + // Out of the shape its declaration gives it, or of a kind with no shape: as it is. + expect(byName.get("older")?.http).toBeUndefined(); + expect(byName.get("older")?.words).toEqual([{ word: "a", found: true }]); + expect(byName.get("asked")?.http).toBeUndefined(); + expect(byName.get("asked")?.kind).toBe("manual"); + }); + + it("gives each observation its kind, what it looks at and its notes, as the record declares them", async () => { + await mkdir(records); + const made = { value: "x", observed_at: "2026-10-07", source: "s" }; + const r = record({ + record_version: 3, + observed: { ci: made, check: made, npm: made, page: made, odd: made, undeclared: made }, + }); + r.declaration = { + ...r.declaration, + observe: [ + { + key: "ci", + kind: "github_ci", + repo: "owner/app", + workflow: "quality.yml", + branch: "main", + notes: ["the main branch", "`gh` must be signed in"], + }, + { + key: "check", + kind: "github_check", + repo: "owner/app", + check: "Builds: app", + branch: "main", + }, + { key: "npm", kind: "npm_version", package: "@scope/cli" }, + { key: "page", kind: "page_version", url: "https://example.com/" }, + // A key not in its shape is left out, the rest of the entry kept. + { key: "odd", kind: "github_release", repo: 1, notes: "not a list" }, + ], + } as typeof r.declaration; + await place(A, r); + const page = await boardPage(records); + if (page.status !== "ok") throw new Error(page.why); + const drawn = page.board.summary.observed.map(({ name, kind, looks_at, notes }) => ({ + name, + kind, + looks_at, + notes, + })); + expect(drawn).toEqual([ + { + name: "ci", + kind: "github_ci", + looks_at: ["owner/app", "quality.yml"], + notes: ["the main branch", "`gh` must be signed in"], + }, + { + name: "check", + kind: "github_check", + looks_at: ["owner/app", "Builds: app"], + notes: undefined, + }, + { name: "npm", kind: "npm_version", looks_at: ["@scope/cli"], notes: undefined }, + { name: "page", kind: "page_version", looks_at: ["https://example.com/"], notes: undefined }, + { name: "odd", kind: "github_release", looks_at: undefined, notes: undefined }, + { name: "undeclared", kind: undefined, looks_at: undefined, notes: undefined }, + ]); + for (const o of page.board.summary.observed.slice(1)) { + expect(Object.hasOwn(o, "notes"), o.name).toBe(false); + } + }); + + it("marks an observation that moved since the previous record, with what that record held by its own declaration", async () => { + await mkdir(records); + const made = (v: unknown, error?: string) => ({ + value: v, + observed_at: "2026-10-07", + source: "s", + ...(error === undefined ? {} : { error }), + }); + const shop = (words: string[]) => ({ + key: "shop", + kind: "http", + url: "https://example.com/", + words, + }); + const at = (observed: Record, words: string[]) => { + const r = record({ record_version: 3, observed }); + r.declaration = { ...r.declaration, observe: [shop(words)] } as typeof r.declaration; + return r; + }; + const ok = (found: boolean) => ({ + status: 200, + redirects: 0, + final_url: "https://example.com/", + words: [found], + }); + await place( + A, + at( + { + npm: made("1.0.0"), + same: made(3), + flaky: made(3), + count: made("12"), + back: made(null, "timeout"), + shop: made(ok(true)), + gone: made(1), + down: made("1.0.0"), + }, + ["was"], + ), + ); + await place( + B, + at( + { + npm: made("1.1.0"), + same: made(3), + flaky: made(3, "partly"), + count: made(12), + back: made("ok"), + shop: made(ok(false)), + fresh: made(true), + down: made(null, "timeout"), + }, + ["is"], + ), + ); + const page = await boardPage(records); + if (page.status !== "ok") throw new Error(page.why); + expect(page.board.summary.previous_record).toBe("found"); + const moved = Object.fromEntries(page.board.summary.observed.map((o) => [o.name, o.moved])); + expect(moved).toEqual({ + npm: { status: "value", value: "1.0.0" }, + same: undefined, + // Its error moved, as record's diff says. + flaky: { status: "value", value: 3 }, + count: { status: "value", value: "12" }, + back: { status: "value", value: null, error: "timeout" }, + // Drawn by the words the previous record declared, not this one's. + shop: { + status: "value", + value: ok(true), + words: [{ word: "was", found: true }], + http: { + status: 200, + redirects: 0, + final_url: "https://example.com/", + body: { words: [{ word: "was", found: true }] }, + }, + }, + fresh: { status: "none" }, + // Not made now: drawn with its previous value, as before, unmarked. + down: undefined, + }); + const down = page.board.summary.observed.find((o) => o.name === "down"); + expect(down?.previous).toEqual({ status: "value", value: "1.0.0" }); + // The first record has no previous one, and marks nothing. + const first = await boardPage(records, A); + if (first.status !== "ok") throw new Error(first.why); + expect(first.board.summary.previous_record).toBe("none"); + expect(first.board.summary.observed.some((o) => o.moved !== undefined)).toBe(false); + // One that cannot be read marks nothing either, and says so. + await place(A, "{ not json"); + const unread = await boardPage(records); + if (unread.status !== "ok") throw new Error(unread.why); + expect(unread.board.summary.previous_record).toBe("unreadable"); + expect(unread.board.summary.observed.some((o) => o.moved !== undefined)).toBe(false); + }); + + it("lists every measure, with the lanes it is drawn in and how much a number moved", async () => { + await mkdir(records); + const at = (values: Record) => { + const r = record({ record_version: 3 }); + r.declaration = { + ...r.declaration, + measures: [ + { id: "src", kind: "line_count", unit: "lines", lane: ["core", "docs"] }, + { id: "test", kind: "line_count", unit: "lines" }, + { id: "latest", kind: "file_name", unit: "name", lane: ["docs"] }, + { id: "mig", kind: "file_paths", unit: "files" }, + { id: "new", kind: "file_count", unit: "files" }, + ], + ratios: [], + } as typeof r.declaration; + r.measure = { + ...r.measure, + measures: Object.fromEntries( + Object.entries(values).map(([id, value]) => [id, { value, unit: "u" }]), + ), + ratios: {}, + } as unknown as typeof r.measure; + return r; + }; + await place(A, at({ src: 100, test: 40, latest: "a", mig: ["x"] })); + await place(B, at({ src: 90, test: null, latest: "b", mig: ["x", "y"], new: 3 })); + const page = await boardPage(records); + if (page.status !== "ok") throw new Error(page.why); + expect(page.board.measures).toEqual([ + { id: "src", value: 90, unit: "lines", lanes: ["Core", "Docs"], delta: -10 }, + { id: "test", value: null, unit: "lines", lanes: [], delta: null }, + { id: "latest", value: "b", unit: "name", lanes: ["Docs"], delta: null }, + { id: "mig", value: ["x", "y"], unit: "files", lanes: [], delta: null }, + // Not in the previous record. + { id: "new", value: 3, unit: "files", lanes: [], delta: null }, + ]); + // No previous record, or one that cannot be read: no change to give. + const first = await boardPage(records, A); + if (first.status !== "ok") throw new Error(first.why); + expect(first.board.measures.map((m) => m.delta)).toEqual([null, null, null, null, null]); + await place(A, "{ not json"); + const unread = await boardPage(records); + if (unread.status !== "ok") throw new Error(unread.why); + expect(unread.board.measures[0]?.delta).toBeNull(); + // A record of version 1 holds one lane id. + await rm(join(records, `${B}.json`)); + await place(A, record()); + const v1 = await boardPage(records); + if (v1.status !== "ok") throw new Error(v1.why); + expect(v1.board.measures).toEqual([ + { id: "src", value: 100, unit: "lines", lanes: ["Core"], delta: null }, + { id: "test", value: null, unit: "lines", lanes: [], delta: null }, + ]); + }); + + it("draws the repos a record measured by its version", async () => { + await mkdir(records); + const repo = { + path: ".", + name: "app", + head: "0123456789abcdef0123456789abcdef01234567", + branch: "main", + last_commit: "2026-10-06T12:00:00+09:00", + commits: 12, + files: 30, + uncommitted: 1, + }; + const v3 = record({ record_version: 3 }); + v3.measure = { + ...v3.measure, + repos: [ + { + ...repo, + base: "origin/main", + behind: 2, + ahead: 1, + fetched_at: "2026-10-06T00:00:00.000Z", + }, + ], + } as typeof v3.measure; + await place(A, v3); + const page = await boardPage(records); + if (page.status !== "ok") throw new Error(page.why); + const { files: _files, ...drawn } = repo; + expect(page.board.repos).toEqual({ + record_version: 3, + base_measured: true, + list: [ + { + ...drawn, + base: "origin/main", + behind: 2, + ahead: 1, + fetched_at: "2026-10-06T00:00:00.000Z", + }, + ], + }); + // A record of version 1 or 2 counted the commits behind as behind_main, + // and measured no base, ahead or fetch time. + for (const version of [1, 2]) { + const older = record({ record_version: version }); + older.measure = { + ...older.measure, + repos: [{ ...repo, behind_main: 4 }], + } as typeof older.measure; + await place(A, older); + const read = await boardPage(records); + if (read.status !== "ok") throw new Error(read.why); + expect(read.board.repos).toEqual({ + record_version: version, + base_measured: false, + list: [{ ...drawn, base: null, behind: 4, ahead: null, fetched_at: null }], + }); + } + // None, or not in the shape of its version: drawn as none, the rest of the record drawn. + for (const repos of [undefined, "x", [{ ...repo, behind_main: 4 }]]) { + const odd = record({ record_version: 3 }); + odd.measure = { ...odd.measure, repos } as typeof odd.measure; + await place(A, odd); + const read = await boardPage(records); + if (read.status !== "ok") throw new Error(`${JSON.stringify(repos)}: ${read.why}`); + expect(read.board.repos).toBeNull(); + } + }); + + it("draws the components a record found by the lanes its registry counts them in", async () => { + await mkdir(records); + const r = record({ record_version: 3 }); + r.declaration = { + ...r.declaration, + components: { + "app/web": { lane: ["docs", "core"] }, + app: { lane: ["core"] }, + "app/db": { lane: ["core"] }, + "app/scripts": { lane: "-", note: "tooling, not a surface" }, + }, + } as typeof r.declaration; + const found = JSON.parse( + '{"app":{"kinds":["manifest"],"status":"known"},"app/web":{"kinds":["edge","manifest"],"status":"known"},"app/scripts":{"kinds":["marker"],"status":"known"},"app/new":{"kinds":["ci"],"status":"unacknowledged"},"__proto__":{"kinds":["build"],"status":"unacknowledged"}}', + ); + r.measure = { + ...r.measure, + components: { + found, + unacknowledged: ["app/new", "__proto__"], + gone: ["app/db", "app/old"], + kind_changed: [{ key: "app/web", before: ["manifest"], after: ["edge", "manifest"] }], + }, + } as unknown as typeof r.measure; + await place(A, r); + const page = await boardPage(records); + if (page.status !== "ok") throw new Error(page.why); + expect(page.board.components).toEqual({ + measured: true, + reasons: [], + lanes: [ + { + id: "core", + name: "Core", + components: [ + { key: "app", kinds: ["manifest"] }, + { key: "app/db", kinds: null }, + { + key: "app/web", + kinds: ["edge", "manifest"], + kind_changed: { before: ["manifest"], after: ["edge", "manifest"] }, + }, + ], + }, + { + id: "docs", + name: "Docs", + components: [ + { + key: "app/web", + kinds: ["edge", "manifest"], + kind_changed: { before: ["manifest"], after: ["edge", "manifest"] }, + }, + ], + }, + ], + uncounted: [{ key: "app/scripts", kinds: ["marker"], note: "tooling, not a surface" }], + unacknowledged: [ + { key: "__proto__", kinds: ["build"] }, + { key: "app/new", kinds: ["ci"] }, + ], + gone: ["app/db", "app/old"], + }); + // Not measured: why, from not_found. + const unmeasured = record({ record_version: 3 }); + unmeasured.measure = { + ...unmeasured.measure, + not_found: [{ at: "components", reason: "two repos are in directories of the same name" }], + components: { found: null, unacknowledged: null, gone: null, kind_changed: null }, + } as unknown as typeof unmeasured.measure; + await place(A, unmeasured); + const none = await boardPage(records); + if (none.status !== "ok") throw new Error(none.why); + expect(none.board.components).toEqual({ + measured: false, + reasons: ["two repos are in directories of the same name"], + lanes: [], + uncounted: [], + unacknowledged: [], + gone: null, + }); + // What was gone is not known when the record before could not be read. + const unknown = record({ record_version: 3 }); + unknown.measure = { + ...unknown.measure, + components: { found: {}, unacknowledged: [], gone: null, kind_changed: null }, + } as unknown as typeof unknown.measure; + await place(A, unknown); + const notKnown = await boardPage(records); + if (notKnown.status !== "ok") throw new Error(notKnown.why); + expect(notKnown.board.components?.gone).toBeNull(); + // Not in its shape: drawn as none, the rest of the record drawn. + for (const components of [undefined, "x", { found: { app: { kinds: "manifest" } } }]) { + const odd = record({ record_version: 3 }); + odd.measure = { ...odd.measure, components } as unknown as typeof odd.measure; + await place(A, odd); + const read = await boardPage(records); + if (read.status !== "ok") throw new Error(`${JSON.stringify(components)}: ${read.why}`); + expect(read.board.components).toBeNull(); + } + }); + it("draws a record whose declared observations are not in their shape, without their words", async () => { await mkdir(records); const words = { value: { words: [true] }, observed_at: "2026-10-07T00:00:00Z", source: "s" }; diff --git a/packages/core/src/board/page.ts b/packages/core/src/board/page.ts index 3c5dd87..333d9c6 100644 --- a/packages/core/src/board/page.ts +++ b/packages/core/src/board/page.ts @@ -3,8 +3,10 @@ import { join } from "node:path"; import { decodeTime } from "ulid"; import { z } from "zod"; import { findErrorCode } from "../lib/error-codes.js"; -import { isCalendarDate } from "./declaration.js"; -import { diffCells } from "./diff.js"; +import { byCodePoint } from "./components.js"; +import { type BoardObserve, isCalendarDate } from "./declaration.js"; +import { type BoardObservedChange, diffCells, diffObserved } from "./diff.js"; +import { observedValueProblems } from "./observed-shape.js"; import { readBoardRecordFile, recordIds } from "./previous.js"; import { BOARD_RECORD_VERSIONS } from "./record.js"; @@ -17,12 +19,46 @@ const count = z.number().nullable(); // What the page draws from a record, kept loose: a key it does not draw is // let through. -// An observation of a record's declaration, as the page reads its words. +// An observation of a record's declaration, as the page reads it: a key of it +// not in its shape is read as not there. +const maybe = (schema: T) => schema.optional().catch(undefined); const declaredObservationSchema = z.looseObject({ key: text, kind: text, - words: z.array(text).optional(), + words: maybe(z.array(text)), + count_pattern: maybe(text), + capture_pattern: maybe(text), + notes: maybe(z.array(text)), + url: maybe(text), + repo: maybe(text), + package: maybe(text), + workflow: maybe(text), + check: maybe(text), }); +type DeclaredObservation = z.output; + +// What an observation looks at, as the page names it under the observation. +const LOOKS_AT = ["url", "repo", "package", "workflow", "check"] as const; + +// A repo of a record of version 3, and of one of version 1 or 2, which held +// the commits behind as behind_main and measured no base, ahead or fetch. +const repoFields = { + path: text, + name: text.nullable(), + head: text.nullable(), + branch: text.nullable(), + last_commit: text.nullable(), + commits: count, + uncommitted: count, +}; +const repoSchema = z.looseObject({ + ...repoFields, + base: text.nullable(), + behind: count, + ahead: count, + fetched_at: text.nullable(), +}); +const olderRepoSchema = z.looseObject({ ...repoFields, behind_main: count }); const pageRecordSchema = z.looseObject({ record_version: z.number(), @@ -55,6 +91,12 @@ const pageRecordSchema = z.looseObject({ measure: z.looseObject({ measured_with: z.looseObject({ basou: text, build: text.nullable() }), complete: z.boolean(), + // Read by the record's version (see repoSchema), and drawn as not in its + // shape, rather than the record not drawn, when it is not. + repos: z.unknown().optional(), + // Read by hand (see componentsOf): a key of its own, such as __proto__, + // stays one. + components: z.unknown().optional(), not_found: z.array(z.looseObject({ at: text, reason: text })), measures: z.record( text, @@ -155,14 +197,100 @@ export type BoardPageObservation = { * for each word. */ words?: { word: string; found: unknown }[]; + /** An http value read as its parts, present when it is in the shape its declaration gives it. */ + http?: BoardPageHttp; + /** Its kind, as the record's declaration says, when that declares it. */ + kind?: string; + /** What it looks at, as the record's declaration says: its url, or its repo, package, workflow and check. */ + looks_at?: string[]; + /** What to mind when making it, as the record's declaration says. */ + notes?: string[]; + /** + * Present when the value is not null and it moved since the previous + * record, as the record's diff compares observations (its value or its + * error): what the previous record held. + */ + moved?: BoardPageMovedFrom; +}; + +/** + * An http value read as its parts. `body` is there when the observation reads + * the body (words, a count or a capture), and null when the status is not + * 2xx, so the body was not read. + */ +export type BoardPageHttp = { + status: number; + redirects: number; + final_url: string; + body?: { + words?: { word: string; found: boolean }[]; + count?: number; + capture?: string | null; + } | null; }; +/** + * What the previous record held of an observation that moved: its value, + * drawn by that record's declaration (its words and the shape of its kind), + * or none, when it had no observation of that name. + */ +export type BoardPageMovedFrom = + | { + status: "value"; + value: unknown; + error?: string; + words?: { word: string; found: unknown }[]; + http?: BoardPageHttp; + } + | { status: "none" }; + /** The previous record's value of an observation: there, none, or not known. */ export type BoardPagePrevious = | { status: "value"; value: unknown } | { status: "none" } | { status: "unreadable" }; +/** A measure of the record, in the table of every measure. */ +export type BoardPageMeasure = { + id: string; + /** A number, a string, or the paths of a `file_paths`. */ + value: number | string | string[] | null; + unit: string; + /** The names of the lanes it is drawn in. */ + lanes: string[]; + /** + * This value less the previous record's, when both are numbers; null when + * one is not, or there is no previous record that can be read. + */ + delta: number | null; +}; + +/** A repository as the board page draws it, measured on the spot or recorded. */ +export type BoardPageRepo = { + path: string; + name: string | null; + head: string | null; + branch: string | null; + last_commit: string | null; + commits: number | null; + uncommitted: number | null; + base: string | null; + behind: number | null; + ahead: number | null; + fetched_at: string | null; +}; + +/** A component as a record's page draws it. */ +export type BoardPageComponent = { + key: string; + /** The kinds of marker found for it, null when it was not found. */ + kinds: string[] | null; + /** Why it counts in no lane, for one registered with the lane '-'. */ + note?: string; + /** Its kinds in the previous record, when they were others. */ + kind_changed?: { before: string[]; after: string[] }; +}; + export type BoardPageCell = { stage: string; state: string; @@ -214,7 +342,7 @@ export type BoardPageWeek = { commits: number | null; }; -/** What the board page draws from one record: the eight sections, in order. */ +/** What the board page draws from one record, by section. */ export type BoardPageBody = { heading: { title: string; @@ -223,7 +351,13 @@ export type BoardPageBody = { complete: boolean; not_found: number; }; - summary: { text: string; tiles: BoardPageTile[]; observed: BoardPageObservation[] }; + summary: { + text: string; + tiles: BoardPageTile[]; + observed: BoardPageObservation[]; + /** Whether there is a previous record, and whether it could be read. */ + previous_record: "found" | "none" | "unreadable"; + }; effort: { start: string; time_zone: string | null; @@ -255,6 +389,8 @@ export type BoardPageBody = { anomalies: { lane: string; stage: string; state: string; before: string }[]; }; lanes: BoardPageLane[]; + /** Every measure the record's declaration holds, in its order. */ + measures: BoardPageMeasure[]; composition: { id: string; label: string; @@ -262,6 +398,32 @@ export type BoardPageBody = { numerator: { id: string; value: number | string | string[] | null; unit: string | null }; denominator: { id: string; value: number | string | string[] | null; unit: string | null }; }[]; + /** + * The repositories as the record measured them. A record of version 1 or 2 + * measured no base, commits ahead or fetch time (`base_measured` false), and + * its commits behind are drawn as `behind`. Null when the record holds none + * in a shape the page reads. + */ + repos: { record_version: number; base_measured: boolean; list: BoardPageRepo[] } | null; + /** + * The components as the record measured them, against the registry its + * declaration holds. Null when the record holds none in a shape the page + * reads. + */ + components: { + /** False when they were not measured: `reasons` says why. */ + measured: boolean; + /** What not_found says at `components` or under it. */ + reasons: string[]; + /** Each lane, with the components registered in it, by code point. */ + lanes: { id: string; name: string; components: BoardPageComponent[] }[]; + /** The components registered with the lane '-', each with its note. */ + uncounted: BoardPageComponent[]; + /** The components found that the registry does not hold. */ + unacknowledged: BoardPageComponent[]; + /** Those registered or found before that were not found; null when not known. */ + gone: string[] | null; + } | null; turns: { text: string; source: string }[]; footnotes: { notes: string[]; @@ -382,7 +544,15 @@ export async function boardPage(recordsDir: string, id?: string): Promise>; + observed: Readonly>; + /** The observations its declaration holds, by key. */ + observe: ReadonlyMap; + /** The value of each of its measures, by id. */ + measures: ReadonlyMap; }; +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +// The observations a declaration holds, by key: an entry not in its shape is +// left out, and drawn with none of what the declaration says of it. +function declaredObservations(observe: unknown): Map { + const out = new Map(); + if (!Array.isArray(observe)) return out; + for (const raw of observe) { + const o = declaredObservationSchema.safeParse(raw).data; + if (o !== undefined && !out.has(o.key)) out.set(o.key, o); + } + return out; +} + +// The value of each measure of a measurement as a record holds it, by id. +function measureValues(measures: unknown): Map { + const out = new Map(); + if (!isRecord(measures)) return out; + for (const [id, entry] of Object.entries(measures)) { + if (isRecord(entry)) out.set(id, entry.value); + } + return out; +} + // The states a cell has when it is done or begun, and when it needs attention. const REACHED = new Set(["done", "part"]); const ATTENTION = new Set(["blocked", "shelved", "unverified"]); @@ -471,12 +671,14 @@ const ATTENTION = new Set(["blocked", "shelved", "unverified"]); function bodyOf(r: PageRecord, previous: Previous): BoardPageBody { const d = r.declaration; const m = r.measure; - const declaredWords = new Map( - (d.observe ?? []).flatMap((raw) => { - const o = declaredObservationSchema.safeParse(raw).data; - return o?.kind === "http" && o.words !== undefined ? [[o.key, o.words] as const] : []; - }), + const declared = declaredObservations(d.observe ?? []); + // The observations that moved, compared as the record's diff compares them. + const movedNow = new Map( + previous.status === "found" + ? diffObserved(previous.observed, r.observed).map((c) => [c.name, c]) + : [], ); + const laneNames = new Map(d.lanes.map((lane) => [lane.id, lane.name])); const stages = Object.entries(d.stages) .map(([id, stage]) => ({ id, meaning: stage.meaning })) .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); @@ -530,7 +732,14 @@ function bodyOf(r: PageRecord, previous: Previous): BoardPageBody { { key: "turns", value: r.prose.operator_turns.length, reported: true }, ], observed: Object.entries(r.observed).map(([name, o]) => { - const words = wordsOf(declaredWords.get(name), o.value); + const declaredNow = declared.get(name); + const words = wordsOf(httpWords(declaredNow), o.value); + const http = httpOf(declaredNow, o.value); + const looksAt = LOOKS_AT.flatMap((key) => { + const value = declaredNow?.[key]; + return value === undefined ? [] : [value]; + }); + const change = movedNow.get(name); return { name, value: o.value, @@ -539,8 +748,16 @@ function bodyOf(r: PageRecord, previous: Previous): BoardPageBody { ...(o.error === undefined ? {} : { error: o.error }), ...(o.value === null ? { previous: previousValue(previous, name) } : {}), ...(words === undefined ? {} : { words }), + ...(http === undefined ? {} : { http }), + ...(declaredNow === undefined ? {} : { kind: declaredNow.kind }), + ...(looksAt.length === 0 ? {} : { looks_at: looksAt }), + ...(declaredNow?.notes === undefined ? {} : { notes: [...declaredNow.notes] }), + ...(o.value === null || change === undefined || previous.status !== "found" + ? {} + : { moved: movedFrom(change.before, previous.observe.get(name)) }), }; }), + previous_record: previous.status, }, effort: effortOf(m.effort, d.effort.milestones ?? []), matrix: { @@ -611,6 +828,23 @@ function bodyOf(r: PageRecord, previous: Previous): BoardPageBody { }, }; }), + measures: d.measures.map((measure) => { + const value = m.measures[measure.id]?.value ?? null; + const before = previous.status === "found" ? previous.measures.get(measure.id) : undefined; + const lanes = + measure.lane === undefined + ? [] + : typeof measure.lane === "string" + ? [measure.lane] + : measure.lane; + return { + id: measure.id, + value, + unit: measure.unit, + lanes: lanes.map((id) => laneNames.get(id) ?? id), + delta: typeof value === "number" && typeof before === "number" ? value - before : null, + }; + }), composition: d.ratios.map((ratio) => ({ id: ratio.id, label: ratio.label, @@ -618,6 +852,8 @@ function bodyOf(r: PageRecord, previous: Previous): BoardPageBody { numerator: measureOf(ratio.numerator), denominator: measureOf(ratio.denominator), })), + repos: reposOf(r.record_version, m.repos), + components: componentsOf(m.components, d.components, d.lanes, m.not_found), turns: r.prose.operator_turns.map(({ text: t, source }) => ({ text: t, source })), footnotes: { notes: [...r.prose.footnotes], @@ -661,6 +897,179 @@ function wordsOf( return words.map((word, i) => ({ word, found: found[i] })); } +// The words of an http observation a declaration holds. +function httpWords(o: DeclaredObservation | undefined): string[] | undefined { + return o?.kind === "http" ? o.words : undefined; +} + +// An http value read as its parts, when it is in the shape the declared +// observation gives it (the shape record holds a value to): a value of +// another shape, as a record written before that check may hold, is drawn +// as it is. +function httpOf(o: DeclaredObservation | undefined, value: unknown): BoardPageHttp | undefined { + if (o?.kind !== "http" || value === null || value === undefined) return undefined; + if (observedValueProblems(o as unknown as BoardObserve, value).length > 0) return undefined; + const v = value as { + status: number; + redirects: number; + final_url: string; + words?: boolean[]; + count?: number; + capture?: string | null; + }; + const reads = + o.words !== undefined || o.count_pattern !== undefined || o.capture_pattern !== undefined; + const read = v.status >= 200 && v.status <= 299; + const body = read + ? { + ...(o.words === undefined + ? {} + : { words: o.words.map((word, i) => ({ word, found: v.words?.[i] === true })) }), + ...(o.count_pattern === undefined ? {} : { count: v.count as number }), + ...(o.capture_pattern === undefined ? {} : { capture: v.capture ?? null }), + } + : null; + return { + status: v.status, + redirects: v.redirects, + final_url: v.final_url, + ...(reads ? { body } : {}), + }; +} + +// What the previous record held of an observation that moved, drawn by that +// record's declaration. +function movedFrom( + before: { value: unknown; error?: string } | null, + declaredThen: DeclaredObservation | undefined, +): BoardPageMovedFrom { + if (before === null) return { status: "none" }; + const words = wordsOf(httpWords(declaredThen), before.value); + const http = httpOf(declaredThen, before.value); + return { + status: "value", + value: before.value, + ...(before.error === undefined ? {} : { error: before.error }), + ...(words === undefined ? {} : { words }), + ...(http === undefined ? {} : { http }), + }; +} + +// The repos of a record, read by its version: one of version 1 or 2 held the +// commits behind as behind_main, and measured no base, ahead or fetch time. +function reposOf(version: number, repos: unknown): BoardPageBody["repos"] { + if (!Array.isArray(repos)) return null; + const list: BoardPageRepo[] = []; + for (const raw of repos) { + if (version >= 3) { + const r = repoSchema.safeParse(raw).data; + if (r === undefined) return null; + list.push({ + path: r.path, + name: r.name, + head: r.head, + branch: r.branch, + last_commit: r.last_commit, + commits: r.commits, + uncommitted: r.uncommitted, + base: r.base, + behind: r.behind, + ahead: r.ahead, + fetched_at: r.fetched_at, + }); + } else { + const r = olderRepoSchema.safeParse(raw).data; + if (r === undefined) return null; + list.push({ + path: r.path, + name: r.name, + head: r.head, + branch: r.branch, + last_commit: r.last_commit, + commits: r.commits, + uncommitted: r.uncommitted, + base: null, + behind: r.behind_main, + ahead: null, + fetched_at: null, + }); + } + } + return { record_version: version, base_measured: version >= 3, list }; +} + +const isTexts = (value: unknown): value is string[] => + Array.isArray(value) && value.every((v) => typeof v === "string"); + +// The components of a record, held against the registry its declaration +// holds, read by hand so that a key of its own (such as __proto__) stays one. +function componentsOf( + measured: unknown, + registry: unknown, + lanes: readonly { id: string; name: string }[], + notFound: readonly { at: string; reason: string }[], +): BoardPageBody["components"] { + if (!isRecord(measured)) return null; + const { found, unacknowledged, gone, kind_changed: kindChanged } = measured; + const reasons = notFound + .filter( + (n) => + n.at === "components" || n.at.startsWith("components.") || n.at.startsWith("components["), + ) + .map((n) => n.reason); + if (found === null) { + return { measured: false, reasons, lanes: [], uncounted: [], unacknowledged: [], gone: null }; + } + const kindsOf = new Map(); + if (!isRecord(found)) return null; + for (const [key, c] of Object.entries(found)) { + if (!isRecord(c) || !isTexts(c.kinds)) return null; + kindsOf.set(key, c.kinds); + } + const changed = new Map(); + if (Array.isArray(kindChanged)) { + for (const c of kindChanged) { + if (isRecord(c) && typeof c.key === "string" && isTexts(c.before) && isTexts(c.after)) { + changed.set(c.key, { before: c.before, after: c.after }); + } + } + } + const row = (key: string, note?: string): BoardPageComponent => { + const moved = changed.get(key); + return { + key, + kinds: kindsOf.get(key) ?? null, + ...(note === undefined ? {} : { note }), + ...(moved === undefined ? {} : { kind_changed: moved }), + }; + }; + const registered = isRecord(registry) ? registry : {}; + const keys = Object.keys(registered).sort(byCodePoint); + const inLane = (id: string) => + keys.filter((key) => { + const lane = (registered[key] as { lane?: unknown } | undefined)?.lane; + return Array.isArray(lane) && lane.includes(id); + }); + const uncounted = keys.flatMap((key) => { + const c = registered[key]; + if (!isRecord(c) || c.lane !== "-") return []; + return [row(key, typeof c.note === "string" ? c.note : undefined)]; + }); + const listed = (value: unknown) => (isTexts(value) ? [...value].sort(byCodePoint) : null); + return { + measured: true, + reasons, + lanes: lanes.map((lane) => ({ + id: lane.id, + name: lane.name, + components: inLane(lane.id).map((key) => row(key)), + })), + uncounted, + unacknowledged: (listed(unacknowledged) ?? []).map((key) => row(key)), + gone: listed(gone), + }; +} + // The previous record's value of an observation this record could not make. function previousValue(previous: Previous, name: string): BoardPagePrevious { if (previous.status === "unreadable") return { status: "unreadable" }; diff --git a/packages/core/src/lib/view-strings.ts b/packages/core/src/lib/view-strings.ts index a1937ee..d66d117 100644 --- a/packages/core/src/lib/view-strings.ts +++ b/packages/core/src/lib/view-strings.ts @@ -904,6 +904,7 @@ export type BoardPageStrings = { effort: string; matrix: string; lanes: string; + measures: string; composition: string; turns: string; footnotes: string; @@ -929,6 +930,44 @@ export type BoardPageStrings = { missing: string; missingNoPrevious: string; missingPreviousUnreadable: string; + /** The parts of an http value: `{n}` redirects, the count, the capture as JSON. */ + redirects: string; + count: string; + capture: string; + captureNone: string; + /** In place of what an http value reads off its body, when the status is not 2xx. */ + bodyNotRead: string; + /** Marks an observation whose value or error moved since the previous record. */ + changed: string; + /** What the previous record held of an observation that moved. */ + before: string; + beforeNotObserved: string; + beforeNone: string; + /** Under the table, when the previous record could not be read. */ + previousUnreadable: string; + }; + /** The table of every measure the record holds. */ + measures: { + id: string; + value: string; + lanes: string; + delta: string; + none: string; + }; + /** What a record's page draws of the repositories and the components it measured. */ + record: { + reposUnread: string; + notMeasuredByVersion: string; + componentsUnread: string; + componentsNotMeasured: string; + noComponentInLane: string; + uncounted: string; + unacknowledged: string; + gone: string; + goneUnknown: string; + none: string; + notFound: string; + kindChanged: string; }; effort: { elapsed: string; @@ -1075,6 +1114,7 @@ const BOARD_PAGE_EN: BoardPageStrings = { effort: "Period and effort", matrix: "Reach matrix", lanes: "Where each lane is", + measures: "Measures", composition: "Composition", turns: "The operator's turns", footnotes: "Footnotes", @@ -1100,6 +1140,39 @@ const BOARD_PAGE_EN: BoardPageStrings = { missing: "not observed (previous: {value})", missingNoPrevious: "not observed (no previous value)", missingPreviousUnreadable: "not observed (the previous record could not be read)", + redirects: "redirects {n}", + count: "count {n}", + capture: "capture {value}", + captureNone: "no capture", + bodyNotRead: "body not read", + changed: "changed", + before: "previous: {value}", + beforeNotObserved: "previous: not observed", + beforeNone: "not in the previous record", + previousUnreadable: + "The previous record could not be read, so no observation is marked as changed.", + }, + measures: { + id: "Measure", + value: "Value", + lanes: "Drawn in", + delta: "Since the previous record", + none: "No measures are declared.", + }, + record: { + reposUnread: "The record holds no repositories in a shape the page reads.", + notMeasuredByVersion: + "A record of version {version} did not measure the base branch, the commits ahead or the last fetch: they are drawn as -, and Behind is its behind_main.", + componentsUnread: "The record holds no components in a shape the page reads.", + componentsNotMeasured: "The components were not measured:", + noComponentInLane: "No component is registered in this lane.", + uncounted: "Counted in no lane", + unacknowledged: "Found, not registered", + gone: "Registered or found before, not found now", + goneUnknown: "not known (the previous record could not be read)", + none: "none", + notFound: "not found", + kindChanged: "was {before} in the previous record", }, effort: { elapsed: "Elapsed", @@ -1250,6 +1323,7 @@ const BOARD_PAGE_JA: BoardPageStrings = { effort: "期間と労力", matrix: "到達マトリクス", lanes: "レーンごとの現在地", + measures: "測った値の一覧", composition: "構成比", turns: "operator の手番", footnotes: "脚注", @@ -1275,6 +1349,38 @@ const BOARD_PAGE_JA: BoardPageStrings = { missing: "未確認(前回値: {value})", missingNoPrevious: "未確認(前回値なし)", missingPreviousUnreadable: "未確認(前回の記録が読めない)", + redirects: "リダイレクト {n}", + count: "件数 {n}", + capture: "capture {value}", + captureNone: "capture なし", + bodyNotRead: "本文は読まない", + changed: "変化", + before: "前回: {value}", + beforeNotObserved: "前回: 未確認", + beforeNone: "前回の記録には無い", + previousUnreadable: "前の記録を読めないので、変化した観測に印を付けていない。", + }, + measures: { + id: "measure", + value: "値", + lanes: "描くレーン", + delta: "前回からの増減", + none: "measure の宣言はない。", + }, + record: { + reposUnread: "この記録の repo は、ページが読める形をしていない。", + notMeasuredByVersion: + "版 {version} の記録は、比べる branch・進み・最後の fetch を測っていない(- で描く)。遅れはこの記録の behind_main。", + componentsUnread: "この記録の構成要素は、ページが読める形をしていない。", + componentsNotMeasured: "構成要素は測れなかった:", + noComponentInLane: "このレーンに登録した構成要素はない。", + uncounted: "どのレーンにも数えない", + unacknowledged: "見つかったが登録していない", + gone: "登録していた・前回見つかったが、今回見つからない", + goneUnknown: "分からない(前回の記録が読めない)", + none: "なし", + notFound: "見つからない", + kindChanged: "前回は {before}", }, effort: { elapsed: "経過", @@ -1457,7 +1563,7 @@ const BOARD_INIT_EN: BoardInitStrings = { "The six stages are fixed; only what each means is the board's. Keep 04 (merged) apart from 05 (in users' hands). `look` and `notes` say what to look at and mind when judging a stage.", lanes: "At least one lane. Its id is lowercase ASCII and stays: records join on it.", measures: - "What to measure in the repos (file_count, dir_count, line_count, match_count, regex_capture, json_length, file_name, file_paths, trail_count), and their sums and differences (total). One with a lane, or a list of lanes, is drawn in each one's section 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).", + "What to measure in the repos (file_count, dir_count, line_count, match_count, regex_capture, json_length, file_name, file_paths, trail_count), and their sums and differences (total). Each is drawn in the board page's table of measures, and one with a lane, or a list of lanes, in each one's section of the page too.", observe: "What to observe outside basou (npm_version, github_release, github_open_issues, github_open_prs, github_ci, github_check, page_version, http, manual), each with notes on what to mind when making it if need be. basou never makes these; the guide says how.", components: @@ -1494,7 +1600,7 @@ const BOARD_INIT_JA: BoardInitStrings = { "6 段の id は固定で、盤が決めるのは意味だけ。04 本番(main に入った)と 05 開通(利用者が手に取れる)を分ける。look と notes は、その段を判定するときに見るものと注意。", lanes: "レーンは 1 本以上。id は小文字の ASCII で、記録の照合に使うので変えない。", measures: - "repo の中で測るもの(file_count・dir_count・line_count・match_count・regex_capture・json_length・file_name・file_paths・trail_count)と、それらの和と差(total)。lane(1 つのレーンか、レーンの並び)を付けたものは盤のページのそれぞれのレーンの節に描かれ、付けないものは記録と差分(と、それを使う ratio や total)にだけ残る。", + "repo の中で測るもの(file_count・dir_count・line_count・match_count・regex_capture・json_length・file_name・file_paths・trail_count)と、それらの和と差(total)。どれも盤のページの measure の一覧に描かれ、lane(1 つのレーンか、レーンの並び)を付けたものはそれぞれのレーンの節にも描かれる。", observe: "basou の外で観測するもの(npm_version・github_release・github_open_issues・github_open_prs・github_ci・github_check・page_version・http・manual)。取るときの注意があれば notes に書く。basou は取りに行かない。取り方は guide が出す。", components: From 77621a141208fd08816a268f22962b13f8141947 Mon Sep 17 00:00:00 2001 From: Takashi Matsuyama Date: Tue, 20 Oct 2026 10:58:07 +0900 Subject: [PATCH 3/7] docs: Note the board page's new sections and the declaration in the text diff in the changelog --- CHANGELOG.md | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index aa1d553..61a7be8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,45 @@ All notable changes to **basou** are recorded here. The project follows ## Unreleased +### Added + +- **`basou board measure` and `basou board record` say where the declaration + changed since the previous record**, in the text they print without + `--json`: under `Since the previous record :`, one line for each + place where board.yaml differs from the declaration that record holds, + named as a diff names a value (`declaration stages.03.look: changed`, + `declaration observe["site.top"]: added`, `declaration lanes: the order + changed`). A changed `look` or `notes` moved the digest, and nothing said + where. Both declarations are compared as a record holds them, so the first + record after a board moves to version 3 lists nothing for that alone, and + what the diff of the measurement shows already (the title, a measure's + unit and lanes, a ratio's numerator and denominator, the effort's start, a + time zone declared in both, a measure or a ratio added or removed) is not + listed again. The JSON of both commands is unchanged. The guide asks for + an axis review's summary from these lines. +- **The board page of `basou view` reads each observation outside as its + kind does, and marks what moved.** An `http` value in the shape its + declaration gives it is drawn as its parts: the status, the redirects, + where it landed, each word found or not, the count and the capture, or + that the body was not read when the status is not 2xx. A value of another + shape, as a record written before 0.72.0 may hold, is drawn as before. + Under each name are its kind and what it looks at, and under each row its + notes, from the declaration the record holds. An observation whose value + or error moved since the previous record (as `basou board record` compares + them) is marked, with the previous record's value drawn by that record's + declaration; one whose type alone moved (`"12"` and `12`) is drawn as JSON. +- **The board page draws every measure in a table of its own**, with its + unit (none for a string, and the paths of a `file_paths` by how many), the + lanes it is drawn in, and, for a number, how much it moved since the + previous record. A measure with no lane was drawn nowhere on the page. +- **A record's board page draws the repos and the components it measured**, + as the page measured on the spot draws them: the repos by the record's + version (one of version 1 or 2 measured no base, commits ahead or fetch + time, and its `behind_main` is drawn as behind), and the components by + the lanes the registry counts them in, then those counted in no lane with + their notes, those found and not registered, and those gone, with a + change of kinds marked. + ### Changed - **`basou board record` (and `--dry-run`) refuses an observation's value From e0b91c5c82929a521f08f2e9d78aaa2ae0cda514 Mon Sep 17 00:00:00 2001 From: Takashi Matsuyama Date: Tue, 20 Oct 2026 11:49:09 +0900 Subject: [PATCH 4/7] basou: Compare a board's declaration without ever failing a record, before it is written and only for the text - diffDeclarations never throws: a declaration nested deeper than the comparison goes (only a record written by hand holds one) gives null, and the text says "declaration: not compared". A record was written, then failed with "Nothing was written." and exit 1 when the previous record's declaration was nested some thousands deep, with --json too; board measure failed the same way. - board record compares the declarations before it writes the record, and not at all with --json, which holds none of it. - A measure's lane is left out of the comparison whole, so it is no longer read as a list first; the reference says so. - Tests: the lanes' order is said only when the lanes both hold moved (not when a lane is added or removed), and never for measures, ratios or observations; record's text with only the declaration moved says it, with "the order changed", and not "nothing moved". --- docs/spec/board.md | 14 ++-- packages/cli/src/commands/board.test.ts | 95 +++++++++++++++++++------ packages/cli/src/commands/board.ts | 14 ++-- packages/core/src/board/diff.test.ts | 60 ++++++++++++++++ packages/core/src/board/diff.ts | 40 +++++------ 5 files changed, 169 insertions(+), 54 deletions(-) diff --git a/docs/spec/board.md b/docs/spec/board.md index 976b5d4..2616909 100644 --- a/docs/spec/board.md +++ b/docs/spec/board.md @@ -1220,10 +1220,10 @@ differs from the one the previous record holds, one line each: `lanes["web"].notes`, `observe["site.top"]` (an observation added or removed), `measures["readme.latest"].include`, `components["app/db"].lane`. - Both declarations are compared as a record holds them - ([4.2](#42-declaration)): with their defaults filled in, a record of - version 1 with `observe` as `[]`, and a measure's lane held as one id the - list of that one, so the first record after a board moves to version 3 - lists nothing for that alone. + ([4.2](#42-declaration)): with their defaults filled in, and a record of + version 1 with `observe` as `[]`. A measure's lane is not listed (see + below), so the first record after a board moves to version 3 lists nothing + for its lanes held as one id. - The lanes, the measures and the ratios are compared by id, and the observations by key, each key of an entry on its own; the lanes' order is one line of its own. Any other list (`notes`, `look`, `words`, `include`, @@ -1233,8 +1233,10 @@ differs from the one the previous record holds, one line each: `denominator`, `effort.start`, `effort.time_zone` when both declare one, and a measure or a ratio added or removed (its values are added or removed). -- When the previous record holds no declaration that is an object, the line - says it is not compared. +- When the previous record holds no declaration that is an object, or one + nested too deep to compare (only a record written by hand can), one line + says it is not compared: `declaration: not compared (...)`. The record is + still written. ### 5.13 The digest diff --git a/packages/cli/src/commands/board.test.ts b/packages/cli/src/commands/board.test.ts index 29e603a..23e2709 100644 --- a/packages/cli/src/commands/board.test.ts +++ b/packages/cli/src/commands/board.test.ts @@ -1824,41 +1824,49 @@ describe("basou board measure and record against the previous record", () => { // With nothing else moved, the declaration alone is said, not "nothing // moved". The records are ignored, so a record written is no file more. await writeFile(join(repo, ".gitignore"), "board/records/\n.gitignore\n"); - const before = await doRunBoardMeasure({ json: true }, ctx(repo)); - await doRunBoardRecord( - {}, - { - ...ctx(repo), - readInput: async () => - JSON.stringify( - judgement(before.digest, ["done", "part"], { - npm: { value: "1.0.0", observed_at: "2026-10-05", source: "registry" }, - }), - ), - }, - ); - await placeBoard( - repo, + const board = (look: string[], lanes: string[]) => JSON.stringify({ board_version: 3, title: "Board v3", - stages: { ...STAGES, "03": { meaning: "stage 03", look: ["the tests", "the docs"] } }, - lanes: [{ id: "core", name: "Core", notes: ["mind it"] }], + stages: { ...STAGES, "03": { meaning: "stage 03", look } }, + lanes: lanes.map((id) => ({ id, name: id })), measures: [{ ...MD, unit: "docs", lane: ["core"] }], axis: { version: 2, review_due_days: 60 }, effort: { start: "2026-04-28" }, observe: [{ key: "npm", kind: "npm_version", package: "basou" }], - }), - ); + }); + const both = (digest: string) => + JSON.stringify({ + ...judgement(digest, [], { + npm: { value: "1.0.0", observed_at: "2026-10-05", source: "registry" }, + }), + cells: ["core", "docs"].flatMap((lane) => + ["01", "02", "03", "04", "05", "06"].map((stage) => ({ lane, stage, state: "none" })), + ), + }); + await placeBoard(repo, board(["the tests"], ["core", "docs"])); + const before = await doRunBoardMeasure({ json: true }, ctx(repo)); + await doRunBoardRecord({}, { ...ctx(repo), readInput: async () => both(before.digest) }); + await placeBoard(repo, board(["the tests", "the docs"], ["docs", "core"])); const after = await doRunBoardMeasure({ json: true }, ctx(repo)); expect(after.diff).toMatchObject({ values: [], added: [], removed: [], methods: [] }); + const alone = " declaration stages.03.look: changed\n declaration lanes: the order changed"; out.length = 0; await doRunBoardMeasure({}, ctx(repo)); const only = out.join("\n"); - expect(only).toMatch( - /Since the previous record [0-9A-Z]{26}:\n {2}declaration stages\.03\.look: changed\n\nRepos:/, - ); + expect(only).toMatch(/Since the previous record [0-9A-Z]{26}:\n/); + expect(only).toContain(`:\n${alone}\n\nRepos:`); expect(only).not.toContain("nothing moved"); + // The record says the same, the cells and the observations unmoved. + out.length = 0; + await doRunBoardRecord( + { dryRun: true }, + { ...ctx(repo), readInput: async () => both(after.digest) }, + ); + const recordText = out.join("\n"); + expect(recordText).toMatch(/\nSince the previous record [0-9A-Z]{26}:\n/); + expect(recordText.endsWith(`:\n${alone}`)).toBe(true); + expect(recordText).not.toContain("nothing moved"); }); it("shows what a previous record holds with its control characters escaped", async () => { @@ -1895,8 +1903,49 @@ describe("basou board measure and record against the previous record", () => { out.length = 0; await doRunBoardMeasure({}, ctx(repo)); expect(out.join("\n")).toContain( - "\n declaration: not compared (the previous record holds no declaration that can be read)\n", + "\n declaration: not compared (the previous record holds no declaration basou can compare with)\n", + ); + }); + + it("records, saying the declaration was not compared, when the previous record's is too deep to compare", async () => { + const { repo, first } = await recorded(); + const records = join(repo, "board", "records"); + const file = join(records, `${first}.json`); + const written = JSON.parse(await readFile(file, "utf8")); + written.declaration.lanes[0].notes = "DEEP"; + const depth = 100_000; + const deep = `${"[".repeat(depth)}"x"${"]".repeat(depth)}`; + await writeFile(file, JSON.stringify(written).replace('"DEEP"', deep)); + // The board declares the lane's notes, so the two are compared. + const declared = JSON.parse(boardYaml([MD])); + declared.lanes[0].notes = ["mind it"]; + await placeBoard(repo, JSON.stringify(declared)); + const notCompared = + " declaration: not compared (the previous record holds no declaration basou can compare with)"; + const { out } = capture(); + const m = await doRunBoardMeasure({ json: true }, ctx(repo)); + out.length = 0; + await doRunBoardMeasure({}, ctx(repo)); + expect(out.join("\n")).toContain(`${notCompared}\n`); + const input = JSON.stringify(judgement(m.digest, ["done", "part"], {})); + // The record is written and said to be, with its text. + out.length = 0; + const result = await doRunBoardRecord({}, { ...ctx(repo), readInput: async () => input }); + expect(result.record).not.toBeNull(); + expect(out.join("\n")).toContain(notCompared); + expect(out.join("\n")).not.toContain("Nothing was written"); + expect(await readdir(records)).toHaveLength(2); + expect(process.exitCode ?? 0).toBe(0); + // And with --json, which holds none of it. + await rm(join(records, `${result.record?.split("/").at(-1)}`)); + out.length = 0; + const json = await doRunBoardRecord( + { json: true }, + { ...ctx(repo), readInput: async () => input }, ); + expect(json.record).not.toBeNull(); + expect(JSON.parse(out.join("\n")).record).toBe(json.record); + expect(await readdir(records)).toHaveLength(2); }); it("takes a record it cannot read as a gap, not as no record", async () => { diff --git a/packages/cli/src/commands/board.ts b/packages/cli/src/commands/board.ts index 485b506..e079c7c 100644 --- a/packages/cli/src/commands/board.ts +++ b/packages/cli/src/commands/board.ts @@ -528,14 +528,14 @@ export async function doRunBoardRecord( } const { result, declared } = checked; if (options.json === true) console.log(JSON.stringify(result, null, 2)); - else printRecordText(result, declared); + else printRecordText(result, declared ?? null); return result; } async function checkAndRecord( options: BoardRecordOptions, ctx: BoardContext, -): Promise<{ result: BoardRecordResult; declared: BoardDeclarationChange[] | null }> { +): Promise<{ result: BoardRecordResult; declared?: BoardDeclarationChange[] | null }> { const cwd = ctx.cwd ?? process.cwd(); const loaded = await loadBoard(options, ctx, "board record"); const beside = (...parts: string[]) => @@ -582,6 +582,9 @@ async function checkAndRecord( recordedAt, recordedWith: { basou: BASOU_CLI_VERSION, build: BASOU_BUILD?.commit ?? null }, }); + // For the text only, and before the record is written: nothing after it + // may fail a record that is there. + const declared = options.json === true ? undefined : declarationChanges(loaded, previous); let written: string | null = null; if (options.dryRun !== true) { let name: string; @@ -616,13 +619,14 @@ async function checkAndRecord( order_anomalies: record.order_anomalies, diff, }, - declared: declarationChanges(loaded, previous), + ...(declared === undefined ? {} : { declared }), }; } // Where the declaration changed since the previous record, for the text a // command prints (its JSON holds none of it): null when there is no previous -// record to compare with, or it holds no declaration as an object. +// record to compare with, or it holds no declaration that can be compared (not +// an object, or nested too deep). It never throws. function declarationChanges( loaded: LoadedBoard, previous: BoardPreviousRecords, @@ -1122,7 +1126,7 @@ function diffLines(diff: Omit, more: boolean): string[] { function declarationLines(changes: BoardDeclarationChange[] | null): string[] { if (changes === null) { return [ - " declaration: not compared (the previous record holds no declaration that can be read)", + " declaration: not compared (the previous record holds no declaration basou can compare with)", ]; } return changes.map( diff --git a/packages/core/src/board/diff.test.ts b/packages/core/src/board/diff.test.ts index 53194c8..fdeb677 100644 --- a/packages/core/src/board/diff.test.ts +++ b/packages/core/src/board/diff.test.ts @@ -528,6 +528,66 @@ describe("diffDeclarations", () => { ]); }); + it("says the lanes' order changed only when the lanes both hold are in another order", () => { + const lane = (id: string) => ({ id, name: id.toUpperCase() }); + const lanes = (...ids: string[]) => declaration({ lanes: ids.map(lane) }); + // A lane added or removed leaves the others in their order. + expect(diffDeclarations(lanes("web", "cli"), lanes("web", "cli", "api"))).toEqual([ + { at: 'lanes["api"]', change: "added" }, + ]); + expect(diffDeclarations(lanes("web", "api", "cli"), lanes("web", "cli"))).toEqual([ + { at: 'lanes["api"]', change: "removed" }, + ]); + expect(diffDeclarations(lanes("web", "cli"), lanes("api", "web", "cli"))).toEqual([ + { at: 'lanes["api"]', change: "added" }, + ]); + expect(diffDeclarations(lanes("web", "cli", "api"), lanes("cli", "web"))).toEqual([ + { at: 'lanes["api"]', change: "removed" }, + { at: "lanes", change: "reordered" }, + ]); + // The order of the measures, the ratios and the observations is no change. + const before = declaration({ + measures: [ + { id: "a", kind: "file_count", unit: "files", repo: ".", at: "worktree", include: ["*"] }, + { id: "b", kind: "file_count", unit: "files", repo: ".", at: "worktree", include: ["*"] }, + ], + ratios: [ + { id: "r", label: "R", numerator: "a", denominator: "b" }, + { id: "s", label: "S", numerator: "b", denominator: "a" }, + ], + observe: [ + { key: "one", kind: "npm_version", package: "one" }, + { key: "two", kind: "npm_version", package: "two" }, + ], + }); + const reversed = Object.fromEntries( + Object.entries(before).map(([key, value]) => [ + key, + key === "lanes" || !Array.isArray(value) ? value : [...value].reverse(), + ]), + ); + expect(diffDeclarations(before, reversed)).toEqual([]); + }); + + it("says nothing it cannot compare rather than throw, for a declaration nested too deep", () => { + const depth = 100_000; + const deep = JSON.parse(`${"[".repeat(depth)}"x"${"]".repeat(depth)}`); + expect(() => JSON.stringify(deep)).toThrow(RangeError); + const notes = (n: unknown) => declaration({ lanes: [{ id: "web", name: "Web", notes: n }] }); + // Compared whole, with what the board declares now. + expect(diffDeclarations(notes(deep), notes(["x"]))).toBeNull(); + // Only in the previous record: not walked into, so it is said. + expect(diffDeclarations(declaration({ deeper: deep }), declaration())).toEqual([ + { at: "deeper", change: "removed" }, + ]); + // Deep as an object on both sides, walked key by key. + let nested: Record = {}; + for (let i = 0; i < depth; i++) nested = { a: nested }; + expect( + diffDeclarations(declaration({ axis: nested }), declaration({ axis: nested })), + ).toBeNull(); + }); + it("compares a list it cannot tell the entries of whole, and has nothing to compare with no declaration", () => { const twice = declaration({ lanes: [ diff --git a/packages/core/src/board/diff.ts b/packages/core/src/board/diff.ts index 7961569..590bb70 100644 --- a/packages/core/src/board/diff.ts +++ b/packages/core/src/board/diff.ts @@ -267,21 +267,21 @@ function byName(list: unknown, field: string): Map | undefined * Where the declaration a board is measured by now differs from the one the * previous record holds, in the order the declaration holds its keys, then * those only the previous one had. Both are compared as a record holds them: - * their defaults filled in, a record of version 1 with `observe` as `[]`, and - * a measure's lane held as one id the list of that one. A list whose entries - * name themselves is compared entry by entry (lanes, measures and ratios by - * id, observations by key, and the lanes' order on its own), and any other - * list whole. Left out is what the measurement's diff shows already: the - * title, a measure's unit and lanes, a ratio's numerator and denominator, the - * effort's start, a time zone declared in both, and a measure or a ratio - * added or removed. Null when the previous record holds no declaration as an - * object. + * their defaults filled in, and a record of version 1 with `observe` as `[]`. + * A list whose entries name themselves is compared entry by entry (lanes, + * measures and ratios by id, observations by key, and the lanes' order on its + * own), and any other list whole. Left out is what the measurement's diff + * shows already: the title, a measure's unit and lanes (there a lane of one + * id, as a record of version 1 or 2 holds it, is the list of that one, so + * moving a board to version 3 shows nothing for that alone), a ratio's + * numerator and denominator, the effort's start, a time zone declared in + * both, and a measure or a ratio added or removed. Null when the previous + * record holds no declaration as an object, or one too deep to compare: it + * never throws, so a caller that has written a record goes on. */ export function diffDeclarations(before: unknown, after: unknown): BoardDeclarationChange[] | null { if (!isRecord(before) || !isRecord(after)) return null; const was = Object.hasOwn(before, "observe") ? before : { ...before, observe: [] }; - // As a record would hold it: a key whose value is undefined is not there. - const is: unknown = JSON.parse(JSON.stringify(after)); const out: BoardDeclarationChange[] = []; const walk = (then: unknown, now: unknown, rule: string, shown: string): void => { if (DECLARED_IN_VALUES.has(rule)) return; @@ -296,17 +296,10 @@ export function diffDeclarations(before: unknown, after: unknown): BoardDeclarat const [old, current] = field === undefined ? [undefined, undefined] : [byName(then, field), byName(now, field)]; if (old !== undefined && current !== undefined) { - const entry = (list: Map, name: string) => - rule === "measures" ? withLaneList(list.get(name)) : list.get(name); const names = [...current.keys(), ...[...old.keys()].filter((n) => !current.has(n))]; for (const name of names) { if (ENTRIES_IN_VALUES.has(rule) && (!old.has(name) || !current.has(name))) continue; - walk( - entry(old, name), - entry(current, name), - `${rule}[]`, - `${shown}[${JSON.stringify(name)}]`, - ); + walk(old.get(name), current.get(name), `${rule}[]`, `${shown}[${JSON.stringify(name)}]`); } if (rule === "lanes") { const kept = (from: Map, other: Map) => @@ -334,7 +327,14 @@ export function diffDeclarations(before: unknown, after: unknown): BoardDeclarat } if (!same(then, now)) out.push({ at: shown, change: "changed" }); }; - walk(was, is, "", ""); + try { + // As a record would hold it: a key whose value is undefined is not there. + walk(was, JSON.parse(JSON.stringify(after)), "", ""); + } catch { + // A declaration nested deeper than the stack goes, which only a record + // written by hand holds. + return null; + } return out; } From 18ddc051e9a7f213abd6ce6eb8604afb6187de17 Mon Sep 17 00:00:00 2001 From: Takashi Matsuyama Date: Tue, 20 Oct 2026 11:49:09 +0900 Subject: [PATCH 5/7] basou: Draw a board page with no marks when the previous record's observations are too deep to compare - The page compared each observation with the previous record's, and a value nested some thousands deep there (only a record written by hand holds one) threw, so a page drawn before 0.72.0 was not drawn at all. Now it is drawn with no observation marked as changed, and the rest of what the previous record gives (the cells that moved) as before. - Tests: a status of 299 is 2xx, so its body is read; a record of version 1 or 2 whose repos are not in its shape draws them as none rather than leaving one out. --- packages/core/src/board/page.test.ts | 53 ++++++++++++++++++++++++++-- packages/core/src/board/page.ts | 19 +++++++--- 2 files changed, 65 insertions(+), 7 deletions(-) diff --git a/packages/core/src/board/page.test.ts b/packages/core/src/board/page.test.ts index e1e9ec3..db44e55 100644 --- a/packages/core/src/board/page.test.ts +++ b/packages/core/src/board/page.test.ts @@ -361,6 +361,8 @@ describe("boardPage", () => { final_url: "https://a.example/", capture: null, }), + // The last status of 2xx: its body was read. + last: made({ status: 299, redirects: 0, final_url: "https://a.example/", words: [false] }), // As a record written before 0.72.0 may hold it. older: made({ status: 200, words: [true] }), asked: made({ status: 200, redirects: 0, final_url: "https://a.example/" }), @@ -383,6 +385,7 @@ describe("boardPage", () => { http("closed", { words: ["a"], count_pattern: "b", capture_pattern: "(c)" }), http("plain"), http("nomatch", { capture_pattern: "(c)" }), + http("last", { words: ["a"] }), http("older", { words: ["a"] }), { key: "asked", kind: "manual", how: "ask" }, ], @@ -418,6 +421,7 @@ describe("boardPage", () => { final_url: "https://example.com/y", }); expect(byName.get("nomatch")?.http?.body).toEqual({ capture: null }); + expect(byName.get("last")?.http?.body).toEqual({ words: [{ word: "a", found: false }] }); // Out of the shape its declaration gives it, or of a kind with no shape: as it is. expect(byName.get("older")?.http).toBeUndefined(); expect(byName.get("older")?.words).toEqual([{ word: "a", found: true }]); @@ -587,6 +591,36 @@ describe("boardPage", () => { expect(unread.board.summary.observed.some((o) => o.moved !== undefined)).toBe(false); }); + it("draws a record without marks when the previous record's observations are too deep to compare", async () => { + await mkdir(records); + const depth = 100_000; + const deep = `${"[".repeat(depth)}"x"${"]".repeat(depth)}`; + const made = (v: unknown) => ({ value: v, observed_at: "2026-10-07", source: "s" }); + const text = JSON.stringify(record({ observed: { npm: made("DEEP") } })).replace( + '"DEEP"', + deep, + ); + await place(A, text); + await place( + B, + record({ + observed: { npm: made("1.0.0") }, + cells: cells({ + core: ["done", "done", "done", "blocked", "none", "none"], + docs: ["done", "done", "done", "done", "done", "done"], + }), + }), + ); + const page = await boardPage(records); + if (page.status !== "ok") throw new Error(page.why); + expect(page.board.summary.previous_record).toBe("found"); + expect(page.board.summary.observed).toEqual([ + { name: "npm", value: "1.0.0", observed_at: "2026-10-07", source: "s" }, + ]); + // The rest of what the previous record gives is still drawn. + expect(page.board.matrix.lanes[0]?.cells[2]?.moved_from).toBe("part"); + }); + it("lists every measure, with the lanes it is drawn in and how much a number moved", async () => { await mkdir(records); const at = (values: Record) => { @@ -702,13 +736,26 @@ describe("boardPage", () => { }); } // None, or not in the shape of its version: drawn as none, the rest of the record drawn. - for (const repos of [undefined, "x", [{ ...repo, behind_main: 4 }]]) { - const odd = record({ record_version: 3 }); + const v3Repo = { ...repo, base: null, behind: 1, ahead: 0, fetched_at: null }; + for (const [version, repos] of [ + [3, undefined], + [3, "x"], + [3, [{ ...repo, behind_main: 4 }]], + [2, [v3Repo]], + [ + 1, + [ + { ...repo, behind_main: 4 }, + { ...repo, path: "../app" }, + ], + ], + ] as const) { + const odd = record({ record_version: version }); odd.measure = { ...odd.measure, repos } as typeof odd.measure; await place(A, odd); const read = await boardPage(records); if (read.status !== "ok") throw new Error(`${JSON.stringify(repos)}: ${read.why}`); - expect(read.board.repos).toBeNull(); + expect(read.board.repos, `${version} ${JSON.stringify(repos)}`).toBeNull(); } }); diff --git a/packages/core/src/board/page.ts b/packages/core/src/board/page.ts index 333d9c6..6aacb1c 100644 --- a/packages/core/src/board/page.ts +++ b/packages/core/src/board/page.ts @@ -654,6 +654,20 @@ function declaredObservations(observe: unknown): Map>, + after: PageRecord["observed"], +): [string, BoardObservedChange][] { + try { + return diffObserved(before, after).map((c) => [c.name, c]); + } catch { + return []; + } +} + // The value of each measure of a measurement as a record holds it, by id. function measureValues(measures: unknown): Map { const out = new Map(); @@ -672,11 +686,8 @@ function bodyOf(r: PageRecord, previous: Previous): BoardPageBody { const d = r.declaration; const m = r.measure; const declared = declaredObservations(d.observe ?? []); - // The observations that moved, compared as the record's diff compares them. const movedNow = new Map( - previous.status === "found" - ? diffObserved(previous.observed, r.observed).map((c) => [c.name, c]) - : [], + previous.status === "found" ? movedObservations(previous.observed, r.observed) : [], ); const laneNames = new Map(d.lanes.map((lane) => [lane.id, lane.name])); const stages = Object.entries(d.stages) From 28bd9a8e7aae2e50bb2f3e269b81fd0edb135059 Mon Sep 17 00:00:00 2001 From: Takashi Matsuyama Date: Tue, 20 Oct 2026 11:49:09 +0900 Subject: [PATCH 6/7] docs: Name every line of a declaration's change in the guide, and when the board page marks an observation in the changelog --- CHANGELOG.md | 11 +++++++---- packages/core/src/board/guide.test.ts | 2 +- packages/core/src/board/guide.ts | 2 +- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61a7be8..585876b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,10 +28,13 @@ All notable changes to **basou** are recorded here. The project follows that the body was not read when the status is not 2xx. A value of another shape, as a record written before 0.72.0 may hold, is drawn as before. Under each name are its kind and what it looks at, and under each row its - notes, from the declaration the record holds. An observation whose value - or error moved since the previous record (as `basou board record` compares - them) is marked, with the previous record's value drawn by that record's - declaration; one whose type alone moved (`"12"` and `12`) is drawn as JSON. + notes, from the declaration the record holds. An observation made whose + value or error moved since the previous record (as `basou board record` + compares them) is marked, with the previous record's value drawn by that + record's declaration; one whose type alone moved (`"12"` and `12`) is drawn + as JSON. One not made is drawn as before, as not observed with the previous + record's value, unmarked. Nothing is marked when there is no previous + record, or it cannot be read (a line under the table says so) or compared. - **The board page draws every measure in a table of its own**, with its unit (none for a string, and the paths of a `file_paths` by how many), the lanes it is drawn in, and, for a number, how much it moved since the diff --git a/packages/core/src/board/guide.test.ts b/packages/core/src/board/guide.test.ts index 4bc5ed6..bab959f 100644 --- a/packages/core/src/board/guide.test.ts +++ b/packages/core/src/board/guide.test.ts @@ -319,7 +319,7 @@ describe("boardGuide: a board declared", () => { it("says the text of measure lists where board.yaml changed, which the JSON does not hold", () => { expect(boardGuide(guideInput())).toContain( - "The top of measure.txt also lists each place where board.yaml changed since that record (`declaration : added`, `removed` or `changed`), which the JSON does not hold: write an `axis_review`'s summary from these lines, not from memory.", + "The top of measure.txt also lists each place where board.yaml changed since that record (`declaration : added`, `removed` or `changed`, and `declaration lanes: the order changed`), or says `declaration: not compared` when that record holds no declaration basou can compare with, which the JSON does not hold: write an `axis_review`'s summary from these lines, not from memory.", ); }); diff --git a/packages/core/src/board/guide.ts b/packages/core/src/board/guide.ts index d756de2..4df5732 100644 --- a/packages/core/src/board/guide.ts +++ b/packages/core/src/board/guide.ts @@ -187,7 +187,7 @@ export function boardGuide(input: BoardGuideInput): string { "- Exit 0: everything was measured. Exit 1 with JSON on stdout: measured with gaps (`complete: false`, a null for each, its reason under `not_found`). Exit 1 with nothing on stdout: read stderr and stop. The declaration or the manifest cannot be read (fix board.yaml through step 2b), `--model` was empty, or W was not used.", "- An empty file means it did not run, not that it passed: check that measure.json reads as JSON.", '- Never read a null as 0. Write "not confirmed" for it and do not judge by it.', - "- What moved is the point: the top of measure.txt (`Since the previous record :`) and `diff` in the JSON. A value marked `method_changed` may have moved because basou measures it another way now. The top of measure.txt also lists each place where board.yaml changed since that record (`declaration : added`, `removed` or `changed`), which the JSON does not hold: write an `axis_review`'s summary from these lines, not from memory.", + "- What moved is the point: the top of measure.txt (`Since the previous record :`) and `diff` in the JSON. A value marked `method_changed` may have moved because basou measures it another way now. The top of measure.txt also lists each place where board.yaml changed since that record (`declaration : added`, `removed` or `changed`, and `declaration lanes: the order changed`), or says `declaration: not compared` when that record holds no declaration basou can compare with, which the JSON does not hold: write an `axis_review`'s summary from these lines, not from memory.", "- A `total` measure's value is what its `plus` adds up to less what its `minus` adds up to, and may be below 0. It is null when a measure it takes was not measured, and when it is out of the range a number holds exactly (-9007199254740991 to 9007199254740991).", "- A `file_paths` measure's value is the list of paths it found. `Measures:` in measure.txt gives it as a count: read the paths in the JSON. `diff` lists each path that came in (`added`) or went out (`removed`) when both records hold a list, and the list whole when only one does.", "- `repos[].base` is the branch of origin a repo is compared with (`origin/`: the one origin/HEAD names, or origin/main when origin/HEAD names none), `behind` counts the commits on it HEAD does not have, and `ahead` those HEAD has that it does not, as of the last fetch; measure does not fetch. `fetched_at` is when FETCH_HEAD was last written, given that it holds that branch fetched from origin at the commit it is at now (a later `git fetch --append` moves it too), and null when it does not (no fetch since the clone, a failed fetch, a fetch of something else since, or a push since). When it is null or old, `behind` and `ahead` may be old too.", From e29f2d9465ff067be99cfe03733d22feedee684d Mon Sep 17 00:00:00 2001 From: Takashi Matsuyama Date: Tue, 20 Oct 2026 11:49:09 +0900 Subject: [PATCH 7/7] basou: Test that the board page draws the error the previous record held with its value --- packages/cli/src/commands/view.test.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/commands/view.test.ts b/packages/cli/src/commands/view.test.ts index 7303e1f..a0c4afe 100644 --- a/packages/cli/src/commands/view.test.ts +++ b/packages/cli/src/commands/view.test.ts @@ -1711,6 +1711,13 @@ describe("basou view: the board page", () => { ...made, }, { name: "flaky", value: 3, error: "partly", moved: { status: "value", value: 3 }, ...made }, + // A value the previous record held with an error, which is drawn with it. + { + name: "mended", + value: 3, + moved: { status: "value", value: 2, error: "partly" }, + ...made, + }, { name: "fresh", value: true, moved: { status: "none" }, ...made }, { name: "shop", @@ -1739,6 +1746,7 @@ describe("basou view: the board page", () => { ["changed", '"12"', "previous: 12"], ["changed", "ok", "previous: not observed (timeout)"], ["changed", "3 (partly)", "previous: 3"], + ["changed", "3", "previous: 2 (partly)"], ["changed", "true", "not in the previous record"], [ "changed", @@ -1748,7 +1756,7 @@ describe("basou view: the board page", () => { ["3"], ["not observed (previous: 1) (timeout)"], ]); - expect(drawn.find((n) => n.className === "chip changed")).toHaveLength(7); + expect(drawn.find((n) => n.className === "chip changed")).toHaveLength(8); const unreadable = boardPageStrings("en").observed.previousUnreadable; expect(drawn.text()).not.toContain(unreadable); expect(summary("unreadable", [{ name: "npm", value: "1", ...made }]).text()).toContain(