diff --git a/CHANGELOG.md b/CHANGELOG.md index 69163ed..516855c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,31 @@ All notable changes to **basou** are recorded here. The project follows 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. +- **A board can add up measures and integers, and take some away (`total`, + from `board_version: 3`, experimental).** A `total` measure lists what it + adds (`plus`, at least one) and what it takes away (`minus`, which it may + leave out), each an integer written in the declaration (from + -9007199254740991 to 9007199254740991, the integers a number holds + exactly) or the id of a measure whose value is one, so measures of + several repos can be added up. + Its value is the difference, and may be below 0. When a measure it takes + was not measured, the total is null and says which (`'hand_count' is not + measured`); one out of the range a number holds exactly is null too. A + total takes no total, itself included, and no measure whose value is not a + number (`regex_capture`, `file_name`, `file_paths`); a ratio may take a + total. +- **A measure can be drawn in more than one lane (`lane` as a list, from + `board_version: 3`, experimental).** `lane` is one lane id, as before, or + a non-empty list of them, each a lane of the board and none twice. The + board page draws the measure in each of its lanes' sections, and + `basou board measure` lists its lanes (`[cockpit, distribution]`). +- **A measure's id and an observation's key may be words joined by `.` + (from `board_version: 3`, experimental),** each word written as an id is + (`site.pages`), and a ratio and a total name such a measure so. `:` and + `/` are still refused: the guide names working files by a key. Where such + a measure is not measured, `not_found` names it as + `measures["site.pages"]`, as the difference from the previous record + already did, so it is not read as a measure `pages` inside `site`. ### Changed @@ -84,6 +109,12 @@ All notable changes to **basou** are recorded here. The project follows version reads is told so on a line of its own (`observe[0].kind: 'http' is read from board_version 3 on; this file is board_version 2`) for a kind or a value as for a key: until now only a key was told so. +- **A record holds a measure's `lane` as a list (`record_version: 3`, + experimental),** in its measurement and in the declaration it keeps, + whatever the version of its board; a measure with no lane has none, as + before. A record of version 1 or 2 holds one lane id, which the board page + and the difference from the previous record read as a list of that one, + so a lane written either way is not a change. - **A branch a `github_ci` or `github_check` observation names may not have a part that starts with `.`** (in a board of any version). git takes no such branch, and GitHub reads `a/./b` in the path of a request as `a/b`, diff --git a/packages/cli/src/commands/board.test.ts b/packages/cli/src/commands/board.test.ts index 921be73..9d52bb8 100644 --- a/packages/cli/src/commands/board.test.ts +++ b/packages/cli/src/commands/board.test.ts @@ -298,7 +298,39 @@ describe("basou board measure", () => { expect(JSON.parse(out.join("\n")).measures.paths).toEqual({ value: ["NOTES.md", "README.md"], unit: "files", - lane: "core", + lane: ["core"], + }); + }); + + it("prints a total, each lane of a measure, and an id with '.', and says what a total lacks", async () => { + const repo = await workspace([{ path: ".", visibility: "private" }]); + const board = JSON.parse( + boardYaml([ + { ...MD, id: "docs.md", lane: ["site", "core"] }, + { id: "left", kind: "total", plus: [1], minus: ["docs.md"], unit: "files", lane: "core" }, + { id: "gone", kind: "line_count", repo: ".", include: ["GONE.md"], unit: "lines" }, + { id: "short", kind: "total", plus: ["gone"], unit: "lines" }, + ]), + ); + board.board_version = 3; + board.lanes = [ + { id: "core", name: "Core" }, + { id: "site", name: "Site" }, + ]; + await placeBoard(repo, JSON.stringify(board)); + const { out } = capture(); + await runBoardMeasure({}, ctx(repo)); + const text = out.join("\n"); + expect(text).toContain("\n docs.md 2 files [site, core]\n"); + expect(text).toContain("\n left -1 files [core]\n"); + expect(text).toContain("\n short not measured\n"); + expect(text).toContain("\n measures.short: 'gone' is not measured\n"); + out.length = 0; + await runBoardMeasure({ json: true }, ctx(repo)); + expect(JSON.parse(out.join("\n")).measures["docs.md"]).toEqual({ + value: 2, + unit: "files", + lane: ["site", "core"], }); }); diff --git a/packages/cli/src/commands/board.ts b/packages/cli/src/commands/board.ts index 7971e0f..fe26558 100644 --- a/packages/cli/src/commands/board.ts +++ b/packages/cli/src/commands/board.ts @@ -1273,7 +1273,7 @@ function printMeasurementText(m: BoardMeasurement, hasRecords: boolean): void { 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}]`; + const lane = measure.lane === undefined ? "" : ` [${measure.lane.join(", ")}]`; lines.push(` ${id.padEnd(width)} ${shownValue(measure.value)}${unit}${listed}${lane}`); } } diff --git a/packages/core/src/board/declaration.test.ts b/packages/core/src/board/declaration.test.ts index f7b1347..5ee47f2 100644 --- a/packages/core/src/board/declaration.test.ts +++ b/packages/core/src/board/declaration.test.ts @@ -1536,9 +1536,9 @@ describe("parseBoardDeclaration: board_version 3 and http", () => { }); it("refuses an http key that is not an id: the guide names a working file by it", () => { - for (const key of ["Shop", "a/b", "a:b", "-a", "a b"]) { + for (const key of ["Shop", "a/b", "a:b", "-a", "a b", "a.", "a..b", ".a", "a.B"]) { expect(errorsOf(parse(withHttp({ key, url })))).toEqual([ - "observe[0].key: must start with a lowercase letter and use only a-z, 0-9, '_' and '-'", + "observe[0].key: must start with a lowercase letter and use only a-z, 0-9, '_', '-' and '.', with a lowercase letter after each '.'", ]); } }); @@ -1862,7 +1862,7 @@ describe("parseBoardDeclaration: board_version 3, file_name and file_paths", () 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" }]); + 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", () => { @@ -1928,7 +1928,7 @@ describe("parseBoardDeclaration: board_version 3, file_name and file_paths", () 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'", + "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' | 'total'", ]); }); @@ -1945,3 +1945,306 @@ describe("parseBoardDeclaration: board_version 3, file_name and file_paths", () } }); }); + +const SRC_LINES = { + id: "src_lines", + kind: "line_count", + repo: "../basou", + include: ["**/*.ts"], + unit: "lines", +}; + +const LEFT = { id: "left", kind: "total", plus: [100], minus: ["src_lines"], unit: "lines" }; + +// A version 3 board whose measures are src_lines and `measures`. +function withTotals(...measures: Record[]): Record { + return { ...board(), board_version: 3, measures: [SRC_LINES, ...measures], ratios: [] }; +} + +const DOTTED_ID = + "must start with a lowercase letter and use only a-z, 0-9, '_', '-' and '.', with a lowercase letter after each '.'"; + +describe("parseBoardDeclaration: board_version 3, total", () => { + it("accepts a total of integers and measures, with or without what it takes away", () => { + const all = { id: "all", kind: "total", plus: ["src_lines", -2, 0], unit: "lines" }; + const result = parse(withTotals(LEFT, { ...all, lane: "cockpit" })); + if (!result.ok) throw new Error(result.errors.join("\n")); + expect(result.declaration.measures.slice(1)).toEqual([LEFT, { ...all, lane: ["cockpit"] }]); + }); + + it("refuses a total with no plus, an empty plus or minus, a term that is not an integer or an id, and what it does not take", () => { + const at = (over: Record) => errorsOf(parse(withTotals({ ...LEFT, ...over }))); + expect(at({ plus: undefined })).toEqual([ + "measures[1].plus: Invalid input: expected array, received undefined", + ]); + expect(at({ plus: [] })).toEqual([ + "measures[1].plus: must list at least one integer or measure id", + ]); + expect(at({ minus: [] })).toEqual([ + "measures[1].minus: must list at least one integer or measure id", + ]); + expect(at({ plus: "src_lines" })).toEqual([ + "measures[1].plus: Invalid input: expected array, received string", + ]); + const integer = "must be an integer from -9007199254740991 to 9007199254740991"; + const id = + "must be an integer or the id of a measure, which starts with a lowercase letter and uses only a-z, 0-9, '_', '-' and '.', with a lowercase letter after each '.'"; + expect(at({ plus: [1.5, "Src", true, null, "src.", 2 ** 53, "100"] })).toEqual([ + `measures[1].plus[0]: ${integer}`, + `measures[1].plus[1]: ${id}`, + "measures[1].plus[2]: must be an integer or the id of a measure", + "measures[1].plus[3]: must be an integer or the id of a measure", + `measures[1].plus[4]: ${id}`, + `measures[1].plus[5]: ${integer}`, + `measures[1].plus[6]: ${id}`, + ]); + // Integers, but not ones a number holds exactly. + expect( + at({ plus: [2 ** 53, -(2 ** 53), 1e300], minus: [2 ** 53 - 1, -(2 ** 53 - 1)] }), + ).toEqual([ + `measures[1].plus[0]: ${integer}`, + `measures[1].plus[1]: ${integer}`, + `measures[1].plus[2]: ${integer}`, + ]); + expect(at({ repo: "../basou", at: "main" })).toEqual([ + "measures[1]: unknown keys 'repo', 'at'", + ]); + }); + + it("refuses a term that names no measure, a total, itself included, or a measure that is not a number", () => { + const doc = withTotals( + LATEST, + { + id: "cap", + kind: "regex_capture", + repo: "../basou", + file: "a.md", + pattern: "(x)", + unit: "x", + }, + MIGRATIONS, + { + id: "t", + kind: "total", + plus: ["t", "nope", 1, "no.such"], + minus: ["src_lines"], + unit: "x", + }, + { + id: "u", + kind: "total", + plus: [1], + minus: ["latest_migration", "cap", "migrations", "u", "t"], + unit: "x", + }, + ); + expect(errorsOf(parse(doc))).toEqual([ + "measures[4].plus[0]: 't' is a total, which no total takes", + "measures[4].plus[1]: no measure has the id 'nope'", + "measures[4].plus[3]: no measure has the id 'no.such'", + "measures[5].minus[0]: 'latest_migration' is a file_name, which is not a number", + "measures[5].minus[1]: 'cap' is a regex_capture, which is not a number", + "measures[5].minus[2]: 'migrations' is a file_paths, which is not a number", + "measures[5].minus[3]: 'u' is a total, which no total takes", + "measures[5].minus[4]: 't' is a total, which no total takes", + ]); + }); + + it("takes a total in a ratio", () => { + const doc = withTotals(LEFT); + doc.ratios = [{ id: "r", label: "r", numerator: "left", denominator: "src_lines" }]; + expect(parse(doc).ok).toBe(true); + }); +}); + +describe("parseBoardDeclaration: board_version 3, a measure's lanes", () => { + const withLane = (lane: unknown) => withMeasureV3({ ...MIGRATIONS, lane }); + + it("reads one lane id, or a list of them, as a list", () => { + for (const [lane, read] of [ + ["cockpit", ["cockpit"]], + [["cockpit"], ["cockpit"]], + [ + ["cockpit", "saddle"], + ["cockpit", "saddle"], + ], + ] as const) { + const result = parse(withLane(lane)); + if (!result.ok) throw new Error(result.errors.join("\n")); + expect(result.declaration.measures[0]?.lane).toEqual(read); + } + const bare = parse(withMeasureV3(MIGRATIONS)); + if (!bare.ok) throw new Error(bare.errors.join("\n")); + expect(Object.hasOwn(bare.declaration.measures[0] as object, "lane")).toBe(false); + // The one lane id of a version 1 or 2 board is read as a list of one. + for (const doc of [board(), boardV2()]) { + const result = parse(doc); + if (!result.ok) throw new Error(result.errors.join("\n")); + expect(result.declaration.measures[0]?.lane).toEqual(["cockpit"]); + } + }); + + it("refuses an empty list, a lane listed twice or not on the board, and what is not a lane id", () => { + expect(errorsOf(parse(withLane([])))).toEqual([ + "measures[0].lane: must list at least one lane id", + ]); + expect(errorsOf(parse(withLane(["cockpit", "nope", "cockpit", "saddle"])))).toEqual([ + "measures[0].lane[1]: no lane has the id 'nope'", + "measures[0].lane[2]: 'cockpit' is listed twice", + ]); + // A lane not on the board is still listed twice. + expect(errorsOf(parse(withLane(["nope", "nope"])))).toEqual([ + "measures[0].lane[0]: no lane has the id 'nope'", + "measures[0].lane[1]: 'nope' is listed twice", + ]); + expect(errorsOf(parse(withLane("nope")))).toEqual([ + "measures[0].lane: no lane has the id 'nope'", + ]); + const laneId = "must start with a lowercase letter and use only a-z, 0-9, '_' and '-'"; + expect(errorsOf(parse(withLane(["Cockpit", "saddle"])))).toEqual([ + `measures[0].lane[0]: ${laneId}`, + ]); + // A lane id holds no '.', as a measure id may. + expect(errorsOf(parse(withLane("cockpit.a")))).toEqual([`measures[0].lane: ${laneId}`]); + expect(errorsOf(parse(withLane(5)))).toEqual([ + "measures[0].lane: must be a lane id or a non-empty list of lane ids", + ]); + expect(errorsOf(parse(withLane(["cockpit", 5])))).toEqual([ + "measures[0].lane: must be a lane id or a non-empty list of lane ids", + ]); + }); +}); + +describe("parseBoardDeclaration: board_version 3, a measure id and an observation key with '.'", () => { + it("accepts words joined by '.', and a ratio and a total that name such a measure", () => { + const pages = (id: string) => ({ + id, + kind: "file_count", + repo: "../basou", + include: [`${id}/*.md`], + unit: "pages", + }); + const doc: Record & { + measures: Record[]; + observe: Record[]; + } = { + ...board(), + board_version: 3, + measures: [ + pages("site.pages"), + pages("site.pages.en"), + pages("site"), + { id: "all.pages", kind: "total", plus: ["site.pages", "site.pages.en"], unit: "pages" }, + ], + ratios: [{ id: "en", label: "en", numerator: "site.pages.en", denominator: "all.pages" }], + observe: [ + { key: "site.en.version", kind: "page_version", url: "https://example.com/" }, + { key: "shop.item-1", kind: "http", url: "https://example.com/i" }, + ], + }; + const result = parse(doc); + if (!result.ok) throw new Error(result.errors.join("\n")); + expect(result.declaration.measures.map((m) => m.id)).toEqual([ + "site.pages", + "site.pages.en", + "site", + "all.pages", + ]); + expect(result.declaration.observe.map((o) => o.key)).toEqual([ + "site.en.version", + "shop.item-1", + ]); + doc.measures = [...doc.measures, pages("site.pages")]; + doc.observe = [...doc.observe, { key: "shop.item-1", kind: "manual", how: "ask" }]; + expect(errorsOf(parse(doc))).toEqual([ + "measures[4].id: duplicate measure id 'site.pages' (first at measures[0])", + "observe[2].key: duplicate observation key 'shop.item-1' (first at observe[1])", + ]); + }); + + it("refuses a '.' at an end or after another, a word that starts with no lowercase letter, ':' and '/'", () => { + for (const id of [ + "a.", + ".a", + "a..b", + "a.B", + "a.1", + "a._", + "a:b", + "a/b", + "a. b", + "site.pages.", + "a.b..c", + "a.b._x", + "a.b.C", + "a.b.c:d", + ]) { + expect(errorsOf(parse(withMeasureV3({ ...MIGRATIONS, id }))), id).toEqual([ + `measures[0].id: ${DOTTED_ID}`, + ]); + expect(errorsOf(parse(withHttp({ key: id, url: "https://example.com/" }))), id).toEqual([ + `observe[0].key: ${DOTTED_ID}`, + ]); + } + const doc = withTotals(LEFT); + doc.ratios = [{ id: "r", label: "r", numerator: "src.", denominator: "no.such" }]; + expect(errorsOf(parse(doc))).toEqual([ + `ratios[0].numerator: ${DOTTED_ID}`, + "ratios[0].denominator: no measure has the id 'no.such'", + ]); + }); + + it("keeps '.' out of a lane id and a ratio id", () => { + const laneId = "must start with a lowercase letter and use only a-z, 0-9, '_' and '-'"; + const doc = withTotals(LEFT); + doc.lanes = [...(doc.lanes as unknown[]), { id: "a.b", name: "A" }]; + doc.ratios = [{ id: "r.s", label: "r", numerator: "left", denominator: "src_lines" }]; + expect(errorsOf(parse(doc))).toEqual([`lanes[2].id: ${laneId}`, `ratios[0].id: ${laneId}`]); + }); +}); + +describe("parseBoardDeclaration: what version 3 reads, in a board of version 1 or 2", () => { + it("says that version 3 reads a total, a list of lanes, and an id or a key with '.'", () => { + for (const version of [1, 2]) { + const doc = version === 1 ? board() : boardV2(); + doc.measures = [ + { ...SRC_LINES, lane: ["saddle", "cockpit"] }, + { id: "site.pages", kind: "file_count", repo: "../basou", include: ["s/*"], unit: "p" }, + LEFT, + ]; + doc.ratios = [{ id: "r", label: "r", numerator: "site.pages", denominator: "src_lines" }]; + doc.observe = [{ key: "site.en", kind: "page_version", url: "https://example.com/" }]; + const told = (where: string, what: string) => + `${where}: ${what} read from board_version 3 on; this file is board_version ${version}`; + expect(errorsOf(parse(doc))).toEqual([ + told("measures[0].lane", "what is written here is"), + told("measures[1].id", "'site.pages' is"), + told("measures[2].kind", "'total' is"), + told("ratios[0].numerator", "'site.pages' is"), + version === 1 + ? told("(top level)", "'observe' is") + : told("observe[0].key", "'site.en' is"), + ]); + } + }); + + it("holds what version 3 reads against the rest of the board, beside the line saying so", () => { + for (const version of [1, 2]) { + const doc = version === 1 ? board() : boardV2(); + doc.measures = [ + { ...SRC_LINES, lane: ["nope", "nope"] }, + { ...LEFT, plus: ["gone"], minus: ["src_lines"] }, + ]; + doc.ratios = []; + const told = (where: string, what: string) => + `${where}: ${what} read from board_version 3 on; this file is board_version ${version}`; + expect(errorsOf(parse(doc))).toEqual([ + told("measures[0].lane", "what is written here is"), + told("measures[1].kind", "'total' is"), + "measures[0].lane[0]: no lane has the id 'nope'", + "measures[0].lane[1]: 'nope' is listed twice", + "measures[1].plus[0]: no measure has the id 'gone'", + ]); + } + }); +}); diff --git a/packages/core/src/board/declaration.ts b/packages/core/src/board/declaration.ts index 16c8564..19d093e 100644 --- a/packages/core/src/board/declaration.ts +++ b/packages/core/src/board/declaration.ts @@ -18,9 +18,11 @@ 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, 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. + * observations, what to mind when making any observation (`notes`), the + * name of a file (`file_name`), the paths of files (`file_paths`) and a sum + * of integers and other measures (`total`) as measures, a measure drawn in + * more than one lane (`lane` as a list), and a measure id and an observation + * key of words joined by '.'. */ export const BOARD_VERSION = 3; @@ -43,6 +45,10 @@ export const BOARD_DEFAULT_AT = "worktree"; export const BOARD_DEFAULT_CAPTURE_GROUP = 1; const ID_PATTERN = /^[a-z][a-z0-9_-]*$/; +// From version 3, a measure's id, and an observation's key, may be words of +// that shape joined by '.'. Not ':' or '/': the guide names working files by +// a key. +const DOTTED_ID_PATTERN = /^[a-z][a-z0-9_-]*(\.[a-z][a-z0-9_-]*)*$/; const DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/; // The C0 controls, DEL and the C1 controls. @@ -62,6 +68,11 @@ const idText = z.string().regex(ID_PATTERN, { error: "must start with a lowercase letter and use only a-z, 0-9, '_' and '-'", }); +const DOTTED_ID_ERROR = + "must start with a lowercase letter and use only a-z, 0-9, '_', '-' and '.', with a lowercase letter after each '.'"; + +const dottedIdText = z.string().regex(DOTTED_ID_PATTERN, { error: DOTTED_ID_ERROR }); + /** Whether a text is a date of the calendar written as YYYY-MM-DD. */ export function isCalendarDate(s: string): boolean { const m = DATE_PATTERN.exec(s); @@ -160,10 +171,25 @@ const captureSectionSchema = z.strictObject({ on_missing: z.null({ error: "must be null for regex_capture (a string has no zero)" }), }); +// A measure's lane is read as a list, of one in a board of version 1 or 2, +// so a record holds the lanes of a measure in one shape whatever the version. const measureCommon = { id: idText, unit: nonEmptyText, - lane: idText.optional(), + lane: idText.transform((lane) => [lane]).optional(), +}; + +// From version 3, a measure's id may hold '.', and it may be drawn in more +// than one lane: `lane` is one lane id or a list of them. +const measureCommonV3 = { + id: dottedIdText, + unit: nonEmptyText, + lane: z + .union([idText, z.array(idText).min(1, { error: "must list at least one lane id" })], { + error: "must be a lane id or a non-empty list of lane ids", + }) + .transform((lane) => (typeof lane === "string" ? [lane] : lane)) + .optional(), }; const fileScope = { @@ -270,6 +296,35 @@ const filePathsSchema = z.strictObject({ exclude: globs.optional(), }); +// A term of a total: an integer written in the declaration, one a number +// holds exactly, or the id of a measure whose value is one. +const totalTerm = z + .union([z.number(), z.string()], { error: "must be an integer or the id of a measure" }) + .superRefine((term, ctx) => { + const problem = + typeof term === "number" + ? Number.isSafeInteger(term) + ? undefined + : `must be an integer from -${Number.MAX_SAFE_INTEGER} to ${Number.MAX_SAFE_INTEGER}` + : DOTTED_ID_PATTERN.test(term) + ? undefined + : "must be an integer or the id of a measure, which starts with a lowercase letter and uses only a-z, 0-9, '_', '-' and '.', with a lowercase letter after each '.'"; + if (problem !== undefined) ctx.addIssue({ code: "custom", message: problem }); + }); + +const totalTerms = z.array(totalTerm).min(1, { + error: "must list at least one integer or measure id", +}); + +// What `plus` adds up less what `minus` adds up. A term may not be a total +// (see crossCheck), so totals are never chained and have no order to find. +const totalSchema = z.strictObject({ + kind: z.literal("total"), + ...measureCommonV3, + plus: totalTerms, + minus: totalTerms.optional(), +}); + /** What a `trail_count` measure counts in the workspace's own trail. */ export const BOARD_TRAIL_COUNTS = [ "decisions_all", @@ -295,17 +350,19 @@ const measureSchemaV1 = z.discriminatedUnion("kind", [ trailCountSchema, ]); -// From version 3, a measure may take the name of a file or the paths of files. +// From version 3, a measure may take the name of a file, the paths of files +// or a total, and every measure takes the id and the lanes of version 3. const measureSchemaV3 = z.discriminatedUnion("kind", [ - fileCountSchema, - dirCountSchema, - lineCountSchema, - matchCountSchema, - regexCaptureSchema, - jsonLengthSchema, - fileNameSchema, - filePathsSchema, - trailCountSchema, + fileCountSchema.extend(measureCommonV3), + dirCountSchema.extend(measureCommonV3), + lineCountSchema.extend(measureCommonV3), + matchCountSchema.extend(measureCommonV3), + regexCaptureSchema.extend(measureCommonV3), + jsonLengthSchema.extend(measureCommonV3), + fileNameSchema.extend(measureCommonV3), + filePathsSchema.extend(measureCommonV3), + trailCountSchema.extend(measureCommonV3), + totalSchema, ]); /** @@ -539,19 +596,19 @@ const observeSchemaV2 = z.discriminatedUnion("kind", [ ]); // From version 3, every observation may say what to mind when making it, in -// the shape of a stage's or a lane's `notes`. -const observeNotes = { notes: z.array(nonEmptyText).optional() }; +// the shape of a stage's or a lane's `notes`, and its key may hold '.'. +const observeV3 = { key: dottedIdText, notes: z.array(nonEmptyText).optional() }; const observeSchemaV3 = z.discriminatedUnion("kind", [ - npmVersionObserve.extend(observeNotes), - githubReleaseObserve.extend(observeNotes), - githubOpenIssuesObserve.extend(observeNotes), - githubOpenPrsObserve.extend(observeNotes), - githubCiObserve.extend(observeNotes), - githubCheckObserve.extend(observeNotes), - pageVersionObserve.extend(observeNotes), - httpObserve.extend(observeNotes), - manualObserve.extend(observeNotes), + npmVersionObserve.extend(observeV3), + githubReleaseObserve.extend(observeV3), + githubOpenIssuesObserve.extend(observeV3), + githubOpenPrsObserve.extend(observeV3), + githubCiObserve.extend(observeV3), + githubCheckObserve.extend(observeV3), + pageVersionObserve.extend(observeV3), + httpObserve.extend(observeV3), + manualObserve.extend(observeV3), ]); const stageSchemaV1 = z.strictObject({ meaning: nonEmptyText }); @@ -573,8 +630,9 @@ const boardVersionText = z.literal(BOARD_VERSIONS, { }); // The keys every version has after its stages, in the order a problem with -// them is reported, with the measures the version reads. -const boardFieldsOf = (measure: M) => ({ +// them is reported, with the measures the version reads and the id a ratio +// names a measure by. +const boardFieldsOf = (measure: M, measureRef: z.ZodString) => ({ lanes: z .array( z.strictObject({ @@ -591,8 +649,8 @@ const boardFieldsOf = (measure: M) => ({ z.strictObject({ id: idText, label: nonEmptyText, - numerator: idText, - denominator: idText, + numerator: measureRef, + denominator: measureRef, }), ) .default([]), @@ -632,14 +690,14 @@ const boardSchemaV1 = z.strictObject({ board_version: boardVersionText, title: nonEmptyText, stages: stagesOf(stageSchemaV1), - ...boardFieldsOf(measureSchemaV1), + ...boardFieldsOf(measureSchemaV1, idText), }); const boardSchemaV2 = z.strictObject({ board_version: boardVersionText, title: nonEmptyText, stages: stagesOf(stageSchemaV2), - ...boardFieldsOf(measureSchemaV1), + ...boardFieldsOf(measureSchemaV1, idText), observe: z.array(observeSchemaV2).default([]), }); @@ -647,15 +705,17 @@ const boardSchemaV3 = z.strictObject({ board_version: boardVersionText, title: nonEmptyText, stages: stagesOf(stageSchemaV2), - ...boardFieldsOf(measureSchemaV3), + ...boardFieldsOf(measureSchemaV3, dottedIdText), 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 1 or 2 - * has no `http` or `github_check` observation, no observation with `notes` - * and no `file_name` or `file_paths` measure. + * has no `http` or `github_check` observation, no observation with `notes`, + * no `file_name`, `file_paths` or `total` measure, no measure of more than + * one lane, and no measure id or observation key with '.'. A measure's + * `lane` is a list whatever the version. */ export type BoardDeclaration = z.output; export type BoardMeasure = BoardDeclaration["measures"][number]; @@ -1016,6 +1076,13 @@ function isId(value: string | undefined): value is string { return value !== undefined && ID_PATTERN.test(value); } +// A measure's id as a version 3 board accepts it. In a board of version 1 or +// 2, one with '.' is reported by the shape check as read from version 3 on, +// and is looked up all the same. +function isMeasureId(value: string | undefined): value is string { + return value !== undefined && DOTTED_ID_PATTERN.test(value); +} + // The kinds of measure that read a repository. const REPO_KINDS = new Set([ "file_count", @@ -1028,7 +1095,8 @@ const REPO_KINDS = new Set([ "file_paths", ]); -// The kinds of measure whose value is not a number, so no ratio takes them. +// The kinds of measure whose value is not a number, so neither a ratio nor a +// total 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. @@ -1044,7 +1112,11 @@ function captureGroups(pattern: string): number | undefined { // What one key says about another. Run on the raw document, so these are // reported even when the shape check found problems elsewhere. Anything the // shape check already refuses is skipped here: a list that is not a list, a -// key the kind does not take, a value of the wrong type or form. +// key the kind does not take, a value of the wrong type or form. What a later +// version reads is not skipped in a board of version 1 or 2 (a measure's list +// of lanes, a total's terms, a measure id with '.'): it is held against the +// rest of the board as the later version holds it, beside the line saying +// which version reads it. function crossCheck(raw: Record, context: BoardDeclarationContext): string[] { const errors: string[] = []; @@ -1108,6 +1180,15 @@ 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 (laneIds !== undefined && Array.isArray(measure.lane)) { + const seen = new Set(); + measure.lane.forEach((id: unknown, j) => { + if (typeof id !== "string" || !isId(id)) return; + if (seen.has(id)) errors.push(`${at}.lane[${j}]: '${id}' is listed twice`); + else if (!laneIds.has(id)) errors.push(`${at}.lane[${j}]: no lane has the id '${id}'`); + seen.add(id); + }); + } if ( measure.kind === "file_name" && measure.pattern === undefined && @@ -1144,18 +1225,33 @@ function crossCheck(raw: Record, context: BoardDeclarationConte } if (measureIds !== undefined) { + // Why a ratio or a total cannot take the measure `ref` names, or undefined. + const notTaken = (ref: string, inTotal: boolean): string | undefined => { + if (!measureIds.has(ref)) return `no measure has the id '${ref}'`; + const kind = measureKinds.get(ref); + if (typeof kind !== "string") return undefined; + if (NOT_NUMBER_KINDS.has(kind)) return `'${ref}' is a ${kind}, which is not a number`; + if (inTotal && kind === "total") return `'${ref}' is a total, which no total takes`; + return undefined; + }; for (const [i, ratio] of entries(raw, "ratios") ?? []) { for (const side of ["numerator", "denominator"] as const) { const ref = stringAt(ratio, side); - if (!isId(ref)) continue; - if (!measureIds.has(ref)) { - errors.push(`ratios[${i}].${side}: no measure has the id '${ref}'`); - } 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`); - } - } + if (!isMeasureId(ref)) continue; + const problem = notTaken(ref, false); + if (problem !== undefined) errors.push(`ratios[${i}].${side}: ${problem}`); + } + } + for (const [i, measure] of entries(raw, "measures") ?? []) { + if (measure.kind !== "total") continue; + for (const side of ["plus", "minus"] as const) { + const terms = measure[side]; + if (!Array.isArray(terms)) continue; + terms.forEach((term: unknown, j) => { + if (typeof term !== "string" || !isMeasureId(term)) return; + const problem = notTaken(term, true); + if (problem !== undefined) errors.push(`measures[${i}].${side}[${j}]: ${problem}`); + }); } } } diff --git a/packages/core/src/board/diff.test.ts b/packages/core/src/board/diff.test.ts index 00e5e6d..b238b57 100644 --- a/packages/core/src/board/diff.test.ts +++ b/packages/core/src/board/diff.test.ts @@ -214,6 +214,32 @@ describe("diffMeasurements", () => { }); }); +describe("diffMeasurements: a measure's lanes", () => { + const withLane = (lane?: unknown) => + measurement({ + measures: { md: { value: 2, unit: "files", ...(lane === undefined ? {} : { lane }) } }, + }); + + it("reads one lane id, as a record of version 1 or 2 holds it, as a list of that one", () => { + expect(diffMeasurements(withLane("core"), withLane(["core"]), "01M4")).toEqual({ + against: "01M4", + values: [], + methods: [], + added: [], + removed: [], + }); + expect(diffMeasurements(withLane("core"), withLane(["core", "docs"]), "01M4").values).toEqual([ + { at: "measures.md.lane", before: ["core"], after: ["core", "docs"] }, + ]); + expect(diffMeasurements(withLane(), withLane(["core"]), "01M4").added).toEqual([ + { at: "measures.md.lane", value: ["core"] }, + ]); + expect(diffMeasurements(withLane("core"), withLane(), "01M4").removed).toEqual([ + { at: "measures.md.lane", value: ["core"] }, + ]); + }); +}); + 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({ diff --git a/packages/core/src/board/diff.ts b/packages/core/src/board/diff.ts index cde1914..a62b728 100644 --- a/packages/core/src/board/diff.ts +++ b/packages/core/src/board/diff.ts @@ -25,7 +25,9 @@ export type BoardMethodChange = { section: string; before: number | null; after: * either, nor the declaration, which the digest also covers) 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 + * previous record. Values are equal when their JSON is, keys sorted, except + * that a measure's lane held as one id, as a record of version 1 or 2 holds + * it, is the list of that one a record of version 3 holds. 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, the * paths of a `file_paths` measure as a set when both records hold a list (a @@ -74,6 +76,14 @@ function shownKey(shown: string, key: string): string { type Leaf = { value: unknown; section: string }; +// A measure as a record of version 3 holds it: a record of version 1 or 2 +// holds its lane as one id, which is the same lane as a list of that one. +function withLaneList(entry: unknown): unknown { + return isRecord(entry) && typeof entry.lane === "string" + ? { ...entry, lane: [entry.lane] } + : entry; +} + // 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. @@ -97,7 +107,12 @@ function leaves(measurement: unknown, sets: ReadonlySet): Map { ); }); + it("says what a total's value is, that it may be below 0, and when it is null", () => { + expect(boardGuide(guideInput())).toContain( + "- A `total` measure's value is what its `plus` adds up to less what its `minus` adds up to, and may be below 0. It is null when a measure it takes was not measured.", + ); + }); + 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.", @@ -514,7 +520,7 @@ describe("boardGuide: the gate on readers, the Stop hook and where a measure is it("says, before a board is declared, which measures the page draws and who may read the repo", () => { const guide = boardGuide(guideInput({ board: { status: "undeclared" } })); expect(guide).toContain( - "A measure with `lane:` 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).", + "A measure with `lane:` (a lane id, or a list of them) is drawn in each of those lanes' sections of the board page; one without is kept only in the records and their differences (and in a ratio or a total that uses it).", ); expect(guide).toContain( "no collaborator but the owner and you (the account gh is signed in as), and no invitation", diff --git a/packages/core/src/board/guide.ts b/packages/core/src/board/guide.ts index 20f5a5a..82000a5 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 `total` measure's value is what its `plus` adds up to less what its `minus` adds up to, and may be below 0. It is null when a measure it takes was not measured.", "- 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.", "", @@ -792,7 +793,7 @@ function undeclared( ); block(`${go} && ${BOARD_INIT_SAVE}; echo "exit=$?"`); line( - "3. Cut the axis, with the operator. The board asks what can be used now and what is stuck, not what was built. Replace the sample lane with lanes cut by who uses what, not by repo. Fix what each of the six stages means for this product, keeping 04 (merged) apart from 05 (in users' hands): merged but closed to users is used by no one. Declare under `observe` what is reached outside the repos (a registry, a release, a site). Declare under `effort.milestones` the days to mark on the period and effort chart: only what was reached, each with a `ref` saying where it can be checked; a board moved from another keeps its milestones there. Measures, ratios and components may stay empty at first. A measure with `lane:` 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).", + "3. Cut the axis, with the operator. The board asks what can be used now and what is stuck, not what was built. Replace the sample lane with lanes cut by who uses what, not by repo. Fix what each of the six stages means for this product, keeping 04 (merged) apart from 05 (in users' hands): merged but closed to users is used by no one. Declare under `observe` what is reached outside the repos (a registry, a release, a site). Declare under `effort.milestones` the days to mark on the period and effort chart: only what was reached, each with a `ref` saying where it can be checked; a board moved from another keeps its milestones there. Measures, ratios and components may stay empty at first. A measure with `lane:` (a lane id, or a list of them) is drawn in each of those lanes' sections of the board page; one without is kept only in the records and their differences (and in a ratio or a total that uses it).", "4. Check it until it reads (every problem with it is listed at once):", ); block(`${go} && basou board measure --model "$MODEL" > "$W/measure.txt"; echo "exit=$?"`); diff --git a/packages/core/src/board/init.test.ts b/packages/core/src/board/init.test.ts index 2e23594..10927e5 100644 --- a/packages/core/src/board/init.test.ts +++ b/packages/core/src/board/init.test.ts @@ -47,13 +47,15 @@ describe("boardInitText", () => { }); expect(text).toContain(`# ${words.comments.measures}\nmeasures: []\n`); } - expect(en.comments.measures).toContain("One with a lane is drawn in that lane's section"); + expect(en.comments.measures).toContain( + "One with a lane, or a list of lanes, is drawn in each one's section", + ); // The comment on observe names every kind a board can declare, http among them. for (const kind of BOARD_OBSERVE_KINDS) { 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. + // The comment on measures names every kind a board can declare, file_name, file_paths and total among them. for (const kind of [ "file_count", "dir_count", @@ -64,11 +66,14 @@ describe("boardInitText", () => { "file_name", "file_paths", "trail_count", + "total", ]) { expect(en.comments.measures).toContain(kind); expect(ja.comments.measures).toContain(kind); } - expect(ja.comments.measures).toContain("lane を付けたものは盤のページのそのレーンの節に描かれ"); + expect(ja.comments.measures).toContain( + "lane\uff081 \u3064\u306e\u30ec\u30fc\u30f3\u304b\u3001\u30ec\u30fc\u30f3\u306e\u4e26\u3073\uff09\u3092\u4ed8\u3051\u305f\u3082\u306e\u306f\u76e4\u306e\u30da\u30fc\u30b8\u306e\u305d\u308c\u305e\u308c\u306e\u30ec\u30fc\u30f3\u306e\u7bc0\u306b\u63cf\u304b\u308c", + ); }); it("writes the words in Japanese for a Japanese anchor", () => { diff --git a/packages/core/src/board/measure.test.ts b/packages/core/src/board/measure.test.ts index 4133228..ff25fa3 100644 --- a/packages/core/src/board/measure.test.ts +++ b/packages/core/src/board/measure.test.ts @@ -894,7 +894,7 @@ describe("measureBoard: ratios, the digest and the result's shape", () => { measured_at: "2026-10-05T03:00:00.000Z", measured_with: WITH, measures: { - src: { value: 4, unit: "lines", lane: "core" }, + src: { value: 4, unit: "lines", lane: ["core"] }, test: { value: 2, unit: "lines" }, }, }); @@ -4509,7 +4509,7 @@ describe("measureBoard: the name of a file and the paths of files", () => { "db/migrations/schema.sql", ], unit: "files", - lane: "core", + lane: ["core"], }); expect(measured(m, "at_main")).toEqual([ "db/migrations/20260101_init.sql", @@ -4671,3 +4671,161 @@ describe.skipIf(process.platform === "win32")( }); }, ); + +describe("measureBoard: totals, a measure's lanes and ids with '.'", () => { + // A version 3 declaration over the repos "app" and "web", with two lanes. + const declare3 = (measures: Record[], extra: Record = {}) => + declare( + measures, + { + board_version: 3, + lanes: [ + { id: "core", name: "Core" }, + { id: "site", name: "Site" }, + ], + ...extra, + }, + ["app", "web"], + ); + const files = ( + id: string, + repoPath: string, + include: string[], + over: Record = {}, + ) => ({ id, kind: "file_count", repo: repoPath, include, unit: "files", ...over }); + const ofMeasures = (m: BoardMeasurement) => + m.not_found.filter((n) => n.at.startsWith("measures")); + + beforeEach(async () => { + await repo("app", { "src/a.ts": "", "src/b.ts": "", "src/c.ts": "" }); + await repo("web", { "pages/x.md": "", "pages/y.md": "" }); + }); + + it("adds up plus and takes away minus, over measures of two repos and integers, below 0 too", async () => { + const m = await measure( + declare3([ + { id: "left", kind: "total", plus: [4], minus: ["app.src", "web.pages"], unit: "slots" }, + files("app.src", "app", ["src/*.ts"]), + files("web.pages", "web", ["pages/*.md"]), + { id: "all", kind: "total", plus: ["app.src", "web.pages", 10], unit: "files" }, + { id: "twice", kind: "total", plus: ["app.src", "app.src"], minus: [-1], unit: "files" }, + { id: "none", kind: "total", plus: [0], unit: "files" }, + ]), + NOW, + ["app", "web"], + ); + expect(measured(m, "left")).toBe(-1); + expect(measured(m, "all")).toBe(15); + expect(measured(m, "twice")).toBe(7); + expect(measured(m, "none")).toBe(0); + expect(ofMeasures(m)).toEqual([]); + // In the order declared, a total ahead of what it takes as well. + expect(Object.keys(m.measures)).toEqual([ + "left", + "app.src", + "web.pages", + "all", + "twice", + "none", + ]); + expect(m.measures.left).toEqual({ value: -1, unit: "slots" }); + }); + + it("is null when a measure it takes was not measured, naming each once in order", async () => { + const m = await measure( + declare3([ + files("gone", "app", ["missing/*.ts"]), + files("app.src", "app", ["src/*.ts"]), + files("web.none", "web", ["none/*.md"]), + { + id: "sum", + kind: "total", + plus: ["gone", "app.src", "gone"], + minus: ["web.none"], + unit: "files", + }, + { id: "one", kind: "total", plus: [1], minus: ["gone"], unit: "files" }, + { id: "fine", kind: "total", plus: ["app.src"], unit: "files" }, + ]), + NOW, + ["app", "web"], + ); + expect(measured(m, "sum")).toBeNull(); + expect(measured(m, "one")).toBeNull(); + expect(measured(m, "fine")).toBe(3); + // An id with '.' is named as a key, not as a path into a measure. + expect(ofMeasures(m)).toEqual([ + { at: "measures.gone", reason: "nothing is under 'missing' in the working tree" }, + { at: 'measures["web.none"]', reason: "nothing is under 'none' in the working tree" }, + { at: "measures.sum", reason: "'gone', 'web.none' are not measured" }, + { at: "measures.one", reason: "'gone' is not measured" }, + ]); + expect(m.complete).toBe(false); + }); + + it("is exact up to the integers a number holds exactly, and null past them", async () => { + const max = Number.MAX_SAFE_INTEGER; + const m = await measure( + declare3([ + { id: "top", kind: "total", plus: [max], unit: "n" }, + { id: "bottom", kind: "total", plus: [0], minus: [max], unit: "n" }, + // max + 2 is not held exactly, but what the total comes to is. + { id: "back", kind: "total", plus: [max, 2], minus: [3], unit: "n" }, + { id: "over", kind: "total", plus: [max, 1], unit: "n" }, + { id: "under", kind: "total", plus: [-1], minus: [max], unit: "n" }, + files("gone", "app", ["missing/*.ts"]), + // What it lacks is said before what it would come to. + { id: "lacking", kind: "total", plus: [max, 1, "gone"], unit: "n" }, + ]), + ); + expect(measured(m, "top")).toBe(max); + expect(measured(m, "bottom")).toBe(-max); + expect(measured(m, "back")).toBe(max - 1); + const past = `the total is out of the range a number holds exactly (-${max} to ${max})`; + expect(ofMeasures(m)).toEqual([ + { at: "measures.over", reason: past }, + { at: "measures.under", reason: past }, + { at: "measures.gone", reason: "nothing is under 'missing' in the working tree" }, + { at: "measures.lacking", reason: "'gone' is not measured" }, + ]); + }); + + it("keeps a measure's lanes as a list, of one or more, and none when it has none", async () => { + const m = await measure( + declare3([ + files("one", "app", ["src/*.ts"], { lane: "core" }), + files("two", "app", ["src/*.ts"], { lane: ["site", "core"] }), + files("bare", "app", ["src/*.ts"]), + { id: "sum", kind: "total", plus: ["one"], unit: "files", lane: ["site"] }, + ]), + NOW, + ["app"], + ); + expect(m.measures.one).toEqual({ value: 3, unit: "files", lane: ["core"] }); + expect(m.measures.two).toEqual({ value: 3, unit: "files", lane: ["site", "core"] }); + expect(m.measures.bare).toEqual({ value: 3, unit: "files" }); + expect(m.measures.sum).toEqual({ value: 3, unit: "files", lane: ["site"] }); + }); + + it("reads the lane of a version 1 board as a list of one, in the measurement and the snapshot", async () => { + const d = declare([files("one", "app", ["src/*.ts"], { lane: "core" })], {}, ["app", "web"]); + const m = await measure(d, NOW, ["app"]); + expect(m.measures.one?.lane).toEqual(["core"]); + expect(boardDeclarationSnapshot(d).measures[0]?.lane).toEqual(["core"]); + }); + + it("takes a total, and a measure whose id holds '.', in a ratio", async () => { + const m = await measure( + declare3( + [ + files("app.src", "app", ["src/*.ts"]), + { id: "all", kind: "total", plus: ["app.src", 3], unit: "files" }, + ], + { ratios: [{ id: "share", label: "share", numerator: "app.src", denominator: "all" }] }, + ), + NOW, + ["app"], + ); + expect(m.ratios.share).toEqual({ value: 0.5, numerator: "app.src", denominator: "all" }); + }); +}); diff --git a/packages/core/src/board/measure.ts b/packages/core/src/board/measure.ts index 2a1f617..9f0d018 100644 --- a/packages/core/src/board/measure.ts +++ b/packages/core/src/board/measure.ts @@ -14,6 +14,7 @@ import { type BoardDeclarationSnapshot, type BoardMeasure, boardDeclarationSnapshot, + formatPath, } from "./declaration.js"; import { type BoardDiff, diffMeasurements } from "./diff.js"; import { BOARD_EFFORT_METHOD, type BoardEffort, measureEffort, todayIn } from "./effort.js"; @@ -66,7 +67,8 @@ export type BoardMeasureValue = { /** A number, a string, or the paths a `file_paths` measure found. */ value: number | string | string[] | null; unit: string; - lane?: string; + /** The lanes it is drawn in, always a list (absent when it has none). */ + lane?: string[]; }; export type BoardRatioValue = { @@ -215,16 +217,26 @@ export async function measureBoard(input: MeasureBoardInput): Promise(); + for (const measure of declaration.measures) { + if (measure.kind === "total") continue; + outcomes.set(measure.id, await measureOne(measure, scopeOf, decisionsOf, tasksOf)); + } + for (const measure of declaration.measures) { + if (measure.kind === "total") outcomes.set(measure.id, totalOf(measure, outcomes)); + } const measures: Record = {}; for (const measure of declaration.measures) { - const outcome = await measureOne(measure, scopeOf, decisionsOf, tasksOf); + // Every measure has its outcome: the ids are not repeated. + const outcome = outcomes.get(measure.id) as Outcome; if (outcome.reason !== undefined) { - notFound.push({ at: `measures.${measure.id}`, reason: outcome.reason }); + notFound.push({ at: formatPath(["measures", measure.id]), reason: outcome.reason }); } measures[measure.id] = { value: outcome.value, unit: measure.unit, - ...(measure.lane === undefined ? {} : { lane: measure.lane }), + ...(measure.lane === undefined ? {} : { lane: [...measure.lane] }), }; } @@ -510,8 +522,42 @@ function pointAt(document: unknown, pointer: string): unknown { return current; } +// The largest integer a number holds exactly: a total above it, or below +// its negative, would not be exact. +const TOTAL_MAX = BigInt(Number.MAX_SAFE_INTEGER); + +// What `plus` adds up less what `minus` adds up, exactly. Null, naming them, +// when a measure it takes was not measured: a part is not the whole. +function totalOf( + measure: Extract, + outcomes: ReadonlyMap, +): Outcome { + const missing: string[] = []; + const sum = (terms: readonly (number | string)[]): bigint => { + let out = 0n; + for (const term of terms) { + const value = typeof term === "number" ? term : outcomes.get(term)?.value; + // Every measure that counts gives a safe integer, as a term written is. + if (typeof value === "number" && Number.isSafeInteger(value)) out += BigInt(value); + else if (typeof term === "string" && !missing.includes(term)) missing.push(term); + } + return out; + }; + const value = sum(measure.plus) - sum(measure.minus ?? []); + if (missing.length > 0) { + const named = missing.map((id) => `'${id}'`).join(", "); + return fail(`${named} ${missing.length === 1 ? "is" : "are"} not measured`); + } + if (value > TOTAL_MAX || value < -TOTAL_MAX) { + return fail( + `the total is out of the range a number holds exactly (-${TOTAL_MAX} to ${TOTAL_MAX})`, + ); + } + return { value: Number(value) }; +} + async function measureOne( - measure: BoardMeasure, + measure: Exclude, scopeOf: (repo: string, at: string) => Promise, decisionsOf: () => Promise, tasksOf: () => Promise, diff --git a/packages/core/src/board/page.test.ts b/packages/core/src/board/page.test.ts index 1d2cf86..ef78245 100644 --- a/packages/core/src/board/page.test.ts +++ b/packages/core/src/board/page.test.ts @@ -274,6 +274,59 @@ describe("boardPage", () => { expect(await boardPage(records)).toMatchObject({ why: "not_a_record" }); }); + it("draws a measure of more than one lane in each of them, and one lane id of an older record in its lane", async () => { + await mkdir(records); + const v3 = record({ record_version: 3 }); + v3.declaration = { + ...v3.declaration, + measures: [ + { id: "site.pages", kind: "file_count", unit: "pages", lane: ["docs", "core"] }, + { id: "src", kind: "line_count", unit: "lines", lane: ["core"] }, + { id: "left", kind: "total", unit: "slots" }, + ], + ratios: [], + } as typeof v3.declaration; + v3.measure = { + ...v3.measure, + measures: { + "site.pages": { value: 12, unit: "pages", lane: ["docs", "core"] }, + src: { value: 100, unit: "lines", lane: ["core"] }, + left: { value: -3, unit: "slots" }, + }, + 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.map((lane) => [lane.id, lane.measures])).toEqual([ + [ + "core", + [ + { id: "site.pages", value: 12, unit: "pages" }, + { id: "src", value: 100, unit: "lines" }, + ], + ], + ["docs", [{ id: "site.pages", value: 12, unit: "pages" }]], + ]); + // A record of version 1 holds one lane id: it is the lane of that id, + // not of every id it holds. + const v1 = record(); + v1.declaration = { + ...v1.declaration, + lanes: [ + { id: "co", name: "Co" }, + { id: "core", name: "Core" }, + ], + } as typeof v1.declaration; + await place(B, v1); + const older = await boardPage(records); + if (older.status !== "ok") throw new Error(older.why); + expect(older.board.lanes.map((lane) => [lane.id, lane.measures])).toEqual([ + ["co", []], + ["core", [{ id: "src", value: 100, unit: "lines" }]], + ]); + }); + 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 b0efd32..25e523f 100644 --- a/packages/core/src/board/page.ts +++ b/packages/core/src/board/page.ts @@ -31,7 +31,10 @@ const pageRecordSchema = z.looseObject({ notes: z.array(text).optional(), }), ), - measures: z.array(z.looseObject({ id: text, unit: text, lane: text.optional() })), + // A list in a record of version 3, one id in one of version 1 or 2. + measures: z.array( + z.looseObject({ id: text, unit: text, lane: z.union([text, z.array(text)]).optional() }), + ), ratios: z.array(z.looseObject({ id: text, label: text, numerator: text, denominator: text })), axis: z.looseObject({ version: z.number() }), effort: z.looseObject({ @@ -160,7 +163,10 @@ export type BoardPageLane = { now: { stage: string; meaning: string; state: string } | null; attention: { stage: string; state: string; reason?: string }[]; prose: string | null; - /** Each measure drawn in the lane: a number, a string, or the paths of a `file_paths`. */ + /** + * Each measure drawn in the lane, one of more than one lane in each: 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 }; }; @@ -514,7 +520,11 @@ function bodyOf(r: PageRecord, previous: Previous): BoardPageBody { ), prose: Object.hasOwn(r.prose.lanes, lane.id) ? (r.prose.lanes[lane.id] as string) : null, measures: d.measures - .filter((measure) => measure.lane === lane.id) + .filter((measure) => + typeof measure.lane === "string" + ? measure.lane === lane.id + : (measure.lane ?? []).includes(lane.id), + ) .map((measure) => ({ id: measure.id, value: m.measures[measure.id]?.value ?? null, diff --git a/packages/core/src/board/record.ts b/packages/core/src/board/record.ts index ea1964f..c4d6dd0 100644 --- a/packages/core/src/board/record.ts +++ b/packages/core/src/board/record.ts @@ -25,9 +25,13 @@ 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`), 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. + * (`github_check`), say what to mind when making an observation (`notes`), + * measure the name of a file (`file_name`), the paths of files + * (`file_paths`), whose value in the measurement is a list of strings, or a + * total of other measures and integers (`total`), draw a measure in more + * than one lane and name a measure or an observation with '.'; and in 3 a + * measure's `lane`, in the measurement and in the declaration, is always a + * list of lane ids, where 1 and 2 hold one id. * 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 7957ec2..e8ef6af 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 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).", + "What to measure in the repos (file_count, dir_count, line_count, match_count, regex_capture, json_length, file_name, file_paths, trail_count), and their sums and differences (total). One with a lane, or a list of lanes, is drawn in each one's section of the board page; one without is kept only in the records and their differences (and in a ratio or a total that uses it).", 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・file_name・file_paths・trail_count)。lane を付けたものは盤のページのそのレーンの節に描かれ、付けないものは記録と差分(と、それを使う ratio)にだけ残る。", + "repo の中で測るもの(file_count・dir_count・line_count・match_count・regex_capture・json_length・file_name・file_paths・trail_count)と、それらの和と差(total)。lane(1 つのレーンか、レーンの並び)を付けたものは盤のページのそれぞれのレーンの節に描かれ、付けないものは記録と差分(と、それを使う ratio や total)にだけ残る。", observe: "basou の外で観測するもの(npm_version・github_release・github_open_issues・github_open_prs・github_ci・github_check・page_version・http・manual)。取るときの注意があれば notes に書く。basou は取りに行かない。取り方は guide が出す。", components: