Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,23 @@ All notable changes to **basou** are recorded here. The project follows
`board_version: 3`, experimental),** in the shape of a stage's or a lane's
`notes`. `basou board guide` prints them under the observation's commands,
and a record keeps them with the declaration it holds.
- **A board can measure the name of a file and the paths of files
(`file_name` and `file_paths`, from `board_version: 3`, experimental).**
Both take a `repo`, `include`, `exclude` and `at` as a `file_count` does,
and are not measured where it is not (a fixed directory with nothing under
it, a symlink that leaves the repository). A `file_name` picks the first or
the last of the files matched (`pick`), in the byte order of their paths in
the repository, as git orders them, and its value is that file's name.
With a `pattern`, it picks only among the files whose name the pattern
matches, and the value is what the pattern's `group` holds (1 when it
names none; a `group` without a `pattern` is refused). No file to pick is
a null with that as the reason: a string has no zero. A `file_paths`
value is the list of the paths matched, in byte order, up to 1000; more
is a null with that as the reason. A ratio takes neither. The difference
from the previous record lists each path that came in or went out when
both records hold a list, and the list whole when only one does. The
`Measures:` section of `basou board measure` gives the list as a count,
and the board page draws it as a count with the paths folded under it.

### Changed

Expand Down
38 changes: 38 additions & 0 deletions packages/cli/src/commands/board.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,44 @@ describe("basou board measure", () => {
expect(text).toMatch(/Digest: sha256:[0-9a-f]{64}/);
});

it("prints the paths of a file_paths measure as a count, and gives them in --json", async () => {
const repo = await workspace([{ path: ".", visibility: "private" }]);
const board = JSON.parse(
boardYaml([
{
id: "paths",
kind: "file_paths",
repo: ".",
include: ["*.md"],
unit: "files",
lane: "core",
},
{
id: "latest",
kind: "file_name",
repo: ".",
include: ["*.md"],
pick: "last",
unit: "name",
},
]),
);
board.board_version = 3;
await placeBoard(repo, JSON.stringify(board));
const { out } = capture();
await runBoardMeasure({}, ctx(repo));
const text = out.join("\n");
expect(text).toContain("\n paths 2 files (paths in --json) [core]\n");
expect(text).toContain("\n latest README.md name\n");
out.length = 0;
await runBoardMeasure({ json: true }, ctx(repo));
expect(JSON.parse(out.join("\n")).measures.paths).toEqual({
value: ["NOTES.md", "README.md"],
unit: "files",
lane: "core",
});
});

it("prints each repo in the summary, saying what a null means", async () => {
const repo = await workspace([{ path: ".", visibility: "private" }, { path: "../gone" }]);
await placeBoard(repo, boardYaml([]));
Expand Down
8 changes: 6 additions & 2 deletions packages/cli/src/commands/board.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1089,9 +1089,12 @@ function diffLines(diff: Omit<BoardDiff, "against">, more: boolean): string[] {
return lines;
}

function shownValue(value: number | string | null): string {
// A value as a line shows it: the paths of a file_paths measure by how many
// there are (--json lists them).
function shownValue(value: number | string | string[] | null): string {
if (value === null) return "not measured";
if (typeof value === "number") return String(Math.round(value * 10000) / 10000);
if (Array.isArray(value)) return String(value.length);
return displayPath(value);
}

Expand Down Expand Up @@ -1269,8 +1272,9 @@ function printMeasurementText(m: BoardMeasurement, hasRecords: boolean): void {
lines.push("", "Measures:");
for (const [id, measure] of measures) {
const unit = measure.value === null ? "" : ` ${displayPath(measure.unit)}`;
const listed = Array.isArray(measure.value) ? " (paths in --json)" : "";
const lane = measure.lane === undefined ? "" : ` [${measure.lane}]`;
lines.push(` ${id.padEnd(width)} ${shownValue(measure.value)}${unit}${lane}`);
lines.push(` ${id.padEnd(width)} ${shownValue(measure.value)}${unit}${listed}${lane}`);
}
}
const ratios = Object.entries(m.ratios);
Expand Down
33 changes: 33 additions & 0 deletions packages/cli/src/commands/view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1508,6 +1508,39 @@ describe("basou view: the board page", () => {
expect(none.map((o) => [o.attrs.value, o.disabled])).toEqual([["", true]]);
});

it("draws the paths of a file_paths measure as a count, with the paths folded under it", () => {
const { call } = drawing(["lanes"]);
const drawn = call("lanes", {
lanes: [
{
id: "core",
name: "Core",
now: null,
attention: [],
prose: null,
measures: [
{ id: "latest", value: "20260401", unit: "name" },
{ id: "migrations", value: ["db/a.sql", "db/<b>.sql"], unit: "files" },
{ id: "gone", value: null, unit: "files" },
],
flags: { live: false, blocked: false, unverified: false },
},
],
});
const items = drawn.find((n) => n.tag === "li" && n.find((c) => c.tag === "li").length === 1);
expect(items.map((n) => n.text())).toEqual([
"latest: 20260401 name",
"db/a.sql",
"db/<b>.sql",
"gone: not measured",
]);
const [details] = drawn.find((n) => n.tag === "details");
expect(details?.attrs.open).toBeUndefined();
expect(details?.find((n) => n.tag === "summary").map((n) => n.text())).toEqual([
"migrations: 2 files",
]);
});

it("marks a lane's marks as reported, and leaves out of the period what was not measured", () => {
const { call } = drawing(["lanes", "effort", "footnotes"]);
const lane = (flags: Record<string, boolean>) => ({
Expand Down
7 changes: 7 additions & 0 deletions packages/cli/src/lib/board-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -639,6 +639,13 @@ export const BOARD_HTML = `<!doctype html>
if (lane.measures.length > 0) {
body.push(el('div', { class: 'muted', text: S.lane.measures }));
body.push(el('ul', null, lane.measures.map(function (m) {
// The paths of a file_paths measure: how many, with the list folded.
if (Array.isArray(m.value)) {
return el('li', null, [el('details', null, [
el('summary', { text: m.id + ': ' + num(m.value.length) + ' ' + m.unit }),
el('ul', null, m.value.map(function (p) { return el('li', { text: p }); }))
])]);
}
return el('li', { text: m.id + ': ' + num(m.value) + (m.value === null ? '' : ' ' + m.unit) });
})));
}
Expand Down
138 changes: 138 additions & 0 deletions packages/core/src/board/declaration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1807,3 +1807,141 @@ describe("parseBoardDeclaration: board_version 3, github_check and an observatio
}
});
});

// A version 3 board whose only measure is `measure`.
function withMeasureV3(measure: Record<string, unknown>): Record<string, unknown> {
return { ...withMeasure(measure), board_version: 3 };
}

const LATEST = {
id: "latest_migration",
kind: "file_name",
repo: "../basou",
include: ["db/migrations/*.sql"],
pick: "last",
unit: "name",
};

const MIGRATIONS = {
id: "migrations",
kind: "file_paths",
repo: "../basou",
include: ["db/migrations/*.sql"],
unit: "files",
};

describe("parseBoardDeclaration: board_version 3, file_name and file_paths", () => {
it("accepts a file_name, with no group without a pattern and group 1 by default with one", () => {
const bare = parse(withMeasureV3(LATEST));
if (!bare.ok) throw new Error(bare.errors.join("\n"));
expect(bare.declaration.measures).toEqual([{ ...LATEST, at: "worktree" }]);
expect(Object.hasOwn(bare.declaration.measures[0] as object, "group")).toBe(false);

const dated = parse(
withMeasureV3({ ...LATEST, pick: "first", pattern: "^(\\d{8})_", exclude: ["**/x.sql"] }),
);
if (!dated.ok) throw new Error(dated.errors.join("\n"));
expect(dated.declaration.measures[0]).toEqual({
...LATEST,
pick: "first",
pattern: "^(\\d{8})_",
exclude: ["**/x.sql"],
at: "worktree",
group: 1,
});

for (const group of [0, 2]) {
const named = parse(
withMeasureV3({ ...LATEST, pattern: "^(\\d{4})(\\d{4})_", group, at: "main" }),
);
if (!named.ok) throw new Error(named.errors.join("\n"));
expect(named.declaration.measures[0]).toMatchObject({ group, at: "main" });
}
});

it("accepts a file_paths, with a lane", () => {
const result = parse(withMeasureV3({ ...MIGRATIONS, lane: "cockpit", at: "v1.0" }));
if (!result.ok) throw new Error(result.errors.join("\n"));
expect(result.declaration.measures).toEqual([{ ...MIGRATIONS, lane: "cockpit", at: "v1.0" }]);
});

it("refuses a file_name with no pick or another, a group with no pattern or past its groups, and what it does not take", () => {
const at = (over: Record<string, unknown>) =>
errorsOf(parse(withMeasureV3({ ...LATEST, ...over })));
expect(at({ pick: undefined })).toEqual(["measures[0].pick: must be first or last"]);
expect(at({ pick: "middle" })).toEqual(["measures[0].pick: must be first or last"]);
expect(at({ group: 1 })).toEqual([
"measures[0].group: a file_name takes a group only with a pattern",
]);
expect(at({ pattern: "^(\\d{8})_", group: 2 })).toEqual([
"measures[0].group: the pattern has 1 capturing group, so group 2 does not exist",
]);
expect(at({ pattern: "^\\d{8}_" })).toEqual([
"measures[0].group: the pattern has 0 capturing groups, so group 1 does not exist",
]);
expect(at({ pattern: "(" })[0]).toMatch(
/^measures\[0\]\.pattern: is not a valid regular expression with the 'u' flag/,
);
expect(at({ group: -1, pattern: "(a)" })[0]).toMatch(/^measures\[0\]\.group: /);
expect(at({ include: undefined })).toEqual([
"measures[0].include: Invalid input: expected array, received undefined",
]);
expect(at({ file: "a.sql" })).toEqual(["measures[0]: unknown key 'file'"]);
expect(at({ repo: "../elsewhere" })).toEqual([
"measures[0].repo: '../elsewhere' is not a repo path in the manifest (the manifest declares '.', '../basou')",
]);
});

it("refuses what a file_paths does not take, and a repo the manifest does not declare", () => {
const at = (over: Record<string, unknown>) =>
errorsOf(parse(withMeasureV3({ ...MIGRATIONS, ...over })));
expect(at({ pick: "last" })).toEqual(["measures[0]: unknown key 'pick'"]);
expect(at({ pattern: "x" })).toEqual(["measures[0]: unknown key 'pattern'"]);
expect(at({ include: [] })).toEqual(["measures[0].include: must list at least one path"]);
expect(at({ repo: "../elsewhere" })).toEqual([
"measures[0].repo: '../elsewhere' is not a repo path in the manifest (the manifest declares '.', '../basou')",
]);
});

it("refuses a ratio of a file_name or a file_paths, as of a regex_capture", () => {
const doc = withMeasureV3(LATEST);
doc.measures = [
LATEST,
MIGRATIONS,
{
id: "src_lines",
kind: "line_count",
repo: "../basou",
include: ["**/*.ts"],
unit: "lines",
},
];
doc.ratios = [
{ id: "a", label: "a", numerator: "latest_migration", denominator: "src_lines" },
{ id: "b", label: "b", numerator: "src_lines", denominator: "migrations" },
];
expect(errorsOf(parse(doc))).toEqual([
"ratios[0].numerator: 'latest_migration' is a file_name, which is not a number",
"ratios[1].denominator: 'migrations' is a file_paths, which is not a number",
]);
});

it("names file_name and file_paths among the kinds of measure a version 3 board reads", () => {
expect(errorsOf(parse(withMeasureV3({ ...MIGRATIONS, kind: "file_names" })))).toEqual([
"measures[0].kind: Invalid discriminator value. Expected 'file_count' | 'dir_count' | 'line_count' | 'match_count' | 'regex_capture' | 'json_length' | 'file_name' | 'file_paths' | 'trail_count'",
]);
});

it("says that version 3 reads file_name and file_paths when version 2 or 1 has them", () => {
for (const version of [1, 2]) {
const doc = version === 1 ? board() : boardV2();
doc.measures = [LATEST, MIGRATIONS, { ...LATEST, id: "c", pick: "middle" }];
doc.ratios = [];
expect(errorsOf(parse(doc))).toEqual([
`measures[0].kind: 'file_name' is read from board_version 3 on; this file is board_version ${version}`,
`measures[1].kind: 'file_paths' is read from board_version 3 on; this file is board_version ${version}`,
`measures[2].kind: 'file_name' is read from board_version 3 on; this file is board_version ${version}`,
]);
}
});
});
Loading
Loading