From 47aa9a8b68e0e9960853195f63a49102ed4d5f43 Mon Sep 17 00:00:00 2001 From: Takashi Matsuyama Date: Wed, 7 Oct 2026 13:08:50 +0900 Subject: [PATCH 1/3] basou: compare a board with its previous record in basou board measure and record For a declaration named board.yaml, the last record in the records/ beside it (by name, of the files named as a ULID with .json) is the previous one. basou board measure now says what moved since it under diff: each value that moved, a number with its change, marked when its section's method changed; the methods that changed; and the values there only now or only then. The clock, the freshness, the axis, measured_with and the effort's daily rows are not compared, and the diff is left out of the digest. The text summary puts it first. The components section holds the kinds that changed since the previous record and counts as gone a component only it found (methods.components is 2). The axis section judges trigger (e), a built-in section's method that changed, and takes the last review from the last record with an axis review, its day in the effort's time zone, before the declaration's seed (methods.axis is 2). A record that cannot be read is never taken as no record: the diff, what only it found and the triggers it bears on are null or not judged, with a reason under not_found. basou board record reports the same, with the cells and the observations that moved, in its text and under diff with --json; the record itself does not hold the diff. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 49 +++-- packages/cli/src/commands/board.test.ts | 148 +++++++++++++- packages/cli/src/commands/board.ts | 128 +++++++++++- packages/core/src/board/axis.test.ts | 94 +++++++++ packages/core/src/board/axis.ts | 115 ++++++++--- packages/core/src/board/components.test.ts | 63 +++++- packages/core/src/board/components.ts | 57 +++++- packages/core/src/board/diff.test.ts | 197 ++++++++++++++++++ packages/core/src/board/diff.ts | 222 +++++++++++++++++++++ packages/core/src/board/index.ts | 16 ++ packages/core/src/board/measure.test.ts | 175 +++++++++++++++- packages/core/src/board/measure.ts | 81 ++++++-- packages/core/src/board/previous.test.ts | 131 ++++++++++++ packages/core/src/board/previous.ts | 133 ++++++++++++ packages/core/src/board/record.test.ts | 13 ++ packages/core/src/board/record.ts | 12 +- packages/core/src/ids/ulid.ts | 5 + packages/core/src/index.ts | 15 ++ 18 files changed, 1554 insertions(+), 100 deletions(-) create mode 100644 packages/core/src/board/diff.test.ts create mode 100644 packages/core/src/board/diff.ts create mode 100644 packages/core/src/board/previous.test.ts create mode 100644 packages/core/src/board/previous.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1615cf76..2efbccf4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,20 +49,32 @@ All notable changes to **basou** are recorded here. The project follows migrations, SQL) find in the working tree of each repository, each keyed by the name of the repository's directory and the component's directory, and held against the declaration's `components`: those it does not - register, and those it registers that were not found (a change in a - component's kinds needs a previous record, so until there is one, - `kind_changed` is a null that means so); and whether the board's axis is - due a review, by the triggers it can judge: the components found - unacknowledged or gone, `review_due_days` passed since the last review - (for now the declaration's `seed_review`) by the effort's time zone, and, - with `--model`, a model other than the one that reviewed last (names - compared lower case, each run of anything but an ASCII letter or digit as - one `-`, or as written when one has no such letter or digit; an empty - `--model` is refused), each with what it rests on, and the triggers it did - not judge. With no review on record, the time trigger fires, and the - model trigger too when `--model` is given. + register, those it registers or the previous record found that were not + found now, and those whose kinds are not the previous record's (with no + previous record, `kind_changed` is a null that means so); and whether the + board's axis is due a review, by the triggers it can judge: the components + found unacknowledged, gone or with changed kinds, `review_due_days` passed + since the last review (that of the last record with an axis review, its + day in the effort's time zone, or else the declaration's `seed_review`) by + the effort's time zone, with `--model`, a model other than the one that + reviewed last (names compared lower case, each run of anything but an + ASCII letter or digit as one `-`, or as written when one has no such + letter or digit; an empty `--model` is refused), and a built-in section's + method that is not the previous record's, each with what it rests on, and + the triggers it did not judge. With no review on record, the time trigger + fires, and the model trigger too when `--model` is given. `review_needed` is null when nothing fired and the components or a time - zone to count days in were not measured. It + zone to count days in were not measured, or a record could not be read. + The previous record is, for a declaration named `board.yaml`, the last by + name in the `records/` beside it of the files named as a ULID with + `.json`. Against it the result also says what moved (`diff`): each value + that moved, a number with its change, marked when its section's method + changed; the methods that changed; and the values there only now or only + then (the clock, the freshness, the axis, `measured_with` and the daily + rows of the effort are not compared). The text summary puts it first. A + record that cannot be read is not taken as no record: the diff, what the + components it alone found and the triggers it bears on are then null or + not judged, with a reason under `not_found`. It writes nothing (not even the task index or a stale git index), sends nothing (a partial clone is not fetched from), starts no file system monitor, and runs no import. Counting the uncommitted paths runs `git @@ -79,7 +91,8 @@ All notable changes to **basou** are recorded here. The project follows cannot be read, for the review gaps and the effort's time and tokens an `events.jsonl` that cannot be read, a `~/.basou/portfolio.yaml` that `basou portfolio` refuses, a dry run of an - import that cannot run, commit dates git cannot give, no declared time zone + import that cannot run, a previous record that cannot be read, commit + dates git cannot give, no declared time zone for the effort where this host's cannot be named, for the components two repositories in directories of the same name or a directory whose name is not valid UTF-8, and for the trail, the @@ -90,7 +103,8 @@ All notable changes to **basou** are recorded here. The project follows is `null`, with a reason under `not_found`, and is never counted as zero. The result carries a digest of its values but the freshness, which moves as work goes on, the measuring session's own included, the axis, which rests on the day, the model and the records rather than on - what was measured, and the days elapsed and today's row of the effort, + what was measured, the diff, derived from the previous record, and the + days elapsed and today's row of the effort, which move with the clock. The declaration is read from `board/board.yaml` in the workspace only when the manifest declares the workspace's own repo private; otherwise `--board` is @@ -124,7 +138,10 @@ All notable changes to **basou** are recorded here. The project follows `records/` holds one board's records, never writes under a `.basou/` directory, and, since a record holds what the trail holds, refuses a `records/` that no repo the manifest declares private holds unless - `--not-private` is given. `--dry-run` checks where the record would go + `--not-private` is given. It says what moved since the previous record, + in the measurement, the cells and the observations, in its text and under + `diff` with `--json`; the record does not hold the diff, which the + previous record derives. `--dry-run` checks where the record would go and the input, and measures, but writes nothing. Exit codes: `0` when the record was written (or, with `--dry-run`, everything checked out), `1` when it was refused, saying why and that nothing was written. The README diff --git a/packages/cli/src/commands/board.test.ts b/packages/cli/src/commands/board.test.ts index bc6bef8e..779e2728 100644 --- a/packages/cli/src/commands/board.test.ts +++ b/packages/cli/src/commands/board.test.ts @@ -912,7 +912,7 @@ describe("basou board record", () => { const { out } = capture(); const result = await doRunBoardRecord({}, fed(repo, input)); expect(result.record).toMatch(/^board\/records\/[0-7][0-9A-HJKMNP-TV-Z]{25}\.json$/); - expect(out.join("\n")).toBe(`Recorded ${result.record}`); + expect(out.join("\n")).toBe(`Recorded ${result.record}\nNot compared with a previous record.`); const name = (result.record ?? "").split("/").at(-1) ?? ""; expect(await readdir(join(repo, "board", "records"))).toEqual([name]); const written = JSON.parse(await readFile(join(repo, result.record ?? ""), "utf8")); @@ -934,11 +934,19 @@ describe("basou board record", () => { const { repo, input } = await judged(); const { out } = capture(); const result = await doRunBoardRecord({ dryRun: true, json: true }, fed(repo, input)); - expect(result).toEqual({ record: null, dry_run: true, complete: true, order_anomalies: [] }); + expect(result).toEqual({ + record: null, + dry_run: true, + complete: true, + order_anomalies: [], + diff: null, + }); expect(JSON.parse(out.join("\n"))).toEqual(result); out.length = 0; await doRunBoardRecord({ dryRun: true }, fed(repo, input)); - expect(out.join("\n")).toBe("Dry run: the record checked out (nothing was written)."); + expect(out.join("\n")).toBe( + "Dry run: the record checked out (nothing was written).\nNot compared with a previous record.", + ); await expect(readdir(join(repo, "board"))).resolves.toEqual(["board.yaml"]); }); @@ -964,7 +972,7 @@ describe("basou board record", () => { expect(result.record).toMatch( /^\.\.\/\.\.\/board\/records\/[0-7][0-9A-HJKMNP-TV-Z]{25}\.json$/, ); - expect(out.join("\n")).toBe(`Recorded ${result.record}`); + expect(out.join("\n")).toBe(`Recorded ${result.record}\nNot compared with a previous record.`); await expect(readFile(join(deeper, result.record ?? ""), "utf8")).resolves.toContain( '"record_version": 1', ); @@ -1130,3 +1138,135 @@ describe("basou board record", () => { ); }); }); + +describe("basou board measure and record against the previous record", () => { + async function recorded(): Promise<{ repo: string; first: string }> { + const repo = await workspace([{ path: ".", visibility: "private" }]); + await placeBoard(repo, boardYaml([MD])); + capture(); + const measured = await doRunBoardMeasure({ json: true }, ctx(repo)); + const result = await doRunBoardRecord( + {}, + { + ...ctx(repo), + readInput: async () => + JSON.stringify( + judgement(measured.digest, ["done", "part"], { + npm: { value: "0.64.0", observed_at: "2026-10-05", source: "registry" }, + site: { value: "0.64.0", observed_at: "2026-10-05", source: "site" }, + }), + ), + }, + ); + vi.restoreAllMocks(); + const first = + (result.record ?? "") + .split("/") + .at(-1) + ?.replace(/\.json$/, "") ?? ""; + return { repo, first }; + } + + function judgement(digest: string, states: string[], observed: Record) { + return { + measure_digest: digest, + observed, + cells: ["01", "02", "03", "04", "05", "06"].map((stage, i) => ({ + lane: "core", + stage, + state: states[i] ?? "none", + })), + prose: { summary: "fine" }, + judged_by: { model: "Claude Opus 5.5", self_reported: true }, + }; + } + + it("measures against the last record, and says first what moved", async () => { + const { repo, first } = await recorded(); + await writeFile(join(repo, "LATER.md"), "# later\n"); + const { out } = capture(); + const m = await doRunBoardMeasure({ json: true }, ctx(repo)); + expect(m.diff?.against).toBe(first); + expect(m.diff?.values).toContainEqual({ + at: "measures.md.value", + before: 2, + after: 3, + delta: 1, + }); + out.length = 0; + await doRunBoardMeasure({}, ctx(repo)); + const lines = out.join("\n").split("\n"); + expect(lines.slice(2, 5)).toEqual(["", `Since the previous record ${first}:`, lines[4]]); + expect(out.join("\n")).toContain(" measures.md.value: 2 -> 3 (+1)\n"); + }); + + it("reads no records for a declaration not named board.yaml", async () => { + const { repo } = await recorded(); + await placeBoard(repo, boardYaml([MD]), "board/other.yaml"); + const { out } = capture(); + const m = await doRunBoardMeasure({ board: "board/other.yaml", json: true }, ctx(repo)); + expect(m.diff).toBeNull(); + expect(m.components.kind_changed).toBeNull(); + out.length = 0; + await doRunBoardMeasure({ board: "board/other.yaml" }, ctx(repo)); + expect(out.join("\n")).toContain( + "\n\nNo records are read for a declaration not named board.yaml.\n", + ); + }); + + it("records again, saying what moved in the cells, the observations and the measurement", async () => { + const { repo, first } = await recorded(); + capture(); + const measured = await doRunBoardMeasure({ json: true }, ctx(repo)); + vi.restoreAllMocks(); + const { out } = capture(); + const result = await doRunBoardRecord( + {}, + { + ...ctx(repo), + readInput: async () => + JSON.stringify( + judgement(measured.digest, ["done", "done", "part"], { + npm: { value: "0.65.0", observed_at: "2026-10-06", source: "registry" }, + site: { value: null, observed_at: "2026-10-06", source: "site", error: "timeout" }, + }), + ), + }, + ); + expect(result.diff).toMatchObject({ + against: first, + cells: [ + { lane: "core", stage: "02", before: "part", after: "done" }, + { lane: "core", stage: "03", before: "none", after: "part" }, + ], + observed: [ + { name: "npm", before: { value: "0.64.0" }, after: { value: "0.65.0" } }, + { name: "site", before: { value: "0.64.0" }, after: { value: null, error: "timeout" } }, + ], + }); + expect(result.diff?.measure.values).toEqual(measured.diff?.values); + const text = out.join("\n"); + expect(text).toContain( + `Since the previous record ${first}:\n core 02: part -> done\n core 03: none -> part\n observed npm: "0.64.0" -> "0.65.0"\n observed site: "0.64.0" -> null (timeout)\n`, + ); + }); + + it("takes a record it cannot read as a gap, not as no record", async () => { + const { repo, first } = await recorded(); + await writeFile(join(repo, "board", "records", `${first}.json`), "{ not json"); + const { out } = capture(); + const m = await doRunBoardMeasure({ json: true }, ctx(repo)); + expect(m.diff).toBeNull(); + expect(m.complete).toBe(false); + expect(m.not_found).toContainEqual({ + at: "diff", + reason: `the record ${first} could not be read as JSON`, + }); + expect(process.exitCode).toBe(1); + out.length = 0; + await doRunBoardMeasure({}, ctx(repo)); + expect(out.join("\n")).toContain( + "\n\nThe previous record could not be read (see Not measured).\n", + ); + }); +}); diff --git a/packages/cli/src/commands/board.ts b/packages/cli/src/commands/board.ts index 94d2241a..3945b4d0 100644 --- a/packages/cli/src/commands/board.ts +++ b/packages/cli/src/commands/board.ts @@ -3,20 +3,29 @@ import { basename, dirname, join, relative, resolve, sep } from "node:path"; import { assertBasouRootSafe, type BasouPaths, + type BoardCellChange, type BoardDeclaration, + type BoardDiff, type BoardMeasurement, + type BoardObservation, + type BoardObservedChange, type BoardOrderAnomaly, + type BoardPreviousRecords, type BoardRepo, basouPaths, buildRecord, byCodePoint, + diffCells, + diffObserved, displayPath, findErrorCode, type Manifest, measureBoard, + NO_PREVIOUS_RECORDS, parseBoardDeclaration, parseRecordInput, readManifest, + readPreviousRecords, writeRecord, } from "@basou/core"; import type { Command } from "commander"; @@ -103,6 +112,9 @@ export function registerBoardCommand(program: Command): void { .addHelpText( "after", ` +For a board.yaml, the last record in the records/ beside it is the previous +one, and the result says what moved since it. + Exit codes: 0 when everything was measured; 1 when something could not be measured (the result is still printed, with null for what is missing and a reason under not_found); 1 when the declaration or the manifest cannot be @@ -170,9 +182,9 @@ 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); + const measurement = await measureLoaded(loaded, ctx, options.model, await previousOf(loaded)); if (options.json === true) console.log(JSON.stringify(measurement, null, 2)); - else printMeasurementText(measurement); + else printMeasurementText(measurement, hasRecords(loaded)); if (!measurement.complete) process.exitCode = 1; return measurement; } @@ -201,6 +213,17 @@ export type BoardRecordResult = { /** Whether the measurement recorded was complete. */ complete: boolean; order_anomalies: BoardOrderAnomaly[]; + /** + * What moved since the previous record: in the measurement, the cells and + * the observations. Null when there is no previous record, or when it + * cannot be read (the measurement says why under not_found). + */ + diff: { + against: string; + measure: Omit; + cells: BoardCellChange[]; + observed: BoardObservedChange[]; + } | null; }; // A refusal of `basou board record` that says nothing was written. @@ -260,7 +283,8 @@ async function checkAndRecord( ); } const recordedAt = ctx.nowProvider?.() ?? new Date(); - const measurement = await measureLoaded(loaded, ctx, parsed.input.judged_by.model); + const previous = await previousOf(loaded); + const measurement = await measureLoaded(loaded, ctx, parsed.input.judged_by.model, previous); if (measurement.digest !== parsed.input.measure_digest) { throw new RecordRefusal( `measure_digest is ${parsed.input.measure_digest}, but the board measures ${measurement.digest} now: something it measures moved since the judgement, or basou was rebuilt or upgraded since that measurement. Measure again and judge that; nothing was written.`, @@ -287,14 +311,37 @@ async function checkAndRecord( } written = beside("records", name); } + const { last } = previous; + let diff: BoardRecordResult["diff"] = null; + if (measurement.diff !== null && last.status === "found") { + const { against, ...moved } = measurement.diff; + diff = { + against, + measure: moved, + cells: diffCells(last.record.cells, parsed.input.cells), + observed: diffObserved(last.record.observed, parsed.input.observed), + }; + } return { record: written, dry_run: options.dryRun === true, complete: measurement.complete, order_anomalies: record.order_anomalies, + diff, }; } +// Only a file named board.yaml has records: one board a records/ directory. +function hasRecords(loaded: LoadedBoard): boolean { + return basename(loaded.board.path) === "board.yaml"; +} + +async function previousOf(loaded: LoadedBoard): Promise { + return hasRecords(loaded) + ? readPreviousRecords(join(dirname(loaded.board.path), "records")) + : NO_PREVIOUS_RECORDS; +} + function errorCode(error: unknown): string | undefined { const code = (error as { code?: unknown } | null)?.code; return typeof code === "string" && code.length > 0 ? code : undefined; @@ -372,6 +419,23 @@ function printRecordText(result: BoardRecordResult): void { : `Recorded ${displayPath(result.record)}`, ]; if (!result.complete) lines.push("The measurement recorded is not complete."); + if (result.diff === null) { + lines.push("Not compared with a previous record."); + } else { + const { cells, observed } = result.diff; + lines.push(`Since the previous record ${result.diff.against}:`); + for (const c of cells) { + lines.push( + ` ${displayPath(c.lane)} ${displayPath(c.stage)}: ${c.before ?? "no cell"} -> ${c.after ?? "no cell"}`, + ); + } + for (const o of observed) { + lines.push( + ` observed ${displayPath(o.name)}: ${observation(o.before)} -> ${observation(o.after)}`, + ); + } + lines.push(...diffLines(result.diff.measure, cells.length + observed.length > 0)); + } if (result.order_anomalies.length > 0) { lines.push(`Order anomalies (${result.order_anomalies.length}):`); for (const a of result.order_anomalies) { @@ -453,6 +517,7 @@ async function measureLoaded( loaded: LoadedBoard, ctx: BoardContext, model: string | undefined, + previous: BoardPreviousRecords, ): Promise { const { root, paths } = loaded; const now = ctx.nowProvider?.() ?? new Date(); @@ -475,6 +540,7 @@ async function measureLoaded( : { portfolioConfigPath: ctx.portfolioConfigPath }), probeImports: () => probeStaleness({ ctx: probeCtx, paths, nowIso: now.toISOString() }), ...(model === undefined ? {} : { model }), + previous, }); } @@ -528,6 +594,47 @@ async function assertWorkspaceInitialized(basouRoot: string): Promise { } } +// A value of a diff as text: a number rounded as the summary rounds it, anything +// else as JSON. +function shownJson(value: unknown): string { + if (typeof value === "number") return shownValue(value); + return displayPath(JSON.stringify(value) ?? "undefined"); +} + +function observation(o: BoardObservation | null): string { + if (o === null) return "not observed"; + return o.error === undefined + ? shownJson(o.value) + : `${shownJson(o.value)} (${displayPath(o.error)})`; +} + +// The lines of what moved in a measurement; `more` says whether other lines +// of the same diff came before them. +function diffLines(diff: Omit, more: boolean): string[] { + const lines: string[] = []; + if (diff.methods.length > 0) { + const moved = diff.methods.map( + (m) => `${m.section} ${m.before ?? "none"} -> ${m.after ?? "none"}`, + ); + lines.push(` methods changed: ${moved.join(", ")}`); + } + const flag = (c: { method_changed?: true }) => + c.method_changed === true ? " (its method changed)" : ""; + for (const c of diff.values) { + const delta = c.delta === undefined ? "" : ` (${c.delta > 0 ? "+" : ""}${shownValue(c.delta)})`; + lines.push( + ` ${displayPath(c.at)}: ${shownJson(c.before)} -> ${shownJson(c.after)}${delta}${flag(c)}`, + ); + } + for (const c of diff.added) + lines.push(` + ${displayPath(c.at)}: ${shownJson(c.value)}${flag(c)}`); + for (const c of diff.removed) { + lines.push(` - ${displayPath(c.at)}: ${shownJson(c.value)}${flag(c)}`); + } + if (lines.length === 0 && !more) lines.push(" nothing moved"); + return lines; +} + function shownValue(value: number | string | null): string { if (value === null) return "not measured"; if (typeof value === "number") return String(Math.round(value * 10000) / 10000); @@ -684,10 +791,23 @@ function axisLines(m: BoardMeasurement): string[] { return lines; } -function printMeasurementText(m: BoardMeasurement): void { +function printMeasurementText(m: BoardMeasurement, hasRecords: boolean): 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)); + } else if (m.not_found.some((n) => n.at === "diff")) { + lines.push("", "The previous record could not be read (see Not measured)."); + } else { + lines.push( + "", + hasRecords + ? "No previous record to compare with." + : "No records are read for a declaration not named board.yaml.", + ); + } if (m.repos.length > 0) lines.push("", "Repos:", ...repoLines(m)); const measures = Object.entries(m.measures); if (measures.length > 0) { diff --git a/packages/core/src/board/axis.test.ts b/packages/core/src/board/axis.test.ts index 014d7e4c..8cf69924 100644 --- a/packages/core/src/board/axis.test.ts +++ b/packages/core/src/board/axis.test.ts @@ -133,3 +133,97 @@ describe("sameModel", () => { ]); }); }); + +describe("judgeAxis against the records", () => { + const METHODS = { repos: 1, components: 2, axis: 2 }; + + it("fires (e) for each section whose method is not the previous record's, one it lacked included", () => { + const { axis } = judged({ + methods: METHODS, + previousMethods: { status: "found", methods: { repos: 1, components: 1, gone: 3 } }, + }); + expect(axis.reasons).toEqual([ + { + trigger: "e", + detail: + "methods changed since the previous record: components 1 -> 2, axis none -> 2, gone 3 -> none", + }, + ]); + expect(axis.unjudged).toEqual([NO_MODEL]); + const same = judged({ + methods: METHODS, + previousMethods: { status: "found", methods: METHODS }, + }); + expect(same.axis.reasons).toEqual([]); + expect(same.axis.review_needed).toBe(false); + }); + + it("does not know whether a review is needed when the previous record cannot be read", () => { + const { axis, notFound } = judged({ + methods: METHODS, + previousMethods: { status: "unreadable", reason: "the record X could not be read as JSON" }, + }); + expect(axis.unjudged).toEqual([ + NO_MODEL, + { + trigger: "e", + why: "the previous record could not be read: the record X could not be read as JSON", + }, + ]); + expect(axis.review_needed).toBeNull(); + expect(notFound).toEqual([ + { + at: "axis.review_needed", + reason: + "no trigger fired, but (e) could not be judged: the previous record could not be read: the record X could not be read as JSON", + }, + ]); + }); + + it("takes the last review from a record before the seed, for (b) and (c)", () => { + const { axis } = judged({ + model: "Claude Opus 5.5", + recordedReview: { status: "found", date: "2026-08-01", model: "Other Model", record: "01M4" }, + }); + expect(axis.last_review).toEqual({ + date: "2026-08-01", + model: "Other Model", + from: "record", + record: "01M4", + }); + expect(axis.reasons).toEqual([ + { trigger: "b", detail: "67 days since the review of 2026-08-01 (due after 60)" }, + { trigger: "c", detail: "Claude Opus 5.5 judges; Other Model reviewed last" }, + ]); + }); + + it("judges neither (b) nor (c) when a record that may hold the last review cannot be read", () => { + const why = "the last review is not known: the record Y could not be read as JSON"; + const { axis, notFound } = judged({ + model: "Claude Opus 5.5", + recordedReview: { status: "unreadable", reason: "the record Y could not be read as JSON" }, + }); + expect(axis.last_review).toBeNull(); + expect(axis.reasons).toEqual([]); + expect(axis.unjudged).toEqual([{ trigger: "b", why }, { trigger: "c", why }, E]); + expect(axis.review_needed).toBeNull(); + expect(notFound).toEqual([ + { at: "axis.last_review", reason: why }, + { + at: "axis.review_needed", + reason: `no trigger fired, but (b) could not be judged: ${why}; (c) could not be judged: ${why}`, + }, + ]); + }); + + it("judges (a) on what is known when what only the previous record found is not", () => { + const unknownGone = { ...QUIET, gone: null }; + expect(judged({ components: unknownGone }).axis.unjudged).toContainEqual({ + trigger: "a", + why: "what the previous record alone found is not known", + }); + expect( + judged({ components: { ...unknownGone, unacknowledged: ["app/x"] } }).axis.reasons, + ).toEqual([{ trigger: "a", detail: "components: 1 unacknowledged" }]); + }); +}); diff --git a/packages/core/src/board/axis.ts b/packages/core/src/board/axis.ts index 049af39e..1d28ec60 100644 --- a/packages/core/src/board/axis.ts +++ b/packages/core/src/board/axis.ts @@ -1,12 +1,13 @@ import type { BoardComponents } from "./components.js"; import { daysBetween } from "./effort.js"; +import type { PreviousOutcome } from "./previous.js"; /** * The version of how the `axis` section judges. Raised whenever a value of * the section would change for the same inputs, as when a trigger is judged * differently. */ -export const BOARD_AXIS_METHOD = 1; +export const BOARD_AXIS_METHOD = 2; /** A trigger of an axis review that measure judges: (d), the operator's word, is not one. */ export type BoardAxisTrigger = "a" | "b" | "c" | "e"; @@ -17,8 +18,14 @@ export type BoardAxisReason = { trigger: BoardAxisTrigger; detail: string }; /** A trigger that was not judged, and why. */ export type BoardAxisUnjudged = { trigger: BoardAxisTrigger; why: string }; -/** The last review of the axis: for now, the declaration's `axis.seed_review`. */ -export type BoardLastReview = { date: string; model: string; from: "seed" }; +/** + * The last review of the axis: the last record with an axis review (its + * date in the effort section's time zone, and the model that judged it), or, + * with no such record, the declaration's `axis.seed_review`. + */ +export type BoardLastReview = + | { date: string; model: string; from: "seed" } + | { date: string; model: string; from: "record"; record: string }; /** * Whether the board's axis is due a review, by the triggers measure can @@ -33,18 +40,22 @@ export type BoardAxis = { version: number; /** * True when a trigger fired. Null, with an entry at `axis.review_needed`, - * when none fired but (a) or (b) could not be judged because what it rests - * on was not measured (the components, or a time zone to count days in). - * False otherwise: a trigger with nothing to judge it by, (c) with no - * `--model` or (e) with no previous record, is listed under `unjudged` - * and does not keep it from being false. + * when none fired but one could not be judged because what it rests on was + * not measured (the components, or a time zone to count days in) or a + * record could not be read. False otherwise: a trigger with nothing to + * judge it by, (c) with no `--model` or (e) with no previous record, is + * listed under `unjudged` and does not keep it from being false. */ review_needed: boolean | null; /** The triggers that fired, in the order a, b, c, e. */ reasons: BoardAxisReason[]; /** The triggers that were not judged, in the same order. */ unjudged: BoardAxisUnjudged[]; - /** Null when there is none, which fires (b), and (c) when a model is given. */ + /** + * Null when there is none, which fires (b), and (c) when a model is given. + * Null with an entry at `axis.last_review` when a record that may hold it + * cannot be read: (b) and (c) are then not judged. + */ last_review: BoardLastReview | null; }; @@ -59,6 +70,12 @@ export type AxisInput = { today: string | undefined; /** The model that will judge the board, when one is given. */ model?: string | undefined; + /** The last review the records hold (default: there is none). */ + recordedReview?: PreviousOutcome<{ date: string; model: string; record: string }>; + /** The methods of this measurement. */ + methods?: Readonly>; + /** The methods of the previous record (default: there is no previous record). */ + previousMethods?: PreviousOutcome<{ methods: Readonly> }>; }; /** @@ -90,34 +107,49 @@ export function judgeAxis(input: AxisInput): { } { const reasons: BoardAxisReason[] = []; const unjudged: BoardAxisUnjudged[] = []; - let unknown = false; + // The triggers not judged for want of what they rest on. + const unknown = new Set(); + const notJudged = (trigger: BoardAxisTrigger, why: string) => { + unjudged.push({ trigger, why }); + unknown.add(trigger); + }; const seed = input.declared.seed_review; + const recorded = input.recordedReview ?? { status: "none" }; const last: BoardLastReview | null = - seed === undefined ? null : { date: seed.date, model: seed.model, from: "seed" }; + recorded.status === "found" + ? { date: recorded.date, model: recorded.model, from: "record", record: recorded.record } + : recorded.status === "unreadable" || seed === undefined + ? null + : { date: seed.date, model: seed.model, from: "seed" }; + const lastUnknown = + recorded.status === "unreadable" ? `the last review is not known: ${recorded.reason}` : null; // (a) const { found, unacknowledged, gone, kind_changed } = input.components; - if (found === null || unacknowledged === null || gone === null) { - unjudged.push({ trigger: "a", why: "the components were not measured" }); - unknown = true; + if (found === null || unacknowledged === null) { + notJudged("a", "the components were not measured"); } else { const changed = kind_changed?.length ?? 0; - if (unacknowledged.length > 0 || gone.length > 0 || changed > 0) { - const parts = [`${unacknowledged.length} unacknowledged`, `${gone.length} gone`]; + if (unacknowledged.length > 0 || (gone?.length ?? 0) > 0 || changed > 0) { + const parts = [`${unacknowledged.length} unacknowledged`]; + if (gone !== null) parts.push(`${gone.length} gone`); if (kind_changed !== null) parts.push(`${changed} with changed kinds`); reasons.push({ trigger: "a", detail: `components: ${parts.join(", ")}` }); + } else if (gone === null) { + notJudged("a", "what the previous record alone found is not known"); } } // (b) - if (last === null) { + if (lastUnknown !== null) { + notJudged("b", lastUnknown); + } else if (last === null) { reasons.push({ trigger: "b", detail: "the axis has no review on record" }); } else if (input.today === undefined) { - unjudged.push({ - trigger: "b", - why: "this host's time zone could not be named, so the days are not known (declare effort.time_zone)", - }); - unknown = true; + notJudged( + "b", + "this host's time zone could not be named, so the days are not known (declare effort.time_zone)", + ); } else { const days = daysBetween(last.date, input.today); if (days >= input.declared.review_due_days) { @@ -131,6 +163,8 @@ export function judgeAxis(input: AxisInput): { // (c) if (input.model === undefined) { unjudged.push({ trigger: "c", why: "no --model was given" }); + } else if (lastUnknown !== null) { + notJudged("c", lastUnknown); } else if (last === null) { reasons.push({ trigger: "c", detail: "the axis has no review on record" }); } else if (!sameModel(input.model, last.model)) { @@ -141,12 +175,29 @@ export function judgeAxis(input: AxisInput): { } // (e) - unjudged.push({ - trigger: "e", - why: "there is no previous record to compare the methods with", - }); + const previous = input.previousMethods ?? { status: "none" }; + if (previous.status === "none") { + unjudged.push({ trigger: "e", why: "there is no previous record to compare the methods with" }); + } else if (previous.status === "unreadable") { + notJudged("e", `the previous record could not be read: ${previous.reason}`); + } else { + const now = input.methods ?? {}; + const sections = [...new Set([...Object.keys(now), ...Object.keys(previous.methods)])]; + const moved = sections.filter((section) => now[section] !== previous.methods[section]); + if (moved.length > 0) { + const shown = (n: number | undefined) => (n === undefined ? "none" : String(n)); + reasons.push({ + trigger: "e", + detail: `methods changed since the previous record: ${moved + .map( + (section) => `${section} ${shown(previous.methods[section])} -> ${shown(now[section])}`, + ) + .join(", ")}`, + }); + } + } - const reviewNeeded = reasons.length > 0 ? true : unknown ? null : false; + const reviewNeeded = reasons.length > 0 ? true : unknown.size > 0 ? null : false; return { axis: { version: input.declared.version, @@ -155,17 +206,19 @@ export function judgeAxis(input: AxisInput): { unjudged, last_review: last, }, - notFound: - reviewNeeded === null + notFound: [ + ...(lastUnknown === null ? [] : [{ at: "axis.last_review", reason: lastUnknown }]), + ...(reviewNeeded === null ? [ { at: "axis.review_needed", reason: `no trigger fired, but ${unjudged - .filter((u) => u.trigger === "a" || u.trigger === "b") + .filter((u) => unknown.has(u.trigger)) .map((u) => `(${u.trigger}) could not be judged: ${u.why}`) .join("; ")}`, }, ] - : [], + : []), + ], }; } diff --git a/packages/core/src/board/components.test.ts b/packages/core/src/board/components.test.ts index b6542108..c6b90e50 100644 --- a/packages/core/src/board/components.test.ts +++ b/packages/core/src/board/components.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { measureComponents } from "./components.js"; +import { measureComponents, type PreviousComponents } from "./components.js"; import type { RepoScopeResult } from "./scope.js"; // A working tree holding `paths` (byte strings, as git lists them). @@ -77,3 +77,64 @@ describe("measureComponents", () => { expect(Object.getPrototypeOf(components.found)).toBe(Object.prototype); }); }); + +describe("measureComponents against the previous record", () => { + const against = (previous: PreviousComponents, paths: string[], registered: string[] = []) => + measureComponents({ + repos: ["."], + names: new Map([[".", "app"]]), + worktreeOf: tree(paths), + registered: Object.fromEntries(registered.map((key) => [key, {}])), + previous, + }); + + it("finds the kinds that changed, and counts gone what only the previous record found", async () => { + const { components, notFound } = await against( + { + status: "found", + found: { + app: { kinds: ["manifest"] }, + "app/web": { kinds: ["manifest", "edge"] }, + "app/tmp": { kinds: ["container"] }, + "app/__proto__": { kinds: ["env"] }, + }, + }, + ["package.json", "Dockerfile", "web/package.json", "web/wrangler.toml", "svc/go.mod"], + ["app/registered"], + ); + expect(components.kind_changed).toEqual([ + { key: "app", before: ["manifest"], after: ["container", "manifest"] }, + ]); + expect(components.gone).toEqual(["app/__proto__", "app/registered", "app/tmp"]); + expect(components.unacknowledged).toEqual(["app", "app/svc", "app/web"]); + expect(notFound).toEqual([]); + }); + + it("has no kind changes to give, a null that means so, when there is no previous record", async () => { + const { components, notFound } = await against({ status: "none" }, ["package.json"], ["app/x"]); + expect(components.kind_changed).toBeNull(); + expect(components.gone).toEqual(["app/x"]); + expect(notFound).toEqual([]); + }); + + it("does not know what is gone or changed when the previous record cannot be read", async () => { + const { components, notFound } = await against( + { status: "unreadable", reason: "the record X could not be read as JSON" }, + ["package.json"], + ["app/x"], + ); + expect(components).toEqual({ + found: { app: { kinds: ["manifest"], status: "unacknowledged" } }, + unacknowledged: ["app"], + gone: null, + kind_changed: null, + }); + expect(notFound).toEqual([ + { + at: "components.gone", + reason: "the record X could not be read as JSON, so a component only it found is not known", + }, + { at: "components.kind_changed", reason: "the record X could not be read as JSON" }, + ]); + }); +}); diff --git a/packages/core/src/board/components.ts b/packages/core/src/board/components.ts index 456dc311..dee349cf 100644 --- a/packages/core/src/board/components.ts +++ b/packages/core/src/board/components.ts @@ -1,12 +1,13 @@ import { fromBytes } from "./glob.js"; +import type { PreviousOutcome } from "./previous.js"; import type { RepoScopeResult } from "./scope.js"; /** * The version of how the `components` section measures. Raised whenever a - * value of the section would change for the same repositories and - * declaration, as when a rule for a marker changes. + * value of the section would change for the same repositories, declaration + * and previous record, as when a rule for a marker changes. */ -export const BOARD_COMPONENTS_METHOD = 1; +export const BOARD_COMPONENTS_METHOD = 2; // A file under one of these directories is not a marker. const SKIP = new Set([ @@ -66,7 +67,10 @@ export type BoardComponentChange = { key: string; before: string[]; after: strin * repositories are in directories of the same name, or a component's * directory is not a valid UTF-8 name (in either case keys could not be told * apart). `kind_changed` is also null, with no entry, when there is no - * previous record to hold the kinds against: a null that means so. + * previous record to hold the kinds against, or it did not measure the + * components: a null that means so. When the previous record cannot be read, + * `gone` and `kind_changed` are null, each with an entry: what that record + * alone found is not known. */ export type BoardComponents = { /** @@ -76,11 +80,20 @@ export type BoardComponents = { found: Record | null; /** The keys found that the declaration does not register, by code point. */ unacknowledged: string[] | null; - /** The keys the declaration registers that were not found, by code point. */ + /** + * The keys the declaration registers, or the previous record found, that + * were not found now, by code point. + */ gone: string[] | null; + /** The keys found now and in the previous record with other kinds, by code point. */ kind_changed: BoardComponentChange[] | null; }; +/** The components the previous record found (none when it has none, or did not measure them). */ +export type PreviousComponents = PreviousOutcome<{ + found: Readonly>; +}>; + export type ComponentsInput = { /** The manifest's paths, in its order. */ repos: readonly string[]; @@ -90,6 +103,8 @@ export type ComponentsInput = { worktreeOf: (repo: string) => Promise; /** The declaration's components, by key. */ registered: Readonly>; + /** The components of the previous record (default: there is none). */ + previous?: PreviousComponents; }; /** The `components` section of a measurement, and why it is missing when it is. */ @@ -140,6 +155,8 @@ export async function measureComponents(input: ComponentsInput): Promise<{ if (undecodable !== undefined) return unmeasured(undecodable); const registered = new Set(Object.keys(input.registered)); + const previous = input.previous ?? { status: "none" }; + const before = previous.status === "found" ? previous.found : {}; const keys = [...kinds.keys()].sort(byCodePoint); // From entries, so that a key such as __proto__ is a key of its own. const found: Record = Object.fromEntries( @@ -151,12 +168,38 @@ export async function measureComponents(input: ComponentsInput): Promise<{ }, ]), ); + if (previous.status === "unreadable") { + return { + components: { + found, + unacknowledged: keys.filter((key) => !registered.has(key)), + gone: null, + kind_changed: null, + }, + notFound: [ + { + at: "components.gone", + reason: `${previous.reason}, so a component only it found is not known`, + }, + { at: "components.kind_changed", reason: previous.reason }, + ], + }; + } + const was = new Set([...registered, ...Object.keys(before)]); return { components: { found, unacknowledged: keys.filter((key) => !registered.has(key)), - gone: [...registered].filter((key) => !kinds.has(key)).sort(byCodePoint), - kind_changed: null, + gone: [...was].filter((key) => !kinds.has(key)).sort(byCodePoint), + kind_changed: + previous.status === "found" + ? keys.flatMap((key) => { + if (!Object.hasOwn(before, key)) return []; + const then = [...(before[key]?.kinds ?? [])].sort(byCodePoint); + const now = found[key]?.kinds ?? []; + return then.join("\0") === now.join("\0") ? [] : [{ key, before: then, after: now }]; + }) + : null, }, notFound: [], }; diff --git a/packages/core/src/board/diff.test.ts b/packages/core/src/board/diff.test.ts new file mode 100644 index 00000000..b40c8f63 --- /dev/null +++ b/packages/core/src/board/diff.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, it } from "vitest"; +import { diffCells, 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 = {}) { + return { + board_version: 1, + title: "Board", + measured_at: "2026-10-06T00:00:00.000Z", + measured_with: { basou: "0.64.0", build: "aaaaaaa" }, + complete: true, + not_found: [], + digest: "sha256:old", + methods: { repos: 1, components: 2 }, + repos: [ + { path: ".", name: "ws", commits: 10, files: 4 }, + { path: "../basou", name: "basou", commits: 100, files: 40 }, + ], + measures: { md: { value: 2, unit: "files" } }, + ratios: { r: { value: 0.5, numerator: "a", denominator: "b" } }, + trail: { decisions_all: 5, tracks_open: [{ id: "decision_A", title: "Track A" }] }, + freshness: { newest_session_at: "2026-10-05T00:00:00Z" }, + effort: { elapsed_days: 3, active_ms: { union: 1000 }, daily: [{ date: "2026-10-05" }] }, + components: { + found: { ws: { kinds: ["manifest"], status: "known" } }, + unacknowledged: ["ws/x"], + gone: [], + kind_changed: null, + }, + axis: { review_needed: false }, + ...over, + }; +} + +describe("diffMeasurements", () => { + it("finds nothing moved between two measurements of the same values, whenever and however measured", () => { + const after = measurement({ + measured_at: "2026-10-07T00:00:00.000Z", + measured_with: { basou: "0.65.0", build: "bbbbbbb" }, + digest: "sha256:new", + freshness: { newest_session_at: "2026-10-07T00:00:00Z" }, + effort: { elapsed_days: 4, active_ms: { union: 1000 }, daily: [{ date: "2026-10-06" }] }, + axis: { review_needed: true }, + components: { + found: { ws: { kinds: ["manifest"], status: "known" } }, + unacknowledged: ["ws/x"], + gone: [], + kind_changed: [{ key: "ws", before: [], after: ["manifest"] }], + }, + diff: { against: "X" }, + }); + expect(diffMeasurements(measurement(), after, "01M4")).toEqual({ + against: "01M4", + values: [], + methods: [], + added: [], + removed: [], + }); + }); + + it("names each value that moved by where it is, with the change of a number", () => { + const after = measurement({ + complete: false, + repos: [ + { path: "../basou", name: "basou", commits: 103, files: 40 }, + { path: ".", name: "ws", commits: 10, files: 4 }, + ], + measures: { md: { value: 3, unit: "files" } }, + ratios: { r: { value: 0.25, numerator: "a", denominator: "b" } }, + title: "Board, renamed", + }); + expect(diffMeasurements(measurement(), after, "01M4").values).toEqual([ + { at: "title", before: "Board", after: "Board, renamed" }, + { at: "complete", before: true, after: false }, + { at: 'repos["../basou"].commits', before: 100, after: 103, delta: 3 }, + { at: "measures.md.value", before: 2, after: 3, delta: 1 }, + { at: "ratios.r.value", before: 0.5, after: 0.25, delta: -0.25 }, + ]); + }); + + it("tells entries apart by what names them, and compares lists of names as sets", () => { + const after = measurement({ + not_found: [{ at: "measures.md", reason: "gone" }], + repos: [{ path: ".", name: "ws", commits: 10, files: 4 }], + trail: { + decisions_all: 5, + tracks_open: [ + { id: "decision_B", title: "Track B" }, + { id: "decision_A", title: "Track A, retitled" }, + ], + }, + components: { + found: { ws: { kinds: ["ci", "manifest"], status: "known" } }, + unacknowledged: [], + gone: ["ws/y"], + kind_changed: null, + }, + }); + const diff = diffMeasurements(measurement(), after, "01M4"); + expect(diff.values).toEqual([ + { + at: 'trail.tracks_open["decision_A"].title', + before: "Track A", + after: "Track A, retitled", + }, + { + at: "components.found.ws.kinds", + before: ["manifest"], + after: ["ci", "manifest"], + }, + ]); + expect(diff.added).toEqual([ + { at: 'not_found["measures.md"].reason', value: "gone" }, + { at: 'trail.tracks_open["decision_B"].title', value: "Track B" }, + { at: 'components.gone["ws/y"]', value: true }, + ]); + expect(diff.removed).toEqual([ + { at: 'repos["../basou"].name', value: "basou" }, + { at: 'repos["../basou"].commits', value: 100 }, + { at: 'repos["../basou"].files', value: 40 }, + { at: 'components.unacknowledged["ws/x"]', value: true }, + ]); + }); + + it("lists the methods that changed, and marks what moved in their sections", () => { + const before = measurement({ methods: { repos: 1, components: 1, trail: 1 } }); + const after = measurement({ + methods: { repos: 1, components: 2, axis: 2 }, + repos: [ + { path: ".", name: "ws", commits: 11, files: 4 }, + { path: "../basou", name: "basou", commits: 100, files: 40 }, + ], + components: { + found: { + ws: { kinds: ["manifest"], status: "known" }, + "ws/z": { kinds: ["ci"], status: "unacknowledged" }, + }, + unacknowledged: ["ws/x", "ws/z"], + gone: [], + kind_changed: null, + }, + }); + const diff = diffMeasurements(before, after, "01M4"); + expect(diff.methods).toEqual([ + { section: "components", before: 1, after: 2 }, + { section: "axis", before: null, after: 2 }, + { section: "trail", before: 1, after: null }, + ]); + expect(diff.values).toEqual([{ at: 'repos["."].commits', before: 10, after: 11, delta: 1 }]); + expect(diff.added).toEqual([ + { at: 'components.found["ws/z"].kinds', value: ["ci"], method_changed: true }, + { at: 'components.found["ws/z"].status', value: "unacknowledged", method_changed: true }, + { at: 'components.unacknowledged["ws/z"]', value: true, method_changed: true }, + ]); + }); +}); + +describe("diffCells", () => { + it("lists the cells whose state moved, and those there only now or only then", () => { + const cell = (lane: string, stage: string, state: string) => ({ lane, stage, state }); + expect( + diffCells( + [cell("core", "01", "done"), cell("core", "02", "none"), cell("old", "01", "part")], + [cell("core", "01", "done"), cell("core", "02", "part"), cell("new", "01", "none")], + ), + ).toEqual([ + { lane: "core", stage: "02", before: "none", after: "part" }, + { lane: "new", stage: "01", before: null, after: "none" }, + { lane: "old", stage: "01", before: "part", after: null }, + ]); + }); +}); + +describe("diffObserved", () => { + it("lists the observations whose value or error moved, and those there only now or only then", () => { + const at = { observed_at: "2026-10-07", source: "s" }; + // As a record and an input hold them, with when and where they were observed. + const before = { + npm: { value: "0.64.0", ...at }, + site: { value: "0.64.0", ...at }, + same: { value: { a: [1] }, ...at }, + gone: { value: 1, ...at }, + }; + const after = { + npm: { value: "0.65.0", ...at }, + site: { value: null, error: "timeout", ...at }, + same: { value: { a: [1] }, ...at, source: "elsewhere" }, + fresh: { value: true, ...at }, + }; + expect(diffObserved(before, after)).toEqual([ + { name: "npm", before: { value: "0.64.0" }, after: { value: "0.65.0" } }, + { name: "site", before: { value: "0.64.0" }, after: { value: null, error: "timeout" } }, + { name: "fresh", before: null, after: { value: true } }, + { name: "gone", before: { value: 1 }, after: null }, + ]); + }); +}); diff --git a/packages/core/src/board/diff.ts b/packages/core/src/board/diff.ts new file mode 100644 index 00000000..6f97ce5e --- /dev/null +++ b/packages/core/src/board/diff.ts @@ -0,0 +1,222 @@ +/** A value that moved since the previous record. */ +export type BoardValueChange = { + /** Where it is, as `repos["../basou"].commits`. */ + at: string; + before: unknown; + after: unknown; + /** After less before, when both are numbers. */ + delta?: number; + /** + * Present when the method of the value's section changed since the + * previous record: the move may be the method's rather than the board's. + */ + method_changed?: true; +}; + +/** A value there only now, or only in the previous record. */ +export type BoardValueOnly = { at: string; value: unknown; method_changed?: true }; + +/** A built-in section whose method is not the previous record's (null where it had none). */ +export type BoardMethodChange = { section: string; before: number | null; after: number | null }; + +/** + * What moved since the previous record, value by value. Compared are the + * values the digest holds but `measured_with`, the methods (compared on their + * own), `effort.daily`, whose rows only grow, and `components.kind_changed`, + * already a change since the previous record. A list whose entries name + * themselves is compared entry by entry (`repos` by path, open tracks by id, + * `not_found` by where), the unacknowledged and gone components as sets, and + * any other list whole. + */ +export type BoardDiff = { + /** The previous record's name, its ULID. */ + against: string; + values: BoardValueChange[]; + methods: BoardMethodChange[]; + added: BoardValueOnly[]; + removed: BoardValueOnly[]; +}; + +// Left out at the top: what is not a value the board measured, what the +// digest leaves out, and the diff itself. +const UNCOMPARED = new Set([ + "measured_at", + "measured_with", + "digest", + "methods", + "freshness", + "axis", + "diff", +]); +// Left out further down. +const SKIPPED = new Set(["effort.elapsed_days", "effort.daily", "components.kind_changed"]); +// Lists whose entries are told apart by a field of theirs. +const KEYED: Readonly> = { + repos: "path", + "trail.tracks_open": "id", + not_found: "at", +}; +// Lists of names, compared as sets. +const SETS = new Set(["components.unacknowledged", "components.gone"]); + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function shownKey(shown: string, key: string): string { + if (shown === "") return key; + return /^[A-Za-z0-9_-]+$/.test(key) ? `${shown}.${key}` : `${shown}[${JSON.stringify(key)}]`; +} + +type Leaf = { value: unknown; section: string }; + +// Each value of a measurement by where it is, with the section it is in. +function leaves(measurement: unknown): Map { + const out = new Map(); + const walk = (value: unknown, rule: string, shown: string, section: string): void => { + if (SKIPPED.has(rule)) return; + if (Array.isArray(value)) { + const field = KEYED[rule]; + if (field !== undefined) { + for (const entry of value) { + if (!isRecord(entry)) continue; + const { [field]: name, ...rest } = entry; + walk(rest, `${rule}[]`, `${shown}[${JSON.stringify(String(name))}]`, section); + } + } else if (SETS.has(rule)) { + for (const name of value) { + out.set(`${shown}[${JSON.stringify(String(name))}]`, { value: true, section }); + } + } else { + out.set(shown, { value, section }); + } + return; + } + if (isRecord(value)) { + for (const [key, child] of Object.entries(value)) { + if (rule === "" && UNCOMPARED.has(key)) continue; + walk( + child, + rule === "" ? key : `${rule}.${key}`, + shownKey(shown, key), + section === "" ? key : section, + ); + } + return; + } + out.set(shown, { value, section }); + }; + walk(measurement, "", "", ""); + return out; +} + +function methodsOf(measurement: unknown): Record { + return isRecord(measurement) && isRecord(measurement.methods) ? measurement.methods : {}; +} + +const same = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b); + +/** What moved from the previous record's measurement to this one. */ +export function diffMeasurements(before: unknown, after: unknown, against: string): BoardDiff { + const [methodsBefore, methodsAfter] = [methodsOf(before), methodsOf(after)]; + const sections = [...new Set([...Object.keys(methodsAfter), ...Object.keys(methodsBefore)])]; + const number = (value: unknown) => (typeof value === "number" ? value : null); + const methods = sections + .filter((section) => !same(methodsBefore[section], methodsAfter[section])) + .map((section) => ({ + section, + before: number(methodsBefore[section]), + after: number(methodsAfter[section]), + })); + const changed = new Set(methods.map((m) => m.section)); + const flag = (section: string) => (changed.has(section) ? { method_changed: true as const } : {}); + + const [was, is] = [leaves(before), leaves(after)]; + const values: BoardValueChange[] = []; + const added: BoardValueOnly[] = []; + for (const [at, leaf] of is) { + const old = was.get(at); + if (old === undefined) { + added.push({ at, value: leaf.value, ...flag(leaf.section) }); + } else if (!same(old.value, leaf.value)) { + const delta = + typeof old.value === "number" && typeof leaf.value === "number" + ? { delta: leaf.value - old.value } + : {}; + values.push({ at, before: old.value, after: leaf.value, ...delta, ...flag(leaf.section) }); + } + } + const removed: BoardValueOnly[] = []; + for (const [at, leaf] of was) { + if (!is.has(at)) removed.push({ at, value: leaf.value, ...flag(leaf.section) }); + } + return { against, values, methods, added, removed }; +} + +/** A cell whose state is not the previous record's (null where there was no such cell). */ +export type BoardCellChange = { + lane: string; + stage: string; + before: string | null; + after: string | null; +}; + +/** The cells whose state moved, in the order given now, then those only the previous record had. */ +export function diffCells( + before: readonly { lane: string; stage: string; state: string }[], + after: readonly { lane: string; stage: string; state: string }[], +): BoardCellChange[] { + const key = (c: { lane: string; stage: string }) => `${c.lane}\0${c.stage}`; + const was = new Map(before.map((c) => [key(c), c.state])); + const is = new Set(after.map(key)); + const out: BoardCellChange[] = []; + for (const cell of after) { + const old = was.get(key(cell)) ?? null; + if (old !== cell.state) { + out.push({ lane: cell.lane, stage: cell.stage, before: old, after: cell.state }); + } + } + for (const cell of before) { + if (!is.has(key(cell))) { + out.push({ lane: cell.lane, stage: cell.stage, before: cell.state, after: null }); + } + } + return out; +} + +/** An observation as a diff shows it. */ +export type BoardObservation = { value: unknown; error?: string }; + +// An observation as a record or an input holds it, with what else it has. +type ObservedEntry = { value: unknown; error?: string | undefined }; + +/** An observation that moved (null where there was no observation of that name). */ +export type BoardObservedChange = { + name: string; + before: BoardObservation | null; + after: BoardObservation | null; +}; + +/** + * The observations whose value or error moved, one observed now that was not + * then, or one observed then that is not now: in the order given now, then + * those only the previous record had. + */ +export function diffObserved( + before: Readonly>, + after: Readonly>, +): BoardObservedChange[] { + const shown = (o: ObservedEntry): BoardObservation => + o.error === undefined ? { value: o.value } : { value: o.value, error: o.error }; + const out: BoardObservedChange[] = []; + for (const [name, now] of Object.entries(after)) { + const then = Object.hasOwn(before, name) ? before[name] : undefined; + if (then === undefined) out.push({ name, before: null, after: shown(now) }); + else if (!same(shown(then), shown(now))) + out.push({ name, before: shown(then), after: shown(now) }); + } + for (const [name, then] of Object.entries(before)) { + if (!Object.hasOwn(after, name)) out.push({ name, before: shown(then), after: null }); + } + return out; +} diff --git a/packages/core/src/board/index.ts b/packages/core/src/board/index.ts index 0aeaf05a..cdc30d99 100644 --- a/packages/core/src/board/index.ts +++ b/packages/core/src/board/index.ts @@ -28,6 +28,16 @@ export { BOARD_VERSION, parseBoardDeclaration, } from "./declaration.js"; +export type { + BoardCellChange, + BoardDiff, + BoardMethodChange, + BoardObservation, + BoardObservedChange, + BoardValueChange, + BoardValueOnly, +} from "./diff.js"; +export { diffCells, diffMeasurements, diffObserved } from "./diff.js"; export type { BoardActiveMs, BoardEffort, BoardEffortDay } from "./effort.js"; export { BOARD_EFFORT_METHOD } from "./effort.js"; export type { BoardFreshness, BoardImportProbe } from "./freshness.js"; @@ -46,6 +56,12 @@ export type { export { boardDigest, measureBoard } from "./measure.js"; export type { BoardPortfolio } from "./portfolio.js"; export { BOARD_PORTFOLIO_METHOD } from "./portfolio.js"; +export type { + BoardPreviousRecords, + PreviousOutcome, + ReadBoardRecord, +} from "./previous.js"; +export { NO_PREVIOUS_RECORDS, readPreviousRecords } from "./previous.js"; export type { BoardOrderAnomaly, BoardRecord, diff --git a/packages/core/src/board/measure.test.ts b/packages/core/src/board/measure.test.ts index 90d1fd49..e9f6ce9c 100644 --- a/packages/core/src/board/measure.test.ts +++ b/packages/core/src/board/measure.test.ts @@ -20,6 +20,7 @@ import type { Event } from "../schemas/event.schema.js"; import { type BasouPaths, ensureBasouDirectory } from "../storage/basou-dir.js"; import { type BoardDeclaration, parseBoardDeclaration } from "./declaration.js"; import { type BoardMeasurement, boardDigest, measureBoard } from "./measure.js"; +import type { BoardPreviousRecords, ReadBoardRecord } from "./previous.js"; const NULL_CONFIG = process.platform === "win32" ? "\\\\.\\nul" : "/dev/null"; const GIT_ENV = { ...process.env, GIT_CONFIG_GLOBAL: NULL_CONFIG, GIT_CONFIG_SYSTEM: NULL_CONFIG }; @@ -879,6 +880,7 @@ describe("measureBoard: ratios, the digest and the result's shape", () => { "effort", "components", "axis", + "diff", ]); expect(m).toMatchObject({ board_version: 1, @@ -1711,8 +1713,8 @@ describe("measureBoard: the repos section", () => { portfolio: 1, freshness: 1, effort: 1, - components: 1, - axis: 1, + components: 2, + axis: 2, }); const later = await measure(declaration, new Date("2026-10-05T09:00:00.000Z"), ["app"]); expect(later.digest).toBe(first.digest); @@ -2750,8 +2752,8 @@ describe("measureBoard: the integrity section", () => { portfolio: 1, freshness: 1, effort: 1, - components: 1, - axis: 1, + components: 2, + axis: 2, }); await placeChained(SES("S01")); const verified = await measure(declaration); @@ -3010,8 +3012,8 @@ describe("measureBoard: the review_gaps section", () => { portfolio: 1, freshness: 1, effort: 1, - components: 1, - axis: 1, + components: 2, + axis: 2, }); await placeWork( s1, @@ -3130,8 +3132,8 @@ describe("measureBoard: the portfolio section", () => { portfolio: 1, freshness: 1, effort: 1, - components: 1, - axis: 1, + components: 2, + axis: 2, }); expect(bare.portfolio).toEqual({ workspaces: 1, initialized: 0 }); await mkdir(join(master, ".basou")); @@ -3801,7 +3803,7 @@ describe("measureBoard: the components section", () => { const dir = await repo("app", { "package.json": "{}\n" }); const declaration = declare([], {}, ["app"]); const first = await measure(declaration, NOW, ["app"]); - expect(first.methods.components).toBe(1); + expect(first.methods.components).toBe(2); await mkdir(join(dir, "svc")); await writeFile(join(dir, "svc", "Dockerfile"), "FROM x\n"); const second = await measure(declaration, NOW, ["app"]); @@ -3887,8 +3889,161 @@ describe("measureBoard: the axis section", () => { it("is left out of the digest", async () => { const declaration = declare([], SEEDED); const m = await measure(declaration); - expect(m.methods.axis).toBe(1); + expect(m.methods.axis).toBe(2); expect(m.axis.review_needed).toBe(false); expect(boardDigest({ ...m, axis: { ...m.axis, review_needed: true } })).toBe(m.digest); }); }); + +describe("measureBoard: against the previous record", () => { + const ID = "01M4A00000000000000000000A"; + + // What a record of `m` reads back as. + function recordOf(m: BoardMeasurement, over: Record = {}): ReadBoardRecord { + const { diff: _diff, ...measure } = m; + return { + record_version: 1, + recorded_at: m.measured_at, + measure, + observed: {}, + cells: [], + judged_by: { model: "Claude Opus 5.5" }, + axis_review: null, + ...over, + } as unknown as ReadBoardRecord; + } + + function against( + declaration: BoardDeclaration, + previous: BoardPreviousRecords, + repos: readonly string[], + model?: string, + ): Promise { + return measureBoard({ + declaration, + root, + repos, + paths, + now: NOW, + measuredWith: WITH, + portfolioConfigPath: PORTFOLIO(), + probeImports: NOTHING_TO_IMPORT, + previous, + ...(model === undefined ? {} : { model }), + }); + } + + it("says what moved since the previous record, and leaves that out of the digest", async () => { + await repo("app", { "package.json": "{}\n" }); + const declaration = declare([], {}, ["app"]); + const first = await measure(declaration, NOW, ["app"]); + expect(first.diff).toBeNull(); + await writeFile(join(root, "app", "README.md"), "# app\n"); + const second = await against( + declaration, + { + last: { status: "found", id: ID, record: recordOf(first) }, + lastReview: { status: "none" }, + }, + ["app"], + ); + expect(second.diff).toEqual({ + against: ID, + values: [ + { at: 'repos["app"].files', before: 1, after: 2, delta: 1 }, + { at: 'repos["app"].uncommitted', before: 0, after: 1, delta: 1 }, + ], + methods: [], + added: [], + removed: [], + }); + expect(boardDigest({ ...second, diff: null })).toBe(second.digest); + expect(second.complete).toBe(true); + }); + + it("counts gone what only the previous record found, and fires (e) for a method that moved", async () => { + await repo("app", { "package.json": "{}\n" }); + const declaration = declare([], { components: { app: { lane: "-", note: "x" } } }, ["app"]); + const first = await measure(declaration, NOW, ["app"]); + const then = recordOf({ + ...first, + methods: { ...first.methods, components: 1 }, + components: { + ...first.components, + found: { + app: { kinds: ["ci", "manifest"], status: "known" }, + "app/old": { kinds: ["env"], status: "unacknowledged" }, + }, + }, + }); + const m = await against( + declaration, + { last: { status: "found", id: ID, record: then }, lastReview: { status: "none" } }, + ["app"], + ); + expect(m.components.gone).toEqual(["app/old"]); + expect(m.components.kind_changed).toEqual([ + { key: "app", before: ["ci", "manifest"], after: ["manifest"] }, + ]); + expect(m.axis.reasons).toEqual([ + { trigger: "a", detail: "components: 0 unacknowledged, 1 gone, 1 with changed kinds" }, + { trigger: "b", detail: "the axis has no review on record" }, + { trigger: "e", detail: "methods changed since the previous record: components 1 -> 2" }, + ]); + expect(m.diff?.methods).toEqual([{ section: "components", before: 1, after: 2 }]); + }); + + it("takes the last review a record holds, dated in the effort's time zone", async () => { + const declaration = declare([], { + axis: { version: 1, review_due_days: 60, seed_review: { date: "2026-01-01", model: "Seed" } }, + effort: { start: "2026-04-28", time_zone: "Asia/Tokyo" }, + }); + const first = await measure(declaration); + // 16:00 on the 4th in UTC is the 5th in Tokyo. + const reviewed = recordOf(first, { + recorded_at: "2026-10-04T16:00:00.000Z", + judged_by: { model: "Other Model" }, + axis_review: { triggers: ["d"], summary: "s" }, + }); + const m = await against( + declaration, + { + last: { status: "found", id: ID, record: reviewed }, + lastReview: { status: "found", id: ID, record: reviewed }, + }, + [], + "Claude Opus 5.5", + ); + expect(m.axis.last_review).toEqual({ + date: "2026-10-05", + model: "Other Model", + from: "record", + record: ID, + }); + expect(m.axis.reasons).toEqual([ + { trigger: "c", detail: "Claude Opus 5.5 judges; Other Model reviewed last" }, + ]); + }); + + it("does not take a previous record it cannot read as none", async () => { + await repo("app", { "package.json": "{}\n" }); + const reason = `the record ${ID} could not be read as JSON`; + const m = await against( + declare([], { components: { app: { lane: "-", note: "x" } } }, ["app"]), + { last: { status: "unreadable", reason }, lastReview: { status: "unreadable", reason } }, + ["app"], + ); + expect(m.diff).toBeNull(); + expect(m.axis.review_needed).toBeNull(); + expect(m.complete).toBe(false); + expect(m.components.gone).toBeNull(); + expect(m.not_found.map((n) => n.at)).toEqual([ + "components.gone", + "components.kind_changed", + "diff", + "axis.last_review", + "axis.review_needed", + ]); + expect(m.not_found).toContainEqual({ at: "diff", reason }); + }); +}); diff --git a/packages/core/src/board/measure.ts b/packages/core/src/board/measure.ts index 87da6d8c..f77b999d 100644 --- a/packages/core/src/board/measure.ts +++ b/packages/core/src/board/measure.ts @@ -11,6 +11,7 @@ import { type BoardDeclaration, type BoardMeasure, } from "./declaration.js"; +import { type BoardDiff, diffMeasurements } from "./diff.js"; import { BOARD_EFFORT_METHOD, type BoardEffort, measureEffort, todayIn } from "./effort.js"; import { BOARD_FRESHNESS_METHOD, @@ -28,6 +29,7 @@ import { } from "./glob.js"; import { BOARD_INTEGRITY_METHOD, type BoardIntegrity, measureIntegrity } from "./integrity.js"; import { BOARD_PORTFOLIO_METHOD, type BoardPortfolio, measurePortfolio } from "./portfolio.js"; +import { type BoardPreviousRecords, NO_PREVIOUS_RECORDS } from "./previous.js"; import { BOARD_REPOS_METHOD, type BoardRepo, measureRepos } from "./repos.js"; import { BOARD_REVIEW_GAPS_METHOD, @@ -79,8 +81,9 @@ export type BoardRatioValue = { * there is no session that is not archived (see {@link BoardFreshness}), the * Codex time when there is no Codex session (see {@link BoardEffort}), the * components' kind changes when there is no previous record (see - * {@link BoardComponents}), and the axis's last review when there is none on - * record (see {@link BoardAxis}). + * {@link BoardComponents}), the axis's last review when there is none on + * record (see {@link BoardAxis}), and the diff when there is no previous + * record. */ export type BoardMeasurement = { board_version: number; @@ -91,13 +94,14 @@ export type BoardMeasurement = { not_found: BoardNotFound[]; /** * `sha256:` and the hex digest of the measurement without `measured_at`, - * `digest`, `freshness`, `axis` and its not_found entries, + * `digest`, `freshness`, `axis` and its not_found entries, `diff`, * `effort.elapsed_days` and today's row of `effort.daily`, serialized with * its keys sorted. Two measurements with the * same values have the same digest whenever they were taken. The freshness * moves as work goes on, the measuring session's own included, the axis - * rests on the day, the model and the records, and the others move with the - * clock, so they are left out. + * rests on the day, the model and the records, the diff is derived from the + * previous record, and the others move with the clock, so they are left + * out. */ digest: string; /** @@ -138,6 +142,11 @@ export type BoardMeasurement = { components: BoardComponents; /** Whether the axis is due a review, by the triggers measure can judge. */ axis: BoardAxis; + /** + * What moved since the previous record. Null, with no entry, when there is + * no previous record; null with an entry at `diff` when it cannot be read. + */ + diff: BoardDiff | null; }; export type MeasureBoardInput = { @@ -160,6 +169,8 @@ export type MeasureBoardInput = { probeImports?: () => Promise; /** The model that will judge the board, for trigger (c) of the axis. */ model?: string; + /** The records the board compares with (default: there are none). */ + previous?: BoardPreviousRecords; }; type Outcome = { value: number | string | null; reason?: string }; @@ -243,18 +254,52 @@ export async function measureBoard(input: MeasureBoardInput): Promise [repo.path, repo.name])), worktreeOf: (repo) => scopeOf(repo, BOARD_DEFAULT_AT), registered: declaration.components, + previous: + last.status !== "found" + ? last + : last.record.measure.components.found === null + ? { status: "none" } + : { status: "found", found: last.record.measure.components.found }, }); notFound.push(...built.notFound); + if (last.status === "unreadable") notFound.push({ at: "diff", reason: last.reason }); + const methods = { + repos: BOARD_REPOS_METHOD, + trail: BOARD_TRAIL_METHOD, + integrity: BOARD_INTEGRITY_METHOD, + review_gaps: BOARD_REVIEW_GAPS_METHOD, + portfolio: BOARD_PORTFOLIO_METHOD, + freshness: BOARD_FRESHNESS_METHOD, + effort: BOARD_EFFORT_METHOD, + components: BOARD_COMPONENTS_METHOD, + axis: BOARD_AXIS_METHOD, + }; const judged = judgeAxis({ declared: declaration.axis, components: built.components, today: todayIn(declaration.effort.time_zone, input.now), model: input.model, + // The day of a review on record, counted in the same calendar as (b). + recordedReview: + lastReview.status !== "found" + ? lastReview + : { + status: "found", + date: + todayIn(declaration.effort.time_zone, new Date(lastReview.record.recorded_at)) ?? + new Date(lastReview.record.recorded_at).toISOString().slice(0, 10), + model: lastReview.record.judged_by.model, + record: lastReview.id, + }, + methods, + previousMethods: + last.status !== "found" ? last : { status: "found", methods: last.record.measure.methods }, }); notFound.push(...judged.notFound); @@ -265,17 +310,7 @@ export async function measureBoard(input: MeasureBoardInput): Promise = {}) { + return { + record_version: 1, + recorded_at: "2026-10-07T00:00:00.000Z", + measure: { + methods: { components: 2 }, + components: { found: { ws: { kinds: ["manifest"], status: "known" } } }, + }, + observed: {}, + cells: [{ lane: "core", stage: "01", state: "done" }], + judged_by: { model: "Claude Opus 5.5", self_reported: true }, + axis_review: null, + ...over, + }; +} + +const REVIEW = { triggers: ["b"], summary: "looked again" }; + +describe("readPreviousRecords", () => { + let dir: string; + let records: string; + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "basou-board-previous-")); + records = join(dir, "records"); + }); + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + const place = (name: string, value: unknown) => + writeFile(join(records, name), typeof value === "string" ? value : JSON.stringify(value)); + + it("finds none where there is no records directory, or nothing in it named as a record", async () => { + expect(await readPreviousRecords(records)).toEqual({ + last: { status: "none" }, + lastReview: { status: "none" }, + }); + await mkdir(records); + await place(".record-1234.tmp", "{ part"); + await place(`${A}.json.orig`, record()); + await place(`${A.toLowerCase()}.json`, record()); + await place("notes.json", record()); + expect(await readPreviousRecords(records)).toEqual({ + last: { status: "none" }, + lastReview: { status: "none" }, + }); + }); + + it("takes the last by name as the previous record, and the last with an axis review", async () => { + await mkdir(records); + await place(`${A}.json`, record({ axis_review: REVIEW, judged_by: { model: "Old Model" } })); + await place(`${C}.json`, record({ recorded_at: "2026-10-09T00:00:00.000Z" })); + await place(`${B}.json`, record()); + const read = await readPreviousRecords(records); + expect(read.last).toMatchObject({ + status: "found", + id: C, + record: { recorded_at: "2026-10-09T00:00:00.000Z" }, + }); + expect(read.lastReview).toMatchObject({ + status: "found", + id: A, + record: { judged_by: { model: "Old Model" } }, + }); + }); + + it("keeps a component named __proto__ as a key of its own", async () => { + await mkdir(records); + await place( + `${A}.json`, + JSON.stringify(record()).replace('"ws":', '"__proto__":{"kinds":["ci"]},"ws":'), + ); + const read = await readPreviousRecords(records); + if (read.last.status !== "found") throw new Error("not found"); + const found = read.last.record.measure.components.found ?? {}; + expect(Object.keys(found)).toEqual(["__proto__", "ws"]); + }); + + it("never takes a record it cannot read as no record, nor reads past it", async () => { + await mkdir(records); + await place(`${A}.json`, record({ axis_review: REVIEW })); + await place(`${B}.json`, "{ not json"); + await place(`${C}.json`, record()); + const read = await readPreviousRecords(records); + expect(read.last).toMatchObject({ status: "found", id: C }); + expect(read.lastReview).toEqual({ + status: "unreadable", + reason: `the record ${B} could not be read as JSON`, + }); + await place(`${C}.json`, record({ record_version: 2 })); + const unread = { + status: "unreadable", + reason: `the record ${C} is of record_version 2, which this basou does not read`, + }; + expect(await readPreviousRecords(records)).toEqual({ last: unread, lastReview: unread }); + await place(`${C}.json`, record({ cells: "all done" })); + expect((await readPreviousRecords(records)).last).toEqual({ + status: "unreadable", + reason: `the record ${C} is not in the shape of a record`, + }); + await place(`${C}.json`, record({ recorded_at: "yesterday" })); + expect((await readPreviousRecords(records)).last).toEqual({ + status: "unreadable", + reason: `the record ${C} is not in the shape of a record`, + }); + }); + + it("cannot read a records/ that is a link or a file", async () => { + const elsewhere = join(dir, "elsewhere"); + await mkdir(elsewhere); + await writeFile(join(elsewhere, `${A}.json`), JSON.stringify(record())); + await symlink(elsewhere, records); + const reason = "the records/ beside the board is not a directory (a symlink or a file)"; + expect(await readPreviousRecords(records)).toEqual({ + last: { status: "unreadable", reason }, + lastReview: { status: "unreadable", reason }, + }); + }); +}); diff --git a/packages/core/src/board/previous.ts b/packages/core/src/board/previous.ts new file mode 100644 index 00000000..76ca891a --- /dev/null +++ b/packages/core/src/board/previous.ts @@ -0,0 +1,133 @@ +import { lstat, readdir, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { z } from "zod"; +import { isUlidBody } from "../ids/ulid.js"; +import { findErrorCode } from "../lib/error-codes.js"; + +// The record versions this reader reads. A version is added here, never +// removed, when the shape of a record changes. +const READABLE_VERSIONS = new Set([1]); + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +// The components a record found, checked but kept as written, so that a key +// such as __proto__ stays a key of its own. +const foundSchema = z.custom | null>( + (value) => + value === null || + (isRecord(value) && + Object.values(value).every( + (c) => isRecord(c) && Array.isArray(c.kinds) && c.kinds.every((k) => typeof k === "string"), + )), +); + +// The parts of a record that are read back, kept loose: what is not named +// here is kept as it was written. +const readRecordSchema = z.looseObject({ + record_version: z.number(), + recorded_at: z.string().refine((s) => !Number.isNaN(Date.parse(s))), + measure: z.looseObject({ + methods: z.record(z.string(), z.number()), + components: z.looseObject({ found: foundSchema }), + }), + observed: z.record( + z.string(), + z.looseObject({ value: z.unknown(), error: z.string().optional() }), + ), + cells: z.array(z.looseObject({ lane: z.string(), stage: z.string(), state: z.string() })), + judged_by: z.looseObject({ model: z.string() }), + axis_review: z.looseObject({ triggers: z.array(z.string()) }).nullable(), +}); + +/** A record as read back: the parts the board compares with, and the rest as written. */ +export type ReadBoardRecord = z.output; + +/** What was found where a record was looked for. */ +export type PreviousOutcome = + | { status: "none" } + | { status: "unreadable"; reason: string } + | ({ status: "found" } & T); + +/** The records a board compares with, from the `records/` beside its board.yaml. */ +export type BoardPreviousRecords = { + /** The previous record: of the files named as a ULID with `.json`, the last by name. */ + last: PreviousOutcome<{ id: string; record: ReadBoardRecord }>; + /** The last record with an axis review, the previous one or one before it. */ + lastReview: PreviousOutcome<{ id: string; record: ReadBoardRecord }>; +}; + +/** No record to compare with. */ +export const NO_PREVIOUS_RECORDS: BoardPreviousRecords = { + last: { status: "none" }, + lastReview: { status: "none" }, +}; + +/** + * Read the records a board compares with. Only a file whose name is a ULID + * with `.json` is a record; any other file (a temporary one, a copy) is + * passed over. A record that cannot be read is never taken as no record: + * what it alone would tell is then unknown, and so is anything before it. + */ +export async function readPreviousRecords(recordsDir: string): Promise { + let names: string[]; + try { + const entry = await lstat(recordsDir); + if (!entry.isDirectory()) { + const reason = "the records/ beside the board is not a directory (a symlink or a file)"; + return { + last: { status: "unreadable", reason }, + lastReview: { status: "unreadable", reason }, + }; + } + names = await readdir(recordsDir); + } catch (error: unknown) { + if (findErrorCode(error, "ENOENT")) return NO_PREVIOUS_RECORDS; + const reason = "the records/ beside the board could not be read"; + return { last: { status: "unreadable", reason }, lastReview: { status: "unreadable", reason } }; + } + const ids = names + .filter((name) => name.endsWith(".json") && isUlidBody(name.slice(0, -".json".length))) + .map((name) => name.slice(0, -".json".length)) + .sort(); + + let last: BoardPreviousRecords["last"] = { status: "none" }; + for (let i = ids.length - 1; i >= 0; i--) { + const id = ids[i] as string; + const read = await readRecord(recordsDir, id); + if (!read.ok) { + const unreadable = { status: "unreadable" as const, reason: read.reason }; + return { last: last.status === "none" ? unreadable : last, lastReview: unreadable }; + } + if (last.status === "none") last = { status: "found", id, record: read.record }; + if (read.record.axis_review !== null) { + return { last, lastReview: { status: "found", id, record: read.record } }; + } + } + return { last, lastReview: { status: "none" } }; +} + +async function readRecord( + recordsDir: string, + id: string, +): Promise<{ ok: true; record: ReadBoardRecord } | { ok: false; reason: string }> { + let value: unknown; + try { + value = JSON.parse(await readFile(join(recordsDir, `${id}.json`), "utf8")); + } catch { + return { ok: false, reason: `the record ${id} could not be read as JSON` }; + } + const version = (value as { record_version?: unknown } | null)?.record_version; + if (typeof version === "number" && !READABLE_VERSIONS.has(version)) { + return { + ok: false, + reason: `the record ${id} is of record_version ${version}, which this basou does not read`, + }; + } + const parsed = readRecordSchema.safeParse(value); + if (!parsed.success || !READABLE_VERSIONS.has(parsed.data.record_version)) { + return { ok: false, reason: `the record ${id} is not in the shape of a record` }; + } + return { ok: true, record: parsed.data }; +} diff --git a/packages/core/src/board/record.test.ts b/packages/core/src/board/record.test.ts index 180ff6a7..4f0df92e 100644 --- a/packages/core/src/board/record.test.ts +++ b/packages/core/src/board/record.test.ts @@ -353,6 +353,19 @@ describe("buildRecord and writeRecord", () => { expect(JSON.parse(await readFile(join(records, name), "utf8"))).toEqual(r); }); + it("does not keep the measurement's diff, which the previous record derives", () => { + const parsed = parseRecordInput(input(), declaration()); + if (!parsed.ok) throw new Error(parsed.errors.join("\n")); + const r = buildRecord({ + declaration: declaration(), + measurement: { digest: DIGEST, diff: { against: "X" } } as unknown as BoardMeasurement, + recordInput: parsed.input, + recordedAt: new Date("2026-10-07T00:00:00.000Z"), + recordedWith: { basou: "0.0.0-test", build: null }, + }); + expect(r.measure).toEqual({ digest: DIGEST }); + }); + it("tries another name rather than replace a record, and leaves no other file", async () => { const records = join(dir, "records"); const first = await writeRecord(records, record()); diff --git a/packages/core/src/board/record.ts b/packages/core/src/board/record.ts index 68b14732..5f0f93f2 100644 --- a/packages/core/src/board/record.ts +++ b/packages/core/src/board/record.ts @@ -313,11 +313,12 @@ export type BoardRecord = { effort: BoardDeclaration["effort"]; }; /** - * What basou measured as it recorded, the digest the judge saw included. - * What the digest leaves out (freshness, the axis, the clock) is as it was - * measured again, not necessarily as the judge saw it. + * What basou measured as it recorded, the digest the judge saw included, + * but not its diff, which the previous record derives. What the digest + * leaves out (freshness, the axis, the clock) is as it was measured again, + * not necessarily as the judge saw it. */ - measure: BoardMeasurement; + measure: Omit; observed: BoardRecordInput["observed"]; cells: BoardRecordInput["cells"]; prose: BoardRecordInput["prose"]; @@ -334,6 +335,7 @@ export function buildRecord(input: { recordedWith: { basou: string; build: string | null }; }): BoardRecord { const { declaration: d, recordInput: r } = input; + const { diff: _diff, ...measure } = input.measurement; return { record_version: BOARD_RECORD_VERSION, recorded_at: input.recordedAt.toISOString(), @@ -348,7 +350,7 @@ export function buildRecord(input: { axis: d.axis, effort: d.effort, }, - measure: input.measurement, + measure, observed: r.observed, cells: r.cells, prose: r.prose, diff --git a/packages/core/src/ids/ulid.ts b/packages/core/src/ids/ulid.ts index a6c61ee6..dd1bc2d4 100644 --- a/packages/core/src/ids/ulid.ts +++ b/packages/core/src/ids/ulid.ts @@ -70,6 +70,11 @@ export function prefixedUlid

(prefix: P): PrefixedId

{ return `${prefix}_${ulid()}` as PrefixedId

; } +/** Whether a string is a ULID of the shape basou generates, with no prefix. */ +export function isUlidBody(value: string): boolean { + return ULID_BODY_REGEX.test(value) && isValidUlid(value); +} + /** * Check whether the given string is a valid prefixed Basou ID. * diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5ccfced8..2595d63d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -89,12 +89,14 @@ export type { BoardAxisReason, BoardAxisTrigger, BoardAxisUnjudged, + BoardCellChange, BoardComponent, BoardComponentChange, BoardComponents, BoardDeclaration, BoardDeclarationContext, BoardDeclarationResult, + BoardDiff, BoardEffort, BoardEffortDay, BoardFreshness, @@ -105,9 +107,13 @@ export type { BoardMeasureKind, BoardMeasurement, BoardMeasureValue, + BoardMethodChange, BoardNotFound, + BoardObservation, + BoardObservedChange, BoardOrderAnomaly, BoardPortfolio, + BoardPreviousRecords, BoardRatioValue, BoardRecord, BoardRecordInput, @@ -116,7 +122,11 @@ export type { BoardReviewGaps, BoardTrack, BoardTrail, + BoardValueChange, + BoardValueOnly, MeasureBoardInput, + PreviousOutcome, + ReadBoardRecord, } from "./board/index.js"; export { BOARD_AXIS_METHOD, @@ -140,11 +150,16 @@ export { boardDigest, buildRecord, byCodePoint, + diffCells, + diffMeasurements, + diffObserved, measureBoard, modelKey, + NO_PREVIOUS_RECORDS, orderAnomalies, parseBoardDeclaration, parseRecordInput, + readPreviousRecords, writeRecord, } from "./board/index.js"; export type { From f2bfde5a1c974d0643d99231c7d383de46e72eb5 Mon Sep 17 00:00:00 2001 From: Takashi Matsuyama Date: Wed, 7 Oct 2026 13:10:54 +0900 Subject: [PATCH 2/3] fix(board): escape what a previous record holds before showing it A cell's state and a method's section are read from the previous record, and were shown as they were written: a record holding a control character could act on the terminal. They now go through displayPath like the rest. Co-Authored-By: Claude Opus 5.5 --- packages/cli/src/commands/board.test.ts | 24 ++++++++++++++++++++++++ packages/cli/src/commands/board.ts | 4 ++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/commands/board.test.ts b/packages/cli/src/commands/board.test.ts index 779e2728..e3e75d86 100644 --- a/packages/cli/src/commands/board.test.ts +++ b/packages/cli/src/commands/board.test.ts @@ -1251,6 +1251,30 @@ describe("basou board measure and record against the previous record", () => { ); }); + 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; + await writeFile(file, JSON.stringify(written)); + const { out } = capture(); + const m = await doRunBoardMeasure({ json: true }, ctx(repo)); + out.length = 0; + await doRunBoardMeasure({}, ctx(repo)); + await doRunBoardRecord( + { dryRun: true }, + { + ...ctx(repo), + readInput: async () => JSON.stringify(judgement(m.digest, ["done", "done"], {})), + }, + ); + const text = out.join("\n"); + 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"); + }); + it("takes a record it cannot read as a gap, not as no record", async () => { const { repo, first } = await recorded(); await writeFile(join(repo, "board", "records", `${first}.json`), "{ not json"); diff --git a/packages/cli/src/commands/board.ts b/packages/cli/src/commands/board.ts index 3945b4d0..ef72b630 100644 --- a/packages/cli/src/commands/board.ts +++ b/packages/cli/src/commands/board.ts @@ -426,7 +426,7 @@ function printRecordText(result: BoardRecordResult): void { lines.push(`Since the previous record ${result.diff.against}:`); for (const c of cells) { lines.push( - ` ${displayPath(c.lane)} ${displayPath(c.stage)}: ${c.before ?? "no cell"} -> ${c.after ?? "no cell"}`, + ` ${displayPath(c.lane)} ${displayPath(c.stage)}: ${displayPath(c.before ?? "no cell")} -> ${displayPath(c.after ?? "no cell")}`, ); } for (const o of observed) { @@ -614,7 +614,7 @@ function diffLines(diff: Omit, more: boolean): string[] { const lines: string[] = []; if (diff.methods.length > 0) { const moved = diff.methods.map( - (m) => `${m.section} ${m.before ?? "none"} -> ${m.after ?? "none"}`, + (m) => `${displayPath(m.section)} ${m.before ?? "none"} -> ${m.after ?? "none"}`, ); lines.push(` methods changed: ${moved.join(", ")}`); } From 14680c68a91d39d5f31704619825f9d9a3b5daf4 Mon Sep 17 00:00:00 2001 From: Takashi Matsuyama Date: Wed, 7 Oct 2026 14:26:51 +0900 Subject: [PATCH 3/3] fix(board): compare only what the digest holds, and judge (a) on what is known The diff no longer compares the axis's not_found entries, which the digest leaves out, and compares values with their keys sorted. When the previous record cannot be read, a component the declaration registers and that was not found still fires trigger (a), and the text summary shows the components measured, the gone ones as not known. basou board record shows what moved right after where the record is, says when the previous record could not be read, and lists what the measurement recorded could not measure, also as not_found with --json. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 9 +++-- packages/cli/src/commands/board.test.ts | 27 +++++++++++++ packages/cli/src/commands/board.ts | 26 +++++++++--- packages/core/src/board/axis.test.ts | 8 +++- packages/core/src/board/axis.ts | 10 ++++- packages/core/src/board/diff.test.ts | 25 ++++++++++++ packages/core/src/board/diff.ts | 22 ++++++++-- packages/core/src/board/measure.test.ts | 51 +++++++++++++++++++++++- packages/core/src/board/measure.ts | 8 +++- packages/core/src/board/previous.test.ts | 8 +++- 10 files changed, 173 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2efbccf4..34034459 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,8 +70,8 @@ All notable changes to **basou** are recorded here. The project follows `.json`. Against it the result also says what moved (`diff`): each value that moved, a number with its change, marked when its section's method changed; the methods that changed; and the values there only now or only - then (the clock, the freshness, the axis, `measured_with` and the daily - rows of the effort are not compared). The text summary puts it first. A + then (the clock, the freshness, the axis and its not_found entries, + `measured_with` and the daily rows of the effort are not compared). The text summary puts it first. A record that cannot be read is not taken as no record: the diff, what the components it alone found and the triggers it bears on are then null or not judged, with a reason under `not_found`. It @@ -140,8 +140,9 @@ All notable changes to **basou** are recorded here. The project follows `records/` that no repo the manifest declares private holds unless `--not-private` is given. It says what moved since the previous record, in the measurement, the cells and the observations, in its text and under - `diff` with `--json`; the record does not hold the diff, which the - previous record derives. `--dry-run` checks where the record would go + `diff` with `--json`, and what the measurement recorded could not measure + (`not_found`); the record does not hold the diff, which the previous + record derives. `--dry-run` checks where the record would go and the input, and measures, but writes nothing. Exit codes: `0` when the record was written (or, with `--dry-run`, everything checked out), `1` when it was refused, saying why and that nothing was written. The README diff --git a/packages/cli/src/commands/board.test.ts b/packages/cli/src/commands/board.test.ts index e3e75d86..72512176 100644 --- a/packages/cli/src/commands/board.test.ts +++ b/packages/cli/src/commands/board.test.ts @@ -938,6 +938,7 @@ describe("basou board record", () => { record: null, dry_run: true, complete: true, + not_found: [], order_anomalies: [], diff: null, }); @@ -1278,6 +1279,31 @@ describe("basou board measure and record against the previous record", () => { it("takes a record it cannot read as a gap, not as no record", async () => { const { repo, first } = await recorded(); await writeFile(join(repo, "board", "records", `${first}.json`), "{ not json"); + // The record says why, in its text and as JSON, before anything else. + capture(); + const measuredNow = await doRunBoardMeasure({ json: true }, ctx(repo)); + vi.restoreAllMocks(); + const said = capture(); + const result = await doRunBoardRecord( + { dryRun: true }, + { + ...ctx(repo), + readInput: async () => JSON.stringify(judgement(measuredNow.digest, ["done"], {})), + }, + ); + expect(result.diff).toBeNull(); + expect(result.not_found).toContainEqual({ + at: "diff", + reason: `the record ${first} could not be read as JSON`, + }); + const recordText = said.out.join("\n").split("\n"); + expect(recordText[1]).toBe( + "The previous record could not be read, so nothing is compared with it.", + ); + expect(recordText[2]).toMatch(/^The measurement recorded is not complete \(\d+\):$/); + expect(recordText).toContain(` diff: the record ${first} could not be read as JSON`); + expect(said.out.join("\n")).not.toContain("not measured"); + vi.restoreAllMocks(); const { out } = capture(); const m = await doRunBoardMeasure({ json: true }, ctx(repo)); expect(m.diff).toBeNull(); @@ -1292,5 +1318,6 @@ describe("basou board measure and record against the previous record", () => { expect(out.join("\n")).toContain( "\n\nThe previous record could not be read (see Not measured).\n", ); + expect(out.join("\n")).toContain("Components:\n 0 found, 0 unacknowledged, gone not known\n"); }); }); diff --git a/packages/cli/src/commands/board.ts b/packages/cli/src/commands/board.ts index ef72b630..302d6a2b 100644 --- a/packages/cli/src/commands/board.ts +++ b/packages/cli/src/commands/board.ts @@ -7,6 +7,7 @@ import { type BoardDeclaration, type BoardDiff, type BoardMeasurement, + type BoardNotFound, type BoardObservation, type BoardObservedChange, type BoardOrderAnomaly, @@ -212,11 +213,13 @@ export type BoardRecordResult = { dry_run: boolean; /** Whether the measurement recorded was complete. */ complete: boolean; + /** What the measurement recorded could not measure, and why. */ + not_found: BoardNotFound[]; order_anomalies: BoardOrderAnomaly[]; /** * What moved since the previous record: in the measurement, the cells and * the observations. Null when there is no previous record, or when it - * cannot be read (the measurement says why under not_found). + * cannot be read (`not_found` then says why, at `diff`). */ diff: { against: string; @@ -326,6 +329,7 @@ async function checkAndRecord( record: written, dry_run: options.dryRun === true, complete: measurement.complete, + not_found: measurement.not_found, order_anomalies: record.order_anomalies, diff, }; @@ -418,9 +422,13 @@ function printRecordText(result: BoardRecordResult): void { ? "Dry run: the record checked out (nothing was written)." : `Recorded ${displayPath(result.record)}`, ]; - if (!result.complete) lines.push("The measurement recorded is not complete."); + // What moved comes first, after where the record is. if (result.diff === null) { - lines.push("Not compared with a previous record."); + lines.push( + result.not_found.some((n) => n.at === "diff") + ? "The previous record could not be read, so nothing is compared with it." + : "Not compared with a previous record.", + ); } else { const { cells, observed } = result.diff; lines.push(`Since the previous record ${result.diff.against}:`); @@ -436,6 +444,12 @@ function printRecordText(result: BoardRecordResult): void { } lines.push(...diffLines(result.diff.measure, cells.length + observed.length > 0)); } + if (!result.complete) { + lines.push(`The measurement recorded is not complete (${result.not_found.length}):`); + for (const missing of result.not_found) { + lines.push(` ${displayPath(missing.at)}: ${displayPath(missing.reason)}`); + } + } if (result.order_anomalies.length > 0) { lines.push(`Order anomalies (${result.order_anomalies.length}):`); for (const a of result.order_anomalies) { @@ -765,16 +779,16 @@ function effortLines(m: BoardMeasurement): string[] { function componentLines(m: BoardMeasurement): string[] { const { found, unacknowledged, gone } = m.components; - if (found === null || unacknowledged === null || gone === null) return [" not measured"]; + if (found === null || unacknowledged === null) return [" not measured"]; const lines = [ - ` ${Object.keys(found).length} found, ${unacknowledged.length} unacknowledged, ${gone.length} gone`, + ` ${Object.keys(found).length} found, ${unacknowledged.length} unacknowledged, ${gone === null ? "gone not known" : `${gone.length} gone`}`, ]; const sorted = Object.entries(found).sort(([a], [b]) => byCodePoint(a, b)); for (const [key, component] of sorted) { const flag = component.status === "unacknowledged" ? " (unacknowledged)" : ""; lines.push(` ${displayPath(key)} ${component.kinds.join(", ")}${flag}`); } - for (const key of gone) lines.push(` ${displayPath(key)} (gone)`); + for (const key of gone ?? []) lines.push(` ${displayPath(key)} (gone)`); return lines; } diff --git a/packages/core/src/board/axis.test.ts b/packages/core/src/board/axis.test.ts index 8cf69924..35a6208b 100644 --- a/packages/core/src/board/axis.test.ts +++ b/packages/core/src/board/axis.test.ts @@ -224,6 +224,12 @@ describe("judgeAxis against the records", () => { }); expect( judged({ components: { ...unknownGone, unacknowledged: ["app/x"] } }).axis.reasons, - ).toEqual([{ trigger: "a", detail: "components: 1 unacknowledged" }]); + ).toEqual([{ trigger: "a", detail: "components: 1 unacknowledged, 0 registered gone" }]); + // What the declaration registers and was not found is known, and fires it. + const registered = judged({ components: unknownGone, declaredGone: ["app/y"] }); + expect(registered.axis.reasons).toEqual([ + { trigger: "a", detail: "components: 0 unacknowledged, 1 registered gone" }, + ]); + expect(registered.axis.review_needed).toBe(true); }); }); diff --git a/packages/core/src/board/axis.ts b/packages/core/src/board/axis.ts index 1d28ec60..a8422d8f 100644 --- a/packages/core/src/board/axis.ts +++ b/packages/core/src/board/axis.ts @@ -70,6 +70,11 @@ export type AxisInput = { today: string | undefined; /** The model that will judge the board, when one is given. */ model?: string | undefined; + /** + * The components the declaration registers that were not found, for (a) + * when `gone` is not known because the previous record cannot be read. + */ + declaredGone?: readonly string[]; /** The last review the records hold (default: there is none). */ recordedReview?: PreviousOutcome<{ date: string; model: string; record: string }>; /** The methods of this measurement. */ @@ -130,9 +135,10 @@ export function judgeAxis(input: AxisInput): { notJudged("a", "the components were not measured"); } else { const changed = kind_changed?.length ?? 0; - if (unacknowledged.length > 0 || (gone?.length ?? 0) > 0 || changed > 0) { + const goneKnown = gone ?? input.declaredGone ?? []; + if (unacknowledged.length > 0 || goneKnown.length > 0 || changed > 0) { const parts = [`${unacknowledged.length} unacknowledged`]; - if (gone !== null) parts.push(`${gone.length} gone`); + parts.push(gone !== null ? `${gone.length} gone` : `${goneKnown.length} registered gone`); if (kind_changed !== null) parts.push(`${changed} with changed kinds`); reasons.push({ trigger: "a", detail: `components: ${parts.join(", ")}` }); } else if (gone === null) { diff --git a/packages/core/src/board/diff.test.ts b/packages/core/src/board/diff.test.ts index b40c8f63..98bd12a3 100644 --- a/packages/core/src/board/diff.test.ts +++ b/packages/core/src/board/diff.test.ts @@ -155,6 +155,27 @@ describe("diffMeasurements", () => { }); }); +describe("diffMeasurements: what the digest leaves out", () => { + it("does not compare the axis's not_found entries, nor the order an object's keys were written in", () => { + const before = measurement({ + not_found: [{ at: "axis.review_needed", reason: "(c) could not be judged" }], + measures: { md: { value: 2, unit: "files" } }, + }); + const after = measurement({ + not_found: [ + { at: "axis.last_review", reason: "not known" }, + { at: "axis", reason: "whole" }, + { at: "axisx", reason: "not the axis's" }, + ], + measures: { md: { unit: "files", value: 2 } }, + }); + const diff = diffMeasurements(before, after, "01M4"); + expect(diff.values).toEqual([]); + expect(diff.removed).toEqual([]); + expect(diff.added).toEqual([{ at: 'not_found["axisx"].reason', value: "not the axis's" }]); + }); +}); + describe("diffCells", () => { it("lists the cells whose state moved, and those there only now or only then", () => { const cell = (lane: string, stage: string, state: string) => ({ lane, stage, state }); @@ -193,5 +214,9 @@ describe("diffObserved", () => { { name: "fresh", before: null, after: { value: true } }, { name: "gone", before: { value: 1 }, after: null }, ]); + const keyed = { v: { value: { x: 1, y: [{ a: 1, b: 2 }] } } }; + const reordered = { v: { value: { y: [{ b: 2, a: 1 }], x: 1 } } }; + expect(diffObserved(keyed, reordered)).toEqual([]); + expect(diffObserved(keyed, { v: { value: { x: 1, y: [{ a: 1, b: 3 }] } } })).toHaveLength(1); }); }); diff --git a/packages/core/src/board/diff.ts b/packages/core/src/board/diff.ts index 6f97ce5e..26b03f0c 100644 --- a/packages/core/src/board/diff.ts +++ b/packages/core/src/board/diff.ts @@ -21,9 +21,10 @@ export type BoardMethodChange = { section: string; before: number | null; after: /** * What moved since the previous record, value by value. Compared are the - * values the digest holds but `measured_with`, the methods (compared on their - * own), `effort.daily`, whose rows only grow, and `components.kind_changed`, - * already a change since the previous record. A list whose entries name + * values the digest holds (so not the axis's not_found entries either) but + * `measured_with`, the methods (compared on their own), `effort.daily`, whose + * rows only grow, and `components.kind_changed`, already a change since the + * previous record. Values are equal when their JSON is, keys sorted. A list whose entries name * themselves is compared entry by entry (`repos` by path, open tracks by id, * `not_found` by where), the unacknowledged and gone components as sets, and * any other list whole. @@ -81,6 +82,8 @@ function leaves(measurement: unknown): Map { for (const entry of value) { if (!isRecord(entry)) continue; const { [field]: name, ...rest } = entry; + // As the digest leaves them out: they come and go with the axis. + if (rule === "not_found" && /^axis(\.|$)/.test(String(name))) continue; walk(rest, `${rule}[]`, `${shown}[${JSON.stringify(String(name))}]`, section); } } else if (SETS.has(rule)) { @@ -114,7 +117,18 @@ function methodsOf(measurement: unknown): Record { return isRecord(measurement) && isRecord(measurement.methods) ? measurement.methods : {}; } -const same = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b); +// JSON with every object's keys sorted, so that the order they were written +// in does not make two values differ. +function canonical(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; + if (isRecord(value)) { + const keys = Object.keys(value).sort(); + return `{${keys.map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`).join(",")}}`; + } + return JSON.stringify(value) ?? "null"; +} + +const same = (a: unknown, b: unknown): boolean => canonical(a) === canonical(b); /** What moved from the previous record's measurement to this one. */ export function diffMeasurements(before: unknown, after: unknown, against: string): BoardDiff { diff --git a/packages/core/src/board/measure.test.ts b/packages/core/src/board/measure.test.ts index e9f6ce9c..1b51b61b 100644 --- a/packages/core/src/board/measure.test.ts +++ b/packages/core/src/board/measure.test.ts @@ -13,7 +13,7 @@ import { } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { stringify } from "yaml"; import { chainEvents } from "../events/chain.js"; import type { Event } from "../schemas/event.schema.js"; @@ -4046,4 +4046,53 @@ describe("measureBoard: against the previous record", () => { ]); expect(m.not_found).toContainEqual({ at: "diff", reason }); }); + + it("fires (a) on a registered component not found, whatever the unreadable record would add", async () => { + await repo("app", { "package.json": "{}\n" }); + const reason = `the record ${ID} could not be read as JSON`; + const m = await against( + declare( + [], + { components: { app: { lane: "-", note: "x" }, "app/gone": { lane: "-", note: "y" } } }, + ["app"], + ), + { last: { status: "unreadable", reason }, lastReview: { status: "none" } }, + ["app"], + ); + expect(m.components.gone).toBeNull(); + expect(m.axis.reasons[0]).toEqual({ + trigger: "a", + detail: "components: 0 unacknowledged, 1 registered gone", + }); + expect(m.axis.review_needed).toBe(true); + }); + + it("dates a review on record by its day in UTC when no time zone can be named", async () => { + const declaration = declare([], { effort: { start: "2026-04-28" } }); + const first = await measure(declaration); + const reviewed = recordOf(first, { + recorded_at: "2026-10-04T23:30:00.000Z", + axis_review: { triggers: ["d"], summary: "s" }, + }); + // This host's zone, as Intl names it under an unknown TZ: none. + const resolved = Intl.DateTimeFormat.prototype.resolvedOptions; + vi.spyOn(Intl.DateTimeFormat.prototype, "resolvedOptions").mockImplementation(function ( + this: Intl.DateTimeFormat, + ) { + return { ...resolved.call(this), timeZone: undefined as unknown as string }; + }); + try { + const m = await against( + declaration, + { + last: { status: "found", id: ID, record: reviewed }, + lastReview: { status: "found", id: ID, record: reviewed }, + }, + [], + ); + expect(m.axis.last_review).toMatchObject({ date: "2026-10-04", from: "record" }); + } finally { + vi.restoreAllMocks(); + } + }); }); diff --git a/packages/core/src/board/measure.ts b/packages/core/src/board/measure.ts index f77b999d..1282befd 100644 --- a/packages/core/src/board/measure.ts +++ b/packages/core/src/board/measure.ts @@ -280,9 +280,14 @@ export async function measureBoard(input: MeasureBoardInput): Promise !Object.hasOwn(found, key)), today: todayIn(declaration.effort.time_zone, input.now), model: input.model, // The day of a review on record, counted in the same calendar as (b). @@ -374,7 +379,8 @@ export function boardDigest(measurement: object): string { // The not_found entries but the axis's, which come and go with the day and // the model as the axis does. Each of them follows a gap another section -// already reports, so `complete` stays as it is. +// reports or a record that cannot be read, neither of which moves with the +// day or the model, so `complete`, which counts them, does not move either. function notOfAxis(notFound: unknown): unknown { if (!Array.isArray(notFound)) return notFound; return notFound.filter((entry) => { diff --git a/packages/core/src/board/previous.test.ts b/packages/core/src/board/previous.test.ts index 958593eb..803bf8f5 100644 --- a/packages/core/src/board/previous.test.ts +++ b/packages/core/src/board/previous.test.ts @@ -123,9 +123,13 @@ describe("readPreviousRecords", () => { await writeFile(join(elsewhere, `${A}.json`), JSON.stringify(record())); await symlink(elsewhere, records); const reason = "the records/ beside the board is not a directory (a symlink or a file)"; - expect(await readPreviousRecords(records)).toEqual({ + const unreadable = { last: { status: "unreadable", reason }, lastReview: { status: "unreadable", reason }, - }); + }; + expect(await readPreviousRecords(records)).toEqual(unreadable); + await rm(records); + await writeFile(records, "not a directory\n"); + expect(await readPreviousRecords(records)).toEqual(unreadable); }); });