diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d5da84..aa1d553 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,50 @@ 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 + +### Changed + +- **`basou board record` (and `--dry-run`) refuses an observation's value + that is not in the shape its kind gives it.** A value was kept as any JSON + value, so a number given as a string (`"200"`) or a version copied with its + line break was recorded, and read as a change from the previous record's + value. Now, on a board that declares observations, a string of + `npm_version`, `github_release`, `github_ci`, `github_check` or + `page_version` must be non-empty, with no white space at either end and no + control character or one a terminal hides (a zero-width or direction mark, + a line separator, a byte order mark); a count of `github_open_issues` or `github_open_prs` a + whole number from 0 to 9007199254740991; and an `http` value an object of + `status` (a whole number from 100 to 599: a response's, never curl's + `000`), `redirects` (0 to 10), `final_url` (an absolute `http://` or + `https://` URL) and exactly the reads the observation declares (`words` as + many true or false as its words, `count` a whole number, `capture` a string + or null), each null when the status is not 2xx. What a string says is not + held to a grammar, and a `capture` is kept as the group holds it. A null + value with its error, a `manual` value, and the values of a board that + declares no observation are not checked, and records already written are + not checked again. A refusal names where in the value, and one of what a + key holds shows up to 60 characters of the JSON it received: + `observed["site.top"].value.status: must be a whole number from 100 to 599, the status of a response, received "200"`. +- **`basou board guide` makes an `http` observation in one block that prints + its whole entry for the record's input**, `{"value": ..., "observed_at": + ..., "source": ...}` on one line, in place of curl's status line to copy + into a value by hand. The block keeps curl's status line and exit code in + the working directory beside the body, and a script the guide has written + there once reads them: when curl exits other than 0 (no connection, more + than 10 redirects), the status is not from 100 to 599 (a server answering + 600), or what it left cannot be read, the entry is a null with the error, + such as `curl exit 47: more than 10 redirects`. The line escapes what a + terminal would act on or hide, as JSON escapes a line break. An empty match + of a `count_pattern` gives a null with its error too, where the command + exited 1. Every `http` observation is made this way, also one that reads + nothing off its body. +- **`basou board guide` says what each kind of observation gives once**, before + the observations, and each observation gives only what it looks at, its + command and its source. A board of 39 `http` observations, each with two + words and a `count_pattern`, had a guide of 143,708 bytes in one workspace; + it is now 70,849 (the bytes vary with the length of the workspace's path). + ## 0.71.2 — 2026-10-19 ### Fixed diff --git a/docs/spec/board.md b/docs/spec/board.md index 88d2a9c..5ad7afc 100644 --- a/docs/spec/board.md +++ b/docs/spec/board.md @@ -10,8 +10,9 @@ > guaranteed. > > This page describes the shapes of basou 0.71.0: `board_version` 3 and -> `record_version` 3. When a later release changes a shape, it raises the -> version and this page with it. +> `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 +> shape, it raises the version and this page with it. This page says what each key of these shapes holds, which keys are required, what a `null` means, and what the digest of a measurement covers. It does not @@ -484,10 +485,13 @@ declared, `basou board guide` prints the command that makes it (or, for makes it and gives it under its key in the record's input ([3.2](#32-observed)). -`basou board record` does not check a value against its kind: it keeps any -JSON value. The value of each kind below is what the guide asks for and the -board page draws, and what the previous record's value is compared with. -Keep the keys: a key joins a value to the previous record's. +From basou 0.72.0, `basou board record` holds each value that is not null +against the shape its kind gives it below, and refuses one in another shape +(before, it kept any JSON value). The value of each kind is what the guide +asks for and the board page draws, and what the previous record's value is +compared with: a number given as a string, or a version copied with its line +break, would read as a change. Keep the keys: a key joins a value to the +previous record's. Every observation has: @@ -514,6 +518,17 @@ never part of a command. | `http` | 3 | `url`, `words`, `count_pattern`, `capture_pattern` | an object ([below](#http)) | | `manual` | 2 | `how` | what the operator answers, as any JSON value | +- **What record refuses**, from basou 0.72.0: a string of a kind above that + is empty, has white space at either end, or holds a control character + (U+0000 to U+001F, U+007F to U+009F) or one a terminal hides (U+061C, + 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)). + The keys: | Key | Of | Required | Default | Holds | @@ -569,12 +584,16 @@ The value recorded: | Key | Present | Holds | |---|---|---| -| `status` | always | a number: the status of the last response | -| `redirects` | always | a number: how many redirects led to it | -| `final_url` | always | a string: the URL it ended at | +| `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 | | `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 number: 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; null when nothing matches or the group takes no part | +| `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. - **A 4xx or a 5xx is a value**, not an observation not made: the status is what was observed. @@ -586,10 +605,15 @@ The value recorded: and HTML entities are not decoded. - The observation is not made (a null value with an error) when the request fails (the name does not resolve, TLS fails, 20 seconds pass, or it takes - more than 10 redirects), or when the guide's command that reads the body - fails. That command stops, 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. + 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. 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 @@ -724,8 +748,9 @@ In this order, and nothing is written when any step refuses: to check against; one that cannot be read is said to be so on stderr, and the input goes on unchecked.) 4. **Its shape, and the declaration.** An unknown key is refused at every - level but the keys of `observed` and of `prose.lanes`, and every problem - found is listed at once. + level but the keys of `observed` and of `prose.lanes`, each observation's + value is held against its kind's shape ([2.7](#27-observe), from basou + 0.72.0), and every problem found is listed at once. 5. **The digest.** The board is measured again, as the model of `judged_by.model`, and the record is refused unless the digest is `measure_digest`: something measured, or `board.yaml`, changed since the @@ -742,7 +767,7 @@ A mapping from an observation's key to what was found: | Key | Required | Holds | |---|---|---| -| `value` | yes | any JSON value, or `null` when the observation was not made | +| `value` | yes | a JSON value in the shape of its kind ([2.7](#27-observe)), or `null` when the observation was not made | | `observed_at` | yes | when it was made, or tried: a date, or a date and a time of day with its offset (`2026-10-19T09:00Z`, `2026-10-19T18:00:00.5+09:00`); a time with no offset is refused | | `source` | yes | a non-empty string: what was asked (the command, the URL, `the operator`) | | `error` | with a `null` value | a non-empty string: why it was not made | @@ -753,9 +778,16 @@ A mapping from an observation's key to what was found: so a closed exit is never dropped from the board unsaid. With none declared, `observed` may be empty. - The value is kept as `JSON.parse` read it, and basou does not interpret it. - It is refused only 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__`. Its shape by kind is in [`observe`](#27-observe). + 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 + (`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 + JSON value, or nests too deep, is refused for that alone. ### 3.3 `cells` diff --git a/packages/core/src/board/guide.test.ts b/packages/core/src/board/guide.test.ts index a76dbea..07c0644 100644 --- a/packages/core/src/board/guide.test.ts +++ b/packages/core/src/board/guide.test.ts @@ -16,7 +16,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { stringify } from "yaml"; -import { type BoardDeclaration, parseBoardDeclaration } from "./declaration.js"; +import { type BoardDeclaration, type BoardObserve, parseBoardDeclaration } from "./declaration.js"; import { BOARD_GUIDE_PORTS, type BoardGuideInput, @@ -26,6 +26,7 @@ import { shellWord, } from "./guide.js"; import { BOARD_INIT_SAVE } from "./init.js"; +import { observedValueProblems } from "./observed-shape.js"; import { type BoardPreviousRecords, NO_PREVIOUS_RECORDS, @@ -903,95 +904,121 @@ describe("boardGuide: an http observation", () => { capture_pattern: "Version ([0-9.]+)", }; - it("gives a GET without -f, then the body read with the patterns and words after --, quoted", () => { - const guide = boardGuide(guideInput({ board: declared(httpDeclaration([full])) })); + it("writes the script once, then gives each observation one block: the GET, its status line and exit code kept in W, and the script with what it reads after --, quoted", () => { + const guide = boardGuide( + guideInput({ + board: declared(httpDeclaration([full, { key: "bare", kind: "http", url: SHOP }])), + }), + ); const blocks = blocksIn(guide); - const get = blocks.find((b) => b.includes("curl ")) as string; + const writes = blocks.filter((b) => b.includes("printf '%s\\n' '")); + expect(writes).toHaveLength(1); + const write = writes[0] as string; + const script = write.slice(write.indexOf("printf '%s\\n' '") + 15, write.indexOf("' > \"$W/")); + // One single-quoted word: nothing in it ends the quotation or escapes. + expect(script).not.toMatch(/['\\]/); + const file = /> "\$W\/(http-read-[0-9a-f]{12}\.cjs)"; echo "exit=\$\?"$/.exec(write)?.[1]; + expect(file).toBeDefined(); + expect(blocks.indexOf(write)).toBeLessThan(blocks.findIndex((b) => b.includes("curl -sS"))); + const get = blocks.find((b) => b.includes('"$W/http-shop.body"')) as string; + expect(get).toMatch(/^cd .* && \{ rm -f "\$W\/http-shop\.body"; curl /); expect(get).toContain( - `curl -sS -L --max-redirs 10 --max-time 20 -o "$W/http-shop.body" -w '%{http_code} %{num_redirects} %{url_effective}\\n' '${SHOP}'; echo "curl exit=$?"`, + `curl -sS -L --max-redirs 10 --max-time 20 -o "$W/http-shop.body" -w '%{http_code} %{num_redirects} %{url_effective}\\n' '${SHOP}' > "$W/http-shop.meta"; echo "$?" > "$W/http-shop.exit"; node "$W/${file}" -- "$W/http-shop" '${SHOP}' '' 'Version ([0-9.]+)' 'in stock' '${IN_STOCK}' 'AT&T'; }; echo "exit=$?"`, ); - expect(get).not.toMatch(/ -f|-fsS/); - const read = blocks.find((b) => b.includes("node -e")) as string; - expect(read).toMatch(/^cd .* && node -e '[^']+' -- /); - expect(read).toContain( - ` -- '' 'Version ([0-9.]+)' 'in stock' '${IN_STOCK}' 'AT&T' < "$W/http-shop.body"; echo "exit=$?"`, + // No -f: a 4xx or 5xx is a status, not a failure. + expect(get.slice(get.indexOf("curl "), get.indexOf(' > "$W/http-shop.meta"'))).not.toMatch( + / -f|-fsS/, ); - expect(read).toContain('readFileSync(0, "utf8")'); + const bare = blocks.find((b) => b.includes('"$W/http-bare.body"')) as string; + expect(bare).toContain(` -- "$W/http-bare" '${SHOP}' '' ''; }; echo "exit=$?"`); expect(guide).toContain("#### `shop` (http)"); - expect(guide).toContain("When the status is 2xx, read the body."); - expect(guide).toContain("not line by line as a measure's `pattern` is"); expect(guide).toContain( - `- \`words\`: whether the body holds each word, as true or false in the order the command gives them (\`in stock\`, \`${IN_STOCK}\`, \`AT&T\`)`, + `A GET of \`${SHOP}\`, reading off a 2xx body \`words\` \`in stock\`, \`${IN_STOCK}\`, \`AT&T\`, \`count\` of \`\`, \`capture\` of \`Version ([0-9.]+)\`:`, ); + expect(guide).toContain(`A GET of \`${SHOP}\`:`); + expect(guide).toContain(`Put the line it prints under \`shop\` as it is. Source: \`${SHOP}\`.`); + // What the kind gives is said once, for both. + 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( - "- `count`: how many times `` matches the body (0 when it does not)", + "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( - "- `capture`: what the first group of the first match of `Version ([0-9.]+)` holds, as a string (null when nothing matches, or when the first group takes no part in the match)", + "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", ); expect(guide).toContain( - 'When the status is not 2xx, do not read the body: give `"words": null, "count": null, "capture": null`, each a null and not a list of nulls', + "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)", ); - expect(guide).toContain("A 4xx or 5xx is a value, not an observation not made"); - expect(guide).toContain("more than 10 redirects: exit 47), or reading the body exits 1,"); - expect(guide).toContain(`Source: \`${SHOP}\`.`); const observed = templateIn(guide).observed as Record; expect(observed.shop?.source).toBe(SHOP); }); - it("gives '' for a pattern not declared, and no body read when nothing is read off the body", () => { + it("writes a word or a pattern as Markdown code that shows it as it is, backquotes and spaces too", () => { const guide = boardGuide( guideInput({ board: declared( httpDeclaration([ - { key: "words_only", kind: "http", url: SHOP, words: ["x"] }, - { key: "bare", kind: "http", url: SHOP }, + { + key: "odd", + kind: "http", + url: SHOP, + words: ["a`b", " in stock ", "``x"], + count_pattern: "|`", + }, ]), ), }), ); - const blocks = blocksIn(guide); - expect(blocks.find((b) => b.includes('"$W/http-words_only.body"; echo'))).toContain( - ` -- '' '' 'x' < "$W/http-words_only.body"`, - ); - expect(blocks.some((b) => b.includes("http-bare.body") && b.includes("node -e"))).toBe(false); - const bare = guide.slice(guide.indexOf("#### `bare` (http)")); - expect(bare).toContain( - 'Value: `{ "status": , "redirects": , "final_url": }`', + expect(guide).toContain( + "reading off a 2xx body `words` ``a`b``, ` in stock `, ``` ``x ```, `count` of `` |` ``:", ); - expect(bare.slice(0, bare.indexOf("### 4."))).not.toContain("2xx"); - expect(guide).toContain('give `"words": null`, each a null and not a list of nulls'); }); - it("writes a word or a pattern as Markdown code that shows it as it is, backquotes and spaces too", () => { + it("says what each kind gives once, whatever the number of its observations", () => { const guide = boardGuide( guideInput({ board: declared( httpDeclaration([ - { - key: "odd", - kind: "http", - url: SHOP, - words: ["a`b", " in stock ", "``x"], - count_pattern: "|`", - }, + { key: "a", kind: "github_check", repo: "o/n", check: "a" }, + { key: "b", kind: "github_check", repo: "o/n", check: "b" }, + { key: "c", kind: "manual", how: "ask" }, ]), ), }), ); - expect(guide).toContain( - "in the order the command gives them (``a`b``, ` in stock `, ``` ``x ```)", - ); - expect(guide).toContain("- `count`: how many times `` |` `` matches the body"); + expect(guide.split("- `github_check`: ")).toHaveLength(2); + expect(guide.split("- `manual`: ")).toHaveLength(2); + expect(guide).not.toContain("- `http`: "); + expect(guide).not.toContain("printf '%s\\n'"); }); }); describe("boardGuide: an http observation, run", () => { let dir: string; + let origin: string; + let server: ReturnType; + const bodies = new Map(); beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), "basou-board-http-")); + bodies.clear(); + server = createServer((req, res) => { + const url = req.url ?? ""; + if (url === "/moved") res.writeHead(301, { location: "/body/item" }).end(); + else if (url.startsWith("/loop")) { + res.writeHead(302, { location: `/loop${(url.length % 50) + 1}` }).end(); + } else if (url === "/empty") res.writeHead(204).end(); + else if (url.startsWith("/body/") && bodies.has(url.slice(6))) { + res.writeHead(200, { "content-type": "text/html" }).end(bodies.get(url.slice(6))); + } else if (url.startsWith("/status/")) res.writeHead(Number(url.slice(8))).end("x"); + // A body that a read of it, which a status not 2xx must not make, would find x in. + else res.writeHead(404).end("not here, x"); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; }); afterEach(async () => { + await new Promise((resolve) => server.close(resolve)); await rm(dir, { recursive: true, force: true }); }); @@ -1006,113 +1033,233 @@ describe("boardGuide: an http observation, run", () => { ); }); - const blockOf = (guide: string, key: string, has: string) => - blocksIn(guide).find((b) => b.includes(`"$W/http-${key}.body"`) && b.includes(has)) as string; + // The guide for these observations, its script written to W; then each + // observation's block, run with its URL pointed at `path` of the server, + // gives the entry it prints. + const observe = async (observations: Record[]) => { + const guide = boardGuide( + guideInput({ anchor: dir, board: declared(httpDeclaration(observations)) }), + ); + const blocks = blocksIn(guide); + const write = blocks.find((b) => b.includes("printf '%s\\n' '")) as string; + expect(await run(write)).toEqual({ out: "exit=0\n", err: "" }); + const declaration = httpDeclaration(observations); + return async (key: string, path: string) => { + const block = blocks.find((b) => b.includes(`"$W/http-${key}.body"`)) as string; + const { out } = await run(block.replaceAll(`'${SHOP}'`, `'${origin}${path}'`)); + const [entry, exit] = out.split("\n"); + expect(exit).toBe("exit=0"); + const parsed = JSON.parse(entry as string) as { + value: unknown; + observed_at: string; + source: string; + error?: string; + }; + expect(parsed.source).toBe(`${origin}${path}`); + expect(parsed.observed_at).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); + // What it prints is what record takes. + const o = declaration.observe.find((x) => x.key === key) as BoardObserve; + if (parsed.value !== null) expect(observedValueProblems(o, parsed.value)).toEqual([]); + return parsed; + }; + }; + + it("reads the words, the count and the capture off the whole 2xx body, decoded once and without its BOM", async () => { + const get = await observe([ + { + key: "shop", + kind: "http", + url: SHOP, + // Found as written: not ignoring case, and at the body's start too. + words: [IN_STOCK, "sold out", "AT&T", "", "VERSION 2.3"], + count_pattern: "^|", + capture_pattern: "Version ([0-9.]+)", + }, + { key: "opt", kind: "http", url: SHOP, capture_pattern: "x(y)?" }, + // Under the u flag . is one character, not one half of a surrogate pair. + { key: "flags", kind: "http", url: SHOP, count_pattern: ".", capture_pattern: "(.)" }, + // Only a BOM at the start is taken off. + { key: "bom", kind: "http", url: SHOP, count_pattern: "\\uFEFF" }, + ]); + // The word starts one byte before 64 KiB (after the 3 bytes of the BOM + // and the 8 of ), so a chunk of a stream would cut it. + bodies.set( + "item", + `\ufeff${"a".repeat(65535 - 3 - 8)}${IN_STOCK} AT&T 12 Version 2.3 and Version 9.9`, + ); + expect((await get("shop", "/moved")).value).toEqual({ + status: 200, + redirects: 1, + final_url: `${origin}/body/item`, + words: [true, false, false, true, false], + count: 3, + capture: "2.3", + }); + bodies.set("item", "nothing here"); + expect((await get("shop", "/body/item")).value).toEqual({ + status: 200, + redirects: 0, + final_url: `${origin}/body/item`, + words: [false, false, false, false, false], + count: 0, + capture: null, + }); + // A group that took no part in the match gives null, as no match does. + bodies.set("x", "x"); + expect((await get("opt", "/body/x")).value).toMatchObject({ capture: null }); + bodies.set("emoji", "\u{1F600}"); + expect((await get("flags", "/body/emoji")).value).toMatchObject({ + count: 1, + capture: "\u{1F600}", + }); + bodies.set("bom", "\ufeffa\ufeffb"); + expect((await get("bom", "/body/bom")).value).toMatchObject({ count: 1 }); + }); + + it("gives a status not 2xx as a value whose reads are null, and an empty body as empty", async () => { + const get = await observe([ + { + key: "shop", + kind: "http", + url: SHOP, + words: ["x"], + count_pattern: "x", + capture_pattern: "(x)", + }, + ]); + expect((await get("shop", "/gone")).value).toEqual({ + status: 404, + redirects: 0, + final_url: `${origin}/gone`, + words: null, + count: null, + capture: null, + }); + expect((await get("shop", "/empty")).value).toEqual({ + status: 204, + redirects: 0, + final_url: `${origin}/empty`, + words: [false], + count: 0, + capture: null, + }); + }); + + it("gives no value but the error when curl fails: past 10 redirects, or no connection", async () => { + const get = await observe([{ key: "shop", kind: "http", url: SHOP }]); + expect(await get("shop", "/loop")).toMatchObject({ + value: null, + error: "curl exit 47: more than 10 redirects", + }); + // A port nothing listens on: curl prints the status 000 and exits 7. + const closed = createServer(); + await new Promise((resolve) => closed.listen(0, "127.0.0.1", resolve)); + const port = (closed.address() as AddressInfo).port; + await new Promise((resolve) => closed.close(resolve)); + const guide = boardGuide( + guideInput({ + anchor: dir, + board: declared(httpDeclaration([{ key: "shop", kind: "http", url: SHOP }])), + }), + ); + const block = blocksIn(guide).find((b) => b.includes('"$W/http-shop.body"')) as string; + const { out } = await run(block.replaceAll(`'${SHOP}'`, `'http://127.0.0.1:${port}/x'`)); + expect(JSON.parse(out.split("\n")[0] as string)).toMatchObject({ + value: null, + error: "curl exit 7: no connection", + }); + }); + + it("reads a body at 299 and not at 300, and gives no value for a status no response has", async () => { + const get = await observe([ + { + key: "shop", + kind: "http", + url: SHOP, + words: ["x"], + count_pattern: "x", + capture_pattern: "(x)", + }, + ]); + expect((await get("shop", "/status/299")).value).toEqual({ + status: 299, + redirects: 0, + final_url: `${origin}/status/299`, + words: [true], + count: 1, + capture: "x", + }); + expect((await get("shop", "/status/300")).value).toEqual({ + status: 300, + redirects: 0, + final_url: `${origin}/status/300`, + words: null, + count: null, + capture: null, + }); + // curl prints what the server answers, and exits 0. + expect(await get("shop", "/status/600")).toMatchObject({ + value: null, + error: "the status 600 is not one of an HTTP response (100 to 599)", + }); + }); - it("reads the words, the count and the capture off the whole body, decoded once and without its BOM", async () => { + it("escapes in its line what a terminal would act on or hide, and keeps the value", async () => { + const get = await observe([ + { key: "shop", kind: "http", url: SHOP, capture_pattern: "Version ([^<]+)" }, + ]); + const odd = `1${String.fromCodePoint(0x2028)} 2${String.fromCodePoint(0x202e)}evil${String.fromCodePoint(0x85)}`; + bodies.set("odd", `Version ${odd}<`); const guide = boardGuide( guideInput({ anchor: dir, board: declared( httpDeclaration([ - { - key: "shop", - kind: "http", - url: SHOP, - // Found as written: not ignoring case, and at the body's start too. - words: [IN_STOCK, "sold out", "AT&T", "", "VERSION 2.3"], - count_pattern: "^|", - capture_pattern: "Version ([0-9.]+)", - }, - { key: "opt", kind: "http", url: SHOP, capture_pattern: "x(y)?" }, - // Under the u flag . is one character, not one half of a surrogate pair. - { key: "flags", kind: "http", url: SHOP, count_pattern: ".", capture_pattern: "(.)" }, - // Only a BOM at the start is taken off. - { key: "bom", kind: "http", url: SHOP, count_pattern: "\\uFEFF" }, + { key: "shop", kind: "http", url: SHOP, capture_pattern: "Version ([^<]+)" }, ]), ), }), ); - const work = boardGuideWorkDir(dir).replace(TMPDIR_WORD, dir); - await mkdir(work, { recursive: true }); - const read = blockOf(guide, "shop", "node -e"); - // The word starts one byte before 64 KiB (after the 3 bytes of the BOM - // and the 8 of ), so a chunk of a stream would cut it. - const body = `\ufeff${"a".repeat(65535 - 3 - 8)}${IN_STOCK} AT&T 12 Version 2.3 and Version 9.9`; - await writeFile(join(work, "http-shop.body"), body, "utf8"); - expect(await run(read)).toEqual({ - out: '{"words":[true,false,false,true,false],"count":3,"capture":"2.3"}\nexit=0\n', - err: "", - }); - await writeFile(join(work, "http-shop.body"), "nothing here", "utf8"); - expect((await run(read)).out).toBe( - '{"words":[false,false,false,false,false],"count":0,"capture":null}\nexit=0\n', - ); - // A group that took no part in the match gives null, as no match does. - await writeFile(join(work, "http-opt.body"), "x", "utf8"); - expect((await run(blockOf(guide, "opt", "node -e"))).out).toBe('{"capture":null}\nexit=0\n'); - await writeFile(join(work, "http-flags.body"), "\u{1F600}", "utf8"); - expect((await run(blockOf(guide, "flags", "node -e"))).out).toBe( - '{"count":1,"capture":"\u{1F600}"}\nexit=0\n', - ); - await writeFile(join(work, "http-bom.body"), "\ufeffa\ufeffb", "utf8"); - expect((await run(blockOf(guide, "bom", "node -e"))).out).toBe('{"count":1}\nexit=0\n'); + const block = blocksIn(guide).find((b) => b.includes('"$W/http-shop.body"')) as string; + const { out } = await run(block.replaceAll(`'${SHOP}'`, `'${origin}/body/odd'`)); + expect(out.split("\n")).toHaveLength(3); + for (const code of [0x2028, 0x202e, 0x85]) { + expect(out.includes(String.fromCodePoint(code)), code.toString(16)).toBe(false); + } + expect(out).toContain("\\u2028"); + expect((await get("shop", "/body/odd")).value).toMatchObject({ capture: odd }); }); - it("stops rather than count an empty match the declaration could not see", async () => { + it("gives no value when what curl left in W cannot be read", async () => { const guide = boardGuide( guideInput({ anchor: dir, - board: declared( - httpDeclaration([{ key: "edge", kind: "http", url: SHOP, count_pattern: "\\b" }]), - ), + 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); - await mkdir(work, { recursive: true }); - await writeFile(join(work, "http-edge.body"), "a b", "utf8"); - expect(await run(blockOf(guide, "edge", "node -e"))).toEqual({ - out: "exit=1\n", - err: "count_pattern matched an empty string at 0\n", + const out = execFileSync( + "node", + [join(work, file), "--", join(work, "http-none"), SHOP, "", ""], + { encoding: "utf8" }, + ); + expect(JSON.parse(out)).toMatchObject({ + value: null, + source: SHOP, + error: "what curl left in W could not be read", }); }); - it("prints the status, the redirects and where it ended, a 404 with exit 0, and exit 47 past 10 redirects", async () => { - const server = createServer((req, res) => { - if (req.url === "/moved") { - res.writeHead(301, { location: "/item" }).end(); - } else if (req.url === "/item") { - res.writeHead(200, { "content-type": "text/html" }).end(IN_STOCK); - } else if (req.url?.startsWith("/loop")) { - res.writeHead(302, { location: `/loop${(req.url.length % 50) + 1}` }).end(); - } else { - res.writeHead(404).end("not here"); - } + 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"); + expect(await get("edge", "/body/ab")).toMatchObject({ + value: null, + error: "the count_pattern matched an empty string at 0", }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - const origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; - try { - const guide = boardGuide( - guideInput({ - anchor: dir, - board: declared(httpDeclaration([{ key: "shop", kind: "http", url: SHOP }])), - }), - ); - const get = (path: string) => - run(blockOf(guide, "shop", "curl ").replace(`'${SHOP}'`, `'${origin}${path}'`)); - expect(await get("/moved")).toEqual({ - out: `200 1 ${origin}/item\ncurl exit=0\n`, - err: "", - }); - const work = boardGuideWorkDir(dir).replace(TMPDIR_WORD, dir); - expect(await readFile(join(work, "http-shop.body"), "utf8")).toBe(IN_STOCK); - expect((await get("/gone")).out).toBe(`404 0 ${origin}/gone\ncurl exit=0\n`); - expect(await readFile(join(work, "http-shop.body"), "utf8")).toBe("not here"); - const loop = await get("/loop"); - expect(loop.out).toMatch(/^302 10 .*\ncurl exit=47\n$/); - } finally { - await new Promise((resolve) => server.close(resolve)); - } }); }); @@ -1157,16 +1304,21 @@ describe("boardGuide: a github_check observation and an observation's notes", () expect(deploy).toContain( "How the latest check run named `Workers Builds: app` on the head commit of `main` of `owner/name` ended", ); - expect(deploy).toContain( + // What the kind gives is said once, before the observations. + const kind = guide.slice(guide.indexOf("- `github_check`: "), guide.indexOf("#### `deploy`")); + expect(kind).toContain( "Value: the first line it prints, as a string (`success`, `failure`, `cancelled`, `skipped`, or the status of a run not finished, such as `queued` or `in_progress`). A second line, `more than 100 runs: `, says the newest run may be on a page not read: keep the first line as the value, and say so in a footnote.", ); - expect(deploy).toContain( - "An empty line is no check run of that name on the head commit: a null, with the error saying that the head commit of `main` has no check run named `Workers Builds: app`.", + expect(kind).toContain( + "An empty line is no check run of that name on the head commit: a null, with the error saying so.", ); - expect(deploy).toContain( + expect(kind).toContain( "A head commit that never started the check (a path filter, `[skip ci]` in its commit message, or the app's own rules) gives this too, even when what the check looks after is fine; a job a workflow skipped with `if:` is a check run, concluded `skipped`.", ); - expect(deploy).toContain("A branch that does not exist exits 1 (HTTP 422)"); + expect(kind).toContain("A branch that does not exist exits 1 (HTTP 422)"); + expect(deploy).toContain( + "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`.", ); @@ -1263,7 +1415,9 @@ describe("boardGuide: a github_check observation and an observation's notes", () /Source: `https:\/\/example\.com\/shop\/item-1`\.\n\n- mind: 404 is right while it is sold out\n- mind: a\\u202eb\n\n$/, ); const users = guide.slice(guide.indexOf("#### `users`"), guide.indexOf("#### `deploy`")); - expect(users).toMatch(/as the operator asked`\.\n\n- mind: ask on Mondays\n\n$/); + expect(users).toMatch( + /Only the operator can make this one: ask\n\n- mind: ask on Mondays\n\n$/, + ); const deploy = guide.slice(guide.indexOf("#### `deploy`"), guide.indexOf("### 4.")); expect(deploy).not.toContain("mind:"); expect([...guide].some((c) => c.codePointAt(0) === 0x202e)).toBe(false); @@ -1294,7 +1448,7 @@ describe("boardGuide: what a board declares, as code and escaped as the template "How many pull requests of `o/_x_` are open:", "How the latest run of the workflow `_w_.yml` on `_b_` of `o/_x_` ended:", "The version `https://example.com/_x_` shows:", - "A GET of `https://example.com/_y_`, following", + "A GET of `https://example.com/_y_`:", "Source: `npm view @scope/a_x_ version`.", "Source: `gh release view --repo o/_x_`.", "Source: `gh issue list --repo o/_x_ --state open`.", diff --git a/packages/core/src/board/guide.ts b/packages/core/src/board/guide.ts index aeb2505..6b3a745 100644 --- a/packages/core/src/board/guide.ts +++ b/packages/core/src/board/guide.ts @@ -1,9 +1,9 @@ import { createHash } from "node:crypto"; -import { displayPath } from "../lib/display-path.js"; import { BOARD_HTTP_CAPTURE_FLAGS, BOARD_HTTP_COUNT_FLAGS, BOARD_HTTP_MAX_REDIRECTS, + BOARD_OBSERVE_KINDS, BOARD_STAGE_IDS, type BoardDeclaration, type BoardObserve, @@ -12,6 +12,7 @@ import { import { daysBetween, todayIn } from "./effort.js"; import { BOARD_INIT_SAVE } from "./init.js"; import type { BoardPreviousRecords } from "./previous.js"; +import { escapeHidden, HIDDEN, shownText as shown } from "./shown-text.js"; /** * The steps an agent follows to judge a board and record it, filled in with @@ -73,30 +74,12 @@ export function shellWord(text: string): string { return `'${text.replaceAll("'", `'\\''`)}'`; } -// What a terminal would act on, or hides: the C1 controls, the Arabic letter -// mark, the zero-width and direction marks, the line and paragraph -// separators, the bidirectional controls, the word joiner and the invisible -// operators, and the byte order mark. -const HIDDEN = /[\u007f-\u009f\u061c\u200b-\u200f\u2028-\u202e\u2060-\u2069\ufeff]/g; - -const escapeHidden = (c: string) => `\\u${(c.codePointAt(0) ?? 0).toString(16).padStart(4, "0")}`; - // JSON with what a terminal would act on, or hides, escaped as well: // JSON.stringify leaves those as they are. function jsonText(value: unknown, indent?: number): string { return JSON.stringify(value, null, indent).replace(HIDDEN, escapeHidden); } -// A surrogate not in a pair, which JSON.stringify writes as an escape. -const LONE_SURROGATE = /[\ud800-\udbff](?![\udc00-\udfff])|(?, "observed_at": , "source": }`. `observed_at` is when you made it, or tried to, with its offset, such as `date -u +%Y-%m-%dT%H:%M:%SZ` prints. When it cannot be made (a command failed, nothing to read, no answer), write `"value": null` and an `"error"` saying why, and still the `observed_at` of when you tried: record refuses one left empty. The board then shows it as not confirmed, with the previous record\'s value. Never carry an old value forward as today\'s. Keep the keys: they join each value to the previous record\'s.', + 'Read only: these commands write nothing and change nothing. Make each one, and write it under its key in `observed` as `{ "value": , "observed_at": , "source": }`; the command of an `http` observation prints this whole entry. `observed_at` is when you made it, or tried to, with its offset, such as `date -u +%Y-%m-%dT%H:%M:%SZ` prints. When it cannot be made (a command failed, nothing to read, no answer), write `"value": null` and an `"error"` saying why, and still the `observed_at` of when you tried: record refuses one left empty. The board then shows it as not confirmed, with the previous record\'s value. Never carry an old value forward as today\'s. Keep the keys: they join each value to the previous record\'s.', + "", + "Write a value as the command printed it, in the shape its kind gives it below: 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). A `manual` value is the operator's answer, as any JSON value.", + "", + "What each kind gives:", "", ); + const kinds = BOARD_OBSERVE_KINDS.filter((kind) => observe.some((o) => o.kind === kind)); + for (const kind of kinds) line(`- \`${kind}\`: ${kindText(kind)}`); + line(""); + if (kinds.includes("http")) { + line( + "Before the first `http` observation, write the script that reads what its GET got. It is basou's own, the same for every `http` observation, and its name changes with what it holds:", + ); + block(`${go} && printf '%s\\n' '${HTTP_SCRIPT}' > "$W/${HTTP_SCRIPT_FILE}"; echo "exit=$?"`); + } for (const o of observe) { line(`#### \`${o.key}\` (${o.kind})`, ""); switch (o.kind) { case "npm_version": line(`The version of ${codeSpan(o.package)} published on npm (its \`latest\` tag):`); block(`npm view ${shellWord(o.package)} version; echo "exit=$?"`); - line( - `Value: the last line of the output, as a string (such as "1.2.3"). Source: ${codeSpan(sourceOf(o))}.`, - "", - ); + line(`Source: ${codeSpan(sourceOf(o))}.`, ""); break; case "github_release": line(`The latest release of ${codeSpan(o.repo)} on GitHub:`); block( `gh release view --repo ${shellWord(o.repo)} --json tagName -q .tagName; echo "exit=$?"`, ); - line( - `Value: the tag, as a string. No release yet is a null with that as the error. Source: ${codeSpan(sourceOf(o))}.`, - "", - ); + line(`Source: ${codeSpan(sourceOf(o))}.`, ""); break; case "github_open_issues": case "github_open_prs": { @@ -518,10 +508,7 @@ function observeSteps( block( `gh ${what} list --repo ${shellWord(o.repo)} --state open --limit 1000 --json number -q length; echo "exit=$?"`, ); - line( - `Value: the number, as a number. At 1000 there may be more: say so in a footnote. Source: ${codeSpan(sourceOf(o))}.`, - "", - ); + line(`Source: ${codeSpan(sourceOf(o))}.`, ""); break; } case "github_ci": @@ -531,37 +518,33 @@ function observeSteps( block( `gh run list --repo ${shellWord(o.repo)} --workflow ${shellWord(o.workflow)} --branch ${shellWord(o.branch)} --limit 1 --json status,conclusion -q '.[0] | if .status == "completed" then .conclusion else .status end'; echo "exit=$?"`, ); - line( - `Value: what it prints, as a string (\`success\`, \`failure\`, or the status of a run not finished, such as \`in_progress\`). Nothing printed (no run) is a null with that as the error. Source: ${codeSpan(sourceOf(o))}.`, - "", - ); + line(`Source: ${codeSpan(sourceOf(o))}.`, ""); break; case "github_check": - githubCheckSteps(o, line, block); - break; - case "page_version": line( - `The version ${codeSpan(o.url)} shows: the first \`v..\` (with what follows a \`-\`) not right after a letter, digit, underscore or dot, once the page's generator meta tags are taken out, however they are quoted or cased (they name the site builder's version, not the product's).`, + `How the latest check run named ${codeSpan(o.check)} on the head commit of ${codeSpan(o.branch)} of ${codeSpan(o.repo)} ended:`, ); block( - `${go} && curl -fsS --max-time 20 ${shellWord(o.url)} > "$W/page-${o.key}.html"; echo "curl exit=$?"`, - `${go} && node -e 'let t="";process.stdin.on("data",(d)=>{t+=d}).on("end",()=>{const m=t.replace(/]*\\bname\\s*=\\s*["\\x27]?generator\\b[^>]*>/gi,"").match(/(? "$W/page-${o.key}.html"; echo "curl exit=$?"`, + `${go} && node -e 'let t="";process.stdin.on("data",(d)=>{t+=d}).on("end",()=>{const m=t.replace(/]*\\bname\\s*=\\s*["\\x27]?generator\\b[^>]*>/gi,"").match(/(? as the operator asked`.", - "", - ); + line(`Only the operator can make this one: ${shown(o.how)}`, ""); break; } for (const note of o.notes ?? []) line(`- mind: ${shown(note)}`); @@ -569,6 +552,30 @@ function observeSteps( } } +// What a kind of observation gives, and how its value is written: said once +// for all the observations of the kind. +function kindText(kind: BoardObserve["kind"]): string { + switch (kind) { + case "npm_version": + return 'the version a package\'s `latest` tag names on npm. Value: the last line of the output, as a string (such as "1.2.3"), with nothing around it.'; + case "github_release": + return "the tag of a repository's latest release on GitHub. Value: the tag, as a string. No release yet is a null with that as the error."; + case "github_open_issues": + case "github_open_prs": + return "how many are open. Value: the number, as a number. At 1000 there may be more: say so in a footnote."; + case "github_ci": + return "how the latest run of a workflow on a branch ended. Value: what it prints, as a string (`success`, `failure`, or the status of a run not finished, such as `in_progress`). Nothing printed (no run) is a null with that as the error."; + case "github_check": + return `how the newest check run of a name on the head commit of a branch ended: a check run of a GitHub App, such as a deploy, as well as a job of a workflow. Value: the first line it prints, as a string (\`success\`, \`failure\`, \`cancelled\`, \`skipped\`, or the status of a run not finished, such as \`queued\` or \`in_progress\`). A second line, \`more than ${CHECK_RUNS_PER_PAGE} runs: \`, says the newest run may be on a page not read: keep the first line as the value, and say so in a footnote. An empty line is no check run of that name on the head commit: a null, with the error saying so. A head commit that never started the check (a path filter, \`[skip ci]\` in its commit message, or the app's own rules) gives this too, even when what the check looks after is fine; a job a workflow skipped with \`if:\` is a check run, concluded \`skipped\`. A branch that does not exist exits 1 (HTTP 422): a null with that as the error.`; + 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.`; + 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`."; + } +} + // The stages and lanes as the declaration has them, and the principles of judging. function judging(d: BoardDeclaration, line: (...lines: string[]) => void): void { line( @@ -640,24 +647,50 @@ function sourceOf(o: BoardObserve): string { } } -// What reads the values of an http observation off its body, on stdin. The -// patterns and the words come after `--`, so none is read as an option of -// node's: the count_pattern, the capture_pattern (each '' when not declared) -// and the words. The body is decoded only once all of it is read, so a -// character is never cut where a chunk ends, and a leading BOM is taken -// off; nothing else of it is decoded. An empty match would count positions, -// not matches: it stops instead. It prints the values as JSON. -const HTTP_BODY_SCRIPT = [ - "const [count, capture, ...words] = process.argv.slice(1);", - 'let t = require("fs").readFileSync(0, "utf8");', - "if (t.charCodeAt(0) === 0xfeff) t = t.slice(1);", - "const v = {};", - "if (words.length > 0) v.words = words.map((w) => t.includes(w));", - `if (count !== "") { let n = 0; for (const m of t.matchAll(new RegExp(count, "${BOARD_HTTP_COUNT_FLAGS}"))) { if (m[0] === "") { console.error("count_pattern matched an empty string at " + m.index); process.exit(1); } n++; } v.count = n; }`, - `if (capture !== "") { const m = new RegExp(capture, "${BOARD_HTTP_CAPTURE_FLAGS}").exec(t); v.capture = m === null || m[1] === undefined ? null : m[1]; }`, - "console.log(JSON.stringify(v));", +// What reads what an http observation's GET got, from the files its block +// leaves in W (curl's exit code, its status line and the body), and prints +// the observation's entry for the record's input on one line: the value, or a +// null and why. The arguments come after `--`, so none is read as an option of +// 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. +// 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 +// does. Written with no ' and no backslash, so that it is one single-quoted +// word. +const HTTP_SCRIPT = [ + 'const fs = require("fs");', + 'const [at, url, count, capture, ...words] = process.argv.slice(process.argv.indexOf("--") + 1);', + "const hidden = (n) => (n >= 0x7f && n <= 0x9f) || n === 0x61c || (n >= 0x200b && n <= 0x200f) || (n >= 0x2028 && n <= 0x202e) || (n >= 0x2060 && n <= 0x2069) || n === 0xfeff;", + 'const shown = (s) => [...s].map((c) => (hidden(c.codePointAt(0)) ? String.fromCharCode(92) + "u" + c.codePointAt(0).toString(16).padStart(4, "0") : c)).join("");', + "const print = (value, error) => console.log(shown(JSON.stringify({ value, observed_at: new Date().toISOString(), source: url, ...(error === undefined ? {} : { error }) })));", + "const read = () => {", + 'const exit = fs.readFileSync(at + ".exit", "utf8").trim();', + `if (exit !== "0") return print(null, "curl exit " + exit + (exit === "47" ? ": more than ${BOARD_HTTP_MAX_REDIRECTS} redirects" : exit === "28" ? ": no answer in 20 seconds" : exit === "6" ? ": the name does not resolve" : exit === "7" ? ": no connection" : ""));`, + 'const m = /^([0-9]{3}) ([0-9]+) (.*)$/.exec(fs.readFileSync(at + ".meta", "utf8").trimEnd());', + '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] };", + "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); }', + "if (words.length > 0) v.words = ok ? words.map((w) => t.includes(w)) : null;", + `if (count !== "") { if (!ok) v.count = null; else { let n = 0; for (const x of t.matchAll(new RegExp(count, "${BOARD_HTTP_COUNT_FLAGS}"))) { if (x[0] === "") return print(null, "the count_pattern matched an empty string at " + x.index); n++; } v.count = n; } }`, + `if (capture !== "") { const x = ok ? new RegExp(capture, "${BOARD_HTTP_CAPTURE_FLAGS}").exec(t) : null; v.capture = x === null || x[1] === undefined ? null : x[1]; }`, + "return print(v);", + "};", + 'try { read(); } catch { print(null, "what curl left in W could not be read"); }', ].join(" "); +// The file the script is written to in W: named by what it holds, so that one +// a basou of another version wrote there is never run instead. +const HTTP_SCRIPT_FILE = `http-read-${createHash("sha256").update(HTTP_SCRIPT).digest("hex").slice(0, 12)}.cjs`; + // A text as Markdown code, as it is: fenced by more backquotes than it // holds in a row, and padded with a space when it starts or ends with a // backquote or a space, which the fence would otherwise eat. @@ -686,77 +719,29 @@ const CHECK_RUNS_PER_PAGE = 100; // page holds, a second line says so: the newest may be on a page not read. const CHECK_RUN_STATE = `(.check_runs | max_by(.id) | if .status == "completed" then .conclusion else .status end), (if .total_count > ${CHECK_RUNS_PER_PAGE} then "more than ${CHECK_RUNS_PER_PAGE} runs: \\(.total_count)" else empty end)`; -// How a github_check observation is made. -function githubCheckSteps( - o: Extract, - line: (...lines: string[]) => void, - block: (...lines: string[]) => void, -): void { - line( - `How the latest check run named ${codeSpan(o.check)} on the head commit of ${codeSpan(o.branch)} of ${codeSpan(o.repo)} ended (a check run of a GitHub App, such as a deploy, as well as a job of a workflow):`, - ); - block( - `gh api -X GET ${shellWord(githubCheckPath(o))} -f check_name=${shellWord(o.check)} -f per_page=${CHECK_RUNS_PER_PAGE} -q ${shellWord(CHECK_RUN_STATE)}; echo "exit=$?"`, - ); - line( - `Value: the first line it prints, as a string (\`success\`, \`failure\`, \`cancelled\`, \`skipped\`, or the status of a run not finished, such as \`queued\` or \`in_progress\`). A second line, \`more than ${CHECK_RUNS_PER_PAGE} runs: \`, says the newest run may be on a page not read: keep the first line as the value, and say so in a footnote. An empty line is no check run of that name on the head commit: a null, with the error saying that the head commit of ${codeSpan(o.branch)} has no check run named ${codeSpan(o.check)}. A head commit that never started the check (a path filter, \`[skip ci]\` in its commit message, or the app's own rules) gives this too, even when what the check looks after is fine; a job a workflow skipped with \`if:\` is a check run, concluded \`skipped\`. A branch that does not exist exits 1 (HTTP 422): a null with that as the error. Source: ${codeSpan(sourceOf(o))}.`, - "", - ); -} - -// How an http observation is made: a GET, then its body read when the -// observation declares what to read off it. +// How an http observation is made: a GET, its status line and exit code kept +// in W beside the body, and the script, written once, reading them. function httpSteps( o: Extract, go: string, line: (...lines: string[]) => void, block: (...lines: string[]) => void, ): void { - const body = `"$W/http-${o.key}.body"`; const reads = [ - ...(o.words === undefined - ? [] - : [ - `\`words\`: whether the body holds each word, as true or false in the order the command gives them (${o.words.map(codeSpan).join(", ")})`, - ]), - ...(o.count_pattern === undefined - ? [] - : [ - `\`count\`: how many times ${codeSpan(o.count_pattern)} matches the body (0 when it does not)`, - ]), - ...(o.capture_pattern === undefined - ? [] - : [ - `\`capture\`: what the first group of the first match of ${codeSpan(o.capture_pattern)} holds, as a string (null when nothing matches, or when the first group takes no part in the match)`, - ]), + ...(o.words === undefined ? [] : [`\`words\` ${o.words.map(codeSpan).join(", ")}`]), + ...(o.count_pattern === undefined ? [] : [`\`count\` of ${codeSpan(o.count_pattern)}`]), + ...(o.capture_pattern === undefined ? [] : [`\`capture\` of ${codeSpan(o.capture_pattern)}`]), ]; line( - `A GET of ${codeSpan(o.url)}, following up to ${BOARD_HTTP_MAX_REDIRECTS} redirects. It prints the status of the last response, how many redirects led to it and the URL it ended at (no \`-f\`: a 4xx or 5xx is printed and exits 0):`, + `A GET of ${codeSpan(o.url)}${reads.length > 0 ? `, reading off a 2xx body ${reads.join(", ")}` : ""}:`, ); + const at = `"$W/http-${o.key}`; + const args = [o.url, o.count_pattern ?? "", o.capture_pattern ?? "", ...(o.words ?? [])]; block( - `${go} && curl -sS -L --max-redirs ${BOARD_HTTP_MAX_REDIRECTS} --max-time 20 -o ${body} -w '%{http_code} %{num_redirects} %{url_effective}\\n' ${shellWord(o.url)}; echo "curl exit=$?"`, + `${go} && { rm -f ${at}.body"; curl -sS -L --max-redirs ${BOARD_HTTP_MAX_REDIRECTS} --max-time 20 -o ${at}.body" -w '%{http_code} %{num_redirects} %{url_effective}\\n' ${shellWord(o.url)} > ${at}.meta"; echo "$?" > ${at}.exit"; node "$W/${HTTP_SCRIPT_FILE}" -- ${at}" ${args.map(shellWord).join(" ")}; }; echo "exit=$?"`, ); - if (reads.length > 0) { - line( - "When the status is 2xx, read the body. The words are found as written, character for character, and HTML entities are not decoded; 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:", - ); - const args = [o.count_pattern ?? "", o.capture_pattern ?? "", ...(o.words ?? [])]; - block( - `${go} && node -e ${shellWord(HTTP_BODY_SCRIPT)} -- ${args.map(shellWord).join(" ")} < ${body}; echo "exit=$?"`, - ); - line("It prints, as JSON:", "", ...reads.map((r) => `- ${r}`), ""); - } - // What the body would give, as the value holds it when the status is not 2xx. - const nulls = (["words", "count", "capture"] as const) - .filter((k) => (k === "words" ? o.words : k === "count" ? o.count_pattern : o.capture_pattern)) - .map((k) => `"${k}": null`) - .join(", "); - const notRead = - nulls === "" - ? "" - : ` When the status is not 2xx, do not read the body: give \`${nulls}\`, each a null and not a list of nulls (an error page's text is not the page's).`; - line( - `Value: \`{ "status": , "redirects": , "final_url": ${reads.length > 0 ? ", " : ""} }\`, the numbers as numbers. A 4xx or 5xx is a value, not an observation not made: the status is what was observed.${notRead} When curl exits other than 0 (the name does not resolve, TLS fails, the time runs out, more than ${BOARD_HTTP_MAX_REDIRECTS} redirects: exit 47)${reads.length > 0 ? ", or reading the body exits 1," : ""} the observation is not made: a null with that as the error. Source: ${codeSpan(sourceOf(o))}.`, + line( + `Put the line it prints under ${codeSpan(o.key)} as it is. Source: ${codeSpan(sourceOf(o))}.`, "", ); } diff --git a/packages/core/src/board/observed-shape.ts b/packages/core/src/board/observed-shape.ts new file mode 100644 index 0000000..9a42e6d --- /dev/null +++ b/packages/core/src/board/observed-shape.ts @@ -0,0 +1,237 @@ +import { BOARD_HTTP_MAX_REDIRECTS, type BoardObserve } from "./declaration.js"; +import { HIDDEN_CHAR, shownText } from "./shown-text.js"; + +/** The lowest status an `http` observation's value may hold: a response's, never curl's `000`. */ +export const BOARD_HTTP_STATUS_MIN = 100; + +/** The highest status an `http` observation's value may hold. */ +export const BOARD_HTTP_STATUS_MAX = 599; + +// How many characters of a value's JSON a problem shows of what it received, +// an escape counted as one. +const RECEIVED_MAX = 60; + +/** A problem of an observation's value: where in the value, and what is wrong. */ +export type ObservedValueProblem = { path: (string | number)[]; message: string }; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +// What was received, as JSON on one line, cut short where an escape of it +// ends, with nothing in it that a terminal acts on or hides. The value is a +// JSON value that nests no deeper than a record takes: record checks that +// before it holds one against its kind. The check of the input for other +// workspaces' names runs before this one, so what is shown names none. +function received(value: unknown): string { + let text: string; + try { + text = value === undefined ? "nothing" : (JSON.stringify(value) ?? String(value)); + } catch { + return "received a value too deep to show"; + } + const parts = text.match(/\\u[0-9a-fA-F]{4}|\\.|[\s\S]/gu) ?? []; + const shown = parts + .slice(0, RECEIVED_MAX) + .map((part) => (part.length === 1 ? shownText(part) : part)); + return `received ${shown.join("")}${parts.length > RECEIVED_MAX ? "..." : ""}`; +} + +function isWholeNumber(value: unknown, min: number, max: number): value is number { + return typeof value === "number" && Number.isInteger(value) && value >= min && value <= max; +} + +// Characters a value copied from a command's output may carry by mistake: +// a control character (a line break, a tab, an escape), one a terminal hides +// (a zero-width or direction mark, a line separator, a byte order mark), and +// white space at either end. Each would read as a change from the previous +// record's value, and none is in what the guide's commands print. +function hasControl(text: string): boolean { + for (const c of text) { + const code = c.codePointAt(0) ?? 0; + if (code <= 0x1f || (code >= 0x7f && code <= 0x9f)) return true; + } + return false; +} + +const SPACE_AT_AN_END = /^\s|\s$/u; + +function isPlainText(value: unknown): value is string { + return ( + typeof value === "string" && + value !== "" && + !hasControl(value) && + !HIDDEN_CHAR.test(value) && + !SPACE_AT_AN_END.test(value) + ); +} + +function isAbsoluteHttpUrl(value: unknown): boolean { + if (typeof value !== "string" || hasControl(value) || /\s/u.test(value)) return false; + if (!/^https?:\/\//i.test(value)) return false; + try { + return new URL(value).hostname !== ""; + } catch { + return false; + } +} + +/** + * 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 + * first, then those left out, then status, redirects, final_url, words, count + * and capture. A null value (the observation was not made) is never held + * 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[] { + const kind = `(kind '${o.kind}')`; + switch (o.kind) { + case "manual": + return []; + case "npm_version": + case "github_release": + case "github_ci": + case "github_check": + case "page_version": + return isPlainText(value) + ? [] + : [ + { + path: [], + message: `must be a non-empty string with no space or line break at either end and no control or hidden character ${kind}, ${received(value)}`, + }, + ]; + case "github_open_issues": + case "github_open_prs": + return isWholeNumber(value, 0, Number.MAX_SAFE_INTEGER) + ? [] + : [ + { + path: [], + message: `must be a whole number from 0 to ${Number.MAX_SAFE_INTEGER} ${kind}, ${received(value)}`, + }, + ]; + case "http": + return httpValueProblems(o, value); + } +} + +// The keys of an http value, in the order the guide's command prints them: +// the three of every response, then those the observation declares a read of. +function httpKeys(o: Extract): string[] { + return [ + "status", + "redirects", + "final_url", + ...(o.words === undefined ? [] : ["words"]), + ...(o.count_pattern === undefined ? [] : ["count"]), + ...(o.capture_pattern === undefined ? [] : ["capture"]), + ]; +} + +function httpValueProblems( + o: Extract, + value: unknown, +): ObservedValueProblem[] { + const keys = httpKeys(o); + if (!isRecord(value)) { + return [ + { + path: [], + message: `must be an object of ${keys.join(", ")} (kind 'http'), ${received(value)}`, + }, + ]; + } + const problems: ObservedValueProblem[] = []; + for (const key of Object.keys(value)) { + if (!keys.includes(key)) { + problems.push({ + path: [key], + message: `is not a key of this observation's value (it takes ${keys.join(", ")})`, + }); + } + } + for (const key of keys) { + if (!Object.hasOwn(value, key)) { + problems.push({ + path: [key], + message: + key === "words" || key === "count" || key === "capture" + ? `must be given: the observation declares ${key === "words" ? "words" : `a ${key}_pattern`} (null when the status is not 2xx)` + : "must be given", + }); + } + } + const { status, redirects, final_url: finalUrl } = value; + const statusKnown = isWholeNumber(status, BOARD_HTTP_STATUS_MIN, BOARD_HTTP_STATUS_MAX); + if (Object.hasOwn(value, "status") && !statusKnown) { + problems.push({ + path: ["status"], + message: `must be a whole number from ${BOARD_HTTP_STATUS_MIN} to ${BOARD_HTTP_STATUS_MAX}, the status of a response, ${received(status)}`, + }); + } + if (Object.hasOwn(value, "redirects") && !isWholeNumber(redirects, 0, BOARD_HTTP_MAX_REDIRECTS)) { + problems.push({ + path: ["redirects"], + message: `must be a whole number from 0 to ${BOARD_HTTP_MAX_REDIRECTS}, ${received(redirects)}`, + }); + } + if (Object.hasOwn(value, "final_url") && !isAbsoluteHttpUrl(finalUrl)) { + problems.push({ + path: ["final_url"], + message: `must be an absolute http or https URL, ${received(finalUrl)}`, + }); + } + // The body is read only when the status is 2xx. With a status that is not + // a status, either shape is let through: the status is what is wrong. + const read = statusKnown ? status >= 200 && status <= 299 : undefined; + const notRead = (key: string) => ({ + path: [key], + message: `must be null: the status ${String(status)} is not 2xx, so the body is not read, ${received(value[key])}`, + }); + if (o.words !== undefined && Object.hasOwn(value, "words")) { + const words = value.words; + const asRead = + Array.isArray(words) && + words.length === o.words.length && + words.every((w) => typeof w === "boolean"); + if (read === false && words !== null) problems.push(notRead("words")); + else if (read === true && !asRead) { + problems.push({ + path: ["words"], + message: `must be a list of ${o.words.length} true or false, one for each word declared, in its order, ${received(words)}`, + }); + } else if (read === undefined && words !== null && !asRead) { + problems.push({ + path: ["words"], + message: `must be a list of ${o.words.length} true or false, or null when the status is not 2xx, ${received(words)}`, + }); + } + } + if (o.count_pattern !== undefined && Object.hasOwn(value, "count")) { + const count = value.count; + const asRead = isWholeNumber(count, 0, Number.MAX_SAFE_INTEGER); + if (read === false) { + if (count !== null) problems.push(notRead("count")); + } else if (!asRead && (read === true || count !== null)) { + const orNull = read === true ? "" : ", or null when the status is not 2xx"; + problems.push({ + path: ["count"], + message: `must be a whole number from 0 to ${Number.MAX_SAFE_INTEGER} (0 when nothing matches)${orNull}, ${received(count)}`, + }); + } + } + if (o.capture_pattern !== undefined && Object.hasOwn(value, "capture")) { + const capture = value.capture; + // What the group holds is kept as it is, spaces and all. + if (read === false && capture !== null) problems.push(notRead("capture")); + else if (capture !== null && typeof capture !== "string") { + problems.push({ + path: ["capture"], + message: `must be a string, or null when nothing matches, ${received(capture)}`, + }); + } + } + return problems; +} diff --git a/packages/core/src/board/record.test.ts b/packages/core/src/board/record.test.ts index 5648af6..3536f8d 100644 --- a/packages/core/src/board/record.test.ts +++ b/packages/core/src/board/record.test.ts @@ -454,6 +454,310 @@ describe("buildRecord: a board of version 3 that observes over http", () => { }); }); +describe("parseRecordInput: the value of each kind of observation", () => { + const url = "https://example.com/shop/item-1"; + const at = "2026-10-20T00:00:00Z"; + const kinds = (): BoardDeclaration => { + const result = parseBoardDeclaration( + stringify({ + board_version: 3, + title: "Test board", + stages: Object.fromEntries(STAGES.map((id) => [id, { meaning: `stage ${id}` }])), + lanes: [ + { id: "core", name: "Core" }, + { id: "docs", name: "Docs" }, + ], + observe: [ + { key: "npm", kind: "npm_version", package: "@scope/cli" }, + { key: "release", kind: "github_release", repo: "owner/name" }, + { key: "issues", kind: "github_open_issues", repo: "owner/name" }, + { key: "prs", kind: "github_open_prs", repo: "owner/name" }, + { key: "ci", kind: "github_ci", repo: "owner/name", workflow: "ci.yml" }, + { key: "check", kind: "github_check", repo: "owner/name", check: "build" }, + { key: "page", kind: "page_version", url }, + { + key: "site.shop", + kind: "http", + url, + words: ["in stock", "sold out"], + count_pattern: "", + capture_pattern: "Version ([0-9.]+)", + }, + { key: "bare", kind: "http", url }, + { key: "users", kind: "manual", how: "ask the operator" }, + ], + axis: { version: 1, review_due_days: 60 }, + effort: { start: "2026-04-28", time_zone: "UTC" }, + }), + { manifestRepoPaths: [] }, + ); + if (!result.ok) throw new Error(result.errors.join("\n")); + return result.declaration; + }; + const shopRead = { + status: 200, + redirects: 1, + final_url: `${url}/`, + words: [true, false], + count: 42, + capture: "2.3", + }; + const good: Record = { + npm: "1.2.3", + release: "v1.2.3", + issues: 0, + prs: 12, + ci: "success", + check: "in_progress", + page: "v1.2.3", + "site.shop": shopRead, + bare: { status: 404, redirects: 0, final_url: "http://example.com/x" }, + users: { anything: ["the operator", 1, null] }, + }; + const observedOf = (values: Record) => + Object.fromEntries( + Object.entries({ ...good, ...values }).map(([key, value]) => [ + key, + value === null + ? { value, observed_at: at, source: "s", error: "not made" } + : { value, observed_at: at, source: "s" }, + ]), + ); + const errorsOf = (values: Record) => { + const result = parseRecordInput(input({ observed: observedOf(values) }), kinds()); + return result.ok ? [] : result.errors; + }; + + it("takes a value of each kind in its shape, a null with its error of any kind, and any answer of a manual one", () => { + expect(errorsOf({})).toEqual([]); + expect(errorsOf(Object.fromEntries(Object.keys(good).map((key) => [key, null])))).toEqual([]); + // Not 2xx: the body is not read, and what it would give is null. + expect( + errorsOf({ + "site.shop": { + status: 503, + redirects: 2, + final_url: url, + words: null, + count: null, + capture: null, + }, + }), + ).toEqual([]); + // 2xx with nothing captured, a count of 0, a captured value with spaces kept. + expect(errorsOf({ "site.shop": { ...shopRead, count: 0, capture: null } })).toEqual([]); + expect(errorsOf({ "site.shop": { ...shopRead, capture: " 2.3 " } })).toEqual([]); + expect(errorsOf({ users: "anything" })).toEqual([]); + }); + + it("refuses a string of a kind that is a number, empty, or carries a line break or spaces at an end", () => { + expect( + errorsOf({ npm: "1.2.3\n", release: "", ci: " success", check: 1, page: "v1\u0007" }), + ).toEqual( + [ + `observed.release.value: must be a non-empty string with no space or line break at either end and no control or hidden character (kind 'github_release'), received ""`, + `observed.ci.value: must be a non-empty string with no space or line break at either end and no control or hidden character (kind 'github_ci'), received " success"`, + "observed.check.value: must be a non-empty string with no space or line break at either end and no control or hidden character (kind 'github_check'), received 1", + `observed.page.value: must be a non-empty string with no space or line break at either end and no control or hidden character (kind 'page_version'), received "v1\\u0007"`, + `observed.npm.value: must be a non-empty string with no space or line break at either end and no control or hidden character (kind 'npm_version'), received "1.2.3\\n"`, + ].sort((a, b) => order(a) - order(b)), + ); + }); + + it("refuses a count that is a string, negative, not whole, or past the integers a number holds exactly", () => { + for (const value of ["12", -1, 1.5, Number.MAX_SAFE_INTEGER + 1]) { + expect(errorsOf({ issues: value })).toEqual([ + `observed.issues.value: must be a whole number from 0 to ${Number.MAX_SAFE_INTEGER} (kind 'github_open_issues'), received ${JSON.stringify(value)}`, + ]); + } + }); + + it("refuses an http status that is not a response's, a string, too many redirects and a URL that is not absolute", () => { + 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"', + ]); + for (const status of [0, 99, 600, 200.5]) { + expect(errorsOf({ bare: { status, redirects: 0, final_url: url } })).toEqual([ + `observed.bare.value.status: must be a whole number from 100 to 599, the status of a response, received ${status}`, + ]); + } + for (const finalUrl of ["ftp://example.com/", "https://", `${url} x`, "example.com/x"]) { + expect(errorsOf({ bare: { status: 200, redirects: 0, final_url: finalUrl } })).toHaveLength( + 1, + ); + } + }); + + it("refuses an http value with a key it does not declare a read of, one left out, and one unknown", () => { + expect( + errorsOf({ + bare: { status: 200, redirects: 0, final_url: url, words: [true], body: "x" }, + "site.shop": { status: 200, redirects: 0, final_url: url }, + }), + ).toEqual( + [ + 'observed["site.shop"].value.words: must be given: the observation declares words (null when the status is not 2xx)', + 'observed["site.shop"].value.count: must be given: the observation declares a count_pattern (null when the status is not 2xx)', + 'observed["site.shop"].value.capture: must be given: the observation declares a capture_pattern (null when the status is not 2xx)', + "observed.bare.value.words: is not a key of this observation's value (it takes status, redirects, final_url)", + "observed.bare.value.body: is not a key of this observation's value (it takes status, redirects, final_url)", + ].sort((a, b) => order(a) - order(b)), + ); + expect(errorsOf({ bare: "200 0 https://example.com/" })).toEqual([ + "observed.bare.value: must be an object of status, redirects, final_url (kind 'http'), received \"200 0 https://example.com/\"", + ]); + }); + + it("refuses what the body gives in a shape of its own: a list of another length, a count as a string, and any read of a body not 2xx", () => { + expect( + errorsOf({ "site.shop": { ...shopRead, words: [true], count: "42", capture: 2.3 } }), + ).toEqual([ + 'observed["site.shop"].value.words: must be a list of 2 true or false, one for each word declared, in its order, received [true]', + `observed["site.shop"].value.count: must be a whole number from 0 to ${Number.MAX_SAFE_INTEGER} (0 when nothing matches), received "42"`, + 'observed["site.shop"].value.capture: must be a string, or null when nothing matches, received 2.3', + ]); + expect( + errorsOf({ + "site.shop": { ...shopRead, status: 404, words: [null, null], count: 0, capture: null }, + }), + ).toEqual([ + 'observed["site.shop"].value.words: must be null: the status 404 is not 2xx, so the body is not read, received [null,null]', + 'observed["site.shop"].value.count: must be null: the status 404 is not 2xx, so the body is not read, received 0', + ]); + // With a status that is no status, either shape of a read is let through. + expect( + errorsOf({ "site.shop": { ...shopRead, status: "200", words: null, count: null } }), + ).toEqual([ + 'observed["site.shop"].value.status: must be a whole number from 100 to 599, the status of a response, received "200"', + ]); + }); + + it("refuses a string of a kind with a control or a hidden character anywhere, or a space at its end, and keeps a capture as it is", () => { + const refusedWith = (value: string) => + errorsOf({ release: value }).map((e) => e.slice(0, e.indexOf(" (kind"))); + const refusal = + "observed.release.value: must be a non-empty string with no space or line break at either end and no control or hidden character"; + for (const value of [ + "v1\u001fx", + "v1\u007f", + "v1\u0085x", + "v1\u009f", + "1.2.3 ", + "v1\u200bx", + "v1\u202ex", + "v1\u2028x", + "\ufeffv1", + "v1\u2060x", + "v1\u061cx", + ]) { + expect(refusedWith(value), JSON.stringify(value)).toEqual([refusal]); + } + expect(errorsOf({ release: "v1 x" })).toEqual([]); + expect(errorsOf({ "site.shop": { ...shopRead, capture: "\u200b2.3\u2028" } })).toEqual([]); + }); + + it("refuses a read of a 2xx body that is null or of another shape, and any read of a body not 2xx, at the edges of 2xx", () => { + expect(errorsOf({ "site.shop": { ...shopRead, words: [true, "false"], count: null } })).toEqual( + [ + 'observed["site.shop"].value.words: must be a list of 2 true or false, one for each word declared, in its order, received [true,"false"]', + `observed["site.shop"].value.count: must be a whole number from 0 to ${Number.MAX_SAFE_INTEGER} (0 when nothing matches), received null`, + ], + ); + expect(errorsOf({ "site.shop": { ...shopRead, words: null } })).toEqual([ + 'observed["site.shop"].value.words: must be a list of 2 true or false, one for each word declared, in its order, received null', + ]); + for (const count of [-1, Number.MAX_SAFE_INTEGER + 1, 0.5]) { + expect(errorsOf({ "site.shop": { ...shopRead, count } })).toHaveLength(1); + } + expect(errorsOf({ "site.shop": { ...shopRead, count: Number.MAX_SAFE_INTEGER } })).toEqual([]); + // 299 is 2xx; 300 and 101 are not. + expect(errorsOf({ "site.shop": { ...shopRead, status: 299 } })).toEqual([]); + const notRead = { words: null, count: null, capture: null }; + for (const status of [300, 101, 599, 100]) { + expect(errorsOf({ "site.shop": { ...shopRead, ...notRead, status } })).toEqual([]); + expect( + errorsOf({ "site.shop": { ...shopRead, ...notRead, status, capture: "2.3" } }), + ).toEqual([ + `observed["site.shop"].value.capture: must be null: the status ${status} is not 2xx, so the body is not read, received "2.3"`, + ]); + } + expect(errorsOf({ "site.shop": { ...shopRead, status: 200, ...notRead } })).toHaveLength(2); + }); + + 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", + ]); + expect(errorsOf({ bare: { status: 200, redirects: 10, final_url: url } })).toEqual([]); + for (const finalUrl of [ + "https:example.com/x", + "https://example.com/\u0001", + "https://e\u0085.com/", + ]) { + expect( + errorsOf({ bare: { status: 200, redirects: 0, final_url: finalUrl } }), + JSON.stringify(finalUrl), + ).toHaveLength(1); + } + expect( + errorsOf({ bare: { status: 200, redirects: 0, final_url: "HTTPS://example.com/" } }), + ).toEqual([]); + }); + + it("refuses a value nested too deep as before, and does not hold it against its kind", () => { + let deep: unknown = 1; + for (let i = 0; i < 20000; i++) deep = [deep]; + const errors = errorsOf({ + release: deep, + bare: { status: deep, redirects: 0, final_url: url }, + }); + expect(errors).toEqual([ + expect.stringMatching(/^observed\.release\.value(\[0\])+: nests more than 100 deep$/), + expect.stringMatching(/^observed\.bare\.value\.status(\[0\])+: nests more than 100 deep$/), + ]); + }); + + it("shows what it received with what a terminal hides escaped, cut where an escape ends", () => { + expect(errorsOf({ release: "\ufeffv1" })).toEqual([ + `observed.release.value: must be a non-empty string with no space or line break at either end and no control or hidden character (kind 'github_release'), received "\\ufeffv1"`, + ]); + // Each \n of the JSON is one of its 60: the cut never splits one. + const [error] = errorsOf({ release: `${"x".repeat(58)}\n\n` }); + expect(error?.endsWith(`received "${"x".repeat(58)}\\n...`)).toBe(true); + const [astral] = errorsOf({ release: ` ${"\u{1F600}".repeat(80)}` }); + expect(astral?.endsWith(`received " ${"\u{1F600}".repeat(58)}...`)).toBe(true); + }); + + it("shows what it received cut short, with nothing that acts on a terminal", () => { + // Refused for the space it starts with; shown with the override escaped. + const [error] = errorsOf({ npm: ` \u202e${"x".repeat(100)}` }); + expect(error).toBe( + `observed.npm.value: must be a non-empty string with no space or line break at either end and no control or hidden character (kind 'npm_version'), received " \\u202e${"x".repeat(57)}...`, + ); + }); +}); + +// The place of an error among the observations, in the order the board declares them. +function order(error: string): number { + const keys = [ + "npm", + "release", + "issues", + "prs", + "ci", + "check", + "page", + '"site.shop"', + "bare", + "users", + ]; + return keys.findIndex( + (key) => error.startsWith(`observed.${key}.`) || error.startsWith(`observed[${key}]`), + ); +} + describe("buildRecord: a board of version 3 that observes a check run, with notes", () => { it("keeps the check run and each observation's notes as declared, and the value as given", () => { const observe = [ diff --git a/packages/core/src/board/record.ts b/packages/core/src/board/record.ts index 4aedf4b..aa5b03a 100644 --- a/packages/core/src/board/record.ts +++ b/packages/core/src/board/record.ts @@ -14,6 +14,7 @@ import { isCalendarDate, } from "./declaration.js"; import type { BoardMeasurement } from "./measure.js"; +import { observedValueProblems } from "./observed-shape.js"; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); @@ -200,9 +201,10 @@ export type BoardRecordInputResult = * part's shape is wrong. An unknown key is refused at every level but the * keys of `observed` and of `prose.lanes`: the latter are limited to the * declaration's lane ids, and the former, when the declaration names what to - * observe, are exactly those names. Every lane has a cell at every stage, - * once. The value is what JSON.parse read, so a key given twice has kept - * its last. + * observe, are exactly those names, and each value that is not null is held + * against the shape its kind gives it (a `manual` one is the operator's + * answer, and is not). Every lane has a cell at every stage, once. The value + * is what JSON.parse read, so a key given twice has kept its last. */ export function parseRecordInput( value: unknown, @@ -254,6 +256,21 @@ export function parseRecordInput( errors.push(`${formatPath(["observed", key])}: is not an observation the board declares`); } } + // A value of the wrong shape, a number given as a string or a line break + // copied with a version, would read as a change from the previous + // record's value: each is refused here, as the guide asks for it. Only a + // JSON value is held against its kind: one that is not is refused above, + // and one nested too deep is never walked again. + for (const o of declaration.observe) { + const entry = Object.hasOwn(observed, o.key) ? observed[o.key] : undefined; + if (!isRecord(entry) || entry.value === null || entry.value === undefined) continue; + if (!observedValue.safeParse(entry.value).success) continue; + for (const problem of observedValueProblems(o, entry.value)) { + errors.push( + `${formatPath(["observed", o.key, "value", ...problem.path])}: ${problem.message}`, + ); + } + } } const cells = cellsSchema.safeParse(given.cells); if (cells.success) { diff --git a/packages/core/src/board/shown-text.ts b/packages/core/src/board/shown-text.ts new file mode 100644 index 0000000..5af0bf1 --- /dev/null +++ b/packages/core/src/board/shown-text.ts @@ -0,0 +1,29 @@ +import { displayPath } from "../lib/display-path.js"; + +// What a terminal would act on, or hides: the C1 controls, the Arabic letter +// mark, the zero-width and direction marks, the line and paragraph +// separators, the bidirectional controls, the word joiner and the invisible +// operators, and the byte order mark. +const HIDDEN_CLASS = "[\\u007f-\\u009f\\u061c\\u200b-\\u200f\\u2028-\\u202e\\u2060-\\u2069\\ufeff]"; + +/** One character a terminal would act on, or hides (see {@link escapeHidden}). */ +export const HIDDEN_CHAR = new RegExp(HIDDEN_CLASS, "u"); + +/** Every character a terminal would act on, or hides. */ +export const HIDDEN = new RegExp(HIDDEN_CLASS, "gu"); + +/** A character as a `\uXXXX` escape. */ +export const escapeHidden = (c: string) => + `\\u${(c.codePointAt(0) ?? 0).toString(16).padStart(4, "0")}`; + +// A surrogate not in a pair, which JSON.stringify writes as an escape. +const LONE_SURROGATE = /[\ud800-\udbff](?![\udc00-\udfff])|(?