Skip to content
Merged
42 changes: 42 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,48 @@ All notable changes to **basou** are recorded here. The project follows

## Unreleased

### Added

- **`basou board measure` and `basou board record` say where the declaration
changed since the previous record**, in the text they print without
`--json`: under `Since the previous record <ULID>:`, one line for each
place where board.yaml differs from the declaration that record holds,
named as a diff names a value (`declaration stages.03.look: changed`,
`declaration observe["site.top"]: added`, `declaration lanes: the order
changed`). A changed `look` or `notes` moved the digest, and nothing said
where. Both declarations are compared as a record holds them, so the first
record after a board moves to version 3 lists nothing for that alone, and
what the diff of the measurement shows already (the title, a measure's
unit and lanes, a ratio's numerator and denominator, the effort's start, a
time zone declared in both, a measure or a ratio added or removed) is not
listed again. The JSON of both commands is unchanged. The guide asks for
an axis review's summary from these lines.
- **The board page of `basou view` reads each observation outside as its
kind does, and marks what moved.** An `http` value in the shape its
declaration gives it is drawn as its parts: the status, the redirects,
where it landed, each word found or not, the count and the capture, or
that the body was not read when the status is not 2xx. A value of another
shape, as a record written before 0.72.0 may hold, is drawn as before.
Under each name are its kind and what it looks at, and under each row its
notes, from the declaration the record holds. An observation made whose
value or error moved since the previous record (as `basou board record`
compares them) is marked, with the previous record's value drawn by that
record's declaration; one whose type alone moved (`"12"` and `12`) is drawn
as JSON. One not made is drawn as before, as not observed with the previous
record's value, unmarked. Nothing is marked when there is no previous
record, or it cannot be read (a line under the table says so) or compared.
- **The board page draws every measure in a table of its own**, with its
unit (none for a string, and the paths of a `file_paths` by how many), the
lanes it is drawn in, and, for a number, how much it moved since the
previous record. A measure with no lane was drawn nowhere on the page.
- **A record's board page draws the repos and the components it measured**,
as the page measured on the spot draws them: the repos by the record's
version (one of version 1 or 2 measured no base, commits ahead or fetch
time, and its `behind_main` is drawn as behind), and the components by
the lanes the registry counts them in, then those counted in no lane with
their notes, those found and not registered, and those gone, with a
change of kinds marked.

### Changed

- **`basou board record` (and `--dry-run`) refuses an observation's value
Expand Down
46 changes: 42 additions & 4 deletions docs/spec/board.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,8 +219,9 @@ Every measure has:

A `lane` is read as a list whatever the version, so a record holds a list
(`lane: cli` is `["cli"]`). A measure with no lane is drawn in no lane's
section, and is still kept in the records, compared with the previous record
and usable by a ratio or a total.
section. Every measure, with a lane or without, is drawn in the board page's
table of measures (from basou 0.72.0), kept in the records, compared with the
previous record and usable by a ratio or a total.

| Kind | Since | Reads | Value |
|---|---|---|---|
Expand Down Expand Up @@ -499,7 +500,7 @@ Every observation has:
|---|---|---|---|
| `key` | yes | 2 | an id, or from version 3 a dotted id |
| `kind` | yes | 2 | one of the kinds below |
| `notes` | no | 3 | a list of non-empty strings: what to mind when making it; the guide prints them under its commands |
| `notes` | no | 3 | a list of non-empty strings: what to mind when making it; the guide prints them under its commands, and the board page under the observation |

The values of an observation are put into commands, each as one
single-quoted word, so each is held to a shape that cannot end its quotation
Expand Down Expand Up @@ -857,6 +858,11 @@ no such cell); and `observed` lists each observation whose value or error
moved, as `{ name, before, after }`, each `{ value, error }` (no `error` when
there is none), or null where there was no such observation.

Without `--json`, the text says where the record is, then what moved since the
previous record: the cells, the observations, the measurement's `diff`, and,
from basou 0.72.0, where the declaration changed ([5.12](#512-diff)), which
the JSON does not hold.

## 4. A record

`records/<ULID>.json`, written whole (to a file of another name, then linked
Expand Down Expand Up @@ -1190,7 +1196,9 @@ no previous record, and null with an entry at `diff` when it cannot be read.
- **Compared** are the values the digest covers, except `measured_with`,
`methods` (compared on their own), `effort.daily` (its rows only grow) and
`components.kind_changed` (already a change since the previous record).
The declaration, which the digest also covers, is not compared.
The declaration, which the digest also covers, is not compared in `diff`:
the JSON holds no change of it. The text `basou board measure` and `basou
board record` print without `--json` lists it (see below).
- Values are equal when their JSON is, keys sorted. A list whose entries name
themselves is compared entry by entry: `repos` by `path`,
`trail.tracks_open` by `id`, `not_found` by `at`. `components.unacknowledged`
Expand All @@ -1200,6 +1208,36 @@ no previous record, and null with an entry at `diff` when it cannot be read.
- A measure's lane held as one id, as a record of version 1 or 2 holds it, is
equal to the list of that one.

**Where the declaration changed, in the text only.** From basou 0.72.0, the
text `basou board measure` and `basou board record` print without `--json`
lists, under `Since the previous record <ULID>:` and after what moved in the
measurement, each place where the declaration the board is measured by now
differs from the one the previous record holds, one line each:
`declaration <where>: added`, `removed` or `changed`, and
`declaration lanes: the order changed`. No key of the JSON holds these.

- `<where>` names the place as `at` does: `stages.03.look`,
`lanes["web"].notes`, `observe["site.top"]` (an observation added or
removed), `measures["readme.latest"].include`, `components["app/db"].lane`.
- Both declarations are compared as a record holds them
([4.2](#42-declaration)): with their defaults filled in, and a record of
version 1 with `observe` as `[]`. A measure's lane is not listed (see
below), so the first record after a board moves to version 3 lists nothing
for its lanes held as one id.
- The lanes, the measures and the ratios are compared by id, and the
observations by key, each key of an entry on its own; the lanes' order is
one line of its own. Any other list (`notes`, `look`, `words`, `include`,
`effort.milestones`) is compared whole.
- Not listed again is what `values`, `added` and `removed` show already: the
title, a measure's `unit` and `lane`, a ratio's `numerator` and
`denominator`, `effort.start`, `effort.time_zone` when both declare one,
and a measure or a ratio added or removed (its values are added or
removed).
- When the previous record holds no declaration that is an object, or one
nested too deep to compare (only a record written by hand can), one line
says it is not compared: `declaration: not compared (...)`. The record is
still written.

### 5.13 The digest

`digest` is `sha256:` and the hex SHA-256 of a JSON text: the measurement
Expand Down
164 changes: 164 additions & 0 deletions packages/cli/src/commands/board.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1760,12 +1760,125 @@ describe("basou board measure and record against the previous record", () => {
);
});

it("says in its text, not in its JSON, where the declaration changed since the previous record", async () => {
const { repo, first } = await recorded();
// The board of version 1 the record holds, read again as version 3 with
// what the measurement holds changed (the title, a unit, a lane) and
// what it does not (a stage's look, a lane's notes, the axis, an observation).
await placeBoard(
repo,
JSON.stringify({
board_version: 3,
title: "Board v3",
stages: { ...STAGES, "03": { meaning: "stage 03", look: ["the tests"] } },
lanes: [{ id: "core", name: "Core", notes: ["mind it"] }],
measures: [{ ...MD, unit: "docs", lane: ["core"] }],
axis: { version: 2, review_due_days: 60 },
effort: { start: "2026-04-28" },
observe: [{ key: "npm", kind: "npm_version", package: "basou" }],
}),
);
const { out } = capture();
const m = await doRunBoardMeasure({ json: true }, ctx(repo));
expect(Object.keys(m.diff ?? {})).toEqual(["against", "values", "methods", "added", "removed"]);
expect(JSON.stringify(m.diff)).not.toContain("look");
// What the measurement's diff shows of the declaration, which the text does not repeat.
expect(m.diff?.values.map((v) => v.at)).toEqual(
expect.arrayContaining(["board_version", "title", "measures.md.unit"]),
);
expect(m.diff?.added.map((v) => v.at)).toContain("measures.md.lane");
out.length = 0;
await doRunBoardMeasure({}, ctx(repo));
const declared = [
" declaration stages.03.look: added",
' declaration lanes["core"].notes: added',
" declaration axis.version: changed",
' declaration observe["npm"]: added',
];
const text = out.join("\n");
expect(text).toContain(`Since the previous record ${first}:\n`);
expect(text).toContain(` title: "Board" -> "Board v3"\n`);
expect(text).toContain(`${declared.join("\n")}\n\nRepos:`);
expect(text.match(/^ {2}declaration /gm)).toHaveLength(declared.length);
// The record says the same in its text, and nothing of it in its JSON.
out.length = 0;
const input = JSON.stringify(
judgement(m.digest, ["done", "part"], {
npm: { value: "1.0.0", observed_at: "2026-10-05", source: "registry" },
}),
);
const dry = await doRunBoardRecord(
{ dryRun: true },
{ ...ctx(repo), readInput: async () => input },
);
expect(Object.keys(dry.diff ?? {})).toEqual(["against", "measure", "cells", "observed"]);
expect(JSON.stringify(dry)).not.toContain("look");
// After what moved in the measurement, as the last of what moved.
expect(out.join("\n")).toContain(` + measures.md.lane: ["core"]\n${declared.join("\n")}`);
out.length = 0;
await doRunBoardRecord(
{ dryRun: true, json: true },
{ ...ctx(repo), readInput: async () => input },
);
expect(out.join("\n")).not.toContain("declaration ");
// With nothing else moved, the declaration alone is said, not "nothing
// moved". The records are ignored, so a record written is no file more.
await writeFile(join(repo, ".gitignore"), "board/records/\n.gitignore\n");
const board = (look: string[], lanes: string[]) =>
JSON.stringify({
board_version: 3,
title: "Board v3",
stages: { ...STAGES, "03": { meaning: "stage 03", look } },
lanes: lanes.map((id) => ({ id, name: id })),
measures: [{ ...MD, unit: "docs", lane: ["core"] }],
axis: { version: 2, review_due_days: 60 },
effort: { start: "2026-04-28" },
observe: [{ key: "npm", kind: "npm_version", package: "basou" }],
});
const both = (digest: string) =>
JSON.stringify({
...judgement(digest, [], {
npm: { value: "1.0.0", observed_at: "2026-10-05", source: "registry" },
}),
cells: ["core", "docs"].flatMap((lane) =>
["01", "02", "03", "04", "05", "06"].map((stage) => ({ lane, stage, state: "none" })),
),
});
await placeBoard(repo, board(["the tests"], ["core", "docs"]));
const before = await doRunBoardMeasure({ json: true }, ctx(repo));
await doRunBoardRecord({}, { ...ctx(repo), readInput: async () => both(before.digest) });
await placeBoard(repo, board(["the tests", "the docs"], ["docs", "core"]));
const after = await doRunBoardMeasure({ json: true }, ctx(repo));
expect(after.diff).toMatchObject({ values: [], added: [], removed: [], methods: [] });
const alone = " declaration stages.03.look: changed\n declaration lanes: the order changed";
out.length = 0;
await doRunBoardMeasure({}, ctx(repo));
const only = out.join("\n");
expect(only).toMatch(/Since the previous record [0-9A-Z]{26}:\n/);
expect(only).toContain(`:\n${alone}\n\nRepos:`);
expect(only).not.toContain("nothing moved");
// The record says the same, the cells and the observations unmoved.
out.length = 0;
await doRunBoardRecord(
{ dryRun: true },
{ ...ctx(repo), readInput: async () => both(after.digest) },
);
const recordText = out.join("\n");
expect(recordText).toMatch(/\nSince the previous record [0-9A-Z]{26}:\n/);
expect(recordText.endsWith(`:\n${alone}`)).toBe(true);
expect(recordText).not.toContain("nothing moved");
});

it("shows what a previous record holds with its control characters escaped", async () => {
const { repo, first } = await recorded();
const file = join(repo, "board", "records", `${first}.json`);
const written = JSON.parse(await readFile(file, "utf8"));
written.cells[1].state = `part${ESC}[31m`;
written.measure.methods[`evil${ESC}[2J`] = 1;
written.declaration.components[`app/evil${String.fromCodePoint(0x2028)}${ESC}[2J`] = {
lane: "-",
note: "n",
};
await writeFile(file, JSON.stringify(written));
const { out } = capture();
const m = await doRunBoardMeasure({ json: true }, ctx(repo));
Expand All @@ -1782,6 +1895,57 @@ describe("basou board measure and record against the previous record", () => {
expect(text).not.toContain(ESC);
expect(text).toContain(" core 02: part\\x1b[31m -> done\n");
expect(text).toContain("methods changed: evil\\x1b[2J 1 -> none");
expect(text).toContain(' declaration components["app/evil\\u2028\\u001b[2J"]: removed\n');
expect(text).not.toContain(String.fromCodePoint(0x2028));
// A declaration that is not an object is not compared, and says so.
written.declaration = "none";
await writeFile(file, JSON.stringify(written));
out.length = 0;
await doRunBoardMeasure({}, ctx(repo));
expect(out.join("\n")).toContain(
"\n declaration: not compared (the previous record holds no declaration basou can compare with)\n",
);
});

it("records, saying the declaration was not compared, when the previous record's is too deep to compare", async () => {
const { repo, first } = await recorded();
const records = join(repo, "board", "records");
const file = join(records, `${first}.json`);
const written = JSON.parse(await readFile(file, "utf8"));
written.declaration.lanes[0].notes = "DEEP";
const depth = 100_000;
const deep = `${"[".repeat(depth)}"x"${"]".repeat(depth)}`;
await writeFile(file, JSON.stringify(written).replace('"DEEP"', deep));
// The board declares the lane's notes, so the two are compared.
const declared = JSON.parse(boardYaml([MD]));
declared.lanes[0].notes = ["mind it"];
await placeBoard(repo, JSON.stringify(declared));
const notCompared =
" declaration: not compared (the previous record holds no declaration basou can compare with)";
const { out } = capture();
const m = await doRunBoardMeasure({ json: true }, ctx(repo));
out.length = 0;
await doRunBoardMeasure({}, ctx(repo));
expect(out.join("\n")).toContain(`${notCompared}\n`);
const input = JSON.stringify(judgement(m.digest, ["done", "part"], {}));
// The record is written and said to be, with its text.
out.length = 0;
const result = await doRunBoardRecord({}, { ...ctx(repo), readInput: async () => input });
expect(result.record).not.toBeNull();
expect(out.join("\n")).toContain(notCompared);
expect(out.join("\n")).not.toContain("Nothing was written");
expect(await readdir(records)).toHaveLength(2);
expect(process.exitCode ?? 0).toBe(0);
// And with --json, which holds none of it.
await rm(join(records, `${result.record?.split("/").at(-1)}`));
out.length = 0;
const json = await doRunBoardRecord(
{ json: true },
{ ...ctx(repo), readInput: async () => input },
);
expect(json.record).not.toBeNull();
expect(JSON.parse(out.join("\n")).record).toBe(json.record);
expect(await readdir(records)).toHaveLength(2);
});

it("takes a record it cannot read as a gap, not as no record", async () => {
Expand Down
Loading
Loading