From 11fa8aeb3d7a8607358320a06d9c828f38078c36 Mon Sep 17 00:00:00 2001 From: Takashi Matsuyama Date: Sat, 17 Oct 2026 17:13:59 +0900 Subject: [PATCH 1/3] basou: Measure the name of a file and the paths of files on a board - board.yaml: board_version 3 adds two kinds of measure over a repo's files, with include, exclude and at as a file_count has them, and not measured where a file_count is not. file_name picks the first or the last of the files matched (pick), in the byte order of their paths, 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, and a group without a pattern is refused). file_paths gives the paths matched, in byte order, up to 1000; more is not measured. No file to pick is not measured: a string has no zero. A ratio takes neither, and a board of version 1 or 2 is told that version 3 reads them. - The value of a measure may be a list of strings. The difference from the previous record lists each path that came in or went out when both hold a list, and the value whole otherwise. - board measure prints a list as a count, and --json gives the paths; the board page draws it in its lane as a count, with the paths folded under it, and reads a record whose measure holds one. - board guide says where to read the paths and how the difference gives them; board init's comment on measures names the two kinds. --- CHANGELOG.md | 17 ++ packages/cli/src/commands/board.test.ts | 38 +++ packages/cli/src/commands/board.ts | 8 +- packages/cli/src/commands/view.test.ts | 33 +++ packages/cli/src/lib/board-ui.ts | 7 + packages/core/src/board/declaration.test.ts | 138 ++++++++++ packages/core/src/board/declaration.ts | 108 +++++++- packages/core/src/board/diff.test.ts | 59 ++++ packages/core/src/board/diff.ts | 35 ++- packages/core/src/board/guide.test.ts | 6 + packages/core/src/board/guide.ts | 1 + packages/core/src/board/init.test.ts | 15 + packages/core/src/board/measure.test.ts | 286 ++++++++++++++++++++ packages/core/src/board/measure.ts | 56 +++- packages/core/src/board/page.test.ts | 35 +++ packages/core/src/board/page.ts | 9 +- packages/core/src/board/record.ts | 4 +- packages/core/src/lib/view-strings.ts | 4 +- 18 files changed, 828 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 82d5c385..96802740 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. `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 diff --git a/packages/cli/src/commands/board.test.ts b/packages/cli/src/commands/board.test.ts index b7b6f811..921be732 100644 --- a/packages/cli/src/commands/board.test.ts +++ b/packages/cli/src/commands/board.test.ts @@ -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([])); diff --git a/packages/cli/src/commands/board.ts b/packages/cli/src/commands/board.ts index 46af63b6..7971e0f7 100644 --- a/packages/cli/src/commands/board.ts +++ b/packages/cli/src/commands/board.ts @@ -1089,9 +1089,12 @@ function diffLines(diff: Omit, 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); } @@ -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); diff --git a/packages/cli/src/commands/view.test.ts b/packages/cli/src/commands/view.test.ts index 1cddc23a..6d5d27ce 100644 --- a/packages/cli/src/commands/view.test.ts +++ b/packages/cli/src/commands/view.test.ts @@ -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/.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/.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) => ({ diff --git a/packages/cli/src/lib/board-ui.ts b/packages/cli/src/lib/board-ui.ts index a061f9da..bb5298ca 100644 --- a/packages/cli/src/lib/board-ui.ts +++ b/packages/cli/src/lib/board-ui.ts @@ -639,6 +639,13 @@ export const BOARD_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) }); }))); } diff --git a/packages/core/src/board/declaration.test.ts b/packages/core/src/board/declaration.test.ts index 5188cd61..f7b1347c 100644 --- a/packages/core/src/board/declaration.test.ts +++ b/packages/core/src/board/declaration.test.ts @@ -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): Record { + 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) => + 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) => + 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}`, + ]); + } + }); +}); diff --git a/packages/core/src/board/declaration.ts b/packages/core/src/board/declaration.ts index 061cb61a..402d4670 100644 --- a/packages/core/src/board/declaration.ts +++ b/packages/core/src/board/declaration.ts @@ -18,7 +18,9 @@ import { TaskStatusSchema } from "../schemas/task.schema.js"; * outside basou (`observe`) and what to look at when judging a stage * (`stages..look` and `.notes`); and 3, which adds the response to an * HTTP GET (`http`) and a check run by its name (`github_check`) as - * observations, and what to mind when making any observation (`notes`). + * observations, what to mind when making any observation (`notes`), and the + * name of a file (`file_name`) and the paths of files (`file_paths`) as + * measures. */ export const BOARD_VERSION = 3; @@ -231,6 +233,40 @@ const jsonLengthSchema = z.strictObject({ pointer: jsonPointer, }); +/** Which file a `file_name` measure picks: the first or the last path, in byte order. */ +export const BOARD_FILE_NAME_PICKS = ["first", "last"] as const; + +/** + * How many paths a `file_paths` measure holds; one that matches more is not + * measured. Fixed with board_version 3: a measure has no method version, so + * changing it would turn the same repository's value between null and a list + * unseen. + */ +export const BOARD_FILE_PATHS_MAX = 1000; + +// The name of the first or last file the patterns match, or what a group of +// `pattern` holds in it. `group` is filled in only when there is a pattern +// (see parseAs), and refused without one (see crossCheck). +const fileNameSchema = z.strictObject({ + kind: z.literal("file_name"), + ...measureCommon, + ...fileScope, + include: globs, + exclude: globs.optional(), + pick: z.enum(BOARD_FILE_NAME_PICKS, { error: "must be first or last" }), + pattern: regexText.optional(), + group: z.number().int().min(0).optional(), +}); + +// The paths of the files the patterns match. +const filePathsSchema = z.strictObject({ + kind: z.literal("file_paths"), + ...measureCommon, + ...fileScope, + include: globs, + exclude: globs.optional(), +}); + /** What a `trail_count` measure counts in the workspace's own trail. */ export const BOARD_TRAIL_COUNTS = [ "decisions_all", @@ -246,7 +282,7 @@ const trailCountSchema = z.strictObject({ status: TaskStatusSchema.optional(), }); -const measureSchema = z.discriminatedUnion("kind", [ +const measureSchemaV1 = z.discriminatedUnion("kind", [ fileCountSchema, dirCountSchema, lineCountSchema, @@ -256,6 +292,19 @@ const measureSchema = z.discriminatedUnion("kind", [ trailCountSchema, ]); +// From version 3, a measure may take the name of a file or the paths of files. +const measureSchemaV3 = z.discriminatedUnion("kind", [ + fileCountSchema, + dirCountSchema, + lineCountSchema, + matchCountSchema, + regexCaptureSchema, + jsonLengthSchema, + fileNameSchema, + filePathsSchema, + trailCountSchema, +]); + /** * What a board can declare to observe outside basou. basou never makes these * observations (it sends nothing): the judge makes each and records what it @@ -521,8 +570,8 @@ const boardVersionText = z.literal(BOARD_VERSIONS, { }); // The keys every version has after its stages, in the order a problem with -// them is reported. -const boardFields = { +// them is reported, with the measures the version reads. +const boardFieldsOf = (measure: M) => ({ lanes: z .array( z.strictObject({ @@ -533,7 +582,7 @@ const boardFields = { }), ) .min(1, { error: "must list at least one lane" }), - measures: z.array(measureSchema).default([]), + measures: z.array(measure).default([]), ratios: z .array( z.strictObject({ @@ -574,20 +623,20 @@ const boardFields = { .array(z.strictObject({ date: dateText, label: nonEmptyText, ref: nonEmptyText })) .optional(), }), -}; +}); const boardSchemaV1 = z.strictObject({ board_version: boardVersionText, title: nonEmptyText, stages: stagesOf(stageSchemaV1), - ...boardFields, + ...boardFieldsOf(measureSchemaV1), }); const boardSchemaV2 = z.strictObject({ board_version: boardVersionText, title: nonEmptyText, stages: stagesOf(stageSchemaV2), - ...boardFields, + ...boardFieldsOf(measureSchemaV1), observe: z.array(observeSchemaV2).default([]), }); @@ -595,14 +644,15 @@ const boardSchemaV3 = z.strictObject({ board_version: boardVersionText, title: nonEmptyText, stages: stagesOf(stageSchemaV2), - ...boardFields, + ...boardFieldsOf(measureSchemaV3), observe: z.array(observeSchemaV3).default([]), }); /** * A declaration as read, whatever its version: one of version 1 has no - * observations and no stage has `look` or `notes`, and one of version 2 has - * no `http` or `github_check` observation and no observation has `notes`. + * observations and no stage has `look` or `notes`, and one of version 1 or 2 + * has no `http` or `github_check` observation, no observation with `notes` + * and no `file_name` or `file_paths` measure. */ export type BoardDeclaration = z.output; export type BoardMeasure = BoardDeclaration["measures"][number]; @@ -748,10 +798,20 @@ function parseAs( } const parsed = (version === 2 ? boardSchemaV2 : boardSchemaV3).safeParse(raw); return parsed.success - ? { ok: true, declaration: parsed.data } + ? { ok: true, declaration: { ...parsed.data, measures: parsed.data.measures.map(withGroup) } } : { ok: false, issues: parsed.error.issues }; } +// A file_name measure with a pattern takes group 1 when it names none; one +// without a pattern has no group, so its snapshot holds none. +function withGroup(measure: BoardMeasure): BoardMeasure { + return measure.kind === "file_name" && + measure.pattern !== undefined && + measure.group === undefined + ? { ...measure, group: BOARD_DEFAULT_CAPTURE_GROUP } + : measure; +} + // The problems of a file of an older version, where a later version reads // what is written told apart from what no version reads: a key, a kind, a // value of a form a later version takes. Each is put down to the first @@ -961,8 +1021,13 @@ const REPO_KINDS = new Set([ "match_count", "regex_capture", "json_length", + "file_name", + "file_paths", ]); +// The kinds of measure whose value is not a number, so no ratio takes them. +const NOT_NUMBER_KINDS = new Set(["regex_capture", "file_name", "file_paths"]); + // How many capturing groups a pattern has, or undefined when it does not compile. function captureGroups(pattern: string): number | undefined { try { @@ -1040,7 +1105,17 @@ function crossCheck(raw: Record, context: BoardDeclarationConte if (laneIds !== undefined && isId(lane) && !laneIds.has(lane)) { errors.push(`${at}.lane: no lane has the id '${lane}'`); } - if (measure.kind === "regex_capture") { + if ( + measure.kind === "file_name" && + measure.pattern === undefined && + measure.group !== undefined + ) { + errors.push(`${at}.group: a file_name takes a group only with a pattern`); + } + if ( + measure.kind === "regex_capture" || + (measure.kind === "file_name" && measure.pattern !== undefined) + ) { const pattern = stringAt(measure, "pattern"); const group = measure.group ?? BOARD_DEFAULT_CAPTURE_GROUP; const groups = pattern === undefined || pattern === "" ? undefined : captureGroups(pattern); @@ -1072,8 +1147,11 @@ function crossCheck(raw: Record, context: BoardDeclarationConte if (!isId(ref)) continue; if (!measureIds.has(ref)) { errors.push(`ratios[${i}].${side}: no measure has the id '${ref}'`); - } else if (measureKinds.get(ref) === "regex_capture") { - errors.push(`ratios[${i}].${side}: '${ref}' is a regex_capture, which is not a number`); + } else { + const kind = measureKinds.get(ref); + if (typeof kind === "string" && NOT_NUMBER_KINDS.has(kind)) { + errors.push(`ratios[${i}].${side}: '${ref}' is a ${kind}, which is not a number`); + } } } } diff --git a/packages/core/src/board/diff.test.ts b/packages/core/src/board/diff.test.ts index 98bd12a3..00e5e6dc 100644 --- a/packages/core/src/board/diff.test.ts +++ b/packages/core/src/board/diff.test.ts @@ -32,6 +32,65 @@ function measurement(over: Record = {}) { }; } +describe("diffMeasurements: the paths of a file_paths measure", () => { + const withPaths = (value: unknown, unit = "files") => + measurement({ measures: { md: { value: 2, unit: "files" }, mig: { value, unit } } }); + + it("lists each path that came in and went out when both hold a list, and nothing for the same set", () => { + const paths = ["db/a.sql", "db/b.sql", 'db/"q".sql']; + const before = withPaths(paths); + const after = withPaths(["db/b.sql", "db/c.sql", 'db/"q".sql']); + expect(diffMeasurements(before, after, "01M4")).toEqual({ + against: "01M4", + values: [], + methods: [], + added: [{ at: 'measures.mig.value["db/c.sql"]', value: true }], + removed: [{ at: 'measures.mig.value["db/a.sql"]', value: true }], + }); + expect(diffMeasurements(before, withPaths([...paths].reverse()), "01M4")).toEqual({ + against: "01M4", + values: [], + methods: [], + added: [], + removed: [], + }); + // The rest of the measure is compared as before. + expect(diffMeasurements(withPaths([]), withPaths(["db/a.sql"], "paths"), "01M4")).toMatchObject( + { + values: [{ at: "measures.mig.unit", before: "files", after: "paths" }], + added: [{ at: 'measures.mig.value["db/a.sql"]', value: true }], + removed: [], + }, + ); + }); + + it("compares the value whole when one of the two is not a list", () => { + expect(diffMeasurements(withPaths(null), withPaths(["db/a.sql"]), "01M4").values).toEqual([ + { at: "measures.mig.value", before: null, after: ["db/a.sql"] }, + ]); + expect(diffMeasurements(withPaths(["db/a.sql"]), withPaths(null), "01M4").values).toEqual([ + { at: "measures.mig.value", before: ["db/a.sql"], after: null }, + ]); + expect(diffMeasurements(withPaths("a.sql"), withPaths(["db/a.sql"]), "01M4").values).toEqual([ + { at: "measures.mig.value", before: "a.sql", after: ["db/a.sql"] }, + ]); + const only = diffMeasurements(measurement(), withPaths(["db/a.sql"]), "01M4"); + expect(only.added).toEqual([ + { at: "measures.mig.value", value: ["db/a.sql"] }, + { at: "measures.mig.unit", value: "files" }, + ]); + }); + + it("names a measure whose id is not a plain word with its id quoted", () => { + const at = (value: unknown) => + measurement({ measures: { "site.pages": { value, unit: "files" } } }); + expect(diffMeasurements(at(["a"]), at(["b"]), "01M4")).toMatchObject({ + added: [{ at: 'measures["site.pages"].value["b"]', value: true }], + removed: [{ at: 'measures["site.pages"].value["a"]', value: true }], + }); + }); +}); + describe("diffMeasurements", () => { it("finds nothing moved between two measurements of the same values, whenever and however measured", () => { const after = measurement({ diff --git a/packages/core/src/board/diff.ts b/packages/core/src/board/diff.ts index e2dbeec7..cde19149 100644 --- a/packages/core/src/board/diff.ts +++ b/packages/core/src/board/diff.ts @@ -27,8 +27,10 @@ export type BoardMethodChange = { section: string; before: number | null; after: * 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. + * `not_found` by where), the unacknowledged and gone components as sets, the + * paths of a `file_paths` measure as a set when both records hold a list (a + * path that came in is added, one that went out removed), and any other list + * whole. */ export type BoardDiff = { /** The previous record's name, its ULID. */ @@ -72,11 +74,33 @@ function shownKey(shown: string, key: string): string { type Leaf = { value: unknown; section: string }; +// The measures whose value is a list in both measurements, by id: their +// paths are compared as a set. When one of them is not a list (not measured, +// or no such measure), the value is compared whole. +function setMeasures(before: unknown, after: unknown): Set { + const lists = (m: unknown): Set => { + const measures = isRecord(m) && isRecord(m.measures) ? m.measures : {}; + return new Set( + Object.entries(measures) + .filter(([, v]) => isRecord(v) && Array.isArray(v.value)) + .map(([id]) => id), + ); + }; + const was = lists(before); + return new Set([...lists(after)].filter((id) => was.has(id))); +} + // Each value of a measurement by where it is, with the section it is in. -function leaves(measurement: unknown): Map { +function leaves(measurement: unknown, sets: ReadonlySet): Map { const out = new Map(); const walk = (value: unknown, rule: string, shown: string, section: string): void => { if (SKIPPED.has(rule)) return; + if (rule === "measures" && isRecord(value)) { + for (const [id, entry] of Object.entries(value)) { + walk(entry, sets.has(id) ? "measures[set]" : "measures[]", shownKey(shown, id), section); + } + return; + } if (Array.isArray(value)) { const field = KEYED[rule]; if (field !== undefined) { @@ -87,7 +111,7 @@ function leaves(measurement: unknown): Map { if (rule === "not_found" && /^axis(\.|$)/.test(String(name))) continue; walk(rest, `${rule}[]`, `${shown}[${JSON.stringify(String(name))}]`, section); } - } else if (SETS.has(rule)) { + } else if (SETS.has(rule) || rule === "measures[set].value") { for (const name of value) { out.set(`${shown}[${JSON.stringify(String(name))}]`, { value: true, section }); } @@ -146,7 +170,8 @@ export function diffMeasurements(before: unknown, after: unknown, against: strin 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 sets = setMeasures(before, after); + const [was, is] = [leaves(before, sets), leaves(after, sets)]; const values: BoardValueChange[] = []; const added: BoardValueOnly[] = []; for (const [at, leaf] of is) { diff --git a/packages/core/src/board/guide.test.ts b/packages/core/src/board/guide.test.ts index b93be87a..a40d413f 100644 --- a/packages/core/src/board/guide.test.ts +++ b/packages/core/src/board/guide.test.ts @@ -308,6 +308,12 @@ describe("boardGuide: a board declared", () => { ); }); + it("says where to read the paths of a file_paths measure, and how the diff gives them", () => { + expect(boardGuide(guideInput())).toContain( + "- A `file_paths` measure's value is the list of paths it found, which measure.txt gives as a count: read them in the JSON. `diff` lists each path that came in (`added`) or went out (`removed`) when both records hold a list.", + ); + }); + it("says which branch behind_main counts against", () => { expect(boardGuide(guideInput())).toContain( "- `repos[].behind_main` counts the commits on origin's default branch (the branch of origin that origin/HEAD names, or origin/main when origin/HEAD names none) as of the last fetch; measure does not fetch.", diff --git a/packages/core/src/board/guide.ts b/packages/core/src/board/guide.ts index f1ef9a72..ea331aa1 100644 --- a/packages/core/src/board/guide.ts +++ b/packages/core/src/board/guide.ts @@ -192,6 +192,7 @@ export function boardGuide(input: BoardGuideInput): string { "- An empty file means it did not run, not that it passed: check that measure.json reads as JSON.", '- Never read a null as 0. Write "not confirmed" for it and do not judge by it.', "- What moved is the point: the top of measure.txt (`Since the previous record :`) and `diff` in the JSON. A value marked `method_changed` may have moved because basou measures it another way now.", + "- A `file_paths` measure's value is the list of paths it found, which measure.txt gives as a count: read them in the JSON. `diff` lists each path that came in (`added`) or went out (`removed`) when both records hold a list.", "- `repos[].behind_main` counts the commits on origin's default branch (the branch of origin that origin/HEAD names, or origin/main when origin/HEAD names none) as of the last fetch; measure does not fetch.", "", ); diff --git a/packages/core/src/board/init.test.ts b/packages/core/src/board/init.test.ts index 7223132f..2e235941 100644 --- a/packages/core/src/board/init.test.ts +++ b/packages/core/src/board/init.test.ts @@ -53,6 +53,21 @@ describe("boardInitText", () => { expect(en.comments.observe).toContain(kind); expect(ja.comments.observe).toContain(kind); } + // The comment on measures names every kind a board can declare, file_name and file_paths among them. + for (const kind of [ + "file_count", + "dir_count", + "line_count", + "match_count", + "regex_capture", + "json_length", + "file_name", + "file_paths", + "trail_count", + ]) { + expect(en.comments.measures).toContain(kind); + expect(ja.comments.measures).toContain(kind); + } expect(ja.comments.measures).toContain("lane を付けたものは盤のページのそのレーンの節に描かれ"); }); diff --git a/packages/core/src/board/measure.test.ts b/packages/core/src/board/measure.test.ts index 6dd3abf8..c06364b4 100644 --- a/packages/core/src/board/measure.test.ts +++ b/packages/core/src/board/measure.test.ts @@ -4377,3 +4377,289 @@ describe("measureBoard: against the previous record", () => { } }); }); + +describe("measureBoard: the name of a file and the paths of files", () => { + // A version 3 declaration of `measures` over the repo "app". + const declare3 = (measures: Record[]) => declare(measures, { board_version: 3 }); + const names = (over: Record) => ({ + id: "latest", + kind: "file_name", + repo: "app", + include: ["db/migrations/*.sql"], + pick: "last", + unit: "name", + ...over, + }); + const paths3 = (over: Record) => ({ + id: "migrations", + kind: "file_paths", + repo: "app", + include: ["db/migrations/*.sql"], + unit: "files", + ...over, + }); + + beforeEach(async () => { + await repo( + "app", + { + "db/migrations/20260101_init.sql": "", + "db/migrations/20260301_users.sql": "", + "db/migrations/schema.sql": "", + "db/migrations/README.md": "", + "db/other/20261231_late.sql": "", + }, + { "db/migrations/20260401_new.sql": "" }, + ); + }); + + it("picks the first or the last name in byte order, narrowed by the pattern before it picks", async () => { + const m = await measure( + declare3([ + names({ id: "last" }), + names({ id: "first", pick: "first" }), + names({ id: "last_date", pattern: "^(\\d{8})_" }), + names({ id: "first_date", pick: "first", pattern: "^(\\d{8})_" }), + names({ id: "whole", pattern: "^\\d{8}_", group: 0 }), + names({ id: "month", pattern: "^(\\d{4})(\\d{2})", group: 2 }), + names({ id: "last_main", pattern: "^(\\d{8})_", at: "main" }), + names({ id: "no_schema", exclude: ["**/schema.sql"] }), + ]), + NOW, + ["app"], + ); + // schema.sql comes after every name that starts with a digit. + expect(measured(m, "last")).toBe("schema.sql"); + expect(measured(m, "first")).toBe("20260101_init.sql"); + // Narrowed first: schema.sql, last in the order, has no date and is not picked. + expect(measured(m, "last_date")).toBe("20260401"); + expect(measured(m, "first_date")).toBe("20260101"); + expect(measured(m, "whole")).toBe("20260401_"); + expect(measured(m, "month")).toBe("04"); + // The untracked one is not at main. + expect(measured(m, "last_main")).toBe("20260301"); + expect(measured(m, "no_schema")).toBe("20260401_new.sql"); + expect(m.not_found).toEqual([]); + expect(m.measures.last).toEqual({ value: "schema.sql", unit: "name" }); + }); + + it("orders by the whole path in bytes, not by the name or the locale", async () => { + await repo("names", { + "a/zzz.txt": "", + "b/aaa.txt": "", + "c/B.txt": "", + "c/a.txt": "", + "d/z.txt": "", + "d/\u00e9.txt": "", + }); + const m = await measure( + declare( + [ + names({ id: "across", repo: "names", include: ["a/*", "b/*"] }), + names({ id: "upper_first", repo: "names", include: ["c/*"], pick: "first" }), + names({ id: "accent_last", repo: "names", include: ["d/*"] }), + paths3({ id: "all", repo: "names", include: ["**/*.txt"] }), + ], + { board_version: 3 }, + ["names"], + ), + NOW, + ["names"], + ); + // b/aaa.txt is after a/zzz.txt as a path, though its name is before. + expect(measured(m, "across")).toBe("aaa.txt"); + // 'B' (0x42) is before 'a' (0x61); the locale would put 'a' first. + expect(measured(m, "upper_first")).toBe("B.txt"); + // The first byte of '\u00e9' (0xc3) is after 'z'. + expect(measured(m, "accent_last")).toBe("\u00e9.txt"); + expect(measured(m, "all")).toEqual([ + "a/zzz.txt", + "b/aaa.txt", + "c/B.txt", + "c/a.txt", + "d/z.txt", + "d/\u00e9.txt", + ]); + }); + + it("takes the paths matched in byte order, an empty list when none is, and keeps the lane", async () => { + const m = await measure( + declare3([ + paths3({ lane: "core" }), + paths3({ id: "at_main", at: "main" }), + paths3({ id: "dated", include: ["db/**/2026*.sql"], exclude: ["db/other/**"] }), + paths3({ id: "none", include: ["db/migrations/*.rb"] }), + ]), + NOW, + ["app"], + ); + expect(m.measures.migrations).toEqual({ + value: [ + "db/migrations/20260101_init.sql", + "db/migrations/20260301_users.sql", + "db/migrations/20260401_new.sql", + "db/migrations/schema.sql", + ], + unit: "files", + lane: "core", + }); + expect(measured(m, "at_main")).toEqual([ + "db/migrations/20260101_init.sql", + "db/migrations/20260301_users.sql", + "db/migrations/schema.sql", + ]); + expect(measured(m, "dated")).toEqual([ + "db/migrations/20260101_init.sql", + "db/migrations/20260301_users.sql", + "db/migrations/20260401_new.sql", + ]); + // Nothing matched under a directory that is there: a list, not a gap. + expect(measured(m, "none")).toEqual([]); + expect(m.complete).toBe(true); + }); + + it("is not measured with no file to pick, a group that took no part, or a directory with nothing under it", async () => { + const m = await measure( + declare( + [ + names({ id: "no_file", include: ["db/migrations/*.rb"] }), + names({ id: "no_name", pattern: "^v(\\d+)_" }), + names({ id: "no_group", pattern: "^(?:(\\d{8})_)?schema", pick: "last" }), + names({ id: "no_dir", include: ["db/nope/*.sql"] }), + names({ id: "no_dir_main", include: ["db/nope/*.sql"], at: "main" }), + paths3({ id: "paths_no_dir", include: ["db/nope/*.sql"] }), + names({ id: "no_repo", repo: "gone" }), + ], + { board_version: 3 }, + ["app", "gone"], + ), + NOW, + ["app"], + ); + for (const id of [ + "no_file", + "no_name", + "no_group", + "no_dir", + "no_dir_main", + "paths_no_dir", + "no_repo", + ]) { + expect(measured(m, id)).toBeNull(); + } + expect(m.complete).toBe(false); + expect(m.not_found.filter((n) => n.at.startsWith("measures."))).toEqual([ + { at: "measures.no_file", reason: "no file is matched in the working tree" }, + { + at: "measures.no_name", + reason: "no file matched has a name the pattern matches in the working tree", + }, + { at: "measures.no_group", reason: "group 1 took no part in the match of 'schema.sql'" }, + { at: "measures.no_dir", reason: "nothing is under 'db/nope' in the working tree" }, + { at: "measures.no_dir_main", reason: "nothing is under 'db/nope' at 'main'" }, + { at: "measures.paths_no_dir", reason: "nothing is under 'db/nope' in the working tree" }, + { + at: "measures.no_repo", + reason: expect.stringMatching(/^the repo 'gone' /) as unknown as string, + }, + ]); + }); + + it("keeps once two paths that read as the same text, as two that are not UTF-8 do", async () => { + const dir = await repo("bytes", { "keep.txt": "" }); + const blob = git(dir, "hash-object", "-w", "keep.txt").trim(); + // x, a byte that is not UTF-8, then .txt: each reads as x\ufffd.txt. + const entry = (byte: number) => + Buffer.concat([ + Buffer.from(`100644 ${blob}\t`), + Buffer.from([0x78, byte]), + Buffer.from(".txt\0"), + ]); + execFileSync("git", ["update-index", "-z", "--index-info"], { + cwd: dir, + env: GIT_ENV, + input: Buffer.concat([entry(0xfe), entry(0xff)]), + }); + git(dir, "commit", "-q", "-m", "bytes"); + const m = await measure( + declare( + [paths3({ id: "all", repo: "bytes", include: ["*.txt"], at: "main" })], + { board_version: 3 }, + ["bytes"], + ), + NOW, + ["bytes"], + ); + expect(measured(m, "all")).toEqual(["keep.txt", "x\ufffd.txt"]); + }); + + it("holds at most 1000 paths, and is not measured with more", async () => { + const files = Object.fromEntries( + Array.from({ length: 1001 }, (_, i) => [`many/f${String(i).padStart(4, "0")}.txt`, ""]), + ); + await repo("big", files); + const m = await measure( + declare( + [ + paths3({ + id: "at_most", + repo: "big", + include: ["many/*.txt"], + exclude: ["many/f1000.txt"], + }), + paths3({ id: "too_many", repo: "big", include: ["many/*.txt"] }), + ], + { board_version: 3 }, + ["big"], + ), + NOW, + ["big"], + ); + const atMost = measured(m, "at_most") as string[]; + expect(atMost).toHaveLength(1000); + expect(atMost[0]).toBe("many/f0000.txt"); + expect(atMost.at(-1)).toBe("many/f0999.txt"); + expect(measured(m, "too_many")).toBeNull(); + expect(m.not_found).toEqual([ + { + at: "measures.too_many", + reason: "1001 files match, more than the 1000 a file_paths measure holds", + }, + ]); + }); +}); + +describe.skipIf(process.platform === "win32")( + "measureBoard: the name and the paths of files, and a symlink that leaves the repository", + () => { + it("is not measured when a file matched is a symlink to outside the repository", async () => { + const dir = await repo("app", { "docs/a.md": "a\n" }); + await writeFile(join(root, "secret.md"), "s\n"); + await symlink("../../secret.md", join(dir, "docs/z.md")); + const m = await measure( + declare( + [ + { + id: "name", + kind: "file_name", + repo: "app", + include: ["docs/*.md"], + pick: "first", + unit: "name", + }, + { id: "paths", kind: "file_paths", repo: "app", include: ["docs/*.md"], unit: "files" }, + ], + { board_version: 3 }, + ), + NOW, + ["app"], + ); + expect(measured(m, "name")).toBeNull(); + expect(measured(m, "paths")).toBeNull(); + expect(m.not_found.filter((n) => n.at.startsWith("measures."))).toEqual([ + { at: "measures.name", reason: "'docs/z.md' is a symlink to outside the repository" }, + { at: "measures.paths", reason: "'docs/z.md' is a symlink to outside the repository" }, + ]); + }); + }, +); diff --git a/packages/core/src/board/measure.ts b/packages/core/src/board/measure.ts index 026d13da..2a1f617f 100644 --- a/packages/core/src/board/measure.ts +++ b/packages/core/src/board/measure.ts @@ -7,6 +7,8 @@ import { BOARD_AXIS_METHOD, type BoardAxis, judgeAxis } from "./axis.js"; import { BOARD_COMPONENTS_METHOD, type BoardComponents, measureComponents } from "./components.js"; import { BOARD_DEFAULT_AT, + BOARD_DEFAULT_CAPTURE_GROUP, + BOARD_FILE_PATHS_MAX, BOARD_REGEX_FLAGS, type BoardDeclaration, type BoardDeclarationSnapshot, @@ -61,7 +63,8 @@ import { export type BoardNotFound = { at: string; reason: string }; export type BoardMeasureValue = { - value: number | string | null; + /** A number, a string, or the paths a `file_paths` measure found. */ + value: number | string | string[] | null; unit: string; lane?: string; }; @@ -178,7 +181,7 @@ export type MeasureBoardInput = { previous?: BoardPreviousRecords; }; -type Outcome = { value: number | string | null; reason?: string }; +type Outcome = { value: number | string | string[] | null; reason?: string }; const fail = (reason: string): Outcome => ({ value: null, reason }); @@ -627,6 +630,8 @@ async function measureOne( if (blocked !== undefined) return fail(blocked); if (unread !== undefined) return fail(unread); if (measure.kind === "file_count") return { value: matched.length }; + if (measure.kind === "file_paths") return filePaths(matched); + if (measure.kind === "file_name") return fileName(measure, matched, scope); const read = await scope.read(matched); const [unreadable] = read.unreadable; @@ -651,3 +656,50 @@ async function measureOne( } return { value: total }; } + +// Byte strings in byte order: each character is one byte, so comparing them +// compares their bytes, as git orders paths. +function byteOrder(paths: readonly string[]): string[] { + return [...paths].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); +} + +// The paths matched, in byte order, as text. +function filePaths(matched: readonly string[]): Outcome { + // Two paths that are not UTF-8 may read as the same text: it is kept once. + const paths = [...new Set(byteOrder(matched).map(shownPath))]; + if (paths.length > BOARD_FILE_PATHS_MAX) { + return fail( + `${paths.length} files match, more than the ${BOARD_FILE_PATHS_MAX} a file_paths measure holds`, + ); + } + return { value: paths }; +} + +// The name of the first or last path matched, in byte order, of those whose +// name the pattern matches when there is one, or what its group holds. The +// names are narrowed before one is picked, so a name with no match at the end +// of the order does not hide the one before it. +function fileName( + measure: Extract, + matched: readonly string[], + scope: RepoScope, +): Outcome { + const pattern = + measure.pattern === undefined ? undefined : new RegExp(measure.pattern, BOARD_REGEX_FLAGS); + const names = byteOrder(matched).map((p) => shownPath(p.slice(p.lastIndexOf("/") + 1))); + const candidates = pattern === undefined ? names : names.filter((name) => pattern.test(name)); + const name = measure.pick === "first" ? candidates[0] : candidates.at(-1); + if (name === undefined) { + return fail( + pattern === undefined + ? `no file is matched ${where(scope)}` + : `no file matched has a name the pattern matches ${where(scope)}`, + ); + } + if (pattern === undefined) return { value: name }; + const group = measure.group ?? BOARD_DEFAULT_CAPTURE_GROUP; + const captured = pattern.exec(name)?.[group]; + return typeof captured === "string" + ? { value: captured } + : fail(`group ${group} took no part in the match of '${name}'`); +} diff --git a/packages/core/src/board/page.test.ts b/packages/core/src/board/page.test.ts index a59972bf..1d2cf86e 100644 --- a/packages/core/src/board/page.test.ts +++ b/packages/core/src/board/page.test.ts @@ -239,6 +239,41 @@ describe("boardPage", () => { expect((await boardPage(records, A)).status).toBe("ok"); }); + it("draws a record of version 3 whose measures hold a name and the paths of files, in their lanes", async () => { + await mkdir(records); + const v3 = record({ record_version: 3 }); + v3.declaration = { + ...v3.declaration, + measures: [ + { id: "latest", kind: "file_name", unit: "name", lane: "core" }, + { id: "migrations", kind: "file_paths", unit: "files", lane: "core" }, + ], + ratios: [], + } as typeof v3.declaration; + v3.measure = { + ...v3.measure, + measures: { + latest: { value: "20260401", unit: "name", lane: "core" }, + migrations: { value: ["db/a.sql", "db/b.sql"], unit: "files", lane: "core" }, + }, + ratios: {}, + } as unknown as typeof v3.measure; + await place(A, v3); + const page = await boardPage(records); + if (page.status !== "ok") throw new Error(page.why); + expect(page.board.lanes[0]?.measures).toEqual([ + { id: "latest", value: "20260401", unit: "name" }, + { id: "migrations", value: ["db/a.sql", "db/b.sql"], unit: "files" }, + ]); + // A list of anything but strings is not a measure's value. + v3.measure = { + ...v3.measure, + measures: { migrations: { value: [1, 2], unit: "files" } }, + } as unknown as typeof v3.measure; + await place(A, v3); + expect(await boardPage(records)).toMatchObject({ why: "not_a_record" }); + }); + it("draws the heading, the tiles and the observations from the record", async () => { await mkdir(records); await place(A, record()); diff --git a/packages/core/src/board/page.ts b/packages/core/src/board/page.ts index d4ebc6dd..b0efd325 100644 --- a/packages/core/src/board/page.ts +++ b/packages/core/src/board/page.ts @@ -44,7 +44,7 @@ const pageRecordSchema = z.looseObject({ not_found: z.array(z.looseObject({ at: text, reason: text })), measures: z.record( text, - z.looseObject({ value: z.union([z.number(), text]).nullable(), unit: text }), + z.looseObject({ value: z.union([z.number(), text, z.array(text)]).nullable(), unit: text }), ), ratios: z.record(text, z.looseObject({ value: z.number().nullable() })), trail: z.looseObject({ @@ -160,7 +160,8 @@ export type BoardPageLane = { now: { stage: string; meaning: string; state: string } | null; attention: { stage: string; state: string; reason?: string }[]; prose: string | null; - measures: { id: string; value: number | string | null; unit: string }[]; + /** Each measure drawn in the lane: a number, a string, or the paths of a `file_paths`. */ + measures: { id: string; value: number | string | string[] | null; unit: string }[]; flags: { live: boolean; blocked: boolean; unverified: boolean }; }; @@ -235,8 +236,8 @@ export type BoardPageBody = { id: string; label: string; value: number | null; - numerator: { id: string; value: number | string | null; unit: string | null }; - denominator: { id: string; value: number | string | null; unit: string | null }; + numerator: { id: string; value: number | string | string[] | null; unit: string | null }; + denominator: { id: string; value: number | string | string[] | null; unit: string | null }; }[]; turns: { text: string; source: string }[]; footnotes: { diff --git a/packages/core/src/board/record.ts b/packages/core/src/board/record.ts index f89109c0..ea1964fb 100644 --- a/packages/core/src/board/record.ts +++ b/packages/core/src/board/record.ts @@ -25,7 +25,9 @@ function isRecord(value: unknown): value is Record { * declaration names (`declaration.observe`) and what each stage is judged by * (`look` and `notes` of `declaration.stages`); 3 may hold a declaration of * board_version 3, which may observe an HTTP response (`http`) or a check run - * (`github_check`) and say what to mind when making an observation (`notes`). + * (`github_check`), say what to mind when making an observation (`notes`) and + * measure the name of a file (`file_name`) or the paths of files + * (`file_paths`), whose value in the measurement is a list of strings. * Every record is written in the newest version, whatever the version of the * board it records, and every version is read. */ diff --git a/packages/core/src/lib/view-strings.ts b/packages/core/src/lib/view-strings.ts index cab66b56..7957ec28 100644 --- a/packages/core/src/lib/view-strings.ts +++ b/packages/core/src/lib/view-strings.ts @@ -1448,7 +1448,7 @@ const BOARD_INIT_EN: BoardInitStrings = { "The six stages are fixed; only what each means is the board's. Keep 04 (merged) apart from 05 (in users' hands). `look` and `notes` say what to look at and mind when judging a stage.", lanes: "At least one lane. Its id is lowercase ASCII and stays: records join on it.", measures: - "What to count in the repos (file_count, dir_count, line_count, match_count, regex_capture, json_length, trail_count). One with a lane is drawn in that lane's section of the board page; one without is kept only in the records and their differences (and in a ratio that uses it).", + "What to measure in the repos (file_count, dir_count, line_count, match_count, regex_capture, json_length, file_name, file_paths, trail_count). One with a lane is drawn in that lane's section of the board page; one without is kept only in the records and their differences (and in a ratio that uses it).", observe: "What to observe outside basou (npm_version, github_release, github_open_issues, github_open_prs, github_ci, github_check, page_version, http, manual), each with notes on what to mind when making it if need be. basou never makes these; the guide says how.", components: @@ -1485,7 +1485,7 @@ const BOARD_INIT_JA: BoardInitStrings = { "6 段の id は固定で、盤が決めるのは意味だけ。04 本番(main に入った)と 05 開通(利用者が手に取れる)を分ける。look と notes は、その段を判定するときに見るものと注意。", lanes: "レーンは 1 本以上。id は小文字の ASCII で、記録の照合に使うので変えない。", measures: - "repo の中で数えるもの(file_count・dir_count・line_count・match_count・regex_capture・json_length・trail_count)。lane を付けたものは盤のページのそのレーンの節に描かれ、付けないものは記録と差分(と、それを使う ratio)にだけ残る。", + "repo の中で測るもの(file_count・dir_count・line_count・match_count・regex_capture・json_length・file_name・file_paths・trail_count)。lane を付けたものは盤のページのそのレーンの節に描かれ、付けないものは記録と差分(と、それを使う ratio)にだけ残る。", observe: "basou の外で観測するもの(npm_version・github_release・github_open_issues・github_open_prs・github_ci・github_check・page_version・http・manual)。取るときの注意があれば notes に書く。basou は取りに行かない。取り方は guide が出す。", components: From aebe24abef2b2de8b850122881dea87b7ed1a241 Mon Sep 17 00:00:00 2001 From: Takashi Matsuyama Date: Sat, 17 Oct 2026 17:25:06 +0900 Subject: [PATCH 2/3] basou: Hold a file_name's byte order apart from UTF-16 order in a test A name of four bytes from 0xf0 (U+1F600) is after one from 0xef (U+FF5E) in byte order, though its first UTF-16 unit is before. The test now tells the two orders apart, so sorting by the decoded text fails it. --- packages/core/src/board/measure.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/core/src/board/measure.test.ts b/packages/core/src/board/measure.test.ts index c06364b4..4133228d 100644 --- a/packages/core/src/board/measure.test.ts +++ b/packages/core/src/board/measure.test.ts @@ -4451,6 +4451,8 @@ describe("measureBoard: the name of a file and the paths of files", () => { "c/a.txt": "", "d/z.txt": "", "d/\u00e9.txt": "", + "e/\uff5e.txt": "", + "e/\u{1f600}.txt": "", }); const m = await measure( declare( @@ -4458,6 +4460,7 @@ describe("measureBoard: the name of a file and the paths of files", () => { names({ id: "across", repo: "names", include: ["a/*", "b/*"] }), names({ id: "upper_first", repo: "names", include: ["c/*"], pick: "first" }), names({ id: "accent_last", repo: "names", include: ["d/*"] }), + names({ id: "astral_last", repo: "names", include: ["e/*"] }), paths3({ id: "all", repo: "names", include: ["**/*.txt"] }), ], { board_version: 3 }, @@ -4472,6 +4475,9 @@ describe("measureBoard: the name of a file and the paths of files", () => { expect(measured(m, "upper_first")).toBe("B.txt"); // The first byte of '\u00e9' (0xc3) is after 'z'. expect(measured(m, "accent_last")).toBe("\u00e9.txt"); + // U+1F600 is four bytes from 0xf0, after U+FF5E (0xef ...), though as + // UTF-16 its first unit (0xd83d) is before 0xff5e. + expect(measured(m, "astral_last")).toBe("\u{1f600}.txt"); expect(measured(m, "all")).toEqual([ "a/zzz.txt", "b/aaa.txt", @@ -4479,6 +4485,8 @@ describe("measureBoard: the name of a file and the paths of files", () => { "c/a.txt", "d/z.txt", "d/\u00e9.txt", + "e/\uff5e.txt", + "e/\u{1f600}.txt", ]); }); From ea66dfef804ae4db82e95dfc3fd971ca1fae0f09 Mon Sep 17 00:00:00 2001 From: Takashi Matsuyama Date: Sat, 17 Oct 2026 23:16:19 +0900 Subject: [PATCH 3/3] basou: Say which part of measure.txt gives a file_paths list as a count - board guide and CHANGELOG: it is the Measures section of board measure that gives a list as a count. The difference from the previous record gives each path that came in or went out when both records hold a list, and the list whole when only one does. - The comment of BOARD_DEFAULT_CAPTURE_GROUP names a file_name measure with a pattern, which takes it too. --- CHANGELOG.md | 6 +++--- packages/core/src/board/declaration.ts | 5 ++++- packages/core/src/board/guide.test.ts | 2 +- packages/core/src/board/guide.ts | 2 +- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96802740..69163ed1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,9 +69,9 @@ All notable changes to **basou** are recorded here. The project follows 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. `basou board measure` gives the list as a - count, and the board page draws it as a count with the paths folded - under it. + 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 diff --git a/packages/core/src/board/declaration.ts b/packages/core/src/board/declaration.ts index 402d4670..16c8564a 100644 --- a/packages/core/src/board/declaration.ts +++ b/packages/core/src/board/declaration.ts @@ -36,7 +36,10 @@ export const BOARD_REGEX_FLAGS = "u"; /** What `at` means when a measure omits it: the working tree, untracked files included. */ export const BOARD_DEFAULT_AT = "worktree"; -/** The capture group a `regex_capture` measure takes when it omits `group`. */ +/** + * The capture group a `regex_capture` measure, or a `file_name` measure with + * a `pattern`, takes when it omits `group`. + */ export const BOARD_DEFAULT_CAPTURE_GROUP = 1; const ID_PATTERN = /^[a-z][a-z0-9_-]*$/; diff --git a/packages/core/src/board/guide.test.ts b/packages/core/src/board/guide.test.ts index a40d413f..068f1794 100644 --- a/packages/core/src/board/guide.test.ts +++ b/packages/core/src/board/guide.test.ts @@ -310,7 +310,7 @@ describe("boardGuide: a board declared", () => { it("says where to read the paths of a file_paths measure, and how the diff gives them", () => { expect(boardGuide(guideInput())).toContain( - "- A `file_paths` measure's value is the list of paths it found, which measure.txt gives as a count: read them in the JSON. `diff` lists each path that came in (`added`) or went out (`removed`) when both records hold a list.", + "- A `file_paths` measure's value is the list of paths it found. `Measures:` in measure.txt gives it as a count: read the paths in the JSON. `diff` lists each path that came in (`added`) or went out (`removed`) when both records hold a list, and the list whole when only one does.", ); }); diff --git a/packages/core/src/board/guide.ts b/packages/core/src/board/guide.ts index ea331aa1..20f5a5a3 100644 --- a/packages/core/src/board/guide.ts +++ b/packages/core/src/board/guide.ts @@ -192,7 +192,7 @@ export function boardGuide(input: BoardGuideInput): string { "- An empty file means it did not run, not that it passed: check that measure.json reads as JSON.", '- Never read a null as 0. Write "not confirmed" for it and do not judge by it.', "- What moved is the point: the top of measure.txt (`Since the previous record :`) and `diff` in the JSON. A value marked `method_changed` may have moved because basou measures it another way now.", - "- A `file_paths` measure's value is the list of paths it found, which measure.txt gives as a count: read them in the JSON. `diff` lists each path that came in (`added`) or went out (`removed`) when both records hold a list.", + "- A `file_paths` measure's value is the list of paths it found. `Measures:` in measure.txt gives it as a count: read the paths in the JSON. `diff` lists each path that came in (`added`) or went out (`removed`) when both records hold a list, and the list whole when only one does.", "- `repos[].behind_main` counts the commits on origin's default branch (the branch of origin that origin/HEAD names, or origin/main when origin/HEAD names none) as of the last fetch; measure does not fetch.", "", );