From dfd8fdfd1338b5fc09d7da2515b6d89bb776cbbe Mon Sep 17 00:00:00 2001 From: Takashi Matsuyama Date: Tue, 20 Oct 2026 18:08:07 +0900 Subject: [PATCH 1/5] basou: Count a repo's files past symlinks that leave it, keep an http final_url to its scheme, host and path, and say on the board page and in the guide what each figure is - repos[].files leaves out a symlink that leaves the repository. A tracked agent instruction file linked in from another repo made files null on every measurement, and a built-in value takes no exclude. A path that cannot be read, or a directory git could not list in full, still makes it null; a declared file_count of ** keeps refusing such a symlink. The repos method is now 3, so the next record marks the section method_changed. - The guide's http script prints final_url as the scheme, host (with a port that is not the scheme's own) and path of the URL curl ended at, and a null with an error when that is not an http or https URL. record refuses a final_url holding '?' or '#', or a user name or a password: a sign-in redirect's token changed the value at every record and put the token in a file kept in git. - The guide's Source: line quotes an argument where the command quotes it (a check name with a space), and leaves a plain name as it was. - The guide asks for all manual observations of a record in one message, and lists them as one operator turn with each key under it. - The guide says a milestone is a day on which something happened (the start of the work too), never a plan or a target date, and that a moved board keeps the milestones it had that happened. - The board page says what its per-day time divides by (the days from the start to today, both included), says the Claude and Codex times are each a union of their sessions' time, and draws each repo's files in the repos tables of the live board and of a record. --- CHANGELOG.md | 74 ++++++++ docs/spec/board.md | 66 ++++--- packages/cli/src/commands/view.test.ts | 55 +++++- packages/cli/src/lib/board-ui.ts | 19 +- packages/core/src/board/guide.test.ts | 204 ++++++++++++++++++++-- packages/core/src/board/guide.ts | 49 ++++-- packages/core/src/board/live.test.ts | 7 +- packages/core/src/board/live.ts | 2 + packages/core/src/board/measure.test.ts | 59 +++++-- packages/core/src/board/observed-shape.ts | 14 +- packages/core/src/board/page.test.ts | 24 ++- packages/core/src/board/page.ts | 10 +- packages/core/src/board/record.test.ts | 29 ++- packages/core/src/board/repos.ts | 20 ++- packages/core/src/lib/view-strings.ts | 11 ++ 15 files changed, 549 insertions(+), 94 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 598a784..eb0de56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,80 @@ All notable changes to **basou** are recorded here. The project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html) starting with v0.1.0. +## Unreleased + +### Added + +- **The board page draws each repo's files in its repos table**, after the + commits, on the board measured on the spot and on a record's page: the + files a measure sees in the working tree, as `repos[].files` counts them. + The measurement and every version of the record held them, and no table + drew them. One not measured is drawn as not measured. +- **The board page says under Claude's and Codex's time that each is a + union**: the time two sessions of the tool overlap counts once, so either + is less than the sum of its sessions' times when they overlap, as the + active time beside them is. `no Codex session` is drawn as before. + +### Changed + +- **`basou board measure` leaves out of `repos[].files` a symlink that + leaves the repository.** One such symlink in a repo's working tree, + tracked or untracked and not ignored, as an agent's instruction file + linked in from another repo is (`AGENTS.md -> ..//AGENTS.md`, with + `CLAUDE.md -> AGENTS.md` beside it), made `files` null at every + measurement, with `'AGENTS.md' is a symlink to outside the repository`, + and a built-in value takes no `exclude`. `files` now counts the other + files, and is a number on a repo that holds such symlinks. A path that + cannot be read, or a directory git could not list in full, still makes it + null with its reason, and a measure the board declares (a `file_count` of + `**`) is still not measured for such a symlink, which its `exclude` can + leave out. The `repos` method is now 3, so the next record marks the + repos section `method_changed`. +- **`basou board record` refuses an `http` value's `final_url` that has a + user, a query or a fragment, and the script `basou board guide` writes + leaves them out.** Behind a sign-in (a redirect to a login page) the URL + curl ended at carries a token signed anew each time: the observation + showed as moved at every record, and the record, kept in git, held a token + a secret scanner may flag. The script prints `final_url` as that URL's + scheme, host (with a port that is not the scheme's own) and path, and a + null with the error `the URL curl ended at is not an absolute http or + https URL` when it is not one. Record refuses a `final_url` that holds a + `?` or a `#`, or a user name or a password: + `observed.shop.value.final_url: must be an absolute http or https URL with no user, query or fragment, received "https://example.com/login?token=..."`. + On a board whose `final_url` had a query, the observation shows as moved + once, at the next record. Records already written are not checked again. +- **`basou board guide` asks for every `manual` observation of a record in + one message**: for each key, what the operator can do as it is written + (the line to run and where, or the page to open) and what to send back, + with a line that answers several keys given once, naming the keys it + answers. A board of ten `manual` observations that one query answers had + the agent ask ten times. The operator's turns in the guide list them as + one item, with each key under it. +- **`basou board guide` says a milestone is a day on which something + happened** (a release, a launch, the start of the work), never a plan or + a target date. It said only what was reached, and also that a board moved + from another keeps its milestones, which a start among them did not fit. + A moved board keeps the milestones it had that happened, and leaves out + those that were only planned. + +### Fixed + +- **The board page says what its per-day time is divided by.** It is the + active time over the days from the start to today, both included (the + rows of the daily chart), one more than the elapsed days in the tile + beside it, so a page showing `Elapsed 105 days` and `Per day 3h 22m` for + 358h 17m could not be checked by hand. The tile now says `over 106 days, + today included`, beside the time a day worked. `docs/spec/board.md` + defines it. +- **`basou board guide` quotes an observation's `Source:` as its command + does.** A `github_check` source wrote the check name as it is + (`-f check_name=Workers Builds: my-app`), which a shell reads as other + words than the command's `-f check_name='Workers Builds: my-app'`. Each + argument of a source is now quoted when a shell would read it otherwise, + as the command quotes it, and a plain name (`owner/repo`, `quality.yml`, + `main`) is written as before, so the sources of existing boards change + only where a name needed quoting. + ## 0.72.0 — 2026-10-20 ### Added diff --git a/docs/spec/board.md b/docs/spec/board.md index 2616909..03694a3 100644 --- a/docs/spec/board.md +++ b/docs/spec/board.md @@ -11,7 +11,7 @@ > > This page describes the shapes of basou 0.71.0: `board_version` 3 and > `record_version` 3, with what `basou board record` refuses of an -> observation's value as of basou 0.72.0. When a later release changes a +> observation's value as of basou 0.73.0. When a later release changes a > shape, it raises the version and this page with it. This page says what each key of these shapes holds, which keys are required, @@ -251,7 +251,8 @@ previous record and usable by a ratio or a total. - A symlink is followed when it ends at a file inside the repository. One that ends nowhere or at a directory is not a file and is left out, as a submodule is; one that leaves the repository makes any measure that matches - it not measured. + it not measured (a repo's built-in `files` leaves it out instead, see + [`repos`](#52-repos)). - basou measures with git and reads files, with no shell. It does not fetch, write a lock or start a file system monitor. @@ -525,10 +526,11 @@ never part of a command. U+200B to U+200F, U+2028 to U+202E, U+2060 to U+2069, U+FEFF); a number of a kind above that is not a whole number from 0 to 9007199254740991; and an `http` value out of its - shape ([below](#http)). What a string says is not held to a grammar (a - version's, or the words GitHub uses), so a new word is never refused. A - `manual` value, and a null, are not checked. Only a board that declares - observations has them checked ([3.2](#32-observed)). + shape ([below](#http)), which from basou 0.73.0 includes a `final_url` + with a user, a query or a fragment. What a string says is not held to a + grammar (a version's, or the words GitHub uses), so a new word is never + refused. A `manual` value, and a null, are not checked. Only a board that + declares observations has them checked ([3.2](#32-observed)). The keys: @@ -587,14 +589,19 @@ The value recorded: |---|---|---| | `status` | always | a whole number from 100 to 599: the status of the last response | | `redirects` | always | a whole number from 0 to 10: how many redirects led to it | -| `final_url` | always | an absolute `http://` or `https://` URL: the URL it ended at | +| `final_url` | always | an absolute `http://` or `https://` URL with no user, query or fragment: the URL it ended at, as its scheme, its host (with a port that is not the scheme's own) and its path | | `words` | with `words` | a list of `true` or `false`, one for each word, in the order declared: whether the body holds it | | `count` | with `count_pattern` | a whole number from 0 to 9007199254740991: how many times it matches the body (0 when it does not) | | `capture` | with `capture_pattern` | a string: what the first group holds in the first match, as it is, spaces and all; null when nothing matches or the group takes no part | From basou 0.72.0, record refuses an `http` value with a key the observation does not declare a read of, or one it declares left out, and a -read that is not null when the status is not 2xx. +read that is not null when the status is not 2xx. From basou 0.73.0, it +refuses a `final_url` that holds a `?` or a `#`, or a user name or a +password, and the guide's command leaves them out of the URL curl ended at: +behind a sign-in (a redirect to a login page) they can carry a token made +anew each time, which would show the observation as moved at every record +and put the token in a record kept in git. - **A 4xx or a 5xx is a value**, not an observation not made: the status is what was observed. @@ -608,13 +615,13 @@ read that is not null when the status is not 2xx. fails (the name does not resolve, TLS fails, 20 seconds pass, or it takes more than 10 redirects), when its status is not one of an HTTP response (100 to 599, such as a server answering 600, which curl prints as it is), - or when what curl left cannot be read. From basou 0.72.0 the guide's - command prints the whole entry for the record's input on one line, with - what a terminal would act on or hide escaped: the value, or the null and - the error. It gives the null, - rather than count, at an empty match the declaration could not refuse: a - `count_pattern` that matches the empty string only next to some text, as - `\b` or a lookaround can. + when the URL it ended at is not an `http://` or `https://` URL (from + basou 0.73.0), or when what curl left cannot be read. From basou 0.72.0 + the guide's command prints the whole entry for the record's input on one + line, with what a terminal would act on or hide escaped: the value, or the + null and the error. It gives the null, rather than count, at an empty + match the declaration could not refuse: a `count_pattern` that matches the + empty string only next to some text, as `\b` or a lookaround can. The keys of the value are basou's words, so the check of the record's input for the names of other workspaces ([3.1](#31-how-it-is-checked)) looks at @@ -782,9 +789,11 @@ A mapping from an observation's key to what was found: It is refused where it is not a JSON value: it nests more than 100 deep, holds a number too large to be held, or has a key named `__proto__`. From basou 0.72.0, when the board declares observations, it is refused too where - it is not in the shape of its kind ([2.7](#27-observe)); a `manual` value - and a null are not checked. With none declared, the keys of `observed` are - free and their values are not checked. A refusal says where in the value + it is not in the shape of its kind ([2.7](#27-observe)), and from basou + 0.73.0 where an `http` value's `final_url` has a user, a query or a + fragment ([`http`](#http)); a `manual` value and a null are not checked. + With none declared, the keys of `observed` are free and their values are + not checked. A refusal says where in the value (`observed["site.top"].value.status`); one of what a key holds shows what it received, as up to 60 characters of its JSON (an escape counted as one) with what a terminal would act on or hide escaped. A value that is not a @@ -975,7 +984,9 @@ is raised only when the same input would give a value of it another way, never for an added field or a new key of the declaration. A method that differs from the previous record's fires the axis review's trigger (e), and the values of that section in `diff` are marked `method_changed`. In 0.71.0 -they are `portfolio` 1 and every other section 2. +they are `portfolio` 1 and every other section 2. From basou 0.73.0 `repos` +is 3: its `files` leaves out a symlink that leaves the repository +([5.2](#52-repos)). ### 5.2 `repos` @@ -989,13 +1000,22 @@ A list, in the manifest's order: | `branch` | the branch HEAD is on | | `last_commit` | HEAD's committer time as git recorded it: ISO 8601 with its offset | | `commits` | the commits reachable from HEAD, merges and all | -| `files` | the files a measure sees in the working tree (tracked, and untracked but not ignored, on disk): a `file_count` of `**` | +| `files` | the files a measure sees in the working tree (tracked, and untracked but not ignored, on disk), as a `file_count` of `**` counts them, less each symlink that leaves the repository (below) | | `uncommitted` | the paths `git status` names: each untracked file on its own, not its directory, and a rename as the two paths it touches | | `base` | the branch of origin HEAD is compared with, as `origin/`: the one `refs/remotes/origin/HEAD` names, or `origin/main` when it names none | | `behind` | the commits on `base` that HEAD does not have, as of the last fetch | | `ahead` | the commits HEAD has that `base` does not, as of the last fetch | | `fetched_at` | when this worktree's `FETCH_HEAD` was last written, in UTC (below) | +- **`files` leaves out a symlink that leaves the repository**, such as an + agent's instruction file linked in from another repo (tracked, or + untracked and not ignored): what it ends at cannot be told to be a file without looking + outside the repository, and a built-in value takes no `exclude`. A + `file_count` of `**` is not measured for one. A path that cannot be read, + or a directory git could not list in full, still makes `files` null, with + the reason at `repos[].files`. Up to `repos` method 2 (before basou + 0.73.0), such a symlink made `files` null too, with the reason `'' is + a symlink to outside the repository`. - **An untracked directory counts every file in it**, in `files` and in `uncommitted` alike, and so does any measure whose globs reach it: what a repo holds but does not own (a build's output, a tool's cache) is counted @@ -1109,7 +1129,7 @@ counts. |---|---| | `start` | the declared `start` | | `time_zone` | the declared time zone or this host's, as `Intl` names it (a declared `utc` is held as `UTC`; the record's `declaration` keeps it as written) | -| `elapsed_days` | the days from the start to today. **Not in the digest** | +| `elapsed_days` | the days from the start to today: today less the start, 0 on the first day. **Not in the digest** | | `active_ms` | `{ union, claude, codex }`: active time in milliseconds, every session's intervals merged, Claude Code's among themselves, and Codex's among themselves | | `output_tokens` | the output tokens of the sessions that recorded them (reasoning not included) | | `sessions_without_tokens` | the Claude Code and Codex imports that recorded no tokens, so that `output_tokens` is a floor while this is above 0 | @@ -1123,6 +1143,10 @@ counts. sessions cannot be read whole. - `active_ms.codex` is null **with no entry** when there is no Codex session at all, where 0 is a Codex that did nothing. +- **Per day**, on the board page, is `active_ms.union` divided by the rows of + `daily`: the days from the start to today, both included, which is one more + than `elapsed_days` (on the first day, the time so far is the day's). The + page says what it divided by. - A repo's `commits` is null, with an entry at `effort.commits[]`, when its history is not known. diff --git a/packages/cli/src/commands/view.test.ts b/packages/cli/src/commands/view.test.ts index a0c4afe..d5ce45c 100644 --- a/packages/cli/src/commands/view.test.ts +++ b/packages/cli/src/commands/view.test.ts @@ -1800,6 +1800,7 @@ describe("basou view: the board page", () => { branch: "main", last_commit: null, commits: 2, + files: 7, uncommitted: 0, base: "origin/main", behind: 3, @@ -1819,12 +1820,19 @@ describe("basou view: the board page", () => { "0123456", "-", "2", + "7", "0", "origin/main", "3", "1", "-", ]); + // Files the record could not measure are said to be. + const noFiles = call("recordRepos", { + repos: { record_version: 3, base_measured: true, list: [{ ...repo, files: null }] }, + footnotes: footnotes(["repos[.].files"]), + }); + expect(cells(noFiles)[5]).toBe("not measured"); expect(v3.text()).not.toContain("did not measure"); const older = { ...repo, @@ -1837,12 +1845,14 @@ describe("basou view: the board page", () => { // What it could not measure is said, by the name it had then. footnotes: footnotes(["repos[.].behind_main", "repos[.].base", "repos[.].ahead"]), }); + // Its files, which every version measured. expect(cells(v2)).toEqual([ "app", "main", "0123456", "-", "2", + "7", "0", "-", "not measured", @@ -2002,6 +2012,12 @@ describe("basou view: the board page", () => { .find((n) => n.className === "tile") .map((t) => t.children.map((c) => c.text())); expect(tiles[0]).toEqual(["Elapsed", "not measured", "from 2026-04-28"]); + // Nothing is said under a time not measured: no union, no divisor. + expect(tiles.slice(2, 5)).toEqual([ + ["Claude", "not measured"], + ["Codex", "not measured"], + ["Per day", "not measured"], + ]); expect(effort.text()).toContain(boardPageStrings("en").effort.noDays); expect(effort.find((n) => n.tag === "svg")).toHaveLength(0); const foot = call("footnotes", { @@ -2140,7 +2156,16 @@ describe("basou view: the board page", () => { "Per day", "Output tokens", ]); - expect(tiles[4]).toEqual(["Per day", "1h 30m", "3h 00m a day worked"]); + const DOT = ` ${String.fromCodePoint(0xb7)} `; + // Over the four daily rows, today's included: one more than the days elapsed. + expect(tiles[4]).toEqual([ + "Per day", + "1h 30m", + `over 4 days, today included${DOT}3h 00m a day worked`, + ]); + // Each tool's time is a union, said under it. + expect(tiles[2]).toEqual(["Claude", "4h 00m", "overlaps counted once, not a sum of sessions"]); + expect(tiles[3]).toEqual(["Codex", "3h 00m", "overlaps counted once, not a sum of sessions"]); const charts = effort.find((n) => n.tag === "svg"); expect(charts).toHaveLength(3); const [timeline, daily, total] = charts as [Node, Node, Node]; @@ -2199,7 +2224,18 @@ describe("basou view: the board page", () => { const soloTiles = solo .find((n) => n.className === "tile") .map((t) => t.children.map((c) => c.text())); - expect(soloTiles[4]).toEqual(["Per day", "2h 00m", "2h 00m a day worked"]); + expect(soloTiles[4]).toEqual([ + "Per day", + "2h 00m", + `over 1 days, today included${DOT}2h 00m a day worked`, + ]); + // No Codex session is said as before, with nothing under it. + expect(soloTiles[3]).toEqual(["Codex", "no Codex session"]); + expect(soloTiles[2]).toEqual([ + "Claude", + "2h 00m", + "overlaps counted once, not a sum of sessions", + ]); }); it("labels the months under a chart without crowding, and the milestones inside it without overlap", () => { @@ -2326,6 +2362,7 @@ describe("basou view: the board page", () => { branch: null, last_commit: null, commits: 2, + files: 5, uncommitted: 1, base: null, behind: null, @@ -2375,13 +2412,14 @@ describe("basou view: the board page", () => { const repos = call("liveRepos", LIVE); const cells = repos.find((n) => n.tag === "td").map((c) => c.textContent); // Nulls that mean so: a detached HEAD, no base, a last commit unread. - expect(cells).toEqual(["app", "-", "0123456", "-", "2", "1", "-", "-", "-", "-"]); + expect(cells).toEqual(["app", "-", "0123456", "-", "2", "5", "1", "-", "-", "-", "-"]); expect(repos.find((n) => n.tag === "th").map((c) => [c.textContent, c.className])).toEqual([ [S.live.repo, ""], [S.live.branch, ""], [S.live.head, ""], [S.live.lastCommit, ""], [S.live.commits, "n"], + [S.live.files, "n"], [S.live.uncommitted, "n"], [S.live.base, ""], [S.live.behind, "n"], @@ -2403,8 +2441,8 @@ describe("basou view: the board page", () => { }; const comparedTds = call("liveRepos", compared).find((n) => n.tag === "td"); const comparedCells = comparedTds.map((c) => c.textContent); - expect(comparedCells.slice(6, 9)).toEqual(["origin/master", "3", "1"]); - expect(comparedCells[9]).toBe(new Date("2026-10-04T12:00:00.000Z").toLocaleString("en-US")); + expect(comparedCells.slice(7, 10)).toEqual(["origin/master", "3", "1"]); + expect(comparedCells[10]).toBe(new Date("2026-10-04T12:00:00.000Z").toLocaleString("en-US")); expect(comparedTds.map((c) => c.className)).toEqual([ "", "", @@ -2412,6 +2450,7 @@ describe("basou view: the board page", () => { "", "n", "n", + "n", "", "n", "n", @@ -2428,6 +2467,7 @@ describe("basou view: the board page", () => { name: null, head: null, commits: null, + files: null, uncommitted: null, }, ], @@ -2445,7 +2485,7 @@ describe("basou view: the board page", () => { .slice(1) .map((row) => row.find((n) => n.tag === "td").map((c) => c.textContent)); expect(rows).toEqual([ - ["app", "not measured", "0123456", "-", "2", "1", "-", "-", "-", "-"], + ["app", "not measured", "0123456", "-", "2", "5", "1", "-", "-", "-", "-"], [ "../gone", "not measured", @@ -2457,6 +2497,7 @@ describe("basou view: the board page", () => { "not measured", "not measured", "not measured", + "not measured", ], ]); // Each of the four the measurement could not measure is said to be. @@ -2472,7 +2513,7 @@ describe("basou view: the board page", () => { call("liveRepos", unmeasuredBase) .find((n) => n.tag === "td") .map((c) => c.textContent) - .slice(6), + .slice(7), ).toEqual(fields.map(() => S.tiles.notMeasured)); const trail = call("liveTrail", LIVE); diff --git a/packages/cli/src/lib/board-ui.ts b/packages/cli/src/lib/board-ui.ts index 2e215b6..c8b7372 100644 --- a/packages/cli/src/lib/board-ui.ts +++ b/packages/cli/src/lib/board-ui.ts @@ -641,16 +641,24 @@ export const BOARD_HTML = ` ]); }; var union = e.active_ms.union; + // Over the daily rows, today's included (one more than the days elapsed), + // so that the first day is a day; the sub-text names the divisor. var perDay = union === null || !e.period_days ? null : union / e.period_days; var perWorked = union === null || !e.active_days ? null : union / e.active_days; + var perDaySub = [ + perDay === null ? null : fill(S.effort.perDayOver, { days: num(e.period_days) }), + perWorked === null ? null : fill(S.effort.perDayWorked, { time: hm(perWorked) }) + ].filter(function (part) { return part !== null; }).join(DOT); var tokens = e.sessions_without_tokens ? fill(S.effort.withoutTokens, { n: e.sessions_without_tokens }) : null; + // Each tool's time is the union of its sessions' intervals, as Active is. + var unionOf = function (ms) { return ms === null ? null : S.effort.toolUnion; }; var tiles = el('div', { class: 'tiles' }, [ tile(S.effort.elapsed, e.elapsed_days === null ? S.effort.notMeasured : fill(S.effort.days, { days: e.elapsed_days }), e.time_zone === null ? fill(S.effort.from, { date: e.start }) : fill(S.effort.fromZone, { date: e.start, zone: e.time_zone })), tile(S.effort.active, hm(union), e.active_days === null ? null : fill(S.effort.daysWorked, { n: e.active_days })), - tile(S.effort.claude, hm(e.active_ms.claude), null), - tile(S.effort.codex, e.active_ms.codex === null && union !== null ? S.effort.noCodex : hm(e.active_ms.codex), null), - tile(S.effort.perDay, hm(perDay), perWorked === null ? null : fill(S.effort.perDayWorked, { time: hm(perWorked) })), + tile(S.effort.claude, hm(e.active_ms.claude), unionOf(e.active_ms.claude)), + tile(S.effort.codex, e.active_ms.codex === null && union !== null ? S.effort.noCodex : hm(e.active_ms.codex), unionOf(e.active_ms.codex)), + tile(S.effort.perDay, hm(perDay), perDaySub), tile(S.effort.outputTokens, num(e.output_tokens), tokens) ]); var commits = e.commits === null ? S.effort.notMeasured : e.commits.map(function (c) { @@ -887,8 +895,8 @@ export const BOARD_HTML = ` // measured no base, ahead or fetch time (drawn as '-') and held the // commits behind as behind_main. function reposTable(list, missing, older) { - var labels = [S.live.repo, S.live.branch, S.live.head, S.live.lastCommit, S.live.commits, S.live.uncommitted, S.live.base, S.live.behind, S.live.ahead, S.live.fetchedAt]; - var numeric = [4, 5, 7, 8]; + var labels = [S.live.repo, S.live.branch, S.live.head, S.live.lastCommit, S.live.commits, S.live.files, S.live.uncommitted, S.live.base, S.live.behind, S.live.ahead, S.live.fetchedAt]; + var numeric = [4, 5, 6, 8, 9]; var head = el('tr', null, labels.map(function (label, i) { return el('th', { class: numeric.indexOf(i) !== -1 ? 'n' : '', text: label }); })); var rows = list.map(function (r) { var at = 'repos[' + r.path + ']'; @@ -906,6 +914,7 @@ export const BOARD_HTML = ` el('td', { text: value('head', r.head, function (h) { return h.slice(0, 7); }) }), el('td', { text: value('last_commit', r.last_commit, when) }), el('td', { class: 'n', text: num(r.commits) }), + el('td', { class: 'n', text: num(r.files) }), el('td', { class: 'n', text: num(r.uncommitted) }), el('td', { text: unmeasured('base', r.base, String) }), el('td', { class: 'n', text: value(older ? 'behind_main' : 'behind', r.behind, num) }), diff --git a/packages/core/src/board/guide.test.ts b/packages/core/src/board/guide.test.ts index bab959f..790a9d5 100644 --- a/packages/core/src/board/guide.test.ts +++ b/packages/core/src/board/guide.test.ts @@ -285,7 +285,10 @@ describe("boardGuide: a board declared", () => { expect(guide).toContain( "Only the operator can make this one: count the users in production, read only", ); - expect(guide).toContain("- Observing `users`: count the users in production, read only"); + expect(guide).toContain( + "- Making the manual observations, asked for together in one message:\n - `users`: count the users in production, read only\n", + ); + // A plain name is given as it is, as before. expect(guide).toContain( "Source: `gh run list --repo owner/name --workflow quality.yml --branch main`.", ); @@ -294,13 +297,42 @@ describe("boardGuide: a board declared", () => { it("says how to ask for a manual observation, and what the source says when the operator hands it over", () => { const guide = boardGuide(guideInput()); expect(guide).toContain( - "Ask for one thing they can do as it is written: the line to run and where to run it, or the page to open, and what to send back.", + "Ask once, in one message, for all the manual observations of this record: for each key, what they can do as it is written (the line to run and where to run it, or the page to open) and what to send back; when one line answers several keys, give it once and say which keys it answers.", ); + expect(guide).not.toContain("Ask for one thing"); expect(guide).toContain( "When the operator tells you to make it yourself, make it as they said and write that in the source instead, such as `ran as the operator asked`.", ); }); + it("asks for every manual observation in one message, and lists them as one turn of the operator's", () => { + const withManual = (observe: Record[]) => + boardGuide(guideInput({ board: declared(httpDeclaration(observe)) })); + const two = withManual([ + { key: "users", kind: "manual", how: "count the users in production" }, + { key: "site", kind: "http", url: "https://example.com/" }, + { key: "orders", kind: "manual", how: "count the orders today" }, + ]); + const turns = two.slice(two.indexOf("## The operator's turns in this work")); + expect(turns).toContain( + [ + "- Approving a milestone before it is added to board.yaml.", + "- Making the manual observations, asked for together in one message:", + " - `users`: count the users in production", + " - `orders`: count the orders today", + "- A gate of step 9 that does not pass.", + ].join("\n"), + ); + expect(two.split("- Making the manual observations")).toHaveLength(2); + expect(two).not.toContain("- Observing `"); + expect(two).toContain("Ask once, in one message, for all the manual observations"); + const none = withManual([{ key: "site", kind: "http", url: "https://example.com/" }]); + expect(none).not.toContain("manual observations"); + expect(none).toContain( + "- Approving a milestone before it is added to board.yaml.\n- A gate of step 9 that does not pass.", + ); + }); + it("asks for decision and task ids in full, in the prose and in a milestone's ref", () => { const guide = boardGuide(guideInput()); expect(guide).toContain( @@ -621,7 +653,7 @@ describe("boardGuide: an observation not made, the milestones, isolation and wha }), ); const holds = - "under effort.milestones, each `{ date, label, ref }`: only what was reached (a release, a launch), with `ref` saying where it can be checked (a tag and its commit, or a decision id in full). The page marks them on the period and effort chart from the next record on: a record draws the declaration it kept. Add one with the operator's approval, before the measure of step 2 or after the record of step 7, never between: the digest covers board.yaml."; + "under effort.milestones, each `{ date, label, ref }`: only days on which something happened (a release, a launch, the start of the work), never a plan or a target date, each with `ref` saying where it can be checked (a tag and its commit, or a decision id in full). The page marks them on the period and effort chart from the next record on: a record draws the declaration it kept. Add one with the operator's approval, before the measure of step 2 or after the record of step 7, never between: the digest covers board.yaml."; expect(boardGuide(guideInput())).toContain(`- Milestones: none declared ${holds}`); expect(boardGuide(guideInput())).toContain( "- Approving a milestone before it is added to board.yaml.", @@ -629,9 +661,13 @@ describe("boardGuide: an observation not made, the milestones, isolation and wha expect(withMilestones(0)).toContain(`- Milestones: none declared ${holds}`); expect(withMilestones(1)).toContain(`- Milestones: 1 milestone declared ${holds}`); expect(withMilestones(2)).toContain(`- Milestones: 2 milestones declared ${holds}`); - expect(boardGuide(guideInput({ board: { status: "undeclared" } }))).toContain( - "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.", + const undeclared = boardGuide(guideInput({ board: { status: "undeclared" } })); + expect(undeclared).toContain( + "Declare under `effort.milestones` the days to mark on the period and effort chart: only days on which something happened (a release, a launch, the start of the work), each with a `ref` saying where it can be checked, never a plan or a target date; a board moved from another keeps there the milestones it had that happened, and leaves out any that were only planned.", ); + // Neither says only what was reached: the start of the work is a milestone too. + expect(boardGuide(guideInput())).not.toContain("only what was reached"); + expect(undeclared).not.toContain("only what was reached"); }); it("keeps other projects out of what the agent writes, and says the portfolio section is basou's own", () => { @@ -947,11 +983,14 @@ describe("boardGuide: an http observation", () => { expect(guide.split("- `http`: ")).toHaveLength(2); expect(guide).toContain("- `http`: a GET, following up to 10 redirects, with no `-f`"); expect(guide).toContain("not line by line as a measure's `pattern` is"); + expect(guide).toContain( + "The value holds `status`, `redirects` and `final_url` (the URL it ended at, with no user, query or fragment: they can carry a token that changes each time, and record refuses a `final_url` that has one)", + ); expect(guide).toContain( "When the status is not 2xx the body is not read, and each read is a null, not a list of nulls", ); expect(guide).toContain( - "more than 10 redirects: exit 47), its status is not one of an HTTP response (100 to 599, such as a server answering 600), or the body cannot be read", + "more than 10 redirects: exit 47), its status is not one of an HTTP response (100 to 599, such as a server answering 600), the URL it ended at is not an http or https one, or the body cannot be read", ); expect(guide).toContain( "record refuses another, such as a number given as a string, a string with a space or a line break at either end, or an `http` status that is not a response's (100 to 599)", @@ -1011,6 +1050,9 @@ describe("boardGuide: an http observation, run", () => { server = createServer((req, res) => { const url = req.url ?? ""; if (url === "/moved") res.writeHead(301, { location: "/body/item" }).end(); + else if (url === "/signin") { + res.writeHead(302, { location: "/login?next=%2Fshop&token=a1b2c3" }).end(); + } else if (url.startsWith("/login?")) res.writeHead(200).end("sign in"); else if (url.startsWith("/loop")) { res.writeHead(302, { location: `/loop${(url.length % 50) + 1}` }).end(); } else if (url === "/empty") res.writeHead(204).end(); @@ -1259,6 +1301,70 @@ describe("boardGuide: an http observation, run", () => { }); }); + it("keeps of the URL it ended at only the scheme, the host and the path: a sign-in's token is left out", async () => { + const get = await observe([{ key: "shop", kind: "http", url: SHOP, words: ["sign in"] }]); + const entry = await get("shop", "/signin"); + expect(entry.value).toEqual({ + status: 200, + redirects: 1, + final_url: `${origin}/login`, + words: [true], + }); + expect(JSON.stringify(entry)).not.toContain("a1b2c3"); + }); + + it("leaves a user, a query and a fragment out of the URL curl printed, and gives no value for one that is no http or https URL", async () => { + const guide = boardGuide( + guideInput({ + anchor: dir, + board: declared(httpDeclaration([{ key: "shop", kind: "http", url: SHOP }])), + }), + ); + const write = blocksIn(guide).find((b) => b.includes("printf '%s\\n' '")) as string; + expect((await run(write)).out).toBe("exit=0\n"); + const file = /"\$W\/(http-read-[0-9a-f]{12}\.cjs)"/.exec(write)?.[1] as string; + const work = boardGuideWorkDir(dir).replace(TMPDIR_WORD, dir); + // The script on what curl would leave: exit 0 and this status line. + const read = async (meta: string) => { + await writeFile(join(work, "http-x.exit"), "0\n"); + await writeFile(join(work, "http-x.meta"), `${meta}\n`); + const out = execFileSync( + "node", + [join(work, file), "--", join(work, "http-x"), SHOP, "", ""], + { + encoding: "utf8", + }, + ); + return JSON.parse(out) as { value: { final_url?: string } | null; error?: string }; + }; + for (const [meta, finalUrl] of [ + [ + "200 0 https://user:secret@example.com:8443/a/b%3Fc?token=x#frag", + "https://example.com:8443/a/b%3Fc", + ], + ["200 1 https://example.com:443/login?state=1", "https://example.com/login"], + ["301 10 http://example.com#top", "http://example.com/"], + ] as const) { + const entry = await read(meta); + expect(entry.value?.final_url, meta).toBe(finalUrl); + const o = httpDeclaration([{ key: "shop", kind: "http", url: SHOP }]) + .observe[0] as BoardObserve; + expect(observedValueProblems(o, entry.value)).toEqual([]); + } + for (const meta of [ + "200 0 not a url", + "200 0 /login", + "200 0 ftp://example.com/x?token=secret", + ]) { + const entry = await read(meta); + expect(entry, meta).toMatchObject({ + value: null, + error: "the URL curl ended at is not an absolute http or https URL", + }); + expect(JSON.stringify(entry)).not.toContain("secret"); + } + }); + it("gives no value when the count_pattern matches an empty string the declaration could not see", async () => { const get = await observe([{ key: "edge", kind: "http", url: SHOP, count_pattern: "\\b" }]); bodies.set("ab", "a b"); @@ -1326,7 +1432,7 @@ describe("boardGuide: a github_check observation and an observation's notes", () "An empty line: the head commit of `main` has no check run named `Workers Builds: app`.", ); expect(deploy).toContain( - "Source: `gh api -X GET repos/owner/name/commits/heads/main/check-runs -f check_name=Workers Builds: app`.", + "Source: `gh api -X GET repos/owner/name/commits/heads/main/check-runs -f check_name='Workers Builds: app'`.", ); // A name with a backquote is still Markdown code as it is, in the prose as in the source. const odd = guide.slice(guide.indexOf("#### `odd` (github_check)"), guide.indexOf("### 4.")); @@ -1337,14 +1443,14 @@ describe("boardGuide: a github_check observation and an observation's notes", () `the head commit of \`release/1.x\` has no check run named \`\`${ODD_CHECK}\`\`.`, ); expect(odd).toContain( - `Source: \`\`gh api -X GET repos/owner/name.js/commits/heads/release/1.x/check-runs -f check_name=${ODD_CHECK}\`\`.`, + `Source: \`\`gh api -X GET repos/owner/name.js/commits/heads/release/1.x/check-runs -f check_name='${ODD_CHECK}'\`\`.`, ); const observed = templateIn(guide).observed as Record; expect(observed.deploy?.source).toBe( - "gh api -X GET repos/owner/name/commits/heads/main/check-runs -f check_name=Workers Builds: app", + "gh api -X GET repos/owner/name/commits/heads/main/check-runs -f check_name='Workers Builds: app'", ); expect(observed.odd?.source).toBe( - `gh api -X GET repos/owner/name.js/commits/heads/release/1.x/check-runs -f check_name=${ODD_CHECK}`, + `gh api -X GET repos/owner/name.js/commits/heads/release/1.x/check-runs -f check_name='${ODD_CHECK}'`, ); }); @@ -1399,6 +1505,74 @@ describe("boardGuide: a github_check observation and an observation's notes", () } }); + it("gives as the source the words the command gives gh, quoted only where a shell would read a name otherwise", async () => { + const dir = await mkdtemp(join(tmpdir(), "basou-board-source-")); + try { + const bin = join(dir, "bin"); + await mkdir(bin); + // A gh that prints each argument it is given on a line of its own. + await writeFile( + join(bin, "gh"), + '#!/bin/sh\nfor a in "$@"; do printf \'[%s]\\n\' "$a"; done\n', + { + mode: 0o755, + }, + ); + const observe = [ + { key: "deploy", kind: "github_check", repo: "o/n", check: "Workers Builds: my-app" }, + { key: "odd", kind: "github_check", repo: "o/n.js", check: ODD_CHECK, branch: "a/b" }, + { key: "plain", kind: "github_check", repo: "o/n", check: "build" }, + { key: "ci", kind: "github_ci", repo: "o/n", workflow: "quality.yml", branch: "rel/1.x" }, + { key: "rel", kind: "github_release", repo: "o/n" }, + { key: "iss", kind: "github_open_issues", repo: "o/n" }, + { key: "prs", kind: "github_open_prs", repo: "o/n" }, + ]; + const guide = boardGuide( + guideInput({ anchor: dir, board: declared(httpDeclaration(observe)) }), + ); + const sources = templateIn(guide).observed as Record; + // A plain name reads as it did before. + expect(sources.plain?.source).toBe( + "gh api -X GET repos/o/n/commits/heads/main/check-runs -f check_name=build", + ); + expect(sources.ci?.source).toBe( + "gh run list --repo o/n --workflow quality.yml --branch rel/1.x", + ); + expect(sources.deploy?.source).toBe( + "gh api -X GET repos/o/n/commits/heads/main/check-runs -f check_name='Workers Builds: my-app'", + ); + const words = (line: string) => + execFileSync("sh", ["-c", line], { + cwd: dir, + env: { ...process.env, PATH: `${bin}:${process.env.PATH ?? ""}` }, + encoding: "utf8", + }) + .split("\n") + .filter((w) => w.startsWith("[")); + // Run as a shell would, each source asks gh what the command asks it, + // the command going on with what it reads off the answer. + const blocks = blocksIn(guide).filter((b) => b.startsWith("gh ")); + expect(blocks).toHaveLength(observe.length); + observe.forEach((o, i) => { + const source = sources[o.key]?.source as string; + const asked = words(blocks[i] as string); + expect(words(source), o.key).toEqual(asked.slice(0, words(source).length)); + expect(words(source).length, o.key).toBeGreaterThan(3); + }); + expect(words(sources.deploy?.source as string)).toEqual([ + "[api]", + "[-X]", + "[GET]", + "[repos/o/n/commits/heads/main/check-runs]", + "[-f]", + "[check_name=Workers Builds: my-app]", + ]); + await expect(readdir(dir)).resolves.toEqual(["bin"]); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + it("prints each observation's notes under how it is made, and nothing for one without", () => { const guide = boardGuide( guideInput({ @@ -1482,13 +1656,13 @@ describe("boardGuide: what a board declares, as code and escaped as the template ); expect(guide).toContain(`How the latest check run named \`${shownCheck}\` on the head commit`); expect(guide).toContain( - `Source: \`gh api -X GET repos/o/n/commits/heads/main/check-runs -f check_name=${shownCheck}\`.`, + `Source: \`gh api -X GET repos/o/n/commits/heads/main/check-runs -f check_name='${shownCheck}'\`.`, ); expect(guide).toContain("Only the operator can make this one: count\\u200fthem"); // The template says the same, and holds the name itself once read. - const source = `gh api -X GET repos/o/n/commits/heads/main/check-runs -f check_name=${check}`; + const source = `gh api -X GET repos/o/n/commits/heads/main/check-runs -f check_name='${check}'`; expect(guide).toContain( - `"source":"gh api -X GET repos/o/n/commits/heads/main/check-runs -f check_name=${shownCheck}"`, + `"source":"gh api -X GET repos/o/n/commits/heads/main/check-runs -f check_name='${shownCheck}'"`, ); expect((templateIn(guide).observed as Record).deploy?.source).toBe( source, @@ -1568,10 +1742,10 @@ describe("boardGuide: every text a board or its records give, escaped as the tem "- `core` C\\u200d: a\\u2062", " - mind: ln\\u061c", "How the latest check run named `c\\u2062` on the head commit", - "check_name=c\\u2062`.", + "check_name='c\\u2062'`.", "- mind: on\\ufeff", "Only the operator can make this one: count\\u061c", - "- Observing `users`: count\\u061c", + " - `users`: count\\u061c", ]) { expect(guide, line).toContain(line); } diff --git a/packages/core/src/board/guide.ts b/packages/core/src/board/guide.ts index 4df5732..6e0a447 100644 --- a/packages/core/src/board/guide.ts +++ b/packages/core/src/board/guide.ts @@ -122,7 +122,7 @@ export function boardGuide(input: BoardGuideInput): string { line(`- Records: board/records/, ${recordsLine(input, recordCount)}`); line(`- Axis: ${axisLine(input)}`); line( - `- Milestones: ${milestonesLine(d)} under effort.milestones, each \`{ date, label, ref }\`: only what was reached (a release, a launch), with \`ref\` saying where it can be checked (a tag and its commit, or a decision id in full). The page marks them on the period and effort chart from the next record on: a record draws the declaration it kept. Add one with the operator's approval, before the measure of step 2 or after the record of step 7, never between: the digest covers board.yaml.`, + `- Milestones: ${milestonesLine(d)} under effort.milestones, each \`{ date, label, ref }\`: only days on which something happened (a release, a launch, the start of the work), never a plan or a target date, each with \`ref\` saying where it can be checked (a tag and its commit, or a decision id in full). The page marks them on the period and effort chart from the next record on: a record draws the declaration it kept. Add one with the operator's approval, before the measure of step 2 or after the record of step 7, never between: the digest covers board.yaml.`, ); line( `- Language: write every prose string and reason in ${LANGUAGE_NAMES[input.language]}, the language this board's page is drawn in (Japanese when the manifest declares ja for this repo, English otherwise)`, @@ -360,8 +360,10 @@ export function boardGuide(input: BoardGuideInput): string { "- Approving an axis review (step 2b) before anything in board.yaml changes.", "- Approving a milestone before it is added to board.yaml.", ); - for (const o of d.observe) { - if (o.kind === "manual") line(`- Observing \`${o.key}\`: ${shown(o.how)}`); + const manual = d.observe.filter((o) => o.kind === "manual"); + if (manual.length > 0) { + line("- Making the manual observations, asked for together in one message:"); + for (const o of manual) line(` - \`${o.key}\`: ${shown(o.how)}`); } line("- A gate of step 9 that does not pass.", ""); return `${out.join("\n").replace(/\n{3,}/g, "\n\n")}\n`; @@ -570,9 +572,9 @@ function kindText(kind: BoardObserve["kind"]): string { case "page_version": return "the first `v..` (with what follows a `-`) a page shows, not right after a letter, digit, underscore or dot, once its generator meta tags are taken out, however they are quoted or cased (they name the site builder's version, not the product's). Value: the version it prints, as a string (such as \"v1.2.3\"). A failed fetch, or no version on the page (exit 1), is a null with that as the error."; case "http": - return `a GET, following up to ${BOARD_HTTP_MAX_REDIRECTS} redirects, with no \`-f\`: a 4xx or 5xx is a value, not an observation not made (the status is what was observed). The block keeps curl's status line and its exit code in W beside the body, and the script reads them: it prints the whole entry on one line, to put under the key as it is. The value holds \`status\`, \`redirects\` and \`final_url\`, and what the observation reads off the body: \`words\`, whether the body holds each word, true or false in the order declared, found as written, character for character, with HTML entities not decoded; \`count\`, how many times the count_pattern matches (0 when it does not); \`capture\`, what the first group of the first match of the capture_pattern holds (null when nothing matches, or when the group takes no part). The patterns are matched against the whole body at once, not line by line as a measure's \`pattern\` is, so \`^\` and \`$\` are the body's start and end. When the status is not 2xx the body is not read, and each read is a null, not a list of nulls: an error page's text is not the page's. When curl exits other than 0 (the name does not resolve, TLS fails, 20 seconds pass, more than ${BOARD_HTTP_MAX_REDIRECTS} redirects: exit 47), its status is not one of an HTTP response (100 to 599, such as a server answering 600), or the body cannot be read, the value is null and the error says why. The line escapes what a terminal would act on or hide (a line separator, a direction mark), as JSON does a line break: the value read is the same.`; + return `a GET, following up to ${BOARD_HTTP_MAX_REDIRECTS} redirects, with no \`-f\`: a 4xx or 5xx is a value, not an observation not made (the status is what was observed). The block keeps curl's status line and its exit code in W beside the body, and the script reads them: it prints the whole entry on one line, to put under the key as it is. The value holds \`status\`, \`redirects\` and \`final_url\` (the URL it ended at, with no user, query or fragment: they can carry a token that changes each time, and record refuses a \`final_url\` that has one), and what the observation reads off the body: \`words\`, whether the body holds each word, true or false in the order declared, found as written, character for character, with HTML entities not decoded; \`count\`, how many times the count_pattern matches (0 when it does not); \`capture\`, what the first group of the first match of the capture_pattern holds (null when nothing matches, or when the group takes no part). The patterns are matched against the whole body at once, not line by line as a measure's \`pattern\` is, so \`^\` and \`$\` are the body's start and end. When the status is not 2xx the body is not read, and each read is a null, not a list of nulls: an error page's text is not the page's. When curl exits other than 0 (the name does not resolve, TLS fails, 20 seconds pass, more than ${BOARD_HTTP_MAX_REDIRECTS} redirects: exit 47), its status is not one of an HTTP response (100 to 599, such as a server answering 600), the URL it ended at is not an http or https one, or the body cannot be read, the value is null and the error says why. The line escapes what a terminal would act on or hide (a line separator, a direction mark), as JSON does a line break: the value read is the same.`; case "manual": - return "only the operator can make it. Ask the operator, and do not make it yourself. Ask for one thing they can do as it is written: the line to run and where to run it, or the page to open, and what to send back. With no answer this time, write a null, say so as the error, and give the time you asked as `observed_at`. Source: `the operator`. When the operator tells you to make it yourself, make it as they said and write that in the source instead, such as `ran as the operator asked`."; + return "only the operator can make it. Ask the operator, and do not make it yourself. Ask once, in one message, for all the manual observations of this record: for each key, what they can do as it is written (the line to run and where to run it, or the page to open) and what to send back; when one line answers several keys, give it once and say which keys it answers. With no answer this time, write a null, say so as the error, and give the time you asked as `observed_at`. Source: `the operator`. When the operator tells you to make it yourself, make it as they said and write that in the source instead, such as `ran as the operator asked`."; } } @@ -624,21 +626,30 @@ function judging(d: BoardDeclaration, line: (...lines: string[]) => void): void line(""); } +// A word of a source as a shell reads it: as it is when it holds only what a +// shell takes literally, so that a plain name reads as before, and quoted as +// the command quotes it otherwise (a check named `Workers Builds: app`). +function sourceWord(text: string): string { + return /^[A-Za-z0-9_@%+=:,./-]+$/.test(text) ? text : shellWord(text); +} + // What an observation's source says: the command, the page, or the operator. +// A command's arguments are quoted where the command quotes them to ask the +// same thing. function sourceOf(o: BoardObserve): string { switch (o.kind) { case "npm_version": - return `npm view ${o.package} version`; + return `npm view ${sourceWord(o.package)} version`; case "github_release": - return `gh release view --repo ${o.repo}`; + return `gh release view --repo ${sourceWord(o.repo)}`; case "github_open_issues": - return `gh issue list --repo ${o.repo} --state open`; + return `gh issue list --repo ${sourceWord(o.repo)} --state open`; case "github_open_prs": - return `gh pr list --repo ${o.repo} --state open`; + return `gh pr list --repo ${sourceWord(o.repo)} --state open`; case "github_ci": - return `gh run list --repo ${o.repo} --workflow ${o.workflow} --branch ${o.branch}`; + return `gh run list --repo ${sourceWord(o.repo)} --workflow ${sourceWord(o.workflow)} --branch ${sourceWord(o.branch)}`; case "github_check": - return `gh api -X GET ${githubCheckPath(o)} -f check_name=${o.check}`; + return `gh api -X GET ${sourceWord(githubCheckPath(o))} -f check_name=${sourceWord(o.check)}`; case "page_version": case "http": return o.url; @@ -654,9 +665,13 @@ function sourceOf(o: BoardObserve): string { // node's: where the files are, the URL (the source), the count_pattern and the // capture_pattern (each '' when not declared), and the words. Curl's status // line is read from a file, never passed as an argument: the URL it ended at -// is not a value of the declaration's. The body is read only when the status -// is 2xx, decoded once all of it is read, so a character is never cut where a -// chunk ends, and with a leading BOM taken off; nothing else of it is decoded. +// is not a value of the declaration's. Of that URL only the scheme, the host +// (with a port that is not the scheme's own) and the path are kept: a user, a +// query or a fragment may carry a token made anew each time, as one behind a +// sign-in does, which would move the value at every record and put the token +// in the record. The body is read only when the status is 2xx, decoded once +// all of it is read, so a character is never cut where a chunk ends, and with +// a leading BOM taken off; nothing else of it is decoded. // An empty match would count positions, not matches: it is no observation, // and so is a status no response has (curl prints what a server answers, 600 // too). The line escapes what a terminal would act on or hide, as jsonText @@ -675,7 +690,9 @@ const HTTP_SCRIPT = [ 'if (m === null) return print(null, "curl printed no status line");', "const status = Number(m[1]);", 'if (status < 100 || status > 599) return print(null, "the status " + m[1] + " is not one of an HTTP response (100 to 599)");', - "const v = { status, redirects: Number(m[2]), final_url: m[3] };", + "let u; try { u = new URL(m[3]); } catch { u = null; }", + 'if (u === null || (u.protocol !== "http:" && u.protocol !== "https:") || u.host === "") return print(null, "the URL curl ended at is not an absolute http or https URL");', + 'const v = { status, redirects: Number(m[2]), final_url: u.protocol + "//" + u.host + u.pathname };', "const ok = status >= 200 && status <= 299;", 'let t = "";', 'if (ok && (words.length > 0 || count !== "" || capture !== "")) { try { t = fs.readFileSync(at + ".body", "utf8"); } catch (e) { if (e.code !== "ENOENT") throw e; } if (t.charCodeAt(0) === 0xfeff) t = t.slice(1); }', @@ -811,7 +828,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. Every measure is drawn in the board page's table of measures, and one with `lane:` (a lane id, or a list of them) in each of those lanes' sections too.", + "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 days on which something happened (a release, a launch, the start of the work), each with a `ref` saying where it can be checked, never a plan or a target date; a board moved from another keeps there the milestones it had that happened, and leaves out any that were only planned. Measures, ratios and components may stay empty at first. Every measure is drawn in the board page's table of measures, and one with `lane:` (a lane id, or a list of them) in each of those lanes' sections too.", ` ${referenceLine(input)}`, "4. Check it until it reads (every problem with it is listed at once):", ); diff --git a/packages/core/src/board/live.test.ts b/packages/core/src/board/live.test.ts index 7b2c300..b998584 100644 --- a/packages/core/src/board/live.test.ts +++ b/packages/core/src/board/live.test.ts @@ -131,7 +131,7 @@ describe("measureBoardLive", () => { ].sort(), ); expect(m.methods).toEqual({ - repos: 2, + repos: 3, trail: 2, integrity: 2, review_gaps: 2, @@ -212,7 +212,9 @@ describe("boardLivePage", () => { { key: name, kinds: ["manifest"] }, { key: `${name}/web`, kinds: ["manifest"] }, ]); - expect(page.repos[0]).not.toHaveProperty("files"); + // The files the repos table draws, as measured. + expect(page.repos[0]?.files).toBe(m.repos[0]?.files); + expect(page.repos[0]?.files).toEqual(expect.any(Number)); expect(page.repos[0]).toMatchObject({ base: "origin/main", behind: 0, @@ -226,6 +228,7 @@ describe("boardLivePage", () => { "branch", "last_commit", "commits", + "files", "uncommitted", "base", "behind", diff --git a/packages/core/src/board/live.ts b/packages/core/src/board/live.ts index e8adb48..10251c9 100644 --- a/packages/core/src/board/live.ts +++ b/packages/core/src/board/live.ts @@ -155,6 +155,7 @@ export type BoardLivePage = { branch: string | null; last_commit: string | null; commits: number | null; + files: number | null; uncommitted: number | null; base: string | null; behind: number | null; @@ -194,6 +195,7 @@ export function boardLivePage(title: string, m: BoardLiveMeasurement): BoardLive branch: r.branch, last_commit: r.last_commit, commits: r.commits, + files: r.files, uncommitted: r.uncommitted, base: r.base, behind: r.behind, diff --git a/packages/core/src/board/measure.test.ts b/packages/core/src/board/measure.test.ts index 2427aae..79dc7f2 100644 --- a/packages/core/src/board/measure.test.ts +++ b/packages/core/src/board/measure.test.ts @@ -1557,20 +1557,59 @@ describe("measureBoard: the repos section", () => { }); it.skipIf(!POSIX)( - "refuses a symlink that leaves the repository as a file_count of '**' does", + "leaves out of files a symlink that leaves the repository, which a file_count of '**' is not measured for", async () => { + // Instruction files kept in another repo and linked in, tracked and not. + await mkdir(join(root, "anchor", "agents", "app"), { recursive: true }); + await writeFile(join(root, "anchor", "agents", "app", "AGENTS.md"), "x\n"); const dir = await repo("app", { "a.txt": "1\n" }); - await symlink("/", join(dir, "out.md")); + await mkdir(join(dir, ".github")); + await mkdir(join(dir, "docs")); + await symlink("../anchor/agents/app/AGENTS.md", join(dir, "AGENTS.md")); + await symlink("AGENTS.md", join(dir, "CLAUDE.md")); + await symlink("../AGENTS.md", join(dir, ".github", "copilot-instructions.md")); + // One that ends at a file inside is that file, and counted. + await symlink("../a.txt", join(dir, "docs", "a.md")); + await commit(dir, {}, "links"); + await symlink("AGENTS.md", join(dir, "GEMINI.md")); const all = { id: "all", kind: "file_count", repo: "app", include: ["**"], unit: "files" }; - const m = await measure(declare([all]), NOW, ["app"]); - expect(m.repos[0]?.files).toBeNull(); + const linked = [".github/copilot-instructions.md", "AGENTS.md", "CLAUDE.md", "GEMINI.md"]; + const rest = { ...all, id: "rest", exclude: linked }; + const m = await measure(declare([all, rest]), NOW, ["app"]); + // a.txt and docs/a.md. + expect(m.repos[0]?.files).toBe(2); + expect(m.measures.rest?.value).toBe(2); + // A measure the board declares is still not measured for one, and can exclude it. + expect(m.measures.all?.value).toBeNull(); expect(m.not_found).toEqual([ - { at: "repos[app].files", reason: "'out.md' is a symlink to outside the repository" }, - { at: "measures.all", reason: "'out.md' is a symlink to outside the repository" }, + { + at: "measures.all", + reason: expect.stringMatching(/^'[^']+' is a symlink to outside the repository$/), + }, ]); }, ); + it.skipIf(!POSIX || ROOT_USER)( + "still does not count the files when a path in them cannot be read, a symlink to outside or not", + async () => { + const dir = await repo("app", { "a.txt": "1\n", "locked/b.ts": "1\n" }); + await symlink("/", join(dir, "AGENTS.md")); + await chmod(join(dir, "locked"), 0o000); + try { + const m = await measureRepos("app"); + expect(m.repos[0]?.files).toBeNull(); + expect(m.not_found).toContainEqual({ + at: "repos[app].files", + reason: "'locked/b.ts' could not be read (EACCES)", + }); + expect(m.not_found.map((n) => n.reason).join("\n")).not.toContain("AGENTS.md"); + } finally { + await chmod(join(dir, "locked"), 0o755); + } + }, + ); + it("counts each path git status names, whatever git's settings say", async () => { const dir = await repo("app", { ".gitignore": "*.log\n", @@ -1965,7 +2004,7 @@ describe("measureBoard: the repos section", () => { const declaration = declare([], {}, ["app"]); const first = await measure(declaration, NOW, ["app"]); expect(first.methods).toEqual({ - repos: 2, + repos: 3, trail: 2, integrity: 2, review_gaps: 2, @@ -3463,7 +3502,7 @@ describe("measureBoard: the integrity section", () => { const declaration = declare([]); const first = await measure(declaration); expect(first.methods).toEqual({ - repos: 2, + repos: 3, trail: 2, integrity: 2, review_gaps: 2, @@ -3723,7 +3762,7 @@ describe("measureBoard: the review_gaps section", () => { ); const none = await measure(declaration); expect(none.methods).toEqual({ - repos: 2, + repos: 3, trail: 2, integrity: 2, review_gaps: 2, @@ -3843,7 +3882,7 @@ describe("measureBoard: the portfolio section", () => { await register(`workspaces:\n - { path: ${master} }\n`); const bare = await measure(declaration); expect(bare.methods).toEqual({ - repos: 2, + repos: 3, trail: 2, integrity: 2, review_gaps: 2, diff --git a/packages/core/src/board/observed-shape.ts b/packages/core/src/board/observed-shape.ts index 9a42e6d..f515b17 100644 --- a/packages/core/src/board/observed-shape.ts +++ b/packages/core/src/board/observed-shape.ts @@ -66,11 +66,15 @@ function isPlainText(value: unknown): value is string { ); } -function isAbsoluteHttpUrl(value: unknown): boolean { +// An absolute http or https URL with no user, query or fragment: what a URL +// behind a sign-in carries there (a token signed anew each time) would move +// the value at every record, and put the token in a file kept in git. +function isPlainHttpUrl(value: unknown): boolean { if (typeof value !== "string" || hasControl(value) || /\s/u.test(value)) return false; - if (!/^https?:\/\//i.test(value)) return false; + if (!/^https?:\/\//i.test(value) || /[?#]/.test(value)) return false; try { - return new URL(value).hostname !== ""; + const url = new URL(value); + return url.hostname !== "" && url.username === "" && url.password === ""; } catch { return false; } @@ -177,10 +181,10 @@ function httpValueProblems( message: `must be a whole number from 0 to ${BOARD_HTTP_MAX_REDIRECTS}, ${received(redirects)}`, }); } - if (Object.hasOwn(value, "final_url") && !isAbsoluteHttpUrl(finalUrl)) { + if (Object.hasOwn(value, "final_url") && !isPlainHttpUrl(finalUrl)) { problems.push({ path: ["final_url"], - message: `must be an absolute http or https URL, ${received(finalUrl)}`, + message: `must be an absolute http or https URL with no user, query or fragment, ${received(finalUrl)}`, }); } // The body is read only when the status is 2xx. With a status that is not diff --git a/packages/core/src/board/page.test.ts b/packages/core/src/board/page.test.ts index db44e55..5b76958 100644 --- a/packages/core/src/board/page.test.ts +++ b/packages/core/src/board/page.test.ts @@ -704,7 +704,8 @@ describe("boardPage", () => { await place(A, v3); const page = await boardPage(records); if (page.status !== "ok") throw new Error(page.why); - const { files: _files, ...drawn } = repo; + // Its files too, which a record of every version holds. + const drawn = repo; expect(page.board.repos).toEqual({ record_version: 3, base_measured: true, @@ -719,12 +720,15 @@ describe("boardPage", () => { ], }); // A record of version 1 or 2 counted the commits behind as behind_main, - // and measured no base, ahead or fetch time. + // and measured no base, ahead or fetch time. Files not measured are null. for (const version of [1, 2]) { const older = record({ record_version: version }); older.measure = { ...older.measure, - repos: [{ ...repo, behind_main: 4 }], + repos: [ + { ...repo, behind_main: 4 }, + { ...repo, path: "../lib", files: null, behind_main: null }, + ], } as typeof older.measure; await place(A, older); const read = await boardPage(records); @@ -732,7 +736,18 @@ describe("boardPage", () => { expect(read.board.repos).toEqual({ record_version: version, base_measured: false, - list: [{ ...drawn, base: null, behind: 4, ahead: null, fetched_at: null }], + list: [ + { ...drawn, base: null, behind: 4, ahead: null, fetched_at: null }, + { + ...drawn, + path: "../lib", + files: null, + base: null, + behind: null, + ahead: null, + fetched_at: null, + }, + ], }); } // None, or not in the shape of its version: drawn as none, the rest of the record drawn. @@ -741,6 +756,7 @@ describe("boardPage", () => { [3, undefined], [3, "x"], [3, [{ ...repo, behind_main: 4 }]], + [3, [{ ...v3Repo, files: "30" }]], [2, [v3Repo]], [ 1, diff --git a/packages/core/src/board/page.ts b/packages/core/src/board/page.ts index 6aacb1c..5934d3b 100644 --- a/packages/core/src/board/page.ts +++ b/packages/core/src/board/page.ts @@ -49,6 +49,7 @@ const repoFields = { branch: text.nullable(), last_commit: text.nullable(), commits: count, + files: count, uncommitted: count, }; const repoSchema = z.looseObject({ @@ -273,6 +274,7 @@ export type BoardPageRepo = { branch: string | null; last_commit: string | null; commits: number | null; + files: number | null; uncommitted: number | null; base: string | null; behind: number | null; @@ -372,7 +374,11 @@ export type BoardPageBody = { * was not measured. */ active_days: number | null; - /** The days from the start to today, the one the effort was measured on. */ + /** + * The days from the start to today (the one the effort was measured on), + * both included: the rows of `daily`, one more than `elapsed_days`. The + * page's time per day is the active time over these. + */ period_days: number | null; /** * Each day from the start, in milliseconds: the active time, Claude's, the @@ -982,6 +988,7 @@ function reposOf(version: number, repos: unknown): BoardPageBody["repos"] { branch: r.branch, last_commit: r.last_commit, commits: r.commits, + files: r.files, uncommitted: r.uncommitted, base: r.base, behind: r.behind, @@ -998,6 +1005,7 @@ function reposOf(version: number, repos: unknown): BoardPageBody["repos"] { branch: r.branch, last_commit: r.last_commit, commits: r.commits, + files: r.files, uncommitted: r.uncommitted, base: null, behind: r.behind_main, diff --git a/packages/core/src/board/record.test.ts b/packages/core/src/board/record.test.ts index 3536f8d..4143e84 100644 --- a/packages/core/src/board/record.test.ts +++ b/packages/core/src/board/record.test.ts @@ -576,7 +576,7 @@ describe("parseRecordInput: the value of each kind of observation", () => { expect(errorsOf({ bare: { status: "200", redirects: 11, final_url: "/item" } })).toEqual([ 'observed.bare.value.status: must be a whole number from 100 to 599, the status of a response, received "200"', "observed.bare.value.redirects: must be a whole number from 0 to 10, received 11", - 'observed.bare.value.final_url: must be an absolute http or https URL, received "/item"', + 'observed.bare.value.final_url: must be an absolute http or https URL with no user, query or fragment, received "/item"', ]); for (const status of [0, 99, 600, 200.5]) { expect(errorsOf({ bare: { status, redirects: 0, final_url: url } })).toEqual([ @@ -686,6 +686,33 @@ describe("parseRecordInput: the value of each kind of observation", () => { expect(errorsOf({ "site.shop": { ...shopRead, status: 200, ...notRead } })).toHaveLength(2); }); + it("refuses a final_url with a user, a password, a query or a fragment, and takes a port and an escaped '?' in the path", () => { + const finalUrlOf = (finalUrl: string) => + errorsOf({ bare: { status: 200, redirects: 0, final_url: finalUrl } }); + for (const finalUrl of [ + "https://example.com/login?token=abc", + "https://example.com/login?", + "https://example.com/page#top", + "https://example.com/#", + "https://user@example.com/", + "https://user:secret@example.com/", + "https://:secret@example.com/", + ]) { + expect(finalUrlOf(finalUrl), finalUrl).toEqual([ + `observed.bare.value.final_url: must be an absolute http or https URL with no user, query or fragment, received ${JSON.stringify(finalUrl)}`, + ]); + } + for (const finalUrl of [ + "https://example.com:8443/a/b", + "http://127.0.0.1:4400/login", + "https://example.com/a%3Fb%23c", + ]) { + expect(finalUrlOf(finalUrl), finalUrl).toEqual([]); + } + // A null with its error is taken as before, and so is any answer of a manual one. + expect(errorsOf({ bare: null, users: "https://user@example.com/?q#f" })).toEqual([]); + }); + it("refuses negative redirects, and a URL with no // or a control in it, but takes a scheme in capitals", () => { expect(errorsOf({ bare: { status: 200, redirects: -1, final_url: url } })).toEqual([ "observed.bare.value.redirects: must be a whole number from 0 to 10, received -1", diff --git a/packages/core/src/board/repos.ts b/packages/core/src/board/repos.ts index fe920f1..89aaf44 100644 --- a/packages/core/src/board/repos.ts +++ b/packages/core/src/board/repos.ts @@ -20,7 +20,7 @@ import { * the section would change for the same repository, so a difference between * two measurements can be told apart from a change in the repository. */ -export const BOARD_REPOS_METHOD = 2; +export const BOARD_REPOS_METHOD = 3; /** * One repository the manifest declares, measured where it is checked out. @@ -49,7 +49,9 @@ export type BoardRepo = { commits: number | null; /** * The files a measure counts in the working tree: tracked, and untracked - * but not ignored, that are on disk (a measure's `file_count` of `**`). + * but not ignored, that are on disk (a measure's `file_count` of `**`), + * less each symlink that leaves the repository, which cannot be told to be + * a file without looking outside it. */ files: number | null; /** @@ -360,13 +362,17 @@ export async function measureRepos( const worktree = await worktreeOf(path); if (!worktree.ok) fail("files", `the working tree ${worktree.reason}`); else { - // As a file_count of "**" does: a file that cannot be followed first, - // then a directory git could not list in full. + // As a file_count of "**" does, less each symlink that leaves the + // repository (one an agent's instruction file may be, and which no + // exclude can leave out of a built-in count): a path that cannot be + // read first, then a directory git could not list in full. + const inside = [...worktree.scope.entries] + .filter(([, entry]) => entry.kind !== "outside") + .map(([p]) => p); const blocked = - blockedAmong(worktree.scope, [...worktree.scope.entries.keys()]) ?? - unreadReaching(worktree.scope, () => true); + blockedAmong(worktree.scope, inside) ?? unreadReaching(worktree.scope, () => true); if (blocked !== undefined) fail("files", blocked); - else repo.files = worktree.scope.entries.size; + else repo.files = inside.length; } const submodule = mayFetch && !partial ? await hasSubmodule(cwd) : false; diff --git a/packages/core/src/lib/view-strings.ts b/packages/core/src/lib/view-strings.ts index d66d117..2cf5460 100644 --- a/packages/core/src/lib/view-strings.ts +++ b/packages/core/src/lib/view-strings.ts @@ -978,8 +978,12 @@ export type BoardPageStrings = { daysWorked: string; claude: string; codex: string; + /** Under Claude's and Codex's time: each is a union of intervals, not a sum of sessions. */ + toolUnion: string; noCodex: string; perDay: string; + /** What the time per day is divided by: the days from the start to today, both included. */ + perDayOver: string; perDayWorked: string; outputTokens: string; withoutTokens: string; @@ -1072,6 +1076,7 @@ export type BoardPageStrings = { head: string; lastCommit: string; commits: string; + files: string; uncommitted: string; base: string; behind: string; @@ -1183,8 +1188,10 @@ const BOARD_PAGE_EN: BoardPageStrings = { daysWorked: "{n} days worked", claude: "Claude", codex: "Codex", + toolUnion: "overlaps counted once, not a sum of sessions", noCodex: "no Codex session", perDay: "Per day", + perDayOver: "over {days} days, today included", perDayWorked: "{time} a day worked", outputTokens: "Output tokens", withoutTokens: "imports that recorded no tokens: {n}", @@ -1280,6 +1287,7 @@ const BOARD_PAGE_EN: BoardPageStrings = { head: "HEAD", lastCommit: "Last commit", commits: "Commits", + files: "Files", uncommitted: "Uncommitted", base: "Base branch", behind: "Behind", @@ -1391,8 +1399,10 @@ const BOARD_PAGE_JA: BoardPageStrings = { daysWorked: "{n} 日に分布", claude: "Claude", codex: "Codex", + toolUnion: "重なりを除いた時間(session ごとの合計ではない)", noCodex: "Codex の session なし", perDay: "1 日あたり", + perDayOver: "今日を含む {days} 日で割った値", perDayWorked: "動いた日だけなら {time}", outputTokens: "出力トークン", withoutTokens: "トークンを記録しなかった取り込み {n}", @@ -1485,6 +1495,7 @@ const BOARD_PAGE_JA: BoardPageStrings = { head: "HEAD", lastCommit: "最終 commit", commits: "commit 数", + files: "ファイル数", uncommitted: "未 commit", base: "比べる branch", behind: "遅れ", From bc1ec95b402cf922ed6254cdf380e9e8aa2292d5 Mon Sep 17 00:00:00 2001 From: Takashi Matsuyama Date: Tue, 20 Oct 2026 18:08:58 +0900 Subject: [PATCH 2/5] basou: Say in the comment board init prints above milestones that a milestone is a day on which something happened The comment said only what was reached, and that a board moved from another keeps its milestones there: the same two clauses the guide had, which a start of the work among a moved board's milestones did not fit. It now gives the guide's rule: days on which something happened (a release, a launch, the start of the work), never a plan or a target date, and a moved board keeps the milestones it had that happened. --- CHANGELOG.md | 13 +++++++------ packages/core/src/board/init.test.ts | 13 +++++++++++-- packages/core/src/lib/view-strings.ts | 4 ++-- 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb0de56..ba1a927 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,12 +52,13 @@ All notable changes to **basou** are recorded here. The project follows answers. A board of ten `manual` observations that one query answers had the agent ask ten times. The operator's turns in the guide list them as one item, with each key under it. -- **`basou board guide` says a milestone is a day on which something - happened** (a release, a launch, the start of the work), never a plan or - a target date. It said only what was reached, and also that a board moved - from another keeps its milestones, which a start among them did not fit. - A moved board keeps the milestones it had that happened, and leaves out - those that were only planned. +- **`basou board guide`, and the comment `basou board init` prints above + `milestones`, say a milestone is a day on which something happened** (a + release, a launch, the start of the work), never a plan or a target date. + They said only what was reached, and also that a board moved from another + keeps its milestones, which a start among them did not fit. A moved board + keeps the milestones it had that happened, and leaves out those that were + only planned. ### Fixed diff --git a/packages/core/src/board/init.test.ts b/packages/core/src/board/init.test.ts index 2ada9ab..09128a5 100644 --- a/packages/core/src/board/init.test.ts +++ b/packages/core/src/board/init.test.ts @@ -148,10 +148,19 @@ describe("boardInitText", () => { } expect(en.comments.milestones).toContain("{ date: YYYY-MM-DD, label, ref }"); expect(en.comments.milestones).toContain( - "A board moved from another keeps its milestones here.", + "only days on which something happened (a release, a launch, the start of the work), never a plan or a target date", ); + expect(en.comments.milestones).toContain( + "A board moved from another keeps here the milestones it had that happened, and leaves out those that were only planned.", + ); + expect(en.comments.milestones).not.toContain("only what was reached"); expect(ja.comments.milestones).toContain("{ date: YYYY-MM-DD, label, ref }"); - expect(ja.comments.milestones).toContain("他の盤から移すときは、その節目をここに移す。"); + expect(ja.comments.milestones).toContain( + "何かが起きた日(出荷・公開・作業の開始)だけを書き、予定や目標の日は書かない。", + ); + expect(ja.comments.milestones).toContain( + "他の盤から移すときは、起きた節目をここに移し、予定だけだったものは移さない。", + ); expect(en.comments.milestones).toContain( "with ref saying where it can be checked (a tag and its commit, or a decision id in full)", ); diff --git a/packages/core/src/lib/view-strings.ts b/packages/core/src/lib/view-strings.ts index 2cf5460..728891c 100644 --- a/packages/core/src/lib/view-strings.ts +++ b/packages/core/src/lib/view-strings.ts @@ -1586,7 +1586,7 @@ const BOARD_INIT_EN: BoardInitStrings = { timeZone: "This host's time zone has no name a board can declare: write effort.time_zone (such as Asia/Tokyo), or the days are counted in an unnamed zone or not at all.", milestones: - "Days marked on the period and effort chart, each { date: YYYY-MM-DD, label, ref }: only what was reached (a release, a launch), with ref saying where it can be checked (a tag and its commit, or a decision id in full). A board moved from another keeps its milestones here.", + "Days marked on the period and effort chart, each { date: YYYY-MM-DD, label, ref }: only days on which something happened (a release, a launch, the start of the work), never a plan or a target date, with ref saying where it can be checked (a tag and its commit, or a decision id in full). A board moved from another keeps here the milestones it had that happened, and leaves out those that were only planned.", }, }; @@ -1623,7 +1623,7 @@ const BOARD_INIT_JA: BoardInitStrings = { timeZone: "このホストの time zone に、盤に書ける名前が無い。effort.time_zone(例 Asia/Tokyo)を書かないと、期間と労力は名前の無い zone で数えられるか、測られない。", milestones: - "期間と労力の図に印を付ける日。各 { date: YYYY-MM-DD, label, ref }。到達したもの(出荷・公開)だけを書き、ref に確かめられる出所(tag とその commit、または完全な decision の ID)を書く。他の盤から移すときは、その節目をここに移す。", + "期間と労力の図に印を付ける日。各 { date: YYYY-MM-DD, label, ref }。何かが起きた日(出荷・公開・作業の開始)だけを書き、予定や目標の日は書かない。ref に確かめられる出所(tag とその commit、または完全な decision の ID)を書く。他の盤から移すときは、起きた節目をここに移し、予定だけだったものは移さない。", }, }; From 852e2f8adcff8409ff550be305c5379eb48ffeaa Mon Sep 17 00:00:00 2001 From: Takashi Matsuyama Date: Tue, 20 Oct 2026 18:57:35 +0900 Subject: [PATCH 3/5] basou: Draw a written record's http value as its parts whatever its final_url holds, and leave out a symlink whose end cannot be reached when its text leaves the repo - The board page held a written record's http value to what record takes now, so a value whose final_url had a query, which record took before, was drawn as raw JSON. It now holds such a value to the shape record took when it was written; record alone refuses a user, a query or a fragment. - record also refuses a final_url holding a character a terminal hides, as it does a string of the other kinds. - A symlink whose end cannot be reached (a directory on the way that may not be entered) was not readable, so repos[].files stayed null for it. It is now outside when its text, or that of a link in the repo it leads to, leaves the repository. - The CHANGELOG and docs/spec/board.md say what moves once at the next record (a fragment, a user, a port that is the scheme's own, capitals, a host outside ASCII), that the repos method fires the axis review's trigger (e) once, how the command reads a URL, and that a token in the path itself is kept; the guide says to make such an observation manual. - The ja words under Claude's and Codex's time say an overlap counts once, and tests pin them and the names a source quotes. --- CHANGELOG.md | 26 ++++++++---- docs/spec/board.md | 35 ++++++++++++---- packages/core/src/board/guide.test.ts | 19 ++++++++- packages/core/src/board/guide.ts | 2 +- packages/core/src/board/measure.test.ts | 32 ++++++++++++++ packages/core/src/board/observed-shape.ts | 49 +++++++++++++++++----- packages/core/src/board/page.test.ts | 13 ++++++ packages/core/src/board/page.ts | 11 +++-- packages/core/src/board/record.test.ts | 13 +++++- packages/core/src/board/scope.ts | 34 ++++++++++++++- packages/core/src/lib/view-strings.test.ts | 12 ++++++ packages/core/src/lib/view-strings.ts | 2 +- 12 files changed, 210 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba1a927..1905d36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,21 +30,33 @@ All notable changes to **basou** are recorded here. The project follows cannot be read, or a directory git could not list in full, still makes it null with its reason, and a measure the board declares (a `file_count` of `**`) is still not measured for such a symlink, which its `exclude` can - leave out. The `repos` method is now 3, so the next record marks the + leave out. A symlink whose end cannot be reached (a directory on the way + that may not be entered) is left out too when its text, or that of a link + in the repository it leads to, leaves the repository. The `repos` method + is now 3, so on every board the next measure fires the axis review's + trigger (e) once, as a raised method does, and the next record marks the repos section `method_changed`. - **`basou board record` refuses an `http` value's `final_url` that has a - user, a query or a fragment, and the script `basou board guide` writes - leaves them out.** Behind a sign-in (a redirect to a login page) the URL + user, a query, a fragment or a hidden character, and the script + `basou board guide` writes leaves them out.** Behind a sign-in (a redirect to a login page) the URL curl ended at carries a token signed anew each time: the observation showed as moved at every record, and the record, kept in git, held a token a secret scanner may flag. The script prints `final_url` as that URL's scheme, host (with a port that is not the scheme's own) and path, and a null with the error `the URL curl ended at is not an absolute http or https URL` when it is not one. Record refuses a `final_url` that holds a - `?` or a `#`, or a user name or a password: - `observed.shop.value.final_url: must be an absolute http or https URL with no user, query or fragment, received "https://example.com/login?token=..."`. - On a board whose `final_url` had a query, the observation shows as moved - once, at the next record. Records already written are not checked again. + `?` or a `#`, a user name or a password, or a character a terminal hides + (as it refuses one in a string of the other kinds): + `observed.shop.value.final_url: must be an absolute http or https URL with no user, query, fragment or hidden character, received "https://example.com/login?token=..."`. + The script prints the URL as `new URL` reads it, so on the next record an + observation shows as moved once wherever the `final_url` it held differs + from that: a query, a fragment (curl keeps one of the declared URL) or a + user dropped, a port that is the scheme's own (`:443`) dropped, a scheme + or host in capitals made lower case, a host outside ASCII in its ASCII + form (`xn--`), a character such as `{` escaped. A token in the path + itself (`/login;jsessionid=...`) is kept. Records already written are not + checked again, and the board page draws an `http` value they hold as its + parts as before. - **`basou board guide` asks for every `manual` observation of a record in one message**: for each key, what the operator can do as it is written (the line to run and where, or the page to open) and what to send back, diff --git a/docs/spec/board.md b/docs/spec/board.md index 03694a3..1835826 100644 --- a/docs/spec/board.md +++ b/docs/spec/board.md @@ -527,7 +527,8 @@ never part of a command. a kind above that is not a whole number from 0 to 9007199254740991; and an `http` value out of its shape ([below](#http)), which from basou 0.73.0 includes a `final_url` - with a user, a query or a fragment. What a string says is not held to a + with a user, a query, a fragment or a character a terminal hides. What a + string says is not held to a grammar (a version's, or the words GitHub uses), so a new word is never refused. A `manual` value, and a null, are not checked. Only a board that declares observations has them checked ([3.2](#32-observed)). @@ -589,7 +590,7 @@ The value recorded: |---|---|---| | `status` | always | a whole number from 100 to 599: the status of the last response | | `redirects` | always | a whole number from 0 to 10: how many redirects led to it | -| `final_url` | always | an absolute `http://` or `https://` URL with no user, query or fragment: the URL it ended at, as its scheme, its host (with a port that is not the scheme's own) and its path | +| `final_url` | always | an absolute `http://` or `https://` URL with no user, query, fragment or hidden character: the URL it ended at, as its scheme, its host (with a port that is not the scheme's own) and its path, as `new URL` reads them | | `words` | with `words` | a list of `true` or `false`, one for each word, in the order declared: whether the body holds it | | `count` | with `count_pattern` | a whole number from 0 to 9007199254740991: how many times it matches the body (0 when it does not) | | `capture` | with `capture_pattern` | a string: what the first group holds in the first match, as it is, spaces and all; null when nothing matches or the group takes no part | @@ -597,11 +598,24 @@ The value recorded: From basou 0.72.0, record refuses an `http` value with a key the observation does not declare a read of, or one it declares left out, and a read that is not null when the status is not 2xx. From basou 0.73.0, it -refuses a `final_url` that holds a `?` or a `#`, or a user name or a -password, and the guide's command leaves them out of the URL curl ended at: -behind a sign-in (a redirect to a login page) they can carry a token made -anew each time, which would show the observation as moved at every record -and put the token in a record kept in git. +refuses a `final_url` that holds a `?` or a `#`, a user name or a password, +or a character a terminal hides (as for a string of the other kinds), and +the guide's command leaves them out of the URL curl ended at: behind a +sign-in (a redirect to a login page) they can carry a token made anew each +time, which would show the observation as moved at every record and put the +token in a record kept in git. A record written before keeps the value it +holds, and the board page draws it as its parts. + +- **The command reads the URL as `new URL` does**: a scheme and a host in + lower case, a host outside ASCII in its ASCII form (`xn--`), a port that is + the scheme's own left out, and the path with its dot segments resolved and + what a URL may not hold as it is escaped (`%2e%2e` or a backslash in the + path curl ended at reads as a step up or a `/`). The value is the same for + the same URL. +- **A token in the path itself is kept**: a session id a server writes into + the path for a client that keeps no cookie (`/login;jsessionid=...`) is + part of the path, so the observation shows as moved at each record and the + token is in the record. Make such an observation `manual` instead. - **A 4xx or a 5xx is a value**, not an observation not made: the status is what was observed. @@ -1009,8 +1023,11 @@ A list, in the manifest's order: - **`files` leaves out a symlink that leaves the repository**, such as an agent's instruction file linked in from another repo (tracked, or - untracked and not ignored): what it ends at cannot be told to be a file without looking - outside the repository, and a built-in value takes no `exclude`. A + untracked and not ignored): what it ends at cannot be told to be a file + without looking outside the repository, and a built-in value takes no + `exclude`. When what it ends at cannot be reached (a directory on the way + that may not be entered), it is still left out when its text, or that of + a link in the repository it leads to, leaves the repository. A `file_count` of `**` is not measured for one. A path that cannot be read, or a directory git could not list in full, still makes `files` null, with the reason at `repos[].files`. Up to `repos` method 2 (before basou diff --git a/packages/core/src/board/guide.test.ts b/packages/core/src/board/guide.test.ts index 790a9d5..6f7cd2c 100644 --- a/packages/core/src/board/guide.test.ts +++ b/packages/core/src/board/guide.test.ts @@ -984,7 +984,7 @@ describe("boardGuide: an http observation", () => { expect(guide).toContain("- `http`: a GET, following up to 10 redirects, with no `-f`"); expect(guide).toContain("not line by line as a measure's `pattern` is"); expect(guide).toContain( - "The value holds `status`, `redirects` and `final_url` (the URL it ended at, with no user, query or fragment: they can carry a token that changes each time, and record refuses a `final_url` that has one)", + "The value holds `status`, `redirects` and `final_url` (the URL it ended at, with no user, query or fragment: they can carry a token that changes each time, and record refuses a `final_url` that has one; a token in the path itself, such as `;jsessionid=`, is kept, so make an observation that ends at such a URL `manual` instead)", ); expect(guide).toContain( "When the status is not 2xx the body is not read, and each read is a null, not a list of nulls", @@ -1522,6 +1522,12 @@ describe("boardGuide: a github_check observation and an observation's notes", () { key: "deploy", kind: "github_check", repo: "o/n", check: "Workers Builds: my-app" }, { key: "odd", kind: "github_check", repo: "o/n.js", check: ODD_CHECK, branch: "a/b" }, { key: "plain", kind: "github_check", repo: "o/n", check: "build" }, + // What a shell would expand, or zsh would with its own options, though nothing matches here. + { key: "star", kind: "github_check", repo: "o/n", check: "e2e*" }, + { key: "mark", kind: "github_check", repo: "o/n", check: "a?b" }, + { key: "set", kind: "github_check", repo: "o/n", check: "[x]" }, + { key: "tilde", kind: "github_check", repo: "o/n", check: "~x" }, + { key: "caret", kind: "github_check", repo: "o/n", check: "^x" }, { key: "ci", kind: "github_ci", repo: "o/n", workflow: "quality.yml", branch: "rel/1.x" }, { key: "rel", kind: "github_release", repo: "o/n" }, { key: "iss", kind: "github_open_issues", repo: "o/n" }, @@ -1541,6 +1547,17 @@ describe("boardGuide: a github_check observation and an observation's notes", () expect(sources.deploy?.source).toBe( "gh api -X GET repos/o/n/commits/heads/main/check-runs -f check_name='Workers Builds: my-app'", ); + for (const [key, check] of [ + ["star", "e2e*"], + ["mark", "a?b"], + ["set", "[x]"], + ["tilde", "~x"], + ["caret", "^x"], + ]) { + expect(sources[key as string]?.source, key).toBe( + `gh api -X GET repos/o/n/commits/heads/main/check-runs -f check_name='${check}'`, + ); + } const words = (line: string) => execFileSync("sh", ["-c", line], { cwd: dir, diff --git a/packages/core/src/board/guide.ts b/packages/core/src/board/guide.ts index 6e0a447..4d4c6f7 100644 --- a/packages/core/src/board/guide.ts +++ b/packages/core/src/board/guide.ts @@ -572,7 +572,7 @@ function kindText(kind: BoardObserve["kind"]): string { case "page_version": return "the first `v..` (with what follows a `-`) a page shows, not right after a letter, digit, underscore or dot, once its generator meta tags are taken out, however they are quoted or cased (they name the site builder's version, not the product's). Value: the version it prints, as a string (such as \"v1.2.3\"). A failed fetch, or no version on the page (exit 1), is a null with that as the error."; case "http": - return `a GET, following up to ${BOARD_HTTP_MAX_REDIRECTS} redirects, with no \`-f\`: a 4xx or 5xx is a value, not an observation not made (the status is what was observed). The block keeps curl's status line and its exit code in W beside the body, and the script reads them: it prints the whole entry on one line, to put under the key as it is. The value holds \`status\`, \`redirects\` and \`final_url\` (the URL it ended at, with no user, query or fragment: they can carry a token that changes each time, and record refuses a \`final_url\` that has one), and what the observation reads off the body: \`words\`, whether the body holds each word, true or false in the order declared, found as written, character for character, with HTML entities not decoded; \`count\`, how many times the count_pattern matches (0 when it does not); \`capture\`, what the first group of the first match of the capture_pattern holds (null when nothing matches, or when the group takes no part). The patterns are matched against the whole body at once, not line by line as a measure's \`pattern\` is, so \`^\` and \`$\` are the body's start and end. When the status is not 2xx the body is not read, and each read is a null, not a list of nulls: an error page's text is not the page's. When curl exits other than 0 (the name does not resolve, TLS fails, 20 seconds pass, more than ${BOARD_HTTP_MAX_REDIRECTS} redirects: exit 47), its status is not one of an HTTP response (100 to 599, such as a server answering 600), the URL it ended at is not an http or https one, or the body cannot be read, the value is null and the error says why. The line escapes what a terminal would act on or hide (a line separator, a direction mark), as JSON does a line break: the value read is the same.`; + return `a GET, following up to ${BOARD_HTTP_MAX_REDIRECTS} redirects, with no \`-f\`: a 4xx or 5xx is a value, not an observation not made (the status is what was observed). The block keeps curl's status line and its exit code in W beside the body, and the script reads them: it prints the whole entry on one line, to put under the key as it is. The value holds \`status\`, \`redirects\` and \`final_url\` (the URL it ended at, with no user, query or fragment: they can carry a token that changes each time, and record refuses a \`final_url\` that has one; a token in the path itself, such as \`;jsessionid=\`, is kept, so make an observation that ends at such a URL \`manual\` instead), and what the observation reads off the body: \`words\`, whether the body holds each word, true or false in the order declared, found as written, character for character, with HTML entities not decoded; \`count\`, how many times the count_pattern matches (0 when it does not); \`capture\`, what the first group of the first match of the capture_pattern holds (null when nothing matches, or when the group takes no part). The patterns are matched against the whole body at once, not line by line as a measure's \`pattern\` is, so \`^\` and \`$\` are the body's start and end. When the status is not 2xx the body is not read, and each read is a null, not a list of nulls: an error page's text is not the page's. When curl exits other than 0 (the name does not resolve, TLS fails, 20 seconds pass, more than ${BOARD_HTTP_MAX_REDIRECTS} redirects: exit 47), its status is not one of an HTTP response (100 to 599, such as a server answering 600), the URL it ended at is not an http or https one, or the body cannot be read, the value is null and the error says why. The line escapes what a terminal would act on or hide (a line separator, a direction mark), as JSON does a line break: the value read is the same.`; case "manual": return "only the operator can make it. Ask the operator, and do not make it yourself. Ask once, in one message, for all the manual observations of this record: for each key, what they can do as it is written (the line to run and where to run it, or the page to open) and what to send back; when one line answers several keys, give it once and say which keys it answers. With no answer this time, write a null, say so as the error, and give the time you asked as `observed_at`. Source: `the operator`. When the operator tells you to make it yourself, make it as they said and write that in the source instead, such as `ran as the operator asked`."; } diff --git a/packages/core/src/board/measure.test.ts b/packages/core/src/board/measure.test.ts index 79dc7f2..0d8dcca 100644 --- a/packages/core/src/board/measure.test.ts +++ b/packages/core/src/board/measure.test.ts @@ -1610,6 +1610,38 @@ describe("measureBoard: the repos section", () => { }, ); + it.skipIf(!POSIX || ROOT_USER)( + "leaves out of files a symlink that leaves the repository when what it ends at cannot be reached", + async () => { + // The other repo's directory may not be entered: the links' text still leaves. + await mkdir(join(root, "anchor", "agents", "app"), { recursive: true }); + await writeFile(join(root, "anchor", "agents", "app", "AGENTS.md"), "x\n"); + const dir = await repo("app", { "a.txt": "1\n" }); + await mkdir(join(dir, ".github")); + await mkdir(join(dir, "docs")); + await symlink("../anchor/agents/app/AGENTS.md", join(dir, "AGENTS.md")); + await symlink("AGENTS.md", join(dir, "CLAUDE.md")); + await symlink("../AGENTS.md", join(dir, ".github", "copilot-instructions.md")); + await symlink("../a.txt", join(dir, "docs", "a.md")); + await commit(dir, {}, "links"); + await chmod(join(root, "anchor"), 0o000); + try { + const all = { id: "all", kind: "file_count", repo: "app", include: ["**"], unit: "files" }; + const m = await measure(declare([all]), NOW, ["app"]); + // a.txt and docs/a.md. + expect(m.repos[0]?.files).toBe(2); + expect(m.not_found).toEqual([ + { + at: "measures.all", + reason: expect.stringMatching(/^'[^']+' is a symlink to outside the repository$/), + }, + ]); + } finally { + await chmod(join(root, "anchor"), 0o755); + } + }, + ); + it("counts each path git status names, whatever git's settings say", async () => { const dir = await repo("app", { ".gitignore": "*.log\n", diff --git a/packages/core/src/board/observed-shape.ts b/packages/core/src/board/observed-shape.ts index f515b17..3ea486e 100644 --- a/packages/core/src/board/observed-shape.ts +++ b/packages/core/src/board/observed-shape.ts @@ -66,20 +66,40 @@ function isPlainText(value: unknown): value is string { ); } -// An absolute http or https URL with no user, query or fragment: what a URL -// behind a sign-in carries there (a token signed anew each time) would move -// the value at every record, and put the token in a file kept in git. -function isPlainHttpUrl(value: unknown): boolean { +// An absolute http or https URL, as record took a final_url from basou +// 0.72.0 on, and as a record written since may hold one. +function isHttpUrl(value: unknown): value is string { if (typeof value !== "string" || hasControl(value) || /\s/u.test(value)) return false; - if (!/^https?:\/\//i.test(value) || /[?#]/.test(value)) return false; + if (!/^https?:\/\//i.test(value)) return false; try { - const url = new URL(value); - return url.hostname !== "" && url.username === "" && url.password === ""; + return new URL(value).hostname !== ""; } catch { return false; } } +// An absolute http or https URL with no user, query or fragment, and no +// character a terminal hides: what a URL behind a sign-in carries there (a +// token signed anew each time) would move the value at every record, and put +// the token in a file kept in git. The guide's command prints no such +// character (a host comes out in its ASCII form). +function isPlainHttpUrl(value: unknown): boolean { + if (!isHttpUrl(value) || HIDDEN_CHAR.test(value) || /[?#]/.test(value)) return false; + const url = new URL(value); + return url.username === "" && url.password === ""; +} + +/** How a value is held: as record takes it now, or as a record already holds it. */ +export type ObservedValueCheck = { + /** + * The value is one a record already holds: its `final_url` is held only to + * the shape record took from basou 0.72.0 on, which let a user, a query, a + * fragment and a hidden character through. The page draws such a value as + * its parts; record never checks a written record again. + */ + written?: boolean; +}; + /** * What is wrong with the value of an observation, held against the shape its * kind gives it: every problem, for an `http` value its keys it does not take @@ -88,7 +108,11 @@ function isPlainHttpUrl(value: unknown): boolean { * here, nor is a `manual` one, which is what the operator answers. The value * is a JSON value no deeper than a record takes. */ -export function observedValueProblems(o: BoardObserve, value: unknown): ObservedValueProblem[] { +export function observedValueProblems( + o: BoardObserve, + value: unknown, + check: ObservedValueCheck = {}, +): ObservedValueProblem[] { const kind = `(kind '${o.kind}')`; switch (o.kind) { case "manual": @@ -117,7 +141,7 @@ export function observedValueProblems(o: BoardObserve, value: unknown): Observed }, ]; case "http": - return httpValueProblems(o, value); + return httpValueProblems(o, value, check.written === true); } } @@ -137,6 +161,7 @@ function httpKeys(o: Extract): string[] { function httpValueProblems( o: Extract, value: unknown, + written: boolean, ): ObservedValueProblem[] { const keys = httpKeys(o); if (!isRecord(value)) { @@ -181,10 +206,12 @@ function httpValueProblems( message: `must be a whole number from 0 to ${BOARD_HTTP_MAX_REDIRECTS}, ${received(redirects)}`, }); } - if (Object.hasOwn(value, "final_url") && !isPlainHttpUrl(finalUrl)) { + if (Object.hasOwn(value, "final_url") && !(written ? isHttpUrl : isPlainHttpUrl)(finalUrl)) { problems.push({ path: ["final_url"], - message: `must be an absolute http or https URL with no user, query or fragment, ${received(finalUrl)}`, + message: written + ? `must be an absolute http or https URL, ${received(finalUrl)}` + : `must be an absolute http or https URL with no user, query, fragment or hidden character, ${received(finalUrl)}`, }); } // The body is read only when the status is 2xx. With a status that is not diff --git a/packages/core/src/board/page.test.ts b/packages/core/src/board/page.test.ts index 5b76958..542fa28 100644 --- a/packages/core/src/board/page.test.ts +++ b/packages/core/src/board/page.test.ts @@ -366,6 +366,12 @@ describe("boardPage", () => { // As a record written before 0.72.0 may hold it. older: made({ status: 200, words: [true] }), asked: made({ status: 200, redirects: 0, final_url: "https://a.example/" }), + // As a record written before record refused a user, a query or a fragment may hold it. + signin: made({ + status: 200, + redirects: 1, + final_url: "https://u@login.example/sso?token=a1b2c3#top", + }), }, }); const http = (key: string, more: Record = {}) => ({ @@ -388,6 +394,7 @@ describe("boardPage", () => { http("last", { words: ["a"] }), http("older", { words: ["a"] }), { key: "asked", kind: "manual", how: "ask" }, + http("signin"), ], } as typeof r.declaration; await place(A, r); @@ -422,6 +429,12 @@ describe("boardPage", () => { }); expect(byName.get("nomatch")?.http?.body).toEqual({ capture: null }); expect(byName.get("last")?.http?.body).toEqual({ words: [{ word: "a", found: false }] }); + // A record already written is not held to what record refuses now. + expect(byName.get("signin")?.http).toEqual({ + status: 200, + redirects: 1, + final_url: "https://u@login.example/sso?token=a1b2c3#top", + }); // Out of the shape its declaration gives it, or of a kind with no shape: as it is. expect(byName.get("older")?.http).toBeUndefined(); expect(byName.get("older")?.words).toEqual([{ word: "a", found: true }]); diff --git a/packages/core/src/board/page.ts b/packages/core/src/board/page.ts index 5934d3b..287e772 100644 --- a/packages/core/src/board/page.ts +++ b/packages/core/src/board/page.ts @@ -920,12 +920,15 @@ function httpWords(o: DeclaredObservation | undefined): string[] | undefined { } // An http value read as its parts, when it is in the shape the declared -// observation gives it (the shape record holds a value to): a value of -// another shape, as a record written before that check may hold, is drawn -// as it is. +// observation gives it (the shape record held a value to when it was +// written, so a final_url with a query, which record took before it refused +// one, is still drawn as a part): a value of another shape, as a record +// written before that check may hold, is drawn as it is. function httpOf(o: DeclaredObservation | undefined, value: unknown): BoardPageHttp | undefined { if (o?.kind !== "http" || value === null || value === undefined) return undefined; - if (observedValueProblems(o as unknown as BoardObserve, value).length > 0) return undefined; + if (observedValueProblems(o as unknown as BoardObserve, value, { written: true }).length > 0) { + return undefined; + } const v = value as { status: number; redirects: number; diff --git a/packages/core/src/board/record.test.ts b/packages/core/src/board/record.test.ts index 4143e84..17dc081 100644 --- a/packages/core/src/board/record.test.ts +++ b/packages/core/src/board/record.test.ts @@ -576,7 +576,7 @@ describe("parseRecordInput: the value of each kind of observation", () => { expect(errorsOf({ bare: { status: "200", redirects: 11, final_url: "/item" } })).toEqual([ 'observed.bare.value.status: must be a whole number from 100 to 599, the status of a response, received "200"', "observed.bare.value.redirects: must be a whole number from 0 to 10, received 11", - 'observed.bare.value.final_url: must be an absolute http or https URL with no user, query or fragment, received "/item"', + 'observed.bare.value.final_url: must be an absolute http or https URL with no user, query, fragment or hidden character, received "/item"', ]); for (const status of [0, 99, 600, 200.5]) { expect(errorsOf({ bare: { status, redirects: 0, final_url: url } })).toEqual([ @@ -699,7 +699,7 @@ describe("parseRecordInput: the value of each kind of observation", () => { "https://:secret@example.com/", ]) { expect(finalUrlOf(finalUrl), finalUrl).toEqual([ - `observed.bare.value.final_url: must be an absolute http or https URL with no user, query or fragment, received ${JSON.stringify(finalUrl)}`, + `observed.bare.value.final_url: must be an absolute http or https URL with no user, query, fragment or hidden character, received ${JSON.stringify(finalUrl)}`, ]); } for (const finalUrl of [ @@ -709,6 +709,15 @@ describe("parseRecordInput: the value of each kind of observation", () => { ]) { expect(finalUrlOf(finalUrl), finalUrl).toEqual([]); } + // A character a terminal hides, which no URL the guide's command prints holds. + for (const code of [0x200b, 0x202e, 0x2028, 0xfeff]) { + const finalUrl = `https://example.com/a${String.fromCodePoint(code)}b`; + const errors = finalUrlOf(finalUrl); + expect(errors, code.toString(16)).toHaveLength(1); + expect(errors[0]).toContain( + "observed.bare.value.final_url: must be an absolute http or https URL with no user, query, fragment or hidden character, received", + ); + } // A null with its error is taken as before, and so is any answer of a manual one. expect(errorsOf({ bare: null, users: "https://user@example.com/?q#f" })).toEqual([]); }); diff --git a/packages/core/src/board/scope.ts b/packages/core/src/board/scope.ts index 7102df2..8f0fc50 100644 --- a/packages/core/src/board/scope.ts +++ b/packages/core/src/board/scope.ts @@ -1,6 +1,6 @@ import { spawn } from "node:child_process"; -import { lstat, readFile, realpath, stat } from "node:fs/promises"; -import { posix, sep } from "node:path"; +import { lstat, readFile, readlink, realpath, stat } from "node:fs/promises"; +import { isAbsolute, posix, sep } from "node:path"; import { findErrorCode } from "../storage/status.js"; import { BOARD_DEFAULT_AT } from "./declaration.js"; import { fromBytes } from "./glob.js"; @@ -316,6 +316,10 @@ async function worktreeEntry(root: Buffer, path: string): Promise { + let at = path; + for (let hops = 0; hops <= 40; hops++) { + let text: Buffer; + try { + text = await readlink(joinBytes(root, at), { encoding: "buffer" }); + } catch { + return false; + } + if (isAbsolute(text.toString("latin1"))) return !within(root, text); + const joined = posix.normalize(posix.join(posix.dirname(at), text.toString("latin1"))); + if (joined === ".." || joined.startsWith("../")) return true; + try { + if (!(await lstat(joinBytes(root, joined))).isSymbolicLink()) return false; + } catch { + return false; + } + at = joined; + } + return false; +} + type TreeEntry = { mode: string; type: string; oid: string }; /** Whether git at this version honors GIT_NO_LAZY_FETCH (2.44 and later). */ diff --git a/packages/core/src/lib/view-strings.test.ts b/packages/core/src/lib/view-strings.test.ts index 0d15b11..e24250a 100644 --- a/packages/core/src/lib/view-strings.test.ts +++ b/packages/core/src/lib/view-strings.test.ts @@ -6,6 +6,7 @@ import type { Manifest } from "../schemas/manifest.schema.js"; import { type BasouPaths, ensureBasouDirectory } from "../storage/basou-dir.js"; import { createManifest, writeManifest } from "../storage/manifest.js"; import { + boardPageStrings, isPositionBoardLine, presetStrings, resolveAnchorContentLanguage, @@ -212,6 +213,17 @@ describe("resolveAnchorContentLanguage", () => { }); }); +describe("boardPageStrings", () => { + it("says under Claude's and Codex's time that each counts an overlap once, in both languages", () => { + expect(boardPageStrings("en").effort.toolUnion).toBe( + "overlaps counted once, not a sum of sessions", + ); + expect(boardPageStrings("ja").effort.toolUnion).toBe( + "重なりは 1 回だけ数えた時間(session ごとの合計ではない)", + ); + }); +}); + describe("presetStrings", () => { // Same rationale as the viewStrings sweep: exercise every parameterized entry // in BOTH languages so a table entry that throws or renders empty cannot ship, diff --git a/packages/core/src/lib/view-strings.ts b/packages/core/src/lib/view-strings.ts index 728891c..44e7e9b 100644 --- a/packages/core/src/lib/view-strings.ts +++ b/packages/core/src/lib/view-strings.ts @@ -1399,7 +1399,7 @@ const BOARD_PAGE_JA: BoardPageStrings = { daysWorked: "{n} 日に分布", claude: "Claude", codex: "Codex", - toolUnion: "重なりを除いた時間(session ごとの合計ではない)", + toolUnion: "重なりは 1 回だけ数えた時間(session ごとの合計ではない)", noCodex: "Codex の session なし", perDay: "1 日あたり", perDayOver: "今日を含む {days} 日で割った値", From 4fc86d1395a4f6b045a45925616abfe1afa47dea Mon Sep 17 00:00:00 2001 From: Takashi Matsuyama Date: Tue, 20 Oct 2026 19:11:32 +0900 Subject: [PATCH 4/5] basou: Draw the reach matrix right after a record's summary tiles, fold the components counted in no lane, and fit the matrix to a narrow screen The board page of a record drew the observations inside the summary and the matrix after them and after the charts of the effort. The summary now keeps its prose and its tiles, the matrix and the lanes follow it, and the observations have a section of their own after the lanes, which carries their reported mark. The board measured on the spot keeps its order. The components counted in no lane are folded under one line that says how many they are, closed until it is opened; with none, the page says so as before. At 600px wide or less each stage's head shows its id alone, the cells take less room and a long lane name wraps. Each stage's head carries its meaning as its title at every width. --- CHANGELOG.md | 21 ++++ packages/cli/src/commands/view.test.ts | 167 ++++++++++++++++++++++--- packages/cli/src/lib/board-ui.ts | 48 +++++-- packages/core/src/lib/view-strings.ts | 4 + 4 files changed, 210 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1905d36..280638a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,6 +71,20 @@ All notable changes to **basou** are recorded here. The project follows keeps its milestones, which a start among them did not fit. A moved board keeps the milestones it had that happened, and leaves out those that were only planned. +- **A record's board page draws the reach matrix right after the summary's + tiles**, then where each lane is, then the observations outside in a + section of their own, then the period and effort and the rest as before. + The summary held the observations, and the matrix came after them and + after the charts of the effort, about 4,500px down a page 800px wide. The + summary keeps its prose and its tiles, and the observations their rows, + their notes and their `reported` mark, now on their section's heading. The + board measured on the spot, which draws no matrix, keeps its order. +- **A record's board page folds the components counted in no lane** under + one line that says how many they are (`Counted in no lane (123)`), closed + until it is opened. A board that registers most of its components with + `lane: "-"` had them fill about a third of the page. When there is none, + the page says so as before; the components of each lane, those found and + not registered, and those gone are drawn open as before. ### Fixed @@ -89,6 +103,13 @@ All notable changes to **basou** are recorded here. The project follows as the command quotes it, and a plain name (`owner/repo`, `quality.yml`, `main`) is written as before, so the sources of existing boards change only where a name needed quoting. +- **The board page's reach matrix fits a narrow screen.** On a screen 600px + wide or less, each stage's head shows its id alone, the cells take less + room, and a long lane name wraps, so that the six stages and a lane name of + 16 characters fit a phone 375px wide. The matrix was 479px wide in the + 343px left for it there, with stages 05 and 06 out of sight to the right. + Each stage's head now carries its meaning as its title at every width, to + be read where it is hidden. A wider screen draws the matrix as before. ## 0.72.0 — 2026-10-20 diff --git a/packages/cli/src/commands/view.test.ts b/packages/cli/src/commands/view.test.ts index d5ce45c..2a1b11c 100644 --- a/packages/cli/src/commands/view.test.ts +++ b/packages/cli/src/commands/view.test.ts @@ -1420,6 +1420,7 @@ describe("basou view: the board page", () => { "setStatus", "heading", "summary", + "observations", "effort", "matrix", "lanes", @@ -1462,12 +1463,15 @@ describe("basou view: the board page", () => { const words = boardPageStrings("en").effort; call("draw", board); expect(document.title).toBe("Board"); - // Every section of a record, the repos and the components it measured among them. + // Every section of a record, the repos and the components it measured + // among them: the matrix and the lanes right after the summary, then the + // observations in a section of their own, then the effort. expect(byId.board?.find((n) => n.tag === "h2").map((n) => n.textContent)).toEqual([ "Summary", - "Period and effort", "Reach matrix", "Where each lane is", + "Observed outside", + "Period and effort", "Measures", "Composition", "The operator's turns", @@ -1539,10 +1543,52 @@ describe("basou view: the board page", () => { expect(none.map((o) => [o.attrs.value, o.disabled])).toEqual([["", true]]); }); + it("draws the summary's prose and tiles, and the observations apart from them, each marked as reported", () => { + const { call } = drawing(["summary", "observations"]); + const S = boardPageStrings("en"); + const made = { observed_at: "2026-10-07T00:00:00Z", source: "s" }; + const board = { + summary: { + text: "All `well`.", + tiles: [{ key: "live_lanes", value: 2, detail: 3, reported: true }], + previous_record: "unreadable", + observed: [ + { name: "npm", value: "1.2.3", notes: ["mind the `tag`"], ...made }, + { name: "site", value: null, error: "timeout", ...made }, + ], + }, + }; + const summary = call("summary", board); + expect(summary.children[0]?.textContent).toBe(S.sections.summary); + expect(summary.find((n) => n.className === "tile")).toHaveLength(1); + // The prose's mark and the tile's: no observation, no heading of them. + expect(summary.find((n) => n.className === "reported")).toHaveLength(2); + expect(summary.find((n) => n.tag === "table" || n.tag === "h3")).toEqual([]); + expect(summary.text()).not.toContain(S.observed.heading); + expect(summary.text()).not.toContain(S.observed.previousUnreadable); + const observations = call("observations", board); + const [head] = observations.children; + expect([head?.tag, head?.textContent]).toEqual(["h2", S.observed.heading]); + expect(head?.find((n) => n.className === "reported")).toHaveLength(1); + // Its rows and the notes under them, and the line on the previous record. + const rows = observations.find((n) => n.tag === "tr").slice(1); + expect(rows.map((r) => [r.className, r.children[0]?.text()])).toEqual([ + ["", "npm"], + ["notes", "mind the tag"], + ["", "site"], + ]); + expect(observations.text()).toContain(S.observed.previousUnreadable); + expect(observations.find((n) => n.className === "tile")).toEqual([]); + expect(observations.text()).not.toContain("All "); + const none = call("observations", { summary: { ...board.summary, observed: [] } }); + expect(none.text()).toContain(S.observed.none); + expect(none.text()).not.toContain(S.observed.previousUnreadable); + }); + it("draws an http observation's words beside what it found of each, in the order declared", () => { - const { call } = drawing(["summary"]); + const { call } = drawing(["observations"]); const made = { observed_at: "2026-10-07T00:00:00Z", source: "s" }; - const drawn = call("summary", { + const drawn = call("observations", { summary: { text: "", tiles: [], @@ -1596,11 +1642,11 @@ describe("basou view: the board page", () => { }); it("draws an http value in its shape as its parts, what each observation is and looks at, and its notes", () => { - const { call } = drawing(["summary"]); + const { call } = drawing(["observations"]); const DOT = ` ${String.fromCodePoint(0xb7)} `; const [YES, NO] = [String.fromCodePoint(0x2713), String.fromCodePoint(0x2717)]; const made = { observed_at: "2026-10-07T00:00:00Z", source: "https://example.com/x" }; - const drawn = call("summary", { + const drawn = call("observations", { summary: { text: "", tiles: [], @@ -1687,7 +1733,7 @@ describe("basou view: the board page", () => { }); it("marks an observation that moved since the previous record, with what that record held", () => { - const { call } = drawing(["summary"]); + const { call } = drawing(["observations"]); const DOT = ` ${String.fromCodePoint(0xb7)} `; const [YES, NO] = [String.fromCodePoint(0x2713), String.fromCodePoint(0x2717)]; const made = { observed_at: "2026-10-07T00:00:00Z", source: "s" }; @@ -1697,9 +1743,9 @@ describe("basou view: the board page", () => { final_url: "https://example.com/", body: { words: [{ word, found }] }, }); - const summary = (previous_record: string, observed: unknown[]) => - call("summary", { summary: { text: "", tiles: [], previous_record, observed } }); - const drawn = summary("found", [ + const observations = (previous_record: string, observed: unknown[]) => + call("observations", { summary: { text: "", tiles: [], previous_record, observed } }); + const drawn = observations("found", [ { name: "npm", value: "1.1.0", moved: { status: "value", value: "1.0.0" }, ...made }, // Its type alone moved: both as JSON would tell them apart. { name: "count", value: 12, moved: { status: "value", value: "12" }, ...made }, @@ -1759,10 +1805,10 @@ describe("basou view: the board page", () => { expect(drawn.find((n) => n.className === "chip changed")).toHaveLength(8); const unreadable = boardPageStrings("en").observed.previousUnreadable; expect(drawn.text()).not.toContain(unreadable); - expect(summary("unreadable", [{ name: "npm", value: "1", ...made }]).text()).toContain( + expect(observations("unreadable", [{ name: "npm", value: "1", ...made }]).text()).toContain( unreadable, ); - expect(summary("unreadable", []).text()).not.toContain(unreadable); + expect(observations("unreadable", []).text()).not.toContain(unreadable); }); it("draws every measure in a table, with its lanes and how much a number moved", () => { @@ -1895,9 +1941,19 @@ describe("basou view: the board page", () => { gone: ["app/db"], }, }); - const flow = drawn.children - .slice(1) - .map((n) => (n.tag === "ul" ? n.children.map((li) => li.text()) : n.text())); + const items = (ul: Node | undefined) => ul?.children.map((li) => li.text()); + // Those in no lane folded, closed, under how many they are. + const flow = drawn.children.slice(1).map((n) => + n.tag === "ul" + ? items(n) + : n.tag === "details" + ? { + open: n.attrs.open, + summary: [n.children[0]?.tag, n.children[0]?.text()], + items: items(n.children[1]), + } + : n.text(), + ); expect(flow).toEqual([ "Core", [ @@ -1907,13 +1963,31 @@ describe("basou view: the board page", () => { ], "Docs", S.record.noComponentInLane, - S.record.uncounted, - ["app/scripts markertooling"], + { + open: undefined, + summary: ["summary", "Counted in no lane (1)"], + items: ["app/scripts markertooling"], + }, S.record.unacknowledged, ["app/new ci"], S.record.gone, ["app/db"], ]); + // Only they are folded. + expect(drawn.find((n) => n.tag === "details")).toHaveLength(1); + const many = call("recordComponents", { + components: { + measured: true, + reasons: [], + lanes: [], + uncounted: Array.from({ length: 1234 }, (_, i) => ({ key: `c${i}`, kinds: ["marker"] })), + unacknowledged: [], + gone: [], + }, + }); + const [folded] = many.find((n) => n.tag === "details"); + expect(folded?.children[0]?.text()).toBe("Counted in no lane (1,234)"); + expect(folded?.find((n) => n.tag === "li")).toHaveLength(1234); const notMeasured = call("recordComponents", { components: { measured: false, @@ -1937,11 +2011,70 @@ describe("basou view: the board page", () => { }, }); expect(unknown.text()).toContain(S.record.goneUnknown); + // None in no lane: said under its heading, with nothing to fold. + expect(unknown.find((n) => n.tag === "details")).toEqual([]); + expect(unknown.children.slice(1, 3).map((n) => [n.tag, n.text()])).toEqual([ + ["h3", S.record.uncounted], + ["p", S.record.none], + ]); expect(call("recordComponents", { components: null }).text()).toContain( S.record.componentsUnread, ); }); + it("puts each stage's meaning in its head's title, and draws a stage by its id alone on a narrow screen", () => { + const { call } = drawing(["matrix"]); + const drawn = call("matrix", { + matrix: { + anomalies: [], + stages: [ + { id: "01", meaning: "Concept" }, + { id: "02", meaning: "Spec x" }, + ], + lanes: [{ id: "core", name: "Core", cells: [{ state: "done" }, { state: "none" }] }], + }, + }); + const stages = drawn.find((n) => n.tag === "th" && n.className === "stage"); + expect(stages.map((th) => th.attrs.title)).toEqual(["Concept", "Spec x"]); + // The id, and the meaning in an element of its own, which a narrow screen hides. + expect(stages.map((th) => th.children.map((c) => [c.tag, c.className, c.text()]))).toEqual([ + [ + ["b", "", "01"], + ["span", "meaning", "Concept"], + ], + [ + ["b", "", "02"], + ["span", "meaning", "Spec x"], + ], + ]); + // The page's style, without its comments, and the rules of a part of it. + const style = BOARD_HTML.slice( + BOARD_HTML.indexOf(""), + ).replace(/\/\*[\s\S]*?\*\//g, ""); + const rules = (css: string) => + Object.fromEntries( + [...css.matchAll(/([^{}]+)\{([^{}]*)\}/g)].map((m) => [m[1]?.trim(), m[2]?.trim()]), + ); + const opener = "@media (max-width: 600px) {"; + const start = style.indexOf(opener); + expect(start).toBeGreaterThan(-1); + const end = style.indexOf("\n }", start) + "\n }".length; + const narrow = rules(style.slice(start + opener.length, end - 1)); + expect(narrow["table.matrix th.stage .meaning"]).toBe("display: none;"); + expect(narrow["table.matrix th.stage"]).toContain("min-width: 0;"); + expect(narrow["table.matrix td.cell"]).toContain("padding: 4px 2px;"); + // A long lane name wraps, in a column of a width it cannot pass. + expect(narrow["table.matrix th:first-child"]).toMatch(/max-width: \d+em;/); + expect(narrow["table.matrix th:first-child"]).toContain("overflow-wrap: anywhere;"); + // Wider screens draw the matrix as before, its meanings shown. + const wide = rules(style.slice(0, start) + style.slice(end)); + expect(wide["table.matrix th.stage"]).toContain("min-width: 64px;"); + expect(wide["table.matrix td.cell"]).toContain("font-size: 18px;"); + expect(Object.keys(wide).filter((selector) => selector.includes("meaning"))).toEqual([]); + expect(wide[".scroll"]).toContain("overflow-x: auto;"); + }); + 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", { diff --git a/packages/cli/src/lib/board-ui.ts b/packages/cli/src/lib/board-ui.ts index c8b7372..ff32632 100644 --- a/packages/cli/src/lib/board-ui.ts +++ b/packages/cli/src/lib/board-ui.ts @@ -69,6 +69,14 @@ export const BOARD_HTML = ` table.matrix th.stage { font-weight: 400; font-size: 12px; text-align: center; min-width: 64px; } table.matrix th.stage b { display: block; font-size: 13px; } table.matrix td.cell { text-align: center; font-size: 18px; line-height: 1; } + /* Six stages do not fit beside their meanings below about 600px: each is + drawn by its id (the meaning is its head's title), and a lane name wraps. */ + @media (max-width: 600px) { + table.matrix th.stage { min-width: 0; padding: 4px 2px; } + table.matrix th.stage .meaning { display: none; } + table.matrix td.cell { font-size: 16px; padding: 4px 2px; } + table.matrix th:first-child { max-width: 10em; overflow-wrap: anywhere; } + } .st::before { display: inline-block; width: 1.2em; } .st.done::before { content: "\\25CF"; color: var(--done); } .st.part::before { content: "\\25D0"; color: var(--done); } @@ -357,6 +365,13 @@ export const BOARD_HTML = ` k, el('div', { class: 'v', text: num(t.value) }), sub === null ? null : el('div', { class: 's', text: sub }) ])); }); + return section(S.sections.summary, false, [el('div', null, [prose(s.text), reported()]), tiles]); + } + + // The observations outside, which the record holds in its summary, in a + // section of their own after the lanes. + function observations(b) { + var s = b.summary; var observed; if (s.observed.length === 0) { observed = el('p', { class: 'muted', text: S.observed.none }); @@ -399,17 +414,11 @@ export const BOARD_HTML = ` ]) ].concat(rows))]); } - var observedHeading = el('h3', { text: S.observed.heading }, [reported()]); - var children = [ - el('div', null, [prose(s.text), reported()]), - tiles, - observedHeading, - observed - ]; + var children = [observed]; if (s.previous_record === 'unreadable' && s.observed.length > 0) { children.push(el('p', { class: 'muted', text: S.observed.previousUnreadable })); } - return section(S.sections.summary, false, children); + return section(S.observed.heading, true, children); } var SVGNS = 'http://www.w3.org/2000/svg'; @@ -696,8 +705,11 @@ export const BOARD_HTML = ` }) ))); } + // The meaning also as the title, read where a narrow screen hides it. var head = el('tr', null, [el('th', { text: S.matrix.lane })].concat(x.stages.map(function (s) { - return el('th', { class: 'stage' }, [el('b', { text: s.id }), s.meaning]); + return el('th', { class: 'stage', title: s.meaning }, [ + el('b', { text: s.id }), el('span', { class: 'meaning', text: s.meaning }) + ]); }))); var rows = x.lanes.map(function (lane) { return el('tr', null, [el('th', { text: lane.name })].concat(lane.cells.map(function (c) { @@ -864,8 +876,10 @@ export const BOARD_HTML = ` var b = page.board; // The board's own title, so that boards open in other tabs can be told apart. document.title = b.heading.title; - [heading(b), summary(b), effort(b, false), matrix(b), lanes(b), measures(b), composition(b), turns(b), - recordRepos(b), recordComponents(b), footnotes(b)] + // Where each lane stops, right under the summary's tiles, and the lanes, + // before the observations and the charts of the effort. + [heading(b), summary(b), matrix(b), lanes(b), observations(b), effort(b, false), measures(b), composition(b), + turns(b), recordRepos(b), recordComponents(b), footnotes(b)] .forEach(function (node) { root.appendChild(node); }); } @@ -936,7 +950,7 @@ export const BOARD_HTML = ` return section(S.live.repos, false, children); } // The components the record found, by the lanes its registry counts them - // in, then those in no lane, those not registered, and those gone. + // in, then those in no lane (folded), those not registered, and those gone. function recordComponents(b) { var c = b.components; if (!c) return section(S.live.components, false, [el('p', { class: 'muted', text: S.record.componentsUnread })]); @@ -962,7 +976,15 @@ export const BOARD_HTML = ` c.lanes.forEach(function (lane) { children = children.concat(group(lane.name, lane.components, S.record.noComponentInLane)); }); - children = children.concat(group(S.record.uncounted, c.uncounted, S.record.none)); + // Those in no lane can be most of them: folded under how many they are. + if (c.uncounted.length > 0) { + children.push(el('details', null, [ + el('summary', { text: fill(S.record.uncountedFolded, { n: num(c.uncounted.length) }) }), + el('ul', null, c.uncounted.map(item)) + ])); + } else { + children = children.concat(group(S.record.uncounted, c.uncounted, S.record.none)); + } children = children.concat(group(S.record.unacknowledged, c.unacknowledged, S.record.none)); children.push(el('h3', { text: S.record.gone })); children.push(c.gone === null ? el('p', { class: 'muted', text: S.record.goneUnknown }) diff --git a/packages/core/src/lib/view-strings.ts b/packages/core/src/lib/view-strings.ts index 44e7e9b..6e6af60 100644 --- a/packages/core/src/lib/view-strings.ts +++ b/packages/core/src/lib/view-strings.ts @@ -962,6 +962,8 @@ export type BoardPageStrings = { componentsNotMeasured: string; noComponentInLane: string; uncounted: string; + /** The line the components in no lane are folded under, with `{n}` of them. */ + uncountedFolded: string; unacknowledged: string; gone: string; goneUnknown: string; @@ -1172,6 +1174,7 @@ const BOARD_PAGE_EN: BoardPageStrings = { componentsNotMeasured: "The components were not measured:", noComponentInLane: "No component is registered in this lane.", uncounted: "Counted in no lane", + uncountedFolded: "Counted in no lane ({n})", unacknowledged: "Found, not registered", gone: "Registered or found before, not found now", goneUnknown: "not known (the previous record could not be read)", @@ -1383,6 +1386,7 @@ const BOARD_PAGE_JA: BoardPageStrings = { componentsNotMeasured: "構成要素は測れなかった:", noComponentInLane: "このレーンに登録した構成要素はない。", uncounted: "どのレーンにも数えない", + uncountedFolded: "どのレーンにも数えない({n})", unacknowledged: "見つかったが登録していない", gone: "登録していた・前回見つかったが、今回見つからない", goneUnknown: "分からない(前回の記録が読めない)", From 334e9332acef45249fff602050b91069727a1c00 Mon Sep 17 00:00:00 2001 From: Takashi Matsuyama Date: Tue, 20 Oct 2026 19:40:01 +0900 Subject: [PATCH 5/5] basou: List the stage meanings under the matrix on a narrow screen, read each once, and have the guide list the board page in its new order - On a screen 600px wide or less, the stages' meanings are listed under the matrix, whose heads show only the ids there; a wider screen hides the list. The visible meaning beside an id is left out of what a screen reader reads, which reads it once, as the head's title. - The guide's list of what the board page draws, and what the agent writes for it, follows the page: summary, reach matrix, each lane, observed outside, period and effort, ratios, the operator's turns, footnotes. It said the summary drew the table of observations, and put the period and effort before the matrix. - The tests pin the narrow screen's rules whole and that they come after the rules of the same weight they override, which a block moved before them would lose to. - The CHANGELOG says the title is read by a screen reader and shown by a pointer that hovers, not on a phone, which has the list. --- CHANGELOG.md | 17 +++++----- packages/cli/src/commands/view.test.ts | 43 ++++++++++++++++++++------ packages/cli/src/lib/board-ui.ts | 16 ++++++++-- packages/core/src/board/guide.test.ts | 30 ++++++++++++++++++ packages/core/src/board/guide.ts | 15 ++++----- 5 files changed, 95 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 280638a..de64b43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,7 +78,9 @@ All notable changes to **basou** are recorded here. The project follows after the charts of the effort, about 4,500px down a page 800px wide. The summary keeps its prose and its tiles, and the observations their rows, their notes and their `reported` mark, now on their section's heading. The - board measured on the spot, which draws no matrix, keeps its order. + board measured on the spot, which draws no matrix, keeps its order. The + list in `basou board guide` of what the page draws, and what the agent + writes for it, follows the new order. - **A record's board page folds the components counted in no lane** under one line that says how many they are (`Counted in no lane (123)`), closed until it is opened. A board that registers most of its components with @@ -104,12 +106,13 @@ All notable changes to **basou** are recorded here. The project follows `main`) is written as before, so the sources of existing boards change only where a name needed quoting. - **The board page's reach matrix fits a narrow screen.** On a screen 600px - wide or less, each stage's head shows its id alone, the cells take less - room, and a long lane name wraps, so that the six stages and a lane name of - 16 characters fit a phone 375px wide. The matrix was 479px wide in the - 343px left for it there, with stages 05 and 06 out of sight to the right. - Each stage's head now carries its meaning as its title at every width, to - be read where it is hidden. A wider screen draws the matrix as before. + wide or less, each stage's head shows its id alone, the stages' meanings + are listed under the matrix, the cells take less room, and a long lane name + wraps, so that the six stages and a lane name of 16 characters fit a phone + 375px wide. The matrix was 479px wide in the 343px left for it there, with + stages 05 and 06 out of sight to the right. Each stage's head also carries + its meaning as its title at every width, which a screen reader reads once + and a pointer that hovers shows. A wider screen draws the matrix as before. ## 0.72.0 — 2026-10-20 diff --git a/packages/cli/src/commands/view.test.ts b/packages/cli/src/commands/view.test.ts index 2a1b11c..7900322 100644 --- a/packages/cli/src/commands/view.test.ts +++ b/packages/cli/src/commands/view.test.ts @@ -2022,7 +2022,7 @@ describe("basou view: the board page", () => { ); }); - it("puts each stage's meaning in its head's title, and draws a stage by its id alone on a narrow screen", () => { + it("puts each stage's meaning in its head's title, and draws a stage by its id alone on a narrow screen, with the meanings listed under the matrix", () => { const { call } = drawing(["matrix"]); const drawn = call("matrix", { matrix: { @@ -2047,6 +2047,11 @@ describe("basou view: the board page", () => { ["span", "meaning", "Spec x"], ], ]); + // A screen reader reads the meaning once, as the title, not again as the text. + expect(stages.map((th) => th.children[1]?.attrs["aria-hidden"])).toEqual(["true", "true"]); + // The meanings listed under the matrix, as text. + const [listed] = drawn.find((n) => n.tag === "ul" && n.className === "stage-meanings"); + expect(listed?.children.map((li) => li.text())).toEqual(["01 Concept", "02 Spec x"]); // The page's style, without its comments, and the rules of a part of it. const style = BOARD_HTML.slice( BOARD_HTML.indexOf("